Files
llm-wiki/raw/branch-notes/feature-distributed-lock-contract.md
T

446 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: branch / feature-distributed-lock-contract
source_type: branch-note
status: raw
branch: feature-distributed-lock-contract
parent_branch:
related_projects: [ca-skeleton]
governing_docs: [raw/project-notes/ca-skeleton-operational-contract]
tags: [branch, ca-skeleton, distributed-lock, advisory-lock, lock-registry]
created: 2026-06-12
target_merge:
status_label: in-progress
id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-052
kind: project-work-item
project: ca-skeleton-operational-contract
work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-052
inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TRANSACTION-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-DATABASE-001@1]
refines: []
overrides: []
depends_on: [WI-CA-SKELETON-OPERATIONAL-CONTRACT-004, WI-CA-SKELETON-OPERATIONAL-CONTRACT-025, WI-CA-SKELETON-OPERATIONAL-CONTRACT-024, WI-CA-SKELETON-OPERATIONAL-CONTRACT-017, WI-CA-SKELETON-OPERATIONAL-CONTRACT-018]
contract_packet: 1
contract_packet_sha256: 174d92e4290cde20c264638b526e3447c27c19eca0eab92d8023a03a66627754
---
# branch: feature-distributed-lock-contract
> Layer: `raw/branch-notes/` — 단일 브랜치의 **TODO·결정·진행 기록**. 머지/종료 후 verified 결과는 `/ingest`로 `wiki/projects/`에 추출. 원본은 raw에 영구 보관.
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
<!-- section-id: branch-parent -->
## 부모 (필수)
- **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]]
> ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note §29.E 신규 branch 권고 #9 (`feature-distributed-lock-contract` — "Redisson / DB advisory lock + 트랜잭션 commit 정합") 영역의 결정/근거/금지 사항을 정제한다.
형제 branch (같은 부모의 다른 자식 — lock 인접 영역):
- [[raw/branch-notes/feature-background-job-async-contract]] — scheduler/outbox 의 lock *적용처* owner (D3). 본 branch 의 `distributedLockProvider` bean 을 consume.
- [[raw/branch-notes/feature-cache-consistency-contract]] — cache stampede lock (Redisson RLock) owner (D3/D4)
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] — `APP_MULTI_INSTANCE_ENABLED` flag + `StartupSafetyValidator` presence 강제 owner (D8)
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: lock provider·lease·transaction commit ordering과 failure test가 명시된다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1` | single-instance가 default이며 multi-instance scheduler/outbox는 DB advisory lock을 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-TRANSACTION-001@1` | application은 Spring transaction annotation 대신 TransactionPort 또는 TransactionalUseCaseRunner를 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
| `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-STACK-DATABASE-001@1` | database는 PostgreSQL 16 단일 stack이다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
<!-- section-id: declared-overrides -->
### 선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|---|---|---|---|
<!-- GENERATED: branch-contract:end -->
<!-- section-id: branch-goal -->
## 목표
multi-instance 배포(`APP_MULTI_INSTANCE_ENABLED=true`) 시 ca-tmpl `StartupSafetyValidator` 가 presence 를 강제하는 5개 instance-coordination bean 중 **`distributedLockProvider` 만 제공 결정의 owner branch 가 없었다** — [[raw/branch-notes/feature-background-job-async-contract]] §Audit **A7 `LOCK_BEAN_OWNER_UNRESOLVED`** (2026-06-11 coverage-auditor): ca-tmpl 코드 주석은 runtime-health 를 가리키나 그 노트는 "consume only" 자기 서술, 어느 branch 도 *bean 을 누가 어떤 메커니즘으로 제공하는지* 결정하지 않음.
본 branch 가 그 owner 가 되어 다음을 결정한다: **general-purpose 분산 락 제공 계약** — 메커니즘 선택(DB 기반 vs Redis 기반), port 추상화, **트랜잭션 commit 정합**(lock 해제 vs DB commit 순서), lease/timeout 계약, 실패 매핑, 정적 강제 요구.
- 이슈: parent project §29.E row #9 / background-job §Audit A7
- PR: (없음 — 계약 단계)
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- `distributedLockProvider` bean 계약의 SSOT ownership (A7 해소) — bean 이름은 ca-tmpl `StartupSafetyValidator.REQUIRED_MULTI_INSTANCE_BEANS` 기존 값 재사용
- general-purpose 분산 락 **메커니즘 선택** (PG advisory lock / ShedLock / Spring Integration LockRegistry / Redisson 비교)
- **port 추상화** — domain/application 층에서 lock client 직접 사용 금지
- **트랜잭션 commit 정합** — lock 해제와 DB commit 의 순서 불변식
- **lease / timeout 획득 계약** — 무한 blocking 금지, 잔존 lock 자동 만료
- lock 획득 실패의 error code / metric **신규 제안** (registry-governance 절차 경유)
- 정적 강제(ArchUnit) **요구사항** 등록 — rule 호스팅은 `feature-architecture-enforcement-rules` 에 위임
- multi-instance contract test 계약 (bean presence + 정합)
### 제외 범위
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
- scheduler/outbox 의 lock **적용 정책** — [[raw/branch-notes/feature-background-job-async-contract]] D3 소유 (본 branch 는 provider 만 공급)
- cache stampede 방지 lock — [[raw/branch-notes/feature-cache-consistency-contract]] D3/D4 소유 (Redisson RLock + `CACHE_STAMPEDE_LOCK_TIMEOUT`)
- `APP_MULTI_INSTANCE_ENABLED` flag 정의와 `StartupSafetyValidator` 집행 — [[raw/branch-notes/feature-env-driven-runtime-configuration]] D8 소유
- distributed rate limiter (`distributedRateLimiter` bean) — `feature-rate-limit-idempotency-contract` 영역
- migration runner lock (`migrationStartupRunner` bean) — `feature-migration-startup-contract` 영역
- **fencing token 도입** — 미도입 결정 (D6). correctness 는 DB 제약으로 보장
- tenant 별 lock namespace — `feature-tenant-context-policy` 활성화 전까지 미정의
## 근거 (필수, 최소 1개+)
> 이 branch의 구현·설계 결정의 **근거가 되는 외부 자료**. 공식 문서·대기업 기술 블로그·강의 등 raw 자료를 인용. 같은 자료가 여러 결정의 근거면 결정 표시와 함께 여러 번 등장 가능.
| Source | 정당화하는 결정 |
|---|---|
| [[raw/official-docs/lock-postgres-advisory-locks]] | ca-tmpl `distributedLockProvider` 의 default 메커니즘으로 PostgreSQL advisory lock 검토 — session-level vs transaction-level 해제 시맨틱(PG-ADV-C2, PG-ADV-C3)이 "lock 해제 vs DB commit 순서 정합" 결정(D4)의 1차 근거 + session-level 배제(D3)·non-blocking try 변형(D5) 근거 |
| [[raw/official-docs/lock-spring-integration-lock-registry]] | ca-tmpl `distributedLockPort` 추상화의 reference 구현 후보로서 Spring Integration `LockRegistry`/`JdbcLockRegistry` 평가 — `java.util.concurrent.locks.Lock` 호환 추상화 + JDBC/Redis/Zookeeper/DynamoDB provider 교체 가능성이 "port 추상화 + provider 교체" 결정의 근거 (SI-LOCK-C1, SI-LOCK-C2, SI-LOCK-C3) + lease 갱신/만료 예외 계약(D5 — SI-LOCK-C4, SI-LOCK-C5) |
| [[raw/official-docs/lock-shedlock-readme]] | ShedLock 평가(D3 배제) — 용도 정의 "scheduled tasks at most once"(SHEDLOCK-C1) + `lockAtMostFor`/`lockAtLeastFor` lease 시맨틱(D5 참조 원리 — SHEDLOCK-C3, SHEDLOCK-C4) + clock 동기화 가정(D6 한계 방증 — SHEDLOCK-C5) |
| [[raw/official-docs/lock-shedlock-issue-899-non-scheduler-use]] | ShedLock 을 general-purpose lock 으로 쓰지 않는 결정(D3)의 직접 근거 — maintainer 가 generic lock 공식 선언 거부(SHEDLOCK-899-C1) + 획득 실패 시 *skip*(대기 없음) 시맨틱이라 blocking 계약과 불일치(SHEDLOCK-899-C2) |
| [[raw/company-tech-blogs/lock-subskribe-advisory-lock-distributed-consensus]] | PostgreSQL advisory lock 의 production 사용 사례 — 추가 인프라 없이 DB 만으로 distributed mutual exclusion 을 달성한 사례(SUBSKRIBE-LOCK-C1) + "optimistic variant(try-lock) 만 사용, pessimistic blocking 은 비권장" 운영 교훈(SUBSKRIBE-LOCK-C2)이 `distributedLockProvider` 메커니즘 비교의 사례 근거 (공식 best practice 아님 — 사례/관점으로만 취급) |
| [[raw/official-docs/cache-redisson-rlock-vs-setnx]] | Redis 기반 대안(D3 의 Redis-활성 분기) — Redisson RLock 의 j.u.c.Lock 호환 + watchdog(LOCK-C3, `needs-confirmation`), TTL 의 deadlock 회피 역할(D5 — LOCK-C2), efficiency vs correctness lock 분리(D6 — LOCK-C4). cache branch 와 공유 raw |
근거 자료가 raw에 아직 없다면 먼저 `raw-source-template` 또는 `lecture-note-template` 으로 raw에 등록한 뒤 여기서 링크.
## TODO
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
- [x] `OperationalError.LOCK_ACQUISITION_TIMEOUT` enum 상수 추가 (shared-contract) — 등급: `actually-implemented` / `locally-verified` (D7, 2026-06-13 이전 세션)
- [x] lock port 인터페이스 3종 정의 (application-core): `DistributedLockPort`, `DistributedLock`, `LockAcquisitionTimeoutException` — 등급: `actually-implemented` / `locally-verified` (D2/D4/D5/D6, 2026-06-13)
- [x] `LockAcquisitionTimeoutExceptionTest` + `DistributedLockPortContractTest` (application-core) — 등급: `locally-verified` (10/10 pass, 2026-06-13)
- [x] `LockSettings` `@ConfigurationProperties("ca-skeleton.lock")` record (adapter-persistence) — 등급: `actually-implemented` / `locally-verified` (D3/D5, 2026-06-13)
- [x] `LockRegistryDistributedLockAdapter implements DistributedLockPort` (adapter-persistence) — 등급: `actually-implemented` / `locally-verified` (D3/D4/D5, 2026-06-13)
- [x] `DistributedLockPersistenceConfig` Spring wiring (adapter-persistence) — in-process (`@Primary`, matchIfMissing) + JDBC conditional beans — 등급: `actually-implemented` / `locally-verified` (D3, 2026-06-13)
- [x] `V4__int_lock.sql` Flyway migration (adapter-persistence/db/migration) — SI 6.5 verbatim PostgreSQL DDL — 등급: `actually-implemented` (D3/D4, 2026-06-13; Testcontainers run-verify is app-bootstrap scope)
- [x] `LockRegistryDistributedLockAdapterTest` 5종 단위 테스트 (DefaultLockRegistry, no Spring context) — 등급: `locally-verified` (5/5 PASS, 2026-06-13)
- [x] `lock.acquisition` metric decorator `MeteredDistributedLockPort` (app-bootstrap) — 등급: `actually-implemented` / `locally-verified` (D7, 2026-06-13)
- [x] `DistributedLockConfig` @ConditionalOnProperty bean wiring (app-bootstrap) — distributedLockProvider `@Primary`, multi-instance=true 시만 활성 — 등급: `actually-implemented` / `locally-verified` (D1/D3, 2026-06-13)
- [x] ca-tmpl `StartupSafetyValidator``distributedLockProvider` 주석 owner 표기 갱신 (runtime-health → 본 branch) — 등급: `actually-implemented` / `locally-verified` (§Audit A1, 2026-06-13)
- [x] `ca-skeleton.lock: wait-time: 3s / lease-ttl: 30s` application.yml 기본값 배선 (app-bootstrap) — 등급: `actually-implemented` (D5, 2026-06-13)
- [x] `MeteredDistributedLockPortTest` 6종 단위 테스트 (app-bootstrap) — acquired/timeout/error + no-registry no-op — 등급: `locally-verified` (6/6 PASS, 2026-06-13)
- [x] `LockAcquisitionTimeoutClassificationContractTest` 5종 계약 테스트 (app-bootstrap) — enum SSOT + skip-not-pass registry/metrics — 등급: `locally-verified` (5/5 PASS, 2026-06-13)
- [x] `DistributedLockProviderContractTest` 4종 계약 테스트 (app-bootstrap) — D1 bean presence/absence + D3 mutual exclusion + D5 lease expiry (Testcontainers PG) — 등급: `locally-verified` (4/4 PASS, 2026-06-13)
- [x] **Quality-review remediation (2026-06-13)**: SI-LOCK-C5 lease-expiry 처리 + D5 테스트 poll 개선 — 등급: `actually-implemented` / `locally-verified`
- `MeteredDistributedLockPort`: `LOCK_LEASE_EXPIRED="lock.lease.expired"` 상수 + `closeHandlingLeaseExpiry()` + `incrementLeaseExpired()` 추가. `tryAcquire` 는 wrapping lambda 반환.
- `MeteredDistributedLockPortTest`: 기존 identity(isSameAs) 어설션 제거(wrapping lambda로 변경됨) + 신규 4종: `LOCK_LEASE_EXPIRED` 상수 pinning + CME 삼킴 + non-CME 전파 + no-registry CME 삼킴 → 10/10 PASS
- `DistributedLockProviderContractTest`: D5 sleep-then-single 취약점 → bounded poll 수정 + SI-LOCK-C5 2종 신규(raw CME 증명 + metered 삼킴+카운터) + intentional discard `@SuppressWarnings("unused")` + 총 6/6 PASS
- [ ] ArchUnit rule 요구사항을 [[raw/branch-notes/feature-architecture-enforcement-rules]] 에 등록 — 등급: `planned` (D8)
- [ ] background-job §테스트 계약의 ShedLock `LockProvider` FQCN 전파 알림 — 등급: `planned` (§Audit A4)
## 진행 중 메모
- 2026-06-12: /branch-spec 자동조사 — wiki-decision-researcher 1회(대안 5개 비교) + wiki-source-summarizer 5회(신규 raw 5건). 비교 매트릭스 축: 인프라 의존 / 트랜잭션 commit 정합 / lease·timeout / reentrancy / Spring 생태계 통합 / 운영 복잡도.
- 대기업(국내) production 사례 공백 — Subskribe(미국 SaaS)·FireHydrant 영어권 사례만 확보. 토스/카카오/네이버 advisory-lock 사례는 검색 미발견 (추가 조사 후보).
- 2026-06-13 **Layer 1 (shared-contract) 완료** (이전 세션): `OperationalError.LOCK_ACQUISITION_TIMEOUT(Category.CONFLICT, 409, true)` 추가. D7 §Decision Evidence Map row 상태 갱신 미완이었음 — 본 세션에서 TODO 행 `actually-implemented` 로 정정.
- 2026-06-13 **Layer 2 (application-core) 완료** (ca-implementer): 3종 타입 신설 + 계약 테스트 10/10 통과.
- `dev.caskeleton.application.lock.DistributedLockPort` — D2/D4/D5/D6 javadoc 포함 (canonical usage + forbidden inverse)
- `dev.caskeleton.application.lock.DistributedLock extends AutoCloseable``close()` no checked exception
- `dev.caskeleton.application.lock.LockAcquisitionTimeoutException` (final, RuntimeException) — `key()`, `waitTime()`, `errorCode()→LOCK_ACQUISITION_TIMEOUT`
- TDD: `compileTestJava` 실패(29 error) 확인 후 구현 → `./gradlew :application-core:test` 10/10 PASS
- 테스트 수정 1건: `message_contains_waitTime``Duration.ofMillis(500).toString()` = `"PT0.5S"` (ISO-8601), "500" 포함 아님. 어설션을 `contains(waitTime.toString())` 로 정정.
- build.gradle 무수정 확인 (`:shared-contract` 이미 `implementation` 의존)
- Spring/JPA import 0 — 순수 `java.time` + `shared.error` 만 사용
- 2026-06-13 **Layer 3 (adapter-persistence) 완료** (ca-implementer): LockSettings + adapter + Config + V4 migration.
- `dev.caskeleton.adapter.persistence.lock.LockSettings``@Validated @ConfigurationProperties("ca-skeleton.lock")` record. compact-ctor: null→default(waitTime=3s, leaseTtl=30s), non-positive → `IllegalArgumentException`, cross-field leaseTtl < waitTime → `IllegalArgumentException`.
- `dev.caskeleton.adapter.persistence.lock.LockRegistryDistributedLockAdapter implements DistributedLockPort` — wraps any SI `LockRegistry`. `tryAcquire`: leaseTtl > configuredTtl guard → `IllegalArgumentException`; `l.tryLock(waitTime.toMillis(), MILLISECONDS)`; InterruptedException → restore interrupt + throw timeout; returns `l::unlock` lambda.
- `dev.caskeleton.adapter.persistence.lock.DistributedLockPersistenceConfig``@Configuration(proxyBeanMethods=false)`. in-process `@Primary @ConditionalOnProperty(... matchIfMissing=true)`; JDBC 3 beans `@ConditionalOnProperty(havingValue="true")`. SI types confined to adapter-persistence (implementation dep — invisible to app-bootstrap/application at compile time). `jdbcDistributedLock` intentionally NOT `@Primary` — app-bootstrap wraps in metrics decorator (cross-module contract).
- `V4__int_lock.sql` — SI 6.5 verbatim PostgreSQL DDL with header comment (D3/D4 + TTL note). V1/V3 present, V2 absent; V4 is correct next.
- `LockRegistryDistributedLockAdapterTest` — 5 unit tests over `DefaultLockRegistry` (no Spring context, no DB). TDD: red(`compileTestJava` 7 errors confirmed) → green(5/5 PASS). Key test: concurrent timeout via CountDownLatch (deterministic, no sleep).
- SI 6.5 TTL finding: `DefaultLockRepository.setTimeToLive(int ms)` is repository-level; per-lock `lock(Duration)` API does not exist in 6.5 (SI 7.0+). configuredTtl guard in adapter prevents callers from overpromising per-call lease.
- `verifyCleanArchitectureDependencies` not run (build.gradle not modified); `./gradlew :adapter-persistence:test` full suite PASS.
- 2026-06-13 **Layer 4 (app-bootstrap) 완료** (ca-implementer): MeteredDistributedLockPort + DistributedLockConfig + StartupSafetyValidator 주석 + application.yml lock 기본값 + 3종 테스트.
- `dev.caskeleton.bootstrap.lock.MeteredDistributedLockPort implements DistributedLockPort``ObjectProvider<MeterRegistry>` no-op 패턴(`BackgroundJobMetrics` 동일). 상수: `LOCK_ACQUISITION="lock.acquisition"`, `TAG_OUTCOME="outcome"`, `OUTCOME_ACQUIRED/TIMEOUT/ERROR`. catch `LockAcquisitionTimeoutException`→TIMEOUT, catch other `RuntimeException`→ERROR, success→ACQUIRED; `increment()` swallows meter errors.
- `dev.caskeleton.bootstrap.lock.DistributedLockConfig``@Configuration(proxyBeanMethods=false)`. `@Bean("distributedLockProvider") @Primary @ConditionalOnProperty(prefix="ca-skeleton.runtime", name="multi-instance-enabled", havingValue="true")`. `@Qualifier("jdbcDistributedLock")` 주입 → `MeteredDistributedLockPort` 래핑.
- `StartupSafetyValidator.java` 주석 수정 — `distributedLockProvider` 행 코멘트를 runtime-health → `feature-distributed-lock-contract (D1/D3 — JdbcLockRegistry distributed lock; in-process default when single-instance)` 로 갱신. §Audit A1 해소.
- `application.yml` lock 블록 추가 — `ca-skeleton.lock: wait-time: 3s / lease-ttl: 30s`. 코드 기본값과 일치(APP_* env 키 미등록 — env-driven-runtime-configuration 소관). `ca-skeleton.runtime:` 블록 아래.
- `app-bootstrap/build.gradle``testImplementation 'org.springframework.integration:spring-integration-jdbc'` 추가. 이유: SI 타입(`DefaultLockRepository`/`JdbcLockRegistry`)이 adapter-persistence `implementation` 의존이라 app-bootstrap 컴파일 classpath 에 미노출. `DistributedLockProviderContractTest` 가 두 개의 독립 registry 인스턴스(두 앱 인스턴스 시뮬레이션)를 직접 빌드하는 데 필요.
- TDD: `MeteredDistributedLockPortTest` 6개 먼저 작성(compileTestJava 실패) → 구현 → 6/6 PASS. `LockAcquisitionTimeoutClassificationContractTest` 5개 → 5/5 PASS. `DistributedLockProviderContractTest` 4개 → 4/4 PASS.
- **핵심 발견: `DefaultLockRepository` Spring 컨텍스트 외부 초기화** — `readCommittedTransactionTemplate``InitializingBean.afterPropertiesSet()` 이 아니라 `SmartInitializingSingleton.afterSingletonsInstantiated()` 에서 생성된다. Spring 컨텍스트 없이 쓸 때는 `setTransactionManager()``afterPropertiesSet()``afterSingletonsInstantiated()``start()` 순서를 명시 호출해야 한다. 누락 시 D3/D5 테스트에서 `CannotAcquireLockException(NullPointerException: readCommittedTransactionTemplate)` 발생.
- 사전 기존 ArchUnit 실패: `outbound_adapter_method_returns_only_domain_or_primitives``OutboundHttpSettings.retry()/.circuitBreaker()` 가 adapter.outbound 내 nested record 반환. commits d702572/2613561/907dfad (이 task 이전) 에서 발생. 본 task 범위 외.
- `verifyCleanArchitectureDependencies verifyEnvKeys` PASS (build.gradle 수정 → verifyCleanArchitectureDependencies 필수). `app-bootstrap` 전체 suite: 274 tests, 1 pre-existing failure.
- 2026-06-13 **Quality-review remediation (ca-implementer)**: Finding 1 (Critical SI-LOCK-C5) + Finding 2 (Important — D5 flaky sleep + SI-LOCK-C5 coverage) + Minor #4 해소.
- `MeteredDistributedLockPort` 변경: `java.util.ConcurrentModificationException` import (JDK — no SI import in main src). `tryAcquire``() -> closeHandlingLeaseExpiry(key, handle)` wrapping lambda 반환. `closeHandlingLeaseExpiry`: CME 만 catch → log.warn + `incrementLeaseExpired()`; 다른 예외 전파. `incrementLeaseExpired()`: 동일 null-guard + try-catch-log-and-swallow 패턴. `LOCK_LEASE_EXPIRED="lock.lease.expired"` 상수 신설.
- `MeteredDistributedLockPortTest` 변경: 기존 2개 테스트의 `isSameAs(expectedHandle)` 어설션 → wrapping lambda 인식하도록 `isNotNull() + close() 정상` 검증으로 교체. 신규 4종: ① `lock_lease_expired_constant_matches_registry_name` (pinning), ② `close_swallows_CME_and_increments_lease_expired_counter`, ③ `close_propagates_non_CME_exception_unchanged`, ④ `close_swallows_CME_when_no_registry_is_present`. → 10/10 PASS.
- `DistributedLockProviderContractTest` 변경: D5 test — `Thread.sleep(+500)` 후 단일 시도 → 수면 후 bounded poll(최대 shortTtl×4, 200ms 간격). intentional discard `@SuppressWarnings("unused")` 변수 명명 추가(Minor #4). 신규 2종: `si_lock_c5_raw_adapter_close_throws_CME_after_lease_expires` (raw CME 문서화) + `si_lock_c5_metered_port_swallows_CME_and_increments_lease_expired_counter` (metered 흡수+카운터). cross-package로 `LOCK_LEASE_EXPIRED` 상수 접근 불가 → 리터럴 `"lock.lease.expired"` 사용 (MeteredDistributedLockPortTest 의 pinning test 가 drift 방지 역할). → 6/6 PASS.
- `app-bootstrap` 전체 suite: 280 tests, 1 pre-existing failure (`outbound_adapter_method_returns_only_domain_or_primitives`).
- `LOCK_LEASE_EXPIRED` 상수 visibility: package-private (기존 상수 패턴 유지). cross-package 테스트는 리터럴 직접 사용 + same-package pinning test 로 drift 방지.
## 결정 사항
> 추후 면접/회고에서 "왜 이렇게 했나" 답할 근거. 대안과 함께 기록. 각 결정의 근거는 위 Sources 또는 새로 추가된 raw 자료를 가리킬 것.
- 2026-06-12 (D1): 본 branch 가 `distributedLockProvider` bean 계약의 SSOT owner — background-job §Audit A7 의 owner 공백 해소 / 이유: 5개 coordination bean 중 유일하게 owner 부재, 코드 주석의 runtime-health 표기는 stale / 대안: runtime-health 가 소유(그 노트가 consume-only 자기 서술이라 기각) / 근거: ca-tmpl `StartupSafetyValidator.java` 코드 + [[raw/branch-notes/feature-background-job-async-contract]] §Audit A7
- 2026-06-12 (D2): lock 접근은 application-core port 경유 — `obtain(key) → java.util.concurrent.locks.Lock` 시맨틱 / 이유: CA 레이어 규칙 + provider 교체 가능성 / 대안: 구현체 직접 사용(레이어 위반 기각) / 근거: [[raw/official-docs/lock-spring-integration-lock-registry]]
- 2026-06-12 (D3): multi-instance 기본 provider = Spring Integration `JdbcLockRegistry`(PG baseline 재사용), Redis 활성 시 `RedisLockRegistry`/Redisson 교체 허용 / 검토 대안 5: PG session advisory(배제 — rollback 비해제·dangling), PG xact advisory(D4 의 보조 경로로 한정), JdbcLockRegistry(채택), ShedLock(배제 — maintainer 거부 + skip 시맨틱), Redisson(Redis-활성 분기) / 근거: [[raw/official-docs/lock-spring-integration-lock-registry]], [[raw/official-docs/lock-shedlock-issue-899-non-scheduler-use]], [[raw/official-docs/lock-postgres-advisory-locks]]
- 2026-06-12 (D4): 트랜잭션 commit 정합 불변식 — lock 해제는 보호 대상 tx 의 commit *이후*에만. tx-scope 일치 use case 는 `pg_advisory_xact_lock` 허용(자동 해제), session-level advisory 는 도입 금지 / 근거: [[raw/official-docs/lock-postgres-advisory-locks]] (PG-ADV-C2/C3)
- 2026-06-12 (D5): 획득 계약 = try-lock + 유한 waitTime + lease(TTL) 필수, 무한 blocking 금지 / 근거: PG-ADV-C4, LOCK-C2, SI-LOCK-C4/C5, SUBSKRIBE-LOCK-C2
- 2026-06-12 (D6): 본 lock 은 efficiency lock 전용 — correctness 는 DB 제약(unique/optimistic lock)으로, fencing token 미도입 / 근거: LOCK-C4 (Kleppmann, `engineering-blog` — 재확인 보류 상태 명시)
- 2026-06-12 (D7): lock 획득 실패 error code `LOCK_ACQUISITION_TIMEOUT` (category `CONFLICT`, retryable true) + metric `lock.acquisition`**registry 에 없는 신규 제안** (기존 값 단정 아님, registry-governance 절차 경유)
- 2026-06-12 (D8): domain-core·application-core 에서 lock 구현체 패키지 의존 금지 (정적 강제 요구) — rule 호스팅은 `feature-architecture-enforcement-rules` SSOT 에 위임
## 결정-근거 매핑
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시한다.
> `Decision ID` 는 이 branch-note 안에서 안정적으로 유지한다. 예: `D1`, `D2`.
> `Supporting Claims` 는 `raw/<category>/<slug>.md#C1` 형식으로 연결한다.
> `선택 조건` 열(R2): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | 본 branch = `distributedLockProvider` bean 계약 SSOT owner (A7 해소). bean 이름은 `StartupSafetyValidator.REQUIRED_MULTI_INSTANCE_BEANS` 기존 값 `"distributedLockProvider"` 재사용 | N/A — owner 공백 해소 (다른 branch 가 이미 소유했다면 본 branch 신설 불요였음) | ca-tmpl `src/app-bootstrap/.../StartupSafetyValidator.java` (code fact) + `raw/branch-notes/feature-background-job-async-contract.md` §Audit A7 | `internal-code-fact + sibling-audit` (외부 출처 비대상 — 내부 ownership 결정) | 코드 주석의 owner 표기가 runtime-health 로 stale (§Audit A1 — ca-tmpl 갱신 필요) |
| D2 | lock 접근은 application-core port 경유, `obtain(key) → java.util.concurrent.locks.Lock` 시맨틱 (LockRegistry 모델 차용) | 구현체가 j.u.c.Lock 호환을 제공하는 한 이 결정. 호환 불가 provider 도입 시(예: skip-시맨틱) port 시그니처 재설계 | `raw/official-docs/lock-spring-integration-lock-registry.md#SI-LOCK-C1`, `raw/official-docs/cache-redisson-rlock-vs-setnx.md#LOCK-C3` (Redisson 도 j.u.c.Lock — 이식성 방증) | `official-vendor-doc` (SI) + `needs-confirmation` (LOCK-C3) | port 명명·메서드 모양은 `UNSUPPORTED_IMPL_DECISION` (§구현 가이드 1) |
| D3 | multi-instance 기본 provider = `JdbcLockRegistry` (PG baseline 재사용, 추가 인프라 0). Redis 활성 시 `RedisLockRegistry`/Redisson 교체 허용. ShedLock·PG session-level advisory 배제 | `APP_MULTI_INSTANCE_ENABLED=true` + Redis 비활성 → JdbcLockRegistry; Redis 활성(cache 활성) → RedisLockRegistry/Redisson 교체 가능; flag=false(default) → bean 불요, in-process 구현으로 충분 | `raw/official-docs/lock-spring-integration-lock-registry.md#SI-LOCK-C2`, `#SI-LOCK-C3`, `raw/official-docs/lock-shedlock-issue-899-non-scheduler-use.md#SHEDLOCK-899-C1`, `#SHEDLOCK-899-C2` (ShedLock 배제), `raw/official-docs/lock-postgres-advisory-locks.md#PG-ADV-C2`, `#PG-ADV-C5` (session-level 배제), `raw/company-tech-blogs/lock-subskribe-advisory-lock-distributed-consensus.md#SUBSKRIBE-LOCK-C1` (DB-only 사례) | `official-vendor-doc + maintainer-statement + company-case-study` | `spring-integration-jdbc` 신규 의존성 + `INT_LOCK` DDL 관리 비용. SI 버전 ↔ Boot BOM 정합 미확인 (§Claims To Verify) |
| D4 | 트랜잭션 commit 정합 불변식: lock 해제는 보호 대상 작업의 DB commit **이후**에만. lock 수명 = 단일 tx 인 use case 는 `pg_advisory_xact_lock` 허용(commit/rollback 자동 해제). session-level advisory 의 수동 unlock 경로는 도입 금지 | lock scope ⊆ 단일 tx → xact advisory lock (자동 정합); lock scope ⊃ tx (여러 tx/외부 호출 포함) → JdbcLockRegistry + "획득 → tx → commit 반환 후 unlock" 순서 강제 | `raw/official-docs/lock-postgres-advisory-locks.md#PG-ADV-C2` (session-level 은 tx 시맨틱 무시 — rollback 후에도 잔존), `#PG-ADV-C3` (xact-level 은 tx 종료 시 자동 해제), `raw/company-tech-blogs/lock-subskribe-advisory-lock-distributed-consensus.md#SUBSKRIBE-LOCK-C4` (사례 보강) | `official-vendor-doc + company-case-study` | Spring `@Transactional` proxy 와 xact lock 의 실제 정합은 `locally-verified` 필요 (§Claims To Verify) |
| D5 | 획득 계약: try-lock + 유한 waitTime + lease(TTL) 필수. 무한 blocking 금지. lease 갱신은 보유 thread 만, lease 만료 후 unlock 은 예외 처리 의무 | N/A — 모든 획득 경로 공통. (lease 없는 lock 이 필요해지면 D6 correctness 경계 재검토가 선행) | `raw/official-docs/lock-postgres-advisory-locks.md#PG-ADV-C4` (try 변형 존재), `raw/official-docs/cache-redisson-rlock-vs-setnx.md#LOCK-C2` (TTL = crash 시 deadlock 회피), `raw/official-docs/lock-spring-integration-lock-registry.md#SI-LOCK-C4` (갱신은 보유 thread 만), `#SI-LOCK-C5` (만료 후 unlock → `ConcurrentModificationException`), `raw/official-docs/lock-shedlock-readme.md#SHEDLOCK-C3`, `#SHEDLOCK-C4` (lease 상·하한 원리 참조), `raw/company-tech-blogs/lock-subskribe-advisory-lock-distributed-consensus.md#SUBSKRIBE-LOCK-C2` (try-only 운영 사례) | `official-vendor-doc + official-reference + company-case-study` | 구체 default 값(waitTime/TTL)은 `UNSUPPORTED_IMPL_DECISION` (§구현 가이드 4) |
| D6 | 본 lock 은 **efficiency lock 전용**. correctness 가 필요한 경로는 DB 제약(unique constraint = `DB_UNIQUE_VIOLATION`, optimistic lock = `PRECONDITION_FAILED` 기존 계약)으로 보장. fencing token 미도입 | 중복 *작업* 방지(비용 절감) 목적 → 본 lock; 중복 *결과* 차단(정합성) 필요 → DB 제약 사용. fencing token 이 필요한 외부 시스템 mutation 등장 시 본 결정 재검토 | `raw/official-docs/cache-redisson-rlock-vs-setnx.md#LOCK-C4` (Kleppmann: lease 기반 correctness 는 unsafe, efficiency 는 충분), `raw/official-docs/lock-shedlock-readme.md#SHEDLOCK-C5` (clock 동기화 *가정* — lease 기반의 전제 한계 방증) | `engineering-blog` (LOCK-C4 — verbatim 재확인 보류) + `official-reference` | LOCK-C4 의 verbatim 재확인 불가 상태 지속 (cache branch 와 공동 — archive.org 스냅샷 필요) |
| D7 | lock 획득 실패/timeout 의 error code = `LOCK_ACQUISITION_TIMEOUT` (category `CONFLICT`, retryable true, client_safe true) + metric `lock.acquisition` (tag: outcome) — **registry 신규 제안** | N/A — 단 registry-governance 검토에서 기존 code 재사용 판정 시 그 code 채택 | `UNSUPPORTED_IMPL_DECISION` — registry(`error-codes.yaml`·`metrics.yaml`)에 일반 lock 항목 부재 확인(2026-06-12 grep). category `CONFLICT` 는 기존 enum(`shared/error/Category.java`) 재사용, code/metric *이름* 은 근거 없는 신규 제안 | `none` (신규 제안 — 기존 값 단정 금지) | registry-governance 절차 미통과 상태. cache 의 `CACHE_STAMPEDE_LOCK_TIMEOUT` 과 의미 경계 문서화 필요 |
| D8 | domain-core·application-core 에서 lock 구현체 패키지(`org.springframework.integration..`, `org.redisson..`, `net.javacrumbs.shedlock..`) 의존 + advisory SQL 직접 호출 금지 — adapter 전용. rule 호스팅은 [[raw/branch-notes/feature-architecture-enforcement-rules]] SSOT 위임 (본 branch 는 요구사항만 등록) | N/A — D2 port 결정의 정적 강제 도출 | D2 의 도출 + ca-tmpl `CLAUDE.md` 의존 방향 매트릭스 (code fact). rule *명명*`UNSUPPORTED_IMPL_DECISION` | `internal-code-fact` (모듈 매트릭스) | rule 이 architecture-enforcement-rules 에 실제 등록되기 전까지 `documented-only` |
## 구현 가이드
> *결정 (Decisions)* 이 "*무엇* 을 할 것인가" 라면, 본 §는 "*어디에 어떻게* 구현될 것인가" 의 사전 명세. 3-rule meta principle (R1 Reference / R2 UNSUPPORTED_IMPL_DECISION / R3 OUT_OF_BRANCH_SCOPE) 준수 — CLAUDE.md §15.5.
> 구현 상태: 본 § 전체가 **`planned`** — src grep 실측(2026-06-12) 결과 lock 관련 구현은 `StartupSafetyValidator` 의 bean-presence 검사뿐, port/adapter/registry 코드는 전무. `actually-implemented` 로 표현 금지.
### 1. Port · adapter · wiring 배치 (D1
> **Trace**: D1 (bean 이름 = code 기존 값) + D2 (port 추상화 — SI-LOCK-C1) + D8 (구현체 격리)
>
> - **UNSUPPORTED_IMPL_DECISION**: ① port 명명 `DistributedLockPort` + 메서드 `tryAcquire(key, waitTime, ttl)` 모양 — 근거 raw 는 *추상화 원칙*(obtain→Lock)만 권고, 명명은 임의 (trade-off: sibling port 명명 패턴 `*Port` 정합). ② 모듈 배치 — adapter 구현을 `adapter-persistence` 에 두는 것은 "JDBC 기반"이라는 도출이지 raw 권고 아님 (trade-off: lock 저장소 = DB 이므로 persistence 인접이 의존 방향 최소).
| 항목 | 명세 | 상태 |
|---|---|---|
| port 인터페이스 | `application-core``DistributedLockPort` (가칭): `tryAcquire(String key, Duration waitTime, Duration ttl)` → lock handle (j.u.c.Lock 호환) | `planned` |
| adapter 구현 | `adapter-persistence``JdbcLockRegistry` wrapping (D3). Redis 분기 구현은 Redis 활성 모듈에 별도 | `planned` |
| bean wiring | `app-bootstrap` — bean 이름 **`distributedLockProvider`** (code 기존 값 — `StartupSafetyValidator.REQUIRED_MULTI_INSTANCE_BEANS[0]`). `APP_MULTI_INSTANCE_ENABLED=true` 일 때만 등록 | `planned` |
| single-instance 경로 | flag=false(default) 시 in-process 구현(SI `DefaultLockRegistry` 동등 시맨틱)으로 port 계약 유지 — bean presence 강제 대상 아님 (env D8 consume) | `planned` |
### 2. Provider 선택 분기 (D3)
> **Trace**: D3 — SI-LOCK-C2 (4종 공식 구현체), SI-LOCK-C3 (JdbcLockRegistry 분산 락), SHEDLOCK-899-C1/C2 (ShedLock 배제), PG-ADV-C2/C5 (session-level 배제), SUBSKRIBE-LOCK-C1 (DB-only 사례)
| 조건 | provider | 비고 |
|---|---|---|
| `APP_MULTI_INSTANCE_ENABLED=false` (default) | in-process (SI `DefaultLockRegistry` 동등) | 분산 조정 불요 — single-instance 계약 |
| flag=true + Redis 비활성 | **`JdbcLockRegistry`** (채택 기본값) | PG baseline 재사용, 추가 인프라 0. `INT_LOCK` 테이블 필요 (DDL 은 migration-startup 계약 경유) |
| flag=true + Redis 활성 | `RedisLockRegistry` 또는 Redisson RLock | port 불변, 구현체만 교체 (SI-LOCK-C2). Redisson 채택 시 cache branch 의존성 재사용 |
| (배제) ShedLock | — | maintainer 가 generic lock 공식 선언 거부(SHEDLOCK-899-C1) + 획득 실패 시 *skip* 시맨틱으로 blocking 계약 불일치(SHEDLOCK-899-C2). scheduler 영역 사용은 background-job D3 소유로 불변 |
| (배제) PG session-level advisory | — | tx rollback 에도 잔존(PG-ADV-C2) + dangling lock 위험(PG-ADV-C5) + pool 반납 시 leak 경로 |
### 3. 트랜잭션 commit 정합 패턴 카탈로그 (D4)
> **Trace**: D4 — PG-ADV-C2 (session = tx 무시), PG-ADV-C3 (xact = 자동 해제), SUBSKRIBE-LOCK-C4 (사례)
| 패턴 | 판정 | 이유 |
|---|---|---|
| lock 획득 → `@Transactional` 작업 → commit 반환 **후** finally unlock | ✅ 허용 (general 경로) | 해제가 commit 에 후행 — 임계 구역이 commit 전에 열리지 않음 |
| `pg_advisory_xact_lock` 을 tx 내부에서 획득 | ✅ 허용 (tx-scope 경로) | commit/rollback 시 자동 해제 (PG-ADV-C3) — 정합을 DB 가 보장 |
| tx **내부**에서 general lock 해제 (commit 전 unlock) | ❌ 금지 | 미commit 상태에서 다른 인스턴스가 임계 구역 진입 — lost update 류 race |
| session-level advisory lock + 수동 unlock | ❌ 금지 | rollback 에도 잔존(PG-ADV-C2) + unlock 누락 시 pool 반납 leak. 본 계약에서 경로 자체 미도입 |
### 4. 획득·해제 계약 + 실패 매핑 (D5
> **Trace**: D5 — PG-ADV-C4, LOCK-C2, SI-LOCK-C4/C5, SHEDLOCK-C3/C4, SUBSKRIBE-LOCK-C2. D7 — registry 부재 확인(2026-06-12 grep).
>
> - **UNSUPPORTED_IMPL_DECISION**: waitTime/TTL default 값 (예: waitTime 3s / TTL 30s) — 어떤 raw 도 구체 값을 권고하지 않음 (trade-off: Redisson watchdog default 30s 와 LOCK-C1 의 PX 30000 을 관행 참고치로만 사용, 측정 후 조정). error code `LOCK_ACQUISITION_TIMEOUT`·metric `lock.acquisition` *이름* — registry 신규 제안 (기존 값 아님을 명시). Jdbc 분기 long-task 의 `renewLock` 호출 *주기* — SI 7.0+ 의 `lock(Duration ttl)` API 존재는 raw 가 보장하나 갱신 주기 값은 임의 (trade-off: TTL 의 1/3 주기 관행 참고, 측정 후 조정).
| 항목 | 계약 | 상태 |
|---|---|---|
| 획득 | try-lock + 유한 waitTime 필수. 무한 blocking API 노출 금지 (PG-ADV-C4 의 try 변형 + SUBSKRIBE-LOCK-C2 운영 교훈) | `planned` |
| lease | TTL 필수 — 보유자 crash 시 자동 만료 (LOCK-C2, SHEDLOCK-C3 원리) | `planned` |
| 갱신 | 보유 thread 만 (SI-LOCK-C4). 자동 watchdog 은 Redisson 분기에서만 (LOCK-C3 — `needs-confirmation`) | `planned` |
| Jdbc 분기 long-task 갱신 | **Jdbc 분기에는 자동 watchdog 이 없음** — lock 보유 시간이 TTL 을 넘을 수 있는 작업은 ① 명시적 `renewLock` 주기 호출(보유 thread, SI-LOCK-C4) 또는 ② TTL ≥ 최대 작업 시간 보장 중 하나를 선택. 주기 값은 `UNSUPPORTED_IMPL_DECISION` (위 헤더) | `planned` |
| 만료 후 해제 | `ConcurrentModificationException` 처리 의무 (SI-LOCK-C5) — 삼킴 금지, 로그 + metric | `planned` |
| 실패 매핑 | timeout → `LOCK_ACQUISITION_TIMEOUT` (**신규 제안** — category `CONFLICT` 기존 enum 재사용, retryable true). registry-governance 통과 전 코드 작성 금지 | `planned` (제안 단계) |
| metric | `lock.acquisition` (tag: `outcome` = acquired/timeout/error) — **신규 제안**. 기존 `metrics.yaml` 에 lock 항목 없음 확인 | `planned` (제안 단계) |
### 5. Contract test 계약 (D1
> **Trace**: D1 (bean presence) + D3 (provider 분기). env D8 의 `StartupSafetyValidator` 집행을 consume — 검사 메커니즘 자체는 env branch 소유 (OUT_OF_BRANCH_SCOPE).
| 테스트 | 검증 내용 | 상태 |
|---|---|---|
| bean presence | `APP_MULTI_INSTANCE_ENABLED=true``distributedLockProvider` bean 부재 → startup fail (기존 `StartupSafetyValidatorTest` 는 이름 기반 presence 만 검증 — 본 branch 는 *실제 bean 등록* 쪽 테스트 추가) | `planned` |
| 상호 배제 | 동일 key 에 2 인스턴스(2 DataSource 컨텍스트) 경쟁 → 1개만 획득 | `planned` |
| commit 정합 | tx 미commit 상태에서 두 번째 획득 시도가 성공하지 않음 (D4 패턴 ✅① 검증) | `planned` |
| lease 만료 | TTL 경과 후 두 번째 인스턴스 획득 가능 + 원 보유자 unlock 시 CME 처리 (SI-LOCK-C5) | `planned` |
## Audit & Findings
> 이관 history + drift 기록 (CLAUDE.md §15.5 R3). §구현 가이드에는 in-scope 만 남기고, 범위 밖/정정/전파는 여기 보존.
- **A1. `STALE_CODE_COMMENT` (drift)** — ca-tmpl `StartupSafetyValidator.java``"distributedLockProvider"` 행 주석이 `feature-runtime-health-lifecycle-contract` 를 owner 로 표기 — 그 노트는 "consume only" 자기 서술(background-job §Audit A7 발견). 본 branch 가 owner 로 확정되었으므로 **코드 주석을 본 branch 로 갱신 권고** (ca-tmpl 측 변경 — 자동 수정 안 함, 정합 권고만).
- **A2. `RESEARCH_CORRECTION`** — 선행 조사(wiki-decision-researcher)가 "ShedLock = scheduler 전용 *공식 입장*"으로 요약했으나 README verbatim(SHEDLOCK-C2 "it's just a lock")은 그 표현을 지지하지 않음. issue #899 verbatim 으로 정정: 배제의 실근거 = *generic lock 공식 선언 거부*(SHEDLOCK-899-C1) + *skip(비대기) 시맨틱*(SHEDLOCK-899-C2). 커뮤니티의 non-scheduler production 사용 보고(SHEDLOCK-899-C4)도 존재 — "기술적 불가"가 아니라 "공식 비지원 + 시맨틱 불일치"가 배제 이유.
- **A3. `OUT_OF_BRANCH_SCOPE` 이관 기록** — ① scheduler/outbox lock 적용 정책 → background-job D3 (불변). ② cache stampede lock + `CACHE_STAMPEDE_LOCK_TIMEOUT` → cache-consistency D3/D4 (불변). ③ `APP_MULTI_INSTANCE_ENABLED` + validator 집행 → env-driven D8 (consume). ④ `INT_LOCK` DDL 의 migration *절차* → migration-startup-contract (본 branch 는 DDL 필요 사실만 제안). ⑤ ArchUnit rule 호스팅 → architecture-enforcement-rules (D8 은 요구사항만).
- **A4. `PROPAGATION_NOTICE` (비차단)** — background-job §테스트 계약·§구현 가이드 4 의 테스트 FQCN `net.javacrumbs.shedlock.core.LockProvider` 는 "ShedLock 또는 동등 bean" 가정 시절의 표기. 본 branch D3 가 `JdbcLockRegistry` 를 기본 채택했으므로 그 테스트 계약의 FQCN 은 port/bean 기준으로 갱신 필요. 동일하게 project-note §27 의 "ShedLock + Redisson + …" 5종 나열도 "distributedLockProvider(본 branch D3)" 로 읽도록 전파 대상. **비차단** — owner(background-job·env·project note) 가 다음 편집 시 반영.
- **A5. `NEW_BRANCH_REGISTRATION`** — parent project §29.E row #9 가 본 branch 를 `(없음)` 예정으로 표기 + §25 SSOT Owner Map 에 distributed lock row 부재. 본 branch 신설로 §31.1 Cluster list + §25 Owner Map + §29 row 상태 갱신 필요 (project-note 사용 절차 #4 의무 — 본 세션에서 최소 반영 또는 다음 project-note 편집 시).
## 엣지·실패·의존
> R4(깊이 게이트) 캡처용. 정상 경로 외에 *구현 중 부딪힐* 실패/엣지/다른 계약 의존을 미리 열거.
- **실패·엣지 경로**:
- 획득 timeout → `LOCK_ACQUISITION_TIMEOUT`(신규 제안) 반환, retryable true — 호출측 재시도 정책은 호출 branch 소유
- lease 만료 *중* 작업 진행 — 두 보유자 동시 진입 가능. D6 efficiency 경계로 *허용*하되 correctness 필요 경로는 DB 제약이 최종 방어 (LOCK-C4)
- lease 만료 후 unlock → `ConcurrentModificationException` (SI-LOCK-C5) — 삼킴 금지, 로그+metric 후 정상 흐름 복귀
- JVM crash → lock row 는 TTL 로 자동 만료 (LOCK-C2/SHEDLOCK-C3 원리) — 잔존 lock 수동 정리 runbook 불요 설계
- clock skew — lease 판정이 노드 시계에 의존하면 SHEDLOCK-C5 의 동기화 가정 필요 → DB 시간 기준 여부 확인 (§Claims To Verify)
- 동일 thread 재진입 — `JdbcLockRegistry` 의 reentrancy 보장 미확인 (§Claims To Verify) — 보장 확인 전까지 재진입 금지 계약
- connection pool 고갈 — lock 대기가 DB connection 을 점유하는 구현(advisory blocking)은 배제됨(D3/D5) — JdbcLockRegistry 의 lock 당 connection 사용 패턴은 확인 필요
- **다른 계약 의존**:
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] D8 — `APP_MULTI_INSTANCE_ENABLED` flag + `StartupSafetyValidator` presence 강제를 consume. flag 의미/집행 변경 시 본 branch bean 등록 조건 영향
- [[raw/branch-notes/feature-background-job-async-contract]] D3 — scheduler/outbox 가 본 branch 의 provider 를 consume (§Audit A4 전파)
- [[raw/branch-notes/feature-cache-consistency-contract]] D3 — Redis 활성 분기에서 Redisson 의존성 공유. cache 가 Redisson 을 제거하면 본 branch Redis 분기 재검토
- `feature-migration-startup-contract``INT_LOCK` DDL 의 Flyway 반영 절차
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — D8 rule 호스팅
## 검증해야 할 주장
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
> 구현 전/중/후에 실제로 검증해야 하는 주장을 분리한다.
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| `spring-integration-jdbc` 가 ca-tmpl Boot BOM 과 호환 + TTL API(`lock(Duration ttl)`, SI 7.0+) 사용 가능 | SI 버전·TTL API 도입 시점과 현재 BOM 미대조 | `build.gradle` 의존성 추가 후 컴파일 + `JdbcLock` TTL 메서드 존재 확인 | `needs-confirmation` |
| `INT_LOCK` 테이블 DDL 은 자동 생성되지 않아 Flyway 수동 migration 필요 | 공식 문서에서 schema 자동 생성 여부 미확인 | SI 배포 schema 스크립트 위치 확인 + 로컬 기동 테스트 | `needs-confirmation` |
| `pg_advisory_xact_lock` 이 Spring `@Transactional` commit 시점에 자동 해제 (D4 ✅② 경로) | proxy 기반 tx 경계와 PG 세션의 실제 상호작용 미검증 | 2-connection 경쟁 통합 테스트: tx A 보유 중 tx B 획득 실패 → A commit 후 B 획득 성공 | `needs-confirmation` |
| `JdbcLockRegistry` 의 동일 thread 재진입 보장 여부 | SI-LOCK-C1 은 j.u.c.Lock 반환만 보장, reentrancy 는 "Does not prove" 명시 | 공식 Javadoc/소스 확인 + 재진입 단위 테스트 | `needs-confirmation` |
| Redisson RLock watchdog 시맨틱 (LOCK-C3) | redisson.org → redisson.pro redirect 차단으로 verbatim 재확인 불가 (cache branch 공동 관심) | Redisson Javadoc 직접 다운로드 또는 GitHub wiki 로 verbatim 격상 | `needs-confirmation` |
| `JdbcLockRegistry` 의 lock 대기가 DB connection 을 점유하는지 (polling 마다 반납 vs holding) | retry-polling(idleBetweenTries) 구조라 점유 패턴 미확인 — holding 이면 pool 고갈 시 self-deadlock 경로 | SI 소스/Javadoc 확인 + pool size 1 로 죄인 통합 테스트에서 동시 lock 대기 시 고갈 여부 관찰 | `needs-confirmation` |
| flag=true + bean 등록 시 `StartupSafetyValidator` 가 실제 통과 (이름 기반 presence) | 현재 테스트는 *부재 → fail* 만 검증, *등록 → pass* 는 bean 타입 무관 이름만 매칭 | `StartupSafetyValidatorTest` 확장 + 실제 adapter bean 으로 기동 테스트 | `planned` |
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
> `/coverage` 가 채우는 **생성물** — 손으로 유지하지 않는다. governing 문서(frontmatter `governing_docs`)가 요구하는 관심사를 이 브랜치가 빠짐없이 덮는지의 결과. 기준: `rules/coverage-gate.md`.
> 상태: `covered-here`(이 브랜치 결정) / `delegated`(다른 owner 브랜치) / `missing`(아무도 안 맡음 → Blocking).
| 관심사 | 상태 | owner | 심각도 | 근거 |
|--------|------|-------|--------|------|
| (생성 전 — `/coverage feature-distributed-lock-contract` 실행 대기) | — | — | — | — |
## 마주친 문제
> 짧은 메모만. 깊이 있는 트러블슈팅은 `raw/errors/` 로 분리하고 아래 Cluster에 연결.
- 2026-06-13 (Layer 2): `LockAcquisitionTimeoutExceptionTest.message_contains_waitTime` 첫 실행 실패. 원인: `Duration.ofMillis(500).toString()``"PT0.5S"` (ISO-8601) — `"500"` 을 포함하지 않음. 어설션을 `contains(waitTime.toString())` 로 수정 후 통과. raw/errors 별도 분리 불필요 (trivial one-liner 수정).
- 2026-06-13 (Layer 4): `DistributedLockProviderContractTest` D3/D5 테스트 — `CannotAcquireLockException(NullPointerException: readCommittedTransactionTemplate)`. `DefaultLockRepository` 를 Spring 컨텍스트 없이 사용할 때 `SmartInitializingSingleton.afterSingletonsInstantiated()` 를 명시 호출해야 함을 발견. 상세: [[raw/errors/spring-integration-defaultlockrepository-aftersingletons-null-template-2026-06-13]].
## 묶음 (이 branch에서 파생된 자료)
<!-- GENERATED: sources:start -->
- [[raw/company-tech-blogs/lock-subskribe-advisory-lock-distributed-consensus]]
- [[raw/official-docs/lock-postgres-advisory-locks]]
- [[raw/official-docs/lock-shedlock-issue-899-non-scheduler-use]]
- [[raw/official-docs/lock-shedlock-readme]]
- [[raw/official-docs/lock-spring-integration-lock-registry]]
<!-- GENERATED: sources:end -->
<!-- GENERATED: errors:start -->
- [[raw/errors/spring-integration-defaultlockrepository-aftersingletons-null-template-2026-06-13]]
<!-- GENERATED: errors:end -->
<!-- GENERATED: blog-topics:start -->
- [[raw/blog-topics/distributed-lock-transaction-commit-boundary-2026-07-02]]
<!-- GENERATED: blog-topics:end -->
> 이 branch는 단일 노트가 아니라 **작업 묶음의 entry point**. 이 branch에서 파생된 모든 raw 노트를 카테고리별로 명시.
### 근거 자료
- [[raw/official-docs/lock-postgres-advisory-locks]] — PostgreSQL §13.3.5 Advisory Locks + §9.28.10 함수 레퍼런스 (session-level vs transaction-level 시맨틱, non-blocking 변형)
- [[raw/official-docs/lock-shedlock-readme]] — ShedLock README: scheduled task 전용 락 / not full-fledged scheduler 공식 경계, `lockAtMostFor`/`lockAtLeastFor` lease 시맨틱, clock 동기화 전제 조건
- [[raw/official-docs/lock-shedlock-issue-899-non-scheduler-use]] — ShedLock Issue #899: maintainer 가 generic lock 공식 선언 거부 + skip semantics 명시 (SHEDLOCK-899-C1, SHEDLOCK-899-C2) — `distributedLockProvider` 후보에서 ShedLock 배제/허용 결정의 근거
- [[raw/official-docs/lock-spring-integration-lock-registry]] — Spring Integration LockRegistry/JdbcLockRegistry 공식 레퍼런스 (j.u.c.Lock 추상화, 4종 구현체, TTL/renewal/CME 시맨틱)
- [[raw/company-tech-blogs/lock-subskribe-advisory-lock-distributed-consensus]] — Subskribe production 사례: advisory lock 만으로 distributed mutual exclusion + optimistic try-lock only 교훈 (company-case-study — 공식 승격 금지)
- [[raw/official-docs/cache-redisson-rlock-vs-setnx]] — (cache branch 와 공유) Redisson RLock/SETNX/Redlock 비교 + Kleppmann efficiency vs correctness (LOCK-C1~C4)
### Sub-branches (세부 작업)
- (아직 없음)
### 오류 기록 (이 branch 작업 중 발생)
- [[raw/errors/spring-integration-defaultlockrepository-aftersingletons-null-template-2026-06-13]] — `DefaultLockRepository` Spring 컨텍스트 외부 초기화 시 `afterSingletonsInstantiated()` 누락 → `readCommittedTransactionTemplate` NPE. Layer 4 `DistributedLockProviderContractTest` 작성 중 발생, resolved.
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- "분산 락에서 lock 해제와 DB commit 의 순서가 왜 중요한가? lost-update race 를 설명하라" (D4 canonical pattern / forbidden inverse)
- "efficiency lock 과 correctness lock 의 차이는 무엇인가? 왜 DB unique constraint 가 최종 방어선인가?" (D6)
- "AutoCloseable 의 `close()``throws Exception` 인데, 왜 이 인터페이스는 그것을 재정의하여 unchecked 로 만들었는가?"
- "tryLock(waitTime) + leaseTtl 조합이 무한 blocking 과 deadlock 을 어떻게 방지하는가?" (D5)
- "finally 블록에서 예외를 던지면 왜 위험한가? 분산 락 해제 중 CME 를 re-throw 하지 않는 이유는?" (SI-LOCK-C5 / 정상 흐름 복귀)
- "Decorator 패턴에서 wrapping lambda 로 handle 을 교체할 때 기존 동일성 테스트(`isSameAs`)가 왜 깨지는가?" (quality-review remediation — MeteredDistributedLockPort)
### 강의 (이 작업을 위해 학습한 강의)
- (아직 없음)
### job-posting tie-ins (이 작업에서 파생된 글감)
- "ShedLock 은 분산 락이 아니다 — maintainer 의 입으로 확인한 skip 시맨틱" (SHEDLOCK-899-C1/C2)
- "분산 락과 트랜잭션: lock.close() 를 finally 에 두는 것만으로는 부족한 이유" (D4 forbidden inverse — commit 전 해제의 lost-update race)
- "Clean Architecture 에서 분산 락 추상화 — DistributedLockPort 가 JdbcLockRegistry 를 숨기는 방법" (D2/D8 port 설계)
- "Spring의 SmartInitializingSingleton: Spring 컨텍스트 없이 bean을 사용할 때 afterSingletonsInstantiated()를 직접 호출해야 하는 이유" (Layer 4 troubleshooting — DefaultLockRepository NPE)
- "finally 블록에서 예외를 삼키는 게 맞을 때도 있다 — JdbcLock lease-expiry CME 처리와 정상 흐름 복귀" (SI-LOCK-C5 / quality-review finding 1)
## 관련 일일 노트
> 이 브랜치를 작업한 날짜들. 양방향 nav 유지.
- (2026-06-12 생성 — daily 노트 미작성)
## 완료 후 정리
> 머지/종료 시점에 채움. `/ingest`가 이 섹션을 기준으로 wiki/projects/에 추출.
- PR 링크: (미생성 — 사용자가 커밋·PR 수행)
- 리뷰 메모: 2026-06-13 3단계 리뷰 체인 전부 `ready`
ca-architect-sentinel(PASS, 0 blocking/0 advisory: SI 가 adapter-persistence `implementation` 으로만 격리, app-bootstrap main 에 SI import 0, D8 모듈매트릭스 충족),
ca-spec-reviewer(PASS, 요구 20/20 met, missing/extra/misinterpreted 0),
ca-quality-reviewer(1차 NEEDS_FIX: Critical 1[SI-LOCK-C5] + Important 2 + Minor 2 → remediation 후 재리뷰 PASS, 0/0/0).
- 머지 결과 / 배포 환경: **로컬 검증 완료** (Testcontainers PG Docker 가용 — 통합 테스트 SKIP 아님, 실제 실행).
최종 gradle 검증(2026-06-13):
- `:shared-contract:test` / `:application-core:test` / `:adapter-persistence:test` — 전부 PASS
- `:app-bootstrap:test` — 280개 중 lock 관련 21개(Metered 10 + Provider 6 + Classification 5) 전부 PASS.
유일한 실패는 **선행 커밋(d702572 등)에서 유래한 무관한 ArchUnit 위반** `outbound_adapter_method_returns_only_domain_or_primitives`
(`OutboundHttpSettings.retry()/.circuitBreaker()` nested record) — `git stash` 후 clean HEAD 에서도 동일 실패 확인 → 본 branch 변경과 무관, 미수정(범위 밖, outbound branch 소유).
- `verifyCleanArchitectureDependencies` / `verifyEnvKeys` — PASS (env 키 신규 0; `ca-skeleton.lock.*` 은 APP_ 비매핑 plain yaml).
- registry 추가: `error-codes.yaml` `LOCK_ACQUISITION_TIMEOUT`(CONFLICT/409/retryable, D7) + `metrics.yaml` `lock.acquisition`(D7) + `lock.lease.expired`(§Edge/SI-LOCK-C5 — quality-review 후 추가, tagless counter).
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
- `actually-implemented` + `locally-verified` 항목 (2026-06-13 현재):
- `OperationalError.LOCK_ACQUISITION_TIMEOUT` (shared-contract) — Layer 1
- `DistributedLockPort` / `DistributedLock` / `LockAcquisitionTimeoutException` (application-core) — Layer 2
- 계약 테스트 10종 (application-core) — Layer 2
- `LockSettings` / `LockRegistryDistributedLockAdapter` / `DistributedLockPersistenceConfig` (adapter-persistence) — Layer 3
- `V4__int_lock.sql` (adapter-persistence) — Layer 3
- `LockRegistryDistributedLockAdapterTest` 5종 (adapter-persistence) — Layer 3
- `prod-verified` 항목: (없음)
- **추출하지 않을 항목** (planned / documented-only / abandoned):
- ArchUnit rule 호스팅 (`feature-architecture-enforcement-rules`) — planned
- background-job ShedLock FQCN 전파 알림 — planned
- **wiki/projects 추출 추가 대상** (quality-review remediation 이후 `actually-implemented` + `locally-verified`):
- Layer 4 완료분: `MeteredDistributedLockPort` (SI-LOCK-C5 포함) + `DistributedLockConfig` + `DistributedLockProviderContractTest` 6종 (2026-06-13)