Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/duplicate-mechanisms/case/case-a-retry-implementation-nobody-calls.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

13 KiB

kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
kind slug title topic project status sourceRevision rootTreeNode evidenceCapturedOn body assets evidence source
CASE a-retry-implementation-nobody-calls 정식 경계라고 적은 클래스가 그것을 구현하지 않는다 duplicate-mechanisms clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:a-retry-implementation-nobody-calls 2026-09-02 case-a-retry-implementation-nobody-calls.body.md
key file
a-retry-implementation-nobody-calls ../../../final/evidence/rendered/a-retry-implementation-nobody-calls.svg
../../../final/evidence/raw/a-retry-implementation-nobody-calls.txt
원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md 의 17번과 24번이다. 17번은 재시도 분류를 구조화된 상태로 제한하는 절이고 등급이 P1 이다. 24번은 두 트랜잭션 스택의 아키텍처 드리프트를 다루고 등급이 P2 다. 25번은 선언적 재시도 제거 뒤 남은 레지스트리를 P3 로 다룬다.
원본의 앞선 판정에서 틀린 것은 구현이 둘이라는 절반이 아니라 그 뒤에 딸려 있던 함의였다. 코디네이터가 조립되지 않는다는 뜻으로 읽혔지만 프로덕션 코드가 조건부 빈으로 만든다. 구현이 둘이라는 절반은 그대로 맞다.
이 기록이 더한 것은 셋이다. 삭제를 설명하는 자바독의 마지막 문장이 사실과 다르다는 것 — 코디네이터는 아무 인터페이스도 구현하지 않고, 그 포트를 구현하는 것은 컴포넌트로 등록된 다른 클래스다. 두 구현이 읽는 값과 게이트가 다르다는 것. 그리고 어드바이스를 얹을 경로가 없어서가 아니라 얹는 클래스가 없어서라는 것이다.

정식 경계라고 적은 클래스가 그것을 구현하지 않는다

선언적 재시도 애너테이션이 지워졌고, 그 삭제를 설명하는 문단이 남아 있다. 그 문단의 마지막 문장이 정식 경계를 구현하는 클래스로 재시도 코디네이터를 지목한다. 코디네이터는 아무 인터페이스도 구현하지 않고, 그 포트를 구현하며 실제로 도는 재시도는 같은 패키지의 다른 클래스다.

관계

  • 재시도 단위는 statement가 아니라 유스케이스 전체다 코디네이터가 구현하는 결정이고, 그 결정이 도는 자리는 다른 구현이다.
  • 중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다 이 사례가 그 확인 절차를 요구한 형태다.
  • @Bean이 있다는 것은 조립 증거가 아니다 빈은 등록되고 호출자는 0 이다.

문제

전체 트랜잭션 재시도 코디네이터가 조건부 빈으로 생성된다.

그것을 주입받아 호출하는 프로덕션 코드가 있는지, 없다면 실제로 도는 재시도가 무엇인지 확인했다.

결론

호출자는 없다. 그리고 재시도는 다른 구현으로 돈다.

먼저 삭제부터다. 선언적 재시도 애너테이션과 그 인터셉터가 지워졌는데, 사고가 아니다. 부트스트랩의 트랜잭션 팩토리 자바독에는 애너테이션이 어댑터 리프에 있어서 애플리케이션 코어가 그것을 붙이려면 바깥 어댑터를 임포트해야 하고, 그러면 이 아키텍처가 세운 의존 방향이 뒤집힌다고 적혀 있다.

같은 문단의 마지막 문장이 틀렸다. 정식 경계가 포트의 한 메서드이고 아래의 재시도 코디네이터가 그것을 구현한다고 적는데, 코디네이터의 선언은 아무 인터페이스도 구현하지 않는다.

그 포트를 구현하는 것은 같은 패키지의 다른 클래스다. 그 클래스는 컴포넌트로 등록되고, 재시도 루프도 그 안에 있다. 시도를 세고, 재시도 여부를 묻고, 백오프로 쉬고, 다시 돈다.

그 루프에는 게이트가 있다. 정책이 하나로 좁혀져 있고, 물리 소유자여야 하고, 시도 수가 최대치 미만이어야 하고, 스레드가 인터럽트되지 않아야 한다.

두 구현이 읽는 값도 다르다. 조립된 쪽의 시도 수와 지연은 설정에서 오고, 기본이 2회에 10 밀리초부터 50 밀리초까지다. 코디네이터는 부트스트랩이 상수로 넘긴 30초 경과 상한과 프로파일에서 만든 정책을 받는다.

