본문 바로가기
Java & Spring

REST API 에러 코드 규격: RFC 7807 Problem Details로 한 번에 정리(Spring Boot 실전)

by yamoojin83 2025. 10. 25.

REST API 에러 코드 규격: RFC 7807 Problem Details로 한 번에 정리(Spring Boot 실전)

API가 커질수록 에러 응답의 일관성이 중요해집니다. 팀/서비스가 바뀔 때마다 형식이 달라지면 클라이언트와 운영이 고통받죠. 표준인 RFC 7807: Problem Details를 채택하면 title, status, detail, type, instance 같은 공통 필드로 사람과 머신 모두 읽기 쉬운 에러를 제공할 수 있습니다. 이 글은 Spring Boot 3.x 기준으로 설계 원칙 → 필드 약속 → 에러 코드 전략 → 전역 핸들러 구현 → 테스트/운영 팁까지 정리합니다.


1) 왜 Problem Details인가

  • 표준: RFC 7807에 정의된 JSON(+XML) 구조.
  • 일관성: 모든 에러가 같은 뼈대 → 클라이언트 파싱/로깅/알림 자동화가 쉬움.
  • 확장성: 도메인 필드를 자유롭게 추가(예: code, errors[], traceId).
{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Validation failed",
  "status": 400,
  "detail": "email must be a valid address",
  "instance": "/v1/users",
  "code": "USR_400_001",
  "errors": [
    {"field":"email", "message":"must be a well-formed email address"}
  ],
  "traceId": "f6f94c3e5a3d"
}

2) 필드 약속(팀 표준)

  • type: 문제 유형의 설명 문서 URL. 정적 페이지(스펙/가이드)나 API 문서로 연결.
  • title: 짧은 요약(영어 또는 서비스 언어, 내부/외부 정책에 맞춤).
  • status: HTTP 상태 코드(정수).
  • detail: 인간을 위한 상세 메시지(개발/운영 로그와 동일할 필요 없음).
  • instance: 문제가 발생한 리소스 경로(요청 경로).
  • code: 서비스 고유 에러코드(검색/대시보드/알람에 필수).
  • errors: 필드 단위 오류 목록(검증 에러 등).
  • traceId: 분산 트레이싱/로그 상관용 ID(MDC에서 주입).

3) 에러 코드 정책

  • 형식: 도메인_상태_번호(예: USR_400_001, AUTH_401_002).
  • 매핑: 같은 HTTP 상태라도 유형별 typecode를 고정(문서화).
  • 노출: 내부 예외 메시지는 숨기고 대체 메시지 제공. 민감 정보(쿼리/경로, 스택트레이스) 노출 금지.

4) Spring Boot 전역 핸들러 구현

4-1) DTO: ProblemDetails 확장(권장)

package com.example.api.error;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;

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

public class ApiProblem extends ProblemDetail {
  public static ApiProblem of(HttpStatus status, String title, String type, String detail) {
    ApiProblem p = (ApiProblem) ProblemDetail.forStatusAndDetail(status, detail);
    p.setTitle(title);
    p.setType(URI.create(type));
    return p;
  }
  public ApiProblem code(String code) {
    this.setProperty("code", code);
    return this;
  }
  public ApiProblem errors(List<Map<String,Object>> errors) {
    this.setProperty("errors", errors);
    return this;
  }
  public ApiProblem traceId(String traceId) {
    this.setProperty("traceId", traceId);
    return this;
  }
}

4-2) 예외 계층

package com.example.api.error;

public abstract class ApiException extends RuntimeException {
  private final String code;
  private final String type;
  private final int status;

  protected ApiException(String message, String code, String type, int status) {
    super(message);
    this.code = code;
    this.type = type;
    this.status = status;
  }
  public String getCode() { return code; }
  public String getType() { return type; }
  public int getStatus() { return status; }
}

// 구체 예외
public class ResourceNotFoundException extends ApiException {
  public ResourceNotFoundException(String msg) {
    super(msg, "USR_404_001",
      "https://api.example.com/problems/resource-not-found", 404);
  }
}

public class ValidationFailedException extends ApiException {
  public ValidationFailedException(String msg) {
    super(msg, "USR_400_001",
      "https://api.example.com/problems/validation-error", 400);
  }
}

4-3) @ControllerAdvice 전역 처리

package com.example.api.error;

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

import java.util.List;
import java.util.Map;

@ControllerAdvice
public class GlobalExceptionHandler {

  @ExceptionHandler(ApiException.class)
  public ApiProblem handleApi(ApiException ex, HttpServletRequest req) {
    String traceId = MDC.get("traceId");
    ApiProblem p = ApiProblem.of(HttpStatus.valueOf(ex.getStatus()),
        HttpStatus.valueOf(ex.getStatus()).getReasonPhrase(),
        ex.getType(), ex.getMessage())
      .code(ex.getCode())
      .traceId(traceId);
    p.setInstance(req.getRequestURI());
    return p;
  }

