Files
tech-log-backend/src/adapter/outbound/persistence-jpa/README.md
T
donghyeon-ka a05a8ada92 merge: integrate JPA production capability
# 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
2026-07-31 23:57:29 +09:00

380 lines
26 KiB
Markdown

# 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<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 누설 없는 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/<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는 별도 명령이다.
```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 가 락을 잡을 수 있다.