처음에 예외를 만드는 방법
예외 처리를 처음 접하면 두 가지 방법을 자연스럽게 떠올린다.
방법 1 — 메시지를 직접 하드코딩
throw new RuntimeException("사용자를 찾을 수 없습니다.");
단순하다. 하지만 같은 예외가 여러 곳에서 발생하면 메시지가 제각각이 된다. HTTP 상태 코드도 던지는 쪽에서 결정해야 하니 일관성이 없어진다.
방법 2 — 예외 클래스를 개별로 생성
throw new UserNotFoundException();
throw new DuplicateEmailException();
throw new PasswordMismatchException();
일관성은 생긴다. 하지만 예외 종류가 늘어날수록 파일이 늘어난다. GlobalExceptionHandler에도 핸들러를 하나씩 등록해야 한다.
enum으로 예외 타입을 관리하는 구조
두 방법의 단점을 해결하는 구조가 있다. 예외 타입을 enum 상수로 정의하고, 하나의 예외 클래스가 타입을 주입받는 방식이다.
public enum UserExceptionType implements BaseExceptionType {
NOT_FOUND(400, HttpStatus.BAD_REQUEST, "user.not_found"),
DUPLICATE_EMAIL(409, HttpStatus.CONFLICT, "email.already.exists"),
PASSWORD_MISMATCH(400, HttpStatus.BAD_REQUEST, "user.password.mismatch"),
EMAIL_NOT_VERIFIED(400, HttpStatus.BAD_REQUEST, "email.verification.required"),
INVALID_REFRESH_TOKEN(401, HttpStatus.UNAUTHORIZED, "user.invalid_refresh_token");
private int errorCode;
private HttpStatus httpStatus;
private String errorMessage;
// ...
}
이 구조에서 예외를 던질 때는 다음과 같이 사용한다.
throw new UserException(UserExceptionType.NOT_FOUND);
throw new UserException(UserExceptionType.DUPLICATE_EMAIL);
이 구조의 장점
예외 종류가 늘어나도 클래스가 늘어나지 않는다
새로운 예외가 필요할 때 enum 상수 하나만 추가하면 된다.
// 구독 만료 예외 추가
SUBSCRIPTION_EXPIRED(400, HttpStatus.BAD_REQUEST, "user.subscription_expired");
파일을 새로 만들 필요도, GlobalExceptionHandler에 핸들러를 등록할 필요도 없다.
예외의 모든 정보가 한 곳에 모인다
NOT_FOUND(400, HttpStatus.BAD_REQUEST, "user.not_found")
// ↑ ↑ ↑
// 에러코드 HTTP 상태코드 다국어 메시지 키
에러 코드, HTTP 상태 코드, 메시지가 한 줄에 선언된다. 나중에 상태 코드를 바꾸거나 메시지를 수정할 때 한 곳만 보면 된다.
GlobalExceptionHandler가 단순해진다
@ExceptionHandler(BaseException.class)
public ResponseEntity handleBaseEx(BaseException exception, HttpServletRequest request) {
Locale locale = localeResolver.resolveLocale(request);
String message = messageSource.getMessage(
exception.getBaseExceptionType().getErrorMessage(), null, locale);
BaseResponse baseResponse = BaseResponse.builder()
.code(700)
.message(message)
.requestUrl(request.getRequestURI())
.build();
return ResponseEntity.status(exception.getBaseExceptionType().getHttpStatus()).body(baseResponse);
}
BaseException을 상속한 모든 도메인 예외가 이 핸들러 하나로 처리된다. UserNotFoundException, OrderNotFoundException을 각각 등록할 필요가 없다.
다국어 처리가 자연스럽게 연결된다
메시지 문자열을 코드에 직접 쓰지 않고 키만 들고 다닌다.
"user.not_found"
# messages_ko.properties
user.not_found=사용자를 찾을 수 없습니다.
# messages_en.properties
user.not_found=User not found.
언어가 추가되거나 메시지 문구가 바뀌어도 코드를 수정할 필요가 없다. properties 파일만 관리하면 된다.
새로운 예외를 추가할 때의 차이
기존 방식과 enum 방식의 실제 작업 단계를 비교해 보자.
| 단계 | 클래스 기반 (기존) | enum 기반 (현재) |
|---|---|---|
| 1 | SubscriptionExpiredException.java 생성 |
UserExceptionType에 상수 한 줄 추가 |
| 2 | GlobalExceptionHandler에 핸들러 추가 |
messages_ko.properties에 메시지 추가 |
| 3 | 메시지 문자열 결정 | — |
| 4 | HTTP 상태 코드 결정 | — |
클래스 기반은 4단계가 필요하고 파일이 하나 늘어난다. enum 기반은 2단계로 끝나고, 기존 코드를 건드리지 않는다.
이 구조가 전제하는 것
이 구조는 한 가지를 전제한다.
도메인 예외는 사용자에게 구체적인 피드백을 주기 위한 것이다.
UserExceptionType의 모든 상수는 사용자의 행동에 대한 응답이다.
NOT_FOUND → 존재하지 않는 사용자로 요청
DUPLICATE_EMAIL → 이미 사용 중인 이메일로 가입 시도
PASSWORD_MISMATCH → 잘못된 비밀번호 입력
사용자가 무엇을 잘못했는지, 또는 어떤 상태인지를 알려주는 것이 도메인 예외의 역할이다.
반대로 S3 업로드 실패, 메일 서버 오류 같은 인프라 예외는 이 구조로 관리하지 않는다. 사용자에게 인프라 장애의 원인을 알려줄 필요도 없고, 알려줘서도 안 되기 때문이다. 그것은 InfrastructureException이 담당한다.
정리
도메인 예외와 인프라 예외의 구분, 그리고 각각의 처리 전략을 한눈에 보면 아래와 같다.
도메인 예외 (BaseException + enum)
→ 사용자 행동에 대한 피드백
→ 종류마다 다른 HTTP 상태, 다른 메시지
→ GlobalExceptionHandler 하나로 처리
인프라 예외 (InfrastructureException)
→ 외부 시스템 장애
→ 원문은 로그에, 사용자에게는 범용 메시지
→ GlobalExceptionHandler 하나로 처리
새로운 예외를 추가할 때 "어디에 속하는가"를 먼저 묻는다. 사용자 행동에 대한 피드백이면 enum 상수 하나를 추가하면 되고, 외부 시스템 문제라면 InfrastructureException 하위 클래스를 만든다.
이 두 가지 판단 기준이 잡히면, 예외를 어디에 어떻게 추가할지 고민하는 시간이 사라진다.