Files
llm-wiki/wiki/publish-blog/api-error-envelope-blog.md

11 KiB

title, source_type, status, confidence, tags, related_projects, last_reviewed, canonical_sources, audience, target_publish, status_label
title source_type status confidence tags related_projects last_reviewed canonical_sources audience target_publish status_label
표준 대신 계약을 선택하다 — API Error Envelope 설계기 blog draft unknown
blog
ca-tmpl
error-handling
api-design
spring-boot
archunit
ca-tmpl
backend-engineer draft

표준 대신 계약을 선택하다: API Error Envelope 설계기

Spring Boot API에서 ProblemDetail 대신 custom error envelope을 선택한 이유와, 그 결정을 코드로 어떻게 고정했는지 정리합니다.

TL;DR

  • Validation, 인증, transport failure가 각자 다른 JSON 모양으로 응답하면 클라이언트도 운영자도 힘들어집니다.
  • Spring 6+가 제공하는 ProblemDetail은 훌륭한 표준이지만, 저희 프로젝트(ca-tmpl)가 원하는 성공/실패 대칭 구조와는 결이 달랐습니다.
  • 그래서 { success, data, error, meta } 형태의 커스텀 envelope을 프로젝트 계약으로 정하고, ArchUnit 룰과 설정 테스트로 되돌아가지 못하게 막았습니다.
  • 현재까지 로컬/개발 환경 검증은 끝났지만, 운영 환경 검증은 아직입니다. 이 글에서는 그 경계를 명확히 짚습니다.

1. 문제: 실패 응답의 모양이 제각각이라면

API 실패 응답은 처음엔 사소해 보입니다. 적당한 HTTP status와 메시지만 내려주면 될 것 같죠. 하지만 프로젝트가 커지면 이야기가 달라집니다.

  • Validation 실패는 필드별 에러 목록을 내려줘야 하고
  • 인증 실패는 Spring Security가 알아서 다른 모양의 응답을 만들고
  • 잘못된 Content-Type이나 너무 큰 요청 본문은 Spring MVC의 transport 레이어에서 또 다른 응답을 만듭니다

이 상태가 계속되면 클라이언트 개발자는 "실패했다"는 사실보다 **"이번엔 또 어떤 모양으로 오지?"**를 먼저 걱정하게 됩니다. 운영자 입장도 비슷합니다. 응답에 trace id가 있는지, 재시도 가능한 오류인지, 어느 계층에서 실패했는지가 매번 다른 위치에 있으면 로그와 응답을 이어서 보기가 어렵습니다.

ca-tmpl 프로젝트는 이 문제를 초기에 계약으로 못 박기로 했습니다. 목표는 모든 실패를 하나의 원인으로 뭉개는 것이 아니라, HTTP status와 error code가 가진 의미는 그대로 보존하면서 바깥 구조만큼은 하나로 통일하는 것이었습니다.


2. 왜 ProblemDetail을 그대로 쓰지 않았나

가장 먼저 나온 질문은 당연히 이거였습니다. "Spring 6+에 이미 ProblemDetail이 있는데, 그냥 쓰면 안 되나?"

ProblemDetail은 RFC 7807 계열의 잘 만들어진 실패 응답 모델이고, Spring에서 기본으로 지원합니다. 그런데 ca-tmpl이 원하는 것과는 두 가지 지점에서 어긋났습니다.

요구 사항 ProblemDetail ca-tmpl이 원한 것
응답 구조 실패 전용 평면(flat) 구조 성공/실패가 같은 top-level envelope을 공유
1급 필드 type, title, detail 등 표준 필드 code, category, retryable, meta를 프로젝트 계약으로

ProblemDetail 위에 커스텀 필드를 계속 얹는 방식도 고려했지만, 그렇게 되면 결국 "표준을 쓰는 척하면서 실제로는 또 다른 custom envelope을 만드는" 셈이 됩니다. 그래서 저희는 우회하지 않고 명시적으로 프로젝트 전용 envelope을 선택했습니다.

