Files
llm-wiki/wiki/publish-blog/data-layer-persistence-cache-outbound-blog.md
T

10 KiB

title, source_type, status, confidence, tags, related_projects, last_reviewed, canonical_sources, audience, target_publish, status_label
title source_type status confidence tags related_projects last_reviewed canonical_sources audience target_publish status_label
Persistence와 Cache, Outbound 경계를 한 문서에서 분리하기 blog draft unknown
blog
ca-tmpl
persistence
caching
spring-data
outbox-pattern
ca-tmpl
backend-engineer draft

Persistence와 Cache, Outbound 경계를 한 문서에서 분리하기

"data layer를 구현했다"는 문장은 편리하지만, 그 안엔 서로 완전히 다른 실패 계약을 가진 세 가지가 섞여 있습니다.

TL;DR

  • Persistence, cache, outbound HTTP는 모두 "data layer"로 뭉뚱그려지기 쉽지만, 실패했을 때 무엇을 보존해야 하는지가 완전히 다릅니다.
  • ca-tmpl에서 구현된 범위는 서로 다릅니다 — idempotency/outbox adapter, OSIV/Hikari startup guard, cache SPI/fail-open, outbound HTTP client는 로컬 검증까지 끝났고, SQLState classifier 전체나 read replica lag metric 같은 건 아직 planned입니다.
  • 핵심 원칙 하나: cache는 fail-open이어도 되지만, outbox는 fail-open이면 안 됩니다. 같은 "adapter 실패"라도 업무 의미가 다르기 때문입니다.

1. "Data Layer"라는 이름 뒤에 숨은 세 가지 다른 문제

data layer라고 부르면 하나로 보이지만, 실제로는 서로 다른 실패 모드가 섞여 있습니다.

  • Persistence는 DB transaction, constraint, connection pool 문제가 중심입니다.
  • Cache는 빠른 조회와 stale data, backend 장애 시 어떻게 degrade할지가 중심입니다.
  • Outbound HTTP는 외부 dependency의 timeout, retry, circuit breaker, shutdown 처리가 중심입니다.

ca-tmpl은 이 셋을 한 문서 안에 두되, 구현된 범위와 계획만 있는 범위를 분리합니다. 이 분리가 중요한 이유는 과장하기 쉽기 때문입니다. "data layer baseline을 구현했다"고 말하면 persistence classifier, cache consistency, outbound resilience가 전부 같은 수준으로 끝난 것처럼 들리거든요. 실제로는 그렇지 않습니다.

구현되고 로컬 검증된 것: idempotency/outbox RDBMS adapter, PostgreSQL migration, OSIV/Hikari startup guard, lower-layer cache SPI/router/fail-open, outbound HTTP client

아직 planned이거나 부분 구현인 것: SQLState classifier 전체, read replica lag metric, cache-aside + Redisson distributed mutex + after-commit invalidation의 전체 contract, 운영 tuning


2. Persistence — 시작 시점에 잘못된 조합을 잡아내기

OSIV를 꺼두고, 꺼져 있는지 시작할 때 확인한다

OSIV(Open Session In View)는 web response를 렌더링하는 시점까지 Hibernate session을 열어두는 방식입니다. 편리하긴 한데, presentation layer에서 실수로 lazy association을 건드리는 순간 DB 쿼리가 튀어나갈 수 있습니다. 이게 나쁜 이유는 레이어 경계가 코드 리뷰가 아니라 우연에 의해 지켜지기 때문입니다.

ca-tmpl은 spring.jpa.open-in-view=true가 설정되어 있으면 애플리케이션 시작 자체를 실패시킵니다.

public void afterSingletonsInstantiated() {
  Boolean openInView = environment.getProperty("spring.jpa.open-in-view", Boolean.class);
  if (Boolean.TRUE.equals(openInView)) {
    throw new IllegalStateException("APP_DATASOURCE_OPEN_IN_VIEW must be false");
  }
}

레이어 경계를 코드 리뷰로만 지키는 게 아니라, 런타임 설정 레벨에서도 깨지지 않게 만든 셈입니다.

HikariCP — 잘못 조합된 숫자를 미리 잡기

Connection pool 설정은 값 하나하나는 멀쩡해 보여도 조합이 잘못되면 문제가 생깁니다. ca-tmpl은 이런 조합을 startup guard로 걸러냅니다.

if (validationTimeout != null
    && connectionTimeout != null
    && validationTimeout >= connectionTimeout) {
  violations.add("validation-timeout must be < connection-timeout");
}
if (keepaliveTime != null && maxLifetime != null && keepaliveTime >= maxLifetime) {
  violations.add("keepalive-time must be < max-lifetime");
}

connection-timeout은 최소 250ms 이상, validation-timeout은 connection-timeout보다 작아야 하고, keepalive-time은 max-lifetime보다 작아야 합니다. leak-detection-threshold를 켤 거면 2000ms 이상이어야 하고요.

여기서 오해하면 안 되는 부분이 있습니다. 이 guard는 pool sizing을 운영 환경에서 측정하고 튜닝했다는 뜻이 아닙니다. 그냥 잘못 조합된 숫자를 애플리케이션이 시작하는 시점에 빨리 실패시키는 안전장치입니다. 운영 튜닝과 startup guard는 다른 문제예요.


3. Cache — fail-open, 그러나 무조건은 아니다

Cache 쪽에서 구현된 핵심은 fail-open 경계입니다. CacheStoreRouter는 logical cache 이름을 backend id로 라우팅합니다.

public Optional<String> get(String logicalName, String key) {
  return resolve(logicalName).get(key);
}

