483 lines
55 KiB
Markdown
483 lines
55 KiB
Markdown
---
|
||
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 / 테스트 계약 / 외부 근거)은 삭제하지 않고 관련 템플릿 섹션 옆에 슬롯해 보존했다.
|
||
|
||
<!-- 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 1–4 완료: `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)
|