Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/operator-approval-and-destructive-operations/case/case-a-resumed-redrive-skips-what-it-could-not-move.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

10 KiB


kind: CASE slug: a-resumed-redrive-skips-what-it-could-not-move title: 재개된 리드라이브가 옮기지 못한 메시지를 건너뛰고 성공으로 닫힌다 topic: operator-approval-and-destructive-operations project: clean-architecture-backend-template status: 게시 전 sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 rootTreeNode: case:a-resumed-redrive-skips-what-it-could-not-move evidenceCapturedOn: 2026-09-02 assets:

  • key: a-resumed-redrive-skips-what-it-could-not-move file: ../../../final/evidence/rendered/a-resumed-redrive-skips-what-it-could-not-move.svg evidence:
  • ../../../final/evidence/raw/a-resumed-redrive-skips-what-it-could-not-move.txt source:
  • analysis/messaging/messaging-admin-runtime.md §12.1·§17, 측정 EVD-306.

재개된 리드라이브가 옮기지 못한 메시지를 건너뛰고 성공으로 닫힌다

재개 지점이 시도한 개수인데 건너뛰는 대상은 매번 새로 조회한 목록이다. 그 목록에서 사라진 것은 성공한 것뿐이므로, 건너뛰기가 정확히 실패분과 미시도분을 대상에서 제외한다.

관계

  • 승인·검증·실행의 분리와 그것을 타입으로 표현하기 이 사례가 속한 구조다.
  • 재개는 인덱스가 아니라 신원으로 한다 이 사례가 만든 규칙이다.
  • 그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다 회귀 테스트가 성립하지 않는 이유다.
  • 모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다 결과 보고가 사실이 아니게 되는 형태가 같다.

문제

리드라이브는 데드레터에 쌓인 메시지를 원래 목적지로 되돌리는 작업이다.

이것을 평범한 루프로 돌렸을 때 세 가지가 잘못됐다고 코드가 적는다. 발행자의 동기 실패가 루프 밖으로 전파되어 남은 후보가 시도되지 않고 감사 기록도 남지 않았고, 재시도는 첫 후보부터 다시 발행했으며, 한 메시지가 몇 번까지 리드라이브될 수 있는지를 제한하는 것이 없었다.

그래서 항목마다 경계를 두고, 저널에 진행을 적고, 그 지점부터 재개하게 됐다.

결론

재개 지점의 계약과 실제로 적히는 값이 어긋난다.

그 값의 의미는 파라미터 javadoc 에 이전 시도가 확실히 옮긴 개수로 적혀 있다. 항목마다 부르는 체크포인트가 올리는 것은 시도한 개수다. 성공과 실패 양쪽에서 같은 카운터가 올라간다.

재개할 때는 데드레터를 다시 조회한다. 그 목록에서 사라진 것은 정착된 것뿐이다. 실패한 것과 아직 시도하지 않은 것은 여전히 앞쪽에 남아 있다.

그런데 재개 코드는 그 새 목록의 앞에서 기록된 개수만큼을 잘라낸다. 잘려나가는 것이 정확히 실패분과 미시도분이다.

그 실행은 성공으로 닫히고 승인이 소진된다. 같은 승인으로는 다시 돌릴 수 없고, 남은 메시지들을 지목하는 기록이 없으므로 새 승인을 받을 근거도 없다.

이 결함을 잡는 술어가 이미 존재한다. 결과 객체에 전부 정산됐는지 묻는 메서드가 있고, 프로덕션 호출부가 0 이다.

검증 환경

OpenJDK : 21.0.12 Gradle : 9.0.0 확인 방식 : 재개 지점의 계약과 기록되는 값 대조, 술어와 구현체의 호출자 검색 소스 수정 : x

재현 조건

  1. 재개 지점 파라미터의 javadoc 과 건너뛰는 자리의 주석을 읽는다.
  2. 체크포인트에 넘기는 카운터가 어디서 올라가는지 확인한다. 성공과 실패 양쪽이다.
  3. 그 값이 다음 재개 지점이 되는 경로를 저널 구현까지 추적하고, 완료로 닫힌 승인이 어떻게 다뤄지는지 확인한다.
  4. 후보 목록이 매번 다시 조회되는지, 확인되지 않은 재발행을 왜 정착시키지 않는지 확인한다.
  5. 전부 정산됐는지 묻는 술어의 호출자를 main 과 test 로 나눠 센다.
  6. 이 경로의 오케스트레이터와 조회 구현체가 몇 개인지 센다.

본문

리드라이브를 평범한 루프로 돌렸을 때 무엇이 잘못됐는지는 코드가 적어 두었다. 발행자가 던지면 남은 후보가 전부 버려지고 감사 기록도 남지 않았고, 재시도는 첫 후보부터 다시 발행했다.

그래서 항목마다 경계를 두고 저널에 진행을 적게 됐다. 그 진행 값이 이 사례의 대상이다.

계약은 옮긴 개수인데 세는 것은 시도한 개수다

:::evidence key="a-resumed-redrive-skips-what-it-could-not-move" alt="코드베이스에서 재개 지점의 계약을 적은 javadoc 과 건너뛰는 자리의 주석, 체크포인트가 성공과 실패 양쪽에서 올라가는 지점, 그 체크포인트 값이 다음 재개 지점이 되는 경로와 두 저널 구현이 각각 값을 되돌아가지 않게 고정하는 줄, 완료로 닫힌 승인이 다시 인수되지 않는 분기, 후보를 매번 다시 조회하는 줄과 확인되지 않은 재발행을 정착시키지 않는 이유, 전부 정산됐는지 묻는 술어와 그 호출자, 그리고 이 경로에 실행 가능한 구현이 없다는 계수와 유일한 테스트 대역의 조회·정착 메서드를 뽑은 출력 88줄. 계약은 옮긴 개수인데 세는 것은 시도한 개수이고 대역의 조회가 목록을 그대로 돌려준다는 것이 그 출력에 보인다." caption="계약과 실제 값 · 체크포인트가 다음 재개 지점이 되는 경로 · 완료된 승인은 재인수 불가 · 술어 호출자 0 · 구현 0 과 대역의 조회 — 88줄" zoom="true" :::

