--- 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 에서 직관 보충) | | 자바 제네릭 `` / `Class` | "`get(uri, User.class)` 가 어떻게 `User` 를 돌려주나?" | Oracle Generics | | 자바 람다 / `Supplier` | "`() -> 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 (미지의 영역)
Controller participant App as 🌫️ application (미지의 영역)
PayUseCase participant Port as PaymentPort
(인터페이스) participant Adapter as PaymentHttpAdapter
(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 이 정의한 "구멍",
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). · **제네릭 ``:** "호출자가 정한 타입". `get(uri, PaymentResult.class)` 면 `T=PaymentResult`. 진입 클래스 `OutboundHttpClient` 는 **외부 의존성 1개당 인스턴스 1개**(결제용 1개, 재고용 1개 …). 공개 메서드 4개: | 부르는 법 | 코드 위치 | 넣는 것 | 나오는 것 | |---|---|---|---| | `baseline(name, baseUrl, …협력자 8개)` | `OutboundHttpClient.java:140` | 의존성 이름 + 협력 빈 | 그 의존성 전용 클라이언트 | | `get(uri, Class)` | `:166` | URI + 응답 타입 | 역직렬화된 `T` | | `exchange(method, uri, body, Class)` | `:184` | 메서드 + URI + 요청 바디 + 응답 타입 | 역직렬화된 `T` | | `stream(method, uri, reader)` | `:281` | 메서드 + URI + 스트림 리더(함수) | 리더가 만든 `T` (대용량 전용, 재시도 X) | ```java // 📄 OutboundHttpClient.java:166-168 — get 은 exchange 의 GET 단축 public T get(String uri, Class responseType) { return exchange(HttpMethod.GET, uri, null, responseType); } ```
✅ 이해 점검 (펼쳐서 스스로 답해보기) 1. `exchange()` 와 `stream()` 의 *출력 형태* 차이는? (정답: exchange=역직렬화된 객체 / stream=리더 함수가 만든 값. stream 은 재시도 없음) 2. `baseline(...)` 이 `static` 인 이유 한 줄? (ArchUnit B7 우회 seam)
--- ## §5. `exchange()` 한 줄씩 — 정상 골격 [신입 필수] > 🔑 **이 코드 읽기 전 5단어** (이거만 알면 아래가 읽힌다): > - **supplier** = "값을 주는 함수"(`Supplier`). *호출(`.get()`)해야* 실제로 실행된다(준비 ≠ 실행). > - **람다 `() -> {...}`** = 이름 없는 함수 한 덩어리. `() ->` 는 "인자 없이 {…} 를 실행". > - **``** = 호출자가 받고 싶은 응답 타입(예: `PaymentResult`). > - **ThreadLocal** = "스레드 전용 변수칸"(다른 스레드와 안 섞임). > - **데코레이션(decorate)** = 함수를 *한 겹 감싸* 새 능력(재시도·차단)을 입히는 것. ```java // 📄 OutboundHttpClient.java:184-258 — exchange() (주석 축약) public T exchange(HttpMethod method, String uri, Object requestBody, Class 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 supplier = buildSupplier(method, uri, requestBody, responseType); // ── Step 4. CB·Retry 로 감싼다 (감싸는 순서의 의미는 §12 [심화]) Optional cb = resilience.circuitBreakerFor(dependencyName); Optional retry = resilience.retryFor(dependencyName); Supplier countingSupplier = () -> { attemptCount[0]++; return supplier.get(); }; Supplier 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 에서 하나씩 연다.
✅ 이해 점검 1. *네트워크가 실제로 일어나는* 코드 한 줄은? (정답: `decorated.get()`) 2. `finally` 의 `endCall()` 을 빼면? (ThreadLocal 누수 → 풀 스레드 재사용 시 이전 요청 컨텍스트 오염 — §14) 3. 응답 과대(Step 6a)만 분류 없이 그대로 던지는 이유? (업스트림 장애가 아니라 "버퍼 API 오용")
--- ## §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 이 책임진다.
✅ 이해 점검 1. 상대가 TCP 연결은 받아주는데 응답 바이트를 영영 안 주면, 어느 타임아웃이 끊나? (read) 2. connect/read 가 왜 두 객체(JDK HttpClient / factory)에 나뉘나? (JDK 에 per-request read API 부재)
--- ## §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 [심화].)
✅ 이해 점검 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)
--- ## §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*. 서킷의 진짜 목적은 가용성이 아니라 **내 스레드 보호 + 아픈 업스트림 보호**다.
✅ 이해 점검 1. 빈 종이에 3상태 + 전이 4개 + 트리거를 직접 그려보라. (E3) 2. `minimum-number-of-calls=100` 이 없으면 서버 켜자마자 무슨 일? (1~2건 실패로 서킷 오픈 — 노이즈) 3. "서킷 쓰면 가용성 ↑?" O/X + 한 줄 교정. (E4)
--- ## §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 변환부 *세부*는 미지의 영역.
✅ 이해 점검 1. 외부 500 + body `{"token":"…"}` → ① 서버 로그 메시지 ② 클라이언트 응답을 각각 써보라. (E5) 2. `HttpConnectTimeoutException` 을 `HttpTimeoutException` 보다 먼저 검사하는 이유? (상속 + instanceof 순서)
--- ## §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---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순위 위험.)
✅ 이해 점검 (E6) 같은 호출이 3번 재시도 끝에 실패했다. CB 슬라이딩 윈도우엔 실패가 몇 건 기록되나? (정답: 1건 — CB 가 바깥이라.)
--- ## §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` | `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/` | `: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 { <> } 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`, `put(key, value)`. **`Class`·TTL·직렬화가 이 모듈엔 없다**(있다면 프로젝트의 `RedisClient` 구현 쪽). 흔한 오해: `get(key, Class)` 형태가 *아니다*. **① Fail-Open 데코레이터 — 장애를 미스로 바꾸는 곳:** ```java // 📄 cache/FailOpenCacheStore.java:42-64 — 모든 백엔드를 감싸는 데코레이터 @Override public Optional get(String key) { try { Optional 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 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.<논리명>=` | 논리 캐시명 → 실제 백엔드 매핑 | 빈 맵 |
✅ 이해 점검 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)
--- ## §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 가 책임지니 삼켜도 되고, 릴레이는 *자기가* 마지막 책임자라 던져 재시도/경보로 이어가야 한다.
✅ 이해 점검 1. 브로커 순단 시 두 발행자의 동작 차이를 *코드 한 줄*로? (catch 가 `throw` 로 끝나는가) 2. `app.messaging.kafka.enabled` 미설정 시 어떤 빈이 등록되고 호출하면? (Disabled* → AdapterDisabledException) 3. `OutboxEnvelopeJson` 이 `payload` 만 escape 안 하는 이유? (이미 JSON → 이중 인코딩 방지)
--- ## §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 게이팅 결정