이건 ProblemDetail이 나쁜 설계라서가 아닙니다. 이 프로젝트가 원하는 success/error 대칭성과 운영 메타데이터가, 표준을 따르는 것보다 더 중요했기 때문입니다.


3. Envelope의 생김새

말로만 설명하면 추상적이니, 실제 응답 예시부터 보겠습니다.

{
  "success": false,
  "data": null,
  "error": {
    "code": "VALIDATION_FAILED",
    "category": "VALIDATION",
    "message": "Request body failed validation",
    "retryable": false,
    "details": []
  },
  "meta": {
    "requestId": "...",
    "traceId": "...",
    "correlationId": "..."
  }
}

성공 응답이든 실패 응답이든 바깥 구조는 항상 같습니다. 실패라면 success=false이고 data=null, error에 실제 정보가 담깁니다. 필드별 역할은 다음과 같습니다.

  • success: 클라이언트가 가장 먼저 확인하는 1차 분기 기준
  • error.code: 클라이언트가 로직으로 분기할 수 있는 machine-readable identifier. 반대로 message는 사람이 읽는 문장이라, 클라이언트 로직이 여기에 의존하면 안 됩니다.
  • error.category: validation / auth / dependency처럼 운영자가 보는 큰 분류
  • error.retryable: 클라이언트가 재시도를 검토할 수 있는 최소한의 힌트
  • error.details: validation field error처럼 항목별 정보가 필요할 때만 채우는 필드
  • meta: requestId, traceId, correlationId로 이 응답을 로그·트레이스와 이어 붙이는 영역

4. 결정을 코드로 고정하기

이 구조가 README 문장으로만 남아 있으면 시간이 지나면서 흐트러지기 마련입니다. 그래서 ca-tmpl은 이 계약을 컴파일되는 타입과 테스트 가능한 경로로 내렸습니다.

4-1. 핵심 타입

// shared-contract/src/main/java/dev/caskeleton/shared/response/Envelope.java
public record Envelope<T>(boolean success, T data, ApiError error, ResponseMeta meta) {

  public static <T> Envelope<T> ok(T data, ResponseMeta meta) {
    return new Envelope<>(true, data, null, meta);
  }

  public static <T> Envelope<T> failure(ApiError error, ResponseMeta meta) {
    return new Envelope<>(false, null, error, meta);
  }
}
// shared-contract/src/main/java/dev/caskeleton/shared/response/ApiError.java
public record ApiError(
    String code, String category, String message, boolean retryable, Object details) {

  public static ApiError of(String code, String category, String message, boolean retryable) {
    return new ApiError(code, category, message, retryable, null);
  }
}

4-2. 실패를 envelope으로 바꾸는 관문

핸들러마다 JSON을 직접 조립하지 않도록, 실패를 envelope으로 변환하는 지점을 하나로 좁혔습니다.

// adapter-web/src/main/java/dev/caskeleton/adapter/web/error/ErrorResponseFactory.java
public static Envelope<Void> body(ApiErrorCode code, String message, Object details) {
  ApiError err =
      details == null
          ? ApiError.of(code.code(), code.category().name(), message, code.retryable())
          : ApiError.withDetails(
              code.code(), code.category().name(), message, code.retryable(), details);
  return Envelope.failure(err, ResponseMetaFactory.fromMdc());
}

4-3. ProblemDetail이 다시 들어오지 못하게 막기

가장 중요한 장치는 이 부분입니다. 설계 결정을 문서에만 남기지 않고, 되돌아가면 빌드가 깨지도록 만들었습니다.

// app-bootstrap/src/test/java/.../CleanArchitectureTest.java
@ArchTest
static final ArchRule NO_PROBLEM_DETAIL_USAGE =
    noClasses()
        .that()
        .resideInAPackage("dev.caskeleton..")
        .should()
        .dependOnClassesThat()
        .haveFullyQualifiedName("org.springframework.http.ProblemDetail");
