wibaek.page

API 에러 응답을 RFC 9457 Problem Details로 표준화하기

새 프로젝트에 들어가거나 다른 팀의 API를 연동하다 보면 에러 응답이 제각각인 상황을 자주 만난다. 어떤 API는 errorCode와 message를 보내고, 다른 API는 code와 error message를 사용한다. 같은 서비스 안에서도 검증 오류와 비즈니스 오류, 예상하지 못한

새 프로젝트에 들어가거나 다른 팀의 API를 연동하다 보면 에러 응답이 제각각인 상황을 자주 만난다.

어떤 API는 errorCodemessage를 보내고, 다른 API는 codeerror_message를 사용한다. 같은 서비스 안에서도 검증 오류와 비즈니스 오류, 예상하지 못한 500 오류의 모양이 서로 다르기도 한다.

에러 응답도 API 계약이다. 계약이 일정하지 않으면 클라이언트에는 서비스별 파싱 코드가 늘어나고, 서버에는 이름만 다른 ErrorResponse DTO가 계속 생긴다.

이 문제의 공통 기준으로 사용할 수 있는 것이 RFC 9457 Problem Details for HTTP APIs다. RFC 9457은 기존 RFC 7807을 대체하며, HTTP API가 오류의 세부 정보를 표현하는 공통 형식을 정의한다.

예를 들면 404 응답을 다음과 같이 표현할 수 있다.

HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
  "type": "https://api.example.com/problems/user-not-found",
  "title": "사용자를 찾을 수 없음",
  "status": 404,
  "detail": "요청한 사용자가 존재하지 않습니다.",
  "instance": "/users/123"
}

이 글에서는 RFC 9457이 무엇을 정하는지부터 살펴보고, Spring Boot와 FastAPI, Django REST Framework에서 같은 계약을 만드는 방법을 비교한다.


왜 에러 응답을 표준화해야 할까

정상 응답은 도메인마다 모양이 달라도 자연스럽다. 사용자 조회와 주문 생성이 같은 JSON을 반환할 이유는 없다.

반면 에러 응답에는 서비스가 달라도 반복되는 정보가 많다.

  • 어떤 종류의 문제인가
  • HTTP 상태 코드는 무엇인가
  • 사람이 이해할 수 있는 설명은 무엇인가
  • 어느 요청에서 발생했는가
  • 검증에 실패한 필드는 무엇인가

이 구조가 일관되면 클라이언트는 공통 파서와 공통 UI를 만들 수 있다. 서버도 프레임워크가 바뀔 때마다 에러 DTO의 철학부터 다시 정할 필요가 없다. API 문서, 로그, 모니터링 도구가 같은 용어를 사용할 수 있다는 점도 크다.

다만 Problem Details는 서버 내부의 모든 예외를 그대로 외부에 공개하자는 규격이 아니다. 내부 예외를 안전하고 안정적인 외부 계약으로 변환하기 위한 표현 형식에 가깝다.


RFC 9457의 기본 멤버

Problem Details에는 다섯 개의 표준 멤버가 정의되어 있다.

type: 문제 유형의 식별자

type은 문제의 종류를 식별하는 URI reference다. 같은 종류의 오류라면 요청마다 바뀌지 않는 안정적인 값을 사용하는 편이 좋다.

{
  "type": "https://api.example.com/problems/user-not-found"
}

HTTP나 HTTPS 주소를 사용한다면 사람이 읽을 수 있는 설명 문서를 제공하는 것이 좋다. 문서에는 오류의 의미, 발생 조건, 해결 방법을 적을 수 있다.

type을 생략하면 의미는 about:blank이다. 이 경우 문제 유형은 HTTP 상태 코드와 같다고 본다. 모든 오류에 억지로 별도 URI를 만들기보다, 클라이언트가 구분해야 하는 문제부터 고유한 type을 만드는 방식도 가능하다.

title: 문제 유형의 짧은 요약

title은 사람이 읽는 짧은 설명이다. 같은 type이라면 일반적으로 같은 제목을 유지하고, 언어에 따른 번역 정도만 달라지는 편이 자연스럽다.

요청마다 달라지는 구체적인 값은 title보다 detail에 넣는다.

status: 원래 HTTP 상태 코드의 사본

status은 응답의 HTTP 상태 코드를 본문에도 담은 값이다. 중간 프록시나 저장된 로그만 볼 때 유용하지만, 실제 응답 상태를 대체하지는 않는다.

