Files
llm-wiki/vault/30-knowledge/projects/ca-tmpl/api-error-envelope-design.md
T

16 KiB

title, source_type, status, confidence, tags, related_projects, last_reviewed
title source_type status confidence tags related_projects last_reviewed
ca-tmpl - API Error Envelope 결정 (custom envelope, ProblemDetail 거부) project verified high
ca-tmpl
api-design
error-handling
actually-implemented
locally-verified
ca-tmpl
2026-07-02

ca-tmpl - API Error Envelope 결정 (custom envelope, ProblemDetail 거부)

Layer: wiki/projects/ — 내 프로젝트 사실. 일반 개념은 wiki/concepts/api-error-envelope-design 참고.

프로젝트 컨텍스트

ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 프로젝트다. 운영 환경에서 API 실패 응답을 일관된 구조로 직렬화하고, client가 분기/재시도/관측 가능하도록 만들기 위해 자체 error envelope을 설계했다. RFC 7807 ProblemDetail이 Spring 6+ 기본 지원이지만 의식적으로 거부하고 다음 shape을 채택했다.

{
  success: boolean,
  data: <T> | null,
  error: {
    code: string,
    category: string,
    message: string,
    retryable: boolean,
    details: <항목별 오류 배열> | null
  } | null,
  meta: { requestId, traceId, correlationId, ... }
}

진행 상태: Phase C2 (구현) 완료 (2026-06-01). envelope record, exception handler, error response factory가 코드에 존재하고 ./gradlew check (전 모듈 test + ArchUnit)가 통과한다. 단 Retry-After 헤더 발행과 span ERROR 기록은 seam/stub 상태이며 owner branch에 위임돼 있다(아래 명시).

Ground-truth 대조 (2026-06-04, ca-tmpl @0c996fc "운영 에러 관측성 foundation 계약 구현", HEAD db61075에서도 존재 확인): 아래 envelope field shape·클래스·enum·ArchUnit/config는 ca-tmpl 코드 실측으로 일치 확인. 패키지 root는 dev.caskeleton.* (이전 stale 추출의 com.example.blog/sample-ticket 류는 발견되지 않음 — 현재 sample 모듈은 sample-portfolio). 모듈 경로는 src/<module>/src/main/java/dev/caskeleton/....

실제 구현 내용 (actually-implemented)

/home/donghyeon/workspace/ca-tmpl 코드에 실재 (grep 확인):

  • shared-contract/response/Envelope.javarecord Envelope<T>(boolean success, T data, ApiError error, ResponseMeta meta). success/failure가 한 shape 공유, ok()/failure() 팩토리. RFC 7807 거부 javadoc 명시.
  • shared-contract/response/ApiError.javarecord ApiError(String code, String category, String message, boolean retryable, Object details). category가 1급 필드(10-enum 이름), retryable 1급, details는 code별 polymorphic.
  • shared-contract/response/ResponseMeta.javarecord ResponseMeta(String requestId, String traceId, String correlationId, ...) — 평면 traceId를 대체한 meta 객체(D20).
  • shared-contract/error/Category.java — 10-value 운영 분류 enum.
  • shared-contract/error/OperationalError.java + error/ApiErrorCode.java — code 카탈로그 + category() 매핑(VALIDATION_FAILED/MAPPING_FAILED/… → VALIDATION, UNAUTHENTICATED/INVALID_TOKENAUTH, FORBIDDENAUTHZ, ROUTE_NOT_FOUNDNOT_FOUND, INTERNAL_ERRORINTERNAL). retryable은 per-code 유지.
  • adapter-web/error/GlobalExceptionHandler.java + error/ErrorResponseFactory.java — 예외 → envelope 변환, code.category().name() 주입.
  • adapter-web/envelope/EnvelopeBodyAdvice.java — 성공 응답 envelope 래핑.
  • feature-api-contract-baseline 이후 transport failure 매핑 — 413(PAYLOAD_TOO_LARGE), 406(NOT_ACCEPTABLE), 415(UNSUPPORTED_MEDIA_TYPE), 405(METHOD_NOT_ALLOWED + Allow header), 412(PRECONDITION_FAILED) 를 같은 envelope shape으로 반환하되, category/status 의미는 보존한다. Spring MVC ResponseEntityExceptionHandler 가 이미 다루는 umbrella exception은 @ExceptionHandler 중복 등록이 아니라 protected override로 처리한다.

ProblemDetail 거부가 빌드 타임에 강제된다 (코드 실측):

  • app-bootstrap/.../architecture/CleanArchitectureTest.java (ArchUnit) — org.springframework.http.ProblemDetail import 금지 규칙(L355 "D5: RFC 7807 ProblemDetail is explicitly rejected").
  • app-bootstrap/src/main/resources/application.ymlspring.mvc.problemdetails.enabled: false로 pin.
  • app-bootstrap/.../settings/ProblemDetailDisabledConfigTest.java — shipped application.yml이 그 플래그를 literal false로 유지하는지 검증 (default flip 회귀 방지).

