RFC 7807 Problem Details란?
API에서 에러 응답 형식이 제각각이면 클라이언트 개발이 어려워집니다. RFC 7807(Problem Details for HTTP APIs)은 에러 응답의 표준 JSON 형식을 정의합니다. Spring Framework 6(Spring Boot 3)부터 ProblemDetail 클래스를 내장 지원하여, 별도 라이브러리 없이 RFC 7807 준수 에러 응답을 쉽게 구현할 수 있습니다.
// RFC 7807 표준 응답 형식
{
"type": "https://api.example.com/errors/insufficient-balance",
"title": "Insufficient Balance",
"status": 422,
"detail": "계좌 잔액이 부족합니다. 현재 잔액: 5,000원, 요청 금액: 10,000원",
"instance": "/api/transfers/txn-12345"
}
표준 필드 설명:
| 필드 | 타입 | 설명 |
|---|---|---|
| type | URI | 에러 유형 식별 URI (문서 링크) |
| title | String | 사람이 읽을 수 있는 에러 요약 |
| status | Integer | HTTP 상태 코드 |
| detail | String | 구체적인 에러 설명 |
| instance | URI | 에러가 발생한 요청 경로 |
Spring Boot 3에서 활성화
application.yml에 한 줄만 추가하면 Spring의 기본 예외(404, 405 등)가 자동으로 ProblemDetail 형식으로 반환됩니다:
# application.yml
spring:
mvc:
problemdetails:
enabled: true # RFC 7807 활성화
// 활성화 전 (기존 Spring 에러 응답)
{
"timestamp": "2026-03-19T17:00:00.000+00:00",
"status": 404,
"error": "Not Found",
"path": "/api/orders/999"
}
// 활성화 후 (RFC 7807)
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"instance": "/api/orders/999"
}
커스텀 예외와 ProblemDetail 매핑
비즈니스 예외를 ProblemDetail로 변환하는 핵심 패턴입니다. @ControllerAdvice와 ResponseEntityExceptionHandler를 확장합니다:
// 커스텀 비즈니스 예외
public class InsufficientBalanceException extends RuntimeException {
private final BigDecimal currentBalance;
private final BigDecimal requestedAmount;
private final String accountId;
public InsufficientBalanceException(
String accountId,
BigDecimal currentBalance,
BigDecimal requestedAmount) {
super("Insufficient balance for account: " + accountId);
this.accountId = accountId;
this.currentBalance = currentBalance;
this.requestedAmount = requestedAmount;
}
// getters
}
public class OrderNotFoundException extends RuntimeException {
private final String orderId;
public OrderNotFoundException(String orderId) {
super("Order not found: " + orderId);
this.orderId = orderId;
}
// getter
}
@ControllerAdvice
public class GlobalExceptionHandler
extends ResponseEntityExceptionHandler {
private static final String ERROR_BASE_URI =
"https://api.example.com/errors/";
@ExceptionHandler(InsufficientBalanceException.class)
public ProblemDetail handleInsufficientBalance(
InsufficientBalanceException ex,
HttpServletRequest request) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.UNPROCESSABLE_ENTITY,
String.format("현재 잔액: %s원, 요청 금액: %s원",
ex.getCurrentBalance(), ex.getRequestedAmount())
);
problem.setType(URI.create(
ERROR_BASE_URI + "insufficient-balance"));
problem.setTitle("Insufficient Balance");
problem.setInstance(URI.create(request.getRequestURI()));
// 확장 필드 추가 (RFC 7807은 커스텀 필드 허용)
problem.setProperty("accountId", ex.getAccountId());
problem.setProperty("currentBalance",
ex.getCurrentBalance());
problem.setProperty("requestedAmount",
ex.getRequestedAmount());
problem.setProperty("timestamp", Instant.now());
return problem;
}
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleOrderNotFound(
OrderNotFoundException ex,
HttpServletRequest request) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setType(URI.create(
ERROR_BASE_URI + "order-not-found"));
problem.setTitle("Order Not Found");
problem.setInstance(URI.create(request.getRequestURI()));
problem.setProperty("orderId", ex.getOrderId());
return problem;
}
}
응답 예시:
// POST /api/transfers → 422
{
"type": "https://api.example.com/errors/insufficient-balance",
"title": "Insufficient Balance",
"status": 422,
"detail": "현재 잔액: 5000원, 요청 금액: 10000원",
"instance": "/api/transfers",
"accountId": "ACC-001",
"currentBalance": 5000,
"requestedAmount": 10000,
"timestamp": "2026-03-19T17:00:00Z"
}
Validation 에러를 ProblemDetail로 변환
Bean Validation 실패 시 필드별 에러를 ProblemDetail에 포함하는 패턴:
@ControllerAdvice
public class GlobalExceptionHandler
extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
"입력값 검증에 실패했습니다.");
problem.setType(URI.create(
ERROR_BASE_URI + "validation-error"));
problem.setTitle("Validation Failed");
// 필드별 에러 목록
List<Map<String, String>> fieldErrors = ex
.getBindingResult()
.getFieldErrors()
.stream()
.map(fe -> Map.of(
"field", fe.getField(),
"message", fe.getDefaultMessage(),
"rejectedValue",
String.valueOf(fe.getRejectedValue())
))
.toList();
problem.setProperty("errors", fieldErrors);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}
}
// POST /api/orders (잘못된 입력) → 400
{
"type": "https://api.example.com/errors/validation-error",
"title": "Validation Failed",
"status": 400,
"detail": "입력값 검증에 실패했습니다.",
"errors": [
{
"field": "customerName",
"message": "이름은 필수입니다",
"rejectedValue": "null"
},
{
"field": "totalAmount",
"message": "금액은 0보다 커야 합니다",
"rejectedValue": "-100"
}
]
}
ErrorResponse 인터페이스 활용
예외 클래스 자체에 ErrorResponse 인터페이스를 구현하면 @ControllerAdvice 없이도 ProblemDetail이 자동 반환됩니다:
public class ResourceNotFoundException extends RuntimeException
implements ErrorResponse {
private final String resourceType;
private final String resourceId;
public ResourceNotFoundException(
String resourceType, String resourceId) {
super(resourceType + " not found: " + resourceId);
this.resourceType = resourceType;
this.resourceId = resourceId;
}
@Override
public HttpStatusCode getStatusCode() {
return HttpStatus.NOT_FOUND;
}
@Override
public ProblemDetail getBody() {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
getStatusCode(), getMessage());
problem.setType(URI.create(
"https://api.example.com/errors/resource-not-found"));
problem.setTitle("Resource Not Found");
problem.setProperty("resourceType", resourceType);
problem.setProperty("resourceId", resourceId);
return problem;
}
}
// 컨트롤러에서 그냥 throw만 하면 자동으로 ProblemDetail 반환
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable String id) {
return orderRepository.findById(id)
.orElseThrow(() ->
new ResourceNotFoundException("Order", id));
}
Content-Type과 클라이언트 호환
RFC 7807 응답의 Content-Type은 application/problem+json입니다. Spring Jackson 직렬화와 함께 설정하면 클라이언트가 에러 응답을 정확히 파싱할 수 있습니다:
// 클라이언트(RestClient)에서 ProblemDetail 파싱
@Component
public class OrderApiClient {
private final RestClient restClient;
public Order getOrder(String id) {
return restClient.get()
.uri("/api/orders/{id}", id)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (req, res) -> {
ProblemDetail problem = res.bodyTo(ProblemDetail.class);
throw new ApiException(
problem.getTitle(),
problem.getDetail(),
problem.getStatus()
);
})
.body(Order.class);
}
}
국제화(i18n) 지원
ProblemDetail의 title/detail을 다국어로 제공하는 패턴:
@ControllerAdvice
public class I18nExceptionHandler
extends ResponseEntityExceptionHandler {
private final MessageSource messageSource;
@ExceptionHandler(InsufficientBalanceException.class)
public ProblemDetail handleInsufficientBalance(
InsufficientBalanceException ex,
HttpServletRequest request,
Locale locale) {
String detail = messageSource.getMessage(
"error.insufficient.balance",
new Object[]{ex.getCurrentBalance(),
ex.getRequestedAmount()},
locale);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.UNPROCESSABLE_ENTITY, detail);
problem.setTitle(messageSource.getMessage(
"error.insufficient.balance.title", null, locale));
return problem;
}
}
# messages_ko.properties
error.insufficient.balance.title=잔액 부족
error.insufficient.balance=현재 잔액: {0}원, 요청 금액: {1}원
# messages_en.properties
error.insufficient.balance.title=Insufficient Balance
error.insufficient.balance=Current balance: {0}, Requested: {1}
마치며
Spring ProblemDetail은 에러 응답을 표준화하는 가장 간결한 방법입니다. spring.mvc.problemdetails.enabled=true 한 줄로 기본 에러가 RFC 7807 형식이 되고, ErrorResponse 인터페이스를 구현하면 @ControllerAdvice 없이도 동작합니다. 확장 필드로 비즈니스 컨텍스트를 포함하고, Validation 에러 매핑과 i18n까지 적용하면 클라이언트 친화적인 API를 구현할 수 있습니다.