  @ExceptionHandler(MethodArgumentNotValidException.class)
  public ApiProblem handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) {
    List<Map<String, Object>> errors = ex.getBindingResult().getFieldErrors().stream()
      .map(fe -> Map.of("field", fe.getField(), "message", fe.getDefaultMessage()))
      .toList();

    ApiProblem p = ApiProblem.of(HttpStatus.BAD_REQUEST,
        "Validation failed",
        "https://api.example.com/problems/validation-error",
        "Request validation failed")
      .code("USR_400_001")
      .errors(errors)
      .traceId(MDC.get("traceId"));
    p.setInstance(req.getRequestURI());
    return p;
  }

  @ExceptionHandler(Exception.class)
  public ApiProblem handleUnknown(Exception ex, HttpServletRequest req) {
    // 내부 메시지는 로깅, 외부 detail은 일반화
    ApiProblem p = ApiProblem.of(HttpStatus.INTERNAL_SERVER_ERROR,
        "Internal Server Error",
        "https://api.example.com/problems/internal-error",
        "Unexpected error occurred")
      .code("SYS_500_000")
      .traceId(MDC.get("traceId"));
    p.setInstance(req.getRequestURI());
    return p;
  }
}

4-4) traceId 주입(MDC)

// 예: OncePerRequestFilter 등록
public class TraceIdFilter extends OncePerRequestFilter {
  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws ServletException, IOException {
    String traceId = Optional.ofNullable(request.getHeader("X-Request-Id"))
      .filter(s -> !s.isBlank())
      .orElse(UUID.randomUUID().toString().replace("-", ""));
    MDC.put("traceId", traceId);
    try {
      response.setHeader("X-Trace-Id", traceId);
      chain.doFilter(request, response);
    } finally {
      MDC.remove("traceId");
    }
  }
}

5) Validation(Bean Validation)와 연동

@Valid + @NotBlank/@Email 등의 제약을 사용하면 MethodArgumentNotValidException이 발생하고, 위 전역 핸들러가 필드별 에러를 errors[]로 내려줍니다.

public record SignUpRequest(
  @NotBlank String name,
  @Email String email
) { }
@PostMapping("/v1/users")
public UserResponse signUp(@Valid @RequestBody SignUpRequest req) {
  // ...
}

6) 클라이언트/문서화를 위한 type URL

type문서로 연결되는 URL이면 가장 좋습니다. 간단한 마크다운/정적 페이지라도 각 문제 유형의 의미/해결법/샘플 응답을 넣어 두면 프론트/외부 파트너 협업이 쉬워집니다.

https://api.example.com/problems/validation-error
https://api.example.com/problems/resource-not-found
https://api.example.com/problems/internal-error

7) 통합 테스트(예시)

@SpringBootTest
@AutoConfigureMockMvc
class ErrorSpec {
  @Autowired MockMvc mvc;

  @Test
  void validation() throws Exception {
    mvc.perform(post("/v1/users")
        .contentType(MediaType.APPLICATION_JSON)
        .content("{\"name\":\"\",\"email\":\"oops\"}"))
      .andExpect(status().isBadRequest())
      .andExpect(jsonPath("$.type").value("https://api.example.com/problems/validation-error"))
      .andExpect(jsonPath("$.code").value("USR_400_001"))
      .andExpect(jsonPath("$.errors").isArray());
  }
}

8) 운영 팁

  • 로깅: 서버 내부 로그에는 스택트레이스/원인 포함, 외부 응답의 detail은 안전하게 축약.
  • 관찰성: code 기준으로 대시보드/알람을 만들면 장애 감지가 빨라집니다.
  • i18n: title/detail 다국어가 필요하면 메시지 소스로 변환(단, codetype은 공용).

9) Nginx/프록시와의 협업

프록시(예: Nginx)에서 413/502/504가 날 때도 가능하면 백엔드로 라우팅해 통일된 ProblemDetails를 내보내는 것이 이상적입니다. 불가피한 프록시 레벨 에러는 error_page로 전용 JSON 템플릿을 내려도 좋습니다(운영 정책에 맞게).

# 예시: Nginx 413 전용 JSON(단순 케이스)
error_page 413 /errors/413.json;
location = /errors/413.json {
  default_type application/problem+json;
  return 413 '{"type":"https://api.example.com/problems/request-too-large",
               "title":"Request entity too large",
               "status":413,"detail":"Payload too large"}';
}

10) 최종 체크리스트

  • 전역 핸들러(@ControllerAdvice)로 모든 예외를 ProblemDetails로 변환
  • code/type 카탈로그 문서화, 문서 URL 연결
  • 검증 에러는 errors[]에 필드 단위 제공
  • MDC traceId를 응답/로그에 함께 노출
  • 민감 정보는 숨기고, 내부 로그에만 상세 원인 보관

Problem Details를 표준으로 삼으면, 팀 규모가 커져도 에러 경험은 항상 동일합니다. 오늘 전역 핸들러를 도입하고, 코드/타입 카탈로그를 문서화하세요. 내일의 디버깅 속도가 달라집니다.