218 KiB
JPA/PostgreSQL Production Capability Deep Design
- 작성일: 2026-07-28
- 상태: 상세 설계 완료, 현행 기능별 R1 이하, JPA/PostgreSQL capability 전체 R2 미달
- 기준: Java 21, Spring Boot 4.0.0, Hibernate ORM 7.1.8, PostgreSQL 16, Gradle 멀티모듈 Clean Architecture
- 대상 leaf:
adapter-outbound-persistence-jpa - Gradle path:
:adapter:outbound:persistence-jpa - 상위 문서: Production Capability Platform Design
- 관련 심화 문서: Redis Production Capability Deep Design, FileServer Production Capability Deep Design, Messaging Production Capability Deep Design
0. 구현 상태
2026-07-28 현재 구현된 범위:
application-core가 Spring annotation 없이 transaction intent를 선언하는TransactionPort;REQUIREDwrite/read-only와 제한적인REQUIRES_NEWtransaction template;- 모든 현재 transaction mode의 명시적
READ_COMMITTEDisolation; - adapter-owned JPA auditing base class와 명시적 audit stamp;
- SQLState 기반 표준/PostgreSQL failure 분류 SPI와 web error carrier;
- unique scope, request fingerprint, TTL, reaper를 가진 JPA idempotency V1;
- inline response와 optional object-storage response seam;
- PostgreSQL
FOR UPDATE SKIP LOCKED를 사용하는 polling outbox V1; - local/JDBC 선택이 가능한 Spring Integration JDBC efficiency lock;
- Flyway V1, V3, V4, V5 production migration;
- OSIV disable 계약, datasource/Hikari/Flyway 설정 및 일부 startup validator;
- sample의 aggregate entity, optimistic version, JPQL constructor projection, PostgreSQL integration test seam.
그러나 다음 핵심 범위는 구현되지 않았거나 실제 production 경로에 연결되지 않았다.
- 모든 repository/query operation을 통과하는 persistence failure translation boundary;
- constraint name 기반의 허용 목록 conflict mapping;
- commit 응답 유실과 일반 connection failure를 분리하는 commit outcome;
- application deadline과 transaction/statement/lock/pool acquisition timeout의 계층;
- transaction policy별 isolation, retry eligibility, query budget;
- primary/replica datasource, 명시적 read consistency와 lag gate;
- owner token/CAS를 가진 idempotency V2;
- immutable event와 delivery state를 분리한 owner-safe outbox V2;
- same-store inbox;
- query ID, N+1 budget, representative
EXPLAINplan qualification; - PostgreSQL을 필수로 기동하는 non-skippable CI task;
- rolling schema compatibility, restore/failover rehearsal evidence;
- tenant discriminator/RLS profile;
- typed primary/replica pool capacity와 shutdown/quiesce 계약.
따라서 이 문서에서 “설계 완료”는 구현 계약, 보장 경계, 검증 순서가 결정되었다는 뜻이다. 현재 JPA leaf나 이를 사용하는 애플리케이션이 production-ready라는 뜻이 아니다.
0.1 현재 capability별 준비도
| Capability card | 현재 | 이 문서의 R2 목표 | 현재 판정 이유 |
|---|---|---|---|
| JPA aggregate store | production R0 / sample reference R1 | R2 | production leaf에는 목표 aggregate 구현이 없고 sample CRUD와 optimistic version만 참고 증거로 존재한다. |
| Application transaction | R1 | R2 | 세 mode와 READ_COMMITTED만 있으며 deadline, outcome, policy가 없다. |
| Query model | production R0 / sample reference R1 | R2 | production query card는 없고 sample projection에 bound, N+1, plan gate가 없다. |
| Flyway migration | R1 | R2 | migration 실행은 있으나 rolling/large-table/rollback evidence가 없다. |
| Polling outbox | R1 | R2 | claim은 있으나 owner-safe completion, aggregate sequence, delivery 분리가 없다. |
| JPA idempotency | R1 | R2 | scope V1이며 stale owner가 새 claim을 변경할 수 있다. |
| JDBC coordination | R1 efficiency | R2 efficiency | fencing/renewal/owner-safe release가 없으므로 correctness lock이 아니다. |
| Primary/replica routing | R0 | R2 optional | 구현과 consistency/lag evidence가 없다. |
| Same-store inbox | R0 | R2 optional | schema, port, consumer transaction choreography가 없다. |
| Tenant isolation | R0 | R2 optional | tenant key, query enforcement, RLS가 없다. |
R1은 local/testable implementation evidence, R2는 production profile evidence,
R3는 실제 운영 및 복구 rehearsal evidence를 의미한다. 한 card가 R2라고 해서 leaf 전체나
다른 card의 R2를 대신하지 않는다.
1. 설계 판정
이 저장소에서 JPA는 범용 ORM 편의 계층이 아니라 다음 capability다.
application이 정의한 transaction과 repository/query port 뒤에서 aggregate state, same-store reliability record, schema evolution을 PostgreSQL에 안전하게 영속화하고, concurrency, timeout, failure, consistency의 의미를 framework-neutral 결과로 보존하는 기능
선택한 핵심 구조는 다음과 같다.
- 정확히 19개인 현재 leaf registry를 유지하고 JPA 공통 코드와 PostgreSQL 전용 코드는
같은 leaf의
.postgresqlpackage로 격리한다. domain-coreentity와 persistence entity를 분리하며 JPA annotation을 core로 유출하지 않는다.- application에는 aggregate별 repository port, 목적별 query port, transaction policy만
노출한다.
JpaRepository,EntityManager,Pageable,Sort,Specification, Hibernate type은 노출하지 않는다. - aggregate command 경로는 JPA를 기본으로 하고, read model은 JPQL projection 또는 PostgreSQL native/JDBC query를 목적별 adapter 내부 구현으로 선택한다.
- transaction boundary는 application use case가
TransactionPort로 소유한다. controller, repository adapter, mapper, scheduler가 business transaction policy를 새로 만들지 않는다. - 기존
inWrite,inRead,inNew는 source-compatible facade로 유지하되, named transaction policy와 absoluteCallBudget를 받을 수 있는 additive contract로 진화시킨다. - 기존
inRead는 항상 primary의 strong read다. replica는 명시적ReadConsistency와 lag qualification 없이는 사용하지 않는다. - optimistic concurrency를 일반 aggregate의 기본값으로 한다. pessimistic lock은 짧고 bounded된 indexed critical section 또는 queue claim에만 사용한다.
- SQLState, Spring/Hibernate exception, transaction phase를 하나의 translation boundary에서 typed failure와 retry disposition으로 변환한다.
- connection failure가 commit 단계에 발생하면
COMMIT_INDETERMINATE로 분류하고 자동 재실행하지 않는다. stable operation ID 또는 business key로 먼저 reconcile한다. - Flyway가 physical schema의 유일한 writer다. production에서 Hibernate schema update, create, create-drop은 금지한다.
- schema 변경은 expand/bridge/backfill/switch/enforce/observe/contract로 진행하고 최소 N/N-1 application 호환성을 검증한다.
- 같은 PostgreSQL transaction에 business write와 outbox/inbox/idempotency transition을 함께 넣을 때만 same-store atomicity를 주장한다.
- database transaction 안에서 broker, HTTP, object storage, file server 같은 remote side effect를 수행하지 않는다.
- 실제 PostgreSQL, concurrency, timeout, migration, query-plan test가 필수 CI lane에서 통과하기 전에는 R2라고 표현하지 않는다.
2. 상위 설계와 이번 심화 설계의 관계
상위 통합 설계는 이미 다음을 결정했다.
- OSIV를 사용하지 않는다.
- transaction boundary는 application이 소유한다.
- Flyway migration과 failure translation을 사용한다.
- pool, timeout, batch/fetch, N+1, query plan을 운영 계약으로 다룬다.
- optimistic lock을 기본으로 하고 pessimistic lock을 제한한다.
- replica read에는 명시적 consistency가 필요하다.
- outbox, idempotency, inbox의 same-store transaction을 지원한다.
- PostgreSQL 전용 구현은 vendor package와 real-service test로 한정한다.
이번 문서는 위 결정을 구현 계획으로 바꿀 수 있도록 다음을 추가로 고정한다.
- 현재 코드의 실제 구현 수준과 결함;
- application transaction API의 additive evolution;
- transaction phase와 commit uncertainty;
- retry 가능 조건과 금지 조건;
- primary/replica routing 시점과 nested transaction 규칙;
- datasource, pool, admission, timeout의 산정과 validation;
- entity ID, time, enum, audit, version, relation baseline;
- query projection, fetch plan, cursor, statement budget, plan evidence;
- constraint/index/lock/tenant schema 계약;
- migration job과 application startup의 분리;
- owner-safe idempotency/outbox/inbox schema와 transaction choreography;
- 설정 activation, health, observability, security, shutdown;
- 실제 PostgreSQL CI lane과 readiness 승격 조건.
JPA/PostgreSQL 범위에서 이 문서와 상위 문서의 요약이 충돌하면 이 문서가 더 구체적인 정본이다. 다른 capability의 결정은 변경하지 않는다.
2.1 Normative decision ledger
| 결정 | 정본 |
|---|---|
| readiness와 capability card | §0, §9 |
| HARD invariant | §5 |
| 모듈/계층/package 소유권 | §7–§8 |
| application repository/query contract | §10 |
| entity와 mapping baseline | §11 |
| transaction contract/policy/propagation | §10.3–§10.4, §12 |
| isolation과 concurrency | §13 |
| locking/JDBC coordination | §14 |
| failure, retry, commit outcome | §15 |
| pool/admission/timeout | §16–§17 |
| write/batch/query/pagination | §18–§20 |
| primary/replica consistency | §21 |
| same-store idempotency/outbox/inbox | §22 |
| Flyway와 rolling migration | §23 |
| schema/index/query-plan | §24 |
| tenancy와 security | §25–§26 |
| configuration/activation/health | §27–§28 |
| observability/lifecycle/DR | §29–§30 |
| test/CI/evidence | §31 |
| Gradle/dependency/split trigger | §32 |
| 단계별 migration과 완료 기준 | §33–§34 |
예시 코드나 YAML이 표의 정본 절과 충돌하면 정본 절을 따른다. deprecated alias와 canonical setting이 동시에 주어지면 임의 precedence를 선택하지 않고 startup을 실패시킨다.
3. 현재 코드의 증거 기반 진단
3.1 모듈 경계
src/config/architecture/modules.json은 다음을 유일한 registry로 정의한다.
id adapter-outbound-persistence-jpa
source_path src/adapter/outbound/persistence-jpa
gradle_path :adapter:outbound:persistence-jpa
allowed_dependencies
- domain-core
- application-core
- shared-contract
실제 leaf는 application-core, shared-contract, Spring Data JPA, Spring Integration JDBC,
Flyway와 PostgreSQL runtime을 소유한다. 조사 시점에 다음 HARD-STOP 위반은 발견하지 않았다.
domain-core의 Spring/JPA 의존;- application의 inbound DTO 또는 JPA type 의존;
- controller의 repository 직접 호출;
- production leaf의 sample 의존;
- registry 밖 project dependency.
현재 한 leaf 안에 vendor-neutral JPA와 PostgreSQL 전용 SQL이 함께 있다. 두 번째 RDBMS나 vendor SDK의 독립 release/security boundary가 실제로 생기기 전에는 leaf를 늘리지 않는다.
3.2 현행 구현과 운영 의미
| 영역 | 현재 구현 | 운영상 의미 |
|---|---|---|
| Transaction | write/read/requires-new template, 모두 READ_COMMITTED |
deadline, stricter isolation, phase-aware outcome이 없다. |
| Read | inRead가 read-only hint만 설정 |
primary/replica 의미와 read consistency가 없다. |
| Failure | SQLState mapping component | production repository가 translator를 호출하지 않아 raw exception이 escape한다. |
| Mapping merge | putAll |
같은 SQLState의 중복 등록이 조용히 덮어써진다. |
| Audit | AuditableEntity와 manual stamp |
adapter ownership은 맞지만 bulk DML과 update copy 규칙이 명시되지 않았다. |
| Idempotency | unique scope + status + TTL | owner token과 owner-checked CAS가 없다. |
| Outbox | 단일 event row에 claim/delivery state | immutable event와 mutable delivery가 섞이고 completion owner 검증이 없다. |
| Outbox order | timestamp 중심 | 같은 timestamp와 aggregate별 strict sequence가 정의되지 않는다. |
| Maintenance | idempotency/outbox reaper가 @Scheduled @Transactional을 직접 소유 |
목표 named maintenance policy와 application command 경계로 이동해야 한다. |
| Lock | Spring Integration JDBC lock | efficiency coordination이며 fencing correctness를 제공하지 않는다. |
| Migration | Flyway V1/V3/V4/V5 | rolling compatibility, large backfill, nontransactional DDL 절차가 없다. |
| Pool validation | 일부 Hikari 제약 | 5s 같은 Duration을 parse하지 못하면 검증을 조용히 건너뛴다. |
| ORM schema | env에서 ddl-auto 선택 |
local default update가 있고 production runtime guard가 충분하지 않다. |
| Query | sample projection와 paging | max size, stable ordering, N+1/plan gate가 없다. |
| Real DB test | app-bootstrap/sample의 PostgreSQL test | Docker 부재 시 assumption으로 skip될 수 있다. |
| Metrics | registry에 db.query.duration |
실제 bounded query recorder는 확인되지 않았다. |
| Activation | broad entity/repository scan | explicit provider/topology activation과 disabled zero-side-effect가 없다. |
3.3 Failure translation은 실제 경로에 연결되지 않았다
PersistenceExceptionTranslator는 SQLState를 분류하지만 production code에서
translate(...)를 호출하는 repository/query adapter를 찾을 수 없다. web handler가
PersistenceFailureException을 처리해도 그 carrier가 만들어지지 않으면 계약은 성립하지
않는다.
목표 설계는 다음을 요구한다.
- 모든 aggregate repository, query adapter, same-store infrastructure store가 공통 operation executor 또는 동일한 translation rule을 통과한다.
- mapping duplicate는 startup fail-fast다.
- framework exception, SQLState, constraint name, transaction phase를 함께 본다.
- unknown failure를 성공이나 retryable로 추정하지 않는다.
- client 응답에는 SQL, SQLState, constraint/table/column 이름을 노출하지 않는다.
3.4 Idempotency V1의 stale-owner 위험
현재 complete와 discard는 scope만으로 row를 갱신한다. 만료된 owner A 뒤 owner B가 같은
scope를 재획득했을 때 A의 늦은 completion이 B의 claim을 변경할 수 있다. 또한
DataIntegrityViolationException 전체를 claim 경쟁으로 보는 것은 다른 schema 결함을
숨길 수 있다.
R2에서는 다음 owner-safe transition이 필요하다.
ABSENT
-- claim(ownerToken, leaseUntil, requestHash) --> CLAIMED
CLAIMED(owner=A)
-- markExecutionStarted(owner=A) --------------> EXECUTING(owner=A)
-- renew(owner=A) -----------------------------> CLAIMED(owner=A)
-- expire + takeover(owner=B) -----------------> CLAIMED(owner=B, attempt+1)
-- releaseBeforeExecution(owner=A) ------------> ABSENT
EXECUTING(owner=A)
-- renew(owner=A) -----------------------------> EXECUTING(owner=A)
-- complete(owner=A, response) ----------------> COMPLETED
-- no-effect confirmed ------------------------> FAILED_RETRYABLE
-- expire/effect unknown ----------------------> ABANDONED / RECOVERY_REQUIRED
owner=A의 늦은 renew/complete/release
------------------------------------------------> OWNER/ATTEMPT/REVISION_MISMATCH, no mutation
만료된 EXECUTING은 blind takeover하지 않는다. committed/no-effect evidence를 inspect한 뒤
reconcile complete 또는 explicit reopen만 허용한다.
3.5 Outbox V1의 ownership와 ordering 위험
현재 claim은 PostgreSQL SKIP LOCKED를 사용하지만 markPublished/Failed/Dead(eventId)가
claim owner와 현재 status를 검증하지 않는다. timestamp-only ordering은 aggregate 단위
ordering을 보장하지 않으며 DEAD row가 뒤 event 진행을 막을 때의 operator 정책도 없다.
R2에서는 다음을 분리한다.
outbox_event_log_v2: immutable business event envelope;outbox_delivery_v2: destination별 mutable claim/attempt/ack state;(aggregate_type, aggregate_id, aggregate_version, event_ordinal)unique order key;claim_owner,claim_token,claim_untilowner-safe CAS;- terminal transition의 current status + token 검증;
- operator requeue/skip/quarantine audit.
3.6 현재 test evidence의 한계
JPA leaf focused test는 unit/mock/wiring 중심이다. PostgreSQL integration test가 다른 module에 존재해도 Docker가 없을 때 skip되면 R2 gate가 아니다. 다음 항목은 real PostgreSQL에서 non-skippable task로 검증해야 한다.
- isolation anomaly와 whole-transaction retry;
- optimistic conflict, deadlock, lock/statement timeout;
- commit-uncertainty fault seam;
- pool exhaustion과 acquisition timeout;
- idempotency/outbox/inbox concurrent owner transition;
- rolling migration N/N-1 compatibility;
- index/query plan invariant;
- primary/replica routing과 lag/failover;
- backup restore와 migration forward recovery.
4. 범위와 명시적 비범위
4.1 primary-only JPA R2 baseline에 포함
- aggregate persistence를 위한 JPA entity, mapper, Spring Data repository adapter;
- application-owned write/read/requires-new transaction;
- transaction deadline와 PostgreSQL local timeout;
- failure translation, retry disposition, commit uncertainty;
- optimistic version과 제한적 pessimistic lock;
- purpose-built query projection, fetch plan, bounded paging/cursor;
- Hikari pool capacity, acquisition timeout, admission, lifecycle;
- Flyway schema validation과 expand-contract migration;
- PostgreSQL-specific SQLState, native query, lock/claim implementation;
- typed settings, startup validation, health, metrics, traces, runbook;
- real PostgreSQL integration, concurrency, migration, plan CI.
4.2 독립 승격 capability card
다음은 primary-only JPA R2의 자동 포함 항목이 아니라 같은 leaf가 제공할 수 있는 독립 capability card다.
- owner-safe JPA idempotency;
- immutable event outbox storage;
- owner-safe polling delivery;
- connector checkpoint 기반 CDC retention/cleanup;
- same-store inbox;
- primary/replica routing;
- tenant discriminator/RLS;
- JDBC efficiency coordination.
canonical optional card ID와 prerequisite는 다음과 같다.
| Optional card ID | Prerequisite card ID | Required non-skippable task |
|---|---|---|
jpa-idempotency-owner-safe-v2 |
jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle |
:adapter:outbound:persistence-jpa:postgresqlIdempotencyIntegrationTest |
jpa-outbox-storage-v2 |
jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle |
:adapter:outbound:persistence-jpa:postgresqlOutboxStorageIntegrationTest |
jpa-outbox-polling-delivery-v2 |
jpa-outbox-storage-v2, jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle |
:adapter:outbound:persistence-jpa:postgresqlOutboxPollingIntegrationTest |
jpa-outbox-cdc-retention-v1 |
jpa-outbox-storage-v2, jpa-observability-lifecycle; external messaging-cdc-dispatch.v1 R2 |
:adapter:outbound:persistence-jpa:postgresqlOutboxCdcCleanupIntegrationTest |
jpa-inbox-same-store-v1 |
jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle |
:adapter:outbound:persistence-jpa:postgresqlInboxIntegrationTest |
jpa-primary-replica |
jpa-transaction-runtime, jpa-query-model, jpa-flyway-migration, jpa-observability-lifecycle |
:adapter:outbound:persistence-jpa:postgresqlReplicaIntegrationTest |
jpa-tenant-discriminator-rls |
jpa-primary-foundation |
:adapter:outbound:persistence-jpa:postgresqlTenantRlsIntegrationTest |
jpa-jdbc-efficiency-coordination |
jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle |
:adapter:outbound:persistence-jpa:postgresqlJdbcCoordinationIntegrationTest |
각 card는 자기 non-skippable PostgreSQL evidence manifest가 있을 때만 별도로 R2가 된다.
한 card의 증거를 다른 card나 JPA leaf 전체의 준비도로 합산하지 않는다. base card ID와
dependency graph, required task의 machine-readable 정본은 §31.3의
src/config/jpa/readiness-cards.yaml이며 이 표와 §34는 그 projection이다.
4.3 optional profile
- primary/replica read routing;
- tenant discriminator와 defense-in-depth RLS;
- JDBC efficiency coordination;
- database-backed scheduled maintenance;
- read-model용 native/JDBC projection;
- PgBouncer 또는 managed proxy.
optional profile도 활성화되면 해당 profile의 R2 gate를 모두 충족해야 한다. 사용하지 않는 profile의 bean, pool, scheduler, migration이 side effect를 만들면 안 된다.
4.4 이번 범위에서 제외
- business aggregate와 use case의 구체 설계: 목표 domain 또는
sample-portfolio책임; - controller validation, HTTP status, inbound DTO: inbound adapter 책임;
- idempotency response의 object-storage reference/finalization: 별도 cross-store response card
책임이며 이번
SAME_STORE_TRANSACTIONALR2에서 제외; - broker publish, external notification: messaging/notification adapter 책임;
- cache/session/rate-limit: Redis 또는 해당 provider 책임;
- MongoDB query와 document schema: persistence-mongo 책임;
- generic reporting/analytics warehouse;
- XA/2PC와 cross-store exactly-once;
- arbitrary SQL console 또는 application-facing generic query language;
- database provisioning, replication orchestration, managed-service control plane;
- DBA 운영 도구 자체 구현;
- 두 번째 RDBMS 지원을 가정한 선제 module split.
5. HARD invariants
다음 중 하나라도 위반하면 기능이 동작해도 완료가 아니다.
domain-core는 JPA, Hibernate, Spring, JDBC, SQL, database type을 import하지 않는다.application-core는JpaRepository,EntityManager,Page,Pageable,Sort,Specification, persistence entity를 import하지 않는다.- controller는 repository, Spring Data interface, persistence entity를 직접 사용하지 않는다.
- inbound DTO를 repository/query/application transaction contract에 전달하지 않는다.
- entity mapper, converter, repository default method에 business invariant를 두지 않는다.
- application use case 밖에서 business transaction boundary를 새로 만들지 않는다.
- OSIV를 켜거나 lazy loading에 web serialization correctness를 의존하지 않는다.
- Hibernate schema update/create를 production schema writer로 사용하지 않는다.
- database connection을 보유한 채 broker/HTTP/object storage/file server를 호출하지 않는다.
- replica read를 strong read 또는 read-your-writes라고 암묵적으로 표현하지 않는다.
08*connection error를 commit 여부가 확실한 일반 retryable failure로 합치지 않는다.- stable operation ID와 reconciliation 없이 commit-indeterminate command를 자동 재실행하지 않는다.
- unique violation 전체를 idempotency claim race나 domain conflict로 간주하지 않는다.
- application이 전달한 raw table/column/order/expression을 SQL identifier로 조합하지 않는다.
- unbounded collection fetch, unbounded
IN, unbounded offset/page size를 허용하지 않는다. - pessimistic lock 구간 안에서 remote I/O, user think time, unbounded computation을 수행하지 않는다.
- efficiency JDBC lock을 fencing correctness lock으로 광고하지 않는다.
REQUIRES_NEW를 record별 loop나 동시 request마다 무제한 중첩하지 않는다.- migration 파일을 적용 후 수정하거나 checksum repair를 정상 배포 절차로 삼지 않는다.
- tenant profile에서 tenant predicate 없는 query/unique/index를 허용하지 않는다.
- 사용하지 않는 replica/maintenance/optional provider가 connection 또는 scheduler를 생성하지 않는다.
- real PostgreSQL test를 조건부 skip한 결과로 R2를 주장하지 않는다.
- SQL/parameter/PII/high-cardinality identifier를 metric tag나 일반 log에 기록하지 않는다.
src/config/architecture/modules.json밖의 dependency edge를 설계 편의로 추가하지 않는다.
6. 대안 검토
A. JPA entity를 domain entity로 통합
boilerplate가 줄어들지만 domain이 JPA annotation, lazy proxy, collection lifecycle, no-arg constructor, persistence identity에 결합한다. template의 가장 중요한 교체 가능성과 HARD-STOP을 훼손하므로 선택하지 않는다.
B. application에 Spring Data repository를 직접 노출
paging과 query 작성은 빠르지만 transport/framework type이 use case contract가 된다. aggregate별 command port와 목적별 query port를 유지한다.
C. 모든 query를 JPA entity graph로 해결
단순 조회에는 편하지만 reporting projection, keyset, PostgreSQL lock/claim, plan control에 불리하다. aggregate write는 JPA, read model은 JPQL projection 또는 native/JDBC를 선택하는 hybrid를 채택한다.
D. 모든 query를 jOOQ로 전환
SQL type safety와 query visibility는 장점이지만 build/code-generation, license/edition, schema source, module dependency가 추가된다. 현재 요구 증거가 없으므로 R2 baseline에서 도입하지 않는다. 복잡한 read model이 충분히 늘고 native query drift가 실제 비용이 될 때 별도 설계로 검토한다.
E. repository adapter마다 @Transactional
호출 단위는 단순해지지만 하나의 use case가 여러 port를 원자적으로 묶기 어렵고 transaction
policy가 adapter에 흩어진다. application-owned TransactionPort를 유지한다.
F. 모든 transient SQLState를 자동 retry
재시도 편의는 생기지만 callback에 remote side effect가 있거나 commit outcome이 unknown이면 중복 실행을 만든다. explicit replay-safe policy가 있는 whole transaction만 bounded retry한다.
G. read-only transaction은 자동 replica
코드 변경이 적지만 read-after-write, lag, failover semantics가 숨겨진다. explicit
ReadConsistency와 route qualification을 선택한다.
H. REQUIRES_NEW로 outbox publish까지 감싸기
broker publish와 DB mark 사이의 원자성은 생기지 않는다. business transaction은 outbox append만 포함하고 relay는 claim/publish/terminal transition을 분리한다.
I. PostgreSQL provider를 즉시 별도 leaf로 분리
현재는 registry 증가와 composition 복잡성만 만든다. 두 번째 RDBMS, 독립 SDK release, security boundary가 실제로 생길 때만 split한다.
J. PgBouncer를 R2 필수 baseline으로 지정
deployment에 따라 유용하지만 transaction/session pooling mode, prepared statement, startup parameter, failover topology가 달라진다. 별도 qualified optional profile로 둔다.
7. 목표 아키텍처
adapter:inbound:*
|
v
application use case
| - validates command semantics
| - selects named TransactionPolicyId
| - owns retry/reconciliation decision
|
+--> TransactionPort -----------------------------+
| |
+--> AggregateRepositoryPort |
+--> PurposeBuiltQueryPort |
+--> OutboxAppendPort / IdempotencyPort / InboxPort|
v
adapter-outbound-persistence-jpa
+------------------------------+
| transaction |
| routing |
| failure |
| aggregate/<feature> |
| query/<feature> |
| idempotency/outbox/inbox |
| migration configuration |
| postgresql/ |
+------------------------------+
| |
v v
primary pool optional replica pool
| |
+------ PostgreSQL ------+
Transaction path:
use case
-> resolve named policy
-> intersect policy timeout with CallBudget
-> resolve route before transaction begins
-> acquire admission permit
-> acquire connection / begin
-> SET LOCAL statement_timeout / lock_timeout / context
-> execute repository/query callbacks
-> flush
-> commit
-> classify phase-aware outcome
-> release connection and admission
route, transaction, timeout, failure는 repository별 임의 utility가 아니라 공통 runtime
boundary다. 그러나 application에는 하나의 generic persistence command API를 노출하지 않고
각 feature의 semantic port를 유지한다.
8. 모듈과 package 소유권
8.1 domain-core
소유:
- aggregate/entity/value object/domain event;
- invariant와 state transition;
- framework-free repository semantics가 정말 domain vocabulary일 때의 port.
금지:
@Entity,@MappedSuperclass,@Version,@Column;Instant를 SQL timestamp로 변환하는 persistence 규칙;- lazy collection, proxy,
EntityManager; - retry, SQLState, isolation, replica.
8.2 application-core
소유:
- command와 use case;
- aggregate별 repository port;
- purpose-built query port와 framework-neutral result/cursor;
TransactionPort,TransactionPolicyId,ReadConsistency,CallBudget;- idempotency/outbox/inbox semantic contract;
- typed application failure와 reconciliation command.
금지:
- Spring transaction annotation;
- JPA entity/repository/type;
- transport DTO;
- raw SQL, table/constraint name;
- provider topology와 JDBC URL.
8.3 adapter-outbound-persistence-jpa
소유:
- JPA entity, embedded ID, attribute converter;
- Spring Data repository;
- domain/persistence mapper;
- repository/query port implementation;
- transaction manager bridge와 route context;
- audit persistence metadata;
- failure translation;
- idempotency/outbox/inbox persistence implementation;
- Flyway migration;
- Hikari/JPA/PostgreSQL provider settings와 validation;
- PostgreSQL native query/SQLState/timeout/claim;
- real PostgreSQL qualification test source set.
권장 package shape:
dev.caskeleton.adapter.outbound.persistence
├── audit
├── config
├── failure
├── routing
├── transaction
├── query
├── idempotency
├── outbox
├── inbox
├── lock
├── migration
└── postgresql
├── config
├── failure
├── routing
├── timeout
├── query
├── idempotency
├── outbox
└── inbox
feature aggregate의 persistence entity/repository/mapper는 production 목표 domain이 생기면
그 feature package에 둔다. sample-portfolio entity는 sample module에 남긴다.
8.4 app-bootstrap
소유:
- canonical activation SSOT;
- datasource/secret material binding;
- application artifact composition;
- Flyway startup vs external migration-job mode;
- readiness/liveness exposure;
- graceful shutdown orchestration.
business use case, repository mapping, SQL을 두지 않는다.
8.5 sample-portfolio
소유:
- WorkLog sample consumer;
- template 사용 예시와 fixture;
- sample-specific entity, query adapter, migration;
- sample integration test.
production leaf는 이 module에 의존하지 않는다. production readiness evidence가 sample에만 존재하면 JPA leaf R2 근거로 충분하지 않다.
9. Readiness와 guarantee 모델
9.1 readiness level
| Level | 의미 |
|---|---|
| R0 | interface, placeholder, unqualified seam만 있다. |
| R1 | deterministic unit/local integration evidence가 있다. |
| R2 | production profile의 real PostgreSQL, failure, concurrency, migration, observability evidence가 필수 CI에서 통과한다. |
| R3 | target topology에서 restore/failover/rolling migration/capacity rehearsal와 운영 SLO evidence가 있다. |
9.2 guarantee descriptor
각 deployment는 최소 다음 descriptor를 startup log와 diagnostics에 노출한다.
provider postgresql
providerVersion 16.<qualified-minor>
ormVersion 7.1.8
primary enabled
replica disabled | enabled
readConsistencyProfiles STRONG[, BOUNDED_STALENESS, EVENTUAL]
migrationMode STARTUP | EXTERNAL_JOB
schemaCompatibility N_AND_N_MINUS_1
tenantMode NONE | DISCRIMINATOR | DISCRIMINATOR_RLS
transactionPolicies [COMMAND_DEFAULT, QUERY_PRIMARY, ...]
legacyWriteIdentity disabled | INVOCATION_UNCORRELATED
idempotency V1 | OWNER_SAFE_V2
outboxStorage V1_MUTABLE | IMMUTABLE_PARTITIONED_V2
outboxDispatchMode disabled | polling | cdc
outboxDispatchProfile disabled | POLLING_DELIVERY_V2 | CDC_RETENTION_V1
inbox disabled | SAME_STORE_V1
jdbcCoordination disabled | EFFICIENCY_ONLY
cardReadiness {cardId: R0 | R1 | R2 | R3}
evidenceManifestIds {cardId: immutableManifestId}
externalEvidenceIds {namespacedCardId: immutableManifestId}
migrationStreamRevisions {cardId: historyTable/revision/state}
durabilityProfile provider-qualified RPO/RTO descriptor
descriptor는 비밀, host, database/user 이름을 포함하지 않는다. configured 값이 아니라 startup validation과 probe가 성공한 effective capability를 나타낸다.
9.3 보장과 비보장
| 제공 가능한 보장 | 제공하지 않는 보장 |
|---|---|
| 한 primary DB transaction 안의 row/constraint atomicity | DB와 broker/object storage 사이 atomic commit |
| explicit version 또는 lock에 의한 lost-update 방지 | 모든 business conflict의 자동 해결 |
| policy-qualified primary read | replica의 무조건 최신 read |
| owner-safe same-store claim transition | cross-store exactly-once |
| migration checksum과 schema compatibility validation | arbitrary rollback migration의 무손실 |
| stable order key를 가진 cursor traversal | concurrent write 중 전체 dataset snapshot, 별도 transaction 없이는 보장 안 함 |
| explicit timeout의 bounded wait intent | network/driver/kernel을 포함한 완전한 hard deadline |
| commit-indeterminate typed outcome | 장애 중 commit 여부의 즉시 판정 |
10. Application 계약
10.1 aggregate repository port
repository port는 기술 CRUD가 아니라 aggregate use case에 필요한 의미를 표현한다.
public interface WorkItemRepositoryPort {
Optional<WorkItem> findById(WorkItemId id);
void add(WorkItem aggregate);
SaveOutcome update(WorkItem aggregate, AggregateVersion expectedVersion);
}
규칙:
save(T)하나로 insert/update/upsert를 숨기지 않는다.- not-found, version conflict, duplicate business key를 구분한다.
- persistence-generated ID에 business 흐름이 종속되지 않도록 ID는 transaction 전 생성한다.
- returned domain object에 lazy proxy가 남지 않는다.
- repository 호출 하나가 transaction을 자동 생성한다고 가정하지 않는다.
- aggregate 밖의 대량 조회나 reporting은 별도 query port로 분리한다.
10.2 purpose-built query port
public interface WorkItemSummaryQueryPort {
WorkItemSlice findSummaries(
WorkItemQuery query,
WorkItemCursor cursor,
PageLimit limit);
}
application query에는 허용된 filter/sort를 typed value로 정의한다. inbound가 전달한 arbitrary field name, direction, expression을 그대로 받지 않는다.
반환 타입은 다음을 포함할 수 있다.
- immutable application projection;
items;- opaque next cursor;
hasNext;- source consistency;
- optional snapshot/as-of marker.
Spring Page, Slice, Sort, Pageable은 adapter 내부에만 존재한다.
read consistency 선택의 SSOT는 query port 인자가 아니라 use case가 여는
TransactionRequest.readConsistency다. query port는 이미 고정된 transaction route에서
실행하며 별도의 consistency를 받아 route를 다시 선택하지 않는다. 결과 projection에는 실제
source consistency와 authority marker를 관측 정보로 담을 수 있지만, 이것은 입력 policy가
아니다.
10.3 transaction contract의 additive evolution
기존 호출자는 다음 facade를 계속 사용할 수 있다.
<T> T inWrite(Supplier<T> action);
<T> T inRead(Supplier<T> action);
<T> T inNew(Supplier<T> action);
기존 interface에 새 abstract method를 바로 추가하면 모든 fake/provider의 source compatibility가 깨진다. 목표 contract는 additive sub-port로 named policy를 추가한다.
public interface PolicyTransactionPort extends TransactionPort {
<T> TransactionResult<T> inTransaction(
TransactionRequest request,
Supplier<T> action);
}
public record TransactionRequest(
TransactionPolicyId policyId,
CallBudget callBudget,
Optional<ReadConsistency> readConsistency,
Optional<OperationId> operationId) {}
정확한 class shape는 구현 계획에서 다듬을 수 있지만 다음 의미는 바꾸지 않는다.
- caller가 임의 isolation/timeout/propagation 숫자를 전달하지 않는다.
- application이 allowlisted
TransactionPolicyId를 선택한다. - inbound DTO가 policy ID를 직접 고르지 않는다.
CallBudget은 absolute monotonic deadline이며 wall-clock으로 serialize하지 않는다.- command의 stable
OperationId는 commit-indeterminate reconciliation에 사용한다. - 기존
inRead는QUERY_PRIMARY + STRONG이다. - ID 인자가 없는 기존
inWrite/inNew는 각각 legacy non-replayable policy에만 연결한다. 새COMMAND_DEFAULT/MAINTENANCE_NEW의 operation identity를 임의 UUID로 가장하지 않는다.
TransactionResult<T>는 nullable value/exception 조합이 아니라 다음 sealed algebra와 동등해야
한다.
COMMITTED(value, optional operationId)
PARTICIPATING_PENDING_OUTER(value)
DETERMINATE_ROLLBACK(failure)
INDETERMINATE(optional operationId, lastObservedPhase, optional reconciliationReference)
COMMITTED_WITH_POST_COMMIT_FAILURE(value, optional operationId, operationalFailure)
- final outcome은 root physical transaction owner만 만든다.
- participant는 value를 반환할 수 있지만 outer 종료 전 commit 성공을 주장하지 않는다.
INDETERMINATE에는 자동 replay 권한이 없다.PolicyTransactionPortwrite policy에서는 stableOperationId와 reconciliation reference가 필수이고 legacy facade에서만 둘이 없을 수 있다.COMMITTED_WITH_POST_COMMIT_FAILURE는 rollback failure가 아니며 value가 이미 commit된 결과다. semantic side effect를afterCommit에 두지 않는다는 architecture rule을 전제로 replay하지 않는다.PolicyTransactionPort의 command/infrastructure write policy는 route/admission 전에operationId존재를 검증한다.OUTBOX_APPENDparticipant는 outer operation identity를 상속한다. replay-safe read만 operation ID를 생략할 수 있다.
Spring adapter 한 instance가 TransactionPort와 PolicyTransactionPort를 함께 구현하고,
기존 read method는 canonical read policy로, 기존 write/new method는 아래 legacy-only
policy로 위임한다. 기존 fake/caller는 그대로 compile되며
named policy가 필요한 use case만 새 sub-port로 순차 이동한다. 모든 provider/fake와
architecture rule이 전환되기 전 기존 interface에 abstract method를 추가하거나 legacy method를
삭제하지 않는다.
legacy root write/new는 invocation 간 stable operation identity가 없으므로 transaction replay를
하지 않는다. commit outcome이 indeterminate면 replay-disabled
LegacyTransactionOutcomeIndeterminateException과 sanitized correlation/phase만 반환하고,
operator가 allowlisted business key 또는 DB fact로 수동 reconcile한다. 별도 호출의 중복 방지를
주장하지 않는다. outer policy transaction에 참여하면 outer operation identity를 상속한다.
호출자는 순차적으로 PolicyTransactionPort.inTransaction(...)으로 이동해 use-case/message/job
identity를 명시한다. 현재 PublishPendingOutboxEventsUseCase 같은 inNew 호출도 이 migration
대상이며, adapter가 생성한 random ID를 business intent와 안정적으로 연결된 ID로 취급하지
않는다.
@UseCaseCapability.transactionMode는 유스케이스의 정적 transaction shape에 대한 canonical
선언이고, TransactionPolicyId는 그 mode 안의 runtime refinement다. 둘의 허용 관계를
application registry와 ArchUnit이 함께 검증한다.
TransactionMode |
허용 policy family |
|---|---|
WRITE |
REQUIRED, primary, read-write command policy |
READ_ONLY |
REQUIRED, read-only query policy |
REQUIRES_NEW |
allowlisted REQUIRES_NEW infrastructure policy |
예를 들어 READ_ONLY use case가 COMMAND_DEFAULT를 선택하거나 WRITE use case가
QUERY_REPLICA_ELIGIBLE을 선택하면 startup/architecture test가 실패한다. generic
inTransaction(...) 도입 시 기존 “inWrite/inRead/inNew 직접 호출” ArchUnit 규칙을
policy-family coherence 규칙으로 함께 교체하며, 어느 한쪽만 바꿔 enforcement 공백을 만들지
않는다.
@UseCaseCapability.externalOutboundAllowed=true는 use case가 remote port를 사용할 수 있다는
선언이지 DB transaction callback 안 remote I/O 허가가 아니다. remote call은 transaction
전후의 명시적 phase 또는 outbox/workflow로 분리한다. transaction callback이 external
provider port를 직접 호출하는 call graph는 architecture test로 거절하고, 간접 호출은 code
review와 fault test가 보강한다.
10.4 named policy
초기 canonical policy:
| Policy | Propagation | Isolation | Read-only | Route | 허용 ReadConsistency |
Operation ID | Retry |
|---|---|---|---|---|---|---|---|
COMMAND_DEFAULT |
REQUIRED | READ_COMMITTED | false | PRIMARY | absent only | required | none |
COMMAND_SERIALIZABLE_REPLAY_SAFE |
REQUIRED | SERIALIZABLE | false | PRIMARY | absent only | required | bounded whole transaction |
QUERY_PRIMARY |
REQUIRED | READ_COMMITTED | true | PRIMARY | STRONG, READ_YOUR_WRITES |
optional | none |
QUERY_REPLICA_ELIGIBLE |
REQUIRED | READ_COMMITTED | true | qualified replica | EVENTUAL, BOUNDED_STALENESS |
optional | query-only bounded |
OUTBOX_APPEND |
join caller | caller | false | PRIMARY | absent only | inherit outer | none |
INBOX_AND_HANDLER |
REQUIRED | READ_COMMITTED | false | PRIMARY | absent only | required message operation | explicit message policy |
MAINTENANCE_NEW |
REQUIRES_NEW | READ_COMMITTED | false | PRIMARY | absent only | required batch/job operation | bounded batch only |
COMMAND_LEGACY_NON_REPLAYABLE |
REQUIRED | READ_COMMITTED | false | PRIMARY | absent only | unavailable unless inherited | none |
MAINTENANCE_LEGACY_NON_REPLAYABLE |
REQUIRES_NEW | READ_COMMITTED | false | PRIMARY | absent only | unavailable unless inherited | none |
read policy는 consistency가 반드시 있어야 하고 command/infrastructure policy에는 없어야 한다.
policy-consistency 조합은 route/admission 전에 resolve해 허용 표 밖이면 fail-fast한다. nested
REQUIRED는 이미 resolved된 outer context와 호환되는 요청만 참여하고 route/consistency를
재선택하지 않는다.
두 legacy policy ID는 PolicyTransactionPort.TransactionRequest가 선택할 수 없는
adapter-internal compatibility entry다. descriptor는 legacy 호출 count와
INVOCATION_UNCORRELATED commit-uncertainty risk를 노출하고 호출 count가 0이 된 뒤 제거한다.
QUERY_REPLICA_ELIGIBLE은 replica profile이 R2가 아니면 startup에 등록하지 않는다. 등록되지
않은 policy를 primary/default에 조용히 매핑하지 않고 configuration failure로 처리한다.
11. Entity와 mapping baseline
11.1 domain과 persistence entity 분리
domain aggregate
<-> explicit mapper
<-> JPA persistence entity
mapper 책임:
- ID/value object와 column representation 변환;
- nullable/optional representation 변환;
- persistence child collection과 domain collection 변환;
- storage enum/version compatibility 변환.
mapper 금지:
- status transition;
- authorization;
- price/limit/eligibility 계산;
- default business policy;
- remote lookup;
- repository 호출;
- transaction 시작.
invariant가 깨진 row를 읽으면 조용히 보정하지 않고 typed corruption/incompatible-schema failure로 격리한다.
11.2 ID
- application/domain에서 UUID를 먼저 생성한다.
- 외부 노출 ID와 내부 surrogate key를 분리할 필요가 있으면 명시적으로 둘 다 모델링한다.
- PostgreSQL UUID column을 기본으로 하고 string UUID 저장은 migration 호환 사유가 있을 때만 사용한다.
- database sequence가 필요한 high-throughput batch aggregate는 별도 benchmark/evidence 후 선택한다.
- ID generator 변경은 rolling compatibility migration으로 다룬다.
11.3 time
- business/audit instant는 Java
Instant, PostgreSQLtimestamptz를 기본으로 한다. - JVM, JDBC, database session timezone은 UTC로 검증한다.
- local business date/time은 의미가 있을 때
LocalDate/LocalTime과 timezone ID를 별도로 저장한다. - ordering에 timestamp 하나만 사용하지 않는다. 동일 timestamp tie-breaker로 stable ID 또는 sequence를 포함한다.
- database time과 application time 중 correctness authority를 operation별로 하나만 선택한다.
lease/claim/expiry는 database transaction 안에서 비교할 때 PostgreSQL clock을 사용한다. domain event occurred time은 application clock port를 사용할 수 있다.
11.4 enum
- JPA ordinal enum은 금지한다.
- string code를 저장하고 rolling deploy에서 old/new version이 모두 이해하는 additive 순서를 따른다.
- DB check constraint를 쓰면 새 value 허용을 old application switch보다 먼저 배포한다.
- unknown future value를 무조건 기존 enum으로 강제 변환하지 않는다. read compatibility strategy가 없으면 schema incompatibility로 fail한다.
11.5 relation과 cascade
- relation은 기본 LAZY다.
EAGER를 N+1 해결책으로 사용하지 않는다.- aggregate boundary 안의 owned child에만 cascade/orphan removal을 사용한다.
CascadeType.ALL을 기본값으로 두지 않는다.- aggregate 간 relation은 ID reference를 우선하며 하나의 거대한 object graph를 만들지 않는다.
- collection은 deterministic order가 필요하면 order column 또는 explicit key를 정의한다.
11.6 optimistic version
- mutable aggregate root에는 version을 둔다.
- application의 expected version과 persistence
@Version을 일관되게 매핑한다. - version conflict는 generic internal error가 아니라 typed concurrent modification이다.
- conflict 후 자동 merge는 domain policy가 명시한 경우에만 한다.
- bulk update/delete는 JPA version과 persistence context를 우회하므로 일반 aggregate command에 사용하지 않는다.
11.7 audit
- audit actor/request context는 adapter의
AuditContextPort에서 받는다. - create/update stamp는 persistence entity mapping의 기술 정보다.
- business event의 actor/reason은 domain/application command에 별도로 남긴다.
- bulk/native DML은 audit/version을 자동 적용하지 않으므로 별도 명시 SQL과 test가 필요하다.
- update 시 기존 creation audit을 보존하기 위해 불필요한 추가 read를 강제하지 않도록 persistence context와 mapping strategy를 설계한다.
- actor가 없을 때
systemfallback을 허용하는 operation 목록을 명시한다.
11.8 column baseline
- 금액은 scale/precision이 명시된 decimal 또는 smallest-unit integer다.
- JSON은 schema/version/size/query requirement가 있을 때만 사용한다.
- large binary는 DB가 correctness/transaction boundary여야 하는 작은 payload에만 사용하고, 일반 object는 object-storage reference를 사용한다.
- nullable column은 migration compatibility와 domain optionality를 구분한다.
- natural/business key에는 명시적 unique constraint name을 부여한다.
- 모든 FK/index/constraint 이름은 deterministic naming convention을 사용한다.
12. Transaction semantics
12.1 transaction boundary
권장 command shape:
use case
validate pure input
derive stable operationId from the application command identity
-> transactionPort.inTransaction(COMMAND_DEFAULT, budget, operationId) {
load aggregate
apply domain transition
persist aggregate
append outbox
}
-> return application result
금지 shape:
controller @Transactional
repository adapter @Transactional
mapper starts transaction
transaction {
write DB
call broker/HTTP/object storage
}
Spring scheduler가 maintenance trigger를 소유할 수는 있으나 business policy와 transaction selection은 application command를 호출해야 한다. 순수 infrastructure reaper도 명시적인 maintenance policy와 bounded batch를 가져야 한다.
12.2 propagation
기본은 REQUIRED다.
- command 내 여러 repository 호출은 하나의 physical transaction에 참여한다.
- nested
REQUIRED가 rollback-only가 되면 outer caller에게 명확히 실패한다. NESTEDsavepoint는 R2 baseline에서 제공하지 않는다.NOT_SUPPORTED,NEVER,MANDATORY를 application-facing generic option으로 노출하지 않는다.REQUIRES_NEW는 outbox/audit/compensation이라는 이름만으로 자동 허용하지 않는다. caller transaction과 독립 commit이 실제 invariant인지 검토한다.
REQUIRES_NEW pool capacity 하한:
required connections
>= max concurrent outer transactions
+ max concurrent REQUIRES_NEW transactions
+ maintenance/migration/health reserve
한 outer transaction이 동시에 하나의 inner transaction만 열어도 각 active outer connection이
반납되지 않는다. record별 REQUIRES_NEW loop는 금지하고 bounded batch transaction을 사용한다.
R2 baseline은 REQUIRES_NEW 최대 중첩을 1로 제한하고 outer lane과 분리된 inner
connection/permit reserve를 둔다. outer가 primary connection과 permit을 보유한 채 일반
command lane의 permit을 다시 기다리는 순환은 금지한다. acquire 순서는
outer permit -> outer connection -> inner-reserve permit -> inner connection으로 고정하며,
inner reserve가 없으면 outer transaction을 시작하기 전에 해당 policy를 거절한다. capacity
test는 모든 outer가 동시에 inner를 요구하는 barrier scenario에서도 유한 시간 안에 진행하거나
명시적으로 admission reject하는지 검증한다.
12.3 read-only
read-only는 다음을 의미한다.
- transaction intent와 ORM flush optimization;
- primary/replica route eligibility의 한 입력;
- PostgreSQL read-only transaction 설정 검증.
다음을 의미하지 않는다.
- replica 자동 사용;
- stale read 허용;
- database가 모든 accidental write를 항상 막는다는 무조건 보장;
- transaction 없이 lazy load 허용.
12.4 flush
- normal command는 commit 직전 flush에 의존할 수 있다.
- constraint/version failure를 특정 application step에서 분류해야 하면 그 step 뒤에 explicit flush한다.
- explicit flush는 commit 성공을 의미하지 않는다.
- bulk loop는 batch마다 flush/clear하고 detached entity를 domain result로 반환하지 않는다.
- query-before-commit의 implicit flush 비용을 query design에 포함한다.
12.5 checked exception과 rollback
application callback은 현재 Supplier/Runnable 기반 RuntimeException contract를 유지한다.
checked failure가 필요한 port는 application typed RuntimeException carrier로 감싸며 원인을
보존한다. 임의 catch (Exception) 후 성공 결과를 반환하지 않는다.
rollback failure가 원래 action failure를 대체할 수 있으므로 transaction outcome에는 primary failure와 cleanup/rollback failure를 함께 보존한다. client에는 하나의 안전한 error code만 노출한다.
13. Isolation과 concurrency
13.1 기본 isolation
PostgreSQL의 READ_COMMITTED를 일반 command/query 기본값으로 유지한다. 각 statement는
statement 시작 시점의 snapshot을 볼 수 있으므로 한 transaction 안의 두 query가 다른
committed state를 볼 수 있음을 문서화한다.
READ_COMMITTED로 충분한 경우:
- primary key로 aggregate를 읽고
@Version으로 update conflict를 검출; - unique/check/FK constraint가 correctness를 최종 보장;
- queue claim이 single statement 또는 lock-protected transition;
- read-only projection이 repeatable snapshot을 요구하지 않음.
13.2 REPEATABLE_READ
다음 경우 named policy로만 사용한다.
- 한 transaction 내 여러 query가 동일 snapshot을 봐야 하는 export/snapshot 계산;
- write skew가 DB constraint/optimistic version으로 방지되는지 별도 검토된 경우.
long-running snapshot은 vacuum과 replica replay를 방해할 수 있으므로 row/time budget, statement timeout, 운영 관측을 필수로 한다.
13.3 SERIALIZABLE
다음 조건을 모두 만족할 때 사용한다.
- business invariant를 constraint나 single-row version만으로 표현하기 어렵다.
- transaction callback 전체가 replay-safe다.
- serialization failure 시 transaction 전체를 처음부터 재실행한다.
- max attempts, jitter, absolute budget이 있다.
- 외부 side effect가 callback 안에 없다.
serialization failure 하나의 statement만 재시도하지 않는다. 이전 read에 의존한 모든 판단을 다시 수행한다.
13.4 database constraint가 최종 correctness authority
“먼저 조회한 뒤 없으면 insert”만으로 uniqueness를 보장하지 않는다. 다음을 사용한다.
- named unique constraint;
- check constraint;
- FK;
- exclusion constraint가 실제 interval conflict에 필요하면 PostgreSQL-specific migration;
- atomic conditional
UPDATE ... WHERE ...; - version predicate.
application pre-check는 친절한 메시지나 빠른 거절을 위한 optimization일 뿐 race correctness가 아니다.
13.5 conflict 결과
| 원인 | application 의미 | 기본 retry |
|---|---|---|
| optimistic version mismatch | concurrent modification | 없음; caller/domain policy |
| allowlisted business unique constraint | duplicate/conflict | 없음 |
serialization 40001 |
replay-safe transaction conflict | bounded whole transaction |
deadlock 40P01 |
lock ordering/runtime conflict | replay-safe일 때만 bounded |
lock timeout 55P03 |
contention timeout | 기본 없음 |
statement cancel/timeout 57014 |
deadline/resource | budget이 남고 query-only일 때만 |
14. Locking
14.1 optimistic locking이 기본
일반 aggregate update:
read aggregate + version
apply domain transition
UPDATE ... WHERE id = ? AND version = ?
affected rows == 1 -> success
affected rows == 0 -> conflict/not-found distinction
장점:
- connection을 보유한 대기 시간이 짧다.
- application이 conflict 의미를 결정할 수 있다.
- cluster node 수와 무관하게 DB row version이 authority다.
14.2 pessimistic lock 허용 조건
다음을 모두 만족해야 한다.
- lock target을 index로 빠르게 찾는다.
- transaction이 짧고 remote I/O가 없다.
- deterministic lock order가 있다.
lock_timeout이 finite다.- max rows가 bounded다.
- timeout/conflict가 typed outcome이다.
- real PostgreSQL concurrency test가 있다.
PESSIMISTIC_WRITE 또는 SELECT ... FOR UPDATE는 해당 query method에 명시한다. repository
전체에 broad default를 적용하지 않는다.
14.3 lock ordering
여러 row/aggregate를 잠글 때 stable key ascending 같은 단일 order를 정의한다. 서로 다른 feature가 같은 table을 잠그면 공유 lock-order 문서와 test를 갖는다.
deadlock은 완전히 제거할 수 있다고 주장하지 않는다. 40P01을 관측하고 replay-safe
transaction에만 bounded retry한다.
14.4 SKIP LOCKED
SKIP LOCKED는 queue-like work claim에만 사용한다.
- 일반 사용자 조회에 사용하지 않는다.
- 결과가 일관된 snapshot이나 모든 row를 포함한다고 주장하지 않는다.
- deterministic eligibility/order와 batch limit가 필요하다.
- claim 후 owner token/lease가 별도 row state에 기록되어야 한다.
- starvation, DEAD head, reaper 정책을 운영 지표로 관측한다.
14.5 advisory lock
PostgreSQL advisory lock은 R2 baseline에서 사용하지 않는다. 도입 시:
- session vs transaction scope;
- key collision;
- connection pool 반환;
- failover;
- fencing 부재;
- observability
를 별도 설계한다. schema migration serialization은 Flyway의 지원 계약을 우선한다.
14.6 JDBC distributed lock의 한계
현행 Spring Integration JDBC lock은 다음 용도만 허용한다.
- duplicate scheduler work를 줄이는 efficiency coordination;
- 재실행 가능한 maintenance batch;
- correctness가 DB constraint/CAS로 별도 보호되는 작업.
다음을 보장하지 않는다.
- stale worker write 차단;
- fencing token;
- exactly-once;
- remote resource ownership;
- lease renewal 중 network partition safety.
release는 owner-safe하고 idempotent한 결과로 진화해야 하며 interruption과 timeout을 구분한다. correctness가 필요하면 resource write가 fencing token을 검증하는 별도 contract를 사용한다.
15. Failure, retry와 commit outcome
15.1 하나의 translation boundary
모든 persistence operation은 다음 boundary를 통과한다.
framework exception
+ SQLException chain / SQLState
+ constraint name
+ transaction phase
+ operation kind
-> PersistenceFailure
-> application-safe error + retry disposition + reconciliation requirement
권장 internal shape:
record PersistenceFailure(
PersistenceFailureCode code,
RetryDisposition retry,
TransactionOutcome outcome,
String operationId,
Throwable cause) {}
application-facing contract가 이 exact record를 가져야 한다는 뜻은 아니다. 중요한 것은
분류 정보가 generic INTERNAL_ERROR 하나로 소실되지 않는 것이다.
15.2 transaction phase
transaction manager 주변 collaborator는 최소 다음 상태를 기록한다.
ROUTE_ADMISSION
-> CONNECTION_ACQUIRED
-> ACTIVE
-> FLUSHED
-> COMMIT_REQUESTED
-> COMMIT_ACKED
-> SYNCHRONIZATION_CLEANUP
| Phase | failure 의미 | DB effect |
|---|---|---|
| route/admission 전 | 실행 안 됨 | 없음 |
| connection acquire/begin | transaction 시작 실패 | 없음으로 판정 가능해야 함 |
| active action/flush | statement, mapping, constraint 실패 | rollback 확인 시 determinate rollback |
COMMIT_REQUESTED, ACK 없음 |
commit 요청/응답 중 연결 유실 | COMMIT_INDETERMINATE 가능 |
COMMIT_ACKED 뒤 synchronization/cleanup |
DB commit은 확인됐으나 후처리 실패 | committed + post-commit failure, replay 금지 |
| rollback | cleanup 실패 | 원래 failure와 함께 운영 escalation |
classification precedence는 단순히 “commit method가 예외를 던졌다”가 아니다.
40001,40P01, rollback-only 또는UnexpectedRollbackException이고 resource rollback이 확인되면 determinate rollback이다.- commit 요청 뒤
08007, connection loss, socket timeout이 발생했고 ACK를 확인하지 못하면 indeterminate다. - JDBC commit ACK 뒤 transaction synchronization 또는 resource cleanup이 실패하면 committed post-commit failure다. callback을 재실행하지 않는다.
- phase를 관측하지 못하면 더 안전한 unknown/indeterminate로 강등한다.
08* connection class를 어느 phase에서든 같은 DB_UNAVAILABLE로만 반환하면 commit
uncertainty를 잃는다. 각 상태의 resource-level fault injection seam이 있어야 하며,
framework exception class보다 실제 phase/outcome 증거가 우선한다.
선택한 Spring 관측 지점
JPA leaf의 정본 구현 방향은 PhaseAwareTransactionExecutor가
PlatformTransactionManager decorator와 가장 먼저 실행되는 ordered
TransactionSynchronization sentinel을 함께 사용하는 것이다.
- decorator는 delegate
getTransaction,commit,rollback호출 전후의 phase를 기록한다. TransactionStatus.isNewTransaction()이 true인 physical owner만 finalCOMMITTED/ROLLED_BACK/UNKNOWN을 판정한다.- 기존 outer transaction에 참여한
REQUIREDboundary는 정상 반환을PARTICIPATING_PENDING_OUTER로 기록하며 committed outcome을 노출하지 않는다. 최종 결과는 outer physical owner가 결정한다. - phase tracker와 sentinel은 logical method call마다가 아니라 physical transaction identity별
하나다.
REQUIRES_NEW는 outer tracker를 suspend하고 독립 tracker/outcome을 만든 뒤 outer를 resume한다. - sentinel
afterCommit진입은 physical commit 뒤의COMMIT_ACKED관측으로 사용한다. afterCompletion(STATUS_COMMITTED)는COMMITTED,STATUS_ROLLED_BACK은ROLLED_BACK,STATUS_UNKNOWN은UNKNOWN이다.- physical owner의 delegate
commit()이 정상 반환하면COMMITTED다. participant의commit()정상 반환은 physical commit 증거가 아니다. - delegate
commit()이 예외를 던져도 sentinel이 commit ACK/committed를 이미 관측했다면COMMITTED_WITH_POST_COMMIT_FAILURE다. - rollback-only/flush failure 뒤
STATUS_ROLLED_BACK이면 determinate rollback이다. - commit 요청 뒤 ACK와 completion status가 모두 없으면
UNKNOWN이며DB_COMMIT_INDETERMINATE로만 번역한다.
sentinel은 business callback을 실행하지 않고 상태만 기록한다. user-defined
afterCommit/afterCompletion callback의 실패가 이 관측을 가리지 않도록 ordering을
고정한다. Spring/JPA 버전 변경 때 이 ordering과 callback lifecycle을 integration test로
재검증한다. 이 seam을 우회해 raw TransactionTemplate을 production에 별도 생성하지 않는다.
integration test는 inner REQUIRED 정상 반환 뒤 outer rollback, inner 정상 반환 뒤 outer
commit ACK 유실, REQUIRES_NEW의 독립 physical outcome을 구분한다.
legacy inWrite/inRead/inNew facade는 기존처럼 value를 반환하며 참여 boundary에서 final commit
성공을 새로 노출하지 않는다. 결과 매핑은 다음으로 고정한다.
| Physical result | PolicyTransactionPort |
legacy facade |
|---|---|---|
COMMITTED |
committed result + value | value 반환 |
PARTICIPATING_PENDING_OUTER |
pending result + value | value 반환, commit 보장 없음 |
DETERMINATE_ROLLBACK |
typed rollback result | 기존 translated persistence exception |
INDETERMINATE |
operation/reconciliation을 포함한 indeterminate result | replay-disabled typed exception; legacy root면 operation reference 없음 |
COMMITTED_WITH_POST_COMMIT_FAILURE |
committed value와 operational failure를 함께 반환 | value 반환 + mandatory incident metric/trace/readiness degradation |
legacy에서 post-commit failure를 rollback/retryable exception처럼 던지지 않는다. 실패한
callback은 observation/resource cleanup만 허용하며, semantic callback은 startup architecture
검증에서 거절하고 outbox/workflow로 옮긴다. cleanup 실패 connection은 폐기하고 incident를
운영자가 확인할 때까지 readiness policy에 반영한다. UNKNOWN completion은 commit 요청 이후면
INDETERMINATE(DB_COMMIT_INDETERMINATE), commit 요청 전 rollback/resource discard도 확인하지
못했으면 INDETERMINATE(DB_TRANSACTION_OUTCOME_UNKNOWN)으로 정규화한다. 둘 다 operation
ledger/reconciliation 전에는 facade callback을 재실행하지 않는다.
15.3 internal failure code와 public error compatibility
다음은 adapter/application 내부 PersistenceFailureCode의 최소 분류다. 곧바로
shared-contract.OperationalError의 public 이름을 교체한다는 뜻이 아니다.
| Code | 대표 근거 | 의미 |
|---|---|---|
DB_UNAVAILABLE |
acquire/begin의 08*, resource failure |
실행 전 또는 확실한 rollback 후 unavailable |
DB_COMMIT_INDETERMINATE |
commit 중 08007 또는 connection loss |
reconcile before retry |
DB_TRANSACTION_OUTCOME_UNKNOWN |
commit 전 rollback/resource discard도 확인 불가 | reconcile/fatal cleanup, no blind retry |
DB_POST_COMMIT_FAILURE |
commit ACK 뒤 synchronization/cleanup failure | committed, replay 금지, 운영 복구 |
DB_SERIALIZATION_FAILURE |
40001 |
whole transaction replay 후보 |
DB_DEADLOCK |
40P01 |
replay-safe일 때 후보 |
DB_CONSTRAINT_VIOLATION |
23* |
allowlist로 세분화하지 못한 constraint |
DB_UNIQUE_VIOLATION |
23505 |
allowlist가 application semantic conflict로 번역할 수 있음 |
DB_FK_VIOLATION |
23503 |
referenced state conflict |
DB_NULL_VIOLATION |
23502 |
schema/data contract defect 또는 invalid input |
DB_CHECK_VIOLATION |
23514 |
invariant/schema conflict |
DB_READ_ONLY |
25006 |
route/role/config drift |
DB_IDLE_TRANSACTION_TIMEOUT |
25P03 |
idle transaction operational guard |
DB_LOCK_TIMEOUT |
55P03 |
bounded contention |
DB_QUERY_TIMEOUT |
57014 + server timeout marker |
statement timeout |
DB_QUERY_CANCELLED |
57014 + caller cancel marker |
explicit cancellation |
DB_STATEMENT_INDETERMINATE |
40003 |
statement completion unknown, reconcile/no blind retry |
DB_RESOURCE_EXHAUSTED |
53* |
PostgreSQL resource capacity |
ADMISSION_REJECTED |
local admission | DB operation 시작 전 lane 거절 |
POOL_ACQUISITION_TIMEOUT |
Hikari wait | pool wait timeout, statement 미실행 |
DB_CONNECT_TIMEOUT |
login/connect bootstrap | physical connection 생성 실패 |
DB_OPTIMISTIC_CONFLICT |
ORM optimistic exception | concurrent aggregate update |
DB_PESSIMISTIC_CONFLICT |
ORM lock exception | lock acquisition failure |
DB_SCHEMA_INCOMPATIBLE |
missing relation/column/type | deploy/migration mismatch |
DB_UNKNOWN |
unmapped | retry false, secure diagnostic |
SQLState exact mapping이 중복되면 startup을 실패시킨다. broad class mapping보다 exact mapping이 우선하되, priority를 암묵적인 bean order로 정하지 않는다.
57014 하나만 보고 timeout과 caller cancellation을 추정하지 않는다. executor가 설치한
server timeout과 explicit cancellation token/statement cancel 관측을 함께 사용하며, 구분할
증거가 없으면 더 좁은 자동 retry 권한을 부여하지 않는다.
현재 public registry의 다음 아홉 이름은 동결한다.
DB_UNAVAILABLE
DB_SERIALIZATION_FAILURE
DB_DEADLOCK
DB_NULL_VIOLATION
DB_FK_VIOLATION
DB_UNIQUE_VIOLATION
DB_CHECK_VIOLATION
DB_IDLE_IN_TX_TIMEOUT
DB_QUERY_CANCELED
internal/public 호환 mapping은 명시적 registry로 관리한다.
| Internal | Existing public |
|---|---|
DB_UNAVAILABLE |
DB_UNAVAILABLE |
DB_SERIALIZATION_FAILURE |
DB_SERIALIZATION_FAILURE |
DB_DEADLOCK |
DB_DEADLOCK |
DB_NULL_VIOLATION |
DB_NULL_VIOLATION |
DB_FK_VIOLATION |
DB_FK_VIOLATION |
DB_UNIQUE_VIOLATION |
DB_UNIQUE_VIOLATION |
DB_CHECK_VIOLATION |
DB_CHECK_VIOLATION |
DB_IDLE_TRANSACTION_TIMEOUT |
DB_IDLE_IN_TX_TIMEOUT |
DB_QUERY_TIMEOUT, DB_QUERY_CANCELLED |
DB_QUERY_CANCELED |
DB_COMMIT_INDETERMINATE, DB_TRANSACTION_OUTCOME_UNKNOWN을 포함한 새 internal outcome은
registry migration 전에 기존 DB_UNAVAILABLE로 뭉개거나 public code로 직접 노출하지 않는다.
해당 target result를 production에 활성화하기 전에 docs/registries/error-codes.yaml,
OperationalError, web mapping, category/HTTP/retryable contract와 consumer compatibility를
하나의 migration으로 변경한다. indeterminate public code는 retryable=false이고
stable operation identity가 있는 policy는 reconciliation reference를 별도 안전한 response
field/header로 전달한다. legacy root는 reference를 만들지 않고
reconciliationAvailable=false만 노출한다. 어느 쪽도 raw DB 정보를 포함하지 않는다.
allowlisted constraint의 business 의미는 application error로 번역하며
skeleton-wide shared-contract에 DUPLICATE 같은 domain vocabulary를 추가하지 않는다.
public error의 기존 retryable=true는 transport/client advisory일 뿐
SAFE_WHOLE_TRANSACTION 허가가 아니다. 자동 transaction replay는 §15.5의 별도 disposition과
operation ledger 조건을 모두 충족해야 한다. 57014를 재시도하더라도 같은 transaction의
statement만 반복하지 않고 결과가 노출되지 않은 replay-safe whole read transaction을 새로
시작한다.
15.4 constraint allowlist
constraint name은 adapter 내부 registry에서 semantic error로 매핑한다.
uk_work_item_external_key -> WORK_ITEM_ALREADY_EXISTS
fk_work_item_owner -> WORK_ITEM_OWNER_MISSING
ck_work_item_status -> PERSISTED_STATE_INVALID
규칙:
- allowlist에 없는
23505를 idempotency race로 취급하지 않는다. - DB constraint name을 client message에 노출하지 않는다.
- rename migration은 old/new name을 rolling window 동안 모두 인식한다.
- mapping coverage를 migration/test가 검증한다.
15.5 retry disposition
NEVER
SAFE_WHOLE_TRANSACTION
RECONCILE_FIRST
CALLER_POLICY
retry 조건:
- transaction policy가 replay-safe를 선언한다.
- callback에 remote side effect가 없다.
- ID, clock/random 결과가 retry 간 안정적이거나 command intent에 고정된다.
- absolute
CallBudget이 남아 있다. - attempts와 exponential backoff/jitter가 bounded다.
- commit outcome이 determinate rollback이다.
금지:
- commit-indeterminate 자동 retry;
- controller/filter의 blanket retry;
- statement 하나만 재시도;
- 모든
DataIntegrityViolationExceptionretry; - 이미 소비한 one-shot stream callback retry;
- 새 operation ID를 생성한 재시도.
15.6 commit-indeterminate reconciliation
replay 가능한 command는 stable OperationId와 canonical intentDigest를 transaction의 첫
write로 operation ledger에 기록한다. operation_id는 unique이고 같은 ID의 다른 digest는
conflict다. 원 transaction과 replay가 겹치면 이 unique row/lock이 database 안에서 둘을
중재한다.
operation_ledger
operation_id primary key
operation_catalog_id
intent_digest
source_revision?
result_digest?
committed_at
raw request나 response를 ledger에 복제하지 않는다. 성공 복원에 필요한 bounded receipt만 저장하고 retention은 idempotency/retry window보다 짧지 않게 한다.
commit response lost
-> return typed INDETERMINATE
-> inspect current writable authority by operation ID
-> if committed intent matches: restore success
-> if conflicting intent: application/operator conflict
-> if absent but authority/timeline/RPO is not qualified: remain indeterminate
-> if same authoritative timeline is qualified and replay is allowed:
retry the same operation ID + intent digest through the unique ledger
(the database, not an absent read, arbitrates)
-> if conflicting intent: operator/application conflict
-> if still unknown: remain indeterminate
“connection error이므로 실패했다” 또는 “retry 후 성공했으므로 한 번만 실행됐다”라고
추론하지 않는다. 한 번의 primary absent read는 original commit이 아직 진행 중이거나
topology가 전환 중일 수 있으므로 safe retry 증거가 아니다. reconciliation은 current writable
role, authority/timeline epoch, observation horizon, declared RPO를 함께 검증한다. failover가
acknowledged write를 잃을 수 있는 window라면 absent여도 INDETERMINATE를 유지한다.
operation ledger가 없거나 operation key로 duplicate safety를 증명하지 못하는 command도 자동
replay하지 않는다. legacy facade root write는 이 범주이며 sanitized correlation과 allowlisted
business key를 이용한 manual inspection만 가능하다. invocation 뒤 새 operation ID를 만들어
ledger에 소급 삽입하거나 별도 재요청을 같은 operation으로 가장하지 않는다.
16. Datasource, pool과 admission
16.1 pool은 deployment-wide budget이다
pool size는 한 pod의 성능 숫자가 아니라 각 PostgreSQL server/proxy의 실제 application
connection budget에서 역산한다. primary와 서로 다른 replica의 max_connections를 하나의
합계로 더하지 않는다.
usable_application_connections(server)
= configured_server_limit
- superuser/provider reserved slots
- platform agents and monitoring
- migration/admin/break-glass reserve
- failover safety reserve
required_connections(server, topology_state)
= sum(maximum pools that can target server in topology_state)
+ health/maintenance reserve
max(required_connections(server, every qualified topology_state))
<= usable_application_connections(server)
topology_state에는 HPA 최대 instance, rolling surge, blue/green overlap, replica promotion,
replica query의 primary fallback을 포함한다. primary, 각 replica, proxy quota마다 별도 표를
남긴다. 현재 pod 수나 정상 상태 하나만 사용하지 않는다.
현행 D12의 per-process 하한도 보존한다.
hikari.maximumPoolSize
>= concurrent_threads * (1 + max_inNew_depth) + 1
추가 health/maintenance reserve가 1보다 크면 별도 합산한다. R2 baseline의
max_inNew_depth는 1이고 §12.2의 전용 inner reserve를 포함한다. 이 local 하한과 server별
deployment 상한을 동시에 만족하지 못하면 concurrency를 낮추거나 REQUIRES_NEW 구조를
제거해야지 pool 설정을 강제로 통과시키지 않는다.
16.2 primary와 replica pool 분리
replica profile은 route와 관측을 위해 별도 datasource/pool을 사용한다.
| Pool | 역할 | minimum idle | maximum | 필수 role probe |
|---|---|---|---|---|
| primary | write, strong read, reconciliation | explicit | capacity-derived | writable primary |
| replica | eventual/bounded read only | explicit | capacity-derived | read-only standby/qualified endpoint |
한 JDBC URL의 multi-host failover 기능만으로 semantic primary/replica routing을 대신하지 않는다.
primary pool은 targetServerType=primary와 role probe로 writable endpoint를 검증한다.
replica pool은 exact secondary/read-only endpoint를 요구한다. preferSecondary처럼 primary로
조용히 fallback하는 설정은 replica consistency descriptor와 충돌하므로 사용하지 않는다.
router가 replica failure 시 primary로 fallback할 수 있는 policy는 별도로 이름 붙인다.
fallback은 consistency를 강화하지만 primary load를 증가시키므로 metric과 admission을 거친다.
fallback은 transaction 시작 전 qualification 실패 때만 바로 허용한다. transaction/query가
시작된 뒤에는 같은 transaction에서 route를 바꾸지 않는다. 새 primary transaction에서 전체
query를 replay하는 fallback은 결과가 한 row도 caller에 노출되지 않았고 query가
replay-safe/materialized인 경우에만 허용한다. streaming 또는 일부 row 소비 뒤 failure는 typed
CONSISTENCY_UNAVAILABLE/DB_UNAVAILABLE로 끝내며 중간부터 이어 읽지 않는다.
16.3 fixed-size와 minimum idle
Hikari의 fixed-size 권장은 capacity가 산정된 production profile에서만 적용한다.
minimumIdle == maximumPoolSize
를 선택하면 startup/warmup connection storm, failover, rolling deploy의 총 연결 수를 검증한다. elastic pool을 선택하면 minimum, idle timeout, cold acquisition SLO를 별도로 검증한다.
template은 모든 환경에 하나의 숫자를 강제하지 않는다. 대신 다음을 강제한다.
- 모든 pool shape 값이 explicit;
- deployment-wide capacity equation;
- invalid/ambiguous Duration fail-fast;
- primary/replica별 metric;
- load evidence와 운영 owner.
16.4 virtual thread와 admission
Java 21 virtual thread는 JDBC connection 수를 늘리지 않는다. 많은 request가 작은 pool 앞에 동시에 대기하면 memory와 tail latency가 커진다.
각 DB operation class에 application-level admission/bulkhead를 둔다.
accepted concurrency
<= pool capacity + bounded wait queue
admission acquire는 CallBudget을 사용하며 connection을 얻기 전에 실패할 수 있다.
command/query/maintenance가 같은 permit을 무제한 경쟁하지 않도록 lane 또는 reserve를 둔다.
health probe가 request pool을 고갈시키지 않게 한다.
각 named policy는 admissionBudget, acquireBudget, beginBudget,
minimumActionWindow, completionMargin을 갖는다. Hikari connectionTimeout은 pool 전역
설정이므로 request마다 mutate하지 않는다. 한 pool을 공유하는 모든 수용 policy에 대해:
connectionTimeout <= policy.acquireBudget
를 startup에 검증한다. 더 짧은 acquisition class가 필요하면 별도 capacity가 산정된 pool을 만들거나 보수적으로 fail-fast하며, 같은 pool의 전역 timeout을 동적으로 바꾸지 않는다.
16.5 connection lifecycle
필수 설정과 조건:
connectionTimeout은 Hikari가 허용하는 finite 값이며 minimum보다 작지 않다.validationTimeout < connectionTimeout.keepaliveTime < maxLifetime.maxLifetime은 infrastructure connection lifetime보다 충분히 짧고 jitter를 고려한다.idleTimeout은 elastic pool일 때만 의미가 있다.- leak detection은 진단 도구이며 correctness나 timeout 대체가 아니다.
- initialization fail timeout과 startup retry 정책을 명시한다.
- JDBC login/connect/socket timeout도 bootstrap budget 안에 둔다.
connectionTimeout은 그 pool을 사용하는 모든 policy의 최소 acquisition budget 이하이다.- admission 뒤 남은 budget이 worst-case pool wait와 begin/action/completion 최소 window를
담지 못하면
getConnection()전에 거절한다.
Duration parser가 5s, ISO-8601, millisecond 중 canonical format을 정확히 읽지 못하면 값을
무시하지 않고 startup을 실패시킨다.
16.6 pool exhaustion outcome
pool acquisition timeout은 query timeout이나 DB unavailable과 구분한다.
ADMISSION_REJECTED application lane capacity
POOL_ACQUISITION_TIMEOUT Hikari wait exhausted
DB_CONNECT_TIMEOUT physical connection/bootstrap
DB_UNAVAILABLE server/route unavailable
각 결과는 서로 다른 운영 대응과 metric을 가진다. acquire 실패에는 DB statement가 실행되지 않았음을 보존한다.
17. Deadline과 timeout 계층
17.1 기본 부등식
한 operation의 목표 계층:
0 < lock_timeout
< statement_timeout
<= Spring transaction timeout
< remaining CallBudget
connection acquisition과 admission도 remaining CallBudget 안에 있어야 하며 response
serialization/cancellation margin을 남긴다.
Hikari가 operation별 wait timeout을 받지 않으므로 “남은 시간과 동적으로 교차한다”라고 과장하지 않는다. baseline은 두 번의 fail-fast gate를 사용한다.
before admission:
remaining >= admissionBudget
+ connectionTimeout
+ beginBudget
+ minimumActionWindow
+ completionMargin
after admission, before getConnection:
remaining >= connectionTimeout
+ beginBudget
+ minimumActionWindow
+ completionMargin
두 번째 gate를 통과하지 못하면 pool을 호출하지 않는다.
정확히 모든 operation에 lock timeout이 필요한 것은 아니다. lock을 사용하지 않는 query는 policy의 작은 default를 유지하거나 명시적으로 적용하지 않을 수 있다. 그러나 무한 대기는 허용하지 않는다.
17.2 effective timeout 계산
remaining = callBudget.remaining(now)
preAcquireRequired =
pool.connectionTimeout
+ policy.beginBudget
+ policy.minimumActionWindow
+ policy.completionMargin
if remaining < preAcquireRequired:
reject before pool acquisition
safeTxWindow =
remaining
- pool.connectionTimeout
- policy.beginBudget
- policy.completionMargin
springTimeoutSeconds =
floor_seconds(min(policy.transactionTimeout, safeTxWindow))
if springTimeoutSeconds < 1:
reject before delegate.getTransaction()
definition.timeout = springTimeoutSeconds
status = delegate.getTransaction(definition)
remainingAfterBegin = callBudget.remaining(now)
springWindowRemaining =
springTimeoutSeconds - elapsedSinceGetTransactionStarted
statementWindow =
min(remainingAfterBegin - completionMargin, springWindowRemaining)
statementTimeout = min(policy.statementTimeout, statementWindow - txMargin)
lockTimeout = min(policy.lockTimeout, statementTimeout - lockMargin)
Spring timeout은 per-call TransactionDefinition을 만든 뒤
delegate.getTransaction(definition)을 호출하기 전에 결정한다. begin 뒤 남은 window가
statement/lock timeout에 부족하면 첫 business statement 전에 rollback한다. 어느 변환에서도
0이 framework default/unlimited 의미가 되지 않게 ceil/floor 규칙을 test한다.
Spring transaction timeout은 초 단위 정수이므로 usable budget을 넘지 않는 양의 floor만
사용한다. floor가 1초 미만이면 transaction을 시작하지 않는다. PostgreSQL millisecond timeout이
있다고 Spring timeout을 0/default로 두지 않는다. 999ms, 1000ms, 1001ms와 margin
경계 test가 안전한 reject/1초 선택을 고정한다.
17.3 PostgreSQL local timeout
PostgreSQL package가 transaction 시작 직후 다음을 transaction-local로 적용한다.
select set_config('statement_timeout', :statement_timeout_text, true);
select set_config('lock_timeout', :lock_timeout_text, true);
select set_config('idle_in_transaction_session_timeout', :idle_guard_timeout_text, true);
또는 동등한 parameterized SET LOCAL protocol을 사용한다.
규칙:
- session-level state를 pool에 누출하지 않는다.
- route/tenant/timeout context는 첫 business statement 전에 설정한다.
- local 설정 실패 시 business query를 진행하지 않는다.
- nested
REQUIRED는 outer transaction timeout/route를 늘리거나 바꾸지 못한다. - participating inner deadline이 더 짧으면 physical transaction context의
statement/lock/idle GUC를 현재 값과 inner effective 값의 minimum으로 한 번 더 낮춘다.
이 축소는 inner 종료/예외 뒤 복원하지 않고 physical transaction 종료까지 sticky하다.
outer의 이후 statement도 축소된 GUC와 absolute
CallBudgetpre-gate를 사용한다. - participant는 Spring transaction timeout을 다시 설정하지 않고, local GUC를 늘리거나 outer absolute deadline을 연장할 수 없다.
- PostgreSQL 16 baseline에 없는 기능을 사용 가능하다고 가정하지 않는다.
set_config의 value는 PostgreSQL이 요구하는 text로 명시적으로 변환하며, bind parameter를 지원하지 않는 rawSET LOCAL ... ?문자열을 만들지 않는다.
sticky minimum을 선택한 이유는 같은 physical transaction에서 set_config(..., true)가
transaction 종료까지 유지되기 때문이다. 저장/복원으로 outer budget을 다시 늘리지 않는다.
outer-before → tighter inner → outer-after, caught inner exception, inner statement timeout과
absolute deadline 경계를 real PostgreSQL에서 검증한다. inner timeout으로 transaction이 abort
상태가 되면 catch 후 계속하지 않고 rollback-only로 종료한다.
PostgreSQL 16에서는 server-side statement_timeout, lock_timeout,
idle_in_transaction_session_timeout과 Spring transaction timeout을 조합한다. 이것을
kernel/network까지 포괄하는 hard cancellation이라고 표현하지 않는다.
idle_in_transaction_session_timeout은 statement/lock total-deadline 부등식의 한 항이
아니라 “business statement 사이에 허용할 최대 idle gap”을 막는 별도 operational guard다.
remote I/O나 user think time을 transaction 안에서 기다리지 않는다는 invariant에서 policy별로
산정하고, statement/lock timeout과 같은 :milliseconds 변수를 재사용하지 않는다.
17.4 JDBC timeout
- query timeout은 statement execution guard다.
- socket timeout은 network read guard지만 commit uncertainty를 만들 수 있다.
- connect/login timeout은 physical connection bootstrap guard다.
- Hikari connection timeout은 pool wait guard다.
하나의 DB_TIMEOUT으로 합치지 않는다. driver property의 단위와 interaction을 typed settings
validation과 real fault test로 검증한다.
17.5 cancellation
caller cancellation이 transaction thread interruption과 정확히 같은 의미라고 가정하지 않는다.
- cancel signal을 받으면 가능한 경우 JDBC statement cancel을 요청한다.
- rollback/connection cleanup 완료 전 성공 또는 재실행 가능 결과를 반환하지 않는다.
- cancel이 commit 단계와 겹치면 outcome은 indeterminate일 수 있다.
- interrupted flag를 보존한다.
- cancellation metric은 query timeout과 분리한다.
18. Write, batch와 persistence context
18.1 단일 aggregate command
- aggregate를 primary transaction에서 읽고 변경한다.
- expected version이 있으면 version predicate를 검증한다.
- domain transition 뒤 mapper가 persistence state를 반영한다.
- outbox가 필요하면 같은 transaction에서 append한다.
- error mapping이 필요한 constraint는 commit 전 explicit flush할 수 있다.
- remote publication은 commit 뒤 별도 relay가 한다.
18.2 insert batching
batching은 entity ID strategy, JDBC driver rewrite, Hibernate ordering과 함께 검증한다.
초기 후보:
hibernate.jdbc.batch_size
hibernate.order_inserts
hibernate.order_updates
잠긴 Hibernate 7.1.8의 실제 BatchSettings에 존재하는 설정만 허용한다. 다른 버전의
문서에서 본 property를 추정해 추가하지 않으며, startup property allowlist test로 exact
version을 검증한다. 설정 존재만으로 batching이 작동한다고 주장하지 않는다. real
PostgreSQL에서 versioned entity를 포함한 statement/round trip 또는 datasource proxy evidence를
확인한다.
UUID application-generated ID는 insert batching과 잘 맞지만 index locality와 page split 비용을 부하 test로 본다. sequence를 도입하면 allocation size와 rollback gap을 정상 동작으로 문서화한다.
18.3 bounded batch
maintenance/backfill/import:
claim/read bounded keys
-> transaction per bounded batch
-> write
-> flush
-> clear
-> persist checkpoint
-> next batch
규칙:
- row count와 byte/time budget을 모두 둔다.
- 전체 dataset을 persistence context에 보관하지 않는다.
- transaction마다 remote I/O를 하지 않는다.
- failure 후 같은 checkpoint에서 안전하게 재시작한다.
- batch size는 configuration upper bound를 넘지 않는다.
- partial progress와 retry semantics를 runbook에 남긴다.
18.4 bulk DML
JPQL/native bulk update/delete는 다음 조건에서만 허용한다.
- aggregate invariant를 우회해도 되는 infrastructure state;
- explicit version/audit predicate와 mutation;
- persistence context clear;
- affected-row count assertion;
- concurrent worker test;
- named operation/query ID.
일반 domain aggregate의 상태 전환을 bulk DML에 숨기지 않는다.
18.5 upsert
PostgreSQL INSERT ... ON CONFLICT는 adapter 내부의 명시적 arbitration operation에만 사용한다.
- conflict target을 named schema constraint와 맞춘다.
- insert와 update의 application 의미를 typed outcome으로 분리한다.
- update predicate에 owner token/version을 포함한다.
- arbitrary entity save를 upsert로 바꾸지 않는다.
- returned row와 affected-row semantics를 real PostgreSQL에서 검증한다.
19. Query, fetch와 N+1
19.1 query catalog
운영 가치가 있는 query에는 stable low-cardinality ID를 부여한다.
work_item.summary.by_owner.v1
outbox.delivery.claim.v2
idempotency.resolve.v2
query catalog는 최소 다음을 기록한다.
| Field | 의미 |
|---|---|
| query catalog ID | metric/trace/plan의 stable low-cardinality key |
| owner port/method | application semantic owner |
| consistency | strong/eventual/bounded |
| max rows/bytes | resource bound |
| sort/order | deterministic order |
| expected index | structural plan expectation |
| statement budget | N+1 포함 최대 count |
| timeout policy | named policy |
| sensitive fields | log/trace redaction |
raw SQL text나 parameter를 metric tag로 쓰지 않는다.
이 ID는 요청별 query/operation instance나 reconciliation OperationId가 아니다.
19.2 projection 우선
list/search/read-model은 필요한 column만 application projection으로 읽는다.
- JPQL constructor/interface projection은 단순한 provider-neutral query에 사용한다.
- native/JDBC projection은 PostgreSQL-specific operator, CTE, window, keyset, claim이 필요한 경우 사용한다.
- entity 전체를 읽은 뒤 web DTO로 대량 변환하는 것을 기본으로 하지 않는다.
- projection constructor와 alias drift를 compile/integration test한다.
19.3 fetch plan
aggregate load에는 use case별 explicit fetch plan을 둔다.
- entity graph;
- fetch join;
- batch fetch;
- secondary bounded query.
하나의 global eager mapping으로 해결하지 않는다. collection fetch join과 paging의 조합은 row multiplication/메모리 paging 위험이 있으므로 사용하지 않거나 two-step key query로 분리한다.
19.4 N+1 budget
대표 use case test는 result correctness와 함께 statement count upper bound를 검증한다.
예:
summary page 50 rows:
expected <= 2 statements
aggregate detail:
expected <= 3 statements
정확한 count는 query design에 따라 다르지만 row 수에 비례해 증가하면 실패해야 한다. Hibernate statistics 또는 datasource instrumentation은 test profile에서만 상세 정보를 수집하고 production에서는 bounded recorder를 사용한다.
19.5 count query
total count는 비용이 있으므로 API가 정말 요구할 때만 실행한다.
Slice/cursor는limit + 1로hasNext를 계산한다.- Page total이 필요하면 목적별 count query와 index를 설계한다.
- collection join이 있는 auto-generated count query를 신뢰하기 전에 plan/result를 검증한다.
- approximate count는 정확한 total과 다른 typed contract로 분리한다.
19.6 dynamic query
허용 filter/sort catalog를 application enum/value로 고정한다.
- empty predicate 의미를 정의한다.
- optional filter 조합 수와 plan을 검증한다.
- string concatenated SQL을 만들지 않는다.
- native identifier가 필요하면 allowlist에서만 선택한다.
- generic
Specification을 application port로 노출하지 않는다.
19.7 query timeout과 slow query
query ID별 timeout policy를 사용한다. slow query log는 다음을 지킨다.
- SQL parameter/PII 미기록;
- normalized query ID;
- elapsed time, row count, route, outcome;
- sampling/rate limit;
- trace correlation;
- stack trace는 반복 rate limit.
PostgreSQL pg_stat_statements를 운영 query aggregate 근거로 사용할 수 있지만 extension 설치와
data retention은 deployment responsibility다. application metric과 database view를 query
ID/normalized shape로 연결한다.
20. Pagination과 large read
20.1 inbound bound
inbound validation과 application value object가 다음을 강제한다.
- positive limit;
- per-query maximum;
- allowlisted sort;
- stable tie-breaker;
- cursor maximum length;
- malformed/expired/version-unknown cursor rejection.
adapter가 음수/과대 page를 임의 default로 바꿔 성공시키지 않는다.
20.2 offset pagination
offset은 다음에만 사용한다.
- shallow, bounded admin/list page;
- total page UX가 실제 요구;
- maximum offset이 명시됨;
- stable order와 index가 있음.
deep offset export/scan에는 사용하지 않는다.
20.3 keyset cursor
keyset 기본 shape:
ORDER BY sort_key DESC, id DESC
WHERE (sort_key, id) < (:last_sort_key, :last_id)
LIMIT :limit_plus_one
cursor는 versioned opaque envelope로 만든다.
version
queryShapeId
sortKey
tieBreaker
filterFingerprint
issuedAt/optional expiry
integrity MAC when client-visible tampering matters
cursor에 PII를 plaintext로 넣지 않는다. filter/sort가 바뀐 cursor를 재사용하면
CURSOR_MISMATCH로 거절한다.
20.4 snapshot 의미
여러 page request 사이에는 일반적으로 새 write가 들어올 수 있다. keyset은 duplicate/skip을 줄이지만 전체 snapshot을 보장하지 않는다.
정확한 snapshot이 필요하면:
- bounded single transaction;
- materialized export job/snapshot table;
- version/as-of predicate;
- 별도 analytical store
중 하나를 선택한다. web request에 long-running open transaction을 유지하는 것을 기본으로 하지 않는다.
20.5 streaming
JPA stream은 transaction과 connection을 stream close까지 보유한다. 따라서:
- application port에 raw
Stream<Entity>를 노출하지 않는다. - try-with-resources close ownership을 adapter가 보장한다.
- row/time/byte limit를 둔다.
- HTTP client 속도에 DB connection lifetime을 직접 묶지 않는다.
- large export는 checkpointed job이 DB batch를 읽고 file/object storage에 publish한다.
21. Primary/replica와 read consistency
21.1 consistency vocabulary
public sealed interface ReadConsistency {
record Strong() implements ReadConsistency {}
record ReadYourWrites(SessionWriteMarker marker) implements ReadConsistency {}
record BoundedStaleness(Duration maximumLag) implements ReadConsistency {}
record Eventual() implements ReadConsistency {}
}
exact API shape는 구현 계획에서 조정할 수 있지만 의미는 다음과 같이 고정한다.
| Consistency | v1 route | 보장 |
|---|---|---|
STRONG |
primary | 현재 writable authority에서 각 statement 시작 시점의 committed snapshot |
READ_YOUR_WRITES |
primary | 같은 authority/timeline 안에서 marker까지 포함한 read |
BOUNDED_STALENESS(maxLag) |
endpoint-bound lag-qualified replica, 실패 시 명시 policy | 실제 query backend에 결속된 보수적 관측 lag가 bound 이내 |
EVENTUAL |
role-qualified replica | 최신성 bound 없음 |
replica read가 linearizable하다고 주장하지 않는다. primary read도 여러 statement 사이 repeatable snapshot을 뜻하지 않는다.
21.2 route 결정 시점
route는 transaction/connection acquisition 전에 결정한다.
resolve policy + consistency
-> bind route context
-> begin transaction
-> datasource router selects pool
-> connection acquired
transaction이 시작된 뒤 route를 바꾸지 않는다. nested REQUIRED query는 outer primary
transaction 안에서 replica로 downgrade되지 않는다.
21.3 nested rule
- outer write transaction 안의 모든 read는 primary다.
- outer strong read 안의 nested eventual request도 primary다.
- outer replica transaction 안에서 write를 시도하면 fail-fast/DB read-only failure다.
REQUIRES_NEW로 route를 바꾸는 것은 allowlisted policy에서만 가능하고 pool capacity를 포함해 검증한다.- async thread에 route context를 암묵적으로 전파하지 않는다.
Spring transaction과 JDBC는 thread-bound이므로 transaction callback 안에서 async/fork를 금지한다. virtual thread 하나가 transaction lifetime 동안 같은 logical execution을 유지한다.
21.4 lag qualification
bounded-staleness route는 provider가 다음을 제공할 때만 활성화한다.
- monotonic 또는 conservative lag observation;
- observation timestamp와 TTL;
- replica replay state;
- stale/unknown 상태;
- failover role detection.
qualification 결과는 일반 boolean이 아니라 다음 internal value와 동등해야 한다.
QualifiedReplicaRoute(
endpointId,
poolGeneration,
roleEpoch,
observedAt,
observedLagUpperBound,
observationErrorMargin,
expiresAt,
evidence)
replica pool generation 하나는 qualification 대상인 한 physical/provider logical endpoint에 고정한다. borrowed connection의 backend identity와 read-only role이 qualification의 endpoint/role epoch와 같은지 첫 business query 전에 확인한다. reconnect, DNS target change, promotion/failover, pool generation 교체가 발생하면 기존 qualification을 즉시 폐기한다. 여러 standby를 숨긴 load-balanced endpoint가 이 결속을 제공하지 못하면 그 pool은 bounded-staleness에 사용할 수 없다.
query 직전 eligibility는 monotonic time으로 다음을 계산한다.
effectiveLagUpperBound =
observedLagUpperBound
+ (monotonicNow - observedAt)
+ observationErrorMargin
eligible iff
monotonicNow <= expiresAt
and effectiveLagUpperBound <= requestedMaximumLag
expiresAt <=
observedAt
+ requestedMaximumLag
- observedLagUpperBound
- observationErrorMargin
음수/0 window는 즉시 ineligible이다. provider가 준 TTL을 그대로 신뢰하지 않고 위 식과 provider TTL 중 더 이른 시각을 사용한다.
v1 BOUNDED_STALENESS query는 statement budget 1인 fully materialized projection으로 제한한다.
N+1, lazy load, data+count 두 statement, streaming을 허용하지 않는다. 따라서 qualification은
borrowed backend 확인 뒤 첫 business statement 직전에 한 번 검증하고 result를 모두
materialize한 뒤 connection을 반환한다. multi-statement bounded read가 필요하면 같은 backend의
각 statement 전 재qualification 또는 pinned snapshot semantics를 별도 card로 설계한다.
provider가 replay timestamp/LSN을 요청 시각과 보수적으로 비교할 time-lag oracle을 제공하지
못하면 v1은 EVENTUAL만 활성화하고 BOUNDED_STALENESS descriptor를 등록하지 않는다. lag
unknown은 bound satisfied가 아니다. fallback policy:
FAIL_CLOSED
FALLBACK_PRIMARY
RETURN_STALE_UNAVAILABLE
를 query policy별로 고정한다. fallback primary는 metric과 trace event를 남긴다. qualification 실패의 primary fallback은 transaction 시작 전에만 가능하다. mid-query disconnect 또는 일부 결과 노출 뒤에는 §16.2의 replay 제한을 적용한다.
21.5 read-your-writes
v1은 같은 request/session의 RYW를 primary route와 SessionWriteMarker(authorityEpoch, operationId/sourceRevision)로 제공한다. 단순히 primary URL을 선택했다는 이유만으로 failover
뒤 RYW를 주장하지 않는다. asynchronous replication의 RPO window에서 acknowledged write가 새
primary에 없을 수 있으므로 authority/timeline이 바뀌면 marker를 operation ledger로 reconcile해
확인하거나 CONSISTENCY_UNAVAILABLE을 반환한다.
read consistency와 durability는 별도 descriptor다. RYW/STRONG label이 provider의 synchronous commit, zero-RPO 또는 failover durability를 암시하지 않는다. WAL LSN token을 client에 전달하고 replica replay를 기다리는 최적화는 다음을 별도 검증한 뒤에만 도입한다.
- token integrity와 topology binding;
- failover timeline;
- wait timeout;
- privacy;
- replica replay API;
- primary fallback.
21.6 health와 failover
replica가 optional인 strong-only deployment에서는 replica 장애가 application readiness를 내리지 않는다. replica-required query profile이면 readiness descriptor에 degraded/unavailable을 반영한다.
primary endpoint가 read-only standby로 바뀌면 write readiness가 실패해야 한다. driver multi-host failover 뒤에도 role probe를 다시 수행한다.
multi-replica qualification, 다른 backend가 선택되는 load-balanced endpoint, reconnect, promotion 전후 marker, result를 일부 소비한 뒤 disconnect를 real topology/fault test에 포함한다.
22. Same-store reliability capability
22.1 공통 원칙
idempotency, outbox, inbox는 단순한 table helper가 아니다. 각자 semantic port, state machine, owner token, retention, reconciliation, metric, runbook을 가진 capability card다.
공통 invariant:
- scope/key는 canonical versioned encoding;
- request/message/event intent hash가 있다.
- claim은 owner token과 finite lease를 반환한다.
- renew/complete/release는 current owner+attempt+operation ID+state revision+status를 CAS한다.
- affected row count를 assert한다.
- database clock으로 lease를 비교한다.
- payload size/schema/version upper bound가 있다.
- reaper가 live owner를 삭제하지 않는다.
- idempotency/inbox와 polling delivery의 terminal delete는 business occurred time이 아니라 각 state machine의 terminal time을 기준으로 한다.
- unknown outcome은 같은 key로 reconcile한다.
lease 비교는 lock wait 전에 고정되는 transaction_timestamp()/statement_timestamp()가 아니라
row lock을 얻은 뒤 mutation CTE에서 한 번 평가한 clock_timestamp()의 db_now를
predicate와 새 lease_until에 함께 사용한다. audit/event created_at처럼 한 transaction의
일관된 기록 시각은 transaction_timestamp()를 사용할 수 있으나 lease authority와 섞지
않는다. CDC outbox에는 terminal row/time이 없으므로 이 terminal-time reaper invariant를
적용하지 않는다. CDC의 retention_bucket은 trusted database insertion time만으로 정하고
occurred_at은 business intent/audit 값일 뿐 retention authority가 아니다. CDC cleanup은
§22.3의 checkpoint/high-watermark, replay retention, incident/legal hold, snapshot 조건을
모두 충족해야 한다.
22.2 owner-safe JPA idempotency V2
canonical scope:
tenant?
principal/client?
operation
idempotencyKeyDigest
raw client key를 table/index/log/metric에 저장하지 않는다. request fingerprint에는 method/path 같은 transport 문자열이 아니라 canonical application intent를 포함한다.
저엔트로피 key에 단순 hash만 적용하면 database 유출 뒤 사전 대입이 가능하다. scope에는
versioned HMAC digest를 기본으로 하고 key_digest_version을 저장한다. 새 write는 active key
version, read/replay는 제한된 prior-version window만 허용하며 rotation 완료 뒤 old version을
contract한다. key 자체가 검증된 충분한 entropy를 가진다고 주장하려면 입력 계약과 test
evidence가 필요하다.
권장 schema:
idempotency_record
scope_hash primary/unique key component
key_digest_version
operation_code
request_fingerprint
state CLAIMED | EXECUTING | COMPLETED | FAILED_RETRYABLE | ABANDONED
state_revision
owner_token
lease_until
attempt
claim_operation_id
last_transition_operation_id
last_transition_kind
last_transition_result_digest
reconciliation_evidence_digest
response_schema
response_inline
response_digest
replay_until
created_at
updated_at
completed_at
expires_at
JPA와 Redis provider는 application-core의 동일한 Idempotency V2 state/result contract를
구현한다. provider가 다르다고 claim/execution vocabulary를 축약하지 않는다. claim algorithm은
single statement UPSERT 또는 lock/CAS로 최소 다음 결과를 구분한다.
ACQUIRED(ownerToken, attempt, leaseUntil)
REPLAYED_ACQUIRE(ownerToken, attempt, leaseUntil)
TAKEN_OVER_CLAIMED(ownerToken, attempt, leaseUntil)
COMPLETED_REPLAY(response, replayUntil)
IN_PROGRESS(retryAfter)
RECOVERY_REQUIRED(currentAttempt)
FINGERPRINT_MISMATCH
OWNER_OPERATION_CONFLICT
INDETERMINATE(operationId)
UNAVAILABLE
state machine:
CLAIMED -> EXECUTING -> COMPLETED
| |-----> FAILED_RETRYABLE
| \-----> ABANDONED
\-> release before execution
expired CLAIMED -> takeover CLAIMED with attempt+1
expired EXECUTING -> ABANDONED/RECOVERY_REQUIRED, no blind takeover
ABANDONED -> verified committed reconciliation
-> verified no-effect reopen
markExecutionStarted, renew, complete, markFailed, releaseBeforeExecution,
inspect, reconcileCommitted, reconcileNoEffectAndReopen의 typed outcomes와 replay rule도
Redis §24의 application contract를 그대로 사용한다. 모든 mutation은 다음 tuple을
검증하고 state_revision을 증가시킨다.
update idempotency_record
set state = 'COMPLETED',
state_revision = state_revision + 1,
last_transition_operation_id = :transition_operation_id,
...
where scope_hash = :scope
and state = 'EXECUTING'
and owner_token = :owner
and attempt = :attempt
and state_revision = :expected_revision
and claim_operation_id = :claim_operation_id
exact column 사용은 transition별로 다듬되 scope + current state + owner + attempt + operation ID + state revision보다 약한 CAS는 허용하지 않는다. 같은 transition operation ID와
result digest의 duplicate는 prior result를 replay하고 다른 digest는 conflict다. affected
row가 0이면 성공으로 간주하지 않는다. read-back/inspect로 owner mismatch, state revision
conflict, expired takeover, already completed, effect unknown을 분류한다.
SAME_STORE_TRANSACTIONAL
inline response가 bounded한 command의 R2 기본 choreography는 business write와 idempotency transition을 같은 primary PostgreSQL transaction에 넣는다.
transaction {
claim row INSERT/SELECT FOR UPDATE
verify fingerprint/state/owner/attempt/revision
CLAIMED -> EXECUTING
business write
outbox append
idempotency complete
}
pre-claim을 admission 목적으로 별도 transaction에서 commit한 profile도 business mutation의
첫 단계에서 claim row를 bounded FOR UPDATE로 잠그고 owner/status/lease/attempt/revision을
다시 검증한 뒤 lease를 갱신한다. 이 row lock은 business write와 completion commit까지
유지한다. A가 lock을 가진 동안 lease 시각이 지나도 B의 takeover update는 진행할 수 없으며,
A가 rollback하면 business write와 completion이 함께 사라진다. B가 먼저 takeover했다면 A는
business write 전에 owner CAS에서 실패한다. completion affected-row 0은 callback failure로
전파해 전체 business transaction을 rollback한다.
lock을 잡은 채 remote I/O, user callback 대기, unbounded work를 하지 않는다. row lock 없이
pre-claim owner를 읽기만 한 뒤 business write를 시작하는 choreography는 금지한다.
lease expires while A holds the business transaction and B attempts takeover barrier test는
B가 동시에 business mutation을 실행하지 못하고 business row가 정확히 한 번만 바뀌는지
검증한다.
SAME_STORE_TRANSACTIONAL R2는 bounded inline response만 광고한다. DB transaction 안에서
object storage put/get/complete를 호출하지 않는다. 큰 response reference는
PENDING_RESPONSE -> staged upload -> finalize/reconcile와 orphan cleanup을 가진 별도
cross-store response card가 설계·검증되기 전에는 이 guarantee에서 제외한다. 현행 V1
response_ref compatibility read가 필요해도 이를 V2 atomic guarantee로 표시하지 않으며
object-store 미활성 상태의 reference는 silent miss가 아니라 profile incompatibility다.
22.3 immutable outbox event와 delivery V2
권장 schema:
outbox_event_identity_v2 unpartitioned uniqueness guard
tenant?
event_id
aggregate_type
aggregate_id
aggregate_version
event_ordinal
retention_bucket
created_at
outbox_publication_control_v2 one row for PRIMARY outbox scope
scope_id
active_epoch
active_authority LEGACY_POLLING | POLLING_V2 | CDC
state PREPARING | ACTIVE | DRAINING
revision
updated_at
outbox_publication_cutover_v2 immutable authority sentinel/audit
scope_id
active_epoch
previous_epoch
transition_kind GENESIS_FRESH | GENESIS_LEGACY | CUTOVER
active_authority
legacy_row_count
legacy_pending_count
legacy_digest
schema_manifest_id
external_manifest_id?
activated_at
outbox_event_log_v2 RANGE(retention_bucket)
retention_bucket
event_id
aggregate_type
aggregate_id
aggregate_version
event_ordinal
event_type
event_schema
logical_destination
partition_key?
publication_epoch
dispatch_authority LEGACY_SHADOW | POLLING_V2 | CDC
content_type
correlation_id?
causation_id?
occurred_at
payload
payload_digest
trace_parent?
tenant?
created_at
outbox_delivery_v2
tenant?
retention_bucket
event_id
destination
state PENDING | CLAIMED | PUBLISHED | RETRY_WAIT | DEAD
claim_owner
claim_token
claim_until
attempt
next_attempt_at
last_error_code
published_at
dead_at
version
constraints:
outbox_publication_control_v2의 baselinescope_id=PRIMARYprimary key와 정확히 한 active authority;outbox_publication_cutover_v2(scope_id, active_epoch)primary key와 cutover procedure의 same-scopeprevious_epoch + 1monotonic check;- unpartitioned identity guard의
event_idprimary key; - identity guard의
(aggregate_type, aggregate_id, aggregate_version, event_ordinal)unique; - identity guard의
(event_id, retention_bucket)unique; - range-partitioned event의
(retention_bucket, event_id)primary key와 같은 두 column의 identity guard foreign key; - delivery의
(retention_bucket, event_id, destination)primary key와 event composite foreign key; - claim eligibility/order index;
- payload size/check constraint;
- state별 required field check.
위 key 표기는 기본 tenantMode=NONE profile이다. tenant card를 선택하면 identity/event/
delivery의 tenant_id는 non-null이고 aggregate uniqueness, identity-event FK와
event-delivery PK/FK를 tenant-prefixed composite key로 바꿔 cross-tenant reference를
database가 거절한다. opaque event_id의 global uniqueness는 그대로 유지하고 tenant
composite constraint를 추가한다. publication control/cutover sentinel은 deployment authority이므로
tenant-owned row가 아니며 §25의 별도 role/manifest 경계를 따른다.
PostgreSQL 16 declarative partition table의 PRIMARY KEY/UNIQUE에는 모든 partition key가
포함돼야 한다. 따라서 partitioned outbox_event_log_v2에 event_id 단독 PK나 aggregate tuple
단독 UNIQUE를 선언하지 않는다. 전역 event/aggregate-tuple uniqueness는 같은 transaction에
먼저 쓰는 compact unpartitioned outbox_event_identity_v2가 보장하고, heavy envelope는
time-range partition에서 보존/제거한다. identity guard는 R2 baseline에서 system lifetime
동안 보존하고 용량/backup을 별도로 산정한다. 이를 purge해 uniqueness를 bounded horizon으로
낮추는 profile은 별도 card/evidence 없이는 활성화하지 않는다. guard에는 payload,
correlation/trace나 직접 PII를 두지 않고 opaque aggregate/event identity와 ordering tuple만
둔다. 그 identity 자체가 개인정보가 될 수 있는 fork는 privacy owner, erasure/tombstone
정책과 guarantee downgrade를 별도로 설계한다.
business transaction:
assert active primary read-write transaction
assert transaction manager/resource identity
select PRIMARY publication control FOR SHARE
derive allowed row authority from active control
assert publication epoch and authority mapping
aggregate update
+ immutable outbox_event_identity_v2 insert
+ immutable partitioned outbox_event_log_v2 insert with the same event/bucket
+ active POLLING_V2 only: initial outbox_delivery_v2 insert
COMMIT
OUTBOX_APPEND는 새 transaction을 여는 policy가 아니라 caller transaction에 반드시 참여하는
operation이다. adapter는 Spring Data save()를 호출하기 전에 active primary read-write
transaction과 같은 EntityManager/datasource resource identity를 검증하고 없거나 다른
transaction manager이면 fail-fast한다. repository의 암묵적 transaction으로 event row만
commit되는 경로를 허용하지 않는다. architecture/integration test는 outer transaction 부재,
read-only transaction, 다른 transaction manager를 각각 거절하는지 검증한다.
publication control의 FOR SHARE는 event insert와 business write가 commit/rollback될 때까지
유지한다. 따라서 cutover의 FOR UPDATE와 충돌해 cutover 전 시작된 append가 commit 또는
rollback되기 전에 authority가 바뀌지 않는다. V2 insert trigger/check는 row의
publication_epoch이 잠근 control epoch와 같은지, authority가
LEGACY_POLLING -> LEGACY_SHADOW, POLLING_V2 -> POLLING_V2, CDC -> CDC mapping인지
검증한다. bridge의 LEGACY_POLLING authority에서는 full-intent V2 copy를
LEGACY_SHADOW로만 허용하고 delivery insert를 거절한다.
fresh install은 선택된 target authority로 epoch 1 control과
GENESIS_FRESH(previous_epoch=0, legacy counts=0, empty-set digest, exact schema/external manifest IDs) sentinel을 같은 migration transaction에서 만든다. 여기서 legacy adoption은
LEGACY_ADOPTED origin을 처리하는 outbox-storage stream의 V1__initialize_or_adopt를
뜻한다. 이 path는 LEGACY_POLLING epoch 1 control, 현재 V1 count/digest를 기록한
GENESIS_LEGACY sentinel과 legacy mutation trigger를 같은 transaction에서 만든다. 어느
genesis sentinel도 없거나 control/manifest와 다르면 writer/dispatcher/startup은
fail-closed한다.
ordering authority의 기본은 optimistic aggregate version과 transaction-local event ordinal의
tuple이다. 한 domain transition에서 발생한 event는 application이 deterministic ordinal을
부여한다. event_id, aggregate version, ordinal, occurred-at, payload와 digest는
whole-transaction retry loop에 들어가기 전에 replay context로 고정한다. update command는
expected aggregate version을 pin하고 retry 중 더 새 version을 만나면 event를 새 version으로
조용히 rebase하지 않고 optimistic conflict로 끝낸다. MAX(sequence)+1은 금지한다. aggregate
version을 제공하지 못하는 destination은 descriptor를 UNORDERED로 낮추거나, 별도
per-aggregate atomic counter row 설계와 contention evidence를 가져야 한다.
relay:
short claim transaction:
select eligible delivery FOR UPDATE SKIP LOCKED
set CLAIMED + owner/token/until
COMMIT
outside transaction:
publish message with stable event ID
short completion transaction:
update by event + destination + owner/token + CLAIMED
-> PUBLISHED or RETRY_WAIT/DEAD
COMMIT
broker publish와 DB completion 사이 응답 유실은 duplicate publish를 만들 수 있다. consumer idempotency/inbox가 필요하며 exactly-once라고 표현하지 않는다.
aggregate strict order가 요구되면 order tuple N이 terminal/explicitly skipped되기 전 N+1을
claim하지 않는다. 여기서 순서는 (aggregate_version, event_ordinal) tuple이다. DEAD head를
무시할지 block할지는 destination policy와 operator audit로 결정한다.
polling delivery reaper는 published_at/dead_at을 기준으로 terminal retention을 계산하고
delivery를 먼저 제거한 뒤 payload partition/row를 정리한다. identity guard는 이 reaper가
삭제하지 않는다.
상위 activation SSOT의 outbox.dispatch-mode에 따라 storage/worker shape를 고정한다.
polling:jpa-outbox-storage-v2와jpa-outbox-polling-delivery-v2를 selected로 하고 immutable identity/event와 delivery를 같은 business transaction에 쓰며 polling relay만 활성화한다.cdc:jpa-outbox-storage-v2,jpa-outbox-cdc-retention-v1과 externalmessaging-cdc-dispatch.v1을 selected/R2로 요구한다. immutable identity/event만 쓰며 CDC connector가 consume하고 application polling delivery scheduler/table stream을 만들지 않는다.disabled: event append가 필요한 use case composition을 fail-fast하고 scheduler/table activity가 없다.
polling과 CDC worker가 같은 event를 동시에 publish하지 않도록 mode는 상호 배타적이다. CDC
connector offset/delivery guarantee는 별도 messaging/CDC card의 증거이며, JPA event insert만으로
broker delivery R2를 주장하지 않는다. polling relay와 CDC connector predicate는 각각
outbox_publication_control_v2와 같은 active epoch/authority 및 그 immutable authority
sentinel만 수용하고 LEGACY_SHADOW, stale/future epoch를 거절한다.
CDC profile에서 immutable outbox_event_log_v2는 trusted database insertion time 기준 range
partition을 사용하고 event envelope에 logical destination과 stable partition_key를 저장한다.
application이 고정한 occurred_at은 과거/미래 시각일 수 있으므로 partition routing이나
retention 판단에 사용하지 않는다.
ordered destination은 non-null key가 필수이며 기본 key는 tenant가 활성화되면 tenant와 aggregate
identity, 아니면 aggregate identity에서 deterministic하게 만든다. connector는 이 값을 broker
key로 전달한다. destination 설정이 ordered인데 key mapping을 증명하지 못하면 startup/card
activation을 실패시킨다.
partition router는 identity insert에서 trusted DB time으로 결정하고 반환한 bounded
retention_bucket을 event/delivery에 동일하게 저장한다. application-supplied bucket과 open
partition allowlist 밖 bucket을 거절한다. identity conflict를 만난 reconciliation은 저장된
bucket/digest를 읽지 새 bucket에 다시 넣지 않는다. adjacent partition에 같은 event_id 또는
같은 aggregate version/ordinal을 동시에 넣는 race가 identity guard에서 정확히 한 건만
성공하는지 PostgreSQL 16 concurrency/migration test로 검증한다.
CDC에는 polling delivery의 published_at가 없으므로 terminal-row reaper를 재사용하지 않는다.
closed partition 제거 조건은 모두 충족해야 한다.
- connector가 partition의 source high watermark보다 뒤의 checkpoint를 durable하게 commit했다.
- connector/control-plane이 그 high watermark까지 event를 consume했다는 immutable evidence를 제공한다.
- configured replay retention과 incident hold가 지났다.
- snapshot/recovery/replay가 그 partition을 더 요구하지 않는다.
JPA maintenance code가 connector offset을 추측하지 않는다. bootstrap이 provider-neutral checkpoint evidence contract를 조합하거나 외부 운영 job이 동일 조건을 증명하며, evidence가 없거나 stale하면 cleanup은 fail-closed한다. cleanup delete/partition detach가 CDC change record나 broker tombstone으로 routing되지 않도록 connector predicate를 고정한다. detach/drop은 audit manifest, row/time bound와 restore reference를 남긴다.
CDC card evidence에는 connector outage 동안의 partition growth, checkpoint 정지, restart, snapshot cutover, retention 직전/직후, cleanup record filtering과 polling↔CDC mode 전환 rehearsal가 포함된다. 전환은 write freeze 또는 backlog drain, connector offset/high-watermark 검증, duplicate 방지와 rollback point를 runbook으로 고정한다. 이 cross-card evidence가 없으면 JPA outbox storage는 R2여도 CDC delivery/retention R2를 주장하지 않는다.
22.4 same-store inbox
scope:
consumer_group
handler_name
tenant?
message_id
권장 state:
RECEIVED -> PROCESSING(owner, lease) -> COMPLETED
\------> RETRYABLE / DEAD
DB-writing handler:
transaction {
claim row INSERT/SELECT FOR UPDATE
verify owner/attempt/state revision
apply business write
append outgoing outbox
complete inbox
}
ack broker after commit
commit 응답 유실 시 broker redelivery가 같은 inbox key를 reconcile한다. broker ack가 먼저 나가면 안 된다. handler의 remote side effect는 outbox/workflow로 옮긴다.
inbox execution mode를 혼합하지 않는다.
TRANSACTIONAL_CLAIM: claim/business write/outbox/completion을 한 transaction에 두는 R2 기본 mode다.LEASED_PRECLAIM: broker admission을 위해 claim을 먼저 commit할 수 있지만 business transaction 첫 단계에서 row를FOR UPDATE하고 owner/attempt/revision을 재검증하며 commit까지 lock을 유지한다.
preclaim을 읽기만 하고 business mutation을 수행하거나, completion CAS 실패를 broker ack 뒤 경고로만 처리하는 구현은 금지한다. lease 만료 중 takeover barrier test는 old/new handler가 business row를 동시에 변경하지 못하고 affected row가 정확히 한 번인지 검증한다.
22.5 reaper와 maintenance ownership
- bounded batch;
- owner-safe eligibility predicate;
- primary route;
- finite transaction/lock timeout;
- per-state retention;
- delete affected rows metric;
- dry-run/count diagnostic;
- shutdown 시 새 claim 중단;
- multi-instance efficiency lock이 실패해도 DB predicate가 correctness를 보장.
scheduler adapter가 직접 policy를 숨긴 @Transactional method를 실행하지 않고 application
maintenance command 또는 명시적 infrastructure transaction policy를 호출한다.
22.6 schema evolution 연계
JPA idempotency V1에서 V2로 이동할 때 Redis 심화 문서 §24가 사용하는
application-core owner-safe state/result contract를 exact reuse한다.
expand owner/lease/attempt/operation/state-revision columns nullable
-> bridge V1 read + V2 write
-> backfill/version existing rows
-> switch full state machine and tuple CAS
-> drain old IN_PROGRESS rows
-> enforce NOT NULL/check/unique
-> observe owner mismatch/unknown outcome
-> contract V1 code/column
outbox V1에서 V2는 event/delivery dual-read보다 명시적 bridge가 필요하다. §23.3의 legacy
adoption 뒤 target-only 새 event는 identity guard + partitioned storage에 쓰고, polling mode만
delivery stream에 함께 쓴다. 다만 현재 V3 row에는 V2의 aggregate type/version/ordinal과
destination contract가 없으므로 legacy row를 fabricated ordering 값으로 V2에 backfill하지
않는다. old writer가 만든 V1-only row는 V1 relay로 drain한다. bridge release가 full V2 intent를
가진 새 event를 같은 stable event ID로 V1과 V2에 dual-write할 때만 V2 copy를
LEGACY_SHADOW로 남긴다. 이 copy에는 delivery row를 만들지 않고 CDC connector도 filter한다.
V1/V2 event-ID reconciliation manifest가 legacy-only, matched-shadow, mismatch를 구분하며
mismatch면 cutover를 중단한다. CDC cutover는 external connector watermark/epoch가 준비되기
전 V1 row나 legacy shadow를 production CDC로 route하지 않는다.
현재 V3가 만든 일반 table outbox_event는 PostgreSQL 16에서 in-place declarative partitioned
table로 바꿀 수 없으므로 V2 physical object는 처음부터 별도 이름
outbox_publication_control_v2, outbox_publication_cutover_v2,
outbox_event_identity_v2, outbox_event_log_v2, outbox_delivery_v2를 사용한다.
LEGACY_ADOPTED origin의 outbox-storage stream V1__initialize_or_adopt가 V1 table의
INSERT와 status UPDATE/DELETE trigger를 추가한다. legacy
db/migration/postgresql stream의 additive adoption migration은 fingerprint/marker만
소유하고 이 target object를 만들지 않는다. trigger는 PRIMARY control row를 FOR SHARE로
읽고 authority가 LEGACY_POLLING일 때만 mutation을 허용한다. 이 lock은 old append
transaction이 끝날 때까지 유지되므로 cutover FOR UPDATE가 in-flight old append를
추월하지 못한다. trigger rejection은 old writer의 business transaction 전체를
rollback시키며 event 없이 aggregate만 commit하는 경로를 허용하지 않는다.
bridge window:
DB control = (PRIMARY, epoch=N, LEGACY_POLLING, ACTIVE)
old writer -> outbox_event V1
bridge/new writer with full V2 intent -> V1 + V2 LEGACY_SHADOW with the same event ID
V1 relay only; V2 relay/CDC production route disabled
checkpointed reconciler -> classify legacy-only/matched-shadow/mismatch, digest/row-count compare
cutover:
freeze new outbox append
drain V1 pending/in-flight and stop old writer/relay binary
verify migrated already-published V2 events are immutable LEGACY_SHADOW rows with no delivery
external migration authority begins one DB transaction
lock PRIMARY control row FOR UPDATE and recheck epoch=N/LEGACY_POLLING
recheck V1 pending/in-flight=0 and matched-shadow digest
REVOKE INSERT, UPDATE, DELETE ON outbox_event FROM the exact runtime role
update control to (epoch=N+1, target authority, ACTIVE)
insert immutable outbox_publication_cutover_v2 sentinel for N+1
COMMIT; target writer/dispatcher require the same control+sentinel
start exactly one target dispatcher
resume with a V2-only writer; new rows carry the new epoch/target dispatch authority
contract:
pre-cutover rollback window와 V1 usage 0 확인
drop old outbox_event only in a forward contract migration
keep V2 physical names stable; optional read-only diagnostic compatibility view만 허용
V1/V2 relay가 같은 production destination을 동시에 publish하지 않는다. write freeze를 생략하는 online dual-authority migration은 별도 fenced epoch/ACL 설계와 evidence 없이는 허용하지 않는다. publication epoch은 immutable event row를 update하는 값이 아니라 DB control row의 monotonic fencing 값이다. application activation manifest는 원하는 mode와 DB epoch/authority/sentinel이 일치하는지만 검증하며 authority가 아니다. paused/partitioned old pod가 cutover 후 복귀하면 legacy ACL과 trigger 중 적어도 하나에서 fail-closed하고 그 business transaction도 rollback한다. runtime role이 legacy table owner/superuser여서 revoke/trigger를 우회할 수 있는 배포는 security card와 cutover gate를 통과하지 못한다. append가 재개된 뒤에는 V1이 V2-only event를 표현할 수 없으므로 old-binary rollback을 허용하지 않고 forward recovery만 수행한다. rollback rehearsal은 target append를 다시 시작하기 전 pre-cutover point에서 target backlog 0/격리와 legacy schema compatibility를 확인한다.
23. Flyway와 rolling migration
23.1 schema authority
- production physical schema writer는 Flyway다.
- Hibernate
ddl-auto는 production에서validate또는none만 허용한다. update,create,create-drop은 startup validator가 production profile에서 거절한다.- migration이 external job이어도 application startup은 schema compatibility를 검증한다.
- applied versioned migration은 immutable이다.
- checksum mismatch를 repair로 숨기지 않는다.
application artifact는 acceptedSchemaEpoch[min,max]와 활성 feature별
requiredSchemaRevision manifest를 포함한다. Flyway가 관리하는 infrastructure marker에는
current schema epoch와 feature revision을 둔다. readiness validator는 다음을 서로 다른
결과로 검증한다.
- Flyway history와 applied checksum integrity;
min <= current schema epoch <= max;- 활성 capability의 required feature revision 충족;
- critical table/column/constraint/index shape probe;
- Hibernate mapping validation.
Flyway checksum이나 ddl-auto=validate 하나만으로 N/N-1 compatibility를 주장하지 않는다.
지원 matrix의 각 application artifact/schema epoch 조합과 negative startup 결과를 CI evidence
manifest에 남긴다.
23.2 migration execution mode
| Mode | 용도 | 규칙 |
|---|---|---|
STARTUP |
local/dev, 단일 instance test | explicit lock/wait budget, failure 시 app startup 실패 |
EXTERNAL_JOB |
production 권장 | dedicated credential/job, app는 migrate하지 않고 validate |
production에서 여러 pod가 동시에 migration을 시도하는 방식을 기본으로 하지 않는다. external job이 성공하기 전 new application traffic을 열지 않는다.
모든 production migration은 script metadata/runbook에 finite lock_timeout,
statement_timeout, maximum wall budget, dedicated application_name, transactional 여부를
기록한다. job은 적용 전에 blocking/long-running transaction과 예상 lock conflict를
read-only preflight하고 허용 범위를 넘으면 traffic을 막는 DDL을 시작하지 않는다.
- transactional migration은 transaction 시작 직후
SET LOCAL/set_config(..., true)로 timeout을 적용한다. - nontransactional migration은 dedicated migration connection/session에 timeout을 설정하고 성공/실패 뒤 session reset이 증명되지 않으면 connection을 폐기한다.
- external job의 wall budget은 process/orchestrator deadline으로도 제한한다.
- timeout/failure 뒤 과거 migration을 수정하지 않고 partial object를 진단한 후 forward recovery migration/runbook을 사용한다.
old application DML이 계속되는 동안 일반 ALTER TABLE lock contention, timeout, partial
nontransactional artifact, forward recovery를 production-size PostgreSQL test에서 검증한다.
23.3 optional capability migration stream
한 Flyway history에서 비활성 card의 낮은 version을 건너뛰고 나중에 outOfOrder=false로
적용하는 구조를 쓰지 않는다. core와 schema-bearing optional card는 독립 location/history
stream을 가진다.
| Stream | Location | History table |
|---|---|---|
| legacy adoption, transition only | db/migration/postgresql |
flyway_schema_history |
| core target | db/migration/jpa/core |
flyway_jpa_core_history |
jpa-idempotency-owner-safe-v2 |
db/migration/jpa/idempotency |
flyway_jpa_idempotency_history |
jpa-outbox-storage-v2 |
db/migration/jpa/outbox-storage |
flyway_jpa_outbox_storage_history |
jpa-outbox-polling-delivery-v2 |
db/migration/jpa/outbox-polling |
flyway_jpa_outbox_polling_history |
jpa-inbox-same-store-v1 |
db/migration/jpa/inbox |
flyway_jpa_inbox_history |
jpa-tenant-discriminator-rls |
db/migration/jpa/tenant |
flyway_jpa_tenant_history |
jpa-jdbc-efficiency-coordination |
db/migration/jpa/coordination |
flyway_jpa_coordination_history |
jpa-primary-replica, jpa-outbox-cdc-retention-v1처럼 자기 schema object가 없는 card는 빈
Flyway stream/history table을 만들지 않는다. 각 stream의 version은 그 stream 안에서만 단조
증가하고 outOfOrder=false, baselineOnMigrate=false를 유지한다. optional migration은 core
object의 소유권을 임의로 이전하지 않으며 card별 schema revision과 core epoch prerequisite를
capability_schema_registry marker에 기록한다. location/history/revision metadata의
machine-readable SSOT도 §31.3의 card registry다.
production external job은 하나의 allowlisted global migration orchestration lock 아래에서 core를 먼저, 활성 optional stream을 dependency 순서로 migrate/validate한다. stream별 Flyway lock만으로 서로 다른 history table의 DDL 충돌을 막을 수 있다고 가정하지 않는다. application readiness는 활성 card의 history/checksum/revision을 요구하고, 이미 설치된 비활성 stream의 revision도 현재 binary가 이해할 수 있는 범위인지 core marker로 확인한다.
현재 단일 history 채택 절차
현재 db/migration/postgresql의 V1/V3/V4/V5와 flyway_schema_history를 새 table에 복사하거나
적용된 script를 수정하지 않는다. 전환은 다음 release sequence로만 수행한다.
- bridge release가 legacy V1/V3/V4/V5 checksum과 idempotency/outbox/
INT_LOCK실제 object fingerprint를 exact allowlist로 검증한다. sample artifact의 V2 같은 registered contribution은 sample owner manifest로 별도 검증하고 target sample history로 채택하며 production core로 흡수하지 않는다. - 같은 legacy stream의 새 additive adoption migration이
capability_schema_registry와 installation originLEGACY_ADOPTED를 만든다. 예상 checksum, column/constraint/index가 하나라도 다르면 중단한다. 이 legacy stream migration은 fingerprint/marker만 소유하고 target outbox control/sentinel/trigger를 만들지 않는다. - 이 bridge release는 legacy history/location을 계속 사용하고 old/new application DML compatibility를 제공한다. 아직 새 stream migration을 실행하지 않는다.
- 모든 old migrator binary를 retire하고 external migration authority를 한 job으로 만든 뒤,
allowlisted
adoptJpaMigrationStreamscommand만 각 target history를 explicit baseline version0으로 초기화한다. 이것은baselineOnMigrate=true가 아니며 preflight fingerprint, global lock, operator approval와 audit manifest 없이는 실행할 수 없다. - 각 target stream의 immutable
V1__initialize_or_adopt는 marker가FRESH면 새 object를 만들고,LEGACY_ADOPTED면 검증된 legacy object를 유지한 채 additive V2 table/column과 dual-read/write bridge를 만든다. outbox storage stream은 기존 V3outbox_event를 유지한 채outbox_publication_control_v2,outbox_publication_cutover_v2,outbox_event_identity_v2,outbox_event_log_v2와 legacy mutation guard를 새로 만들며, 같은 migration transaction에서 origin별 epoch 1 genesis sentinel도 만든다. polling stream을 선택했을 때만outbox_delivery_v2를 만든다. 그 밖의 shape/origin이거나 control/sentinel 한쪽만 생성되면 실패한다. - backfill과 dual-write observation 뒤 target read/write를 switch한다. legacy table/path 사용 0과 old binary 0을 확인한 뒤에만 contract migration을 수행한다.
- legacy history와 V1/V3/V4/V5 resource는 declared rollback window 동안 read-only compatibility evidence로 유지하며 repair/delete하지 않는다.
완전히 빈 database는 origin FRESH로 초기화하고 같은 explicit version-0 stream initialization
후 target core/selected optional V1부터 실행한다. legacy V1/V3/V4/V5를 실행하지 않으므로 fresh
disabled card는 schema/history side effect가 없다. legacy history가 있는데 adoption marker가
없거나, 두 history authority가 동시에 write 가능하거나, non-empty schema인데 origin이 없으면
fail-closed한다.
CI는 실제 현재 V1/V3/V4/V5 schema/history snapshot에서 bridge→explicit baseline→V1 adopt→backfill/switch를 실행한다. legacy checksum 한 글자 변경, constraint/index drift, 중단된 baseline/adoption, N/N-1 binary overlap과 rollback window를 negative/rolling test로 검증한다.
lifecycle 의미:
never installed + disabled
-> optional Flyway instance/history/schema object 없음
disabled -> enabled
-> external job이 해당 stream 전체 checksum 검증/migrate
-> feature revision 충족 뒤 runtime bean/worker 활성
enabled -> disabled
-> worker/bean/새 migration 실행 중단
-> 기존 schema/history는 파괴하지 않고 INSTALLED_INACTIVE
installed inactive -> enabled
-> 기존 checksum/accepted revision 검증
-> forward migration 뒤 활성
disable은 destructive rollback이 아니다. fresh disabled profile의 “schema 없음”과 이전에 설치된 card를 비활성화한 “inert schema 잔존”을 descriptor에서 구분한다. 각 schema-bearing card는 fresh disabled, first enable, disable after use, re-enable, interrupted migration, old/new binary rolling 조합을 real PostgreSQL에서 검증한다.
23.4 expand-contract 순서
1. EXPAND
additive nullable column/table/index/constraint support
2. BRIDGE
old/new application이 함께 읽고 쓸 수 있는 code
3. BACKFILL
checkpointed bounded data conversion
4. SWITCH
canonical read/write를 new representation으로 전환
5. ENFORCE
NOT NULL, validation, uniqueness, FK/check
6. OBSERVE
old path 사용 0, drift/invalid row 0
7. CONTRACT
old column/index/code 제거
N/N-1 compatibility:
- schema S+1은 application N과 N-1을 안전하게 실행한다.
- application N은 migration 전/후 허용된 schema window를 명시한다.
- enum/check/column rename/drop은 한 release에 끝내지 않는다.
- rollback은 old binary가 new writes를 이해할 때만 가능하다.
23.5 large backfill
large backfill을 하나의 Flyway transaction에 넣지 않는다.
- additive schema migration만 먼저 적용;
- 별도 application/ops job;
- stable key cursor와 checkpoint;
- bounded batch/timeout;
- idempotent update predicate;
- throttle와 pause;
- progress/error metric;
- old/new read reconciliation;
- 완료 후 validation/enforcement migration.
backfill code와 schema window를 release artifact에 함께 추적한다.
23.6 index migration
large table의 production index는 CREATE INDEX CONCURRENTLY를 검토한다.
- PostgreSQL transaction block 안에서 실행할 수 없는 migration은 Flyway
executeInTransaction=false로 명시한다. - 하나의 mixed migration에서 transactional/nontransactional statement를 섞지 않는다.
- 실패한 concurrent build의 invalid index를 detect/cleanup하는 runbook이 필요하다.
- 같은 table에서 concurrent index build 제한과 deploy concurrency를 반영한다.
- index 생성 후 query plan과 write amplification을 관측한다.
23.7 constraint enforcement
대형 table의 check/FK는 가능한 경우:
ADD CONSTRAINT ... NOT VALID
-> validate existing rows/background repair
-> VALIDATE CONSTRAINT
를 사용한다. exact lock level과 PostgreSQL version 동작은 migration review에서 확인한다. NOT NULL 전환은 null row 0, writer bridge, lock/time evidence 뒤에 수행한다.
23.8 destructive change
column/table drop, type narrowing, irreversible rewrite:
- explicit data-retention owner;
- backup/restore point;
- old binary 사용 0;
- dual-read/write 종료;
- query/index dependency 검사;
- production-size rehearsal;
- roll-forward plan;
- change window.
schema rollback file을 자동 생성한다고 무손실 rollback을 주장하지 않는다. 기본 복구 전략은 forward fix다.
23.9 migration location
production core와 §23.3에서 selected인 optional stream location을 external migration
composition에서 명시적으로 결합한다. application runtime validator는 같은 stream registry를
읽되 external-job mode에서 migrate하지 않는다. customizer가 default location을 교체하는지
추가하는지 test한다. legacy db/migration/postgresql location은 LEGACY_ADOPTED transition
job/rollback window에서만 읽고 fresh target installation에는 결합하지 않는다. sample migration은 별도 sample-owned stream/history를 사용하며 production
artifact에 들어가지 않는다. sample artifact에는 필요한 production core/selected stream이
빠지지 않게 manifest를 검증한다.
23.10 PostgreSQL version
현재 local baseline은 PostgreSQL 16이다. production qualification은:
- 정확한 supported minor;
- container image digest 또는 managed engine version;
- pgjdbc/Flyway/Hibernate compatibility;
- extension 목록;
- upgrade/failover rehearsal
를 기록한다. floating postgres:16-alpine은 local convenience일 뿐 immutable production
evidence가 아니다.
24. Schema, index와 query-plan 설계
24.1 schema naming
- application-owned explicit schema를 사용한다.
- runtime role의
search_path는 trustedpg_catalog, <application_schema>로 고정하고 startup borrowed connection마다 검증한다."$user"나 untrusted writable schema를 넣지 않는다. PUBLIC과 runtime role의publicschemaCREATE를 revoke하고 runtime role에는 application schemaCREATE/owner 권한을 주지 않는다. 필요하지 않으면 databaseTEMP도 부여하지 않는다.- Hibernate
default_schema를 application schema로 고정하고 native SQL은 schema-qualified object를 사용한다. function/operator 호출도 allowlist/schema qualification을 적용한다. - extension은 migration role이 승인된 trusted schema에 설치하며
public에 암묵적으로 생기는 것을 피한다. - table/column/index/constraint 이름은 lower snake case와 bounded length를 사용한다.
- reserved keyword와 quoted mixed-case identifier를 피한다.
startup/security integration test는 writable-schema injection과 동일 이름 function/table shadowing을 시도해 runtime query가 공격자 object를 resolve하지 않는지 검증한다.
24.2 index는 query/constraint에서 파생
각 index에는 owner query/constraint가 있어야 한다.
query predicate equality columns
-> range/sort columns
-> stable tie-breaker
-> optional INCLUDE projection columns
multicolumn index의 column order를 “selectivity가 높은 순” 하나의 규칙으로 결정하지 않는다. 실제 predicate, ordering, prefix usability를 본다.
24.3 unique
- business uniqueness는 named unique constraint/index로 표현한다.
- nullable unique semantics를 명시한다.
- soft delete가 있으면 active-row partial unique index를 고려한다.
- tenant mode에서는 tenant key가 uniqueness scope에 포함된다.
- case-insensitive uniqueness는 normalization authority와 collation을 명시한다.
application normalize와 DB expression/collation이 다르면 correctness가 깨지므로 canonical normalization test를 둔다.
24.4 foreign key
- aggregate delete/cascade semantics를 domain lifecycle과 맞춘다.
- broad
ON DELETE CASCADE를 편의 기본값으로 쓰지 않는다. - FK source column에 필요한 index를 query/delete workload 기준으로 검토한다.
- cyclic FK와 deferrable constraint는 별도 transaction semantics가 있을 때만 사용한다.
24.5 partial/covering/expression index
PostgreSQL-specific index는 .postgresql migration/query card가 소유한다.
- predicate가 query와 논리적으로 일치하는지;
- INCLUDE가 write/storage 비용보다 이득인지;
- expression normalization이 application과 같은지;
- index-only scan이 visibility map과 workload에서 실제로 가능한지
를 real plan/test로 검증한다.
24.6 representative plan evidence
EXPLAIN test는 전체 textual plan snapshot을 brittle하게 고정하지 않는다. 대표적인
production-scale fixture/statistics에서 구조 invariant를 검증한다.
예:
- forbidden sequential scan on large selective table;
- expected index 또는 bitmap path;
- bounded estimated/actual rows;
- no disk sort above threshold;
- no nested loop explosion;
- query completes within generous CI budget.
EXPLAIN ANALYZE는 실제 query를 실행하므로 destructive/mutating statement에 무심코 사용하지
않는다. fixture scale과 statistics drift를 versioned test data로 관리한다.
24.7 plan change governance
- query ID별 representative plan artifact;
- PostgreSQL/Hibernate/driver upgrade 전 comparison;
- index 추가/제거의 write/read impact;
ANALYZE/statistics requirement;- parameter skew와 generic/custom plan 위험;
- production slow query observation;
- rollback/roll-forward index plan.
plan text가 달라졌다는 이유만으로 실패시키지 않고, 성능/correctness invariant가 깨질 때 실패시킨다.
25. Tenant isolation
25.1 기본 profile
template 기본은 tenantMode=NONE이다. tenant concept를 모든 새 project에 억지로 넣지 않는다.
활성화 시 discriminator를 baseline으로 한다.
25.2 discriminator invariant
- 모든 tenant-owned row에 non-null
tenant_id; - 모든 repository/query predicate에 tenant key;
- primary key 또는 lookup index prefix에 tenant requirement 반영;
- 모든 business unique constraint에 tenant key;
- FK가 다른 tenant row를 참조하지 못하도록 composite key/FK 또는 별도 validation;
- outbox/idempotency/inbox scope에 tenant;
- cursor/filter fingerprint에 tenant context;
- cache/object/message key에도 동일 canonical tenant authority.
tenant ID는 inbound request body에서 신뢰하지 않고 authenticated application context에서 얻는다.
25.3 enforcement
repository developer의 기억만으로 tenant predicate를 보장하지 않는다.
- tenant-aware base collaborator 또는 query builder;
- integration test에서 cross-tenant fixture;
- native query review/check;
- query catalog tenant flag;
- schema constraint;
- optional RLS defense-in-depth.
generic JPA filter가 bulk/native SQL과 maintenance를 모두 자동 보호한다고 주장하지 않는다.
25.4 RLS optional profile
RLS를 사용하면:
- runtime role은 table owner, superuser,
BYPASSRLS가 아니다. - 필요한 table에
ENABLE과FORCE ROW LEVEL SECURITY를 검토한다. - transaction-local tenant setting을 첫 query 전에 parameterized하게 설정한다.
- pool 반환 시 session state가 남지 않는다.
- migration/maintenance role은 별도다.
- missing tenant context는 empty result가 아니라 fail-closed가 되어야 한다.
- partition, FK, unique, background job, backup/restore를 test한다.
- startup role probe와 negative integration test가 owner/superuser/
BYPASSRLS우회를 각각 거절한다.
RLS만으로 encryption, authorization, tenant-aware uniqueness를 대신하지 않는다.
25.5 schema/database per tenant
schema-per-tenant와 database-per-tenant는 tenant 수, migration fan-out, pool explosion, provisioning/backup/legal isolation 요구가 실제로 있을 때 별도 capability로 설계한다. 이번 R2 baseline에 넣지 않는다.
26. Security, secret와 privacy
26.1 transport security
production PostgreSQL connection은 TLS와 hostname/certificate verification을 사용한다.
pgjdbc의 sslmode=verify-full 또는 deployment가 동등하게 검증한 설정을 canonical로 한다.
require만으로 hostname verification까지 되었다고 주장하지 않는다.
trust material:
- secret reference/volume로 주입;
- repository, image, example에 secret 없음;
- permission 최소화;
- rotation 절차;
- expiry metric/alert;
- 새 connection 검증 뒤 old pool drain.
26.2 role 분리
| Role | 권한 |
|---|---|
| migration | DDL과 승인된 migration DML |
| runtime-primary | 필요한 schema DML/sequence execute |
| runtime-replica | read only |
| monitoring | 승인된 statistics/health view |
| break-glass admin | 평시 application에서 미사용, audit |
runtime role에 schema owner/superuser/replication 권한을 주지 않는다. production application은 Flyway migration credential를 상시 보유하지 않는 external-job mode를 우선한다.
26.3 SQL injection과 identifier
- value는 bind parameter;
- sort/table/column/function은 allowlisted enum에서만 선택;
- native query string에 client input concat 금지;
- LIKE pattern escaping 의미를 명시;
- full-text/search extension query도 typed builder 사용;
- migration placeholder에 untrusted runtime input 금지.
26.4 data classification
entity/column/query card에 classification을 둔다.
PUBLIC
INTERNAL
CONFIDENTIAL
RESTRICTED
RESTRICTED data:
- log/trace/query parameter 미기록;
- 최소 projection;
- retention/delete owner;
- backup 포함 암호화;
- access audit;
- lower environment masking/synthetic fixture;
- support dump redaction.
database encryption-at-rest는 application logging/authorization/column exposure 문제를 해결하지 않는다. field-level encryption이 필요하면 query/index/key rotation과 domain ownership을 별도 설계한다.
26.5 error privacy
client:
- stable application error code;
- safe message;
- correlation ID.
server diagnostic:
- query ID;
- SQLState;
- semantic constraint ID;
- route/pool;
- transaction phase;
- retry/outcome;
- sanitized exception class.
SQL text, bind value, JDBC URL credential, raw tenant/principal/idempotency key는 제외한다.
27. Configuration design
27.1 activation SSOT
권장 canonical shape:
ca-skeleton:
capabilities:
persistence:
provider: jpa-postgresql
idempotency:
provider: jdbc
guarantee: same-store-transactional
outbox:
dispatch-mode: polling
inbox:
provider: disabled
lock:
provider: disabled
providers:
jpa-postgresql:
implementation-version: jpa-postgresql-v2
migration-mode: external-job
schema-compatibility: n-and-n-minus-1
tenant-mode: none
read-routing: primary-only
primary: {}
replica: {}
jdbc-coordination:
guarantee: efficiency-only
provider/mode 선택의 유일한 SSOT는 상위 platform 설계의
ca-skeleton.capabilities.*.provider와 outbox.dispatch-mode다. JPA provider subtree는
선택된 provider의 tuning/schema implementation version만 가지며 별도 enabled나 mode로
다시 활성화하지 않는다. V1/V2는 activation 선택이 아니라 descriptor와 rolling schema
compatibility를 위한 implementation version이다. 선택 값과 provider subtree가 불일치하면
startup을 실패시킨다. card별 schema revision을 operator configuration scalar로 복제하지
않는다. §31.3 registry의 selected card, schema-stream, migration location/history,
required core epoch와 feature revision이 유일한 schema-capability SSOT다. compiled runtime
descriptor와 immutable migration evidence는 그 metadata를 그대로 담는다. 따라서 CDC
selection에는 storage revision과 external messaging manifest만 있고 polling delivery
revision은 없으며, polling selection에는 storage와 polling revision이 각각 존재한다.
Spring spring.datasource.*, spring.jpa.*, spring.flyway.*를 자유로운 외부 public contract로
노출하는 대신 typed settings가 canonical env를 검증하고 필요한 framework properties를
composition한다. migration 기간의 legacy env alias는 충돌 시 fail-fast한다.
27.2 typed settings group
최소 그룹:
JpaCapabilitySettings
PrimaryDataSourceSettings
ReplicaDataSourceSettings
PoolSettings
TransactionPolicySettings
PostgreSqlTimeoutSettings
MigrationSettings
QueryGuardSettings
TenantSettings
IdempotencyJpaSettings
OutboxJpaSettings
InboxJpaSettings
JdbcCoordinationSettings
string map으로 arbitrary policy를 받기보다 validated record와 allowlisted policy catalog를 사용한다.
27.3 startup validation
활성 profile에서 다음을 fail-fast한다.
- persistence provider가
jpa-postgresql이 아니거나 capability/provider subtree가 불일치; - primary URL/credential/role/schema 누락;
- production에서
ddl-auto=update/create/create-drop; - OSIV true 또는 canonical setting 누락;
- pool size <= 0 또는 deployment budget 초과;
- invalid/unknown Duration;
- validation timeout >= connection timeout;
- pool
connectionTimeout이 수용 policy의 최소 acquisition budget보다 큼; - policy 최소 remaining budget이 admission + connection acquire + begin + action + completion window를 담지 못함;
- keepalive >= max lifetime;
- transaction/statement/lock/deadline hierarchy 위반;
- replica policy 활성인데 replica datasource/lag probe 없음;
- primary endpoint가 read-only;
- replica endpoint가 writable primary;
- migration external mode인데 checksum/schema epoch/feature revision/object compatibility 중 하나가 불충족;
- selected card별 code descriptor와 registry의 schema stream/core epoch/feature revision, applied migration evidence가 불일치;
- CDC selected인데 polling delivery revision이 광고되거나 external messaging manifest가 없고, polling selected인데 CDC manifest가 광고됨;
- selected outbox mode와 DB publication control의 active epoch/authority, immutable cutover sentinel이 불일치하거나 target cutover 뒤 runtime role에 legacy table mutation 권한이 남음;
- tenant RLS mode인데 runtime role/tenant context 검증 실패;
- duplicate SQLState/constraint mapping;
- deprecated/canonical config 동시 설정;
- sample migration이 production artifact에 포함됨.
27.4 disabled zero-side-effect
현재 application artifact는 JPA primary persistence가 필수다.
capabilities.persistence.provider=disabled는 DB-free composition card와 composition test가
구현되기 전에는 “DB 없이 기동”이 아니라 startup failure다.
read-routing=primary-only, inbox.provider=disabled, lock.provider=disabled,
outbox.dispatch-mode=disabled처럼 optional sub-card가 disabled이면 그 sub-card는:
- 해당 replica/provider datasource/pool을 만들지 않는다.
- 해당 endpoint DNS/connection을 시도하지 않는다.
- scheduler/reaper를 등록하지 않는다.
- disabled card 전용 migration/worker를 실행하지 않는다. never-installed card는 전용
history/schema object도 만들지 않고, previously-installed card는 §23.3의
INSTALLED_INACTIVEschema를 파괴하지 않는다. - disabled card의 repository scan/bean side effect를 만들지 않는다.
- required semantic port가 없으면 composition이 명시적으로 실패하거나 feature use case 자체가 비활성화된다.
미래 DB-free artifact가 추가되면 전체 persistence provider disabled에서 primary datasource/Flyway/entity scan이 모두 zero-side-effect임을 별도 artifact/composition test로 증명한 뒤에만 그 보장을 descriptor에 추가한다.
27.5 environment registry
새 env key는 repository의 env registry와 verifyEnvKeys를 함께 갱신한다.
- canonical key;
- type/unit;
- safe default 또는 required;
- secret 여부;
- environment별 example;
- deprecated alias와 제거 release;
- owning settings class;
- validation rule
를 기록한다. secret의 실제 값은 example/fixture에 넣지 않는다.
28. Bootstrap, readiness와 health
28.1 startup sequence
EXTERNAL_JOB production:
resolve secrets
-> validate typed settings
-> create primary pool
-> probe connectivity + writable role + TLS
-> validate Flyway checksum + schema epoch/range + feature revision + critical objects
-> optional replica pool/probe/lag capability
-> validate transaction/query/failure catalogs
-> expose readiness
-> accept traffic
STARTUP local:
resolve/validate
-> create migration datasource
-> migrate
-> create/validate runtime
-> readiness
migration datasource와 runtime datasource가 같은 pool/credential이어야 한다고 가정하지 않는다.
28.2 liveness
liveness는 database availability에 의존하지 않는다. DB 장애가 pod restart storm을 만들지 않게 process/event-loop 상태만 본다.
28.3 readiness
write-serving readiness:
- primary pool initialized;
- writable primary role;
- required Flyway checksum/schema epoch/feature revision/object compatibility;
- critical settings/catalog validation;
- admission runtime active.
replica:
- optional query면 degraded indicator;
- required query profile이면 해당 route readiness;
- lag unknown/over-bound를 bounded-staleness ready로 보지 않는다.
readiness query는 request pool을 고갈시키지 않고 low-cost/bounded여야 한다.
28.4 startup failure와 retry
orchestrator가 restart/backoff를 소유하는 production profile에서는 application 내부 무한 startup retry를 하지 않는다. finite bootstrap budget 뒤 명확히 실패한다. transient secret/ DNS/database startup ordering이 필요한 local compose는 bounded retry를 별도 profile로 둔다.
28.5 shutdown/quiesce
readiness false
-> 신규 request/maintenance claim 중단
-> in-flight request budget 안에서 drain
-> outbox/inbox/idempotency worker claim 중단
-> owner lease가 끝나거나 safe release
-> pools close
connection pool을 먼저 닫아 active transaction을 indeterminate로 만들지 않는다. drain timeout 초과 시 active transaction/claim 수를 기록하고 강제 종료의 중복 가능성을 runbook에 남긴다.
29. Observability
29.1 metric naming 원칙
기존 repository metric registry를 SSOT로 사용하고 새 metric은 registry와 instrumentation을 같이 추가한다. metric tag는 bounded enum만 사용한다.
기존 registry 이름을 같은 의미의 새 이름으로 복제하지 않는다.
| Metric | 처리 | 안전한 tags |
|---|---|---|
db.query.duration |
기존 이름 재사용 | existing bounded operation, outcome |
hikaricp.connections.acquire |
기존 이름 재사용 | pool, outcome |
hikaricp.connections.usage |
기존 이름 재사용 | pool |
hikaricp.connections.active |
기존 이름 재사용 | pool |
outbox.pending.size |
기존 이름/상태 migration | bounded status |
db.transaction.duration |
신규 registry 후 사용 | policy, outcome |
db.transaction.retry |
신규 registry 후 사용 | policy, reason |
db.transaction.indeterminate |
신규 registry 후 사용 | policy, phase |
db.query.rows |
신규 registry 후 사용 | bounded query catalog/class, route |
db.query.timeout |
신규 registry 후 사용 | bounded query catalog/class, route, cause |
db.optimistic.conflict |
신규 registry 후 사용 | bounded aggregate catalog ID |
db.lock.timeout |
신규 registry 후 사용 | bounded operation catalog ID |
db.replica.fallback |
신규 registry 후 사용 | bounded query catalog ID, reason |
db.replica.lag |
신규 registry 후 사용 | configured replica logical ID |
db.migration.duration |
신규 registry 후 사용 | mode, outcome, bounded schema epoch bucket |
db.idempotency.owner_mismatch |
신규 registry 후 사용 | bounded operation catalog ID |
db.inbox.redelivery |
신규 registry 후 사용 | bounded handler catalog ID |
새 metric/tag/allowed value는 docs/registries/metrics.yaml, instrumentation, cardinality contract,
alert mapping을 같은 change에서 추가한다. alias/deprecation migration 없이
db.operation.duration, db.pool.*, db.outbox.pending 같은 중복 이름을 만들지 않는다.
exact migration version은 계속 증가하므로 metric tag가 아니라 structured event/evidence
artifact에 둔다.
operationCatalogId/queryCatalogId는 startup에 등록된 작은 allowlist이고,
reconciliation용 per-request operationInstanceId/OperationId와 다른 타입과 이름을 사용한다.
instance ID는 metric tag에 절대 넣지 않는다.
금지 tag:
- SQL text;
- table/column/constraint raw name;
- entity ID;
- tenant/user/client ID;
- idempotency key;
- message/event ID;
- operation instance/reconciliation ID;
- JDBC URL/host.
29.2 trace
span 예:
db.transaction
db.query
db.outbox.claim
db.outbox.complete
db.idempotency.claim
db.inbox.handle
db.migration.validate
attribute:
- bounded semantic operation/query catalog ID;
- policy;
- route;
- attempt;
- outcome;
- row-count bucket;
- timeout bucket.
OpenTelemetry의 database semantic convention을 사용하되 parameter와 full statement recording은 privacy 정책으로 제한한다.
29.3 structured log
주요 event:
- startup capability descriptor;
- schema incompatibility;
- role mismatch;
- pool exhaustion;
- commit indeterminate;
- retry exhausted;
- slow query threshold;
- owner mismatch;
- outbox DEAD/head blocked;
- replica lag/fallback;
- migration failure;
- shutdown drain timeout.
동일 장애의 stack trace/log storm을 sampling/rate limit한다. client-visible correlation ID로 server event를 찾을 수 있게 한다.
29.4 alert/SLO
alert 후보:
- pool pending/acquire p95/p99와 timeout;
- transaction/query error ratio;
- commit-indeterminate > 0;
- deadlock/serialization retry 증가;
- outbox oldest pending age/dead count;
- idempotency owner mismatch;
- replica lag over policy;
- schema readiness failure;
- migration duration/failure;
- disk/WAL/connection saturation은 platform DB alert와 연결.
경고 threshold는 load/capacity evidence에서 정한다. template 숫자를 production SLO로 고정하지 않는다.
30. Lifecycle, HA, backup과 disaster recovery
30.1 HA 의미
client가 reconnect했다는 사실은:
- 이전 transaction rollback;
- commit 여부;
- replica 최신성;
- prepared statement/session state 보존
을 뜻하지 않는다. failover 후 새 connection role/schema/timeout/tenant initialization을 다시 검증한다.
30.2 primary failover
검증 scenario:
- idle connection 중 failover;
- statement 실행 중;
- commit request 직전/중/직후;
- pool의 stale connection;
- DNS/endpoint 갱신;
- new primary role probe;
- replica route topology 변경;
- in-flight owner lease/outbox claim.
commit 중 failover는 COMMIT_INDETERMINATE를 만들 수 있어 stable operation reconciliation이
필수다.
새 primary가 writable하다는 사실만으로 이전 authority의 acknowledged write가 존재한다고 추정하지 않는다. provider RPO/durability와 authority timeline을 확인하고 RYW marker 및 operation ledger를 reconcile한다. 조건을 충족하지 못하면 write가 사라지지 않았다는 보장이나 RYW를 광고하지 않고 typed unavailable/indeterminate를 유지한다.
30.3 backup
R2 운영 문서는 다음을 명시한다.
- backup 방식과 schedule;
- RPO/RTO;
- encryption/key ownership;
- retention/legal deletion;
- WAL/PITR 여부;
- schema/migration artifact 보관;
- object-storage response reference 등 외부 payload와의 일관성;
- restore target PostgreSQL version.
backup job 성공만으로 복구 가능성을 주장하지 않는다.
30.4 restore rehearsal
R3 gate:
restore isolated environment
-> role/search_path/extension 확인
-> Flyway validate
-> application N/N-1 compatibility smoke
-> integrity/reconciliation query
-> outbox/idempotency/inbox state 확인
-> representative query plan
-> measured RPO/RTO 기록
outbox terminal state와 external broker delivery, object-storage response reference는 cross-store reconciliation이 필요하다.
30.5 maintenance
- vacuum/analyze/autovacuum visibility;
- long transaction;
- idle-in-transaction;
- table/index bloat;
- unused/duplicate index;
- sequence/ID capacity;
- partition lifecycle가 있다면 attach/detach;
- transaction ID age;
- schema lock wait
를 platform DBA/managed-service observability와 연결한다. application이 DB maintenance engine을 재구현하지 않는다.
30.6 PgBouncer/proxy optional card
도입 시 별도 qualification:
- session/transaction pooling mode;
- prepared statement compatibility;
- SET LOCAL과 session state;
- server reset query;
- primary/replica endpoint;
- TLS 양 구간;
- auth/secret rotation;
- pool multiplication;
- failover;
- metrics/health.
proxy가 있다고 application pool/admission이 불필요한 것은 아니다.
31. Test, CI와 evidence design
31.1 unit test
application:
- repository/query fake로 use case transaction intent;
- policy selection;
- retry eligibility;
- commit-indeterminate reconciliation;
- cursor/filter fingerprint;
- idempotency/outbox/inbox state transition.
JPA leaf:
- mapper round-trip/invariant failure;
- SQLState/constraint mapping duplicate fail-fast;
- exception cause-chain/phase classification;
- timeout calculation/rounding;
- settings validation;
- route context/nested rule;
- claim/complete affected-row handling;
- sensitive log/metric tag guard.
31.2 JPA slice/integration
H2를 PostgreSQL correctness evidence로 사용하지 않는다. 빠른 mapper/repository wiring test에 쓸 수 있어도 다음은 real PostgreSQL에서만 검증한다.
- native SQL;
- UUID/json/time semantics;
- isolation/lock;
- constraint name/SQLState;
SKIP LOCKED;- concurrent index/migration;
- query plan.
31.3 real PostgreSQL task
owner leaf 또는 명시적인 qualification source set에 다음 canonical task를 만든다. 이 목록은 아래 card registry의 사람이 읽기 위한 projection이며 §34.1 표와 exact match해야 한다.
:adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest
:adapter:outbound:persistence-jpa:postgresqlSecurityBaselineIntegrationTest
:adapter:outbound:persistence-jpa:postgresqlMigrationIntegrationTest
:adapter:outbound:persistence-jpa:postgresqlTransactionIntegrationTest
:adapter:outbound:persistence-jpa:postgresqlAggregateIntegrationTest
:adapter:outbound:persistence-jpa:postgresqlQueryIntegrationTest
:adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence
postgresqlConcurrencyTest, postgresqlQueryPlanTest 같은 helper suite를 추가할 수 있지만,
그 suite는 위 canonical task의 dependsOn/test-result input으로 명시적으로 매핑한다. helper
이름 자체를 readiness manifest의 producer로 사용하지 않는다.
card/evidence SSOT는 구현 시 src/config/jpa/readiness-cards.yaml로 만들고 Gradle,
capability descriptor, evidence writer가 함께 읽는다. target registry shape는 다음과 같다.
schema-version: 1
legacy-adoption:
state: transition-only
location: "db/migration/postgresql"
history-table: "flyway_schema_history"
immutable-applied-versions: [1, 3, 4, 5]
allowed-origin: LEGACY_ADOPTED
cards:
jpa-observability-lifecycle:
state: selected
schema-stream: none
prerequisites: []
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest"
required-evidence: [real-postgresql, lifecycle, observability, no-skip]
jpa-security-baseline:
state: selected
schema-stream: none
prerequisites: [jpa-observability-lifecycle]
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlSecurityBaselineIntegrationTest"
support-tasks:
- ":adapter:outbound:persistence-jpa:verifyJpaSqlConstructionSafety"
- ":adapter:outbound:persistence-jpa:verifyJpaSecurityFixtures"
required-evidence: [real-postgresql, tls, roles, namespace, redaction, no-skip]
jpa-flyway-migration:
state: selected
schema-stream: owned
prerequisites: [jpa-observability-lifecycle, jpa-security-baseline]
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlMigrationIntegrationTest"
required-evidence: [real-postgresql, migration, rolling-compatibility, no-skip]
migration:
location: "db/migration/jpa/core"
history-table: "flyway_jpa_core_history"
required-core-epoch: 0
feature-revision: 1
lifecycle-evidence: [fresh, legacy-adoption, interrupted-recovery]
jpa-transaction-runtime:
state: selected
schema-stream: none
prerequisites: [jpa-observability-lifecycle, jpa-security-baseline]
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlTransactionIntegrationTest"
required-evidence: [real-postgresql, concurrency, fault, no-skip]
jpa-aggregate-store:
state: selected
schema-stream: contributes-to-core
prerequisites: [jpa-transaction-runtime, jpa-flyway-migration]
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlAggregateIntegrationTest"
required-evidence: [real-postgresql, mapping, optimistic-conflict, no-skip]
jpa-query-model:
state: selected
schema-stream: contributes-to-core
prerequisites: [jpa-transaction-runtime, jpa-flyway-migration]
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlQueryIntegrationTest"
required-evidence: [real-postgresql, query-contract, query-plan, no-skip]
jpa-primary-foundation:
state: selected
schema-stream: none
prerequisites:
- jpa-observability-lifecycle
- jpa-security-baseline
- jpa-flyway-migration
- jpa-transaction-runtime
- jpa-aggregate-store
- jpa-query-model
readiness-task: ":adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence"
support-tasks:
- ":adapter:outbound:persistence-jpa:test"
- ":app-bootstrap:test"
- ":verifyCleanArchitectureDependencies"
- ":verifyEnvKeys"
- ":verifyPublicPathSnapshot"
required-evidence: [architecture, configuration, base-card-manifests, no-skip]
jpa-idempotency-owner-safe-v2:
state: not-implemented
schema-stream: owned
prerequisites: [jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle]
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlIdempotencyIntegrationTest"
required-evidence: [real-postgresql, concurrency, fault, migration, stream-lifecycle, no-skip]
migration:
location: "db/migration/jpa/idempotency"
history-table: "flyway_jpa_idempotency_history"
required-core-epoch: 1
feature-revision: 2
lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery]
jpa-outbox-storage-v2:
state: not-implemented
schema-stream: owned
prerequisites: [jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle]
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlOutboxStorageIntegrationTest"
dispatch-modes: [polling, cdc]
required-evidence: [real-postgresql, same-resource, partition-uniqueness, publication-authority-fence, legacy-writer-rejection, migration, stream-lifecycle, no-skip]
migration:
location: "db/migration/jpa/outbox-storage"
history-table: "flyway_jpa_outbox_storage_history"
required-core-epoch: 1
feature-revision: 2
lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery]
jpa-outbox-polling-delivery-v2:
state: not-implemented
schema-stream: owned
prerequisites:
- jpa-outbox-storage-v2
- jpa-transaction-runtime
- jpa-flyway-migration
- jpa-observability-lifecycle
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlOutboxPollingIntegrationTest"
dispatch-modes: [polling]
required-evidence: [real-postgresql, concurrency, publish-fault, ordering, migration, stream-lifecycle, no-skip]
migration:
location: "db/migration/jpa/outbox-polling"
history-table: "flyway_jpa_outbox_polling_history"
required-core-epoch: 1
feature-revision: 2
lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery]
jpa-outbox-cdc-retention-v1:
state: not-implemented
schema-stream: none
prerequisites: [jpa-outbox-storage-v2, jpa-observability-lifecycle]
external-prerequisites:
- registry: "src/config/messaging/readiness-cards.yaml"
card-id: "messaging-cdc-dispatch.v1"
minimum-readiness: R2
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlOutboxCdcCleanupIntegrationTest"
dispatch-modes: [cdc]
required-evidence:
- real-postgresql
- connector-checkpoint-high-watermark
- outage-restart
- replay-retention
- delete-tombstone-filtering
- mode-transition
- no-skip
jpa-inbox-same-store-v1:
state: not-implemented
schema-stream: owned
prerequisites: [jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle]
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlInboxIntegrationTest"
required-evidence: [real-postgresql, redelivery, concurrency, migration, stream-lifecycle, no-skip]
migration:
location: "db/migration/jpa/inbox"
history-table: "flyway_jpa_inbox_history"
required-core-epoch: 1
feature-revision: 1
lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery]
jpa-primary-replica:
state: not-implemented
schema-stream: none
prerequisites:
- jpa-transaction-runtime
- jpa-query-model
- jpa-flyway-migration
- jpa-observability-lifecycle
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlReplicaIntegrationTest"
required-evidence: [real-postgresql, replica, lag, failover, no-skip]
jpa-tenant-discriminator-rls:
state: not-implemented
schema-stream: owned
prerequisites: [jpa-primary-foundation]
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlTenantRlsIntegrationTest"
required-evidence: [real-postgresql, tenant-isolation, rls, migration, stream-lifecycle, no-skip]
migration:
location: "db/migration/jpa/tenant"
history-table: "flyway_jpa_tenant_history"
required-core-epoch: 1
feature-revision: 1
lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery]
jpa-jdbc-efficiency-coordination:
state: not-implemented
schema-stream: owned
prerequisites: [jpa-transaction-runtime, jpa-flyway-migration, jpa-observability-lifecycle]
readiness-task: ":adapter:outbound:persistence-jpa:postgresqlJdbcCoordinationIntegrationTest"
required-evidence: [real-postgresql, contention, owner-safety, migration, stream-lifecycle, no-skip]
migration:
location: "db/migration/jpa/coordination"
history-table: "flyway_jpa_coordination_history"
required-core-epoch: 1
feature-revision: 2
lifecycle-evidence: [fresh-disabled, first-enable, disable, re-enable, interrupted-recovery]
state는 selected | implemented-candidate | not-implemented만 허용한다. registry loader는
unknown/missing ID, alias, duplicate task, cycle, selected card의 non-selected prerequisite,
존재하지 않는 selected readiness/support task를 fail-closed한다. schema-bearing card의
migration 누락, duplicate location/history table, invalid core epoch/revision, lifecycle
evidence 누락도 실패한다. Flyway orchestrator/startup validator/evidence writer는 같은
migration node를 읽는다. schema-stream=owned는 migration node가 필수,
none은 금지, contributes-to-core는 jpa-flyway-migration core manifest에 exact
resource/checksum contribution이 필수다. namespaced external prerequisite는 지정 registry의 exact card ID,
minimum readiness와 immutable manifest ID를 composition/release 시 검증하며 내부 card로
조용히 대체하지 않는다. 이것은 bootstrap/release evidence edge이지 JPA leaf에서 messaging
leaf로 향하는 Gradle/project dependency가 아니다. readiness task는 선언된 support task와
required evidence producer를 dependsOn으로 연결한다. registry key, §34 heading, descriptor
cardReadiness key, test card-<id> tag와 evidence cardId는 byte-for-byte 같아야 한다.
release에서 CLI property로 selection/DAG를 덮어쓰지 않는다. §4.2와 §34 표는 이 registry의
사람이 읽기 위한 projection이며 독립 SSOT가 아니다.
outbox selection compiler는 dispatch-mode=disabled면 세 outbox card를 모두 non-selected,
polling이면 storage+polling만 selected, cdc면 storage+CDC만 selected로 만든다. CDC는
namespaced messaging prerequisite가 없거나 R2 미만이면 fail-closed한다. polling과 CDC card를
동시에 selected로 만든 registry/release assertion은 거절한다.
여기서 selected는 target release가 반드시 검증해야 한다는 뜻이지 R2를 미리 부여한다는
뜻이 아니다. registry/task가 아직 없는 현재 repository readiness는 §3의 R0/R1 판정을
유지한다. implementation이 registry를 추가한 순간 selected task가 없거나 실패하면 release가
fail-closed해야 한다.
CI R2 lane에서는 Docker/Testcontainers/service가 없으면 skip하지 않고 실패한다. developer local focused test는 환경 사유로 별도 task를 선택할 수 있지만 결과를 R2로 오인하지 않는다.
31.4 transaction/concurrency matrix
최소 scenario:
- two writers optimistic conflict;
- unique check race;
- foreign/check/not-null constraint mapping;
READ_COMMITTEDnon-repeatable observation documented;REPEATABLE_READsnapshot;SERIALIZABLE40001whole retry;- deterministic deadlock and
40P01; - pessimistic lock timeout;
- statement timeout, explicit cancel, idle transaction timeout,
40003unknown 분류; - connection acquire exhaustion;
CallBudget/Hikari/Spring timeout999/1000/1001msboundary;- Hikari가
connectionTimeout가까이 기다린 뒤에도 first statement/total budget이 overshoot되지 않는 real-DB test; - nested REQUIRED route/timeout;
- 모든 outer가 inner를 요구하는 bounded
REQUIRES_NEWreserve/deadlock barrier; - rollback-only propagation;
- flush failure와 confirmed rollback;
- commit request 뒤 ACK 유실;
- legacy root write의 operation-ID 없는 indeterminate가 자동 replay되지 않고
INVOCATION_UNCORRELATED로 관측됨; - commit ACK 뒤
afterCommit/cleanup failure; afterCompletion(ROLLED_BACK/COMMITTED/UNKNOWN)outcome.
31.5 idempotency/outbox/inbox concurrency
- same scope concurrent claim one owner;
- expired
CLAIMEDtakeover와 expiredEXECUTINGrecovery-required; - stale owner renew/complete/release no mutation;
- owner/attempt/operation/state-revision conflict;
- lease가 A business transaction 안에서 만료되는 동안 B takeover barrier와 business row exactly-once mutation;
- request mismatch;
- bounded inline response compatibility; object-reference profile은 별도 card 전까지 R2 제외;
- crash before/after business commit;
- outbox multi-worker disjoint claim;
- stale claim reaper;
- late old owner completion;
- same aggregate concurrent writer와
(aggregate_version, event_ordinal)ordering; - adjacent range partition에 같은 event ID 또는 aggregate tuple을 넣는 race와 identity guard global uniqueness;
- 한 transition의 multiple event ID/ordinal/digest가 retry 간 stable;
- freeze 직전/도중 V1 append를 멈춘 barrier에서 legacy trigger의
FOR SHARE때문에 cutoverFOR UPDATE가 추월하지 못하고, V1 commit 뒤 pending 재검증으로 cutover가 중단됨; - cutover 후 paused/reconnected old writer의 V1 insert/status mutation이 ACL/trigger에서 거절되고 aggregate business transaction 전체가 rollback됨;
- V2 writer의 stale/future publication epoch 또는 authority mismatch가 business mutation과 함께 rollback되고, control update/legacy revoke/sentinel insert 중간 실패도 원자적으로 이전 authority를 보존함;
- fresh polling/CDC initialization이 epoch 1 control과 matching
GENESIS_FRESHsentinel을 같은 transaction에 만들고, control-only/sentinel-only/epoch·authority·manifest mismatch 상태에서 startup과 dispatcher를 거절함; - polling/CDC mutual exclusion과 N/N+1 claim;
- CDC ordered destination의 stable
partition_key/broker key mapping; - CDC closed range partition의 connector checkpoint/high-watermark/replay-retention cleanup;
- connector outage/restart/snapshot cutover 동안 cleanup fail-closed와 delete/tombstone filtering;
- polling↔CDC mode 전환 backlog/offset/rollback runbook;
- DEAD head operator policy;
- publish success/DB ack loss duplicate;
- inbox redelivery before/after commit;
- polling delivery/inbox retention은 terminal timestamp, CDC retention은 checkpoint proof를 사용.
31.6 query test
- projection mapping;
- empty/max filter;
- max page/IN bound;
- stable keyset tie;
- cursor tamper/mismatch/version;
- N+1 statement upper bound;
- collection fetch/paging guard;
- count correctness;
- representative plan invariant;
- row/byte/time budget;
- slow-query recorder redaction.
31.7 migration compatibility
matrix:
schema S + application N-1
schema S+1 + application N-1
schema S+1 + application N
schema S+2 + application N during contract gate
scenario:
- fresh migrate;
- migrate from every supported production baseline;
- checksum validate;
- accepted schema epoch/feature revision matrix와 startup negative cell;
- checksum, epoch, required object failure의 typed 구분;
- repeatable migration if any;
- concurrent startup/external job;
- failed nontransactional index and recovery;
- old application DML 중 DDL lock contention, finite timeout, forward recovery;
- transactional/nontransactional timeout 적용과 session reset;
- bridge/backfill restart;
- exact current V1/V3/V4/V5 history/object snapshot의 controlled adoption과 checksum/shape drift rejection;
- 실제 V3 일반
outbox_eventsnapshot을 보존한 상태에서 별도outbox_event_identity_v2/outbox_event_log_v2생성, V1-only drain, matchedLEGACY_SHADOWreconciliation, DB publication control/legacy mutation guard, pre-cutover rollback과 forward-only cutover; - legacy/target history dual-authority 금지, explicit version-0 baseline audit와 old migrator retirement;
- independent optional Flyway stream의 fresh-disabled/first-enable/disable/re-enable lifecycle;
- outbox fresh polling/CDC stream의 epoch 1 control + origin별 genesis sentinel atomicity와 control/sentinel partial state rejection;
- optional stream history/checksum/core-epoch prerequisite와 interrupted migration recovery;
- old/new enum/column writes;
- downgrade binary within declared window;
- contract after old usage zero.
31.8 failure/HA
- PostgreSQL unavailable before begin;
- server terminates connection during statement;
- proxy drops response during commit;
- delayed original commit와 same operation ledger replay arbitration;
- primary failover 전/중/후 authority timeline, RPO, reconciliation;
- replica unavailable/lag/role change와 qualification generation invalidation;
- multi-replica에서 qualified backend와 borrowed backend mismatch;
- bounded lag TTL 경계, query 직전 expiry, multi-statement 요청 거절;
- connect/begin failure, mid-query disconnect, 일부 row 소비 뒤 fallback 금지;
- RYW marker와 acknowledged-write loss 가능 failover;
- secret/certificate rotation;
- pool close during quiesce;
- application kill after claim/publish/commit points.
fault injection이 실제 commit timing을 완전히 결정하지 못하면 evidence 한계를 기록하고 operation-ID reconciliation outcome을 검증한다.
31.9 security
postgresqlSecurityBaselineIntegrationTest:
- TLS hostname/certificate failure;
- wrong runtime role;
- schema/search_path/function/table shadow spoof;
- SQL identifier allowlist;
- log/trace/metric parameter redaction;
- secret absence/rotation;
- lower-environment fixture에 production PII 없음.
이 readiness task는 verifyJpaSqlConstructionSafety와 verifyJpaSecurityFixtures를
dependsOn한다. 첫 task는 native SQL concatenation, identifier/function/schema qualification,
bind/allowlist architecture rule을 검사한다. 둘째 task는 fixture provenance/PII denylist와
sensitive-output negative corpus를 검사한다. test/task count 0, skipped/aborted, 결과 파일 누락은
security manifest 생성 실패다.
optional postgresqlTenantRlsIntegrationTest:
- cross-tenant query/native/bulk/maintenance;
- RLS missing context/owner/superuser/
BYPASSRLSbypass; - pool reuse 뒤 tenant context 누출;
- tenant-scoped backup/restore/export/reaper.
tenant/RLS test는 optional profile이 꺼진 base task에서 skip하지 않고 독립 card task에서만 실행한다.
31.10 performance/capacity
R2 performance evidence:
- target-like row cardinality와 data skew;
- pool sizes와 concurrent workload;
- p50/p95/p99 acquire/query/transaction latency;
- throughput;
- connection saturation;
- batch/fetch sizes;
- index/storage/write amplification;
- long transaction/vacuum impact;
- replica lag under write load;
- outbox backlog catch-up;
- memory/persistence-context bound.
benchmark는 correctness test를 대체하지 않는다. CI threshold는 hardware 변동을 고려해 regression budget으로 관리하고 production SLO는 별도 environment evidence를 사용한다.
31.11 architecture/Gradle gate
필수:
cd src
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
./gradlew \
:adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest \
:adapter:outbound:persistence-jpa:postgresqlSecurityBaselineIntegrationTest \
:adapter:outbound:persistence-jpa:postgresqlMigrationIntegrationTest \
:adapter:outbound:persistence-jpa:postgresqlTransactionIntegrationTest \
:adapter:outbound:persistence-jpa:postgresqlAggregateIntegrationTest \
:adapter:outbound:persistence-jpa:postgresqlQueryIntegrationTest \
:adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence \
--console=plain
./gradlew test --console=plain
./gradlew check --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew verifyPublicPathSnapshot --console=plain
./gradlew verifyEnvKeys --console=plain
새 real-service task가 구현되기 전에는 존재하지 않는 명령을 현재 통과 증거로 쓰지 않는다.
R2 implementation plan에서 task를 추가하고 check 또는 명시적 CI production lane에
연결한다.
31.12 evidence grade
| Grade | 증거 |
|---|---|
| E0 | 문서/정적 추론 |
| E1 | unit/fake test |
| E2 | real single PostgreSQL integration |
| E3 | concurrency/fault/migration/plan matrix |
| E4 | target topology failover/restore/load rehearsal |
R1은 E1 이상, R2는 해당 card의 E2/E3, R3는 E4가 필요하다. 다른 module/sample의 우연한 test가 owner card의 evidence manifest를 대신하지 않는다.
manifest는 card별로 분리하고 최소 cardId, prerequisite card/version/manifest ID, source
revision, canonical producer Gradle task/CI job, executed test count, skipped/aborted count,
no-skip sentinel 결과, PostgreSQL image digest/managed engine version,
pgjdbc/Hibernate/Flyway version, date, topology와 artifact location을 기록한다.
schema-bearing card는 migration location/history table/core epoch/feature revision/stream lifecycle
evidence ID도 기록한다. outbox card는 dispatchMode를 필수로 기록하고 CDC면 connector/plugin
version, source/destination topology, external messaging-cdc-dispatch.v1 manifest ID,
checkpoint/high-watermark와 cleanup evidence ID를 추가한다. polling evidence를 CDC로, storage
evidence를 delivery로 재사용하지 않는다. idempotency/outbox/inbox/replica evidence를 하나의
“JPA integration passed” 행으로 합치지 않는다. cardReadiness descriptor는 이 immutable
manifest ID를 가리킬 때만 R2를 노출한다.
32. Gradle, dependency와 split trigger
32.1 registry
src/config/architecture/modules.json이 source path, Gradle path, production dependency edge의
SSOT다. 설계 구현 중 project edge가 필요해 보이면 먼저 registry와 architecture 의미를
검토한다.
32.2 production dependency
JPA leaf가 소유할 수 있는 dependency:
- Spring Data JPA/Hibernate;
- Spring JDBC/transaction integration;
- Hikari runtime integration;
- Flyway core와 PostgreSQL database support;
- pgjdbc;
- PostgreSQL-specific test tooling;
- application/shared/domain project edge는 registry 허용 범위 안.
금지:
- inbound-web;
- app-bootstrap;
- sample-portfolio;
- messaging/object-storage/fileserver adapter;
- Redis/Mongo provider SDK;
- domain/application에 대한 역방향 framework leakage.
idempotency response object storage seam은 application/provider-neutral port로 호출하되 JPA leaf가 object-storage adapter를 직접 의존하지 않는다.
32.3 Testcontainers
Testcontainers/PostgreSQL container dependency는 test scope에 둔다. image version/digest와 reuse/parallelism을 CI 문서에 고정한다. Docker가 없을 때 assumption skip하는 task와 R2 required task를 분리한다.
32.4 leaf split trigger
다음 중 실제 요구가 생기면 persistence-jpa와 persistence-postgresql split을 검토한다.
- 두 번째 RDBMS를 같은 template에서 first-class 지원;
- PostgreSQL SDK/Flyway release와 vendor-neutral JPA release 독립 필요;
- 보안/라이선스/deployment boundary;
- vendor code가 공통 code보다 커져 review/ownership이 분리;
- application artifact가 JPA만 포함하고 PostgreSQL을 배제해야 함.
split 전:
- registry leaf 수와 edge;
- migration resource ownership;
- entity/repository scan;
- transaction manager/datasource composition;
- test fixture;
- bootstrap artifact
를 설계한다. package 분리가 선행 seam이며 지금은 physical split하지 않는다.
33. 단계별 migration
Phase 0 — Truthful baseline과 contract freeze
- 현재 capability card/R1 evidence manifest 작성;
- production failure translator 미연결을 명시;
- SQLState mapping duplicate fail-fast;
- OSIV false와 production
ddl-autoguard; - Duration parser silent skip 제거 설계/test;
- existing API와 schema V1 compatibility freeze;
- real PostgreSQL task의 non-skippable lane 정의;
- outdated module/runbook name 정리;
- metrics registry와 실제 instrumentation drift 목록화.
승격: 전체 R2가 아니라 truthful R1 baseline.
Phase 1 — Transaction/failure/deadline foundation
- named transaction policy와 additive
TransactionPort; CallBudgetintersection;- transaction-local statement/lock timeout;
- phase-aware transaction outcome;
- commit-indeterminate/reconciliation contract;
- common repository/query translation boundary;
- constraint allowlist;
- retry disposition와 whole-transaction executor;
- pool typed settings/admission/capacity validation;
- transaction/concurrency real PostgreSQL test.
승격 상태: jpa-transaction-runtime R2 candidate. 이 phase의 기능 test만으로는 R2가 아니다.
§31.3 registry의 jpa-observability-lifecycle, jpa-security-baseline prerequisite와 자기
evidence gate까지 통과한 뒤에만 manifest가 R2를 선언한다.
Phase 2 — Entity/query discipline
- entity/mapping baseline 적용;
- aggregate repository와 query port 분리;
- projection/fetch plan;
- paging bound와 keyset cursor;
- query ID catalog;
- N+1 statement budget;
- representative PostgreSQL plan tests;
- batch/persistence-context bound;
- slow query recorder/redaction.
승격 상태: jpa-aggregate-store, jpa-query-model R2 candidate. 두 card 모두
jpa-transaction-runtime, jpa-flyway-migration prerequisite와 §34.1의 자기 task가
통과하기 전에는 R2를 선언하지 않는다.
Phase 3 — Migration/operation hardening
- external migration job production profile;
- schema compatibility validator;
- expand-contract/N/N-1 matrix;
- nontransactional index procedure;
- checkpointed backfill framework;
- migration/restore runbook;
- exact PostgreSQL/driver/ORM/Flyway version matrix;
- startup/readiness/shutdown lifecycle.
승격 상태: jpa-observability-lifecycle, jpa-security-baseline,
jpa-flyway-migration의 prerequisite를 포함한
base-card gate를 닫는 단계다. Phase 1–2 candidate도 §31.3 registry의 독립 manifest와
jpa-security-baseline gate가 모두 통과한 것만 R2로 승격한다. jpa-primary-foundation은 여섯 base
card가 모두 R2인 뒤에만 R2가 된다.
Phase 4 — Owner-safe same-store reliability
- JPA idempotency V2 owner token/CAS;
- bounded inline response와 cross-store response-reference 비보장 경계;
- outbox immutable identity/partitioned storage V2 + polling delivery V2;
- aggregate version/ordinal ordering과 DEAD-head operator policy;
- same-store inbox;
- maintenance command와 owner-safe reaper;
- V1 bridge/drain/contract migrations;
- publish/commit/ack failure injection tests.
- card별 task/CI/no-skip/image digest evidence manifest.
승격 대상: jpa-idempotency-owner-safe-v2, jpa-outbox-storage-v2,
jpa-outbox-polling-delivery-v2, jpa-inbox-same-store-v1 중 §31.3 registry에서 selected이고
자기 task/manifest가 통과한 card만 R2. CDC를 선택한 release는 polling card 대신
jpa-outbox-cdc-retention-v1과 external messaging-cdc-dispatch.v1을 독립적으로 통과해야
한다.
Phase 5 — Optional primary/replica
- separate primary/replica pools;
- explicit consistency API;
- pre-transaction route context;
- endpoint-bound lag qualification/fallback policy; conservative oracle가 없으면 bounded profile 비활성화;
- role/read-only probes;
- read-your-writes primary implementation;
- failover/lag/load tests;
- topology health/runbook.
승격 대상: jpa-primary-replica card R2. 이 phase 전에도 jpa-primary-foundation R2는
가능하다.
Phase 6 — Optional tenant/RLS와 coordination
- tenant discriminator schema/query/unique/FK;
- cross-tenant architecture/integration tests;
- optional FORCE RLS/runtime role/context;
- JDBC lock owner-safe release와 efficiency evidence;
- fencing이 필요하면 별도 contract/provider 설계;
- retention/privacy/backup alignment.
승격 대상: jpa-tenant-discriminator-rls, jpa-jdbc-efficiency-coordination 중 활성화하고
자기 task/manifest를 통과한 card만 R2.
Phase 7 — R3 rehearsal
- target-like load/capacity;
- primary failover;
- rolling migration;
- secret/certificate rotation;
- backup restore/PITR;
- outbox/inbox/idempotency reconciliation;
- measured RPO/RTO와 SLO;
- operator game day evidence.
34. 완료 기준
34.1 JPA primary foundation canonical gate
base card의 ID, dependency와 구현 시 추가할 non-skippable Gradle task는 다음 표와 같다.
machine-readable 정본은 §31.3의 src/config/jpa/readiness-cards.yaml이다.
아래 task는 현재 존재하는 통과 증거가 아니라 R2 implementation plan이 생성하고 production
CI lane에 연결해야 할 target이다.
| Card ID | Direct prerequisite | Required non-skippable task |
|---|---|---|
jpa-observability-lifecycle |
없음 | :adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest |
jpa-security-baseline |
jpa-observability-lifecycle |
:adapter:outbound:persistence-jpa:postgresqlSecurityBaselineIntegrationTest |
jpa-flyway-migration |
jpa-observability-lifecycle, jpa-security-baseline |
:adapter:outbound:persistence-jpa:postgresqlMigrationIntegrationTest |
jpa-transaction-runtime |
jpa-observability-lifecycle, jpa-security-baseline |
:adapter:outbound:persistence-jpa:postgresqlTransactionIntegrationTest |
jpa-aggregate-store |
jpa-transaction-runtime, jpa-flyway-migration |
:adapter:outbound:persistence-jpa:postgresqlAggregateIntegrationTest |
jpa-query-model |
jpa-transaction-runtime, jpa-flyway-migration |
:adapter:outbound:persistence-jpa:postgresqlQueryIntegrationTest |
jpa-primary-foundation |
jpa-observability-lifecycle, jpa-security-baseline, jpa-flyway-migration, jpa-transaction-runtime, jpa-aggregate-store, jpa-query-model |
:adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence |
card readiness는 prerequisite보다 높을 수 없다. 각 required task는 executed test가 0이거나
skipped/aborted가 하나라도 있으면 실패해야 한다. jpa-primary-foundation task는 여섯
immutable base manifest와 아래 common architecture manifest를 검증하는
aggregation gate이지, 하위 test를 한 개의 불투명한 “JPA passed” 행으로 합치는 대체 증거가
아니다.
34.1.1 jpa-observability-lifecycle R2
- typed settings가 잘못된 Duration/capacity 조합을 silent skip하지 않고 fail-fast한다.
- fixed Hikari acquisition timeout, pool capacity equation과 admission이 target deployment에 맞게 검증된다.
- startup/readiness/shutdown과 pool drain이 real PostgreSQL에서 검증된다.
- metrics/traces/log가 cardinality-bounded이며 SQL value, credential, endpoint를 redaction한다.
- lifecycle/alert/runbook과 immutable evidence manifest가 있다.
postgresqlLifecycleIntegrationTest가 zero-skip로 통과한다.
34.1.2 jpa-security-baseline R2
postgresqlSecurityBaselineIntegrationTest가 TLS hostname mismatch, expired/untrusted certificate와 revoked credential을 real PostgreSQL에서 거절한다.- production은 pgjdbc
sslmode=verify-full또는 hostname과 trust chain을 동등하게 검증하는 deployment control을 사용하고 secret/certificate rotation을 검증한다. - migration/runtime role이 분리되고 runtime role은 least privilege이며
owner/superuser/
BYPASSRLS가 아니다. - runtime
search_path는 trusted schema로 고정하고 untrusted schema와public의CREATE를 revoke하며 startup catalog probe와 shadow-spoof negative test가 실제 값을 검증한다. - PostgreSQL의 default
PUBLIC TEMPORARYprivilege를 runtime에서 revoke한다. 임시 relation이 필요한 별도 profile은 모든 relation schema qualification과pg_tempshadow negative evidence 없이는 활성화하지 않는다. - value는 bind하고 identifier/sort/function은 allowlist/schema qualification을 사용하며 native SQL string concatenation architecture test가 통과한다.
- client error, log/trace/metric/evidence artifact가 SQL value, constraint/raw server detail, password/token/certificate/JDBC URL secret, host/database/user를 노출하지 않는다.
- lower-environment fixture에 production PII가 없고 redaction negative corpus가 통과한다.
- zero-skip task 결과와 immutable security manifest가 있다.
34.1.3 jpa-flyway-migration R2
- production OSIV false와 Hibernate schema update 금지가 fail-fast한다.
- Flyway external/startup mode, checksum, schema epoch와 feature compatibility가 명확하다.
- expand-contract, N/N-1 rolling matrix와 finite lock/statement timeout이 검증된다.
- 현재 V1/V3/V4/V5 history/object에서 controlled adoption, explicit baseline audit와 legacy/target dual-authority rejection이 검증된다.
- nontransactional DDL과 checkpointed backfill의 recovery procedure가 있다.
- restore/forward-recovery runbook과 immutable evidence manifest가 있다.
postgresqlMigrationIntegrationTest가 zero-skip로 통과한다.
34.1.4 jpa-transaction-runtime R2
- application-owned named transaction policy와 policy/consistency admission이 있다.
- fixed Hikari acquisition timeout과 dynamic
CallBudgetpre-gate를 포함해 deadline/transaction/statement/lock timeout이 finite하고 검증된다. - phase-aware failure translation이 모든 production persistence path에 연결된다.
- physical owner와 participating
REQUIREDoutcome이 구분되고 commit-indeterminate가 blind retry되지 않는다. - named write policy는 stable operation ID를 요구하고 legacy facade는 별도 non-replayable/uncorrelated risk와 migration count를 노출한다.
- optimistic conflict와 allowlisted constraint가 typed 결과다.
- isolation/deadlock/timeout/pool exhaustion/commit uncertainty real-DB test가 있다.
postgresqlTransactionIntegrationTest가 zero-skip로 통과하고 immutable manifest를 남긴다.
34.1.5 jpa-aggregate-store R2
- domain aggregate와 persistence entity가 분리되고 mapper에 business policy가 없다.
- aggregate root/version/child ownership과 optimistic conflict contract가 검증된다.
- write transaction의 constraint/flush/commit failure가 공통 translator를 통과한다.
- batch와 persistence-context size가 bounded다.
postgresqlAggregateIntegrationTest가 zero-skip로 통과하고 immutable manifest를 남긴다.
34.1.6 jpa-query-model R2
- aggregate repository와 purpose-built query projection port가 분리된다.
- fetch plan, N+1 statement budget, page limit와 keyset cursor가 bounded다.
- query ID catalog와 representative PostgreSQL plan regression test가 있다.
- consistency/source marker가 실제 transaction route와 일치한다.
postgresqlQueryIntegrationTest가 zero-skip로 통과하고 immutable manifest를 남긴다.
34.1.7 jpa-primary-foundation aggregation R2
- module registry, Gradle dependency verification과 architecture test가 통과한다.
- core에 JPA/Spring/transport type이 없고 controller가 persistence type/repository를 직접 사용하지 않는다.
jpa-observability-lifecycle,jpa-security-baseline,jpa-flyway-migration,jpa-transaction-runtime,jpa-aggregate-store,jpa-query-model이 각각 자기 immutable manifest로 R2이며 registry prerequisite DAG가 닫혔다.verifyJpaPrimaryFoundationEvidence가 base manifest ID, zero-skip sentinel,:adapter:outbound:persistence-jpa:test,:app-bootstrap:test,:verifyCleanArchitectureDependencies,:verifyEnvKeys,:verifyPublicPathSnapshot결과를 검증하고 immutable bundle manifest를 남긴다.
34.2 jpa-idempotency-owner-safe-v2 R2
jpa-transaction-runtime,jpa-flyway-migration,jpa-observability-lifecycleprerequisite가 R2이고 독립 evidence manifest가 있다.- Redis/JPA 공통 V2 state/result contract와 owner/attempt/operation/state-revision CAS를 구현한다.
- stale owner transition이 no-op typed mismatch다.
- expired
EXECUTING을 blind takeover하지 않고 inspect/reconcile한다. - same-store business commit과 idempotency state의 transaction choreography가 검증된다.
- preclaim lease expiry/takeover barrier에서 business row가 정확히 한 번만 변경된다.
- DB transaction 안 object-storage I/O가 없고 R2 response는 bounded inline이다.
- fresh-disabled/enable/disable/re-enable/interrupted stream과 V1 migration/drain/contract가 rolling-safe하다.
postgresqlIdempotencyIntegrationTest가 zero-skip로 통과하고 exact card ID의 immutable manifest를 남긴다.
34.3 jpa-outbox-storage-v2 R2
jpa-transaction-runtime,jpa-flyway-migration,jpa-observability-lifecycleprerequisite가 R2이고 독립 evidence manifest가 있다.- compact identity guard와 range-partitioned immutable event envelope가 분리된다.
- 기존 V3
outbox_event와 targetoutbox_publication_control_v2/outbox_publication_cutover_v2/outbox_event_identity_v2/outbox_event_log_v2의 물리 이름과 schema authority가 충돌하지 않는다. - DB publication control이 정확히 한 active epoch/authority를 가지며 V2 append는 같은
business transaction에서 이를
FOR SHARE로 잠그고 row epoch/authority를 검증한다. - fresh polling/CDC 설치가 epoch 1 control과 exact origin/schema/external manifest를 가진
GENESIS_FRESHsentinel을 한 migration transaction에서 만들고 partial/mismatch state를 startup에서 거절한다. - identity guard가 global event ID와 aggregate version/ordinal uniqueness를 보장하고 partitioned event PK/FK는 partition key를 포함한다.
- adjacent partition의 duplicate event/aggregate tuple concurrency가 정확히 한 건만 성공한다.
- sequence authority가 aggregate version + deterministic ordinal이며
MAX+1을 쓰지 않는다. - append는 active same-resource primary write transaction이 없으면 fail-fast한다.
- identity/event/optional delivery insert가 aggregate write와 같은 transaction이다.
- identity guard lifetime/capacity/backup/privacy와 payload partition lifecycle이 분리된다.
- V1-only row에 없는 ordering semantics를 조작해 backfill하지 않고, V1 drain, matched-shadow reconciliation, pre-cutover rollback과 post-cutover forward recovery가 실제 V3 snapshot에서 검증된다.
- cutover의 control
FOR UPDATE, legacy DML revoke, target authority update와 immutable sentinel insert가 한 transaction이며 중간 실패는 이전 authority를 보존한다. - freeze 직전/도중 paused V1 writer를 cutover가 추월하지 않고, cutover 후 paused/reconnected old writer와 stale V2 epoch가 거절되며 business row도 commit되지 않는다.
- storage V2 adoption/contract와 optional stream lifecycle이 rolling-safe하다.
postgresqlOutboxStorageIntegrationTest가 zero-skip로 통과하고 exact card ID의 immutable manifest를 남긴다.
34.4 jpa-outbox-polling-delivery-v2 R2
jpa-outbox-storage-v2,jpa-transaction-runtime,jpa-flyway-migration,jpa-observability-lifecycleprerequisite가 R2이고 독립 evidence manifest가 있다.- immutable event와 mutable destination delivery가 분리된다.
- delivery의 composite PK/FK가 event retention/partition key를 포함한다.
- polling relay는 active
POLLING_V2control/sentinel과 같은 epoch의 delivery만 claim한다. - fresh polling genesis sentinel 누락/epoch·authority·manifest mismatch negative test가 zero-skip로 실행된다.
- outbox completion이 owner/token/status를 검증한다.
- aggregate strict-order gate와 DEAD-head operator policy가 있다.
- broker ack loss와 duplicate publish test가 있다.
- polling/CDC activation이 상호 배타적이고 destination ordering descriptor가 truthful하다.
- polling retention만
published_at/dead_atterminal timestamp를 사용하고 identity guard를 제거하지 않는다. - polling delivery stream의 fresh-disabled/enable/disable/re-enable/interrupted migration과 V1 drain/contract가 rolling-safe하다.
postgresqlOutboxPollingIntegrationTest가 zero-skip로 통과하고 exact card ID의 immutable manifest를 남긴다.
34.5 jpa-outbox-cdc-retention-v1 R2
jpa-outbox-storage-v2,jpa-observability-lifecycle과 externalmessaging-cdc-dispatch.v1R2 immutable manifest가 exact prerequisite다.- CDC mode에서 delivery row/polling scheduler가 없고 DB epoch/dispatch authority가 polling과 상호 배타적이다.
- connector predicate가 active
CDCcontrol/sentinel epoch만 route하고LEGACY_SHADOW와 stale/future epoch를 제외한다. - fresh CDC genesis sentinel 누락/epoch·authority·external manifest mismatch negative test가 zero-skip로 실행된다.
- ordered destination의 non-null stable
partition_key가 connector broker key와 같다. - closed partition의 모든 destination에 대해 connector checkpoint/high-watermark coverage, replay retention, incident/legal hold를 검증하고 evidence가 stale/unknown이면 cleanup을 거절한다.
- cleanup delete/detach/drop이 connector event/tombstone으로 route되지 않고 identity guard는 유지된다.
- connector outage/restart, snapshot cutover, partition growth와 polling↔CDC 전환 rehearsal가 있다.
- manifest가
dispatchMode=cdc, connector/plugin version/topology, external manifest ID와 cleanup evidence ID를 기록한다. postgresqlOutboxCdcCleanupIntegrationTest가 zero-skip로 통과하고 exact card ID의 immutable manifest를 남긴다.
34.6 jpa-inbox-same-store-v1 R2
jpa-transaction-runtime,jpa-flyway-migration,jpa-observability-lifecycleprerequisite가 R2이고 독립 evidence manifest가 있다.- inbox claim/business write/completion이 같은 transaction이다.
- leased preclaim이면 business mutation 전 row lock/owner CAS를 하고 commit까지 유지한다.
- broker redelivery/commit uncertainty/takeover barrier가 business row exactly-once mutation을 검증한다.
- retention이 terminal timestamp를 사용한다.
- fresh-disabled/enable/disable/re-enable/interrupted stream과 V1 migration/drain/contract가 rolling-safe하다.
postgresqlInboxIntegrationTest가 zero-skip로 통과하고 exact card ID의 immutable manifest를 남긴다.
34.7 jpa-primary-replica R2
jpa-transaction-runtime,jpa-query-model,jpa-flyway-migration,jpa-observability-lifecycleprerequisite가 R2이고 독립 evidence manifest가 있다.- separate pool/role validation이 있다.
- application이 explicit
ReadConsistency를 선택한다. - existing transaction route가 downgrade되지 않는다.
- bounded staleness qualification이 endpoint/pool generation/role epoch와 실제 borrowed backend에 결속되며, oracle이 없으면 비활성화된다.
- observed lag + monotonic elapsed + error margin upper bound와 single-statement 제한이 검증된다.
- fallback primary가 pre-transaction 또는 no-result replay-safe read로 제한되고 explicit/observable하다.
- RYW가 authority timeline/RPO와 분리되어 failover 때 reconcile/fail-closed한다.
- failover/lag/load/secret rotation test가 있다.
- replica 장애와 application readiness 의미가 profile별로 정해졌다.
postgresqlReplicaIntegrationTest가 zero-skip로 통과하고 exact card ID의 immutable manifest를 남긴다.
34.8 jpa-tenant-discriminator-rls R2
jpa-primary-foundationprerequisite가 R2이고 독립 evidence manifest가 있다.- 모든 tenant-owned schema/query/index/unique/FK에 tenant scope가 있다.
- cross-tenant native/bulk/maintenance test가 있다.
- RLS runtime role이 owner/superuser/
BYPASSRLS가 아니다. - missing tenant context가 fail-closed다.
- pool session state 누출이 없다.
- backup/restore/export/reaper에도 tenant isolation이 유지된다.
- tenant stream의 fresh-disabled/enable/disable/re-enable/interrupted migration이 rolling-safe하다.
postgresqlTenantRlsIntegrationTest가 zero-skip로 통과하고 exact card ID의 immutable manifest를 남긴다.
34.9 jpa-jdbc-efficiency-coordination R2 efficiency card
jpa-transaction-runtime,jpa-flyway-migration,jpa-observability-lifecycleprerequisite가 R2이고 독립 evidence manifest가 있다.- descriptor가
EFFICIENCY_ONLY이며 correctness/fencing을 주장하지 않는다. - acquire/renew/release가 owner/lease를 검증하고 stale release가 no-op이다.
- database predicate가 scheduler/reaper correctness를 독립적으로 보장한다.
- timeout, owner crash, lease loss, multi-instance contention test와 runbook이 있다.
- correctness lock이 필요한 consumer는 fenced contract/provider 없이는 composition이 실패한다.
- coordination stream의 checksum/core epoch와 fresh-disabled/enable/disable/re-enable/interrupted migration이 검증된다.
postgresqlJdbcCoordinationIntegrationTest가 zero-skip로 통과하고 exact card ID의 immutable manifest를 남긴다.
34.10 R3
- production-like failover/restore rehearsal가 있다.
- rolling application/schema upgrade와 rollback window가 검증된다.
- capacity/load와 SLO evidence가 있다.
- operator가 commit-indeterminate/outbox DEAD/migration failure를 실제 절차로 해결했다.
- evidence artifact의 date/version/topology가 추적된다.
35. 금지된 주장
다음 문구는 해당 증거가 없으면 사용하지 않는다.
- “JPA를 사용하므로 transaction-safe다.”
- “
@Transactional이 exactly-once를 보장한다.” - “connection error이므로 commit되지 않았다.”
- “retry했으므로 안전하다.”
- “read-only이므로 replica를 사용한다.”
- “replica가 거의 실시간이라 strong consistency다.”
- “optimistic lock이 모든 race를 막는다.”
- “
SKIP LOCKED이 순서를 보장한다.” - “JDBC lock이 distributed correctness lock이다.”
- “Flyway가 있으므로 zero-downtime migration이다.”
- “
ddl-auto=validate가 rolling compatibility를 보장한다.” - “Hikari 기본값이면 production pool sizing이 끝났다.”
- “virtual thread라 connection pool이 필요 없다.”
- “N+1은 lazy loading으로 해결된다.”
- “index가 있으므로 query가 빠르다.”
- “Testcontainers test가 skip되었지만 통과했다.”
- “outbox라서 메시지는 정확히 한 번 전달된다.”
- “idempotency key가 있으므로 command는 한 번만 실행된다.”
- “RLS를 켰으므로 tenant isolation이 완성됐다.”
- “backup이 있으므로 복구 가능하다.”
- “JPA leaf가 R2라 모든 capability card가 R2다.”
36. 운영 runbook 요구
최소 문서:
db-startup-schema-incompatible- migration mode, schema version, checksum, role, safe forward fix.
db-pool-exhaustion- active/pending/acquire latency, long transaction, capacity/admission, scale 주의.
db-query-timeout- query ID, plan/statistics, lock vs statement, safe cancel.
db-deadlock-serialization- SQLState, transaction policy, retry eligibility, lock order.
db-commit-indeterminate- operation ID primary reconciliation, 절대 blind retry 금지.
db-primary-failover- endpoint/role/pool refresh, indeterminate transaction, readiness.
db-replica-lag- bound, fallback, traffic shedding, catch-up.
db-migration-failure- transactional/nontransactional 구분, invalid index, forward fix.
db-backfill-pause-resume- checkpoint, throttle, validation, contract gate.
db-outbox-backlog-dead- oldest age, DEAD head, requeue/skip audit, duplicate risk.
db-idempotency-stuck-owner- lease, owner mismatch, reconcile, response reference.
db-inbox-redelivery- broker ack, DB transaction, message scope, DEAD.
db-secret-certificate-rotation- new pool probe, drain, rollback.
db-backup-restore- PITR target, application/schema validation, cross-store reconcile.
db-jdbc-lock-timeout- 실제 table 이름/owner/lease와 efficiency-only 한계.
runbook의 SQL은 read-only diagnostic을 기본으로 하고 destructive mutation/requeue/repair는 precondition, expected affected rows, audit, recovery를 명시한다. 과거 migration을 수정하거나 무조건 Flyway repair하는 절차를 제공하지 않는다.
37. 알려진 위험과 구현 전 확인 사항
| 위험/질문 | 현재 판단 | 구현 전 필요한 증거 |
|---|---|---|
| 선택한 phase-aware decorator/sentinel가 Spring lifecycle을 안정적으로 식별하는가 | §15.2 관측 지점을 정본으로 선택 | Spring transaction integration/fault test |
SET LOCAL 적용이 JPA 첫 statement보다 항상 앞서는가 |
설계상 필수 | connection/transaction hook real DB test |
| Hikari와 application admission의 최적 크기 | deployment별 | target-like load/capacity test |
| replica lag source와 failover semantics | provider별 | managed service/topology contract |
| owner-safe idempotency UPSERT의 race | PostgreSQL native SQL 필요 가능 | concurrent takeover test |
| outbox aggregate strict ordering 비용 | destination별 선택 | backlog/head-of-line load test |
| sample migration location composition | customizer가 교체할 수 있음 | production/sample artifact test |
| query metric instrumentation | registry만 있고 recorder 불명확 | actual meter emission test |
| production slow query logging redaction | deferred | synthetic sensitive parameter test |
| JPA/Hibernate 7.1 upgrade plan drift | version-sensitive | ORM migration guide + full suite |
| exact PostgreSQL 16 minor/image | floating local tag | immutable CI/production version matrix |
| RLS와 connection pool state | optional, high risk | FORCE RLS/role/reset/failover test |
| PgBouncer prepared statement/SET LOCAL | topology-specific | proxy mode integration test |
이 표는 설계 결정을 다시 열어 둔 목록이 아니라 선택한 계약을 R2로 승격하기 전 확인할 implementation evidence다. 증거가 실패하면 문서의 보장을 낮추거나 별도 설계 변경을 승인받아야 하며, 구현자가 임의 대안을 선택하지 않는다. 구현 계획은 각 항목을 task와 acceptance test로 변환해야 한다.
38. 구현 계획 작성 시 작업 분할
실제 구현은 한 PR/commit 범위로 몰지 않는다. 권장 독립 작업:
- baseline guard와 drift 수정;
- transaction policy/deadline;
- phase-aware failure translation;
- pool typed settings/admission;
- real PostgreSQL test source set;
- query catalog/N+1/plan;
- Flyway external job, legacy adoption과 optional stream compatibility;
- idempotency V2;
- outbox identity/partitioned storage V2;
- polling delivery V2;
- CDC retention과 external messaging evidence composition;
- inbox;
- replica;
- tenant/RLS;
- HA/restore evidence.
각 작업은 owner leaf의 closest CLAUDE.md, registry path, focused test를 다시 확인하고
test-first로 진행한다. architecture, runtime, data migration 경계가 바뀌면 별도 review를
요청한다.
39. Primary references
Spring
- Spring Framework — Programmatic Transaction Management
- Spring Data JPA 4.0 — Locking
- Spring Data JPA 4.0 — Projections
- Spring Data JPA — Query Methods and Scrolling
- Spring Data — Query Method Details
- Spring Boot 4.0 — Data Access
Hibernate ORM
PostgreSQL
- PostgreSQL 16 — Transaction Isolation
- PostgreSQL 16 — Serialization Failure Handling
- PostgreSQL 16 — Explicit Locking
- PostgreSQL 16 — SELECT and
SKIP LOCKED - PostgreSQL 16 — Client Connection Defaults and Timeouts
- PostgreSQL 16 — Error Codes
- PostgreSQL 16 — SET
- PostgreSQL 16 — Hot Standby
- PostgreSQL 16 — High Availability, Load Balancing, and Replication
- PostgreSQL 16 — EXPLAIN
- PostgreSQL 16 — Indexes
- PostgreSQL 16 — Table Partitioning
- PostgreSQL 16 — Privileges
- PostgreSQL 16 — ALTER TABLE
- PostgreSQL 16 — CREATE INDEX
- PostgreSQL 16 — Row Security Policies
- PostgreSQL Versioning Policy