개발에 AtoZ까지

[Spring Boot] 에러 응답을 ProblemDetail로 표준화하기 — HTTP 상태 코드 제어 2026년판 본문

백엔드

[Spring Boot] 에러 응답을 ProblemDetail로 표준화하기 — HTTP 상태 코드 제어 2026년판

AtoZ 개발자 2026. 7. 30. 09:29
반응형

🚨 API마다 에러 모양이 다 다릅니다

안녕하세요 😀

REST API를 만들다 보면 어느 순간 이런 상태가 됩니다. 어떤 컨트롤러는 {"message": "..."}를 내려보내고, 어떤 컨트롤러는 {"error": "..."}를 내려보냅니다. 정작 예외를 못 잡은 자리에서는 화이트라벨 에러 페이지가 그대로 나갑니다.

그러다 프런트엔드에서 연락이 옵니다. "에러 포맷 좀 통일해 주실 수 있을까요." 그때부터 공통 에러 응답 클래스를 직접 만들기 시작합니다.

그런데 그걸 직접 만들 필요가 없습니다. HTTP 에러 응답 본문에는 이미 표준 규격이 있고, Spring은 그걸 프레임워크 안에 넣어 뒀습니다. ProblemDetail이라는 이름입니다.

이 글에서 정리하고 갈 것은 네 가지입니다. 상태 코드와 본문을 정하는 방법을 어떻게 구분해서 쓰는지, ProblemDetail이 정확히 무슨 규격인지, 설정 한 줄로 켜는 방법, 그리고 켠 다음 사람들이 당황하는 지점입니다.

이 글은 제가 운영 중인 서비스에 적용한 기록이 아니라, Spring 공식 레퍼런스와 RFC 원문, 이슈 트래커를 기준으로 정리한 내용입니다. 확인일은 2026년 7월 28일입니다.

직전 글에서 파라미터 이름을 못 찾는 에러를 다뤘습니다. 요청을 제대로 받게 만들었으면, 그다음은 잘못된 요청에 무엇을 돌려줄 것인가입니다.


🧭 상태 코드와 본문을 정하는 방법은 네 가지입니다

2021년에 정리했던 HTTP Status Code 제어 글에서는 상태 코드 자체에 집중했습니다. 지금은 선택지가 하나 더 늘었습니다. 본문 형식까지 표준으로 정해 주는 방법이 생겼기 때문입니다.

방법 상태 코드 응답 본문 언제 쓰나
@ResponseStatus 고정 직접 만듦 예외 하나가 항상 같은 상태 코드일 때
ResponseEntity 코드에서 결정 직접 만듦 정상 응답에서 상태를 분기할 때
ResponseStatusException 던질 때 결정 프레임워크가 만듦 서비스 로직에서 바로 던지고 싶을 때
ProblemDetail / ErrorResponse status 필드가 결정 표준 형식 에러 응답 전체를 통일하고 싶을 때

앞의 세 가지는 상태 코드를 어떻게 정할지에 대한 답입니다. 본문은 여전히 개발자가 만듭니다. 그래서 팀마다 모양이 갈립니다.

네 번째가 다른 점은 본문 모양까지 규격으로 정해져 있다는 것입니다. 그래서 프런트엔드가 "이 API는 어떤 필드로 오나요"를 매번 묻지 않아도 됩니다.


📦 ProblemDetail은 RFC 9457이라는 표준입니다

Spring이 새로 만든 형식이 아닙니다. Spring MVC 레퍼런스는 이렇게 시작합니다.

The Spring Framework supports the "Problem Details for HTTP APIs" specification, RFC 9457.

여기서 RFC는 인터넷 표준 문서를 뜻합니다. 그리고 RFC 9457은 예전에 많이 인용되던 RFC 7807을 대체(obsolete)한 최신판입니다. 아직 "7807 기준"이라고 적힌 글이 많은데, 지금 Spring 문서는 9457을 가리킵니다.

표준이 정한 필드는 다섯 개입니다.

필드 RFC 9457의 정의 실무에서의 의미
type 문제 유형을 식별하는 URI 참조. 없으면 기본값은 about:blank 이 에러가 "어떤 종류"인지
title 문제 유형에 대한 짧은 사람이 읽을 수 있는 요약 사람이 보는 제목
status 원 서버가 만든 HTTP 상태 코드 400, 404, 409 등
detail 이번 발생 건에 대한 사람이 읽을 수 있는 설명 이 요청에서 구체적으로 뭐가 틀렸는지
instance 문제가 발생한 특정 지점을 식별하는 URI 참조 보통 요청 경로

응답 형태는 이렇게 나갑니다.

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Invalid request content.",
  "instance": "/api/orders"
}

