Files
clean-architecture-backend-…/docs/study/postgresql-jpa-walkthrough.ko.md
T

3620 lines
187 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PostgreSQL / JPA 모듈 완전 해부
> **성격**: 정책 문서가 아니라 **읽기 기록**이다. `adapter:outbound:persistence-jpa` leaf와
> 그 주변(app-bootstrap 배선, application-core 포트, Flyway 마이그레이션, 테스트 레인)에
> 실제로 구현되어 있는 것을 코드 기준으로 하나씩 확인하면서, "무엇이 있는가"와 "왜 그렇게
> 되어 있는가"를 정리한 글이다.
>
> SSOT는 여전히 `src/config/architecture/modules.json`, 모듈 `CLAUDE.md`,
> `docs/jpa/support-matrix.md`다. 이 문서와 그것들이 어긋나면 그쪽이 맞다.
>
> 마지막 장(§17)에 **읽으면서 발견한 손볼 거리**를 우선순위대로 모아 두었다.
> **커버리지 범위 (읽은 것과 안 읽은 것)**
>
> JPA와 PostgreSQL은 별개 모듈이 아니다. 레지스트리에 `adapter-outbound-persistence-jpa`
> 하나만 있고 그 leaf가 둘 다 갖는다 — 모듈 `build.gradle` 첫 줄이
> *"merged RDBMS base + PostgreSQL vendor module"* 이라고 말한다. 벤더 중립부(`api`,
> `transaction`, `springdata`, `hibernate`, `migration`)와 벤더부(`postgresql`, `h2`)는 같은
> leaf 안의 패키지로 갈라져 있고 ArchUnit이 그 경계를 지킨다. 분리해서 읽는 게 불가능하다.
>
> | 영역 | 파일 | 본문 비중 | 이 문서에서 |
> |---|---|---|---|
> | **JPA/PostgreSQL 플랫폼** | 266 | 66.3% | **전문 정독** (§210, §1215) |
> | `fileserver` 스토어 | 25 | 8.7% | 스키마·경계·게이트만 (§11.1) |
> | `notification` 스토어 | 53 | 25.0% | 스키마·경계·게이트만 (§11.2) |
>
> 뒤의 둘을 전문 정독하지 않은 것은 **측정에 근거한 결정**이다. 두 패키지는 JPA 플랫폼
> 타입을 **하나도 import하지 않고**(§11.0), 실제 의존은 각자의 `application-core` 포트다.
> 플랫폼 이해에 더해지는 것이 없고, 그 둘을 손볼 때는 시작점 자체가 `application-core`라
> 별도 문서가 맞다. 이 문서는 그 둘에 대해 **JPA 모듈 쪽에서 알아야 할 것** — 왜 여기
> 있고, 왜 entity scan에서 빠졌고, 어떤 게이트를 공유하는가 — 까지만 다룬다.
---
## 0. 숫자로 먼저 보는 지도
무작정 파일을 열기 전에 규모부터 재 봤다. 규모를 모르면 "다 읽었다"는 말을 할 수가 없다.
| 항목 | 값 |
|---|---|
| 레지스트리 전체 leaf 수 | 44 (`src/config/architecture/modules.json`) |
| 이 leaf의 Gradle path | `:adapter:outbound:persistence-jpa` |
| 허용 의존 | `domain-core`, `application-core`, `shared-contract` |
| runtime membership | `app-bootstrap`, `sample-portfolio` |
| `src/main` Java 파일 | 344개 / 약 26,657줄 |
| `src/main` 패키지 수 | 56개 (top-level child 22개) |
| top-level child 패키지 | `api audit auditing cache config envers experimental failure fileserver h2 hibernate idempotency lock migration notification observation outbox postgresql querydsl security springdata transaction` |
| `@Entity` 클래스 | 23개 |
| 매핑된 테이블 | 20개 (`idempotency_record`, `outbox_event`, `fs_*` 6개, `notification_*` 12개) |
| Spring Data 리포지토리 인터페이스 | 21개 |
| `src/test` (hermetic 단위) | 100개 |
| `src/postgresqlIntegrationTest` (실 PostgreSQL) | 69개 |
| `src/testkit` (재사용 규칙/픽스처) | 41개 |
| `src/jpaPlatformPerformanceTest` (풀 동작 계약) | 3개 |
| Flyway 마이그레이션 스크립트 | 29개 / 8개 독립 스트림 |
PostgreSQL과 직접 관련된 레지스트리 leaf는 세 개다.
| leaf | 성격 |
|---|---|
| `adapter-outbound-persistence-jpa` | 이 글의 주인공. JPA + PostgreSQL 어댑터이자 "관계형 퍼시스턴스 플랫폼" |
| `messaging-outbox-jdbc-postgresql` | vendored messaging 제품의 outbox 구현 (별개 계보) |
| `messaging-inbox-jdbc-postgresql` | 같음 |
messaging 쪽 두 개는 **이 애플리케이션의 레이어가 아니라 vendored 라이브러리**라서 계보가
완전히 다르다. 루트 `CLAUDE.md`가 그 예외를 명시적으로 적어 두고 있고, 이 글은
`adapter:outbound:persistence-jpa` 하나에 집중한다.
---
## 1. 이 모듈의 정체 — leaf 하나 안에 들어온 "플랫폼"
처음 `src/main/java/.../persistence` 아래를 보고 당황했다. Clean Architecture 어댑터라면
"엔티티 + 리포지토리 + 매퍼 + 어댑터" 정도를 기대하는데, `springdata`, `hibernate/batch`,
`postgresql/copy`, `migration`, `experimental/replica` 같은 게 나온다.
이유는 `docs/jpa/repository-adaptation.md`에 적혀 있었다.
- 원래 설계(`docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md`)는
이걸 **18개의 Stable Gradle 라이브러리 모듈 + 7개 Experimental 모듈**로 모델링한다.
- 그런데 이 저장소는 **fail-closed 레지스트리**를 쓴다. `src/settings.gradle`
`ca.architecture-registry` 플러그인을 통해 `modules.json`을 검증하고, 거기 등록되지 않은
프로젝트는 존재할 수 없다. Gradle 프로젝트를 25개 더 만드는 건 `AGENTS.md`의 HARD-STOP #5
위반이다.
- 그래서 **설계상의 모듈이 이 leaf 안의 패키지 경계가 됐다.**
즉 이 leaf의 top-level 패키지 하나하나가 원래는 별도 라이브러리였다는 뜻이다. 그걸 알고
나서야 패키지 구조가 읽힌다.
| 패키지 | 원래 모듈 | 소유 |
|---|---|---|
| `api` (+`.capability/.error/.query/.transaction`) | `jpa-core-api` | 프레임워크 없는 계약: 이름, 안정 예외 계층, 트랜잭션/재시도 프로파일, keyset 커서 |
| `transaction` | `jpa-transaction` | 커밋 증거, Spring 실행기, 전체 트랜잭션 재시도, completion-unknown |
| `springdata` | `jpa-spring-data` | 리포지토리 fragment, 안전 정렬, fetch plan, keyset 실행, 스트림 가드 |
| `hibernate` (+`.batch/.bulk/.stateless`) | `jpa-hibernate` | statement inspector, 통계, batch, bulk DML, stateless session |
| `postgresql` (+ 7개 하위) | `jpa-postgresql`, `jpa-postgresql-copy` | SQLSTATE 분류, 락/작업 클레임, JSONB, 배열/범위, 업서트, COPY |
| `migration` | `jpa-migration-flyway` | Flyway 정책, validate 게이트, concurrent index 가드 |
| `auditing`,`cache`,`envers`,`querydsl`,`security`,`observation` | 각각 대응 모듈 | opt-in capability + 런타임 롤 검증 |
| `experimental` | `jpa-experimental/*` | 멀티테넌시, RLS, 리드 리플리카, forward-compat 레인 — 전부 플래그 게이트 |
| `testkit` (`src/testkit/java`) | `jpa-testkit*` | ArchUnit 규칙, 픽스처, 쿼리/플랜 어서션, PostgreSQL 매트릭스, 실패 주입 |
그리고 **두 개는 이 leaf 밖으로 나갔다.**
- `jpa-spring-boot-starter``:app-bootstrap``dev.caskeleton.bootstrap.autoconfigure.jpa`.
어댑터 leaf가 스스로를 auto-configure하면 안 되기 때문이다. 조립은 컴포지션 루트 소유다.
- `jpa-testkit*` → 별도 Gradle 프로젝트가 아니라 **별도 source set**. 의존이 test
configuration에만 선언되니 "production 모듈이 testkit에 의존하지 않는다"는 보장이
프로젝트를 새로 만들지 않고도 성립한다.
경계 자체는 `JpaModuleBoundaryTest`가 지킨다. production root의 직계 자식 패키지 **닫힌
카탈로그**를 들고, 실제 트리와 **정확히 같은지** 비교하고, 관측된 top-level edge가 선언된
것인지 확인하고, 사이클을 거부한다.
여기 재미있는 실패 기록이 문서에 남아 있다.
> 카탈로그에 13개가 적혀 있고 트리에는 22개가 있었다. 그래서 `audit`, `config`, `failure`,
> `fileserver`, `h2`, `idempotency`, `lock`, `notification`, `outbox` 9개는 **아무 규칙의
> 지배도 받지 않았고**, `transaction → postgresql` / `postgresql → transaction` 사이클이
> 통과했다.
"카탈로그와 트리의 정확한 동등성 검사"가 없으면 **새 패키지는 누락으로 인해 초록불이 된다**는
것. 이게 이 저장소 전반에 반복되는 패턴이다 — *빠뜨림이 곧 통과가 되는 게이트는 게이트가
아니다.*
### 이 leaf가 명시적으로 아닌 것
모듈 `CLAUDE.md`가 못 박는다.
- **NoSQL 중립 코어가 아니다.** MongoDB/Redis/DynamoDB 어댑터는 형제 모듈이고, 이 모듈에
의존해서는 안 된다. `adapter-persistence-nosql-core`를 선제적으로 만들지 말라고 명시.
- **`GenericRepository<T, ID>`도, 플랫폼 base repository도 없다.** 도메인이 자기
리포지토리를 소유한다. ArchUnit `noGenericRepository()`가 지킨다.
- **어댑터가 `@Transactional` 경계를 소유하지 않는다.** 유스케이스가 `TransactionPort`
소유한다.
- **DB 드라이버(`org.postgresql..`, `org.h2..`)는 벤더 패키지 밖에 나올 수 없다.**
ArchUnit `PERSISTENCE_RDBMS_STAYS_VENDOR_NEUTRAL` / `..._NEUTRAL_OF_H2`가 지킨다.
---
## 2. `api` — 프레임워크가 없는 계약층
가장 먼저 읽어야 하는 층이다. Spring도 Hibernate도 import하지 않는다. 여기 있는 결정이
아래층 전부를 규정한다.
### 2.1 "이름"을 타입으로 만든다
```java
public record PersistenceOperationName(String value) {
private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,95}");
...
}
```
같은 패턴이 `QueryName`, `ConstraintCode`, `FetchPlanName`, `BulkOperationName`,
`StatelessWorkName`, `WorkQueueName`, `NativeWriteName`, `CopyOperationName`, `JsonPathName`,
`TenantId`에 반복된다. 목적은 하나다.
> 이 값은 metric tag / trace / retry policy의 키가 된다. 따라서 **엔티티 id, tenant id, SQL
> 조각, 요청 스코프 값을 절대 담을 수 없다.** 카디널리티 상한이 관례가 아니라 **타입 차원**에서
> 보장된다.
읽으면서 제일 인상적이었던 부분. "메트릭 태그에 id 넣지 마세요"를 코드 리뷰 규칙이 아니라
**정규식을 통과 못 하면 생성자가 던진다**로 만들었다.
### 2.2 `api.error` — 안정 예외 계층
23개 파일. 계층 루트는 `JpaPersistenceException`이고, **메시지를 서브클래스가 만들지
않는다.** 루트가 `JpaFailureContext`의 bounded 조각들과 고정된 카테고리 라벨로 조립한다.
```java
private static String describe(FailureCategory category, JpaFailureContext context) {
// 등록된 operation name, 검증된 SQLSTATE, bounded identifier, enum, int, boolean 뿐.
// row 데이터에서 유래할 수 있는 것이 하나도 없다.
}
```
원본 provider 예외는 `getCause()`로만 보존한다 — in-process 분류와 서버 사이드 진단용.
`FailureCategory`는 16값 enum이다. SERIALIZATION_FAILURE, COMPLETION_UNKNOWN, DEADLOCK,
UNIQUE/FOREIGN_KEY/CHECK/NOT_NULL_CONSTRAINT, LOCK_NOT_AVAILABLE, OPTIMISTIC_CONFLICT,
QUERY_TIMEOUT, TRANSACTION_TIMEOUT, CONNECTION_UNAVAILABLE, SCHEMA_MISMATCH, DATA_CORRUPTION,
ENTITY_NOT_FOUND, UNKNOWN.
**핵심 불변식**은 `JpaFailureContext` 생성자에 있다.
```java
if (completionUnknown && retryable) {
throw new IllegalArgumentException("completion unknown failures are never retryable");
}
```
이게 이 플랫폼 전체가 존재하는 이유다. *커밋됐을지도 모르는 작업을 자동 재실행하는 것*이
할 수 있는 최악의 일이고, 그래서 **타입 시스템이 그 상태를 표현하는 것 자체를 거부한다.**
이중 방어도 있다. `TransactionCompletionUnknownException` 생성자가
`forceCompletionUnknown(context)`로 컨텍스트를 다시 만든다. 정책 버그가 retryable한
completion-unknown을 만들어낼 방법이 없다.
부속 결정들:
- `sqlState`는 5자 영숫자 정규식을 통과해야 하고, 아니면 `"redacted"`. 없으면 `"00000"`.
- `constraintName`, `traceId``[A-Za-z0-9._:-]{1,128}`을 통과해야 하고 아니면 `"redacted"`.
- `ConstraintViolationDetails`는 **애플리케이션이 분기할 수 있는 `ConstraintCode`**와 **운영자용
물리 인덱스 이름**을 분리한다. 인덱스를 rename하거나 partial index로 쪼개거나 concurrently
재생성해도 애플리케이션 로직이 한 줄도 안 바뀐다.
- `SqlExceptionSqlStateResolver`는 cause chain **과** `SQLException.getNextException()`
둘 다 따라간다. 드라이버가 batch 실패를 `getNextException`으로 엮고, 설명이 되는 상태가
두 번째 링크에 있는 경우가 잦기 때문. `IdentityHashMap` + `MAX_DEPTH=64`로 사이클 안전.
### 2.3 `api.transaction` — 프로파일과 재시도 예산
12개 파일. 값 타입 위주다.
`PropagationMode`는 **REQUIRED / MANDATORY / REQUIRES_NEW 셋뿐**이다. NESTED, SUPPORTS,
NOT_SUPPORTED, NEVER는 "호출자의 작업이 트랜잭션 안에 있는지 자체를 조용히 바꾸기 때문에"
의도적으로 없다.
`IsolationLevel`에는 **READ_UNCOMMITTED가 없다**. PostgreSQL이 그걸 READ COMMITTED로
취급하니까, 제공하면 프로파일이 DB가 제공하지 않는 격리를 주장하게 된다.
`TransactionProfile`의 생성자 규칙:
```java
if (!readOnly && (timeout == null || timeout.isZero() || timeout.isNegative())) {
throw new IllegalArgumentException("write transaction requires positive timeout");
}
```
**쓰기 프로파일에는 유한 타임아웃이 필수.** 무제한 write 트랜잭션은 statement 하나가 막히면
커넥션·락·row version을 무한정 잡는다.
`RetryProfile`은 재시도 가능한 카테고리를 **화이트리스트**로 받는데, 생성자가 두 가지를
거부한다.
```java
if (retryableFailures.contains(FailureCategory.COMPLETION_UNKNOWN)) { throw ...; }
for (FailureCategory category : retryableFailures) {
if (!ELIGIBLE.contains(category)) { throw ...; } // ELIGIBLE = 5개 contention 카테고리
}
```
**설정으로도 completion-unknown을 재시도 대상에 넣을 수 없다.**
`JitterMode`(NONE/FULL/EQUAL)는 장식이 아니다. 데드락/직렬화 폭풍에서는 모든 경쟁자가 같은
순간에 실패하고, jitter가 없으면 전부 같은 backoff를 계산해서 같은 순간에 다시 충돌한다.
`RetryDisposition``RECONCILE`이 있는 게 핵심이다 — **"모른다"가 재시도의 위장이 아니라
일급 결과**다.
`TransactionCompletionEvidence`는 NOT_STARTED → ACTIVE → COMMITTING → COMMITTED /
ROLLED_BACK / UNKNOWN. **COMMITTING 단계에서 관측된 실패만** completion-unknown이 될 수 있다.
### 2.4 `api.query` — keyset 페이지네이션과 서명 커서
10개 파일.
`KeysetPageRequest`**offset 필드가 없다.** 없는 게 요점이고, "필드의 부재가 나중에 하나
추가되는 걸 막는다"고 주석에 적혀 있다. `size`는 1..500으로 bounded. `fetchSize()`
`size + 1`을 돌려주는데, 이 여분 한 행이 **count 쿼리 없이 `hasNext`를 답하는 전체
메커니즘**이다.
`KeysetSlice`는 total count도, page number도 **의도적으로 없다.** 둘 다 같은 predicate에
두 번째 집계 쿼리가 필요하고, 그건 keyset이 없애려고 존재하는 비용이다. 게다가 움직이는
데이터셋에서 그 숫자는 클라이언트에 닿기 전에 이미 낡았다.
`SignedJsonCursorCodec`이 이 패키지의 하이라이트다. 형식은
`<version>.<base64url(payload)>.<base64url(mac)>`이고, MAC이 **버전과 payload를 함께** 덮는다
(prefix를 다시 써서 옛 커서 포맷으로 다운그레이드하는 걸 막는다).
서명하는 이유가 명확하게 적혀 있다.
> 기밀성이 아니다 — payload는 읽을 수 있다. **무결성**이다. 서명 없는 커서는
> 클라이언트가 제어하는 정렬 상태이고, 그걸 고쳐 쓰면 임의의 키로 seek할 수 있다. 스캔이
> 어디서 시작했는지에 predicate가 의존하는 곳이라면 이건 **접근 제어 우회**다.
방어 순서도 인상적이다.
1. `MAX_ENCODED_LENGTH(4096)` 길이 검사가 **substring/decode/MAC 이전 첫 줄**에 있다.
페이징 엔드포인트는 public이고, 그 아래 모든 코드가 caller가 보낸 크기에 비례해서 할당한다.
2. base64는 4/3으로 늘어나니, **인코딩된 segment 길이만으로** decode 후 크기를 정확히
bound한다. 할당하기 전에 거부.
3. MAC 길이가 정확히 32바이트인지 먼저 확인. `MessageDigest.isEqual`은 **같은 길이 입력에
대해서만** constant time이다.
4. `MessageDigest.isEqual`로 constant-time 비교. short-circuit 비교는 정답 MAC을 한 바이트씩
흘린다.
5. **서명 검증 후에야** payload를 decode한다. 서명 없는 토큰은 애플리케이션의 JSON 파싱에
도달조차 못 한다.
`CursorPayloadCodec`이라는 seam이 있어서 core 계약은 JSON 라이브러리 없이 Java 표준
라이브러리만으로 성립한다.
### 2.5 `api.capability` — 지원 수준을 "선언"한다
`JpaCapability` 16값 enum + `SupportLevel` (STABLE / ADVANCED / EXPERIMENTAL / UNSUPPORTED)
+ `CapabilitySupport` record.
> 수준은 **추론이 아니라 선언**이다: evidence suite가 돌지 않은 capability는 컴파일이
> 된다는 이유로 STABLE이 되지 않는다.
그리고 `CapabilitySupport`는 provider 객체를 절대 담지 않는다 — `DataSource`
`EntityManagerFactory``SessionFactory`도. 리포트로 직렬화되고 actuator 엔드포인트로
publish될 수 있어야 하는데, 살아있는 리소스를 값 타입에 끌고 들어가면 리포트가 JDBC URL이나
자격증명을 흘릴 수 있다.
---
## 3. `transaction` — Spring 위에 얹힌 두 개의 트랜잭션 포트
29개 파일, 약 10만 바이트. 이 leaf에서 제일 밀도가 높은 패키지다.
먼저 알아야 할 것: **트랜잭션 포트가 두 개다.** 하나는 레거시 4-mode, 하나는 정책 기반이고,
같은 빈이 둘 다 구현한다.
```
application-core:
TransactionPort inWrite / inRootWrite / inRead / inNew
PolicyTransactionPort → extends TransactionPort
+ inTransaction(TransactionRequest, Supplier) : TransactionResult<T>
adapter:
SpringTransactionPort implements PolicyTransactionPort
├─ 레거시 4모드 → 미리 만들어 둔 TransactionTemplate 3개
└─ inTransaction → SpringPolicyTransactionPort (package-private 위임체)
```
### 3.1 `SpringTransactionPort` — 레거시 4-mode
모드별로 `TransactionTemplate`**생성 시점에 하나씩 미리 만들어 둔다.**
| 모드 | Propagation | Isolation | read-only |
|---|---|---|---|
| `inWrite` | REQUIRED | READ_COMMITTED | false |
| `inRootWrite` | REQUIRED | READ_COMMITTED | false |
| `inRead` | REQUIRED | READ_COMMITTED | true |
| `inNew` | REQUIRES_NEW | READ_COMMITTED | false |
**왜 미리 만드나**: `TransactionTemplate`은 문서상 thread-safe지만 **mutable**하다. 호출마다
propagation/readOnly를 바꿔 쓰면 같은 빈을 공유하는 동시 요청 사이에 race window가 생긴다.
**`inRootWrite`가 왜 별도 템플릿을 안 만드나**: 실행 속성이 `inWrite`와 같다. 차이는 실행 전
precondition이다.
```java
public <T> T inRootWrite(Supplier<T> action) {
if (TransactionSynchronizationManager.isActualTransactionActive()) {
throw new NestedRootTransactionRejectedException();
}
return executeLegacy(writeTemplate, action);
}
```
`REQUIRES_NEW`로 suspend해서 "root인 척"하지 **않는다.** 그러면 호출자 트랜잭션과 독립
커밋되는 silent 의미 변경이 생긴다. 대신 fail-fast.
그리고 `TransactionTemplate.execute`는 커밋까지 성공한 다음에 값을 반환한다. 따라서
`inRootWrite`의 결과는 **post-commit에만** 호출자에게 보이고, 커밋 실패는 값 대신 원래
트랜잭션 예외로 전파된다.
`inNew`에는 풀 사이징 제약이 붙는다 (application-port D12).
```
hikari.maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1
```
`REQUIRES_NEW`는 바깥 트랜잭션의 커넥션을 **핀한 채로** 새 물리 JDBC 커넥션을 딴다.
레코드마다 `inNew`를 도는 루프는 금지 (풀 고갈 + 데드락 위험). 배치로 묶거나 루프를 트랜잭션
밖으로 빼야 한다.
### 3.2 `SpringPolicyTransactionPort` — 정책 기반 실행기
`TransactionPolicyId` 7개를 Spring 정의로 매핑한다.
| policy | propagation | isolation | readOnly | 비고 |
|---|---|---|---|---|
| `COMMAND_DEFAULT` | REQUIRED | READ_COMMITTED | false | |
| `INBOX_AND_HANDLER` | REQUIRED | READ_COMMITTED | false | |
| `COMMAND_SERIALIZABLE_REPLAY_SAFE` | REQUIRED | **SERIALIZABLE** | false | 유일한 재시도 대상 |
| `QUERY_PRIMARY` | REQUIRED | READ_COMMITTED | true | |
| `QUERY_REPLICA_ELIGIBLE` | REQUIRED | READ_COMMITTED | true | `replicaRequired=true`**현재 admission 거부** |
| `OUTBOX_APPEND` | REQUIRED | READ_COMMITTED | false | |
| `MAINTENANCE_NEW` | REQUIRES_NEW | READ_COMMITTED | false | |
`QUERY_REPLICA_ELIGIBLE`은 지금 **무조건 `TransactionAdmissionException`으로 거부**된다.
리플리카 capability가 qualified되기 전까지는 "지원하는 척"을 안 한다.
반환 타입 `TransactionResult<T>`가 sealed interface로 결과 대수를 만든다.
| 변형 | 의미 |
|---|---|
| `Committed(value, operationId)` | 물리 커밋 확인됨 |
| `Participating(value)` | 바깥 트랜잭션에 참여 — **커밋을 주장하지 않는다** |
| `DeterminateRollback(failure)` | 롤백 확정 |
| `Indeterminate(operationId, lastObservedPhase, reconciliationReference)` | 결과 불명 — **replay 권한을 주지 않는다** |
| `CommittedWithPostCommitFailure(value, operationId, operationalFailure)` | 커밋됐지만 afterCommit 훅이 실패 |
핵심은 실행 루틴이다.
```java
tracker.observe(TransactionPhase.COMMIT_REQUESTED);
try {
transactionManager.commit(status);
} catch (RuntimeException commitFailure) {
if (sentinel.commitAcknowledged()) {
return CommittedWithPostCommitFailure(...); // afterCommit이 이미 왔다
}
if (sentinel.rolledBack()
|| commitFailure instanceof UnexpectedRollbackException
|| TransactionRetryClassifier.isReplayCandidate(commitFailure)) {
return DeterminateRollback(translate(commitFailure)); // 롤백 확정
}
return Indeterminate(operationId, tracker.lastObserved(), empty()); // 모른다
}
```
`PhaseSentinel``TransactionSynchronization` + `Ordered.HIGHEST_PRECEDENCE`로 등록되어
`beforeCommit → FLUSHED`, `afterCommit → COMMIT_ACKED`, `afterCompletion → SYNCHRONIZATION_CLEANUP`
기록한다. **물리 소유자(`status.isNewTransaction()`)일 때만** 등록한다.
재시도는 딱 한 정책만 받는다.
```java
if (policyId != TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE
|| !attemptResult.physicalOwner()
|| attempt >= retryBackoff.maximumAttempts()
|| Thread.currentThread().isInterrupted()) {
return false;
}
```
그리고 `DeterminateRollback`이면서 `isReplayCandidate`(40001 또는 40P01)일 때만 재시도.
**`Indeterminate`는 절대 재시도하지 않는다.**
### 3.3 데드라인 계산 — CallBudget → Spring timeout → PostgreSQL local timeout
`TransactionDeadlineCalculator`가 하는 일이 이 모듈에서 제일 실무적인 부분이었다.
**획득 전 (`beforeAcquisition`)**:
```
required = connectionTimeout + beginBudget + minimumActionWindow + completionMargin
if (remaining < required) → TransactionAdmissionException
safeTransactionNanos = remaining - connectionTimeout - beginBudget - completionMargin
springTimeoutSeconds = min(safeTransactionNanos, settings.transactionTimeout) / 1s
if (springTimeoutSeconds < 1) → TransactionAdmissionException
```
Spring의 `TransactionDefinition#setTimeout`이 **초 단위**라서, 1초도 남지 않으면 아예
시작하지 않는다.
**begin 이후 (`afterBegin`)**:
```
springRemaining = springTimeoutSeconds*1s - elapsed
callRemaining = callBudget.remainingNanosAt(now)
statementWindow = min(callRemaining - completionMargin, springRemaining) - transactionMargin
statement = min(settings.statementTimeout, statementWindow)
lock = min(settings.lockTimeout, statement - lockMargin)
idle = min(settings.idleGuardTimeout, callRemaining - completionMargin)
셋 중 하나라도 1ms 미만 → TransactionAdmissionException
```
그 결과 `EffectiveTransactionTimeouts(statement, lock, idle)`가 나오고,
`TransactionLocalTimeoutConfigurer` SPI가 그걸 DB에 밀어 넣는다.
PostgreSQL 구현:
```java
"select set_config('statement_timeout', ?, true)"
"select set_config('lock_timeout', ?, true)"
"select set_config('idle_in_transaction_session_timeout', ?, true)"
```
세 번째 인자 `true`가 **transaction-local**이라는 뜻이다. 트랜잭션이 끝나면 되돌아간다.
`SET statement_timeout = ?`는 파라미터 바인딩 전에 파싱되어 syntax error가 나므로,
`set_config` 함수 호출로 우회하고 **동시에 값이 statement text에서 빠진다**.
H2 구현은 두 가지가 다르다. 둘 다 H2의 성질이지 선택이 아니다.
- **세션 스코프다.** H2의 `SET`은 session-wide라서 트랜잭션이 끝나도 pooled 커넥션에 남는다.
실무에서 stale하지 않은 이유는 포트가 매 트랜잭션 전에 적용하니 매번 덮어쓰기 때문. 그
경로 밖에서 빌린 커넥션은 이전 트랜잭션의 가드를 들고 있다.
- **idle-in-transaction 가드가 없다.** H2에 대응물이 없어서 caller-side deadline에 맡기고,
"적용했다"고 거짓 보고하지 않는다.
- 값이 inline된다(H2 `SET`은 bind 파라미터를 안 받는다). 검증된 settings에서 온
`Duration`이고 음수는 거부한다.
설정은 `ca-skeleton.jpa.transaction.*`, 바인딩은 `JpaTransactionSettings` record다. 기본값과
**교차 필드 불변식**이 전부 생성자에 있다.
| 키 | 기본값 |
|---|---|
| `transaction-timeout` | 30s |
| `begin-budget` | 250ms |
| `minimum-action-window` | 1s |
| `completion-margin` | 500ms |
| `statement-timeout` | 10s |
| `lock-timeout` | 2s |
| `idle-guard-timeout` | 15s |
| `transaction-margin` | 250ms |
| `lock-margin` | 100ms |
| `retry-base-delay` | 10ms |
| `retry-maximum-delay` | 50ms |
| `retry-maximum-attempts` | 2 (1..5) |
불변식: `statement ≤ transaction`, `lock < statement`, `transactionMargin < statement`,
`lockMargin < statement - lock`, `retryBase ≤ retryMax`, 모든 duration은 (0, 1일].
`JpaTransactionConfig`가 이 타입을 바인딩하는데, 그 존재 이유가 재밌다. 컴포지션 루트의
`@ConfigurationPropertiesScan`이 persistence 트리를 **의도적으로 제외**한다(JPA 꺼진 배포에서
persistence settings가 바인딩되지 않도록). 그 제외가 JPA **켜진** 배포에서도 타입을 unbound로
남겨서, 트랜잭션 포트를 아예 생성할 수 없게 만들었다. 그래서 이 `@Configuration`
`transaction` 패키지 안에, settings 바로 옆에 산다. 중앙에서 enable하면 `config` 패키지가
자기 settings를 명명한 모든 패키지로 edge를 가져야 하니까.
### 3.4 커밋 증거 — `TransactionEvidenceContext`
`EvidenceAwareJpaTransactionManager``JpaTransactionManager`를 상속해서 단계를 기록한다.
```java
protected void doCommit(DefaultTransactionStatus status) {
TransactionEvidenceContext.mark(COMMITTING); // ← provider commit 직전, 절대 이후가 아님
try {
super.doCommit(status);
TransactionEvidenceContext.mark(COMMITTED);
} catch (RuntimeException failure) {
TransactionEvidenceContext.mark(UNKNOWN);
throw classifier.translateCommitFailure(failure);
} finally {
// 일부러 clear하지 않는다.
}
}
```
이 순서가 전체 메커니즘이다. JVM/네트워크/서버가 `super.doCommit` 안에서 죽으면 마지막으로
기록된 것이 **"물어봤고, 모른다"**이고, 그게 롤백으로 오인되면 안 되는 상태다.
`TransactionEvidenceContext`는 단일 슬롯이 아니라 **스택(`ArrayDeque`)**이다.
`REQUIRES_NEW`가 같은 스레드에서 바깥 트랜잭션을 suspend하고 안쪽을 시작하기 때문. 단일
슬롯이면 안쪽 커밋이 바깥의 phase를 덮어쓰고, 나중에 바깥에서 커밋 실패가 나면 이미 끝난
작업의 증거로 분류된다.
여기 아주 좋은 버그 기록이 남아 있다.
> pop하는 주인이 **둘**이었다. 트랜잭션 매니저가 commit/rollback에서 자기 프레임을 지웠고,
> executor의 `finally`가 operation과 attempt가 일치하면 top 프레임을 지웠다. 바깥과 같은
> operation·같은 attempt 번호를 가진 `REQUIRES_NEW` 안쪽 트랜잭션(기본 경로에서는 둘 다
> attempt 1이라 **항상** 그렇다)에서는, 안쪽 매니저가 안쪽 프레임을 pop하고 executor가 그
> 다음에 바깥 프레임을 자기 것으로 오인해 pop했다. 이후 바깥의 커밋 실패는 operation 없이,
> attempt 1로, reconciliation key 없이 보고됐다.
해결은 `TransactionEvidenceScope`다. **깊이(depth)로 소유권을 식별**하고, 자기 프레임이
top일 때만 pop한다. 순서가 어긋나면 아무것도 pop하지 않고 `false`를 돌려준다 —
"nest되지 않은 lifecycle은 버그이고, 남의 프레임을 지워서 덮는 건 원래 결함이 밖에서
보이던 모습 그대로다."
ThreadLocal도 `withInitial`**아니다.**
```java
private static final ThreadLocal<Deque<TransactionEvidenceFrame>> FRAMES = new ThreadLocal<>();
```
initializing thread-local은 읽을 때마다 값을 설치하므로, 마지막 프레임이 정리된 뒤의 읽기가
`clear()`가 방금 제거한 것을 정확히 다시 등록해 버린다. `clear()`는 스택이 비면
`FRAMES.remove()`까지 한다 — pooled thread에서 빈 deque를 남기는 건 요청 스레드마다 쌓이는
느린 누수다.
`hasRawThreadLocalValue()`라는 테스트 전용 접근자까지 있다. 누수 없음을 검증하려고.
### 3.5 `CommitFailureClassifier` — `57P01`의 발견
completion-unknown 판정 규칙은 **좁다.** 커밋 단계에서 발생했고 **동시에** 드라이버가 어느
쪽인지 말해주지 못했을 때만이다.
```java
COMPLETION_UNKNOWN_STATE = "40003"
CONNECTION_CLASS = "08" // startsWith
SERVER_TERMINATION_STATES = {"57P01", "57P02", "57P03"}
+ transport break: SocketException | IOException | SQLRecoverableException
| SQLTransientConnectionException | SQLNonTransientConnectionException
```
`57P0x`가 여기 들어온 경위가 `repository-adaptation.md` §4에 기록되어 있다.
> **§17.2의 "커밋 모호성은 SQLSTATE class 08뿐"은 틀렸다.** 커밋이 in-flight인 백엔드에
> `pg_terminate_backend`를 하면 connection-class가 아니라 `57P01`(admin_shutdown)이
> 보고되고 — 그리고 그게 도착할 때 커밋 레코드는 **이미 WAL에 있을 수 있다.**
> 컨테이너 레인이 그걸 보여줬고, `CommitAmbiguityContractTest`가 SQLSTATE를 직접
> assert해서 규칙이 다시 조용히 좁아지지 못하게 한다.
넓히지 않는 이유도 명시되어 있다.
> 커밋 단계의 모든 커넥션 에러를 completion-unknown으로 표시하면 평범한 풀 고갈과 서버
> 재시작이 reconciliation 큐로 밀려들어가고, 운영자는 그 큐를 **읽지 않고 비우는 습관**을
> 배운다 — 그러면 중요했던 한 건이 나머지와 같이 지워진다.
### 3.6 실패 번역 체인과 재시도 코디네이터
`PersistenceFailureTranslatorChain`의 순서가 계약이다.
1. 이미 분류된 실패는 **그대로 통과**. 다시 번역하면 이미 들고 있는 attempt/key/completion
판정을 잃는다.
2. **낙관적 충돌** — provider 예외라 SQLSTATE가 없어서 SQLSTATE 기반 번역기가 못 알아본다.
3. **벤더 SQLSTATE** — 직렬화 실패, 데드락, 제약 계열.
4. 나머지는 **손대지 않고 반환.** 도메인 예외, assertion 실패, `NullPointerException`
퍼시스턴스 실패가 아니고, 그렇게 포장하면 프로그래밍 에러가 retryable하게 보인다.
여기에도 실패 기록이 있다.
> 재시도 코디네이터는 `JpaPersistenceException`만 catch하는데, executor는 아무것도 번역하지
> 않고 템플릿을 돌렸다. 그래서 경합이 실제로 만들어내는 실패 — Hibernate
> `OptimisticLockException`, Spring `OptimisticLockingFailureException`, raw 직렬화/데드락
> `DataAccessException` — 이 번역되지 않은 채 executor를 떠나 코디네이터의 catch를 완전히
> 비껴갔고, **production 경합은 재시도되지 않았다.** 단위 픽스처는 이미 번역된 예외를
> 던져서 초록불이었다.
`OptimisticConflictTranslator`는 cause chain 전체를 본다(`OptimisticLockException`,
`OptimisticLockingFailureException`, 그리고 이름으로 매칭하는
`org.hibernate.StaleObjectStateException` / `StaleStateException`). 이름 매칭은 트랜잭션
모듈을 Hibernate API 표면에서 떼어놓기 위해서다. 충돌 엔티티 타입은 **등록된 카탈로그**를
통해서만 보고하고 나머지는 버린다 — provider 예외는 엔티티 *인스턴스*와 식별자를 들고
있는데, 그걸 옮기면 row key가 모든 로그 라인과 메트릭 태그에 들어간다.
`VendorFailureTranslator``api`에 선언되어 있는 이유도 사이클 때문이다. `transaction`
`postgresql`을 직접 의존하면, `PostgreSqlPersistenceConfig`가 SPI 때문에 이미
`transaction`을 의존하니까 사이클이 닫힌다.
`DefaultJpaRetryPolicy`의 검사 **순서**도 계약이다.
1. completion unknown → `RECONCILE`. **가장 먼저** 확인해서 뒤의 어떤 규칙도 못 건드린다.
2. 되돌릴 수 없는 외부 부수효과를 수행한 시도(`IrreversibleSideEffectContext.performed()`)
`FAIL`. 예산이 남아도 안 된다.
3. 프로파일이 비활성 → `FAIL`.
4. **번역기가 non-retryable로 표시한 실패 → `FAIL`.** 프로파일의 화이트리스트는 어떤
*카테고리*가 재시도될 수 있는지를 넓히지, *이 실패*에 대한 판정을 뒤집지 않는다.
(예전엔 화이트리스트를 먼저 봐서, 카테고리를 opt-in한 프로파일이 분류기가 이미
terminal이라고 부른 실패를 되살릴 수 있었다.)
5. 프로파일이 명시적으로 opt-in한 카테고리만 재시도.
6. 나머지는 전부 실패. **미지의 SQLSTATE 포함.** 아무도 분류하지 않은 실패를 재시도하는 게
미인식 에러가 중복 쓰기가 되는 경로다.
`FullTransactionRetryCoordinator`의 재시도 단위는 **statement가 아니라 유스케이스 전체**다.
낙관적 충돌/직렬화 실패에서 유일하게 옳은 granularity다 — 시도가 실패한 이유가 "계산의
기준이 된 상태가 더 이상 커밋된 상태가 아니라서"이므로, 같은 statement를 다시 쏘면 같은
틀린 답을 계산한다. executor에 다시 들어가면 **새 트랜잭션과 새 Persistence Context**를
받고, 그게 도메인 규칙을 reload된 데이터에 대해 다시 돌게 만든다.
`RetryBudget`은 **두 개의 독립적 상한**을 건다.
> attempt 카운트만으로는 bound가 아니다. 타임아웃에 10초 걸리는 락에 3번 시도하면 30초짜리
> 호출이고, 상류 호출자는 한참 전에 포기했다. elapsed bound가 재시도를 요청보다 오래
> 살아남지 못하게 한다.
`BackoffCalculator`는 지수 성장 → cap → jitter. `RetrySleeper`는 seam이라서 테스트가 실제로
자지 않는다. `ThreadRetrySleeper`는 interrupt를 삼키지 않고 flag를 복원하고 던진다 —
셧다운이 시도 사이의 작업을 실제로 멈출 수 있게.
`CompletionUnknownRecord` / `CompletionUnknownRecorder`가 reconciliation 핸드오프다. 채널은
애플리케이션이 고르고, **결과가 불명인 그 트랜잭션 바깥**이어야 한다 — 커밋됐을지도 모르는
같은 커넥션으로 감사 기록을 쓰면 그 기록이 문서화하려는 실패를 공유하게 된다. 구현체는
원래 유스케이스를 호출해서는 안 된다. "이 메서드는 관찰한다; 절대 고치지 않는다."
`TransactionProfileRegistry`는 fail-closed lookup이다. 기본값 fallback이 있으면
`"order-wrtie"` 같은 오타가 남의 격리/타임아웃/재시도 예산으로 조용히 돈다.
---
## 4. `failure` — 웹까지 새지 않는 SQLState 매트릭스
이건 `api.error`와 **다른 계층**이다. `api.error`는 플랫폼 내부의 안정 예외 계층이고,
`failure`**Spring `DataAccessException` → `shared.error.PersistenceFailureException`**
변환이다. 즉 웹 어댑터가 응답 코드를 정할 수 있는 형태로 바꾸는 층.
`PersistenceExceptionTranslator`가 등록된 모든 `SqlStateErrorMapping` 빈을 **생성 시점에
merge**한다. 그리고 merge 규칙이 강경하다.
```java
OperationalError previous = merged.putIfAbsent(sqlState, candidate);
if (previous != null) {
throw new IllegalStateException("Duplicate exact SQLState mapping " + sqlState + ": ...");
}
```
**서로 다른 contributor가 같은 exact SQLState를 등록하면 코드가 같아도 startup을
실패시킨다.** last-writer-wins merge는 매핑 소유권 drift를 숨기니까 허용하지 않는다.
매트릭스:
**core (`StandardSqlStateErrorMapping`) — 모든 RDBMS 공통 5행**
| SQLState | code | category | http | retryable |
|---|---|---|---|---|
| `40001` | `DB_SERIALIZATION_FAILURE` | CONFLICT | 409 | true |
| `23502` | `DB_NULL_VIOLATION` | DATA_INTEGRITY | 409 | false |
| `23503` | `DB_FK_VIOLATION` | DATA_INTEGRITY | 409 | false |
| `23505` | `DB_UNIQUE_VIOLATION` | CONFLICT | 409 | false |
| `23514` | `DB_CHECK_VIOLATION` | DATA_INTEGRITY | 409 | false |
**`08*` prefix → `DB_UNAVAILABLE` (TRANSIENT_DEPENDENCY / 503 / retryable)** 는 맵 엔트리가
아니라 translator가 직접 처리한다. 그래서 core 맵에 `08*`가 없다.
**vendor 기여**
| SQLState | code | vendor |
|---|---|---|
| `40P01` | `DB_DEADLOCK` | PostgreSQL |
| `25P03` | `DB_IDLE_IN_TX_TIMEOUT` | PostgreSQL |
| `57014` | `DB_QUERY_CANCELED` | PostgreSQL |
| `23513` | `DB_CHECK_VIOLATION` | H2 — H2는 CHECK을 23513으로 보고한다 |
| `HYT00` | `DB_QUERY_CANCELED` | H2 — statement/lock timeout을 하나로 합친다 |
H2 행 두 개는 "표준에서 추론한 게 아니라 **실행 중인 H2 2.4.240에서 읽어낸 값**"이라고
명시되어 있다.
**Fallback**: 기여된 어떤 행에도 없는 SQLState — 또는 cause chain에 `SQLException` 자체가
없는 경우 — 는 `Optional.empty()`. 호출부는 원본 예외를 web catch-all까지 전파시켜 detail
누설 없는 generic `INTERNAL` 봉투로 답한다. **translator는 미지의 상태에 대해 `DB_*` 코드를
절대 지어내지 않는다.**
변환된 carrier의 진단 메시지에 SQLState를 넣는 건 **server-log 전용**이다(웹 어댑터가 절대
surface하지 않음).
리포지토리 어댑터의 사용 패턴은 이렇다.
```java
translator.translate(ex).ifPresent(e -> { throw e; });
// 비어 있으면 원본을 다시 던져서 web catch-all이 generic INTERNAL로 답하게 한다
```
카테고리 SSOT는 10값 `Category` enum이고, **`PERSISTENCE` 카테고리는 없다**(branch-note
§Audit CATEGORY_DRIFT).
---
## 5. `springdata` — 쿼리 표면을 좁히는 17개 타입
이 패키지의 주제는 하나다. **caller가 SQL의 어떤 부분도 쓰지 못하게 하되, 필요한 표현력은
등록(registry)으로 준다.**
### 5.1 `JpaRepositoryFragmentSupport` — 없는 게 요점인 base 클래스
```java
public abstract class JpaRepositoryFragmentSupport {
protected final <T> TypedQuery<T> typedQuery(QueryName name, String jpql, Class<T> resultType)
protected final Query nativeQuery(QueryName name, String sql)
protected final <T> T observing(QueryName name, Supplier<T> work)
}
```
주석이 직접 말한다.
> 이 클래스에 **없는 것**을 보라: `save`도, `findById`도, `findAll`도, `delete`도 없다.
> 이건 리포지토리가 아니고 절대 리포지토리가 되어서는 안 된다. Spring Data가 이미 CRUD를
> 구현하고 있고, 플랫폼이 소유한 base repository가 그걸 다시 구현하면 설계가 명시적으로
> 지목하는 실패가 생긴다 — 모든 애그리거트가 강제로 통과해야 하는 generic API, 그리고 한
> 애그리거트의 요구가 전부의 동작을 조용히 바꾸는 단일 지점.
대신 제공하는 건 fragment가 혼자서는 얻을 수 없는 것: **이름 붙은 쿼리**. 모든 쿼리가
등록된 `QueryName`을 들고 다니고, 그게 `org.hibernate.comment` 힌트로 SQL에 실려
`pg_stat_activity`에 나타난다.
### 5.2 `SafeSortMapper` / `SafeSortRegistry` / `SafeSortField`
정렬 파라미터는 **바인딩 값이 아니라 ORDER BY 절의 일부**다. 클라이언트 문자열을 그대로
넘기면 클라이언트가 statement의 일부를 쓴다는 뜻이다.
두 규칙:
1. **모든 필드는 조회된다. 절대 통과되지 않는다.** Spring Data의 `JpaSort.unsafe`
caller 문자열을 ORDER BY에 그대로 넣는다 — 사용자 제어 값에 대해서는 SQL 인젝션 + α.
이 매퍼에는 거기 도달하는 경로가 없다.
2. **tie-breaker가 항상 append된다.** unique 컬럼으로 끝나지 않는 정렬에는 total order가
없고, non-total order 위의 페이징은 조용히 행을 중복시키거나 건너뛴다.
`SafeSortField``entityPath`는 JPA property path 정규식
(`[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*`)으로 검증된다. **등록 실수조차** 함수
호출이나 두 번째 절을 넣을 수 없다.
여기도 버그 기록이 있다.
> 매퍼가 tie-breaker를 생략한 모든 ordering에 `DESC`를 붙였다. 그래서 `createdAt ASC`로
> 정렬하는 엔드포인트가 조용히 `createdAt ASC, id DESC`가 됐다 — 아무도 고르지 않은
> total order이고, 어떤 ascending 커서도 올바르게 페이징할 수 없는 것이다.
그래서 `SafeSortRegistry``tieBreakerDirection`이 생겼다.
`MAX_SORT_TERMS = 4`.
### 5.3 keyset 페이지네이션 실행
`KeysetPredicateBuilder`가 핵심이다. **사전식(lexicographic)** predicate여야 한다.
`(createdAt, id)` ordering에서 "`(t, x)` 이후"는
```sql
createdAt < t OR (createdAt = t AND id < x)
```
이지 `createdAt <= t AND id < x`**아니다.** 두 번째는 그럴듯하게 읽히고 틀렸다 —
`createdAt`이 더 이르면서 `id`가 더 큰 모든 행을 떨어뜨려서, **결과 집합 한가운데의 행을
조용히 삭제한다.**
`KeysetTerm<T>`**term별로** direction과 타입을 갖는다. 이전 시그니처
(`List<KeysetTerm<T>>` + 스캔 전체 `SortDirection` 하나)는 둘 다 표현 못 했다. `(Instant,
UUID)` 커서는 하나의 `T`에 안 들어가고, `createdAt ASC, id DESC`는 하나의 direction에 안
들어간다. 그래서 caller가 하나를 골라야 했고, ordering이 `id < x`를 원하는데
`created_at > t AND id > x`가 만들어져서 **페이지 경계마다 행이 건너뛰거나 반복됐다.**
마지막 term은 반드시 unique 컬럼이어야 한다(생성자가 `terms.size() < 2`를 거부).
`KeysetSliceAssembler``size+1` fetch를 페이지 + next cursor로 조립한다. **next cursor는
반환된 마지막 행에서 뽑는다. 여분 행이 아니다** — 여분 행을 쓰면 다음 페이지에서 그 행을
건너뛴다.
`JpaKeysetQuerySupport`는 이 경로 어디에도 count 쿼리가 없다고 명시한다. keyset이 존재하는
이유가 `OFFSET n`이 DB로 하여금 n행을 걷고 버리게 만드는 것이고, 거기에 `COUNT(*)`를 붙이면
keyset이 없앤 full scan을 도로 넣는 것이다.
### 5.4 `JpaStreamExecutor` / `JpaStreamScope` — 스트림은 커서다
```java
public static void requireActiveReadOnly() {
if (!isActualTransactionActive()) throw ...; // "커서가 첫 statement 후 닫히고 커넥션이 중간에 반납된다"
if (!isCurrentTransactionReadOnly()) throw ...; // "write 트랜잭션은 순회 내내 write 커넥션을 핀한다"
}
```
JPA `Stream`은 살아있는 커서다 — JDBC `ResultSet`을 들고, 그게 statement를 들고, 그게
커넥션을 든다. 셋 다 스트림이 닫히고 트랜잭션이 끝나야 풀린다. 트랜잭션 경계 밖으로
반환하면(리포지토리→컨트롤러) 그 체인이 풀이 눈치챌 때까지 열려 있고, 이건 **무관한 곳의
타임아웃으로 나타나는 커넥션 누수**다.
`consume`은 스트림을 절대 밖으로 내보내지 않는다. try-with-resources로 모든 경로(정상 반환,
예외, early termination)에서 닫는다.
**reactive 반환을 거부**하는 것도 인상적이다.
```java
private static final Set<String> REACTIVE_TYPES = Set.of(
"org.reactivestreams.Publisher", "reactor.core.publisher.Mono",
"reactor.core.publisher.Flux", "io.reactivex.rxjava3.core.Flowable",
"kotlinx.coroutines.flow.Flow", "java.util.concurrent.Flow.Publisher");
```
이유: publisher를 반환하면 컴파일은 되고 런타임에 최악으로 실패한다 — subscriber가
스트림이 닫히고 트랜잭션이 사라진 뒤에 돌아서, 원인에서 멀리 떨어진 closed-cursor 에러로
나타난다. 그리고 **타입 계층 전체를 검사한다**(패키지 이름만 보면 애플리케이션이 직접
선언한 구현체를 전부 통과시킨다). 이름으로 매칭해서 Reactor를 이 블로킹 모듈의 컴파일
클래스패스에서 뺀다.
`ScrollPolicy(fetchSize, maxRows, adminToken)`:
- `fetchSize`가 드라이버의 전체 result set 클라이언트 버퍼링을 막는다. PostgreSQL에서
fetchSize 없는 statement는 첫 행 전에 전부 버퍼링한다 — 스트리밍의 의미가 사라진다.
- `maxRows`는 별개의 "끝난다"는 보장.
- `MAX_NON_ADMIN_ROWS = 100_000`. 그 이상은 명시적 admin 토큰 필요.
여기도 버그 기록이 있다. supplier가 원래 plain `Supplier`여서 executor가 든 fetch size가
스트림을 여는 쿼리에 **닿지 않았다**. 그래서 `Function<ScrollPolicy, Stream<T>>`로 바뀌었다.
### 5.5 fetch plan과 specification 정책
`EntityGraphCatalog`**factory를 저장한다**(`Function<EntityManager, EntityGraph<?>>`).
graph는 자기를 만든 `EntityManager`에 속하고 공유할 수 없기 때문.
`FetchPlanApplier``fetchgraph`/`loadgraph`를 **별도 메서드**로 나눈 이유가 좋다.
> fetch graph는 exhaustive다 — 매핑이 뭐라 하든 그 밖의 attribute는 lazy. load graph는
> additive다 — 나열되지 않은 attribute는 매핑된 기본값. 잘못 고르면 데이터가 빠지거나,
> graph가 피하려던 증폭이 생긴다.
그리고 왜 mapping에서 `EAGER`를 안 쓰나: 그 방법은 그게 필요했던 한 유스케이스를 고치고 **id만
원했던 것 포함 그 엔티티에 대한 다른 모든 쿼리에 추가 조인을 부과한다.**
`SpecificationPolicy`:
> predicate 없는 specification은 builder 옷을 입은 full table scan이다. 보통 모든 optional
> 필터가 없을 때 나온다 — 빈 검색 화면 제출 — 그리고 **어느 한 줄도 틀리지 않았기 때문에**
> 코드 리뷰에서 무해해 보인다.
`ALLOW_UNBOUNDED_TOKEN = "allow-unbounded-scan"` 명시 opt-in, 그리고 `pageable.isUnpaged()`
거부.
### 5.6 `RegisteredQuery` — 지워진 가드의 기록
여기 좋은 자기비판이 남아 있다.
> 여기 source-token 검사는 없다. 예전에는 완성된 statement에서 `"' +"`(Java 문자열 연결
> 연산자)를 찾았는데, 그건 **컴파일러가 이미 평가해서 제거한** 뒤에 어떤 문자열이든 이
> 생성자에 도달한다. 그 가드는 문자 그대로 그 문자를 담은 statement에만 발동할 수 있었고,
> 방지하려던 동적 구성에는 절대 발동하지 못했다.
>
> statement는 등록된 카탈로그에서 오고 파라미터는 typed binder로 받는다. **동적 구성 금지는
> source에 대한 규칙이고, ArchUnit과 리뷰가 source 규칙이 사는 곳이다.**
같은 문구가 `WorkQueueDefinition`에도 반복된다. 런타임 정규식으로 소스 규칙을 흉내 내려다
실패한 걸 지우고 기록으로 남긴 것.
---
## 6. `hibernate` — 프로바이더를 측정 가능하게 만들기
### 6.1 statement에 이름 붙이기
`QueryNameContext`(ThreadLocal) + `NamedStatementInspector`(`StatementInspector` 구현).
```java
return "/* " + value + " */ " + sql;
```
이 주석이 SQL과 함께 `pg_stat_activity`, `auto_explain` 출력, slow-query 로그로 흘러간다.
없으면 "이 쿼리를 어느 엔드포인트가 쏘는가"를 코드베이스에서 SQL 조각을 grep해서 답해야 한다.
**comment terminator `*/`를 거부**한다 — 이름이 주석을 닫고 SQL로 이어질 수 없게.
`QueryNameContext.with(name, work)`는 clear가 아니라 **이전 이름을 복원**한다. 중첩 호출
때문 — 바깥 named query 안에서 helper query를 쏘는 fragment가 안쪽이 끝났을 때 바깥
statement를 unattributed로 남기면 안 된다.
### 6.2 통계와 JDBC batch 카운터
`HibernateStatisticsCollector.snapshot()`은 statistics가 꺼져 있으면 **던진다.**
> 비활성 registry를 조용히 측정하는 fetch 테스트는 아무것도 증명하지 않고 영원히 통과한다.
`HibernateStatisticsSnapshot`이 entity **load**와 entity **fetch**를 따로 갖는 이유:
> load는 Persistence Context에 hydrate된 행이고, fetch는 association을 만족시키려고 발행된
> 별도 statement다. 한 statement로 100개를 hydrate한 쿼리와 100개의 statement를 쏜 쿼리는
> **같은 load count를 보고할 수 있고**, fetch count만이 둘을 구별한다 — 그게 바로 N+1이다.
`jdbcBatches`는 Hibernate `Statistics`에 없어서 **JDBC 층에서** 센다(`JdbcBatchCounter`,
모든 `PreparedStatement.executeBatch()`).
> `hibernate.jdbc.batch_size`를 설정하는 건 아무것도 증명하지 않는다. IDENTITY generator,
> 중간에 낀 select, 잘못된 순간의 flush가 배칭을 조용히 끄는데 설정은 여전히 켜져 있다고
> 말한다.
기본 구현은 0을 반환하고 `instrumented() == false`라고 **말한다**. 계약 suite는 진짜 카운터를
설치한다.
### 6.3 `HibernateProviderPolicy` — 선언 baseline과 런타임 버전을 분리
```java
public static final String DECLARED_STABLE_BASELINE = "7.4";
public String runtimeVersion() // org.hibernate.Version.getVersionString()
public boolean driftsFromDeclaredBaseline()
```
설계는 Hibernate 7.4를 Stable로 고정한다. 이 저장소의 Spring Boot BOM은 Boot가 관리하는
버전(현재 **7.1.8.Final**)을 resolve하고, 설계는 BOM 밖에서 프로바이더 버전을 pin하지 말라고
똑같이 명확하다. 둘 다 지킬 가치가 있으니 **조용히 하나를 고르는 대신 둘을 분리해서 든다.**
> 대안 — 선언된 baseline을 런타임인 것처럼 assert하는 것 — 은 collection-fetch-pagination
> 게이트가 전혀 다른 프로바이더로 도는 동안 **상수가 자기 자신과 같다는 걸 증명하는 초록불**을
> 준다.
### 6.4 `hibernate.batch` — flush/clear 경계
`HibernateJpaBatchExecutor`의 핵심은 flush/clear 쌍이다. flush만 하면 statement는 나가고
엔티티는 남아서 **이미 persist된 행으로 힙이 찬다** — 고전적 bulk-import OOM.
여기 정말 좋은 버그 기록이 있다.
> clear는 모든 managed 엔티티를 detach하고, **flush되지 않은 detached 엔티티는 그냥
> 사라진다**: INSERT가 발행되지 않고 아무도 문제를 보고하지 않는다. `flushSize=100`,
> `clearSize=150`이면 150에서의 clear가 flush 조건이 false일 때 도착하고, **101150행이
> 유실**된 채 executor는 `processed=300`을 반환하고 테이블에는 250개가 있었다.
그래서 지금은 **clear가 항상 먼저 flush한다.** 두 knob은 독립으로 남고(그게 API가 제공하는
것), 독립을 안전하게 만드는 불변식이 caller의 배수 선택이 아니라 여기서 강제된다.
`HibernateBatchConfigurationGuard``GenerationType.IDENTITY`를 거부한다.
> identity 컬럼 값은 insert 시 DB가 배정하므로, Hibernate는 Persistence Context에 넣을 id를
> 알기 위해 각 insert를 즉시 실행해야 한다 — 보내지 않은 statement를 큐에 넣을 수 없다.
> 그래서 `hibernate.jdbc.batch_size`가 뭐라 하든 배칭이 **조용히** 꺼진다.
>
> "조용히"가 문제다. 설정은 맞아 보이고, import는 돌고, 유일한 증상은 예상보다 한 자릿수
> 느리다는 것이다.
`@Id` 필드를 찾을 때 **superclass를 걷는다** — mapped superclass가 식별자를 들고 있는 게 흔한
모양이고, 선언 클래스에서 멈추면 이 가드가 쓰여진 바로 그 엔티티들을 전부 "identity 아님"으로
분류한다.
`executor`는 활성 트랜잭션을 요구하고 절대 열지 않는다. 각 flush가 auto-commit하게 두면
실패 시 **롤백 방법 없이 부분 import된 데이터셋**이 남는다.
### 6.5 `hibernate.bulk` — flush → statement → clear
```
flush 먼저: 안 하면 pending managed 변경이 bulk statement '뒤에' 쓰여서 덮어쓴다.
clear 나중: 안 하면 managed 엔티티가 pre-bulk 값을 계속 제공하고,
나중 flush가 그 stale 값을 bulk가 방금 한 것 위에 쓴다.
```
`AffectedRowsExpectation`은 **blast-radius 가드**다.
> `WHERE` 절을 잃은 bulk `UPDATE`는 여전히 성공한다 — 그냥 테이블 전체를 업데이트할 뿐이고,
> 아무도 눈치채기 전에 트랜잭션이 커밋된다.
검증은 caller 트랜잭션 안에서 일어나서 롤백이 여전히 가능하다.
bulk DML은 Persistence Context를 완전히 우회한다 — 엔티티 콜백 없음, `@Version` 체크 없음,
managed 엔티티 갱신 없음. **그게 부수적 한계가 아니라 bulk DML이 빠른 이유**다.
### 6.6 `hibernate.stateless` — 유일하게 트랜잭션을 소유하는 executor
```java
try (StatelessSession session = sessionFactory.openStatelessSession()) {
Transaction transaction = session.beginTransaction();
...
}
```
이게 이 플랫폼에서 executor가 애플리케이션 트랜잭션에 참여하지 않고 **자기 트랜잭션을
소유하는 유일한 곳**이다. 경계 규칙의 예외라기보다 **귀결**이다: stateless session은 JPA
트랜잭션에 join할 수 없고(다른 session, 다른 커넥션), 아닌 척하면 caller가 믿는 트랜잭션
밖에서 조용히 돈다.
`StatelessWorkResult(value, affectedRows)`가 생긴 이유도 버그다.
> runner가 `maxRows` 인자를 받아서 양수인지 확인하고는 **아무것과도 비교하지 않았다** — cap은
> caller가 공급하고 아무도 강제하지 않는 파라미터였다. stateless session은 정확히 Persistence
> Context가 감당 못 할 만큼 많은 행을 건드릴 수 있는 작업을 위해 존재하니, "몇 개를
> 건드렸나"가 bound가 말하는 유일한 질문이다.
초과하면 **먼저 롤백하고** `StatelessRowCapExceededException`을 던진다.
---
## 7. `postgresql` — 벤더 패키지 전부
### 7.1 SQLSTATE 분류
`PostgreSqlState` enum이 구조적으로 분류하는 13개 상태:
| code | 이름 | → FailureCategory |
|---|---|---|
| `40001` | serialization_failure | SERIALIZATION_FAILURE |
| `40003` | statement_completion_unknown | COMPLETION_UNKNOWN |
| `40P01` | deadlock_detected | DEADLOCK |
| `23505` | unique_violation | UNIQUE_CONSTRAINT |
| `23503` | foreign_key_violation | FOREIGN_KEY_CONSTRAINT |
| `23514` | check_violation | CHECK_CONSTRAINT |
| `23502` | not_null_violation | NOT_NULL_CONSTRAINT |
| `55P03` | lock_not_available | LOCK_NOT_AVAILABLE |
| `57014` | query_canceled | QUERY_TIMEOUT |
| `25P03` | idle_in_transaction_session_timeout | TRANSACTION_TIMEOUT |
| `42P01` | undefined_table | SCHEMA_MISMATCH |
| `42703` | undefined_column | SCHEMA_MISMATCH |
| `42501` | insufficient_privilege | **UNKNOWN** |
`08*` prefix는 `CONNECTION_UNAVAILABLE`. 그리고 **의도적으로 completion-unknown이 아니다**
커넥션 유실이 커밋을 불명으로 남겼는지는 트랜잭션 phase에 달렸고, 그건 `CommitFailureClassifier`
안다.
미인식 상태는 `UNKNOWN`이고, 이웃 카테고리로 **추측하지 않는다.**
> 미지의 상태를 직렬화 실패로 분류하면 재시도 코디네이터가 **이미 성공한 쓰기를 기꺼이
> 다시 돌린다.**
`PostgreSqlExceptionTranslator`에서 retryable로 표시할 수 있는 카테고리는
`SERIALIZATION_FAILURE`, `DEADLOCK`, `OPTIMISTIC_CONFLICT` **셋뿐**이다.
### 7.2 `PostgreSqlServerErrorFields` — 리플렉션으로 구조적 필드 읽기
드라이버가 `runtimeOnly` 의존이라 `PSQLException`/`ServerErrorMessage`가 컴파일
클래스패스에 없다. 그래서 리플렉션으로 `getServerErrorMessage().getConstraint()` /
`.getTable()`을 읽는다.
대안은 메시지 텍스트에서 constraint 이름을 파싱하는 것인데, 그건 설계 §18.2가 금지하는
바로 그것이다 — **메시지는 localized이고 서버 버전마다 바뀌므로, 텍스트 파서는 `lc_messages`
다른 서버에서 조용히 아무것도 반환하지 않는다.**
모든 lookup은 `Optional.empty()`로 degrade한다. 드라이버 부재/shaded 드라이버/accessor
rename이 **에러 리포팅을 두 번째 에러로 만들면 안 된다.**
### 7.3 `postgresql.lock` — SKIP LOCKED 작업 큐
`WorkClaimExecutor`가 **일반 "skip locked reads" 스위치가 아니라 큐 API**라는 게 핵심이다.
> `FOR UPDATE SKIP LOCKED`는 의도적으로 테이블의 **불완전한 뷰**를 반환한다. 경쟁하는
> worker에게 disjoint한 작업을 나눠주는 데는 옳고, **모든 매칭 행을 봐야 하는 것에는 조용히
> 틀리다.** 리포지토리 플래그로 노출하면 두 번째 용도가 사고로 등장하니, 이름 붙은 큐를
> 통해서만 도달할 수 있게 한다.
`WorkQueueDefinition` 생성자가 등록 SQL을 검사한다.
- `skip locked` 없으면 거부 — 없으면 worker들이 서로를 블록한다(claim 쿼리의 정반대).
- `order by` 없으면 거부 — 없으면 PostgreSQL이 임의 순서로 반환해서 우선순위가 plan에 따라
결정되고 starvation이 재현 불가능해진다.
`PostgreSqlWorkClaimExecutor`는 batch size / lease expiry를 **파라미터로 바인딩**하고 queue
name은 **statement를 선택**할 뿐 interpolate되지 않는다. lease는 여기서 계산해서 claim을
잡는 **같은 statement가 쓴다** — 나중 statement가 lease를 세팅하면 "이 트랜잭션이 잠갔지만
아무도 소유하지 않은 것으로 표시된" 창이 생긴다.
`MAX_BATCH_SIZE = 1_000`.
단일 컬럼 프로젝션 정규화도 있다.
```java
private static Object[] toColumns(Object row) {
return row instanceof Object[] columns ? columns : new Object[] {row};
}
```
> JPA는 statement가 한 컬럼만 프로젝션하면 1-원소 배열이 아니라 bare value를 반환한다.
> `Object[]`로 쓴 매퍼는 **단일 컬럼 큐에서만** 실패한다 — 가장 틀리기 쉽고 가장 알아채기
> 어려운 모양.
`PostgreSqlLockOptions``NOWAIT`와 wait timeout을 **별도 필드**로 모델링한다.
> 하나의 필드와 magic zero로 모델링하는 게 "no wait"가 조용히 "wait forever"가 되는 방식이다.
무한 타임아웃은 금지. 두 케이스는 **hint 경계에서만** 수렴한다(`jakarta.persistence.lock.timeout`
0이 JPA가 정의한 `NOWAIT` 인코딩).
`PostgreSqlLockExceptionTranslator``55P03`(statement-level, 트랜잭션 살아 있음, terminal)과
`40P01`(transaction-level, 서버가 이미 롤백함, retryable)을 분리한다.
> 둘을 합치면 — generic "lock error"가 그렇듯 — 커밋할 수 없는 살아있는 트랜잭션 위에서
> 재시도 루프를 돌거나, 계속 진행할 수 있었던 트랜잭션을 불필요하게 abort시킨다.
SQLSTATE가 chain에서 살아남지 못한 경우를 위해 `jakarta.persistence.LockTimeoutException` /
`PessimisticLockException`도 인식한다(Hibernate가 lock timeout 힌트를 스스로 강제할 때 발생).
### 7.4 `postgresql.write` — 등록된 업서트
`RegisteredUpsertStatement` 생성자가 `insert into` + `on conflict`를 요구한다.
`RegisteredPostgreSqlUpsertExecutor`가 statement 앞뒤로 하는 두 가지:
- **앞: flush.** native statement는 DB로 직행하므로 flush되지 않은 pending managed 변경이
upsert *뒤에* 쓰여서 덮어쓴다.
- **뒤: clear.** upsert가 context가 이미 든 행을 바꿨고 Hibernate는 알 방법이 없다.
`WriteDisposition`(INSERTED / UPDATED / UNDETERMINED)은 **business-visible**하다 — "생성됨"과
"이미 있어서 갱신됨"은 보통 다른 이벤트를 낸다. 판정은 PostgreSQL의 `(xmax = 0) AS inserted`
프로젝션으로 하고, statement가 그걸 생략하면 **가정하지 않고 UNDETERMINED**를 보고한다.
`ON CONFLICT DO NOTHING`이 행을 반환하지 않으면 `UPDATED`가 아니라 `undetermined(null)`이다.
> 쓰기가 일어나지 않았고, 그걸 update로 보고하면 caller에게 존재하지 않는 상태의 행이
> 있다고 말하는 것이다.
`UpsertConflictTarget`은 conflict/update 컬럼을 unquoted PostgreSQL identifier
정규식(`[a-z_][a-z0-9_]{0,62}`)으로 검증한다. 빈 update 컬럼 집합은 거부 — `DO UPDATE SET`
아무것도 없는 건 유효한 SQL이 아니고, 보통 의도하는 `DO NOTHING`은 반환이 다른 별개의
statement라 따로 등록해야 한다.
### 7.5 `postgresql.json` / `.array` / `.range`
**JSONB**: `JsonDocument(schema, version, payload)`가 envelope다.
> JSONB 컬럼은 DB 레벨에서 schema-less라서, envelope 없이는 저장된 문서의 의미를 기록한 게
> **그걸 쓴 코드뿐**이다 — 그러면 두 릴리스 전에 쓴 문서와 지금 것을 구별할 수 없다.
그리고 payload는 Java 클래스 이름을 절대 담지 않는다.
> 문서 안의 타입 메타데이터는 JSONB 컬럼을 **역직렬화 가젯**으로 만든다: 행을 쓸 수 있는
> 자가 reader가 인스턴스화할 클래스를 고른다.
`@class` 키가 있으면 재귀적으로 찾아서 생성자가 거부한다.
`JsonDocumentCodec`은 envelope 필드를 **저장 객체의 평범한 멤버**로 쓴다. 그래서 schema와
version을 payload와 같은 JSONB 연산자로 질의할 수 있다 — "version 1에 아직 남아 있는 모든
문서"를 찾는 마이그레이션이 애플리케이션을 통한 full scan이 아니라 평범한 인덱스 쿼리가 된다.
decode 실패는 **offending value를 하나도 담지 않는** `DataCorruptionException`이다.
`PostgreSqlJsonQuerySupport`의 보안 속성이 명확하다.
> **path는 registry에서 오고 고정 statement의 일부다. value는 caller에서 오고 항상 bound
> parameter다.** JSON path는 parameterize할 수 없으니 path를 caller에서 받는 설계는 입력을
> SQL에 연결하는 것이고, value는 할 수 있으니 안 할 이유가 없다.
`@>` containment를 쓰는 이유는 그게 JSONB 컬럼의 **GIN 인덱스가 실제로 답할 수 있는 것**이기
때문(`->>` 동등 비교가 아니라).
**배열**: `Connection.createArrayOf`로 만든다. 손으로 literal을 포맷하는 건 quoting 버그가
사는 곳이고, 콤마/중괄호/백슬래시를 담은 태그가 배열의 **내용이 아니라 모양**을 바꾸며 실패가
조용하다. 지원 element type은 `text varchar uuid int4 int8 numeric boolean timestamptz` 8개.
**범위**: `PgRange<T>(lower, lowerInclusive, upper, upperInclusive)`.
> 범위를 두 개의 평범한 컬럼으로 모델링하면 정확히 중요한 정보를 잃는다: `[09:00, 10:00)`과
> `[10:00, 11:00)`이 겹치는지는 **값이 아니라 bracket**에 달렸고, `timestamptz` 컬럼 쌍은
> 그걸 말할 수 없다.
inverted range는 Java에서 거부한다 — PostgreSQL도 에러를 내지만 그때는 이미 statement가
전송됐고 트랜잭션이 오염됐고 caller가 실수 지점의 argument 에러 대신 SQLSTATE를 받는다.
`PgRangeCodec`의 두 디테일:
1. **endpoint 함수를 caller가 공급한다.** PostgreSQL의 text form은 ISO-8601이 아니다 —
`tstzrange``1970-01-01 00:00:00+00`(T가 아니라 공백)로 돌아온다. `Instant::parse`
쓴 파서는 이 codec만 문자열을 만진 단위 테스트에서는 round-trip하고 **진짜 서버가 답하는
순간 던진다.**
2. **`empty`를 거부한다.** PostgreSQL은 값을 담을 수 없는 범위를 literal `empty`로 정규화하고,
그건 endpoint가 아예 없다. 진짜 값이지 파싱 실패가 아니고, unbounded range로 읽으면
**의미가 완전히 반전된다.**
3. separator 탐색이 quote를 인식한다. 순진한 `indexOf(',')`는 endpoint 값에 콤마가 있는
순간 엉뚱한 곳을 자르고 **에러가 아니라 조용히 틀린 bound**를 만든다.
`PgRangeJdbcType``Types.OTHER`로 literal을 보낸다. 그래야 서버가 `tstzrange`,
`daterange` 등 어떤 range type이든 컬럼 선언 타입에 따라 파싱한다. plain string으로 보내면
서버가 `text`로 보고 cast 에러로 실패한다.
### 7.6 `postgresql.copy` — 관리자 전용 벌크 로더
`COPY`는 J4 admin capability다. Persistence Context, 엔티티 콜백, 낙관적 버전 체크, Envers를
**전부 우회한다.**
`RegisteredCopyStatement` 생성자가 강제하는 두 속성:
- `from stdin`이어야 한다.
- `COPY ... TO`면 안 된다.
> `COPY ... FROM '/path'`는 **DB 서버의 파일**을 서버 OS 사용자로 읽고, `COPY ... TO '/path'`는
> 하나를 쓴다. 정확히 그 이유로 둘 다 superuser 전용이고, 어느 쪽도 애플리케이션 API 뒤에
> 있으면 안 된다.
`CopyLimits(maxRows, maxBytes, timeout)` 셋 다 필수.
`BoundedCopyInputStream`**드라이버가 읽는 동안** 센다.
> source 크기를 미리 확인하는 건 옵션이 아니다 — `COPY` source는 스트림이고 길이는 보통 끝날
> 때까지 모른다. 드라이버가 읽는 동안 세는 게 limit을 진짜로 만든다.
예외 타입이 `IOException`인 것도 의도다 — 드라이버가 이 스트림을 읽고 있고, 그 read loop
안에서 던져진 unchecked 예외는 예상하지 못한 코드를 관통해 **커넥션에 copy 프로토콜을
반쯤 끝난 상태로 남긴다.**
timeout은 **서버 사이드** `statement_timeout`으로 건다.
> 클라이언트 사이드 timeout은 서버가 계속 행을 쓰는 동안 호출을 버린다. `statement_timeout`은
> 서버 자신이 멈추게 하고, 그게 작업을 실제로 멈추는 유일한 버전이다.
그리고 `SET statement_timeout = ?`가 아니라 `set_config('statement_timeout', ?, true)`
이유가 여기도 나온다 — `SET`은 파라미터 바인딩 전에 파싱되므로 placeholder가 syntax error다.
로더는 JPA 커넥션을 빌리지 않고 **자기 `DataSource`**를 받는다. `COPY`는 bulk/admin
credential로 도는데, 그건 설계상 런타임 롤과 다른 롤이다 — 런타임 롤은 DDL이 없음이
검증되고, mass-insert를 해야 할 롤도 아니다.
`CopyAdminCapability(operator, reason)`은 boolean 프로퍼티가 아니라 **capability 객체**다.
> 그러면 운영자와 이유가 호출과 함께 이동해서, 로드가 익명 벌크 쓰기가 아니라 **누군가의
> 행동**으로 감사 기록에 나타난다.
`CopyFormat``BINARY`가 없다 — PostgreSQL 내부 타입 표현을 인코딩해서, 한 서버 버전에서
로드되는 파일이 다른 버전에서 조용히 오독될 수 있다.
### 7.7 벤더 선택과 H2 대칭
두 벤더 composition이 각각 **같은 네 개의 SPI 빈**을 등록한다.
| SPI | PostgreSQL | H2 |
|---|---|---|
| `OutboxClaimRepository` | `PostgreSqlOutboxClaimRepository` | `H2OutboxClaimRepository` |
| `IdempotencyClaimRepository` | `PostgreSqlIdempotencyClaimRepository` | `H2IdempotencyClaimRepository` |
| `SqlStateErrorMapping` | `PostgreSqlSqlStateErrorMapping` | `H2SqlStateErrorMapping` |
| `TransactionLocalTimeoutConfigurer` | `PostgreSqlLocalTimeoutConfigurer` | `H2LocalTimeoutConfigurer` |
선택자는 `ca-skeleton.persistence.vendor` (`PersistenceVendorSettings`, enum 바인딩).
> enum 바인딩이 미지의 벤더를 **startup 실패**로 만든다. raw string이면 두
> `@ConditionalOnProperty` 벤더 config가 **둘 다 꺼진 채로 남고**, 첫 번째 누락 SPI 빈이
> `OutboxClaimRepository`를 명명한 `NoSuchBeanDefinitionException`으로 나타난다 — 원인이 된
> 오타에서 몇 층 떨어진 증상.
부재는 PostgreSQL이다(`matchIfMissing = true`) — 선택자가 생기기 전 모든 배포가 돌던 벤더.
**두 statement가 갈라지고, 이유는 추정이 아니라 측정이다 (H2 2.4.240).**
- **outbox claim은 동일하다.** H2가 `FOR UPDATE SKIP LOCKED`를 받아들이고 잠긴 행을 진짜로
건너뛴다. 한 커넥션이 row lock을 든 상태에서 동시 `SKIP LOCKED` claim이 블록하거나 락을
뚫고 읽는 게 아니라 **0행을 반환**하는 걸 확인했다. 그래서 claim이 의미를 유지한다.
- **idempotency claim은 다르다.** H2에는 `INSERT ... ON CONFLICT ... RETURNING`이 없어서
같은 세 결과를 갖는 `MERGE ... USING`이다. `H2ClaimSqlTest`가 둘 다 실제 H2에 실행한다.
H2의 `MERGE`가 세 결과를 어떻게 만드는지:
| 상황 | 분기 | 반환 |
|---|---|---|
| scope에 행 없음 | `WHEN NOT MATCHED` insert | 1 |
| live 행 | 어느 분기도 발동 안 함 | 0 (live winner에게 짐) |
| expired 행 | `WHEN MATCHED AND expires_at <= now` | 1 (takeover) |
한 statement인 게 SPI의 약속을 지킨다 — **claim 실패가 constraint violation이 아니라 0행**이라
주변 트랜잭션을 오염시키지 않는다.
`RETURNING`이 필요 없는 이유도 적혀 있다: PostgreSQL statement가 반환하는 `EXCLUDED.id`
양쪽 분기에서 proposed id이므로, claim된 행은 항상 이 caller의 proposed id다.
**H2 vendor config에는 Flyway location customizer가 일부러 없다.** H2 등가 트리가 없고,
local profile이 Flyway를 끄고 Hibernate가 엔티티에서 스키마를 만들게 하기 때문. 두 가지
귀결이 명시되어 있다.
- 마이그레이션에만 있는 테이블 — capability schema registry, polling-delivery/inbox 스트림,
Spring Integration lock 테이블 — 은 H2에서 생성되지 않는다.
- Flyway를 켠 fork는 location override가 없어서 `classpath:db/migration`으로 fallback하고
**H2가 파싱할 수 없는 PostgreSQL DDL을 포함한 트리 전체를 걷는다.**
그리고 PostgreSQL vendor의 Flyway customizer에도 좋은 수정 기록이 있다.
> 무조건 `locations(...)`를 호출했는데, 그건 Spring이 `spring.flyway.locations`에서
> 바인딩한 것을 **대체**한다. 그래서 운영자가 `SPRING_FLYWAY_LOCATIONS`로 capability
> 스트림을 추가하고, Flyway가 성공적 마이그레이션을 보고하는 걸 보고, **벤더 스트림만**
> 얻을 수 있었다 — 프로퍼티는 읽히고 바인딩되고 그 뒤에 도는 customizer가 버렸다.
> `local-notification-ingest` 레인은 7개 location을 세팅하고 1개를 적용했다.
지금은 "아무도 고르지 않았을 때만 기여하고, 누군가 골랐으면 비켜선다".
---
## 8. `migration` — Flyway가 스키마의 주인
### 8.1 정책과 게이트
`FlywaySchemaPolicy`가 프로필별 모드를 정한다.
| 프로필 | 모드 |
|---|---|
| local / test / dev | `MIGRATE_ON_STARTUP` |
| staging / prod | `VALIDATE_ONLY` |
| (fallback) | `VALIDATE_ONLY` |
> local/test/dev는 startup에 마이그레이션해도 된다: 루프가 빠르고 자격증명이 로컬이다.
> staging/prod는 대신 **배포 파이프라인 소유 마이그레이션**을 지원한다. 애플리케이션 안에서
> 마이그레이션한다는 건 rolling deploy의 모든 인스턴스가 같은 스크립트를 적용하려고 경주하고,
> **패자의 실패가 진짜 실패와 구별되지 않는다**는 뜻이기 때문.
`ddl-auto` 허용값은 `none`, `validate` 둘뿐. `update`/`create`/`create-drop`은 거부.
**Repair는 모드가 아니다.** 운영자가 의도적으로 호출하는 operation descriptor로만 존재하고,
startup 동작으로는 절대 아니다.
`FlywayValidationGate`도 절대 repair하지 않는다.
> Flyway의 `repair`는 schema history 테이블을 지금 디스크에 있는 스크립트에 맞게 다시 쓴다.
> 이건 **증거를 지워서 증상을 해결**한다: checksum mismatch는 배포된 스크립트가 적용된 것과
> 다르다는 뜻이고, 흥미로운 질문은 **어떤 변경이 이 DB에 빠졌는가**다. Repair는 그 질문을
> 물을 수 없게 만들어서 답한다.
예외에는 **Flyway의 구조적 error code만** 넣는다. validation 메시지는 스크립트 경로와, 실패한
마이그레이션의 경우 statement 일부를 담는데, 둘 다 클라이언트에 도달하는 에러에 들어가면 안 된다.
### 8.2 `ConcurrentIndexMigrationInspector`
PostgreSQL은 `CREATE INDEX CONCURRENTLY`를 트랜잭션 블록 안에서 아예 거부하고, Flyway는
기본적으로 마이그레이션을 트랜잭션으로 감싼다. **그래서 첫 배포에서 실패하는데, 그게 좋은
경우다.**
> 나쁜 경우는 다른 statement와 섞인 마이그레이션이다: 그것들은 커밋되고, concurrent index는
> 안 되고, 스키마는 **history row가 반대로 말하는 채로 부분 마이그레이션 상태로 남는다.**
검사 대상: `create index concurrently`, `drop index concurrently`, `reindex index concurrently`,
`reindex table concurrently`, `reindex concurrently`.
`requireIsolatedStatement`도 있다 — concurrent 빌드가 실패하면 invalid index가 남고, 그
복구는 마이그레이션이 다른 걸 아무것도 안 했으면 `DROP INDEX` 하나지만 뭔가 했으면 부분
상태를 손으로 재구성해야 한다.
`FailedConcurrentIndexRecovery``pg_index.indisvalid = false`를 조회해서 리포트한다.
**자동으로 지우지 않는다.**
> invalid index는 **빌드가 아직 돌고 있다**는 뜻일 수도 있고, 카탈로그만으로는 둘을 구별할 수
> 없다. 자동 drop은 곧 끝나려던 concurrent 빌드와 경주하게 되고, rolling deploy의 모든
> 인스턴스에서 동시에 그렇게 한다.
리포트는 운영자가 돌릴 statement를 준다(그 자체가 non-transactional인 `DROP INDEX CONCURRENTLY`).
### 8.3 실제 마이그레이션 트리 — 8개 독립 스트림
여기가 이 모듈에서 제일 배울 게 많았던 부분이다.
```
db/migration/postgresql/ ← 기본 location, flyway_schema_history (legacy/adoption)
db/migration/jpa/core/ ← flyway_jpa_core_history (독립)
db/migration/jpa/idempotency/ ← flyway_jpa_idempotency_history (독립)
db/migration/jpa/outbox-storage/ ← flyway_jpa_outbox_storage_history (독립)
db/migration/jpa/outbox-polling/ ← flyway_jpa_outbox_polling_history (독립)
db/migration/jpa/inbox/ ← flyway_jpa_inbox_history (독립)
db/migration/jpa/fileserver/ ← (capability 스트림) (독립)
db/migration/jpa/notification-platform/ ← flyway_jpa_notification_history (독립)
db/experimental-rls/ ← Stable location이 절대 적용하지 않음
```
**왜 독립 스트림인가**가 `NotificationSchemaStream` javadoc에 제일 명확하게 적혀 있다.
> 마이그레이션이 `db/migration/jpa/notification-platform`에 있는데 primary location은
> `db/migration/postgresql`이라 아무것도 적용하지 않았다. 뻔한 해법 — 디렉터리를 primary
> location 목록에 추가 — 이 틀린 이유: **두 트리가 다 V1부터 번호를 매기고**, 공유 history
> 테이블은 `V1__notification_platform_core`와 `V1__initial_schema`를 같은 버전으로 만든다.
> Flyway는 두 번째를 거부하거나, 더 나쁘게는 resolution 순서에 따라 하나를 기록하고 하나를
> 건너뛴다.
독립 스트림 + 자기 history 테이블이 두 버전 계열을 독립적으로 유지하고, **그게 capability를
진짜로 optional하게 만든다.**
`baselineVersion("0")` + `baselineOnMigrate(true)`가 필수인 이유도 좋다.
> 이 스트림은 core 스트림이 이미 채운 스키마 위에 자기 history 테이블을 갖는다. 그래서
> Flyway가 "history 테이블 없는 비어있지 않은 스키마"라고 시작을 거부한다. version 0
> baseline은 **모든 notification 마이그레이션이 여전히 돈다**는 뜻이다 — 더 높은 버전
> baseline이면 그것들을 건너뛰는데, 그게 이 설정이 보통 두려워하는 실패 모드다.
그리고 `cleanDisabled(true)`, `validateOnMigrate(true)`, `outOfOrder(false)`.
### 8.4 `capability_schema_registry` — 독립 스트림의 접착제
독립 스트림 설계의 핵심 테이블이다.
```sql
CREATE TABLE capability_schema_registry (
capability_id varchar(128) NOT NULL,
schema_stream varchar(128) NOT NULL, -- V2/V9에서 32 → 128로 확장
installation_origin varchar(32) NOT NULL, -- FRESH | LEGACY_ADOPTED
core_epoch integer NOT NULL,
feature_revision integer NOT NULL,
lifecycle_state varchar(32) NOT NULL, -- INSTALLED_INACTIVE | ACTIVE
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
...
);
```
각 capability 스트림의 V1이 하는 일이 항상 같은 패턴이다.
1. **선행조건 검사**`DO $$ ... RAISE EXCEPTION ... $$`로 core epoch가 ACTIVE인지,
legacy adoption이면 호환 테이블이 있는지 확인.
2. 테이블/인덱스/트리거 생성.
3. **레지스트리에 자기를 `INSTALLED_INACTIVE`로 등록.**
즉 **스키마가 적용된 것과 capability가 사용 승인된 것이 분리**되어 있다. 어댑터들이 런타임에
이걸 확인한다.
```sql
select count(*) from capability_schema_registry
where capability_id = 'jpa-idempotency-owner-safe-v2'
and core_epoch = 1 and feature_revision = 2 and lifecycle_state = 'ACTIVE'
```
`schema_stream` 컬럼 확장(V2/V9)의 기록이 아주 좋다.
> 컬럼이 `varchar(32)`였고, 작성 당시 존재하던 모든 스트림에 맞았고,
> `'db/migration/jpa/notification-platform'`(38자)에서 안 맞기 시작했다. 실패 모드가 나쁜
> 종류다: 모든 면에서 올바른 capability 등록이 `value too long for type character varying(32)`로
> 마이그레이션 타임에 실패하고, **뻔한 우회책은 스트림의 경로가 아닌 더 짧은 경로를
> 기록하는 것** — 스키마가 어디서 왔는지에 대해 거짓말하는 레지스트리는 레지스트리가 없는
> 것보다 나쁘다.
그리고 왜 하필 128인가: `capability_id``varchar(128)`이고 둘 다 같은 종류의 bounded
identifier이며, **하나의 bound가 두 개보다 추론하기 쉽기 때문.**
같은 확장이 `db/migration/postgresql/V9`에도 있고, 번호가 **V9인 이유**가 또 재밌다.
> V9이지 V8이나 V7이 아니다. `db/migration/postgresql`과 `db/sample-migration`이 sample
> composition root에 의해 하나의 Flyway location 목록으로 합쳐져서 **하나의 버전 공간을
> 공유**한다: sample이 이미 V2, V7, V8을 소유한다. 중복 버전은 Flyway가 해결하는 merge
> conflict가 아니라 — **아예 시작을 거부한다.**
### 8.5 `char(64)` → `varchar(64)` — H2가 숨긴 버그
내가 제일 좋아하는 기록이다. `V10__idempotency_request_hash_varchar.sql`.
> V1이 `request_hash`를 `char(64)`로 선언했는데 `IdempotencyRecordEntity`는 `length = 64`로
> 매핑하고 Hibernate는 그걸 `varchar(64)`로 읽는다. 실 PostgreSQL에 대해 `ddl-auto=validate`로
> 시작하면 실패했다:
>
> ```
> Schema-validation: wrong column type encountered in column [request_hash] in table
> [idempotency_record]; found [bpchar (Types#CHAR)], but expecting [varchar(64) (Types#VARCHAR)]
> ```
>
> **로컬 개발이 H2로 도는 한 보이지 않았다.** H2의 create-drop은 엔티티에서 스키마를 만들고,
> 그래서 **절대 엔티티와 불일치할 수 없다.**
그리고 왜 엔티티가 아니라 마이그레이션을 바꿨나: 64자 hex digest는 padding을 절대 안 쓰고,
코드베이스의 나머지(엔티티, H2 composition, JPQL)가 이미 varchar를 가정하고, bpchar→varchar
cast는 trailing blank를 제거하니 padding된 값도 무손실 변환된다.
두 스트림(`db/migration/postgresql/V10`, `db/migration/jpa/idempotency/V2`)이 **똑같은 가드된
변환**을 각각 갖는다. 상대 순서가 고정되어 있지 않아서, 테이블을 만든 쪽이 어느 쪽이든 다음에
도는 쪽이 고쳐야 한다.
```sql
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid
WHERE c.relname = 'idempotency_record' AND a.attname = 'request_hash'
AND NOT a.attisdropped
AND format_type(a.atttypid, a.atttypmod) = 'character(64)')
THEN ALTER TABLE idempotency_record ALTER COLUMN request_hash TYPE varchar(64);
END IF;
END $$;
```
> V1을 in-place로 고치는 게 더 작고 틀렸을 것이다: **적용된 마이그레이션의 checksum은 이미
> 그것을 돌린 모든 배포에 대한 약속이다.**
이 사건이 `application-local.yml`을 통째로 바꿨다. 지금은 local도 PostgreSQL + Flyway +
`ddl-auto=validate`가 기본이고, H2는 별도 개발자 task로 밀려났다.
> `local`이 JPA 켜진 상태에서 dev/prod와 같은 벤더, 같은 마이그레이션 모드, 같은 스키마
> 소유자를 갖고 주소와 자격증명만 다르다. H2가 기본이었을 때 "로컬에서는 됩니다"는 다른
> 모든 환경이 도는 것과 **다른 데이터베이스**에 대한 진술이었다: 마이그레이션이 적용된 적
> 없고, ddl-auto가 엔티티에서 스키마를 쓰고, 마이그레이션 트리와 불일치하는 매핑을 실
> PostgreSQL이 볼 때까지 발견할 수 없었다. 이번 wave에 두 번 그랬다 — `fs_cleanup_item`과
> `request_hash`.
---
## 9. V1 인프라 테이블 — idempotency / outbox / INT_LOCK
레거시(그러나 현역) 스키마 세 개.
### 9.1 `idempotency_record` (V1)
```sql
CREATE TABLE idempotency_record (
id uuid, tenant varchar(128) NOT NULL DEFAULT '', principal varchar(256) NOT NULL,
idempotency_key varchar(256) NOT NULL, use_case_name varchar(256) NOT NULL,
request_hash char(64) NOT NULL, -- → V10에서 varchar(64)
status varchar(16) NOT NULL, response_payload text, response_ref varchar(512),
created_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
CONSTRAINT uq_idempotency_scope UNIQUE (tenant, principal, idempotency_key, use_case_name)
);
CREATE INDEX ix_idempotency_record_expires_at ON idempotency_record (expires_at);
```
**`tenant`가 절대 `null`이 아니다** (single-tenant는 빈 문자열).
> PostgreSQL은 NULL을 서로 distinct로 취급하므로, null을 허용하면 single-tenant 행의 unique
> scope dedup이 깨진다.
매퍼가 `null` 애플리케이션 tenant ↔ row의 `""`를 왕복시킨다.
**엔티티에 setter가 없는 것도 의도**다. 상태 전이 때 row를 재구성·재저장한다(Vernon Option A) —
애플리케이션 관점에서 엔티티를 immutable로 유지.
동시성 중재는 unique 제약이다. `tryBegin`:
- SPI(`IdempotencyClaimRepository`)가 있으면 벤더 statement 한 방.
- 없으면 lookup → 만료 행 delete → `saveAndFlush`(flush가 unique 검사를 그 자리에서 강제) →
`DataIntegrityViolationException`이면 `false`.
PostgreSQL SPI:
```sql
INSERT INTO idempotency_record (...) VALUES (..., 'IN_FLIGHT', NULL, NULL, ...)
ON CONFLICT ON CONSTRAINT uq_idempotency_scope
DO UPDATE SET id = EXCLUDED.id, request_hash = EXCLUDED.request_hash, status = 'IN_FLIGHT', ...
WHERE idempotency_record.expires_at <= :now
RETURNING id
```
`DO UPDATE ... WHERE`가 실패하면 행이 반환되지 않는다 → 패자는 `Optional.empty()`.
`tryClaim`이 하는 detach가 중요하다.
```java
if (exactExpiredEntity != null && entityManager.contains(exactExpiredEntity)) {
entityManager.detach(exactExpiredEntity);
}
```
이 statement가 덮어쓰려는 행의 managed 복사본이 있으면 커밋 시점에 claim 위로 flush된다.
만료는 **세 곳**에서 강제된다: read(`find`가 만료 행을 부재로 취급), reclaim(`tryBegin`
만료 행을 재claim 전에 처리), 그리고 `IdempotencyReaper`(`@Scheduled`, 기본 10분,
`deleteExpired` bulk delete). reaper는 유일한 만료 수단이 아니라 **테이블 성장을 묶는
backstop**이다.
§F responseRef split: payload ≤ 8KB는 row에 inline, 더 크면 `IdempotencyResponseObjectStore`
offload하고 reference만 보관. object store 빈이 없으면 **경고와 함께 inline으로 안전 degrade**
(D9 프로젝트 선택 — 8KB 임계와 object-store 분리는 cited normative basis가 없고 production
빈도도 미측정).
### 9.2 `outbox_event` (V3)
```sql
CREATE TABLE outbox_event (
event_id varchar(64) PK, aggregate_id varchar(256), event_type varchar(256),
payload text, occurred_at timestamptz,
status varchar(16), -- PENDING | IN_FLIGHT | PUBLISHED | FAILED | DEAD
attempt_count integer DEFAULT 0,
next_attempt_at timestamptz, -- dual-purpose
correlation_id varchar(64), idempotency_key varchar(256)
);
CREATE INDEX ix_outbox_event_eligible ON outbox_event (next_attempt_at)
WHERE status IN ('PENDING','FAILED','IN_FLIGHT');
CREATE INDEX ix_outbox_event_aggregate_occurred ON outbox_event (aggregate_id, occurred_at);
CREATE INDEX ix_outbox_event_published_occurred ON outbox_event (occurred_at)
WHERE status = 'PUBLISHED';
CREATE INDEX ix_outbox_event_status_occurred ON outbox_event (status, occurred_at);
```
**`next_attempt_at` dual-purpose (I6 — 추가 컬럼 없음)**:
| status | 값 | 효과 |
|---|---|---|
| `PENDING` | insert 시 `occurred_at` | 최초 claim 체크(`next_attempt_at <= now`)가 즉시 만족 |
| `IN_FLIGHT` | `claim_time + in_flight_timeout` | orphan 행이 visibility window 만료 후 재claim 가능 |
| `FAILED` | `now + backoff` | backoff 경과 후에만 재시도 |
claim SQL이 **eligibility predicate와 per-aggregate FIFO gate를 전부 SQL에서** 강제한다.
```sql
SELECT * FROM outbox_event o
WHERE o.next_attempt_at <= :now
AND o.status IN ('PENDING', 'FAILED', 'IN_FLIGHT')
AND NOT EXISTS ( -- I4: per-aggregate FIFO gate
SELECT 1 FROM outbox_event p
WHERE p.aggregate_id = o.aggregate_id
AND p.occurred_at < o.occurred_at
AND p.status <> 'PUBLISHED')
ORDER BY o.occurred_at ASC
LIMIT :limit
FOR UPDATE SKIP LOCKED -- I3: concurrent-relay 안전
```
한 배치에 **aggregate당 최대 한 행(head)**만 나타난다. 어댑터는 추가 in-memory 필터링을
하지 않는다 — 리포지토리가 반환한 모든 행을 `IN_FLIGHT`로 전이시켜 호출자에 돌려준다.
`markPublished` / `markFailed` / `markDead`가 **row 부재 시 throw**하는 이유:
> relay가 방금 같은 서비스 인스턴스에서 claim한 row이므로, 부재는 프로그래밍/동시성 버그다.
> 조용히 no-op하면 row가 영원히 `IN_FLIGHT`로 남아 그 aggregate의 FIFO 큐를 막고, 호출자나
> 로그에 아무 신호도 남지 않는다.
`OutboxEventEntity`**`AuditableEntity`를 상속하지 않는** 것도 결정이다.
> `IdempotencyRecordEntity`처럼 outbox row는 도메인 애그리거트가 아니라 인프라 record다.
> 자체 temporal 필드(`occurred_at`, `next_attempt_at`)가 도메인 의미를 갖고, generic
> `created_at`/`updated_at` audit 컬럼과 섞이면 안 된다.
반대로 **mutating setter 노출은 의도**다 — relay 어댑터가 managed 엔티티 위에서 상태를
전이시키되 full reload-and-replace 없이 한다.
메트릭 쿼리 반환 형태의 비대칭도 기록되어 있다.
```java
Map<OutboxEventStatus, Long> countByStatus() EnumMap (키가 enum)
Map<String, Long> oldestUnpublishedAgeSecondsByEventType(Instant) HashMap (키가 String event-type)
```
### 9.3 `INT_LOCK` (V4/V5) — 분산 락
Spring Integration JDBC `LockRegistry` 백킹 테이블. V4가 SI 6.5 PostgreSQL 스키마 그대로이고,
V5가 SI 7의 `EXPIRED_AFTER` 컬럼을 추가한다 (**V4를 immutable로 두고 앞으로 진화**).
```sql
ALTER TABLE INT_LOCK ADD COLUMN IF NOT EXISTS EXPIRED_AFTER TIMESTAMP;
UPDATE INT_LOCK SET EXPIRED_AFTER = CREATED_DATE WHERE EXPIRED_AFTER IS NULL;
ALTER TABLE INT_LOCK ALTER COLUMN EXPIRED_AFTER SET NOT NULL;
```
`DistributedLockPersistenceConfig`의 provider 선택:
| `ca-skeleton.runtime.multi-instance-enabled` | 빈 |
|---|---|
| `false` 또는 부재 | `DefaultLockRegistry` (in-process), `@Primary` |
| `true` | `DefaultLockRepository` + `JdbcLockRegistry` + `jdbcDistributedLock` |
**`jdbcDistributedLock` 빈이 일부러 `@Primary`가 아니고 이름도 `distributedLockProvider`
아니다.** app-bootstrap이 이걸 metrics 데코레이터로 감싸 `@Primary`/`distributedLockProvider`
빈을 노출한다. 그래서 SI 타입이 컴파일 타임에 adapter-persistence 위 레이어에 보이지 않는다.
`setCheckDatabaseOnStart(false)``INT_LOCK`은 첫 lock acquire 전에 Flyway V4/V5가
provision하므로 DDL 체크를 건너뛴다.
`JdbcLockRegistry(lockRepository, settings.leaseTtl())` — SI 7.0 이후 기본 TTL은 repository
setter가 아니라 **registry 생성자**에서 설정한다.
`LockRegistryDistributedLockAdapter`의 계약:
- **D4 — transaction-commit ordering invariant.** 이 어댑터는 트랜잭션 경계를 관리하지 않는다.
호출자는 보호된 트랜잭션이 **커밋된 뒤에만** 핸들을 release해야 한다. 커밋 전에 release하면
lost-update race가 생긴다.
- **D5 — finite waitTime + lease TTL.** 무한 블로킹은 쓰지 않는다.
- `leaseTtl > configuredTtl``IllegalArgumentException` — provider 기본 TTL보다 긴 lease를
약속하는 건 false contract.
- `InterruptedException`은 interrupt flag를 복원하고 `LockAcquisitionTimeoutException`으로 변환.
- 반환 핸들은 `lock::unlock` 람다.
**SI-LOCK-C5**: lease TTL이 `close()` 전에 만료된 `JdbcLockRegistry`에서는 내부
`JdbcLock.unlock()``ConcurrentModificationException`을 던진다(row가 이미 회수됨). 이
어댑터는 **일부러 잡지 않는다** — metered 데코레이터가 로그 + `lock.lease.expired` metric 후
정상 return해서, 만료가 호출자의 보호작업 예외를 가리지 않게 한다.
`LockSettings`의 교차 필드 불변식: `leaseTtl >= waitTime`.
> TTL이 waitTime보다 먼저 만료되면 첫 holder의 보호작업이 끝나기 전에 두 번째 holder가 락을
> 잡을 수 있다.
기본값: `waitTime = 3s`, `leaseTtl = 30s`.
---
## 10. V2 후보 구현 — owner-safe 상태 기계 네 개
여기가 이 모듈에서 가장 최근이고 가장 정교한 부분이다. 전부
`implemented-candidate` 상태이고, `readiness-cards.yaml`이 그걸 명시한다.
네 구현이 **공통 패턴**을 공유한다.
1. **capability guard**`capability_schema_registry`에서 `ACTIVE` + 정확한 epoch/revision 확인.
2. **same-resource primary write transaction guard**
`isActualTransactionActive() && !isCurrentTransactionReadOnly() && hasResource(dataSource)`.
3. **row lock 먼저, `clock_timestamp()` 그 다음** — 시간을 애플리케이션 시계가 아니라 DB에서,
그리고 **행이 바뀔 수 없게 된 뒤에** 읽는다.
4. **owner CAS 튜플을 SQL where 절에 전부 반복** — scope, token, attempt, claim operation,
state revision. update count가 곧 답이다.
5. **transition digest** — 재시도가 같은 전이인지 다른 전이인지 구별.
6. **Spring stereotype 없음** — 두 composition root가 `dev.caskeleton.adapter`
component-scan하므로 `@Repository`를 붙이면 선택하지 않은 배포에서도 빈이 된다.
### 10.1 `PostgreSqlOwnerSafeIdempotencyStore`
`IdempotencyStorePortV2` 구현. 상태:
`CLAIMED → EXECUTING → COMPLETED | FAILED_RETRYABLE | ABANDONED`.
클래스가 하는 일과 협력자로 분리한 것이 명확히 문서화되어 있다.
| 협력자 | 책임 |
|---|---|
| `IdempotencyCapabilityGuard` | 3개 precondition (vendor, capability, transaction) |
| `IdempotencyRowMapper` | row 읽기 + `clock_timestamp()` |
| `IdempotencyClaimGateway` | 소유권을 **정하는** 3개 statement (insert / reset / abandon) |
| `IdempotencyTransitionGateway` | 이미 가진 claim을 **진행시키는** 5개 statement |
| `IdempotencyDigestPolicy` | transition digest 구성 |
| `IdempotencyRecordRow` | row 정의 (package-private) |
협력자를 주입이 아니라 **생성자에서 조립**하는 이유: "이건 이 store의 부품이지 애플리케이션이
조립하거나 교체하는 서비스가 아니고, 주입하면 public bean 표면이 1개에서 6개로 넓어진다."
**claim 결정 트리**(`claim()`)를 순서대로:
```
insert (on conflict (scope_hash) where record_version = 2 do nothing)
row = findForUpdate(scope) ← 여기서 lock
dbNow = clock_timestamp() ← lock 이후
inserted == 1 → Acquired
COMPLETED이고 replay_until 지남 → resetClaim → TakenOverClaimed
request_hash 불일치 → FingerprintMismatch
COMPLETED이고 replay_until 있음 → CompletedReplay(payload, replayUntil)
같은 claim attempt(token+operationId) → ReplayedAcquire
같은 token, 다른 operationId → OwnerOperationConflict
CLAIMED이고 lease 만료 → resetClaim → TakenOverClaimed
FAILED_RETRYABLE → resetClaim → TakenOverClaimed
EXECUTING이고 lease 만료 → abandonExpiredExecution → RecoveryRequired
ABANDONED → RecoveryRequired
그 외 → InProgress(retryAfter, attempt)
```
**만료된 `CLAIMED`만 takeover하고, 만료된 `EXECUTING``ABANDONED`로 닫고 reconciliation을
요구한다.** 실행이 시작된 뒤의 blind retry를 하지 않는다는 뜻이다.
`IdempotencyCapabilityGuard`에 아주 좋은 버그 기록이 있다.
> store가 스레드에 활성 read-write 트랜잭션이 있는지 확인했는데, 그건 **어떤 data source에서든
> 어떤 트랜잭션이든 열려 있으면 참**이다. 형제 outbox/inbox 어댑터는
> `hasResource(dataSource)`를 확인하는데, 그게 실제로 중요한 질문이다.
>
> 차이는 data source가 둘인 애플리케이션에서 드러난다: **다른** 쪽의 트랜잭션 안에서 발행된
> mutation이 옛 검사를 통과하고, 이 store의 커넥션에서 트랜잭션 밖으로 돌고,
> 원자적이어야 했던 작업과 **독립적으로 커밋됐다.**
vendor 검사도 있다(`getDatabaseProductName()`, 한 번만, `volatile boolean`으로 캐시).
> store의 statement는 `on conflict ... do nothing`, `clock_timestamp()`, `for update`를 쓴다 —
> 이 provider를 선택한 H2/MySQL 배포는 refusal이 아니라 **mutation 안 어딘가에서** syntax나
> semantics 실패를 얻는다. 그리고 vendor가 더 싼 질문이다.
`IdempotencyTransitionGateway`의 5개 statement가 전부 같은 모양이다.
```sql
update idempotency_record
set status = ..., state_revision = state_revision + 1,
last_transition_operation_id = ?, last_transition_kind = ?,
last_transition_result_digest = ?, updated_at = clock_timestamp()
where scope_hash = ? and record_version = 2
and status = ? -- 기대 상태
and owner_token = ? and attempt = ? and claim_operation_id = ?
and state_revision = ? -- CAS
and processing_lease_until > clock_timestamp() -- START/RENEW만
```
> update count가 **곧 답이다**: 1이면 이 owner가 이 revision에서 여전히 owner였고, 0이면 다른
> 무언가가 record를 움직였으니 caller는 자기 view를 현재로 취급하면 안 된다. row를 읽고 나서
> scope만으로 update하면 lease가 만료된 worker가 takeover한 worker의 상태를 덮어쓸 수 있다.
`IdempotencyDigestPolicy`의 기록이 특히 좋다.
> `transition|operationId|ownerToken|attempt|stateRevision`은 **누가, 언제**를 덮고 **무엇**은
> 전혀 덮지 않는다: retryable로 기록된 `FAIL`과 abandoned로 기록된 `FAIL`이 같은 digest를
> 냈고, 서로 다른 응답이나 서로 다른 retention을 가진 두 completion도 그랬다. digest를
> 비교하는 replay는 **차이 전체가 중요한 부분인 두 전이**를 "같은 전이"라고 결론지었다.
그래서 지금은 `semanticArguments` 가변 인자로 disposition/retention/response digest/codec
identity를 전부 포함하고, **length-framed**로 구성한다.
```java
private static void write(StringBuilder out, String value) {
out.append(value.length()).append(':').append(value);
}
```
> 모든 구성요소가 가변 폭 텍스트이고 최소 하나(owner token)는 이 플랫폼이 제약할 것이 아니다.
> delimiter로 join하면 **서로 다른 두 구성요소 목록이 하나의 문자열로 렌더링될 수 있다.**
`VERSION = 2`이고, 구성이 바뀌면 bump해서 저장된 digest가 그것을 가로질러 비교되지 않게 한다.
`replayVerdict`가 3값이다: `NOT_A_REPLAY` / `SAME_ARGUMENTS` / `DIFFERENT_ARGUMENTS`.
> kind와 operation id가 검사 전부였고 전이가 쓴 digest는 아무도 읽지 않았다. 그래서 하나의
> operation id 아래 두 번째 `markFailed`는 **무엇을 요청하든** `ALREADY_MARKED_SAME_OPERATION`으로
> 답했다: disposition을 retryable에서 abandoned로 바꾼 재시도나 retention을 바꾼 재시도가
> 이미 적용된 전이로 보고되고 **조용히 일어나지 않았다.** caller의 증거는 record가
> abandoned라고 말하는데 row는 retryable이라고 말했다.
`INLINE_RESPONSE_MAX_BYTES = 8 * 1024`, `MAXIMUM_PROCESSING_LEASE = 1시간`,
`MAXIMUM_RETENTION = 30일`.
owner token은 `SecureRandom` 32바이트 hex.
**raw client key를 저장하지 않는다** — versioned HMAC scope digest만.
스키마(`db/migration/jpa/idempotency/V1`)는 **전부 additive**다. V1 컬럼이 호환 기간 내내
읽을 수 있게 남고, legacy COMPLETED 행에 synthetic owner를 만들어내지 않는다.
세 개의 `CHECK` 제약이 전부 `NOT VALID`로 추가된다 — 기존 행을 재검증하지 않고 앞으로의
쓰기에만 적용.
partial unique index가 V1과 V2를 공존시킨다.
```sql
CREATE UNIQUE INDEX uq_idempotency_record_v2_scope ON idempotency_record (scope_hash)
WHERE record_version = 2;
```
### 10.2 `PostgreSqlImmutableOutboxAppendAdapter`
**immutable identity/envelope 분리**가 핵심이다.
| 테이블 | 성격 |
|---|---|
| `outbox_publication_control_v2` | 단일 행(`scope_id='PRIMARY'`), 현재 epoch/authority/state |
| `outbox_publication_cutover_v2` | epoch별 immutable sentinel (UPDATE/DELETE 트리거로 거부) |
| `outbox_event_identity_v2` | compact global identity guard, aggregate order unique |
| `outbox_event_log_v2` | immutable envelope, **`PARTITION BY RANGE (retention_bucket)`** |
append 순서:
```java
requireSameResourcePrimaryWriteTransaction();
requireActiveCapability();
control = lockPublicationControl(); // FOR SHARE OF control
identity insert (on conflict do nothing) returning retention_bucket, created_at
envelope insert (같은 트랜잭션)
```
`FOR SHARE`가 핵심이다.
> 활성 publication control 행을 caller 트랜잭션이 끝날 때까지 `FOR SHARE`로 든다. 따라서
> identity/envelope insert가 **authority cutover를 가로지를 수 없다.** cutover는 control에
> `FOR UPDATE`가 필요하니 이미 시작된 append를 추월하지 못한다.
DB 트리거 3개가 불변식을 강제한다.
| 트리거 | 대상 | 하는 일 |
|---|---|---|
| `trg_outbox_event_v2_authority` | BEFORE INSERT `outbox_event_log_v2` | control state가 ACTIVE인지, `publication_epoch`/`dispatch_authority`가 control과 일치하는지 |
| `trg_fence_legacy_outbox_writer` | BEFORE INSERT/UPDATE/DELETE `outbox_event` | authority가 `LEGACY_POLLING`이 아니면 **레거시 writer를 펜싱** |
| `trg_reject_outbox_cutover_mutation` | BEFORE UPDATE/DELETE `outbox_publication_cutover_v2` | 무조건 예외 — sentinel은 immutable |
genesis cutover row가 legacy 상태의 digest를 담는다.
```sql
md5(intent) || md5('outbox-v2:' || intent) -- intent = event_id:status:attempt_count 정렬 concat
```
`classifyExisting`이 identity insert가 졌을 때 세 가지를 구별한다.
| 상황 | 결과 |
|---|---|
| 같은 `event_id`이고 intent 동일 | `ALREADY_APPENDED_SAME_EVENT` |
| 같은 `event_id`이고 intent 다름 | `EVENT_ID_CONFLICT` |
| 다른 `event_id`인데 같은 (type,id,version,ordinal) | `AGGREGATE_ORDER_CONFLICT` |
| 셋 다 아님 | `IllegalStateException` — 있을 수 없는 상태 |
`mapStoredEvent`에 좋은 방어가 하나 더 있다.
```java
if (dispatch == null) {
throw new IllegalStateException("outbox identity exists without its same-transaction immutable envelope");
}
```
envelope 없는 identity는 원자성이 깨졌다는 뜻이다.
payload 크기 제약이 스키마에 있다: `octet_length(payload) BETWEEN 1 AND 1048576`.
### 10.3 `PostgreSqlPollingDeliveryAdapter`
**mutable delivery state를 immutable identity/envelope에서 분리**한 테이블
`outbox_delivery_v2`. 상태: `PENDING | CLAIMED | PUBLISHED | RETRY_WAIT | DEAD`.
**delivery row는 애플리케이션이 만들지 않는다 — 트리거가 만든다.**
```sql
CREATE TRIGGER trg_create_polling_delivery_v2
AFTER INSERT ON outbox_event_log_v2
FOR EACH ROW EXECUTE FUNCTION create_polling_delivery_v2();
```
함수 안에서 `IF NEW.dispatch_authority = 'POLLING_V2'`일 때만 초기 `PENDING` 행을 넣는다.
그래서 **같은 트랜잭션**에 생긴다.
claim SQL이 이 모듈에서 제일 복잡한 statement다. 4단계 CTE.
```sql
with authority as ( -- control FOR SHARE, active_authority = 'POLLING_V2'만
...
), db_clock as (
select clock_timestamp() as db_now
), eligible as (
select delivery.retention_bucket, delivery.event_id, delivery.destination
from outbox_delivery_v2 delivery
join outbox_event_log_v2 event on (bucket, event_id)
cross join authority cross join db_clock
where delivery.destination = ?
and delivery.publication_epoch = authority.active_epoch
and ( (state in ('PENDING','RETRY_WAIT') and next_attempt_at <= db_now)
or (state = 'CLAIMED' and claim_until <= db_now) ) -- lease 만료 회수
and not exists ( -- strict-order head gate
select 1 from outbox_delivery_v2 prior_delivery
join outbox_event_log_v2 prior_event on ...
where prior_delivery.destination = delivery.destination
and prior_event.aggregate_type = event.aggregate_type
and prior_event.aggregate_id = event.aggregate_id
and (prior_event.aggregate_version, prior_event.event_ordinal)
< (event.aggregate_version, event.event_ordinal) -- row-value 비교
and prior_delivery.state <> 'PUBLISHED')
order by event.created_at, event.event_id
for update of delivery skip locked
limit ?
), claimed as (
update outbox_delivery_v2 delivery
set state='CLAIMED', claim_owner=?, claim_token=?,
claim_until = db_now + (? * interval '1 millisecond'),
attempt = attempt + 1, version = version + 1, updated_at = db_now
from eligible, db_clock
where (bucket, event_id, destination) 매칭
returning delivery.*
)
select claimed.* , event.* from claimed join outbox_event_log_v2 event on ...
order by event.created_at, event.event_id
```
핵심 디테일 셋:
1. **row-value 비교** `(aggregate_version, event_ordinal) < (...)`로 사전식 순서를 SQL이
직접 표현한다.
2. **`for update of delivery`** — join된 `event` 테이블은 잠그지 않는다.
3. **head gate가 destination별**이다 — 같은 aggregate라도 destination이 다르면 독립.
completion CAS 3개(`markPublished`/`markRetryable`/`markDead`)가 owner/token/attempt/version/
epoch 전부에 더해 **authority가 여전히 유효한지**까지 `EXISTS` 서브쿼리로 확인한다.
```sql
and exists (select 1 from outbox_publication_control_v2 control
where control.scope_id='PRIMARY' and control.state='ACTIVE'
and control.active_authority='POLLING_V2'
and control.active_epoch = delivery.publication_epoch)
```
CAS가 0행이면 `classifyFailedTransition``INSPECT_SQL`로 이유를 판정한다.
```
last_operation_id 일치 → digest 같으면 ALREADY_APPLIED_SAME_OPERATION, 다르면 RESULT_CONFLICT
authority state/authority/epoch 불일치 → AUTHORITY_MISMATCH
owner/token/attempt 불일치 → NOT_OWNER
state != CLAIMED → NOT_CLAIMED
version 불일치 → STALE_VERSION
그 외 → RESULT_CONFLICT
```
**broker 호출은 트랜잭션 밖**이고, duplicate publish 가능성은 stable event ID로 consumer
inbox에서 처리한다.
`errorCode` 정규식: `[A-Z][A-Z0-9_.-]{0,63}`.
스키마의 CHECK 제약이 상태 모양을 강제한다.
```sql
CONSTRAINT ck_outbox_delivery_v2_state_shape CHECK (
(state = 'CLAIMED' AND claim_owner IS NOT NULL AND claim_token IS NOT NULL
AND claim_until IS NOT NULL AND published_at IS NULL AND dead_at IS NULL)
OR (state <> 'CLAIMED' AND claim_owner IS NULL AND claim_token IS NULL
AND claim_until IS NULL))
CONSTRAINT ck_outbox_delivery_v2_terminal_shape CHECK (
(state='PUBLISHED' AND published_at IS NOT NULL AND dead_at IS NULL)
OR (state='DEAD' AND dead_at IS NOT NULL AND published_at IS NULL)
OR (state NOT IN ('PUBLISHED','DEAD') AND published_at IS NULL AND dead_at IS NULL))
```
### 10.4 `PostgreSqlSameStoreInboxAdapter`
`inbox_record_v1`. 상태: `RECEIVED | PROCESSING | COMPLETED | RETRYABLE | DEAD`.
> transactional claim이 business mutation / outgoing outbox / completion과 **caller의 한
> primary write transaction에 참여**한다. broker ACK는 **commit 이후에만** 실행한다.
claim 결정 트리가 idempotency와 대칭이되 한 곳이 다르다.
```
COMPLETED/DEAD이고 retention 지남 → resetClaim → TakenOver
intent digest 불일치 → IntentMismatch
같은 claim attempt이고 COMPLETED → Completed
같은 claim attempt → ReplayedAcquire
같은 token, 다른 operationId → OwnerOperationConflict
COMPLETED → Completed
RECEIVED이고 lease 만료 / RETRYABLE → resetClaim → TakenOver ← received lease는 takeover 가능
PROCESSING이고 lease 만료 → expireProcessing(DEAD) → RecoveryRequired ← processing lease는 blind retry 안 함
DEAD → RecoveryRequired
그 외 → InProgress
```
> **received lease expiry는 takeover할 수 있지만 processing lease expiry는 blind retry하지
> 않고 recovery-required terminal state로 보낸다.**
`scope_hash`는 consumer-group/handler/tenant/message-ID의 canonical digest이고,
**raw broker metadata는 저장하지 않는다.**
`MAXIMUM_RETENTION = 30일`.
`terminal_at` CHECK가 상태와 짝을 맞춘다.
```sql
CHECK ((state IN ('COMPLETED','DEAD') AND terminal_at IS NOT NULL)
OR (state NOT IN ('COMPLETED','DEAD') AND terminal_at IS NULL))
```
`FAIL_SQL``terminal_at`이 조건부 표현식인 것도 재밌다.
```sql
terminal_at = case when ? = 'DEAD' then clock_timestamp() else null end
```
---
## 11. 이 leaf 안의 다른 PostgreSQL 소비자
이 모듈은 "플랫폼"만 있는 게 아니라 **실제 capability 스토어 두 벌**을 더 들고 있다.
둘 다 자기 Flyway 스트림과 자기 capability 스위치를 갖는다.
### 11.0 먼저 — 이들은 플랫폼과 결합되어 있지 않다
같은 leaf에 있으니 플랫폼을 쓸 거라고 짐작했는데, 세어 보니 아니었다.
| 방향 | 결과 |
|---|---|
| `fileserver`/`notification` → JPA 플랫폼 타입 | **0건** |
| JPA 플랫폼 → `fileserver`/`notification` | **0건** (javadoc 주석 4줄 + exclusion용 String 상수 2개뿐) |
```bash
B=src/main/java/dev/caskeleton/adapter/outbound/persistence
for pkg in fileserver notification; do
grep -rh "^import dev\.caskeleton\.adapter\.outbound\.persistence\." $B/$pkg \
| sed 's/^import dev\.caskeleton\.adapter\.outbound\.persistence\.//' \
| awk -F. '{print $1}' | sort | uniq -c
done
# fileserver → 29 fileserver (전부 자기 자신)
# notification → 28 notification (전부 자기 자신)
```
실제 의존은 이렇다.
```
fileserver : application.fileserver 63건 + 자기 자신 29건
notification : application.notification 180건 + 자기 자신 28건
기술 스택 : Spring Data JPA, @Entity/@Column, JdbcTemplate — 전부 순정
```
`springdata`도, `hibernate.*`도, `postgresql.{write,lock,json,array,range,copy}`도, `api.*`도,
`transaction`**하나도 안 쓴다.** `SafeSortMapper` 대신 자기 `@Query`를 쓰고,
`PostgreSqlWorkClaimExecutor` 대신 자기 native `FOR UPDATE SKIP LOCKED`를 쓴다
(`RecipientClaimSql`, `FileserverCleanupRepository.claim`).
역방향 참조도 코드가 아니다. 플랫폼이 이 두 이름을 언급하는 곳은 전부 **"여기서 제외한다"는
표시**다.
```
config/JpaAdapterComponentsConfig.java javadoc — 스캔에서 빠진 이유 설명
config/PersistenceJpaConfig.java OPT_IN_NOTIFICATION_PACKAGE / OPT_IN_FILESERVER_PACKAGE 상수
```
`docs/jpa/` 11개 문서 중 이들을 언급하는 것도 `repository-adaptation.md` 한 곳뿐이고, 그것도
"패키지 카탈로그가 누락했던 9개" 목록에서다. `support-matrix.md`, `entity-mapping-guide.md`,
`transaction-guide.md` 어디에도 없다.
**결론: 같은 leaf에 세들어 살 뿐 아키텍처적으로는 독립이다.** 소유자는 각자의
`application-core` 포트이고, JPA 모듈이 소유하는 건 그 포트의 저장 구현뿐이다.
그래도 **JPA 모듈 쪽에서 알아야 할 것 세 가지**가 있고, 이 장은 거기까지만 다룬다.
1. **검증 체계는 공유한다.** 이 모듈 `build.gradle`이 이들의 readiness 태스크 4개를 등록하고
(`postgresqlFileserverMigration/Metadata/Reclamation`,
`postgresqlNotificationSchemaActivation`), `readiness-cards.yaml`에 card 2개
(`jpa-fileserver-metadata-v1`, `jpa-notification-platform-v4`)가 있고, evidence manifest에
포함된다.
2. **JPA 모듈의 규칙 세 개가 이들 때문에 생겼다.** `PersistenceJpaConfig`의 열거식
`@EntityScan`(§14.3), `JpaModuleBoundaryTest`의 카탈로그 정확 동등성(§1), 그리고 모듈
`CLAUDE.md`의 "Capability-gated stores" 절 자체.
3. **§17 P8의 가장 강한 증거다.** 같은 leaf에서 제일 큰 스토어 두 개(78파일, 350KB)가
플랫폼을 하나도 쓰지 않는다.
### 11.1 `fileserver` — 6개 테이블, 조건부 전이
25개 파일. 엔티티 6개 / 리포지토리 7개 / 어댑터 9개.
| 테이블 | 역할 |
|---|---|
| `fs_file` | 파일 메타데이터 + 상태 기계 (11 상태) |
| `fs_upload_session` | 업로드 세션 + writer lease + lifecycle_state |
| `fs_verification_result` | 검증 결과 이력 |
| `fs_quota_reservation` | 쿼터 예약/커밋 원장 |
| `fs_cleanup_item` | 물리 정리 큐 (fenced lease) |
| `fs_recovery_item` | 자동 조정 실패 목록 |
V1의 헤더가 설계 의도를 요약한다.
> **파일시스템이 아니라 관계형 레코드가** 파일이 공개적으로 읽을 수 있는지 결정한다. 그래서
> 모든 상태 전이가 `state`와 `version` **둘 다**로 가드된다. 물리 경로/마운트/원본 물리
> 파일명을 저장하지 않는다: `content_key`는 서버 생성 opaque key이고 `original_name`은
> 신뢰할 수 없는 표시용 텍스트일 뿐이다.
가장 좋은 제약이 이거다.
```sql
CONSTRAINT ck_fs_file_ready_is_complete CHECK (
state <> 'READY'
OR (content_key IS NOT NULL AND actual_size IS NOT NULL AND sha256 IS NOT NULL
AND strong_etag IS NOT NULL AND published_at IS NOT NULL))
```
**READY가 유일하게 공개 읽기 가능한 상태이므로, 완전하고 검증된 identity를 반드시 들고 있어야
한다.** 이걸 DB CHECK으로 강제한다.
`FileTransitionRepository`의 모든 statement가 optimistic CAS다.
```sql
update FileEntity f set f.state = :targetState, ..., f.version = f.version + 1
where f.fileId = :fileId and f.state = :expectedState and f.version = :expectedVersion
```
> 반환 count가 0이면 precondition이 진 것이고 optimistic conflict로 번역된다 — **blind retry는
> 절대 하지 않는다.**
`coalesce(:param, f.column)` 패턴으로 부분 업데이트를 한 statement에 담는다.
V2/V3/V4가 각각 실제 사고의 수정이다.
**V2 — `upload_id` 컬럼 + `fs_recovery_item`**
> staging 객체는 file이 아니라 upload로 주소지정된다. 그래서 큐에 넣은 staging cleanup이
> **이미 published된 콘텐츠에 대해서만** 무엇을 회수할지 명명할 수 있었다. 이 컬럼 없이는
> 취소되거나 만료된 업로드가 **아무도 찾을 수 없는 바이트를 남긴다.**
`CHECK (upload_id IS NULL OR content_key IS NULL)` — 항목은 정확히 하나의 대상을 명명한다.
`uq_fs_recovery_open` partial unique index (`WHERE status = 'PENDING'`) — 파일당 열린 항목
하나. "큐는 worklist이지 모든 sweep의 로그가 아니다."
**V3 — fenced cleanup lease**
> claim이 항목을 IN_PROGRESS로 옮기고 **다른 건 아무것도 기록하지 않았다**: owner도, token도,
> lease expiry도. 같은 누락에서 두 결과가 나왔다.
>
> 물리 delete를 수행하고 DB를 정리하기 전에 죽은 worker는 행을 **영원히 IN_PROGRESS로**
> 남겼다. 어떤 쿼리도 그 항목을 살아있는 worker가 활발히 삭제 중인 것과 구별할 수 없어서
> 아무것도 회수하지 않았고, 파일의 쿼터와 lifecycle이 미정착으로 남았다.
>
> 그리고 완료 update가 `cleanup_id`만으로 매칭했다. 합리적인 lease를 한참 지나 멈춰 있던
> worker가 여전히 다른 worker가 그 사이 claim해서 반쯤 진행한 항목 위에 DONE을 쓸 수 있었다 —
> notification dispatcher가 겪은 것과 **같은 fenced-lease 문제.**
`NULL` lease를 reaper가 **자동 회수하지 않고** 운영자에게 넘기는 것도 좋다.
> `NULL` lease는 fencing이 존재하기 전에 claim됐다는 뜻이고, 자동 takeover는 **아무도 상태를
> 기록하지 않은 작업**에 대해 추측하는 것이다.
`reclaimExpiredClaim``PENDING`이 아니라 `FAILED`로 되돌리고 attempt를 올리는 것도 의도다 —
매번 worker가 죽는 항목이 outright 실패하는 항목과 같은 retry 예산에 묶인다.
**V4 — upload terminal state**
> cleanup이 writer lease를 읽고, 없는 걸 확인하고, staging 바이트를 삭제했다. 그 read와
> delete 사이에 writer가 바로 그 lease를 획득할 수 있다 — **업로드가 끝났다고 말하는 게
> DB에 아무것도 없었으니까** — 그리고 cleanup이 제거한 객체는 업로드가 활발히 append 중이던
> 것이었다.
해법이 정석적이다. cancel/failed finalize가 **cleanup을 큐잉하는 같은 트랜잭션 안에서**
세션을 `TERMINAL`로 옮기고, acquire/renew/offset commit은 `ACTIVE`를 요구하고, cleanup은
읽은 값으로 판단하는 대신 **조건부 update로 행을 claim**한다.
`FileserverQuotaRepository.reduceCommitted`의 가드도 좋다.
```sql
... set committed_bytes = committed_bytes - :amount ... where committed_bytes >= :amount
```
> 다른 reclaim이 이미 `amount` 아래로 끌어내린 행은 0행을 업데이트하고, caller는 원장을
> 음수로 몰지 않고 다음 행으로 넘어간다.
### 11.2 `notification.platform` — 12개 테이블, 4개 개정
53개 파일, 약 196KB. 이 leaf에서 단일 capability로는 제일 크다.
테이블: `notification_request`, `notification_recipient_delivery`, `notification_delivery_attempt`,
`notification_provider_event`, `notification_contact_point`, `notification_template_version`,
`notification_consent`, `notification_preference`, `notification_suppression`,
`notification_deduplication_claim`, `notification_admin_audit`, `notification_inbox_item`.
마이그레이션이 V1~V10까지 있고, capability는 `jpa-notification-platform-v4`
(`core_epoch=1, feature_revision>=4, ACTIVE`).
**PostgreSQL 관점에서 볼 만한 것 두 개.**
**(1) `RecipientClaimSql` — SQL을 상수로 뽑는 이유**
```java
/**
* Kept as constants rather than inline in the repository annotation so a contract test can
* execute the statement the adapter actually runs. A test that retypes the SQL proves that the
* test author and the adapter author agreed about the query, which is not the property anybody
* needs; the property is that *this* statement, against a real PostgreSQL, claims what it should.
*/
```
이게 §15에서 다시 다룰 주제의 정답이다.
claim SQL 자체:
```sql
WITH claimable AS (
SELECT id FROM notification_recipient_delivery
WHERE next_dispatch_at <= :now
AND delivery_state IN ('PENDING', 'READY_TO_DISPATCH', 'RETRY_WAITING')
AND (expires_at IS NULL OR expires_at > :now)
AND (lease_until IS NULL OR lease_until < :now)
ORDER BY next_dispatch_at, id
FOR UPDATE SKIP LOCKED
LIMIT :batchSize
)
UPDATE notification_recipient_delivery AS d
SET lease_owner = :owner, lease_until = :leaseUntil,
lease_fence = d.lease_fence + 1,
delivery_state = 'DISPATCHING',
version = d.version + 1, -- ← JPA @Version 컬럼을 native write가 직접 올린다
updated_at = :now
FROM claimable WHERE d.id = claimable.id
RETURNING d.id, d.lease_fence
```
`version = d.version + 1` 주석이 특히 좋다.
> JPA `@Version` 컬럼을 native write가 올린다. 이게 없으면 claim 전에 로드된 managed 엔티티가
> 여전히 옛 version을 들고 있어서, 그 flush가 성공하고 **lease와 state를 pre-claim 값으로
> 덮어쓴다** — native statement가 충돌이 있었다고 말해주지 않았기 때문에 **충돌을 보고하지
> 않는 optimistic locking.**
세 predicate의 의미도 적혀 있다.
- `next_dispatch_at <= :now` — 예약 delivery를 평범한 queued delivery로 만드는 것.
- 상태 목록에 `PENDING`이 있는 이유: rolling deploy 중 옛 인스턴스가 쓴 행도 진짜 job이고,
**그게 없어서 모든 예약 요청이 좌초됐었다.**
- expiry 체크가 lease와 **같은 statement**에 있어서, 기다리는 동안 window가 닫힌 delivery는
claim되고 나서 재고되는 게 아니라 **아예 claim되지 않는다.**
**(2) `TenantBoundRepositoryGuard`**
```java
public void assertTenant(String actualTenantId, TenantId expected) {
if (!expected.value().equals(actualTenantId)) {
// No identifiers in the message: a cross-tenant probe must not learn what it nearly reached.
throw new IllegalStateException("row does not belong to the requesting tenant");
}
}
```
리포지토리가 이미 finder를 scope하지만, **identity만으로 행에 도달하는 경로**(이벤트가 자기
attempt를 resolve, 운영자가 attempt id로 행동)가 있고 거기서 tenant 검사를 한다.
"`found by id`가 조용히 `readable by anyone`을 의미하지 않게."
### 11.3 `NotificationSchemaActivation` / `FileserverSchemaActivation`
두 capability 다 **operator-applied 스트림**이다. 애플리케이션은
`db/migration/postgresql`만 마이그레이션하고, capability 스트림은 의도적으로 적용 + ACTIVE
승격된다.
`NotificationSchemaActivation`의 javadoc이 이 검사가 없을 때의 두 실패 모양을 정확히 적는다.
> `ddl-auto=validate`에서는 기능을 **끈** 배포에서 `notification_request`가 없다고 boot가
> 실패했다 — 엔티티가 무조건 스캔됐기 때문. `ddl-auto=none`이고 기능이 켜졌으면 boot는
> 성공하고 **첫 발송이 raw `relation "notification_request" does not exist`를** 처음 보낸
> 사람에게 반환했다.
그리고 **startup에서만 확인하고 호출마다 확인하지 않는** 이유:
> 승격되지 않은 스트림은 **배포 상태**이고, 매 notification마다 레지스트리에 다시 물으면
> 프로세스가 도는 동안 바뀔 수 없는 질문에 답하려고 send path에 round trip을 넣는 것이다.
레지스트리가 읽히지 않으면 "괜찮다고 가정"이 아니라 **"승격 안 됨"으로 취급**한다.
---
## 12. `security` / `observation` / `cache` / `envers` / `querydsl` / `audit`
### 12.1 `security` — 런타임 롤은 DDL을 못 한다
`PostgreSqlRuntimeRoleVerifier`가 서버에 직접 묻는다.
```sql
select current_user as current_user_name,
current_setting('search_path') as search_path,
has_schema_privilege(current_user, current_schema(), 'CREATE') as create_on_schema,
has_database_privilege(current_user, current_database(), 'CREATE') as create_on_database
```
> 질문은 **설정이 아니라 서버가** 답한다. 롤의 유효 권한은 직접 grant, 상속된 롤 멤버십,
> `PUBLIC` grant, 스키마 소유권에서 나오고, 어떤 배포 매니페스트를 읽어도 그 조합을 신뢰성
> 있게 재구성할 수 없다. `has_schema_privilege`는 할 수 있다.
startup에서 돌고 fail-closed다.
> 사고 후에 애플리케이션 자신의 자격증명이 테이블을 drop할 수 있었다는 걸 발견하는 건 너무
> 늦게 발견하는 것이다.
`DatabaseRolePolicy`가 세 가지를 요구한다: 롤이 승인 목록에 있을 것, 스키마 CREATE 없을 것,
DB CREATE 없을 것. 그리고 `SearchPathPolicy`.
> 런타임 롤을 마이그레이션 롤에서 분리하는 게 "Flyway가 스키마 변경을 소유한다"를 열망이
> 아니라 **강제 가능**하게 만든다: 애플리케이션 자신의 자격증명이 DDL을 실행할 수 없으면,
> 어떤 코드 경로도, 어떤 라이브러리도, 어떤 주입된 statement도 런타임에 스키마를 바꿀 수 없다.
`SearchPathPolicy`가 allowlist인 이유:
> `search_path`는 unqualified 이름이 어느 스키마로 해석되는지를 정한다. 공격자가 객체를 만들
> 수 있는 스키마(고전적으로 `public`, 구버전 PostgreSQL에서 `CREATE`가 널리 부여됨)를 담고
> 있으면, 거기 심어진 테이블/함수/연산자가 진짜를 **가릴 수 있고** 애플리케이션은 눈치채지
> 못하고 실행한다.
`$user`는 건너뛴다 — 연결된 롤 이름의 스키마로 해석되고, 그 롤만 소유한다.
`DatabasePrivilegeReport`는 JDBC URL도, 패스워드도, 호스트도 담지 않는다 — actuator에
노출해도 안전하게 설계됐고, "publish 전에 마스킹해야 할 것은 애초에 들어가지 않는다."
### 12.2 `observation` — 태그를 유한하게
`JpaMetricTags`는 **다섯 개 태그**뿐이다: `persistence.unit`, `persistence.operation`,
`persistence.query`, `outcome`, `failure.category`. 없는 것이 요점이다 — 엔티티 id 없음,
tenant id 없음, SQL 파라미터 없음, 예외 메시지 없음.
검증이 **registry가 아니라 생성자**에서 일어난다 — unbounded 값이 대시보드가 안 뜰 때까지
살아남는 게 아니라 도입된 자리에서 실패하게.
`LowCardinality.REGISTERED = [a-zA-Z][a-zA-Z0-9._-]{0,95}`. **sanitize가 아니라 reject**다 —
sanitize하면 caller가 unbounded 값을 계속 넘기고 절대 눈치채지 못한다.
메트릭:
| 이름 | 종류 |
|---|---|
| `jpa.transaction.duration` | Timer |
| `jpa.transaction.rollback` | Counter |
| `jpa.transaction.timeout` | Counter |
| `jpa.transaction.completion.unknown` | Counter |
| `jpa.retry.attempt` | Counter (+ `retry.disposition` 태그) |
| `jpa.retry.attempts` | DistributionSummary |
| `jpa.retry.exhausted` | Counter |
| `jpa.query.duration` | Timer |
| `jpa.query.rows` | DistributionSummary |
`completion.unknown`**자기 카운터를 갖는** 이유:
> 사람이 봐야 하는 유일한 결과다: 다른 모든 실패는 **확실히 일어나지 않은** 트랜잭션이고,
> 이건 **일어났을 수도 있는** 트랜잭션이다. 일반 실패율에 묻는 게 눈치채지 못하게 되는 방식이다.
`JpaRetryObservation`이 재시도를 로그가 아니라 메트릭으로 만드는 이유:
> 재시도된 시도는 warning이 아니다. 낙관적 충돌과 직렬화 실패는 **동시성의 예상 비용**이고,
> 각각을 WARN으로 로깅하면 정확히 설계대로 동작하는 시스템 때문에 누군가를 호출하게 되고 —
> 그러고 나면 retry 로그가 필터링되면서 진짜 흥미로운 항목까지 같이 사라진다.
`jpa.query.rows`가 duration과 별개인 이유:
> 한 statement를 쏘고 2만 행을 hydrate하는 쿼리는 statement당 빠르고 요청당 재앙이며,
> duration 메트릭만으로는 **그냥 느리다**고 보고한다.
`SqlDiagnosticRedactor`는 의도적으로 뭉툭하다 — 문자열 리터럴, 숫자, 이메일 같은 것을 통째로
치환하고 512자로 자른다.
> "무해한" 값을 보존하려는 redactor는 **어느 컬럼이 개인정보를 담는지 알아야 하는데**, 그게
> 정확히 그것이 갖지 못한 지식이다.
### 12.3 `cache` — L2 캐시의 기본값 거부
`HibernateCacheGuard`가 두 가지를 거부한다.
**Query Cache는 꺼진 채로 둔다.**
> 쿼리+파라미터로 키가 잡힌 result-id 목록을 캐시하고, 그 엔트리는 **table-space
> timestamp**로 무효화된다 — 그래서 캐시된 쿼리가 건드리는 테이블에 대한 **어떤 쓰기든**
> 그 위의 모든 캐시된 쿼리를 무효화한다. write-active 테이블에서는 절약보다 비용이 크고,
> **조용히 그렇다.**
**`ENABLE_SELECTIVE`만.** `ALL`은 staleness가 성능 문제가 아니라 **정확성 문제**인 엔티티까지
캐시한다.
`requireBulkEviction`도 있다 — bulk DML은 L2를 완전히 우회하므로, 캐시된 엔티티를 bulk로
업데이트하면 region이 만료될 때까지 pre-update 값을 계속 제공한다.
`HibernateCachePolicy`가 **설정에 보이지 않는 두 운영 가정**을 기록한다.
- **외부 writer.** L2 캐시는 이 애플리케이션이 유일한 writer일 때만 coherent하다. 배치 job,
관리자 콘솔, 복제 스트림이 같은 테이블을 쓰면 캐시된 엔티티가 조용히 stale해지고, **어떤
캐시 설정도 그걸 감지할 수 없다.**
- **클러스터 무효화.** 인스턴스가 둘 이상이고 로컬 캐시면 한 노드의 eviction이 다른 노드에
도달하지 않는다. 분산 region 없이는 인스턴스 하나가 늘 때마다 **독립적인 stale 사본이
하나 더** 생긴다.
### 12.4 `envers` — 히스토리는 데이터 보호 문제
`EnversConfigurationGuard`가 막는 두 실패.
**Blanket enrolment.** 공유 base 클래스에 `@Audited`를 붙이면 그걸 상속하는 모든 엔티티가
등록된다.
**retention/PII 정책 없는 production.**
> audit 테이블은 기본적으로 모든 이전 값을 **영원히** 보관한다 — 나중의 정정이나 삭제가
> live row에서 제거한 개인 데이터까지. 그건 오래 돌수록 비싸지는 데이터 보호 문제다.
`EnversHistoryPolicy`가 retention을 **필수**로 받고, `productionReady()`
`piiPolicyDeclared && !auditedEntities.isEmpty()`.
`EnversRevisionMetadata`는 actor id와 correlation id만 담는다 — security principal 자체를
저장하면 역할/토큰/개인 속성이 세션보다 오래 사는 append-only 테이블에 복사된다.
Envers/Querydsl 둘 다 `compileOnly`다.
> 플랫폼이 계약을 구현하지만 Stable 런타임 클래스패스는 **둘 다 실어서는 안 된다.**
> `compileOnly`가 support 클래스를 컴파일하면서도 모든 배포에서 아티팩트를 빼고, opt-in하는
> 배포가 직접 아티팩트를 추가하며, 가드가 클래스 부재 시 capability를 거부한다.
### 12.5 두 개의 감사 메커니즘 (이건 문제다)
`docs/jpa/support-matrix.md`가 정직하게 적어 둔 부분.
| | `audit/AuditableEntity` | `auditing/AuditMetadata` |
|---|---|---|
| 형태 | `@MappedSuperclass` | `@Embeddable` |
| 컬럼 | `created_at/by`, `updated_at/by` | `created_at/by`, `modified_at/by` |
| actor 길이 | 256 | 64 |
| 캡처 | 어댑터가 명시적으로 stamp | Spring Data 리스너 (`@CreatedDate` 등) |
| 상태 | **canonical** | **candidate, 조립 안 됨** |
`JpaAuditMechanismRule.entitiesUseExactlyOneAuditMechanism`이 production 그래프에서 한
엔티티가 둘 다 쓰는 걸 막는다.
> 하나를 고른 엔티티는 일관된 계약을 얻는다. 둘 다 고른 엔티티는 **하나의 의미에 두 writer,
> 하나의 사실에 두 컬럼 계열**, 그리고 어느 엔티티가 뭘 골랐는지 알아야 하는 마이그레이션을
> 얻는다.
그리고 **어느 메커니즘도 bulk/native update에 도달하지 않는다.**
`bulkUpdatesOfAuditedEntitiesStampAudit``@Modifying` 메서드의 `@Query` 텍스트를 파싱해서
audited 엔티티에 대한 `update`가 audit 컬럼을 set하는지 확인한다.
> stamp하지 않은 bulk update는 **행이 마지막 평범한 save를 한 사람에 의해 마지막으로
> 수정됐다고 주장하게** 만든다 — 빠진 답이 아니라 **틀린 답**이다.
`bulkUpdateViolation`이 public static으로 노출된 이유도 좋다.
> 통과와 실패 케이스가 한 번도 실행된 적 없는 아키텍처 규칙은 **모양을 알 수 없는 규칙**이고,
> production 그래프에는 오늘 audited bulk update가 없어서 그래프만으로는 통과만 보여줄 수 있다.
`DomainContextAuditContextPort`는 actor를 `DomainContextPropagator`에서 읽고, 빈/blank면
`"system"`으로 떨어뜨린다 — scheduler / Flyway / anonymous 경로에서도 NOT NULL
`created_by`/`updated_by`를 항상 만족시키려고. `ACTOR_KEY`를 상수로 격리해서 canonical actor
key가 바뀌어도 **정확히 한 클래스**만 손대면 된다.
---
## 13. `experimental` — 전부 플래그 뒤에
8개 feature, 각각 `backend.jpa.experimental.*` 프로퍼티.
```
multitenancy-column | multitenancy-rls | multitenancy-schema | multitenancy-database
read-replica | jakarta-persistence-4 | hibernate-8 | postgresql-19
```
`ExperimentalFeatureGate`의 원칙:
> **클래스패스에 있는 것은 동의가 아니다.** experimental 모듈은 transitive하게 도착할 수
> 있고, jar가 있다는 이유로 스스로 켜지는 tenant-isolation이나 replica-routing 기능은 가능한
> 최악의 기본값이다. 게이트가 **결정의 부재를 활성화가 아니라 에러**로 만든다.
그리고 이게 실제로 강제되도록 세 클래스가 **생성자를 package-private으로 바꾸고 `enabledBy`
정적 팩토리만 남겼다**: `RlsTenantSessionBinder`, `ConsistencyAwareDataSourceRouter`,
`SchemaTenantMigrationOrchestrator`.
> 게이트 자신의 문서가 "클래스패스에 있는 게 실행 동의는 아니다"라고 말하지만 **아무것도
> 그걸 강제하지 않았다**: public 생성자는 `new` 하나가 완전한 우회라는 뜻이었고, 이건
> 우발적 활성화가 read를 엉뚱한 곳으로 보낼 때까지 보이지 않는 종류의 동작이다.
>
> 게이트를 static이 아니라 **파라미터로** 받는 게 요구사항을 시그니처의 일부로 만든다:
> caller는 컴파일러가 요구하는 인자를 잊을 수 없다.
### 13.1 RLS
`db/experimental-rls/V1__tenant_rls.sql`이 RLS가 실제로 격리하려면 참이어야 하는 세 가지를
전부 적어 둔다.
1. `ENABLE ROW LEVEL SECURITY` — 테이블에 policy를 켠다.
2. `FORCE ROW LEVEL SECURITY`**테이블 OWNER에게도** 적용. 없으면 owner는 자기 policy에서
면제되고, **owner는 흔히 마이그레이션 롤이고 그게 사람들이 테스트하는 롤이다.**
3. 런타임 롤이 `BYPASSRLS`를 가지면 안 된다 — 이건 테이블 속성이 아니라 롤 속성이라
`RlsPolicyVerifier`가 startup에 assert한다.
policy:
```sql
create policy tenant_scoped_item_isolation on tenant_scoped_item
using (tenant_id = current_setting('app.tenant_id', true))
with check (tenant_id = current_setting('app.tenant_id', true));
```
`current_setting`의 두 번째 인자 `true`가 설정이 없을 때 raise 대신 NULL을 반환하게 한다.
**NULL은 절대 `tenant_id`와 같지 않으므로, tenant가 바인딩되지 않은 쿼리는 0행을 반환하고
쓰기는 거부된다 — fail-closed.**
`RlsTenantSessionBinder`:
```java
private static final String BIND_SQL = "select set_config('app.tenant_id', ?, true)";
```
`true`(transaction-local)가 안전 속성 전부다.
> session-local 설정은 커넥션이 풀로 돌아가도 살아남고, 그 커넥션의 **다음 트랜잭션 — 아마도
> 다른 tenant의 것 — 이 그걸 상속해서 이전 tenant로서 모든 RLS policy를 통과한다.**
`RlsPolicyVerifier`가 RLS가 조용히 아무것도 안 하는 **세 가지 방법**을 전부 확인한다:
policy 없음/RLS 미활성, `BYPASSRLS` 보유, **런타임 롤이 테이블을 소유**(FORCE 없으면 면제).
unique index 하나에도 좋은 코멘트가 있다.
```sql
create unique index ux_tenant_scoped_item_tenant_value on tenant_scoped_item (tenant_id, value);
```
> tenant 격리가 이 컬럼에 의존하므로, 테이블에 대한 **모든 uniqueness 요구**에 들어가야
> 한다: `(value)`만의 unique index는 다른 tenant가 그 값을 이미 썼다는 이유로 한 tenant의
> insert를 실패하게 만드는데, 그건 버그이자 **정보 유출**이다.
### 13.2 tenant column / schema / database
`TenantContext`(ThreadLocal)는 fail-closed다.
> shared-schema 배포에서 tenant가 바인딩되지 않았다는 건 tenant predicate 없는 쿼리라는 뜻이고,
> 그건 **모든 tenant의 행을 반환한다 — 성공한 요청처럼 보이는 cross-tenant 데이터 유출.**
`TenantAwareRepositoryGuard`가 존재하는 이유:
> **Hibernate filter는 보안 경계가 아니다.** filter는 엔티티 쿼리에 적용되고, native SQL,
> bulk DML, `getReference`, L2 캐시를 통해 도달하는 것에는 적용되지 않는다 — 그래서 filter만
> 믿는 설계는 다른 tenant의 행으로 가는 경로 여러 개를 활짝 열어 둔다.
`TenantEntityListenerGuard``@PrePersist`에서 stamp하고 `@PreUpdate`에서 cross-tenant
이동을 거부한다.
> 읽기는 격리 문제의 절반일 뿐이다. tenant 컬럼이 틀리거나 없는 쓰기는 **쓰는 tenant는 볼 수
> 없고 다른 tenant는 볼 수 있는 행**을 넣는다.
`TenantId` 정규식 `[a-z0-9][a-z0-9_-]{1,62}`.
> tenant id가 스키마 이름, `set_config` 값, 라우팅 키에 들어간다. `../public` 같은 tenant id가
> schema-per-tenant를 **path-traversal 문제**로 만든다.
그리고 **절대 메트릭 태그가 되지 않는다** — tenant 카디널리티는 정의상 unbounded이고, 텔레메트리
안의 tenant id는 그렇게 취급되지 않는 시스템 안의 고객 데이터다.
`SchemaTenantRegistry`가 스키마를 사전 등록한다. 스키마 이름은 statement의 일부라 파라미터로
바인딩할 수 없으므로, tenant id에서 유도하면 외부 영향 데이터로 SQL을 만드는 것이다.
`SchemaMultiTenantConnectionProvider`의 **reset**이 중요하다.
> `search_path`는 세션 설정이라, 풀로 돌아간 커넥션은 여전히 마지막 tenant의 스키마를 들고
> 있다. 다음 borrower — 아마 다른 tenant, 또는 tenant 없는 백그라운드 job — 가 **어떤
> statement도 틀리지 않은 채로** 거기서 읽고 쓴다.
`NEUTRAL_SCHEMA = "pg_catalog"`로 되돌린다.
`SchemaTenantMigrationOrchestrator`의 발견 하나:
> `MigrateResult.targetSchemaVersion`은 **이미 최신이라 아무것도 적용하지 않은 실행에서
> 비어 있다.** 비어 있는 걸 기록하면 partial rollout 동안 마이그레이션된 tenant를
> 미마이그레이션으로 보고한다. 그래서 **history 테이블에서 읽어 온다.**
`TenantMigrationStatus``failureType`은 예외의 **simple name만** 담는다 — Flyway 실패
메시지는 스크립트 경로와 실패한 statement 일부를 담는다.
`TenantPoolBudget`이 **두 개의 ceiling**을 갖는 이유:
> database-per-tenant는 특정한 방식으로 실패한다: 각 tenant의 풀은 개별적으로 합리적이고
> 그 **합이 아니다.** 50개 tenant × 10 커넥션 = `max_connections`가 100인 서버에 500 커넥션,
> 그리고 실패는 **idle이던 것 포함 모든 tenant에 동시에** connection refusal로 도착한다.
>
> pool count만으로는 풀 크기가 다르다는 걸 무시하고, connection total만으로는 각자 스레드와
> 모니터링을 가진 무한한 수의 작은 풀을 허용한다.
`TenantDataSourceLifecycle.drainAndClose`가 close 실패를 삼키는 것도 의도다 — 서버가 이미
사라진 풀은 close에서 던지는데, 그걸 전파하면 loop 중간에 나머지 풀을 방치해서 **eviction이
해제하려던 바로 그 커넥션을 누수시킨다.**
### 13.3 read replica
`ConsistencyAwareDataSourceRouter`의 규칙이 전부 `readOnly=true`만으로는 틀리는 방식이다.
- write 트랜잭션은 당연히 primary — **그런데 락을 잡는 read 트랜잭션도** primary다. **replica
위의 락은 아무것도 지키지 않기 때문.**
- read-after-write는 replica가 **증명될 때까지** primary. 이게 "저장했는데 저장이 안 됐어요"를
만드는 케이스다.
- lag 증거를 못 얻으면 primary. **증거의 부재는 신선함의 증거가 아니다.**
결정은 **한 번** 내려지고 트랜잭션 전체에 유지된다.
> 중간에 바꾸면 하나의 작업 단위 안에서 두 DB에 두 커넥션을 갖게 되고, **각각이 보는 것
> 사이에 아무 관계가 없다.**
`ReplicaLagMonitor.replayedThrough()``Optional`인 이유:
> lag를 측정할 수 없을 때 — monitor가 죽었거나, replica가 unreachable하거나, metric이
> stale하거나 — 올바른 답은 **"모른다"**이고, 라우터는 그걸 primary를 쓸 이유로 취급해야 한다.
> 실패 시 lag 0을 반환하는 monitor는 **정확히 뭔가 잘못됐을 때** replica로 read를 보낸다.
`ReplicaRoutingDecision``reason`을 들고 다니는 것도 좋다 — "이 read가 왜 stale 데이터를
봤나"는 그렇지 않으면 로그에서 답할 수 없다.
### 13.4 forward-compat lane
`CompatibilityLane``publicationEnabled`는 항상 false다.
> lane은 플랫폼이 더 새로운 spec/provider/server에서 여전히 동작하는지 **알아내기 위해**
> 존재한다. lane에서 컴파일된 아티팩트를 publish하면 소비자가 그 답이 나오기 전에 그것에
> 의존할 수 있게 된다.
`ExperimentalPromotionGate`는 기술 증거 5개(compatibility/security/failure/migration/
performance) **전부** + 리뷰된 ADR을 요구한다.
> 기술 suite는 **동작한다**는 걸 확립하고, ADR은 **플랫폼이 그걸 약속해야 한다고 누군가
> 결정했다**는 걸 기록한다 — 그 약속이 지는 지원 부담 포함.
`PromotionEvidence`에 **버전 가용성이 없는 것**이 요점이다.
> "PostgreSQL 19가 나왔다"는 세상에 대한 사실이고, "우리 계약이 그 위에서 통과한다"는 이
> 플랫폼에 대한 사실이며, **두 번째만이** support matrix가 약속하는 것을 바꾸는 것을
> 정당화한다.
---
## 14. 조립 — app-bootstrap 배선
### 14.1 마스터 스위치와 구조적 off
```
ca-skeleton.persistence-jpa.enabled (APP_PERSISTENCE_JPA_ENABLED, 기본 false)
PersistenceJpaRootAutoConfiguration @AutoConfiguration @ConditionalOnProperty
↓ @Import
JpaAdapterComponentsConfig ← 6개 패키지 component scan
PostgreSqlPersistenceConfig ← vendor=postgresql (기본)
H2PersistenceConfig ← vendor=h2
FileserverJpaPersistenceConfig ← app.fileserver-platform.enabled
NotificationJpaPersistenceFacade ← ca-skeleton.notification.platform.enabled
MigrationStartupConfig
JpaPlatformRuntimeAutoConfiguration ← 8개 add-on 빈
```
> **모든 JPA 설정이 component scan이 아니라 이 import를 통해 도달**한다. 그래서 다음 달에
> 그중 하나에 추가된 빈이 아무도 조건을 반복하는 걸 기억하지 않아도 게이트된다. 그게 **꺼진
> capability**와 **알려진 빈들이 개별적으로 조건화된 capability**의 차이다.
`JpaOffAutoConfigurationImportFilter`가 한 층 더 있다.
> 이 저장소 자신의 설정만 조건화하는 건 부족하다: JPA/Flyway starter가 Boot의 import
> metadata를 통해 자기 것을 기여하므로, 평범한 `@EnableAutoConfiguration` 애플리케이션은
> **어떤 프로젝트 조건이 뭐라 하든 풀을 열고 마이그레이션을 돌린다.**
필터가 막는 10개: `DataSourceAutoConfiguration`, `JdbcTemplateAutoConfiguration`,
`JdbcClientAutoConfiguration`, `DataSourceTransactionManagerAutoConfiguration`,
`DataSourceHealthContributorAutoConfiguration`, `DataSourcePoolMetricsAutoConfiguration`,
`HibernateJpaAutoConfiguration`, `JpaBaseConfiguration`, `JpaRepositoriesAutoConfiguration`,
`FlywayAutoConfiguration`.
그리고 질문을 **"JPA가 켜졌나"가 아니라 "관계형 커넥션이 필요한 capability가 있나"**로 던진다.
`DataSourceRequirement.reasons(environment)`가 6개를 확인한다.
```
ca-skeleton.persistence-jpa.enabled = true
ca-skeleton.outbox.enabled = true
ca-skeleton.capabilities.idempotency.provider = jdbc
ca-skeleton.lock.multi-instance-enabled = true
notification.platform.enabled && persistence != none
app.fileserver-platform.enabled && transaction-provider != none
```
> 풀은 JPA의 사유물이 아니다. outbox, JDBC idempotency store, multi-instance lock,
> notification store, Fileserver 트랜잭션 경로가 전부 필요로 하고, "JPA 꺼졌나?"만 물으면
> **아무도 안 쓰는 풀을 열거나, 정당하게 쓰던 capability를 조용히 망가뜨린다.**
필터의 실패 모드도 정직하게 적혀 있다: 목록의 오타는 **조용히 fail-open**한다(필터가 그냥
매치하지 않음). 그래서 테스트가 반환값이 아니라 **빈 부재**로 assert한다.
### 14.2 `JpaAdapterComponentsConfig` — scan을 되살린 기록
이 클래스의 javadoc이 이 저장소 전체에서 제일 값진 사고 기록 중 하나다.
> 컴포지션 루트의 `@ComponentScan`이 `dev.caskeleton.adapter.outbound.persistence.**`를
> 정규식으로 제외하고, 그 제외는 **옳다**: 그게 optional capability를 optional하게 만든다.
>
> 빠진 건 나머지 절반이다. 이 leaf의 여덟 클래스가 scanned component로 쓰여 있는데 —
> `SpringTransactionPort`, `PersistenceExceptionTranslator`, `StandardSqlStateErrorMapping`,
> `DomainContextAuditContextPort`, idempotency store와 reaper, outbox store와 reaper —
> 넓은 scan이 그들에게 도달하기를 멈추자 **아무것도 도달하지 않았다.** `@Component`와
> `@Repository`가 붙어 있고 **돌고 있는 어떤 애플리케이션에서도 빈이 아니었다**:
> 특히 `TransactionPort`는 구현이 전혀 없어서, **트랜잭션을 여는 모든 유스케이스가 열
> 포트를 갖지 못했다.**
>
> 그게 unsatisfied dependency로 나타난 건 트랜잭션이 필요한 capability가 실제로 조립될 때 —
> `local-notification-ingest` 레인의 notification orchestrator — 였고, **단위 테스트가 볼
> 수 있는 건 아무것도 아니었다.** 이 클래스들 각각이 자기 테스트에서 직접 생성되기 때문.
scan 대상 6개: `audit`, `failure`, `idempotency`, `lock`, `outbox`, `transaction`.
`fileserver``notification`은 일부러 빠졌다(각자 자기 스위치를 가진 config가 소유).
`lock`이 목록에 있는 이유도 같은 역사다.
> `DistributedLockPersistenceConfig`가 두 lock provider를 다 소유하는데 **테스트가
> `ctx.register(...)`를 호출하는 것 말고는 아무것도 등록하지 않았다.** 그래서 단일 인스턴스
> 배포는 `DistributedLockPort`가 아예 없었고, 다중 인스턴스 배포는 시작할 수 없었다:
> 컴포지션 루트 자신의 `DistributedLockConfig`가 그 설정만 선언하는 `jdbcDistributedLock`
> 한정 빈을 요구한다.
### 14.3 `PersistenceJpaConfig` — 엔티티 스캔 목록
`@EntityScan` + `@EnableJpaRepositories`가 **패키지를 열거**한다(root를 주지 않는다). 그리고
`notification`, `fileserver` 두 개가 의도적으로 빠져 있다.
> 둘 다 **opt-in** capability이고 스키마 스트림이 기본 Flyway location에 없어서, 요청하지
> 않은 배포에는 테이블이 존재하지 않는다. root 전체를 스캔하면 그 엔티티들이 무조건
> persistence unit에 들어가고, `ddl-auto=validate`가 **두 capability를 다 끈 배포에서**
> `notification_request`와 `fs_cleanup_item`을 찾다가 boot를 실패시켰다.
>
> fileserver 쪽이 더 비쌌다: **JPA 켜진 모든 Compose 레인을 막았고**, H2에서는 보이지
> 않았다 — H2의 `create-drop`은 엔티티가 기술하는 걸 그대로 만든다. **진짜 마이그레이션
> 히스토리를 가진 진짜 PostgreSQL이 있어야 보였다.**
새 always-installed 하위 패키지를 여기 추가하지 않는 건 컴파일 에러가 아니라 **조용한 누락**이라
`PersistenceEntityScanCoverageTest`가 확인한다.
### 14.4 add-on 빈과 조건 순서 버그
`JpaPlatformRuntimeAutoConfiguration`의 javadoc에 두 개의 좋은 기록이 있다.
**(1) 이름만 AutoConfiguration이던 세 클래스**
> 이 패키지의 세 클래스가 `...AutoConfiguration`이라 이름 붙었고 **plain factory였다**:
> `@AutoConfiguration`도, `@Bean`도, `AutoConfiguration.imports` 엔트리도 없었다 —
> 그리고 `CaSkeletonApplication`은 이 패키지를 component scan에서 일부러 제외한다.
> 그래서 capability 리포트는 transaction retry, completion evidence, observability를
> Stable로 나열했고 **돌고 있는 컨텍스트에는 그중 아무것도 없었다.**
> 개발자가 **재시도되지 않는 재시도에 의존하는 코드를 배포할 수 있었다.**
**(2) `@ConditionalOnBean(DataSource.class)`의 평가 시점**
> 이 클래스는 `PersistenceJpaRootAutoConfiguration`이 **import**하지 auto-configure하는 게
> 아니라, 그 조건이 **클래스가 파싱되는 동안 — datasource 빈 정의가 존재하기 전에** 평가됐고
> 따라서 **모든 실제 배포에서 false**였다. 아래 여덟 빈이 전부 조용히 사라졌고, 아무것도
> 그중 어느 것에도 의존하지 않아서 아무것도 그걸 보고하지 않았다. datasource validator가
> 마침내 caller에 배선되고 JPA Compose 레인이 "No qualifying bean"이라고 답했을 때에야
> 드러났다.
`PersistenceJpaRootAutoConfiguration``jpaResolvedDataSourceCheck`에도 같은 함정 회피가
기록되어 있다 — validator를 주입받지 않고 **직접 생성**한다.
> 그건 stateless이고, 그것을 공급하던 빈은 `JpaPlatformRuntimeAutoConfiguration`에서
> 오는데 그 클래스가 plain `@Configuration`에 `@ConditionalOnBean(DataSource.class)`를
> 달고 있었다 — 즉 **그 클래스가 사라진 것과 같은 이유로 이 검사도 사라졌을 것이다.**
### 14.5 startup 가드
| 가드 | 하는 일 |
|---|---|
| `JpaDangerousConfigurationGuard` | `spring.jpa.open-in-view=true` (local convenience 프로필 외), 스키마 변경 `ddl-auto` 거부 |
| `JpaDataSourceProfileValidator` | resolved `DataSource`를 열어서 product/version 확인 |
| `PostgreSqlVersionPolicy` | PostgreSQL 16/17/18만 허용 |
| `PersistenceVendorProdSafetyValidator` | prod에서 H2 거부 |
| `HikariPoolConstraintValidator` | `spring.datasource.hikari.*` 제약 |
| `NotificationSchemaActivation` / `FileserverSchemaActivation` | capability 스트림 승격 확인 |
`JpaSafetySettings.enabled``@DefaultValue("true")`인 이유도 사고다.
> primitive `boolean`은 프로퍼티가 없으면 `false`로 바인딩되므로, 이 record는 `defaults()`에서
> "기본값은 enabled"라고 말하면서 아무것도 설정하지 않은 애플리케이션은 **안전 스위치가
> 꺼진 채로** 받았다 — 두 답이 **한 번도 그것에 대해 생각해 본 적 없는 배포에서 정확히**
> 어긋났다.
`localConvenienceProfiles`가 boolean이 아니라 목록인 이유: "local에서 켜짐"이 누군가 프로필
이름을 재사용하는 첫 순간에 조용히 "staging에서 켜짐"을 의미하지 않게.
`JpaDataSourceProfileValidator`의 자기비판도 좋다.
> 다른 datasource를 검증하고 있었다. bound가 `app.jpa-platform.datasource.*`에 바인딩된
> `JpaDataSourceSettings`에서 왔는데 **요청을 서비스하는 풀은 `spring.datasource.hikari.*`에서**
> 만들어진다 — 하나의 풀에 두 개의 기술, 그리고 validator는 **사용되지 않는 기술에 대해
> 통과할 수 있다.**
>
> 더 나쁜 건, 그 평행 네임스페이스가 어떤 shipped YAML에도, env-key 레지스트리의 어떤 행에도
> 나타나지 않아서 두 필드가 항상 null이었고 `requirePoolBounds`가 **모든 배포에서 던졌을
> 것**이다. 아무것도 그걸 호출하지 않아서 아무것도 실패하지 않았다. **서로를 상쇄한 두
> 결함이고, 애플리케이션이 시작한 이유는 두 번째가 첫 번째를 숨겼기 때문이다.**
### 14.6 실제 설정 값
`ca-skeleton.jpa.transaction.*` (application.yml):
```yaml
transaction-timeout: 30s begin-budget: 250ms minimum-action-window: 1s
completion-margin: 500ms statement-timeout: 10s lock-timeout: 2s
idle-guard-timeout: 15s transaction-margin: 250ms lock-margin: 100ms
retry-base-delay: 10ms retry-maximum-delay: 50ms retry-maximum-attempts: 2
```
Hikari:
```yaml
maximum-pool-size: ${APP_DATASOURCE_POOL_MAX_SIZE:10}
minimum-idle: ${APP_DATASOURCE_POOL_MIN_IDLE:2}
connection-timeout: ${APP_DATASOURCE_CONNECTION_TIMEOUT:5000} # 밀리초 정수만!
idle-timeout: 600000 max-lifetime: 1800000 keepalive-time: 120000
leak-detection-threshold: 30000 validation-timeout: 3000 initialization-fail-timeout: 1
```
`connection-timeout` 주석에 또 하나의 사고가 있다.
> **PLAIN MILLISECONDS ONLY.** 이 키는 `HikariConfig#setConnectionTimeout`(long)에
> 바인딩되므로 binder가 `"5s"`를 즉시 거부한다. `"5s"`로 출시됐고 **모든 prod/dev 배포가
> 시작에 실패했는데** local은 `application-local.yml`이 값을 override해서 통과했다 —
> 그래서 **prod 레인만이 그걸 찾을 수 있었다.** `HikariPoolConstraintValidator`는 이 키를
> `DurationStyle`로 읽어서 `"5s"`를 기꺼이 받아들인다; **그 관대함이 잘못된 기본값을
> 지원되는 것처럼 보이게 만들었다.**
Flyway는 세 옵션이 **pin**되어 있다(env-driven 아님): `baseline-on-migrate: false`,
`out-of-order: false`, `clean-disabled: true`. `FlywayProdSafetyValidator`가 prod에서 override를
거부(exit 71).
`ca-skeleton.outbox.*`: `poll-interval: PT5S`, `batch-size: 20`, `in-flight-timeout: PT5M`,
`reaper-interval: PT10M`, `published-retention: P7D`.
`ca-skeleton.idempotency.ttl: 24h`, `reaper-interval: 10m`.
---
## 15. 검증 — 레인, readiness card, evidence manifest
### 15.1 소스 세트와 Gradle 레인
```groovy
strictTestLanes {
sourceSet('postgresqlIntegrationTest') { compilesAgainst 'main'; inherits ... }
sourceSet('testkit') { compilesAgainst 'main' }
sourceSet('jpaPlatformPerformanceTest') { compilesAgainst 'main', 'testkit' }
}
testkitPublisher { consumedBy 'test', 'postgresqlIntegrationTest'; publishAs 'jpaTestkit' }
```
| Gradle task | 태그/필터 | 내용 |
|---|---|---|
| `test` | — | hermetic 단위 |
| `jpaPlatformContractTest` | `jpa-contract` | 실 PostgreSQL 계약 |
| `jpaPlatformMigrationTest` | `jpa-migration` | Flyway 업그레이드 시나리오 |
| `jpaPlatformFailureTest` | `jpa-failure` | 데드락, 직렬화, 커밋 모호성 |
| `jpaPlatformQueryPlanTest` | `jpa-queryplan` | EXPLAIN 구조 |
| `jpaPlatformSecurityTest` | `jpa-security` | 런타임 롤 권한 |
| `jpaPlatformPoolContractTest` | — | Hikari 풀 / REQUIRES_NEW 동작 |
| `jpaPlatformReleaseGate` | — | 위 전부 집계 |
그 밖에 **readiness task 15개**가 클래스별로 등록되어 있다
(`postgresqlLifecycleIntegrationTest`, `postgresqlSecurityBaselineIntegrationTest`,
`postgresqlMigrationIntegrationTest`, `postgresqlTransactionIntegrationTest`,
`postgresqlAggregateIntegrationTest`, `postgresqlQueryIntegrationTest`,
`postgresqlIdempotencyIntegrationTest`, `postgresqlOutboxStorageIntegrationTest`,
`postgresqlOutboxPollingIntegrationTest`, `postgresqlInboxIntegrationTest`,
`postgresqlFileserver*IntegrationTest` 3개, `postgresqlNotificationSchemaActivationIntegrationTest`).
**모든 레인이 fail-closed다.**
```groovy
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
```
> `failOnNoDiscoveredTests`가 여기서는 평소보다 중요하다: 아무것도 발견하지 못한 선택된
> 레인은 **성공을 보고**하고, 조용히 돌기를 멈춘 계약 suite는 **통과하는 것과 구별되지
> 않는다.**
Docker 부재도 skip이 아니라 **에러**다.
```java
throw new IllegalStateException(
"Docker is required for the PostgreSQL contract suite and is not available; this lane"
+ " fails closed rather than skipping, because a skipped contract reports success"
+ " for a database nobody tested");
```
`PostgreSqlVersion`은 정확히 16/17/18. `parseSelection("")`은 **에러**다 —
"빈 PostgreSQL 매트릭스 선택은 빈 실행이 아니라 에러다."
`PostgreSqlContractExtension`이 컨테이너를 **버전별로 JVM 전체에 캐시**한다.
### 15.2 두 개의 SQL 안전 게이트 (Gradle 태스크)
`verifyJpaSqlConstructionSafety`가 벤더 소스를 정규식으로 훑는다.
```groovy
def concatenatedSql = text =~ /(?s)(createNativeQuery|queryForObject|update)\s*\([^;]*"\s*\+/
if (line.contains("set_config('") && !line.contains('?')) { violation }
```
`verifyJpaSecurityFixtures`는 보안 픽스처에 `runtimeRoleCannotCreateInApplicationSchema`,
`assertDockerAvailable`, `42501` 문자열이 있는지 확인한다.
### 15.3 `jpaPlatformPoolContractTest` — 이름을 바꾼 이유
이 build.gradle 코멘트가 특히 좋다.
> `jpaPlatformPerformanceTest`였고, pool과 REQUIRES_NEW 압력을 **certify**한다고 기술됐고,
> **나타나는 모든 곳에서 off가 기본인 boolean 뒤에** 게이트되어 있었다 — 이 파일에서도,
> 명시적으로 off로 설정한 nightly workflow에서도. 그래서 릴리스 게이트가 **유일한 threshold
> assertion이 "threshold를 assert하지 않고 있다"인 레인**에 의존했고, "certified"는 **어떤
> latency나 throughput bound도 무언가와 비교된 적 없는 실행**을 기술했다.
>
> 프로퍼티는 사라졌다; 그 이름은 여기 일부러 반복하지 않는데, **주석 속의 이름이 다음
> 사람이 설정해 보려는 바로 그것**이기 때문이다.
지금 이름은 약속하지 않는다 — REQUIRES_NEW depth 1이 동시 스레드당 커넥션 2개를 필요로 하고,
포화된 풀이 pending count를 보고하고, caller가 커넥션 없이 진행하지 않고 기다린다는 **행동
계약**만 검증한다. 진짜 성능 게이트는 전용 러너, warmup/sample 수, 기록된 threshold가 필요하고
그때는 별도 레인이어야 한다.
### 15.4 `release-registry.json` — 문서를 렌더링으로
`config/jpa/release-registry.json`이 타입 있는 SSOT다.
```json
"databases": [
{ "major": 16, "support-level": "stable", "image": "postgres:16-alpine" },
{ "major": 17, "support-level": "stable", "image": "postgres:17-alpine" },
{ "major": 18, "support-level": "stable", "image": "postgres:18-alpine" },
{ "major": 19, "support-level": "experimental", "image": "postgres:19-alpine" }
],
"provider": { "name": "hibernate-orm",
"stable-tested-baseline": "7.1.8.Final",
"compatibility-target": "7.4" },
"gates": [ 6, 각각 name + 실제 Gradle task path + blocking ]
```
이게 왜 생겼는지가 `_comment`에 있다.
> 예전엔 산문이었다. `JpaReleaseManifest`가 support matrix가 언급하는 모든 `PostgreSQL NN`과
> 그것이 담은 모든 `` `name` | gate ``를 수집했고, **매치가 어느 테이블에서 왔는지, 그 행이
> 어떤 지원 수준을 선언했는지 전혀 몰랐다.** 그래서 Experimental PG19가 Stable major와 같은
> 목록에 들어갔고, 문장에서 한 번 언급된 버전이 supported로 카운트됐고, **PG17을
> Experimental로 강등해도 문자열이 문서 어딘가에 살아남는 한 아무것도 바뀌지 않았다.**
그리고 릴리스 매트릭스 버그도 하나 더.
> 릴리스 레인이 `-Pjpa.matrix.versions=16,17,18`을 `selectedVersions().get(0)`을 쓰는
> `JpaPlatformContractSupport.start()`에 넘겼고, **통합 suite 전체가 PostgreSQL 16에 대해
> 돌았으며**, 이 테이블은 **3개 assertion짜리 smoke test의 힘으로** 17과 18을 완전 커버로
> 기록했다.
지금은 `start()`가 다중 버전 선택을 아예 거부하고, `jpa-release.yml`이 major당 job으로
fan-out하고, promotion job이 세 major의 증거가 **같은 commit SHA**를 담기를 요구한다.
`JpaReleaseRenderingTest`가 DB 테이블, gate 테이블, workflow의 matrix/promotion 목록을
레지스트리와 비교하고, `verifyJpaReleaseGateTasks`가 모든 gate의 task를 **실제 Gradle task
그래프에 대해 resolve**한다.
### 15.5 `readiness-cards.yaml` — 17개 capability card
`config/jpa/readiness-cards.yaml` (718줄)이 card/task/scenario 매핑의 SSOT다.
| card | state | schema-stream |
|---|---|---|
| `jpa-observability-lifecycle` | selected | none |
| `jpa-security-baseline` | selected | none |
| `jpa-flyway-migration` | selected | owned |
| `jpa-transaction-runtime` | selected | none |
| `jpa-aggregate-store` | selected | contributes-to-core |
| `jpa-query-model` | selected | contributes-to-core |
| `jpa-primary-foundation` | selected | none |
| `jpa-idempotency-owner-safe-v2` | **implemented-candidate** | owned |
| `jpa-outbox-storage-v2` | **implemented-candidate** | owned |
| `jpa-outbox-polling-delivery-v2` | **implemented-candidate** | owned |
| `jpa-inbox-same-store-v1` | **implemented-candidate** | owned |
| `jpa-fileserver-metadata-v1` | **implemented-candidate** | owned |
| `jpa-notification-platform-v4` | **implemented-candidate** | owned |
| `jpa-outbox-cdc-retention-v1` | not-implemented | none |
| `jpa-primary-replica` | not-implemented | none |
| `jpa-tenant-discriminator-rls` | not-implemented | owned |
| `jpa-jdbc-efficiency-coordination` | not-implemented | owned |
각 card가 `required-evidence` 목록과 `evidence.scenarios`(정확한 JUnit selector +
covers 목록)를 갖는다. `verifyJpaReadinessRegistryContract`가 unknown claim, duplicate
selector, 다른 card의 task 차용을 **mutation test로** 거부한다.
`legacy-adoption` 블록:
```json
{ "state": "transition-only", "location": "db/migration/postgresql",
"history-table": "flyway_schema_history",
"immutable-applied-versions": [1, 3, 4, 5],
"allowed-origin": "LEGACY_ADOPTED" }
```
### 15.6 evidence manifest와 R2 게이트
`gradle/jpa-evidence.gradle` (917줄).
```bash
./gradlew :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence --console=plain
```
active card 11개의 producer를 실행하고 **JUnit XML에서 exact selector와
executed/skipped/failure/error 수를 읽는다.** 각 manifest는 source revision/dirty digest,
prerequisite manifest ID, **PostgreSQL image digest**, pgjdbc/Hibernate/Flyway version,
topology, migration/dispatch metadata를 담고 canonical JSON SHA-256 이름으로 생성된다.
```
build/jpa-evidence/manifests/<card-id>/<sha256>.json
```
후보 검증은 zero-skip / schema / content hash / prerequisite link가 맞으면 성공하지만
**`attainedReadiness=R1`을 유지한다.** 각 manifest가 candidate profile, dirty source, 아직
R2가 아닌 prerequisite를 `readinessBlockers`에 보존해서 **후보 통과를 R2로 오인할 수 없다.**
진짜 aggregation 게이트는 별도다.
```bash
./gradlew :adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence \
-PjpaEvidenceProfile=r2 --console=plain
```
clean revision, `JPA_EVIDENCE_CI_JOB`, `JPA_EVIDENCE_ARTIFACT_LOCATION`, immutable PostgreSQL
image digest, 모든 required evidence, R2 prerequisite DAG가 있어야만 성공한다. 로컬 dirty
worktree나 unpublished 실행은 `worktree-is-dirty` / CI provenance blocker를 보고 **실패하는
것이 정식 동작**이다.
### 15.7 ArchUnit 규칙 팩
`testkit/arch``JpaArchitectureRules`:
| 규칙 | 막는 것 |
|---|---|
| `noEntityFromWeb()` | 컨트롤러가 엔티티 반환 → 트랜잭션 밖에서 lazy association 직렬화 |
| `entitiesFollowPortableMappingRules()` | `final` 엔티티(프록시 불가 → 모든 lazy 참조가 eager), no-arg 생성자 |
| `entitiesStayOutOfWebPackages()` | web 패키지 안의 엔티티는 노출될 엔티티 |
| `domainDoesNotDependOnHibernate()` | 도메인이 ORM에 의존 |
| `tenantScopedRepositoriesDoNotInheritBroadCrud()` | tenant-scoped 엔티티 리포지토리가 CRUD 상속 |
| `noGenericRepository()` | 플랫폼이 `CrudRepository` 재구현 |
`tenantScopedRepositoriesDoNotInheritBroadCrud`의 설명이 좋다.
> **이름은 중요한 속성이 아니다.** `tenantId`를 가진 엔티티에 대해 `JpaRepository`를 확장하는
> `OrderRepository`는 `findById(UUID)`, `findAll()`, `deleteById(UUID)`를 노출한다 — 전부
> tenant-blind, 전부 상속, 그리고 **리뷰어가 볼 만한 어디에도 적혀 있지 않다.** 그게
> cross-tenant read가 사고로 쓰이는 방식이고, 이름 기반 규칙이 볼 수 없는 것이다.
규칙 팩이 `main`이 아니라 `testkit`에 있는 이유: ArchUnit은 테스트 라이브러리고,
`main`에 두면 테스트에서만 도는 코드를 위해 **모든 배포의 런타임 클래스패스**에 끌려간다.
`allowEmptyShould(true)`가 붙은 규칙들에도 정직한 코멘트가 있다.
> 그런 타입이 없는 게 통과 상태이고, 이 규칙의 일은 그걸 유지하는 것이다. 중요한 emptiness —
> **아무것도 찾지 못한 import** — 는 어떤 규칙이 돌기 전에 production suite가 따로 assert한다.
---
## 16. 읽고 나서 정리한 설계 원칙 14가지
코드 전체를 관통하는 원칙을 뽑아 봤다. 이 모듈에서 배울 게 있다면 대부분 여기에 있다.
**1. 빠뜨림이 통과가 되는 게이트는 게이트가 아니다.**
`JpaModuleBoundaryTest`의 카탈로그 **정확한 동등성** 검사, `PersistenceEntityScanCoverageTest`,
`failOnNoDiscoveredTests = true`, `verifyDocumentedLeafCount`의 트리 walk. 전부 같은 교훈이다 —
"목록에 추가하는 걸 잊으면 초록불"인 구조를 제거한다.
**2. 모르는 것은 일급 결과여야 한다.**
`RetryDisposition.RECONCILE`, `TransactionResult.Indeterminate`,
`WriteDisposition.UNDETERMINED`, `TransactionCompletionEvidence.UNKNOWN`,
`ReplicaLagMonitor.replayedThrough(): Optional`. "모른다"를 성공이나 실패로 접으면 그
정보가 영원히 사라진다.
**3. 위험한 상태는 타입이 표현할 수 없게 만든다.**
`JpaFailureContext`가 retryable + completionUnknown 조합을 거부한다. `RetryProfile`
`COMPLETION_UNKNOWN`을 화이트리스트에 못 넣게 한다. 리뷰 규칙이 아니라 생성자다.
**4. 이름은 값이 아니라 registry key다.**
쿼리 이름, 큐 이름, 업서트 이름, JSON path 이름, COPY 이름 전부. **이름이 statement를
선택하지, statement의 일부가 되지 않는다.** 이게 SQL injection 표면 전체를 없앤다.
**5. path/identifier는 등록, value는 바인딩.**
JSON path, 정렬 필드, 스키마 이름, conflict 컬럼, `ON CONFLICT` 타깃 — 전부 파라미터로 바인딩할
**없는** 것들이라 registry로 고정한다. 그 외 전부는 bound parameter.
**6. 시간은 DB에서, 그리고 락 이후에 읽는다.**
`clock_timestamp()``for update` 다음에. 애플리케이션 시계로 lease 만료를 판단하면 lease를
쓴 머신이 아닌 곳에서 판단하는 것이고, 락 전에 DB 시계를 읽으면 행이 바뀔 수 있는 시점 이전의
순간으로 판단하는 것이다.
**7. CAS 튜플을 SQL where 절에 전부 반복하고, update count를 답으로 쓴다.**
읽고 나서 PK만으로 update하면 그 사이에 takeover한 worker의 상태를 덮어쓴다. V2 구현 넷과
fileserver 전이가 전부 이 패턴이다.
**8. 관측을 위한 데이터가 관측 대상보다 위험할 수 있다.**
메트릭 태그, 예외 메시지, 로그가 전부 PII 유출 경로다. `LowCardinality`, `JpaMetricTags`,
`SqlDiagnosticRedactor`, `JpaFailureContext`의 redaction이 전부 그것 때문이다.
**9. 스키마는 마이그레이션이 소유하고, ORM은 확인만 한다.**
그리고 그걸 **런타임 롤에서 DDL 권한을 뺌으로써** 강제 가능하게 만든다. 정책 문서가 아니라
`has_schema_privilege`.
**10. 기능이 꺼졌다는 건 구조적이어야 한다.**
빈이 없고, 소켓/풀/스레드가 없고, 설정이 바인딩되지 않고, 스키마 기대치도 없다. 조건을
빈마다 반복하는 게 아니라 **import 지점 하나**에 건다.
**11. 로컬 환경이 다른 DB면 로컬 테스트는 다른 시스템에 대한 진술이다.**
`char(64)``fs_cleanup_item` 두 사건이 `application-local.yml`을 PostgreSQL 기본으로 바꿨다.
**12. 계약 테스트는 SQL을 재타이핑하지 말고 어댑터가 실제로 돌리는 statement를 실행해야 한다.**
`RecipientClaimSql`이 이걸 이름 붙여 놨다. "테스트 작성자와 어댑터 작성자가 쿼리에 대해
합의했다"는 아무도 필요로 하지 않는 속성이다.
**13. 벤더 차이는 추정하지 말고 측정한 뒤 근거를 적는다.**
"H2 2.4.240이 `FOR UPDATE SKIP LOCKED`를 진짜로 지킨다(측정함)"와 "그건 production 보장이
아니다"를 동시에 기록한다.
**14. 버그를 고칠 때 왜 그 버그가 가능했는지를 코드 옆에 남긴다.**
이 모듈의 javadoc 상당량이 사후 기록이다. 처음엔 장황하다고 느꼈는데, 다 읽고 나니
**같은 실수가 다시 들어오는 걸 막는 유일하게 작동하는 장치**로 보인다.
---
## 17. 손볼 것 — 읽으면서 발견한 것들
우선순위 순. 각 항목에 확인 방법을 같이 적었다.
### P1 — 재시도 구현이 둘이고, 하나는 아무도 호출하지 않는다
**사실**
- `FullTransactionRetryCoordinator``JpaPlatformRuntimeAutoConfiguration#jpaRetryCoordinator`
빈으로 등록하지만, **production 코드 어디에서도 주입되지 않는다.** 참조는
app-bootstrap의 자기 config 2곳 + 자기 테스트뿐이다.
- 실제로 도는 재시도는 `SpringPolicyTransactionPort` + `TransactionRetryBackoff`이고,
**`COMMAND_SERIALIZABLE_REPLAY_SAFE` 정책에만** 적용된다.
- 두 재시도가 서로 다른 설정을 읽는다.
- `SpringPolicyTransactionPort``ca-skeleton.jpa.transaction.retry-*` (기본 최대 2회)
- `FullTransactionRetryCoordinator``RetryProfile.boundedContention("jpa-platform-default", 3)`
(하드코딩, 20ms~500ms, FULL jitter)
- `DefaultJpaRetryPolicy`의 정교한 6단계 순서, `IrreversibleSideEffectContext` 확인,
`RetryBudget`의 elapsed 상한 — **전부 아무도 호출하지 않는 경로**에 있다.
**확인**
```bash
grep -rn "FullTransactionRetryCoordinator" src | grep -v adapter/outbound/persistence-jpa
# → app-bootstrap의 두 config와 그 테스트뿐
```
**왜 문제인가**
`docs/jpa/support-matrix.md`가 "Full-transaction retry | Stable"이라고 선언한다. 그런데
`TransactionPort.inWrite()`를 쓰는 유스케이스는 그 경로를 절대 만나지 않는다. §14.4에 기록된
"재시도되지 않는 재시도에 의존하는 코드를 배포할 수 있었다"와 **같은 모양의 문제**가 한 단계
위에 남아 있는 셈이다.
**선택지**
(a) `SpringPolicyTransactionPort``TransactionRetryBackoff` 대신 `DefaultJpaRetryPolicy` +
`RetryBudget`을 쓰도록 통합. 정책 순서와 elapsed 상한이 실제로 적용된다.
(b) `FullTransactionRetryCoordinator`를 명시적 "advanced 조립용"으로 강등하고 support-matrix
행을 그렇게 고친다.
(c) 최소한 두 재시도가 같은 설정을 읽게 한다.
---
### P2 — 계약 테스트 8개가 production 타입을 하나도 실행하지 않는다
**사실**
`src/postgresqlIntegrationTest/.../platform`의 테스트 클래스 중 production 패키지를 하나도
import하지 않는 것들:
| 클래스 | prod import |
|---|---|
| `PostgreSqlUpsertContractTest` | 0 |
| `PostgreSqlWorkClaimContractTest` | 0 |
| `PostgreSqlQueryPlanContractTest` | 0 |
| `JpaAuditingContractTest` | 0 |
| `JpaLifecycleAssociationContractTest` | 0 |
| `JpaValueMappingContractTest` | 0 |
`PostgreSqlUpsertContractTest``PostgreSqlWorkClaimContractTest`는 **SQL을 자체 상수로 다시
쓴다.**
```java
// PostgreSqlWorkClaimContractTest
private static final String CLAIM_SQL =
"select id from claim_queue where claimed = false order by priority, id limit ?"
+ " for update skip locked";
```
결과적으로 다음 production 클래스는 **`test``postgresqlIntegrationTest` 어디에서도 참조되지
않는다** — 즉 커버리지 0이다.
```
RegisteredPostgreSqlUpsertExecutor
PostgreSqlWorkClaimExecutor
PostgreSqlRangeQuerySupport
SpecificationPolicy
PgRangeJdbcType
```
**확인**
```bash
cd src/adapter/outbound/persistence-jpa
grep -rn "UpsertExecutor\|WorkClaimExecutor\|RangeQuerySupport\|SpecificationPolicy\|PgRangeJdbcType" \
src/test src/postgresqlIntegrationTest # → 결과 없음
```
**왜 문제인가**
같은 저장소의 `RecipientClaimSql` javadoc이 정확히 이 안티패턴을 이름 붙여 놨다.
> "테스트 작성자와 어댑터 작성자가 쿼리에 대해 합의했다"는 **아무도 필요로 하지 않는
> 속성**이고, 필요한 속성은 **이** statement가 실제 PostgreSQL에 대해 claim해야 할 것을
> claim한다는 것이다.
`readiness-cards.yaml``evidence.scenarios`로 selector를 고정하지만, **그 selector가 실행하는
게 production 코드인지는 검사하지 않는다.**
**제안**
- 각 계약 테스트가 registry를 실제로 구성하고 executor를 통해 실행하도록 바꾼다
(`RecipientClaimSql`이 한 방식 — SQL을 production 상수에서 가져오기).
- 최소한 `readiness-cards.yaml`에 "이 시나리오가 실행하는 production 타입" 필드를 추가하거나,
ArchUnit/Gradle 게이트로 "vendor SQL 상수는 production에서만 선언된다"를 강제한다.
---
### P3 — `docs/**` 문서의 leaf 수 drift가 게이트 사각지대에 있다
**사실**
`docs/jpa/repository-adaptation.md` §1:
> ...declares **exactly 19 leaf identities**, and `src/settings.gradle` throws when the registry
> does not contain exactly 19 modules. Creating 25 more Gradle projects would violate HARD-STOP #5.
§4 표에도 "Fail-closed 19-leaf registry".
그런데:
- 레지스트리는 **44개** leaf를 갖는다.
- `src/settings.gradle`은 16줄이고 **`19`도, leaf 수 검사도 없다.** 검증은
`ca.architecture-registry` 플러그인에 위임됐다.
- `verifyDocumentedLeafCount``CLAUDE.md`, `AGENTS.md`, leaf `build.gradle`만 스캔한다.
**`docs/**`는 walk 대상이 아니다.**
같은 drift가 다른 문서에도 있다.
```
docs/jpa/repository-adaptation.md "exactly 19 leaf identities" (×2)
docs/mongodb/repository-adaptation.md "exactly 19 leaf identities. Creating 31 more"
docs/httpclient/repository-adaptation.md "exactly 19 leaf identities. Creating 19 more"
docs/notification/module-mapping.md "19-leaf Clean Architecture template"
docs/adr/ADR-MONGO-001-platform-boundary.md "exactly 19 leaf identities"
```
**확인**
```bash
python3 -c "import json;print(len(json.load(open('src/config/architecture/modules.json'))['modules']))" # 44
grep -c "" src/settings.gradle # 16
grep -rnE "[0-9]+ ?(개 ?)?-?(leaf|leaves)" docs | grep -v "^docs/superpowers/\|^docs/reviews/"
```
**왜 문제인가**
`verifyDocumentedLeafCount`의 코멘트가 스스로 이렇게 말한다.
> 이름이 적힌 목록은 다섯 개의 모듈 `CLAUDE.md`와 네 개의 leaf `build.gradle`을 놓쳤고,
> 각각은 messaging 플랫폼 leaf가 등록되기 전의 "19-leaf"를 다시 진술하고 있었다 — **이
> task가 실패시키려고 존재하는 바로 그 drift**를, **그 모듈의 독자가 가장 믿을 만한
> 파일에서.**
정확히 같은 이유로 `docs/**`도 walk 대상이어야 한다. 지금은 게이트가 "확장돼야 하는 목록"에서
"세 종류의 파일명"으로 좁아진 것뿐이다.
**제안**
- `policyDocuments` walk에 repository 루트의 `docs/**/*.md`를 추가한다
(`docs/superpowers/`, `docs/reviews/`는 역사 기록이므로 제외하거나 별도 처리).
- 아니면 문서를 고친다. 다만 개별 수정은 다음에 leaf가 추가되면 또 드리프트한다 —
게이트 확장이 낫다.
---
### P4 — `outbox` 스토어/리퍼에 capability 조건이 없다
**사실**
| 클래스 | 조건 |
|---|---|
| `IdempotencyStoreAdapter` | `@ConditionalOnProperty(ca-skeleton.capabilities.idempotency.provider = jdbc, matchIfMissing = true)` |
| `IdempotencyReaper` | 동일 |
| `OutboxStoreAdapter` | **없음** (`@Repository`만) |
| `OutboxReaper` | **없음** (`@Component` + `@Scheduled`) |
`JpaAdapterComponentsConfig``...persistence.outbox` 패키지를 component scan한다. 따라서
`ca-skeleton.persistence-jpa.enabled=true`이면 `ca-skeleton.outbox.enabled=false`여도
`OutboxStoreAdapter``OutboxReaper`가 빈이 되고, reaper의 `@Scheduled`가 10분마다
`delete from outbox_event where status='PUBLISHED' and occurred_at < ?`를 돈다.
**확인**
```bash
grep -rn "ConditionalOn" src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/outbox/ # 없음
grep -n "basePackages" -A10 src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/JpaAdapterComponentsConfig.java
```
**왜 문제인가**
`application.yml` 코멘트가 이 구분을 명시적으로 만들었다.
> `enabled`는 **capability 스위치**다. off는 outbox settings, validator, leader token, metrics,
> store binding이 **아예 없다**는 뜻이고, `relay-enabled`는 스케줄러가 도는지만 결정한다.
> 둘을 합쳤더니 relay-off 배포가 **database-less 배포에는 없는 store 포트 위에 OutboxMetrics를
> 조립했다.**
app-bootstrap의 `OutboxConfig``@ConditionalOnProperty(ca-skeleton.outbox.enabled=true)`
제대로 게이트되어 있는데, **어댑터 쪽 두 빈만 무조건**이다. §10 원칙("off는 구조적")과
어긋난다.
**추가 리스크**: `db/migration/jpa/outbox-storage/V1``outbox_event`
`trg_fence_legacy_outbox_writer`(BEFORE INSERT/UPDATE/**DELETE**, FOR EACH ROW)를 건다.
authority가 `LEGACY_POLLING`이 아니면 예외를 던진다. 즉 **outbox storage V2로 cutover한 뒤에는
V1 `OutboxReaper`의 bulk delete가 매 주기 실패한다.** 지금은 V2가 candidate라 활성화되지
않았지만, cutover 런북에 "V1 reaper/relay 먼저 끄기"가 명시돼야 한다.
**제안**
- `OutboxStoreAdapter` / `OutboxReaper`에 `@ConditionalOnProperty(prefix="ca-skeleton.outbox",
name="enabled", havingValue="true")` 추가.
- cutover 절차에 V1 writer 비활성화 단계를 명시.
---
### P5 — `55P03`이 웹 표면 매트릭스에 없다
**사실**
`55P03`(lock_not_available)은:
- `PostgreSqlState`에 등록되어 있고 → `FailureCategory.LOCK_NOT_AVAILABLE`
- `PostgreSqlLockExceptionTranslator`가 `PessimisticLockTimeoutException`으로 번역하고
- **`PostgreSqlSqlStateErrorMapping`에는 없다** (`40P01`, `25P03`, `57014` 셋뿐)
따라서 웹 표면의 `PersistenceExceptionTranslator`는 `55P03`에 대해 `Optional.empty()`를
반환하고, 클라이언트는 **generic `INTERNAL`**을 받는다.
**확인**
```bash
grep -rn "55P03" src/adapter/outbound/persistence-jpa/src/main/java
# PostgreSqlState, PostgreSqlLockExceptionTranslator, FailureCategory, H2SqlStateErrorMapping 주석에만 나옴
```
`H2SqlStateErrorMapping` javadoc이 이걸 인지하고 있다.
> PostgreSQL은 같은 영역을 57014(statement)와 55P03(lock)으로 나누고 **이 저장소는 57014만
> 매핑한다.**
**왜 문제인가**
락 타임아웃은 재시도 가능한(또는 최소한 클라이언트가 backoff해야 하는) 상황인데, 지금은
"내부 오류"로 보인다. 두 층(`api.error` vs `failure`)의 커버리지가 어긋나 있다.
**제안**
`OperationalError`에 적절한 코드가 있는지 확인하고, `55P03` 행을 추가한다
(`DB_LOCK_TIMEOUT` 신설 또는 기존 코드 재사용 — 어느 쪽이든 결정이 필요하다).
---
### P6 — `auditing/AuditMetadata`가 완성돼 있는데 아무것도 안 쓴다
**사실**
`AuditMetadata`(`@Embeddable`) + `JpaAuditorProvider` + `JpaAuditingConfiguration`이 완전히
구현되어 있는데:
- 어떤 엔티티도 embed하지 않는다.
- `JpaAuditingConfiguration`을 조립하는 곳이 없다.
- 컬럼 이름이 canonical(`audit/AuditableEntity`)과 다르다: `modified_*` vs `updated_*`.
- actor 길이도 다르다: 64 vs 256.
`docs/jpa/support-matrix.md`가 "Candidate, not composed"로 정직하게 표기했고,
`JpaAuditMechanismRule`이 한 엔티티가 둘 다 쓰는 걸 막는다.
**왜 문제인가**
문제라기보다 **미결 결정**이다. 하지만 남겨 두는 비용이 있다.
- 두 메커니즘 × 두 컬럼 계열을 아는 ArchUnit 규칙을 유지해야 한다.
- 새 엔티티를 쓰는 사람이 어느 쪽을 골라야 하는지 코드만으로는 알 수 없다.
**제안**
support-matrix가 이미 선택지를 적어 뒀다 — canonical 컬럼으로 reshape하거나 새 컬럼용
forward migration을 쓰거나. 셋째 선택지(**제거**)도 표에 올릴 만하다.
---
### P7 — `postgresql.json`이 Jackson을 transitive로 쓴다
**사실**
`JsonDocumentCodec`, `JsonDocument`, `PostgreSqlJsonQuerySupport`가
`com.fasterxml.jackson.databind.*`를 import하는데, `build.gradle`에 **jackson 선언이 없다.**
**확인**
```bash
cd src/adapter/outbound/persistence-jpa
grep -i jackson build.gradle # (없음)
grep -i "jackson-databind" gradle.lockfile # compileClasspath, runtimeClasspath 등에 존재
```
**왜 문제인가**
BOM 관리 하에 있고 lock 파일에 고정되어 있으니 지금은 깨지지 않는다. 다만 transitive 경로가
바뀌면(예: 다른 starter가 빠지면) **컴파일이 깨진다.** 이 저장소의 다른 곳은 필요한 걸
명시적으로 선언하는 편이다(예: `implementation 'io.micrometer:micrometer-core'`에 "Micrometer의
observation API는 Spring과 함께 오지만 meter registry는 아니다"라는 코멘트까지 달려 있다).
**제안**
`implementation 'com.fasterxml.jackson.core:jackson-databind'` 선언 + 왜 필요한지 코멘트
(§8.3 JSONB envelope). 버전은 BOM이 관리한다.
---
### P8 — 플랫폼 capability의 대부분에 production 소비자가 없다
**사실**
leaf 밖 참조 수를 세어 보면:
| 타입 | 외부 참조 |
|---|---|
| `JpaKeysetQuerySupport`, `SafeSortMapper`, `JpaStreamExecutor`, `FetchPlanApplier` | 0 |
| `HibernateJpaBatchExecutor`, `HibernateBulkDmlExecutor`, `HibernateStatelessSessionRunner` | 0 |
| `RegisteredPostgreSqlUpsertExecutor`, `PostgreSqlWorkClaimExecutor` | 0 |
| `PostgreSqlJsonQuerySupport`, `PostgreSqlRangeQuerySupport`, `PostgreSqlArraySupport` | 0 |
| `RegisteredPostgreSqlCopyLoader`, `HibernateEnversHistoryReader`, `QuerydslJpaSupport` | 0 |
| `CacheRegionCatalog`, `TransactionProfileRegistry`, `EntityGraphCatalog` | 0 |
| `FullTransactionRetryCoordinator` | 3 (전부 app-bootstrap 자기 config/test) |
즉 이 leaf의 **`springdata` / `hibernate.*` / `postgresql.{write,lock,json,array,range,copy}` /
`cache` / `envers` / `querydsl` 전부가 "구현된 라이브러리"이고 이 애플리케이션은 쓰지 않는다.**
**결정적 증거: 같은 leaf 안의 두 스토어도 안 쓴다.**
`fileserver`(25파일)와 `notification`(53파일)은 이 leaf에서 제일 큰 production 코드 덩어리이고
(합쳐 350KB, 본문의 33.7%), 매일 PostgreSQL에 쓰기를 한다. 그런데 **JPA 플랫폼 타입을 하나도
import하지 않는다** (§11.0 측정).
| 이 플랫폼이 제공하는 것 | 두 스토어가 실제로 하는 것 |
|---|---|
| `SafeSortMapper` (allowlist 정렬) | 자기 `@Query`에 `order by` 직접 작성 |
| `PostgreSqlWorkClaimExecutor` (등록된 SKIP LOCKED 큐) | `RecipientClaimSql`, `FileserverCleanupRepository.claim` — 자기 native SQL |
| `RegisteredPostgreSqlUpsertExecutor` (등록된 업서트) | 자기 `@Modifying` 조건부 UPDATE |
| `JpaKeysetQuerySupport` (keyset 페이지) | `Limit` + `order by` |
| `FullTransactionRetryCoordinator` (전체 트랜잭션 재시도) | 조건부 UPDATE의 count로 낙관적 충돌 판정 |
**같은 저장소, 같은 leaf, 같은 DB, 같은 문제(큐 클레임 / 정렬 / 충돌 판정)를 풀면서 플랫폼을
안 쓰고 각자 다시 만들었다.** 플랫폼이 안 쓰이는 이유가 "아직 소비자가 없어서"가 아니라
**소비자가 있는데도 안 쓴다**는 뜻이라, 문제의 성격이 다르다.
**왜 문제인가**
`support-matrix.md`가 대부분을 Advanced로 표기하니 **거짓말은 아니다.** 다만 조합이 비싸다.
- P2와 겹치는 부분은 "구현됐고, 안 쓰이고, 테스트도 없다"가 된다.
- 26,657줄 중 상당 부분이 이 카테고리다. 리팩터링/업그레이드 때마다 유지비가 든다.
- 템플릿의 목적(파생 프로젝트가 가져다 씀)을 생각하면 정당화될 수 있지만,
**그 정당화가 문서에 명시적으로 없다.**
**제안**
셋 중 하나를 골라야 한다. 지금은 셋 다 아닌 상태다.
1. **채택** — `fileserver`/`notification`의 큐 클레임·정렬·업서트를 플랫폼 타입으로 옮긴다.
플랫폼이 소비자를 얻고, 두 벌로 존재하는 SKIP LOCKED 로직이 한 벌이 된다.
2. **명시적 라이브러리 선언** — support-matrix에 "Advanced = 구현되어 있으나 이 저장소의
production 경로에서 사용되지 않으며, 파생 프로젝트가 가져다 쓰라고 존재한다"를 적고,
각 Advanced capability에 어댑터를 통과하는 계약 테스트를 요구한다(P2와 같은 작업).
3. **제거** — 파생 프로젝트가 실제로 안 가져다 쓴다면 26,657줄의 상당 부분을 덜어낸다.
1번이 P2까지 같이 해결한다: 어댑터를 통과하는 실제 사용처가 생기면 커버리지 0인 다섯 클래스에
자동으로 실행 경로가 생긴다.
---
### P9 — 확인만 하고 넘어간 것들 (문제 아님, 기록용)
- **`42501` → `FailureCategory.UNKNOWN`**: `PostgreSqlState`에 등록된 상태 중 유일하게
`UNKNOWN`으로 매핑된다. 권한 오류는 재시도 불가이고 스키마 문제도 아니니 UNKNOWN이 맞을
수 있지만, "등록했는데 UNKNOWN"은 코드만 보면 미완처럼 읽힌다. 한 줄 주석이면 해소된다.
- **`H2LocalTimeoutConfigurer` 세션 스코프**: javadoc이 이미 인정하고 근거를 적어 뒀다.
- **`SafeSortMapper.MAX_SORT_TERMS = 4`**: registry에 더 많이 등록할 수 있지만 요청은 4개까지.
의도로 보인다.
- **`PgRangeCodec.parse("empty")` 거부**: 의도이고 근거가 적혀 있다.
- **`SpringTransactionPort`의 non-`@Autowired` 생성자들**: 테스트 전용
(`withoutAcquisitionEnvelope`). package-private이라 표면이 넓어지지 않는다.
---
## 18. 블로그 글감 후보
한 편에 다 넣기엔 너무 크다. 쪼갠다면 이렇게 나눌 것 같다.
| # | 제목(가안) | 핵심 소재 | 분량감 |
|---|---|---|---|
| 1 | **"커밋했는지 모르겠습니다" — 분산 트랜잭션 없이 커밋 모호성을 다루는 법** | `TransactionCompletionEvidence`, `EvidenceAwareJpaTransactionManager`, `CommitFailureClassifier`, `57P01` 발견, `TransactionResult.Indeterminate` | 中 (이게 제일 강하다) |
| 2 | **타입으로 메트릭 카디널리티를 막기** | `PersistenceOperationName` 계열, `LowCardinality`, `JpaMetricTags`, `SqlDiagnosticRedactor` | 小 |
| 3 | **keyset 페이지네이션을 제대로 구현하면 생기는 6가지 함정** | 사전식 predicate, term별 direction, tie-breaker direction, `size+1`, 서명 커서, count 쿼리 없음 | 中 |
| 4 | **`ON CONFLICT`와 `SKIP LOCKED`로 만드는 owner-safe 상태 기계** | V2 idempotency/outbox/inbox 공통 패턴, CAS 튜플, transition digest, lock→`clock_timestamp()` 순서 | 大 |
| 5 | **Flyway 스트림을 쪼개는 이유** | V1 충돌, 독립 history table, `baselineVersion("0")`, `capability_schema_registry`, ACTIVE 승격 | 中 |
| 6 | **로컬을 H2로 돌리면 무엇을 못 보게 되는가** | `char(64)` 사건, `fs_cleanup_item` 사건, `ddl-auto=validate`, H2 SQLSTATE 차이 | 小 (임팩트 좋음) |
| 7 | **"꺼짐"을 구조적으로 만들기** | 마스터 스위치, `AutoConfigurationImportFilter`, `DataSourceRequirement`, capability별 EntityScan | 中 |
| 8 | **초록불인데 아무것도 검증하지 않는 게이트들** | `failOnNoDiscoveredTests`, Docker skip 금지, 카탈로그 동등성, 성능 레인 rename, release registry, P2/P3 발견 | 中 (자기비판 톤이 잘 먹힌다) |
| 9 | **PostgreSQL RLS가 조용히 아무것도 안 하는 세 가지 방법** | `FORCE ROW LEVEL SECURITY`, `BYPASSRLS`, 테이블 소유자, transaction-local `set_config` | 小 |
| 10 | **재시도해도 되는 실패와 안 되는 실패** | `FailureCategory`, `DefaultJpaRetryPolicy` 6단계 순서, `RetryProfile` 화이트리스트, `IrreversibleSideEffectContext` | 中 |
**내 추천 순서**: 1 → 6 → 8 → 3 → 4.
1번은 "왜 이런 게 필요한가"가 직관적이지 않아서 설명 가치가 크고, 6번은 짧으면서 누구나
겪는 문제고, 8번은 이 저장소가 가진 자기비판 기록이 그대로 소재가 된다.
---
## 부록 A. 자주 쓸 명령
```bash
cd src
# 단위 (hermetic)
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
# 실 PostgreSQL 레인
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformFailureTest
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest
# 매트릭스 선택
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest -Pjpa.matrix.versions=16,17,18
# 릴리스 게이트 (루트에서)
./gradlew jpaPlatformReleaseGate
# SQL 안전 게이트
./gradlew :adapter:outbound:persistence-jpa:verifyJpaSqlConstructionSafety
./gradlew :adapter:outbound:persistence-jpa:verifyJpaSecurityFixtures
# evidence
./gradlew :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence
./gradlew :adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence -PjpaEvidenceProfile=r2
# 아키텍처
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
```
## 부록 B. 읽은 순서 (다시 읽는다면)
1. `src/config/architecture/modules.json` — leaf 정체
2. 모듈 `CLAUDE.md` — 규칙과 계약 표
3. 모듈 `README.md` — 결정의 근거
4. `docs/jpa/repository-adaptation.md` — 왜 패키지인가
5. `api/**` — 계약층 (여기 먼저 읽어야 나머지가 읽힌다)
6. `transaction/**` — 제일 밀도 높음
7. `postgresql/**` — 벤더
8. `src/main/resources/db/migration/**` — 실제 스키마
9. `postgresql/{idempotency,outbox,inbox}` — V2 상태 기계
10. `app-bootstrap/.../autoconfigure/{jpa,persistencejpa}` — 조립
11. `build.gradle` + `config/jpa/*` + `gradle/jpa-evidence.gradle` — 검증 체계
12. `src/testkit/**`, `src/postgresqlIntegrationTest/**` — 무엇이 실제로 증명되는가