1606 lines
95 KiB
Markdown
1606 lines
95 KiB
Markdown
# 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-core`와 `application-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 | `audit`와 `auditing` 계약이 병존 | 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.yml`을 `strategy.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-47`은 `template_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 varying`을 `jsonb` 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-49`의 `markLeased` update는 `WHERE id IN (:ids)`만 검사한다.
|
|
- `JpaRecipientLeaseStore.java:50-57`의 `renew()`가 같은 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-25`의 `recipientsOf(NotificationId)`와
|
|
`refreshStatus(NotificationId)`는 tenant를 받지 않는다.
|
|
- `NotificationRequestJpaRepository`는 주석과 달리 `JpaRepository`를 상속해 tenant 없는 `findById`,
|
|
`findAll`, `deleteById`를 모두 노출한다.
|
|
- `JpaRecipientDeliveryStore.java:26-37,59-64`와 `JpaNotificationRequestStore.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-68`은 `bootstrap.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-99`는 `JpaPersistenceException`만 catch한다.
|
|
- `SpringJpaTransactionExecutor.java:58-70`은 `TransactionTemplate`을 실행하지만 raw failure translator를
|
|
호출하지 않는다.
|
|
- `PostgreSqlExceptionTranslator`와 `OptimisticConflictTranslator`는 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-25`는 `ThreadLocal.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-100`은 `UnknownOperation`, 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-35`는 `dev.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-310`은 `performance.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 — `audit`와 `auditing`이 다른 column/actor/lifecycle 계약으로 공존한다
|
|
|
|
**근거**
|
|
|
|
- canonical 문서와 sample은 manual `audit/AuditableEntity`를 사용한다.
|
|
- `AuditableEntity.java:13-47`은 `updated_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 호환을 우선하면 `AuditMetadata`를 `updated_*`, 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 구조**
|
|
|
|
```text
|
|
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-39`는 `JpaModuleBoundaryTest`가 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-54`는 `saveAndFlush`의 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.attemptId`는 `updatable=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-42`는 `clearSize >= 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-85`도 `created_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**
|
|
- `RegisteredQuery`와 `WorkQueueDefinition`은 완성된 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-50`은 `maxRows`를 양수 검증하지만 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 매 변경의 기본 검증
|
|
|
|
```bash
|
|
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 변경
|
|
|
|
```bash
|
|
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 변경
|
|
|
|
```bash
|
|
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 변경
|
|
|
|
```bash
|
|
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을 모두 실행해야 한다.
|
|
|
|
```bash
|
|
./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 성공
|
|
|
|
```bash
|
|
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 성공했다.
|
|
|
|
```bash
|
|
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를 재확인했다.
|
|
|
|
```bash
|
|
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.xml`을 `UU` 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.md`에 `2026-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은 정상).
|