이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Spring MVC 요청 처리 중 예외가 발생하면 어떤 계층을 거쳐 응답으로 바뀌는가?
- Controller 내부 처리, 전역 Advice, Boot 기본 에러 처리의 책임은 어떻게 나뉘는가?
- 어떤 예외를 잡고 어떤 예외를 마지막 방어선으로 보내야 하는가?
개요
Spring MVC 예외 처리는 계층 구조로 이해해야 한다. Controller 메서드 내부에서 직접 처리할 수도 있고, @ExceptionHandler가 잡을 수도 있고, @ControllerAdvice가 공통 정책으로 처리할 수도 있다. 그래도 처리되지 않은 오류는 Spring Boot의 기본 에러 처리까지 내려간다.
문서가 어려워지는 지점은 예외 처리 기능을 한 덩어리로 외우기 때문이다. 실제 설계에서는 예외가 가진 의미와 처리 위치를 나누어야 한다. 가까운 곳에서 처리할 예외, 전역 정책으로 바꿀 예외, 시스템 장애로 기록하고 숨길 예외가 다르다.
흐름
Controller 실행 중 예외가 발생하면 Spring MVC는 HandlerExceptionResolver 체계를 통해 처리 가능한 예외 처리기를 찾는다. @ExceptionHandler 메서드, ResponseStatusException, @ResponseStatus, 기본 MVC 예외 처리 등이 이 흐름에 참여할 수 있다. 이 흐름은 DispatcherServlet 안의 MVC 처리 단계 기준이므로, Filter Chain이나 Security entry point에서 끝난 예외까지 모두 같은 Advice가 처리한다고 보면 안 된다.
애플리케이션이 별도로 처리하지 못한 오류는 Servlet 컨테이너의 오류 디스패치와 Spring Boot의 /error 처리로 이어진다. Boot는 BasicErrorController와 ErrorAttributes를 이용해 기본 에러 응답이나 페이지를 만든다.
여러 예외 처리기가 후보가 되면 예외 타입의 구체성, root/cause 매칭, Advice 우선순위가 영향을 준다. 따라서 “이 예외는 이 handler가 잡겠지”라고 추측하지 말고 MockMvc 테스트로 실제 상태 코드와 body를 고정하는 편이 안전하다.
이 흐름은 실패를 숨기기 위한 것이 아니다. 실패를 가장 적절한 계층에서 의미 있는 응답으로 바꾸기 위한 것이다. Controller에서만 필요한 화면 복귀는 Controller 가까이에 두고, API 전체의 오류 형식은 Advice에 두며, 예상하지 못한 시스템 장애는 공통 500 응답과 로그로 다룬다.
예외 분류
입력 검증 실패는 보통 클라이언트가 수정할 수 있는 문제다. 400 Bad Request와 필드별 오류 정보가 필요하다.
인증 실패와 권한 실패는 Spring Security 흐름이 처리하는 경우가 많다. 인증이 없거나 실패하면 401, 인증은 되었지만 권한이 부족하면 403으로 나누어야 한다. MVC Advice에서만 처리하려 하면 Security filter 단계에서 발생한 실패와 응답 형식이 어긋날 수 있다.
비즈니스 예외는 유스케이스 규칙 위반이다. 예를 들어 이미 취소된 주문을 다시 취소하려는 요청은 단순 문법 오류가 아니라 상태 충돌일 수 있다.
시스템 예외는 데이터베이스 장애, 외부 API 장애, 예상하지 못한 NullPointerException처럼 클라이언트가 직접 고칠 수 없는 문제다. 내부 상세를 숨기고 추적 가능한 로그를 남겨야 한다.
코드 예제
public class OrderAlreadyCanceledException extends RuntimeException {
private final Long orderId;
public OrderAlreadyCanceledException(Long orderId) {
super("order already canceled: " + orderId);
this.orderId = orderId;
}
public Long orderId() {
return orderId;
}
}Service 예외는 HTTP를 직접 알 필요가 없다. 주문이 이미 취소되었다는 유스케이스 의미를 담으면 된다.
@RestControllerAdvice
public class ApiExceptionAdvice {
@ExceptionHandler(OrderAlreadyCanceledException.class)
ResponseEntity<ApiError> handle(OrderAlreadyCanceledException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError(
"ORDER_ALREADY_CANCELED",
"이미 취소된 주문입니다.",
Map.of("orderId", ex.orderId())));
}
}Web 계층이 이 의미를 HTTP 409 Conflict와 API 오류 코드로 변환한다. 이렇게 나누면 Service는 웹이 아닌 환경에서도 같은 실패 의미를 유지할 수 있다.
실전 팁
- 예외 클래스에는 유스케이스 실패 의미와 필요한 식별자만 담는다.
- HTTP 상태와 API 오류 코드는 Advice에서 결정한다.
- 예상 가능한 비즈니스 실패와 예상 못한 시스템 실패를 같은 handler에 넣지 않는다.
- 인증/인가 실패는 Security의 entry point와 access denied handler까지 함께 맞춘다.
- 마지막
Exception.classhandler는 내부 정보를 숨기고 추적 ID를 남기는 정도로 제한한다. - 오류 로그는 같은 예외가 중복 기록되지 않도록 처리 계층을 정한다.
위험 신호!
- Service 계층에서
ResponseEntity를 반환한다. - 모든 예외를
RuntimeException하나로 던지고 메시지 문자열로 분기한다. - 전역 handler가
Exception.class만 잡고 모든 응답을400으로 보낸다. - Filter나 Security 단계의 예외가
@RestControllerAdvice에서 모두 처리될 것이라고 기대한다. - 예상하지 못한 예외의 스택트레이스가 API 응답에 그대로 노출된다.
- 비즈니스 실패인데 로그 레벨이 항상
ERROR로 찍혀 운영 알림을 오염시킨다.
확인 질문
확인 질문
- 처리되지 않은 MVC 예외는 최종적으로 어디까지 갈 수 있는가?
- Spring Boot의
/error기본 에러 처리까지 갈 수 있다.- Service 예외에 HTTP 상태를 직접 넣지 않는 이유는 무엇인가?
- Service는 유스케이스 실패 의미를 표현하고 Web 계층이 HTTP 계약으로 변환하는 편이 계층 분리에 좋기 때문이다.
- 마지막
Exception.classhandler는 어떤 역할이어야 하는가?
- 예상하지 못한 실패를 내부 정보 없이 일관된 500 응답으로 바꾸고 추적 가능한 로그를 남기는 역할이어야 한다.