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>
60 KiB
messaging-admin-api 완전 해부
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/messaging/messaging-admin-apiSSOT owner:messaging-admin-apiintegration/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.
*/
ReplayPlan → ApprovedReplayPlan → 실행. 각 화살표가 타입 경계이고, 각 경계에서 검사가 생성자 안에 있어 우회 경로가 없다.
경계 밖: 이 리프는 브로커를 모른다(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 클래스다. DestructiveOperationGuard 는 final 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> 을 돌려주고, CompositeTopologyValidator 는 TopologyIssue 를 만든다. 두 개의 비교 메커니즘이 공존한다(§12.3(c)).
10. 테스트 레인과 실제 증명 범위
EVD-305: ./gradlew :messaging:messaging-admin-api:test --rerun-tasks → 9 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 빈 자체가 없다. 따라서 부팅된 애플리케이션에서 토폴로지 검증이 실행되는 경로가 없다.
대조군이 같은 파일에 있다: MessagingAdminDurabilityValidator 는 InitializingBean 이고 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 — 검사의 이중화. verify → Approved*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-testkit 의 BrokerFailureMatrix 가 사후에 고쳐야 했던 결함과 같은 것이다. 여기서는 처음부터 예방했고, 저기서는 발생한 뒤 고쳤다.
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진수다. topologyVersion 은 isBlank() 만 검사하며 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_dependencies 와 build.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). redriveId를MessageId와 분리한 이유 (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). HmacApprovalVerifiersrc/main 생성 0건,ApprovalVerifier구현체 1개 (EVD-303).- 계획 다이제스트 입력 필드별
|포함 가능성 전수 조사와 현재 단사성 판정 (EVD-304). module-info.java저장소 전체 0개, 소유 모듈 밖에서 같은 패키지를 선언한 파일 0개.
확인하지 못한 것
JdbcAdminOperationJournal이AdminOperationJournaljavadoc 의 세 의무(공유·내구,(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 를 선언해도 컨텍스트는 정상 기동한다. 타입은 그 상황을 정확히 표현할 수 있고, 표현한 것을 아무도 읽지 않는다.
고치는 방법이 같은 파일에 이미 있다. MessagingAdminDurabilityValidator 는 InitializingBean 으로 저널 내구성을 기동 시점에 검사하고 실패시킨다. 같은 모양의 빈 하나 — 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." 즉 검증된 승인으로 목적지 삭제를 인가하는 것을 막는 검사다.
혼동을 키우는 정황이 하나 더 있다. anApplicationRuntimeCannotRedrive 는 REDRIVE 요청에 REPLAY 승인을 넘기지만 adminCredentialPresent=false 라 분기 2에서 먼저 걸린다. 불일치 조합이 테스트에 등장하지만 그 분기는 실행되지 않는다.
수정: javadoc 을 여섯으로 고치고, new DestructiveOperationGuard(true) 위에서 operation 불일치·source 불일치 각각 1건씩 테스트를 추가한다. 이 리프에는 이미 verified(operation, source, from, until) 헬퍼가 있어 두 줄이면 된다.
P3 — 서명 능력과 검증 능력이 같은 객체에 있다
HmacApprovalVerifier 는 sign() 과 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 하나뿐이기 때문이다. 그러나 그 조건은 코드 어디에도 적혀 있지 않고, 필드가 하나 추가되면 조용히 깨진다. ApprovalGrant 의 appendField 를 PlanDigest 쪽으로 옮겨 재사용하는 편이 낫다 — 규칙과 그 근거가 이미 같은 리프에 있다.
부수적으로 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_EXPIRED 가 HmacApprovalVerifier, 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건 재실패 승인은 다른 결정이다.redriveId와MessageId분리. 리드라이브 루프를 일반 트래픽과 구별 가능하게 한다.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