S3 SDK를 호출하는 코드에서 예외 처리를 안 하면, SDK 예외가 그대로 올라가서 모든 실패가 똑같은 500 응답이 된다. 업로드 실패인지, 다운로드 실패인지 구분이 안 되는 것이다. 이걸 커스텀 예외로 감싸서 구분하는 방법을 정리한다.


문제 - 예외 처리 없이 SDK 예외가 그대로 올라가는 경우

현재 S3BinaryContentStorage의 메서드들은 S3 SDK를 호출하는데, 예외 처리가 없다.

// 현재 put() — S3 실패 시 SDK 예외가 그대로 올라감
s3Client.putObject(...);

이러면 S3 SDK 예외가 그대로 올라가서 GlobalExceptionHandler의 맨 마지막 핸들러에 걸린다.

// GlobalExceptionHandler.java
@ExceptionHandler(Exception.class)  // ← 여기로 올라감
public ResponseEntity<ErrorResponse> handleGeneralException(Exception ex) {
    // 무조건 500 INTERNAL_SERVER_ERROR
}

결과적으로 업로드 실패든, 다운로드 실패든, 삭제 실패든 모두 동일한 500 응답을 받게 된다.


목표 - 업로드와 다운로드를 구분하기

원하는 구조는 이렇다.

S3Exception (부모)
├── S3UploadException   — 업로드 실패 시
└── S3DownloadException — 다운로드 실패 시

이렇게 하면 예외 종류에 따라 다른 응답과 로깅이 가능하다.


cause 전달이 필요한 이유

S3 SDK 예외를 잡고 커스텀 예외를 던질 때, 원본 예외를 버리면 안 된다.

원본 예외를 버리는 경우

} catch (Exception e) {
    throw new S3UploadException(fileName);  // e를 전달하지 않음
}

로그에 남는 스택트레이스는 이렇게 된다.

S3UploadException: 파일 업로드 실패
    at S3BinaryContentStorage.put(S3BinaryContentStorage.java:52)
    ...
// 끝. S3에서 실제로 무엇이 잘못되었는지 모름

원본 예외를 보존하는 경우

} catch (Exception e) {
    throw new S3UploadException(fileName, e);  // e를 cause로 전달
}

로그에 남는 스택트레이스는 이렇게 된다.

S3UploadException: 파일 업로드 실패
    at S3BinaryContentStorage.put(S3BinaryContentStorage.java:52)
    ...
Caused by: software.amazon.awssdk.services.s3.model.S3Exception: Access Denied
    at ...
// ← 실제 원인이 남음

Caused by가 남는 건 RuntimeException의 기본 기능이다. super(message, cause)로 원본 예외를 전달하면 자동으로 붙는다.

스택트레이스가 한 레이어 더 쌓이는 건 문제가 아니다

예외를 변환하는 것은 일반적인 패턴이다. 스택트레이스가 두 레이어가 되는 건 정상이고, Caused by를 통해 원본 정보가 유지되는 게 핵심이다.


현재 프로젝트 예외 구조와의 연결

현재 프로젝트는 DiscodeitException을 기반으로 한 예외 계층을 사용한다.

// DiscodeitException — 현재 구조
public class DiscodeitException extends RuntimeException {
    private final ErrorCode errorCode;
    protected final Map<String, Object> details;

    public DiscodeitException(Class<?> entity, ErrorCode errorCode, Map<String, Object> details) {
        // super()에 cause를 전달하지 않음 ← 포인트
        this.errorCode = errorCode;
        this.details = details;
    }
}

현재 DiscodeitExceptioncause를 받는 생성자가 없다.

S3 예외 계층을 만들 때 cause를 보존하려면, 부모 클래스에서 super(cause)를 호출해야 한다.

방법 A - DiscodeitException 계층 안에서 cause 전달

DiscodeitException 자체에 cause를 받는 생성자를 추가하면 모든 커스텀 예외에서 활용 가능하다.

// DiscodeitException에 추가
public DiscodeitException(Class<?> entity, ErrorCode errorCode, Map<String, Object> details, Throwable cause) {
    super(cause);  // ← RuntimeException에 cause 전달
    this.errorCode = errorCode;
    this.details = details;
    this.details.put("entity", entity.getSimpleName());
}

사용 예시는 다음과 같다.

