Files
llm-wiki/vault/30-knowledge/projects/ca-tmpl/data-layer-persistence-cache-outbound.md
T

27 KiB

title, source_type, status, confidence, tags, related_projects, last_reviewed
title source_type status confidence tags related_projects last_reviewed
ca-tmpl - Data Layer Baseline 결정 (Persistence + Cache + Outbound) project verified high
ca-tmpl
persistence
jpa
cache
http-client
actually-implemented
locally-verified
ca-tmpl
2026-07-02

UPDATE 2026-06-15 (스코프: Outbound HTTP 한정): 본 문서가 2026-05-22 에 기록한 "Phase C2 미진입 / 코드 없음" 전제는 Outbound HTTP 영역에 한해 더 이상 사실이 아니다. src/adapter-outbound/.../httpclient/ 에 outbound HTTP 클라이언트가 구현 + 로컬 테스트로 검증되어 있다(아래 "Outbound HTTP Client" 절). 이 절은 wiki/explainer/adapter-outbound 가 코드 사실의 근거로 인용한다.

UPDATE 2026-07-02: /home/donghyeon/workspace/ca-tmpl/src 대조 및 ./gradlew check 통과로 이 문서를 verified로 승격했다. Outbound HTTP, lower-layer cache SPI/router/fail-open, idempotency/outbox persistence, OSIV/Hikari startup guard는 구현·로컬 검증됐다. 단 본 문서의 원래 "Cache 결정"(cache-aside + Caffeine + Redisson 분산 lock + after-commit invalidation)은 일부가 lower-layer cache SPI와 다른 층이므로 구현 범위를 분리해서 읽어야 한다.

ca-tmpl - Data Layer Baseline 결정 (Persistence + Cache + Outbound)

Layer: wiki/projects/ — ca-tmpl skeleton의 data layer baseline 결정 사실 기록. 일반 개념·근거는 wiki/concepts/data-layer-persistence-cache-outbound에 둠. 본 문서는 "내 프로젝트에서 무엇을 결정했고, 어디까지 진행되었는가"만 다룬다.

프로젝트 컨텍스트

ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 템플릿이다. 본 문서는 그 안에서 data layer baseline 3축(Persistence / Cache / Outbound HTTP)을 어떻게 결정했는지를 기록한다.

  • 결정한 baseline:
    • Persistence: SQLState 9-row classifier matrix + Hibernate OSIV off + HikariCP pool wait/exhaustion alert + read replica lag threshold (raw/project-notes/ca-skeleton-operational-contract §6).
    • Cache: cache-aside default + Caffeine local lock(single-instance) + Redisson RLock distributed mutex(multi-instance HPA) + after-commit invalidation + eventual consistency window 5s (canonical §11).
    • Outbound HTTP: Spring RestClient baseline + Resilience4j CircuitBreaker · TimeLimiter · Retry + timeout connect 2s / read 5s / global 10s + retry default disabled (canonical §11, §29 G-C).
  • 진행 단계: C2 부분 구현 + 로컬 검증 완료. 본 문서의 범위는 구현된 outbound/cache/persistence slice와 아직 planned로 남은 cache-aside/replica-lag/운영 tuning 경계를 분리한다.

실제 구현 내용 (actually-implemented)

Persistence / Cache / Outbound 일부 구현됨. SQLState classifier와 read-replica lag metric은 별도 확인이 필요하지만, idempotency/outbox persistence adapter와 Flyway migration, OSIV/Hikari startup guard, lower-layer cache SPI/router/fail-open/Redis adapter, outbound HTTP baseline은 코드에 존재한다.

Outbound HTTP Client (actually-implemented)

모듈 src/adapter-outbound/.../httpclient/, 단일 업스트림 의존성 1개당 인스턴스 1개. 진입 클래스 OutboundHttpClient.

입출력 (공개 API)