로컬/dev 검증 (locally-verified)

./gradlew check (전 모듈 test + verifyCleanArchitectureDependencies + ArchUnit CleanArchitectureTest) BUILD SUCCESSFUL (2026-06-01). 검증 테스트: EnvelopeTest/ApiErrorTest/CategoryTest(shared-contract), EnvelopeMetaIntegrationTest(adapter-web standalone MockMvc — meta/category 필드 + leak 차단). 단 운영(prod) 검증은 아직 없음.

feature-api-contract-baseline 의 transport failure envelope 범위는 TransportErrorHandlingTest 로 413/406/415 distinct + 405 Allow header를 검증했고, WorkLogControllerWireTestIf-Match mismatch → 412 envelope 흐름을 검증했다. 이 검증은 framework/transport failure를 domain validation과 같은 원인으로 섞는 것이 아니라, 같은 response shape 안에서 status/code/category를 보존하는 범위다.

운영 검증 (prod-verified)

없음. 운영 배포 자체가 존재하지 않는다.

설계 결정 (구현됨 — actually-implemented + locally-verified)

2026-06-01 이전에는 본 섹션 전체가 documented-only였으나 Phase C2로 envelope schema가 코드화·로컬 검증됨. 아래 schema 결정·leak catalog는 이제 코드에 반영돼 있다. 단 Retry-After 헤더 발행 / 5xx span ERROR 기록은 여전히 seam/stub(owner branch 위임), business rule violation → envelope 변환은 다른 branch 책임이다(아래 명시).

Envelope schema 결정

  • success / error 대칭 envelope: 성공도 동일한 top-level shape으로 감싸 success: true/false 분기를 client에 단일 규칙으로 제공.
  • error.code (머신리더블 식별자) 와 error.category (운영 분류) 를 별도 1급 필드로 분리.
  • error.retryable: boolean을 1급 필드로 승격. client 재시도 정책을 envelope 자체에서 가이드.
  • error.details로 항목 단위 오류(예: validation field error) 를 배열로 운반.
  • metarequestId, traceId, correlationId를 1급으로 노출 — 로그/트레이스와 응답을 join 가능.

출처: raw/project-notes/ca-skeleton-operational-contract §3 (Structured API Response Contract) / §5 (Exception Ownership Contract) / §6 (Operational Error Category).

5종 envelope 대안 검토 결과

raw/project-notes/ca-skeleton-operational-contract §29 Topic 4에서 다음 5종을 비교 후 custom envelope 채택.

후보 거부 사유
RFC 7807 ProblemDetail 실패 전용 평면 shape — success/error 대칭 요구와 구조적 충돌. code/retryable/category 표준 부재로 결국 표준 위에 사실상 custom 레이어 추가가 필요.
Google rpc.Status gRPC/protobuf 결합. HTTP REST 전용에서 Any 디코딩 부담을 client에 전가. CRUD 비중 큰 skeleton에 과한 표현력.
JSON:API errors errors[] + source.pointer는 항목 단위 강점이나 category/retryable 1급 필드 없음. 부분 채택 시 표준성 상실, 완전 채택 시 spec 전체 lock-in.
GraphQL errors HTTP 200 + errors 규약. REST envelope과 패러다임 자체가 다름. CDN/proxy/observability 4xx/5xx 알람과 부조화.
Custom envelope (채택) 표준 client SDK가 0개라는 비용을 감수하는 대신 success/error 대칭 + retryable/category 1급화 + observability 메타 노출이라는 운영 요구를 충족.

Exception leak 금지 항목 catalog

응답 envelope에 절대 노출 금지로 계약된 항목:

  • exception class fully-qualified name
  • stack trace 전체 또는 일부
  • SQL / SQL fragment / bind parameter
  • token / credential / secret 값
  • raw request body / raw upstream response body

출처: raw/branch-notes/feature-operational-error-observability-foundation (envelope schema SSOT 및 leak 금지 catalog).

Validation / business rule 매핑

foundation 측 exception → envelope 변환 골격(GlobalExceptionHandler/ErrorResponseFactory)은 구현됨. 위 항목별 매핑 세부(validation field 매핑 / business invariant 분류)는 각 owner branch 책임이다.

Blog-topic ingest: spring-responseentityexceptionhandler-transport-failure-envelope (2026-07-02)

raw/blog-topics/spring-responseentityexceptionhandler-transport-failure-envelope-2026-07-02 는 Spring MVC transport failure를 custom envelope에 태운 경험을 블로그로 풀기 위한 raw seed다. canonical 승격 기준은 다음처럼 정리했다.

  • locally-verified 로 말할 수 있는 부분: 413/406/415/405(+Allow) transport failure와 412 precondition failure가 ca-tmpl envelope shape으로 매핑되고 테스트된다.
  • source-backed 로 말할 수 있는 부분: 406/415/405/412/413의 HTTP status 의미는 RFC 9110 계열 근거와 기존 api-evolution-and-schema project canonical에 연결된다.
  • project-local implementation 으로 말할 부분: Spring MVC ResponseEntityExceptionHandler 흐름을 깨지 않기 위해 umbrella exception은 protected override로 처리한다는 구현 선택.
  • 블로그 전 과장 방지: Spring MVC의 모든 예외가 envelope으로 포괄된다고 쓰지 않는다. 검증된 transport failure row와 owner branch 범위로 제한한다.

