본문 바로가기
Java & Spring

REST API 에러 코드 규격: 문제 상세(Problem Details)로 정리

by yamoojin83 2025. 10. 18.

REST API 에러 코드 규격: 문제 상세(Problem Details)로 정리

API 에러 응답이 제각각이면 클라이언트는 분기 지옥에 빠집니다. 표준 규격인 RFC 7807 Problem Details(application/problem+json)을 Spring Boot 3에서 간단한 설정으로 통일하는 방법을 정리합니다.


1) 목표 스펙

  • Content-Type: application/problem+json
  • 필드: type(문서 링크), title, status, detail, instance
  • 확장: code(내부 에러코드), traceId(관찰), errors(필드 오류 목록)

{
  "type": "https://api.example.com/problems/validation-error",
  "title": "요청 값이 유효하지 않습니다",
  "status": 400,
  "detail": "email 필드가 비어있습니다",
  "instance": "/api/users",
  "code": "E400_VALIDATION",
  "traceId": "c0a80123-..."
}

2) 에러 코드 체계

HTTP 상태와 1:1로만 묶지 말고 업무 의미를 담은 코드를 만듭니다.


public enum ErrorCode {
  E400_VALIDATION(400, "요청 값이 유효하지 않습니다"),
  E401_UNAUTHORIZED(401, "인증이 필요합니다"),
  E403_FORBIDDEN(403, "접근 권한이 없습니다"),
  E404_NOT_FOUND(404, "리소스를 찾을 수 없습니다"),
  E409_CONFLICT(409, "리소스 상태 충돌"),
  E500_INTERNAL(500, "서버 오류");

  public final int status;
  public final String defaultMessage;
  ErrorCode(int s, String m) { this.status = s; this.defaultMessage = m; }
}

3) ProblemDetail + @ControllerAdvice 기본기

Spring Boot 3는 ProblemDetail 타입을 제공합니다.


@ResponseStatus(HttpStatus.NOT_FOUND)
class UserNotFoundException extends RuntimeException {
  public UserNotFoundException(String id) {
    super("사용자를 찾을 수 없습니다: " + id);
  }
}

@RestControllerAdvice
public class GlobalExceptionHandler {

  @ExceptionHandler(UserNotFoundException.class)
  public ResponseEntity<ProblemDetail> handleNotFound(
      UserNotFoundException ex, HttpServletRequest req) {

    ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
    pd.setTitle("리소스를 찾을 수 없습니다");
    pd.setDetail(ex.getMessage());
    pd.setType(URI.create("https://api.example.com/problems/not-found"));
    pd.setInstance(URI.create(req.getRequestURI()));
    pd.setProperty("code", ErrorCode.E404_NOT_FOUND.name());
    pd.setProperty("traceId", getTraceId());
    return ResponseEntity.status(pd.getStatus()).body(pd);
  }

  private String getTraceId() {
    return Optional.ofNullable(org.slf4j.MDC.get("traceId")).orElse(UUID.randomUUID().toString());
  }
}

포인트: setProperty로 확장 필드를 자유롭게 추가할 수 있습니다. traceId는 로그 상관관계에 필수입니다(게이트웨이에서 전달 받는 것도 좋음).


4) 검증 오류(Bean Validation)까지 통일

@Valid에서 발생하는 MethodArgumentNotValidException을 Problem Details로 매핑합니다.


record FieldErrorDetail(String field, String message) {}

@RestControllerAdvice
class ValidationExceptionHandler {

  @ExceptionHandler(MethodArgumentNotValidException.class)
  public ResponseEntity<ProblemDetail> handleValidation(
      MethodArgumentNotValidException ex, HttpServletRequest req) {

    List<FieldErrorDetail> errors = ex.getBindingResult().getFieldErrors().stream()
      .map(fe -> new FieldErrorDetail(fe.getField(), fe.getDefaultMessage()))
      .toList();

    ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    pd.setTitle("요청 값이 유효하지 않습니다");
    pd.setDetail("입력한 필드를 확인하세요");
    pd.setType(URI.create("https://api.example.com/problems/validation-error"));
    pd.setInstance(URI.create(req.getRequestURI()));
    pd.setProperty("code", ErrorCode.E400_VALIDATION.name());
    pd.setProperty("errors", errors);
    pd.setProperty("traceId", getTraceId());
    return ResponseEntity.badRequest().body(pd);
  }

