55 KiB
title, source_type, status, branch, parent_branch, governing_docs, related_projects, tags, created, target_merge, status_label, id, kind, project, work_item, inherits, refines, overrides, depends_on, contract_packet, contract_packet_sha256
| title | source_type | status | branch | parent_branch | governing_docs | related_projects | tags | created | target_merge | status_label | id | kind | project | work_item | inherits | refines | overrides | depends_on | contract_packet | contract_packet_sha256 | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| branch / feature-outbound-http-client-baseline | branch-note | raw | feature-outbound-http-client-baseline |
|
|
|
2026-05-21 | in-progress | BR-CA-SKELETON-OPERATIONAL-CONTRACT-007 | project-work-item | ca-skeleton-operational-contract | WI-CA-SKELETON-OPERATIONAL-CONTRACT-007 |
|
1 | 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 / 테스트 계약 / 외부 근거)은 삭제하지 않고 관련 템플릿 섹션 옆에 슬롯해 보존했다.
부모 (필수)
- 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) 의 결정/근거/금지 사항을 정제한다.
브랜치 계약 패킷
- 생성 시 프로젝트 개정:
1 - 패킷 스키마:
contract_packet: 1 - 완료 조건: timeout·retry·circuit-breaker contract test가 통과한다
상속한 프로젝트 결정
| 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 |
브랜치 지역 결정
기존 branch-local 결정은 아래
## Decision Evidence Map / 결정-근거 매핑의 D-row가 소유하며 이 packet에서 복제하지 않는다.
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|
선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|
목표
외부/내부 API 호출 실패를 HTTP status만으로 처리하면 원인과 운영 조치가 흐려집니다. RestClient를 기본 표준으로 두고 status, timeout, DNS, connect failure, retry/backoff/circuit breaker 기준을 정리합니다.
- 이슈:
- PR:
범위
포함 범위
- 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 또는 동등 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(staticbaseline(...)factory),OutboundHttpErrorMapper(6 DEPENDENCY_* codes),OutboundHttpDependencyLogger(registry log fields, body-free),OutboundHttpTimeoutEnforcer(BeanPostProcessor),OutboundHttpShutdownGuard(SmartLifecycle),OutboundHttpResilienceConfig+OutboundHttpResilience+OutboundRetryPolicy(allactually-implemented + locally-verified2026-06-11). 모든 Task 1–4 완료:application.ymlapp.outbound.http블록,src/.env6키,application-test.ymltest 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) + registryenv-keys.yaml:489·503·516.
- UNSUPPORTED_IMPL_DECISION: global call 10s 의 적용 지점(Resilience4j
TimeLimitervs 자체 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/내부 vssupport/)는 근거 없음 — trade-off:httpclient/내부가 RestClient 예외 타입(RestClientException계열)과 응집. 채택:httpclient/OutboundHttpErrorMapper(actually-implemented + locally-verified2026-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) + registryenv-keys.yaml(APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMITdefault 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:
ContextClosedEventlistener vsSmartLifecycle.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_TIMEOUT504 retryable(2s) — 테스트 계약 "retryable dependency failure 분류"와 일치 (D12). - connect/DNS 실패 → 503 retryable (각 2s/5s) — DNS 만 retry_after 5s 인 이유는 registry 에 명시 근거 없음 (해석).
- upstream 4xx →
DEPENDENCY_4XX_CLIENT502 non-retryable — 408/429 가 4xx 이면서 의미상 retryable 인 엣지 미해결 (D12 Open Risk). 기대 동작 미정 → 구현 전 결정 필요. - circuit open →
DEPENDENCY_CIRCUIT_OPEN503 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).
- upstream timeout →
- 다른 계약 의존:
- raw/branch-notes/feature-repository-access-permission-contract —
EXTERNAL_OUTBOUND_ALLOWEDcapability owner (capabilities.yaml:92~101). 본 client 를 직접 호출하는 use case 는@UseCaseCapability(externalOutboundAllowed = true)선언 필수 — ArchUnit ruleexternal_outbound_calls_require_external_outbound_allowed_capability는actually-implemented(adapter-outbound/CLAUDE.md:49-52). - raw/branch-notes/feature-metrics-alerting-contract —
dependency.client.requeststimer 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-Keyheader 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 의 미설정 차단 메커니즘 영향.
- raw/branch-notes/feature-repository-access-permission-contract —
테스트 계약
- 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건
-
t3 connect-refused 포트 획득 방법:
HttpServer.create().stop(0)는 포트를 TIME_WAIT 상태로 남겨 즉시ConnectException대신TIMEOUT발생. 해결:ServerSocket(0)→close()패턴으로 교체. -
DNS failure 분류 오류 (
OutboundHttpErrorMapperbug): JDK 21HttpClient는 DNS 실패를ConnectException(cause=ConnectException(cause=UnresolvedAddressException))으로 래핑. 기존 단일 패스 cause-chain walk 에서ConnectException이 먼저 매칭되어DEPENDENCY_DNS_FAILED대신DEPENDENCY_CONNECT_FAILED반환. 수정:ConnectException매칭 시hasDnsCauseInChain()로 서브 체인을 추가 스캔, DNS 근원 발견 시DEPENDENCY_DNS_FAILED우선 반환. -
retry 미작동 (공유 인스턴스 계약 위반):
OutboundHttpResilience내부의Retry는retryPolicy::shouldRetry를retryOnExceptionpredicate 로 등록.shouldRetry는retryPolicy.beginCall()로 세팅된 ThreadLocal context 를 확인. 테스트에서retryPolicy(settings)를 두 번 호출하면 서로 다른 인스턴스 →shouldRetry가 항상 null context → return false → retry 0회. 해결:sharedPolicy변수 하나로 resilience 와 client 에 동일 인스턴스 전달. -
MeterFilter ordering 및
replaceTagValues호환성 문제 (OutboundHttpResilienceConfigbug — 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()를 직접 수행하는 customMeterFilter3개로 교체.
후속 리팩터
-
2026-06-16
OutboundHttpClientorchestration 분리 (god-object 초입 완화, behavior-preserving): 리뷰가 "client 가 생성+관측+분류 정책을 모두 들고 있어 god object 초입"이라 지적 → 사용자 지시로 과분할 금지, 딱 2개만 추출. SDD 루프(ca-implementer+ 머신 검증)로 수행.OutboundHttpRestClientFactory(package-private,static Clients create(name, baseUrl, settings)+ nestedrecord 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:test190/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(②),TraceContextPropagationInterceptorFORK 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
MetricsContractConfigFQN javadoc 2곳(@see/{@code})을.resilience.로 갱신. 검증: httpclient 스코프 테스트 119/119 PASS,CleanArchitectureTest49/49 PASS, 모듈 컴파일 0 에러. - 사고(tooling): test-file import 삽입
sed가\&(리터럴 앰퍼샌드) 버그로 5개 test 파일 1행 package 선언을&로 덮음(gradle-runner 포착) → 복구 후 재검증 green. 교훈: sed replacement 에서 매치 텍스트 보존은 비이스케이프&,\&는 리터럴&. - 컨텍스트: 동시점에 사용자가 messaging/notification 을
core/서브패키지로 병행 리팩터 중 — 본 작업은 httpclient 에만 한정, messaging/notification 미접촉.
- 가드레일 무영향: ArchUnit 규칙 전부
묶음 (이 branch에서 파생된 자료)
- 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
- 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
- raw/blog-topics/jdk-httpclient-dns-connectexception-classification-2026-07-02
- raw/blog-topics/micrometer-meterfilter-resilience4j-functioncounter-2026-07-02
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 21HttpClientDNS failure →ConnectException래핑 패턴이 단일 패스 cause-chain walk 를 뚫고 지나감. 해결 →hasDnsCauseInChain()helper 로 서브 체인 추가 스캔.actually-fixed + locally-verified2026-06-11. - raw/errors/micrometer-meterfilter-replacetagvalues-functioncounter-2026-06-11 — MeterFilter ordering +
replaceTagValuescompat (OutboundHttpResilienceConfig): eager gauge 등록 전 filter 적용 + Micrometer 1.15.xFunctionCounter/DefaultGauge에서replaceTagValues/renameTag미적용. 해결 → filter-first 순서 + customMeterFilter.map().actually-fixed + locally-verified2026-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-verified2026-06-11. - 공유 retryPolicy 인스턴스 계약: resilience 와 client 에 동일
OutboundRetryPolicy인스턴스를 전달해야 ThreadLocal context 공유 가능.actually-documented + locally-verified2026-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와 customMeterFilter의 차이 및 FunctionCounter 에서 발생하는 호환성 문제.- ThreadLocal 기반 call context (
OutboundRetryPolicy) 를 공유 인스턴스로 주입해야 하는 이유.
Blog topics
- "JDK HttpClient DNS failure classification: why
UnresolvedAddressExceptionhides insideConnectExceptionand how to handle it robustly" — cause-chain walk 전략 + 우선순위 처리. - "Micrometer MeterFilter gotcha with Resilience4j: why
replaceTagValuessilently fails on FunctionCounter in 1.15.x" — filter-first 순서 + custommap()필요성.
관련 일일 노트
이 브랜치를 작업한 날짜들. 양방향 nav 유지.
raw/daily-notes/2026-06-11(파일 미생성 — 일일 노트는 별도 생성)- 2026-06-16 —
OutboundHttpClientorchestration 분리 후속 리팩터(동작 무변경). 위 §후속 리팩터 참조.
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,OutboundHttpResilienceConfiglocally-verified항목: timeout enforcement, DNS classification fix, MeterFilter ordering fix, retry shared-instance contractprod-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)