fix: 하네스 제거 및 keycloak 문서 보강

This commit is contained in:
DongHyeonka
2026-07-25 12:53:13 +09:00
parent 6c53ded9cb
commit d71669eb59
2329 changed files with 138239 additions and 172816 deletions
-1
View File
@@ -1 +0,0 @@
../../vault/30-knowledge/explainer/adapter-identifier.md
+18
View File
@@ -0,0 +1,18 @@
---
title: (강사 설명) adapter-identifier 모듈
source_type: explainer
status: raw
confidence: unknown
tags: [explainer, ca-tmpl, resource-identifier]
related_projects: [ca-tmpl]
last_reviewed:
---
# (강사 설명) adapter-identifier 모듈
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** 개인 이해용이며 외부 공개 대상이 아니다.
> 사실·근거·검증 등급은 여기서 만들지 않고 canonical 에서 가져온다.
**아직 작성되지 않은 스텁입니다.** `[[wiki/explainer/adapter-outbound]]` 와 같은 ca-tmpl 모듈별
설명 시리즈의 자리만 잡아둔 상태이며, 본문은 canonical(`[[wiki/projects/ca-tmpl/resource-identifier-format]]`)을
경유해 작성해야 합니다.
-1
View File
@@ -1 +0,0 @@
../../vault/30-knowledge/explainer/adapter-outbound.md
+923
View File
@@ -0,0 +1,923 @@
---
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 게이팅 결정
-1
View File
@@ -1 +0,0 @@
../../vault/30-knowledge/explainer/adapter-persistence.md
+18
View File
@@ -0,0 +1,18 @@
---
title: (강사 설명) adapter-persistence 모듈
source_type: explainer
status: raw
confidence: unknown
tags: [explainer, ca-tmpl, persistence]
related_projects: [ca-tmpl]
last_reviewed:
---
# (강사 설명) adapter-persistence 모듈
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** 개인 이해용이며 외부 공개 대상이 아니다.
> 사실·근거·검증 등급은 여기서 만들지 않고 canonical 에서 가져온다.
**아직 작성되지 않은 스텁입니다.** `[[wiki/explainer/adapter-outbound]]` 와 같은 ca-tmpl 모듈별
설명 시리즈의 자리만 잡아둔 상태이며, 본문은 canonical(`[[wiki/projects/ca-tmpl]]`)을 경유해
작성해야 합니다.
-1
View File
@@ -1 +0,0 @@
../../vault/30-knowledge/explainer/adapter-web.md
+18
View File
@@ -0,0 +1,18 @@
---
title: (강사 설명) adapter-web 모듈
source_type: explainer
status: raw
confidence: unknown
tags: [explainer, ca-tmpl, api-design]
related_projects: [ca-tmpl]
last_reviewed:
---
# (강사 설명) adapter-web 모듈
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** 개인 이해용이며 외부 공개 대상이 아니다.
> 사실·근거·검증 등급은 여기서 만들지 않고 canonical 에서 가져온다.
**아직 작성되지 않은 스텁입니다.** `[[wiki/explainer/adapter-outbound]]` 와 같은 ca-tmpl 모듈별
설명 시리즈의 자리만 잡아둔 상태이며, 본문은 canonical(`[[wiki/projects/ca-tmpl]]`)을 경유해
작성해야 합니다.
-1
View File
@@ -1 +0,0 @@
../../vault/30-knowledge/explainer/application-core.md
+18
View File
@@ -0,0 +1,18 @@
---
title: (강사 설명) application-core 모듈
source_type: explainer
status: raw
confidence: unknown
tags: [explainer, ca-tmpl, application]
related_projects: [ca-tmpl]
last_reviewed:
---
# (강사 설명) application-core 모듈
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** 개인 이해용이며 외부 공개 대상이 아니다.
> 사실·근거·검증 등급은 여기서 만들지 않고 canonical 에서 가져온다.
**아직 작성되지 않은 스텁입니다.** `[[wiki/explainer/adapter-outbound]]` 와 같은 ca-tmpl 모듈별
설명 시리즈의 자리만 잡아둔 상태이며, 본문은 canonical(`[[wiki/projects/ca-tmpl]]`)을 경유해
작성해야 합니다.
-1
View File
@@ -1 +0,0 @@
../../vault/30-knowledge/explainer/domain-core.md
+18
View File
@@ -0,0 +1,18 @@
---
title: (강사 설명) domain-core 모듈
source_type: explainer
status: raw
confidence: unknown
tags: [explainer, ca-tmpl, architecture]
related_projects: [ca-tmpl]
last_reviewed:
---
# (강사 설명) domain-core 모듈
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** 개인 이해용이며 외부 공개 대상이 아니다.
> 사실·근거·검증 등급은 여기서 만들지 않고 canonical 에서 가져온다.
**아직 작성되지 않은 스텁입니다.** `[[wiki/explainer/adapter-outbound]]` 와 같은 ca-tmpl 모듈별
설명 시리즈의 자리만 잡아둔 상태이며, 본문은 canonical(`[[wiki/projects/ca-tmpl]]`)을 경유해
작성해야 합니다.
@@ -1 +0,0 @@
../../../vault/30-knowledge/explainer/images/outbound-adapter-architecture.png

