CS/Spring

Spring MVC 예외 처리 — @ExceptionHandler / @ControllerAdvice / ProblemDetail

dding-shark 2026. 4. 13. 20:01
728x90

Spring MVC 예외 처리 — @ExceptionHandler / @ControllerAdvice / ProblemDetail


들어가며

컨트롤러에 throw new IllegalArgumentException("잘못된 요청") 한 줄을 박아두고 클라이언트가 어떤 응답을 받는지 지켜보자. 운이 좋으면 JSON 바디로 깔끔한 4xx가 돌아오고, 운이 나쁘면 Tomcat의 흰 배경 에러 페이지가 HTML로 돌아온다. 같은 코드인데 응답이 두 가지로 갈리는 이유는 예외가 누구의 손을 거쳤느냐에 있다. 스프링의 DispatcherServlet을 거쳤으면 HandlerExceptionResolver 체인이 잡고, 그 밖에서 던져졌으면 서블릿 컨테이너가 ERROR dispatch로 넘긴다. 이 경계를 모르면 @ControllerAdvice는 잡고 싶은 예외를 자꾸 놓친다.

스프링은 예외 처리를 위한 세 개의 레이어를 갖고 있다. 첫째는 @ExceptionHandler로 컨트롤러 지역 핸들러, 둘째는 @ControllerAdvice로 전역 핸들러, 셋째는 Spring 6의 ProblemDetail / ErrorResponseException으로 RFC 9457 표준 응답이다. 이 셋은 같은 스택 위에 얹혀 있고, 매칭 우선순위와 적용 범위가 엇갈린다. 특히 실무에서 거의 매주 밟는 함정이 넷 있다.

  • @ControllerAdvice를 붙였는데 예외가 안 잡힌다 — Filter나 Interceptor preHandle 밖에서 던졌기 때문이다
  • 같은 예외에 로컬 @ExceptionHandler와 글로벌 advice가 둘 다 있다 — 항상 로컬이 이긴다
  • @ResponseStatus(reason = "...")를 달았더니 JSON 바디 대신 Tomcat 에러 페이지가 뜬다reasonsendError를 유발한다
  • Spring 6부터는 @ExceptionHandler 없이도 ErrorResponseException만으로 RFC 9457 응답이 나간다 — 직접 advice 메서드를 쓸 필요가 없다

이 글은 왜 → 체인 → 매칭 → advice 경계 → ProblemDetail → 경계 바깥 → 실무 순으로, Spring Framework 6.x / Spring Boot 3.x / Java 17+ 기준으로 정리한다.


목차


1) 세 가지 레이어: 왜 이렇게 나뉘었나

스프링의 예외 처리는 처음부터 한 덩어리로 설계되지 않았다. 가장 오래된 기본층은 서블릿 컨테이너의 ERROR dispatch다. 컨트롤러 밖에서 예외가 던져지거나 response.sendError(...)가 호출되면, 컨테이너가 web.xml<error-page> 또는 /error 경로로 재진입한다. 스프링 MVC는 이 위에 HandlerExceptionResolver라는 자신만의 레이어를 얹어 컨트롤러 안에서 던진 예외를 핸들러에 전달하기 전에 가로채도록 만들었다.

그 위에 사용자가 코드를 짤 수 있는 두 개의 슬롯이 얹혔다. 컨트롤러 지역의 @ExceptionHandler, 그리고 전역의 @ControllerAdvice다. Spring 6부터는 응답 바디의 표준 포맷까지 고민하지 않도록 RFC 9457 기반의 ProblemDetail을 추가했고, ErrorResponseException이라는 전용 예외 타입을 내놓았다. 레이어가 이렇게 쌓인 이유를 한 줄로 요약하면 "같은 예외라도 던진 위치에 따라 잡을 수 있는 지점이 다르고, 잡은 뒤 응답 포맷까지 표준화할 필요가 있었다"이다.

1-1) 레이어 책임 매트릭스

