Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/multitenancy-isolation/case/case-analysis-finding-a03-f001.md
T
DongHyeonkaandClaude Fable 5.1 b25357c48a docs(clean-architecture-backend-template): fold analysis into final and re-select one topic
- 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>
2026-09-07 12:39:20 +09:00

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
key file
analysis-finding-a03-f001 ../../../final/evidence/rendered/analysis-finding-a03-f001.svg
../../../final/evidence/raw/analysis-finding-a03-f001.txt
원본 분석 절은 final/document.md#a03 §16 P1 이다.

네 답을 주는 claim 이 있는데 서비스는 있음·없음 두 갈래로 판단한다

AdminOperationClaimCLAIMED·REPLAY·IN_PROGRESS·CONFLICT 를 돌려주도록 만들어졌고, 그 넷을 하나로 접으면 안 되는 이유가 클래스 자바독에 적혀 있다. NotificationAdminApplicationServiceclaim 을 한 줄도 부르지 않고 네 경로 모두 findByOperationIdOptional 이 비었는지로 판단한다.

관계

  • 조건부 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

재현 조건

  1. 저장소 포트와 청구 결과 타입을 전문으로 싣는다.
  2. 그 연산을 만든 마이그레이션의 헤더 주석을 읽는다.
  3. JPA 구현이 무엇을 보내고 네 답을 어디서 나누는지 확인하고, 리포지터리의 실제 문장을 인용한다.
  4. 서비스가 포트에 거는 호출을 전부 나열하고, claim 계수를 없는 이름과 대조 이름으로 함께 센다.
  5. commandFingerprint 가 나오는 자리를 전부 찾아 값을 만드는 코드가 있는지 본다.
  6. 서비스가 선언한 협력자 필드 이름에서 패턴을 유도해 네 메서드 창 안의 호출을 뽑는다. 고정 목록을 손으로 적지 않는다.
  7. 계약 시험의 태그를 찾아 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:9save:12claim:31 을 선언한다.

:8 의 자바독은 findByOperationId 를 "연산 식별자를 claim 하거나 그 식별자의 앞선 결과를 돌려준다" 고 적는다. 실제로 이 메서드는 조회만 하고 아무것도 붙들지 않는다.

claim 의 자바독 :15~:30 이 이 연산이 왜 생겼는지 적는다. 관리 경로가 조회 다음 부수 효과 다음 저장이었고, 같은 식별자를 낸 두 호출자가 모두 "없음" 을 읽고 모두 재구동을 실행하고 모두 저장했다는 것이다. 멱등 키가 검사되기만 하고 붙들리지 않아서 반복은 막았지만 경합은 막지 못했다는 것이 그 이유다.

같은 자바독이 명령 지문도 청구의 조건이라고 적는다. 같은 식별자에 다른 명령이 오면 재생이 아니라 충돌이고, 그것을 앞선 결과로 답하면 두 명령 중 어느 것도 실행되지 않는다.

claim 은 네 답을 주고 findByOperationId 는 두 갈래를 준다

AdminOperationClaimCLAIMED·REPLAY·IN_PROGRESS·CONFLICT 를 갖는 record 다. 클래스 자바독은 넷을 합치면 중요한 구별이 사라진다고 적는다.

JpaAdminOperationStore 가 그 넷을 나눈다. :48claimed(), :55:65inProgress(), :62 가 지문이 다를 때 conflict(), :69replay() 다.

서비스가 쓰는 findByOperationId 의 반환은 Optional<AdminOperationResult> 하나뿐이라 이 네 갈래를 표현할 자리가 없다.

JPA 구현은 ON CONFLICT DO NOTHING 의 삽입 건수를 답으로 쓴다

JpaAdminOperationStore:40audits.claimOperation 을 부르고 :39 가 그 반환값을 claimed 에 담는다.

AdminAuditJpaRepository:31~:40 을 보면 그 문장은 notification_admin_audit 에 행을 넣는 native INSERT 이고 끝에 ON CONFLICT (operation_id) DO NOTHING 이 붙어 있다. UPDATEWHERE 절도 없다. 원자성은 조건절이 아니라 operation_id 의 유니크 제약에서 나온다.

