20 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 빈으로 기여한다.
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 에 있다.
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 가 락을 잡을 수 있다.