메서드 입력 반환 비고
static OutboundHttpClient baseline(name, baseUrl, settings, guard, resilience, retryPolicy, errorMapper, logger) 협력자 8개 OutboundHttpClient 정적 팩토리 — 빈으로 등록하지 않음. fork 프로젝트가 의존성마다 named 인스턴스 생성. static 인 이유: ArchUnit B7(어댑터 타입 반환 public static 메서드 금지) seam
<T> T get(String uri, Class<T> type) URI, 응답 타입 T exchange(GET, uri, null, type) 위임
<T> T exchange(HttpMethod m, String uri, Object body, Class<T> type) 메서드/URI/요청바디/응답타입 T 전체 파이프라인(아래)
<T> T stream(HttpMethod m, String uri, Function<InputStream,T> reader) 메서드/URI/스트림 리더 T 리트라이 없음 · size 인터셉터 없음. 대용량 응답 전용

호출 파이프라인 (exchange 정상 경로)

  1. 셧다운 fast-failguard.isShuttingDown() 이면 네트워크를 맺지 않고 즉시 DependencyFailureException(DEPENDENCY_CIRCUIT_OPEN, name, "shutdown in progress — outbound call rejected fail-fast (D8)") throw, 로그 outcome=REJECTED.
  2. deadline = Instant.now().plus(globalCallTimeout) 산정 → retryPolicy.beginCall(method, deadline) (ThreadLocal 에 적재).
  3. 데코레이션 합성: CircuitBreaker.decorateSupplier(cb, Retry.decorateSupplier(retry, countingSupplier))합성 순서 = CB(바깥) → Retry(안) → 실제 호출. 리트라이가 CB 안쪽이라 각 재시도가 독립적으로 CB 윈도우에 카운트됨.
  4. 예외 분기: OutboundResponseSizeExceededException분류하지 않고 그대로 재throw(업스트림 장애가 아니라 "버퍼 API 오용" 계약 위반); 그 외 모든 ThrowableerrorMapper.classify(name, t) 로 매핑 → 로그 → throw. 호출자는 항상 DependencyFailureException(또는 size 예외)만 본다.
  5. finally 에서 retryPolicy.endCall() 항상 실행(ThreadLocal 누수 방지).

예외 / 오류코드 매핑 (OutboundHttpErrorMapper.classify, cause chain 순회 → 첫 매치 채택)

진단 메시지는 server-log-only — status code + 예외 클래스명만 담고 업스트림 raw 응답 body 는 절대 미포함(D12 PII 안전, DependencyFailureException javadoc 계약). 오류코드는 OperationalError(SSOT docs/registries/error-codes.yaml):

매치 (cause chain) 코드 HTTP / retryable
CallNotPermittedException(R4j) DEPENDENCY_CIRCUIT_OPEN 503 / true
UnknownHostException·UnresolvedAddressException DEPENDENCY_DNS_FAILED 503 / true
HttpConnectTimeoutException DEPENDENCY_CONNECT_FAILED 503 / true
ConnectException(DNS cause 포함) DEPENDENCY_DNS_FAILED 503 / true
ConnectException(그 외) DEPENDENCY_CONNECT_FAILED 503 / true
HttpTimeout·SocketTimeout·TimeoutException DEPENDENCY_TIMEOUT 504 / true
RestClientResponseException 4xx DEPENDENCY_4XX_CLIENT 502 / false (401→"check credential", 403→"check scope" 힌트)
RestClientResponseException 5xx DEPENDENCY_5XX_SERVER 502 / true
매치 없음(fallback) DEPENDENCY_CONNECT_FAILED 503 / true

순서 주의: HttpConnectTimeoutException extends HttpTimeoutException 이라 connect 를 read timeout 보다 먼저 검사. Open Risk(D12): 408/429 는 의미상 재시도 가능하지만 현재 모든 4xx 가 non-retryable.

자료구조 + 선택 이유