레이어 적용 범위 예외 소스 응답 형태
@ExceptionHandler (로컬) 선언된 컨트롤러 해당 컨트롤러 내부 자유 (ResponseEntity 등)
@ControllerAdvice (글로벌) selector로 고른 컨트롤러 DispatcherServlet이 호출한 핸들러 자유
ResponseStatusException 어디서 던지든 핸들러 실행 중 ProblemDetail 자동 (6.x)
ErrorResponseException 어디서 던지든 핸들러 실행 중 RFC 9457 ProblemDetail
서블릿 ERROR dispatch DispatcherServlet 바깥 포함 Filter, async 등 /error 재진입
BasicErrorController 위 전부 못 잡은 최후 Boot가 등록한 /error JSON or HTML whitelabel

경계가 흐릿해 보이지만 핵심은 하나다. 컨트롤러 안에서 던져진 예외만 @ExceptionHandler가 본다. 나머지는 다음 레이어로 떨어진다.


2) HandlerExceptionResolver 체인 순서

DispatcherServlet은 컨트롤러 메서드에서 예외가 올라오면 등록된 HandlerExceptionResolver 빈들을 @Order 순서로 돌며 resolveException(...)을 호출한다. 첫 번째로 null이 아닌 ModelAndView를 반환한 resolver가 이긴다. 기본적으로 세 개가 이 순서로 꽂힌다.

  1. ExceptionHandlerExceptionResolver@ExceptionHandler@ControllerAdvice를 실행
  2. ResponseStatusExceptionResolver@ResponseStatus가 붙은 예외 또는 ResponseStatusException을 처리
  3. DefaultHandlerExceptionResolver — 스프링이 내장한 표준 예외(NoHandlerFoundException, HttpRequestMethodNotSupportedException 등)를 매핑

앞쪽이 처리하면 뒤쪽은 돌지 않는다. 여기서 **"@ExceptionHandler를 달면 무조건 @ResponseStatus를 이긴다"**는 규칙이 나온다. 같은 예외 타입에 둘 다 걸려 있어도 체인 순서상 1번이 먼저 잡기 때문이다.

18_spring-mvc-exception-handling-01

이 다이어그램에는 이 글에서 다룰 거의 모든 경계가 들어 있다. 체인의 어느 지점에서 응답이 결정되는지가 "왜 내 advice가 안 먹히지"의 답이다.

2-1) 커스텀 resolver는 어디에 꽂히는가

직접 HandlerExceptionResolver 빈을 등록하면 스프링은 기본 3개 앞쪽@Order 값에 따라 삽입한다. Ordered.HIGHEST_PRECEDENCE로 두면 맨 앞, 그 외에는 중간 어딘가다. 커스텀 resolver가 먼저 잡고 null이 아닌 값을 반환하면 나머지 체인은 건너뛴다. 이런 패턴은 거의 쓸 일이 없고, 실무에서는 advice로 충분하다.


3) @ExceptionHandler 매칭 규칙: 로컬 > 글로벌, root > cause

@ExceptionHandler의 매칭은 두 축을 동시에 따진다. 하나는 위치(로컬 컨트롤러 vs 글로벌 advice), 다른 하나는 타입 거리(예외 계층에서 가장 가까운 선언)다. 이 두 축이 어떻게 결합하는지가 가장 혼란스러운 지점이다.

3-1) 위치 축: 로컬이 글로벌을 항상 이긴다

컨트롤러 클래스 안에 선언된 @ExceptionHandler는 같은 컨트롤러에서 던진 예외에 한해 어떤 @ControllerAdvice보다 먼저 시도된다. advice의 @Order가 0이든 HIGHEST_PRECEDENCE이든 무관하다. 여기서 틀리기 쉽다. 로컬 핸들러를 하나 둔 채로 "전역 응답 포맷이 바뀌지 않는다"고 리포트하는 버그의 거의 전부가 이 규칙이다.

@RestController
public class OrderController {

    @GetMapping("/orders/{id}")
    public OrderResponse get(@PathVariable Long id) { ... }

    // 이 컨트롤러 안의 IllegalArgumentException은 글로벌 advice가 아닌 여기서 먹는다
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<?> handle(IllegalArgumentException e) {
        return ResponseEntity.badRequest().body(Map.of("msg", e.getMessage()));
    }
}

