본문으로 건너뛰기
김신건의 로그

[Spring MVC] Exception Handling: @ExceptionHandler, ControllerAdvice, ProblemDetail

· 수정 · 📖 약 1분 · 401자/단어 #spring #mvc #exception #problem-detail #error-handling
@ExceptionHandler, @ControllerAdvice, @RestControllerAdvice, ProblemDetail, RFC 7807, ResponseEntityExceptionHandler, ErrorAttributes, global exception handler

정의

Spring MVC 의 예외 처리 3 계층:

  1. @ExceptionHandler - 컨트롤러 내부
  2. @ControllerAdvice / @RestControllerAdvice - 전역
  3. ResponseEntityExceptionHandler 상속 - Spring 의 표준 예외 자동 처리

처리 우선순위

flowchart TB
    Ex[예외 발생]
    Ex --> C1["1. 컨트롤러 안 @ExceptionHandler"]
    C1 -->|매칭 안됨| C2["2. @ControllerAdvice 의 @ExceptionHandler"]
    C2 -->|매칭 안됨| C3["3. ResponseEntityExceptionHandler (Spring 표준)"]
    C3 -->|매칭 안됨| C4["4. ErrorController (BasicErrorController)"]
    C4 --> Default["기본 에러 응답 (HTML 또는 JSON)"]

컨트롤러 내부

@RestController
public class UserController {

    @GetMapping("/{id}")
    public User get(@PathVariable Long id) {
        return userService.findById(id);   // throws UserNotFoundException
    }

    @ExceptionHandler(UserNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleNotFound(UserNotFoundException ex) {
        return new ErrorResponse("USER_NOT_FOUND", ex.getMessage());
    }
}

컨트롤러 한 곳에서만 처리. 공통 예외@ControllerAdvice 로.

@ControllerAdvice / @RestControllerAdvice

@RestControllerAdvice  // = @ControllerAdvice + @ResponseBody
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ProblemDetail> handleNotFound(UserNotFoundException ex) {
        ProblemDetail body = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        body.setType(URI.create("https://example.com/probs/user-not-found"));
        body.setTitle("User Not Found");
        return ResponseEntity.status(404).body(body);
    }

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

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ProblemDetail> handleAll(Exception ex) {
        log.error("unhandled", ex);
        return ResponseEntity.internalServerError()
            .body(ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "내부 오류"));
    }
}

ControllerAdvice 범위

@RestControllerAdvice(basePackages = "com.example.api")
@RestControllerAdvice(assignableTypes = {UserController.class, OrderController.class})
@RestControllerAdvice(annotations = ApiV1.class)

큰 시스템에서 API v1 vs v2 별로 분리 가능.

ProblemDetail (RFC 7807, Spring 6+)

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://example.com/probs/validation",
  "title": "Validation failed",
  "status": 400,
  "detail": "Email is required",
  "instance": "/api/users",
  "errors": [
    { "field": "email", "code": "REQUIRED" }
  ]
}

2026 시점 REST API 의 에러 응답 표준. Spring 6 / Boot 3 부터 자동 활성.

@ExceptionHandler(BusinessException.class)
public ProblemDetail handle(BusinessException ex) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
    pd.setType(URI.create("https://example.com/probs/" + ex.getCode()));
    pd.setTitle(ex.getTitle());
    pd.setProperty("errorCode", ex.getCode());
    pd.setProperty("traceId", MDC.get("traceId"));
    return pd;
}

활성화 (Spring Boot)

spring:
  mvc:
    problemdetails:
      enabled: true   # Spring 6 default true

→ Spring 의 모든 표준 예외 가 자동 ProblemDetail 로 응답.

@Valid 실패 처리

@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "검증 실패");
    pd.setProperty("errors", ex.getFieldErrors().stream()
        .map(e -> Map.of(
            "field", e.getField(),
            "rejected", e.getRejectedValue(),
            "message", e.getDefaultMessage()
        ))
        .toList());
    return pd;
}

ResponseEntityExceptionHandler 상속

Spring 의 수십 가지 표준 예외 (HttpMessageNotReadableException, HttpRequestMethodNotSupportedException 등) 처리.