  private String getTraceId() {
    return Optional.ofNullable(org.slf4j.MDC.get("traceId")).orElse(UUID.randomUUID().toString());
  }
}

5) 공통 처리(가드 레일)

  • Content-Type 강제: application/problem+json
  • 로깅 레벨: 4xx는 WARN, 5xx는 ERROR
  • 메시지 위생: 내부 스택/SQL/토큰 등 민감 정보 노출 금지
  • 다국어: title/detail을 메시지소스로 국제화 가능

@RestControllerAdvice
class FallbackHandler {

  @ExceptionHandler(Exception.class)
  public ResponseEntity<ProblemDetail> handleAny(Exception ex, HttpServletRequest req) {
    ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
    pd.setTitle("서버 오류");
    pd.setDetail("일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요.");
    pd.setType(URI.create("https://api.example.com/problems/internal"));
    pd.setInstance(URI.create(req.getRequestURI()));
    pd.setProperty("code", ErrorCode.E500_INTERNAL.name());
    pd.setProperty("traceId", getTraceId());
    // 로그는 서버 내부에서 스택 포함 기록
    return ResponseEntity.status(500).body(pd);
  }

  private String getTraceId() {
    return Optional.ofNullable(org.slf4j.MDC.get("traceId")).orElse(UUID.randomUUID().toString());
  }
}

6) 스프링 시큐리티와의 정합

인증 실패(401)과 인가 실패(403)는 시큐리티 엔트리포인트/핸들러에서 Problem Details로 내려주세요.


@Component
class SecurityProblemSupport {

  public AuthenticationEntryPoint authenticationEntryPoint() {
    return (req, res, ex) -> {
      ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED);
      pd.setTitle("인증이 필요합니다");
      pd.setDetail("토큰이 없거나 만료되었습니다");
      pd.setType(URI.create("https://api.example.com/problems/unauthorized"));
      pd.setInstance(URI.create(req.getRequestURI()));
      pd.setProperty("code", ErrorCode.E401_UNAUTHORIZED.name());
      pd.setProperty("traceId", Optional.ofNullable(MDC.get("traceId")).orElse("-"));
      writeProblem(res, pd);
    };
  }

  public AccessDeniedHandler accessDeniedHandler() {
    return (req, res, ex) -> {
      ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.FORBIDDEN);
      pd.setTitle("접근 권한이 없습니다");
      pd.setDetail("요청한 작업을 수행할 수 없습니다");
      pd.setType(URI.create("https://api.example.com/problems/forbidden"));
      pd.setInstance(URI.create(req.getRequestURI()));
      pd.setProperty("code", ErrorCode.E403_FORBIDDEN.name());
      pd.setProperty("traceId", Optional.ofNullable(MDC.get("traceId")).orElse("-"));
      writeProblem(res, pd);
    };
  }

  private void writeProblem(HttpServletResponse res, ProblemDetail pd) throws IOException {
    res.setStatus(pd.getStatus());
    res.setContentType("application/problem+json;charset=UTF-8");
    res.getWriter().write(new ObjectMapper().writeValueAsString(pd));
  }
}

7) 클라이언트 가이드(합의 문서)

  • status는 HTTP 코드 그대로 사용
  • code로 세부 분기(UX 메시지/재시도/로그인 이동 등)
  • traceId를 서버에 신고하면 1분 내 원인 추적 가능
  • 검증 에러는 errors[]에 필드별 메시지 배열로 제공

8) 체크리스트

  • 모든 예외가 Problem Details로 떨어지는지(로컬 4xx/5xx 시나리오 테스트)
  • 로그 레벨/형식 분리(보안 감사 로그 별도 파일 추천)
  • 스키마(필드/타입) 문서화: type/code/traceId/errors 의미

이 구조를 도입하면 서버/클라이언트/운영이 동일한 언어로 문제를 공유할 수 있습니다. “에러가 났다”가 아니라 “E409_CONFLICT로 상태 충돌, traceId=...” 같은 대화가 됩니다.