Files
llm-wiki/raw/branch-notes/feature-outbound-http-client-baseline.md

489 lines
55 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: branch / feature-outbound-http-client-baseline
source_type: branch-note
status: raw
branch: feature-outbound-http-client-baseline
parent_branch:
governing_docs: [wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]
related_projects: [ca-skeleton]
tags: [branch, ca-skeleton, outbound-http, rest-client, adapter]
created: 2026-05-21
target_merge:
status_label: in-progress
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-007
kind: project-work-item
project: ca-skeleton-operational-contract
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-007
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RESILIENCE-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TESTCONTAINERS-001@1]
refines: []
overrides: []
depends_on: []
contract_packet: 1
contract_packet_sha256: 17707c8f1f903e49e2466e6f22228fa014375942bc5e97f2098b8b3ffdcac255
---
# branch: feature-outbound-http-client-baseline
> Layer: `raw/branch-notes/` — outbound HTTP adapter 실패 분류와 RestClient baseline을 정의합니다.
> 구조 메모: 이 노트는 2026-05-21 생성(현 branch-note 템플릿 이전 포맷). 2026-06-10 `/branch-spec` 에서 템플릿 순서로 재정렬 + §구현 가이드·§엣지·실패·의존·§Audit & Findings·§관련 일일 노트 신설. 템플릿에 없는 pre-template 보조 섹션(Work Item Contract / Decisionized Work Items / 테스트 계약 / 외부 근거)은 삭제하지 않고 관련 템플릿 섹션 옆에 슬롯해 보존했다.
> [!note] 개정 예정 고지 (2026-07-28)
> 본 branch 는 **owner 를 유지**하되 내용을 코드 사실에 맞춰 개정한다. 근거: `docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md` §5.3.
>
> - **개정 사유**: `globalCallTimeout` 이 실행 중 호출을 취소하지 않고 다음 재시도 진입 여부만 판정하며, decorator 순서가 문서 서술과 다르다. total deadline 의 능동 취소와 순서 계약을 재판정한다.
> - **참조 설계**: ca-tmpl `docs/superpowers/specs/2026-07-27-httpclient-production-capability-design.md` §3·§15·§17~§20 (등급 `internal-design-doc` — 공식 문서 아님)
<!-- section-id: branch-parent -->
## 부모 (필수)
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§11 Adapter Failure Contract — Outbound HTTP, §32.3 Outbound HTTP / Resilience, 외부 근거 Group G-C) 의 결정/근거/금지 사항을 정제한다.
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: timeout·retry·circuit-breaker contract test가 통과한다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-RESILIENCE-001@1` | Resilience4j가 default이며 Spring Retry는 simple blocking retry에만 허용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TESTCONTAINERS-001@1` | Testcontainers는 persistence/outbound integration test에 사용하고 unit·architecture·contract test에서는 금지한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
<!-- section-id: declared-overrides -->
### 선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|---|---|---|---|
<!-- GENERATED: branch-contract:end -->
<!-- section-id: branch-goal -->
## 목표
외부/내부 API 호출 실패를 HTTP status만으로 처리하면 원인과 운영 조치가 흐려집니다. RestClient를 기본 표준으로 두고 status, timeout, DNS, connect failure, retry/backoff/circuit breaker 기준을 정리합니다.
- 이슈:
- PR:
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- RestClient baseline.
- upstream 4xx/5xx 분류.
- timeout/connect/DNS failure 분류.
- outbound dependency log field.
- request/response body logging 금지.
- allowlist 기반 redaction 기준.
- retry/backoff/circuit breaker 도입 기준.
### 제외 범위
- WebClient 기본 탑재.
- provider-specific SDK 구현.
- business-specific upstream contract.
## 근거 (필수, 최소 1개+)
> 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조.
| Source | 정당화하는 결정 |
|---|---|
| [[raw/official-docs/outbound-spring-restclient-baseline]] | RestClient baseline 채택의 공식 근거 + RestTemplate maintenance 상태 |
| [[raw/official-docs/outbound-resilience4j-vs-spring-retry]] | Resilience4j 채택 + Spring Retry 좁은 예외 허용 + Hystrix 배제 근거 |
| [[raw/official-docs/outbound-webclient-vs-restclient-spring]] | WebClient를 baseline에서 배제하고 extension doc으로 미루는 이유 (reactor event-loop blocking risk |
| [[raw/official-docs/outbound-openfeign-declarative-client]] | OpenFeign declarative 대안 + maintenance status + Spring 6 |
| [[raw/company-tech-blogs/outbound-stripe-rate-limit-retry-engineering]] | retry + idempotency-key 결합, full-jitter backoff |
| [[raw/official-docs/resilience4j-micrometer-module]] | D4 — CircuitBreaker `resilience4j.circuitbreaker.calls`/`state` metric 명 + default tag (`kind`/`name`) 의 vendor 공식 근거 (ca-tmpl 의 `dependency.name`/`dependency.type`/`outcome` 재매핑 대상) |
| [[raw/official-docs/spring-restclient-builder-reference]] | D5/D7 mechanism — RestClient builder + 5개 `ClientRequestFactory` 추상화 (timeout 정량 값은 vendor 미권고 — UNSUPPORTED 유지) + default 4xx/5xx error handling |
| [[raw/official-docs/spring-smartlifecycle-reference]] | D8 — `SmartLifecycle` interface (`Lifecycle` + `Phased`) + startup ascending/shutdown descending phase + `stop(Runnable)` graceful shutdown 의 vendor 공식 근거 |
| [[raw/official-docs/rfc9110-http-semantics]] | D6 — idempotent method 정의 (PUT/DELETE + safe GET/HEAD/OPTIONS/TRACE) + client SHOULD NOT auto-retry non-idempotent (RFC 9110 §9.2.2) 의 official-standard 근거 |
## 외부 근거 (Group G-C — Outbound HTTP)
ca-tmpl outbound HTTP baseline 결정 + 대안 비교 자료.
- 채택 결정의 공식 근거:
- [[raw/official-docs/outbound-spring-restclient-baseline]] — RestClient baseline 채택의 공식 근거 + RestTemplate maintenance 상태.
- [[raw/official-docs/outbound-resilience4j-vs-spring-retry]] — Resilience4j 채택 + Spring Retry 좁은 예외 허용 + Hystrix 배제 근거.
- 대안 비교:
- [[raw/official-docs/outbound-webclient-vs-restclient-spring]] — WebClient를 baseline에서 배제하고 extension doc으로 미루는 이유 (reactor event-loop blocking risk).
- [[raw/official-docs/outbound-openfeign-declarative-client]] — OpenFeign declarative 대안 + maintenance status + Spring 6.1+ `@HttpExchange` 후속.
- 사례 / 산업 패턴:
- [[raw/company-tech-blogs/outbound-stripe-rate-limit-retry-engineering]] — retry + idempotency-key 결합, full-jitter backoff. ca-tmpl default-disabled의 보수성 vs Stripe default-enabled 대비.
검색 키워드 기록: `Spring RestClient maintenance RestTemplate`, `Resilience4j vs Spring Retry circuit breaker`, `WebClient blocking reactor event loop`, `Spring Cloud OpenFeign maintenance @HttpExchange`, `Stripe rate limiters engineering blog`.
## TODO
> TODO drained — 결정은 아래 표/결정 사항 참조.
## 진행 중 메모
- WebClient는 별도 extension 문서에서만 다루며, baseline은 RestClient로 고정합니다.
## 결정 사항
- 2026-05-21: 기본 outbound HTTP는 RestClient 기준.
- 2026-05-22: retry/circuit breaker 기본 라이브러리는 Resilience4j. Spring Retry는 simple blocking retry에만 예외 허용.
- 2026-05-22: retry 기본값은 disabled이며, 활성화 시 retryable registry error와 low-cardinality retry metric이 필수.
- 2026-05-22: circuit breaker metric은 `dependency.name`, `dependency.type`, `outcome`까지만 tag로 허용.
- 2026-05-22: outbound HTTP timeout default = connect 2s / read 5s / global call 10s. timeout 미설정 또는 무한 timeout은 forbidden. per-endpoint override는 capability registry에 등록 시에만 허용.
- 2026-05-22: retry 분기는 idempotent method(GET/HEAD/PUT/DELETE)만 default retry 허용, POST/PATCH는 idempotency key 헤더가 있을 때만 retry 허용.
- 2026-05-22: response size limit default = 10MB streaming threshold. 초과 시 streaming 처리 의무.
- 2026-05-22: shutdown 중 retry suppression 의무. ApplicationListener<ContextClosedEvent> 또는 동등 mechanism으로 retry policy를 NO_RETRY로 전환. shutdown 중 신규 호출은 즉시 fail-fast (timeout 대기 금지).
- 2026-05-22: retry/DLQ vocabulary는 background-job-async-contract SSOT consume. 본 branch는 outbound-specific Resilience4j 도구 결정만 owns.
## Work Item Contract
각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다.
| field | required | rule |
| --- | --- | --- |
| Decision | yes | 구현자가 선택해야 하는 기본값 |
| Allowed | yes | 허용되는 예외와 조건 |
| Forbidden | yes | 절대 금지되는 구현/문서 상태 |
| Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 |
| Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 |
| Failure condition | yes | review/build에서 실패로 판정할 상태 |
| Canonical extraction target | yes | `wiki/projects` 승급 위치 |
## Decisionized Work Items
| item | Decision | Allowed | Forbidden | Required test |
| --- | --- | --- | --- | --- |
| client | Spring RestClient baseline | WebClient extension doc | provider SDK bypassing mapper | adapter contract |
| retry | Resilience4j disabled by default | Spring Retry simple blocking | retry all 4xx | retry classification |
| circuit breaker | Resilience4j optional env | disabled local | no metric when enabled | metric assertion |
| body logging | request/response body off | allowlisted metadata only | raw upstream body in log/response | leakage test |
| redaction | allowlist only | provider-specific safe fields | blacklist-only secret control | redaction test |
| timeout | connect=2s, read=5s, global call=10s | per-endpoint override via capability registry | timeout 미설정 또는 무한 timeout | outbound client bean이 timeout 미설정으로 등록되면 fail |
| retry method scope | idempotent (GET/HEAD/PUT/DELETE) default retry | POST/PATCH는 idempotency key 헤더 있을 때만 | non-idempotent blind retry | retry method scope test |
| response size | 10MB streaming threshold | streaming for oversize | in-memory load for >10MB | response size streaming test |
## 결정-근거 매핑
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. company-tech-blog 증거는 `company-case-study` 로 표기하며 공식 best practice 로 승격하지 않음.
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|
| D1 | 기본 outbound HTTP = Spring RestClient baseline (RestTemplate 회피, WebClient 는 extension) | `raw/official-docs/outbound-spring-restclient-baseline.md#RESTCLIENT-C1`, `raw/official-docs/outbound-spring-restclient-baseline.md#RESTCLIENT-C2`, `raw/official-docs/outbound-spring-restclient-baseline.md#RESTCLIENT-C3`, `raw/official-docs/outbound-spring-restclient-baseline.md#RESTCLIENT-C4`, `raw/official-docs/outbound-spring-restclient-baseline.md#RESTCLIENT-C6`, `raw/official-docs/outbound-spring-restclient-baseline.md#RESTCLIENT-C7` | `official-vendor-doc` (Spring 7.0 RestTemplate deprecated, 6.1 NOTE: RestClient 가 sync 표준) | Spring Boot 3.x 의 RestClient auto-configuration / `RestClient.Builder` bean 노출 확인 필요 (RESTCLIENT Usage Boundaries 참조) |
| D2 | retry/circuit breaker 기본 라이브러리 = Resilience4j, Spring Retry 는 simple blocking retry 에만 예외 허용 | `raw/official-docs/outbound-resilience4j-vs-spring-retry.md#R4J-C1`, `raw/official-docs/outbound-resilience4j-vs-spring-retry.md#R4J-C2`, `raw/official-docs/outbound-resilience4j-vs-spring-retry.md#R4J-C3`, `raw/official-docs/outbound-resilience4j-vs-spring-retry.md#R4J-C4` | `official-vendor-doc` (Resilience4j vendor 공식) + Spring Retry/Hystrix 비교는 `R4J-C6` 가 needs-confirmation 명시 | Spring Retry README + Hystrix maintenance 상태 별도 source 보강 필요 (R4J-C6 negative finding) |
| D3 | retry 기본값 = disabled, 활성화 시 retryable registry error + low-cardinality retry metric 필수 | `raw/official-docs/outbound-resilience4j-vs-spring-retry.md#R4J-C3` (retry 모듈 존재) + UNSUPPORTED 보조 (default disabled 정책은 ca-tmpl 자체 결정 — vendor 가 default disabled 권고 안 함) | `official-vendor-doc + UNSUPPORTED_DECISION` (default 정책 자체는 자체 결정) | retry-after header honor 가 Resilience4j Retry default 가 아니라는 점 — 별도 검증 필요 |
| D4 | circuit breaker metric tag scope = `dependency.name`, `dependency.type`, `outcome` 만 허용 (low-cardinality) | `raw/official-docs/resilience4j-micrometer-module.md#R4J-MICROMETER-C1` (Micrometer 모듈 지원), `raw/official-docs/resilience4j-micrometer-module.md#R4J-MICROMETER-C2` (CircuitBreaker `resilience4j.circuitbreaker.calls` metric + `kind`/`name` default tag), `raw/official-docs/resilience4j-micrometer-module.md#R4J-MICROMETER-C3` (`resilience4j.circuitbreaker.state` gauge + 5 state vocabulary) | `official-vendor-doc` (Resilience4j vendor 공식 metric 명 + default tag 매핑 증거) — ca-tmpl 의 `dependency.name`/`dependency.type`/`outcome` 으로의 재매핑 자체 (MeterFilter 사용) 는 자체 정책이므로 vendor 가 권고하는 것은 아님 (default 는 `kind`/`name`) | vendor default tag (`kind`/`name`) 를 ca-tmpl tag scope (`dependency.name`/`dependency.type`/`outcome`) 로 변환하는 MeterFilter 구현 + Prometheus scrape cardinality 측정 필요. metrics-alerting branch 와 cross-link. tag 표기 underscore 정합은 §Audit F2 |
| D5 | timeout default = connect 2s / read 5s / global call 10s, 미설정 또는 무한 timeout forbidden | **Mechanism (SUPPORTED)**: `raw/official-docs/spring-restclient-builder-reference.md#SPRING-RESTCLIENT-REF-C1` (RestClient = synchronous + HTTP library 추상화), `raw/official-docs/spring-restclient-builder-reference.md#SPRING-RESTCLIENT-REF-C2` (builder 옵션 — HTTP library 선택), `raw/official-docs/spring-restclient-builder-reference.md#SPRING-RESTCLIENT-REF-C4` (5개 `ClientRequestFactory` 구현체 — JDK/Apache/Jetty/Reactor Netty/Simple). **Quantitative (UNSUPPORTED_DECISION)**: connect 2s / read 5s / call 10s 정량 값은 cited official-doc 중 직접 인용 없음 — `SPRING-RESTCLIENT-REF-C4` 는 default timeout 값이 "본 인용 범위 밖" 임을 명시. 단 정량 값은 registry 계약으로 고정됨 (`ca-tmpl/docs/registries/env-keys.yaml:489·503·516` — §구현 가이드 B) | `official-vendor-doc` (mechanism — RequestFactory 추상화 + 5 구현체) + `UNSUPPORTED_DECISION` (정량 값 2s/5s/10s 는 SRE 운영 경험 기반, vendor 권고 부재) | 각 RequestFactory 의 `setConnectTimeout`/`setReadTimeout` API 별 페이지 추가 보강 필요. 정량 값은 운영 측정 후 재검토 — 별도 source 없음. per-endpoint override 의 capability row 부재는 §Audit F3 |
| D6 | retry 분기 = idempotent method (GET/HEAD/PUT/DELETE) default retry, POST/PATCH 는 idempotency key 헤더 있을 때만 | `raw/official-docs/rfc9110-http-semantics.md#RFC9110-C1` (idempotent 정의 — PUT/DELETE + safe methods GET/HEAD/OPTIONS/TRACE 가 idempotent), `raw/official-docs/rfc9110-http-semantics.md#RFC9110-C2` (client SHOULD NOT automatically retry non-idempotent method — POST/PATCH 자동 retry 금지의 normative 근거) + `raw/company-tech-blogs/outbound-stripe-rate-limit-retry-engineering.md#STRIPE-RL-C1`, `raw/company-tech-blogs/outbound-stripe-rate-limit-retry-engineering.md#STRIPE-RL-C2` (industry case 보강) | `official-standard` (RFC 9110 §9.2.2 — idempotent normative + client SHOULD NOT auto-retry non-idempotent) + `company-case-study` (Stripe 사례 보강, best practice 승격 금지) | RFC9110-C1 의 "POST/PATCH 가 idempotent 가 아니라는 normative 진술은 본 인용에 직접 포함 안됨 (열거 부재가 의미상 함의됨)" — 추가 corroboration (RFC 9110 §9.2.1 safe methods enumeration) 권고. idempotency-key header 패턴 자체는 RFC 9110 가 표준화하지 않음 (application-level). outbound 방향 헤더 계약 부재는 §Audit F4 |
| D7 | response size limit default = 10MB streaming threshold, 초과 시 streaming 처리 의무 | **Mechanism (SUPPORTED)**: `raw/official-docs/spring-restclient-builder-reference.md#SPRING-RESTCLIENT-REF-C5` (RestClient default 4xx/5xx → `RestClientException` throw + `onStatus` override — error path 추상화 존재). **Quantitative (UNSUPPORTED_DECISION)**: 10MB 정량 임계값은 cited official-doc 중 직접 인용 없음. 단 10MB 는 registry 계약으로 고정됨 (`env-keys.yaml` `APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT` default 10MB — §구현 가이드 G) | `official-vendor-doc` (mechanism — onStatus error handling + streaming API 추상화 존재) + `UNSUPPORTED_DECISION` (10MB 정량 임계값 vendor 권고 부재) | RestClient streaming API (`exchange(...)` + `ClientHttpResponse.getBody()`) 의 별도 페이지 보강 필요. 10MB 정량 값은 자체 정책 — 별도 source 부재 |
| D8 | shutdown 중 retry suppression 의무 (`ApplicationListener<ContextClosedEvent>` 또는 동등 mechanism 으로 retry policy NO_RETRY 전환, 신규 호출 즉시 fail-fast) | `raw/official-docs/spring-smartlifecycle-reference.md#SPRING-SMARTLC-C2` (`SmartLifecycle` interface = `Lifecycle` + `Phased` 확장 + `isAutoStartup()` + `stop(Runnable)`), `raw/official-docs/spring-smartlifecycle-reference.md#SPRING-SMARTLC-C3` (startup ascending / shutdown descending phase 순서 — retry-가능 컴포넌트를 outbound client 보다 먼저 stop 시킬 수 있는 phase 메커니즘 근거), `raw/official-docs/spring-smartlifecycle-reference.md#SPRING-SMARTLC-C7` (`stop(Runnable)` async 시맨틱 + `DefaultLifecycleProcessor` phase-level timeout 대기 — graceful shutdown 의 정식 메커니즘) | `official-vendor-doc` (Spring Framework `SmartLifecycle` 공식 mechanism — phase 순서 + graceful stop callback) — ca-tmpl 의 retry policy → NO_RETRY 전환 자체 (`ContextClosedEvent` listener 또는 `SmartLifecycle.stop()` 내부 구현) 는 자체 정책이며 Spring 이 권고하지는 않음 | [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] 와 cross-link 필요. SPRING-SMARTLC-C7 의 timeout default 값 (30s) 은 본 인용 범위 밖 — `DefaultLifecycleProcessor.setTimeoutPerShutdownPhase` 별도 검증. `ContextClosedEvent` listener vs `SmartLifecycle.stop()` 중 어느 쪽이 outbound client 에 적합한지 구현 결정 필요 |
| D9 | (대안 비교) OpenFeign declarative client 배제 | `raw/official-docs/outbound-openfeign-declarative-client.md#OPENFEIGN-C1`, `raw/official-docs/outbound-openfeign-declarative-client.md#OPENFEIGN-C2`, `raw/official-docs/outbound-openfeign-declarative-client.md#OPENFEIGN-C3` | `official-vendor-doc` + `OPENFEIGN-C5` 가 negative finding (maintenance-only 상태는 본 페이지로 미증명) | OpenFeign 배제의 1차 근거가 "maintenance-only" 라면 별도 source 보강 필수 (현재는 needs-confirmation). `@HttpExchange` 대체 가능성도 별도 검증 필요 |
| D10 | (대안 비교) WebClient 를 baseline 에서 배제, reactor event-loop blocking risk | `raw/official-docs/outbound-webclient-vs-restclient-spring.md#WEBCLIENT-C1`, `raw/official-docs/outbound-webclient-vs-restclient-spring.md#WEBCLIENT-C3`, `raw/official-docs/outbound-webclient-vs-restclient-spring.md#WEBCLIENT-C5`, `raw/official-docs/outbound-webclient-vs-restclient-spring.md#WEBCLIENT-C6` | `official-vendor-doc` + `WEBCLIENT-C7` 가 negative finding (event loop deadlock 정확 문구는 본 페이지 미발견 — needs-confirmation) | reactor scheduler / event loop deadlock 경고는 별도 출처 (Project Reactor 문서) 보강 필요 |
| D11 | (보강) Stripe rate limit + retry + idempotency-key 사례 — ca-tmpl default-disabled 의 보수성 vs Stripe default-enabled 대비 | `raw/company-tech-blogs/outbound-stripe-rate-limit-retry-engineering.md#STRIPE-RL-C1`, `raw/company-tech-blogs/outbound-stripe-rate-limit-retry-engineering.md#STRIPE-RL-C2`, `raw/company-tech-blogs/outbound-stripe-rate-limit-retry-engineering.md#STRIPE-RL-C3`, `raw/company-tech-blogs/outbound-stripe-rate-limit-retry-engineering.md#STRIPE-RL-C4` | `company-case-study` (best practice 승격 금지 — Stripe 사례 한정) | STRIPE-RL-C5 가 negative — 409/429/500/502/503/504 + idempotency-key 자동 + full jitter 0.5x~1.5x + 2-3 attempts 의 정확한 정책은 stripe-java SDK 코드 별도 확인 필요 |
| D12 | upstream 실패 분류 contract = `DEPENDENCY_*` 6종 (TIMEOUT 504/2s · CONNECT_FAILED 503/2s · DNS_FAILED 503/5s · 4XX_CLIENT 502 non-retryable · 5XX_SERVER 502/2s · CIRCUIT_OPEN 503/10s) | `project-decision` — registry 계약으로 고정됨 (`ca-tmpl/docs/registries/error-codes.yaml:636~711`, 전 row `owner_branch: feature-outbound-http-client-baseline`, category TRANSIENT_DEPENDENCY/PERMANENT_DEPENDENCY — 2026-06-10 Tiered Extraction 인용 검증 PASS). 분류 체계 자체의 외부 표준 인용은 없음 | `project-decision + registry-ground-truth` (계약 row 는 `actually-implemented`; `OperationalError` enum 6 constants + `DependencyFailureException``actually-implemented + locally-verified` 2026-06-11; `OutboundHttpErrorMapper``actually-implemented + locally-verified` 2026-06-11 — `OutboundHttpErrorMapperTest` 19/19 PASS; web-layer `GlobalExceptionHandler.handleDependencyFailure` + `RetryAfterAdvisor` 5 dependency entries 는 `actually-implemented + locally-verified` 2026-06-11 — `GlobalExceptionHandlerTest` 7 new PASS + `RetryAfterAdvisorTest` 6 new PASS) | 4XX 일괄 PERMANENT 분류는 408(Request Timeout)/429(Too Many Requests) 같은 의미상 retryable 4xx 엣지 미해결 (§엣지 — D12 Open Risk, documented) |
| D13 | request/response body logging 금지 + allowlist 기반 redaction | `UNSUPPORTED_DECISION` (외부 인용 없음 — OWASP Logging Cheat Sheet 등 보강 deferred, §9 funnel 계상). 단 부분 구현 실재: `support/OutboundDependencyLogger` 가 body/recipient/provider payload 를 시그니처 차원에서 받지 않음 — "PII cannot reach a log line by construction" (`ca-tmpl/src/adapter-outbound/CLAUDE.md:18-21`, src grep 2026-06-10). `OutboundHttpDependencyLogger` 도 동일 by-construction 계약 — 시그니처에 body/URI/payload 없음 (`actually-implemented + locally-verified` 2026-06-11 — `OutboundHttpDependencyLoggerTest` 8/8 PASS, failure_log_contains_only_exception_class_and_message_not_body PASS) | `UNSUPPORTED_DECISION` (rationale) + `actually-implemented + locally-verified` (outbound HTTP 경로 포함) | allowlist redaction 의 구체 필드 목록 미정의 — 외부 근거 (OWASP/vendor) 보강 후 확정 권고. §Audit F1 필드명 불일치는 `OutboundHttpDependencyLogger` 에서 registry 필드명(`dependency_name` 등)으로 해소됨 |
## 구현 가이드
> R1(Trace 필수)·R2(`UNSUPPORTED_IMPL_DECISION` 라벨)·R3(범위 밖 이관) — CLAUDE.md §15.5. ca-tmpl ground truth 는 2026-06-10 Tiered Extraction(codex 발che, 인용 56/56 결정론 검증 PASS) + `src/` grep 으로 확인.
>
> **현재 코드 상태 요약 (2026-06-11 Task 4 완료 이후)**: `adapter-outbound/httpclient/` seam **완전 구현** — `OutboundHttpClient`(static `baseline(...)` factory), `OutboundHttpErrorMapper`(6 DEPENDENCY_* codes), `OutboundHttpDependencyLogger`(registry log fields, body-free), `OutboundHttpTimeoutEnforcer`(BeanPostProcessor), `OutboundHttpShutdownGuard`(SmartLifecycle), `OutboundHttpResilienceConfig`+`OutboundHttpResilience`+`OutboundRetryPolicy` (all `actually-implemented + locally-verified` 2026-06-11). 모든 Task 14 완료: `application.yml` `app.outbound.http` 블록, `src/.env` 6키, `application-test.yml` test defaults, `verifyEnvKeys`/`verifyCleanArchitectureDependencies`/CleanArchitectureTest/`:app-bootstrap:test`/`test` (full suite) ALL GREEN 2026-06-11.
### 1. Client 배치
> **Trace**: D1 (`RESTCLIENT-C1~C7`) + D9/D10 (대안 배제).
>
> - **UNSUPPORTED_IMPL_DECISION**: client bean 명명·구성 단위(전역 1 bean vs dependency 별 bean)는 근거 raw 가 권고하지 않음 — trade-off: dependency 별 분리가 D4 metric tag(`dependency_name`) 주입과 D12 분류 주입에 단순.
| 항목 | 명세 | 등급 |
|---|---|---|
| 구현 위치 | `src/adapter-outbound/.../adapter/outbound/httpclient/` — CLAUDE.md 가 "external HTTP client seam (`httpclient/`, currently empty)" 로 예약 | seam 예약 `actually-implemented` / 본체 `planned` |
| client 종류 | Spring `RestClient` (sync). WebClient 는 extension 문서 전용 (D10), OpenFeign 배제 (D9) | `planned` |
| 선례 | `sample-portfolio``RepoStatsPortClient` 가 RestClient 사용 (sample 모듈 한정 — baseline 구현 아님, src grep 2026-06-10) | 참고 |
### 2. Timeout 적용
> **Trace**: D5 (`SPRING-RESTCLIENT-REF-C1·C2·C4`) + registry `env-keys.yaml:489·503·516`.
>
> - **UNSUPPORTED_IMPL_DECISION**: global call 10s 의 적용 지점(Resilience4j `TimeLimiter` vs 자체 wrapper)은 인용 근거 없음 — trade-off: TimeLimiter 가 D2 라이브러리 선택과 일관되고 metric 일원화.
| 항목 | 명세 | 등급 |
|---|---|---|
| env 계약 | `APP_OUTBOUND_HTTP_CONNECT_TIMEOUT=2s`(required) · `APP_OUTBOUND_HTTP_READ_TIMEOUT=5s`(required) · `APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT=10s`(required) — owner_branch 본 branch | registry `actually-implemented` |
| connect/read 적용 | `RestClient.Builder.requestFactory(...)` + factory 별 `setConnectTimeout`/`setReadTimeout` (5종 `ClientRequestFactory` — C4) | `planned` |
| 미설정 차단 | timeout 미설정 outbound client bean 등록 시 ApplicationContext 시작 실패 (Decisionized "timeout" Forbidden · Claims To Verify 행) — bean post-processor 검사 | `planned` |
| per-endpoint override | capability registry 등록 시에만 허용 (D5 Allowed) — **`capabilities.yaml` 에 해당 row 부재 → 신규 제안 필요 (§Audit F3)**. 기존 값처럼 단정 금지 | `planned` + 신규 제안 |
### 3. Retry / Circuit Breaker
> **Trace**: D2 (`R4J-C1~C4`) · D3 (`R4J-C3` + env-keys.yaml:531 주석) · D4 (`R4J-MICROMETER-C1~C3` + metrics.yaml).
| 항목 | 명세 | 등급 |
|---|---|---|
| env 계약 | `APP_OUTBOUND_HTTP_RETRY_ENABLED=false`(optional) · `APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED=false`(optional) — `env-keys.yaml:529·543` | registry `actually-implemented` |
| 라이브러리 | Resilience4j (D2) — **src·build.gradle grep 0건 (2026-06-10): 의존성 미추가** | `planned` |
| metric 계약 | `resilience4j.retry.calls`(log: dependency_name/outcome/retry_attempt) · `resilience4j.circuitbreaker.state`(dependency_name) · `resilience4j.circuitbreaker.calls`(dependency_name/outcome/duration_ms) — `metrics.yaml:97·116~`, owner_branch 본 branch | registry `actually-implemented` |
| tag 재매핑 | vendor default tag(`kind`/`name`) → `dependency_name`/`dependency_type`/`outcome` 은 MeterFilter (D4 — vendor 미권고 자체 정책). 표기 정합은 §Audit F2 | `planned` |
| 활성화 가드 | retry enabled 인데 retryable registry error + low-cardinality metric 부재 → forbidden (D3) — enforcement 지점(기동 검사 vs 계약 테스트)은 미정 | `planned` |
### 4. Retry method scope
> **Trace**: D6 (`RFC9110-C1·C2` + `STRIPE-RL-C1·C2`).
| 항목 | 명세 | 등급 |
|---|---|---|
| default retry 대상 | GET/HEAD/PUT/DELETE (RFC 9110 idempotent) | `planned` |
| POST/PATCH | Idempotency-Key 헤더 동반 시에만 retry — **`headers.yaml:43` 의 row 는 `direction: inbound` (owner: [[raw/branch-notes/feature-rate-limit-idempotency-contract]]) → outbound 첨부 계약 미정의 (§Audit F4)** | `planned` + cross-branch 협의 |
### 5. 실패 분류
> **Trace**: D12 (`error-codes.yaml:636~711` — 전 row owner_branch 본 branch).
>
> - **UNSUPPORTED_IMPL_DECISION**: mapper 클래스 명명·배치(`httpclient/` 내부 vs `support/`)는 근거 없음 — trade-off: `httpclient/` 내부가 RestClient 예외 타입(`RestClientException` 계열)과 응집. 채택: `httpclient/OutboundHttpErrorMapper` (`actually-implemented + locally-verified` 2026-06-11).
registry 계약 (row 는 `actually-implemented`, 매핑 코드는 `actually-implemented + locally-verified` 2026-06-11 — `OutboundHttpErrorMapperTest` 19/19 PASS; web-layer mapping 은 `actually-implemented + locally-verified` 2026-06-11 — Task 3 아래 참조):
| code | category | HTTP | retryable | retry_after |
|---|---|---|---|---|
| `DEPENDENCY_TIMEOUT` | TRANSIENT_DEPENDENCY | 504 | true | 2s |
| `DEPENDENCY_CONNECT_FAILED` | TRANSIENT_DEPENDENCY | 503 | true | 2s |
| `DEPENDENCY_DNS_FAILED` | TRANSIENT_DEPENDENCY | 503 | true | 5s |
| `DEPENDENCY_4XX_CLIENT` | PERMANENT_DEPENDENCY | 502 | false | — |
| `DEPENDENCY_5XX_SERVER` | TRANSIENT_DEPENDENCY | 502 | true | 2s |
| `DEPENDENCY_CIRCUIT_OPEN` | TRANSIENT_DEPENDENCY | 503 | true | 10s |
### 6. Dependency 로그
> **Trace**: D13 + `metrics.yaml:90` (log_field_mapping) + `adapter-outbound/CLAUDE.md:18-21`.
| 항목 | 명세 | 등급 |
|---|---|---|
| 기존 구현 | `support/OutboundDependencyLogger``dependency`/`operation`/`outcome`/`correlationId` 만 로깅, body·recipient·payload 는 시그니처가 받지 않음 (by construction) | `actually-implemented` (notification 경로) |
| outbound HTTP 로그 필드 | registry log_field_mapping = `dependency_name`/`dependency_type`/`outcome`/`duration_ms` — 기존 logger 필드와 불일치 (§Audit F1). RestClient 경로 구현 시 registry 필드명 채택 권고 | `planned` |
| body 금지·redaction | D13 — allowlist 구체 필드 목록 미정의 (외부 근거 보강 deferred) | `planned` |
### 7. Response size / streaming
> **Trace**: D7 (`SPRING-RESTCLIENT-REF-C5`) + registry `env-keys.yaml` (`APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT` default 10MB, optional).
- 10MB 초과 응답은 streaming 처리 의무 — RestClient streaming API (`exchange(...)` + `ClientHttpResponse.getBody()`) 의 공식 페이지 보강 필요 (D7 Open Risk). 전부 `planned`.
### 8. Shutdown retry suppression
> **Trace**: D8 (`SPRING-SMARTLC-C2·C3·C7`).
>
> - **UNSUPPORTED_IMPL_DECISION**: `ContextClosedEvent` listener vs `SmartLifecycle.stop()` 선택은 근거 없음 — trade-off: SmartLifecycle 은 phase 순서로 retry-가능 컴포넌트를 outbound client 보다 먼저 정지 가능(C3), listener 는 구현 단순. [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] 의 phase 배치와 협의 필수.
- retry policy → NO_RETRY 전환 + shutdown 중 신규 호출 즉시 fail-fast (timeout 대기 금지). 전부 `planned`.
## 엣지·실패·의존
- **실패·엣지 경로**:
- upstream timeout → `DEPENDENCY_TIMEOUT` 504 retryable(2s) — 테스트 계약 "retryable dependency failure 분류"와 일치 (D12).
- connect/DNS 실패 → 503 retryable (각 2s/5s) — DNS 만 retry_after 5s 인 이유는 registry 에 명시 근거 없음 (해석).
- upstream 4xx → `DEPENDENCY_4XX_CLIENT` 502 non-retryable — **408/429 가 4xx 이면서 의미상 retryable 인 엣지 미해결** (D12 Open Risk). 기대 동작 미정 → 구현 전 결정 필요.
- circuit open → `DEPENDENCY_CIRCUIT_OPEN` 503 retry_after 10s — upstream 미호출 fail-fast.
- shutdown 중 신규 outbound 호출 → 즉시 fail-fast, timeout 대기 금지 (D8). retry 진행 중 shutdown 시그널 수신 → NO_RETRY 전환.
- 응답 >10MB → streaming 의무 (D7). in-memory 적재는 테스트 계약 위반.
- POST/PATCH 에 Idempotency-Key 부재 → retry 금지 (D6). outbound 첨부 계약 자체가 미정의 (§Audit F4) — 정의 전까지 POST/PATCH retry 는 사실상 전면 금지가 안전 동작.
- retry enabled + retryable registry/metric 미충족 → forbidden (D3) — enforcement 지점 미정 (§구현 가이드 3).
- **다른 계약 의존**:
- [[raw/branch-notes/feature-repository-access-permission-contract]] — `EXTERNAL_OUTBOUND_ALLOWED` capability owner (`capabilities.yaml:92~101`). 본 client 를 직접 호출하는 use case 는 `@UseCaseCapability(externalOutboundAllowed = true)` 선언 필수 — ArchUnit rule `external_outbound_calls_require_external_outbound_allowed_capability``actually-implemented` (`adapter-outbound/CLAUDE.md:49-52`).
- [[raw/branch-notes/feature-metrics-alerting-contract]] — `dependency.client.requests` timer owner (`metrics.yaml:71~90`, outcome ∈ SUCCESS/FAILURE/CIRCUIT_OPEN/TIMEOUT/REJECTED). 본 branch 는 consume only + `resilience4j.*` 3종만 owns.
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]] — `Idempotency-Key` header row owner (`headers.yaml:43`, inbound). D6 outbound 사용은 owner 와 협의 (§Audit F4).
- [[raw/branch-notes/feature-background-job-async-contract]] — retry/DLQ vocabulary SSOT (결정 사항 2026-05-22). 본 branch 는 outbound-specific Resilience4j 도구 결정만 owns — vocabulary 가 바뀌면 retry metric/로그 명명 영향.
- [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] — D8 shutdown phase 순서·timeout 협의. phase 계약이 바뀌면 retry suppression 시점 영향.
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] — env key 정의·검증 스키마 (env-keys.yaml outbound 블록 주석이 양 branch 공동 표기). env 검증 규칙이 바뀌면 §구현 가이드 2 의 미설정 차단 메커니즘 영향.
## 테스트 계약
- upstream timeout은 retryable dependency failure로 분류되어야 함.
- upstream raw error body가 response/log에 노출되면 실패.
- 401/403은 credential/scope/config 문제로 분류되어야 함.
- outbound log에 dependency.name/type/duration_ms가 없으면 실패.
- retry/circuit breaker enabled인데 Resilience4j metric과 retryable classification이 없으면 실패.
- shutdown phase에서 outbound HTTP 호출이 retry를 시도하면 실패.
## 검증해야 할 주장
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| ca-tmpl 의 connect 2s / read 5s / call 10s timeout 이 Spring RestClient `JdkClientHttpRequestFactory` 로 실제 적용 | D5 UNSUPPORTED_DECISION — Spring Boot 3.x auto-configuration 의 default factory 확인 필요 | `RestClient.Builder.requestFactory(factory)` + `JdkClientHttpRequestFactory.setReadTimeout` + JDK `HttpClient.connectTimeout` + `OutboundHttpClientTest.t2_read_timeout_*` PASS | `actually-verified 2026-06-11` (read timeout DEPENDENCY_TIMEOUT PASS) |
| outbound HTTP client bean 이 timeout 미설정으로 등록되면 ApplicationContext post-processor 가 fail | timeout 미설정 검증 자체 메커니즘 미정의 | `OutboundHttpTimeoutEnforcer` BeanPostProcessor — `OutboundHttpTimeoutEnforcerTest` 4/4 PASS | `actually-verified 2026-06-11` |
| Resilience4j retry/circuit breaker metric 이 `outcome` tag 만 노출 (`kind` tag 제거) + CB state tag uppercase | D4 — vendor default tag 는 `kind`/`name`, ca-tmpl 재매핑은 MeterFilter 자체 구현 필요 (vendor 미권고) | custom `MeterFilter.map()` + filter-first order + `OutboundHttpClientTest.t8/t9` PASS | `actually-verified 2026-06-11` (Micrometer 1.15.x requires custom map(), not replaceTagValues) |
| shutdown phase 에서 outbound HTTP 호출이 retry 를 시도하지 않음 (D8) | D8 mechanism 은 `SPRING-SMARTLC-C2/C3/C7` 로 SUPPORTED, `OutboundHttpShutdownGuard.stop()` 로 flag set | `OutboundHttpClientTest.t10_shutdown_*` PASS — `stop()` 후 호출 즉시 DEPENDENCY_CIRCUIT_OPEN + outcome="REJECTED", 서버 hit count 0 | `actually-verified 2026-06-11` |
| WebClient 의 reactor event-loop blocking risk (D10 의 deadlock 가능성) | WEBCLIENT-C7 negative — 정확 문구 미발견 | Project Reactor 문서 fetch + 통합 테스트로 WebClient.block() in single-thread scheduler deadlock 재현 | `needs-confirmation` |
| OpenFeign maintenance-only 상태 (D9 의 배제 정당화) | OPENFEIGN-C5 negative — 본 페이지 미명시 | spring-cloud-openfeign GitHub README + Spring blog announcement 별도 fetch | `needs-confirmation` |
| upstream raw error body 가 response/log 에 노출되지 않음 | DefaultResponseErrorHandler 의 4xx → HttpClientErrorException / 5xx → HttpServerErrorException 매핑 + error mapper 의 응답 sanitize | grep + 통합 테스트로 upstream 500 응답 body 가 log/response 에 등장하지 않는지 확인 | `planned` |
| 401/403 이 credential/scope/config 문제로 정확히 분류 | error mapper 분류 logic 자체 검증 필요 | 통합 테스트로 401 → AUTH_*, 403 → AUTHZ_* 분류 확인 | `planned` |
| Stripe 의 retry + idempotency-key 자동 첨부 정책이 ca-tmpl 의 POST/PATCH retry 정책과 정합 (D6) | STRIPE-RL-C5 negative — 정확한 정책 미증명 | stripe-java SDK `StripeResponseGetter` 코드 별도 확인 + ca-tmpl idempotency-key 정책 cross-link | `needs-confirmation` |
## 관심사 커버리지
> coverage-auditor 자동 생성 (2026-06-10 — verdict: Covered, Blocking 0 / Should-fix 2 / Advisory 2).
governing_docs: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]] (§ Outbound HTTP documented-only + hub §11 Outbound HTTP + §32.3)
| 관심사 | 상태 | owner | 심각도 | 근거 |
|--------|------|-------|--------|------|
| RestClient baseline 채택 (RestTemplate 회피 / WebClient extension 분리 / OpenFeign 배제) | covered-here | — | — | D1 / D9 / D10 |
| upstream 실패 분류 6종 (TIMEOUT/CONNECT_FAILED/DNS_FAILED/4XX_CLIENT/5XX_SERVER/CIRCUIT_OPEN) | covered-here | — | — | D12; error-codes.yaml:636~711 |
| timeout 3계층 (connect 2s / read 5s / global call 10s) + 미설정 forbidden | covered-here | — | — | D5; env-keys.yaml:489/503/516 |
| retry/CB 라이브러리 = Resilience4j, default disabled | covered-here | — | — | D2 / D3; env-keys.yaml:529/543 |
| retry method scope (idempotent default / POST·PATCH idempotency-key 조건부) | covered-here | — | — | D6; §구현 가이드 4 |
| response size limit (10MB streaming threshold) | covered-here | — | — | D7; env-keys.yaml:557 |
| shutdown 중 retry suppression (NO_RETRY 전환 + fail-fast) | covered-here | — | — | D8; §구현 가이드 8 |
| dependency log field (dependency_name/type/outcome/duration_ms) | covered-here | — | — | D13; metrics.yaml:90 log_field_mapping |
| request/response body logging 금지 + allowlist redaction | covered-here | — | — | D13; adapter-outbound/CLAUDE.md:18-21 |
| Resilience4j metric 3종 + low-cardinality tag scope | covered-here | — | — | D4; metrics.yaml:97/116/136 |
| dependency.client.requests timer | delegated | [[raw/branch-notes/feature-metrics-alerting-contract]] | OK | metrics.yaml:71 |
| EXTERNAL_OUTBOUND_ALLOWED capability gate | delegated | [[raw/branch-notes/feature-repository-access-permission-contract]] | OK | capabilities.yaml:92; adapter-outbound/CLAUDE.md:49-52 |
| Idempotency-Key header (inbound row 소유 + outbound row 미정의 gap) | delegated | [[raw/branch-notes/feature-rate-limit-idempotency-contract]] | Should-fix (HEADER_DIRECTION_GAP) | headers.yaml:43 direction:inbound; §Audit F4 |
| per-endpoint timeout override capability row 신설 | delegated | [[raw/branch-notes/feature-repository-access-permission-contract]] | Should-fix (CAPABILITY_ROW_ABSENT) | §Audit F3 |
| env key 검증 스키마 | delegated | [[raw/branch-notes/feature-env-driven-runtime-configuration]] | Advisory | §다른 계약 의존 |
| retry/DLQ vocabulary SSOT | delegated | [[raw/branch-notes/feature-background-job-async-contract]] | Advisory | 결정 사항 2026-05-22 |
| shutdown phase 순서 협의 | delegated | [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] | Advisory | §구현 가이드 8; §다른 계약 의존 |
## Audit & Findings (2026-06-10 ground-truth 정합 감사)
> /branch-spec 실행 시 ca-tmpl registry·코드 대조 결과 (Tiered Extraction codex 발췌 56/56 인용 검증 + src grep). 사용자 결정 영역은 rewrite 하지 않고 정합 권고만 기록.
| # | Finding | 내용 | 권고 |
|---|---|---|---|
| F1 | `LOG_FIELD_DRIFT` | 로그 필드 3원 불일치 — 본 노트 테스트 계약 `dependency.name/type/duration_ms``metrics.yaml:90` log_field_mapping `[dependency_name, dependency_type, outcome, duration_ms]` ↔ 코드 `OutboundDependencyLogger` 실 출력 `dependency/operation/outcome/correlationId` (duration 부재) | RestClient 경로 구현 시 registry 필드명(`dependency_name` 등) 채택. 기존 logger 는 notification adapter 용 — outbound HTTP 전용 로깅은 별도 구현 |
| F2 | `TAG_NAME_DRIFT` | D4·결정 사항의 tag 표기 `dependency.name`/`dependency.type`(dot) vs `metrics.yaml:71~` 실제 tag `dependency_name`/`dependency_type`(underscore) | registry 가 계약 SSOT — 노트 표기의 underscore 정합 권고 (사용자 결정 영역 — 자동 rewrite 안 함) |
| F3 | `CAPABILITY_ROW_ABSENT` | D5 Allowed "per-endpoint override 는 capability registry 등록 시에만" — `capabilities.yaml` 에 timeout-override capability row 부재 (현재 outbound 관련 row 는 `EXTERNAL_OUTBOUND_ALLOWED` 뿐) | 신규 row 제안 필요 — capability vocabulary owner 인 [[raw/branch-notes/feature-repository-access-permission-contract]] 와 협의 |
| F4 | `HEADER_DIRECTION_GAP` | D6 의 outbound Idempotency-Key 첨부 vs `headers.yaml:43``direction: inbound` 만 정의 | outbound row 신설 또는 direction 확장 — header owner [[raw/branch-notes/feature-rate-limit-idempotency-contract]] 와 협의. 정의 전까지 POST/PATCH retry 전면 금지가 안전 동작 |
## 마주친 문제
### Task 2b (2026-06-11) — OutboundHttpClientTest 작성 중 발견된 production bug 4건
1. **t3 connect-refused 포트 획득 방법**: `HttpServer.create().stop(0)` 는 포트를 TIME_WAIT 상태로 남겨 즉시 `ConnectException` 대신 `TIMEOUT` 발생. 해결: `ServerSocket(0)``close()` 패턴으로 교체.
2. **DNS failure 분류 오류** (`OutboundHttpErrorMapper` bug): JDK 21 `HttpClient` 는 DNS 실패를 `ConnectException(cause=ConnectException(cause=UnresolvedAddressException))` 으로 래핑. 기존 단일 패스 cause-chain walk 에서 `ConnectException` 이 먼저 매칭되어 `DEPENDENCY_DNS_FAILED` 대신 `DEPENDENCY_CONNECT_FAILED` 반환. 수정: `ConnectException` 매칭 시 `hasDnsCauseInChain()` 로 서브 체인을 추가 스캔, DNS 근원 발견 시 `DEPENDENCY_DNS_FAILED` 우선 반환.
3. **retry 미작동** (공유 인스턴스 계약 위반): `OutboundHttpResilience` 내부의 `Retry``retryPolicy::shouldRetry``retryOnException` predicate 로 등록. `shouldRetry``retryPolicy.beginCall()` 로 세팅된 ThreadLocal context 를 확인. 테스트에서 `retryPolicy(settings)` 를 두 번 호출하면 서로 다른 인스턴스 → `shouldRetry` 가 항상 null context → return false → retry 0회. 해결: `sharedPolicy` 변수 하나로 resilience 와 client 에 동일 인스턴스 전달.
4. **MeterFilter ordering 및 `replaceTagValues` 호환성 문제** (`OutboundHttpResilienceConfig` bug — 2건):
- `TaggedCircuitBreakerMetrics.bindTo()` 가 state gauge 를 eager 등록 → 이후 filter 설치 → `map()` 미호출 → uppercase 미적용. 수정: `applyMeterFilters()``bindTo()` BEFORE 로 이동.
- Micrometer 1.15.x 에서 `MeterFilter.replaceTagValues()` / `renameTag()``FunctionCounter` / `DefaultGauge` 에 대해 `map()` 를 신뢰성 있게 호출하지 않음 (Micrometer 1.15.11 + Resilience4j 2.2.0 로컬 검증). 수정: 각 meter 에 대해 tag iteration + `id.replaceTags()` 를 직접 수행하는 custom `MeterFilter` 3개로 교체.
## 후속 리팩터
- **2026-06-16 `OutboundHttpClient` orchestration 분리 (god-object 초입 완화, behavior-preserving)**: 리뷰가 "client 가 생성+관측+분류 정책을 모두 들고 있어 god object 초입"이라 지적 → 사용자 지시로 **과분할 금지, 딱 2개만** 추출. SDD 루프(`ca-implementer` + 머신 검증)로 수행.
- `OutboundHttpRestClientFactory` (package-private, `static Clients create(name, baseUrl, settings)` + nested `record Clients(buffered, streaming)`): 단일 공유 `JdkClientHttpRequestFactory` + 두 `RestClient`(buffered=Trace+SizeBounding, streaming=Trace) 생성을 생성자에서 추출. 변경-이유 축 = timeout 적용/interceptor 조립/RestClient 구현체. **static 메서드라 B7 제외**, 빈 아님(timeout enforcer 는 RestClient *빈* 만 금지).
- `OutboundHttpCallObserver` (package-private): duration 계산 + success/failure 로그 + `outcomeFor`(DFE→outcome) + shutdown-rejection 생성 흡수. `recordSuccess` / `recordFailure(...)→DFE` / `rejectShutdown(msg)→DFE` (호출부는 `throw observer.recordFailure(...)` 로 throw 가시성 유지). 메서드 package-private 라 B7 제외.
- `OutboundHttpClient` 는 shutdown 체크 → deadline/retryPolicy → buildSupplier → CB/retry 데코레이션 → 실행으로 슬림화. `baseline(...)` 8-param 시그니처 불변(포크/테스트 호환). size-exception 비분류 전파는 client 에 잔류.
- **동작 무변경** 보존: 로그 필드·retryAttempt=`max(0,n-1)`·stream=0·REJECTED(0,0)·공유 request factory·interceptor 순서 모두 동일. 검증: `:adapter-outbound:test` **190/190**, `:app-bootstrap:test --tests '*CleanArchitectureTest'` **49/49** PASS (B7·의존방향 위반 0).
- **컨트롤러 개입**: implementer 가 범위 밖 `OutboundHttpDependencyLogger` 의 PII-safety(D13) JavaDoc 2블록을 삭제 → 문서 회귀로 판단해 `git checkout HEAD` 로 되돌림. 신규 main 2개·test 2개만 잔류.
- **보류(동일 리뷰의 나머지)**: `DependencyLogFields` 공통 helper(②), `TraceContextPropagationInterceptor` FORK LANDMINE 주석 이관(④) — 사유는 [[raw/branch-notes/feature-integration-adapter-templates]] 2026-06-16 rename 항목과 동일(②는 효익 적음, ④는 in-file 유지가 안전).
- **2026-06-16 httpclient 관심사별 서브패키지화 (하이브리드 C, behavior-preserving)**: 리뷰가 "13개 한 폴더 → 관심사 폴더로(execution/transport/resilience/diagnostics)" 제안. **package-private 캡슐화를 깨지 않는 하이브리드 C**로 진행 — package-private 묶음(`OutboundHttpClient`+`OutboundHttpRestClientFactory`+`OutboundHttpCallObserver`+`ResponseSizeBoundingInterceptor`)은 root 유지, 이미 public·독립적인 쌍만 분리: `httpclient/resilience/`(`OutboundHttpResilience`,`OutboundHttpResilienceConfig`) + `httpclient/diagnostics/`(`OutboundHttpDependencyLogger`,`OutboundHttpErrorMapper`). 전체 5분할(B) 미채택 근거: observer/factory를 `public`으로 올려야 해 직전 캡슐화를 되돌림 + 기존 outbound 서브패키징이 "백엔드별"(`cache/redis` 등)이라 "관심사별"은 축 불일치(스켈레톤 가독성).
- **가드레일 무영향**: ArchUnit 규칙 전부 `..adapter.outbound..` 재귀 패턴 + `.adapter.outbound.` substring 체크(`CleanArchitectureTest:436`)라 서브패키지 자동 커버 → Prime Directive "서브패키지 추가 시 규칙 확장" 불필요. Gradle 매트릭스는 모듈 단위라 무관.
- 이동 main 4 + test 4, package 선언 + import 정정(컴파일러 주도). app-bootstrap `MetricsContractConfig` FQN javadoc 2곳(`@see`/`{@code}`)을 `.resilience.` 로 갱신. 검증: httpclient 스코프 테스트 **119/119 PASS**, `CleanArchitectureTest` 49/49 PASS, 모듈 컴파일 0 에러.
- **사고(tooling)**: test-file import 삽입 `sed``\&`(리터럴 앰퍼샌드) 버그로 5개 test 파일 1행 package 선언을 `&`로 덮음(gradle-runner 포착) → 복구 후 재검증 green. 교훈: sed replacement 에서 매치 텍스트 보존은 비이스케이프 `&`, `\&` 는 리터럴 `&`.
- **컨텍스트**: 동시점에 사용자가 messaging/notification 을 `core/` 서브패키지로 병행 리팩터 중 — 본 작업은 httpclient 에만 한정, messaging/notification 미접촉.
## 묶음 (이 branch에서 파생된 자료)
<!-- GENERATED: sources:start -->
- [[raw/company-tech-blogs/outbound-stripe-rate-limit-retry-engineering]]
- [[raw/official-docs/outbound-openfeign-declarative-client]]
- [[raw/official-docs/outbound-resilience4j-vs-spring-retry]]
- [[raw/official-docs/outbound-spring-restclient-baseline]]
- [[raw/official-docs/outbound-webclient-vs-restclient-spring]]
- [[raw/official-docs/resilience4j-micrometer-module]]
- [[raw/official-docs/rfc9110-http-semantics]]
- [[raw/official-docs/spring-restclient-builder-reference]]
- [[raw/official-docs/spring-smartlifecycle-reference]]
<!-- GENERATED: sources:end -->
<!-- GENERATED: errors:start -->
- [[raw/errors/archunit-b7-configprops-nested-record-accessor-outbound-2026-06-13]]
- [[raw/errors/jdk-httpclient-dns-unresolvedaddress-connectexception-2026-06-11]]
- [[raw/errors/micrometer-meterfilter-replacetagvalues-functioncounter-2026-06-11]]
- [[raw/errors/spring-configuration-bean-factory-method-not-processed-2026-06-11]]
<!-- GENERATED: errors:end -->
<!-- GENERATED: blog-topics:start -->
- [[raw/blog-topics/jdk-httpclient-dns-connectexception-classification-2026-07-02]]
- [[raw/blog-topics/micrometer-meterfilter-resilience4j-functioncounter-2026-07-02]]
<!-- GENERATED: blog-topics:end -->
> 2026-06-11 Phase C2 실 구현 완료 — 파생 error note 3건 생성 (아래 wikilink). interview/blog 시드는 인라인 보관, standalone 추출은 canonical 요청 시.
### 오류 기록 (본 feature 작업 중 발생)
- [[raw/errors/jdk-httpclient-dns-unresolvedaddress-connectexception-2026-06-11]] — **DNS 분류 오류** (`OutboundHttpErrorMapper`): JDK 21 `HttpClient` DNS failure → `ConnectException` 래핑 패턴이 단일 패스 cause-chain walk 를 뚫고 지나감. 해결 → `hasDnsCauseInChain()` helper 로 서브 체인 추가 스캔. `actually-fixed + locally-verified` 2026-06-11.
- [[raw/errors/micrometer-meterfilter-replacetagvalues-functioncounter-2026-06-11]] — **MeterFilter ordering + `replaceTagValues` compat** (`OutboundHttpResilienceConfig`): eager gauge 등록 전 filter 적용 + Micrometer 1.15.x `FunctionCounter`/`DefaultGauge` 에서 `replaceTagValues`/`renameTag` 미적용. 해결 → filter-first 순서 + custom `MeterFilter.map()`. `actually-fixed + locally-verified` 2026-06-11.
- [[raw/errors/spring-configuration-bean-factory-method-not-processed-2026-06-11]] — **@Configuration 팩토리 등록 함정** (테스트 배선): `@Configuration` 클래스를 다른 구성 클래스의 `@Bean` 팩토리 반환값으로 등록하면 내부 `@Bean` 정의가 처리되지 않아 D3 기동-실패 테스트가 false-green. 해결 → `withUserConfiguration(...)` 직접 등록. `actually-fixed + locally-verified` 2026-06-11.
- **공유 retryPolicy 인스턴스 계약**: resilience 와 client 에 동일 `OutboundRetryPolicy` 인스턴스를 전달해야 ThreadLocal context 공유 가능. `actually-documented + locally-verified` 2026-06-11. (단독 error note 불요 — 설계 계약으로 §Task 2b 기록에 보존)
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- JDK `HttpClient` 가 DNS 실패를 `ConnectException` 으로 래핑하는 이유와 cause-chain walk 기반 분류 전략의 우선순위 문제.
- Micrometer `MeterFilter.map()` 호출 시점 (meter 등록 시점 한정) 과 eager vs lazy 등록 패턴 (FunctionCounter = lazy, DefaultGauge = eager) 의 차이 — filter-first 순서 중요성.
- `replaceTagValues` 와 custom `MeterFilter` 의 차이 및 FunctionCounter 에서 발생하는 호환성 문제.
- ThreadLocal 기반 call context (`OutboundRetryPolicy`) 를 공유 인스턴스로 주입해야 하는 이유.
### Blog topics
- "JDK HttpClient DNS failure classification: why `UnresolvedAddressException` hides inside `ConnectException` and how to handle it robustly" — cause-chain walk 전략 + 우선순위 처리.
- "Micrometer MeterFilter gotcha with Resilience4j: why `replaceTagValues` silently fails on FunctionCounter in 1.15.x" — filter-first 순서 + custom `map()` 필요성.
## 관련 일일 노트
> 이 브랜치를 작업한 날짜들. 양방향 nav 유지.
- `raw/daily-notes/2026-06-11` (파일 미생성 — 일일 노트는 별도 생성)
- 2026-06-16 — `OutboundHttpClient` orchestration 분리 후속 리팩터(동작 무변경). 위 §후속 리팩터 참조.
## Task 4 — Bootstrap wiring (2026-06-11 완료)
**Mode**: bootstrap-only (+ settings files `.env`, `adapter-outbound/CLAUDE.md`)
| 파일 | 변경 내용 | 상태 |
|---|---|---|
| `src/app-bootstrap/src/main/resources/application.yml` | `app.outbound.http` 블록 추가 (3 required + 3 optional with defaults; feature-outbound-http-client-baseline D5/D3/D7 header comment) | `actually-implemented` |
| `src/.env` | `APP_OUTBOUND_HTTP_*` 6종 추가 (CONNECT_TIMEOUT=2s, READ_TIMEOUT=5s, GLOBAL_CALL_TIMEOUT=10s, RETRY_ENABLED=false, CIRCUIT_BREAKER_ENABLED=false, RESPONSE_SIZE_LIMIT=10MB) | `actually-implemented` |
| `src/adapter-outbound/CLAUDE.md` | Responsibility bullet 업데이트 (httpclient/ 구현 설명 + Allowed 목록에 spring-web/micrometer-core/resilience4j 추가) | `actually-implemented` |
| `src/app-bootstrap/src/test/resources/application-test.yml` | `app.outbound.http` test defaults 추가 (OutboundHttpSettings requires 3 non-zero timeouts; @ConfigurationPropertiesScan via CaSkeletonApplication picks it up in any full-context test) | `actually-implemented` |
**검증 결과 (2026-06-11)**:
- `./gradlew verifyEnvKeys` → OK — 81 env keys, 73 required placeholders covered, 68 APP_ keys registered.
- `./gradlew verifyCleanArchitectureDependencies` → BUILD SUCCESSFUL
- `./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'` → BUILD SUCCESSFUL
- `./gradlew :app-bootstrap:test` → BUILD SUCCESSFUL
- `./gradlew test` (full suite) → BUILD SUCCESSFUL
**주의**: Spring Boot의 `RestClientAutoConfiguration`이 prototype-scoped `RestClient.Builder` bean을 자동등록하나, prototype beans는 `BeanPostProcessor.postProcessAfterInitialization`에서 인스턴스화 온디맨드이므로 `OutboundHttpTimeoutEnforcer`가 이를 트립하지 않는다 — 실제로 전체 suite 통과로 확인.
## 완료 후 정리
- PR 링크:
- 리뷰 메모:
- 머지 결과 / 배포 환경:
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
- `actually-implemented` 항목: `application.yml app.outbound.http`, `src/.env 6종`, `OutboundHttpClient baseline factory`, `OutboundHttpErrorMapper 6 codes`, `OutboundHttpDependencyLogger`, `OutboundHttpTimeoutEnforcer`, `OutboundHttpShutdownGuard`, `OutboundHttpResilienceConfig`
- `locally-verified` 항목: timeout enforcement, DNS classification fix, MeterFilter ordering fix, retry shared-instance contract
- `prod-verified` 항목: (없음 — 아직 prod 배포 미완)
- **추출하지 않을 항목** (planned / documented-only / abandoned):
- per-endpoint timeout override capability row (CAPABILITY_ROW_ABSENT — F3)
- outbound Idempotency-Key header row (HEADER_DIRECTION_GAP — F4)