자바 백엔드 입문 33편 — @ExceptionHandler @ControllerAdvice

2026-05-16자바 백엔드 입문

자바 백엔드 입문 33편. Phase 4 Web MVC 마무리. @ExceptionHandler·@ControllerAdvice로 컨트롤러 예외를 일관된 JSON 오류 응답으로 변환하는 표준 패턴을 호텔 컴플레인 데스크 비유로 풀어쓴 학습 노트.

📚 자바 백엔드 입문 · 33편 — @ExceptionHandler @ControllerAdvice

이 글은 자바 백엔드 입문 시리즈 59편 중 33편이에요. Phase 4 Web MVC 마지막 글입니다. 29편까지 "요청을 받고 응답을 보내는" 흐름을 다 다뤘는데, 이번 33편은 그 흐름 중에 "예외가 터졌을 때 어떻게 처리하는가" 의 표준 패턴을 들여다봅니다.

예외 처리가 헷갈리는 이유

처음 컨트롤러를 짜다 보면 — orderService.findById(id) 호출에서 "주문 없음" 예외가 터졌을 때 어떻게 클라이언트에 알릴지가 안 잡혀요. 컨트롤러마다 try-catch 박으면 코드가 너무 더러워지고, 안 박으면 500 에러가 그대로 나가요.

이 글에서는 호텔 컴플레인 데스크 비유로 풀어요. 모든 객실에서 일어난 문제(예외)를 — 객실 매니저(컨트롤러)가 직접 처리하지 않고 — 별도 컴플레인 데스크(@ControllerAdvice) 한 곳에 모아 일관된 응답으로 처리하는 그림. 끝까지 따라오시면 한국 회사 백엔드의 예외 처리 표준 골격이 한 번에 들어와요.

예외를 그냥 두면 어떻게 되나

먼저 "예외 처리 안 했을 때" 의 디폴트 동작.

@GetMapping("/orders/{id}")
public Order get(@PathVariable Long id) {
    return orderService.findById(id);   // 없으면 NoSuchElementException
}

NoSuchElementException 이 터지면 — Spring Boot 기본 처리기가 받아서 500 Internal Server Error + Whitelabel 페이지를 응답해요. 클라이언트가 보기엔 "서버가 망가졌나?". 실제로는 "주문이 없다" 라는 정상 케이스인데도.

문제 — 모든 예외가 500으로 통합 되어 클라이언트가 원인을 못 알아요. "주문 없음" 은 404, "권한 없음" 은 403, "검증 실패" 는 400처럼 — 상황에 맞는 상태 코드 + 일관된 JSON 오류 응답이 필요해요.

@ExceptionHandler — 컨트롤러 안에서 예외 처리

가장 기본 패턴. @ExceptionHandler 메서드를 컨트롤러 안에 박아 "이 컨트롤러 안에서 이 예외 터지면 이 메서드로 처리해" 를 선언.

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping("/{id}")
    public Order get(@PathVariable Long id) {
        return orderService.findById(id)
                .orElseThrow(() -> new OrderNotFoundException(id));
    }

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException e) {
        ErrorResponse body = new ErrorResponse("ORDER_NOT_FOUND", e.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }
}

이렇게 박아두면 — OrderNotFoundException 이 발생할 때마다 자동으로 404 + JSON 오류 응답이 나가요. 컨트롤러 메서드 안에 try-catch를 박을 필요 없어요. Spring이 알아서 가로채 핸들러를 호출.

다만 한계 — 이 컨트롤러 안에서만 동작해요. 다른 컨트롤러에서 같은 예외가 터지면 또 핸들러를 박아야 함. 회사 시스템에 컨트롤러 50개 있으면 50번 박는 셈.

@ControllerAdvice — 글로벌 예외 처리

