Spring MVC에서 @ExceptionHandler를 제대로 쓰는 방법: 예외 흐름부터 전역 처리까지
Spring MVC에서 예외 처리는 부가 기능이 아니라 API 설계의 일부입니다. 같은 실패 상황인데 어떤 엔드포인트는 400을 주고, 어떤 곳은 500을 주고, 어떤 응답은 메시지 형식조차 다르다면 클라이언트 코드도 복잡해지고 운영 중 원인 파악도 어려워집니다.
@ExceptionHandler는 이런 혼란을 줄이기 위한 핵심 도구입니다. 컨트롤러 실행 과정에서 발생한 예외를 잡아, 애플리케이션이 의도한 HTTP 상태 코드와 응답 본문으로 바꿔 주는 역할을 합니다. 단순히 예외를 숨기는 기능이 아니라, 예외를 API 계약에 맞는 응답으로 번역하는 장치라고 이해하면 훨씬 실무적입니다.
이 글에서는 @ExceptionHandler가 정확히 어디서 동작하는지, @ControllerAdvice와는 어떻게 역할을 나누는지, 그리고 실제로 어떤 응답 구조를 만들어야 유지보수가 쉬운지까지 한 번에 정리해보겠습니다.
왜 컨트롤러 예외 처리를 따로 설계해야 할까
예외는 없어지지 않습니다. 잘못된 요청 본문, 존재하지 않는 리소스, 현재 상태에서는 허용되지 않는 비즈니스 요청, 예상하지 못한 런타임 오류는 서비스가 살아 있는 한 계속 발생합니다. 중요한 것은 예외 발생 자체가 아니라, 그 예외가 어떤 HTTP 응답으로 변환되느냐입니다.
예외 처리를 설계하지 않으면 보통 다음 문제가 나타납니다.
- 사용자 입력 오류와 서버 내부 오류가 모두 500으로 보입니다.
- 프론트엔드가 엔드포인트마다 다른 실패 형식을 처리해야 합니다.
- 컨트롤러마다
try-catch가 늘어나서 비즈니스 로직이 흐려집니다. - 운영 로그에는 스택 트레이스만 남고, 클라이언트에게는 맥락 없는 오류 메시지만 전달됩니다.
실무에서는 예외를 “없애는 것”보다 “의미 있게 분류하는 것”이 더 중요합니다. 예를 들어 리소스 없음은 404, 요청 값 오류는 400, 상태 충돌은 409, 미처 고려하지 못한 장애는 500처럼 구분해야 합니다. 이 구분을 일관되게 구현하는 데 @ExceptionHandler가 매우 유용합니다.
@ExceptionHandler는 무엇을 하는가
@ExceptionHandler는 특정 예외 타입을 처리할 메서드를 지정하는 애너테이션입니다. 보통 두 위치에서 사용합니다.
- 특정 컨트롤러 내부
@ControllerAdvice또는@RestControllerAdvice클래스 내부
컨트롤러 내부에 두면 해당 컨트롤러에 가까운 예외 정책을 표현하기 좋고, advice에 두면 여러 컨트롤러에 공통으로 적용할 수 있습니다.
몇 가지 중요한 특성을 먼저 짚고 가면 좋습니다.
- 서비스 계층에서 던진 예외라도 컨트롤러 호출 흐름을 타고 올라오면 처리할 수 있습니다.
@ExceptionHandler는 MVC 예외 해석기 위에서 동작하므로, 필터나 Spring Security 앞단에서 발생한 예외는 같은 방식으로 처리되지 않을 수 있습니다.- 더 구체적인 예외 타입의 핸들러가 더 넓은 타입보다 우선합니다.
- JSON API라면
@RestControllerAdvice가 대체로 더 자연스럽습니다. Exception.class를 받는 광범위한 핸들러는 마지막 안전망으로 두는 편이 좋습니다.
즉, @ExceptionHandler는 예외를 잡는 기술이라기보다 예외를 해석해서 응답을 만드는 기술에 가깝습니다.
Spring MVC에서 예외가 처리되는 내부 흐름
@ExceptionHandler를 제대로 쓰려면 Spring MVC 내부 흐름을 알고 있어야 합니다. 요청이 들어오면 DispatcherServlet이 적절한 핸들러 메서드를 찾아 실행합니다. 이 과정에서 예외가 발생하면, DispatcherServlet은 곧바로 WAS로 예외를 던지지 않고 등록된 HandlerExceptionResolver들에게 처리를 위임합니다.
일반적으로 많이 보게 되는 순서는 다음과 같습니다.
ExceptionHandlerExceptionResolverResponseStatusExceptionResolverDefaultHandlerExceptionResolver
가장 먼저 보는 것은 ExceptionHandlerExceptionResolver입니다. 여기서 현재 컨트롤러의 @ExceptionHandler를 찾고, 없으면 @ControllerAdvice 계열 전역 핸들러를 조회합니다. 이 단계에서 처리되면 예외는 애플리케이션 내부에서 응답으로 바뀌고 더 이상 위로 전파되지 않습니다.
그다음 ResponseStatusExceptionResolver는 @ResponseStatus가 붙은 예외나 ResponseStatusException을 상태 코드 기반으로 처리합니다. 마지막으로 DefaultHandlerExceptionResolver는 HTTP 메서드 불일치, 파라미터 바인딩 오류 같은 Spring MVC 기본 예외를 표준 상태 코드에 매핑합니다.
여기서 가장 많이 헷갈리는 지점은 “모든 예외가 @ExceptionHandler로 잡히는가?”입니다. 답은 그렇지 않다는 것입니다. @ExceptionHandler는 컨트롤러 실행 흐름 안에서 특히 강력합니다. 하지만 다음 영역은 별도 전략이 필요할 수 있습니다.
- 서블릿 필터
- Spring Security 인증/인가 체인
- 컨트롤러 진입 전 단계의 저수준 예외
- 이미 응답이 커밋된 뒤 발생한 예외
이 경계를 이해하면, 왜 어떤 예외는 잘 잡히고 어떤 예외는 기대대로 동작하지 않는지 훨씬 빠르게 파악할 수 있습니다.
컨트롤러 단위 처리와 @RestControllerAdvice 전역 처리는 어떻게 나눌까
실전에서는 “어디에 두는가”보다 “무엇을 어디까지 공통화할 것인가”를 먼저 정하는 편이 좋습니다. 보통은 응답 형식을 먼저 결정하고, 그다음 예외의 범위를 나눕니다.
아래 예시는 공통 에러 응답과 도메인 예외, 그리고 컨트롤러 내부의 로컬 핸들러를 함께 보여줍니다. OrderNotFoundException처럼 주문 컨텍스트에 강하게 묶인 예외는 컨트롤러에 두고, 여러 API에서 공통으로 발생할 수 있는 예외는 전역 advice로 올리는 방식입니다.
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import java.time.LocalDateTime;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
public record ApiError(
String code,
String message,
String path,
LocalDateTime timestamp
) {}
public record CreateOrderRequest(
@NotBlank(message = "상품명은 필수입니다.")
String itemName,
@Min(value = 1, message = "수량은 1 이상이어야 합니다.")
int quantity
) {}
public record OrderResponse(Long id, String status) {}
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long id) {
super("주문을 찾을 수 없습니다. id=" + id);
}
}
public class InvalidOrderStateException extends RuntimeException {
public InvalidOrderStateException(String message) {
super(message);
}
}
@RestController
@RequestMapping("/orders")
public class OrderController {
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
if (id < 1) {
throw new InvalidOrderStateException("주문 ID는 1 이상이어야 합니다.");
}
if (id == 9999L) {
throw new OrderNotFoundException(id);
}
return new OrderResponse(id, "READY");
}
@PostMapping
public OrderResponse create(@Valid @RequestBody CreateOrderRequest request) {
return new OrderResponse(1001L, "CREATED");
}
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ApiError> handleOrderNotFound(
OrderNotFoundException ex,
HttpServletRequest request
) {
ApiError body = new ApiError(
"ORDER_NOT_FOUND",
ex.getMessage(),
request.getRequestURI(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
}
이 코드는 컨트롤러에 가까운 정책을 명확하게 드러낸다는 장점이 있습니다. 주문 조회 API에서 발생하는 “없음” 예외는 그 자리에서 404로 변환됩니다. 반면 검증 실패나 예기치 못한 일반 예외까지 이 안에 모두 넣으면 컨트롤러가 무거워지므로, 공통 정책은 아래처럼 전역 advice에 두는 편이 낫습니다.
import jakarta.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(basePackages = "com.example")
public class GlobalApiExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalApiExceptionHandler.class);
@ExceptionHandler(InvalidOrderStateException.class)
public ResponseEntity<ApiError> handleInvalidState(
InvalidOrderStateException ex,
HttpServletRequest request
) {
ApiError body = new ApiError(
"INVALID_ORDER_STATE",
ex.getMessage(),
request.getRequestURI(),
LocalDateTime.now()
);
return ResponseEntity.badRequest().body(body);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(
MethodArgumentNotValidException ex,
HttpServletRequest request
) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.findFirst()
.orElse("요청 값을 다시 확인해 주세요.");
ApiError body = new ApiError(
"VALIDATION_ERROR",
message,
request.getRequestURI(),
LocalDateTime.now()
);
return ResponseEntity.badRequest().body(body);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleUnexpected(
Exception ex,
HttpServletRequest request
) {
log.error("Unhandled exception", ex);
ApiError body = new ApiError(
"INTERNAL_SERVER_ERROR",
"일시적인 오류가 발생했습니다.",
request.getRequestURI(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
}
이 구성이 좋은 이유는 관심사가 분리되기 때문입니다. 특정 API 문맥에 가까운 예외는 로컬에서 처리하고, 여러 컨트롤러에 걸친 공통 정책은 전역에서 관리합니다. 모든 예외를 전역 하나에 몰아넣는 방식도 가능하지만, 시간이 지나면 도메인 의미가 흐려지고 조건 분기가 커지기 쉽습니다.
API 에러 응답은 어떤 형태로 설계하면 좋을까
에러 응답은 화려할 필요가 없습니다. 대신 규칙이 분명해야 합니다. 최소한 다음 정도는 유지하는 편이 실용적입니다.
code: 클라이언트가 분기 처리하기 위한 안정적인 식별자message: 사람이 읽을 수 있는 설명path: 어느 요청에서 발생했는지 확인할 경로timestamp: 문제 추적 시점 확인용 정보
이 구조의 장점은 프론트엔드와 백엔드가 같은 실패 언어를 쓰게 된다는 점입니다. 예를 들어 프론트엔드는 code=VALIDATION_ERROR면 입력 폼 강조를 하고, code=INTERNAL_SERVER_ERROR면 일반 에러 토스트를 띄우는 식으로 단순하게 대응할 수 있습니다.
Spring 6 이상에서는 ProblemDetail도 좋은 선택지입니다. 다만 이미 팀에서 공통 포맷을 운영 중이거나, 에러 코드를 별도로 강하게 관리하고 싶다면 DTO 방식이 더 명시적일 수 있습니다. 중요한 것은 어떤 방식을 쓰든 응답 구조를 엔드포인트마다 바꾸지 않는 것입니다.
또 하나 실무에서 중요한 원칙은 500 응답에 내부 구현 세부사항을 노출하지 않는 것입니다. 예외 메시지를 그대로 내려보내면 디버깅에는 편해 보이지만, 운영 환경에서는 오히려 불필요한 정보 노출이 됩니다. 자세한 내용은 서버 로그에 남기고, 외부 응답은 안전하고 일관되게 유지하는 편이 맞습니다.
curl로 바로 확인하는 테스트 예시
예외 처리는 코드를 쓰는 것보다 실제 응답을 확인하는 과정이 더 중요합니다. 아래 예시는 각각 다른 종류의 실패를 빠르게 검증하는 데 유용합니다.
curl -i http://localhost:8080/orders/9999
curl -i http://localhost:8080/orders/0
curl -i -X POST http://localhost:8080/orders \
-H 'Content-Type: application/json' \
-d '{"itemName":"","quantity":0}'
첫 번째 요청은 존재하지 않는 주문이므로 404와 함께 ORDER_NOT_FOUND를 기대할 수 있습니다. 두 번째는 비즈니스 규칙상 잘못된 상태이므로 400과 INVALID_ORDER_STATE가 나오는 것이 자연스럽습니다. 세 번째는 Bean Validation 실패이므로 MethodArgumentNotValidException이 발생하고, 전역 핸들러가 400과 VALIDATION_ERROR를 반환하게 됩니다.
예상 응답은 다음처럼 일정한 구조를 유지해야 합니다.
{
"code": "VALIDATION_ERROR",
"message": "itemName: 상품명은 필수입니다.",
"path": "/orders",
"timestamp": "2026-04-04T14:20:30"
}
이처럼 상태 코드는 상황에 따라 달라도, 응답 바디의 형식이 유지되면 클라이언트 입장에서 훨씬 다루기 쉬워집니다. 결국 좋은 예외 처리는 “실패했을 때도 예측 가능한 API”를 만드는 일입니다.
예외가 기대대로 잡히지 않을 때 확인할 디버깅 포인트
@ExceptionHandler를 붙였는데도 동작하지 않는다고 느껴질 때는, 애너테이션 자체를 의심하기보다 먼저 예외의 발생 위치를 확인해야 합니다. 실제 원인은 대체로 구조와 흐름에 있습니다.
로그를 조금만 보강해도 원인을 찾는 속도가 크게 올라갑니다. 아래 설정은 DispatcherServlet과 예외 해석기의 동작을 추적할 때 유용합니다.
logging:
level:
org.springframework.web.servlet.DispatcherServlet: DEBUG
org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver: DEBUG
이 상태에서 아래 항목을 차례로 점검하면 대부분의 문제를 정리할 수 있습니다.
- 예외가 정말 컨트롤러 실행 중에 발생했는지 확인합니다.
- 실제 던져진 예외 타입이 핸들러의 파라미터 타입과 일치하는지 봅니다.
@RestControllerAdvice가 컴포넌트 스캔 범위 안에 있는지 확인합니다.basePackages를 사용했다면 대상 패키지가 정확한지 확인합니다.- 로컬
@ExceptionHandler가 먼저 매칭되어 전역 핸들러가 호출되지 않는 상황인지 봅니다. - 검증 실패 예외가
MethodArgumentNotValidException인지, 다른 예외로 들어오는 상황인지 확인합니다. - 응답이 이미 커밋된 뒤 예외가 발생한 것은 아닌지 살펴봅니다.
특히 자주 나오는 오해가 두 가지 있습니다. 첫째, 서비스에서 던진 예외는 못 잡는다고 생각하는 경우입니다. 실제로는 컨트롤러 호출 흐름 안에서 올라온다면 충분히 처리할 수 있습니다. 둘째, 웹 계층에서 발생한 예외는 모두 advice가 잡는다고 생각하는 경우입니다. 필터 체인이나 보안 계층은 별도 처리 지점이 필요할 수 있습니다.
디버깅의 핵심은 “어디서 예외가 났는가”와 “누가 그것을 처리했는가”를 분리해서 보는 것입니다. 이 관점을 잡으면 @ExceptionHandler 관련 문제는 대부분 빠르게 수습할 수 있습니다.
정리
@ExceptionHandler는 Spring MVC 예외 처리를 예쁘게 꾸미는 기능이 아니라, 예외를 HTTP 응답 계약으로 연결하는 핵심 구성 요소입니다. 컨트롤러 내부에서는 도메인에 가까운 예외를 처리하고, @RestControllerAdvice에서는 여러 API에 공통인 정책을 관리하면 구조가 자연스럽습니다.
핵심은 세 가지입니다. 예외를 HTTP 의미로 분류할 것, 응답 형식을 일관되게 유지할 것, 그리고 예외가 발생하는 계층 경계를 정확히 이해할 것입니다. 이 세 가지만 지켜도 API 품질은 눈에 띄게 안정됩니다.
원문 참고
https://www.maeil-mail.kr/question/8
댓글
댓글 쓰기