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>
100 lines
5.1 KiB
Markdown
100 lines
5.1 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: untranslated-contention-bypassed-the-retry-catch
|
|
title: 번역되지 않은 경합 예외가 재시도 코디네이터의 catch를 통째로 비껴갔다
|
|
topic: commit-ambiguity-as-a-result
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:untranslated-contention-bypassed-the-retry-catch
|
|
evidenceCapturedOn: 2026-09-01
|
|
body: case-untranslated-contention-bypassed-the-retry-catch.body.md
|
|
assets:
|
|
- key: untranslated-contention-bypassed-the-retry-catch
|
|
file: ../../../final/evidence/rendered/untranslated-contention-bypassed-the-retry-catch.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/untranslated-contention-bypassed-the-retry-catch.txt
|
|
source:
|
|
- 원본 분석 절은 final/document.md#4-1 · analysis/05 §3.6 이다.
|
|
---
|
|
|
|
# 번역되지 않은 경합 예외가 재시도 코디네이터의 catch를 통째로 비껴갔다
|
|
|
|
재시도 코디네이터가 자기 플랫폼 예외 하나만 catch 하는데 executor 는 아무것도 번역하지 않았다. 경합이 실제로 만들어 내는 예외들이 번역되지 않은 채 나가서 catch 를 비껴갔고, 프로덕션 경합은 재시도되지 않았다.
|
|
|
|
## 관계
|
|
|
|
- **실패 번역 사슬의 순서는 계약이다**
|
|
이 사례가 그 순서를 계약으로 만든 이유다.
|
|
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
|
|
번역이 없으면 분류도 없고, 분류가 없으면 세 번째 결과도 만들어지지 않는다.
|
|
- **아무도 부르지 않는 재시도 구현**
|
|
같은 형태의 공백이 다른 곳에서 나타난 사례다.
|
|
|
|
## 문제
|
|
|
|
재시도 코디네이터는 JpaPersistenceException 만 catch 한다. 그런데 executor 는 트랜잭션 템플릿을 돌리면서 아무것도 번역하지 않았다.
|
|
|
|
경합이 실제로 만들어 내는 실패는 이런 것들이다.
|
|
|
|
Hibernate 의 OptimisticLockException
|
|
Spring 의 OptimisticLockingFailureException
|
|
raw 직렬화 실패 또는 데드락 DataAccessException
|
|
|
|
셋 다 JpaPersistenceException 이 아니다. 번역되지 않은 채 executor 를 떠나면 코디네이터의 catch 에 걸리지 않는다. 그래서 프로덕션에서 경합은 재시도되지 않았다.
|
|
|
|
단위 픽스처는 초록불이었다. 픽스처가 이미 번역된 예외를 던졌기 때문이다.
|
|
|
|
## 결론
|
|
|
|
번역 사슬이 단일 지점으로 만들어졌고, 그 순서가 계약으로 고정됐다.
|
|
|
|
PersistenceFailureTranslatorChain 의 클래스 javadoc 이 이 회귀를 사후 기록으로 남긴다. 그리고 네 단계 순서를 계약이라고 명시한다.
|
|
|
|
1단계 : 이미 분류된 실패는 그대로 통과시킨다. 다시 번역하면 그 실패가 이미 들고 있는 attempt 와 key 와 completion 판정을 잃는다.
|
|
2단계 : 낙관적 충돌. provider 예외이고 SQLSTATE 가 없으므로 SQLSTATE 기반 번역기가 알아볼 수 없다.
|
|
3단계 : 벤더 SQLSTATE. 직렬화 실패와 데드락과 제약 계열을 덮는다.
|
|
4단계 : 나머지는 손대지 않고 반환한다. 도메인 예외나 assertion 실패나 NullPointerException 은 퍼시스턴스 실패가 아니며, 그것을 퍼시스턴스 실패로 포장하면 프로그래밍 에러가 재시도 가능한 것처럼 보인다.
|
|
|
|
모든 번역기는 한 번의 호출에서 같은 operation 과 attempt 와 elapsed 와 trace 값을 받는다. 두 번역기가 같은 시도를 다르게 서술할 수 없게 하기 위해서다.
|
|
|
|
## 검증 환경
|
|
|
|
OpenJDK : 21.0.12
|
|
Gradle : 9.0.0
|
|
Spring Boot : 4.0.8
|
|
근거 : 저장소의 javadoc 이 사후 기록으로 남긴 회귀
|
|
|
|
## 재현 조건
|
|
|
|
이 회귀 자체는 저장소가 이미 고쳤고, 그 기록이 코드 주석에 남아 있다. 현재 형태를 확인하는 절차는 다음과 같다.
|
|
|
|
1. PersistenceFailureTranslatorChain 의 클래스 javadoc 을 읽어 네 단계 순서와 그 근거를 확인한다.
|
|
2. 사슬 구현이 그 순서대로 실행되는지 확인한다.
|
|
3. 재시도 코디네이터가 catch 하는 예외 타입을 확인한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
재시도 코디네이터가 `JpaPersistenceException`만 catch하는데 executor는 아무것도 번역하지 않고 템플릿을 돌렸다.
|
|
|
|
## JpaPersistenceException 참조 위치
|
|
|
|
:::evidence key="untranslated-contention-bypassed-the-retry-catch" alt="코드베이스에서 JpaPersistenceException 를 검색한 출력 37줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="JpaPersistenceException 코드베이스 검색 — 37줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
## 경합이 실제로 만들어내는 실패 셋
|
|
|
|
Hibernate `OptimisticLockException`, Spring `OptimisticLockingFailureException`, raw 직렬화/데드락 `DataAccessException` — 이것들이 번역되지 않은 채 executor를 떠나 catch를 완전히 비껴갔고 **production 경합은 재시도되지 않았다.**
|
|
|
|
## 픽스처가 초록불이던 이유
|
|
|
|
단위 픽스처는 이미 번역된 예외를 던졌다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
현재 사슬이 네 단계 순서를 지키는지는 코드로 확인했으나, 경합을 실제로 일으켜 재시도가 일어나는 것을 관측하지 않았다.
|
|
|
|
<!-- body:end -->
|