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>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,116 @@
---
kind: CASE
slug: a-digest-that-covered-who-but-not-what
title: transition digest가 "누가·언제"만 덮고 "무엇"을 덮지 않아 다른 전이를 같다고 보고했다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-digest-that-covered-who-but-not-what
evidenceCapturedOn: 2026-09-02
assets:
- key: a-digest-that-covered-who-but-not-what
file: ../../../final/evidence/rendered/a-digest-that-covered-who-but-not-what.svg
evidence:
- ../../../final/evidence/raw/a-digest-that-covered-who-but-not-what.txt
source:
- 이 사례의 사실은 분석 문서가 아니라 다이제스트 정책 클래스의 javadoc — 고친 쪽이 남긴 사후 기록 — 과 현재 구현에서 왔다. 후보 원장이 지정한 `analysis/05` 의 절 번호는 그 파일에 존재하지 않는다. 완료 쪽이 아직 열려 있다는 것은 같은 문서 §59.2 이고, 위에 인용한 탐침 값이 그 절에 있다.
---
# transition digest가 "누가·언제"만 덮고 "무엇"을 덮지 않아 다른 전이를 같다고 보고했다
전이 다이제스트가 전이 종류와 연산 식별자와 소유자와 시도와 상태 리비전을 덮었다. 누가 언제 했는지는 덮고 무엇을 했는지는 덮지 않아서, 결말이 다른 전이가 같은 값을 냈다.
## 관계
- **digest는 길이 프레이밍하고 버전을 붙인다**
이 사례가 만든 규칙이다.
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
같은 저장소의 소유자 안전 규칙이다.
- **complete()의 replay 판정이 replayTtl 변경을 무시한다**
이 다이제스트가 실제 판정에 쓰이지 않는 경로다.
## 문제
다이제스트가 덮던 다섯 성분은 전부 누가 몇 번째로 어느 상태에서 했는지를 말한다. 무엇을 했는지를 말하는 성분이 없었다.
그래서 재시도 가능한 실패와 포기한 실패가 같은 값을 냈고, 응답이 다른 두 완료와 보존 기간이 다른 두 완료도 그랬다.
## 결론
다이제스트를 비교한 재생 판정이 세 쌍을 같은 전이로 결론지었다. 세 쌍에서 갈린 것은 호출자의 행동이 달라지는 자리뿐이었다.
수정은 두 층으로 왔다. 호출부마다 의미 인자를 넘기게 했고, 실패 쪽은 전이 종류 자체를 성향별로 갈랐다.
이어 붙이는 방식도 바뀌었다. 성분마다 앞에 길이를 적고, 버전 상수를 다이제스트 입력에 넣는다.
다만 완료 쪽 재생 분기는 이 다이제스트를 비교하지 않는다. 그래서 응답이 같고 재생 창만 다른 두 완료는 지금도 같은 결과로 판정된다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 다이제스트 정책과 다섯 호출부, 완료 재생 분기 확인
소스 수정 : x
## 재현 조건
1. 다이제스트 정책의 javadoc 에서 덮던 성분과 충돌한 쌍을 읽는다.
2. 전이 다이제스트를 부르는 다섯 지점에서 각각 무엇을 넘기는지 확인한다.
3. 실패 쪽 전이 종류가 어떻게 갈라지는지 확인한다.
4. 이어 붙이기가 각 성분 앞에 적는 길이와, 해시가 실제로 먹는 바이트를 확인한다.
5. 완료 재생 분기가 무엇을 비교하는지 확인한다.
## 본문
<!-- body:start -->
전이 다이제스트가 덮던 것은 전이 종류와 연산 식별자와 소유자 토큰과 시도와 상태 리비전이었다.
## 결말을 가르는 성분이 하나도 없었다
:::evidence key="a-digest-that-covered-who-but-not-what" alt="코드베이스에서 전이 다이제스트가 덮던 다섯 성분과 그때 같은 값을 내던 쌍을 적은 javadoc, 지금 다섯 호출부가 실제로 넘기는 의미 인자, 각 성분 앞에 적는 길이와 해시가 먹는 바이트, 버전 상수, 그리고 완료 재생 분기가 비교하는 값을 뽑은 출력 49줄. 완료 재생 분기가 전이 다이제스트가 아니라 연산 식별자와 응답 다이제스트만 본다는 것이 그 출력에 그대로 보인다." caption="덮던 다섯과 충돌한 쌍 · 호출부별 의미 인자 · 길이 프레이밍과 해시 입력 · 완료 재생 분기가 비교하는 값 — 49줄 · exit 0" zoom="true"
:::
다섯은 전부 누가 몇 번째로 어느 상태에서 했는지를 말한다. 결말에 가장 가까운 것이 전이 종류인데, 그때는 실패가 성향과 무관하게 하나의 `FAIL` 이었다.
javadoc 이 같은 값을 내던 쌍 셋을 든다. 재시도 가능한 실패와 포기한 실패, 응답이 다른 두 완료, 보존 기간이 다른 두 완료다.
세 쌍이 달랐던 부분은 호출자가 실제로 행동을 바꾸는 부분뿐이었다. 다이제스트를 비교한 재생 판정은 그 셋을 전부 같은 전이라고 답했다.
## 무엇을 넘길지는 호출부가 정한다
지금은 다섯 호출부가 각자 인자를 넘긴다.
갱신은 처리 임차 유효 기간을 넘긴다. 완료는 응답 다이제스트와 재생 유효 기간을 넘기고, 그 자리 주석이 이유를 적는다 — 응답 다이제스트를 전이 다이제스트 대신 쓰면 같은 응답을 낸 서로 다른 연산의 완료가 구별되지 않고, 완료가 함께 결정하는 재생 창도 잃는다.
실패는 성향과 보존 기간을 넘긴다. 그리고 전이 종류 자체가 `FAIL_RETRYABLE``FAIL_ABANDONED` 로 갈린다. 첫 번째 쌍은 두 겹으로 갈라진 셈이다.
시작과 해제는 아무것도 넘기지 않는다. javadoc 이 예시로 든 코덱 신원은 지금 어느 호출부도 넘기지 않는다.
## 이어 붙이는 방식이 구별을 잃게 할 수 있다
성분은 전부 가변 폭 텍스트다. 그중 소유자 토큰은 문자 집합을 이 플랫폼이 정하지 않는다 — 소유자 타입이 강제하는 것은 1자 이상 128자 이하와 공백 불가뿐이다.
구분자로 이으면 서로 다른 성분 목록이 하나의 문자열로 렌더링될 수 있다. 그래서 각 성분 앞에 길이를 적고 콜론을 찍은 뒤 값을 적는다.
적는 길이는 자바 문자 수다. 해시가 먹는 것은 UTF-8 바이트다. 같은 저장소의 메시징 파티션 키는 같은 목적에 UTF-8 바이트 수를 쓰고, 비 ASCII 골든 벡터로 그 선택을 고정한다.
## 버전 상수는 다이제스트 입력 안에 있다
상수 값은 2 이고, 이어 붙이기가 그 값을 `v2` 로 맨 앞에 쓴다. 형식이 바뀌면 값이 바뀌므로 옛 형식으로 계산된 다이제스트와 섞이지 않는다.
저장소 이력에는 이 파일이 지금 형태 그대로 커밋 하나에 들어와 있다. 값이 언제 몇 번 올랐는지는 남아 있지 않다.
## 완료 쪽은 아직 이 다이제스트를 보지 않는다
정책이 고쳐진 것과 그 정책이 판정에 쓰이는 것은 다르다.
이미 완료된 동일 연산의 재생 분기는 전이 다이제스트를 비교하지 않는다. 상태가 COMPLETED 인지, 마지막 전이 종류가 COMPLETE 인지, 연산 식별자가 같은지를 보고, 그다음 응답 다이제스트만 비교한다.
그래서 응답은 같고 재생 유효 기간만 다른 두 완료가 같은 결과로 판정된다. 분석 문서가 실제 PostgreSQL 탐침으로 그것을 확인했다 — 첫 호출에 한 시간, 두 번째 호출에 아홉 시간을 넘겼는데 두 번째가 같은 결과로 판정됐고, 저장된 창은 3600초 그대로였다.
## 확인하지 못한 것
수정 이전의 충돌을 실행으로 재현하지 않았다. 지금 정책이 지켜지는지는 다이제스트 정책 전용 테스트가 고정한다 — 성향 차이, 응답 차이, 재생 창 차이, 길이 프레이밍, 그리고 널 인자와 인자 없음의 구분이다. 완료 쪽이 아직 열려 있다는 것은 분석 문서의 실측 탐침이 확인했다.
<!-- body:end -->
@@ -0,0 +1,130 @@
---
kind: CASE
slug: a-lease-without-an-owner
title: 리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-lease-without-an-owner
evidenceCapturedOn: 2026-09-02
assets:
- key: a-lease-without-an-owner
file: ../../../final/evidence/rendered/a-lease-without-an-owner.svg
evidence:
- ../../../final/evidence/raw/a-lease-without-an-owner.txt
source:
- 분석 문서는 메시징 플랫폼 편 §7.3 이 V2 헤더를 인용하며 시간과 소유권 토큰의 차이를 짚고, outbox 어댑터 편 §12.3 이 두 세대의 술어와 SET 절과 반환 타입을 표로 대조한다. `@Deprecated` 가 없다는 것은 reliability-api 편이 P2 로 다룬다. 옛 경로가 남아 있는 것은 열린 질문이 아니라 P3 결함이고, 권고는 애너테이션이 아니라 제거다.
- 같은 형태의 다른 다섯 곳은 JPA 어댑터 편 §70·§71·§89 와 §83.3, mongo 어댑터 편 §55 다. §83.2 는 파일서버 쪽에 남은 구간 — 리스 만료 직후 인수 전에 아직 settle 할 수 있는 창 — 을 finding 으로 올리지 않은 이유와 함께 기록한다.
---
# 리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다
outbox 리스가 만료 시각만 담고 최종 상태 쓰기가 메시지 식별자만으로 행을 찾았다. 리스를 지나 멈춰 있던 작업자가 이미 발행된 행의 상태를 덮을 수 있었고, 그러면 행은 다시 청구 가능해져 메시지가 두 번 발행된다.
## 관계
- **fenced lease — 만료 시각만으로는 부족한 이유**
이 사례가 만든 개념이다.
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
이 결함의 수정 형태를 규칙으로 옮긴 것이다.
- **만료된 claim과 만료된 실행은 다르게 다뤄야 한다**
만료된 claim 과 만료된 실행을 갈라 다루는 판단이다.
## 문제
V1 스키마는 리스 만료 시각만 기록했다. 청구는 언제 끝나는지를 말했고 누가 들고 있는지는 말하지 않았다.
릴레이의 최종 상태 쓰기는 메시지 식별자만으로 행을 찾았다. 그래서 리스를 지나 멈춰 있던 작업자의 쓰기가 다른 작업자의 결과를 덮을 수 있었다.
## 결론
V2 가 세 컬럼을 더해 고쳤다. 리스 소유자와 리스 토큰과 다음 시도 시각이다. 청구가 토큰을 서버에서 올리고, 최종 쓰기가 소유자와 토큰을 함께 조건으로 건다.
같은 마이그레이션이 최종 상태 어휘도 넓혔다. 시도 예산이 확인 없이 소진된 상태는 브로커가 거절한 것과 다르므로 별도 상태가 됐다.
수정은 전이 메서드를 두 세대로 남겼다. 이 저장소의 프로덕션 코드는 전부 신세대만 부르지만, 펜싱 없는 옛 경로가 인터페이스와 구현에 그대로 있고 컴파일러가 막지 않는다.
## 검증 환경
데이터베이스 : PostgreSQL
확인 방식 : 마이그레이션 헤더의 사후 기록과 현재 스키마·구현 확인
소스 수정 : x
## 재현 조건
1. 메시징 outbox 의 V2 마이그레이션이 더한 컬럼과 제약, 상태 CHECK 변경을 읽는다.
2. 구현의 청구문이 소유자와 토큰을 어디서 쓰는지, 그 청구가 읽는 술어와 부분 인덱스를 확인한다.
3. 최종 쓰기의 술어와 0행 처리 방식을 확인한다.
4. 릴레이가 부르는 전이가 어느 세대인지 센다.
5. 펜싱 없는 옛 메서드가 남아 있는지, 그 자신의 javadoc 과 신세대 javadoc 이 각각 무엇을 적는지 대조한다.
6. 두 세대의 AMBIGUOUS 쓰기가 지우는 컬럼을 대조한다.
## 본문
<!-- body:start -->
## 시간을 늘려도 순서는 막히지 않는다
마이그레이션 헤더가 순서를 세 줄로 적어 둔다. 릴레이 A 가 청구하고 브로커를 부른다. 리스가 만료되어 릴레이 B 가 다시 청구하고, 발행하고, PUBLISHED 를 쓴다. 릴레이 A 가 그제서야 타임아웃되어 그 위에 AMBIGUOUS 를 쓴다.
AMBIGUOUS 는 청구 가능한 상태다. 확인된 메시지가 다시 발행 대상이 된다.
리스를 발행 타임아웃보다 길게 잡으면 확률은 내려간다. 그래도 GC 정지와 스케줄러 지연과 느린 브로커는 그 방식으로 데이터 제약이 되지 않는다.
## V2 가 더한 것: 소유자와 토큰과 다음 시도 시각
:::evidence key="a-lease-without-an-owner" alt="코드베이스에서 V2 마이그레이션이 더한 컬럼과 토큰 제약, 다섯 상태에서 여섯으로 바뀐 CHECK, 청구문이 소유자와 토큰을 쓰는 SET 절과 그 청구가 읽는 술어와 그에 맞춘 부분 인덱스, 최종 쓰기의 펜싱 술어와 0행을 보고한다는 주석, 릴레이가 부르는 다섯 전이, 펜싱 없는 옛 메서드의 선언과 그 자신의 javadoc 과 신세대 javadoc 의 폐기 문장과 @Deprecated 매치 수와 호출 수, 두 세대가 AMBIGUOUS 에서 남기는 컬럼의 차이, 그리고 파일서버 마이그레이션이 같은 결함을 다른 곳에서 지목하는 줄을 뽑은 출력. 옛 메서드의 javadoc 이 아직 안전을 주장하고 신세대 javadoc 이 그것을 폐기라 적는다는 것이 나란히 보인다." caption="V2 컬럼과 제약 · 청구 술어와 부분 인덱스 · 펜싱 술어와 0행 보고 · 옛 경로의 두 javadoc · AMBIGUOUS 에서의 세대 차이" zoom="true"
:::
토큰에는 음수가 아니라는 제약이 붙는다. 백필은 하지 않는다 — 기본값이 0 이고 첫 청구가 그것을 올리므로 정확성에 필요하지 않으며, 제약은 코드가 의존하는 불변식을 스키마에 남기려는 것이다.
청구문이 토큰을 `o.lease_token + 1` 로 올린다. 청구를 내주는 바로 그 문장 안에서, 서버가 올린다. 그래서 같은 행을 두고 경쟁한 두 릴레이가 같은 번호를 받을 수 없다.
같은 청구가 `next_attempt_at``attempts` 도 술어에 넣었다. 그 두 술어가 없을 때는 AMBIGUOUS 행이 바로 다음 순회에 다시 청구 가능해져서, 브로커 장애 한 번이 폴링 간격마다 백로그 전체를 재발행하게 만들었다. V2 의 부분 인덱스가 그 술어를 그대로 담는다.
## 최종 쓰기는 0행을 삼키지 않는다
최종 쓰기의 술어에 `lease_owner``lease_token` 이 들어간다. 지나간 획득의 쓰기는 걸릴 행이 없다.
그 자리 javadoc 이 0행을 어떻게 다루는지 적는다. 0행은 삼키지 않고 보고한다 — 지나간 쓰기가 있었다는 것은 이 작업자가 중복 발행을 만들었을 수 있다는 뜻이고, 그것이 운영자가 봐야 하는 사실이라는 것이다.
술어를 붙이는 SQL 문자열은 두 개이고, 그 둘을 만드는 헬퍼 둘이 다섯 전이의 최종 쓰기를 전부 처리한다.
## 여섯 번째 상태는 리뷰가 아니라 제약에서 막혔다
시도 예산이 확인 없이 소진된 것과 브로커가 메시지를 거절한 것은 다른 결말인데, V1 의 CHECK 가 다섯 상태를 열거하고 있었다.
그래서 여섯 번째를 쓰려는 시도는 코드 리뷰가 아니라 데이터베이스에서 실패했다. 열거형 CHECK 는 스키마 변경 없이 어휘를 넓히지 못하게 한다.
## 옛 경로가 옆에 남아 있고, 두 javadoc 이 반대말을 한다
이 저장소의 프로덕션 코드는 청구도 전이 넷도 전부 신세대만 부른다. 아래는 지금 일어나는 일이 아니라 포트가 두 형태를 나란히 둔 결과다.
옛 청구 메서드가 인터페이스에 그대로 있다. 그 메서드 자신의 javadoc 은 아직 이렇게 적는다 — 단순 조회가 아니라 리스를 잡는 것이 여러 릴레이를 안전하게 만들고, 한 릴레이가 청구한 레코드는 리스가 만료될 때까지 다른 릴레이에 보이지 않으므로 같은 메시지가 두 프로세스에서 동시에 발행되지 않는다는 것이다.
이 사례가 반증한 문장이 그대로 있다.
폐기를 적은 것은 신세대 쪽 javadoc 이다. 옛 메서드는 토큰 없는 레코드를 돌려주므로 호출자가 자기 쓰기가 자기 청구에 속한다는 것을 증명할 수 없고, 조사 경로용으로 남기며 릴레이가 쓰기에는 폐기되었다는 것이다.
그 문장은 산문이다. messaging 트리 전체에 `@Deprecated` 가 하나도 없다.
## 두 세대가 AMBIGUOUS 에 남기는 것이 다르다
신세대는 `lease_owner` 를 비우고 정책이 계산한 `next_attempt_at` 을 행에 쓴다. 구세대는 둘 다 건드리지 않는다.
AMBIGUOUS 는 청구 술어의 상태 목록에 있고 부분 인덱스의 조건에도 있다. 그래서 구세대가 남긴 `next_attempt_at` 은 그 행이 다음에 언제 청구되는지를 그대로 바꾼다.
반환 타입도 다르다. 신세대는 전이 결과를 돌려주므로 0행을 이름으로 부를 수 있다. 구세대는 `void` 라서 같은 일이 일어나도 부를 이름이 없다.
## 같은 형태가 메시징 아웃박스 밖에도 있었다
파일서버의 청구 펜싱 마이그레이션이 자기 헤더에서 그것을 알림 디스패처가 겪은 것과 같은 펜싱 리스 문제라고 적는다.
분석 문서를 따라가면 이 형태가 이 저장소 안에서만 여섯 곳이다. 메시징 아웃박스와 파일서버 정리와 Mongo 마이그레이션 락은 각자의 마이그레이션으로 펜싱을 얻었다. baseline outbox 와 durable operation, 그리고 파일서버 헤더가 이름을 부른 알림 디스패처는 아직 열려 있다 — 알림 쪽은 리스가 소유자와 펜스를 갖지만 프로바이더 호출 뒤의 투영 쓰기가 그 술어를 우회한다.
## 확인하지 못한 것
이 순서를 실제로 재현하지 않았다. 두 릴레이를 동시에 돌려 리스 만료 구간에서 이중 발행을 관측한 것은 아니다. 이 저장소 밖에 옛 경로를 실제로 부르는 배포가 있는지까지는 확인하지 않았다.
<!-- body:end -->
@@ -0,0 +1,160 @@
---
kind: CASE
slug: an-active-transaction-check-that-asked-the-wrong-question
title: 활성 트랜잭션 검사가 data source를 묻지 않아 남의 트랜잭션이 통과했다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:an-active-transaction-check-that-asked-the-wrong-question
evidenceCapturedOn: 2026-09-04
body: case-an-active-transaction-check-that-asked-the-wrong-question.body.md
assets:
- key: an-active-transaction-check-that-asked-the-wrong-question
file: ../../../final/evidence/rendered/an-active-transaction-check-that-asked-the-wrong-question.svg
evidence:
- ../../../final/evidence/raw/an-active-transaction-check-that-asked-the-wrong-question.txt
source:
- 원본 분석 절은 final/document.md#4-1 이다.
---
# 활성 트랜잭션 검사가 data source를 묻지 않아 남의 트랜잭션이 통과했다
옛 검사는 스레드에 트랜잭션이 열려 있는지만 확인했고 어느 데이터소스의 것인지는 확인하지 않았다. 지금은 `IdempotencyCapabilityGuard:101``hasResource(dataSource)` 를 뒤에 붙였는데, `dataSource` 가 널이면 그 검사를 건너뛴다.
## 관계
- **CAS 튜플과 update count가 답이 되는 구조**
그 개념에서 답이 되는 갱신 건수는 소유자 튜플을 반복한 문장이 하나의 트랜잭션 안에서 이 저장소의 커넥션 위로 실행됐을 때만 답이 된다. 그 전제를 거는 것이 이 가드다.
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
같은 세 검사가 가드와 outbox 어댑터와 inbox 어댑터 세 곳에 각각 따로 구현돼 있고, 그중 가드의 사본만 `dataSource != null` 을 앞에 달아 검사를 건너뛸 수 있다.
- **두 오버로드가 있었고 호출되는 쪽이 틀린 쪽이었다**
형제인 inbox 어댑터의 같은 세 검사를 그 기록이 먼저 적었다. 거기서는 셋이 한 조건으로 묶이고 널 가드가 없다.
## 문제
멱등성 저장소는 변경을 쓰기 전에 전제 셋을 통과해야 하고, 마지막 하나가 트랜잭션의 소유자를 가린다.
옛 검사는 그 셋 중 세 번째에 틀린 답을 냈고, 그 사실을 클래스 자바독이 사후 기록으로 남겼다.
## 결론
IdempotencyCapabilityGuard 의 클래스 자바독(:12~:21)이 무엇이 틀렸는지 남겨 두었다. 옛 검사는 트랜잭션의 존재만 보아 소유자를 가리지 못했고, 데이터소스가 둘인 배포에서 남의 트랜잭션이 그 검사를 통과했다. 그 변경이 커밋된 곳은 이 저장소의 커넥션이고, 그것을 감싸는 트랜잭션은 없었다.
지금 requirePrimaryWriteTransaction(:92~:108)에는 검사가 셋이다. :93 활성 트랜잭션, :97 읽기 전용 여부, :101 데이터소스 결속이다. 옛 검사가 첫 줄에 그대로 있고 새 검사가 맨 뒤에 붙었다.
맨 뒤 검사에는 앞선 조건이 하나 더 있다. dataSource 가 널이 아닐 때만 hasResource 를 평가한다. 생성자(:34~:39)에서 jdbc 와 activeCapabilitySql 은 requireNonNull 을 지나는데 dataSource 만 그대로 대입된다.
그 값은 PostgreSqlOwnerSafeIdempotencyStore:105~:109 의 dataSourceOf 가 정하고, JdbcTemplate 이 아니면 널이다.
프로덕션에서는 널이 되지 않는다. 조립 자리가 PostgreSqlIdempotencyProviderConfig:60 하나이고, 저장소에 JdbcOperations 구현이나 JdbcTemplate 하위 클래스가 0 건이라 그 빈은 JdbcTemplate 이다. 널 경로가 열려 있는 곳은 mock(JdbcOperations.class) 를 넘기는 유닛 시험(OwnerSafeIdempotencyPreconditionTest:45)이다.
그 검사에는 시험이 붙어 있지 않다. :101 이 던지는 메시지를 저장소에서 찾으면 던지는 줄 하나만 나오고, 전제 시험 셋은 트랜잭션 없음과 다른 벤더와 승인되지 않은 스키마만 단언한다.
형제 둘은 데이터소스를 생성자로 직접 받고 :158 과 :210 에서 requireNonNull 로 거른다. 그래서 널 가드를 둘 이유가 없다.
## 검증 환경
OpenJDK : 21.0.12
Spring Boot : 4.0.8
근거 : 저장소의 자바독이 사후 기록으로 남긴 회귀
확인 방식 : 가드 자바독의 사후 기록 확인, 필드와 생성자의 널 검사 유무 확인, 전제 검사 메서드의 세 검사와 각 실패 메시지 확인, 데이터소스를 정하는 메서드와 그 자바독 확인, 저장소를 만드는 자리 전수와 각각이 넘기는 값 확인, JdbcOperations 구현과 JdbcTemplate 하위 클래스 검색, 세 번째 검사의 메시지를 단언하는 시험 검색, 가드의 두 메서드를 부르는 자리 전수, 형제 어댑터 둘의 데이터소스 주입과 세 검사 형태 대조
소스 수정 : x
## 재현 조건
1. IdempotencyCapabilityGuard 의 클래스 자바독을 읽는다. 세 질문과 틀린 답이 적혀 있다.
2. 필드와 생성자를 읽고 어느 인자가 널 검사를 지나는지 본다.
3. 전제를 검사하는 메서드의 본문을 끝까지 읽고 검사가 몇 개인지, 각각 어떤 메시지로 실패하는지 적는다.
4. 데이터소스 검사에 붙은 조건을 읽고 그 값이 어디서 오는지 거슬러 올라간다.
5. 그 값을 정하는 메서드와 자바독을 읽는다.
6. 이 저장소를 만드는 자리를 전부 찾고 각각이 무엇을 넘기는지 확인한다.
7. 저장소에 그 인터페이스의 다른 구현이 있는지 찾는다.
8. 세 번째 검사가 던지는 메시지를 저장소에서 찾아 그것을 단언하는 시험이 있는지 본다.
9. 가드의 두 메서드를 부르는 자리를 전부 찾는다.
10. 형제인 outbox 와 inbox 어댑터가 데이터소스를 어떻게 받고 같은 세 검사를 어떤 형태로 거는지 확인한다.
## 본문
<!-- body:start -->
`IdempotencyCapabilityGuard` 는 멱등성 저장소가 SQL 을 돌리기 전에 만족해야 할 전제를 모아 둔 타입이다. 클래스 자바독이 이 타입이 따로 생긴 이유를 적는데, 전제가 서로 다른 세 질문이고 저장소가 세 번째를 틀리게 답했다는 것이다.
## 자바독이 남긴 사후 기록
:::evidence key="an-active-transaction-check-that-asked-the-wrong-question" alt="저장소 루트에서 돌린 정적 검색 출력 149줄. IdempotencyCapabilityGuard 의 클래스 자바독이 9번부터 23번 줄까지 원문 그대로 실려 세 질문과 틀린 답과 데이터소스가 둘일 때의 결과가 나온다. 이어서 필드 셋과 생성자가 28번부터 39번 줄까지 실리는데 jdbc 와 activeCapabilitySql 은 requireNonNull 을 지나고 dataSource 만 그대로 대입된다. requirePrimaryWriteTransaction 의 본문이 87번부터 109번 줄까지 나와 세 검사와 각각의 메시지가 보이고, 마지막 검사가 dataSource 가 널이 아닐 때만 hasResource 를 평가한다. 그 값을 정하는 dataSourceOf 가 JdbcTemplate 일 때만 getDataSource 를 돌려주고 아니면 널이라는 것과, 그 절충을 인정하는 자바독이 함께 나온다. 이 저장소를 만드는 자리 셋이 소스 세트별로 나오는데 프로덕션은 하나이고 그 자바독이 가드가 데이터소스를 식별하므로 다른 데이터소스의 트랜잭션은 통과할 수 없다고 약속한다. 유닛 시험은 mock 을 넘긴다. 저장소에 JdbcOperations 구현이나 JdbcTemplate 하위 클래스는 0 건이다. 세 번째 검사의 메시지를 찾으면 던지는 줄 하나만 나오고 시험은 없으며, 전제 시험 셋이 무엇을 단언하는지 이름으로 나온다. 마지막으로 가드를 부르는 열일곱 줄과 형제 어댑터 둘이 데이터소스를 생성자로 받아 requireNonNull 하는 것과 같은 세 검사를 거는 형태가 나온다." caption="세 질문과 틀린 답을 적은 자바독 · dataSource 만 널 검사를 지나지 않는 생성자 · 지금의 세 검사와 마지막에 붙은 널 조건 · 그 값을 정하는 dataSourceOf · 조립 자리 셋과 프로덕션 자바독의 약속 · JdbcOperations 구현 0 · 세 번째 검사를 덮는 시험 0 · 형제 둘의 생성자 주입과 세 검사 — 149줄 · exit 0" zoom="true"
:::
자바독 `:12`\~`:16` 은 세 질문을 나열한다. 스키마가 승인되었는가, 트랜잭션이 있는가, 그것이 이 저장소의 트랜잭션인가.
이어서 저장소가 세 번째를 틀리게 답했다고 적는다. 스레드에 활성 읽기 쓰기 트랜잭션이 있는지만 확인했는데 그 조건은 어느 데이터소스에서든 트랜잭션이 열려 있으면 참이고, 형제인 outbox 와 inbox 어댑터는 `hasResource(dataSource)` 를 확인하며 그것이 실제로 중요한 질문이라는 것이다.
`:18`\~`:21` 이 결과를 적는다. 데이터소스가 둘인 애플리케이션에서 다른 쪽의 트랜잭션 안에서 발행된 변경이 옛 검사를 통과했고, 이 저장소의 커넥션에서 트랜잭션 없이 실행됐으며, 원자적이어야 할 작업과 독립적으로 커밋됐다.
## 지금의 검사는 셋이다
`requirePrimaryWriteTransaction:92` 가 세 검사를 차례로 건다.
`:93``isActualTransactionActive()` 를 본다. 자바독이 틀렸다고 적은 바로 그 검사이고 지금도 첫 줄에 있다. `:97``isCurrentTransactionReadOnly()` 를 본다. `:101``dataSource != null && !hasResource(dataSource)` 를 본다.
`:102`\~`:103` 의 주석이 마지막 것은 outbox 와 inbox 어댑터가 이미 하는 검사이고, 이것이 없을 때 다른 데이터소스의 트랜잭션이 가드를 만족시키는 동안 이 저장소의 작업이 따로 커밋됐다고 적는다.
옛 검사를 지우지 않고 뒤에 검사 하나를 더했다. 세 실패가 각각 다른 메시지를 낸다.
## dataSource 가 널이면 \:101 을 건너뛴다
`:101` 의 조건은 데이터소스를 아는 경우에만 뒤쪽을 평가한다.
그 값이 어떻게 들어오는지는 생성자에 있다. `:36``jdbc` 를, `:38``activeCapabilitySql` 을 각각 `Objects.requireNonNull` 로 받는데 `:37``this.dataSource = dataSource` 만 그대로 대입한다. 널이 허용된다는 것이 이 세 줄에 나란히 적혀 있다.
넣는 쪽은 `PostgreSqlOwnerSafeIdempotencyStore:91` 이다. 생성자가 `dataSourceOf(jdbc)` 로 값을 만드는데, `:105`\~`:109` 의 그 메서드는 `jdbc``JdbcTemplate` 이면 `template.getDataSource()` 를 돌려주고 아니면 널을 돌려준다.
그 자바독(`:98`\~`:104`)이 절충을 인정한다. `JdbcTemplate` 은 자기 데이터소스를 알지만 손으로 만든 `JdbcOperations` 는 모를 수 있고, 가드는 알 수 없는 데이터소스를 "검사할 수 없음" 으로 다루는데 그것은 outbox 어댑터의 정확한 검사보다 약하고 이전보다는 강하며 협력자를 정말로 식별할 수 없을 때의 정직한 답이라는 것이다.
## 그 널 경로가 실제로 열리는 곳
이 저장소를 만드는 자리는 셋이다.
프로덕션은 `PostgreSqlIdempotencyProviderConfig:60` 하나이고 `JdbcOperations` 빈을 받는다. 그 메서드의 자바독 `:55`\~`:56` 은 저장소의 트랜잭션 가드가 그것으로부터 자기 데이터소스를 식별하므로 다른 데이터소스에서 연 트랜잭션은 통과할 수 없다고 적는다.
저장소에는 `JdbcOperations` 를 구현하거나 `JdbcTemplate` 을 상속하는 클래스가 0 건이다. 그러므로 이 저장소가 조립하는 배포에서 그 빈은 `JdbcTemplate` 이고 `:101` 은 살아 있다.
널 경로가 실제로 열려 있는 곳은 저장소 자신의 유닛 시험이다. `OwnerSafeIdempotencyPreconditionTest:45``mock(JdbcOperations.class)` 를 만들고 `:47` 이 그것으로 저장소를 만든다. 그 시험들이 도는 동안 `:101` 은 매번 건너뛰어진다.
## 그 검사를 덮는 시험이 없다
`:101` 이 던지는 메시지를 저장소 전체에서 찾으면 나오는 것은 던지는 줄 하나다. 그것을 단언하는 시험이 없다.
전제 시험 셋이 단언하는 것은 트랜잭션이 없는 경우(`:57`), 다른 벤더인 경우(`:67`), 승인되지 않은 스키마 스트림인 경우(`:81`)다. 세 번째 질문은 그 목록에 없다.
## 형제 어댑터와 같은 형태인가
`PostgreSqlImmutableOutboxAppendAdapter``:321` 에서 활성 트랜잭션을, `:325` 에서 읽기 전용 여부를, `:328` 에서 `hasResource` 를 각각 다른 `if` 로 검사한다. `PostgreSqlSameStoreInboxAdapter:503`\~`:505` 는 같은 셋을 한 조건으로 묶는다.
가드도 셋을 각각 다른 `if` 로 나누므로 outbox 와 같은 모양이다.
갈리는 것은 데이터소스를 얻는 방법이다. 형제 둘은 생성자가 `DataSource` 를 직접 받고 `:158``:210``Objects.requireNonNull` 로 거른다. 널일 수 없으므로 널 가드가 필요 없다. 가드는 `JdbcOperations` 에서 추론하고, 추론이 실패하면 널이 된다.
## 이 가드를 부르는 자리
`PostgreSqlOwnerSafeIdempotencyStore` 의 여섯 자리 — `:122`, `:176`, `:211`, `:255`, `:303`, `:360` — 가 같은 클래스의 private `requirePrimaryWriteTransaction`(`:506`\~`:507`)을 부르고, 그 메서드가 `guard.requirePrimaryWriteTransaction` 으로 넘긴다. `requireActiveCapability` 를 부르는 자리는 `:123` 하나다.
## 원문과 갈리는 자리
원문은 이 수정이 세 번째 질문을 형제와 같은 형태로 바꾼 것이라고 적었다. 바꾼 것이 아니라 더한 것이다. `:93` 의 옛 검사가 그대로 첫 줄에 있다.
원문은 형제 어댑터의 검사를 `hasResource(dataSource)` 하나로 적었다. outbox 는 셋을 각각 다른 `if` 로 걸고 inbox 는 같은 셋을 한 조건으로 묶는다.
세 질문과 틀린 답, 데이터소스 둘일 때의 결과는 원문대로다.
## 확인하지 못한 것
데이터소스를 둘 띄워 옛 동작을 재연하지 않았다. 남아 있는 기록과 현재 코드와 형제 구현을 나란히 놓고 읽었다.
`JdbcTemplate` 이 아닌 다른 `JdbcOperations` 구현을 넘겨 `:101` 이 열린 채 지나가는 것을 실행으로 보이지 않았다. 조건과 그 값을 정하는 메서드와 유닛 시험이 넘기는 값을 읽은 데까지다.
이 템플릿을 가져다 쓰는 애플리케이션이 자기 `JdbcOperations` 빈을 등록하는 경우는 보지 않았다. 이 저장소 안에 그런 구현이 없다는 것까지 확인했다.
이 템플릿을 가져다 쓰는 애플리케이션이 자기 `JdbcOperations` 빈을 등록하는 경우는 보지 않았다. 이 저장소 안에 그런 구현이 없다는 것까지 확인했다.
<!-- body:end -->
@@ -0,0 +1,99 @@
---
kind: CASE
slug: expired-claim-versus-expired-execution
title: 만료된 CLAIMED는 takeover하고 만료된 EXECUTING은 조정을 요구하도록 갈랐다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:expired-claim-versus-expired-execution
evidenceCapturedOn: 2026-09-01
assets:
- key: expired-claim-versus-expired-execution
file: ../../../final/evidence/rendered/expired-claim-versus-expired-execution.svg
evidence:
- ../../../final/evidence/raw/expired-claim-versus-expired-execution.txt
source:
- 원본 분석 절은 analysis/05 §10.1, §10.4 이다.
---
# 만료된 CLAIMED는 takeover하고 만료된 EXECUTING은 조정을 요구하도록 갈랐다
리스가 만료된 두 상태를 같이 다루면 안 된다. 청구만 하고 실행하지 않은 소유자는 밀어내도 되지만, 실행을 시작한 소유자는 무엇을 했는지 알 수 없으므로 조정으로 넘긴다.
## 관계
- **만료된 claim과 만료된 실행은 다르게 다뤄야 한다**
이 사례에서 끌어낸 규칙이다.
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
조정으로 넘기는 판정이 그 규칙의 적용이다.
- **fenced lease — 만료 시각만으로는 부족한 이유**
리스 만료를 다루는 맥락이다.
## 문제
리스가 만료되면 다른 작업자가 그 레코드를 가져갈 수 있어야 한다. 그러지 않으면 죽은 작업자의 레코드가 영원히 막힌다.
문제는 만료된 소유자가 무엇을 하다가 만료됐는지에 따라 안전한 처리가 다르다는 점이다.
## 결론
청구 결정 트리가 두 상태를 갈라 다르게 답한다.
같은 소유자 토큰이면 연산 충돌로 답한다
상태가 CLAIMED 이고 리스가 지났으면 청구를 재설정한다. 즉 가져간다
상태가 재시도 가능 실패면 마찬가지로 재설정한다
상태가 EXECUTING 이고 리스가 지났으면 복구 필요로 답한다
상태가 포기됨이면 복구 필요로 답한다
그 외에는 진행 중으로 답하고 재시도 시각을 준다
CLAIMED 는 자리를 잡았지만 아직 아무것도 실행하지 않은 상태다. 그 소유자를 밀어내도 외부 효과가 없다.
EXECUTING 은 실행을 시작한 상태다. 그 소유자가 무엇을 어디까지 했는지 이 저장소는 모른다. 밀어내고 다시 실행하면 그 작업이 두 번 일어날 수 있다.
그래서 EXECUTING 만료는 자동 처리 대상이 아니라 조정 대상이다. 결과 타입이 복구 필요라는 별도 값을 갖고, 그 값이 시도 번호를 함께 들고 간다.
같은 구별이 해제 경로에도 있다. 소유자 튜플이 다르면 소유자 아님으로 답하고, 상태가 이미 EXECUTING 이면 실행이 시작되었음으로 답하며, CLAIMED 가 아니면 연산 충돌로 답한다. 해제는 아직 실행하지 않은 청구에 대해서만 허용된다.
같은 형태가 인박스 어댑터에도 있다.
## 검증 환경
OpenJDK : 21.0.12
데이터베이스 : PostgreSQL
확인 방식 : 결정 트리와 그 javadoc 확인
소스 수정 : x
## 재현 조건
1. 소유자 안전 멱등성 저장소의 청구 결정 트리를 읽는다.
2. CLAIMED 만료와 EXECUTING 만료가 각각 어떤 결과를 내는지 확인한다.
3. 해제 경로의 상태 검사를 확인한다.
4. 인박스 어댑터의 같은 형태를 비교한다.
## 본문
<!-- body:start -->
만료된 lease를 일률적으로 takeover하면 이미 실행이 시작된 작업을 blind retry하게 된다.
## 이 저장소는 상태로 나눈다
만료된 `CLAIMED``resetClaim`으로 takeover하고, 만료된 `EXECUTING``abandonExpiredExecution`으로 `ABANDONED`에 넣고 `RecoveryRequired`를 반환한다.
## RecoveryRequired 참조 위치
:::evidence key="expired-claim-versus-expired-execution" alt="코드베이스에서 RecoveryRequired 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RecoveryRequired 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## inbox도 같은 축을 쓴다
`RECEIVED`(takeover 가능)와 `PROCESSING`(→ DEAD, recovery-required)로 나눈다. 즉 "claim만 했다"와 "실행에 들어갔다"가 만료 시 다른 결론을 낳는다.
## 확인하지 못한 것
만료된 EXECUTING 이 실제로 조정 큐로 흘러가 사람이 처리하는 경로를 따라가지 않았다. 확인한 것은 저장소가 그 상태를 별도 결과로 답한다는 것이다.
컨테이너 레인 미실행
<!-- body:end -->
@@ -0,0 +1,88 @@
---
kind: CASE
slug: native-claim-did-not-bump-the-version
title: native claim이 @Version을 올리지 않아 충돌을 보고하지 않는 낙관적 잠금이 됐다
topic: owner-safe-state-machines
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:native-claim-did-not-bump-the-version
evidenceCapturedOn: 2026-09-01
assets:
- key: native-claim-did-not-bump-the-version
file: ../../../final/evidence/rendered/native-claim-did-not-bump-the-version.svg
evidence:
- ../../../final/evidence/raw/native-claim-did-not-bump-the-version.txt
source:
- 원본 분석 절은 final/document.md#4-1 · analysis/05 §11.2 이다.
---
# native claim이 @Version을 올리지 않아 충돌을 보고하지 않는 낙관적 잠금이 됐다
네이티브 SQL 로 청구하면서 JPA 버전 컬럼을 올리지 않았다. 청구 전에 로드된 관리 엔티티가 옛 버전을 그대로 들고 있어서, 그 플러시가 성공하면서 리스와 상태를 청구 이전 값으로 덮었다.
## 관계
- **CAS 튜플과 update count가 답이 되는 구조**
네이티브 청구가 쓰는 구조다.
- **Atomic 타입의 존재는 원자성의 증거가 아니다**
잠금 장치가 있다는 것과 그것이 충돌을 보고한다는 것이 다르다는 점에서 같은 계열이다.
## 문제
수신자 전달 청구는 네이티브 SQL 로 이뤄진다. 리스 소유자와 리스 만료와 펜스를 설정하고 상태를 전달 중으로 바꾼다.
같은 테이블에 JPA 엔티티가 매핑되어 있고 그 엔티티에는 버전 컬럼이 있다.
## 결론
네이티브 문장이 버전 컬럼을 함께 올리지 않으면 낙관적 잠금이 무력해진다.
문장 안의 주석이 그 이유를 정확히 적는다. 이것이 JPA 의 버전 컬럼이고 네이티브 쓰기가 그것을 전진시킨다는 것이다. 이것이 없으면 청구 전에 로드된 관리 엔티티가 여전히 옛 버전을 들고 있고, 그 플러시가 성공하면서 리스와 상태를 청구 이전 값으로 덮는다. 충돌이 없다고 보고하는 낙관적 잠금이 되는 것이다.
문제의 성격이 특이하다. 낙관적 잠금은 있고 동작하며 예외를 던지지 않는다. 던지지 않는 이유가 네이티브 문장이 버전이 바뀌었다고 알려 주지 않았기 때문이다.
그래서 증상은 잠금 실패가 아니라 조용한 덮어쓰기다. 청구가 성공했고 다른 트랜잭션의 플러시가 그것을 되돌린다.
수정은 네이티브 문장의 SET 절에 버전 증가를 넣는 것이다. 같은 문장이 펜스도 함께 올린다.
## 검증 환경
OpenJDK : 21.0.12
데이터베이스 : PostgreSQL
확인 방식 : 문장과 그 주석 확인
소스 수정 : x
## 재현 조건
1. 수신자 청구 SQL 의 SET 절을 읽는다.
2. 버전 컬럼 증가 줄과 그 위 주석을 확인한다.
3. 같은 문장이 펜스를 올리는지 확인한다.
4. 같은 테이블에 매핑된 엔티티의 버전 컬럼을 확인한다.
## 본문
<!-- body:start -->
claim이 native `UPDATE`인데 JPA `@Version` 컬럼을 올리지 않으면, claim 전에 로드된 managed 엔티티가 여전히 옛 version을 들고 있다.
## native UPDATE 와 managed 엔티티
:::evidence key="native-claim-did-not-bump-the-version" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 충돌을 보고하지 않는 낙관적 잠금이 된다
그 flush가 **성공하면서 lease와 state를 pre-claim 값으로 덮어쓴다.** native statement가 충돌이 있었다고 말해주지 않았기 때문이다.
## 수정은 한 줄이고 주석이 붙어 있다
statement에 `version = d.version + 1`을 넣는다.
## 확인하지 못한 것
청구 전에 로드한 엔티티를 플러시해 덮어쓰기를 재현하지 않았다. 이 기록은 문장과 그 주석에 근거한다.
컨테이너 레인 미실행
<!-- body:end -->