Files
clean-architecture-backend-…/docs/reviews/2026-08-14-jpa-module-code-review.md
T

95 KiB

JPA persistence 모듈 상세 코드·아키텍처 리뷰

  • 기준 일자: 2026-08-14
  • 기준 Git HEAD: 539e3eb58bed5db63e3a17f47eec213db2d2df79
  • 대상 Gradle leaf: :adapter:outbound:persistence-jpa
  • 주 대상 경로: src/adapter/outbound/persistence-jpa
  • 교차 확인 경로: src/application-core, src/app-bootstrap, src/config, .github/workflows, docs/jpa
  • 판정: CHANGES REQUIRED
  • 검토 방식: 전체 트리 정적 탐색, 핵심 실행 경로 정독, 3개 병렬 리뷰, source/test/CI 교차검증
  • 변경 범위: 이 리뷰 문서만 추가했다. production/test 코드는 수정하지 않았다.

1. 결론

이 leaf는 더 이상 단순한 JPA repository adapter가 아니다. transaction과 retry, completion evidence, Spring Data 확장, Hibernate provider 기능, PostgreSQL 전용 SQL, migration, idempotency/outbox/inbox, fileserver/notification persistence, observability/security, experimental multi-tenancy와 release evidence까지 한 Gradle leaf에 담은 관계형 persistence platform이다. framework-free API, typed policy, SQLSTATE 기반 분류, real PostgreSQL 계약 테스트, testkit source set처럼 보존할 설계도 많다.

그러나 현재 상태를 그대로 Stable 또는 production-ready라고 판단하면 안 된다. 가장 먼저 고쳐야 할 계약은 다음과 같다.

  1. release job이 PostgreSQL 16·17·18 전체 계약을 실행한다고 주장하지만 대부분의 테스트는 첫 버전인 16만 실행한다.
  2. notification migration은 기본 Flyway와 readiness 어디에도 연결되지 않았고, entity에는 없는 컬럼과 잘못된 JSONB mapping이 있으며 일부 Spring Data repository는 발견조차 되지 않는다.
  3. notification lease 갱신은 소유자·상태·fence를 검사하지 않아 만료된 worker가 새 owner의 lease를 다시 탈취할 수 있다.
  4. 새 JPA platform 구성 클래스는 이름과 달리 Spring configuration이 아니어서 transaction retry, completion evidence, observability, endpoint가 실제 runtime에 조립되지 않는다.
  5. application이 이미 사용하는 PolicyTransactionPort와 새 JPA executor/AOP 계약이 두 벌로 존재하고, 새 retry path에는 raw PostgreSQL/optimistic failure를 stable exception으로 바꾸는 실행 연결이 없다.
  6. transaction evidence stack은 manager와 executor가 같은 frame을 각각 pop할 수 있어 nested REQUIRES_NEW 뒤 outer reconciliation key를 잃을 수 있다.
  7. keyset sort와 predicate가 동일한 ordering specification을 공유하지 않아 mixed type과 mixed ASC/DESC를 올바르게 표현할 수 없다.

따라서 즉시 운영 원칙은 다음처럼 잡는 것이 안전하다.

  • 이 문서의 JPA-001~010을 해결하기 전에는 JPA platform/notification을 Stable로 승격하지 않는다.
  • PolicyTransactionPort를 application-facing transaction SSOT로 유지하고 병렬 transaction API를 더 확산시키지 않는다.
  • notification V1~V3가 어느 환경에도 적용되지 않았다는 증거가 없으면 기존 SQL을 수정하지 않고 V4 forward migration으로 수습한다.
  • package 대이동은 correctness 수정 뒤에 한다. 현재 19-leaf registry를 임의로 늘리지 않는다.
  • unit test 통과를 runtime bean 조립, migration upgrade, PostgreSQL version compatibility의 증거로 해석하지 않는다.

2. 범위와 증거 경계

2.1 현재 규모

항목 현재 값
production Java 파일 324
production Java LOC 23,086
일반 unit-test Java 파일 84
PostgreSQL integration-test Java 파일 49
testkit Java 파일 38
performance-test Java 파일 3
production 최상위 package 22
public top-level type 선언 파일 318 / 324

최상위 package는 api, audit, auditing, cache, config, envers, experimental, failure, fileserver, h2, hibernate, idempotency, lock, migration, notification, observation, outbox, postgresql, querydsl, security, springdata, transaction이다.

2.2 검토 깊이

영역 상태 대표 근거
registry/build/source set/runtime membership READ_FULL modules.json, JPA build.gradle, root JPA tasks
application transaction port와 JPA transaction/retry/evidence READ_FULL port/result algebra, 두 executor 계열, manager/classifier/interceptor와 tests
Spring runtime composition READ_FULL main scan, JPA factory/config/settings/endpoint, auto-configuration imports와 tests
notification entity/repository/migration/config READ_FULL V1~V3, entity/repository/store/config, notification workflow
Spring Data keyset/sort/cursor READ_FULL registry/mapper/predicate/codec/page request와 tests
package/ArchUnit 경계 READ_FULL JpaModuleBoundaryTest, reusable rules, root architecture tests
PostgreSQL matrix/migration/release lane READ_FULL support/extension/scenarios/manifest/gates/workflows
idempotency/outbox/inbox/fileserver READ_PARTIAL composition 및 큰 실행 seam 중심 정독, 전수 method 승인은 아님
Hibernate/querydsl/envers/cache/security/experimental READ_PARTIAL public entry, activation, dependency와 release 주장 중심

READ_PARTIAL 영역의 모든 method를 승인했다는 뜻은 아니다. Docker-backed PostgreSQL lane과 실제 장애 주입을 이번 통합 리뷰에서 다시 실행하지 않았으므로 운영 결과는 UNVERIFIED다. 리뷰 도중 다른 사용자 작업이 root build/settings와 src/config/spotbugs/exclude.xml을 변경하고 conflict 상태로 만든 것을 확인했으며, 해당 변경은 이 리뷰 범위에 포함하거나 수정하지 않았다. 위 JPA/app-bootstrap/docs/CI 대상 파일은 기준 HEAD와 동일함을 git diff --quiet HEAD -- <review-scope>로 확인했다.

3. 유지할 설계

다음은 리팩터링 중에도 보존할 가치가 있다.

  • domain-coreapplication-core가 JPA/Hibernate/Spring Data type에 의존하지 않는다.
  • GenericRepository<T, ID>나 platform base entity/repository를 도입하지 않고 도메인별 port를 둔다.
  • TransactionProfile, RetryProfile, QueryName, SafeSortRegistry처럼 policy를 문자열 분기보다 명시적 값으로 모델링한다.
  • completion-unknown을 retryable로 표현하지 못하게 한 failure-context 불변식은 유지해야 한다.
  • SQL message text가 아니라 SQLSTATE와 등록된 constraint를 사용해 분류하려는 방향이 맞다.
  • cursor MAC의 constant-time 비교, 최소 32-byte key, sort allowlist, unique tie-breaker 요구는 적절하다.
  • Testcontainers/testkit을 main output과 분리한 source-set 구조는 production classpath 오염을 막는다.
  • Docker가 없을 때 skip하지 않고 실패하고, platform task에 failOnNoDiscoveredTests=true를 둔 정책은 release evidence의 기본 전제다.
  • H2를 PostgreSQL 호환성 증거로 사용하지 않고 local convenience로 제한한 문서화가 명확하다.
  • Querydsl/Envers를 compileOnly로 두어 Stable runtime classpath에서 제외한 선택은 유지한다.
  • fileserver의 독립 migration stream, activation record, readiness card는 notification capability를 정리할 때 재사용할 좋은 선례다.
  • owner-safe idempotency store가 아직 candidate라는 이유로 stereotype을 제거한 방식은 다른 candidate adapter에도 적용할 수 있다.

4. 우선순위 요약

ID 우선순위 심각도 주제 완료 조건
JPA-001 P0 High PG17/18 full-suite release 증거가 거짓 양성 Stable major별 전체 lane이 별도 job에서 실행됨
JPA-002 P0 High notification schema stream이 runtime/readiness에 미연결 enabled 시 ACTIVE schema만 boot, disabled 시 entity/repo 0개
JPA-003 P0 Critical notification entity/schema/JSONB/repository 불일치 forward migration + Hibernate validate + 모든 repository CRUD 통과
JPA-004 P0 High stale notification worker가 lease를 탈취 owner/state/fence CAS와 두-worker PG test 통과
JPA-005 P1 High persistence에 roll-up 정책과 tenant 없는 API 유출 application이 상태를 결정하고 모든 query/update가 tenant scoped
JPA-006 P1 High Stable JPA runtime composition이 실제로 없음 auto-config bean/advisor/endpoint context test 통과
JPA-007 P1 High transaction authority와 application contract가 두 벌 PolicyTransactionPort 단일 facade로 통합
JPA-008 P1 High raw DB failure가 새 retry classifier에 도달하지 않음 실제 optimistic/40001/40P01 번역·retry test 통과
JPA-009 P1 High evidence frame double-pop과 empty ThreadLocal 재생성 identity scope ownership과 nested commit-unknown test 통과
JPA-010 P1 High migration release lane이 실제 migration을 검증하지 않음 empty/N-1/oldest snapshot으로 migrate+validate+invariant 실행
JPA-011 P1 High keyset sort/predicate 계약 불일치 mixed type/direction 한 SSOT와 real criteria test 통과
JPA-012 P1 High broad scan이 optional/candidate bean을 활성화 capability marker scan과 candidate bean inventory 통과
JPA-013 P1 Medium signed cursor input 크기가 무제한 pre-decode token/payload bound와 boundary test 통과
JPA-014 P1 Medium integration support의 Hikari/container lifecycle 누수 pool→container close 및 shared-resource isolation 통과
JPA-015 P1 Medium package DAG rule이 9개 package와 cycle을 놓침 exact package catalog/edge/cycle negative fixture 통과
JPA-016 P1 Medium reusable ArchUnit rule이 production graph에 미적용 실제 runtime leaves를 import해 네 rule 모두 실행
JPA-017 P2 Medium release manifest/support 문서가 실행 SSOT가 아님 typed manifest에서 docs/task/matrix를 생성·검증
JPA-018 P2 Medium experimental gate/workflow가 대상 runtime을 실행하지 않음 별도 variant에서 실제 dependency/PG19 lane 실행
JPA-019 P2 Medium performance flag와 R2/release evidence 연결이 무효 명칭을 contract로 낮추거나 측정 artifact를 실제 gate에 연결
JPA-020 P2 Medium owner-safe idempotency store가 미조립인 890-line facade provider-selected facade와 package-private gateway로 분해
JPA-021 P2 Medium notification FK/tenant/index 불변식 부족 V4 composite FK/fence index 또는 명시적 retention invariant
JPA-022 P2 Low-Medium auditauditing 계약이 병존 canonical 한 모델과 schema migration/activation 선택
JPA-023 P3 Medium public surface와 package 상태가 통제되지 않음 api/spi/config export allowlist와 internal 축소
JPA-024 P3 Low 문서·Hibernate baseline·H2 표현 drift machine manifest 및 문서 검증으로 실제 실행과 일치
JPA-025 P0 Critical notification idempotency loser가 aborted tx에서 재조회 ON CONFLICT DO NOTHING RETURNING 동시성 수렴
JPA-026 P1 High provider callback dedupe/matching/status가 durable하지 않음 hash 기반 CAS bind와 sweep/outcome PG test 통과
JPA-027 P0 High batch clear가 unflushed entity를 유실 clear-before-flush 불가와 300-row real PG test 통과
JPA-028 P1 High fileserver cleanup claim/writer fencing 부재 expiring fenced claim과 terminal upload state 경쟁 test 통과
JPA-029 P1 Medium inbox tuple cutoff와 durable signal 계약 불일치 (createdAt,id) update와 transactional outbox replay
JPA-030 P2 Medium query/stateless/stream safety policy가 선언만 됨 construction-time guard와 실행-time row/fetch bound 강제

5. 상세 발견 사항과 구현 명세

JPA-001 — PostgreSQL 17·18 전체 계약을 실행하지 않고 full release로 판정한다

