Files

924 lines
68 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: (강사 설명) adapter-outbound 모듈의 아웃바운드 연동 및 리질리언스 설계 구조
source_type: explainer
status: reviewed
confidence: high
tags: [explainer, ca-tmpl, architecture, spring-boot, integration]
related_projects: [ca-tmpl]
last_reviewed: 2026-06-15
---
# (강사 설명) adapter-outbound — Outbound HTTP 클라이언트 완전 정복
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** "나의 진짜 이해" 를 위한 1타강사 칠판이다.
> 정확한 사실·근거·검증 등급은 여기서 만들지 않는다. 전부 canonical 에서 가져온다:
> - 개념·대안·근거: [[wiki/concepts/fail-open-fail-closed.md]], [[wiki/concepts/idempotency.md]], [[wiki/concepts/circuit-breaker.md]], [[wiki/concepts/outbox-pattern.md]], [[wiki/concepts/distributed-tracing-baggage.md]], [[wiki/concepts/spring-smart-lifecycle.md]]
> - 내 프로젝트 실제 구현·검증 범위: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md]] (§Outbound HTTP Client — 코드 사실 SSOT, `locally-verified`), [[wiki/projects/ca-tmpl/config-and-adapter-templates.md]] (adapter on/off 게이팅)
>
> 이 문서의 **비유는 의도적으로 부정확**하다 (이해를 위한 단순화). 비유를 사실로 인용하지 마라. 면접에서 말할 땐 canonical 의 표현을 써라.
>
> 📁 코드 경로 기준(본문 캡션은 파일명만 표기): `ca-tmpl/src/adapter-outbound/src/main/java/dev/caskeleton/adapter/outbound/httpclient/`
> 📌 본문의 `D5`·`D8`·`I5`·`B7` 같은 코드는 ca-tmpl 의 **설계 결정/규칙 번호**다. 흐름 이해엔 무시해도 된다(추적용 꼬리표).
---
## §0. 학습 계약 — 시작 전에 꼭 읽기
🟢 **[신입 필수]** — 이 섹션은 먼저 읽는다.
이 수업은 **하나의 클래스(`OutboundHttpClient`)가 외부 API 호출의 위험을 어떻게 가두는가**를 *코드 레벨*로 가르친다. 다 읽으면 면접에서 이 주제로 "깊이 있게" 답할 수 있는 게 목표다.
### 이 수업을 마치면 — 수료 역량 (이 질문들에 이 깊이로 답하게 된다)
| # | 질문 | 답에 *반드시* 들어가야 할 키워드 |
|---|---|---|
| E1 | 외부 API 를 그냥 `RestClient` 한 줄로 부르면 뭐가 문제인가? | 타임아웃 부재→스레드 고갈 / 무지성 재시도→이중결제 / 무한버퍼→OOM / 종료 중 호출 (4개 중 3개 + 인과) |
| E2 | POST 는 왜 재시도 안 하나? | 비멱등 → 중복 부작용. RFC 9110 멱등 메서드(GET/HEAD/PUT/DELETE)만. Idempotency-Key 미보장 |
| E3 | 서킷 브레이커 상태와 전이를 설명하라 | CLOSED/OPEN/HALF_OPEN + 각 전이 트리거 + 설정값(임계 50%·대기 60s·시험 10건) |
| E4 | "서킷 쓰면 가용성 올라가?" (함정) | **틀림** → OPEN 동안 정상 요청도 거부(가용성 일시 0). 목적 = 내 스레드·업스트림 보호 |
| E5 | 외부가 5xx + 본문에 토큰을 줬다. 클라이언트는 뭘 받나? | `{"error":{"code":"DEPENDENCY_5XX_SERVER"}}`만. 진단메시지=status+클래스명, body 비유출(2중 방어) |
| E6 | CB 와 Retry 의 감싸는 순서가 왜 중요한가? | CB 바깥 → 재시도 전체가 CB 에 **1건**으로 집계(트레이드오프 설명) |
| E7 | 100MB 응답은 어떻게 받나? | `exchange()`(buffered, 10MB 초과 예외) 대신 `stream()`(raw stream, 재시도 없음 — 스트림은 되감기 불가) |
| E8 | 배포 종료 중 새 호출이 오면? | `SmartLifecycle` phase=MAX_VALUE → 가드가 먼저 stop → 플래그 → `exchange()` Step1 fail-fast |
### 시작 전 알아야 할 것 — 선행 지식 (self-check 통과하면 OK)
| 알아야 할 것 | self-check (한 줄로 답되면 통과) | 모르면 |
|---|---|---|
| HTTP 메서드·상태코드 | "GET·POST 의 부작용 차이? 404 와 503 중 '내 잘못'은?" | MDN HTTP |
| 스레드 / 스레드 풀 | "요청 1개가 스레드 1개를 점유한다는 게 무슨 뜻?" | (§1 #1 에서 직관 보충) |
| 자바 제네릭 `<T>` / `Class<T>` | "`get(uri, User.class)` 가 어떻게 `User` 를 돌려주나?" | Oracle Generics |
| 자바 람다 / `Supplier<T>` | "`() -> x`*언제* 실행되나(즉시? 나중?)" | Oracle Lambda |
| 예외 / cause chain | "`new RuntimeException(e)` 에서 `e` 는 어디로?" | Throwable.getCause() |
| Spring Bean / `@Bean` / DI | "'빈을 등록한다'가 무슨 뜻?" | Spring IoC Container |
> 람다·제네릭이 약하면 §5 의 "코드 읽기 전 5단어" 박스를 먼저 봐라.
### 난이도 레인 & 최소 완주 경로
각 섹션 제목에 라벨이 있다: **[신입 필수]** / **[심화]** / **[참조]**.
- **신입은 §1~§11(필수)까지만 읽어도** E1~E5·E7·E8 을 답할 수 있다.
- **[심화]**(§12~§14)는 E6 + 면접 압박 질문(라이브러리 내부)을 위한 것. 1회독 후 와도 된다.
- **[참조]**(§15~§16)는 학습용이 아니라 *복습/치트시트*다.
### 이 수업을 관통하는 한 줄기 🧵
처음부터 끝까지 **"결제 호출 1건(`POST /v1/payments`, 주문 `ord-1001`)의 생애"** 를 따라간다. 이 한 건이 정상일 때 어떻게 흐르고, 각 안전장치를 만날 때 어떻게 갈리는지를 *순서대로* 본다. (결제 도메인은 이해를 위한 **가상 예시** — ca-tmpl skeleton 엔 결제 코드가 없다.)
---
## §1. 한 장면 — 5초 만에 고통 느끼기
🟢 **[신입 필수]**
외부 결제사에 `POST /payments` 를 보내다 네트워크가 순간 튀었다. 개발자가 재시도를 걸었다 → **고객에게 이중 결제**가 청구돼 민원 폭탄.
또는 Redis 캐시가 죽자 그 여파로 홈 화면 API 전체가 500 으로 마비.
또는 배포 종료(SIGTERM) 신호가 왔는데 진행 중 재시도가 커넥션을 안 놓고 버티다 강제 종료(SIGKILL), 데이터가 반쯤 처리된 채 꼬임.
또는 외부에서 수 GB 응답을 무작정 버퍼에 담다 JVM 힙이 가득 차 **OOM** 사망.
> 💡 **왜 타임아웃이 "생사 문제"인가(스레드 풀 보충):** 톰캣 같은 서버는 요청 하나당 스레드 하나를 배정한다. 스레드 수는 유한(풀, 예: 200개). 외부가 응답을 안 주는데 타임아웃이 없으면 그 스레드는 *영원히* 그 요청에 묶인다. 이런 요청이 200개 쌓이면 *새 요청을 받을 스레드가 없어* 서버 전체가 멈춘다. 이게 "스레드 고갈"이다.
**그래서 진짜 고민 한 줄: 외부 인프라·네트워크 장애로부터 우리 시스템 리소스를 어떻게 격리하고, 사이드 이펙트 없이 우아하게 방어할 것인가?**
---
## §2. 단 하나의 축
🟢 **[신입 필수] — 이 주제의 축: 정합성(Consistency) ↔ 가용성(Availability)**
아웃바운드 설계의 모든 결정은 **데이터 정합성(Consistency) ↔ 시스템 가용성(Availability)** 이라는 하나의 축 위에서 갈린다.
```text
[안전제일 / Fail-Closed (정합성 최우선)] ◄──────────────────────► [가용성 / Fail-Open (가용성 최우선)]
- Outbox Relay (Kafka) 연동 - 캐시 스토어 (Redis) 연동
- 비멱등(POST/PATCH) HTTP 재시도 차단 - 멱등(GET/PUT/DELETE) HTTP 재시도 허용
- 셧다운 가드 (즉시 신규 요청 거절) - 직접 알림 발행 (Slack/Email)
```
어떤 의존성은 장애 시 즉각 멈춰야 정합성을 지키고(Fail-Closed), 어떤 의존성은 장애를 삼키고 우회해야 가용성을 지킨다(Fail-Open). HTTP 클라이언트는 이 축 위에서 "**장애를 분류해 예외로 전달**"하는 중간 전략을 쓴다 — 무엇을 재시도/차단할지 메서드와 예외 종류로 가른다.
---
## §3. 큰 그림
🟢 **[신입 필수] — 관통 줄기: 결제 호출 1건(`ord-1001`)의 정상 항해**
### 레이어 경계 — Port & Adapter
> **새 용어 — Port & Adapter(육각형/클린 아키텍처):** Port = application(비즈니스 로직)이 "이런 기능이 필요해"라고 선언한 **인터페이스(구멍)**. Adapter = 그 구멍을 실제 기술(HTTP/Redis/Kafka)로 **메우는 구현체**. 비즈니스 로직이 "외부가 HTTP 인지 Redis 인지" 몰라도 되게 분리하는 게 목적. 의존성 화살표는 항상 바깥(adapter)에서 안쪽(core)을 향한다.
![Outbound Adapter Architecture](images/outbound-adapter-architecture.png)
`OutboundHttpClient` 는 그 그림에서 **HTTP adapter 가 외부 세계로 나가는 출구**다. 사용자의 "결제하기" 클릭 한 번이 이렇게 흐른다:
```mermaid
sequenceDiagram
autonumber
participant Web as 🌫️ web (미지의 영역)<br>Controller
participant App as 🌫️ application (미지의 영역)<br>PayUseCase
participant Port as PaymentPort<br>(인터페이스)
participant Adapter as PaymentHttpAdapter<br>(outbound adapter)
participant Client as OutboundHttpClient
participant Ext as 외부 결제사 서버
Web->>App: PaymentCommand(orderId, amount, currency)
App->>Port: pay(command)
Note over Port,Adapter: Port 는 application 이 정의한 "구멍",<br>Adapter 가 그 구멍을 HTTP 로 메운다
Adapter->>Client: exchange(POST, "/v1/payments", reqObj, PaymentResult.class)
Client->>Ext: POST /v1/payments (객체 → JSON 직렬화)
Ext-->>Client: 200 OK (JSON 본문)
Client-->>Adapter: PaymentResult (JSON → 객체 역직렬화)
Adapter-->>App: 도메인 결과
```
### 경계를 넘는 실제 값 (JSON 입·출력)
> **새 용어 — 직렬화/역직렬화:** 자바 객체 ↔ JSON 문자열 변환. 나갈 때 객체→JSON(직렬화), 들어올 때 JSON→객체(역직렬화). 내가 짜지 않고 RestClient 가 한다(자세히는 §13).
1. **application → adapter (자바 객체):** application 은 외부가 HTTP 인지 모른다. 객체만 넘긴다.
`PaymentCommand(orderId="ord-1001", amount=15000, currency="KRW")`
2. **adapter → `exchange()` 호출:**
```java
PaymentResult result = paymentClient.exchange(
HttpMethod.POST, "/v1/payments",
new PaymentRequest("ord-1001", 15000, "KRW"), // ← requestBody (자바 객체)
PaymentResult.class); // ← 응답을 이 타입으로 받겠다
```
3. **`exchange()` 가 실제로 내보내는 HTTP (객체 → JSON):**
```http
POST /v1/payments HTTP/1.1
Host: payment
traceparent: 00-4bf9...-00f0...-00 ← 인터셉터가 자동 주입 (§11)
Content-Type: application/json
{"orderId":"ord-1001","amount":15000,"currency":"KRW"}
```
4. **외부 성공 응답(JSON):** `{"paymentId":"pay_abc","status":"APPROVED","approvedAt":"2026-06-15T09:00:00Z"}`
5. **반환값:** RestClient 가 JSON 을 `PaymentResult` 로 역직렬화 → adapter 는 `PaymentResult(paymentId="pay_abc", status=APPROVED, …)` 객체를 받음.
6. **실패(500)면?** `exchange()` 는 `DependencyFailureException`(코드 `DEPENDENCY_5XX_SERVER`)를 **던진다** → §10 에서 추적.
➡️ 한 줄 요약: `exchange()` 입력 = **(메서드 + 경로 + 요청객체 + 응답타입)**, 출력 = **역직렬화된 응답객체** 또는 **던져진 `DependencyFailureException`**.
---
## §4. 클래스의 모양 — 공개 메서드 4개 [신입 필수]
> **새 용어 — 정적 팩토리(static factory):** `new` 대신 `static` 메서드로 객체를 만드는 방식. 여기선 아키텍처 규칙(ArchUnit "B7": 어댑터 타입을 반환하는 *일반* 메서드 금지)을 `static` 으로 우회하는 합법 통로(seam). · **제네릭 `<T>`:** "호출자가 정한 타입". `get(uri, PaymentResult.class)` 면 `T=PaymentResult`.
진입 클래스 `OutboundHttpClient` 는 **외부 의존성 1개당 인스턴스 1개**(결제용 1개, 재고용 1개 …). 공개 메서드 4개:
| 부르는 법 | 코드 위치 | 넣는 것 | 나오는 것 |
|---|---|---|---|
| `baseline(name, baseUrl, …협력자 8개)` | `OutboundHttpClient.java:140` | 의존성 이름 + 협력 빈 | 그 의존성 전용 클라이언트 |
| `get(uri, Class<T>)` | `:166` | URI + 응답 타입 | 역직렬화된 `T` |
| `exchange(method, uri, body, Class<T>)` | `:184` | 메서드 + URI + 요청 바디 + 응답 타입 | 역직렬화된 `T` |
| `stream(method, uri, reader)` | `:281` | 메서드 + URI + 스트림 리더(함수) | 리더가 만든 `T` (대용량 전용, 재시도 X) |
```java
// 📄 OutboundHttpClient.java:166-168 — get 은 exchange 의 GET 단축
public <T> T get(String uri, Class<T> responseType) {
return exchange(HttpMethod.GET, uri, null, responseType);
}
```
<details><summary>✅ 이해 점검 (펼쳐서 스스로 답해보기)</summary>
1. `exchange()` 와 `stream()` 의 *출력 형태* 차이는? (정답: exchange=역직렬화된 객체 / stream=리더 함수가 만든 값. stream 은 재시도 없음)
2. `baseline(...)` 이 `static` 인 이유 한 줄? (ArchUnit B7 우회 seam)
</details>
---
## §5. `exchange()` 한 줄씩 — 정상 골격 [신입 필수]
> 🔑 **이 코드 읽기 전 5단어** (이거만 알면 아래가 읽힌다):
> - **supplier** = "값을 주는 함수"(`Supplier<T>`). *호출(`.get()`)해야* 실제로 실행된다(준비 ≠ 실행).
> - **람다 `() -> {...}`** = 이름 없는 함수 한 덩어리. `() ->` 는 "인자 없이 {…} 를 실행".
> - **`<T>`** = 호출자가 받고 싶은 응답 타입(예: `PaymentResult`).
> - **ThreadLocal** = "스레드 전용 변수칸"(다른 스레드와 안 섞임).
> - **데코레이션(decorate)** = 함수를 *한 겹 감싸* 새 능력(재시도·차단)을 입히는 것.
```java
// 📄 OutboundHttpClient.java:184-258 — exchange() (주석 축약)
public <T> T exchange(HttpMethod method, String uri, Object requestBody, Class<T> responseType) {
// ── Step 1. 셧다운 fail-fast: 종료 중이면 네트워크를 맺지도 않고 즉시 거부 (§6)
if (shutdownGuard.isShuttingDown()) {
DependencyFailureException rejected = new DependencyFailureException(
OperationalError.DEPENDENCY_CIRCUIT_OPEN, // ← 나가는 예외 "값"
dependencyName,
"shutdown in progress — outbound call rejected fail-fast (D8)", null);
logger.logFailure(dependencyName, "REJECTED", 0L, 0, rejected);
throw rejected; // ← 여기서 나간다
}
// ── Step 2. 마감시한 산정 + 스레드에 적재 (재시도 루프가 이 시각을 본다) (§7)
Instant deadline = Instant.now().plus(settings.globalCallTimeout());
retryPolicy.beginCall(method, deadline);
int[] attemptCount = {0}; // 시도 횟수(람다가 고치려고 1칸 배열 — §14)
long startNs = System.nanoTime();
try {
// ── Step 3. 실제 호출(buffered)을 supplier 로 "준비"만 한다 (아직 실행 X)
Supplier<T> supplier = buildSupplier(method, uri, requestBody, responseType);
// ── Step 4. CB·Retry 로 감싼다 (감싸는 순서의 의미는 §12 [심화])
Optional<CircuitBreaker> cb = resilience.circuitBreakerFor(dependencyName);
Optional<Retry> retry = resilience.retryFor(dependencyName);
Supplier<T> countingSupplier = () -> { attemptCount[0]++; return supplier.get(); };
Supplier<T> decorated = countingSupplier;
if (retry.isPresent()) decorated = Retry.decorateSupplier(retry.get(), decorated);
if (cb.isPresent()) decorated = CircuitBreaker.decorateSupplier(cb.get(), decorated);
T result = decorated.get(); // ← 여기서 비로소 실제 네트워크 호출이 일어난다
// ── Step 5. 성공 로그 (소요시간 + 재시도 횟수)
long durationMs = (System.nanoTime() - startNs) / 1_000_000;
logger.logSuccess(dependencyName, durationMs, Math.max(0, attemptCount[0] - 1));
return result;
} catch (OutboundResponseSizeExceededException sizeEx) {
throw sizeEx; // ── Step 6a. 응답 과대 = "API 오용" → 분류 없이 그대로 (§9)
} catch (Throwable t) { // Throwable = 자바 모든 예외의 최상위 = 사실상 전부
// ── Step 6b. 그 외 모든 실패 → 하나의 DependencyFailureException 으로 "번역" (§10)
long durationMs = (System.nanoTime() - startNs) / 1_000_000;
DependencyFailureException dfe = errorMapper.classify(dependencyName, t);
logger.logFailure(dependencyName, outcomeFor(dfe), durationMs,
Math.max(0, attemptCount[0] - 1), dfe);
throw dfe; // ← 호출자는 항상 이 분류된 예외만 본다
} finally {
retryPolicy.endCall(); // 성공·예외 무관 *반드시* 실행 → ThreadLocal 정리(누수 방지 §14)
}
}
```
골격 5줄 요약: ① 종료 중이면 즉시 거부 → ② 마감시한 적재 → ③ 호출을 *준비* → ④ 감싸서 `decorated.get()` 으로 *실행* → ⑤/⑥ 성공 로그 또는 예외 번역. **`supplier` 는 레시피일 뿐, `.get()` 을 불러야 요리된다**(지연 실행). 각 안전장치의 *내부*는 §6~§11 에서 하나씩 연다.
<details><summary>✅ 이해 점검</summary>
1. *네트워크가 실제로 일어나는* 코드 한 줄은? (정답: `decorated.get()`)
2. `finally` 의 `endCall()` 을 빼면? (ThreadLocal 누수 → 풀 스레드 재사용 시 이전 요청 컨텍스트 오염 — §14)
3. 응답 과대(Step 6a)만 분류 없이 그대로 던지는 이유? (업스트림 장애가 아니라 "버퍼 API 오용")
</details>
---
## §5.5. 안전장치 ⓪ 타임아웃 3종 — connect·read·global [신입 필수]
> **새 용어:** **connect timeout** = TCP 연결(핸드셰이크) 맺기까지의 제한. **read timeout** = 연결 후 *한 번의* 응답 바이트를 기다리는 제한. **global-call timeout** = 재시도까지 포함한 *전체* 마감(= §7 의 deadline 예산).
타임아웃은 가장 기본 안전장치다 — 셧다운·재시도·서킷보다 먼저, **모든 호출에 무조건** 적용된다. 하나라도 빠지면 §1 #1 의 "무한 대기 → 스레드 고갈"이 그 구멍으로 샌다. 세 개가 *서로 다른 단계*를 끊는다:
```text
[연결 시도] ──connect timeout(예 2s)──▶ [연결됨] ──read timeout(예 5s)──▶ [응답 한 번 도착]
└──────────────── global-call timeout(예 10s): 재시도 다 합쳐 여기까지 ────────────────┘
```
설정/배선 (생성자에서):
```java
// 📄 OutboundHttpClient.java:95-99 — 타임아웃 2원화
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(settings.connectTimeout()).build(); // ← connect 는 JDK HttpClient 가
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(settings.readTimeout()); // ← read 는 factory 가
// global 은 타임아웃 객체가 아니라 exchange() 의 deadline 예산으로 강제 (§7)
```
> 🤔 **왜 connect 와 read 가 다른 객체에?** JDK `HttpClient.Builder` 엔 connectTimeout API 만 있고 *per-request read timeout 이 없다*. 그래서 Spring 의 `JdkClientHttpRequestFactory.setReadTimeout` 이 그 공백을 메운다(라이브러리 API 한계). global 은 라이브러리가 안 주니 우리가 deadline 으로 직접 만든다.
#### 타임아웃 설정값과 역할 (`app.outbound.http.*`)
| 설정 | 역할 (무엇을 끊나) | 기본 | 없거나 0/음수면 |
|---|---|---|---|
| `connect-timeout` | TCP 연결(핸드셰이크)까지 | **필수(기본 없음)** | 죽은/방화벽 막힌 호스트에 무한 대기 |
| `read-timeout` | 연결 후 응답 한 번까지 | **필수** | 응답을 질질 끄는 서버에 스레드 묶임 |
| `global-call-timeout` | 재시도 포함 전체 마감(=deadline) | **필수** | 재시도 루프가 끝없이 늘어짐 |
셋 다 **필수 입력**이라, 하나라도 비거나 잘못되면 §13① 의 `@ConfigurationProperties` 검증이 `IllegalArgumentException` 으로 **앱 기동을 막는다** — 무한 대기 구멍을 *기동 시점에* 봉쇄한다.
> 🧑‍🏫 **한마디:** connect/read 는 *한 단계*를, global 은 *전체*를 끊는다. 보통 connect ≤ read ≤ global 로 잡아 어느 단계에서 멈춰도 새는 곳이 없게 한다. 단 §7 에서 봤듯 global(deadline)은 *진행 중 read 를 강제로 못 끊어* 하드컷이 아니다 — 진행 중 호출의 상한은 결국 read timeout 이 책임진다.
<details><summary>✅ 이해 점검</summary>
1. 상대가 TCP 연결은 받아주는데 응답 바이트를 영영 안 주면, 어느 타임아웃이 끊나? (read)
2. connect/read 가 왜 두 객체(JDK HttpClient / factory)에 나뉘나? (JDK 에 per-request read API 부재)
</details>
---
## §6. 안전장치 ① 셧다운 fail-fast — `SmartLifecycle` [신입 필수]
배포로 서버가 종료 중일 때 새 외부 호출이 들어오면, 반쯤 죽은 빈을 건드려 NPE·자원 누수가 난다. 그래서 **종료가 시작되면 가장 먼저 깃발을 올려** 신규 호출을 즉시 끊는다.
```java
// 📄 OutboundHttpShutdownGuard.java:41-77 (발췌) — SmartLifecycle 구현
@Override public void stop() { shuttingDown.set(true); running.set(false); } // 종료 시 호출됨
@Override public int getPhase(){ return Integer.MAX_VALUE; } // ← phase 최대 = 내림차순에서 1순위로 stop
public boolean isShuttingDown() { return shuttingDown.get(); } // exchange Step1 / shouldRetry 가 조회
```
**어떻게 동작하나:** Spring 컨테이너는 종료 시 `SmartLifecycle` 빈들의 `stop()` 을 **phase 큰 것부터(내림차순)** 호출한다. phase 를 `Integer.MAX_VALUE` 로 둬서 이 가드의 `stop()` 이 *맨 먼저* 불리고 `shuttingDown` 깃발이 켜진다 → 외부 호출하는 다른 빈이 아직 살아있을 때 이미 신규 호출을 막는다.
> 🤔 **왜 `ContextClosedEvent` 가 아니라 `SmartLifecycle`?** (자가점검 단골) `ContextClosedEvent` 리스너는 *컨테이너가 이미 닫히기 시작한 뒤* + 리스너 간 순서 보장 없이 불린다 → 그 사이 다른 빈이 먼저 죽어버릴 수 있다. `SmartLifecycle` 의 phase 순서는 *결정론적*이라 "내가 1순위"를 보장한다.
이 깃발은 두 곳이 읽는다: `exchange()` Step 1(신규 호출 즉시 `DEPENDENCY_CIRCUIT_OPEN`) + `shouldRetry()` 관문1(진행 중 재시도 중단).
---
## §7. 안전장치 ② 재시도 — *할지*(4-관문) + *어떻게*(루프·백오프) [신입 필수]
> **새 용어 — 멱등(idempotent):** 같은 요청을 여러 번 보내도 결과가 한 번과 같음. GET/PUT/DELETE 는 멱등(안전하게 재시도 가능), **POST 는 비멱등**(보낼 때마다 새 결제가 생김 → 재시도 금지). · **데드라인 예산:** "늦어도 이 시각까지"라는 전체 마감. · **백오프/지터:** 재시도 간 대기를 점점 늘리고(backoff) 거기에 무작위를 섞어(jitter) 모두가 동시에 재시도(thundering herd)하는 걸 막음.
#### A. 재시도를 *할지* 결정 — 4-관문 (`shouldRetry`)
재시도는 무조건 하면 위험하다(이중 결제). 그래서 **4-관문을 전부 통과해야만** 재시도한다:
```java
// 📄 OutboundRetryPolicy.java:103-133 — shouldRetry() (반환문 압축)
public boolean shouldRetry(Throwable failure) {
if (guard.isShuttingDown()) return false; // 관문1: 종료 중이면 끝
CallContext ctx = callContextHolder.get();
if (ctx == null) return false; // beginCall 안 됐으면 끝
if (!IDEMPOTENT_METHODS.contains(ctx.method())) return false; // 관문2: POST/PATCH 차단
boolean retryable;
if (failure instanceof DependencyFailureException dfe)
retryable = dfe.errorCode().retryable(); // 이미 번역됨 → 그 코드의 플래그
else
retryable = mapper.classify("_retry-check_", failure).errorCode().retryable();
if (!retryable) return false; // 관문3: 재시도 가능 코드만 (§10 표)
return Instant.now().isBefore(ctx.deadline()); // 관문4: 마감시한 예산 남았나
}
// IDEMPOTENT_METHODS = Set.of(GET, HEAD, PUT, DELETE) ← :52-53 (POST/PATCH 의도적 제외)
```
순서대로: ① **종료 중 아님** → ② **멱등 메서드** → ③ **재시도 가능 코드**(§10 의 "재시도?" 칸) → ④ **마감시한 남음**. (코드상으론 `ctx==null` 까지 5개의 조기 반환이지만, 논리적으론 4-관문.)
> ⚠️ **[심화] deadline 은 "하드 데드라인"이 아니다.** 관문4 는 *재시도를 시작하기 전*에만 검사한다(`shouldRetry` 안). 즉 **이미 시작된 read 는 강제로 못 끊는다** → 마지막 시도가 read-timeout 만큼 deadline 을 *초과*해 끝날 수 있다. "deadline=다음 재시도 차단선"이지 "30s 면 무조건 30s 에 끊김"이 아니다. 진짜 하드 컷이 필요하면 Resilience4j `TimeLimiter`(+별도 스레드)가 필요한데, 동기 클라이언트엔 스레드 낭비라 *의도적으로* deadline 예산만 택했다(결정 I3).
#### B. 재시도가 *어떻게* 도나 — 루프·횟수·백오프·지터
게이트(A)가 "해도 된다"고 하면, Resilience4j `Retry` 가 *실제 루프*를 돈다. 그 설정을 만드는 코드:
```java
// 📄 OutboundHttpResilience.java:82-90 — retryFor(): 재시도 설정 빌드
RetryConfig config = RetryConfig.custom()
.maxAttempts(r.maxAttempts()) // 총 시도 횟수 (기본 3)
.intervalFunction(IntervalFunction.ofExponentialRandomBackoff( // 지수 백오프 + 지터
r.initialBackoff(), r.backoffMultiplier())) // 기본 100ms, ×2.0
.retryOnException(retryPolicy::shouldRetry) // ← 4-관문(A)이 여기 꽂힌다
.build();
```
- **`retryOnException(shouldRetry)`** — 매 실패마다 Retry 가 4-관문을 *다시* 물어본다. true 면 한 번 더, false 면 즉시 포기. 즉 **게이트(A)는 루프 안에서 매 회 호출**된다.
- **`maxAttempts=3`** — 첫 시도 1 + 재시도 2 = **총 3번**. (재시도 켠 채 매번 500 주는 GET 은 서버를 *정확히 3번* 친다 — 테스트 검증.)
- **백오프 = 지수 + 지터** — 시도 사이 *대기 시간*. nominal = `initial-backoff × multiplier^(n-1)` → 기본값이면 100ms, 200ms … 거기에 **±50% 무작위(지터)** 를 섞는다(Resilience4j 기본 randomizationFactor 0.5).
타임라인 (기본값, GET 이 매번 timeout):
```text
시도1 ─실패→ 대기 ~100ms(지터 [50,150]) → 시도2 ─실패→ 대기 ~200ms(지터 [100,300]) → 시도3 ─실패→ 포기(예외 전파)
└─────────────────── 매 대기 직전 4-관문④(deadline)을 다시 확인 ───────────────────┘
```
> **왜 지터?** 장애 순간 수백 개 요청이 *똑같이* 100ms 뒤 동시에 재시도하면 회복 중인 상대를 또 무너뜨린다(thundering herd). ±무작위로 시점을 흩뜨려 막는다.
#### 재시도 설정값과 역할 (`app.outbound.http.retry.*`)
| 설정 | 역할 | 기본 | 바꾸면 |
|---|---|---|---|
| `retry-enabled` | 재시도 기능 on/off (off 면 `retryFor`→`Optional.empty()` = 데코 안 함) | `false` | `true` 라야 위 루프가 생김 |
| `retry.max-attempts` | **총** 시도 횟수(첫 시도 포함) | `3` | `5` → 최대 4번 재시도 |
| `retry.initial-backoff` | 첫 재시도 전 nominal 대기 | `100ms` | 키우면 첫 대기 ↑ |
| `retry.backoff-multiplier` | 매 재시도마다 대기 ×배수 | `2.0` | `3.0` → 100→300→900ms |
> 🧑‍🏫 **한마디:** 게이트(A)=*할지*, 루프(B)=*어떻게*. 재시도가 실제로 일어나려면 **`retry-enabled=true`** + **4-관문 통과** 둘 다 필요하다. (서킷 §8 과 합쳐지는 순서·집계는 §12 [심화].)
<details><summary>✅ 이해 점검</summary>
1. `POST /orders` 가 `SocketTimeoutException` → 재시도되나? 어느 관문에서 탈락? (관문2)
2. `GET /products/1` 가 404 → 재시도되나? 왜? (관문3 — 4xx 는 retryable=false, §10)
3. `max-attempts=3` 이고 매번 실패면 서버를 몇 번 치고, 대기는 몇 번 하나? (정답: 3번 호출 / 2번 대기)
4. `initial-backoff=100ms`, `backoff-multiplier=2.0` 면 *두 번째* 재시도 전 nominal 대기는? (200ms)
</details>
---
## §8. 안전장치 ③ 서킷 브레이커 — 0부터 [신입 필수]
> 근거: 개념 [[wiki/concepts/circuit-breaker.md]], 설정 수치는 canonical project 문서. 라이브러리는 Resilience4j.
**서킷 브레이커가 뭔데?** 집 누전차단기(두꺼비집)다. 과부하/누전 시 차단기가 *탁* 내려가 집 전체 화재를 막고, 잠시 뒤 다시 올려본다. **단 — 차단기가 내려간 동안은 멀쩡한 가전도 못 쓴다.** 소프트웨어도 똑같다: 어떤 외부 의존성이 계속 실패하면 그쪽 호출을 한동안 *아예 끊는다*. 죽은 서버를 계속 두들겨봐야 ① 내 스레드만 묶이고 ② 아픈 상대를 더 괴롭히기 때문.
**무엇을 감시?** 그 의존성으로 나간 **최근 100건의 실패 비율**(= 슬라이딩 윈도우).
```mermaid
stateDiagram-v2
[*] --> CLOSED
CLOSED --> OPEN: 최근 100건 실패율 ≥ 50%
OPEN --> HALF_OPEN: 60초 경과
HALF_OPEN --> CLOSED: 시험 10건 실패율 < 50% (복구)
HALF_OPEN --> OPEN: 시험 10건 실패율 ≥ 50% (아직 아픔)
note right of CLOSED
정상. 통과시키며 실패율만 측정
end note
note right of OPEN
차단. 네트워크 안 감.
즉시 CallNotPermittedException
end note
note right of HALF_OPEN
간 보기. 동시 10건만 통과
end note
```
- **CLOSED(정상):** 다 통과시키며 실패율을 잰다.
- **OPEN(차단):** 외부로 **안 보내고** 즉시 `CallNotPermittedException` 을 던진다(= §10 표의 `DEPENDENCY_CIRCUIT_OPEN`). ms 단위로 빠르게 실패(fail-fast).
- **HALF_OPEN(간 보기):** 대기 후 "살아났나?" 확인하려 **동시 10건만** 통과시키고 나머진 거부. 그 10건 결과가 다 모이면 CLOSED 복귀냐 OPEN 회귀냐 결정.
**각 전이를 어떤 설정값이 정하나:**
| 전이 | 트리거 | 설정 (`app.outbound.http.circuit-breaker.*`) | 기본 |
|---|---|---|---|
| CLOSED → OPEN | 윈도우가 차고 실패율 임계 이상 | `sliding-window-size` / `minimum-number-of-calls` / `failure-rate-threshold` | 100 / 100 / 50% |
| OPEN → HALF_OPEN | 대기시간 경과 | `wait-duration-in-open-state` | 60s |
| HALF_OPEN → CLOSED/OPEN | 시험 호출 실패율 < / ≥ 임계 | `permitted-calls-in-half-open` (+ 임계) | 10 |
> 🧩 **두 설정이 헷갈린다 — `sliding-window-size` vs `minimum-number-of-calls`:** 우연히 둘 다 100이지만 *다른 손잡이*다. 윈도우 크기 = "실패율을 *재는 표본 범위*", min-calls = "실패율을 *계산하기 시작하는 최소 건수*". 100건이 안 모이면 한두 번 실패해도 서킷을 안 연다(통계 노이즈 방지).
>
> 🔬 **[심화] HALF_OPEN 의 윈도우는 따로다.** HALF_OPEN 에 들어가면 100짜리 윈도우를 비우고 `permitted-calls-in-half-open`(10) 크기의 *별도 시험 윈도우*로 평가한다. 그 10건의 실패율로 CLOSED/OPEN 을 가른다. (이 윈도우는 시간이 아니라 *건수* 기준 = `slidingWindowType` 이 COUNT_BASED. 코드엔 노출 안 됨 = Resilience4j 기본값. TIME_BASED 로 바꾸려면 코드 수정 필요.)
**타임라인(기본값):** ① payment 가 100건 중 60건 실패 → 실패율 60% → **OPEN.** ② 60초간 모든 payment 호출 즉시 거절(내 스레드 보호, 상대 숨 돌림). ③ 60초 후 **HALF_OPEN**, 10건 떠봄 → 1건만 실패(10%) → **CLOSED 복귀.** ④ 만약 6건 실패면 → **다시 OPEN.**
**코드에서 어디?** `OutboundHttpResilience.circuitBreakerFor("payment")` 가 의존성 이름별 인스턴스를 캐시 → payment 와 inventory 는 *독립된* 두꺼비집(한쪽이 열려도 다른 쪽 멀쩡).
> ⚠️ **과장 금지(면접용):** "서킷 쓰면 가용성 올라간다"는 **틀린 말**이다. OPEN 동안은 멀쩡한 요청도 거절돼 *그 의존성 가용성은 일시적으로 0*. 서킷의 진짜 목적은 가용성이 아니라 **내 스레드 보호 + 아픈 업스트림 보호**다.
<details><summary>✅ 이해 점검</summary>
1. 빈 종이에 3상태 + 전이 4개 + 트리거를 직접 그려보라. (E3)
2. `minimum-number-of-calls=100` 이 없으면 서버 켜자마자 무슨 일? (1~2건 실패로 서킷 오픈 — 노이즈)
3. "서킷 쓰면 가용성 ↑?" O/X + 한 줄 교정. (E4)
</details>
---
## §9. 안전장치 ④ 응답 크기 & 스트리밍 [신입 필수]
상대가 2GB 응답을 주는데 버퍼에 다 담으면 OOM. 그래서 **buffered 경로엔 크기 상한(기본 10MB)**, 대용량은 **streaming 경로**로 분리한다.
`ResponseSizeBoundingInterceptor` 는 **2단 방어**다:
1. **Content-Length 빠른 차단:** 응답 헤더의 선언 크기가 상한을 넘으면 본문을 *한 바이트도 안 읽고* `OutboundResponseSizeExceededException`.
2. **스트림 카운팅:** 헤더가 없거나 *거짓말*하면, `BoundedInputStream` 이 읽는 바이트를 세다 상한 초과 시 throw.
대용량은 buffered 가 아니라 streaming:
```java
// 📄 OutboundHttpClient.java:295-298 — 버퍼 없이 raw InputStream 을 reader 에게 직접
T result = streamingClient.method(method).uri(uri)
.exchange((req, res) -> reader.apply(res.getBody()));
```
`exchange()` 콜백은 응답을 메모리에 다 담지 않고 `InputStream` 을 그대로 넘긴다 → 100MB CSV OK. 단 **재시도 없음** — 한 번 흘려보낸 스트림은 (수도꼭지에서 이미 흘러간 물처럼) 되감을 수 없어 다시 보낼 수 없다(자가점검 Q5 답).
---
## §10. 실패의 번역 — `classify()` + 예외 운반 [신입 필수]
> **새 용어 — cause chain(원인 사슬):** 예외 A 가 예외 B 때문에 났을 때 `A.getCause()==B` 로 줄줄이 연결된 것. 진짜 원인은 사슬 아래에 숨어 있곤 한다.
`exchange()` 가 잡은 raw 예외(`Throwable`)는 **하나의 `DependencyFailureException` 으로 번역**된다. `classify()` 가 cause chain 을 훑어 첫 매치를 채택:
```java
// 📄 OutboundHttpErrorMapper.java:63-169 — classify() (메시지 인자 …로 생략)
public DependencyFailureException classify(String dependencyName, Throwable failure) {
Throwable current = failure;
while (current != null) { // 원인 사슬을 위에서부터 한 칸씩
if (current instanceof CallNotPermittedException) // 규칙1: 서킷 OPEN (§8)
return new DependencyFailureException(OperationalError.DEPENDENCY_CIRCUIT_OPEN, …);
if (current instanceof UnknownHostException || current instanceof UnresolvedAddressException)
return new DependencyFailureException(OperationalError.DEPENDENCY_DNS_FAILED, …); // 규칙2: DNS
if (current instanceof HttpConnectTimeoutException) // 규칙3: 연결 (먼저!)
return new DependencyFailureException(OperationalError.DEPENDENCY_CONNECT_FAILED, …);
if (current instanceof ConnectException) {
if (hasDnsCauseInChain(current.getCause())) // 연결예외가 사실 DNS 를 감쌌으면 DNS 로
return new DependencyFailureException(OperationalError.DEPENDENCY_DNS_FAILED, …);
return new DependencyFailureException(OperationalError.DEPENDENCY_CONNECT_FAILED, …);
}
if (current instanceof HttpTimeoutException || current instanceof SocketTimeoutException
|| current instanceof TimeoutException) // 규칙4: 시간 초과
return new DependencyFailureException(OperationalError.DEPENDENCY_TIMEOUT, …);
if (current instanceof RestClientResponseException responseEx) { // 규칙5/6: HTTP 상태
int status = responseEx.getStatusCode().value();
if (status >= 400 && status < 500) // I5: 모든 4xx = 비재시도
return new DependencyFailureException(OperationalError.DEPENDENCY_4XX_CLIENT, …);
if (status >= 500)
return new DependencyFailureException(OperationalError.DEPENDENCY_5XX_SERVER, …);
}
current = current.getCause(); // 다음 원인으로
}
return new DependencyFailureException(OperationalError.DEPENDENCY_CONNECT_FAILED, …); // 규칙7: fallback
}
```
> 🤔 **왜 `HttpConnectTimeoutException` 을 먼저 검사하나?** 자바는 부모 타입으로 `instanceof` 하면 자식도 다 걸린다. `HttpConnectTimeoutException` 은 `HttpTimeoutException`(규칙4)의 *자식*이라, 규칙4 를 먼저 두면 connect-timeout 이 일반 timeout 으로 *오분류*된다 → 그래서 규칙3(connect)이 위. · **ConnectException 의 DNS 재탐색:** JDK 가 DNS 실패를 `ConnectException(cause=UnresolvedAddressException)` 로 감싸는 패턴이 있어, 그 *하위 사슬*을 한 번 더 훑어 DNS 면 `DNS_FAILED` 로 승격한다(분류 충실도).
번역 결과(코드·HTTP·재시도 여부)는 `OperationalError` enum 에 못박혀 있다:
| 실제로 터진 예외 | 번역된 코드 | HTTP / 재시도? |
|---|---|---|
| `CallNotPermittedException`(서킷 OPEN) | `DEPENDENCY_CIRCUIT_OPEN` | 503 / ✅ |
| `UnknownHostException`(DNS) | `DEPENDENCY_DNS_FAILED` | 503 / ✅ |
| `ConnectException`(연결) | `DEPENDENCY_CONNECT_FAILED` | 503 / ✅ |
| `SocketTimeoutException` 등(시간초과) | `DEPENDENCY_TIMEOUT` | 504 / ✅ |
| 상대 4xx | `DEPENDENCY_4XX_CLIENT` | 502 / ❌ (401→자격증명, 403→권한 힌트) |
| 상대 5xx | `DEPENDENCY_5XX_SERVER` | 502 / ✅ |
> 4xx 가 ❌ 인 이유: 잘못 보낸 요청을 똑같이 다시 보내봐야 또 거절. **알려진 한계:** 408(타임아웃)·429(과다요청)는 원래 재시도 가치가 있는데 "모든 4xx=비재시도"라 함께 막힌다.
### 예외 객체는 어떻게 "담겨서" 위로 가나
```java
// 📄 shared/error/DependencyFailureException.java (발췌) — 분류된 실패 운반체
public class DependencyFailureException extends RuntimeException {
private final ApiErrorCode errorCode; // ① 클라이언트에 줄 코드 (DEPENDENCY_5XX_SERVER)
private final String dependencyName; // ② 누가 실패했나 ("payment")
public DependencyFailureException(ApiErrorCode errorCode, String dependencyName,
String diagnosticMessage, Throwable cause) {
super(diagnosticMessage, cause); // ③ diagnosticMessage = 서버 로그 전용, ④ cause = 원본 예외
...
}
}
```
5xx 메시지 조립(`:151-154`): `"Upstream 5xx from dependency: payment status=500 (HttpServerErrorException)"` — **status + 예외 클래스명만.**
> 🔒 **비밀(토큰/PII)이 안 새는 2중 방어:** (1) `classify()` 가 메시지에 `getResponseBodyAsString()`(외부 응답 본문)을 *안 넣는다* + (2) 구조화 로거(`OutboundHttpDependencyLogger`)는 애초에 **응답 body·URI 를 받는 파라미터가 없다**(시그니처 차원 봉쇄, "by construction"). 그래서 외부가 `{"token":"sk_live_secret"}` 를 줘도:
> - 서버 로그 메시지: `…status=500 (HttpServerErrorException)` (secret 없음)
> - 클라이언트 응답: `{"error":{"code":"DEPENDENCY_5XX_SERVER"}}` (코드만)
> - 원본 예외는 `cause` 로 서버 스택트레이스에만. (이 비유출을 단위 테스트가 검증.)
**전달 경로:** `exchange()` 가 throw → adapter·application 은 안 잡음 → 🌫️ web 의 `GlobalExceptionHandler` 가 잡아 `errorCode()` 만 읽어 클라이언트용 봉투로 변환(이 계약은 `DependencyFailureException` javadoc 에 명시). web 변환부 *세부*는 미지의 영역.
<details><summary>✅ 이해 점검</summary>
1. 외부 500 + body `{"token":"…"}` → ① 서버 로그 메시지 ② 클라이언트 응답을 각각 써보라. (E5)
2. `HttpConnectTimeoutException` 을 `HttpTimeoutException` 보다 먼저 검사하는 이유? (상속 + instanceof 순서)
</details>
---
## §11. 횡단 관심사 — trace / baggage 인터셉터 [신입 필수]
> **새 용어 — MDC:** 로그·추적용 "스레드별 메모장". **traceparent:** W3C 표준 분산추적 헤더(`00-traceid-spanid-flag`). **baggage:** 서비스 간 따라다니는 키-값. **allowlist:** 허용 목록(나머지는 차단).
외부로 나가는 모든 요청에 `TraceContextPropagationInterceptor` 가 *먼저* 끼어들어 MDC 의 추적 정보를 헤더로 붙인다 — 여러 서버를 관통하는 한 요청을 추적하려고. 단 **baggage 는 allowlist(`tenant_id`·`request_id`)만** 통과시키고 나머지(이메일·토큰 등)는 전송 전 박멸한다(보안 경계).
```text
입력 MDC: trace_id, span_id, tenant_id, user_email(민감)
출력 헤더: traceparent: 00-<trace_id>-<span_id>-00
baggage: tenant_id=... (user_email 은 자동 탈락)
```
> ⚠️ **[한계]** 현재 traceparent 의 샘플링 비트가 `00`(Not-Sampled)으로 하드코딩이다. 실제 운영 분산추적엔 OpenTelemetry SDK / Micrometer Tracing 연동으로 교체해야 한다(스켈레톤 한계).
---
## §12. [심화] 데코레이션 순서의 진실 — CB 는 retry 의 *바깥*
§5 Step 4 에서 `Retry.decorateSupplier` 로 감싼 뒤 `CircuitBreaker.decorateSupplier` 로 또 감쌌다. **마지막에 감싼 게 가장 바깥 껍질**이므로 최종 구조는:
```text
CircuitBreaker( Retry( countingSupplier → 실제 호출 ) )
└ 바깥 ─────────┘ └ 안쪽 ┘
실행: cb.executeSupplier( () -> retry.executeSupplier( counting ) )
```
**이게 무슨 뜻인가(★중요):** CB 가 가장 바깥이라, **한 논리적 호출(재시도 N번 포함)이 CB 에는 단 1건으로 기록된다.**
- 일시 실패가 재시도로 복구되면 → CB 는 그 흔들림을 *안 보고* 성공 1건만 기록(블립 흡수).
- 재시도까지 다 실패하면 → CB 에 실패 1건.
- 즉 **재시도 각각이 따로 카운트되지 않는다.**
트레이드오프:
- **CB-바깥(현재 코드):** CB 가 "이 논리적 호출이 최종 실패했나"만 본다. 재시도로 흡수된 일시 장애가 윈도우를 오염시키지 않음(장점). 대신 시도별 실패 *빈도*는 CB 가 못 봄.
- **CB-안쪽(반대 배치):** 시도마다 CB 에 기록 → 한 번 실패한 호출이 윈도우를 재시도 횟수만큼 부풀림 + CB 가 OPEN 되면 남은 재시도가 `CallNotPermitted` 로 즉시 끊김.
> 🐞 **반드시 알아야 할 코드 모순(내 코드의 결함):** 실제 코드 주석(`OutboundHttpClient.java:212-216`)은 "Retry is OUTSIDE the CB so each retry attempt is independently CB-counted"(재시도가 따로 카운트됨)라고 적었지만, 바로 아래 `:225-231` 의 데코 순서는 **CB 를 바깥**에 둔다 → 주석의 주장과 정반대로 동작한다(재시도는 1건으로 묶임). 이 문서의 *이전 버전도 그 틀린 주석을 베껴* "재시도가 따로 잡힌다"고 잘못 썼었다. **➡️ 코드 소유 브랜치(`feature-outbound-http-client-baseline`)에서 주석을 고치거나, 의도가 "시도별 집계"였다면 데코 순서를 바꿔야 한다.** (면접에서 "CB 바깥이라 재시도가 따로 잡힌다"고 말하면 Resilience4j 아는 면접관이 바로 반박한다 — 1순위 위험.)
<details><summary>✅ 이해 점검 (E6)</summary>
같은 호출이 3번 재시도 끝에 실패했다. CB 슬라이딩 윈도우엔 실패가 몇 건 기록되나? (정답: 1건 — CB 가 바깥이라.)
</details>
---
## §13. [심화] 배선 & Spring 메커니즘 — "그게 어떻게 가능한가"
**① `@ConfigurationProperties` + `@ConstructorBinding`** (`OutboundHttpSettings.java:36, 60`)
```java
@ConfigurationProperties(prefix = "app.outbound.http") // 이 prefix 설정만 모음
public record OutboundHttpSettings(
@ConstructorBinding // setter 없이 "생성자로만" 주입
Duration connectTimeout, Duration readTimeout, Duration globalCallTimeout, ...) {
public OutboundHttpSettings { // compact 생성자 = 값이 들어오는 길목에서 검증
if (connectTimeout == null || connectTimeout.isZero() || connectTimeout.isNegative())
throw new IllegalArgumentException("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT ... (D5)");
}
}
```
어떻게 가능한가: ① Spring Boot 의 **`Binder`** 가 `Environment`(env+yaml+프로퍼티)에서 prefix 키를 긁고 → ② **느슨한 바인딩**(`connect-timeout` ≡ `APP_OUTBOUND_HTTP_CONNECT_TIMEOUT` ≡ `connectTimeout`) → ③ 타입 변환(`"30s"`→`Duration`, `"10MB"`→`DataSize`) → ④ `@ConstructorBinding` 이라 생성자로만 주입 → 불변 → ⑤ compact 생성자 검증에서 `throw` 하면 빈 생성 실패 → `BeanCreationException` → **앱이 아예 안 뜸**(런타임 아님). 핵심: Binder 가 리플렉션으로 record 파라미터↔키를 자동 매칭하므로 내가 파싱 코드를 안 짠다.
**② `BeanPostProcessor` 타임아웃 강제기** (`OutboundHttpTimeoutEnforcer.java:39`) — 모든 빈 생성 직후 끼어드는 콜백. raw `RestClient`/`Builder` 빈을 발견하면 `BeanCreationException` 으로 기동 차단(타임아웃 없는 클라 봉쇄). `static @Bean` 인 이유: 다른 빈보다 먼저 만들어져야 검사 가능. **잔여 위험:** 메서드 *본문 안*의 인라인 `RestClient.create()` 는 빈이 아니라 못 잡는다 → 코드리뷰/import 게이트가 그 방어선. (그래서 §1 의 "원천 차단"은 정확히는 *빈으로 등록된* raw 클라 차단.)
**③ 타임아웃 2원화** (`OutboundHttpClient.java:95-99`) — connect timeout 은 JDK `HttpClient` 가, read timeout 은 `JdkClientHttpRequestFactory` 가 맡는다. *왜 두 군데?* JDK `HttpClient.Builder` 엔 connectTimeout 만 있고 *per-request read timeout API 가 없어서*, Spring factory 가 그 공백을 메운다(라이브러리 API 한계).
**④ RestClient 의 객체↔JSON 은 Jackson "만"이 아니다** — `.body(obj)` / `.body(responseType)` 는 RestClient 의 **`HttpMessageConverter` 체인**을 돌며 타입 + `Content-Type` 협상으로 컨버터를 고른다. JSON 이면 `MappingJackson2HttpMessageConverter` 가 담당할 뿐, XML/폼/String 도 같은 메커니즘. "RestClient=무조건 Jackson"으로 일반화하면 안 된다.
**⑤ Resilience4j `decorateSupplier`** — `Supplier` 를 감싸 능력 부여(§12). 의존성 이름별 인스턴스를 Registry 가 캐시.
**⑥ Micrometer `MeterFilter`** (`OutboundHttpResilienceConfig.java`) — 지표 등록 *전*에 끼어들어 핵심 3종만 남기고 `DENY` + 태그 정규화(§16 "카디널리티").
---
## §14. [심화] 자료구조 & 배선 함정
| 쓴 것 | 코드 위치 | 왜 (한 겹 더) |
|---|---|---|
| `AtomicBoolean` | `OutboundHttpShutdownGuard.java:28-29` | 종료 스레드의 write 를 요청 스레드가 *즉시* 보게(가시성). JMM 상 plain boolean 은 다른 스레드가 캐시된 옛 값을 영원히 볼 수 있다 → `AtomicBoolean` 은 내부가 `volatile`+CAS 라 **happens-before** 로 가시화. (여기선 CAS 안 쓰니 `volatile boolean` 으로도 충분 — 표현 명시성 때문에 Atomic 선택) |
| `ThreadLocal<CallContext>` | `OutboundRetryPolicy.java:59` | 호출이 한 스레드를 타고 가니 마감시한·메서드를 스레드별 격리. **누수 위험:** 톰캣 풀 스레드는 재사용되므로 `endCall()`(`remove()`)을 안 하면 다음 요청이 *이전 컨텍스트*를 봄(오판) + GC 안 됨 → `exchange()` `finally` 가 필수 |
| `Set.of(GET,HEAD,PUT,DELETE)` | `:52-53` | 불변 + O(1) 멱등 판정 |
| `int[] attemptCount = {0}` | `OutboundHttpClient.java:206` | 람다는 바깥 지역변수를 못 바꿈 → 1칸 배열의 *안*을 고침 |
| `record CallContext` | `:136` | per-call 불변 컨텍스트 |
| `Optional<Retry>/<CircuitBreaker>` | `:217-218` | "기능 off → 데코 없음"을 호출자가 반드시 처리하게 |
```mermaid
classDiagram
class OutboundHttpClient {
+baseline(...)$ OutboundHttpClient
+exchange(method, uri, body, type) T
+stream(method, uri, reader) T
}
class OutboundHttpShutdownGuard { +isShuttingDown() boolean }
class OutboundRetryPolicy { +beginCall() +shouldRetry() boolean +endCall() }
class OutboundHttpResilience { +circuitBreakerFor(name) Optional +retryFor(name) Optional }
class OutboundHttpErrorMapper { +classify(name, failure) DependencyFailureException }
class OutboundHttpSettings
class SmartLifecycle { <<interface>> }
OutboundHttpClient --> OutboundHttpSettings : 설정
OutboundHttpClient --> OutboundHttpShutdownGuard : 종료 검문
OutboundHttpClient --> OutboundRetryPolicy : beginCall/endCall
OutboundHttpClient --> OutboundHttpResilience : CB·Retry 공급
OutboundHttpClient --> OutboundHttpErrorMapper : 예외 번역
OutboundHttpResilience --> OutboundRetryPolicy : shouldRetry 를 재시도 조건으로
OutboundRetryPolicy --> OutboundHttpShutdownGuard : 종료 시 중단
OutboundHttpShutdownGuard ..|> SmartLifecycle : 구현
```
생성/배선: `OutboundHttpClientConfig` 가 협력 빈들을 `@Bean` 등록(단 `OutboundHttpClient` 자체는 의존성마다 `baseline(...)` 으로 직접 생성), `OutboundHttpResilienceConfig` 가 `OutboundHttpResilience` 빈 + MeterFilter.
> ⚠️ **함정:** `OutboundRetryPolicy` 를 `OutboundHttpResilience`(판정)와 `OutboundHttpClient`(`beginCall` 적재)가 *다른 객체*로 들면, 판정 측 `callContextHolder.get()` 이 항상 `null` → **영영 재시도 안 함**(관문2 탈락). 반드시 **같은 빈 공유**.
---
## §15. [참조] 설정값 레퍼런스 (전부 `OutboundHttpSettings.java`)
| 키 (`app.outbound.http.*`) | 기본값 | 효과 |
|---|---|---|
| `connect-timeout` / `read-timeout` / `global-call-timeout` | **없음(필수)** | TCP 연결 / 한 번 읽기 / 재시도 포함 전체. 누락 시 기동 실패 |
| `retry-enabled` / `circuit-breaker-enabled` | `false` / `false` | 재시도 / 서킷 활성. 하나라도 켜면 `MeterRegistry` 필수 |
| `response-size-limit` | `10MB` | buffered 응답 메모리 상한 |
| `retry.max-attempts` / `initial-backoff` / `backoff-multiplier` | `3` / `100ms` / `2.0` | 시도 횟수 / 첫 대기 / 지수 배수 |
| `circuit-breaker.failure-rate-threshold` | `50%` | 이 실패율 넘으면 OPEN |
| `…sliding-window-size` / `…minimum-number-of-calls` | `100` / `100` | 표본 범위 / 계산 최소 건수 |
| `…wait-duration-in-open-state` / `…permitted-calls-in-half-open` | `60s` / `10` | OPEN 유지 / HALF_OPEN 시험 호출 수 |
---
## §16. [참조] 용어집 — 치트시트 (복습용)
> 학습용이 아니라 *남 앞에서 설명하기 직전* 빠르게 훑는 카드. 각 항목: 정의 → 한 줄로 말하면.
- **Port & Adapter** — application 이 선언한 인터페이스(Port)를 어댑터가 실제 기술로 구현. → *"핵심 로직은 인터페이스에만 의존하고 외부 기술은 어댑터가 갈아끼웁니다."*
- **직렬화/역직렬화** — 객체 ↔ JSON. RestClient 가 메시지 컨버터로 처리. → *"객체를 넣으면 JSON 으로 바꿔 보내고 응답 JSON 을 객체로 돌려줍니다."*
- **타임아웃 3종** — connect/read/global. → *"어디서 멈춰도 스레드가 안 묶이게 셋 다 끊습니다."*
- **데드라인 예산** — 재시도 누적 시간의 절대 마감. *재시도 차단선*이지 하드컷 아님(§7). → *"재시도가 전체 마감을 못 넘게 하는 예산입니다."*
- **멱등(idempotent)** — 여러 번 = 한 번(GET/PUT/DELETE). POST 는 비멱등. → *"멱등 메서드만 재시도해 이중 결제를 막습니다."*
- **재시도/백오프/지터** — 다시 시도 / 대기 점증 / 무작위 섞기. → *"지수 백오프에 지터를 섞어 재시도 쏠림(thundering herd)을 막습니다."*
- **서킷 브레이커 / CLOSED·OPEN·HALF_OPEN / 슬라이딩 윈도우** — 실패 잦은 의존성을 잠시 끊는 두꺼비집. → *"세 상태 FSM 으로 아픈 서버를 잠시 끊어 내 스레드·상대를 보호(가용성 목적 아님)."*
- **fail-fast / fail-open / fail-closed** — 즉시 실패 / 삼키고 통과 / 막고 멈춤. → *"의존성 성격에 따라 정합성↔가용성 중 무엇을 지킬지 고릅니다."*
- **`@ConfigurationProperties`+`@ConstructorBinding`** — 설정→불변 record + 생성자 검증 → *"값이 틀리면 앱이 아예 안 뜨게 합니다."*
- **`SmartLifecycle`/phase** — 순서 보장 시작/종료. → *"가드를 phase 최대로 둬 가장 먼저 멈춰 신규 호출을 선차단합니다."*
- **`BeanPostProcessor`** — 빈 생성 직후 후크. raw 클라 적발에 사용.
- **데코레이터/`decorateSupplier`** — 함수를 감싸 능력 추가. CB 는 retry 의 *바깥*(§12).
- **`ThreadLocal`** — 스레드 전용 칸. 풀 스레드면 `remove()` 안 하면 누수.
- **`AtomicBoolean` / 가시성** — 스레드 간 즉시 보이는 boolean(volatile+CAS).
- **MDC / traceparent / baggage / allowlist** — 추적 메모장 / W3C 추적 헤더 / 따라다니는 키값 / 허용 목록.
- **시계열 DB / 카디널리티** — 시각별 측정값 수열 저장소 / 라벨 조합 가짓수. 무한값 라벨은 폭발 → 핵심 지표만(저카디널리티).
- **cause chain** — `getCause()` 로 줄줄이 연결된 원인. → *"래퍼에 가려진 진짜 원인을 따라가며 분류합니다."*
---
## §17. 캐시 모듈 — Fail-Open 데코레이터 + 라우터 [신입 필수]
> §2 축의 *가용성 끝*. "캐시가 죽어도 본점은 정상 영업." HTTP 와 달리 캐시는 장애를 **삼켜서 미스인 척** 한다. 근거: [[wiki/concepts/fail-open-fail-closed.md]]
**한 줄 그림:** `CacheStore`(Port: get/put) ← `CacheBackend`(+backendId) ← {`RedisCacheStore`→`RedisClient`(프로젝트 구현), `FailOpenCacheStore`(데코레이터)}. `CacheStoreRouter` 가 logicalName→backendId→backend 로 라우팅.
> ⚠️ **HTTP 와 타입이 다르다:** 캐시 값은 전부 `String`. `get(key)` → `Optional<String>`, `put(key, value)`. **`Class<T>`·TTL·직렬화가 이 모듈엔 없다**(있다면 프로젝트의 `RedisClient` 구현 쪽). 흔한 오해: `get(key, Class)` 형태가 *아니다*.
**① Fail-Open 데코레이터 — 장애를 미스로 바꾸는 곳:**
```java
// 📄 cache/FailOpenCacheStore.java:42-64 — 모든 백엔드를 감싸는 데코레이터
@Override public Optional<String> get(String key) {
try {
Optional<String> value = delegate.get(key);
dependencyLogger.logSuccess(delegate.backendId(), "cache", "get");
return value;
} catch (Exception ex) { // ← 백엔드가 던지는 모든 예외를 잡아
dependencyLogger.logFailure(delegate.backendId(), "cache", "get", ex); // WARN(+correlation_id)
return Optional.empty(); // ← 미스인 척 → 호출자는 DB 로 fallback
}
}
@Override public void put(String key, String value) {
try { delegate.put(key, value); /* logSuccess */ }
catch (Exception ex) { dependencyLogger.logFailure(...); } // ← put 실패는 조용히 삼킴(no-op)
}
```
→ Redis 가 죽어도 컨트롤러는 **예외를 안 받는다.** `get` 은 빈 Optional(미스), `put` 은 무시. 실패는 WARN 로그로만 *관측*된다(5xx 아님). 단 대량 동시 미스 → DB 쏠림(캐시 스탬피드) 위험은 서킷 병행으로 보완.
**② 백엔드는 안 삼킨다 — "장애"를 "미스"로 오인하지 않게:**
```java
// 📄 cache/redis/RedisCacheStore.java:34-40 — 얇은 Redis 바인딩
@Override public Optional<String> get(String key) {
try { return client.read(key); } // RedisClient = 프로젝트가 구현하는 seam
catch (Exception ex) { throw new CacheBackendException(BACKEND_ID, ex); } // 감싸서 *전파*
}
```
`CacheBackendException` 메시지 = `"cache backend 'redis' access failed"`. **백엔드는 전파, 데코레이터(①)는 삼킴** — 이 2단 분리 덕에 "진짜 장애"와 "그냥 미스(키 없음)"가 안 섞인다.
**③ 라우터 = 설정 오류엔 fail-fast (fail-open 과 정반대 층):**
```java
// 📄 cache/CacheStoreRouter.java:77-87 — 바인딩 안 된 logical 이름 접근
private CacheStore resolve(String logicalName) {
String backendId = bindings.get(logicalName);
if (backendId == null)
throw new AdapterDisabledException("cache",
"no cache backend bound for logical cache '" + logicalName + "' — set app.cache.bindings...");
return backends.get(backendId);
}
```
생성 시엔 **중복 backendId / 없는 backend 바인딩 → `IllegalStateException`**(기동 차단). 즉 *런타임 장애*는 fail-open(①), *설정 실수*는 fail-fast(③) — 같은 모듈 안 두 정책.
**설정값과 역할:**
| 설정 | 역할 | 기본 |
|---|---|---|
| `app.cache.redis.enabled` | Redis 백엔드 빈 등록 여부(`@ConditionalOnProperty`) | `false` |
| `app.cache.bindings.<논리명>=<backendId>` | 논리 캐시명 → 실제 백엔드 매핑 | 빈 맵 |
<details><summary>✅ 이해 점검</summary>
1. Redis 가 완전히 죽었다. `Router.get("worklog","k")` 의 반환과 컨트롤러가 받는 예외는? (Optional.empty / 예외 없음 → DB fallback)
2. `RedisCacheStore` 는 왜 예외를 안 삼키고 `CacheBackendException` 으로 던지나? (장애를 "미스"로 오인 못 하게 — 삼킴은 데코레이터 책임)
3. `app.cache.bindings.worklog=redis` 인데 `redis.enabled=false` 면? (기동 시 IllegalStateException — fail-fast)
</details>
---
## §18. 메시징·아웃박스 모듈 — Fail-Open vs Fail-Closed (한 줄 차이) [신입 필수]
> §2 축의 *양쪽을 한 모듈에서 동시에* 보여주는 곳. 같은 Kafka 인데 **실시간 발행은 fail-open, 백그라운드 릴레이는 fail-closed**. 차이는 catch 블록이 `throw` 로 끝나느냐뿐. 근거: [[wiki/concepts/outbox-pattern.md]]
**① 실시간 발행 — Fail-Open (삼킴):**
```java
// 📄 messaging/kafka/KafkaMessagePublisher.java:39-48
@Override public void publish(OutboundMessage message) {
try { sender.send(message); dependencyLogger.logSuccess("kafka","messaging","publish"); }
catch (Exception ex) {
dependencyLogger.logFailure("kafka","messaging","publish", ex); // 로깅만
// ← throw 없음. 브로커가 죽어도 사용자 API 는 200.
}
}
```
왜 삼켜도 되나? 이미 같은 트랜잭션에서 **Outbox 테이블에 메시지가 영속화**됐기 때문(전달은 릴레이가 책임). 브로커 장애가 사용자 응답을 5xx 로 만들지 않는다.
**② 백그라운드 릴레이 — Fail-Closed (전파):**
```java
// 📄 messaging/outbox/KafkaOutboxMessagePublishAdapter.java:56-71
@Override public void publish(OutboxEvent event) {
String envelope = OutboxEnvelopeJson.toJson(event);
OutboundMessage message = new OutboundMessage(event.eventType(), event.aggregateId(), envelope);
try { sender.send(message); dependencyLogger.logSuccess(...); }
catch (RuntimeException ex) { dependencyLogger.logFailure(...); throw ex; } // ← 그대로 던짐
catch (Exception ex) { dependencyLogger.logFailure(...);
throw new RuntimeException("Kafka outbox publish failed", ex); } // checked 는 감싸 던짐
}
```
왜 던져야 하나? 릴레이가 예외를 **봐야** 그 Outbox 레코드를 `FAILED`/`DEAD` 로 전이하고 트랜잭션을 롤백해 *재시도 루프*에 남긴다. 삼키면 레코드가 `IN_FLIGHT` 로 영영 박혀 큐가 조용히 막힌다(지표 이상도 없음).
> 🎯 **단 한 줄의 차이:** 둘 다 `logFailure` 를 부른다. 실시간(①)의 catch 는 *그냥 끝*나고, 릴레이(②)의 catch 는 *`throw` 로 끝*난다. 이게 fail-open ↔ fail-closed 의 전부다.
**③ 봉투 직렬화 — Jackson 없이 손으로:**
```java
// 📄 messaging/outbox/OutboxEnvelopeJson.java:49-58 — D12 wire format
public static String toJson(OutboxEvent event) {
return "{"
+ "\"eventId\":\"" + escape(event.eventId()) + "\","
+ /* eventType, aggregateId, occurredAt, correlationId, idempotencyKey — 모두 escape */
+ "\"payload\":" + event.payload() // ← payload 는 *이미 JSON* 이라 escape 없이 raw 삽입
+ "}";
}
```
`payload` 는 이미 직렬화된 JSON 이라 그대로(이중 인코딩 방지), 나머지 문자열은 `escape()`(RFC 8259 제어문자). 스켈레톤은 `jackson-databind` 를 안 싣는다.
**④ on/off 게이팅 — 같은 플래그가 real ↔ Disabled 빈을 교체:**
```java
// 📄 messaging/kafka/KafkaAdapterConfig.java:30-41 — @ConditionalOnProperty 한 쌍
@Bean @ConditionalOnProperty(name="app.messaging.kafka.enabled", havingValue="true", matchIfMissing=false)
public MessagePublisher kafkaMessagePublisher(...) { return new KafkaMessagePublisher(...); }
@Bean @ConditionalOnProperty(name="app.messaging.kafka.enabled", havingValue="false", matchIfMissing=true)
public MessagePublisher disabledMessagePublisher() { return new DisabledMessagePublisher(); }
```
플래그 하나로 *정확히 하나*의 빈만 등록된다. 꺼지면(기본) `DisabledMessagePublisher` 가 올라가, 누가 실수로 호출하면 `AdapterDisabledException("kafka")` 를 던진다(Layer 3 — Layer 1 게이팅이 뚫렸을 때의 최후 방어선).
**설정값:** `app.messaging.kafka.enabled`(false) — 실시간/릴레이 두 포트를 *한 플래그*로 동시 제어. `app.messaging.kafka.brokers`(켜면 CSV `host:port` 필수, regex 검증).
> 🧑‍🏫 **한마디:** "같은 Kafka 인데 왜 한쪽은 삼키고 한쪽은 던지나?"는 단골 질문. 답: **누가 그 실패를 책임지느냐**. 실시간은 Outbox 가 책임지니 삼켜도 되고, 릴레이는 *자기가* 마지막 책임자라 던져 재시도/경보로 이어가야 한다.
<details><summary>✅ 이해 점검</summary>
1. 브로커 순단 시 두 발행자의 동작 차이를 *코드 한 줄*로? (catch 가 `throw` 로 끝나는가)
2. `app.messaging.kafka.enabled` 미설정 시 어떤 빈이 등록되고 호출하면? (Disabled* → AdapterDisabledException)
3. `OutboxEnvelopeJson` 이 `payload` 만 escape 안 하는 이유? (이미 JSON → 이중 인코딩 방지)
</details>
---
## §19. [심화] 더 깊이 — 이 코드 *밖*의 5가지 (면접 천장 뚫기)
> 여기부터는 ca-tmpl 에 **구현되어 있지 않은** 주제다(skeleton 범위 밖). 면접에서 "그 다음은?"으로 꼬리를 물 때 막히지 않도록 *왜 이 코드엔 없고, 있으면 어떻게 되는지*만 정리한다. (canonical 프로젝트 사실 아님 — 일반 지식 + 이 설계와의 연결.)
**1. Bulkhead(동시성 격리) — 지금 빠진 가장 큰 구멍.** 서킷·타임아웃은 있지만 *동시 호출 수 제한*이 없다. 동기 RestClient 는 호출당 스레드를 점유하므로, 한 의존성이 느려지면 서킷이 *열리기 전까지* 호출 스레드가 무더기로 묶인다. Resilience4j `Bulkhead`(세마포어/스레드풀)로 "이 의존성엔 동시 N개까지"를 막아야 완전하다. → *"타임아웃+서킷은 '오래 걸리는 것'을, Bulkhead 는 '한꺼번에 많은 것'을 막습니다."*
**2. Idempotency-Key 프로토콜 — POST 재시도의 진짜 해법.** 지금은 "POST 재시도 전면 금지"로 *회피*한다(§7 관문2). 진짜는: 클라이언트가 요청마다 고유 키(UUID)를 만들어 *재전송 시 동일 키 유지* → 서버가 그 키로 중복을 제거. 그러면 POST 도 안전하게 재시도 가능. → *"멱등 키 계약이 서면 비멱등 메서드도 재시도할 수 있습니다. 지금은 그 계약이 없어 보수적으로 막은 겁니다."*
**3. 재시도 예산(retry budget) — retry storm 방지.** per-call deadline(§7)은 *한 요청*의 재시도만 제한한다. *서비스 전역*으로 "전체 요청의 N%만 재시도 허용"하는 상한(Google SRE retry budget)은 없다. 장애 시 모두가 재시도하면 트래픽이 증폭돼 상대를 더 무너뜨린다(retry storm). → *"deadline 은 한 건을, retry budget 은 전체를 지킵니다."*
**4. 분산추적 샘플링 — traceparent `00` 의 실체.** §11 에서 샘플링 비트가 `00`(not-sampled) 하드코딩이라 했다. 실무는 head-based(시작 시 결정) vs tail-based(끝나고 느린 것만) 샘플링 + 부모 결정 전파(ParentBased)가 일관돼야 한다. 지금은 그게 없어 *수집이 안 된다.* OpenTelemetry SDK 로 교체 필요. → *"추적 헤더는 붙지만 샘플링 결정이 죽어 있어, 실제 백엔드 연동 전엔 트레이스가 안 모입니다."*
**5. 커넥션 풀 / HTTP/2 멀티플렉싱.** JDK `HttpClient` 의 connection pool·executor 를 *명시 설정하지 않아* 기본값에 의존한다(skeleton 한계). HTTP/2 면 한 커넥션에 다중 스트림이 흐르는데, 이때 head-of-line blocking 과 read-timeout 의 상호작용이 미묘하다. 동시성 상한은 결국 timeout+서킷으로 *간접* 보호될 뿐 명시적 풀 튜닝은 없다. → *"커넥션 재사용·풀 사이즈는 아직 기본값이라 고부하에선 별도 튜닝이 필요합니다."*
> 🧑‍🏫 **한마디:** 1~3 은 "구현된 것의 *다음 단계*", 4~5 는 "스켈레톤이라 *기본값에 맡긴* 부분". 면접에서 이걸 *먼저* "여기까진 했고, 다음은 Bulkhead/멱등키/재시도예산입니다"로 말하면 천장이 아니라 로드맵이 된다.
---
## 그래서 어떤 문제로 "정의" 했나
`ca-tmpl` 은 연동 문제를 **"외부 시스템의 장애·지연이 우리 서버의 스레드 잠식이나 데이터 정합성 훼손으로 전이되지 않도록 강력한 완충 경계(Isolation Boundary)를 강제"** 로 정의했다. 그래서:
- **물리 리소스 제약:** `bufferedClient`/`streamingClient` 격리 + `ResponseSizeBoundingInterceptor` 로 힙 통제.
- **시간 예산:** connect/read 외에 deadline 예산으로 재시도 무한 대기 차단.
- **Optional 빈 게이팅 & 센티넬:** 비활성 의존성 호출 시 `AdapterDisabledException` 으로 기동 거부(Layer 3).
### 실제 구현·검증 범위 (`locally-verified`)
`adapter-outbound` 모듈에 구현되어 있고 단위 테스트로 검증됨(prod 배포·측정 없음): `OutboundHttpClientTest`(재시도/CB/셧다운/사이즈), `OutboundHttpErrorMapperTest`(예외 매핑), `OutboundHttpResilienceTest/ConfigTest`, `OutboundRetryPolicyTest`, `OutboundHttpShutdownGuardTest`, `OutboundHttpSettingsTest`, `TraceContextPropagationInterceptorTest` 등.
(상세는 canonical [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md]] §Outbound HTTP Client.)
> [!WARNING]
> traceparent 샘플링 비트 `00` 하드코딩(§11). 실제 분산추적은 OpenTelemetry/Micrometer Tracing 으로 교체 필요.
> §12 의 CB 데코 주석 모순은 *코드* 결함 — 소유 브랜치에서 정정 필요.
---
## 자가 점검 — 다시 처음 장면으로
1. **[멱등성]** `Idempotency-Key` 명세가 없는 `POST /payments` 에 재시도를 켜두면, 어느 안전장치(어느 관문)가 거부하나? (§7 관문2)
2. **[캐시 Fail-Open]** Redis 완전 다운 시 홈 API 호출 → `FailOpenCacheStore` 내부에서 무슨 일이? 컨트롤러가 받는 최종 예외는? (예외 없음 → DB fallback, §17)
3. **[우아한 종료]** SIGTERM 시 `SmartLifecycle` 대신 `ContextClosedEvent` 로 깃발을 세우면 어떤 비결정 순서 오류가? (§6)
4. **[아웃박스]** 브로커 순단 시 `KafkaMessagePublisher`(실시간) vs `KafkaOutboxMessagePublishAdapter`(릴레이) 가 각각 왜 삼킴/전파를 택하나? (§18)
5. **[대용량]** 100MB CSV 를 `get(uri, Class)` 로 받으면 무슨 에러? 우회 API 는? (size 예외 → `stream()`, §9)
6. **[서킷-재시도]** 한 호출이 3번 재시도 끝에 실패했다. CB 윈도우엔 실패 몇 건? (1건 — §12)
---
## Sources (이 설명의 출처 — 모두 canonical)
- [[wiki/concepts/fail-open-fail-closed.md]] — 실패 처리 설계 철학 및 트레이드오프
- [[wiki/concepts/idempotency.md]] — RFC 9110 HTTP 멱등성 및 재시도 게이트
- [[wiki/concepts/circuit-breaker.md]] — 서킷 브레이커 FSM 상태 전이 및 저카디널리티 지표 필터링
- [[wiki/concepts/outbox-pattern.md]] — 트랜잭셔널 아웃복스 및 릴레이의 정합성 보장
- [[wiki/concepts/distributed-tracing-baggage.md]] — MDC 트레이싱 전파와 배기지 보안 필터
- [[wiki/concepts/spring-smart-lifecycle.md]] — SmartLifecycle 을 통한 Graceful Shutdown
- [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md]] — **§Outbound HTTP Client: 코드 사실 SSOT** (입출력·예외 매핑·자료구조·Spring 메커니즘·설정·검증, `locally-verified`)
- [[wiki/projects/ca-tmpl/config-and-adapter-templates.md]] — adapter on/off 게이팅 결정