Files
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

161 lines
15 KiB
Markdown

---
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 -->