근거

  • .github/workflows/jpa-release.yml:38-45는 한 job에 -Pjpa.matrix.versions=16,17,18을 전달한다.
  • JPA build.gradle:244-260은 그 문자열을 각 tagged lane의 system property로 그대로 전달한다.
  • JpaPlatformContractSupport.java:38-48의 parameterless start()selectedVersions().get(0)만 시작한다.
  • 현재 28개 integration test class가 parameterless start()를 사용한다.
  • 전 버전을 순회하는 StablePostgreSqlMatrixContractTest.java:28-76은 server version, unique SQLSTATE, SKIP LOCKED 세 종류의 얕은 검증만 한다.
  • docs/jpa/support-matrix.md:9-15는 PG16·17·18을 모두 “full contract suite, release lane”으로 기록한다.

실패 모드

PG17/18에서만 달라진 JSONB/range mapping, Hibernate SQL, Flyway upgrade, runtime role, query plan, deadlock/commit ambiguity가 있어도 tag release는 PG16의 전체 결과와 PG17/18의 얕은 smoke 결과만으로 통과할 수 있다. PR/nightly 일부가 단일 major job을 사용해도 tag release의 같은 SHA가 문서에 적힌 전체 gate를 재현하지 못한다.

구현 결정: CI matrix + single-version fail-closed contract

  1. .github/workflows/jpa-release.ymlstrategy.matrix.postgresql: [16, 17, 18]로 나눈다.
  2. 각 job은 정확히 한 major만 -Pjpa.matrix.versions=${{ matrix.postgresql }}로 전달하고 contract, migration, failure, queryplan, security를 모두 실행한다.
  3. 현재 구조에서는 JpaPlatformContractSupport.start()가 선택 버전 수 != 1이면 즉시 실패하게 한다. comma selection을 유지하려면 모든 test를 @TestTemplate/extension으로 버전별 반복시키는 별도 리팩터링이 필요하다.
  4. 세 job의 JUnit XML/evidence manifest에 git SHA, major, image digest, 실행 task를 기록하고 aggregate promotion job은 세 artifact가 모두 같은 SHA인지 확인한다.
  5. container 비용은 JPA-014의 root-store sharing을 먼저 적용해 줄인다.

필수 테스트

  • JpaPlatformContractSupportTest.rejectsMultipleVersionsForSingleVersionStart
  • PG16/17/18 각각에서 다섯 non-performance lane을 --rerun-tasks로 실행한다.
  • mutation: selectedVersions().get(0) 또는 PG17 job 제거 시 release manifest 검증이 실패해야 한다.

JPA-002 — notification migration은 opt-in인데 runtime과 readiness가 그 stream을 소유하지 않는다

근거

  • V1__notification_platform_core.sql:3-5는 notification이 opt-in tree이며 기본 Flyway location에서 실행되지 않는다고 명시한다.
  • PostgreSqlPersistenceConfig.java:55-58은 기본 location을 classpath:db/migration/postgresql 하나로 고정한다.
  • PersistenceJpaConfig.java:12-14는 반대로 persistence 전체 package의 entity/repository를 스캔한다.
  • NotificationPlatformPersistenceConfig.java:61-66은 adapter bean만 feature property로 막는다.
  • root readiness exact set(src/build.gradle:1161-1189)의 16 card/8 owned stream에는 notification이 없다.
  • notification workflow는 persistence의 일반 unit test만 실행한다 (.github/workflows/notification-platform.yml:66-68).

실패 모드

  • ddl-auto=validate: feature가 꺼져도 notification entity가 persistence-unit에 포함되어 table이 없는 DB에서 boot가 실패할 수 있다.
  • ddl-auto=none: feature를 켜도 migration activation guard가 없어 첫 repository 호출에서 relation does not exist가 발생한다.
  • local ddl-auto=update는 Hibernate가 migration 밖에서 table을 만들어 schema ownership 결함을 숨길 수 있다.

구현 결정: Capability Module + activation record

  1. notification을 진짜 optional capability로 유지할지, 항상 설치되는 base schema로 바꿀지 먼저 결정한다. 현재 문서와 fileserver 선례에 맞는 권장은 optional 유지다.
  2. jpa-notification-platform-v4 readiness card를 추가하고 location, dedicated history table, core prerequisite, checksum/content hash, ACTIVE promotion을 등록한다.
  3. NotificationSchemaActivation을 fileserver와 같은 lifecycle로 구현한다. enabled=true인데 ACTIVE가 아니면 repository/worker 생성 전에 boot를 실패시킨다.
  4. NotificationJpaPersistenceConfiguration에서 notification marker entity/repository만 scan하고 master switch와 activation condition을 함께 건다. disabled일 때 entity metadata와 repository bean이 모두 없어야 한다.
  5. broad classpath:db/migration으로 합치지 않는다. 여러 독립 V1 stream과 history ownership이 섞인다.

필수 테스트

  • disabled + fresh DB: notification entity/repository/bean 0개, boot 성공.
  • enabled + stream 미적용: startup fail-closed.
  • first enable, V1→V4, V2→V4, V3→V4, disable/re-enable, interrupted migration recovery.
  • migration 후 Hibernate ddl-auto=validate로 실제 application context boot.

JPA-003 — notification entity와 V1~V3 schema가 첫 실제 CRUD에서 충돌한다

근거

  • NotificationRequestEntity.java:46-47template_locale을 mapping하지만 V1 request table(V1...sql:11-29)에는 해당 column이 없다.
  • migration의 다음 column은 jsonb지만 entity는 일반 String/VARCHAR mapping이다.
    • metadata_json: NotificationRequestEntity.java:70-71
    • routing_plan_json: RecipientDeliveryEntity.java:39-40
    • normalized_payload_json: ProviderEventEntity.java:68-69
    • content_json: TemplateVersionEntity.java:48-49, InboxItemEntity.java:42-43
    • preferred_order, muted_channels: PreferenceEntity.java:28-32
    • attributes: AdminAuditEntity.java:37-38
  • 같은 entity들의 UUID에는 이미 @JdbcTypeCode(SqlTypes.UUID)를 쓰지만 JSON column에는 @JdbcTypeCode(SqlTypes.JSON) 또는 converter가 없다.
  • NotificationPolicyJpaRepositories.java:8-58은 repository 5개를 nested interface로 묶었다. @EnableJpaRepositories의 nested repository discovery 기본값은 false인데 현재 config는 considerNestedRepositories를 켜지 않는다.
  • bootstrap config는 이 다섯 repository bean을 모두 constructor parameter로 요구한다 (NotificationPlatformPersistenceConfig.java:159-200).

실패 모드

  • Hibernate schema validate가 없는 template_locale 또는 VARCHAR↔JSONB mismatch로 boot에서 실패한다.
  • validate를 끄면 첫 insert/update에서 PostgreSQL이 character varyingjsonb column에 바인딩하는 것을 거부할 수 있다.
  • schema를 고친 뒤에도 preference/consent/dedup/admin/template repository bean이 발견되지 않아 enabled context가 조립되지 않는다.

구현 결정: forward migration + explicit JSON type + top-level repository

  1. 기존 migration 적용 이력을 먼저 확인한다. 미적용을 증명하지 못하면 V1~V3를 편집하지 않는다.
  2. V4에서 notification_request.template_locale varchar(35)을 추가한다. null 허용 여부와 backfill 후 NOT NULL 전환 여부는 application contract에 맞춘다.
  3. JSON을 계속 canonical String으로 보관한다면 각 field에 @JdbcTypeCode(SqlTypes.JSON)를 붙이고 mapper boundary에서 parsing/size validation을 한다. 구조적 query가 필요하면 immutable value/object 또는 JsonNode로 통일하되 application DTO를 entity에 넣지 않는다.
  4. nested repository 5개를 각각 top-level file로 분리한다. 전역 considerNestedRepositories=true는 의도하지 않은 nested test/repository까지 발견할 수 있어 권장하지 않는다.
  5. migration/entity column manifest test를 추가해 name, nullable, type, length의 drift를 비교한다.

필수 테스트

  • real PG에서 migration 후 ddl-auto=validate boot.
  • 모든 JSONB field의 insert/read/update round-trip과 malformed/oversized payload rejection.
  • 5개 repository bean exact inventory와 실제 CRUD.
  • V3 seed row가 V4 후 보존되고 template_locale backfill 정책을 만족하는지 검증.

JPA-004 — notification lease renew가 새 owner의 lease를 다시 빼앗을 수 있다

근거

  • RecipientDeliveryJpaRepository.java:37-49markLeased update는 WHERE id IN (:ids)만 검사한다.
  • JpaRecipientLeaseStore.java:50-57renew()가 같은 update를 재사용한다.
  • release query는 그나마 lease_owner를 조건으로 검사한다 (RecipientDeliveryJpaRepository.java:51-59).
  • claim은 select-for-update와 update가 adapter 내부 transaction으로 묶였다는 보장이 없고, native update는 entity @Version을 자동 증가시키지 않는다.
  • scheduler는 leases.claim을 직접 호출하고, runtime config는 모든 instance에 동일한 notification-worker-1 owner 문자열을 제공한다. 동일 owner면 stale process의 release도 새 process의 lease와 구분되지 않는다.

실패 모드

worker A의 lease가 만료된 뒤 B가 claim했는데 A의 늦은 renew가 도착하면, id만 일치하므로 owner와 lease_until을 다시 A 값으로 덮을 수 있다. 두 worker가 동일 수신자에게 provider request를 보내 duplicate delivery가 발생할 수 있다.

구현 결정: fenced lease state machine + compare-and-set

  1. claim, renew, release를 서로 다른 SQL로 분리한다.
  2. lease row에 monotonically increasing lease_fence를 추가한다. claim 결과는 (id, owner, fence, leaseUntil) token을 반환한다.
  3. renew 조건은 최소한 id, lease_owner, lease_fence, delivery_state='DISPATCHING', 아직 유효한 lease를 모두 확인한다. update count가 1이 아니면 LeaseLost를 반환한다.
  4. provider side effect를 시작하기 직전에도 현재 fence 소유를 확인하고, completion projection update도 같은 fence를 조건으로 한다.
  5. claim을 PostgreSQL single-statement CTE(FOR UPDATE SKIP LOCKED + UPDATE ... RETURNING)로 만들거나 application-owned transaction port로 select/update를 원자화한다.
  6. owner는 ${instanceId}:${startupNonce}처럼 process incarnation을 포함한다. 설정된 instance id만으로 소유권 token을 만들지 않는다.

필수 테스트

  • A claim → expiry → B claim → A late renew가 0 row이고 B owner/fence가 유지되는 real PG concurrency test.
  • double claim, partial batch claim, transaction rollback, process crash 후 expiry reclaim.
  • stale fence로 provider completion을 기록할 수 없는지 검증.

JPA-005 — notification 상태 정책과 tenant 경계가 persistence adapter로 유출됐다

근거

  • JpaNotificationRequestStore.java:10은 application service인 NotificationSubmissionService를 직접 import한다.
  • 같은 파일 :78-90은 recipient state를 읽고 NotificationSubmissionService.rollUp(states)로 최종 RequestStatus를 결정한다.
  • 비즈니스 결정표는 application의 NotificationSubmissionService.java:249-278에 있다.
  • NotificationRequestStorePort.java:21-25recipientsOf(NotificationId)refreshStatus(NotificationId)는 tenant를 받지 않는다.
  • NotificationRequestJpaRepository는 주석과 달리 JpaRepository를 상속해 tenant 없는 findById, findAll, deleteById를 모두 노출한다.
  • JpaRecipientDeliveryStore.java:26-37,59-64JpaNotificationRequestStore.java:85-90에 실제 무tenant 조회가 있다.

실패 모드

현재 caller가 tenant를 먼저 확인하면 우연히 안전할 수 있지만 port/repository 자체는 다른 tenant의 UUID를 구조적으로 거부하지 않는다. 새 consumer가 선행 확인을 빠뜨리면 cross-tenant read/update가 가능하다. 또한 request 상태 결정 규칙을 바꾸려면 application과 persistence를 동시에 수정해야 한다.