지원 매트릭스는 전체 트랜잭션 재시도를 안정 등급으로 선언한다. 선언이 가리키는 구현과 도는 구현이 다르다.

호출자가 없다는 것은 여섯 경로를 모두 확인했다. main 에서 이 타입을 언급하는 파일은 넷이고, 셋은 만드는 쪽이며 하나는 자바독에서 이름만 부른다. 파라미터로 받는 선언도, 공급자로 꺼내는 곳도, 컨테이너에서 꺼내는 곳도, 소스 밖 설정에서 부르는 곳도 0 이다. 인터페이스를 구현하지 않으니 상위 타입을 통해 닿을 길도 없다.

지워진 인터셉터 이름을 건드린 커밋은 넷이다. 하나가 설계 문서에 이름을 넣었고, 하나가 인터셉터와 그 테스트를 더했고, 하루 뒤 909 파일을 건드린 커밋이 둘 다 지웠다. 지운 커밋의 제목에는 삭제가 드러나지 않는다.

어드바이스를 얹을 자리가 없어서가 아니다. 같은 트리의 배출기와 정리기가 이미 트랜잭션 애너테이션으로 프록시되고, 인바운드 쪽은 이 애플리케이션이 직접 쓴 어드바이저를 갖고 있다. 빠진 것은 경로가 아니라 코디네이터를 그 경로에 얹는 클래스 하나다.

판정은 P1 이다. 안정 등급으로 선언한 능력의 구현이 도달 불가이고, 그 자리에서 실제로 도는 것은 다른 값과 좁은 정책을 가진 다른 구현이다.

검증 환경

OpenJDK : 21.0.12 확인 방식 : 타입 참조 전수 확인, 포트 구현체 추적, 커밋 이력 검색 소스 수정 : x

재현 조건

  1. 삭제를 설명하는 자바독 문단을 읽는다.
  2. 그 문단이 지목한 클래스의 선언에 구현 절이 있는지 본다.
  3. 그 포트를 구현한다고 선언한 클래스를 저장소 전체에서 찾는다.
  4. 그 클래스가 어떻게 등록되는지, 그 안의 재시도 루프와 게이트를 읽는다.
  5. 두 구현이 읽는 값을 나란히 본다.
  6. main 에서 코디네이터를 언급하는 파일을 전수로 센다.
  7. 파라미터·공급자·컨테이너 조회·소스 밖 참조를 각각 센다.
  8. 지원 매트릭스가 이 능력을 어떤 등급으로 선언하는지 확인한다.
  9. 지워진 인터셉터 이름을 경로 제한 없이 이력에서 찾는다.
  10. 같은 트리에 어드바이스가 이미 도는지 확인한다.

본문

선언적 재시도 애너테이션이 지워졌고, 그 자리에 이유를 적은 문단이 남아 있다.

삭제는 사고가 아니었다

:::evidence key="a-retry-implementation-nobody-calls" alt="삭제를 설명하는 자바독 문단과 그 문단이 정식 경계 구현체로 지목한 클래스의 선언, 그 포트를 실제로 구현한다고 선언한 클래스와 그 등록 애너테이션, 조립된 쪽의 재시도 루프와 그 게이트, 두 구현이 읽는 값, main 에서 코디네이터를 언급하는 파일 전부와 파라미터·공급자·컨테이너 조회·소스 밖 참조의 수, 지원 매트릭스가 이 능력에 매긴 등급, 지워진 인터셉터 이름을 건드린 커밋 전부, 그리고 같은 트리에서 이미 도는 어드바이스를 출력한 터미널 기록." caption="자바독은 코디네이터가 정식 경계를 구현한다고 적지만 그 선언에는 구현 절이 없다 · 포트를 구현하는 것은 컴포넌트로 등록된 다른 클래스이고 재시도 루프도 그 클래스에 있다 · 코디네이터를 파라미터·공급자·컨테이너 조회·소스 밖에서 부르는 곳은 전부 0 · 지원 매트릭스는 이 능력을 Stable 로 선언한다 — 84줄 · exit 0" zoom="true" :::

 * <p>There is no declarative retry annotation any more. {@code @RetryableJpaTransaction} lived in
 * the persistence leaf and documented itself as something an application service would put on its
 * own methods — which application-core cannot do without importing an outbound adapter and
 * inverting the dependency this architecture is built on. The canonical boundary is {@code
 * PolicyTransactionPort.inTransaction(TransactionRequest, Supplier)}; the retry coordinator below
 * is what implements it, not a second way to ask for the same thing.