3-2) 타입 축: root를 먼저, 그 다음 cause

ExceptionHandlerExceptionResolver는 후보 핸들러를 고를 때 다음 순서로 ExceptionHandlerMethodResolver에 질의한다.

  1. root exception 자체에 대한 최적 매칭
  2. 못 찾으면 cause 체인을 따라 올라가며 재질의

스프링은 ExceptionDepthComparator로 후보를 정렬해 타입 거리가 가장 짧은 핸들러를 고른다. root exception에서 못 찾으면 cause 체인을 따라 올라가며 같은 절차를 반복한다(Spring 5.3부터 cause 탐색 깊이 제한이 완화되었다).

3-3) 피하기 / 선호

피하기:

// RuntimeException 하나로 다 잡는다. 의도 파악이 어렵고, cause 매칭도 건너뛴다.
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<?> any(RuntimeException e) {
    return ResponseEntity.internalServerError().body(e.getMessage());
}

선호:

@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ProblemDetail> notFound(OrderNotFoundException e) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    pd.setType(URI.create("https://errors.example.com/order-not-found"));
    return ResponseEntity.of(Optional.of(pd));
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ProblemDetail> badArg(IllegalArgumentException e) {
    return ResponseEntity.badRequest().body(
        ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage())
    );
}

구체 타입 우선, 응답 포맷은 ProblemDetail로 통일. 이 두 원칙이 리뷰에서 반복해서 등장한다.


4) @ControllerAdvice selector 3종과 @Order

글로벌 advice는 @ControllerAdvice 어노테이션이 달린 빈이다. selector를 지정하지 않으면 모든 컨트롤러에 적용된다. selector는 세 가지로, 여러 개를 동시에 쓰면 OR 논리로 결합한다.

selector 적용 대상
basePackages 패키지 하위의 모든 컨트롤러
assignableTypes 특정 클래스의 하위 타입 컨트롤러
annotations 특정 어노테이션이 붙은 컨트롤러 (예: 커스텀 @AdminApi)
@RestControllerAdvice(basePackages = "com.example.api.v2")
public class V2ApiAdvice { ... }

@RestControllerAdvice(annotations = AdminApi.class)
public class AdminApiAdvice { ... }

4-1) @Order로 advice 간 우선순위를 잡는다

advice 여러 개가 같은 예외에 반응할 수 있으면 @Order가 결정한다. 숫자가 작을수록 먼저. 적용되지 않는 advice로 계속 내려가다가 한 군데에서 매칭이 성사되면 거기서 멈춘다. 항상 지켜지는 규칙: 로컬 @ExceptionHandler > 모든 advice. 순서 경쟁은 advice 사이에서만 일어난다.

4-2) request/session 스코프 advice는 @Order를 무시한다

여기서 틀리기 쉽다. @Scope("request")@Scope("session")으로 선언된 advice는 @Order가 적용되지 않는다. 스프링 내부 ControllerAdviceBean 정렬이 싱글톤 빈 기준으로만 정리되기 때문이다. 스코프 advice는 등록 순서 그대로 훑힌다. 예외 처리용 advice는 거의 항상 싱글톤으로 두는 편이 안전하다. 요청별 상태가 필요하면 HttpServletRequest를 파라미터로 주입받아 쓰면 된다.


5) @RestControllerAdvice와 응답 바디 계약

@ControllerAdvice는 리턴값을 뷰 이름으로 취급한다. REST API를 짠다면 @RestControllerAdvice를 쓰자. 이건 @ControllerAdvice + @ResponseBody 합본이고, 핸들러 메서드가 반환한 객체는 HttpMessageConverter를 거쳐 본문에 직렬화된다.

@RestControllerAdvice
@RequiredArgsConstructor
public class ApiExceptionAdvice {

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ProblemDetail> onNotFound(OrderNotFoundException e) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
        pd.setProperty("orderId", e.getOrderId());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ProblemDetail> onUnhandled(Exception e) {
        // 최후 안전망: 500 하나로 수렴
        return ResponseEntity.internalServerError().body(
            ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "internal error")
        );
    }
}

