왜 오류 응답을 먼저 표준화해야 하는가

Spring 기반 API를 운영하다 보면 성공 응답보다 장애 응답의 품질이 더 빨리 서비스 신뢰도를 드러냅니다. 같은 400 오류라도 어떤 엔드포인트는 문자열만 반환하고, 어떤 엔드포인트는 스택 트레이스 일부를 노출하고, 또 다른 엔드포인트는 프론트엔드가 파싱하기 어려운 필드명을 사용하면 클라이언트는 매번 예외 처리를 따로 만들어야 합니다. 운영팀도 로그와 사용자 문의를 연결하기 어렵고, QA는 오류 케이스를 재현해도 어떤 규칙이 맞는지 판단하기 힘듭니다.

Spring 6와 Spring Boot 3 계열에서는 표준 오류 모델로 ProblemDetail을 사용할 수 있습니다. 핵심은 모든 예외를 억지로 하나의 메시지로 뭉개는 것이 아니라, HTTP 상태 코드, 사람이 읽는 제목, 상세 설명, 추적 가능한 오류 코드, 필드 검증 실패 목록을 일관된 구조로 내려주는 것입니다. 이렇게 하면 웹, 모바일, 백오피스, 외부 연동 API가 같은 오류 계약을 공유할 수 있습니다.

권장 응답 구조

실무에서는 RFC 기반 기본 필드에 서비스 전용 확장 필드를 조금 더하는 방식이 다루기 쉽습니다. 기본 필드인 type, title, status, detail, instance는 HTTP 오류의 의미를 설명하고, 확장 필드인 code, traceId, fieldErrors는 서비스 운영에 필요한 정보를 담습니다. 여기서 traceId는 로그 MDC나 분산 추적 시스템의 요청 ID와 맞추면 문의 대응 시간이 크게 줄어듭니다.

  • type: 문서화 가능한 오류 유형 URI입니다. 처음에는 about:blank로 시작해도 됩니다.
  • title: 오류 분류를 나타내는 짧은 한국어 또는 영어 제목입니다.
  • status: HTTP 상태 코드 숫자입니다.
  • detail: 사용자 또는 개발자가 이해할 수 있는 구체 설명입니다.
  • code: 프론트엔드와 고객지원 시스템이 분기할 수 있는 안정적인 내부 오류 코드입니다.
  • fieldErrors: 입력 검증 실패 시 필드 단위 오류를 배열로 제공합니다.

전역 예외 처리 코드

아래 예시는 Spring Boot 3 애플리케이션에서 바로 적용할 수 있는 기본 골격입니다. 도메인 예외는 BusinessException으로 받고, Bean Validation 실패는 MethodArgumentNotValidException에서 필드 목록으로 변환합니다. 알 수 없는 예외는 상세 내부 정보를 숨기고 공통 코드만 반환합니다.

package com.example.api.error;

import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.net.URI;
import java.util.List;

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    ProblemDetail handleBusiness(BusinessException ex, HttpServletRequest request) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(ex.status(), ex.getMessage());
        problem.setType(URI.create("https://api.example.com/problems/" + ex.code()));
        problem.setTitle(ex.title());
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("code", ex.code());
        problem.setProperty("traceId", MDC.get("traceId"));
        return problem;
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
        List<FieldErrorResponse> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
            .map(error -> new FieldErrorResponse(
                error.getField(),
                error.getDefaultMessage(),
                String.valueOf(error.getRejectedValue())
            ))
            .toList();

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST,
            "요청 값이 올바르지 않습니다. fieldErrors를 확인하세요."
        );
        problem.setType(URI.create("https://api.example.com/problems/validation-failed"));
        problem.setTitle("입력값 검증 실패");
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("code", "VALIDATION_FAILED");
        problem.setProperty("traceId", MDC.get("traceId"));
        problem.setProperty("fieldErrors", fieldErrors);
        return problem;
    }

    @ExceptionHandler(Exception.class)
    ProblemDetail handleUnknown(Exception ex, HttpServletRequest request) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR,
            "일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요."
        );
        problem.setTitle("서버 오류");
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("code", "INTERNAL_ERROR");
        problem.setProperty("traceId", MDC.get("traceId"));
        return problem;
    }

    record FieldErrorResponse(String field, String message, String rejectedValue) {}
}

도메인 예외는 얇고 명확하게 둔다

전역 예외 처리기가 강력해져도 서비스 계층에서 던지는 예외가 모호하면 응답 품질은 나아지지 않습니다. 도메인 예외는 상태 코드, 내부 코드, 제목, 메시지를 명확히 갖도록 만들고, 컨트롤러에서는 이를 잡지 않는 편이 좋습니다. 컨트롤러가 예외 변환을 담당하기 시작하면 엔드포인트마다 응답 규칙이 다시 갈라지기 때문입니다.

