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 |
|
|
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 예산 안에서 실패를 분류해야 합니다
세 가지를 같은 잣대로 재려고 하면 어느 하나는 반드시 잘못 다뤄지게 됩니다.