Files
llm-wiki/wiki/explainer/adapter-outbound.md
T

68 KiB
Raw Blame History

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
explainer
ca-tmpl
architecture
spring-boot
integration
ca-tmpl
2026-06-15

(강사 설명) adapter-outbound — Outbound HTTP 클라이언트 완전 정복

Layer: wiki/explainer/derived(파생) 교육 문서. "나의 진짜 이해" 를 위한 1타강사 칠판이다. 정확한 사실·근거·검증 등급은 여기서 만들지 않는다. 전부 canonical 에서 가져온다:

이 문서의 비유는 의도적으로 부정확하다 (이해를 위한 단순화). 비유를 사실로 인용하지 마라. 면접에서 말할 땐 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)을 향한다.

Outbound Adapter Architecture

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).

  1. application → adapter (자바 객체): application 은 외부가 HTTP 인지 모른다. 객체만 넘긴다. PaymentCommand(orderId="ord-1001", amount=15000, currency="KRW")
  2. adapter → exchange() 호출:
    PaymentResult result = paymentClient.exchange(
            HttpMethod.POST, "/v1/payments",
            new PaymentRequest("ord-1001", 15000, "KRW"),   // ← requestBody (자바 객체)
            PaymentResult.class);                            // ← 응답을 이 타입으로 받겠다
    
  3. 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"}
    
  4. 외부 성공 응답(JSON): {"paymentId":"pay_abc","status":"APPROVED","approvedAt":"2026-06-15T09:00:00Z"}
  5. 반환값: RestClient 가 JSON 을 PaymentResult 로 역직렬화 → adapter 는 PaymentResult(paymentId="pay_abc", status=APPROVED, …) 객체를 받음.
  6. 실패(500)면? exchange()DependencyFailureException(코드 DEPENDENCY_5XX_SERVER)를 던진다 → §10 에서 추적.

➡️ 한 줄 요약: exchange() 입력 = (메서드 + 경로 + 요청객체 + 응답타입), 출력 = 역직렬화된 응답객체 또는 던져진 DependencyFailureException.


§4. 클래스의 모양 — 공개 메서드 4개 [신입 필수]

새 용어 — 정적 팩토리(static factory): new 대신 static 메서드로 객체를 만드는 방식. 여기선 아키텍처 규칙(ArchUnit "B7": 어댑터 타입을 반환하는 일반 메서드 금지)을 static 으로 우회하는 합법 통로(seam). · 제네릭 <T>: "호출자가 정한 타입". get(uri, PaymentResult.class)T=PaymentResult.

진입 클래스 OutboundHttpClient외부 의존성 1개당 인스턴스 1개(결제용 1개, 재고용 1개 …). 공개 메서드 4개:

부르는 법 코드 위치 넣는 것 나오는 것
baseline(name, baseUrl, …협력자 8개) OutboundHttpClient.java:140 의존성 이름 + 협력 빈 그 의존성 전용 클라이언트
get(uri, Class<T>) :166 URI + 응답 타입 역직렬화된 T
exchange(method, uri, body, Class<T>) :184 메서드 + URI + 요청 바디 + 응답 타입 역직렬화된 T
stream(method, uri, reader) :281 메서드 + URI + 스트림 리더(함수) 리더가 만든 T (대용량 전용, 재시도 X)
// 📄 OutboundHttpClient.java:166-168 — get 은 exchange 의 GET 단축
public <T> T get(String uri, Class<T> 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<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 에서 하나씩 연다.

이해 점검
  1. 네트워크가 실제로 일어나는 코드 한 줄은? (정답: decorated.get())
  2. finallyendCall() 을 빼면? (ThreadLocal 누수 → 풀 스레드 재사용 시 이전 요청 컨텍스트 오염 — §14)
  3. 응답 과대(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 이 책임진다.

이해 점검
  1. 상대가 TCP 연결은 받아주는데 응답 바이트를 영영 안 주면, 어느 타임아웃이 끊나? (read)
  2. 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 에 끊김"이 아니다. 진짜 하드 컷이 필요하면 Resilience4j TimeLimiter(+별도 스레드)가 필요한데, 동기 클라이언트엔 스레드 낭비라 의도적으로 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 면 retryForOptional.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 /ordersSocketTimeoutException → 재시도되나? 어느 관문에서 탈락? (관문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건의 실패 비율(= 슬라이딩 윈도우).

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 경로로 분리한다.

ResponseSizeBoundingInterceptor2단 방어다:

  1. Content-Length 빠른 차단: 응답 헤더의 선언 크기가 상한을 넘으면 본문을 한 바이트도 안 읽고 OutboundResponseSizeExceededException.
  2. 스트림 카운팅: 헤더가 없거나 거짓말하면, 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 하면 자식도 다 걸린다. HttpConnectTimeoutExceptionHttpTimeoutException(규칙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 변환부 세부는 미지의 영역.

이해 점검
  1. 외부 500 + body {"token":"…"} → ① 서버 로그 메시지 ② 클라이언트 응답을 각각 써보라. (E5)
  2. HttpConnectTimeoutExceptionHttpTimeoutException 보다 먼저 검사하는 이유? (상속 + 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 의 BinderEnvironment(env+yaml+프로퍼티)에서 prefix 키를 긁고 → ② 느슨한 바인딩(connect-timeoutAPP_OUTBOUND_HTTP_CONNECT_TIMEOUTconnectTimeout) → ③ 타입 변환("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 decorateSupplierSupplier 를 감싸 능력 부여(§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(...) 으로 직접 생성), OutboundHttpResilienceConfigOutboundHttpResilience 빈 + MeterFilter.

⚠️ 함정: OutboundRetryPolicyOutboundHttpResilience(판정)와 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 chaingetCause() 로 줄줄이 연결된 원인. → "래퍼에 가려진 진짜 원인을 따라가며 분류합니다."

§17. 캐시 모듈 — Fail-Open 데코레이터 + 라우터 [신입 필수]

§2 축의 가용성 끝. "캐시가 죽어도 본점은 정상 영업." HTTP 와 달리 캐시는 장애를 삼켜서 미스인 척 한다. 근거: wiki/concepts/fail-open-fail-closed.md

한 줄 그림: CacheStore(Port: get/put) ← CacheBackend(+backendId) ← {RedisCacheStoreRedisClient(프로젝트 구현), 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> 논리 캐시명 → 실제 백엔드 매핑 빈 맵
이해 점검
  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 (삼킴):

// 📄 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 가 책임지니 삼켜도 되고, 릴레이는 자기가 마지막 책임자라 던져 재시도/경보로 이어가야 한다.

이해 점검
  1. 브로커 순단 시 두 발행자의 동작 차이를 코드 한 줄로? (catch 가 throw 로 끝나는가)
  2. app.messaging.kafka.enabled 미설정 시 어떤 빈이 등록되고 호출하면? (Disabled* → AdapterDisabledException)
  3. OutboxEnvelopeJsonpayload 만 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+서킷으로 간접 보호될 뿐 명시적 풀 튜닝은 없다. → "커넥션 재사용·풀 사이즈는 아직 기본값이라 고부하에선 별도 튜닝이 필요합니다."

🧑‍🏫 한마디: 13 은 "구현된 것의 다음 단계", 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 데코 주석 모순은 코드 결함 — 소유 브랜치에서 정정 필요.


자가 점검 — 다시 처음 장면으로

  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)