구조 위치 이유
AtomicBoolean running/shuttingDown OutboundHttpShutdownGuard 셧다운 스레드 write ↔ 요청 스레드 read 간 가시성
ThreadLocal<CallContext> + record CallContext(HttpMethod, Instant deadline) OutboundRetryPolicy 동기 클라이언트라 호출이 한 스레드를 타고 가므로 deadline·method 를 스레드별 격리
Set.of(GET,HEAD,PUT,DELETE) OutboundRetryPolicy.IDEMPOTENT_METHODS 불변 + O(1) 멱등 판정. POST/PATCH 의도적 제외
int[] attemptCount = {0} OutboundHttpClient.exchange 람다가 캡처 지역변수를 못 바꾸므로 1칸 배열을 가변 closure cell 로 사용
record OutboundHttpSettings + 중첩 record Retry/CircuitBreaker(박싱 Integer/Double/Float) OutboundHttpSettings 불변 값 + null = "기본값 적용" 신호
Optional<Retry>/Optional<CircuitBreaker> OutboundHttpResilience "데코레이션 없음"(기능 off)을 호출자가 강제로 다루게

Spring / Resilience4j / Micrometer 메커니즘

  • @ConfigurationProperties(prefix="app.outbound.http") + @ConstructorBinding → env/yaml → record 바인딩, compact 생성자 검증 실패 시 startup 실패.
  • SmartLifecycle(OutboundHttpShutdownGuard): getPhase()=Integer.MAX_VALUE → 컨텍스트 종료 시 phase 내림차순 stop → 이 빈의 stop() 이 가장 먼저 호출(다른 아웃바운드 빈보다 먼저 플래그 set). ContextClosedEvent 는 너무 늦고 순서 미보장이라 부적합.
  • BeanPostProcessor(OutboundHttpTimeoutEnforcer, static @Bean): raw RestClient/RestClient.Builder 빈 발견 시 BeanCreationException → timeout 미설정 클라이언트 등록을 startup 차단. static 이라 다른 빈보다 일찍 생성돼 가로챔.
  • Resilience4j: decorateSupplier 합성, RetryRegistry/CircuitBreakerRegistry 가 dependency 이름별 인스턴스 캐시(= per-dependency 지표), IntervalFunction.ofExponentialRandomBackoff(지수 + jitter).
  • Micrometer MeterFilter(OutboundHttpResilienceConfig): 저카디널리티 정규화 — kindoutcome 태그 리네임, state 값 대문자화, 그 외 resilience4j.* 미터 전부 DENY. 활성화 가드(D3): retry/CB 중 하나라도 켜졌는데 MeterRegistry 없으면 IllegalStateException.
  • RestClient 2개: connect timeout = HttpClient.connectTimeout, read timeout = JdkClientHttpRequestFactory.setReadTimeout. buffered(trace→size 인터셉터) / streaming(trace 만).

설정 (app.outbound.http.*)

기본값 효과
connect-timeout / read-timeout / global-call-timeout 없음(필수) TCP 연결 / 소켓 읽기 / 리트라이 포함 전체 deadline 예산. 누락 시 startup 실패
retry-enabled / circuit-breaker-enabled false / false R4j retry / CB 활성. 하나라도 켜면 MeterRegistry 필수
response-size-limit 10MB buffered 본문 in-memory 상한(초과 시 OutboundResponseSizeExceededException)
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 COUNT_BASED 윈도우 / rate 계산 최소 호출
…wait-duration-in-open-state / …permitted-calls-in-half-open 60s / 10 open→half-open 대기 / half-open 시험 호출 수