본문의 status와 HTTP 응답 상태는 반드시 같은 의미를 가져야 한다. 헤더는 200인데 본문만 "status": 404인 응답은 Problem Details를 사용했다고 보기 어렵다.

detail: 이번 발생 건에 대한 설명

detail은 해당 오류 발생 건을 사람이 이해할 수 있도록 설명한다.

{
  "detail": "사용자 ID 123에 해당하는 사용자가 없습니다."
}

클라이언트가 detail 문장을 파싱해서 분기하도록 만들면 안 된다. 기계가 오류 유형을 구분해야 한다면 안정적인 type이나 별도 확장 멤버를 사용해야 한다.

스택 트레이스, SQL, 파일 경로, 내부 클래스명처럼 구현 세부사항이 들어가지 않도록 주의한다.

instance: 이번 발생 건의 식별자

instance는 특정 오류 발생 건을 식별하는 URI reference다. 요청 경로를 넣을 수도 있고, 별도의 오류 조회 URI나 추적 식별자를 연결할 수도 있다.

민감한 쿼리 문자열이나 내부 인프라 주소를 그대로 넣지는 않는다.

다섯 멤버가 모두 항상 필요한 것은 아니다

RFC 9457은 다섯 멤버의 의미를 정의하지만 모든 응답에 다섯 개를 강제하지 않는다. 중요한 것은 멤버를 넣었을 때 그 의미를 지키는 것이다.

실무에서는 type, title, status를 공통 기반으로 두고, 필요한 경우 detailinstance를 추가하는 식으로 운영할 수 있다.


확장 멤버로 도메인 정보를 표현한다

RFC 9457은 표준 멤버 외의 확장 멤버를 허용한다. 예를 들어 요청 본문의 여러 필드가 유효하지 않다면 errors 배열을 추가할 수 있다.

{
  "type": "https://api.example.com/problems/validation-error",
  "title": "요청 검증 실패",
  "status": 422,
  "detail": "요청 본문에 유효하지 않은 값이 있습니다.",
  "errors": [
    {
      "pointer": "#/age",
      "detail": "0보다 큰 정수여야 합니다."
    },
    {
      "pointer": "#/profile/color",
      "detail": "green, red, blue 중 하나여야 합니다."
    }
  ]
}

여기서 errors는 RFC의 표준 멤버가 아니라 서비스가 정의한 확장이다. pointer는 요청 본문의 위치를 가리키는 JSON Pointer로 사용할 수 있다.

이름이 반드시 errors여야 하는 것도 아니며, 검증 실패에 반드시 422를 사용해야 하는 것도 아니다. 어떤 서비스는 400을 사용한다. 중요한 것은 팀이 선택한 구조와 상태 코드 의미를 문서화하고 일관되게 유지하는 것이다.


프레임워크보다 먼저 계약을 정해야 한다

Problem Details를 도입할 때 다음 항목을 먼저 합의하는 편이 좋다.

  1. 문제 유형별 type URI 규칙
  2. HTTP 상태 코드와 본문 status의 일치
  3. 검증 오류 확장 구조와 400 또는 422 선택
  4. 외부에 공개할 detail 정책
  5. instance와 추적 ID 사용 방식
  6. 예상하지 못한 500 오류의 안전한 기본 응답

Spring, FastAPI, DRF의 구현 방식은 다르지만 이 계약은 같아야 한다.


Spring Boot: ProblemDetail을 사용한다

Spring Framework 6 이상은 RFC 9457을 표현하는 ProblemDetail, ErrorResponse, ResponseEntityExceptionHandler를 제공한다. Spring Boot에서는 다음 설정으로 프레임워크의 기본 Web MVC 예외도 Problem Details 형태로 처리할 수 있다.

spring:
  mvc:
    problemdetails:
      enabled: true

비즈니스 예외는 @RestControllerAdvice에서 직접 ProblemDetail로 변환할 수 있다.

import java.net.URI;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ProblemDetail handleUserNotFound(
            UserNotFoundException exception,
            HttpServletRequest request
    ) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND,
                "요청한 사용자가 존재하지 않습니다."
        );

        problem.setType(URI.create(
                "https://api.example.com/problems/user-not-found"
        ));
        problem.setTitle("사용자를 찾을 수 없음");
        problem.setInstance(URI.create(request.getRequestURI()));

        return problem;
    }
}

Spring은 ProblemDetail을 반환할 때 상태 코드와 application/problem+json 렌더링을 처리한다. 설정에 따라 instance가 현재 요청 경로로 자동 지정될 수도 있다.

