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=...” 같은 대화가 됩니다.
'Java & Spring' 카테고리의 다른 글
| Spring Cache 심화: Caffeine + Redis 하이브리드 캐시로 API 10배 최적화하는 실전 전략 (0) | 2025.12.05 |
|---|---|
| REST API 에러 코드 규격: RFC 7807 Problem Details로 한 번에 정리(Spring Boot 실전) (0) | 2025.10.25 |
| 리소스 서버 분리 & API Gateway 연동(Spring Cloud Gateway) (0) | 2025.10.15 |
| CORS·XSS·헤더 보안: SPA/REST 현실 설정 (0) | 2025.10.14 |
| 권한 모델링: ROLE vs 권한 문자열, @PreAuthorize, 도메인 권한 (0) | 2025.10.14 |