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,184 @@
---
kind: CASE
slug: a05-f029-for-update-skip-locked
title: SKIP LOCKED 로 잡은 조정 작업을 두 번째 연결이 그대로 받는다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a05-f029-for-update-skip-locked
evidenceCapturedOn: 2026-09-02
assets:
- key: a05-f029-for-update-skip-locked
file: ../../../final/evidence/rendered/a05-f029-for-update-skip-locked.svg
- key: a05-f029-for-update-skip-locked-probe
file: ../../../final/evidence/rendered/a05-f029-for-update-skip-locked-probe.svg
evidence:
- ../../../final/evidence/raw/a05-f029-for-update-skip-locked.txt
- ../../../final/evidence/raw/a05-f029-for-update-skip-locked-probe.txt
source:
- 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §90 이다. 등급은 P2 이고, 잠금 수명과 작업 수명이 다르다는 판정과 두 작업자 시나리오 탐침이 그 절에 있다. 그 절도 수정으로 짧은 청구 쪽을 택한다.
- 표에 소유자와 리스 열이 아예 없다는 것, 같은 패키지의 전달 큐 청구문과의 대조, 그리고 두 도달 조건은 이 기록에서 확인했다.
---
# SKIP LOCKED 로 잡은 조정 작업을 두 번째 연결이 그대로 받는다
조정 작업 청구는 `FOR UPDATE SKIP LOCKED` 로 끝나는 SELECT 하나다. 트랜잭션도 열지 않고 표에 소유자나 리스를 쓰지도 않으므로, 그 조회가 끝나면서 잠금도 사라진다. 같은 패키지의 전달 큐는 같은 잠금 구문 뒤에 `UPDATE` 를 붙여 청구를 행에 남긴다.
## 관계
- **조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다**
이 사례가 위반하는 규칙이다.
- **fenced lease — 만료 시각만으로는 부족한 이유**
수정 방향이 기대는 구조다.
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
조건을 where 절에 전부 넣고 갱신 결과로 판단하라는 규칙이다.
## 문제
청구 저장소의 javadoc 은 잠금 구문을 쓰는 이유를 전달 큐와 같다고 적는다. 같은 표를 폴링하는 두 작업자가 같은 작업을 둘 다 가져가면 안 된다는 것이다.
## 결론
청구 메서드는 질의 하나를 실행하고 끝난다. 그 클래스에서 트랜잭션을 여는 애너테이션도 포트 호출도 찾을 수 없다. 자동 커밋이면 SELECT 종료와 함께 암묵 커밋이 걸리고 행 잠금도 그 자리에서 풀린다.
표 쪽에도 남길 자리가 없다. 조정 작업 표의 열은 다음 확인 시각과 시도 횟수와 마지막 결과뿐이고, 이 표를 건드리는 마이그레이션은 만든 것과 색인 하나와 완결성 검사용 이름 리터럴이 전부다.
작업자도 전체 구간을 감싸지 않는다. 만기 조회로 목록을 받은 뒤, 제공자를 다녀오고 나서야 상태를 쓴다.
실제 PostgreSQL 16 에서 자동 커밋 연결 둘이 그 문장을 차례로 실행했다. 사이에 정산은 없다. 둘 다 같은 작업을 받았고 행 상태는 그대로다. 겹쳐 실행하지 않아도 재현된다는 것이 잠금이 이미 사라졌다는 뜻이다.
같은 패키지의 전달 큐는 다르게 한다. 이쪽은 잠금 구문을 CTE 로 감싸고 한 문장 안에서 소유자·리스 만료·펜스를 갱신하며 상태를 배송 중으로 바꾼다. 청구가 행에 남는 시점이 잠금이 풀리기 전이다. 조정 쪽에서만 그 쓰기가 생략됐다.
여기서 일어나는 일이 전송이 아니므로 판정을 한 단계 낮췄다. 깨지는 것은 javadoc 이 적어 둔 계약 쪽이다.
## 검증 환경
OpenJDK : 21.0.12
데이터베이스 : PostgreSQL 16.15, 실제 실행
확인 방식 : 청구 메서드와 작업자의 경계 선언 확인, 표를 건드리는 마이그레이션 전수, 실제 PostgreSQL 에서 두 연결로 같은 문장 실행
소스 수정 : x
ca-skeleton.notification.platform.enabled 가 참이고 모드가 SERVING 이며 인스턴스가 둘 이상인 배포다. 출하 기본값이 거짓이고, 프로세스가 하나면 스케줄러가 자기 겹침을 막는다.
## 재현 조건
1. 청구 저장소의 javadoc 과 청구 문장, 그리고 그것을 실행하는 메서드를 읽는다.
2. 그 클래스와 작업자 클래스의 트랜잭션 경계 선언을 센다.
3. 조정 작업 표의 열과, 그 표를 건드리는 마이그레이션을 전부 나열한다.
4. 같은 패키지의 전달 큐 청구문을 읽는다.
5. PostgreSQL 에 알림 플랫폼 마이그레이션을 적용하고 만기 작업을 하나 넣는다.
6. 자동 커밋 연결 둘로 그 문장을 차례로 실행하고 받은 작업과 행 상태를 비교한다.
## 본문
<!-- body:start -->
조정 작업 저장소는 청구 질의를 `FOR UPDATE SKIP LOCKED` 로 끝낸다. javadoc 이 이유를 적어 두었다.
```text
* <p>Claiming is {@code FOR UPDATE SKIP LOCKED} inside the select, for the same reason the delivery
* queue is: two workers polling the same table must not both take the same job.
```
## 청구 메서드는 트랜잭션을 열지 않는다
:::evidence key="a05-f029-for-update-skip-locked" alt="청구 저장소의 javadoc 과 청구 문장과 그것을 실행하는 메서드, 그 클래스의 트랜잭션 경계 선언 수, 같은 패키지 전달 큐의 청구문 전문, 작업자의 처리 순서와 그 클래스의 경계 선언 수, 조정 작업 표의 열 정의와 그 표를 건드리는 마이그레이션 전부, 그리고 알림 플랫폼 마스터 스위치의 출하 기본값과 배경 작업자 스케줄러의 스레드 수를 출력한 터미널 기록." caption="javadoc 은 전달 큐와 같은 이유라고 적음 · 청구는 질의 하나, 경계 선언 0 · 전달 큐는 같은 잠금 구문에 UPDATE 를 붙여 소유자·리스·펜스를 씀 · 표에는 그 열이 없음 · 마스터 스위치 기본값 false, 스케줄러 스레드 1 — 97줄 · exit 0" zoom="true"
:::
```java
public List<ReconciliationJob> claimDue(int limit, Instant now) {
Objects.requireNonNull(now, "now");
if (limit < 1) {
throw new IllegalArgumentException("limit");
}
return jdbc.query(CLAIM_DUE, MAPPER, Timestamp.from(now), limit);
}
```
그 클래스에는 `@Transactional``TransactionPort` 호출도 없다. 자동 커밋에서는 이 SELECT 가 끝나면서 암묵 커밋이 일어나고, 행 잠금은 거기서 사라진다.
표에도 남길 곳이 없다. 조정 작업 표에는 소유자도 리스도 상태도 없다. 다음 확인 시각과 시도 횟수와 마지막 결과가 전부다. 이 표를 건드리는 마이그레이션은 만든 것과 색인 하나, 그리고 완결성 검사용 이름 리터럴뿐이다.
## 작업자는 조회와 정산 사이에 제공자를 부른다
```java
public int reconcileOnce() {
List<ReconciliationJob> due = jobs.claimDue(batchSize, clock.instant());
int settled = 0;
for (ReconciliationJob job : due) {
// One job's failure is not the pass's: a provider that is refusing connections would
// otherwise stop every other provider's jobs behind it.
try {
if (handle(job)) {
settled++;
}
} catch (RuntimeException failure) {
jobs.reschedule(
job, failure.getClass().getSimpleName(), clock.instant().plus(retryBackoff));
}
}
return settled;
}
```
목록을 먼저 받고, 반복문 안에서 제공자 조정을 수행한 뒤에야 완료나 재예약을 부른다. 작업자 클래스도 마찬가지로 경계 선언이 없다.
## 두 연결이 같은 작업을 받는다
:::evidence key="a05-f029-for-update-skip-locked-probe" alt="실제 PostgreSQL 컨테이너에 알림 플랫폼 마이그레이션을 적용해 상위 표들이 세워진 것을 확인하고, 만기 작업 하나를 넣은 뒤 자동 커밋 연결 두 개가 저장소의 청구 문장을 차례로 실행해 각각 받은 작업 식별자와 그 둘이 같은지, 그리고 행 상태를 출력한 터미널 기록. 상위 행 사슬을 만들지 않으려고 외래 키 하나만 뗐다는 단서가 함께 적혀 있다." caption="PostgreSQL 16.15 · 상위 표는 마이그레이션이 세움 · 두 연결 모두 autoCommit true · A 와 B 가 같은 작업을 받음 · 행은 attempts 0, last_result null, 다음 확인 시각 그대로 — 11줄 · exit 0" zoom="true"
:::
```text
두 연결의 autoCommit : A=true B=true
작업자 A 가 받은 작업 : 441a6b30-6574-4af2-a637-1caeef230678
작업자 B 가 받은 작업 : 441a6b30-6574-4af2-a637-1caeef230678
같은 작업인가 : true
행 상태 : attempts=0 last_result=null next_check_at 그대로
```
겹쳐 실행하지 않아도 재현된다. 두 번째 호출 시점에는 첫 호출의 잠금이 이미 사라져 있다.
## 전달 청구는 같은 문장 안에서 소유자를 쓴다
javadoc 이 이유로 든 전달 큐의 청구문이 같은 패키지에 있다.
```sql
WITH claimable AS (
SELECT id FROM notification_recipient_delivery
WHERE next_dispatch_at <= :now
...
FOR UPDATE SKIP LOCKED
LIMIT :batchSize
)
UPDATE notification_recipient_delivery AS d
SET lease_owner = :owner,
lease_until = :leaseUntil,
lease_fence = d.lease_fence + 1,
delivery_state = 'DISPATCHING',
...
```
같은 잠금 구문을 CTE 안에 넣고, 그 문장의 `UPDATE` 로 소유자와 리스 만료와 펜스를 쓰고 상태를 배송 중으로 바꾼다. 잠금이 풀리기 전에 청구가 행에 남는다.
조정 작업 청구에는 그 `UPDATE` 가 없다. 차이는 절 하나다.
## 이 결함이 나타나는 조건
알림 플랫폼 마스터 스위치가 켜져 있어야 한다. `application.yml` 이 그것을 거짓으로 내보내므로 기본 배포는 작업자 빈 자체를 만들지 않는다.
인스턴스도 둘 이상이어야 한다. 한 프로세스 안에서 이 패스는 스레드 하나짜리 스케줄러에 고정 지연으로 걸려 자기 자신과 겹치지 않는다. 두 작업자란 두 프로세스를 뜻한다.
## 영향과 수정
조정은 전송이 아니라 제공자 상태 조회와 상태 투영이므로 영향은 전달 경로보다 낮다. 다만 javadoc 이 적은 계약은 깨진다.
수정은 전달 청구처럼 잠금과 같은 문장 안에서 소유자와 펜스와 리스를 쓰는 청구문을 두는 것이다. 외부 제공자 호출을 긴 데이터베이스 트랜잭션 안에 넣는 것보다 이 방식이 낫다.
## 확인하지 못한 것
두 작업자가 실제로 제공자 조정을 중복 수행했을 때의 결과를 관측하지 않았다. 확인한 것은 두 호출이 같은 작업을 받는다는 사실이다.
탐침은 상위 행 사슬을 만들지 않으려고 외래 키 하나를 뗐다. 표는 마이그레이션이 모두 세운다.
<!-- body:end -->
@@ -0,0 +1,228 @@
---
kind: CASE
slug: a06-f013-recordapplied
title: recordApplied가 펜스를 비교하지 않아 밀려난 실행자가 원장을 차지한다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a06-f013-recordapplied
evidenceCapturedOn: 2026-09-02
body: case-a06-f013-recordapplied.body.md
assets:
- key: a06-f013-recordapplied
file: ../../../final/evidence/rendered/a06-f013-recordapplied.svg
- key: a06-f013-recordapplied-probe
file: ../../../final/evidence/rendered/a06-f013-recordapplied-probe.svg
- key: a06-f013-recordapplied-around
file: ../../../final/evidence/rendered/a06-f013-recordapplied-around.svg
evidence:
- ../../../final/evidence/raw/a06-f013-recordapplied.txt
- ../../../final/evidence/raw/a06-f013-recordapplied-probe.txt
- ../../../final/evidence/raw/a06-f013-recordapplied-around.txt
source:
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md#L884 이다. 등급은 P2 다. 계약과 구현의 어긋남, 복제 세트 탐침의 네 줄, 체크포인트 저장에서 이미 한 번 고친 형태라는 지적, 도달성 셋, 그리고 시험이 이 경계를 보지 않는다는 사실이 그 절에 있다.
- 갱신과 삽입 사이에 들어가는 것이 사후조건 검사 하나라는 것, 갱신이 던지는 예외의 타입, 색인 없이 같은 경합이 남기는 항목 수, 그리고 실서버 시험의 단언 문장은 이 기록에서 확인했다.
---
# recordApplied가 펜스를 비교하지 않아 밀려난 실행자가 원장을 차지한다
원장 기록 메서드의 계약은 펜스가 여전히 현재 것일 때만 기록한다고 적는다. 구현이 하는 검사는 펜스가 미지정 값인지 하나뿐이다. 복제 세트에서 밀려난 실행자의 항목이 원장에 남고, 실제로 작업한 실행자는 드라이버의 중복 키 오류를 받았다.
## 관계
- **fenced lease — 만료 시각만으로는 부족한 이유**
펜스와 소유자를 함께 요구하는 이유를 적은 문서다.
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
같은 원장의 다른 메서드가 지키는 규칙이다.
- **리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다**
다른 리프에서도 소유자를 기록하지 않아 최종 상태가 되돌려졌다.
## 문제
원장은 포크가 구현하도록 공개된 인터페이스다. 그 인터페이스의 javadoc 이 기록 메서드의 계약을 세 문장으로 적는다. 펜스가 현재 것일 때만 기록한다는 것, 밀려난 실행자의 항목은 대체 실행자가 덮어쓴 작업을 완료됐다고 말한다는 것, 더 새로운 획득이 있으면 전용 예외를 던진다는 것이다.
## 결론
구현은 널 검사 넷과 미지정 값 검사 하나를 하고 삽입한다. 저장된 펜스와의 비교도, 서버측 조건도 없다. 검사 메서드 이름은 requireCurrentFence 인데 몸통의 조건은 fence == UNFENCED 하나다. 그 메서드의 javadoc 은 자기가 거절하는 것이 미지정 리스뿐이라고 정확히 적는다. 어긋난 것은 이름과 그것이 만족시켜야 할 계약이다.
같은 파일의 체크포인트 저장은 그 계약을 지킨다. 저장된 펜스를 필터에 걸고, 중복 키 오류를 잡아 플랫폼 예외로 번역한다. 그 자리의 주석은 왜 그렇게 됐는지도 적는다. 밀려난 실행자가 자기 몫으로 쓰인 문장 대신 드라이버 오류를 받았기 때문이다. 같은 형태를 한쪽에서 고치고 다른 쪽에는 적용하지 않았다.
복제 세트에서 확인했다. 펜스 5 의 체크포인트가 있는 상태에서 펜스 1 을 든 실행자가 두 메서드를 부르면 체크포인트는 거절되고 원장 기록은 수용된다. 그 뒤 펜스 5 로 기록하면 고유 색인이 중복 키로 거절한다. 주석이 서술한 과거와 받는 쪽이 바뀌어 있다. 그때는 밀려난 실행자가 드라이버 오류를 받았고 지금은 실제로 작업한 실행자가 받는다.
중복 키가 나오는 것은 고유 색인이 있을 때뿐이다. 그 색인을 만드는 메서드를 부르는 곳은 시험뿐이고, 만들지 않고 운영하면 같은 경합이 오류 없이 원장에 두 줄을 남긴다. 색인이 없으면 오류가 나지 않으므로 중복이 더 늦게 발견된다.
실행기와 원장과 잠금을 조립하는 프로덕션 코드가 이 리프에 없다. 포크가 이 저장소의 실행기와 잠금을 함께 쓰면 인접한 장치가 막는다. 실행기가 원장을 쓰기 전에 리스를 갱신하고, 그 갱신은 소유자와 펜스를 조건으로 건다. 다만 셋이 남는다. 갱신과 삽입 사이에 사후조건 검사가 들어가는데 그것은 마이그레이션이 넘긴 코드이고, 그 갱신이 던지는 것은 플랫폼의 거절 문장이 아니라 맨 IllegalStateException 이며, 인터페이스가 약속한 보호는 어느 원장 구현에도 없다. 다른 구현은 펜스 인자를 받기만 한다.
시험 중에 밀려난 펜스로 원장 기록을 부르는 것은 없다. 원장 쓰기가 펜스를 실어 나른다는 이름을 단 시험은 미완료를 돌려주는 마이그레이션을 써서 원장 기록 분기까지 가지 않는다. 실서버 레인의 시험 하나가 같은 펜스로 두 번 부르는데, 그 단언에 붙은 문장이 막는 것은 읽고-쓰기 검사가 아니라 고유 색인이라고 적는다.
수정은 체크포인트 저장이 쓰는 방식을 그대로 쓰면 된다. 원장 기록도 저장된 펜스를 조건으로 삼고, 중복 키를 잡아 플랫폼 예외로 번역하는 것이다.
## 검증 환경
OpenJDK : 21.0.12
MongoDB : 8.0.16 단일 노드 복제 세트
확인 방식 : 계약과 구현 대조, 실제 복제 세트에 두 실행자의 쓰기 실행
소스 수정 : x
## 재현 조건
1. 인터페이스 javadoc 의 계약과 구현의 몸통, 그리고 검사 메서드와 그 javadoc 을 나란히 읽는다.
2. 같은 파일의 체크포인트 저장이 거는 필터와 중복 키 처리, 그리고 그 자리 주석을 읽는다.
3. 단일 노드 복제 세트를 띄우고 고유 색인을 만든다.
4. 펜스 5 로 체크포인트를 쓴다.
5. 펜스 1 로 체크포인트 저장과 원장 기록을 각각 시도하고 원장 항목을 읽는다.
6. 펜스 5 로 원장 기록을 시도한다.
7. 두 메서드에 미지정 값을 넣어 결과를 비교한다.
8. 고유 색인 없이 같은 경합을 반복하고 원장 항목 수를 센다.
9. 실행기에서 갱신과 원장 기록 사이에 무엇이 실행되는지, 완료를 만드는 팩토리가 체크포인트를 남기는지 읽는다.
10. 원장 기록을 부르는 시험과 고유 색인을 만드는 곳을 전수로 센다.
## 본문
<!-- body:start -->
원장 인터페이스는 포크가 구현하도록 공개돼 있고, 기록 메서드의 계약을 javadoc 이 적는다.
## 계약과 몸통
:::evidence key="a06-f013-recordapplied" alt="원장 인터페이스의 javadoc 계약, 그 계약을 구현한 메서드의 삽입 부분, 그것이 부르는 펜스 검사와 그 검사의 javadoc, 같은 파일의 체크포인트 저장이 거는 저장된 펜스 필터, 그리고 그 자리가 중복 키를 플랫폼 예외로 번역하게 된 이유를 적은 주석과 번역 코드를 출력한 터미널 기록." caption="계약은 펜스가 현재 것일 때만 기록 · 구현은 검사 하나 뒤 삽입 · 검사의 조건은 fence == UNFENCED 하나이고 javadoc 도 미지정 리스만 거절한다고 적음 · 체크포인트 저장은 저장된 펜스를 필터에 걸고 중복 키를 번역 — 64줄 · exit 0" zoom="true"
:::
```java
/**
* Records a completed migration, only if the fence is still the current one.
*
* <p>Conditioned on the fence because the runner that wrote the batches may no longer be the
* runner that owns the lease. A ledger entry from a superseded runner says a migration completed
* when the work it describes was overwritten by the runner that replaced it.
*
* @param fence the acquisition token from {@link MongoMigrationLock#fence()}
* @throws ... MongoOperationRejectedException when a newer acquisition exists
*/
```
구현은 검사 하나를 부르고 삽입한다.
```java
requireCurrentFence(fence, "ledger entry for " + migrationId.value());
ledger.insertOne(
new Document(MIGRATION_ID, migrationId.value())
.append("checksum", checksum.value())
...
.append("fence", fence));
```
그 검사의 javadoc 과 몸통은 서로 맞는다.
```java
/**
* Refuses a write from an unfenced lease.
...
private static void requireCurrentFence(long fence, String what) {
if (fence == MongoMigrationLock.UNFENCED) {
```
비교 대상은 저장된 값이 아니라 상수 하나다. 서버로 나가는 조건에 펜스는 없고, 펜스는 삽입되는 문서의 필드로만 남는다. 문서가 틀린 것이 아니라, 이 검사로는 인터페이스가 적은 계약을 만족시킬 수 없다.
## 같은 파일이 다른 메서드에서는 지킨다
체크포인트 저장은 저장된 펜스를 필터에 건다.
```java
Filters.and(
Filters.eq(MIGRATION_ID, checkpoint.migrationId().value()),
Filters.or(Filters.exists("fence", false), Filters.lte("fence", fence))),
```
그리고 중복 키를 잡아 번역한다. 그 자리 주석이 이유를 적는다.
```java
// Nor was the refusal itself reachable. With `upsert(true)`, a superseded write matches nothing
// and MongoDB attempts an insert, which the unique index on migrationId rejects — so a
// superseded runner got a driver-level duplicate-key error instead of the sentence written for
// it, and the branch meant to produce that sentence was dead.
```
## 복제 세트에서
:::evidence key="a06-f013-recordapplied-probe" alt="단일 노드 복제 세트에서 살아 있는 실행자가 펜스 5 로 쓴 체크포인트, 펜스 1 을 든 밀려난 실행자가 체크포인트 저장과 원장 기록을 각각 시도한 결과와 그 뒤의 원장 항목, 살아 있는 실행자가 다시 원장 기록을 시도한 결과, 두 메서드에 미지정 펜스를 넣었을 때의 결과, 그리고 고유 색인 없이 같은 경합을 반복했을 때 남은 원장 항목 수와 그 두 항목을 출력한 터미널 기록." caption="밀려난 펜스로 체크포인트는 거절되고 원장 기록은 수용 · 살아 있는 펜스의 기록은 migrationId_1 중복 키로 거절 · 미지정 펜스는 두 메서드 다 거절 · 색인이 없으면 둘 다 수용되어 원장에 두 줄 — 22줄 · exit 0" zoom="true"
:::
펜스 5 의 체크포인트가 있는 상태에서 펜스 1 을 든 실행자가 두 메서드를 부른다.
```text
[밀려난 실행자, 펜스 1] 같은 계약을 두 메서드에 건다
saveCheckpoint -> 거절, MongoOperationRejectedException: a newer migration runner owns the lease; this runner's checkpoint write was refused
recordApplied -> 수용
원장 항목 : {"migrationId": "20260829-001", "checksum": "superseded", "operator": "stale-runner", ..., "fence": 1}
```
그 뒤 살아 있는 실행자가 기록하면 이렇게 된다.
```text
recordApplied -> 거절, MongoWriteException code=11000 index=migrationId_1
```
같은 드라이버 오류가 지금 원장 기록에서 나온다. 다만 받는 쪽이 바뀌었다. 주석이 적은 과거에는 밀려난 실행자가 그 오류를 받았고, 지금은 실제로 작업한 실행자가 받는다.
미지정 펜스는 두 메서드 다 거절한다. 밀려난 펜스는 원장 기록만 통과한다. 두 결과 사이에 이 구현이 거절할 수 있는 값의 집합이 있다.
그 색인이 없으면 어떻게 되는지도 같이 돌렸다.
```text
[대조 2] ensureIndexes 를 부르지 않은 원장에서 같은 경합
recordApplied -> 수용
recordApplied -> 수용
원장 항목 수 : 2
```
## 포크가 조립할 때 인접한 장치가 막는다
:::evidence key="a06-f013-recordapplied-around" alt="실행기에서 리스 갱신과 원장 기록 사이에 실행되는 것, 완료와 미완료를 만드는 두 팩토리, 그 갱신이 던지는 예외와 같은 인터페이스의 다른 잠금 구현이 같은 상황에 던지는 예외, 펜스 인자를 쓰지 않는 다른 원장 구현, 원장 기록을 부르는 시험 전수와 그중 실서버 시험의 단언, 그리고 고유 색인을 만드는 메서드를 부르는 곳 전수를 출력한 터미널 기록." caption="갱신과 삽입 사이에 사후조건 검사 · 완료 팩토리는 체크포인트를 널로 넣음 · 갱신은 IllegalStateException, 다른 잠금 구현은 플랫폼 예외 · 다른 원장 구현은 펜스 미사용 · 실서버 시험의 단언 문장이 막는 것은 고유 색인이라고 적음 · 색인 생성 호출자는 시험 하나 — 69줄 · exit 0" zoom="true"
:::
이 리프에는 실행기와 원장과 잠금을 조립하는 프로덕션 코드가 없다. 포크가 이 저장소의 실행기와 잠금을 함께 쓰면, 실행기가 원장을 쓰기 전에 리스를 갱신한다.
```java
lock.refresh(template.maxTime());
if (!migration.postcondition().isSatisfied(context, result)) {
...
result.resumePoint().ifPresent(checkpoint -> ledger.saveCheckpoint(checkpoint, lock.fence()));
if (result.status() == MongoMigrationResult.Status.COMPLETED && !context.dryRun()) {
ledger.recordApplied(
```
갱신과 삽입 사이에 있는 것은 사후조건 검사 하나다. 바로 위 줄의 체크포인트 저장은 이 경로에 들어오지 않는다. 완료를 만드는 팩토리가 체크포인트를 널로 넣으므로 두 줄은 배타적이다.
```java
public static MongoMigrationResult completed(long processedCount) {
return new MongoMigrationResult(Status.COMPLETED, processedCount, null, "");
}
```
그리고 그 갱신이 실패했을 때 나오는 것은 플랫폼의 거절 문장이 아니다.
```java
throw new IllegalStateException(
"the migration lease was lost before it could be refreshed; another runner may have "
```
같은 인터페이스의 다른 잠금 구현은 같은 상황에 플랫폼 예외를 던진다. 이 기록이 다루는 형태가 보호 장치 쪽에서 한 번 더 나온다.
## 시험은 이 자리를 밟지 않는다
원장 기록을 부르는 시험은 다섯 줄이고, 밀려난 펜스를 넣는 것은 없다. 이름이 원장 쓰기가 펜스를 실어 나른다고 말하는 시험은 미완료를 돌려주는 마이그레이션을 쓰므로 원장 기록 분기에 들어가지 않는다. 실서버 레인의 시험 하나가 같은 펜스로 두 번 부르는데, 그 단언에 붙은 문장이 이 기록의 결론을 그대로 적는다.
```java
.as("the unique index, not the read-then-write check, is what makes this impossible")
.isInstanceOf(RuntimeException.class);
```
그 고유 색인을 만드는 메서드를 부르는 파일은 시험 하나와 선언 파일 자신뿐이다.
## 확인하지 못한 것
원장이 밀려난 값으로 남은 뒤 후속 마이그레이션 판정이 어떻게 되는지 추적하지 않았다. 갱신과 삽입 사이의 창을 실제로 벌려 보지도 않았다. 확인한 것은 그 창을 지나 원장 기록에 도달했을 때 무엇이 일어나는지다.
<!-- body:end -->
@@ -0,0 +1,110 @@
---
kind: CASE
slug: assigned-id-turns-claim-into-upsert
title: 배정 식별자 때문에 insert-first 청구가 UPSERT 가 되어 커밋된 결과를 덮었다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:assigned-id-turns-claim-into-upsert
evidenceCapturedOn: 2026-09-01
body: case-assigned-id-turns-claim-into-upsert.body.md
assets:
- key: assigned-id-turns-claim-into-upsert
file: ../../../final/evidence/rendered/assigned-id-turns-claim-into-upsert.svg
evidence:
- ../../../final/evidence/raw/assigned-id-turns-claim-into-upsert.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-operation-ledger-jpa.md#L133 이다.
---
# 배정 식별자 때문에 insert-first 청구가 UPSERT 가 되어 커밋된 결과를 덮었다
어댑터가 먼저 넣고 충돌에서 읽는 순서를 자기 존재 이유로 적는다. 엔티티의 식별자가 배정값이라 저장 메서드가 병합으로 가고, 파생 기본 키가 유니크 제약과 같은 행을 가리켜 두 번째 청구가 위반 없이 기존 행을 덮는다.
## 관계
- **Atomic 타입의 존재는 원자성의 증거가 아니다**
같은 계열의 규칙이다.
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
테스트 이중과 실제 저장소가 갈리는 지점을 찾는 방법이다.
- **커밋 증거 프레임을 두 주인이 pop해서 바깥 트랜잭션의 실패가 익명이 됐다**
같은 리프 계열의 소유권 사례다.
## 문제
어댑터의 자바독이 순서를 계약으로 적는다.
먼저 넣고 충돌에서 읽는다는 것이다. 읽고 나서 넣는 구현은 읽기와 넣기 사이에 창이 있고, 그 창의 너비가 정확히 그것이 닫으려는 경합의 너비이며, 두 시도를 동시에 돌리지 않는 모든 테스트를 통과한다는 것이다.
전제는 저장 호출이 삽입이고, 같은 신원의 두 번째 청구가 유니크 제약을 건드린다는 것이다.
## 결론
두 전제가 모두 성립하지 않는다.
엔티티의 식별자는 호출자가 배정한다. 청구 팩토리가 신원에서 파생한 저장 키를 필드에 채우므로 식별자가 널이 아니다.
Spring Data 의 저장 구현은 식별자가 널이 아닌 엔티티를 새 것으로 보지 않는다. 병합으로 보낸다.
그리고 저장 키는 유니크 제약의 세 컬럼을 이어 붙인 파생값이다. 호출자 지문과 정규 메서드 이름과 멱등 키 해시다.
그러므로 같은 신원의 두 번째 청구는 같은 기본 키 행을 겨냥한다.
실제로 일어나는 일은 이렇다.
병합이 그 행을 찾는다. 유니크 제약은 발화하지 않는다. 새 행을 넣는 것이 아니라 같은 행을 갱신하기 때문이다.
분리 상태의 새 엔티티가 기존 행 위에 복사된다. 상태가 진행 중으로, 결과 참조가 널로, 완료 시각이 널로, 청구 시각이 지금으로 바뀐다.
예외가 없으므로 청구는 빈 값을 돌려준다. 호출자는 자기가 청구를 소유했다고 읽는다.
즉 이미 커밋된 연산의 결과 참조가 지워지고, 재시도가 그 변경을 다시 실행한다. 이 모듈이 존재하는 이유로 든 결과 그대로다.
데이터베이스의 검사 제약도 막지 못한다. 갱신 후 상태는 진행 중이고 완료 시각이 널이라 셋 다 합법이다.
테스트가 이것을 볼 수 없는 이유는 이중에 있다. 인메모리 저장소의 저장 메서드는 키가 이미 있으면 예외를 던진다. 삽입과 유니크 제약을 모사한다.
즉 이중은 삽입을 하고 실제 저장소는 갱신을 한다. 빌드 파일 주석이 실제 데이터스토어를 쓰지 않는 근거로 벤더 의미론이 시험 대상일 때만 정당하다고 적었는데, 여기서 갈린 것이 정확히 벤더 의미론이다.
## 검증 환경
OpenJDK : 21.0.12
Spring Data JPA : 4.0.7
확인 방식 : 엔티티 식별자 형태와 저장 계약 대조, 파생 키 구성 확인
소스 수정 : x
## 재현 조건
원문은 document-detail 의 analysis/grpc/grpc-operation-ledger-jpa.md 에 있다.
1. 어댑터의 청구 메서드와 그 자바독을 읽는다.
2. 엔티티의 식별자가 어디서 채워지는지 확인한다.
3. 저장 구현이 식별자 널 여부로 무엇을 고르는지 확인한다.
4. 저장 키의 파생식과 유니크 제약의 컬럼을 대조한다.
5. 테스트 이중의 저장 메서드가 무엇을 하는지 읽는다.
## 본문
<!-- body:start -->
`claim` 은 insert-first, read-on-conflict 를 주장한다. 그러나 엔티티의 `@Id` 가 배정값(`caller|method|keyHash`)이라 `SimpleJpaRepository.save``persist` 가 아니라 `merge` 로 간다.
## claim 이 주장하는 순서
:::evidence key="assigned-id-turns-claim-into-upsert" alt="분석 문서 analysis/grpc/grpc-operation-ledger-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-operation-ledger-jpa.md 발췌 — 15줄" zoom="true"
:::
## 위반 대신 갱신이 일어난다
그 파생 키가 유니크 제약의 세 컬럼과 같은 행을 가리키므로 두 번째 청구는 위반을 일으키지 않고 기존 행을 갱신한다 — 상태가 `IN_PROGRESS` 로, `outcome_reference` 가 널로 되돌아가고 `claim` 은 빈 값을 돌려줘 호출자가 소유를 얻었다고 읽는다.
## 테스트 이중이 그 차이를 가린다
인메모리 테스트 이중의 `save` 는 키가 있으면 던지므로 INSERT 를 흉내 낸다.
## 확인하지 못한 것
실제 데이터베이스로 같은 신원을 두 번 청구해 재현하지 않았다. 이 리프는 어떤 배포에도 조립되지 않아 실행 경로가 없다.
<!-- body:end -->
@@ -0,0 +1,107 @@
---
kind: CASE
slug: complete-drain-rolls-back-a-rotation
title: 배수 완료가 자기가 읽은 값으로 상태를 다시 써서 진행 중인 자격증명 회전을 되돌린다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:complete-drain-rolls-back-a-rotation
evidenceCapturedOn: 2026-09-01
body: case-complete-drain-rolls-back-a-rotation.body.md
assets:
- key: complete-drain-rolls-back-a-rotation
file: ../../../final/evidence/rendered/complete-drain-rolls-back-a-rotation.svg
evidence:
- ../../../final/evidence/raw/complete-drain-rolls-back-a-rotation.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-policy.md#L158 이다.
---
# 배수 완료가 자기가 읽은 값으로 상태를 다시 써서 진행 중인 자격증명 회전을 되돌린다
회전 관리자가 원자 참조를 들고 있으면서 두 메서드 모두 읽고 나서 조건 없이 쓴다. 배수 완료가 자기가 읽은 현재 세대로 새 상태를 만들기 때문에, 그 사이에 일어난 회전이 지워지고 이전 세대가 다시 현재가 된다.
## 관계
- **Atomic 타입의 존재는 원자성의 증거가 아니다**
이 사례가 그 규칙의 형태다.
- **배정 식별자 때문에 insert-first 청구가 UPSERT가 되어 커밋된 결과를 덮었다**
같은 통독에서 나온 같은 계열의 사례다.
- **재시도 안전은 증거로 결정된다**
같은 가족이 원자성을 제대로 다룬 정본이 그 옆에 있다.
## 문제
자격증명 회전 관리자의 자바독이 존재 이유를 적는다.
자재를 제자리에서 바꾸는 것이 이 클래스가 피하려는 실패를 만든다는 것이다. 교체 시점에 진행 중이던 모든 호출이 인증 오류로 실패하고, 그 오류는 클라이언트에서 보면 애초에 유효하지 않았던 자격증명과 똑같아 보인다는 것이다.
그래서 준비하고 교체하고 배수하는 순서를 지킨다.
상태는 원자 참조 하나에 담긴다. 현재 세대와 배수 중 세대와 배수 마감을 묶은 값이다.
## 결론
두 메서드 모두 원자적이지 않다.
회전은 현재 상태를 읽고, 승계 여부를 판정하고, 새 상태를 조건 없이 쓴다.
배수 완료는 현재 상태를 읽고, 자기가 읽은 현재 세대로 새 상태를 만들어 조건 없이 쓴다.
두 결과가 다르다.
회전 경합에서는 두 회전이 같은 값을 읽고 둘 다 승계 검사를 통과할 수 있다. 나중 쓰기가 앞의 것을 덮고, 덮인 회전이 배수 대상으로 기록해 둔 세대가 상태에서 사라진다. 그 세대 위의 호출은 아무도 배수하지 않는다.
자바독이 이 상황을 이미 안다. 승계 검사의 존재 이유로 회전이 뒤로 가는 흔한 원인이 두 회전자의 경합이라고 적는다. 검사는 있고 원자성이 없다.
배수 완료의 되돌림이 더 무겁다.
읽기와 쓰기 사이에 회전이 일어나면, 그 회전이 활성화한 세대가 지워지고 이전 세대가 다시 현재가 된다.
방금 교체된 자격 자재가 되살아난다. 이 클래스의 존재 이유가 그 교체다.
같은 가족 안에 정본이 있다. 재시도 예산과 헤징 예산이 정확한 비교 후 교체 루프를 쓰고, 수요 제어기는 같은 형태를 동기화로 닫는다.
그리고 같은 형태가 옆 리프에도 있다. 채널 런타임 레지스트리의 회전이 같은 파일의 설치가 비교 후 교체를 쓰는데도 조건 없이 덮는다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 두 메서드의 읽기와 쓰기 사이 원자성 분석, 같은 가족의 정본 대조
소스 수정 : x
## 재현 조건
원문은 document-detail 의 analysis/grpc/grpc-policy.md 와 analysis/grpc/grpc-client.md 에 있다.
1. 회전 관리자의 상태 필드 타입을 확인한다.
2. 회전 메서드에서 읽기와 쓰기 사이에 조건이 있는지 본다.
3. 배수 완료 메서드가 새 상태를 무엇으로 만드는지 읽는다.
4. 자바독의 경합 서술을 읽는다.
5. 같은 가족의 예산 클래스들과 비교한다.
## 본문
<!-- body:start -->
`AtomicReference` 를 들고 있으면서 `rotate``completeDrain` 이 모두 `get()` 후 조건 없는 `set()` 을 한다.
## 두 메서드가 같은 참조를 다루는 방식
:::evidence key="complete-drain-rolls-back-a-rotation" alt="분석 문서 analysis/grpc/grpc-policy.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-policy.md 발췌 — 15줄" zoom="true"
:::
## 두 경합의 결과가 다르다
회전 경합에서는 덮인 세대가 배수 목록에 오르지 못한다. 배수 완료에서는 자기가 읽은 `observed.current()` 로 새 상태를 만들기 때문에 그 사이에 일어난 회전이 지워지고 이전 세대가 다시 현재가 된다.
## 클래스의 목적이 뒤집힌다
자격 자재를 떨어뜨리지 않고 교체하려고 만든 클래스가 교체 자체를 되돌린다. 같은 파일의 형제(`install`)와 같은 가족의 `GrpcRetryBudget` 이 비교 후 교체를 정확히 쓴다.
## 확인하지 못한 것
동시 회전과 동시 배수 완료를 실행으로 재현하지 않았다. 두 리프 모두 배선 경로가 없다.
<!-- body:end -->
@@ -0,0 +1,78 @@
---
kind: CASE
slug: grpc-admin-f03
title: 배수 조정자가 가변이고 동기화가 없다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-admin-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-admin-f03
file: ../../../final/evidence/rendered/grpc-admin-f03.svg
evidence:
- ../../../final/evidence/raw/grpc-admin-f03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-admin.md#L181 이다.
module: grpc-admin
priority: P3
---
# 배수 조정자가 가변이고 동기화가 없다
phasesRun(ArrayList), startedAt, completedUnaryCalls, signalledStreams 가 평범한 필드다. synchronized·volatile·동시 자료구조가 없다.
## 문제
phasesRun(ArrayList), startedAt, completedUnaryCalls, signalledStreams 가 평범한 필드다.
synchronized·volatile·동시 자료구조가 없다.
## 결론
같은 리프의 건강 레지스트리는 정반대다 — ConcurrentHashMap 둘과 volatile boolean draining.
즉 이 리프는 동시성을 인지하고 있고 한 클래스에만 적용했다.
조정자의 javadoc 이 대기를 호출자에게 맡긴다고 적으므로 단일 호출자 전제로 읽을 수 있다.
다만 그 전제가 자바독에 적혀 있지 않고, unaryDrainComplete 는 반복 호출을 전제한 형태라 종료 훅과 상태 조회가 다른 스레드에서 닿기 쉽다.
수정은 단일 스레드 전제를 자바독에 적거나, 형제 클래스와 같은 수준으로 맞추는 것이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 조정자 필드 선언과 동시성 장치(synchronized·volatile·동시 자료구조) 유무 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-admin.md#L181 에 있다.
## 본문
<!-- body:start -->
`phasesRun`(`ArrayList`), `startedAt`, `completedUnaryCalls`, `signalledStreams` 가 평범한 필드다. `synchronized`·`volatile`·동시 자료구조가 없다.
## 조정자의 필드 선언
:::evidence key="grpc-admin-f03" alt="분석 문서 analysis/grpc/grpc-admin.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-admin.md 발췌 — 15줄" zoom="true"
:::
## 같은 리프의 건강 레지스트리는 정반대다
`ConcurrentHashMap` 둘과 `volatile boolean draining` 을 쓴다. 즉 이 리프는 동시성을 인지하고 있고 한 클래스에만 적용했다.
## 단일 호출자 전제로 읽을 수는 있다
조정자의 javadoc 이 대기를 호출자에게 맡긴다고 적는다. 다만 그 전제가 자바독에 적혀 있지 않고, `unaryDrainComplete` 는 반복 호출을 전제한 형태라 종료 훅과 상태 조회가 다른 스레드에서 닿기 쉽다. 수정은 단일 스레드 전제를 자바독에 적거나, 형제 클래스와 같은 수준으로 맞추는 것이다.
## 확인하지 못한 것
여러 스레드에서 조정자를 동시에 호출해 경합을 재현하지 않았다. 배선 경로가 없어 실제 서버로 배수를 돌릴 수 없다.
<!-- body:end -->
@@ -0,0 +1,84 @@
---
kind: CASE
slug: grpc-advanced-bootstrap-f03
title: 깃발 홀더가 가변이고 동기화가 없다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-advanced-bootstrap-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-bootstrap-f03
file: ../../../final/evidence/rendered/grpc-advanced-bootstrap-f03.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-bootstrap-f03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-bootstrap.md#L209 이다.
module: grpc-advanced-bootstrap
priority: P3
---
# 깃발 홀더가 가변이고 동기화가 없다
GrpcAdvancedFeatureFlags 는 두 EnumMap 을 enable·withGrade 로 갱신하고, available·active 가 같은 맵을 읽는다. synchronized·volatile·동시 자료구조가 없다.
## 문제
GrpcAdvancedFeatureFlags 는 두 EnumMap 을 enable·withGrade 로 갱신하고, available·active 가 같은 맵을 읽는다.
synchronized·volatile·동시 자료구조가 없다.
## 결론
시작 시 전부 설정하고 그 뒤로 읽기만 한다면 안전 공개 문제만 남는다.
다만 두 메서드가 this 를 돌려주는 유창한 형태라 런타임 중 갱신을 권하는 모양이고, active() 는 순회 중 갱신에 노출된다.
같은 저장소가 이 형태를 다른 리프에서 결함으로 기록했다(GrpcCompletionReconciler 의 동기화 없는 ArrayList).
여기서는 등급과 깃발이 요청 경로에서 읽히므로 같은 노출이 생길 수 있다.
수정은 홀더를 불변으로 만들고 enable·withGrade 가 새 인스턴스를 돌려주게 하는 것이다.
이 저장소가 다른 곳에서 쓰는 형태다(GrpcProtoStyleManifest.allowingWellKnownTypes 등).
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcAdvancedFeatureFlags 참조 7건 검색과 두 EnumMap 의 갱신·읽기 지점 동기화 마커 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-advanced-bootstrap.md#L209 에 있다.
## 본문
<!-- body:start -->
`GrpcAdvancedFeatureFlags` 는 두 `EnumMap``enable`·`withGrade` 로 갱신하고, `available`·`active` 가 같은 맵을 읽는다. `synchronized`·`volatile`·동시 자료구조가 없다.
## GrpcAdvancedFeatureFlags 참조 위치
:::evidence key="grpc-advanced-bootstrap-f03" alt="코드베이스에서 GrpcAdvancedFeatureFlags 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcAdvancedFeatureFlags 코드베이스 검색 — 7줄 · exit 0" zoom="true"
:::
## 시작 시 전부 설정한다면 안전 공개 문제만 남는다
다만 두 메서드가 `this` 를 돌려주는 유창한 형태라 런타임 중 갱신을 권하는 모양이고, `active()` 는 순회 중 갱신에 노출된다.
## 같은 저장소가 이 형태를 다른 리프에서 결함으로 기록했다
`GrpcCompletionReconciler` 의 동기화 없는 `ArrayList` 다. 여기서는 등급과 깃발이 요청 경로에서 읽히므로 같은 노출이 생길 수 있다.
## 수정
홀더를 불변으로 만들고 `enable`·`withGrade` 가 새 인스턴스를 돌려주게 하는 것이다. 이 저장소가 다른 곳에서 쓰는 형태다(`GrpcProtoStyleManifest.allowingWellKnownTypes` 등).
## 확인하지 못한 것
동시 갱신과 읽기를 실행으로 재현하지 않았다. 동기화 장치가 없다는 코드 형태로 판정했다.
<!-- body:end -->
@@ -0,0 +1,101 @@
---
kind: CASE
slug: grpc-advanced-resilience-f03
title: 리졸버의 개정 가드가 비교 후 교체가 아니다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-advanced-resilience-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-resilience-f03
file: ../../../final/evidence/rendered/grpc-advanced-resilience-f03.svg
- key: grpc-advanced-resilience-f03-diagram
file: ../../../final/assets/diagrams/grpc-advanced-resilience-f03.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-resilience-f03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-resilience.md#L176 이다.
module: grpc-advanced-resilience
priority: P2
---
# 리졸버의 개정 가드가 비교 후 교체가 아니다
GrpcCustomResolver 의 javadoc 이 지키겠다고 하는 것은 명확하다. 빈 집합은 GrpcEndpointSnapshot 의 생성자가 지키므로 성립한다.
## 문제
GrpcCustomResolver 의 javadoc 이 지키겠다고 하는 것은 명확하다.
빈 집합은 GrpcEndpointSnapshot 의 생성자가 지키므로 성립한다.
## 결론
개정 가드는 그렇지 않다.
AtomicReference 를 쓰면서 읽기와 쓰기 사이에 원자성이 없다.
개정 5 와 6 을 든 두 스레드가 같은 applied(개정 4)를 읽으면 둘 다 supersedes 를 통과하고, 나중에 set 하는 쪽이 이긴다.
6 이 먼저 쓰이고 5 가 덮으면 채널이 옛 엔드포인트로 되돌아간다 — 개정 번호가 존재하는 이유가 정확히 그것을 막는 것이다.
listener.accept(update) 도 set 밖에 있으므로, applied 의 최종 값이 옳더라도 리스너(=채널)가 받는 순서는 뒤집힐 수 있다.
채널은 마지막으로 받은 것을 믿는다.
같은 형태가 이 가족에 셋이다.
정본이 같은 리프 안에 있다는 점이 §12.2 의 대조와 같다 — 이 리프는 예산에서는 CAS 를 쓰고 리졸버에서는 쓰지 않는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcCustomResolver 참조 5건 검색과 개정 가드의 읽기·쓰기 순서 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-advanced-resilience.md#L176 에 있다.
## 본문
<!-- body:start -->
`GrpcCustomResolver` 의 javadoc 이 지키겠다고 하는 것은 명확하다. 빈 집합은 `GrpcEndpointSnapshot` 의 생성자가 지키므로 성립한다. 개정 가드는 그렇지 않다.
## 가드가 지키지 못하는 구간
:::evidence key="grpc-advanced-resilience-f03-diagram" alt="두 스레드의 개정이 같은 applied 읽기와 둘 다 통과를 지나 옛 엔드포인트 회귀로 이어진다" caption="가드가 지키지 못하는 구간" zoom="false"
:::
`AtomicReference` 를 쓰면서 읽기와 쓰기 사이에 원자성이 없다. 개정 5 와 6 을 든 두 스레드가 같은 `applied`(개정 4)를 읽으면 둘 다 `supersedes` 를 통과하고, 나중에 `set` 하는 쪽이 이긴다. 6 이 먼저 쓰이고 5 가 덮으면 **채널이 옛 엔드포인트로 되돌아간다** — 개정 번호가 존재하는 이유가 정확히 그것을 막는 것이다.
## GrpcCustomResolver 참조 위치
:::evidence key="grpc-advanced-resilience-f03" alt="코드베이스에서 GrpcCustomResolver 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcCustomResolver 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
## 통지 순서도 뒤집힐 수 있다
`listener.accept(update)``set` 밖에 있으므로, `applied` 의 최종 값이 옳더라도 리스너(=채널)가 받는 순서는 뒤집힐 수 있다. 채널은 마지막으로 받은 것을 믿는다.
## 정본이 같은 리프 안에 있다
이 리프는 예산에서는 CAS 를 쓰고 리졸버에서는 쓰지 않는다. 같은 형태가 이 가족에 셋이다.
## 테스트는 순차 경로만 본다
`a stale revision is dropped rather than applied` 는 단일 스레드에서 개정 2 를 적용한 뒤 개정 1 을 제시한다. 순차적으로는 가드가 정확히 작동한다.
## 수정
`applied.updateAndGet` 안에서 판정과 교체를 함께 하거나, `compareAndSet(observed, snapshot)` 이 실패하면 다시 읽어 판정한다. 리스너 통지는 성공한 CAS 뒤에 그 CAS 가 이긴 순서로 해야 한다 — 예산 쪽의 `tryConsume` 루프가 같은 리프 안의 본보기다. 이 리프가 배선되지 않으므로 P2.
## 확인하지 못한 것
두 스레드로 개정을 겹쳐 옛 엔드포인트 회귀를 재현하지 않았다. 읽기와 쓰기가 원자적이지 않다는 코드 형태로 판정했다.
<!-- body:end -->
@@ -0,0 +1,92 @@
---
kind: CASE
slug: grpc-advanced-streaming-f03
title: 체크포인트 전진이 ConcurrentMap 위의 확인 후 쓰기다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-advanced-streaming-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-streaming-f03
file: ../../../final/evidence/rendered/grpc-advanced-streaming-f03.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-streaming-f03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-streaming.md#L180 이다.
module: grpc-advanced-streaming
priority: P3
---
# 체크포인트 전진이 ConcurrentMap 위의 확인 후 쓰기다
GrpcClientStreamCheckpoint.advancedTo 가 뒤로 가는 것을 거부하고, 그 메시지가 원인을 정확히 짚는다 — "two writers are checkpointing one session". 그 가드가 보는 것은 호출한 스레드가 읽은 값 이다.
## 문제
GrpcClientStreamCheckpoint.advancedTo 가 뒤로 가는 것을 거부하고, 그 메시지가 원인을 정확히 짚는다 — "two writers are checkpointing one session".
그 가드가 보는 것은 호출한 스레드가 읽은 값 이다.
## 결론
두 스레드가 순번 5 와 6 을 적용하며 같은 체크포인트(4)를 읽으면 둘 다 advancedTo 를 통과한다.
5 를 든 쪽이 나중에 put 하면 체크포인트는 6 에서 5 로 뒤로 간다 — advancedTo 가 막겠다고 한 바로 그 상태이고, 이번에는 예외 없이 조용히 일어난다.
그러면 순번 6 의 메시지가 다시 APPLY 로 판정되어 두 번 적용된다.
이 클래스가 존재하는 이유가 정확히 그것을 막는 것이다.
ConcurrentHashMap 에는 이 형태를 위한 연산이 있다.
compute 안에서는 읽기와 쓰기가 원자적이므로, 뒤처진 쪽이 advancedTo 의 예외를 실제로 받는다 — 가드가 설계대로 발화한다.
같은 리프의 GrpcDemandController 는 모든 공개 메서드가 synchronized 이고, GrpcBidiSequenceTracker 도 그렇다(§12.2 가 그것을 이 가족의 모범으로 든다).
중복 제거기만 ConcurrentMap 의 원자 연산을 쓰지 않는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : ConcurrentMap 참조 23건 검색과 체크포인트 전진의 확인·쓰기 분리 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-advanced-streaming.md#L180 에 있다.
## 본문
<!-- body:start -->
`GrpcClientStreamCheckpoint.advancedTo` 가 뒤로 가는 것을 거부하고, 그 메시지가 원인을 정확히 짚는다 — "two writers are checkpointing one session". 그 가드가 보는 것은 **호출한 스레드가 읽은 값** 이다.
## ConcurrentMap 참조 위치
:::evidence key="grpc-advanced-streaming-f03" alt="코드베이스에서 ConcurrentMap 를 검색한 출력 23줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ConcurrentMap 코드베이스 검색 — 23줄 · exit 0" zoom="true"
:::
## 체크포인트가 조용히 뒤로 간다
두 스레드가 순번 5 와 6 을 적용하며 같은 체크포인트(4)를 읽으면 둘 다 `advancedTo` 를 통과한다. 5 를 든 쪽이 나중에 `put` 하면 체크포인트는 6 에서 5 로 뒤로 간다 — `advancedTo` 가 막겠다고 한 바로 그 상태이고, 이번에는 예외 없이 조용히 일어난다. 그러면 순번 6 의 메시지가 다시 `APPLY` 로 판정되어 두 번 적용된다.
## ConcurrentHashMap 에는 이 형태를 위한 연산이 있다
`compute` 안에서는 읽기와 쓰기가 원자적이므로, 뒤처진 쪽이 `advancedTo` 의 예외를 실제로 받는다 — 가드가 설계대로 발화한다.
## 같은 리프의 다른 클래스들은 닫혀 있다
`GrpcDemandController` 는 모든 공개 메서드가 `synchronized` 이고, `GrpcBidiSequenceTracker` 도 그렇다(§12.2 가 그것을 이 가족의 모범으로 든다). 중복 제거기만 `ConcurrentMap` 의 원자 연산을 쓰지 않는다.
## 시험 아홉 개가 전부 단일 스레드다
순차적으로는 `advancedTo` 가 정확히 작동하고, 전용 시험(`aCheckpointRecordsWhatWasApplied`)이 확인하는 것은 record 의 메서드이지 맵에 쓰는 경로가 아니다. 미배선이므로 P3. 다만 이 클래스의 javadoc 이 "The application effect and this checkpoint belong in one transaction" 이라고 적어 둔 것과 함께 보면, 이 자리는 배선되는 날 트랜잭션 경계와 함께 다시 설계될 곳이다.
## 확인하지 못한 것
두 writer 가 한 세션을 체크포인트하는 경합을 재현하지 않았다. 가드가 보는 값이 호출 스레드가 읽은 값이라는 코드 형태로 판정했다.
<!-- body:end -->
@@ -0,0 +1,89 @@
---
kind: CASE
slug: grpc-client-f01
title: rotate 가 비교 후 교체가 아니라 덮어쓰기다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-client-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-client-f01
file: ../../../final/evidence/rendered/grpc-client-f01.svg
- key: grpc-client-f01-diagram
file: ../../../final/assets/diagrams/grpc-client-f01.svg
evidence:
- ../../../final/evidence/raw/grpc-client-f01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-client.md#L120 이다.
module: grpc-client
priority: P2
---
# rotate 가 비교 후 교체가 아니라 덮어쓰기다
install 은 정확하다. rotate 는 그렇지 않다.
## 문제
install 은 정확하다.
rotate 는 그렇지 않다.
## 결론
두 회전이 동시에 들어오면 둘 다 같은 previous 를 읽고, 둘 다 대체본을 만들고, 나중 set 이 앞의 대체본을 덮는다.
덮인 대체본은 어디에도 등록되지 않는다 — draining 목록에 들어가는 것은 previous 뿐이다.
그러므로 그 세대는 배수도 회수도 되지 않고, 그 위에서 시작된 호출은 아무도 세지 않는다.
클래스가 이 문제를 인지하고 있다는 증거가 같은 파일에 있다 — install 의 비교 후 교체와 AtomicReference 선택이다.
회전 쪽만 그 규율에서 벗어나 있다.
수정은 holder.compareAndSet(previous, replacement) 로 바꾸고 실패 시 다시 읽어 판정하거나 던지는 것이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : install 과 rotate 두 경로의 읽기·쓰기 원자성 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-client.md#L120 에 있다.
## 본문
<!-- body:start -->
`install` 은 정확하다. `rotate` 는 그렇지 않다.
## 경합이 끼는 자리
:::evidence key="grpc-client-f01-diagram" alt="동시 회전 둘이 같은 previous 읽기와 나중 set 이 덮음을 지나 덮인 세대 미등록으로 이어진다" caption="경합이 끼는 자리" zoom="false"
:::
두 회전이 동시에 들어오면 둘 다 같은 `previous` 를 읽고, 둘 다 대체본을 만들고, 나중 `set` 이 앞의 대체본을 덮는다.
## install 과 rotate 의 차이
:::evidence key="grpc-client-f01" alt="분석 문서 analysis/grpc/grpc-client.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-client.md 발췌 — 15줄" zoom="true"
:::
## 덮인 대체본은 어디에도 등록되지 않는다
`draining` 목록에 들어가는 것은 `previous` 뿐이다. 그러므로 그 세대는 배수도 회수도 되지 않고, 그 위에서 시작된 호출은 아무도 세지 않는다.
## 클래스가 이 문제를 인지한다는 증거
같은 파일의 `install` 이 비교 후 교체를 쓰고 `AtomicReference` 를 골랐다. 회전 쪽만 그 규율에서 벗어나 있다. 수정은 `holder.compareAndSet(previous, replacement)` 로 바꾸고 실패 시 다시 읽어 판정하거나 던지는 것이다.
## 확인하지 못한 것
실제 채널을 만들어 회전시키지 않았다. ManagedChannel 을 만드는 코드가 이 리프에 없다.
<!-- body:end -->
@@ -0,0 +1,87 @@
---
kind: CASE
slug: grpc-client-f02
title: 비원자적 감소가 세대를 영구히 회수 불가로 만든다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-client-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-client-f02
file: ../../../final/evidence/rendered/grpc-client-f02.svg
- key: grpc-client-f02-diagram
file: ../../../final/assets/diagrams/grpc-client-f02.svg
evidence:
- ../../../final/evidence/raw/grpc-client-f02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-client.md#L149 이다.
module: grpc-client
priority: P2
---
# 비원자적 감소가 세대를 영구히 회수 불가로 만든다
카운터가 1 일 때 두 스레드가 동시에 끝나면 둘 다 조건을 통과해 둘 다 감소시켜 −1 이 된다. 그 결과가 이 리프에서는 구체적이다.
## 문제
카운터가 1 일 때 두 스레드가 동시에 끝나면 둘 다 조건을 통과해 둘 다 감소시켜 −1 이 된다.
그 결과가 이 리프에서는 구체적이다.
## 결론
정확히 0 을 요구한다.
음수가 되면 조용해짐 판정이 영원히 거짓이고, retireQuiescent 가 그 세대를 결코 제거하지 않는다.
회전이 반복될수록 draining 목록이 자란다.
같은 형태가 이 가족의 다른 두 곳에도 있다(GrpcAdmissionController.release, GrpcStreamAdmission.release).
그쪽은 경계가 느슨해지는 결과였고, 이쪽은 자원이 회수되지 않는 결과다.
수정은 updateAndGet(v -> Math.max(0, v - 1)) 이나 decrementAndGet() 후 하한 보정이다.
같은 가족의 GrpcRetryBudget 이 정확한 비교 후 교체 루프를 이미 쓴다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcAdmissionController 참조 16건 검색과 감소 연산의 원자성 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-client.md#L149 에 있다.
## 본문
<!-- body:start -->
카운터가 1 일 때 두 스레드가 동시에 끝나면 둘 다 조건을 통과해 둘 다 감소시켜 −1 이 된다.
## 회수가 막히는 자리
:::evidence key="grpc-client-f02-diagram" alt="비원자적 감소에서 계수기 음수로 둘 다 통과가 건너가고 계수기 음수에서 회수 불가로 조용함 거짓이 건너간다" caption="회수가 막히는 자리" zoom="false"
:::
조용해짐 판정이 정확히 0 을 요구하므로, 음수가 되면 그 판정이 영원히 거짓이고 `retireQuiescent` 가 그 세대를 결코 제거하지 않는다. 회전이 반복될수록 `draining` 목록이 자란다.
## GrpcAdmissionController 참조 위치
:::evidence key="grpc-client-f02" alt="코드베이스에서 GrpcAdmissionController 를 검색한 출력 16줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcAdmissionController 코드베이스 검색 — 16줄 · exit 0" zoom="true"
:::
## 같은 형태가 이 가족의 다른 두 곳에도 있다
`GrpcAdmissionController.release`, `GrpcStreamAdmission.release`. 그쪽은 경계가 느슨해지는 결과였고, 이쪽은 자원이 회수되지 않는 결과다. 수정은 `updateAndGet(v -> Math.max(0, v - 1))` 이나 `decrementAndGet()` 후 하한 보정이다 — 같은 가족의 `GrpcRetryBudget` 이 정확한 비교 후 교체 루프를 이미 쓴다.
## 확인하지 못한 것
동시 해제를 실행으로 재현해 계수기가 음수가 되는 것을 관측하지 않았다. 원자성 분석으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,80 @@
---
kind: CASE
slug: grpc-client-f03
title: 배수 목록의 순회가 동기화 밖에서 일어난다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-client-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-client-f03
file: ../../../final/evidence/rendered/grpc-client-f03.svg
evidence:
- ../../../final/evidence/raw/grpc-client-f03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-client.md#L176 이다.
module: grpc-client
priority: P3
---
# 배수 목록의 순회가 동기화 밖에서 일어난다
Collections.synchronizedList 는 개별 연산만 동기화한다. 순회는 호출자가 그 목록을 잠그고 해야 한다는 것이 그 API 의 계약이다.
## 문제
Collections.synchronizedList 는 개별 연산만 동기화한다.
순회는 호출자가 그 목록을 잠그고 해야 한다는 것이 그 API 의 계약이다.
## 결론
List.copyOf(...) 와 stream() 둘 다 순회다.
회전이 동시에 add 하면 동시 변경 예외가 가능하다.
그리고 읽고 지우는 두 단계가 원자적이지 않으므로, 그 사이에 조용해진 세대가 추가되면 이번 회수에서 빠진다.
후자는 다음 호출에서 회수되므로 무해하다.
수정은 CopyOnWriteArrayList 로 바꾸는 것이다.
배수 목록은 쓰기가 드물고 읽기가 잦아 그 자료구조의 전형적 용례다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : synchronizedList 의 순회 계약과 실제 순회 지점의 잠금 유무 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-client.md#L176 에 있다.
## 본문
<!-- body:start -->
`Collections.synchronizedList` 는 개별 연산만 동기화한다. 순회는 호출자가 그 목록을 잠그고 해야 한다는 것이 그 API 의 계약이다. `List.copyOf(...)``stream()` 둘 다 순회다.
## synchronizedList 의 계약
:::evidence key="grpc-client-f03" alt="분석 문서 analysis/grpc/grpc-client.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-client.md 발췌 — 15줄" zoom="true"
:::
## 두 결과가 다르다
회전이 동시에 `add` 하면 동시 변경 예외가 가능하다. 그리고 읽고 지우는 두 단계가 원자적이지 않으므로, 그 사이에 조용해진 세대가 추가되면 이번 회수에서 빠진다 — 후자는 다음 호출에서 회수되므로 무해하다.
## 수정
`CopyOnWriteArrayList` 로 바꾸는 것이다. 배수 목록은 쓰기가 드물고 읽기가 잦아 그 자료구조의 전형적 용례다.
## 확인하지 못한 것
순회 중 동시 변경으로 예외를 재현하지 않았다. 순회가 호출자 잠금을 요구한다는 API 계약과 코드 형태로 판정했다.
<!-- body:end -->
@@ -0,0 +1,82 @@
---
kind: CASE
slug: grpc-codegen-f02
title: 릴리스 버전 불변성이 프로세스 안에서만 성립한다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-codegen-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-codegen-f02
file: ../../../final/evidence/rendered/grpc-codegen-f02.svg
evidence:
- ../../../final/evidence/raw/grpc-codegen-f02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-codegen.md#L220 이다.
module: grpc-codegen
priority: P3
---
# 릴리스 버전 불변성이 프로세스 안에서만 성립한다
발행 이력이 발행자 인스턴스의 필드다. 새 프로세스는 아무것도 기억하지 못하므로 같은 버전을 다른 해시로 다시 발행하려는 시도가 통과한다.
## 문제
발행 이력이 발행자 인스턴스의 필드다.
새 프로세스는 아무것도 기억하지 못하므로 같은 버전을 다른 해시로 다시 발행하려는 시도가 통과한다.
## 결론
이 클래스가 존재하는 이유가 그 규칙이다 — "refuses to let a released version change underneath its consumers." 그 규칙이 지켜지는 범위가 한 발행자 인스턴스의 수명이다.
빌드마다 새 프로세스가 도는 것이 정상 형태이므로, 실제로 이 검사가 무언가를 막으려면 이력이 산출물 저장소나 파일에서 와야 한다.
GrpcSchemaBaseline 이 이미 릴리스된 해시를 들고 있으므로 그 방향의 재료는 있다.
덧붙여 이 맵은 동기화되지 않는다.
발행자를 공유해 병렬로 평가하면 경합한다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcSchemaBaseline 참조 11건 검색과 발행 이력의 보관 위치 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-codegen.md#L220 에 있다.
## 본문
<!-- body:start -->
발행 이력이 발행자 인스턴스의 필드다. 새 프로세스는 아무것도 기억하지 못하므로 같은 버전을 다른 해시로 다시 발행하려는 시도가 통과한다.
## GrpcSchemaBaseline 참조 위치
:::evidence key="grpc-codegen-f02" alt="코드베이스에서 GrpcSchemaBaseline 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcSchemaBaseline 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## 이 클래스가 존재하는 이유가 그 규칙이다
"refuses to let a released version change underneath its consumers." 그 규칙이 지켜지는 범위가 한 발행자 인스턴스의 수명이다.
## 빌드마다 새 프로세스가 도는 것이 정상이다
실제로 이 검사가 무언가를 막으려면 이력이 산출물 저장소나 파일에서 와야 한다. `GrpcSchemaBaseline` 이 이미 릴리스된 해시를 들고 있으므로 그 방향의 재료는 있다.
## 덧붙여 이 맵은 동기화되지 않는다
발행자를 공유해 병렬로 평가하면 경합한다.
## 확인하지 못한 것
새 프로세스에서 같은 버전을 다른 해시로 발행해 통과를 관측하지 않았다. 이력이 인스턴스 필드라는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,97 @@
---
kind: CASE
slug: grpc-discovery-f03
title: 목록으로 보고하는 검증기가 주소 수 0 에서 던진다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-discovery-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-discovery-f03
file: ../../../final/evidence/rendered/grpc-discovery-f03.svg
evidence:
- ../../../final/evidence/raw/grpc-discovery-f03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-discovery.md#L187 이다.
module: grpc-discovery
priority: P3
---
# 목록으로 보고하는 검증기가 주소 수 0 에서 던진다
profile.resolverProfile(n) 이 new GrpcResolverProfile(DNS, …, n) 을 만들고, 그 정규 생성자가 거부한다. 그래서 violations(profile, 0) 은 빈 목록도 위반 목록도 아닌 IllegalArgumentException 이다.
## 문제
profile.resolverProfile(n) 이 new GrpcResolverProfile(DNS, …, n) 을 만들고, 그 정규 생성자가 거부한다.
그래서 violations(profile, 0) 은 빈 목록도 위반 목록도 아닌 IllegalArgumentException 이다.
## 결론
같은 메서드가 profile == null 에는 명시적으로 던지고 나머지는 목록으로 답하므로, 호출자는 이 API 를 "던지지 않고 보고한다" 로 읽는다.
expectedAddressCount 는 이 리프가 계산하지 않고 입력으로 받는 값이고(§16), 그 출처는 헤드리스 레코드의 DNS 조회 결과다.
롤아웃 중 파드가 모두 교체되는 순간이나 셀렉터가 어긋난 서비스에서 그 답은 0 이다.
그것은 이 리프가 다루는 문제 영역 안의 상태이지 프로그래밍 오류가 아니다 — 그리고 운영자가 가장 보고받고 싶어 할 상태다.
GrpcResolverProfile 쪽 거부 자체는 옳다.
값 객체가 "주소 0 개인 목표"를 표현하지 않는 것은 §4 의 두 겹 분담과 일치한다.
어긋난 것은 그 위에 얹힌 검증기가 그 예외를 그대로 통과시킨다는 점이다.
violations 가 expectedAddressCount < 1 을 먼저 보고 위반 문자열로 보고한 뒤 나머지 검사를 건너뛴다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : resolverProfile(n) 이 부르는 정규 생성자의 거부 조건 경로 추적
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-discovery.md#L187 에 있다.
## 본문
<!-- body:start -->
검증기는 목록으로 보고하는 형태다.
```java
public static List<String> violations(GrpcKubernetesProfile profile, int expectedAddressCount) {
List<String> violations = new ArrayList<>(
GrpcDiscoveryPolicyValidator.violations(profile.resolverProfile(expectedAddressCount)));
```
`profile.resolverProfile(n)``new GrpcResolverProfile(DNS, …, n)` 을 만들고, 그 정규 생성자가 `expectedAddressCount < 1` 을 거부한다. 그래서 `violations(profile, 0)` 은 빈 목록도 위반 목록도 아닌 `IllegalArgumentException` 이다.
## 목록으로 보고하는 형태
:::evidence key="grpc-discovery-f03" alt="분석 문서 analysis/grpc/grpc-discovery.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-discovery.md 발췌 — 15줄" zoom="true"
:::
## 호출자는 이 API 를 던지지 않는 것으로 읽는다
같은 메서드가 `profile == null` 에는 명시적으로 던지고 나머지는 목록으로 답한다.
## 왜 0 이 실제 값인가
`expectedAddressCount` 는 이 리프가 계산하지 않고 입력으로 받는 값이고(§16), 그 출처는 헤드리스 레코드의 DNS 조회 결과다. 롤아웃 중 파드가 모두 교체되는 순간이나 셀렉터가 어긋난 서비스에서 그 답은 0 이다 — 이 리프가 다루는 문제 영역 안의 상태이지 프로그래밍 오류가 아니고, 운영자가 가장 보고받고 싶어 할 상태다.
## 값 객체 쪽 거부 자체는 옳다
값 객체가 "주소 0 개인 목표"를 표현하지 않는 것은 §4 의 두 겹 분담과 일치한다. 어긋난 것은 그 위에 얹힌 검증기가 그 예외를 그대로 통과시킨다는 점이다. `violations``expectedAddressCount < 1` 을 먼저 보고 위반 문자열로 보고한 뒤 나머지 검사를 건너뛰면 된다.
## 확인하지 못한 것
violations(profile, 0) 을 실행으로 재현하지 않았다. resolverProfile → GrpcResolverProfile 정규 생성자 경로로 판정했다.
<!-- body:end -->
@@ -0,0 +1,79 @@
---
kind: CASE
slug: grpc-operation-ledger-jpa-f02
title: markCommitted 는 던지고 markFailed 는 조용히 넘어간다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-operation-ledger-jpa-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-operation-ledger-jpa-f02
file: ../../../final/evidence/rendered/grpc-operation-ledger-jpa-f02.svg
evidence:
- ../../../final/evidence/raw/grpc-operation-ledger-jpa-f02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-operation-ledger-jpa.md#L192 이다.
module: grpc-operation-ledger-jpa
priority: P3
---
# markCommitted 는 던지고 markFailed 는 조용히 넘어간다
커밋 쪽의 근거는 자바독에 있다 — 청구 없이 커밋하면 변경은 내구적이고 보호받지 못한다. 실패 쪽에는 근거가 없다.
## 문제
커밋 쪽의 근거는 자바독에 있다 — 청구 없이 커밋하면 변경은 내구적이고 보호받지 못한다.
실패 쪽에는 근거가 없다.
## 결론
청구가 사라진 뒤 도착한 종결 실패가 아무 흔적도 남기지 않는다.
회수가 청구를 지운 뒤 원래 소유자가 실패를 기록하려는 경우가 그 형태다.
의도라면 그 이유를 자바독에 적어야 하고, 아니라면 커밋 쪽과 같게 다뤄야 한다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 두 종결 경로의 없는 청구 처리 분기와 각 자바독의 근거 유무 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-operation-ledger-jpa.md#L192 에 있다.
## 본문
<!-- body:start -->
두 종결 경로가 없는 청구를 다르게 다룬다.
```java
markCommitted findById(...).orElseThrow(IllegalStateException) // 청구 없으면 실패
markFailed findById(...).ifPresent(entity -> ) // 청구 없으면 무동작
```
## 두 종결 경로의 차이
:::evidence key="grpc-operation-ledger-jpa-f02" alt="분석 문서 analysis/grpc/grpc-operation-ledger-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-operation-ledger-jpa.md 발췌 — 15줄" zoom="true"
:::
## 커밋 쪽에는 근거가 있고 실패 쪽에는 없다
커밋 쪽의 근거는 자바독에 있다 — 청구 없이 커밋하면 변경은 내구적이고 보호받지 못한다.
## 흔적 없이 사라지는 경우
청구가 사라진 뒤 도착한 종결 실패가 아무 흔적도 남기지 않는다. 회수가 청구를 지운 뒤 원래 소유자가 실패를 기록하려는 경우가 그 형태다. 의도라면 그 이유를 자바독에 적어야 하고, 아니라면 커밋 쪽과 같게 다뤄야 한다.
## 확인하지 못한 것
청구 없는 markFailed 가 흔적 없이 사라지는 것을 실제 데이터베이스로 재현하지 않았다. 두 메서드의 분기 대조로 판정했다.
<!-- body:end -->
@@ -0,0 +1,77 @@
---
kind: CASE
slug: grpc-policy-f04
title: 완료 조정자가 요청 경로에서 동기화 없는 가변 리스트를 변경한다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-policy-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-policy-f04
file: ../../../final/evidence/rendered/grpc-policy-f04.svg
- key: grpc-policy-f04-diagram
file: ../../../final/assets/diagrams/grpc-policy-f04.svg
evidence:
- ../../../final/evidence/raw/grpc-policy-f04.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-policy.md#L271 이다.
module: grpc-policy
priority: P2
---
# 완료 조정자가 요청 경로에서 동기화 없는 가변 리스트를 변경한다
synchronized·Concurrent*·volatile·Lock 전부 0 이고 단일 스레드 전용 표기도 없다. 같은 리프의 GrpcSerializedStreamWriter 는 아홉 마커로 제대로 닫혀 있어, 이 리프가 동시성을 인지하고 있음을 보여 준다.
## 문제
synchronized·Concurrent*·volatile·Lock 전부 0 이고 단일 스레드 전용 표기도 없다.
같은 리프의 GrpcSerializedStreamWriter 는 아홉 마커로 제대로 닫혀 있어, 이 리프가 동시성을 인지하고 있음을 보여 준다.
## 결론
reconcile 은 완료 결과가 불확실한 호출마다 불린다 — 장애 상황에서 동시에 몰리는 경로다.
그리고 pending 이 담는 것은 사람이 조정해야 하는 연산 목록이므로, 유실은 조정되지 않은 채 잊히는 연산이 된다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcSerializedStreamWriter 참조 11건 검색과 조정자 쪽 동기화 마커 유무 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-policy.md#L271 에 있다.
## 본문
<!-- body:start -->
`synchronized`·`Concurrent*`·`volatile`·`Lock` 전부 0 이고 단일 스레드 전용 표기도 없다.
## 동기화가 걸린 곳과 아닌 곳
:::evidence key="grpc-policy-f04-diagram" alt="GrpcSerializedStreamWriter 만 동기화 마커 안에 놓이고 완료 조정자의 pending 이 바깥에 빗금으로 놓인다" caption="동기화가 걸린 곳과 아닌 곳" zoom="false"
:::
같은 리프의 `GrpcSerializedStreamWriter` 는 아홉 마커로 제대로 닫혀 있어, 이 리프가 동시성을 인지하고 있음을 보여 준다.
## GrpcSerializedStreamWriter 참조 위치
:::evidence key="grpc-policy-f04" alt="코드베이스에서 GrpcSerializedStreamWriter 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcSerializedStreamWriter 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## 하필 장애 상황에 몰리는 경로다
`reconcile` 은 완료 결과가 불확실한 호출마다 불린다. 그리고 `pending` 이 담는 것은 사람이 조정해야 하는 연산 목록이므로, 유실은 조정되지 않은 채 잊히는 연산이 된다.
## 확인하지 못한 것
요청 경로에서 동시 변경을 재현하지 않았다. 동기화 마커가 0 이고 단일 스레드 전용 표기도 없다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,91 @@
---
kind: CASE
slug: grpc-policy-f05
title: 스트림 수명 조정자의 배수 신호가 스레드를 건너면서 volatile 이 아니다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-policy-f05
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-policy-f05
file: ../../../final/evidence/rendered/grpc-policy-f05.svg
- key: grpc-policy-f05-diagram
file: ../../../final/assets/diagrams/grpc-policy-f05.svg
evidence:
- ../../../final/evidence/raw/grpc-policy-f05.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-policy.md#L285 이다.
module: grpc-policy
priority: P2
---
# 스트림 수명 조정자의 배수 신호가 스레드를 건너면서 volatile 이 아니다
두 메서드의 호출자가 다른 스레드다. signalDrain() 은 서버가 내려갈 때 종료 훅이 부르고, terminationDue(...) 는 스트림 자신의 틱에서 불린다 — 클래스 javadoc 이 검사 순서를 "then drain, because a server that has been told to stop should stop before its own timers fire" 로 규정한 그 틱이다.
## 문제
두 메서드의 호출자가 다른 스레드다.
signalDrain() 은 서버가 내려갈 때 종료 훅이 부르고, terminationDue(...) 는 스트림 자신의 틱에서 불린다 — 클래스 javadoc 이 검사 순서를 "then drain, because a server that has been told to stop should stop before its own timers fire" 로 규정한 그 틱이다.
## 결론
평범한 boolean 이고 volatile·synchronized·AtomicBoolean 어느 것도 없다.
자바 메모리 모델 아래서 틱 스레드가 이 쓰기를 관측할 보장이 없다.
관측하지 못하면 스트림은 배수 명령을 받고도 계속 돌고, 최대 수명(기본 1시간)이 차야 끝난다.
같은 저장소가 같은 뜻의 플래그를 두 번은 volatile 로 적었다(§12.3).
세 번째만 빠졌다.
수정은 volatile boolean 한 단어다.
heartbeat 의 lastActivity 는 같은 문제가 아니다 — 스트림 틱 스레드만 만진다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 두 메서드를 부르는 스레드의 소속 추적과 배수 신호 필드의 volatile 유무 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-policy.md#L285 에 있다.
## 본문
<!-- body:start -->
두 메서드의 호출자가 다른 스레드다. `signalDrain()` 은 서버가 내려갈 때 종료 훅이 부르고, `terminationDue(...)` 는 스트림 자신의 틱에서 불린다 — 클래스 javadoc 이 검사 순서를 "then drain, because a server that has been told to stop should stop before its own timers fire" 로 규정한 그 틱이다.
## 신호가 건너는 경계
:::evidence key="grpc-policy-f05-diagram" alt="종료 훅 스레드에서 배수 신호 boolean 으로 signalDrain 쓰기가 건너가고 배수 신호 boolean 에서 스트림 틱 스레드로 terminationDue 읽기가 건너간다" caption="신호가 건너는 경계" zoom="false"
:::
평범한 `boolean` 이고 `volatile`·`synchronized`·`AtomicBoolean` 어느 것도 없다.
## 두 메서드를 부르는 스레드
:::evidence key="grpc-policy-f05" alt="분석 문서 analysis/grpc/grpc-policy.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-policy.md 발췌 — 15줄" zoom="true"
:::
## 관측하지 못하면 최대 수명까지 돈다
자바 메모리 모델 아래서 틱 스레드가 이 쓰기를 관측할 보장이 없다. 관측하지 못하면 스트림은 배수 명령을 받고도 계속 돌고, 최대 수명(기본 1시간)이 차야 끝난다.
## 같은 뜻의 플래그를 두 번은 volatile 로 적었다
§12.3. 세 번째만 빠졌다. 수정은 `volatile boolean` 한 단어다. `heartbeat``lastActivity` 는 같은 문제가 아니다 — 스트림 틱 스레드만 만진다.
## 확인하지 못한 것
가시성 실패를 관측하지 않았다. 관측하려면 배수 스레드와 스트림 틱 스레드를 분리한 반복 시험이 필요하고, 이런 실패는 재현되지 않는 것이 정상이다.
<!-- body:end -->
@@ -0,0 +1,78 @@
---
kind: CASE
slug: grpc-testkit-f02
title: 릴리스 게이트의 입력이 전부 호출자가 손으로 만드는 값이다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-testkit-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-testkit-f02
file: ../../../final/evidence/rendered/grpc-testkit-f02.svg
evidence:
- ../../../final/evidence/raw/grpc-testkit-f02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-testkit.md#L172 이다.
module: grpc-testkit
priority: P3
---
# 릴리스 게이트의 입력이 전부 호출자가 손으로 만드는 값이다
세 입력 중 어느 것도 실제 레인 결과나 실제 산출물에서 오지 않는다. 게이트를 부르는 곳은 자기 테스트 하나뿐이고, 그 테스트가 세 값을 리터럴로 만든다.
## 문제
세 입력 중 어느 것도 실제 레인 결과나 실제 산출물에서 오지 않는다.
게이트를 부르는 곳은 자기 테스트 하나뿐이고, 그 테스트가 세 값을 리터럴로 만든다.
## 결론
이 형태 자체는 이 저장소의 다른 게이트와 다르다.
mongo 가족의 증거 검증기는 테스트 결과 XML 을 읽고 파일의 수정 시각까지 본다.
이쪽 게이트는 그런 산출물 판독기를 갖지 않는다.
지금은 무해하다 — 릴리스 절차가 이 게이트를 부르지 않기 때문이다.
기록하는 이유는 §17.1 을 고쳐 레인을 자동으로 돌리게 되면, 그 결과를 이 게이트에 넣어 주는 코드가 함께 필요하다는 점이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 게이트의 세 입력이 오는 지점 추적과 유일한 호출처인 테스트의 값 생성 방식 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-testkit.md#L172 에 있다.
## 본문
<!-- body:start -->
세 입력 중 어느 것도 실제 레인 결과나 실제 산출물에서 오지 않는다. 게이트를 부르는 곳은 자기 테스트 하나뿐이고, 그 테스트가 세 값을 리터럴로 만든다.
## 게이트의 세 입력이 오는 곳
:::evidence key="grpc-testkit-f02" alt="분석 문서 analysis/grpc/grpc-testkit.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-testkit.md 발췌 — 15줄" zoom="true"
:::
## 이 저장소의 다른 게이트와 형태가 다르다
mongo 가족의 증거 검증기는 테스트 결과 XML 을 읽고 파일의 수정 시각까지 본다. 이쪽 게이트는 그런 산출물 판독기를 갖지 않는다.
## 지금은 무해하다
릴리스 절차가 이 게이트를 부르지 않는다. 기록하는 이유는 §17.1 을 고쳐 레인을 자동으로 돌리게 되면, 그 결과를 이 게이트에 넣어 주는 코드가 함께 필요하다는 점이다.
## 확인하지 못한 것
게이트를 실제 레인 결과로 실행해 보지 않았다. 세 값이 테스트 안에서 리터럴로 만들어진다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,76 @@
---
kind: CASE
slug: messaging-admin-runtime-f06
title: 저널의 itemsCompleted 단조성이 인터페이스 계약에 없다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-admin-runtime-f06
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-runtime-f06
file: ../../../final/evidence/rendered/messaging-admin-runtime-f06.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-runtime-f06.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-runtime.md#L936 이다.
module: messaging-admin-runtime
priority: P3
---
# 저널의 itemsCompleted 단조성이 인터페이스 계약에 없다
AdminOperationJournal javadoc 은 구현 의무 셋을 명시하면서 이것을 빠뜨렸고, fail 의 @param 은 오히려 문자 그대로 저장하라고 읽힌다. 유일한 호출자는 낡은 값을 넘긴다.
## 문제
AdminOperationJournal javadoc 은 구현 의무 셋을 명시하면서 이것을 빠뜨렸고, fail 의 @param 은 오히려 문자 그대로 저장하라고 읽힌다.
유일한 호출자는 낡은 값을 넘긴다.
## 결론
두 구현이 각각 clamp 해서 무사한 상태다(EVD-308).
두 가지 중 하나가 필요하다.
인터페이스 javadoc 에 "itemsCompleted 는 단조 증가해야 하며 구현은 기존 값보다 작은 값을 무시한다" 를 명시하거나, 호출자가 실제 체크포인트 값을 넘기도록 고친다.
후자가 더 정직하다 — 지금 journal.fail(lease, lease.resumeFrom(), …) 은 "이번 시도가 아무것도 못 했다" 고 주장하는 것이고, 그것은 대개 사실이 아니다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : AdminOperationJournal 참조 30건 검색과 javadoc 의 구현 의무 목록·유일한 호출자가 넘기는 값 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-admin-runtime.md#L936 에 있다.
## 본문
<!-- body:start -->
`AdminOperationJournal` javadoc 은 구현 의무 셋을 명시하면서 `itemsCompleted` 단조성을 빠뜨렸고, `fail``@param` 은 오히려 문자 그대로 저장하라고 읽힌다.
## AdminOperationJournal 참조 위치
:::evidence key="messaging-admin-runtime-f06" alt="코드베이스에서 AdminOperationJournal 를 검색한 출력 30줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="AdminOperationJournal 코드베이스 검색 — 30줄 · exit 0" zoom="true"
:::
## 유일한 호출자가 낡은 값을 넘긴다
두 구현이 각각 clamp 해서 무사한 상태다(`EVD-308`).
## 두 가지 중 하나가 필요하다
인터페이스 javadoc 에 "`itemsCompleted` 는 단조 증가해야 하며 구현은 기존 값보다 작은 값을 무시한다" 를 명시하거나, 호출자가 실제 체크포인트 값을 넘기도록 고친다. 후자가 더 정직하다 — 지금 `journal.fail(lease, lease.resumeFrom(), …)` 은 "이번 시도가 아무것도 못 했다" 고 주장하는 것이고, 그것은 대개 사실이 아니다.
## 확인하지 못한 것
JdbcAdminOperationJournal 의 실제 동작을 확인하지 않았다. Postgres 컨테이너가 필요하고 미실행이다. SQL 문자열은 읽어 GREATEST 를 확인했다.
<!-- body:end -->
@@ -0,0 +1,74 @@
---
kind: CASE
slug: messaging-admin-runtime-f07
title: 리플레이가 리스를 받지만 재개하지 않는다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-admin-runtime-f07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-runtime-f07
file: ../../../final/evidence/rendered/messaging-admin-runtime-f07.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-runtime-f07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-runtime.md#L942 이다.
module: messaging-admin-runtime
priority: P3
---
# 리플레이가 리스를 받지만 재개하지 않는다
executeReplay 는 journal.begin(...) 으로 리스를 받고 lease.resumeFrom() 을 쓰지 않는다. ReplayService.replay(...) 시그니처에 재개 지점이 없고 체크포인트 콜백도 없다.
## 문제
executeReplay 는 journal.begin(...) 으로 리스를 받고 lease.resumeFrom() 을 쓰지 않는다.
ReplayService.replay(...) 시그니처에 재개 지점이 없고 체크포인트 콜백도 없다.
## 결론
클래스 javadoc 의 "a retry continues the same operation" 은 리드라이브에만 해당한다.
리플레이가 재개 불필요하다면(같은 구간을 다시 읽는 것이 멱등이므로) 그 근거를 적고, 저널 사용을 "중복 실행 방지" 로만 한정하는 것이 낫다.
재개가 필요하다면 리드라이브와 같은 형태로 맞춘다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : executeReplay 가 리스에서 읽는 값과 replay 시그니처의 재개 지점 유무 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-admin-runtime.md#L942 에 있다.
## 본문
<!-- body:start -->
`executeReplay``journal.begin(...)` 으로 리스를 받고 `lease.resumeFrom()` 을 쓰지 않는다. `ReplayService.replay(...)` 시그니처에 재개 지점이 없고 체크포인트 콜백도 없다.
## executeReplay 가 리스를 다루는 방식
:::evidence key="messaging-admin-runtime-f07" alt="분석 문서 analysis/messaging/messaging-admin-runtime.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-admin-runtime.md 발췌 — 15줄" zoom="true"
:::
## 클래스 javadoc 의 문장이 절반에만 해당한다
"a retry continues the same operation" 은 리드라이브에만 해당한다.
## 두 방향
리플레이가 재개 불필요하다면(같은 구간을 다시 읽는 것이 멱등이므로) 그 근거를 적고 저널 사용을 "중복 실행 방지" 로만 한정하는 것이 낫다. 재개가 필요하다면 리드라이브와 같은 형태로 맞춘다.
## 확인하지 못한 것
리플레이를 중간에 끊고 재개해 관측하지 않았다. 시그니처에 재개 지점과 체크포인트 콜백이 없다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,93 @@
---
kind: CASE
slug: messaging-rabbit-f02
title: 반환을 순번에 맞추는 조각이 production 에 없고, 시험이 그 자리를 스스로 메운다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-rabbit-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-rabbit-f02
file: ../../../final/evidence/rendered/messaging-rabbit-f02.svg
- key: messaging-rabbit-f02-diagram
file: ../../../final/assets/diagrams/messaging-rabbit-f02.svg
evidence:
- ../../../final/evidence/raw/messaging-rabbit-f02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-rabbit.md#L238 이다.
module: messaging-rabbit
priority: P2
---
# 반환을 순번에 맞추는 조각이 production 에 없고, 시험이 그 자리를 스스로 메운다
이 어댑터의 핵심 보장(§1)은 반환과 확인을 같은 발행 에 묶는 데 달려 있다. 묶는 열쇠는 순번이다.
## 문제
이 어댑터의 핵심 보장(§1)은 반환과 확인을 같은 발행 에 묶는 데 달려 있다.
묶는 열쇠는 순번이다.
## 결론
그런데 AMQP 의 basic.return 콜백은 순번을 주지 않는다.
교환기·라우팅 키·속성·본문만 온다.
그래서 발행자가 순번을 메시지에 실어 보내고 반환에서 되읽어야 한다.
RabbitHeaderMapper.toProperties 전문에 그런 헤더가 없다.
쓰는 것은 msg.* 예약 헤더들과 AMQP 의 messageId·correlationId·timestamp·deliveryMode 뿐이다.
그 조각이 존재하는 곳은 시험 하나다.
그 메서드의 javadoc 이 문제를 정확히 서술한다.
"the adapter has to" 인데 어댑터는 하지 않는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : RabbitHeaderMapper 참조 13건 검색과 toProperties 전문에서 순번 헤더 유무 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-rabbit.md#L238 에 있다.
## 본문
<!-- body:start -->
이 어댑터의 핵심 보장(§1)은 반환과 확인을 **같은 발행** 에 묶는 데 달려 있고, 묶는 열쇠는 순번이다. 그런데 AMQP 의 `basic.return` 콜백은 순번을 주지 않는다 — 교환기·라우팅 키·속성·본문만 온다.
## 프로덕션에 없는 조각
:::evidence key="messaging-rabbit-f02-diagram" alt="msg 예약 헤더와 AMQP 표준 속성만 헤더 매퍼가 쓰는 것 안에 놓이고 순번 헤더가 바깥에 빗금으로 놓인다" caption="프로덕션에 없는 조각" zoom="false"
:::
`RabbitHeaderMapper.toProperties` 전문에 그런 헤더가 없다. 쓰는 것은 `msg.*` 예약 헤더들과 AMQP 의 `messageId`·`correlationId`·`timestamp`·`deliveryMode` 뿐이다.
## RabbitHeaderMapper 참조 위치
:::evidence key="messaging-rabbit-f02" alt="코드베이스에서 RabbitHeaderMapper 를 검색한 출력 13줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RabbitHeaderMapper 코드베이스 검색 — 13줄 · exit 0" zoom="true"
:::
## 그 조각이 존재하는 곳은 시험 하나다
그 메서드의 javadoc 이 문제를 정확히 서술한다 — "the adapter has to" 인데 어댑터는 하지 않는다. `RabbitChannelPublisher` 의 javadoc 은 등록 경합(확인이 `basicPublish` 반환보다 먼저 올 수 있다)만 설명하고 이 상관 문제는 언급하지 않는다.
## 구현하는 사람이 규약을 다시 발명해야 한다
발명하지 않으면 `onReturn` 이 호출되지 않아 unroutable 발행이 **`CONFIRMED` 로 보고된다** — 이 어댑터가 존재하는 이유로 든 바로 그 실패다. 수정은 순번 헤더를 `RabbitHeaderMapper``RabbitPublishMapper` 로 올려 production 계약으로 만들고, 그 이름을 `RabbitChannelPublisher` javadoc 에 적는 것이다.
## 확인하지 못한 것
실행으로 재현하지 않았다. 매퍼 전문에 순번 헤더가 없다는 것과, 통합 시험이 자기 발행 람다에서 그 헤더를 붙인다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,50 @@
---
kind: REFERENCE
slug: messaging-claim-check-f02
title: 같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:messaging-claim-check-f02
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-claim-check.md#L516
---
# 같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다
## 관계
- **배포 아티팩트가 싣지만 아무도 부르지 않고, 다른 곳의 에러 메시지가 이 경로를 권한다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
- **예외 승격이 에러 코드 문자열 접미사에 의존한다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
## 목적
두 값이 나란히 선언돼 있고 어느 쪽도 다른 쪽을 읽지 않으면, 실행되는 순간 한쪽만 살아남고 다른 쪽은 선언으로만 남는다. 목적지별로 다르게 두려던 설계가 전역 값 하나에 덮이는 것이 그 형태다.
## 규칙
1. 같은 의미의 튜닝 값이 두 계층에 있는지 먼저 센다
messaging-policy 의 PayloadPolicy.claimCheckThresholdBytes 는 목적지별이고 DestinationProfileValidator:49 가 검사한다. messaging-claim-check 의 ClaimCheckPolicy.thresholdBytes 는 전역이다.
2. 두 값을 대조하는 코드가 있는지 확인한다
대조가 없으면 둘은 같은 이름을 가진 서로 다른 설정이다.
3. 우선순위를 코드로 표현한다
좁은 쪽이 넓은 쪽을 읽거나, 넓은 쪽에서 그 필드를 없앤다. 문서로만 정한 우선순위는 강제되지 않는다.
## 적용 조건
같은 값이 정책 계층과 구현 계층에 각각 선언되는 자리. 문턱·상한·타임아웃처럼 목적지별로 달라질 수 있는 값이 특히 그렇다.
## 예외
SSOT 가 이 규칙의 반례를 적지 않았다. 두 값을 의도적으로 다르게 두는 설계가 있다면 그 이유가 어느 한쪽 javadoc 에 있어야 하는데, 지금은 없다.
## 예시
두 필드의 선언 위치와 DestinationProfileValidator:49 의 검사 대상, 그리고 두 값을 잇는 코드가 없다는 것. 원문 근거는 evidence/raw/290 §B 이다.
@@ -0,0 +1,52 @@
---
kind: REFERENCE
slug: messaging-security-f08
title: 가변 필드로 상태 전이를 표현하면 가시성을 함께 정한다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:messaging-security-f08
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-security.md#L697
---
# 가변 필드로 상태 전이를 표현하면 가시성을 함께 정한다
## 관계
- **종료 시 자격증명 소거가 호출되지 않는다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
- **같은 TLS posture를 두 클래스가 다른 엄격도로 검사한다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
- **권한 거부가 `AUTHORIZATION`이 아니라 `CONFIGURATION`으로 기록된다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
## 목적
정상 경로에서는 ConcurrentHashMap 의 compute 가 happens-before 를 준다. clearAll() 경로에는 그 보장이 없어서, 다른 스레드가 소거된 배열의 옛 참조를 읽을 수 있다. 방향은 안전한 쪽이다 — 비밀 유출이 아니라 0 으로 채워진 값을 읽는다.
## 규칙
1. 상태 전이를 표현하는 필드의 선언을 확인한다
private char[] material 이 volatile 이 아니고 clear() 가 그것을 교체한다.
2. 그 필드를 읽는 경로가 전부 같은 동기화 안에 있는지 본다
clearAll() 은 락 없이 순회한다. 그 경로만 보장 밖이다.
3. 가시성을 필드나 접근 경로 중 한쪽에서 정한다
material 을 volatile 로 하거나 clearAll() 을 compute 기반으로 바꾼다.
## 적용 조건
소거·회전·상태 전이를 필드 교체로 표현하고, 그 필드를 여러 스레드가 읽는 모든 자리.
## 예외
모든 읽기와 쓰기가 같은 compute 안에서 일어나면 별도 가시성 선언이 필요 없다. 이 클래스는 그 조건을 한 경로에서만 만족한다.
## 예시
CredentialRuntime.java:29,129-132 와 CredentialRuntimeRegistry.java:129-132. 확인 방법은 필드 선언을 보는 것이다.
@@ -0,0 +1,43 @@
---
kind: REFERENCE
slug: messaging-spring-cloud-stream-bridge-f04
title: 함께 읽히는 두 맵은 한 값으로 묶는다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:messaging-spring-cloud-stream-bridge-f04
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-spring-cloud-stream-bridge.md#L579
---
# 함께 읽히는 두 맵은 한 값으로 묶는다
## 목적
두 put 사이에 dispatch 가 들어오면 destination == null 이 되어 NO_BRIDGED_HANDLER 가 난다. 방향은 안전하다 — 잘못된 목적지로 전달하지는 않는다. 틀리는 것은 에러 코드다. 핸들러가 없다고 말하는데 실제로는 핸들러가 있고 목적지가 아직 없다.
## 규칙
1. 한 논리 등록이 몇 번의 put 으로 나뉘는지 센다
SpringCloudStreamConsumerBridge.register 가 handlers.put(...) 후 destinations.put(...) 을 한다. 같은 형태가 publisher 의 두 맵에도 있고 그쪽은 키가 각각 독립이다.
2. 그 사이에 읽는 경로가 있는지 본다
dispatch 가 그 창에 들어온다.
3. 두 값을 한 record 로 묶어 한 번에 넣는다
창 자체가 사라진다.
## 적용 조건
한 등록·한 전이가 두 개 이상의 맵 갱신으로 표현되고, 그 맵들을 함께 읽는 경로가 있는 자리.
## 예외
두 맵의 키가 독립이고 읽는 쪽이 둘을 함께 보지 않으면 대상이 아니다. publisher 쪽이 그 경우에 가깝다.
## 예시
SpringCloudStreamConsumerBridge.java:38-39 의 두 put. 확인 방법은 그 사이의 창을 보는 것이다.
@@ -0,0 +1,43 @@
---
kind: REFERENCE
slug: messaging-spring-cloud-stream-bridge-f06
title: 에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다
topic: state-ownership-and-concurrency
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:messaging-spring-cloud-stream-bridge-f06
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-spring-cloud-stream-bridge.md#L597
---
# 에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다
## 목적
STREAM_BRIDGE_DISABLED 메시지가 지시하는 프로퍼티를 읽는 코드가 없다. 이 저장소에서 같은 형태가 세 번째다 — messaging-kafka-share-experimental 과 messaging-claim-check 가 앞선 둘이다.
## 규칙
1. 메시지에 등장하는 키를 검색한다
git grep -n 'spring-cloud-stream=true' -- src 가 이 leaf 의 문자열 하나만 돌려준다.
2. 같은 형태가 가족 안에 몇 번 있는지 센다
세 leaf 가 같은 방식으로 실행 불가능한 지시를 남겼다. 개별 실수가 아니라 형태다.
3. 바인딩을 만들거나 메시지에서 키를 뺀다
지시는 실행 가능할 때만 지시다.
## 적용 조건
실패 메시지가 복구 방법을 프로퍼티 키로 제시하는 모든 자리.
## 예외
SSOT 가 이 규칙의 반례를 적지 않았다. 배선 계획이 확정된 키를 미리 안내하는 경우라면 그 사실이 메시지 안에 있어야 한다.
## 예시
backend.messaging.bridge.spring-cloud-stream=true 가 STREAM_BRIDGE_DISABLED 메시지에만 존재한다는 것.