클래스 연관 (빈 배선)

  • OutboundHttpClientConfig 가 공유 빈(ShutdownGuard/TimeoutEnforcer/ErrorMapper/OutboundHttpDependencyLogger/RetryPolicy)을 @Bean @ConditionalOnMissingBean 등록하되 OutboundHttpClient 빈은 일부러 안 만든다(의존성마다 named 인스턴스).
  • OutboundHttpResilienceConfigOutboundHttpResilience 빈 생산 + MeterFilter 설치.
  • ⚠️ retryPolicy 인스턴스는 resilience(shouldRetry predicate)와 OutboundHttpClient(beginCall)가 같은 것을 공유해야 한다 — 다르면 shouldRetry 가 ctx=null 로 영영 재시도하지 않음.
  • 인터셉터: TraceContextPropagationInterceptor(MDC→traceparent/baggage 헤더, 샘플 플래그 00 하드코딩, allowlist=tenant_id·request_id), ResponseSizeBoundingInterceptor(Content-Length 또는 BoundedInputStream 누적이 limit 초과 시 throw, buffered 전용).

로컬/dev 검증 (locally-verified)

Persistence / (data-layer) Cache: 없음 (재확인 안 함).

Outbound HTTP Client: locally-verifiedsrc/adapter-outbound/src/test/.../httpclient/ 의 단위 테스트로 다음이 검증됨(prod 배포·측정은 없음):

  • OutboundHttpClientTest — retry-on 500 GET 정확히 3회 / POST 정확히 1회(I4 비멱등 차단) · CB OPEN 시 0회 short-circuit + DEPENDENCY_CIRCUIT_OPEN · 셧다운 시 0회 + REJECTED · 업스트림 secret body 미유출 · buffered size 초과 시 OutboundResponseSizeExceededException(stream() 은 성공) · outcome 태그 존재/kind 태그 부재.
  • OutboundHttpErrorMapperTest — 위 예외 매핑 테이블 전 행 + 408/429 Open Risk + body 미유출.
  • OutboundHttpResilienceTest / OutboundHttpResilienceConfigTest — decorate 순서 · 기본값(3/100ms/2.0, 50%/100/100/60s/10) · MeterRegistry 가드.
  • OutboundRetryPolicyTest — 4-조건 게이트(셧다운/멱등/retryable/deadline).
  • OutboundHttpShutdownGuardTest — phase=Integer.MAX_VALUE, start/stop 플래그.
  • OutboundHttpSettingsTest — config 바인딩 + 잘못된 값 startup IllegalArgumentException.
  • TraceContextPropagationInterceptorTest / OutboundHttpDependencyLoggerTest — 헤더 주입 · 로그 레벨/필드.

운영 검증 (prod-verified)

없음. 운영(prod) 환경에 배포된 적이 없다. 따라서 릴리즈 노트 / 운영 로그 / 모니터링 대시보드 / 인시던트 보고서 어느 것도 존재하지 않는다.

  • 운영(prod) 환경에 배포된 적이 없다. 따라서 릴리즈 노트 / 운영 로그 / 모니터링 대시보드 / 인시던트 보고서 어느 것도 존재하지 않는다.

문서/계획만 존재 (documented-only / planned)

다음은 모두 문서/설계 단계의 결정이며, 코드로 강제되어 있지 않다. 면접에서 "구현했다"고 말하면 안 되는 부분이다.

Persistence (partially-implemented)

  • SQLState 9-row classifier matrix(08* connection, 40001 serialization, 40P01 deadlock, 23xxx integrity, 57014 query canceled 등) → Spring DataAccessException hierarchy 위에 TRANSIENT_DEPENDENCY / CONFLICT / DATA_INTEGRITY 카테고리 매핑 (Category.java 10-enum 정합 — 이전 PERSISTENCE 표기는 stale, 부모 §6 2026-06-01 정합 + error-codes.yaml authoritative).
  • Hibernate OSIV off를 baseline으로 결정 (Vlad Mihalcea anti-pattern 평가 + Spring Boot startup WARN 근거).
  • HikariCP pool wait p99 / pool exhaustion을 1차 alert 지표로 지정.
  • Read replica lag threshold를 SLO에 포함.
  • 근거: canonical raw/project-notes/ca-skeleton-operational-contract §6 + raw/branch-notes/feature-persistence-failure-baseline.
  • 구현됨: idempotency/outbox RDBMS adapter, PostgreSQL migration, OSIV-off startup guard, Hikari inter-knob startup guard.
  • 남음: SQLState 9-row classifier 전체, read replica lag metric/alert, 운영 pool tuning 측정.

