예외 응답 표준화와 검증 흐름

응답 모양부터 고정하기

REST API 예외 처리가 컨트롤러마다 달라지면 클라이언트는 상태 코드보다 응답 본문을 더 많이 의심한다. Spring Framework 6의 ProblemDetail은 type, title, status, detail, instance를 중심으로 오류를 표현한다.

Spring Boot에서 내장 MVC 예외까지 문제 상세 형식으로 받고 싶다면 먼저 설정을 켠다. 기본 웹 예외 처리까지 맞추려는 선택이다.

spring:
  mvc:
    problemdetails:
      enabled: true

공식 속성 목록 기준으로 이 값의 기본값은 false다. 새 프로젝트에서는 명시적으로 켰는지 확인한다.

비즈니스 예외를 ProblemDetail로 바꾸기

핵심은 도메인 예외를 HTTP 응답 정책으로 매핑하는 위치를 한 곳에 두는 것이다. 컨트롤러가 예외 본문을 직접 만들면 성공 흐름과 실패 흐름이 섞인다.

@RestControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail handle(OrderNotFoundException ex, HttpServletRequest request) {
        ProblemDetail body = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND,
                "주문을 찾을 수 없습니다."
        );
        body.setTitle("Order Not Found");
        body.setInstance(URI.create(request.getRequestURI()));
        body.setProperty("code", "ORDER_NOT_FOUND");
        return body;
    }
}

status는 HTTP 상태와 맞추고, code처럼 서비스가 필요한 값은 확장 속성으로 둔다. 표준 필드와 내부 오류 코드를 분리하면 공통 처리와 화면 문구 처리를 나누기 쉽다.

검증 오류도 같은 규칙으로 묶기

요청 바디 검증 실패는 필드 단위 설명이 필요하다. 이때도 ProblemDetail에 확장 속성을 붙이면 전체 오류 형식은 유지하면서 세부 항목만 추가할 수 있다.

@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
    ProblemDetail body = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST,
            "요청 값이 유효하지 않습니다."
    );
    body.setTitle("Validation Failed");
    body.setProperty("code", "INVALID_REQUEST");
    body.setProperty("errors", ex.getBindingResult().getFieldErrors()
            .stream()
            .map(error -> error.getField())
            .toList());
    return body;
}

실무에서는 errors를 필드, 거절 값, 메시지 코드가 있는 객체 목록으로 다듬을 수 있다. 중요한 점은 검증 실패도 같은 최상위 필드를 유지한다는 것이다.

MockMvc로 계약 확인하기

예외 응답은 눈으로 확인하고 끝내면 흔들린다. 테스트에서는 상태 코드, 콘텐츠 타입, 오류 코드를 함께 고정한다.

mockMvc.perform(get("/orders/999"))
        .andExpect(status().isNotFound())
        .andExpect(header().string("Content-Type", containsString("application/problem+json")))
        .andExpect(jsonPath("$.status").value(404))
        .andExpect(jsonPath("$.code").value("ORDER_NOT_FOUND"));

이 테스트가 있으면 예외 클래스나 메시지를 바꿔도 API 계약이 깨졌는지 바로 드러난다. 운영 응답에는 스택트레이스나 내부 클래스명을 넣지 말고, 추적 값은 로그의 trace id와 연결한다.

댓글