오래간만의 회사 일 이야기. 계속해서 개발에 참여 중인 빌링 포털 서비스의 백엔드에 요 근래에 오류 응답 구조에 대한 표준을 도입 하기로 하였다. 새로 팀에 합류 하신 동료 분께서 먼저 프로젝트에서 사용할 오류 응답 표준을 정의하여 제안 해 주셨는데, 여기에 다른 프로젝트 에서도 활용 하였던 HTTP Problem Details 형식으로 응답하면 어떤지 의견을 드렸다. 아무튼 해당 프로젝트에서 새로 도입된 오류 응답 표준은 HTTP Problem Details 형식을 바탕으로 조금 확정하여 사용하기로 하였고, 관련하여 사용할 클래스 등도 정의가 되었다.
그런데 그것은 동료 분께서 주로 기여를 해 주신 것이기도 하여(?) 이 글에서는 프로젝트에서 어떻게 구현 하였나 보다는, HTTP Problem Details 표준 자체에 대해서 알아보고, 일반적으로 어떻게 구현 하는지 등을 이참에 정리 해 보고자 한다.
RFC 9457: Problem Details for HTTP APIs
일반적으로 서버에서는 요청을 처리 하다가 오류가 있으면, 이에 해당하는 HTTP Status Code (보통 4xx 또는 5xx)와 함께 오류 메시지를 클라이언트에 반환한다. 예를 들면 아래와 같다.
HTTP/1.1 400 Bad Request
Date: Sun, 13 Sep 2026 17:16:00 GMT
Server: nginx/1.18.0
Content-Type: text/plain; charset=UTF-8
Content-Length: 43
Connection: close
올바른 이메일 형식이 아닙니다.
요즘 HTTP API 에서는 JSON 형식으로 응답을 많이 제공하니 아래와 같은 예시도 있을 수 있다.
HTTP/1.1 400 Bad Request
Date: Sun, 13 Sep 2026 17:16:00 GMT
Server: nginx/1.18.0
Content-Type: text/plain; charset=UTF-8
Content-Length: 64
Connection: close
{
"message": "올바른 이메일 형식이 아닙니다."
}
이렇게 하고, 메시지를 바로 사용자에게 보여주는 것으로 충분 하다면 좋겠지만, 항상 그렇지는 않다. 같은 HTTP Status Code 에 대해서도 여러 종류의 오류가 있을 수 있고, 오류 유형에 따라서 다음 동작 분기를 해야 하는 경우도 자주 있을 수 있다. 때문에 HTTP Status Code 와 메시지 텍스트 만으로는 부족할 수 있다. Problem Details 는 RFC 9457로 표준이 정의되어 있는데, 이 문서의 서론에도 처음부터 아래와 같은 배경이 설명되어 있다.
- Introduction
HTTP status codes (Section 15 of [HTTP]) cannot always convey enough information about errors to be helpful. While humans using web browsers can often understand an HTML [HTML5] response content, non-human consumers of HTTP APIs have difficulty doing so.
To address that shortcoming, this specification defines simple JSON [JSON] and XML [XML] document formats to describe the specifics of a problem encountered – “problem details”.
- 서론
HTTP 상태 코드 ([HTTP]의 섹션 15)는 항상 유용하기에 충분한 정보를 전달 할 수는 없다. 사람은 웹 브라우저를 사용하며 보통 HTML [HTML5] 응답 내용을 이해할 수 있지만, HTTP API의 사람이 아닌 소비자는 이를 이해하기 어려울 수 있다.
이러한 단점을 보완하기 위해, 본 명세서는 발생한 문제의 구체적인 내용, 즉 ‘문제 세부 정보(problem details)‘를 기술하기 위한 간단한 JSON [JSON] 및 XML [XML] 문서 형식을 정의한다.
그래서 이것이 무엇인지는 초록에 간단히 나와있다.
Abstract
This document defines a “problem detail” to carry machine-readable details of errors in HTTP response content to avoid the need to define new error response formats for HTTP APIs.
초록
이 문서는 HTTP API를 위한 새로운 오류 응답 형식을 정의할 필요가 없도록, HTTP 응답 콘텐츠 내 오류에 관한 기계 판독 가능한 세부 정보를 전달하는 ‘문제 세부 정보(problem detail)‘를 정의한다.
어떻게 생겼는가
흔히 보던 JSON 형식으로 응답 내용이 구성되어 있고 아래와 같은 형태이다. 아래 예시는 해당 RFC 문서에서 발췌 하였다.
예를 들어 아래와 같이 상점에서 물건을 주문하는 API 를 호출 하였는데,
POST /purchase HTTP/1.1
Host: store.example.com
Content-Type: application/json
Accept: application/json, application/problem+json
{
"item": 123456,
"quantity": 2
}
크레딧이 부족하여 주문 오류가 발생 하였다고 가정하자, 그러면 Content-Type: application/problem+json 으로 식별할 수 있는, Problem Details 형식으로 아래와 같이 오류 정보를 제공할 수 있다.
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en
{
"type": "https://example.com/probs/out-of-credit",
"title": "You do not have enough credit.",
"detail": "Your current balance is 30, but that costs 50.",
"instance": "/account/12345/msgs/abc",
"balance": 30,
"accounts": ["/account/12345",
"/account/67890"]
}
설계나 구현에 따라서, 같은 문제 유형에 대해서 여러 오류 인스턴스를 제공하는 것도 가능하다.
POST /details HTTP/1.1
Host: account.example.com
Accept: application/json
{
"age": 42.3,
"profile": {
"color": "yellow"
}
}
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: en
{
"type": "https://example.net/validation-error",
"title": "Your request is not valid.",
"errors": [
{
"detail": "must be a positive integer",
"pointer": "#/age"
},
{
"detail": "must be 'green', 'red' or 'blue'",
"pointer": "#/profile/color"
}
]
}
혹시나 HTTP API 에서 응답을 XML 형식으로 제공한다면, 아래 예시처럼 Problem Details 또한 XML 형식으로 제공할 수 있다.
HTTP/1.1 403 Forbidden
Content-Type: application/problem+xml
Content-Language: en
<?xml version="1.0" encoding="UTF-8"?>
<problem xmlns="urn:ietf:rfc:7807">
<type>https://example.com/probs/out-of-credit</type>
<title>You do not have enough credit.</title>
<detail>Your current balance is 30, but that costs 50.</detail>
<instance>https://example.net/account/12345/msgs/abc</instance>
<balance>30</balance>
<accounts>
<i>https://example.net/account/12345</i>
<i>https://example.net/account/67890</i>
</accounts>
</problem>
이러한 Problem Details 형식 응답을 보면, 몇가지 공통적인 필드도 있고 그렇지 않은 필드도 있다. RFC 문서를 참고해서 정리 해 보면 아래와 같다.
type: 클라이언트에서 문제 유형을 식별 할 때 사용해야 하는 값 이다. 일반적으로 URI 형태이고,http://https://로 시작하는 주소 형태이면, 실제로 접속 가능한 주소여야 한다는 요건이 있다.type에 들어가는 URI 는 상대경로 형태일 수도 있고 (예:/errors/validation-error), 실제로 접속은 불가한 임의의 URI 로 할 수도 있다. (예:tag:[email protected],2021-09-17:OutOfLuck)status: HTTP 상태 코드이다. 편의성 목적으로 제공되는 것 인데, 서버에서는 응답에 포함한 HTTP Status Code 값과 당연하게도 동일한 것으로 제공해야 한다.title: 사람이 읽을 수 있는 오류에 대한 간단할 설명 텍스트이다.detail: 오류에 대한 세부적인 설명 등을 포함한 사람이 읽을 수 있는 텍스트이다. 클라이언트에서는 여기에 들어간 값을 파싱해서 사용하면 안 되고, 그대로 보여주는 용도로 사용해야 한다.instance: 오류가 발생한 요청의 URI 를 나타내는 값이다. 일반적으로 클라이언트가 호출한 서버의 URI 를 보여주는 경우가 많다.
위 필드 이외에 추가적으로 확장 필드를 정의하여 사용 하기도 한다.
- 위에 예시처럼
errors필드에 오류 상세 정보를 포함 시키키고 하고 type필드와 별도로,code필드를 추가하여 간단한 오류 코드 값을 제공하여, 클라이언트에서 분기 로직 처리에 유용하게 활용할 수 있도록 하기도 한다. 예를 들면ERROR_DUPLICATE와 같은.
구현
회사 프로젝트 개발에서는 ASP.NET Core (.NET 8, C#)을 자주 사용한다. 그러니 먼저 이 프레임워크에서 지원하는 기능을 활용하는 법을 소개하면 좋을 것 같다.
ASP.NET Core 에서는 Problem Details 관련 기능이 내장되어 있다. 이를 활용하는 것 만으로 간단히 구현이 가능하다. 아래 예시처럼, 컨트롤러 단에서 직접 ProblemDetails 객체를 생성하여 반환하는 방법도 있고.
[Route("api/[controller]/[action]")]
[ApiController]
public class Values3Controller : ControllerBase
{
// /api/values3/divide/1/2
[HttpGet("{Numerator}/{Denominator}")]
public IActionResult Divide(double Numerator, double Denominator)
{
if (Denominator == 0)
{
var errorType = new MathErrorFeature
{
MathError = MathErrorType.DivisionByZeroError
};
HttpContext.Features.Set(errorType);
return Problem(
title: "Bad Input",
detail: "Divison by zero is not defined.",
type: "https://en.wikipedia.org/wiki/Division_by_zero",
statusCode: StatusCodes.Status400BadRequest
);
}
return Ok(Numerator / Denominator);
}
}
직접 ProblemDetails 로 반환 하도록 각 컨트롤러마다 수정하면 좋겠지만, 한번에 다 바꾸기는 힘들기도 하니 Exception Handler 를 등록하여 전역 처리를 함께 하기도 한다. 간혹 컨트롤러단에서 미처 처리하지 못한 예외가 발생하는 경우가 있는데, 이런 경우 Exception Handler 설정이 없으면 클라이언트에 Stack Trace 가 응답에 그대로 노출된다. 이를 방지하기 위한 목적으로도 설정하면 보안 측면에서도 좋다고 할 수 있다.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.MapControllers();
app.Run();
ProblemDetailsFactory 를 활용할 수도 있다. ControllerBase 를 상속하는 클래스에서 Problem 메소드 사용도 좋지만, 필드를 확장해야 하는 경우 ProblemDetailsFactory 를 사용하면 유연하게 처리할 수 있다. 아래 예시처럼 orderId 확장 필드를 넣을 수도 있고, 일반적으로 가장 많이 넣는 확장 필드 중 하나가 code 이다. 여기에 서비스에서 정의 한 오류코드를 제공하여, FE 쪽에서 분기처리를 하여 필요한 메시지를 보여주거나 아니면 사용자를 다른 화면으로 보내주는 용도로 많이 사용한다.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly ProblemDetailsFactory _problemDetailsFactory;
public OrdersController(ProblemDetailsFactory problemDetailsFactory)
{
_problemDetailsFactory = problemDetailsFactory;
}
[HttpGet("{id:guid}")]
public IActionResult GetOrder(Guid id)
{
var order = FindOrder(id);
if (order is null)
{
// Generates a ProblemDetails object populated with framework defaults
var problem = _problemDetailsFactory.CreateProblemDetails(
HttpContext,
statusCode: StatusCodes.Status404NotFound,
title: "Order Not Found",
detail: $"No order was found with ID '{id}'."
);
// Add custom extensions if needed
problem.Extensions["orderId"] = id;
return NotFound(problem);
}
return Ok(order);
}
private static object? FindOrder(Guid id) => null;
}
아직 적용을 하지는 않았지만, 회사에서 개발하는 서비스 중 IAM 서비스는 Golang 과 Gin 으로 개발되어 있다. 그래서 이 경우에 어떻게 하는지도 간단히 알아 보았다. Gin 의 경우에는 ASP.NET Core 처럼 ProblemDetails 기능을 내장하고 있지는 않고 있다. 그래서 직접 구현할 수도 있고(정해진 JSON 스키마 구조 따르고, Content-Type 헤더 설정하는 정도), 다른 사람들이 만들어 둔 패키지를 활용 할 수도 있다.
직접 구현하는 경우, 예를 들면 아래와 같은 형태로 구현할 수 있다.
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// RFC 7807 / RFC 9457 구조를 따르는 ProblemDetails 구조체
type ProblemDetails struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
Invalid map[string]any `json:"invalid-params,omitempty"` // Custom extension
}
// RFC 7807 / RFC 9457 형식의 응답을 application/problem+json 의 content-type 으로 반환하는 함수
func RenderProblemDetails(c *gin.Context, pd ProblemDetails) {
c.Header("Content-Type", "application/problem+json")
c.JSON(pd.Status, pd)
}
func main() {
r := gin.Default()
r.GET("/accounts/:id", func(c *gin.Context) {
id := c.Param("id")
if id != "123" {
RenderProblemDetails(c, ProblemDetails{
Type: "https://api.example.com/errors/not-found",
Title: "Account Not Found",
Status: http.StatusNotFound,
Detail: "No account exists with the provided ID.",
Instance: c.Request.URL.Path,
})
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "name": "John Doe"})
})
r.Run(":8080")
}
그리고 오류를 처리하는 미들웨어 패턴을 활용하여, 컨트롤러에서 처리하지 못한 에러가 반환되면 처리 하는 미들웨어를 만들어서 사용 할 수도 있다.
아래 예시의 ProblemDetailsMiddleware 미들웨어는, gin.Context 에 Error 가 있는 지 확인하고, 각종 케이스에 따라서 ProblemDetails 형식의 응답을 반환하는 로직이 들어가 있다.
아래와 같은 케이스의 오류를 처리한다.
- 유효성 검사 오류
- 일반적인 Go 오류
- Panic 발생 후 Recover 되었을 때 이에 대한 오류 처리
package main
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
// ProblemDetails represents an RFC 7807 / RFC 9457 payload
type ProblemDetails struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
Invalid map[string]any `json:"invalid-params,omitempty"`
}
// ProblemDetailsMiddleware catches all c.Errors and panics
func ProblemDetailsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Recovery block to catch panics and format them as 500 Problem Details
defer func() {
if r := recover(); r != nil {
pd := ProblemDetails{
Type: "https://api.example.com/errors/internal-server-error",
Title: http.StatusText(http.StatusInternalServerError),
Status: http.StatusInternalServerError,
Detail: fmt.Sprintf("An unexpected panic occurred: %v", r),
Instance: c.Request.URL.Path,
}
c.Header("Content-Type", "application/problem+json")
c.AbortWithStatusJSON(http.StatusInternalServerError, pd)
}
}()
// Process downstream request handlers
c.Next()
// If no errors were logged to the context, return cleanly
if len(c.Errors) == 0 {
return
}
// Grab the last registered error on the context
err := c.Errors.Last().Err
var pd ProblemDetails
// 1. Check for Gin/go-playground validation errors (from c.ShouldBind)
var ve validator.ValidationErrors
if errors.As(err, &ve) {
invalidParams := make(map[string]any)
for _, fe := range ve {
invalidParams[fe.Field()] = fmt.Sprintf("failed on rule '%s'", fe.Tag())
}
pd = ProblemDetails{
Type: "https://api.example.com/errors/validation-error",
Title: "Unprocessable Entity",
Status: http.StatusUnprocessableEntity,
Detail: "One or more request fields failed validation.",
Instance: c.Request.URL.Path,
Invalid: invalidParams,
}
} else {
// 2. Fallback for generic Go errors
status := c.Writer.Status()
if status == http.StatusOK || status == 0 {
status = http.StatusInternalServerError
}
pd = ProblemDetails{
Type: "about:blank",
Title: http.StatusText(status),
Status: status,
Detail: err.Error(),
Instance: c.Request.URL.Path,
}
}
// Write the RFC 7807 problem details response
c.Header("Content-Type", "application/problem+json")
c.JSON(pd.Status, pd)
}
}
글을 마치며
아무튼 이번 글에서는 오랜만에 회사 일 하다가 새로 배운 것을 간단히 정리 해 보았다. 오류 응답에 대한 형식은 글을 정리 하면서도 느낀 것이지만, 꼭 나 아니면 회사 동료 뿐만 아니라, 웹 개발을 하는 다른 개발자도 어쩌면 당연하게도 항상 고민하던 것이고, 덕분에 이런 표준이 나온것도 알게 되었다. 개인적으로 회사 프로젝트이든 개인 프로젝트 개발을 하며 느끼는 기술적인 고민은 물론 나도 하지만 다른 개발자도 많이 하는 것이라는 생각을 자주 한다. 그래서 프레임워크에서 혹시 기능을 제공하지는 않나 아니면 다른 사람들은 어떻게 해결하나 자료를 자주 찾아보곤 하는데, 이번에는 HTTP Problem Details 가 그런 것이였고, 업무에도 잘 적용 해 볼 수 있게 되었다.