재개 지점 파라미터의 javadoc 이 그 값을 이전 시도가 확실히 옮긴 개수라고 적는다. 건너뛰는 자리의 주석도 같은 말을 한다 — 그 앞의 것은 이전 시도가 옮기고 정착시켰으므로 다시 발행하는 것은 재시도가 아니라 중복이라는 것이다.

실제로 올라가는 값은 다르다. 카운터가 재개 지점에서 시작해서, 성공 분기와 실패 분기를 지난 뒤 공통으로 한 번 올라간다.

그 값이 다음 재개 지점이 되고, 되돌아가지 않는다

인수는 이전 레코드의 진행 값을 그대로 이어받아 리스의 재개 지점으로 내놓는다. 그 자리 주석이 이유를 적는다 — 이전 시도가 실패했든 리스가 만료됐든 둘 다 체크포인트에서 재개하며, 새 토큰이 이전 보유자를 막는다.

두 저널 구현이 각각 그 값을 큰 쪽으로만 고정한다. 하나는 최댓값 함수로, 다른 하나는 SQL 의 같은 함수로. 인터페이스가 요구하지 않는데 둘 다 그렇게 한다. 시도 개수라는 선택이 우연이 아니라는 뜻이다.

작업이 끝날 때 부르는 완료는 다르다. 그 값은 다음 재개 지점이 되지 못한다. 완료로 닫힌 승인은 인수 자체가 거절되기 때문이다.

목록은 매번 다시 조회되고, 사라지는 것은 성공한 것뿐이다

재개할 때 후보를 다시 조회한다. 이전에 옮겨져 정착된 메시지는 데드레터에서 빠졌으니 목록에 없다.

실패한 메시지는 남아 있다. 결과 타입의 javadoc 이 이유를 적는다 — 확인되지 않은 재발행을 정착시키면 마지막 사본을 지우는 셈이고, 데드레터로 보낼 때 적용되는 규칙이 리드라이브에도 똑같이 적용된다는 것이다.

그래서 새 목록의 앞쪽은 실패분과 미시도분이다. 거기서 시도한 개수만큼을 잘라내면 정확히 그것들이 사라진다.

메시지 다섯 건에서 어디가 잘리는지

데드레터에 다섯이 있고 첫 시도가 첫째를 옮겨 정착시킨다. 둘째는 확인되지 않아 남는다. 여기서 프로세스가 죽는다. 체크포인트에 적힌 진행은 둘이다.

재개하면 목록은 넷이다. 원래의 첫째만 사라졌으니 남은 것은 원래의 둘째부터 다섯째다. 앞에서 둘을 잘라내면 이 목록의 셋째와 넷째 — 원래의 넷째와 다섯째 — 만 대상이 된다.

원래의 둘째와 셋째가 잘려나간다. 하나는 실패했던 것이고 하나는 시도조차 되지 않은 것이다.

둘 다 성공하면 결과는 후보 넷 중 둘을 옮기고 실패 0 으로 닫힌다. 예외도 실패 카운트도 남지 않는다.

승인은 한 번 쓰이고 닫힌다

작업이 완료로 닫히면 그 승인은 소진된다. 같은 티켓으로 다시 인수하려 하면 저널이 거절한다 — 승인은 한 번의 실행을 허가하는 것이지 상시 권한이 아니라는 문장과 함께.

새 승인을 받으면 키가 달라지므로 재개 지점 0 에서 새로 시작할 수 있다. 문제는 그 승인을 받을 근거다. 두 메시지는 데드레터에 남아 있고, 이 실행이 남긴 어떤 기록도 그 둘을 지목하지 않는다.

잡을 수 있는 술어가 이미 있다

결과 객체에 전부 정산됐는지 묻는 메서드가 있다. 옮긴 개수와 남아 있는 개수의 합이 후보 수와 같은지 본다. 위 경우에는 둘 더하기 0 이 넷과 같지 않다.

그 자리 javadoc 은 정산되지 않은 메시지가 부분 성공이 아니라 버그라고 적는다. 재발행되지도 남겨지지도 않았다는 것은 리드라이브가 그것을 놓쳤다는 뜻이다.

이 술어를 부르는 프로덕션 코드가 0 이다. 부르는 것은 admin-api 타입만 조립하는 테스트 한 건이고, 그 안에서 참과 거짓을 각각 한 번씩 단언한다.

이 경로에는 아직 실행 가능한 구현이 없다

재개 지점을 저널과 잇는 오케스트레이터를 생성하는 코드가 저장소 전체에 0 이다. 조회 인터페이스를 구현하는 main 코드도 0 이다.

유일한 구현이 테스트 대역이고, 그 조회는 담아 둔 목록을 그대로 돌려준다. 정착은 별도 목록에 추가만 한다.

이 결함의 성립 조건은 정착된 메시지가 다음 조회에서 사라진다는 것이다. 그 대역 위에서는 결함 있는 구현과 올바른 구현이 같은 결과를 낸다. 지금 이 결함을 겨냥한 테스트를 써도 통과한다.

확인하지 못한 것

이 경로를 실행한 것이 아니다. 오케스트레이터를 생성하는 코드가 없고, 정착된 메시지가 조회 목록에서 사라진다는 성질을 갖춘 조회 구현도 없다.