# messaging-admin-runtime 완전 해부 > 상태: COMPLETE > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/messaging/messaging-admin-runtime` > SSOT owner: `messaging-admin-runtime` > integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지와 숫자 지도 - registered leaf id: `messaging-admin-runtime` - canonical state `analysisFile`: `analysis/messaging/messaging-admin-runtime.md` - source path: `src/messaging/messaging-admin-runtime` - registry `allowed_dependencies`: `["messaging-core-api", "messaging-policy", "messaging-admin-api", "messaging-transport-spi", "messaging-security", "messaging-observability"]` - registry `runtime_memberships`: **`["app-bootstrap"]`** — 배포된다 ### 숫자 | 항목 | 수 | |---|---:| | production Java 파일 | 12 | | test Java 파일 | 6 | | 전체 LOC | 2,304 (main 1,313 / test 991) | | 패키지 | 1 (`dev.caskeleton.messaging.admin.runtime`) | | test 메서드(실행 확인) | **51** (`EVD-309`) | | 선언된 의존 | project 6 (전부 `api`) | | **실제 import되는 의존** | **project 3** — policy·transport-spi·security 는 0건 (§12.4) | | leaf 밖에서 import 하는 파일 | 3 (starter 2 + kafka IT 1) | 12개 production 타입: | 타입 | 종류 | 역할 | src/main 생성 | |---|---|---|---:| | `MessagingAdminService` | interface | 비파괴 admin 표면 (5메서드) | — | | `DefaultMessagingAdminService` | class 257줄 | **유일한 오케스트레이터** | **0** | | `DestructiveMessagingAdmin` | interface | purge/delete/offset-reset | **구현 0** | | `ReplayService` | class | 리플레이 실행 | **0** | | `RedriveService` | class 210줄 | 리드라이브 실행 | 0 (test 1) | | `ReplayReport` / `RedriveReport` | record | 실행 1회 결과 | — | | `BrokerTopologyInspector` | interface | 브로커 토폴로지 읽기 SPI | — | | `CompositeTopologyValidator` | class | 매니페스트 전건 비교 (Stack A) | 1 (스타터) | | `TopologyValidator` | class | 1건 비교 + severity 판정 | 1 (내부 필드) | | `TopologyValidationRuntime` | class | **두 번째 토폴로지 스택 (Stack B)** | **0** | | `InMemoryAdminOperationJournal` | class 237줄 | 단일 프로세스 저널 | 1 (스타터) | ### Coverage ledger | scope/file group | count | disposition | reason | |---|---:|---|---| | `src/main/java/**` (12) | 12 | `FULL_READ` | 전 파일 본문 확인 | | `src/test/java/**` (6) | 6 | `FULL_READ` | 51개 테스트 본문·대역 구현 확인 | | `build.gradle` | 1 | `FULL_READ` | 10줄 | | 하류/상류 (starter, outbox-jdbc, admin-api) | — | `STRUCTURAL_ONLY` | 도달성·계약 대조에 필요한 범위. SSOT 는 각 리프 소유 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체와 경계 `messaging-admin-api` 가 정의한 타입들을 **실제로 실행하는 계층**이다. 계획을 세우고, 저널에 자리를 잡고, 옮기고, 결과를 보고한다. 구조는 세 층이다. 1. **오케스트레이션** — `MessagingAdminService` / `DefaultMessagingAdminService`. 계획·승인·저널·실행을 잇는다. 2. **실행** — `ReplayService`, `RedriveService`. 각각 하나의 작업을 수행하며, 브로커 접촉은 SPI(`ReplayExecutor`, `RedriveSource`, `RedrivePublisher`)로 밀어낸다. 3. **토폴로지·저널** — `CompositeTopologyValidator`+`TopologyValidator`, `TopologyValidationRuntime`, `InMemoryAdminOperationJournal`. 경계 밖: 브로커 클라이언트가 없다. Kafka·Rabbit 어느 것도 import 하지 않고, 모든 브로커 접촉이 함수형 인터페이스 뒤에 있다. Spring 도 없다 — 배선은 전부 starter 몫이다. 읽고 나서 남는 인상은 두 가지로 갈린다. **개별 부품은 대단히 정교하다** — 저널의 펜싱 프로토콜, 리드라이브 루프의 per-item 경계, 토폴로지 severity 판정은 각각 실패 사례를 겪고 나온 코드로 보이며 그 근거가 주석에 있다. 반면 **부품을 잇는 층은 실행된 적이 없다** — §12.1 에서 보듯 `DefaultMessagingAdminService` 는 프로덕션에서도 테스트에서도 인스턴스화되지 않는다. --- ## 2. 의존성과 런타임 배선 ```groovy // messaging-admin-runtime/build.gradle 전문 apply plugin: 'java-library' dependencies { api project(':messaging:messaging-core-api') api project(':messaging:messaging-policy') api project(':messaging:messaging-admin-api') api project(':messaging:messaging-transport-spi') api project(':messaging:messaging-security') api project(':messaging:messaging-observability') } ``` 실측 import (`EVD-308`): | 선언 | 패키지 | import | 판정 | |---|---|---:|---| | `messaging-admin-api` | `…messaging.admin` | 45 | O | | `messaging-core-api` | `…messaging.api` | 6 | O | | `messaging-observability` | `…messaging.observation` | 1 | O | | `messaging-policy` | `…messaging.policy` | **0** | X | | `messaging-transport-spi` | `…messaging.transport` | **0** | X | | `messaging-security` | `…messaging.security` | **0** | X | 6개 중 3개가 미사용이다. 지금까지 본 리프 중 가장 많다. 배선은 starter 한 곳뿐이고, 이 리프에서 빈이 되는 것은 **둘**이다(`EVD-307`). ```java // MessagingAdminAutoConfiguration.java (@ConditionalOnProperty app.messaging.admin.enabled=true) :57 return new InMemoryAdminOperationJournal(); :84 return new CompositeTopologyValidator(inspector); // @ConditionalOnBean(BrokerTopologyInspector) ``` `MessagingAdminService`, `ReplayService`, `RedriveService`, `DestructiveMessagingAdmin` — 넷 다 빈이 없다. starter 는 그중 하나에 대해서만 이유를 밝힌다. ```java // MessagingAdminAutoConfiguration.java:22-24 *
{@link …DestructiveMessagingAdmin} is deliberately absent from this class. No bean for it is
* ever auto-configured: an operator tool that needs purge or delete registers one itself, with an
* admin credential this runtime does not hold.
```
나머지 셋의 부재에 대한 설명은 어디에도 없다.
---
## 3. 패키지/컴포넌트 지도
단일 패키지. 의존 방향이 한 곳에서 어긋난다.
```
MessagingAdminService (interface)
^
| implements
DefaultMessagingAdminService ──────┐
| |
| uses | uses
v v
ReplayService RedriveService
| |
| ReplayExecutor | RedriveSource / RedrivePublisher
v v
[브로커 — 이 리프 밖] [DLQ — 이 리프 밖]
ReplayService.audit : RedriveService.AuditSink <-- 형제의 중첩 타입에 의존 (§12.3(b))
```
토폴로지 쪽은 **두 개의 완전히 분리된 스택**이 나란히 있다(§12.3(a)).
```
Stack A: BrokerTopologyInspector -> CompositeTopologyValidator -> TopologyValidator
-> List Execution runs four checks, in this order, and the order is the point.
*
* The journal entry is written before the work rather than after it. Writing it
* afterwards leaves a window where a second execution starts while the first is still running,
* which is precisely the double-redrive the journal exists to prevent.
*
* Writing it first used to have a cost the previous store never paid: an operation that died
* halfway had consumed its approval and left no record of how far it got. The journal keeps a
* checkpoint and hands back a lease that says where to resume, so a retry continues the same
* operation instead of either redoing it or requiring a new approval.
*/
```
코드가 그 순서를 지킨다.
```java
// executeRedrive, :174-203 (executeReplay 도 동형)
plan.requireExecutable(now, inspector.topologyVersion()); // 검사 1·2
AdminOperationLease lease = journal.begin(…); // 검사 3
Instant startedAt = clock.get();
try {
report = redriveService.redrive(…, lease.resumeFrom(), completed -> journal.checkpoint(…));
} catch (RuntimeException failure) {
journal.fail(lease, lease.resumeFrom(), failureCodeOf(failure), clock.get());
throw failure;
}
journal.complete(lease, report.moved() + report.failed(), clock.get());
```
실패 경로의 근거도 있다.
```java
// :143-148
} catch (RuntimeException failure) {
// The operation stays resumable rather than silently consuming the approval: the journal
// entry moves to FAILED at its checkpoint, and a retry takes it over from there.
```
저널에 들어가는 실패 코드는 정제된다.
```java
// :214-227
/**
* The journal is read by operators during incidents and its contents outlive the process. A
* raw exception message can carry a destination, a payload fragment, or a credential from a
* driver's own error text, so only the platform's own code or the exception's simple name goes in.
*/
private static String failureCodeOf(RuntimeException failure) {
if (failure instanceof …MessagingException messaging) { return messaging.failure().code(); }
return failure.getClass().getSimpleName();
}
```
리스 길이 선택에도 근거가 붙어 있다.
```java
// :45-51
/**
* Long enough that a slow batch does not lose its lease mid-flight, short enough that a dead
* replica does not park an approval for an hour.
*/
private static final Duration LEASE_DURATION = Duration.ofMinutes(5);
```
**리플레이와 리드라이브의 비대칭이 하나 있다.** 리드라이브는 `lease.resumeFrom()` 과 체크포인트 콜백을 실행 측에 넘기지만, 리플레이는 넘기지 않는다.
```java
// :140-142 executeReplay
report = replayService.replay(request, Optional.of(plan.approval()), plan.approval().approvedBy(), now);
```
`ReplayService.replay(...)` 시그니처에 `resumeFrom` 이 없다(`ReplayService.java:53-54`). 즉 리플레이는 리스를 받지만 재개하지 않는다 — 죽으면 처음부터 다시 읽는다. 클래스 javadoc 의 "a retry continues the same operation instead of either redoing it" 은 리드라이브에만 해당한다.
### 4.2 `RedriveService` — per-item 경계와 `finally` 감사
세 가지 실패를 고쳤다고 javadoc 이 적는다.
```java
// RedriveService.java:67-84
/**
* Three things were wrong with running this as a plain loop. A synchronous failure from the
* publisher — a broker that refuses the connection rather than the message — propagated out of
* the loop, so the remaining candidates were never attempted and the audit record was never
* written: the operation left no trace of the items it had already moved. A retry then started
* from the first candidate and republished them. And nothing bounded how many times one message
* could be redriven.
*/
```
세 수정이 코드에 있다.
```java
// :142-156 (1) 한 건의 예외는 한 건의 실패지 패스 전체의 실패가 아니다
private boolean attempt(MessageId messageId, RedriveRequest request) {
try { result = publisher.republish(…); }
catch (RuntimeException failure) {
// One message that cannot be republished is a failed item, not a failed pass. Letting it
// propagate abandoned every candidate behind it.
return false;
}
if (result.completion() != PublishCompletion.CONFIRMED) { return false; }
source.settle(request.source(), messageId); // 확인된 것만 정산
return true;
}
// :121-137 (2) 감사 기록은 finally 에서
} finally {
// In the finally block on purpose: an operation that dies partway must still leave a record
// of what it moved, because that record is what the resumed attempt and the incident review
// both read.
audit.record(new MessagingAuditEvent("REDRIVE", subject, …));
}
```
발행 → 확인 → 정산 순서가 이 리프의 핵심 불변식이다.
```java
// :19-21
/**
* A redrive is a publish followed by a settlement, in that order, exactly like dead lettering in
* reverse. A message whose republish did not confirm stays in the dead letter destination: losing
* it on the way back would be the one outcome worse than leaving it parked.
*/
```
**세 번째 수정 — 재개 — 는 인덱스 계산이 틀렸다.** §12.1(a)에서 상술한다.
### 4.3 `ReplayService` — 안전한 형태를 공짜로 만든다
```java
// ReplayService.java:13-22
/**
* An isolated replay reads alongside the live consumer and needs no approval, because it changes
* nothing: a throwaway group has its own offsets. Replaying into an existing production group is a
* different operation entirely — it rewinds a live consumer and reprocesses everything since — so
* it goes through the destructive guard.
*
* Making the safe form free and the destructive form approved is what keeps operators from
* reaching for the destructive one out of convenience.
*/
```
판단은 옳다. 구현이 그 판단을 `dryRun` 파라미터로 표현한다.
```java
// :58-64
boolean needsApproval = !request.isolatedConsumerGroup();
guard.authorize(
DestructiveOperation.REPLAY,
request.destination(),
approval,
request.dryRun() || !needsApproval, // <- guard 의 dryRun 인자
now);
```
`DestructiveOperationGuard.authorize` 는 `dryRun` 이 참이면 즉시 반환한다(`DestructiveOperationGuard.java:54-56`). 즉 격리 리플레이는 "승인 불필요" 가 아니라 "dry run 인 척" 으로 통과한다. 감사 이벤트는 그 구분을 남긴다 — `approval.map(VerifiedApproval::ticket).orElse("isolated")`(`:77`) — 그러나 guard 쪽에는 남지 않는다. §17 P3.
### 4.4 `InMemoryAdminOperationJournal` — 프로토콜이 단순화되지 않았다
```java
// InMemoryAdminOperationJournal.java:17-27
/**
* A single-process journal, for tests and for local development.
*
* It reports {@link #isDurable()} as false, and the starter refuses to run a production profile
* on a journal that says so. That declaration is the point of this class existing at all: the
* previous in-memory store was registered as the production default and nothing distinguished it
* from a shared one, so the gap was invisible until two replicas executed the same approval.
*
* The semantics are otherwise the real ones — uniqueness on {@code (ticket, digest)}, lease
* takeover with a monotonic token, resume from checkpoint — so a test that passes here is testing
* the protocol rather than a simplification of it.
*/
```
마지막 문장이 지켜지는지가 이 클래스의 값어치다. 확인 결과 지켜진다.
`begin` 의 `claim(...)` 이 네 갈래다(`:64-115`).
| 기존 상태 | 처리 | 코드 |
|---|---|---|
| 없음 | 새 record, token=1, itemsCompleted=0 | `:72-85` |
| `COMPLETED` | `APPROVAL_ALREADY_EXECUTED` — "an approval authorises one execution, not a standing permission" | `:86-92` |
| `STARTED` + 리스 유효 | `ADMIN_OPERATION_IN_FLIGHT` — "two runtimes executing one approval is a duplicate storm, not a faster redrive" | `:93-100` |
| `FAILED` 또는 리스 만료 | 체크포인트 유지, **token+1** 로 인수 | `:101-114` |
펜스는 `update(...)` 에 있다.
```java
// :206-214
if (current.leaseToken() != lease.leaseToken()) {
// The fence. A stalled runtime that wakes up and writes here would otherwise overwrite
// the progress of whichever replica took the operation over.
throw new MessageAuthorizationException("ADMIN_OPERATION_LEASE_LOST", …);
}
```
그리고 키 생성이 `ApprovalGrant.canonicalForm()` 의 규칙을 그대로 가져온다.
```java
// :219-225
private static String key(String approvalTicket, PlanDigest planDigest) {
// Length-prefixed for the same reason the grant's canonical form is: a ticket containing the
// separator must not be able to collide with a different ticket and digest pair.
return String.join("", Integer.toString(approvalTicket.length()), ":", approvalTicket,
planDigest.value());
}
```
길이 접두 규칙이 `messaging-admin-api` 밖으로 전파된 사례다. (그 규칙이 **닿지 않은** 유일한 곳이 계획 다이제스트라는 점은 `analysis/messaging/messaging-admin-api.md` §12.3(b)에 있다.)
`checkpoint`/`complete`/`fail` 셋 다 `Math.max(current.itemsCompleted(), itemsCompleted)` 로 clamp 한다(`:128, :147, :167`). 이것이 `DefaultMessagingAdminService` 가 `journal.fail(lease, lease.resumeFrom(), …)` 로 **낡은 값**을 넘겨도 진행이 되돌아가지 않는 이유다. §12.4(c).
### 4.5 `TopologyValidator` — severity 가 판단이다
```java
// TopologyValidator.java:11-22
/**
* Which discrepancies block is a judgement encoded here rather than left to configuration.
* Replication factor and absence are blocking because a destination that is missing or unreplicated
* cannot deliver the durability its profile promises. A partition count that is higher
* than declared is advisory rather than blocking: extra partitions do not break durability, and
* someone scaling a topic up deliberately should not be met with a refusal to start.
*
* A partition count that is lower is blocking, because it silently reduces the
* concurrency the destination was sized for and, on a keyed topic, changes which key lands where.
*/
```
| 조건 | severity | 코드 |
|---|---|---|
| 목적지 부재 | BLOCKING (그리고 즉시 반환) | `:39-43` |
| `physicalName` 불일치 | BLOCKING | `:45-49` |
| 파티션 < 선언 | BLOCKING | `:51-57` |
| 파티션 > 선언 | **ADVISORY** | `:58-66` |
| 복제 계수 < 선언 | BLOCKING | `:68-75` |
| 필수 설정 불일치/부재 | BLOCKING (`"unset"`) | `:77-87` |
부재 시 즉시 반환하는 것도 옳다 — 없는 목적지의 파티션 수를 보고할 이유가 없다.
### 4.6 `DestructiveMessagingAdmin` — 분리가 곧 통제
```java
// DestructiveMessagingAdmin.java:10-20
/**
* The operations that destroy data an application cannot recreate.
*
* A separate interface from {@link MessagingAdminService}, and no bean for it is ever registered
* in an application runtime. The separation is the control: an application that never receives this
* type cannot purge a topic even if every other guard is bypassed, because the method does not
* exist on anything it holds.
*
* Each operation takes an {@link Approved} argument rather than an approval parameter, so the
* authorisation cannot be forgotten at a call site — there is no way to call these without one.
*/
```
첫 문단의 논리는 견고하다. 두 번째 문단이 문제다 — `Approved` 가 담는 것은 `VerifiedApproval` 이 아니라 평범한 `AdminApproval` 이다. §17 P2.
---
## 5. 주요 실행 경로
**경로 A — 리드라이브 (설계상 의도된 흐름)**
```
DefaultMessagingAdminService.executeRedrive(ApprovedRedrivePlan)
1) plan.requireExecutable(now, inspector.topologyVersion())
승인 윈도우 / 승인 토폴로지 / 계획 토폴로지 / 루프 승인
2) journal.begin(ticket, digest, redriveId, leaseOwner, 5분, now)
-> COMPLETED 면 거절, 유효 리스 있으면 거절, 아니면 token+1 로 인수
-> AdminOperationLease(resumeFrom = 이전 체크포인트)
3) redriveService.redrive(request, approval, subject, now, resumeFrom, checkpoint)
guard.authorize(REDRIVE, source, approval, dryRun, now)
candidates = source.peek(source, batchSize)
for m in candidates.subList(resumeFrom, end):
republish -> CONFIRMED 면 settle, 아니면 failed++
completed++ ; checkpoint(completed) -> journal.checkpoint(...)
finally: audit.record(...)
4) journal.complete(lease, moved + failed, now)
5) RedriveResult(candidates, moved, stillParked=failed, elapsed, dryRun)
```
**경로 B — 토폴로지 검증**
Stack A 는 `validateTopology()` 로 진입해 보고서를 돌려준다. 그 보고서로 `requireAcceptable()` 을 부르는 코드는 없다. Stack B 는 `validate(...)` 안에서 직접 던진다. 둘 다 프로덕션 진입점이 없다(`EVD-307`).
**경로 C — 파괴적 작업**
없다. `DestructiveMessagingAdmin` 구현체가 0건이므로 `PURGE`·`OFFSET_RESET`·`DELETE_DESTINATION` 은 이 저장소에 실행 경로가 없다.
---
## 6. 실패 경로와 복구/번역
| 상황 | 처리 | 위치 |
|---|---|---|
| 발행이 예외를 던짐 | 그 한 건만 실패 처리, 루프 계속 | `RedriveService:146-150` |
| 발행이 CONFIRMED 아님 | 실패 처리, 정산하지 않음 → DLQ 잔류 | `RedriveService:151-153` |
| 실행 중 예외 | `journal.fail(...)` 후 재던짐 → 재개 가능 상태 | `DefaultMessagingAdminService:143-148` |
| 예외 메시지 | 코드 또는 클래스 단순명만 저널에 | `:222-227` |
| 승인 이미 소진 | `APPROVAL_ALREADY_EXECUTED` | `InMemory…:86-92` |
| 다른 런타임이 실행 중 | `ADMIN_OPERATION_IN_FLIGHT` | `:93-100` |
| 리스 상실 후 쓰기 | `ADMIN_OPERATION_LEASE_LOST` | `:206-214` |
| 저널 항목 없음 | `ADMIN_OPERATION_NOT_JOURNALLED` | `:201-205` |
| 토폴로지 불일치 (A) | `MessagingConfigurationException("TOPOLOGY_MISMATCH")` | `TopologyValidationReport:78` |
| 토폴로지 불일치 (B) | `MessageTopologyException("TOPOLOGY_MISMATCH")` | `TopologyValidationRuntime:54` |
마지막 두 줄이 §12.3(a)의 요약이다 — 같은 코드 문자열, 다른 예외 타입, 다른 판정 규칙.
`attempt(...)` 가 모든 `RuntimeException` 을 삼키는 것은 근거가 있지만 대가도 있다: 실패 사유가 어디에도 남지 않는다. 감사 이벤트는 `failed` 개수만 담고(`:135`), 어떤 메시지가 왜 실패했는지는 기록되지 않는다.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 경계 없음 — `InMemoryAdminOperationJournal` 은 `ConcurrentHashMap.compute(...)` 로 키 단위 원자성을 얻는다(`:44, :198`). `begin` 의 검사-후-갱신 전체가 `compute` 람다 안에 있어 두 복제본이 동시에 `begin` 해도 하나만 성공한다. `AdminOperationJournalTest.twoReplicasRacingProduceExactlyOneLease` 가 그것을 검증한다.
펜싱 토큰은 세 지점에서 동작한다: 인수 시 `existing.leaseToken() + 1`(`:110`), 쓰기 시 토큰 대조(`:206`), 그리고 clamp 로 인한 단조성(`:128, :147, :167`). `aRuntimeThatLostItsLeaseCannotWriteOverTheSuccessor` 가 세 가지를 한 번에 확인한다 — 낡은 리스의 `complete(30)` 이 거절되고 기록은 45·STARTED 로 남는다.
`RedriveService`·`ReplayService`·`DefaultMessagingAdminService` 는 모두 불변 필드만 갖는다. `clock` 을 `Supplier An unaccounted message is a bug, not a partial success: it was neither republished nor left
* parked, which means the redrive lost track of it.
*/
public boolean isFullyAccounted() { return moved + stillParked == candidates; }
```
위 시나리오는 `2 + 0 == 4` → `false`. 정확히 이 결함을 잡는다. 그러나 `isFullyAccounted()` 의 프로덕션 호출부는 0건이다(`EVD-302`). 아무도 묻지 않는다.
**(b) 오케스트레이션 계층이 어디에서도 생성되지 않는다** (`EVD-307`)
```
DefaultMessagingAdminService src/main=0 src/test=0
ReplayService src/main=0 src/test=0
RedriveService src/main=0 src/test=1
TopologyValidationRuntime src/main=0 src/test=4
CompositeTopologyValidator src/main=1 src/test=1
InMemoryAdminOperationJournal src/main=1 src/test=3
```
`src/main` 생성은 전 저장소에서 2건뿐이며 둘 다 starter 다(`:57`, `:84`).
`DefaultMessagingAdminService` 는 이 리프에서 가장 큰 클래스이고 "검사 순서가 요점" 이라고 스스로 말하는 클래스인데, 그 순서가 한 번도 실행된 적이 없다.
**(c) `DestructiveMessagingAdmin` 은 구현체가 0건이다**
```
git grep -n "DestructiveMessagingAdmin" -- src
DestructiveMessagingAdmin.java:21 (선언)
MessagingAdminService.java:21 ({@link} 참조)
MessagingAdminAutoConfiguration.java:22 ({@link} 참조)
git grep -n "DestructiveMessagingAdmin.Approved|new Approved(|DestructiveResult" -- src
(선언 파일 제외 후 출력 없음)
```
`DestructiveOperation` 5개 상수 중 `PURGE`·`OFFSET_RESET`·`DELETE_DESTINATION` 세 개는 이 저장소에 실행 경로가 없다. starter 가 그 부재를 의도로 설명하지만("an operator tool … registers one itself"), 그 도구는 이 저장소에 없다.
**(d) 여덟 개 SPI 전부 프로덕션 구현 0건.** §9 표.
### 12.2 Conditional sibling comparison
**대조군 1 — 리플레이 vs 리드라이브의 재개.** 리드라이브는 `resumeFrom` + 체크포인트 콜백을 받고, 리플레이는 받지 않는다(§4.1). 둘 다 같은 저널을 쓰고 같은 리스를 받는다. 리플레이가 재개되지 않는 이유를 설명하는 문장은 없다. 리플레이가 본질적으로 멱등(같은 구간을 다시 읽음)이라 재개가 불필요하다는 해석은 가능하나, 그렇다면 리스를 받는 이유가 설명되지 않는다.
**대조군 2 — 두 개의 저널 구현.** `InMemoryAdminOperationJournal`(`Math.max`)과 `JdbcAdminOperationJournal`(`GREATEST`)이 **독립적으로 같은 clamp 를 구현했다**. 인터페이스는 그것을 요구하지 않는다. §12.4(c).
**대조군 3 — `MessagingAuditSink` vs `RedriveService.AuditSink`.** 시그니처가 동일한 두 인터페이스. 전자는 "모든 기록이 `MessagingRedactor` 를 통과했다" 는 계약을 갖고, 후자는 갖지 않는다. §12.3(b).
### 12.3 Duplicate mechanism sweep
**(a) 토폴로지 검증 스택 2벌 — 판정이 어긋난다** (`EVD-307`)
| 항목 | Stack A (`CompositeTopologyValidator`+`TopologyValidator`) | Stack B (`TopologyValidationRuntime`) |
|---|---|---|
| 입력 SPI | `BrokerTopologyInspector` | `TopologyReader` |
| 비교 로직 | `TopologyValidator.compare` | `TopologyManifest.differencesFrom` |
| 결과 타입 | `List The approved-plan types used to hold a plain {@code AdminApproval} record with a public
* constructor, so "this plan was approved" was a claim the caller made about itself. Any code that
* could reach the execute method could write {@code new AdminApproval("TICKET-1", "someone", now,
* later)} and the platform believed it.
```
수정은 `REPLAY`·`REDRIVE`(복구 가능한 작업)에 적용되었고, `PURGE`·`DELETE_DESTINATION`·`OFFSET_RESET`(복구 불가능한 작업)에는 적용되지 않았다.
현재 구현체가 0건이라 실행되는 결함은 아니다(`EVD-307`). 그러나 이 인터페이스는 운영자 도구가 구현하라고 존재하는 것이고, 그 도구가 생기는 순간의 모양이 이것이다. `Approved` 를 `ApprovedReplayPlan` 과 같은 형태로 — `VerifiedApproval` + 생성자 검사 — 바꾸는 것이 맞다.
### P2 — 토폴로지 검증 스택이 두 벌이고 판정이 어긋난다
Stack A 는 파티션 스케일업을 ADVISORY 로 두어 기동을 허용하고 그 근거를 명시한다. Stack B 는 같은 상황을 차이로 보고 기동을 거부한다. 둘 다 프로덕션 호출부가 0건이라 지금은 충돌하지 않지만, `analysis/messaging/messaging-admin-api.md` §17 첫 항목대로 토폴로지 검증을 기동에 배선하는 순간 **어느 스택을 배선하느냐가 스케일업한 배포의 기동 여부를 가른다**.
Stack A 가 남아야 할 것으로 보인다 — severity 구분, `physicalName` 검사, 근거 주석이 있고 테스트도 13건으로 더 두껍다. Stack B(`TopologyValidationRuntime`, `TopologyReader`, `ObservedTopology`, 그리고 그것만 쓰는 `TopologyManifest.differencesFrom`)를 제거하는 편이 낫다.
같은 코드 문자열 `TOPOLOGY_MISMATCH` 를 두 예외 타입이 쓰는 것도 정리 대상이다.
### P2 — 오케스트레이터가 어디에서도 실행되지 않는다
`DefaultMessagingAdminService` 257줄과 `ReplayService` 99줄이 프로덕션에서도 테스트에서도 인스턴스화되지 않는다(`EVD-307`). 검사 순서·저널 시퀀스·실패 시 재던짐·실패 코드 정제가 전부 미검증이다.
`DefaultMessagingAdminService` 의 생성자는 10개 인자를 받고 그중 8개가 SPI 또는 `Supplier` 이므로, 대역으로 조립하는 테스트를 쓰는 비용은 낮다. §12.1(a)의 회귀 테스트도 이 층에서 쓰는 것이 자연스럽다 — 저널·리스·리드라이브 루프가 함께 도는 것이 결함이 나타나는 조건이기 때문이다.
### P3 — public 인터페이스를 패키지 밖에서 구현할 수 없다
`RedriveEstimator`(public)의 반환 타입 `RedriveEstimate` 가 package-private 이다(`EVD-308`). `DefaultMessagingAdminService` 의 public 생성자가 그 인터페이스를 요구하므로, 외부 조립이 불가능하다.
`RedriveEstimate` 를 public 으로 올리는 것이 최소 수정이다. 더 나은 방향은 `DefaultMessagingAdminService` 밖의 최상위 record 로 꺼내는 것 — 지금은 오케스트레이터의 내부 타입이 SPI 계약의 일부가 되어 있다.
### P3 — 감사 싱크가 중복 선언되어 있고 레닥션 계약이 유실된다
`RedriveService.AuditSink` 는 `MessagingAuditSink` 와 시그니처가 같다. admin-runtime 은 이미 `messaging-observability` 를 의존한다. 표준 싱크를 쓰면 세 가지가 함께 해결된다: `ReplayService` 가 형제의 중첩 타입에 의존하는 것, `InMemory` 구현 재작성, 그리고 무엇보다 **"모든 기록이 `MessagingRedactor` 를 통과했다" 는 계약**.
현재 `RedriveService:125-136` 은 목적지 이름과 details 를 그대로 넣는다. 목적지 이름은 `DestinationName` 이라 형식이 제한되어 있어 지금은 문제가 아니지만, 계약이 없는 자리에 값이 늘어나는 것을 막을 것이 없다.
### P3 — 저널의 `itemsCompleted` 단조성이 인터페이스 계약에 없다
`AdminOperationJournal` javadoc 은 구현 의무 셋을 명시하면서 이것을 빠뜨렸고, `fail` 의 `@param` 은 오히려 문자 그대로 저장하라고 읽힌다. 유일한 호출자는 낡은 값을 넘긴다. 두 구현이 각각 clamp 해서 무사한 상태다(`EVD-308`).
두 가지 중 하나가 필요하다. 인터페이스 javadoc 에 "`itemsCompleted` 는 단조 증가해야 하며 구현은 기존 값보다 작은 값을 무시한다" 를 명시하거나, 호출자가 실제 체크포인트 값을 넘기도록 고친다. 후자가 더 정직하다 — 지금 `journal.fail(lease, lease.resumeFrom(), …)` 은 "이번 시도가 아무것도 못 했다" 고 주장하는 것이고, 그것은 대개 사실이 아니다.
### P3 — 리플레이가 리스를 받지만 재개하지 않는다
`executeReplay` 는 `journal.begin(...)` 으로 리스를 받고 `lease.resumeFrom()` 을 쓰지 않는다. `ReplayService.replay(...)` 시그니처에 재개 지점이 없고 체크포인트 콜백도 없다. 클래스 javadoc 의 "a retry continues the same operation" 은 리드라이브에만 해당한다.
리플레이가 재개 불필요하다면(같은 구간을 다시 읽는 것이 멱등이므로) 그 근거를 적고, 저널 사용을 "중복 실행 방지" 로만 한정하는 것이 낫다. 재개가 필요하다면 리드라이브와 같은 형태로 맞춘다.
### P3 — 격리 리플레이의 guard 우회가 `dryRun` 파라미터로 표현된다
```java
// ReplayService.java:58-64
guard.authorize(REPLAY, request.destination(), approval, request.dryRun() || !needsApproval, now);
```
판단 자체는 근거가 있다. 다만 "승인이 필요 없다" 와 "실제로는 아무것도 하지 않는다" 가 guard 입장에서 구별되지 않는다. `DestructiveOperationGuard` 에 `skipAuthorization` 성격의 별도 경로를 두거나, 격리 리플레이는 애초에 guard 를 거치지 않는 편이 의도를 드러낸다.
### P3 — 선언된 의존 6개 중 3개가 import 0건
`messaging-policy`, `messaging-transport-spi`, `messaging-security`. 제거 후보.
### P3 — 실패한 리드라이브 항목의 사유가 어디에도 남지 않는다
`attempt(...)` 는 예외와 미확인을 모두 `false` 로 접는다(`RedriveService:142-156`). 감사 이벤트는 `failed` 개수만 담는다(`:135`). 사건 복구 중에 "왜 이 메시지들이 안 갔는가" 를 물을 수 있어야 하는데 답이 없다. `RedriveReport` 에 실패 사유별 집계(코드 → 개수) 정도만 추가해도 크게 달라진다.
### 확인된 설계(문제 아님)
- **저널을 작업 전에 쓰고, 리스·펜싱 토큰·체크포인트로 분산 실행을 통제하는 프로토콜.** `begin` 의 네 갈래, `update` 의 토큰 대조, 인수 시 토큰 증가가 전부 근거와 함께 있고 테스트 8건이 확인한다.
- **`compute(...)` 로 검사-후-갱신을 원자화한 것.** 두 복제본 경쟁이 정확히 하나의 리스를 낳는다.
- **저널 키의 길이 접두.** `ApprovalGrant.canonicalForm()` 의 규칙을 명시적으로 인용해 가져왔다.
- **실패 코드 정제.** 저널이 사건 중에 읽히고 프로세스보다 오래 산다는 이유가 명시적이다.
- **리드라이브 루프의 per-item 경계.** 한 건의 예외가 뒤의 후보를 버리지 않는다.
- **감사 기록을 `finally` 에 둔 것.** 중단된 작업도 흔적을 남긴다.
- **발행→확인→정산 순서.** 미확인 메시지는 DLQ 에 남는다 — "돌아오는 길에 잃는 것이 주차된 채로 두는 것보다 나쁘다".
- **격리 리플레이를 무료로 둔 것.** 안전한 형태를 편하게 만들어 파괴적 형태로 손이 가지 않게 한다.
- **`isDurable()` 선언 + starter 의 기동 거부.** 이 리프에서 배선까지 완료된 유일한 안전 장치.
- **in-memory 저널이 프로토콜을 단순화하지 않은 것.** 여기서 통과한 테스트가 프로토콜을 검증한다는 주장이 실제로 성립한다.
- **토폴로지 severity 판정을 설정이 아니라 코드에 둔 것**, 그리고 각 판정에 근거를 붙인 것.
- **부재 시 즉시 반환.** 없는 목적지의 파티션 수를 보고하지 않는다.
- **파괴적 작업을 별도 인터페이스로 분리하고 빈을 만들지 않는 것.** 타입을 받지 못한 코드는 메서드 자체가 없다.
- **`BrokerTopologyInspector` 를 읽기 전용으로 둔 것.**
- **`topologyVersion` 을 파싱하지 않는 불투명 값으로 규정한 것.**
- **시간을 `Supplier
*
*
*