- 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>
15 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-a03-f001 | 네 답을 주는 claim 이 있는데 서비스는 있음·없음 두 갈래로 판단한다 | multitenancy-isolation | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:analysis-finding-a03-f001 | 2026-09-04 | case-analysis-finding-a03-f001.body.md |
|
|
|
네 답을 주는 claim 이 있는데 서비스는 있음·없음 두 갈래로 판단한다
AdminOperationClaim 은 CLAIMED·REPLAY·IN_PROGRESS·CONFLICT 를 돌려주도록 만들어졌고, 그 넷을 하나로 접으면 안 되는 이유가 클래스 자바독에 적혀 있다. NotificationAdminApplicationService 는 claim 을 한 줄도 부르지 않고 네 경로 모두 findByOperationId 의 Optional 이 비었는지로 판단한다.
관계
- 조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다
NotificationAdminApplicationService의 네 경로가 어기는 규칙이다. 붙들고 판단하는 연산이 포트에 있는데 서비스는 조회 결과의 있음·없음으로 판단한다. - CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다
삽입 건수를 답으로 쓰는 부분을
JpaAdminOperationStore:40이 지킨다. 그 값을 받아 분기하는 코드가 서비스에 없다. - 중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다
이 사례가 속한 구조다. 만들어지는 것과 호출되는 것을 나눠 세면
claim은 포트와 JPA 구현이 다 있는데 부르는 코드가 없다.
문제
관리자 연산은 멱등 키를 받는다. 그 키가 있는 이유를 포트 자바독 첫 줄이 적어 둔다.
그 키를 원자적으로 붙드는 연산이 포트와 JPA 구현 양쪽에 있다. 서비스가 그것을 부르는지 확인했다.
결론
포트에는 연산이 셋 있다. 서비스가 부르는 조회 메서드에 붙은 자바독이 그것을 claim 이라고 부르는데, 그 메서드는 아무것도 붙들지 않는다.
그 자바독은 네 답이 각각 무엇을 요구하는지도 적는다. 재생은 앞선 결과를 돌려주고 실행하지 않아야 하고, 다른 호출자가 들고 있는 동안에는 실행하지도 끝난 척하지도 않아야 하며, 같은 식별자에 다른 명령이 온 것은 멱등한 반복이 아니라 보고할 실수다.
JPA 구현은 그 자바독대로 붙든다. JpaAdminOperationStore:40 이 부르는 claimOperation 은 ON CONFLICT (operation_id) DO NOTHING 을 붙인 native INSERT 이고, 삽입된 행 수가 1 이면 이 호출자가 붙든 것이다. :48·:55·:62·:69 가 네 답을 나눠 돌려준다.
NotificationAdminApplicationService 는 claim 을 부르지 않는다. operations 포트에 거는 호출 여덟이 전부 findByOperationId 넷과 save 넷이고, claim 은 0 줄이다. 대조로 센 값이 4 를 내므로 이 계수는 살아 있다.
네 경로가 그 사이에 부르는 협력자는 서로 다르다. redrive:121 이 recipients.transition, reconcile:172 가 명령이 준 시도마다 reconciliation.reconcile, suppress:229·:236 이 suppressions.remove 또는 upsert, setProviderState:298 이 runtimes.setState 를 부른다.
부를 준비도 되어 있지 않다. claim 의 두 번째 인자를 채울 다이제스트를 만드는 코드가 저장소 어디에도 없고, 그 이름이 나오는 자리는 파라미터 선언과 컬럼뿐이다.
V8 마이그레이션 헤더가 실제 증상을 적는다. 두 번째 요청은 결국 저장에서 막혔지만 그때는 파괴적 동작이 이미 두 번 돈 뒤였다.
그 계약을 고정한 시험은 postgresqlIntegrationTest 소스 세트에 있고 기본 check 에 걸리지 않는다. 다만 @Tag("jpa-contract") 가 jpaPlatformContractTest 레인에 묶여 있고, notification-platform.yml:85 가 그 레인을 pull_request 에서 부르며 경로 필터가 이 서비스 파일을 포함한다. 서비스를 고치는 PR 마다 이 시험이 돈다.
검증 환경
OpenJDK : 21.0.12 확인 방식 : 포트와 청구 결과 타입 전문 게재, 마이그레이션 헤더 인용, JPA 구현의 네 분기와 리포지터리의 native 문장 인용, 서비스가 포트에 거는 호출 전수와 claim 계수를 없는 이름과 대조 이름으로 함께 계수, commandFingerprint 가 나오는 자리 전수, 협력자 필드 선언에서 정규식을 만들어 네 메서드 창 안의 호출을 뽑음, 시험 태그에서 gradle 레인과 워크플로 경로 필터까지 배선 추적 소스 수정 : x
재현 조건
- 저장소 포트와 청구 결과 타입을 전문으로 싣는다.
- 그 연산을 만든 마이그레이션의 헤더 주석을 읽는다.
- JPA 구현이 무엇을 보내고 네 답을 어디서 나누는지 확인하고, 리포지터리의 실제 문장을 인용한다.
- 서비스가 포트에 거는 호출을 전부 나열하고, claim 계수를 없는 이름과 대조 이름으로 함께 센다.
- commandFingerprint 가 나오는 자리를 전부 찾아 값을 만드는 코드가 있는지 본다.
- 서비스가 선언한 협력자 필드 이름에서 패턴을 유도해 네 메서드 창 안의 호출을 뽑는다. 고정 목록을 손으로 적지 않는다.
- 계약 시험의 태그를 찾아 gradle 레인과 워크플로와 그 경로 필터까지 따라간다.
본문
AdminOperationStorePort 는 관리자 연산의 멱등 기록을 맡는다. 같은 키로 온 재시도가 파괴적 동작을 두 번 돌리지 못하게 하는 것이 목적이다.
붙드는 것은 :31 의 claim 인데 :8 의 자바독은 findByOperationId 를 claim 이라고 적는다
:::evidence key="analysis-finding-a03-f001" alt="저장소 루트에서 돌린 정적 검색 출력 227줄. 먼저 AdminOperationStorePort 33줄이 전문으로 실린다. 8번 줄 자바독은 findByOperationId 를 연산 식별자를 claim 하거나 앞선 결과를 돌려주는 것으로 적고, 1530번 줄 자바독은 관리 경로가 조회 다음 부수 효과 다음 저장이었다는 것과 두 호출자가 모두 없음을 읽고 모두 실행하고 모두 저장했다는 것, 그리고 지문이 청구의 일부라는 것을 적는다. 이어서 AdminOperationClaim 535번 줄이 실려 네 답 CLAIMED, REPLAY, IN_PROGRESS, CONFLICT 와 넷을 합치면 중요한 구별이 사라진다는 클래스 자바독이 보인다. 다음으로 V8 마이그레이션 헤더 111번 줄이 실리는데, operation_id 의 유니크 제약이 이미 있어서 두 번째 저장은 실패했지만 그것은 두 번째 부수 효과가 이미 일어난 뒤였다고 적고, 이제 청구는 삽입 자체이며 ON CONFLICT DO NOTHING 이 정확히 한 호출자만 행을 만들게 한다고 적는다. 그 아래에 JpaAdminOperationStore 3071번 줄이 실려 claim 이 삽입 건수로 claimed 를 판정하고 48번에서 claimed, 62번에서 지문이 다르면 conflict, 55번과 65번에서 inProgress, 69번에서 replay 를 돌려주는 것이 보인다. AdminAuditJpaRepository 2845번 줄은 그 문장이 ON CONFLICT (operation_id) DO NOTHING 을 붙인 native INSERT 임을 보여 준다. 서비스가 operations 포트에 거는 호출은 여덟이고 findByOperationId 넷과 save 넷이다. operations.claim 은 0 줄, 없는 이름으로 건 자기시험도 0 줄, 대조로 센 operations.save 는 4 줄이다. commandFingerprint 가 나오는 자리는 파라미터와 컬럼 이름뿐이고 값을 계산하는 코드가 없다. 그 아래에 네 경로가 조회와 저장 사이에 부르는 협력자가 나오는데, 서비스가 선언한 필드 이름에서 패턴을 유도했다. redrive 는 121번에서 recipients.transition, reconcile 은 172번에서 reconciliation.reconcile, suppress 는 229번에서 suppressions.remove 또는 236번에서 suppressions.upsert, setProviderState 는 298번에서 runtimes.setState 를 부른다. 마지막으로 그 경합을 고정한 시험이 어느 레인에서 도는지 나온다. AdminOperationClaimContractTest 는 postgresqlIntegrationTest 소스 세트에 있고 34번 줄이 jpa-contract 태그를 달며, build.gradle 251252번이 그 태그를 jpaPlatformContractTest 레인에 묶고, jpa-pr.yml 과 notification-platform.yml 이 pull_request 에서 그 태스크를 부르는데 경로 필터가 src/application-core/src//notification/ 을 포함한다. 서비스 이름을 파일명에 가진 시험은 0 개다." caption="포트 33줄 전문과 findByOperationId 자바독 · claim 의 네 답 · V8 헤더가 적은 실제 증상 · JPA 구현 30~71 과 ON CONFLICT DO NOTHING INSERT · 서비스의 호출 여덟과 claim 0 · 지문을 계산하는 코드 부재 · 필드에서 유도한 네 경로의 협력자 호출 · 태그에서 워크플로까지의 레인 배선 — 227줄 · exit 0" zoom="true"
:::
포트는 findByOperationId:9 와 save:12 와 claim:31 을 선언한다.
:8 의 자바독은 findByOperationId 를 "연산 식별자를 claim 하거나 그 식별자의 앞선 결과를 돌려준다" 고 적는다. 실제로 이 메서드는 조회만 하고 아무것도 붙들지 않는다.
claim 의 자바독 :15~:30 이 이 연산이 왜 생겼는지 적는다. 관리 경로가 조회 다음 부수 효과 다음 저장이었고, 같은 식별자를 낸 두 호출자가 모두 "없음" 을 읽고 모두 재구동을 실행하고 모두 저장했다는 것이다. 멱등 키가 검사되기만 하고 붙들리지 않아서 반복은 막았지만 경합은 막지 못했다는 것이 그 이유다.
같은 자바독이 명령 지문도 청구의 조건이라고 적는다. 같은 식별자에 다른 명령이 오면 재생이 아니라 충돌이고, 그것을 앞선 결과로 답하면 두 명령 중 어느 것도 실행되지 않는다.
claim 은 네 답을 주고 findByOperationId 는 두 갈래를 준다
AdminOperationClaim 은 CLAIMED·REPLAY·IN_PROGRESS·CONFLICT 를 갖는 record 다. 클래스 자바독은 넷을 합치면 중요한 구별이 사라진다고 적는다.
JpaAdminOperationStore 가 그 넷을 나눈다. :48 이 claimed(), :55 와 :65 가 inProgress(), :62 가 지문이 다를 때 conflict(), :69 가 replay() 다.
서비스가 쓰는 findByOperationId 의 반환은 Optional<AdminOperationResult> 하나뿐이라 이 네 갈래를 표현할 자리가 없다.
JPA 구현은 ON CONFLICT DO NOTHING 의 삽입 건수를 답으로 쓴다
JpaAdminOperationStore:40 이 audits.claimOperation 을 부르고 :39 가 그 반환값을 claimed 에 담는다.
AdminAuditJpaRepository:31~:40 을 보면 그 문장은 notification_admin_audit 에 행을 넣는 native INSERT 이고 끝에 ON CONFLICT (operation_id) DO NOTHING 이 붙어 있다. UPDATE 도 WHERE 절도 없다. 원자성은 조건절이 아니라 operation_id 의 유니크 제약에서 나온다.
V8 마이그레이션 헤더가 그 관계를 적는다. 그 제약은 예전에도 있었고 두 번째 저장을 실패시켰지만, 그것은 두 번째 부수 효과가 이미 일어난 뒤였다. 이제 청구가 삽입 자체이므로 정확히 한 호출자만 행을 만든다.
서비스가 operations 포트에 거는 호출 여덟은 findByOperationId 와 save 뿐이다
findByOperationId 가 :94·:154·:203·:282 넷이고 save 가 :145·:194·:272·:328 넷이다.
operations.claim 을 부르는 줄은 0 개다. 이 0 이 검색식 오류가 아닌지 보려고 같은 파일에서 operations.save 를 세면 4 가 나온다.
부를 수 없는 이유도 있다. claim 은 commandFingerprint 를 요구하는데 그 값을 계산하는 코드가 저장소에 없다. 그 이름이 나오는 자리는 포트 선언과 JPA 구현의 파라미터, 리포지터리의 컬럼 이름, 계약 시험의 고정 문자열뿐이다.
네 경로가 조회와 저장 사이에 부르는 협력자
부수 효과를 손으로 적은 목록으로 찾으면 이름을 하나 빠뜨렸을 때 조용히 사라진다. 그래서 서비스가 선언한 협력자 필드에서 패턴을 유도했다.
redrive:90~:147 은 :94 에서 조회하고 :117 의 트랜잭션 안에서 :121 의 recipients.transition 으로 수신자 배달 상태를 옮기고 :145 에서 저장한다.
reconcile:150~:195 는 :154 에서 조회하고 :172 에서 명령이 준 시도 식별자마다 reconciliation.reconcile 을 부른 뒤 :194 에서 저장한다. 트랜잭션 안이 아니다.
suppress:198~:275 는 :203 에서 조회하고 :218 의 트랜잭션 안에서 :229 의 suppressions.remove 또는 :236 의 suppressions.upsert 를 부른 뒤 :272 에서 저장한다. 둘은 배타적 분기다.
setProviderState:278~:329 는 :282 에서 조회하고 :298 에서 runtimes.setState 로 제공자 런타임 상태를 바꾼 뒤 :328 에서 저장한다. 여기도 트랜잭션 밖이다.
자바독이 과거형으로 적은 조회 → 부수 효과 → 저장 순서가 지금 네 경로에 그대로 있다.
청구 계약 시험은 postgresqlIntegrationTest 소스 세트에 있다
AdminOperationClaimContractTest 는 adapter/outbound/persistence-jpa 의 postgresqlIntegrationTest 에 있고 :34 가 @Tag("jpa-contract") 를 단다.
persistence-jpa/build.gradle:251~:252 가 그 태그를 jpaPlatformContractTest 레인에 묶는다. jpa-pr.yml:86 과 notification-platform.yml:85 가 pull_request 에서 그 태스크를 부르고, 뒤엣것의 경로 필터 :21 이 src/application-core/src/**/notification/** 을 포함한다.
즉 이 시험은 기본 check 에는 없지만 서비스를 고치는 PR 마다 돈다. 저장소 계층의 청구는 그렇게 고정돼 있다.
NotificationAdminApplicationService 를 이름에 가진 시험 파일은 0 개다.
원문과 갈리는 자리
원문은 서비스가 네 경로에서 조회 후 부수 효과 후 저장을 쓴다고 적었고 그것은 그대로다.
원문이 적지 않은 것이 셋이다. claim 이 네 답을 주는데 findByOperationId 는 두 갈래뿐이라는 것, 두 번째 저장이 유니크 제약으로 실패한다는 것과 그것이 두 번째 부수 효과 뒤라는 것, 그리고 commandFingerprint 를 만드는 코드가 없어서 오늘은 claim 을 부를 수도 없다는 것이다.
확인하지 못한 것
스레드 둘로 같은 식별자를 밀어 넣어 파괴적 동작이 두 번 도는 장면을 만들지 않았다. 조회와 부수 효과와 저장이 세 연산이라는 것까지다.
네 협력자의 동작이 두 번 실행됐을 때 각각 어떤 상태가 되는지 구현까지 읽지 않았다. 포트 자바독이 파괴적 연산의 이중 실행을 막으려는 것이라고 적은 것을 근거로 삼았다.
이 네 메서드를 호출하는 인바운드 어댑터가 있는지 세지 않았다.