처음에 예외를 만드는 방법

예외 처리를 처음 접하면 두 가지 방법을 자연스럽게 떠올린다.

방법 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 하위 클래스를 만든다.

이 두 가지 판단 기준이 잡히면, 예외를 어디에 어떻게 추가할지 고민하는 시간이 사라진다.