18 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 | verified | high |
|
|
2026-07-02 |
|
backend-engineer | ready |
API Error Envelope을 프로젝트 계약으로 고정하기
Parent / 부모 (필수)
- Canonical source: wiki/projects/ca-tmpl/api-error-envelope-design
- Supporting concept: wiki/concepts/api-error-envelope-design
타깃 독자 / Target reader
- Spring Boot 기반 REST API에서 error response contract를 정해야 하는 백엔드 엔지니어.
- 이미 HTTP status, Spring MVC exception handling, validation error mapping의 기본은 알고 있다고 가정한다.
- 이 글에서 처음 보게 될 포인트:
ProblemDetail을 거부하는 이유가 "표준이 싫어서"가 아니라, 프로젝트가 요구한 success/error 대칭 envelope과 운영 메타데이터 계약 때문이라는 점.
도입 / Hook
API 실패 응답은 보통 나중에 정리하려고 미루기 쉽다. 그런데 validation, security, transport failure, business rule violation이 각자 다른 JSON shape을 반환하기 시작하면 client는 실패 원인을 안정적으로 분기할 수 없고, 운영자는 응답과 로그/트레이스를 한 번에 이어 보기 어렵다.
ca-tmpl에서는 Spring 6+의 ProblemDetail을 그대로 쓰지 않고, { success, data, error, meta } 형태의 custom envelope을 프로젝트 계약으로 고정했다. 이 글은 그 결정이 어떤 요구에서 나왔고, 어디까지 코드로 구현되고 로컬 검증됐으며, 아직 구현됐다고 말하면 안 되는 부분이 무엇인지 정리한다.
본문 outline / Body outline
-
실패 응답 shape이 흩어질 때 생기는 문제
- validation, security, transport failure가 서로 다른 응답 구조를 만들면 client 분기와 테스트가 어려워진다.
- ca-tmpl의 목표는 모든 실패를 같은 원인으로 섞는 것이 아니라, 같은 envelope 안에서 status/code/category 의미를 보존하는 것이다.
-
왜
ProblemDetail을 그대로 쓰지 않았나ProblemDetail은 실패 전용 평면 shape이다.- ca-tmpl은 성공과 실패를 같은 top-level envelope으로 감싸고,
error.code,error.category,error.retryable,error.details,meta를 1급 계약으로 두고 싶었다. - 따라서 표준 위에 다시 custom 확장층을 얹기보다 프로젝트 전용 envelope을 명시적으로 선택했다.
-
ca-tmpl envelope의 핵심 필드
success: client의 1차 분기 기준.data: 성공 응답 payload.error.code: machine-readable error identifier.error.category: 운영 분류.error.retryable: client retry 판단의 최소 힌트.error.details: validation field error 같은 항목별 오류.meta:requestId,traceId,correlationId로 응답과 로그/트레이스를 연결하는 영역.
-
구현으로 고정한 계약
Envelope,ApiError,ResponseMeta,Category,OperationalError,ApiErrorCode.GlobalExceptionHandler,ErrorResponseFactory,EnvelopeBodyAdvice.ProblemDetailimport 금지 ArchUnit rule.spring.mvc.problemdetails.enabled: falsepin과 config regression test.
-
transport failure까지 같은 shape으로 태우기
- 413, 406, 415, 405(+
Allow), 412는 envelope shape으로 반환되도록 테스트됐다. - Spring MVC
ResponseEntityExceptionHandler가 이미 다루는 umbrella exception은 중복@ExceptionHandler가 아니라 protected override로 다룬다. - 이 범위는 검증된 transport row에 한정한다.
- 413, 406, 415, 405(+
-
아직 말하면 안 되는 부분
- 운영 배포와 prod metric 검증은 없다.
Retry-Afterheader 발행은 planned/stub이다.- 5xx span ERROR 기록도 planned/stub이다.
- business rule violation의 세부 category/details mapping은 별도 owner branch 책임이다.
본문 / Body
API error response는 처음에는 작아 보입니다. 실패하면 적당한 HTTP status와 message만 내려주면 될 것처럼 보입니다. 그런데 프로젝트가 커지면 이야기가 달라집니다. validation 실패는 field 목록을 내려주고, 인증 실패는 Spring Security가 다른 shape을 만들고, 잘못된 Content-Type이나 큰 request body는 Spring MVC transport layer에서 또 다른 응답을 만들 수 있습니다.
이 상태가 오래가면 client 입장에서는 "실패했다"는 사실보다 "이번 실패는 어떤 모양으로 오지?"를 먼저 걱정해야 합니다. 운영하는 사람 입장에서도 비슷합니다. 응답에 trace id가 있는지, 재시도해도 되는 오류인지, validation 문제인지 인증 문제인지가 매번 다른 위치에 있으면 로그와 응답을 이어서 보기 어렵습니다.
ca-tmpl에서 API Error Envelope을 먼저 계약으로 잡은 이유는 여기에 있습니다. 목표는 모든 실패를 똑같은 원인으로 뭉개는 것이 아니었습니다. HTTP status와 error code의 의미는 유지하되, client가 읽는 바깥 구조를 하나로 맞추는 것이었습니다.
쉽게 말하면 실패 응답에도 "봉투"를 하나 씌운 셈입니다. 봉투 바깥에는 success, data, error, meta가 있고, 실패일 때는 success=false, data=null, error에 실제 오류 정보가 들어갑니다. meta에는 요청과 로그, trace를 이어 볼 수 있는 id들이 들어갑니다.
{
"success": false,
"data": null,
"error": {
"code": "VALIDATION_FAILED",
"category": "VALIDATION",
"message": "Request body failed validation",
"retryable": false,
"details": []
},
"meta": {
"requestId": "...",
"traceId": "...",
"correlationId": "..."
}
}
여기서 중요한 점은 이 구조가 단순히 보기 좋은 JSON이 아니라는 것입니다. error.code는 client가 분기할 수 있는 machine-readable identifier입니다. message는 사람이 읽는 문장이므로 client 로직이 여기에 의존하면 안 됩니다. error.category는 운영 분류입니다. validation 문제인지, auth 문제인지, dependency 문제인지 같은 큰 묶음을 나타냅니다. retryable은 client가 재시도를 검토할 수 있게 해 주는 최소 힌트입니다. details는 validation field error처럼 항목별 정보가 필요한 경우에만 채웁니다.
그럼 Spring 6+에서 제공하는 ProblemDetail을 쓰면 되지 않을까요? 이 질문이 자연스럽습니다. ProblemDetail은 RFC 7807 계열의 실패 응답 모델이고, Spring에서도 기본 지원합니다. 하지만 ca-tmpl의 요구와는 결이 달랐습니다.
ProblemDetail은 실패 전용 평면 shape입니다. 반면 ca-tmpl은 성공과 실패를 모두 같은 top-level envelope으로 감싸고 싶었습니다. 성공 응답도 success=true, 실패 응답도 success=false로 읽히게 만들고 싶었던 것입니다. 또한 ca-tmpl은 code, category, retryable, meta를 프로젝트 계약의 1급 필드로 두고 싶었습니다. ProblemDetail 위에 확장 필드를 계속 얹으면 결국 표준을 쓰는 척하면서 실제로는 custom envelope을 하나 더 만든 셈이 됩니다.
그래서 ca-tmpl의 선택은 "ProblemDetail이 나쁜 설계라서 버린다"가 아니었습니다. 실패 전용 표준 모델보다, 이 skeleton이 원하는 success/error 대칭 구조와 운영 메타데이터가 더 중요했기 때문에 custom envelope을 명시적으로 선택한 것입니다.
이 결정은 문서에만 남아 있지 않습니다. shared-contract 모듈에는 Envelope, ApiError, ResponseMeta가 있고, web adapter에는 예외를 envelope으로 바꾸는 GlobalExceptionHandler와 ErrorResponseFactory가 있습니다. 즉 "우리 프로젝트는 이런 실패 응답을 쓴다"가 README 문장에 머문 것이 아니라, 컴파일되는 타입과 테스트 가능한 경로로 내려왔습니다.
Envelope는 성공과 실패가 같은 바깥 구조를 공유한다는 결정을 담습니다. 성공이면 data가 있고 error가 없습니다. 실패이면 error가 있고 data가 없습니다. ApiError는 실패 안쪽의 구조를 고정합니다. 특히 category와 retryable을 field로 올려 둔 점이 중요합니다. 이 둘을 message 안에 섞어 두면 client와 운영 도구가 안정적으로 읽기 어렵습니다.
ErrorResponseFactory는 이 결정을 Spring MVC 응답으로 바꾸는 관문입니다. ApiErrorCode가 가진 HTTP status, code, category, retryable 값을 읽고 Envelope.failure(...)를 만들어 냅니다. 이 관문이 있으면 handler마다 JSON을 직접 조립하지 않아도 됩니다. 실패 응답을 만드는 길을 하나로 좁혀 두는 효과가 있습니다.
또 하나 중요한 장치는 ProblemDetail을 다시 들여오지 못하게 막는 것입니다. ca-tmpl은 spring.mvc.problemdetails.enabled=false를 application.yml에 명시하고, 그 값이 유지되는지 테스트합니다. 여기에 더해 ArchUnit rule로 production code가 org.springframework.http.ProblemDetail에 의존하지 못하게 막습니다. 이것은 "개발자가 조심하자" 수준의 약속이 아니라, build가 깨지는 계약입니다.
transport failure를 같은 envelope에 태운 것도 이 글의 핵심입니다. 예를 들어 request body가 너무 크면 413, 지원하지 않는 Content-Type이면 415, 지원하지 않는 HTTP method면 405가 됩니다. 이때 status의 의미는 그대로 보존해야 합니다. ca-tmpl은 이런 실패들을 모두 VALIDATION_FAILED 같은 하나의 오류로 뭉개지 않고, PAYLOAD_TOO_LARGE, UNSUPPORTED_MEDIA_TYPE, METHOD_NOT_ALLOWED처럼 구분된 code/status로 envelope에 담습니다.
특히 Spring MVC의 ResponseEntityExceptionHandler가 이미 처리하는 계열은 주의가 필요합니다. 같은 예외를 @ExceptionHandler로 다시 등록하면 framework가 가진 처리 흐름과 충돌할 수 있습니다. ca-tmpl은 이런 경우 protected override를 사용해서 Spring MVC의 흐름 위에서 body만 envelope shape으로 바꿉니다. 예를 들어 405에서는 Allow header도 함께 보존합니다. 실패 응답의 바깥 shape은 통일하지만, HTTP가 가진 의미까지 지워 버리지는 않는다는 뜻입니다.
다만 이 글에서 말할 수 있는 범위는 분명히 제한해야 합니다. 현재 근거는 코드 구현과 로컬 검증입니다. canonical 문서 기준으로 ./gradlew check가 통과했고, 413/406/415/405(+Allow)/412 같은 transport failure row가 테스트됐다고 말할 수 있습니다. 하지만 운영 배포에서 검증했다거나, 실제 production metric으로 개선을 확인했다고 말할 수는 없습니다.
아직 planned/stub으로 남은 것도 있습니다. Retry-After header 발행은 rate-limit owner branch의 책임으로 남아 있습니다. 5xx span ERROR 기록도 tracer-neutral seam은 있지만, 이 글에서 운영 추적이 완성됐다고 말하면 안 됩니다. business rule violation을 어떤 category와 details로 세분화할지도 foundation이 아니라 별도 owner branch의 범위입니다.
정리하면 ca-tmpl의 API Error Envelope은 "표준을 몰라서 만든 custom JSON"이 아닙니다. ProblemDetail, JSON:API errors, Google rpc.Status 같은 선택지를 비교한 뒤, 이 skeleton이 더 중요하게 본 요구를 코드 계약으로 고정한 결과입니다. 그 요구는 성공/실패 응답의 대칭성, client가 읽을 수 있는 안정적인 error code, 운영자가 볼 수 있는 category와 meta, 그리고 exception leak을 막는 일관된 실패 응답 경로였습니다.
좋은 error response 설계는 예쁜 JSON을 만드는 일이 아니라, 실패를 다루는 책임을 어디에 둘지 정하는 일에 가깝습니다. ca-tmpl의 선택은 그 책임을 프로젝트 초기에 명시하고, 테스트와 ArchUnit rule로 회귀하지 않게 붙잡아 둔 사례입니다.
코드 예제 / Code samples (있다면)
// 출처: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
// 실제 파일: 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);
}
}
// 출처: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
// 실제 파일: 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);
}
}
// 출처: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
// 실제 파일: 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());
}
// 출처: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
// 실제 파일: 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");
# 출처: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
# 실제 파일: app-bootstrap/src/main/resources/application.yml
spring:
mvc:
problemdetails:
enabled: false
Sources / 근거 (canonical 인용 필수, derived layer 의무)
- wiki/projects/ca-tmpl/api-error-envelope-design - 이 글의 1차 canonical.
verified,actually-implemented,locally-verified범위와 과장 금지 항목을 따른다. - wiki/concepts/api-error-envelope-design -
ProblemDetail, Googlerpc.Status, JSON:API errors, GraphQL errors, custom envelope trade-off를 정리한 개념 canonical. - raw/project-notes/ca-skeleton-operational-contract - Structured API Response Contract, Exception Ownership Contract, Operational Error Category, Topic 4 envelope 대안 검토.
- raw/branch-notes/feature-operational-error-observability-foundation - envelope schema SSOT와 exception leak 금지 catalog.
- raw/branch-notes/feature-api-contract-baseline - 413/406/415/405(+
Allow)/412 transport failure envelope mapping 검증. - raw/blog-topics/spring-responseentityexceptionhandler-transport-failure-envelope-2026-07-02 - Spring MVC transport failure envelope 글감.
- raw/blog-topics/operational-error-envelope-meta-category-migration-2026-06-01 -
error.category와metamigration 글감. - raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08 - Spring Security filter-layer envelope 후속 글감.
사실 vs 의견 / Fact vs opinion 구분
- 사실: ca-tmpl에는
Envelope,ApiError,ResponseMeta,Category,OperationalError,GlobalExceptionHandler,ErrorResponseFactory,EnvelopeBodyAdvice가 코드로 존재한다. - 사실:
ProblemDetail은 ArchUnit rule과spring.mvc.problemdetails.enabled: false설정으로 금지/비활성화되어 있다. - 사실:
./gradlew check가 2026-06-01에 통과했고, 413/406/415/405(+Allow)/412 transport failure mapping이 테스트로 검증됐다. - 사실: 운영 배포와 prod 검증은 없다.
- 의견: ca-tmpl의 요구 조합에서는
ProblemDetail위에 확장을 쌓는 것보다 custom envelope을 명시적으로 고정하는 편이 더 설명 가능하다. - 의견:
retryable과category를 1급 필드로 두면 client/retry/observability 설계가 단순해진다. 다만RetryInfo.retry_delay같은 더 구체적인 표준 정보를 잃는 trade-off가 있다.
답할 수 있는 범위 / Answer boundary
- 자신 있게 답할 수 있음: ca-tmpl이 왜
ProblemDetail을 채택하지 않았는지. - 자신 있게 답할 수 있음: envelope field shape과 각 필드의 책임.
- 자신 있게 답할 수 있음: 어떤 클래스와 테스트로 계약을 고정했는지.
- 자신 있게 답할 수 있음: 어떤 transport failure row가 envelope으로 검증됐는지.
- 제한해서 답해야 함: 운영에서의 동작. 현재는 local/dev verification까지만 말한다.
- 제한해서 답해야 함:
Retry-After, 5xx span ERROR, business rule details mapping. 현재 문서 기준으로는 planned/stub 또는 별도 owner branch 범위다.
게시 체크리스트 / Publish checklist
- 원천 canonical이
reviewed | verified | published-ready상태인지 확인 - derived 문서가 raw를 1차 근거처럼 사용하지 않는지 확인
- 코드 발췌가 실제 ca-tmpl 코드와 일치하는지 확인
actually-implemented,locally-verified,prod-verified범위를 분리했는지 확인- 금지 마케팅 표현을 쓰지 않았는지 확인
canonical_sources를 실제 인용 canonical로 채웠는지 확인- 본문 작성 후
status_label을ready로 갱신했는지 확인
Related / 관련
- 관련 project 문서: wiki/projects/ca-tmpl/api-error-envelope-design
- 관련 concept 문서: wiki/concepts/api-error-envelope-design
- 후속 글 후보: raw/blog-topics/spring-responseentityexceptionhandler-transport-failure-envelope-2026-07-02
- 후속 글 후보: raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08
- 후속 글 후보: raw/blog-topics/operational-error-envelope-meta-category-migration-2026-06-01