커스텀 에러 포맷을 RFC 9457 ProblemDetail로 표준화하며 고민한 것들
code·message로 만든 커스텀 에러 응답을 Spring의 ProblemDetail(RFC 9457) 표준으로 옮기면서, 클라이언트 호환과 확장 필드 설계를 어떻게 정리했는지 기록했습니다.

예전에 REST API 예외 처리를 정리하면서, 실패 응답을 code와 message 두 필드로 고정한 적이 있습니다.
그때는 그게 충분히 깔끔하다고 생각했습니다. 프론트는 code로 분기하고, message는 안내 문구로 쓰면 됐으니까요. 그 과정은 Rest API 예외 처리 설계에 따로 적어 두었습니다.
그런데 시간이 지나니 이 "우리만의 포맷"이 슬슬 걸리기 시작했습니다. 외부에 API를 열고, 다른 서비스와 연동이 생기고, 문서를 넘겨줄 일이 늘어나면서였습니다. 상대방이 매번 "이 에러 바디는 어떤 구조예요?"라고 묻는 상황이 반복됐습니다.
그러다 Spring이 이미 ProblemDetail이라는 표준 에러 바디를 지원한다는 걸 다시 보게 됐습니다. RFC 9457(구 RFC 7807)로 규격화된 포맷이었습니다. "이왕 계약을 정할 거면 우리가 새로 발명하지 말고 표준을 쓰자"는 생각으로 마이그레이션을 시작했고, 그 과정에서 고민한 것들을 정리했습니다.
RFC 9457이 정해둔 필드부터 봤다
RFC 9457은 에러 응답 바디에 들어갈 표준 필드를 정의합니다. Spring에서는 org.springframework.http.ProblemDetail 클래스가 이 규격을 그대로 담습니다.
| 필드 | 의미 |
|---|---|
type | 문제 유형을 식별하는 URI. 설정하지 않으면 about:blank로 간주됩니다 |
title | 문제 유형에 대한 짧은 사람용 요약 |
status | HTTP 상태 코드 |
detail | 이번 발생 건에 대한 사람용 설명 |
instance | 이 문제가 발생한 구체적 위치를 가리키는 URI |
기본 응답을 만들어보면 아래처럼 나갑니다. 미디어 타입도 일반 JSON이 아니라 application/problem+json으로 나가는 게 특징입니다.
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "요청 값을 다시 확인해주세요.",
"instance": "/orders"
}여기서 type을 안 넣으면 about:blank, title을 안 넣으면 상태 코드의 표준 사유 문구(Bad Request 등)가 채워집니다. instance는 별도로 세팅하지 않아도 프레임워크가 현재 요청 경로로 채워줬습니다. 처음엔 이 자동 채움이 어색했는데, 로그에서 어떤 경로가 실패했는지 바로 보여서 나중엔 오히려 편했습니다.
우리 포맷과 뭐가 다른지 표로 정리했다
옮기기 전에 기존 포맷과 무엇이 달라지는지부터 맞춰봤습니다.
| 항목 | 변경 전 (커스텀) | 변경 후 (ProblemDetail) |
|---|---|---|
| 바디 필드 | code, message | type, title, status, detail, instance |
| 상태 코드 위치 | HTTP status에만 존재 | HTTP status + 바디 status 양쪽 |
| 미디어 타입 | application/json | application/problem+json |
| 표준 준수 | 우리 팀 규칙 | RFC 9457 표준 |
| 도구 호환 | 문서로 설명 필요 | 표준 인지 클라이언트가 바로 해석 |
| 확장 필드 | 자유롭게 추가 | properties로 추가 |
표를 만들고 나서 한 가지가 분명해졌습니다. 우리가 쓰던 code는 표준 필드에 없다는 점이었습니다. 이게 마이그레이션에서 가장 오래 고민한 부분이 됐습니다. 뒤에서 다시 다루겠습니다.
내장 예외까지 표준으로 내리는 스위치
Spring Boot에는 내장 예외(예: HttpRequestMethodNotSupportedException)를 자동으로 ProblemDetail로 내려주는 스위치가 있습니다. 바로 spring.mvc.problemdetails.enabled 프로퍼티인데, 기본값이 false라 켜기 전까지는 동작하지 않습니다.
spring:
mvc:
problemdetails:
enabled: true # 기본값 false. 내장 예외도 RFC 9457로 응답이걸 켜면 Spring이 내부적으로 ResponseEntityExceptionHandler 기반 핸들러를 자동 등록합니다(핸들러 순서 0). 그래서 우리가 미처 잡지 못한 405, 415 같은 예외들도 제각각이던 기본 화이트라벨 응답 대신 표준 포맷으로 나갔습니다.
한 가지 주의할 점은, 특정 내장 예외의 응답을 우리가 직접 바꾸고 싶다면 Spring 기본 핸들러보다 앞선 순서의 @ControllerAdvice를 따로 둬야 한다는 것이었습니다. 기본 핸들러가 순서 0이라, 그냥 두면 우리 커스터마이징이 먹지 않는 경우가 있었습니다.
@ExceptionHandler에서 ProblemDetail을 직접 반환했다
가장 먼저 바꾼 건 기존 전역 핸들러였습니다. 예전에는 커스텀 ErrorResponse 레코드를 ResponseEntity에 담아 내렸는데, 이제는 @ExceptionHandler 메서드가 ProblemDetail을 그대로 반환하도록 고쳤습니다. ProblemDetail을 반환하면 Spring이 상태 코드와 application/problem+json 처리를 알아서 해줍니다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ProblemDetail handleBusiness(BusinessException e) {
ErrorCode errorCode = e.getErrorCode();
// forStatusAndDetail(HttpStatusCode, String)
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
errorCode.getStatus(), errorCode.getMessage());
problem.setType(URI.create("https://api.srue.kr/problems/order-not-enough-stock"));
problem.setTitle("Business Rule Violation");
// 표준에 없는 우리 code는 확장 필드로 (아래에서 설명)
problem.setProperty("code", errorCode.getCode());
log.warn("business-exception code={}, message={}",
errorCode.getCode(), e.getMessage());
return problem;
}
}ProblemDetail.forStatusAndDetail(...)로 상태와 detail을 세팅하고, setType/setTitle로 문제 유형을 채웠습니다. 기존 글에서 만든 ErrorCode enum과 BusinessException은 그대로 살렸습니다. 즉 서비스 레이어 코드는 손대지 않고, 응답을 만드는 마지막 단계만 표준으로 바꾼 셈이었습니다.
사라진 code는 확장 필드로 살렸다
표준 다섯 필드에는 우리가 쓰던 code가 없습니다. 그렇다고 code를 버리면 프론트의 모든 에러 분기 로직을 다시 짜야 했습니다. RFC 9457은 이런 상황을 위해 표준 필드 외의 확장(extension) 멤버를 허용합니다. Spring에서는 setProperty(String, Object)로 넣습니다.
problem.setProperty("code", "ORDER-409");Jackson을 쓰면 ProblemDetailJacksonMixin이 이 properties 맵을 풀어서 최상위 JSON 필드로 렌더링해 줍니다. 그래서 응답은 이렇게 나갔습니다.
{
"type": "https://api.srue.kr/problems/order-not-enough-stock",
"title": "Business Rule Violation",
"status": 409,
"detail": "재고가 부족합니다.",
"instance": "/orders",
"code": "ORDER-409"
}이 지점이 마이그레이션의 핵심이었습니다. 표준 필드는 표준대로 채우되, 기존 클라이언트가 의존하던 code는 확장 필드로 남겨서 양쪽을 동시에 만족시켰습니다. 프론트는 급하지 않게 표준 필드로 옮겨갈 수 있고, 기존 code 분기는 당분간 그대로 동작했습니다.
Validation 에러는 errors 배열로 확장했다
입력값 검증 실패는 필드별 오류를 여러 개 내려줘야 해서, detail 문장 하나로는 부족했습니다. 그래서 errors라는 확장 필드에 배열로 담았습니다.
MethodArgumentNotValidException 같은 Spring MVC 예외는 이미 ErrorResponse 인터페이스를 구현하고 있어서, getBody()로 완성된 ProblemDetail을 얻을 수 있었습니다. 여기에 errors만 얹으면 됐습니다.
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
ProblemDetail problem = ex.getBody(); // ErrorResponse가 제공하는 ProblemDetail
List<Map<String, String>> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(fe -> Map.of(
"field", fe.getField(),
"message", fe.getDefaultMessage() == null ? "" : fe.getDefaultMessage()))
.toList();
problem.setProperty("code", "COMMON-400");
problem.setProperty("errors", errors);
return handleExceptionInternal(ex, problem, headers, status, request);
}
}핵심은 전역 핸들러가 ResponseEntityExceptionHandler를 상속했다는 점입니다. 이 클래스는 Spring MVC 내장 예외와 모든 ErrorResponse 예외를 표준 바디로 처리하는 베이스라, 우리는 필요한 메서드만 오버라이드해서 확장 필드를 끼워 넣으면 됐습니다. 결과 응답은 아래처럼 나갔습니다.
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request content.",
"instance": "/orders",
"code": "COMMON-400",
"errors": [
{ "field": "quantity", "message": "1 이상이어야 합니다." }
]
}커스텀 예외는 ErrorResponseException으로 옮겨봤다
핸들러에서 매번 ProblemDetail을 조립하는 대신, 예외 자체가 응답 정보를 들고 있게 만드는 방법도 있었습니다. ErrorResponseException을 상속하면 됩니다. 이 클래스는 ErrorResponse의 기본 구현이라, 상속만 해두면 별도 핸들러 없이도 표준 응답으로 나갑니다.
public class OrderNotFoundException extends ErrorResponseException {
public OrderNotFoundException(Long orderId) {
super(HttpStatus.NOT_FOUND); // 생성자에 HttpStatusCode
setDetail("주문을 찾을 수 없습니다. id=" + orderId);
setType(URI.create("https://api.srue.kr/problems/order-not-found"));
getBody().setProperty("code", "ORDER-404");
}
}setDetail, setType, setInstance, setTitle은 ErrorResponseException이 그대로 노출하고, 확장 필드는 getBody().setProperty(...)로 넣었습니다. 다만 여기서 한 번 헷갈렸던 게 있는데, ErrorResponseException에는 setDetailMessageCode 같은 메시지 코드 세터가 없고 생성자 인자로만 넘길 수 있습니다. 처음엔 세터로 넣으려다 컴파일이 안 돼서 문서를 다시 봤습니다.
다만 모든 예외를 ErrorResponseException으로 바꾸진 않았습니다. 기존 BusinessException + ErrorCode 체계가 이미 잘 돌고 있어서, 그건 전역 핸들러에서 ProblemDetail로 변환하는 방식으로 두고, 새로 만드는 도메인 예외만 이 방식을 써봤습니다.
type URI와 title, 그리고 다국어
type은 단순 문자열이 아니라 URI입니다. 처음엔 about:blank로 대충 두려다가, 이왕이면 문제 유형별로 문서 페이지를 가리키게 하자 싶었습니다. 그래서 https://api.srue.kr/problems/... 형태로 정하고, 그 경로에 각 에러의 원인과 해결법을 적는 방향으로 잡았습니다. 아직 문서 페이지는 준비 중입니다.
title과 detail은 다국어도 신경 썼습니다. ResponseEntityExceptionHandler는 내장 예외의 title/detail 메시지 코드를 MessageSource로 해석해서 ProblemDetail에 채워줍니다. 예를 들어 메시지 코드는 problemDetail.title.[예외 FQN], problemDetail.[예외 FQN] 형태라, 리소스 번들에 문구를 넣어두면 로케일에 맞게 바뀌었습니다. 다만 우리 서비스는 아직 한국어 위주라, 이 부분은 확장 여지만 확인해두고 깊게 파진 않았습니다.
클라이언트 호환은 결국 이중 운영으로 풀었다
마이그레이션에서 가장 신경 쓰인 건 기술이 아니라 호환이었습니다. 이미 배포된 앱과 외부 연동은 예전 { code, message } 바디를 기대하고 있었기 때문입니다. 바디 구조를 하루아침에 바꾸면 그쪽이 깨집니다.
그래서 택한 방식이 앞서 말한 확장 필드였습니다. 표준 필드를 새로 채우되 code는 그대로 유지해서, 기존 클라이언트의 code 분기는 살아 있게 했습니다. 응답 형식 자체가 계약이라는 건 Rest API Response 구조 설계에서도 느꼈던 부분이라, 이번에도 "필드를 빼는 변경"만은 피하려 했습니다. 필드는 더할 수 있어도, 빼는 순간 그건 깨는 변경(breaking change)이니까요.
전환 순서는 대략 이렇게 잡았습니다.
- 실패 응답에 표준 필드를 추가하되 기존
code는 유지 (더하기만) - 프론트가 표준 필드(
status,detail,type)로 점진 이전 - 확장
code의존이 사라진 걸 확인한 뒤에야 제거 검토
마무리
이번 작업은 새 기능을 만든 게 아니라, 우리가 만들었던 규칙을 표준으로 바꾼 일이었습니다. 처음엔 "잘 돌던 code/message를 왜 굳이 건드리나" 싶은 마음도 있었습니다.
그런데 정리하고 나니, 에러 응답도 결국 남들과 맞춰 쓰는 계약이라는 걸 다시 느꼈습니다. 우리만 아는 포맷은 우리끼리는 편하지만, 경계를 넘는 순간 설명 비용이 됩니다. ProblemDetail은 그 설명을 표준에 위임하게 해줬습니다.
그리고 표준으로 옮길 때도, 기존 클라이언트를 한 번에 버리지 않고 확장 필드로 다리를 놓아 천천히 건너가는 게 중요하다고 느꼈습니다. 표준을 따르는 일과 쓰던 사람을 배려하는 일은 확장 필드 하나로 같이 갈 수 있었습니다.