init: 클린 아키텍처 백엔드

This commit is contained in:
DongHyeonka
2026-07-24 14:29:36 +09:00
parent 9eed16d097
commit 821fe00c32
971 changed files with 74769 additions and 1 deletions
@@ -0,0 +1,250 @@
# 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).
## 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 누설 없는 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` 에 있다.
## 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 가 락을 잡을 수 있다.