@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
        MethodArgumentNotValidException ex,
        HttpHeaders headers,
        HttpStatusCode status,
        WebRequest request
    ) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail((HttpStatus) status, "Validation failed");
        pd.setProperty("errors", ex.getFieldErrors());
        return ResponseEntity.status(status).body(pd);
    }

    // 비즈니스 예외
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ProblemDetail> handle(BusinessException ex) { ... }
}

옛 ErrorAttributes 와 비교

방식의미
ResponseEntity<...> 반환직접 응답 객체 만듦
ProblemDetailRFC 7807 표준 (Spring 6+)
ErrorAttributes/error endpoint 의 응답 데이터
ErrorController옛 BasicErrorController override

2026 시점 ProblemDetail 이 표준. 옛 ErrorAttributesHTML 에러 페이지 정도에만.

예외 분류 전략

flowchart TB
    Q{예외 종류}
    Q --> Biz["비즈니스 예외<br/>(BusinessException)"]
    Q --> Val["검증 예외<br/>(ValidationException)"]
    Q --> Sys["시스템 예외<br/>(DB, Network)"]
    Q --> Sec["보안 예외<br/>(Auth, AccessDenied)"]
    Biz --> B1["Status: 4xx<br/>(보통 422 또는 409)"]
    Val --> V1[400 + ProblemDetail with errors]
    Sys --> S1[500 + log + alert]
    Sec --> Se1[401 / 403]

보안 측면

@ExceptionHandler(Exception.class)
public ProblemDetail handleAll(Exception ex, WebRequest req) {
    String traceId = MDC.get("traceId");
    log.error("unhandled [{}]", traceId, ex);   // 내부 로그

    ProblemDetail pd = ProblemDetail.forStatusAndDetail(
        HttpStatus.INTERNAL_SERVER_ERROR,
        "내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요."   // ← 일반화
    );
    pd.setProperty("traceId", traceId);   // 사용자 → 운영자 추적 가능
    return pd;
}

CAUTION

예외 메시지 / stack trace 클라이언트 노출 금지. 내부 정보 (DB 구조, 파일 경로) 누출.

Logging + Tracing

@ExceptionHandler(BusinessException.class)
public ProblemDetail handle(BusinessException ex) {
    log.warn("biz: code={}, detail={}", ex.getCode(), ex.getMessage());
    return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
}
예외 종류로그 레벨
400 (검증, 입력)INFO (정상 흐름)
401, 403INFO 또는 WARN
404INFO
422 (비즈니스)WARN
500 (시스템)ERROR + alert

흔한 함정

WARNING

  1. @ExceptionHandler 가 더 구체적인 예외부터 = Spring 이 최적 매칭. 단 순서가 보장 안 되니 명확한 클래스만.
  2. Throwable 잡음 = Error (OOM 등) 까지 잡힘. Exception 까지만.
  3. @RestControllerAdvice + 뷰 컨트롤러 = JSON 응답. @ControllerAdvice + String view 분리.
  4. 예외 안에서 다시 예외 = handler 가 recursion. 주의.

관련 위키

이 글의 용어 (4개)
[API Design] REST API: 원칙, 자원 모델링, HATEOAS, 버전 관리api-design
정의 REST (Representational State Transfer) 는 Roy Fielding 의 2000년 박사논문에서 정립된 분산 시스템 아키텍처 스타일. HTTP 의…
[Spring MVC] Model + BindingResult: 뷰 모델과 바인딩 오류 처리spring
정의 Model = 컨트롤러 → 뷰 (Thymeleaf, JSP) 로 데이터 전달. BindingResult = 요청 파라미터 → 객체 바인딩 결과 + 오류. 둘 다 Spring…
[Spring] Bean Validation: @Valid, @NotBlank, customspring
정의 Spring은 Jakarta Bean Validation (JSR-380)을 표준으로 사용. , , 등 어노테이션으로 선언적 검증. Spring MVC가 로 자동 통합. 설…
[Spring] Spring MVC: @RestController, @RequestMappingspring
정의 Spring MVC는 Servlet 기반 동기 웹 프레임워크. 이 요청을 받아 핸들러(컨트롤러 메서드)에 라우팅. REST API와 전통 MVC(HTML) 모두 지원. We…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기