이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • @ExceptionHandler는 Controller 내부와 Advice 내부에서 적용 범위가 어떻게 달라지는가?
  • 여러 예외 처리기가 있을 때 우선순위와 분리 기준을 어떻게 잡아야 하는가?
  • ProblemDetail, ResponseEntity, 커스텀 DTO 중 무엇을 선택해야 하는가?

개요

@ExceptionHandler는 특정 예외가 발생했을 때 실행할 메서드를 선언한다. Controller 안에 두면 그 Controller의 예외 정책이 되고, @ControllerAdvice 안에 두면 여러 Controller에 적용되는 공통 정책이 된다.

@RestControllerAdvice는 REST API에서 자주 쓰인다. @ControllerAdvice@ResponseBody 성격이 더해져 예외 처리 결과가 JSON 같은 HTTP body로 바로 내려간다.

Spring MVC의 @ExceptionHandler는 던져진 root exception뿐 아니라 wrapper exception 안의 cause와도 매칭될 수 있다. 여러 handler가 동시에 후보가 되면 구체적인 예외 타입, root/cause 관계, Advice 우선순위가 결과를 좌우한다.

적용 범위

Controller 내부 @ExceptionHandler는 특정 화면이나 특정 Controller의 맥락을 강하게 반영할 때 적합하다. 예를 들어 하나의 폼 화면에서만 필요한 복구 흐름이 있다면 Controller 가까이에 두는 편이 읽기 쉽다.

전역 Advice는 여러 Controller에서 반복되는 정책을 모은다. 검증 실패, 리소스 없음, 비즈니스 충돌, 인증/인가 관련 응답 형식처럼 애플리케이션 전체에서 일관되어야 하는 실패가 여기에 해당한다.

Advice는 패키지, 애너테이션, Controller 타입 등으로 적용 범위를 좁힐 수 있다. 관리자 API와 공개 API의 오류 형식이 다르다면 하나의 거대한 Advice보다 범위를 나눈 Advice가 더 명확하다.

같은 예외라도 클라이언트가 원하는 media type에 따라 JSON 오류 응답과 HTML 오류 페이지를 나눌 수 있다. @ExceptionHandler(produces = "...")를 사용하면 에러 처리 단계에서도 content negotiation이 작동한다.

코드 예제

public record ApiError(
        String code,
        String message,
        Map<String, Object> details
) {
}
 
@RestControllerAdvice
public class ApiExceptionAdvice {
 
    @ExceptionHandler(MemberNotFoundException.class)
    ResponseEntity<ApiError> handle(MemberNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(new ApiError(
                        "MEMBER_NOT_FOUND",
                        "회원을 찾을 수 없습니다.",
                        Map.of("memberId", ex.memberId())));
    }
 
    @ExceptionHandler(BusinessRuleViolationException.class)
    ResponseEntity<ApiError> handle(BusinessRuleViolationException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(new ApiError(ex.code(), ex.userMessage(), Map.of()));
    }
}

이 방식은 애플리케이션 전용 오류 계약을 직접 제어하기 쉽다. 클라이언트가 의존할 오류 코드도 명확해진다.

@ExceptionHandler(MemberNotFoundException.class)
ProblemDetail handleAsProblemDetail(MemberNotFoundException ex) {
    ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
    detail.setTitle("Member Not Found");
    detail.setDetail("회원을 찾을 수 없습니다.");
    detail.setProperty("memberId", ex.memberId());
    return detail;
}

ProblemDetail은 RFC 9457 계열의 문제 상세 응답 모델을 사용할 때 유용하다. 조직의 API 표준이 Problem Details에 맞춰져 있다면 좋은 선택이다. Spring MVC built-in 예외는 ErrorResponse 계약과도 연결되며, ResponseEntityExceptionHandler를 확장하면 기본 MVC 예외를 ProblemDetail 기반으로 일관되게 다루기 쉽다. 이미 사내 표준 오류 DTO가 있다면 커스텀 DTO가 더 단순할 수 있다.

우선순위와 분리

예외 처리는 구체적인 예외부터 처리하고, 넓은 예외는 마지막에 둔다. Exception.class를 너무 앞세우면 더 의미 있는 handler가 실행되지 않거나 모든 실패가 같은 응답으로 뭉개진다. 한 handler 안에서도 root/cause 매칭 차이가 있을 수 있으므로, 다중 Advice 구조에서는 @Order와 적용 범위를 명확히 둔다.

Advice가 많아질 때는 역할별로 나눈다. ValidationExceptionAdvice, BusinessExceptionAdvice, SystemExceptionAdvice처럼 분리하면 어떤 예외가 어디서 응답으로 변하는지 찾기 쉽다. 단, 같은 예외를 여러 Advice에서 동시에 처리하지 않게 순서와 범위를 명확히 해야 한다.

실전 팁

  • 예외 handler 메서드 이름은 handle만 반복하기보다 예외 의미가 드러나게 짓는다.
  • 클라이언트가 의존할 오류 코드는 enum이나 상수로 관리한다.
  • ProblemDetail은 표준 호환성이 필요할 때, 커스텀 DTO는 도메인 오류 코드를 선명하게 노출하고 싶을 때 선택한다.
  • built-in MVC 예외를 표준 ProblemDetail로 맞추고 싶다면 ResponseEntityExceptionHandler 확장이나 Boot의 Problem Details 설정을 함께 검토한다.
  • 넓은 Exception.class handler는 예상 못한 오류용 마지막 방어선으로 둔다.
  • Advice 테스트는 MockMvc로 실제 JSON 응답과 상태 코드를 확인한다.

위험 신호!

  • 모든 예외 handler가 HttpStatus.BAD_REQUEST를 반환한다.
  • Exception.class handler가 구체적인 비즈니스 예외보다 먼저 사실상 모든 실패를 잡는다.
  • 같은 예외가 여러 Advice에 중복 등록되어 어느 handler가 실행되는지 불분명하다.
  • root/cause 매칭과 Advice 순서를 테스트하지 않고 운영에서 우연히 선택되게 둔다.
  • 오류 코드가 예외 클래스 이름이나 메시지 문자열에서 즉석 생성된다.
  • API 오류 응답 형식이 화면 Controller와 REST Controller에서 뒤섞인다.

확인 질문

확인 질문

  • Controller 내부 @ExceptionHandler와 전역 Advice의 가장 큰 차이는 무엇인가?
    • 적용 범위다. Controller 내부 handler는 해당 Controller에 가깝고, Advice는 여러 Controller에 공통 적용된다.
  • ProblemDetail은 언제 선택하기 좋은가?
    • 표준 문제 상세 응답 형식을 API 계약으로 사용하고 싶을 때 좋다.
  • 넓은 예외 handler를 조심해야 하는 이유는 무엇인가?
    • 구체적인 예외 의미가 뭉개지고 모든 실패가 같은 응답으로 처리될 수 있기 때문이다.

참고 문서