Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/multitenancy-isolation/case/case-analysis-finding-a03-f001.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

156 lines
15 KiB
Markdown

---
kind: CASE
slug: analysis-finding-a03-f001
title: 네 답을 주는 claim 이 있는데 서비스는 있음·없음 두 갈래로 판단한다
topic: multitenancy-isolation
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a03-f001
evidenceCapturedOn: 2026-09-04
body: case-analysis-finding-a03-f001.body.md
assets:
- key: analysis-finding-a03-f001
file: ../../../final/evidence/rendered/analysis-finding-a03-f001.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a03-f001.txt
source:
- 원본 분석 절은 analysis/03-application-core.md §16 P1 이다.
---
# 네 답을 주는 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
## 재현 조건
1. 저장소 포트와 청구 결과 타입을 전문으로 싣는다.
2. 그 연산을 만든 마이그레이션의 헤더 주석을 읽는다.
3. JPA 구현이 무엇을 보내고 네 답을 어디서 나누는지 확인하고, 리포지터리의 실제 문장을 인용한다.
4. 서비스가 포트에 거는 호출을 전부 나열하고, claim 계수를 없는 이름과 대조 이름으로 함께 센다.
5. commandFingerprint 가 나오는 자리를 전부 찾아 값을 만드는 코드가 있는지 본다.
6. 서비스가 선언한 협력자 필드 이름에서 패턴을 유도해 네 메서드 창 안의 호출을 뽑는다. 고정 목록을 손으로 적지 않는다.
7. 계약 시험의 태그를 찾아 gradle 레인과 워크플로와 그 경로 필터까지 따라간다.
## 본문
<!-- body:start -->
`AdminOperationStorePort` 는 관리자 연산의 멱등 기록을 맡는다. 같은 키로 온 재시도가 파괴적 동작을 두 번 돌리지 못하게 하는 것이 목적이다.
## 붙드는 것은 \:31 의 claim 인데 \:8 의 자바독은 findByOperationId 를 claim 이라고 적는다
:::evidence key="analysis-finding-a03-f001" alt="저장소 루트에서 돌린 정적 검색 출력 227줄. 먼저 AdminOperationStorePort 33줄이 전문으로 실린다. 8번 줄 자바독은 findByOperationId 를 연산 식별자를 claim 하거나 앞선 결과를 돌려주는 것으로 적고, 15~30번 줄 자바독은 관리 경로가 조회 다음 부수 효과 다음 저장이었다는 것과 두 호출자가 모두 없음을 읽고 모두 실행하고 모두 저장했다는 것, 그리고 지문이 청구의 일부라는 것을 적는다. 이어서 AdminOperationClaim 5~35번 줄이 실려 네 답 CLAIMED, REPLAY, IN_PROGRESS, CONFLICT 와 넷을 합치면 중요한 구별이 사라진다는 클래스 자바독이 보인다. 다음으로 V8 마이그레이션 헤더 1~11번 줄이 실리는데, operation_id 의 유니크 제약이 이미 있어서 두 번째 저장은 실패했지만 그것은 두 번째 부수 효과가 이미 일어난 뒤였다고 적고, 이제 청구는 삽입 자체이며 ON CONFLICT DO NOTHING 이 정확히 한 호출자만 행을 만들게 한다고 적는다. 그 아래에 JpaAdminOperationStore 30~71번 줄이 실려 claim 이 삽입 건수로 claimed 를 판정하고 48번에서 claimed, 62번에서 지문이 다르면 conflict, 55번과 65번에서 inProgress, 69번에서 replay 를 돌려주는 것이 보인다. AdminAuditJpaRepository 28~45번 줄은 그 문장이 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 251~252번이 그 태그를 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` 을 부를 수도 없다는 것이다.
## 확인하지 못한 것
스레드 둘로 같은 식별자를 밀어 넣어 파괴적 동작이 두 번 도는 장면을 만들지 않았다. 조회와 부수 효과와 저장이 세 연산이라는 것까지다.
네 협력자의 동작이 두 번 실행됐을 때 각각 어떤 상태가 되는지 구현까지 읽지 않았다. 포트 자바독이 파괴적 연산의 이중 실행을 막으려는 것이라고 적은 것을 근거로 삼았다.
이 네 메서드를 호출하는 인바운드 어댑터가 있는지 세지 않았다.
<!-- body:end -->