V8 마이그레이션 헤더가 그 관계를 적는다. 그 제약은 예전에도 있었고 두 번째 저장을 실패시켰지만, 그것은 두 번째 부수 효과가 이미 일어난 뒤였다. 이제 청구가 삽입 자체이므로 정확히 한 호출자만 행을 만든다.

서비스가 operations 포트에 거는 호출 여덟은 findByOperationId 와 save 뿐이다

findByOperationId:94·:154·:203·:282 넷이고 save:145·:194·:272·:328 넷이다.

operations.claim 을 부르는 줄은 0 개다. 이 0 이 검색식 오류가 아닌지 보려고 같은 파일에서 operations.save 를 세면 4 가 나온다.

부를 수 없는 이유도 있다. claimcommandFingerprint 를 요구하는데 그 값을 계산하는 코드가 저장소에 없다. 그 이름이 나오는 자리는 포트 선언과 JPA 구현의 파라미터, 리포지터리의 컬럼 이름, 계약 시험의 고정 문자열뿐이다.

네 경로가 조회와 저장 사이에 부르는 협력자

부수 효과를 손으로 적은 목록으로 찾으면 이름을 하나 빠뜨렸을 때 조용히 사라진다. 그래서 서비스가 선언한 협력자 필드에서 패턴을 유도했다.

redrive:90~:147:94 에서 조회하고 :117 의 트랜잭션 안에서 :121recipients.transition 으로 수신자 배달 상태를 옮기고 :145 에서 저장한다.

reconcile:150~:195:154 에서 조회하고 :172 에서 명령이 준 시도 식별자마다 reconciliation.reconcile 을 부른 뒤 :194 에서 저장한다. 트랜잭션 안이 아니다.

suppress:198~:275:203 에서 조회하고 :218 의 트랜잭션 안에서 :229suppressions.remove 또는 :236suppressions.upsert 를 부른 뒤 :272 에서 저장한다. 둘은 배타적 분기다.

setProviderState:278~:329:282 에서 조회하고 :298 에서 runtimes.setState 로 제공자 런타임 상태를 바꾼 뒤 :328 에서 저장한다. 여기도 트랜잭션 밖이다.

자바독이 과거형으로 적은 조회 → 부수 효과 → 저장 순서가 지금 네 경로에 그대로 있다.

청구 계약 시험은 postgresqlIntegrationTest 소스 세트에 있다

AdminOperationClaimContractTestadapter/outbound/persistence-jpapostgresqlIntegrationTest 에 있고 :34@Tag("jpa-contract") 를 단다.

persistence-jpa/build.gradle:251~:252 가 그 태그를 jpaPlatformContractTest 레인에 묶는다. jpa-pr.yml:86notification-platform.yml:85pull_request 에서 그 태스크를 부르고, 뒤엣것의 경로 필터 :21src/application-core/src/**/notification/** 을 포함한다.

즉 이 시험은 기본 check 에는 없지만 서비스를 고치는 PR 마다 돈다. 저장소 계층의 청구는 그렇게 고정돼 있다.

NotificationAdminApplicationService 를 이름에 가진 시험 파일은 0 개다.

원문과 갈리는 자리

원문은 서비스가 네 경로에서 조회 후 부수 효과 후 저장을 쓴다고 적었고 그것은 그대로다.

원문이 적지 않은 것이 셋이다. claim 이 네 답을 주는데 findByOperationId 는 두 갈래뿐이라는 것, 두 번째 저장이 유니크 제약으로 실패한다는 것과 그것이 두 번째 부수 효과 뒤라는 것, 그리고 commandFingerprint 를 만드는 코드가 없어서 오늘은 claim 을 부를 수도 없다는 것이다.

확인하지 못한 것

스레드 둘로 같은 식별자를 밀어 넣어 파괴적 동작이 두 번 도는 장면을 만들지 않았다. 조회와 부수 효과와 저장이 세 연산이라는 것까지다.

네 협력자의 동작이 두 번 실행됐을 때 각각 어떤 상태가 되는지 구현까지 읽지 않았다. 포트 자바독이 파괴적 연산의 이중 실행을 막으려는 것이라고 적은 것을 근거로 삼았다.

이 네 메서드를 호출하는 인바운드 어댑터가 있는지 세지 않았다.