Spring Boot 실무 가이드 · Part 6

예외 처리와 응답 표준화

장애가 났을 때 클라이언트와 운영자가 각각 필요한 정보를 받게 만들기

작성 기준2026년 7월버전과 지원 현황은 이후 달라질 수 있으니 공식 문서를 함께 확인하세요.

이 파트에서 다루는 내용

예외 처리를 모으는 이유전역 예외 처리에러 응답 형식로깅과 노출 분리
01

컨트롤러마다 try-catch를 쓰면 응답이 제각각이 됩니다

예외 처리를 각 컨트롤러에서 하면 같은 종류의 실패인데 어떤 API는 400을, 어떤 API는 200에 실패 메시지를 담아 내보내게 됩니다. 클라이언트는 API마다 다른 처리 코드를 만들어야 합니다.

게다가 비즈니스 로직 사이사이에 try-catch가 끼면서 정작 중요한 흐름이 안 보이게 됩니다.

해결은 간단합니다. 예외는 발생한 곳에서 던지고, 응답으로 바꾸는 일은 한곳에서 처리합니다.

예외를 던지는 쪽
서비스 계층

업무 규칙에 맞지 않으면 의미가 드러나는 예외를 던집니다. 응답 형식이나 상태 코드는 신경 쓰지 않습니다.

응답으로 바꾸는 쪽
@RestControllerAdvice

어떤 예외를 어떤 상태 코드와 형식으로 내보낼지 한곳에서 정합니다. 규칙이 한 파일에 모입니다.

02

@RestControllerAdvice로 전역 처리합니다

@RestControllerAdvice가 붙은 클래스는 모든 컨트롤러에서 발생한 예외를 가로챕니다. 그 안에서 @ExceptionHandler로 예외 종류별 처리를 정의합니다.

여러 핸들러가 있으면 더 구체적인 타입이 우선 적용됩니다. 그래서 Exception을 받는 핸들러는 최후의 안전망 역할을 합니다.

전역 예외 처리 기본형java
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 업무 규칙 위반: 클라이언트가 상황을 고치면 해결됩니다
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) {
        log.warn("business error: code={}, message={}", e.getCode(), e.getMessage());
        return ResponseEntity
                .status(e.getStatus())
                .body(ErrorResponse.of(e.getCode(), e.getMessage()));
    }

    // 검증 실패: 어떤 필드가 왜 틀렸는지 돌려줍니다
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
        List<FieldError> errors = e.getBindingResult().getFieldErrors().stream()
                .map(error -> new FieldError(error.getField(), error.getDefaultMessage()))
                .toList();
        return ResponseEntity
                .badRequest()
                .body(ErrorResponse.of("INVALID_REQUEST", "요청 값이 올바르지 않습니다", errors));
    }

    // 최후의 안전망: 예상하지 못한 예외
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
        log.error("unexpected error", e);
        return ResponseEntity
                .internalServerError()
                .body(ErrorResponse.of("INTERNAL_ERROR", "요청을 처리하지 못했습니다"));
    }
}

마지막 핸들러가 메시지를 고정 문구로 내보내는 점을 보세요. 예외 메시지를 그대로 전달하면 내부 구조가 노출됩니다.

주의

Spring Security의 인증·인가 예외는 컨트롤러에 도달하기 전 필터 단계에서 발생하므로 @RestControllerAdvice로 잡히지 않습니다. 401·403 응답 형식을 맞추려면 별도 설정이 필요합니다. Part 12에서 다룹니다.

03

에러 응답 형식은 팀이 먼저 합의합니다

형식을 정하지 않으면 API마다 필드 이름이 달라집니다. 클라이언트가 message를 볼지 msg를 볼지 매번 확인해야 합니다.

직접 정의하는 방법과 표준을 따르는 방법이 있습니다. Spring Framework 6부터는 RFC 표준 형식인 ProblemDetail이 내장돼 있어 새 프로젝트라면 검토해 볼 만합니다.

자체 형식
가장 흔함

code, message, 필요 시 필드 오류 목록과 추적 아이디를 담습니다. 팀 상황에 맞게 정하되 모든 API가 동일한 형태여야 합니다.

  • 기존 클라이언트와 호환이 쉬움
  • 사내 표준이 이미 있는 경우 적합