Content-Type도 일반 JSON이 아닙니다. application/problem+json을 씁니다. XML이면 application/problem+xml입니다.

여기서 자주 놓치는 규칙이 하나 있습니다. RFC는 detail을 프로그램이 파싱하지 말라고 못 박습니다.

Consumers SHOULD NOT parse the "detail" member for information; extensions are more suitable and less error-prone ways to obtain such information.

즉 프런트엔드가 detail 문자열을 잘라서 분기하면 안 됩니다. 기계가 읽을 값이 필요하면 확장 필드를 따로 만들어야 합니다. 이 규칙을 알고 시작하면 나중에 응답 스펙을 다시 갈아엎는 일이 줄어듭니다.


⚡ 설정 한 줄로 켜집니다

Spring Boot에서는 기본적으로 꺼져 있습니다. 켜면 기존 에러 응답 모양이 바뀌기 때문에, 일부러 옵트인 방식으로 두었습니다.

# application.properties
spring.mvc.problemdetails.enabled=true
# application.yml
spring:
  mvc:
    problemdetails:
      enabled: true

이 한 줄을 켜면, Spring MVC가 스스로 던지는 예외들이 표준 형식으로 나가기 시작합니다. 잘못된 JSON, 타입 변환 실패, 지원하지 않는 메서드 같은 것들입니다.

가능한 이유는 프레임워크 예외들이 이미 규격을 구현해 두었기 때문입니다. 레퍼런스의 표현은 이렇습니다.

ErrorResponse — contract to expose HTTP error response details including HTTP status, response headers, and a body in the format of RFC 9457 … All Spring MVC exceptions implement this.

WebFlux를 쓰신다면 속성 이름이 다릅니다. spring.webflux.problemdetails.enabled를 켜시면 됩니다.


✍️ 내 도메인 예외도 같은 형식으로 내보내기

프레임워크 예외만 표준이 되면 반쪽입니다. 주문을 찾을 수 없음 같은 내 예외도 같은 모양으로 나가야 의미가 있습니다.

방법은 어렵지 않습니다. 레퍼런스는 이렇게 안내합니다.

You can return ProblemDetail or ErrorResponse from any @ExceptionHandler or from any @RequestMapping method to render an RFC 9457 response.

@ExceptionHandler에서 ProblemDetail을 그냥 반환하면 됩니다.

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ProblemDetail handleOrderNotFound(OrderNotFoundException e) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, "주문번호 " + e.getOrderId() + " 를 찾을 수 없습니다.");
        pd.setTitle("주문을 찾을 수 없음");
        pd.setType(URI.create("https://api.example.com/problems/order-not-found"));
        // 기계가 읽을 값은 detail이 아니라 확장 필드로
        pd.setProperty("orderId", e.getOrderId());
        pd.setProperty("timestamp", Instant.now());
        return pd;
    }
}

응답은 이렇게 나갑니다.

{
  "type": "https://api.example.com/problems/order-not-found",
  "title": "주문을 찾을 수 없음",
  "status": 404,
  "detail": "주문번호 A-1024 를 찾을 수 없습니다.",
  "instance": "/api/orders/A-1024",
  "orderId": "A-1024",
  "timestamp": "2026-07-28T00:31:00Z"
}

여기서 두 가지가 자동으로 채워집니다. status 값이 HTTP 상태 코드를 결정하고, instance를 비워 두면 현재 요청 경로로 채워집니다.

에러 문구를 다국어로 관리하고 싶다면 메시지 코드 규칙도 준비돼 있습니다. problemDetail.title.[예외 클래스 전체 이름]problemDetail.[예외 클래스 전체 이름] 형태로 프로퍼티 파일에 넣으면 MessageSource를 통해 값이 채워집니다.


🕳️ 켜고 나서 당황하는 지점들

여기부터가 실제로 시간을 잡아먹는 부분입니다.

커스텀 핸들러가 조용히 무시됩니다

가장 골치 아픈 경우입니다. spring.mvc.problemdetails.enabled=true를 켠 상태에서 @RestControllerAdvice@ExceptionHandler(MethodArgumentNotValidException.class)를 만들어 두면, 그 핸들러가 동작하지 않을 수 있습니다.

경고도 에러도 안 뜹니다. 프레임워크가 자동 등록한 핸들러가 먼저 잡아 버리기 때문입니다. Spring Framework 이슈 트래커에도 같은 내용이 올라와 있고, Spring 팀은 이를 문서로 보완할 사안으로 분류했습니다.

해결 방법은 두 가지입니다.

// 방법 1. 내 핸들러의 우선순위를 올린다
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice
public class ApiExceptionHandler { ... }
// 방법 2. 프레임워크 기본 핸들러를 상속해서 필요한 것만 재정의한다
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
        // 여기서 ProblemDetail을 손봐서 반환
        return super.handleMethodArgumentNotValid(ex, headers, status, request);
    }
}

