197 lines
10 KiB
Markdown
197 lines
10 KiB
Markdown
---
|
|
title: Persistence와 Cache, Outbound 경계를 한 문서에서 분리하기
|
|
source_type: blog
|
|
status: draft
|
|
confidence: unknown
|
|
tags: [blog, ca-tmpl, persistence, caching, spring-data, outbox-pattern]
|
|
related_projects: [ca-tmpl]
|
|
last_reviewed:
|
|
canonical_sources: []
|
|
audience: backend-engineer
|
|
target_publish:
|
|
status_label: 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`가 설정되어 있으면 애플리케이션 시작 자체를 실패시킵니다.
|
|
|
|
```java
|
|
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로 걸러냅니다.
|
|
|
|
```java
|
|
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로 라우팅합니다.
|
|
|
|
```java
|
|
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 호출이 실패하는 경우는 다르게 다룹니다.
|
|
|
|
```java
|
|
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합니다.
|
|
|
|
```java
|
|
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 종류를 가립니다.
|
|
|
|
```java
|
|
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 예산 안에서 실패를 분류해야 합니다
|
|
|
|
세 가지를 같은 잣대로 재려고 하면 어느 하나는 반드시 잘못 다뤄지게 됩니다.
|