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>
101 lines
5.9 KiB
Markdown
101 lines
5.9 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: two-owners-popped-the-evidence-frame
|
|
title: 커밋 증거 프레임을 두 주인이 pop해서 바깥 트랜잭션의 실패가 익명이 됐다
|
|
topic: commit-ambiguity-as-a-result
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:two-owners-popped-the-evidence-frame
|
|
evidenceCapturedOn: 2026-09-01
|
|
body: case-two-owners-popped-the-evidence-frame.body.md
|
|
assets:
|
|
- key: two-owners-popped-the-evidence-frame
|
|
file: ../../../final/evidence/rendered/two-owners-popped-the-evidence-frame.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/two-owners-popped-the-evidence-frame.txt
|
|
source:
|
|
- 원본 분석 절은 final/document.md#4-1 · analysis/05 §3.4 이다.
|
|
---
|
|
|
|
# 커밋 증거 프레임을 두 주인이 pop해서 바깥 트랜잭션의 실패가 익명이 됐다
|
|
|
|
증거 프레임을 pop 하는 주인이 둘이었다. REQUIRES_NEW 안쪽 트랜잭션에서 executor 가 바깥 프레임을 자기 것으로 오인해 pop 했고, 이후 바깥의 커밋 실패는 operation 도 조정 키도 없이 보고됐다.
|
|
|
|
## 관계
|
|
|
|
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
|
|
증거가 사라지면 세 번째 결과를 만들 재료도 사라진다.
|
|
- **재시도 단위는 statement가 아니라 유스케이스 전체다**
|
|
executor 의 finally 가 프레임을 정리하려 한 이유가 그 재시도 단위에 있다.
|
|
- **커밋 증거 단계 — NOT_STARTED에서 UNKNOWN까지**
|
|
이 프레임이 담는 단계 모델이다.
|
|
|
|
## 문제
|
|
|
|
증거 컨텍스트는 스레드에 묶인 프레임 스택이다. 스택인 이유는 REQUIRES_NEW 가 같은 스레드에서 바깥 트랜잭션을 suspend 하고 안쪽을 시작하기 때문이다. 슬롯이 하나면 안쪽의 커밋이 바깥의 phase 를 덮어쓰고, 나중에 바깥에서 난 커밋 실패가 이미 끝난 작업의 증거로 분류된다.
|
|
|
|
문제는 그 프레임을 pop 하는 주인이 둘이었다는 점이다.
|
|
|
|
트랜잭션 매니저 : commit 과 rollback 에서 자기 프레임을 정리
|
|
executor 의 finally : operation 과 attempt 가 일치하면 top 프레임을 정리
|
|
|
|
바깥과 안쪽이 같은 operation 이고 같은 attempt 인 경우, executor 의 조건은 안쪽 프레임만큼이나 바깥 프레임도 정확하게 서술한다. 그리고 기본 경로에서는 둘 다 attempt 1 이므로 이 조건이 항상 성립한다.
|
|
|
|
순서는 이렇게 된다. 안쪽 매니저가 안쪽 프레임을 pop 하고, executor 가 바깥 프레임을 자기 것으로 오인해 pop 한다. 이후 바깥 트랜잭션의 커밋 실패는 operation 없이, attempt 1 로, 조정 키 없이 보고된다.
|
|
|
|
## 결론
|
|
|
|
소유권을 내용이 아니라 깊이로 식별하도록 바꿨다.
|
|
|
|
scope 가 스택에서의 자기 위치를 들고, 닫을 때 그 위치가 top 일 때만 pop 한다. 매니저는 phase 만 표시하고 아무것도 pop 하지 않는다.
|
|
|
|
깊이를 기준으로 삼은 이유는 내용이 변하기 때문이다. phase 전이마다 프레임 값이 교체되지만 깊이는 변하지 않고, 엄격하게 중첩된 수명주기에서는 이 scope 가 push 한 깊이의 프레임이 곧 이 scope 의 프레임이다.
|
|
|
|
순서를 어겨 닫으면 아무것도 pop 하지 않는다. 이것은 의도된 동작이다. 중첩되지 않은 수명주기는 버그이고, 무관한 프레임을 지워서 그것을 덮는 것이 원래 결함이 밖에서 보이던 모습이기 때문이다.
|
|
|
|
프레임이 남는 것은 프레임이 없는 것보다 나쁘다. 풀링된 요청 스레드가 낡은 COMMITTING 을 무관한 작업으로 들고 가고, 플랫폼은 존재한 적 없는 트랜잭션에 대해 completion-unknown 을 보고하게 된다.
|
|
|
|
ThreadLocal 을 withInitial 로 만들지 않은 것도 같은 계열의 판단이다. 초기화하는 스레드 로컬은 읽을 때마다 값을 설치하므로, 마지막 프레임을 지운 뒤의 읽기가 clear 가 방금 제거한 것을 다시 등록한다.
|
|
|
|
## 검증 환경
|
|
|
|
OpenJDK : 21.0.12
|
|
Gradle : 9.0.0
|
|
Spring Boot : 4.0.8
|
|
근거 : 저장소의 javadoc 이 사후 기록으로 남긴 회귀와 현재 코드 형태
|
|
|
|
## 재현 조건
|
|
|
|
수정된 형태를 확인하는 절차는 다음과 같다.
|
|
|
|
1. TransactionEvidenceContext 의 클래스 javadoc 에서 두 주인 문제의 서술을 읽는다.
|
|
2. TransactionEvidenceScope 가 depth 를 들고 ownsTopOf 로 판정하는지 확인한다.
|
|
3. close 가 멱등인지 확인한다. 두 번째 close 는 그 사이에 들어온 것을 pop 하지 않고 아무것도 하지 않는다.
|
|
4. TransactionEvidenceScopeTest 의 hasRawThreadLocalValue 단언을 확인한다. 프레임이 남지 않았음을 스레드 로컬 수준에서 검사한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
증거 프레임을 pop하는 주인이 둘이었다 — 트랜잭션 매니저가 commit/rollback에서, executor의 `finally`가 operation·attempt 일치 시.
|
|
|
|
## 두 주인이 pop 하는 조건
|
|
|
|
:::evidence key="two-owners-popped-the-evidence-frame" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
|
|
:::
|
|
|
|
## 기본 경로에서는 항상 겹친다
|
|
|
|
바깥과 같은 operation·같은 attempt를 가진 `REQUIRES_NEW` 안쪽 트랜잭션에서 — 기본 경로에서는 둘 다 attempt 1이라 **항상** 그렇다 — 안쪽 매니저가 안쪽을 pop하고 executor가 바깥을 자기 것으로 오인해 pop했다. 이후 바깥의 커밋 실패는 operation 없이, attempt 1로, reconciliation key 없이 보고됐다.
|
|
|
|
## 해결
|
|
|
|
깊이로 소유권을 식별하고 자기 프레임이 top일 때만 pop한다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
없다. 수정된 형태와 그 테스트를 코드로 확인했다.
|
|
|
|
<!-- body:end -->
|