public class S3UploadException extends S3Exception {
    public S3UploadException(String fileName, Throwable cause) {
        super(fileName, ErrorCode.INTERNAL_SERVER_ERROR, cause);  // cause를 위로 전달
    }
}

방법 B - S3Exception에서 직접 처리

DiscodeitException을 건드리지 않고, S3Exception 부모 클래스에서 직접 처리하는 방식이다.

public class S3Exception extends DiscodeitException {
    public S3Exception(String fileName, ErrorCode errorCode, Throwable cause) {
        super(BinaryContent.class, errorCode);  // DiscodeitException 초기화
        // 별도로 cause를 저장하거나, details에 원본 예외 메시지를 넣는 방식
        details.put("fileName", fileName);
        details.put("cause", cause.getMessage());
    }
}
방법 A방법 B
Caused by 남기는지남긴다안 남긴다 (details에 메시지만 남김)
DiscodeitException 변경 여부변경 필요변경 불필요
다른 예외에서 재사용 가능 여부가능S3만 가능

디버깅 가능성을 생각하면 방법 A가 낫다. 단, DiscodeitException을 수정하면 기존 예외 클래스들에도 영향을 줄 수 있으니 확인이 필요하다.


S3BinaryContentStorage 메서드별 적용

예외 클래스 계층은 별도로 만드는 부분이므로, 여기서는 각 메서드에서 어떤 예외를 잡고 던져야 하는지를 정리한다.

put() - 업로드

@Override
public UUID put(UUID uuid, byte[] content) {
    BinaryContent file = binaryContentRepository.findById(uuid)
            .orElseThrow(() -> new BinaryContentNotFoundException(uuid));
    try {
        s3Client.putObject(
                PutObjectRequest.builder()
                        .key(file.getFileName())
                        .bucket(bucket)
                        .contentType(file.getContentType())
                        .build(),
                RequestBody.fromBytes(content)
        );
    } catch (Exception e) {
        log.error("S3 업로드 실패 - 파일: {}, 오류: {}", file.getFileName(), e.getMessage());
        throw new S3UploadException(file.getFileName(), e);  // cause 전달
    }
    log.info("S3 파일 업로드 완료 : {}", file.getFileName());
    return uuid;
}

get() - 파일 조회

@Override
public InputStream get(String fileName) {
    try {
        return s3Client.getObject(GetObjectRequest.builder()
                .bucket(bucket)
                .key(fileName)
                .build()
        );
    } catch (Exception e) {
        log.error("S3 다운로드 실패 - 파일: {}, 오류: {}", fileName, e.getMessage());
        throw new S3DownloadException(fileName, e);  // cause 전달
    }
}

download() - presigned URL 생성

@Override
public ResponseEntity<?> download(BinaryContentDto dto) {
    try {
        return ResponseEntity.status(HttpStatus.FOUND)
                .header(HttpHeaders.LOCATION, generatePresignedUrl(dto.fileName()))
                .build();
    } catch (Exception e) {
        log.error("Presigned URL 생성 실패 - 파일: {}, 오류: {}", dto.fileName(), e.getMessage());
        throw new S3DownloadException(dto.fileName(), e);  // URL 생성도 다운로드 흐름
    }
}

delete() - 삭제

@Override
public void delete(String fileName) {
    try {
        s3Client.deleteObject(
                DeleteObjectRequest.builder()
                        .key(fileName)
                        .bucket(bucket)
                        .build()
        );
    } catch (Exception e) {
        log.error("S3 삭제 실패 - 파일: {}, 오류: {}", fileName, e.getMessage());
        // 삭제는 업로드도 다운로드도 아님 → S3Exception(부모) 사용 또는 별도 예외 추가
        throw new S3Exception(fileName, e);
    }
}

메서드별 예외 정리

메서드던져야 할 예외이유
put()S3UploadException업로드 실패
get()S3DownloadException파일 조회 실패
download()S3DownloadExceptionpresigned URL 생성도 다운로드 흐름의 일부
delete()S3Exception (부모) 또는 별도 예외업로드/다운로드 둘 다 아니다

결정해야 할 것

  1. DiscodeitExceptioncause 생성자를 추가할지 (방법 A vs B)
  2. delete() 실패 시 부모 S3Exception을 던질지, S3DeleteException을 별도로 만들지