구현 결정: application Policy + narrow Repository port

  1. application service가 recipient states를 받아 rollUp하고 결정된 RequestStatus만 port에 전달한다.
  2. port를 recipientsOf(TenantId, NotificationId)updateStatus(TenantId, NotificationId, RequestStatus)로 바꾼다.
  3. adapter에서 NotificationSubmissionService import와 상태 결정 로직을 제거한다.
  4. tenant-owned repository는 JpaRepository 대신 Spring Data marker Repository<Entity, UUID>를 상속하고 필요한 save와 tenant-scoped finder/update만 선언한다.
  5. persistence package가 ..application..*Service/*UseCase에 의존하지 못하게 ArchUnit rule을 둔다.

필수 테스트

  • application unit: recipient state 조합별 roll-up 결정표.
  • persistence PG: 틀린 tenant의 read/update/delete가 empty 또는 0 row.
  • reflection/architecture: tenant repository public API에 findAll, raw findById, deleteById가 없음.

JPA-006 — 이름만 auto-configuration이고 Stable platform bean은 runtime에 없다

근거

  • CaSkeletonApplication.java:42-44,66-68bootstrap.autoconfigure.*를 component scan에서 제외한다. 이 package는 auto-configuration entry로만 들어와야 한다.
  • AutoConfiguration.imports:1-2에는 fileserver와 HTTP client만 있고 JPA entry가 없다.
  • JpaPlatformAutoConfiguration.java:19-28, JpaTransactionAutoConfiguration.java:28-32, JpaObservabilityAutoConfiguration.java:13-26@AutoConfiguration, @Configuration, @Bean이 없는 plain factory다.
  • JpaPlatformEndpoint.java:20-21@Endpoint일 뿐 production @Bean 등록이 없다.
  • RetryableJpaTransactionInterceptor를 실제 method pointcut과 연결하는 Advisor도 없다.
  • JpaPlatformAutoConfigurationTest.java:21-25,108-119는 factory와 endpoint를 직접 생성하므로 Spring bean graph를 검증하지 않는다.
  • 기존 RuntimeSafetyConfig가 OSIV/schema validator를 별도로 조립하므로 모든 JPA safety가 사라진 것은 아니다. 문제는 새 platform의 retry/evidence/provider/report/endpoint가 inert하다는 점이다.

실패 모드

capability report는 transaction retry, completion evidence, observability를 Stable로 나열하지만 default application context에는 executor/coordinator/evidence-aware manager/advisor/endpoint가 없다. 개발자는 annotation을 붙여도 실제 retry되지 않는 method를 운영에 배포할 수 있다.

구현 결정: Composition Root + explicit auto-configuration

  1. application contract를 JPA-007대로 먼저 결정한다. outbound annotation을 제거한다면 불필요한 AOP를 새로 wiring하지 않는다.
  2. 남길 Stable component는 실제 @AutoConfiguration(proxyBeanMethods=false) class로 만들고 imports에 등록한다.
  3. @ConditionalOnBean(DataSource/EntityManagerFactory/PlatformTransactionManager), master property, @ConditionalOnMissingBean을 각 bean 의미에 맞게 적용한다.
  4. evidence-aware manager를 기본 manager로 채택할 경우 manager replacement/back-off 규칙을 명시하고 JTA/custom manager를 침범하지 않는다.
  5. endpoint와 report supplier를 bean으로 등록하고 management exposure는 기존 actuator policy를 따른다.
  6. JpaSafetySettings.enabled는 primitive binding absent 시 false인데 defaults()는 true라고 말하는 모순을 없앤다. property default를 명시하거나 constructor defaulting을 일관되게 한다.

필수 테스트

  • ApplicationContextRunner: disabled, missing DataSource, custom TM, default JPA, invalid property 조합.
  • 실제 CaSkeletonApplication context에서 Stable bean/advisor/endpoint exact inventory.
  • annotation을 유지할 때만 proxy fixture가 raw serialization failure를 실제 새 transaction으로 retry.

JPA-007 — application transaction SSOT와 JPA 전용 transaction API가 병렬로 존재한다

근거

  • application에는 TransactionPort, PolicyTransactionPort, TransactionRequest, TransactionResult가 있고 다수 use case가 이를 사용한다.
  • live adapter인 SpringTransactionPort.java:31-32@Component이며 PolicyTransactionPort를 구현한다.
  • JpaTransactionExecutor, SpringJpaTransactionExecutor, FullTransactionRetryCoordinator는 별도 outcome/retry 모델이다.
  • coordinator는 constructor의 고정 JpaRetryPolicy와 호출별 임의 TransactionProfile을 함께 사용해 profile A eligibility/backoff와 profile B attempt budget을 섞을 수 있다.
  • DefaultJpaRetryPolicy는 failure context의 retryable=false를 먼저 거부하지 않아 translator가 terminal로 표시한 category를 profile allowlist가 다시 활성화할 여지가 있다.
  • outbound leaf의 RetryableJpaTransaction.java:9-29는 “public application-service method”가 사용한다고 문서화한다. application이 이를 import하면 의존 방향을 역행한다.
  • IrreversibleSideEffectContext, TransactionCompletionResolver, 일부 constraint/reconciliation type도 application/domain이 호출해야 한다고 설명하지만 outbound adapter에 있다.

실패 모드

Clean Architecture를 지키면 새 JPA annotation과 ambient API를 application에서 사용할 수 없어 dead surface가 된다. 반대로 사용하면 application-core → outbound adapter 역의존으로 HARD-STOP이다. 두 transaction engine은 isolation/timeout/retry/translation/completion-unknown 결과를 다르게 발전시킨다.

구현 결정: application Port + adapter Facade 하나

  1. 템플릿의 canonical boundary를 PolicyTransactionPort.inTransaction(TransactionRequest, Supplier)로 고정한다.
  2. TransactionPolicyId가 propagation/isolation/readOnly/timeout/replay-safe를 표현하게 하고 JPA 전용 annotation은 제거한다. 선언형 API가 꼭 필요하면 annotation은 inbound/composition concern으로 두되 application-core가 outbound type을 import하지 않게 한다.
  3. reconciliation key/result/resolver는 application-core의 outbound port/value로 이동한다.
  4. JPA executor, retry coordinator, evidence manager, translator는 SpringTransactionPort 내부 facade 구현 세부로 축소한다.
  5. legacy와 new engine의 policy/result parity contract를 만든 뒤 호출을 한쪽으로 옮기고 중복 코드를 삭제한다.
  6. 통합 전 임시로 새 engine을 유지한다면 policy는 호출 profile에서 매번 resolve하고 failure.context().retryable()==false를 category allowlist보다 우선한다.

필수 테스트

  • application unit에서 fake PolicyTransactionPort로 committed, determinate rollback, indeterminate, post-commit failure 분기.
  • 모든 TransactionPolicyId → Spring definition mapping contract.
  • application/sample production code의 outbound persistence import 0개 ArchUnit.

JPA-008 — 새 retry coordinator는 raw PostgreSQL/optimistic failure를 retry하지 못한다

근거

  • FullTransactionRetryCoordinator.java:79-99JpaPersistenceException만 catch한다.
  • SpringJpaTransactionExecutor.java:58-70TransactionTemplate을 실행하지만 raw failure translator를 호출하지 않는다.
  • PostgreSqlExceptionTranslatorOptimisticConflictTranslator는 production retry path의 caller가 검색되지 않는다.
  • RetryableJpaTransactionInterceptorTest.java:63-65는 이미 번역된 SerializationFailureException을 직접 던져 real provider translation을 우회한다.
  • completion-unknown commit path만 evidence-aware manager가 별도로 번역한다.

실패 모드

실제 Hibernate/Spring이 던지는 OptimisticLockException, OptimisticLockingFailureException, raw serialization/deadlock DataAccessException은 coordinator의 catch에 걸리지 않아 retry 없이 밖으로 나간다. unit fixture가 녹색이어도 운영 contention retry는 동작하지 않는다.

구현 결정: Chain of Responsibility failure translation

  1. executor attempt 경계에서 단 하나의 PersistenceFailureTranslatorChain을 호출한다.
  2. 순서는 completion-unknown 보존 → optimistic conflict → vendor SQLSTATE → known constraint → unknown passthrough로 고정한다.
  3. translator는 operation, attempt, elapsed, trace/reconciliation metadata를 한 context factory에서 받는다. 각 translator가 서로 다른 attempt/context를 만들지 않게 한다.
  4. domain/application exception과 programming error는 persistence failure로 오분류하지 않고 그대로 보낸다.
  5. JPA-007 통합 후 legacy PersistenceExceptionTranslator와 새 translator의 SQLSTATE catalog도 하나로 합친다.

필수 테스트

  • real PG 40001/40P01가 번역되고 각 attempt가 새 transaction/EntityManager를 사용하는지 검증.
  • optimistic version conflict가 whole-use-case replay되며 domain rule이 다시 실행되는지 검증.
  • 23505는 retry되지 않고 registered constraint만 노출.
  • commit 08xxx/40003은 body replay 0회이며 reconciliation key를 보존.
  • non-persistence application exception은 identity 그대로 전파.

JPA-009 — transaction evidence frame의 pop 소유자가 두 곳이라 outer frame을 지운다

근거

  • TransactionEvidenceContext.java:24-25ThreadLocal.withInitial(ArrayDeque::new) stack을 사용한다.
  • EvidenceAwareJpaTransactionManager.java:50-60,64-70은 commit/rollback finally에서 frame을 pop한다.
  • SpringJpaTransactionExecutor.java:63-69도 finally에서 현재 frame의 operation과 attempt가 같으면 pop한다.
  • default execute는 attempt 1이다. nested REQUIRES_NEW의 outer/inner가 같은 operation/attempt를 가질 수 있다.
  • 현재 nested test(EvidenceAwareJpaTransactionManagerTest.java:74-87)는 context를 수동으로 한 번만 clear하며 manager+executor 조합을 검증하지 않는다.
  • 마지막 manager clear 뒤 executor가 current()를 호출하면 withInitial이 빈 deque를 다시 등록한다. 이는 TransactionEvidenceContext.java:67-71의 제거 의도와 다르다.

실패 모드

inner manager가 inner frame을 지운 다음 executor finally가 top의 outer frame을 같은 operation/attempt로 오인해 한 번 더 지운다. 이후 outer commit이 08xxx로 실패하면 CommitFailureClassifier.java:83-100UnknownOperation, attempt 1, reconciliation key 없음으로 보고한다.

구현 결정: identity-bound Scope token

  1. begin()은 opaque identity를 가진 TransactionEvidenceScope implements AutoCloseable을 반환한다.
  2. scope close는 자기 token이 stack top일 때만 정확히 한 frame을 pop한다. out-of-order close는 fail-closed diagnostic을 남기고 다른 frame을 지우지 않는다.
  3. lifecycle ownership을 executor 하나로 통일한다. manager는 phase만 mark하고 pop하지 않는다.
  4. read-only 조회는 FRAMES.get()으로 값을 생성하지 않는 nullable ThreadLocal 접근을 사용한다.
  5. test 전용 hasRawThreadLocalValue() 또는 injectable context storage로 종료 후 잔존 여부를 검증한다.

필수 테스트

  • same operation/attempt outer + REQUIRES_NEW inner success 후 outer frame/key 유지.
  • inner completion 후 outer 08xxx commit failure가 outer operation/key/attempt를 보존.
  • begin/begin/close outer 순서 오류가 inner를 삭제하지 않음.
  • 정상/rollback/begin failure/mandatory admission failure 뒤 raw ThreadLocal value 없음.

JPA-010 — migration release lane은 실제 repository migration tree를 upgrade하지 않는다

근거

  • FlywayUpgradeContractTest.java:48-108은 temp directory에 합성 V1/V2 SQL을 만든다.
  • PostgreSqlMigrationUpgradeContractTest.java:47-53은 세 scenario 이름만 확인한다.
  • 같은 test :98-108의 clean validation은 존재하지 않는 location과 missing migration 허용을 사용한다.
  • MigrationScenario.java:39-57의 previous/oldest setup SQL과 invariant는 비어 있다.
  • 실제 restore→migrate→validate→invariant를 수행하는 MigrationContractRunner.java:44-58은 production/test caller가 없다.
  • 이 task는 release gate에 포함된다(JPA build.gradle:267-270,301-310).

실패 모드

db/migration/postgresql 또는 optional stream에 SQL 문법 오류, checksum drift, N-1 upgrade 파손, data loss, entity/schema mismatch가 생겨도 synthetic policy test는 녹색일 수 있다.

구현 결정: Snapshot Migration Contract

  1. 실제 release artifact에서 N-1/oldest schema snapshot과 seed data를 만든다. 빈 문자열 setup은 허용하지 않는다.
  2. MigrationContractRunner를 각 real location에 연결해 restore → migrate → Flyway validate → seed invariant → Hibernate validate를 실행한다.
  3. base/fileserver/notification 등 독립 stream마다 history table, prerequisite, snapshot owner를 둔다.
  4. migration file을 수정한 mutation, missing location, ignored migration, seed deletion이 test를 실패하게 한다.
  5. synthetic Flyway-policy test는 unit lane에 남기되 release upgrade evidence로 이름 붙이지 않는다.

필수 테스트

  • empty install, N-1, oldest-supported, interrupted recovery, rolling window, checksum mutation.
  • 모든 stream에서 preexisting row count/semantic invariant와 ddl-auto=validate context boot.

JPA-011 — keyset ordering과 predicate가 하나의 계약을 공유하지 않는다

근거

  • KeysetPredicateBuilder.java:38-39는 전체 List<KeysetTerm<T>>에 하나의 generic T와 하나의 SortDirection을 강제한다.
  • typical cursor (Instant createdAt, UUID id)는 서로 다른 T라 한 list로 compile할 수 없다.
  • KeysetPredicateBuilder.java:55-70은 모든 strict comparison에 동일 direction을 적용한다.
  • SafeSortMapper.java:43-49,63-65는 요청 term별 direction을 허용하고 tie-breaker가 없으면 무조건 DESC로 붙인다.
  • 따라서 createdAt ASC, id DESC는 mapper가 만들 수 있지만 predicate builder는 표현할 수 없다.
  • 현재 production/test에서 KeysetPredicateBuilder caller가 0개라 즉시 장애보다는 사용 전 차단해야 할 깨진 public seam이다.

실패 모드

caller가 mixed order를 ASC 하나로 builder에 넘기면 올바른 조건 created_at > t OR (created_at = t AND id < x) 대신 id에도 >가 적용된다. page 경계에서 row가 skip/duplicate된다. null ordering과 cursor sort context까지 다르면 같은 token을 다른 query에 재사용할 위험도 생긴다.

구현 결정: Query Object + Specification 한 개

  1. KeysetOrder<C>를 sort의 SSOT로 만든다. 각 term은 path/expression resolver, typed cursor extractor, direction, null policy, unique 여부를 갖는다.
  2. heterogeneous term은 List<? extends KeysetTerm<?>>와 private generic-capture helper로 안전하게 비교한다. raw Comparable cast를 public API에 노출하지 않는다.
  3. 같은 KeysetOrder가 Spring Sort, Criteria Order, lexicographic Predicate, cursor payload/order fingerprint를 모두 생성한다.
  4. tie-breaker direction을 임의 DESC로 붙이지 않는다. registry가 endpoint별 total order 전체를 선언하고 request는 허용된 변형만 선택한다.
  5. null 허용 column은 explicit NULLS FIRST/LAST 의미를 predicate와 order 양쪽에 동일하게 구현한다.

필수 테스트

  • (Instant ASC, UUID DESC)(String DESC, Long ASC, UUID DESC) criteria integration.
  • page 사이 insert/delete에도 허용된 consistency semantics 안에서 duplicate/skip 0건.
  • sort fingerprint가 다른 endpoint/order에 cursor 재사용 시 거부.
  • unique tie-breaker 누락, null boundary, backward scan, first/last row.

JPA-012 — 전체 component/entity/repository scan이 optional과 candidate 상태를 무시한다

근거

  • CaSkeletonApplication.java:29-35dev.caskeleton.adapter 전체를 component-scan한다.
  • PersistenceJpaConfig.java:12-14는 persistence root 전체의 entity/repository를 scan한다.
  • README가 implemented-candidate로 분류한 다음 구현은 unconditional @Repository다.
    • PostgreSqlSameStoreInboxAdapter.java:39-40
    • PostgreSqlImmutableOutboxAppendAdapter.java:34-35
    • PostgreSqlPollingDeliveryAdapter.java:31-32
  • 반대로 PostgreSqlOwnerSafeIdempotencyStore.java:45-55는 provider selection 전 자동 bean이 되지 않게 stereotype을 제거했다.
  • fileserver/notification bean property gate는 entity metadata와 Spring Data repository scan을 막지 않는다.

실패 모드

H2/local이나 capability-off runtime에도 PostgreSQL candidate port bean과 optional entity/repository가 생긴다. 호출하면 PG 전용 SQL 또는 적용되지 않은 capability table에서 실패할 수 있고, ddl-auto=validate 에서는 호출 전 boot부터 실패할 수 있다. 같은 port의 legacy/candidate 구현이 동시에 bean이 되면 selection ambiguity도 생긴다.

구현 결정: explicit Capability Configuration + bean inventory

  1. candidate 세 adapter에서 stereotype을 제거한다. candidate integration test는 직접 생성한다.
  2. PersistenceJpaConfig를 최소 세 marker configuration으로 나눈다.
    • core/base persistence
    • fileserver capability
    • notification capability
  3. vendor-specific candidate는 PostgreSql...Configuration이 provider + capability + schema ACTIVE를 확인한 뒤에만 등록한다.
  4. 장기적으로 adapter root를 broad component scan에서 제외하고 app-bootstrap이 명시적 configuration facade만 import한다.
  5. runtime profile별 bean inventory snapshot을 둔다: H2, PG base, fileserver on, notification on, candidate qualification.

필수 테스트

  • H2/base에서 PG candidate port bean 0개.
  • capability off에서 해당 entity/repository/store 0개.
  • legacy/candidate provider별 동일 port 구현 정확히 1개.
  • schema inactive인데 worker/store만 생성되는 mutation이 context startup에서 실패.

JPA-013 — signed cursor가 크기 제한 없이 decode/MAC/JSON parsing을 수행한다

근거

  • SignedJsonCursorCodec.java:53-82는 encoded token/payload 길이 상한을 검사하지 않는다.
  • substring, Base64 decode, MAC 입력 byte 배열, JSON string을 attacker가 보낸 크기만큼 할당한다.
  • HMAC-SHA256 presented MAC가 32 bytes인지 별도로 확인하지 않는다.
  • SignedJsonCursorCodecTest.java:12는 cursor를 “bounded”라고 설명하지만 :24-77은 tamper, key, version과 page size만 검사한다.

실패 모드

공개 paging endpoint에 매우 큰 token을 반복 전송하면 signature 검증 전에 큰 String/byte array를 만들고 payload decoder까지 호출해 CPU/heap pressure를 일으킨다. page size bound는 cursor token bound를 대체하지 않는다.

구현 결정: input Boundary Object

  1. MAX_ENCODED_LENGTH, MAX_PAYLOAD_BYTES를 endpoint/cursor contract에서 정하고 decode 첫 줄에서 encoded char length를 검사한다.
  2. Base64 expansion 계산으로 payload segment가 byte bound를 넘는지 decode 전에 거부한다.
  3. presented MAC decoded length가 정확히 32 bytes가 아니면 constant-time comparison 전에 거부한다.
  4. encode 결과도 같은 bound를 넘으면 application programming/configuration error로 실패시킨다.
  5. payload에는 schema version뿐 아니라 query/sort fingerprint를 포함해 다른 ordering에 재사용되지 않게 한다.

필수 테스트

  • max-1/max/max+1 encoded와 payload boundary.
  • oversized token에서 payload decoder invocation 0회.
  • huge malformed Base64, extra separator, wrong MAC length, Unicode byte/char 차이.

JPA-014 — PostgreSQL contract support가 Hikari pool과 container를 소유하지 못한다

근거

  • JpaPlatformContractSupport.java:14-19의 주석은 JVM에서 PostgreSQL을 공유한다고 설명한다.
  • 실제 start(version)은 호출마다 새 container를 생성/시작한다(:44-48).
  • dataSource()도 호출마다 새 HikariDataSource를 만든다(:63-70).
  • connection()은 새 pool에서 connection만 반환해 pool owner reference를 잃는다(:90-93).
  • close()는 container만 stop한다(:137-140).
  • parameterless start caller는 28 class, support dataSource()/connection() call은 76곳이다.
  • PostgreSqlReadinessSupport.java:22-28,61-70,119-125,155-159에는 pool을 보관하고 닫는 더 나은 소유 모델이 이미 있다.

실패 모드

connection close는 connection을 unreachable pool로 반환할 뿐 pool housekeeping thread와 physical connection을 닫지 않는다. suite가 진행될수록 max_connections, thread, startup time을 소모하고 90분 CI timeout과 flaky container failure 가능성을 키운다.

구현 결정: JUnit CloseableResource + schema isolation

  1. support가 기본 Hikari pool 하나와 role-specific pools registry를 소유한다.
  2. close()는 모든 pool을 먼저 닫고 container를 마지막에 stop한다.
  3. 미사용 PostgreSqlContractExtension.java:24-52을 JUnit root-store CloseableResource로 연결해 major별 container를 job/JVM 안에서 공유한다.
  4. 공유할 때 test class/scenario마다 unique database 또는 schema를 생성하고 migration/history/search_path를 격리한다.
  5. pool name에 bounded test id를 넣고 종료 후 active pool/thread 0개를 검증한다.

필수 테스트

  • dataSource/connection 반복 호출이 동일 owned pool을 사용.
  • exception/cancel/failed migration 뒤에도 pool→container close order와 active connection 0.
  • parallel test에서 schema/history 오염이 없음.

JPA-015 — package를 module처럼 쓴다고 하지만 DAG와 cycle을 강제하지 않는다

근거

  • JpaModuleBoundaryTest.java:31-46의 downstream catalog는 13개 package만 나열한다.
  • 실제 top-level 중 audit, config, failure, fileserver, h2, idempotency, lock, notification, outbox 9개가 빠져 있다.
  • rule은 api 역의존, testkit, stable→experimental, 일부 opt-in sibling만 검사한다(:84-149).
  • 현재 transaction → postgresql (SpringTransactionPort.java:6,207-210)과 postgresql → transaction (PostgreSqlPersistenceConfig.java:8,44-47) cycle도 통과한다.
  • springdata → hibernate, observation → transaction 같은 문서 map 밖 edge도 있다.
  • docs/jpa/repository-adaptation.md:38-39는 이 test가 package mapping을 재현한다고 주장해 실행보다 강한 보장을 한다.

실패 모드

새 package/edge가 catalog에 등록되지 않아도 test가 녹색이다. vendor-neutral transaction이 PostgreSQL 구현을 직접 만들고 PostgreSQL config가 transaction SPI를 다시 제공하므로 provider 교체나 향후 physical module 추출 시 양쪽을 동시에 수정해야 한다.

구현 결정: closed Package Catalog + Dependency Rule

  1. production root의 direct child package를 자동 발견하고 명시적 catalog와 exact equality를 비교한다.
  2. catalog에 package 상태(Stable/Advanced/Candidate/Experimental), exported 여부, allowed dependency를 둔다.
  3. 모든 observed production edge가 allowed map 안에 있는지 검사하고 top-level cycle rule을 추가한다.
  4. SpringTransactionPort의 PostgreSQL mapping 직접 생성을 제거하고 composition이 Collection<SqlStateErrorMapping>을 주입한다.
  5. cross-cutting SPI(RetryEventListener, timeout configurer, query-name context)는 dependency 방향상 더 안쪽인 api/spi로 이동한다.

필수 테스트

  • unregistered package, forbidden transaction → postgresql, A↔B cycle mutation fixture.
  • source import가 0개인 빈 package test false-pass 방지.
  • JpaModuleBoundaryTest focused rerun에서 현재 illegal fixture가 실제 실패하는지 검증.

JPA-016 — reusable ArchUnit rule은 fixture만 검사하고 production graph를 검사하지 않는다

근거

  • JpaArchitectureRules.java:33-94는 controller entity exposure, domain Hibernate dependency, generic repository 등 네 rule을 제공한다.
  • JpaArchitectureRulesTest.java:25-90은 test fixture class만 import한다.
  • domainDoesNotDependOnHibernate()noGenericRepository()는 그 fixture test에서도 호출되지 않는다.
  • repository 전체 검색에서 rule pack의 production graph caller가 없다.
  • root CleanArchitectureTest.java:1192-1210의 기존 rule은 raw return type/과거 package naming 중심이라 List<NotificationRequestEntity> 같은 generic exposure와 현재 entity package를 놓칠 수 있다.

실패 모드

rule 구현 자체의 unit test는 녹색이지만 실제 controller/application/domain class에 rule이 한 번도 적용되지 않는다. 테스트 이름만 보고 persistence entity leak이나 domain Hibernate import가 자동 차단된다고 오해할 수 있다.

구현 결정: Consumer-side production architecture suite

  1. testkit outgoing test configuration을 만들고 app-bootstrap architecture test가 소비한다.
  2. JpaProductionArchitectureTest가 등록된 runtime production leaves를 import하고 네 rule을 모두 실행한다.
  3. imported class count, entity count, controller count가 nonzero인지 먼저 assert해 empty-import false-pass를 막는다.
  4. generic return/component type을 재귀적으로 검사한다.
  5. fixture test는 rule library 자체의 negative test로 유지하고 production suite와 역할을 구분한다.

필수 테스트

  • List<NotificationRequestEntity> controller fixture와 application의 Hibernate import fixture가 실패.
  • GenericRepository 이름뿐 아니라 tenant entity에 broad CRUD를 노출하는 repository policy fixture.
  • runtime membership 변경 시 import set이 자동 갱신되고 0 class면 실패.

JPA-017 — Markdown regex와 static list가 release/support SSOT를 흉내 낸다

근거

  • JpaReleaseManifest.java:24-25,50-71은 문서 전체의 PostgreSQL NN `name` | gate를 정규식으로 수집한다. support-level column이나 table boundary를 해석하지 않는다.
  • 따라서 Experimental PG19와 prose에 우연히 언급된 major도 versions에 들어간다.
  • JpaReleaseManifestTest.java:51-62는 Stable exact set이 아니라 .contains(16,17,18)만 검사한다.
  • JpaReleaseGate.java:35-43에 gate static list가 또 있고 실제 Gradle task/selector 존재를 연결하지 않는다.
  • workflow에도 Stable version이 별도로 hard-code돼 있다.
  • support-matrix.md:27-33은 Hibernate 7.4를 Stable baseline이라 부르지만 실제 Boot BOM은 7.1.8.Final이고 fetch-pagination gate도 실제 7.1에서 돈다.

실패 모드

PG17을 Experimental로 낮추거나 gate task dependency를 삭제해도 이름이 prose 어딘가에 남으면 test가 통과할 수 있다. Hibernate 7.4에서 한 번도 실행하지 않은 gate가 hibernate-7.4-* 증거로 표시된다.

구현 결정: typed Release Manifest SSOT

  1. JSON/YAML registry에 database major, support level, image/digest source, required tasks, JUnit selector, blocking 여부를 구조화한다.
  2. Gradle task와 workflow matrix는 registry에서 생성/검증한다. Markdown support matrix는 같은 registry를 렌더링한다.
  3. Stable set은 exact [16,17,18], Experimental [19]로 검증하고 duplicate/unknown level/task를 거부한다.
  4. Hibernate는 실제 7.1.x를 Stable tested baseline으로 기록하고 7.4는 target/compatibility lane으로 분리하거나, 실제 7.4 dependency로 full lane을 실행한 뒤에만 Stable로 바꾼다.
  5. gate는 이름 존재가 아니라 Gradle dependency graph와 fresh JUnit artifact까지 확인한다.

필수 테스트

  • Stable→Experimental mutation, prose-only major, duplicate gate, missing task dependency가 실패.
  • generated Markdown/workflow drift check.
  • resolved Hibernate version과 evidence manifest version exact equality.

JPA-018 — experimental flag와 compatibility workflow가 실제 대상 runtime을 격리·실행하지 않는다

근거

  • docs/jpa/repository-adaptation.md:154-156은 experimental 기능이 flag 뒤에 있고 Stable composition에 들어오지 않는다고 선언한다.
  • ExperimentalFeatureGate.java:6-32도 classpath 존재가 consent가 아니라고 설명한다.
  • 그러나 ConsistencyAwareDataSourceRouter, RlsTenantSessionBinder, SchemaTenantMigrationOrchestrator public entry는 gate를 받거나 호출하지 않는다.
  • experimental/**은 별도 source set/variant가 아니라 main artifact에 포함된다.
  • Hibernate 8/JPA4/PG19 workflows는 각각 current 7.x policy unit, lane-definition, boolean evidence test만 실행하고 실제 target dependency/container를 실행하지 않는다.
  • target-named tests는 오히려 현재 classpath에 JPA4/Hibernate8이 없음을 assert하고 PG19 container를 시작하지 않는다.

실패 모드

direct construction/import 한 번으로 experimental flag를 우회할 수 있다. workflow green은 “미래 target에 호환된다”가 아니라 “현재 runtime에 target이 없다”는 사실만 증명한다. tenant/RLS/replica 같은 안전성 영향 코드가 Stable artifact에 상시 포함된다.

구현 결정: Gradle Feature Variant + executable compatibility lane

  1. jpaExperimental source set 또는 feature variant를 만들고 experimental/**을 main output에서 제외한다.
  2. app-bootstrap은 variant dependency와 typed settings가 모두 있을 때만 feature configuration을 import한다.
  3. 당장 source 분리가 어렵다면 public constructor를 package-private로 줄이고 모든 factory가 exact ExperimentalFeatureGate token을 요구한다.
  4. JPA4/Hibernate8은 별도 configuration으로 실제 dependency를 resolve/compile/test한다. PG19는 실제 experimental image로 contract/migration/security/failure lane을 실행한다.
  5. target이 아직 resolve/execute 불가능하면 workflow artifact를 NOT_EXECUTABLE로 남긴다. green compatibility로 표현하지 않는다.

필수 테스트

  • default main JAR에 experimental class 0개 또는 uncomposed public constructor 0개.
  • flag absent/false에서 bean 0개, true+variant에서 exact bean inventory.
  • 실제 target dependency version/container version이 artifact에 기록됨.

JPA-019 — performance flag와 release/R2 evidence가 실제 측정·promotion을 강제하지 않는다

근거

  • JPA build.gradle:284-310performance.assertions.enabled 기본값 false인 performance task를 release gate에 포함한다.
  • nightly도 명시적으로 false다(.github/workflows/jpa-nightly.yml:122-130).
  • PoolPressureContractTest.java:24-50은 false일 때 false임을 확인할 뿐 true branch에 추가 threshold assertion이 없다.
  • 별도 Hikari saturation/REQUIRES_NEW tests는 real PG behavior contract로서 유효하다. 문제는 machine-bound certification 표현과 flag다.
  • root jpaReleaseGate는 platform lanes/architecture를 의존하지만 readiness/candidate/R2 evidence producer와 동일 SHA artifact를 요구하지 않는다.
  • R2 workflow는 수동이고 primary list도 기존 base cards에 머문다.

실패 모드

release가 “performance certification”과 R2 candidate evidence를 통과한 것처럼 보이지만 실제 latency/ throughput threshold는 한 번도 assert되지 않고, retained evidence가 없거나 다른 SHA여도 promotion된다.

구현 결정: honesty-first lane split

  1. 목적이 pool behavior contract라면 flag와 “certifies/reports machine bounds” 표현을 제거하고 jpaPlatformPoolContractTest로 이름을 바꾼다.
  2. 성능 gate가 필요하면 dedicated runner에서 warmup/sample count, acquire latency, pending, throughput, variance, threshold를 정의하고 JSON/JUnit artifact를 생성한다. release job은 assertions=true를 강제한다.
  3. release manifest는 required readiness/R2 artifacts의 commit SHA, task, content hash, producer version을 검증한다.
  4. 비용이 큰 R2는 모든 release에서 재실행하거나, 관련 source/migration hash가 변하지 않았을 때만 동일 SHA/ancestor evidence reuse를 허용하는 명시적 정책을 둔다.

필수 테스트

  • assertions=true mutation에서 threshold 초과가 실제 실패.
  • missing/stale/wrong-SHA R2 artifact가 promotion을 실패.
  • behavior-only 선택 시 release 문서에 수치 성능 보장 문구가 남지 않음.

JPA-020 — owner-safe idempotency V2는 구현됐지만 선택할 수 없고 한 클래스가 너무 많은 책임을 가진다

근거

  • PostgreSqlOwnerSafeIdempotencyStore.java:45-55는 stereotype이 없고 selector에 이 store를 고르는 값도 없어 production composition이 없다고 스스로 설명한다.
  • integration test는 직접 생성한다.
  • 이 class는 890 lines로 claim/start/renew/complete/fail/release/inspect, capability guard, SQL constants, row mapping, hashing, validation, transaction precondition을 모두 소유한다.
  • 현재 public IdempotencyStorePortV2 구현이지만 runtime에서는 사용할 수 없어 implemented-candidate다.
  • mutation guard는 현재 thread에 read-write transaction이 있다는 것만 확인하고 이 store의 DataSource resource가 bind됐는지는 확인하지 않는다. outbox V2의 exact hasResource(dataSource) guard보다 약하다.
  • row에서 codec/policy/replay metadata를 읽지만 replay 결과가 이를 충분히 검증하지 않고, duplicate transition digest도 TTL/disposition/response digest 같은 semantic argument를 모두 포함하지 않는다.

실패 모드

capability/report/docs가 존재를 기능으로 오인할 수 있고, provider selector를 성급히 추가하면 거대한 class의 transaction/capability/mapping seam을 한 번에 production으로 노출한다. SQL transition 하나를 수정할 때 unrelated hashing/row mapping과 충돌할 가능성이 높다.

구현 결정: provider Facade + package-private Gateways

  1. application의 canonical V2 port와 executor 계약을 먼저 하나로 고정한다.
  2. provider enum/selector에 owner-safe PostgreSQL 값을 추가하되 migration ACTIVE와 vendor=PostgreSQL을 동시에 요구한다.
  3. public facade는 port orchestration과 exact DataSource transaction precondition만 소유한다.
  4. 다음 package-private collaborator로 분해한다.
    • IdempotencyCapabilityGuard
    • IdempotencyClaimGateway
    • IdempotencyTransitionGateway
    • IdempotencyRowMapper
    • IdempotencyDigestPolicy
  5. collaborator를 모두 Spring bean으로 만들 필요는 없다. facade constructor에서 명시적으로 조립해 public bean surface를 늘리지 않는다.
  6. legacy JDBC/Redis/owner-safe V2에 동일 conformance contract를 적용하고 provider별 지원 기능 차이를 manifest에 기록한다.
  7. replay 결과는 codec id/version/policy revision과 DB authoritative time을 검증하고 operation digest는 모든 semantic input을 포함한다. 같은 operation id와 다른 input은 RESULT_CONFLICT로 분류한다.

필수 테스트

  • disabled/jdbc/redis/owner-safe 각각 store/executor bean exact count.
  • full scanned context에서 claim→start→complete→replay와 owner/fence loss.
  • capability inactive, wrong vendor, transaction absent에서 work 실행 전 fail-closed.
  • wrong-DataSource transaction, codec mismatch, expired inspect, same operation/different arguments.

JPA-021 — notification schema가 tenant 관계와 queue query를 DB 불변식으로 보강하지 않는다

근거

  • V1의 notification_recipient_delivery.notification_id는 request id만 FK로 잡고 tenant 일치를 보장하지 않는다(V1...sql:11-34,40-65).
  • notification_delivery_attempt.contact_point_id는 NOT NULL이지만 FK가 없다(:80-110). contact point table은 V2에서 만들어진다.
  • expired-dispatch query는 delivery_state='DISPATCHING' AND lease_until ...을 사용하지만 V1의 queue indexes(:67-78)에는 이 조건을 지원하는 partial index가 없다.

실패 모드

DB 자체는 tenant B recipient가 tenant A request를 참조하는 행을 허용한다. 존재하지 않거나 삭제된 contact point를 attempt가 참조할 수 있다. expired lease sweep은 데이터가 커질수록 불필요한 row/index scan을 할 수 있다. 마지막 성능 영향은 representative cardinality로 아직 측정하지 않았으므로 확정 장애가 아니라 검증해야 할 위험이다.

구현 결정: V4 relational invariant + measured index

  1. request에 (id, tenant_id) unique key를 두고 recipient (notification_id, tenant_id) composite FK를 추가한다.
  2. contact point retention이 attempt보다 길다면 V4에서 FK를 추가한다. 익명화/삭제 정책 때문에 FK가 불가능하면 immutable contact snapshot과 cleanup invariant를 명시한다.
  3. WHERE delivery_state='DISPATCHING' partial index를 (lease_until, id)로 검토한다.
  4. index는 대표 row distribution의 EXPLAIN (ANALYZE, BUFFERS)와 write amplification을 측정한 뒤 채택한다.
  5. existing orphan/cross-tenant row를 사전 query로 탐지하고 0건일 때 constraint를 validate한다.

필수 테스트

  • cross-tenant FK와 nonexistent contact insertion 거부.
  • online NOT VALID→backfill/audit→VALIDATE CONSTRAINT upgrade.
  • due/expired cardinality별 plan shape와 bounded planner-estimate error.

JPA-022 — auditauditing이 다른 column/actor/lifecycle 계약으로 공존한다

근거

  • canonical 문서와 sample은 manual audit/AuditableEntity를 사용한다.
  • AuditableEntity.java:13-47updated_at/updated_by, actor length 256, 명시적 initializeAudit/applyModification을 사용한다.
  • auditing/AuditMetadata.java:29-46은 Spring Data annotation과 modified_at/modified_by, actor length 64를 사용한다.
  • JpaAuditingConfiguration은 Spring @Configuration이 아니고 production consumer도 검색되지 않는다.

실패 모드

새 entity 작성자가 두 package 중 하나를 임의로 고르면 table마다 column name/length/capture lifecycle이 갈린다. 둘을 동시에 적용하면 같은 의미를 두 번 stamp하거나 migration이 entity마다 달라진다.

구현 결정: one canonical technical audit model

  1. 지금은 auditing을 candidate로 명시하고 Stable capability report에서 제외한다.
  2. 승격 시 schema 호환을 우선하면 AuditMetadataupdated_*, length 256에 맞추고 existing entity를 단계적으로 embeddable로 전환한다.
  3. modified_* schema를 선택하면 forward migration, sample 전환, AuditContextPort → AuditorAware, Clock → DateTimeProvider bridge를 원자적으로 적용한다.
  4. bulk/native update는 어느 mechanism도 자동 stamp하지 않으므로 explicit audit update policy를 둔다.
  5. entity가 audit mechanism을 exactly one 또는 zero만 쓰게 ArchUnit rule을 추가한다.

필수 테스트

  • fixed Clock/actor insert/update stamp, immutable create columns.
  • column snapshot과 old→new migration validate.
  • bulk/native update audit behavior와 exactly-one architecture rule.

JPA-023 — 318개 public type과 app-bootstrap의 구현 import가 리팩터링 경계를 고정한다

근거

  • production 324 Java file 중 318개에 public top-level type 선언이 있다.
  • 명시적 api package 외 entity/repository/mapper/adapter 대부분도 public이다.
  • root package-info.java는 짧은 anchor뿐이고 package별 Stable/Advanced/Candidate/Experimental/export 상태가 없다.
  • NotificationPlatformPersistenceConfig.java:3-24는 app-bootstrap에서 implementation/repository 22개를 직접 import한다.
  • 같은 leaf의 가장 큰 class는 owner-safe idempotency 890 lines, same-store inbox 588, polling delivery 531, immutable outbox append 380, policy transaction executor 287 lines다.

실패 모드

.api 밖 구현도 cross-leaf source contract처럼 굳어 package 이동/visibility 축소가 bootstrap compile을 깨뜨린다. capability 상태가 package나 Gradle artifact가 아니라 문서 관례라 dead/candidate class가 Stable API처럼 보인다.

구현 결정: Export Allowlist + adapter-owned Configuration Facade

  1. cross-leaf export를 application ports, 좁은 persistence SPI, adapter-owned configuration facade로 allowlist한다.
  2. app-bootstrap은 NotificationJpaPersistenceConfiguration 같은 facade 하나만 import하고 entity/repository/ mapper는 facade package 안에서 package-private로 조립한다.
  3. package별 package-info.java 또는 machine catalog에 상태와 allowed consumers를 기록하고 ArchUnit이 외부 import를 검사한다.
  4. 큰 class는 line count만으로 나누지 말고 transaction/state-machine 경계가 보이는 gateway로 분해한다.
  5. 현재 19-leaf registry는 유지한다. physical module 분리는 dependency graph와 runtime membership을 원자적으로 바꾸는 별도 architecture 승인 사항이다.

권장 목표 package 구조

dev.caskeleton.adapter.outbound.persistence
├── configuration
│   ├── core
│   ├── fileserver
│   └── notification
├── platform
│   ├── transaction
│   ├── query
│   ├── provider.hibernate
│   └── vendor
│       ├── postgresql
│       └── h2
├── capability
│   ├── idempotency
│   │   ├── entity
│   │   ├── repository
│   │   └── adapter
│   ├── outbox
│   ├── fileserver
│   └── notification
└── experimental   # 별도 feature variant가 소유

이 tree는 최종 방향이며 한 번에 324 file을 이동하지 않는다. 먼저 configuration facade와 exact package catalog를 도입한 뒤 capability 단위로 이동한다.

JPA-024 — 문서가 실제 baseline과 검증 범위를 여러 곳에서 다르게 말한다

근거

  • support matrix는 H2에 SKIP LOCKED guarantee가 없다고 말하지만 module CLAUDE.md는 현재 H2 2.4.240이 syntax와 실제 skip을 수용한다고 측정해 기록한다. “현재 관찰 동작”과 “production guarantee로 인정하지 않음”을 구분해야 한다.
  • repository-adaptation.md:38-39JpaModuleBoundaryTest가 full package map을 재현한다고 말하지만 :100은 registry/app-bootstrap test가 같은 역할을 한다고 적고 실제 rule도 불완전하다.
  • Hibernate 7.4 declared baseline과 실제 7.1.8 execution이 같은 Stable row/gate 이름에 섞여 있다.
  • performance lane는 certification/reporting이라고 설명하지만 artifact/threshold가 없다.

필요 조치

  1. JPA-017의 typed manifest에서 provider/database/lane 상태를 렌더링한다.
  2. H2 문구는 “현재 버전에서 관찰됐지만 PostgreSQL contract evidence로 인정하지 않는다”로 통일한다.
  3. package boundary 문서는 exact catalog가 실제 구현된 뒤에만 “enforced”라고 쓴다. 그전에는 known gap을 명시한다.
  4. documentation contract test는 단순 문자열 존재가 아니라 manifest/task/resolved version과 비교한다.

JPA-025 — notification idempotency loser가 aborted PostgreSQL transaction에서 winner를 조회한다

근거

  • JpaNotificationRequestStore.java:48-54saveAndFlush의 named unique violation을 catch/translate한다.
  • NotificationSubmissionService.java:105-124는 전체 submit을 같은 transactions.inWrite lambda에서 실행한다.
  • 같은 lambda의 catch(:117)에서 winner를 즉시 SELECT한다(:118-121).
  • PostgreSQL은 statement-level unique violation 뒤 현재 transaction을 aborted 상태로 두며 rollback 전 후속 SQL을 허용하지 않는다.

실패 모드

동일 tenant/idempotency key의 두 요청이 pre-read를 동시에 miss하면 loser insert가 unique violation을 낸다. adapter가 exception을 application conflict로 바꿔도 physical transaction은 aborted다. 같은 payload의 winner를 읽으려는 SELECT는 SQLSTATE 25P02 또는 최종 UnexpectedRollbackException으로 실패해 원래 의도인 “동일 fingerprint는 같은 receipt로 수렴”을 만족하지 못한다.

구현 결정: database-authoritative Insert Outcome

  1. port에 NotificationRequestInsertOutcome tryInsert(...)를 추가한다.
  2. PostgreSQL adapter는 INSERT ... ON CONFLICT (tenant_id,idempotency_key) DO NOTHING RETURNING id를 사용한다.
  3. returned id가 있으면 winner이며 그때만 recipient jobs를 저장한다.
  4. 0 row면 transaction을 poison하는 exception 없이 existing winner를 tenant/key로 읽고 fingerprint를 비교한다.
  5. 다른 fingerprint면 application conflict, 같은 fingerprint면 existing receipt를 반환한다.
  6. REQUIRES_NEW로 예외를 격리하는 대안은 connection/transaction 경계를 늘리고 request+recipients atomicity를 복잡하게 하므로 이 경우 권장하지 않는다.

필수 테스트

  • barrier를 사용한 same key 2-thread real PG test.
  • same fingerprint: 동일 notification id/receipt, row 1개, 25P02 없음.
  • different fingerprint: 한 success/한 deterministic conflict, orphan recipient 0개.
  • winner transaction rollback 시 loser가 phantom receipt를 반환하지 않음.

JPA-026 — 먼저 온 provider callback과 duplicate/status update가 durable state machine을 이루지 못한다

근거

  • JpaProviderEventLedger.java:71-78은 pre-read 뒤 insert해 concurrent duplicate race를 unique constraint exception으로 노출한다.
  • provider request raw id는 hash만 저장하지만 entity→record rehydrate 시 raw id를 Optional.empty()로 버린다(:152-176,190-205).
  • projector resolver는 attempt id 또는 raw request id만 사용한다 (ProviderEventProjectionService.java:84-95).
  • ProviderEventEntity.attemptIdupdatable=false인데 bind mutator가 있다(:40-42,124-127).
  • unmatched/pending query와 bindAttempt의 production scheduler caller가 없다.
  • NO_PROJECTOR 경로는 transaction 밖에서 markFailed를 호출하고, adapter는 detached entity만 mutate한 뒤 explicit save/update를 하지 않는다.

실패 모드

  • 동일 callback 두 개가 pre-read를 모두 통과하면 loser는 duplicate outcome이 아니라 HTTP/transaction failure가 된다.
  • callback이 attempt의 provider id 저장보다 먼저 오면 attempt_id=null event가 남는다. later sweep이 raw id를 보지 못해 영구 PENDING이 된다.
  • projector 부재를 FAILED로 기록했다고 생각하지만 detached mutation이 DB에 반영되지 않을 수 있다.

구현 결정: append idempotency + hash-based reconciliation state machine

  1. event append도 ON CONFLICT DO NOTHING RETURNING id로 만들고 0 row면 existing event를 읽어 duplicate outcome을 반환한다.
  2. application record에 bounded ProviderRequestIdHash를 보존하고 resolver port에 byProviderRequestIdHash(profile, hash)를 추가한다. raw provider id는 계속 저장하지 않는다.
  3. attempt_id를 update 가능하게 바꾸고 UPDATE ... SET attempt_id=? WHERE id=? AND attempt_id IS NULL CAS를 둔다.
  4. bounded ProviderEventProjectionWorker가 unmatched/pending을 claim, resolve, bind, project한다.
  5. projection status transition은 @Modifying CAS update 또는 application-owned write transaction 안의 save로 명시하고 affected row를 확인한다.

필수 테스트

  • concurrent duplicate: created 1, duplicate 1, exception 0.
  • callback-before-attempt → attempt 저장 → sweep bind/project → 재실행 no-op.
  • NO_PROJECTOR 후 DB row가 FAILED이고 retry policy가 명확함.
  • stale projector가 terminal status를 되돌리지 못함.

JPA-027 — batch clear가 flush되지 않은 entity를 detach해 성공한 것처럼 유실한다

근거

  • JpaBatchProfile.java:35-42clearSize >= flushSize만 검증한다.
  • HibernateJpaBatchExecutor.java:48-60은 flush와 clear를 서로 독립적인 modulus로 실행한다.
  • clear 직전에 항상 flush한다는 불변식이 없다.

실패 모드

flushSize=100, clearSize=150, 300 rows이면 100에서 flush한 뒤 101~150 entity를 persist한다. 150에서 flush 조건은 false이고 clear 조건만 true라 50개 unflushed managed entity가 detach된다. executor는 processed=300을 성공으로 반환할 수 있지만 DB에는 250개만 남는다.

구현 결정: flush-before-clear invariant

  1. 모든 clear branch는 무조건 entityManager.flush()clear()한다.
  2. 같은 index에서 regular flush가 이미 실행됐더라도 중복 flush는 correctness를 위해 허용한다. 필요하면 loop에서 lastFlushedIndex로 중복만 줄인다.
  3. 대안으로 clearSize % flushSize == 0을 constructor에서 강제할 수 있지만 두 knob의 독립 조정 의미를 포기한다. 기존 API가 독립 값을 노출하므로 flush-before-clear가 더 안전하다.
  4. final remainder flush와 exception rollback도 명시적으로 검증한다.

필수 테스트

  • real PG에서 300 rows, (100,150) 후 count 정확히 300.
  • (100,100), (100,250), row count < flush size, exact boundary.
  • 151번째 action failure 시 전체 transaction rollback 또는 문서화된 partial semantics.

JPA-028 — fileserver cleanup claim은 crash 후 복구되지 않고 active writer와 경쟁한다

근거

  • FileserverCleanupRepository.java:31-40의 claim은 status를 IN_PROGRESS로 바꾸지만 owner/token/ lease-until/version을 기록하지 않는다.
  • completion/failure update도 id-only unconditional이다(:42-58).
  • worker crash 후 expired IN_PROGRESS를 reclaim하는 query/caller가 없다.
  • staging cleanup은 session lease를 read-check한 뒤 외부 delete한다 (DefaultCleanupService.java:130-137).
  • writer acquire SQL은 file lifecycle/cancel fact를 확인하지 않고 upload expiry/lease만 본다 (UploadLeaseRepository.java:20-40).
  • append 역시 session expiry는 보지만 terminal file state를 검사하지 않는다.

실패 모드

  • physical delete 뒤 process가 DB settlement 전에 죽으면 row가 영구 IN_PROGRESS이고 quota/file state가 정산되지 않는다.
  • cleanup이 “lease 없음”을 읽은 직후 writer가 lease를 획득하면 cleanup이 active staging file을 삭제할 수 있다.

구현 결정: fenced cleanup lease + terminal upload state

  1. cleanup item에 claim owner/token/lease-until/version을 추가하고 CleanupClaim이 token을 반환한다.
  2. done/failed/retry transition은 full token/version CAS를 사용하고 expired-claim reaper를 둔다.
  3. upload session에 작은 ACTIVE | TERMINAL state를 추가한다. cancel/finalize transaction이 terminalize하고 acquire/renew/append는 ACTIVE만 허용한다.
  4. cleanup은 TERMINAL + writer lease expired 조건을 database에서 claim한 뒤 physical delete한다.
  5. delete success 후 settlement가 실패해도 동일 token/reconciliation로 idempotently 재정산한다.

필수 테스트

  • claim → simulated crash/time advance → 다른 worker reclaim.
  • cancel/cleanup vs writer acquire barrier에서 acquire 또는 delete 정확히 하나만 승리.
  • stale cleanup token으로 terminal settlement 불가.
  • physical delete success/DB failure 재시도에서 quota double-decrement 없음.

JPA-029 — inbox cursor는 tuple인데 bulk cutoff는 timestamp만 쓰고 signal retry는 구현되지 않았다

근거

  • InboxCursor.java:7-17과 list query는 (createdAt DESC, id DESC) total order를 사용한다.
  • JpaNotificationInbox.java:143-150의 mark-all-read는 createdAt만 repository에 넘긴다.
  • InboxItemJpaRepository.java:69-85created_at <= cutoff만 사용한다.
  • NotificationInboxSignalPort.java:3-7은 relay retry를 약속하지만 InboxCommitEventPublisher.java:42-47은 after-commit publish exception을 영구 swallow한다.
  • InboxOutboxRecordFactory는 production caller가 없다.

실패 모드

같은 millisecond에 여러 inbox row가 있을 때 middle cursor 기준 bulk mark-read가 cursor 뒤/앞의 같은 timestamp row까지 과다 update하거나 누락한다. after-commit signal이 한 번 실패하면 durable retry record가 없어 downstream notification이 영구 누락될 수 있다.

구현 결정: tuple cutoff + Transactional Outbox

  1. bulk API에 cutoff id를 함께 전달하고 정렬 의미에 맞는 tuple predicate를 사용한다.
  2. tenant/owner/category/state 조건을 그대로 유지하고 affected count를 결과에 포함한다.
  3. signal이 best-effort라면 port 문서와 운영 기대를 그렇게 낮춘다.
  4. retry가 계약이면 inbox mutation과 outbox append를 같은 application-owned transaction에서 수행하고 relay만 외부 publish한다. after-commit callback을 durability mechanism으로 사용하지 않는다.

필수 테스트

  • 동일 timestamp UUID 3개 중 middle cursor의 정확한 update 경계.
  • outbox append와 inbox update atomic rollback.
  • signal 첫 publish 실패 후 relay replay, duplicate publish consumer idempotency.

JPA-030 — 여러 safety policy가 runtime input을 실제로 제한하지 않는다

근거와 수정

  1. Dynamic query construction guard
    • RegisteredQueryWorkQueueDefinition은 완성된 runtime String에서 Java source token "' +"를 찾는다. concatenation 연산자는 이미 평가되어 사라졌으므로 보안 guard가 성립하지 않는다.
    • raw statement constructor를 외부에 노출하지 말고 enum/catalog id + typed parameter binder만 허용한다. 동적 construction 금지는 ArchUnit/source rule과 code review가 맡는다.
  2. Stateless max rows
    • HibernateStatelessSessionRunner.java:35-50maxRows를 양수 검증하지만 affected rows를 세거나 제한하지 않는다.
    • work가 StatelessWorkResult<T>(value, affectedRows)를 반환하게 하고 cap 초과 시 transaction을 rollback한다. 가능하면 query/loop 자체에 remaining budget을 전달한다.
  3. Stream fetch policy
    • JpaStreamExecutor.java:52-71의 supplier는 ScrollPolicy를 받지 않아 fetch size가 query에 적용되지 않는다.
    • Function<ScrollPolicy, Stream<T>> 또는 typed-query factory가 fetch size/hint를 설정한 뒤 stream을 열게 한다. resource close/transaction scope guard는 유지한다.

필수 테스트

  • unregistered/dynamic query path는 work 실행 전 거부.
  • stateless work가 maxRows+1에서 rollback되고 정확한 max까지 허용.
  • PG JDBC fetch behavior를 proxy/statement inspector로 확인하고 stream close 시 ResultSet/connection 반환.

6. 디자인 패턴 적용 지침

패턴은 이름을 늘리기 위해 적용하지 않는다. 현재 실패 모드에서 책임과 상태 전이를 한 곳으로 모으는 경우에만 사용한다.

문제 권장 패턴 적용 위치 핵심 제약
application transaction 계약 두 벌 Facade + Adapter PolicyTransactionPort 구현 application port 하나만 public, JPA engine은 internal
raw failure translation 분산 Chain of Responsibility + Strategy transaction attempt boundary completion-unknown 우선, terminal flag 재활성화 금지
keyset sort/predicate drift Query Object + Specification SafeKeysetOrder Sort/Predicate/Cursor fingerprint 한 SSOT
optional schema/bean scan Capability Module fileserver/notification configuration property만 아니라 schema ACTIVE와 marker scan을 함께 요구
ThreadLocal frame double-pop Scope/Token (AutoCloseable) transaction evidence identity 일치 frame만 owner가 close
worker/cleanup concurrency Explicit State Machine + fenced CAS notification/fileserver claims owner+token+version/fence를 모든 transition에 사용
890-line idempotency store Facade + package-private Gateway PostgreSQL idempotency package transaction orchestration과 SQL/row/digest 책임 분리
release/docs drift Typed Manifest + generated view docs/jpa, Gradle, workflow prose regex가 실행 권위가 되지 않음
app-bootstrap의 구현 import Configuration Facade adapter-owned configuration package bootstrap은 entity/repository를 직접 import하지 않음

6.1 적용하지 말아야 할 패턴

  • Generic Repository: JPA convenience를 이유로 GenericRepository<T,ID>를 만들지 않는다. tenant, lock, aggregate-specific query와 transition을 숨긴다.
  • 전역 BaseEntity: audit/soft-delete/version을 모든 table hierarchy에 강제하지 않는다. 필요한 entity가 opt-in하는 embeddable/mapped superclass만 사용한다.
  • Active Record: entity가 repository/service를 호출하거나 transaction을 시작하게 하지 않는다.
  • Class-per-state State pattern: notification/fileserver 상태 전이가 많아도 먼저 enum + transition table + conditional SQL로 표현한다. 상태별 class 수가 domain behavior를 실제로 단순화할 때만 고려한다.
  • outbound annotation magic: application service가 outbound adapter annotation을 import하게 하지 않는다. 명시적 application port 호출이 이 템플릿의 의존 방향과 더 잘 맞는다.
  • statement-only retry: serialization/optimistic conflict에서 실패한 SQL 한 줄만 재실행하지 않는다. application use case 전체를 새 transaction/Persistence Context에서 다시 실행한다.
  • blanket @Transactional: repository 결함을 가리기 위해 adapter class 전체에 붙이지 않는다. transaction owner는 application port이며, claim처럼 single-statement atomic SQL은 그 계약을 코드로 드러낸다.
  • runtime String 보안 검사: 이미 조립된 SQL에서 source concatenation 흔적을 찾지 않는다. typed catalog와 construction API를 제한한다.

7. 권장 구현 순서

각 phase는 별도 PR/merge 단위로 만들 수 있다. 앞 phase의 characterization test가 뒤 refactoring의 safety net이다.

Phase 0 — 기준선과 false evidence 차단

  1. current P0 scenario를 재현하는 failing test부터 추가한다.
    • batch (100,150) 300-row count
    • notification V3 migration + Hibernate validate
    • same-key concurrent submission
    • stale lease renew/release
  2. JpaPlatformContractSupport.start()가 multi-version selection을 거부하게 해 tag release의 false green을 먼저 끊는다.
  3. release workflow를 PG16/17/18 single-major matrix로 바꾼다.
  4. 실제 migration tree를 쓰지 않는 lane은 이름/claim을 낮추고 JPA-010 구현 전 promotion blocking으로 표시한다.

완료 조건: 기존 false-positive lane이 실패하도록 만든 negative fixture가 있고, 기존 behavior를 우연히 green으로 유지하는 bypass가 없다.

Phase 1 — 즉시 데이터 유실/중복 수정

  1. HibernateJpaBatchExecutor에 flush-before-clear 불변식을 적용한다.
  2. notification V4 migration과 JSON mapping을 추가하고 nested repositories를 top-level로 분리한다.
  3. notification schema activation/readiness와 conditional marker scan을 구현한다.
  4. request insert와 provider event append를 ON CONFLICT ... DO NOTHING RETURNING outcome으로 바꾼다.
  5. recipient claim/renew/release와 provider event bind/status를 token/fence CAS로 바꾼다.
  6. existing V1~V3 checksum을 변경하지 않았는지 검증한다.

완료 조건: fresh/V1/V2/V3 upgrade, Hibernate validate, JSON round-trip, concurrent submission/callback/lease tests가 real PG에서 통과한다.

Phase 2 — application policy와 capability 경계 복원

  1. notification roll-up을 application service로 되돌리고 tenant-aware port를 추가한다.
  2. broad JpaRepository 상속을 narrow Spring Data repository로 바꾼다.
  3. candidate PG adapters의 stereotype을 제거하고 provider/capability configuration에서만 조립한다.
  4. fileserver cleanup fenced claim과 terminal upload state를 추가한다.
  5. inbox tuple cutoff와 durable signal(outbox 또는 honest best-effort)을 결정한다.

완료 조건: capability off에서 entity/repository/store 0개, wrong-tenant access 0 row, worker crash/stale owner 경쟁 test 통과.

Phase 3 — transaction engine 단일화

  1. PolicyTransactionPort를 canonical API로 확정하고 old/new parity test를 만든다.
  2. translation chain과 exact context factory를 canonical executor에 연결한다.
  3. identity TransactionEvidenceScope를 도입하고 pop owner를 하나로 만든다.
  4. JPA platform config를 실제 composition root bean graph로 전환한다.
  5. 호출을 canonical engine으로 이관한 뒤 dead annotation/coordinator/classifier를 삭제하거나 internal로 축소한다.

완료 조건: application에 outbound JPA import 0개, real 40001/40P01/optimistic retry, 08xxx no-retry+ reconciliation, nested REQUIRES_NEW scope cleanup 통과.

Phase 4 — query/API/package 경계

  1. SafeKeysetOrder로 sort/predicate/cursor를 통합한다.
  2. cursor token/payload bound를 적용한다.
  3. stateless maxRows, stream fetch policy, registered-query construction boundary를 실행 코드에 연결한다.
  4. exact package catalog/allowed edge/cycle rule을 만들고 reusable architecture rule을 production graph에 적용한다.
  5. adapter-owned configuration facade를 만든 뒤 public/internal surface를 capability 단위로 줄인다.

완료 조건: mixed direction/type paging, oversized cursor, maxRows/fetch policy, package mutation fixture가 모두 의도대로 실패/통과한다.

Phase 5 — release/experimental/docs 정합화

  1. typed release manifest를 도입하고 docs/workflow/task를 생성 또는 exact 검증한다.
  2. migration snapshots와 same-SHA R2 evidence를 release prerequisite로 연결한다.
  3. experimental feature variant와 executable Hibernate8/JPA4/PG19 lane을 만든다.
  4. performance lane을 behavior contract 또는 실제 measured gate 중 하나로 명확히 정한다.
  5. audit model 하나를 canonical로 선택하고 docs/H2/Hibernate/package claims를 실제 실행과 맞춘다.

완료 조건: support matrix의 각 Stable claim에서 exact task, target version, artifact SHA/digest로 추적할 수 있다.

7.1 권장 PR 분할

PR 포함 범위 섞지 않을 항목
PR-1 PG version selection fail-closed + release matrix package 이동
PR-2 batch flush-before-clear + regression notification
PR-3 notification V4/JSON/top-level repositories/validate worker state machine
PR-4 notification schema activation + conditional scan transaction engine
PR-5 request/event upsert + lease/event fencing docs/release manifest
PR-6 tenant-aware port + roll-up policy 이동 keyset
PR-7 canonical transaction facade + translator + evidence scope experimental variant
PR-8 keyset/cursor/stream/stateless safety public package 대이동
PR-9 fileserver cleanup + inbox outbox release manifest
PR-10 package catalog/export facade/public 축소 physical Gradle leaf 추가
PR-11 typed release manifest/migration snapshots/experimental lanes domain 기능 추가

8. 권장 검증 매트릭스

8.1 매 변경의 기본 검증

cd src
./gradlew :application-core:test \
  :adapter:outbound:persistence-jpa:test \
  :app-bootstrap:test \
  --rerun-tasks --no-daemon --max-workers=2 --console=plain

./gradlew verifyCleanArchitectureDependencies \
  verifyRuntimeModuleMembership \
  verifyDependencyLocks \
  verifyPublicPathSnapshot \
  verifyEnvKeys \
  --rerun-tasks --no-daemon --max-workers=2 --console=plain

8.2 notification/batch/fileserver 변경

cd src
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest \
  :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest \
  :adapter:outbound:persistence-jpa:postgresqlFileserverReclamationIntegrationTest \
  -Pjpa.matrix.versions=16 \
  --rerun-tasks --no-daemon --max-workers=2 --console=plain

같은 migration/contract를 PG17과 PG18에서 각각 별도 process/job으로 실행한다. notification 전용 test는 tag나 class selector로 분명히 드러나야 하며 test discovery 0은 실패해야 한다.

8.3 transaction 변경

cd src
./gradlew :adapter:outbound:persistence-jpa:test \
  --tests '*Transaction*' \
  --rerun-tasks --no-daemon --max-workers=2 --console=plain

./gradlew :adapter:outbound:persistence-jpa:jpaPlatformFailureTest \
  -Pjpa.matrix.versions=16 \
  --rerun-tasks --no-daemon --max-workers=2 --console=plain

그 다음 PG17/18 failure lane을 별도 실행한다. commit ambiguity는 body replay count 0과 reconciliation key 보존을 함께 assert한다.

8.4 architecture/package 변경

cd src
./gradlew :adapter:outbound:persistence-jpa:test \
  --tests '*JpaModuleBoundaryTest' \
  --tests '*JpaProductionArchitectureTest' \
  --rerun-tasks --no-daemon --max-workers=2 --console=plain

./gradlew check --rerun-tasks --no-daemon --max-workers=2 --console=plain

8.5 release 후보

release job은 major별로 다음 non-performance lane을 모두 실행해야 한다.

./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest \
  :adapter:outbound:persistence-jpa:jpaPlatformMigrationTest \
  :adapter:outbound:persistence-jpa:jpaPlatformFailureTest \
  :adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest \
  :adapter:outbound:persistence-jpa:jpaPlatformSecurityTest \
  -Pjpa.matrix.versions=16 \
  --rerun-tasks --no-daemon --max-workers=2 --console=plain

aggregate promotion은 세 major artifact, migration/R2 manifest, resolved provider, image digest, commit SHA가 모두 일치할 때만 통과한다.

9. 이번 리뷰에서 실행한 검증

9.1 성공

cd src
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
  • fresh Gradle process: BUILD SUCCESSFUL in 30s.
  • 10 actionable tasks: 7 executed, 3 up-to-date.
  • 이 성공은 hermetic unit lane의 현재 회귀가 없다는 증거다. runtime composition, notification migration, PostgreSQL concurrency, PG17/18 compatibility를 증명하지 않는다.
  • 리뷰 문서 작성 후 current dirty worktree에서 같은 task를 --rerun-tasks --no-daemon --max-workers=2로 다시 실행했다. BUILD SUCCESSFUL in 32s, 10 actionable tasks 전부 executed였다. 다른 사용자 변경과 unmerged SpotBugs 설정이 존재해도 scoped JPA unit lane은 fresh 성공했다.
cd src
./gradlew :adapter:outbound:persistence-jpa:test \
  --tests '*JpaModuleBoundaryTest' --rerun-tasks --console=plain
  • 병렬 architecture review에서 BUILD SUCCESSFUL in 1m 55s, 10 tasks 실행.
  • 현재 cycle/unregistered packages가 있어도 test가 통과한다는 JPA-015의 false-negative를 재확인했다.
cd src
./gradlew verifyJpaReadinessRegistry \
  :adapter:outbound:persistence-jpa:verifyJpaEvidenceHarnessContract \
  --rerun-tasks --console=plain
  • 병렬 test/ops review에서 성공.

  • 현재 exact 16 cards/8 owned streams와 skip/dirty/content-mutation fail-closed를 확인했다.

  • notification stream이 그 exact set에 없다는 JPA-002의 근거이기도 하다.

  • :adapter:outbound:persistence-jpa:check --dry-run: custom source-set 정적 분석은 포함되지만 platform Docker test tasks가 기본 check에 포함되지 않음을 확인했다.

  • jpaReleaseGate --dry-run: platform 6개 lane은 포함되지만 readiness/candidate/R2 evidence가 release dependency에 없음을 확인했다.

  • 신규 리뷰 문서: git diff --no-index --check /dev/null <review-file> whitespace 진단 0 bytes, Markdown fence 18개(even), priority table/상세 heading JPA-001~030 exact set 일치, placeholder/금지 과장 표현 grep 결과 0건.

9.2 실패 또는 제약

  • 최초 sandbox unit 실행은 user Gradle cache의 .zip.lck write 권한 때문에 실패했고 승인된 실행으로 재시도해 위 unit success를 얻었다. source failure로 분류하지 않았다.
  • 한 병렬 agent의 추가 :test --rerun-tasks는 다른 Gradle process와 shared output이 경합해 application-core JAR/compile-result 관련 실패가 났다. 독립 재시도 결과가 아니라서 source defect 증거로 사용하지 않았다.
  • 리뷰 후반에 다른 사용자 작업이 root build/settings를 대량 변경하고 src/config/spotbugs/exclude.xmlUU conflict 상태로 둔 것을 확인했다. 사용자 변경을 되돌리거나 conflict를 해결하지 않았다. 최종 fresh root check 가능 여부는 아래에서 별도로 기록한다.

9.3 실행하지 않은 검증

  • Docker-backed jpaPlatformContract/Migration/Failure/QueryPlan/Security/PerformanceTest 실제 실행.
  • PG17/18 full suite.
  • notification V1~V3 migration + Hibernate validate + JSON CRUD. 현재 전용 test가 없다.
  • app-bootstrap 전체 application context와 actuator endpoint/advisor runtime probe.
  • 실제 PostgreSQL two-worker race, commit acknowledgement loss, fileserver crash recovery.

실행하지 않은 영역은 이전 기록이나 test 이름을 이번 fresh 운영 증거로 승격하지 않는다.

10. Definition of Done

다음 조건을 모두 만족하기 전에는 이 리뷰를 “수정 완료”로 닫지 않는다.

  • JPA-001~004, 025, 027의 P0 failing test가 먼저 추가되고 수정 후 real PG에서 통과한다.
  • notification V1~V3는 checksum을 유지하고 V4 forward migration으로 정렬된다.
  • notification disabled context에 entity/repository/store가 없고 enabled context는 schema ACTIVE를 요구한다.
  • notification 7개 entity의 JSONB field 8개 mapping과 template_locale가 Hibernate validate/CRUD를 통과한다.
  • same-key submission, duplicate callback, stale lease/cleanup owner가 deterministic outcome으로 수렴한다.
  • request roll-up과 tenant policy가 application에 있고 persistence는 mapping/conditional SQL만 소유한다.
  • application-facing transaction 계약은 PolicyTransactionPort 하나이며 outbound JPA annotation/API를 import하지 않는다.
  • raw optimistic/40001/40P01가 번역·retry되고 completion-unknown은 재실행되지 않는다.
  • nested REQUIRES_NEW 종료 후 outer evidence/key가 유지되고 thread-local이 남지 않는다.
  • mixed type/direction keyset이 모든 row를 정확히 한 번 반환하고 cursor input이 bounded다.
  • package catalog가 22개 top-level package exact set, allowed edge, cycle, status/export를 강제한다.
  • production graph에 reusable JPA architecture rules가 실제 적용된다.
  • PG16/17/18 각각에서 full release lane artifact가 같은 SHA로 생성된다.
  • migration lane이 실제 empty/N-1/oldest snapshot과 Hibernate validate를 실행한다.
  • support docs, resolved Hibernate/database version, Gradle task, workflow, evidence manifest가 한 typed SSOT와 일치한다.
  • focused test, full test, full check, architecture validators의 실행 명령과 결과를 PR에 남긴다.
  • Docker/보호 환경 때문에 실행하지 못한 검증은 이유와 남은 위험을 명시한다.

11. LLM Wiki capture

이 리뷰는 의미 있는 아키텍처/코드 감사이므로 root AGENTS.md의 capture 대상이다. 최종 응답 전에 /home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/main.md에 current HEAD, finding, 변경 파일, 검증, 미실행 Docker lane, evidence grade를 추가한다. 이번 작업은 구현 전 read-only review이므로 별도 canonical wiki/ 추출이나 interview/blog 파생 문서는 만들지 않는다.

실제 capture 결과:

  • raw/branch-notes/main.md2026-08-14 캡처 — merge 이후 JPA persistence 모듈 상세 리뷰를 추가했다.
  • 제품 HEAD/규모/30개 finding 요약, 단계별 결정, 변경 파일, 성공·실패·미실행 검증과 evidence grade를 기록했다.
  • raw/errors/는 새 실패 모드가 없어 별도 생성하지 않았고, shared Gradle output 경합은 기존 [[raw/errors/parallel-gradle-shared-build-race-2026-08-10]]를 가리켰다.
  • raw/interviews/, raw/blog-topics/, canonical wiki/는 구현 전 read-only finding이므로 별도 파생 없음으로 기록했다.
  • wiki_structure_lint.py --file raw/branch-notes/main.md --links-only: PASS.
  • full single-file lint는 제품 규칙이 요구하는 실제 branch 파일 main.md와 vault의 4-prefix naming rule이 충돌해 기존 NAMING_VIOLATION 1건으로 실패했다. 파일을 임의 rename하거나 어느 정책도 완화하지 않았다.
  • untracked branch-note에 대한 git diff --no-index --check /dev/null ...은 whitespace 진단 출력 0 bytes였다 (내용 diff 때문에 command exit 1은 정상).