--- kind: CONCEPT slug: messaging-observability-c03 title: 차원이 닫혀 있고, 사전 확인과 커밋 사이에 lock이 없다 topic: delivery-and-settlement-models project: clean-architecture-backend-template status: 게시 전 sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 rootTreeNode: concept:messaging-observability-c03 evidenceCapturedOn: 2026-09-01 assets: - key: messaging-observability-c03 file: ../../../final/evidence/rendered/messaging-observability-c03.svg - key: messaging-observability-c03-diagram file: ../../../final/assets/diagrams/messaging-observability-c03.svg evidence: - ../../../final/evidence/raw/messaging-observability-c03.txt source: - 원본 분석 절은 final/document.md#a19-messaging-observability#L113 이다. module: messaging-observability --- # 차원이 닫혀 있고, 사전 확인과 커밋 사이에 lock이 없다 여섯 차원: broker, destinationProfile, operation, outcome, failureCategory, retryStage. 없는 값은 NONE = "none"이다 — null도 빈 문자열도 아니고 명시적 sentinel이다. ## 관계 - **타입이 문서화한 불변식은 타입이 강제한다** 같은 분석 리프에서 끌어낸 규칙이다. ## 본문 `MessagingTags`는 열린 map이 아니라 고정 record다 — "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." ## 태그에서 일부러 뺀 것 :::evidence key="messaging-observability-c03-diagram" alt="여섯 고정 차원과 없는 값은 none 이 MessagingTags 안에 놓이고 message id 와 partition key, tenant id 와 offset 이 바깥에 빗금으로 놓인다" caption="태그에서 일부러 뺀 것" zoom="false" ::: 여섯 차원은 `broker`, `destinationProfile`, `operation`, `outcome`, `failureCategory`, `retryStage`다. 없는 값은 `NONE = "none"`이다 — null도 빈 문자열도 아니고 명시적 sentinel이다. ## 두 factory의 차이 `new MessagingTags(6개 인자)`는 `failureCategory`와 `retryStage`를 호출자가 지정하고, `MessagingTags.of(4개 인자)`는 둘 다 `NONE` 고정이다. 이 차이가 §12.1의 핵심이 된다. ## LinkedHashMap 참조 위치 :::evidence key="messaging-observability-c03" alt="코드베이스에서 LinkedHashMap 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="LinkedHashMap 코드베이스 검색 — 10줄 · exit 0" zoom="true" ::: `asMap()`이 `LinkedHashMap`으로 순서를 고정하고 `Map.copyOf`로 불변화한다. ## 호출자가 철자를 정하지 않는다 `MessagingObservation`은 네 메서드와 네 상수(`PUBLISH`, `CONSUME`, `SETTLE`, `DEAD_LETTER`)를 갖는다. `publish(...)`는 `PublishCompletion`과 `Optional`를 받아 **enum에서 문자열을 파생**한다. 이 클래스는 소비자가 0이다(§12.1). ## 이전 결함 둘이 코드에 남아 있다 기본 상한은 200/차원이다. 먼저 lock 없이 `values.contains(value)`로 빠른 경로를 두고, 새 값일 때만 `synchronized`로 들어가 다시 확인한다 — double-checked 패턴이다. 테스트가 경합을 직접 재현한다(`MessagingSecretLeakTest.concurrentAdmissionNeverExceedsTheLimit`). ## 사전 확인과 커밋 사이에 lock이 없다 `wouldAdmit`으로 전수 사전 확인 후 `admit`으로 커밋한다. 그 사이에 lock이 없으므로 두 스레드가 동시에 통과할 수 있고, 그 경우 두 번째 `admit`이 false를 반환해 `admitted &= ...`가 false가 된다 — 상한은 지켜지고 결과만 거절이 된다. 안전한 방향이다. ## 같은 문제를 두 강도로 푼다 거부 목록은 27개 키이고 **두 범주**를 섞어 담는다. `isDenied`가 소문자 정규화 후 정확 일치다. **`messaging-core-api`의 `MessageHeaders.carriesACredential`은 세그먼트 매칭 + 인접 결합**(그쪽 §4.6)인데 이쪽은 정확 일치다(§12.3). `msg.id`가 목록에 리터럴로 들어 있다.