반환 타입은 ResponseEntity<T>, ProblemDetail, 혹은 임의 DTO 모두 허용된다. ProblemDetail을 반환하면 Content-Type이 application/problem+json으로 세팅된다. 이 자동 설정은 Spring 6의 기본 계약이다.

5-1) advice 메서드 시그니처 자유도

advice 메서드는 컨트롤러 핸들러 수준의 파라미터 자유도를 갖는다. 던져진 예외, HttpServletRequest, Locale, HandlerMethod 등을 파라미터로 바로 받을 수 있다.

@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ProblemDetail> onDenied(
    AccessDeniedException e,
    HttpServletRequest req,
    Locale locale
) {
    log.warn("denied path={} user={}", req.getRequestURI(), req.getUserPrincipal());
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, "access denied");
    pd.setInstance(URI.create(req.getRequestURI()));
    return ResponseEntity.status(HttpStatus.FORBIDDEN).body(pd);
}

HandlerMethod를 받으면 "어느 컨트롤러의 어느 메서드에서 터졌는지"를 찍어둘 수 있다. 운영 로그용으로 유용하다.


6) ResponseStatusException vs @ResponseStatus

비즈니스 예외에 HTTP 상태를 부여하는 방법이 두 가지 있다. 직접 타입을 만들어 @ResponseStatus를 붙이거나, 인라인으로 ResponseStatusException을 던지는 방식이다.

@ResponseStatus(HttpStatus.NOT_FOUND)
public class OrderNotFoundException extends RuntimeException { ... }

// 또는

throw new ResponseStatusException(HttpStatus.NOT_FOUND, "order not found");

양쪽 다 2번 체인의 ResponseStatusExceptionResolver가 처리한다. 차이는 재사용성(타입은 도메인 공용, 인라인은 즉흥적)과 바디 제어이다. 생성자로 reason/cause를 넘기고, Spring 6부터는 getBody()가 반환하는 ProblemDetailsetType/setDetail/setProperty를 호출해 응답 바디를 세밀히 제어한다.

6-1) @ResponseStatus(reason = "...")는 Tomcat 에러 페이지를 연다

@ResponseStatus(value = ..., reason = "something")에서 reason을 지정하면 스프링은 내부적으로 response.sendError(status, reason)을 호출한다. 이건 서블릿 컨테이너의 ERROR dispatch를 발동시킨다. 결과적으로 JSON 바디가 나갈 자리에 Tomcat whitelabel HTML이 나간다. REST API에서 reason은 쓰지 말 것. 대신 ResponseStatusExceptiondetail을 쓰거나 @ExceptionHandler에서 ProblemDetail을 직접 만들어 반환하자.

// 피하기: sendError를 타고 whitelabel이 나갈 위험
@ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "order not found")
public class OrderNotFoundException extends RuntimeException {}

// 선호: value만 지정하고 메시지는 예외 메시지 또는 ProblemDetail detail에 담는다
@ResponseStatus(HttpStatus.NOT_FOUND)
public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(Long id) { super("order " + id + " not found"); }
}

6-2) Spring 6에서 ResponseStatusException은 ErrorResponse다

Spring 6의 ResponseStatusExceptionErrorResponse 인터페이스를 구현한다. 이 인터페이스는 getBody() → ProblemDetail을 제공해서, 별도 advice 없이도 **기본 응답이 application/problem+json**이 된다. 버전 차이를 놓치면 "왜 advice도 없는데 ProblemDetail이 나오지?"라는 혼란에 빠진다. 검증 실패 예외도 같은 resolver 체인 위에서 처리된다.


7) MethodArgumentNotValidException: 검증 실패의 표준 흐름