# app-bootstrap/src/main/resources/application.yml
spring:
  mvc:
    problemdetails:
      enabled: false

이 두 가지는 "개발자가 조심하자" 수준의 약속이 아닙니다. ArchUnit 룰은 빌드 단계에서, 설정값은 회귀 테스트로 각각 강제됩니다.


5. Transport 실패도 같은 봉투에 담기

Validation 실패만 envelope으로 감싸는 건 절반의 해결책입니다. 실제로는 요청이 컨트롤러에 도달하기도 전에 실패하는 경우가 많습니다.

  • 요청 본문이 너무 크면 413
  • 지원하지 않는 Content-Type이면 415
  • 지원하지 않는 HTTP method면 405

ca-tmpl은 이런 실패들을 전부 VALIDATION_FAILED 하나로 뭉개지 않고, PAYLOAD_TOO_LARGE, UNSUPPORTED_MEDIA_TYPE, METHOD_NOT_ALLOWED처럼 구분된 code와 status로 같은 envelope에 담습니다.

여기서 한 가지 주의할 점이 있습니다. Spring MVC의 ResponseEntityExceptionHandler가 이미 처리하고 있는 예외 계열을 @ExceptionHandler로 다시 등록하면 프레임워크의 기본 처리 흐름과 충돌할 수 있습니다. 그래서 이런 경우엔 새로 핸들러를 추가하는 대신 protected override를 사용해, Spring MVC가 가진 흐름 위에서 응답 body만 envelope 모양으로 바꿉니다. 예를 들어 405 응답에서는 Allow 헤더도 그대로 보존합니다.

즉, 바깥 모양은 통일하되 HTTP가 원래 가진 의미까지 지워버리지는 않는다는 원칙입니다.

현재까지 413, 406, 415, 405(+Allow), 412가 이 방식으로 테스트를 통과했습니다.


6. 아직은 말할 수 없는 것들

이 글이 과장되지 않도록, 지금 시점에서 확실한 것과 아닌 것을 분리해 둡니다.

확실한 것 (로컬/개발 검증 완료)

  • Envelope, ApiError, ResponseMeta 등 핵심 타입이 코드로 존재하고 컴파일됩니다.
  • ProblemDetail은 ArchUnit 룰과 설정값으로 금지·비활성화되어 있습니다.
  • ./gradlew check가 통과했고, 위에서 언급한 transport failure row들이 테스트로 검증됐습니다.

아직 아닌 것

  • 운영 환경 배포 및 실제 production metric을 통한 검증은 이루어지지 않았습니다.
  • Retry-After 헤더 발행은 아직 계획 단계입니다.
  • 5xx 오류를 트레이싱 span에 ERROR로 기록하는 부분도 계획 단계입니다.
  • business rule violation을 어떤 category와 details로 세분화할지는 이 설계의 범위 밖이며, 별도 트랙에서 다룹니다.

마무리

API error envelope 설계는 예쁜 JSON을 만드는 작업이 아니라, 실패를 다루는 책임을 어디에 둘 것인지 정하는 작업에 가깝습니다.

ca-tmpl은 ProblemDetail, JSON:API errors, Google rpc.Status 같은 여러 선택지를 검토한 뒤, 성공/실패 응답의 대칭성, 클라이언트가 안정적으로 분기할 수 있는 error code, 운영자가 볼 수 있는 category와 meta, 그리고 예외가 그대로 새어 나가지 않는 일관된 실패 응답 경로를 우선순위로 두고 custom envelope을 선택했습니다.

그리고 그 선택을 문서에만 남기지 않고, 테스트와 ArchUnit 룰로 붙잡아 뒀습니다. 다음 글에서는 Spring Security 필터 레이어의 예외를 같은 envelope에 태우는 과정을 다룰 예정입니다.