# grpc-operation-ledger-jpa 완전 해부 > 상태: COMPLETE > 재오픈 게이트: cycle 2 — `src/main` production 3파일과 마이그레이션 1개 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음. > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/grpc/grpc-operation-ledger-jpa` > SSOT owner: `grpc-operation-ledger-jpa` > integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지와 숫자 지도 - `allowed_dependencies`: `["grpc-core-api"]` - `runtime_memberships`: **`[]`** — build-only | 파일 | LOC | 성격 | |---|---:|---| | `GrpcOperationLedgerEntity` | 155 | JPA 엔티티 + 상태 전이 | | `JpaGrpcOperationLedger` | 93 | 포트 구현 (insert-first 주장) | | `GrpcOperationLedgerRepository` | 28 | Spring Data 인터페이스 (메서드 4개) | | `V001__create_grpc_operation_ledger.sql` | 39 | 테이블 + 제약 4 + 인덱스 1 | | `GrpcOperationLedgerRepositoryTest` | 208 | 테스트 (인메모리 이중) | | `build.gradle` | 17 | project 1 + vendor 2 | ### Coverage ledger | scope | count | disposition | reason | |---|---:|---|---| | `main/java/**` | 3 | `FULL_READ` | 155+93+28 전 본문 | | `main/resources/db/migration/grpc/*.sql` | 1 | `FULL_READ` | 39줄 전문 | | `test/java/**` | 1 | `FULL_READ` | 208줄, 인메모리 이중 구현 포함 | | `build.gradle` | 1 | `FULL_READ` | 17줄 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체 ```groovy // build.gradle:3-8 // Durable mutation idempotency: the operation ledger entity, its state machine, the vendor-neutral // repository port and the Spring Data JPA binding, plus the migration that owns the unique // constraint the whole contract rests on. // … Tests here run against a hand-rolled in-memory port implementation — a real datastore is only // justified when vendor semantics are the thing under test, and the constraint is asserted by the migration. ``` 마지막 문장이 이 리프의 검증 전략을 규정한다. §17.1 이 그 전략의 경계를 다룬다. ## 2. 스키마가 계약이다 ```sql CONSTRAINT pk_grpc_operation_ledger PRIMARY KEY (storage_key), CONSTRAINT uq_grpc_operation_ledger_identity UNIQUE (caller_fingerprint, full_method_name, idempotency_key_hash), CONSTRAINT ck_grpc_operation_ledger_state CHECK (state IN ('IN_PROGRESS', 'COMMITTED', 'FAILED_TERMINAL')), CONSTRAINT ck_grpc_operation_ledger_committed_has_outcome CHECK (state <> 'COMMITTED' OR outcome_reference IS NOT NULL), CONSTRAINT ck_grpc_operation_ledger_terminal_has_completion CHECK (state = 'IN_PROGRESS' OR completed_at IS NOT NULL) ``` 마이그레이션 헤더가 왜 애플리케이션 검사가 아니라 제약인지 적는다. > "A uniqueness check in application code instead would be a read followed by a write, with a window > between them precisely as wide as the race it is meant to close." 커밋 행이 결과를 반드시 갖는다는 검사를 자바 record 와 DB 양쪽에 둔 이유도 적혀 있다 — 마이그레이션·백필·지원 스크립트가 쓴 행은 record 를 지나지 않는다. 전용 Flyway 위치(`db/migration/grpc`)를 쓰는 이유도 적혀 있다. gRPC 플랫폼을 채택하지 않은 배포가 이 테이블을 만들도록 강요받지 않기 위해서다. ## 3. 저장 키와 유니크 제약이 같은 행을 가리킨다 `GrpcOperationIdentity`(grpc-core-api): ```java public String storageKey() { return callerFingerprint + "|" + method.canonical() + "|" + idempotencyKeyHash; } ``` 즉 기본 키는 유니크 제약의 세 컬럼을 이어 붙인 파생값이다. 엔티티 javadoc 이 그 이중 저장을 설명한다 — 복합 쪽이 원자성을 주고, 파생 키가 조회에 단일 컬럼 기본 키를 준다. 같은 신원의 두 번째 청구는 **같은 기본 키 행**을 겨냥한다. §17.1 이 그 사실에서 나온다. ## 4. 좁은 저장소 인터페이스 `Repository` 를 확장하고 네 메서드만 이름 짓는다. > "`JpaRepository` publishes `deleteAll`, `findAll` and `saveAll` on the table that decides whether a > payment runs twice." ## 5. 어댑터의 주장 `JpaGrpcOperationLedger` javadoc: > "`claim` is insert-first, read-on-conflict — not read-then-insert. That ordering is the whole > adapter… A read-first implementation has a window between the read and the insert that is exactly > as wide as the race it is supposed to close, and it passes every test that does not run the two > attempts concurrently." 트랜잭션 애너테이션이 없는 이유도 적혀 있다 — 커밋은 호출자의 업무 트랜잭션 안에서 일어나야 하고 `REQUIRES_NEW` 는 변경이 내구적인데 청구는 아닌 창을 다시 만든다. ## 6. 상태 전이 `IN_PROGRESS` 에서만 전이할 수 있다(`requireInProgress`). 커밋은 결과 참조가 비면 거부한다. `EnumType.STRING` 을 쓰는 이유가 javadoc 에 있다 — 서수 컬럼은 열거형에 값이 끼어들면 저장된 모든 행을 조용히 다른 값으로 만든다. ## 10. 테스트 레인 11개 테스트. 인메모리 저장소 이중이 `putIfAbsent` 로 기존 행이 있으면 `DataIntegrityViolationException` 을 던진다 — INSERT + 유니크 제약의 동작을 모사한다. 마지막 테스트가 마이그레이션 파일을 직접 읽어 유니크 제약 문장이 있는지 단언한다. ## 12. negative-space probes **12.1 도달성.** build-only. `JpaGrpcOperationLedger` 를 만드는 production 코드가 없다. **12.2 마이그레이션 적용 경로.** `db/migration/grpc` 를 가리키는 설정이 저장소에 없다. main 설정 어디에도 `spring.flyway.locations` 가 없고(app-bootstrap 의 네 프로파일 yml 전수 확인), 그 경로를 이름으로 부르는 것은 이 리프의 테스트 한 곳뿐이다. messaging 가족이 §7.2 에서 기록한 것과 같은 형태다. **12.4 드리프트.** build.gradle 주석이 서술한 네 요소(엔티티·상태 기계·포트·Spring Data 바인딩)와 마이그레이션이 전부 존재한다. 드리프트 없음. ## 16. 확인하지 못한 것 - 실제 데이터베이스로 `claim` 을 두 번 돌려 §17.1 을 재현하지 않았다. Spring Data JPA 의 `save` 계약과 이 엔티티의 식별자 형태로 판정했다. - 동시 청구를 실제 커넥션 둘로 재현하지 않았다. ## 17. 손볼 것 ### 17.1 P2 — insert-first 주장이 Spring Data 의 `save` 계약과 어긋난다. 그리고 테스트 이중이 그 차이를 가린다 어댑터는 이렇게 쓴다. ```java try { repository.save(GrpcOperationLedgerEntity.claim(identity, requestFingerprint, now)); return Optional.empty(); // 내가 이겼다 } catch (DataIntegrityViolationException alreadyClaimed) { return repository.findById(identity.storageKey()).map(entity -> entity.toRecord(identity)); } ``` 전제는 `save` 가 INSERT 이고, 같은 신원의 두 번째 청구가 유니크 제약을 건드린다는 것이다. 그러나 이 엔티티의 식별자는 **호출자가 배정한다**. `claim(...)` 팩토리가 `storageKey` 를 `identity.storageKey()` 로 채우므로 `@Id` 가 널이 아니다. Spring Data JPA 의 `SimpleJpaRepository.save` 는 식별자가 널이 아닌 엔티티를 새 것으로 보지 않고 `EntityManager.merge` 로 보낸다. 그리고 §3 에서 확인했듯 기본 키는 유니크 제약의 세 컬럼에서 파생된다. 같은 신원의 두 번째 청구는 **같은 행**을 겨냥한다. 따라서 실제 JPA 에서 일어나는 일은 이렇다. 1. 두 번째 청구가 `merge` 로 들어간다. 그 행은 이미 존재한다. 2. 유니크 제약이 발화하지 않는다. 새 행을 넣는 것이 아니라 같은 행을 갱신하기 때문이다. 3. 분리 상태의 새 엔티티가 기존 행 위에 복사된다 — `state` 는 `IN_PROGRESS`, `outcome_reference` 는 널, `completed_at` 은 널, `claimed_at` 은 지금. 4. 예외가 없으므로 `claim` 은 `Optional.empty()` 를 돌려준다. 호출자는 자기가 청구를 소유했다고 읽는다. 즉 이미 커밋된 연산의 결과 참조가 지워지고, 재시도가 그 변경을 다시 실행한다. 이 모듈이 존재하는 이유로 든 바로 그 결과다. 세 CHECK 제약도 이것을 막지 못한다. 갱신 후 상태는 `IN_PROGRESS` + `completed_at` 널이라 전부 합법이다. 덧붙여 `merge` 는 즉시 flush 하지 않으므로, 서로 다른 트랜잭션의 진짜 경합에서 제약 위반이 나더라도 그것은 flush 나 커밋 시점에 도착한다 — `try` 블록 밖이다. **테스트가 이것을 볼 수 없는 이유.** 인메모리 이중의 `save` 는 키가 이미 있으면 예외를 던진다. ```java GrpcOperationLedgerEntity existing = rows.putIfAbsent(entity.getStorageKey(), entity); if (existing != null && existing != entity) { throw new DataIntegrityViolationException(...); } ``` 즉 이중은 INSERT 를, 실제 저장소는 UPSERT 를 한다. build.gradle 주석이 실제 데이터스토어를 쓰지 않는 근거로 "vendor semantics 가 시험 대상일 때만 정당하다" 고 적었는데, 여기서 어긋난 것이 정확히 vendor semantics 다. **등급.** 이 리프는 build-only 이고 어떤 배포도 이 어댑터를 조립하지 않는다. 그래서 오늘의 사고는 아니다. 배선하는 순간 성립한다. **수정.** 셋 중 하나다. - 엔티티가 `Persistable` 을 구현해 `isNew()` 를 명시한다. 신규 여부를 어댑터가 안다. - 저장소에 `@Modifying @Query` 로 명시적 INSERT 를 두고 `save` 를 청구 경로에서 쓰지 않는다. - 청구를 `INSERT … ON CONFLICT DO NOTHING` 의 영향 행 수로 판정한다. 어느 쪽이든 테스트 이중이 아니라 실제 데이터베이스에서 두 번 청구하는 계약 테스트가 함께 필요하다. ### 17.2 P3 — 낙관적 잠금 컬럼이 없어 전이 가드가 메모리 안에만 있다 `requireInProgress()` 가 두 번째 종결 전이를 막는다. 그 가드는 한 영속성 컨텍스트 안의 인스턴스 상태에만 적용된다. 엔티티에 `@Version` 이 없으므로 두 트랜잭션이 같은 행을 읽어 각각 전이하면 나중 쓰기가 앞의 것을 덮는다. DB 의 세 CHECK 제약은 행의 모양을 지키지 지 전이 순서를 지키지 않는다. `COMMITTED` 행이 다른 결과 참조로 갱신되는 것을 막는 제약이 없다. 청구가 배타적이라는 설계 전제 아래서는 도달성이 낮다. 다만 §17.1 을 고치면 이 전제가 실제로 성립하는지가 함께 확인되어야 한다. ### 17.3 P3 — `markCommitted` 는 던지고 `markFailed` 는 조용히 넘어간다 ```java markCommitted → findById(...).orElseThrow(IllegalStateException…) // 청구 없으면 실패 markFailed → findById(...).ifPresent(entity -> …) // 청구 없으면 무동작 ``` 커밋 쪽의 근거는 자바독에 있다 — 청구 없이 커밋하면 변경은 내구적이고 보호받지 못한다. 실패 쪽에는 근거가 없다. 청구가 사라진 뒤 도착한 종결 실패가 아무 흔적도 남기지 않는다. 회수가 청구를 지운 뒤 원래 소유자가 실패를 기록하려는 경우가 그 형태다. 의도라면 그 이유를 자바독에 적어야 하고, 아니라면 커밋 쪽과 같게 다뤄야 한다. ### 확인된 설계(문제 아님) - **유니크 제약을 애플리케이션 검사 대신 쓰기로 한 판단과 그 근거.** - **커밋 행이 결과를 갖는다는 규칙을 record 와 DB 양쪽에 둔 것** — 마이그레이션·백필·지원 스크립트는 record 를 지나지 않는다. - **`Repository` 를 확장해 네 메서드만 노출한 것.** - **`EnumType.STRING`** — 서수 컬럼의 조용한 재지정을 피한다. - **전용 Flyway 위치** — 채택하지 않은 배포에 테이블을 강요하지 않는다. - **트랜잭션 애너테이션을 두지 않은 것과 그 근거.** - **상태·완료 시각 CHECK 제약** — 종결 상태는 완료 시각을 갖는다. --- ## Source anchors ``` src/grpc/grpc-operation-ledger-jpa/build.gradle:1-17 main/java/…/ledger/JpaGrpcOperationLedger.java:1-93 main/java/…/ledger/GrpcOperationLedgerEntity.java:1-155 main/java/…/ledger/GrpcOperationLedgerRepository.java:1-28 main/resources/db/migration/grpc/V001__create_grpc_operation_ledger.sql:1-39 test/java/…/ledger/GrpcOperationLedgerRepositoryTest.java:1-208 src/grpc/grpc-core-api/…/ledger/GrpcOperationIdentity.java:36-38 src/app-bootstrap/src/main/resources/application*.yml (flyway locations 부재 확인) ```