# messaging-observability 완전 해부 > 상태: COMPLETE > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/messaging/messaging-observability` > SSOT owner: `messaging-observability` > integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지와 숫자 지도 - registered leaf id: `messaging-observability` - canonical state `analysisFile`: `analysis/messaging/messaging-observability.md` - source path: `src/messaging/messaging-observability` - registry `allowed_dependencies`: `["messaging-core-api"]` - registry `runtime_memberships`: `["app-bootstrap"]` ### 숫자 | 항목 | 수 | |---|---:| | production Java 파일 | 9 | | production LOC | 838 | | 패키지 | 1 (`dev.caskeleton.messaging.observation`) | | test 파일 | 6 | | test 메서드(실행 확인) | 42 | | 외부(비프로젝트) 의존성 | 1 (`io.micrometer:micrometer-core`, **`api`**) | 아홉 타입을 세 축으로 나누면: | 축 | 타입 | leaf 밖 소비자 | |---|---|---| | **관측 seam** | `MessagingObservation`(interface) · `MessagingMetrics`(Micrometer 구현) · `MessagingTags`(record) · `DefaultMessagingObservationConvention` | seam 2 · 구현 **0** · tags 2 · convention **0** | | **경계** | `CardinalityGuard` · `MessagingRedactor` | 1 · 1 | | **추적·감사** | `MessagingTracer` · `MessagingAuditSink` · `MessagingAuditEvent` | **0** · **0** · 2 | ### Coverage ledger | scope/file group | count | disposition | reason | |---|---:|---|---| | `src/main/java/**` (9) | 9 | `FULL_READ` | 전 파일 본문 확인 | | `src/test/java/**` (6) | 6 | `FULL_READ` | 클래스 javadoc·단언·테스트명 전수 확인 | | `build.gradle` | 1 | `FULL_READ` | 주석 포함 9줄 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 | | `build/**` | — | `EXCLUDED` | 빌드 산출물 | `UNCLASSIFIED` 0. --- ## 1. 모듈의 정체와 경계 이 leaf는 **"메시징이 무엇을 밖으로 내보내도 되는가"**를 소유한다. 메트릭·추적·감사 셋이 여기 있고, 셋 다 같은 제약 아래 있다 — **경계가 알려진 값만 나간다.** Micrometer를 `api`로 선언한 이유가 build.gradle에 있다. ```groovy // api: MessagingMetrics' public constructor takes a MeterRegistry, so wiring it requires // naming the type. api 'io.micrometer:micrometer-core' ``` `src/messaging/CLAUDE.md:40-43`의 vendor `api` 게이트를 통과한다. 다만 `MessagingObservation` 인터페이스 자체는 Micrometer를 모른다 — 벤더는 `MessagingMetrics` 한 클래스에만 나타난다. 즉 **seam은 중립이고 구현만 벤더에 묶인다.** 의존이 `messaging-core-api` 하나뿐인 것도 의도적이다. `MessagingTracer`가 `TraceContext`·`MessageHeaders`를 쓰고 `DefaultMessagingObservationConvention`이 `PublishCompletion`·`FailureCategory`를 쓴다. policy나 transport는 필요 없다. --- ## 2. 의존성과 런타임 배선 들어오는 것: `messaging-core-api`(api), `micrometer-core`(api). 나가는 것: `messaging-runtime-core`, `messaging-kafka`, `messaging-rabbit`, `messaging-admin-runtime`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-boot-starter`. **출하 조립은 두 개뿐이다.** | bean | 라인 | 소비 | |---|---:|---| | `MessagingRedactor` | `MessagingCoreAutoConfiguration:253` | **없음** | | `CardinalityGuard` | `:264` | **없음** | 두 클래스는 `MessagingMetrics`의 생성자 인자다. 그런데 `MessagingMetrics` bean이 없다(§12.1). 즉 **재료 둘만 bean으로 있고 그것을 조립하는 것이 없다.** `MessagingTracer`·`MessagingAuditSink`·`DefaultMessagingObservationConvention`은 bean도 없고 소비자도 없다. --- ## 3. 패키지/컴포넌트 지도 ``` seam MessagingObservation (5 메서드: publish · delivery · settlement · backlog · diagnostics) ↑ 구현 MessagingMetrics ──┬── CardinalityGuard (차원당 200값 상한) ├── MessagingRedactor (키 denylist 27개) └── MeterRegistry (Micrometer) 어휘 MessagingTags (record, 6차원 고정) ↑ 생성 DefaultMessagingObservationConvention (publish/consume/settlement/deadLetter) 추적 MessagingTracer (inject / extract / shouldLinkRatherThanContinue) 감사 MessagingAuditSink (interface + InMemory) ── MessagingAuditEvent (record) ``` --- ## 4. 계약·불변식·상태 모델 ### 4.1 `MessagingTags` — 닫힌 6차원 ```java // :8-14 *
It is a fixed record rather than an open map on purpose. Every field here is bounded by * configuration or by an enum, so the cardinality of the metric is known before it is ever scraped. * Message ids, partition keys, tenant ids, and offsets are all deliberately absent: each of them is * unbounded at runtime and would multiply every series by the message volume. ``` 여섯 차원: `broker`, `destinationProfile`, `operation`, `outcome`, `failureCategory`, `retryStage`. 없는 값은 `NONE = "none"`이다 — null도 빈 문자열도 아니고 명시적 sentinel이다. **두 factory의 차이가 §12.1의 핵심이 된다.** | factory | failureCategory | retryStage | |---|---|---| | `new MessagingTags(6개 인자)` | 호출자가 지정 | 호출자가 지정 | | `MessagingTags.of(4개 인자)` | **`NONE` 고정** | **`NONE` 고정** | `asMap()`이 `LinkedHashMap`으로 순서를 고정하고 `Map.copyOf`로 불변화한다. ### 4.2 `DefaultMessagingObservationConvention` — 태그 값이 공개 계약이다 ```java // :12-18 *
Centralised because the tag values are a public contract: dashboards, alert rules, and SLOs * are written against these exact strings, so an adapter inventing its own spelling of "rejected" * silently breaks every alert that was watching for it. The conversion lives here, once, rather * than at each call site. * *
Only bounded inputs are accepted. Every parameter is an enum or a configured name, which is
* what lets {@link CardinalityGuard} bound the resulting series.
```
네 메서드와 네 상수(`PUBLISH`, `CONSUME`, `SETTLE`, `DEAD_LETTER`). `publish(...)`는 `PublishCompletion`과 `Optional Cardinality failures are not gradual. A tag that accidentally carries a message id looks fine
* in a test with ten messages and takes down the metrics backend in production, and by then the
* series already exist. The guard bounds each dimension at registration time and refuses the value
* that would cross the limit, so the damage is one rejected tag rather than a monitoring outage.
*
* It fails loudly rather than silently substituting a placeholder, because a metric that quietly
* collapses distinct values is worse than one that is missing: it looks correct.
```
기본 상한 200/차원.
**두 개의 이전 결함이 코드에 남아 있다.**
```java
// admit(String, String):57-59
// Size-then-add was not atomic: N threads could each read size == limit - 1 and each add, so
// the configured limit was an average rather than a bound. A guard that can be exceeded under
// load is no guard — load is when it matters.
synchronized (values) { ... }
```
먼저 lock 없이 `values.contains(value)`로 빠른 경로를 두고, 새 값일 때만 `synchronized`로 들어가 다시 확인한다 — double-checked 패턴이다. 테스트가 경합을 직접 재현한다(`MessagingSecretLeakTest.concurrentAdmissionNeverExceedsTheLimit`).
```java
// admit(MessagingTags):81-85
// Preflight every dimension before committing any of them.
//
// The loop used to admit each dimension as it went, so a tag set rejected on its last
// dimension had already permanently added the earlier ones — spending the budget of a bounded
// dimension on a series that was never emitted.
```
`wouldAdmit`으로 전수 사전 확인 후 `admit`으로 커밋한다. **사전 확인과 커밋 사이에 lock이 없으므로** 두 스레드가 동시에 통과할 수 있고, 그 경우 두 번째 `admit`이 false를 반환해 `admitted &= ...`가 false가 된다 — 상한은 지켜지고 결과만 거절이 된다. 안전한 방향이다.
### 4.4 `MessagingRedactor` — allowlist가 아니라 denylist인 이유
```java
// :11-15
* This is a denylist of keys that must never leave the process, not an allowlist, because
* diagnostic maps are assembled ad hoc at call sites and an allowlist would quietly drop the useful
* half. Two categories are removed. Secrets, for the obvious reason. And per-message identity —
* message ids, keys, offsets, delivery tags — because those are what turn a bounded metric into one
* series per message, and a support log into a re-identification surface.
```
27개 키. **두 범주**를 섞어 담는다.
| 범주 | 키 |
|---|---|
| 자격증명 (11) | `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `access_token`, `refresh_token`, `api_key`, `apikey`, `password`, `client_secret`, `credential`, `secret`, `token` |
| 메시지별 신원 (10) | `messageid`, `msg.id`, `correlationid`, `causationid`, `partitionkey`, `orderingkey`, `key`, `offset`, `deliverytag`, `sequence` |
| 본문·진단 (5) | `payload`, `body`, `data`, `exceptionmessage`, `stacktrace` |
두 메서드가 다른 목적을 갖는다.
| 메서드 | 동작 | 언제 |
|---|---|---|
| `sanitize` | 거부 키를 **제거** | 값이 나가면 안 되고 키의 존재도 의미 없을 때 |
| `mask` | 값을 `[redacted]`로 **대체** | "Useful where the presence of a field is itself the diagnostic signal" |
`isDenied`가 소문자 정규화 후 정확 일치다. **`messaging-core-api`의 `MessageHeaders.carriesACredential`은 세그먼트 매칭 + 인접 결합**(그쪽 §4.6)인데 이쪽은 정확 일치다 — 같은 저장소에서 같은 문제를 두 강도로 푼다(§12.3).
`msg.id`가 목록에 리터럴로 들어 있다. `ReservedHeaders.MESSAGE_ID` 상수가 있는데 참조하지 않는다 — `analysis/messaging/messaging-core-api.md` §12.3(c)가 이 사실을 관측했다.
### 4.5 `MessagingMetrics` — 순서가 계약이다
```java
// :19-27
* Every tag set passes the {@link CardinalityGuard} before a meter is created. That ordering is
* the whole point: a meter registry never forgets a series, so a single tag carrying a message id
* permanently inflates the backend. Refused tag sets are counted under a fixed {@code
* messaging.tags.rejected} counter, which makes the rejection visible without creating the series
* that caused it.
*
* Logical messages and physical attempts are separate meters. One message redelivered four times
* is one publish and five attempts; a single counter would make a redelivery storm read as traffic
* growth and hide the incident.
```
여섯 미터:
| 상수 | 이름 | 종류 |
|---|---|---|
| `PUBLISH_TIMER` | `messaging.publish` | Timer (histogram) |
| `DELIVERY_TIMER` | `messaging.delivery` | Timer (histogram) |
| `MESSAGE_COUNTER` | `messaging.messages` | Counter — **첫 시도만** |
| `SETTLEMENT_COUNTER` | `messaging.settlements` | Counter |
| `BACKLOG_GAUGE` | `messaging.backlog` | Gauge |
| `REJECTED_TAGS_COUNTER` | `messaging.tags.rejected` | Gauge (`LongAdder`) |
```java
// recordDelivery:87-89
// Only the first attempt counts as a logical message; later attempts are the same
// message arriving again, and counting them would inflate throughput during a storm.
if (attempt == 1) { registry.counter(MESSAGE_COUNTER, micrometerTags).increment(); }
```
**거절 카운터가 gauge인 것이 중요하다.** 거절된 태그 세트는 미터를 만들지 않으므로 그 사실을 기록할 유일한 방법이 고정 이름의 별도 미터다. 그것마저 태그를 붙이면 같은 문제가 생긴다.
**`recordDiagnostics`가 가장 긴 주석을 갖는다.**
```java
// :122-132
// The value never becomes a tag.
//
// It used to: every diagnostic key and value was attached to a counter, behind a guard that
// only bounded the base dimensions. One unique message id, exception message or URL per
// request created one meter series per request — permanently, in the backend and in this
// process's heap — and the redactor only masks keys it recognises, so free-form text carried
// whatever it carried.
//
// What stays is the shape: which diagnostic keys occurred, counted against the bounded base
// dimensions. The values belong in a structured log or a trace event, where they are bounded
// by retention rather than by cardinality.
```
현재 구현은 **키만** 태그로 만들고(`Tag.of("diagnostic", key)`), 그 키도 `guard.admit("diagnostic", key)`를 통과해야 한다. 값은 어디에도 가지 않는다.
redaction 순서도 명시돼 있다 — "Redact before anything else touches the values. Diagnostics are the one place where a caller can pass arbitrary keys."
`backlogs` 맵이 `computeIfAbsent`로 gauge를 한 번만 등록하고 `AtomicLong`을 재사용한다 — Micrometer gauge는 재등록해도 첫 참조를 유지하므로 필요한 패턴이다.
### 4.6 `MessagingTracer` — 브로커 홉을 건너는 추적
```java
// :12-22
* Messaging breaks in-process trace propagation: the publish and the consume happen in different
* processes, often minutes apart, so the only way the two spans meet is if the context travels in
* the message headers. W3C {@code traceparent}/{@code tracestate} are used rather than a private
* format so that a non-Java consumer, or a broker-side tool, can still join the trace.
*
* The consume side is deliberately a link rather than a child span in the general case.
* A batch consume can draw messages from many unrelated traces, and forcing them into one parent
* would invent a causal relationship that does not exist. Retry and dead-letter hops keep the
* original trace so a message's whole journey stays one story.
```
`inject`가 `MessageHeaders.platform(values)`를 쓴다 — 예약 이름을 쓸 수 있는 factory다.
```java
// inject:38-40
* Written as platform headers, not application headers, so that an application cannot
* overwrite them and silently sever the trace.
```
`messaging-core-api`의 두 factory 분리(그쪽 §4.8)를 실제로 쓰는 **두 번째** production 지점이다(첫 번째는 `messaging-policy`의 `DeadLetterEnvelopeFactory`).
`shouldLinkRatherThanContinue(batchSize)`가 `batchSize > 1`이다 — 단일 전달은 계속, 배치는 링크. 테스트가 두 경우를 각각 확인한다.
`inject`가 `traceparent`가 비면 **헤더를 건드리지 않고 그대로 반환**한다. 활성 추적이 없을 때 빈 헤더를 만들지 않는다.
### 4.7 감사 — 메트릭과 분리된 이유
```java
// MessagingAuditSink.java:10-13
* Separate from metrics and from application logs. An audit trail answers "who authorised this
* destructive operation", which is a different retention, access, and integrity requirement from
* "how slow was publish yesterday"; mixing them means either the audit gets dropped with the
* metrics or the metrics inherit the audit's retention cost.
```
`MessagingAuditEvent`가 여섯 필드를 요구하고 넷은 빈 문자열을 거절한다 — `operation`, `subject`, `destination`, `approvalTicket`. **승인 티켓이 필수**인 것이 설계다.
```java
// MessagingAuditEvent.java:10-13
* Audit covers the operations that change state an application cannot: replay, redrive, offset
* reset, purge, and delete. The subject is the operator identity and the details are passed through
* {@link MessagingRedactor}, so an audit trail proves who did what without becoming a second copy
* of the payload.
```
`MessagingAuditSink.inMemory()`가 `CopyOnWriteArrayList` 기반 구현을 준다 — "for tests and for a deployment that has no external audit store yet".
**javadoc이 "details are passed through `MessagingRedactor`"라고 하지만 `MessagingAuditEvent` 생성자는 redactor를 부르지 않는다.** `Map.copyOf`만 한다. 즉 redaction은 호출자 책임이고 타입이 강제하지 않는다 — §17.
---
## 5. 주요 실행 경로
**메트릭:** 호출자가 `MessagingTags`를 만들어 `MessagingObservation`의 다섯 메서드 중 하나를 호출 → `MessagingMetrics.admitted(tags)` → `guard.admit(tags)` → 통과하면 Micrometer `Tags`로 변환 후 미터 기록, 거절되면 `rejectedTagSets.increment()`
**추적(발행):** `tracer.inject(context, headers)` → `traceparent` 없으면 그대로 반환 → 있으면 세 헤더를 `platform` factory로 추가
**추적(수신):** `tracer.extract(headers)` → `traceparent` 없으면 `TraceContext.none()` → 있으면 세 값으로 `TraceContext` 재구성(**core-api의 W3C 검증을 통과해야 함**)
**감사:** 호출자가 `MessagingAuditEvent`를 만들어 sink에 `record`
---
## 6. 실패 경로와 복구/번역
이 leaf는 `MessagingException`을 하나도 던지지 않는다. 실패를 **값으로 표현**한다.
| 상황 | 결과 |
|---|---|
| 태그 세트가 상한 초과 | 미터를 만들지 않고 `messaging.tags.rejected` 증가 |
| 진단 키가 상한 초과 | 그 키만 건너뜀 |
| 진단 키가 denylist | `sanitize`가 제거 |
| `traceparent` 없음 | `TraceContext.none()` |
`IllegalArgumentException`을 던지는 곳은 셋 — `CardinalityGuard` 생성자(`limitPerDimension < 1`), `MessagingMetrics.recordDelivery`(`attempt < 1`), `MessagingTracer.shouldLinkRatherThanContinue`(`batchSize < 1`), `MessagingAuditEvent` 생성자(빈 필드). 전부 호출자의 프로그래밍 오류다.
**`extract`가 W3C 검증에 걸릴 수 있다.** `new TraceContext(traceparent, tracestate, baggage)`가 core-api의 정규식·바이트 상한·all-zero 검사를 돌리므로(그쪽 §4.11), 다른 시스템이 보낸 손상된 `traceparent`는 `IllegalArgumentException`이 된다. 그 예외는 `MessagingException`이 아니고 `extract`는 그것을 잡지 않는다. `messaging-cloudevents`의 id 파싱과 같은 형태다(`analysis/messaging/messaging-cloudevents.md` §17). §17.
---
## 7. 트랜잭션·동시성·수명주기
트랜잭션 없음. 이 leaf는 messaging family에서 `messaging-transport-spi` 다음으로 동시성이 조밀하다.
| 지점 | 도구 | 보호 |
|---|---|---|
| `CardinalityGuard.observed` | `ConcurrentHashMap` + `ConcurrentHashMap.newKeySet()` | 차원별 값 집합 |
| `CardinalityGuard.admit` | 빠른 경로 `contains` + `synchronized(values)` 재확인 | 상한이 평균이 아니라 경계 |
| `MessagingMetrics.backlogs` | `ConcurrentHashMap` + `computeIfAbsent` | gauge 한 번만 등록 |
| `MessagingMetrics.rejectedTagSets` | `LongAdder` | 경합 하 카운트 |
| `MessagingAuditSink.InMemory.events` | `CopyOnWriteArrayList` | 읽기 우세 |
`MessagingRedactor`·`MessagingTracer`·`DefaultMessagingObservationConvention`은 상태가 없다. `MessagingTags`는 불변 record다.
**`synchronized(values)`가 `Set` 인스턴스를 락으로 쓴다.** 그 `Set`은 `ConcurrentHashMap.newKeySet()`이고 외부에 노출되지 않으므로(`observed` 맵이 private) 외부 락 경합은 없다. 차원별로 락이 분리되는 효과도 있다.
수명주기 참여 없음.
---
## 8. 설정·기능 플래그·환경 차이
| 상수 | 값 | 위치 |
|---|---:|---|
| `CardinalityGuard.DEFAULT_LIMIT` | 200 | `:21` (private) |
| `MessagingTags.NONE` | `"none"` | public |
| 미터 이름 6개 | `messaging.*` | `MessagingMetrics` public 상수 |
| 연산 이름 4개 | `publish`/`consume`/`settle`/`deadLetter` | `DefaultMessagingObservationConvention` public 상수 |
| W3C 헤더 3개 | `traceparent`/`tracestate`/`baggage` | `MessagingTracer` public 상수 |
| denylist | 27개 키 | `MessagingRedactor` private |
starter가 `CardinalityGuard`를 기본 생성자로 만든다(`:264-265`) — 상한 200이 설정 불가다.
---
## 9. 퍼시스턴스/외부 시스템 세부
없다. `MeterRegistry`가 유일한 외부 접점이고 인터페이스로 주입된다.
---
## 10. 테스트 레인과 실제 증명 범위
레인: `./gradlew :messaging:messaging-observability:test`. **BUILD SUCCESSFUL, 42 tests, 0 skipped, 0 failures**.
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|---|---:|---|---|
| `MessagingMetricCardinalityTest` | 8 | 상한 도달 시 미터 미생성, 거절 카운트, 첫 시도만 message counter | 실제 backend 동작 |
| `MessagingRedactorTest` | 6 | denylist 동작, `sanitize`/`mask` 차이 | — |
| `MessagingSecretLeakTest` | 9 | 금지 헤더 전수, 대소문자 무관, 메시지별 신원 제거, payload/예외 제거, **비밀이 meter registry에 도달하지 않음**, 서로 다른 진단 값이 새 series를 만들지 않음, **경합 하 상한 유지**, mask의 존재 신호 유지, 감사 이벤트가 payload를 안 담음 | — |
| `MessagingTraceLinkTest` | 8 | 브로커 홉 왕복, tracestate/baggage 보존, platform 헤더로 기록, 기존 헤더 보존, 추적 없음 처리, 단일=계속/배치=링크 | 실제 collector |
| `SecretLeakStaticScanTest` | 6 | **messaging 소스 트리 전체를 정적 스캔** — 콘솔 출력 없음, 민감 식별자 문자열 연결 없음, 스캐너 자체 동작 3건 | 런타임 유출 |
| `SecretLeakScannerCharacterizationTest` | 5 | 스캐너 분류기의 현재 판정을 고정 | — |
### 10.1 정적 스캔 테스트
이 저장소에서 드문 형태다 — **테스트가 소스 트리를 읽는다.**
```java
// SecretLeakStaticScanTest.java:16-21
* A runtime redactor only protects the values that pass through it. A {@code toString()} that
* concatenates a credential, or a log line that interpolates a payload, bypasses it entirely and is
* invisible to every unit test — the leak only shows up in a production log, after the fact. A
* static scan is the cheapest way to make that class of mistake fail in CI instead.
```
분류기가 네 단계로 오탐을 줄인다 — 문자열 리터럴 제거, `+` 주변 피연산자 추출, 안전한 파생(`.length`/`.size`/`getSimpleName`…) 제외, 산술(`+ 1`) 제외, 서술형 접미사(`Id`/`Name`/`Count`…) 제외.
`theScanActuallyReachesTheSourceTree`라는 테스트가 있다 — **스캔이 실제로 파일을 읽었는지 확인한다.** 경로 탐색이 실패해 0개 파일을 스캔하고 통과하는 것을 막는다. 이 저장소가 반복하는 주제(게이트가 아무것도 검사하지 않는 것을 막기)의 좋은 예다.
### 10.2 특성화 테스트의 자기 서술
`SecretLeakScannerCharacterizationTest`의 javadoc이 자기 존재 이유와 **제거 조건**을 적는다.
```java
// :12-27
* Records exactly what {@link SecretLeakStaticScanTest}'s line classifier does today, so the fix
* that removes its two false positives can be checked against the detection power it must keep.
*
* The classifier below is a verbatim copy of the one under test. A characterization test that
* called the real method would be the better design, and Wave 2 makes that possible by extracting
* the classifier; until then a copy is the only way to assert on the decision procedure at all,
* because every part of it is private and static. The copy is deleted in the same change that
* proves the extracted classifier agrees with it.
*
* Two cases here were the offenders that failed the full {@code test} run at HEAD, and naming
* them as characterization turned "the build is red" into "the scanner cannot see a method call's
* suffix, and cannot see that {@code + 1} is arithmetic".
```
**분류기가 두 파일에 복제돼 있고, 그 복제를 지울 조건("Wave 2")이 명시돼 있으며, 그 Wave 2는 아직 일어나지 않았다.** §12.3.
---
## 11. 빌드/ArchUnit/CI 강제 지점
| 게이트 | 이 leaf에 대해 |
|---|---|
| `verifyCleanArchitectureDependencies` | `["messaging-core-api"]` |
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
| vendor `api` 규칙 | Micrometer가 `MessagingMetrics` 생성자에 등장 → `api`. **통과** |
| **`SecretLeakStaticScanTest`** | messaging 소스 트리 전체에 대해 콘솔 출력·민감 문자열 연결을 금지. `:messaging-observability:test`로 실행 |
| ArchUnit | 전용 규칙 없음 |
네 번째가 특이하다 — **한 leaf의 테스트가 family 전체 소스를 검사한다.** 스캔 루트가 `messaging-core-api` 디렉터리를 찾아 올라가는 방식이므로 messaging 전체가 대상이다. 즉 이 leaf의 테스트 레인이 family 수준 게이트를 겸한다.
---
## 12. 실제 사용 여부와 negative-space probes
원시 증거: `evidence/raw/285-observability-tag-vocabulary-bypass.txt`.
### 12.1 Public surface reachability
> **방법 주의.** 단어 검색은 `CardinalityGuard`에서 **오탐 9건**을 냈다. 저장소에 같은 이름의 클래스가 둘 있다. 아래는 import로 확인한 값이다.
```
src/application-core/.../notification/platform/observation/CardinalityGuard.java:24 ← 다른 클래스
src/messaging/messaging-observability/.../observation/CardinalityGuard.java:19 ← 이 leaf
```
import 기준으로 이 leaf의 `CardinalityGuard`를 쓰는 파일은 **한 개**다(`MessagingCoreAutoConfiguration:6`). 나머지 다섯은 notification 쪽 동명 클래스를 import한다. `messaging-schema-api`의 `SchemaRegistry`와 같은 함정이다(그쪽 §12.1).
교정 후 표:
| 타입 | leaf 밖 소비자 | 판정 |
|---|---:|---|
| `MessagingObservation` | 2 (`DefaultMessagePublisher` + 그 테스트) | 사용됨 |
| `MessagingTags` | 2 (같음) | 사용됨 |
| `MessagingAuditEvent` | 2 production (`RedriveService`, `ReplayService`) + 1 test | 사용됨 |
| `MessagingRedactor` | 1 (starter bean) | bean만 |
| `CardinalityGuard` | 1 (starter bean) | bean만 |
| **`MessagingMetrics`** | **0** | 구현이 조립되지 않음 |
| **`MessagingTracer`** | **0** | |
| **`MessagingAuditSink`** | **0** | |
| **`DefaultMessagingObservationConvention`** | **0** | |
**(a) 관측 구현이 조립되지 않는다**
`MessagingMetrics`는 `MessagingObservation`의 유일한 구현이고 저장소 전체에서 자기 테스트에서만 생성된다. starter는 그 **두 생성자 인자**(`MessagingRedactor:253`, `CardinalityGuard:264`)를 bean으로 만들고 그 둘을 합칠 bean은 만들지 않는다.
그리고 `DefaultMessagePublisher`는 6인자 생성자로 조립되어 `NO_OBSERVATION`을 쓴다. 상세는 `analysis/messaging/messaging-runtime-core.md` §12.1(b)가 소유한다. 이 leaf 쪽 사실은 **구현·재료·seam이 다 있는데 조립만 없다**는 것이다.
**(b) 태그 어휘가 존재하고 유일한 호출부가 우회한다**
`DefaultMessagingObservationConvention`은 "the tag values are a public contract … an adapter inventing its own spelling of 'rejected' silently breaks every alert"를 이유로 만들어졌고, 소비자가 0이다.
유일한 production 호출부가 이렇게 쓴다.
```java
// DefaultMessagePublisher.observe:260-269
observation.recordPublish(
MessagingTags.of(
profile.broker(),
profile.name().value(),
"publish", // ← 리터럴
result.completion().name().toLowerCase(Locale.ROOT)), // ← 직접 파생
elapsedSince(startedAt));
```
두 가지가 어긋난다.
1. `"publish"`가 리터럴이다. `DefaultMessagingObservationConvention.PUBLISH` 상수가 같은 값으로 존재한다.
2. **4인자 `MessagingTags.of(...)`를 쓰므로 `failureCategory`가 항상 `NONE`이다.** convention의 `publish(broker, dest, completion, Optional