--- 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 단언을 확인한다. 프레임이 남지 않았음을 스레드 로컬 수준에서 검사한다. ## 본문 증거 프레임을 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한다. ## 확인하지 못한 것 없다. 수정된 형태와 그 테스트를 코드로 확인했다.