@Valid가 붙은 @RequestBody가 검증 실패하면 MethodArgumentNotValidException이 올라온다. DefaultHandlerExceptionResolver가 기본 400으로 매핑해 advice 없이도 상태 코드는 나간다. ResponseEntityExceptionHandler를 상속하면 바디가 ProblemDetail로 채워진다. 하지만 바디가 비어 있거나 기본 포맷이 마음에 안 드는 경우가 대부분이라 실무에서는 커스터마이즈한다.

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> onValidation(MethodArgumentNotValidException e) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "validation failed");
    List<Map<String, String>> errors = e.getBindingResult().getFieldErrors().stream()
        .map(fe -> Map.of("field", fe.getField(), "message", String.valueOf(fe.getDefaultMessage())))
        .toList();
    pd.setProperty("errors", errors);
    return ResponseEntity.badRequest().body(pd);
}

ProblemDetailsetProperty는 임의 키-값을 확장 필드로 붙일 수 있는 공식 API다. errors 배열을 관례로 두면 클라이언트 쪽 핸들링이 단순해진다.

7-1) 비슷한 검증 예외들

예외 언제
MethodArgumentNotValidException @Valid + @RequestBody 실패
ConstraintViolationException @Validated 클래스의 파라미터 검증 실패
BindException @ModelAttribute 또는 폼 바인딩 실패
HttpMessageNotReadableException JSON 파싱 실패
MissingServletRequestParameterException 필수 쿼리 파라미터 누락

이들 전부를 advice에서 잡아 ProblemDetail로 모으는 패턴이 Spring 6 기준으로 가장 깔끔하다.


8) Spring 6 ProblemDetail과 RFC 9457

ProblemDetailRFC 9457 "Problem Details for HTTP APIs"의 구현체다. 핵심 필드는 다섯 개다.

필드 의미
type 문제 유형 URI (클라이언트가 이 URI로 문서를 찾을 수 있다)
title 사람이 읽을 짧은 요약
status HTTP 상태 코드
detail 이 특정 요청에 대한 설명
instance 이 특정 발생 사례의 URI (보통 요청 경로)

임의 확장 필드는 properties 맵에 담긴다. setProperty("errors", [...]) 같은 식이다.

{
  "type": "https://errors.example.com/order-not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "order 42 not found",
  "instance": "/api/v2/orders/42",
  "orderId": 42
}

기본 Content-Type은 application/problem+json이다. 에러 응답과 정상 응답의 MIME이 다르다는 점이 클라이언트 로직에서 유용하다. 정상/에러 분기를 Content-Type으로 한 줄에 끊을 수 있다.

8-1) Boot 3 자동 응답 전환

application.properties에서 spring.mvc.problemdetails.enabled=true를 켜면 스프링이 기본 처리하는 모든 MVC 예외의 응답이 ProblemDetail로 자동 변환된다. Boot 3에서는 기본값이 false라 꺼져 있으니 새 프로젝트에서 RFC 9457을 기본 포맷으로 쓰려면 명시적으로 켜야 한다.


9) ResponseEntityExceptionHandler 마이그레이션

Spring 6의 ResponseEntityExceptionHandler는 MVC 표준 예외 전부에 대한 advice 메서드를 이미 구현해둔 추상 클래스다. 상속받기만 하면 HttpRequestMethodNotSupportedException, HttpMessageNotReadableException, MethodArgumentNotValidException 등이 전부 ProblemDetail 응답으로 통일된다.

@RestControllerAdvice
public class ApiExceptionAdvice extends ResponseEntityExceptionHandler {

    // 도메인 예외만 추가로 정의하면 나머지는 부모가 처리
    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ProblemDetail> onNotFound(OrderNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
            ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage())
        );
    }

    // 부모 훅을 오버라이드해서 포맷만 손보기
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
        MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest req
    ) {
        ProblemDetail pd = (ProblemDetail) ex.getBody();
        pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> Map.of("field", fe.getField(), "message", fe.getDefaultMessage()))
            .toList());
        return ResponseEntity.status(status).headers(headers).body(pd);
    }
}

Spring 5의 ResponseEntityExceptionHandler를 상속받던 코드에서 6로 오면 시그니처 일부가 바뀌고, 기본 바디가 빈 문자열이 아닌 ProblemDetail로 변한다. 오버라이드만 하던 메서드의 반환값이 바뀌었는지 확인해야 한다. advice 메서드 자체를 더 줄이려면 다음 절의 ErrorResponseException이 짝이 된다.


