Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/non-atomic-check-then-act/case/case-a-rotation-that-overwrites-and-a-generation-that-cannot-be-reclaimed.md
T
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

11 KiB

kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, assets, evidence, source
kind slug title topic project status sourceRevision rootTreeNode evidenceCapturedOn assets evidence source
CASE a-rotation-that-overwrites-and-a-generation-that-cannot-be-reclaimed 회전이 비교 후 교체가 아니라 덮어쓰기이고, 세대 계수기는 음수가 되면 회수되지 않는다 non-atomic-check-then-act clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:a-rotation-that-overwrites-and-a-generation-that-cannot-be-reclaimed 2026-09-02
key file
a-rotation-that-overwrites-and-a-generation-that-cannot-be-reclaimed ../../../final/evidence/rendered/a-rotation-that-overwrites-and-a-generation-that-cannot-be-reclaimed.svg
../../../final/evidence/raw/a-rotation-that-overwrites-and-a-generation-that-cannot-be-reclaimed.txt
분석 문서는 grpc-client 편 §17.1 이 회전의 덮어쓰기를, §17.2 가 비원자적 감소를, §17.3 이 배수 목록 순회를 다룬다. 권고는 각각 비교 후 교체, 하한을 포함한 원자 갱신, 복사 후 쓰기 배열이다. 세대가 실제 채널을 들고 있지 않다는 것은 같은 문서 §16 이 확인하지 못한 것으로 적어 둔 자리다.
가족을 건넌 재현은 플랫폼 편 §7.4 와 교차 범위 편 §6 이 다룬다.

회전이 비교 후 교체가 아니라 덮어쓰기이고, 세대 계수기는 음수가 되면 회수되지 않는다

채널 런타임 레지스트리의 회전이 읽고 판단한 뒤 조건 없이 쓴다. 같은 모듈의 세대 계수기는 확인하고 별도로 감소시켜, 음수가 되면 그 세대를 영영 회수할 수 없다.

관계

  • 원자 타입 위의 검사 후 실행과 비교 후 교체 루프 이 사례가 속한 구조다.
  • 같은 자격 증명 회전 결함이 한 가족에서 닫히고 다른 가족에서 재현됐다 같은 자료구조 오용이 옆 모듈에도 있는데, 그 모듈의 사례는 다른 가족이 이미 닫은 형태의 재현이다.
  • 배수 완료가 자기가 읽은 값으로 상태를 다시 써서 진행 중인 자격증명 회전을 되돌린다 옆 모듈의 그 결과를 다루는 기록이다.

문제

채널 런타임은 세대를 갖는다. 설정이 바뀌면 새 세대를 만들고, 옛 세대는 진행 중인 호출이 끝날 때까지 배수 목록에 남아 있다가 회수된다.

준비하고 교체하고 배수하는 순서를 지키는 연산이 셋이다. 교체와 계수와 회수다.

결론

앞의 둘이 같은 형태의 결함을 갖는다. 읽기와 쓰기가 원자 타입 위에서 두 연산으로 갈라진다. 셋째는 다른 곳에서 어긋난다. 동기화 목록을 잠그지 않고 순회한다.

교체는 현재 런타임을 읽어 판단한 뒤 조건 없이 쓴다. 두 회전이 겹치면 나중 것이 먼저 것을 덮고, 덮인 쪽은 배수 목록에 오르지 못한 채 사라진다. 그 세대 위에서 시작된 호출은 아무도 세지 않는다. 같은 클래스의 첫 설치는 비교 후 교체를 쓴다.

계수기 감소는 확인 후 실행이다. 값이 0보다 큰지 확인하고 별도 연산으로 감소시킨다. 증가와 감소가 짝을 유지하는 한 이 값은 0 아래로 내려가지 않는다. 짝 없는 감소가 섞이면 두 스레드가 같은 값을 읽고 둘 다 감소시킬 수 있다.

두 번째 오용은 스스로 회복되지 않는다. 조용함을 판정하는 술어가 계수기를 0과 비교하고, 배수가 시작된 런타임은 증가를 거절하므로, 0 아래로 내려간 값은 그 자리에 머문다.