Blog-topic ingest: operational-error-envelope-meta-category-migration (2026-07-02)

raw/blog-topics/operational-error-envelope-meta-category-migration-2026-06-01 는 기존 {success,data,error,traceId} 응답을 error.categorymeta.{requestId,traceId,correlationId}가 있는 richer envelope로 additive migration한 경험을 블로그로 풀기 위한 raw seed다.

  • canonical 반영 범위: verified error envelope 구현 문서에 meta/category migration, enum vocabulary, response meta factory 글감을 연결했다.
  • blogify 전 가능 범위: 이 canonical은 verified 이므로 blogify 후보가 될 수 있다.
  • 블로그 전 과장 방지: 운영 배포/운영 검증이 아니라 코드 구현 + 로컬 검증 범위로 제한한다.
  • raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08: Spring Security filter-layer 인증/인가 실패를 custom AuthenticationEntryPoint / AccessDeniedHandler에서 같은 envelope shape으로 직렬화하는 글감. 보안 adapter 구현 여부와 heuristic 분류 한계를 재확인한다.

문서/계획만 존재 (documented-only / planned)

  • Retry-After 헤더 발행 (planned): rate-limit owner branch 위임. GlobalExceptionHandler 내 seam/stub 상태. 실제 헤더 발행 로직은 미구현.
  • 5xx span ERROR 기록 (planned): distributed-tracing owner branch 위임. span 조립/에러 마킹 로직은 seam/stub 상태.
  • business rule violation → error.category 매핑 세부 (planned): raw/branch-notes/feature-business-rule-validation-contract 담당. use case 내부 invariant 위반을 error.category·error.details로 분류하는 세부 정책은 foundation 측 골격만 존재하고 실제 분류 로직은 미구현.

면접에서 말할 수 있는 범위

자신 있게 답할 수 있는 질문

  • ProblemDetail을 채택하지 않은 이유 — 실패 전용 평면 shape이라 success/error 대칭 요구와 구조적으로 충돌하고, code/retryable/category가 표준 부재라 결국 표준 위에 custom 레이어가 또 필요해진다.
  • retryable을 1급 필드로 둔 의미 — client 재시도 정책을 envelope 자체에서 가이드하기 위함. 단, RetryInfo.retry_delay 수준의 actionable delay 정보는 잃는다는 trade-off까지 인지.
  • validation error를 error.details에 매핑하는 정책의 의도 (boundary vs business rule 구분).
  • exception leak 금지 항목 catalog와 각 항목이 왜 금지인지.

적당히 답할 수 있는 질문

  • Stripe / GitHub / 토스페이먼츠 envelope과 ca-tmpl envelope의 차이점.
  • JSON:API source.pointer 와 ca-tmpl error.details[].field 표현의 비교.

말할 수 있는 범위 (구현 사실 + 한계)

  • "이 envelope을 코드로 구현했는가" — 답: 그렇다 (actually-implemented + locally-verified). Envelope/ApiError/ResponseMeta record + GlobalExceptionHandler가 코드에 있고 ./gradlew check 통과. 단 로컬 검증까지다.
  • "운영에서 어떻게 동작하는가 / 운영 측정값" — 답: 운영(prod) 검증은 없음. 로컬 빌드/테스트 수준까지만.
  • "Retry-After 헤더·5xx span ERROR 기록도 동작하는가" — 답: seam/stub 단계. 헤더 발행/span 조립은 rate-limit·distributed-tracing owner branch 위임.

과장 금지 지점

  • "ca-tmpl envelope이 표준이다". 어떤 IETF/W3C 표준도 success/error 대칭 + retryable 1급 + category 1급을 동시에 강제하지 않는다. 자체 결정이다.
  • "ProblemDetail이 잘못된 설계다". 실패 전용 use case (외부 노출 API, RFC 9457 client 생태계 활용)에서는 유효한 선택이다. ca-tmpl의 요구 조합과 맞지 않았을 뿐이다.
  • "envelope을 운영에서 검증했다". 코드 구현 + ./gradlew check 로컬 통과까지(locally-verified)이며, prod 배포·측정은 없다. "구현했다"는 OK, "운영 검증했다"는 과장.
  • "Stripe/GitHub/토스가 다 custom이니까 표준은 의미 없다". 그들은 SDK가 envelope을 흡수하는 전제 위에 동작한다. 표준 미준수가 정당화되는 게 아니라 trade-off가 다른 것뿐이다.

관련 개념

Sources

Cluster / 묶음