10) ErrorResponseException으로 advice 없이 끝내기

ErrorResponseException은 Spring 6에서 새로 추가된 "RFC 9457 응답이 박혀 있는 예외"다. 던지기만 하면 ResponseStatusExceptionResolver가 그 안의 ProblemDetail을 그대로 바디로 쓴다. 별도 @ExceptionHandler가 필요 없다.

public class OrderNotFoundException extends ErrorResponseException {
    public OrderNotFoundException(Long id) {
        super(HttpStatus.NOT_FOUND,
              ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "order " + id + " not found"),
              null);
        getBody().setType(URI.create("https://errors.example.com/order-not-found"));
        getBody().setProperty("orderId", id);
    }
}

컨트롤러에서는 그냥 throw new OrderNotFoundException(id)만 하면 된다. 이게 advice 메서드 없이 RFC 9457 응답을 내는 가장 짧은 경로다. 도메인 예외가 이미 많고 advice가 반복적이라면, 공통 부모를 ErrorResponseException으로 바꾸는 마이그레이션이 코드량을 크게 줄인다.

10-1) 언제 advice를 유지해야 하는가

ErrorResponseException으로 모든 걸 처리할 수는 없다. 다음 경우는 여전히 advice가 필요하다.

  • 예외를 내가 정의하지 않은 외부 라이브러리가 던지는 경우 (JpaSystemException, RedisConnectionFailureException 등)
  • 같은 예외를 엔드포인트 그룹에 따라 다른 포맷으로 응답해야 하는 경우 (admin vs public)
  • 로그 기록, 메트릭 송출 같은 부수 효과를 예외 처리 시점에 엮어야 하는 경우

advice와 ErrorResponseException은 배타적이지 않다. 도메인 예외는 ErrorResponseException으로 단순화하고, 외부 예외와 공통 훅만 advice가 담당하는 분업이 현실적이다.


11) Filter 예외와 ERROR dispatch 경계

여기가 이 글의 가장 자주 등장하는 함정이다. @ControllerAdviceDispatcherServlet이 호출한 핸들러에서 던져진 예외만 본다. 서블릿 필터에서 던진 예외는 DispatcherServlet에 도달하기 전에 컨테이너로 튕겨 나간다. 컨테이너는 이걸 받아 /error 경로로 ERROR dispatch를 건다. 이 ERROR dispatch는 새 요청처럼 컨트롤러 매핑을 탄다. Boot라면 BasicErrorController가 이 경로를 잡는다.

// 이 예외는 @ControllerAdvice에 안 걸린다
public class AuthFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) {
        String token = req.getHeader("Authorization");
        if (token == null) {
            throw new AuthFailedException("missing token"); // advice 밖
        }
        chain.doFilter(req, res);
    }
}

11-1) Filter 예외를 잡는 방법

몇 가지 패턴이 있다.

  1. Filter 안에서 직접 응답을 쓴다. res.sendError(...) 또는 res.setStatus(...) + JSON write. 가장 단순하고 확실하다.
  2. 예외를 request attribute로 옮기고 체인을 계속 돌린다. 뒤쪽에서 공통 처리.
  3. **Spring Security의 AuthenticationEntryPoint / AccessDeniedHandler**를 쓴다. 보안 필터 예외의 표준 훅이다.
  4. HandlerExceptionResolver를 주입받아 수동으로 호출한다. 고급 트릭이고, 문서화된 API는 아니다.

11-2) ERROR dispatch 재진입 시 @ControllerAdvice는 미적용

/error로 재진입한 요청의 핸들러는 BasicErrorController다. 여기서 다시 예외가 나면 @ControllerAdvice가 "이 컨트롤러"를 basePackages/annotations selector로 커버하고 있어야 적용된다. 일반 API advice는 BasicErrorController를 selector 범위에 안 두는 경우가 대부분이라 적용되지 않는다. 그래서 ERROR dispatch 경로는 "advice가 안 잡힌다"는 오해의 단골이다.


12) BasicErrorController 안전망

