--- 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. 같은 트리에 어드바이스가 이미 도는지 확인한다. ## 본문 선언적 재시도 애너테이션이 지워졌고, 그 자리에 이유를 적은 문단이 남아 있다. ## 삭제는 사고가 아니었다 :::evidence key="a-retry-implementation-nobody-calls" alt="삭제를 설명하는 자바독 문단과 그 문단이 정식 경계 구현체로 지목한 클래스의 선언, 그 포트를 실제로 구현한다고 선언한 클래스와 그 등록 애너테이션, 조립된 쪽의 재시도 루프와 그 게이트, 두 구현이 읽는 값, main 에서 코디네이터를 언급하는 파일 전부와 파라미터·공급자·컨테이너 조회·소스 밖 참조의 수, 지원 매트릭스가 이 능력에 매긴 등급, 지워진 인터셉터 이름을 건드린 커밋 전부, 그리고 같은 트리에서 이미 도는 어드바이스를 출력한 터미널 기록." caption="자바독은 코디네이터가 정식 경계를 구현한다고 적지만 그 선언에는 구현 절이 없다 · 포트를 구현하는 것은 컴포넌트로 등록된 다른 클래스이고 재시도 루프도 그 클래스에 있다 · 코디네이터를 파라미터·공급자·컨테이너 조회·소스 밖에서 부르는 곳은 전부 0 · 지원 매트릭스는 이 능력을 Stable 로 선언한다 — 84줄 · exit 0" zoom="true" ::: ```text *
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 This class does not retry. Retry lives in {@link FullTransactionRetryCoordinator}, which calls
```
만드는 쪽 셋과 자바독 하나다. 받는 쪽은 이렇다.
```text
FullTransactionRetryCoordinator 를 파라미터로 받는 선언 : 0
ObjectProvider