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

60 KiB

messaging-admin-api 완전 해부

상태: COMPLETE 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/messaging/messaging-admin-api SSOT owner: messaging-admin-api integration/family document: analysis/19-messaging-platform.md (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • registered leaf id: messaging-admin-api
  • canonical state analysisFile: analysis/messaging/messaging-admin-api.md
  • source path: src/messaging/messaging-admin-api
  • registry allowed_dependencies: ["messaging-core-api", "messaging-policy"]
  • registry runtime_memberships: ["app-bootstrap"] — 배포된다

숫자

항목
production Java 파일 24
test Java 파일 1
전체 LOC 1,760
패키지 1 (dev.caskeleton.messaging.admin)
test 메서드(실행 확인) 9 (EVD-305)
선언된 의존 project 2
실제 import되는 의존 project 2 (core-api O, policy X — §12.4)
leaf 밖에서 import 하는 파일 21 (4개 모듈)

24 타입을 역할로 묶으면 다섯 덩어리다.

덩어리 타입
승인 신원·서명 AdminApproval, ApprovalGrant, ApprovalVerifier, HmacApprovalVerifier, VerifiedApproval, PlanDigest 6
파괴적 작업 게이트 DestructiveOperation, DestructiveOperationGuard 2
리플레이 ReplayRequest, ReplayPlan, ApprovedReplayPlan, ReplayResult 4
리드라이브 RedriveRequest, RedrivePlan, ApprovedRedrivePlan, RedriveResult 4
실행 저널 AdminOperationJournal, AdminOperationRecord, AdminOperationLease, AdminOperationState 4
토폴로지 TopologyManifest, DestinationTopology, TopologyIssue, TopologyValidationReport, TopologyManagementMode 5

Coverage ledger

scope/file group count disposition reason
src/main/java/** (24) 24 FULL_READ 전 파일 본문 확인
src/test/java/** (1) 1 FULL_READ 147줄, 9개 테스트
build.gradle 1 FULL_READ 6줄
하류 소비자 (starter·admin-runtime) STRUCTURAL_ONLY 도달성 판정에 필요한 범위만. SSOT 는 각 리프 소유
build/** EXCLUDED 빌드 산출물

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

되돌릴 수 없는 작업을 사람의 승인에 묶는 타입 집합이다. 실행 코드는 하나도 없다 — 브로커를 만지는 것도, 메시지를 옮기는 것도 전부 messaging-admin-runtime 과 어댑터가 한다. 이 리프가 정의하는 것은 "무엇이 승인이고, 승인이 무엇을 인가하며, 인가되지 않은 것이 왜 컴파일되지 않는가" 다.

설계의 축은 하나다: 권한을 불리언이 아니라 타입으로 만든다.

// VerifiedApproval.java:6-17
/**
 * A grant that an {@link ApprovalVerifier} has checked, and that nobody else can manufacture.
 *
 * <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.
 *
 * <p>This class has no public constructor and no public factory. The only way to obtain one is to
 * present a grant and a signature to a verifier, which is exactly the property the type is for:
 * holding a {@code VerifiedApproval} is itself the evidence that verification happened.
 */

이 문단이 이 리프 전체의 요약이다. 그리고 그 위에 같은 기법이 한 층 더 쌓인다.

// ApprovedReplayPlan.java:8-13
/**
 * A replay plan that a named human has approved.
 *
 * <p>A distinct type from {@link ReplayPlan} rather than a boolean on it. The execute method takes
 * this type, so an unapproved plan cannot reach it — the authorisation is enforced by the compiler
 * instead of by a runtime check somebody can forget to write.
 */

ReplayPlanApprovedReplayPlan → 실행. 각 화살표가 타입 경계이고, 각 경계에서 검사가 생성자 안에 있어 우회 경로가 없다.

경계 밖: 이 리프는 브로커를 모른다(DestinationTopology 는 브로커가 보고한 값을 담는 record 일 뿐 조회하지 않는다), 저장소를 모른다(AdminOperationJournal 은 인터페이스), 스프링을 모른다.


2. 의존성과 런타임 배선

// messaging-admin-api/build.gradle 전문
apply plugin: 'java-library'

dependencies {
    api project(':messaging:messaging-core-api')
    api project(':messaging:messaging-policy')
}

messaging-core-api 에서 쓰는 것: DestinationName, MessageAuthorizationException, MessagingConfigurationException. messaging-policy 는 import 0건이다(§12.4).

leaf 밖 소비자 21개 파일 / 4개 모듈:

모듈 src/main src/test 역할
messaging-admin-runtime 10 6 실제 실행·검증 서비스
messaging-spring-boot-starter 2 0 빈 배선 + 저널 내구성 검사
messaging-outbox-jdbc-postgresql 1 1 JdbcAdminOperationJournal
messaging-kafka 0 1 KafkaTopologyValidationIT

부팅된 애플리케이션에서 이 리프의 타입 중 실제로 살아나는 것은 둘뿐이다(EVD-302, EVD-303).

// MessagingAdminAutoConfiguration.java (@ConditionalOnProperty app.messaging.admin.enabled=true)
:37  DestructiveOperationGuard destructiveOperationGuard()  -> new DestructiveOperationGuard(false)
:56  AdminOperationJournal adminOperationJournal()          -> new InMemoryAdminOperationJournal()
:69  MessagingAdminDurabilityValidator                     (InitializingBean)
:83  CompositeTopologyValidator compositeTopologyValidator(BrokerTopologyInspector)

MessagingAdminService 빈은 없고 ApprovalVerifier 빈도 없다. 이는 명시된 설계다.

// MessagingAdminAutoConfiguration.java:14-24
/**
 * Wires the admin plane, and only when a deployment has explicitly asked for it.
 *
 * <p>Off unless {@code app.messaging.admin.enabled=true}. An application that acquires the admin
 * plane by adding a starter to its classpath is exactly the situation the plane's guards exist to
 * prevent …
 *
 * <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.
 */

그러나 이 스탠스가 토폴로지 검증까지 덮지는 않는다 — §12.1 과 §17 의 첫 항목이 그것이다.


3. 패키지/컴포넌트 지도

단일 패키지. 신뢰 흐름은 왼쪽에서 오른쪽으로만 흐른다.

  [변경관리 시스템 = 발급자]           issuingKey 보유
        |  ApprovalGrant 조립 (approver != operator 가 생성자에서 강제)
        |  HmacApprovalVerifier.sign(grant)  ->  signature
        v
  (grant, signature)  전달
        |
        v
  [실행 런타임]  ApprovalVerifier.verify(grant, signature, executingDigest, now)
        |    - planDigest == executingDigest ?
        |    - 윈도우 열려 있나 ?
        |    - HMAC 상수시간 일치 ?
        v
  VerifiedApproval           <- 생성자 private, 팩토리 package-private
        |
        +--> ApprovedReplayPlan  / ApprovedRedrivePlan   (생성자에서 4가지 재검사)
        |        |  requireExecutable(now, currentTopologyVersion)
        |        v
        +--> DestructiveOperationGuard.authorize(...)     (6가지 검사)
                 |
                 v
             [실행 — 이 리프 밖]
                 AdminOperationJournal.begin/checkpoint/complete/fail

같은 검사가 두 자리에 나타나는 것은 중복이 아니라 의도된 이중화다(§12.3(a)).


4. 계약·불변식·상태 모델

4.1 ApprovalGrant — 서명되는 것의 전부

// ApprovalGrant.java:8-18
/**
 * Everything an issuer signs when granting one destructive operation.
 *
 * <p>{@link AdminApproval} carried a ticket, an approver, and a window. Nothing in it said which
 * operation, on which destination, against which topology, or up to what impact — so the same
 * approval authorised every plan presented inside the window, and the audit trail recorded a ticket
 * that proved nothing about what was executed.
 *
 * <p>Every field here is part of the signed canonical form, which is what makes the grant
 * non-transferable: change the target, the topology version, the impact ceiling, or the plan, and
 * the signature no longer verifies.
 */

8개 필드. 그중 requestedBy 가 생성자에서 4-eyes 를 강제한다.

// ApprovalGrant.java:50-55
if (approval.approvedBy().equals(requestedBy)) {
  // Four-eyes, expressed where it cannot be skipped: an operator approving their own
  // destructive operation is the control not existing.
  throw new IllegalArgumentException(
      "the approver and the operator must be different people; both are " + requestedBy);
}

"우회할 수 없는 자리에 표현했다" — 검사기가 아니라 record 생성자에 두었으므로, 서명 대상 객체가 존재하는 것 자체가 4-eyes 통과를 뜻한다.

canonicalForm() 은 길이 접두 인코딩이다.

// ApprovalGrant.java:61-87
/**
 * <p>Every field is length-prefixed rather than delimited. A delimiter can be smuggled into a
 * ticket or an identity to make two different grants render identically; a length prefix cannot.
 */
public String canonicalForm() {
  StringBuilder canonical = new StringBuilder("v1");
  appendField(canonical, approval.ticket());
   11개 필드 
}
private static void appendField(StringBuilder canonical, String value) {
  canonical.append(value.length()).append(':').append(value);
}

버전 접두 "v1" 이 앞에 있어 형식 교체 여지를 남긴 것도 의도적으로 보인다. 이 규칙이 계획 다이제스트 쪽에는 적용되지 않았다 — §12.3(b).

4.2 HmacApprovalVerifier — 대칭키를 고른 이유와 그 대가

// HmacApprovalVerifier.java:12-24
/**
 * Verifies grants with a shared HMAC-SHA256 issuing key.
 *
 * <p>A shared secret rather than a public-key signature is a deliberate scope choice: the issuer is
 * the change-management system operated by the same organisation as the runtime, so key
 * distribution is already solved and the property that matters — that an operator cannot mint an
 * approval for themselves — holds as long as the key is not on the operator's machine. A public-key
 * verifier can be substituted by implementing {@link ApprovalVerifier}; nothing else in the
 * platform depends on how the signature is produced.
 *
 * <p>Comparison is constant-time. A verifier that compares with {@code equals} leaks the signature
 * one byte at a time to a caller who can retry, which for an offline attacker with unlimited
 * attempts is the whole secret.
 */

세 가지가 코드로 지켜진다.

// :39-43  키 길이 하한
if (issuingKey.length < 32) {
  throw new IllegalArgumentException(
      "the approval issuing key must be at least 256 bits; a shorter key makes the signature"
          + " brute-forceable offline");
}
// :44  방어적 복사
this.issuingKey = issuingKey.clone();
// :92  상수시간 비교
if (!MessageDigest.isEqual(mac(grant.canonicalForm()), presented)) {  }

검증 순서도 의미가 있다: 다이제스트 대조 → 윈도우 → 16진 파싱 → HMAC. 다이제스트를 먼저 보는 이유가 주석에 있다.

// :71-79
if (!grant.planDigest().equals(executingDigest)) {
  // The signature would verify: this is a genuine approval, for a different plan. Replaying one
  // approval across plans is the attack the digest binding exists to stop.
  throw new MessageAuthorizationException("APPROVAL_PLAN_MISMATCH", );
}

sign(...) 이 같은 클래스에 public 으로 있고, javadoc 이 그 위험을 스스로 명시한다.

// :47-57
/**
 * Produces the signature an issuer would attach to a grant.
 *
 * <p>Present because the issuer and the verifier must agree on the canonical form exactly, and a
 * second implementation of that agreement is a second thing to drift. Holding this key is what
 * makes a caller an issuer — it is not, and must not become, available to the runtime that
 * executes operations.
 */

이 문장과 대칭키 선택이 만드는 구조적 결과가 §17 의 한 항목이다: 검증하는 쪽은 반드시 서명할 수도 있다.

4.3 DestructiveOperationGuard — 여섯 개의 검사

// DestructiveOperationGuard.java:9-19
/**
 * The single gate every destructive messaging operation passes through.
 *
 * <p>Four conditions, all required. The caller must hold the admin credential — an application
 * runtime does not, by construction. The approval must be present and inside its validity window.
 * And a dry run is always permitted, because the way to make operators plan before they act is to
 * make planning free.
 *
 * <p>Centralised so that adding a new destructive operation means adding an enum constant, not
 * remembering to re-implement the checks.
 */

실제 분기는 여섯이다.

# 조건 코드 javadoc 에 서술됨
1 dryRun → 통과 :54-56 O
2 관리자 자격증명 없음 :57-61 O
3 승인 부재 :62-67 O
4 윈도우 밖 :68-71 O
5 operation 불일치 :72-83 X
6 source 불일치 :84-93 X

5번에는 별도 인라인 주석이 있어 의도된 검사임이 분명하다.

// :72-75
if (granted.grant().operation() != operation) {
  // The approval verified — for something else. A guard that only checks presence and window
  // lets a verified redrive approval authorise a destination deletion.

"검사는 있는데 클래스 javadoc 이 세지 않는" 두 항목이, 동시에 어떤 테스트에도 도달하지 않는 두 항목이다(EVD-303, §12.1).

"dry run 은 항상 허용" 의 근거도 적혀 있다 — "the way to make operators plan before they act is to make planning free". 이것은 보안 완화가 아니라 행동 설계다.

4.4 계획 → 승인된 계획: 생성자에서 네 가지, 실행 직전에 세 가지

ApprovedReplayPlan 생성자(:19-52):

검사 실패 코드
approval.grant().planDigest() == plan.digest() APPROVAL_PLAN_MISMATCH
operation == REPLAY APPROVAL_OPERATION_MISMATCH
grant().source() == plan.request().destination() APPROVAL_SOURCE_MISMATCH
plan.estimatedMessages() <= grant().maxImpact() APPROVAL_IMPACT_EXCEEDED

requireExecutable(now, currentTopologyVersion)(:54-85):

검사 실패 코드
승인 윈도우 APPROVAL_EXPIRED
grant().topologyVersion() == 현재 TOPOLOGY_CHANGED_SINCE_APPROVAL
plan.topologyVersion() == 현재 TOPOLOGY_CHANGED_SINCE_APPROVAL

토폴로지를 두 번 보는 이유가 주석에 있다.

// ApprovedReplayPlan.java:76-84
if (!plan.topologyVersion().equals(currentTopologyVersion)) {
  // Every number in the plan was computed against the old topology, so the approver agreed to
  // an impact estimate that no longer describes what would happen.

승인이 서명된 토폴로지와 계획이 계산된 토폴로지가 다를 수 있으므로 둘 다 현재와 대조한다.

ApprovedRedrivePlan 은 같은 네 검사에 더해 loopAcknowledged 를 별도 필드로 갖는다.

// ApprovedRedrivePlan.java:8-19
/**
 * <p>Carries {@code loopAcknowledged} separately from the approval itself. Approving a redrive of
 * 900 parked messages and approving a redrive that will re-fail 400 of them are different
 * decisions, and the second one needs the approver to have seen the number …
 */

그리고 requireExecutable 의 마지막 분기가 그것을 강제한다.

// :87-93
if (plan.risksALoop() && !loopAcknowledged) {
  throw new MessageAuthorizationException(
      "REDRIVE_LOOP_NOT_ACKNOWLEDGED",
      "%d of the %d candidates already failed a previous redrive; re-running them without "
              .formatted(plan.alreadyRedrivenCandidates(), plan.candidates())
          + "fixing the cause produces a loop that looks like progress");
}

"진행처럼 보이는 루프" 는 이 리프에서 반복되는 관점이다 — 대시보드에서 옳아 보이는 실패를 타입으로 막는다.

4.5 실행 저널 — 리스와 펜싱 토큰

// AdminOperationJournal.java:7-18
/**
 * <p>The implementation this replaced was a {@code ConcurrentHashMap} registered by the starter as
 * the default. Two consequences followed, and both are worse than having no store at all because
 * the map made the platform look protected. A restart forgot every claim, so the same approval
 * could be executed again by the same process. And two replicas each had their own map, so both
 * could claim the same approval at the same moment and each believe it was the only one.
 *
 * <p>Implementations must therefore be shared and durable, must enforce uniqueness on {@code
 * (approvalTicket, planDigest)}, and must hand out leases with a monotonic fencing token so a
 * process that stalled past its lease cannot write over the replica that took over from it.
 */

키를 (approvalTicket, planDigest) 로 잡은 이유:

// AdminOperationRecord.java:9-12
/**
 * <p>Keyed by {@code (approvalTicket, planDigest)} rather than by the ticket alone, because the
 * ticket alone cannot distinguish "this approval already ran" from "this approval is being reused
 * for a different plan" — and those need opposite answers.
 */

리스가 불리언이 아니라 resumeFrom 을 나르는 이유:

// AdminOperationLease.java:8-11
/**
 * <p>{@code resumeFrom} is the whole reason a lease is handed out rather than a boolean. A retry
 * after a crash is not a new execution of the approval — it is the same operation continuing, and
 * treating it as new is what republishes the items the first attempt already moved.
 */

상태 세 개(STARTED/COMPLETED/FAILED)를 만든 이유:

// AdminOperationState.java:5-9
/**
 * <p>The store this replaces recorded one fact — "this approval was claimed" — and recorded it
 * before any work happened. A run that died halfway had consumed its approval, left no record of
 * how far it got, and offered the operator two equally bad choices: request a fresh approval and
 * redo work that may already have been done, or leave the operation half-applied.
 */

isDurable() 을 인터페이스에 둔 이유도 명시적이다 — "Declared rather than inferred so the starter can refuse to run a production profile on an in-memory journal instead of discovering the gap during an incident." 이 선언은 실제로 배선되어 있다(§12.2 의 대조군).

4.6 토폴로지 — 선언과 실측을 다른 타입으로

// DestinationTopology.java:6-11
/**
 * What the broker actually reports for one destination.
 *
 * <p>The counterpart to {@link TopologyManifest}: the manifest is what was declared, this is what
 * exists. Kept as a separate type rather than reusing the manifest so that a comparison cannot
 * accidentally compare a manifest with itself and report success.
 */

"자기 자신과 비교해서 성공을 보고하는 것" 을 타입으로 막았다 — messaging-testkit 의 인증 행렬이 고친 결함과 정확히 같은 형태이며, 여기서는 처음부터 타입으로 예방했다.

심각도를 finding 에 붙인 이유:

// TopologyIssue.java:8-12
/**
 * <p>Severity is part of the finding because the two kinds behave differently at startup. A {@link
 * Severity#BLOCKING} issue means the destination cannot deliver its declared guarantee —
 * replication factor 1 on a destination promising durability is not a warning, it is a promise the
 * platform cannot keep — so the context refuses to start. …
 */

전부 모아 보고하는 이유:

// TopologyValidationReport.java:10-12
/**
 * <p>Reports both severities together rather than failing on the first blocking issue. An operator
 * fixing a topology wants the whole list — fixing one attribute, redeploying, and discovering the
 * next one is how a ten-minute fix becomes an afternoon.
 */

두 문장 모두 "기동을 거부한다" 를 전제한다. 그 전제가 배선되지 않았다 — §12.1.


5. 주요 실행 경로

경로 A — 계획 (승인 불필요, dry run 무료)

ReplayRequest / RedriveRequest      (생성자에서 창 역전·자기참조·배치 상한 검사)
  -> [admin-runtime] 브로커 조회로 추정치 계산
  -> ReplayPlan / RedrivePlan       (topologyVersion 캡처)
  -> plan.digest()                  -> PlanDigest (SHA-256)
  -> plan.describeImpact()          -> 운영자에게 보여줄 한 줄  (프로덕션 소비자 없음, §12.1)

경로 B — 승인 발급 (이 리프 밖, 변경관리 시스템)

ApprovalGrant(approval, requestedBy, operation, source, target,
              topologyVersion, maxImpact, planDigest)   <- 생성자에서 4-eyes
  -> HmacApprovalVerifier.sign(grant)  -> hex signature

경로 C — 실행

verifier.verify(grant, signature, plan.digest(), now)      -> VerifiedApproval
  -> new ApprovedReplayPlan(plan, approval)                 (4검사)
  -> approvedPlan.requireExecutable(now, inspector.topologyVersion())   (3검사)
  -> guard.authorize(operation, destination, Optional.of(approval), dryRun, now)  (6검사)
  -> journal.begin(ticket, digest, operationId, owner, leaseDuration, now)
       -> AdminOperationLease(resumeFrom = 이전 체크포인트)
  -> [실행] … journal.checkpoint(...) 반복 … journal.complete(...) 또는 fail(...)
  -> ReplayResult / RedriveResult   (dry run 은 0 이어야 함이 생성자에서 강제)

경로 C 에서 검사가 세 지점(verify / Approved*Plan / guard)에 걸쳐 겹친다. ApprovalVerifier javadoc 이 그 이유를 설명한다.

// ApprovalVerifier.java:8-12
/**
 * <p>An implementation must check, at minimum: the issuer's signature over {@link
 * ApprovalGrant#canonicalForm()}, that the approval window is open at {@code now}, and that the
 * digest the caller is about to execute is the digest that was signed. Everything else the grant
 * binds — operation, source, target, topology version, impact ceiling, approver-versus-operator
 * separation — is inside the canonical form, so a signature check covers them all at once.
 */

즉 서명 검증이 통과하면 나머지는 이미 보장되지만, ApprovedReplayPlan 생성자와 guard 가 같은 것을 다시 본다. 방어적 중복이며 §12.3(a) 에서 다시 다룬다.


6. 실패 경로와 복구/번역

전부 MessageAuthorizationException 또는 MessagingConfigurationException 이고, 코드가 붙어 있다.

코드 던지는 곳 의미
APPROVAL_PLAN_MISMATCH HmacApprovalVerifier:74, ApprovedReplayPlan:26, ApprovedRedrivePlan:28 진짜 승인, 다른 계획
APPROVAL_EXPIRED HmacApprovalVerifier:81, DestructiveOperationGuard:69, Approved*Plan:66/71 윈도우 밖
APPROVAL_SIGNATURE_INVALID HmacApprovalVerifier:89, :93 16진 아님 / HMAC 불일치
ADMIN_CREDENTIAL_REQUIRED DestructiveOperationGuard:58 애플리케이션 런타임
APPROVAL_REQUIRED DestructiveOperationGuard:65 승인 없음
APPROVAL_OPERATION_MISMATCH DestructiveOperationGuard:75, Approved*Plan:32/34 다른 작업의 승인
APPROVAL_SOURCE_MISMATCH DestructiveOperationGuard:85, Approved*Plan:38/41 다른 목적지의 승인
APPROVAL_IMPACT_EXCEEDED Approved*Plan:47/52 승인 상한 초과
TOPOLOGY_CHANGED_SINCE_APPROVAL Approved*Plan:70/74, :79/82 추정치 무효
REDRIVE_LOOP_NOT_ACKNOWLEDGED ApprovedRedrivePlan:88 루프 위험 미승인
AUTO_CREATE_IN_PRODUCTION TopologyManagementMode:30 프로덕션 자동 생성
TOPOLOGY_MISMATCH TopologyValidationReport:78 BLOCKING 존재

메시지가 전부 "무엇이 왜 거절되었는가" 를 서술형으로 쓴다. 예:

// ApprovedRedrivePlan.java:43-49
"approval %s authorises %s to %s, not %s to %s"

IllegalArgumentException구조적으로 불가능한 값에만 쓴다 — 음수 카운터, 빈 문자열, 역전된 윈도우, 4-eyes 위반. 인가 실패와 프로그래밍 오류가 예외 타입으로 갈린다.

toString() 하나가 로그 위생을 명시적으로 다룬다.

// VerifiedApproval.java:81-87
@Override
public String toString() {
  // Deliberately not the whole grant: this string reaches logs, and the plan digest plus the
  // ticket identify the operation without restating who approved what to an audience that has
  // not been authorised to read it.
  return "VerifiedApproval[" + ticket() + " " + grant.planDigest().value().substring(0, 12) + "]";
}

ApprovalGrant 는 record 라 기본 toString() 이 전 필드를 찍는다는 점은 대비된다 — 다만 ApprovalGrant 자체가 로그에 닿는 경로는 확인되지 않았다.


7. 트랜잭션·동시성·수명주기

이 리프에 실행 코드가 없으므로 동시성 계약은 전부 인터페이스 문서로 표현되어 있고, 강제는 구현 리프의 몫이다.

// AdminOperationJournal.java:16-18
 * Implementations must therefore be shared and durable, must enforce uniqueness on {@code
 * (approvalTicket, planDigest)}, and must hand out leases with a monotonic fencing token so a
 * process that stalled past its lease cannot write over the replica that took over from it.

펜싱 토큰의 하한이 타입으로 강제된다.

// AdminOperationLease.java:39-41 / AdminOperationRecord.java:59-61
if (leaseToken < 1) {
  throw new IllegalArgumentException("a lease token starts at one");
}

begin/checkpoint 의 javadoc 이 각각 던져야 할 조건을 명시한다 — begin 은 "already completed, or another runtime holds a live lease", checkpoint 는 "the lease has been taken over by a newer token". 즉 오래된 토큰의 쓰기를 거절하는 것이 구현 의무로 문서화되어 있다.

이 리프의 모든 타입은 record 이거나 불변 final 클래스다. DestructiveOperationGuardfinal boolean 하나만 갖고, HmacApprovalVerifier 는 키를 clone 해 보관한다. 공유해도 안전하다.

수명주기 훅은 없다. MessagingAdminDurabilityValidator(스타터, InitializingBean)가 유일한 기동 시점 훅이며 이 리프 밖이다.


8. 설정·기능 플래그·환경 차이

이 리프 자체에는 설정이 없다. 환경 차이를 만드는 지점은 셋이다.

스위치 위치 기본 효과
app.messaging.admin.enabled 스타터 @ConditionalOnProperty 미설정(=off) admin 빈 전체 on/off
adminCredentialPresent DestructiveOperationGuard 생성자 인자 스타터에서 false 고정 파괴적 작업 전면 거절
활성 프로파일 prod/production MessagingAdminDurabilityValidator 비내구 저널이면 기동 실패

TopologyManagementMode 는 설정처럼 보이지만 어떤 프로퍼티에도 묶여 있지 않다(§12.1).


9. 퍼시스턴스/외부 시스템 세부

직접 접점 없음. 두 개의 포트로만 표현된다.

  • AdminOperationJournal — 유일한 프로덕션 구현은 JdbcAdminOperationJournal(outbox-jdbc-postgresql). 개발용 InMemoryAdminOperationJournal(admin-runtime)은 isDurable()==false 를 선언하고, 스타터가 프로덕션 프로파일에서 그것을 거절한다.
  • DestinationTopology / TopologyManifest — 브로커가 보고한 값과 선언값의 대조.

TopologyManifest.differencesFrom(...)List<String> 을 돌려주고, CompositeTopologyValidatorTopologyIssue 를 만든다. 두 개의 비교 메커니즘이 공존한다(§12.3(c)).


10. 테스트 레인과 실제 증명 범위

EVD-305: ./gradlew :messaging:messaging-admin-api:test --rerun-tasks9 tests, 0 failures, 0 skipped.

production 타입 24개에 대해 자체 테스트가 파일 1개·9건이다. 그 9건이 직접 겨냥하는 타입은 셋뿐이다.

타입 테스트 내용
DestructiveOperationGuard 5 자격증명 없음 / 승인 없음 / 만료 / dry run / 통과
RedriveRequest 2 자기 참조 금지, 배치 상한 100
TopologyManifest 2 differencesFrom 차이 열거 / 일치

나머지 21개 타입의 불변식은 하류 리프의 테스트가 검증한다.

위치 테스트 겨냥
messaging-admin-runtime ApprovalForgeryTest 10 위조·변조·타 발급자·계획 불일치·소스 불일치·상한 초과·작업 불일치·만료·4-eyes·정상
messaging-admin-runtime ApprovedPlanExecutionTest 11 만료·토폴로지 변경·루프 승인·영향 서술·미달 신호·정산 누락·dry run
messaging-admin-runtime AdminOperationJournalTest 리스·재개·펜싱
messaging-admin-runtime TopologyValidatorTest BLOCKING/ADVISORY·requireAcceptable·requireSafeFor
messaging-kafka KafkaTopologyValidationIT 실제 브로커 대상 requireAcceptable

ApprovalForgeryTest.aVerifiedApprovalCannotBeConstructedOutsideTheVerifier 가 이 리프의 핵심 타입 속성을 직접 검증한다는 점은 좋다. 다만 SSOT 를 소유한 리프가 자기 불변식의 대부분을 하류에서 검증받는 구조이며, 그 결과 이 리프만 빌드·테스트해서는 24개 타입 중 3개만 증명된다.

증명되지 않는 두 분기가 있다(EVD-303): DestructiveOperationGuard 의 operation 불일치(:72-83)와 source 불일치(:84-93). §12.1 에서 상술한다.


11. 빌드/ArchUnit/CI 강제 지점

build.gradle 6줄이 전부다. 이 리프 고유의 게이트는 없다. 루트에서 오는 공통 게이트(Checkstyle, SpotBugs+findsecbugs, ErrorProne -Werror)만 적용된다.

주목할 점: VerifiedApproval 의 위조 방지가 package-private 에 의존하는데, 이 저장소는 JPMS 를 쓰지 않는다.

find src -name "module-info.java" -not -path "*/build/*" | wc -l
0

즉 어떤 모듈이든 package dev.caskeleton.messaging.admin; 을 선언한 클래스를 만들면 VerifiedApproval.of(grant) 를 부를 수 있다. 현재 그런 파일은 없다.

git grep -ln "^package dev.caskeleton.messaging.admin;" -- src | grep -v "messaging-admin-api/"
(출력 없음)

이것을 지키는 자동 검사(ArchUnit 규칙 등)는 이 리프에 없다. §17 의 P3 항목이다.


12. 실제 사용 여부와 negative-space probes

12.1 Public surface reachability

방법 주의. 이 리프의 메서드 이름은 저장소 전체와 충돌이 심하다. git grep "\.absent(" 는 60건, "\.blocking(" 은 19건, "\.authorize(" 는 48건을 내지만 대부분 fileserver·graphql·web 의 동명 메서드다. 아래 수치는 전부 소유 타입을 확인한 뒤 센 것이다(EVD-302, EVD-303).

멤버 src/main 호출 테스트 호출 판정
DestructiveOperationGuard.authorize 2 (RedriveService:97, ReplayService:59) 5 사용됨
HmacApprovalVerifier.verify / .sign 0 / 0 O 프로덕션 배선 없음
AdminOperationJournal.* O (admin-runtime, outbox-jdbc) O 사용됨
TopologyManifest.differencesFrom 1 (TopologyValidationRuntime:48) 2 사용됨
TopologyValidationReport.requireAcceptable 0 4 §17 첫 항목
TopologyManagementMode (타입 전체) 0 4 미배선
ReplayPlan.describeImpact 0 2 테스트 전용
RedrivePlan.describeImpact 0 (자기 내부 1) 2 테스트 전용
ReplayResult.fellShortOfTheEstimate 0 1 테스트 전용
RedriveResult.isFullyAccounted 0 2 테스트 전용
AdminOperationLease.isResumption 0 1 테스트 전용
RedrivePlan.risksALoop 1 (ApprovedRedrivePlan:87) 사용됨
Approved*Plan.requireExecutable 2 (DefaultMessagingAdminService:127, :178) O 사용됨

세 덩어리로 읽힌다.

(a) 토폴로지 보장이 배선되지 않았다. TopologyIssue.Severity.BLOCKING 의 javadoc 은 "the context refuses to start", requireAcceptable() 의 javadoc 은 "Fails startup when any blocking issue was found" 라고 쓴다. 그러나:

git grep -n "requireAcceptable" -- src
  선언 1 + 테스트 4.  src/main 호출 0건.
git grep -n "validateTopology" -- src
  MessagingAdminService:32 (선언) + DefaultMessagingAdminService:106 (구현).  호출 0건.

스타터는 CompositeTopologyValidator 빈을 만들지만 그것을 호출하는 빈을 만들지 않고, MessagingAdminService 빈 자체가 없다. 따라서 부팅된 애플리케이션에서 토폴로지 검증이 실행되는 경로가 없다.

대조군이 같은 파일에 있다: MessagingAdminDurabilityValidatorInitializingBean 이고 afterPropertiesSet()validate() → 프로덕션+비내구면 MessagingConfigurationException 을 던진다. 저널 쪽에는 기동 실패 배선이 있고 토폴로지 쪽에는 없다. 패턴은 이미 존재한다.

(b) TopologyManagementMode 는 완전히 고아다. 자기 선언 + 테스트 4건이 전부이며, 이 타입의 필드·파라미터·설정 프로퍼티가 저장소에 하나도 없다. enum javadoc 은 "VALIDATE_ONLY in production, always" 라고 쓰지만 이 모드를 읽어 분기하는 코드가 없다.

(c) 운영자용 표면 전체가 프로덕션 소비자를 갖지 않는다. describeImpact(영향 요약), fellShortOfTheEstimate(추정치 미달), isFullyAccounted(후보 정산 누락), isResumption(재개 여부) — 이 리프가 "운영자가 결정을 내리기 위해 봐야 할 것" 으로 정의한 네 가지가 전부 테스트에서만 호출된다. 이것들을 렌더링하는 API·CLI·로그가 src/main 에 없다.

(d) DestructiveOperationGuard 의 분기 5·6 에 도달하는 테스트가 없다. guard.authorize 를 부르는 테스트는 DestructiveOperationGuardTest 의 5건뿐이고, 그 5건이 각각 분기 1~4와 통과 경로를 덮는다. 두 에러 코드를 단언하는 테스트는 저장소 전체에 없다.

특히 눈에 띄는 것은, 유일하게 불일치 조합을 실제로 넘기는 테스트가 앞 분기에서 먼저 걸린다는 점이다.

// DestructiveOperationGuardTest.java:24-25, 50-59
private static final VerifiedApproval VALID =
    verified(DestructiveOperation.REPLAY, ORDERS, NOW.minusSeconds(60), NOW.plusSeconds(3600));

void anApplicationRuntimeCannotRedrive() {
  DestructiveOperationGuard guard = new DestructiveOperationGuard(false);   // 자격증명 없음
  assertThatThrownBy(() -> guard.authorize(
          DestructiveOperation.REDRIVE, ORDERS, Optional.of(VALID), false, NOW))
      .hasMessageContaining("admin credential");
}

REDRIVE 요청에 REPLAY 승인을 넘기므로 분기 5의 조건은 참이지만, 분기 2가 먼저 던진다. 테스트 이름이 겨냥한 것도 자격증명이므로 테스트 자체는 옳다 — 다만 이 조합이 존재하는 탓에 "불일치가 검사된다" 는 인상이 생긴다.

(e) 프로덕션에서는 분기 3~6이 도달 불가다. 스타터가 new DestructiveOperationGuard(false) 로 고정하므로 부팅된 애플리케이션에서는 항상 분기 2에서 멈춘다. 이는 의도된 설계이며 주석이 명시한다("An operator tool overrides this bean with true."). 즉 분기 3~6은 운영자 도구가 존재할 때만 쓰이는 코드이고, 그 운영자 도구는 이 저장소에 없다.

12.2 Conditional sibling comparison

대조군 1 — 같은 스타터의 두 검사. MessagingAdminDurabilityValidator(저널) vs 토폴로지 검증. 전자는 InitializingBean 으로 기동을 실제로 막고, 후자는 타입에 "기동을 막는다" 고 쓰여 있으나 호출부가 없다. 같은 팀, 같은 파일, 같은 위험 서술, 다른 결과.

대조군 2 — 검사의 이중화. verifyApproved*Plan 생성자 → guard.authorize 세 곳이 operation/source/expiry 를 겹쳐 본다. ApprovalVerifier javadoc 은 "a signature check covers them all at once" 라고 하므로 뒤의 두 곳은 원리상 잉여다. 그러나 ApprovedReplayPlan:24-25 의 주석이 이유를 밝힌다: "Checking it here means the mismatch cannot survive to the execute call under any code path." — 서명 검증을 거치지 않고 VerifiedApproval 을 얻는 경로가 생기더라도 계획 결합은 유지된다는 심층 방어다.

대조군 3 — messaging-testkit 과의 관점 공유. DestinationTopology 의 "manifest 를 자기 자신과 비교해 성공을 보고하는 것을 타입으로 막는다" 는 messaging-testkitBrokerFailureMatrix 가 사후에 고쳐야 했던 결함과 같은 것이다. 여기서는 처음부터 예방했고, 저기서는 발생한 뒤 고쳤다.

12.3 Duplicate mechanism sweep

(a) 인가 검사 3중화. 위 대조군 2. 의도된 심층 방어이며 주석 근거가 있다. 다만 대가가 있다: APPROVAL_OPERATION_MISMATCH 같은 코드가 세 파일에 각각 문자열로 존재하고, 세 곳의 메시지 문구가 서로 다르다. 상수 하나로 모으는 편이 검색·집계에 낫다.

(b) 정규 형식 인코딩 2종. (EVD-304)

// ApprovalGrant.canonicalForm()  — 길이 접두
canonical.append(value.length()).append(':').append(value);
//   javadoc: "A delimiter can be smuggled into a ticket or an identity to make two different
//             grants render identically; a length prefix cannot."

// ReplayPlan.digest() / RedrivePlan.digest()  — 구분자 결합
PlanDigest.ofCanonical(String.join("|", "REPLAY", replayId, destination, , topologyVersion, ));

계획 다이제스트 입력 중 | 를 담을 수 있는 필드는 topologyVersion 하나뿐이다 — DestinationName[a-z0-9][a-z0-9.-]{0,159} 로 막혀 있고 나머지는 UUID·불리언·Instant·10진수다. topologyVersionisBlank() 만 검사하며 BrokerTopologyInspector.topologyVersion()(애플리케이션이 구현하는 SPI)에서 온다.

현재 충돌은 만들 수 없다. 자유 형식 필드가 하나뿐이고 그 앞뒤 필드가 | 를 담을 수 없으므로 인코딩이 단사다. 기록하는 이유는 두 가지다: (1) 바로 옆 파일이 정확히 이 위험을 이유로 다른 방식을 쓰고 그 이유를 남겼다, (2) 단사성이 "자유 필드가 하나뿐" 이라는 조건에 의존하며 그 조건이 깨졌을 때의 결과가 "한 승인이 다른 계획을 인가" — PlanDigest 가 존재하는 이유 자체의 무력화다.

(c) 토폴로지 비교 2종. TopologyManifest.differencesFrom(...)List<String> 을, CompositeTopologyValidator(admin-runtime)는 TopologyIssue 를 만든다. 전자에는 severity 개념이 없다. 두 경로가 같은 판단을 서로 다른 표현으로 내리며, differencesFrom 의 프로덕션 호출부는 TopologyValidationRuntime:48 한 곳이다.

12.4 Documentation / measured-count drift

(a) "Four conditions" vs 실제 여섯. DestructiveOperationGuard 클래스 javadoc(:12-15)이 세는 조건은 넷이고 코드의 분기는 여섯이다. 빠진 둘(operation·source 불일치)은 인라인 주석으로 의도가 설명되어 있으므로 누락은 서술 쪽이다. 그리고 그 둘이 §12.1(d)의 미검증 분기와 정확히 같다.

(b) messaging-policy 의존이 import 0건.

git grep -n "import dev.caskeleton.messaging.policy" -- src/messaging/messaging-admin-api
(출력 없음)

allowed_dependenciesbuild.gradle 이 선언하지만 쓰이지 않는다. messaging-spring-cloud-stream-bridge, messaging-kafka-share-experimental, messaging-testkit 에 이어 네 번째 사례다.

(c) TopologyManagementMode 의 javadoc 이 강제 주체 없는 규칙을 서술한다. "VALIDATE_ONLY in production, always" 와 requireSafeFor(boolean production) 가 있으나, 이 enum 을 읽는 프로덕션 코드가 없으므로 규칙을 적용할 지점이 없다.

(d) 테스트 파일 수 대비 타입 수. production 24 : test 1. §10 참조.


13. Git/설계 문서에서 확인한 변화와 실패 기록

messaging-testkit 과 마찬가지로 이 리프도 javadoc 이 커밋 로그를 대신한다. 여섯 개의 "이전에는 이랬다" 기록이 있고, 전부 같은 결함 계열을 가리킨다: 자기 자신을 근거로 삼는 주장.

위치 기록된 과거 결함
VerifiedApproval.java:9-13 "…a plain AdminApproval record with a public constructor, so 'this plan was approved' was a claim the caller made about itself."
ApprovalGrant.java:11-14 "AdminApproval carried a ticket, an approver, and a window. Nothing in it said which operation… the audit trail recorded a ticket that proved nothing about what was executed."
PlanDigest.java:12-16 "The operator who got a redrive of one dead-letter destination approved could execute a redrive of a different one with the same ticket, and every audit record would look correct."
AdminOperationJournal.java:10-14 "…a ConcurrentHashMap registered by the starter as the default… both are worse than having no store at all because the map made the platform look protected."
AdminOperationState.java:5-9 "…recorded one fact — 'this approval was claimed' — and recorded it before any work happened."
DestructiveOperationGuardTest.java:30-32 "The guard used to take a plain AdminApproval record, which any caller could construct."
MessagingAdminDurabilityValidator.java:14-18 (스타터) "The previous default was an in-memory map registered by this starter, and nothing in the application said so."

일곱 개가 하나의 이야기다: 승인이 처음에는 데이터였고, 지금은 타입이다.

커밋 로그 자체는 정보가 없다.

a24ece9c feat: web, websocket 어댑터 추가 구현
01372634 refactor: 각 어댑터터별 리펙토링 진행
2f5d2fc2 feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가
d646c2f1 feat(messaging): 브로커 중립 메시징 플랫폼 24개 leaf 추가

14. 런타임·터미널 Evidence

ID 파일 내용
EVD-302 evidence/raw/302-admin-api-topology-guarantee-unwired.txt requireAcceptable/validateTopology 프로덕션 호출 0건, 스타터 빈 전수, TopologyManagementMode 미사용, 운영자 표면 테스트 전용
EVD-303 evidence/raw/303-destructive-guard-untested-branches.txt guard 6분기 중 5·6 미검증, 불일치 조합이 앞 분기에 걸리는 정황, HmacApprovalVerifier src/main 생성 0건
EVD-304 evidence/raw/304-canonical-form-asymmetry.txt 길이 접두 vs | 결합, 필드별 | 포함 가능성 조사, 현재 단사성 판정
EVD-305 evidence/raw/305-messaging-admin-api-test-lane.txt 테스트 레인 9건 통과, 하류 리프 검증 분포

15. 명시적 설계 이유와 추론을 구분한 정리

코드/주석에 명시된 것

  • 승인을 타입으로 만든 이유 — 호출자가 자기 자신에 대해 주장하던 것을 검증 증거로 바꾼다 (VerifiedApproval.java:9-16).
  • 승인된 계획을 별도 타입으로 만든 이유 — 컴파일러가 인가를 강제한다 (ApprovedReplayPlan.java:10-13).
  • 4-eyes 를 record 생성자에 둔 이유 — 건너뛸 수 없는 자리 (ApprovalGrant.java:51-52).
  • 길이 접두 정규 형식의 이유 — 구분자 밀반입 방지 (ApprovalGrant.java:64-65).
  • 대칭키를 고른 이유와 그 전제 (HmacApprovalVerifier.java:15-20).
  • sign 을 같은 클래스에 둔 이유 — 정규 형식 합의가 두 곳으로 갈라지지 않도록 (:50-53).
  • 상수시간 비교의 이유 (:22-24).
  • 다이제스트를 먼저 검사하는 이유 — 진짜 승인의 계획 간 재사용이 막으려는 공격 (:72-73).
  • guard 를 중앙화한 이유 — 새 작업 추가가 enum 상수 추가로 끝나도록 (DestructiveOperationGuard.java:17-18).
  • dry run 을 항상 허용하는 이유 — 계획을 공짜로 만들어야 계획한다 (:14-15).
  • operation 검사가 필요한 이유 (:73-74).
  • 저널 키를 (ticket, digest) 로 잡은 이유 (AdminOperationRecord.java:10-12).
  • 리스가 resumeFrom 을 나르는 이유 (AdminOperationLease.java:8-11).
  • 상태 셋으로 나눈 이유 (AdminOperationState.java:5-9).
  • isDurable() 을 선언값으로 둔 이유 (AdminOperationJournal.java:87-89).
  • 토폴로지 선언/실측을 다른 타입으로 둔 이유 (DestinationTopology.java:9-11).
  • severity 를 finding 에 붙인 이유 (TopologyIssue.java:8-12).
  • 전부 모아 보고하는 이유 (TopologyValidationReport.java:10-12).
  • 프로덕션 자동 생성 금지의 이유 (TopologyManagementMode.java:8-12).
  • loopAcknowledged 를 분리한 이유 (ApprovedRedrivePlan.java:11-14).
  • 토폴로지를 두 번 보는 이유 (ApprovedReplayPlan.java:77-78).
  • redriveIdMessageId 와 분리한 이유 (RedriveRequest.java:10-13).
  • stillParked != candidates - moved 인 이유 (RedriveResult.java:10-13).
  • VerifiedApproval.toString() 을 줄인 이유 (:83-85).
  • 스타터가 false 를 고정하고 DestructiveMessagingAdmin 을 배선하지 않는 이유 (MessagingAdminAutoConfiguration.java:17-24, 38-39).

추론 (근거는 있으나 문서에 없음)

  • canonicalForm()"v1" 접두는 형식 버전 관리용으로 보인다. 명시된 문장은 없고, 버전을 읽어 분기하는 코드도 없다.
  • 계획 다이제스트가 길이 접두를 쓰지 않은 것은 의도적 예외가 아니라 누락으로 보인다 — 같은 리프에 반대 규칙의 명시적 근거가 있기 때문. 확인할 근거는 없다.
  • messaging-policy 의존이 남아 있는 이유는 알 수 없다.
  • 토폴로지 검증이 기동에 배선되지 않은 이유가 "운영자 도구 전용" 스탠스의 연장인지, 누락인지 판단할 근거가 코드에 없다. 스타터 javadoc 은 파괴적 작업에 대해서만 그 스탠스를 밝힌다.
  • 운영자 표면(describeImpact 등)의 소비자가 없는 것은 API·CLI 계층이 아직 없기 때문으로 보이나, 그 계획을 서술한 문서는 이 리프에 없다.

16. 확인한 것 / 확인하지 못한 것

확인한 것

  • production 24 + test 1 = 25개 Java 파일 전부 본문 확인.
  • 테스트 레인 9건 전건 통과 (EVD-305).
  • requireAcceptable·validateTopology 프로덕션 호출 0건, 스타터가 만드는 admin 빈 4종 전수 (EVD-302).
  • TopologyManagementMode 프로덕션 사용 0건 (EVD-302).
  • guard 6분기 중 5·6에 도달하는 테스트 부재, 그 이유까지 (EVD-303).
  • HmacApprovalVerifier src/main 생성 0건, ApprovalVerifier 구현체 1개 (EVD-303).
  • 계획 다이제스트 입력 필드별 | 포함 가능성 전수 조사와 현재 단사성 판정 (EVD-304).
  • module-info.java 저장소 전체 0개, 소유 모듈 밖에서 같은 패키지를 선언한 파일 0개.

확인하지 못한 것

  • JdbcAdminOperationJournalAdminOperationJournal javadoc 의 세 의무(공유·내구, (ticket,digest) 유일성, 단조 펜싱 토큰)를 실제로 지키는지 — 그 리프의 SSOT 이며 Postgres 컨테이너가 필요하다. 이 세션에서 실행하지 않았다.
  • messaging-admin-runtime 의 서비스들이 검사 순서를 올바르게 호출하는지 — 다음 문서에서 다룬다.
  • 부팅된 컨텍스트에서 app.messaging.admin.enabled=true 로 빈 그래프가 실제로 어떻게 되는지 — 런타임 관측 미수행.
  • 운영자 도구(adminCredentialPresent=true 를 등록하는 쪽)가 이 저장소 밖에 존재하는지.
  • topologyVersion 이 실제 배포에서 어떤 형식인지 — BrokerTopologyInspector 구현이 이 저장소에 없다.

17. 손볼 것

P2 — "BLOCKING 이면 기동이 실패한다" 는 보장이 어떤 배선에서도 실행되지 않는다

TopologyIssue.Severity.BLOCKING javadoc: "The destination cannot deliver a declared guarantee; startup must fail." TopologyValidationReport.requireAcceptable() javadoc: "Fails startup when any blocking issue was found." 그러나 requireAcceptable() 의 프로덕션 호출부는 0건이고, 그것을 부를 수 있는 유일한 진입점 validateTopology() 도 호출부가 0건이며, MessagingAdminService 빈은 스타터가 만들지 않는다(EVD-302).

결과: 복제 계수 1인 목적지에 durability 를 선언해도 컨텍스트는 정상 기동한다. 타입은 그 상황을 정확히 표현할 수 있고, 표현한 것을 아무도 읽지 않는다.

고치는 방법이 같은 파일에 이미 있다. MessagingAdminDurabilityValidatorInitializingBean 으로 저널 내구성을 기동 시점에 검사하고 실패시킨다. 같은 모양의 빈 하나 — CompositeTopologyValidator + 선언된 TopologyManifest 목록을 받아 afterPropertiesSet() 에서 validate(...).requireAcceptable() 을 호출 — 이면 된다. 단, @ConditionalOnBean(BrokerTopologyInspector.class) 는 유지해야 한다(inspector 없이 검증할 수 없으므로).

이 항목이 P2 인 이유: 이 리프는 runtime_memberships: ["app-bootstrap"] 이고, 보장이 문서·타입·테스트에 모두 존재하는데 배선만 없다. 읽는 사람은 보장이 있다고 믿을 근거가 세 겹으로 있다.

P2 — DestructiveOperationGuard 의 두 분기가 문서에도 없고 테스트에도 없다

operation 불일치(:72-83)와 source 불일치(:84-93)는 클래스 javadoc 의 "Four conditions" 에 포함되지 않고, 두 에러 코드를 단언하는 테스트도 저장소 전체에 없다(EVD-303).

이 둘은 사소한 검사가 아니다 — 5번 분기의 인라인 주석이 정확히 말한다: "A guard that only checks presence and window lets a verified redrive approval authorise a destination deletion." 즉 검증된 승인으로 목적지 삭제를 인가하는 것을 막는 검사다.

혼동을 키우는 정황이 하나 더 있다. anApplicationRuntimeCannotRedriveREDRIVE 요청에 REPLAY 승인을 넘기지만 adminCredentialPresent=false 라 분기 2에서 먼저 걸린다. 불일치 조합이 테스트에 등장하지만 그 분기는 실행되지 않는다.

수정: javadoc 을 여섯으로 고치고, new DestructiveOperationGuard(true) 위에서 operation 불일치·source 불일치 각각 1건씩 테스트를 추가한다. 이 리프에는 이미 verified(operation, source, from, until) 헬퍼가 있어 두 줄이면 된다.

P3 — 서명 능력과 검증 능력이 같은 객체에 있다

HmacApprovalVerifiersign()verify() 를 같은 키로 제공한다. javadoc 이 위험을 명시한다: "Holding this key is what makes a caller an issuer — it is not, and must not become, available to the runtime that executes operations."

그러나 대칭키에서는 검증하려면 서명할 수 있는 키를 가져야 한다. 승인을 검증하는 프로세스는 정의상 승인을 발급할 수 있고, 그 프로세스가 운영자 도구라면 "an operator cannot mint an approval for themselves" 는 성립하지 않는다. 이 리프의 테스트가 그 구조를 그대로 보여준다.

// DestructiveOperationGuardTest.java:20-22, 47
private static final HmacApprovalVerifier ISSUER = new HmacApprovalVerifier();

return ISSUER.verify(grant, ISSUER.sign(grant), digest, from);

같은 객체가 발급자이자 검증자이며, 변수 이름이 ISSUER 다.

현재 프로덕션 배선이 ApprovalVerifier 빈을 만들지 않으므로 지금 문제가 발생하지는 않는다(EVD-303). 그러나 운영자 도구가 등장하는 순간 이 구조가 활성화된다. 이 리프는 다른 모든 곳에서 "능력을 타입으로 표현" 하는데(VerifiedApproval 이 검증 사실을 증명하듯), 서명 능력만 타입으로 분리되어 있지 않다.

두 가지 방향이 있고 둘 다 javadoc 이 이미 열어 두었다. (1) ApprovalIssuer 를 별도 타입으로 분리해 "누가 서명 능력을 쥐는가" 를 타입에 드러낸다 — 클래스는 나뉘고 정규 형식은 ApprovalGrant.canonicalForm() 하나로 유지되므로 javadoc 이 우려한 "두 번째 구현이 드리프트한다" 는 발생하지 않는다. (2) javadoc 이 이미 언급한 대로 공개키 검증자를 구현해 검증 측이 서명 키를 갖지 않게 한다.

P3 — 계획 다이제스트가 승인 정규 형식과 다른 인코딩을 쓴다

ApprovalGrant.canonicalForm() 은 길이 접두를, ReplayPlan.digest()/RedrivePlan.digest()String.join("|", …) 를 쓴다(§12.3(b), EVD-304).

현재는 충돌을 만들 수 없다 — 자유 형식 필드가 topologyVersion 하나뿐이기 때문이다. 그러나 그 조건은 코드 어디에도 적혀 있지 않고, 필드가 하나 추가되면 조용히 깨진다. ApprovalGrantappendFieldPlanDigest 쪽으로 옮겨 재사용하는 편이 낫다 — 규칙과 그 근거가 이미 같은 리프에 있다.

부수적으로 topologyVersion 에 형식 제약을 주는 것도 검토할 만하다. 지금은 isBlank() 만 본다.

P3 — TopologyManagementMode 가 어디에도 연결되어 있지 않다

자기 선언과 테스트 4건이 전부다. 이 enum 을 읽는 프로덕션 코드도, 이것으로 매핑되는 설정 프로퍼티도 없다(EVD-302). 두 선택지가 있다: 실제로 배선하거나(선언된 토폴로지 관리 모드를 설정에서 읽고 requireSafeFor(isProduction) 를 기동 시 호출), 제거한다. 지금 상태는 "규칙이 코드에 있다" 는 인상만 준다.

P3 — 운영자용 표면 전체에 프로덕션 소비자가 없다

ReplayPlan.describeImpact, RedrivePlan.describeImpact, ReplayResult.fellShortOfTheEstimate, RedriveResult.isFullyAccounted, AdminOperationLease.isResumption — 다섯 개가 전부 테스트에서만 호출된다(EVD-302).

이것들은 잉여 코드가 아니라 아직 소비자가 없는 잘 설계된 표면이다. describeImpact 의 javadoc 이 "operator-facing" 이라고 쓰고 ApprovedPlanExecutionTest.aReplayIntoTheLiveGroupSaysSoInCapitals 가 대문자 LIVE 까지 검증한다. 문제는 그 문자열이 도달할 화면이 없다는 것이다. admin API·CLI 계층을 만들 때 이 다섯이 그 계층의 명세라는 점을 문서에 남겨 두는 것이 낫다.

P3 — VerifiedApproval 의 위조 방지가 package-private 에만 의존한다

이 저장소는 JPMS 를 쓰지 않는다(module-info.java 0개). 따라서 어떤 모듈이든 package dev.caskeleton.messaging.admin; 을 선언하면 VerifiedApproval.of(grant) 를 호출할 수 있다. 현재 그런 파일은 없지만, 이 타입의 존재 이유가 "아무도 만들 수 없다" 이므로 그 조건을 자동으로 지키는 검사가 있어야 한다.

ApprovalForgeryTest.aVerifiedApprovalCannotBeConstructedOutsideTheVerifier 가 있으나, 그것은 같은 패키지 안에서 API 표면을 확인하는 테스트지 다른 모듈의 패키지 선언을 막지 못한다. ArchUnit 규칙 한 줄 — "dev.caskeleton.messaging.admin 패키지는 messaging-admin-api 소스 경로에만 존재한다" — 이면 된다.

P3 — messaging-policy 의존이 import 0건이다

선언만 남아 있다. 제거 후보.

P3 — 같은 인가 실패 코드가 세 파일에 문자열 리터럴로 흩어져 있다

APPROVAL_OPERATION_MISMATCH, APPROVAL_SOURCE_MISMATCH, APPROVAL_PLAN_MISMATCH, APPROVAL_EXPIREDHmacApprovalVerifier, DestructiveOperationGuard, ApprovedReplayPlan, ApprovedRedrivePlan 에 각각 리터럴로 존재하며 메시지 문구가 서로 다르다. 검사의 3중화 자체는 의도된 심층 방어지만(§12.2 대조군 2), 코드 문자열은 상수 하나로 모으는 편이 집계와 검색에 낫다.

확인된 설계(문제 아님)

  • VerifiedApproval 의 unforgeable-token 패턴. private 생성자 + package-private 팩토리 + 검증자만 호출. 타입을 쥔 것이 검증 증거다.
  • Approved*Plan 을 별도 타입으로 만든 것. 인가를 컴파일러가 강제한다.
  • 4-eyes 를 record 생성자에 둔 것. 서명 대상 객체의 존재가 곧 통과 증거다.
  • 길이 접두 정규 형식. 구분자 밀반입을 원천 차단.
  • 상수시간 HMAC 비교 + 256비트 키 하한 + 키 방어적 복사.
  • 다이제스트를 서명에 묶은 것. 승인이 "기간" 이 아니라 "계획" 에 대한 것이 된다.
  • 검사의 3중화. ApprovedReplayPlan:24-25 가 이유를 밝힌다 — 어떤 코드 경로로도 불일치가 execute 까지 살아남지 못한다.
  • (approvalTicket, planDigest) 복합 키. "이미 실행됨" 과 "다른 계획에 재사용" 을 구분한다.
  • 리스가 resumeFrom 을 나르는 것. 재시도가 새 실행이 되지 않는다.
  • 펜싱 토큰 하한을 타입으로 강제.
  • isDurable() 선언값 + 스타터의 기동 실패 검사. 이 리프에서 실제로 배선까지 완료된 유일한 안전 장치다.
  • 선언 토폴로지와 실측 토폴로지를 다른 타입으로 둔 것. 자기 자신과 비교해 성공을 보고하는 것을 타입으로 막는다.
  • BLOCKING 을 첫 발견에서 던지지 않고 전부 모아 보고하는 것.
  • dry run 무조건 허용. 계획을 공짜로 만들어 계획을 유도한다.
  • loopAcknowledged 분리. 900건 승인과 400건 재실패 승인은 다른 결정이다.
  • redriveIdMessageId 분리. 리드라이브 루프를 일반 트래픽과 구별 가능하게 한다.
  • stillParked 를 뺄셈으로 계산하지 않는 것. DLQ-confirm-before-settle 규칙이 리드라이브에도 적용된다.
  • dry run 이 0을 넘기면 생성자가 거절. 결과 타입이 스스로를 검증한다.
  • VerifiedApproval.toString() 축약. 로그에 승인자를 다시 쓰지 않는다.
  • 인가 실패(MessageAuthorizationException)와 프로그래밍 오류(IllegalArgumentException)의 예외 타입 분리.

Source anchors

src/messaging/messaging-admin-api/build.gradle:1-6
src/config/architecture/modules.json                    (messaging-admin-api 항목)

main/…/AdminApproval.java:6-17,21-33,35-44
main/…/ApprovalGrant.java:8-28,39-59,61-87,89-97
main/…/ApprovalVerifier.java:5-13,16-28
main/…/HmacApprovalVerifier.java:12-25,28-45,47-61,63-100,102-110
main/…/VerifiedApproval.java:6-17,20-32,34-69,71-87
main/…/PlanDigest.java:9-19,22-28,30-46
main/…/DestructiveOperation.java:3-25
main/…/DestructiveOperationGuard.java:9-19,22-31,33-94
main/…/ReplayRequest.java:9-18,27-35
main/…/ReplayPlan.java:6-23,31-40,42-64,66-78
main/…/ApprovedReplayPlan.java:7-16,19-52,54-85
main/…/ReplayResult.java:7-21,30-39,41-48
main/…/RedriveRequest.java:7-20,24,26-36
main/…/RedrivePlan.java:6-19,27-40,42-62,64-71,73-85
main/…/ApprovedRedrivePlan.java:8-19,23-57,59-94
main/…/RedriveResult.java:7-21,25-38,40-50
main/…/AdminOperationJournal.java:7-19,22-41,43-54,56-63,65-73,75-82,84-92
main/…/AdminOperationRecord.java:7-25,39-62
main/…/AdminOperationLease.java:5-18,27-45,47-54
main/…/AdminOperationState.java:3-21
main/…/TopologyManifest.java:7-19,27-42,44-75
main/…/DestinationTopology.java:6-18,26-35,37-45
main/…/TopologyIssue.java:5-19,23-29,31-37,39-65,67-75
main/…/TopologyValidationReport.java:7-16,19-25,27-35,37-57,59-66,68-82
main/…/TopologyManagementMode.java:5-13,14-20,22-36
test/…/DestructiveOperationGuardTest.java:17-25,27-48,50-110,112-125,127-146

src/messaging/messaging-core-api/.../destination/DestinationName.java:5-23
src/messaging/messaging-spring-boot-starter/.../MessagingAdminAutoConfiguration.java:14-86
src/messaging/messaging-spring-boot-starter/.../MessagingAdminDurabilityValidator.java:11-67
src/messaging/messaging-admin-runtime/.../DefaultMessagingAdminService.java:106,117,127,169,178
src/messaging/messaging-admin-runtime/.../MessagingAdminService.java:32
src/messaging/messaging-admin-runtime/.../CompositeTopologyValidator.java:19-50
src/messaging/messaging-admin-runtime/.../TopologyValidationRuntime.java:48
src/messaging/messaging-admin-runtime/.../RedriveService.java:97
src/messaging/messaging-admin-runtime/.../ReplayService.java:59
src/messaging/messaging-admin-runtime/src/test/.../ApprovalForgeryTest.java:51,69,84,96,110,133,146,158,172,192
src/messaging/messaging-admin-runtime/src/test/.../ApprovedPlanExecutionTest.java:121-221