의존 방향 때문이라는 설명이다. 마지막 문장이 문제다.

    30:public final class FullTransactionRetryCoordinator {

구현 절이 없다. 이 클래스는 그 포트를 구현하지 않는다.

그 포트를 구현하는 쪽

    adapter/outbound/persistence/transaction/SpringTransactionPort.java:31:public class SpringTransactionPort implements PolicyTransactionPort {
    30:@Component

같은 패키지에 있고 컴포넌트로 등록된다. 재시도 루프도 그 클래스 안에 있다.

int attempt = 1;
while (true) {
  AttemptResult<T> attemptResult = executeOnce(request, action, policy);
  if (!shouldRetry(request.policyId(), attemptResult, attempt)) {
    return attemptResult.result();
  }
  if (!retryBackoff.pauseBeforeRetry(request.callBudget(), attempt)) {
    return attemptResult.result();
  }
  attempt++;
}

게이트가 좁다.

if (policyId != TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE
    || !attemptResult.physicalOwner()
    || attempt >= retryBackoff.maximumAttempts()
    || Thread.currentThread().isInterrupted()) {
  return false;

정책 하나에서만 재시도한다.

두 구현이 읽는 값

    retryBaseDelay = defaulted(retryBaseDelay, Duration.ofMillis(10), "retry-base-delay");
    retryMaximumDelay = defaulted(retryMaximumDelay, Duration.ofMillis(50), "retry-maximum-delay");
    retryMaximumAttempts = retryMaximumAttempts == null ? 2 : retryMaximumAttempts;

도는 쪽은 설정에서 읽는다. 기본 2회에 10 밀리초부터 50 밀리초다.

    43:  public static final Duration DEFAULT_MAX_RETRY_ELAPSED = Duration.ofSeconds(30);
    110:        executor, policy, retrySleeper(), clock, DEFAULT_MAX_RETRY_ELAPSED, listener);

코디네이터는 부트스트랩이 넘긴 상수와 프로파일에서 만든 정책을 받는다. 두 값이 만난 적이 없다.

부르는 코드가 없다

    bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java:170:  public FullTransactionRetryCoordinator jpaRetryCoordinator(
    bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:99:  public FullTransactionRetryCoordinator retryCoordinator(
    bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:109:    return new FullTransactionRetryCoordinator(
    adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java:21: * <p>This class does not retry. Retry lives in {@link FullTransactionRetryCoordinator}, which calls

만드는 쪽 셋과 자바독 하나다. 받는 쪽은 이렇다.

    FullTransactionRetryCoordinator 를 파라미터로 받는 선언 : 0
    ObjectProvider<FullTransactionRetryCoordinator> : 0
    getBean 으로 이 타입을 꺼내는 곳 : 0
    소스 밖에서 이 이름이 나오는 곳 : 0

상위 타입으로 도달할 수도 없다. 구현한 인터페이스가 없기 때문이다.

선언은 안정 등급이다

    83:| Full-transaction retry | Stable |

지워진 이름

    e98b56eb feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링
    2f5d2fc2 feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가
    0e61f86e feat(jpa): implement the JPA relational persistence platform
    3b5aee50 feat: 설계 문서 추가

0e61f86e 가 인터셉터와 그 테스트를 더했고, 하루 뒤 909 파일을 건드린 2f5d2fc2 가 둘 다 지웠다. 그 커밋의 제목은 구현체 추가다.

어드바이스를 쓴 사람이 없다

    adapter/outbound/persistence/outbox/OutboxReaper.java:38:  @Transactional
    6:import org.springframework.aop.Advisor;
    50:  static Advisor requiresPermissionAuthorizationAdvisor(
    55:    Pointcut onMethod = AnnotationMatchingPointcut.forMethodAnnotation(RequiresPermission.class);

같은 트리의 배출기가 이미 프록시되고, 인바운드 쪽에는 이 애플리케이션이 직접 쓴 어드바이저가 있다. AOP 가 없어서가 아니다. 빠진 것은 코디네이터를 어떤 메서드에 얹는 클래스 하나다.

확인하지 못한 것

조건은 세 겹이다. 루트 자동설정의 활성화 속성은 기본값이 없어 명시하지 않으면 꺼져 있고, 그 안에서 애드온 속성은 기본 켜짐이며, 마지막으로 빈 자체가 트랜잭션 실행기 빈과 빈 부재 조건을 요구한다. 실제 배포 컨텍스트를 부팅해 이 세 겹을 통과시키지는 않았다.

참조 계수는 이름 기반 정적 검색이므로 리플렉션으로 조립되는 경로까지는 배제하지 못했다.