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>
152 lines
16 KiB
Markdown
152 lines
16 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: analysis-finding-a05-f028
|
|
title: 제공자 제출 뒤의 다섯 쓰기 가운데 결과 기록만 조건이 없다
|
|
topic: multitenancy-isolation
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:analysis-finding-a05-f028
|
|
evidenceCapturedOn: 2026-09-04
|
|
body: case-analysis-finding-a05-f028.body.md
|
|
assets:
|
|
- key: analysis-finding-a05-f028
|
|
file: ../../../final/evidence/rendered/analysis-finding-a05-f028.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/analysis-finding-a05-f028.txt
|
|
source:
|
|
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md §89 이다.
|
|
---
|
|
|
|
# 제공자 제출 뒤의 다섯 쓰기 가운데 결과 기록만 조건이 없다
|
|
|
|
제공자 제출은 `NotificationDispatchService:169` 이고 그 뒤에 남는 쓰기는 다섯이다. `applyNextAction` 의 네 분기는 리스를 `where` 절에 넣는 연산을 쓰고, `DispatchOutcomeRecorder:135` 만 조건 없는 `save` 를 쓴다. 그 클래스는 리스를 받지도 않는다.
|
|
|
|
## 관계
|
|
|
|
- **fenced lease — 만료 시각만으로는 부족한 이유**
|
|
그 개념이 정의하는 펜스 값이 여기서 `where` 절의 세 번째 조건이다. `RecipientDeliveryJpaRepository:80` 과 `:105` 가 `id` 와 `lease_owner` 와 `lease_fence` 를 함께 요구한다.
|
|
- **native claim이 @Version을 올리지 않아 충돌을 보고하지 않는 낙관적 잠금이 됐다**
|
|
그 기록의 네이티브 청구문과 이 기록의 포트 자바독이 같은 이유를 든다. `version` 이 잡는 것은 동시 편집이지 밀려난 작업자가 아니다.
|
|
- **조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다**
|
|
펜싱하는 쪽이 지키는 규칙이다. `RecipientDeliveryJpaRepository:82` 의 `saveProjectionHeldBy` 가 소유자와 펜스를 `where` 에 넣고 `int` 갱신 건수를 돌려주며, 포트의 `saveHeldBy` 는 그 건수를 `Optional` 로 옮긴다.
|
|
|
|
## 문제
|
|
|
|
리스를 붙든 작업자가 제공자에 제출하는 동안 리스가 만료되면, 그 작업은 다른 작업자에게 넘어간다. 밀려난 작업자가 돌아와 쓰는 것을 막는 것이 펜싱이다.
|
|
|
|
제출 뒤에 남은 쓰기가 그것을 쓰는지 확인했다.
|
|
|
|
## 결론
|
|
|
|
포트는 다섯 쓰기를 선언한다. save 와 transition 과 saveHeldBy 와 transitionHeldBy 와 renewLease 다. 뒤의 셋만 RecipientLease 를 인자로 받고 Optional 을 돌려준다.
|
|
|
|
구현은 그 자바독대로다. JpaRecipientDeliveryStore:69 의 saveHeldBy 가 RecipientDeliveryJpaRepository:82 의 saveProjectionHeldBy 를 부르고, 그 갱신문의 where 절 :80 이 id 와 lease_owner 와 lease_fence 를 모두 요구한다.
|
|
|
|
stillHeld 검사는 :161 이라 제출 앞이다. 제출 뒤의 쓰기는 두 갈래로 나뉜다.
|
|
|
|
한쪽은 :176 이 부르는 applyNextAction 이다. 그 안의 switch 는 분기가 넷인데 :422·:428·:434·:436 이 모두 펜싱 연산이라, 어느 쪽으로 가도 리스가 조건에 들어간다.
|
|
|
|
다른 쪽은 :173 이 부르는 DispatchOutcomeRecorder.record 이고 별도 파일에 있다. 그 서명에 RecipientLease 가 없으며 :135 가 recipients.save(updated) 다.
|
|
|
|
그 save 가 덮는 것은 소유권이 아니다. JpaRecipientDeliveryStore:39 가 넘기는 값이 전부 투영 필드라, delivery_state 부터 next_dispatch_at 까지 열 개 컬럼이 밀려난 작업자의 값으로 바뀐다.
|
|
|
|
펜싱 갱신문 자체도 절반이다. :80 과 :105 는 소유자와 펜스만 보고 lease_until 을 보지 않는다. renewLease:134 와 :154 는 본다.
|
|
|
|
## 검증 환경
|
|
|
|
OpenJDK : 21.0.12
|
|
확인 방식 : 포트가 선언한 다섯 쓰기와 saveHeldBy 자바독 인용, 펜싱 갱신문과 세 where 절 대조, 조건 없는 save 가 거치는 구현과 applyProjection 인자 전수, 결과 쓰기 자리 인용, 연산별 호출자 전수, stillHeld 검사와 제출과 기록의 순서 인용, applyNextAction 자바독과 switch 네 분기 인용, 펜싱 연산이 나오는 시험 자리 전수
|
|
소스 수정 : x
|
|
|
|
## 재현 조건
|
|
|
|
1. 포트가 선언한 쓰기를 전부 뽑고 saveHeldBy 자바독을 인용한다.
|
|
2. 펜싱 갱신문을 인용하고 세 갱신문의 where 절을 나란히 놓는다.
|
|
3. 조건 없는 save 가 거치는 구현과 그것이 부르는 applyProjection 의 인자를 전부 나열한다.
|
|
4. 결과를 쓰는 메서드를 인용한다.
|
|
5. 다섯 연산 각각의 호출자를 저장소 전체에서 찾는다.
|
|
6. stillHeld 검사와 제출과 기록의 순서를 인용하고, applyNextAction 자바독과 switch 를 함께 싣는다.
|
|
7. saveHeldBy 와 transitionHeldBy 가 나오는 시험 파일과 줄을 전부 나열한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
`RecipientDeliveryStorePort` 는 배달 행을 쓰는 다섯 연산을 선언한다. 그중 셋만 리스를 인자로 받는다.
|
|
|
|
## 포트가 이 사고를 자기 자바독에 적어 두었다
|
|
|
|
:::evidence key="analysis-finding-a05-f028" alt="저장소 루트에서 돌린 정적 검색 출력 250줄. 먼저 RecipientDeliveryStorePort 가 선언한 다섯 쓰기가 나오고 21~36번 줄의 saveHeldBy 자바독이 실린다. 그 자바독은 청구도 갱신도 펜싱되는데 완료만 한동안 그렇지 않았다고 적고, 제공자 호출 중에 리스가 만료된 작업자가 돌아와 조건 없는 save 로 새 소유자가 이미 청구한 행 위에 썼다고 적으며, 낙관적 version 컬럼은 동시 편집을 잡지 밀려난 작업자를 잡지 못한다고 적는다. 이어서 RecipientDeliveryJpaRepository 56~82번 줄이 실리는데 56~65번 자바독이 이 갱신문이 제공자 호출 뒤의 쓰기를 위한 것이며 제출 이전의 작업은 새 소유자가 다시 하면 그만이지만 결과는 그렇지 않다고 적고, 80번 where 절이 id 와 lease_owner 와 lease_fence 를 모두 요구한다. 그다음 세 갱신문의 where 절이 나란히 실린다. saveProjectionHeldBy 의 80번과 transitionHeldBy 의 105번은 세 조건뿐이고, renewLease 의 127~134번과 153~154번에만 lease_until 이 now 보다 크다는 조건이 붙는다. 이어서 조건 없는 save 가 거치는 구현이 나온다. JpaRecipientDeliveryStore 32~52번이 findById 로 엔티티를 읽어 applyProjection 을 부르고 saveAndFlush 하는데, RecipientDeliveryEntity 158~182번의 applyProjection 이 받는 인자 열하나는 deliveryState 와 submissionOutcome 과 deliveryOutcome 과 evidenceLevel 과 두 불린과 routeCursor 와 attemptCount 와 lastFailureCategory 와 nextDispatchAt 과 시각이고 리스 필드가 하나도 없다. 그 아래에 DispatchOutcomeRecorder 14~19번 클래스 자바독과 108~136번 결과 쓰기가 나오는데 109~134번이 새 RecipientDeliveryRecord 를 만들고 135번이 recipients.save 를 부른다. 다음으로 같은 흐름의 쓰기가 연산별로 나열된다. save 는 DispatchOutcomeRecorder 135번 하나이고, transition 은 NotificationAdminApplicationService 121번과 NotificationDispatchService 139번·396번과 NotificationSubmissionService 305번과 ReconciliationService 108번 다섯이며, saveHeldBy 는 NotificationDispatchService 434번, transitionHeldBy 는 같은 파일 422번·428번·436번이다. 그다음 NotificationDispatchService 158~178번 줄이 실려 161번이 leases.stillHeld 를 확인하고 162~164번 주석이 그 검사가 부수 효과 직전이며 그것을 막을 수 있는 마지막 순간이라고 적으며, 168~169번이 트랜잭션 밖에서 제출하고 171~173번이 그 뒤에 레코더를 부르고 176번이 applyNextAction 을 부른다. 이어서 398~412번 줄이 실리는데 401~411번 자바독이 applyNextAction 의 모든 쓰기가 제공자 호출 뒤에 일어나고 그것이 플랫폼이 일부러 트랜잭션 밖에서 시간을 쓰는 유일한 구간이며 그 사이에 리스가 만료돼 다른 작업자가 일을 가져갈 수 있고 펜싱된 변형은 그런 쓰기가 아무것도 맞지 않게 만든다고 적고, 리스를 잃은 것은 보고할 오류가 아니라고도 적는다. 그 아래 417~445번이 그 메서드의 switch 인데 RetryAfter 와 Reconcile 과 Stop 분기가 transitionHeldBy 를, Fallback 분기가 saveHeldBy 를 부르고 마지막에 refreshStatus 가 분기와 무관하게 돈다. 마지막으로 파일명에 Fenc 나 Lease 가 든 시험 파일이 열한 개, DispatchOutcomeRecorder 를 이름에 가진 시험 파일이 0 개라고 나오고, 펜싱 연산 이름이 나오는 시험 자리 여섯이 실린다. LeaseRecoveryServiceTest 306·313번과 PlatformFakes 263·272번은 시험 대역이 그 메서드를 구현하는 선언이고, 실제로 부르는 것은 postgresqlIntegrationTest 소스 세트의 PostgreSqlRecipientLeaseFencingIntegrationTest 93번과 127번이다." caption="포트의 다섯 쓰기와 saveHeldBy 자바독 · 펜싱 갱신문과 세 where 절 대조 · 조건 없는 save 가 덮는 투영 컬럼 · 결과 쓰기 자리 · 연산별 호출자 · stillHeld 검사와 제출과 기록의 순서 · applyNextAction 자바독과 switch 네 분기 · 펜싱 연산이 나오는 시험 자리 — 250줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
`:21`\~`:36` 자바독에는 청구와 갱신은 펜싱되는데 완료만 한동안 그렇지 않았다고 적혀 있다. 제공자 호출 중에 리스가 만료된 작업자가 돌아와 조건 없는 `save` 로, 새 소유자가 이미 청구하고 어쩌면 이미 발송한 행 위에 썼다는 것도 같은 자리에 있다.
|
|
|
|
낙관적 `version` 컬럼은 동시 편집을 잡지, 밀려난 작업자를 잡지 못한다. 늦은 작업자의 읽기는 이기기에 충분할 만큼 최근이었다.
|
|
|
|
`saveHeldBy` 와 `transitionHeldBy` 는 둘 다 `Optional` 을 돌려준다. 리스가 밀려났으면 아무것도 쓰지 않은 채 빈 `Optional` 이 나온다.
|
|
|
|
## 펜싱 갱신문이 where 절에 넣는 것
|
|
|
|
`RecipientDeliveryJpaRepository:80` 의 `where` 절이 `id = :id AND lease_owner = :owner AND lease_fence = :fence` 다. 셋이 모두 맞아야 한 행이 갱신된다.
|
|
|
|
그 위 `:57`\~`:62` 자바독은 이 갱신문이 어느 자리를 위한 것인지 적는다. 제공자 호출 *뒤에* 일어나는 쓰기이고, 제출 이전의 작업은 새 소유자가 다시 하면 그만이지만 결과는 그렇지 않다는 것이다. 밀려난 리스로 결과를 쓰면 한 작업자의 결과가 다른 작업자의 시도에 보고되고, 둘이 발송 여부에 대해 같은 답을 가질 이유가 없다.
|
|
|
|
세 갱신문의 `where` 절을 나란히 놓으면 하나가 더 보인다. `saveProjectionHeldBy:80` 과 `transitionHeldBy:105` 는 소유자와 펜스만 본다. `lease_until > :now` 조건은 `renewLease:134` 와 `:154` 에만 있다. 아직 아무도 가져가지 않은 만료된 리스로는 앞의 둘이 성공한다.
|
|
|
|
## applyNextAction 은 네 분기 모두 펜싱을 쓴다
|
|
|
|
`:176` 이 부르는 `applyNextAction:412` 은 `RetryDecision` 을 `switch` 로 가른다. `RetryAfter` 와 `Reconcile` 과 `Stop` 세 분기가 `transitionHeldBy`(`:422`·`:428`·`:436`), `Fallback` 분기가 `saveHeldBy`(`:434`)를 부른다. 한 번 실행에 한 분기만 돌고, 어느 쪽이든 리스가 조건에 들어간다.
|
|
|
|
그 메서드의 자바독 `:402`\~`:407` 이 이유를 적는다. 여기의 모든 쓰기가 제공자 호출 뒤에 일어나고, 그 제출이 이 플랫폼이 일부러 트랜잭션 밖에서 시간을 쓰는 유일한 구간이며, 그 사이에 리스가 만료돼 다른 작업자가 일을 가져갈 수 있다는 것이다. 펜싱된 변형은 그런 쓰기가 아무 행에도 맞지 않게 만든다.
|
|
|
|
`:409`\~`:410` 은 리스를 잃은 것이 보고할 오류가 아니라고도 적는다. 새 소유자가 자기 결과를 기록할 것이고, 밀려난 작업자에게 남은 의무는 멈추는 것뿐이다.
|
|
|
|
`:444` 의 `refreshStatus(work)` 는 그 `switch` 밖이라 갱신 건수와 무관하게 돈다.
|
|
|
|
## 결과 기록은 다른 클래스에 있고 리스를 받지 않는다
|
|
|
|
`recipients.save` 를 부르는 main 자리는 `DispatchOutcomeRecorder:135` 하나다.
|
|
|
|
그 클래스는 `NotificationDispatchService` 가 아니라 별도 파일이고, `record` 의 인자에 `RecipientLease` 가 없다. `:109`\~`:134` 가 새 `RecipientDeliveryRecord` 를 만들고 `:135` 가 그것을 저장한다.
|
|
|
|
그 `save` 가 무엇을 덮는지는 구현에 있다. `JpaRecipientDeliveryStore:33`\~`:52` 가 `findById` 로 행을 읽어 `applyProjection` 을 부르고 `saveAndFlush` 한다. `RecipientDeliveryEntity:159`\~`:170` 의 `applyProjection` 이 받는 인자 열하나에 리스 필드가 없다. 그래서 늦은 쓰기가 소유권을 빼앗지는 않는다. 덮이는 것은 `delivery_state`·`submission_outcome`·`delivery_outcome`·`evidence_level`·`ambiguous_attempt_exists`·`duplicate_risk`·`route_cursor`·`attempt_count`·`last_failure_category`·`next_dispatch_at` 이다. 새 소유자의 행에 밀려난 작업자의 결과가 실린다.
|
|
|
|
`findById` 가 같은 쓰기 트랜잭션 안에서 도는 신선한 읽기라, `@Version` 이 견줄 값은 이미 현재값이다.
|
|
|
|
펜싱 없는 `transition` 은 다섯 자리 더 있다. `NotificationAdminApplicationService:121`, `NotificationDispatchService:139` 와 `:396`, `NotificationSubmissionService:305`, `ReconciliationService:108` 이다. `:139` 와 `:396` 은 제출 이전 경로이고 나머지 셋은 관리와 제출과 재조정 흐름이라 이 구간 밖이다.
|
|
|
|
## stillHeld 검사는 제출 앞에 있고, 결과 쓰기는 제출 뒤에 있다
|
|
|
|
`:161` 이 `leases.stillHeld` 를 확인한다. `:162`\~`:164` 주석이 이 검사가 부수 효과 직전이며 그것을 막을 수 있는 마지막 순간이라고 적는다.
|
|
|
|
`:168`\~`:169` 가 트랜잭션 밖에서 제공자에 제출한다. `:171`\~`:173` 이 그 뒤에 레코더를 부른다.
|
|
|
|
검사와 제출 사이가 아니라 제출과 기록 사이에 리스가 만료되면, `:161` 은 이미 지난 뒤다.
|
|
|
|
## DispatchOutcomeRecorder 를 다루는 시험이 없다
|
|
|
|
파일명에 `Fenc` 나 `Lease` 가 든 시험 파일은 열하나다. `DispatchOutcomeRecorder` 를 이름에 가진 시험 파일은 0 개다.
|
|
|
|
펜싱 연산 이름이 나오는 시험 자리는 여섯인데 그중 넷은 시험 대역이 그 메서드를 구현하는 선언이다. `LeaseRecoveryServiceTest:306`·`:313` 과 `PlatformFakes:263`·`:272` 다.
|
|
|
|
실제로 그 연산을 부르는 것은 `postgresqlIntegrationTest` 소스 세트의 `PostgreSqlRecipientLeaseFencingIntegrationTest:93` 과 `:127` 두 자리이고, 둘 다 `transitionHeldBy` 다.
|
|
|
|
## 원문에 없는 것
|
|
|
|
원문은 제공자 호출 뒤의 투영 쓰기가 펜싱을 우회한다고 적는다. 여기에 더한 것은 그 우회가 어디서 갈리는지와 대가가 정확히 무엇인지다.
|
|
|
|
갈리는 지점은 메서드다. `applyNextAction` 의 네 분기는 모두 펜싱을 쓰고, 별도 클래스에 있는 `record` 만 쓰지 않는다. 대가는 소유권이 아니다. `applyProjection` 에 리스 인자가 없어서 덮이는 것은 투영 컬럼 열이다. 그리고 펜싱 갱신문 둘도 `lease_until` 을 보지 않으므로, 펜싱으로 바꾸는 것만으로 만료 구간이 전부 닫히지는 않는다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
이 결함이 실제 행 위에서 일어나는 것을 데이터베이스로 보지 않았다. 필요한 스키마가 다른 마이그레이션 집합의 테이블을 참조해 단독으로 세워지지 않는다.
|
|
|
|
낙관적 잠금이 이 쓰기를 걸러 내지 못한다는 판단은 코드 경로를 읽어 내린 것이다. 실행으로 확인하지 않았다.
|
|
|
|
만료됐지만 아직 아무도 가져가지 않은 리스로 두 갱신문이 통과하는 시간이 얼마나 되는지 재지 않았다.
|
|
|
|
밀려난 작업자의 늦은 `save` 가 새 소유자의 행을 덮는 장면을 데이터베이스로 재현하지 않았다. V1 이 `capability_schema_registry` 를 읽는데 그 테이블은 다른 마이그레이션 계열에 있어서, 알림 플랫폼 마이그레이션만 빈 데이터베이스에 올리면 거기서 멈춘다.
|
|
|
|
<!-- body:end -->
|