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 상태라도 유형별
type과code를 고정(문서화). - 노출: 내부 예외 메시지는 숨기고 대체 메시지 제공. 민감 정보(쿼리/경로, 스택트레이스) 노출 금지.
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다국어가 필요하면 메시지 소스로 변환(단,code와type은 공용).
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를 표준으로 삼으면, 팀 규모가 커져도 에러 경험은 항상 동일합니다. 오늘 전역 핸들러를 도입하고, 코드/타입 카탈로그를 문서화하세요. 내일의 디버깅 속도가 달라집니다.
'Java & Spring' 카테고리의 다른 글
| 성능 최적화: @Transactional(readOnly)와 JDBC Fetch 튜닝으로 DB 부하 60% 줄이는 실전 전략 (0) | 2025.12.07 |
|---|---|
| Spring Cache 심화: Caffeine + Redis 하이브리드 캐시로 API 10배 최적화하는 실전 전략 (0) | 2025.12.05 |
| REST API 에러 코드 규격: 문제 상세(Problem Details)로 정리 (0) | 2025.10.18 |
| 리소스 서버 분리 & API Gateway 연동(Spring Cloud Gateway) (0) | 2025.10.15 |
| CORS·XSS·헤더 보안: SPA/REST 현실 설정 (0) | 2025.10.14 |