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;
}
}
현재 DiscodeitException은 cause를 받는 생성자가 없다.
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() | S3DownloadException | presigned URL 생성도 다운로드 흐름의 일부 |
delete() | S3Exception (부모) 또는 별도 예외 | 업로드/다운로드 둘 다 아니다 |
결정해야 할 것
DiscodeitException에cause생성자를 추가할지 (방법 A vs B)delete()실패 시 부모S3Exception을 던질지,S3DeleteException을 별도로 만들지