같은 자료구조 오용이 옆 모듈의 자격증명 회전 관리자에도 있다. 그 관리자의 사례는 다른 가족이 계약 테스트로 이미 닫은 형태의 재현이다.

검증 환경

OpenJDK : 21.0.12 확인 방식 : 읽기와 쓰기가 별개 연산이라는 코드 형태 확인과 호출자 계수 소스 수정 : x

재현 조건

  1. 레지스트리의 회전 메서드에서 읽기와 쓰기를 찾고, 그 사이에 비교 후 교체가 있는지 확인한다.
  2. 같은 클래스의 설치 메서드가 무엇을 쓰는지 대조하고, 배수 목록에 들어가는 값이 무엇인지 확인한다.
  3. 세대가 들고 있는 필드를 나열하고, 이 모듈이 grpc 타입을 참조하는지 센다.
  4. 계수기의 확인과 감소, 증가 경로의 배수 거절, 조용함 판정의 비교 대상을 확인한다.
  5. 그 메서드들의 프로덕션 호출자를 센다.
  6. 회수와 배수 목록 반환이 어떤 목록을 어떻게 순회하는지 확인한다.
  7. 옆 모듈의 배수 완료 메서드 전문을 읽고 그 파일의 원자 갱신을 센다.

본문

설정이 바뀌면 레지스트리가 새 세대를 만들어 포인터를 옮기고 옛 세대를 배수 목록에 넣는다. 준비하고 교체하고 배수하는 순서다.

그 순서를 지키는 세 연산이 각각 다른 곳에서 어긋난다. 앞의 둘은 원자 타입 위에서 읽고 판단한 뒤 따로 쓴다. 셋째는 다른 형태다 — 동기화 목록을 잠그지 않고 순회한다.

같은 클래스가 같은 참조를 두 가지로 다룬다

:::evidence key="a-rotation-that-overwrites-and-a-generation-that-cannot-be-reclaimed" alt="코드베이스에서 회전 메서드가 참조를 읽고 판단한 뒤 조건 없이 쓰는 구간과 같은 클래스의 첫 설치가 쓰는 비교 후 교체, 그 파일의 비교 후 교체 매치 수와 배수 목록에 들어가는 값, 세대가 들고 있는 네 필드와 이 모듈의 grpc 참조 수, 계수기를 확인 후 감소시키는 두 메서드와 그 파일의 원자 갱신 매치 수와 배수 중에 증가를 거절하는 분기와 조용함 판정, 그 메서드의 프로덕션 호출자 수, 동기화 목록을 잠그지 않고 순회하는 두 메서드와 회수의 프로덕션 호출자 수, 옆 모듈의 배수 완료 전문과 그 파일의 원자 갱신 매치 수, 그리고 두 모듈의 런타임 소속을 뽑은 출력 77줄. 세대가 채널을 들고 있지 않고 두 모듈 어디에도 원자 갱신이 없다는 것이 그 출력에 보인다." caption="회전은 덮어쓰기 · 설치는 비교 후 교체 · 세대의 네 필드와 grpc 참조 0 · 계수기의 확인 후 감소 · 잠금 밖 순회 둘 · 옆 모듈의 판단 없는 쓰기 — 77줄" zoom="true" :::

회전은 프로파일의 참조를 꺼내고, 현재 런타임을 지역 변수에 담고, 새 세대가 그것을 이어받는지 확인한 뒤, 조건 없이 쓴다.

같은 클래스의 첫 설치는 다르다. 널에서 새 런타임으로 비교 후 교체하고, 실패하면 이미 설치되어 있다는 뜻으로 다룬다. 이 파일의 비교 후 교체는 그 한 번뿐이다.

읽기와 쓰기 사이에 다른 회전이 끼면 나중 쓰기가 먼저 쓰기를 덮는다. 배수 목록에 들어가는 것은 이전 값 하나이므로, 덮인 대체본은 어디에도 등록되지 않는다. 그 세대는 배수도 회수도 되지 않고, 그 위에서 시작된 호출은 아무도 세지 않는다.

그 세대가 들고 있는 것은 채널이 아니다

세대의 필드는 넷이다. 세대 기술자와 계수기 둘과 배수 플래그다.