검증 오류처럼 비표준 필드가 필요하면 setProperty를 사용할 수 있다.

problem.setProperty("errors", errors);

Spring의 Jackson 지원은 이 properties 맵을 최상위 JSON 멤버로 펼쳐준다. 단, 예외 객체의 메시지를 그대로 detail에 넣기보다 외부 공개용 문장을 별도로 만드는 편이 안전하다.


FastAPI: 전역 예외 처리기에서 변환한다

FastAPI의 기본 HTTPException 응답은 {"detail": ...} 형태이며 Problem Details는 아니다. 대신 @app.exception_handler로 기본 처리기를 교체할 수 있다.

먼저 공통 응답 함수를 만든다.

from typing import Any

from fastapi.responses import JSONResponse


def problem_response(
    *,
    status: int,
    type_: str,
    title: str,
    detail: str | None = None,
    instance: str | None = None,
    **extensions: Any,
) -> JSONResponse:
    body: dict[str, Any] = {
        "type": type_,
        "title": title,
        "status": status,
    }

    if detail is not None:
        body["detail"] = detail
    if instance is not None:
        body["instance"] = instance

    body.update(extensions)

    return JSONResponse(
        status_code=status,
        content=body,
        media_type="application/problem+json",
    )

그다음 Starlette의 HTTPException과 FastAPI의 RequestValidationError를 전역에서 변환한다.

from http import HTTPStatus
from typing import Any

from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException

app = FastAPI()


def body_pointer(location: tuple[Any, ...]) -> str | None:
    if not location:
        return None

    source, *parts = location
    if source != "body" or not parts:
        return None

    escaped = [
        str(part).replace("~", "~0").replace("/", "~1")
        for part in parts
    ]
    return "#/" + "/".join(escaped)


@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(
    request: Request,
    exc: StarletteHTTPException,
) -> JSONResponse:
    detail = (
        exc.detail
        if isinstance(exc.detail, str)
        else "요청을 처리할 수 없습니다."
    )

    return problem_response(
        status=exc.status_code,
        type_=f"https://api.example.com/problems/http-{exc.status_code}",
        title=HTTPStatus(exc.status_code).phrase,
        detail=detail,
        instance=request.url.path,
    )


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
    request: Request,
    exc: RequestValidationError,
) -> JSONResponse:
    errors: list[dict[str, Any]] = []

    for error in exc.errors():
        item: dict[str, Any] = {"detail": error["msg"]}
        pointer = body_pointer(tuple(error["loc"]))

        if pointer is not None:
            item["pointer"] = pointer
        else:
            item["location"] = [str(part) for part in error["loc"]]

        errors.append(item)

    return problem_response(
        status=422,
        type_="https://api.example.com/problems/validation-error",
        title="요청 검증 실패",
        detail="요청 데이터를 다시 확인해주세요.",
        instance=request.url.path,
        errors=errors,
    )

등록할 때는 FastAPI의 HTTPException이 아니라 기반 클래스인 Starlette의 HTTPException을 대상으로 잡는 편이 안전하다. Starlette 내부나 확장 기능에서 발생한 같은 계열의 예외도 함께 처리할 수 있기 때문이다.

예제의 location 역시 서비스가 정의한 확장이다. 쿼리나 경로 파라미터는 요청 본문 JSON Pointer로 표현할 수 없으므로 별도의 위치 정보를 사용했다.


Django REST Framework: 기본 처리 결과를 공통 계약으로 감싼다

DRF의 일반적인 오류 응답은 detail 키를 사용하고, 검증 오류는 필드명을 키로 갖는 객체를 사용한다. EXCEPTION_HANDLER 설정으로 전역 처리기를 등록하면 이를 Problem Details 형태로 바꿀 수 있다.

from http import HTTPStatus
from typing import Any

from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.views import exception_handler


def problem_exception_handler(exc: Exception, context: dict[str, Any]):
    default_response = exception_handler(exc, context)
    if default_response is None:
        return None

    request = context.get("request")
    is_validation_error = isinstance(exc, ValidationError)

    raw_detail = (
        default_response.data.get("detail")
        if isinstance(default_response.data, dict)
        else None
    )
    detail = (
        str(raw_detail)
        if isinstance(raw_detail, str)
        else "요청 데이터를 다시 확인해주세요."
    )

    problem: dict[str, Any] = {
        "type": (
            "https://api.example.com/problems/validation-error"
            if is_validation_error
            else "https://api.example.com/problems/request-error"
        ),
        "title": (
            "요청 검증 실패"
            if is_validation_error
            else HTTPStatus(default_response.status_code).phrase
        ),
        "status": default_response.status_code,
        "detail": detail,
    }

    if request is not None:
        problem["instance"] = request.path

    if is_validation_error and isinstance(default_response.data, dict):
        problem["errors"] = default_response.data

    headers = {
        key: value
        for key, value in default_response.headers.items()
        if key.lower() != "content-type"
    }

    return Response(
        problem,
        status=default_response.status_code,
        headers=headers,
        content_type="application/problem+json",
    )
