- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다 - 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5 (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를 techviz 로 만들었다 - 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs 돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다. Concept 이 인용한 코드가 SSOT 에 없어 뺐다 - candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
220 lines
13 KiB
Markdown
220 lines
13 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: a-retry-implementation-nobody-calls
|
|
title: 정식 경계라고 적은 클래스가 그것을 구현하지 않는다
|
|
topic: duplicate-mechanisms
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:a-retry-implementation-nobody-calls
|
|
evidenceCapturedOn: 2026-09-02
|
|
body: case-a-retry-implementation-nobody-calls.body.md
|
|
assets:
|
|
- key: a-retry-implementation-nobody-calls
|
|
file: ../../../final/evidence/rendered/a-retry-implementation-nobody-calls.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/a-retry-implementation-nobody-calls.txt
|
|
source:
|
|
- 원본 분석 절은 final/document.md#a05 의 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. 같은 트리에 어드바이스가 이미 도는지 확인한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
선언적 재시도 애너테이션이 지워졌고, 그 자리에 이유를 적은 문단이 남아 있다.
|
|
|
|
## 삭제는 사고가 아니었다
|
|
|
|
:::evidence key="a-retry-implementation-nobody-calls" alt="삭제를 설명하는 자바독 문단과 그 문단이 정식 경계 구현체로 지목한 클래스의 선언, 그 포트를 실제로 구현한다고 선언한 클래스와 그 등록 애너테이션, 조립된 쪽의 재시도 루프와 그 게이트, 두 구현이 읽는 값, main 에서 코디네이터를 언급하는 파일 전부와 파라미터·공급자·컨테이너 조회·소스 밖 참조의 수, 지원 매트릭스가 이 능력에 매긴 등급, 지워진 인터셉터 이름을 건드린 커밋 전부, 그리고 같은 트리에서 이미 도는 어드바이스를 출력한 터미널 기록." caption="자바독은 코디네이터가 정식 경계를 구현한다고 적지만 그 선언에는 구현 절이 없다 · 포트를 구현하는 것은 컴포넌트로 등록된 다른 클래스이고 재시도 루프도 그 클래스에 있다 · 코디네이터를 파라미터·공급자·컨테이너 조회·소스 밖에서 부르는 곳은 전부 0 · 지원 매트릭스는 이 능력을 Stable 로 선언한다 — 84줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
```text
|
|
* <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.
|
|
```
|
|
|
|
의존 방향 때문이라는 설명이다. 마지막 문장이 문제다.
|
|
|
|
```text
|
|
30:public final class FullTransactionRetryCoordinator {
|
|
```
|
|
|
|
구현 절이 없다. 이 클래스는 그 포트를 구현하지 않는다.
|
|
|
|
## 그 포트를 구현하는 쪽
|
|
|
|
```text
|
|
adapter/outbound/persistence/transaction/SpringTransactionPort.java:31:public class SpringTransactionPort implements PolicyTransactionPort {
|
|
30:@Component
|
|
```
|
|
|
|
같은 패키지에 있고 컴포넌트로 등록된다. 재시도 루프도 그 클래스 안에 있다.
|
|
|
|
```java
|
|
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++;
|
|
}
|
|
```
|
|
|
|
게이트가 좁다.
|
|
|
|
```java
|
|
if (policyId != TransactionPolicyId.COMMAND_SERIALIZABLE_REPLAY_SAFE
|
|
|| !attemptResult.physicalOwner()
|
|
|| attempt >= retryBackoff.maximumAttempts()
|
|
|| Thread.currentThread().isInterrupted()) {
|
|
return false;
|
|
```
|
|
|
|
정책 하나에서만 재시도한다.
|
|
|
|
## 두 구현이 읽는 값
|
|
|
|
```text
|
|
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 밀리초다.
|
|
|
|
```text
|
|
43: public static final Duration DEFAULT_MAX_RETRY_ELAPSED = Duration.ofSeconds(30);
|
|
110: executor, policy, retrySleeper(), clock, DEFAULT_MAX_RETRY_ELAPSED, listener);
|
|
```
|
|
|
|
코디네이터는 부트스트랩이 넘긴 상수와 프로파일에서 만든 정책을 받는다. 두 값이 만난 적이 없다.
|
|
|
|
## 부르는 코드가 없다
|
|
|
|
```text
|
|
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
|
|
```
|
|
|
|
만드는 쪽 셋과 자바독 하나다. 받는 쪽은 이렇다.
|
|
|
|
```text
|
|
FullTransactionRetryCoordinator 를 파라미터로 받는 선언 : 0
|
|
ObjectProvider<FullTransactionRetryCoordinator> : 0
|
|
getBean 으로 이 타입을 꺼내는 곳 : 0
|
|
소스 밖에서 이 이름이 나오는 곳 : 0
|
|
```
|
|
|
|
상위 타입으로 도달할 수도 없다. 구현한 인터페이스가 없기 때문이다.
|
|
|
|
## 선언은 안정 등급이다
|
|
|
|
```text
|
|
83:| Full-transaction retry | Stable |
|
|
```
|
|
|
|
## 지워진 이름
|
|
|
|
```text
|
|
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` 가 둘 다 지웠다. 그 커밋의 제목은 구현체 추가다.
|
|
|
|
## 어드바이스를 쓴 사람이 없다
|
|
|
|
```text
|
|
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 가 없어서가 아니다. 빠진 것은 코디네이터를 어떤 메서드에 얹는 클래스 하나다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
조건은 세 겹이다. 루트 자동설정의 활성화 속성은 기본값이 없어 명시하지 않으면 꺼져 있고, 그 안에서 애드온 속성은 기본 켜짐이며, 마지막으로 빈 자체가 트랜잭션 실행기 빈과 빈 부재 조건을 요구한다. 실제 배포 컨텍스트를 부팅해 이 세 겹을 통과시키지는 않았다.
|
|
|
|
참조 계수는 이름 기반 정적 검색이므로 리플렉션으로 조립되는 경로까지는 배제하지 못했다.
|
|
|
|
<!-- body:end -->
|