Boot는 기본적으로 /error 매핑에 BasicErrorController를 등록한다. 위 체인이 전부 실패하거나 Filter에서 예외가 던져졌을 때 이 컨트롤러가 최후의 응답을 만든다. Accept 헤더에 따라 HTML(whitelabel) 또는 JSON을 낸다.

JSON 응답의 키는 server.error.include-* 프로퍼티로 제어한다.

프로퍼티 기본값 효과
server.error.include-message never message 필드 포함 여부
server.error.include-binding-errors never 검증 실패 상세 포함 여부
server.error.include-stacktrace never trace 필드에 스택 포함
server.error.include-exception false exception 필드에 타입명 포함
server.error.whitelabel.enabled true HTML whitelabel 페이지 사용 여부

운영에서 include-stacktracealways로 두면 외부 공격자에게 내부 구조를 노출한다. never 또는 on_param 고정이 안전하다. 커스텀 /error 응답이 필요하면 ErrorController 구현 빈을 등록하면 ErrorMvcAutoConfiguration@ConditionalOnMissingBean(ErrorController.class) 조건으로 기본 등록을 건너뛴다.

12-1) 안전망을 없애면 안 되는 이유

"우리는 advice로 다 잡는다"고 BasicErrorController를 꺼버리는 시도를 본다. 이건 위험하다. Filter 예외, 비동기 컨텍스트 예외, 핸들러 매핑 실패(NoHandlerFoundException을 advice로 안 잡는 경우) 전부 이 안전망이 마지막으로 형태를 만들어준다. 꺼버리면 whitelabel HTML이 그대로 노출되거나, 컨테이너 기본 응답(Tomcat 흰 화면)이 튀어나간다.


13) 실무에서 이렇게 읽고 쓴다

  • advice는 한 곳에 모으고 @RestControllerAdvice + ResponseEntityExceptionHandler 상속. 표준 MVC 예외 처리를 재사용하면서 도메인 예외만 추가.
  • 응답 포맷은 ProblemDetail로 통일. spring.mvc.problemdetails.enabled=true로 자동 응답까지 표준화.
  • **도메인 예외 공통 부모를 ErrorResponseException**으로 두면 advice 없이도 RFC 9457 응답. advice는 외부 라이브러리 예외와 공통 훅 용도로만.
  • @ResponseStatus(reason=...)는 금지 패턴. sendError를 유발해 whitelabel로 빠진다.
  • Filter 예외는 advice가 못 잡는다. Security의 AuthenticationEntryPoint/AccessDeniedHandler, 혹은 Filter 안의 직접 응답으로 처리.
  • BasicErrorController는 끄지 말 것. 최후 안전망이 없으면 컨테이너 기본 HTML이 노출된다.
  • request/session 스코프 advice 금지. @Order가 안 먹는다. 요청 상태는 HttpServletRequest 파라미터 주입으로.
  • 로컬 @ExceptionHandler를 남발하지 말 것. 글로벌 응답 포맷을 깨는 가장 흔한 원인이다. 컨트롤러별로 다른 포맷이 정말 필요할 때만.
  • 운영에서 server.error.include-stacktracenever. 공격 표면을 줄인다.
  • 디버깅 시 순서: DispatcherServlet DEBUG 로그 → resolver 체인 추적 → advice selector 매칭 확인 → Filter 경로 의심 → BasicErrorController 로그 확인.

14) 한 줄 정리

예외 처리의 첫 질문은 "이 예외가 DispatcherServlet 안에서 던져졌는가"이고, 답이 예스여야만 @ExceptionHandler@ControllerAdvice가 의미를 갖는다. 매칭은 로컬이 글로벌을 이기고 타입 거리로 우열이 갈리며, Spring 6부터는 ProblemDetail/ErrorResponseException이 advice 코드를 상당 부분 치환한다. Filter 예외와 @ResponseStatus(reason=...)sendError 경로는 체인을 우회하므로, 별도의 훅을 두거나 아예 그 패턴을 쓰지 않는 쪽이 안전하다.


태그: spring-mvc, exception-handler, controller-advice, problem-detail, rfc-9457, response-status-exception, error-response-exception, basic-error-controller, filter-exception, spring-boot-3

728x90