이 모듈의 프로덕션 코드에는 grpc 타입 참조가 0 이다. 닫아야 할 채널 객체가 여기에는 없다. 잃는 것은 자원이 아니라 추적이다.

계수기는 원자 타입 위에서 원자적이지 않다

두 종료 메서드가 같은 순서로 같은 일을 한다. 값이 0보다 큰지 읽어 보고, 그다음 별도 연산으로 감소시킨다. 이 파일에는 그 둘을 한 연산으로 묶는 호출이 없다.

그 확인은 하한이지만 원자적인 하한이 아니다. 감소가 증가와 짝을 이루는 동안에는 값이 음수가 되지 않는다. 두 스레드가 동시에 종료 안에 있으면 계수기는 최소 2 이기 때문이다.

짝 없는 감소가 하나라도 섞이면 달라진다. 같은 호출을 두 번 끝내거나, 시작이 거절된 뒤에도 종료를 부르면, 두 스레드가 같은 값을 읽고 둘 다 감소시킬 수 있다.

이 저장소에는 그 호출자가 아직 없다. 감소가 그 보호를 호출자에게 맡기고 있다는 것이 형태의 결함이다.

내려간 값은 그 자리에 머문다

조용함을 판정하는 술어가 두 계수기를 각각 0 과 비교한다.

계수기를 올리는 두 경로는 배수가 시작된 런타임의 작업을 거절한다. 회전이 이전 세대의 배수를 시작하고 목록에 넣으므로, 배수 목록에 오른 런타임은 새 작업을 받지 않고 따라서 값이 다시 올라가지 않는다.

0 아래로 내려간 값은 그래서 돌아오지 않는다. 그 세대는 회수 대상이 되지 못한 채 목록에 남는다.

회수는 다른 곳에서 어긋난다

배수 목록은 동기화 래퍼로 감싼 리스트다. 그 래퍼는 개별 연산만 동기화하고, 순회는 호출자가 목록을 잠그고 해야 한다는 것이 그 API 의 계약이다.

회수 메서드는 잠그지 않고 스트림으로 거르고, 그 결과를 목록에서 뺀다. 배수 목록을 밖으로 내주는 메서드도 잠그지 않고 복사한다. 복사도 순회다.

어느 쪽이든 회전이 동시에 항목을 더하면 순회 중 변경이 된다.

읽고 지우는 두 단계가 원자적이지 않은 것도 같지만, 이쪽은 무해하다. 그 사이에 조용해진 세대는 다음 호출에서 회수된다.

옆 모듈은 판단조차 없다

자격증명 회전 관리자가 상태를 하나의 참조로 들고 있다. 회전은 그것을 읽어 담고 승계를 판정한 뒤 조건 없이 쓴다.

배수 완료는 판정이 없다. 읽은 값의 현재 세대로 새 상태를 만들어 그대로 쓴다. 그래서 읽기와 쓰기 사이에 회전이 끼면 그 회전이 활성화한 세대가 지워지고 이전 세대가 다시 현재가 된다. 방금 교체하기 전의 자격 재료가 다시 현재 값이 된다.

그 파일에는 비교 후 교체도, 원자 갱신도, 동기화 블록도 0 이다.

이 형태는 처음 나온 것이 아니다. 다른 가족이 같은 회전 결함을 동시성 계약 테스트로 닫고 그 이력을 남겼는데, 이쪽 가족의 회전 관리자가 그것을 반복한다.

배선되지 않은 두 모듈

두 모듈 모두 모듈 레지스트리의 런타임 소속이 비어 있다. 계수기를 움직이는 메서드도, 회수를 부르는 메서드도 프로덕션 호출자가 0 이다.

남는 것은 형태다. 이 클래스는 같은 참조를 한 곳에서는 비교 후 교체로 다루고 다른 곳에서는 덮어쓴다.

확인하지 못한 것

경합 자체를 돌려서 만들어 보지는 않았다. 두 모듈 모두 런타임 소속이 비어 있고, 이 연산들을 부르는 프로덕션 코드도 없다.

경합을 실행으로 재현하지 않았다. 판정은 읽기와 쓰기가 별개 연산이라는 코드 형태와 조용함 술어의 비교 대상에 근거한다.