- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다 - 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5 (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를 techviz 로 만들었다 - 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs 돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다. Concept 이 인용한 코드가 SSOT 에 없어 뺐다 - candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
14 KiB
kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | rootTreeNode | evidenceCapturedOn | body | assets | evidence | source | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | analysis-finding-a05-f030 | claim 이 넣은 CLAIMED 행을 완료로 바꾸는 코드가 없다 | multitenancy-isolation | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:analysis-finding-a05-f030 | 2026-09-04 | case-analysis-finding-a05-f030.body.md |
|
|
|
claim 이 넣은 CLAIMED 행을 완료로 바꾸는 코드가 없다
JpaAdminOperationStore.claim:40 이 AdminAuditJpaRepository.claimOperation:41 을 부르고, 그 native INSERT :34~:39 가 phase 자리에 'CLAIMED' 를 넣는다. 그 행을 COMPLETED 로 옮기는 프로덕션 코드는 0 줄이고, save:96 은 새 식별자로 두 번째 행을 만들려다 유일성 제약에서 멈춘다.
관계
- 네 답을 주는 claim 이 있는데 서비스는 있음·없음 두 갈래로 판단한다
NotificationAdminApplicationService는claim을 한 번도 부르지 않는다. 이 기록은 불러도 그 뒤가 이어지지 않는다는 것을 저장소 계층에서 확인했다. - 조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다 이 청구가 구현하는 규칙이다. 삽입 건수를 답으로 쓰는 부분까지는 그 규칙대로다.
- @Bean이 있다는 것은 조립 증거가 아니다
main 참조 0 이 강한 신호라는 규칙이다.
AdminOperationClaim을 참조하는 프로덕션 파일이 포트와 JPA 구현뿐이라 청구 연산은 후보로만 남아 있다.
문제
V8 마이그레이션은 조회 후 실행 후 저장이 낳은 사고를 헤더에 적고, 청구를 삽입 자체로 바꿨다.
그 설계가 청구부터 완료까지 이어지는지 확인했다.
결론
마이그레이션이 세 컬럼을 더한다. command_fingerprint 와 phase 와 claimed_at 이고 phase 의 기본값은 COMPLETED 다. operation_id 의 유일성 제약 uk_notification_admin_operation 은 V3 :61 에 이미 있었다.
청구는 그 설계대로다. AdminAuditJpaRepository:34~:39 가 ON CONFLICT (operation_id) DO NOTHING 을 붙인 삽입이고 phase 자리에 'CLAIMED' 를 넣는다. 삽입 건수가 1 이면 이 호출자가 붙든 것이다.
붙든 행을 완료로 옮기는 프로덕션 코드가 0 줄이다. UPDATE notification_admin_audit 과 SET phase 를 저장소 전체에서 세면 한 줄이 나오는데 그것도 시험이고, CHECK 제약이 알 수 없는 값을 거절하는지 보려고 넣은 것이다.
완료를 기록하는 save 는 앞선 행을 찾지 않는다. :98 이 만든 새 식별자로 엔티티를 하나 더 만들어 :96 이 밀어 넣으므로, 같은 operation_id 가 두 번째로 들어간다.
그 엔티티가 매핑하는 필드는 아홉인데 commandFingerprint 도 phase 도 claimedAt 도 그중에 없다. V8 이 더한 셋을 JPA 쪽이 아직 모른다.
PostgreSQL 에 두 삽입을 이어 넣어 확인했다. 두 번째가 uk_notification_admin_operation 위반으로 거절되고, 남는 행은 청구가 넣은 CLAIMED 하나다.
지금은 그 장면이 배포에서 나지 않는다. NotificationAdminApplicationService 가 operations.claim 을 0 줄 부르고, 대조로 센 operations.save 는 4 줄이다.
계약 시험의 범위도 청구까지다. @Test 다섯이 배타성과 독립성과 단계 기록과 지문 기록과 단계 제약을 확인하고, COMPLETED 는 그 파일에 한 번도 나오지 않는다.
검증 환경
OpenJDK : 21.0.12 PostgreSQL : postgres:16-alpine 확인 방식 : V8 마이그레이션 헤더와 추가 컬럼 인용, V3 의 유일성 제약 확인, claim 과 native INSERT 와 save 본문 인용, 서비스의 청구 호출 계수를 대조와 함께 확인, 청구 타입을 참조하는 파일 전수, phase 갱신 자리 계수와 전수, 엔티티 필드 전수, 계약 시험의 메서드 이름 전수와 COMPLETED 계수, 실제 데이터베이스에 두 삽입을 이어 실행 소스 수정 : x
재현 조건
- 마이그레이션 헤더와 그것이 더한 컬럼을 인용하고, 유일성 제약이 언제부터 있었는지 확인한다.
- claim 과 그것이 부르는 native INSERT 와 save 본문을 나란히 인용한다.
- 서비스가 청구를 부르는 줄을 세고 대조 이름으로 같은 검색을 건다.
- 청구 결과 타입을 참조하는 파일을 전부 찾는다.
- phase 를 갱신하는 자리를 프로덕션과 전체로 나눠 세고 감사 엔티티의 필드를 전부 나열한다.
- 계약 시험의 메서드 이름을 전부 뽑고 COMPLETED 가 나오는 줄을 센다.
- postgres:16-alpine 에 V3 와 V8 의 해당 DDL 을 적용하고 두 삽입을 이어 실행한다.
본문
V8 마이그레이션은 관리 연산의 멱등 청구를 삽입 자체로 바꿨다. 헤더 주석이 그 이전에 무슨 일이 있었는지 적는다.
V8 이 더한 세 컬럼과 V3 부터 있던 유일성 제약
:::evidence key="analysis-finding-a05-f030" alt="저장소 루트에서 돌린 정적 검색과 데이터베이스 실행 출력 182줄. 먼저 V8 마이그레이션 129번 줄이 실린다. 헤더 주석은 관리 경로가 조회 다음 부수 효과 다음 저장이었고 두 호출자가 모두 없음을 읽고 모두 실행했으며, operation_id 의 유일성 제약이 이미 있어 두 번째 저장은 실패했지만 그것은 두 번째 부수 효과 뒤였다고 적는다. 이제 청구가 삽입 자체이며 ON CONFLICT DO NOTHING 이 정확히 한 호출자만 행을 만들게 한다고 적고, 1215번이 command_fingerprint 와 phase 와 claimed_at 을 더하는데 phase 의 기본값이 COMPLETED 다. 1725번 COMMENT 가 phase 는 붙든 동안 CLAIMED 이고 끝나면 COMPLETED 이며 CLAIMED 로 남은 행은 두 번째 호출자에게 일이 끝난 것이 아니라 진행 중임을 알린다고 적는다. 2729번이 phase 를 CLAIMED 와 COMPLETED 와 FAILED 로 제한하는 CHECK 다. 그 아래 V3 마이그레이션 61번의 uk_notification_admin_operation 유일성 제약이 나온다. 이어서 JpaAdminOperationStore 3072번의 claim 이 실려 40번이 audits.claimOperation 을 부르고 47번이 삽입 건수 1 을 청구됨으로 판정하며, AdminAuditJpaRepository 2247번이 그 native INSERT 인데 3439번 문자열이 phase 자리에 'CLAIMED' 를 넣고 ON CONFLICT (operation_id) DO NOTHING 으로 끝난다. 그다음 JpaAdminOperationStore 91108번의 save 가 실리는데 98번 ids.nextId 로 새 식별자를 만들어 AdminAuditEntity 를 새로 생성하고 96번이 saveAndFlush 하며 phase 를 지정하지 않는다. 다음으로 operations.claim 을 부르는 main 줄이 0 이고 대조로 센 operations.save 가 4 줄이며 AdminOperationClaim 을 참조하는 파일이 포트와 그 타입과 JPA 구현과 계약 시험 넷이라는 것이 나온다. 이어서 프로덕션에서 phase 컬럼을 갱신하는 자리가 0 개이고, 시험을 포함해도 그 문장이 나오는 자리는 AdminOperationClaimContractTest 110번 하나인데 'ALMOST' 를 넣어 제약을 시험하는 줄이다. AdminAuditEntity 가 가진 필드 아홉이 나열되는데 id 와 operationId 와 action 과 actorRef 와 reasonCode 와 tenantId 와 attributes 와 dryRun 과 occurredAt 이고 commandFingerprint 도 phase 도 claimedAt 도 없다. 그다음 계약 시험의 메서드가 애너테이션과 함께 나오는데 5455번이 BeforeEach 의 migrate 이고 68번과 77번과 84번과 94번과 104번이 각각 동시 청구 중 하나만 이긴다는 것, 다른 식별자는 독립적으로 청구된다는 것, 청구가 phase 를 기록한다는 것, 청구가 지문을 기록한다는 것, CHECK 제약이 알 수 없는 단계를 거절한다는 것이다. 그 파일에서 COMPLETED 가 나오는 줄은 0 개이고 phase 를 단언하는 줄은 1 개다. 마지막으로 postgres:16-alpine 에 V3 5162 와 V8 1215·2729 를 적용하고 두 삽입을 이어 실행한 결과가 나온다. 첫 번째 claimOperation 의 삽입은 조용히 통과하고, 두 번째 save 의 삽입은 uk_notification_admin_operation 유일성 제약 위반으로 거절되며 DETAIL 이 operation_id op-1 이 이미 있다고 적는다. 남은 행을 읽으면 op-1 이 phase CLAIMED 로 한 줄뿐이고 id 는 청구가 넣은 값이다." caption="V8 헤더가 적은 설계와 phase COMMENT 와 CHECK 제약 · V3 의 유일성 제약 · claim 이 부르는 native INSERT · save 가 새로 짓는 엔티티 · 청구 호출 0 과 대조 4 · 프로덕션의 phase 갱신 0 과 엔티티 필드 아홉 · 계약 시험 다섯과 BeforeEach · 두 삽입을 이어 실행한 결과 — 182줄 · exit 0" zoom="true"
:::
헤더는 관리 경로가 조회 다음 부수 효과 다음 저장이었다고 적는다. 두 호출자가 모두 "없음" 을 읽고 모두 실행했으며, operation_id 의 유일성 제약이 이미 있어 두 번째 저장은 실패했지만 그때는 두 번째 부수 효과가 이미 일어난 뒤였다.
그래서 청구를 삽입으로 바꿨다. ON CONFLICT DO NOTHING 이 정확히 한 호출자만 행을 만들게 하고 나머지는 그 호출자가 무엇을 하는지 읽는다.
:12~:15 가 컬럼 셋을 더한다. command_fingerprint 와 phase 와 claimed_at 이다. phase 의 기본값은 COMPLETED 인데, 기존 행이 전부 완료된 것이기 때문이다.
:17~:25 의 COMMENT 가 그 컬럼의 계약을 적는다. 붙든 동안 CLAIMED 이고 끝나면 COMPLETED 이며, CLAIMED 로 남은 행은 두 번째 호출자에게 일이 끝난 것이 아니라 진행 중이라고 알린다는 것이다.
claim 은 CLAIMED 행을 넣는다
JpaAdminOperationStore:40 이 audits.claimOperation 을 부른다.
그 메서드는 AdminAuditJpaRepository:34~:39 의 native INSERT 다. 컬럼 목록에 command_fingerprint 와 phase 와 claimed_at 이 들어가고, VALUES 의 phase 자리에 리터럴 'CLAIMED' 가 놓이며, 마지막이 ON CONFLICT (operation_id) DO NOTHING 이다.
JpaAdminOperationStore:47 이 삽입 건수가 1 일 때 청구됨을 돌려준다. 아니면 기존 행을 읽어 재생이나 진행 중이나 충돌로 나눈다.
그 행을 COMPLETED 로 옮기는 코드가 없다
UPDATE notification_admin_audit 이나 SET phase 가 나오는 프로덕션 줄이 0 이다.
시험까지 넣어도 그 문장이 나오는 자리는 AdminOperationClaimContractTest:110 하나인데, 'ALMOST' 를 넣어 CHECK 제약이 거절하는지 보는 줄이다.
JpaAdminOperationStore:98 의 save 는 ids.nextId() 로 새 식별자를 만들고 AdminAuditEntity 를 새로 생성한다. :96 이 그것을 saveAndFlush 한다. 앞선 행을 찾지도 갱신하지도 않는다.
그 엔티티에는 마이그레이션이 더한 세 컬럼에 대응하는 필드가 없다. id·operationId·action·actorRef·reasonCode·tenantId·attributes·dryRun·occurredAt 아홉이다.
두 삽입을 이어 넣으면 두 번째가 거절된다
postgres:16-alpine 에 V3 :51~:62 의 테이블과 V8 :12~:15·:27~:29 를 그대로 적용하고, 두 SQL 문을 코드에 적힌 모양대로 이어 실행했다.
첫 번째는 조용히 통과한다. 두 번째는 uk_notification_admin_operation 위반이고 DETAIL 이 Key (operation_id)=(op-1) already exists 다.
남은 행은 하나다. operation_id 가 op-1, phase 가 CLAIMED, id 는 청구가 넣은 값이다. 청구한 행은 그대로 남고 완료 기록은 만들어지지 않는다.
서비스가 claim 을 한 줄도 부르지 않는다
NotificationAdminApplicationService 에서 operations.claim 이 나오는 줄이 0 이다. 같은 파일에서 operations.save 를 세면 4 줄이 나오므로, 0 은 검색이 안 걸린 것이 아니라 실제로 호출이 없는 것이다.
AdminOperationClaim 을 참조하는 파일은 AdminOperationStorePort, AdminOperationClaim, JpaAdminOperationStore, AdminOperationClaimContractTest 넷이다. 그중 프로덕션 코드는 포트와 구현뿐이고, 그 값을 받아 분기하는 코드는 없다.
그래서 서비스가 여전히 조회 후 실행 후 저장을 쓰는 동안에는 save 가 만나는 행이 청구가 넣은 것이 아니다. 같은 operation_id 가 동시에 두 번 저장되는 경우에는 V8 헤더가 적은 대로 두 번째 저장이 제약에서 실패한다.
계약 시험 다섯은 청구까지만 단언한다
@Test 는 다섯이다. 동시 청구 중 하나만 이기는 것(:68), 다른 식별자가 독립적으로 청구되는 것(:77), 청구가 phase 를 기록하는 것(:84), 청구가 지문을 기록하는 것(:94), CHECK 제약이 알 수 없는 단계를 거절하는 것(:104)이다. :55 의 migrate 는 @BeforeEach 로 매 시험 전에 스키마를 다시 만든다.
그 파일에 COMPLETED 는 한 번도 나오지 않는다. 청구가 넣은 행이 나중에 완료가 되는지 보는 시험이 없다.
원문에 없는 것
원문은 프로덕션 호출 그래프에 청구 호출이 0 이고 완료로 잇는 상태 전이도 이어지지 않는다고 적는다. 여기에 더한 것은 그 단절이 어디까지 굳어 있는지다. phase 를 갱신하는 프로덕션 코드가 0 줄이고, AdminAuditEntity 에 그 컬럼을 담을 필드가 없으며, 계약 시험에 COMPLETED 가 한 번도 나오지 않는다. 세 자리 모두 청구 뒤를 다루지 않는다.
확인하지 못한 것
두 SQL 문을 psql 로 직접 넣었다. 하이버네이트가 saveAndFlush 를 어떤 문장으로 바꾸는지는 보지 않았다.
경로마다 트랜잭션이 그 위반을 어디까지 덮는지 나눠 세지 않았다.
단계를 완료로 옮기는 책임을 어느 계층에 두어야 하는지 결론 내지 않았다.