REST_FRAMEWORK = {
    "EXCEPTION_HANDLER": "my_project.api.problem_exception_handler",
}

실제 서비스에서는 DRF의 중첩된 검증 오류를 errors 배열과 JSON Pointer로 평탄화하는 함수를 별도로 두는 편이 좋다. 위 코드는 전역 변환 흐름에 집중한 최소 예시다.

또 하나의 중요한 한계가 있다. DRF의 커스텀 예외 처리기는 발생한 예외만 처리한다. 뷰가 Response(..., status=400)을 직접 반환하면 이 처리기를 거치지 않는다. 직접 만드는 4xx 응답도 공통 helper를 사용하거나 별도의 정책으로 통제해야 서비스 전체 계약이 유지된다.


RFC 9457이 정해주지 않는 것

Problem Details를 적용하면 모든 에러 설계가 자동으로 끝나는 것은 아니다. RFC는 다음 항목까지 대신 결정하지 않는다.

  • 각 상황에서 400, 404, 409, 422 중 무엇을 쓸지
  • 비즈니스 오류 유형을 어떤 단위로 나눌지
  • 검증 오류 확장 멤버의 정확한 구조
  • 사용자 메시지의 언어와 문체
  • 추적 ID와 로그를 어떻게 연결할지
  • 재시도 가능한 오류를 어떻게 표시할지

따라서 “RFC 9457을 쓴다”는 말은 에러 분류 정책이 완성됐다는 뜻이 아니다. 공통 봉투를 선택했다는 뜻에 가깝다. 그 안에 어떤 문제 유형과 확장 멤버를 넣을지는 팀의 API 계약으로 정해야 한다.


도입할 때 함께 정하면 좋은 규칙

type을 버전이 안정적인 식별자로 관리한다

클라이언트가 분기에 사용할 수 있으므로 배포마다 바뀌는 문자열을 사용하지 않는다. 문서 URL을 사용한다면 해당 문서의 변경과 폐기 정책도 함께 관리한다.

detail과 로그 메시지를 분리한다

사용자에게 필요한 설명과 운영자가 필요한 디버깅 정보는 목적이 다르다. 외부 응답에는 안전한 설명을 넣고, 스택 트레이스와 내부 예외는 추적 ID와 함께 서버 로그에 남긴다.

검증 오류 구조를 계약 테스트로 고정한다

필드명, JSON Pointer, 오류 코드가 프레임워크 업그레이드로 바뀌지 않도록 응답 스키마를 테스트한다. HTTP 상태와 본문의 status, Content-Type도 함께 검증한다.

예상하지 못한 500 오류도 같은 형식으로 처리한다

알려진 비즈니스 오류만 Problem Details로 반환하고 나머지 예외는 프레임워크 기본 HTML이나 다른 JSON으로 내려가면 계약은 다시 깨진다. 500 응답에는 내부 원인을 숨긴 안전한 기본 detail을 사용한다.


마무리

RFC 9457의 가치는 DTO 하나를 줄이는 데 있지 않다. 서로 다른 프레임워크와 서비스가 실패를 같은 문법으로 설명할 수 있게 만드는 데 있다.

Spring Boot는 ProblemDetail을 기본 제공하고, FastAPI와 DRF는 전역 예외 처리기에서 같은 계약으로 변환할 수 있다. 구현 방식은 달라도 클라이언트가 보는 type, title, status, detail, instance의 의미는 같아야 한다.

가장 먼저 할 일은 새로운 공통 DTO를 만드는 것이 아니다. 우리 API가 어떤 문제 유형을 외부 계약으로 공개할지, 검증 오류를 어떤 구조로 표현할지, 내부 정보를 어디까지 숨길지를 합의하는 것이다. 그 계약의 공통 형식으로 RFC 9457을 사용하면 프레임워크가 달라져도 에러 응답은 덜 흔들린다.


참고 자료