Files
tech-log-backend/src/adapter/outbound/persistence-jpa
DongHyeonka 0da7c7e2db fix: use the Jackson 3 mapper the persistence module actually has
JdbcProjectRepositoryAdapter asked for com.fasterxml.jackson.databind.ObjectMapper
— Jackson 2. This build is on Jackson 3 (tools.jackson.databind), so no bean
of that type exists and the context failed to refresh: the pod crash-looped
with 'Parameter 1 ... required a bean of type ObjectMapper that could not be
found'.

Compilation could not catch it. The Jackson 2 types are still on the classpath
through some transitive dependency, so the wrong import resolves and only the
container tells you. PublicJson in the sibling package was already on Jackson 3
and is the shape this now follows.

readTree/asString rather than readValue with a TypeReference: Jackson 3 does
not throw a checked exception here, so the surrounding try/catch narrows to
RuntimeException and the method keeps its contract of degrading to an empty
list rather than failing the whole edit screen.
2026-08-20 23:54:18 +09:00
..

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.* 를 놓친다. @SpringBootApplicationscanBasePackages 는 컴포넌트 스캔만 넓힐 뿐 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)

tryBeginuq_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):

  • PENDINGnext_attempt_at <= now(insert 시 즉시 eligible)
  • FAILEDnext_attempt_at <= now(backoff 경과)
  • IN_FLIGHTnext_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 경과 후에만 재시도.
  • statusPENDING | 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.oldestUnpublishedAgeSecondsByEventTypeHashMap 을 쓰는 건 키가 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 EXECUTINGABANDONED로 닫고 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.yamlevidence.scenariosevidence.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 Integration DistributedLock 이면 tryLock(waitTime, leaseTtl) 로, 일반 Lock 이면 Lock.tryLock(long, TimeUnit) 으로 최대 waitTime 만 블록하고, 잡으면 핸들을, 못 잡으면 LockAcquisitionTimeoutException 을 던진다. 무한 블로킹은 쓰지 않는다.
  • leaseTtl > configuredTtlIllegalArgumentException 으로 거부한다. 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 의존).
  • DefaultLockRepositoryInitializingBean/SmartLifecycle 을 구현해 Spring 이 lifecycle 을 자동 관리하고, ApplicationContextAwarePlatformTransactionManager 를 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 생성자에서 설정한다. 어댑터는 호출별 leaseTtlDistributedLock.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 가 락을 잡을 수 있다.