Files
document-haness/docs/clean-architecture-backend-template/analysis/messaging/messaging-admin-runtime.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

1021 lines
59 KiB
Markdown

# 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
* <p>{@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<TopologyIssue> (severity) -> TopologyValidationReport.requireAcceptable()
-> MessagingConfigurationException("TOPOLOGY_MISMATCH")
Stack B: TopologyValidationRuntime.TopologyReader -> TopologyValidationRuntime.validate(...)
-> TopologyManifest.differencesFrom() -> List<String>
-> MessageTopologyException("TOPOLOGY_MISMATCH") (직접 throw)
```
---
## 4. 계약·불변식·상태 모델
### 4.1 `DefaultMessagingAdminService` — 검사 순서가 요점이다
```java
// DefaultMessagingAdminService.java:22-42
/**
* Wires plan, approval, and execution together for the non-destructive admin operations.
*
* <p>Execution runs four checks, in this order, and the order is the point.
*
* <ol>
* <li>The approval is still inside its window.
* <li>The topology has not changed since the plan was approved.
* <li>The approval has not already been executed.
* <li>Only then does anything move.
* </ol>
*
* <p>The journal entry is written <em>before</em> 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.
*
* <p>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
/**
* <p>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
/**
* <p>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
/**
* <p>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
/**
* <p>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
/**
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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
/**
* <p>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 <em>higher</em>
* 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.
*
* <p>A partition count that is <em>lower</em> 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.
*
* <p>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.
*
* <p>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<Instant>` 로 주입받아 시간도 외부화되어 있다.
수명주기 훅 없음. 이 리프의 어떤 클래스도 `InitializingBean`·`SmartLifecycle` 을 구현하지 않는다 — 이것이 §17 첫 항목의 직접 원인이다.
---
## 8. 설정·기능 플래그·환경 차이
이 리프 자체에는 설정이 없다. 상수 하나가 코드에 고정되어 있다.
| 값 | 위치 | 근거 |
|---|---|---|
| `LEASE_DURATION = 5분` | `DefaultMessagingAdminService:51` | javadoc `:47-49` |
| `MAX_BATCH = 100` | (admin-api `RedriveRequest:24`) | — |
리스 5분은 프로퍼티가 아니다. 근거는 명시적이지만("느린 배치가 리스를 잃지 않을 만큼 길고, 죽은 복제본이 승인을 한 시간 묶어두지 않을 만큼 짧게"), 배치 크기·브로커 지연에 따라 달라질 값을 조정할 수단이 없다.
---
## 9. 퍼시스턴스/외부 시스템 세부
직접 접점 없음. 전부 SPI 뒤에 있다.
| SPI | 구현 (프로덕션) | 구현 (테스트) |
|---|---|---|
| `BrokerTopologyInspector` | **0** — 애플리케이션이 제공해야 함 | `TopologyValidatorTest:133` 익명 1 |
| `ReplayService.ReplayExecutor` | **0** | 0 |
| `RedriveService.RedriveSource` | **0** | `RecordingSource` 1 |
| `RedriveService.RedrivePublisher` | **0** | 람다 4 |
| `RedriveService.AuditSink` | **0** | `RecordingAudit` 1 |
| `TopologyValidationRuntime.TopologyReader` | **0** | 람다 4 |
| `DefaultMessagingAdminService.ReplayEstimator` | **0** | 0 |
| `DefaultMessagingAdminService.RedriveEstimator` | **0** | 0 — 그리고 패키지 밖에서는 구현 불가 (§12.4(a)) |
여덟 개 SPI 전부 프로덕션 구현이 0이다. `AdminOperationJournal` 만이 예외로, `JdbcAdminOperationJournal`(outbox-jdbc-postgresql)과 `InMemoryAdminOperationJournal` 둘을 갖는다.
---
## 10. 테스트 레인과 실제 증명 범위
`EVD-309`: `./gradlew :messaging:messaging-admin-runtime:test --rerun-tasks`**51 tests, 0 failures, 0 skipped**.
| 클래스 | 수 | 실제 겨냥 대상 |
|---|---:|---|
| `TopologyValidatorTest` | 13 | `TopologyValidator`(6) · `TopologyValidationReport`(2) · `CompositeTopologyValidator`(1) · **`TopologyManagementMode`(3, admin-api 소유)** |
| `ApprovedPlanExecutionTest` | 11 | **전부 admin-api 타입** (`Approved*Plan`, `*Result`, `*Plan.describeImpact`) |
| `ApprovalForgeryTest` | 10 | **전부 admin-api 타입** (`VerifiedApproval`, `HmacApprovalVerifier`, `ApprovalGrant`) |
| `AdminOperationJournalTest` | 8 | `InMemoryAdminOperationJournal` |
| `RedriveResumptionTest` | 5 | `RedriveService` |
| `TopologyValidationRuntimeTest` | 4 | `TopologyValidationRuntime` |
**51건 중 21건이 이 리프의 클래스를 거치지 않는다.** `ApprovalForgeryTest``ApprovedPlanExecutionTest``messaging-admin-api` 의 타입을 직접 조립해 검증한다. 이는 admin-api 문서 §10에서 본 것의 반대쪽 면이다 — 그 리프의 불변식이 여기서 검증되고, 여기의 오케스트레이터는 검증되지 않는다.
증명되지 않는 것:
- **`DefaultMessagingAdminService` 257줄 — 인스턴스화하는 테스트 0건**(`EVD-307`). 검사 순서, 저널 begin/checkpoint/fail 시퀀스, 실패 시 재던짐, `failureCodeOf` 정제 — 전부 미실행.
- **`ReplayService` 99줄 — 인스턴스화 0건.** 격리 리플레이의 guard 우회, 감사 이벤트 구성, dry run 조기 반환 전부 미실행.
- **`RedriveService` 의 실패+재개 교집합**(§12.1(a)).
- **Stack A 와 Stack B 의 파티션 스케일업 불일치** — 양쪽이 각자의 테스트에서 반대 결과를 내는데, 그 대비를 확인하는 테스트가 없다(§12.3(a)).
컨테이너 레인 없음. `JdbcAdminOperationJournal` 의 Postgres IT 는 다른 리프 소유이며 이 세션에서 실행하지 않았다.
---
## 11. 빌드/ArchUnit/CI 강제 지점
`build.gradle` 10줄. 이 리프 고유의 게이트는 없다. 루트 공통 게이트만 적용된다.
주목: **`RedriveEstimate` 의 접근성 문제를 잡는 게이트가 없다.** public 인터페이스가 package-private 타입을 반환하는 것은 Java 가 허용하고 Checkstyle·SpotBugs·ErrorProne 기본 설정 어느 것도 기본으로 잡지 않는다. ErrorProne 에 관련 검사가 있으나 활성화되어 있지 않다.
---
## 12. 실제 사용 여부와 negative-space probes
### 12.1 Public surface reachability
**(a) [P1] 재개된 리드라이브가 옮기지 못한 메시지를 건너뛴다** (`EVD-306`)
`resumeFrom` 은 매 시도마다 **새로 peek 한 목록**의 인덱스로 쓰인다.
```java
// RedriveService.java:100, 107-120
List<MessageId> candidates = source.peek(request.source(), request.batchSize());
int completed = resumeFrom;
for (MessageId messageId :
candidates.subList(Math.min(resumeFrom, candidates.size()), candidates.size())) {
if (attempt(messageId, request)) { moved.add(messageId); } else { failed++; }
completed++; // 성공·실패 양쪽에서 증가
checkpoint.accept(completed);
}
```
주석은 `// Everything before resumeFrom was moved and settled by the previous attempt.` 이라고 쓴다(`:109-110`). 그러나 `completed``moved + failed` 다. 실패분은 `settle` 되지 않아 DLQ 에 남고, 다음 `peek` 결과에 그대로 포함된다. 성공분만 사라진다.
구체적 시나리오:
```
DLQ = [m1, m2, m3, m4, m5]
1차: peek -> [m1..m5]
m1 CONFIRMED -> settle (DLQ 에서 제거) completed=1, checkpoint(1)
m2 미확인 -> failed++ (DLQ 잔류) completed=2, checkpoint(2)
프로세스 사망. 저널 itemsCompleted = 2
2차: lease.resumeFrom = 2
peek -> [m2, m3, m4, m5] (m1 만 사라짐)
subList(min(2,4), 4) = [m4, m5]
-> m2(실패했던 것), m3(시도조차 안 된 것)을 영구히 건너뛴다
m4, m5 성공. RedriveReport(candidates=4, moved=2, failed=0)
journal.complete(lease, 2, now) -> COMPLETED, 승인 소진
```
운영자에게는 성공으로 보이고, m2·m3 는 DLQ 에 남으며, 어떤 기록도 그 둘을 지목하지 않는다. 승인이 소진되었으므로 재실행은 `APPROVAL_ALREADY_EXECUTED` 로 거절된다.
**플랫폼은 이것을 감지할 술어를 이미 갖고 있다.**
```java
// messaging-admin-api/RedriveResult.java:40-50
/**
* <p>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<TopologyIssue>` (severity) | `List<String>` |
| **파티션 > 선언** | **ADVISORY — 기동 허용** | **차이 → 기동 거부** |
| `physicalName` 검사 | O (BLOCKING) | X |
| 부재 처리 | BLOCKING issue | `"… does not exist"` 문자열 |
| 실패 방식 | 보고서 반환 → `requireAcceptable()` | `validate(...)` 안에서 직접 throw |
| 예외 타입 | `MessagingConfigurationException` | `MessageTopologyException` |
| 코드 문자열 | `TOPOLOGY_MISMATCH` | `TOPOLOGY_MISMATCH` |
| 프로덕션 호출부 | **0** | **0** |
파티션 스케일업 판정이 정반대이며, **양쪽 다 자기 테스트에서 확인된다**.
```java
// TopologyValidatorTest.java:63-72 (Stack A)
void extraPartitionsAreAdvisoryBecauseScalingUpIsLegitimate() {
List<TopologyIssue> issues = validator.compare(manifest(), observed(24, 3, )); // 선언 12
assertThat(issues).singleElement()
.satisfies(issue -> assertThat(issue.severity()).isEqualTo(TopologyIssue.Severity.ADVISORY));
}
// TopologyValidatorTest.java:118-127
void anAdvisoryOnlyReportStillStarts() {
assertThatCode(report::requireAcceptable).doesNotThrowAnyException();
}
```
Stack A 의 판단에는 근거가 명시되어 있다(`TopologyValidator.java:59` — "Scaling a topic up is a legitimate operation; refusing to start would punish it"). Stack B 의 `differencesFrom``actualPartitions != partitions` 로 방향을 구분하지 않는다(`TopologyManifest.java:57`). `TopologyValidationRuntimeTest` 4건은 스케일업을 시도하지 않아 불일치가 드러나지 않는다.
**(b) 감사 싱크 인터페이스 2벌** (`EVD-308`)
```java
// messaging-observability/MessagingAuditSink.java:18-25
public interface MessagingAuditSink { void record(MessagingAuditEvent event); }
// RedriveService.java:199-209
public interface AuditSink { void record(observation.MessagingAuditEvent event); }
```
시그니처도 이벤트 타입도 같다. admin-runtime 은 이미 `messaging-observability` 를 의존하며 그 모듈에서 `MessagingAuditEvent` 를 import 한다(`RedriveService:126`). 즉 표준 싱크를 쓸 수 있는데 중첩 인터페이스를 새로 선언했다.
파생 결과 셋:
- `ReplayService` 가 형제 서비스의 중첩 타입에 의존한다 — `private final RedriveService.AuditSink audit;`(`ReplayService:28`).
- `MessagingAuditSink``InMemory` 구현을 제공하는데(`:33-55`), `RedriveResumptionTest``RecordingAudit` 를 다시 만든다(`:203-210`).
- 계약이 하나 유실된다. `MessagingAuditSink` javadoc: *"Every record has already passed `MessagingRedactor`, so an audit trail proves who did what without becoming a second copy of the payload."* `RedriveService.AuditSink` 에는 그런 서술이 없고, `RedriveService:125-136` 은 목적지 이름과 details 를 레닥션 없이 넣는다.
**(c) `TopologyValidator` 인스턴스가 `CompositeTopologyValidator``private final` 필드로 고정되어 있다.**
```java
// CompositeTopologyValidator.java:22
private final TopologyValidator validator = new TopologyValidator();
```
주입이 아니라 생성이다. `TopologyValidator` 가 상태 없는 순수 비교기이므로 실질 문제는 없으나, severity 판정을 교체하려면 이 클래스를 고쳐야 한다 — "which discrepancies block is a judgement encoded here rather than left to configuration"(`TopologyValidator:14`)와 일관된 선택이다.
### 12.4 Documentation / measured-count drift
**(a) public 인터페이스가 패키지 밖에서 구현 불가능하다** (`EVD-308`)
```java
// DefaultMessagingAdminService.java:242-256
record RedriveEstimate(int candidates, int alreadyRedriven) {} // 수식어 없음 = package-private
@FunctionalInterface
public interface RedriveEstimator { // public
RedriveEstimate estimate(RedriveRequest request); // package-private 반환 타입
}
```
생성자는 이것을 외부에서 받는다 — `public DefaultMessagingAdminService(…, RedriveEstimator, …)`(`:78-88`). 그러나 `RedriveEstimator` 를 구현하려면 `RedriveEstimate` 를 이름으로 써야 하고, 그 타입은 패키지 밖에서 접근할 수 없다. 컴파일은 통과한다.
대조: 같은 파일의 `ReplayEstimator``long` 을 반환하므로 외부 구현이 가능하다.
현재 드러나지 않는 이유는 §12.1(b) 다 — 이 생성자를 부르는 코드가 없다.
**(b) 선언된 의존 6개 중 3개가 import 0건.** `messaging-policy`, `messaging-transport-spi`, `messaging-security`. §2 표.
**(c) 저널의 단조성이 인터페이스 계약에 없다** (`EVD-308`)
`AdminOperationJournal` javadoc 은 구현 의무 셋을 명시한다 — "shared and durable", "uniqueness on `(approvalTicket, planDigest)`", "leases with a monotonic fencing token". **`itemsCompleted` 의 단조성은 그 목록에 없다.** `fail``@param` 은 오히려 반대로 읽힌다: "how many items are durably done".
그런데 유일한 호출자가 낡은 값을 넘긴다.
```java
// DefaultMessagingAdminService.java:146, :200
journal.fail(lease, lease.resumeFrom(), failureCodeOf(failure), clock.get());
```
`lease.resumeFrom()` 은 **이번 시도가 시작될 때**의 값이다. 이번 시도의 체크포인트로 올라간 값이 아니다. 진행이 되돌아가지 않는 것은 두 구현이 각각 clamp 하기 때문이다.
```java
// InMemoryAdminOperationJournal.java:128, 147, 167
Math.max(current.itemsCompleted(), itemsCompleted)
// JdbcAdminOperationJournal CHECKPOINT / SETTLE SQL
SET items_completed = GREATEST(items_completed, ?)
```
파라미터를 문자 그대로 저장하는 세 번째 구현은 이 호출자와 결합했을 때 체크포인트를 잃는다. `AdminOperationJournalTest.aCheckpointNeverMovesBackwards` 가 in-memory 구현에 대해 이 성질을 검증하지만, 그것은 구현 테스트지 계약이 아니다.
**(d) `DestructiveMessagingAdmin.Approved``VerifiedApproval` 이 아니라 `AdminApproval` 을 담는다.** §17 P2.
---
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
이 리프도 javadoc 이 이력을 대신한다. 다섯 개의 "이전에는 이랬다" 가 있고 전부 **분산 실행의 실패**를 가리킨다.
| 위치 | 기록된 과거 결함 |
|---|---|
| `DefaultMessagingAdminService:34-41` | "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…" |
| `RedriveService:70-75` | "A synchronous failure from the publisher … propagated straight out, so every candidate behind it was abandoned and the audit record was never written. … A retry then started from the first candidate and republished them." |
| `InMemoryAdminOperationJournal:20-23` | "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." |
| `AdminOperationJournalTest:19-22` | "recorded a single fact — 'this approval was claimed' — before any work happened, in a map. An operation that died halfway had spent its approval…" |
| `RedriveResumptionTest:33-36` | "The loop had no per-item boundary. … The operation left no trace of what it had already moved, and a retry started again from the first candidate and republished it." |
다섯이 하나의 이야기다: **크래시와 복제본을 고려하지 않은 admin 평면**. 고친 결과가 리스·펜싱·체크포인트·per-item 경계다.
그리고 마지막 두 항목이 §12.1(a)와 이어진다 — "재시도가 처음부터 다시 시작하는" 문제를 고치려고 `resumeFrom` 을 도입했고, 도입한 지점의 인덱스 계산이 실패분을 고려하지 않았다.
커밋 로그는 정보가 없다(4개, messaging 전체 공통).
---
## 14. 런타임·터미널 Evidence
| ID | 파일 | 내용 |
|---|---|---|
| EVD-306 | `evidence/raw/306-redrive-resume-skips-unmoved.txt` | 재개 인덱스 결함, 구체적 시나리오, 테스트 대역이 불변식을 재현하지 못하는 지점 |
| EVD-307 | `evidence/raw/307-admin-runtime-two-topology-stacks.txt` | 토폴로지 스택 2벌 대조표, 조립 탐침 전수, `DestructiveMessagingAdmin` 구현 0건 |
| EVD-308 | `evidence/raw/308-admin-runtime-api-and-dependency-defects.txt` | `RedriveEstimate` 접근성, 감사 싱크 중복, 미사용 의존 3건, 저널 단조성 계약 부재, `Approved` 의 승인 타입 |
| EVD-309 | `evidence/raw/309-messaging-admin-runtime-test-lane.txt` | 51건 통과 + 커버리지 분포 |
---
## 15. 명시적 설계 이유와 추론을 구분한 정리
**코드/주석에 명시된 것**
- 검사 순서와 그 이유 (`DefaultMessagingAdminService:25-32`).
- 저널을 작업 **전에** 쓰는 이유 (`:34-37`).
- 리스가 재개 지점을 나르는 이유 (`:38-41`).
- 리스 5분의 상하한 근거 (`:47-49`).
- 실패 시 재개 가능 상태로 남기는 이유 (`:144-145`).
- 실패 코드를 정제하는 이유 — 저널은 사건 중 운영자가 읽고 프로세스보다 오래 산다 (`:216-220`).
- 한 건의 발행 예외가 패스 전체를 죽이면 안 되는 이유 (`RedriveService:147-148`).
- 감사 기록을 `finally` 에 두는 이유 (`:122-124`).
- 발행→확인→정산 순서의 이유 (`:19-21`).
- 리드라이브 id·카운터가 메시지와 함께 이동하는 이유 (`:23-25`).
- 격리 리플레이를 무료로 두는 이유 (`ReplayService:16-22`).
- in-memory 저널이 `isDurable()==false` 를 선언하는 이유와 그 존재 이유 (`InMemoryAdminOperationJournal:20-23`).
- 프로토콜을 단순화하지 않은 이유 (`:25-27`).
- 리스 인수 시 토큰을 올리는 이유 = 펜스 (`:101-102, :207-208`).
- 저널 키를 길이 접두로 만든 이유 (`:221-222`).
- severity 판정을 코드에 두는 이유, 그리고 각 판정의 근거 (`TopologyValidator:14-22, :59`).
- 전부 모아 보고하는 이유 (`CompositeTopologyValidator:14-17`).
- `BrokerTopologyInspector` 가 읽기 전용인 이유 (`:9-12`).
- 파괴적 작업을 별도 인터페이스로 분리한 이유 (`DestructiveMessagingAdmin:13-16`).
- 토폴로지 버전이 파싱되지 않는 불투명 값인 이유 (`BrokerTopologyInspector:27-28`).
**추론 (근거는 있으나 문서에 없음)**
- 리플레이가 재개되지 않는 이유. 리플레이가 멱등이라 불필요하다는 해석이 자연스러우나, 그렇다면 리스를 받는 이유가 설명되지 않는다.
- `RedriveService.AuditSink``MessagingAuditSink` 대신 선언한 이유. 의존 순서 문제로 보이지는 않는다 — 이미 그 모듈을 의존한다.
- `TopologyValidationRuntime`(Stack B)이 남아 있는 이유. Stack A 가 나중 것으로 보이나(severity·physicalName 검사가 추가되었으므로), 그 판단을 뒷받침할 커밋 이력이 없다.
- `policy`·`transport-spi`·`security` 의존이 남아 있는 이유.
- `RedriveEstimate` 가 package-private 인 것이 의도인지 누락인지.
---
## 16. 확인한 것 / 확인하지 못한 것
**확인한 것**
- production 12 + test 6 = 18개 Java 파일 전부 본문 확인.
- 테스트 레인 51건 전건 통과, 클래스별 분포 (`EVD-309`).
- 재개 인덱스 결함과 테스트 대역이 그것을 재현할 수 없는 이유 (`EVD-306`).
- 조립 탐침 전수 — `DefaultMessagingAdminService`·`ReplayService` 생성 0건 (`EVD-307`).
- 토폴로지 두 스택의 판정 대조표, 양쪽 테스트가 반대 결과를 확인한다는 사실 (`EVD-307`).
- `DestructiveMessagingAdmin` 구현 0건, `Approved`/`DestructiveResult` 사용 0건 (`EVD-307`).
- `RedriveEstimate` 접근성, 감사 싱크 중복, 의존 3건 미사용, 저널 clamp 를 두 구현이 각각 갖는다는 사실 (`EVD-308`).
**확인하지 못한 것**
- §12.1(a)의 시나리오를 **실제로 재현하지 않았다.** 결함은 코드와 테스트 대역을 읽어 도출했고, 실패+재개를 조합하는 테스트를 작성해 관찰하지는 않았다. (문서화 작업이 애플리케이션 소스를 수정하지 않는다는 제약 때문. 재현 테스트는 코드 변경 요청이 있을 때 작성하는 것이 맞다.)
- `JdbcAdminOperationJournal` 의 실제 동작 — Postgres 컨테이너 필요, 미실행. SQL 문자열은 읽어서 `GREATEST` 를 확인했다.
- 부팅된 컨텍스트에서 `app.messaging.admin.enabled=true` 일 때의 빈 그래프 — 런타임 관측 미수행.
- `BrokerTopologyInspector` 의 실제 구현이 어떤 `topologyVersion` 문자열을 내는지 — 구현이 저장소에 없다.
- Stack B 가 언제·왜 남았는지.
---
## 17. 손볼 것
### P1 — 재개된 리드라이브가 옮기지 못한 메시지를 영구히 건너뛴다
`resumeFrom` 은 "시도한 개수"(`moved + failed`)인데, `subList` 로 건너뛰는 대상은 **매번 새로 peek 한 목록**이고 그 목록에서 사라진 것은 "성공한 것"뿐이다. 실패분과 미시도분이 앞쪽에 남아 있으므로, 건너뛰기는 정확히 그것들을 지운다(`EVD-306`).
결과: 리드라이브가 성공으로 보고되고, 승인이 소진되고, 일부 메시지가 DLQ 에 남으며, 어떤 기록도 그것들을 지목하지 않는다. 사건 복구 중에 실행되는 작업이라는 점이 심각도를 올린다.
고칠 방향은 두 가지다.
1. **인덱스 대신 신원으로 재개한다.** 저널이 개수가 아니라 이미 옮긴 `MessageId` 집합(또는 마지막 성공 위치의 브로커 오프셋)을 들고 있으면 목록이 줄어드는 것과 무관해진다. `AdminOperationRecord` 에 필드 추가가 필요하다.
2. **`completed``moved.size()` 로 바꾸고 실패분은 세지 않는다.** 그러면 `resumeFrom` 이 "사라진 개수" 와 일치하므로 새 peek 의 인덱스로 유효해진다. 다만 실패분을 반복해서 재시도하게 되므로, 리드라이브 횟수 상한(javadoc `:75` 가 언급하는 "nothing bounded how many times one message could be redriven")이 함께 필요하다.
어느 쪽이든 **`RedriveService.RedriveSource` 대역이 `settle``staged` 에서 제거하도록 고쳐야** 회귀 테스트가 성립한다. 현재 대역은 실제 불변식을 재현하지 못한다.
```java
// RedriveResumptionTest.java:192-200 — settle 이 staged 를 줄이지 않는다
@Override public List<MessageId> peek(DestinationName destination, int batchSize) { return staged; }
@Override public void settle(DestinationName destination, MessageId messageId) { settled.add(messageId); }
```
그리고 **`RedriveResult.isFullyAccounted()` 를 실제로 호출하는 곳을 만들어야 한다.** 이 결함을 잡는 술어가 이미 존재하는데 프로덕션 호출부가 0건이다(`EVD-302`). `DefaultMessagingAdminService.executeRedrive` 가 결과를 만든 직후 확인하고, 불일치면 저널에 `FAILED` 로 남기는 것이 자연스럽다.
### P2 — 파괴적 작업의 승인만 위조 가능한 형태로 남아 있다
```java
// DestructiveMessagingAdmin.java:23-38
record Approved(
DestructiveOperation operation,
DestinationName destination,
AdminApproval approval, // <- public 생성자를 가진 평범한 record
long estimatedMessagesAffected) { }
```
생성자는 null·음수만 본다. `approval` 이 이 `operation` 을 인가하는지, 이 `destination` 을 인가하는지, `estimatedMessagesAffected` 가 승인 상한 이하인지 — 아무것도 검사하지 않는다. 계획 다이제스트 필드 자체가 없다.
이 형태가 정확히 `messaging-admin-api` 가 고쳤다고 기록한 것이다.
```java
// messaging-admin-api/VerifiedApproval.java:9-13
* <p>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<Instant>` 로 외부화한 것.**
---
## Source anchors
```
src/messaging/messaging-admin-runtime/build.gradle:1-10
src/config/architecture/modules.json (messaging-admin-runtime 항목)
main/…/MessagingAdminService.java:13-24,25-65
main/…/DefaultMessagingAdminService.java:22-42,45-51,53-62,64-103,105-108,110-119,121-158,160-170,172-212,214-227,229-240,242-256
main/…/DestructiveMessagingAdmin.java:10-20,23-38,40-57,59-81
main/…/ReplayService.java:13-23,26-42,44-85,87-98
main/…/RedriveService.java:16-26,29-51,53-65,67-91,92-140,142-156,158-179,181-197,199-209
main/…/ReplayReport.java:6-20
main/…/RedriveReport.java:3-17
main/…/BrokerTopologyInspector.java:6-13,16-22,24-32
main/…/CompositeTopologyValidator.java:11-18,21-22,24-31,33-51
main/…/TopologyValidator.java:11-22,25-90
main/…/TopologyValidationRuntime.java:10-17,20-29,31-58,60-71,73-87
main/…/InMemoryAdminOperationJournal.java:17-28,33-62,64-115,117-135,137-154,156-174,176-179,181-184,186-193,195-217,219-225,227-236
test/…/AdminOperationJournalTest.java:16-23,34-57,59-90,92-112,114-125,127-136,138-144
test/…/RedriveResumptionTest.java:30-37,51-73,75-91,93-113,115-125,127-137,139-158,183-201,203-210
test/…/TopologyValidatorTest.java:22-30,32-104,106-127,129-152,154-171
test/…/TopologyValidationRuntimeTest.java:14-16,18-64
test/…/ApprovalForgeryTest.java:51,69,84,96,110,133,146,158,172,192,219
test/…/ApprovedPlanExecutionTest.java:39,121-221
src/messaging/messaging-admin-api/.../AdminOperationJournal.java:7-19,43-54,65-73
src/messaging/messaging-admin-api/.../RedriveResult.java:40-50
src/messaging/messaging-admin-api/.../TopologyManifest.java:44-75
src/messaging/messaging-admin-api/.../VerifiedApproval.java:9-13
src/messaging/messaging-admin-api/.../DestructiveOperationGuard.java:54-56
src/messaging/messaging-observability/.../MessagingAuditSink.java:7-17,18-25,33-55
src/messaging/messaging-spring-boot-starter/.../MessagingAdminAutoConfiguration.java:14-24,37-41,54-58,80-85
src/messaging/messaging-outbox-jdbc-postgresql/.../JdbcAdminOperationJournal.java:73-89,217-245
```