# Conflicts: # .github/ci-gate-matrix.yml # .github/scripts/verify-gate-matrix.sh # .github/workflows/ci-quality-gates.yml # src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java
26 KiB
adapter-persistence-rdbms — 설계 결정 참조
RDBMS/JPA 퍼시스턴스 베이스 모듈. 패키지 루트: dev.caskeleton.adapter.persistence.
허용/금지 의존, 테스트 명령, 그리고 계약 테이블(TransactionPort 모드표, SQLState → Error Code 매트릭스, auditing 계약, 분산 락 provider 선택표)의 SSOT 는 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<String> 가 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 누설 없는 genericINTERNAL엔벨로프로 답하게 한다. 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)가 도메인 의미를 갖고, genericcreated_at/updated_ataudit 컬럼과 섞이면 안 된다.- mutating setter 노출은 의도. relay 어댑터가 managed 엔티티 위에서 상태(status /
attempt_count / next_attempt_at)를 전이시키되 full reload-and-replace 없이 한다. outbox 어댑터만
이 필드를 건드리고, 모든 mutation 이 use case 소유
TransactionPort.inWrite()경계 안에서 돌기에 안전하다. next_attempt_atdual-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이 아니라 실패하도록 등록돼 있다.
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로 거절한다.
./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 이름으로 생성된다.
build/jpa-evidence/manifests/<card-id>/<sha256>.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는 별도 명령이다.
./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 IntegrationDistributedLock이면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 가 락을 잡을 수 있다.