Cache (partially-implemented)

  • cache-aside default + Caffeine local lock(@Cacheable(sync = true) / AsyncLoadingCache) + Redisson RLock distributed mutex(multi-instance HPA 가정).
  • after-commit invalidation 강제 (Spring TransactionSynchronizationManager.registerSynchronizationafterCommit() hook).
  • Eventual consistency window 5초로 명시.
  • Strict consistency use case(잔액, 인증, idempotency 검증)는 cache bypass.
  • Negative cache(존재하지 않는 row, TTL 60s)는 invalidation 채널 적용 대상에서 제외.
  • 근거: canonical §11 + raw/branch-notes/feature-cache-consistency-contract.
  • 구현됨: CacheStore, FailOpenCacheStore, CacheStoreRouter, RedisCacheStore, CacheBindingSettings, 관련 단위 테스트.
  • 남음: 원래 문서의 cache-aside+Caffeine local lock+Redisson distributed mutex+after-commit invalidation 전체 contract와 운영 consistency window 측정.

Outbound HTTP (결정 — 현재 구현됨, 위 "Outbound HTTP Client" 절 참조)

다음은 baseline 결정 사실이며, 결정 자체는 그대로 유효하다. 2026-06-15 기준 코드로 구현되어 있다(시간/리트라이 기본값은 결정 당시 수치와 일부 다르게 구현됨 — 아래 표시):

  • Spring RestClient(6.1+)를 baseline으로 결정. RestTemplate은 maintenance-only로 신규 채택 제외, WebClient는 MVC servlet baseline의 blocking risk로 extension 분리, OpenFeign은 Spring Cloud 의존으로 baseline에서 제외. → 구현: RestClient 2종(buffered/streaming).
  • Resilience4j로 retry / circuit breaker 일원화. Hystrix는 maintenance mode로 배제. → 구현: OutboundHttpResilience + OutboundHttpResilienceConfig. (TimeLimiter 대신 동기 클라이언트라 deadline 예산 + OutboundRetryPolicy 게이트로 대체.)
  • Timeout 계층: connect / read / global 3축 모두 필수 강제(하나라도 누락 시 startup 실패). → 구현됨. 단 결정 당시 예시값 2s/5s/10s기본값이 아니라 필수 입력으로 구현(@ConfigurationProperties, 기본값 없음).
  • Retry default disabled(retry-enabled=false) → 구현됨. idempotency-key 미보장 일반 API 보수적 결정. 켜도 비멱등(POST/PATCH)은 OutboundRetryPolicy 가 차단.
  • 근거: canonical §11, §29 Group G-C + raw/branch-notes/feature-outbound-http-client-baseline + 코드 src/adapter-outbound/.../httpclient/.

대안 검토 범위 (요약 — 상세는 concept 참조)

각 sub-topic마다 5종 이상 대안을 비교했고 baseline을 선정했다. 비교의 출처/세부는 wiki/concepts/data-layer-persistence-cache-outbound에 있다.

  • Persistence: SQLState classifier vs vendor-specific code, JPA blocking vs R2DBC reactive, OSIV on vs off, Hikari sizing 공식.
  • Cache: cache-aside vs write-through vs write-behind vs read-through, Caffeine vs Hazelcast(local), Redisson RLock vs SETNX vs Redlock.
  • Outbound HTTP: RestClient vs RestTemplate vs WebClient vs OpenFeign vs @HttpExchange, Resilience4j vs Hystrix vs Spring Retry.

면접에서 말할 수 있는 범위