Before

Width:  |  Height:  |  Size: 78 B

After

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 B

After

Width:  |  Height:  |  Size: 672 KiB

@@ -1 +0,0 @@
../../../vault/30-knowledge/explainer/images/outbound-http-sequence.png

Before

Width:  |  Height:  |  Size: 71 B

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 B

After

Width:  |  Height:  |  Size: 606 KiB

-1
View File
@@ -1 +0,0 @@
../../vault/30-knowledge/explainer/shared-contract.md
+18
View File
@@ -0,0 +1,18 @@
---
title: (강사 설명) shared-contract 모듈
source_type: explainer
status: raw
confidence: unknown
tags: [explainer, ca-tmpl, architecture]
related_projects: [ca-tmpl]
last_reviewed:
---
# (강사 설명) shared-contract 모듈
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** 개인 이해용이며 외부 공개 대상이 아니다.
> 사실·근거·검증 등급은 여기서 만들지 않고 canonical 에서 가져온다.
**아직 작성되지 않은 스텁입니다.** `[[wiki/explainer/adapter-outbound]]` 와 같은 ca-tmpl 모듈별
설명 시리즈의 자리만 잡아둔 상태이며, 본문은 canonical(`[[wiki/projects/ca-tmpl]]`)을 경유해
작성해야 합니다.
@@ -1 +0,0 @@
../../vault/30-knowledge/explainer/transaction-boundary-abstraction.md
@@ -0,0 +1,220 @@
---
title: (강사 설명) 트랜잭션 경계를 어디에 둘 것인가 — 5가지 답이 갈리는 진짜 이유
source_type: explainer
status: draft
confidence: medium
tags: [transaction, clean-architecture, spring]
related_projects: [ca-skeleton]
last_reviewed: 2026-06-04
---
# (강사 설명) 트랜잭션 경계를 어디에 둘 것인가 — 5가지 답이 갈리는 진짜 이유
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** "나의 진짜 이해" 를 위한 1타강사 칠판이다.
> 정확한 사실·근거·검증 등급은 여기서 만들지 않는다. 전부 아래 canonical 에서 가져온다:
> - 개념·대안·근거: [[wiki/concepts/transaction-boundary-abstraction]]
> - 내 프로젝트 실제 구현·검증 범위: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
>
> 이 문서의 **비유는 의도적으로 부정확**하다 (이해를 위한 단순화). 비유를 사실로 인용하지 마라. 면접에서 말할 땐 위 canonical 의 표현을 써라.
---
## 0. 한 장면 — 5초 만에 고통 느끼기
`PostService.createPost()` 안에서 DB 에 두 번 쓴다.
```text
1) posts 테이블에 글 한 줄 INSERT ← 성공
2) tags 테이블에 태그 세 줄 INSERT ← 여기서 예외 펑!
```
자, 1번은 이미 커밋됐고 2번은 터졌다. 결과는? **태그 없는 반쪽짜리 글**이 DB 에 영원히 남는다. 누구도 지워주지 않는다.
이걸 막는 게 트랜잭션이다. "1번과 2번은 **한 묶음**. 둘 다 되든가, 둘 다 없던 일이 되든가." 은행 송금이랑 똑같다 — 내 계좌 -1만 원, 상대 계좌 +1만 원, 중간에 멈추면 돈이 증발한다. 그래서 "다 되거나 다 취소(rollback)" 로 묶는다.
**여기까진 아무도 이견이 없다.** 진짜 싸움은 다음 한 줄에서 시작된다:
> "그래서 이 '한 묶음' 의 시작과 끝을, **코드 어디에, 누가, 어떻게** 선언하지?"
5가지 답이 있다. 그리고 답이 갈리는 이유는 — 곧 보겠지만 — 사람마다 **무엇이 문제인지 자체가 다르기 때문**이다.
---
## 1. 진짜 문제는 무엇인가 — 모든 대안이 싸우는 단 하나의 축
트랜잭션은 비즈니스 로직이 아니다. "글을 쓴다" 는 비즈니스고, "이걸 한 묶음으로 처리해라" 는 **인프라 관심사(infrastructure concern)** 다. DB 라는 기계를 다루는 기술적 약속이지, 도메인 규칙이 아니다.
그래서 모든 대안이 답하려는 질문은 결국 **하나의 축** 위에 있다:
> **인프라 관심사인 '트랜잭션 경계' 를, 비즈니스 핵심 코드에서 얼마나 떼어낼 것인가?**
```text
분리 0% ─────────────────────────────────────────────► 분리 100%
"핵심 코드에 그냥 붙여" "핵심 코드는 트랜잭션을 몰라야 해"
대안1 대안2 대안4 대안3 대안5
@Transactional Template Interceptor Functional TransactionPort
직접 부착 명령형 커스텀 AOP monad (port 추상화)
```
축의 **왼쪽 끝** 신념: "분리? 그거 다 오버엔지니어링이야. 트랜잭션 경계가 코드에 **눈으로 보이는 게** 제일 중요해."
축의 **오른쪽 끝** 신념: "비즈니스 핵심은 Spring 이든 뭐든 **프레임워크를 몰라야 해**. 그래야 갈아끼우고 테스트하기 좋아."
5개 대안은 이 축 위 서로 다른 지점에 점을 찍은 것뿐이다. **누가 맞고 틀린 게 아니라, 무엇을 더 두려워하는지가 다른 것이다.** 이제 한 명씩 그 사람 입장이 되어보자.
---
## 2. 대안들 = 문제를 "다르게 정의한" 답 ★이 문서의 심장★
각 대안을 똑같은 5단으로 본다:
**(a) 이 사람이 본 문제 → (b) 핵심 직관 → (c) 왜 이게 그 문제를 푸는가(끝까지) → (d) 언제 맞고 어디서 깨지나 → (e) 근거.**
---
### 대안 1. `@Transactional` 직접 부착 — "경계는 *눈에 보이는 곳*에 둬" (다수파)
**(a) 이 사람이 본 문제:**
"트랜잭션이 어디서 시작하고 끝나는지, 코드를 열었을 때 **그 자리에서 바로** 보여야 한다. 한 겹 추상화를 끼우면 그 가시성이 사라진다. 추상화는 비용이고, 나는 그 비용을 낼 이유가 없다."
**(b) 핵심 직관:**
메서드 위에 붙인 `@Transactional` 은 **형광펜**이다. "여기부터 여기까지 한 묶음" 이라고 코드에 직접 칠해두는 표시.
**(c) 왜 이게 문제를 푸는가 (끝까지):**
Spring 이 이 형광펜을 발견하면, 네 객체를 그대로 안 쓰고 **대역(proxy) 객체**를 하나 만든다. 대역은 네 메서드를 부르기 *직전*에 몰래 `BEGIN`(트랜잭션 시작) 을 끼우고, 무사히 끝나면 `COMMIT`, 예외가 터지면 `ROLLBACK` 을 대신 해준다. 그래서 너는 트랜잭션 코드를 **한 줄도 안 쓴다.** 형광펜만 칠하면 끝.
**그런데** 이 마법은 "대역을 거쳐야만" 작동한다. 만약 같은 클래스 안에서 `this.otherMethod()` 처럼 내 메서드를 *직접* 부르면? 대역을 안 거치고 진짜 객체를 바로 부른다 → 형광펜이 **그냥 무시된다(self-invocation 함정).** 분명 `@Transactional` 을 붙였는데 트랜잭션이 안 걸리는 미스터리가 여기서 나온다.
> **강사의 한마디:** 이 함정은 "치명적 결함" 이 아니라 "알면 피하는 함정" 이다. self-injection, public 메서드 분리, 별도 bean 으로 빼기 — 표준 우회가 여러 개 있고 수많은 프로덕션이 이걸로 잘 돌아간다. "AOP 는 self-invocation 때문에 깨진다" 고 *단정하면 과장*이다.
**(d) 언제 맞나 / 어디서 깨지나:**
- ✅ 맞다: 단순 CRUD 위주, 프레임워크 바꿀 계획 없음, 팀이 Spring 에 익숙. → 형광펜의 가시성이 추상화 비용보다 명백히 이득.
- ❌ 깨진다: "비즈니스 핵심 코드는 프레임워크를 import 하면 안 된다" 는 규칙(Clean Architecture)을 세운 순간. 형광펜을 칠하려면 `org.springframework...Transactional` 을 import 해야 하는데, **그 import 자체가 규칙 위반**이 된다. (대안 5 의 출발점이 바로 여기다.)
**(e) 근거:** [[wiki/concepts/transaction-boundary-abstraction]] 대안 1 · self-invocation = claim `AT-TX-C5`. 다수파라는 증거(Buckpal·Reflectoring) 도 같은 문서 Claim-backed 표 참조.
---
### 대안 2. `TransactionTemplate` 명령형 — "마법 말고 *내 손으로* 묶을게"
**(a) 이 사람이 본 문제:**
"형광펜(annotation)은 *선언*일 뿐, 실제 실행은 보이지 않는 대역(proxy)이 한다. 그 보이지 않는 마법과 self-invocation 함정이 싫다. 트랜잭션 시작·끝을 **내가 쓴 코드로 명시적으로** 보고 싶다."
**(b) 핵심 직관:**
형광펜 대신 **직접 괄호를 친다.** `template.execute(status -> { ...여기 안이 한 묶음... })`. 묶음의 시작과 끝이 중괄호로 눈에 보인다.
**(c) 왜 이게 문제를 푸는가 (끝까지):**
`execute(...)` 를 부르는 순간 그 자리에서 진짜로 `BEGIN` 이 실행되고, 람다가 끝나면 `COMMIT`, 예외면 `ROLLBACK`. **프록시 대역이 없다.** 내가 직접 부른 메서드 안에서 시작하므로 self-invocation 함정도 원천적으로 없다. 경계가 "선언" 이 아니라 "실행되는 코드 한 줄" 이 됐다.
**그런데** 대가가 있다. 묶고 싶은 use case 마다 `template.execute(...)` 보일러플레이트를 반복해서 써야 한다. 그리고 결정적으로 — 이 `TransactionTemplate` 클래스 역시 `org.springframework...` 소속이다. 즉 **비즈니스 코드가 여전히 Spring 을 직접 안고 있다.** 가시성·함정 문제는 풀었지만 "프레임워크 분리" 축에서는 대안 1 과 같은 자리다.
**(d) 언제 맞나 / 어디서 깨지나:**
- ✅ 맞다: self-invocation 같은 AOP 함정을 확실히 피하고 싶고, 트랜잭션 경계를 코드로 또렷이 보고 싶을 때.
- ❌ 깨진다: 보일러플레이트가 늘어나는 게 싫을 때 / "프레임워크 import 금지" 규칙이 있을 때 (여전히 Spring 클래스 import).
**(e) 근거:** [[wiki/concepts/transaction-boundary-abstraction]] 대안 2 · Spring 공식 programmatic API.
---
### 대안 3. 함수형 Resource/monad (예: Arrow Kt) — "트랜잭션을 *값* 으로 만들어"
**(a) 이 사람이 본 문제:**
"트랜잭션은 '효과(effect)' 다. 효과를 숨겨진 마법(proxy)이나 명령형 괄호로 다루지 말고, **타입으로 드러내서 합성** 하고 싶다. 그래야 컴파일러가 검증해주고, 순수 함수처럼 테스트할 수 있다."
**(b) 핵심 직관:**
트랜잭션을 *행동* 이 아니라 **레시피(값)** 로 본다. "이 작업은 트랜잭션이 필요함" 이라는 사실이 **타입에 적혀** 따라다닌다. 레시피들을 레고처럼 합쳐서 마지막에 한 번 실행한다.
**(c) 왜 이게 문제를 푸는가 (끝까지):**
효과가 타입에 드러나면, "이 함수는 트랜잭션 안에서 돌아야 한다" 를 **컴파일 타임에** 강제할 수 있다 → 실행은 순수 함수 합성이라, Spring context 같은 무거운 환경 없이 검증 가능 → testability 가 최고로 올라간다.
**그런데** 이건 사고방식 자체가 다르다. Java 위주 Spring 팀에게 monad/패턴 매칭/함수 합성은 **학습 절벽**이다. 게다가 Spring 이 공짜로 주던 propagation·isolation 의미를 monad 위에 직접 다시 구현해야 할 때도 있다. 강력하지만 비싸다.
**(d) 언제 맞나 / 어디서 깨지나:**
- ✅ 맞다: 팀이 이미 함수형(Kotlin/Arrow 등)에 능하고, 효과를 타입으로 다루는 가치를 아는 경우.
- ❌ 깨진다: 평범한 Java/Spring 팀. 도입 비용이 이득을 압도한다. "함수형이 테스트에 항상 우월" 은 *과장* — 팀 역량/언어/기존 코드가 비용을 결정한다.
**(e) 근거:** [[wiki/concepts/transaction-boundary-abstraction]] 대안 3 (Arrow Kt Resource).
---
### 대안 4. 커스텀 `TransactionInterceptor` (AOP) — "마법은 좋아, 근데 *내 마법*으로"
**(a) 이 사람이 본 문제:**
"annotation 기반 마법(대안 1)의 편리함은 좋다. 하지만 표준 `@Transactional` 은 트랜잭션만 한다. 나는 트랜잭션 *경계에서* 추가 정책 — 예를 들어 권한(capability) 검증 — 을 같이 끼우고 싶다."
**(b) 핵심 직관:**
대안 1 의 형광펜을 **내가 직접 만든 형광펜**으로 바꾼다. 내 annotation, 내 interceptor → 묶음의 시작·끝에 내가 원하는 로직을 추가로 끼워넣는다.
**(c) 왜 이게 문제를 푸는가 (끝까지):**
내 interceptor 가 메서드 호출을 가로채니, `BEGIN`/`COMMIT` 사이에 커스텀 정책을 자유롭게 주입할 수 있다 → 트랜잭션 + 정책을 한 곳에서 다룬다.
**그런데** 이건 결국 대안 1 과 같은 AOP 기반이다. **self-invocation 함정 그대로 상속**한다. interceptor 구현 자체가 Spring AOP 에 의존하고, `@TransactionalEventListener` 같은 표준 도구와의 호환을 내가 직접 챙겨야 한다. "마법을 커스터마이즈" 한 대가로 표준이 주던 보장을 일부 떠안는다.
**(d) 언제 맞나 / 어디서 깨지나:**
- ✅ 맞다: 트랜잭션 경계에 *정말로* 횡단 정책을 묶어야 하는 특수 요구가 있을 때.
- ❌ 깨진다: 그냥 트랜잭션만 필요한데 이걸 쓰면 — AOP 함정 + 호환성 부담만 떠안는 오버엔지니어링.
**(e) 근거:** [[wiki/concepts/transaction-boundary-abstraction]] 대안 4 (custom interceptor 사례).
---
### 대안 5. `TransactionPort` 추상화 — "핵심 코드는 *트랜잭션이 뭔지도 몰라야 해*" (소수파, ca-tmpl 채택)
**(a) 이 사람이 본 문제:**
"내 비즈니스 핵심(application layer)은 **Spring 의 존재 자체를 몰라야 한다.** 그래야 (1) 프레임워크를 갈아끼워도 핵심이 안 흔들리고, (2) use case 를 Spring context 없이 가볍게 단위 테스트할 수 있다. 트랜잭션이라는 인프라 관심사도 예외 없이 이 규칙을 따라야 한다."
**(b) 핵심 직관:**
핵심 코드에는 **콘센트 구멍(interface)** 만 뚫어둔다 — `tx.inWrite(() -> { ... })`. 이 구멍은 "트랜잭션으로 묶어줘" 라고 *요청* 만 할 뿐, **어떻게** 묶는지는 모른다. 진짜 Spring 플러그(`SpringTransactionPort`)는 바깥 어댑터 계층에서 꽂는다. 핵심은 콘센트 규격만 알고, 전기 회사가 한전인지 아닌지는 모른다.
**(c) 왜 이게 문제를 푸는가 (끝까지):**
application 은 자기가 만든 `TransactionPort` 인터페이스만 import 한다 → `org.springframework...` 가 비즈니스 코드에서 **완전히 사라진다** → Clean Architecture 의 "의존성은 안쪽(핵심)으로만" 규칙을 트랜잭션 경계까지 지킨다 → 테스트에선 진짜 Spring 대신 **가짜(fake) port** 를 꽂아 "경계가 제대로 선언됐나" 를 Spring context 없이 검증한다.
**그런데** 공짜가 아니다. port 인터페이스 추가 + 어댑터 구현체 추가 + "propagation/isolation 을 port 시그니처에 어떻게 드러낼까" 라는 설계 결정 비용이 든다. 그리고 이건 **소수파**다 — 유명한 hexagonal 예제(Buckpal)나 Spring 공식 incubator(Modulith)조차 오히려 `@Transactional` 을 직접/메타로 부착한다. 즉 "추상화만이 정답" 이라고 말하면 *과장*이다.
**(d) 언제 맞나 / 어디서 깨지나:**
- ✅ 맞다: "핵심은 프레임워크를 모른다" 를 **진짜 규칙으로 강제** 하려는 프로젝트 (skeleton/템플릿처럼 규율이 자산인 경우). 도메인 복잡도가 크고 testability 가 중요할 때.
- ❌ 깨진다: 단순 CRUD 가 대부분이고 프레임워크 교체 계획도 없는데 이걸 쓰면 — 그냥 **오버엔지니어링.** 콘센트 한 겹이 가시성만 깎아먹는다.
**(e) 근거:** [[wiki/concepts/transaction-boundary-abstraction]] 대안 5 · 소수파 증거(Buckpal/Modulith는 반대 방향) Claim-backed 표 참조.
---
## 3. 그래서 나는 어떤 문제로 "정의" 했나
**ca-tmpl 이 대안 5(`TransactionPort`)를 고른 이유.**
여기가 핵심이다. ca-tmpl 이 `TransactionPort` 를 고른 건 "그게 제일 우월해서" 가 **아니다.** **내가 문제를 그렇게 정의했기 때문**이다.
ca-tmpl 은 **Clean Architecture skeleton 템플릿**이다. 이 프로젝트의 존재 이유 자체가 "규율(discipline)을 코드로 강제해서 남에게 물려주는 것" 이다. 그래서 나는 가장 먼저 이 규칙을 세웠다:
> **"application layer 는 Spring 을 import 하지 않는다."**
이 규칙을 세운 *순간*, 답은 거의 정해졌다. 대안 1·2·4 는 전부 `org.springframework...` import 를 요구하니 **규칙 위반**이다. 대안 3 은 팀 언어(Java)에 안 맞는다. 남는 건 대안 5. → **문제 정의가 답을 결정했다.**
내가 실제로 한 것 (검증된 사실만 — 자세히는 [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]):
- `TransactionPort` 인터페이스: `inWrite` / `inRead` / `inNew` 3개. 인자를 `Supplier`/`Runnable` 로만 받는다 → checked exception 을 시그니처에 노출 안 함(설계 결정 D11).
- `inNew` = `REQUIRES_NEW` = **새 물리 connection** 을 잡는다 → pool 을 소모하므로 loop 안에서 부르면 안 됨(D12). 비싼 도구라 명시적 케이스(outbox/audit)에만.
- `Isolation``READ_COMMITTED` **한 값만** 노출 (나머지는 다른 브랜치로 위임 — 범위를 좁혀 결정 비용을 미룸).
- 진짜 강제 장치: **ArchUnit fitness function** 이 application 패키지에서 `@Transactional` import 를 발견하면 **테스트를 깨뜨린다.** 규칙이 문서가 아니라 빌드 게이트가 됐다.
**검증은 어디까지?** JVM 단위 테스트 + 정적 분석(ArchUnit)까지. **실 DB 통합 테스트도, 운영 배포도 없다.** 그러니 면접에서 "운영에서 검증했다", "실 DB 로 전파를 측정했다" 고 말하면 **거짓말**이다. (과장 금지 전체 목록: project 문서 §과장 금지)
> **강사의 결론:** 누가 "왜 그냥 `@Transactional` 안 썼어요? 그게 표준인데" 라고 물으면, 정답은 "추상화가 우월해서" 가 **아니라** 이렇게 답해야 한다 —
> *"제 프로젝트의 문제 정의가 '핵심은 프레임워크를 모른다' 였습니다. 그 규칙을 세운 순간 `@Transactional` import 는 위반이 됩니다. 만약 단순 CRUD 서비스였다면 저도 `@Transactional` 을 직접 붙였을 겁니다. 문제 정의가 다르면 답도 다릅니다."*
> 이게 "대안을 안다" 의 진짜 의미다.
---
## 4. 다시 처음 장면으로 — 원리를 곱씹는 자가 점검
0번의 그 장면(반쪽짜리 글)으로 돌아가자. 이제 너는 단순히 "트랜잭션 걸면 됨" 이 아니라, **어디에 점을 찍을지** 를 물을 수 있어야 한다. 답을 보지 말고 스스로 재구성해봐라:
1. 만약 네가 지금 만드는 게 **사내 단순 게시판 CRUD** 라면, 위 축에서 어느 점을 찍겠는가? 그 이유를 "두려워하는 것" 의 언어로 한 문장으로 말해봐라.
2. 누가 "AOP `@Transactional` 은 self-invocation 때문에 깨지니까 쓰지 마세요" 라고 단정한다. 어디가 과장인가? 표준 우회를 하나라도 말할 수 있는가?
3. 대안 2(`TransactionTemplate`)와 대안 5(`TransactionPort`)는 **둘 다 명시적 호출**이다. 그런데 분리 축에서 자리가 다르다. **무엇 하나** 때문에 갈리는가? (힌트: import 하는 클래스가 누구 소속인가)
4. ca-tmpl 이 `Isolation``READ_COMMITTED` 한 값만 노출한 건 "결정을 미룬 것" 이다. 이게 왜 *나쁜 게으름이 아니라* 좋은 설계 판단일 수 있는가? (힌트: skeleton 의 목적 + 결정 비용)
5. 한 단계 더: `inNew`(REQUIRES_NEW)를 for-loop 안에서 100번 부르면 무슨 일이 일어나는가? 왜 그게 D12 에서 금지됐는가? (힌트: 비유에서 콘센트가 아니라 "새 전선을 매번 새로 까는" 비용)
이 5개를 막힘없이 말로 설명할 수 있으면, 너는 이 주제를 "외운" 게 아니라 "이해한" 거다.
---
## Sources (이 설명의 출처 — 모두 canonical)
- [[wiki/concepts/transaction-boundary-abstraction]] — 5개 대안 정의 / Claim-backed 근거 / 과장 금지 (사실의 금고)
- [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] — ca-tmpl 실제 구현 · 검증 범위 · 면접 가능 범위 (내 프로젝트 사실)