해결책이 @ControllerAdvice. "모든 컨트롤러에 공통으로 적용되는 예외 처리 컴포넌트" 선언.

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

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException e) {
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse("ORDER_NOT_FOUND", e.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)   // @Valid 실패
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getAllErrors().stream()
                .map(err -> err.getDefaultMessage())
                .collect(Collectors.joining(", "));
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(new ErrorResponse("VALIDATION_FAILED", message));
    }

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccess(AccessDeniedException e) {
        return ResponseEntity
                .status(HttpStatus.FORBIDDEN)
                .body(new ErrorResponse("ACCESS_DENIED", "권한이 없습니다"));
    }

    @ExceptionHandler(Exception.class)                       // 모든 나머지
    public ResponseEntity<ErrorResponse> handleUnknown(Exception e) {
        log.error("Unhandled exception", e);
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new ErrorResponse("INTERNAL_ERROR", "서버 오류"));
    }
}

이 클래스 한 개로 모든 컨트롤러의 예외가 한 곳에서 처리돼요. 새 컨트롤러를 추가해도 이 글로벌 핸들러가 자동 적용. 컴플레인 데스크 비유 정확히 일치.

@RestControllerAdvice = @ControllerAdvice + @ResponseBody. JSON 응답 반환에 표준.

예외 클래스 직접 만들기

비즈니스 예외는 별도 클래스로 정의하는 게 표준이에요.

// 1. 비즈니스 예외 정의
public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(Long id) {
        super("주문을 찾을 수 없습니다: " + id);
    }
}

// 2. 서비스에서 throw
@Service
public class OrderService {
    public Order findById(Long id) {
        return orderRepository.findById(id)
                .orElseThrow(() -> new OrderNotFoundException(id));
    }
}

// 3. 글로벌 핸들러에서 처리 (위 GlobalExceptionHandler)

RuntimeException 상속이 표준. Checked Exception(Exception 직접 상속)은 매번 throws 선언이 필요해서 코드가 더러워지고, Spring 트랜잭션 롤백 기본 동작도 RuntimeException 기반이라.

@ResponseStatus로 간단 처리

예외 클래스 자체에 @ResponseStatus 한 줄 박으면 — 따로 핸들러 없이도 상태 코드 자동 매핑.

@ResponseStatus(HttpStatus.NOT_FOUND)
public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(Long id) {
        super("주문을 찾을 수 없습니다: " + id);
    }
}

이 예외가 터지면 — Spring이 자동으로 404 응답. 다만 응답 본문 포맷을 일관되게 만들기 어려워서, 실무는 거의 @RestControllerAdvice + ResponseEntity<ErrorResponse> 패턴.

ErrorResponse DTO — 일관된 오류 응답 포맷

회사 시스템 표준은 "모든 오류 응답이 동일한 JSON 구조" 여야 클라이언트가 처리하기 쉬워요. 보통 다음 같은 DTO.

@Getter
@AllArgsConstructor
public class ErrorResponse {
    private String code;          // "ORDER_NOT_FOUND" 같은 식별자
    private String message;        // 사람이 읽는 메시지
    private LocalDateTime timestamp = LocalDateTime.now();
    private List<FieldError> fieldErrors;   // 검증 실패 시 필드별

    @Getter @AllArgsConstructor
    public static class FieldError {
        private String field;
        private String message;
    }
}

// JSON 응답:
// {
//   "code": "ORDER_NOT_FOUND",
//   "message": "주문을 찾을 수 없습니다: 123",
//   "timestamp": "2026-05-16T12:30:45",
//   "fieldErrors": null
// }

코드(ORDER_NOT_FOUND)는 "클라이언트가 분기 로직에 쓰는 식별자", 메시지는 "사람이 화면에 표시". 두 가지를 분리하는 게 표준.

Spring 6의 ProblemDetail — RFC 7807 표준

Spring Framework 6(Spring Boot 3) 부터 ProblemDetail 이라는 RFC 7807 표준 응답 형식을 지원해요. 직접 ErrorResponse를 만들지 않아도 표준 형식 사용 가능.

@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException e) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    pd.setTitle("Order Not Found");
    pd.setType(URI.create("/errors/order-not-found"));
    return pd;
}

// JSON 응답:
// {
//   "type": "/errors/order-not-found",
//   "title": "Order Not Found",
//   "status": 404,
//   "detail": "주문을 찾을 수 없습니다: 123"
// }

RFC 7807 표준이라 — "국제 표준 호환 API" 를 만들 때 유리. 다만 한국 회사 백엔드는 자체 ErrorResponse DTO를 쓰는 곳이 여전히 많아요.

