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

59 KiB

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. 의존성과 런타임 배선

// 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).

// MessagingAdminAutoConfiguration.java  (@ConditionalOnProperty app.messaging.admin.enabled=true)
:57  return new InMemoryAdminOperationJournal();
:84  return new CompositeTopologyValidator(inspector);   // @ConditionalOnBean(BrokerTopologyInspector)

MessagingAdminService, ReplayService, RedriveService, DestructiveMessagingAdmin — 넷 다 빈이 없다. starter 는 그중 하나에 대해서만 이유를 밝힌다.

// 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 — 검사 순서가 요점이다

// 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.
 */

코드가 그 순서를 지킨다.

// 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());

실패 경로의 근거도 있다.

// :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.

저널에 들어가는 실패 코드는 정제된다.

// :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();
}

리스 길이 선택에도 근거가 붙어 있다.

// :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() 과 체크포인트 콜백을 실행 측에 넘기지만, 리플레이는 넘기지 않는다.

// :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 이 적는다.

// 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.
 */

세 수정이 코드에 있다.

// :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, ));
}

발행 → 확인 → 정산 순서가 이 리프의 핵심 불변식이다.

// :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 — 안전한 형태를 공짜로 만든다

// 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 파라미터로 표현한다.

// :58-64
boolean needsApproval = !request.isolatedConsumerGroup();
guard.authorize(
    DestructiveOperation.REPLAY,
    request.destination(),
    approval,
    request.dryRun() || !needsApproval,     // <- guard 의 dryRun 인자
    now);

DestructiveOperationGuard.authorizedryRun 이 참이면 즉시 반환한다(DestructiveOperationGuard.java:54-56). 즉 격리 리플레이는 "승인 불필요" 가 아니라 "dry run 인 척" 으로 통과한다. 감사 이벤트는 그 구분을 남긴다 — approval.map(VerifiedApproval::ticket).orElse("isolated")(:77) — 그러나 guard 쪽에는 남지 않는다. §17 P3.

4.4 InMemoryAdminOperationJournal — 프로토콜이 단순화되지 않았다

// 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.
 */

마지막 문장이 지켜지는지가 이 클래스의 값어치다. 확인 결과 지켜진다.

