> - 내 프로젝트 실제 구현·검증 범위: [[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 미보장 |
- **신입은 §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)** 이라는 하나의 축 위에서 갈린다.
어떤 의존성은 장애 시 즉각 멈춰야 정합성을 지키고(Fail-Closed), 어떤 의존성은 장애를 삼키고 우회해야 가용성을 지킨다(Fail-Open). HTTP 클라이언트는 이 축 위에서 "**장애를 분류해 예외로 전달**"하는 중간 전략을 쓴다 — 무엇을 재시도/차단할지 메서드와 예외 종류로 가른다.
> **새 용어 — Port & Adapter(육각형/클린 아키텍처):** Port = application(비즈니스 로직)이 "이런 기능이 필요해"라고 선언한 **인터페이스(구멍)**. Adapter = 그 구멍을 실제 기술(HTTP/Redis/Kafka)로 **메우는 구현체**. 비즈니스 로직이 "외부가 HTTP 인지 Redis 인지" 몰라도 되게 분리하는 게 목적. 의존성 화살표는 항상 바깥(adapter)에서 안쪽(core)을 향한다.
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 오용")
> **새 용어:** **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 으로 직접 만든다.
| `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·자원 누수가 난다. 그래서 **종료가 시작되면 가장 먼저 깃발을 올려** 신규 호출을 즉시 끊는다.
@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(진행 중 재시도 중단).
> **새 용어 — 멱등(idempotent):** 같은 요청을 여러 번 보내도 결과가 한 번과 같음. GET/PUT/DELETE 는 멱등(안전하게 재시도 가능), **POST 는 비멱등**(보낼 때마다 새 결제가 생김 → 재시도 금지). · **데드라인 예산:** "늦어도 이 시각까지"라는 전체 마감. · **백오프/지터:** 재시도 간 대기를 점점 늘리고(backoff) 거기에 무작위를 섞어(jitter) 모두가 동시에 재시도(thundering herd)하는 걸 막음.
#### A. 재시도를 *할지* 결정 — 4-관문 (`shouldRetry`)
재시도는 무조건 하면 위험하다(이중 결제). 그래서 **4-관문을 전부 통과해야만** 재시도한다:
순서대로: ① **종료 중 아님** → ② **멱등 메서드** → ③ **재시도 가능 코드**(§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(): 재시도 설정 빌드
> 근거: 개념 [[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% |
| 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()` 콜백은 응답을 메모리에 다 담지 않고 `InputStream` 을 그대로 넘긴다 → 100MB CSV OK. 단 **재시도 없음** — 한 번 흘려보낸 스트림은 (수도꼭지에서 이미 흘러간 물처럼) 되감을 수 없어 다시 보낼 수 없다(자가점검 Q5 답).
---
## §10. 실패의 번역 — `classify()` + 예외 운반 [신입 필수]
> **새 용어 — cause chain(원인 사슬):** 예외 A 가 예외 B 때문에 났을 때 `A.getCause()==B` 로 줄줄이 연결된 것. 진짜 원인은 사슬 아래에 숨어 있곤 한다.
`exchange()` 가 잡은 raw 예외(`Throwable`)는 **하나의 `DependencyFailureException` 으로 번역**된다. `classify()` 가 cause chain 을 훑어 첫 매치를 채택:
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 에 못박혀 있다:
> - 원본 예외는 `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 가 가장 바깥이라, **한 논리적 호출(재시도 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 가 바깥이라.)
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` 가 필수 |
생성/배선: `OutboundHttpClientConfig` 가 협력 빈들을 `@Bean` 등록(단 `OutboundHttpClient` 자체는 의존성마다 `baseline(...)` 으로 직접 생성), `OutboundHttpResilienceConfig` 가 `OutboundHttpResilience` 빈 + MeterFilter.
> ⚠️ **함정:** `OutboundRetryPolicy` 를 `OutboundHttpResilience`(판정)와 `OutboundHttpClient`(`beginCall` 적재)가 *다른 객체*로 들면, 판정 측 `callContextHolder.get()` 이 항상 `null` → **영영 재시도 안 함**(관문2 탈락). 반드시 **같은 빈 공유**.
- **시계열 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) {
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)를 강제"** 로 정의했다. 그래서:
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 표 참조.
"형광펜(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.
"트랜잭션은 '효과(effect)' 다. 효과를 숨겨진 마법(proxy)이나 명령형 괄호로 다루지 말고, **타입으로 드러내서 합성** 하고 싶다. 그래야 컴파일러가 검증해주고, 순수 함수처럼 테스트할 수 있다."
**(b) 핵심 직관:**
트랜잭션을 *행동* 이 아니라 **레시피(값)** 로 본다. "이 작업은 트랜잭션이 필요함" 이라는 사실이 **타입에 적혀** 따라다닌다. 레시피들을 레고처럼 합쳐서 마지막에 한 번 실행한다.
**(c) 왜 이게 문제를 푸는가 (끝까지):**
효과가 타입에 드러나면, "이 함수는 트랜잭션 안에서 돌아야 한다" 를 **컴파일 타임에** 강제할 수 있다 → 실행은 순수 함수 합성이라, Spring context 같은 무거운 환경 없이 검증 가능 → testability 가 최고로 올라간다.
→ **그런데** 이건 사고방식 자체가 다르다. Java 위주 Spring 팀에게 monad/패턴 매칭/함수 합성은 **학습 절벽**이다. 게다가 Spring 이 공짜로 주던 propagation·isolation 의미를 monad 위에 직접 다시 구현해야 할 때도 있다. 강력하지만 비싸다.
**(d) 언제 맞나 / 어디서 깨지나:**
- ✅ 맞다: 팀이 이미 함수형(Kotlin/Arrow 등)에 능하고, 효과를 타입으로 다루는 가치를 아는 경우.
- ❌ 깨진다: 평범한 Java/Spring 팀. 도입 비용이 이득을 압도한다. "함수형이 테스트에 항상 우월" 은 *과장* — 팀 역량/언어/기존 코드가 비용을 결정한다.
"annotation 기반 마법(대안 1)의 편리함은 좋다. 하지만 표준 `@Transactional` 은 트랜잭션만 한다. 나는 트랜잭션 *경계에서* 추가 정책 — 예를 들어 권한(capability) 검증 — 을 같이 끼우고 싶다."
**(b) 핵심 직관:**
대안 1 의 형광펜을 **내가 직접 만든 형광펜**으로 바꾼다. 내 annotation, 내 interceptor → 묶음의 시작·끝에 내가 원하는 로직을 추가로 끼워넣는다.
**(c) 왜 이게 문제를 푸는가 (끝까지):**
내 interceptor 가 메서드 호출을 가로채니, `BEGIN`/`COMMIT` 사이에 커스텀 정책을 자유롭게 주입할 수 있다 → 트랜잭션 + 정책을 한 곳에서 다룬다.
→ **그런데** 이건 결국 대안 1 과 같은 AOP 기반이다. **self-invocation 함정 그대로 상속**한다. interceptor 구현 자체가 Spring AOP 에 의존하고, `@TransactionalEventListener` 같은 표준 도구와의 호환을 내가 직접 챙겨야 한다. "마법을 커스터마이즈" 한 대가로 표준이 주던 보장을 일부 떠안는다.
**(d) 언제 맞나 / 어디서 깨지나:**
- ✅ 맞다: 트랜잭션 경계에 *정말로* 횡단 정책을 묶어야 하는 특수 요구가 있을 때.
- ❌ 깨진다: 그냥 트랜잭션만 필요한데 이걸 쓰면 — AOP 함정 + 호환성 부담만 떠안는 오버엔지니어링.
"내 비즈니스 핵심(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 실제 구현 · 검증 범위 · 면접 가능 범위 (내 프로젝트 사실)
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.