Spring 예외 처리를 중앙화하는 설계
API가 커질수록 예외 처리 코드는 비즈니스 로직보다 빠르게 반복된다. 각 컨트롤러에서 try catch를 작성하면 응답 상태 코드와 메시지가 달라지고, 로그에 민감한 정보가 섞일 수 있다. Spring MVC는 ExceptionHandler와 RestControllerAdvice로 예외 처리 경계를 중앙에 둘 수 있다.
오류 응답을 먼저 정의하기
클라이언트는 예외 클래스 이름보다 안정적인 오류 코드와 메시지가 필요하다. 오류 응답에 요청 추적 ID를 포함하면 로그를 연결하기 쉽다.
public record ErrorResponse(
String code,
String message,
String traceId
) {
}
성공 응답의 ResponseEntity처럼 오류 응답도 계약으로 관리한다. 검증 실패, 인증 실패, 권한 부족, 리소스 없음, 서버 오류를 서로 다른 HTTP 상태로 구분하되, 내부 예외 메시지를 그대로 외부에 노출하지 않는다.
컨트롤러 전용 처리와 전역 처리
ExceptionHandler는 특정 컨트롤러 안에 둘 수도 있고, ControllerAdvice 클래스에 둘 수도 있다. JSON API라면 RestControllerAdvice를 사용하면 응답 본문으로 바로 직렬화된다.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException exception,
HttpServletRequest request
) {
String message = exception.getBindingResult()
.getFieldErrors()
.stream()
.findFirst()
.map(FieldError::getDefaultMessage)
.orElse("입력값을 확인해 주세요.");
return ResponseEntity.badRequest()
.body(new ErrorResponse("INVALID_INPUT", message, traceId(request)));
}
@ExceptionHandler(ResourceNotFoundException.class)
ResponseEntity<ErrorResponse> handleNotFound(
ResourceNotFoundException exception,
HttpServletRequest request
) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("NOT_FOUND", exception.getMessage(), traceId(request)));
}
}
예외 처리 메서드는 변환만 담당하고, 실제 정책은 도메인 예외에 담는다. 서비스가 ResourceNotFoundException을 던지면 핸들러는 404와 공개 가능한 메시지로 변환한다. 데이터베이스 예외나 네트워크 예외를 컨트롤러까지 전달해 임의의 상태 코드로 바꾸는 방식은 피한다.
상태 코드 선택 기준
| 상황 | 권장 상태 | 이유 |
|---|---|---|
| JSON 형식·필드 검증 실패 | 400 | 요청 자체가 유효하지 않음 |
| 로그인 정보 없음 | 401 | 인증이 필요함 |
| 로그인했지만 권한 없음 | 403 | 리소스 접근 권한 부족 |
| 대상 리소스 없음 | 404 | 요청한 대상을 찾지 못함 |
| 중복·상태 충돌 | 409 | 현재 상태와 요청이 충돌함 |
| 예상하지 못한 서버 오류 | 500 | 서버가 처리하지 못함 |
모든 예외를 400으로 반환하면 클라이언트가 재시도 여부를 판단하기 어렵고 모니터링 지표도 흐려진다. 오류 코드와 상태 코드를 함께 문서화한다.
로그와 보안
사용자 비밀번호, 토큰, 주민번호 같은 값은 예외 로그에 남기지 않는다. 스택 트레이스를 남길 때도 요청 본문 전체를 함께 출력하지 않는다. 외부에는 일반적인 메시지를 보내고, 내부 로그에는 원인 예외와 trace ID를 남긴다.
RuntimeException 하나로 모든 오류를 잡는 핸들러는 마지막 안전망으로만 둔다. 이 핸들러가 정상적인 도메인 오류까지 삼키면 원인별 상태 코드가 사라진다. 마지막 핸들러는 500과 공통 메시지를 반환하고, 상세 원인은 서버 로그에만 남긴다.
테스트로 고정할 계약
MockMvc 테스트에서 예외별 상태 코드와 응답 JSON을 고정한다. 검증 오류는 특정 필드 메시지가 반환되는지, 인증 오류는 민감한 내부 메시지가 노출되지 않는지 함께 확인한다. 핸들러 단위 테스트만으로는 필터나 메시지 변환 경계를 검증할 수 없으므로 대표 API의 통합 테스트도 남긴다.
예외 중앙화의 목표는 오류를 숨기는 것이 아니다. 실패를 같은 형식으로 전달하고, 로그와 사용자 응답의 책임을 나누는 것이다. 예외 타입, 상태 코드, 오류 코드, 로그 수준을 한 세트로 정의하면 새로운 기능이 추가되어도 API 계약이 흔들리지 않는다.
함께 읽기:
원문 기록: Spring Day 21: 예외 처리