ResponseEntityExceptionHandler는 레퍼런스가 "Spring MVC의 모든 예외와 ErrorResponseException을 처리하는 @ControllerAdvice용 편의 기반 클래스"라고 설명하는 클래스입니다. 전부 다 재정의할 필요는 없고, 손볼 것만 오버라이드하면 됩니다.

브라우저로 열면 여전히 HTML이 나옵니다

ProblemDetail은 API 응답용입니다. 브라우저가 HTML을 요청하면 Spring Boot는 여전히 화이트라벨 에러 뷰를 내려보냅니다. API 서버라면 문제가 안 되지만, 같은 애플리케이션이 화면도 같이 렌더링한다면 두 경로를 따로 생각하셔야 합니다.

Spring Boot 4로 올렸다면 속성 이름이 바뀌었습니다

이건 놓치기 쉬운 부분입니다. server.error.*로 익숙하던 에러 관련 속성들이 Spring Boot 4.0에서 spring.web.error.*로 옮겨졌습니다.

Spring Boot 3.x까지 Spring Boot 4.0부터
server.error.include-message spring.web.error.include-message
server.error.include-stacktrace spring.web.error.include-stacktrace
server.error.include-binding-errors spring.web.error.include-binding-errors
server.error.include-exception spring.web.error.include-exception
server.error.include-path spring.web.error.include-path
server.error.path spring.web.error.path
server.error.whitelabel.enabled spring.web.error.whitelabel.enabled

속성 이름이 틀리면 조용히 무시됩니다. "분명히 include-message를 켰는데 메시지가 안 나온다"면 이쪽을 확인해 보시면 됩니다.


📋 적용 전에 확인할 것들

  • ☐ 프런트엔드와 에러 응답 포맷을 바꾸기로 합의했는가 (기존 클라이언트가 깨질 수 있음)
  • spring.mvc.problemdetails.enabled(WebFlux면 spring.webflux.problemdetails.enabled)를 켰는가
  • ☐ 기존 @ExceptionHandler가 무시되지 않는지 확인했는가 (@Order 또는 상속)
  • ☐ 기계가 읽어야 하는 값을 detail 문자열이 아니라 확장 필드로 넣었는가
  • type을 팀에서 관리하는 URI로 정했는가 (안 정하면 전부 about:blank)
  • ☐ 브라우저 요청 경로(화이트라벨)도 함께 검토했는가
  • ☐ Spring Boot 4로 올렸다면 spring.web.error.*로 속성명을 바꿨는가
  • ☐ 에러 응답에 내부 정보(스택트레이스, 쿼리, 내부 식별자)가 새지 않는가

🧭 이런 경우에 쓰시면 됩니다

바로 적용하시면 좋은 경우는 새로 시작하는 API이거나, 클라이언트가 아직 소수인 내부 API입니다. 포맷을 직접 설계하는 시간을 아끼고, 표준을 아는 사람이 합류했을 때 설명할 게 없습니다.

단계적으로 가셔야 하는 경우는 이미 외부에 공개된 API입니다. 응답 본문 모양이 바뀌는 건 클라이언트 입장에서 파괴적 변경입니다. 새 버전 경로에서 먼저 쓰거나, 확장 필드에 기존 키를 같이 담아 이행 기간을 두는 편이 안전합니다.

굳이 안 쓰셔도 되는 경우는 화면을 렌더링하는 서버 사이드 애플리케이션입니다. 이건 애초에 사람이 보는 에러 페이지가 필요한 자리라, 표준 JSON이 해결해 주는 게 없습니다.


➡️ 이어서 보면 좋은 글

이 글은 요청을 제대로 받는 이야기의 다음 편입니다. 파라미터 바인딩 쪽에서 막히셨다면 파라미터 이름을 못 찾는 에러 글을 먼저 보시면 됩니다. 상태 코드 자체를 어떻게 고를지가 헷갈리신다면 HTTP Status Code 제어 글에 정리해 두었습니다.


📚 참고 자료

 

  1. Spring Framework 레퍼런스 — Error Responses (ProblemDetail·ErrorResponse)
  2. RFC 9457 — Problem Details for HTTP APIs
  3. Spring Boot 레퍼런스 — Servlet Web Applications / Error Handling
  4. Spring Boot 4.0 Configuration Changelog (속성 이름 변경 목록)
  5. spring-framework issue #35982 — 커스텀 @ExceptionHandler가 무시되는 문제
  6. Spring Boot 릴리스·지원 종료 일정 (endoflife.date)
반응형
Comments