68 KiB
title, source_type, status, confidence, tags, related_projects, last_reviewed
| title | source_type | status | confidence | tags | related_projects | last_reviewed | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| (강사 설명) adapter-outbound 모듈의 아웃바운드 연동 및 리질리언스 설계 구조 | explainer | reviewed | high |
|
|
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) 이라는 하나의 축 위에서 갈린다.
[안전제일 / 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)을 향한다.
OutboundHttpClient 는 그 그림에서 HTTP adapter 가 외부 세계로 나가는 출구다. 사용자의 "결제하기" 클릭 한 번이 이렇게 흐른다:
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).
- application → adapter (자바 객체): application 은 외부가 HTTP 인지 모른다. 객체만 넘긴다.
PaymentCommand(orderId="ord-1001", amount=15000, currency="KRW") - adapter →
exchange()호출:PaymentResult result = paymentClient.exchange( HttpMethod.POST, "/v1/payments", new PaymentRequest("ord-1001", 15000, "KRW"), // ← requestBody (자바 객체) PaymentResult.class); // ← 응답을 이 타입으로 받겠다 exchange()가 실제로 내보내는 HTTP (객체 → JSON):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"}- 외부 성공 응답(JSON):
{"paymentId":"pay_abc","status":"APPROVED","approvedAt":"2026-06-15T09:00:00Z"} - 반환값: RestClient 가 JSON 을
PaymentResult로 역직렬화 → adapter 는PaymentResult(paymentId="pay_abc", status=APPROVED, …)객체를 받음. - 실패(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) |
// 📄 OutboundHttpClient.java:166-168 — get 은 exchange 의 GET 단축
public <T> T get(String uri, Class<T> responseType) {
return exchange(HttpMethod.GET, uri, null, responseType);
}
✅ 이해 점검 (펼쳐서 스스로 답해보기)
exchange()와stream()의 출력 형태 차이는? (정답: exchange=역직렬화된 객체 / stream=리더 함수가 만든 값. stream 은 재시도 없음)baseline(...)이static인 이유 한 줄? (ArchUnit B7 우회 seam)
§5. exchange() 한 줄씩 — 정상 골격 [신입 필수]
🔑 이 코드 읽기 전 5단어 (이거만 알면 아래가 읽힌다):
- supplier = "값을 주는 함수"(
Supplier<T>). 호출(.get())해야 실제로 실행된다(준비 ≠ 실행).- 람다
() -> {...}= 이름 없는 함수 한 덩어리.() ->는 "인자 없이 {…} 를 실행".<T>= 호출자가 받고 싶은 응답 타입(예:PaymentResult).- ThreadLocal = "스레드 전용 변수칸"(다른 스레드와 안 섞임).
- 데코레이션(decorate) = 함수를 한 겹 감싸 새 능력(재시도·차단)을 입히는 것.
// 📄 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 에서 하나씩 연다.
✅ 이해 점검
- 네트워크가 실제로 일어나는 코드 한 줄은? (정답:
decorated.get()) finally의endCall()을 빼면? (ThreadLocal 누수 → 풀 스레드 재사용 시 이전 요청 컨텍스트 오염 — §14)- 응답 과대(Step 6a)만 분류 없이 그대로 던지는 이유? (업스트림 장애가 아니라 "버퍼 API 오용")
§5.5. 안전장치 ⓪ 타임아웃 3종 — connect·read·global [신입 필수]
새 용어: connect timeout = TCP 연결(핸드셰이크) 맺기까지의 제한. read timeout = 연결 후 한 번의 응답 바이트를 기다리는 제한. global-call timeout = 재시도까지 포함한 전체 마감(= §7 의 deadline 예산).
타임아웃은 가장 기본 안전장치다 — 셧다운·재시도·서킷보다 먼저, 모든 호출에 무조건 적용된다. 하나라도 빠지면 §1 #1 의 "무한 대기 → 스레드 고갈"이 그 구멍으로 샌다. 세 개가 서로 다른 단계를 끊는다:
[연결 시도] ──connect timeout(예 2s)──▶ [연결됨] ──read timeout(예 5s)──▶ [응답 한 번 도착]
└──────────────── global-call timeout(예 10s): 재시도 다 합쳐 여기까지 ────────────────┘
설정/배선 (생성자에서):
// 📄 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 이 책임진다.
✅ 이해 점검
- 상대가 TCP 연결은 받아주는데 응답 바이트를 영영 안 주면, 어느 타임아웃이 끊나? (read)
- connect/read 가 왜 두 객체(JDK HttpClient / factory)에 나뉘나? (JDK 에 per-request read API 부재)
§6. 안전장치 ① 셧다운 fail-fast — SmartLifecycle [신입 필수]
배포로 서버가 종료 중일 때 새 외부 호출이 들어오면, 반쯤 죽은 빈을 건드려 NPE·자원 누수가 난다. 그래서 종료가 시작되면 가장 먼저 깃발을 올려 신규 호출을 즉시 끊는다.
// 📄 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-관문을 전부 통과해야만 재시도한다:
// 📄 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 에 끊김"이 아니다. 진짜 하드 컷이 필요하면 Resilience4jTimeLimiter(+별도 스레드)가 필요한데, 동기 클라이언트엔 스레드 낭비라 의도적으로 deadline 예산만 택했다(결정 I3).
B. 재시도가 어떻게 도나 — 루프·횟수·백오프·지터
게이트(A)가 "해도 된다"고 하면, Resilience4j Retry 가 실제 루프를 돈다. 그 설정을 만드는 코드:
// 📄 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):
시도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 [심화].)
✅ 이해 점검
POST /orders가SocketTimeoutException→ 재시도되나? 어느 관문에서 탈락? (관문2)GET /products/1가 404 → 재시도되나? 왜? (관문3 — 4xx 는 retryable=false, §10)max-attempts=3이고 매번 실패면 서버를 몇 번 치고, 대기는 몇 번 하나? (정답: 3번 호출 / 2번 대기)initial-backoff=100ms,backoff-multiplier=2.0면 두 번째 재시도 전 nominal 대기는? (200ms)
§8. 안전장치 ③ 서킷 브레이커 — 0부터 [신입 필수]
근거: 개념 wiki/concepts/circuit-breaker.md, 설정 수치는 canonical project 문서. 라이브러리는 Resilience4j.
서킷 브레이커가 뭔데? 집 누전차단기(두꺼비집)다. 과부하/누전 시 차단기가 탁 내려가 집 전체 화재를 막고, 잠시 뒤 다시 올려본다. 단 — 차단기가 내려간 동안은 멀쩡한 가전도 못 쓴다. 소프트웨어도 똑같다: 어떤 외부 의존성이 계속 실패하면 그쪽 호출을 한동안 아예 끊는다. 죽은 서버를 계속 두들겨봐야 ① 내 스레드만 묶이고 ② 아픈 상대를 더 괴롭히기 때문.
무엇을 감시? 그 의존성으로 나간 최근 100건의 실패 비율(= 슬라이딩 윈도우).
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-sizevsminimum-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. 서킷의 진짜 목적은 가용성이 아니라 내 스레드 보호 + 아픈 업스트림 보호다.
✅ 이해 점검
- 빈 종이에 3상태 + 전이 4개 + 트리거를 직접 그려보라. (E3)
minimum-number-of-calls=100이 없으면 서버 켜자마자 무슨 일? (1~2건 실패로 서킷 오픈 — 노이즈)- "서킷 쓰면 가용성 ↑?" O/X + 한 줄 교정. (E4)
§9. 안전장치 ④ 응답 크기 & 스트리밍 [신입 필수]
상대가 2GB 응답을 주는데 버퍼에 다 담으면 OOM. 그래서 buffered 경로엔 크기 상한(기본 10MB), 대용량은 streaming 경로로 분리한다.
ResponseSizeBoundingInterceptor 는 2단 방어다:
- Content-Length 빠른 차단: 응답 헤더의 선언 크기가 상한을 넘으면 본문을 한 바이트도 안 읽고
OutboundResponseSizeExceededException. - 스트림 카운팅: 헤더가 없거나 거짓말하면,
BoundedInputStream이 읽는 바이트를 세다 상한 초과 시 throw.
대용량은 buffered 가 아니라 streaming:
// 📄 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 을 훑어 첫 매치를 채택:
// 📄 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=비재시도"라 함께 막힌다.
예외 객체는 어떻게 "담겨서" 위로 가나
// 📄 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 변환부 세부는 미지의 영역.
✅ 이해 점검
- 외부 500 + body
{"token":"…"}→ ① 서버 로그 메시지 ② 클라이언트 응답을 각각 써보라. (E5) HttpConnectTimeoutException을HttpTimeoutException보다 먼저 검사하는 이유? (상속 + instanceof 순서)
§11. 횡단 관심사 — trace / baggage 인터셉터 [신입 필수]
새 용어 — MDC: 로그·추적용 "스레드별 메모장". traceparent: W3C 표준 분산추적 헤더(
00-traceid-spanid-flag). baggage: 서비스 간 따라다니는 키-값. allowlist: 허용 목록(나머지는 차단).
외부로 나가는 모든 요청에 TraceContextPropagationInterceptor 가 먼저 끼어들어 MDC 의 추적 정보를 헤더로 붙인다 — 여러 서버를 관통하는 한 요청을 추적하려고. 단 baggage 는 allowlist(tenant_id·request_id)만 통과시키고 나머지(이메일·토큰 등)는 전송 전 박멸한다(보안 경계).
입력 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 로 또 감쌌다. 마지막에 감싼 게 가장 바깥 껍질이므로 최종 구조는:
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)
@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 → 데코 없음"을 호출자가 반드시 처리하게 |
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 데코레이터 — 장애를 미스로 바꾸는 곳:
// 📄 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 쏠림(캐시 스탬피드) 위험은 서킷 병행으로 보완.
② 백엔드는 안 삼킨다 — "장애"를 "미스"로 오인하지 않게:
// 📄 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 과 정반대 층):
// 📄 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> |
논리 캐시명 → 실제 백엔드 매핑 | 빈 맵 |
✅ 이해 점검
- Redis 가 완전히 죽었다.
Router.get("worklog","k")의 반환과 컨트롤러가 받는 예외는? (Optional.empty / 예외 없음 → DB fallback) RedisCacheStore는 왜 예외를 안 삼키고CacheBackendException으로 던지나? (장애를 "미스"로 오인 못 하게 — 삼킴은 데코레이터 책임)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 (삼킴):
// 📄 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 (전파):
// 📄 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 없이 손으로:
// 📄 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 빈을 교체:
// 📄 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 가 책임지니 삼켜도 되고, 릴레이는 자기가 마지막 책임자라 던져 재시도/경보로 이어가야 한다.
✅ 이해 점검
- 브로커 순단 시 두 발행자의 동작 차이를 코드 한 줄로? (catch 가
throw로 끝나는가) app.messaging.kafka.enabled미설정 시 어떤 빈이 등록되고 호출하면? (Disabled* → AdapterDisabledException)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 은 "구현된 것의 다음 단계", 45 는 "스켈레톤이라 기본값에 맡긴 부분". 면접에서 이걸 먼저 "여기까진 했고, 다음은 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 데코 주석 모순은 코드 결함 — 소유 브랜치에서 정정 필요.
자가 점검 — 다시 처음 장면으로
- [멱등성]
Idempotency-Key명세가 없는POST /payments에 재시도를 켜두면, 어느 안전장치(어느 관문)가 거부하나? (§7 관문2) - [캐시 Fail-Open] Redis 완전 다운 시 홈 API 호출 →
FailOpenCacheStore내부에서 무슨 일이? 컨트롤러가 받는 최종 예외는? (예외 없음 → DB fallback, §17) - [우아한 종료] SIGTERM 시
SmartLifecycle대신ContextClosedEvent로 깃발을 세우면 어떤 비결정 순서 오류가? (§6) - [아웃박스] 브로커 순단 시
KafkaMessagePublisher(실시간) vsKafkaOutboxMessagePublishAdapter(릴레이) 가 각각 왜 삼킴/전파를 택하나? (§18) - [대용량] 100MB CSV 를
get(uri, Class)로 받으면 무슨 에러? 우회 API 는? (size 예외 →stream(), §9) - [서킷-재시도] 한 호출이 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 게이팅 결정