예외 처리 우선순위 — 가장 구체적인 게 이김

@ControllerAdvice 에 여러 @ExceptionHandler 가 박혀 있을 때, Spring이 가장 구체적인 예외 타입과 매칭되는 핸들러를 골라요.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)   // 1순위 — 정확
    public ... handleSpecific(...) { ... }

    @ExceptionHandler(RuntimeException.class)         // 2순위 — 부모
    public ... handleRuntime(...) { ... }

    @ExceptionHandler(Exception.class)                // 3순위 — 최상위
    public ... handleAll(...) { ... }
}

OrderNotFoundException 터지면 — 1순위가 맞으면 1순위로 처리. 매칭되는 핸들러 없으면 부모 클래스 핸들러로 fallback. 마지막 안전망으로 Exception.class 박아 "예상 못 한 예외도 잡기" 가 표준 패턴.

⚠️ 절대 빠뜨리면 안 되는 것 — 로깅

5XX 서버 오류 핸들러(Exception.class)에서는 반드시 로그를 남기세요. 클라이언트엔 일반화된 메시지만 보내고, 실제 스택 트레이스는 서버 로그에 남겨야 디버깅 가능. log.error("Unhandled", e) 한 줄이 시스템 트러블슈팅의 생명선.

한국 회사 표준 패턴 — 그림으로 정리

지금까지 다룬 모든 게 합쳐진 표준 골격.

// 1. 비즈니스 예외
public class OrderNotFoundException extends RuntimeException { ... }

// 2. 오류 응답 DTO
public class ErrorResponse { ... }

// 3. 서비스
@Service
public class OrderService {
    public Order findById(Long id) {
        return repo.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
    }
}

// 4. 컨트롤러 — try-catch 없음, 깨끗
@RestController
public class OrderController {
    @GetMapping("/{id}")
    public Order get(@PathVariable Long id) {
        return orderService.findById(id);
    }
}

// 5. 글로벌 예외 핸들러
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(...) { ... }
    // ... 다른 예외들
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnknown(Exception e) {
        log.error("Unhandled", e);
        return ResponseEntity.status(500).body(...);
    }
}

5단계 패턴. 컨트롤러에서 예외 처리 코드 없음 이 핵심. 글로벌 핸들러가 다 처리.

한 줄 정리 — @ExceptionHandler 메서드 + @RestControllerAdvice 글로벌 핸들러로 모든 예외를 한 곳에서 일관된 JSON 응답으로 변환. 컨트롤러는 깨끗하게.

시험 직전 한 번 더 — 예외 처리 입문자가 매번 헷갈리는 것

  • 예외 처리 안 하면 = 500 Internal Server Error + Whitelabel 페이지 기본
  • @ExceptionHandler = 한 컨트롤러 안 예외 처리
  • @ControllerAdvice = 모든 컨트롤러에 공통 적용되는 예외 처리
  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody (JSON 반환)
  • 한국 회사 표준 = @RestControllerAdvice 패턴
  • 비즈니스 예외는 RuntimeException 상속 표준
  • Checked Exception(Exception 직접 상속) 비권장
  • @ResponseStatus = 예외 클래스에 상태 코드 매핑
  • ResponseEntity<ErrorResponse> 반환이 가장 유연
  • ErrorResponse DTO = code + message + timestamp + fieldErrors
  • code (식별자) ≠ message (사람 메시지) — 분리
  • Spring Boot 3 / Spring Framework 6 = ProblemDetail (RFC 7807) 지원
  • 예외 처리 우선순위 = 가장 구체적인 예외 타입이 먼저
  • 마지막 안전망으로 Exception.class 핸들러 필수
  • 5XX 핸들러에서는 반드시 로그 남기기 (log.error("...", e))
  • 클라이언트엔 일반 메시지, 서버 로그엔 스택 트레이스
  • @Valid 실패 = MethodArgumentNotValidException (전용 핸들러)
  • Spring Security AccessDeniedException 도 따로 처리
  • 글로벌 핸들러가 있어도 컨트롤러별 @ExceptionHandler 가 우선
  • 컨트롤러에 try-catch 박지 X — 예외는 throw, 핸들러가 처리

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!