ProblemDetail
표준 형식

type, title, status, detail, instance를 갖는 표준 구조입니다. Spring 6 이상에서 기본 제공되며 외부 공개 API에 유리합니다.

  • 별도 클래스 정의 없이 사용 가능
  • 표준을 아는 클라이언트가 바로 해석
두 가지 응답 형식json
// 자체 형식 예시
{
  "code": "ORDER_NOT_FOUND",
  "message": "주문을 찾을 수 없습니다",
  "traceId": "b3f1c2a4",
  "errors": []
}

// ProblemDetail 형식 예시
{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "주문을 찾을 수 없습니다",
  "instance": "/api/orders/1024"
}

무엇을 고르든 code나 type처럼 기계가 분기할 수 있는 값을 반드시 포함합니다. message만 있으면 클라이언트가 문구를 비교하는 코드를 짜게 됩니다.

04

로그에 남길 것과 응답에 담을 것을 구분합니다

운영자는 원인을 알아야 하고, 클라이언트는 다음 행동을 알아야 합니다. 두 대상이 필요한 정보가 다릅니다.

스택트레이스를 응답에 담으면 클래스 구조, 라이브러리 버전, 파일 경로가 그대로 노출됩니다. 공격자에게는 유용한 정보이고 사용자에게는 쓸모가 없습니다.

응답에 담을 것
클라이언트용
  • 기계가 분기할 수 있는 오류 코드
  • 사용자에게 보여줄 수 있는 안내 문구
  • 문의 시 사용할 추적 아이디
로그에 남길 것
운영자용
  • 예외 종류와 스택트레이스
  • 요청 경로, 파라미터, 사용자 식별자
  • 추적 아이디 (응답과 동일한 값)
로그 레벨 기준
구분
  • 4xx로 나갈 예외는 warn: 클라이언트가 고칠 문제
  • 5xx로 나갈 예외는 error: 서버가 봐야 할 문제
  • 정상 흐름의 예외를 error로 남기면 알림이 무의미해집니다
추적 아이디
연결 고리

응답과 로그에 같은 값을 남기면 사용자가 문의한 건을 로그에서 바로 찾을 수 있습니다. 운영 대응 속도가 크게 달라집니다.

05

실무에서 반복되는 실수

모든 예외를 500으로 처리
가장 흔함

잘못된 요청까지 500이 되면 모니터링 알림이 계속 울리고, 정작 진짜 장애를 구분하지 못합니다.

예외를 잡고 아무것도 하지 않음
위험

catch 블록을 비워 두면 실패가 성공처럼 보입니다. 데이터가 조용히 어긋나고 원인 추적이 불가능해집니다.

예외 메시지 그대로 노출
보안

SQL 구문이나 파일 경로가 담긴 메시지가 클라이언트로 나갑니다. 응답 문구는 별도로 정의합니다.

원인 예외를 버리고 다시 던짐
추적 불가

새 예외를 만들 때 원인 예외를 함께 넘기지 않으면 스택트레이스가 끊겨 실제 발생 지점을 잃습니다.

응답 형식이 API마다 다름
협업 비용

클라이언트가 API별 분기 코드를 갖게 됩니다. 형식은 한 번 정하고 전역 처리로 강제합니다.

정리

예외 처리의 목표는 '에러를 감추는 것'이 아니라 '누가 무엇을 해야 할지 분명하게 만드는 것'입니다. 클라이언트는 고칠 수 있는지 알고, 운영자는 원인을 찾을 수 있어야 합니다.

버전

3.x와 4.x 차이

본문은 현장에서 가장 많이 쓰는 3.x 기준입니다. 4.x에서 달라진 부분만 아래에 정리합니다.

  • ProblemDetail은 Spring Framework 6부터 제공되므로 Spring Boot 3.x와 4.x 모두 사용할 수 있습니다. 2.7 이하에는 없습니다.
  • 4.0의 Jackson 3 전환으로 에러 응답 직렬화 결과가 미세하게 달라질 수 있습니다. null 필드 포함 여부와 날짜 형식을 업그레이드 후 확인합니다.
  • 예외 처리 구조 자체는 3.x와 4.x가 동일합니다. 이 파트 내용은 버전에 관계없이 그대로 적용됩니다.
체크

이 파트 완료 기준