3620 lines
187 KiB
Markdown
3620 lines
187 KiB
Markdown
# 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% | **전문 정독** (§2–10, §12–15) |
|
||
> | `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일 때 도착하고, **101–150행이
|
||
> 유실**된 채 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/**` — 무엇이 실제로 증명되는가
|