beginclaim(...) 이 네 갈래다(: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(...) 에 있다.

// :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() 의 규칙을 그대로 가져온다.

// :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). 이것이 DefaultMessagingAdminServicejournal.fail(lease, lease.resumeFrom(), …)낡은 값을 넘겨도 진행이 되돌아가지 않는 이유다. §12.4(c).

4.5 TopologyValidator — severity 가 판단이다

// 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 — 분리가 곧 통제

// 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. 트랜잭션·동시성·수명주기

트랜잭션 경계 없음 — InMemoryAdminOperationJournalConcurrentHashMap.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 는 모두 불변 필드만 갖는다. clockSupplier<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-tasks51 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건이 이 리프의 클래스를 거치지 않는다. ApprovalForgeryTestApprovedPlanExecutionTestmessaging-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 한 목록의 인덱스로 쓰인다.

// 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). 그러나 completedmoved + 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 로 거절된다.

플랫폼은 이것을 감지할 술어를 이미 갖고 있다.

// 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 == 4false. 정확히 이 결함을 잡는다. 그러나 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

파티션 스케일업 판정이 정반대이며, 양쪽 다 자기 테스트에서 확인된다.

// 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 의 differencesFromactualPartitions != partitions 로 방향을 구분하지 않는다(TopologyManifest.java:57). TopologyValidationRuntimeTest 4건은 스케일업을 시도하지 않아 불일치가 드러나지 않는다.

(b) 감사 싱크 인터페이스 2벌 (EVD-308)

// 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).
  • MessagingAuditSinkInMemory 구현을 제공하는데(:33-55), RedriveResumptionTestRecordingAudit 를 다시 만든다(: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 인스턴스가 CompositeTopologyValidatorprivate final 필드로 고정되어 있다.

// 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)

// 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 를 이름으로 써야 하고, 그 타입은 패키지 밖에서 접근할 수 없다. 컴파일은 통과한다.

대조: 같은 파일의 ReplayEstimatorlong 을 반환하므로 외부 구현이 가능하다.

현재 드러나지 않는 이유는 §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".

그런데 유일한 호출자가 낡은 값을 넘긴다.

// DefaultMessagingAdminService.java:146, :200
journal.fail(lease, lease.resumeFrom(), failureCodeOf(failure), clock.get());

lease.resumeFrom()이번 시도가 시작될 때의 값이다. 이번 시도의 체크포인트로 올라간 값이 아니다. 진행이 되돌아가지 않는 것은 두 구현이 각각 clamp 하기 때문이다.

// 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.ApprovedVerifiedApproval 이 아니라 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.AuditSinkMessagingAuditSink 대신 선언한 이유. 의존 순서 문제로 보이지는 않는다 — 이미 그 모듈을 의존한다.
  • 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. completedmoved.size() 로 바꾸고 실패분은 세지 않는다. 그러면 resumeFrom 이 "사라진 개수" 와 일치하므로 새 peek 의 인덱스로 유효해진다. 다만 실패분을 반복해서 재시도하게 되므로, 리드라이브 횟수 상한(javadoc :75 가 언급하는 "nothing bounded how many times one message could be redriven")이 함께 필요하다.

어느 쪽이든 RedriveService.RedriveSource 대역이 settlestaged 에서 제거하도록 고쳐야 회귀 테스트가 성립한다. 현재 대역은 실제 불변식을 재현하지 못한다.

// 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 — 파괴적 작업의 승인만 위조 가능한 형태로 남아 있다

// DestructiveMessagingAdmin.java:23-38
record Approved(
    DestructiveOperation operation,
    DestinationName destination,
    AdminApproval approval,            // <- public 생성자를 가진 평범한 record
    long estimatedMessagesAffected) {  }

생성자는 null·음수만 본다. approval 이 이 operation 을 인가하는지, 이 destination 을 인가하는지, estimatedMessagesAffected 가 승인 상한 이하인지 — 아무것도 검사하지 않는다. 계획 다이제스트 필드 자체가 없다.

이 형태가 정확히 messaging-admin-api 가 고쳤다고 기록한 것이다.

// 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). 그러나 이 인터페이스는 운영자 도구가 구현하라고 존재하는 것이고, 그 도구가 생기는 순간의 모양이 이것이다. ApprovedApprovedReplayPlan 과 같은 형태로 — 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.AuditSinkMessagingAuditSink 와 시그니처가 같다. 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 — 리플레이가 리스를 받지만 재개하지 않는다

executeReplayjournal.begin(...) 으로 리스를 받고 lease.resumeFrom() 을 쓰지 않는다. ReplayService.replay(...) 시그니처에 재개 지점이 없고 체크포인트 콜백도 없다. 클래스 javadoc 의 "a retry continues the same operation" 은 리드라이브에만 해당한다.

리플레이가 재개 불필요하다면(같은 구간을 다시 읽는 것이 멱등이므로) 그 근거를 적고, 저널 사용을 "중복 실행 방지" 로만 한정하는 것이 낫다. 재개가 필요하다면 리드라이브와 같은 형태로 맞춘다.

P3 — 격리 리플레이의 guard 우회가 dryRun 파라미터로 표현된다

// ReplayService.java:58-64
guard.authorize(REPLAY, request.destination(), approval, request.dryRun() || !needsApproval, now);

판단 자체는 근거가 있다. 다만 "승인이 필요 없다" 와 "실제로는 아무것도 하지 않는다" 가 guard 입장에서 구별되지 않는다. DestructiveOperationGuardskipAuthorization 성격의 별도 경로를 두거나, 격리 리플레이는 애초에 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