ProblemDetail - Spring Boot 4의 표준 에러 응답
API 에러 응답의 현실
팀마다, 서비스마다 에러 응답 형식이 다르다.
// A 서비스
{ "error": "not found", "code": 404 }
// B 서비스
{ "message": "사용자를 찾을 수 없습니다", "status": "FAIL" }
// C 서비스
{ "err_msg": "NOT_FOUND", "result": false, "data": null }
클라이언트 입장에서는 서비스마다 에러 파싱 로직을 따로 만들어야 한다. MSA 환경에서 서비스가 10개면 에러 처리 코드도 10벌이다.
이 문제를 해결하기 위해 IETF가 HTTP API 에러 응답 표준을 만들었다.
RFC 9457 — Problem Details for HTTP APIs
RFC 7807(2016)로 처음 제안되었고, RFC 9457(2023)로 개정되었다. 핵심은 간단하다. 에러 응답의 JSON 필드 이름과 의미를 통일하자.
표준 필드
{
"type": "https://api.example.com/errors/user-not-found",
"title": "User Not Found",
"status": 404,
"detail": "사용자 ID 42를 찾을 수 없습니다",
"instance": "/api/users/42"
}
| 필드 | 의미 | 예시 |
|---|---|---|
type |
에러 종류를 식별하는 URI. 해당 URI에 에러 설명 문서가 있으면 좋다 | https://api.example.com/errors/user-not-found |
title |
에러 종류의 짧은 요약. 사람이 읽을 수 있는 제목 | "User Not Found" |
status |
HTTP 상태 코드 | 404 |
detail |
이 요청에서 구체적으로 뭐가 잘못됐는지 | "사용자 ID 42를 찾을 수 없습니다" |
instance |
문제가 발생한 요청의 URI | "/api/users/42" |
모든 필드가 선택(optional)이다. type의 기본값은 about:blank이다.
title vs detail
이 둘의 차이가 중요하다.
title: "Insufficient Balance" ← 에러 종류 (항상 같은 문구)
detail: "잔액이 3,000원 부족합니다" ← 이 요청에서 구체적으로 뭐가 부족한지
title: "Validation Failed" ← 에러 종류
detail: "endDT는 startDT보다 이후여야 합니다" ← 이 요청에서 뭐가 틀렸는지
title은 에러 카테고리, detail은 에러 인스턴스다.
커스텀 필드 확장
표준 5개 필드 외에 자유롭게 필드를 추가할 수 있다.
{
"type": "https://api.example.com/errors/invalid-period",
"title": "Invalid Period",
"status": 400,
"detail": "시작시간이 종료시간보다 늦습니다",
"instance": "/api/downtimes",
"errorCode": "DOWNTIME_003",
"timestamp": "2026-02-25T14:30:00",
"traceId": "abc123def456"
}
errorCode, timestamp, traceId는 표준에 없는 필드지만 추가해도 된다. RFC 9457이 명시적으로 확장을 허용한다.
Content-Type
ProblemDetail 응답의 Content-Type은 application/problem+json이다. 일반 JSON 응답(application/json)과 구분할 수 있어서, 클라이언트가 에러 응답인지 정상 응답인지 Content-Type만 보고도 판단할 수 있다.
Spring Boot 4에서의 지원
Spring Framework 6에서 ProblemDetail 클래스가 추가되었고, Spring Boot 4(Spring Framework 7)에서 더 개선되었다.
활성화
# application.yml
spring:
mvc:
problemdetail:
enabled: true
이 설정을 켜면 @ExceptionHandler가 없는 예외도 자동으로 RFC 9457 형식으로 응답된다.
활성화 전후 비교
비활성화 (기본값) — TypeMismatchException 발생 시:
{
"timestamp": "2026-02-25T14:30:00.000+00:00",
"status": 400,
"error": "Bad Request",
"path": "/api/downtimes"
}
Spring Boot의 자체 포맷이다. type, title, detail이 없다.
활성화 후 — 동일한 예외:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Failed to convert 'downtimeSeq' with value: 'abc'",
"instance": "/api/downtimes"
}
RFC 9457 형식으로 바뀐다. detail에 구체적인 원인이 담긴다.
실제 적용
기본 사용
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(NoSuchElementException::class)
fun handleNotFound(ex: NoSuchElementException, request: HttpServletRequest): ProblemDetail {
val problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
ex.message ?: "리소스를 찾을 수 없습니다"
)
problem.instance = URI.create(request.requestURI)
return problem
}
}
응답:
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "다운타임 시퀀스 999를 찾을 수 없습니다",
"instance": "/api/downtimes/999"
}
ProblemDetail.forStatusAndDetail()로 생성하면 title은 HTTP 상태 코드의 reason phrase("Not Found")가 자동으로 채워진다.
ErrorCode 체계와 결합
대부분의 프로젝트에는 이미 커스텀 ErrorCode가 있다. ProblemDetail과 자연스럽게 결합할 수 있다.
interface ErrorCode {
val status: HttpStatus
val code: String
val message: String
}
enum class DowntimeErrorCode(
override val status: HttpStatus,
override val code: String,
override val message: String
) : ErrorCode {
INVALID_PERIOD(HttpStatus.BAD_REQUEST, "DOWNTIME_001", "시작시간이 종료시간보다 늦습니다"),
ALREADY_DELETED(HttpStatus.BAD_REQUEST, "DOWNTIME_002", "이미 삭제된 다운타임입니다"),
INVALID_TARGET_TYPE(HttpStatus.BAD_REQUEST, "DOWNTIME_003", "해당 서비스에서 사용할 수 없는 타겟입니다"),
}
class BusinessException(val errorCode: ErrorCode) : RuntimeException(errorCode.message)
ExceptionHandler:
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(BusinessException::class)
fun handleBusiness(ex: BusinessException, request: HttpServletRequest): ProblemDetail {
val problem = ProblemDetail.forStatusAndDetail(ex.errorCode.status, ex.message)
problem.title = ex.errorCode.code
problem.instance = URI.create(request.requestURI)
problem.setProperty("errorCode", ex.errorCode.code)
problem.setProperty("timestamp", LocalDateTime.now())
return problem
}
}
// 서비스 코드
fun deleteDowntime(downtimeSeq: Long) {
val downtime = repository.findById(downtimeSeq)
?: throw BusinessException(DowntimeErrorCode.ALREADY_DELETED)
// ...
}
응답:
{
"type": "about:blank",
"title": "DOWNTIME_002",
"status": 400,
"detail": "이미 삭제된 다운타임입니다",
"instance": "/api/downtimes/42",
"errorCode": "DOWNTIME_002",
"timestamp": "2026-02-25T14:30:00"
}
Bean Validation 에러
@Valid 검증 실패 시 어떤 필드가 왜 실패했는지 알려줘야 한다.
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(ex: MethodArgumentNotValidException, request: HttpServletRequest): ProblemDetail {
val problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
"입력값 검증에 실패했습니다"
)
problem.title = "Validation Failed"
problem.instance = URI.create(request.requestURI)
val fieldErrors = ex.bindingResult.fieldErrors.map { error ->
mapOf(
"field" to error.field,
"rejectedValue" to error.rejectedValue,
"message" to error.defaultMessage
)
}
problem.setProperty("fieldErrors", fieldErrors)
return problem
}
응답:
{
"type": "about:blank",
"title": "Validation Failed",
"status": 400,
"detail": "입력값 검증에 실패했습니다",
"instance": "/api/downtimes",
"fieldErrors": [
{
"field": "startDT",
"rejectedValue": null,
"message": "must not be null"
},
{
"field": "serviceType",
"rejectedValue": null,
"message": "must not be null"
}
]
}
traceId 자동 주입
OpenTelemetry를 사용하고 있다면 모든 에러 응답에 traceId를 넣어두면 디버깅이 쉬워진다.
@RestControllerAdvice
class GlobalExceptionHandler {
// 모든 ProblemDetail 응답에 traceId 추가
private fun ProblemDetail.withTrace(request: HttpServletRequest): ProblemDetail {
this.instance = URI.create(request.requestURI)
this.setProperty("traceId", MDC.get("traceId"))
this.setProperty("timestamp", LocalDateTime.now())
return this
}
@ExceptionHandler(BusinessException::class)
fun handleBusiness(ex: BusinessException, request: HttpServletRequest): ProblemDetail {
return ProblemDetail.forStatusAndDetail(ex.errorCode.status, ex.message)
.apply { title = ex.errorCode.code }
.withTrace(request)
}
@ExceptionHandler(NoSuchElementException::class)
fun handleNotFound(ex: NoSuchElementException, request: HttpServletRequest): ProblemDetail {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.message ?: "리소스를 찾을 수 없습니다")
.withTrace(request)
}
}
에러 발생 시 운영팀에 traceId만 전달하면 해당 요청의 전체 흐름을 추적할 수 있다.
ErrorResponse 인터페이스
예외 클래스 자체가 ProblemDetail을 만들도록 할 수도 있다. Spring의 ErrorResponse 인터페이스를 구현하면 된다.
class DowntimeNotFoundException(
val downtimeSeq: Long
) : ErrorResponseException(
HttpStatus.NOT_FOUND,
ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
"다운타임 시퀀스 ${downtimeSeq}를 찾을 수 없습니다"
).apply {
title = "Downtime Not Found"
setProperty("downtimeSeq", downtimeSeq)
},
null
)
// 서비스 코드
fun getDowntime(downtimeSeq: Long): Downtime {
return repository.findById(downtimeSeq)
?: throw DowntimeNotFoundException(downtimeSeq)
}
ErrorResponseException을 상속하면 @ExceptionHandler를 별도로 작성하지 않아도 Spring이 자동으로 ProblemDetail 응답을 생성한다.
@ExceptionHandler vs ErrorResponseException
@ExceptionHandler |
ErrorResponseException |
|
|---|---|---|
| 에러 정의 위치 | @RestControllerAdvice에 모여있음 |
예외 클래스 자체에 포함 |
| 장점 | 에러 처리 로직이 한 곳에서 관리됨 | 예외 생성 시점에 응답이 결정됨. Handler 불필요 |
| 단점 | Handler 클래스가 비대해질 수 있음 | 예외 클래스마다 응답 로직이 분산됨 |
| 권장 | BusinessException처럼 공통 패턴 | 특정 도메인 전용 예외 |
둘을 섞어 써도 된다. 공통 에러(BusinessException)는 @ExceptionHandler로, 특정 도메인 에러(DowntimeNotFoundException)는 ErrorResponseException으로 처리하면 깔끔하다.
기존 에러 응답에서 마이그레이션
이미 커스텀 에러 응답 형식을 쓰고 있다면 한 번에 바꿀 필요는 없다.
기존 형식
data class ApiErrorResponse(
val status: Int,
val code: String,
val message: String?,
val path: String
)
{ "status": 400, "code": "DOWNTIME_001", "message": "시작시간이 종료시간보다 늦습니다", "path": "/api/downtimes" }
ProblemDetail로 전환
{
"type": "about:blank",
"title": "DOWNTIME_001",
"status": 400,
"detail": "시작시간이 종료시간보다 늦습니다",
"instance": "/api/downtimes",
"errorCode": "DOWNTIME_001"
}
매핑 관계:
기존 code → ProblemDetail title + 커스텀 errorCode
기존 message → ProblemDetail detail
기존 path → ProblemDetail instance
기존 status → ProblemDetail status
클라이언트가 status, detail(구 message), errorCode(구 code)를 파싱하면 되므로 호환성 유지가 어렵지 않다. 다만 필드 이름이 바뀌므로 클라이언트 측 수정은 필요하다.
정리
문제: 서비스마다 에러 응답 형식이 제각각
→ 클라이언트가 서비스별로 에러 파싱 로직을 따로 작성
해결: RFC 9457이 표준 형식을 정의
→ type, title, status, detail, instance + 커스텀 확장
Spring 지원: ProblemDetail 클래스 기본 제공
spring.mvc.problemdetail.enabled=true 한 줄로 활성화
@ExceptionHandler에서 ProblemDetail 반환하면 끝
새 프로젝트를 시작한다면 처음부터 ProblemDetail을 도입하는 게 좋다. 별도 에러 응답 DTO를 만들 필요 없이 표준을 따르면서도 커스텀 필드를 자유롭게 확장할 수 있다.
댓글