public class BusinessException extends RuntimeException {
    private final HttpStatus status;
    private final String code;
    private final String title;

    public BusinessException(HttpStatus status, String code, String title, String message) {
        super(message);
        this.status = status;
        this.code = code;
        this.title = title;
    }

    public HttpStatus status() { return status; }
    public String code() { return code; }
    public String title() { return title; }

    public static BusinessException orderNotFound(Long orderId) {
        return new BusinessException(
            HttpStatus.NOT_FOUND,
            "ORDER_NOT_FOUND",
            "주문 없음",
            "주문을 찾을 수 없습니다. orderId=" + orderId
        );
    }
}

검증 오류 설계 기준

입력 검증 오류는 프론트엔드 UX와 직접 연결됩니다. 단순히 첫 번째 오류 메시지만 내려주면 사용자는 제출 버튼을 여러 번 눌러야 모든 문제를 알 수 있습니다. 반대로 필드 목록을 안정적으로 제공하면 화면은 각 입력칸 아래에 메시지를 바로 표시할 수 있습니다. 필드명은 DTO의 JSON 프로퍼티와 맞추고, 메시지는 사용자에게 보여도 되는 문장으로 관리하는 것이 좋습니다.

주의할 점은 거절된 값 전체를 무조건 내려주지 않는 것입니다. 비밀번호, 토큰, 주민등록번호, 카드번호처럼 민감한 값은 응답에 포함되면 안 됩니다. 위 예시는 구조를 보여주기 위한 것이므로, 실제 적용 시에는 필드명 기준으로 마스킹하거나 rejectedValue를 제거하는 정책을 추가해야 합니다. 또한 내부 예외 메시지를 그대로 detail에 넣으면 SQL, 파일 경로, 외부 API 키 이름 같은 운영 정보가 노출될 수 있으므로 500 계열 오류에는 공개용 문구를 사용해야 합니다.

로그와 traceId 연결

오류 응답에 traceId를 넣었다면 서버 로그에도 같은 값이 반드시 남아야 합니다. 요청 필터에서 UUID를 만들거나, 게이트웨이가 전달한 요청 ID를 받아 MDC에 저장하면 됩니다. 고객지원 담당자는 사용자 화면의 traceId를 개발팀에 전달하고, 개발팀은 해당 ID로 로그를 검색해 정확한 요청 흐름을 확인할 수 있습니다. 이 연결이 없으면 오류 응답 표준화는 보기 좋은 JSON에서 멈춥니다.

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.UUID;

@Component
public class TraceIdFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain chain
    ) throws ServletException, IOException {
        String traceId = request.getHeader("X-Request-Id");
        if (traceId == null || traceId.isBlank()) {
            traceId = UUID.randomUUID().toString();
        }
        MDC.put("traceId", traceId);
        response.setHeader("X-Request-Id", traceId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove("traceId");
        }
    }
}

테스트로 오류 계약을 고정한다

오류 응답은 문서보다 테스트로 고정하는 편이 안전합니다. 특히 code, status, fieldErrors 구조는 프론트엔드와 외부 연동이 의존하는 계약이므로 리팩터링 중 바뀌면 즉시 실패해야 합니다. MockMvc 또는 WebTestClient로 대표 오류 케이스를 검증하고, 공통 응답 스키마를 API 문서에도 반영하면 배포 전 누락을 줄일 수 있습니다.

  • 도메인 예외마다 내부 오류 코드를 정하고 중복을 피합니다.
  • 500 오류에는 내부 예외 메시지와 스택 트레이스를 응답하지 않습니다.
  • 검증 오류는 필드 단위 배열로 내려 프론트엔드가 한 번에 표시할 수 있게 합니다.
  • 응답의 traceId와 서버 로그의 traceId가 같은지 통합 테스트 또는 운영 로그 샘플로 확인합니다.
  • 오류 코드표를 API 문서에 포함하고, 삭제보다 deprecated 표시를 우선합니다.

실전 적용 체크리스트

Spring API 오류 응답 표준화는 라이브러리 하나를 추가하는 작업이 아니라 팀의 계약을 정리하는 작업입니다. 먼저 현재 운영 중인 오류 응답을 수집해 가장 많이 발생하는 400, 401, 403, 404, 409, 500 케이스를 분류합니다. 그다음 ProblemDetail 기반 공통 구조를 만들고, 도메인 예외와 검증 예외부터 전역 처리기에 연결합니다. 마지막으로 traceId, 민감정보 마스킹, 테스트, API 문서화를 함께 적용하면 장애 대응과 클라이언트 개발이 모두 단순해집니다.