private CacheStore resolve(String logicalName) {
  String backendId = bindings.get(logicalName);
  if (backendId == null) {
    throw new AdapterDisabledException("cache", "no cache backend bound");
  }
  return backends.get(backendId);
}

주목할 점은, binding 자체가 없는 logical cache를 호출하면 조용히 no-op 하지 않고 예외를 던진다는 것입니다. 이건 설정 실수를 숨기지 않겠다는 뜻이에요.

반면 backend가 정상적으로 구성된 뒤 실제 cache 호출이 실패하는 경우는 다르게 다룹니다.

public Optional<String> get(String key) {
  try {
    return delegate.get(key);
  } catch (Exception ex) {
    dependencyLogger.logFailure(delegate.backendId(), "cache", "get", ex);
    return Optional.empty();
  }
}

get은 miss로, put은 관찰된 실패로 낮춥니다. cache는 성능을 보조하는 장치이므로, cache backend 장애가 그대로 5xx로 이어지지 않게 하는 방향입니다.

즉 정리하면: "바인딩이 안 된 것"은 설정 실수라서 즉시 실패시키고, "바인딩은 됐는데 backend가 죽은 것"은 운영 중 발생 가능한 일이라서 degrade시킵니다. 같은 "cache 문제"처럼 보여도 원인에 따라 대응이 다릅니다.


4. 같은 "실패"인데 왜 outbox는 다르게 다루는가

여기가 이 글에서 가장 중요한 지점입니다. Outbox publish는 fail-open이면 안 됩니다.

메시지 발행 실패를 cache처럼 조용히 삼키면, downstream 시스템이 영원히 변경 사실을 모를 수 있습니다. cache miss는 다시 조회하면 그만이지만, 발행되지 않은 이벤트는 재시도하지 않는 한 영영 사라집니다.

그래서 outbox는 FAILED/DEAD state machine과 error log를 갖습니다. 실패를 숨기는 대신, 실패를 상태로 남겨서 다시 처리할 수 있게 만든 것입니다.

Cache Outbox
실패 시 동작 miss로 degrade (fail-open) 상태로 기록, 재처리 대상 (fail-closed 성격)
이유 성능 보조 장치라서 장애가 5xx로 번지면 안 됨 발행 실패를 숨기면 downstream이 변경을 영영 모름

같은 "adapter failure"라도 업무적 의미가 다르면 대응도 달라야 한다는 게 이 비교가 전하려는 요점입니다.


5. Outbound HTTP — 재시도해도 되는 것과 안 되는 것

OutboundHttpClient는 dependency 이름별로 baseline client를 만들고, shutdown이 진행 중이면 네트워크 연결을 맺기도 전에 fail-fast합니다.

if (shutdownGuard.isShuttingDown()) {
  throw observer.rejectShutdown("shutdown in progress — outbound call rejected fail-fast");
}

retryPolicy.beginCall(method, deadline);
try {
  Supplier<T> decorated = countingSupplier;
  if (retry.isPresent()) decorated = Retry.decorateSupplier(retry.get(), decorated);
  if (cb.isPresent()) decorated = CircuitBreaker.decorateSupplier(cb.get(), decorated);
  return decorated.get();
} finally {
  retryPolicy.endCall();
}

여기서 흥미로운 구분이 하나 있습니다. Buffered 호출은 retry/circuit breaker를 거치지만, streaming 호출은 재시도하지 않습니다. 이미 일부 bytes를 소비한 스트림은 안전하게 재시도하기 어렵기 때문입니다. 한번 읽기 시작한 스트림을 재시도하면 데이터가 중복되거나 깨질 수 있으니까요.

Retry policy도 method 종류를 가립니다.

private static final Set<HttpMethod> IDEMPOTENT_METHODS =
    Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.PUT, HttpMethod.DELETE);

if (!IDEMPOTENT_METHODS.contains(ctx.method())) {
  return false;
}

GET/HEAD/PUT/DELETE처럼 멱등한(idempotent) method만 재시도 대상이 됩니다. POST/PATCH는 기본적으로 제외되는데, 같은 요청이 두 번 실행되면 의도치 않게 리소스가 중복 생성될 수 있기 때문입니다.

Timeout은 하나가 아니라 세 축이다

outbound HTTP에서 중요한 개념이 하나 더 있습니다. Timeout을 하나의 값으로 뭉치지 않고 세 축으로 나눠서 생각합니다.

  • Connect timeout: TCP 연결을 맺는 단계
  • Read timeout: 소켓에서 데이터를 읽는 단계
  • Global call timeout: retry를 포함한 전체 호출 예산

ca-tmpl의 현재 구현은 이 값들을 기본값으로 그냥 박아두기보다, 필수 설정으로 요구하고 누락되거나 잘못 등록된 raw RestClient를 시작 시점에 막는 방향을 택했습니다.


6. 정리 — 이름이 아니라 실패 계약으로 나누기

ca-tmpl의 data layer baseline은 "DB, cache, HTTP를 다 구현했다"는 단순한 한 문장이 아닙니다. 구현된 것은 구현됐다고 말하고, planned인 것은 planned로 남겨두는 문서입니다.

이 글의 가장 중요한 학습 포인트도 여기 있습니다. data layer의 경계는 기술 이름(DB냐 cache냐 HTTP냐)으로 나뉘는 게 아니라, 실패했을 때 무엇을 보존해야 하는지로 나뉩니다.

  • DB transaction은 정합성을 보존해야 하고
  • cache는 miss로 degrade해도 되며
  • outbound HTTP는 retry/timeout 예산 안에서 실패를 분류해야 합니다

세 가지를 같은 잣대로 재려고 하면 어느 하나는 반드시 잘못 다뤄지게 됩니다.