# adapter-persistence-rdbms — 설계 결정 참조 RDBMS/JPA 퍼시스턴스 베이스 모듈. 패키지 루트: `dev.caskeleton.adapter.persistence`. 허용/금지 의존, 테스트 명령, 그리고 **계약 테이블**(TransactionPort 모드표, SQLState → Error Code 매트릭스, auditing 계약, 분산 락 provider 선택표)의 SSOT 는 [CLAUDE.md](CLAUDE.md) 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할 때 본다. 표가 CLAUDE.md 에 있으면 여기서는 중복하지 않고 그 근거만 적는다. ## transaction — `SpringTransactionPort` ### 왜 모드별 템플릿을 미리 만들어 두나 `TransactionTemplate` 은 문서상 thread-safe 지만 **mutable** 하다. 매 호출마다 propagation / readOnly 를 바꿔 쓰면 같은 빈을 공유하는 동시 요청 사이에 race window 가 생긴다. 모드별로 (`WRITE` / `READ_ONLY` / `REQUIRES_NEW`) 템플릿을 생성 시점에 하나씩 만들어 두면 그 race 가 사라지고, 각 모드를 따로 감사(audit)할 수 있다. 세 템플릿 모두 isolation 을 `READ_COMMITTED` 로 고정한다(모드표는 CLAUDE.md §TransactionPort implementation contract). ### 왜 `inRootWrite`가 별도 템플릿이나 `NEVER` propagation을 만들지 않나 `inRootWrite`의 실행 속성은 `inWrite`와 같은 `WRITE + REQUIRED + READ_COMMITTED`라 기존 write template을 재사용한다. 차이는 실행 전 precondition이다. `TransactionSynchronizationManager.isActualTransactionActive()`가 `true`이면 action과 `PlatformTransactionManager`를 호출하기 전에 `NestedRootTransactionRejectedException`으로 fail-fast한다. `REQUIRES_NEW`로 suspend해서 "root처럼 보이게" 하지 않으므로 호출자 transaction과 독립 commit되는 silent 의미 변경이 없다. `TransactionTemplate.execute`는 commit까지 성공한 다음 값을 반환한다. 따라서 `inRootWrite`의 결과는 post-commit에만 호출자에게 보이고, commit 실패는 값 대신 원래 transaction 예외로 전파된다. 이 보장은 action이 외부 객체를 직접 변경하는 것을 되돌리는 보상이 아니라, 경계의 반환값을 성공으로 노출하지 않는 계약이다. ## audit — `AuditableEntity` / `AuditContextPort` / `DomainContextAuditContextPort` ### 캡처 메커니즘 — Manual explicit-set (D1 현재 스켈레톤 기본값) `AuditableEntity` 의 네 필드(`created_at` / `updated_at` / `created_by` / `updated_by`)는 평범한 `@Column` 이다 — Spring Data 의 `@CreatedDate`/`@LastModifiedDate`/`@CreatedBy`/`@LastModifiedBy` 도, `@EntityListeners(AuditingEntityListener.class)` 도 **붙이지 않는다**. 퍼시스턴스 어댑터가 `initializeAudit`(INSERT) 와 `carryCreation` + `applyModification`(UPDATE)로 명시적으로 값을 세팅한다. 공유 `Clock` 빈(D4)과 `AuditContextPort` actor(D5)를 재사용하는 방식으로, `IdempotencyStoreAdapter` 가 생성자 주입 `Clock` 으로 row 를 재구성하는 선례와 동일하다. - INSERT 는 `created_*` 와 `updated_*` 를 같은 `now`/`actor` 로 찍는다. NOT NULL 인 `updated_*` 를 신규 row 에서 채우기 위함이며, JPA-auditing 의 `modifyOnCreate` 기본동작에 의존하지 않는다. - UPDATE 는 두 단계다: `carryCreation` 으로 (Vernon Option A 재구성된) 엔티티가 잃어버린 `created_*` 를 다시 채워 넣고, `applyModification` 으로 `updated_*` 만 옮긴다. - `created_*` 는 `updatable = false` 라 INSERT 이후 모든 UPDATE 문에서 제외된다 — 생성 actor/시각이 덮어써질 수 없다. - `version`/optimistic-lock 은 여기 두지 않는다. 이 베이스는 audit 전용으로 남기고, 낙관적 잠금 정책은 개별 영속성 모델이 소유한다. ### Growth path (D1, deferred — 여기 와이어링 안 됨) audited 애그리거트 수가 늘어 수동 세팅이 누락 위험을 키우면 Spring Data JPA Auditing 으로 이전한다: 필드에 `@CreatedDate`/`@LastModifiedDate`/`@CreatedBy`/`@LastModifiedBy` + `@EntityListeners(AuditingEntityListener.class)` 를 붙이고, 컴포지션 루트에 `@EnableJpaAuditing(dateTimeProviderRef=..., auditorAwareRef=...)` 를 둔다. `DateTimeProvider` 가 같은 `Clock`(D4)을, `AuditorAware` 가 `AuditContextPort`(D5)를 감싼다. bulk/native `@Query` UPDATE 는 두 캡처 경로를 모두 우회하므로 거기서는 audit 를 명시적으로 찍어야 한다. ### actor seam 을 왜 별도 포트로 격리하나 `AuditContextPort` 는 퍼시스턴스가 "누가 행위하는가"에 대해 의존하는 단 하나의 seam 이다. actor 의 **값 의미론**(user id vs email vs subject claim)은 application/web 쪽의 사용자 모델 책임이고 여기서는 의도적으로 다루지 않는다. 그래서 타입을 `String` 으로 못박고, 이 포트가 보장하는 건 오직 "non-null actor"(프레임워크의 blank-on-absent 가 아니라) 하나 — principal 이 없으면 `"system"`. `DomainContextAuditContextPort` 는 actor id 를 runtime-context-propagation seam (`DomainContextPropagator`)에서 읽는다. 그 브랜치가 canonical actor key 를 소유하지만 API 가 확정되기 전까지 이 어댑터가 `ACTOR_KEY` 뒤로 격리해, 키가 바뀌어도 정확히 한 클래스만 손대게 한다(D5 Open Risk). 빈/blank context 값은 null/blank actor 가 아니라 `"system"` 으로 떨어뜨려 scheduler / Flyway / anonymous 경로에서도 NOT NULL `created_by`/`updated_by` 를 항상 만족시킨다. ## config — `PersistenceJpaConfig` ### 왜 명시적 `@EntityScan` / `@EnableJpaRepositories` 가 필요한가 Spring Boot 메인 클래스는 `dev.caskeleton.bootstrap` 에 있어서, `@AutoConfigurationPackage` 가 앵커로 삼는 기본 엔티티/리포지토리 스캔이 `dev.caskeleton.adapter.persistence.*` 를 놓친다. `@SpringBootApplication` 의 `scanBasePackages` 는 컴포넌트 스캔만 넓힐 뿐 JPA 엔티티/리포지토리 스캔은 넓히지 않는다. 이 설정이 없으면 production 리포지토리(예: `IdempotencyRecordJpaRepository`) 가 생성되지 않아 소비자(예: `IdempotencyReaper`)가 부팅에서 와이어링 실패한다. 스캔을 엔티티를 소유한 모듈에 둬서 와이어링을 그 자리에 유지한다. ### 왜 이름이 `JpaConfig` 가 아닌가 sample 모듈에 이미 `...sample.portfolio.adapter.persistence.config.JpaConfig` 가 있다. IDE 의 "Run main class" 가 테스트 스코프 sample 모듈을 클래스패스에 올리면, simple name `JpaConfig` 를 공유하는 두 `@Configuration` 이 기본 빈 이름 `jpaConfig` 에서 충돌한다 (`ConflictingBeanDefinitionException`). 다른 simple name 으로 이를 피한다. ## failure — 퍼시스턴스 실패 변환 SPI 매트릭스(SQLState → `DB_*` 코드, category, http, retryable)의 SSOT 는 CLAUDE.md §Persistence failure translation contract 다. 여기서는 SPI 구조와 fallback 근거만 적는다. ### SPI-pluggable 설계 (vendor 추출) `PersistenceExceptionTranslator` 는 exact-state → code 맵을 등록된 모든 `SqlStateErrorMapping` 빈을 생성 시점에 merge 해서 만든다. core 모듈(`StandardSqlStateErrorMapping`)은 모든 RDBMS 가 공통으로 반환하는 portable/vendor-neutral 5개 row(`40001`, `23502`, `23503`, `23505`, `23514`)만 기여한다. vendor 별 row(`40P01`, `25P03`, `57014` 등 PostgreSQL)는 `adapter-persistence-postgresql` 가 추가 `SqlStateErrorMapping` 빈으로 기여한다. - 서로 다른 contributor가 같은 exact SQLState를 등록하면 code가 같더라도 startup construction을 실패시킨다. last-writer-wins merge는 mapping ownership drift를 숨기므로 허용하지 않는다. - `08*` connection-class prefix → `DB_UNAVAILABLE` 규칙은 맵 엔트리가 아니라 translator 가 직접 처리한다. 따라서 core 매핑 맵에는 `08*` 가 없다. - **Fallback:** 기여된 어떤 row 에도 없는 SQLState — 또는 cause chain 에 `SQLException` 자체가 없는 경우 — 는 `Optional.empty()` 를 반환한다. 호출부는 원본 예외를 web catch-all 까지 전파시켜 detail 누설 없는 generic `INTERNAL` 엔벨로프로 답하게 한다. translator 는 unknown state 에 대해 `DB_*` 코드를 절대 지어내지 않는다. - 변환된 carrier 의 진단 메시지에 SQLState 를 넣는 건 server-log 전용이다(web 어댑터가 절대 surface 하지 않음) — triage 를 돕되 클라이언트로 새지 않는다. ## idempotency — `IdempotencyStoreAdapter` 외 스키마는 Flyway(`V1__idempotency_record.sql`)가 소유하고 엔티티는 그것을 매핑만 한다. ### 동시성 중재 (D7 insert-or-read) `tryBegin` 은 `uq_idempotency_scope` unique 제약을 동시성 중재자로 쓴다 — 동시 중복은 insert 를 잃고 `false` 를 받는다. lookup 과 flush 사이에 다른 호출자가 끼어들면 `DataIntegrityViolationException` 으로 잡아 `false` 를 반환한다(`saveAndFlush` 의 flush 가 unique 제약 검사를 그 자리에서 강제한다). 같은 scope 의 만료 row 는 insert 전에 reclaim(delete)해, stale record 가 새 요청을 영구히 막지 못하게 한다. 이 delete + insert 는 호출자 트랜잭션(use case 가 `TransactionPort` 로 소유) 안에서 도는 것을 전제로 atomic 하다. ### 엔티티 불변/스코프 (D3 / §B / §F) - `tenant` 은 절대 `null` 이 아니다(single-tenant 는 빈 문자열). PostgreSQL 은 NULL 을 서로 distinct 로 취급하므로, null 을 허용하면 single-tenant row 의 unique scope dedup 이 깨진다. 매퍼가 `null` 애플리케이션 tenant ↔ row 의 `""` 를 왕복시킨다. - `status = COMPLETED` 가 되면 `responsePayload` / `responseRef` 중 정확히 하나만 채워진다(§F ≤8KB inline / >8KB ref). - 엔티티에 setter 가 없는 건 의도다: 상태 전이 때 row 를 재구성·재저장한다(Vernon Option A) — 애플리케이션 관점에서 엔티티를 immutable 로 유지한다. ### §F responseRef split & 만료 - `complete` 의 §F 분기: payload ≤ 8KB 는 row 에 inline 저장, 더 크면 `IdempotencyResponseObjectStore`(와이어링된 경우)로 offload 하고 reference 만 보관한다. object store 가 없으면 큰 payload 도 경고와 함께 inline 으로 안전 degrade 한다(D9 프로젝트 선택). - 만료는 세 곳에서 강제된다: read(`find` 가 만료 row 를 부재로 취급), reclaim(`tryBegin` 이 만료 row 를 재claim 전에 delete), 그리고 `IdempotencyReaper`. reaper 는 유일한 만료 수단이 아니라 테이블 성장을 묶는 backstop 이다. reaper 의 고정 interval 과 clock-skew 미처리는 source-mandated 가 아니다(프로젝트 선택 — 부하 하 cadence 측정 필요). ### `IdempotencyResponseObjectStore` 는 왜 optional 인가 (D9 프로젝트 선택) 8KB 임계와 object-store 분리는 cited normative basis 가 없고 production 빈도도 미측정이다. S3 호환 클라이언트가 템플릿에 없으므로 어댑터는 이 포트를 optional 로 취급한다 — 빈이 없으면 inline DB 저장으로 fallback. 실제 구현(S3 / GCS / MinIO)을 와이어링하면 offload 가 활성화된다. ## outbox — `OutboxStoreAdapter` 외 스키마는 Flyway(`V3__outbox_event.sql`)가 소유한다. ### 트랜잭션 경계 계약 - **append**: 호출자의 `TransactionPort.inWrite()` 경계 안에서 호출되어야 하며, 내부에서 새 트랜잭션을 열지 않는다. `@Repository` 가 빈만 등록하고 TX 는 use case 가 소유한다. - **No `@Transactional`**: 이 코드베이스의 퍼시스턴스 어댑터는 `@Transactional` 을 선언하지 않는다. 유일한 트랜잭션 경계는 use case 가 소유한 `TransactionPort` 다(CLAUDE.md "Forbidden"). ### claim 계약 — vendor SPI (`OutboxClaimRepository`) row claim 은 vendor 별 락 전략이 필요해 `OutboxClaimRepository` SPI 로 추출했다. canonical PostgreSQL 구현은 native query 의 `FOR UPDATE SKIP LOCKED` 를 쓰고, 다른 벤더는 등가물을 공급한다(예: SQL Server `WITH (UPDLOCK, READPAST)`). eligibility predicate(I3 — concurrent-relay 안전을 위한 `SKIP LOCKED`)와 per-aggregate FIFO gate(I4 — `NOT EXISTS` correlated subquery)를 전부 SQL 에서 강제한다. 한 배치에 aggregate 당 최대 한 row(head)만 나타난다. 어댑터는 추가 in-memory 필터링을 하지 않는다 — 리포지토리가 반환한 모든 row 를 IN_FLIGHT 로 전이시켜 호출자에 돌려준다. 전체 SQL gate 동작은 PG contract 테스트(Testcontainers)가 검증한다. eligible row(I3/I4/I6): - `PENDING` — `next_attempt_at <= now`(insert 시 즉시 eligible) - `FAILED` — `next_attempt_at <= now`(backoff 경과) - `IN_FLIGHT` — `next_attempt_at <= now`(orphaned row) ### `markPublished` / `markFailed` / `markDead` 는 왜 row 부재 시 throw 하나 row 가 없으면 `IllegalStateException` 을 던진다. relay 가 방금 같은 서비스 인스턴스에서 claim 한 row 이므로, 부재는 프로그래밍/동시성 버그다. 조용히 no-op 하면 row 가 영원히 `IN_FLIGHT` 로 남아 그 aggregate 의 FIFO 큐를 막고, 호출자나 로그에 아무 신호도 남지 않는다(markFailed 는 relay 가 재시도 예약을 믿게, markDead 는 runbook 가시성·수동 DEAD 해결을 막게 된다). ### `OutboxEventEntity` 의 결정 - **`AuditableEntity` 미상속(D6 — infra 엔티티).** `IdempotencyRecordEntity` 처럼 outbox row 는 도메인 애그리거트가 아니라 인프라 record 다. 자체 temporal 필드(`occurred_at`, `next_attempt_at`)가 도메인 의미를 갖고, generic `created_at`/`updated_at` audit 컬럼과 섞이면 안 된다. - **mutating setter 노출은 의도.** relay 어댑터가 managed 엔티티 위에서 상태(status / attempt_count / next_attempt_at)를 전이시키되 full reload-and-replace 없이 한다. outbox 어댑터만 이 필드를 건드리고, 모든 mutation 이 use case 소유 `TransactionPort.inWrite()` 경계 안에서 돌기에 안전하다. - **`next_attempt_at` dual-purpose(I6 — 추가 컬럼 없음):** - `PENDING`: insert 때 `occurred_at` 으로 세팅 → 최초 claim 체크(`next_attempt_at <= now`)가 즉시 만족. - `IN_FLIGHT`: `claim_time + in_flight_timeout` → orphaned row 가 visibility window 만료 후 재claim 가능. - `FAILED`: `now + backoff` → backoff window 경과 후에만 재시도. - `status` 는 `PENDING | IN_FLIGHT | PUBLISHED | FAILED | DEAD` 문자열이다. ### metric 쿼리 반환 형태 `OutboxEventJpaRepository.countGroupedByStatus()` 는 `[status(String), count(Long)]`, `findOldestUnpublishedOccurredAtByEventType()` 는 `[eventType(String), oldestOccurredAt(Instant)]` 2-요소 배열 리스트를 돌려준다(각각 outbox.pending.size, outbox.publisher.lag gauge 용). `OutboxStoreAdapter.oldestUnpublishedAgeSecondsByEventType` 가 `HashMap` 을 쓰는 건 키가 enum 이 아니라 String(event-type 이름)이기 때문이다 — `countByStatus()` 는 키가 `OutboxEventStatus` enum 이라 `EnumMap` 을 쓴다(두 반환 타입이 의도적으로 다름). ### `OutboxReaper` PUBLISHED row 는 이미 전달된 terminal-success record 라 무한 보관할 필요가 없다. reaper 가 retention 보다 오래된 PUBLISHED row 를 주기적으로 비워 테이블 성장과 metric gauge 를 묶는다. 스케줄링은 컴포지션 루트의 `@EnableScheduling` 으로 켜지고, `@Transactional` bulk delete 가 purge 를 한 문장으로 유지한다. 고정 interval / clock-skew 미처리는 프로젝트 선택(부하 하 cadence 측정 필요). retention 한 값은 reaper-local 이라 `@Value` 로 받지만, canonical 6-property 문서는 app-bootstrap `OutboxSettings` / `application.yml` 에 있다. ## JPA production capability candidate `src/config/jpa/readiness-cards.yaml`이 15개 capability와 7개 독립 schema stream의 machine-readable SSOT다. `selected` base card와 `implemented-candidate` reliability card를 구분하며, 실제 PostgreSQL 테스트 통과만으로 immutable 운영 evidence가 필요한 R2를 주장하지 않는다. 독립 Flyway stream은 broad `classpath:db/migration`으로 함께 실행하지 않는다. 각 stream은 자기 location/history table을 사용하고 non-empty schema adoption 때 version 0 baseline을 명시한 뒤 V1부터 실행한다. | Capability | Location | History table | 상태 | |---|---|---|---| | core/adoption | `db/migration/jpa/core` | `flyway_jpa_core_history` | selected candidate | | idempotency V2 | `db/migration/jpa/idempotency` | `flyway_jpa_idempotency_history` | implemented-candidate | | outbox storage V2 | `db/migration/jpa/outbox-storage` | `flyway_jpa_outbox_storage_history` | implemented-candidate | | polling delivery V2 | `db/migration/jpa/outbox-polling` | `flyway_jpa_outbox_polling_history` | implemented-candidate | | inbox V1 | `db/migration/jpa/inbox` | `flyway_jpa_inbox_history` | implemented-candidate | ### owner-safe idempotency V2 `PostgreSqlOwnerSafeIdempotencyStore`는 row lock을 얻은 뒤 `clock_timestamp()`를 평가한다. claim takeover와 start/renew/complete/fail/release는 scope/state/owner/attempt/claim operation/ state revision을 SQL predicate로 다시 검증한다. expired `CLAIMED`만 takeover하며 expired `EXECUTING`은 `ABANDONED`로 닫고 reconciliation을 요구한다. raw client key는 저장하지 않고 versioned HMAC scope digest만 쓴다. ### immutable outbox storage와 polling delivery V2 `PostgreSqlImmutableOutboxAppendAdapter`는 publication control을 `FOR SHARE`로 잠근 상태에서 compact global identity guard와 partitioned immutable envelope를 같은 business transaction에 기록한다. cutover는 control `FOR UPDATE`와 충돌하므로 시작된 append를 추월하지 못하며, target authority 활성화 뒤 legacy V1 writer trigger가 실패한다. polling mode일 때 database trigger가 initial `outbox_delivery_v2` row를 같은 transaction에 생성한다. `PostgreSqlPollingDeliveryAdapter`는 bounded `FOR UPDATE SKIP LOCKED` claim, aggregate version/ordinal strict-order head gate, owner/token/attempt/version/epoch completion CAS를 사용한다. broker 호출은 transaction 밖이고 duplicate publish 가능성은 stable event ID로 consumer inbox에서 처리한다. ### same-store inbox `PostgreSqlSameStoreInboxAdapter`의 transactional claim은 business mutation/outgoing outbox/ completion과 caller의 한 primary write transaction에 참여한다. received lease expiry는 takeover할 수 있지만 processing lease expiry는 blind retry하지 않고 recovery-required terminal state로 보낸다. broker ACK는 commit 이후에만 실행한다. ### 실제 PostgreSQL task base 6개 task 외에 다음 candidate task가 Docker 부재 시 skip이 아니라 실패하도록 등록돼 있다. ```text postgresqlIdempotencyIntegrationTest postgresqlOutboxStorageIntegrationTest postgresqlOutboxPollingIntegrationTest postgresqlInboxIntegrationTest ``` ### evidence manifest와 R2 gate `readiness-cards.yaml`의 `evidence.scenarios`와 `evidence.task-claims`가 required evidence를 실제 JUnit selector/Gradle task에 연결한다. `verifyJpaReadinessRegistryContract`는 unknown claim, duplicate selector와 다른 card task 차용을 mutation test로 거절한다. ```bash ./gradlew :adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence --console=plain ``` 위 task는 active card 11개의 producer를 실행하고 JUnit XML에서 exact selector와 executed/skipped/failure/error 수를 읽는다. 각 manifest는 source revision/dirty digest, prerequisite manifest ID, PostgreSQL image digest, pgjdbc/Hibernate/Flyway version, topology와 migration/dispatch metadata를 담고 다음 위치에 canonical JSON SHA-256 이름으로 생성된다. ```text build/jpa-evidence/manifests//.json ``` 후보 검증은 zero-skip, schema, content hash와 prerequisite link가 맞으면 성공하지만 `attainedReadiness=R1`을 유지한다. 로컬 후보 lane은 다음 E2/E3 동작을 실제 PostgreSQL에서 검증한다. - bounded pool saturation과 shutdown 뒤 connection 거부 - runtime/migration role 분리, trusted namespace, TLS `verify-full`의 정상·hostname mismatch· untrusted CA·expired certificate 경로 - persistence failure의 HTTP/log/span redaction - fresh/legacy adoption, interrupted migration forward recovery, N/N-1 additive rolling shape - serialization/deadlock/lock/statement timeout, pool exhaustion, commit transport 단절 - idempotency/outbox/inbox 독립 stream의 disabled/first-enable/disable/re-enable/interrupted lifecycle 각 manifest는 그래도 candidate profile, dirty source와 아직 R2가 아닌 prerequisite를 `readinessBlockers`에 보존하므로 후보 통과를 R2로 오인할 수 없다. 실제 aggregation gate는 별도 명령이다. ```bash ./gradlew \ :adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence \ -PjpaEvidenceProfile=r2 \ --console=plain ``` 이 task는 clean revision, `JPA_EVIDENCE_CI_JOB`, `JPA_EVIDENCE_ARTIFACT_LOCATION`, immutable PostgreSQL image digest, 모든 required evidence와 R2 prerequisite DAG가 있어야만 성공한다. `.github/workflows/ci-quality-gates.yml`의 candidate job은 PR에서 zero-skip manifest를 보존하고, `.github/workflows/jpa-r2-evidence.yml`은 명시적으로 실행하는 production-profile lane이다. 로컬 dirty worktree 또는 unpublished 실행은 `worktree-is-dirty`/CI provenance blocker를 보고 실패하는 것이 정식 동작이다. R2 승격은 clean revision에서 workflow를 실행하고 보존된 manifest artifact를 검토한 뒤에만 가능하다. stream migration이 중단되면 history/registry/object 상태를 먼저 확인하고 기존 migration을 임의 수정하거나 history를 바로 `repair`하지 않는다. 장애를 수정한 forward migration으로 복구하는 운영 절차는 `docs/runbooks/migration-failed.md`를 따른다. ## lock — 분산 락 provider 선택표(flag → bean → registry)의 SSOT 는 CLAUDE.md(또는 app-bootstrap 와이어링)다. 여기서는 어댑터/설정 결정 근거만 적는다. ### `LockRegistryDistributedLockAdapter` 계약 - **D4 — transaction-commit ordering invariant.** 이 어댑터는 트랜잭션 경계를 관리하지 않는다. 호출자는 보호된 트랜잭션이 **커밋된 뒤에만** 핸들(`DistributedLock.close()`)을 release 해야 한다. 커밋 전(트랜잭션 안)에 release 하면 lost-update race 가 생긴다. - **D5 — finite waitTime + lease TTL.** `tryAcquire` 는 Spring Integration `DistributedLock` 이면 `tryLock(waitTime, leaseTtl)` 로, 일반 `Lock` 이면 `Lock.tryLock(long, TimeUnit)` 으로 최대 `waitTime` 만 블록하고, 잡으면 핸들을, 못 잡으면 `LockAcquisitionTimeoutException` 을 던진다. 무한 블로킹은 쓰지 않는다. - `leaseTtl > configuredTtl` 은 `IllegalArgumentException` 으로 거부한다. provider 기본 TTL 보다 긴 lease 를 약속하는 건 false contract 다. shipped 와이어링에선 `leaseTtl == configuredTtl` (둘 다 `LockSettings.leaseTtl()` 바인딩)이라 이 가드는 mis-wired 호출자/테스트에서만 fail-fast 로 발동한다. - 반환 핸들은 `lock::unlock` 람다(SAM 인터페이스 충족). `InterruptedException` 은 interrupt flag 를 복원하고 `LockAcquisitionTimeoutException` 으로 변환한다. ### TTL 주의 (SI 7.0) - `JdbcLockRegistry`: 기본 TTL 은 `JdbcLockRegistry(LockRepository, Duration)` 생성자로 설정하고, acquisition 별 TTL 은 `DistributedLock.tryLock(Duration waitTime, Duration ttl)` 로 전달한다. 크래시한 JVM 의 row 는 lock TTL 만료 후 다음 acquire 시도에서 회수된다. - `DefaultLockRegistry`: TTL 은 advisory 이고 무의미하다 — JVM 크래시가 in-JVM 락을 프로세스와 함께 자동으로 떨군다. ### SI-LOCK-C5 — lease 만료 후 release 반환 핸들은 `lock::unlock` 이다. lease TTL 이 `close()` 전에 만료된 `JdbcLockRegistry` 의 경우, 내부 `JdbcLock.unlock()` 이 `ConcurrentModificationException` 을 던진다(row 가 이미 회수됨). 이 어댑터는 여기서 일부러 잡지 않는다 — metered `distributedLockProvider` 데코레이터 (컴포지션 루트)가 SI-LOCK-C5 계약을 소유한다: 로그 + `lock.lease.expired` metric 후 `close()` 에서 정상 return 해, 만료가 호출자의 보호작업 예외를 가리지 않게 한다. in-process `DefaultLockRegistry` 경로는 만료가 없어 그 `close()` 가 이 예외를 던질 수 없다. ### `DistributedLockPersistenceConfig` 와이어링 결정 - `jdbcDistributedLock` 빈은 일부러 `@Primary` 가 아니고 이름도 `distributedLockProvider` 가 아니다. app-bootstrap 이 이를 metrics 데코레이터(`MeteredDistributedLockPort`)로 감싸 `@Primary`/`distributedLockProvider` 빈을 노출한다. 이렇게 해서 SI 타입이 컴파일 타임에 adapter-persistence 위 레이어에 보이지 않게 유지된다(SI 는 `implementation` 의존). - `DefaultLockRepository` 는 `InitializingBean`/`SmartLifecycle` 을 구현해 Spring 이 lifecycle 을 자동 관리하고, `ApplicationContextAware` 로 `PlatformTransactionManager` 를 auto-discover 한다. app-bootstrap Testcontainers 테스트가 와이어링 갭을 드러내면 부트스트랩이 `setTransactionManager` 로 명시 전달할 수 있다. - `setCheckDatabaseOnStart(false)`: `INT_LOCK` 테이블은 첫 lock acquire 전에 Flyway V4/V5 가 provision 하므로 DDL 체크를 건너뛴다. - `JdbcLockRegistry(lockRepository, settings.leaseTtl())`: Spring Integration 7.0 이후 기본 TTL 은 repository setter 가 아니라 registry 생성자에서 설정한다. 어댑터는 호출별 `leaseTtl` 도 `DistributedLock.tryLock(waitTime, leaseTtl)` 로 전달한다. ### `LockSettings` `ca-skeleton.lock.*` 에서 바인딩되는 yaml-only 기본값이다. 새 `APP_*` env 키가 아니므로 `env-keys.yaml` 엔트리가 필요 없다. bootstrap 의 `@ConfigurationPropertiesScan` 으로 잡혀 `@EnableConfigurationProperties` 명시가 필요 없다. cross-field invariant: `leaseTtl >= waitTime` 이어야 한다 — TTL 이 waitTime 보다 먼저 만료되면 첫 holder 의 보호작업이 끝나기 전에 두 번째 holder 가 락을 잡을 수 있다.