자신 있게 답할 수 있는 질문

  • ca-tmpl의 SQLState 9-row classifier matrix를 왜 만들었고, Spring DataAccessException hierarchy 위에서 어떤 카테고리(TRANSIENT_DEPENDENCY / CONFLICT / DATA_INTEGRITY, Category.java 10-enum)로 매핑하기로 했는가.
  • Hibernate OSIV를 anti-pattern으로 보는 근거(Vlad Mihalcea + Spring Boot WARN)와 OSIV off를 ca-tmpl baseline으로 둔 이유.
  • cache-aside의 eventual consistency window 5초가 의미하는 바, 그리고 strict consistency가 필요한 use case(잔액, 인증, idempotency 검증)를 cache bypass로 분리한 의도.
  • Resilience4j를 Hystrix 대신 선택한 이유(Hystrix maintenance mode + Resilience4j functional decorator 모델).
  • Outbound HTTP timeout을 connect 2s / read 5s / global 10s로 분리한 의도와, 셋 중 어떤 게 빠지면 어떤 위험이 생기는지.

적당히 답할 수 있는 질문

  • Caffeine vs Hazelcast 같은 local cache 후보 비교 (개념 수준은 가능, 실측 비교 없음).
  • RestClient vs WebClient (concept-level trade-off는 답할 수 있으나 실제 throughput 측정 없음).
  • after-commit invalidation을 강제하는 이유 (개념 + Spring API 위치는 설명 가능, 실 hook 코드 없음).

답하면 안 되는 질문 (모른다고 해야 함)

  • HikariCP pool size 튜닝 경험, pool wait p99 실측치, pool exhaustion 대응 경험 — 측정/운영 경험 없음.
  • cache hit ratio 측정 / TTL 튜닝 / negative cache stale 사례 — 계측 없음.
  • Resilience4j circuit breaker open 운영 경험, half-open probe 동작 관찰, 실제 retry budget 튜닝 — 운영 경험 없음.
  • read replica lag 운영 경험, replica failover 대응 — 운영 경험 없음.
  • 본 baseline을 적용한 서비스의 SLO 달성 여부 — prod 배포 없음.

과장 금지 지점

이 프로젝트를 외부에 설명할 때 사실보다 부풀려지기 쉬운 표현.

  • "ca-tmpl에 SQLState classifier 전체를 구현했다" → . persistence failure classifier 전체는 별도 확인 필요.
  • "OSIV off startup guard와 Hikari inter-knob guard를 로컬 검증했다" → 가능.
  • "cache-aside + Redisson RLock으로 분산 환경에서 안전한 캐시를 구현했다" → . lower-layer cache SPI/router와 원래 cache-aside+distributed mutex contract를 혼동하지 않는다.
  • "Resilience4j로 circuit breaker/retry baseline을 구현했다" → 가능. 단 운영 장애 대응 경험은 없음.
  • "RestClient + timeout 2s/5s/10s로 outbound baseline을 구현하고 로컬 테스트로 검증했다" → 가능. 단 운영 SLO 보장은 아님.
  • "성능 측정 후 baseline을 튜닝했다" → . 측정·튜닝 모두 미수행.
  • "운영에서 검증된 baseline이다" → . prod 배포 없음.

면접·블로그·이력서에서는 항상 "구현된 slice와 planned slice를 분리"해야 한다. outbound/cache SPI/idempotency/outbox persistence는 구현·로컬 검증, cache-aside distributed consistency와 운영 tuning은 planned로 둔다.

Blog-topic ingest: cache/webhook/outbound 묶음 (2026-07-02)

아래 raw seed들은 data-layer/cache/outbound canonical에 연결했다. 이 문서는 2026-07-02 기준 코드와 ./gradlew check로 검증되어 blogify 가능하다. 단 각 글에서는 구현된 slice와 planned slice를 분리한다.

관련 개념

Sources

Cluster / 묶음