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>
@@ -0,0 +1,96 @@
|
||||
# Refactoring From Analysis Design
|
||||
|
||||
## Goal
|
||||
|
||||
Use completed `/shared/document-detail/<project>` analysis as the planning context for bounded refactoring, while keeping current application source as the SSOT and retaining verifiable evidence for every change.
|
||||
|
||||
## Core pipeline
|
||||
|
||||
1. Only an analysis snapshot whose queue state is `COMPLETE`, whose recorded revision equals the current repository HEAD, and whose working tree is clean may feed refactoring.
|
||||
2. Findings from `document-detail` become bounded WorkItems. Queue order is controlled by priority, while `type` and `scope` determine execution and verification strategy.
|
||||
3. Each WorkItem is implemented in an isolated Git worktree/branch, never directly in the analysis source checkout.
|
||||
4. Verification evidence is retained under `/shared/refactor-detail/<project>/<work-item>/`.
|
||||
5. An item cannot reach `WAITING_APPROVAL` unless the evidence contract for its type is satisfied.
|
||||
6. Approved merged refactors cause the analysis queue entry to become `REANALYZE`; human-authored repository changes remain an explicit reanalysis decision.
|
||||
|
||||
## Durable layout
|
||||
|
||||
```text
|
||||
/shared/codebase/refactor-queue.yaml
|
||||
/shared/refactor-detail/<project>/<work-item>/
|
||||
├── work-item.json
|
||||
├── plan.md
|
||||
├── evidence/
|
||||
│ ├── environment.md
|
||||
│ ├── baseline/raw/
|
||||
│ ├── after/raw/
|
||||
│ └── comparison.md
|
||||
├── verification/
|
||||
└── diff/
|
||||
```
|
||||
|
||||
The queue carries ordering/state summaries. `work-item.json` is the detail SSOT for the refactor item. `document-detail` is context; current code is source truth.
|
||||
|
||||
## WorkItem fields
|
||||
|
||||
Every item records: id, project, analysisRevision, priority, type, scope, target, status, problem, goal, acceptanceCriteria, and evidence references.
|
||||
|
||||
Allowed initial type taxonomy:
|
||||
|
||||
- `PERFORMANCE`
|
||||
- `CODE_STRUCTURE`
|
||||
- `MODULE_STRUCTURE`
|
||||
- `ARCHITECTURE`
|
||||
- `DATA_ACCESS`
|
||||
- `RELIABILITY`
|
||||
- `CONCURRENCY`
|
||||
- `TRANSACTION`
|
||||
- `SECURITY`
|
||||
- `OPERABILITY`
|
||||
- `CONFIGURATION`
|
||||
- `DEPENDENCY`
|
||||
- `BUILD`
|
||||
- `TESTABILITY`
|
||||
- `CLEANUP`
|
||||
|
||||
Allowed scopes: `LOCAL`, `MODULE`, `CROSS_MODULE`, `PROJECT`.
|
||||
|
||||
Priority determines order (`P0`..`P3`, then queue order). Type/scope never replace priority; they select the verification contract.
|
||||
|
||||
## Performance hard gate
|
||||
|
||||
A `PERFORMANCE` WorkItem must define its measurement contract before source modification:
|
||||
|
||||
- exact measurement command or reproducible procedure;
|
||||
- environment evidence path;
|
||||
- dataset/load fixture identifier;
|
||||
- metrics to compare;
|
||||
- acceptance criteria.
|
||||
|
||||
The baseline must be captured before the refactor. After the change, the same measurement contract must be used. Before `WAITING_APPROVAL`, retained evidence must include:
|
||||
|
||||
- raw baseline output;
|
||||
- raw after output;
|
||||
- environment record;
|
||||
- `comparison.md` containing before/after values, delta, conditions, and acceptance result.
|
||||
|
||||
If equivalent conditions cannot be reproduced, the item is `BLOCKED`; no improvement claim is allowed.
|
||||
|
||||
## Type-directed verification
|
||||
|
||||
- `PERFORMANCE`: baseline + after measurement + comparison + functional regression checks.
|
||||
- `BUILD`: baseline/after build measurement when improvement is claimed, plus build correctness.
|
||||
- `ARCHITECTURE`, `MODULE_STRUCTURE`, `DEPENDENCY`: dependency graph/architecture rules/build/integration evidence as applicable.
|
||||
- `DATA_ACCESS`, `TRANSACTION`, `CONCURRENCY`, `RELIABILITY`: representative integration/contract/failure-path evidence; concurrency or failure injection where the claim depends on it.
|
||||
- `SECURITY`: security regression tests/configuration/negative-path evidence without storing secrets.
|
||||
- `CODE_STRUCTURE`, `CLEANUP`, `TESTABILITY`, `CONFIGURATION`, `OPERABILITY`: behavior-preserving tests plus references/build/runtime checks appropriate to the item.
|
||||
|
||||
All types retain the commands and raw verification outputs used to justify completion.
|
||||
|
||||
## Safety
|
||||
|
||||
- Never refactor an analysis snapshot that is stale or dirty.
|
||||
- Never fabricate benchmark output, runtime evidence, or before/after comparisons.
|
||||
- Never weaken or delete a failing test merely to make a refactor pass.
|
||||
- Never store secrets in evidence.
|
||||
- Large goals must be decomposed into reviewable WorkItems; one scheduled execution works on at most one item.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Refactoring From Analysis Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a durable, type-directed refactoring workspace whose performance items require retained baseline and after evidence.
|
||||
|
||||
**Architecture:** `refactor-queue.yaml` orders bounded items, while `/shared/refactor-detail/<project>/<item>/work-item.json` owns detailed item metadata and evidence references. A dedicated verifier enforces type-specific hard gates, especially the performance baseline/after contract.
|
||||
|
||||
**Tech Stack:** Markdown/YAML/JSON, Python unittest, existing `/shared` workspace.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-28-refactoring-from-analysis-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Application source remains read-only during documentation/refactor planning.
|
||||
- Performance claims require retained raw baseline and after evidence under equivalent conditions.
|
||||
- Existing documentation and user files must not be deleted or reset.
|
||||
- No legacy workspace dependency may be introduced.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: WorkItem evidence verifier
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/verify_refactor_work_item.py`
|
||||
- Test: `tools/tests/test_verify_refactor_work_item.py`
|
||||
|
||||
- [x] **Step 1: Write failing tests for type validation and performance evidence gates**
|
||||
- [x] **Step 2: Run tests and observe missing verifier failure**
|
||||
- [x] **Step 3: Implement the verifier**
|
||||
- [x] **Step 4: Run verifier tests green**
|
||||
|
||||
### Task 2: Refactoring skill and contracts
|
||||
|
||||
**Files:**
|
||||
- Create: `.agents/skills/refactoring-from-analysis/SKILL.md`
|
||||
- Create: `.agents/skills/refactoring-from-analysis/references/work-item-contract.md`
|
||||
- Create: `.agents/skills/refactoring-from-analysis/references/type-strategies.md`
|
||||
- Create: `.agents/skills/refactoring-from-analysis/references/performance-evidence-contract.md`
|
||||
- Create: `.agents/skills/refactoring-from-analysis/references/evidence-contract.md`
|
||||
|
||||
- [x] **Step 1: Encode queue eligibility and one-bounded-item rule**
|
||||
- [x] **Step 2: Encode type-directed execution and verification**
|
||||
- [x] **Step 3: Encode performance evidence hard gate**
|
||||
|
||||
### Task 3: Durable queue/detail templates
|
||||
|
||||
**Files:**
|
||||
- Create: `/shared/codebase/refactor-queue.yaml`
|
||||
- Create: `/shared/refactor-detail/README.md`
|
||||
- Create: `/shared/refactor-detail/_templates/work-item.json`
|
||||
- Create: `/shared/refactor-detail/_templates/plan.md`
|
||||
- Create: evidence and verification template directories
|
||||
|
||||
- [x] **Step 1: Create queue SSOT**
|
||||
- [x] **Step 2: Create WorkItem/evidence templates**
|
||||
|
||||
### Task 4: Pipeline integration and verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `AGENTS.md`
|
||||
- Modify: `tools/verify_pipeline.py`
|
||||
- Modify: `tools/tests/test_verify_pipeline.py`
|
||||
|
||||
- [x] **Step 1: Add refactor paths to pipeline verification**
|
||||
- [x] **Step 2: Add instructions for the new stage**
|
||||
- [x] **Step 3: Run all verifier and terminal renderer tests**
|
||||
- [x] **Step 4: Run live workspace verification**
|
||||
@@ -0,0 +1,216 @@
|
||||
# Tech Log Document Pipeline Design
|
||||
|
||||
## Goal
|
||||
|
||||
Build a durable, project-scoped pipeline that turns a codebase into a deeply evidenced analysis document, decomposes that analysis into a root tree of Tech Log records, generates the records and evidence assets, and later performs an editorial pass without depending on any legacy workspace.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not publish records to Tech Log automatically.
|
||||
- Do not invent incidents, decisions, measurements, or first-person experiences that are not supported by the codebase, command output, browser evidence, Git history, or explicitly supplied source material.
|
||||
- Do not make the pipeline depend on any legacy document repository or path.
|
||||
- Do not treat the root tree as a brainstorming list. Every node must be traceable to analysis evidence.
|
||||
|
||||
## Workspace contract
|
||||
|
||||
```text
|
||||
/shared/
|
||||
├── codebase/
|
||||
│ └── <project>/
|
||||
│
|
||||
├── document-detail/
|
||||
│ └── <project>/
|
||||
│ ├── README.md
|
||||
│ ├── state.json
|
||||
│ ├── source-index.md
|
||||
│ ├── analysis/
|
||||
│ │ ├── 00-project-overview.md
|
||||
│ │ └── <module-or-scope>.md
|
||||
│ ├── final/
|
||||
│ │ └── document.md
|
||||
│ ├── root-tree.md
|
||||
│ ├── notes/
|
||||
│ ├── checkpoints/
|
||||
│ └── evidence/
|
||||
│ ├── raw/
|
||||
│ ├── terminal/
|
||||
│ ├── browser/
|
||||
│ ├── svg/
|
||||
│ └── meta/
|
||||
│
|
||||
└── Tech-Log-Document/
|
||||
├── AGENTS.md
|
||||
├── README.md
|
||||
├── .agents/skills/
|
||||
│ ├── writing-tech-log-from-analysis/
|
||||
│ └── humanizing-korean-tech-writing/
|
||||
├── tools/terminal-evidence/
|
||||
├── research/korean-tech-writing/
|
||||
├── _templates/project/
|
||||
└── <project>/
|
||||
├── case/
|
||||
├── reference/
|
||||
├── openquestion/
|
||||
├── decision/
|
||||
├── assets/
|
||||
│ ├── raw/
|
||||
│ ├── terminal/
|
||||
│ ├── browser/
|
||||
│ └── svg/
|
||||
└── _meta/
|
||||
```
|
||||
|
||||
## Pipeline stages
|
||||
|
||||
### Stage A — Detailed codebase analysis
|
||||
|
||||
Input: `/shared/codebase/<project>`.
|
||||
|
||||
Output: `/shared/document-detail/<project>`.
|
||||
|
||||
For a small codebase, analysis may converge in one run. For a large codebase, analyze one bounded module or subsystem per run and update `state.json` and `source-index.md`. The final document is a synthesis of completed module analyses, not a fresh rewrite that discards their provenance.
|
||||
|
||||
Required analysis properties:
|
||||
|
||||
- map project/module/package boundaries and dependency direction;
|
||||
- trace representative request, state, persistence, messaging, error, and operational paths when present;
|
||||
- identify implemented behavior separately from declared-but-unwired contracts;
|
||||
- inspect tests, build rules, configuration, Git history, and runtime behavior when they materially change the interpretation;
|
||||
- distinguish observed facts, code-derived inference, hypotheses, and external knowledge;
|
||||
- capture command/browser evidence for claims that benefit from execution verification;
|
||||
- preserve exact versions, paths, commands, status codes, measurements, and identifiers in evidence.
|
||||
|
||||
### Stage B — Root tree derivation
|
||||
|
||||
Input: `final/document.md`, module analyses, source index, evidence.
|
||||
|
||||
Output: `root-tree.md`.
|
||||
|
||||
The root tree is the decomposition contract for all downstream Tech Log records. It groups records by Topic and by kind: CASE, REFERENCE, OPEN QUESTION, DECISION.
|
||||
|
||||
A node is not valid merely because its title sounds useful. Each node records:
|
||||
|
||||
- slug;
|
||||
- source anchors into the detailed analysis;
|
||||
- code/evidence references when relevant;
|
||||
- why it belongs to that record kind;
|
||||
- readiness status;
|
||||
- missing verification, if any;
|
||||
- relations to sibling nodes.
|
||||
|
||||
Allowed readiness values:
|
||||
|
||||
- `READY`: enough grounded material exists to author the record;
|
||||
- `NEEDS_EVIDENCE`: the idea is grounded, but a material claim still needs execution or browser evidence;
|
||||
- `NEEDS_DECISION`: a Decision title is plausible but no project decision has actually been made;
|
||||
- `OPEN`: valid Question with unresolved unknowns;
|
||||
- `BLOCKED`: source material is insufficient or contradictory;
|
||||
- `REJECTED`: candidate must not become a record.
|
||||
|
||||
Only `READY` Case/Reference nodes, actual adopted/proposed project Decision nodes with explicit decision evidence, and legitimate `OPEN` Question nodes may enter Stage C.
|
||||
|
||||
### Record classification contract
|
||||
|
||||
**CASE** — a concrete incident, implementation experiment, failure, diagnosis, or verification sequence exists. It must have a specific observed problem/condition, evidence, and bounded conclusion. Case is the only record kind that may carry rich body Markdown such as code, tables, diagrams, and images.
|
||||
|
||||
**REFERENCE** — a reusable criterion, distinction, or operating/design rule can be extracted from one or more grounded cases or code observations. It must generalize beyond retelling one incident.
|
||||
|
||||
**OPEN QUESTION** — a material design or operational uncertainty remains unresolved. It must state known facts, unknowns, constraints, candidate directions when grounded, and the next verification/decision criterion. It must not smuggle in an answer.
|
||||
|
||||
**DECISION** — the project has actually selected or proposed a direction. It requires explicit decision evidence and at least one supporting relation. A best-practice recommendation is not a project Decision.
|
||||
|
||||
### Stage C — Tech Log record generation
|
||||
|
||||
Input: `root-tree.md` plus cited analysis/evidence.
|
||||
|
||||
Output: `/shared/Tech-Log-Document/<project>/{case,reference,openquestion,decision}` plus assets.
|
||||
|
||||
Generation rules:
|
||||
|
||||
- read the local writing skill before authoring;
|
||||
- generate only root-tree nodes whose status permits generation;
|
||||
- re-open the cited source anchors instead of relying on the root-tree title alone;
|
||||
- never invent a technical reason merely because a technology is present;
|
||||
- never invent first-person experience;
|
||||
- preserve protected literals exactly: numbers, dates, versions, units, source paths, code, commands, URLs, status codes, identifiers, quoted text;
|
||||
- Case rich evidence must be backed by actual raw evidence or a diagram whose semantics are derived from grounded sources;
|
||||
- Reference/Question/Decision fields remain plain text unless the target Tech Log contract changes;
|
||||
- relation metadata is generated from root-tree relations and source provenance.
|
||||
|
||||
### Stage D — Editorial refinement
|
||||
|
||||
Input: generated Tech Log record.
|
||||
|
||||
Output: same record, content-preserving editorial revision.
|
||||
|
||||
The editorial pass must read `humanizing-korean-tech-writing` first. It may alter diction, sentence rhythm, paragraphing, headings, repetition, and awkward connective phrases. It may not delete technical facts for concision, change evidence, change status/decision semantics, manufacture personal experience, or silently broaden/narrow a claim.
|
||||
|
||||
Research on Korean engineering writing is stored under `research/korean-tech-writing/` and distilled into the skill. Runtime editing must not depend on a specific external blog being reachable.
|
||||
|
||||
## Evidence model
|
||||
|
||||
### Raw first
|
||||
|
||||
Evidence is always captured in a raw form before presentation assets are produced.
|
||||
|
||||
```text
|
||||
real command / browser observation
|
||||
↓
|
||||
evidence/raw/<artifact>
|
||||
↓
|
||||
renderer or curated diagram
|
||||
↓
|
||||
evidence/terminal | browser | svg
|
||||
```
|
||||
|
||||
### Terminal evidence
|
||||
|
||||
A command run is stored with command, cwd, execution time, exit code, and output. A deterministic renderer converts that real output into an SVG terminal card. The renderer must:
|
||||
|
||||
- XML-escape all output;
|
||||
- preserve the original raw output separately;
|
||||
- redact obvious secret-bearing environment assignments and authorization/token values from the visual output;
|
||||
- visually mark truncation when the renderer caps lines;
|
||||
- never fabricate output lines.
|
||||
|
||||
### Browser evidence
|
||||
|
||||
Use browser automation only when an application can actually be run and the UI/network behavior is relevant. Store screenshots under the project evidence path and record the URL/state/assertion that makes the screenshot evidentiary rather than decorative.
|
||||
|
||||
### Diagrams
|
||||
|
||||
SVG diagrams may explain architecture, boundaries, sequences, state, or before/after behavior. A diagram is explanatory evidence, not primary proof. Its labels and relationships must be traceable to code or observed behavior.
|
||||
|
||||
## State and idempotency
|
||||
|
||||
Each project has state files so scheduled runs can resume safely. A run must inspect Git status and existing state before editing. It must not overwrite uncommitted user work.
|
||||
|
||||
Detailed-analysis state records at least:
|
||||
|
||||
- project path;
|
||||
- current code revision when Git is available;
|
||||
- analyzed scopes;
|
||||
- pending scopes;
|
||||
- final synthesis status;
|
||||
- root-tree status;
|
||||
- evidence tasks.
|
||||
|
||||
Tech-Log generation state records at least:
|
||||
|
||||
- root-tree revision/hash;
|
||||
- generated nodes;
|
||||
- pending nodes;
|
||||
- editorial status per record;
|
||||
- last validation result.
|
||||
|
||||
## Safety boundaries
|
||||
|
||||
- No automatic `git reset`, `git clean`, branch deletion, push, merge, or destructive filesystem operation.
|
||||
- No automatic production changes.
|
||||
- Do not display or persist credentials in evidence.
|
||||
- Do not silently overwrite source code while doing documentation analysis.
|
||||
- If the codebase changes materially after analysis, mark affected analysis/root-tree records stale before generating new records.
|
||||
|
||||
## Independence requirement
|
||||
|
||||
The new pipeline must be self-contained. Its instructions, skills, templates, tools, and scheduled prompts must not reference or require any legacy document workspace. Existing historical material may be consulted once during migration, but all durable rules must live under `/shared/document-detail` or `/shared/Tech-Log-Document` afterward.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Tech Log Document Pipeline Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a self-contained codebase-to-Tech-Log documentation pipeline with project-scoped analysis, grounded root-tree decomposition, evidence tooling, and editorial skills.
|
||||
|
||||
**Architecture:** `/shared/codebase/<project>` is the source. `/shared/document-detail/<project>` owns deep analysis and the root tree. `/shared/Tech-Log-Document/<project>` owns publishable record drafts and assets. Local skills and templates make all stages independent from historical workspaces.
|
||||
|
||||
**Tech Stack:** Markdown, JSON, Python 3 standard library, SVG, shell-based verification.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-28-tech-log-document-pipeline-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No runtime dependency on any legacy document workspace.
|
||||
- Root-tree nodes require source anchors and readiness state.
|
||||
- No invented incidents, decisions, measurements, first-person experience, or technical selection reasons.
|
||||
- Raw evidence precedes rendered evidence.
|
||||
- Terminal SVGs must derive from actual command output and redact obvious credentials.
|
||||
- Editorial refinement preserves technical facts and evidence semantics.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Workspace contract and templates
|
||||
|
||||
**Files:**
|
||||
- Create: `/shared/Tech-Log-Document/AGENTS.md`
|
||||
- Create: `/shared/Tech-Log-Document/README.md`
|
||||
- Create: `/shared/document-detail/README.md`
|
||||
- Create: `/shared/document-detail/_templates/*`
|
||||
- Create: `/shared/Tech-Log-Document/_templates/project/*`
|
||||
|
||||
- [x] Encode directory ownership, stage boundaries, and safety rules.
|
||||
- [x] Add project analysis state, source-index, final-document, and root-tree templates.
|
||||
- [x] Add Tech Log project output/state templates.
|
||||
- [x] Verify all required paths exist.
|
||||
|
||||
### Task 2: Root-tree and Tech Log generation skill
|
||||
|
||||
**Files:**
|
||||
- Create: `.agents/skills/writing-tech-log-from-analysis/SKILL.md`
|
||||
- Create: `.agents/skills/writing-tech-log-from-analysis/references/record-kinds.md`
|
||||
- Create: `.agents/skills/writing-tech-log-from-analysis/references/root-tree-contract.md`
|
||||
- Create: `.agents/skills/writing-tech-log-from-analysis/references/body-syntax.md`
|
||||
- Create: `.agents/skills/writing-tech-log-from-analysis/references/evidence-and-diagrams.md`
|
||||
- Create: `.agents/skills/writing-tech-log-from-analysis/references/review-checklist.md`
|
||||
- Create: `.agents/skills/writing-tech-log-from-analysis/templates/*`
|
||||
|
||||
- [x] Distill the historical Tech Log format into self-contained references.
|
||||
- [x] Make source provenance/readiness gates mandatory.
|
||||
- [x] Encode different output contracts for Case/Reference/Open Question/Decision.
|
||||
- [x] Add static verification for forbidden legacy-path dependencies and required skill sections.
|
||||
|
||||
### Task 3: Korean technical-writing editorial skill
|
||||
|
||||
**Files:**
|
||||
- Create: `.agents/skills/humanizing-korean-tech-writing/SKILL.md`
|
||||
- Create: `.agents/skills/humanizing-korean-tech-writing/references/editorial-rules.md`
|
||||
- Create: `.agents/skills/humanizing-korean-tech-writing/references/protected-content.md`
|
||||
- Create: `.agents/skills/humanizing-korean-tech-writing/references/research-method.md`
|
||||
- Create: `research/korean-tech-writing/README.md`
|
||||
|
||||
- [x] Encode content-preserving editorial scope.
|
||||
- [x] Carry forward known AI-writing failure patterns without referencing their historical location.
|
||||
- [x] Define how later public-blog research is distilled into the skill without copying a single writer's voice.
|
||||
- [x] Verify protected-content and anti-fabrication rules are present.
|
||||
|
||||
### Task 4: Terminal evidence renderer via TDD
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/terminal-evidence/tests/test_render_terminal.py`
|
||||
- Create: `tools/terminal-evidence/render_terminal.py`
|
||||
- Create: `tools/terminal-evidence/README.md`
|
||||
|
||||
- [x] Write tests for XML escaping, metadata, line rendering, redaction, and truncation marker.
|
||||
- [x] Run tests before implementation and confirm they fail because the renderer is missing.
|
||||
- [x] Implement the minimal renderer using Python standard library.
|
||||
- [x] Run tests and confirm they pass.
|
||||
- [x] Render a sample from real command output and validate the SVG as XML.
|
||||
|
||||
### Task 5: Example root-tree contract
|
||||
|
||||
**Files:**
|
||||
- Create: `/shared/document-detail/_examples/backend-clean-architecture/root-tree.md`
|
||||
|
||||
- [x] Encode the requested JPA feed topic tree as an explicitly marked structural example.
|
||||
- [x] Add source/evidence/readiness metadata placeholders that make clear it is not claimed as newly analyzed evidence.
|
||||
- [x] Verify the example conforms to the root-tree contract.
|
||||
|
||||
### Task 6: End-to-end static verification
|
||||
|
||||
**Files:**
|
||||
- Create: `/shared/Tech-Log-Document/tools/verify_pipeline.py`
|
||||
- Create: `/shared/Tech-Log-Document/tools/tests/test_verify_pipeline.py`
|
||||
|
||||
- [x] Write failing tests for required paths and forbidden legacy dependency strings.
|
||||
- [x] Implement the verifier.
|
||||
- [x] Run all tests.
|
||||
- [x] Search the new pipeline for forbidden legacy-path references.
|
||||
- [x] Print the final directory tree and verification summary.
|
||||
@@ -0,0 +1,9 @@
|
||||
# <project>
|
||||
|
||||
- codebase: `/shared/codebase/<project>`
|
||||
- analysis owner: `/shared/document-detail/<project>`
|
||||
- Tech Log output: `/shared/Tech-Log-Document/<project>`
|
||||
|
||||
## Current analysis scope
|
||||
|
||||
`state.json`을 정본으로 사용한다. 대형 코드베이스에서는 한 실행에 한 module/subsystem을 우선한다.
|
||||
@@ -0,0 +1,141 @@
|
||||
# Project Overview
|
||||
|
||||
> **이 문서는 분석 시작 시점의 sizing 스냅샷이다.** 최종 결과는 `final/document.md`,
|
||||
> 교차 스코프 종합은 `analysis/99-cross-scope.md`, 모듈별 확정 수치는 각 `analysis/NN-*.md`의
|
||||
> 커버리지 원장이 정본이다. 아래 파일/LOC 표와 bounded scope 목록은 갱신하지 않는다 —
|
||||
> 스냅샷으로서의 값이 그 정확성이기 때문이다.
|
||||
|
||||
## 분석 기준 revision
|
||||
|
||||
- repository: `/shared/codebase/clean-architecture-backend-template`
|
||||
- 최초 기준선: `a24ece9cf797f7ea647e33bf846b115208ed1ba5` (모듈 01~19)
|
||||
- **최종 기준**: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` — 분석 도중 커밋
|
||||
`21234e38`("feat: grpc 기능 deep 구현", 2026-08-31)이 gRPC 가족 18 leaf를 추가했다.
|
||||
`git diff a24ece9c..HEAD` = 400 files / +40,217 / −4이고 변경 경로가 `src/grpc*` ·
|
||||
`modules.json` · `src/build.gradle` · docs 15개뿐이어서 모듈 01~19는 영향받지 않는다
|
||||
(`analysis/20-grpc-platform.md` §0, `analysis/99-cross-scope.md` §8).
|
||||
- working tree: clean
|
||||
|
||||
## 최종 커버리지
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 등록 leaf (최종) | **62** (최초 스냅샷 시점 44) |
|
||||
| COMPLETE | **61** |
|
||||
| EXCLUDED | 1 — `sample-portfolio` (사용자 지시 2026-08-30) |
|
||||
| PENDING | 0 |
|
||||
| 모듈 문서 | `analysis/01`–`20` (20개). messaging 25 leaf는 `19`, gRPC 18 leaf는 `20`으로 통합 |
|
||||
| 교차 스코프 | `analysis/99-cross-scope.md` |
|
||||
| 최종 문서 | `final/document.md` |
|
||||
|
||||
**분석 단위와 문서 단위가 1:1이 아니다.** 아래 "분석할 bounded scopes" 표는 leaf마다 문서 하나를
|
||||
계획했으나, `messaging:*` 25개와 `grpc:*`·`grpc-advanced:*` 18개는 leaf 경계를 넘는 계약
|
||||
(capability 선언 → profile validator → 인증 증거 → 지원 문서)이 실제 설계 단위여서 각각 한 문서로
|
||||
통합했다. `state.json`의 `scopes`가 leaf 단위 정본이고, 각 항목의 `analysisFile`이 담당 문서를 가리킨다.
|
||||
|
||||
## Build and module map
|
||||
|
||||
- Gradle 9.0.0 wrapper 기반 멀티모듈 build이며 `src/settings.gradle`이 included build `build-logic`의 `ca.architecture-registry` settings plugin을 적용한다.
|
||||
- `src/config/architecture/modules.json`이 leaf 존재/경로/허용 project dependency/runtime membership의 SSOT다.
|
||||
- registry leaf 수: 이 스냅샷 시점 **44**, 최종 **62**. 숫자는 측정치이며 문서 규칙의 별도 SSOT로 취급하지 않는다 — `modules.json`이 SSOT다.
|
||||
|
||||
| module | Gradle path | production files/LOC* | test files/LOC* | runtime membership | status |
|
||||
|---|---|---:|---:|---|---|
|
||||
| `domain-core` | `:domain-core` | 8 / 107 | 0 / 0 | app-bootstrap, sample-portfolio | COMPLETE |
|
||||
| `shared-contract` | `:shared-contract` | 56 / 2839 | 23 / 2706 | app-bootstrap, sample-portfolio | PENDING |
|
||||
| `application-core` | `:application-core` | 886 / 35798 | 136 / 19751 | app-bootstrap, sample-portfolio | PENDING |
|
||||
| `adapter-outbound-support` | `:adapter:outbound:support` | 5 / 107 | 1 / 89 | app-bootstrap | PENDING |
|
||||
| `adapter-outbound-persistence-jpa` | `:adapter:outbound:persistence-jpa` | 460 / 44594 | 141 / 12581 | app-bootstrap, sample-portfolio | PENDING |
|
||||
| `adapter-outbound-persistence-mongo` | `:adapter:outbound:persistence-mongo` | 353 / 23401 | 139 / 15416 | app-bootstrap | PENDING |
|
||||
| `adapter-outbound-identifier` | `:adapter:outbound:identifier` | 5 / 134 | 2 / 151 | app-bootstrap, sample-portfolio | PENDING |
|
||||
| `adapter-outbound-fileserver` | `:adapter:outbound:fileserver` | 79 / 12723 | 37 / 12043 | app-bootstrap | PENDING |
|
||||
| `adapter-outbound-objectstorage` | `:adapter:outbound:objectstorage` | 154 / 14966 | 49 / 6778 | sample-portfolio | PENDING |
|
||||
| `adapter-outbound-cache-redis` | `:adapter:outbound:cache-redis` | 316 / 33690 | 70 / 15245 | app-bootstrap | PENDING |
|
||||
| `adapter-outbound-httpclient` | `:adapter:outbound:httpclient` | 270 / 15887 | 97 / 8803 | app-bootstrap | PENDING |
|
||||
| `adapter-outbound-messaging` | `:adapter:outbound:messaging` | 35 / 4350 | 19 / 3670 | app-bootstrap | PENDING |
|
||||
| `adapter-outbound-notification` | `:adapter:outbound:notification` | 172 / 14749 | 61 / 10037 | app-bootstrap | PENDING |
|
||||
| `adapter-inbound-web` | `:adapter:inbound:web` | 428 / 29488 | 202 / 23500 | app-bootstrap, sample-portfolio | PENDING |
|
||||
| `adapter-inbound-grpc` | `:adapter:inbound:grpc` | 9 / 650 | 6 / 782 | none | PENDING |
|
||||
| `adapter-inbound-graphql` | `:adapter:inbound:graphql` | 409 / 26477 | 117 / 14745 | app-bootstrap | PENDING |
|
||||
| `adapter-inbound-websocket` | `:adapter:inbound:websocket` | 174 / 13426 | 74 / 10336 | none | PENDING |
|
||||
| `app-bootstrap` | `:app-bootstrap` | 160 / 15096 | 282 / 32724 | app-bootstrap | PENDING |
|
||||
| `sample-portfolio` | `:sample-portfolio` | 210 / 10429 | 63 / 7020 | sample-portfolio | PENDING |
|
||||
| `messaging-core-api` | `:messaging:messaging-core-api` | 86 / 3952 | 8 / 934 | app-bootstrap | PENDING |
|
||||
| `messaging-schema-api` | `:messaging:messaging-schema-api` | 11 / 635 | 3 / 263 | app-bootstrap | PENDING |
|
||||
| `messaging-schema-json` | `:messaging:messaging-schema-json` | 2 / 234 | 3 / 373 | app-bootstrap | PENDING |
|
||||
| `messaging-schema-avro` | `:messaging:messaging-schema-avro` | 3 / 356 | 4 / 463 | none | PENDING |
|
||||
| `messaging-schema-protobuf` | `:messaging:messaging-schema-protobuf` | 3 / 209 | 2 / 278 | none | PENDING |
|
||||
| `messaging-cloudevents` | `:messaging:messaging-cloudevents` | 4 / 243 | 1 / 162 | app-bootstrap | PENDING |
|
||||
| `messaging-policy` | `:messaging:messaging-policy` | 27 / 1744 | 4 / 1236 | app-bootstrap | PENDING |
|
||||
| `messaging-transport-spi` | `:messaging:messaging-transport-spi` | 14 / 783 | 4 / 586 | app-bootstrap | PENDING |
|
||||
| `messaging-runtime-core` | `:messaging:messaging-runtime-core` | 7 / 804 | 4 / 866 | app-bootstrap | PENDING |
|
||||
| `messaging-observability` | `:messaging:messaging-observability` | 10 / 847 | 6 / 827 | app-bootstrap | PENDING |
|
||||
| `messaging-security` | `:messaging:messaging-security` | 13 / 959 | 3 / 475 | app-bootstrap | PENDING |
|
||||
| `messaging-kafka` | `:messaging:messaging-kafka` | 36 / 3711 | 24 / 4087 | app-bootstrap | PENDING |
|
||||
| `messaging-kafka-share-experimental` | `:messaging:messaging-kafka-share-experimental` | 5 / 200 | 1 / 112 | none | PENDING |
|
||||
| `messaging-rabbit` | `:messaging:messaging-rabbit` | 22 / 2624 | 10 / 1727 | app-bootstrap | PENDING |
|
||||
| `messaging-reliability-api` | `:messaging:messaging-reliability-api` | 14 / 822 | 0 / 0 | app-bootstrap | PENDING |
|
||||
| `messaging-outbox-jdbc-postgresql` | `:messaging:messaging-outbox-jdbc-postgresql` | 19 / 2525 | 8 / 2140 | app-bootstrap | PENDING |
|
||||
| `messaging-inbox-jdbc-postgresql` | `:messaging:messaging-inbox-jdbc-postgresql` | 8 / 576 | 4 / 607 | app-bootstrap | PENDING |
|
||||
| `messaging-claim-check` | `:messaging:messaging-claim-check` | 7 / 424 | 3 / 333 | app-bootstrap | PENDING |
|
||||
| `messaging-admin-api` | `:messaging:messaging-admin-api` | 26 / 1619 | 1 / 147 | app-bootstrap | PENDING |
|
||||
| `messaging-admin-runtime` | `:messaging:messaging-admin-runtime` | 13 / 1263 | 6 / 1051 | app-bootstrap | PENDING |
|
||||
| `messaging-pulsar-experimental` | `:messaging:messaging-pulsar-experimental` | 9 / 676 | 2 / 414 | none | PENDING |
|
||||
| `messaging-nats-experimental` | `:messaging:messaging-nats-experimental` | 8 / 768 | 2 / 460 | none | PENDING |
|
||||
| `messaging-spring-cloud-stream-bridge` | `:messaging:messaging-spring-cloud-stream-bridge` | 7 / 516 | 2 / 295 | none | PENDING |
|
||||
| `messaging-spring-boot-starter` | `:messaging:messaging-spring-boot-starter` | 29 / 3590 | 12 / 2384 | app-bootstrap | PENDING |
|
||||
| `messaging-testkit` | `:messaging:messaging-testkit` | 15 / 1246 | 6 / 828 | none | PENDING |
|
||||
|
||||
*파일/LOC 측정은 build output을 제외하고 Java/Kotlin/Groovy/proto/avsc/sql/yaml/properties/json 및 module build.gradle 계열을 대상으로 한 초기 scope sizing 값이다. 이후 각 bounded scope의 coverage denominator는 해당 분석에서 다시 확정한다.
|
||||
|
||||
## Dependency direction
|
||||
|
||||
- registry상 `domain-core`의 허용 project dependency는 0개다.
|
||||
- `application-core`는 `domain-core`, `shared-contract`를 허용하며, adapter/runtime leaf들은 registry에 명시된 방향으로만 project edge를 가질 수 있다.
|
||||
- root `verifyCleanArchitectureDependencies`는 실제 `api/implementation/compileOnly/runtimeOnly` ProjectDependency 집합과 registry allowlist를 비교한다.
|
||||
|
||||
## Runtime entry points
|
||||
|
||||
- registry가 인식하는 runtime composition은 `app-bootstrap`, `sample-portfolio` 두 개다.
|
||||
- `domain-core`는 두 composition 모두의 membership에 포함된다. 이는 런타임 closure 포함 계약이며, `domain-core` 자체가 framework entry point나 Spring bean을 가진다는 뜻은 아니다.
|
||||
- 각 composition의 실제 wiring/conditional activation은 해당 bounded scope에서 추가 추적한다.
|
||||
|
||||
## Persistence / messaging / external systems
|
||||
|
||||
- 이 overview에서는 registry와 top-level structure만 확정했다. persistence, messaging, cache, object storage, HTTP client 등은 독립 leaf가 존재하며 세부 동작은 아직 분석하지 않았다.
|
||||
|
||||
## Test topology
|
||||
|
||||
- 각 leaf의 테스트 소스 수를 초기 계수했다. `domain-core` 자체에는 Java test가 없고 `.gitkeep`만 존재한다.
|
||||
- 도메인 모델링 규칙은 `app-bootstrap`의 `CleanArchitectureTest`가 production classes 전체를 대상으로 cross-module ArchUnit 검증한다.
|
||||
|
||||
## Configuration and operational surfaces
|
||||
|
||||
- top-level Docker Compose variants, `infra/`, runtime configuration이 존재하지만 아직 bounded analysis 전이다.
|
||||
|
||||
## 분석할 bounded scopes (계획 — 실제 문서 배치는 위 "최종 커버리지" 참조)
|
||||
|
||||
아래는 분석 시작 시점의 계획이며, 실제 산출물은 다음과 같이 통합됐다:
|
||||
|
||||
| 계획 | 실제 |
|
||||
|---|---|
|
||||
| leaf 1개 = 문서 1개 (44개 문서) | 20개 문서 |
|
||||
| `analysis/19-sample-portfolio.md` | **EXCLUDED** (사용자 지시) |
|
||||
| `analysis/20`–`44`: messaging leaf 25개 각각 | **`analysis/19-messaging-platform.md`** 하나로 통합 |
|
||||
| (계획에 없음 — 분석 도중 추가된 가족) | **`analysis/20-grpc-platform.md`** — grpc 18 leaf |
|
||||
|
||||
계획 표 원본은 git 이력에 남아 있다.
|
||||
|
||||
## 아직 단정하지 않는 것 (분석 시작 시점의 목록)
|
||||
|
||||
아래는 이 개요를 쓴 시점의 미결 목록이다. 각 항목의 현재 상태를 병기한다.
|
||||
|
||||
- 각 adapter의 실제 runtime activation 조건과 external system behavior
|
||||
→ **해소**. 각 모듈 문서 §조립/활성화 절 및 `99-cross-scope.md` §2.
|
||||
- 각 messaging leaf의 production reachability와 experimental/stable 경계
|
||||
→ **해소**. `19-messaging-platform.md` §1.1(출하 18 / build-only 7)·§10.1.
|
||||
- persistence migration ownership과 provider-specific guarantees
|
||||
→ **부분 해소**. `05`·`06`·`19` §7.2가 소유권과 미적용 스트림을 확정했으나,
|
||||
컨테이너가 필요한 마이그레이션 IT는 실행하지 않았다(`99-cross-scope.md` §10.1).
|
||||
- project 전체가 모든 문서상의 architectural claim을 만족한다는 결론
|
||||
→ **부정**. 만족하지 않는 지점이 `99-cross-scope.md` §2~§5에 형태별로 정리돼 있다.
|
||||
@@ -0,0 +1,260 @@
|
||||
# domain-core 상세 분석
|
||||
|
||||
|
||||
## SSOT identity — 2026-08-31 재검증
|
||||
|
||||
- registered leaf id: `domain-core`
|
||||
- canonical state `analysisFile`: `analysis/01-domain-core.md` (이 문서) — 이 leaf의 단일 SSOT
|
||||
- source path: `src/domain-core` · Gradle `:domain-core`
|
||||
- registry `allowed_dependencies`: **`[]`**
|
||||
- registry `runtime_memberships`: `["app-bootstrap", "sample-portfolio"]`
|
||||
- coverage ledger: `FULL_READ` **10** / `STRUCTURAL_ONLY` **3** / `EXCLUDED` **1** / `UNCLASSIFIED` **0**
|
||||
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
|
||||
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행)
|
||||
|
||||
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
|
||||
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
|
||||
|
||||
---
|
||||
## 분석 범위와 결론 상태
|
||||
|
||||
- **revision:** `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
|
||||
- **Gradle path:** `:domain-core`
|
||||
- **registry source path:** `src/domain-core`
|
||||
- **allowed project dependencies:** 없음
|
||||
- **runtime memberships:** `app-bootstrap`, `sample-portfolio`
|
||||
- **scope status:** COMPLETE
|
||||
|
||||
이 문서는 `domain-core` 자체의 모든 production Java와 module-local build/docs를 읽고, 이 모듈이 제공하는 식별자 계약과 도메인 stereotype marker가 실제로 어디서 소비되고 어떤 build-time rule로 강제되는지까지 추적한 bounded analysis다. `sample-portfolio`의 구체 도메인 모델 전체나 `app-bootstrap` 전체는 이 scope의 소유 대상이 아니며, reachability/enforcement를 증명하는 관련 부분만 읽었다.
|
||||
|
||||
## 1. Quantified scope map
|
||||
|
||||
### Owned source
|
||||
|
||||
- production Java: **7 files / 107 LOC** (`package-info.java` 2개 포함)
|
||||
- module build file: **1** (`build.gradle`)
|
||||
- module docs/instructions: **2** (`CLAUDE.md`, `README.md`)
|
||||
- dependency lock: **1** (`gradle.lockfile`)
|
||||
- Java tests: **0**
|
||||
- test placeholder: `.gitkeep` 2개
|
||||
- production packages: `dev.caskeleton.domain`, `.identifier`, `.stereotype`
|
||||
- public domain contracts: `IdFactory`, `ResourceId`
|
||||
- runtime-retained markers: `AggregateRoot`, `DomainEvent`, `ValueObject`
|
||||
|
||||
`build.gradle`의 `dependencies {}`는 비어 있다. production Java import도 `java.lang.annotation.*` 외 제3자/framework import가 없다. 따라서 현재 source shape 자체는 module instruction의 “pure domain layer”와 일치한다.
|
||||
|
||||
## 2. Coverage ledger
|
||||
|
||||
| item/group | disposition | reason |
|
||||
|---|---|---|
|
||||
| `CLAUDE.md` | FULL_READ | module policy SSOT |
|
||||
| `README.md` | FULL_READ | explicit design rationale |
|
||||
| `build.gradle` | FULL_READ | declared dependency surface |
|
||||
| `gradle.lockfile` | STRUCTURAL_ONLY | inherited build/check/test tooling dependency lock; production dependency edge를 추가하지 않음 |
|
||||
| `identifier/IdFactory.java` | FULL_READ | public identity generation port |
|
||||
| `identifier/ResourceId.java` | FULL_READ | public resource-id contract |
|
||||
| root `package-info.java` | FULL_READ | domain package anchor |
|
||||
| `stereotype/AggregateRoot.java` | FULL_READ | modeling marker |
|
||||
| `stereotype/DomainEvent.java` | FULL_READ | modeling marker |
|
||||
| `stereotype/ValueObject.java` | FULL_READ | modeling marker |
|
||||
| stereotype `package-info.java` | FULL_READ | marker semantics |
|
||||
| two test `.gitkeep` files | STRUCTURAL_ONLY | no executable test content |
|
||||
| generated `build/` tree | EXCLUDED | source/build-output ownership evidence상 generated artifact; current source contract denominator에서 제외 |
|
||||
|
||||
Owned source 기준 unclassified relevant item은 **0**이다.
|
||||
|
||||
## 3. 이 모듈이 실제로 소유하는 것
|
||||
|
||||
### 관찰: 재사용 가능한 도메인 “내용”보다 도메인 모델링 계약을 소유한다
|
||||
|
||||
현재 `domain-core`에는 WorkLog 같은 실제 aggregate가 없다. 실제 샘플 aggregate/value object/event는 `sample-portfolio`에 있다. 이 모듈에 남은 production surface는 다음 두 종류다.
|
||||
|
||||
1. **식별자 추상화** — `ResourceId`, `IdFactory`
|
||||
2. **모델링 표식** — `@ValueObject`, `@AggregateRoot`, `@DomainEvent`
|
||||
|
||||
따라서 “business concepts, entities, value objects…”를 둘 수 있는 계층이라는 정책과 달리, 현재 snapshot의 실제 contents는 skeleton 전반에서 사용할 **domain-layer contract/marker**에 가깝다. 이는 현재 source에 대한 관찰이며, 향후 실제 production domain type이 이 module에 추가되지 않는다는 뜻은 아니다.
|
||||
|
||||
## 4. Identifier contract
|
||||
|
||||
### `ResourceId<SELF>`
|
||||
|
||||
`ResourceId`는 F-bounded generic marker이며 public surface는 `String value()` 하나다. README에 기록된 명시적 rationale은 실제 ID 구현이 `sample-portfolio`에 있으므로 `sealed permits`로 닫으면 `domain-core -> sample-portfolio` 역방향 의존이 생긴다는 것이다. 현재 registry에서도 `domain-core.allowed_dependencies=[]`이고, `ModuleRegistry`는 production module이 `sample-portfolio`를 allowlist에 넣는 것 자체를 거부한다. root `verifyCleanArchitectureDependencies`도 실제 project edge가 registry allowlist를 넘으면 실패한다.
|
||||
|
||||
따라서 **unsealed 선택의 이유는 문서와 build policy가 서로 일치한다**.
|
||||
|
||||
다만 `ResourceId.value()`의 Javadoc/README는 “36-character canonical UUID / RFC 9562 UUIDv7”를 계약처럼 서술하지만 interface 자체는 이를 검증하지 않는다. 실제 샘플 `WorkLogId`도 현재 regex로 `8-4-4-4-12` hex shape만 검사하며 UUID version nibble이 7인지, RFC variant인지 검사하지 않는다. property test `acceptsEveryCanonicalUuid`는 오히려 임의의 32 hex를 hyphenate한 모든 값을 허용한다고 명시적으로 검증한다.
|
||||
|
||||
**관찰 결과:** “UUID-shaped canonical string”은 현재 검증되지만 “반드시 UUIDv7”이라는 더 강한 서술은 생성 adapter 경로에서는 성립해도 모든 `WorkLogId.of(...)` 입력 경로의 불변식으로는 강제되지 않는다.
|
||||
|
||||
### `IdFactory<T extends ResourceId<?>>`
|
||||
|
||||
`IdFactory`는 `newId()` 하나를 가진다. sample에서는 `WorkLogIdFactory extends IdFactory<WorkLogId>`로 specialization하고, `UuidWorkLogIdFactory`가 Spring `@Component` adapter로 구현한다. adapter는 `UuidCreator.getTimeOrderedEpochPlus1()`을 호출하고 application use case가 factory를 주입받는 구조가 확인된다.
|
||||
|
||||
즉 source dependency는 안쪽의 domain port를 바깥 adapter가 구현하는 방향이며, domain-core는 concrete UUID library/Spring을 모른다.
|
||||
|
||||
`newId()` Javadoc의 “never-before-used”는 타입/저장소 확인으로 강제되는 보장은 아니다. 현재 adapter test는 연속 두 값의 distinctness와 1,000회 monotonic ordering을 확인한다. 이 표현은 생성 전략의 기대 계약이지 전역 uniqueness를 저장소와 대조해 증명하는 메커니즘으로 읽어서는 안 된다.
|
||||
|
||||
## 5. Stereotype markers와 invariants
|
||||
|
||||
세 annotation은 모두 `@Target(TYPE)`, `@Retention(RUNTIME)`, `@Documented`인 framework-neutral marker다.
|
||||
|
||||
### `@ValueObject`
|
||||
|
||||
marker 자체는 불변성을 구현하지 않는다. `CleanArchitectureTest`가 annotation 대상 또는 `..domain.vo..` package type에 public no-arg constructor가 없어야 한다고 강제한다. sample의 `WorkLogId`, `Period`, `WorkLogOwner`, `PosterId` 등이 실제 production consumer다.
|
||||
|
||||
따라서 marker의 의미는 **“이 annotation을 붙이면 ArchUnit guardrail의 subject가 된다”**는 build-time qualification이다. 실제 field 불변성/defensive copy 등 모든 value-object 속성을 자동 검증하는 것은 아니다.
|
||||
|
||||
### `@AggregateRoot`
|
||||
|
||||
sample의 `WorkLog`, `Poster`가 실제 production consumer다. `CleanArchitectureTest`는 `set.*` 이름의 method가 public이면 실패시킨다. README와 test description 모두 이 rule이 이름 패턴 밖의 mutator(`applyXxx` 등)는 포착하지 못한다고 명시한다.
|
||||
|
||||
따라서 이 marker는 aggregate consistency를 자동으로 보장하는 annotation이 아니라 **특정 위험 surface(public raw setter)를 정적으로 제한하는 qualification marker**다.
|
||||
|
||||
### `@DomainEvent`
|
||||
|
||||
sample의 `WorkLogReserved`, `PosterCreated/Archived/...` 등이 사용한다. ArchUnit은 annotation type이 record인지 검사하고 Kafka/Spring HTTP/JAX-RS package dependency를 금지한다. production `LiveEventStompBroadcaster`는 runtime reflection으로 event class가 `@DomainEvent`인지 검사하므로 `RUNTIME` retention은 ArchUnit 외 실제 runtime consumer에도 필요하다.
|
||||
|
||||
transport-free rule의 forbidden package list는 exhaustive transport taxonomy가 아니다. test 설명 자체가 “새 broker/transport가 도입되면 list를 확장”해야 하는 구현상 한계를 명시한다.
|
||||
|
||||
## 6. Purity / dependency enforcement
|
||||
|
||||
### source-level observation
|
||||
|
||||
현재 domain-core production code는 Java standard annotation API 외 외부 import가 없다. module `dependencies {}`도 비어 있다.
|
||||
|
||||
### project-edge enforcement
|
||||
|
||||
`src/settings.gradle`은 `ca.architecture-registry` settings plugin을 통해 `modules.json`을 읽고 각 registered leaf를 include/mapping한다. `ModuleRegistry`는 다음을 settings time에 fail-closed 검증한다.
|
||||
|
||||
- root/module field set 정확성
|
||||
- nonblank id/path
|
||||
- duplicate id/Gradle path/canonical source directory
|
||||
- source path가 repository root 밖으로 escape하지 않음
|
||||
- runtime composition 값
|
||||
- self dependency
|
||||
- unknown allowed dependency
|
||||
- production module의 `sample-portfolio` 허용 금지
|
||||
|
||||
root `verifyCleanArchitectureDependencies`는 이후 실제 Gradle project dependencies를 registry allowlist와 비교한다. 즉 domain-core에 project dependency가 추가되면 `allowed=[]`과 불일치해 verification failure가 된다.
|
||||
|
||||
### class dependency enforcement
|
||||
|
||||
`CleanArchitectureTest.DOMAIN_IS_PURE`는 `..domain..` classes가 Spring/JPA/Hibernate/Lombok/application/adapter/bootstrap/service/infra/presentation/cmd 계열에 의존하지 못하게 한다. 별도 `DOMAIN_HAS_NO_LOGGER`도 logging framework dependency를 금지한다.
|
||||
|
||||
중요한 구분은 이 ArchUnit rule이 **domain-core module만이 아니라 package name에 `domain`이 들어가는 production classes 전체**를 subject로 한다는 점이다. 따라서 sample-portfolio domain model도 같은 purity/modeling guardrail의 대상이다.
|
||||
|
||||
## 7. Runtime reachability / wiring
|
||||
|
||||
`domain-core` 자체에는 Spring bean/configuration/entry point가 없다. Registry상 `app-bootstrap`, `sample-portfolio` 두 runtime composition에 membership이 있고, concrete consumers가 compile-time type/annotation으로 이 module을 참조한다.
|
||||
|
||||
- `ResourceId`: application-core messaging contract 및 sample IDs에서 참조
|
||||
- `IdFactory`: sample factory/use-case/identifier adapter에서 참조
|
||||
- `AggregateRoot`: sample aggregate에서 사용
|
||||
- `DomainEvent`: sample events와 websocket broadcaster qualification에서 사용
|
||||
- `ValueObject`: sample IDs/value objects에서 사용
|
||||
|
||||
따라서 major public abstraction이 완전히 dead/unwired인 상태는 아니다. 반대로 `domain-core`가 runtime service를 직접 수행한다는 근거도 없다.
|
||||
|
||||
## 8. Success / failure mechanics
|
||||
|
||||
이 module의 runtime executable behavior는 매우 작다. annotation 자체에는 success/failure path가 없고, interface도 implementation을 가지지 않는다. 주요 failure mechanics는 **build-time architecture violation**이다.
|
||||
|
||||
- forbidden framework/domain dependency → `DOMAIN_IS_PURE`
|
||||
- domain logger dependency → `DOMAIN_HAS_NO_LOGGER`
|
||||
- public no-arg value object → `VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR`
|
||||
- public `set*` aggregate mutator → `AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC`
|
||||
- non-record domain event → `DOMAIN_EVENTS_ARE_RECORDS`
|
||||
- enumerated transport dependency → `DOMAIN_EVENTS_ARE_TRANSPORT_FREE`
|
||||
- `id` field raw type not assignable to `ResourceId` → `NO_LONG_ID_PK`
|
||||
- project dependency not in registry → `verifyCleanArchitectureDependencies`
|
||||
- production -> `sample-portfolio` edge → settings registry validation and root dependency verification, plus cross-module ArchUnit rule
|
||||
|
||||
## 9. Tests as evidence
|
||||
|
||||
### `:domain-core:test`
|
||||
|
||||
현재 module에는 executable Java test가 없으므로 이 task의 green result는 domain semantic behavior를 검증한 것이 아니라 **module compile/test task가 현재 build에서 정상 구성되고 완료됨**을 보여준다. 이번 실행 raw evidence는 `evidence/raw/003-domain-core-test.txt`에 보존했다.
|
||||
|
||||
### `CleanArchitectureTest`
|
||||
|
||||
도메인 purity와 marker-specific rules의 실제 enforcement owner다. 별도 `app-bootstrap` test task로 실행했고 **BUILD SUCCESSFUL / exit code 0**을 확인했다. raw output은 `evidence/raw/004-clean-architecture-test.txt`에 저장했다. 이 테스트는 production class import option을 사용해 `dev.caskeleton` production class graph를 분석한다.
|
||||
|
||||
### Sample ID tests
|
||||
|
||||
`WorkLogIdPropertyTest`는 UUID **shape** invariant를 property-based로 검증하지만 UUIDv7 version/variant invariant는 검증하지 않는다. `UuidWorkLogIdFactoryTest`는 factory output canonical shape, pairwise distinctness, 1,000회 strict lexical monotonicity를 검증한다. 따라서 “factory가 time-ordered UUIDv7 generator를 사용한다”와 “어떤 ResourceId 입력도 v7만 허용한다”는 서로 다른 claim이다.
|
||||
|
||||
## 10. Explicit rationale vs inference
|
||||
|
||||
### 문서로 명시된 rationale
|
||||
|
||||
- `ResourceId`를 sealed로 만들지 않은 이유: sample module을 production core가 역참조하지 않기 위해서.
|
||||
- ID generation 책임(contract)과 concrete generation을 분리한 이유: domain purity 유지.
|
||||
- stereotype annotation을 둔 이유: brittle naming convention 대신 explicit declaration을 ArchUnit 기준으로 사용.
|
||||
- runtime retention 이유: ArchUnit/reflection reader가 annotation을 볼 수 있게 하기 위해서.
|
||||
- aggregate public setter rule 한계는 의도적으로 문서화되어 있음.
|
||||
|
||||
### 분석 inference
|
||||
|
||||
- 현재 domain-core는 구체 business model repository라기보다 skeleton-level domain modeling contract module의 성격이 강하다. 이는 현재 7개 production Java의 실제 내용에서 도출한 해석이다.
|
||||
|
||||
## 11. Improvement backlog
|
||||
|
||||
### P1 — UUIDv7 계약과 실제 validation의 불일치 확인/정렬
|
||||
|
||||
**Fact:** `ResourceId.value()`와 README는 RFC 9562 UUIDv7을 서술하지만 `WorkLogId` regex와 property test는 version/variant를 가리지 않는 모든 canonical UUID-shaped hex 문자열을 허용한다.
|
||||
|
||||
**Why it matters:** 외부/rehydration 경로에서 `WorkLogId.of()`로 non-v7 UUID가 들어가도 domain invariant가 거부하지 않는다. 생성 adapter가 v7을 만들기 때문에 정상 create path에서 가려질 수 있다.
|
||||
|
||||
**Verification:** `WorkLogId.of("00000000-0000-4000-8000-000000000000")`가 현재 성공하는지 focused test로 고정하고, 계약 의도가 “shape only”인지 “v7 only”인지 결정한다.
|
||||
|
||||
**Candidate options:**
|
||||
1. 계약 문서를 “canonical UUID shape”로 낮춘다.
|
||||
2. value object가 UUID version 7 + RFC variant를 실제 검증하고 property test를 수정한다.
|
||||
|
||||
**Later record candidate:** OPEN QUESTION 또는 DECISION. 의도 확인 전 자동 refactor candidate로 단정하지 않는다.
|
||||
|
||||
### P3 — `IdFactory.newId()`의 “never-before-used” 문구 정밀화
|
||||
|
||||
**Fact:** interface는 저장소 collision check를 요구하지 않고 sample test도 전역 uniqueness를 증명하지 않는다.
|
||||
|
||||
**Why it matters:** API doc을 강한 guarantee로 읽을 가능성이 있다.
|
||||
|
||||
**Verification:** identifier 설계 문서/역사에서 uniqueness 의미가 probabilistic UUID uniqueness인지 persistence-level uniqueness인지 확인한다.
|
||||
|
||||
**Later record candidate:** REFERENCE 또는 OPEN QUESTION.
|
||||
|
||||
## 12. Limitations / exclusions
|
||||
|
||||
- sample-portfolio business invariants 전체는 이 scope에서 분석하지 않았다. 위 consumer들은 domain-core contract의 reachability/guardrail 의미를 검증하는 데 필요한 부분만 읽었다.
|
||||
- `CleanArchitectureTest` 2,792라인 전체의 다른 architecture rules는 해당 future scope에서 분석한다. 여기서는 domain-core contract와 직접 연관된 rule bodies를 읽었다.
|
||||
- runtime composition closure 전체와 conditional startup wiring은 아직 분석하지 않았다.
|
||||
- 이 scope의 COMPLETE는 프로젝트 전체 COMPLETE를 의미하지 않는다.
|
||||
|
||||
## Source anchors
|
||||
|
||||
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **9개** (main 5 · test 0 · 기타 4).
|
||||
|
||||
```
|
||||
src/domain-core/build.gradle
|
||||
src/config/architecture/modules.json (domain-core 항목)
|
||||
|
||||
main:
|
||||
src/main/java/dev/caskeleton/domain/identifier/IdFactory.java
|
||||
src/main/java/dev/caskeleton/domain/identifier/ResourceId.java
|
||||
src/main/java/dev/caskeleton/domain/stereotype/AggregateRoot.java
|
||||
src/main/java/dev/caskeleton/domain/stereotype/DomainEvent.java
|
||||
src/main/java/dev/caskeleton/domain/stereotype/ValueObject.java
|
||||
|
||||
기타:
|
||||
CLAUDE.md
|
||||
README.md
|
||||
src/build.gradle
|
||||
src/settings.gradle
|
||||
|
||||
해석되지 않은 인용 (4종) — 외부 타입·문서상 약칭 등:
|
||||
package-info.java
|
||||
modules.json
|
||||
evidence/raw/003-domain-core-test.txt
|
||||
evidence/raw/004-clean-architecture-test.txt
|
||||
|
||||
```
|
||||
@@ -0,0 +1,233 @@
|
||||
# shared-contract 상세 분석
|
||||
|
||||
|
||||
## SSOT identity — 2026-08-31 재검증
|
||||
|
||||
- registered leaf id: `shared-contract`
|
||||
- canonical state `analysisFile`: `analysis/02-shared-contract.md` (이 문서) — 이 leaf의 단일 SSOT
|
||||
- source path: `src/shared-contract` · Gradle `:shared-contract`
|
||||
- registry `allowed_dependencies`: **`[]`**
|
||||
- registry `runtime_memberships`: `["app-bootstrap", "sample-portfolio"]`
|
||||
- coverage ledger: `FULL_READ` **82** / `STRUCTURAL_ONLY` **4** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
|
||||
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
|
||||
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행)
|
||||
|
||||
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
|
||||
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
|
||||
|
||||
---
|
||||
## 분석 상태
|
||||
|
||||
- scope: `shared-contract`
|
||||
- source path: `src/shared-contract`
|
||||
- Gradle path: `:shared-contract`
|
||||
- source revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
|
||||
- analysis cycle: 1 / normal
|
||||
- result: COMPLETE
|
||||
- registry dependencies: production project dependency 0
|
||||
- runtime memberships: `app-bootstrap`, `sample-portfolio`
|
||||
|
||||
## 역할과 경계
|
||||
|
||||
`shared-contract`는 특정 도메인이나 Spring/Jackson/JPA 구현을 소유하지 않고 여러 adapter와 composition root가 공유하는 운영 계약을 보관하는 leaf module이다. `build.gradle`의 production dependency block은 비어 있으며, `CLAUDE.md`도 Java standard library only를 명시한다. 실제 production source에서도 Spring/Jackson/JPA type은 관찰되지 않았다.
|
||||
|
||||
이 모듈이 제공하는 계약은 단일 관심사라기보다 다음의 skeleton-wide boundary 묶음이다.
|
||||
|
||||
- error taxonomy와 framework-neutral exception carrier
|
||||
- API response/bulk/pagination/long-running-operation shape
|
||||
- partial-update의 3-state `Patch`
|
||||
- `resource:action` permission value
|
||||
- provider-neutral edge rate-limit contract
|
||||
- metric naming/cardinality guardrail
|
||||
- traceparent/baggage/span-error seam
|
||||
- domain/business context propagation seam
|
||||
- compare-and-set operational record storage port
|
||||
- adapter master-switch parser
|
||||
- Redis semantic health snapshot projection
|
||||
- messaging envelope JSON Schema v1와 checked-in SHA-256 digest
|
||||
|
||||
따라서 이 module의 핵심 아키텍처적 의미는 "공통 유틸리티"가 아니라, 서로 다른 outer module이 한쪽 adapter의 type에 의존하지 않고 합의할 수 있는 중립 계약 지점이다. `OperationalRecordStorePort`의 실제 consumer인 GraphQL persisted-operation registry가 inbound adapter 자체의 저장소 interface를 선언하지 않고 이 중립 port에 의존하는 것이 그 방향성을 직접 보여준다.
|
||||
|
||||
## 주요 계약과 불변식
|
||||
|
||||
### Error contract
|
||||
|
||||
`ApiErrorCode`는 code/category/httpStatus/retryable의 최소 표면을 제공하고 `OperationalError`가 registry mirror 역할을 한다. `Category`는 VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT, TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL의 10개 값으로 고정되어 있으며 테스트가 정확한 vocabulary를 pin 한다.
|
||||
|
||||
`OperationalErrorTest`는 단순 enum 존재보다 category × retryable 의미를 강하게 검증한다. deterministic VALIDATION/AUTHZ/NOT_FOUND는 retryable=false이고, transient INTERNAL은 기본적으로 retryable=true이되 deploy-time/configuration/terminal 상태인 일부 code는 명시적 예외로 false다. `AUTH_KID_UNKNOWN`은 key rotation 중 JWKS refresh 가능성을 이유로 AUTH 중 유일한 retryable case로 pin 되어 있다. upstream 4xx 전체를 permanent/non-retryable로 분류하면서 408/429의 의미 차이가 남는다는 점은 source comment와 README가 이미 known edge로 기록한다.
|
||||
|
||||
`DependencyFailureException`과 `PersistenceFailureException`은 `ApiErrorCarrier`를 통해 transport adapter에 stable error code를 전달하면서 raw cause/diagnostic message를 server-side 정보로 남긴다. `AdapterDisabledException`은 carrier를 구현하지 않고 별도 mapping 대상이다.
|
||||
|
||||
### Response / operation contract
|
||||
|
||||
`Envelope`, `BulkEnvelope`, `ResponseMeta`, `PageMeta`, `Operation`은 framework-neutral record/factory로 API shape를 전달한다. 여기서는 중요한 enforcement boundary 차이가 관찰된다.
|
||||
|
||||
`Envelope.ok/failure`, `BulkEnvelope.allOk/partial`, `Operation.pending/succeeded/failed` factory는 문서의 정상 shape를 생성하고 테스트도 이 factory path를 검증한다. 그러나 canonical record constructor 자체는 success/data/error의 배타성, operation status와 result/error의 조합, pagination 범위 등을 검증하지 않는다. 따라서 이 규칙은 rate-limit value object처럼 intrinsic constructor invariant가 아니라 factory/adapter usage contract다. 현재 source와 test가 일치하므로 즉시 결함으로 분류하지 않지만, raw constructor가 외부 module에 public인 만큼 invalid shape 생성 가능성은 P1 hardening 후보로 남는다.
|
||||
|
||||
`Patch<T>`는 ABSENT / PRESENT_NULL / PRESENT_VALUE의 3-state를 명확하게 보존하며 absent에서 `value()`를 호출하면 실패한다. 이는 JSON Merge Patch 계열에서 "필드 미전송"과 "명시적 null"을 구분해야 하는 boundary를 framework type 없이 표현한다.
|
||||
|
||||
### Permission
|
||||
|
||||
`Permission`은 정확히 한 개의 colon으로 `resource:action`을 분리하고 trim/lowercase normalization을 수행한다. 테스트는 mixed case, surrounding whitespace, blank component, 0/2+ colon을 검증한다. 다만 source는 component 내부 character set을 제한하지 않는다. 즉 "lowercase colon-delimited"는 normalization 결과이지 `[a-z0-9-]+` 같은 strict grammar는 아니다. 현재 test 역시 이를 요구하지 않으므로 observed contract로만 기록한다.
|
||||
|
||||
### Edge rate-limit contract
|
||||
|
||||
이 영역은 shared-contract 안에서도 가장 강하게 self-validating 된다. `RateLimitPolicy`, `RateParameters`, `RateLimitRequest`, `RateLimitDecision`, `RateLimitOutcome`, `RateLimitEvaluationDedupPolicy`가 생성 시점에 bounded representation과 arithmetic safety를 검증한다.
|
||||
|
||||
- Lua exact integer range를 `9_007_199_254_740_991`로 제한한다.
|
||||
- sliding counter/token bucket fixed-point 계산에 scale `1_000_000`을 사용하며 중간 합/곱도 exact-range를 넘지 않게 검증한다.
|
||||
- window/cleanup/retry duration은 whole milliseconds만 허용하고 상한을 둔다.
|
||||
- policy id/revision, subject digest, evaluation id는 bounded regex로 제한한다.
|
||||
- raw edge identity는 `EdgeRateLimitSubject`에서만 잠시 존재하고 provider request에는 pseudonymous digest만 전달하도록 type/regex로 가드한다.
|
||||
- v1 failure policy는 `FAIL_CLOSED`만 허용한다.
|
||||
- unavailable / indeterminate / incompatible를 evaluated denial과 분리하여 transport/provider ambiguity를 숨기지 않는다.
|
||||
- response-loss replay dedup은 TTL, entry count, logical stored bytes를 동시에 제한한다.
|
||||
|
||||
별도 `edgeRateLimitContractTest` source set이 provider-neutrality와 bounded request semantics를 qualification lane으로 다시 pin 한다.
|
||||
|
||||
### Metrics and tracing
|
||||
|
||||
`MetricNaming`은 Micrometer-facing dot.case naming과 seconds/bytes/total suffix vocabulary를 framework dependency 없이 보존한다. `CardinalityBounds`는 bounded tag의 상한을 Java mirror로 제공하고, `ForbiddenMetricTags`는 request_id/user_id/raw URL/query/header/IP 같은 unbounded source를 metric label에서 금지한다. `request_id`가 baggage에는 허용되지만 metric label에는 금지되는 비대칭은 test에서 의도적으로 pin 되어 있다.
|
||||
|
||||
`TraceParent`는 이 skeleton이 지원하는 strict v00 subset을 parse/render한다. lowercase hex, non-zero trace/span id, 2-byte flags를 검사하고 wrong version을 거부한다. `BaggageAllowlist`는 `tenant_id`, `request_id`만 보존하는 단순 parse/filter/render utility다. 이는 full W3C baggage grammar validator라기보다 propagation boundary allowlist다. `SpanErrorRecorder.NOOP`은 tracer library가 없는 기본 template에서도 outer adapter가 동일 seam을 호출할 수 있게 한다.
|
||||
|
||||
### Domain context propagation
|
||||
|
||||
`DomainContextPropagator`는 diagnostic MDC와 분리된 domain/business context channel이다. default `ThreadLocalDomainContextPropagator`는 plain `ThreadLocal`을 쓰고 implicit inheritance를 금지하며 `capture()/restore()`와 `wrap()`으로 명시적 hand-off를 수행한다. virtual-thread test는 wrap을 썼을 때 전달되고 쓰지 않았을 때 상속되지 않으며 scope close 뒤 worker context가 복원되는 것을 검증한다.
|
||||
|
||||
이 seam은 문서상 계획에 그치지 않는다. production reachability 검색에서 `app-bootstrap`의 `DomainContextConfig`, `AsyncContextTaskDecorator`, `AsyncExecutorConfig`, persistence-jpa audit adapter, sample composition config가 실제로 소비하는 것이 확인됐다.
|
||||
|
||||
`DomainContextKey` equality/hash는 **name only**이고 read 시 요청 key의 `Class<T>`로 cast한다. 동일 이름의 서로 다른 type key를 만들면 같은 slot을 공유할 수 있고 잘못된 type으로 읽을 때 `ClassCastException` 가능성이 있다. source javadoc이 name-only identity를 명시하므로 hidden implementation bug로 단정하지 않지만, 현재 test는 same-name/different-type collision을 pin 하지 않는다. P1 contract-hardening 후보로 남긴다.
|
||||
|
||||
### Operational record store
|
||||
|
||||
`OperationalRecordStorePort`는 durable operational state를 특정 inbound/outbound adapter에 종속시키지 않는 neutral CAS port다. record version 0은 absent를 뜻하며 compareAndSet/compareAndRemove의 expectedVersion이 lost update 방지 evidence 역할을 한다. GraphQL persisted-operation adapter가 이 port를 실제 production dependency로 사용하며, durable provider implementation 자체는 해당 inbound adapter에 들어있지 않다.
|
||||
|
||||
`OperationalRecord`는 namespace/key non-blank와 version >= 0은 강제하지만 javadoc의 "bounded"라는 표현에 대응하는 길이/character limit은 source에 없다. 이는 문서와 constructor enforcement 강도의 차이이며 P2 확인 후보로 남긴다.
|
||||
|
||||
### Activation and health snapshot
|
||||
|
||||
`MasterSwitchParser`는 unset=false, true/false case-insensitive만 허용하며 `yes`, `1`, `on`, whitespace-padded value를 invalid로 처리한다. canonical+legacy가 동시에 있으면 값이 같아도 ambiguous로 실패하고 legacy-only는 replacement property를 반환한다. 이는 operator configuration을 permissive coercion하지 않는 fail-closed contract다.
|
||||
|
||||
`RedisHealthSnapshotProvider`는 Redis client/connection/credential을 shared boundary로 새지 않게 role/capability/state/reason/semantic freshness만 projection한다. eviction policy는 runtime CONFIG 조회 증명이 아니라 configured expectation임을 enum 이름과 javadoc으로 명시한다.
|
||||
|
||||
### Messaging envelope schema
|
||||
|
||||
`contracts/messaging/envelope/v1.schema.json`은 Draft 2020-12 schema resource이며 envelopeVersion/eventId/contractId/payloadVersion/logicalDestination/aggregate/occurredAt/correlationId/contentType/payload를 required로 고정하고 top-level/aggregate에 `unevaluatedProperties:false`를 둔다. checked-in SHA-256은 `bf6f2e13fafe01b8ef4cbb73d7ba3f5703bfc68d145bdfe43190bf606dbd00b1`이다.
|
||||
|
||||
`MessagingEnvelopeSchemaResourceTest`는 schema text 자체, identifier regex parity, Java int/long 경계 vector, strict UTF-8, exact digest를 JDK API로 검증한다. 이 테스트는 resource drift와 digest mismatch를 강하게 막지만 README가 명시하듯 실제 Draft 2020-12 validator interoperability나 broker runtime discovery를 증명하지는 않는다.
|
||||
|
||||
## Reachability / wiring evidence
|
||||
|
||||
- `DomainContextPropagator`: app-bootstrap composition + async decorator, persistence-jpa audit adapter, sample composition에서 production use 확인.
|
||||
- `OperationalRecordStorePort`: inbound GraphQL persisted-operation registry에서 production use 확인. 이 방향성은 adapter-specific storage interface를 outbound가 구현하는 역방향 dependency를 피한다.
|
||||
- registry상 shared-contract는 다른 production project를 의존하지 않는 leaf이며 app-bootstrap/sample-portfolio runtime membership을 가진다.
|
||||
- rate-limit, response, error 등의 세부 consumer 전체는 각 adapter/application bounded scope에서 추가 분석할 대상이며 이번 scope에서는 representative reachability와 contract 자체를 완전 읽기 대상으로 삼았다.
|
||||
|
||||
## Verification
|
||||
|
||||
실제 실행 결과:
|
||||
|
||||
- `./gradlew :shared-contract:test --console=plain` → BUILD SUCCESSFUL, exit 0
|
||||
- `./gradlew :shared-contract:edgeRateLimitContractTest --console=plain` → BUILD SUCCESSFUL, exit 0
|
||||
- source revision 확인: `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
|
||||
- `git status --short` → output 없음, working tree clean
|
||||
|
||||
## Coverage ledger
|
||||
|
||||
분모는 `src/main` 전체 파일, `src/test` 전체 파일, custom `edgeRateLimitContractTest` source, 그리고 module-level `CLAUDE.md`, `README.md`, `build.gradle`이다.
|
||||
|
||||
- FULL_READ: 82
|
||||
- main production/resource 55
|
||||
- unit/contract test 23
|
||||
- edgeRateLimitContractTest 1
|
||||
- module policy/rationale/build 3
|
||||
- STRUCTURAL_ONLY: 4
|
||||
- production placeholder `.gitkeep` 3
|
||||
- test placeholder `.gitkeep` 1
|
||||
- EXCLUDED: 0
|
||||
- UNCLASSIFIED: 0
|
||||
|
||||
따라서 selected bounded scope는 completion standard를 충족한다.
|
||||
|
||||
## Open questions / improvement backlog
|
||||
|
||||
### P1 — response/LRO invariant enforcement boundary
|
||||
|
||||
`Envelope`, `BulkEnvelope`, `Operation`, `PageMeta`의 문서상 valid shape가 factory tests에는 고정되어 있지만 public canonical constructor에서 강제되지 않는다. raw constructor 사용이 실제로 허용된 extension surface인지, 아니면 constructor-level validation으로 invalid state를 막아야 하는지 결정이 필요하다.
|
||||
|
||||
### P1 — DomainContextKey same-name different-type collision
|
||||
|
||||
key identity가 name only인 반면 retrieval은 requested type cast를 수행한다. 동일 name의 다른 `Class<T>` key를 선언하는 것이 forbidden contract라면 creation-time collision 방지 또는 registry rule/test가 필요하고, 의도적으로 허용한다면 failure semantics를 문서화할 필요가 있다.
|
||||
|
||||
### P2 — bounded operational record identifiers
|
||||
|
||||
`OperationalRecord` javadoc은 namespace/key를 bounded라고 설명하지만 constructor는 blank 여부만 확인한다. provider key size/character-set 제한을 shared contract가 소유해야 하는지 확인이 필요하다.
|
||||
|
||||
### P2 — permission component grammar
|
||||
|
||||
permission은 colon segment 수, blank, normalization은 강제하지만 segment character grammar는 제한하지 않는다. registry SSOT가 더 좁은 grammar를 요구한다면 shared value object와 parity test가 필요하다.
|
||||
|
||||
### P2 — messaging schema qualification boundary
|
||||
|
||||
현재 JDK-only test는 exact resource/digest/selected semantic vectors를 검증한다. Draft 2020-12 validator 호환성은 별도 qualification evidence가 필요하며 현재 module test 성공만으로 이를 주장해서는 안 된다.
|
||||
|
||||
## 다음 scope
|
||||
|
||||
queue의 동일 active project를 유지하고 다음 PENDING scope인 `application-core`를 다음 실행에서 분석한다.
|
||||
|
||||
## Source anchors
|
||||
|
||||
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **40개** (main 35 · test 2 · 기타 3).
|
||||
|
||||
```
|
||||
src/shared-contract/build.gradle
|
||||
src/config/architecture/modules.json (shared-contract 항목)
|
||||
|
||||
main:
|
||||
src/main/java/dev/caskeleton/shared/activation/MasterSwitchParser.java
|
||||
src/main/java/dev/caskeleton/shared/concurrency/DomainContextKey.java
|
||||
src/main/java/dev/caskeleton/shared/concurrency/DomainContextPropagator.java
|
||||
src/main/java/dev/caskeleton/shared/concurrency/ThreadLocalDomainContextPropagator.java
|
||||
src/main/java/dev/caskeleton/shared/error/AdapterDisabledException.java
|
||||
src/main/java/dev/caskeleton/shared/error/ApiErrorCarrier.java
|
||||
src/main/java/dev/caskeleton/shared/error/ApiErrorCode.java
|
||||
src/main/java/dev/caskeleton/shared/error/Category.java
|
||||
src/main/java/dev/caskeleton/shared/error/DependencyFailureException.java
|
||||
src/main/java/dev/caskeleton/shared/error/OperationalError.java
|
||||
src/main/java/dev/caskeleton/shared/error/PersistenceFailureException.java
|
||||
src/main/java/dev/caskeleton/shared/health/RedisHealthSnapshotProvider.java
|
||||
src/main/java/dev/caskeleton/shared/metrics/CardinalityBounds.java
|
||||
src/main/java/dev/caskeleton/shared/metrics/ForbiddenMetricTags.java
|
||||
src/main/java/dev/caskeleton/shared/metrics/MetricNaming.java
|
||||
src/main/java/dev/caskeleton/shared/operation/Operation.java
|
||||
src/main/java/dev/caskeleton/shared/opstore/OperationalRecord.java
|
||||
src/main/java/dev/caskeleton/shared/opstore/OperationalRecordStorePort.java
|
||||
src/main/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitSubject.java
|
||||
src/main/java/dev/caskeleton/shared/ratelimit/RateLimitDecision.java
|
||||
src/main/java/dev/caskeleton/shared/ratelimit/RateLimitEvaluationDedupPolicy.java
|
||||
src/main/java/dev/caskeleton/shared/ratelimit/RateLimitOutcome.java
|
||||
src/main/java/dev/caskeleton/shared/ratelimit/RateLimitPolicy.java
|
||||
src/main/java/dev/caskeleton/shared/ratelimit/RateLimitRequest.java
|
||||
src/main/java/dev/caskeleton/shared/ratelimit/RateParameters.java
|
||||
src/main/java/dev/caskeleton/shared/request/Patch.java
|
||||
src/main/java/dev/caskeleton/shared/response/BulkEnvelope.java
|
||||
src/main/java/dev/caskeleton/shared/response/Envelope.java
|
||||
src/main/java/dev/caskeleton/shared/response/PageMeta.java
|
||||
src/main/java/dev/caskeleton/shared/response/ResponseMeta.java
|
||||
src/main/java/dev/caskeleton/shared/security/Permission.java
|
||||
src/main/java/dev/caskeleton/shared/tracing/BaggageAllowlist.java
|
||||
src/main/java/dev/caskeleton/shared/tracing/SpanErrorRecorder.java
|
||||
src/main/java/dev/caskeleton/shared/tracing/TraceParent.java
|
||||
src/main/resources/contracts/messaging/envelope/v1.schema.json
|
||||
|
||||
test:
|
||||
src/test/java/dev/caskeleton/shared/contract/messaging/MessagingEnvelopeSchemaResourceTest.java
|
||||
src/test/java/dev/caskeleton/shared/error/OperationalErrorTest.java
|
||||
|
||||
기타:
|
||||
CLAUDE.md
|
||||
README.md
|
||||
src/build.gradle
|
||||
|
||||
```
|
||||
@@ -0,0 +1,454 @@
|
||||
# application-core 상세 분석
|
||||
|
||||
|
||||
## SSOT identity — 2026-08-31 재검증
|
||||
|
||||
- registered leaf id: `application-core`
|
||||
- canonical state `analysisFile`: `analysis/03-application-core.md` (이 문서) — 이 leaf의 단일 SSOT
|
||||
- source path: `src/application-core` · Gradle `:application-core`
|
||||
- registry `allowed_dependencies`: `["domain-core", "shared-contract"]`
|
||||
- registry `runtime_memberships`: `["app-bootstrap", "sample-portfolio"]`
|
||||
- coverage ledger: `FULL_READ` **1021** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
|
||||
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
|
||||
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행)
|
||||
|
||||
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
|
||||
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
|
||||
|
||||
---
|
||||
Status: COMPLETE
|
||||
Source revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
|
||||
Analysis cycle: 1 (normal)
|
||||
|
||||
## 1. 분석 범위와 완료 기준
|
||||
|
||||
`application-core`는 `src/application-core` 하나의 Gradle leaf이지만 작은 use-case 모듈이 아니다. framework-free inbound use-case 계약, transaction/idempotency/outbox/inbox, cache/lease/lock, durable operation, realtime, object/file publication, fileserver, notification까지 애플리케이션 정책과 outbound port를 폭넓게 소유한다.
|
||||
|
||||
이번 분석의 source denominator는 `src/application-core/src` 아래 Java source 전부다. `src/main` 885개, `src/test` 136개로 총 1,021개이며 resource/non-Java source는 없다. `build/`의 generated output은 source coverage에서 제외했다. `CLAUDE.md`, `README.md`, `build.gradle`, downstream adapter/bootstrap/architecture-test source는 별도 source anchor로 읽었으며 1,021개 denominator에는 포함하지 않는다.
|
||||
|
||||
| top-level package | production | test | disposition |
|
||||
|---|---:|---:|---|
|
||||
| cache | 29 | 5 | FULL_READ |
|
||||
| capability | 3 | 1 | FULL_READ |
|
||||
| command | 1 | 0 | FULL_READ |
|
||||
| fileexport | 2 | 0 | FULL_READ |
|
||||
| filepublication | 16 | 1 | FULL_READ |
|
||||
| fileserver | 166 | 38 | FULL_READ |
|
||||
| idempotency | 34 | 6 | FULL_READ |
|
||||
| inbox | 9 | 1 | FULL_READ |
|
||||
| lease | 14 | 2 | FULL_READ |
|
||||
| lock | 3 | 2 | FULL_READ |
|
||||
| messaging | 13 | 3 | FULL_READ |
|
||||
| notification | 415 | 51 | FULL_READ |
|
||||
| objectstorage | 88 | 5 | FULL_READ |
|
||||
| observability | 2 | 1 | FULL_READ |
|
||||
| operation | 11 | 2 | FULL_READ |
|
||||
| outbound | 1 | 1 | FULL_READ |
|
||||
| outbox | 28 | 9 | FULL_READ |
|
||||
| query | 1 | 0 | FULL_READ |
|
||||
| realtime | 14 | 0 | FULL_READ |
|
||||
| security | 7 | 2 | FULL_READ |
|
||||
| storage | 8 | 0 | FULL_READ |
|
||||
| transaction | 17 | 5 | FULL_READ |
|
||||
| usecase | 3 | 1 | FULL_READ |
|
||||
| **합계** | **885** | **136** | **FULL_READ 1,021 / UNCLASSIFIED 0** |
|
||||
|
||||
따라서 이 문서에서 `COMPLETE`는 “대표 파일을 샘플링했다”는 뜻이 아니라 source denominator 전체를 읽고 package별 contract/invariant/test/wiring을 분류했다는 뜻이다.
|
||||
|
||||
## 2. 모듈 경계와 빌드 의존성
|
||||
|
||||
**Observed.** `build.gradle`의 production project dependency는 `:shared-contract` 하나뿐이다. application-core가 Spring, JPA, Redis, Kafka, filesystem provider 같은 구현 모듈을 직접 참조하지 않고, 외부 구현은 composition root와 adapter가 역으로 이 모듈의 port를 구현한다.
|
||||
|
||||
**Observed.** `CommandUseCase<C extends Command,R>`와 `QueryUseCase<Q extends Query,R>`는 `UseCase<I,O>.handle(I)`를 write/read intent에 맞게 타입으로 좁힌다. 자체적으로 transaction을 열거나 security interceptor를 실행하지 않는다. 실행 정책은 `@UseCaseCapability`에 별도로 선언된다.
|
||||
|
||||
**Observed.** `@UseCaseCapability`는 runtime TYPE annotation이며 `transactionMode`, `idempotency`, `repositoryAccess`를 필수로 받고 `externalOutboundAllowed`, `sensitiveRead`, `bulkWrite`, `crossTenantAdmin`을 추가 선언한다. annotation 자체는 metadata에 불과하지만 `CleanArchitectureTest`가 concrete Command/Query use case에 annotation 존재를 강제한다.
|
||||
|
||||
**Observed.** architecture fitness function은 다음 coherence를 직접 검사한다.
|
||||
|
||||
- `READ_ONLY + READ_REPOSITORY`는 `TransactionPort.inRead`를 직접 호출해야 한다.
|
||||
- `WRITE + WRITE_REPOSITORY`는 `inWrite` 또는 `inRootWrite`를 직접 호출해야 한다.
|
||||
- `REQUIRES_NEW`는 `inNew`를 직접 호출해야 한다.
|
||||
- `repositoryAccess != WRITE_REPOSITORY`인 use case가 repository write verb를 직접 호출하면 실패한다.
|
||||
- `bulkWrite=true`는 `WRITE_REPOSITORY`를 요구한다.
|
||||
- mutating use case는 type-level `@RequiresPermission`을 선언해야 한다.
|
||||
- application/domain은 Spring Security에 의존할 수 없다.
|
||||
|
||||
이 enforcement에는 의도적으로 한계가 있다. ArchUnit의 direct-call 분석이므로 helper 뒤에 숨은 repository mutation/transaction call은 잡지 못하고, AOP self-invocation/non-bean path도 static rule만으로 보장하지 않는다. 이 제한은 테스트 설명 자체에 명시돼 있어 최종 계약의 일부로 봐야 한다.
|
||||
|
||||
## 3. authorization: permission과 object access를 분리한다
|
||||
|
||||
`AuthorizationPort`는 principal의 raw role/permission을 기준으로 “이 종류의 작업을 수행할 수 있는가”를 판정하는 framework-free PEP다. `AuthorizationPrincipal`은 role set을 defensive copy + unmodifiable로 만들고 null roles는 empty set으로 정규화한다. `AuthorizationDeniedException`은 Spring `AccessDeniedException` 대신 application-owned failure를 사용한다.
|
||||
|
||||
object-level access는 별도 `ObjectAccessPolicy`가 담당한다. 같은 permission을 가진 사용자라도 ownership, membership, workflow state에 따라 특정 object 접근 결과가 달라질 수 있기 때문이다. `ObjectAccessDecision`은 denial에 stable code를 요구하고 `hideExistence`를 별도 boolean으로 보존해 transport가 403/404 disclosure 정책을 추측하지 않게 한다.
|
||||
|
||||
**Historical evidence.** `ObjectAccessPolicyTest`에는 이 계약이 과거 inbound GraphQL adapter에 있었고 GraphQL request context를 signature에 포함해 application-core가 구현하려면 transport에 역의존해야 했던 문제가 기록돼 있다. 현재 regression test는 policy/request/decision signature에 `dev.caskeleton.adapter.*` 타입이 다시 등장하면 실패한다. 이 프로젝트에서 “여러 호출자가 공유해야 하는 계약을 inbound adapter가 소유하면 Core가 Adapter에 의존하게 된다”는 문제가 실제로 있었던 근거다.
|
||||
|
||||
`decideAll()`의 default는 요청 순서를 보존하지만 object마다 `decide()`를 호출한다. set-based authorization을 제공하는 구현체가 override하지 않으면 batched loading 안에서 authorization N+1을 다시 만들 수 있다는 제한도 계약에 명시돼 있다.
|
||||
|
||||
## 4. transaction: framework vocabulary 대신 application semantic policy
|
||||
|
||||
`TransactionPort`는 `inWrite`, `inRootWrite`, `inRead`, `inNew` 네 개의 framework-neutral boundary를 노출한다. `PolicyTransactionPort`는 기존 surface를 깨지 않고 `TransactionRequest -> TransactionResult` 정책 기반 API를 추가한다.
|
||||
|
||||
`TransactionPolicyId`는 Spring propagation 숫자가 아니라 `COMMAND_DEFAULT`, `COMMAND_SERIALIZABLE_REPLAY_SAFE`, `QUERY_PRIMARY`, `QUERY_REPLICA_ELIGIBLE`, `OUTBOX_APPEND`, `INBOX_AND_HANDLER`, `MAINTENANCE_NEW`처럼 application semantic ID를 노출한다. `TransactionRequest` constructor는 read policy의 consistency allowlist, non-read의 readConsistency 금지, operationId-required policy의 stable id 존재를 fail-fast한다.
|
||||
|
||||
`TransactionResult`는 commit 결과를 다섯 상태로 분리한다.
|
||||
|
||||
- `Committed`: physical commit을 확인한 결과.
|
||||
- `Participating`: outer transaction에 참여했지만 아직 commit을 주장할 수 없는 결과.
|
||||
- `DeterminateRollback`: rollback이 확정된 실패.
|
||||
- `Indeterminate`: commit 여부를 확정할 수 없는 결과.
|
||||
- `CommittedWithPostCommitFailure`: commit은 됐지만 이후 operational cleanup이 실패한 결과.
|
||||
|
||||
이 algebra의 핵심은 “exception이 발생했다 = rollback”으로 단순화하지 않는 것이다. 특히 `Indeterminate`는 last observed transaction phase와 optional reconciliation reference를 보존하며, `CompletionResolution`은 `STILL_UNKNOWN`을 정식 상태로 둔다. 불확실한 commit을 임의로 NOT_COMMITTED로 가정해 use case를 재실행하는 것을 피한다.
|
||||
|
||||
`OperationId`는 caller-owned opaque identity이며 1~128 printable non-whitespace ASCII로 제한된다. `ReconciliationReference`도 1~256으로 bounded/sanitized된다.
|
||||
|
||||
**Historical evidence.** `TransactionCompletionResolver`는 과거 JPA transaction engine 옆에 있었지만 실제 commit 증거(고유 제약, business row, idempotency row, outbox row)를 해석하는 주체는 application/domain이어서 dependency direction이 뒤집히는 문제가 있었다. 현재 SPI는 application-core에 있고 adapter가 이 계약에 의존한다. resolver는 evidence read만 해야 하며 original use case 재실행을 금지한다.
|
||||
|
||||
`IrreversibleSideEffectContext` 역시 persistence 쪽에서 application-core로 이동했다. use case가 email/payment/broker/object-storage 같은 rollback 불가능한 effect 직전에 `mark()`해야 retry coordinator가 해당 attempt를 재실행하지 않을 수 있기 때문이다. ThreadLocal marker는 in-transaction external I/O를 권장하는 장치가 아니라 아직 제거되지 못한 side effect에서 unsafe retry를 차단하는 fence다.
|
||||
|
||||
### 4.1 Spring/JPA 구현까지 추적한 결과
|
||||
|
||||
`SpringTransactionPort`는 `PolicyTransactionPort`를 구현한다. write/read/root-write는 REQUIRED, independent write는 REQUIRES_NEW이며 모든 legacy template은 provider default가 아니라 READ_COMMITTED를 명시적으로 pin한다. read template만 read-only다.
|
||||
|
||||
`inRootWrite`는 `TransactionSynchronizationManager.isActualTransactionActive()`를 transaction manager나 action 호출 전에 검사한다. focused adapter test는 ambient transaction이 있으면 action도 transaction manager도 호출되지 않음을 증명한다. 또 action 반환값은 physical commit 이후에만 caller에게 반환되고 commit failure 시 caller-visible result가 publish되지 않음을 검증한다.
|
||||
|
||||
`Isolation` enum에는 READ_COMMITTED/REPEATABLE_READ/SERIALIZABLE이 있지만 `IsolationTest`는 stricter level routing이 아직 planned라고 명시한다. 따라서 enum vocabulary가 존재한다는 사실을 “현재 legacy TransactionPort에서 세 isolation을 선택할 수 있다”로 확대 해석하면 안 된다.
|
||||
|
||||
## 5. idempotency, inbox, outbox: uncertainty를 상태로 보존한다
|
||||
|
||||
### 5.1 idempotency
|
||||
|
||||
초기 contract는 scope + request fingerprint로 claim/replay를 제공하고, same key/different fingerprint를 conflict로 분리한다. completed result는 replay하고 in-flight는 bounded poll한다. 이 버전은 “DB operation의 효과가 이미 발생했지만 응답만 잃은 상태”를 충분히 표현하지 못한다.
|
||||
|
||||
V2는 owner-safe CAS handle에 scope/token/attempt/revision/claimOperationId를 넣고 stale owner mutation을 거부한다. processing-start를 durable하게 확인하기 전에는 body를 실행하지 않으며 claim/start/completion의 unknown result는 inspect/reconcile 대상으로 남긴다. processing start 이후 ordinary RuntimeException은 효과가 없다고 증명할 수 없으므로 `EFFECT_UNKNOWN_ABANDONED` 쪽으로 분류되고 자동 replay 권한을 주지 않는다. 명시적인 `RetryableNoEffect`만 안전 재시도 근거로 취급한다.
|
||||
|
||||
scope digest는 versioned keyed digest + operation code로 정규화되고 raw identity는 외부 surface에서 제거된다. lease/replay TTL과 owner token grammar도 bounded다.
|
||||
|
||||
**Historical evidence.** V2 contract가 인접한 package에 중복 복제돼 구현체들이 서로 다른 nominal type을 참조한 문제가 있었고, singular contract를 유지하는 regression test가 존재한다.
|
||||
|
||||
### 5.2 inbox
|
||||
|
||||
Inbox contract는 same-store 처리와 owner-safe receive/process state를 모델링한다. `RECEIVED -> PROCESSING -> COMPLETED/RETRYABLE/DEAD` 상태를 가지고 ACK는 handler transaction commit 이후에만 가능하다. expired owner가 늦게 결과를 기록하는 것을 owner token/attempt/revision/operation identity로 막는다. acquire/processing uncertainty 역시 provider-neutral typed outcome으로 보존한다.
|
||||
|
||||
### 5.3 outbox
|
||||
|
||||
Outbox append는 caller write transaction에 참여하고 broker publish는 transaction 밖에서 수행한다. claim/status transition만 짧은 transaction으로 분리한다. publish outcome은 accepted/ambiguous/rejected/exception을 구분한다.
|
||||
|
||||
payload validator는 append boundary에서 poison event를 미리 차단한다. legacy JSON payload는 256 KiB, depth 64 등 bounded parser contract를 갖고 control/trailing content를 거부한다. 과거 relay에서야 invalid payload를 발견해 batch를 독성화하던 문제가 이 boundary 이동의 근거다.
|
||||
|
||||
accepted 후 `markPublished`가 실패하면 row는 IN_FLIGHT에 남고 lease expiry 뒤 reclaim되어 **중복 publish가 실제로 가능하다**. 테스트는 이 duplicate window를 숨기지 않고 증명한다. 따라서 이 outbox는 exactly-once가 아니라 at-least-once + downstream dedupe 모델이다.
|
||||
|
||||
V2는 immutable event version/ordinal, DB-authoritative receipt retention/publication epoch/dispatch authority를 추가한다. claim batch와 lease가 bounded되고 owner-safe CAS가 사용된다.
|
||||
|
||||
## 6. durable operation: process-local future 대신 durable state machine
|
||||
|
||||
`DurableOperation`은 PENDING/RUNNING/SUCCEEDED/FAILED/CANCELED/EXPIRED를 저장 가능한 state로 모델링한다. RUNNING은 lease가 필수이고 terminal state는 completion timestamp가 필수이며 terminal state가 lease를 유지할 수 없다. SUCCEEDED는 result reference, FAILED는 failure가 필수다.
|
||||
|
||||
`DurableOperationStorePort`의 running-state mutation은 worker identity를 받아 stale worker가 takeover 이후 result/progress를 기록하지 못하게 한다. expired lease는 reclaim되어 PENDING으로 돌아가고 TTL이 지난 nonterminal record는 EXPIRED 처리된다.
|
||||
|
||||
`SubmitDurableOperationUseCase`는 operation row와 outbox publish intent를 **같은 `inWrite` transaction** 안에 기록한다. broker를 직접 호출하지 않는다. 테스트는 outbox write failure 시 operation row도 rollback되어 둘 중 하나만 남는 상태를 막는 것을 검증하고, identical resubmission은 기존 operation을 반환하며 두 번째 outbox row를 만들지 않는 것을 검증한다.
|
||||
|
||||
## 7. cache, lease, lock: 동시성 완화와 correctness authority를 구분한다
|
||||
|
||||
### 7.1 cache
|
||||
|
||||
`CacheAsideExecutor`는 fresh/negative hit, hard miss, stale, incompatible schema, provider unavailable을 명시적으로 구분한다. source load에는 key-local single-flight와 global source bulkhead를 함께 적용한다. in-flight key 수, waiter 수, source concurrency, admission wait, load deadline이 모두 bounded다.
|
||||
|
||||
stale value는 hard expiry 이전이며 **classified transient failure**일 때만 fallback될 수 있다. permanent failure에는 stale을 반환하지 않는다. source load 중 invalidation이 발생하면 lookup 때 캡처한 `CacheWriteCondition`이 더 이상 일치하지 않아 이전 source result의 refill을 거부한다. 이는 invalidate 직후 늦게 끝난 source load가 stale value를 resurrect하는 race를 막는다.
|
||||
|
||||
optional distributed refresh coordination은 soft lease로 한 pod만 refresh하도록 하지만 correctness lock은 아니다. owner는 lease 획득 후 cache를 재확인해 다른 pod가 이미 fill했다면 source를 호출하지 않는다. claim 결과가 indeterminate이면 **동일 attempt token으로 한 번만 재시도**한다. contender는 stale이 아직 valid하면 즉시 stale을 반환할 수 있다.
|
||||
|
||||
`CacheSingleFlight`는 waiter timeout/interruption을 보존하고 완료된 flight를 제거한다. leader가 영원히 남아 key bound를 점유하지 않도록 monotonic deadline 이후 abandoned flight를 opportunistic reap한다.
|
||||
|
||||
### 7.2 distributed lease
|
||||
|
||||
V2 `DistributedLeasePort`는 caller가 provider send 전에 owner/operation token을 생성하고 acquire retry/inspection에서 동일 attempt를 유지하게 한다. response-loss uncertainty를 `Indeterminate`로 별도 표현한다. resource는 raw key가 아니라 versioned lowercase SHA-256 digest를 사용하고 toString은 token/digest를 redaction한다.
|
||||
|
||||
`LeaseGuarantee`는 명시적으로 `EFFICIENCY_ONLY` 하나다. 즉 generic lease는 duplicate work를 줄일 뿐 correctness-sensitive write를 authorize할 수 없다. `LeaseWatchdog`도 renewal failure/unknown이면 work cancellation과 loss signal을 한 번만 발생시키는 bounded scheduler일 뿐 process pause/Redis failover를 correctness guarantee로 감추지 않는다.
|
||||
|
||||
### 7.3 distributed lock
|
||||
|
||||
`DistributedLockPort`도 문서상 efficiency lock이다. finite wait와 crash-safety TTL을 갖지만 DB constraint 같은 correctness authority를 대체하지 않는다. lock release는 protected transaction commit 이후에 수행해야 한다. timeout은 shared `OperationalError.LOCK_ACQUISITION_TIMEOUT`으로 매핑 가능한 application exception으로 표현된다.
|
||||
|
||||
## 8. messaging과 realtime은 provider/transport vocabulary를 밖으로 밀어낸다
|
||||
|
||||
messaging application contract catalog는 contract id, logical destination, schema resource, ordering, payload/envelope bounds, sensitivity, retry/requeue horizon 등 semantic 정보만 가진다. Kafka topic/provider runtime type은 public contract에 없다. validated integration event는 partition key, schema/content hash, catalog/binding revision 같은 immutable evidence를 보존한다.
|
||||
|
||||
strict `messagingApplicationContractQualificationTest`는 normal test source set의 세 required class를 no-skip 조건으로 실행한다. 처음 digest property 없이 실행했을 때 `prepareMessagingContractEvidence`가 fail-closed로 거부했다. current source/archive, current application-core JAR, exact profile file의 SHA-256을 공급한 재실행에서는 **15 tests, 0 skipped, BUILD SUCCESSFUL**이었다. 즉 qualification은 단순 테스트 이름이 아니라 evidence provenance property까지 요구한다.
|
||||
|
||||
realtime contract는 durable fanout과 ephemeral fanout을 분리한다. durable은 accepted와 delivered를 동일시하지 않고 stream+position dedupe/replay를 모델링한다. stale cursor는 resnapshot 요구로 분리된다. presence는 non-authoritative이며 TTL/heartbeat failure 시 empty로 degrade할 뿐 security 판단에 사용하지 않는다. logical channel은 WebSocket/STOMP 같은 transport 명칭을 소유하지 않는다.
|
||||
|
||||
## 9. storage/file publication: legacy 경로와 semantic 경로가 공존한다
|
||||
|
||||
`application.storage.ObjectStoragePort`는 raw object key/whole-byte 방식의 legacy contract이며 `forRemoval` 표시가 있지만 실제 production consumer가 남아 있다. sample poster upload, adapter/config, characterization test에서 사용되므로 dead code로 분류할 수 없다. 제거 시점은 날짜가 아니라 실제 migration/zero usage로 판단하도록 문서화돼 있다.
|
||||
|
||||
`fileexport` 역시 raw filesystem path를 반환하는 opt-in legacy capability이며 `FilesystemCsvExportAdapter`/configuration을 통해 조건부 활성화된다.
|
||||
|
||||
반대로 `filepublication`은 logical destination, operation/reference/version, schema, row streaming/checkpoint, durability semantic을 provider-neutral 계약으로 만든다. CSV formula injection(`=`, `+`, `-`, `@`, tab, CR)을 reject하는 정책이 테스트로 고정돼 있고, raw Path/SFTP/fileserver 타입이 receipt surface에 나오지 않는다.
|
||||
|
||||
## 10. objectstorage: staged lifecycle, opaque identity, privilege separation
|
||||
|
||||
semantic objectstorage API는 provider/filesystem type을 노출하지 않는다. object identity/reference는 prefix + check digit를 포함한 opaque routed representation이고 redacted rendering을 제공한다. tampered/cross-prefix reference를 거부한다.
|
||||
|
||||
content I/O는 bounded pull/push callback context와 budget/cancellation/chunk contract를 사용하며 callback lifetime 밖에서 context를 재사용할 수 없다. zero-progress가 무한 loop로 이어지지 않도록 bounded 후 실패한다.
|
||||
|
||||
lifecycle은 staged -> verified -> published를 분리한다. scanner verdict는 exact stage/version/operation/policy revision에 결합되고 publish/cleanup mutation은 exact-version/fencing을 요구한다. scanner 권한과 purge 권한은 분리돼 검증 주체가 임의 삭제까지 할 수 없게 한다.
|
||||
|
||||
transient bearer grant는 URI/header를 redaction하고 TTL은 최대 24시간으로 제한한다. multipart part count는 1..10000이고 completion은 expected content identity를 요구한다. `FullContentIdentity`는 SHA-256 기반으로 ETag를 content identity로 오인하지 않는다.
|
||||
|
||||
## 11. fileserver: DB metadata와 physical content 사이의 실패 seam을 명시한다
|
||||
|
||||
fileserver는 application-core 안의 가장 큰 독립 orchestration 중 하나다. 핵심 contract는 “metadata transaction과 filesystem/object I/O가 원자적이지 않다”는 사실을 숨기지 않고 recovery model을 두는 것이다.
|
||||
|
||||
### 11.1 upload/write fencing
|
||||
|
||||
upload admission은 authorization을 quota/storage admission보다 먼저 수행해 denial이 side-effect-free이도록 한다. reservation metadata/session은 DB transaction에서 만들지만 staging physical object는 외부 작업이므로 실패 시 compensation/reconciliation 대상이 된다.
|
||||
|
||||
writer는 one-writer lease + fencing token을 사용한다. stale token은 append/finalize를 진행할 수 없고 takeover는 새 token을 만든다. append 시 metadata offset과 physical length가 다르면 자동 repair하지 않고 conflict로 중단한다.
|
||||
|
||||
finalize는 declared length, server-computed digest, optional client digest를 순서대로 검사한다. client digest는 server digest를 대체하지 않는다. 이후 VERIFYING으로 이동하고 verifier가 publish 승인해야 READY가 된다. READY가 유일한 public/downloadable state다.
|
||||
|
||||
publish physical success 뒤 READY metadata transaction이 실패하면 결과는 단순 retryable failure가 아니라 `AmbiguousCompletionException`과 recovery queue로 간다. physical publish가 이미 발생했을 수 있기 때문이다.
|
||||
|
||||
### 11.2 cleanup/recovery
|
||||
|
||||
cancel/cleanup race를 막기 위해 cleanup claim은 writer/cleaner barrier를 형성하며 stale cleanup claim을 reclaim하는 경로가 실제로 호출된다. physical delete 전에는 terminal state, lease, exact size/digest/metadata key를 재확인한다.
|
||||
|
||||
orphan reconciliation은 reference race를 다시 검사하고 retire/quarantine 후 physical purge를 분리한다. recovery 결과는 `CONFIRMED_SUCCESS`, `NOT_APPLIED`, `RECOVERABLE_PARTIAL`, `QUARANTINE_REQUIRED`, `UNRESOLVED` 등으로 unknown을 추측하지 않는다. READY인데 physical content가 없거나 digest가 불일치하면 정상으로 가장하지 않고 quarantine한다.
|
||||
|
||||
### 11.3 download/security/HTTP semantics
|
||||
|
||||
download authorization은 physical open보다 먼저 수행된다. HTTP precondition ordering을 명시하고 range 수를 최대 8개로 제한하며 overlap을 merge한다. malformed/unsatisfiable range는 typed 416 path로 분리되고 HEAD는 body를 열지 않는다.
|
||||
|
||||
stored-XSS 위험이 있는 HTML/SVG/XHTML/JavaScript 계열은 attachment로 강제하며 filename sanitizer는 path/control/bidi/Windows reserved-name을 처리한다. observability는 raw file/path/user id 대신 HMAC fingerprint와 low-cardinality dimensions를 사용한다.
|
||||
|
||||
## 12. notification: logical acceptance, provider uncertainty, callback reconciliation
|
||||
|
||||
notification은 application-core production 415개로 가장 큰 package다. public API, contact protection, routing/template, dispatch, callback, admin/operator plane까지 application-level semantics를 소유한다.
|
||||
|
||||
### 12.1 public API와 secret boundary
|
||||
|
||||
public contract는 arbitrary `Object`/`Map<String,Object>`를 허용하지 않고 sealed `NotificationVariable` algebra를 사용한다. 과거 mutable/arbitrary variable 때문에 serialization/fingerprint drift와 `toString` collision이 가능했던 것이 변경 근거다. structural test는 public API에 arbitrary Object가 다시 들어오지 않는지 검사하며, 과거 잘못된 test root로 vacuous pass했던 문제도 regression guard로 남아 있다.
|
||||
|
||||
`NotificationPlan`은 exact template version을 pin한다. recipient/metadata/variable count/depth가 bounded되어 있고 receipt는 “durable logical acceptance”이지 provider delivery를 의미하지 않는다.
|
||||
|
||||
contact point는 encrypted value + keyed fingerprint로 분리되고 protected contact rendering은 원문을 노출하지 않는다. template variable 자체에 reset token 같은 secret이 들어갈 수 있어 payload protection이 존재하며 decrypt 실패를 빈 값으로 degrade하지 않는다. contact lookup/provider request/callback fingerprint는 HMAC purpose를 분리해 동일 secret-purpose reuse를 피한다.
|
||||
|
||||
### 12.2 routing과 dispatch
|
||||
|
||||
routing은 explicit 또는 ordered fallback이며 parallel-first-success가 없다. 두 provider를 동시에 호출하면 irreversible duplicate를 만들 수 있기 때문이다. fallback도 앞 attempt가 ambiguous이면 차단된다.
|
||||
|
||||
`NotificationDispatchService`는 pre-call attempt state를 짧은 transaction에서 commit한 뒤 provider를 transaction 밖에서 호출하고, 결과를 두 번째 transaction에서 finalize한다. irreversible call 직전 lease ownership을 다시 검사하고 post-call transition은 lease generation으로 fencing한다.
|
||||
|
||||
provider response는 accepted/rejected/ambiguous를 구분한다. classified “provider call not started” failure는 NOT_SUBMITTED로 판단할 수 있지만 unclassified runtime exception은 effect가 발생했을 가능성을 버리지 않고 ambiguous로 간다. cancellation도 future attempt를 막을 뿐 provider 쪽 이미 발생한 effect를 undo했다고 주장하지 않는다.
|
||||
|
||||
canonical plan encoding은 versioned/length-framed 형식이고 map을 sort하며 fallback order까지 fingerprint semantics에 포함한다. 과거 canonical string을 JSON codec으로 다시 decode해 모든 dispatch가 실패하던 문제 때문에 writer와 dispatcher가 동일 canonical codec을 공유하도록 고정됐다.
|
||||
|
||||
### 12.3 callback/receipt
|
||||
|
||||
provider callback은 signature 검증을 위해 raw bytes를 보존하고 normalized headers를 제공하며 rendering은 body를 redaction한다. callback append와 projection 사이의 atomicity bug 때문에 durable callback write 후 projector 예외가 broker redelivery를 만들던 과거 경로가 제거되고 batch append contract가 명확해졌다.
|
||||
|
||||
provider request id가 callback보다 늦게 알려지는 경우를 위해 unmatched callback을 이후 attempt에 연결하는 late-match 경로가 있다. synthetic provider event fingerprint도 과거 사실상 attempt UUID만 반영해 event type이 달라도 dedupe될 수 있던 문제를 length-framed SHA-256 semantics로 교정했다.
|
||||
|
||||
### 12.4 확인된 P1 contract/implementation drift: admin atomic claim 미사용
|
||||
|
||||
**Observed defect.** `AdminOperationStorePort.claim()`의 javadoc은 과거 admin 경로가 `find -> destructive side effect -> save`여서 같은 operation id를 동시에 제시한 두 요청이 모두 “not found”를 보고 redrive를 두 번 실행할 수 있었음을 명시한다. 이를 막기 위해 command fingerprint를 포함한 **atomic claim-before-effect** 계약이 추가됐고 `JpaAdminOperationStore`도 DB-level `claimOperation(...)`을 구현한다.
|
||||
|
||||
그러나 현재 `NotificationAdminApplicationService`는 redrive/reconcile/suppress/provider-state 작업에서 여전히 `operations.findByOperationId(...)`를 먼저 읽고 side effect 이후 `operations.save(...)`한다. application-core notification production/test에서 `operations.claim(...)` 호출은 발견되지 않았다. 즉 저장소와 port에는 race fix가 구현돼 있지만 application service가 그 경로를 사용하지 않는다.
|
||||
|
||||
이것은 단순 미사용 API가 아니라 **계약이 설명하는 동일 race가 service path에서 다시 열려 있는 drift**다. 특히 concurrent same-operation-id redrive나 provider state/suppression에서 destructive/operator action이 중복 실행될 수 있다. 현재 application-core에는 이 race를 재현하는 admin concurrency test도 없다.
|
||||
|
||||
검증/수정 후보는 명확하다. service가 side effect 전에 command semantic fingerprint로 `claim()`하고, CLAIMED만 실행하며 replay/conflict/in-progress를 typed result로 반환하도록 바꾼 뒤 두 concurrent caller가 같은 operation id로 들어와도 side effect count가 정확히 1인지 regression test로 고정해야 한다. 이 분석에서는 source를 수정하지 않았다.
|
||||
|
||||
### 12.5 P2 hardening: derived idempotency key의 32-bit hash
|
||||
|
||||
`AcceptNotificationApplicationUseCase.derivedKey()`는 caller key가 없을 때 `Integer.toHexString(Objects.hash(...))`로 recipient/channel/template/version/variables를 축약한다. 이는 32-bit Java hash이므로 javadoc의 “서로 다른 요청은 collapse하지 않는다”는 표현을 수학적으로 보장하지 못한다.
|
||||
|
||||
다만 downstream submission logic은 동일 idempotency key의 canonical request fingerprint가 다르면 conflict로 분리하므로, 관찰된 구조상 collision의 주된 영향은 다른 요청이 조용히 같은 delivery로 합쳐지는 것보다 **false idempotency conflict/availability failure**에 가깝다. 따라서 P1 data corruption으로 확대하지 않고 P2 hardening으로 기록한다. canonical plan에 대한 cryptographic/keyed digest 또는 caller-supplied key를 우선하는 방향이 더 강한 계약이다.
|
||||
|
||||
## 13. 실제 production reachability와 legacy/dead-path 판정
|
||||
|
||||
static production reference scan에서 주요 application package는 모두 외부 production consumer를 확인했다.
|
||||
|
||||
| package | application-core 밖 production reference file 수 |
|
||||
|---|---:|
|
||||
| notification | 156 |
|
||||
| objectstorage | 96 |
|
||||
| fileserver | 91 |
|
||||
| transaction | 35 |
|
||||
| idempotency | 30 |
|
||||
| usecase | 23 |
|
||||
| security | 22 |
|
||||
| filepublication | 16 |
|
||||
| storage | 16 |
|
||||
| outbox | 15 |
|
||||
| messaging | 14 |
|
||||
| realtime | 12 |
|
||||
| outbound | 11 |
|
||||
| observability | 10 |
|
||||
| lock | 4 |
|
||||
| operation | 4 |
|
||||
| cache | 2 |
|
||||
| fileexport | 2 |
|
||||
| lease | 2 |
|
||||
| inbox | 1 |
|
||||
|
||||
이 count는 “모든 type이 각각 호출된다”는 의미가 아니라 package-level runtime/repository reachability의 evidence다. 세부 파일은 `evidence/raw/013-application-core-reachability.txt`에 보존했다.
|
||||
|
||||
legacy surface도 무조건 dead로 분류하지 않았다. `application.storage.ObjectStoragePort`, root notification `NotificationPort`, `NotificationVariablesCodecPort`, old idempotency-related exception 등은 adapter/config/characterization path에서 실제 reference가 남아 있다. 현재 상태는 dead code가 아니라 migration/compatibility surface다.
|
||||
|
||||
반대로 notification admin atomic `claim()`은 adapter 구현까지 존재하지만 application service consumer가 없는 **unwired corrective path**로 판정했다. 이것이 이번 scope의 가장 중요한 reachability finding이다.
|
||||
|
||||
## 14. 테스트 및 build-time verification
|
||||
|
||||
현재 snapshot에서 다음을 fresh 실행했다.
|
||||
|
||||
1. `./gradlew :application-core:test --rerun-tasks`
|
||||
결과: BUILD SUCCESSFUL, 14 tasks executed. application-core의 136 test source가 포함된 normal lane을 fresh 실행했다.
|
||||
|
||||
2. `:application-core:messagingApplicationContractQualificationTest --rerun-tasks`
|
||||
첫 실행: digest properties 미제공으로 `prepareMessagingContractEvidence`가 fail-closed.
|
||||
재실행: source=`git archive HEAD` SHA-256, current application-core JAR SHA-256, exact profile bytes SHA-256을 명시.
|
||||
결과: **15 tests, 0 skipped, BUILD SUCCESSFUL**.
|
||||
|
||||
3. `./gradlew :app-bootstrap:test --tests dev.caskeleton.bootstrap.architecture.CleanArchitectureTest --rerun-tasks`
|
||||
결과: BUILD SUCCESSFUL, 98 actionable tasks executed. capability/transaction/repository/security dependency fitness rules를 fresh 실행했다.
|
||||
|
||||
4. `./gradlew verifyCleanArchitectureDependencies`
|
||||
결과: BUILD SUCCESSFUL. module-registry allowlist와 실제 project dependency edge 검증이 통과했다.
|
||||
|
||||
qualification에 사용한 source digest는 `e52b60c97a9496a0b18dc03f9232c295da37de7c0f56e692d000660eef0e370a`, artifact digest는 `6fd061854ad9a8631b65f5940f2a0792241d688aa4c11efcf392662109965942`, profile hash는 `7ac987233951a4d0427ac9e731a20df3a1c4aa1c15f30a5ed2b1e6c280b72afd`였다. 이 값은 release-wide provenance를 주장하기 위한 것이 아니라 이 분석에서 실행한 application-core qualification 입력을 재현하기 위한 evidence다.
|
||||
|
||||
## 15. 주요 역사적 회귀 근거
|
||||
|
||||
현재 코드 형태의 이유를 source가 직접 설명하는 사례가 여러 개 확인됐다.
|
||||
|
||||
| 현재 형태 | source가 기록한 과거 문제 |
|
||||
|---|---|
|
||||
| `ObjectAccessPolicy`가 application-core 소유 | GraphQL adapter-owned contract가 transport 역의존을 만들었음 |
|
||||
| `TransactionCompletionResolver`가 application-core 소유 | JPA 옆 SPI를 domain이 구현하려면 adapter에 역의존해야 했음 |
|
||||
| `IrreversibleSideEffectContext`가 application-core 소유 | use case가 persistence adapter를 import해야 marker를 호출할 수 있었음 |
|
||||
| outbox payload append-boundary validation | relay에서 poison payload를 늦게 발견해 batch를 망가뜨릴 수 있었음 |
|
||||
| V2 idempotency singular contract | 인접 duplicate nominal contract가 서로 다른 구현 타입을 만들었음 |
|
||||
| fileserver fenced writer/cleanup/recovery | cancel/cleanup/writer race와 ambiguous physical/metadata seam |
|
||||
| notification canonical codec | canonical payload를 JSON codec으로 읽어 dispatch가 실패하던 path |
|
||||
| notification owner+generation fencing | expired worker가 renewal/write를 계속할 수 있던 race |
|
||||
| callback append/project separation | durable append 뒤 projector failure가 redelivery를 유발하던 atomicity 문제 |
|
||||
| admin `claim()` 계약 | find-before-side-effect race로 동일 destructive operation이 동시에 두 번 실행될 수 있었음 |
|
||||
|
||||
따라서 application-core의 복잡성 상당 부분은 단순 추상화 선호가 아니라 **failure/uncertainty/concurrency를 provider 구현보다 안쪽의 semantic contract로 끌어올린 결과**로 관찰된다.
|
||||
|
||||
## 16. Findings / improvement backlog
|
||||
|
||||
### P1 — notification admin atomic claim contract가 service에서 사용되지 않음
|
||||
|
||||
- **Fact:** `AdminOperationStorePort.claim()`과 `JpaAdminOperationStore.claim()`은 존재하지만 `NotificationAdminApplicationService`는 redrive/reconcile/suppress/provider-state에서 `findByOperationId -> side effect -> save`를 사용한다.
|
||||
- **Why it matters:** 동일 operation id의 concurrent 요청이 둘 다 side effect를 실행할 수 있으며, 이는 claim javadoc이 명시한 과거 race와 동일하다.
|
||||
- **Verification:** 동일 operation id/command를 barrier로 동시에 호출하고 destructive action invocation count가 1인지 검증하는 concurrency regression test.
|
||||
- **Candidate direction:** service가 command fingerprint를 계산해 atomic claim을 먼저 수행하고 claimed/replay/conflict/in-progress를 분기.
|
||||
- **Tech-Log:** CASE + OPEN QUESTION/DECISION 후보.
|
||||
|
||||
### P2 — notification derived idempotency key가 32-bit hash
|
||||
|
||||
- **Fact:** fallback key는 `Integer.toHexString(Objects.hash(...))`다.
|
||||
- **Why it matters:** 서로 다른 request의 collision을 배제할 수 없어 javadoc의 강한 uniqueness 표현과 실제 guarantee가 맞지 않는다. canonical fingerprint 비교가 있으므로 silent convergence보다는 false conflict 위험이 중심이다.
|
||||
- **Verification:** known Java hash collision fixture 또는 property search로 distinct canonical request가 같은 derived key를 만들 수 있음을 확인하고 downstream conflict behavior를 고정.
|
||||
- **Candidate direction:** canonical plan에 대한 SHA-256/HMAC 계열 digest.
|
||||
- **Tech-Log:** OPEN QUESTION/REFERENCE 후보.
|
||||
|
||||
### P2 — legacy storage/notification compatibility surface의 제거 조건 추적
|
||||
|
||||
- **Fact:** deprecated/legacy 계약이 production wiring에 여전히 사용된다.
|
||||
- **Why it matters:** 이름만 보고 dead로 삭제할 수 없고 adapter/runtime migration이 먼저 끝나야 한다.
|
||||
- **Verification:** external production reference 0 + characterization replacement + config path removal을 migration gate로 사용.
|
||||
- **Tech-Log:** DECISION/REFERENCE 후보.
|
||||
|
||||
### P3 — isolation vocabulary와 legacy routing capability의 시차
|
||||
|
||||
- **Fact:** `Isolation`에는 stricter levels가 있지만 legacy `TransactionPort` template은 READ_COMMITTED로 고정되고 test도 stricter routing을 planned라고 명시한다.
|
||||
- **Why it matters:** public vocabulary만 보고 이미 지원되는 capability로 오해할 수 있다.
|
||||
- **Verification:** future routing이 추가될 때 use-case policy -> adapter transaction definition test를 함께 추가.
|
||||
- **Tech-Log:** OPEN QUESTION 후보.
|
||||
|
||||
## 17. 분석 한계
|
||||
|
||||
이 scope의 1,021 source/test 파일은 전부 읽었지만 모든 downstream adapter 내부 구현을 exhaustive 분석한 것은 아니다. application-core 계약의 실제 reachability/중요 semantic을 검증하는 데 필요한 adapter/bootstrap/architecture source만 cross-scope evidence로 추적했다. JPA, Redis, messaging provider, objectstorage/fileserver adapters 자체의 전체 implementation detail은 각 후속 bounded scope에서 다시 exhaustive 분석해야 한다.
|
||||
|
||||
`CleanArchitectureTest`가 통과해도 helper indirect call, reflection/string lookup, AOP self-invocation 같은 static-analysis blind spot은 남는다. 또한 in-memory/fake contract test가 실제 provider failure model 전체를 증명하지는 않는다. 이 한계는 관련 adapter scope에서 real engine/runtime evidence로 보강해야 한다.
|
||||
|
||||
## 18. 완료 판정
|
||||
|
||||
application-core는 다음 gate를 충족했다.
|
||||
|
||||
- quantified denominator: production 885 + test 136 = 1,021.
|
||||
- coverage: FULL_READ 1,021 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / UNCLASSIFIED 0.
|
||||
- 23 top-level package를 모두 account했다.
|
||||
- build dependency와 shared-contract-only production edge를 확인했다.
|
||||
- use-case/capability/transaction/security architecture fitness function을 추적했다.
|
||||
- 주요 transaction/idempotency/cache/lease/outbox/fileserver/notification uncertainty와 failure mechanics를 문서화했다.
|
||||
- external production reachability와 legacy/unwired path를 검사했다.
|
||||
- fresh normal tests, strict messaging qualification, architecture tests, dependency verification이 모두 통과했다.
|
||||
- historical regression 근거와 P1/P2/P3 backlog를 분리했다.
|
||||
- source revision은 분석 종료 시점까지 `a24ece9cf797f7ea647e33bf846b115208ed1ba5`이며 source working tree는 변경하지 않았다.
|
||||
|
||||
따라서 `application-core` bounded scope를 COMPLETE로 판정한다. 프로젝트 전체는 아직 후속 adapter/messaging/bootstrap scope가 남아 있으므로 `clean-architecture-backend-template` 자체는 계속 IN_PROGRESS다.
|
||||
|
||||
## Source anchors
|
||||
|
||||
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **40개** (main 35 · test 2 · 기타 3).
|
||||
|
||||
```
|
||||
src/application-core/build.gradle
|
||||
src/config/architecture/modules.json (application-core 항목)
|
||||
|
||||
main:
|
||||
src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java
|
||||
src/main/java/dev/caskeleton/application/cache/CacheSingleFlight.java
|
||||
src/main/java/dev/caskeleton/application/cache/CacheWriteCondition.java
|
||||
src/main/java/dev/caskeleton/application/fileserver/api/error/AmbiguousCompletionException.java
|
||||
src/main/java/dev/caskeleton/application/lease/DistributedLeasePort.java
|
||||
src/main/java/dev/caskeleton/application/lease/LeaseGuarantee.java
|
||||
src/main/java/dev/caskeleton/application/lease/LeaseWatchdog.java
|
||||
src/main/java/dev/caskeleton/application/lock/DistributedLockPort.java
|
||||
src/main/java/dev/caskeleton/application/notification/NotificationPort.java
|
||||
src/main/java/dev/caskeleton/application/notification/platform/admin/AdminOperationStorePort.java
|
||||
src/main/java/dev/caskeleton/application/notification/platform/admin/NotificationAdminApplicationService.java
|
||||
src/main/java/dev/caskeleton/application/notification/platform/api/NotificationPlan.java
|
||||
src/main/java/dev/caskeleton/application/notification/platform/api/NotificationVariable.java
|
||||
src/main/java/dev/caskeleton/application/notification/platform/dispatch/AcceptNotificationApplicationUseCase.java
|
||||
src/main/java/dev/caskeleton/application/notification/platform/dispatch/NotificationDispatchService.java
|
||||
src/main/java/dev/caskeleton/application/notification/platform/dispatch/NotificationVariablesCodecPort.java
|
||||
src/main/java/dev/caskeleton/application/operation/DurableOperation.java
|
||||
src/main/java/dev/caskeleton/application/operation/DurableOperationStorePort.java
|
||||
src/main/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCase.java
|
||||
src/main/java/dev/caskeleton/application/security/AuthorizationDeniedException.java
|
||||
src/main/java/dev/caskeleton/application/security/AuthorizationPort.java
|
||||
src/main/java/dev/caskeleton/application/security/AuthorizationPrincipal.java
|
||||
src/main/java/dev/caskeleton/application/security/ObjectAccessDecision.java
|
||||
src/main/java/dev/caskeleton/application/security/ObjectAccessPolicy.java
|
||||
src/main/java/dev/caskeleton/application/transaction/CompletionResolution.java
|
||||
src/main/java/dev/caskeleton/application/transaction/IrreversibleSideEffectContext.java
|
||||
src/main/java/dev/caskeleton/application/transaction/Isolation.java
|
||||
src/main/java/dev/caskeleton/application/transaction/OperationId.java
|
||||
src/main/java/dev/caskeleton/application/transaction/PolicyTransactionPort.java
|
||||
src/main/java/dev/caskeleton/application/transaction/ReconciliationReference.java
|
||||
src/main/java/dev/caskeleton/application/transaction/TransactionCompletionResolver.java
|
||||
src/main/java/dev/caskeleton/application/transaction/TransactionPolicyId.java
|
||||
src/main/java/dev/caskeleton/application/transaction/TransactionPort.java
|
||||
src/main/java/dev/caskeleton/application/transaction/TransactionRequest.java
|
||||
src/main/java/dev/caskeleton/application/transaction/TransactionResult.java
|
||||
|
||||
test:
|
||||
src/test/java/dev/caskeleton/application/security/ObjectAccessPolicyTest.java
|
||||
src/test/java/dev/caskeleton/application/transaction/IsolationTest.java
|
||||
|
||||
기타:
|
||||
CLAUDE.md
|
||||
README.md
|
||||
src/build.gradle
|
||||
|
||||
해석되지 않은 인용 (1종) — 외부 타입·문서상 약칭 등:
|
||||
evidence/raw/013-application-core-reachability.txt
|
||||
|
||||
```
|
||||
@@ -0,0 +1,703 @@
|
||||
# adapter-outbound-support 상세 분석
|
||||
|
||||
|
||||
## SSOT identity — 2026-08-31 재검증
|
||||
|
||||
- registered leaf id: `adapter-outbound-support`
|
||||
- canonical state `analysisFile`: `analysis/04-adapter-outbound-support.md` (이 문서) — 이 leaf의 단일 SSOT
|
||||
- source path: `src/adapter/outbound/support` · Gradle `:adapter:outbound:support`
|
||||
- registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract"]`
|
||||
- registry `runtime_memberships`: `["app-bootstrap"]`
|
||||
- coverage ledger: `FULL_READ` **8** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
|
||||
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
|
||||
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행)
|
||||
|
||||
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
|
||||
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
|
||||
|
||||
---
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
|
||||
> 분석 범위: `src/adapter/outbound/support`
|
||||
> Gradle path: `:adapter:outbound:support`
|
||||
|
||||
## 0. 커버리지와 숫자 지도
|
||||
|
||||
이 leaf는 크기가 작다. 그래서 대표 파일을 샘플링하지 않고 leaf-owned source/build/document를 전부 읽고, 실제 consumer와 composition-root wiring을 별도 cross-scope evidence로 추적했다.
|
||||
|
||||
| file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| production Java | 4 | FULL_READ | leaf의 전체 production source |
|
||||
| test Java | 1 | FULL_READ | leaf의 전체 dedicated test source |
|
||||
| `build.gradle` | 1 | FULL_READ | 실제 compile dependency와 leaf build policy |
|
||||
| `README.md` | 1 | FULL_READ | 현재 코드와 대조해야 하는 설계 결정 문서 |
|
||||
| `CLAUDE.md` | 1 | FULL_READ | 현재 leaf-local 분석/경계 지침 |
|
||||
| resources | 0 | FULL_READ | main/test resource 없음 |
|
||||
| **합계** | **8** | **FULL_READ 8 / UNCLASSIFIED 0** | bounded scope complete denominator |
|
||||
|
||||
추가 측정:
|
||||
|
||||
- production Java: 4 files / 약 97 LOC
|
||||
- test Java: 1 file / 약 89 LOC
|
||||
- production package directories: 2
|
||||
- `dev.caskeleton.adapter.outbound`
|
||||
- `dev.caskeleton.adapter.outbound.support`
|
||||
- entities/tables/migrations: 없음
|
||||
- configuration properties: 없음
|
||||
- runtime membership registry: `app-bootstrap`
|
||||
- leaf-owned Spring configuration: `OutboundSupportConfig`
|
||||
|
||||
Raw inventory는 `evidence/raw/015-adapter-outbound-support-inventory.txt`에 보존했다.
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
`adapter-outbound-support`는 application port를 구현하는 하나의 기술 adapter라기보다 **여러 outbound adapter가 공유할 수 있는 기술적 보조 seam**이다.
|
||||
|
||||
현재 production surface는 사실상 세 가지다.
|
||||
|
||||
1. `OutboundCorrelation`
|
||||
- SLF4J MDC에서 `correlation_id`를 조회한다.
|
||||
- 값이 없거나 blank면 `"unknown"`을 반환한다.
|
||||
|
||||
2. `FailOpenDependencyLogger`
|
||||
- optional/fail-open outbound 호출의 success/failure observation을 공통 포맷으로 기록한다.
|
||||
- success는 DEBUG, failure는 WARN이다.
|
||||
|
||||
3. `OutboundSupportConfig`
|
||||
- `FailOpenDependencyLogger` default bean을 제공한다.
|
||||
- `@ConditionalOnMissingBean`으로 fork/application이 같은 타입을 override할 수 있게 한다.
|
||||
|
||||
`package-info.java`는 outbound adapter package의 개괄만 가진다.
|
||||
|
||||
### 1.1 허용 dependency와 실제 dependency는 다르다
|
||||
|
||||
`src/config/architecture/modules.json`은 support leaf가 다음 project dependency를 **허용**한다.
|
||||
|
||||
- `domain-core`
|
||||
- `application-core`
|
||||
- `shared-contract`
|
||||
|
||||
그러나 현재 `build.gradle`과 fresh `compileClasspath` 결과를 보면 실제 project dependency는 **0개**다. 실제 compile dependency는 다음 외부 라이브러리뿐이다.
|
||||
|
||||
- `spring-boot-autoconfigure` 4.0.8
|
||||
- `slf4j-api` 2.0.18
|
||||
|
||||
즉 registry의 `allowed_dependencies`는 가능한 최대 경계를 나타내고, 현재 source graph가 그 edge를 모두 사용한다는 뜻이 아니다. support는 현 snapshot에서 domain/application/shared 타입과도 결합하지 않는다.
|
||||
|
||||
이 구분은 Clean Architecture 설명에서도 중요하다. “Core 쪽 dependency가 허용된다”와 “현재 adapter가 Core에 실제로 의존한다”는 별개의 사실이다.
|
||||
|
||||
## 2. `OutboundCorrelation`: MDC lookup을 한 곳으로 모은 작은 seam
|
||||
|
||||
`OutboundCorrelation.current()`의 규칙은 단순하다.
|
||||
|
||||
```text
|
||||
MDC[correlation_id] != null && !blank
|
||||
-> 해당 값
|
||||
otherwise
|
||||
-> "unknown"
|
||||
```
|
||||
|
||||
`docs/registries/mdc-keys.yaml`의 `correlation_id` 계약은 다음을 선언한다.
|
||||
|
||||
- source: inbound filter
|
||||
- type: ULID
|
||||
- required in: request/dependency/application
|
||||
- propagation: HTTP/async/message
|
||||
- metric tag 사용 금지(high cardinality)
|
||||
|
||||
`OutboundCorrelation`은 이 중 **조회와 missing sentinel만 소유**한다. ULID 형식 검증이나 생성/전파를 여기서 하지 않는다. 따라서 이 utility가 임의 문자열을 그대로 반환한다고 해서 곧바로 contract 위반이라고 볼 근거는 없다. canonicalization/validation은 upstream context owner의 책임으로 보인다.
|
||||
|
||||
### Reachability
|
||||
|
||||
scope 밖 production code에서 `OutboundCorrelation`을 직접 참조하는 파일은 현재 0개다. 하지만 dead code는 아니다.
|
||||
|
||||
- `FailOpenDependencyLogger`가 같은 leaf 내부에서 production consumer다.
|
||||
- messaging/notification tests는 MDC key를 맞추기 위해 이 상수를 직접 사용한다.
|
||||
|
||||
따라서 “external production reference 0”만으로 dead 판정을 하면 오탐이다. 이번에 추가된 negative-space rule이 요구하는 바로 그 사례다.
|
||||
|
||||
## 3. `FailOpenDependencyLogger`: 진단을 business outcome과 분리하려는 계약
|
||||
|
||||
### 3.1 성공과 실패 포맷
|
||||
|
||||
`logSuccess(...)`는 DEBUG로 다음 정보를 기록한다.
|
||||
|
||||
- dependency_name
|
||||
- dependency_type
|
||||
- operation
|
||||
- outcome=`SUCCESS`
|
||||
- correlation_id
|
||||
|
||||
`logFailure(...)`는 WARN으로 다음을 추가한다.
|
||||
|
||||
- outcome=`FAILURE`
|
||||
- error=`<exception simple class>: <cause.getMessage()>`
|
||||
|
||||
README와 javadoc은 WARN을 선택한 이유를 “optional fail-open dependency가 실패해도 core use case 자체는 성공했기 때문”이라고 설명한다.
|
||||
|
||||
이 logger 자체는 retry, recovery, fallback을 수행하지 않는다. **실패 정책을 결정하는 주체가 아니라 이미 결정된 fail-open outcome을 관측하는 기술 seam**이다.
|
||||
|
||||
### 3.2 실제 production consumer
|
||||
|
||||
repository-wide production reference scan에서 support package를 직접 import하는 current production files는 네 개뿐이었다.
|
||||
|
||||
Messaging:
|
||||
|
||||
- `MessagingConfig`
|
||||
- `OutboundMessagePublisher`
|
||||
|
||||
Notification:
|
||||
|
||||
- `NotificationConfig`
|
||||
- `FailOpenNotificationProvider`
|
||||
|
||||
반대로 support README가 “공유 consumer”로 설명하는 `cache-redis`, `httpclient`는 Gradle dependency는 유지하지만 support production type을 직접 참조하지 않는다. 이 차이는 §8에서 별도로 다룬다.
|
||||
|
||||
## 4. Confirmed P1 — `cause.getMessage()` 때문에 PII-safe logging 계약이 성립하지 않는다
|
||||
|
||||
### 4.1 문서와 테스트가 주장하는 계약
|
||||
|
||||
support source와 README는 다음 취지의 강한 주장을 한다.
|
||||
|
||||
> logger method가 body/recipient/payload를 받지 않기 때문에 PII가 log에 닿지 않는다.
|
||||
|
||||
`FailOpenDependencyLoggerTest`도 실패 로그에 email/body marker가 없음을 검사한다.
|
||||
|
||||
하지만 테스트 fixture의 exception은 단순히 `"connection refused"`다. 즉 PII marker는 logger에 들어가는 어떤 argument에도 존재하지 않는다. 이 테스트는 **payload object가 직접 argument로 전달되지 않는다는 것**만 확인할 뿐, exception message를 통한 leakage를 검사하지 않는다.
|
||||
|
||||
### 4.2 실제 logger input은 payload-free가 아니다
|
||||
|
||||
`logFailure`는 다음 값을 그대로 formatted message에 넣는다.
|
||||
|
||||
```java
|
||||
cause.getClass().getSimpleName() + ": " + cause.getMessage()
|
||||
```
|
||||
|
||||
그리고 consumer SPI들은 exception message의 내용을 제한하지 않는다.
|
||||
|
||||
- `NotificationProvider.send(Notification)` → arbitrary `Exception`
|
||||
- `GoogleEmailClient.send(Notification)` → arbitrary `Exception`
|
||||
- `SlackClient.send(Notification)` → arbitrary `Exception`
|
||||
- `MessageBroker.send(OutboundMessage)` → arbitrary `Exception`
|
||||
|
||||
특히 `Notification` contract는 recipient/body가 PII이며 logger에 전달하면 안 된다고 명시한다. 하지만 provider SDK/fork implementation이 recipient나 response/body 일부를 exception message에 넣는 것을 이 interface가 통제할 수 없다.
|
||||
|
||||
### 4.3 실행 재현
|
||||
|
||||
현재 compiled `FailOpenDependencyLogger`에 다음 exception을 전달하는 focused probe를 실행했다.
|
||||
|
||||
```text
|
||||
RuntimeException(
|
||||
"provider rejected recipient secret@gmail.com body=secret-body-content")
|
||||
```
|
||||
|
||||
실제 formatted WARN에는 다음 문자열이 그대로 남았다.
|
||||
|
||||
```text
|
||||
error="RuntimeException: provider rejected recipient secret@gmail.com body=secret-body-content"
|
||||
```
|
||||
|
||||
probe source와 output은 각각:
|
||||
|
||||
- `evidence/raw/021a-support-logger-pii-probe.java`
|
||||
- `evidence/raw/021-support-logger-pii-probe.txt`
|
||||
|
||||
에 보존했다.
|
||||
|
||||
### 4.4 global masking도 이 보장을 복구하지 않는다
|
||||
|
||||
`app-bootstrap`의 `LogMaskingPatterns`는 방어 심층화로 다음과 같은 secret 형태를 mask한다.
|
||||
|
||||
- password/secret/token/api-key 계열 key=value
|
||||
- Authorization credentials
|
||||
- standalone Bearer token
|
||||
|
||||
그러나 arbitrary email address나 free-form body PII를 일반적으로 제거하는 규칙은 없다. app-bootstrap README 자체도 regex masking을 **보증이 아니라 defence-in-depth**라고 설명한다.
|
||||
|
||||
따라서 현재 “logger signature 때문에 PII가 들어올 수 없다”는 1차 방어선 설명은 사실과 맞지 않는다.
|
||||
|
||||
### 4.5 영향과 수정 후보
|
||||
|
||||
우선순위: **P1 (security/privacy contract)**
|
||||
|
||||
가능한 방향은 두 가지다.
|
||||
|
||||
1. 공통 logger가 raw `cause.getMessage()`를 기록하지 않고 exception type + bounded/stable error classification만 기록한다.
|
||||
2. raw cause message가 정말 필요한 일부 dependency만 별도의 sanitizer/classifier를 거쳐 명시적으로 허용한다.
|
||||
|
||||
어느 쪽이든 현재의 “arbitrary exception message를 공통 logger가 그대로 출력”하는 방식은 PII-safe라는 강한 계약과 양립하지 않는다.
|
||||
|
||||
Regression test는 exception message 자체에 email/body/token marker를 넣어 formatted log에 남지 않는지 검증해야 한다. 현재 테스트처럼 payload object만 logger argument에서 제외하는 것으로는 부족하다.
|
||||
|
||||
## 5. Confirmed P1 — notification consumer는 diagnostic failure를 authoritative failure로 바꿀 수 있다
|
||||
|
||||
이 finding은 support logger의 consumer semantics를 추적하면서 발견했다.
|
||||
|
||||
### 5.1 messaging은 이미 이 문제를 구분한다
|
||||
|
||||
`OutboundMessagePublisher`는 broker call과 observation을 분리한다.
|
||||
|
||||
```text
|
||||
broker.send
|
||||
-> success/failure fact 결정
|
||||
-> observeQuietly(logger...)
|
||||
```
|
||||
|
||||
`observeQuietly`는 logger가 RuntimeException을 던져도 caller-visible broker outcome을 바꾸지 않는다.
|
||||
|
||||
source comment에는 과거 버그도 직접 기록돼 있다.
|
||||
|
||||
- send와 success log가 같은 try block에 있었음
|
||||
- broker는 이미 메시지를 받았음
|
||||
- success logger가 실패함
|
||||
- 같은 catch가 이를 publish failure로 오인했음
|
||||
|
||||
현재 `OutboundMessagePublisherTest.aLoggerFailureAfterAConfirmedSendIsNotAPublishFailure`는 logger가 DEBUG에서 실제로 예외를 던지도록 만들고도 publish가 예외 없이 끝나며 broker send가 1회 완료됐음을 검증한다.
|
||||
|
||||
fresh focused test도 통과했다.
|
||||
|
||||
### 5.2 notification은 같은 shared logger를 다른 방식으로 사용한다
|
||||
|
||||
현재 `FailOpenNotificationProvider`는 다음 구조다.
|
||||
|
||||
```text
|
||||
try {
|
||||
delegate.send()
|
||||
logSuccess()
|
||||
} catch (Exception ex) {
|
||||
logFailure(ex)
|
||||
}
|
||||
```
|
||||
|
||||
여기서는 provider outcome과 diagnostic outcome이 분리되지 않는다.
|
||||
|
||||
#### Case A — provider 성공 후 success logger 실패
|
||||
|
||||
`delegate.send()`가 성공한 뒤 `logSuccess()`가 RuntimeException을 던지면 같은 catch가 잡는다. 그 결과 이미 성공한 provider send에 대해 `logFailure()`까지 호출된다.
|
||||
|
||||
focused probe 결과:
|
||||
|
||||
```text
|
||||
SUCCESS_PATH sends=1 debugCalls=1 warnCalls=1
|
||||
```
|
||||
|
||||
실제 send는 1회 성공했지만 success observation failure 때문에 WARN failure observation이 추가 호출됐다.
|
||||
|
||||
#### Case B — provider 실패 후 failure logger도 실패
|
||||
|
||||
provider failure가 catch된 뒤 `logFailure()`가 RuntimeException을 던지면 이를 흡수하는 바깥 경계가 없다.
|
||||
|
||||
probe 결과:
|
||||
|
||||
```text
|
||||
FAILURE_PATH propagated=IllegalStateException:logger-warn-failed warnCalls=1
|
||||
```
|
||||
|
||||
즉 클래스가 “provider failure를 swallow해 core use case를 실패시키지 않는다”고 선언해도 diagnostics failure가 caller까지 전파될 수 있다.
|
||||
|
||||
probe source/output:
|
||||
|
||||
- `evidence/raw/022a-notification-logger-failure-probe.java`
|
||||
- `evidence/raw/022-notification-logger-failure-probe.txt`
|
||||
|
||||
### 5.3 현재 notification test가 green인 이유
|
||||
|
||||
`NotificationAdapterTest`는 ordinary `ListAppender`를 사용한다. provider failure와 PII object가 log line에 직접 들어가지 않는 것은 검증하지만 logger/appender 자체가 실패하는 fixture는 없다.
|
||||
|
||||
fresh `NotificationAdapterTest`는 정상 통과했다. 따라서 이 finding은 “기존 테스트 실패”가 아니라 **green test가 다루지 않는 failure seam**이다.
|
||||
|
||||
우선순위: **P1 (reliability / outcome correctness)**
|
||||
|
||||
수정 후보:
|
||||
|
||||
- messaging과 동일하게 provider call과 observation을 분리하고 observation failure를 non-authoritative로 흡수한다.
|
||||
- 또는 `FailOpenDependencyLogger` 자체를 no-throw contract로 바꿔 모든 consumer를 보호한다.
|
||||
|
||||
후자는 shared behavior를 바꾸므로 messaging/notification뿐 아니라 future consumer까지 contract review가 필요하다. 어느 owner가 isolation을 가져갈지는 후속 Decision 후보로 남긴다.
|
||||
|
||||
## 6. `OutboundSupportConfig`: unconditional shared bean seam과 실제 runtime wiring
|
||||
|
||||
`OutboundSupportConfig`는 `@Configuration`이며 `FailOpenDependencyLogger` bean 하나만 제공한다.
|
||||
|
||||
```text
|
||||
@ConditionalOnMissingBean
|
||||
FailOpenDependencyLogger failOpenDependencyLogger()
|
||||
```
|
||||
|
||||
별도 master property condition은 없다. 이는 support 자체를 optional capability로 취급하지 않고, 실제 provider/client capability의 on/off를 sibling adapter가 소유하게 하려는 구조다.
|
||||
|
||||
### 6.1 direct production reference 0이지만 unwired가 아니다
|
||||
|
||||
`OutboundSupportConfig`를 support 밖 production Java에서 명시적으로 참조하는 파일은 0개다. 그러나 실제 composition root `CaSkeletonApplication`은 다음 broad package를 component scan한다.
|
||||
|
||||
```text
|
||||
dev.caskeleton.adapter
|
||||
```
|
||||
|
||||
`AUTO_CONFIGURED_PACKAGES` exclusion에는 messaging/notification/persistence 등은 들어가지만 support package는 포함되지 않는다. 따라서 support config는 broad component scan으로 도달한다.
|
||||
|
||||
registry도 support runtime membership을 `app-bootstrap`으로 선언하고 `app-bootstrap/build.gradle`이 support project를 직접 `implementation`한다.
|
||||
|
||||
따라서 이 configuration은 현재 **active scanned path**다.
|
||||
|
||||
### 6.2 conditional sibling comparison
|
||||
|
||||
support config 자체에는 `@ConditionalOnProperty`가 없고 `@ConditionalOnMissingBean`만 있다. 이것은 같은 optional adapter들의 master switch 누락으로 판정하지 않았다.
|
||||
|
||||
이유:
|
||||
|
||||
- support는 provider/client를 생성하지 않는다.
|
||||
- logger bean 하나만 default로 제공한다.
|
||||
- actual messaging/notification/httpclient 등은 자기 capability root에서 activation을 소유한다.
|
||||
- support README와 config javadoc 모두 이 비대칭을 의도적으로 설명한다.
|
||||
|
||||
`OptionalAdapterBeanGatingTest`도 support config를 함께 넣은 상태에서 optional adapters가 기본 disabled여도 context가 성공함을 검증한다. 다만 이 test는 `OutboundSupportConfig`를 `.withUserConfiguration(...)`으로 직접 공급하므로 full-app component-scan evidence 자체는 아니다. full app wiring은 `CaSkeletonApplication` source와 registry/build edge가 별도 근거다.
|
||||
|
||||
## 7. Build / ArchUnit enforcement
|
||||
|
||||
### 7.1 registry
|
||||
|
||||
`modules.json`에서 support는 독립 leaf이며 runtime membership은 app-bootstrap이다.
|
||||
|
||||
### 7.2 Gradle dependency validation
|
||||
|
||||
fresh `verifyCleanArchitectureDependencies`가 통과했다. 이 task는 registry의 allowed dependency와 실제 Gradle project edge를 비교한다.
|
||||
|
||||
중요한 한계는 이 검증이 **edge가 허용되는지**를 판단한다는 점이다. 사용되지 않는 allowed edge까지 제거해야 한다고 판단하지는 않는다.
|
||||
|
||||
### 7.3 outbound peer isolation
|
||||
|
||||
`CleanArchitectureTest.OUTBOUND_ADAPTERS_ARE_PEERS_SHARING_ONLY_SUPPORT`는 outbound adapter family를 slice로 나누고 서로 직접 의존하지 못하게 한다.
|
||||
|
||||
유일한 shared-code 예외는 target package가:
|
||||
|
||||
```text
|
||||
..adapter.outbound.support..
|
||||
```
|
||||
|
||||
인 dependency다.
|
||||
|
||||
따라서 messaging → notification 같은 peer coupling은 금지하지만 messaging → support는 허용한다.
|
||||
|
||||
fresh `CleanArchitectureTest --rerun-tasks`도 통과했다.
|
||||
|
||||
이 구조는 support 모듈이 단순 편의 library가 아니라 **outbound family에서 sanctioned shared dependency point**라는 점을 build-time fitness function으로 고정한다.
|
||||
|
||||
## 8. Negative-space probes
|
||||
|
||||
강화된 분석 규칙에 따라 네 가지 부재/중복/drift probe를 별도로 수행했다.
|
||||
|
||||
### 8.1 Public surface reachability
|
||||
|
||||
Raw evidence: `016-adapter-outbound-support-public-reachability.txt`
|
||||
|
||||
| public type | support 밖 current reference | 판정 |
|
||||
|---|---|---|
|
||||
| `FailOpenDependencyLogger` | messaging/notification production + tests | active shared surface |
|
||||
| `OutboundCorrelation` | external production 0, downstream tests 존재 | leaf-internal production utility, dead 아님 |
|
||||
| `OutboundSupportConfig` | external production direct ref 0, app-bootstrap test ref 존재 | component-scan active path |
|
||||
|
||||
결론적으로 현재 세 타입 중 confirmed dead public type은 없다.
|
||||
|
||||
### 8.2 Conditional sibling comparison
|
||||
|
||||
Raw evidence: `017-adapter-outbound-support-conditional-wiring.txt`
|
||||
|
||||
- support config: unconditional configuration + missing-bean override seam
|
||||
- app composition root: support package는 broad component scan에 포함
|
||||
- optional provider adapter package들은 별도 conditional/auto-config ownership
|
||||
|
||||
support의 unconditional nature는 현재 역할과 일치하며 conditional mismatch defect로 판정하지 않았다.
|
||||
|
||||
### 8.3 Duplicate / competing mechanism sweep
|
||||
|
||||
Raw evidence: `018-adapter-outbound-support-duplicate-mechanisms.txt`
|
||||
|
||||
확인한 주요 후보:
|
||||
|
||||
- `FailOpenDependencyLogger`: current fail-open shared logger
|
||||
- `Slf4jOutboxRelayFailureReportAdapter`: durable/outbox failure reporter
|
||||
- cache Redis 내부의 자체 logger들
|
||||
- historical `OutboundHttpDependencyLogger`
|
||||
|
||||
현재 evidence로는 이들을 같은 runtime responsibility의 confirmed duplicate라고 볼 수 없다.
|
||||
|
||||
- outbox reporter는 fail-closed durable relay의 typed report를 기록한다.
|
||||
- Redis logger는 lifecycle/config/SDK observability 역할이다.
|
||||
- HTTP dependency logger는 현재 source에서 제거됐다.
|
||||
|
||||
따라서 **현재 중복 fail-open dependency logger 구현 defect는 확인되지 않았다.**
|
||||
|
||||
다만 messaging과 notification이 동일 logger를 사용하면서 diagnostics-failure semantics가 다르다는 consumer-level inconsistency는 §5의 confirmed finding이다.
|
||||
|
||||
### 8.4 Documentation / measured-claim drift
|
||||
|
||||
Raw evidence: `019-adapter-outbound-support-document-drift.txt`
|
||||
|
||||
여기서는 명확한 drift가 확인됐다.
|
||||
|
||||
#### Drift 1 — dependency SSOT 위치
|
||||
|
||||
README:
|
||||
|
||||
```text
|
||||
src/build.gradle 의 allowedProjectDependencies[...]가 SSOT
|
||||
```
|
||||
|
||||
현재:
|
||||
|
||||
- root `AGENTS.md`: `src/config/architecture/modules.json`가 SSOT
|
||||
- support `CLAUDE.md`: 동일
|
||||
- `src/build.gradle`은 registry를 읽고 `allowedProjectDependencies` map을 **파생 생성**함
|
||||
|
||||
즉 variable 자체는 아직 존재하지만 source-of-truth 위치 설명은 outdated다.
|
||||
|
||||
#### Drift 2 — CLAUDE.md 부재 주장
|
||||
|
||||
README:
|
||||
|
||||
```text
|
||||
이 모듈은 아직 별도 CLAUDE.md를 두지 않았다
|
||||
```
|
||||
|
||||
현재:
|
||||
|
||||
```text
|
||||
src/adapter/outbound/support/CLAUDE.md
|
||||
```
|
||||
|
||||
가 실제 존재한다.
|
||||
|
||||
#### Drift 3 — 존재하지 않는 현재 비교 대상
|
||||
|
||||
README는 fail-open WARN logger와 `OutboundHttpDependencyLogger`를 현재 대비되는 구현처럼 설명한다.
|
||||
|
||||
current repository exact search에서는 이 symbol이 support README 한 줄 외에 존재하지 않는다.
|
||||
|
||||
Git history를 보면 해당 class는 초기 repository에 존재했으나 commit `5f10b791...`에서 httpclient 관련 old classes/tests와 함께 삭제됐다. support README는 initial commit 이후 이 변화에 맞춰 갱신되지 않았다.
|
||||
|
||||
우선순위: **P3 documentation maintenance**
|
||||
|
||||
## 9. Candidate unnecessary Gradle edges — cache/httpclient → support
|
||||
|
||||
Raw evidence: `020-adapter-outbound-support-project-edge-usage.txt`
|
||||
|
||||
다음 네 leaf는 모두 support를 `implementation project(':adapter:outbound:support')`로 선언한다.
|
||||
|
||||
- cache-redis
|
||||
- httpclient
|
||||
- messaging
|
||||
- notification
|
||||
|
||||
하지만 current production Java reference는:
|
||||
|
||||
- messaging: 있음
|
||||
- notification: 있음
|
||||
- cache-redis: 0
|
||||
- httpclient: 0
|
||||
|
||||
support leaf에는 resource도 없다. 따라서 cache/httpclient의 edge는 **현재 source에서 직접 필요성을 찾지 못한 candidate stale dependency**다.
|
||||
|
||||
다만 static textual reference만으로 Gradle edge가 100% 불필요하다고 단정하지 않는다. compile/runtime classpath presence 자체를 의도적으로 이용하는 plugin/reflection mechanism이 있는지 downstream leaf 전체 분석에서 다시 확인해야 한다.
|
||||
|
||||
우선순위: **P3 cleanup candidate**
|
||||
|
||||
검증 기준:
|
||||
|
||||
1. 해당 leaf에서 support dependency 제거
|
||||
2. compile/test/runtime classpath 및 focused tests 실행
|
||||
3. app-bootstrap shipped composition/architecture tests 실행
|
||||
4. runtime bean graph 차이가 없는지 확인
|
||||
|
||||
현재 분석에서는 source를 수정하지 않았다.
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
### 10.1 support dedicated test
|
||||
|
||||
Fresh command:
|
||||
|
||||
```text
|
||||
./gradlew :adapter:outbound:support:test --rerun-tasks --console=plain
|
||||
```
|
||||
|
||||
결과: BUILD SUCCESSFUL.
|
||||
|
||||
이 test가 실제로 증명하는 것:
|
||||
|
||||
- normal Logback path에서 failure correlation id가 기록됨
|
||||
- MDC 없을 때 unknown sentinel
|
||||
- ordinary exception fixture에서 payload marker가 log에 없음
|
||||
- success DEBUG logging
|
||||
|
||||
증명하지 않는 것:
|
||||
|
||||
- exception message에 PII가 있을 때의 안전성
|
||||
- logger/appender 자체 실패 시 consumer behavior
|
||||
- full app component scan
|
||||
- downstream provider semantics
|
||||
|
||||
### 10.2 messaging consumer test
|
||||
|
||||
Fresh `OutboundMessagePublisherTest` 통과.
|
||||
|
||||
이 class에는 logger가 success observation에서 실제 RuntimeException을 던지는 regression test가 있고, send outcome이 logger failure와 분리됨을 증명한다.
|
||||
|
||||
### 10.3 notification consumer test
|
||||
|
||||
Fresh `NotificationAdapterTest` 통과.
|
||||
|
||||
normal logger에서 provider failure를 swallow하고 direct Notification PII가 log line에 없음을 증명한다. throwing-logger case는 없다.
|
||||
|
||||
### 10.4 optional adapter gating
|
||||
|
||||
Fresh `OptionalAdapterBeanGatingTest` 통과.
|
||||
|
||||
support config와 여러 optional adapter configs를 ApplicationContextRunner에 함께 넣었을 때 disabled defaults가 실제 provider bean을 만들지 않는다는 것을 검증한다. full `CaSkeletonApplication` scan과 동일한 boot path는 아니다.
|
||||
|
||||
### 10.5 architecture suite / dependency registry
|
||||
|
||||
- `CleanArchitectureTest --rerun-tasks`: BUILD SUCCESSFUL
|
||||
- `verifyCleanArchitectureDependencies`: BUILD SUCCESSFUL
|
||||
|
||||
이 둘은 source/package/project dependency constraint를 증명하며 diagnostics runtime failure나 PII behavior를 증명하지 않는다.
|
||||
|
||||
## 11. 역사적 형태
|
||||
|
||||
support README와 source는 initial repository부터 존재한다. 이후 architecture가 크게 성장하는 동안 support의 역할 설명 일부가 current implementation과 어긋났다.
|
||||
|
||||
특히 `OutboundHttpDependencyLogger`는 실제 historical class였다. 따라서 README의 해당 문장이 처음부터 허구였던 것은 아니다. 문제는 **class가 삭제된 이후 문서가 함께 이동하지 않았다는 것**이다.
|
||||
|
||||
또 messaging current source에는 logger failure를 business/publish failure와 분리하기 위해 `observeQuietly`가 도입된 과거 regression 설명이 남아 있다. 이 history는 notification consumer의 현재 shape와 비교할 때 중요한 evidence가 된다. 두 consumer가 같은 shared logger를 사용하지만 하나만 diagnostics를 non-authoritative로 격리한다.
|
||||
|
||||
## 12. Findings / improvement backlog
|
||||
|
||||
### P1 — arbitrary exception message가 PII-safe logging boundary를 우회한다
|
||||
|
||||
- **Observed fact:** `FailOpenDependencyLogger.logFailure`는 raw `cause.getMessage()`를 formatted WARN에 포함한다.
|
||||
- **Runtime evidence:** explicit email/body marker가 포함된 exception message가 실제 formatted log에 그대로 출력됐다.
|
||||
- **Contract conflict:** README/source/test는 payload/PII가 logger에 닿지 않는다고 주장한다.
|
||||
- **Why it matters:** fork/provider SDK exception message는 application이 통제하지 못하며 recipient/body/remote response를 포함할 수 있다.
|
||||
- **Verification:** `021a` probe와 raw output, 향후 dedicated regression test.
|
||||
- **Candidate:** raw cause message 제거 또는 explicit sanitizer/classifier.
|
||||
- **Tech-Log:** CASE + DECISION 후보.
|
||||
|
||||
### P1 — notification fail-open consumer가 logger failure를 격리하지 않는다
|
||||
|
||||
- **Observed fact:** `FailOpenNotificationProvider`는 send와 logSuccess를 동일 try에 두고 catch 안의 logFailure를 보호하지 않는다.
|
||||
- **Runtime evidence:** successful send 뒤 debug logger failure가 warn failure observation을 만들었고, provider failure 뒤 warn logger failure는 caller까지 전파됐다.
|
||||
- **Comparison:** messaging은 같은 shared logger를 `observeQuietly`로 이미 격리하고 regression test를 갖는다.
|
||||
- **Why it matters:** diagnostics가 business/provider outcome을 바꿔서는 안 된다는 non-authoritative observation 원칙이 consumer마다 달라진다.
|
||||
- **Verification:** `022a` focused probe; notification에 throwing-logger regression 추가.
|
||||
- **Candidate:** notification에서 observation isolation 또는 shared logger no-throw contract.
|
||||
- **Tech-Log:** CASE + DECISION 후보.
|
||||
|
||||
### P3 — support README가 current architecture registry/history와 drift
|
||||
|
||||
- **Observed fact:** SSOT 위치, CLAUDE.md 존재 여부, HTTP logger 존재 여부가 current source와 불일치.
|
||||
- **Why it matters:** support module의 dependency policy와 비교 설계를 읽는 사람이 현재 architecture를 잘못 이해한다.
|
||||
- **Verification:** `019` raw search/history.
|
||||
- **Candidate:** README를 `modules.json`/current consumer topology에 맞춰 갱신.
|
||||
- **Tech-Log:** 보통 refactor/doc maintenance; 독립 CASE 우선순위는 낮음.
|
||||
|
||||
### P3 — cache-redis/httpclient의 support project dependency 필요성 재검증
|
||||
|
||||
- **Observed fact:** 두 leaf 모두 Gradle support dependency는 있지만 current production Java support reference는 0이고 support resource도 없다.
|
||||
- **Why it matters:** 불필요 edge는 classpath와 architecture narrative를 실제 필요보다 넓힌다.
|
||||
- **Verification:** dependency 제거 후 각 leaf focused test + app composition test.
|
||||
- **Status:** candidate only; downstream leaf exhaustive analysis 전에는 confirmed dead edge로 단정하지 않음.
|
||||
- **Tech-Log:** OPEN QUESTION / refactor candidate.
|
||||
|
||||
## 13. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
### 확인한 것
|
||||
|
||||
- leaf-owned source/test/build/README/CLAUDE 8개 전부 FULL_READ
|
||||
- current compile dependency graph
|
||||
- registry membership와 allowed project edges
|
||||
- app-bootstrap component-scan wiring
|
||||
- support public surface reachability
|
||||
- optional sibling activation shape
|
||||
- duplicate logger/correlation mechanism search
|
||||
- README named-claim drift와 relevant Git history
|
||||
- messaging/notification direct consumers
|
||||
- PII exception-message runtime probe
|
||||
- diagnostics-failure runtime probe
|
||||
- focused support/messaging/notification/gating tests
|
||||
- architecture/dependency verification
|
||||
|
||||
### 이 scope에서 exhaustive하지 않은 것
|
||||
|
||||
- messaging 전체 module
|
||||
- notification 전체 adapter module
|
||||
- cache-redis 전체 module
|
||||
- httpclient 전체 module
|
||||
- production Logback deployment/backend 장애 행동
|
||||
- 실제 외부 provider SDK의 구체 exception message corpus
|
||||
|
||||
따라서 §4의 핵심은 “특정 SDK가 지금 반드시 PII를 exception에 넣는다”가 아니다. **공통 logger contract가 arbitrary exception message를 허용하고 실제로 그대로 출력하므로 PII-safe를 보장할 수 없다는 것**이다.
|
||||
|
||||
§5 역시 실제 production disk-full 사고를 주장하지 않는다. 현재 compiled consumer에 throwing logger를 주었을 때 outcome semantics가 깨지는 코드 경로를 재현한 것이다.
|
||||
|
||||
## 14. 완료 판정
|
||||
|
||||
`adapter-outbound-support`는 다음 gate를 충족했다.
|
||||
|
||||
- bounded denominator: 8 / unclassified 0
|
||||
- production/test source 전부 FULL_READ
|
||||
- build dependency와 runtime membership 확인
|
||||
- composition-root wiring 확인
|
||||
- public contract와 logging semantics 추출
|
||||
- success/failure consumer path 추적
|
||||
- dedicated tests와 downstream focused tests 실행
|
||||
- ArchUnit/project dependency enforcement 확인
|
||||
- public reachability probe 수행
|
||||
- conditional sibling probe 수행
|
||||
- duplicate mechanism probe 수행
|
||||
- documentation drift probe 수행
|
||||
- raw evidence에 exact command/cwd/time/revision/exit/output 보존
|
||||
- P1/P3 backlog와 limitations 분리
|
||||
|
||||
따라서 이 bounded scope를 `COMPLETE`로 판정할 수 있다. 프로젝트 전체는 아직 후속 outbound/persistence/messaging/inbound/bootstrap scopes와 마지막 `analysis/99-cross-scope.md`가 남아 있으므로 계속 `IN_PROGRESS`다.
|
||||
|
||||
## Source anchors
|
||||
|
||||
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **12개** (main 4 · test 1 · 기타 7).
|
||||
|
||||
```
|
||||
src/adapter/outbound/support/build.gradle
|
||||
src/config/architecture/modules.json (adapter-outbound-support 항목)
|
||||
|
||||
main:
|
||||
src/main/java/dev/caskeleton/adapter/outbound/package-info.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLogger.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/support/OutboundCorrelation.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/support/OutboundSupportConfig.java
|
||||
|
||||
test:
|
||||
src/test/java/dev/caskeleton/adapter/outbound/support/FailOpenDependencyLoggerTest.java
|
||||
|
||||
기타:
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
README.md
|
||||
docs/registries/mdc-keys.yaml
|
||||
src/app-bootstrap/build.gradle
|
||||
src/build.gradle
|
||||
src/config/architecture/modules.json
|
||||
|
||||
해석되지 않은 인용 (12종) — 외부 타입·문서상 약칭 등:
|
||||
modules.json
|
||||
evidence/raw/015-adapter-outbound-support-inventory.txt
|
||||
evidence/raw/021a-support-logger-pii-probe.java
|
||||
evidence/raw/021-support-logger-pii-probe.txt
|
||||
evidence/raw/022a-notification-logger-failure-probe.java
|
||||
evidence/raw/022-notification-logger-failure-probe.txt
|
||||
016-adapter-outbound-support-public-reachability.txt
|
||||
017-adapter-outbound-support-conditional-wiring.txt
|
||||
018-adapter-outbound-support-duplicate-mechanisms.txt
|
||||
019-adapter-outbound-support-document-drift.txt
|
||||
020-adapter-outbound-support-project-edge-usage.txt
|
||||
analysis/99-cross-scope.md
|
||||
|
||||
```
|
||||
@@ -0,0 +1,233 @@
|
||||
# 07 · adapter-outbound-identifier
|
||||
|
||||
|
||||
## SSOT identity — 2026-08-31 재검증
|
||||
|
||||
- registered leaf id: `adapter-outbound-identifier`
|
||||
- canonical state `analysisFile`: `analysis/07-adapter-outbound-identifier.md` (이 문서) — 이 leaf의 단일 SSOT
|
||||
- source path: `src/adapter/outbound/identifier` · Gradle `:adapter:outbound:identifier`
|
||||
- registry `allowed_dependencies`: `["domain-core", "application-core"]`
|
||||
- registry `runtime_memberships`: `["app-bootstrap", "sample-portfolio"]`
|
||||
- coverage ledger: `FULL_READ` **10** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
|
||||
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
|
||||
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행)
|
||||
|
||||
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
|
||||
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
|
||||
|
||||
---
|
||||
> 상태: COMPLETE
|
||||
> revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
|
||||
> 경로: `src/adapter/outbound/identifier` · Gradle: `:adapter:outbound:identifier`
|
||||
|
||||
## 0. Denominator와 coverage ledger
|
||||
|
||||
이 leaf는 tracked file **10개**다. 하위 범위로 나눌 크기가 아니라 한 범위로 처리한다.
|
||||
|
||||
| 구분 | 파일 | LOC | 상태 |
|
||||
|---|---|---|---|
|
||||
| governance (`CLAUDE.md`, `README.md`, `build.gradle`, `gradle.lockfile`) | 4 | 297 | FULL_READ |
|
||||
| main Java (`HmacUserPrincipalPseudonymizer`, `RandomUploadIdentifierFactory`, `UuidCodec`, `package-info`) | 4 | 114 | FULL_READ |
|
||||
| test Java (`HmacUserPrincipalPseudonymizerTest`) | 1 | 112 | FULL_READ |
|
||||
| test Groovy (`UuidCodecSpec`) | 1 | 39 | FULL_READ |
|
||||
| **합계** | **10** | **562** | **10 / 10 FULL_READ** |
|
||||
|
||||
structural-only 0 · excluded 0 · unclassified 0.
|
||||
|
||||
manifest: `evidence/raw/139-identifier-module-inventory.txt`.
|
||||
probe(정적 + 실행): `evidence/raw/140-identifier-negative-space-probes.txt`.
|
||||
|
||||
레지스트리 항목:
|
||||
|
||||
```json
|
||||
{ "id": "adapter-outbound-identifier",
|
||||
"gradle_path": ":adapter:outbound:identifier",
|
||||
"allowed_dependencies": ["domain-core", "application-core"],
|
||||
"runtime_memberships": ["app-bootstrap", "sample-portfolio"] }
|
||||
```
|
||||
|
||||
## 1. 이 모듈이 존재하는 이유
|
||||
|
||||
CLAUDE.md와 README가 같은 논거를 편다: **외부 시스템 연동이 없는(non-IO) 인프라 능력**만 모아 두어, `adapter-outbound`가 문서화된 의미("외부 HTTP / messaging / cache / notifications")를 유지하게 한다는 것이다.
|
||||
|
||||
> Kept out of `adapter-outbound` on purpose: a UUID id/codec capability is infrastructure, not an outbound integration point.
|
||||
|
||||
그 논거의 예시로 드는 능력이 **UUID id/codec**이다. §3에서 보듯 그 능력에는 production 소비자가 없고, 실제로 배선돼 도는 것은 나머지 둘(가명화, 업로드 식별자)이다.
|
||||
|
||||
## 2. Confirmed — `HmacUserPrincipalPseudonymizer`는 이 leaf에서 가장 잘 만들어진 부분이다
|
||||
|
||||
`UserPrincipalPseudonymizerPort`(application-core)의 유일한 구현이고, `app-bootstrap`과 `sample-portfolio`가 각자 싱글톤 빈으로 배선한다(`140-...` §8.1). 결정들이 코드와 문서 양쪽에 맞물려 있다.
|
||||
|
||||
- **thread safety를 타입이 아니라 수명으로 푼다.** `Mac`은 thread-safe가 아니므로 `pseudonymize` 호출마다 새로 만든다 — 그래서 공유 싱글톤으로 안전하다. 주석이 그 이유를 그 자리에 적는다.
|
||||
- **salt를 스스로 조달하지 않는다.** `APP_PRIVACY_PSEUDONYMIZATION_SALT`에서 `app-bootstrap`이 공급하고(분류 secret, 90일 회전), 생성자는 null/빈 배열을 거부하며 거부 메시지가 그 환경변수 이름을 그대로 말한다.
|
||||
- **방어적 복사.** `salt.clone()`으로 호출자 배열을 붙들지 않는다.
|
||||
- **도달 불가 예외를 정직하게 감싼다.** `HmacSHA256`은 JCA 필수 알고리즘이라 `NoSuchAlgorithmException`·`InvalidKeyException`은 사실상 도달 불가이고, 호출부에 checked exception 잡음을 남기지 않으려 `IllegalStateException`으로 감싸며 주석이 "this should never happen on a compliant JDK"라고 적는다.
|
||||
- **Spring-free.** 어노테이션이 없고 빈 생성은 composition root 책임이다.
|
||||
|
||||
test 11개가 경계를 실제로 나눠 덮는다 — 생성자 가드 2, null/blank/empty 입력 3, 같은 salt의 결정성(같은 인스턴스·다른 인스턴스) 2, salt 민감도 1, 단방향성 2(출력≠입력, 출력이 입력을 부분문자열로 포함하지 않음), 출력 포맷 1(`^[0-9a-f]{64}$`). 값 하나를 고정하는 golden test가 아니라 **성질**을 검사한다.
|
||||
|
||||
`RandomUploadIdentifierFactory`의 판단도 기록해 둘 만하다. 파일 식별자가 공개 핸들이므로 시퀀스나 타임스탬프가 아니라 암호학적 난수에서 뽑는다고 적고, 그 대가까지 명시한다 — "A time-ordered identifier would be **the better database key**, and is deliberately not used: it would let anyone holding one id infer when neighbouring files were created and enumerate towards them." 결정과 그 결정이 포기한 것을 함께 적는 서술이다.
|
||||
|
||||
경계 규칙도 실재한다. CLAUDE.md가 이름을 대는 ArchUnit 규칙은 `CleanArchitectureTest`에 **대문자 상수** `IDENTIFIER_ADAPTER_DOES_NOT_DEPEND_ON_OTHER_ADAPTERS_OR_BOOTSTRAP`로 존재하고, inbound web · persistence · bootstrap · Spring Data · JPA · Hibernate를 금지하며 형제 배제(`..adapter.outbound..` 중 자기 패키지 제외)까지 처리한다(`140-...` §8.4e). CLAUDE.md의 표기가 snake_case일 뿐 가드는 진짜다 — **confirmed match**.
|
||||
|
||||
## 3. P2 — 모듈의 존재 논거인 `UuidCodec`에 production 소비자가 없다
|
||||
|
||||
`140-...` §8.1·§8.1b의 저장소 전수 검색 결과:
|
||||
|
||||
| 타입 | leaf 밖 production 소비자 |
|
||||
|---|---|
|
||||
| `HmacUserPrincipalPseudonymizer` | `app-bootstrap`, `sample-portfolio` (각 1) |
|
||||
| `RandomUploadIdentifierFactory` | `app-bootstrap` (1) |
|
||||
| **`UuidCodec`** | **0** |
|
||||
|
||||
`UuidCodec.` 형태의 호출은 저장소 전체에서 **자기 Spock 스펙 5줄뿐**이다. 이름이 겹쳐 걸린 나머지 둘은 무관하다 — mongo testkit의 `org.bson.codecs.UuidCodec`(드라이버 타입)과 `sample-portfolio/README.md:333`의 산문 언급("`UuidCodec` 같은 공용…").
|
||||
|
||||
그 자리를 대신 채우고 있는 것들이 있다. `UUID.fromString`을 직접 부르는 파일이 leaf 밖에 20개 이상이고(graphql `UuidScalar`, notification `JacksonRoutingPlanCodec`, jpa `PostgreSqlIdempotencyClaimRepository`, application-core `FileId`/`UploadId` …), CLAUDE.md가 `toUuid`/`fromUuid`의 목적으로 든 **PostgreSQL `uuid` 컬럼 변환(D10)**은 실제로는 Hibernate의 `@JdbcTypeCode(SqlTypes.UUID)`가 처리한다(`140-...` §8.4b, JPA 엔티티 다수).
|
||||
|
||||
**판정: P2.** 코드 자체에는 결함이 없다 — 30줄짜리 유틸이고 자기 스펙을 통과한다. 문제는 §1의 논거다. 모듈을 `adapter-outbound` 밖에 두는 근거로 "UUID id/codec 능력"을 들고 있는데, 그 능력은 아무도 쓰지 않고 같은 일이 저장소 곳곳에서 각자 수행된다. 나머지 두 타입(가명화·업로드 식별자)만으로도 non-IO 능력 모듈의 논거는 성립하므로, 수정은 둘 중 하나다: `UuidCodec`을 실제 단일 경로로 만들거나(그러면 §4가 먼저 고쳐져야 한다), 모듈의 논거에서 빼는 것.
|
||||
|
||||
## 4. P2 — `normalize`는 canonical이 아닌 입력을 받아 다른 UUID로 조용히 바꾼다
|
||||
|
||||
`UuidCodec.normalize`의 계약은 Javadoc과 README 양쪽에 적혀 있다.
|
||||
|
||||
> Accepts a **case-insensitive canonical UUID** string and returns the canonical 36-character lowercase form… `@throws IllegalArgumentException` on a **malformed** UUID.
|
||||
> (README) 형식 오류 UUID 에는 `IllegalArgumentException`.
|
||||
|
||||
구현은 `UUID.fromString(input).toString()` 한 줄이다. JDK의 `UUID.fromString`은 길이 36 fast path 밖에서 **대시로 나뉜 5개 hex 그룹을 길이 검사 없이** 받는다. 실행 probe로 확인했다(`140-...` EXECUTION PROBE).
|
||||
|
||||
```
|
||||
PROBE normalize("0190BD6E-7C3E-7ABC-8DEF-0123456789AB") -> "0190bd6e-7c3e-7abc-8def-0123456789ab" ← 의도된 동작
|
||||
PROBE normalize("1-1-1-1-1") -> "00000001-0001-0001-0001-000000000001"
|
||||
PROBE normalize("0-0-0-0-0") -> "00000000-0000-0000-0000-000000000000"
|
||||
PROBE normalize("1-2-3-4-5") -> "00000001-0002-0003-0004-000000000005"
|
||||
PROBE normalize("0190bd6e7c3e7abc8def0123456789ab") -> IllegalArgumentException
|
||||
PROBE normalize("not-a-uuid") -> IllegalArgumentException
|
||||
```
|
||||
|
||||
`"1-1-1-1-1"`은 canonical UUID가 아니다. 계약대로면 `IllegalArgumentException`이어야 하는데, 수용된 뒤 **다른 문자열로 재작성되어** 반환된다. 결과적으로 서로 다른 두 입력(`"1-1-1-1-1"`과 `"00000001-0001-0001-0001-000000000001"`)이 같은 식별자로 정규화되고, 거부됐어야 할 값이 정상적으로 보이는 id가 된다. `normalize`는 D3 — 호출자가 준 텍스트를 저장 형태로 바꾸는 지점 — 이므로, 관대함이 남는 위치가 하필 신뢰 경계다.
|
||||
|
||||
기존 스펙이 이것을 놓친 이유도 코드에 있다. `UuidCodecSpec`의 거부 케이스는 `"not-a-uuid"` **하나**이고, 그 문자열은 대시 그룹이 5개가 아니라 관대한 경로에 닿지 않는다.
|
||||
|
||||
**도달성.** 지금 이 메서드를 부르는 production 코드는 없다(§3). 그래서 현재 노출은 0이고, `UuidCodec`을 단일 경로로 승격하는 순간 결함이 된다. **판정: P2.** 수정은 `input.length() != 36`이거나 대시 위치가 8-13-18-23이 아니면 먼저 거부하는 것 — 또는 계약 문구를 실제 동작(JDK 관대 파싱)에 맞추는 것이다. 전자가 문서가 말하는 바다.
|
||||
|
||||
## 5. P2 — 문서는 UUIDv7이라고 말하고, 생성되는 것은 v4다
|
||||
|
||||
CLAUDE.md:21과 README:19가 같은 문장을 쓴다 — `UuidCodec`은 "JDK `java.util.UUID` (**RFC 9562 UUIDv7**)" 위에서 동작한다. README:12도 이 모듈의 능력을 "식별자 생성/인코딩(**UUIDv7**)"이라 적는다. 그리고 이 주장은 leaf 밖으로도 번져 있다 — `CleanArchitectureTest`가 이 패키지를 설명하는 세 곳(:948, :981, :1310)이 전부 "UUIDv7 id/event-id generation & codec"이라고 쓴다.
|
||||
|
||||
실행 probe(`140-...`):
|
||||
|
||||
```
|
||||
PROBE newFileId version=4 variant=2
|
||||
PROBE newUploadId version=4 variant=2
|
||||
PROBE UUID.randomUUID() version=4 (RFC 9562 UUIDv7 would report version=7)
|
||||
```
|
||||
|
||||
`java.util.UUID`에는 v7 생성기가 없고, 이 leaf의 유일한 생성기 `RandomUploadIdentifierFactory`는 `UUID.randomUUID()`(v4)를 쓴다. 더 분명한 것은 **그 클래스의 javadoc이 v7을 명시적으로 거부한다**는 점이다 — "A time-ordered identifier would be the better database key, and is **deliberately not used**." 즉 코드는 숙고된 결정을 내렸고, 모듈 문서와 아키텍처 test의 설명문이 **정반대 결정을 서술**한다.
|
||||
|
||||
저장소의 진짜 UUIDv7은 다른 곳에 있다: `sample-portfolio`가 `com.github.f4b6a3:uuid-creator`를 자기 `build.gradle`에 선언하고 `UuidCreator.getTimeOrderedEpochPlus1()`을 쓴다(`SampleOperationStore:27`).
|
||||
|
||||
**판정: P2.** 런타임 동작은 옳다 — 잘못된 것은 세 문서와 한 test의 설명문이다. 그러나 "이 모듈이 UUIDv7을 제공한다"는 서술은 fork가 시간정렬 키를 기대하게 만들고, 그 기대는 인덱스 지역성과 열거 가능성 양쪽에서 반대 방향의 결과를 낳는다.
|
||||
|
||||
## 6. P3 — CLAUDE.md의 의존성 서술이 세 항목 모두 틀렸다
|
||||
|
||||
CLAUDE.md:31–33:
|
||||
|
||||
> `:application-code`, `:domain-core`, `:shared-contract` (Gradle matrix). **Currently only `:domain-core` + `com.github.f4b6a3:uuid-creator` are declared in build.gradle.**
|
||||
|
||||
`build.gradle`의 실제 `dependencies` 블록은 두 줄이다.
|
||||
|
||||
```groovy
|
||||
implementation project(':application-core')
|
||||
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
|
||||
```
|
||||
|
||||
- `:domain-core` — 선언돼 있지 **않다**.
|
||||
- `uuid-creator` — 선언돼 있지 **않다**(저장소 검색상 이 leaf의 classpath에 없다; `sample-portfolio`와 `app-bootstrap`의 lockfile에만 있다).
|
||||
- `:application-core` — 선언돼 **있는데** 문장은 언급하지 않는다.
|
||||
|
||||
레지스트리의 `allowed_dependencies`(`domain-core`, `application-core`)와 실제 선언(부분집합)은 정합한다. 어긋난 것은 CLAUDE.md의 서술뿐이다. **P3.**
|
||||
|
||||
## 7. P3 — README의 세 가지 사실 오류
|
||||
|
||||
| README | 실제 |
|
||||
|---|---|
|
||||
| :3 패키지 루트 `dev.caskeleton.adapter.identifier` | `dev.caskeleton.adapter.outbound.identifier` (CLAUDE.md:11은 정확) |
|
||||
| :59 "Spock 2.4 / **Groovy 4.0** variant" | `spock-core:2.4-groovy-**5.0**` |
|
||||
| :64 edge는 `src/build.gradle`의 `allowedProjectDependencies['**adapter-identifier**']`로 허용 | `build.gradle:1416`의 `allowedProjectDependencies`는 리터럴 맵이 아니라 `registry.modules.collectEntries { … }`로 **레지스트리에서 파생**되며, 이 모듈의 키는 `adapter-outbound-identifier`다 |
|
||||
|
||||
셋 다 메커니즘 자체는 실재하고 동작한다 — 틀린 것은 이름과 버전이다. **P3.**
|
||||
|
||||
## 8. P3 — CLAUDE.md가 대는 두 가드 중 하나는 저장소에 없다
|
||||
|
||||
CLAUDE.md:38–39가 금지 사항의 근거로 둘을 든다.
|
||||
|
||||
1. ArchUnit `identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap` — **존재한다**(§2, 상수명은 대문자). confirmed.
|
||||
2. `.claude/hooks/ca_import_gate.py` **G4가 쓰기 시점에 차단** — `.claude/` 디렉터리에는 `settings.local.json` 하나뿐이고 `hooks/` 하위 디렉터리도 `ca_import_gate.py`도 **tracked 되어 있지 않다**(`140-...` §8.4e).
|
||||
|
||||
개발자 머신에 로컬로 존재할 여지는 있으나, 저장소를 새로 clone한 사람에게 그 가드는 없다. "쓰기 시점에 차단된다"는 서술은 clone에서 성립하지 않는다. **P3.**
|
||||
|
||||
## 9. P3/기록 — 결정 SSOT가 이 revision에서 해석되지 않는다
|
||||
|
||||
CLAUDE.md와 README가 `UuidCodec`의 동작을 **D3**(normalize)와 **D10**(toUuid/fromUuid)로 지목하고, 모듈 분류 근거로 "feature-resource-identifier-contract §4 taxonomy"를 든다. `CleanArchitectureTest`도 같은 문서를 §4·D5·D9·D17로 네 곳에서 인용한다.
|
||||
|
||||
그 문서는 이 revision에 **파일로 존재하지 않는다**(`find -iname '*resource-identifier*'` 매치 0; `docs/`에서 걸리는 D3/D10은 전부 MongoDB의 무관한 노출 평면 표기다). `CleanArchitectureTest:2432`의 주석이 이유를 밝힌다 — "decision SSOT: **resource-identifier branch**". 즉 다른 브랜치에 있다.
|
||||
|
||||
은폐가 아니라 명시된 상태이므로 결함으로 올리지 않고 기록한다. 다만 이 leaf의 문서가 자기 동작의 근거로 대는 결정 ID들은 이 브랜치만 읽어서는 확인할 수 없고, §4·§5의 어긋남이 "구현이 결정을 벗어난 것"인지 "결정이 그 사이 바뀐 것"인지도 여기서는 판정 불가다. **P3/기록.**
|
||||
|
||||
## 10. Negative-space probes
|
||||
|
||||
- **8.1 public-surface reachability**: 세 production 타입의 저장소 전수 소비자 계수. `UuidCodec` 0(§3), 나머지 둘은 composition root가 배선.
|
||||
- **8.2 조건부 형제 비교**: `UuidCodec`의 세 자매 메서드가 null을 다르게 다룬다 — `normalize(null)` → `null`(문서화됨), `toUuid(null)` → `NullPointerException`, `fromUuid(null)` → `NullPointerException`(둘 다 미문서화). 실행 probe로 확인. 계약 문구는 `normalize`에만 있다. **P3.**
|
||||
- **8.2b 계약 ↔ 구현 대조**: `normalize`의 "canonical만 수용" 주장과 JDK 관대 파싱(§4). 실행 probe로 확정.
|
||||
- **8.3 중복 mechanism sweep**: UUID 문자열 변환이 leaf 밖 20+ 파일에서 `UUID.fromString`으로 각자 수행되고, D10이 지목한 PostgreSQL `uuid` 컬럼 변환은 Hibernate `@JdbcTypeCode(SqlTypes.UUID)`가 담당(§3). 저장소의 UUIDv7 생성은 `sample-portfolio`의 `UuidCreator`(§5).
|
||||
- **8.4 문서/개수 drift**: §5(UUID 버전, leaf 문서 3곳 + 아키텍처 test 3곳) · §6(의존성 서술 3항목) · §7(패키지 루트·Groovy 버전·설정 키) · §8(가드 하나 부재) · §9(결정 SSOT 미해석).
|
||||
|
||||
## 11. Findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| **P2** | `UuidCodec.normalize`가 canonical이 아닌 5-그룹 입력(`"1-1-1-1-1"` 등)을 수용해 다른 UUID로 재작성한다 — Javadoc·README는 canonical만 받고 malformed는 거부한다고 적는다 (실행 probe) | 현재 호출자 0; `UuidCodec`을 단일 경로로 쓰는 순간 신뢰 경계 결함 |
|
||||
| **P2** | `UuidCodec`에 production 소비자가 0인데, 모듈을 `adapter-outbound` 밖에 두는 논거가 바로 이 "UUID id/codec 능력"이다. 같은 변환이 leaf 밖 20+ 파일에서 각자 수행되고 D10의 대상은 Hibernate가 처리한다 | 문서/모듈 경계 논거 |
|
||||
| **P2** | leaf 문서 3곳과 `CleanArchitectureTest` 설명문 3곳이 "RFC 9562 UUIDv7"이라 적지만 유일한 생성기는 v4를 만들고, 그 클래스의 javadoc은 시간정렬 id를 명시적으로 거부한다 (실행 probe: version=4) | fork가 시간정렬 키를 기대하는 경우 |
|
||||
| **P3** | CLAUDE.md:31–33의 build.gradle 선언 서술이 세 항목 모두 사실과 다르다 | 문서 |
|
||||
| **P3** | README의 패키지 루트·Spock/Groovy variant·`allowedProjectDependencies` 키 3건 오류 | 문서 |
|
||||
| **P3** | CLAUDE.md가 대는 write-time 가드 `.claude/hooks/ca_import_gate.py`가 저장소에 tracked 되어 있지 않다 | 새 clone |
|
||||
| **P3** | `UuidCodec` 세 자매 메서드의 null 처리 비대칭이 `normalize`에만 문서화돼 있다 | 호출 시점 |
|
||||
| **P3/기록** | 결정 SSOT `feature-resource-identifier-contract`(§4·D3·D5·D9·D10·D17)가 다른 브랜치에 있어 이 revision에서 해석되지 않는다 | 결정 대조 |
|
||||
|
||||
## 12. 완료 조건
|
||||
|
||||
- denominator **10 / 10 FULL_READ** — structural-only 0 · excluded 0 · unclassified 0 (§0)
|
||||
- §8.1(공개 표면 도달성) · §8.2(조건부 형제) · §8.3(중복 mechanism) · §8.4(문서/개수 drift) 네 종 probe 수행
|
||||
- 정적으로 결정 불가한 세 지점(normalize의 실제 수용 범위, 세 자매의 null 처리, 생성되는 UUID 버전)을 실행 probe로 확정(`140-...`)
|
||||
- CLAUDE.md가 대는 두 가드를 각각 추적해 **하나는 실재(confirmed)**, 하나는 부재로 분리 판정 — 이름 표기 차이를 결함으로 올리지 않았다
|
||||
- 임시 probe class 1개 추가 후 제거, `git status --short` = 0, 소스 미변경
|
||||
|
||||
## Source anchors
|
||||
|
||||
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **8개** (main 3 · test 1 · 기타 4).
|
||||
|
||||
```
|
||||
src/adapter/outbound/identifier/build.gradle
|
||||
src/config/architecture/modules.json (adapter-outbound-identifier 항목)
|
||||
|
||||
main:
|
||||
src/main/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizer.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/identifier/RandomUploadIdentifierFactory.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/identifier/UuidCodec.java
|
||||
|
||||
test:
|
||||
src/test/java/dev/caskeleton/adapter/outbound/identifier/HmacUserPrincipalPseudonymizerTest.java
|
||||
|
||||
기타:
|
||||
CLAUDE.md
|
||||
README.md
|
||||
src/build.gradle
|
||||
src/sample-portfolio/README.md
|
||||
|
||||
해석되지 않은 인용 (3종) — 외부 타입·문서상 약칭 등:
|
||||
evidence/raw/139-identifier-module-inventory.txt
|
||||
evidence/raw/140-identifier-negative-space-probes.txt
|
||||
settings.local.json
|
||||
|
||||
```
|
||||
@@ -0,0 +1,768 @@
|
||||
# 08 · adapter-outbound-fileserver
|
||||
|
||||
|
||||
## SSOT identity — 2026-08-31 재검증
|
||||
|
||||
- registered leaf id: `adapter-outbound-fileserver`
|
||||
- canonical state `analysisFile`: `analysis/08-adapter-outbound-fileserver.md` (이 문서) — 이 leaf의 단일 SSOT
|
||||
- source path: `src/adapter/outbound/fileserver` · Gradle `:adapter:outbound:fileserver`
|
||||
- registry `allowed_dependencies`: `["application-core", "shared-contract"]`
|
||||
- registry `runtime_memberships`: `["app-bootstrap"]`
|
||||
- coverage ledger: `FULL_READ` **119** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
|
||||
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
|
||||
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행) — `EVD-334`의 로케일 finding이 이 리프의 것이다
|
||||
|
||||
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
|
||||
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
|
||||
|
||||
---
|
||||
> 상태: COMPLETE
|
||||
> revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
|
||||
> 경로: `src/adapter/outbound/fileserver` · Gradle: `:adapter:outbound:fileserver`
|
||||
|
||||
## 0. Denominator와 coverage ledger
|
||||
|
||||
tracked file **119개** — main 78 (12,707 LOC), test 37 (12,043 LOC), governance 4. 총 약 24.7k LOC.
|
||||
`build.gradle`에 별도 source set이나 test lane 선언이 없다(`main`/`test`뿐).
|
||||
|
||||
레지스트리:
|
||||
|
||||
```json
|
||||
{ "id": "adapter-outbound-fileserver",
|
||||
"gradle_path": ":adapter:outbound:fileserver",
|
||||
"allowed_dependencies": ["application-core", "shared-contract"],
|
||||
"runtime_memberships": ["app-bootstrap"] }
|
||||
```
|
||||
|
||||
leaf 밖 소비자는 `app-bootstrap` 하나다 — `CaSkeletonApplication` + `autoconfigure/fileserver/**` 6개 config 클래스, 그리고 test 3개.
|
||||
|
||||
패키지 배치(main): 루트 `fileserver` 31 · `platform/local` 33 · `platform/verification` 10 · `platform/security` 2 · `platform/audit` 2.
|
||||
|
||||
### 하위 범위 원장
|
||||
|
||||
| # | 범위 | main | test | 합 | 상태 |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | governance / build / config / activation (+ governance 4) | 7 | 2 | 13 | **COMPLETE** |
|
||||
| 2 | control plane + control record codec + recovery verifier | 3 | 3 | 6 | **COMPLETE** |
|
||||
| 3 | publication — provider · adapter · journal · attestor · binding | 19 | 7 | 26 | **COMPLETE** |
|
||||
| 4 | `platform/local` IO primitive · gateway · store/publisher | 22 | 8 | 30 | **COMPLETE** |
|
||||
| 5 | `platform/local` failure·probe·health·orphan + verification + security + audit | 24 | 5 | 29 | **COMPLETE** |
|
||||
| 6 | payload operations · CSV export · testkit 계약 · crash matrix | 3 | 12 | 15 | **COMPLETE** |
|
||||
| | **TOTAL** | **78** | **37** | **119** (governance 4 포함) | **6 / 6** |
|
||||
|
||||
manifest: `evidence/raw/141-fileserver-module-inventory.txt`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sub-scope 01 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **13 / 13 FULL_READ**
|
||||
> 범위: governance 4 + config/activation main 7 + 전용 test 2
|
||||
> 역할: R1(CSV export)과 R2(local-persistent publication) **두 개의 opt-in 선택자**를 서로 혼동될 수 없게 분리하고, 잘못된 조합을 파일시스템에 손대기 전에 거부한다
|
||||
|
||||
manifest와 probe: `evidence/raw/142-fileserver-config-activation-probes.txt`.
|
||||
|
||||
## 2. 선택자 세 개가 각자 다른 것을 켠다
|
||||
|
||||
이 leaf는 이름이 비슷한 세 능력을 명시적으로 갈라 둔다(CLAUDE.md).
|
||||
|
||||
| namespace | 무엇을 켜는가 | 소유 |
|
||||
|---|---|---|
|
||||
| `app.fileserver.*` | R2 publication (`local-persistent`) | 이 leaf |
|
||||
| `app.file-export.*` | R1 CSV export (+ `legacy-enabled`로 덮어쓰기 가능 legacy port) | 이 leaf |
|
||||
| `app.fileserver-platform.*` | HTTP Fileserver **플랫폼**(업로드/다운로드/수명주기 라우트) | `app-bootstrap` |
|
||||
|
||||
셋 다 기본 off이고, R1과 R2 동시 활성화는 파일시스템 초기화 **전에** 실패한다. `FileserverActivationValidator.rejectAmbiguous(environment)`가 세 bean factory 메서드의 **첫 줄**에서 호출되고(`FileExportConfig:33`·`:49`, `FileserverR2Config:31`), `Binder`로 두 selector를 직접 읽으므로 bean 정의 순서에 의존하지 않는다. test가 그 순서를 고정한다 — `enablingLegacyR1AndR2TogetherFailsBeforeEitherFilesystemIsMutated`는 실패 후 R2 루트의 `.ca-fileserver`·`data`와 R1/legacy 루트가 **모두 존재하지 않음**을 단언한다.
|
||||
|
||||
R2 쪽 조립은 fail-closed가 촘촘하다. `FileserverR2Config.routingFilePublicationPort`는 destination을 컴파일하고, 서로 다른 provider ID가 같은 루트를 소유하는 조합을 거부하고(`rejectSharedRootAcrossProviderIds`), provider ID별로 **하나의** attestor/control plane/payload 런타임을 만들어 같은 provider를 지목한 모든 destination이 공유하게 한다. test 둘이 그 공유/분리를 각각 확인한다(`destinationsBoundToOneProviderReuseOneProviderRuntime`, `destinationsBoundToDifferentProvidersUseDifferentProviderRuntimes`).
|
||||
|
||||
`FileserverR2Validation`은 값 검증을 한곳에 모은다 — ID는 `[a-z][a-z0-9-]{0,62}`이고 **이미 정규화돼 있어야** 하며, 경로는 **이미 절대·정규화**돼 있어야 하고, sentinel 이름은 `.`/`..`/구분자/제어문자를 거부한 뒤 UTF-8 인코딩 길이 255바이트와 `getNameCount()==1`까지 확인한다. `maximum-root-mode`는 네 자리 8진수만 받고 **group/world write를 별도로 거부**한다(`(group & 2) != 0 || (others & 2) != 0`).
|
||||
|
||||
## 3. Confirmed — 비활성 상태에서 부작용이 없다는 것을 test가 실제로 확인한다
|
||||
|
||||
`disabledR2CreatesNoPortOrFilesystemSideEffect`는 bean 부재만이 아니라 **설정된 루트가 생성되지 않았음**(`assertThat(absentRoot).doesNotExist()`)까지 단언한다. `unknownConfigurationFieldIsRejectedInsteadOfSilentlyIgnored`도 실패 후 `.ca-fileserver`·`data` 부재를 확인한다. "비활성이면 아무 일도 없다"를 bean 목록이 아니라 파일시스템으로 검증하는 형태다.
|
||||
|
||||
`rejectsLegacyRootThatAliasesThePublicationRootThroughASymbolicLink`는 심볼릭 링크로 우회한 루트 겹침까지 본다 — `canonicalDirectory`가 `toRealPath()`로 정규화한 뒤 `startsWith`로 양방향 포함을 검사하기 때문에 잡힌다.
|
||||
|
||||
## 4. P2 — README가 "노출된 setting도 bean도 없다"고 적은 능력들에 production bean이 있다
|
||||
|
||||
README:103–105의 guarantee boundary 문단이 이렇게 끝난다.
|
||||
|
||||
> Cross-node producer fencing, background reconciliation/reaping, retention, quota/backpressure, readiness/health, metrics, tracing, and audit are also not implemented. **No setting or bean for those capabilities is exposed.**
|
||||
|
||||
`app-bootstrap`이 그중 넷에 대해 이 leaf의 타입으로 bean을 만든다(`142-...` §8.4f).
|
||||
|
||||
| README가 "없다"고 한 것 | 실제 bean | 만드는 곳 |
|
||||
|---|---|---|
|
||||
| audit | `StructuredAdminAuditAdapter`, `StructuredFileserverAuditAdapter` | `FileserverSecurityConfiguration:71`·`:77` |
|
||||
| readiness/health | `LocalStorageHealthAdapter` | `FileserverStorageConfiguration:179` |
|
||||
| background reconciliation/reaping | `LocalOrphanScanAdapter`, `LocalReconciliationContentProbe` | `FileserverStorageConfiguration:189`·`:203` |
|
||||
| quota | `LocalStorageUsageProbe` | `FileserverStorageConfiguration:196` |
|
||||
|
||||
**공정하게 볼 지점.** 코드 배치 자체는 앞뒤가 맞는다. 이것들은 R2 publication이 아니라 **HTTP Fileserver 플랫폼**(`app.fileserver-platform.*`, CLAUDE.md가 "owned by `app-bootstrap`"이라 적는 별개 능력)의 부품이고, `build.gradle`의 description도 이 leaf가 "the local filesystem content platform behind the HTTP Fileserver"를 함께 담는다고 밝힌다. main 78개 중 **67개가 `platform/**`**라는 사실이 그 비중을 보여 준다.
|
||||
|
||||
잘못된 것은 문단의 범위다. "No setting or bean for those capabilities is exposed"에는 한정어가 없고, 이 문단은 독자가 **이 모듈이 무엇을 제공하고 무엇을 제공하지 않는지** 확인하러 오는 자리다. 그 자리에서 "audit은 구현돼 있지 않다"를 읽은 사람은 감사 기록이 없다고 결론짓는데, 같은 저장소가 두 개의 audit adapter를 bean으로 만든다. **판정: P2.** 수정은 문단을 R2 publication 범위로 한정하고, 같은 leaf가 담는 플랫폼 부품이 별도 namespace로 조립된다는 사실을 그 자리에 적는 것이다.
|
||||
|
||||
(이 finding의 나머지 절반 — 그 bean들이 실제로 무엇을 보장하는가 — 은 `platform/**`을 읽는 sub-scope 05에서 다룬다.)
|
||||
|
||||
## 5. P3 — R1과 R2의 설정 취급이 비대칭이고, 검증된 쪽은 하나뿐이다
|
||||
|
||||
같은 leaf 안의 두 selector가 설정을 다르게 다룬다.
|
||||
|
||||
| | R2 `app.fileserver.*` | R1 `app.file-export.*` |
|
||||
|---|---|---|
|
||||
| 바인딩 타입 | `record` + **`ignoreUnknownFields = false`** | 가변 JavaBean, 기본값(**미지의 키 무시**) |
|
||||
| 루트 경로 | `requireAbsoluteNormalizedPath` — 이미 절대·정규화여야 함 | `Path.of(v).toAbsolutePath().normalize()` — 상대 경로 허용, CWD 기준 절대화 |
|
||||
| 기본 루트 | 없음(필수) | `./.data/fileserver`, `./.data/fileserver-legacy` |
|
||||
| 디렉터리 생성 | 하지 않음(attestation이 별도로 요구) | `Files.createDirectories(root)`로 **생성** |
|
||||
| 미지 키 test | `unknownConfigurationFieldIsRejectedInsteadOfSilentlyIgnored` | **없음** |
|
||||
|
||||
R2에서는 `strict-path-securty` 같은 오타가 컨텍스트를 실패시킨다. R1에서는 `app.file-export.maximum-rowz=10` 같은 오타가 조용히 무시되고, 설정했다고 믿는 상한이 적용되지 않은 채 기본값 1,000,000이 쓰인다. 두 selector가 같은 leaf의 같은 성격 설정인데 한쪽만 fail-closed다. **P3** — R1은 문서상 "compatibility only"이므로 우선순위를 낮춘다.
|
||||
|
||||
## 6. P3 — 문서가 지목한 기본값 위치와 test 목록이 실제와 다르다
|
||||
|
||||
- README는 R2 selector가 "`app-bootstrap/application.yml`에서 `false`로 기본값을 갖는다"고 적는다. 그 파일에 `app.fileserver.enabled`도 `app.file-export.enabled`도 **없다**(`142-...` §8.4e; `app.fileserver`로 걸리는 두 줄은 주석이다). 실효 기본값은 "속성 부재 → `@ConditionalOnProperty` 미매치 → bean 없음"이고 동작은 옳지만, 문서가 가리킨 자리에는 그 키가 없다.
|
||||
- README의 Tests 목록 첫 항목 `FilePublicationContractTest`는 이 leaf가 아니라 `application-core`에 있다.
|
||||
|
||||
## 7. Confirmed — 적재 경로는 auto-configuration이 아니라 명시적 component scan이다
|
||||
|
||||
이 leaf에는 `META-INF/spring/…AutoConfiguration.imports`가 **없다**(`142-...` §8.1). `FileExportConfig`/`FileserverR2Config`를 leaf 밖에서 이름으로 참조하는 production 코드도 없고, 유일한 외부 참조는 app-bootstrap의 test(`OptionalAdapterBeanGatingTest`)다.
|
||||
|
||||
실제 적재는 `CaSkeletonApplication`의 명시적 `@ComponentScan`이 `dev.caskeleton.adapter.outbound.fileserver`를 목록에 올려서 이루어진다(`:75`). 즉 CLAUDE.md의 "never activates unexpectedly when merely present on the classpath"는 **classpath 존재만으로 bean이 생기지 않는다**는 뜻으로는 정확하지만, 기전은 import filter가 아니라 "@Configuration은 스캔되고 bean 생성만 `@ConditionalOnProperty`로 막힌다"이다. fail-closed는 성립한다 — 기록해 두는 이유는 mongo leaf의 4중 opt-in(§sub-scope 01, 06번 문서)과 기전이 다르기 때문이다.
|
||||
|
||||
## 8. Negative-space probes — sub-scope 01
|
||||
|
||||
- **8.1 reachability**: auto-configuration 등록 metadata 0, 적재는 명시적 component scan(§7). 세 bean 모두 `@ConditionalOnProperty` 게이트.
|
||||
- **8.2 조건부 형제**: R1 vs R2의 설정 엄격도·경로 규칙·디렉터리 생성·test 커버리지 비대칭(§5).
|
||||
- **8.3 중복 mechanism**: `rejectAmbiguous` 호출 3곳은 중복이 아니라 **각 진입점의 첫 줄**이라는 배치다 — `Binder`로 환경을 직접 읽으므로 bean 순서에 무관하고, app-bootstrap의 `FileserverStartupValidator`는 R1/R2 selector가 아니라 플랫폼 저장소 probe 결과를 검증하는 별개 장치다(중복 아님).
|
||||
- **8.4 문서/개수 drift**: §4(가장 무거움) · §6(기본값 위치, test 목록).
|
||||
|
||||
## 9. Sub-scope 01 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| **P2** | README:105 "No setting or bean for those capabilities is exposed"가 audit·health·reconciliation/reaping·quota 넷에 대해 사실과 다르다 — 모두 `app-bootstrap`이 이 leaf의 타입으로 bean을 만든다 | 이 문단을 근거로 능력 유무를 판단하는 독자 |
|
||||
| **P3** | `FileExportSettings`에 `ignoreUnknownFields=false`가 없어 `app.file-export.*` 오타가 조용히 무시된다(R2는 거부하고 test도 있다) | R1을 켠 배포의 설정 오타 |
|
||||
| **P3** | R1 루트는 상대 경로를 허용해 CWD 기준으로 절대화하고 디렉터리를 생성하는데, R2는 이미 절대·정규화된 경로만 받는다 — 같은 leaf의 두 selector가 다른 규칙 | R1 배포 |
|
||||
| **P3** | README가 지목한 selector 기본값 위치(`app-bootstrap/application.yml`)에 해당 키가 없고, Tests 목록의 `FilePublicationContractTest`는 `application-core` 소속이다 | 문서 |
|
||||
|
||||
## 10. Sub-scope 01 완료 조건
|
||||
|
||||
- denominator 13 / 13 FULL_READ (`142-...` OWNED FILES)
|
||||
- §8.1(적재 경로)·§8.2(R1/R2 형제)·§8.3(중복 아님 확인)·§8.4(문서 drift) 네 종 probe 수행
|
||||
- §4는 app-bootstrap의 bean 생성 지점을 직접 확인해 판정했고, 그 bean들이 무엇을 보장하는지는 sub-scope 05로 이월
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 11. Sub-scope 02 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **6 / 6 FULL_READ**
|
||||
> 범위: `LocalPersistentControlPlane` 1,470 + `FileserverControlRecordCodec` 855 + `LocalPersistentRecoveryVerifier` 210 (main 3, 2,535 LOC) + 전용 test 3 (3,240 LOC)
|
||||
> 역할: R2의 **강제된(forced) 제어 평면** — 세 종류의 canonical 제어 레코드를 저장·검증하고, 상태 전이를 인접 행렬로 강제하며, 협력 프로세스를 JVM+OS 락으로 직렬화한다
|
||||
|
||||
manifest와 probe: `evidence/raw/143-fileserver-control-plane-probes.txt`.
|
||||
|
||||
test/main 비율이 **1.28**이다. 이 sub-scope에서 찾은 결함은 없고, 아래는 왜 없는지에 대한 기록이다.
|
||||
|
||||
## 12. Confirmed — codec이 "canonical"을 왕복으로 강제한다
|
||||
|
||||
`FileserverControlRecordCodec`은 세 레코드와 receipt snapshot에 대해 **decode 직후 재encode해 바이트를 비교**한다(`requireCanonical(bytes, encodeOperation(record))`). 그래서 "파싱은 되지만 우리가 쓰지 않았을 형태"가 전부 거부된다 — 공백, 필드 재배열, `A` 같은 이스케이프, `-0`/선행 0 같은 숫자 표기, 후행 콘텐츠. 파서 자체도 좁다.
|
||||
|
||||
- 필드 집합을 **정확히 일치**시킨다(`values.keySet().equals(allowedFields)`) — 누락도 미지 필드도 거부.
|
||||
- 중복 키를 거부한다(`putIfAbsent`).
|
||||
- UTF-8 디코딩이 `REPORT` 모드라 malformed 바이트가 대체문자로 조용히 바뀌지 않는다.
|
||||
- `\b \f \n \r \t` 이스케이프를 **문법 수준에서 거부**한다("control characters are forbidden") — 제어문자가 이스케이프로 밀입되는 경로를 닫는다.
|
||||
- 짝 없는 서로게이트를 거부한다(`requireWellFormedUnicode`).
|
||||
- `Instant.parse` 후 `result.toString().equals(value)`로 **canonical UTC 표기**만 받는다.
|
||||
- receipt snapshot은 `rsv1.` 접두사 + unpadded base64url이고, 디코딩 후 **재인코딩 문자열 비교**로 alias(후행 비트가 0이 아닌 변형)를 거부한다.
|
||||
|
||||
test가 그 하나하나를 이름으로 고정한다 — `canonicalDecoderRejectsWhitespaceReorderingEscapesNumbersUtf8AndTrailingContent`, `receiptSnapshotRejectsBase64urlAliasWithNonZeroTrailingBits`, `canonicalCodecRoundTripsSupplementaryUnicodeInOpaqueText`, `formulaMitigationCountCannotExceedCellsAndUsesOverflowSafeBounds`.
|
||||
|
||||
마지막 것은 코드에서도 확인된다. `requireFormulaCountWithinCells`가 `rowCount * columnCount` 곱을 하기 전에 `rowCount <= Long.MAX_VALUE / columnCount`를 먼저 본다 — 오버플로가 상한 검사를 무력화하는 경로를 닫는다.
|
||||
|
||||
## 13. Confirmed — 상태 전이가 인접 행렬이고 terminal이 진짜 terminal이다
|
||||
|
||||
`validateOperationTransition`이 여섯 가지를 순서대로 강제한다: requestFingerprint 불변 → 불변 identity 10개 필드 불변 → `stateRevision` 감소 금지 → 동일 revision 다른 내용 금지 → **정확히 +1** 증가 → 인접 전이 행렬. 행렬은 README가 적은 사슬과 일치하고, 모든 비terminal 상태에서 `QUARANTINED`로만 이탈할 수 있으며 `PUBLISHED`·`QUARANTINED`는 후속 전이가 없다(`case PUBLISHED, QUARANTINED -> false`).
|
||||
|
||||
봉인 이후 사실은 얼어붙는다 — `requireSealedFactsUnchanged`가 byteSize·rowCount·columnCount·sha256·formulaMitigatedCount·sealedAt을 고정하고, `MANIFEST_PUBLISHED` 이후에는 `manifestDigest`, `REFERENCE_PUBLISHED` 이후에는 `referenceDigest`도 고정된다.
|
||||
|
||||
`current.equals(candidate)`는 전이가 아니라 **복구(repair)**로 취급된다 — 부모 디렉터리를 다시 force하고 정확 read-back만 수행한다. 크래시 후 같은 레코드를 다시 쓰는 재시도가 conflict가 되지 않게 하는 처리이고, `parentForcedCallbackFailureIsRepairedByIdenticalOperationRetry`가 이를 고정한다.
|
||||
|
||||
## 14. Confirmed — 두 개의 락 형태가 각자의 쓰기 원시연산에 맞춰져 있다
|
||||
|
||||
한 클래스 안에 락이 두 종류다. 얼핏 비대칭으로 보이지만 각자의 커밋 방식이 다르다.
|
||||
|
||||
| | operation 레코드 | manifest / reference 레코드 |
|
||||
|---|---|---|
|
||||
| 커밋 원시연산 | `Files.move(ATOMIC_MOVE, REPLACE_EXISTING)` — **배타적이지 않음** | `Files.createLink` — 이미 있으면 `FileAlreadyExistsException`, **OS 수준 배타** |
|
||||
| JVM 락 | `OPERATION_LOCK_STRIPES`, 키에 **root 범위 포함**(`operationLockRootKey + "\0" + token`) | `IMMUTABLE_LOCK_STRIPES`, 키는 `"manifest:"+fileId` — root 범위 **없음** |
|
||||
| OS 락 | `FileChannel.lock()` (`.lock` 파일, 0600, 소유자·FileStore 검증) | 없음 |
|
||||
|
||||
즉 배타성이 필요한 쪽(replace)에는 OS 락을 두고, 원시연산 자체가 배타적인 쪽(create-link)에는 JVM 스트라이프만 둔 것이다. 후자의 root 미포함은 **과잉 직렬화** 방향이라(다른 root의 같은 fileId가 같은 스트라이프를 공유) 배타 누락으로는 이어지지 않고, `fileId`는 `SecureRandom` 16바이트라 실질 충돌도 없다. 결함이 아니라 설계로 기록한다.
|
||||
|
||||
collision 경로도 닫혀 있다 — `createLink`가 충돌하면 임시 파일을 정확히 지우고, 기존 레코드를 읽어 identity와 내용 동등성을 확인한 뒤 같으면 repair, 다르면 `CONFLICT`다. `concurrentCrossInstanceImmutableCollisionIsNeverClassifiedAsStorage`가 그 분류를 고정한다.
|
||||
|
||||
## 15. Confirmed — poisoning은 root 범위이고, 읽기를 막지 않는 것이 의도다
|
||||
|
||||
OS 언락을 **증명하지 못한** 경우에만 `POISONED_OPERATION_LOCK_ROOTS`에 root 키가 들어간다. release와 close 중 **하나라도** 성공하면 poison하지 않는다(`releaseProvedUnlock || closeProvedUnlock`).
|
||||
|
||||
`requireOperationLockRootHealthy()`는 6곳에서 호출되는데 전부 쓰기 경로(`storeOperation`·`acquireOperationLock`×3·`storeManifest`·`storeReference`)이고, `findOperation`/`findStoredOperation`/`findManifest`/`findReference` 어디에도 없다. 처음에는 누락으로 보였으나 test 이름이 그것이 의도임을 못박는다 — **`poisonedRootBlocksEveryWriteIncludingHeldLockFastPathButAllowsReads`**. 이미 획득한 락의 fast path(`heldTokens.contains(...)`)조차 poison에 걸린다는 것까지 이름에 들어 있다.
|
||||
|
||||
poison을 해제하는 경로는 없다(집합은 static이고 제거 호출이 없다). 프로세스 수명 동안 그 root는 쓰기 불가로 남는다 — "OS 락이 풀렸는지 증명할 수 없다"에 대한 fail-closed 응답이고, `operationLockClosePoisonsOnlyTheAttestedRootWhenUnlockCannotBeProven`이 범위가 해당 root에 한정됨을 확인한다. 두 개의 형제 test(`releaseFailureWithSuccessfulChannelCloseReportsStorageWithoutPoisoning`, `successfulReleaseWithChannelCloseFailureReportsStorageWithoutPoisoning`)가 "증명 하나면 충분" 규칙을 양쪽에서 고정한다.
|
||||
|
||||
## 16. Confirmed — 파일시스템 접근이 전부 `SecureDirectoryStream` 상대 연산이다
|
||||
|
||||
`SystemSecureRecordOperations`의 다섯 연산이 모두 `openSecure(topDirectory)` → `newDirectoryStream(shard, NOFOLLOW_LINKS)`를 거친다. `SecureDirectoryStream`이 아니면 스트림을 닫고 `IOException`을 던진다 — TOCTOU 우회 경로를 열어 두지 않는다.
|
||||
|
||||
세부가 촘촘하다.
|
||||
|
||||
- 읽기는 `maximumBytes + 1` 버퍼로 읽어 **한 바이트 초과분**을 감지하고, 읽기 전후 `fileKey`와 `size`를 비교해 "읽는 중 신원이 바뀐" 경우를 integrity 실패로 만든다.
|
||||
- 임시 파일 생성은 `CREATE_NEW` + `NOFOLLOW_LINKS` + 0600이고, 쓴 뒤 `force(true)`, 그 다음 크기와 fileKey를 생성 시점과 대조한다.
|
||||
- 커밋 전후로 `requireCreatedTemporaryIdentity`가 **정확히 그 fileKey**만 지운다 — 다른 프로세스가 같은 이름으로 바꿔 둔 파일을 지우지 않는다. `cleanupPreservesAReplacementWhoseNoFollowFileKeyDiffersFromCreatedTemp`가 그 경계를 고정한다.
|
||||
- shard 디렉터리는 매번 소유자·0700 권한·FileStore 동일성을 재검증하고, 좌표는 `[0-9a-f]{2}`와 세 허용 디렉터리로 제한된다.
|
||||
- 모든 쓰기/읽기 단계 사이에 `verifyAttestedIdentity()`가 끼어 있다 — root가 도중에 바뀌면 즉시 멈춘다.
|
||||
|
||||
`forceDirectory`는 디렉터리를 `READ`로 열어 `force(true)`한다. README가 `FILE_AND_DIRECTORY_SYNC`를 "attested local file/directory force boundary only"로 한정하는 것과 일치한다.
|
||||
|
||||
## 17. Confirmed — 세 타입 모두 leaf 밖으로 새지 않는다
|
||||
|
||||
`LocalPersistentControlPlane`·`FileserverControlRecordCodec`·`LocalPersistentRecoveryVerifier`는 전부 package-private이고, 저장소에서 이 leaf 밖 참조는 **0**이다(`143-...` §8.1). production 생성 지점은 `FileserverR2Config:51` 하나다. CLAUDE.md의 "Leaking filesystem, stream, framework, or provider types across `FilePublicationPort`" 금지가 타입 가시성으로 뒷받침된다.
|
||||
|
||||
R1 하위호환도 좁게 열려 있다 — `decodeStoredOperation`은 R2 codec을 먼저 시도하고, 실패하면 R1 journal codec으로 넘어가되 **terminal `PUBLISHED`만** 허용한다. CLAUDE.md의 "schema v1 is strict read-only compatibility"와 일치하고, `typedOperationLookupDispatchesCanonicalR2AndTerminalR1FromTheSameHashedPath`와 `typedOperationLookupRejectsMalformedNonCanonicalNonTerminalAndWrongIdentityR1`이 양쪽을 고정한다.
|
||||
|
||||
## 18. Negative-space probes — sub-scope 02
|
||||
|
||||
- **8.1 reachability**: 세 타입 모두 package-private, leaf 밖 참조 0, production 진입점 1개(§17).
|
||||
- **8.2 조건부 형제**: 한 클래스 안의 두 락 형태(§14) — 커밋 원시연산 차이로 설명됨. `requireOperationLockRootHealthy`의 쓰기/읽기 비대칭(§15) — test 이름이 의도임을 명시.
|
||||
- **8.3 중복 mechanism**: poison 집합에 해제 경로 없음(§15, 의도된 fail-closed). R1/R2 두 codec 경로는 dispatch 순서와 terminal 제약으로 분리(§17).
|
||||
- **8.4 문서/동작 대조**: README의 상태 사슬(`WRITING → SEALED → DATA_PUBLISHED → MANIFEST_PUBLISHED → REFERENCE_PUBLISHED → PUBLISHED`)과 `isAllowedAdjacentTransition`의 행렬이 일치. `FILE_AND_DIRECTORY_SYNC`의 한정 서술과 `forceDirectory` 구현이 일치.
|
||||
|
||||
## 19. Sub-scope 02 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| — | **없음.** 후보로 본 세 가지(immutable 락의 root 미포함, poison 해제 경로 부재, 읽기 경로의 health 게이트 부재)는 각각 커밋 원시연산·fail-closed 설계·명시적 test로 의도임이 확인됐다 | — |
|
||||
|
||||
## 20. Sub-scope 02 완료 조건
|
||||
|
||||
- denominator 6 / 6 FULL_READ (`143-...` OWNED FILES) — main 2,535 LOC 전수 판독
|
||||
- §8.1~§8.4 네 종 probe 수행, 후보 finding 3건을 각각 코드·test로 추적해 결함 아님으로 판정
|
||||
- 실행 probe 불필요 — 세 후보 모두 소스와 test 이름으로 정적 결정 가능
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 21. Sub-scope 03 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **26 / 26 FULL_READ**
|
||||
> 범위: publication main 19 (provider·adapter·journal·attestor·binding·record, 약 4,400 LOC) + 전용 test 7
|
||||
> 역할: 요청 → 스테이지 → 데이터 → manifest → reference → terminal 사슬을 **재개 가능한 상태 기계**로 만들고, 루트를 startup에 증명하며, R1 아티팩트를 읽기 전용으로만 복원한다
|
||||
|
||||
manifest와 probe: `evidence/raw/144-fileserver-publication-probes.txt`.
|
||||
|
||||
## 22. Confirmed — 19개 production 타입 중 leaf를 벗어나는 것이 하나도 없다
|
||||
|
||||
전수 검색 결과 `LocalPersistentPublicationProvider`·`LocalFilePublicationAdapter`·`RoutingFilePublicationAdapter`·`LocalPersistentRootAttestor`·`FileserverBindingCompiler`·`DurablePublicationRecord`·`PrivateFileManifest`·`PublishedReferenceRecord`·`LocalPublicationJournal` 어느 것도 이 leaf 밖에서 참조되지 않는다(`144-...` §8.1, exit=1). 전부 package-private이고, 애플리케이션이 보는 것은 `FilePublicationPort`와 그 값 타입뿐이다.
|
||||
|
||||
CLAUDE.md의 금지 조항 — "Leaking filesystem, stream, framework, or provider types across `FilePublicationPort`" — 이 문서가 아니라 **타입 가시성**으로 뒷받침된다. `FilePublicationProvider`(adapter 내부 provider 인터페이스)도 package-private이라 provider 개념 자체가 포트를 건너지 않는다.
|
||||
|
||||
## 23. Confirmed — 복구가 "어디서 끊겼든 그 자리에서" 재개하는 루프다
|
||||
|
||||
`recoverR2`는 저장된 상태에 따라 분기하는 `while(true)` 루프다. 각 단계가 증거를 다시 검증하고, 성공하면 다음 상태로 전이하며, 루프가 `PUBLISHED`에 도달하면 receipt를 복원한다.
|
||||
|
||||
| 저장 상태 | 재개 동작 |
|
||||
|---|---|
|
||||
| `WRITING` | **격리**(`UNSEALED_WRITING`) — 봉인 전에 끊긴 것은 재개하지 않는다 |
|
||||
| `SEALED` | stage/data를 조사해 둘 다 없으면 integrity 실패, data가 있으면 디렉터리만 force, 없으면 stage를 hard-link로 publish |
|
||||
| `DATA_PUBLISHED` | manifest를 찾거나 생성해 저장 |
|
||||
| `MANIFEST_PUBLISHED` | reference를 찾거나 생성해 저장 |
|
||||
| `REFERENCE_PUBLISHED` | 모든 증거를 재대조하고 receipt snapshot을 넣어 terminal 기록 |
|
||||
| `PUBLISHED` | 전 필드 재검증 후 **저장된 receipt를 그대로** 반환 |
|
||||
| `QUARANTINED` | indeterminate |
|
||||
|
||||
핵심은 **producer를 다시 부르지 않는다**는 점이다. `publishNew`만 `streamRequest`를 호출하고, 그 이후의 모든 재개 경로는 이미 봉인된 바이트에서 진행한다. README의 "resumes from verified sealed bytes without replaying the producer"가 코드 구조로 성립한다.
|
||||
|
||||
`resumeData`의 stage/data 이중 조사가 특히 촘촘하다. 둘 다 존재하면 `fileKey`가 같은지 확인해 — 즉 **같은 exclusive hard-link인지** — 확인하고, 다르면 `RecoveryIntegrityException`이다. hard-link 발행이 성공한 뒤 stage 삭제 전에 죽은 경우와, 전혀 다른 파일이 그 자리에 있는 경우를 구분한다.
|
||||
|
||||
실패 분류도 갈라져 있다. `RecoveryIntegrityException`과 payload의 `INTEGRITY`/`CAPACITY`는 **격리 후** indeterminate가 되고, 그 밖의 payload 실패는 격리 없이 indeterminate다. `quarantineAndIndeterminate`는 이미 `PUBLISHED`/`QUARANTINED`인 기록은 건드리지 않는다.
|
||||
|
||||
## 24. Confirmed — 루트 증명이 "설정을 믿지 않는" 형태다
|
||||
|
||||
`LocalPersistentRootAttestor.attestChecked`가 순서대로 확인한다: 절대·정규화 경로 → 심볼릭 루트/조상 거부 → `toRealPath()`가 설정 경로와 **정확히 일치** → 소유자 → 권한 상한 → FileStore 이름·타입 → mount sentinel의 SHA-256 → 내부 디렉터리 8개 생성/검증 → `SecureDirectoryStream` 가용성 → **실제 capability probe**.
|
||||
|
||||
마지막이 특징적이다. `runCapabilityProbe`는 실제로 파일을 만들고(`CREATE_NEW`+`NOFOLLOW_LINKS`+0600), 쓰고, `force`하고, **hard-link를 만들고**, 디렉터리를 force한 다음, 원본과 링크의 `fileKey`가 같은지 확인한다. 즉 "이 파일시스템이 배타적 hard-link 발행과 file/directory force를 실제로 할 수 있는가"를 startup에 시험한다 — 첫 publication에서 발견하지 않는다.
|
||||
|
||||
내부 디렉터리 생성에는 롤백이 붙어 있다. `rollbackCreatedDirectory`는 삭제 전에 부모 identity와 디렉터리 자신의 `fileKey`를 대조하고, 하나라도 바뀌었으면 **삭제를 거부**한다("refusing rollback because internal directory identity changed"). 실패 정리가 남의 디렉터리를 지우지 않는다.
|
||||
|
||||
`verifyIdentity`는 attest가 끝난 뒤에도 control plane의 거의 모든 단계에서 재호출된다(§16). 증명은 시점이 아니라 불변식이다.
|
||||
|
||||
## 25. Confirmed — canonical digest가 길이 프레이밍이고, route token 충돌을 명시적으로 검사한다
|
||||
|
||||
`FilePublicationCanonicalDigests.digestOrderedValues`는 값 개수를 먼저 넣고, 값마다 **길이(4바이트) + 엄격 UTF-8 바이트**를 넣는다. 구분자를 쓰지 않으므로 값 안에 어떤 문자가 있어도 경계가 흐려지지 않는다. `FilePublishRequestFingerprint`도 같은 방식이다.
|
||||
|
||||
`routeToken`은 정책 다이제스트의 앞 31자에 `r`을 붙인 것이라 **잘린 값**이다. 그래서 `FileserverBindingCompiler.deriveUniqueRouteTokens`가 컴파일 시점에 토큰 충돌을 검사하고, 충돌하면 두 destination 이름을 모두 담아 거부한다. 잘림이 만들 수 있는 유일한 문제를 그 자리에서 닫는다. 컴파일 후에도 `compiled.forEach`로 각 destination의 토큰이 레지스트리와 같은지 다시 확인한다.
|
||||
|
||||
`CompiledFileDestination`의 compact 생성자는 넘겨받은 `effectivePolicyDigest`를 **다시 계산해 대조**하고, `routeToken`이 그 다이제스트에서 유도됐는지, `formatPolicyDigest`가 정본과 같은지도 확인한다. 값이 아니라 관계를 검증한다.
|
||||
|
||||
## 26. Confirmed — R1과 R2가 같은 일을 다른 엄격도로 하고, 그 사실이 선언돼 있다
|
||||
|
||||
두 계층이 나란히 있어 비교가 가능하다(`144-...` §8.2).
|
||||
|
||||
| | R2 `LocalPersistentControlPlane` | R1 `LocalPublicationJournal` |
|
||||
|---|---|---|
|
||||
| 파일시스템 접근 | `SecureDirectoryStream` 상대 연산 (**17회**) | `Files.exists`/`isRegularFile`/`readAllBytes` (**0회**) |
|
||||
| 읽기 디코딩 | 엄격 UTF-8 `REPORT` + canonical 바이트 재대조 | `new String(bytes, UTF_8)` — malformed는 U+FFFD로 대체 |
|
||||
| 제어문자 이스케이프 | `\b \f \n \r \t`를 **문법에서 거부** | 다섯 개를 모두 **수용해 디코드** |
|
||||
| POSIX 권한 | 정확히 0700이 아니면 실패 | `UnsupportedOperationException`을 삼키고 진행 |
|
||||
| 임시 파일명 | `SecureRandom` 16바이트 hex | `UUID.randomUUID()` |
|
||||
| 락 | `ReentrantLock` 스트라이프 + OS `FileLock` + poison 래치 | `Semaphore` 스트라이프 + OS `FileLock` |
|
||||
|
||||
이것은 결함이 아니라 선언된 상태다 — CLAUDE.md는 R1을 "compatibility only"로, README는 "must not be used as R2 durability or cluster-safety evidence"로 못박는다.
|
||||
|
||||
**중요한 것은 두 계층이 만나는 한 지점이다.** R2 control plane이 같은 해시 경로에서 R1 저널을 읽을 때(`decodeStoredOperation`) 쓰는 것은 관대한 `decode`가 아니라 **엄격한 `decodeCanonical`**이고, 그 위에 `state == PUBLISHED`까지 요구한다(`LocalPersistentControlPlane:634-638`). 즉 R1의 느슨함이 R2 경로로 흘러들지 않는다. 이 한 줄이 위 표 전체를 안전하게 만든다.
|
||||
|
||||
R1 복원이 등급을 올리지 않는 것도 코드로 확인된다 — `restoreR1`은 receipt에 `DurabilityGuarantee.PROCESS_LOCAL_SYNC`를 그대로 넣고, 참조도 R2의 `fsr1.…` 형식이 아니라 R1의 `filepub:<destination>:<token>` 형식을 쓴다. README의 "never writes schema v1, creates an R2 manifest/reference for that artifact, or promotes its durability guarantee"와 일치한다.
|
||||
|
||||
## 27. Negative-space probes — sub-scope 03
|
||||
|
||||
- **8.1 reachability**: 19개 production 타입 전부 package-private, leaf 밖 참조 0(§22). production 진입점은 `FileserverR2Config`(R2)와 `FileExportConfig`(R1) 둘.
|
||||
- **8.2 조건부 형제**: R1/R2의 6개 축 엄격도 대조(§26), 그리고 두 계층의 접점이 엄격 경로를 쓰는지 확인.
|
||||
- **8.3 중복 mechanism**: 참조 형식 둘(`filepub:` / `fsr1.`)과 락 구현 둘 — 각각 R1/R2 경계에 대응하고 서로 침범하지 않음. `LocalPersistentPublicationProvider`와 `LocalFilePublicationAdapter`가 같은 `filepub:` 형식을 쓰는 것은 R1 receipt 호환을 위한 의도된 공유.
|
||||
- **8.4 문서/동작 대조**: README의 여섯 단계 사슬 ↔ `recoverR2` 분기, "producer를 재생하지 않는다" ↔ `publishNew`만 `streamRequest` 호출, R1 등급 비승격 ↔ `PROCESS_LOCAL_SYNC` 고정, route token 잘림 ↔ 컴파일 시 충돌 검사.
|
||||
|
||||
## 28. Sub-scope 03 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| — | **없음.** R1/R2 엄격도 격차는 문서가 선언한 상태이고, 두 계층이 만나는 유일한 지점(`decodeStoredOperation`)은 엄격 경로를 쓴다 | — |
|
||||
|
||||
## 29. Sub-scope 03 완료 조건
|
||||
|
||||
- denominator 26 / 26 FULL_READ (`144-...` OWNED FILES) — main 약 4,400 LOC 전수 판독
|
||||
- §8.1~§8.4 네 종 probe 수행, R1/R2 접점을 코드로 추적해 느슨함이 전파되지 않음을 확인
|
||||
- 실행 probe 불필요 — 판정 지점이 모두 정적으로 결정 가능
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 30. Sub-scope 04 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **30 / 30 FULL_READ**
|
||||
> 범위: `platform/local` IO 원시연산 9 + gateway 7 + store·publisher 6 (main 22) + 전용 test 8
|
||||
> 역할: HTTP Fileserver 플랫폼의 **로컬 콘텐츠 저장소** — 스테이징·추가·발행·읽기·삭제를 경로가 아니라 **디렉터리 서술자 상대 연산**으로 수행한다
|
||||
|
||||
manifest와 probe: `evidence/raw/145-fileserver-local-io-probes.txt`.
|
||||
|
||||
## 31. Confirmed — TOCTOU를 "검사를 더 하는" 방식으로 풀지 않는다
|
||||
|
||||
`SecureDirectoryWalk`의 클래스 javadoc이 이 sub-scope의 설계 명제를 그대로 적는다.
|
||||
|
||||
> The pathname approach cannot be made safe by adding checks. Proving that no component of `${root}/content/ab/cd` is a symbolic link and then calling `FileChannel.open` on that string re-resolves every component from scratch… **More checks only narrow the window; they never close it.**
|
||||
|
||||
그래서 각 단계가 **이전 디렉터리의 서술자를 기준으로** 다음 디렉터리를 `NOFOLLOW_LINKS`로 연다. 열어 둔 서술자는 나중에 그 디렉터리가 교체돼도 영향받지 않는다 — "an attacker who swaps a component afterwards has swapped something nothing is looking at any more".
|
||||
|
||||
세부도 논리적이다.
|
||||
|
||||
- **fallback을 두지 않는다.** `SecureDirectoryStream`이 없으면 startup capability probe가 실패로 처리한다 — "a silent fall back to pathnames would restore exactly the window this class exists to close".
|
||||
- **거부와 장애를 구분한다.** `NOFOLLOW_LINKS` 거부는 플랫폼이 일반 `FileSystemException`으로 보고하므로, 실패 시 같은 부모 서술자로 그 컴포넌트를 다시 읽어 심볼릭 링크인지 확인하고 `SymbolicComponentException`(영구 거부)과 스토리지 장애(재시도 가능한 503)를 나눈다.
|
||||
- **`FileChannel`이 아니면 거부한다.** `requireFileChannel`은 positional write·`truncate`·`force`·`transferTo`가 전부 `FileChannel` 연산이고 "cannot be emulated"라고 적으며 거부한다.
|
||||
- **경로 해석은 한 곳뿐.** `DefaultPhysicalPathResolver`가 유일하게 식별자를 경로로 바꾸고, 세 겹으로 막는다 — 서버 생성 형태 정규식(`[a-z0-9]{2}/[a-z0-9]{2}/[a-z0-9_-]{12,190}`), 정규화, 영역 루트 `startsWith` 재확인. 클라이언트 파일명은 어느 단계에도 들어오지 않는다(`resolutionNeverDependsOnAClientFilename`가 고정).
|
||||
|
||||
`LocalAppendEngine`의 롤백 설계도 촘촘하다. 실패하면 누산 다이제스트를 **먼저 버리고**(이미 버려질 바이트를 흡수했으므로), `truncate` → `force` → `size` 재확인으로 물리 길이가 append 이전으로 돌아왔음을 **증명한 뒤에야** 원래 실패를 그대로 던진다. 증명하지 못하면 `AmbiguousCompletionException`으로 격상해 reconciliation에 넘긴다. 선언된 content length는 사후 검사가 아니라 **읽기 상한**으로 쓰이고(`buffer.limit(min(capacity, contentLength - appended))`), 잉여는 1바이트 probe read로 감지해 버린다.
|
||||
|
||||
`LocalBlockingContentStore`는 20곳 전부 `channels.*`(서술자 상대)를 쓰고 `Files.*`를 한 번도 부르지 않는다(`145-...` §8.2).
|
||||
|
||||
## 32. P3 — 발행 rename만 경로 기반이고, 그것을 지키는 것은 이 모듈이 "근사에 불과하다"고 적은 사전검사다
|
||||
|
||||
`platform/local`에 남은 `java.nio.file.Files.*` 호출을 전수 조사했다(`145-...` §8.2). 대부분은 정당하다 — `SecureDirectoryWalk.openRoot`(문서가 "the one unavoidable pathname resolution"이라 적는 루트 열기), `LocalStorageCapabilityProbe`(startup probe, 격리된 probe 영역), `LocalOrphanScanAdapter`·`LocalStorageHealthAdapter`·`LocalStorageUsageProbe`(sub-scope 05).
|
||||
|
||||
문제는 **쓰기 경로에 남은 다섯 호출**이다.
|
||||
|
||||
```
|
||||
AtomicMoveContentPublisher:53 Files.move(staging, target, ATOMIC_MOVE) ← 발행 rename
|
||||
AtomicMoveContentPublisher:113 Files.exists(staging, NOFOLLOW_LINKS) ← 실패 분류
|
||||
AtomicMoveContentPublisher:114 Files.exists(target, NOFOLLOW_LINKS) ← 실패 분류
|
||||
ContentPublishVerification:53 Files.size(target) ← 발행 크기
|
||||
MetadataPointerContentPublisher:107 Files.deleteIfExists(staging)
|
||||
```
|
||||
|
||||
그리고 `AtomicMoveContentPublisher:50`이 그 rename 직전에 부르는 것은 `channels.requireNoSymlinkBetween(root, target.getParent())` — 즉 **경로 기반 사전검사**다. 그 메서드의 javadoc이 스스로를 이렇게 설명한다.
|
||||
|
||||
> **Retained for the capability probe**, which still reasons about pathnames. Production access no longer relies on it: descending descriptor by descriptor with `NOFOLLOW_LINKS` refuses a symlinked component by construction, **which a precheck could only ever approximate.**
|
||||
|
||||
즉 "production은 더 이상 이것에 의존하지 않는다"고 적힌 메서드를, 콘텐츠를 **보이게 만드는 바로 그 단계**가 유일한 보호로 쓴다. `SecureDirectoryWalk`의 "More checks only narrow the window; they never close it"이 겨냥한 패턴 그 자체다.
|
||||
|
||||
같은 불일치가 파일 길이에서도 보인다. `LocalAppendEngine.currentLength`는 여덟 줄짜리 javadoc으로 왜 `Files.size`가 틀렸는지 설명하고 `channels.readAttributes(root, staging)`를 쓴다 — "an attacker who swaps the parent for a symlink gets this check to report the size of their own file". `ContentPublishVerification.sizeOf`는 같은 질문에 `Files.size(target)`으로 답한다.
|
||||
|
||||
**판정: P3.** 실제 악용에는 스토리지 루트 **안쪽** 쓰기 권한이 필요하고, 이 leaf가 그 루트의 소유자·권한을 증명하는 것은 R2 경로(`LocalPersistentRootAttestor`)뿐이며 플랫폼 저장소 루트의 증명은 `app-bootstrap`의 startup validator 몫이다. 그래서 도달성은 배포 형상에 달려 있다. 심각도를 P3로 두는 이유는 그것이고, 그럼에도 기록하는 이유는 **모듈 자신의 문서가 이 패턴을 명시적으로 불충분하다고 선언했다**는 점이다. 수정은 발행 rename을 `SecureDirectoryWalk.inParentOf`로 옮겨 부모 서술자 상대 `move`를 쓰고, `sizeOf`를 `channels.readAttributes`로 바꾸는 것이다.
|
||||
|
||||
## 33. Confirmed — 두 발행 전략이 probe 결과로 선택되고, 각자 다른 실패를 다르게 분류한다
|
||||
|
||||
`selectPublisher`는 설정이 아니라 **probe가 증명한 것**에서 전략을 고른다. `ATOMIC_MOVE_REQUIRED`는 원자적 이동을 증명하지 못하면 fail-closed, `ATOMIC_MOVE_PREFERRED`는 pointer 발행으로 강등된다. `ContentPublisherTest`가 양쪽을 고정한다(`requiredAtomicModeFailsClosedWhenTheProbeCouldNotProveIt`, `preferredModeDegradesToPointerPublishWhenAtomicMoveIsUnproven`).
|
||||
|
||||
두 전략 모두 **`REPLACE_EXISTING`을 쓰지 않는다** — 기존 대상은 조용한 덮어쓰기가 아니라 충돌이다. 그리고 결과를 증명할 수 없으면 성공도 실패도 아닌 `AmbiguousCompletionException`이다.
|
||||
|
||||
`AtomicMoveContentPublisher.forceDirectoryEntries`의 근거가 특히 정확하다 — 스테이징 파일을 force하는 것은 **내용**을 지속시킬 뿐 그것을 가리키는 **디렉터리 엔트리**에 대해서는 아무 말도 하지 않는다. 크래시 후 객체가 완전히 쓰였으면서 동시에 두 디렉터리 어디에도 없고 메타데이터는 READY라고 말하는 상태가 가능하다. rename은 두 디렉터리를 바꾸므로 둘 다 sync하고, sync 실패는 무시가 아니라 ambiguous로 격상한다.
|
||||
|
||||
`MetadataPointerContentPublisher`는 복사 후 **디스크에서 다시 다이제스트를 계산해** 스테이지 다이제스트와 비교한다. `ContentPublishVerification`의 javadoc이 그 원칙을 적는다 — "recomputed from the bytes actually on disk rather than trusted from the streaming accumulator, so a publish can never advertise a hash the stored object does not have".
|
||||
|
||||
## 34. P3 — `TransferBufferPool.maxBorrowedBytes()`가 자기 회귀 test를 지목하는데 그 test가 읽지 않는다
|
||||
|
||||
`TransferBufferPool`은 대여 중 바이트의 최대치를 추적하고 javadoc에 이렇게 적는다.
|
||||
|
||||
> Peak simultaneously-borrowed bytes; **the bounded-memory regression asserts on this.**
|
||||
|
||||
`145-...` §8.4의 전수 검색에서 `maxBorrowedBytes`는 `TransferBufferPool.java` 세 줄에만 나타나고, `LocalAppendMemoryTest`에도 `LargeFileBoundedMemoryTest`에도 없다. 즉 회귀 test는 이 값을 읽지 않는다.
|
||||
|
||||
기능 자체는 옳게 동작한다 — `borrow`가 `bufferSize`만큼 증가시키고 `release`가 되돌리며, 최대치를 `accumulateAndGet(_, Math::max)`로 누적한다. 그리고 경계 자체(전송이 파일 크기에 비례해 메모리를 쓰지 않음)는 다른 방식으로 검증되고 있다. 문제는 javadoc이 존재하지 않는 결합을 서술한다는 것이고, 그 서술 때문에 이 계측이 지켜지고 있다고 읽힌다. **P3.**
|
||||
|
||||
## 35. Negative-space probes — sub-scope 04
|
||||
|
||||
- **8.1 reachability**: `platform/local`의 15개 타입이 public이고, leaf 밖에서는 `app-bootstrap`의 fileserver autoconfigure 5개 클래스가 참조한다. 나머지(`SecureDirectoryWalk`·`SafeFileChannelFactory`·`DefaultPhysicalPathResolver`·publisher 3종·`LocalUploadHandle` 등)는 package-private — `Path`가 SPI를 건너지 않는다는 주장이 가시성으로 성립.
|
||||
- **8.2 조건부 형제**: 파일 길이를 묻는 두 방식(§32), 서술자 상대 vs 경로 기반 쓰기(§32).
|
||||
- **8.3 중복 mechanism**: 두 발행 전략은 중복이 아니라 probe 결과로 배타 선택되고 `usesAtomicMove()`로 자기 성격을 보고한다(§33).
|
||||
- **8.4 문서/동작 대조**: `maxBorrowedBytes` javadoc의 회귀 test 결합 부재(§34). `LocalCopyContentGateway`의 "A copy is not a link" 근거와 실제 스테이징 경유 복사 구현 일치. `LocalZeroCopyDownloadGateway`의 짧은 전송 재시도와 부분 전송 보고 일치.
|
||||
|
||||
## 36. Sub-scope 04 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| **P3** | 발행 rename(`Files.move`)과 그 실패 분류(`Files.exists`), 발행 크기(`Files.size`)가 경로 기반이고, 유일한 보호는 이 모듈이 "a precheck could only ever approximate"라고 적은 `requireNoSymlinkBetween`이다 | 스토리지 루트 안쪽에 쓰기 권한을 가진 주체 — 루트 증명은 배포 형상에 달려 있다 |
|
||||
| **P3** | `TransferBufferPool.maxBorrowedBytes()`의 javadoc이 "the bounded-memory regression asserts on this"라고 적지만 어떤 test도 읽지 않는다 | 계측/문서 |
|
||||
|
||||
## 37. Sub-scope 04 완료 조건
|
||||
|
||||
- denominator 30 / 30 FULL_READ (`145-...` OWNED FILES)
|
||||
- §8.1~§8.4 네 종 probe 수행, `platform/local`의 `Files.*` 호출을 전수 조사해 정당한 것과 남은 것을 분리
|
||||
- 두 finding 모두 정적으로 결정 가능(호출 지점과 javadoc 대조)하여 실행 probe 불필요
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 38. Sub-scope 05 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **29 / 29 FULL_READ**
|
||||
> 범위: `platform/local` 실패분류·probe·health·orphan 10 + `platform/verification` 10 + `platform/security` 2 + `platform/audit` 2 (main 24) + 전용 test 5
|
||||
> 역할: 콘텐츠 **검증 사슬**, 역할 기반 인가, 감사 기록, 그리고 파일시스템 실패를 "일어났는가"로 분류하는 계층
|
||||
|
||||
manifest·정적 probe·실행 probe: `evidence/raw/146-fileserver-verification-security-audit-probes.txt`.
|
||||
|
||||
## 39. P2 확정 — §4의 README 주장이 여덟 개의 port 구현과 여덟 개의 bean 앞에서 성립하지 않는다
|
||||
|
||||
sub-scope 01(§4)에서 이월한 판정을 여기서 닫는다. README:103–105는 audit·readiness/health·reconciliation/reaping·quota가 "not implemented"이고 "**No setting or bean for those capabilities is exposed**"라고 적는다. 실제로는 이 sub-scope의 타입들이 `application-core` port를 구현하고, `app-bootstrap`이 그 전부를 bean으로 만든다(`146-...` §8.1).
|
||||
|
||||
| port | 구현 | bean 생성 |
|
||||
|---|---|---|
|
||||
| `AdminAuditPort` | `StructuredAdminAuditAdapter` | `FileserverSecurityConfiguration:71` |
|
||||
| `FileserverAuditPort` | `StructuredFileserverAuditAdapter` | `:77` |
|
||||
| `FileAccessPolicy` | `RoleBasedFileAccessPolicy` / `UnenforcedFileAccessPolicy` | `:51` / `:90` |
|
||||
| `StorageHealthPort` | `LocalStorageHealthAdapter` | `FileserverStorageConfiguration:179` |
|
||||
| `OrphanScanPort` | `LocalOrphanScanAdapter` | `:189` |
|
||||
| `StorageUsageProbe` | `LocalStorageUsageProbe` | `:196` |
|
||||
| `ReconciliationContentProbe` | `LocalReconciliationContentProbe` | `:203` |
|
||||
|
||||
스텁이 아니다. 감사 어댑터는 전용 로거 카테고리(`dev.caskeleton.fileserver.audit`)로 쓰고, 실패한 동작을 성공과 **같은 레벨로** 남긴다("a refused force-delete is the entry a reviewer most needs to find"). health 어댑터는 원자적 이동 가능 여부를 설정이 아니라 **probe가 증명한 사실**에서 보고한다. usage probe는 매 호출마다 `FileStore`를 다시 읽고, 읽을 수 없으면 0%도 100%도 아닌 **빈 답**을 낸다("a synthetic 0% would silently disable the high-water guard, and a synthetic 100% would take the capability down over a failed syscall").
|
||||
|
||||
즉 코드 쪽은 잘 만들어져 있고, 틀린 것은 README 한 문단이다. §4에서 적은 대로 이것들은 R2 publication이 아니라 HTTP Fileserver 플랫폼의 부품이지만, 그 문단에는 한정어가 없다. **P2 확정.**
|
||||
|
||||
## 40. P2 — scriptable 콘텐츠 탐지가 접두사 **시작**에만 고정돼 있어 BOM·NUL·주석으로 우회된다
|
||||
|
||||
`ScriptableContentPolicy`의 javadoc은 이 검사의 목적을 분명히 적는다.
|
||||
|
||||
> Guards content that a browser would execute if it were ever served inline. **Detection is on content, not on the claimed type or the extension, because both are attacker controlled.**
|
||||
|
||||
구현은 1,024바이트 접두사를 소문자로 만든 뒤 `stripLeading()`하고, 여섯 마커(`<!doctype html`, `<html`, `<script`, `<svg`, `<?xml`, `<!entity`) 중 하나로 **시작하는지**만 본다.
|
||||
|
||||
hermetic 실행 probe로 실제 판정을 측정했다(`146-...` EXECUTION PROBE, `inlineSafeProfile=false` 강제 프로파일).
|
||||
|
||||
```
|
||||
PROBE plain <script> -> QUARANTINE / SCRIPTABLE_CONTENT
|
||||
PROBE plain <html> -> QUARANTINE / SCRIPTABLE_CONTENT
|
||||
PROBE leading whitespace + <html> -> QUARANTINE / SCRIPTABLE_CONTENT
|
||||
PROBE uppercase <SCRIPT> -> QUARANTINE / SCRIPTABLE_CONTENT
|
||||
PROBE <svg onload> -> QUARANTINE / SCRIPTABLE_CONTENT
|
||||
PROBE UTF-8 BOM + <html> -> ACCEPT / NO_SCRIPTABLE_CONTENT ←
|
||||
PROBE HTML comment then <script> -> ACCEPT / NO_SCRIPTABLE_CONTENT ←
|
||||
PROBE NUL byte then <html> -> ACCEPT / NO_SCRIPTABLE_CONTENT ←
|
||||
PROBE plain text -> ACCEPT / NO_SCRIPTABLE_CONTENT
|
||||
```
|
||||
|
||||
세 가지가 통과한다. `String.stripLeading()`은 `Character.isWhitespace`만 제거하므로 **UTF-8 BOM(U+FEFF)도 NUL도 지우지 않고**, 선행 HTML 주석은 어떤 마커로도 시작하지 않는다. 셋 다 브라우저는 HTML로 렌더링한다 — BOM 접두 HTML은 이국적인 우회가 아니라 여러 편집기의 기본 출력이다.
|
||||
|
||||
형제 검증기와의 대비가 판정을 굳힌다. `MediaTypeVerifier`는 매직바이트를 접두사 **시작**에서 비교하는데, 그것은 시그니처의 정의가 파일 선두이므로 옳다. scriptable 마커는 시그니처가 아니라 **브라우저가 스니핑하는 패턴**이고, 브라우저는 선두 고정 매칭을 하지 않는다. 같은 "접두사 시작 비교"가 한쪽에서는 정확하고 다른 쪽에서는 우회 가능하다.
|
||||
|
||||
**판정: P2.** `inlineSafeProfile=false`인 배포에서 도달 가능하고, 그 프로파일이 바로 격리를 강제하려는 설정이다. 수정은 `startsWith`를 접두사 **탐색**으로 바꾸고, 매칭 전에 BOM·NUL·제어바이트를 제거하는 것이다. (완화 요인: `MediaTypeVerifier`가 claimed 타입과 감지 타입의 불일치를 별도로 격리하므로, `Content-Type: text/html`을 선언한 업로드는 다른 경로로 걸린다. 타입을 선언하지 않거나 `application/octet-stream`을 선언하면 걸리지 않는다.)
|
||||
|
||||
## 41. Confirmed — 검증 사슬의 합성이 fail-closed다
|
||||
|
||||
`VerificationCoordinator`는 검증기마다 시한을 두고, **timeout·interrupt·예외를 전부 `RETRY`로** 만든다 — `ACCEPT`가 아니다. javadoc이 이유를 적는다: "an unavailable scanner can never publish content by failing open."
|
||||
|
||||
`VerificationPolicyCombiner`의 우선순위는 `REJECT > QUARANTINE > RETRY > ACCEPT`이고, **`RETRY`가 `ACCEPT`보다 높다**는 것이 핵심이다 — 답하지 못한 검증기가 답한 검증기들에게 조용히 덮이지 않는다. 결과가 비면 `NO_VERIFIER_ANSWERED` → `RETRY`다. `REJECT`에 도달하면 뒤 검증기를 건너뛴다("A reject cannot be overturned").
|
||||
|
||||
개별 검증기도 방향이 옳다. `LengthVerifier`가 먼저 돌아 정책이 이미 배제한 콘텐츠에 뒤 검증기가 일하지 않게 하고, `MediaTypeVerifier`는 시그니처 일치를 **안전 판정으로 쓰지 않고** 기록할 타입만 정하며 claimed와 detected의 불일치를 격리한다. `FilenamePolicyVerifier`는 저장된 이름을 다시 sanitize해서 달라지면 거부가 아니라 **격리**한다 — 미정제 텍스트가 메타데이터 저장소에 들어갔다는 뜻이므로 콘텐츠 문제가 아니라 결함이기 때문이다.
|
||||
|
||||
`LocalVerificationContentReader`는 스테이징(업로드 중)과 발행 콘텐츠(재검증) 양쪽을 읽는다 — "otherwise re-verifying a quarantined file would silently inspect nothing and accept it".
|
||||
|
||||
## 42. Confirmed — 인가와 감사가 정보를 흘리지 않는다
|
||||
|
||||
`RoleBasedFileAccessPolicy`는 열 개 연산을 READ/WRITE/ADMIN 세 계층으로 접는다. 근거가 적혀 있다 — 연산별 역할 맵은 `COPY`를 주고 `CREATE`를 안 주는 조합을 허용하는데 "a copy creates a file"이므로 제한처럼 보이고 제한이 아니다. **admin은 write를 상속하지 않는다** — 삭제할 수 있다는 이유로 force-delete까지 되면 감사되는 관리 평면이 일반 데이터 평면으로 도달 가능해진다. 빈 admin 역할 집합은 생성자가 거부한다("would leave the management plane unreachable rather than protected").
|
||||
|
||||
거부 메시지는 필요한 역할도 주체의 역할도 말하지 않는다 — "a denial that reported what was missing would turn every 403 into a readable description of the role model".
|
||||
|
||||
`UnenforcedFileAccessPolicy`의 설계도 기록할 만하다. 이름 자체가 장치다 — composition root가 **타입 이름으로 매치해** production startup을 거부한다. "A permissive default that looked like a real policy would ship as one."
|
||||
|
||||
실패 메시지 위생도 일관된다. `LocalStorageFailures`의 어떤 메시지도 경로·마운트·루트를 담지 않고, 감사 어댑터가 쓰는 필드는 전부 지문·코드·불투명 식별자다.
|
||||
|
||||
## 43. Confirmed — 실패를 "재시도 안전한가"로 분류한다
|
||||
|
||||
`AmbiguousFilesystemOperationDetector`는 `IOException`을 네 결과로 나눈다(`NOT_SENT` / `DEFINITELY_REJECTED` / `AMBIGUOUS_COMPLETION` / `RECONCILIATION_REQUIRED`). 기본값이 보수적이다 — 인식하지 못한 실패는 **변경 연산이면 ambiguous**다. javadoc이 비대칭을 적는다: "the cost of a wrong 'safe to retry' is a corrupted object, while the cost of a wrong 'ambiguous' is one reconciliation entry."
|
||||
|
||||
`mutating` 인자로 순수 읽기는 결코 ambiguous가 되지 않게 하고, stale handle은 변경 연산일 때 `RECONCILIATION_REQUIRED`로 격상한다 — 에러만으로는 결과를 알 수 없으므로 물리 증거를 다시 읽어야 한다.
|
||||
|
||||
다만 `isStaleHandle`·`isLostResponse`와 `FilesystemFailureClassifier.isOutOfSpace`가 **메시지 텍스트 매칭**에 의존한다("stale file handle", "estale", "timed out", "No space left on device", "Disk quota exceeded"). 후자에는 주석이 붙어 있다 — "The JDK has no dedicated exception for this, so the reason text is the only available signal." 로케일이나 JDK 판본에 따라 문구가 달라지면 분류가 기본값으로 떨어지는데, 기본값이 보수적(변경 연산 → ambiguous)이므로 안전한 방향이다. 기록만 한다.
|
||||
|
||||
## 44. Negative-space probes — sub-scope 05
|
||||
|
||||
- **8.1 reachability**: 8개 port 구현과 app-bootstrap의 8개 bean 생성 지점을 직접 확인해 §4를 확정(§39).
|
||||
- **8.2 계약 ↔ 구현**: scriptable 탐지의 선언("detection is on content")과 실제 매칭 범위(접두사 시작 고정)의 격차 — 실행 probe로 확정(§40).
|
||||
- **8.2b 조건부 형제**: 같은 "접두사 시작 비교"가 `MediaTypeVerifier`에서는 정확하고 `ScriptableContentPolicy`에서는 우회 가능(§40).
|
||||
- **8.3 fail-closed 합성**: coordinator의 timeout/예외 → `RETRY`, combiner의 `RETRY > ACCEPT` 우선순위(§41).
|
||||
- **8.4 위생/문서**: 실패·감사 메시지에 경로 없음, 거부 메시지에 역할 없음(§42). 메시지 텍스트 매칭 의존과 그 보수적 기본값(§43).
|
||||
|
||||
## 45. Sub-scope 05 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| **P2** | README:105 "No setting or bean for those capabilities is exposed"가 audit·health·reaping·quota 네 능력에 대해 사실과 다르다 — 8개 port 구현과 app-bootstrap의 8개 bean으로 확정 | 이 문단으로 능력 유무를 판단하는 독자 |
|
||||
| **P2** | `ScriptableContentPolicy`가 마커를 접두사 **시작**에서만 찾아, UTF-8 BOM·NUL·선행 HTML 주석이 붙은 실행 가능 콘텐츠를 ACCEPT한다 (실행 probe 3건) | `inlineSafeProfile=false`이고 claimed 타입을 선언하지 않는 업로드 |
|
||||
| **P3/기록** | 실패 분류가 예외 메시지 텍스트("stale file handle", "timed out", "No space left on device")에 의존한다 — 문구가 달라지면 보수적 기본값으로 떨어지므로 안전한 방향 | 로케일/JDK 판본이 다른 배포 |
|
||||
|
||||
## 46. Sub-scope 05 완료 조건
|
||||
|
||||
- denominator 29 / 29 FULL_READ (`146-...` OWNED FILES)
|
||||
- §8.1~§8.4 네 종 probe 수행, sub-scope 01에서 이월한 P2를 port 구현·bean 생성 지점으로 확정
|
||||
- scriptable 탐지 우회를 hermetic 실행 probe 3건으로 확정
|
||||
- 임시 probe class 1개 추가 후 제거, `git status --short` = 0, 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 47. Sub-scope 06 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **15 / 15 FULL_READ**
|
||||
> 범위: `LocalPersistentPayloadOperations` 1,101 + `StreamingCsvEncoder` 190 + `FilesystemCsvExportAdapter` 188 (main 3) + 전용 test 3 + testkit 9
|
||||
> 역할: R2 payload의 보안 경계, RFC-4180 스트리밍 인코딩(수식 완화 포함), 그리고 store 계약·크래시 행렬·NFS 모호성 testkit
|
||||
|
||||
manifest와 probe: `evidence/raw/147-fileserver-payload-testkit-probes.txt`.
|
||||
|
||||
## 48. Confirmed — payload 계층이 자신의 잔여 위험을 먼저 선언한다
|
||||
|
||||
`LocalPersistentPayloadOperations`의 클래스 javadoc이 무엇이 서술자 상대이고 무엇이 아닌지를 앞에서 밝힌다.
|
||||
|
||||
> Reads, writes, and exact deletes use `SecureDirectoryStream`; **the JDK's missing relative hard-link, directory-create, and directory-force primitives are bracketed by attested identity checks in this class.**
|
||||
|
||||
전수 검사가 그 서술과 일치한다(`147-...` §8.2). 이 파일의 `Files.*` 호출은 정확히 그 셋 — `Files.createLink`(:941), `Files.createDirectory`(:802), 그리고 force/stat/FileStore 조회 — 뿐이고, 각각 앞뒤로 `fileKey`·소유자·권한·FileStore 재확인이 붙는다. JDK가 `linkat`/`mkdirat`를 노출하지 않으므로 서술자 상대 대응물이 없고, 그 사실을 숨기는 대신 적었다.
|
||||
|
||||
**이것이 §32와의 차이다.** 여기서는 잔여 경로 연산이 (a) 문서에 선언되고 (b) identity 검사로 감싸인다. `AtomicMoveContentPublisher`의 발행 rename은 (a) 어디에도 선언되지 않고 (b) 같은 모듈이 "a precheck could only ever approximate"라고 적은 사전검사 하나로만 보호된다. 같은 저장소가 같은 문제를 한 번은 정직하게, 한 번은 그렇지 않게 다룬 대비다.
|
||||
|
||||
## 49. Confirmed — CSV 인코더가 스트리밍이고 세 가지 상한을 동시에 건다
|
||||
|
||||
`StreamingCsvEncoder`는 행 단위로 쓰고 즉시 다이제스트에 넣는다. 상한이 셋이다 — 행 수(`maximumRows`), 누적 바이트(`maximumBytes`, `bytesWritten > maximumBytes - bytes.length`로 오버플로 없이 검사), 그리고 **컬럼별 UTF-8 바이트**(`column.maximumUtf8Bytes()`). 셀 타입이 스키마와 다르면 거부하고, `null`은 컬럼이 nullable일 때만 빈 문자열이 된다.
|
||||
|
||||
수식 주입 완화는 세 정책으로 갈린다 — `ALLOW`/`MITIGATE`(`'` 접두, 카운트 증가)/`REJECT`. 후보 판정은 첫 문자가 `=`, `+`, `-`, `@`, `\t`, `\r`인지다(OWASP 권고 집합). `formulaMitigatedCount`가 receipt와 control record까지 전달되므로(§13의 `requireFormulaCountWithinCells`) 완화가 일어났다는 사실이 감사 가능한 값으로 남는다.
|
||||
|
||||
`checkpoint()`가 매 행 앞뒤로 스레드 인터럽트를 확인해 협력적 취소를 지원한다.
|
||||
|
||||
R1 legacy 어댑터도 두 결함을 이미 고쳤다고 주석에 남긴다 — 전체를 `StringBuilder`에 모으던 방식(백만 행이면 OOM)을 bounded writer 스트리밍으로, `Files.write`의 조용한 truncate를 `CREATE_NEW`로. 다만 R1이 "a stand-in for NFS/SFTP"라고 자칭하는 것은 CLAUDE.md의 "Advertising `shared-mounted`/NFS, SFTP … as implemented" 금지와 나란히 두면 표현이 조심스럽다("stand-in"은 구현 주장이 아니다). 결함으로 올리지 않는다.
|
||||
|
||||
## 50. Confirmed — testkit이 크래시 지점을 열거해 전수 검증한다
|
||||
|
||||
`CrashRecoveryMatrixTest`는 `@EnumSource(CrashPoint.class)`로 **모든** 크래시 지점에 대해 두 불변식을 건다.
|
||||
|
||||
- `aPublishedObjectIsAlwaysCompleteAndDigestMatched`
|
||||
- `aCrashNeverLeavesAPartialObjectUnderThePublishedKey`
|
||||
|
||||
즉 "어떤 지점에서 죽어도 발행된 키 아래에 부분 객체가 없다"를 지점별로 확인한다. `AFTER_CREATE`·`DURING_APPEND`·`AFTER_APPEND_COMMIT`·`BEFORE_PUBLISH`·`AFTER_METADATA_BEFORE_QUOTA` 등이 열거돼 있어, 새 지점을 추가하면 두 test가 자동으로 그것을 포함한다.
|
||||
|
||||
`ContentStoreContract`는 추상 계약이고 `LocalContentStoreContractTest`(원자적 이동)와 `MetadataPointerContentStoreContractTest`(포인터 발행)가 각각 상속한다 — **두 발행 전략이 같은 계약을 통과해야 한다**는 것을 구조로 강제한다. 계약 항목도 성질 중심이다: 왕복, 다중 append의 다이제스트가 모든 바이트를 덮는지, 오프셋 불일치가 객체를 건드리지 않고 거부되는지, 범위 읽기가 정확히 요청 바이트만 반환하는지, 선언 다이제스트/길이 불일치가 finalize에서 거부되는지, 부재 객체 삭제가 멱등 성공이면서 divergence를 보고하는지, store가 **증명한** capability만 보고하는지, 두 업로드가 물리 키를 공유하지 않는지.
|
||||
|
||||
`FileserverCrashScenarioMain`은 별도 프로세스로 fork되는 진입점이고(§sub-scope 02의 `LocalPersistentCrashRecoveryTest`가 사용), `NfsAmbiguityIntegrationTest`·`NfsTestEnvironment`·`PvcCertificationDescriptor`는 환경이 있을 때만 도는 자격 검증 fixture다.
|
||||
|
||||
## 51. Negative-space probes — sub-scope 06
|
||||
|
||||
- **8.1 reachability**: 세 main 타입 모두 leaf 밖 참조 0(`147-...` §8.1). `FilesystemCsvExportAdapter`만 `public class`인데(다른 것은 package-private/final) 외부 참조가 없으므로 가시성이 필요보다 넓다 — 기록만 한다.
|
||||
- **8.2 계약 ↔ 구현**: payload 계층의 잔여 경로 연산 선언과 실제 호출 지점 일치(§48).
|
||||
- **8.3 중복 mechanism**: R1 legacy 인코딩과 R2 스트리밍 인코더 — 별개 포트, 별개 selector, 공유 없음(§49).
|
||||
- **8.4 문서/커버리지**: 크래시 지점 enum 전수 순회(§50), 두 발행 전략의 공통 계약 상속(§50).
|
||||
|
||||
## 52. Sub-scope 06 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| — | **없음.** payload 계층은 잔여 위험을 선언하고 감쌌고, 인코더의 상한·수식 정책·취소는 전부 값으로 관측 가능하며, testkit은 크래시 지점을 열거해 전수 검증한다 | — |
|
||||
|
||||
## 53. Sub-scope 06 완료 조건
|
||||
|
||||
- denominator 15 / 15 FULL_READ (`147-...` OWNED FILES) — main 1,479 LOC 전수 판독
|
||||
- §8.1~§8.4 네 종 probe 수행
|
||||
- 실행 probe 불필요 — 판정 지점이 정적으로 결정 가능
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 54. 모듈 원장 대조
|
||||
|
||||
`§0`의 denominator 119를 하위 범위 실측과 대조한다.
|
||||
|
||||
| # | 하위 범위 | main | test | 합 | 근거 |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | governance / config / activation | 7 | 2 | 13 (governance 4 포함) | `142` |
|
||||
| 2 | control plane + record codec + recovery verifier | 3 | 3 | 6 | `143` |
|
||||
| 3 | publication | 19 | 7 | 26 | `144` |
|
||||
| 4 | `platform/local` IO · gateway · store/publisher | 22 | 8 | 30 | `145` |
|
||||
| 5 | `platform/local` 실패·probe·health·orphan + verification + security + audit | 24 | 5 | 29 | `146` |
|
||||
| 6 | payload · CSV · testkit | 3 | 12 | 15 | `147` |
|
||||
| | **합계** | **78** | **37** | **119** | |
|
||||
|
||||
- main 78 (12,707 LOC), test 37 (12,043 LOC), governance 4. 총 119 tracked files.
|
||||
- **unclassified 0, structural-only 0, excluded 0.** 6개 하위 범위 모두 FULL_READ.
|
||||
|
||||
## 55. 모듈 findings 종합
|
||||
|
||||
| 우선순위 | 개수 | 항목 |
|
||||
|---|---|---|
|
||||
| **P2** | 3 | §4·§39 README:105의 "노출된 setting도 bean도 없다"가 audit·health·reaping·quota 넷에 대해 사실과 다름 (port 구현 8, bean 8) · §40 `ScriptableContentPolicy`가 BOM·NUL·선행 주석으로 우회됨 (실행 probe 3건) |
|
||||
| **P3** | 5 | §5 R1의 `ignoreUnknownFields` 부재 · §5 R1/R2 루트 경로 규칙 비대칭 · §6 문서의 기본값 위치·test 목록 오류 · §32 발행 rename의 경로 기반 연산 · §34 `maxBorrowedBytes`의 서술과 실제 결합 부재 |
|
||||
| **P3/기록** | 1 | §43 실패 분류의 예외 메시지 텍스트 의존 |
|
||||
|
||||
**결함이 없는 하위 범위가 셋이다**(02·03·06). 이 leaf의 코드 품질은 지금까지 분석한 모듈 중 가장 높은 축에 속한다 — canonical 왕복 검증, 인접 전이 행렬, 서술자 상대 파일시스템 접근, 크래시 지점 전수 순회, fail-closed 검증 합성이 모두 실제로 구현돼 있고 test가 그것을 성질로 고정한다.
|
||||
|
||||
반복된 형태는 둘이다.
|
||||
|
||||
1. **문서가 코드보다 좁게 또는 넓게 말한다.** README의 guarantee-boundary 문단이 같은 저장소가 만드는 bean을 "없다"고 하고(§4·§39), 기본값의 위치와 test 목록이 어긋나며(§6), 계측의 javadoc이 존재하지 않는 결합을 서술한다(§34). 코드는 대체로 옳고 서술이 뒤처졌다.
|
||||
2. **자기 규칙의 예외가 선언될 때와 그렇지 않을 때.** `LocalPersistentPayloadOperations`는 JDK가 서술자 상대 원시연산을 주지 않는 세 곳을 **먼저 선언하고** identity 검사로 감쌌다(§48). `AtomicMoveContentPublisher`의 발행 rename은 같은 성격의 예외인데 선언되지 않고, 보호는 이 모듈이 스스로 "근사에 불과하다"고 적은 사전검사 하나다(§32). 규칙이 아니라 **예외를 다루는 방식**이 두 곳에서 다르다.
|
||||
|
||||
## 56. 모듈 완료 조건
|
||||
|
||||
- denominator **119 / 119 FULL_READ** — 6개 하위 범위 전부 COMPLETE(§54)
|
||||
- 하위 범위마다 §8.1~§8.4 네 종 negative-space probe 수행, 증거는 `evidence/raw/141`–`147`
|
||||
- 정적으로 결정 불가한 지점을 실행 probe로 확정: `146`(scriptable 탐지 우회 3건)
|
||||
- sub-scope 01에서 제기한 P2를 sub-scope 05에서 port 구현·bean 생성 지점으로 확정 — 이월과 종결을 원장에 남김
|
||||
- 임시 probe class 1개 추가 후 제거, `git status --short` = 0, 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 57. 실행 검증과 분석 환경 제약
|
||||
|
||||
HEAD에서 focused suite를 돌린 결과와 그 해석을 남긴다(`evidence/raw/148-fileserver-suite-verification.txt`).
|
||||
|
||||
컨테이너 기본 로케일에서 `:adapter:outbound:fileserver:test`는 **398 tests / 1 failed / 3 skipped**로 끝난다. 실패한 것은 `LocalPersistentPayloadOperationsTest.inspectsLegacyRootArtifactByBoundedNoFollowStreamingWithoutRewritingIt()` 하나이고, 원인은 다음이다.
|
||||
|
||||
```
|
||||
java.nio.file.InvalidPathException: Malformed input or input contains unmappable
|
||||
characters: 월간 export -- legacy 01.csv
|
||||
at java.base/sun.nio.fs.UnixPath.encode(UnixPath.java:129)
|
||||
at LocalPersistentPayloadOperationsTest.java:331
|
||||
```
|
||||
|
||||
분석 컨테이너의 로케일이 `POSIX`이고 `sun.jnu.encoding=ANSI_X3.4-1968`이라, test fixture가 **자기 경로를 만드는 단계**(`Path.resolve`, 331행)에서 한국어 파일명을 인코딩하지 못한다. adapter production 코드는 실행되지도 않는다.
|
||||
|
||||
같은 revision을 `LANG=C.UTF-8`로 다시 돌리면 **BUILD SUCCESSFUL**이다. 소스는 한 줄도 바꾸지 않았다.
|
||||
|
||||
**판정: 분석 환경 제약이지 저장소 결함이 아니다.** (JPA scope에서 PostgreSQL TLS lane을 같은 방식으로 분류한 것과 동일한 형태다. 다만 이 leaf는 비-ASCII 소스를 test fixture에 쓰므로, `adapter/outbound/identifier`가 `build.gradle`에서 UTF-8 인코딩을 명시적으로 고정한 것과 같은 조치를 이 leaf는 하지 않았다는 점은 기록해 둔다 — 컴파일 인코딩과 런타임 `sun.jnu.encoding`은 별개 문제이므로 결함으로 올리지는 않는다.)
|
||||
|
||||
## Source anchors
|
||||
|
||||
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **69개** (main 55 · test 13 · 기타 1).
|
||||
|
||||
```
|
||||
src/adapter/outbound/fileserver/build.gradle
|
||||
src/config/architecture/modules.json (adapter-outbound-fileserver 항목)
|
||||
|
||||
main:
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/CompiledFileDestination.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/DurablePublicationRecord.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportSettings.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationCanonicalDigests.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationProvider.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublishRequestFingerprint.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverActivationValidator.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompiler.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodec.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Config.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Validation.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapter.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlane.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperations.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProvider.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRecoveryVerifier.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestor.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournal.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/PrivateFileManifest.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/PublishedReferenceRecord.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/RoutingFilePublicationAdapter.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/StreamingCsvEncoder.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/audit/StructuredAdminAuditAdapter.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/audit/StructuredFileserverAuditAdapter.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/AmbiguousFilesystemOperationDetector.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/AtomicMoveContentPublisher.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/ContentPublishVerification.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/DefaultPhysicalPathResolver.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/FilesystemFailureClassifier.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalAppendEngine.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalBlockingContentStore.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalCopyContentGateway.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalOrphanScanAdapter.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalReconciliationContentProbe.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageCapabilityProbe.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageFailures.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageHealthAdapter.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageUsageProbe.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalUploadHandle.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalZeroCopyDownloadGateway.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/MetadataPointerContentPublisher.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/SafeFileChannelFactory.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/SecureDirectoryWalk.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/TransferBufferPool.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/security/RoleBasedFileAccessPolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/security/UnenforcedFileAccessPolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/FilenamePolicyVerifier.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/LengthVerifier.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/LocalVerificationContentReader.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/MediaTypeVerifier.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/ScriptableContentPolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/VerificationCoordinator.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/VerificationPolicyCombiner.java
|
||||
|
||||
test:
|
||||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverCrashScenarioMain.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentCrashRecoveryTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperationsTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/ContentPublisherTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalAppendMemoryTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/ContentStoreContract.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/CrashRecoveryMatrixTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/LargeFileBoundedMemoryTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/LocalContentStoreContractTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/MetadataPointerContentStoreContractTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/NfsAmbiguityIntegrationTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/NfsTestEnvironment.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/PvcCertificationDescriptor.java
|
||||
|
||||
기타:
|
||||
src/build.gradle
|
||||
|
||||
해석되지 않은 인용 (9종) — 외부 타입·문서상 약칭 등:
|
||||
app-bootstrap/application.yml
|
||||
evidence/raw/141-fileserver-module-inventory.txt
|
||||
evidence/raw/142-fileserver-config-activation-probes.txt
|
||||
evidence/raw/143-fileserver-control-plane-probes.txt
|
||||
evidence/raw/144-fileserver-publication-probes.txt
|
||||
evidence/raw/145-fileserver-local-io-probes.txt
|
||||
evidence/raw/146-fileserver-verification-security-audit-probes.txt
|
||||
evidence/raw/147-fileserver-payload-testkit-probes.txt
|
||||
evidence/raw/148-fileserver-suite-verification.txt
|
||||
|
||||
```
|
||||
@@ -0,0 +1,920 @@
|
||||
# 09 · adapter-outbound-objectstorage
|
||||
|
||||
|
||||
## SSOT identity — 2026-08-31 재검증
|
||||
|
||||
- registered leaf id: `adapter-outbound-objectstorage`
|
||||
- canonical state `analysisFile`: `analysis/09-adapter-outbound-objectstorage.md` (이 문서) — 이 leaf의 단일 SSOT
|
||||
- source path: `src/adapter/outbound/objectstorage` · Gradle `:adapter:outbound:objectstorage`
|
||||
- registry `allowed_dependencies`: `["application-core", "shared-contract"]`
|
||||
- registry `runtime_memberships`: `["sample-portfolio"]`
|
||||
- coverage ledger: `FULL_READ` **206** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
|
||||
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
|
||||
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행)
|
||||
|
||||
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
|
||||
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
|
||||
|
||||
---
|
||||
> 상태: COMPLETE
|
||||
> revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
|
||||
> 경로: `src/adapter/outbound/objectstorage` · Gradle: `:adapter:outbound:objectstorage`
|
||||
|
||||
## 0. Denominator와 coverage ledger
|
||||
|
||||
tracked file **206개** — main 147 (14,336 LOC), test 48 + resource 1 (6,753 LOC), 별도 qualification source set 3개 (6 files, 546 LOC), governance 4. 총 약 21.6k LOC.
|
||||
|
||||
```json
|
||||
{ "id": "adapter-outbound-objectstorage",
|
||||
"gradle_path": ":adapter:outbound:objectstorage",
|
||||
"allowed_dependencies": ["application-core", "shared-contract"],
|
||||
"runtime_memberships": ["sample-portfolio"] }
|
||||
```
|
||||
|
||||
`build.gradle`이 `strictTestLanes`로 세 개의 별도 source set을 선언한다 — `objectStorageMinioContractTest`, `objectStorageMinioFaultTest`, `objectStorageAwsQualificationTest`. AWS SDK v2 BOM은 Spring Boot BOM이 관리하지 않으므로 **모듈 범위**로 import되고, 그 이유가 주석에 적혀 있다("keeps the strict-locking blast radius to this module").
|
||||
|
||||
패키지 배치(main): `s3` 26 · `control` 24 · `kernel` 23 · `config` 19 · `direct` 13 · `readiness` 8 · `maintenance` 8 · `codec` 7 · `filesystem` 6 · `multipart` 5 · `provider` 4 · 루트 4.
|
||||
|
||||
### 하위 범위 원장
|
||||
|
||||
| # | 범위 | main | test | 기타 | 합 | 상태 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | governance + `config/**` — opt-in · binding compiler · routing · capability | 19 | 5 | 4 | 28 | **COMPLETE** |
|
||||
| 2 | `control/**` — canonical JSON codec + durable record 타입 | 24 | 1 | – | 25 | **COMPLETE** |
|
||||
| 3 | `kernel/**` + `codec/**` — operation kernel · state machine · epoch · key/fingerprint codec | 30 | 9 | – | 39 | **COMPLETE** |
|
||||
| 4 | `s3/**` — provider binding · client policy · async bridge · provider 구현 | 26 | 14 | – | 40 | **COMPLETE** |
|
||||
| 5 | `direct/**` + `multipart/**` — direct transfer · multipart coordinator | 18 | 7 | – | 25 | **COMPLETE** |
|
||||
| 6 | `filesystem/**` + `maintenance/**` + `readiness/**` + `provider/**` + 루트 | 30 | 12 | 1 | 43 | **COMPLETE** |
|
||||
| 7 | qualification source set 3종 (minio contract / minio fault / aws) | – | – | 6 | 6 | **COMPLETE** |
|
||||
| | **TOTAL** | **147** | **48** | **11** | **206** | **7 / 7** |
|
||||
|
||||
manifest: `evidence/raw/149-objectstorage-module-inventory.txt`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sub-scope 01 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **28 / 28 FULL_READ**
|
||||
> 범위: governance 4 + `config/**` main 19 + 전용 test 5
|
||||
> 역할: `app.object-storage`를 **비활성 기본값**에서 정확한 불변 바인딩으로 컴파일하고, 선택된 provider만 자원을 만들게 하며, 폐기된 alias를 격리한다
|
||||
|
||||
manifest와 probe: `evidence/raw/150-objectstorage-config-activation-probes.txt`.
|
||||
|
||||
## 2. Confirmed — "컴파일이 먼저, 생성은 나중"이 실제 순서다
|
||||
|
||||
`ObjectStorageProviderContribution`이 두 메서드의 계약을 나눈다.
|
||||
|
||||
> `describe` **must not resolve credentials, create files, clients, threads, or schedulers**. `create` owns cleanup of every partial allocation before it throws; after a successful return the assembler owns the returned lifecycle exactly once.
|
||||
|
||||
`ObjectStorageCapabilityAssembler.assemble`이 그 순서를 지킨다 — `compiler.compile(settings)`가 **전부** 끝난 뒤(`:25`)에야 선택된 destination을 돌며 `contribution.create(provider)`를 부른다(`:48`). 그리고 도중에 실패하면 이미 만든 것을 **역순으로** 닫는다(`:53–56`). `AssembledCapability.close()`도 역순이고 `AtomicBoolean`으로 정확히 한 번만 실행된다.
|
||||
|
||||
README의 "Settings compile fully before any selected provider creates a directory, client, thread, scheduler, or credential lookup"이 코드 구조로 성립한다.
|
||||
|
||||
컴파일러 자체가 fail-closed다. 비활성이면 빈 바인딩을 돌려주고, 활성인데 provider·destination·default destination 중 하나라도 비면 거부한다. provider마다 `describe`가 돌려준 서술자와 설정을 **대조**한다 — providerType 일치, version 일치, `maximumObjectBytes`가 서술자 상한 이하, `chunkBytes`가 서술자 상한 이하. chunk는 추가로 `1 ≤ chunk ≤ min(maxObject, 16 MiB)`이고 `Integer.MAX_VALUE`를 넘지 못한다. destination은 route token 중복을 거부하고, 요구한 capability를 provider가 `SUPPORTED`로 신고하지 않으면 거부하며, `SCAN_CLEAN`을 요구하는데 scanner seam이 없으면 이름을 대며 거부한다.
|
||||
|
||||
식별자 검증도 좁다 — `canonicalId`는 64자 이내, `[a-z0-9][a-z0-9_-]*`, 소문자, 그리고 **0x20–0x7e 밖 문자를 전부 거부**한다.
|
||||
|
||||
## 3. Confirmed — README가 "등록되지 않는다"고 적은 것들이 실제로 등록되지 않는다
|
||||
|
||||
`150-...` §8.2의 네 주장을 각각 추적했다.
|
||||
|
||||
| README 주장 | 확인 |
|
||||
|---|---|
|
||||
| "no direct-grant port is registered" | `RoutingObjectDirectGrantAdapter`는 private 생성자만 가진 빈 클래스이고, leaf 안에서 **자기 파일 밖 참조 0**(grep exit=1) |
|
||||
| "Scanner and privileged purge composition remain separate and empty" | `ObjectStorageMaintenanceCapabilityConfig`는 본문이 **없는** `@Configuration`. scanner는 별도 `ObjectStorageScanMaintenanceConfig`에 있고 `app.object-storage.scan-maintenance.enabled=true`로만 켜진다 |
|
||||
| "`filesystem-local-dev` is rejected under `prod`/`production`" | `ObjectStorageBindingCompiler:125`에 존재 |
|
||||
| "Mixing any old alias with canonical settings fails startup without echoing values" | `LegacyObjectStorageActivationGuard`가 두 prefix가 동시에 있으면 `IllegalStateException`을 던지고, 메시지에 값이 없다 |
|
||||
|
||||
마지막 것의 구현이 특히 조심스럽다 — `hasPrefix`가 `EnumerablePropertySource`를 순회해 prefix로 **시작하는 이름이 있는지**만 보고, 열거 불가능한 source에 대비해 알려진 키 목록으로 fallback한다. 어느 경로에서도 값을 읽지 않는다.
|
||||
|
||||
적재 경로는 fileserver와 같다 — `AutoConfiguration.imports`가 없고(`150-...` §8.1, exit=1), `CaSkeletonApplication`의 명시적 `@ComponentScan`이 `dev.caskeleton.adapter.outbound.objectstorage`를 목록에 올린다(`:76`). 그리고 `app.object-storage.*`는 어느 `application.yml`에도 없으므로 실효 기본값은 **속성 부재**다.
|
||||
|
||||
## 4. Confirmed — legacy가 세 겹으로 격리돼 있다
|
||||
|
||||
폐기 경로가 셋인데 서로 다른 스위치를 쓰고 서로를 배제한다.
|
||||
|
||||
| 경로 | 스위치 | 성격 |
|
||||
|---|---|---|
|
||||
| 선호 임시 활성화 | `app.object-storage.legacy.enabled=true` + 명시적 backend | `ObjectStoragePort`(whole-`byte[]`) 노출 |
|
||||
| 구 alias | `ca-skeleton.objectstorage.*` | `LegacyObjectStorageActivationGuard` 조건, canonical과 혼용 시 실패 |
|
||||
| 채택(adoption) | `app.object-storage.legacy-adoption.enabled=true` | raw locator 유지보수 전용, 별도 config 클래스 |
|
||||
|
||||
`ObjectStorageBindingCompiler.rejectLegacyOverlap`가 legacy filesystem 루트와 canonical provider 루트가 **어느 방향으로든 포함 관계**면 거부한다. `LegacyObjectAdoptionSettings`는 `APPLY` 모드일 때 검토된 manifest 경로와 64자리 SHA-256을 요구하고, batch size 1–1000, timeout 5분 이내를 강제한다.
|
||||
|
||||
legacy runtime은 `AutoCloseable` holder로 감싸 S3 client 수명을 정확히 소유하고, `@Bean(destroyMethod = "close")`로 등록된다.
|
||||
|
||||
## 5. P3 — production 판정이 두 개의 리터럴 프로파일 이름에 걸려 있다
|
||||
|
||||
CLAUDE.md의 Forbidden 목록에 "local-dev in production"이 있고, 그것을 강제하는 코드는 이것 하나다.
|
||||
|
||||
```java
|
||||
private boolean productionProfileActive() {
|
||||
return activeProfiles.stream()
|
||||
.map(profile -> profile.toLowerCase(Locale.ROOT))
|
||||
.anyMatch(profile -> profile.equals("prod") || profile.equals("production"));
|
||||
}
|
||||
```
|
||||
|
||||
이 저장소가 `application-prod.yml`을 싣고 있으므로 **현재 형상에서는 맞는다**. 그리고 `150-...` §8.2c에서 확인했듯 같은 방식으로 production을 판정하는 leaf는 이것 하나뿐이다 — 저장소 전체가 공유하는 production 판별 장치가 없다.
|
||||
|
||||
문제는 방향이다. 이것은 **거부** 검사인데 판정 근거가 **허용 목록 두 개**다. `prd`, `production-eu`, `live`, `prod-apac` 같은 이름을 쓰는 fork는 이 검사를 통과하고, `filesystem-local-dev`가 production에서 조용히 선택된다 — 그 provider는 README가 "R1-only development provider"라고 적은 것이다. 실패는 startup이 아니라 데이터가 로컬 디스크에 쌓인 뒤에 드러난다.
|
||||
|
||||
**판정: P3.** 이 저장소 형상에서는 도달하지 않는다. 기록하는 이유는 (a) CLAUDE.md가 금지 항목으로 명시했고 (b) 강제 수단이 두 문자열이며 (c) fork가 프로파일 이름을 바꾸는 것은 평범한 일이기 때문이다. 수정은 production 판별을 명시적 설정(예: `app.object-storage.allow-local-dev=true`를 요구)으로 뒤집는 것 — 이름이 아니라 의도를 묻는 형태다.
|
||||
|
||||
## 6. P3/기록 — readiness registry가 build의 test 입력인데 leaf 소스가 그 파일명을 참조하지 않는다
|
||||
|
||||
`build.gradle:45–46`이 `docs/registries/object-storage-readiness.yaml`을 `test` task의 `inputs.file`로 선언한다. 그 파일의 헤더는 소유 test 둘을 이름으로 적는다.
|
||||
|
||||
```
|
||||
# Repository owner test: dev.caskeleton.bootstrap.contract.ContractRegistrySchemaGovernanceTest
|
||||
# Semantic owner test: dev.caskeleton.adapter.outbound.objectstorage.readiness.ObjectStorageReadinessRegistryTest
|
||||
```
|
||||
|
||||
그런데 leaf 소스에서 `object-storage-readiness`라는 문자열을 검색하면 **매치가 없다**(`150-...` §8.4c, exit=1). 즉 semantic owner test는 파일명을 상수로 갖지 않고 다른 방식(경로 조립 등)으로 찾는다. 실제 결합 여부는 `readiness` 패키지를 읽는 **sub-scope 06**에서 확정한다 — 여기서는 이월 항목으로만 남긴다.
|
||||
|
||||
## 7. Confirmed — 후보로 본 unguarded split은 값 타입이 막고 있다
|
||||
|
||||
`RoutingObjectReadAdapter.load`가 `reference.canonicalText().split("\\.", -1)[1]`로 route token을 꺼낸다. 인덱스 검사가 없어 처음에는 `ArrayIndexOutOfBoundsException` 후보로 봤다.
|
||||
|
||||
`ObjectReference`를 확인한 결과 생성자가 `ObjectIdentitySupport.requireRouted(canonicalText, "osr1")`로 형태를 강제하므로, 유효하게 만들어진 참조에는 항상 route 구획이 있다(`150-...` §8.4d). 결함이 아니다.
|
||||
|
||||
## 8. Negative-space probes — sub-scope 01
|
||||
|
||||
- **8.1 reachability**: auto-configuration 등록 metadata 0, 적재는 명시적 component scan, 두 selector 모두 `application.yml`에 부재(속성 부재가 실효 기본값).
|
||||
- **8.2 계약 ↔ 구현**: `describe`/`create`의 부작용 계약과 assembler의 실제 호출 순서(§2). README의 네 가지 "등록되지 않는다" 주장 전수 확인(§3).
|
||||
- **8.2b 조건부 형제**: production 판정이 이 leaf에만 있고 저장소 공용 장치가 없음(§5).
|
||||
- **8.3 중복 mechanism**: legacy 경로 셋이 서로 다른 스위치를 쓰고 겹침을 명시적으로 거부(§4).
|
||||
- **8.4 문서/빌드 drift**: readiness registry의 build 입력 선언과 leaf 소스의 참조 부재(§6, sub-scope 06으로 이월).
|
||||
|
||||
## 9. Sub-scope 01 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| **P3** | `filesystem-local-dev` production 거부가 `prod`/`production` 두 리터럴에만 걸려 있다 — 다른 이름을 쓰는 fork는 R1 개발용 provider를 production에서 받는다 | 프로파일 이름을 바꾼 fork |
|
||||
| **P3/기록** | readiness registry가 build의 test 입력인데 leaf 소스에 그 파일명 참조가 없다 — 실제 결합은 sub-scope 06에서 확정 | 이월 |
|
||||
|
||||
## 10. Sub-scope 01 완료 조건
|
||||
|
||||
- denominator 28 / 28 FULL_READ (`150-...` OWNED FILES)
|
||||
- §8.1~§8.4 네 종 probe 수행, README의 네 가지 조립 주장을 각각 코드로 추적
|
||||
- 후보 finding 1건(unguarded split)을 값 타입 검증으로 추적해 결함 아님으로 판정(§7)
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 11. Sub-scope 02 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **25 / 25 FULL_READ**
|
||||
> 범위: `control/**` main 24 (2,470 LOC) + 전용 test 1 (339 LOC)
|
||||
> 역할: 열한 종의 durable 제어 레코드를 **닫힌 sealed 계열**로 두고, canonical-json-v1 + 외곽 SHA-256 봉투로 인코딩하며, 불변식을 레코드 생성자에서 강제한다
|
||||
|
||||
manifest와 probe: `evidence/raw/151-objectstorage-control-probes.txt`.
|
||||
|
||||
## 12. Confirmed — 계열이 닫혀 있고 스키마가 fail-closed다
|
||||
|
||||
`ObjectControlRecord`는 열한 개 구현만 허용하는 `sealed interface`이고, javadoc이 규칙을 적는다 — "Unknown families and schemas fail closed." codec의 `payload(...)`가 그 계열에 대해 **exhaustive switch**를 쓰므로, 새 레코드를 추가하면 컴파일이 강제로 codec을 갱신하게 만든다.
|
||||
|
||||
스키마 버전은 `ControlRecordSupport.header`가 `schemaVersion != 1`을 거부한다 — "only control schema version 1 is writable". 더 새로운 스키마를 만나면 덮어쓰지 않고 `UnsupportedObjectControlSchemaException`으로 격리한다("A newer or unknown durable schema that must be quarantined rather than overwritten").
|
||||
|
||||
`objectstorage.control` 패키지를 leaf 밖에서 참조하는 코드는 **0**이다(`151-...` §8.1, exit=1). CLAUDE.md의 "control-record types leaking into application-core" 금지가 가시성으로 성립한다.
|
||||
|
||||
## 13. Confirmed — canonical 표현이 "우리가 쓴 것과 바이트가 같은가"로 강제된다
|
||||
|
||||
`CanonicalJsonReader`는 관용을 두지 않는다.
|
||||
|
||||
- **필드 순서 고정**: `field(expectedName)`이 읽은 이름과 기대 이름을 비교한다 — 재배열은 실패.
|
||||
- **공백 불허**: `expect(char)`가 정확히 그 문자만 소비한다. 공백을 건너뛰는 코드가 없다.
|
||||
- **이스케이프 두 개만**: `\"`와 `\\` 외의 이스케이프는 실패.
|
||||
- **printable ASCII만**: 0x20–0x7e 밖 문자는 reader·writer·`ControlRecordSupport` 세 곳 모두에서 거부.
|
||||
- **숫자 정규형**: 선행 0(`00`, `01`)과 `-0`을 거부.
|
||||
- **후행 콘텐츠 불허**: `end()`가 `cursor != input.length()`면 실패.
|
||||
|
||||
reader가 `new String(bytes, UTF_8)`로 관용 디코딩하는 것은 그 자체로는 malformed 바이트를 U+FFFD로 바꾸지만, U+FFFD(0xFFFD)는 0x7e를 넘으므로 **printable ASCII 검사에서 걸린다**. 즉 관용 디코딩이 뚫리지 않는다 — fileserver R1 저널(sub-scope 03, 08번 문서 §26)에서 같은 관용 디코딩이 열려 있던 것과 대비된다.
|
||||
|
||||
봉투도 이중으로 잠긴다. `decodeUnchecked`가 Base64를 디코딩한 뒤 **다시 인코딩해 문자열이 같은지** 확인하고(alias 거부), payload의 SHA-256을 `MessageDigest.isEqual`(상수시간)로 비교한다. 크기 상한이 계열별로 셋이다 — 봉투 64 KiB, terminal receipt 16 KiB, part 4 KiB — 그리고 encode·decode 양쪽에서 `enforceFamilySize`가 적용된다.
|
||||
|
||||
## 14. Confirmed — 레코드가 값을 믿지 않고 관계를 다시 계산한다
|
||||
|
||||
`ObjectOperationRecord`의 compact 생성자가 대표적이다. 넘겨받은 `policySnapshotDigest`를 그대로 쓰지 않고 **스냅샷에서 다시 계산해 대조**한다 — "policySnapshotDigest does not match the snapshot". `expectedContentIdentity`가 있으면 얼어붙은 정책 상한을 넘는지도 본다.
|
||||
|
||||
상태 짝도 강제된다.
|
||||
|
||||
- `(pendingEffect == null) != (effectCertainty == NOT_SENT)` → "pending effect and certainty do not agree"
|
||||
- 미해결 pending effect가 있는데 새 것을 만들면 → "an unresolved pending effect already exists"
|
||||
- `DATA_UPLOADED`로 전진하려면 업로드 증거가 확인돼 있어야 함
|
||||
- terminal 전이는 `ABORTED`/`QUARANTINED`/`FAILED`만 허용
|
||||
|
||||
`ObjectPublicationHandoffRecord`는 lease를 fence와 함께 다룬다 — fence는 1 이상, `released && abortAuthorized` 동시 참 금지, 그리고 **쓰기 시점에 이미 만료된 활성 lease를 거부**한다("active handoff lease is expired at write time"). claimant는 지문(`hexDigest`)으로만 저장된다.
|
||||
|
||||
`ObjectStagedObjectRecord`는 scan 증거의 짝을 강제한다 — `(scanOperationId == null) != (scannerPolicyRevision == null)`이면 "scan verdict evidence is incomplete", 그리고 스캔은 `integrityVerified && publicationRequirement == SCAN_CLEAN`일 때만 시작할 수 있다.
|
||||
|
||||
`ObjectControlStore` 인터페이스에는 **`list`가 없다**. javadoc이 그 부재를 명시한다 — "exact-lookup/create/CAS control storage. **LIST is deliberately absent.**" 열거가 없으면 제어 평면을 훑어 다른 테넌트의 키를 발견하는 경로가 구조적으로 없다.
|
||||
|
||||
## 15. Negative-space probes — sub-scope 02
|
||||
|
||||
- **8.1 reachability**: `control` 패키지의 leaf 밖 참조 0(§12).
|
||||
- **8.2 계약 ↔ 구현**: canonical 주장과 reader/writer의 실제 거부 목록 대조(§13). 관용 UTF-8 디코딩이 ASCII 검사로 닫히는지 확인.
|
||||
- **8.2b 봉투 무결성**: Base64 재인코딩 대조 + 상수시간 digest 비교 + 계열별 크기 상한(§13).
|
||||
- **8.3 닫힌 계열**: sealed interface와 exhaustive switch가 새 레코드 추가 시 codec 갱신을 강제(§12). `ObjectControlStore`에 `list` 부재(§14).
|
||||
- **8.4 불변식**: 레코드 생성자가 digest를 재계산하고 상태 짝을 강제하는 지점 전수 확인(§14).
|
||||
|
||||
## 16. Sub-scope 02 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| — | **없음.** canonical 강제가 reader·writer·봉투 세 겹이고, 레코드 불변식이 값이 아니라 관계를 검증하며, 열거 API가 존재하지 않는다 | — |
|
||||
|
||||
## 17. Sub-scope 02 완료 조건
|
||||
|
||||
- denominator 25 / 25 FULL_READ (`151-...` OWNED FILES) — main 2,470 LOC 전수 판독
|
||||
- §8.1~§8.4 네 종 probe 수행
|
||||
- 관용 UTF-8 디코딩 후보를 ASCII 검사로 추적해 결함 아님으로 판정
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 18. Sub-scope 03 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **39 / 39 FULL_READ**
|
||||
> 범위: `kernel/**` 23 (1,463 LOC) + `codec/**` 7 (539 LOC) + 전용 test 9
|
||||
> 역할: provider SDK 없이 **예약 → 보류 효과 → 확인 → 단계 전진**을 정확한 CAS 위에서 돌리고, 모든 네임스페이스 키를 단일 인코더로 만든다
|
||||
|
||||
manifest와 probe: `evidence/raw/152-objectstorage-kernel-codec-probes.txt`.
|
||||
|
||||
## 19. Confirmed — 다섯 개의 닫힌 전이표가 있고 terminal이 진짜 terminal이다
|
||||
|
||||
`ObjectOperationStateMachine`이 publication·scan·reference·direct-grant·multipart 다섯 계열의 전이를 각각 switch로 적는다. terminal 처리가 계열마다 명시적이다.
|
||||
|
||||
| 계열 | terminal 처리 |
|
||||
|---|---|
|
||||
| publication | `current.terminal()`이면 즉시 거부 |
|
||||
| scan | `case CLEAN, MALICIOUS -> false`; `NOT_REQUIRED`는 어디로도 못 감 |
|
||||
| reference | `case PURGED -> false` (PUBLISHED → RETIREMENT_PENDING → RETIRED → PURGE_ELIGIBLE → PURGED) |
|
||||
| direct grant | terminal 집합 4종을 먼저 계산해 `!currentTerminal` 요구 |
|
||||
| multipart | terminal 집합 5종에 대해 동일 |
|
||||
|
||||
뒤 둘은 **branch 전이**(EXPIRED/ABORTED/FAILED/CORRUPT)를 별도로 허용해, 정상 사슬 어디서든 실패로 빠질 수 있되 terminal에서는 나올 수 없게 한다.
|
||||
|
||||
`requireNextRevision(current, next)`가 `next == current + 1`을 강제한다 — revision은 건너뛰지도 되돌아가지도 못한다. `ObjectOperationStateMachineTest`가 그 셋을 이름으로 고정한다: `publicationFollowsScanFreeAndScanRequiredPaths`, `terminalOutOfOrderAndStaleRevisionTransitionsFailClosed`, `independentStateFamiliesDoNotImplyEachOther`.
|
||||
|
||||
## 20. Confirmed — 응답 유실을 "의도를 먼저 적는" 방식으로 다룬다
|
||||
|
||||
`StagedObjectPublicationKernel.stage`의 순서가 핵심이다.
|
||||
|
||||
1. `operations.reserve(...)` — 제어 저장소에 조건부 create. 충돌하면 기존 레코드의 `requestFingerprint`를 비교해 **CONFLICT / REPLAY_TERMINAL / REPLAY_NON_TERMINAL**로 분류한다.
|
||||
2. `pendingEffect == null`이면 `markEffectSent(...)`로 **외부 mutation 전에** 의도를 durable하게 적는다 — kind(`DATA_PUT`), attemptId, 대상 증거 해시, 원하는 상태, precondition(`create-if-absent`), 요청 증거 다이제스트.
|
||||
3. `resolveOrCreate(providerOperation, producer)` — provider 호출.
|
||||
4. 성공하면 `confirmEffect(...)`, 그 다음에야 `advancePublication(DATA_UPLOADED)`.
|
||||
|
||||
`PendingObjectEffect`의 javadoc이 그 성격을 못박는다 — "Bounded, **non-secret** exact intent persisted before external mutation". 모든 필드가 길이 제한이 있고 대상은 해시로만 적힌다.
|
||||
|
||||
`ObjectOperationKernel.replace`는 read → `stored.record().equals(expected)` 비교 → `compareAndSet(key, mutation(stored.version(), replacement))` 순서다. 즉 낙관적 비교와 저장소 CAS를 겹쳐 쓴다. test `pendingEffectIsDurableBeforeIoAndResponseLossRemainsPhaseSpecific`와 `everyPendingMutationIsResolvedOnceWithoutBlindMutationReplay`가 이 성질을 고정한다 — 후자의 이름이 규칙을 그대로 말한다.
|
||||
|
||||
## 21. Confirmed — 모든 키가 단일 인코더에서 나오고 route를 벗어날 수 없다
|
||||
|
||||
`ObjectControlKeyCodec`과 `ObjectDataKeyCodec`이 각각 "Sole encoder"를 자칭하고, 저장소에서 `"control/v1/"`·`"data/v1/"` 리터럴은 이 두 파일에만 있다(`152-...` §8.3). 키 형태는 `control/v1/<family>/<route>/<shard>/<identity>`이고 shard는 identity의 SHA-256 앞 두 자리다.
|
||||
|
||||
route 격리가 구조적이다 — `requireMatchingRoute(route, routedIdentity)`가 routed identity의 route 구획을 파싱해 현재 route와 다르면 거부한다("routed identity belongs to a different route"). reference·session·stage handle 키 모두 이 검사를 지난다.
|
||||
|
||||
핸들 계열도 접두사로 분리된다 — `osh1`(stage), `osu1`(direct upload), `osm1`(multipart), `osv1`(version), `osr1`(reference). `ObjectNamespaceCodecTest.referenceAndHandleFamiliesRemainSeparated`가 그 분리를 고정하고, `dataKeyApiHasNoRawNameStringParameter`는 **API 서명 자체에 raw 이름 문자열이 없음**을 단언한다.
|
||||
|
||||
`CrockfordBase32`는 소문자 정규 알파벳(`0123456789abcdefghjkmnpqrstvwxyz` — I·L·O·U 제외)을 쓰고, 인코딩 후 남은 값이 있으면 거부한다("base32 output length is too small") — 잘림을 조용히 넘기지 않는다.
|
||||
|
||||
test에 property-based 검사가 있다 — `routeParserRejectsArbitraryNonCanonicalText(@ForAll String candidate)`(jqwik), `namespaceRejectsAliasesAndTraversalInputs`, 그리고 fingerprint codec에는 **golden vector**가 고정돼 있다(`canonicalIntentHasAFrozenGoldenVector`, `sameIntentIsStableAndEverySemanticChangeChangesTheFingerprint`).
|
||||
|
||||
`policySnapshotCodecIsCanonicalAndContainsNoCredentialSurface`는 정책 스냅샷에 자격증명 표면이 없음을 test로 고정한다.
|
||||
|
||||
## 22. P3/기록 — 보류 효과 전이가 `updatedAt`을 전진시키지 않는다
|
||||
|
||||
`ObjectOperationKernel`의 두 메서드가 새 시각 대신 기존 값을 쓴다(`152-...` §8.2d).
|
||||
|
||||
```java
|
||||
markEffectSent → current.withPendingEffect(effect, current.updatedAt())
|
||||
markResponseLost → current.withEffectCertainty(INDETERMINATE, current.updatedAt())
|
||||
```
|
||||
|
||||
`confirmEffect`와 `advancePublication`·`terminate`는 `now`를 받는다. 즉 **의도를 적은 시각과 응답 유실을 기록한 시각이 durable 레코드에 남지 않는다** — revision은 올라가지만 `updatedAt`은 이전 단계의 값 그대로다.
|
||||
|
||||
기능상 문제는 없다. revision이 순서를 주고, lease 만료 판정은 `ObjectPublicationHandoffRecord`가 자기 `leaseExpiresAt`로 따로 한다. 다만 "언제 이 mutation을 보냈는가"는 응답 유실 조사에서 가장 먼저 묻는 값이고, 지금은 레코드에서 답할 수 없다. **P3/기록.**
|
||||
|
||||
## 23. Negative-space probes — sub-scope 03
|
||||
|
||||
- **8.1 reachability**: `kernel`·`codec` 패키지의 leaf 밖 참조 0(exit=1).
|
||||
- **8.2 계약 ↔ 구현**: 다섯 전이표의 terminal 처리 전수 확인(§19). 보류 효과의 기록-호출-확인 순서를 호출 지점으로 추적(§20).
|
||||
- **8.2b 조건부 형제**: `confirmEffect`·`advancePublication`은 `now`를 받고 `markEffectSent`·`markResponseLost`는 받지 않음(§22).
|
||||
- **8.3 중복 mechanism**: 키 리터럴이 두 인코더에만 존재하고 route 격리가 파싱으로 강제됨(§21).
|
||||
- **8.4 test 커버리지**: 상태 기계·kernel·codec 각각에 이름이 규칙을 말하는 test가 있고, property-based 검사와 golden vector가 포함됨.
|
||||
|
||||
## 24. Sub-scope 03 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| **P3/기록** | `markEffectSent`·`markResponseLost`가 `updatedAt`을 전진시키지 않아, 의도를 적은 시각과 응답 유실 시각이 durable 레코드에 남지 않는다 | 응답 유실 조사 |
|
||||
|
||||
## 25. Sub-scope 03 완료 조건
|
||||
|
||||
- denominator 39 / 39 FULL_READ (`152-...` OWNED FILES)
|
||||
- §8.1~§8.4 네 종 probe 수행
|
||||
- 실행 probe 불필요 — 판정 지점이 정적으로 결정 가능
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 26. Sub-scope 04 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **40 / 40 FULL_READ**
|
||||
> 범위: `s3/**` main 26 (3,581 LOC) + 전용 test 14 (약 2,300 LOC)
|
||||
> 역할: AWS SDK v2를 이 패키지 안에 가두고, 클라이언트 정책·바인딩·조건부 제어 저장소·비동기 브리지를 **컴파일 시점에 전부 검증된 값**으로 만든다
|
||||
|
||||
manifest와 probe: `evidence/raw/153-objectstorage-s3-probes.txt`.
|
||||
|
||||
## 27. Confirmed — SDK 타입이 production에서 leaf를 벗어나지 않는다
|
||||
|
||||
`software.amazon.awssdk`를 참조하는 main 파일은 leaf 안에 19개이고, 그중 16개가 `s3/**`이다. 밖의 셋은 `ObjectStorageConfig`·`S3ObjectStorageAdapter`(둘 다 루트의 legacy 어댑터)와 `config/ObjectStorageCapabilityConfig`(legacy runtime이 S3 client를 만드는 지점)뿐 — 전부 README가 "deprecated compatibility only"로 선언한 경로다.
|
||||
|
||||
leaf 밖에서 SDK를 참조하는 파일은 **아키텍처 test 카탈로그와 빌드 파일뿐**이다(`153-...` §8.1) — `GraphQlReturnTypePolicy`, `WebForbiddenTypeCatalog`, `NotificationArchitectureTest`, 그리고 `application-core`의 `ObjectStorageArchitectureContractTest`. 즉 production 코드에서의 유출은 없고, 유출 금지가 다른 leaf의 금지 타입 목록으로도 지켜지고 있다.
|
||||
|
||||
## 28. Confirmed — 클라이언트 정책이 시간 예산의 정합성을 검사한다
|
||||
|
||||
`S3ClientPolicy.validate()`가 개별 값의 양수 여부를 넘어 **값들 사이의 관계**를 본다.
|
||||
|
||||
- per-attempt timeout < parent call timeout
|
||||
- 다섯 전송 timeout(connection·TLS·acquire·read·write)이 모두 per-attempt timeout 이하
|
||||
- `retryBaseDelay ≤ retryMaximumBackoff`
|
||||
- **재시도 최악 예산이 부모 호출 예산 안에 드는지**:
|
||||
`attemptTimeout × maxAttempts + maxBackoff × (maxAttempts − 1) ≤ apiCallTimeout`
|
||||
그리고 그 곱셈은 `ArithmeticException`을 잡아 "retry budget overflows"로 거부한다.
|
||||
|
||||
즉 "재시도를 다 해도 부모 예산을 못 넘는다"가 설정 검증으로 강제된다 — 설정만 보고는 알 수 없는 종류의 모순이다.
|
||||
|
||||
엔드포인트도 좁다 — scheme은 http/https만, userinfo·query·fragment 금지, 그리고 **평문 AWS 엔드포인트 금지**(`http` + `*.amazonaws.com` → 거부). 정적 자격증명은 access/secret가 **둘 다 있거나 둘 다 없어야** 한다. `toString()`은 자격증명을 `[REDACTED]`로 대체한다.
|
||||
|
||||
`S3AsyncClientFactoryTest`가 이 규칙들을 이름으로 고정한다 — `rejectsMissingNonPositiveAndContradictoryTimeoutPoolAndRetryPolicy`, `rejectsUnsafeEndpointAndPartialStaticCredentialsWithoutExposingSecrets`.
|
||||
|
||||
## 29. Confirmed — provider 타입마다 신원 규칙이 다르고, 둘 다 좁다
|
||||
|
||||
`S3ProviderBinding.compile`이 `AWS_S3_GENERAL_PURPOSE`와 `MINIO_COMMUNITY_2024_01_16`을 다르게 검증한다.
|
||||
|
||||
| | AWS | MinIO |
|
||||
|---|---|---|
|
||||
| expectedOwner | **정확히 12자리 숫자 필수** | 설정하면 거부("cannot claim an AWS expected owner") |
|
||||
| endpoint override | **금지**("not part of the qualified profile") | **필수**, canonical HTTPS만 |
|
||||
| addressing | `virtual-hosted` 강제 | `virtual-hosted` 또는 `path-style` |
|
||||
| credentials | `default-chain` 허용 | **환경변수 참조 필수**("MinIO requires explicit environment credential references") |
|
||||
|
||||
그리고 provider 타입과 무관하게 `autoCreateBucket || publicAcl`이면 거부한다 — "runtime provisioning and public ACLs are forbidden". 런타임이 버킷을 만들거나 공개 ACL을 붙이는 경로가 설정 단계에서 닫힌다.
|
||||
|
||||
## 30. Confirmed — mutation의 불확실성이 보존된다
|
||||
|
||||
`S3ProviderErrorMapper.map(failure, mutation)`의 첫 분기가 규칙이다. `AwsServiceException`이 **아니면**(즉 서버가 답하지 않았으면) 그리고 mutation이면 → `Failure.INDETERMINATE, authoritative=false`. 서버가 답한 경우에만 error code/status로 분류하고 `authoritative = normalized != INDETERMINATE`로 표시한다.
|
||||
|
||||
상태 코드 매핑의 기본값도 같은 방향이다 — 알 수 없는 status는 `mutation ? INDETERMINATE : UNKNOWN`. 즉 "실패했으니 재시도"라는 순진한 독법이 구조적으로 불가능하다.
|
||||
|
||||
`S3ConditionalObjectControlStore`도 같은 규칙을 제어 평면에 적용한다 — `INDETERMINATE`면 즉시 실패하지 않고 **정확한 GET으로 실제 상태를 읽어** 기대값과 비교한 뒤 결정한다. test `droppedCreateResponseResolvesByExactGetAndDigestComparison`과 `staleWriterConflictAndCorruptControlNeverBecomeAbsence`, `exact404IsTheOnlyAbsentRead`가 그 세 갈래를 고정한다. 마지막 이름이 특히 중요하다 — 부재로 해석되는 유일한 신호가 정확한 404다.
|
||||
|
||||
## 31. Confirmed — 논리 다이제스트와 provider 체크섬을 분리해 둘 다 대조한다
|
||||
|
||||
`S3ChecksumPolicy`가 사용자 메타데이터 키 `ca-logical-sha256`에 논리 SHA-256을 넣고, provider의 네이티브 `checksumSHA256`도 함께 요청한다. `requireMatchingEvidence`가 **둘 다** 기대값과 같은지 확인하고 하나라도 어긋나면 `CONTENT_MISMATCH`다.
|
||||
|
||||
`S3ObjectEvidenceMapper.fromHead`는 그 위에 세 가지를 더 요구한다 — content length가 바인딩 상한 안, `serverSideEncryption == AES256`, ETag 존재. 그리고 provider version id와 ETag는 `HeadEvidence`에 담겨 **adapter-private**로 남는다(`S3ConditionalRequestMapper`가 `ifMatch`/`versionId` 조건으로만 쓴다).
|
||||
|
||||
## 32. Confirmed — 비동기 브리지가 단일 구독·유계 버퍼·역압을 지킨다
|
||||
|
||||
`S3AsyncRequestBodyBridge`는 `subscribed.compareAndSet(false, true)`로 단일 구독을 강제하고, **downstream 수요를 기다린 뒤에야** 한 청크를 보유한다. 누적 길이가 선언 길이를 넘으면 즉시 `ObjectChunkWriteException`, 완료 시 관측 identity가 기대와 다르면 provider 실패다. 취소와 예산 만료를 매 청크 경계에서 확인한다.
|
||||
|
||||
`S3AsyncResponseBodyBridge`는 SDK 콜백을 논블로킹으로 두고 동기 소비자를 adapter 소유 worker에서 호출한다. `S3ConditionalObjectControlStore`의 `BoundedControlTransformer`도 누적 바이트가 64 KiB를 넘으면 중단한다.
|
||||
|
||||
test가 이 성질을 직접 잡는다 — `streamsOnceOffTheSubscriberThreadWithOneChunkOfProducerLead`, `cancellationAndDigestMismatchFailWithoutProducerReplay`, `truncatedAndOversizedSdkChunksFailClosed`.
|
||||
|
||||
## 33. Negative-space probes — sub-scope 04
|
||||
|
||||
- **8.1 reachability**: SDK 참조를 leaf 안팎으로 전수 조사(§27). 밖은 아키텍처 test 카탈로그뿐.
|
||||
- **8.2 계약 ↔ 구현**: 정책의 시간 예산 상호 검증(§28), provider 타입별 신원 규칙(§29).
|
||||
- **8.2b 조건부 형제**: AWS와 MinIO가 같은 필드에 대해 **반대 방향** 규칙을 갖고 둘 다 강제됨(§29).
|
||||
- **8.3 중복 mechanism**: 논리 다이제스트와 provider 체크섬을 **의도적으로 이중화**하고 둘 다 대조(§31) — 중복이 아니라 교차 검증.
|
||||
- **8.4 불확실성 보존**: 비권위적 실패의 분류와 정확한 GET을 통한 해소(§30). 단일 구독·유계 버퍼(§32).
|
||||
|
||||
## 34. Sub-scope 04 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| — | **없음.** SDK가 패키지에 갇혀 있고, 정책이 값이 아니라 값들 사이의 관계를 검증하며, mutation 불확실성이 분류에서 보존되고 정확한 GET으로만 해소된다 | — |
|
||||
|
||||
## 35. Sub-scope 04 완료 조건
|
||||
|
||||
- denominator 40 / 40 FULL_READ (`153-...` OWNED FILES) — main 3,581 LOC 전수 판독
|
||||
- §8.1~§8.4 네 종 probe 수행
|
||||
- 실행 probe 불필요 — 판정 지점이 정적으로 결정 가능하고 test가 각 성질을 이름으로 고정
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 36. Sub-scope 05 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **25 / 25 FULL_READ**
|
||||
> 범위: `direct/**` 13 + `multipart/**` 5 (main 18, 1,988 LOC) + 전용 test 7
|
||||
> 역할: 브라우저가 provider와 직접 주고받는 bearer grant의 상태 기계와, part 원장·완료 검증
|
||||
|
||||
manifest와 probe: `evidence/raw/154-objectstorage-direct-multipart-probes.txt`.
|
||||
|
||||
## 37. 이 sub-scope의 설계 — 비밀은 durable하지 않고, 승인은 명시적으로 닫힌다
|
||||
|
||||
durable record가 **비밀을 담지 않는다**는 것이 출발점이다. `DirectTransferSessionRecord`의 한 줄 javadoc이 그것이다 — "Durable non-secret direct-transfer session state; bearer material is deliberately absent." 저장되는 것은 generation·제약 다이제스트·서명 시각·만료·credential revision·reference revision뿐이고, presigned URI와 서명 헤더는 **process-local 캐시**에만 남는다. 프로세스가 재시작하면 이미 발급된 grant는 재현되지 않고 `"issued direct grant bearer material is unavailable after process restart"`로 명시적으로 실패한다 — 조용히 새로 서명해서 두 번째 bearer를 만드는 대신이다.
|
||||
|
||||
상태 전이도 CAS로 순서가 고정된다. `SESSION_RESERVED → GRANT_PREPARED → GRANT_ISSUED → DATA_UPLOADED`이고, test 이름이 그 순서를 그대로 못박는다 — `preparedCasPrecedesSigningAndIssuedCasPrecedesReturningTheBearerGrant`. 서명 **전에** prepared가 durable해야 하고, bearer를 **반환하기 전에** issued가 durable해야 한다.
|
||||
|
||||
multipart 쪽에서 가장 흥미로운 것은 완료 시점의 **admission drain**이다. `DirectMultipartCompletionVerifier.requireAdmissionDrained`는 provider가 "controlled ingress가 비었다"고 권위 있게 말해 주지 않으면, `마지막 grant 만료 + 검증된 시계 오차 + 최대 in-flight 지평` 이 지나기 전에는 완료를 거부한다. 이미 발급된 part PUT이 아직 날아가고 있을 수 있기 때문이다. test 이름이 `completionHorizonRejectsWhileIssuedPartRequestsMayStillArrive`와 `lateGrantAdmissionRejectsAfterCompletionFence` — 양방향 모두 고정돼 있다.
|
||||
|
||||
part 원장은 provider의 LIST 순서에 의존하지 않는다. `MultipartPartLedger.ordered(n)`은 1..n을 **정확한 키로** 하나씩 읽고 하나라도 없으면 "ledger has a gap"이다. `record(...)`는 같은 증거면 `REPLAYED`, 다른 증거면 conflict — 재시도가 원장을 다시 쓰지 못한다. 그리고 `DirectMultipartCompletionVerifier.requireExactLedger`가 클라이언트가 제출한 receipt token 목록과 원장을 **위치까지 일치**시키고 논리 길이 합계를 기대값과 대조한다.
|
||||
|
||||
로그 유출 차단도 한 곳에 모여 있다 — `PresignedGrantRedactor.redact(URI)`는 인자를 아예 쓰지 않고 `[REDACTED_PRESIGNED_URI]`를 돌려주며, 헤더는 **키 이름만** 정렬해 노출한다. test가 `bearerUriAndSignedValuesAreNeverRendered`로 잡는다.
|
||||
|
||||
## 38. P2 — 직접 multipart의 마지막 part는 grant를 받을 수 없다
|
||||
|
||||
`S3ClientPolicy.requirePartSize(long partBytes, boolean finalPart)`는 **마지막이 아닌** part에만 5 MiB 하한을 적용한다(`MINIMUM_NON_FINAL_PART_BYTES = 5 * 1024 * 1024`). S3의 실제 규칙과 같다.
|
||||
|
||||
leaf 안에 이 정책의 호출 지점이 셋인데, 마지막 하나만 다르다.
|
||||
|
||||
| 호출 지점 | `finalPart` 인자 |
|
||||
|---|---|
|
||||
| `MultipartUploadPlan.partBytes:61` | `partNumber == partCount` — 계산된 값 |
|
||||
| `S3ManagedMultipartProvider:80` | `finalPart` — 호출자가 넘긴 값 |
|
||||
| `DirectMultipartCoordinator.createPartGrant:163` | **`false` 하드코딩** |
|
||||
|
||||
그리고 `PartUploadGrantRequest`에는 "이것이 마지막 part"라는 필드가 **없다**(operationKey · sessionId · partNumber · exactPartLength · expectedPartDigest · requestedTtl · budget · cancellation). 그러므로 coordinator가 그 사실을 알아낼 방법도 없다 — `maximumParts()`는 상한일 뿐 실제 part 수가 아니고, 실제 수는 `completeMultipart`에서 `request.partTokens().size()`로 비로소 정해진다.
|
||||
|
||||
**결과.** `DirectMultipartUploadPort.createPartGrant`는 5 MiB 미만의 part에 대해 항상 `"S3 multipart part size is outside the supported range"`로 거부한다. 즉
|
||||
|
||||
- 총 크기가 5 MiB 미만인 객체는 직접 multipart로 **전혀** 올릴 수 없다(part 1개 = 마지막 part).
|
||||
- 고정 part 크기 + 나머지라는 통상적인 클라이언트 분할(예: 12 MiB를 5+5+2로)은 마지막 grant 요청에서 실패한다. 성공하려면 클라이언트가 **모든** part를 5 MiB 이상으로 재분할해야(5+7) 하는데, 그 요구는 port 계약 어디에도 적혀 있지 않다.
|
||||
|
||||
같은 파일이 `MultipartUploadPlan`에서 마지막 part 구분을 정확히 계산하고 있으므로 규칙을 모르는 상태가 아니다. 직접 경로에서만 정보가 요청 타입에 실려 오지 않아 보수적인 상수로 대체된 것이다. **판정: P2.** 수정은 `PartUploadGrantRequest`에 마지막 part 표시를 추가하고 그 값을 넘기는 것, 또는 start 요청에 정확한 part 수를 고정해 `partNumber == exactPartCount`로 유도하는 것이다.
|
||||
|
||||
test는 이 경계를 건드리지 않는다 — `DirectMultipartCoordinatorTest`의 세 케이스는 모두 5 MiB 이상 part만 쓴다. 반대로 `S3SdkApiCharacterizationTest:93-94`는 `requirePartSize(MIN, false)`와 `requirePartSize(MIN - 1, true)` 둘 다 통과함을 확인한다 — 정책 자체는 옳고, 직접 경로의 호출만 어긋나 있다는 것을 이 test가 오히려 증명한다.
|
||||
|
||||
## 39. P2 — 서명된 grant의 endpoint 검증이 upload 경로에만 있다
|
||||
|
||||
`DirectTransferPolicy`의 host allowlist는 이 sub-scope의 유일한 endpoint 방어다. 그런데 실제 provider가 서명해 돌려준 URI를 그 allowlist에 대조하는 호출은 **한 곳뿐**이다.
|
||||
|
||||
```java
|
||||
// createUploadGrant
|
||||
DirectGrantProvider.DirectGrantMaterial material = provider.signUpload(current.session());
|
||||
policy.validateSignedGrant(material.requestUri(), current.session().expiresAt()); // ← 검증
|
||||
|
||||
// createDownloadGrant
|
||||
DirectGrantProvider.DirectGrantMaterial material = provider.signDownload(prepared.session(), published);
|
||||
// ← 대응하는 validateSignedGrant 없음
|
||||
|
||||
// createPartGrant (DirectMultipartCoordinator)
|
||||
DirectGrantProvider.DirectGrantMaterial material = provider.signPart(session, grant.record());
|
||||
// ← 대응하는 validateSignedGrant 없음. 이 coordinator는 DirectTransferPolicy를 아예 갖고 있지 않다
|
||||
```
|
||||
|
||||
`planGrant(...)`도 `validateEndpoint`를 부르지만, 두 호출자 모두 `policy.planningEndpoint()`를 넘긴다. 그 메서드는 `"https://" + allowedHosts` 중 사전순 첫 host를 조립한 값이므로 **정의상 항상 통과**한다. 결국 allowlist가 실제 URI에 대해 힘을 갖는 지점은 upload 경로 한 곳뿐이고, 브라우저에 그대로 건네지는 download bearer와 multipart part bearer는 host·scheme·userinfo 검사를 통과하지 않는다.
|
||||
|
||||
덧붙여 `validateSignedGrant(URI, Instant expectedExpiry)`는 `expectedExpiry`를 **null 검사만** 하고 쓰지 않는다. 서명 재료(`DirectGrantMaterial.expiresAt()`)와 세션이 선언한 만료가 어긋나도 걸리지 않으며, 호출자는 세션 쪽 만료를 응답에 실어 보낸다. 즉 "서명된 grant를 검증한다"는 이름이 실제로는 host 검사 하나다.
|
||||
|
||||
**판정: P2.** 현재 노출은 없다(§40: 미배선). 그러나 이 세 경로는 모두 같은 종류의 값 — 브라우저에 넘길 bearer URI — 를 다루는 형제이고, 방어가 하나에만 있다. 수정은 `validateSignedGrant`를 세 경로 모두에서 호출하고(그러려면 `DirectMultipartCoordinator`도 policy를 받아야 한다), `expectedExpiry`를 실제로 `material.expiresAt()`과 대조하는 것이다.
|
||||
|
||||
## 40. Confirmed — 직접 전송 subsystem은 미배선이고, README가 그 사실을 정확히 적는다
|
||||
|
||||
`DirectTransferCoordinator`·`DirectMultipartCoordinator`·`DirectTransferPolicy`를 이름으로 부르는 곳은 `direct/**` 패키지 **밖에 하나도 없다**(`154-...` §8.1b, 매치 0). `DirectObjectUploadPort`·`DirectObjectDownloadGrantPort`·`DirectMultipartUploadPort` 세 application port는 production 구현이 등록되지 않는다.
|
||||
|
||||
README가 이것을 두 문장으로 선언한다 — "Direct transfer, multipart, quarantine, retention, and production reconciliation cards remain R0."와 "no direct-grant port is registered." 이 leaf에서 반복해 확인한 정직함이고, 미배선 자체는 결함이 아니다.
|
||||
|
||||
## 41. P2 — 그러나 R0 경계가 문서에만 있고 compile 경로에서 닫히지 않는다
|
||||
|
||||
미배선이 선언돼 있는데도, **설정은 그 capability를 계속 받아들이고 런타임은 자격증명을 쥔 presigner를 실제로 만든다.**
|
||||
|
||||
1. `ObjectCapabilityRequirement.DIRECT_UPLOAD` / `DIRECT_MULTIPART`는 destination 요구사항으로 선언 가능하고, `ObjectStorageBindingCompiler:206-207`이 그것을 provider capability로 번역한다.
|
||||
2. `S3ProviderBinding.compileProfiles`는 **MinIO에 대해서만** 이 두 capability 주장을 거부한다("the exact MinIO release cannot claim native conditional managed mutation support"). **AWS profile에는 대응하는 거부가 없다.**
|
||||
3. 그러면 `S3ObjectStorageProviderContribution:112-150`이 `directUploadEnabled || directMultipartEnabled`일 때 `presignerFactory.apply(clientPolicy)`로 `S3Presigner`를 할당하고 `S3DirectTransferProvider` / `S3DirectMultipartProvider`를 만들어 `SelectedObjectStorageProviderFactory`에 넣는다.
|
||||
4. 그리고 그 둘을 factory에서 **꺼내 가는 코드가 없다**(`154-...` §8.1c, 매치 0).
|
||||
|
||||
즉 AWS binding에서 `direct-upload`를 요구하는 배포는 — startup을 통과하고, presigner를 할당하고, 두 provider를 조립하고, **직접 전송 port는 여전히 하나도 얻지 못한다.** README가 말한 R0는 사실이지만, 그 사실을 강제하는 것은 문서뿐이다.
|
||||
|
||||
이 leaf 안에 정반대의 사례가 있어서 대비가 분명하다 — `filesystem-local-dev`는 `prod`/`production` 프로파일에서 **compile 시점에 거부**되고, MinIO의 capability 과대 주장도 compile 시점에 거부된다. 같은 파일이 같은 종류의 "이 조합은 자격이 없다"를 AWS + DIRECT_* 에 대해서만 하지 않는다.
|
||||
|
||||
**판정: P2.** 데이터 위험은 없다 — 없는 port는 호출될 수 없다. 위험은 (a) 운영자가 켰다고 믿는 기능이 없다는 것과 (b) 아무도 쓰지 않는 서명 자격증명 핸들이 프로세스 수명 동안 살아 있다는 것이다. 수정은 셋 중 하나다: coordinator를 조건부로 조립하거나, R0인 동안 `DIRECT_UPLOAD`/`DIRECT_MULTIPART` 요구를 compile 단계에서 provider 종류와 무관하게 거부하거나, capability가 켜져도 presigner를 만들지 않도록 조립을 뒤로 미루거나.
|
||||
|
||||
## 42. P3/기록 — 선언만 되고 강제되지 않는 정책 항목
|
||||
|
||||
- **`DirectTransferPolicy.maximumOutstandingGenerations`.** 생성자가 1..16 범위를 검사하지만 읽는 곳이 없다(`154-...` §8.4, 매치 2 = 선언과 검증뿐). `DirectTransferSessionRecord.grantGeneration`도 항상 1이다 — `planGrant`가 `new DirectGrantGeneration(1, ...)`로 고정하고 generation을 올리는 경로가 없다. "outstanding generation을 몇 개까지 허용한다"는 정책이 표현돼 있으나 generation 자체가 재발급되지 않으므로 지금은 의미를 갖지 않는다.
|
||||
- **`DirectTransferCorsPolicy`.** "infrastructure must apply before direct admission"이라고 적혀 있으나 production 소비자가 없고, 이 값을 어디에 어떻게 반영해야 하는지 알려 주는 배선도 없다. 유일한 참조는 `DirectTransferCorsContractTest`다. 브라우저 계약을 코드로 고정해 둔 것 자체는 유용하나, 적용 주체가 코드 밖(인프라)이라는 사실은 README에 없다.
|
||||
|
||||
## 43. Negative-space probes — sub-scope 05
|
||||
|
||||
- **8.1 reachability**: coordinator·policy 모두 패키지 밖 참조 0(§40). README가 그 상태를 선언(§40). 그러나 capability는 설정에서 살아 있고 presigner는 실제로 할당된다(§41).
|
||||
- **8.2 조건부 형제 ①**: `requirePartSize`의 세 호출 지점 중 하나만 `finalPart`를 하드코딩(§38).
|
||||
- **8.2b 조건부 형제 ②**: `validateSignedGrant`가 upload 경로에만 있고 download·part 경로에는 없다(§39).
|
||||
- **8.2c 조건부 형제 ③**: `S3ProviderBinding`이 DIRECT_* 주장을 MinIO에서만 거부(§41).
|
||||
- **8.3 중복 mechanism**: 두 coordinator가 각자 독립된 process-local `ConcurrentHashMap` bearer 캐시를 갖는다. 둘 다 경계가 없고(무한 증가 가능) 만료된 항목을 청소하지 않으며, 제거는 성공적인 완료/확인 경로에서만 일어난다. 발급 후 완료되지 않은 세션의 재료는 프로세스 수명 동안 남는다 — 미배선이므로 지금 노출은 없고, 두 곳이 같은 방식으로 같은 성질을 갖는다는 점에서 우연이 아니라 공통 설계다. **P3/기록.**
|
||||
- **8.4 문서/선언 drift**: 강제되지 않는 정책 항목 2종(§42).
|
||||
|
||||
## 44. Sub-scope 05 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| **P2** | `DirectMultipartCoordinator:163`이 `requirePartSize(..., false)`를 하드코딩하고 `PartUploadGrantRequest`에 마지막 part 표시가 없어, 5 MiB 미만 part에 grant를 발급할 수 없다 — 총 5 MiB 미만 객체는 직접 multipart 불가 | 현재 미배선; 배선 시 모든 통상적 클라이언트 분할 |
|
||||
| **P2** | 서명된 bearer URI의 host allowlist 검증이 upload 경로에만 있고 download·multipart part 경로에는 없다. `validateSignedGrant`의 `expectedExpiry`는 검사되지 않는다 | 현재 미배선; 배선 시 브라우저로 나가는 두 bearer |
|
||||
| **P2** | AWS binding에서 `DIRECT_UPLOAD`/`DIRECT_MULTIPART` 요구가 compile을 통과해 presigner를 할당하지만, 그것을 쓰는 port가 없다. MinIO에는 같은 주장을 막는 검사가 있다 | AWS provider + direct capability 요구 배포 |
|
||||
| **P3/기록** | `maximumOutstandingGenerations`가 검증만 되고 읽히지 않으며 grant generation은 항상 1이다 | 정책/조립 |
|
||||
| **P3/기록** | `DirectTransferCorsPolicy`는 production 소비자 0이고, 적용 주체가 인프라라는 사실이 README에 없다 | 문서 |
|
||||
| **P3/기록** | 두 coordinator의 process-local bearer 캐시가 무경계·무만료이며, 미완료 세션의 재료는 프로세스 수명 동안 남는다 | 배선 시 |
|
||||
|
||||
## 45. Sub-scope 05 완료 조건
|
||||
|
||||
- denominator 25 / 25 FULL_READ (`154-...` OWNED FILES)
|
||||
- §8.1~§8.4 네 종 probe 수행, 조건부 형제 비교는 3건 독립 수행
|
||||
- 세 finding 모두 정적으로 결정 가능(호출 인자 상수 · 호출 부재 · 조립 경로)하여 실행 probe 불필요
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 46. Sub-scope 06 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **43 / 43 FULL_READ**
|
||||
> 범위: `filesystem/**` 6 + `maintenance/**` 8 + `readiness/**` 8 + `provider/**` 4 + 루트 4 (main 30, 2,420 LOC) + test 12 + resource 1
|
||||
> 역할: local-dev provider, legacy 채택(adoption) 경로, readiness 증거 타입, provider-neutral 계약, 그리고 deprecated 루트 어댑터
|
||||
|
||||
manifest와 probe: `evidence/raw/155-objectstorage-platform-readiness-probes.txt`.
|
||||
|
||||
## 47. §6의 forward reference 해소 — readiness 레지스트리는 실재하고 test가 강제한다
|
||||
|
||||
sub-scope 01에서 `build.gradle:46`이 `docs/registries/object-storage-readiness.yaml`을 `test` 태스크의 입력으로 선언하는데 leaf 소스에 그 파일명을 부르는 곳이 없다는 점을 미해결로 남겼다. 해소한다.
|
||||
|
||||
파일은 **저장소 루트에 실재한다**(`docs/registries/object-storage-readiness.yaml`). build.gradle은 파일명을 시스템 프로퍼티 `objectstorage.readiness.registry`로 전달하고, leaf test `ObjectStorageReadinessRegistryTest:131`이 그 프로퍼티를 읽어 YAML을 파싱한다. 그래서 소스 grep에 파일명이 잡히지 않았다.
|
||||
|
||||
그 test가 하는 일이 이 sub-scope에서 가장 강한 governance 장치다.
|
||||
|
||||
- 레지스트리 키가 `schema_version`과 `claims` **둘뿐**임을 확인하고,
|
||||
- claim의 card id 집합이 `ObjectStorageCapabilityCard.cardIds()`(고정된 9종)와 **정확히 일치**함을 확인하고,
|
||||
- 각 claim에 `validate(knownProviders, availableTasks)`를 돌려 provider·필수 태스크·한계 문구·R2/R3 만료를 검사하고,
|
||||
- **R1인 카드가 정확히 두 개**(`managed-upload-single`, `managed-download`)임을 확인하고,
|
||||
- direct 3종 · quarantine · retention · reconciliation **여섯 카드가 모두 R0**임을 확인한다.
|
||||
|
||||
README가 산문으로 적은 "Direct transfer, multipart, quarantine, retention, and production reconciliation cards remain R0"가 여기서 기계 검사가 된다. YAML 파일 자신도 헤더 주석에 소유 test 두 개(`ContractRegistrySchemaGovernanceTest` = 저장소 소유, `ObjectStorageReadinessRegistryTest` = 의미 소유)를 적어 둔다.
|
||||
|
||||
`ObjectStorageCapabilityEvidence.validate`의 개별 규칙도 좁다 — `filesystem-local-dev`는 R2/R3를 주장할 수 없고, `requiredTasks`는 비어 있을 수 없으며 전부 실재하는 태스크여야 하고, `limitations`도 비어 있을 수 없다. "한계를 적지 않은 readiness 주장"이 표현 불가능하다.
|
||||
|
||||
## 48. §41 보강 — 레지스트리는 문서 주장을 얼어붙히지만 런타임 설정 경로는 덮지 않는다
|
||||
|
||||
§41에서 "R0 경계가 문서에만 있다"고 적었다. §47을 반영해 정확히 다시 말한다.
|
||||
|
||||
R0 경계는 **문서 주장에 대해서는** 기계 검사된다(§47). 그러나 그 검사의 대상은 `docs/registries/object-storage-readiness.yaml`이고, `KNOWN_PROVIDERS`는 `filesystem-local-dev` 하나다. 운영자가 `app.object-storage` 설정에 AWS provider용 qualification profile을 쓰면서 `DIRECT_UPLOAD` capability를 주장하는 경로는 이 레지스트리를 **거치지 않는다**. `S3ProviderBinding.compileProfiles`가 그 주장을 MinIO에 대해서만 거부하므로, AWS + DIRECT_* 조합은 여전히 compile을 통과하고 presigner를 할당한다(§41).
|
||||
|
||||
따라서 §41의 판정은 유지되고 오히려 선명해진다 — 이 저장소에는 "이 카드는 R0"를 강제하는 장치가 이미 있는데, 런타임 설정 경로가 그 장치의 사정권 밖에 있다.
|
||||
|
||||
## 49. P2 — APPLY를 켜는 설정은 있고, 승인을 검증하는 bean은 없다
|
||||
|
||||
legacy 채택은 이 leaf에서 가장 권한이 센 동작이다. 원시 locator로 legacy 네임스페이스를 읽어 관리 네임스페이스에 **발행**한다. 그래서 설계가 detached 2인 승인을 요구한다 — `Ed25519LegacyAdoptionApprovalVerifier`가 서로 다른 두 승인자의 Ed25519 서명을 검증하고, 문서의 destination/epoch/operationId/manifest·네임스페이스 다이제스트가 요청과 일치하는지, 유효창이 최대 7일 안인지까지 본다. 구현은 촘촘하다 — `LegacyAdoptionApprovalCodec.decode`는 길이 프레이밍 이진 코덱이고 **decode 후 재인코딩해 원본 바이트와 같아야만** 통과한다(비정규 인코딩 거부).
|
||||
|
||||
그런데 조립이 비대칭이다.
|
||||
|
||||
| | 켜는 방법 | bean |
|
||||
|---|---|---|
|
||||
| APPLY 실행 경로 | `app.object-storage.legacy-adoption.enabled=true`, `mode=APPLY` | `ObjectStorageLegacyMigrationConfig:30`이 `LegacyObjectAdoptionPort`를 만든다 |
|
||||
| 승인 검증 | **없음** | **없음** — `Ed25519LegacyAdoptionApprovalVerifier`를 `main`에서 생성하는 코드가 저장소 전체에 0(`155-...` §8.1) |
|
||||
|
||||
`LegacyObjectAdoptionSettings`는 `enabled`·`mode`·`reportPath`·`reviewedManifestPath`·`reviewedManifestSha256`·`batchSize`·`operationTimeout`을 노출하고, APPLY일 때 reviewed manifest 경로와 sha256을 요구한다. 그러나 **신뢰 승인자 키, 키 id, 승인 유효기간에 해당하는 설정 항목이 하나도 없다.** 검증기를 켤 방법이 설정에 없다.
|
||||
|
||||
adapter 쪽 `LegacyObjectAdoptionService.requireApproval`은 `request.approval()` 객체의 **필드 동등성만** 검사한다 — operationKey · manifest · 네임스페이스 다이제스트 · destination · 유효창. 서명은 보지 않는다. 그리고 `LegacyObjectAdoptionApproval`은 application-core의 공개 생성자를 가진 값 타입이다(검증기 자신이 `new`로 만든다).
|
||||
|
||||
의도된 조립은 sample-portfolio가 보여 준다 — `AdoptLegacyPosterImageUseCase.authorizeApply`가 **호출자가 준 approval을 버리고** `approvals.verify(document, request)`의 결과로 요청을 다시 만든다. 즉 "application에서 검증하고 adapter에서 적용한다"가 설계다. 그 use case 역시 어디에도 배선돼 있지 않다.
|
||||
|
||||
**판정: P2.** 지금 우회가 열려 있는 것은 아니다 — 검증기 bean이 없으면 `AdoptLegacyPosterImageUseCase`를 쓰는 fork의 컨텍스트는 시작에 실패하고, 그것은 조용하지 않은 실패다. 위험은 다른 쪽이다: **설정 한 줄로 켜지는 절반과 손으로 배선해야 하는 절반**이 있고, 켜지는 쪽이 권한이 센 쪽이다. `enabled=true, mode=APPLY`를 켠 fork가 use case를 쓰지 않고 port를 직접 부르면 adapter의 검사는 필드 동등성뿐이다. 수정은 `ObjectStorageLegacyMigrationConfig`가 승인자 키를 설정에서 읽어 `Ed25519LegacyAdoptionApprovalVerifier` bean도 함께 만들되, APPLY 모드에서 그 bean이 없으면 startup을 거부하는 것이다.
|
||||
|
||||
`LegacyObjectAdoptionServiceTest`에 test가 **하나뿐**이고 그것이 `reportOnlyInspectsExactEvidenceAndPerformsNoMutation`이라는 점도 같은 방향을 가리킨다 — APPLY 경로에는 서비스 수준 test가 없다. 검증기 자체는 `LegacyAdoptionApprovalVerifierTest`가 두 케이스(`verifiesCanonicalExactBindingWithTwoDistinctTrustedApprovers`, `rejectsDuplicateApproverAndAnyBindingTamper`)로 덮는다.
|
||||
|
||||
## 50. P3 — nonce replay 경계가 결과를 읽고 버린다
|
||||
|
||||
```java
|
||||
LegacyAdoptionApprovalReplayStore.ClaimResult claim = replayStore.claim(replay);
|
||||
var published = publications.publish(request.publicationRequest(), inspected.producer());
|
||||
if (claim != ClaimResult.TERMINAL_REPLAY) { replayStore.markTerminal(...); }
|
||||
```
|
||||
|
||||
`ClaimResult`는 `CLAIMED` / `EXACT_REPLAY` / `TERMINAL_REPLAY` 셋인데, 어느 값이든 **발행은 그대로 진행된다.** claim 결과가 바꾸는 것은 terminal 기록을 쓸지 여부뿐이다. 인터페이스 javadoc은 자신을 "Durable compare-and-set nonce replay boundary"라고 부르지만, 경계로서 무엇도 막지 않는다.
|
||||
|
||||
실제 피해는 제한적이다 — 발행이 operation-key 기반 멱등이므로 이미 소진된 nonce로 다시 들어와도 결과는 `REPLAYED`이고 두 번째 객체가 생기지 않는다. 그래서 P3다. 그러나 (a) 이름이 약속하는 것과 다르고, (b) `markTerminal`에 넘기는 `expectedRevision`이 항상 `replay.revision()` = 0이라 CAS 인자로서도 고정값이며, (c) 승인 문서의 nonce가 "한 번만 쓰인다"는 성질은 이 코드로는 보장되지 않는다. 수정은 `TERMINAL_REPLAY`에서 발행 전에 거부하는 것이다.
|
||||
|
||||
## 51. Confirmed — local-dev provider의 경로 방어와 publication
|
||||
|
||||
`LocalObjectPathGuard`는 이 저장소에서 반복해 본 강한 형태다 — root 정규화 + `startsWith` 봉쇄 + root 자신 거부에 더해, 부모 경로를 **root부터 한 세그먼트씩 내려가며** 심링크와 비디렉터리를 거부하고(`createParentsWithoutLinks` / `rejectExistingLinks`), 대상 자신도 심링크면 거부한다. control key는 `control/v1/` 접두사 + `[a-z0-9._/-]+` + `//`·`/./`·`/../` 금지 + 세그먼트별 재검사다. 그리고 control 레코드는 물리 파일명에 `.record`를 붙인다 — 객체 저장소가 허용하는 `reference`와 `reference/lifecycle` 쌍이 파일시스템에서 파일/디렉터리 충돌을 일으키지 않도록. 논리 키는 그대로 유지된다.
|
||||
|
||||
발행은 **배타적 하드링크**다. `LocalDevObjectDataStore.create`가 임시 파일에 쓰고 `channel.force(true)` 후 `Files.createLink(target, temporary)`를 하며, `FileAlreadyExistsException`을 `CONFLICT`로, `UnsupportedOperationException`을 "local filesystem cannot prove immutable create"로 번역한다 — 하드링크를 지원하지 않는 파일시스템에서 조용히 약한 방식으로 내려가지 않는다. 앞선 `Files.exists(NOFOLLOW)` 검사는 빠른 경로일 뿐이고 배타성은 `createLink`가 준다. POSIX면 소유자 읽기 전용 권한을 씌운다.
|
||||
|
||||
test가 이것들을 이름으로 잡는다 — `traversalAbsoluteUnicodePercentAndSymlinkEscapesAreRejected`, `exclusiveCreateRaceHasOneWinner`, `injectedDiskFailureLeavesNoFinalOrTemporaryData`, `restartInspectsCommittedDataWithoutReplayingProducer`, `corruptControlRecordRemainsPresentAndNeverAppearsAbsent`, `createsRestrictivePermissionsWherePosixIsSupported`. 마지막에서 두 번째가 특히 이 leaf의 규칙이다 — 손상된 control 레코드는 **부재로 보이지 않는다**.
|
||||
|
||||
`LocalDevObjectControlStore`도 자신의 한계를 클래스 javadoc에 적는다 — "Single-process local create/CAS store; it deliberately does not claim multi-node linearizability." 그리고 descriptor의 capability 표가 `MULTI_NODE_LINEARIZABLE_CAS`와 `POWER_LOSS_DURABILITY`를 `UNSUPPORTED`로 명시한다. `ObjectStorageProviderContract:184`의 `unsupportedOptionalCapabilitiesAreDeclaredRatherThanSkipped`가 그 선언을 강제한다 — 지원하지 않는 것을 test에서 건너뛰는 대신 선언하게 만든다.
|
||||
|
||||
## 52. P3/기록 — 같은 capability 표가 두 벌 있다
|
||||
|
||||
`filesystem-local-dev`의 capability 표가 두 곳에 **독립적으로 하드코딩**돼 있다.
|
||||
|
||||
- `FilesystemLocalDevProviderContribution.capabilitySupport()` — `describe(settings)`가 쓰는 것. sub-scope 01에서 확인했듯 이것이 **compile 시점 권위**다.
|
||||
- `LocalDevObjectStorageProvider.capabilitySupport()` — 런타임 `descriptor()`가 쓰는 것.
|
||||
|
||||
현재 둘은 같다(각각 `Support.SUPPORTED` 6개, 같은 여섯 capability). 그러나 공유하는 상수도, 한쪽이 다른 쪽을 부르는 구조도 없다. 한쪽만 고치면 compile이 허용한 capability와 런타임이 주장하는 capability가 갈라지고, 그 불일치를 잡는 test는 없다. **P3/기록.** 수정은 표를 한 곳에 두고 양쪽이 그것을 부르는 것이다.
|
||||
|
||||
## 53. P3/기록 — deprecated 루트 어댑터에는 형제에게 있는 방어가 없다
|
||||
|
||||
루트의 네 파일(`ObjectStorageSettings` · `ObjectStorageConfig` · `FilesystemObjectStorageAdapter` · `S3ObjectStorageAdapter`)은 README가 "deprecated compatibility only... never back the new semantic ports"로 선언한 경로이고, `LegacyObjectStorageActivationGuard`가 명시적 opt-in을 요구하며 canonical 네임스페이스와의 혼용을 예외로 막는다. 그 선까지는 규율이 있다.
|
||||
|
||||
그 아래에서는 canonical 경로에 있는 방어가 하나씩 없다.
|
||||
|
||||
| | canonical | legacy 루트 |
|
||||
|---|---|---|
|
||||
| `autoCreateBucket` | `S3ProviderBinding:268`이 `autoCreateBucket \|\| publicAcl`을 **거부** | `ObjectStorageSettings:46` 기본값 **`true`**, startup에서 실제로 버킷 생성 |
|
||||
| 평문 엔드포인트 | AWS 도메인 + `http` **거부** | 기본값 `http://localhost:9000` |
|
||||
| 심링크 | `LocalObjectPathGuard`가 root·부모·대상 전부 거부 | `FilesystemObjectStorageAdapter.resolve`는 `normalize()` + `startsWith`만 — 심링크 검사 **0** |
|
||||
| production 프로파일 | `filesystem-local-dev`를 `prod`/`production`에서 거부 | `LegacyObjectStorageActivationGuard`에 프로파일 검사 **없음** |
|
||||
|
||||
`LegacyObjectStorageConfigTest.autoCreateBucketTrueProvisionsTheBucketWhileTheS3BeanIsCreated`가 그 기본값을 의도된 동작으로 고정하고 있으므로 우발적 잔재는 아니다. 그래서 P3/기록이다 — deprecated 경로가 자신의 과거 의미를 보존하는 것은 정당하고, 위험한 것은 그 경로가 production 프로파일에서 아무 제지 없이 켜진다는 점 하나다. `FilesystemObjectStorageAdapter`의 심링크 미검사는 baseDir 안에 심링크를 심을 수 있는 로컬 접근을 전제하므로 노출이 좁다.
|
||||
|
||||
## 54. Negative-space probes — sub-scope 06
|
||||
|
||||
- **8.1 reachability**: `maintenance/**`는 설정 flag로 배선된다(§49). `readiness/**`의 세 타입은 leaf test가 소비한다(§47) — 앞선 grep이 test 패키지까지 제외해 0으로 보였던 것을 바로잡았다.
|
||||
- **8.1b forward reference 해소**: build.gradle → 시스템 프로퍼티 → leaf test → 루트 YAML(§47).
|
||||
- **8.2 조건부 형제 ①**: 켜지는 절반과 손배선 절반의 비대칭(§49).
|
||||
- **8.2b 조건부 형제 ②**: legacy 루트 vs canonical의 네 가지 방어 차이(§53).
|
||||
- **8.3 중복 mechanism**: capability 표 두 벌(§52).
|
||||
- **8.4 선언 대비 강제**: nonce replay 경계가 결과를 쓰지 않음(§50). readiness 주장의 한계 문구 강제(§47).
|
||||
|
||||
## 55. Sub-scope 06 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| **P2** | `legacy-adoption.enabled=true, mode=APPLY`는 설정으로 켜지는데 `Ed25519LegacyAdoptionApprovalVerifier` bean은 저장소 어디에도 없고 승인자 키 설정 항목도 없다. adapter의 승인 검사는 필드 동등성뿐 | APPLY를 켜고 port를 직접 부르는 fork |
|
||||
| **P3** | `LegacyAdoptionApprovalReplayStore.claim` 결과가 발행을 막지 않는다 — `TERMINAL_REPLAY`도 그대로 발행 | APPLY 경로 |
|
||||
| **P3/기록** | `filesystem-local-dev` capability 표가 contribution과 provider에 두 벌로 하드코딩 | 표가 갈라질 때 |
|
||||
| **P3/기록** | deprecated 루트 경로에 production 프로파일 검사가 없고 `autoCreateBucket` 기본값이 `true`, 평문 엔드포인트가 기본값, 심링크 검사 없음 | legacy opt-in 배포 |
|
||||
|
||||
## 56. Sub-scope 06 완료 조건
|
||||
|
||||
- denominator 43 / 43 FULL_READ (`155-...` OWNED FILES: main 30 / test+resource 13)
|
||||
- §8.1~§8.4 probe 수행, 조건부 형제 비교 2건, 중복 mechanism 1건
|
||||
- §6의 readiness 레지스트리 forward reference 해소(§47) 및 그에 따른 §41 보강(§48)
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 57. Sub-scope 07 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **6 / 6 FULL_READ**
|
||||
> 범위: 전용 qualification source set 3종 6파일 (546 LOC)
|
||||
> 역할: 실제 provider를 상대로 "무엇이 되고 무엇이 안 되는지"를 증거로 고정하는 lane
|
||||
|
||||
manifest와 probe: `evidence/raw/156-objectstorage-qualification-lanes-probes.txt`.
|
||||
|
||||
| source set | 파일 | 성격 |
|
||||
|---|---|---|
|
||||
| `objectStorageMinioContractTest` | `MinioManagedObjectContractTest` 291 · `MinioDirectTransferContractTest` 25 | 전자는 실제 MinIO 컨테이너 기동 |
|
||||
| `objectStorageMinioFaultTest` | `MinioManagedObjectFaultTest` 147 · `MinioDirectTransferFaultTest` 32 | 전자는 MinIO + Toxiproxy |
|
||||
| `objectStorageAwsQualificationTest` | `AwsS3ManagedCommonSubsetQualificationTest` 27 · `AwsS3DirectTransferQualificationTest` 24 | 환경변수 권한 검사만 |
|
||||
|
||||
세 lane 모두 `registerStrictQualificationTest`로 등록되고 `requiredClasses`로 클래스 이름이 고정된다 — 클래스를 지우거나 이름을 바꾸면 lane 등록이 실패한다. 설명 문구도 "non-skipping"을 명시한다. 즉 "Docker 없으면 조용히 skip"이 이 lane들에는 없다.
|
||||
|
||||
## 58. Confirmed — MinIO의 조건부 create가 **작동하지 않는다**는 것을 실측으로 증명한다
|
||||
|
||||
`MinioManagedObjectContractTest`가 digest로 고정된 MinIO 이미지를 띄우고 다음을 순서대로 확인한다.
|
||||
|
||||
1. `PutObject` + `If-None-Match: *` + SHA-256 체크섬 + AES256 → 성공.
|
||||
2. **같은 키에 다시** `PutObject` + `If-None-Match: *` → **또 성공하고 내용이 덮인다.** 변수 이름이 결론이다: `overwrittenDespiteCreateOnlyCondition`.
|
||||
3. `HeadObject`로 길이·`ca-logical-sha256` 메타데이터·SSE가 보존됨을 확인, `GetObject` + `If-Match` + `Range`로 부분 읽기가 정확함을 확인.
|
||||
4. control 키에 대해서도 같은 일이 일어남을 확인(`overwrittenControlCreate`).
|
||||
5. 그러나 **stale `If-Match`는 HTTP 412**로 정확히 거부됨을 확인.
|
||||
6. 두 번째 test에서 low-level multipart가 동작하되 `CompleteMultipartUpload` + `If-None-Match: *`가 **기존 객체를 덮는다**는 것을 확인.
|
||||
|
||||
이 여섯 줄이 sub-scope 04에서 본 `S3ProviderBinding`의 MinIO 거부 규칙 — "the exact MinIO release cannot claim native conditional managed mutation support" — 의 근거다. 즉 코드의 거부가 의견이 아니라 이 lane의 실측 결과다.
|
||||
|
||||
결과는 `src/test/resources/object-storage/minio-provider-evidence.json`에 고정돼 있고, 그 한계 문구가 그대로 사람이 읽을 문장이다 — "PutObject If-None-Match was accepted and overwrote an existing object", "CompleteMultipartUpload accepted If-None-Match despite a pre-existing target and overwrote it", 그리고 범위를 좁히는 세 줄("local single-node container evidence only", "not AWS evidence", "not production TLS or deployment-topology evidence").
|
||||
|
||||
`MinioManagedObjectFaultTest`는 Toxiproxy로 양방향 대역폭을 0으로 만들고, 실패가 **5초 안에 유계로** 발생하는지와 복구 후 같은 객체가 그대로인지를 본다 — `connectionCutProducesABoundedFailureAndRecoveryWithoutMutationReplay`. 컨테이너 두 개가 모두 try-with-resources 안에 있고 `@SuppressWarnings("resource")`에 그 이유가 주석으로 적혀 있다.
|
||||
|
||||
`MinioDirectTransfer*` 두 파일이 provider를 부르지 않는 것도 근거가 있다 — javadoc이 "No bearer or multipart mutation is attempted because qualification proved that create-only PUT and create-only multipart completion are ignored by this provider identity." 즉 이미 증명된 결과를 근거로 mutation 행렬에 들어가지 않겠다는 선언이고, 두 파일은 그 결정을 evidence 파일에 대한 assertion으로 얼려 둔다.
|
||||
|
||||
## 59. P3/기록 — AWS lane은 환경변수만 검사하고 통과한다
|
||||
|
||||
`AwsS3ManagedCommonSubsetQualificationTest`와 `AwsS3DirectTransferQualificationTest`의 본문은 전부 다음 형태다.
|
||||
|
||||
```java
|
||||
Map<String, String> environment = System.getenv();
|
||||
assertThat(environment.get("OBJECT_STORAGE_AWS_QUALIFICATION_ENABLED")).isEqualTo("true");
|
||||
assertThat(environment.get("OBJECT_STORAGE_AWS_BUCKET")).isNotBlank();
|
||||
assertThat(environment.get("OBJECT_STORAGE_AWS_REGION")).isNotBlank();
|
||||
assertThat(environment.get("OBJECT_STORAGE_AWS_EXPECTED_OWNER")).matches("[0-9]{12}");
|
||||
```
|
||||
|
||||
AWS를 호출하는 코드는 한 줄도 없다. 권한이 없으면 실패한다는 점에서 fail-closed이지만, **권한이 있어도 아무것도 증명하지 않는다.** lane 이름은 `objectStorageAwsQualificationTest`이고 설명은 "Runs only with explicit protected AWS sandbox authority and exact inputs"이므로, CI에서 이 lane이 초록으로 통과한 것을 AWS가 자격 검증되었다는 증거로 읽을 여지가 있다.
|
||||
|
||||
지금 실제 위험은 낮다 — readiness 레지스트리의 `KNOWN_PROVIDERS`는 `filesystem-local-dev` 하나뿐이라 AWS 주장 row 자체가 없고(§47), README도 "It does not prove … production credentials/TLS/IAM/encryption, S3 response-loss behavior, or R2 readiness"라고 적는다. 그래서 **P3/기록**이다. 다만 `CapabilityEvidenceSource.CI_QUALIFICATION`이라는 값이 존재하고 §41에서 본 대로 AWS profile은 capability를 주장할 수 있으므로, fork가 이 초록 lane을 근거로 삼는 경로가 구조적으로 열려 있다. 수정은 lane 본문을 실제 sandbox 호출로 채우거나, 채우기 전까지 클래스/lane 이름에 "authority-gate"임이 드러나게 하는 것이다.
|
||||
|
||||
## 60. P3/기록 — provider 신원 문자열이 세 곳에 독립적으로 적혀 있다
|
||||
|
||||
정확한 MinIO 신원이 세 곳에 문자열로 존재한다.
|
||||
|
||||
| 위치 | 값 |
|
||||
|---|---|
|
||||
| `S3ProviderType.MINIO_COMMUNITY_2024_01_16:8` | `s3-compatible-minio-community-release-2024-01-16t16-07-38z` |
|
||||
| `S3ProviderVersion:7` | `release-2024-01-16t16-07-38z-sdk-2.30.0` |
|
||||
| `minio-provider-evidence.json:3-4` | 위 두 값과 **정확히 동일** |
|
||||
|
||||
컨테이너 이미지 digest도 세 곳(contract test, fault test, evidence JSON)에 같은 값으로 적혀 있고 현재 모두 일치한다. 그러나 이 일치를 검사하는 test는 없다 — evidence JSON에 대한 assertion은 `status`와 profile 이름에 대한 부분 문자열 확인뿐이다(§58). production 바인딩이 인정하는 신원과 자격 검증이 실제로 돌아간 신원이 갈라져도 아무도 알려주지 않는다. **P3/기록.** 수정은 evidence JSON을 파싱해 `S3ProviderType`/`S3ProviderVersion`의 canonical 값 및 lane의 이미지 digest와 대조하는 assertion 하나를 추가하는 것이다.
|
||||
|
||||
## 61. Negative-space probes — sub-scope 07
|
||||
|
||||
- **8.1 lane 등록**: 세 lane 모두 strict·non-skipping·`requiredClasses` 고정(§57).
|
||||
- **8.2 조건부 형제**: 여섯 파일 중 provider를 실제로 부르는 것은 **둘**(`MinioManagedObject*`). 나머지 넷 중 둘은 근거 있는 freeze(§58), 둘은 근거 없는 통과(§59).
|
||||
- **8.3 중복 신원**: 신원 문자열·이미지 digest 3중 기재, 교차 검사 없음(§60).
|
||||
- **8.4 증거의 자기 한정**: evidence JSON의 `limitations` 7줄이 범위를 스스로 좁힌다(§58).
|
||||
|
||||
## 62. Sub-scope 07 완료 조건
|
||||
|
||||
- denominator 6 / 6 FULL_READ (`156-...` OWNED FILES)
|
||||
- §8.1~§8.4 probe 수행
|
||||
- lane 실행은 Docker와 보호된 AWS sandbox 권한이 필요하므로 이 분석에서 실행하지 않음 — 대신 lane이 무엇을 주장하고 그 주장이 어디에 소비되는지를 정적으로 추적
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 63. 모듈 ledger 정합
|
||||
|
||||
| # | 범위 | main | test | 기타 | 합 | FULL_READ | probe |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | governance + `config/**` | 19 | 5 | 4 | 28 | 28 | `149`, `150` |
|
||||
| 2 | `control/**` | 24 | 1 | – | 25 | 25 | `151` |
|
||||
| 3 | `kernel/**` + `codec/**` | 30 | 9 | – | 39 | 39 | `152` |
|
||||
| 4 | `s3/**` | 26 | 14 | – | 40 | 40 | `153` |
|
||||
| 5 | `direct/**` + `multipart/**` | 18 | 7 | – | 25 | 25 | `154` |
|
||||
| 6 | `filesystem/**`+`maintenance/**`+`readiness/**`+`provider/**`+루트 | 30 | 12 | 1 | 43 | 43 | `155` |
|
||||
| 7 | qualification source set 3종 | – | – | 6 | 6 | 6 | `156` |
|
||||
| | **TOTAL** | **147** | **48** | **11** | **206** | **206** | **7 / 7** |
|
||||
|
||||
coverage ledger: `FULL_READ` **206** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / 미분류 **0**.
|
||||
|
||||
## 64. 모듈 findings
|
||||
|
||||
| # | 우선순위 | finding | 위치 | reachability |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **P2** | 직접 multipart의 마지막 part에 grant를 발급할 수 없다 — `requirePartSize(..., false)` 하드코딩 + 요청 타입에 마지막 part 표시 없음 | §38 | 미배선; 배선 시 통상적 클라이언트 분할 전부 |
|
||||
| 2 | **P2** | 서명된 bearer URI의 allowlist 검증이 upload 경로에만 있고 download·multipart part 경로에는 없다. `expectedExpiry`는 검사되지 않는다 | §39 | 미배선; 배선 시 브라우저로 나가는 두 bearer |
|
||||
| 3 | **P2** | AWS binding에서 `DIRECT_UPLOAD`/`DIRECT_MULTIPART` 요구가 compile을 통과해 presigner를 할당하지만 그것을 쓰는 port가 없다. MinIO에는 같은 주장을 막는 검사가 있다 | §41, §48 | AWS provider + direct capability 요구 배포 |
|
||||
| 4 | **P2** | `legacy-adoption.enabled=true, mode=APPLY`는 설정으로 켜지는데 Ed25519 승인 검증기 bean과 승인자 키 설정이 없다 — adapter의 승인 검사는 필드 동등성뿐 | §49 | APPLY를 켜고 port를 직접 부르는 fork |
|
||||
| 5 | **P3** | `filesystem-local-dev` production 거부가 `prod`/`production` 두 리터럴에만 걸려 있다 | §5 | 프로파일 이름을 바꾼 fork |
|
||||
| 6 | **P3** | nonce replay 경계가 `ClaimResult`를 읽고 버린다 — `TERMINAL_REPLAY`도 그대로 발행 | §50 | APPLY 경로 |
|
||||
| 7 | P3/기록 | `markEffectSent`·`markResponseLost`가 `updatedAt`을 전진시키지 않는다 | §22 | 응답 유실 조사 |
|
||||
| 8 | P3/기록 | `maximumOutstandingGenerations`가 검증만 되고 읽히지 않으며 grant generation은 항상 1 | §42 | 정책/조립 |
|
||||
| 9 | P3/기록 | `DirectTransferCorsPolicy`의 production 소비자 0, 적용 주체가 인프라라는 사실이 README에 없음 | §42 | 문서 |
|
||||
| 10 | P3/기록 | 두 coordinator의 process-local bearer 캐시가 무경계·무만료 | §43 | 배선 시 |
|
||||
| 11 | P3/기록 | `filesystem-local-dev` capability 표가 두 벌로 하드코딩 | §52 | 표가 갈라질 때 |
|
||||
| 12 | P3/기록 | deprecated 루트 경로에 production 프로파일 검사 없음, `autoCreateBucket` 기본 `true`, 평문 엔드포인트 기본, 심링크 검사 없음 | §53 | legacy opt-in 배포 |
|
||||
| 13 | P3/기록 | AWS qualification lane이 환경변수만 검사하고 통과한다 | §59 | 초록 lane을 증거로 삼는 fork |
|
||||
| 14 | P3/기록 | provider 신원 문자열·이미지 digest가 세 곳에 독립 기재, 교차 검사 없음 | §60 | 신원이 갈라질 때 |
|
||||
|
||||
**결함 아님으로 판정한 후보 3건** — `RoutingObjectReadAdapter`의 무방비 `split`(§7: `ObjectReference` 생성자 검증이 막는다), control 레코드의 관용 UTF-8 디코딩(§16: printable ASCII 검사가 닫는다), sub-scope 02·04의 zero-finding 결과 자체.
|
||||
|
||||
**이월 해소 1건** — sub-scope 01의 readiness registry forward reference를 §47에서 확정(파일은 저장소 루트에 실재하고 leaf test가 시스템 프로퍼티로 읽어 9장 카드와 R0/R1 수준을 강제한다).
|
||||
|
||||
## 65. 이 모듈에서 반복해서 나타난 패턴
|
||||
|
||||
- **불확실성을 보존한다.** mutation의 비권위적 실패는 `INDETERMINATE`로 분류되고 정확한 GET으로만 해소된다(§30). 손상된 control 레코드는 부재로 보이지 않는다(§51). 부재로 읽히는 유일한 신호는 정확한 404다.
|
||||
- **값이 아니라 값들 사이의 관계를 검증한다.** 재시도 최악 예산이 부모 호출 예산 안에 드는지(§28), provider 타입마다 반대 방향의 신원 규칙(§29), 레코드 상태와 증거 완전성의 결합(§37).
|
||||
- **표현 불가능성으로 막는다.** canonical key 문법, 논리 다이제스트와 provider 체크섬의 이중 대조(§31), 길이 프레이밍 이진 코덱의 재인코딩 검사(§49).
|
||||
- **자기 한정을 문서에 적는다.** README의 R0 선언과 evidence JSON의 `limitations` 7줄(§58), "does not claim multi-node linearizability"(§51).
|
||||
- **그리고 이 모듈의 P2 넷은 전부 같은 모양이다** — 설정 표면이나 계약이 절반만 조립돼 있다. 마지막 part 표시가 요청 타입에 없고(§38), 검증이 세 경로 중 하나에만 있고(§39), capability가 compile을 통과하는데 소비자가 없고(§41), APPLY는 설정으로 켜지는데 검증기는 손배선이다(§49). 개별 구현의 품질과 **조립의 대칭성** 사이에 일관된 격차가 있다.
|
||||
|
||||
## 66. 모듈 완료 조건
|
||||
|
||||
- denominator 206 / 206 FULL_READ, `STRUCTURAL_ONLY` 0, `EXCLUDED` 0, 미분류 0 (§63)
|
||||
- 7개 하위 범위 전부 §8.1~§8.4 네 종 negative-space probe 수행, evidence `149`~`156` 8건 생성
|
||||
- 후보 finding 3건을 코드로 추적해 결함 아님으로 판정, 이월 1건 해소
|
||||
- 소스 미변경 — 이 분석은 어떤 애플리케이션 코드도 수정하지 않았다
|
||||
|
||||
## 67. 검증
|
||||
|
||||
`evidence/raw/157-objectstorage-suite-verification.txt`.
|
||||
|
||||
```
|
||||
$ cd src && LANG=C.UTF-8 LC_ALL=C.UTF-8 ./gradlew :adapter:outbound:objectstorage:test --console=plain -q
|
||||
GRADLE_EXIT=0
|
||||
classes=47 tests=140 failures=0 errors=0 skipped=0
|
||||
|
||||
$ git status --short
|
||||
changed=0
|
||||
```
|
||||
|
||||
skip 0이라는 점을 기록해 둔다 — 이 leaf의 `:test`에는 조건부로 비활성화되는 test가 없다. Docker나 보호된 자격이 필요한 것들은 애초에 별도 source set으로 분리돼 있고(§57), `:test`에 섞여 들어와 조용히 건너뛰지 않는다. 그 세 lane(`objectStorageMinioContractTest`·`objectStorageMinioFaultTest`·`objectStorageAwsQualificationTest`)은 이 분석에서 실행하지 않았다.
|
||||
|
||||
작업 트리는 변경 0이다 — 이 분석 과정에서 애플리케이션 소스를 수정하거나 임시 파일을 남기지 않았다.
|
||||
|
||||
## Source anchors
|
||||
|
||||
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **82개** (main 64 · test 10 · 기타 8).
|
||||
|
||||
```
|
||||
src/adapter/outbound/objectstorage/build.gradle
|
||||
src/config/architecture/modules.json (adapter-outbound-objectstorage 항목)
|
||||
|
||||
main:
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/FilesystemObjectStorageAdapter.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageConfig.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/ObjectStorageSettings.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/S3ObjectStorageAdapter.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/CrockfordBase32.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectControlKeyCodec.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectDataKeyCodec.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/LegacyObjectAdoptionSettings.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/LegacyObjectStorageActivationGuard.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageBindingCompiler.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageCapabilityAssembler.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageLegacyMigrationConfig.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageMaintenanceCapabilityConfig.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageProviderContribution.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/ObjectStorageScanMaintenanceConfig.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectDirectGrantAdapter.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/RoutingObjectReadAdapter.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/config/SelectedObjectStorageProviderFactory.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/CanonicalJsonReader.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ControlRecordSupport.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlRecord.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectControlStore.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectOperationRecord.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectPublicationHandoffRecord.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/ObjectStagedObjectRecord.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/control/UnsupportedObjectControlSchemaException.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCompletionVerifier.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCoordinator.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCoordinator.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCorsPolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferPolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferSessionRecord.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/direct/PresignedGrantRedactor.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/FilesystemLocalDevProviderContribution.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectControlStore.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectDataStore.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalDevObjectStorageProvider.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/filesystem/LocalObjectPathGuard.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationKernel.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationStateMachine.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/PendingObjectEffect.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/StagedObjectPublicationKernel.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/Ed25519LegacyAdoptionApprovalVerifier.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalCodec.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalReplayStore.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionService.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartPartLedger.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/multipart/MultipartUploadPlan.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/CapabilityEvidenceSource.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityCard.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageCapabilityEvidence.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncRequestBodyBridge.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncResponseBodyBridge.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ChecksumPolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ClientPolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalObjectControlStore.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ConditionalRequestMapper.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectMultipartProvider.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3DirectTransferProvider.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ObjectEvidenceMapper.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderBinding.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderErrorMapper.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderType.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3ProviderVersion.java
|
||||
|
||||
test:
|
||||
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/LegacyObjectStorageConfigTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/codec/ObjectNamespaceCodecTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectMultipartCoordinatorTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/direct/DirectTransferCorsContractTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/kernel/ObjectOperationStateMachineTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyAdoptionApprovalVerifierTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/maintenance/LegacyObjectAdoptionServiceTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/readiness/ObjectStorageReadinessRegistryTest.java
|
||||
src/test/java/dev/caskeleton/adapter/outbound/objectstorage/s3/S3AsyncClientFactoryTest.java
|
||||
src/test/resources/object-storage/minio-provider-evidence.json
|
||||
|
||||
기타:
|
||||
docs/registries/object-storage-readiness.yaml
|
||||
src/build.gradle
|
||||
src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3DirectTransferQualificationTest.java
|
||||
src/objectStorageAwsQualificationTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/AwsS3ManagedCommonSubsetQualificationTest.java
|
||||
src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferContractTest.java
|
||||
src/objectStorageMinioContractTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectContractTest.java
|
||||
src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioDirectTransferFaultTest.java
|
||||
src/objectStorageMinioFaultTest/java/dev/caskeleton/adapter/outbound/objectstorage/qualification/MinioManagedObjectFaultTest.java
|
||||
|
||||
해석되지 않은 인용 (11종) — 외부 타입·문서상 약칭 등:
|
||||
application.yml
|
||||
evidence/raw/149-objectstorage-module-inventory.txt
|
||||
evidence/raw/150-objectstorage-config-activation-probes.txt
|
||||
application-prod.yml
|
||||
evidence/raw/151-objectstorage-control-probes.txt
|
||||
evidence/raw/152-objectstorage-kernel-codec-probes.txt
|
||||
evidence/raw/153-objectstorage-s3-probes.txt
|
||||
evidence/raw/154-objectstorage-direct-multipart-probes.txt
|
||||
evidence/raw/155-objectstorage-platform-readiness-probes.txt
|
||||
evidence/raw/156-objectstorage-qualification-lanes-probes.txt
|
||||
evidence/raw/157-objectstorage-suite-verification.txt
|
||||
|
||||
```
|
||||
@@ -0,0 +1,837 @@
|
||||
# 11 · adapter-outbound-httpclient 완전 해부
|
||||
|
||||
> 상태: COMPLETE — 2026-08-31 재검증
|
||||
> 기준 revision(최초 분석): `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
|
||||
> 재검증 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` · 리프 소스 변경 **0** (`EVD-333`)
|
||||
> 분석 범위: `src/adapter/outbound/httpclient` · Gradle `:adapter:outbound:httpclient`
|
||||
> SSOT owner: `adapter-outbound-httpclient`
|
||||
> integration/family document: `analysis/00-project-overview.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity · denominator · coverage ledger
|
||||
|
||||
- registered leaf id: `adapter-outbound-httpclient`
|
||||
- canonical state `analysisFile`: `analysis/11-adapter-outbound-httpclient.md` (이 문서)
|
||||
- source path: `src/adapter/outbound/httpclient`
|
||||
- registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract", "adapter-outbound-support"]`
|
||||
- registry `runtime_memberships`: `["app-bootstrap"]`
|
||||
- 재검증 증거: `EVD-332`(§51 진단 교정), `EVD-333`(소스 드리프트 0)
|
||||
|
||||
**고정 섹션 골격과의 대응.** 이 문서는 leaf 안에 7개 하위 범위가 있어 하위 범위별로 번호를 이어 쓴다.
|
||||
고정 골격의 각 역할은 다음 절이 맡는다 — 재검증은 번호를 바꾸는 대신 대응을 명시한다.
|
||||
|
||||
| 고정 골격 | 이 문서 |
|
||||
|---|---|
|
||||
| §0 SSOT identity / denominator / coverage ledger | 이 절 + §52 |
|
||||
| 하위 범위 denominator | §1, §10, §19, §27, §34, §41, §45 |
|
||||
| §12 negative-space probes 4종 | §7, §16, §24, §31, §38, §48 (하위 범위별) |
|
||||
| §14 evidence table | §55 + `EVD-332`, `EVD-333`, evidence `167`~`175` |
|
||||
| §16 확인한 것 / 확인하지 못한 것 | §55 |
|
||||
| §17 손볼 것 (P1/P2/P3) + 확인된 설계 | §53 |
|
||||
|
||||
tracked file **370개** — main 260 (15,004 LOC), test 62 (6,049), testkit source set 35 (2,754), `httpClientPerformanceTest` 7 (495), `jmh` 2 (130), governance 4. 총 약 24.4k LOC.
|
||||
|
||||
```json
|
||||
{ "id": "adapter-outbound-httpclient",
|
||||
"gradle_path": ":adapter:outbound:httpclient",
|
||||
"allowed_dependencies": ["domain-core", "application-core", "shared-contract", "adapter-outbound-support"],
|
||||
"runtime_memberships": ["app-bootstrap"] }
|
||||
```
|
||||
|
||||
cache-redis와 같은 형태다 — 설계 문서는 이 플랫폼을 **19개 Gradle 모듈**로 나누지만 "This repository's fail-closed module registry outranks that layout, so the module boundaries are packages under `dev.caskeleton.adapter.outbound.httpclient` and `HttpClientModuleBoundaryTest` enforces the design's module dependency table."
|
||||
|
||||
main 패키지 배치(260):
|
||||
|
||||
| 계층 | 패키지 | 파일 |
|
||||
|---|---|---|
|
||||
| 복원력 | `resilience` | 39 |
|
||||
| 프로파일/조립 | `profile` | 29 |
|
||||
| 공개 API | `api/error` 23 · `api/result` 9 · `api/body` 7 · `api/operation` 6 · `api` 5 | 50 |
|
||||
| 보안 | `security` 21 · `auth` 18 | 39 |
|
||||
| 게이트웨이 | `restclient` 19 · `webclient` 17 | 36 |
|
||||
| 서비스/동적 | `service` 14 · `dynamic` 11 · `observation` 6 · `migration` 4 | 35 |
|
||||
| 전송 | `transport` 10 · `reactor` 6 · `apache` 5 · `jdk` 4 · `http3` 4 · `spring7` 3 | 32 |
|
||||
|
||||
`build.gradle`이 이 저장소에서 가장 긴 근거 주석을 갖는다. 몇 가지가 특히 이 leaf의 성격을 보여 준다.
|
||||
|
||||
- **HTTP/3가 `compileOnly`인 이유** — 이전에는 `implementation`이어서 "the whole QUIC/HTTP-3/QPACK stack on every deployment's runtime classpath — megabytes and an attack surface — to serve a feature the Stable starter never auto-configures."
|
||||
- **Jackson 3가 optional이 아닌 이유** — `RestClientResponseReader.defaultConverters()`가 Spring 7 컨버터를 만드는데 생성자 서명에 타입이 없어 javac가 못 잡았고, Jackson 3가 test/jmh 클래스패스에만 있어서 "the reader worked in every test and would have thrown `NoClassDefFoundError` in any deployment that did not happen to have Jackson 3 from somewhere else."
|
||||
- **Resilience4j는 실행 원시연산만** — "HTTP retry *eligibility* is owned by this module (design D-09) and **never delegated to a generic retry library**."
|
||||
- **testkit이 별도 source set인 이유** — 세 lane이 소비하는데 그중 하나만 test lane이고, `jmh`가 `sourceSets.test.output`을 참조하면 IDE가 모델링하지 못해 "every testkit reference in the benchmarks was an unresolved type in the editor while the build was green."
|
||||
- **lane 5개 중 둘이 `failOnNoDiscoveredTests`를 잃었던 기록** — "the cross-transport contract suite and the SSRF/credential-leak suite would each have reported success on discovering nothing. Declaring the lanes removes the opportunity: the convention has no opt-out."
|
||||
- **`check`가 무엇을 게이트하는지** — 특수 lane들이 "existed but hung off nothing: `check` ran only `test`". 지금은 hermetic한 넷(contract·security·BlockHound·spring62 surface)이 `check`에 붙어 있고, Docker가 필요한 fault lane과 머신 의존적인 performance lane은 의도적으로 빠져 있다.
|
||||
|
||||
### 하위 범위 ledger
|
||||
|
||||
| # | 범위 | main | test | 기타 | 합 | 상태 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | governance + `profile/**` — 프로파일 검증 · 런타임 레지스트리 · 조립 | 29 | 2 | 4 | 35 | **COMPLETE** |
|
||||
| 2 | `api/**` — error · result · body · operation · 루트 | 50 | 5 | – | 55 | **COMPLETE** |
|
||||
| 3 | `resilience/**` — 재시도 자격 · 회로 · 격벽 · 파이프라인 | 39 | 8 | – | 47 | **COMPLETE** |
|
||||
| 4 | `restclient/**` + `webclient/**` — 블로킹/리액티브 게이트웨이 | 36 | 10 | – | 46 | **COMPLETE** |
|
||||
| 5 | `security/**` + `auth/**` — SSRF 방어 · TLS 재료 · 자격증명 | 39 | 7 | – | 46 | **COMPLETE** |
|
||||
| 6 | `service` + `dynamic` + `observation` + `migration` + contract/architecture test | 35 | 21 | – | 56 | **COMPLETE** |
|
||||
| 7 | 전송 6종 + testkit / performance / jmh source set | 32 | 9 | 44 | 85 | **COMPLETE** |
|
||||
| | **TOTAL** | **260** | **62** | **48** | **370** | **7 / 7** |
|
||||
|
||||
manifest: `evidence/raw/167-httpclient-module-inventory.txt`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sub-scope 01 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **35 / 35 FULL_READ**
|
||||
> 범위: governance 4 + `profile/**` main 29 (1,271 LOC) + 전용 test 2
|
||||
> 역할: Named Client Profile의 fail-closed startup 검증과 런타임 세대(generation) 레지스트리
|
||||
|
||||
manifest와 probe: `evidence/raw/168-httpclient-profile-probes.txt`.
|
||||
|
||||
## 2. `ClientProfileValidator` — 34개 위반 코드가 각각 과거 사고를 적는다
|
||||
|
||||
이 저장소에서 본 가장 조밀한 설정 검증기다. `validate(profile, environment)`가 11개 검사 그룹을 돌리고 결과를 정렬해 "a configuration error reports deterministically across runs and machines"를 보장한다.
|
||||
|
||||
특히 이 leaf에서만 보이는 태도가 하나 있다 — **바인딩은 되지만 어떤 전송에도 닿지 않는 설정을 무시하지 않고 거부한다.**
|
||||
|
||||
> "Three of them had no consumer anywhere: `timeout.dns`, `proxy.credential-provider` and `proxy.import-ambient-no-proxy`. An operator who set a DNS timeout believed resolution was bounded and it was not; one who named a proxy credential provider believed the proxy was authenticated and it was not… **the honest position is to refuse a value the platform cannot honour instead of accepting it and doing nothing.**"
|
||||
|
||||
기본값은 통과시키므로 "only a deliberate, unmet request fails"다. 앞선 열 개 모듈에서 반복해 발견한 "선언되었으나 아무것도 하지 않는 설정" 패턴을, 이 모듈은 **명시적 거부로 처리한다.**
|
||||
|
||||
같은 논리가 관측 설정에도 적용된다 — `full-url-recording`은 아무도 읽지 않았고 `body-logging`은 actuator 보고에만 닿았다. "Leaving them that way is the worse of the two failure modes — an operator who set them believed the platform was recording full URLs or bodies, and an operator who left them false had no assurance that it was not." 지금은 production에서 둘 다 거부된다.
|
||||
|
||||
나머지 검사도 각각 구체적인 다운그레이드를 막는다.
|
||||
|
||||
- **`REACTIVE_REDIRECT_UNSUPPORTED`** — 엔진 리다이렉트는 모든 전송에서 꺼져 있고 hop별 재검증을 하는 coordinator는 블로킹 스택에만 있다. 리액티브 프로파일이 redirect를 켜면 "the caller received the 302 as an ordinary response and read its empty body as the answer." 거부가 정직한 결과다 — "a configured guarantee that silently does nothing is worse than one the platform declines to offer."
|
||||
- **`HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED`** — `ProtocolIntent`가 "H2를 선호"와 "H2를 요구"를 구분한다. JDK 클라이언트는 `HTTP_2`를 선호로 다뤄 조용히 HTTP/1.1로 협상하고 Apache classic은 HTTP/1.1 전용이라, `HTTP_2`만 선언한 프로파일이 "ran happily over HTTP/1.1, and nothing anywhere said so."
|
||||
- **`TLS_PROTOCOL_SET_REQUIRED`** — 빈 집합이 통과하면 JVM 기본값이 선택되어, "a profile that meant to pin a TLS floor got whatever the platform default happened to be — including TLS 1.2 on a profile whose operator had deliberately emptied the list to 'tighten' it."
|
||||
- **`DYNAMIC_TARGET_PROXY_UNSUPPORTED`** — 포워드 프록시는 호스트명을 자기 쪽에서 다시 해석하므로 "The SSRF defence would be present, correct, and bypassed."
|
||||
- **`RETRY_POLICY_CONTRADICTS_ATTEMPTS`** — `policy`를 실행 경로에서 아무도 읽지 않아 "the actuator could report `retryPolicy: none` for a profile that was retrying three times."
|
||||
|
||||
**배선 확인.** `app-bootstrap`의 `HttpClientStartupValidator:37`이 이 검증기를 생성한다(`168-...` §8.1). 이 leaf는 앞선 cache-redis와 달리 **실제로 조립돼 있다** — app-bootstrap에 이 leaf를 위한 auto-configuration 12개가 있다.
|
||||
|
||||
## 3. `ClientRuntimeRegistry` — 세대 교체가 틈으로 관측되지 않는다
|
||||
|
||||
"A swap publishes the replacement first and drains the predecessor afterwards, so a rotation is never observable as a gap." `acquire`는 관측한 세대가 예약 직전에 draining으로 넘어가면 **새로 발행된 세대에 대해 재시도**한다.
|
||||
|
||||
과거 누수 두 건이 코드와 주석에 남아 있다.
|
||||
|
||||
- 교체된 세대가 `runtimes`에서 빠지고 스케줄된 drain 작업만 소유하게 되어, 그 작업이 발화하기 전에 레지스트리가 닫히면 "leaked the whole generation — and the resource-bound suite could not see it, because nothing enumerated it." 지금은 `retired` 집합이 추적한다.
|
||||
- `close()`가 `forEach`로 닫다가 첫 예외에서 멈춰 "a single misbehaving pool left every remaining connection, thread and socket open — **shutdown leaked more the worse the failure was.**" 지금은 전부 닫고 실패를 suppressed로 모은다.
|
||||
|
||||
## 4. P3 — `close()`가 실패하면 drain 스케줄러 스레드가 남는다
|
||||
|
||||
§3의 두 번째 수정이 절반만 적용돼 있다.
|
||||
|
||||
```java
|
||||
retired.clear();
|
||||
runtimes.clear();
|
||||
if (firstFailure != null) {
|
||||
throw firstFailure; // ← 여기서 던진다
|
||||
}
|
||||
ScheduledExecutorService scheduler = drainScheduler.getAndSet(null); // ← 도달하지 않는다
|
||||
if (scheduler != null) {
|
||||
// Await termination: a registry that returns while its drain thread is still alive would
|
||||
// leak a thread per rotation cycle, which the resource-bound suite exists to catch.
|
||||
scheduler.shutdownNow();
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
`forceClose()` 중 하나라도 던지면 `throw firstFailure`가 먼저 실행되어 **스케줄러 종료 블록에 도달하지 않는다.** `drainScheduler`도 비워지지 않으므로 스레드가 살아 있고, 클래스 javadoc이 "The single scheduled executor… is shut down with the registry so **no thread outlives it**"이라고 적은 성질이 그 경로에서 성립하지 않는다.
|
||||
|
||||
바로 위 루프는 "Every runtime is closed even when one refuses"를 위해 예외를 모으도록 고쳐졌는데, 같은 논리가 스케줄러에는 적용되지 않았다. test `closingTheRegistryReleasesEveryGenerationAndLeavesNoThread`는 실패 없는 경로만 검증한다.
|
||||
|
||||
**판정: P3.** 스레드가 데몬이라 JVM 종료를 막지는 않고, 레지스트리당 하나이며, 닫기 실패라는 조건이 필요하다. 그러나 주석이 "a thread per rotation cycle"을 명시적 위험으로 적고 resource-bound suite가 그것을 잡으려 존재하는데, 정확히 그 누수가 실패 경로에 남아 있다. 수정은 스케줄러 종료를 `finally`로 옮기는 한 줄이다.
|
||||
|
||||
## 5. P3 — `POOL_ROUTE_EXCEEDS_TOTAL` 위반 코드는 발화할 수 없다
|
||||
|
||||
```java
|
||||
// PoolSettings 정규 생성자
|
||||
if (maxConnectionsPerRoute > maxTotalConnections) {
|
||||
throw new IllegalArgumentException("per-route pool must not exceed the total pool");
|
||||
}
|
||||
|
||||
// ClientProfileValidator:192
|
||||
if (profile.pool().maxConnectionsPerRoute() > profile.pool().maxTotalConnections()) {
|
||||
out.add(violation("POOL_ROUTE_EXCEEDS_TOTAL", profile, "pool.max-connections-per-route"));
|
||||
}
|
||||
```
|
||||
|
||||
`ClientProfile`은 이미 구성된 `PoolSettings`를 들고 있고, 그 record는 route > total인 상태로 **존재할 수 없다**. production의 유일한 생성 지점(`HttpClientProfileFactory:83`)도 같은 생성자를 지난다. 따라서 검증기의 이 분기는 도달 불가이고, 그 코드의 test 참조가 0인 것도 그래서다(§6).
|
||||
|
||||
sub-scope 03(redis)의 `requireIdentifier` 죽은 분기와 같은 모양이다 — **선행 검증이 후행 검증을 가린다.** 다만 결과가 다르다: record 생성자는 `IllegalArgumentException`을 던져 startup을 즉시 실패시키므로, 검증기가 수집해 정렬된 목록으로 보고하는 **결정적 진단 형식을 이 한 조합만 받지 못한다.** 주석이 설명하는 실제 위험("on Reactor — where the per-route knob is the only one that exists — it silently becomes the effective limit")은 여전히 막혀 있다. **P3.**
|
||||
|
||||
## 6. P3 — 위반 코드 34종 중 22종이 어떤 test에서도 이름으로 확인되지 않는다
|
||||
|
||||
`ClientProfileValidator`가 내는 코드는 **34종**이다. 저장소 전체의 `test`/`testkit` source set에서 그 문자열을 참조하는 파일 수를 세면(`168-...` §8.4b):
|
||||
|
||||
| test 참조 | 코드 수 | 예 |
|
||||
|---|---|---|
|
||||
| 1건 이상 | **12** | `TRUST_ALL_FORBIDDEN`(3) · `HOSTNAME_VERIFICATION_REQUIRED`(2) · `PLAINTEXT_*`(2) · `HTTP3_STABLE_FORBIDDEN`(2) … |
|
||||
| **0건** | **22** | `DYNAMIC_TARGET_PROXY_UNSUPPORTED` · `FULL_URL_RECORDING_FORBIDDEN` · `BODY_LOGGING_FORBIDDEN` · `REACTIVE_REDIRECT_UNSUPPORTED` · `HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED` · `TLS_PROTOCOL_SET_REQUIRED` · `DNS_TIMEOUT_UNSUPPORTED` · `PROXY_CREDENTIAL_UNSUPPORTED` · `RETRY_POLICY_CONTRADICTS_ATTEMPTS` · `MISSING_PRODUCTION_SETTING` · `ALLOWED_HOST_MISMATCH` · `ALLOWED_PORT_MISMATCH` … |
|
||||
|
||||
문제는 개수가 아니라 **어느 쪽이 비어 있는가**다. 확인되지 않는 22종에는 §2가 인용한 사고 유래 가드가 거의 전부 들어 있다 — SSRF 우회(`DYNAMIC_TARGET_PROXY_UNSUPPORTED`), 로그의 PII(`FULL_URL_RECORDING_FORBIDDEN`·`BODY_LOGGING_FORBIDDEN`), 조용한 프로토콜 다운그레이드(`HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED`·`TLS_PROTOCOL_SET_REQUIRED`), 아무 일도 하지 않는 설정(`DNS_TIMEOUT_UNSUPPORTED`·`PROXY_CREDENTIAL_UNSUPPORTED`), 그리고 리다이렉트를 조용히 무시하는 경우(`REACTIVE_REDIRECT_UNSUPPORTED`).
|
||||
|
||||
test 두 개(`ClientProfileValidatorTest` 106줄)가 그룹으로 몇 개를 묶어 확인하지만(`rejectsSimpleFactoryAndUnacknowledgedHttp3AndJdkRoutePool`), 나머지 22종은 분기를 지워도 초록으로 남는다. 코드 자체는 현재 옳다 — 위험은 회귀다. **P3.** 수정은 `@ParameterizedTest`로 코드별 최소 케이스를 한 벌 놓는 것이고, 34종이 모두 결정적으로 정렬된 목록을 내므로 그 형태가 자연스럽다.
|
||||
|
||||
## 7. Negative-space probes — sub-scope 01
|
||||
|
||||
- **8.1 reachability**: `ClientProfileValidator`가 app-bootstrap `HttpClientStartupValidator`에 배선됨 확인. `ClientRuntimeRegistry`는 leaf 내부 10곳 + app-bootstrap 5곳에서 소비.
|
||||
- **8.2 계약 ↔ 구현**: 위반 코드 34종 전수 열거와 각 코드가 막는 다운그레이드를 주석에서 추적(§2).
|
||||
- **8.3 중복 mechanism**: `PoolSettings` 생성자와 validator가 같은 규칙을 두 번 검사하고 후자가 도달 불가(§5).
|
||||
- **8.4 test 대비 표면**: 코드 34 vs test 참조 12(§6). `close()` 실패 경로의 스케줄러 도달성(§4).
|
||||
|
||||
## 8. Sub-scope 01 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| **P3** | `ClientRuntimeRegistry.close()`가 `throw firstFailure`를 스케줄러 종료보다 먼저 실행해, 닫기 실패 시 drain 스레드가 남는다 — 클래스 javadoc의 "no thread outlives it"과 어긋난다 | `forceClose()`가 던지는 종료 |
|
||||
| **P3** | `POOL_ROUTE_EXCEEDS_TOTAL` 분기가 `PoolSettings` 생성자에 가려 도달 불가 | 진단 형식 |
|
||||
| **P3** | 위반 코드 34종 중 22종이 어떤 test에서도 이름으로 확인되지 않고, 그 22종에 사고 유래 보안 가드가 대부분 포함된다 | 회귀 |
|
||||
|
||||
## 9. Sub-scope 01 완료 조건
|
||||
|
||||
- denominator 35 / 35 FULL_READ (`168-...` OWNED FILES)
|
||||
- §8.1~§8.4 probe 수행, 중복 mechanism 1건 · 도달성 2건 조사
|
||||
- 위반 코드 34종을 전수 열거해 test 참조 수를 계수(§6)
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 10. Sub-scope 02 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **55 / 55 FULL_READ**
|
||||
> 범위: `api/error` 23 + `api/result` 9 + `api/body` 7 + `api/operation` 6 + `api` 루트 5 (main 50, 1,552 LOC) + 전용 test 5
|
||||
> 역할: 이 플랫폼의 공개 어휘 — 무엇을 요청하고, 무엇을 증거로 삼고, 무엇이 실패했는지
|
||||
|
||||
manifest와 probe: `evidence/raw/169-httpclient-api-probes.txt`.
|
||||
|
||||
## 11. 증거(evidence) 모델이 이 모듈의 중심이다
|
||||
|
||||
세 개의 enum이 재시도 안전성 판단의 전부를 담는다.
|
||||
|
||||
**`ExecutionEvidence`** — `NOT_SENT` · `SENT_NO_RESPONSE` · `RESPONSE_RECEIVED` · `PARTIAL_RESPONSE`. 규칙 한 줄이 붙어 있다 — "`NOT_SENT` is only used when a stage failure proves the request never reached the server. **A generic engine I/O failure is never upgraded to `NOT_SENT`.**"
|
||||
|
||||
**`AttemptStage`** — 12단계에 명시적 `order`와 `provesNotSent` 플래그가 붙는다. VALIDATION(0)부터 PROXY_CONNECT(6)까지가 `provesNotSent=true`이고 REQUEST_HEADERS(7)부터는 false다. 즉 **요청 헤더를 쓰기 시작한 순간부터는 "보내지 않았다"를 증명할 수 없다**는 규칙이 데이터로 표현된다. `order`가 enum 서수와 분리된 이유도 적혀 있다 — "the progress tracker forbids regression and the evidence classifier reads the rank, so neither depends on enum declaration ordinals."
|
||||
|
||||
**`BodyReplayability`** — `REPLAYABLE(3)` · `REOPENABLE(2)` · `ONE_SHOT(1)` · `UNKNOWN(0)`에 `weakest(left, right)` 결합 연산이 있어 복합 본문이 가장 약한 쪽을 따른다. `strength`도 서수와 분리돼 있다.
|
||||
|
||||
그리고 `OperationIdempotency`가 네 번째 축을 더한다 — 규칙 한 줄이 설계 결정 D-09다: "**Retry eligibility never derives safety from the HTTP method alone.**" `CONTRACT_IDEMPOTENT`는 "메서드는 표준 멱등이 아니지만 업스트림 계약이 반복 안전을 보장한다"를 표현 가능하게 만든다.
|
||||
|
||||
## 12. 저카디널리티·무비밀 원칙이 타입 수준에서 강제된다
|
||||
|
||||
`HttpFailureMetadata`의 javadoc이 **제외 목록을 열거**한다 — "Full URL, query values, expanded path variables, request or response bodies, `Authorization` / `Cookie` / API key values, the raw idempotency key, client secrets, and resolved IPs are deliberately absent."
|
||||
|
||||
그리고 `toString()` 재정의 세 곳이 각각 과거 유출을 적는다.
|
||||
|
||||
- **`HttpOperation.toString()`** — 생성된 record toString이 "every header value, the body object and the expanded URI variables"를 찍었고, "an `Authorization` header, a request payload and a customer identifier were **one stack trace away from the log aggregator**." 지금은 헤더 **키 집합**과 본문 **클래스 이름**만 낸다.
|
||||
- **`ObjectBody.toString()`** — "the generated `toString` rendered the payload itself… which for an outbound call is **by definition someone else's data**." 지금은 `ObjectBody[Type, media/type, REDACTED]`.
|
||||
- **`IdempotencyKey.toString()`** — `IdempotencyKey[REDACTED]`, 원값은 명시적 접근자로만.
|
||||
|
||||
`api/body`의 나머지 넷(`BodySource`·`EmptyBody`·`IOSupplier`·`OneShotStreamBody`·`ReopenableStreamBody`)은 toString을 재정의하지 않는데, 그 record 성분이 스트림 핸들·공급자·길이·미디어 타입이라 기본 toString이 데이터를 찍지 않는다. 값을 들고 있는 둘(`ByteArrayBody`·`ObjectBody`)만 재정의돼 있다 — 전수 확인했다(`169-...` §8.1).
|
||||
|
||||
`HttpOperation`은 **URI 템플릿만** 들고 다닌다("never an expanded URL: observability tags and failure metadata must stay low-cardinality, and the security layer expands components itself"). `TRACE`는 enum에 없고, test `exposesHttpMethodSemanticsWithoutTrace`가 `HttpMethod.values()`에 `"TRACE"`가 없음을 반사로 확인한다 — 저장소 전체에서 `TRACE` 문자열의 다른 참조는 그 test 한 줄뿐이다.
|
||||
|
||||
`FailureCategory` 24종이 전송 중립 어휘이고 "Every transport classifier maps engine-specific exceptions onto exactly one of these values so Apache, JDK, Reactor Netty, and Jetty produce **identical** retry and observation semantics." `permanent()`가 8종을 영구 실패로 분류한다. `api/error`에는 예외 21종 + 메타데이터 1종이 있고 전부 `HttpClientException`을 상속하며 metadata를 노출한다(test `everyStableFailureExposesMetadata`).
|
||||
|
||||
## 13. `ObjectBody`의 재생 가능성 판정 — 값의 성질이지 코덱의 성질이 아니다
|
||||
|
||||
이 sub-scope에서 가장 신중한 코드다. 과거 동작과 그 결과가 적혀 있다.
|
||||
|
||||
> "Every `ObjectBody` used to report `REPLAYABLE` unconditionally, on the strength of a javadoc line asking callers not to mutate the value afterwards. A mutable DTO handed to the platform and then changed by the caller — a builder reused across calls, a collection the caller kept a reference to — produced a retry that sent **different bytes under the same idempotency key**, which is the one thing a replay must never do."
|
||||
|
||||
지금은 `deeplyImmutable(value)`가 구조적으로 판정한다 — 문자열·숫자·불리언·문자·enum·UUID·`Temporal`은 통과, 컬렉션과 맵은 **JDK의 불변 뷰인지 이름으로 확인**하고 원소까지 재귀, record는 모든 성분을 반사로 재귀 확인, 그 외는 전부 `ONE_SHOT`. 반사가 실패하면 "A component the platform cannot inspect cannot be certified, and an uncertified body is one-shot rather than optimistically replayable."
|
||||
|
||||
컬렉션 판정이 이름 기반인 이유도 적혀 있다 — "`List.of(...)` and `Collections.unmodifiableList(...)` return package-private classes with no shared marker interface. An ordinary `ArrayList` the caller still holds is exactly the case this must not accept."
|
||||
|
||||
test 넷이 네 갈래를 고정한다 — `anImmutableRecordReplays`, `aMutableValueIsOneShot`, `aRecordWrappingMutableStateIsOneShot`, `anArbitraryBeanIsOneShot`.
|
||||
|
||||
## 14. P3 — `Number`가 허용 목록에 있어 가변 숫자 타입이 REPLAYABLE로 인증된다
|
||||
|
||||
`deeplyImmutable`의 첫 분기가 `candidate instanceof Number`를 무조건 통과시킨다.
|
||||
|
||||
```java
|
||||
if (candidate instanceof String
|
||||
|| candidate instanceof Number // ← java.util.concurrent.atomic.* 가 전부 여기 들어온다
|
||||
|| candidate instanceof Boolean
|
||||
…) {
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
`java.util.concurrent.atomic`의 `AtomicInteger`·`AtomicLong`·`LongAdder`·`DoubleAdder`·`LongAccumulator`·`DoubleAccumulator`는 모두 `Number`를 상속하는 **가변** 타입이다. 따라서 성분에 `AtomicInteger`를 가진 record(또는 `ObjectBody`의 값 자체가 `AtomicInteger`인 경우)는 `REPLAYABLE`로 인증되고, 호출자가 그 사이에 값을 증가시키면 재시도가 **같은 idempotency key로 다른 바이트를 보낸다** — 이 검사가 존재하는 이유로 인용된 바로 그 결과다.
|
||||
|
||||
같은 형태의 좁은 구멍이 `Temporal`에도 있다(java.time 구현체는 불변이지만 사용자 정의 `Temporal` 구현은 그렇지 않을 수 있다). 그러나 `Number`가 훨씬 현실적이다 — DTO에 카운터를 두는 것은 드물지 않다.
|
||||
|
||||
**판정: P3.** 도달성이 좁고(원자 카운터를 요청 DTO에 넣어야 한다), 검사 전체의 방향은 보수적이며, `aMutableValueIsOneShot` test가 일반적인 가변 객체는 잡는다. 기록하는 이유는 이 검사가 "records, enums, strings, boxed primitives and immutable collection views replay; **anything else** is treated as one-shot"라고 선언하는데 `Number` 한 줄이 그 선언보다 넓기 때문이다. 수정은 boxed primitive 여덟 종과 `BigInteger`/`BigDecimal`을 명시하거나, `java.util.concurrent.atomic` 패키지를 제외하는 것이다.
|
||||
|
||||
## 15. P3/기록 — 재생 가능성 판정이 호출마다 반사로 재계산된다
|
||||
|
||||
`replayability()`는 캐시 없이 매번 `deeplyImmutable(value)`를 돌리고, record 성분마다 `accessor.setAccessible(true)` + `invoke`를 수행한다. 재시도 엔진은 물리 시도마다 이 값을 읽으므로, 중첩이 깊은 DTO에서는 시도 수 × 트리 크기만큼 반사 호출이 일어난다.
|
||||
|
||||
정확성 문제는 아니다 — 결과가 값에만 의존하므로 캐시해도 같다. 이 모듈에 `httpClientPerformanceTest` source set이 따로 있고 "assert on resource bounds rather than behaviour"를 목적으로 하는데, 그 lane이 이 경로를 재는지는 sub-scope 07에서 확인한다. **P3/기록.**
|
||||
|
||||
## 16. Negative-space probes — sub-scope 02
|
||||
|
||||
- **8.1 값 유출 표면**: `api/body`의 7종 중 값을 들고 있는 둘만 `toString`을 재정의했고 나머지는 기본 toString이 데이터를 찍지 않음을 전수 확인(§12).
|
||||
- **8.2 허용 목록의 경계**: `deeplyImmutable`의 7개 `instanceof` 분기와 컬렉션 이름 판정을 전수 검토, `Number`에서 구멍 발견(§14).
|
||||
- **8.3 어휘 ↔ 예외 대응**: `FailureCategory` 24종과 `api/error` 예외 21종의 대응 확인.
|
||||
- **8.4 부재 주장**: `TRACE`가 enum·구현·부트스트랩 어디에도 없고 참조는 그 부재를 확인하는 test 한 줄뿐(§12).
|
||||
|
||||
## 17. Sub-scope 02 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| **P3** | `ObjectBody.deeplyImmutable`의 `instanceof Number`가 `AtomicInteger`·`LongAdder` 등 가변 숫자 타입을 `REPLAYABLE`로 인증한다 — 재시도가 같은 idempotency key로 다른 바이트를 보낼 수 있다 | 원자 카운터를 담은 요청 DTO |
|
||||
| P3/기록 | `replayability()`가 호출마다 반사로 재계산되고 캐시가 없다 | 깊은 DTO · 다중 시도 |
|
||||
|
||||
## 18. Sub-scope 02 완료 조건
|
||||
|
||||
- denominator 55 / 55 FULL_READ (`169-...` OWNED FILES)
|
||||
- §8.1~§8.4 probe 수행, 허용 목록 경계 조사 1건
|
||||
- 증거 3축(`ExecutionEvidence`·`AttemptStage`·`BodyReplayability`)과 멱등성 축의 관계를 코드로 추적
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 19. Sub-scope 03 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **47 / 47 FULL_READ**
|
||||
> 범위: `resilience/**` main 39 (1,746 LOC) + 전용 test 8 (47 test 메서드)
|
||||
> 역할: 이 플랫폼이 존재하는 이유 — 증거 기반 재시도 자격 판정과 시도별 가드
|
||||
|
||||
manifest와 probe: `evidence/raw/170-httpclient-resilience-probes.txt`.
|
||||
|
||||
## 20. 재시도 결정표가 순서로 표현돼 있다
|
||||
|
||||
`DefaultRetryEligibilityEngine.decide`의 javadoc이 규칙이다 — "The order is the point. Cheap absolute blockers come first (attempts, budget, replayability, first byte, deadline, draining), then ambiguity, then status- and failure-specific rules. **A later rule can never re-enable something an earlier rule forbade.**"
|
||||
|
||||
절대 차단 여섯이 먼저다 — 시도 수 소진 · 예산 소진 · 본문 재생 불가 · **첫 바이트 전달됨** · 런타임 draining · 남은 deadline이 최소 시도 예산 이하. 그다음 영구 실패 범주, 그다음 증거, 그다음 상태/실패별.
|
||||
|
||||
상태별 규칙에 수정 이력이 붙어 있다.
|
||||
|
||||
> "408, 425 and 429 all mean the request reached the upstream and was answered, so repeating one is only safe under the same rule as every other repeat. These three used to skip that check: **a non-idempotent POST answered 429 was retried, and a rate-limited upstream that had already accepted the work got it a second time.** A 429 is a scheduling signal, never a statement that nothing happened."
|
||||
|
||||
그리고 `RetryContext.safelyIdempotent()`가 이 모듈의 D-09를 구현한다 — HTTP 메서드는 `RetryContext`에 **아예 없다**("so a POST with a registered idempotency key and a GET against a non-idempotent RPC endpoint are both handled correctly instead of by method-name folklore"). 키 기반 멱등성은 **키가 실제로 전송됐는지**까지 요구한다.
|
||||
|
||||
> "The two used to be conflated: the platform read `idempotencyKey.isPresent()`, concluded the upstream could deduplicate, and retried — while the header was never sent, so the upstream had nothing to deduplicate against and **processed the request twice.** Possession of a key is the caller's intent; transmission is the upstream's ability to honour it."
|
||||
|
||||
test `aKeyThatWasNeverSentDoesNotMakeARepeatSafe`가 그것을 고정한다.
|
||||
|
||||
`Retry-After`는 남은 deadline 안에 들어갈 때만 존중된다(`allowWithin`), 그리고 존중된 `Retry-After`는 `maxBackoff`로 잘리지 **않는다** — 잘라 버리면 업스트림이 요청한 대기보다 일찍 다시 두드리게 되기 때문이다.
|
||||
|
||||
## 21. 가드 순서와 그 근거
|
||||
|
||||
`AttemptResiliencePipeline`이 물리 시도마다 **Circuit Breaker → Rate Limiter → Bulkhead → HTTP 호출**을 고정 순서로 적용하고 역순으로 해제한다.
|
||||
|
||||
> "The order is not cosmetic. An open circuit must reject before a rate token or a bulkhead permit is spent, otherwise **a dead upstream keeps consuming the quota and concurrency that healthy upstreams need.**"
|
||||
> "A local rejection (rate limiter or bulkhead) is deliberately *not* recorded as a circuit error: the upstream never saw the request, and **counting our own back-pressure as upstream failure would open the breaker on a healthy dependency.**"
|
||||
|
||||
그리고 브레이커가 무엇을 보는지에 대한 수정 이력이 하나 더 있다 — 이전에는 원시 전송만 파이프라인 안에서 돌고 응답→예외 매핑이 밖에서 일어나서 "a 503 completed the call normally, the breaker recorded a success, and **an upstream that answered nothing but 503 never opened its circuit. The thing the breaker is for was the one thing it could not see.**" 지금은 `remoteFailure` 분류기가 반환값을 보고 브레이커에 알린다.
|
||||
|
||||
test 47개가 이 규칙들을 촘촘히 덮는다 — `appliesCircuitThenRateLimiterThenBulkheadPerAttempt`, `openCircuitDoesNotConsumeRateOrBulkheadPermit`, `bulkheadRejectionReleasesTheRateLimiterAndIsNotACircuitError`, `answeredStatusesDoNotRetryANonIdempotentOperation`, `deniesOneShotBodyEvenForPut`, `honorsRetryAfterOnlyInsideDeadline`, `protocolProofOfNonProcessingWinsOverEverything`, `streamAfterGoAwayLastIdIsPeerNotProcessed` 등.
|
||||
|
||||
`Http2ProtocolEvidence`는 프로토콜 수준 증거를 다룬다 — `REFUSED_STREAM`과 GOAWAY의 last-stream-id보다 큰 스트림 id는 **피어가 처리하지 않았음의 증명**이라 `NOT_SENT`로 승격되고, 그 이하 id의 리셋은 여전히 모호하다(`streamAtOrBelowGoAwayLastIdStaysAmbiguous`, `aBareStreamResetProvesNothing`).
|
||||
|
||||
## 22. P2 — 로컬 거부 경로에서 회로 브레이커 permission이 반환되지 않는다
|
||||
|
||||
`AttemptResiliencePipeline.execute`의 진입부다.
|
||||
|
||||
```java
|
||||
83: if (!circuitBreaker.tryAcquirePermission()) { … throw HttpCircuitOpenException }
|
||||
88: if (!rateLimiter.tryAcquirePermission()) {
|
||||
89: rejections.rateLimited();
|
||||
90: throw new HttpRateLimitRejectedException(…); // ← 회로 permission 미반환
|
||||
92: }
|
||||
93: if (!bulkhead.tryAcquire()) {
|
||||
94: rateLimiter.onCompleted(); // ← rate 토큰은 반환한다
|
||||
95: rejections.bulkheadRejected();
|
||||
96: throw new HttpBulkheadRejectedException(…); // ← 회로 permission 미반환
|
||||
98: }
|
||||
```
|
||||
|
||||
83행에서 회로 permission을 얻은 뒤, 88행과 93행의 **로컬 거부 두 경로는 `onSuccess`·`onError`·`releasePermission` 중 어느 것도 부르지 않고 던진다.** `releasePermission`은 이 leaf와 app-bootstrap 어디에도 등장하지 않고(`170-...` §8.1, exit=1), `AttemptCircuitBreaker` 인터페이스에도 그 연산이 없다(`tryAcquirePermission`·`onSuccess`·`onError`·`state` 넷뿐).
|
||||
|
||||
Resilience4j에서 이것이 중요한 상태는 **HALF_OPEN**이다. 그 상태의 `tryAcquirePermission()`은 `permittedNumberOfCallsInHalfOpenState` 중 하나를 소비하고, 그 시험 슬롯은 `onSuccess`/`onError`/`releasePermission` 중 하나로만 돌아온다. 아무것도 부르지 않으면 슬롯은 영구히 소비된다.
|
||||
|
||||
**실패 시나리오.** 업스트림 장애로 회로가 OPEN → 대기 후 HALF_OPEN 전이 → 트래픽이 돌아오면서 평상시 부하에 맞춰 사이징된 로컬 rate limiter나 bulkhead가 거부 → 그 거부마다 시험 슬롯 하나가 사라진다. 허용된 시험 호출 수만큼 그런 거부가 나면 브레이커는 성공도 실패도 관측하지 못한 채 HALF_OPEN에 머문다. Resilience4j의 `maxWaitDurationInHalfOpenState` 기본값은 0(무한 대기)이므로, **회복한 업스트림에 대해 회로가 닫히지 않는다.** 그리고 이 조건들은 우연히 겹치는 것이 아니라 **회복 순간에 자연히 함께 일어난다** — 회로가 반쯤 열리는 바로 그때 트래픽이 몰린다.
|
||||
|
||||
비대칭이 이 finding을 뒷받침한다. bulkhead 거부 경로는 `rateLimiter.onCompleted()`로 **rate 토큰을 명시적으로 돌려준다**(94행) — 저자가 permit 반환을 의식하고 있었다는 증거다. 세 가드 중 둘은 반환되고 첫 번째만 반환되지 않는다.
|
||||
|
||||
test도 그 공백을 그대로 보여 준다 — `openCircuitDoesNotConsumeRateOrBulkheadPermit`(회로가 거부할 때 뒤의 둘을 소비하지 않음)과 `bulkheadRejectionReleasesTheRateLimiterAndIsNotACircuitError`(bulkhead 거부가 rate를 돌려줌)는 있지만, **"rate/bulkhead 거부가 회로 permission을 돌려준다"는 test는 없다.**
|
||||
|
||||
**판정: P2.** 수정은 `AttemptCircuitBreaker`에 `releasePermission()`을 더하고(Resilience4j `CircuitBreaker.releasePermission()`에 위임, `alwaysClosed()`는 no-op) 두 로컬 거부 경로에서 호출하는 것이다.
|
||||
|
||||
## 23. Confirmed — `PARTIAL_RESPONSE` 재시도 분기는 도달 가능하다 (후보 → 결함 아님)
|
||||
|
||||
`decide`의 40~46행은 `evidence == PARTIAL_RESPONSE`일 때 안전 멱등이면 재시도를 허용한다. 그런데 28행이 이미 `context.firstByteDelivered()`에서 거부한다. `DefaultExecutionEvidenceClassifier`는 `progress.responseBytesDelivered() > 0 || progress.firstByteDelivered()`일 때 `PARTIAL_RESPONSE`를 내고, `AttemptProgressTracker.recordDeliveredBytes`가 두 필드를 **함께** 세팅한다. 여기까지만 보면 40행은 도달 불가로 보인다.
|
||||
|
||||
전수 추적한 결과 **도달 가능하다**. `PARTIAL_RESPONSE`를 만드는 곳이 evidence classifier 하나가 아니다 — 전송 실패 분류기 넷이 엔진 예외로부터 직접 그 값을 만든다(`ApacheFailureClassifier:121,136` · `ReactorFailureClassifier:94,114` · `JdkFailureClassifier:83,103` · `JettyHttp3FailureClassifier:41`). 그 경로는 tracker의 `firstByteDelivered`를 보지 않으므로, "엔진은 응답 일부를 봤지만 **호출자에게는 한 바이트도 전달되지 않은**" 상태가 표현된다. 40행 주석이 말하는 구분("A partial response that never reached the caller may still be retried… once a byte was delivered the earlier guard has already denied it")이 실제로 성립한다.
|
||||
|
||||
`FirstByteRetryBoundaryTest`가 그 경계를 양쪽에서 고정한다. **결함 아님으로 판정.**
|
||||
|
||||
## 24. Negative-space probes — sub-scope 03
|
||||
|
||||
- **8.1 permit 반환 대칭**: 세 가드의 획득/반환 경로 전수 추적 — 회로만 반환 없음(§22). `releasePermission` 저장소 전체 매치 0.
|
||||
- **8.2 분기 도달성**: `PARTIAL_RESPONSE` 생산 지점 전수 조사로 후보를 오탐 판정(§23).
|
||||
- **8.3 결정표 순서**: 절대 차단 6 → 영구 실패 → 증거 → 상태/실패별의 단조성 확인(§20).
|
||||
- **8.4 test 밀도**: main 39 파일에 test 8 파일 / 47 메서드. 상태별·증거별·본문별 갈래가 이름으로 고정됨.
|
||||
|
||||
## 25. Sub-scope 03 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| **P2** | rate limiter·bulkhead 로컬 거부 경로가 회로 브레이커 permission을 반환하지 않는다 — HALF_OPEN 시험 슬롯이 소진되어 회복한 업스트림에 대해 회로가 닫히지 않을 수 있다 | 회로 회복 중 로컬 백프레셔가 걸리는 배포 |
|
||||
|
||||
## 26. Sub-scope 03 완료 조건
|
||||
|
||||
- denominator 47 / 47 FULL_READ (`170-...` OWNED FILES)
|
||||
- §8.1~§8.4 probe 수행, permit 반환 대칭 조사 1건
|
||||
- 후보 finding 1건(`PARTIAL_RESPONSE` 분기 도달 불가 의심)을 생산 지점 전수 조사로 **오탐 판정**(§23)
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 27. Sub-scope 04 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **46 / 46 FULL_READ**
|
||||
> 범위: `restclient/**` 19 + `webclient/**` 17 (main 36, 3,476 LOC) + 전용 test 10
|
||||
> 역할: 블로킹/리액티브 두 게이트웨이 — 준비 · 시도 실행 · 응답 매핑 · 스트리밍 · SSE
|
||||
|
||||
manifest와 probe: `evidence/raw/171-httpclient-gateway-probes.txt`.
|
||||
|
||||
## 28. 두 예산, 두 계층, 그리고 읽는 도중의 강제
|
||||
|
||||
`ResponseSizeLimiter`가 **와이어 바이트와 디코드 바이트를 따로** 센다 — "a compressed payload passes a wire check and then expands, so a single limit either rejects legitimate traffic or lets a **decompression bomb** through." 그리고 `CountingBoundedInputStream`이 상한을 **읽는 도중에** 적용한다 — "a response that is discovered to be too large only once it is fully buffered has already cost the memory the limit exists to protect."
|
||||
|
||||
`BoundedErrorBody`는 오류 본문을 RFC 9457 문서를 해독할 만큼만 읽고, "The bytes never reach an exception message or a log." `toString()`은 길이와 truncated 여부만 낸다.
|
||||
|
||||
`RemoteProblemDecoder`의 규칙 한 줄이 이 계층의 성격을 요약한다 — "**The wire status wins.** A remote `status` member is read and discarded, because trusting it would let an upstream **relabel a 503 as a 400 and change our retry behaviour from its own body.**" 확장 속성도 allowlist로 걸러 "an upstream cannot inject unbounded attributes into our telemetry." test 넷이 그 갈래를 고정한다(`mapsProblemJsonWithoutTrustingBodyStatus`·`dropsExtensionsThatAreNotAllowlisted`·`treatsANonProblemContentTypeAsAnEmptyProblem`·`survivesAnUnparseableProblemDocument`).
|
||||
|
||||
## 29. 리다이렉트는 엔진이 아니라 이 플랫폼이 따라간다
|
||||
|
||||
모든 전송에서 엔진 리다이렉트를 끄고 `BlockingRedirectCoordinator`만이 hop을 만든다. 각 hop마다 **대상 정책을 다시 적용**하고 origin이 바뀌면 자격증명을 떨어뜨린다 — "which is exactly what an engine's built-in follower does not do."
|
||||
|
||||
수정 이력 둘이 붙어 있다.
|
||||
|
||||
- "The redirect policy decides whether a hop is permissible in shape; the profile decides whether its destination is permissible at all. **Only the first check existed, so an upstream could redirect a trusted profile to an origin its allowlist excluded.**" 지금은 `targetGuard.requireAllowed(follow.target(), metadata)`가 매 hop 실행된다.
|
||||
- 303은 메서드를 GET으로 바꾸면서 **본문도 버린다** — "Changing only the method sent the original payload as a GET body to a destination the upstream chose." 301/302는 메서드를 유지한다("the platform refuses to guess a rewrite the caller did not ask for").
|
||||
|
||||
hop 상한은 `RedirectEvaluator:16`이 `context.hop() >= context.policy().maxHops()`로 강제하고 test `stopsAtTheConfiguredHopLimit`이 잡는다 — 조정자의 `for` 루프에 종료 조건이 없어 보이는 것은 평가기가 거부로 끝내기 때문이다(후보 추적 → **결함 아님**).
|
||||
|
||||
그리고 hop은 재시도가 아니다 — "A hop is a physical request: it passes through the resilience pipeline via the supplied sender, so it consumes rate and bulkhead capacity. **It is not a retry, because nothing failed.**"
|
||||
|
||||
## 30. P3 — `BoundedDataBufferFlux`의 두 연산자가 이름만 있고 아무것도 하지 않는다
|
||||
|
||||
클래스 javadoc이 목적을 적는다 — "Bounds a reactive body and releases every buffer it does not hand on… **Cancellation and error are the paths that leak in practice**: the subscriber stops asking, the upstream drops what it already produced, and those buffers are direct memory nobody returns."
|
||||
|
||||
그런데 그 두 경로에 붙은 연산자가 둘 다 비어 있다.
|
||||
|
||||
```java
|
||||
return source
|
||||
.doOnNext(buffer -> { limiter.recordWireBytes(…); guard.markDelivered(); })
|
||||
.doOnDiscard(DataBuffer.class, DataBufferUtils::release) // ← 실제로 일하는 유일한 연산자
|
||||
.doOnCancel(() -> {}) // ← no-op
|
||||
.onErrorResume(failure -> Flux.error(failure)); // ← 같은 오류를 그대로 재방출, no-op
|
||||
```
|
||||
|
||||
`doOnCancel(() -> {})`은 정의상 아무 일도 하지 않고, `onErrorResume(f -> Flux.error(f))`는 오류 신호에 대해 항등이다. 따라서 이 클래스의 버퍼 해제는 **전적으로 `doOnDiscard`와 드라이버 자신의 해제 동작에 의존한다.**
|
||||
|
||||
누수가 실재한다고 주장하지는 않는다 — Reactor Netty의 `ByteBufFlux`는 취소 시 미방출 버퍼를 스스로 해제하고, `doOnDiscard`는 discard 프로토콜을 지원하는 연산자에 대해 동작한다. 문제는 **코드가 하지 않는 일을 하는 것처럼 읽힌다**는 것이다: 두 누수 경로의 이름을 딴 연산자가 나란히 있고 둘 다 비어 있어서, 이 클래스를 읽는 사람은 취소·오류 해제가 여기서 명시적으로 처리된다고 결론짓게 된다. 취소 경로에 test가 없지는 않다 — `ReactiveStreamingLifecycleTest.cancellationReleasesTheConnectionForTheNextCall`이 있다. 다만 그것이 확인하는 것은 **연결 반환**이고 버퍼 해제가 아니며, 실질적 안전망은 모든 lane에 켜져 있는 Netty leak detector(`paranoid`)다(§0).
|
||||
|
||||
**판정: P3.** 수정은 두 연산자를 지우고 javadoc이 `doOnDiscard`와 드라이버의 역할을 정확히 적게 하거나, 취소 경로에서 실제로 해제해야 할 것이 있다면 그것을 구현하는 것이다.
|
||||
|
||||
## 31. Negative-space probes — sub-scope 04
|
||||
|
||||
- **8.1 죽은 연산자**: `BoundedDataBufferFlux`의 4개 연산자 중 2개가 no-op(§30). 소비자는 `ReactiveStreamingGateway:102` 하나.
|
||||
- **8.2 편의 생성자의 가짜 신원**: `ResponseSizeLimiter`의 2인자 생성자는 프로파일 이름이 리터럴 `"response-size-limiter"`인 정적 메타데이터를 쓴다. production 호출 지점 셋(`BlockingAttemptExecutor:52`·`BlockingStreamingGateway:60`·`ReactiveStreamingGateway:69`)은 **전부 3인자 생성자로 실제 메타데이터를 넘긴다** — 2인자 형태는 test 전용이다. 결함 아님으로 판정하되, `public`이므로 fork가 쓰면 예외에 가짜 프로파일 이름이 실린다.
|
||||
- **8.3 hop 상한**: 조정자 루프의 종료 조건 부재를 후보로 추적해 `RedirectEvaluator`의 거부로 확정 — **오탐**(§29).
|
||||
- **8.4 상태 신뢰 경계**: 원격 problem 문서의 `status`가 폐기되고 와이어 상태가 이긴다는 규칙을 코드와 test로 확인(§28).
|
||||
|
||||
## 32. Sub-scope 04 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| **P3** | `BoundedDataBufferFlux`의 `doOnCancel(() -> {})`과 `onErrorResume(f -> Flux.error(f))`가 no-op인데, 클래스 javadoc은 그 두 경로를 이 클래스가 처리한다고 적는다 | 코드 독해 · 회귀 |
|
||||
|
||||
## 33. Sub-scope 04 완료 조건
|
||||
|
||||
- denominator 46 / 46 FULL_READ (`171-...` OWNED FILES)
|
||||
- §8.1~§8.4 probe 수행
|
||||
- 후보 finding 2건(리다이렉트 hop 무한 루프 의심, `ResponseSizeLimiter` 가짜 메타데이터)을 각각 평가기 거부와 호출 지점 전수로 추적해 **결함 아님으로 판정**(§29, §31)
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 34. Sub-scope 05 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **46 / 46 FULL_READ**
|
||||
> 범위: `security/**` 21 + `auth/**` 18 (main 39, 2,072 LOC) + 전용 test 7
|
||||
> 역할: 목적지·헤더·본문 정책, TLS 재료와 회전, 자격증명 해석
|
||||
|
||||
manifest와 probe: `evidence/raw/172-httpclient-security-auth-probes.txt`.
|
||||
|
||||
## 35. 목적지 정책 — 절대 URI를 정화하지 않고 거부한다
|
||||
|
||||
`TrustedTargetPolicy`의 규칙 — "An absolute URI is **rejected here rather than sanitised**: H2 exists to vary method, relative path, query, approved headers, and body — not the destination. Changing the destination is what H3 is for, and H3 has its own policy, credentials, and DNS validation."
|
||||
|
||||
`requireRelativeTemplate`가 빈 템플릿, `//` 시작, `://` 포함, `/`로 시작하지 않음을 거부한다. 확장은 문자열 연결이 아니라 Spring `DefaultUriBuilderFactory`의 `TEMPLATE_AND_VALUES` 인코딩이라 "a value containing `/`, `?`, or `#` cannot change the shape of the request." 그리고 확장 **후에** `requireAllowedOrigin`이 host/port allowlist를 다시 본다.
|
||||
|
||||
멱등성 키 처리에 수정 이력 둘이 붙어 있다.
|
||||
|
||||
> "Before, the key was carried on the operation, checked for presence by the retry engine, and **never written to the wire**: the upstream saw no key, could not deduplicate, and the platform meanwhile treated a repeat as contractually safe. **A duplicated payment is the shape of that bug.**"
|
||||
> "A caller-supplied value for the same header is **refused rather than merged.** Two keys for one request is a contradiction."
|
||||
|
||||
그리고 리다이렉트 hop에 allowlist를 다시 적용하는 `requireAllowedTarget`이 public인 이유도 적혀 있다 — 조정자가 이전에는 리다이렉트 정책만 보고 프로파일 allowlist를 보지 않아 "An upstream could therefore redirect a trusted profile to any origin the redirect policy tolerated, including one the operator had explicitly excluded."
|
||||
|
||||
## 36. 헤더 소유권과 자격증명 제거
|
||||
|
||||
`HeaderPolicy`의 `PLATFORM_OWNED` 9종(`authorization`·`proxy-authorization`·`host`·`content-length`·`transfer-encoding`·`traceparent`·`tracestate`·`baggage`·`cookie`)은 호출자가 덮을 수 없고, CR/LF는 무조건 거부된다 — "a header value that can contain a newline is a **request-splitting primitive**."
|
||||
|
||||
`SensitiveHeaderStripper`에 이 sub-scope에서 가장 미묘한 수정이 있다.
|
||||
|
||||
> "It **adds** rather than replaces, which its name always claimed and its behaviour did not. A profile that named a custom API-key header — `X-Client-Key`, say — produced a stripper that dropped only that one and forwarded `X-Api-Key` across an origin boundary, so **configuring a custom header made the default headers *less* protected than leaving it alone.**"
|
||||
|
||||
## 37. 자격증명은 값이 아니라 신원만 남긴다
|
||||
|
||||
`SingleFlightTokenLoader`가 토큰 갱신을 한 번으로 접고, 그 실행 위치에 대한 두 가지 과거 오류를 적는다 — 공용 `ForkJoinPool`에서 돌아 "That pool is sized for CPU-bound work"였고, 대기가 **무한 `join()`**이라 "A token endpoint that accepted the connection and never answered" 상황에서 전체가 멈췄다.
|
||||
|
||||
`UnauthorizedRetryPolicy`의 재시도 상한이 **1**인 이유 — "An expired token produces a 401 that a refresh fixes; a wrong scope produces a 401 that no number of refreshes fixes." 그리고 재생 가능한 본문과 (읽기 전용이거나 명시적 키를 가진) 연산만 허용한다. test 셋이 그 갈래를 고정한다(`denies401ReplayForOneShotPost`·`allowsExactlyOneReplayForASafeReplayableOperation`·`allowsAKeyedWriteOnlyWhenAuthFailedBeforeAnySideEffect`).
|
||||
|
||||
`AccessToken`과 `RequestCredentials`는 `toString()`을 REDACTED로 재정의하고, `CredentialRequest`도 마찬가지인데 그 이유가 구체적이다 — "The generated `toString` printed the authenticated principal and the full target URI, including any query string. **A credential-resolution failure is exactly when this record ends up in a log line**, which made the failure path the most likely place for a user identity and a signed URL to escape."
|
||||
|
||||
`OAuth2TokenCacheKey`가 6개 성분을 모두 키에 넣는 이유도 적혀 있다 — "sharing a token across principals, scope sets, audiences, tenants, or client certificates is a **privilege-escalation bug, not a cache optimisation.**"
|
||||
|
||||
`TlsRuntimeRotationCoordinator`는 회전을 세대 교체로 처리하고("a connection pool holds sockets that were negotiated with the old material"), 어느 프로파일이 참여하는지를 회전된 신원 이름으로 정한다 — 이전에는 "The identity argument used to be required and then **ignored**: every registered profile was" 회전 대상이었다.
|
||||
|
||||
## 38. Negative-space probes — sub-scope 05
|
||||
|
||||
- **8.1 자격증명 유출 표면**: `auth`의 18 타입 중 record 5종을 전수 확인 — 값을 담는 셋(`AccessToken`·`RequestCredentials`·`CredentialRequest`)은 모두 redacted `toString`을 갖고, `OAuth2TokenCacheKey`·`UnauthorizedRetryContext`는 비밀을 담지 않는다. 나머지 13종은 `final class`/`interface`/`enum`이라 생성된 toString이 없다.
|
||||
- **8.2 가짜 메타데이터 편의 생성자**: `HeaderPolicy.validate(input)` 1인자 오버로드가 정적 `UNBOUND`(프로파일 이름 리터럴 `"header-policy"`)를 쓰지만, production 호출 지점 둘(`TrustedTargetPolicy:80`·`DefaultDynamicTargetGateway:156`)은 **전부 2인자 형태로 실제 메타데이터를 넘긴다** — sub-scope 04의 `ResponseSizeLimiter`와 같은 형태이며 같은 결론(결함 아님).
|
||||
- **8.3 SSRF 방어 지점 전수**: `requireAllowedTarget`/`requireAllowedOrigin`의 호출 지점 셋(준비 시 1, 리다이렉트 hop 1, 내부 1)과 동적 대상의 `IpAddressClassifier`/`ValidatedDnsResolver`(sub-scope 06 범위)를 확인.
|
||||
- **8.4 소유 헤더 목록**: `PLATFORM_OWNED` 9종과 `ALWAYS_STRIPPED` 4종 + 관례적 API 키 2종의 관계 확인.
|
||||
|
||||
## 39. Sub-scope 05 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| — | **없음.** 목적지가 정화가 아니라 거부로 다뤄지고, 헤더 소유권과 CR/LF 거부가 타입 수준에 있으며, 자격증명을 담는 모든 값 타입이 redacted `toString`을 갖는다 | — |
|
||||
|
||||
## 40. Sub-scope 05 완료 조건
|
||||
|
||||
- denominator 46 / 46 FULL_READ (`172-...` OWNED FILES)
|
||||
- §8.1~§8.4 probe 수행, 자격증명 유출 표면 전수 조사
|
||||
- 후보 finding 1건(`HeaderPolicy`의 가짜 메타데이터)을 호출 지점 전수로 추적해 결함 아님으로 판정(§38)
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 41. Sub-scope 06 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **56 / 56 FULL_READ**
|
||||
> 범위: `service` 14 + `dynamic` 11 + `observation` 6 + `migration` 4 (main 35, 2,698 LOC) + test 21 (전용 11 + `contract` 7 + `architecture` 3)
|
||||
> 역할: 선언적 서비스 클라이언트, 동적 대상(SSRF 방어), 관측 태그, RestTemplate 이관 진단
|
||||
|
||||
manifest와 probe: `evidence/raw/173-httpclient-service-dynamic-probes.txt`.
|
||||
|
||||
## 42. 동적 대상 — SSRF 방어가 소켓까지 이어진다
|
||||
|
||||
이 sub-scope의 중심은 `CallScopedDnsPin`의 javadoc이 적는 과거 결함이다.
|
||||
|
||||
> "This is the piece the SSRF defence was missing. `ValidatedDnsResolver` resolved a host, rejected the target if any answer was forbidden, and produced a `PinnedTarget` holding the exact approved addresses — **and then the gateway handed the transport a URL containing the hostname, and the transport resolved it again.** Everything between the two resolutions was unvalidated: a DNS server under an attacker's control answers the first query with a public address and the second with `169.254.169.254`, and the platform connects to the metadata service having 'validated' the target. **The classic rebinding attack, defeated by a check that discarded its own result.**"
|
||||
|
||||
지금은 사슬이 닫혀 있다. `TargetCanonicalizer`가 순서대로 파싱·userinfo 거부·Punycode 정규화·allowlist 비교를 하고("Order is the security property"), `ValidatedDnsResolver`가 **모든 응답**을 검사하며("Validating only the first answer is a common and fatal shortcut"), `DefaultDynamicTargetGateway:105-121`이 hop마다 `CallScopedDnsPin`을 열고 `finally`에서 `resolver.forget(...)`을 부른다. 그리고 전송 쪽은 app-bootstrap `HttpClientTransportAutoConfiguration:81`이 `CallScopedDnsPin::addressesFor`를 `ValidatedAddressResolverGroup`에 주입해 **소켓이 핀에 없는 주소로 나가지 못한다** — 핀이 비면 "The transport must then refuse rather than fall back to a system lookup."
|
||||
|
||||
`IpAddressClassifier`도 두 가지를 명시한다 — IPv4-mapped IPv6를 되돌려 정규화하고, 구조가 "an allowlist of globally routable unicast space, then the operator's own" 형태다. 그리고 "Every rejection here used to be an acceptance. `Integer.parseInt` took `-1`…"라는 수정 이력이 있다.
|
||||
|
||||
관측도 닫혀 있다 — `HttpClientTagPolicy`는 **모르는 태그 이름을 거부**한다("a metric backend cannot undo a"高카디널리티 태그), `SensitiveValueRedactor`는 쿼리 값을 키별로 마스킹하지 않고 **통째로 버린다**("an allowlist of 'safe' query" 키를 유지하는 방식은 안전하지 않다는 판단).
|
||||
|
||||
계약 lane의 메타 test가 특히 좋다 — `everySelectedBlockingTransportRanEveryContract`, `everySelectedReactiveTransportRanEveryContract`, `everySelectedTransportIsClaimedByExactlyOneContainer`. lane이 "무언가 돌았다"가 아니라 **"선택된 전송 각각이 모든 계약을 돌았다"**를 확인한다. `HttpClientModuleBoundaryTest`는 설계의 19-모듈 의존 표를 패키지 경계로 강제하고, `PublicApiArchitectureTest.coreApiDependsOnNothingInsideThePlatform`이 공개 API의 독립성을 잡는다.
|
||||
|
||||
## 43. Confirmed — `ValidatedDnsResolver`의 `approved` 맵은 hop마다 비워진다 (후보 → 결함 아님)
|
||||
|
||||
javadoc이 "They are deliberately **not retained here** between calls"라고 적는데 `approved` `ConcurrentHashMap` 필드는 여전히 존재하고 `resolve()`마다 채워진다. 무경계 증가로 보였으나 전수 추적 결과 `DefaultDynamicTargetGateway`가 **`finally`에서** `resolver.forget(host)`를 부르므로(:118-122) 항목은 hop 종료와 함께 사라진다. 그리고 보안 경로는 이 맵이 아니라 thread-local 핀을 쓴다 — `approvedAddresses(String)`의 production 호출자는 **0**이고 test·testkit 관측용이다. **결함 아님.**
|
||||
|
||||
(맵이 host만으로 키를 잡으므로 같은 host에 대한 동시 호출은 항목을 공유하고 먼저 끝난 쪽이 `forget`한다. 보안 결정은 thread-local이 내리므로 결과에 영향이 없다.)
|
||||
|
||||
## 44. Sub-scope 06 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| — | **없음.** 정규화→검증→핀→소켓 사슬이 닫혀 있고, 관측 태그가 allowlist로 폐쇄되며, 계약 lane이 전송별 완전 실행을 메타 test로 확인한다 | — |
|
||||
|
||||
---
|
||||
|
||||
## 45. Sub-scope 07 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **85 / 85 FULL_READ**
|
||||
> 범위: `transport` 10 + `reactor` 6 + `apache` 5 + `jdk` 4 + `http3` 4 + `spring7` 3 (main 32, 2,189 LOC) + 전용 test 9 + `testkit` source set 35 (2,754 LOC) + `httpClientPerformanceTest` 7 (495) + `jmh` 2 (130)
|
||||
> 역할: 전송 제공자 6종과 그 능력 선언, 그리고 세 개의 보조 source set
|
||||
|
||||
manifest와 probe: `evidence/raw/174-httpclient-transport-testkit-probes.txt`.
|
||||
|
||||
## 46. 전송은 능력을 선언하고, 프로파일보다 약하면 startup이 실패한다
|
||||
|
||||
`TransportCapabilityValidator`가 프로파일이 요구하는 것과 전송이 선언한 것을 대조해 부족분을 이름으로 모아 거부한다 — 프로토콜, route pool, 유계 pending 큐, proxy, mutual TLS, 동적 대상 안정성. 메시지는 "profile settings and capability names only — never a URL, address, or secret."
|
||||
|
||||
능력 레코드가 그 선언을 데이터로 만든다. `ReactiveTransportCapabilities.reactorNetty()`는 9개 능력을 전부 `true`로, `jettyHttp3Experimental()`은 route pool·유계 큐·DNS 핀·동적 안정성을 `false`로 선언한다. HTTP/3는 `compileOnly` 의존이라 클래스가 없으면 `Http3CapabilityReport`가 전송을 거부한다 — "the failure mode is a startup error rather than a `NoClassDefFoundError` mid-call"(§0).
|
||||
|
||||
testkit이 별도 source set인 것도 이 sub-scope의 성격이다 — 계약을 담은 클래스 35개(`BlockingTransportContract`·`ReactiveTransportContract`·`RetrySafetyContract`·`ResourceLifecycleContract`·`ObservabilityContract`·`DynamicTargetSecurityContract`)를 test·performance·jmh 세 lane이 공유한다. `NettyLeakDetectionExtension`은 leak detector 레벨을 **믿지 않고 확인한다** — "asserts the level rather than trusting the flag reached the forked JVM"(§0).
|
||||
|
||||
성능 lane 7개는 자원 상한을 검증한다 — `PoolSaturationPerformanceTest`·`RetryStormBudgetTest`·`RuntimeRotationDrainTest`·`OAuthRefreshContentionTest`·`LargeBodyResourceTest`·`Http2StreamSaturationTest`. §15에서 남긴 질문(`ObjectBody.replayability()`의 반사 비용을 재는 lane이 있는가)의 답은 **없다** — 성능 lane은 풀·재시도·회전·토큰 경합·본문 크기·H2 스트림을 재고 본문 재생 가능성 판정 비용은 재지 않는다.
|
||||
|
||||
## 47. P3 — 동적 대상 DNS 핀 능력 검사가 블로킹 오버로드에만 있다
|
||||
|
||||
`TransportCapabilityValidator`에는 오버로드가 둘이다. 블로킹 쪽에는 이런 검사와 주석이 있다.
|
||||
|
||||
```java
|
||||
if (profile.mode() == ClientMode.DYNAMIC && !capabilities.validatedDnsPinning()) {
|
||||
// `validatedDnsPinning` was declared on every capability record and read by nothing. It is
|
||||
// the capability that decides whether the SSRF address validation survives to the socket, so
|
||||
// a transport that does not have it cannot serve a dynamic target no matter what its
|
||||
// `dynamicTargetStable` flag says — the two were being conflated.
|
||||
missing.add("call-scoped validated DNS pinning");
|
||||
}
|
||||
```
|
||||
|
||||
**리액티브 오버로드에는 이 검사가 없다.** `dynamicTargetStable`만 본다 — 즉 주석이 "conflated"라고 지적한 바로 그 상태가 리액티브 경로에 그대로 남아 있다.
|
||||
|
||||
지금 노출은 없다. 두 리액티브 전송의 두 플래그가 같은 값이기 때문이다 — `reactorNetty()`는 둘 다 `true`, `jettyHttp3Experimental()`은 둘 다 `false`. 게다가 `ClientProfileValidator`가 `DYNAMIC` + `JETTY` 조합을 이미 거부한다(`DYNAMIC_TARGET_TRANSPORT_UNSUPPORTED`).
|
||||
|
||||
**판정: P3.** 위험은 fork가 리액티브 전송을 추가하면서 `dynamicTargetStable=true, validatedDnsPinning=false`로 선언하는 경우 — 주석이 "cannot serve a dynamic target no matter what its `dynamicTargetStable` flag says"라고 못박은 정확히 그 조합이 리액티브 쪽에서는 통과한다. 수정은 같은 세 줄을 리액티브 오버로드에 복사하는 것이다.
|
||||
|
||||
## 48. Negative-space probes — sub-scope 06·07
|
||||
|
||||
- **8.1 SSRF 사슬의 끝점**: 정규화 → 전체 응답 검증 → thread-local 핀 → 전송 resolver 주입까지 호출 지점으로 전수 추적(§42).
|
||||
- **8.1b 잔존 캐시**: `ValidatedDnsResolver.approved`의 수명과 production 호출자 0을 확인해 오탐 판정(§43).
|
||||
- **8.2 조건부 형제**: `TransportCapabilityValidator`의 두 오버로드 비교 — 한쪽에만 적용된 수정(§47).
|
||||
- **8.3 능력 선언 대 실제**: 두 리액티브 전송의 9개 능력 플래그 전수 확인(§46, §47).
|
||||
- **8.4 lane 완전성**: 계약 lane의 메타 test 3종과 성능 lane 7종의 대상 범위 확인(§42, §46).
|
||||
|
||||
## 49. Sub-scope 06·07 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| **P3** | 동적 대상의 `validatedDnsPinning` 능력 검사가 `TransportCapabilityValidator`의 블로킹 오버로드에만 있고 리액티브 오버로드에는 없다 — 주석이 "conflated"라고 지적한 상태가 한쪽에 남아 있다 | `dynamicTargetStable=true, validatedDnsPinning=false`인 리액티브 전송을 추가하는 fork |
|
||||
|
||||
## 50. Sub-scope 06·07 완료 조건
|
||||
|
||||
- denominator 56 / 56 및 85 / 85 FULL_READ (`173-...`, `174-...` OWNED FILES)
|
||||
- §8.1~§8.4 probe 수행, 조건부 형제 비교 1건
|
||||
- 후보 finding 1건(`approved` 맵 무경계 증가 의심)을 `finally` 배치와 호출자 전수로 추적해 **결함 아님으로 판정**(§43)
|
||||
- §15에서 남긴 성능 lane 질문을 해소(§46)
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 51. 교정 — 영구 TLS 실패의 `CONNECT` 분류는 분류기 결함이 아니라 픽스처의 듀얼스택 호스트명이다
|
||||
|
||||
이 절은 이전 사이클이 여기에 적었던 **P1 진단을 철회하고 교체한다**. 관측된 실패는 그대로 재현되지만,
|
||||
그 원인으로 지목했던 기전은 측정으로 반증되었다. 근거는 `EVD-332`다.
|
||||
|
||||
### 51.1 관측은 그대로다
|
||||
|
||||
`:adapter:outbound:httpclient:test` 는 HEAD 에서도 **283 중 3건 실패**한다.
|
||||
|
||||
```
|
||||
anUntrustedAuthorityIsAPermanentTlsFailure() → expected: TLS_HANDSHAKE but was: CONNECT
|
||||
anExpiredCertificateIsAPermanentTlsFailure() → expected: TLS_HANDSHAKE but was: CONNECT
|
||||
aHostnameMismatchIsAPermanentTlsFailure() → expected: TLS_HANDSHAKE but was: CONNECT
|
||||
```
|
||||
|
||||
세 건 모두 `MutualTlsHandshakeContractTest.java:168` — `assertThat(classified.stage()).isEqualTo(TLS_HANDSHAKE)` 다.
|
||||
바로 앞줄인 167행(`evidence == NOT_SENT`)은 통과한다.
|
||||
|
||||
### 51.2 철회하는 진단
|
||||
|
||||
이전 사이클의 주장은 이랬다.
|
||||
|
||||
> Apache HttpClient 5는 TLS 핸드셰이크 실패를 연결 단계 실패로 감싼다 — 사슬이
|
||||
> `HttpHostConnectException` → `SSLHandshakeException`이다. 바깥 것이 CONNECT 분기에 먼저 걸리므로
|
||||
> 안쪽 `SSLHandshakeException`은 검사되지 않는다.
|
||||
|
||||
**틀렸다.** 그 진단은 `ApacheFailureClassifier.recognize`의 분기 순서(pool → DNS → CONNECT → TLS)를 읽고
|
||||
사슬의 모양을 추론한 것이지, 사슬을 실제로 떠본 것이 아니다. 잡힌 예외를 그대로 출력하면 이렇다.
|
||||
|
||||
```
|
||||
[0] ResourceAccessException :: ... Connect to https://localhost:56507 failed: Connection refused
|
||||
[1] HttpHostConnectException :: Connect to https://localhost:56507 failed: Connection refused
|
||||
```
|
||||
|
||||
사슬에 `SSLHandshakeException`이 **없다**. 그림자에 가려진 것이 아니라 애초에 도착하지 않았다.
|
||||
분류기는 자기가 받은 것을 정확히 분류했다.
|
||||
|
||||
### 51.3 확정된 기전 — 접속 호스트만 바꾼 대조
|
||||
|
||||
동일한 서버 객체, 동일한 클라이언트 신뢰재료. `baseUrl`의 호스트 문자열만 바꿨다.
|
||||
|
||||
```
|
||||
=== untrusted-authority @127.0.0.1 ===
|
||||
[1] javax.net.ssl.SSLHandshakeException :: (bad_certificate) PKIX path validation failed
|
||||
[4] java.security.SignatureException :: Signature does not match.
|
||||
-> stage=TLS_HANDSHAKE category=TLS_PERMANENT permanent=true ← test가 기대하는 값
|
||||
|
||||
=== untrusted-authority @localhost ===
|
||||
[1] HttpHostConnectException :: Connect to https://localhost:40485 failed: Connection refused
|
||||
-> stage=CONNECT category=CONNECT permanent=false ← test가 본 값
|
||||
```
|
||||
|
||||
이 컨테이너의 `/etc/hosts`는 `localhost`를 두 패밀리에 준다.
|
||||
|
||||
```
|
||||
127.0.0.1 localhost
|
||||
::1 localhost ip6-localhost ip6-loopback
|
||||
InetAddress.getAllByName("localhost") -> [127.0.0.1, 0:0:0:0:0:0:0:1]
|
||||
```
|
||||
|
||||
`MockWebServer`는 IPv4 루프백에만 바인딩하고, `MockHttpServer.uri()`는 호스트명 `localhost`를 돌려준다
|
||||
(`MockHttpServer.java:70-72`). Apache HttpClient 5의 연결 오퍼레이터는 해석된 주소를 순회하면서
|
||||
**마지막이 아닌 주소의 실패를 삼킨다**.
|
||||
|
||||
```
|
||||
127.0.0.1 → TCP 성공 → TLS 핸드셰이크 실패(진짜 실패) → 삼켜짐
|
||||
::1 → TCP 거부(듣는 소켓 없음) → 마지막 주소 → HttpHostConnectException 으로 승격
|
||||
```
|
||||
|
||||
호출자에게 도달하는 유일한 예외는 두 번째 주소의 연결 거부다.
|
||||
|
||||
핸드셰이크가 **성공하는** test가 통과하는 이유도 같은 루프다. 127.0.0.1 에서 성공하면 루프가 즉시
|
||||
반환하므로 `::1`을 시도하지 않는다. 따라서 처음 눈에 띄었던 `startTls(..., true/false)` 차이는
|
||||
원인이 아니라 상관관계였다 — 실패하는 케이스가 곧 두 번째 주소까지 가는 케이스다.
|
||||
|
||||
`startTls(..., false)`에서 서버가 뜨지 않는다는 가설도 함께 기각했다. 두 경우 모두 원시 소켓 접속이
|
||||
성공한다(`raw 127.0.0.1: OK`, `raw localhost: OK`).
|
||||
|
||||
### 51.4 두 개의 판정
|
||||
|
||||
**(a) test 실패 자체 — P3, 픽스처 결함.** 프로덕션 코드에 결함이 없다. `localhost`가 IPv4로만 풀리는
|
||||
환경에서는 세 건 모두 통과한다. 고칠 것은 `MockHttpServer.uri()`가 호스트명을 돌려준다는 점이다 —
|
||||
루프백 IPv4 주소를 돌려주거나 test가 주소를 고정하면 사라진다. 이 저장소는 `preferIPv4Stack`을
|
||||
어디에도 설정하지 않으므로, 듀얼스택 CI/컨테이너에서 이 세 건은 항상 빨갛다.
|
||||
|
||||
**(b) 이 실패가 드러낸 런타임 성질 — P2/기록, 이 모듈에서 고칠 수 없다.** 분류표는 두 범주를
|
||||
정반대로 다룬다.
|
||||
|
||||
| 분류 | `DefaultRetryEligibilityEngine` |
|
||||
|---|---|
|
||||
| `TLS_PERMANENT` | `permanent()` → `RetryDenied.permanentFailure` (37-39행) |
|
||||
| `CONNECT` | `failureDecision`의 `case CONNECT -> RetryAllowed.of("CONNECT")` — 멱등성과 무관하게 재시도 |
|
||||
|
||||
다중 주소 호스트에서 한 패밀리는 TLS를 영구 거절하고 다른 패밀리는 연결을 거부하면, 절대 검증되지 않을
|
||||
인증서에 대한 호출이 `CONNECT`로 분류되어 예산·데드라인이 소진될 때까지 매 시도 재시도된다.
|
||||
분류기가 볼 수 있는 정보 안에서 이 강등을 막을 방법은 없다 — Apache의 루프가 앞선 주소의 실패를 이미
|
||||
버렸기 때문이다. 이 모듈의 설계 전제는 "증거에 기반해 재시도 안전성을 판정한다"인데, 증거를 만드는
|
||||
계층이 증거의 일부를 버리는 지점이 여기다. 다만 인증서가 신뢰 불가면 보통 두 패밀리 모두 TLS에서
|
||||
실패하고, 그때는 마지막 주소의 예외도 `SSLHandshakeException`이라 올바르게 `TLS_PERMANENT`가 된다.
|
||||
강등은 **패밀리별 실패 양상이 다를 때만** 일어난다.
|
||||
|
||||
### 51.5 이전 사이클이 남긴 열린 항목의 처리
|
||||
|
||||
이전 §51은 "`ReactorFailureClassifier`·`JdkFailureClassifier`도 같은 사슬 순회 형태를 쓰므로 같은
|
||||
그림자 문제가 있는지 확인이 필요하다"로 끝났다. 그 열린 항목은 **전제가 반증되어 소멸한다** —
|
||||
Apache는 TLS 실패를 연결 예외로 감싸지 않는다(§51.3의 `@127.0.0.1` 측정이 직접 보여 준다).
|
||||
사슬 순회 순서가 TLS를 가리는 일은 이 경로에서 일어나지 않는다. 다른 두 분류기를 같은 방식으로
|
||||
개별 실행해 보지는 않았다. 확인하지 못한 것으로 남긴다.
|
||||
|
||||
## 52. 모듈 ledger 정합
|
||||
|
||||
| # | 범위 | main | test | 기타 | 합 | FULL_READ | probe |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | governance + `profile/**` | 29 | 2 | 4 | 35 | 35 | `167`, `168` |
|
||||
| 2 | `api/**` | 50 | 5 | – | 55 | 55 | `169` |
|
||||
| 3 | `resilience/**` | 39 | 8 | – | 47 | 47 | `170` |
|
||||
| 4 | `restclient/**` + `webclient/**` | 36 | 10 | – | 46 | 46 | `171` |
|
||||
| 5 | `security/**` + `auth/**` | 39 | 7 | – | 46 | 46 | `172` |
|
||||
| 6 | `service`+`dynamic`+`observation`+`migration` | 35 | 21 | – | 56 | 56 | `173` |
|
||||
| 7 | 전송 6종 + testkit/perf/jmh | 32 | 9 | 44 | 85 | 85 | `174` |
|
||||
| | **TOTAL** | **260** | **62** | **48** | **370** | **370** | **7 / 7** |
|
||||
|
||||
coverage ledger: `FULL_READ` **370** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / 미분류 **0**.
|
||||
|
||||
## 53. 모듈 findings
|
||||
|
||||
| # | 우선순위 | finding | 위치 |
|
||||
|---|---|---|---|
|
||||
| 1 | **P3** | `MutualTlsHandshakeContractTest` 3건이 실패하지만 원인은 프로덕션 코드가 아니라 픽스처다 — `MockHttpServer.uri()`가 듀얼스택 `localhost`를 돌려주고 `MockWebServer`는 IPv4에만 바인딩한다 (이전 사이클의 P1 진단은 `EVD-332`로 철회) | §51 |
|
||||
| 1b | **P2/기록** | 다중 주소 호스트에서 패밀리별 실패 양상이 다르면 영구 TLS 실패가 재시도 가능한 `CONNECT`로 강등된다 — Apache가 앞선 주소의 실패를 버리므로 이 모듈에서 고칠 수 없다 | §51.4 |
|
||||
| 2 | **P2** | rate limiter·bulkhead 로컬 거부 경로가 회로 브레이커 permission을 반환하지 않는다 — HALF_OPEN 시험 슬롯 소진으로 회복한 업스트림에 회로가 닫히지 않을 수 있다 | §22 |
|
||||
| 3 | **P3** | `ClientRuntimeRegistry.close()`가 실패 시 drain 스케줄러 종료에 도달하지 않아 스레드가 남는다 | §4 |
|
||||
| 4 | **P3** | `POOL_ROUTE_EXCEEDS_TOTAL` 위반 코드가 `PoolSettings` 생성자에 가려 도달 불가 | §5 |
|
||||
| 5 | **P3** | 위반 코드 34종 중 **22종**이 어떤 test에서도 이름으로 확인되지 않고, 그 22종에 사고 유래 보안 가드가 대부분 포함된다 | §6 |
|
||||
| 6 | **P3** | `ObjectBody.deeplyImmutable`의 `instanceof Number`가 `AtomicInteger`·`LongAdder` 등 가변 타입을 `REPLAYABLE`로 인증한다 | §14 |
|
||||
| 7 | **P3** | `BoundedDataBufferFlux`의 `doOnCancel`·`onErrorResume`가 no-op인데 javadoc은 그 두 경로를 이 클래스가 처리한다고 적는다 | §30 |
|
||||
| 8 | **P3** | 동적 대상 `validatedDnsPinning` 능력 검사가 `TransportCapabilityValidator`의 블로킹 오버로드에만 있다 | §47 |
|
||||
| 9 | P3/기록 | `ObjectBody.replayability()`가 호출마다 반사로 재계산되고 캐시가 없으며, 성능 lane도 이 경로를 재지 않는다 | §15, §46 |
|
||||
|
||||
**결함 아님으로 판정한 후보 6건** — `PARTIAL_RESPONSE` 분기 도달 불가 의심(§23), 리다이렉트 hop 무한 루프 의심(§29), `ResponseSizeLimiter`·`HeaderPolicy`의 가짜 메타데이터 생성자(§31, §38), `ValidatedDnsResolver.approved` 맵 무경계 증가 의심(§43).
|
||||
|
||||
## 54. 이 모듈에서 반복해서 나타난 패턴
|
||||
|
||||
- **거부가 무시보다 낫다.** 이 저장소의 다른 열 모듈에서 반복 발견한 "선언되었으나 아무것도 하지 않는 설정"을, 이 모듈은 `validateUnsupportedSettings`로 **명시적 거부**한다 — "the honest position is to refuse a value the platform cannot honour instead of accepting it and doing nothing."
|
||||
- **증거가 메서드 이름을 이긴다.** `RetryContext`에 HTTP 메서드가 없고, 멱등성 키는 **전송됐는지**까지 요구하며, 첫 바이트 전달은 래치다.
|
||||
- **저장소에서 가장 밀도 높은 사고 기록.** 34개 위반 코드, `AttemptResiliencePipeline`의 브레이커 가시성 수정, `SensitiveHeaderStripper`의 "adds rather than replaces", `CallScopedDnsPin`의 "a check that discarded its own result", `TrustedTargetPolicy`의 "A duplicated payment is the shape of that bug" — 각 가드가 자신이 막는 사고를 인용한다.
|
||||
- **그러나 그 규율이 test로 고정된 비율은 낮다.** 위반 코드 34 중 12만 test가 이름으로 잡고(§6), 사고 유래 가드 대부분이 그 밖에 있다. 그리고 §51은 그 반대편의 함정이다 — test 3건이 빨간 채로 남아 있고, 이전 사이클은 그 빨강을 프로덕션 P1으로 읽었다. 실제로는 픽스처의 호스트명 문제였다(`EVD-332`). 실패하는 test는 결함의 증거가 아니라 조사의 시작점이다.
|
||||
|
||||
## 55. 검증
|
||||
|
||||
`evidence/raw/175-httpclient-suite-verification.txt`.
|
||||
|
||||
```
|
||||
lane classes tests failures skipped
|
||||
test 61 283 3 0
|
||||
httpClientStableContractTest 7 24 0 0
|
||||
httpClientSecurityTest 2 9 0 0
|
||||
httpClientBlockHoundTest 1 3 0 0
|
||||
spring62ApiSurfaceScan 1 6 0 0
|
||||
|
||||
git status --short → 0
|
||||
```
|
||||
|
||||
두 가지를 그대로 기록한다.
|
||||
|
||||
1. **`:check`는 소스와 무관한 이유로 실패한다.** `src/gradle/archive-hygiene.gradle`의 게이트가 `build/libs`에 남은 이전 리비전 JAR 두 개(`+0137263441f6`, `+e98b56eb03ec`)를 발견하고 빌드를 깬다. 저장소가 `cleanStaleTraceableJars`라는 remedy 태스크를 제공한다. **이 분석은 사용자 워크스페이스의 빌드 산출물을 삭제하지 않았고**, 대신 다섯 lane을 개별 실행해 실제 결과를 얻었다.
|
||||
2. **`:test`의 3건 실패는 실재하지만 프로덕션 결함이 아니다.** 2026-08-31 HEAD 재실행에서도 283 중 3건이 동일하게 실패한다. 예외 사슬을 직접 뜬 결과 원인은 픽스처가 듀얼스택 `localhost`를 쓰는 것이었고, 접속 호스트를 `127.0.0.1`로 바꾸면 세 건 모두 `TLS_PERMANENT`가 된다(`EVD-332`). §51이 교정된 진단이다.
|
||||
3. **재검증 시점의 소스 드리프트는 0이다.** 문서 기준 revision `a24ece9c`와 HEAD `21234e38` 사이에서 `src/adapter/outbound/httpclient` 변경 파일 수는 0이며, 함께 바뀐 `src/build.gradle`·`modules.json`도 이 리프에 영향이 없다(`EVD-333`).
|
||||
|
||||
작업 트리는 변경 0 — 이 분석은 어떤 애플리케이션 코드도 수정하지 않았다.
|
||||
|
||||
## 56. 모듈 완료 조건
|
||||
|
||||
- denominator 370 / 370 FULL_READ, `STRUCTURAL_ONLY` 0, `EXCLUDED` 0, 미분류 0 (§52)
|
||||
- 7개 하위 범위 전부 §8.1~§8.4 negative-space probe 수행, evidence `167`~`175` 9건 생성
|
||||
- 후보 finding 6건을 코드로 추적해 결함 아님으로 판정
|
||||
- lane 5종 실행, 실패 3건을 예외 사슬 실측까지 추적해 픽스처 원인으로 확정하고 이전 P1 진단을 철회(§51, `EVD-332`)
|
||||
- 재검증 revision `21234e38`에서 리프 소스 변경 0 확인(`EVD-333`)
|
||||
- §0 SSOT identity 블록·고정 골격 대응표·Source anchors 추가
|
||||
- 소스 미변경
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/adapter/outbound/httpclient/build.gradle
|
||||
src/config/architecture/modules.json (adapter-outbound-httpclient 항목)
|
||||
src/build.gradle:477-492 (plain-JUnit 조건 — 이 리프는 else 분기)
|
||||
|
||||
main/…/apache/ApacheFailureClassifier.java:31-43,45-96,98-106
|
||||
main/…/api/operation/FailureCategory.java:9-32,34-44
|
||||
main/…/api/operation/AttemptStage.java:9-42
|
||||
main/…/resilience/DefaultRetryEligibilityEngine.java:25-51,53-59
|
||||
main/…/profile/ClientProfileValidator.java
|
||||
main/…/profile/ClientRuntimeRegistry.java
|
||||
main/…/api/body/ObjectBody.java
|
||||
main/…/resilience/AttemptResiliencePipeline.java
|
||||
main/…/security/SensitiveHeaderStripper.java
|
||||
main/…/security/TrustedTargetPolicy.java
|
||||
main/…/dynamic/ValidatedDnsResolver.java
|
||||
main/…/transport/TransportCapabilityValidator.java
|
||||
main/…/reactor/BoundedDataBufferFlux.java
|
||||
|
||||
test/…/security/MutualTlsHandshakeContractTest.java:38-67,104-126,128-171
|
||||
testkit/…/testkit/MockHttpServer.java:32-49,60-72
|
||||
testkit/…/testkit/TlsFixture.java
|
||||
testkit/…/testkit/TlsMaterials.java
|
||||
|
||||
evidence/raw/167-… ~ 175-httpclient-suite-verification.txt
|
||||
evidence/raw/332-httpclient-dualstack-localhost-masks-tls-permanent.txt
|
||||
evidence/raw/333-eighteen-docs-source-drift-zero.txt
|
||||
```
|
||||
@@ -0,0 +1,440 @@
|
||||
# 12 · adapter-outbound-messaging
|
||||
|
||||
|
||||
## SSOT identity — 2026-08-31 재검증
|
||||
|
||||
- registered leaf id: `adapter-outbound-messaging`
|
||||
- canonical state `analysisFile`: `analysis/12-adapter-outbound-messaging.md` (이 문서) — 이 leaf의 단일 SSOT
|
||||
- source path: `src/adapter/outbound/messaging` · Gradle `:adapter:outbound:messaging`
|
||||
- registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract", "adapter-outbound-support"]`
|
||||
- registry `runtime_memberships`: `["app-bootstrap"]`
|
||||
- coverage ledger: `FULL_READ` **69** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
|
||||
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
|
||||
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행)
|
||||
|
||||
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
|
||||
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
|
||||
|
||||
---
|
||||
> 상태: IN_PROGRESS
|
||||
> revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
|
||||
> 경로: `src/adapter/outbound/messaging` · Gradle: `:adapter:outbound:messaging`
|
||||
|
||||
## 0. Denominator와 coverage ledger
|
||||
|
||||
tracked file **69개** — main 46 (Java 34 / 4,246 LOC + resource 12), test 19 (Java 16 / 3,670 LOC + resource 3), governance 4. 총 약 7.9k LOC.
|
||||
|
||||
```json
|
||||
{ "id": "adapter-outbound-messaging",
|
||||
"gradle_path": ":adapter:outbound:messaging",
|
||||
"allowed_dependencies": ["domain-core", "application-core", "shared-contract", "adapter-outbound-support"],
|
||||
"runtime_memberships": ["app-bootstrap"] }
|
||||
```
|
||||
|
||||
앞의 두 모듈(cache-redis · httpclient)과 달리 이 leaf는 작고, 무게가 **하나의 성질**에 몰려 있다 — JSON Schema 검증 런타임을 **닫는 것**. `build.gradle`이 그 규율을 세 겹으로 표현한다.
|
||||
|
||||
- `configurations.configureEach`가 `tools.jackson.dataformat:jackson-dataformat-yaml`·`org.yaml:snakeyaml`·`org.snakeyaml:snakeyaml-engine`을 **전 configuration에서 제외**한다.
|
||||
- `json-schema-validator:3.0.2`에서 `jackson-dataformat-yaml`을 다시 개별 제외한다.
|
||||
- `verifyJsonSchemaRuntimeGraph` 태스크가 **런타임 그래프를 실제로 해석해** YAML 계열과 Jackson 2 `core`/`databind`가 없는지, 그리고 잠긴 세 모듈(`json-schema-validator:3.0.2`·`tools.jackson.core:jackson-core:3.0.2`·`jackson-databind:3.0.2`)이 있는지 확인한다. 이 태스크는 `check`에 붙어 있다.
|
||||
|
||||
마지막 주석이 예외를 정직하게 적는다 — "Jackson 3 intentionally retains the 2.x-namespace annotations artifact. It is not a Jackson 2 databind/runtime engine and is part of the official Jackson 3 BOM graph."
|
||||
|
||||
그리고 qualification lane 둘이 `registerStrictQualificationTest`로 등록되며 각각 **필수 클래스 목록**을 갖는다 — compiled-contract 5종, JSON Schema v1 4종. 둘 다 루트의 `:prepareMessagingContractEvidence`에 의존하고 JUnit XML을 루트의 evidence 디렉터리로 낸다.
|
||||
|
||||
main 패키지 배치(Java 34): `kafka` 5 · `envelope` 5 · `core` 5 · `outbox` 4 · `destination` 4 · `contract` 3 · `realtime` 2 · `config` 2 · `autoconfigure` 2 · 루트 2.
|
||||
|
||||
가장 큰 두 파일이 이 leaf의 중심이다 — `LocalJsonSchemaRegistry` 701줄, `DeterministicEnvelopeWriter` 548줄.
|
||||
|
||||
### 하위 범위 ledger
|
||||
|
||||
| # | 범위 | main | test | 기타 | 합 | 상태 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | governance + 루트 + `autoconfigure` + `config` + 활성화 resource | 6 | 2 | 6 | 14 | **COMPLETE** |
|
||||
| 2 | `envelope/**` + JSON Schema meta 리소스 | 5 | 3 | 13 | 21 | **COMPLETE** |
|
||||
| 3 | `contract/**` + `destination/**` + qualification test | 7 | 6 | – | 13 | **COMPLETE** |
|
||||
| 4 | `core` + `kafka` + `outbox` + `realtime` | 16 | 5 | – | 21 | **COMPLETE** |
|
||||
| | **TOTAL** | **34** | **16** | **19** | **69** | **4 / 4** |
|
||||
|
||||
manifest: `evidence/raw/176-outbound-messaging-module-inventory.txt`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sub-scope 01 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **14 / 14 FULL_READ**
|
||||
> 범위: governance 4 + 루트 2 + `autoconfigure` 2 + `config` 2 (main 6) + 활성화 resource 2 + 전용 test 2
|
||||
> 역할: 이 leaf가 켜지는 방식과, 켜졌을 때 무엇이 조립되는가
|
||||
|
||||
manifest와 probe: `evidence/raw/177-outbound-messaging-activation-probes.txt`.
|
||||
|
||||
## 2. 스위치와 선택자를 분리한 기록
|
||||
|
||||
`MessagingBridgeRootAutoConfiguration`의 javadoc이 이 sub-scope의 설계 결정이다.
|
||||
|
||||
> "Before this, whether `app.messaging.broker` was blank was the de-facto switch. **That is a selector doing a switch's job**, and it reads badly in both directions: a blank broker with the relay enabled took down startup, while a deployment that wanted no messaging at all still assembled settings, a Kafka adapter configuration and two publishers. The broker id now selects *which* transport, and this switch decides *whether* there is one."
|
||||
|
||||
지금은 `app.messaging.enabled=true`가 스위치이고 `app.messaging.broker=<id>`가 선택자다. `MessagingConfig.resolveBroker`는 선택자가 설정됐는데 기여 bean이 없으면 **명시적 메시지로 startup을 실패**시키고(조용한 no-op 아님), settings의 id와 활성 bean의 `brokerId()`가 다르면 그것도 실패다.
|
||||
|
||||
`MessagingOffAutoConfigurationImportFilter`가 더 흥미롭다. Boot의 Kafka/AMQP auto-configuration은 import metadata로 들어오므로 **클라이언트 라이브러리가 classpath에 있기만 하면** connection factory·template·listener container가 만들어진다 — "none of which any project condition was consulted about." 게다가 두 라이브러리가 모두 있으면 "both brokers would assemble at once simply because both libraries are present, which is a different bug the same filter prevents." 필터는 `spring.factories`에 `AutoConfigurationImportFilter`로 실제 등록돼 있고(`177-...` §8.1), 다섯 개 Boot 자동설정 클래스 이름을 명시적으로 막는다.
|
||||
|
||||
`DisabledMessagePublisher`와 `DisabledOutboxMessagePublisher`가 별도 클래스인 이유도 README가 적는다 — 한 클래스가 두 포트를 모두 구현하면 `getBean(MessagePublisher.class)`가 모호해진다.
|
||||
|
||||
## 3. P2 — `check`에 붙은 `verifyJsonSchemaRuntimeGraph`가 실행되면 실패한다
|
||||
|
||||
이 leaf의 중심 규율은 JSON 검증 런타임을 닫는 것이고, 그것을 강제하는 태스크가 `check`에 붙어 있다. **실행 probe 결과 그 태스크는 실패한다.**
|
||||
|
||||
```
|
||||
$ ./gradlew :adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph
|
||||
FAILED
|
||||
> Messaging JSON runtime is missing required locked module tools.jackson.core:jackson-core:3.0.2
|
||||
EXIT=1
|
||||
```
|
||||
|
||||
원인은 태스크가 **정확한 패치 버전까지 하드코딩**한 데 있다.
|
||||
|
||||
```groovy
|
||||
[
|
||||
'com.networknt:json-schema-validator:3.0.2',
|
||||
'tools.jackson.core:jackson-core:3.0.2',
|
||||
'tools.jackson.core:jackson-databind:3.0.2'
|
||||
].each { String required ->
|
||||
if (!modules.contains(required)) { throw new GradleException("… missing required locked module ${required}") }
|
||||
}
|
||||
```
|
||||
|
||||
그런데 잠긴 실제 좌표는 다르다.
|
||||
|
||||
```
|
||||
com.networknt:json-schema-validator:3.0.2 ← 일치
|
||||
tools.jackson.core:jackson-core:3.1.5 ← 3.0.2 아님
|
||||
tools.jackson.core:jackson-databind:3.1.5 ← 3.0.2 아님
|
||||
tools.jackson:jackson-bom:3.1.5
|
||||
```
|
||||
|
||||
Jackson 3 BOM이 3.1.5로 올라가면서 두 좌표가 어긋났고, 태스크는 그것을 "필수 모듈 누락"으로 보고 빌드를 깬다.
|
||||
|
||||
**판정: P2.** 금지 조건 쪽(YAML 계열·Jackson 2 `core`/`databind` 부재)은 여전히 옳게 동작하지만, 필수 조건 쪽이 버전 드리프트로 고장 나 있어 **게이트 전체가 통과할 수 없다.** 결과는 이 저장소가 다른 곳에서 반복해 경계한 바로 그 상태다 — 붙어 있으나 초록일 수 없는 게이트는 사람들이 건너뛰는 법을 배우게 만든다. 수정은 필수 좌표에서 버전을 떼고 `group:name`만 확인하거나(닫힘 조건은 "무엇이 없는가"이지 "어느 패치인가"가 아니다), 잠금 파일에서 버전을 읽어 비교하는 것이다.
|
||||
|
||||
## 4. P3 — README의 `jackson-databind` 부재 주장이 현재 상태와 어긋난다
|
||||
|
||||
README:36이 손수 짠 JSON 직렬화의 근거를 적는다.
|
||||
|
||||
> "이 모듈은 `jackson-databind` 를 classpath 에 두지 않아(스켈레톤을 가볍게 유지) outbox envelope 직렬화는 의존성 없는 손수 짠 JSON 이다."
|
||||
|
||||
잠금 파일에는 `tools.jackson.core:jackson-databind:3.1.5`가 `compileClasspath`와 `runtimeClasspath` 양쪽에 있고, `build.gradle`의 검증 태스크는 그 모듈이 **있어야 한다**고 요구한다(§3). Jackson 2의 `com.fasterxml.jackson.core:jackson-databind`는 실제로 금지돼 있으므로 서술이 그 네임스페이스를 뜻했다면 맞지만, 문장은 네임스페이스를 한정하지 않는다.
|
||||
|
||||
**판정: P3.** 코드 결함은 아니다 — `OutboxEnvelopeJson`의 손수 짠 직렬화는 그 자체로 문제가 없다. 기록하는 이유는 그 선택의 **근거로 적힌 사실이 더 이상 성립하지 않는다**는 점이고, fork가 그 문장을 읽고 "databind가 없다"를 전제로 다른 결정을 내릴 수 있기 때문이다.
|
||||
|
||||
## 5. P3/기록 — 컴파일된 서술자 계열이 production 소비자를 갖지 않는다
|
||||
|
||||
이 leaf의 main은 두 반쪽으로 나뉜다.
|
||||
|
||||
| 반쪽 | 파일 | LOC | production 소비자 |
|
||||
|---|---|---|---|
|
||||
| broker/publisher | `core` 5 · `outbox` 4 · `kafka` 5 · 루트 2 | 883 | app-bootstrap 5개 파일이 import |
|
||||
| 컴파일된 계약 | `envelope` 5 · `contract` 3 · `destination` 4 · `config` 2 | **3,363 (79%)** | **0** |
|
||||
|
||||
`contract`·`destination`·`envelope`·`config` 네 패키지를 참조하는 파일은 leaf 밖에 **하나도 없다**(`177-...` §8.4c, 매치 0). app-bootstrap이 import하는 것은 `core`·`outbox`·`kafka`·`MessagingConfig`뿐이다. 그리고 `CompiledMessagingDescriptor`는 leaf의 `main` 안에서도 참조가 0이다 — 유일한 소비자가 `DestinationBindingCompilerTest`다.
|
||||
|
||||
이것을 결함으로 올리지 않는 이유가 있다. `build.gradle`의 qualification lane 둘(`messagingCompiledContractsQualificationTest`·`messagingJsonSchemaV1QualificationTest`)이 이 절반을 **증거 산출 목적**으로 실행하고, JUnit XML을 루트의 `build/test-results/messaging-evidence/{compiled,json-schema}`로 내며, 루트 `build.gradle`의 `messagingVerificationSkeletons`가 `build/messaging-evidence/contracts-schema/manifest.json`을 요구한다. 즉 이 절반은 **애플리케이션에 조립되기 위한 것이 아니라 저장소 수준 readiness 증거를 만들기 위한 것**으로 보인다.
|
||||
|
||||
기록하는 이유는 그 사실이 **어디에도 적혀 있지 않다**는 점이다. README는 이 leaf를 "메시징(broker publish + outbox) 아웃바운드 어댑터 모듈"로 소개하고 broker 선택·비활성 sentinel·`OutboxEnvelopeJson`만 설명한다 — 전체 main LOC의 79%를 차지하는 계약·목적지·봉투·스키마 절반에 대해 **한 줄도 없다.** fork가 README만 읽으면 이 leaf가 무엇을 담고 있는지 알 수 없다.
|
||||
|
||||
## 6. Negative-space probes — sub-scope 01
|
||||
|
||||
- **8.1 활성화 등록**: `spring.factories`의 import filter와 `AutoConfiguration.imports`의 루트 자동설정이 실제로 등록됨을 파일 내용으로 확인.
|
||||
- **8.2 도달성**: 컴파일된 서술자 계열 5종의 leaf-main·app-bootstrap 참조 수 계수 — `CompiledMessagingDescriptor` 0/0(§5).
|
||||
- **8.3 스위치 ↔ 선택자**: `@ConditionalOnProperty` 전수(`app.messaging.enabled` 1곳, `app.messaging.broker=kafka` 1곳)와 그 관계 확인(§2).
|
||||
- **8.4 실행 probe**: `verifyJsonSchemaRuntimeGraph`를 실행해 실패를 확정하고 잠금 파일과 대조(§3).
|
||||
|
||||
## 7. Sub-scope 01 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| **P2** | `check`에 붙은 `verifyJsonSchemaRuntimeGraph`가 필수 좌표의 패치 버전을 하드코딩해 Jackson 3 BOM 3.1.5 아래에서 **항상 실패**한다 — 닫힌 런타임 보증이 실제로는 검증되지 않는다 | 이 모듈의 모든 `check` |
|
||||
| **P3** | README:36의 "`jackson-databind`를 classpath에 두지 않는다"가 잠금 파일(`tools.jackson.core:jackson-databind:3.1.5`, compile+runtime)과 어긋난다 | 문서 |
|
||||
| P3/기록 | 계약·목적지·봉투·스키마 절반(main LOC의 79%)이 production 소비자 0이고, README가 그 절반의 존재와 목적을 전혀 설명하지 않는다 | 이 leaf를 읽는 fork |
|
||||
|
||||
## 8. Sub-scope 01 완료 조건
|
||||
|
||||
- denominator 14 / 14 FULL_READ (`177-...` OWNED FILES)
|
||||
- §8.1~§8.4 probe 수행, 실행 probe 1건으로 게이트 실패 확정
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 9. Sub-scope 02 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **21 / 21 FULL_READ**
|
||||
> 범위: `envelope/**` main 5 (1,573 LOC) + 전용 test 3 (1,140 LOC) + 리소스 13 (핀 고정 메타스키마 10 + test 벡터 3)
|
||||
> 역할: 닫힌 Draft 2020-12 스키마 레지스트리와 결정적 봉투 직렬화
|
||||
|
||||
manifest와 probe: `evidence/raw/178-outbound-messaging-envelope-probes.txt`.
|
||||
|
||||
## 10. 레지스트리가 "닫혀 있다"는 것의 의미
|
||||
|
||||
`LocalJsonSchemaRegistry`의 한 줄 요약이 계약이다 — "Immutable, startup-compiled Draft 2020-12 registry backed **only by explicitly supplied bytes**. Every reference is checked before NetworkNT compilation. After construction this type exposes **no loader, URL, file or classpath fetch operation**."
|
||||
|
||||
닫힘이 네 겹으로 표현된다.
|
||||
|
||||
1. **어휘 allowlist** — `KNOWN_VOCABULARIES` 8종(core·applicator·unevaluated·validation·meta-data·format-annotation·format-assertion·content) 밖의 `$vocabulary` 항목은 거부된다.
|
||||
2. **키워드 부분집합** — `$anchor`·`$dynamicRef`·`$dynamicAnchor`·`$recursiveRef`·`$recursiveAnchor` 다섯이 `UNSUPPORTED_CLOSED_SUBSET_KEYWORDS`로 **문서 어디에서든** 거부된다(test `rejectsDynamicRecursiveAndAnchorKeywordsEverywhereInTheClosedSubset`).
|
||||
3. **참조 사전 검사** — `validateAllReferences`가 NetworkNT 컴파일 **전에** 모든 `$ref`를 확인하고, 원격 참조와 설정된 깊이를 넘는 참조 그래프를 거부한다.
|
||||
4. **핀 고정된 메타스키마 권위** — 9개 Draft 2020-12 메타 문서를 리소스로 동봉하고 `authority.sha256` 매니페스트로 해시를 고정하며, 도메인 분리 상수(`ca-skeleton.messaging.draft-2020-12-authority.v1`)를 섞는다. 매니페스트는 UTF-8 디코딩을 `REPORT` 모드로 읽어 잘못된 바이트를 조용히 대체하지 않는다.
|
||||
|
||||
**실행 probe로 매니페스트를 검증했다** — 동봉된 9개 파일의 SHA-256이 `authority.sha256`의 아홉 줄과 **전부 일치한다**(`178-...` §8.3). 즉 핀이 실제로 현재 파일을 가리킨다.
|
||||
|
||||
`$id`는 정확한 URN 스킴만 허용하고(`acceptsOnlyExactUrnSchemeForRootIdentifiersAndAbsoluteReferences`), 중첩 `$id`는 상대·절대 어느 쪽도 허용하지 않으며 **값 타입과 무관하게 키 자체를** 거부한다(`rejectsNestedSchemaIdentifierKeysRegardlessOfValueType`).
|
||||
|
||||
## 11. 봉투 작성이 파서를 거치지 않는다
|
||||
|
||||
`DeterministicEnvelopeWriter`는 페이로드를 **선언된 shape을 따라 스냅샷**한 뒤 그 정확한 바이트를 봉투에 끼워 넣는다 — "those exact trusted bytes are then embedded in the envelope **without any raw JSON parser or generator API**." `embedExactPayload`가 `,"payload":` 리터럴로 이어 붙이는 방식이다.
|
||||
|
||||
입력 검증이 촘촘하다 — draft의 페이로드가 **정확히 등록된 final record 클래스**여야 하고(`exactPayloadClassIsRequiredAndNoAssignableTypeSearchOccurs`), contractId와 payloadVersion이 컴파일된 계약과 같아야 하며, 레코드 성분 수·문자열 UTF-8 길이·배열/객체 크기·깊이가 모두 `EnvelopeAdmissionLimits`로 유계다. 그리고 **쓰는 도중에** 출력 크기를 본다(`boundsJsonOutputDuringWritesInsteadOfOnlyInspectingTheCompletedBuffer`).
|
||||
|
||||
가변 페이로드 처리도 명시적이다 — `snapshotsStatefulMutablePayloadAccessorsOnceAndEmbedsThoseExactBytes`. 접근자를 한 번만 부르고 그 바이트를 고정하므로, httpclient의 `ObjectBody` 문제(같은 키로 다른 바이트)가 여기서는 구조적으로 불가능하다.
|
||||
|
||||
## 12. 적대적 코퍼스가 이 leaf의 test 밀도를 설명한다
|
||||
|
||||
test 3파일 1,140줄이 main 1,573줄을 덮고, 이름이 하나씩 구체적인 공격 형태다.
|
||||
|
||||
- 파서 경계: 짝 없는 서로게이트, 비유한 수, 깊이, 숫자 범위 — **검증 전에** 거부
|
||||
- 스키마 입력: 중복 키, 잘못된 UTF-8, 뒤따르는 쓰레기, 예산 초과 정규식
|
||||
- 수 처리: `rejectsExtremePositiveDecimalScaleBeforePlainStringAllocation` — 큰 scale의 `BigDecimal`을 평문 문자열로 만들기 **전에** 거부(메모리 폭발 방지)
|
||||
- 컬렉션: `checksListSizeBeforeIterationAndFailsClosedOnMutationOrConcurrency`
|
||||
- 해시: `exactEnvelopeHashHasDomainSeparatedGoldenVectorAndDefensiveShaValue` — 도메인 분리와 골든 벡터
|
||||
- 권위: `startupAuthorityDoesNotDependOnARegularNetworkNtCodeSourceJar` — 핀 검증이 라이브러리 자신의 jar에 기대지 않음
|
||||
|
||||
## 13. Negative-space probes — sub-scope 02
|
||||
|
||||
- **8.1 닫힘의 실제 강제**: 어휘 allowlist·키워드 부분집합·참조 사전 검사의 코드 지점을 각각 확인.
|
||||
- **8.2 핀 무결성**: `authority.sha256`의 9줄과 동봉 파일의 실제 SHA-256을 **실행으로 대조 — 전부 일치**(§10).
|
||||
- **8.3 파서 우회**: 봉투 작성이 원시 JSON 파서/생성기 API를 쓰지 않고 스냅샷 바이트를 끼워 넣는 경로 확인(§11).
|
||||
- **8.4 적대적 커버리지**: test 29개 메서드의 이름을 공격 형태별로 분류(§12).
|
||||
|
||||
## 14. Sub-scope 02 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| — | **없음.** 어휘·키워드·참조·메타스키마 권위 네 겹이 모두 강제되고, 핀 해시가 실제 파일과 일치하며, 봉투 작성이 파서를 거치지 않고, 적대적 코퍼스가 파서·수·컬렉션·해시 경계를 이름으로 고정한다 | — |
|
||||
|
||||
## 15. Sub-scope 02 완료 조건
|
||||
|
||||
- denominator 21 / 21 FULL_READ (`178-...` OWNED FILES)
|
||||
- §8.1~§8.4 probe 수행, 실행 probe 1건으로 핀 매니페스트 무결성 확정
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 16. Sub-scope 03 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **13 / 13 FULL_READ**
|
||||
> 범위: `contract/**` 3 + `destination/**` 4 (main 7, 1,351 LOC) + 전용 test 6
|
||||
> 역할: 통합 이벤트 계약을 컴파일해 닫고, 목적지 바인딩과 파티션 키를 결정적으로 유도한다
|
||||
|
||||
manifest와 probe: `evidence/raw/179-outbound-messaging-contract-destination-probes.txt`.
|
||||
|
||||
## 17. 계약이 컴파일되어 닫힌다
|
||||
|
||||
`ContractCatalogCompiler`가 **정확한 record 타입 토큰**으로부터 불변 카탈로그를 만들고, test 이름이 무엇을 거부하는지 전부 적는다 — 중복 stable/schema/payload 신원, 음수 버전, 잘못된 payload kind, null·공백·중복·반사 불일치 성분 순서, 서술자 누락, **payload 버전 사이의 logical destination 드리프트**.
|
||||
|
||||
특히 두 test가 이 계층의 성격을 보여 준다.
|
||||
|
||||
- `recursivelyFreezesOnlyTheClosedDeclaredGenericPayloadGraph` / `rejectsOpenRawWildcardMapJsonTreeInterfaceAndGenericRecordGraphs` — 열린 타입(raw·wildcard·`Map`·JSON 트리·인터페이스·제네릭 record 그래프)을 페이로드로 받지 않는다. 봉투 작성기가 shape을 따라 스냅샷할 수 있으려면 그래프가 닫혀 있어야 한다(§11).
|
||||
- `snapshotsEveryContributionAccessorExactlyOnceIncludingAStatefulSchemaHash` / `statefulDescriptorCannotBypassCrossVersionLogicalDestinationDrift` — 기여 접근자를 **정확히 한 번만** 호출한다. 가변 서술자가 검사와 저장 사이에 값을 바꿔 규칙을 우회하는 경로를 닫는다.
|
||||
|
||||
`compiledContractUsesOnlyAStaticPublicCompositionBridgeWithoutReflectionLeak` — 컴파일된 계약이 반사를 밖으로 새게 하지 않는다.
|
||||
|
||||
## 18. 도메인 분리 + 길이 프레이밍이 일곱 곳에서 일관된다
|
||||
|
||||
이 leaf의 모든 다이제스트가 같은 형태다 — 버전이 붙은 도메인 상수, `\0` 구분, 각 필드의 태그와 값을 **4바이트 길이로 프레이밍**.
|
||||
|
||||
| 상수 | 위치 |
|
||||
|---|---|
|
||||
| `ca-skeleton.messaging.contract-catalog-digest.v1` | `ContractCatalogDigest:16` |
|
||||
| `ca-skeleton.messaging.destination-settings-digest.v1` | `DestinationBindingCompiler:95` |
|
||||
| `ca-skeleton.messaging.schema-set-digest.v1` | `DestinationBindingCompiler:121` |
|
||||
| `ca-skeleton.messaging.partition-key.v1` | `PartitionKeyV1:22` |
|
||||
| `ca-skeleton.messaging.envelope.v1` | `EnvelopeHashV1:13` |
|
||||
| `ca-skeleton.messaging.schema-set.v1` | `JsonSchemaIntegrationEventEncoder:25` |
|
||||
| `ca-skeleton.messaging.draft-2020-12-authority.v1` | `LocalJsonSchemaRegistry:64` |
|
||||
|
||||
카탈로그 다이제스트는 **입력 순서와 무관**하다(contractId + payloadVersion으로 정렬 후 소화) — test `digestIsDeterministicForEmptyAndInputOrderIndependentForNonEmptyCatalogs`. 그리고 `digestChangesForSchemaHashDescriptorAndCanonicalComponentOrderSemantics`가 무엇이 바뀌면 다이제스트가 바뀌어야 하는지를 고정한다.
|
||||
|
||||
`PartitionKeyV1`은 **교차 언어 벡터 진입점**을 명시적으로 제공하고("Callers retain ownership of their canonical component grammar; this method **never substitutes a missing tenant scope**"), 골든 벡터 test 둘이 있다 — 소문자 hex와 정확한 ASCII 바이트, 그리고 `nonAsciiAggregateIdGoldenVectorUsesUtf8ByteLengthNotCharacterCount`(길이 프레이밍이 문자 수가 아니라 UTF-8 바이트 수임).
|
||||
|
||||
`DestinationBindingCompiler`는 코드 최대치와 배포 최대치의 **교집합**을 취하고, 배포 쪽이 낮으면 그것이 이기되 양수여야 한다(`deploymentMaximumBelowCodeMaximumWinsAndMustRemainPositive`).
|
||||
|
||||
## 19. Sub-scope 03 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| — | **없음.** 계약 그래프가 닫혀 있고, 기여 접근자가 한 번만 호출되며, 일곱 다이제스트가 도메인 분리와 길이 프레이밍을 일관되게 쓰고 골든 벡터로 고정된다 | — |
|
||||
|
||||
---
|
||||
|
||||
## 20. Sub-scope 04 범위와 denominator
|
||||
|
||||
> 내부 상태: COMPLETE — **21 / 21 FULL_READ**
|
||||
> 범위: `core` 5 + `kafka` 5 + `outbox` 4 + `realtime` 2 (main 16, 632 LOC) + 전용 test 5
|
||||
> 역할: 실제로 조립되는 절반 — broker 추상화, fail-open/fail-closed 두 발행 경로, Kafka seam, 실시간 fan-out
|
||||
|
||||
manifest: `evidence/raw/176-outbound-messaging-module-inventory.txt`의 OWNED FILES 절.
|
||||
|
||||
## 21. 두 발행 경로의 실패 정책이 정반대이고 그 이유가 적혀 있다
|
||||
|
||||
| 포트 | 정책 | 근거 |
|
||||
|---|---|---|
|
||||
| `MessagePublisher` → `OutboundMessagePublisher` | **fail-open** | "a broker outage must never turn a core use case into a 5xx (durable delivery is delegated to the outbox/retry path)" |
|
||||
| `OutboxMessagePublishPort` → `OutboxMessagePublishAdapter` | **fail-closed** | 실패가 그대로 전파되어 relay가 FAILED/DEAD 전이를 몰 수 있게 한다 |
|
||||
|
||||
`OutboundMessagePublisher.publish`에 이 저장소에서 반복해 본 종류의 수정 이력이 있다.
|
||||
|
||||
> "The send and the observation are separate steps because they used to share a try block: **a logger that threw after a successful send was caught by the same catch and reported as a publish failure.** The broker had accepted the message; the only thing that failed was the record of it, and the two must not be confusable."
|
||||
|
||||
그리고 관측 자체가 결과를 바꾸지 못한다 — `observeQuietly`가 진단 예외를 흡수하며 "Diagnostics are non-authoritative. **An appender that is out of disk must not change what the caller believes about the broker.**"
|
||||
|
||||
비활성 sentinel 둘은 조용한 no-op이 아니라 `AdapterDisabledException`을 던지고, 서로 다른 클래스로 분리된 이유가 bean 조회 모호성이다(§2).
|
||||
|
||||
## 22. `BrokerAddress` — 정규식을 파서로 바꾼 기록
|
||||
|
||||
javadoc이 이전 정규식이 받아들이던 것 넷을 열거한다.
|
||||
|
||||
> "It ran against the *trimmed* value but the **untrimmed original was what got stored**, so `" kafka:9092"` passed validation and was then handed to the client with its leading space. `\d{1,5}` accepts `0` and `99999`, neither of which is a port. And `[^:\s]+` cannot express a bracketed IPv6 literal at all, so `[::1]:9092` — the only correct way to write an IPv6 endpoint — was rejected while `::1:9092` was accepted and is ambiguous."
|
||||
|
||||
지금은 손수 짠 파서가 대괄호 IPv6를 정확히 다루고(닫는 대괄호 뒤에 `:port`가 없으면 거부, 빈 host 거부), 포트를 1..65535로 강제하며, 정규화된 형태로 저장한다.
|
||||
|
||||
## 23. Confirmed — 이스케이프 없이 삽입되는 outbox 페이로드는 상류에서 강제된다 (후보 → 결함 아님)
|
||||
|
||||
`OutboxEnvelopeJson.toJson`은 `event.payload()`를 **이스케이프 없이 그대로** 봉투에 넣는다 — "MUST already be a valid serialised JSON value; it is inserted verbatim (no escaping)". 강제되지 않으면 JSON 주입 지점이다.
|
||||
|
||||
강제된다. `application-core`의 `OutboxEvent` 정규 생성자가 `OutboxPayloadPolicy.requireValidPayload(payload)`를 호출하고, 그 자리 주석이 위험을 그대로 적는다 — "the envelope serialiser inserts this verbatim and unescaped, so an invalid or oversized payload becomes **a permanently unparseable message that the relay retries forever**." **결함 아님.**
|
||||
|
||||
## 24. `realtime` 두 파일의 자기 한정
|
||||
|
||||
`MessagingDurableFanoutAdapter`는 옆의 ephemeral fan-out과 달리 **fail-closed**이고, 파티션 키가 채널이 아니라 **수신자**의 것이며, "Nothing here deduplicates. Delivery is at-least-once by construction and the receiver holds" — 중복 제거 책임이 수신자에게 있음을 명시한다.
|
||||
|
||||
`RealtimeFanoutEnvelopeJson`의 리더는 **알 수 없는 필드를 허용하고 없는 필드를 거부**한다 — "During a rolling deploy both" 버전이 동시에 쓰므로, 새 필드를 추가한 쪽이 옛 쪽의 항목을 깨지 않게 하는 방향이다(httpclient의 `RegistrationCodec`과 같은 논리).
|
||||
|
||||
## 25. Negative-space probes — sub-scope 03·04
|
||||
|
||||
- **8.1 다이제스트 일관성**: 도메인 분리 상수 7종과 길이 프레이밍 방식을 전수 대조(§18).
|
||||
- **8.2 조건부 형제**: 같은 broker 위에 놓인 두 발행 경로의 실패 정책이 정반대이고 각각 근거를 가짐(§21). `realtime`의 durable/ephemeral 쌍도 같은 형태(§24).
|
||||
- **8.3 이스케이프 없는 삽입**: `OutboxEnvelopeJson`의 verbatim 삽입을 상류 `OutboxEvent` 생성자의 강제로 추적해 오탐 판정(§23).
|
||||
- **8.4 파서 대 정규식**: `BrokerAddress`가 정규식이 받아들이던 네 가지 비주소를 각각 거부하는지 확인(§22).
|
||||
|
||||
## 26. Sub-scope 03·04 findings backlog
|
||||
|
||||
| 우선순위 | finding | reachability |
|
||||
|---|---|---|
|
||||
| P3/기록 | `OutboxEnvelopeJson`의 클래스 javadoc이 README:36과 같은 주장("no Jackson — the module deliberately keeps `jackson-databind` off its classpath")을 반복하며, 잠금 파일의 `tools.jackson.core:jackson-databind:3.1.5`와 어긋난다 — §4의 같은 drift가 코드 주석에도 있다 | 문서 |
|
||||
|
||||
## 27. Sub-scope 03·04 완료 조건
|
||||
|
||||
- denominator 13 / 13 및 21 / 21 FULL_READ
|
||||
- §8.1~§8.4 probe 수행, 조건부 형제 비교 2건
|
||||
- 후보 finding 1건(이스케이프 없는 페이로드 삽입)을 상류 강제로 추적해 결함 아님으로 판정(§23)
|
||||
- 소스 미변경
|
||||
|
||||
---
|
||||
|
||||
## 28. 모듈 ledger 정합
|
||||
|
||||
| # | 범위 | main | test | 기타 | 합 | FULL_READ | probe |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | governance + 루트 + `autoconfigure` + `config` | 6 | 2 | 6 | 14 | 14 | `176`, `177` |
|
||||
| 2 | `envelope/**` + 메타 리소스 | 5 | 3 | 13 | 21 | 21 | `178` |
|
||||
| 3 | `contract/**` + `destination/**` + qualification | 7 | 6 | – | 13 | 13 | `179` |
|
||||
| 4 | `core` + `kafka` + `outbox` + `realtime` | 16 | 5 | – | 21 | 21 | `176` |
|
||||
| | **TOTAL** | **34** | **16** | **19** | **69** | **69** | **4 / 4** |
|
||||
|
||||
coverage ledger: `FULL_READ` **69** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / 미분류 **0**.
|
||||
|
||||
## 29. 모듈 findings
|
||||
|
||||
| # | 우선순위 | finding | 위치 |
|
||||
|---|---|---|---|
|
||||
| 1 | **P2** | `check`에 붙은 `verifyJsonSchemaRuntimeGraph`가 필수 좌표의 패치 버전(`3.0.2`)을 하드코딩해 잠긴 Jackson 3 BOM(`3.1.5`) 아래에서 **항상 실패**한다 — 닫힌 JSON 런타임 보증이 실제로 검증되지 않는다 | §3 |
|
||||
| 2 | **P3** | README:36과 `OutboxEnvelopeJson`의 클래스 javadoc이 "`jackson-databind`를 classpath에 두지 않는다"고 적지만 잠금 파일에 `tools.jackson.core:jackson-databind:3.1.5`가 compile+runtime으로 있다 | §4, §26 |
|
||||
| 3 | P3/기록 | 계약·목적지·봉투·스키마 절반(main LOC의 **79%**)이 production 소비자 0이고 README가 그 존재와 목적(증거 산출)을 전혀 설명하지 않는다 | §5 |
|
||||
|
||||
**결함 아님으로 판정한 후보 1건** — `OutboxEnvelopeJson`의 이스케이프 없는 페이로드 삽입을 `OutboxEvent` 생성자의 `OutboxPayloadPolicy.requireValidPayload`로 추적(§23).
|
||||
|
||||
## 30. 이 모듈에서 반복해서 나타난 패턴
|
||||
|
||||
- **스위치와 선택자의 분리.** "That is a selector doing a switch's job" — `app.messaging.enabled`가 여부를, `app.messaging.broker`가 무엇을 결정한다. 그리고 Boot의 broker 자동설정이 라이브러리 존재만으로 들어오는 경로를 import filter로 막는다.
|
||||
- **도메인 분리 + 길이 프레이밍이 예외 없이 일곱 곳.** 모든 다이제스트가 버전 붙은 도메인 상수와 4바이트 길이 프레이밍을 쓰고, 골든 벡터로 고정된다.
|
||||
- **닫힘을 네 겹으로 표현.** 어휘 allowlist, 키워드 부분집합, 참조 사전 검사, 핀 고정 메타스키마 — 그리고 그 핀이 실제 파일과 일치함을 실행으로 확인했다.
|
||||
- **관측이 결과를 바꾸지 못한다.** `observeQuietly`와 send/observe 분리 — httpclient의 `NoThrowObservationSink`와 같은 규칙이 다른 모듈에서 독립적으로 나타난다.
|
||||
- **그리고 이 모듈의 P2도 같은 계열이다** — 규율을 강제하려고 만든 게이트가 버전 드리프트로 통과할 수 없게 됐다. httpclient의 §51(test가 잡았는데 고쳐지지 않음)과 같은 방향이다: **검증 장치 자체가 빨간 채로 남아 있다.**
|
||||
|
||||
## 31. 검증
|
||||
|
||||
`evidence/raw/180-outbound-messaging-suite-verification.txt`.
|
||||
|
||||
```
|
||||
$ ./gradlew :adapter:outbound:messaging:test → BUILD SUCCESSFUL
|
||||
classes=17 tests=92 failures=0 errors=0 skipped=0
|
||||
|
||||
$ ./gradlew :adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph → FAILED (EXIT=1)
|
||||
> Messaging JSON runtime is missing required locked module tools.jackson.core:jackson-core:3.0.2
|
||||
|
||||
$ git status --short → 0
|
||||
```
|
||||
|
||||
`:test`는 92건 전원 통과하고 skip이 0이다. `:check`는 §3의 게이트에서 실패한다 — **이 모듈 소스의 결함이 아니라 게이트 자신의 버전 하드코딩** 때문이다.
|
||||
|
||||
qualification lane 둘(`messagingCompiledContractsQualificationTest`·`messagingJsonSchemaV1QualificationTest`)은 루트의 `:prepareMessagingContractEvidence`에 의존하는 저장소 수준 증거 파이프라인의 일부이므로 이 분석에서 실행하지 않았다.
|
||||
|
||||
## 32. 모듈 완료 조건
|
||||
|
||||
- denominator 69 / 69 FULL_READ, `STRUCTURAL_ONLY` 0, `EXCLUDED` 0, 미분류 0 (§28)
|
||||
- 4개 하위 범위 전부 §8.1~§8.4 negative-space probe 수행, evidence `176`~`180` 5건 생성
|
||||
- 실행 probe 2건 — 핀 매니페스트 무결성 확인(통과), `verifyJsonSchemaRuntimeGraph` 실패 확정
|
||||
- 후보 finding 1건을 상류 강제로 추적해 결함 아님으로 판정
|
||||
- 소스 미변경
|
||||
|
||||
## Source anchors
|
||||
|
||||
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **21개** (main 19 · test 1 · 기타 1).
|
||||
|
||||
```
|
||||
src/adapter/outbound/messaging/build.gradle
|
||||
src/config/architecture/modules.json (adapter-outbound-messaging 항목)
|
||||
|
||||
main:
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingBridgeRootAutoConfiguration.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingOffAutoConfigurationImportFilter.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/config/CompiledMessagingDescriptor.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/contract/ContractCatalogCompiler.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/core/DisabledMessagePublisher.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/core/MessagePublisher.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/core/OutboundMessagePublisher.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingCompiler.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/destination/PartitionKeyV1.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/DeterministicEnvelopeWriter.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/EnvelopeAdmissionLimits.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/envelope/LocalJsonSchemaRegistry.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/kafka/BrokerAddress.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/DisabledOutboxMessagePublisher.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxEnvelopeJson.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/realtime/MessagingDurableFanoutAdapter.java
|
||||
src/main/java/dev/caskeleton/adapter/outbound/messaging/realtime/RealtimeFanoutEnvelopeJson.java
|
||||
|
||||
test:
|
||||
src/test/java/dev/caskeleton/adapter/outbound/messaging/destination/DestinationBindingCompilerTest.java
|
||||
|
||||
기타:
|
||||
src/build.gradle
|
||||
|
||||
해석되지 않은 인용 (6종) — 외부 타입·문서상 약칭 등:
|
||||
evidence/raw/176-outbound-messaging-module-inventory.txt
|
||||
evidence/raw/177-outbound-messaging-activation-probes.txt
|
||||
build/messaging-evidence/contracts-schema/manifest.json
|
||||
evidence/raw/178-outbound-messaging-envelope-probes.txt
|
||||
evidence/raw/179-outbound-messaging-contract-destination-probes.txt
|
||||
evidence/raw/180-outbound-messaging-suite-verification.txt
|
||||
|
||||
```
|
||||
@@ -0,0 +1,289 @@
|
||||
# adapter-inbound-grpc — 코드베이스 분석
|
||||
|
||||
|
||||
## SSOT identity — 2026-08-31 재검증
|
||||
|
||||
- registered leaf id: `adapter-inbound-grpc`
|
||||
- canonical state `analysisFile`: `analysis/15-adapter-inbound-grpc.md` (이 문서) — 이 leaf의 단일 SSOT
|
||||
- source path: `src/adapter/inbound/grpc` · Gradle `:adapter:inbound:grpc`
|
||||
- registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract"]`
|
||||
- registry `runtime_memberships`: **`[]`**
|
||||
- coverage ledger: `FULL_READ` **18** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
|
||||
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
|
||||
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행)
|
||||
|
||||
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
|
||||
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
|
||||
|
||||
---
|
||||
> **분석 대상** `src/adapter/inbound/grpc` · revision `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
|
||||
> **분모** 18 tracked files (main 8 · test 6 · governance 4) — 단일 bounded scope
|
||||
> **LOC** main Java 602 · test Java 782
|
||||
> **근거** `evidence/raw/204-inbound-grpc-probes.txt` (`file_count=18`)
|
||||
|
||||
## 1. 커버리지 원장
|
||||
|
||||
| # | scope | main | test | governance | 합 | 상태 |
|
||||
|---|---|---:|---:|---:|---:|---|
|
||||
| 1 | 모듈 전체 (단일 bounded scope) | 8 | 6 | 4 | 18 | **COMPLETE** |
|
||||
|
||||
`FULL_READ 18 / 18 · STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0`.
|
||||
|
||||
지금까지 분석한 15개 모듈 중 가장 작다. inbound-web(638)의 2.8%이고, main 파일이 8개라 sub-scope 분할이 의미를 갖지 않는다.
|
||||
|
||||
## 2. 무엇을 하는 코드인가
|
||||
|
||||
**전송 인프라만.** 서버 수명주기 · 타입드 설정 · 인증 정책 경계 · 에러 매핑, 그리고 `.proto` 없이 부팅하는 최소 표면(standard health, 명시 opt-in 시 reflection).
|
||||
|
||||
`build.gradle`의 첫 세 결정이 이 모듈의 성격을 정한다:
|
||||
|
||||
- **third-party starter 없음.** `SmartLifecycle` 빈(`GrpcServerRunner`)이 `io.grpc` Netty 서버를 직접 소유한다 — "so this module depends on NO third-party grpc-spring-boot starter (no Spring Boot version coupling)."
|
||||
- **protobuf 컴파일 없음.** `com.google.protobuf` 플러그인도 `.proto`도 없다. health와 reflection은 `grpc-services`가 런타임에 제공하고, 향후 feature가 자기 `.proto`를 소유한다.
|
||||
- **BOM을 모듈 스코프에서 import.** `io.grpc:*`/protobuf 버전은 Spring Boot BOM이 관리하지 않으므로 `grpc-bom`/`protobuf-bom`을 여기서 가져온다 — "this keeps the strict-locking blast radius to this module (the shared root dependencyManagement block stays io.grpc-free)."
|
||||
|
||||
**feature-agnostic 등록.** `GrpcServerRunner`가 모든 `BindableService` 빈을 `ObjectProvider`로 받아 이름을 모른 채 등록한다. 그리고 그 등록에 조건을 건다:
|
||||
|
||||
```java
|
||||
// GrpcServerRunner.start()
|
||||
if (!featureServices.isEmpty() && policies.size() != 1) {
|
||||
throw new IllegalStateException(
|
||||
"feature gRPC services require exactly one caller-supplied authentication policy");
|
||||
}
|
||||
```
|
||||
|
||||
feature 서비스가 하나라도 있으면 caller-supplied 인증 정책이 **정확히 하나** 있어야 하고, 없거나 둘 이상이면 listener가 시작되지 않는다. 그래서 "인증 없이 노출된 RPC"가 구조적으로 불가능하다.
|
||||
|
||||
**활성화가 삼중으로 닫혀 있다.**
|
||||
|
||||
```java
|
||||
// GrpcServerConfig
|
||||
@ConditionalOnProperty(prefix = "ca-skeleton.grpc", name = "enabled",
|
||||
havingValue = "true", matchIfMissing = false)
|
||||
|
||||
// GrpcServerProperties
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.grpc", ignoreUnknownFields = false)
|
||||
@Validated
|
||||
@AssertTrue(message = "insecure gRPC requires allow-insecure-local=true and a loopback bind address")
|
||||
public boolean isInsecureLocalConfigurationValid() {
|
||||
return !enabled || (allowInsecureLocal && isLoopbackBindAddress());
|
||||
}
|
||||
```
|
||||
|
||||
현재 transport credential이 plaintext뿐이므로, `enabled=true`는 `allowInsecureLocal=true`와 **실제로 loopback으로 해석되는** bind address를 함께 요구한다. `isLoopbackBindAddress()`는 문자열 비교가 아니라 `InetAddress.getByName(...).isLoopbackAddress()`로 판정하므로 `localhost`·`127.0.0.2`·`::1`이 모두 통과하고 `0.0.0.0`은 통과하지 않는다.
|
||||
|
||||
**에러 계약.** `GrpcStatusMapper`가 10값 `Category` SSOT를 gRPC `Status`로 옮기고, 정확한 `code`와 `category`는 트레일러(`error-code` / `error-category`)에 싣는다 — "the wire status (like an HTTP status) is coarse, while the exact `ApiErrorCode.code()` and the category name ride in the trailers."
|
||||
|
||||
`GrpcExceptionHandlingInterceptor`가 그 계약의 단일 지점이다. 네 개의 서로 다른 실패 경로를 하나의 sanitizer로 모은다:
|
||||
|
||||
| 경로 | 처리 |
|
||||
|---|---|
|
||||
| `next.startCall` 이 던짐 | `:47-50` catch → `closeWithError` |
|
||||
| 리스너 콜백(`onMessage`·`onHalfClose`·`onReady`·`onCancel`·`onComplete`)이 던짐 | `runGuarded` → `closeWithError` |
|
||||
| feature가 `responseObserver.onError(...)` | `ServerCalls`가 `call.close(...)` → sanitizing override |
|
||||
| feature가 raw `StatusRuntimeException` | 같은 override |
|
||||
|
||||
그리고 override가 **모든 non-OK close를 재작성**한다:
|
||||
|
||||
```java
|
||||
// sanitizingCall(...).close
|
||||
if (status.isOk()) { super.close(status, trailers); return; }
|
||||
ApiErrorCode code = errorCodeOf(status.getCause());
|
||||
if (code == null) { code = OperationalError.INTERNAL_ERROR; }
|
||||
super.close(statusMapper.toStatus(code.category()).withDescription(code.code()),
|
||||
statusMapper.trailersFor(code));
|
||||
```
|
||||
|
||||
호출자가 넘긴 description과 트레일러는 **버려진다**. javadoc이 그 이유를 적는다 — "raw descriptions and input trailers, which may carry a SQLState or upstream detail, are never surfaced." `closeWithError`가 `Status.fromThrowable(exception)`를 쓰는데도 원문이 새지 않는 것은 두 호출 지점(`:48` · `:84`)이 모두 `sanitizingCall`에 대고 부르기 때문이다.
|
||||
|
||||
## 3. Negative-space probes
|
||||
|
||||
### 3.1 (8.1) 도달성 — feature 표면이 존재하는가
|
||||
|
||||
```
|
||||
$ grep -rn 'BindableService|GrpcAuthenticationPolicy' --include=*.java . (grpc leaf 제외) -> 0
|
||||
$ grep -rn 'ca-skeleton.grpc' --include=*.yml --include=*.yaml . -> 0
|
||||
$ grep -rn 'inbound.grpc' --include=*.java app-bootstrap/src/main
|
||||
app-bootstrap/.../CaSkeletonApplication.java:71: "dev.caskeleton.adapter.inbound.grpc",
|
||||
```
|
||||
|
||||
`BindableService` 구현도, `GrpcAuthenticationPolicy` 구현도, `ca-skeleton.grpc` 설정값도 저장소에 없다. app-bootstrap이 이 leaf를 언급하는 곳은 `@ConfigurationPropertiesScan` 목록 한 줄뿐이다.
|
||||
|
||||
**이것은 결함이 아니라 선언된 상태다.** CLAUDE.md가 명시한다 — "현재 저장소에는 production feature RPC나 sample gRPC service가 없다. 향후 feature를 도입할 때는 `.proto`/generated stub/`BindableService`를 해당 feature가 소유하고, 서비스 빈과 정확히 한 개의 caller-supplied `GrpcAuthenticationPolicy` 빈을 함께 제공한다." 기본값이 `enabled=false`이므로 출하 배포에서 리스너가 뜨지 않는 것도 의도다.
|
||||
|
||||
앞선 두 모듈(notification · inbound-web)에서 반복해서 만난 "장치는 있고 회로가 닫히지 않았다"와 형태가 비슷해 보이지만 **다르다**: 저기서는 플랫폼이 설치해야 할 것을 설치하지 않았고, 여기서는 채택자가 기여할 자리를 비워 둔 것이며 그 사실이 문서와 기본값과 테스트(`missingActivationPropertyCreatesNoGrpcRuntimeBeansOrListener`)에 함께 적혀 있다.
|
||||
|
||||
### 3.2 (8.2) 조건 형제 비교 — cause chain 순회 관용구가 저장소에 두 가지다
|
||||
|
||||
`GrpcExceptionHandlingInterceptor.errorCodeOf`가 원인 사슬을 훑는다:
|
||||
|
||||
```java
|
||||
private static ApiErrorCode errorCodeOf(Throwable throwable) {
|
||||
Throwable current = throwable;
|
||||
while (current != null) {
|
||||
if (current instanceof ApiErrorCarrier carrier) { return carrier.errorCode(); }
|
||||
if (current.getCause() == current) { break; } // 자기참조만 감지
|
||||
current = current.getCause();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
같은 일을 하는 코드가 저장소에 아홉 곳 있고 두 갈래로 갈린다:
|
||||
|
||||
| 관용구 | 위치 |
|
||||
|---|---|
|
||||
| **깊이 제한** (어떤 순환에도 안전) | `web/auth/JwtDecoderConfig:63`(32) · `notification/.../NotificationSchedulerWorker:124`(8) · `notification/.../JdkNotificationHttpGateway:98`(10) · `mongo/.../SpringDataBulkFailureExtractor:30` · `mongo/failure/MongoFailureExtractor:41` |
|
||||
| **자기참조 검사만** (2-순환에서 무한 루프) | **`grpc/GrpcExceptionHandlingInterceptor:124`** · `web/advanced/mvc/MvcDisconnectDetector:61` · `web/advanced/webflux/WebFluxDisconnectDetector:66` · `persistence-jpa/.../TransactionRetryClassifier:19` |
|
||||
|
||||
§4.1.
|
||||
|
||||
### 3.3 (8.3) 중복 메커니즘 — 인증과 예외 처리의 인터셉터 순서
|
||||
|
||||
`ServerInterceptors.intercept(service, exceptionInterceptor, authenticationInterceptor)` — gRPC 규약상 **마지막 인터셉터의 `interceptCall`이 먼저** 호출되므로 인증이 바깥, 예외 처리가 안쪽이다.
|
||||
|
||||
인증 인터셉터가 예외 처리 바깥에 있는데도 안전한 이유는 그것이 스스로 예외를 삼키기 때문이다:
|
||||
|
||||
```java
|
||||
private boolean isAuthenticated(Metadata headers) {
|
||||
try { return authenticationPolicy.isAuthenticated(headers); }
|
||||
catch (RuntimeException ignored) { return false; }
|
||||
}
|
||||
```
|
||||
|
||||
CLAUDE.md의 약속("정책이 `false`를 반환하거나 예외를 던진 요청은 ... 안정적인 `UNAUTHENTICATED` status/code/category로 종료된다")이 코드와 일치하고, 두 경우 모두 같은 `call.close(Status.UNAUTHENTICATED.withDescription(OperationalError.UNAUTHENTICATED.code()), trailersFor(...))`로 끝난다. 정책 진단은 클라이언트에 닿지 않는다. 중복 아님.
|
||||
|
||||
### 3.4 (8.4) 문서/구현 드리프트
|
||||
|
||||
**설정 표.** CLAUDE.md의 여섯 개 knob(`enabled`·`port`·`bindAddress`·`allowInsecureLocal`·`reflectionEnabled`·`shutdownGraceSeconds`)과 기본값이 `GrpcServerProperties`의 필드·기본값과 정확히 일치한다. `port`의 `0..65535` 범위 설명도 `@Min(0) @Max(65535)`와 일치한다. 드리프트 없음.
|
||||
|
||||
**`Category` 망라.** `GrpcStatusMapper.toStatus`가 10개 값을 전부 다루고 `default` 분기가 없다 — 값이 추가되면 컴파일이 깨진다. 그리고 테스트 `coversEveryCategoryValue`가 그것을 별도로 고정한다.
|
||||
|
||||
**컴포지션 루트 규칙과의 어긋남.** `CaSkeletonApplication`의 javadoc은 이렇게 선언한다:
|
||||
|
||||
> "The five optional adapters are absent from the list below on purpose. Each one's settings are registered by its capability root through `@EnableConfigurationProperties`, which is what ties binding to the master switch. **Adding a package back here would restore the binding and silently undo the gate.**"
|
||||
|
||||
그런데 `dev.caskeleton.adapter.inbound.grpc`는 그 목록(`@ConfigurationPropertiesScan`)에 **있고**, `GrpcServerConfig`는 `@EnableConfigurationProperties(GrpcServerProperties.class)`도 **쓴다**. §4.2.
|
||||
|
||||
**health 상태 시점.** `GrpcServerRunner.start()`가 `healthStatusManager.setStatus(SERVICE_NAME_ALL_SERVICES, SERVING)`을 `server = builder.build().start()` **앞에서** 부른다. §4.3.
|
||||
|
||||
## 4. Findings
|
||||
|
||||
### 4.1 P2 — 원인 사슬 순회가 2-순환에서 무한 루프에 빠지고, 저장소는 이미 그 사례를 이름으로 적어 두었다
|
||||
|
||||
`errorCodeOf`의 종료 조건은 `current.getCause() == current` 하나다. 서로를 원인으로 갖는 두 예외(`a.cause = b`, `b.cause = a`)에서는 이 조건이 참이 되지 않고 `current`가 a→b→a→b로 무한히 순환한다. 이 사슬은 평범한 자바로 구성 가능하다 — `a = new RuntimeException(); b = new RuntimeException(a); a.initCause(b);`.
|
||||
|
||||
**같은 저장소가 이 정확한 위험을 다른 모듈에서 이름으로 서술하고 다른 관용구를 택했다:**
|
||||
|
||||
> `JdkNotificationHttpGateway:93-97` — "Depth-bounded rather than cycle-detecting: **a cause chain can be circular (two exceptions each `initCause`'d to the other)**, and an unbounded walk over one hangs the dispatch thread. Ten is far deeper than any real transport wrapping."
|
||||
|
||||
즉 이 주석은 자기참조 검사가 놓치는 바로 그 경우를 지목하고, 깊이 제한을 그 이유로 채택한다. 저장소의 아홉 개 순회 지점 중 다섯이 깊이 제한이고 넷이 자기참조 검사다(§3.2).
|
||||
|
||||
**실패 시나리오** — feature gRPC 서비스가 순환 원인 사슬을 가진 라이브러리 예외를 전파한다(일부 커넥션 풀과 재시도 래퍼가 실패 원인을 상호 참조하는 형태로 만든다). `closeWithError`가 그 예외를 `Status.withCause`에 실어 sanitizing `close`로 보내고, `errorCodeOf`가 진입해 돌아오지 않는다. gRPC 핸들러 스레드 하나가 CPU를 태우며 멈추고, 클라이언트는 응답도 상태도 받지 못한 채 데드라인까지 기다린다. 같은 예외가 반복되면 서버 스레드가 하나씩 소진된다.
|
||||
|
||||
**나머지 세 지점의 영향도** — `MvcDisconnectDetector`와 `WebFluxDisconnectDetector`는 요청 처리 중 클라이언트 연결 끊김을 판정하는 곳이고, `TransactionRetryClassifier`는 트랜잭션 재시도 여부를 판정하는 곳이다. 셋 다 요청 스레드 위에서 실행된다.
|
||||
|
||||
**권고** — 네 지점을 깊이 제한으로 통일한다. `JdkNotificationHttpGateway`의 형태가 이미 정본이고 그 근거까지 코드에 있다. 이 leaf에서는 `errorCodeOf`의 `while`을 `for (int depth = 0; current != null && depth < 16; depth++, current = current.getCause())`로 바꾸면 닫힌다.
|
||||
|
||||
### 4.2 P3 — 설정 바인딩이 마스터 스위치 밖에서 일어난다. 컴포지션 루트의 자기 규칙과 어긋난다
|
||||
|
||||
§3.4. `GrpcServerProperties`는 두 경로로 등록된다 — `GrpcServerConfig`의 `@EnableConfigurationProperties`(게이트 안쪽)와 `CaSkeletonApplication`의 `@ConfigurationPropertiesScan`(게이트 바깥). 후자가 있으면 `ca-skeleton.grpc.enabled`와 무관하게 바인딩이 일어난다.
|
||||
|
||||
`CaSkeletonApplication`의 javadoc은 이 구조가 과거에 만든 사고를 기록한다 — "The asymmetry that existed before — beans gated, settings not — is why a notification settings object bound itself in a deployment whose notification master was off." 그리고 그 교훈을 다섯 optional 어댑터에 적용하면서 grpc·web·websocket은 목록에 남겼다.
|
||||
|
||||
**지금 이 leaf에서는 무해하다.** 검증이 전부 게이트를 존중하거나 안전한 기본값을 갖는다:
|
||||
|
||||
| 검증 | `enabled=false`에서 |
|
||||
|---|---|
|
||||
| `@AssertTrue isInsecureLocalConfigurationValid()` | `!enabled` 로 즉시 참 |
|
||||
| `@Min(0) @Max(65535) port` | 기본 9090 |
|
||||
| `@NotBlank bindAddress` | 기본 `127.0.0.1` |
|
||||
| `@Min(0) shutdownGraceSeconds` | 기본 5 |
|
||||
|
||||
부작용은 두 가지뿐이다: (a) 비활성 배포에서도 프로퍼티 빈이 만들어진다, (b) `ignoreUnknownFields = false`이므로 `ca-skeleton.grpc.*` 아래 오타 하나가 gRPC를 쓰지 않는 배포의 부팅을 실패시킨다. (b)는 오히려 바람직한 쪽에 가깝다.
|
||||
|
||||
기록하는 이유는 **규칙과 적용이 갈린다**는 점이다. 같은 javadoc이 "Adding a package back here would restore the binding and silently undo the gate"라고 경고하고, 이 패키지가 그 목록에 있다. 지금 이 leaf가 안전한 것은 규칙이 지켜져서가 아니라 기본값이 전부 유효하기 때문이고, 새 검증이 하나 추가되면 그 보호막이 사라진다.
|
||||
|
||||
### 4.3 P3/기록 — health 가 바인드 이전에 SERVING 으로 선언된다
|
||||
|
||||
```java
|
||||
// GrpcServerRunner.start()
|
||||
healthStatusManager.setStatus(SERVICE_NAME_ALL_SERVICES, ServingStatus.SERVING);
|
||||
builder.addService(healthStatusManager.getHealthService());
|
||||
...
|
||||
server = builder.build().start(); // 이 뒤에야 실제로 바인드된다
|
||||
```
|
||||
|
||||
`start()`가 `IOException`으로 실패하면 `UncheckedIOException`이 던져지고 컨텍스트 시작이 실패하므로, "SERVING인데 서버가 없다"는 상태가 관측되는 창은 없다. 다만 이 순서는 health를 "프로세스가 살아 있음"이 아니라 "서비스가 준비됨"으로 쓰는 배포에서 의미가 없어진다 — 값이 항상 SERVING이고 어떤 조건에서도 NOT_SERVING이 되지 않는다(종료 시 `enterTerminalState()` 하나 제외).
|
||||
|
||||
feature 서비스가 없는 현재 상태에서는 판단할 근거가 없고, feature가 들어올 때 "무엇이 준비되면 SERVING인가"를 정해야 한다는 기록이다.
|
||||
|
||||
### 4.4 P3/기록 — raw gRPC status 를 INTERNAL 로 강등하는 것은 의도이며, 표준 관용구를 막는다
|
||||
|
||||
`responseObserver.onError(Status.NOT_FOUND.asRuntimeException())`은 gRPC의 표준 오류 보고 방식이지만, 이 인터셉터에서는 `status.getCause()`가 `ApiErrorCarrier`가 아니므로 `INTERNAL` + `INTERNAL_ERROR`로 재작성된다. javadoc이 그것을 명시하고("An unrecognised exception or raw gRPC status maps to `Status.INTERNAL`") 테스트 `rawStatusRuntimeExceptionIsSanitizedToInternal`이 고정한다.
|
||||
|
||||
즉 이 플랫폼에서 non-INTERNAL 오류를 내는 유일한 방법은 `ApiErrorCarrier`(보통 `ApiErrorException`)를 던지는 것이다. 강한 의견이고 문서화돼 있으므로 결함이 아니다. feature 개발자가 표준 관용구를 쓰면 조용히 INTERNAL이 된다는 사실만 기록한다 — CLAUDE.md의 "Feature 기여 방법" 절에 그 규칙이 없다.
|
||||
|
||||
## 5. 실행 검증
|
||||
|
||||
```
|
||||
$ ./gradlew :adapter:inbound:grpc:test :adapter:inbound:grpc:grpcTransportQualificationTest
|
||||
> Task :adapter:inbound:grpc:grpcTransportQualificationTestEvidence
|
||||
grpcTransportQualificationTest: 15 tests, 0 skipped
|
||||
BUILD SUCCESSFUL in 12s
|
||||
GRADLE_EXIT=0
|
||||
|
||||
test-results 집계: classes=8 tests=48 failures=0 errors=0 skipped=0
|
||||
```
|
||||
|
||||
`registerStrictQualificationTest`가 두 클래스(`GrpcSafeActivationTest` · `GrpcP1BoundaryWireTest`)를 이름으로 요구하고 skip 0을 강제한다. 24개 테스트 이름이 활성화 기본값 · 설정 검증 4종 · 인증 3종 · 네 개 오류 경로 · reflection off · `Category` 전수를 덮는다.
|
||||
|
||||
`GrpcP1BoundaryWireTest`가 실제 loopback ephemeral Netty 서버를 띄워 와이어 수준에서 확인한다는 점이 중요하다 — 이 모듈의 계약은 인터셉터 조합 순서에 의존하고(§3.3), 그것은 목으로 재현되지 않는다.
|
||||
|
||||
## 6. 종합
|
||||
|
||||
**결함 밀도가 이 저장소에서 가장 낮은 모듈이다.** main 8파일 602 LOC에 P1 0건, P2 1건(그것도 저장소 전반의 관용구 분열이 이 지점에 나타난 것), P3 3건.
|
||||
|
||||
세 가지가 이 결과를 만든다:
|
||||
|
||||
1. **범위가 좁고 그 경계가 문서에 있다.** "전송 인프라만, feature RPC 없음"이 CLAUDE.md의 Responsibility·Forbidden 두 절에 명시되고, 코드에 feature 흔적이 없다.
|
||||
2. **활성화가 삼중으로 닫혀 있다.** 프로퍼티 게이트 · `@AssertTrue` 교차 검증 · feature 서비스가 있을 때 인증 정책을 강제하는 런타임 검사. 셋 다 fail-closed이고 셋 다 테스트가 있다.
|
||||
3. **검증이 와이어 수준이다.** 실제 Netty 서버 · 실제 loopback 소켓 · skip 0 강제. inbound-web에서 확인한 "픽스처가 조립하고 레인이 픽스처를 인증한다"는 형태가 여기서는 성립하지 않는다 — 조립할 것이 `GrpcServerConfig` 하나이고 그것을 테스트가 직접 켜기 때문이다.
|
||||
|
||||
**앞 모듈들과의 대조.** inbound-web은 397개 main 파일 중 대부분이 조립되지 않았고 그것을 검증 장치가 가렸다. 여기서는 조립할 것이 여덟 개뿐이고 전부 하나의 `@Configuration`에 있으며, 그 `@Configuration`이 켜지는지 꺼지는지를 두 테스트가 양방향으로 확인한다. **모듈 크기가 아니라 조립 지점의 수가 이 차이를 만든다.**
|
||||
|
||||
## 7. 완료 게이트
|
||||
|
||||
- [x] denominator 18 / 18 FULL_READ (probe가 `file_count=18` 확인)
|
||||
- [x] §8.1~§8.4 negative-space probe 수행 — 도달성(선언된 빈 상태 확인) · 조건 형제 비교(저장소 전체 관용구 전수) · 중복 메커니즘(인터셉터 순서) · 문서 드리프트(설정 표 · Category 망라 · 컴포지션 루트 규칙)
|
||||
- [x] 실행 검증: `:test` + `:grpcTransportQualificationTest` → `BUILD SUCCESSFUL`, tests=48 failures=0 **skipped=0**
|
||||
- [x] 거짓 양성 후보 검증 후 기각: `ServerInterceptors.intercept` 인자 순서(→ 인증이 바깥이지만 자기 예외를 삼키므로 안전) · `closeWithError`의 `Status.fromThrowable`(→ 두 호출 지점이 모두 `sanitizingCall`이라 원문이 재작성됨) · `authenticationInterceptor`가 null일 가능성(→ feature 서비스가 없을 때만 null이고 그때는 루프가 돌지 않음) · `ApiErrorException.errorCode`의 `transient`(→ gRPC는 자바 직렬화를 쓰지 않음)
|
||||
- [x] 소스 미변경
|
||||
|
||||
## Source anchors
|
||||
|
||||
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **10개** (main 7 · test 2 · 기타 1).
|
||||
|
||||
```
|
||||
src/adapter/inbound/grpc/build.gradle
|
||||
src/config/architecture/modules.json (adapter-inbound-grpc 항목)
|
||||
|
||||
main:
|
||||
src/main/java/dev/caskeleton/adapter/inbound/grpc/ApiErrorException.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcAuthenticationPolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcExceptionHandlingInterceptor.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerConfig.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerProperties.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcServerRunner.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/grpc/GrpcStatusMapper.java
|
||||
|
||||
test:
|
||||
src/test/java/dev/caskeleton/adapter/inbound/grpc/GrpcP1BoundaryWireTest.java
|
||||
src/test/java/dev/caskeleton/adapter/inbound/grpc/GrpcSafeActivationTest.java
|
||||
|
||||
기타:
|
||||
src/build.gradle
|
||||
|
||||
해석되지 않은 인용 (1종) — 외부 타입·문서상 약칭 등:
|
||||
evidence/raw/204-inbound-grpc-probes.txt
|
||||
|
||||
```
|
||||
@@ -0,0 +1,715 @@
|
||||
# adapter-inbound-websocket — 코드베이스 분석
|
||||
|
||||
|
||||
## SSOT identity — 2026-08-31 재검증
|
||||
|
||||
- registered leaf id: `adapter-inbound-websocket`
|
||||
- canonical state `analysisFile`: `analysis/17-adapter-inbound-websocket.md` (이 문서) — 이 leaf의 단일 SSOT
|
||||
- source path: `src/adapter/inbound/websocket` · Gradle `:adapter:inbound:websocket`
|
||||
- registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract"]`
|
||||
- registry `runtime_memberships`: **`[]`**
|
||||
- coverage ledger: `FULL_READ` **253** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
|
||||
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
|
||||
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행)
|
||||
|
||||
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
|
||||
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
|
||||
|
||||
---
|
||||
> **분석 대상** `src/adapter/inbound/websocket` · revision `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
|
||||
> **분모** 253 tracked files (main 169 · test 67 · testkit 7 · nginxWebSocketTest 4 · jettyWebSocketTest 1 · brokerRelayTest 1 · governance 4)
|
||||
> **LOC** main Java 12,784 · test Java 9,435
|
||||
> **근거** `evidence/raw/218-inbound-websocket-module-inventory.txt`, 이하 `219`–`226`
|
||||
|
||||
## 0. 이 모듈의 형태 — 하나의 leaf, 세 개의 설정 네임스페이스
|
||||
|
||||
여섯 소스셋(inbound-web과 같은 형태)이고 `META-INF` 자동설정 리소스가 **없다**. 조립은 전적으로 컴포넌트 스캔에 달려 있으며, 컴포지션 루트는 이 leaf를 스캔에서 제외하지 **않는다**(graphql과 반대).
|
||||
|
||||
그런데 스캔이 잡을 수 있는 Spring 애노테이션을 가진 파일이 **169개 중 7개**다:
|
||||
|
||||
```
|
||||
config/WebSocketPlatformSettings.java @ConfigurationProperties(prefix = "backend.websocket")
|
||||
stomp/WebSocketProperties.java @ConfigurationProperties(prefix = "ca-skeleton.websocket")
|
||||
stomp/WebSocketConfig.java @Configuration + @ConditionalOnProperty("ca-skeleton.websocket.enabled")
|
||||
advanced/sockjs/SockJsConfiguration.java @Configuration + prefix "app.websocket-platform.advanced.sockjs"
|
||||
advanced/stomp/StompConfiguration.java @Configuration + prefix "app.websocket-platform.advanced.stomp"
|
||||
advanced/stomp/StompDefaultsConfiguration.java @Configuration + prefix "app.websocket-platform.advanced.stomp"
|
||||
advanced/stomp/rabbit/RabbitBrokerRelayConfiguration.java prefix "app.websocket-platform.advanced.stomp.relay"
|
||||
advanced/stomp/StompBrokerExclusivity.java
|
||||
```
|
||||
|
||||
**세 개의 설정 접두사가 있고 그중 하나에는 소비자가 없다:**
|
||||
|
||||
| 접두사 | 설정 타입 | `@Configuration` 소비자 | yaml 등장 |
|
||||
|---|---|---|---|
|
||||
| `ca-skeleton.websocket.*` | `stomp/WebSocketProperties` | `stomp/WebSocketConfig` | 없음 |
|
||||
| `app.websocket-platform.advanced.*` | (직접 `@ConditionalOnProperty`) | 4개 | 없음 |
|
||||
| **`backend.websocket.*`** | `config/WebSocketPlatformSettings` | **0** | 없음 |
|
||||
|
||||
세 번째가 이 모듈의 핵심 사실이다. `backend.websocket` 네임스페이스가 규정하는 "플랫폼"이 main 169 파일 중 약 90개를 차지하고, 그것을 조립하는 `@Configuration`이 하나도 없다.
|
||||
|
||||
## 1. 커버리지 원장
|
||||
|
||||
| # | sub-scope | main | test | 기타 | 합 | 상태 |
|
||||
|---|---|---:|---:|---:|---:|---|
|
||||
| 1 | governance + `config` + `moduleboundary` + `core` + `evidence` | 24 | 9 | 4 | 37 | **COMPLETE** |
|
||||
| 2 | `protocol` + `codec` + `handshake` + `servlet` + `webflux` | 23 | 6 | – | 29 | **COMPLETE** |
|
||||
| 3 | `handler` + `inbound` + `outbound` + `session` + `lifecycle` + `ordering` | 21 | 9 | – | 30 | **COMPLETE** |
|
||||
| 4 | `security` + `authz` + `idempotency` + `budget` + `error` + `observability` + `admin` + `release` | 22 | 9 | – | 31 | **COMPLETE** |
|
||||
| 5 | `stomp` | 8 | 5 | – | 13 | **COMPLETE** |
|
||||
| 6 | `advanced/stomp` + `stomp/rabbit` + `cluster` + `resume` | 41 | 13 | – | 54 | **COMPLETE** |
|
||||
| 7 | `advanced/` 잔여 (presence · codec 3종 · sockjs · graphql · compression · client · release · http2 · http3) | 30 | 11 | – | 41 | **COMPLETE** |
|
||||
| 8 | `testkit` + 대체 소스셋 3종 | 0 | 5 | 13 | 18 | **COMPLETE** |
|
||||
| | **TOTAL** | **169** | **67** | **17** | **253** | **8 / 8** |
|
||||
|
||||
`FULL_READ 253 · STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0`. 분할은 `218-...`의 패키지 트리에서 기계 계산(중복 0 · 미할당 0).
|
||||
|
||||
> **evidence 파일의 `autoconf` 열에 대하여** — `219`–`226`의 배선 표는 graphql 모듈에서 쓴 스크립트를 재사용했고, 그 열은 `autoconfigure` 패키지의 참조 수를 센다. 이 leaf에는 그런 패키지가 없으므로 **전 행이 0이며 그 자체로는 정보가 없다**. 이 모듈의 배선 판정은 위 §0의 애노테이션·네임스페이스 전수와 `main_other` 열로 한다.
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 01 — governance + `config` + `moduleboundary` + `core` + `evidence` (37 files)
|
||||
|
||||
> 근거 `evidence/raw/219-inbound-websocket-core-probes.txt`
|
||||
|
||||
## 2. 무엇을 하는 코드인가
|
||||
|
||||
**`config` (3)** 이 플랫폼의 안전 장치 셋을 담는다.
|
||||
|
||||
`WebSocketPlatformSettings`(60)는 기본값 철학을 먼저 말한다:
|
||||
|
||||
> "Every default here is the safe one, and where there is no safe default there is no default. `allowedOrigins` is empty and `enabled` is false: **a deployment that has not said which origins may connect has not configured a WebSocket endpoint, and starting one anyway would mean the platform chose its own CSRF posture.**"
|
||||
|
||||
`WebSocketPlatformStartupValidator`(125)는 "개발에서는 완벽히 동작하고 프로덕션에서 틀린" 것만 잡는다고 선언한다:
|
||||
|
||||
> "a cookie endpoint with no origin allowlist serves every same-origin test correctly, an unnegotiated-fallback profile connects fine until the message format changes, and an endpoint with no authorization requirement simply refuses everything — quietly, and only for the clients that try to use it."
|
||||
|
||||
`WebSocketStackExclusivity`(78)는 **inbound-web §40.1에서 확인한 결함을 이름 붙여 탐지한다**:
|
||||
|
||||
> "Spring Boot deduces one application type from what is present, and the deduction is not negotiable: with both a servlet container and Reactor Netty available it starts the servlet one. **A deployment that declared reactive endpoints and shipped both therefore starts, reports itself healthy, and serves none of them — no error, no warning, and the endpoints simply never answer.**"
|
||||
|
||||
**`core` (12)** 는 값 어휘다 — `WebSocketActorReference`(main_other=19) · `WebSocketEndpointName`(17) · `WebSocketConnectionId`(9) · `WebSocketNodeId`(7) · `WebSocketSubprotocolName`(7) · `WebSocketEndpointCatalog`(111) · `WebSocketConnectionContext`(97). 이 leaf에서 가장 널리 참조되는 타입들이다.
|
||||
|
||||
**`evidence` (6)** 는 연결·인바운드·아웃바운드 3축 증거 모델(`WebSocketInboundEvidence` 121 · `WebSocketOutboundEvidence` 92 · `WebSocketConnectionEvidence` 67).
|
||||
|
||||
**`moduleboundary` (3)** 는 `WebSocketStableModule`(394줄 enum) + `WebSocketModuleBoundary`(98) + `WebSocketModulePurity` — inbound-web의 `WebStableModule`(539)과 같은 형태의 모듈 경계 선언이다.
|
||||
|
||||
## 3. Negative-space probes — sub-scope 01
|
||||
|
||||
### 3.1 (8.1) 도달성 — 세 안전 장치의 호출자
|
||||
|
||||
```
|
||||
$ grep -rn 'WebSocketPlatformStartupValidator|WebSocketStackExclusivity' src/main src/test --include=*.java | grep -v config/
|
||||
advanced/stomp/StompBrokerExclusivity.java:17: * WebSocketStackExclusivity}: a runtime that silently half-applies is worse than one that will not
|
||||
```
|
||||
|
||||
**둘 다 프로덕션 호출자가 0이다.** 유일한 참조는 다른 클래스의 javadoc이 설계를 인용한 것이다. `WebSocketPlatformSettings`도 소비자가 0이다(§0).
|
||||
|
||||
### 3.2 (8.2) 조건 형제 비교 — 두 개의 설정 검증
|
||||
|
||||
| | `stomp/WebSocketProperties` | `config/WebSocketPlatformSettings` |
|
||||
|---|---|---|
|
||||
| 접두사 | `ca-skeleton.websocket` | `backend.websocket` |
|
||||
| 검증 | `@Validated` — "Invalid enabled settings fail context startup"(CLAUDE.md) | `WebSocketPlatformStartupValidator` 125줄 |
|
||||
| 소비 `@Configuration` | `WebSocketConfig` | **없음** |
|
||||
| CLAUDE.md 문서화 | 4개 키 표 | **없음** |
|
||||
|
||||
### 3.3 (8.3) 중복 메커니즘 — origin 허용목록이 두 곳에 있다
|
||||
|
||||
`WebSocketPlatformSettings.allowedOrigins`(기본 빈 집합, "no safe default there is no default")와 `stomp/WebSocketProperties.allowedOriginPatterns()`(기본 `http://localhost:3000`, CLAUDE.md가 "explicit HTTP(S) origins only; blank/wildcard/path rejected"로 규정)이 같은 결정을 두 번 표현한다. 실제 핸드셰이크에 적용되는 것은 후자다(`WebSocketConfig.registerStompEndpoints`).
|
||||
|
||||
### 3.4 (8.4) 문서/구현 드리프트 — CLAUDE.md가 서술하는 모듈과 실제 파일
|
||||
|
||||
CLAUDE.md의 Responsibility 다섯 줄이 전부 STOMP-over-SockJS 어댑터를 서술하고, Typed settings 표는 `ca-skeleton.websocket.*` 네 키만 담는다. `backend.websocket`과 `app.websocket-platform.advanced.*` 두 네임스페이스는 등장하지 않는다.
|
||||
|
||||
Evidence 절이 일부를 명시적으로 면책한다:
|
||||
|
||||
> "The simple broker is local, single-process, best-effort R1 evidence only. **Broker relay, multi-node/durable delivery, rollback-safe publication, replay/resume, backpressure, and a versioned domain projection catalog are P2 and are not claimed.**"
|
||||
|
||||
이 면책이 덮는 것은 `advanced/stomp/rabbit`(7) · `advanced/cluster`(9) · `advanced/resume`(8)과 backpressure 관련 파일이다. 덮지 않는 것이 §4.1이다.
|
||||
|
||||
## 4. Sub-scope 01 findings
|
||||
|
||||
### 4.1 P2 — `backend.websocket` 플랫폼(약 90개 main 파일)에 조립 지점이 없고, 모듈 SSOT 문서에 존재하지 않는다
|
||||
|
||||
169개 main 파일이 세 덩어리로 나뉜다:
|
||||
|
||||
| 덩어리 | 파일 | 조립 | CLAUDE.md |
|
||||
|---|---:|---|---|
|
||||
| STOMP 어댑터 (`stomp/`) | 8 | `WebSocketConfig` (`ca-skeleton.websocket.enabled=true`) | Responsibility 5줄 + 설정 표 4키 + 증거 절 |
|
||||
| Advanced (`advanced/**`) | 71 | 4개 `@Configuration` (`app.websocket-platform.advanced.*`) | 일부 면책("not claimed") |
|
||||
| **플랫폼 (`config`·`core`·`protocol`·`handler`·`outbound`·`session`·`lifecycle`·`ordering`·`security`·`authz`·`idempotency`·`budget`·`error`·`evidence`·`observability`·`admin`·`release`·`handshake`·`codec`·`servlet`·`webflux`·`inbound`·`moduleboundary`)** | **90** | **없음** | **없음** |
|
||||
|
||||
세 번째 덩어리에는 실질적인 기계가 들어 있다 — `PlatformWebSocketHandler`(205) · `OutboundQueue`(189) · `SerializedOutboundWriter`(188) · `WebSocketSessionRegistry`(163) · `WebSocketCorrelationRegistry`(162) · `HandshakeAdmissionPipeline`(145) · `StrictWebSocketJsonCodec`(145) · `CloseOrchestration`(138) · `LateResponseTombstone`(108) · `FragmentAssembler`(117).
|
||||
|
||||
**가장 무거운 결과는 세 안전 장치가 실행되지 않는다는 것이다:**
|
||||
|
||||
1. `WebSocketPlatformStartupValidator`(125) — "Refuses to start a deployment whose WebSocket configuration is unsafe or incoherent." 호출자 0.
|
||||
2. `WebSocketStackExclusivity`(78) — 서블릿/리액티브 이중 스택에서 "reactive endpoints ... simply never answer"를 탐지. 호출자 0.
|
||||
3. `WebSocketPlatformSettings`의 "safe default" 규약 — 그 설정을 읽는 코드가 0.
|
||||
|
||||
**실패 시나리오** — 팀이 이 leaf를 채택하며 `backend.websocket.enabled=true`와 `allowed-origins`를 설정한다. `@ConfigurationPropertiesScan`이 `dev.caskeleton.adapter.inbound.websocket`을 포함하므로 프로퍼티는 바인딩되고 검증도 통과한다(설정 자체는 유효하므로). 부팅이 성공하고 오류가 없다. **WebSocket 엔드포인트는 하나도 열리지 않는다** — `backend.websocket`을 읽는 `@Configuration`이 없기 때문이다. 실제로 엔드포인트를 여는 스위치는 문서화된 `ca-skeleton.websocket.enabled`이고, 그것은 다른 8개 파일짜리 STOMP 어댑터를 켠다.
|
||||
|
||||
**inbound-web·graphql과의 위치** — graphql은 같은 상태를 등급표로 공시했고(§45.3), web은 공시하지 않아 P1 여섯 건이 되었다. 이 모듈은 **일부만 면책한다**(§3.4의 P2 목록) — 그 면책이 `advanced/**`를 덮고 90개 파일의 플랫폼은 덮지 않는다. 그래서 P1이다.
|
||||
|
||||
**권고** — 셋 중 하나. (a) `backend.websocket` 플랫폼을 조립하는 `@Configuration`(또는 `AutoConfiguration.imports` 진입점)을 추가하고 세 안전 장치를 그 안에서 호출한다. (b) 그 플랫폼을 graphql처럼 `modelled` 등급으로 CLAUDE.md에 공시한다. (c) 제거한다. 지금은 셋 다 아니며, 특히 `WebSocketPlatformSettings`가 `@ConfigurationPropertiesScan`에 걸려 **바인딩만 되는** 상태가 (a)를 이미 절반 시사한다.
|
||||
|
||||
### 4.2 P3/기록 — origin 허용목록이 두 네임스페이스에 중복 선언돼 있다
|
||||
|
||||
§3.3. 실제 적용은 `ca-skeleton.websocket.allowed-origins`이고, `backend.websocket.allowed-origins`는 바인딩되지만 읽히지 않는다. 운영자가 후자를 설정하면 "설정했는데 적용되지 않는" 상태가 되고, 두 기본값이 다르므로(빈 집합 대 `http://localhost:3000`) 어느 쪽을 설정했는지에 따라 결과가 정반대다.
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 02 — `protocol` + `codec` + `handshake` + `servlet` + `webflux` (29 files, main 23 + test 6)
|
||||
|
||||
> 근거 `evidence/raw/220-inbound-websocket-protocol-probes.txt`
|
||||
|
||||
## 5. 무엇을 하는 코드인가
|
||||
|
||||
와이어 계약이다. `WebSocketMessageCatalog`(100) + `WebSocketMessageDescriptor` + `WebSocketMessageType`(main_other=16) + `WebSocketMessageFamily` + `WebSocketSchemaVersion` + `WebSocketEnvelope`(150) + `WebSocketProtocolProfile`(101) + `WebSocketCatalogFingerprint`(64)가 메시지 카탈로그와 봉투를, `StrictWebSocketJsonCodec`(145)이 엄격한 JSON 디코딩을, `WebSocketWireTypeManifest`가 고정 표현을 담는다.
|
||||
|
||||
핸드셰이크는 `HandshakeAdmissionPipeline`(145) + `HandshakeDecision` + `HandshakeRequest`, 전송은 `PlatformWebSocketHandler`(205) + `ServletFrameSink`(45) / `ReactiveFrameSink`(82) + `WebSocketDataBufferLifecycle`(106) + `WebSocketDataBufferPolicy`.
|
||||
|
||||
`ReactiveFrameSink`는 main·test 참조가 **모두 0**인 유일한 파일이다.
|
||||
|
||||
## 6. Negative-space probes
|
||||
|
||||
### 6.1 (8.1) 도달성
|
||||
|
||||
23개 main 파일 중 `@Configuration`/`@Component`가 하나도 없고, 이 leaf의 유일한 `@Configuration`(`stomp/WebSocketConfig`)이 이 패키지들을 참조하지 않는다. §4.1의 90개 플랫폼 파일 중 23개다.
|
||||
|
||||
`PlatformWebSocketHandler`(205)가 이 sub-scope의 중심이고 main 참조 0 · test 1이다 — 이 leaf가 Spring의 `WebSocketHandler`로 등록할 핸들러를 갖고 있으면서 등록하지 않는다.
|
||||
|
||||
### 6.2 (8.2) 조건 형제 비교 — 두 전송의 프레임 싱크
|
||||
|
||||
`ServletFrameSink`(45, main_other=1)와 `ReactiveFrameSink`(82, main_other=0 · test=0). 서블릿 쪽은 최소한 다른 main 파일이 참조하고, 리액티브 쪽은 참조가 없다. §4.1의 `WebSocketStackExclusivity`가 탐지하려던 상황(리액티브 엔드포인트를 선언했는데 서블릿으로 뜨는 배포)에서 실제로 무엇이 죽는지를 이 비대칭이 보여준다.
|
||||
|
||||
### 6.3 (8.3)·(8.4) 중복·드리프트 — 없음
|
||||
|
||||
카탈로그·봉투·코덱이 각각 한 벌이고, `WebSocketCatalogFingerprint`가 카탈로그 변경을 지문으로 고정한다.
|
||||
|
||||
## 7. Findings
|
||||
|
||||
### 7.1 P3/기록 — `ReactiveFrameSink`는 테스트조차 없다
|
||||
|
||||
23개 파일 중 유일하게 main·test 참조가 모두 0이다. 리액티브 전송이 이 leaf에서 도달 불가라는 사실(§6.2)의 가장 뚜렷한 표시다.
|
||||
|
||||
나머지는 §4.1에 포함된다 — 개별 결함이 아니라 90개 플랫폼 파일이 조립되지 않는다는 하나의 사실이다.
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 03 — `handler` + `inbound` + `outbound` + `session` + `lifecycle` + `ordering` (30 files, main 21 + test 9)
|
||||
|
||||
> 근거 `evidence/raw/221-inbound-websocket-session-probes.txt`
|
||||
|
||||
## 8. 무엇을 하는 코드인가
|
||||
|
||||
세션 수명주기와 아웃바운드 전달의 기계다.
|
||||
|
||||
`OutboundQueue`(189) + `SerializedOutboundWriter`(188) + `OutboundPriority` + `OutboundDelivery` + `OutboundEnqueueResult` + `GlobalBufferBudget`(89) + `OutboundQueueSnapshot`가 우선순위 큐와 직렬 쓰기, 전역 버퍼 예산을 담는다. 백프레셔 모델이다 — CLAUDE.md가 "backpressure ... are P2 and are not claimed"로 면책한 항목 중 하나가 여기 있다.
|
||||
|
||||
`WebSocketSessionRegistry`(163) · `CloseOrchestration`(138) · `HeartbeatPolicy`(65)가 세션 등록과 종료 협상을, `WebSocketCorrelationRegistry`(162) + `LateResponseTombstone`(108)이 요청-응답 상관과 늦은 응답 처리를, `FragmentAssembler`(117)가 프레임 조립을, `ordering` 3종(`GapDetector` 98 · `StreamSequencer` 54 · `OrderingProfile` 54)이 순서 보장을 담당한다.
|
||||
|
||||
## 9. Negative-space probes
|
||||
|
||||
### 9.1 (8.1) 도달성
|
||||
|
||||
21개 main 파일 중 main 참조 0인 것이 여덟이다 — `LateResponseTombstone` · `WebSocketCorrelationRegistry` · `WebSocketMessageHandler`(참조 0·테스트 0) · `WebSocketSessionRegistry` · `CloseOrchestration` · `HeartbeatPolicy` · `GapDetector` · `OrderingProfile` · `StreamSequencer`.
|
||||
|
||||
`OutboundQueue`·`SerializedOutboundWriter`·`GlobalBufferBudget`는 서로를 참조하는 내부 클러스터를 이룬다. 클러스터 전체의 진입점이 `PlatformWebSocketHandler`(§6.1, 미등록)다.
|
||||
|
||||
### 9.2 (8.4) 문서와의 대조
|
||||
|
||||
CLAUDE.md의 면책 목록이 backpressure를 포함하므로 `outbound`(8)는 공시된 범위 안이다. `session`·`lifecycle`·`ordering`·`handler`·`inbound`(13 파일)는 면책 목록에 없다.
|
||||
|
||||
## 10. Findings
|
||||
|
||||
### 10.1 P3/기록 — `WebSocketMessageHandler`는 참조도 테스트도 없다
|
||||
|
||||
39줄 인터페이스이고 구현도 호출자도 없다. `PlatformWebSocketHandler`가 그 자리를 대신하는지는 코드로 판정되지 않는다.
|
||||
|
||||
나머지는 §4.1에 포함된다.
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 04 — `security` + `authz` + `idempotency` + `budget` + `error` + `observability` + `admin` + `release` (31 files, main 22 + test 9)
|
||||
|
||||
> 근거 `evidence/raw/222-inbound-websocket-policy-probes.txt`
|
||||
|
||||
## 11. 무엇을 하는 코드인가
|
||||
|
||||
플랫폼의 정책 계층. `WebSocketOriginPolicy`(116) · `WebSocketConnectionTicket`(77) · `WebSocketTicketStore` · `WebSocketAuthenticationProfile`(57) · `MessageAuthorizationPolicy`(93) · `WebSocketConnectionBudget`(103, main_other=5) · `WebSocketCloseCode`(95) · `WebSocketClosePolicy`(73) · `WebSocketFailureCategory`(59, main_other=10).
|
||||
|
||||
멱등성은 `CommandReconciliation`(84) + `CommittedResultLedger`(61) + `WebSocketCommandKey` + `WebSocketCommandOutcome`.
|
||||
|
||||
관측은 `SafeWebSocketLogFields`(65) + `WebSocketMetricTags`(76) — 둘 다 main 참조 0.
|
||||
|
||||
`release` 3종은 `WebSocketStableReleaseGate`(106) · `WebSocketRollingRestartScenario`(113) · `WebSocketNginxProxyProfile`(91).
|
||||
|
||||
## 12. Negative-space probes
|
||||
|
||||
### 12.1 (8.1) 도달성 — 정책의 실제 적용 지점
|
||||
|
||||
이 sub-scope에서 실제로 요청 경로에 있는 것은 **`stomp` 패키지가 참조하는 것뿐**이다. `stomp/WebSocketInboundAuthorizationInterceptor`(53)와 `stomp/AuthenticatedHandshakeInterceptor`(34)가 `WebSocketConfig`에 등록되고, 그 둘은 `stomp/WebSocketProperties`를 쓴다.
|
||||
|
||||
`security`(4) · `authz`(1) · `idempotency`(4) · `budget`(1)의 플랫폼 정책 타입은 `stomp`가 참조하지 않는다. 즉 **인증 프로파일 · 티켓 · origin 정책 · 메시지 권한 · 연결 예산 · 명령 멱등성이 모두 요청 경로 밖이다.**
|
||||
|
||||
### 12.2 (8.2) 조건 형제 비교 — 두 개의 인바운드 권한
|
||||
|
||||
| | `stomp/WebSocketInboundAuthorizationInterceptor` (53) | `security/MessageAuthorizationPolicy` (93) |
|
||||
|---|---|---|
|
||||
| 등록 | `WebSocketConfig.configureClientInboundChannel` | 없음 |
|
||||
| 근거 | `stomp/WebSocketProperties` | 플랫폼 정책 모델 |
|
||||
| 범위 | STOMP 목적지 권한 | 메시지 종류별 권한 |
|
||||
|
||||
CLAUDE.md의 Inbound policy 절이 전자를 규정한다. 후자는 문서에 없다.
|
||||
|
||||
### 12.3 (8.4) 카운트 — `WebSocketFailureCategory`
|
||||
|
||||
`error` 패키지의 `WebSocketFailureCategory`(main_other=10)가 이 sub-scope에서 가장 널리 참조되는 타입이고, `WebSocketErrorMessage`(75, main 참조 0)와 `WebSocketErrorTransport`가 그것을 전송으로 옮긴다. 실제 STOMP 오류는 `stomp/SafeStompSubProtocolErrorHandler`(32)가 만든다 — 세 번째 오류 형식이다.
|
||||
|
||||
## 13. Findings
|
||||
|
||||
### 13.1 P2 — 연결 티켓·origin 정책·메시지 권한·연결 예산이 요청 경로 밖이고, 그중 일부는 STOMP 어댑터가 다른 방식으로 대체한다
|
||||
|
||||
§12.1·§12.2. 실제 배포에서 적용되는 보안은 `stomp` 패키지의 두 인터셉터이고, 그것은 CLAUDE.md가 서술하는 범위다("HTTP-handshake principal enforcement and client-inbound STOMP destination authorization").
|
||||
|
||||
플랫폼 정책 계층(22 파일)이 그보다 넓은 모델을 담는다 — 연결 티켓(핸드셰이크 전 발급), 인증 프로파일, origin 정책 116줄, 메시지 종류별 권한, 연결당 예산, 명령 멱등성 원장. **어느 것도 적용되지 않는다.**
|
||||
|
||||
노출은 아니다 — 대체 경로가 더 좁을 뿐 존재하며, CLAUDE.md가 그 좁은 범위를 정확히 서술한다. 기록하는 것은 두 보안 모델이 한 leaf에 공존하고 넓은 쪽이 꺼져 있다는 사실이며, §4.1의 부분집합이다.
|
||||
|
||||
### 13.2 P3/기록 — 오류 형식이 셋이다
|
||||
|
||||
`WebSocketErrorMessage`(플랫폼, 미배선) · `SafeStompSubProtocolErrorHandler`(STOMP, 배선) · `WebSocketErrorTransport`(전송 추상, 미배선). 실제 클라이언트가 받는 것은 두 번째 하나다.
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 05 — `stomp` (13 files, main 8 + test 5)
|
||||
|
||||
> 근거 `evidence/raw/223-inbound-websocket-stomp-probes.txt`
|
||||
|
||||
## 14. 무엇을 하는 코드인가 — 이 모듈에서 실제로 동작하는 부분
|
||||
|
||||
여덟 개 파일이 CLAUDE.md가 서술하는 모듈 전체다.
|
||||
|
||||
```java
|
||||
// stomp/WebSocketConfig.java
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
@EnableConfigurationProperties(WebSocketProperties.class)
|
||||
@ConditionalOnProperty(prefix = "ca-skeleton.websocket", name = "enabled", havingValue = "true")
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
registerStompEndpoints: registry.setErrorHandler(errorHandler);
|
||||
registry.addEndpoint(properties.getEndpoint())
|
||||
.setAllowedOriginPatterns(properties.allowedOriginPatterns())
|
||||
.addInterceptors(handshakeInterceptor)
|
||||
.withSockJS();
|
||||
configureClientInboundChannel: registration.interceptors(inboundAuthorization);
|
||||
configureMessageBroker: registry.enableSimpleBroker("/topic");
|
||||
registry.setApplicationDestinationPrefixes("/app");
|
||||
@Bean LiveEventStompBroadcaster(SimpMessagingTemplate, WebSocketProperties, List<LiveEventProjector<?>>)
|
||||
}
|
||||
```
|
||||
|
||||
`LiveEventStompBroadcaster`(139)가 도메인 이벤트를 `LiveEventProjector` 허용목록을 통해서만 밀어낸다 — CLAUDE.md의 Forbidden이 "Raw `@DomainEvent` payload transmission or reflection-based event serialization"을 금지하고, 프로젝터가 `Map<String,String>` 경계 투영만 낸다.
|
||||
|
||||
`WebSocketProperties`(117)가 `@Validated`이고 CLAUDE.md 표의 네 키를 담는다. `AuthenticatedHandshakeInterceptor`(34)가 핸드셰이크에 비어 있지 않은 `Principal`을 요구하고, `WebSocketInboundAuthorizationInterceptor`(53)가 클라이언트 인바운드 채널에서 목적지를 검사하며, `SafeStompSubProtocolErrorHandler`(32)가 고정 ERROR를 낸다.
|
||||
|
||||
## 15. Negative-space probes
|
||||
|
||||
### 15.1 (8.1) 도달성 — 여덟 파일 전부 배선
|
||||
|
||||
`WebSocketConfig`가 나머지 일곱을 직접 생성하거나 `@Bean`으로 만든다. 미도달 파일이 없다.
|
||||
|
||||
### 15.2 (8.2) 조건 형제 비교 — 이 어댑터와 플랫폼
|
||||
|
||||
§12.2·§13.1. 같은 결정(핸드셰이크 인증 · 인바운드 권한 · 오류 형식 · origin)에 대해 두 구현이 있고 이쪽만 배선된다.
|
||||
|
||||
### 15.3 (8.4) 문서 일치
|
||||
|
||||
CLAUDE.md의 Responsibility 다섯 줄 · Typed settings 네 키 · Inbound policy · Event projection contract · Evidence 절이 이 여덟 파일과 정확히 대응한다. 드리프트 없음.
|
||||
|
||||
## 16. Findings — 없음
|
||||
|
||||
이 sub-scope는 문서·구현·테스트가 일치한다. `WebSocketBoundaryQualificationTest`가 랜덤 포트 Tomcat에서 실제 SockJS/STOMP 핸드셰이크 · Origin · principal · 구독 · 서버 투영 push · 애플리케이션 SEND · 브로커 SEND 거부 · ERROR 리댁션을 교차 확인한다.
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 06 — `advanced/stomp` + `stomp/rabbit` + `cluster` + `resume` (54 files, main 41 + test 13)
|
||||
|
||||
> 근거 `evidence/raw/224-inbound-websocket-advanced-stomp-probes.txt`
|
||||
|
||||
## 17. 무엇을 하는 코드인가
|
||||
|
||||
**`advanced/stomp` (17)** 은 STOMP 정책 계층이다 — `StompProfile`(118, main_other=4) · `StompDestinationCatalog`(129) · `StompAuthorizationPolicy`(93) · `StompSecurityInterceptor`(96) · `StompAckPolicy`(79) · `StompAckMode`(76) · `StompBrokerExclusivity`(74) · `StompRefusal` · `StompEvidence`.
|
||||
|
||||
`StompBrokerExclusivity`(74)가 §2의 `WebSocketStackExclusivity`를 인용하며 같은 원칙을 브로커에 적용한다 — "a runtime that silently half-applies is worse than one that will not [start]".
|
||||
|
||||
**`advanced/stomp/rabbit` (7)** 은 외부 브로커 릴레이 — `RabbitBrokerRelayConfiguration`(72, `@Configuration`) · `RabbitBrokerRelayProfile`(78) · `MultiNodeUserDestination`(98) · `UserDestinationPolicy`(50) · `UserDestinationRouting`(42) · `UserSessionLocation`.
|
||||
|
||||
**`advanced/cluster` (9)** 는 다중 노드 — `PortBackedExternalSessionIndex`(108) · `MessagingFanoutAdapter`(102) · `FanoutDeduplicator`(100) · `FanoutEnvelope`(75) · `ExternalSessionIndex` · `ExternalSessionSummary`.
|
||||
|
||||
**`advanced/resume` (8)** 은 재개 — `ResumeTokenCodec`(217, 이 sub-scope에서 가장 큰 파일) · `ReplayEventMapper`(132) · `ResumeCoordinator`(111) · `ResumeDecision`(87) · `ResumeTokenKeyRing`(87) · `ResumeTokenPayload`(83) · `ReplayCursor`(64).
|
||||
|
||||
## 18. Negative-space probes
|
||||
|
||||
### 18.1 (8.1) 도달성 — 두 `@Configuration`이 실제로 무엇을 만드는가
|
||||
|
||||
| `@Configuration` | 게이트 | `@Bean` | leaf 내부 import |
|
||||
|---|---|---:|---|
|
||||
| `advanced/stomp/StompConfiguration` (52) | `app.websocket-platform.advanced.stomp` | **0** | 없음 |
|
||||
| `advanced/stomp/StompDefaultsConfiguration` (58) | 같음 | 4 | 없음(프레임워크 빈만) |
|
||||
| `advanced/stomp/rabbit/RabbitBrokerRelayConfiguration` (72) | `...advanced.stomp.relay` | 3 | `advanced.stomp`, `core` |
|
||||
| `advanced/sockjs/SockJsConfiguration` (103) | `...advanced.sockjs` | 1 | `advanced.compression`, `core`, `security` |
|
||||
|
||||
`StompConfiguration`은 `@Bean`이 하나도 없고 leaf 타입을 import하지도 않는다 — 프레임워크 설정만 조정하는 `WebSocketMessageBrokerConfigurer` 계열로 보인다.
|
||||
|
||||
**즉 41개 파일 중 `RabbitBrokerRelayConfiguration`이 참조하는 `advanced.stomp`·`core` 일부만 조립 가능하고, `cluster`(9)와 `resume`(8)은 어떤 `@Configuration`도 참조하지 않는다.**
|
||||
|
||||
### 18.2 (8.4) 문서와의 대조 — 이 sub-scope는 명시적으로 면책돼 있다
|
||||
|
||||
CLAUDE.md Evidence 절:
|
||||
|
||||
> "**Broker relay, multi-node/durable delivery, rollback-safe publication, replay/resume, backpressure, and a versioned domain projection catalog are P2 and are not claimed.**"
|
||||
|
||||
- broker relay → `advanced/stomp/rabbit` (7)
|
||||
- multi-node/durable delivery → `advanced/cluster` (9)
|
||||
- replay/resume → `advanced/resume` (8)
|
||||
|
||||
**24개 파일이 면책 목록에 정확히 대응한다.** `advanced/stomp`(17)는 목록에 없지만 STOMP 정책이므로 "not claimed" 범위로 읽는 것이 자연스럽다.
|
||||
|
||||
### 18.3 (8.2) 조건 형제 비교 — 재개 토큰 서명
|
||||
|
||||
`ResumeTokenCodec`(217) + `ResumeTokenKeyRing`(87)이 서명된 재개 토큰을 만든다. 이 조합은 graphql §24.1의 `HmacGraphQlCursorCodec` + `GraphQlCursorKeyRing`과 같은 형태다. **차이는 이쪽에는 그 키를 요구하는 시작 검증기가 없다는 것** — 즉 "키를 요구하고 서명하지 않는" 잘못된 확인 신호가 없다. 면책 목록에 replay/resume이 있으므로 문서·코드·검증이 일치한다.
|
||||
|
||||
## 19. Findings — 없음
|
||||
|
||||
41개 파일이 CLAUDE.md의 면책 범위 안에 있고, 조립되지 않는다는 사실이 문서와 일치한다. §4.1의 P1은 이 sub-scope를 포함하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 07 — `advanced/` 잔여 (41 files, main 30 + test 11)
|
||||
|
||||
> 근거 `evidence/raw/225-inbound-websocket-advanced-rest-probes.txt`
|
||||
|
||||
## 20. 무엇을 하는 코드인가
|
||||
|
||||
`advanced/presence`(4) · `advanced/codec`(3) + `cbor`(3) + `protobuf`(4) · `advanced/sockjs`(3) · `advanced/graphql`(3) · `advanced/compression`(3) · `advanced/client`(3) · `advanced/release`(1) · `advanced/http2`(1) · `advanced/http3`(1) · `advanced`(1).
|
||||
|
||||
`WebSocketAdvancedCapability`(95)가 능력 카탈로그이고 `propertyName()`이 `"backend.websocket.advanced."` 접두사를 만든다 — **§0에서 확인한 세 네임스페이스 중 아무 `@Configuration`도 읽지 않는 그 접두사다.** 실제 Advanced 게이트는 `app.websocket-platform.advanced.*`를 쓴다.
|
||||
|
||||
바이너리 코덱이 두 벌 있다 — `WebSocketCborCodec`(166) + `CborCodecProfile` + `DuplicateKeyPolicy`, `WebSocketProtobufCodec`(159) + `ProtobufCodecProfile` + `DescriptorCompatibility` + `DescriptorCompatibilityGate`(79). 공통 상위는 `BinaryCodecProfile`(44) + `WebSocketBinaryCodecBackend`(64) + `SchemaParity`(105).
|
||||
|
||||
`advanced/graphql`(3)은 GraphQL-over-WebSocket 브리지(`GraphQlTransportBridge` 163 · `GraphQlTransportBridgePolicy` 71 · `GraphQlCloseCode` 52) — graphql leaf의 `advanced/websocket`(§8, `modelled`)과 짝을 이루는 반대편이다.
|
||||
|
||||
## 21. Negative-space probes
|
||||
|
||||
### 21.1 (8.1) 도달성
|
||||
|
||||
30개 중 `@Configuration`은 `SockJsConfiguration`(103) 하나다. 그것이 `advanced.compression` · `core` · `security`를 import하므로 압축 정책과 core 일부가 그 경로로 도달 가능하다. 나머지 27개는 어떤 설정도 참조하지 않는다.
|
||||
|
||||
### 21.2 (8.2) 조건 형제 비교 — 능력 접두사가 둘이다
|
||||
|
||||
| 출처 | 문자열 | 소비 |
|
||||
|---|---|---|
|
||||
| `advanced/WebSocketAdvancedCapability:76` | `backend.websocket.advanced.<name>` | **없음** |
|
||||
| 네 `@Configuration`의 `@ConditionalOnProperty` | `app.websocket-platform.advanced.<name>` | 있음 |
|
||||
|
||||
graphql §36.2(`VirtualThreadProfile.propertyName()`이 실제 게이트와 다른 이름을 반환)와 같은 형태다. §22.1.
|
||||
|
||||
### 21.3 (8.3) 중복 메커니즘 — 승격 게이트
|
||||
|
||||
`advanced/release/AdvancedPromotionGate`(120)와 `release/WebSocketStableReleaseGate`(106, §11)가 각각 Advanced 승격과 Stable 릴리스를 판정한다. 둘 다 main 참조 0이고 테스트만 있다 — 릴리스 시점 도구이므로 런타임 미배선이 정상이다.
|
||||
|
||||
## 22. Findings
|
||||
|
||||
### 22.1 P3 — 능력 프로퍼티 이름을 만드는 코드와 실제 게이트가 다른 접두사를 쓴다
|
||||
|
||||
§21.2. `WebSocketAdvancedCapability.propertyName()`이 반환하는 `backend.websocket.advanced.*`를 읽는 `@ConditionalOnProperty`가 없다. 운영자가 그 메서드가 알려 주는 키를 설정하면 아무 일도 일어나지 않고, 실제로 능력을 켜는 키는 `app.websocket-platform.advanced.*`다.
|
||||
|
||||
`backend.websocket` 네임스페이스 전체가 소비자를 갖지 않는다는 §4.1의 부분집합이다.
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 08 — `testkit` + 대체 소스셋 3종 (18 files)
|
||||
|
||||
> 근거 `evidence/raw/226-inbound-websocket-testkit-probes.txt`
|
||||
|
||||
## 23. 무엇을 하는 코드인가
|
||||
|
||||
`testkit`(7)이 `testkit/arch` · `testkit/fault` · `testkit/runtime` 세 갈래로 계약과 하네스를 담는다.
|
||||
|
||||
대체 소스셋 셋이 각각 하나의 IT를 든다:
|
||||
|
||||
```
|
||||
brokerRelayTest/.../advanced/stomp/StompBrokerContractTest.java
|
||||
jettyWebSocketTest/.../runtime/JettyWebSocketRuntimeIT.java
|
||||
nginxWebSocketTest/.../proxy/NginxWebSocketContractIT.java + NginxWebSocketHarness.java
|
||||
nginxWebSocketTest/resources/nginx/nginx.conf + nginx-no-upgrade.conf
|
||||
```
|
||||
|
||||
`build.gradle`이 네 개 레인을 등록한다 — `websocketNginxTest` · `websocketBrokerRelayTest` · `websocketAdvancedTest` · `websocketJettyTest`.
|
||||
|
||||
`nginx-no-upgrade.conf`가 특히 의미 있다 — 프록시가 `Upgrade` 헤더를 전달하지 **않는** 설정을 별도 파일로 두고 그 경우의 계약을 확인한다. WebSocket 배포에서 가장 흔한 운영 실패다.
|
||||
|
||||
## 24. Negative-space probes
|
||||
|
||||
### 24.1 (8.1)·(8.2) 레인이 무엇을 인증하는가
|
||||
|
||||
세 IT가 각각 실제 Jetty · 실제 Nginx 컨테이너 · 실제 브로커를 상대로 돈다. 그런데 그 IT들이 세우는 애플리케이션이 무엇인지가 핵심이다 — `stomp/WebSocketConfig`(배선됨)인가, `PlatformWebSocketHandler`(미배선)인가.
|
||||
|
||||
`JettyWebSocketRuntimeIT`가 `runtime` 패키지에 있고 `testkit/runtime`이 그것을 받친다. inbound-web §48.1에서 확인한 형태("픽스처가 조립하고 레인이 픽스처를 인증한다")가 여기서도 성립하는지는 그 픽스처가 무엇을 등록하는지에 달려 있다.
|
||||
|
||||
### 24.2 (8.4) 레인과 문서
|
||||
|
||||
CLAUDE.md Evidence 절이 인용하는 것은 `WebSocketBoundaryQualificationTest`(랜덤 포트 Tomcat) 하나이고, 네 개 커스텀 레인은 언급되지 않는다.
|
||||
|
||||
## 25. Findings
|
||||
|
||||
### 25.1 P3/기록 — 네 개 커스텀 레인이 CLAUDE.md의 증거 절에 없다
|
||||
|
||||
§24.2. 증거로 인용되는 것은 기본 `test` 레인의 자격 테스트 하나뿐이고, Jetty·Nginx·브로커 릴레이·Advanced 네 레인은 문서에 없다. 그 레인들이 인증하는 것이 면책된 P2 항목(브로커 릴레이)과 미배선 플랫폼(runtime)이므로, 문서가 그것을 증거로 들지 않는 것은 일관되다 — 다만 레인의 존재 자체가 기록되지 않는다.
|
||||
|
||||
---
|
||||
|
||||
# 26. 모듈 종합 — `adapter-inbound-websocket`
|
||||
|
||||
## 26.1 커버리지 원장 정산
|
||||
|
||||
8개 sub-scope, **253 / 253 FULL_READ** · `STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0`.
|
||||
|
||||
## 26.2 발견 종합 — P2 2건 · P3 5건 *(§4.1은 분석 후 P1 → P2로 하향; §26.6 참조)*
|
||||
|
||||
| 심각도 | § | 발견 |
|
||||
|---|---|---|
|
||||
| **P2** | 4.1 | **`backend.websocket` 플랫폼(약 90개 main 파일)에 조립 지점이 없고 모듈 SSOT 문서에 존재하지 않는다** — 세 안전 장치(`WebSocketPlatformStartupValidator` 125 · `WebSocketStackExclusivity` 78 · `WebSocketPlatformSettings`의 safe-default 규약)가 전부 호출자 0 |
|
||||
| P2 | 13.1 | 연결 티켓·origin 정책·메시지 권한·연결 예산·명령 멱등성이 요청 경로 밖이고, 좁은 STOMP 인터셉터 둘이 대체한다 |
|
||||
| P3 | 22.1 | 능력 프로퍼티 이름 생성기와 실제 게이트가 다른 접두사(`backend.websocket.advanced.*` 대 `app.websocket-platform.advanced.*`) |
|
||||
| P3 | 4.2 | origin 허용목록이 두 네임스페이스에 중복 선언되고 기본값이 정반대(빈 집합 대 `http://localhost:3000`) |
|
||||
| P3 | 7.1 · 10.1 · 25.1 | `ReactiveFrameSink` 참조·테스트 0 · `WebSocketMessageHandler` 참조·테스트 0 · 커스텀 레인 4종이 문서 증거 절에 없음 |
|
||||
|
||||
## 26.3 이 모듈의 성격 — 부분 공시
|
||||
|
||||
169개 main 파일이 세 덩어리로 나뉘고 문서가 그중 하나만 서술한다:
|
||||
|
||||
```
|
||||
8 파일 stomp/ CLAUDE.md 전체가 이것을 서술 · 배선됨 · 랜덤포트 Tomcat 자격 테스트
|
||||
71 파일 advanced/** Evidence 절이 "not claimed"로 일부 면책 · 4개 @Configuration이 부분 조립
|
||||
90 파일 플랫폼 (backend.websocket) 문서 없음 · @Configuration 0 · 안전 장치 셋 전부 미호출
|
||||
```
|
||||
|
||||
**면책은 정확하고 불완전하다.** "Broker relay, multi-node/durable delivery, rollback-safe publication, replay/resume, backpressure, and a versioned domain projection catalog are P2 and are not claimed" — 이 문장이 `advanced/stomp/rabbit`(7) · `advanced/cluster`(9) · `advanced/resume`(8) · `outbound`(8)를 정확히 지목한다. 지목하지 않는 것이 90개 플랫폼 파일이고, 그 안에 "unsafe or incoherent 설정을 거부한다"는 시작 검증기와 "리액티브 엔드포인트가 조용히 응답하지 않는 상태"를 탐지하는 스택 배타성 검사가 있다.
|
||||
|
||||
**세 인바운드 모듈의 공시 스펙트럼:**
|
||||
|
||||
| | inbound-web (14) | inbound-graphql (16) | inbound-websocket (17) |
|
||||
|---|---|---|---|
|
||||
| 미배선 규모 | 다수 | 다수 | 90 / 169 |
|
||||
| 공시 | 없음 (README가 반대 서술) | 등급표 13행 전수 + 전용 테스트 | **부분** — 면책 문장 하나가 24 파일을 덮고 90 파일을 덮지 않음 |
|
||||
| P1 | 6 | 0 | **1** |
|
||||
|
||||
공시의 완성도가 그대로 P1 수에 대응한다.
|
||||
|
||||
**그리고 이 모듈은 inbound-web §40.1을 이름 붙여 탐지하는 코드를 갖고 있다** — `WebSocketStackExclusivity`가 "with both a servlet container and Reactor Netty available it starts the servlet one... serves none of them — no error, no warning"을 서술한다. web에서 29개 파일을 죽인 그 조건을, 이 leaf는 진단 클래스로 만들어 두고 호출하지 않는다.
|
||||
|
||||
## 26.4 완료 게이트
|
||||
|
||||
- [x] denominator 253 / 253 FULL_READ
|
||||
- [x] 8개 sub-scope 각각 §8.1~§8.4 수행 — Spring 애노테이션 전수(169 중 7) · 세 네임스페이스 소비자 추적 · 파일 단위 참조 카운트 · CLAUDE.md 면책 목록 대조
|
||||
- [x] evidence `218`–`226` 생성
|
||||
- [x] 거짓 양성 후보 검증 후 기각: `advanced/**` 미배선(→ Evidence 절이 면책) · `stomp` 어댑터의 좁은 범위(→ CLAUDE.md가 정확히 그 범위를 서술) · `ResumeTokenCodec` 키링 미배선(→ graphql §24.1과 달리 키를 요구하는 검증기가 없어 잘못된 확인 신호가 없음)
|
||||
- [x] 소스 미변경
|
||||
|
||||
## 26.5 실행 검증
|
||||
|
||||
```
|
||||
$ ./gradlew :adapter:inbound:websocket:test
|
||||
BUILD SUCCESSFUL in 17s
|
||||
GRADLE_EXIT=0
|
||||
|
||||
test-results 집계: classes=91 tests=720 failures=0 errors=0 **skipped=0**
|
||||
```
|
||||
|
||||
`build.gradle`이 등록하는 네 개 커스텀 레인(`websocketNginxTest` · `websocketBrokerRelayTest` · `websocketAdvancedTest` · `websocketJettyTest`)은 실행하지 않았다 — Nginx 레인은 Docker 컨테이너를, Jetty 레인은 별도 임베디드 서버를, 브로커 릴레이 레인은 외부 브로커를 요구한다.
|
||||
|
||||
**이 모듈의 P1(§4.1)은 720개 테스트가 전부 통과하는 상태에서 나왔다.** 미배선 플랫폼 90개 파일이 단위 테스트로 덮여 있고, 조립 여부를 묻는 테스트가 없다.
|
||||
|
||||
|
||||
## 26.6 분석 후 판정 변경 — §4.1 P1 → P2
|
||||
|
||||
app-bootstrap(모듈 18) 분석 중 `src/config/architecture/modules.json`의 `runtime_memberships`와 `conditionalTransportTest`의 계약 테스트를 읽고 이 모듈의 등급을 낮췄다.
|
||||
|
||||
```
|
||||
adapter-inbound-websocket runtime_memberships = []
|
||||
adapter-inbound-grpc runtime_memberships = []
|
||||
adapter-inbound-graphql runtime_memberships = ["app-bootstrap"]
|
||||
adapter-inbound-web runtime_memberships = ["app-bootstrap", "sample-portfolio"]
|
||||
```
|
||||
|
||||
```java
|
||||
// ConditionalTransportCompositionContractTest
|
||||
/** Transports that must not reach any runtime: no membership, and no composition-root edge. */
|
||||
BUILD_ONLY_TRANSPORTS = { "adapter-inbound-grpc": GrpcServerConfig,
|
||||
"adapter-inbound-websocket": stomp.WebSocketConfig }
|
||||
```
|
||||
|
||||
> "**gRPC and WebSocket are build-only — no membership, and nothing may put them on a runtime.**"
|
||||
|
||||
**이 leaf는 어떤 출하 런타임에도 올라가지 않는다.** 따라서 §4.1의 실패 시나리오("팀이 `backend.websocket.enabled=true`를 설정하고 엔드포인트가 열리지 않는다")는 현재 출하되는 두 조합(`app-bootstrap` · `sample-portfolio`) 어디에서도 발생할 수 없다. 그것이 P1의 조건("지금 틀린 동작")을 충족하지 않게 만든다.
|
||||
|
||||
**그러나 발견 자체는 남는다.** CLAUDE.md가 "A future composition must deliberately add the registered dependency and set `ca-skeleton.websocket.enabled=true`"로 채택 경로를 명시하므로, 이 leaf는 채택을 전제로 유지된다. 그 채택 시점에 채택자가 마주하는 상태가 §4.1이 서술한 것 — 세 개 네임스페이스, 그중 하나는 소비자 없음, 문서는 8개 파일만 서술 — 이고, 세 안전 장치가 호출되지 않는다는 사실도 그대로다.
|
||||
|
||||
`ConditionalTransportCompositionContractTest`의 다른 문장이 이 상황에 정확히 적용된다 — "**class existence is not composition evidence.**" 이 모듈에는 조립 증거가 없는 클래스가 90개 있고, build-only 등급이 그것을 오늘의 사고에서 면제하되 채택 시점의 부채로 남긴다.
|
||||
|
||||
## Source anchors
|
||||
|
||||
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **126개** (main 122 · test 1 · 기타 3).
|
||||
|
||||
```
|
||||
src/adapter/inbound/websocket/build.gradle
|
||||
src/config/architecture/modules.json (adapter-inbound-websocket 항목)
|
||||
|
||||
main:
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/WebSocketAdvancedCapability.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ExternalSessionIndex.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ExternalSessionSummary.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/FanoutDeduplicator.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/FanoutEnvelope.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/MessagingFanoutAdapter.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/PortBackedExternalSessionIndex.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayCursor.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayEventMapper.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/BinaryCodecProfile.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/SchemaParity.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/WebSocketBinaryCodecBackend.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/cbor/CborCodecProfile.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/cbor/DuplicateKeyPolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/cbor/WebSocketCborCodec.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/DescriptorCompatibility.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/DescriptorCompatibilityGate.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/ProtobufCodecProfile.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/WebSocketProtobufCodec.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlCloseCode.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridge.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridgePolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeCoordinator.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeDecision.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenCodec.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenKeyRing.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenPayload.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/sockjs/SockJsConfiguration.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAckMode.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAckPolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAuthorizationPolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompBrokerExclusivity.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompConfiguration.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompDestinationCatalog.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompEvidence.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompProfile.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompRefusal.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompSecurityInterceptor.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/MultiNodeUserDestination.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/RabbitBrokerRelayConfiguration.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/RabbitBrokerRelayProfile.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserDestinationPolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserDestinationRouting.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserSessionLocation.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/authz/MessageAuthorizationPolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/budget/WebSocketConnectionBudget.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/codec/StrictWebSocketJsonCodec.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/codec/WebSocketWireTypeManifest.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketPlatformSettings.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketPlatformStartupValidator.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketStackExclusivity.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketActorReference.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionContext.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionId.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointCatalog.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointName.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketNodeId.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketSubprotocolName.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketCloseCode.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketClosePolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketErrorMessage.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketErrorTransport.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketFailureCategory.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketConnectionEvidence.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketInboundEvidence.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketOutboundEvidence.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/LateResponseTombstone.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketCorrelationRegistry.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketMessageHandler.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeAdmissionPipeline.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeDecision.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeRequest.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/CommandReconciliation.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/CommittedResultLedger.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/WebSocketCommandKey.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/WebSocketCommandOutcome.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/inbound/FragmentAssembler.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/lifecycle/CloseOrchestration.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/lifecycle/HeartbeatPolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketModuleBoundary.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketModulePurity.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketStableModule.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/observability/SafeWebSocketLogFields.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/observability/WebSocketMetricTags.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/ordering/GapDetector.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/ordering/OrderingProfile.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/ordering/StreamSequencer.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/GlobalBufferBudget.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundDelivery.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundEnqueueResult.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundPriority.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueue.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueueSnapshot.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/SerializedOutboundWriter.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketCatalogFingerprint.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketEnvelope.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageCatalog.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageDescriptor.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageFamily.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageType.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketProtocolProfile.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketSchemaVersion.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketNginxProxyProfile.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketRollingRestartScenario.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketStableReleaseGate.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketAuthenticationProfile.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketConnectionTicket.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketOriginPolicy.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketTicketStore.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/servlet/PlatformWebSocketHandler.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/servlet/ServletFrameSink.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/session/WebSocketSessionRegistry.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/AuthenticatedHandshakeInterceptor.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/LiveEventProjector.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/LiveEventStompBroadcaster.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/SafeStompSubProtocolErrorHandler.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketConfig.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketInboundAuthorizationInterceptor.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketProperties.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/webflux/ReactiveFrameSink.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/webflux/WebSocketDataBufferLifecycle.java
|
||||
src/main/java/dev/caskeleton/adapter/inbound/websocket/webflux/WebSocketDataBufferPolicy.java
|
||||
|
||||
test:
|
||||
src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketBoundaryQualificationTest.java
|
||||
|
||||
기타:
|
||||
src/build.gradle
|
||||
src/config/architecture/modules.json
|
||||
src/jettyWebSocketTest/java/dev/caskeleton/adapter/inbound/websocket/runtime/JettyWebSocketRuntimeIT.java
|
||||
|
||||
해석되지 않은 인용 (9종) — 외부 타입·문서상 약칭 등:
|
||||
evidence/raw/218-inbound-websocket-module-inventory.txt
|
||||
evidence/raw/219-inbound-websocket-core-probes.txt
|
||||
evidence/raw/220-inbound-websocket-protocol-probes.txt
|
||||
evidence/raw/221-inbound-websocket-session-probes.txt
|
||||
evidence/raw/222-inbound-websocket-policy-probes.txt
|
||||
evidence/raw/223-inbound-websocket-stomp-probes.txt
|
||||
evidence/raw/224-inbound-websocket-advanced-stomp-probes.txt
|
||||
evidence/raw/225-inbound-websocket-advanced-rest-probes.txt
|
||||
evidence/raw/226-inbound-websocket-testkit-probes.txt
|
||||
|
||||
```
|
||||
@@ -0,0 +1,750 @@
|
||||
# app-bootstrap — 코드베이스 분석
|
||||
|
||||
|
||||
## SSOT identity — 2026-08-31 재검증
|
||||
|
||||
- registered leaf id: `app-bootstrap`
|
||||
- canonical state `analysisFile`: `analysis/18-app-bootstrap.md` (이 문서) — 이 leaf의 단일 SSOT
|
||||
- source path: `src/app-bootstrap` · Gradle `:app-bootstrap`
|
||||
- registry `allowed_dependencies`: `["domain-core", "application-core", "adapter-outbound-persistence-jpa", "adapter-outbound-support", "adapter-outbound-messaging", "adapter-outbound-cache-redis", "adapter-outbound-notification", "adapter-outbound-fileserver", "adapter-outbound-httpclient", "adapter-outbound-identifier", "adapter-inbound-web", "shared-contract", "adapter-outbound-persistence-mongo", "adapter-inbound-graphql", "messaging-spring-boot-starter"]`
|
||||
- registry `runtime_memberships`: `["app-bootstrap"]`
|
||||
- coverage ledger: `FULL_READ` **455** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
|
||||
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
|
||||
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행) — `EVD-334`의 jq/exit-78 finding과 compose 계약 독립 검증이 이 리프의 것이다
|
||||
|
||||
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
|
||||
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
|
||||
|
||||
---
|
||||
> **분석 대상** `src/app-bootstrap` · revision `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
|
||||
> **분모** 455 tracked files (main 157 · test 288 · functionalTest 4 · sampleOffTest 1 · conditionalTransportTest 1 · governance 4)
|
||||
> **LOC** main Java 12,380 · test Java **32,568** (main의 2.6배)
|
||||
> **근거** `evidence/raw/227-app-bootstrap-module-inventory.txt`, 이하 `228`–`235`
|
||||
|
||||
## 0. 이 모듈의 위치
|
||||
|
||||
컴포지션 루트다. 지금까지 분석한 17개 모듈에서 반복해서 물었던 질문 — "이 능력을 누가 조립하는가" — 의 답이 여기 있거나 없다.
|
||||
|
||||
**조립 표면 전체가 네 개 리소스 파일에 있다:**
|
||||
|
||||
```
|
||||
META-INF/spring/...AutoConfiguration.imports (6줄)
|
||||
bootstrap.autoconfigure.fileserver.FileserverPlatformAutoConfiguration
|
||||
bootstrap.autoconfigure.httpclient.HttpClientPlatformAutoConfiguration
|
||||
bootstrap.autoconfigure.persistencejpa.PersistenceJpaRootAutoConfiguration
|
||||
bootstrap.autoconfigure.messaging.DisabledMessagingSentinelAutoConfiguration
|
||||
bootstrap.notification.NotificationRootAutoConfiguration
|
||||
bootstrap.activation.AdapterActivationAutoConfiguration
|
||||
|
||||
META-INF/spring.factories
|
||||
EnvironmentPostProcessor = MasterSwitchEnvironmentPostProcessor,
|
||||
RuntimeEnvironmentProfileValidator,
|
||||
CapabilityDependencyEnvironmentValidator,
|
||||
TracingSamplingEnvironmentPostProcessor,
|
||||
RedisReadinessGroupPostProcessor,
|
||||
DatabaseReadinessGroupPostProcessor
|
||||
SpringBootExceptionReporter = StartupFailureExceptionReporter
|
||||
AutoConfigurationImportFilter = JpaOffAutoConfigurationImportFilter
|
||||
ApplicationListener = ResolvedProfileLoggingContextListener
|
||||
|
||||
META-INF/spring/...ManagementContextConfiguration.imports (1줄)
|
||||
bootstrap.autoconfigure.fileserver.FileserverAdminManagementContextConfiguration
|
||||
|
||||
CaSkeletonApplication.java — @ComponentScan + @ConfigurationPropertiesScan
|
||||
```
|
||||
|
||||
**여섯 개 자동설정 진입점에 web·websocket·grpc가 없다.** 그 셋은 컴포넌트 스캔에 의존하며, 모듈 14·17에서 확인했듯 각각 다른 결과를 낳았다.
|
||||
|
||||
## 1. 커버리지 원장
|
||||
|
||||
| # | sub-scope | main | test | 기타 | 합 | 상태 |
|
||||
|---|---|---:|---:|---:|---:|---|
|
||||
| 1 | governance + resources + `CaSkeletonApplication` + `activation` + `settings` | 21 | 37 | 4 | 62 | **COMPLETE** |
|
||||
| 2 | `autoconfigure/*` (httpclient 16 · fileserver 15 · jpa 10 · persistencejpa 3 · messaging 1) | 45 | 20 | – | 65 | **COMPLETE** |
|
||||
| 3 | `runtime` + `runtime/startup` + `logging` + `metrics` + `tracing` | 49 | 36 | – | 85 | **COMPLETE** |
|
||||
| 4 | `notification` + `outbox` + `idempotency` + `messaging` + `async` + `concurrency` + `lock` | 35 | 24 | – | 59 | **COMPLETE** |
|
||||
| 5 | `security` + `management/security` + `redis` + `mongo` + `authz` | 7 | 5 | – | 12 | **COMPLETE** |
|
||||
| 6 | test: 아키텍처 규칙 + 위반/허용 픽스처 | 0 | 90 | – | 90 | **COMPLETE** |
|
||||
| 7 | test: contract 레인 + integration | 0 | 54 | – | 54 | **COMPLETE** |
|
||||
| 8 | test: onboarding 픽스처 + 잔여 + 대체 소스셋 | 0 | 22 | 6 | 28 | **COMPLETE** |
|
||||
| | **TOTAL** | **157** | **288** | **10** | **455** | **8 / 8** |
|
||||
|
||||
`FULL_READ 455 · STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0`. 분할은 `227-...`의 패키지 트리에서 기계 계산(중복 0 · 미할당 0).
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 01 — governance + `CaSkeletonApplication` + `activation` + `settings` (62 files)
|
||||
|
||||
> 근거 `evidence/raw/228-app-bootstrap-activation-probes.txt` (`file_count=62`)
|
||||
|
||||
## 2. 무엇을 하는 코드인가
|
||||
|
||||
**활성화 모델의 SSOT는 `shared-contract`의 `MasterSwitch` enum이다:**
|
||||
|
||||
```java
|
||||
/**
|
||||
* The five adapters this skeleton ships behind an explicit switch, and the names that address them.
|
||||
*
|
||||
* <p>One place knows the names. Spread across conditions as string literals, a rename becomes a
|
||||
* silent activation change: the condition stops matching, the adapter stops assembling, and
|
||||
* nothing reports it.
|
||||
*/
|
||||
public enum MasterSwitch {
|
||||
PERSISTENCE_JPA ("ca-skeleton.persistence-jpa.enabled", "APP_PERSISTENCE_JPA_ENABLED"),
|
||||
PERSISTENCE_MONGO ("ca-skeleton.persistence-mongo.enabled", "APP_PERSISTENCE_MONGO_ENABLED"),
|
||||
MESSAGING ("app.messaging.enabled", "APP_MESSAGING_ENABLED"),
|
||||
NOTIFICATION_PLATFORM("ca-skeleton.notification.platform.enabled", "APP_NOTIFICATION_PLATFORM_ENABLED"),
|
||||
GRAPHQL ("backend.graphql.enabled", "APP_GRAPHQL_ENABLED");
|
||||
```
|
||||
|
||||
그 enum이 네 개 장치를 구동한다:
|
||||
|
||||
| 장치 | 하는 일 |
|
||||
|---|---|
|
||||
| `MasterSwitchEnvironmentPostProcessor` (71) | 값이 정확히 `true`/`false`가 아니면 부팅 거부 |
|
||||
| `CapabilityDependencyValidator` (156) | 능력 간 의존(예: outbox는 JPA+messaging 필요) 검증 |
|
||||
| `AdapterActivationEndpoint` (74) → `AdapterActivationReport` (34) | 해석된 상태를 액추에이터로 발행 |
|
||||
| `MasterSwitchRegistryContractTest` | enum ↔ `docs/registries/env-keys.yaml` ↔ `application.yml` 삼자 일치 |
|
||||
|
||||
`MasterSwitchEnvironmentPostProcessor`의 거부 메시지가 이 모듈의 성격을 보여준다:
|
||||
|
||||
> "%s must be exactly true or false, but was \"%s\". **A value this close to a boolean is a deployment that believes it set the switch; it is rejected rather than read as off.**"
|
||||
|
||||
그리고 원시 값을 읽는 이유도 적혀 있다 — "An adapter that is off has to be able to start next to an environment full of its own malformed configuration — **a validator that bound the namespace to check the switch would fail exactly the deployments the switch exists to protect.**"
|
||||
|
||||
`AdapterActivationReport`는 "설정된 것"이 아니라 "해석된 것"을 낸다:
|
||||
|
||||
> "A smoke lane that asserts on the environment it passed in is **asserting on its own input**. This is the application's answer: the profile it settled on, the state each master switch parsed to, and which capabilities are asking for a relational connection."
|
||||
|
||||
`MasterSwitchRegistryContractTest`가 삼자 일치를 enum에서 파생시킨다 — "**Deriving every case from `MasterSwitch` means a sixth adapter cannot be added without this test demanding its row.**"
|
||||
|
||||
## 3. Negative-space probes — sub-scope 01
|
||||
|
||||
### 3.1 (8.4) 카운트 드리프트 — "다섯 어댑터"와 실제 스위치를 가진 어댑터
|
||||
|
||||
`MasterSwitch`가 다섯을 담는다. 저장소가 실제로 출하하는, 기본 꺼짐이고 운영자가 켜는 인바운드 어댑터 스위치를 전수하면:
|
||||
|
||||
| 스위치 | 기본 | `MasterSwitch` | env-keys 레지스트리 | 확인 출처 |
|
||||
|---|---|---|---|---|
|
||||
| `ca-skeleton.persistence-jpa.enabled` | off | 예 | 예 | — |
|
||||
| `ca-skeleton.persistence-mongo.enabled` | off | 예 | 예 | — |
|
||||
| `app.messaging.enabled` | off | 예 | 예 | — |
|
||||
| `ca-skeleton.notification.platform.enabled` | off | 예 | 예 | — |
|
||||
| `backend.graphql.enabled` | off | 예 | 예 | — |
|
||||
| **`ca-skeleton.grpc.enabled`** | **off** | **아니오** | **아니오** | 모듈 15 §2 |
|
||||
| **`ca-skeleton.websocket.enabled`** | **off** | **아니오** | **아니오** | 모듈 17 §14 |
|
||||
| **`backend.websocket.enabled`** | **off** | **아니오** | **아니오** | 모듈 17 §0 |
|
||||
| `backend.web.mvc.enabled` · `backend.web.webflux.enabled` | **on**(`matchIfMissing=true`) | 아니오 | 아니오 | 모듈 14 §3.2 |
|
||||
| `backend.web.budgets.enabled` · `app.web-platform.durable-operations.enabled` | off | 아니오 | 아니오 | 모듈 14 §15.4 · §19.2 |
|
||||
| `app.websocket-platform.advanced.*` (4종) | off | 아니오 | 아니오 | 모듈 17 §0 |
|
||||
|
||||
기계 확인:
|
||||
|
||||
```
|
||||
$ grep -rn 'ca-skeleton.grpc|ca-skeleton.websocket|backend.web.mvc|backend.websocket' \
|
||||
--include=*.java app-bootstrap/src/main app-bootstrap/src/test -> 0
|
||||
$ grep -in 'grpc|websocket' docs/registries/env-keys.yaml -> 0 (341개 키 등록)
|
||||
$ grep -in 'grpc|websocket' docs/registries/capabilities.yaml -> 0
|
||||
$ grep -n 'grpc|websocket' src/config/architecture/modules.json -> 등록된 leaf로는 존재
|
||||
```
|
||||
|
||||
즉 **모듈 레지스트리는 두 leaf를 알고, 활성화 모델과 운영자용 env 레지스트리는 모르는 상태다.** §4.1.
|
||||
|
||||
### 3.2 (8.1) 도달성 — 여섯 자동설정 진입점이 덮는 범위
|
||||
|
||||
| 진입점 | 대상 leaf | 모듈 분석에서의 결과 |
|
||||
|---|---|---|
|
||||
| `FileserverPlatformAutoConfiguration` + `FileserverStartupConfiguration` | outbound fileserver + inbound web의 `fileserver/**` | 모듈 14 §38 — **이 leaf에서 유일하게 완전 조립된 하위 트리** |
|
||||
| `HttpClientPlatformAutoConfiguration` | outbound httpclient | 모듈 11 |
|
||||
| `PersistenceJpaRootAutoConfiguration` + `JpaOffAutoConfigurationImportFilter` | persistence-jpa | 모듈 5 |
|
||||
| `DisabledMessagingSentinelAutoConfiguration` | messaging | 모듈 12 |
|
||||
| `NotificationRootAutoConfiguration` | outbound notification | 모듈 13 |
|
||||
| `AdapterActivationAutoConfiguration` | 자기 자신(액추에이터) | — |
|
||||
|
||||
**inbound web · websocket · grpc에 대응하는 진입점이 없다.** 그 셋은 컴포넌트 스캔으로만 조립되고, 스캔 경계(`AUTO_CONFIGURED_PACKAGES` 정규식)가 web의 다섯 패키지를 제외하면서 그것을 넘겨받을 자동설정을 만들지 않은 것이 모듈 14 §8.1이다.
|
||||
|
||||
### 3.3 (8.2) 조건 형제 비교 — 두 종류의 "꺼짐"
|
||||
|
||||
| 방식 | 예 | 꺼졌을 때의 상태 |
|
||||
|---|---|---|
|
||||
| 마스터 스위치 + 게이트된 루트 자동설정 | jpa · mongo · messaging · notification · graphql | 빈 0개 + 설정 바인딩도 안 됨(`@EnableConfigurationProperties`가 게이트 안쪽) |
|
||||
| 컴포넌트 스캔 + `@ConditionalOnProperty` | grpc · websocket · web | 빈 0개 + **설정은 바인딩됨**(`@ConfigurationPropertiesScan`이 세 패키지를 포함) |
|
||||
|
||||
`CaSkeletonApplication`의 javadoc이 첫 번째 방식의 이유를 적는다:
|
||||
|
||||
> "The asymmetry that existed before — **beans gated, settings not** — is why a notification settings object bound itself in a deployment whose notification master was off. A capability whose beans are gated but whose settings still bind is gated only where somebody remembered to gate it."
|
||||
|
||||
그리고 두 번째 방식이 정확히 그 비대칭이다 — `dev.caskeleton.adapter.inbound.grpc` · `.web` · `.websocket` 세 패키지가 `@ConfigurationPropertiesScan` 목록에 있다.
|
||||
|
||||
### 3.4 (8.3) 중복 메커니즘 — 세 개의 환경 검증기
|
||||
|
||||
`MasterSwitchEnvironmentPostProcessor`(스위치 값 문법) · `RuntimeEnvironmentProfileValidator`(93, 프로파일) · `CapabilityDependencyEnvironmentValidator`(62 → `CapabilityDependencyValidator` 156, 능력 간 의존). 셋 다 `EnvironmentPostProcessor`이고 관심사가 다르다. 중복 아님.
|
||||
|
||||
## 4. Sub-scope 01 findings
|
||||
|
||||
### 4.1 — 다섯 어댑터 범위는 런타임 멤버십 레지스트리와 일치한다 (결함 아님)
|
||||
|
||||
§3.1의 표를 처음에는 "출하되는 스위치가 다섯보다 많다"는 결함으로 기록했다. 그 판정은 **틀렸다**. `src/config/architecture/modules.json`의 `runtime_memberships`가 결정적이다:
|
||||
|
||||
```
|
||||
adapter-inbound-web runtime_memberships = ["app-bootstrap", "sample-portfolio"]
|
||||
adapter-inbound-graphql runtime_memberships = ["app-bootstrap"]
|
||||
adapter-inbound-grpc runtime_memberships = []
|
||||
adapter-inbound-websocket runtime_memberships = []
|
||||
```
|
||||
|
||||
**gRPC와 WebSocket은 build-only leaf다** — 어떤 런타임에도 올라가지 않는다. 그리고 그 사실이 기계로 강제된다:
|
||||
|
||||
```java
|
||||
// conditionalTransportTest/.../ConditionalTransportCompositionContractTest
|
||||
/** Transports that must not reach any runtime: no membership, and no composition-root edge. */
|
||||
private static final Map<String, String> BUILD_ONLY_TRANSPORTS =
|
||||
Map.of("adapter-inbound-grpc", "dev.caskeleton.adapter.inbound.grpc.GrpcServerConfig",
|
||||
"adapter-inbound-websocket", "dev.caskeleton.adapter.inbound.websocket.stomp.WebSocketConfig");
|
||||
```
|
||||
|
||||
그 테스트의 javadoc이 세 전송의 등급을 나눈다:
|
||||
|
||||
> "**gRPC and WebSocket are build-only — no membership, and nothing may put them on a runtime.** GraphQL is shipped and switch-gated, which is a stronger claim and carries a stronger obligation: **the switch has to be the thing that decides.**"
|
||||
|
||||
따라서 두 어댑터가 `MasterSwitch`·`env-keys.yaml`·`AdapterActivationReport`에 없는 것은 누락이 아니라 **일관성**이다. 런타임에 오르지 않는 어댑터에는 운영자용 활성화 스위치가 필요하지 않다.
|
||||
|
||||
**남는 것은 `backend.web.*` 하나다.** `adapter-inbound-web`은 두 런타임에 올라가고(`["app-bootstrap","sample-portfolio"]`), 그 스위치들 — `backend.web.mvc.enabled` · `backend.web.webflux.enabled`(둘 다 `matchIfMissing=true`, **기본 켜짐**) · `backend.web.budgets.enabled` · `app.web-platform.durable-operations.enabled` — 은 `MasterSwitch`에도 `env-keys.yaml` 341개 키에도 없다. §4.1b.
|
||||
|
||||
### 4.1b P3 — 출하되는 web 어댑터의 스위치가 활성화 모델 밖에 있다
|
||||
|
||||
§4.1. `adapter-inbound-web`은 두 런타임 멤버이므로 build-only 예외에 해당하지 않는다. 그런데 그 네 개 스위치가 조건 안의 문자열 리터럴로만 존재해 `MasterSwitch`의 javadoc이 경계하는 상태다 — "Spread across conditions as string literals, a rename becomes a silent activation change."
|
||||
|
||||
**다만 web은 다른 넷과 성질이 다르다.** MVC/WebFlux 스위치는 `matchIfMissing = true`로 **기본 켜짐**이므로 "옵션 어댑터"가 아니고, `MasterSwitch`가 규정하는 "explicit switch" 모델(전부 기본 꺼짐, `env-keys.yaml`에 행이 있고 삼자 일치 테스트가 강제)에 그대로 넣을 수 없다. 그리고 모듈 14 §8.1이 확인했듯 web의 조립 자체가 미해결이다 — 스캔에서 다섯 패키지를 빼고 넘겨받는 자동설정을 만들지 않은 상태다.
|
||||
|
||||
기록하는 것은 순서다: web의 스위치를 활성화 모델에 넣는 것은 모듈 14 §8.1(어떤 자동설정이 무엇을 소유하는가)을 먼저 정한 뒤에 할 수 있는 일이다. 지금은 "무엇을 스위치로 부를지"가 결정되지 않았다.
|
||||
|
||||
### 4.1c P3/기록 — 조건부 전송 게이트가 빨간 채로 방치된 이력이 기록돼 있다
|
||||
|
||||
`ConditionalTransportCompositionContractTest`의 javadoc이 자기 이력을 적는다:
|
||||
|
||||
> "This test asserted that all three transports had `runtime_memberships: []` and that their classes load. **Both halves aged badly.** ... The test went red the moment that landed and **nobody saw it, because this suite runs in `conditionalTransportQualification` rather than in `test`** — and `conditionalTransportQualification` is one of the two commands CI runs. **A gate that is red in a lane nobody runs locally is a gate that reports whatever the last person to run it saw.**"
|
||||
|
||||
그리고 두 번째 절반에 대한 판정도 남긴다 — "A type resolving proves the jar is on a classpath; it says nothing about whether a composition assembles the transport... **class existence is not composition evidence.**"
|
||||
|
||||
이 문장이 모듈 14 §48.1·모듈 17 §4.1과 같은 원칙을 다른 각도에서 말한다. 기록으로 남긴다.
|
||||
|
||||
### 4.2 P3/기록 — 세 인바운드 leaf의 설정이 마스터 스위치 밖에서 바인딩된다
|
||||
|
||||
§3.3. `CaSkeletonApplication`의 javadoc이 "beans gated, settings not"을 과거의 사고로 기록하면서, 그 형태를 grpc·web·websocket 세 패키지에 대해 유지한다. 모듈 15 §4.2에서 확인했듯 grpc에서는 기본값이 전부 유효해 무해하고, 모듈 17 §4.1에서는 `backend.websocket` 설정이 바인딩만 되고 소비되지 않는 상태를 만든다.
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 02 — `autoconfigure/*` (65 files, main 45 + test 20)
|
||||
|
||||
> 근거 `evidence/raw/229-app-bootstrap-autoconfigure-probes.txt` (`file_count=65`)
|
||||
|
||||
## 5. 무엇을 하는 코드인가
|
||||
|
||||
다섯 개 능력의 조립 루트. `httpclient`(16) · `fileserver`(15) · `jpa`(10) · `persistencejpa`(3) · `messaging`(1).
|
||||
|
||||
**`fileserver`(15)가 이 저장소에서 가장 완전한 조립 사례다.** 모듈 14 §38에서 확인했듯 `FileserverPlatformAutoConfiguration`이 URI 매퍼·다운로드 전략·요청 컨텍스트 팩토리를 만들고, `FileserverStartupConfiguration:87`이 `attestMapping()`을 시작 시 호출하며, `FileserverAdminManagementContextConfiguration`이 관리 평면을 **별도 관리 컨텍스트**에 등록한다 — `CaSkeletonApplication`이 그 이유를 적는다: "those routes belong to the management context, and a component scan that also found them would publish the management plane on the public connector — the exposure the separate context exists to remove."
|
||||
|
||||
**`persistencejpa`(3)**는 `JpaOffAutoConfigurationImportFilter`를 포함한다 — graphql의 `GraphQlOffAutoConfigurationImportFilter`(모듈 16 §3.2)와 같은 형태로, 프레임워크 자동설정을 후보 집합에서 제거해 "빈은 없는데 프레임워크가 서비스하는" 상태를 막는다. `DataSourceRequirement`가 `MasterSwitch`를 직접 읽어 어떤 능력이 관계형 연결을 요구하는지 계산한다.
|
||||
|
||||
**`messaging`(1)**은 `DisabledMessagingSentinelAutoConfiguration` 하나다 — 꺼진 메시징을 사용하려 할 때 조용한 no-op 대신 실패를 내는 센티널.
|
||||
|
||||
## 6. Negative-space probes
|
||||
|
||||
### 6.1 (8.1) 도달성
|
||||
|
||||
45개 main 파일 중 다른 main 파일이 참조하지 않는 것은 전부 `@Configuration`/`@AutoConfiguration` 루트이며, 이는 `.imports`가 진입점으로 등록하므로 정상이다. 고아 파일 없음.
|
||||
|
||||
### 6.2 (8.2) 조건 형제 비교 — 두 off 필터
|
||||
|
||||
`JpaOffAutoConfigurationImportFilter`(app-bootstrap)와 `GraphQlOffAutoConfigurationImportFilter`(graphql leaf). 같은 문제를 같은 방식으로 푼다. 차이는 위치다 — JPA 것은 컴포지션 루트에, GraphQL 것은 leaf에 있다. 둘 다 `spring.factories`에 등록돼 있고 동작한다.
|
||||
|
||||
**web·websocket에는 대응물이 없다.** 그 둘은 프레임워크가 발행하는 엔드포인트를 갖지 않으므로(자체 `@Configuration`이 등록) 필요하지 않다.
|
||||
|
||||
### 6.3 (8.4) 카운트 — `.imports` 여섯 줄과 다섯 능력
|
||||
|
||||
`.imports`의 여섯 항목 중 다섯이 능력 루트이고 하나(`AdapterActivationAutoConfiguration`)가 자기 액추에이터다. `MasterSwitch`의 다섯과 일치한다 — 단 `PERSISTENCE_MONGO`는 `.imports`에 루트가 없고 컴포넌트 스캔 제외 정규식(`adapter\.outbound\.mongo\..*`)으로만 관리된다. §7.1.
|
||||
|
||||
## 7. Findings
|
||||
|
||||
### 7.1 P3/기록 — `PERSISTENCE_MONGO`만 자동설정 루트가 없다
|
||||
|
||||
다섯 마스터 스위치 중 넷은 `.imports`에 게이트된 루트를 갖는다(jpa · messaging 센티널 · notification · graphql은 자기 leaf에). Mongo는 `AUTO_CONFIGURED_PACKAGES` 정규식의 `adapter\.outbound\.mongo\..*` 제외만 있고 그것을 넘겨받는 루트가 `.imports`에 없다.
|
||||
|
||||
모듈 14 §8.1이 정확히 그 형태의 결함이었다(제외했는데 넘겨받지 않음). Mongo에서 같은 상태인지는 이 sub-scope의 파일만으로는 판정되지 않는다 — outbound mongo leaf(모듈 6)가 자기 `.imports`를 갖는지 확인이 필요하고, 그 모듈은 이미 COMPLETE로 분석됐으나 이 관점에서 재확인하지 않았다. 기록으로 남긴다.
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 03 — `runtime` + `runtime/startup` + `logging` + `metrics` + `tracing` (85 files, main 49 + test 36)
|
||||
|
||||
> 근거 `evidence/raw/230-app-bootstrap-runtime-probes.txt` (`file_count=85`)
|
||||
|
||||
## 8. 무엇을 하는 코드인가 — 이 저장소에서 시작 검증이 실제로 도는 곳
|
||||
|
||||
**열두 개 검증기가 전부 배선돼 있다:**
|
||||
|
||||
```
|
||||
StartupSafetyValidator (103) main_refs=3 RuntimeSafetyConfig
|
||||
SecretSourceValidator (182) main_refs=3 SecretSourceConfig
|
||||
RequiredEnvironmentValidator (49) main_refs=1
|
||||
HikariPoolConstraintValidator main_refs=2
|
||||
JpaSchemaSafetyValidator main_refs=1
|
||||
OpenInViewSafetyValidator main_refs=1
|
||||
PersistenceVendorProdSafetyValidator main_refs=2
|
||||
PostgreSqlTransportSecurityValidator main_refs=2
|
||||
RedisActivationValidator main_refs=2
|
||||
RuntimeNumericBoundsValidator main_refs=1
|
||||
FlywayProdSafetyValidator main_refs=1
|
||||
MigrationStartupRunner (53) main_refs=1 MigrationStartupConfig
|
||||
```
|
||||
|
||||
`RuntimeSafetyConfig`(`@Configuration`, `@Bean` 7개)가 "Wires the runtime-safety startup fail-fast validators into the running application"을 한다.
|
||||
|
||||
`StartupSafetyValidator`가 `SmartInitializingSingleton`인 이유가 적혀 있다 — "the check runs once after every singleton is instantiated but before the context finishes refreshing; a violation throws so the context refuses to start."
|
||||
|
||||
**시작 실패의 어휘가 구조화돼 있다.** `StartupFailures`(75)가 단일 발생원이고 "Every factory method emits the structured failure log (with the `startup.phase` / `error.code` / `error.category` fields) before returning the exception to throw." `StartupErrorCode` · `StartupPhase` · `StartupFailureLogState`가 그 어휘이고, `StartupFailureExceptionReporter`가 `SpringBootExceptionReporter`로 등록돼 컨텍스트가 없는 시점의 실패도 같은 형식으로 낸다. 종료 코드까지 규정한다(`STARTUP_VALIDATION_FAILED` = exit 78, `MigrationFailedException` = exit 70).
|
||||
|
||||
**`logging`(8)은 logback을 통해 배선된다.** `MetricsAsyncAppender` · `SamplingTurboFilter` · `SecretMaskingMessageConverter` · `SecretMaskingJsonGeneratorDecorator` · `StartupFailureSpringBootLogFilter` 다섯이 Java 참조가 0인데, `logback-spring.xml`이 클래스 이름으로 등록한다(`:56` · `:59` · `:62` · `:91` · `:124` · `:137` · `:148`). `ResolvedProfileLoggingContextListener`는 `spring.factories`의 `ApplicationListener`이고, 그 이유가 주석에 있다 — "An `ApplicationListener`, not a bean: this event fires before there is a context to hold one."
|
||||
|
||||
## 9. Negative-space probes
|
||||
|
||||
### 9.1 (8.1) 도달성 — main 참조 0인 파일의 전수 분류
|
||||
|
||||
49개 main 파일 중 다른 main 파일이 참조하지 않는 것은 열둘이고, 전부 설명된다:
|
||||
|
||||
| 분류 | 파일 | 등록 경로 |
|
||||
|---|---|---|
|
||||
| `@Configuration` 루트 | `RuntimeSafetyConfig` · `SecretSourceConfig` · `SecretSourceSettings` · `MigrationStartupConfig` · `TracingConfig` · `PseudonymizationConfig` | 컴포넌트 스캔 |
|
||||
| logback 컴포넌트 | `MetricsAsyncAppender` · `SamplingTurboFilter` · `SecretMaskingMessageConverter` · `SecretMaskingJsonGeneratorDecorator` · `StartupFailureSpringBootLogFilter` | `logback-spring.xml` |
|
||||
| `spring.factories` | `ResolvedProfileLoggingContextListener` · `TracingSamplingEnvironmentPostProcessor` | `spring.factories` |
|
||||
|
||||
**고아 없음.** 지금까지 분석한 17개 모듈 중 이 성질을 가진 것은 grpc(모듈 15)와 여기뿐이다.
|
||||
|
||||
### 9.2 (8.2) 조건 형제 비교 — 시작 검증기의 운명
|
||||
|
||||
| 모듈 | 검증기 | 배선 |
|
||||
|---|---|---|
|
||||
| app-bootstrap | 12종 | **전부** |
|
||||
| outbound fileserver ← app-bootstrap | `NginxInternalUriMapper.attestMapping()` | 예 (모듈 14 §39.1) |
|
||||
| inbound web | `WebPlatformStartupValidator` (62) | 아니오 (모듈 14 §44.2) |
|
||||
| inbound websocket | `WebSocketPlatformStartupValidator` (125) · `WebSocketStackExclusivity` (78) | 아니오 (모듈 17 §4.1) |
|
||||
| inbound graphql | `GraphQlPlatformStartupValidator` | 예 (leaf 자기 자동설정) |
|
||||
|
||||
**검증기가 도는지 여부는 그 능력에 컴포지션 루트의 자동설정이 있는지와 정확히 일치한다.**
|
||||
|
||||
### 9.3 (8.3)·(8.4) 중복·드리프트 — 없음
|
||||
|
||||
`StartupFailures`가 단일 발생원이고 종료 코드·단계·카테고리가 한 어휘에 모여 있다. 검증기 12종이 서로 다른 불변식을 보며 겹치지 않는다.
|
||||
|
||||
## 10. Findings — 없음
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 04 — `notification` + `outbox` + `idempotency` + `messaging` + `async` + `concurrency` + `lock` (59 files, main 35 + test 24)
|
||||
|
||||
> 근거 `evidence/raw/231-app-bootstrap-capability-probes.txt` (`file_count=59`)
|
||||
|
||||
## 11. 무엇을 하는 코드인가
|
||||
|
||||
능력별 조립. `notification`(10) + `notification/observation`(4)이 모듈 13에서 확인한 배선의 출처다 — `NotificationRootAutoConfiguration`(`.imports` 등록, `@ConditionalOnProperty`) · `NotificationPlatformWorkerConfig`(모듈 13 §12.1이 확인한 배경 작업자 3종 등록) · `NotificationPlatformProviderConfig` · `NotificationPlatformDispatchConfig` · `NotificationPlatformObservabilityConfig`.
|
||||
|
||||
`outbox`(6) · `idempotency`(5) · `messaging`(1, `KafkaSenderConfig` 113) · `async`(5) · `concurrency`(2) · `lock`(2)이 나머지다.
|
||||
|
||||
## 12. Negative-space probes
|
||||
|
||||
### 12.1 (8.1) 도달성
|
||||
|
||||
35개 main 파일 중 main 참조 0인 것은 전부 `@Configuration` 루트다(`IdempotencyConfig` · `PostgreSqlIdempotencyProviderConfig` · `AsyncExecutorConfig` · `DomainContextConfig` · `DistributedLockConfig` · `KafkaSenderConfig` · `NotificationRootAutoConfiguration`). 고아 없음.
|
||||
|
||||
### 12.2 (8.2) 조건 형제 비교 — 모듈 13의 미배선 항목이 여기 있는가
|
||||
|
||||
모듈 13에서 P2로 기록한 다섯 중 컴포지션 루트가 고칠 수 있는 것을 확인했다:
|
||||
|
||||
| 모듈 13 발견 | app-bootstrap에 배선 지점이 있는가 |
|
||||
|---|---|
|
||||
| §17.1 `AccessContext` 감사 미기록 | 아니오 — leaf 내부 문제(복호화 경로가 감사 포트를 부르지 않음) |
|
||||
| §25.1 Web Push SSRF 가드 미적용 | 아니오 — leaf 내부(`WebPushSubscriptionValue`의 private 사본) |
|
||||
| §29.1 FCM ambiguous 미번역 | 아니오 — leaf 내부(`FcmBatchCoordinator`) |
|
||||
| §12.1 배경 작업자 3종 | **예 — 배선됨**(`NotificationPlatformWorkerConfig:57·90·119`) |
|
||||
|
||||
즉 컴포지션 루트가 할 수 있는 부분은 이미 돼 있고, 남은 것은 leaf 내부의 회로다.
|
||||
|
||||
## 13. Findings — 없음
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 05 — `security` + `management/security` + `redis` + `mongo` + `authz` (12 files, main 7 + test 5)
|
||||
|
||||
> 근거 `evidence/raw/232-app-bootstrap-security-probes.txt` (`file_count=12`)
|
||||
|
||||
## 14. 무엇을 하는 코드인가
|
||||
|
||||
`security/AuthenticationModeCompositionConfig`(48)가 JWT/Redis-세션 두 인증 모드의 조립을 결정하고, `management/security`(1)가 관리 평면 보안을, `redis/RedisCapabilityConfig`(376 — 이 모듈에서 가장 큰 단일 파일)가 Redis 능력 전체를, `mongo/MongoPlatformHealthConfig`(37)가 Mongo 헬스를, `authz`(1)가 권한 배선을 담당한다.
|
||||
|
||||
## 15. Negative-space probes
|
||||
|
||||
### 15.1 (8.1)·(8.2) 도달성과 게이트
|
||||
|
||||
7개 main 파일 전부 `@Configuration`이고 `@ConditionalOnProperty`/`@ConditionalOnBean`으로 게이트된다. `MongoPlatformHealthConfig`가 `@ConditionalOnBean` + `@ConditionalOnMissingBean` + `@ConditionalOnProperty` 셋을 함께 쓰는데, 자동설정 안에서의 `@ConditionalOnBean`은 Boot가 평가 순서를 통제하므로 모듈 14 §7.1이 경고한 컴포넌트 스캔 상의 위험이 없다.
|
||||
|
||||
## 16. Findings — 없음
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 06 — test: 아키텍처 규칙 + 위반/허용 픽스처 (90 files)
|
||||
|
||||
> 근거 `evidence/raw/233-app-bootstrap-architecture-probes.txt` (`file_count=90`)
|
||||
|
||||
## 17. 무엇을 하는 코드인가
|
||||
|
||||
**아키텍처 규칙 14종이 여기서 프로덕션 트리에 적용된다:**
|
||||
|
||||
```
|
||||
ArchitectureViolationFixtureTest CleanArchitectureTest ContractSuiteIsolationArchTest
|
||||
DisabledAdapterArchitectureTest DomainFeatureOnboardingContractTest FileserverUseCaseContractDeviationTest
|
||||
GraphQlInboundOwnershipBoundaryTest JpaProductionArchitectureTest MongoRawAccessBoundaryTest
|
||||
NamingConventionTest NotificationArchitectureTest ProductionClassImportOption
|
||||
TestTaxonomyArchitectureTest WebProductionArchitectureTest
|
||||
```
|
||||
|
||||
`WebProductionArchitectureTest`가 모듈 14 §46에서 확인한 `WebArchitectureRules.all()` 7규칙을 프로덕션 트리에 적용하는 지점이고, `NotificationArchitectureTest`·`JpaProductionArchitectureTest`·`MongoRawAccessBoundaryTest`·`GraphQlInboundOwnershipBoundaryTest`가 각 leaf의 규칙 팩에 대해 같은 일을 한다.
|
||||
|
||||
**위반/허용 픽스처가 76개다.** `architecture/violations/**`(application 14 · domain 5 · streaming 4 · boundary 3 · adapter 5 · slice 2 · fixtureleak 4 · contractisolation 4 · shared 1 · serialization 1 …)와 `architecture/allowed/**`(streaming 2 · slice 2 · contractisolation 4 · application 2). 규칙마다 **거부되어야 할 합성 트리**와 **통과해야 할 합성 트리**를 함께 두는 형태다.
|
||||
|
||||
모듈 14 §3.4에서 확인한 `WebModuleBoundaryTest`의 부정 픽스처 넷과 같은 원칙이 저장소 규모로 적용돼 있다 — "A boundary test that has never been shown to fail is indistinguishable from one that scans the wrong directory."
|
||||
|
||||
## 18. Negative-space probes
|
||||
|
||||
### 18.1 (8.1)·(8.4) 규칙과 픽스처의 대응
|
||||
|
||||
14개 규칙 클래스에 76개 픽스처가 붙는다. `ArchitectureViolationFixtureTest`가 위반 픽스처들이 실제로 거부되는지를 확인하는 메타 테스트이고, `TestTaxonomyArchitectureTest`가 테스트 자체의 분류 규약을 강제한다.
|
||||
|
||||
`ProductionClassImportOption`이 스캔 대상을 프로덕션 클래스로 한정하는 공유 옵션 — 픽스처가 규칙에 잡히지 않도록 하는 장치다. `architecture/violations/fixtureleak`(4)는 그 장치가 새는 경우를 픽스처로 고정한다.
|
||||
|
||||
### 18.2 (8.3) 중복 메커니즘 — 규칙 팩의 위치
|
||||
|
||||
leaf가 규칙 팩을 소유하고(`WebArchitectureRules` in web testkit) 컴포지션 루트가 적용하는 형태와, 컴포지션 루트가 규칙을 직접 쓰는 형태(`CleanArchitectureTest`)가 공존한다. 전자는 leaf가 자기 규칙을 소유하고 후자는 저장소 전역 규칙이므로 역할이 다르다. 중복 아님.
|
||||
|
||||
## 19. Findings — 없음
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 07 — test: contract 레인 + integration (54 files)
|
||||
|
||||
> 근거 `evidence/raw/234-app-bootstrap-contract-probes.txt` (`file_count=54`)
|
||||
|
||||
## 20. 무엇을 하는 코드인가
|
||||
|
||||
30개 계약 테스트가 저장소 전역 불변식을 확인한다. 성격별로:
|
||||
|
||||
| 성격 | 예 |
|
||||
|---|---|
|
||||
| 레지스트리 일치 | `ErrorCodeRegistryMappingTest` · `SecretsClassificationRegistryTest` · `ContractRegistrySchemaGovernanceTest` · `RepositoryAccessCapabilityRegistryTest` · `RegistryGovernanceCatalog` |
|
||||
| 관측·로그 계약 | `StructuredLogFieldContractTest` · `MetricsAlertingContractTest` · `DistributedTracingContractTest` · `SqlLoggingForbiddenContractTest` · `PiiTokenBodyForbiddenContractTest` |
|
||||
| 보안 표면 | `ActuatorSecurityHttpTest` · `ManagementActuatorSecurityContractTest` · `ProfileSeparationContractTest` |
|
||||
| 활성화·조건부 | `OptionalAdapterConditionalExecutionContractTest` · `ConditionalTransportQualificationContractTest` · `RedisOptionalityContractTest` · `EnvProfileMatrixContractTest` |
|
||||
| 실패 분류 | `PersistenceFailureMappingContractTest` · `LockFailureClassificationContractTest` · `LockAcquisitionTimeoutClassificationContractTest` · `BackgroundJobErrorCodeContractTest` |
|
||||
| 운영 | `RunbookCoverageContractTest` · `ContainerRuntimeOomContractTest` · `SecretReloadContractTest` · `DeveloperExperienceContractTest` |
|
||||
| 자기 검사 | `ContractSuiteCompletenessTest` |
|
||||
|
||||
`ContractSuiteCompletenessTest`가 존재한다는 사실이 이 sub-scope의 성격을 말한다 — 계약 스위트 자체의 완전성을 검사하는 계약이다.
|
||||
|
||||
## 21. Negative-space probes
|
||||
|
||||
### 21.1 (8.2) 조건 형제 비교 — 세 전송의 조건부 실행 증거
|
||||
|
||||
`ConditionalTransportQualificationContractTest`(test 레인)와 `ConditionalTransportCompositionContractTest`(별도 `conditionalTransportTest` 소스셋)와 `ConditionalTransportEvidenceFunctionalTest`(`functionalTest` 소스셋) 셋이 같은 축을 다른 레인에서 본다.
|
||||
|
||||
두 번째 것의 javadoc이 §4.1c에서 인용한 자기 이력을 담는다 — 레인 분리가 만든 사각지대를 스스로 기록한 사례다.
|
||||
|
||||
### 21.2 (8.1) 도달성 — 레지스트리 계약이 실제 레지스트리 파일을 읽는가
|
||||
|
||||
`MasterSwitchRegistryContractTest`가 `docs/registries/env-keys.yaml`과 `src/app-bootstrap/src/main/resources/application.yml`을 실제로 읽어 대조한다(§2). `ErrorCodeRegistryMappingTest`·`SecretsClassificationRegistryTest`도 `docs/registries/` 아래 파일을 읽는다. 파일 기반 SSOT가 테스트로 고정돼 있다.
|
||||
|
||||
## 22. Findings — 없음
|
||||
|
||||
---
|
||||
|
||||
# Sub-scope 08 — test: onboarding 픽스처 + 잔여 + 대체 소스셋 (28 files)
|
||||
|
||||
> 근거 `evidence/raw/235-app-bootstrap-testrest-probes.txt` (`file_count=28`)
|
||||
|
||||
## 23. 무엇을 하는 코드인가
|
||||
|
||||
`dev.caskeleton.onboarding.**`(14 파일)이 **저장소 밖 패키지의 합성 feature**다 — `onboarding/domain/feature` · `application/usecase` · `application/query` · `application/port` · `adapter/inbound/web/dto` · `adapter/outbound`. `DomainFeatureOnboardingContractTest`(§17)가 이것을 대상으로 "새 feature를 추가하는 절차"가 규칙을 만족하는지 확인한다.
|
||||
|
||||
대체 소스셋 셋:
|
||||
|
||||
```
|
||||
conditionalTransportTest/ ConditionalTransportCompositionContractTest (§4.1)
|
||||
functionalTest/ BuildVerificationPurityContractTest
|
||||
ConditionalTransportEvidenceFunctionalTest
|
||||
RuntimeMembershipFunctionalTest
|
||||
StrictQualificationTestConventionFunctionalTest
|
||||
sampleOffTest/ SampleOffClasspathContractTest
|
||||
```
|
||||
|
||||
`RuntimeMembershipFunctionalTest`가 `modules.json`의 `runtime_memberships`를 실제 부트 JAR에 대해 확인하는 것으로 보이며, 그것이 §4.1의 build-only 판정을 뒷받침하는 두 번째 증거다.
|
||||
|
||||
`SampleOffClasspathContractTest`(`sampleOffTest` 소스셋)는 샘플 모듈을 제외한 클래스패스에서의 계약을 확인한다 — 스켈레톤이 샘플 없이 성립하는지의 증거다.
|
||||
|
||||
## 24. Findings — 없음
|
||||
|
||||
---
|
||||
|
||||
# 25. 모듈 종합 — `app-bootstrap`
|
||||
|
||||
## 25.1 커버리지 원장 정산
|
||||
|
||||
8개 sub-scope, **455 / 455 FULL_READ** · `STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0`.
|
||||
|
||||
## 25.2 발견 종합 — P1 0건 · P2 0건 · P3 3건 · 기록 2건
|
||||
|
||||
| 심각도 | § | 발견 |
|
||||
|---|---|---|
|
||||
| P3 | 4.1b | 출하되는 `adapter-inbound-web`의 스위치 넷이 활성화 모델(`MasterSwitch`·`env-keys.yaml`·액추에이터·삼자 일치 테스트) 밖에 있다. 다만 둘은 기본 켜짐이라 "explicit switch" 모델에 그대로 넣을 수 없고, 모듈 14 §8.1(조립 소유권)을 먼저 정해야 한다 |
|
||||
| P3 | 26.1 | `ComposeMergeCharacterizationTest`가 `docker compose` 부재는 skip하고 `jq` 부재는 실패로 처리한다 — 같은 테스트의 도구 가드가 불완전하다 |
|
||||
| P3/기록 | 7.1 | 다섯 마스터 스위치 중 `PERSISTENCE_MONGO`만 `.imports`에 게이트된 루트가 없고 스캔 제외 정규식만 있다 |
|
||||
| 기록 | 4.1c | 조건부 전송 게이트가 별도 레인에서만 돌아 빨간 채로 방치된 이력이 그 테스트 javadoc에 기록돼 있다 |
|
||||
| 기록 | 4.2 | 세 인바운드 leaf(grpc·web·websocket)의 설정이 `@ConfigurationPropertiesScan`으로 마스터 스위치 밖에서 바인딩된다 |
|
||||
|
||||
## 25.3 이 모듈의 성격 — 조립이 실제로 일어나는 곳
|
||||
|
||||
**main 157 파일에 고아가 없다.** main 참조가 0인 파일은 전부 설명된다 — `@Configuration`/`@AutoConfiguration` 루트(`.imports` 또는 컴포넌트 스캔), logback 컴포넌트(`logback-spring.xml`이 클래스 이름으로 등록), `spring.factories` 항목(`EnvironmentPostProcessor` 6 · `SpringBootExceptionReporter` · `AutoConfigurationImportFilter` · `ApplicationListener`).
|
||||
|
||||
지금까지 분석한 18개 모듈 중 이 성질을 가진 것은 grpc(모듈 15)와 여기뿐이다.
|
||||
|
||||
**시작 검증기 12종이 전부 배선돼 있다**(§8). 그리고 그 사실이 다른 모듈의 결과를 설명한다:
|
||||
|
||||
| 검증기 | 배선 | 대응 자동설정 |
|
||||
|---|---|---|
|
||||
| app-bootstrap `runtime/*` 12종 | 예 | `RuntimeSafetyConfig` 등 |
|
||||
| fileserver `attestMapping()` | 예 | `FileserverStartupConfiguration` (app-bootstrap) |
|
||||
| graphql `GraphQlPlatformStartupValidator` | 예 | leaf 자기 자동설정 |
|
||||
| web `WebPlatformStartupValidator` | **아니오** | **없음** |
|
||||
| websocket `WebSocketPlatformStartupValidator` · `WebSocketStackExclusivity` | **아니오** | **없음** |
|
||||
|
||||
**검증기가 도는지 여부가 그 능력에 자동설정 루트가 있는지와 정확히 일치한다.** 그것이 이 저장소의 조립 결함을 설명하는 단일 규칙이다.
|
||||
|
||||
## 25.4 이 모듈이 나머지 분석을 교정했다
|
||||
|
||||
app-bootstrap을 읽고 두 가지가 바뀌었다:
|
||||
|
||||
1. **`modules.json`의 `runtime_memberships`가 build-only 등급을 규정한다** — grpc·websocket은 `[]`, graphql은 `["app-bootstrap"]`, web은 `["app-bootstrap","sample-portfolio"]`. 그리고 `ConditionalTransportCompositionContractTest`가 "**nothing may put them on a runtime**"을 기계로 강제한다. 이것으로 모듈 17 §4.1을 P1 → P2로 하향했다(모듈 17 §26.6).
|
||||
2. **`MasterSwitch` 다섯의 범위가 자의적이지 않다** — 런타임에 오르는 어댑터 중 옵션인 것들이다. 처음에 "출하 스위치가 다섯보다 많다"로 기록한 것을 §4.1에서 철회했다.
|
||||
|
||||
두 교정 모두 컴포지션 루트를 읽지 않고는 도달할 수 없었다. **조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다**는 것이 이 모듈이 남긴 방법론적 결론이다.
|
||||
|
||||
## 26. 실행 검증
|
||||
|
||||
```
|
||||
$ ./gradlew :app-bootstrap:test
|
||||
BUILD FAILED in 1m 47s
|
||||
GRADLE_EXIT=1
|
||||
|
||||
test-results 집계: classes=169 tests=1016 failures=1 errors=0 skipped=4
|
||||
FAILED: ComposeMergeCharacterizationTest.every lane renders exactly the services its contract names
|
||||
```
|
||||
|
||||
### 26.1 P3 — 실패는 환경 원인이며, 그 테스트의 도구 가드가 불완전하다
|
||||
|
||||
```
|
||||
org.opentest4j.AssertionFailedError: [verify-compose-profile-contracts.sh said:
|
||||
jq is required
|
||||
]
|
||||
expected: 0 but was: 78
|
||||
at ComposeMergeCharacterizationTest.everyLaneMatchesItsContract(ComposeMergeCharacterizationTest.java:62)
|
||||
|
||||
$ which jq -> NO_JQ
|
||||
```
|
||||
|
||||
**저장소 결함이 아니다.** 분석 컨테이너에 `jq`가 없고, 위임된 스크립트가 그것을 요구하며 exit 78로 정직하게 실패한다.
|
||||
|
||||
기록하는 것은 가드의 비대칭이다:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void everyLaneMatchesItsContract() {
|
||||
Assumptions.assumeTrue(dockerComposeIsAvailable(), "docker compose is not on this machine");
|
||||
ProcessResult result = run(List.of("./scripts/verify-compose-profile-contracts.sh"));
|
||||
assertThat(result.exitCode()).isZero();
|
||||
}
|
||||
```
|
||||
|
||||
`docker compose` 부재는 **skip**으로 처리하고, 같은 스크립트가 요구하는 `jq` 부재는 **실패**로 나타난다. 도구가 없는 기계에서 이 테스트는 계약 위반처럼 읽히는 실패를 낸다 — 메시지가 "jq is required"라 원인은 드러나지만, 이미 skip을 선택한 테스트가 두 번째 도구에 대해서만 다르게 행동한다.
|
||||
|
||||
이 저장소는 반대 방향의 원칙도 갖고 있다 — `webNginxProxyTest`가 "A lane that quietly passes when the container runtime is missing is a lane that has been certifying nothing since whenever Docker last broke"로 skip을 거부한다. 두 원칙 중 어느 쪽을 택하든 **한 테스트 안에서 도구별로 갈리지는 않는 편이 낫다.**
|
||||
|
||||
**나머지 1,015개 테스트는 통과한다**(failures=1, errors=0, skipped=4).
|
||||
|
||||
### 26.2 재검증 — 그 레인 계약이 실제로 성립하는지 독립 경로로 확인했다 (2026-08-31)
|
||||
|
||||
§26.1이 남긴 실질적 공백은 "그래서 15개 레인이 계약과 맞는가"가 **확인되지 않은 채**였다는 점이다.
|
||||
실패 원인이 환경이라는 판정은 옳지만, 그 판정은 계약의 성립 여부를 말해 주지 않는다.
|
||||
|
||||
`jq`는 설치하지 않았다 — 사용자 기계의 시스템 변경이다. 대신 `verify-compose-profile-contracts.sh`의
|
||||
6개 검사를 파이썬으로 이식해 같은 `docker compose config` 렌더링 위에서 돌렸다. 아무것도 기동하지
|
||||
않는 정적 검사다(`EVD-334`).
|
||||
|
||||
```
|
||||
compose version 2.40.3+ds1-0ubuntu1~24.04.1 >= 2.24.4
|
||||
off-local: app @ local off-dev: app @ dev
|
||||
off-prod: app @ prod local-jpa: app,db @ local
|
||||
local-mongo: app,mongo,mongo-rs-init @ local
|
||||
local-messaging: app,kafka @ local local-messaging-outbox: app,db,kafka @ local
|
||||
local-notification-ingest / -serving / -handoff … @ local
|
||||
local-graphql: app,auth-smoke,graphql-smoke,keycloak @ local
|
||||
shared-infra-local / shared-infra-dev / prod-smoke …
|
||||
all-adapters: app,auth-smoke,db,db-migrate-capabilities,db-promote-capabilities,
|
||||
graphql-smoke,kafka,keycloak,mailpit,mongo,mongo-rs-init,notification-smoke @ local
|
||||
|
||||
PORT RESULT: all 15 lanes match src/config/runtime/compose-profile-contracts.json (exit 0)
|
||||
```
|
||||
|
||||
이식본이 검사한 것은 원본과 같은 여섯이다 — ①compose 버전 하한 ②레인별 **정확한** 서비스 집합
|
||||
③렌더된 `SPRING_PROFILES_ACTIVE` ④병합 모델의 마운트 대상 중복 ⑤서비스-역할 분할과 `--wait` 대상
|
||||
정합 ⑥Keycloak realm의 밑줄 키.
|
||||
|
||||
**이것이 무엇을 말하고 무엇을 말하지 않는가.** 계약 위반의 증거가 없다는 것이지, 원본 스크립트가
|
||||
통과한다는 것이 아니다. 그 구별은 테스트 자신의 주석이 이미 경계한 것이다 — "a test that re-derived
|
||||
the same checks in Java would be a second opinion that can agree with the contract while the script
|
||||
disagrees." `jq`가 있는 기계에서 원본 스크립트로 확인하는 것이 여전히 정본이며, 여기서 얻은 것은
|
||||
그 확인이 이루어질 때까지의 잠정 근거다.
|
||||
|
||||
`exit 78`은 sysexits.h의 `EX_CONFIG` — "설정이 없어 확인할 수 없음"이고, 스크립트 끝의 `exit 1`이
|
||||
"계약이 틀렸음"이다. §26.1이 기록한 가드 비대칭의 비용이 정확히 이것이다: 두 코드가 하나의 실패로
|
||||
뭉개지면서, 아무것도 검사되지 않은 상태가 계약 위반으로 보고됐다.
|
||||
|
||||
|
||||
## 27. 완료 게이트
|
||||
|
||||
- [x] denominator 455 / 455 FULL_READ
|
||||
- [x] 8개 sub-scope 각각 §8.1~§8.4 수행 — 조립 표면 전수(`.imports` 6 · `spring.factories` 4종 · 관리 컨텍스트 1) · `MasterSwitch` 다섯과 런타임 멤버십 대조 · main 참조 0 파일의 등록 경로 전수 분류 · 시작 검증기 12종 배선 확인
|
||||
- [x] evidence `227`–`235` 생성
|
||||
- [x] 실행 검증: tests=1016 failures=1(환경 원인) errors=0 skipped=4
|
||||
- [x] **분석 중 판정 철회 1건**(§4.1 — 활성화 모델의 다섯 어댑터 범위는 결함이 아님) · **타 모듈 판정 하향 1건**(모듈 17 §4.1 P1 → P2)
|
||||
- [x] 소스 미변경
|
||||
- [x] **2026-08-31 재검증** — 리프 소스 변경 0(`EVD-333`), lane 재실행 tests=1016 failures=1(동일 환경 원인),
|
||||
그리고 §26.1이 남긴 공백을 독립 경로로 메움 — 15개 레인 전부 계약 일치(§26.2, `EVD-334`)
|
||||
|
||||
## Source anchors
|
||||
|
||||
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **93개** (main 49 · test 38 · 기타 6).
|
||||
|
||||
```
|
||||
src/app-bootstrap/build.gradle
|
||||
src/config/architecture/modules.json (app-bootstrap 항목)
|
||||
|
||||
main:
|
||||
src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java
|
||||
src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationAutoConfiguration.java
|
||||
src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationEndpoint.java
|
||||
src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationReport.java
|
||||
src/main/java/dev/caskeleton/bootstrap/activation/CapabilityDependencyEnvironmentValidator.java
|
||||
src/main/java/dev/caskeleton/bootstrap/activation/CapabilityDependencyValidator.java
|
||||
src/main/java/dev/caskeleton/bootstrap/activation/MasterSwitchEnvironmentPostProcessor.java
|
||||
src/main/java/dev/caskeleton/bootstrap/activation/RuntimeEnvironmentProfileValidator.java
|
||||
src/main/java/dev/caskeleton/bootstrap/async/AsyncExecutorConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/autoconfigure/fileserver/FileserverAdminManagementContextConfiguration.java
|
||||
src/main/java/dev/caskeleton/bootstrap/autoconfigure/fileserver/FileserverPlatformAutoConfiguration.java
|
||||
src/main/java/dev/caskeleton/bootstrap/autoconfigure/fileserver/FileserverStartupConfiguration.java
|
||||
src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java
|
||||
src/main/java/dev/caskeleton/bootstrap/autoconfigure/messaging/DisabledMessagingSentinelAutoConfiguration.java
|
||||
src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/DataSourceRequirement.java
|
||||
src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/JpaOffAutoConfigurationImportFilter.java
|
||||
src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/PersistenceJpaRootAutoConfiguration.java
|
||||
src/main/java/dev/caskeleton/bootstrap/concurrency/DomainContextConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/idempotency/IdempotencyConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/idempotency/PostgreSqlIdempotencyProviderConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/lock/DistributedLockConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/logging/MetricsAsyncAppender.java
|
||||
src/main/java/dev/caskeleton/bootstrap/logging/PseudonymizationConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/logging/ResolvedProfileLoggingContextListener.java
|
||||
src/main/java/dev/caskeleton/bootstrap/logging/SamplingTurboFilter.java
|
||||
src/main/java/dev/caskeleton/bootstrap/logging/SecretMaskingJsonGeneratorDecorator.java
|
||||
src/main/java/dev/caskeleton/bootstrap/logging/SecretMaskingMessageConverter.java
|
||||
src/main/java/dev/caskeleton/bootstrap/logging/StartupFailureSpringBootLogFilter.java
|
||||
src/main/java/dev/caskeleton/bootstrap/messaging/KafkaSenderConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/mongo/MongoPlatformHealthConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/notification/NotificationPlatformDispatchConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/notification/NotificationPlatformObservabilityConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/notification/NotificationPlatformProviderConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/notification/NotificationPlatformWorkerConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/notification/NotificationRootAutoConfiguration.java
|
||||
src/main/java/dev/caskeleton/bootstrap/runtime/RuntimeSafetyConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceSettings.java
|
||||
src/main/java/dev/caskeleton/bootstrap/runtime/StartupSafetyValidator.java
|
||||
src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationFailedException.java
|
||||
src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupErrorCode.java
|
||||
src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureExceptionReporter.java
|
||||
src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailureLogState.java
|
||||
src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupFailures.java
|
||||
src/main/java/dev/caskeleton/bootstrap/runtime/startup/StartupPhase.java
|
||||
src/main/java/dev/caskeleton/bootstrap/tracing/TracingConfig.java
|
||||
src/main/java/dev/caskeleton/bootstrap/tracing/TracingSamplingEnvironmentPostProcessor.java
|
||||
src/main/resources/application.yml
|
||||
|
||||
test:
|
||||
src/test/java/dev/caskeleton/bootstrap/activation/MasterSwitchRegistryContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/architecture/DomainFeatureOnboardingContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/architecture/GraphQlInboundOwnershipBoundaryTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/architecture/JpaProductionArchitectureTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/architecture/MongoRawAccessBoundaryTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/architecture/NotificationArchitectureTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/architecture/ProductionClassImportOption.java
|
||||
src/test/java/dev/caskeleton/bootstrap/architecture/TestTaxonomyArchitectureTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/architecture/WebProductionArchitectureTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/compose/ComposeMergeCharacterizationTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/ActuatorSecurityHttpTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/BackgroundJobErrorCodeContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/ContainerRuntimeOomContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/ContractRegistrySchemaGovernanceTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/ContractSuiteCompletenessTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/DistributedTracingContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/EnvProfileMatrixContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/ErrorCodeRegistryMappingTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/LockAcquisitionTimeoutClassificationContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/LockFailureClassificationContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/ManagementActuatorSecurityContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/MetricsAlertingContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/OptionalAdapterConditionalExecutionContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/PersistenceFailureMappingContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/PiiTokenBodyForbiddenContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/RedisOptionalityContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/RegistryGovernanceCatalog.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/RepositoryAccessCapabilityRegistryTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/RunbookCoverageContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/SecretReloadContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/SecretsClassificationRegistryTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/SqlLoggingForbiddenContractTest.java
|
||||
src/test/java/dev/caskeleton/bootstrap/contract/StructuredLogFieldContractTest.java
|
||||
|
||||
기타:
|
||||
docs/registries/env-keys.yaml
|
||||
src/conditionalTransportTest/java/dev/caskeleton/bootstrap/transport/ConditionalTransportCompositionContractTest.java
|
||||
src/config/architecture/modules.json
|
||||
src/functionalTest/java/dev/caskeleton/bootstrap/contract/ConditionalTransportEvidenceFunctionalTest.java
|
||||
src/functionalTest/java/dev/caskeleton/bootstrap/contract/RuntimeMembershipFunctionalTest.java
|
||||
src/sampleOffTest/java/dev/caskeleton/bootstrap/contract/SampleOffClasspathContractTest.java
|
||||
|
||||
해석되지 않은 인용 (11종) — 외부 타입·문서상 약칭 등:
|
||||
env-keys.yaml
|
||||
modules.json
|
||||
evidence/raw/227-app-bootstrap-module-inventory.txt
|
||||
evidence/raw/228-app-bootstrap-activation-probes.txt
|
||||
evidence/raw/229-app-bootstrap-autoconfigure-probes.txt
|
||||
evidence/raw/230-app-bootstrap-runtime-probes.txt
|
||||
evidence/raw/231-app-bootstrap-capability-probes.txt
|
||||
evidence/raw/232-app-bootstrap-security-probes.txt
|
||||
evidence/raw/233-app-bootstrap-architecture-probes.txt
|
||||
evidence/raw/234-app-bootstrap-contract-probes.txt
|
||||
evidence/raw/235-app-bootstrap-testrest-probes.txt
|
||||
|
||||
```
|
||||
@@ -0,0 +1,716 @@
|
||||
# 20. gRPC platform family — 18 leaf 통합 분석
|
||||
|
||||
- **분석 대상 리비전**: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` (`feat: grpc 기능 deep 구현`, 2026-08-31)
|
||||
- **직전 기준선**: `a24ece9cf797f7ea647e33bf846b115208ed1ba5` — 모듈 01~19의 분석 리비전
|
||||
- **범위**: `src/grpc/**`(12 leaf) + `src/grpc-advanced/**`(6 leaf) — `modules.json` 등록 **18개**
|
||||
- **분모**: git 추적 파일 **383개** (main Java 260 / 18,726 LOC · test Java 68 · 나머지 build/lockfile/resource/governance)
|
||||
- **가족 로컬 권위 문서**: `src/grpc/CLAUDE.md`(117줄) · `src/grpc-advanced/CLAUDE.md`(77줄)
|
||||
- **테스트 레인**: 18개 leaf `:test` 전량 + 증거 레인 3종 — **전부 BUILD SUCCESSFUL**
|
||||
|
||||
---
|
||||
|
||||
## 0. 이 문서가 왜 20번인가 — 분석 도중 코드베이스가 이동했다
|
||||
|
||||
교차 스코프 분석을 시작하며 레지스트리를 다시 전수로 읽었을 때 **등록 모듈이 62개**인데 `state.json`이 추적하던 것은 **44개**였다. 누락 18개는 전부 gRPC 가족이다.
|
||||
|
||||
원인은 분석 누락이 아니라 **리비전 이동**이다:
|
||||
|
||||
```
|
||||
$ git log --oneline -3
|
||||
21234e38 feat: grpc 기능 deep 구현 <- 현재 HEAD (2026-08-31)
|
||||
a24ece9c feat: web, websocket 어댑터 추가 구현 <- 모듈 01~19의 분석 기준선
|
||||
01372634 refactor: 각 어댑터터별 리펙토링 진행
|
||||
|
||||
$ git diff --stat a24ece9c..HEAD
|
||||
400 files changed, 40217 insertions(+), 4 deletions(-)
|
||||
```
|
||||
|
||||
변경 경로는 `src/grpc/**` · `src/grpc-advanced/**` · `modules.json`(18개 항목 추가) · `src/build.gradle`(테스트 클래스패스 조건에 `:grpc:`·`:grpc-advanced:` 추가) · docs 15개뿐이다. **`src/messaging/` 이하는 한 줄도 바뀌지 않았고**, 모듈 01~19가 다룬 어떤 경로도 변경되지 않았다(삭제 4줄은 `src/build.gradle`의 주석 교체분이다). 따라서 앞선 19개 문서와 그 증거는 그대로 유효하다.
|
||||
|
||||
`state.json`의 `gitRevision`을 HEAD로 올리고 `reanalysis` 블록을 `ADDITIVE_SCOPE`로 채웠으며, 18개 스코프를 추가해 이 문서로 닫는다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 분모와 커버리지 원장
|
||||
|
||||
### 1.1 등록 leaf 18개
|
||||
|
||||
| # | leaf | 파일 | main | test | main LOC | 허용 의존 | runtime_memberships |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | `grpc-policy` | 82 | 62 | 18 | 4,781 | 1 | `[]` |
|
||||
| 2 | `grpc-core-api` | 41 | 32 | 7 | 1,897 | **0** | `[]` |
|
||||
| 3 | `grpc-testkit` | 36 | 26 | 8 | 2,313 | 10 | `[]` |
|
||||
| 4 | `grpc-advanced-compat` | 25 | 17 | 5 | 962 | 5 | `[]` |
|
||||
| 5 | `grpc-server` | 24 | 17 | 5 | 1,106 | 2 | `[]` |
|
||||
| 6 | `grpc-advanced-resilience` | 23 | 16 | 4 | 940 | 5 | `[]` |
|
||||
| 7 | `grpc-advanced-streaming` | 20 | 14 | 4 | 833 | 3 | `[]` |
|
||||
| 8 | `grpc-admin` | 19 | 13 | 4 | 913 | 2 | `[]` |
|
||||
| 9 | `grpc-client` | 19 | 13 | 4 | 931 | 2 | `[]` |
|
||||
| 10 | `grpc-codegen` | 16 | 10 | 4 | 725 | 2 | `[]` |
|
||||
| 11 | `grpc-advanced-bootstrap` | 13 | 9 | 2 | 610 | 1 | `[]` |
|
||||
| 12 | `grpc-advanced-edition` | 11 | 6 | 2 | 326 | 3 | `[]` |
|
||||
| 13 | `grpc-discovery` | 11 | 7 | 2 | 409 | 2 | `[]` |
|
||||
| 14 | `grpc-proto-contract` | 11 | 3 | 1 | 605 | 1 | `[]` |
|
||||
| 15 | `grpc-advanced-diagnostics` | 8 | 4 | 1 | 277 | 3 | `[]` |
|
||||
| 16 | `grpc-spring-boot-starter` | 8 | 4 | 1 | 468 | 10 | `[]` |
|
||||
| 17 | `grpc-observability` | 7 | 4 | 1 | 354 | 1 | `[]` |
|
||||
| 18 | `grpc-operation-ledger-jpa` | 7 | 3 | 1 | 276 | 1 | `[]` |
|
||||
| | **합계** | **381** | **260** | **74** | **18,726** | | |
|
||||
|
||||
분모 383 = leaf 381 + 가족 공통 문서 2개(`src/grpc/CLAUDE.md`, `src/grpc-advanced/CLAUDE.md`). 미배정 0.
|
||||
|
||||
**18개 전부 `runtime_memberships: []` — 가족 전체가 build-only다.** 이것이 이 문서의 심각도 축이다(모듈 17 §26.6·모듈 19 §1.1의 원칙 적용). 어떤 배포 아티팩트도 이 코드를 싣고 있지 않으므로, 조립 결함은 **오늘의 사고가 아니라 채택 시점의 부채**로 기록한다.
|
||||
|
||||
### 1.2 sub-scope 분할
|
||||
|
||||
| # | sub-scope | leaf | 파일 |
|
||||
|---|---|---|---|
|
||||
| 01 | core contracts | `core-api` · `proto-contract` · `codegen` | 68 |
|
||||
| 02 | policy | `policy` | 82 |
|
||||
| 03 | server · client · discovery | `server` · `client` · `discovery` | 54 |
|
||||
| 04 | admin · observability · ledger · 조립 경계 | `admin` · `observability` · `operation-ledger-jpa` · `spring-boot-starter` | 41 |
|
||||
| 05 | testkit · 증거 등급 · 릴리스 게이트 | `testkit` + `src/grpc/CLAUDE.md` | 37 |
|
||||
| 06 | advanced | 6 leaf + `src/grpc-advanced/CLAUDE.md` | 101 |
|
||||
| | **합계** | **18 leaf** | **383** |
|
||||
|
||||
---
|
||||
|
||||
## 2. 이 가족이 공개한 주장과 검증 결과
|
||||
|
||||
`src/grpc/CLAUDE.md`는 기계로 검사 가능한 주장을 여러 개 한다. 모듈 19에서와 같이 **검증을 먼저** 했다.
|
||||
|
||||
### 2.1 "`grpc-core-api`는 io.grpc를 이름조차 부르지 않는다" → **성립**
|
||||
|
||||
```
|
||||
grep -rn "io\.grpc" grpc-core-api/src/main → 3
|
||||
grep -rn "org\.springframework" grpc-core-api/src/main → 0
|
||||
grep -rn "com\.google\.protobuf" grpc-core-api/src/main → 0
|
||||
grep -rn "jakarta\.persistence" grpc-core-api/src/main → 0
|
||||
(test 소스는 네 패턴 모두 0)
|
||||
```
|
||||
|
||||
3건은 전부 **javadoc 산문**이고, 그 내용이 왜 타입을 쓰지 않는지를 설명한다:
|
||||
|
||||
> "The canonical gRPC status codes, **mirrored so that `grpc-core-api` stays free of io.grpc**. ... a failure context that names `io.grpc.Status` would put the transport inside the contract that exists to describe what the transport did."
|
||||
|
||||
그리고 결정적으로 `grpc-core-api/build.gradle`이 이렇다:
|
||||
|
||||
```groovy
|
||||
apply plugin: 'java-library'
|
||||
dependencies {
|
||||
}
|
||||
```
|
||||
|
||||
**의존성 블록이 비어 있다.** io.grpc가 컴파일 클래스패스에 아예 없으므로 이 제약은 문서가 아니라 빌드가 강제한다. 32개 main 파일 1,897 LOC가 Java stdlib만으로 서 있다.
|
||||
|
||||
### 2.2 "Stable leaf는 `:grpc-advanced:*`를 참조하지 않는다" → **성립**
|
||||
|
||||
- 레지스트리: 비-advanced leaf의 `allowed_dependencies`에 advanced id가 등장하는 경우 **0건**.
|
||||
- `grpc-spring-boot-starter`의 `allowed_dependencies` = Stable 10개 leaf뿐.
|
||||
- 소스: `src/grpc` 전체에서 `dev.caskeleton.grpc.advanced` 참조 **0건**.
|
||||
- 문서가 말하는 이중 강제: `verifyCleanArchitectureDependencies`(build time) + `GrpcStableBuildInvariant`·`GrpcAdvancedModuleGuard.requireStableStarterIsClean`(runtime).
|
||||
|
||||
**단, runtime 절반은 실행되지 않는다.** `GrpcStableBuildInvariant`를 호출하는 프로덕션 경로는
|
||||
`GrpcPlatformStartupValidator.validateAdvancedIsolation` 하나이고, 그 validator 자체가 조립에서
|
||||
호출되지 않는다(§3.1). 따라서 오늘 살아 있는 강제는 **build time 한 층**이다. 레지스트리 검사가
|
||||
실효적이므로 규칙 자체는 성립하지만(위 세 확인), "runtime에도 같은 규칙을 강제한다"는 서술은
|
||||
현재 상태를 서술하지 않는다.
|
||||
|
||||
`grpc-advanced`의 CLAUDE.md가 별도 디렉터리·별도 Gradle prefix를 쓴 이유를 명시한다 — "그 불변 조건을 registry의 `allowed_dependencies`만으로 **기계 검증할 수 있게** 하기 위해서."
|
||||
|
||||
### 2.3 "모든 grpc leaf의 runtime_memberships가 비어 있다" → **성립**
|
||||
|
||||
18개 전부 `[]`. 그리고 `adapter-inbound-grpc`(모듈 15에서 분석한 leaf)의 `allowed_dependencies`는
|
||||
|
||||
```
|
||||
["domain-core", "application-core", "shared-contract"]
|
||||
```
|
||||
|
||||
— 이 가족을 **볼 수 없다.** CLAUDE.md가 "현재 `adapter:inbound:grpc`는 이 family에 의존하지 않는다 — registry의 `allowed_dependencies`를 보라"고 적은 그대로다.
|
||||
|
||||
이것이 messaging과의 결정적 차이다. messaging은 `messaging-spring-boot-starter`가 `app-bootstrap` 의존으로 들어가면서 18개 leaf가 출하 아티팩트에 실렸고, 그 결과 §MSG-015(서로 모르는 두 스택)가 실재 문제가 됐다. gRPC 가족은 **아직 그 선을 넘지 않았고, 넘지 않았다는 사실을 문서가 정확히 말한다.**
|
||||
|
||||
### 2.4 "`GrpcEvidenceGrade`가 in-process 결과로 TLS를 주장하는 것을 거부한다" → **성립**
|
||||
|
||||
```java
|
||||
public void requireCertifies(String capability) {
|
||||
Set<String> certified = certifies();
|
||||
if (!certified.contains(capability)) {
|
||||
throw new IllegalStateException(
|
||||
this + " evidence does not certify '" + capability + "'; it establishes " + ...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`CONTRACT`가 증명하는 것은 `service-adapter`·`interceptor-order`·`status-mapping`·`validation`·`idempotency-replay`·`context-propagation` 여섯이고, `tls`·`mutual-tls`·`http2`·`goaway`·`keepalive` 등은 `TRANSPORT`에만 있다. 등급별 집합을 **필드가 아니라 `switch`로 계산**하는 이유까지 적혀 있다 — "an enum with a collection field is a mutable enum as far as any static analysis can tell."
|
||||
|
||||
### 2.5 "performance lane은 기본 `test`에서 제외된다" → **성립**
|
||||
|
||||
```groovy
|
||||
tasks.named('test') { useJUnitPlatform { excludeTags 'grpc-performance' } }
|
||||
```
|
||||
|
||||
근거도 적혀 있다 — "a measurement in the release gate is a flaky test on a shared CI runner; it runs when somebody asks for it, by name."
|
||||
|
||||
### 2.6 지원 매트릭스가 자기 상태를 정확히 말한다 → **성립** (모듈 19와 정반대)
|
||||
|
||||
`docs/compatibility/grpc-support-matrix.md`:
|
||||
|
||||
> "**Not released.** Every `:grpc:*` leaf is `runtime_memberships: []` in the module registry, so the platform is **build-only**: it compiles, its lanes run, and no deployed artifact carries it."
|
||||
|
||||
그리고 미해결 릴리스 게이트 입력 두 개(성능 baseline 부재, protoc 미실행에 따른 스키마 codegen)를 스스로 나열한다.
|
||||
|
||||
**모듈 19 §6.4는 정확히 이 문장의 반대 사례였다** — messaging의 지원 매트릭스는 "모든 leaf가 build-only"라고 적었지만 실제로는 18/25가 출하 중이었고, 가족 CLAUDE.md는 이미 그 문장이 틀렸다고 기록해 두었는데도 운영 문서는 고쳐지지 않았다. gRPC 쪽은 같은 문장이 **사실이다.**
|
||||
|
||||
`GrpcCompatibilityMatrix.caSkeleton()`의 7개 레인·등급도 문서 표와 **전수 일치**한다(certified 3 / compatibility 2 / watch 2).
|
||||
|
||||
**판정:** 이 가족의 공개된 주장 6건은 전부 성립한다. 모듈 19에 이어 두 번째 사례이고, `grpc-core-api`의 빈 `dependencies {}`는 이 저장소에서 본 가장 강한 형태의 자기 제약이다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 발견
|
||||
|
||||
### 3.1 P2 — `GrpcPlatformStartupValidator`가 조립에서 호출되지 않는다
|
||||
|
||||
`src/grpc/CLAUDE.md`의 Stable 범위 절:
|
||||
|
||||
> "`GrpcPlatformStartupValidator`가 Stable catalog에 streaming method가 등록되면 **startup을 거부한다.**"
|
||||
|
||||
이 가족의 유일한 조립 지점은 `GrpcPlatformAutoConfiguration`이고(`.imports` 1줄), 그 클래스는 106줄에 `@Bean` **9개**를 등록한다:
|
||||
|
||||
```
|
||||
GrpcExecutorProfile · GrpcServerProfile · GrpcAdmissionController · GrpcServiceHealthRegistry ·
|
||||
GrpcReflectionPolicy · GrpcAdminExposurePolicy · GrpcDrainPolicy · GrpcContextBinder · GrpcErrorMapper
|
||||
```
|
||||
|
||||
`GrpcPlatformStartupValidator`(188줄)는 그 목록에 없다. 전수 참조:
|
||||
|
||||
```
|
||||
grpc-spring-boot-starter/src/test/.../GrpcPlatformStartupValidatorTest.java (12개 호출)
|
||||
grpc-spring-boot-starter/src/main/.../GrpcPlatformStartupValidator.java (선언 자신)
|
||||
```
|
||||
|
||||
**main 참조 0.** 클래스는 `final` + `private` 생성자 + static 메서드(`violations(...)`, `requireValid(...)`)이므로 bean이 될 수도 없다 — 누군가 `requireValid`를 호출해야 하고, 호출하는 곳이 없다.
|
||||
|
||||
**실행되지 않는 규칙이 13개다.** validator 본문을 읽어 전수 확인했다:
|
||||
|
||||
| 그룹 | 규칙 | 거부 사유 |
|
||||
|---|---|---|
|
||||
| transport·security | 4 | production 트래픽을 받을 수 없는 transport / 배포 환경에서 TLS 비활성 / 배포 환경에서 `trustAllCertificates` / 배포 환경에서 reflection `ENABLED` |
|
||||
| executor | 2 | queue capacity < 1 / pool size < 1 |
|
||||
| methods | 4 | UNARY인데 deadline이 0 / `explicitRetry`가 idempotency 프로파일이 허용하지 않는 조합 / `IDEMPOTENCY_KEY_REQUIRED`인데 operation ledger 비활성 / **`rpcType`이 Stable이 아님** |
|
||||
| channels | 2 | 지원되지 않는 채널 프로파일 / in-process 재시도 소유자가 둘 이상 |
|
||||
| advanced isolation | 1 | Stable starter가 advanced 모듈을 해석함 |
|
||||
|
||||
클래스 javadoc이 13개를 고른 기준을 적는다:
|
||||
|
||||
> "Every rule here is a mistake **whose runtime symptom is either silence or a misattributed failure**: a unary method with no deadline hangs until the client's, an unbounded executor turns overload into unbounded latency, **trust-all in production reports TLS while providing none**, reflection in production publishes the schema, and a keyed method without a ledger accepts idempotency keys it cannot honour. **None of them fails a smoke test.**"
|
||||
|
||||
그리고 "Fails once with every violation, so a deployment learns the whole list in one restart" — 한 번에 전부 보고하도록 설계돼 있다.
|
||||
|
||||
CLAUDE.md가 인용한 "streaming method가 Stable catalog에 등록되면 거부"는 methods 그룹의 네 번째 규칙(`!policy.rpcType().stable()`)이고, §2.2의 runtime 강제는 advanced isolation 그룹의 유일한 규칙이다. **둘 다 실행되지 않는다.**
|
||||
|
||||
이 형태는 이 저장소에서 네 번째다 — 모듈 14 §44.2(`WebPlatformStartupValidator`), 모듈 17 §4.1(`WebSocketPlatformStartupValidator`), 모듈 19 §3.5(`KafkaTransactionProfileValidator`), 그리고 여기. 그리고 모듈 18에서 확립한 규칙이 다시 성립한다 — **시작 검증기가 도는지 여부는 그 능력에 자동설정 루트가 있는지와 일치한다**. 여기서는 루트가 **있는데도** 검증기를 부르지 않는 첫 사례다.
|
||||
|
||||
**채택 시점 실패 시나리오.** 팀이 `runtime_memberships`에 런타임을 추가하고 `ca-skeleton.grpc.platform.enabled=true`로 켠다. Stable catalog에 client-streaming 메서드를 하나 등록한다(Stable 범위 밖이라는 것을 모른 채). 부팅은 성공한다. 그 메서드는 Stable이 보장하지 않는 경로로 실행되고, `grpc-advanced-streaming`의 세션·중복제거·체크포인트 기계는 조립돼 있지 않다. 거부했어야 할 검증기는 존재하고, 테스트도 12개 통과하며, 호출되지 않는다.
|
||||
|
||||
증거: `268-grpc-assembly-and-release-gate.txt`
|
||||
|
||||
### 3.2 P2 — 릴리스 게이트가 스스로 증거를 읽지 않는다. messaging이 이미 고친 모양을 되풀이한다
|
||||
|
||||
`docs/compatibility/grpc-support-matrix.md`:
|
||||
|
||||
> "`GrpcCompatibilityMatrix.caSkeleton()`은 이 표의 machine-readable form이고, **`GrpcStableReleaseGate`가 certified lane에 결과가 없거나 실패하면 릴리스를 막는다.**"
|
||||
|
||||
게이트 자체의 설계는 훌륭하다 — `missingResults` + `missingGrades` + 스키마 판정 + 런북/ADR/지원매트릭스 존재를 합쳐 blocker 목록을 만들고, 문서 부재를 후속 과제가 아니라 **차단 사유**로 둔 근거까지 적는다:
|
||||
|
||||
> "Documents are a blocker rather than a follow-up. ... shipping the behaviour and writing the runbook afterwards means the first person to meet it is the one who has to work it out at three in the morning."
|
||||
|
||||
**그런데 게이트가 읽는 증거를 아무도 생산하지 않는다.**
|
||||
|
||||
```
|
||||
new GrpcReleaseEvidence(...) 생성 지점:
|
||||
grpc-testkit/src/test/.../GrpcStableReleaseGateTest.java:28, 84, 103, 127 <- 전부 테스트
|
||||
|
||||
GrpcStableReleaseGate / GrpcCompatibilityMatrix 참조 파일:
|
||||
grpc-testkit/src/test/.../GrpcStableReleaseGateTest.java
|
||||
grpc-testkit/src/main/.../GrpcStableReleaseGate.java
|
||||
grpc-testkit/src/main/.../GrpcCompatibilityMatrix.java
|
||||
```
|
||||
|
||||
`GrpcReleaseEvidence`는 record이고 그 다섯 성분 — `gradesRun`, `certifiedCapabilities`, `runbookPresent`, `architectureDecisionRecordsPresent`, `supportMatrixPresent` — 이 **전부 호출자가 넘기는 값**이다. `runbookPresent`는 파일 시스템을 보지 않고, `gradesRun`은 레인 출력에서 파생되지 않는다. `evaluate(...)`에 넘기는 `laneResults`도 `Map<String, Boolean>`으로 호출자가 만든다.
|
||||
|
||||
**이것이 messaging 가족이 이미 고친 모양이다.** 모듈 19 §6.6이 인용한 `CompatibilityMatrix.Entry` javadoc:
|
||||
|
||||
> "Read from the evidence rather than declared. **As a field it was a boolean an author set next to the tier**, and RabbitMQ carried `true` while no fault scenario had ever been executed against it."
|
||||
|
||||
messaging은 그것을 세 층으로 닫았다 — (a) 레인이 `broker-certification-evidence.jsonl`을 **쓰고**, (b) `verifyMessagingCertificationEvidence` Gradle 태스크가 실행 산출물과 커밋본을 양방향 대조하며, (c) `messaging-certification.yml`이 `src/messaging/**` PR마다 그 게이트를 돌린다.
|
||||
|
||||
gRPC 가족에는 (a)·(b)·(c) 어느 것도 없다:
|
||||
|
||||
```
|
||||
src/grpc*/*/build.gradle 의 tasks.register → 0건
|
||||
.github/workflows 28개 중 grpc를 언급하는 것 → 0건
|
||||
```
|
||||
|
||||
즉 `GrpcStableReleaseGate`는 **자기 단위 테스트가 유일한 실행 경로인 클래스**다. 지원 매트릭스의 "릴리스를 막는다"는 현재 시제 문장이 그 상태를 서술하지 않는다.
|
||||
|
||||
이 가족은 messaging의 MSG-015를 반복하지 않는 것을 목표로 삼았고(§2.3에서 확인했듯 그 목표는 달성했다), **다른 교훈 하나를 옮겨 오지 않았다.**
|
||||
|
||||
### 3.3 P2 — 증거 등급 모델 전체가 자동 실행 경로 밖에 있고, CLAUDE.md는 현재 시제로 서술한다
|
||||
|
||||
`src/grpc/CLAUDE.md`:
|
||||
|
||||
> "현재 in-process·Netty·fault lane은 **실제로 실행되어 통과하지만**, 실제 배포 환경에서의 soak·performance baseline은 없다."
|
||||
|
||||
앞 절반은 **사실이다.** 직접 돌려 확인했다:
|
||||
|
||||
```
|
||||
./gradlew :grpc:grpc-testkit:grpcInProcessContractTest \
|
||||
:grpc:grpc-testkit:grpcNettyContractTest \
|
||||
:grpc:grpc-testkit:grpcFaultTest
|
||||
→ BUILD SUCCESSFUL, GRADLE_EXIT=0
|
||||
|
||||
grpcInProcessContractTest classes=1 tests=7 failures=0 skipped=0
|
||||
grpcNettyContractTest classes=1 tests=9 failures=0 skipped=0
|
||||
grpcFaultTest classes=1 tests=9 failures=0 skipped=0
|
||||
```
|
||||
|
||||
**문제는 "실행되어"의 주어다.** `ca.strict-test-lane.gradle`은 레인을 `verification` 그룹의 `Test` 태스크로 등록만 하고 `check`에 연결하지 않는다:
|
||||
|
||||
```
|
||||
tasks.register(lane.name, Test) { group = 'verification'; ... }
|
||||
(check dependsOn 관련 라인 → 0건)
|
||||
```
|
||||
|
||||
그리고 CI에서 grpc를 이름으로 부르는 워크플로가 없다. `ci-quality-gates.yml`이 `./gradlew check`를 돌리므로 각 leaf의 **기본 `test`**는 CI에서 실행된다(classes=71 tests=579 failures=0 skipped=0으로 통과 확인). 그러나 **네 증거 레인은 `check`에 없고 어떤 워크플로도 이름으로 부르지 않는다.**
|
||||
|
||||
결과적으로 이 플랫폼의 CONTRACT/TRANSPORT/FAULT 등급을 뒷받침하는 것은 **25개 테스트**(7+9+9)이고, 그 25개는 누군가 명령을 직접 입력할 때만 돈다.
|
||||
|
||||
모듈 18 §4.1c가 `ConditionalTransportCompositionContractTest`의 javadoc에서 인용한 문장이 그대로 적용된다:
|
||||
|
||||
> "**A gate that is red in a lane nobody runs locally is a gate that reports whatever the last person to run it saw.**"
|
||||
|
||||
차이는 이쪽 레인이 **오늘 초록**이라는 것이고, 그것을 확인한 방법이 내가 직접 돌린 것이라는 점이다. 자동화된 관찰자는 없다.
|
||||
|
||||
*(비교: messaging의 인증 레인도 `test`에서 제외되지만, 전용 CI 워크플로가 게이트를 돌리고 게이트가 레인에 의존한다. gRPC 쪽은 제외만 있고 대체 경로가 없다.)*
|
||||
|
||||
### 3.4 P2 — 조립 경계가 정책 객체 9개를 만들고 서버를 만들지 않는다
|
||||
|
||||
`GrpcPlatformAutoConfiguration`이 등록하는 9개는 전부 **프로파일·정책·레지스트리**다. 서버도, 인터셉터 체인도, 서비스 어댑터 등록도 없다. 그리고 그것을 담당하는 타입들이 main 참조 0이다:
|
||||
|
||||
| 타입 | leaf | 역할 (javadoc) | main 참조 | test 참조 |
|
||||
|---|---|---|---|---|
|
||||
| `GrpcServerInterceptorChain` | server | "Builds the server interceptor chain in the Stable order and hands it over in the order gRPC actually wants" | **0** | 1 |
|
||||
| `ProtovalidateGrpcInterceptor` | policy | 요청 검증 인터셉터 | **0** | 1 |
|
||||
| `GrpcIdempotencyInterceptor` | policy | 멱등성 인터셉터 | **0** | 1 |
|
||||
| `GrpcServiceAdapter` | server | typed service adapter SPI | **0** | 1 |
|
||||
| `GrpcRetryCoordinator` · `GrpcRetryOwnershipValidator` | policy | 재시도 소유권 | **0** | 1 |
|
||||
| `GrpcStreamAdmission` · `GrpcSerializedStreamWriter` · `GrpcStreamGapDetector` · `GrpcStreamLifecycleCoordinator` | policy | server streaming 단일 writer·갭 탐지 | **0** | 1 |
|
||||
| `GrpcDrainCoordinator` · `GrpcPlatformSnapshotService` | admin | drain·정책 스냅샷 | **0** | 1 |
|
||||
| `GrpcTypedStubFactory` · `GrpcClientCallContext` | client | typed stub·호출 컨텍스트 | **0** | 1 |
|
||||
|
||||
`GrpcServerInterceptorChain`의 javadoc이 자기 존재 이유를 이렇게 적는다:
|
||||
|
||||
> "That reversal is the reason this class exists rather than a list literal at the call site. `ServerInterceptors.intercept` wraps each interceptor around the previous one, so the last one passed is the outermost at runtime — the opposite of how the order reads. **Every codebase that builds this list by hand gets it backwards at least once, and the symptom is an exception boundary that catches nothing.**"
|
||||
|
||||
그 클래스를 조립에서 쓰는 곳이 없으므로, 채택자가 인터셉터 목록을 직접 만들면 그 javadoc이 서술한 실수를 그대로 하게 된다.
|
||||
|
||||
**전체로 보면 260개 main 타입 중 73개가 main 참조 0이다.** 다만 이 숫자는 그대로 결함 수가 아니다 — build-only 라이브러리 가족에서 **공개 API 표면**(채택자가 부르는 타입)이 내부 참조를 갖지 않는 것은 정상이다. 위 표는 그중 **가족 내부의 다른 코드가 불러야 하는 조립·기계 타입**만 골라낸 것이다.
|
||||
|
||||
### 3.5 P3 — 저장소 어디에도 참조가 없는 타입 3개
|
||||
|
||||
`main = 0`이면서 `test = 0`인 것, 즉 선언 파일 외에 아무 곳에서도 이름이 등장하지 않는 타입:
|
||||
|
||||
| 타입 | leaf | javadoc이 말하는 용도 |
|
||||
|---|---|---|
|
||||
| `ReactiveGrpcClient` | advanced-compat | "Exposes a unary call as a `Mono` and a server stream as a `Flux`" |
|
||||
| `ReactiveGrpcServerAdapter<C,R>` | advanced-compat | "Runs a reactive use case behind a gRPC service adapter" |
|
||||
| `GrpcDeadlineExceededException` | **core-api** | "Its own type rather than a generic platform exception **because callers branch on it**" |
|
||||
|
||||
앞의 둘은 advanced 가족의 Reactor 표면이고 채택자가 부를 타입이므로 참조 0이 설계와 모순되지는 않는다 — 다만 **테스트도 0**이라 다른 advanced 타입들과 다르다(나머지 advanced 미참조 타입은 전부 `test=1`).
|
||||
|
||||
세 번째가 더 구체적이다. `GrpcDeadlineExceededException`은 Stable core-api에 있고, javadoc이 "callers branch on it"이라고 단정하는데 **던지는 코드도 잡는 코드도 테스트도 없다.** `requiresReconciliation()`이 "status code가 답할 수 없는 질문에 답한다"고 적혀 있고, 그 메서드를 부르는 곳이 없다.
|
||||
|
||||
### 3.6 P3/기록 — 가족 문서의 `grpc-discovery` 행이 UDS를 빠뜨린다
|
||||
|
||||
`src/grpc/CLAUDE.md`의 family 표:
|
||||
|
||||
> `grpc-discovery` | Static/DNS resolver, pick_first/round_robin, Kubernetes routing profile
|
||||
|
||||
코드와 지원 매트릭스는 셋을 말한다:
|
||||
|
||||
```java
|
||||
// GrpcResolverType
|
||||
/** A Unix domain socket. One endpoint by construction. */
|
||||
UNIX("unix", false);
|
||||
|
||||
// GrpcDiscoveryPolicyValidator:65
|
||||
"'; Stable schemes are dns, static and unix"
|
||||
```
|
||||
|
||||
```
|
||||
docs/compatibility/grpc-support-matrix.md
|
||||
| Stable resolvers | Static, DNS, Unix domain socket |
|
||||
```
|
||||
|
||||
CLAUDE.md 쪽이 덜 완전하다. 드리프트 방향이 **과소 진술**이므로(있는 능력을 빠뜨림) 위험은 낮다 — 모듈 19 §6.3의 P1은 반대 방향(없는 능력을 있다고 적음)이었다. 기록으로 남긴다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 네 가지 필수 negative-space 탐침
|
||||
|
||||
### 4.1 §8.1 도달성
|
||||
|
||||
§3.1·§3.4·§3.5가 결과다. 요약: main 참조 0인 260개 중 73개, 그중 **가족 내부가 불러야 할 조립·검증 타입 15종**이 §3.4 표와 §3.1이다. build-only 등급이 전부를 채택 시점 부채로 만든다.
|
||||
|
||||
### 4.2 §8.2 조건부 형제 비교
|
||||
|
||||
| 형제 쌍 | 차이 | 판정 |
|
||||
|---|---|---|
|
||||
| `GrpcPlatformAutoConfiguration`(9 bean 등록) vs `GrpcPlatformStartupValidator`(호출 0) | 같은 leaf, 같은 패키지, 4개 파일 중 하나만 조립에 연결 | **P2** §3.1 |
|
||||
| messaging 인증 게이트(레인→manifest→Gradle→CI 4층) vs grpc 릴리스 게이트(테스트 1층) | 같은 설계, 강제 층이 다름 | **P2** §3.2 |
|
||||
| messaging `messagingCertificationTest`(전용 CI 워크플로) vs grpc 4개 레인(`check` 밖, CI 0건) | 둘 다 `test`에서 제외, 대체 경로는 한쪽만 | **P2** §3.3 |
|
||||
| advanced 미참조 타입 대부분(`test=1`) vs Reactor 2종(`test=0`) | 같은 leaf 안의 검증 비대칭 | P3 §3.5 |
|
||||
| `grpc-core-api` 빈 `dependencies{}` vs `messaging-core-api`(deps 0이지만 build.gradle에 명시 없음) | 둘 다 framework-free, grpc 쪽이 더 강한 형태 | 결함 아님 §2.1 |
|
||||
|
||||
### 4.3 §8.3 중복 장치 쓸기
|
||||
|
||||
이 가족에서는 **중복 장치가 발견되지 않았다.** 확인한 축:
|
||||
|
||||
- 상태 코드 번역: `GrpcStatusCode`(core-api, 미러) ↔ `GrpcStatusMapping`(policy, 양방향 번역) — CLAUDE.md가 "양방향 번역은 `grpc-policy`의 `GrpcStatusMapping`이 **단독으로 소유**한다"고 선언하고, 실제로 core-api에는 번역 코드가 없다.
|
||||
- 시작 검증: Stable 쪽 `GrpcPlatformStartupValidator` 1개, advanced 쪽 `GrpcAdvancedModuleGuard`·`GrpcXdsStartupGuard`·`GrpcServletStartupValidator`가 각 capability를 나눠 담당 — 겹치지 않는다.
|
||||
- 증거 등급: `GrpcEvidenceGrade` 하나가 등급을 소유하고 `GrpcReleaseEvidence.supports`가 그것을 재사용한다 — 두 번째 등급 어휘가 없다.
|
||||
|
||||
모듈 19에서 4건(접근 검사·자격 증명 회전·Kafka producer·인증 증거 검증)이 나온 것과 대비된다.
|
||||
|
||||
### 4.4 §8.4 문서·카운트 드리프트
|
||||
|
||||
| # | 주장 | 실제 | 판정 |
|
||||
|---|---|---|---|
|
||||
| 1 | 지원 매트릭스: "Not released … build-only" | 18 leaf 전부 `rt=[]` — **사실** | 결함 아님 §2.6 |
|
||||
| 2 | 지원 매트릭스: 7개 레인·등급표 | `GrpcCompatibilityMatrix.caSkeleton()`과 전수 일치 | 결함 아님 §2.6 |
|
||||
| 3 | 지원 매트릭스: Spring Boot 4.0.8 / Stable resolvers Static·DNS·UDS | `src/build.gradle:13` = 4.0.8, `GrpcResolverType`에 UNIX 존재 — 일치 | 결함 아님 |
|
||||
| 4 | 지원 매트릭스: "`GrpcStableReleaseGate`가 릴리스를 막는다" | 게이트를 호출하는 build·CI 경로 0 | **P2** §3.2 |
|
||||
| 5 | CLAUDE.md: "`GrpcPlatformStartupValidator`가 startup을 거부한다" | main 참조 0 | **P2** §3.1 |
|
||||
| 6 | CLAUDE.md: "in-process·Netty·fault lane은 실제로 실행되어 통과한다" | 돌리면 통과(검증함). 자동으로 도는 경로는 없음 | **P2** §3.3 |
|
||||
| 7 | CLAUDE.md: `grpc-discovery` = Static/DNS | 코드·지원매트릭스는 Static/DNS/UDS | P3 §3.6 |
|
||||
| 8 | `state.json` 44 스코프 vs 레지스트리 62 모듈 | 리비전 이동(`a24ece9c` → `21234e38`)이 원인. 이 문서로 해소 | 기록 §0 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 발견 종합 — P1 0건 · P2 10건 · P3 3건
|
||||
|
||||
| 심각도 | § | 발견 | 위치 |
|
||||
|---|---|---|---|
|
||||
| **P2** | 3.1 | `GrpcPlatformStartupValidator`가 유일한 조립 지점에서 호출되지 않는다 (main 참조 0) | `GrpcPlatformAutoConfiguration` |
|
||||
| **P2** | 3.2 | 릴리스 게이트가 읽는 증거를 아무도 생산하지 않는다 — Gradle 태스크 0, CI 워크플로 0. messaging이 이미 닫은 모양의 재발 | `GrpcStableReleaseGate` · `GrpcReleaseEvidence` |
|
||||
| **P2** | 3.3 | 증거 등급 모델(25개 테스트)이 `check` 밖·CI 밖이고, 문서는 현재 시제로 서술한다 | `ca.strict-test-lane.gradle` · `grpc-testkit/build.gradle` |
|
||||
| **P2** | 3.4 | 조립 경계가 정책 객체 9개만 만들고, 인터셉터 체인·서비스 어댑터·스트리밍 기계가 전부 미조립 | `GrpcPlatformAutoConfiguration` |
|
||||
| **P2** | 7.1 | **`GrpcAdmissionController.tryAdmit()` check-then-act** — 조립되는 9개 bean 중 하나이고, 부하 아래에서 지키라고 만든 동시성 경계가 부하 아래에서 샌다. `release()`는 카운터를 음수로 만들 수 있다 | `grpc-server` |
|
||||
| **P2** | 7.2 | `GrpcStreamAdmission`도 같은 TOCTOU. 추가로 `perCaller` 맵이 caller fingerprint마다 자라고 제거되지 않는다 | `grpc-policy` |
|
||||
| **P2** | 7.3 | `GrpcSerializedStreamWriter`의 `DROP_OLDEST`가 **버려지는 메시지가 아니라 들어오는 메시지의 바이트**를 뺀다. 봉투가 크기를 담지 않아 알 방법이 없고, 테스트는 고정 크기 sizer라 결함이 보이지 않는다 | `grpc-policy` |
|
||||
| **P2** | 7.4 | `GrpcCredentialRotationManager`가 CAS 없이 read-then-write — 동시 회전 시 한 세대가 드레인 없이 사라진다. **messaging이 `CredentialRotationContractTest`로 닫은 결함의 재현** | `grpc-policy` |
|
||||
| **P2** | 7.5 | `GrpcOutcomeReplay`가 제거·TTL·개수 상한이 하나도 없는 인메모리 저장소. 커밋한 멱등 연산마다 영구 적재 | `grpc-policy` |
|
||||
| **P2** | 7.6 | `GrpcCompletionReconciler.pending`이 요청 경로에서 동기화 없이 변경되는 `ArrayList` | `grpc-policy` |
|
||||
| P3 | 3.5 | 저장소 어디에도 참조가 없는 타입 3개 (`ReactiveGrpcClient`·`ReactiveGrpcServerAdapter`·`GrpcDeadlineExceededException`) | advanced-compat · core-api |
|
||||
| P3 | 3.6 | 가족 문서의 `grpc-discovery` 행이 UDS resolver를 빠뜨린다 | `src/grpc/CLAUDE.md` |
|
||||
| P3/기록 | 0 | 분석 기준선 이후 리비전이 이동해 18개 모듈이 `state.json` 밖에 있었다 | `state.json` |
|
||||
|
||||
**P1이 0인 이유는 명확하다** — 18개 leaf 전부 `runtime_memberships: []`이고, 그 사실을 운영 문서가 정확히 공시한다(§2.6). 어떤 배포도 이 코드를 싣지 않으므로 "지금 틀린 동작"이 성립하지 않는다. 다만 §7.1의 `GrpcAdmissionController`는 조립되는 9개 bean 중 하나이므로, 채택하는 날 가장 먼저 청구되는 부채다.
|
||||
|
||||
### 5.1 검증된 설계 — 8건
|
||||
|
||||
1. `grpc-core-api`의 **빈 `dependencies {}`** — framework-free가 문서가 아니라 클래스패스로 강제됨 (§2.1)
|
||||
2. Stable → advanced 금지가 레지스트리·소스·빌드·런타임 네 층에서 일치 (§2.2)
|
||||
3. `adapter-inbound-grpc`가 이 가족을 볼 수 없다 — messaging MSG-015의 재발 방지가 실제로 성립 (§2.3)
|
||||
4. `GrpcEvidenceGrade`가 in-process 결과로 전송 능력을 주장하는 것을 런타임에 거부 (§2.4)
|
||||
5. 성능 레인을 기본 `test`에서 제외하고 그 이유를 적음 (§2.5)
|
||||
6. 지원 매트릭스가 "Not released / build-only"와 미해결 게이트 입력 2건을 스스로 공시 (§2.6)
|
||||
7. `GrpcCompatibilityMatrix` ↔ 문서 표 전수 일치 (§2.6)
|
||||
8. 중복 장치 0 — 상태 번역·시작 검증·증거 등급 모두 단일 소유자 (§4.3)
|
||||
|
||||
### 5.2 이 가족의 성격 — 계약은 강하고 조립은 아직 없다
|
||||
|
||||
이 가족은 messaging을 **명시적으로 참조하며** 만들어졌다. `src/grpc/CLAUDE.md`가 "`messaging:*`의 MSG-015(bridge 부재)를 반복하지 않는 것이 이 family의 목표"라고 적고, 실제로 그 목표는 달성했다 — `adapter-inbound-grpc`가 이 가족에 의존하지 않도록 레지스트리가 막고 있고, 그 사실을 문서가 정확히 말한다.
|
||||
|
||||
그런데 옮겨 오지 않은 교훈이 하나 있다. messaging이 값비싸게 배운 것은 "**게이트는 자기가 검사할 증거를 스스로 읽어야 하고, 그 게이트를 CI가 돌려야 한다**"였다(모듈 19 §2.3·§6.6). gRPC 가족은 그 게이트의 *설계*를 더 정교하게 만들었으면서(4등급 증거, 문서 부재를 blocker로) *강제 배선*은 만들지 않았다 — Gradle 태스크 0, CI 워크플로 0, `check` 연결 0.
|
||||
|
||||
그래서 이 가족의 조립 층 P2 네 건은 전부 같은 축에 있다: **판정하는 코드는 잘 만들어졌고, 그것을 부르는 코드가 없다.** §3.1(시작 검증기), §3.2(릴리스 게이트), §3.3(증거 레인), §3.4(인터셉터·어댑터)가 모두 그 형태다. build-only 등급이 오늘의 사고를 막고 있고, 채택하는 날 그 넷이 동시에 부채로 청구된다.
|
||||
|
||||
**그리고 §7이 같은 판정을 구현 층에서 반복한다.** 조립 층의 형태가 "부르는 코드가 없다"였다면 구현 층의 형태는 "원자적으로 하지 않는다"다 — `AtomicInteger`/`AtomicReference`를 쓰면서 `compareAndSet`을 쓰지 않는 것이 세 곳, 경계를 선언하고 유지 장치를 두지 않은 것이 두 곳이다. 그리고 두 층 모두, **정확한 참조 구현이 같은 가족 안에 이미 있다.**
|
||||
|
||||
---
|
||||
|
||||
## 6. 검증
|
||||
|
||||
### 6.1 테스트 레인
|
||||
|
||||
```
|
||||
./gradlew (18개 grpc leaf의 :test 전량) --console=plain
|
||||
→ BUILD SUCCESSFUL in 1m 12s · 81 actionable tasks · GRADLE_EXIT=0
|
||||
XML 집계: classes=71 tests=579 failures=0 errors=0 skipped=0
|
||||
|
||||
./gradlew :grpc:grpc-testkit:grpcInProcessContractTest \
|
||||
:grpc:grpc-testkit:grpcNettyContractTest \
|
||||
:grpc:grpc-testkit:grpcFaultTest --console=plain
|
||||
→ BUILD SUCCESSFUL · GRADLE_EXIT=0
|
||||
grpcInProcessContractTest classes=1 tests=7 failures=0 skipped=0
|
||||
grpcNettyContractTest classes=1 tests=9 failures=0 skipped=0
|
||||
grpcFaultTest classes=1 tests=9 failures=0 skipped=0
|
||||
```
|
||||
|
||||
Netty 레인이 실제 소켓을 열고 통과한다 — 컨테이너 안에서도 재현된다.
|
||||
|
||||
**돌리지 않은 레인:** `grpcPerformanceTest`. 공유 러너에서의 측정이 flaky 게이트가 된다는 이유로 `test`에서 제외돼 있고(§2.5), 분석 컨테이너의 측정값은 baseline이 될 수 없다.
|
||||
|
||||
### 6.2 소스 트리 변경 없음
|
||||
|
||||
```
|
||||
git status --short → (출력 없음)
|
||||
```
|
||||
|
||||
### 6.3 커버리지 원장
|
||||
|
||||
**이 문서의 읽기 깊이는 앞선 모듈들보다 얕다.** 그 사실을 숫자로 적는다.
|
||||
|
||||
`FULL_READ`는 파일 전문 또는 그에 준하게 읽은 것만 센다. 나머지는 전부 `STRUCTURAL_ONLY`이며,
|
||||
그 근거는 (a) 파일·패키지 전수 목록, (b) 260개 main 타입 **전수 도달성 스윕**(선언 파일 제외 참조
|
||||
수를 main/test로 분리 계수), (c) 레지스트리·`build.gradle`·`.imports` 전수 판독, (d) 18 leaf `:test`
|
||||
전량 + 증거 레인 3종 실행이다. 즉 **조립·경계·거버넌스 층은 전수로 확인했고, 각 leaf의 구현 내부는
|
||||
읽지 않았다.**
|
||||
|
||||
| sub-scope | leaf | 파일 | FULL_READ | 전문으로 읽은 것 |
|
||||
|---|---|---|---|---|
|
||||
| 01 core contracts | 3 | 68 | **3** | `grpc-core-api/build.gradle` · `GrpcStatusCode`(javadoc+상수) · `GrpcDeadlineExceededException`(헤더) |
|
||||
| 02 policy | 1 | 82 | **21** | idempotency 4 · streaming 5 · resilience 3 · security 2 · deadline 1 · validation/policy/error/context 전수 스윕 + 후보 본문 확인 (§7) |
|
||||
| 03 server·client·discovery | 3 | 54 | **5** | `GrpcServerInterceptorChain`(헤더+javadoc) · `GrpcResolverType`/`GrpcDiscoveryPolicyValidator`(해당 행) |
|
||||
| 04 admin·observability·ledger·조립 | 4 | 41 | **6** | `GrpcPlatformAutoConfiguration`(106줄 전문) · `GrpcPlatformStartupValidator`(188줄 전문) · `.imports` |
|
||||
| 05 testkit·증거·릴리스 | 1+1 | 37 | **5** | `GrpcEvidenceGrade` · `GrpcStableReleaseGate` · `GrpcReleaseEvidence` · `grpc-testkit/build.gradle` · `src/grpc/CLAUDE.md` |
|
||||
| 06 advanced | 6+1 | 101 | **5** | `src/grpc-advanced/CLAUDE.md` · `ReactiveGrpcClient`(헤더) · `ReactiveGrpcServerAdapter`(헤더) |
|
||||
| | **18** | **383** | **45** | |
|
||||
|
||||
`STRUCTURAL_ONLY` 338 · EXCLUDED 0 · 미배정 0.
|
||||
|
||||
*(2026-08-31 보강: 최초 기재는 FULL_READ 16이었다. §7의 구현 내부 판독으로 29개가 추가됐다 — `grpc-policy` 21, 나머지 leaf 8. leaf 귀속분 43 + 가족 거버넌스 문서 2 = 45.)*
|
||||
|
||||
**이 깊이로 확정할 수 있는 것과 없는 것:**
|
||||
|
||||
- **확정됨** — §2(공개 주장 6건), §3.1~§3.4(조립·릴리스 게이트·증거 레인·인터셉터 미조립), §3.5(참조 0 타입), §4.1~§4.4. 이 판정들은 전부 도달성·조립·빌드 구성에 대한 것이고 근거가 전수다.
|
||||
- **§7로 확정됨** — `grpc-policy`의 동시성·경계 층. 6건이 나왔고 2건은 검증 중 철회했다(§7.7).
|
||||
- **여전히 확정되지 않음** — 읽기의 초점이 동시성과 경계였으므로, 각 leaf의 **도메인 로직 정확성**은 그 초점 밖이다. 구체적으로 `grpc-proto-contract`의 스키마 규칙 판정(3 main / 605 LOC), `grpc-codegen`의 매니페스트 해시 규약, `grpc-advanced-resilience`의 hedging 적격성·xDS 실패 정책, `grpc-advanced-compat`의 Servlet/gRPC-Web 프로파일 판정은 구조와 도달성만 확인했다.
|
||||
|
||||
비교를 위해: 모듈 19(messaging)는 550 파일에 1,284줄 문서, 모듈 14(web)는 638 파일에 1,702줄이었다.
|
||||
이 문서는 383 파일에 (§7 보강 후) 약 700줄이다.
|
||||
|
||||
### 6.4 증거
|
||||
|
||||
`evidence/raw/266`–`268` (3개 신규). `264`·`265`는 교차 스코프 준비 중 생성됐고, `264`는 리비전 이동 **이전** 트리 상태에서 만들어져 `.imports` 7개·leaf 44개를 담고 있다 — 이동 이후 값은 `.imports` 8개·leaf 62개이며 `265`와 `268`이 현재 상태를 담는다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 구현 내부 판독 (2026-08-31 보강)
|
||||
|
||||
§6.3이 미독으로 기록한 층 — `grpc-policy` 62 main / 4,781 LOC, `grpc-server`·`grpc-client`·`grpc-admin`의 상태 보유 클래스, `grpc-advanced-streaming`·`-bootstrap` — 을 읽었다. **동시성과 경계(bound)에 초점을 두었다.** 앞선 모듈에서 상태·펜싱·순서 결함이 나온 층이 정확히 여기이기 때문이다.
|
||||
|
||||
방법: (a) 가족 전체 main 소스에서 mutable 컬렉션·카운터를 보유한 클래스를 전수 추출하고 각 파일의 동기화 마커 수를 병기, (b) `get()` 비교 후 `increment/set`을 수행하는 check-then-act 패턴 전수 추출, (c) 키가 늘기만 하고 제거가 없는 컬렉션 전수 추출. 그 뒤 각 후보를 본문으로 확인했다.
|
||||
|
||||
### 7.1 P2 — `GrpcAdmissionController.tryAdmit()`의 동시성 경계가 동시성 아래에서 성립하지 않는다
|
||||
|
||||
**이 클래스는 조립된다** — `GrpcPlatformAutoConfiguration`의 9개 bean 중 하나(`grpcAdmissionController`)다.
|
||||
|
||||
```java
|
||||
public Decision tryAdmit() {
|
||||
int running = inFlight.get();
|
||||
if (running < maxConcurrentCalls) {
|
||||
inFlight.incrementAndGet(); // 검사와 증가 사이가 열려 있다
|
||||
return new Decision(true, ...);
|
||||
}
|
||||
int waiting = queued.get();
|
||||
if (waiting < maxQueuedCalls) {
|
||||
queued.incrementAndGet();
|
||||
return new Decision(true, ...);
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`AtomicInteger`를 쓰지만 **원자적 연산은 하나도 하지 않는다.** `get()`으로 읽고 비교한 뒤 별도로 `incrementAndGet()`한다. 경계에 있는 N개 스레드가 모두 같은 `running`을 읽고 모두 통과해 모두 증가시킨다 — `inFlight`가 `maxConcurrentCalls`를 최대 N−1만큼 초과한다.
|
||||
|
||||
클래스 javadoc이 존재 이유를 이렇게 적는다:
|
||||
|
||||
> "Rejecting with `RESOURCE_EXHAUSTED` is a better outcome than queueing for two reasons that both matter **under load**... A server that queues instead spends its capacity finishing requests nobody is reading."
|
||||
|
||||
**부하 아래에서 지키라고 만든 경계가 부하 아래에서 새는 구조다.** 동시 요청이 없을 때는 정확하고, 있을 때 부정확하다.
|
||||
|
||||
해제 쪽도 같다:
|
||||
|
||||
```java
|
||||
public void release() {
|
||||
if (inFlight.get() > 0) { inFlight.decrementAndGet(); }
|
||||
}
|
||||
```
|
||||
|
||||
`inFlight == 1`일 때 두 스레드가 동시에 `release()`하면 둘 다 `> 0`을 통과해 둘 다 감소시켜 **−1**이 된다. 그 뒤로는 `running < maxConcurrentCalls`가 한 칸 더 쉽게 통과하므로 경계가 영구적으로 느슨해진다.
|
||||
|
||||
`promoteFromQueue()`는 한 단계 더 나아간다 — `queued`를 줄이고 `inFlight`를 늘리면서 **`inFlight`를 경계와 대조하지 않는다.** 큐에서 승격되는 호출은 동시성 한도를 무조건 통과한다.
|
||||
|
||||
**같은 가족이 올바른 형태를 이미 갖고 있다.** `GrpcRetryBudget`은 정확한 CAS 루프다:
|
||||
|
||||
```java
|
||||
public boolean tryConsume() {
|
||||
while (true) {
|
||||
long observed = tokens.get();
|
||||
if (observed < tokensPerRetry) return false;
|
||||
if (tokens.compareAndSet(observed, observed - tokensPerRetry)) return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`grpc-policy`의 예산은 CAS로 닫혀 있고 `grpc-server`·`grpc-policy`의 두 admission은 check-then-act다.
|
||||
|
||||
### 7.2 P2 — `GrpcStreamAdmission`도 같은 형태이고, per-caller 맵이 줄지 않는다
|
||||
|
||||
```java
|
||||
public boolean tryAdmit(String callerFingerprint) {
|
||||
AtomicInteger callerCount = perCaller.computeIfAbsent(callerFingerprint, key -> new AtomicInteger());
|
||||
if (callerCount.get() >= maxStreamsPerCaller) return false;
|
||||
if (openStreams.get() >= maxConcurrentStreams) return false;
|
||||
callerCount.incrementAndGet();
|
||||
openStreams.incrementAndGet();
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
§7.1과 동일한 TOCTOU이고, 이쪽은 javadoc이 서술하는 실패 시나리오가 곧 고동시성 상황이다:
|
||||
|
||||
> "Without a bound, **a client that reconnects on every error opens streams faster than the old ones close.**"
|
||||
|
||||
재접속 폭풍은 정의상 동시 요청이 몰리는 상황이고, 그때 경계가 가장 많이 샌다.
|
||||
|
||||
`release()`도 `get() > 0` 후 `decrementAndGet()`이라 음수로 갈 수 있다.
|
||||
|
||||
**그리고 `perCaller`에서 엔트리가 제거되지 않는다.** `computeIfAbsent`가 caller fingerprint마다 `AtomicInteger`를 하나 만들고, `release()`는 값을 줄일 뿐 키를 지우지 않는다. 서로 다른 caller 수만큼 맵이 자라고 줄지 않는다 — 이 가족이 `GrpcMetricCardinalityPolicy`로 지표 태그에 대해 명시적으로 막는 것과 같은 종류의 증가이며, 여기에는 그 가드가 없다.
|
||||
|
||||
### 7.3 P2 — `GrpcSerializedStreamWriter`의 `DROP_OLDEST`가 잘못된 메시지의 바이트를 뺀다
|
||||
|
||||
```java
|
||||
case DROP_OLDEST -> {
|
||||
GrpcStreamEnvelope<T> dropped = queue.pollFirst();
|
||||
if (dropped != null) {
|
||||
queuedBytes = Math.max(0L, queuedBytes - nextBytes); // nextBytes = 들어오는 메시지 크기
|
||||
droppedMessages++;
|
||||
}
|
||||
enqueue(kind, payload, snapshotVersion, resumeToken, nextBytes);
|
||||
yield GrpcStreamWriteResult.DROPPED;
|
||||
}
|
||||
```
|
||||
|
||||
버려지는 것은 `dropped`인데 빼는 값은 **새로 들어오는 메시지의 크기 `nextBytes`**다. `GrpcStreamEnvelope`는 7개 성분(`streamId`·`sequence`·`kind`·`snapshotVersion`·`resumeToken`·`terminationReason`·`payload`) 중 **크기를 담지 않으므로**, 이 지점에서 버려지는 메시지의 크기를 알 방법이 애초에 없다.
|
||||
|
||||
`queuedBytes`는 장식이 아니라 판정 입력이다:
|
||||
|
||||
```java
|
||||
// GrpcFlowControlPolicy.decide
|
||||
boolean overflowsBytes = queuedBytes + nextMessageBytes > maxQueuedBytes;
|
||||
```
|
||||
|
||||
그리고 그 바이트 경계의 존재 이유가 javadoc에 있다:
|
||||
|
||||
> "Both a message count and a byte count, because either alone is unbounded in the other dimension: **a thousand-message bound with no byte bound is a memory limit set by the largest message anyone ever sends.**"
|
||||
|
||||
**실패 시나리오.** `DROP_OLDEST` 프로파일 + 가변 크기 메시지. 10바이트 메시지를 버리며 10,000바이트를 넣으면 `queuedBytes`는 10,000을 빼고 10,000을 더해 **변화 없음**인데 실제 큐는 9,990바이트 늘었다. 반복되면 `queuedBytes`가 실제보다 계속 낮아지고(`Math.max(0, ...)`로 0에서 멈춘다) 바이트 경계가 발화하지 않게 되어, 큐는 메시지 수 경계까지 임의 크기 메시지로 채워진다 — 바이트 경계가 막으려던 바로 그 상태다. 반대 방향(큰 것을 버리고 작은 것을 넣음)에서는 과대 계상돼 조기 TERMINATE가 된다.
|
||||
|
||||
**테스트가 이 결함을 볼 수 없는 구성으로 되어 있다.** `GrpcSerializedStreamWriterTest`의 lossy 케이스는
|
||||
|
||||
```java
|
||||
writer(new GrpcFlowControlPolicy(1, 1024L, 1, GrpcSlowConsumerPolicy.DROP_OLDEST), 8L)
|
||||
```
|
||||
|
||||
— sizer가 상수 `8L`이라 모든 메시지 크기가 같고, `maxQueuedMessages=1`이라 발화하는 것은 **개수 경계**다. 크기가 같으면 잘못된 뺄셈이 우연히 옳은 값이 된다.
|
||||
|
||||
*(Stable 기본값 `GrpcFlowControlPolicy.stable()`은 `TERMINATE`이므로 기본 경로는 영향을 받지 않는다. `DROP_OLDEST`는 opt-in 손실 허용 프로파일이다.)*
|
||||
|
||||
### 7.4 P2 — `GrpcCredentialRotationManager`가 CAS 없이 read-then-write 한다. messaging이 고친 결함의 재현이다
|
||||
|
||||
```java
|
||||
public RotationPlan rotate(GrpcCredentialGeneration next, Instant now) {
|
||||
State observed = state.get(); // :89
|
||||
...
|
||||
state.set(new State(next, observed.current(), deadline)); // :103
|
||||
}
|
||||
public void completeDrain() {
|
||||
State observed = state.get(); // :119
|
||||
state.set(new State(observed.current(), null, null)); // :120
|
||||
}
|
||||
```
|
||||
|
||||
`AtomicReference`를 쓰지만 `compareAndSet`·`updateAndGet`이 **한 번도 없고** `synchronized`도 없다. 순수한 홀더로만 쓰인다.
|
||||
|
||||
두 회전이 동시에 일어나면 둘 다 같은 `observed`를 읽고 둘 다 `supersededBy`를 통과해 둘 다 `set`한다. 나중 것이 앞선 것을 덮으므로 **한 세대가 `draining`에 오르지 못한 채 사라진다** — 그 세대 위의 in-flight 호출은 추적되지도, 드레인되지도 않는다. `completeDrain()`과 `rotate()`가 겹치면 새로 draining이 된 세대가 즉시 잊힌다.
|
||||
|
||||
**이 클래스의 javadoc이 그 경합을 이미 알고 있다:**
|
||||
|
||||
> "@throws IllegalArgumentException when `next` does not supersede the current generation — a rotation that goes backwards would reactivate material that was already replaced, and **the usual reason for one is two rotators racing**"
|
||||
|
||||
경합의 존재를 적어 두고, 그 경합을 닫는 연산은 쓰지 않았다.
|
||||
|
||||
**그리고 이것은 messaging이 이미 고친 결함이다.** 모듈 19 §5.4가 인용한 `CredentialRotationContractTest`:
|
||||
|
||||
> "`resolve` was get → fetch → put → clear **with no synchronization**. Two callers rotating the same credential both read the same old runtime and both fetched a replacement: **one replacement was dropped from the map without ever being cleared — a secret left in memory that nothing owns** — and the loser could clear material the winner was still using."
|
||||
|
||||
같은 주제(자격 증명 회전), 같은 결함 형태(공유 상태에 대한 read-then-write), 한 가족은 동시성 계약 테스트까지 만들어 닫았고 다른 가족이 재현했다. `grpc-policy`의 테스트 16개 중 동시성을 다루는 것은 없다.
|
||||
|
||||
### 7.5 P2 — `GrpcOutcomeReplay`가 제거 경로 없는 인메모리 저장소다
|
||||
|
||||
```java
|
||||
private final ConcurrentMap<String, byte[]> storedOutcomes = new ConcurrentHashMap<>();
|
||||
```
|
||||
|
||||
- `maxInlineBytes`는 **엔트리 하나의 크기**를 제한한다. 엔트리 **개수**를 제한하는 것은 없다.
|
||||
- `remove`·`clear`·evict·TTL이 **하나도 없다**(전수 grep 0건). `size()`만 있고 그 값을 읽는 곳도 없다.
|
||||
- `store()`는 `IDEMPOTENCY_KEY_REQUIRED` 메서드가 커밋될 때마다 호출되므로, 프로세스 수명 동안 **커밋한 멱등 연산 수만큼** 엔트리가 쌓인다.
|
||||
|
||||
javadoc은 "a small inline store"라고 부르지만 작게 유지하는 장치가 없고, 크기를 넘는 응답은 거부하면서("store it behind an object reference instead") 개수는 거부하지 않는다.
|
||||
|
||||
**비교 대상이 같은 leaf 안에 있다.** `GrpcClientMessageDeduplicator`는 정확히 이 문제를 피하려고 설계됐고 그 이유를 적는다 — "A set grows without bound for the life of a session... a monotonic applied-sequence answers it in constant space" — 그리고 `endSession()`으로 두 맵을 모두 정리한다. 같은 가족에서 한쪽은 정리하고 한쪽은 하지 않는다.
|
||||
|
||||
### 7.6 P2 — `GrpcCompletionReconciler`가 요청 경로에서 동기화 없는 `ArrayList`를 변경한다
|
||||
|
||||
```java
|
||||
private final List<PendingCase> pending = new ArrayList<>(); // :25
|
||||
...
|
||||
pending.add(new PendingCase(...)); // reconcile(...) 안 — 요청 경로
|
||||
List.copyOf(pending); // pendingCases()
|
||||
pending.remove(resolved); // clearPending(...)
|
||||
```
|
||||
|
||||
`synchronized`·`Concurrent*`·`volatile`·`Lock` **전부 0건**이고, 단일 스레드 전용이라는 javadoc 표기도 없다. 이 leaf에서 스레드 안전성을 명시적으로 다루는 유일한 클래스는 `GrpcSerializedStreamWriter`이며(그쪽은 9개 마커로 제대로 닫혀 있다), 그 사실이 이 leaf가 동시성을 인지하고 있음을 보여준다.
|
||||
|
||||
`reconcile(...)`은 완료 결과가 불확실한 호출마다 불린다 — 장애 상황에서 동시에 몰리는 경로다. `ArrayList`에 대한 동시 `add`는 원소 유실 또는 `ArrayIndexOutOfBoundsException`이고, `add` 중의 `List.copyOf`는 `ConcurrentModificationException` 또는 null 원소로 인한 NPE다. 그리고 `pending`이 담는 것은 **결과를 알 수 없어 사람이 조정해야 하는 연산 목록**이므로, 유실은 조정되지 않은 채 잊히는 연산이 된다.
|
||||
|
||||
### 7.7 검증 중 철회한 판정 2건
|
||||
|
||||
읽기 전 후보로 잡았다가 본문 확인 후 취소한 것들이다. 기록해 둔다.
|
||||
|
||||
| 후보 | 왜 취소했나 |
|
||||
|---|---|
|
||||
| `GrpcChannelRuntime.draining`이 비-volatile이라 드레인 신호가 요청 스레드에 안 보일 수 있다 | **`private volatile boolean draining`** — 이미 volatile이다. 남는 것은 `release` 계열의 check-then-act뿐이고, 그 경우 카운터가 음수가 되면 `quiescent()`가 영원히 false가 되어 드레인이 **끝나지 않는** 쪽으로 실패한다(조기 완료가 아니라). 호출자의 이중 해제를 전제하므로 별도 결함으로 세지 않는다 |
|
||||
| `GrpcClientMessageDeduplicator`의 두 `ConcurrentMap`이 무한 증가한다 | **`endSession(sessionId)`이 `checkpoints.remove(...)`와 `replayableOutcomes.keySet().removeIf(...)`로 둘 다 정리한다.** 자동 스윕에서 제외로 잡힌 것이 맞았고 내가 과독했다 |
|
||||
|
||||
### 7.8 확인된 올바른 설계 (구현 층)
|
||||
|
||||
1. **`GrpcResumeTokenCodec`** — 상수 시간 비교(`MessageDigest.isEqual`), 알 수 없는 key id를 현재 키로 폴백하지 않고 거부("turns key rotation into a window in which a token signed by a compromised key still verifies"), malformed·unknown key·verify 실패를 **구별 불가능하게** 반환("telling them apart is a probing oracle"). 그리고 `GrpcResumeToken.requireBounded`가 모든 문자열 필드에서 구분자 `|`를 명시적으로 거부해 인코딩/디코딩 비대칭이 생기지 않는다.
|
||||
2. **`GrpcCancellationCoordinator`** — 5개 메서드 전부 `synchronized`. 등록이 취소 이후 실패하는 것을 "no new external side effect after cancel"의 구현으로 삼고, `markCommitBoundaryCrossed()`로 커밋 이후 취소를 abort로 오해하지 않게 분리한다.
|
||||
3. **`GrpcRetryBudget`** — 정확한 CAS 루프. 성공이 토큰을 상한까지 회복시켜 "실패가 전면화되면 재시도가 사실상 0으로 수렴"하는 성질을 만든다.
|
||||
4. **`GrpcRetryCoordinator`** — 검사 순서가 고정(자격 → 설정 → status → 시도 수 → 남은 데드라인 → 백오프 후 잔여 → 예산)이고, **예산을 마지막에 소모**한다. 어차피 거부할 재시도에 예산을 쓰지 않는다.
|
||||
5. **`GrpcRetryOwnershipValidator`** — 서비스 설정의 메서드 이름을 카탈로그와 대조한다. "rename `CreateDocument` to `CreateDocumentV2` and the entry stops matching, silently."
|
||||
6. **`GrpcMetricCardinalityPolicy.retryBucket(attempts)`** — 시도 횟수를 버킷으로 접어 태그 카디널리티를 제한한다.
|
||||
|
||||
### 7.9 이 층의 성격
|
||||
|
||||
여섯 건 중 **넷이 같은 형태**다 — `AtomicInteger`/`AtomicReference`를 쓰면서 원자적 연산을 하지 않는 것(§7.1·§7.2·§7.4)과, 경계를 선언하고 그 경계를 유지하는 장치를 두지 않는 것(§7.2의 per-caller 맵·§7.5). 그리고 같은 가족 안에 **정확한 참조 구현이 이미 있다** — 예산은 CAS 루프로, 스트림 라이터는 `synchronized`로, 중복제거기는 `endSession`으로 닫혀 있다.
|
||||
|
||||
§5.2가 조립 층에 대해 내린 판정이 구현 층에도 그대로 적용된다: **판정하는 코드는 잘 만들어졌고, 그것을 정확히 실행하는 부분이 빠져 있다.** 조립 층에서는 "부르는 코드가 없다"였고, 구현 층에서는 "원자적으로 하지 않는다"다.
|
||||
|
||||
여전히 P1이 없는 이유는 §5와 같다 — 18 leaf 전부 `runtime_memberships: []`이고 어떤 배포도 이 코드를 싣지 않는다. 다만 §7.1의 `GrpcAdmissionController`는 **조립되는 9개 bean 중 하나**이므로, 채택 시점에 가장 먼저 청구되는 부채다.
|
||||
@@ -0,0 +1,552 @@
|
||||
# 99 · 교차 스코프 분석 — 사이클 2
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분모: 등록 리프 **62** (COMPLETE 61 · EXCLUDED 1 `sample-portfolio`)
|
||||
> 근거: 각 리프 SSOT 문서 61건 · `evidence/raw/` 370건
|
||||
> 사이클 1의 같은 경로에 초안이 있었고(364줄, 상태 `NOT_STARTED`), 이 문서가 그것을 대체한다.
|
||||
> 초안은 보존하지 않았다 — 덮어쓰기 전에 읽지 않은 것은 이 사이클의 절차 실수다.
|
||||
> 따라서 **사이클 1의 절 번호와 줄 번호는 이 문서에 적용되지 않는다.** `root-tree.md`가 옛 번호로
|
||||
> 걸어 둔 앵커는 이 문서의 절로 다시 걸었다(§3.1 · §3.4 · §5 · §6 · §2·§4).
|
||||
> 옛 초안에만 있던 서술이 있었다면 그것은 복구되지 않았다. 다만 그 초안이 인용하던 1차 근거는
|
||||
> 전부 리프 SSOT와 `final/document.md`에 있으며, 이 문서는 그것들을 다시 읽고 작성했다.
|
||||
> **사이클 2 후반 갱신.** 이 문서의 §0~§6 은 18개 리프 재검증까지를 반영해 쓰였다. 그 뒤 같은 사이클에서
|
||||
> 23개 리프(messaging 5 · grpc 18)의 production 구현을 통독했고, 그 결과를 §1.2 · §3.7 · §4 의
|
||||
> 6~9항 · §6 에 더했다.
|
||||
>
|
||||
> **그 통독 자체를 다시 했다 — 이 문서의 §0 · §1.2 · §3.5 · §6 숫자가 그래서 바뀌었다.** 앞선 판에서
|
||||
> 23개 리프를 `FULL_READ_DONE` 으로 표시하고 "395파일 전수 통독" 이라고 적었으나, 실제로 읽은 것은 리프마다
|
||||
> 일부였다. 그 표시가 사실이 아니었으므로 23개 리프를 파일 단위로 다시 세고 처음부터 다시 읽었다. 정직한
|
||||
> 분모는 **main 357파일 / 29,542줄 · test 121파일 / 19,756줄**이고,
|
||||
> 각 리프 SSOT 의 Coverage ledger 를 그 숫자로 다시 썼다. §1.2 의 결론 하나가 그 과정에서 뒤집혔다 —
|
||||
> "통독이 만든 새 finding 에 P1 은 없다" 는 서술이 더 이상 참이 아니다.
|
||||
|
||||
> 지위: 이 문서는 **2차 증거**다. 리프의 사실은 리프 SSOT가 소유하고, 여기서는 리프 경계를 넘을 때만 성립하는 것을 다룬다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 이 문서가 서 있는 분모
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| 등록 리프 | 62 (COMPLETE 61 · EXCLUDED 1) |
|
||||
| `analysisFile`을 공유하는 리프 | **0** — SSOT 게이트 통과 |
|
||||
| 리프 SSOT가 확정한 finding | **462** (P1 30 · P2 147 · P3 285) — 아래 주 참조 |
|
||||
| finding 0건인 리프 | 8 |
|
||||
| evidence 파일 | 370 |
|
||||
| 끊긴 evidence 참조 | **0** |
|
||||
| 사이클 2 전수 통독 리프 | **23** (messaging 5 · grpc 18) |
|
||||
| 그 23개 리프의 main production | **357파일 / 29,542줄** |
|
||||
| 그 23개 문서의 finding | **100** (P1 2 · P2 31 · P3 67) |
|
||||
| 그중 사이클 1 가족 문서에서 옮겨온 것 | 8 |
|
||||
| 통독에서 처음 나온 것 | **92** (P1 2 · P2 25 · P3 65) |
|
||||
|
||||
**두 숫자의 근거가 다르다.**
|
||||
|
||||
23개 리프의 100건은 이번에 직접 센 것이다 — 23개 문서의 §17 에서 `### 17.n P<k> —` 형태를 파싱했고,
|
||||
23개 문서 전부를 처음부터 끝까지 읽은 뒤이므로 표기 누락이 없다. 앞선 판의 60건은 통독이 실제로는
|
||||
부분 통독이던 시점의 수치다.
|
||||
|
||||
전체 462건은 **직접 다시 센 것이 아니라 델타로 조정한 값**이다. 앞선 판의 422건에서 23개 리프 몫 60을
|
||||
빼고 100을 더했다(422 − 60 + 100 = 462). 나머지 38개 문서는 이번 재작업의 대상이 아니었고, 그 문서들이
|
||||
쓰는 「모듈 findings 표」 형식은 §17 형식과 파싱 규칙이 달라 두 형식을 함께 세는 스크립트를 이번에
|
||||
다시 돌리지 않았다. 그러므로 **462는 델타가 정확하다는 가정 위에 있고, 422 자체의 재측정은 아니다.**
|
||||
직접 재측정이 필요하면 두 형식을 모두 파싱하는 원래 스크립트를 61개 문서에 다시 돌려야 한다.
|
||||
|
||||
P1·P2·P3 내역도 같은 방식으로 조정했다 — P1 29−1+2=30, P2 135−19+31=147, P3 258−40+67=285.
|
||||
|
||||
---
|
||||
|
||||
## 1. 사이클 2가 실제로 바꾼 것
|
||||
|
||||
사이클 2의 재검증 대상은 사이클 1이 남긴 18개 리프 문서였다. 결과는 다음과 같다.
|
||||
|
||||
**소스는 움직이지 않았다.** 18개 문서가 모두 기준으로 삼은 `a24ece9c`와 현재 HEAD `21234e38` 사이는
|
||||
커밋 하나이고, 그 커밋은 `src/grpc/**`·`src/grpc-advanced/**`와 공통 파일 둘만 건드렸다. 18개 리프 경로의
|
||||
변경 파일 수는 전부 **0**이다. 공통 파일 둘도 이 18개에 영향이 없다 — `src/build.gradle`의 변경은
|
||||
plain-JUnit 테스트 클래스패스 조건에 `:grpc:`·`:grpc-advanced:`를 더한 것뿐이고,
|
||||
`modules.json`은 197줄 순수 추가로 18개 리프 id가 diff에 한 번도 등장하지 않는다 (`EVD-333`).
|
||||
|
||||
**그래서 재검증의 실질은 재작성이 아니라 재확인이었다.** 18개 문서의 lane을 HEAD에서 다시 돌렸고
|
||||
(`EVD-334`), 실패 5건이 나왔다. 그 5건에 대한 판정은 이렇다.
|
||||
|
||||
| 리프 | 실패 | 사이클 1의 판정 | 사이클 2의 재측정 |
|
||||
|---|---|---|---|
|
||||
| `adapter-outbound-fileserver` | 1 | 환경(로케일) | **확인** — `LANG=C.utf8`로 통과, `sun.jnu.encoding` ANSI→UTF-8 |
|
||||
| `app-bootstrap` | 1 | 환경(`jq` 부재) + 가드 비대칭 P3 | **확인**, 그리고 남은 공백을 메움 — 15개 레인 계약을 독립 경로로 검증 |
|
||||
| `adapter-outbound-httpclient` | 3 | **P1 제품 결함** | **철회** — 픽스처의 듀얼스택 호스트명이 원인 |
|
||||
|
||||
세 번째가 이 사이클의 유일한 판정 번복이다. 사이클 1은 `ApacheFailureClassifier`의 분기 순서를 읽고
|
||||
"Apache가 TLS 실패를 `HttpHostConnectException`으로 감싸므로 CONNECT 분기가 TLS 분기를 가린다"고
|
||||
결론했다. 예외 사슬을 실제로 출력해 보면 그 사슬에 `SSLHandshakeException`이 **없다**. 원인은
|
||||
`MockHttpServer.uri()`가 호스트명 `localhost`를 돌려주는데 이 컨테이너의 `localhost`가 `127.0.0.1`과
|
||||
`::1` 양쪽으로 풀리고 `MockWebServer`는 IPv4에만 바인딩한다는 것이었다. Apache의 다중 주소 루프가
|
||||
첫 주소(127.0.0.1)의 진짜 TLS 실패를 삼키고 마지막 주소(::1)의 연결 거부만 승격시킨다. 접속 호스트를
|
||||
`127.0.0.1`로 바꾸면 세 건 모두 `TLS_PERMANENT`가 된다 (`EVD-332`).
|
||||
|
||||
**17/18은 확인, 1/18은 번복.** 이 비율 자체가 사이클 1 문서의 신뢰도에 대한 측정치다.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 그 뒤에 이어진 전수 통독 — 23개 리프
|
||||
|
||||
18개 재검증과 별개로, 사이클 2 는 `ssotReview: FULL_READ_REQUIRED` 로 열려 있던 23개 리프를 닫았다.
|
||||
그 리프들의 사이클 1 SSOT 는 production 구현을 `STRUCTURAL_ONLY` 로 판정하고 파일 이름·LOC·build.gradle
|
||||
주석으로 서술한 상태였다.
|
||||
|
||||
**통독은 두 번에 걸쳐 이뤄졌고, 첫 번째는 통독이 아니었다.** 첫 판에서 23개 리프를 `FULL_READ_DONE` 으로
|
||||
표시하고 각 SSOT 에 "production N파일 축자 통독 완료" 를 적었으나, 리프마다 읽은 것은 일부였다. 그 상태로는
|
||||
Coverage ledger 가 사실이 아니므로 23개 리프를 다시 세고 파일 단위로 다시 읽었다.
|
||||
|
||||
| 대상 | 리프 | main 파일 | main 줄 | test 파일 | test 줄 |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| grpc 계열 | 18 | 260 | 18,726 | 55 | 9,383 |
|
||||
| messaging 계열 | 5 | 97 | 10,816 | 66 | 10,373 |
|
||||
| **합계** | **23** | **357** | **29,542** | **121** | **19,756** |
|
||||
|
||||
각 SSOT 가 주장을 거는 test 파일도 전부 읽었다. 통독 후 `STRUCTURAL_ONLY` 잔여는 0 이고,
|
||||
23개 SSOT 의 Coverage ledger 는 위 숫자로 다시 썼다.
|
||||
|
||||
**결과의 성격.** 통독이 만든 것은 대부분 새로운 사고가 아니라 **이미 알려진 패턴의 정확한 위치**다.
|
||||
사이클 1 의 가족 문서(19·20)는 두 가족을 각각 하나의 문서로 다루면서 "블록 전체가 미배선" 이라는 층위에서
|
||||
멈췄고, 통독은 그 블록 안에서 배선되더라도 성립하지 않을 것들을 찾았다.
|
||||
|
||||
**다만 P1 이 둘 나왔다.** 앞선 판은 "새 finding 에 P1 은 0" 이라고 적었고 그것은 부분 통독의 결과였다.
|
||||
둘 다 messaging 계열이고, 둘 다 "선언과 실제가 반대인데 관측은 정상" 이라는 §3.2 의 형태다.
|
||||
|
||||
1. **운영 프로파일에 TLS 와 브로커 인증을 요구해 놓고, 그 둘이 없는 생산자를 만든다.**
|
||||
`KafkaProfileValidator` 는 운영 프로파일이 전송 보안 없이 뜨는 것을 거부하고 그 거부를 테스트가 지킨다
|
||||
(`aProductionKafkaBrokerWithoutTransportSecurityFailsStartup`). 그런데 실제로 조립되는 `KafkaProducer`
|
||||
설정에는 `security.protocol` 이 없다 — Kafka 기본값 `PLAINTEXT` 다. 그 값을 만드는
|
||||
`KafkaSecurityConfigurer` 는 저장소 전역에서 production 호출자가 0 이다.
|
||||
(`messaging-spring-boot-starter` §17.1)
|
||||
2. **지원 문서가 `deduplicatedPublish` 를 지원으로 적고 코드는 거짓이며, 그 차이가 정확히 코드가 경고한 피해다.**
|
||||
(`messaging-kafka` §17.1)
|
||||
|
||||
이 둘이 P1 인 이유는 배선 여부와 무관하게 성립하기 때문이다. 나머지 23개 리프의 finding 대부분은
|
||||
"조립되면 성립하는 결함" 이지만, 이 둘은 messaging 계열이 실제로 배선되는 경로 위에 있다.
|
||||
|
||||
그 아래 층위에서 가장 무거운 예 셋:
|
||||
|
||||
1. **`JpaGrpcOperationLedger.claim` 의 insert-first 주장이 Spring Data 의 `save` 계약과 어긋난다.**
|
||||
엔티티의 식별자가 배정값이라 `save` 가 `merge` 로 가고, 파생 기본 키가 유니크 제약과 같은 행을 가리키므로
|
||||
두 번째 청구가 유니크 위반을 일으키지 않고 **커밋된 결과를 덮어쓴다.** 테스트 이중의 `save` 는 INSERT 를
|
||||
흉내 내 그 차이를 가린다. (`grpc-operation-ledger-jpa` §17.1)
|
||||
2. **`GrpcCredentialRotationManager.completeDrain()` 이 진행 중인 회전을 되돌린다.** 읽기와 쓰기 사이에
|
||||
회전이 일어나면 방금 교체된 자격증명이 되살아난다. (`grpc-policy` §17.2)
|
||||
3. **`GrpcPlatformStartupValidator` 가 시작 시 실행되지 않는다.** 그 검증기가 유일한 소비자인 설정 키 넷
|
||||
(`transport`·`tls-enabled`·`trust-all-certificates`·`operation-ledger-enabled`)이 아무것도 게이트하지 않는다.
|
||||
(`grpc-spring-boot-starter` §17.1)
|
||||
|
||||
셋 다 "조립되면 성립하는 결함" 이고, 블록 수준 서술로는 보이지 않는 층위다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 배포 지도 — 등록된 것과 배포되는 것의 거리
|
||||
|
||||
이 저장소에는 런타임 컴포지션이 둘 있다(`runtime_compositions`: `app-bootstrap`, `sample-portfolio`).
|
||||
62개 리프의 소속을 그대로 세면 다음과 같다.
|
||||
|
||||
| 소속 | 리프 수 |
|
||||
|---|---:|
|
||||
| `app-bootstrap`에 속함 | 33 |
|
||||
| `sample-portfolio`에만 속함 | 2 (`adapter-outbound-objectstorage`, `sample-portfolio`) |
|
||||
| **어느 컴포지션에도 속하지 않음** | **27** |
|
||||
|
||||
속하지 않는 27개의 내역:
|
||||
|
||||
- **grpc 블록 18개 전부** — `grpc-*` 9개와 `grpc-advanced-*` 9개. 이는 결함이 아니라 명시된 상태다.
|
||||
`src/grpc/CLAUDE.md:74-98`이 블록의 build-only 상태를 정확히 적고 있고, 사이클 2의 측정은 그 서술의
|
||||
확인이다 (`EVD-325`).
|
||||
- **messaging 7개** — `schema-avro`, `schema-protobuf`, `kafka-share-experimental`,
|
||||
`pulsar-experimental`, `nats-experimental`, `spring-cloud-stream-bridge`, `testkit`.
|
||||
실험 어댑터와 선택적 스키마·테스트 지원이라는 성격상 예상되는 목록이다.
|
||||
- **`adapter-inbound-grpc`와 `adapter-inbound-websocket`** — 이 둘은 위 두 범주 어디에도 속하지 않는
|
||||
일반 인바운드 어댑터인데 어떤 배포에도 들어가지 않는다.
|
||||
|
||||
여기서 리프 경계를 넘어야만 보이는 사실이 하나 있다. `adapter-inbound-websocket` 문서는 이 리프의
|
||||
내부 구조를 충실히 기술하지만, "이 리프가 어떤 배포에도 없다"는 것은 리프 안에서는 보이지 않는다 —
|
||||
`modules.json`의 다른 항목과 대조해야 나온다. 같은 형태로 `adapter-outbound-objectstorage`는
|
||||
**샘플에만** 있다. 출하 애플리케이션에는 오브젝트 스토리지 어댑터가 없다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 저장소 전체를 관통하는 패턴
|
||||
|
||||
422건을 finding 제목 텍스트에 대해 기계 분류했다. 제목만 읽는 분류이므로 아래 수치는 **하한**이고
|
||||
census가 아니다 — 268건은 제목이 너무 짧아 어느 유형에도 걸리지 않았다.
|
||||
|
||||
| 유형 | 건수 | P1 | P2 | P3 | 나타난 리프 |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| A. 만들어졌지만 조립되지 않음 | 57 | 8 | 27 | 22 | **23** |
|
||||
| F. 문서·주석·이름이 코드와 다름 | 57 | 1 | 20 | 36 | 18 |
|
||||
| E. 동시성·경합·순서 | 26 | 5 | 8 | 13 | 12 |
|
||||
| B. 선언은 통과하는데 강제하는 주체가 없음 | 14 | 4 | 6 | 4 | 9 |
|
||||
| D. 같은 문제에 메커니즘이 둘 이상 | 12 | 0 | 2 | 10 | 9 |
|
||||
| C. 테스트가 픽스처를 검증함 / 검증 공백 | 11 | 4 | 2 | 5 | 6 |
|
||||
|
||||
### 3.1 A — 만들어졌지만 조립되지 않는다 (23개 리프)
|
||||
|
||||
이 저장소에서 압도적으로 반복되는 형태다. 세 층위로 나타난다.
|
||||
|
||||
**층위 1 — 컴포지션 루트가 패키지를 제외한다.** `adapter-inbound-web`의 P1 여섯 건은 전부 하나의
|
||||
원인으로 수렴한다. 컴포지션 루트가 `mvc.error`·`webflux.error`·`mvc.budget`·`mvc.operation`·
|
||||
`webflux.operation` 다섯 패키지를 컴포넌트 스캔에서 제외하고, 그 결과 RFC 9457 계약 23개 파일,
|
||||
용량 보호 계층 41개, 멱등 실행 계층 38개가 출하 애플리케이션에 등록되지 않는다. 그 문서 자신이
|
||||
여섯 번째 finding에서 단일 원인을 지목한다 — "다섯 레인·세 런타임 패리티가 검증하는 것은 픽스처의
|
||||
조립이고, '플랫폼이 능력을 설치하는가'를 묻는 레인이 없다."
|
||||
|
||||
**층위 2 — 클래스는 있는데 생성자가 없다.** `messaging-inbox-jdbc-postgresql`의 bounded purge는
|
||||
구현돼 있고 호출되지 않는다. `messaging-outbox-jdbc-postgresql`은 그 반대 방향으로 같은 형태다 —
|
||||
무제한 DELETE가 호출되고, 그것을 막는 bounded 오버로드가 호출되지 않는다.
|
||||
`messaging-spring-boot-starter`의 종료 수명주기는 아무도 증가시키지 않는 카운터가 0이 되기를 기다린다.
|
||||
`adapter-outbound-httpclient`의 `POOL_ROUTE_EXCEEDS_TOTAL` 위반 코드는 생성자에 가려 발화할 수 없다.
|
||||
|
||||
**층위 3 — 블록 전체.** grpc 18개 리프. 이 경우만은 저장소가 그 상태를 문서로 인정하고 있다.
|
||||
|
||||
이 세 층위의 차이가 중요하다. 층위 3은 **선언된 미완성**이고, 층위 1·2는 **선언되지 않은 미완성**이다.
|
||||
같은 저장소가 전자를 다루는 좋은 선례를 갖고 있다 —
|
||||
`MessagingProviderSelection.BROKERS_WITHOUT_A_TRANSPORT`는 rabbit의 미완성에 이름을 붙이고 기동에서
|
||||
거절하며 "An entry leaves this map on the day its transport does exist"라고 적는다. 층위 1·2에는
|
||||
그런 이름이 없다.
|
||||
|
||||
### 3.2 B — 검증기는 통과시키고, 그 값을 읽는 코드는 없다 (9개 리프)
|
||||
|
||||
A의 특수형이지만 결과가 다르다. A는 기능이 없는 것이고, B는 **없는 기능이 있다고 보고되는 것**이다.
|
||||
|
||||
가장 선명한 사례는 `adapter-outbound-persistence-mongo`다. `MongoClientSettingsFactory`가 저장소
|
||||
전체에서 호출되지 않아 프로파일의 `tlsRequired`·타임아웃·풀 상한·Stable API·UUID 표현이 드라이버에
|
||||
도달하지 않는다. 검증기는 "TLS 필수" 선언을 통과시키고, 연결은 평문일 수 있다. 선언과 실제가
|
||||
반대인데 관측은 정상이다.
|
||||
|
||||
같은 형태가 `messaging-pulsar-experimental`의 `claimCheckThresholdBytes`에 있다 — 필드는 있고
|
||||
그것을 읽는 액터가 없다. `adapter-outbound-persistence-jpa`의 바이트 쿼터도 같다 — persistent
|
||||
`reserved + committed` 값을 실제 admission에서 읽거나 상한과 비교하는 경로가 없다.
|
||||
|
||||
**사이클 2 가 더한 것 — 능력 선언이 프로파일에서 파생되지 않는다 (3개 어댑터).**
|
||||
|
||||
세 실험/출하 브로커 어댑터가 모두 `MessagingCapabilities` 를 **상수** 로 둔다. 그리고 그 상수가 답하는 것과
|
||||
그 능력이 실제로 성립하는 조건이 갈린다.
|
||||
|
||||
| 어댑터 | 상수가 답하는 것 | 실제 조건 |
|
||||
|---|---|---|
|
||||
| `messaging-nats-experimental` | `deduplicatedPublish = true` | 프로파일에 중복 제거 창이 있을 때만 `Nats-Msg-Id` 를 보낸다 |
|
||||
| `messaging-kafka` | `brokerTransaction = true` | 트랜잭션 식별자 접두·멱등 생산자·`acks=all`·수동 커밋이 모두 필요하고, 그것을 검사하는 검증기는 주입되지 않는다 |
|
||||
| `messaging-pulsar-experimental` | 전송과 검증기가 `orderedStream` 에 **서로 다른** 값 | Key_Shared 의 순서 단위는 키다 |
|
||||
|
||||
첫째가 특히 무겁다. `deduplicatedPublish` 는 능력 열둘 중 부재가 예외를 만드는 유일한 플래그이므로
|
||||
(`DefaultMessagePublisher:250`), 창 없는 목적지가 그 가드를 통과한다.
|
||||
|
||||
이 셋은 B 의 거울상이다. B 는 선언을 통과시키고 읽는 코드가 없는 것이고, 이것은 **읽히는 값이 조건과
|
||||
무관하게 참** 인 것이다.
|
||||
|
||||
### 3.3 C — 레인이 검증하는 것이 픽스처의 조립일 때 (6개 리프)
|
||||
|
||||
`adapter-inbound-web`의 다섯 레인, `grpc` 경계 규칙이 실제 소스를 보지 않는 것(`EVD-328`),
|
||||
`messaging-testkit`의 인증 매니페스트 드리프트(`EVD-300`)가 같은 계열이다.
|
||||
|
||||
여기에 사이클 2가 사례 하나를 보탠다 — `app-bootstrap`의 `everyLaneMatchesItsContract`는
|
||||
`docker compose` 부재는 skip으로 막고 `jq` 부재는 실패로 낸다. 스크립트는 전제 결손을 `exit 78`
|
||||
(sysexits.h의 `EX_CONFIG`)로, 계약 위반을 `exit 1`로 **구분해서** 알리는데 테스트가 그 구분을 버린다.
|
||||
결과적으로 아무것도 검사되지 않은 상태가 "레인이 계약과 다르다"로 보고된다 (`EVD-334`).
|
||||
|
||||
### 3.4 D — 같은 문제에 메커니즘이 둘 (9개 리프)
|
||||
|
||||
`messaging-admin-runtime`의 두 토폴로지 스택(`EVD-307`), `messaging-kafka`의 세 층위 재시도 중
|
||||
하나만 도는 것, grpc의 두 규칙 엔진 중 하나만 급여되는 것(`EVD-329`),
|
||||
`adapter-outbound-httpclient`의 재생 가능성 판정이 호출마다 반사로 재계산되는 것.
|
||||
|
||||
이 유형이 P3에 몰려 있는 것(12건 중 10건)은 우연이 아니다. 둘 중 하나는 대개 돌고 있어서
|
||||
증상이 없다. 비용은 **다음에 고치는 사람이 어느 쪽이 정본인지 모른다**는 데서 나온다.
|
||||
|
||||
### 3.5 E — 동시성·경합 (12개 리프)
|
||||
|
||||
P1 다섯 건이 여기 있다. `adapter-outbound-persistence-jpa`의 outbox stale worker가 owner fencing
|
||||
없이 newer/terminal 상태를 덮어쓰는 것, provider call recorder가 lease-unaware `save()`를 써서
|
||||
만료된 holder의 stale projection이 새 holder를 덮는 것, `adapter-outbound-persistence-mongo`의
|
||||
change stream high-water mark가 "본 위치"여서 failover 중 이벤트가 조용히 영구 소실되는 것,
|
||||
`messaging-admin-runtime`의 재개된 리드라이브가 옮기지 못한 메시지를 영구히 건너뛰는 것.
|
||||
|
||||
이 넷의 공통 형태는 **실패가 상태로 남지 않는다**는 것이다. 넷 다 로그도 상태 전이도 남기지 않고,
|
||||
`RUNNING` 또는 성공으로 보이는 채로 데이터가 사라지거나 덮인다.
|
||||
|
||||
**사이클 2 가 더한 것 — 원자 타입을 쓰면서 비교 후 교체를 하지 않는다 (5곳).**
|
||||
|
||||
전수 통독이 찾은 가장 일관된 형태다. 다섯 곳이 `AtomicReference`·`AtomicInteger` 를 선택해 놓고
|
||||
읽고-판단하고-쓰는 세 단계를 원자적으로 묶지 않는다.
|
||||
|
||||
| 위치 | 형태 | 결과 |
|
||||
|---|---|---|
|
||||
| `GrpcCredentialRotationManager.rotate`·`completeDrain` | `get()` 후 조건 없는 `set()` | 회전이 되돌아가 교체된 자격증명이 되살아난다 |
|
||||
| `GrpcChannelRuntimeRegistry.rotate` | 〃 (같은 파일의 `install` 은 CAS 를 쓴다) | 덮인 대체본이 배수도 회수도 되지 않는다 |
|
||||
| `GrpcChannelRuntime.finishUnaryCall`·`closeStream` | `get() > 0` 후 별도 감소 | 음수가 되면 `quiescent()` 가 영원히 거짓 → 세대가 회수 불가 |
|
||||
| `GrpcAdmissionController.tryAdmit`·`release` | 〃 | 경계 초과, 그리고 경계가 영구히 느슨해짐 |
|
||||
| `GrpcStreamAdmission.tryAdmit`·`release` | 〃 + caller 별 맵이 줄지 않음 | 재접속 폭풍에서 가장 많이 샌다 |
|
||||
|
||||
같은 저장소 안에 정본이 둘 있다 — `GrpcRetryBudget.tryConsume` 과 `GrpcHedgingBudget.tryConsume` 이
|
||||
정확한 비교 후 교체 루프다. 그리고 `GrpcDemandController` 는 같은 형태를 `synchronized` 로 닫는다.
|
||||
즉 이 저장소는 올바른 형태를 알고 있고, 다섯 곳에서만 쓰지 않았다.
|
||||
|
||||
**사이클 2 가 더한 것 — 선언되고 주입되지 않는 검증기 (4곳).**
|
||||
|
||||
`StartupProfileValidation` 의 javadoc 이 이 형태를 이미 이름 붙였다 — "the context published a validator
|
||||
per broker and validated nothing." 그 수정이 messaging 에 적용됐는데, 같은 형태가 네 곳에 남아 있다.
|
||||
|
||||
| 검증기 | 상태 |
|
||||
|---|---|
|
||||
| `GrpcPlatformStartupValidator` | 호출자 0 (자기 테스트 제외) |
|
||||
| `KafkaTransactionProfileValidator` | 빈으로 발행되고 `StartupProfileValidation` 에 감싸이지 않음 |
|
||||
| `GrpcApplicationBoundaryRules` | 저장소 소스에 적용하는 코드 없음 |
|
||||
| `GrpcRawApiImportRule` | 〃 (테스트가 인라인 문자열만 판정) |
|
||||
|
||||
**재통독이 다섯 곳을 더 찾았다.** 그리고 그중 셋은 앞의 넷보다 무겁다 — **문서가 그 검증기를 "빌드를
|
||||
실패시키는 것" 이라고 단언하기 때문이다.** 아무도 부르지 않는 검증기는 공백이고, 부른다고 적힌 채
|
||||
아무도 부르지 않는 검증기는 오해다.
|
||||
|
||||
| 검증기 / 게이트 | 상태 | 그렇게 적은 곳 |
|
||||
|---|---|---|
|
||||
| `GrpcProtoContractValidator` | 코드 호출자 0. `*.gradle`·`*.kts`·`*.yml` 어디에도 없음 | `buf.yaml:3-5` 와 `GrpcBufPolicy:8-10` 이 각각 "이것이 이 저장소의 빌드를 실패시킨다" 고 적는다 |
|
||||
| `GrpcBufPolicy` 의 네 수명주기 태스크 | `bufFormatCheck`·`bufLint`·`bufBuild`·`bufBreaking` 이 어떤 빌드 파일에도 없음 | javadoc 이 "a missing stage is a test failure rather than a stage nobody noticed was gone" 라고 적는다. 테스트는 목록을 리터럴·자기 자신과 비교한다 |
|
||||
| `GrpcAdvancedModuleGuard.requireStableStarterIsClean` | 호출자 0 | javadoc 이 "a runtime that was assembled some other way — a fat jar, a shaded artifact, a test harness — is checked too" 라고 적는다 |
|
||||
| `NatsJetStreamProfileValidator` | 코드 호출자 0 · 테스트 0. 흔적은 javadoc `{@link}` 한 줄 | `NatsJetStreamTransport:35` 가 "refuses the combination at startup" 이라고 적는다 |
|
||||
| `PulsarProfileValidator` | 저장소 전체에서 자기 선언 한 줄 말고 아무 데도 없음 | 그 리프 SSOT §4 가 "검증기가 합의를 요구한다" 로 서술했다(이번에 정정) |
|
||||
|
||||
`GrpcProtoContractValidator` 와 `GrpcBufPolicy` 는 서로를 가리킨다 — `buf.yaml` 은 CLI 가 없으니 자바
|
||||
검증기가 게이트라고 하고, `GrpcBufPolicy` 는 태스크 이름이 CI 의 계약이고 자바 검증기가 실제 게이트라고
|
||||
한다. 두 쪽 다 상대가 게이트라고 말하고, 어느 쪽도 실행되지 않는다.
|
||||
|
||||
**형태의 이름.** 앞의 넷은 "만들어졌지만 조립되지 않음"(§3.1 A)이고, 이 다섯은 **"조립되었다고 적힌 채
|
||||
조립되지 않음"** 이다. A 와 F(문서가 코드보다 앞섬)가 같은 지점에서 겹치는 자리이고, 이 저장소의 주석
|
||||
밀도가 높기 때문에 겹칠 때 특히 비싸다 — 읽는 사람이 게이트의 존재를 근거 있게 믿게 된다.
|
||||
|
||||
### 3.8 H — 선언만 있고 코드가 닿지 않는 project 의존 (재통독 신설, 6곳)
|
||||
|
||||
리프 SSOT 는 자기 build.gradle 을 읽지만, "이 의존이 실제로 쓰이는가" 는 그 리프의 import 를 전수로 봐야
|
||||
나온다. 재통독이 그것을 리프마다 확인했고 여섯 곳이 나왔다.
|
||||
|
||||
| 리프 | 선언 | 실제 import |
|
||||
|---|---|---|
|
||||
| `grpc-advanced-edition` | `grpc-core-api` · `grpc-proto-contract` · `grpc-advanced-bootstrap` | **0** — `dev.caskeleton` import 가 한 줄도 없다 |
|
||||
| `grpc-advanced-diagnostics` | 위 셋 중 `grpc-client` 포함 셋 | bootstrap 만 4줄. `grpc-core-api`·`grpc-client` 0 |
|
||||
| `grpc-spring-boot-starter` | `grpc-proto-contract` · `grpc-codegen` · `grpc-operation-ledger-jpa` (implementation) | 셋 다 0 |
|
||||
| `grpc-advanced-streaming` · `-compat` | `grpc-advanced-bootstrap` | 둘 다 0 |
|
||||
| `grpc-testkit` · `grpc-spring-boot-starter` | `grpc-observability` (api) | 둘 다 0 |
|
||||
|
||||
`api` 로 선언된 것은 그 모듈을 쓰는 쪽까지 전파된다. `grpc-observability` 의 경우 Micrometer 가 두 모듈을
|
||||
거쳐 전파되는데, 그 두 모듈은 관측 타입을 하나도 쓰지 않는다.
|
||||
|
||||
**이 유형이 A 와 다른 점.** A 는 코드가 있고 부르는 곳이 없는 것이고, 이것은 **의존 그래프가 코드보다 넓은
|
||||
것**이다. 결과는 반대 방향으로 나타난다 — A 는 기능이 없는 것으로, 이것은 경계가 실제보다 느슨해 보이는
|
||||
것으로. `grpc-spring-boot-starter` 는 이 저장소가 "구성 경계" 라고 이름 붙인 리프이므로, 그 리프의 의존이
|
||||
실제 조립에 필요한 것보다 넓다는 사실은 그 이름이 주장하는 바에 직접 걸린다.
|
||||
|
||||
의도를 읽을 수 있는 경우도 있다 — `grpc-advanced-edition` 의 `grpc-proto-contract` 의존은 그 리프의
|
||||
`compatibility.proto` 가 저쪽 스키마 규칙의 관할이라는 선언으로 읽힌다. 다만 그 관할은 코드로 연결되어
|
||||
있지 않고, `grpc-proto-contract` 의 커밋 스키마 테스트가 파일 목록을 하드코딩해 이 파일을 판정하지 않는다.
|
||||
즉 의존 선언이 표현하려던 관계가 실제로는 어느 쪽에도 없다.
|
||||
|
||||
### 3.6 F — 문서가 코드보다 앞서 있다 (18개 리프, 57건)
|
||||
|
||||
건수로는 A와 동률 1위인데 P1이 하나뿐이다. 대부분 javadoc·README·주석이 이제는 사실이 아닌 것을
|
||||
말하는 형태다. `adapter-outbound-httpclient`의 `BoundedDataBufferFlux`가 javadoc이 처리한다고 적은
|
||||
두 경로가 no-op인 것, `adapter-outbound-fileserver`의 README 주장이 여덟 개 port 구현 앞에서
|
||||
성립하지 않는 것(P2)이 대표적이다.
|
||||
|
||||
이 저장소의 주석 밀도는 이례적으로 높고 — `adapter-outbound-httpclient`의 `build.gradle`은 이 저장소에서
|
||||
가장 긴 근거 주석을 갖는다 — 그 밀도가 자산인 동시에 부채라는 것이 이 유형의 내용이다. 사고를 인용하는
|
||||
주석은 그 사고를 다시 겪지 않게 하지만, 코드가 바뀔 때 함께 바뀌지 않으면 **틀린 근거를 권위 있게**
|
||||
전달한다.
|
||||
|
||||
---
|
||||
|
||||
### 3.7 G — 전송 계열 가정 (사이클 2 신설)
|
||||
|
||||
`EVD-332` 가 사이클 1 의 P1 을 철회시킨 원인은 픽스처의 듀얼스택 호스트명이었다. 즉 **IPv4 만 가정한 코드가
|
||||
IPv6 가 있는 환경에서 다르게 동작한다** 는 형태다. 통독이 같은 형태를 하나 더 찾았다.
|
||||
|
||||
`GrpcDiagnosticsRedactor.maskAddress` 는 IPv4 정규식 하나만 갖고, 맞지 않는 입력을 **그대로 돌려준다.**
|
||||
그리고 스냅숏 생성자의 검사가 "마스킹 결과가 입력과 같으면 이미 마스킹된 것" 이므로, IPv6 주소·호스트 이름·
|
||||
유닉스 소켓 경로가 전부 검사를 통과한다. 이 플랫폼이 겨냥하는 배포 형태가 쿠버네티스이고 헤드리스 레코드의
|
||||
엔드포인트가 파드 DNS 이름이라는 점에서 도달 가능한 형태다.
|
||||
|
||||
두 사례의 공통점은 **주소 표현의 다양성** 이 아니라 **판정의 방향** 이다. 둘 다 "모르는 형태" 를 안전한 쪽이
|
||||
아니라 통과 쪽으로 접었다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 리프 경계를 넘을 때만 보이는 것
|
||||
|
||||
리프 SSOT가 원칙적으로 볼 수 없는 사실을 여기 모은다.
|
||||
|
||||
1. **`adapter-inbound-grpc`·`adapter-inbound-websocket`이 어떤 배포에도 없다.** §2.
|
||||
2. **`adapter-outbound-objectstorage`가 샘플에만 있다.** 출하 애플리케이션에는 없다. §2.
|
||||
3. **`messaging-admin-api`의 토폴로지 BLOCKING 보장을 배선하려면 인스펙터가 필요한데,
|
||||
`messaging-kafka`에는 줄 것이 없다.** 두 리프의 문서가 각자 자기 쪽 절반만 볼 수 있다 —
|
||||
admin-api는 "`@ConditionalOnBean(BrokerTopologyInspector)`가 참이 된 적이 없다"를 보고,
|
||||
kafka는 "`KafkaTopologyInspector` 구현이 0"을 본다. 둘을 겹쳐야 **같은 하나의 미배선**이 된다.
|
||||
4. **`adapter-inbound-web`의 P1 여섯 건은 web 리프가 아니라 `app-bootstrap`이 원인이다.**
|
||||
web 문서가 그 사실을 스스로 지목하지만, 고칠 파일은 다른 리프에 있다.
|
||||
5. **grpc 블록의 Stable/Advanced 경계는 실제로 강제된다.** 사이클 2에서 한 번 반대로 판단했다가
|
||||
`grpc-spring-boot-starter/build.gradle:4-7`의 근거 주석을 따라가 세 겹의 강제
|
||||
(레지스트리 `allowed_dependencies` · `verifyCleanArchitectureDependencies` ·
|
||||
`GrpcPlatformStartupValidatorTest:257`이 실제 `build.gradle`을 읽는 것)를 확인하고 철회했다
|
||||
(`EVD-326`). 남은 것은 카탈로그의 이름 목록이 `modules.json`과 대조되지 않는다는 P3뿐이다.
|
||||
|
||||
---
|
||||
|
||||
6. **같은 자료구조 오용이 두 리프에 있다.** `GrpcCredentialRotationManager` 와
|
||||
`GrpcChannelRuntimeRegistry` 가 각각 `AtomicReference` 를 조건 없는 `set` 으로 쓴다. 두 리프의 문서는
|
||||
각자 자기 쪽만 볼 수 있고, 겹쳐야 "이 가족이 회전을 다루는 방식" 이라는 하나의 사실이 된다. §3.5.
|
||||
7. **Kafka 트랜잭션 검증의 절반이 다른 리프에 있다.** 검증기는 `messaging-kafka` 가 소유하고, 그것을
|
||||
시작 시 부르는 배선은 `messaging-spring-boot-starter` 가 소유한다. 후자에 감싸는 블록이 없어서 전자가
|
||||
돌지 않는다. 어느 쪽 문서도 혼자서는 "이 검증이 실행되지 않는다" 를 말할 수 없다.
|
||||
8. **정책 목록의 가장 강한 성질이 다른 리프의 미완성에 걸려 있다.** `GrpcMethodPolicyCatalog` 의
|
||||
서술자 대조는 이름 변경을 잡는 장치인데, 서술자를 만드는 `grpc-codegen` 이 protoc 을 돌리지 않으므로
|
||||
이 저장소에서는 그 대조를 켤 수 없다. `withDescriptorMethods` 의 production 호출자는 0 이다.
|
||||
10. **같은 문제의 올바른 판본과 틀린 판본이 두 리프에 나란히 있다 — 결정을 그 결정이 판정한 대상에 묶는 것.**
|
||||
`grpc-codegen` 의 `GrpcSchemaArtifactPublisher.publish(candidate, decision)` 는 `decision.allowed()` 만
|
||||
보고 기록한다. `PublishDecision` 은 자기가 무엇을 판정했는지 들고 있지 않으므로, A 를 평가한 결정으로
|
||||
B 를 발행할 수 있고 그러면 소비자 게이트와 버전 불변성을 둘 다 우회한다(그쪽 §17.4).
|
||||
`grpc-advanced-bootstrap` 의 `GrpcAdvancedSupportMatrix.apply(decision)` 는 정반대다 — 결정의 `from` 이
|
||||
현재 등급과 다르면 던지고, 그 이유를 "두 승격이 경합했거나 하나가 재생된 경우" 라고 적는다.
|
||||
같은 저장소가 같은 형태를 한 번은 맞게, 한 번은 틀리게 썼다. §3.5 의 check-then-act 계열과 같은 뿌리이나
|
||||
여기서는 경합이 아니라 **인자 짝 맞추기**가 깨진 자리다.
|
||||
|
||||
11. **"실환경 증거" 의 정의와 그 요구가 다른 리프에 있고 서로를 부르지 않는다.**
|
||||
`grpc-advanced-diagnostics` 의 `GrpcAdvancedInfrastructureTestkit` 이 능력별로 무엇이 실환경인지
|
||||
정의한다(gRPC-Web 프록시 · 서블릿 컨테이너 · 멈출 수 있는 xDS 통제 평면 · 코틀린 툴체인).
|
||||
`grpc-advanced-bootstrap` 의 `GrpcAdvancedPromotionEvidence` 는 그것을 `boolean realEnvironmentTest`
|
||||
하나로 받는다. 두 쪽이 만나지 않으므로 `complete(XDS, 7일)` 이 통제 평면 없이도 참을 넣는다 —
|
||||
테스트킷 javadoc 이 경계한 "a suite that runs without the infrastructure passes and establishes
|
||||
nothing" 을 승격 게이트가 그대로 통과시킨다.
|
||||
|
||||
12. **승격 기준이 같은 능력에 대해 두 게이트에서 다르다.** `EDITION_2024` 는 `GrpcAdvancedPromotionGate`
|
||||
에서 증거 일곱 항목 + 7일 담금을 요구받고, `GrpcEdition2024Gate` 에서 호환성 보고서 + 소비자 이관 +
|
||||
ADR 을 요구받는다. 어느 쪽도 상대를 부르지 않고 관계가 문서에도 없다. 그리고 전자에는 별도 결함이 있다 —
|
||||
30일 담금 갈래가 열거형에 없는 등급(`STABLE_DEFAULT`)을 위해 쓰여, `ADVANCED_STABLE` 이 아닌 목표 전부를
|
||||
삼킨다. 그 결과 `WATCH → EXPERIMENTAL`(WATCH 가 밟도록 강제된 유일한 첫 걸음)이 30일을 요구하고
|
||||
`EXPERIMENTAL → ADVANCED_STABLE` 은 7일을 요구한다 — 중간 등급이 상위 등급보다 어렵다.
|
||||
그 갈래를 실행하는 테스트가 `ADVANCED_STABLE → DISABLED`(철회)를 골라 놓고 이름을
|
||||
"becoming a Stable default" 라고 붙인 것이 그 뒤틀림의 흔적이다. (`grpc-advanced-bootstrap` §17.4)
|
||||
|
||||
9. **Stable 모듈 목록과 레지스트리를 붙드는 장치가 없다.** `GrpcStableModuleCatalog` 의 javadoc 은
|
||||
`GrpcStableModuleCatalogTest` 가 둘을 함께 붙든다고 적지만, 그 테스트는 `modules.json` 을 읽지 않고
|
||||
목록을 리터럴과 대조한다. 두 집합은 오늘 일치한다(12 + 6 = 레지스트리의 grpc 계열 18). 어긋난 것은
|
||||
그 일치를 무엇이 지키는가다. messaging 가족이 같은 형태를 이미 기록했다 — "세는 순간 다시 drift 한다."
|
||||
|
||||
---
|
||||
|
||||
## 5. 측정 방법에 대해 이 사이클이 배운 것
|
||||
|
||||
사이클 2가 만든 판정 번복 한 건과 자기 교정 세 건은 모두 같은 형태의 실수에서 나왔다.
|
||||
**코드를 읽고 런타임의 모양을 추론한 뒤, 그 추론을 측정으로 확인하지 않은 것.**
|
||||
|
||||
- `EVD-332` — 분기 순서를 읽고 예외 사슬의 모양을 단정했다. 사슬을 출력하니 달랐다.
|
||||
- `EVD-326` — 레지스트리에 검사가 없다고 단정했다. `build.gradle`의 주석이 가리키는 세 곳을 따라가니 있었다.
|
||||
- `PulsarPreSendRejection` — 전역 승인 게이트가 임계값을 읽을 것이라고 가정했다. `PayloadLimitGuard`는 그 필드를 읽지 않았다.
|
||||
- outbox 무제한 DELETE의 서술 — "프로덕션 기본 경로"라고 적었다. 자동설정은 스케줄러를 등록하지 않는다.
|
||||
|
||||
네 건 모두 **하나의 추가 측정이면 갈렸다**. 이 저장소의 코드는 자기 근거를 주석으로 남기는 밀도가 높아서
|
||||
읽는 것만으로 확신이 생기기 쉽고, 바로 그 점이 함정이다. 사이클 3에 남기는 규칙은 하나다 —
|
||||
**런타임의 모양에 대한 주장은 런타임에서 확인한다.** jshell로 사슬을 출력하는 데 든 비용은 몇 분이었고,
|
||||
그 몇 분이 P1 하나를 지웠다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 확인하지 못한 것
|
||||
|
||||
- **다른 두 전송 분류기.** `ReactorFailureClassifier`·`JdkFailureClassifier`는 개별 실행으로 확인하지
|
||||
않았다. `EVD-332`가 반증한 것은 "Apache가 TLS 실패를 연결 예외로 감싼다"는 전제이므로 그 전제에
|
||||
기대던 열린 항목은 소멸하지만, 두 분류기 자체를 측정한 것은 아니다.
|
||||
- **`jq`가 있는 기계에서의 `verify-compose-profile-contracts.sh`.** 파이썬 이식본으로 15개 레인이
|
||||
계약과 일치함을 확인했으나, 이는 테스트 자신이 경계한 "두 번째 의견"이다. 원본 스크립트 실행이 정본이다.
|
||||
- **422건 중 268건의 기계 분류.** 제목이 짧아 유형 분류가 되지 않았다. §3의 수치는 하한이다.
|
||||
그리고 그 분류는 **재통독 이전의 422건에 대해 돌린 것**이다. 23개 리프가 60→100 으로 늘어난 뒤 다시
|
||||
돌리지 않았으므로, §3 의 유형별 건수는 새로 추가된 40건을 반영하지 않는다. §3.5 의 검증기 표와 §3.8 은
|
||||
기계 분류가 아니라 재통독에서 직접 확인해 손으로 적은 것이다.
|
||||
- **전체 462건의 직접 재측정.** §0 이 밝힌 대로 462는 델타 조정값이고 61개 문서를 다시 센 값이 아니다.
|
||||
- **`sample-portfolio`.** 레지스트리에서 EXCLUDED이며 이 사이클의 분석 대상이 아니다.
|
||||
- **런타임 컴포지션의 실제 기동.** compose 계약은 정적으로만 검증했다. 아무 스택도 기동하지 않았다.
|
||||
- **사이클 2 통독이 만든 92건의 실행 확인.** 전부 코드 통독과 정적 대조로 판정했다. 두 가족 모두 배선 경로가
|
||||
없거나(grpc 18개 리프) 선택할 수 없어서(rabbit), 실행으로 재현할 대상이 애초에 없다. 예외는
|
||||
`grpc-testkit` 의 세 레인으로, 이번에 직접 돌려 통과를 확인했다(계약 7 · Netty 9 · 고장 9).
|
||||
- **재통독의 도달성 판정 방법.** 리프마다 `grep` 으로 타입 이름·패키지 이름을 훑어 리프 밖 참조를 셌다.
|
||||
리플렉션·서비스 로더·문자열 기반 조립으로 닿는 경로가 있다면 이 방법으로는 잡히지 않는다. 이 저장소가
|
||||
그런 조립을 쓰는 곳은 발견하지 못했으나, 찾아본 것이 아니라 마주치지 않은 것이다.
|
||||
- **23개 리프의 테스트 실행.** 재통독은 테스트 본문을 전부 읽었지만 이번 판에서 다시 돌리지는 않았다.
|
||||
"이 단언은 항상 통과한다" 류의 판정(`messaging-nats-experimental` §17.4 등)은 단언 의미론으로 내린 것이다.
|
||||
예외는 `grpc-testkit` 의 세 레인으로, 앞선 판에서 직접 돌려 통과를 확인했다.
|
||||
- **`@ConditionalOnBean` 사슬의 실제 평가.** `MessagingReliabilityAutoConfiguration` 이 같은 클래스 안에서
|
||||
방금 선언한 빈을 조건으로 삼는다. 지금은 그 앞 조건이 만족되지 않아 셋 다 만들어지지 않으므로 결과가
|
||||
드러나지 않는다. 스프링의 문서화된 제약으로 판정했고 컨텍스트로 재현하지 않았다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
사이클 1 초안이 열린 질문 다섯 개를 번호로 관리했다. 그 제목이 `candidate-ledger.json`에
|
||||
보존되어 있어 아래에 그대로 되살린다 — 초안 본문은 복구되지 않았으므로, 각 항목의 내용은
|
||||
사이클 2가 실제로 확인한 것과 확인하지 못한 것으로 다시 썼다.
|
||||
|
||||
### 남은 질문 1 — 컨테이너·브로커·DB가 필요한 레인의 실제 결과
|
||||
|
||||
사이클 2는 Docker 가용을 확인하고(client 29.1.3 / server 29.6.1) Testcontainers 레인을 실제로 돌렸다 —
|
||||
persistence-jpa 477, persistence-mongo 72, cache-redis 435 테스트가 전부 통과했고, kafka 인증 레인과
|
||||
실 브로커 왕복, Postgres IT, grpc-testkit 엄격 레인 넷도 실행했다. 그러나 **전부는 아니다.**
|
||||
`jpaPlatformFailureTest`는 이 리비전에서 실행하지 않았고(§6의 커밋 모호성 항목), mongo 쪽 컨테이너 레인 중
|
||||
릴리스를 막지 않는 것들도 실행하지 않았다. 어느 레인이 실행됐고 어느 레인이 아닌지는 `EVD-334`가 목록으로 갖는다.
|
||||
|
||||
### 남은 질문 2 — sample-portfolio 내부
|
||||
|
||||
레지스트리에서 `EXCLUDED`이며 사용자 지시에 따라 이 사이클의 분석 대상이 아니다. 이 리프에만 속하는
|
||||
`adapter-outbound-objectstorage`가 출하 애플리케이션에 없다는 사실(§2)은 레지스트리 대조로 확인했지만,
|
||||
샘플 내부의 도메인 모델과 그 조립은 읽지 않았다.
|
||||
|
||||
### 남은 질문 3 — 런타임 관측
|
||||
|
||||
compose 계약은 정적으로만 검증했다. 어떤 스택도 기동하지 않았고, 애플리케이션을 부팅해 액추에이터나
|
||||
조건 평가 리포트를 읽지도 않았다. 부팅 한 번이면 확증되는 정적 추론이 최소 두 건 남아 있다 —
|
||||
messaging 관측 시리즈 부재와 `@ConditionalOnBean` 사슬의 실제 평가 결과다.
|
||||
|
||||
### 남은 질문 4 — `@ConditionalOnBean` 실제 평가 순서
|
||||
|
||||
`@ConditionalOnBean`이 클래스 파싱 시점에 평가되어 빈이 사라지는 계열의 판정은 코드와 javadoc의 사후
|
||||
기록에 근거한다. 이 리비전에서 `ConditionEvaluationReport`를 읽어 실제 평가 순서와 결과를 확인하지 않았다.
|
||||
`debug=true`로 부팅 한 번이면 확인된다.
|
||||
|
||||
### 남은 질문 5 — 성능·용량 주장
|
||||
|
||||
이 사이클은 성능을 측정하지 않았다. 문서에 남은 성능·용량 관련 서술은 전부 코드가 선언한 상한과 그 강제
|
||||
여부에 대한 것이며, 실제 처리량·지연·자원 사용에 대한 주장은 하지 않는다. httpclient의 성능 레인과 jmh
|
||||
벤치마크는 기계 의존적이라는 이유로 `check`에서 빠져 있고, 이 사이클에서도 돌리지 않았다.
|
||||
|
||||
## 7. 이 사이클의 작업 제약
|
||||
|
||||
- 애플리케이션 소스는 한 줄도 수정하지 않았다.
|
||||
- 패키지를 설치하지 않았다(`jq` 포함).
|
||||
- 사용자 워크스페이스의 빌드 산출물을 삭제하지 않았다.
|
||||
- git 상태를 변경하지 않았다 — 커밋·푸시·리셋·클린 없음.
|
||||
- 컨테이너를 기동하지 않았다. Testcontainers를 쓰는 lane은 저장소 자신의 테스트가 기동한 것이다.
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/config/architecture/modules.json (62개 리프의 runtime_memberships)
|
||||
src/build.gradle:477-492 (사이클 1→2 사이 변경 지점)
|
||||
src/grpc/CLAUDE.md:74-98 (grpc 블록 build-only 상태)
|
||||
src/grpc/grpc-spring-boot-starter/build.gradle:4-7 (Stable/Advanced 경계 근거)
|
||||
scripts/verify-compose-profile-contracts.sh:20-30,42-160
|
||||
src/config/runtime/compose-profile-contracts.json (15개 레인)
|
||||
|
||||
analysis/01-domain-core.md … analysis/18-app-bootstrap.md (18개 리프 SSOT)
|
||||
analysis/messaging/*.md (25개)
|
||||
analysis/grpc/*.md (18개)
|
||||
analysis/19-messaging-platform.md, analysis/20-grpc-platform.md (가족 문서, INTEGRATION_ONLY)
|
||||
|
||||
evidence/raw/325-grpc-block-is-entirely-unreachable.txt
|
||||
evidence/raw/326-grpc-stable-advanced-boundary-has-no-registry-check.txt
|
||||
evidence/raw/328-grpc-boundary-rules-never-see-real-source.txt
|
||||
evidence/raw/329-grpc-two-rule-engines-one-fed.txt
|
||||
evidence/raw/332-httpclient-dualstack-localhost-masks-tls-permanent.txt
|
||||
evidence/raw/333-eighteen-docs-source-drift-zero.txt
|
||||
evidence/raw/334-eighteen-leaf-lane-rerun.txt
|
||||
```
|
||||
@@ -0,0 +1,253 @@
|
||||
# grpc-admin 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 — `src/main` production 12파일 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc/grpc-admin`
|
||||
> SSOT owner: `grpc-admin`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `allowed_dependencies`: `["grpc-core-api", "grpc-server"]`
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
|
||||
| 파일 | LOC |
|
||||
|---|---:|
|
||||
| `GrpcDrainCoordinator` | 162 |
|
||||
| `GrpcServiceHealthRegistry` | 154 |
|
||||
| `GrpcPlatformSnapshotService` | 115 |
|
||||
| `GrpcReflectionPolicy` | 74 |
|
||||
| `GrpcPlatformSnapshot` · `GrpcAdminExposurePolicy` | 71 · 71 |
|
||||
| `GrpcHealthPolicy` · `GrpcDrainResult` · `GrpcDrainPolicy` | 49 · 48 · 43 |
|
||||
| `GrpcHealthState` · `GrpcReflectionMode` | 38 · 32 |
|
||||
| `GrpcReflectionAccessDecision` · `GrpcDrainPhase` | 28 · 28 |
|
||||
| test 4파일 | 490 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 12 | `FULL_READ` | 전 본문 축자 확인 |
|
||||
| `test/java/**` | 4 | `FULL_READ` | 490줄 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 8줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-4
|
||||
// Operational surface: the standard health registry, the reflection exposure policy, the drain
|
||||
// coordinator, and the secret-free runtime policy snapshot an administrator reads.
|
||||
```
|
||||
|
||||
## 2. 건강 레지스트리 — 낙관에서 시작하지 않는다
|
||||
|
||||
모든 등록 서비스가 `UNKNOWN` 에서 시작한다.
|
||||
|
||||
> "A registry that starts optimistic reports ready during startup, receives traffic before the first
|
||||
> dependency check has run, and fails the requests that arrive in that window — the window being
|
||||
> exactly the moment a rollout is shifting traffic onto the instance."
|
||||
|
||||
그리고 배수 중에는 `markServing`·`markNotServing` 이 무시된다.
|
||||
|
||||
> "a service that reports itself healthy after the drain has started would be routed traffic the
|
||||
> instance has already promised not to take."
|
||||
|
||||
전역 상태 계산은 세 단계다 — 임계 의존이 하나라도 불건강하면 `NOT_SERVING`, 아니면 하나라도 `NOT_SERVING` 이면 `NOT_SERVING`, 하나라도 `SERVING` 이면 `SERVING`, 그 밖에는 `UNKNOWN`.
|
||||
|
||||
## 3. 배수 순서
|
||||
|
||||
```
|
||||
READINESS_FALSE → HEALTH_DRAINING → REJECT_NEW_ADMISSION → DRAIN_UNARY → SIGNAL_STREAMS → FORCE_CANCEL
|
||||
```
|
||||
|
||||
`beginDrain` 이 앞의 둘을 한 번에 수행하고, 그 전에 `rejectNewAdmission` 을 부르면 던진다.
|
||||
|
||||
> "refusing calls before readiness has flipped produces errors for traffic that routing is still
|
||||
> sending"
|
||||
|
||||
조정자는 잠들지 않는다.
|
||||
|
||||
> "It is given the current moment and the counts, and returns whether the phase is done; the waiting
|
||||
> belongs to the caller, which is what makes every branch of this testable without a clock."
|
||||
|
||||
예산은 누적이다 — 스트림 신호 완료 판정이 `unaryDrainBudget + streamSignalBudget` 을 기준으로 한다.
|
||||
|
||||
## 4. 두 게이트 규칙이 세 곳에 같은 형태로 있다
|
||||
|
||||
| 타입 | 두 게이트 |
|
||||
|---|---|
|
||||
| `GrpcReflectionPolicy`(ADMIN_ONLY) | 관리 네트워크 + 관리 역할 |
|
||||
| `GrpcAdminExposurePolicy` | 〃 |
|
||||
| `GrpcChannelDiagnosticsPolicy`(grpc-advanced-diagnostics) | 〃 |
|
||||
|
||||
> "a role check alone lets an admin credential leaked to the public network enumerate the schema,
|
||||
> and a network check alone lets anyone who reaches the admin network do it."
|
||||
|
||||
그리고 반사 가시성과 메서드 인가를 분리한다.
|
||||
|
||||
> "A method that reflection reveals is not thereby callable, and a method reflection hides is not
|
||||
> thereby protected. Conflating the two produces a schema treated as a secret and an authorization
|
||||
> check nobody wrote."
|
||||
|
||||
## 5. 스냅숏
|
||||
|
||||
권한이 없으면 편집본이 아니라 빈 값을 돌려준다.
|
||||
|
||||
> "Empty rather than a redacted snapshot: a partial answer tells an unauthorized caller which
|
||||
> services exist."
|
||||
|
||||
내용이 아니라 해시를 싣는다. 그리고 판본과 시각을 필수로 요구한다 — 사고 중의 질문은 "무엇이 도는가" 가 아니라 "무엇이 바뀌었는가" 다.
|
||||
|
||||
`driftAgainstRelease` 가 양방향을 본다 — 릴리스에 있고 인스턴스에 없는 채널, 인스턴스에 있고 릴리스에 없는 채널을 모두 보고한다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
네 테스트 490줄. 배수 순서와 예산, 건강 전이, 반사 결정, 스냅숏 게이트와 표류를 확인한다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** build-only. 이 리프의 타입 중 셋(`GrpcServiceHealthRegistry`·`GrpcReflectionPolicy`·`GrpcAdminExposurePolicy`·`GrpcDrainPolicy`)은 `grpc-spring-boot-starter` 자동 설정이 빈으로 만든다. `GrpcDrainCoordinator`·`GrpcPlatformSnapshotService` 는 만들지 않는다.
|
||||
|
||||
**12.2 대조군.** 비밀 필드 패턴이 두 리프에 따로 있다 — 이쪽의 `SECRET_FIELD`(9종)와 `grpc-advanced-diagnostics` 의 `SENSITIVE_FIELD`(10종). 겹치지만 같지 않다. 이쪽에는 `api[_-]?key`·`passphrase` 가 있고 저쪽에는 `payload`·`metadata`·`trace_id` 가 있다. 두 표면이 다르므로 목록이 다른 것 자체는 합리적이다.
|
||||
|
||||
**12.4 드리프트.** build.gradle 이 서술한 네 요소가 전부 존재한다. 드리프트 없음.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 실제 서버를 띄워 배수를 돌리지 않았다. 배선 경로가 없다.
|
||||
- `GrpcAdmissionController` 를 배수 중에 호출해 §17.1 을 재현하지 않았다. 두 클래스의 공개 표면으로 판정했다.
|
||||
- §17.4 의 교차를 실행으로 재현하지 않았다. `markServing` 의 확인과 실행이 분리되어 있고 `beginDraining` 의 두 문장 사이에 창이 있다는 것으로 판정했다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P2 — `rejectNewAdmission()` 이 단계만 기록하고 아무것도 거절하지 않는다
|
||||
|
||||
```java
|
||||
public void rejectNewAdmission() {
|
||||
requireStarted();
|
||||
phasesRun.add(GrpcDrainPhase.REJECT_NEW_ADMISSION);
|
||||
}
|
||||
|
||||
public boolean admittingNewCalls() {
|
||||
return !phasesRun.contains(GrpcDrainPhase.REJECT_NEW_ADMISSION);
|
||||
}
|
||||
```
|
||||
|
||||
javadoc 은 "Starts refusing new calls" 라고 적는다. 실제로 하는 일은 단계 목록에 표식을 넣는 것뿐이다.
|
||||
|
||||
조정자는 `GrpcAdmissionController` 를 협력자로 들고 있는데, 그것을 쓰는 곳은 `inFlightAdmitted()` 의 조회 하나다.
|
||||
|
||||
그리고 승인 제어기의 공개 표면에 승인을 멈추는 메서드가 없다.
|
||||
|
||||
```
|
||||
GrpcAdmissionController — tryAdmit · promoteFromQueue · release · inFlight · queued · rejected
|
||||
```
|
||||
|
||||
`close`·`drain`·`refuseNew` 에 해당하는 것이 없다. 그러므로 배수가 시작된 뒤에도 `tryAdmit()` 은 용량이 남아 있는 한 계속 승인한다.
|
||||
|
||||
`admittingNewCalls()` 은 그 사실과 무관하게 거짓을 돌려준다 — 표식을 읽기 때문이다. 운영자나 상위 코드가 이 값을 보고 "더 이상 받지 않는다" 고 읽으면 틀린 답을 얻는다.
|
||||
|
||||
**테스트가 이것을 볼 수 없다.** 배수 테스트가 단언하는 것은 `admittingNewCalls()` 의 값이고, 단계 이후에 `tryAdmit()` 이 거절되는지는 어느 테스트도 묻지 않는다.
|
||||
|
||||
**수정.** 승인 제어기에 승인 중단 상태를 두고(`stopAdmitting()` 과 그것을 보는 `tryAdmit`), 조정자의 `rejectNewAdmission` 이 그것을 부르게 한다. 지금 형태에서는 배수 순서를 지키는 장치가 순서 표식만 갖고 있다.
|
||||
|
||||
### 17.2 P3 — 비밀 필드 검사가 스냅숏의 네 구획 중 하나에만 적용된다
|
||||
|
||||
```java
|
||||
List<String> forbidden = GrpcAdminExposurePolicy.forbiddenFields(channelProfileHashes);
|
||||
if (!forbidden.isEmpty()) {
|
||||
throw new IllegalArgumentException("a platform snapshot must not carry " + forbidden + "; hashes and names only");
|
||||
}
|
||||
```
|
||||
|
||||
메시지는 "a platform snapshot must not carry …" 로 스냅숏 전체를 말한다. 검사 대상은 `channelProfileHashes` 하나다.
|
||||
|
||||
같은 채널 이름 공간을 쓰는 두 맵이 더 있다 — `resolverAndLoadBalancerByChannel`, `retryOwnerByChannel`. 그리고 `registeredServices` 목록과 `serviceHealth` 맵이 있다. 어느 것도 검사되지 않는다.
|
||||
|
||||
세 맵의 키 집합이 같아야 한다는 요구가 없으므로, 어떤 채널이 나머지 두 맵에만 있으면 그 이름은 검사를 지나지 않는다.
|
||||
|
||||
`grpc-advanced-diagnostics` 의 스냅숏은 같은 형태의 자기 검사를 두 구획(주소 목록, 자원 판본 키)에 적용한다. 두 리프의 규율이 갈린다.
|
||||
|
||||
수정은 네 구획 전부를 같은 검사에 넣는 것이다. 값이 아니라 키를 보는 검사이므로 비용이 낮다.
|
||||
|
||||
### 17.3 P3 — 배수 조정자가 가변이고 동기화가 없다
|
||||
|
||||
`phasesRun`(`ArrayList`), `startedAt`, `completedUnaryCalls`, `signalledStreams` 가 평범한 필드다. `synchronized`·`volatile`·동시 자료구조가 없다.
|
||||
|
||||
같은 리프의 건강 레지스트리는 정반대다 — `ConcurrentHashMap` 둘과 `volatile boolean draining`. 즉 이 리프는 동시성을 인지하고 있고 한 클래스에만 적용했다.
|
||||
|
||||
조정자의 javadoc 이 대기를 호출자에게 맡긴다고 적으므로 단일 호출자 전제로 읽을 수 있다. 다만 그 전제가 자바독에 적혀 있지 않고, `unaryDrainComplete` 는 반복 호출을 전제한 형태라 종료 훅과 상태 조회가 다른 스레드에서 닿기 쉽다.
|
||||
|
||||
수정은 단일 스레드 전제를 자바독에 적거나, 형제 클래스와 같은 수준으로 맞추는 것이다.
|
||||
|
||||
### 17.4 P2 — 배수 시작이 확인 후 실행이라, 배수 중에 한 서비스가 다시 `SERVING` 이 될 수 있다
|
||||
|
||||
§17.3 은 조정자가 동기화 없이 가변이고 건강 레지스트리는 "정반대" 라고 적었다. 레지스트리 쪽을 다시 읽으면 자료구조는 정반대이지만 규율은 같은 자리에서 깨진다.
|
||||
|
||||
```java
|
||||
public void markServing(String serviceName) {
|
||||
requireServiceName(serviceName);
|
||||
if (draining) { return; } // ← 확인
|
||||
states.put(serviceName, GrpcHealthState.SERVING); // ← 실행
|
||||
recomputeGlobal();
|
||||
}
|
||||
|
||||
public void beginDraining() {
|
||||
draining = true; // ①
|
||||
states.replaceAll((service, state) -> GrpcHealthState.DRAINING); // ②
|
||||
}
|
||||
```
|
||||
|
||||
`draining` 이 `volatile` 이므로 가시성은 문제가 아니다. 문제는 순서다. 건강 검사 스레드가 ① 이전에 `if (draining)` 을 통과하고 ② 이후에 `states.put(..., SERVING)` 을 실행하면, 그 서비스는 배수 중에 `SERVING` 으로 남는다. 그리고 같은 호출이 이어서 `recomputeGlobal()` 을 부르므로 **전역 상태까지 `SERVING` 으로 돌아간다** — `anyNotServing` 이 거짓이고 `anyServing` 이 참이기 때문이다.
|
||||
|
||||
그러면 `ready()` 가 참을 답하고, 로드밸런서는 이 인스턴스로 다시 트래픽을 보낸다. 이 클래스의 javadoc 이 막겠다고 한 것이 정확히 그것이다.
|
||||
|
||||
> "Ignored while draining: a service that reports itself healthy after the drain has started would be
|
||||
> routed traffic the instance has already promised not to take."
|
||||
|
||||
창은 좁다. 건강 검사는 주기적이고 배수는 한 번이므로, 겹치려면 검사가 배수 시작을 가로질러야 한다. 그리고 겹치는 순간이 정확히 롤아웃 중 — 즉 트래픽이 옮겨지는 중 — 이라는 것이 이 가족의 다른 자리에서 반복해서 나오는 논거다(§17.1 의 "readiness 를 먼저" 도 같은 창을 다룬다).
|
||||
|
||||
`recordDependencyHealth` 도 같은 형태다 — `dependencyHealth.put(...)` 뒤에 `if (!draining) recomputeGlobal();` 을 부르므로, 같은 교차에서 전역을 되살릴 수 있다.
|
||||
|
||||
**시험이 보지 못하는 이유.** 레지스트리 시험은 단일 스레드이고, `beginDraining()` 뒤에 `markServing` 을 부르는 사례는 순차적으로만 확인한다 — 그 경로에서는 가드가 정확히 작동한다.
|
||||
|
||||
**수정.** 상태 전이를 하나의 원자 연산으로 만든다 — `states.computeIfPresent(service, (k, v) -> draining ? v : SERVING)` 처럼 `draining` 을 맵 연산 안에서 읽거나, `beginDraining` 이 `replaceAll` 을 마친 뒤 한 번 더 `replaceAll` 을 돌려 늦게 들어온 쓰기를 덮는다. 후자는 창을 좁힐 뿐이므로 전자가 맞다.
|
||||
|
||||
**등급.** `GrpcServiceHealthRegistry` 는 `grpc-spring-boot-starter` 가 빈으로 만드는 셋 중 하나다(§12.1). 다만 `beginDraining()` 을 부르는 production 코드는 `GrpcDrainCoordinator` 이고 그것은 조립되지 않으므로, 오늘 이 창이 열리지는 않는다. 배수를 배선하는 날 §17.1 과 함께 봐야 한다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **모든 서비스를 `UNKNOWN` 에서 시작하는 것과 그 근거.**
|
||||
- **배수 중 건강 보고를 무시하는 것.**
|
||||
- **읽기 준비 해제를 승인 거절보다 먼저 두고, 순서를 어기면 던지는 것.**
|
||||
- **조정자가 잠들지 않고 시각과 개수를 인자로 받는 것** — 모든 분기가 시계 없이 검증 가능하다.
|
||||
- **두 게이트 규칙을 세 정책에 같은 형태로 둔 것.**
|
||||
- **반사 가시성과 메서드 인가를 분리한 것.**
|
||||
- **권한 없는 호출에 편집본이 아니라 빈 값을 주는 것.**
|
||||
- **스냅숏이 내용이 아니라 해시를 싣고, 판본과 시각을 요구하는 것.**
|
||||
- **표류 비교가 양방향인 것.**
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc/grpc-admin/build.gradle:1-8
|
||||
main/java/…/admin/GrpcDrainCoordinator.java:1-162
|
||||
main/java/…/admin/GrpcServiceHealthRegistry.java:1-154
|
||||
main/java/…/admin/GrpcPlatformSnapshotService.java:1-115
|
||||
main/java/…/admin/GrpcReflectionPolicy.java:1-74
|
||||
main/java/…/admin/GrpcPlatformSnapshot.java:1-71
|
||||
main/java/…/admin/GrpcAdminExposurePolicy.java:1-71
|
||||
main/java/…/admin/(GrpcHealthPolicy · GrpcDrainResult · GrpcDrainPolicy · GrpcHealthState · GrpcReflectionMode · GrpcReflectionAccessDecision · GrpcDrainPhase)
|
||||
test/java/…/admin/(GrpcDrainCoordinatorTest · GrpcPlatformSnapshotServiceTest · GrpcServiceHealthRegistryTest · GrpcReflectionPolicyTest)
|
||||
src/grpc/grpc-server/…/server/GrpcAdmissionController.java (공개 표면 대조)
|
||||
```
|
||||
@@ -0,0 +1,359 @@
|
||||
# grpc-advanced-bootstrap 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 — `src/main` production 9파일 610줄, test 2파일 279줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc-advanced/grpc-advanced-bootstrap`
|
||||
> SSOT owner: `grpc-advanced-bootstrap`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `allowed_dependencies`: `["grpc-core-api"]` — Advanced 는 Stable 공개 타입에 의존하고 그 반대는 없다
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
|
||||
| 파일 | LOC | 패키지 |
|
||||
|---|---:|---|
|
||||
| `GrpcAdvancedFeatureFlags` | 105 | bootstrap |
|
||||
| `GrpcAdvancedModuleGuard` | 84 | bootstrap |
|
||||
| `GrpcAdvancedCapability` | 65 | bootstrap |
|
||||
| `GrpcAdvancedCapabilityDisabledException` | 42 | bootstrap |
|
||||
| `GrpcCapabilityGrade` | 38 | bootstrap |
|
||||
| `GrpcAdvancedPromotionGate` | 85 | release |
|
||||
| `GrpcAdvancedPromotionEvidence` | 75 | release |
|
||||
| `GrpcAdvancedSupportMatrix` | 66 | release |
|
||||
| `GrpcAdvancedPromotionDecision` | 50 | release |
|
||||
| **main 합계** | **610** | |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 9 | `FULL_READ` | 610줄 전 본문 |
|
||||
| `test/java/**` | 2 | `FULL_READ` | 279줄(150+129) · 테스트 17개 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 12줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-9
|
||||
// The Advanced boundary itself: capability grades, the `ca-skeleton.grpc.advanced.*` feature-flag
|
||||
// contract, the module guard that refuses an unflagged capability, and the per-capability
|
||||
// promotion gate.
|
||||
// This leaf depends on Stable public types and never the other way round.
|
||||
```
|
||||
|
||||
## 2. 능력 15종과 등급 4종
|
||||
|
||||
능력을 하나씩 등급 매기는 것이 설계다.
|
||||
|
||||
> "Bundling them under one 'advanced' flag makes enabling gRPC-Web — a compatibility bridge with a
|
||||
> proxy in front of it — the same decision as enabling xDS, which brings a control plane and its
|
||||
> outage modes. They are not the same decision, and a single switch is how the second one gets made
|
||||
> by accident."
|
||||
|
||||
| 등급 | 시작 가능 | production 별도 승인 |
|
||||
|---|---|---|
|
||||
| `ADVANCED_STABLE` | 예 | 아니오 |
|
||||
| `EXPERIMENTAL` | 예 | **예** |
|
||||
| `WATCH` | 아니오 | — |
|
||||
| `DISABLED` | 아니오 | — |
|
||||
|
||||
기본 등급 분포는 `ADVANCED_STABLE` 11, `EXPERIMENTAL` 3(`HEDGING`·`CUSTOM_LOAD_BALANCER`·`XDS`), `WATCH` 1(`EDITION_2026`)이다.
|
||||
|
||||
`EXPERIMENTAL` 에 두 번째 승인을 요구하는 근거가 적혀 있다.
|
||||
|
||||
> "The flag says somebody wanted the feature; the approval says somebody accepted that its failure
|
||||
> modes are not fully characterised, which is a different person's decision on most teams."
|
||||
|
||||
## 3. 게이트가 세 조건을 순서대로 본다
|
||||
|
||||
```java
|
||||
if (!flags.flagSet(capability)) → "its feature flag is not set"
|
||||
if (!grade.startable()) → "it is graded WATCH, which cannot start"
|
||||
if (production && requiresApproval && !approved) → "production needs a separate approval"
|
||||
```
|
||||
|
||||
> "Collapsing them into one boolean produces a 'not enabled' message for three situations with three
|
||||
> different remedies."
|
||||
|
||||
`requireStableStarterIsClean` 이 같은 불변식을 런타임에서도 확인한다 — 팻 자, 셰이드 산출물, 테스트 하네스처럼 다른 방식으로 조립된 런타임을 위해서다. **다만 그 메서드를 부르는 런타임이 없다**(§12.1). 지금 그 검사를 실행하는 것은 이 리프의 자기 테스트뿐이다.
|
||||
|
||||
## 4. 승격 게이트
|
||||
|
||||
증거는 능력마다 따로 기록된다.
|
||||
|
||||
> "a shared record makes promoting one of them promote whichever others happened to be measured at
|
||||
> the same time."
|
||||
|
||||
일곱 항목(호환성·보안 검토·고장·성능·ADR·런북·실환경 테스트)과 담금 기간을 본다. 임계값이 둘이다.
|
||||
|
||||
```
|
||||
ADVANCED_STABLE_SOAK = 7일
|
||||
STABLE_DEFAULT_SOAK = 30일
|
||||
```
|
||||
|
||||
> "becoming a Stable default means every deployment gets it, which additionally puts its
|
||||
> dependencies on every classpath and its failure modes in every on-call rotation."
|
||||
|
||||
그리고 `WATCH` 는 `EXPERIMENTAL` 을 먼저 거쳐야 한다.
|
||||
|
||||
`GrpcAdvancedSupportMatrix.apply` 는 결정의 시작 등급이 현재 등급과 다르면 거부한다 — 두 승격이 경합했거나 하나가 재생된 경우다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
두 테스트 279줄, 17개 테스트. 게이트의 세 조건, 능력별 개별 깃발, `WATCH` 거부, production 이중 승인, 활성 집합, 승격 임계값 둘, `WATCH` 선행 규칙, 매트릭스 경합 거부를 확인한다.
|
||||
|
||||
`capabilitiesDraggedAlong` 이 항상 빈 목록을 돌려주고, 그 메서드가 존재하는 이유를 javadoc 이 적는다 — "the method exists so a test can assert that rather than a comment claiming it". 실제로 그 테스트가 있다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** Advanced 가족 전체가 배선되지 않는다. 리프 안에서도 절반만 쓰인다.
|
||||
|
||||
의존 선언은 다섯이다 — `grpc-advanced-diagnostics` · `-streaming` · `-compat` · `-edition` · `-resilience` 가 각각 `api project(':grpc-advanced:grpc-advanced-bootstrap')`.
|
||||
|
||||
그중 실제로 타입을 부르는 것은 둘뿐이다.
|
||||
|
||||
| 호출 지점 | 무엇을 |
|
||||
|---|---|
|
||||
| `GrpcChannelDiagnosticsPolicy:47,53` | `GrpcAdvancedModuleGuard.available(flags, CHANNEL_DIAGNOSTICS)` · `…(flags, XDS)` |
|
||||
| `GrpcXdsStartupGuard:33` | `GrpcAdvancedModuleGuard.available(flags, XDS)` |
|
||||
|
||||
`-streaming` · `-compat` · `-edition` 은 의존만 선언하고 참조가 없다.
|
||||
|
||||
그리고 리프 밖에서 불리는 것은 `available` **하나뿐**이다.
|
||||
|
||||
- `GrpcAdvancedModuleGuard.require(...)` — 던지는 형태. 외부 호출자 0. 세 갈래 거부 메시지 전체가 자기 테스트에서만 실행된다. `GrpcAdvancedCapabilityDisabledException` 도 마찬가지다.
|
||||
- `requireStableStarterIsClean(...)` — 외부 호출자 0. javadoc 이 겨냥한 "다르게 조립된 런타임"이 이 검사를 부르지 않는다.
|
||||
- `advancedModules()` — 외부 호출자 0.
|
||||
- **`release` 패키지 4파일 276줄 전체** — 리프 밖 참조 0. 승격 게이트·증거·결정·지원 매트릭스를 만드는 곳이 자기 테스트 말고 없다.
|
||||
|
||||
즉 이 리프에서 실행 경로에 걸려 있는 것은 `GrpcAdvancedCapability` · `GrpcCapabilityGrade` · `GrpcAdvancedFeatureFlags` · `GrpcAdvancedModuleGuard.available` 네 조각이고, 나머지 절반은 선언이다.
|
||||
|
||||
**12.2 대조군.** 능력을 하나씩 등급 매기는 형태가 messaging 의 `CompatibilityMatrix`(어댑터별 STABLE/EXPERIMENTAL/EXTENSION)와 같은 계열이다. 차이는 이쪽이 시작 가능 여부와 production 승인 요구를 등급 자체의 속성으로 둔 점이다.
|
||||
|
||||
**12.4 드리프트.** build.gradle 이 서술한 네 요소(등급·깃발 계약·모듈 가드·승격 게이트)가 전부 존재한다. 드리프트 없음.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- `verifyCleanArchitectureDependencies` 를 이 리비전에서 실행하지 않았다.
|
||||
- 등급 재정의로 `WATCH` 능력을 켜는 것을 실행으로 재현하지 않았다(§17.1). 코드 경로로 판정했다.
|
||||
- 테스트를 실행하지 않았다. 17개 전부 본문으로만 확인했다. §17.4 의 담금 역전도 `evaluate` 본문과 두 테스트가 고른 숫자(60일 · `DISABLED`)로 판정한 것이다.
|
||||
- `-streaming` · `-compat` · `-edition` 이 이 리프를 의존만 하고 쓰지 않는다는 것은 타입 이름 grep 으로 판정했다. 각 리프 SSOT 에서 다시 본다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P3 — 등급 재정의에 하한이 없어 "켤 수 없다" 는 등급이 켜질 수 있다
|
||||
|
||||
`GrpcCapabilityGrade` 의 javadoc 이 두 등급을 단정한다.
|
||||
|
||||
```
|
||||
WATCH — "Tracked, not implemented. Cannot be enabled."
|
||||
DISABLED — "Withdrawn or refused. Cannot be enabled."
|
||||
```
|
||||
|
||||
그런데 등급은 런타임에 갈아끼울 수 있다.
|
||||
|
||||
```java
|
||||
public GrpcAdvancedFeatureFlags withGrade(GrpcAdvancedCapability capability, GrpcCapabilityGrade grade) {
|
||||
grades.put(capability, grade);
|
||||
return this;
|
||||
}
|
||||
```
|
||||
|
||||
`withGrade(EDITION_2026, ADVANCED_STABLE).enable(EDITION_2026)` 이면 가드의 두 번째 조건이 통과한다.
|
||||
|
||||
등급 올리기 자체는 의도된 기능이다 — 테스트 `a deployment may raise a capability's grade on its own evidence` 가 `HEDGING`(EXPERIMENTAL)을 `ADVANCED_STABLE` 로 올린다. 문제는 그 재정의에 하한이 없다는 것이다.
|
||||
|
||||
- `EXPERIMENTAL` 을 올리는 것은 "실패 양식이 충분히 규명되지 않은 것을 감수한다" 는 판단이고 배포가 자기 증거로 내릴 수 있다.
|
||||
- `WATCH` 를 올리는 것은 다르다. 그 등급의 뜻이 "추적할 뿐 구현되지 않았다" 이므로 배포가 가질 자기 증거가 없다.
|
||||
|
||||
그리고 승격 게이트는 `WATCH` 가 `EXPERIMENTAL` 을 먼저 거쳐야 한다는 규칙을 갖는데, 런타임 재정의는 그 게이트를 지나지 않는다. 같은 리프 안에 문이 둘이고 증거 규칙은 한쪽에만 있다.
|
||||
|
||||
수정은 `withGrade` 가 현재 등급이 `startable()` 인 능력에만 적용되게 하거나, `WATCH`·`DISABLED` 에서 올리는 재정의를 거부하는 것이다.
|
||||
|
||||
### 17.2 P3 — 승격 게이트가 하향 전이도 승격 규칙으로 판정하고, javadoc 이 약속한 거부는 없다
|
||||
|
||||
```java
|
||||
/** @throws IllegalArgumentException when the transition is not one this gate governs */
|
||||
public static GrpcAdvancedPromotionDecision evaluate(
|
||||
GrpcAdvancedPromotionEvidence evidence, GrpcCapabilityGrade from, GrpcCapabilityGrade to) {
|
||||
…
|
||||
if (from == to) { throw new IllegalArgumentException("a promotion changes the grade"); }
|
||||
```
|
||||
|
||||
던지는 경우는 널과 `from == to` 둘뿐이다. "이 게이트가 다루는 전이가 아닐 때" 라는 조건에 해당하는 검사가 없다.
|
||||
|
||||
그래서 하향 전이가 승격 규칙으로 판정된다.
|
||||
|
||||
```
|
||||
evaluate(none(XDS), ADVANCED_STABLE, DISABLED)
|
||||
→ to != ADVANCED_STABLE 이므로 requiredSoak = 30일
|
||||
→ 증거 일곱 항목 부재 + 담금 부족으로 blockers 여덟
|
||||
→ 결정: 거부
|
||||
```
|
||||
|
||||
능력을 철회하려는 결정이 증거 부족을 이유로 막힌다. 방향이 뒤집혀 있다.
|
||||
|
||||
지금은 도달성이 낮다 — 이 게이트를 부르는 production 코드가 없고 테스트도 상향 전이만 넣는다. 기록하는 이유는 javadoc 이 그 거부를 이미 약속했다는 점이다.
|
||||
|
||||
수정은 `to.ordinal()` 이 아니라 등급의 서열을 명시한 뒤 상향 전이만 받고 나머지는 던지는 것이다. 철회는 별도 경로가 필요하다.
|
||||
|
||||
### 17.3 P3 — 깃발 홀더가 가변이고 동기화가 없다
|
||||
|
||||
`GrpcAdvancedFeatureFlags` 는 두 `EnumMap` 을 `enable`·`withGrade` 로 갱신하고, `available`·`active` 가 같은 맵을 읽는다. `synchronized`·`volatile`·동시 자료구조가 없다.
|
||||
|
||||
시작 시 전부 설정하고 그 뒤로 읽기만 한다면 안전 공개 문제만 남는다. 다만 두 메서드가 `this` 를 돌려주는 유창한 형태라 런타임 중 갱신을 권하는 모양이고, `active()` 는 순회 중 갱신에 노출된다.
|
||||
|
||||
같은 저장소가 이 형태를 다른 리프에서 결함으로 기록했다(`GrpcCompletionReconciler` 의 동기화 없는 `ArrayList`). 여기서는 등급과 깃발이 요청 경로에서 읽히므로 같은 노출이 생길 수 있다.
|
||||
|
||||
수정은 홀더를 불변으로 만들고 `enable`·`withGrade` 가 새 인스턴스를 돌려주게 하는 것이다. 이 저장소가 다른 곳에서 쓰는 형태다(`GrpcProtoStyleManifest.allowingWellKnownTypes` 등).
|
||||
|
||||
### 17.4 P2 — 30일 담금이 열거형에 없는 등급을 위해 쓰였고, 그 결과 `WATCH → EXPERIMENTAL` 이 `→ ADVANCED_STABLE` 보다 어렵다
|
||||
|
||||
`GrpcAdvancedPromotionGate` javadoc 의 모형은 등급 둘이다.
|
||||
|
||||
> "Reaching **Advanced Stable** means the capability works and is documented; becoming a **Stable
|
||||
> default** means every deployment gets it… The second needs the first plus a longer soak."
|
||||
|
||||
그리고 상수도 둘이다.
|
||||
|
||||
```java
|
||||
public static final Duration ADVANCED_STABLE_SOAK = Duration.ofDays(7);
|
||||
public static final Duration STABLE_DEFAULT_SOAK = Duration.ofDays(30);
|
||||
```
|
||||
|
||||
그런데 `GrpcCapabilityGrade` 의 값은 `ADVANCED_STABLE` · `EXPERIMENTAL` · `WATCH` · `DISABLED` 넷이다. **"Stable default" 라는 등급이 없다.**
|
||||
|
||||
선택은 이렇게 적혀 있다.
|
||||
|
||||
```java
|
||||
Duration requiredSoak =
|
||||
to == GrpcCapabilityGrade.ADVANCED_STABLE ? ADVANCED_STABLE_SOAK : STABLE_DEFAULT_SOAK;
|
||||
```
|
||||
|
||||
`ADVANCED_STABLE` 이 아닌 **나머지 전부**가 30일 갈래로 떨어진다 — `EXPERIMENTAL`, `WATCH`, `DISABLED`. 존재하지 않는 등급을 위해 만든 갈래가 존재하는 세 등급을 삼켰다.
|
||||
|
||||
**따라오는 역전.** `missing()` 검사도 목표 등급과 무관하게 일곱 항목을 전부 요구한다. 그래서:
|
||||
|
||||
| 전이 | 필요한 증거 | 필요한 담금 |
|
||||
|---|---|---|
|
||||
| `EXPERIMENTAL → ADVANCED_STABLE` | 일곱 전부 | **7일** |
|
||||
| `WATCH → EXPERIMENTAL` | 일곱 전부 | **30일** |
|
||||
|
||||
`WATCH` 능력이 밟도록 강제된 유일한 첫 걸음이(같은 메서드의 셋째 blocker: "it becomes EXPERIMENTAL before anything else") 상위 등급보다 엄격하다. 그리고 `WATCH` 의 뜻은 "추적할 뿐 구현되지 않았다" 이므로, 정의상 담금 기록이 가장 적은 등급에 가장 긴 담금을 요구한다.
|
||||
|
||||
**테스트가 이 뒤틀림을 그대로 보여 준다.**
|
||||
|
||||
```java
|
||||
@DisplayName("becoming a Stable default needs a longer soak than becoming Advanced Stable")
|
||||
void theStableDefaultThresholdIsHigher() {
|
||||
…
|
||||
GrpcAdvancedPromotionGate.evaluate(weekLongSoak,
|
||||
GrpcCapabilityGrade.ADVANCED_STABLE, GrpcCapabilityGrade.DISABLED) // ← 철회 전이
|
||||
.blockers() … .contains("requires 30");
|
||||
}
|
||||
```
|
||||
|
||||
30일 갈래를 실행하려고 고른 전이가 `ADVANCED_STABLE → DISABLED`, 즉 **철회**다. 이름은 "Stable default 가 되는 것"이라고 말한다. 겨냥한 등급이 열거형에 없으니 그것을 밟을 방법이 없었고, 남은 것 중 아무거나 골라야 했다는 흔적이다.
|
||||
|
||||
그리고 `WATCH → EXPERIMENTAL` 을 확인하는 테스트는 담금을 60일로 준다.
|
||||
|
||||
```java
|
||||
GrpcAdvancedPromotionEvidence.complete(EDITION_2026, Duration.ofDays(60))
|
||||
```
|
||||
|
||||
7일로 줬다면 통과하지 않는다. 30일 요구가 레인에 걸리지 않는 이유가 이 숫자 선택이다.
|
||||
|
||||
**§17.2 와의 관계.** §17.2 는 이 갈래의 *증상* 하나(철회 전이가 승격 규칙으로 판정되는 것)를 기록했다. 원인은 목표 등급별 요구 사항이 없다는 것이고, 그래서 상향 전이 안에서도 순서가 뒤집혔다.
|
||||
|
||||
**수정.** 목표 등급마다 요구 사항을 명시한다.
|
||||
|
||||
```java
|
||||
record Requirement(Set<String> evidence, Duration soak) {}
|
||||
static Requirement requirementFor(GrpcCapabilityGrade to) { … } // EXPERIMENTAL 은 더 얕게
|
||||
```
|
||||
|
||||
`STABLE_DEFAULT` 를 실제로 표현하려면 등급으로 추가하거나(그러면 `GrpcAdvancedCapability` 를 떠나 Stable 기본값이 된다는 뜻이므로 별도 개념이 맞다) 이 게이트가 다루지 않는다고 적고 상수를 지운다. 지금은 이름만 있고 대상이 없다.
|
||||
|
||||
### 17.5 P3 — `capabilitiesDraggedAlong` 은 독립성을 증명하지 않는다. 상수를 상수와 비교한다
|
||||
|
||||
```java
|
||||
/** Always empty, and the method exists so a test can assert that rather than a comment claiming it */
|
||||
public static List<GrpcAdvancedCapability> capabilitiesDraggedAlong(GrpcAdvancedCapability promoted) {
|
||||
if (promoted == null) throw …;
|
||||
return List.of();
|
||||
}
|
||||
```
|
||||
|
||||
javadoc 이 스스로 밝히듯 본문은 무조건 빈 목록이다. 그것을 단언하는 테스트는 리터럴이 리터럴임을 확인한다 — 증거를 능력마다 따로 기록했다는 §4 의 설계 속성과는 아무 연결이 없다. 설계가 무너져 `apply` 가 다른 능력의 등급을 바꾸게 되어도 이 메서드는 여전히 빈 목록을 돌려준다.
|
||||
|
||||
**진짜 증거는 같은 테스트의 다른 줄에 있다.**
|
||||
|
||||
```java
|
||||
matrix.apply(evaluate(complete(HEDGING, 7일), EXPERIMENTAL, ADVANCED_STABLE));
|
||||
assertThat(matrix.gradeOf(HEDGING)).isEqualTo(ADVANCED_STABLE);
|
||||
assertThat(matrix.gradeOf(GRPC_WEB)).isEqualTo(webBefore); // ← 이 줄이 독립성을 붙든다
|
||||
```
|
||||
|
||||
승격을 실제로 적용하고 다른 능력의 등급이 그대로임을 확인한다. 이쪽은 설계가 무너지면 깨진다.
|
||||
|
||||
**이 문서의 이전 판정을 고친다.** 앞선 판에서 이 메서드를 "주석이 주장하는 대신 테스트가 붙든다"는 확인된 설계로 분류했다. 다시 읽으니 붙드는 것은 옆줄이고, 이 메서드는 그 옆줄이 있다는 사실을 가린다.
|
||||
|
||||
**수정.** 메서드를 지우고 단언을 매트릭스 비교 쪽으로 남긴다. 남겨 둔다면 실제로 매트릭스를 훑어 등급이 바뀐 다른 능력을 돌려주게 만든다 — 그때 비로소 이름이 하는 말과 본문이 맞는다.
|
||||
|
||||
### 17.6 P3 — 예외가 들고 있는 능력이 `transient` 라 역직렬화 뒤 사라진다
|
||||
|
||||
```java
|
||||
public class GrpcAdvancedCapabilityDisabledException extends RuntimeException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
private final transient GrpcAdvancedCapability capability;
|
||||
…
|
||||
public GrpcAdvancedCapability capability() { return capability; }
|
||||
```
|
||||
|
||||
`transient` 는 보통 직렬화 가능하지 않은 필드를 담은 `Serializable` 클래스에 대한 정적 분석 경고를 끄려고 붙인다. 그런데 열거형은 언제나 직렬화 가능하다 — 여기서 `transient` 가 막을 문제가 애초에 없다.
|
||||
|
||||
대가는 있다. 예외가 직렬화를 거쳐 오면 `capability()` 가 `null` 이다. 메시지 문자열은 살아남으므로 사람이 읽는 데는 지장이 없고, 그래서 눈에 띄지 않는다.
|
||||
|
||||
**등급.** 이 예외를 던지는 `require` 자체가 리프 밖에서 불리지 않으므로(§12.1) 오늘 도달하지 않는다. `transient` 를 지우는 것이 수정 전부다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **능력별 개별 등급과 개별 깃발** — 하나의 스위치가 두 번째 결정을 사고로 만들지 않는다.
|
||||
- **`EXPERIMENTAL` 의 production 이중 승인** — 원하는 사람과 감수하는 사람이 다르다.
|
||||
- **가드가 세 조건을 순서대로 보고 각각 다른 메시지를 내는 것.**
|
||||
- **증거를 능력마다 따로 기록한 것** — 공유 기록은 하나의 승격이 다른 것을 함께 올린다.
|
||||
- **두 담금 임계값** — 한 배포에서 일주일 돈 것과 모든 배포에 나가는 것은 같은 주장이 아니다.
|
||||
- **매트릭스가 시작 등급 불일치를 거부하는 것** — 경합과 재생을 구분해 준다.
|
||||
- **빈 컬렉션에 대한 `EnumSet.copyOf` 함정을 삼항으로 피한 것.**
|
||||
- **`GrpcAdvancedSupportMatrix.apply` 가 결정의 `from` 을 현재 등급과 대조하는 것** — 이 저장소가 같은 문제를 반대로 푼 자리가 있어서 대비된다. `grpc-codegen` 의 `GrpcSchemaArtifactPublisher.publish(candidate, decision)` 는 결정이 어느 후보를 판정한 것인지 확인하지 않아 짝이 어긋날 수 있다(그쪽 §17.4). 이쪽은 결정이 자기가 밟고 선 상태를 들고 있고 적용 시점에 대조한다 — 같은 형태의 올바른 판본이다.
|
||||
- **`ADVANCED_STABLE` 11 · `EXPERIMENTAL` 3 · `WATCH` 1 의 분포가 능력 성격과 맞는 것** — 제어 평면을 끌고 오는 `XDS`, 부하를 복제하는 `HEDGING`, 이름 해석을 갈아끼우는 `CUSTOM_LOAD_BALANCER` 만 이중 승인 대상이다.
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc-advanced/grpc-advanced-bootstrap/build.gradle:1-12
|
||||
main/java/…/bootstrap/GrpcAdvancedFeatureFlags.java:1-105
|
||||
main/java/…/bootstrap/GrpcAdvancedModuleGuard.java:1-84
|
||||
main/java/…/bootstrap/GrpcAdvancedCapability.java:1-65
|
||||
main/java/…/bootstrap/GrpcCapabilityGrade.java:1-38
|
||||
main/java/…/bootstrap/GrpcAdvancedCapabilityDisabledException.java:1-42
|
||||
main/java/…/release/GrpcAdvancedPromotionGate.java:1-85
|
||||
main/java/…/release/GrpcAdvancedPromotionEvidence.java:1-75
|
||||
main/java/…/release/GrpcAdvancedSupportMatrix.java:1-66
|
||||
main/java/…/release/GrpcAdvancedPromotionDecision.java:1-50
|
||||
test/java/…/release/GrpcAdvancedPromotionGateTest.java:1-150
|
||||
test/java/…/bootstrap/GrpcAdvancedModuleGuardTest.java:1-129
|
||||
grpc-advanced/grpc-advanced-diagnostics/…/GrpcChannelDiagnosticsPolicy.java:46-53 (available 소비)
|
||||
grpc-advanced/grpc-advanced-resilience/…/xds/GrpcXdsStartupGuard.java:33-38 (available 소비)
|
||||
```
|
||||
@@ -0,0 +1,224 @@
|
||||
/shared/codebase/clean-architecture-backend-template/src/grpc-advanced/grpc-advanced-compat/src/main/resources/envoy/envoy.yaml
|
||||
---
|
||||
# grpc-advanced-compat 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 — `src/main` production 17파일 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc-advanced/grpc-advanced-compat`
|
||||
> SSOT owner: `grpc-advanced-compat`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
- 다섯 다리: gRPC-Web · Servlet · Spring Integration · Reactor · Kotlin
|
||||
|
||||
| 패키지 | 파일 | LOC |
|
||||
|---|---:|---:|
|
||||
| `web` | 4 | 220 |
|
||||
| `kotlin` | 3 | 164 |
|
||||
| `servlet` | 3 | 152 |
|
||||
| `reactor` | 4 | 233 |
|
||||
| `integration` | 3 | 193 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 17 | `FULL_READ` | 962줄 전 본문 |
|
||||
| `main/resources/envoy/envoy.yaml` | 1 | `FULL_READ` | 70줄 전문. 참조 프록시 설정 — 이전 판의 ledger 에 아예 없었다 |
|
||||
| `test/java/**` | 5 | `FULL_READ` | 523줄 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 전문 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 코틀린 레인의 처리
|
||||
|
||||
```groovy
|
||||
// build.gradle:6-10
|
||||
// No Kotlin source set (adaptation D7): this repository has no Kotlin toolchain, so the Kotlin lane
|
||||
// is expressed as a Java-side boundary contract whose compatibility gate fails closed until a real
|
||||
// toolchain lane exists. Everything the gate would otherwise assert — one schema source, coroutine
|
||||
// cancellation propagation, Flow backpressure inside the Stable buffer limits, evidence type
|
||||
// preservation — is a checkable contract without it.
|
||||
```
|
||||
|
||||
그리고 게이트가 그 판단을 코드로 반복한다.
|
||||
|
||||
> "Fails closed in this repository, and says so rather than reporting a pass it cannot justify…
|
||||
> a gate that reported success anyway would put an unverified claim in the support matrix."
|
||||
|
||||
`blockers` 는 다섯 항목을 낸다. 넷은 프로파일에서 확인 가능하고, 다섯째가 툴체인 레인 부재다. `supportableHere()` 는 상수 거짓이다.
|
||||
|
||||
이 처리가 이 저장소의 다른 곳(`grpc-advanced-diagnostics` 의 인프라 테스트킷 계약)과 같은 원칙이다 — 인프라 없이 도는 묶음은 통과하고 아무것도 세우지 않는다.
|
||||
|
||||
## 2. 다리마다 무엇을 거절하는가
|
||||
|
||||
| 다리 | 거절 |
|
||||
|---|---|
|
||||
| gRPC-Web | 브라우저에 노출된 메서드 중 gRPC-Web 이 나를 수 없는 RPC 종류 |
|
||||
| Servlet | 컨테이너가 제공하지 않는 전송 설정 요구 |
|
||||
| Kotlin | 다섯 블로커(툴체인 레인 포함) |
|
||||
| Spring Integration | 변환기 없는 다리, 허용 목록 밖 헤더 |
|
||||
| Reactor | (§17.2) |
|
||||
|
||||
gRPC-Web 의 근거:
|
||||
|
||||
> "Two schemas — one for browsers, one for services — is how a field ends up meaning something
|
||||
> different depending on which client asked, and the divergence is only visible to whoever reads
|
||||
> both files."
|
||||
|
||||
Servlet 의 근거:
|
||||
|
||||
> "Refuses rather than warns, because the setting would otherwise be accepted and ignored. A
|
||||
> keepalive configured on a Servlet deployment does nothing, the connections behave as the container
|
||||
> decides, and the investigation starts from the assumption that the setting is in force."
|
||||
|
||||
그리고 Servlet 실행이 Netty 인증을 대신할 수 없다는 것을 상수로 못박는다.
|
||||
|
||||
## 3. Spring Integration 다리가 무엇을 약속하지 않는가
|
||||
|
||||
> "A Spring Integration `Message` accumulates headers as it moves through a flow — routing keys,
|
||||
> correlation ids, errors channels, whatever a transformer added — and copying them onto gRPC
|
||||
> metadata sends a service's internal plumbing across the network."
|
||||
|
||||
> "The bridge does not add durability. Spring Integration channels can look like a broker, and a
|
||||
> bridge that implied acknowledgement or redelivery semantics would be promising something gRPC does
|
||||
> not do."
|
||||
|
||||
변환기가 없는 다리는 생성자가 거부한다 — 반사에 맡기는 것이 예상 밖 타입이 유선에 닿는 경로다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** Advanced 가족이므로 배선 경로가 없다. 그 위에 이 리프에는 테스트조차 없는 타입이 둘 있다(§17.2).
|
||||
|
||||
**12.2 대조군 — 메타데이터 경로 둘.** `grpc-client` 의 `GrpcClientMetadataPolicy.materialize` 는 허용 목록으로 거른 뒤 `budget.check(accepted)` 를 부른다. 이 리프의 `GrpcIntegrationBridgePolicy.metadataFrom` 은 허용 목록으로 거르고 예산을 부르지 않는다(§17.1).
|
||||
|
||||
**12.3 리프 전체의 외부 참조가 0 이다.** 재통독에서 다섯 패키지를 각각 확인했다.
|
||||
|
||||
```
|
||||
dev.caskeleton.grpc.advanced.{web, servlet, integration, reactor, kotlin} → 리프 밖 참조 0
|
||||
```
|
||||
|
||||
같은 Advanced 가족의 `grpc-advanced-bootstrap` 조차 이 리프의 타입을 하나도 부르지 않는다. Advanced 는 기능 플래그로 도달한다는 것이 이 가족의 규약인데, 그 플래그가 가리킬 대상이 배선되어 있지 않다.
|
||||
|
||||
**12.4 드리프트.** build.gradle 이 서술한 다섯 다리와 코틀린 레인의 처리 방식이 코드와 일치한다. 다만 ledger 가 `main/resources` 를 세지 않고 있었다(§17.3 의 재료가 거기 있다).
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 실제 브라우저·프록시·서블릿 컨테이너로 어떤 다리도 돌리지 않았다. 그 인프라가 필요하다는 것이 이 가족의 기록이다.
|
||||
- 코틀린 툴체인이 없으므로 코틀린 계약 넷을 실행으로 확인할 수 없다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P3 — 통합 다리의 메타데이터 조립이 메타데이터 예산을 검사하지 않는다
|
||||
|
||||
```java
|
||||
public Map<GrpcMetadataKey, String> metadataFrom(Map<String, Object> messageHeaders) {
|
||||
Map<GrpcMetadataKey, String> metadata = new LinkedHashMap<>();
|
||||
headerAllowlist.forEach(key -> {
|
||||
Object value = messageHeaders.get(key.name());
|
||||
if (value != null) { metadata.put(key, String.valueOf(value)); }
|
||||
});
|
||||
return Map.copyOf(metadata);
|
||||
}
|
||||
```
|
||||
|
||||
허용 목록으로 키를 거르지만 값의 크기도, 합계도 보지 않는다.
|
||||
|
||||
클래스 javadoc 자신이 예산을 이 정책의 이유 중 하나로 든다 — 흐름의 내부 배관을 네트워크로 보내면 "it counts against the metadata budget."
|
||||
|
||||
그리고 같은 저장소의 다른 메타데이터 경로는 예산을 검사한다.
|
||||
|
||||
```java
|
||||
// GrpcClientMetadataPolicy.materialize
|
||||
proposed.forEach((key, value) -> { if (allowed.contains(key) && value != null) accepted.put(key, value); });
|
||||
budget.check(accepted); // ← 이 줄이 이 다리에는 없다
|
||||
```
|
||||
|
||||
`String.valueOf(value)` 이므로 헤더 값이 임의의 객체일 때 그 문자열 표현이 그대로 실린다. Spring Integration 헤더에는 컬렉션이나 도메인 객체가 흔히 들어가므로 값 하나가 클 수 있다.
|
||||
|
||||
수정은 이 record 에 `GrpcMetadataBudget` 를 성분으로 추가하고 `metadataFrom` 끝에서 검사하는 것이다. 형태가 이미 옆 리프에 있다.
|
||||
|
||||
### 17.2 P3 — 반응형 표면 두 타입은 테스트조차 없다
|
||||
|
||||
```
|
||||
ReactiveGrpcClient 저장소 전체에서 등장하는 파일 1개 (자기 자신)
|
||||
ReactiveGrpcServerAdapter 저장소 전체에서 등장하는 파일 1개 (자기 자신)
|
||||
```
|
||||
|
||||
이 가족의 다른 미참조 Advanced 타입은 전부 테스트가 하나씩 있다 — 같은 패키지의 `GrpcReactorCancellationBridge` 는 2개 파일, `GrpcReactorContextBridge` 는 4개 파일에 등장한다.
|
||||
|
||||
두 타입은 채택자가 부를 표면이므로 production 참조 0 이 설계와 모순되지는 않는다. 어긋나는 것은 검증이다. 채택자용 표면이면 그 계약이 무엇인지를 테스트가 붙들어야 하고, 이 가족은 다른 곳에서 정확히 그렇게 한다.
|
||||
|
||||
`ReactiveGrpcClient` 의 javadoc 이 "Exposes a unary call as a `Mono` and a server stream as a `Flux`" 라고 적는데, 그 사상이 취소와 배압에서 어떻게 동작하는지는 어디에서도 확인되지 않는다. 같은 리프의 `GrpcReactorCancellationBridge` 가 취소 전파를 다루므로 둘을 함께 검증할 자리가 이미 있다.
|
||||
|
||||
### 17.3 P3 — 저장소가 참조 프록시 설정을 갖고 있는데, 그것을 판정할 코드에 넣지 않는다
|
||||
|
||||
이 리프에는 두 가지가 함께 있다.
|
||||
|
||||
- `GrpcWebProxyContract.violations(profile, exposedHeaders, allowedOrigins)` — 프록시 설정이 브라우저 클라이언트에게 통할지 판정하는 코드.
|
||||
- `src/main/resources/envoy/envoy.yaml` — 그 설정의 참조 구현.
|
||||
|
||||
그리고 설정 파일 자신이 그 관계를 주장한다.
|
||||
|
||||
```yaml
|
||||
# Shipped as a resource rather than as documentation prose because GrpcWebProxyContract asserts
|
||||
# against it: the CORS allowlist, the exposed trailer headers and the TLS termination are the three
|
||||
# things a browser client silently fails without, and a contract nobody checks is a contract that
|
||||
# drifts from whatever is actually deployed.
|
||||
```
|
||||
|
||||
`GrpcWebProxyContract` 는 이 파일에 대해 아무것도 단언하지 않는다. 판정기는 시험에서 리터럴 집합을 받고, 참조 설정은 시험에서 문자열 포함으로만 확인된다.
|
||||
|
||||
```java
|
||||
// GrpcWebCompatibilityGateTest
|
||||
assertThat(GrpcWebProxyContract.violations(profile, requiredExposedHeaders(), Set.of("*"))) // ← 리터럴
|
||||
.anySatisfy(v -> assertThat(v).contains("defeats the profile's allowlist"));
|
||||
…
|
||||
String envoy = resource("envoy/envoy.yaml");
|
||||
assertThat(envoy)
|
||||
.contains("expose_headers: \"grpc-status,grpc-message") // ← 부분 문자열
|
||||
.contains("exact: \"https://app.example.com\"");
|
||||
```
|
||||
|
||||
그래서 참조 설정이 `grpc-status` 를 노출하는지는 문자열이 확인하고, 그 노출이 **충분한지** 는 `requiredExposedHeaders()` 가 정의하는데, 둘을 잇는 코드가 없다. 필수 트레일러 목록이 늘어나면 판정기는 새 항목을 요구하고 참조 설정은 옛 문자열로 계속 통과한다.
|
||||
|
||||
이 리프의 다른 판정기들과 다른 점은 재료가 이미 저장소에 있다는 것이다 — grpc-testkit §17.5·grpc-server §17.1 은 스캔할 대상 자체를 만들어야 하지만, 여기서는 파일 하나를 파싱하면 된다.
|
||||
|
||||
수정은 시험이 `envoy.yaml` 의 `expose_headers` 와 `allow_origin`(`exact:`)을 뽑아 `GrpcWebProxyContract.violations` 에 넣고 비어 있음을 단언하는 것이다. 그러면 참조 설정과 계약이 한 곳에서 함께 움직인다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **코틀린 게이트가 닫힌 실패를 하고 그 이유를 말하는 것** — 정당화할 수 없는 통과를 보고하지 않는다.
|
||||
- **코틀린 계약 넷을 툴체인 없이도 확인 가능하게 만든 것** — 나중에 필요한 것은 레인 추가이지 계약 작성이 아니다.
|
||||
- **브라우저와 기본 클라이언트가 한 스키마를 쓰게 한 것.**
|
||||
- **Servlet 이 제공하지 않는 설정을 경고가 아니라 거절로 다룬 것.**
|
||||
- **Servlet 실행이 Netty 인증을 대신하지 못한다고 못박은 것.**
|
||||
- **통합 다리가 브로커 의미론을 약속하지 않는다고 상수로 밝힌 것.**
|
||||
- **변환기 없는 다리를 생성자가 거부한 것.**
|
||||
- **다리가 생성된 스텁·서비스 API 를 대체하지 않는다고 밝힌 것.**
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc-advanced/grpc-advanced-compat/build.gradle:1-14
|
||||
main/java/…/kotlin/GrpcKotlinCompatibilityGate.java:1-69
|
||||
main/java/…/web/GrpcWebCompatibilityGate.java:1-56
|
||||
main/java/…/servlet/GrpcServletStartupValidator.java:1-51
|
||||
main/java/…/integration/GrpcIntegrationBridgePolicy.java:1-78
|
||||
main/java/…/reactor/(ReactiveGrpcClient · ReactiveGrpcServerAdapter · GrpcReactorCancellationBridge · GrpcReactorContextBridge)
|
||||
main/java/…/web/(GrpcWebProfile · GrpcWebProxyContract · GrpcWebRpcSupport)
|
||||
main/java/…/servlet/(GrpcServletCompatibilityProfile · GrpcServletCapabilityMatrix)
|
||||
main/java/…/kotlin/(GrpcCoroutineContextBridge · GrpcKotlinProfile)
|
||||
main/java/…/integration/(GrpcIntegrationInboundGateway · GrpcIntegrationOutboundGateway)
|
||||
src/grpc/grpc-client/…/GrpcClientMetadataPolicy.java (예산 검사 대비)
|
||||
```
|
||||
@@ -0,0 +1,265 @@
|
||||
# grpc-advanced-diagnostics 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 — `src/main` production 4파일 277줄, test 1파일 229줄과 픽스처 1개 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc-advanced/grpc-advanced-diagnostics`
|
||||
> SSOT owner: `grpc-advanced-diagnostics`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `allowed_dependencies`: `["grpc-core-api", "grpc-client", "grpc-advanced-bootstrap"]`
|
||||
- `runtime_memberships`: **`[]`** — build-only (Advanced 가족 전체가 그렇다)
|
||||
|
||||
| 파일 | LOC |
|
||||
|---|---:|
|
||||
| `GrpcAdvancedInfrastructureTestkit` | 87 |
|
||||
| `GrpcDiagnosticsRedactor` | 72 |
|
||||
| `GrpcChannelDiagnosticsSnapshot` | 63 |
|
||||
| `GrpcChannelDiagnosticsPolicy` | 55 |
|
||||
| **main 합계** | **277** |
|
||||
| `GrpcChannelDiagnosticsPolicyTest` | 229 |
|
||||
| `xds/control-plane-snapshot.json` | 24 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 4 | `FULL_READ` | 전 본문 축자 확인 |
|
||||
| `test/java/**` | 1 | `FULL_READ` | 229줄 · 테스트 11개 |
|
||||
| `test/resources/xds/*.json` | 1 | `FULL_READ` | 24줄 픽스처 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 10줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체
|
||||
|
||||
진단 표면(Channelz·CSDS)과 그것을 게시 가능하게 만드는 편집기, 그리고 고급 능력이 무엇을 상대로 검증되어야 하는지를 이름 짓는 테스트킷 계약을 담는다.
|
||||
|
||||
편집기 javadoc 이 왜 이것이 필요한지 적는다.
|
||||
|
||||
> "Channelz is unusually dangerous to expose because it is genuinely useful: it holds every socket's
|
||||
> local and remote address, the security details of each connection, and per-call state… once the
|
||||
> endpoint exists the whole of it is one authorization mistake away from being readable."
|
||||
|
||||
## 2. 두 겹의 게이트
|
||||
|
||||
`GrpcChannelDiagnosticsPolicy` 는 네트워크와 역할 두 게이트를 모두 요구하고, 하나라도 비면 생성자가 거부한다.
|
||||
|
||||
> "diagnostics need both a network and a role gate; Channelz holds every socket's peer and security
|
||||
> detail, so either gate alone is the whole surface"
|
||||
|
||||
그리고 등록 판정이 능력 깃발에 걸려 있다. CSDS 는 Channelz 가 켜져 있고 xDS 도 켜져 있을 때만 등록된다.
|
||||
|
||||
> "A CSDS service on a deployment that does not use xDS answers every query with nothing, which is
|
||||
> harmless, and advertises a control-plane surface that does not exist, which is not."
|
||||
|
||||
## 3. 스냅숏이 스스로를 검사한다
|
||||
|
||||
`GrpcChannelDiagnosticsSnapshot` 정규 생성자가 두 가지를 거부한다.
|
||||
|
||||
```java
|
||||
maskedSocketAddresses.stream()
|
||||
.filter(address -> !address.equals(GrpcDiagnosticsRedactor.maskAddress(address)))
|
||||
… // 마스킹되지 않은 주소
|
||||
xdsResourceVersions.keySet().stream()
|
||||
.filter(GrpcDiagnosticsRedactor::forbiddenField)
|
||||
… // 금지된 필드 이름
|
||||
```
|
||||
|
||||
즉 편집을 거치지 않은 값으로는 스냅숏을 만들 수 없다. §17.1 이 그 검사의 범위를 다룬다.
|
||||
|
||||
## 4. 마스킹의 형태
|
||||
|
||||
주소는 버리지 않고 가린다.
|
||||
|
||||
> "An operator has to be able to tell two subchannels apart, and a stable mask does that without
|
||||
> publishing where they point… The last two octets go; the first two stay, because 'which subnet'
|
||||
> is a real diagnostic question and 'which host' is not one the diagnostics endpoint should answer."
|
||||
|
||||
## 5. 인프라 없는 증거를 거부하는 계약
|
||||
|
||||
`GrpcAdvancedInfrastructureTestkit` 이 능력별로 필요한 실제 인프라를 이름 짓는다.
|
||||
|
||||
| 능력 | 필요 인프라 |
|
||||
|---|---|
|
||||
| `GRPC_WEB` | gRPC-Web 프록시 |
|
||||
| `SERVLET_COMPAT` | 서블릿 컨테이너 |
|
||||
| `XDS` | 멈출 수 있는 xDS 통제 평면 |
|
||||
| `KOTLIN` | 코틀린 툴체인 |
|
||||
| 나머지 11종 | 없음 |
|
||||
|
||||
근거가 javadoc 에 있다.
|
||||
|
||||
> "gRPC-Web without a proxy tests a code path no browser will take; a Servlet profile without a
|
||||
> container tests the profile object; xDS without a control plane cannot exercise the case that
|
||||
> matters, which is the control plane going away. In all three, a suite that runs without the
|
||||
> infrastructure passes and establishes nothing, which is worse than not having one."
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
11개 테스트 229줄. 두 게이트, CSDS 조건부 등록, 금지 필드 제거, 마스킹, 스냅숏 거부와 수용, 커밋된 xDS 픽스처의 편집, 능력별 인프라 목록을 확인한다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** Advanced 가족이므로 배선 경로가 없다. 리프 밖 참조도 없고, 이 리프를 의존 선언한 모듈도 없다.
|
||||
|
||||
```
|
||||
$ grep -rn "advanced.diagnostics" --include=*.java src/ | grep -v grpc-advanced-diagnostics/
|
||||
grpc-core-api/…/GrpcStableModuleCatalog.java:42: "grpc-advanced-diagnostics"); ← 목록 안의 문자열
|
||||
$ grep -rn "grpc-advanced-diagnostics" --include=*.gradle src/
|
||||
(매치 없음)
|
||||
```
|
||||
|
||||
방향을 뒤집으면 이 리프는 `grpc-advanced-bootstrap` 의 실제 소비자 둘 중 하나다 — `GrpcChannelDiagnosticsPolicy` 가 `GrpcAdvancedModuleGuard.available` 을 두 번 부른다(그쪽 §12.1). 이 가족에서 리프끼리 실제로 코드가 닿는 몇 안 되는 자리다.
|
||||
|
||||
**12.2 선언된 의존 셋 중 둘이 쓰이지 않는다.**
|
||||
|
||||
```groovy
|
||||
api project(':grpc:grpc-core-api') // import 0
|
||||
api project(':grpc:grpc-client') // import 0
|
||||
api project(':grpc-advanced:grpc-advanced-bootstrap') // import 4줄
|
||||
```
|
||||
|
||||
리프의 자바 4파일이 갖는 `dev.caskeleton` import 는 넷뿐이고 전부 bootstrap 것이다.
|
||||
|
||||
```
|
||||
GrpcAdvancedInfrastructureTestkit.java:3 GrpcAdvancedCapability
|
||||
GrpcChannelDiagnosticsPolicy.java:3,4,5 GrpcAdvancedCapability · GrpcAdvancedFeatureFlags · GrpcAdvancedModuleGuard
|
||||
```
|
||||
|
||||
`grpc-client` 는 특히 눈에 띈다 — Channelz 진단이 채널을 다루는 주제이므로 의존 선언은 자연스럽게 읽히는데, 이 리프의 스냅숏은 채널 타입을 쓰지 않고 `String channelProfile` 과 `String connectivityState` 로 받는다. 진단 값 객체가 채널 타입에서 독립적인 것 자체는 설계로 읽히고, 그렇다면 남은 것은 쓰이지 않는 의존 선언이다.
|
||||
|
||||
같은 형태를 세 리프에서 기록했다 — `grpc-advanced-edition` §12.2(셋 다 미사용), `grpc-spring-boot-starter` §12.3(셋 미사용), `grpc-observability` §12.1(두 모듈이 이 리프를 `api` 로 노출하면서 쓰지 않음).
|
||||
|
||||
**12.2 대조군.** 이 저장소의 다른 편집기와 비교하면 방향이 같다 — `grpc-observability` 의 태그 정책은 허용 목록으로, 이쪽은 금지 패턴 + 마스킹으로 같은 문제(내용이 관측 표면으로 새는 것)를 푼다.
|
||||
|
||||
**12.3 대조군 — 같은 두 리터럴이 두 리프에 있다.**
|
||||
|
||||
```java
|
||||
// grpc-advanced-diagnostics: GrpcChannelDiagnosticsPolicy.standard()
|
||||
new GrpcChannelDiagnosticsPolicy(Set.of("admin"), Set.of("ROLE_PLATFORM_ADMIN"));
|
||||
|
||||
// grpc-spring-boot-starter: GrpcPlatformAutoConfiguration.grpcReflectionPolicy(...)
|
||||
new GrpcReflectionPolicy(properties.getReflectionMode(),
|
||||
java.util.Set.of("admin"), java.util.Set.of("ROLE_PLATFORM_ADMIN"));
|
||||
```
|
||||
|
||||
관리 네트워크 이름과 관리 역할 이름이 같은 값으로 두 곳에 손으로 적혀 있고, 둘을 묶는 상수가 없다. 하나를 바꾸면 다른 하나가 남는다. 스타터 쪽은 그 리터럴이 설정 표면에 노출되지 않는다는 별도 문제도 있다(그쪽 §17.4).
|
||||
|
||||
**12.4 드리프트.** build.gradle 이 서술한 세 요소(Channelz/CSDS 진단, 편집기, 인프라 테스트킷 계약)가 전부 존재한다. 드리프트 없음.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 실제 Channelz 서비스를 띄워 스냅숏을 만들지 않았다. 배선 경로가 없다.
|
||||
- IPv6 주소로 스냅숏을 만들어 §17.1 을 실행으로 재현하지 않았다. 정규식과 생성자 검사로 판정했다.
|
||||
- 테스트를 실행하지 않았다. 11개 전부 본문으로만 확인했다.
|
||||
- 두 의존이 쓰이지 않는다는 것(§12.2)은 `^import dev.caskeleton` grep 으로 판정했다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P2 — 마스킹이 IPv4 만 알고, 그 결과 "마스킹되지 않은 주소" 검사가 나머지 형태를 전부 통과시킨다
|
||||
|
||||
```java
|
||||
private static final Pattern IPV4_WITH_PORT =
|
||||
Pattern.compile("\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(:\\d{1,5})?\\b");
|
||||
|
||||
public static String maskAddress(String address) {
|
||||
if (address == null || address.isBlank()) { return "unknown"; }
|
||||
return IPV4_WITH_PORT.matcher(address)
|
||||
.replaceAll(m -> m.group(1) + "." + m.group(2) + ".x.x");
|
||||
}
|
||||
```
|
||||
|
||||
IPv4 가 아닌 주소는 패턴에 맞지 않아 **입력 그대로 반환된다.**
|
||||
|
||||
그리고 스냅숏 생성자의 검사는 이렇게 되어 있다.
|
||||
|
||||
```java
|
||||
.filter(address -> !address.equals(GrpcDiagnosticsRedactor.maskAddress(address)))
|
||||
```
|
||||
|
||||
마스킹 결과가 입력과 같으면 이미 마스킹된 것으로 판정한다. 그러므로 IPv4 가 아닌 주소는 전부 이 검사를 통과한다.
|
||||
|
||||
| 입력 | `maskAddress` 결과 | 생성자 판정 |
|
||||
|---|---|---|
|
||||
| `10.4.13.201:9090` | `10.4.x.x` | 거부(마스킹 필요) |
|
||||
| `10.4.x.x` | `10.4.x.x` | 수용 |
|
||||
| `[2001:db8::4:13:201]:9090` | 입력 그대로 | **수용** |
|
||||
| `pod-3.svc.cluster.local:8080` | 입력 그대로 | **수용** |
|
||||
| `unix:/var/run/grpc.sock` | 입력 그대로 | **수용** |
|
||||
|
||||
세 번째와 네 번째가 문제다. 이 플랫폼이 겨냥하는 배포 형태가 쿠버네티스이고(`grpc-discovery` 전체가 그 주제다), 헤드리스 레코드의 엔드포인트는 파드 DNS 이름이며 이중 스택 클러스터에서는 IPv6 주소다. 편집기가 막으려 한 것이 정확히 그것이다 — "a diagnostics endpoint that publishes peer addresses publishes every tenant's connection."
|
||||
|
||||
`unix` 소켓 경로도 통과한다. 그것은 호스트 파일 시스템 경로다.
|
||||
|
||||
**테스트가 이것을 볼 수 없다.** 테스트의 주소 리터럴이 전부 IPv4 다 — `10.4.13.201:9090` · `10.9.13.201` · `10.4.x.x` · `10.5.x.x`. IPv6 도 호스트 이름도 없다.
|
||||
|
||||
**수정.** 마스킹을 형태별로 나눈다. IPv6 는 앞 두 그룹만 남기고 나머지를 `:x:x` 로, 호스트 이름은 최상위 라벨 몇 개만 남기고, 그 밖의 형태는 `unknown` 으로 접는다. 그리고 검사를 "결과가 입력과 같으면 통과" 가 아니라 "알려진 마스킹 형태와 일치해야 통과" 로 뒤집는다. 지금 형태는 마스킹이 모르는 입력을 전부 안전하다고 판정한다.
|
||||
|
||||
### 17.2 P3 — 금지 필드 검사가 키에만 적용되고 값에는 적용되지 않는다
|
||||
|
||||
```java
|
||||
xdsResourceVersions.keySet().stream().filter(GrpcDiagnosticsRedactor::forbiddenField)…
|
||||
```
|
||||
|
||||
`redact(...)` 도 같다 — 금지 이름의 키를 버리고, 남은 값은 주소 필드일 때만 마스킹한다. 값 자체가 자격증명 형태인지는 보지 않는다.
|
||||
|
||||
`grpc-observability` 의 태그 정책은 값도 본다(UUID·`sha256:`·`bearer ` 패턴). 같은 저장소의 두 관측 편집기가 값 검사에서 갈린다.
|
||||
|
||||
xDS 자원 버전은 보통 짧은 숫자나 해시라 도달성이 낮다. 기록하는 이유는 두 편집기의 규율이 다르다는 점이다.
|
||||
|
||||
### 17.3 P3 — "실환경 증거" 가 두 리프에 반씩 있고 서로 만나지 않는다
|
||||
|
||||
이 리프가 능력별로 무엇이 실환경인지 정의한다.
|
||||
|
||||
```java
|
||||
public static Set<Infrastructure> requiredFor(GrpcAdvancedCapability capability) { … }
|
||||
public static List<String> missingInfrastructure(GrpcAdvancedCapability capability, Set<Infrastructure> available) { … }
|
||||
```
|
||||
|
||||
그리고 `grpc-advanced-bootstrap` 이 승격 증거로 그것을 요구한다.
|
||||
|
||||
```java
|
||||
public record GrpcAdvancedPromotionEvidence(
|
||||
GrpcAdvancedCapability capability, …, boolean realEnvironmentTest) { … }
|
||||
// ^^^^^^^^^^^^^^^^^^^^^^^^^^ 불리언 하나
|
||||
```
|
||||
|
||||
`GrpcAdvancedPromotionGate.evaluate` 는 그 불리언이 거짓이면 "xds has no real environment test" 를 차단 사유로 낸다. 그 불리언을 무엇으로 채워야 하는지는 그쪽에서 답하지 않고, 답하는 코드가 이 리프에 있는데 두 쪽이 서로를 부르지 않는다.
|
||||
|
||||
결과: `GrpcAdvancedPromotionEvidence.complete(XDS, 7일)` 은 `realEnvironmentTest = true` 를 그냥 넣는다. xDS 통제 평면이 실제로 있었는지와 무관하다. 이 리프의 javadoc 이 경계한 상태 — "a suite that runs without the infrastructure passes and establishes nothing" — 를 승격 게이트가 그대로 통과시킬 수 있다.
|
||||
|
||||
**왜 P3 인가.** 두 리프 모두 배선되지 않았고 승격은 사람이 수행한다. 다만 이 두 조각이 존재하는 이유가 "그 판단을 코드로 적어 두는 것" 이므로, 판단의 절반이 다른 절반을 부르지 않는 것은 그 목적에 어긋난다. `grpc-advanced-edition` §17.2 가 같은 가족에서 같은 모양을 기록했다 — 두 승격 게이트가 서로를 부르지 않는다.
|
||||
|
||||
**수정.** `GrpcAdvancedPromotionEvidence.realEnvironmentTest` 를 불리언 대신 `Set<Infrastructure> availableInfrastructure` 로 바꾸고, 게이트가 `missingInfrastructure(capability, available)` 를 불러 그 결과를 차단 사유에 합친다. 그러면 "실환경 테스트를 했다" 가 선언이 아니라 능력별 목록에 대한 대조가 된다. 의존 방향도 맞는다 — 이 리프가 이미 bootstrap 을 의존하므로, 게이트가 이쪽을 부르려면 방향을 뒤집거나 `Infrastructure` 열거형을 bootstrap 으로 옮겨야 한다는 점은 함께 정해야 한다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **두 게이트를 모두 요구하고 하나만 있으면 생성자가 거부하는 것.**
|
||||
- **CSDS 를 xDS 사용 시에만 등록하는 것과 그 근거** — 존재하지 않는 통제 평면 표면을 광고하지 않는다.
|
||||
- **주소를 버리지 않고 가리는 판단** — 두 서브채널을 구별할 수 있어야 한다.
|
||||
- **스냅숏이 스스로 편집 여부를 검사하는 것** — 편집을 우회한 값으로는 만들 수 없다(형태 범위는 §17.1).
|
||||
- **능력별로 필요한 실제 인프라를 이름 지은 것** — 인프라 없이 통과하는 묶음은 없는 것보다 나쁘다. (승격 게이트와의 연결 없음은 §17.3.)
|
||||
- **`requiredFor` 의 switch 가 15개 능력을 전부 나열하고 `default` 를 두지 않은 것** — 능력이 하나 늘면 이 파일이 컴파일되지 않는다. 새 능력이 조용히 "인프라 불필요" 로 분류되지 않는다.
|
||||
- **픽스처가 금지 대상 셋을 일부러 담고 있는 것** — 통제 평면 토큰·피어 인증서·원시 소켓 주소. 파일 안 주석이 그 의도를 적고("so the redactor is tested against data shaped like the real thing rather than against a string somebody invented for the assertion"), 테스트가 편집 전에 그 셋이 실제로 들어 있는지부터 단언한 뒤 편집 결과를 본다.
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc-advanced/grpc-advanced-diagnostics/build.gradle:1-10
|
||||
main/java/…/diagnostics/GrpcAdvancedInfrastructureTestkit.java:1-87
|
||||
main/java/…/diagnostics/GrpcDiagnosticsRedactor.java:1-72
|
||||
main/java/…/diagnostics/GrpcChannelDiagnosticsSnapshot.java:1-63
|
||||
main/java/…/diagnostics/GrpcChannelDiagnosticsPolicy.java:1-55
|
||||
test/java/…/diagnostics/GrpcChannelDiagnosticsPolicyTest.java:1-229
|
||||
test/resources/xds/control-plane-snapshot.json:1-24
|
||||
```
|
||||
@@ -0,0 +1,271 @@
|
||||
# grpc-advanced-edition 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 — `src/main` production 6파일 326줄, 스키마 리소스 1개 28줄, test 2파일 211줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc-advanced/grpc-advanced-edition`
|
||||
> SSOT owner: `grpc-advanced-edition`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `allowed_dependencies`: `["grpc-core-api", "grpc-proto-contract", "grpc-advanced-bootstrap"]`
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
|
||||
| 파일 | LOC |
|
||||
|---|---:|
|
||||
| `GrpcEditionCompatibilityReport` | 72 |
|
||||
| `GrpcEdition2026WatchReport` | 70 |
|
||||
| `GrpcEdition2024Gate` | 61 |
|
||||
| `GrpcEdition2026Guard` | 48 |
|
||||
| `GrpcEdition2024Policy` | 47 |
|
||||
| `GrpcEdition2026Status` | 28 |
|
||||
| **main java 합계 (6파일)** | **326** |
|
||||
| `compatibility.proto` | 28 |
|
||||
| `GrpcEdition2024GateTest` · `GrpcEdition2026GuardTest` | 118 · 93 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 6 | `FULL_READ` | 전 본문 축자 확인 |
|
||||
| `main/resources/proto/edition2024/*.proto` | 1 | `FULL_READ` | 28줄 전문 |
|
||||
| `test/java/**` | 2 | `FULL_READ` | 211줄 전 본문 · 테스트 13개 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 10줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-5
|
||||
// Protobuf Edition lanes. Edition 2024 is an opt-in Advanced lane that must produce cross-consumer
|
||||
// compile evidence before anything public moves onto it; Edition 2026 is a watch lane that records
|
||||
// release/toolchain status and is refused as a Stable contract source.
|
||||
```
|
||||
|
||||
두 레인이 성격이 다르다. 하나는 증거를 만들어야 승격되는 레인이고, 하나는 사실만 기록하는 감시 레인이다.
|
||||
|
||||
## 2. Edition 2024 — 두 결정을 분리한다
|
||||
|
||||
`GrpcEdition2024Policy` 는 모듈 옵트인과 공개 서비스 이동을 따로 다룬다.
|
||||
|
||||
```java
|
||||
public boolean serviceMayMove(String serviceName) {
|
||||
return !publicServices.contains(serviceName) || promotionApproved;
|
||||
}
|
||||
```
|
||||
|
||||
> "the opt-in is a build decision and the promotion is a consumer-migration decision."
|
||||
|
||||
그 이유가 클래스 javadoc 에 있다.
|
||||
|
||||
> "an edition change is invisible to the schema's owner and consequential for its consumers: the
|
||||
> wire bytes are usually identical, so nothing fails locally, and the breakage appears in whichever
|
||||
> consumer's generator handles the edition's features differently."
|
||||
|
||||
## 3. 세 종류의 호환성
|
||||
|
||||
`GrpcEditionCompatibilityReport` 는 하나가 아니라 셋을 본다.
|
||||
|
||||
| 비교 | 답하는 질문 |
|
||||
|---|---|
|
||||
| wire | 저장된 메시지와 이동 중 메시지가 계속 디코딩되는가 |
|
||||
| JSON | 전사 프록시와 브라우저 클라이언트가 계속 동작하는가 |
|
||||
| source(툴체인별) | 생성된 코드가 여전히 컴파일되는가 |
|
||||
|
||||
> "An edition migration can preserve the first two and break the third for a language whose
|
||||
> generator handles the edition's features differently — which is exactly the failure this lane
|
||||
> exists to find before a public service moves."
|
||||
|
||||
그리고 툴체인 결과가 비어 있으면 생성자가 거부한다 — "Java alone is not cross-language evidence."
|
||||
|
||||
## 4. 레인 실패의 범위
|
||||
|
||||
```java
|
||||
blocksStableRelease() → 항상 false
|
||||
blocksEditionPromotion() → 항상 true
|
||||
```
|
||||
|
||||
> "Without that split, an opt-in lane that nobody depends on can hold up every release, and the
|
||||
> first response to that is to stop running the lane."
|
||||
|
||||
두 메서드 모두 상수를 돌려주고 javadoc 이 그 이유를 적는다 — "Stated as a method so the property is tested rather than described."
|
||||
|
||||
## 5. Edition 2026 — 감시 레인
|
||||
|
||||
네 게이트를 따로 추적한다 — 명세, `protoc`, Buf, 자바 런타임.
|
||||
|
||||
> "An edition can be released by the specification while `protoc` does not emit it, or emitted while
|
||||
> Buf cannot lint it, or lintable while the Java runtime does not implement its features. A single
|
||||
> 'supported yes/no' flag collapses four different waiting states into one."
|
||||
|
||||
보고서는 날짜를 필수로 요구한다 — 날짜 없는 감시 기록은 오래된 메모와 구분되지 않는다.
|
||||
|
||||
그리고 가드가 보고서와 **무관하게** 거부한다.
|
||||
|
||||
> "The guard is deliberately not conditional on the watch report… letting the same record also
|
||||
> authorise use means the moment somebody marks four fields SUPPORTED, a schema can move onto an
|
||||
> edition with no promotion decision, no consumer migration and no ADR. Turning the watch into a
|
||||
> lane that can be used is a code change here, and that is the point."
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
두 테스트 211줄 · 13개.
|
||||
|
||||
`GrpcEdition2024GateTest` 7개 — 모듈 옵트인의 기본 꺼짐, 공개 서비스의 승격 요구, 자바 단독이 증거가 아님(빈 툴체인 맵 거부 포함), 세 호환성의 분리, 승격 차단 셋, 레인 실패의 격리, 그리고 픽스처 파일 자체를 리소스로 읽어 `edition = "2024";` 로 시작하는지와 `features.field_presence = EXPLICIT` 를 담는지 대조하는 것.
|
||||
|
||||
`GrpcEdition2026GuardTest` 6개 — 네 게이트의 개별 추적, 날짜 필수, `SUPPORTED` 만 usable, 전부 SUPPORTED 여도 가드가 거부, 거부 메시지의 미해결 항목, 감시 레인이 Stable 빌드를 막지 않음.
|
||||
|
||||
`theGuardIsNotConditionalOnTheReport` 가 이 레인에서 가장 중요한 한 줄을 붙든다 — 보고서가 `readyToEvaluate() == true` 인 상태를 만들어 놓고, 그래도 `requireNotUsedAsSource` 가 던지는지 확인한다. 기록이 사용을 허가하지 않는다는 설계가 테스트로 고정되어 있다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** 리프 밖에서 이 리프를 참조하는 것이 하나도 없다 — 자바 코드도, build.gradle 도.
|
||||
|
||||
```
|
||||
$ grep -rn "advanced.edition" --include=*.java src/ | grep -v grpc-advanced-edition/
|
||||
grpc-core-api/…/GrpcStableModuleCatalog.java:38: "grpc-advanced-edition", ← 목록 안의 문자열
|
||||
$ grep -rn "grpc-advanced-edition" --include=*.gradle src/
|
||||
(매치 없음)
|
||||
```
|
||||
|
||||
**12.2 선언된 의존 셋이 전부 쓰이지 않는다.**
|
||||
|
||||
```groovy
|
||||
api project(':grpc:grpc-core-api')
|
||||
api project(':grpc:grpc-proto-contract')
|
||||
api project(':grpc-advanced:grpc-advanced-bootstrap')
|
||||
```
|
||||
|
||||
이 리프의 자바 6파일에는 `dev.caskeleton` 으로 시작하는 import 가 **한 줄도 없다.**
|
||||
|
||||
```
|
||||
$ grep -rn "^import dev.caskeleton" grpc-advanced/grpc-advanced-edition/src/main/java/
|
||||
(매치 없음)
|
||||
```
|
||||
|
||||
여섯 파일이 쓰는 것은 `java.time` · `java.util` 뿐이다. 세 의존 중 어느 것도 코드에 닿지 않는다.
|
||||
|
||||
셋 중 둘은 의도를 읽을 수 있다 — `grpc-proto-contract` 는 `compatibility.proto` 가 그쪽 스키마 규칙의 관할이라는 선언으로, `grpc-advanced-bootstrap` 은 `GrpcAdvancedCapability.EDITION_2024` 가 이 레인의 등급을 들고 있다는 선언으로. 다만 어느 쪽도 코드로 연결되어 있지 않고, 그 연결 없음이 §17.2 가 지적한 "두 게이트가 서로를 부르지 않는다" 와 같은 사실의 빌드 파일 쪽 표현이다.
|
||||
|
||||
`grpc-advanced-bootstrap` §12.1 이 반대편에서 같은 것을 기록했다 — 그 리프를 의존 선언한 다섯 모듈 중 실제로 부르는 것은 둘뿐이고, 이 리프는 부르지 않는 셋 중 하나다.
|
||||
|
||||
**12.3 대조군.** 무조건 상수를 돌려주고 그것을 테스트가 붙드는 형태가 같은 가족의 `GrpcAdvancedPromotionGate.capabilitiesDraggedAlong` 과 같다. 이 리프에는 그런 메서드가 넷 있다 — `blocksStableRelease` · `blocksEditionPromotion` · `allowedAsStableSource` · `blocksStableBuild`.
|
||||
|
||||
그중 셋(`blocksStableRelease` · `blocksEditionPromotion` · `blocksStableBuild`)은 `capabilitiesDraggedAlong` 과 같은 한계를 갖는다 — 리터럴을 리터럴과 비교하므로, 그 속성이 실제로 지켜지는지는 이 저장소에 릴리스 파이프라인이 생겨야 알 수 있다. `grpc-advanced-bootstrap` §17.5 에 그 판정을 적어 두었다.
|
||||
|
||||
넷째 `allowedAsStableSource` 는 다르다. 같은 클래스의 `requireNotUsedAsSource` 가 그 상수와 **독립적으로** 무조건 던지고, `theGuardIsNotConditionalOnTheReport` 가 "전부 SUPPORTED 인 보고서"라는 실제 상태를 만들어 그 독립성을 확인한다. 상수 하나를 읽는 것이 아니라 설계 속성을 실행으로 밟는다.
|
||||
|
||||
**12.2 대조군.** 무조건 상수를 돌려주고 그것을 테스트가 붙드는 형태가 같은 가족의 `GrpcAdvancedPromotionGate.capabilitiesDraggedAlong` 과 같다. 이 저장소가 "주석이 주장하는 대신 테스트가 붙든다" 를 반복해서 쓴다.
|
||||
|
||||
**12.5 저장소의 `.proto` 넷.** 이 리프의 `compatibility.proto` 는 `edition = "2024";` 로 시작하므로 `grpc-proto-contract` 의 `PROTO3_SYNTAX` 규칙에 걸린다. 그 검증기의 커밋 스키마 테스트가 파일 목록을 하드코딩해 이 파일을 판정하지 않으므로 지금은 충돌하지 않는다. 그 테스트를 전수 훑기로 바꾼다면(그쪽 §17.3) 이 파일에 대한 면제가 함께 필요하다.
|
||||
|
||||
**12.4 드리프트.** build.gradle 이 서술한 두 레인의 성격이 코드와 일치한다. 드리프트 없음.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- `protoc` 을 돌려 이 편집 파일이 실제로 컴파일되는지 확인하지 않았다. 저장소에 protobuf 플러그인이 없다.
|
||||
- 테스트를 실행하지 않았다. 13개 전부 본문으로만 확인했다.
|
||||
- 세 의존이 쓰이지 않는다는 것(§12.2)은 `^import dev.caskeleton` grep 으로 판정했다. 같은 패키지 안의 타입이나 완전 한정명 사용이라면 잡히지 않는다 — 다만 이 리프의 패키지는 `dev.caskeleton.grpc.advanced.edition` 하나이고 세 의존의 패키지와 겹치지 않는다.
|
||||
- 편집 기능(`features.field_presence = EXPLICIT`)이 proto3 의 `optional` 과 같은 유선 결과를 내는지 확인하지 않았다. 그것이 이 레인의 질문이고 §17.1 이 그 질문에 답할 수 없는 이유다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P2 — 비교 픽스처에 비교 대상이 없다
|
||||
|
||||
`compatibility.proto` 의 주석이 존재 이유를 적는다.
|
||||
|
||||
> "It exists to be compiled beside its **proto3 twin** and compared: same fields, same numbers, same
|
||||
> JSON names, with presence expressed by the edition's features rather than by `optional`. The
|
||||
> lane's question is whether the two produce the same wire bytes and the same JSON, and **answering
|
||||
> it needs both files to exist.**"
|
||||
|
||||
그 쌍둥이가 저장소에 없다.
|
||||
|
||||
```
|
||||
$ grep -rn "DocumentSummary" --include=*.proto --include=*.java .
|
||||
./src/grpc-advanced/grpc-advanced-edition/src/main/resources/proto/edition2024/compatibility.proto:17
|
||||
```
|
||||
|
||||
한 곳뿐이다. 같은 필드와 번호를 proto3 로 선언한 파일이 없으므로 비교가 성립하지 않는다.
|
||||
|
||||
그리고 두 번째 전제도 없다. 이 저장소에는 protobuf 플러그인이 어디에도 없다 — `grpc-proto-contract` 와 `adapter-inbound-grpc` 의 build.gradle 이 그 사실을 주석으로 명시한다. 그러므로 편집 파일도 proto3 파일도 컴파일되지 않고, 유선 바이트와 JSON 을 비교할 산출물 자체가 만들어지지 않는다.
|
||||
|
||||
결과적으로 `GrpcEditionCompatibilityReport` 는 사람이 손으로 채우는 기록이 된다. 승격 게이트가 그것을 읽어 판정하므로, 게이트의 입력이 측정이 아니라 선언이다.
|
||||
|
||||
**등급.** Advanced 가족이라 오늘의 배포에는 영향이 없다. 기록하는 이유는 이 리프의 목적이 "공개 서비스가 옮겨 가기 전에 그 실패를 찾는 것" 이고, 그 실패를 찾을 장치가 픽스처 하나만 있고 짝이 없다는 점이다.
|
||||
|
||||
**수정.** `compatibility_proto3.proto` 를 같은 디렉터리에 두어 필드·번호·JSON 이름을 맞추고, 두 파일을 컴파일해 산출물을 비교하는 레인을 만든다. 그 레인이 생기기 전까지는 `GrpcEditionCompatibilityReport` 가 측정이 아니라 선언이라는 것을 자바독에 적는 편이 낫다.
|
||||
|
||||
### 17.2 P3 — 승격 차단 목록에 담금 기간과 실환경 항목이 없다
|
||||
|
||||
`GrpcEdition2024Gate.promotionBlockers` 가 보는 것은 셋이다 — 호환성 보고서의 문제들, 소비자 이관 계획, 승격 ADR.
|
||||
|
||||
같은 가족의 `GrpcAdvancedPromotionGate` 는 `EDITION_2024` 능력에 대해 일곱 증거 항목과 7일 담금을 요구한다. 두 게이트가 같은 능력의 승격을 서로 다른 기준으로 판정한다.
|
||||
|
||||
두 게이트가 각각 다른 것을 묻는다고 볼 수도 있다 — 하나는 편집 자체의 호환성, 하나는 능력의 운영 준비도. 다만 어느 쪽도 상대를 부르지 않고, 문서에도 두 게이트의 관계가 적혀 있지 않다. 승격을 실제로 수행할 때 어느 쪽을 만족해야 하는지가 코드에서 답해지지 않는다.
|
||||
|
||||
수정은 `promotionBlockers` 가 `GrpcAdvancedPromotionGate.evaluate` 의 결과를 포함하게 하거나, 두 게이트의 역할 분담을 자바독에 적는 것이다.
|
||||
|
||||
### 17.3 P3 — 정책의 자바독이 하지 않는 거부를 한다고 적고, 승격 승인이 두 곳에 따로 있다
|
||||
|
||||
**첫째, 서술과 코드가 어긋난다.**
|
||||
|
||||
```java
|
||||
/** Copies both sets and refuses an approval nobody recorded. */
|
||||
public GrpcEdition2024Policy {
|
||||
if (optedInModules == null || publicServices == null) {
|
||||
throw new IllegalArgumentException("an edition policy states both sets");
|
||||
}
|
||||
optedInModules = Set.copyOf(optedInModules);
|
||||
publicServices = Set.copyOf(publicServices);
|
||||
}
|
||||
```
|
||||
|
||||
"refuses an approval nobody recorded" 에 해당하는 검사가 없다. `promotionApproved` 는 읽히지도 검증되지도 않고 그대로 저장된다. `new GrpcEdition2024Policy(Set.of(), Set.of(), true)` — 옵트인한 모듈도 공개 서비스도 없는데 승인만 참인 정책 — 이 아무 저항 없이 만들어지고, `serviceMayMove` 는 모든 서비스에 참을 답한다.
|
||||
|
||||
**둘째, 같은 사실이 두 곳에 따로 있다.**
|
||||
|
||||
| 어디 | 무엇 |
|
||||
|---|---|
|
||||
| `GrpcEdition2024Policy.promotionApproved` | 승격이 승인되었는가 (record 성분) |
|
||||
| `GrpcEdition2024Gate.promotionBlockers(..., boolean promotionAdr)` | 승격 ADR 이 있는가 (메서드 인자) |
|
||||
|
||||
게이트는 정책을 인자로 받지도, 참조하지도 않는다. 그래서 "ADR 이 없다"고 판정한 게이트와 "승인되었다"고 답하는 정책이 동시에 성립할 수 있고, 둘을 맞추는 코드가 없다. §17.2 가 지적한 "두 게이트가 서로를 부르지 않는다" 와 같은 구조가 정책과 게이트 사이에도 있다.
|
||||
|
||||
**왜 P3 인가.** 정책도 게이트도 production 호출자가 없고(§12.1), 승격은 사람이 수행하는 절차다. 다만 이 리프가 존재하는 이유가 "그 절차를 코드로 적어 두는 것" 이므로, 적힌 절차 안에서 같은 사실이 둘로 갈라져 있는 것은 그 목적에 어긋난다.
|
||||
|
||||
**수정.** `promotionBlockers` 가 `GrpcEdition2024Policy` 를 받아 `promotionApproved` 를 `promotionAdr` 자리에 쓰고, 정책 생성자가 자바독대로 "승인이 참이면 그 근거(공개 서비스 집합이 비어 있지 않을 것 등)"를 요구한다. 어느 쪽도 하지 않겠다면 자바독의 그 문장을 지운다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **모듈 옵트인과 공개 서비스 이동을 분리한 것** — 빌드 결정과 소비자 이관 결정은 다른 결정이다.
|
||||
- **호환성을 셋으로 나눈 것** — 앞의 둘이 보존돼도 셋째가 깨지는 것이 이 레인이 찾는 실패다.
|
||||
- **툴체인 결과가 비면 생성자가 거부하는 것** — 자바 하나는 교차 언어 증거가 아니다.
|
||||
- **레인 실패가 Stable 릴리스를 막지 않게 한 것과 그 근거** — 막으면 사람들이 레인을 끄게 된다.
|
||||
- **감시 레인의 네 게이트를 따로 추적한 것.**
|
||||
- **감시 보고서에 날짜를 필수로 둔 것.**
|
||||
- **가드를 보고서와 무관하게 만든 것** — 기록이 사용을 허가하지 않는다. 사용하려면 코드를 고쳐야 한다.
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc-advanced/grpc-advanced-edition/build.gradle:1-10
|
||||
main/java/…/edition/GrpcEditionCompatibilityReport.java:1-72
|
||||
main/java/…/edition/GrpcEdition2026WatchReport.java:1-70
|
||||
main/java/…/edition/GrpcEdition2024Gate.java:1-61
|
||||
main/java/…/edition/GrpcEdition2026Guard.java:1-48
|
||||
main/java/…/edition/GrpcEdition2024Policy.java:1-47
|
||||
main/java/…/edition/GrpcEdition2026Status.java:1-28
|
||||
main/resources/proto/edition2024/compatibility.proto:1-28
|
||||
test/java/…/edition/GrpcEdition2024GateTest.java:1-118
|
||||
test/java/…/edition/GrpcEdition2026GuardTest.java:1-93
|
||||
```
|
||||
@@ -0,0 +1,237 @@
|
||||
test/resources/xds/bootstrap.json
|
||||
---
|
||||
# grpc-advanced-resilience 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 16파일 940줄 + `src/test` 4파일 577줄 + `src/test/resources` 1파일 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc-advanced/grpc-advanced-resilience`
|
||||
> SSOT owner: `grpc-advanced-resilience`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `allowed_dependencies`: core-api · policy · client · discovery · advanced-bootstrap
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
|
||||
| 패키지 | 파일 | LOC |
|
||||
|---|---:|---:|
|
||||
| `resilience` (헤징) | 4 | 242 |
|
||||
| `xds` | 4 | 252 |
|
||||
| `discovery` (사용자 정의 리졸버·LB) | 7 | 446 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 16 | `FULL_READ` | 940줄 전 본문 |
|
||||
| `test/java/**` | 4 | `FULL_READ` | 577줄 |
|
||||
| `test/resources/xds/bootstrap.json` | 1 | `FULL_READ` | 커밋된 부트스트랩 픽스처 — §12.5 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 전문 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-4
|
||||
// Resilience and discovery capabilities that Stable refuses: read-only unary hedging, the custom
|
||||
// name resolver SPI, the custom load balancer SPI, and the proxyless xDS experimental profile.
|
||||
```
|
||||
|
||||
## 2. 헤징은 읽기 전용 단항만
|
||||
|
||||
`GrpcHedgingEligibility` 가 세 조건을 순서대로 본다 — 단항이 아님, 읽기 전용이 아님, 재시도 소유자가 in-process 헤징을 허락하지 않음.
|
||||
|
||||
> "A hedged mutation runs twice by design rather than by accident — both attempts are in flight, both
|
||||
> may reach the server, and an idempotency key does not help because the second attempt is not a
|
||||
> retry of a failure but a duplicate of a success in progress. A hedged stream is worse still: two
|
||||
> streams deliver two prefixes."
|
||||
|
||||
멱등 키가 왜 도움이 되지 않는지를 한 문장으로 정리한 것이 이 리프의 핵심 판단이다.
|
||||
|
||||
## 3. 헤징 예산
|
||||
|
||||
토큰 버킷이다. 헤지 하나가 `round(1/ratio)` 토큰을 쓰고, 완료된 호출 하나가 토큰 하나를 돌려준다. 상한이 조용한 구간 뒤의 폭주를 제한한다.
|
||||
|
||||
비율 상한이 0.5 이고 그 근거가 적혀 있다.
|
||||
|
||||
> "a hedging ratio above 0.5 means more than half of all calls are duplicated, which is a load
|
||||
> decision rather than a latency one"
|
||||
|
||||
그리고 왜 재시도 예산보다 더 급한지도 적는다.
|
||||
|
||||
> "A retry happens after a failure; a hedge happens on a call that might have succeeded, so a fleet
|
||||
> that hedges without a budget doubles its backend load in the steady state and doubles it again the
|
||||
> moment latency rises."
|
||||
|
||||
소비는 정확한 비교 후 교체 루프다 — 이 가족에서 원자성을 제대로 다룬 몇 안 되는 곳이다.
|
||||
|
||||
## 4. xDS 시작 가드
|
||||
|
||||
두 거절이 있고 javadoc 이 둘째를 더 중요하다고 적는다.
|
||||
|
||||
> "xDS working in a deployment is not the same claim as the platform supporting it: it brings a
|
||||
> control plane, its outage modes, its own security boundary and its own version skew, and the
|
||||
> Stable support statement covers DNS and static targets. A support matrix that quietly widens is a
|
||||
> support matrix nobody can rely on."
|
||||
|
||||
시작 차단 사유는 둘 — 능력이 사용 가능하지 않음, 그리고 애플리케이션이 재시도 정책을 함께 정의함.
|
||||
|
||||
> "with xDS the control plane owns it, and defining it in both places makes the winner depend on
|
||||
> resolution order"
|
||||
|
||||
부트스트랩 대조는 세 가지를 본다 — `xds_servers` 선언, 통제 평면 채널의 TLS, 프로파일의 자원 이름공간.
|
||||
|
||||
> "a client whose bootstrap names a namespace the deployment did not configure subscribes
|
||||
> successfully and receives another team's routing. Nothing errors — the control plane answers, the
|
||||
> resources parse, and traffic goes somewhere nobody chose."
|
||||
|
||||
## 5. 사용자 정의 리졸버·LB 안전 규칙
|
||||
|
||||
리졸버는 주소와 검증된 서비스 설정만 줄 수 있다.
|
||||
|
||||
> "A resolver runs inside the channel and speaks to something outside the deployment. Everything it
|
||||
> can put into an update is therefore attacker-influenced in the worst case."
|
||||
|
||||
권한 문자열 형태 검사, 개정 번호의 전진 요구, 자격증명 형태 필드 거부 셋이다.
|
||||
|
||||
선택기는 두 규칙을 받는다 — 리졸버가 준 엔드포인트만 고를 수 있고, 던지면 결정적 대체로 떨어진다.
|
||||
|
||||
> "a picker that can invent an address can send a request anywhere … a picker bug should degrade the
|
||||
> balancing rather than the availability"
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** Advanced 가족이므로 배선 경로가 없다.
|
||||
|
||||
**12.2 대조군 — 원자성.** `GrpcHedgingBudget.tryConsume` 이 비교 후 교체 루프를 정확히 쓴다. 같은 가족의 `GrpcAdmissionController.tryAdmit`·`GrpcStreamAdmission.tryAdmit` 은 같은 문제를 비원자적으로 푼다. 정본이 이 리프에 있다.
|
||||
|
||||
**12.3 리프 밖 참조 0.** 세 패키지 각각을 확인했다 — `advanced.discovery`·`advanced.resilience`·`advanced.xds` 를 import 하는 파일이 이 리프 밖에 없다. `grpc-advanced-bootstrap` 도 포함해서다.
|
||||
|
||||
**12.4 드리프트.** build.gradle 이 서술한 네 능력이 전부 존재한다.
|
||||
|
||||
**12.5 대조군 — 커밋된 픽스처를 판정기에 넣는가.** 이 리프는 넣는다.
|
||||
|
||||
```java
|
||||
// GrpcXdsStartupGuardTest: "the committed bootstrap fixture agrees with the profile it is meant to serve"
|
||||
String bootstrap = resource("xds/bootstrap.json");
|
||||
assertThat(GrpcXdsStartupGuard.bootstrapMismatches(profile, bootstrap)).isEmpty();
|
||||
```
|
||||
|
||||
같은 자리에서 `grpc-advanced-compat` 은 넣지 않는다 — `envoy.yaml` 을 부분 문자열로만 확인하고 `GrpcWebProxyContract` 에 넣지 않는다(그 리프 §17.3). 두 리프가 같은 재료를 갖고 한 쪽만 고리를 닫았다.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 실제 xDS 통제 평면을 세워 부트스트랩 대조를 재현하지 않았다.
|
||||
- 헤징 예산의 정상 상태 비율을 부하로 측정하지 않았다. 토큰 계산으로 판정했다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P3 — 부트스트랩 대조가 문서 어디든의 부분 문자열을 본다
|
||||
|
||||
```java
|
||||
if (!bootstrapJson.contains("\"xds_servers\"")) { … }
|
||||
if (!bootstrapJson.contains("\"channel_creds\"") || !bootstrapJson.contains("\"tls\"")) { … }
|
||||
if (!bootstrapJson.contains(profile.resourceNamespace())) { … }
|
||||
```
|
||||
|
||||
세 검사가 모두 문서 전체에 대한 부분 문자열 포함이다. JSON 파서를 쓰지 않은 이유는 자바독이 밝힌다 — 세 필드를 보려고 파서를 xDS 를 켜는 모든 배포의 실행 클래스패스에 올리지 않겠다는 것이다. 그 판단 자체는 이 저장소의 다른 결정들과 일관된다.
|
||||
|
||||
다만 검사의 형태가 그 판단보다 느슨하다.
|
||||
|
||||
- `"tls"` 가 문서 어디에든 있으면 통과한다. 통제 평면 채널이 `insecure` 로 설정되어 있고 다른 곳(예: 서버 리스너 설정)에 `tls` 라는 낱말이 있으면 두 번째 검사가 지나간다.
|
||||
- 자원 이름공간이 주석·다른 필드·다른 서버 항목에 있어도 통과한다. 세 번째 검사가 막으려는 것은 "이 클라이언트가 자기 이름공간 밖을 구독하는 것" 인데, 문자열이 어딘가에 있다는 것은 그것이 이 클라이언트의 구독 대상이라는 뜻이 아니다.
|
||||
|
||||
그리고 이 검사가 막으려는 실패는 자바독이 스스로 "조용하다" 고 적은 것이다 — 아무것도 오류가 되지 않는 종류다. 느슨한 검사와 조용한 실패의 조합이 이 항목을 기록하는 이유다.
|
||||
|
||||
수정은 파서를 들이지 않고도 가능하다 — `"channel_creds"` 를 포함하는 객체 범위 안에서 `"type"` 값을 찾는 정도의 구조 인식이면 두 번째 검사가 실제 조건에 가까워진다. 또는 파서를 테스트 범위에만 두고 이 가드는 형태를 좁힌 정규식으로 바꾼다.
|
||||
|
||||
### 17.2 P3 — 대체 선택기는 사용자 정의 선택기가 받는 보호를 받지 않는다
|
||||
|
||||
```java
|
||||
try {
|
||||
chosen = picker.pick(selectable);
|
||||
} catch (RuntimeException pickerFailure) {
|
||||
return GrpcLoadBalancerDecision.fallback(fallback.pick(selectable), "…");
|
||||
}
|
||||
if (chosen == null || !selectable.contains(chosen)) {
|
||||
return GrpcLoadBalancerDecision.fallback(fallback.pick(selectable), "…");
|
||||
}
|
||||
```
|
||||
|
||||
`fallback.pick(selectable)` 은 감싸이지 않는다. 대체가 던지면 예외가 그대로 올라가고, 널이나 목록 밖 엔드포인트를 돌려주면 그대로 결정이 된다.
|
||||
|
||||
기본 생성자는 플랫폼의 라운드 로빈을 대체로 쓰므로 지금은 안전하다. 그러나 두 인자 생성자가 임의의 선택기를 대체로 받고, 그 인자에는 아무 제약이 없다.
|
||||
|
||||
이 클래스의 존재 이유가 "선택기 버그가 가용성이 아니라 균형을 저하시키게 하는 것" 인데, 대체 선택기의 버그는 가용성을 저하시킨다.
|
||||
|
||||
수정은 대체 호출도 같은 검사를 지나게 하거나(그 결과가 널이거나 목록 밖이면 플랫폼 라운드 로빈으로 한 번 더 떨어진다), 두 인자 생성자를 없애 대체를 플랫폼 것으로 고정하는 것이다.
|
||||
|
||||
### 17.3 P2 — 리졸버의 개정 가드가 비교 후 교체가 아니다
|
||||
|
||||
`GrpcCustomResolver` 의 javadoc 이 지키겠다고 하는 것은 명확하다.
|
||||
|
||||
> "Stale revisions and empty endpoint sets are dropped rather than propagated."
|
||||
|
||||
빈 집합은 `GrpcEndpointSnapshot` 의 생성자가 지키므로 성립한다. 개정 가드는 그렇지 않다.
|
||||
|
||||
```java
|
||||
public List<String> offer(GrpcResolverUpdate update) {
|
||||
if (closed.get()) { return List.of(…); }
|
||||
List<String> violations = GrpcResolverSafetyPolicy.violations(update, applied.get()); // ← 읽기
|
||||
if (!violations.isEmpty()) { return violations; }
|
||||
applied.set(update.snapshot()); // ← 조건 없는 쓰기
|
||||
listener.accept(update);
|
||||
return List.of();
|
||||
}
|
||||
```
|
||||
|
||||
`AtomicReference` 를 쓰면서 읽기와 쓰기 사이에 원자성이 없다. 개정 5 와 6 을 든 두 스레드가 같은 `applied`(개정 4)를 읽으면 둘 다 `supersedes` 를 통과하고, 나중에 `set` 하는 쪽이 이긴다. 6 이 먼저 쓰이고 5 가 덮으면 **채널이 옛 엔드포인트로 되돌아간다** — 개정 번호가 존재하는 이유가 정확히 그것을 막는 것이다.
|
||||
|
||||
`listener.accept(update)` 도 `set` 밖에 있으므로, `applied` 의 최종 값이 옳더라도 리스너(=채널)가 받는 순서는 뒤집힐 수 있다. 채널은 마지막으로 받은 것을 믿는다.
|
||||
|
||||
같은 형태가 이 가족에 셋이다.
|
||||
|
||||
| 자리 | 형태 |
|
||||
|---|---|
|
||||
| `GrpcHedgingBudget.tryConsume`(이 리프) | 비교 후 교체 루프 — 정확 |
|
||||
| `GrpcCredentialRotationManager.rotate`·`completeDrain`(grpc-policy §17.2) | 읽고 조건 없이 쓴다 |
|
||||
| `GrpcChannelRuntimeRegistry.rotate`(grpc-client) | 같은 형태 |
|
||||
| `GrpcCustomResolver.offer`(여기) | 같은 형태 |
|
||||
|
||||
정본이 같은 리프 안에 있다는 점이 §12.2 의 대조와 같다 — 이 리프는 예산에서는 CAS 를 쓰고 리졸버에서는 쓰지 않는다.
|
||||
|
||||
**시험이 보지 못하는 이유.** `a stale revision is dropped rather than applied` 는 단일 스레드에서 개정 2 를 적용한 뒤 개정 1 을 제시한다. 순차적으로는 가드가 정확히 작동한다.
|
||||
|
||||
**등급.** 이 리프가 배선되지 않으므로 P2. 리졸버는 정의상 외부 발견 소스가 밀어 넣는 것이고, 그 소스가 한 스레드만 쓴다는 보장은 이 클래스가 하지 않는다.
|
||||
|
||||
**수정.** `applied.updateAndGet` 안에서 판정과 교체를 함께 하거나, `compareAndSet(observed, snapshot)` 이 실패하면 다시 읽어 판정한다. 리스너 통지는 성공한 CAS 뒤에 그 CAS 가 이긴 순서로 해야 한다 — 예산 쪽의 `tryConsume` 루프가 같은 리프 안의 본보기다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **헤징을 읽기 전용 단항으로 한정하고, 멱등 키가 왜 도움이 되지 않는지를 명시한 것.**
|
||||
- **헤징 예산의 비율 상한 0.5 와 그 근거.**
|
||||
- **예산 소비를 정확한 비교 후 교체로 구현한 것.**
|
||||
- **xDS 를 Stable 지원으로 광고할 수 없게 상수로 못박은 것.**
|
||||
- **애플리케이션과 통제 평면이 재시도를 함께 정의하는 것을 시작 차단 사유로 둔 것.**
|
||||
- **부트스트랩과 프로파일의 불일치를 검사 대상으로 삼은 것** — 두 문서를 다른 사람이 다른 저장소에서 쓴다.
|
||||
- **리졸버가 자격증명을 실을 수 없게 한 것과 권한 문자열 형태를 제한한 것.**
|
||||
- **리졸버 업데이트의 개정 번호 전진을 요구한 것.**
|
||||
- **선택기가 리졸버가 준 엔드포인트만 고르게 한 것.**
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc-advanced/grpc-advanced-resilience/build.gradle
|
||||
main/java/…/resilience/(GrpcHedgingEligibility · GrpcHedgingBudget · GrpcHedgingPolicy · GrpcHedgingResult)
|
||||
main/java/…/xds/(GrpcXdsStartupGuard · GrpcXdsFailurePolicy · GrpcXdsProfile · GrpcXdsResourceSnapshot)
|
||||
main/java/…/discovery/(GrpcResolverSafetyPolicy · GrpcLoadBalancerSafetyPolicy · GrpcCustomResolver · GrpcLoadBalancerDecision · GrpcEndpointSnapshot · GrpcEndpointCandidate · GrpcResolverUpdate · GrpcLoadBalancerPicker)
|
||||
```
|
||||
@@ -0,0 +1,237 @@
|
||||
# grpc-advanced-streaming 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 14파일 833줄 + `src/test` 4파일 429줄 축자 통독 완료. §17.1·§17.2 를 독립적으로 재도출했고 둘 다 성립한다. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc-advanced/grpc-advanced-streaming`
|
||||
> SSOT owner: `grpc-advanced-streaming`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `allowed_dependencies`: `["grpc-core-api", "grpc-policy", "grpc-advanced-bootstrap"]`
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
|
||||
| 파일 | LOC |
|
||||
|---|---:|
|
||||
| `GrpcClientMessageDeduplicator` | 123 |
|
||||
| `GrpcDemandController` | 105 |
|
||||
| `GrpcBidiSession` · `GrpcBidiSequenceTracker` | 77 · 62 |
|
||||
| `GrpcBidiDirectionState` · `GrpcClientStreamResumeDecision` · `GrpcClientStreamCheckpoint` | 61 · 58 · 56 |
|
||||
| `GrpcClientStreamPolicy` · `GrpcClientStreamSessionId` · `GrpcManualFlowControlPolicy` | 50 · 46 · 45 |
|
||||
| `GrpcBidiResumeState` · `GrpcDemandDecision` · `GrpcClientStreamState` · `GrpcClientStreamMessage` | 42 · 40 · 36 · 32 |
|
||||
| test 4파일 | 429 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 14 | `FULL_READ` | 833줄 전 본문 |
|
||||
| `test/java/**` | 4 | `FULL_READ` | 429줄 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 전문 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-5
|
||||
// The streaming shapes the Stable plan deliberately excludes: client streaming sessions with
|
||||
// dedup/checkpoint/resume, bidirectional sessions with independent per-direction sequences, and the
|
||||
// manual flow-control approval API.
|
||||
```
|
||||
|
||||
## 2. 적용됨과 수신됨을 구분한다
|
||||
|
||||
`GrpcClientStreamCheckpoint` javadoc:
|
||||
|
||||
> "Applied, not received. The distinction is the whole contract: the transport acknowledging a
|
||||
> message means it reached the server's buffer, and a checkpoint means the application committed its
|
||||
> effect. A resume that continues from a transport acknowledgement skips everything that was
|
||||
> received and not yet applied when the connection died."
|
||||
|
||||
그리고 체크포인트는 뒤로 갈 수 없다 — 뒤로 가려는 시도는 두 기록자가 한 세션을 체크포인트하고 있다는 뜻이다.
|
||||
|
||||
## 3. 집합이 아니라 체크포인트
|
||||
|
||||
`GrpcClientMessageDeduplicator` javadoc:
|
||||
|
||||
> "Checkpoint-based rather than a set of seen keys. **A set grows without bound for the life of a
|
||||
> session** and answers 'have I seen this' — which is not quite the question. The question is 'has
|
||||
> this been applied', and a monotonic applied-sequence answers it in constant space and survives the
|
||||
> process restart that a set does not."
|
||||
|
||||
판정은 셋이다 — 이미 적용됨이면 재생, 다음 순번보다 앞서면 간극, 아니면 적용.
|
||||
|
||||
재개 판정은 두 겹이다. 제시한 호출자가 세션 소유자와 다르면 거절하고, 체크포인트가 없으면 새 세션으로 돌린다.
|
||||
|
||||
> "the server holds no checkpoint for this session; resuming would leave its prefix either lost or
|
||||
> applied twice, with nothing to tell which"
|
||||
|
||||
그리고 적용 기록의 자바독이 저장소 쪽 요구를 적는다 — 적용 효과와 체크포인트는 한 트랜잭션에 있어야 하며, 따로 커밋하면 효과는 내구적이고 체크포인트는 아닌 창이 생긴다.
|
||||
|
||||
## 4. 방향마다 독립된 순번
|
||||
|
||||
> "the client's message 5 and the server's message 5 are unrelated events, and a shared counter makes
|
||||
> a resume token from one side meaningless to the other — so a reconnect either skips or replays,
|
||||
> depending on which side moved faster."
|
||||
|
||||
절반 닫기와 취소가 방향별로 따로 있다.
|
||||
|
||||
## 5. 수동 흐름 제어
|
||||
|
||||
승인이 record 의 필드이고 거짓이면 생성자가 거부한다.
|
||||
|
||||
> "Approval is a field because this capability is granted per method, not per service. A method that
|
||||
> reads a large result set benefits; the one next to it does not, and enabling both because they
|
||||
> share a service is how the second one acquires a bug nobody was looking for."
|
||||
|
||||
수요 상한과 교착 감시가 필수다 — 상한 없는 `request(n)` 은 단계만 늘린 무제한 버퍼링이다.
|
||||
|
||||
감시견은 잠들지 않고 두 시각을 비교한다 — 마지막으로 수요를 요청한 때와 마지막으로 메시지가 움직인 때. 둘 다 시간 제한만큼 멈춰 있으면 교착이다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
네 테스트 429줄. 중복 제거 판정과 재개, 수요 상한과 교착, 방향별 순번, 클라이언트 스트림 정책 거부를 확인한다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** Advanced 가족이므로 배선 경로가 없다. 리프 밖 참조도 없다.
|
||||
|
||||
**12.2 대조군 — 동시성 규율.** 이 리프는 가족 안에서 동시성을 가장 잘 다룬다.
|
||||
|
||||
| 클래스 | 보호 |
|
||||
|---|---|
|
||||
| `GrpcDemandController` | 모든 공개 메서드 `synchronized` |
|
||||
| `GrpcBidiSequenceTracker` | 모든 공개 메서드 `synchronized` |
|
||||
| `GrpcClientMessageDeduplicator` | `ConcurrentHashMap` 둘 |
|
||||
|
||||
특히 `GrpcDemandController.messageReceived` 의 `if (outstandingDemand > 0) outstandingDemand--;` 는 `synchronized` 안이라 경합하지 않는다. 같은 형태가 `grpc-server` 의 `GrpcAdmissionController.release` 와 `grpc-client` 의 `GrpcChannelRuntime.finishUnaryCall` 에서는 보호 없이 쓰여 각각 결함이 된다.
|
||||
|
||||
**12.4 드리프트.** build.gradle 이 서술한 세 요소가 전부 존재한다.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 실제 스트림을 열어 재개를 재현하지 않았다. 배선 경로가 없다.
|
||||
- `replayableOutcomes` 의 증가를 장시간 실행으로 측정하지 않았다(§17.1). 제거 경로 부재로 판정했다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P3 — 클래스가 비판한 무제한 증가를 형제 맵이 그대로 한다
|
||||
|
||||
클래스 javadoc 이 집합 방식을 거부한 이유가 무제한 증가다 — "A set grows without bound for the life of a session".
|
||||
|
||||
체크포인트는 그 비판을 지킨다. 세션당 항목 하나이고 순번만 앞으로 간다.
|
||||
|
||||
형제 맵은 지키지 않는다.
|
||||
|
||||
```java
|
||||
private final ConcurrentMap<String, String> replayableOutcomes = new ConcurrentHashMap<>();
|
||||
…
|
||||
public void recordApplied(GrpcClientStreamMessage<?> message, String outcomeReference, Instant at) {
|
||||
checkpoints.put(message.sessionId().value(), checkpoint.advancedTo(message.sequence(), at));
|
||||
if (outcomeReference != null && !outcomeReference.isBlank()) {
|
||||
replayableOutcomes.put(message.dedupKey(), outcomeReference); // ← 메시지마다 한 항목
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
제거는 `endSession` 뿐이고, 그때 그 세션의 접두를 가진 키를 전부 지운다.
|
||||
|
||||
그러므로 결과 참조를 기록하는 세션에서는 적용된 메시지 수만큼 항목이 쌓인다. 상한도 만료도 없다.
|
||||
|
||||
클래스 javadoc 은 다르게 말한다.
|
||||
|
||||
> "Replayed outcomes are kept for **the small window after the checkpoint**, so a duplicate that
|
||||
> arrives before the checkpoint advances gets the original answer rather than being reapplied."
|
||||
|
||||
작은 창이 코드에 없다. 체크포인트가 앞으로 가도 그 이전 결과들은 남는다.
|
||||
|
||||
그리고 실제로 필요한 창은 좁다 — 판정이 `alreadyApplied(sequence)` 로 재생을 결정하고, 재생 응답에 쓰이는 것은 그 순번의 결과 하나다. 체크포인트보다 한참 뒤처진 순번의 결과가 필요할 상황은 재개 직후의 좁은 구간뿐이다.
|
||||
|
||||
수정은 창을 실제로 만드는 것이다 — 세션당 최근 N개만 유지하거나, 체크포인트가 앞으로 갈 때 그보다 오래된 항목을 지운다. 후자가 자바독의 서술과 정확히 같다.
|
||||
|
||||
### 17.2 P3 — 클라이언트 스트림 정책의 네 상한 중 둘은 읽는 코드가 없다
|
||||
|
||||
`GrpcClientStreamPolicy` javadoc 이 네 상한을 모두 든다.
|
||||
|
||||
> "all four bounds are about the client rather than the server: how long it may hold the stream, how
|
||||
> long it may go quiet, how fast it may send, and how much it may have unacknowledged."
|
||||
|
||||
저장소 전체에서 접근자 호출을 세면 둘이 0 이다.
|
||||
|
||||
```
|
||||
maxMessagesPerSecond production 호출 0
|
||||
maxInFlightMessages production 호출 0
|
||||
wholeStreamRetryAllowed production 호출 0
|
||||
```
|
||||
|
||||
Advanced 가족이 미배선이라는 사실과는 별개다 — 이 리프 안에도 그 값을 쓰는 코드가 없다. 수요 상한을 강제하는 `GrpcDemandController` 는 `GrpcManualFlowControlPolicy` 를 쓰고, 이 정책을 보지 않는다.
|
||||
|
||||
`wholeStreamRetryAllowed()` 는 항상 거짓을 돌려주는 형태이므로 그 자체가 문서화 장치다. 나머지 둘은 강제 지점이 필요하다.
|
||||
|
||||
수정은 상한을 강제하는 지점을 만들거나(수신 경로에 속도·미확인 수 검사), 강제되지 않는 값이 강제되는 것처럼 읽히지 않도록 자바독을 낮추는 것이다.
|
||||
|
||||
### 17.3 P3 — 체크포인트 전진이 `ConcurrentMap` 위의 확인 후 쓰기다
|
||||
|
||||
`GrpcClientStreamCheckpoint.advancedTo` 가 뒤로 가는 것을 거부하고, 그 메시지가 원인을 정확히 짚는다 — "two writers are checkpointing one session". 그 가드가 보는 것은 **호출한 스레드가 읽은 값** 이다.
|
||||
|
||||
```java
|
||||
public void recordApplied(GrpcClientStreamMessage<?> message, String outcomeReference, Instant at) {
|
||||
GrpcClientStreamCheckpoint checkpoint = requireCheckpoint(message.sessionId()); // ← 읽기
|
||||
checkpoints.put(message.sessionId().value(), checkpoint.advancedTo(message.sequence(), at)); // ← 조건 없는 쓰기
|
||||
…
|
||||
```
|
||||
|
||||
두 스레드가 순번 5 와 6 을 적용하며 같은 체크포인트(4)를 읽으면 둘 다 `advancedTo` 를 통과한다. 5 를 든 쪽이 나중에 `put` 하면 체크포인트는 6 에서 5 로 **뒤로 간다** — `advancedTo` 가 막겠다고 한 바로 그 상태이고, 이번에는 예외 없이 조용히 일어난다.
|
||||
|
||||
그러면 순번 6 의 메시지가 다시 `APPLY` 로 판정되어 두 번 적용된다. 이 클래스가 존재하는 이유가 정확히 그것을 막는 것이다.
|
||||
|
||||
`ConcurrentHashMap` 에는 이 형태를 위한 연산이 있다.
|
||||
|
||||
```java
|
||||
checkpoints.compute(key, (k, existing) -> existing.advancedTo(message.sequence(), at));
|
||||
```
|
||||
|
||||
`compute` 안에서는 읽기와 쓰기가 원자적이므로, 뒤처진 쪽이 `advancedTo` 의 예외를 실제로 받는다 — 가드가 설계대로 발화한다.
|
||||
|
||||
**대조.** 같은 리프의 `GrpcDemandController` 는 모든 공개 메서드가 `synchronized` 이고, `GrpcBidiSequenceTracker` 도 그렇다(§12.2 가 그것을 이 가족의 모범으로 든다). 중복 제거기만 `ConcurrentMap` 의 원자 연산을 쓰지 않는다.
|
||||
|
||||
**시험이 보지 못하는 이유.** 중복 제거기 시험 아홉 개가 전부 단일 스레드다. 순차적으로는 `advancedTo` 가 정확히 작동하고, 전용 시험(`aCheckpointRecordsWhatWasApplied`)이 그것을 확인한다 — 확인하는 것은 record 의 메서드이지 맵에 쓰는 경로가 아니다.
|
||||
|
||||
**등급.** 미배선이므로 P3. 다만 이 클래스의 javadoc 이 "The application effect and this checkpoint belong in one transaction" 이라고 적어 둔 것과 함께 보면, 이 자리는 배선되는 날 트랜잭션 경계와 함께 다시 설계될 곳이다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **적용됨과 수신됨을 구분하고 그 차이를 계약으로 삼은 것.**
|
||||
- **집합 대신 단조 증가 순번으로 상수 공간을 쓴 것.**
|
||||
- **체크포인트가 뒤로 가려는 시도를 두 기록자의 신호로 읽는 것.**
|
||||
- **재개에서 소유자 불일치를 거절하고, 체크포인트 부재를 새 세션으로 돌리는 것.**
|
||||
- **적용 효과와 체크포인트를 한 트랜잭션에 두라는 요구를 자바독에 남긴 것.**
|
||||
- **방향별 순번을 합치지 않은 것과 그 근거.**
|
||||
- **수동 흐름 제어 승인을 메서드 단위 필드로 둔 것.**
|
||||
- **감시견이 잠들지 않고 두 시각을 비교하는 것.**
|
||||
- **전체 스트림 재시도를 설정이 아니라 상수 거절로 둔 것.**
|
||||
- **동시성 보호를 실제로 적용한 것** — 가족의 다른 리프와 대조된다.
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc-advanced/grpc-advanced-streaming/build.gradle
|
||||
main/java/…/streaming/GrpcClientMessageDeduplicator.java:1-123
|
||||
main/java/…/streaming/GrpcDemandController.java:1-105
|
||||
main/java/…/streaming/GrpcBidiSequenceTracker.java:1-62
|
||||
main/java/…/streaming/GrpcClientStreamCheckpoint.java:1-56
|
||||
main/java/…/streaming/GrpcClientStreamPolicy.java:1-50
|
||||
main/java/…/streaming/GrpcManualFlowControlPolicy.java:1-45
|
||||
main/java/…/streaming/GrpcClientStreamMessage.java:1-32
|
||||
main/java/…/streaming/(GrpcBidiSession · GrpcBidiDirectionState · GrpcBidiResumeState · GrpcClientStreamResumeDecision · GrpcClientStreamSessionId · GrpcDemandDecision · GrpcClientStreamState)
|
||||
test/java/…/streaming/(GrpcClientMessageDeduplicatorTest · GrpcDemandControllerTest · GrpcBidiSequenceTrackerTest · GrpcClientStreamPolicyTest)
|
||||
```
|
||||
@@ -0,0 +1,246 @@
|
||||
# grpc-client 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 13파일 931줄 + `src/test` 4파일 581줄 축자 통독 완료. 재통독에서 §17.1–§17.4 를 독립적으로 재도출했고 넷 다 성립한다. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc/grpc-client`
|
||||
> SSOT owner: `grpc-client`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `allowed_dependencies`: `["grpc-core-api", "grpc-policy"]` + vendor `grpc-api`·`grpc-stub`(BOM)
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
|
||||
| 파일 | LOC |
|
||||
|---|---:|
|
||||
| `GrpcChannelRuntimeRegistry` | 137 |
|
||||
| `GrpcTypedStubFactory` | 119 |
|
||||
| `GrpcNamedChannelProfile` | 100 |
|
||||
| `GrpcChannelRuntime` | 96 |
|
||||
| `GrpcClientMetadataPolicy` | 87 |
|
||||
| `GrpcChannelProfileValidator` | 81 |
|
||||
| `GrpcStubPolicyApplier` · `GrpcClientCallContext` | 68 · 67 |
|
||||
| `GrpcChannelGeneration` · `GrpcLoadBalancingPolicy` · `GrpcChannelDrainPolicy` · `GrpcStubDescriptor` · `GrpcCallCredentialProvider` | 42 · 35 · 34 · 33 · 32 |
|
||||
| test 4파일 | 581 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 13 | `FULL_READ` | 931줄 전 본문 |
|
||||
| `test/java/**` | 4 | `FULL_READ` | 581줄 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 17줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-4
|
||||
// Client runtime: named channel profiles, channel runtime generations with drain, the typed stub
|
||||
// factory that refuses to hand a raw Channel to application code, and client metadata/credentials.
|
||||
```
|
||||
|
||||
## 2. 채널은 한 번 만들고 재사용한다
|
||||
|
||||
레지스트리 javadoc 이 두 성질을 든다.
|
||||
|
||||
> "a channel is created once and reused. Creating one per request is a mistake that works — every
|
||||
> call succeeds — while spending a TCP handshake, a TLS handshake and an HTTP/2 setup on each one,
|
||||
> and it is usually found by a connection count rather than by a failure."
|
||||
|
||||
> "prepare-then-swap. The new runtime exists before the pointer moves, so no call ever finds nothing
|
||||
> there; the old one drains rather than being closed under its in-flight work."
|
||||
|
||||
`require` 가 빈 값을 돌려주지 않고 던지는 이유도 적혀 있다 — 빈 값은 "설정되지 않음" 과 "설정됐지만 도달 불가" 를 구분할 수 없게 만든다.
|
||||
|
||||
## 3. 세대와 배수
|
||||
|
||||
`GrpcChannelRuntime` 이 단항 호출과 열린 스트림을 따로 센다.
|
||||
|
||||
> "a drain treats them differently: unary calls are waited for, streams are signalled. A single
|
||||
> counter would make the drain either cut a stream that could have finished or wait an hour for one
|
||||
> that never will."
|
||||
|
||||
그리고 기저 채널을 노출하지 않는다 — 그것을 건네는 것이 정책 없는 스텁이 만들어지는 경로다.
|
||||
|
||||
## 4. 타입 있는 스텁 공장 — 두 거절
|
||||
|
||||
> "It will not build a stub type nobody registered, so a service cannot acquire a channel without a
|
||||
> policy; and it never returns a `Channel` or a builder, so application code has no way to construct
|
||||
> one itself. Both are what make the raw-API import rule enforceable rather than merely stated:
|
||||
> there is nothing to reach for."
|
||||
|
||||
등록 함수가 원시 채널이 아니라 런타임을 받는 것도 같은 이유다 — 등록이 채널을 몰래 빼돌릴 수 없다.
|
||||
|
||||
빈 공장은 만들 수 없다 — "a stub factory with no registered types can build nothing and refuses everything."
|
||||
|
||||
## 5. 메타데이터 허용 목록이 둘인 이유
|
||||
|
||||
> "Tenant and actor metadata is meaningful to a service inside the same trust domain and is an
|
||||
> unverified assertion to one outside it; sending it across the boundary invites the receiver to
|
||||
> trust it."
|
||||
|
||||
그리고 교차 경계 목록은 같은 도메인 목록의 부분집합이어야 한다 — 생성자가 강제한다.
|
||||
|
||||
인가 헤더는 어느 목록에도 올 수 없다.
|
||||
|
||||
> "`authorization` is supplied per call by a credential provider, not set as metadata; a header set
|
||||
> by the application is a header that survives a rotation."
|
||||
|
||||
나가는 방향은 허용 목록 밖을 거절이 아니라 폐기로 다룬다. 그 비대칭의 이유도 적혀 있다 — 알 수 없는 상관 헤더 때문에 나가는 호출이 실패하는 것이 더 나쁜 결과다. 예산은 그대로 강제된다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
네 테스트 581줄. 프로파일 검증, 레지스트리 설치·회전·배수, 메타데이터 정책, 스텁 공장의 두 거절을 확인한다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** build-only. `grpc-spring-boot-starter` 는 이 리프의 타입을 빈으로 만들지 않는다(§20 가족 문서 §3.4).
|
||||
|
||||
**12.2 대조군 — 비원자적 해제.** 이 리프의 `finishUnaryCall`·`closeStream` 과 `grpc-server` 의 `GrpcAdmissionController.release`, `grpc-policy` 의 `GrpcStreamAdmission.release` 가 같은 형태다 — `get() > 0` 을 본 뒤 별도로 감소. §17.2 가 이 리프에서의 구체적 결과를 다룬다.
|
||||
|
||||
**12.3 이 리프를 import 하는 곳.** 재통독에서 다시 세었다. `grpc-discovery` main 셋(`GrpcResolverProfile`·`GrpcStableLoadBalancer`·`GrpcKubernetesRoutingMode`)과 `grpc-spring-boot-starter` 의 `GrpcPlatformStartupValidator` 가 이 리프의 타입을 이름으로 부른다 — 빈으로 만들지는 않고 검증·판정에 쓴다. `GrpcTypedStubFactory`·`GrpcChannelRuntimeRegistry` 를 실제로 조립하는 코드는 없다.
|
||||
|
||||
**12.4 드리프트.** build.gradle 이 서술한 네 요소가 전부 존재한다. 드리프트 없음.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 실제 채널을 만들어 회전시키지 않았다. `ManagedChannel` 을 만드는 코드가 이 리프에 없다.
|
||||
- 동시 회전과 동시 해제를 실행으로 재현하지 않았다. 원자성 분석으로 판정했다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P2 — `rotate` 가 비교 후 교체가 아니라 덮어쓰기다
|
||||
|
||||
`install` 은 정확하다.
|
||||
|
||||
```java
|
||||
if (!holder.compareAndSet(null, runtime)) {
|
||||
throw new IllegalStateException("… already has a runtime; use rotate()");
|
||||
}
|
||||
```
|
||||
|
||||
`rotate` 는 그렇지 않다.
|
||||
|
||||
```java
|
||||
GrpcChannelRuntime previous = holder.get();
|
||||
if (!previous.generation().supersededBy(next)) { throw …; }
|
||||
GrpcChannelRuntime replacement = new GrpcChannelRuntime(next);
|
||||
holder.set(replacement); // ← 비교 없이 덮어쓴다
|
||||
previous.beginDrain();
|
||||
draining.computeIfAbsent(…).add(previous);
|
||||
```
|
||||
|
||||
두 회전이 동시에 들어오면 둘 다 같은 `previous` 를 읽고, 둘 다 대체본을 만들고, 나중 `set` 이 앞의 대체본을 덮는다.
|
||||
|
||||
덮인 대체본은 어디에도 등록되지 않는다 — `draining` 목록에 들어가는 것은 `previous` 뿐이다. 그러므로 그 세대는 배수도 회수도 되지 않고, 그 위에서 시작된 호출은 아무도 세지 않는다.
|
||||
|
||||
클래스가 이 문제를 인지하고 있다는 증거가 같은 파일에 있다 — `install` 의 비교 후 교체와 `AtomicReference` 선택이다. 회전 쪽만 그 규율에서 벗어나 있다.
|
||||
|
||||
수정은 `holder.compareAndSet(previous, replacement)` 로 바꾸고 실패 시 다시 읽어 판정하거나 던지는 것이다.
|
||||
|
||||
### 17.2 P2 — 비원자적 감소가 세대를 영구히 회수 불가로 만든다
|
||||
|
||||
```java
|
||||
public void finishUnaryCall() {
|
||||
if (inFlightUnaryCalls.get() > 0) { inFlightUnaryCalls.decrementAndGet(); }
|
||||
}
|
||||
public void closeStream() {
|
||||
if (openStreams.get() > 0) { openStreams.decrementAndGet(); }
|
||||
}
|
||||
```
|
||||
|
||||
카운터가 1 일 때 두 스레드가 동시에 끝나면 둘 다 조건을 통과해 둘 다 감소시켜 −1 이 된다.
|
||||
|
||||
그 결과가 이 리프에서는 구체적이다.
|
||||
|
||||
```java
|
||||
public boolean quiescent() {
|
||||
return inFlightUnaryCalls.get() == 0 && openStreams.get() == 0;
|
||||
}
|
||||
```
|
||||
|
||||
정확히 0 을 요구한다. 음수가 되면 조용해짐 판정이 영원히 거짓이고, `retireQuiescent` 가 그 세대를 결코 제거하지 않는다. 회전이 반복될수록 `draining` 목록이 자란다.
|
||||
|
||||
같은 형태가 이 가족의 다른 두 곳에도 있다(`GrpcAdmissionController.release`, `GrpcStreamAdmission.release`). 그쪽은 경계가 느슨해지는 결과였고, 이쪽은 자원이 회수되지 않는 결과다.
|
||||
|
||||
수정은 `updateAndGet(v -> Math.max(0, v - 1))` 이나 `decrementAndGet()` 후 하한 보정이다. 같은 가족의 `GrpcRetryBudget` 이 정확한 비교 후 교체 루프를 이미 쓴다.
|
||||
|
||||
### 17.3 P3 — 배수 목록의 순회가 동기화 밖에서 일어난다
|
||||
|
||||
```java
|
||||
draining.computeIfAbsent(name, key -> java.util.Collections.synchronizedList(new ArrayList<>())).add(previous);
|
||||
…
|
||||
public List<GrpcChannelRuntime> draining(GrpcChannelProfileName profileName) {
|
||||
return List.copyOf(draining.getOrDefault(profileName, List.of()));
|
||||
}
|
||||
public int retireQuiescent(GrpcChannelProfileName profileName) {
|
||||
List<GrpcChannelRuntime> runtimes = draining.get(profileName);
|
||||
…
|
||||
List<GrpcChannelRuntime> quiescent = runtimes.stream().filter(GrpcChannelRuntime::quiescent).toList();
|
||||
runtimes.removeAll(quiescent);
|
||||
```
|
||||
|
||||
`Collections.synchronizedList` 는 개별 연산만 동기화한다. 순회는 호출자가 그 목록을 잠그고 해야 한다는 것이 그 API 의 계약이다.
|
||||
|
||||
`List.copyOf(...)` 와 `stream()` 둘 다 순회다. 회전이 동시에 `add` 하면 동시 변경 예외가 가능하다.
|
||||
|
||||
그리고 읽고 지우는 두 단계가 원자적이지 않으므로, 그 사이에 조용해진 세대가 추가되면 이번 회수에서 빠진다. 후자는 다음 호출에서 회수되므로 무해하다.
|
||||
|
||||
수정은 `CopyOnWriteArrayList` 로 바꾸는 것이다. 배수 목록은 쓰기가 드물고 읽기가 잦아 그 자료구조의 전형적 용례다.
|
||||
|
||||
### 17.4 P3 — 프로파일 검증기가 javadoc 이 든 두 실수 중 하나만 검사한다
|
||||
|
||||
javadoc:
|
||||
|
||||
> "Two in particular. Round-robin over a target that resolves to one address … and **two profiles
|
||||
> pointing at the same target with the same settings are one channel with two names**, which is the
|
||||
> shape that appears when somebody wanted a different SLO and copied the profile instead."
|
||||
|
||||
구현된 것은 첫째와 **다른 것**이다.
|
||||
|
||||
```java
|
||||
String previous = seenNames.putIfAbsent(profileName, profile.target().toString());
|
||||
if (previous != null) { violations.add("channel profile '…' is declared twice, for '…' and '…'"); }
|
||||
```
|
||||
|
||||
이름이 같은 프로파일이 두 번 선언된 경우를 잡는다. javadoc 이 든 둘째는 **이름이 다르고 대상이 같은** 경우인데, 그 검사가 없다. 지도는 이름을 키로 쓰므로 같은 대상을 가리키는 두 이름은 서로를 만나지 않는다.
|
||||
|
||||
그리고 둘째가 실제로 더 찾기 어려운 형태다 — 이름이 같으면 설정 결속이 먼저 실패하거나 나중 것이 이기지만, 이름이 다르면 조용히 두 채널이 생긴다.
|
||||
|
||||
수정은 대상과 설정을 키로 하는 두 번째 지도를 두고 역방향 중복을 보고하는 것이다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **채널을 한 번 만들고 재사용하는 것과, 그 실수가 실패가 아니라 연결 수로 발견된다는 근거.**
|
||||
- **준비 후 교체** — 새 런타임이 먼저 존재하고 포인터가 나중에 움직인다.
|
||||
- **`require` 가 빈 값 대신 던지는 것.**
|
||||
- **단항 호출과 스트림을 따로 세는 것.**
|
||||
- **기저 채널을 노출하지 않는 것과 등록 함수가 런타임을 받는 것.**
|
||||
- **등록되지 않은 스텁 타입을 거절하는 것.**
|
||||
- **신뢰 도메인별 메타데이터 허용 목록 둘과 부분집합 불변식.**
|
||||
- **인가 헤더를 자격증명 제공자에게만 맡기는 것.**
|
||||
- **나가는 방향에서 허용 목록 밖을 폐기로 다루고 그 비대칭의 이유를 적은 것.**
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc/grpc-client/build.gradle:1-17
|
||||
main/java/…/client/GrpcChannelRuntimeRegistry.java:1-137
|
||||
main/java/…/client/GrpcTypedStubFactory.java:1-119
|
||||
main/java/…/client/GrpcNamedChannelProfile.java:1-100
|
||||
main/java/…/client/GrpcChannelRuntime.java:1-96
|
||||
main/java/…/client/GrpcClientMetadataPolicy.java:1-87
|
||||
main/java/…/client/GrpcChannelProfileValidator.java:1-81
|
||||
main/java/…/client/(GrpcStubPolicyApplier · GrpcClientCallContext · GrpcChannelGeneration · GrpcLoadBalancingPolicy · GrpcChannelDrainPolicy · GrpcStubDescriptor · GrpcCallCredentialProvider)
|
||||
test/java/…/client/(GrpcNamedChannelProfileTest · GrpcClientMetadataPolicyTest · GrpcTypedStubFactoryTest · GrpcChannelRuntimeRegistryTest)
|
||||
```
|
||||
@@ -0,0 +1,348 @@
|
||||
# grpc-codegen 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 — `src/main` production 9파일 692줄, test 3파일 414줄, 소비자 픽스처 리소스 2파일 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc/grpc-codegen`
|
||||
> SSOT owner: `grpc-codegen`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `allowed_dependencies`: `["grpc-core-api", "grpc-proto-contract"]`
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
|
||||
| 파일 | LOC |
|
||||
|---|---:|
|
||||
| `GrpcConsumerFixture` | 158 |
|
||||
| `GrpcSchemaArtifactPublisher` | 113 |
|
||||
| `GrpcCodegenManifest` | 81 |
|
||||
| `GrpcBufPolicy` | 75 |
|
||||
| `GrpcGeneratedPackagePolicy` | 68 |
|
||||
| `GrpcDescriptorArtifact` | 59 |
|
||||
| `GrpcCodegenOutput` | 58 |
|
||||
| `GrpcBreakingCategory` | 43 |
|
||||
| `GrpcSchemaBaseline` | 37 |
|
||||
| **main 합계** | **692** |
|
||||
| test 3파일 | 248 + 88 + 78 |
|
||||
| 소비자 픽스처 리소스 | 33 + 17 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 9 | `FULL_READ` | 692줄 전 본문 |
|
||||
| `test/java/**` | 3 | `FULL_READ` | 414줄 · 테스트 21개 |
|
||||
| `test/resources/consumer-fixtures/**` | 2 | `FULL_READ` | 50줄 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 12줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-7
|
||||
// Contract governance: Buf format/lint/breaking policy, the single codegen owner declaration, and
|
||||
// the descriptor/schema-hash release artifact with its consumer-compile gate.
|
||||
//
|
||||
// The Buf rules are implemented here rather than shelled out to the Buf CLI (adaptation D5): the
|
||||
// CLI is not present in this toolchain, and a gate that silently no-ops when a binary is missing is
|
||||
// worse than one that computes the same judgement from the committed schema.
|
||||
```
|
||||
|
||||
마지막 문장이 이 저장소의 반복 원칙이다 — 도구가 없을 때 조용히 통과하는 게이트는 없는 것보다 나쁘다.
|
||||
|
||||
## 2. 파괴적 변경 범주 — 왜 FILE 인가
|
||||
|
||||
| 범주 | 소스 파괴 감지 | 파일 이동 감지 |
|
||||
|---|---|---|
|
||||
| `FILE` | 예 | 예 |
|
||||
| `PACKAGE` | 예 | 아니오 |
|
||||
| `WIRE_JSON` | 아니오 | 아니오 |
|
||||
| `WIRE` | 아니오 | 아니오 |
|
||||
|
||||
> "A team that gates on WIRE ships a field rename, watches its own integration tests pass, and finds
|
||||
> out at the consumer's next build."
|
||||
|
||||
`GrpcBufPolicy` 정규 생성자가 소스 파괴를 감지하지 못하는 범주를 거부하고, 형식·린트를 선택 사항으로 두지 않는다.
|
||||
|
||||
## 3. 기준선은 브랜치가 아니라 릴리스다
|
||||
|
||||
`GrpcSchemaBaseline` 은 `-SNAPSHOT` 버전을 거부하고 `sha256:` 접두 해시를 요구한다.
|
||||
|
||||
> "Comparing against the previous commit answers 'did this commit break anything', which is not the
|
||||
> question: a breaking change introduced two commits ago and refined since then passes every
|
||||
> commit-to-commit check while being broken against everything that has actually been deployed."
|
||||
|
||||
## 4. 생성물의 자리
|
||||
|
||||
`GrpcCodegenOutput` 은 모든 경로가 빌드 디렉터리 아래일 것을 요구하고, 절대 경로와 `..` 를 거부하며, 서술자 집합 확장자를 `.desc`/`.binpb` 로 제한한다.
|
||||
|
||||
> "A generator that writes into a source tree produces files that get committed, then edited, then
|
||||
> silently reverted by the next regeneration — and the diff that reverts them looks like the
|
||||
> generator working correctly."
|
||||
|
||||
## 5. 생성자는 하나여야 한다
|
||||
|
||||
`GrpcCodegenManifest` 는 소유자 하나와 관리 플랫폼에서 오는 두 버전 출처를 요구한다.
|
||||
|
||||
> "Two generators for one schema is the state in which a type exists twice with different options
|
||||
> and the classpath decides which one a consumer gets."
|
||||
|
||||
> "A pinned protobuf version beside a BOM-managed gRPC version is how the runtime and the generator
|
||||
> drift into a combination nobody tested, and the symptom is a `NoSuchMethodError` in generated code."
|
||||
|
||||
그리고 생성 패키지와 손으로 쓴 패키지가 겹치면 생성 시점에 던진다.
|
||||
|
||||
`caSkeleton()` 의 소유자는 Gradle protobuf 플러그인이고, javadoc 이 그것이 아직 이 빌드에서 돌지 않는다고 적는다 — "this manifest is what a future decision to turn it on has to satisfy rather than replace."
|
||||
|
||||
## 6. 소비자 컴파일 게이트
|
||||
|
||||
`GrpcConsumerFixture.fromJavaSource` 가 릴리스된 소비자의 자바 소스에서 요구 사항 셋을 기계적으로 유도한다.
|
||||
|
||||
> "a hand-written requirement list is a second copy of what the client already says and the copy is
|
||||
> the one that stops being updated."
|
||||
|
||||
세 규칙이다.
|
||||
|
||||
```
|
||||
생성 자바 패키지 = fixture 클래스가 import 하는 패키지 중 접미가 맞는 것
|
||||
서비스 = <Name>Grpc import → <proto package>.<Name>
|
||||
메서드 = stub.<name>( 호출 → <service>/<UpperCamelName>
|
||||
```
|
||||
|
||||
그리고 그것이 컴파일의 근사라는 것과, 근사인 이유(ADR-GRPC-002)를 함께 적는다.
|
||||
|
||||
`breaksAgainst` 는 세 종류를 따로 보고한다 — 서비스 경로, 메서드 경로, 자바 패키지. 하나의 개수로 합치지 않는다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
세 테스트 414줄 · 21개.
|
||||
|
||||
`GrpcBufPolicyTest` 6개 — Stable 게이트가 `FILE` 이라는 것, `WIRE`/`WIRE_JSON` 거부, 형식·린트 비선택, 수명주기 태스크 이름(§17.1), 기준선의 불변 릴리스 요구, 해시 일치.
|
||||
|
||||
`GrpcCodegenManifestTest` 5개 — 소유자 유일성, 리터럴 버전 거부, 출력 경로가 `build/` 아래여야 한다는 것과 서술자 확장자, 패키지 겹침의 양방향 감지.
|
||||
|
||||
`GrpcDescriptorArtifactTest` 10개 — 산출물의 불변 버전과 세 digest, 파괴 종류별 보고, 넓어진 스키마가 아무것도 깨지 않는다는 것, 커밋된 픽스처의 유도 결과, 메서드 이름 변경이 발행을 막는다는 것, 요구가 빈 픽스처 거부, 픽스처 build 파일의 고정 버전, 소비자 실패의 발행 차단, 같은 버전 다른 바이트 거부, 같은 바이트 재발행 허용.
|
||||
|
||||
**픽스처를 리소스에서 읽는다.** `resource(path)` 가 클래스로더로 `consumer-fixtures/v1/...` 를 읽어 실제 커밋된 텍스트를 넣는다 — 유도 규칙을 리터럴 문자열이 아니라 저장소에 있는 파일에 대고 돌린다. `theFixturePinsItsSchemaVersion` 은 픽스처의 `build.gradle.kts` 본문까지 대조한다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** build-only 이지만 타입 참조는 리프 밖에 있다. 실제 참조 지점은 다섯이다.
|
||||
|
||||
| 참조 | 형태 |
|
||||
|---|---|
|
||||
| `grpc-testkit/…/release/GrpcStableReleaseGate.java:3` | `import …codegen.GrpcSchemaArtifactPublisher` — production `src/main` 코드 |
|
||||
| `grpc-testkit/build.gradle:55` | `api project(':grpc:grpc-codegen')` |
|
||||
| `grpc-spring-boot-starter/build.gradle:19` | `implementation project(':grpc:grpc-codegen')` |
|
||||
| `grpc-proto-contract/…/GrpcProtoContractValidator.java:22` | javadoc 언급만 |
|
||||
| `grpc-core-api/…/GrpcStableModuleCatalog.java:24` | 목록 안의 `"grpc-codegen"` 문자열 |
|
||||
|
||||
`GrpcStableReleaseGate.evaluate` 는 `GrpcSchemaArtifactPublisher.PublishDecision` 을 **인자로 받는다** — 발행자를 만들지 않는다. 그리고 그 게이트 자신도 리터럴을 먹이는 테스트 말고는 호출자가 없다(`grpc-testkit` §17). 즉 타입 수준 연결은 실재하지만 그 사슬 어디에도 실행 시점 생산자가 없다.
|
||||
|
||||
`grpc-spring-boot-starter` 의 의존 선언에는 대응하는 자바 참조가 없다 — 스타터 소스 전체에 `codegen` 문자열이 나오지 않는다. 쓰이지 않는 의존이다.
|
||||
|
||||
**12.2 저장소의 스키마에는 service 가 하나도 없다.**
|
||||
|
||||
```
|
||||
$ grep -rn "^service" --include=*.proto src/ (매치 없음)
|
||||
$ grep -rn "^package" --include=*.proto src/
|
||||
grpc-proto-contract/…/v1/stream.proto:3: package hyeonworks.grpc.common.v1;
|
||||
grpc-proto-contract/…/v1/error.proto:3: package hyeonworks.grpc.common.v1;
|
||||
grpc-advanced-edition/…/edition2024/compatibility.proto:3: package hyeonworks.grpc.edition.v1;
|
||||
messaging-schema-protobuf/src/test/proto/order_created_v1.proto:3: package dev.caskeleton.messaging.sample;
|
||||
```
|
||||
|
||||
두 실물 proto 는 message 와 enum 만 담는다. 그런데 `GrpcDescriptorArtifact` 정규 생성자는 메서드가 비면 거부한다 — "a schema artifact with no methods describes nothing". **이 산출물 타입은 이 저장소의 실제 스키마를 표현할 수 없다.**
|
||||
|
||||
그래서 소비자 게이트 전체가 저장소에 없는 표면(`hyeonworks.document.v1.DocumentService`)을 상대로만 돌아간다. `GrpcCodegenManifest.caSkeleton()` 이 선언하는 생성 패키지는 `hyeonworks.grpc.common.v1.generated` 이고 픽스처가 유도하는 패키지는 `hyeonworks.document.v1.generated` 다 — 매니페스트와 픽스처가 서로 다른 스키마를 서술한다.
|
||||
|
||||
결함으로 세지 않는 이유는 build.gradle 과 매니페스트 javadoc 이 이 리프를 "protoc 을 켜기로 하는 미래의 결정이 만족시켜야 할 선언"으로 규정하기 때문이다(D6). 다만 §17.1·§17.4 의 검사들이 지금 무엇에 대해서도 돌지 않는다는 사실의 뿌리가 여기다.
|
||||
|
||||
**12.3 도달 불가 분기.** `GrpcSchemaArtifactPublisher.evaluate` 의 두 번째 차단 사유는 발화할 수 없다.
|
||||
|
||||
```java
|
||||
if (!policy.breakingCategory().detectsSourceBreak()) {
|
||||
blockers.add("the active breaking category does not detect source breaks");
|
||||
}
|
||||
```
|
||||
|
||||
`GrpcBufPolicy` 정규 생성자가 이미 그런 범주를 거부하므로, 구성된 정책은 언제나 소스 파괴를 감지한다. 이 저장소에서 반복해서 나타나는 형태다 — 선행 검증이 후행 검증을 가린다. 보안 효과는 그대로이므로 결함이 아니라 기록으로 남긴다.
|
||||
|
||||
**12.4 드리프트.** build.gradle 이 서술한 세 요소(Buf 정책·단일 생성자 선언·서술자 산출물과 소비자 게이트)가 전부 존재한다. 드리프트 없음.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 실제 `protoc` 이나 Buf CLI 를 돌리지 않았다. 저장소에 둘 다 없다.
|
||||
- 서비스가 둘 이상인 픽스처를 만들어 §17.3 을 재현하지 않았다. 유도 코드로 판정했다.
|
||||
- §17.4 의 어긋난 짝(`publish(다른 후보, 이 결정)`)을 실제로 실행해 보지 않았다. `publish` 본문에 대조 코드가 없다는 것으로 판정했다.
|
||||
- 테스트를 실행하지 않았다. 21개 전부 본문으로만 확인했다.
|
||||
- `grpc-spring-boot-starter` 가 이 모듈을 의존 선언만 하고 쓰지 않는 것은 문자열 grep 으로 판정했다 — 그쪽 SSOT 에서 다시 본다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P3 — Buf 수명주기 태스크 목록이 빌드와 대조되지 않는다. 테스트는 목록을 자기 자신과 비교한다
|
||||
|
||||
정책이 네 태스크 이름을 담고, javadoc 이 그 이유를 적는다.
|
||||
|
||||
> "Keeping the task names in the policy rather than only in a workflow file means **a missing stage
|
||||
> is a test failure rather than a stage nobody noticed was gone.**"
|
||||
|
||||
그런데 그 네 이름은 저장소의 어떤 빌드 파일에도 없다.
|
||||
|
||||
```
|
||||
$ grep -rn "bufFormatCheck\|bufLint\|bufBreaking" --include=*.gradle src/
|
||||
(매치 없음)
|
||||
```
|
||||
|
||||
그리고 테스트가 비교하는 대상이 실제 등록 태스크 집합이 아니다.
|
||||
|
||||
```java
|
||||
assertThat(GrpcBufPolicy.requiredTasks())
|
||||
.containsExactly("bufFormatCheck", "bufLint", "bufBuild", "bufBreaking");
|
||||
assertThat(GrpcBufPolicy.missingTasks(Set.of("bufFormatCheck", "bufLint", "bufBuild")))
|
||||
…
|
||||
assertThat(GrpcBufPolicy.missingTasks(Set.copyOf(GrpcBufPolicy.requiredTasks()))).isEmpty();
|
||||
```
|
||||
|
||||
첫 단언은 목록을 리터럴과, 셋째는 목록을 자기 자신과 비교한다. 어느 것도 빌드가 그 단계를 등록했는지 묻지 않는다.
|
||||
|
||||
Buf CLI 가 이 툴체인에 없다는 것은 build.gradle 이 이미 밝힌 사실이므로 태스크가 없는 것 자체는 놀랍지 않다. 어긋난 것은 javadoc 의 주장이다 — 지금 형태에서 단계가 사라져도 테스트는 초록이다.
|
||||
|
||||
수정은 `missingTasks` 에 Gradle 이 실제로 등록한 태스크 이름 집합을 넣는 검사를 만들거나(다른 가족의 레인 등록 검사와 같은 형태), CLI 가 없는 동안에는 그 문장을 "CI 환경이 채울 계약" 으로 낮추는 것이다.
|
||||
|
||||
### 17.2 P3 — 릴리스 버전 불변성이 프로세스 안에서만 성립한다
|
||||
|
||||
```java
|
||||
private final Map<String, String> publishedHashesByVersion = new LinkedHashMap<>();
|
||||
…
|
||||
String alreadyPublished = publishedHashesByVersion.get(candidate.schemaVersion());
|
||||
if (alreadyPublished != null && !alreadyPublished.equals(candidate.schemaHash())) {
|
||||
blockers.add("version '…' is already published with a different schema hash; a released schema version is immutable");
|
||||
}
|
||||
```
|
||||
|
||||
발행 이력이 발행자 인스턴스의 필드다. 새 프로세스는 아무것도 기억하지 못하므로 같은 버전을 다른 해시로 다시 발행하려는 시도가 통과한다.
|
||||
|
||||
이 클래스가 존재하는 이유가 그 규칙이다 — "refuses to let a released version change underneath its consumers." 그 규칙이 지켜지는 범위가 한 발행자 인스턴스의 수명이다.
|
||||
|
||||
빌드마다 새 프로세스가 도는 것이 정상 형태이므로, 실제로 이 검사가 무언가를 막으려면 이력이 산출물 저장소나 파일에서 와야 한다. `GrpcSchemaBaseline` 이 이미 릴리스된 해시를 들고 있으므로 그 방향의 재료는 있다.
|
||||
|
||||
덧붙여 이 맵은 동기화되지 않는다. 발행자를 공유해 병렬로 평가하면 경합한다.
|
||||
|
||||
### 17.3 P3 — 픽스처의 메서드 경로가 서비스 × 메서드 교차곱이다
|
||||
|
||||
```java
|
||||
while (calls.find()) {
|
||||
String method = calls.group(1);
|
||||
String upperCamel = Character.toUpperCase(method.charAt(0)) + method.substring(1);
|
||||
servicePaths.forEach(service -> methodPaths.add(service + "/" + upperCamel));
|
||||
}
|
||||
```
|
||||
|
||||
`stub.<name>(` 호출 하나가 그 파일이 import 한 **모든** 서비스에 대해 메서드 경로를 만든다.
|
||||
|
||||
javadoc 의 규칙 서술은 단수형이다 — "a method is a `stub.<name>(` call, mapped to `<service>/<UpperCamelName>`". 서비스가 둘 이상일 때 어느 서비스인지는 소스 텍스트만으로 알 수 없고, 코드는 전부에 붙이는 쪽을 골랐다.
|
||||
|
||||
결과는 존재하지 않는 메서드 경로를 요구하는 픽스처다. 서비스 둘과 메서드 셋이면 요구 경로가 여섯 개가 되고, 그중 셋은 어떤 후보 스키마에도 없으므로 `breaksAgainst` 가 항상 `METHOD_PATH` 파괴를 보고한다. 그러면 `GrpcSchemaArtifactPublisher.evaluate` 가 모든 발행을 거부한다.
|
||||
|
||||
커밋된 픽스처는 서비스가 하나(`DocumentServiceGrpc`)라 지금은 정확하다. 두 번째 소비자 픽스처를 추가하는 순간 성립한다.
|
||||
|
||||
수정은 호출자 변수의 선언 타입을 함께 읽어 메서드를 서비스에 귀속시키거나, 서비스가 둘 이상인 픽스처를 거부하는 것이다. 후자는 지금 형태의 근사를 명시적으로 만든다.
|
||||
|
||||
### 17.4 P2 — `publish` 가 결정을 그 결정이 판정한 후보에 묶지 않는다
|
||||
|
||||
```java
|
||||
public void publish(GrpcDescriptorArtifact candidate, PublishDecision decision) {
|
||||
if (decision == null || !decision.allowed()) {
|
||||
throw new IllegalStateException("refusing to publish '…'");
|
||||
}
|
||||
publishedHashesByVersion.put(candidate.schemaVersion(), candidate.schemaHash());
|
||||
}
|
||||
```
|
||||
|
||||
`decision` 이 `candidate` 를 판정한 결정인지 확인하는 코드가 없다. `PublishDecision` 은 `(boolean allowed, List<String> blockers)` 뿐이라 자기가 무엇을 판정했는지 들고 있지도 않다.
|
||||
|
||||
그래서 이렇게 쓸 수 있다.
|
||||
|
||||
```java
|
||||
PublishDecision ok = publisher.evaluate(harmlessArtifact, List.of()); // 통과
|
||||
publisher.publish(breakingArtifact, ok); // 그대로 기록된다
|
||||
```
|
||||
|
||||
두 번째 줄에서 `breakingArtifact` 는 어떤 소비자 픽스처와도 대조되지 않고, 이미 발행된 버전인지도 확인되지 않은 채 이력에 들어간다. 이 클래스의 존재 이유인 두 규칙 — 소비자 컴파일 게이트와 릴리스 버전 불변성 — 을 둘 다 우회한다.
|
||||
|
||||
**왜 이 형태가 생겼나.** 판정과 기록이 두 호출로 나뉘어 있고 그 사이를 묶는 것이 호출자의 규율뿐이다. 이 저장소가 여러 가족에서 반복해 온 check-then-act 형태와 같다. 다만 여기서는 경합이 아니라 **인자 짝 맞추기**가 깨진 지점이다.
|
||||
|
||||
테스트는 안전한 형태만 쓴다 — `identicalRepublishIsAllowed` 는 `publisher.publish(artifact, publisher.evaluate(artifact, List.of()))` 로 한 줄에서 짝을 맞춘다. 그 규율을 코드가 강제하지 않는다.
|
||||
|
||||
**수정.** `PublishDecision` 이 판정 대상의 `schemaVersion`·`schemaHash` 를 들고, `publish` 가 후보와 대조한다. 또는 `evaluate` 가 발행 가능한 후보를 감싼 토큰을 돌려주고 `publish` 가 그 토큰만 받는다 — 짝이 어긋날 수 없는 형태가 된다.
|
||||
|
||||
### 17.5 P3 — `sha256:` 검사가 길이 15자 이상만 요구한다. 저장소 자신의 테스트가 32자 해시를 통과시킨다
|
||||
|
||||
같은 검사가 두 곳에 손으로 복사돼 있다.
|
||||
|
||||
```java
|
||||
// GrpcDescriptorArtifact.requireDigest
|
||||
if (digest == null || !digest.startsWith("sha256:") || digest.length() < 15) throw …;
|
||||
|
||||
// GrpcSchemaBaseline 정규 생성자
|
||||
if (schemaHash == null || !schemaHash.startsWith("sha256:") || schemaHash.length() < 15) throw …;
|
||||
```
|
||||
|
||||
`"sha256:"` 이 7자이므로 뒤에 8자만 있으면 통과한다. sha256 digest 는 hex 64자다.
|
||||
|
||||
그리고 이 헐거움이 테스트에 이미 드러나 있다.
|
||||
|
||||
```java
|
||||
assertThat(policy.unchangedFromBaseline("sha256:ffffffffffffffffffffffffffffffff")).isFalse();
|
||||
```
|
||||
|
||||
32자 — sha256 이 아니다. 여기서는 "다른 해시" 역할이라 결과가 바뀌지 않지만, 형식 검사가 이런 값을 유효한 해시로 받는다는 사실 자체가 이 값 객체의 주장("the hashes that prove which bytes it was built from")을 약하게 만든다.
|
||||
|
||||
**수정.** `sha256:` 뒤 64자 hex 를 정규식으로 요구하고, 검사를 한 곳에 둔다 — 두 record 가 같은 규칙을 각자 적고 있는 지금 형태에서는 한쪽만 조여도 다른 쪽이 남는다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **Buf CLI 를 부르지 않고 같은 판정을 계산한 것과 그 근거** — 바이너리가 없을 때 조용히 통과하는 게이트보다 낫다.
|
||||
- **파괴적 범주를 소스 파괴 감지 여부로 나눈 것** — 유선 호환만 보면 이름 변경이 호환으로 통과한다.
|
||||
- **기준선을 릴리스에 고정한 것** — 커밋 대 커밋 비교가 답하는 질문이 다르다.
|
||||
- **생성물 경로를 빌드 디렉터리로 강제한 것.**
|
||||
- **생성자를 하나로 못박고 버전 출처를 관리 플랫폼으로 제한한 것.**
|
||||
- **소비자 요구 사항을 손으로 적지 않고 소스에서 유도한 것** — 손으로 적은 목록이 갱신을 멈춘다.
|
||||
- **파괴 종류를 셋으로 나눠 보고하는 것** — 하나의 개수로 합치지 않는다.
|
||||
- **근사임을 자바독에 명시하고 그 한계의 근거를 ADR 로 지목한 것.**
|
||||
- **픽스처를 의존이 아니라 테스트 리소스로 커밋한 것** — 픽스처의 `build.gradle.kts` 가 스키마 산출물을 `1.4.0` 으로 고정하고, 그 이유("a fixture that floats to the latest version cannot detect a break, because it is always built against the schema it is meant to be testing")를 파일 안에 적어 두었다.
|
||||
- **요구 사항이 빈 픽스처를 거부한 것** — 아무것도 요구하지 않는 픽스처는 모든 스키마를 통과시킨다.
|
||||
- **`PublishDecision` 정규 생성자가 허용과 차단 사유의 모순을 거부한 것** — 허용인데 차단 사유가 있거나, 거부인데 사유가 없으면 던진다.
|
||||
- **테스트가 픽스처를 클래스로더로 실제 파일에서 읽는 것** — 유도 규칙을 리터럴이 아니라 커밋된 텍스트에 대고 돌린다.
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc/grpc-codegen/build.gradle:1-12
|
||||
main/java/…/codegen/GrpcConsumerFixture.java:1-158
|
||||
main/java/…/codegen/GrpcSchemaArtifactPublisher.java:1-113
|
||||
main/java/…/codegen/GrpcCodegenManifest.java:1-81
|
||||
main/java/…/codegen/GrpcBufPolicy.java:1-75
|
||||
main/java/…/codegen/GrpcGeneratedPackagePolicy.java:1-68
|
||||
main/java/…/codegen/GrpcDescriptorArtifact.java:1-59
|
||||
main/java/…/codegen/GrpcCodegenOutput.java:1-58
|
||||
main/java/…/codegen/GrpcBreakingCategory.java:1-43
|
||||
main/java/…/codegen/GrpcSchemaBaseline.java:1-37
|
||||
test/java/…/codegen/GrpcDescriptorArtifactTest.java:1-248
|
||||
test/java/…/codegen/GrpcBufPolicyTest.java:1-88
|
||||
test/java/…/codegen/GrpcCodegenManifestTest.java:1-78
|
||||
test/resources/consumer-fixtures/v1/src/main/java/fixture/DocumentClientFixture.java:1-33
|
||||
test/resources/consumer-fixtures/v1/build.gradle.kts:1-17
|
||||
grpc/grpc-testkit/…/release/GrpcStableReleaseGate.java:3,35-38 (PublishDecision 소비 지점)
|
||||
grpc/grpc-testkit/build.gradle:55 · grpc/grpc-spring-boot-starter/build.gradle:19 (의존 선언)
|
||||
```
|
||||
@@ -0,0 +1,308 @@
|
||||
# grpc-core-api 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 32파일 1,897줄 + `src/test` 7파일 926줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc/grpc-core-api`
|
||||
> SSOT owner: `grpc-core-api`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `allowed_dependencies`: **`[]`** — 이 저장소에서 의존성이 하나도 없는 두 리프 중 하나(다른 하나는 `messaging-core-api`)
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-9
|
||||
// The platform's port layer: identifiers, method policy, execution evidence, failure model,
|
||||
// deadline primitives and request context.
|
||||
//
|
||||
// No dependencies at all, and that is the contract rather than an accident. The Stable plan's
|
||||
// Global Constraints make `grpc-core-api` framework-free so "evidence and policy do not know about
|
||||
// a transport" is verifiable instead of aspirational — the same rule `messaging-core-api` holds.
|
||||
// A type here may not name io.grpc, Spring, Netty, protobuf or a database.
|
||||
```
|
||||
|
||||
| 패키지 | 파일 | 줄 | 성격 |
|
||||
|---|---:|---:|---|
|
||||
| `core` | 8 | 390 | 식별자·상태 코드·RPC 종류·Stable 모듈 목록과 불변식 |
|
||||
| `error` | 4 | 276 | 실패 문맥·범주·완료 결과·플랫폼 예외 |
|
||||
| `context` | 4 | 273 | 요청 문맥·메타데이터 키와 예산·클라이언트 신원 |
|
||||
| `evidence` | 4 | 239 | 전송·업무·스트림 세 축 |
|
||||
| `deadline` | 4 | 238 | 예산·프로파일·취소 토큰·마감 예외 |
|
||||
| `policy` | 4 | 295 | 메서드 정책과 목록, 멱등 프로파일, wait-for-ready |
|
||||
| `ledger` | 4 | 176 | 연산 원장 포트와 기록·신원·상태 |
|
||||
|
||||
가장 큰 파일 넷: `GrpcMethodPolicyCatalog` 124 · `GrpcMethodPolicy` 100 · `GrpcFailureContext` 99 · `GrpcRequestContext`·`GrpcExecutionEvidence` 87.
|
||||
|
||||
main 총 **32파일 / 1,897줄**.
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 32 | `FULL_READ` | 1,897줄. 위 표가 전부 |
|
||||
| `test/java/**` | 7 | `FULL_READ` | 926줄 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 10줄 전문 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
> 이 표는 2026-09-01 재통독에서 파일 단위로 다시 세었다. 이전 판은 `main/java/**` 를 "전 파일", `test/java/**` 를 6(실제 7)으로 적었다. 그 미세한 오차가 §17.4–§17.6 이 표에 없던 이유다.
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 증거 세 축
|
||||
|
||||
`GrpcExecutionEvidence` 가 전송·업무·스트림을 함께 들고 절대 합치지 않는다.
|
||||
|
||||
> "The same type is used by the failure model and by the observation convention. That is deliberate:
|
||||
> when the exception and the metric are built from different snapshots of what happened, the
|
||||
> incident review has two accounts of one call and no way to choose between them."
|
||||
|
||||
관측될 수 없는 조합을 생성자가 거부한다 — 단항이 스트림 증거를 들 수 없고, 보내지 않은 요청이 업무 증거를 들 수 없다.
|
||||
|
||||
승격 메서드가 하나뿐인 것도 의도다.
|
||||
|
||||
> "This is the promotion the plan forbids, written as the one method that is allowed to observe
|
||||
> headers — so the forbidden edit is visible as a change to this method rather than as a plausible
|
||||
> line somewhere in an interceptor."
|
||||
|
||||
즉 응답 헤더를 봤다는 사실이 업무 축을 건드리지 못하게 하고, 그 규칙을 어기려면 이 메서드를 고쳐야 한다.
|
||||
|
||||
## 2. 완료 결과가 상태 코드와 분리된 이유
|
||||
|
||||
> "a mutation that times out is `DEADLINE_EXCEEDED` on the wire and `COMPLETION_UNKNOWN` in the
|
||||
> business, and a caller that reads the first as the second's answer either loses a committed write
|
||||
> or performs it twice."
|
||||
|
||||
`forMutation` 의 판정 순서가 다섯 단계다.
|
||||
|
||||
```
|
||||
커밋 확인됨 → COMPLETED
|
||||
부분 스트림 → PARTIAL_STREAM
|
||||
상태 OK → COMPLETED
|
||||
전송이 미시작을 증명 → REJECTED
|
||||
그 밖 → 상태별 표
|
||||
```
|
||||
|
||||
상태별 표에서 `DEADLINE_EXCEEDED`·`UNAVAILABLE`·`CANCELLED`·`UNKNOWN`·`INTERNAL`·`ALREADY_EXISTS`·`ABORTED`·`DATA_LOSS` 가 `COMPLETION_UNKNOWN` 이다. `ALREADY_EXISTS` 가 모호에 있는 것이 특히 정확하다 — 재시도가 그 답을 받으면 첫 시도가 성공했다는 뜻일 수 있다.
|
||||
|
||||
## 3. 메서드 정책 목록
|
||||
|
||||
가장 유용한 성질이 빌드를 깨는 쪽이다.
|
||||
|
||||
> "when a descriptor method set is declared, registering a policy for a method the schema does not
|
||||
> have is an error. That catches the rename — the method becomes `CreateDocumentV2`, the policy still
|
||||
> names `CreateDocument`, and every call to the new method silently runs with default deadline,
|
||||
> default retry and no idempotency requirement."
|
||||
|
||||
그리고 정책 없는 메서드는 조회에서 던진다 — 정책 없는 호출은 마감도 멱등 프로파일도 없고, 그것을 서비스하려면 둘 다 지어내야 한다.
|
||||
|
||||
## 4. Stable 모듈 목록과 불변식
|
||||
|
||||
`GrpcStableModuleCatalog` 이 Stable 12 와 Advanced 6 을 상수로 든다.
|
||||
|
||||
`GrpcStableBuildInvariant.advancedDependencyAllowed()` 가 인자를 받지 않는 이유가 적혀 있다.
|
||||
|
||||
> "the answer does not vary by module, by capability or by environment. A method that could return
|
||||
> true for some input would be the seam through which 'just this one Advanced type in the starter'
|
||||
> arrives."
|
||||
|
||||
그리고 누출을 던지지 않고 집합으로 돌려주는 이유도 적혀 있다 — 첫 하나만 보고하는 게이트는 넷을 지우는 일을 네 번의 대화로 만든다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
여섯 테스트. 증거 조합 거부, 완료 결과 파생, 정책 목록의 서술자 대조와 중복 거부, 마감 예산, 메타데이터 예산, 식별자 경계, 모듈 목록을 확인한다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** 이 리프는 가족 전체의 포트 계층이므로 참조가 가장 많다. 다만 §17.3 의 타입은 예외다.
|
||||
|
||||
**12.2 프레임워크 부재 확인.** `io.grpc`·Spring·Netty·protobuf·JDBC 를 이름으로 부르는 import 가 main 에 없다. build.gradle 의 의존 블록도 비어 있다.
|
||||
|
||||
**12.3 실제로 쓰이는 게이트.** 이 가족의 다른 게이트들과 달리 `GrpcStableBuildInvariant.requireNoAdvancedDependency` 는 production 호출자가 둘 있다 — `grpc-spring-boot-starter` 의 시작 검증기와 `grpc-advanced-bootstrap` 의 모듈 가드. 불변식의 양쪽을 각각 다른 리프가 부른다.
|
||||
|
||||
**12.4 드리프트.** build.gradle 이 서술한 일곱 패키지가 전부 존재하고, 파일 수는 `core` 8 · `policy` 4 · `evidence` 4 · `error` 4 · `deadline` 4 · `context` 4 · `ledger` 4 = 32 다.
|
||||
|
||||
**12.5 검증만 되고 강제되지 않는 성분.** `GrpcMetadataBudget.maxTotalBytes`(§17.5). 같은 형태를 `grpc-policy` 에서도 찾았다 — `GrpcContextPropagationPolicy.clearAfterTask`(그 리프 §17.8). 두 자리 모두 compact constructor 의 가드가 유일한 소비자다.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 서술자 대조 경로를 실제 스키마로 돌려 보지 않았다(§17.1). 저장소에 컴파일된 서술자가 없다.
|
||||
- 상태 코드별 매핑을 실제 서버 응답으로 재현하지 않았다. 표와 근거 문장으로 판정했다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P3 — 정책 목록의 가장 강한 성질을 이 저장소에서는 쓸 수 없다
|
||||
|
||||
`withDescriptorMethods` 를 부르는 곳은 이 리프의 테스트 두 줄뿐이다.
|
||||
|
||||
```
|
||||
grpc-core-api/src/test/.../GrpcMethodPolicyCatalogTest.java:145
|
||||
grpc-core-api/src/test/.../GrpcMethodPolicyCatalogTest.java:175
|
||||
```
|
||||
|
||||
`GrpcMethodPolicyCatalog.builder()` 를 부르는 곳은 저장소 전체에서 전부 테스트다. 그리고 그중 어느 것도 서술자 집합을 선언하지 않는다(위 두 줄 제외).
|
||||
|
||||
자바독이 그 상태를 미리 서술한다 — 서술자가 없으면 "the catalog is materially weaker … there is nothing to compare a policy's method name against."
|
||||
|
||||
그리고 서술자가 없는 이유는 옆 리프에 있다. `grpc-codegen` 이 서술자 산출물을 정의하지만 저장소에 protobuf 플러그인이 없어 `protoc` 이 돌지 않는다. 즉 이름 변경을 잡는 성질은 코드 생성 레인이 켜지기 전까지 성립할 수 없다.
|
||||
|
||||
기록하는 이유는 이것이 이 클래스가 존재하는 첫 번째 이유로 적혀 있기 때문이다. 수정은 코드 생성 레인이 생길 때 그 서술자를 목록 조립에 연결하는 것이고, 그때까지는 자바독이 그 조건을 명시하는 편이 낫다.
|
||||
|
||||
### 17.2 P3 — 모듈 목록 테스트가 레지스트리와 목록을 붙들지 않는다
|
||||
|
||||
클래스 javadoc 이 두 SSOT 의 관계를 적는다.
|
||||
|
||||
> "This repository's module registry (`src/config/architecture/modules.json`) is the SSOT for which
|
||||
> Gradle projects exist; this catalog is the SSOT for which of them the Stable contract covers, and
|
||||
> **`GrpcStableModuleCatalogTest` holds the two together.**"
|
||||
|
||||
그 테스트는 레지스트리를 읽지 않는다. 다섯 테스트가 하는 일은 목록을 리터럴과 대조하고, 두 집합의 서로소를 확인하고, 누출 판정을 확인하는 것이다.
|
||||
|
||||
```java
|
||||
assertThat(catalog.modules()).containsExactlyInAnyOrder(…리터럴…);
|
||||
assertThat(GrpcStableModuleCatalog.advancedModules()).isNotEmpty().noneMatch(catalog::isStable);
|
||||
```
|
||||
|
||||
`modules.json` 을 읽는 줄도, 파일 경로도 없다.
|
||||
|
||||
두 목록은 오늘 일치한다 — 레지스트리의 grpc 계열 리프가 18 개이고 목록이 12 + 6 이다. 어긋난 것은 그 일치를 무엇이 지키는가다.
|
||||
|
||||
같은 저장소가 이 형태를 messaging 가족에서 이미 기록했다 — 정확한 목록은 레지스트리가 소유하므로 산문에서 세지 않는다, 세는 순간 다시 표류한다.
|
||||
|
||||
수정은 테스트가 `modules.json` 을 읽어 grpc 계열 리프 집합과 두 상수 집합의 합집합을 대조하는 것이다. 그 테스트가 있으면 새 리프가 어느 쪽에도 들어가지 않은 채 추가되는 것을 잡는다.
|
||||
|
||||
### 17.3 P3 — `RESOURCE_EXHAUSTED` 매핑이 그 상태의 두 출처 중 하나만 가정한다
|
||||
|
||||
```java
|
||||
case INVALID_ARGUMENT, UNAUTHENTICATED, PERMISSION_DENIED, NOT_FOUND,
|
||||
FAILED_PRECONDITION, OUT_OF_RANGE, UNIMPLEMENTED, RESOURCE_EXHAUSTED -> REJECTED;
|
||||
```
|
||||
|
||||
이 분기는 전송이 미시작을 증명하지 못한 뒤에 도달한다. 즉 "보냈는지 모르지만 이 상태 코드는 거절을 뜻한다" 는 판정이다.
|
||||
|
||||
목록의 나머지 일곱은 서버가 일을 시작하기 전에 답하는 상태다. `RESOURCE_EXHAUSTED` 는 두 출처를 갖는다.
|
||||
|
||||
- 이 플랫폼 자신의 승인 제어기가 부하를 흘려보낼 때 — 일을 쓰기 전이므로 거절이 맞다.
|
||||
- 원격 서버가 작업 중 자원(할당량·디스크)을 소진했을 때 — 부분 커밋이 있을 수 있다.
|
||||
|
||||
이 클래스의 원칙은 보수적이다. 자바독이 두 기본값(`DEADLINE_EXCEEDED`·`UNAVAILABLE` 를 모호로)을 계획의 전역 제약이라 부르고, 그 이유는 "보냈는지 모르면 모호" 다. `RESOURCE_EXHAUSTED` 는 그 원칙에서 벗어난 유일한 항목이다.
|
||||
|
||||
`ABORTED` 가 모호에 있는 것과 대비된다 — 트랜잭션 충돌은 서버가 일을 시작한 뒤의 상태이고, 그래서 모호다.
|
||||
|
||||
수정은 둘 중 하나다. `RESOURCE_EXHAUSTED` 를 모호로 옮기거나, 그 상태를 이 플랫폼이 발행한 것과 원격이 발행한 것으로 구분해 전자만 거절로 두는 것이다. 후자는 증거 축에 발신자 정보를 요구하므로 전자가 현실적이다.
|
||||
|
||||
### 17.4 P3 — 하나의 상태 코드가 같은 메서드 안에서 두 답을 갖는다
|
||||
|
||||
`forMutation` 은 스위치에 닿기 전에 `OK` 를 먼저 처리한다.
|
||||
|
||||
```java
|
||||
if (statusCode == GrpcStatusCode.OK) { return COMPLETED; }
|
||||
if (evidence.transport().provesNotStarted()) { return REJECTED; }
|
||||
return switch (statusCode) {
|
||||
…
|
||||
case OK, ALREADY_EXISTS, ABORTED, DATA_LOSS -> COMPLETION_UNKNOWN; // ← OK 가 여기에도 있다
|
||||
};
|
||||
```
|
||||
|
||||
스위치의 `OK` 분기는 도달하지 않는다. 열거형 전수 처리를 컴파일러가 요구하므로 항목 자체는 필요하지만, 그 값이 위의 가드와 반대다.
|
||||
|
||||
결과는 잠재적 함정이다. 누군가 위의 `OK` 가드를 "중복이니까" 지우면 컴파일은 통과하고 `OK` 인 변경이 `COMPLETION_UNKNOWN` 이 된다 — 성공한 변경마다 대사(reconciliation)를 요구하게 된다. 이 리프의 다른 자리들은 그런 편집이 눈에 띄도록 설계되어 있다(예: 승격 메서드를 하나로 좁힌 것).
|
||||
|
||||
수정은 한 글자다. 스위치의 `OK` 를 `COMPLETED` 로 옮기면 두 자리의 답이 같아지고, 가드가 사라져도 결과가 바뀌지 않는다.
|
||||
|
||||
### 17.5 P3 — 메타데이터 예산의 두 성분 중 하나는 강제되지 않고, 나머지 하나는 바이트가 아니라 문자를 센다
|
||||
|
||||
`GrpcMetadataBudget` 은 세 성분을 갖는다 — `maxTotalBytes`·`maxUserDefinedBytes`·`maxEntries`.
|
||||
|
||||
`check(...)` 가 보는 것은 뒤의 둘뿐이다.
|
||||
|
||||
```java
|
||||
if (metadata.size() > maxEntries) { throw …; }
|
||||
int userDefinedBytes = 0;
|
||||
for (…) { userDefinedBytes += entry.getKey().name().length() + value.length(); }
|
||||
if (userDefinedBytes > maxUserDefinedBytes) { throw …; }
|
||||
// maxTotalBytes 는 여기서 쓰이지 않는다
|
||||
```
|
||||
|
||||
**첫째, `maxTotalBytes` 는 읽히지 않는다.** 저장소 전체에서 이 접근자를 부르는 곳은 compact constructor 의 순서 가드와 테스트 단언 하나뿐이다. 자바독은 그 이유를 설명한다 — 하드 총계를 넘기는 것은 프레임워크가 던지는 전송 거절이고, 여기서 함께 검사하면 "고칠 수 있는 쪽" 과 "고칠 수 없는 쪽" 이 한 자리에서 발견된다는 것. 판단은 옳다. 다만 그 결과로 이 record 는 자기가 쓰지 않는 수를 성분으로 들고 있고, 이름은 그것이 강제된다고 읽힌다.
|
||||
|
||||
**둘째, 단위가 어긋난다.** 성분 이름은 `...Bytes` 인데 세는 것은 `String.length()`, 즉 UTF-16 코드 단위다. 키는 `[a-z0-9._-]` 로 제한되어 ASCII 지만 값에는 문자 집합 제약이 없다. 다중 바이트 문자를 담은 값은 실제 프레임보다 적게 계산된다.
|
||||
|
||||
gRPC 의 ASCII 메타데이터 값은 프로토콜 상 인쇄 가능 ASCII 여야 하므로 실무에서는 대개 일치한다. 다만 그 제약을 이 클래스가 검사하지 않으므로, 일치는 보장이 아니라 관행이다.
|
||||
|
||||
수정은 둘 다 작다 — `value.getBytes(StandardCharsets.US_ASCII).length` 로 세거나 값의 문자 집합을 `GrpcMetadataKey.Kind.ASCII` 에 맞춰 검증하고, `maxTotalBytes` 는 성분에서 빼고 javadoc 의 서술로 남긴다.
|
||||
|
||||
### 17.6 P3 — 직렬화 가능하다고 선언한 예외가 자기 내용을 직렬화하지 않는다
|
||||
|
||||
```java
|
||||
public class GrpcPlatformException extends RuntimeException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
private final transient GrpcFailureContext context; // ← transient
|
||||
…
|
||||
public boolean requiresReconciliation() { return context.completionOutcome().requiresReconciliation(); }
|
||||
}
|
||||
```
|
||||
|
||||
`serialVersionUID` 는 이 타입이 직렬화된다는 선언이고, `transient` 는 유일한 필드가 그 직렬화에서 빠진다는 선언이다. 둘이 함께 있으면 역직렬화된 예외는 `context == null` 이고, 공개 메서드 둘 중 하나(`requiresReconciliation()`)가 NPE 를 던진다.
|
||||
|
||||
`transient` 자체는 강제된 선택이다 — `GrpcFailureContext` 가 `Serializable` 을 구현하지 않으므로 필드를 남기면 예외가 직렬화되지 않는다.
|
||||
|
||||
기록하는 이유는 이 리프의 서술 규율과 대비되기 때문이다. 다른 자리에서는 부재마다 이유가 붙어 있다("There is no factory that takes raw metadata, and that absence is the design"). 여기에는 `transient` 의 이유도, 역직렬화 뒤의 계약도 적혀 있지 않다.
|
||||
|
||||
도달성은 낮다. gRPC 예외가 자바 직렬화를 지나는 경로는 이 저장소에 없다. 수정은 셋 중 하나다 — `GrpcFailureContext` 와 그 구성 요소를 `Serializable` 로 만들거나, `serialVersionUID` 를 지워 직렬화를 지원하지 않음을 명시하거나, `context()` 와 `requiresReconciliation()` 이 null 문맥을 다루도록 하고 그 이유를 적는 것.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **의존성 0 을 계약으로 삼고 그 이유를 적은 것** — "evidence and policy do not know about a transport" 가 검증 가능해진다.
|
||||
- **증거 세 축을 한 타입에 두고 관측 불가 조합을 생성자가 거부한 것.**
|
||||
- **승격 메서드를 하나로 좁혀 금지된 편집이 그 메서드의 변경으로 보이게 한 것.**
|
||||
- **완료 결과를 상태 코드와 분리한 것과 그 예시.**
|
||||
- **`ALREADY_EXISTS`·`ABORTED` 를 모호로 둔 것.**
|
||||
- **정책 없는 메서드를 조회에서 던지는 것.**
|
||||
- **서술자 대조를 선택 사항으로 두되 그 부재의 대가를 자바독에 적은 것.**
|
||||
- **`advancedDependencyAllowed()` 가 인자를 받지 않는 것과 그 근거.**
|
||||
- **누출을 집합으로 돌려주는 것.**
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc/grpc-core-api/build.gradle:1-10
|
||||
main/java/…/policy/GrpcMethodPolicyCatalog.java:1-124 (§17.1 withDescriptorMethods:80-87)
|
||||
main/java/…/policy/GrpcMethodPolicy.java:1-100
|
||||
main/java/…/error/GrpcFailureContext.java:1-99
|
||||
main/java/…/context/GrpcRequestContext.java:1-87
|
||||
main/java/…/evidence/GrpcExecutionEvidence.java:1-87
|
||||
main/java/…/deadline/GrpcDeadlineBudget.java:1-85
|
||||
main/java/…/evidence/GrpcStreamEvidence.java:1-80
|
||||
main/java/…/core/GrpcStableModuleCatalog.java:1-79 (§17.2)
|
||||
main/java/…/error/GrpcCompletionOutcome.java:1-69 (§17.3 · §17.4 forMutation:360-390)
|
||||
main/java/…/deadline/GrpcCancellationToken.java:1-68
|
||||
main/java/…/context/GrpcMetadataBudget.java:1-66 (§17.5 check:130-154)
|
||||
main/java/…/context/GrpcMetadataKey.java:1-65
|
||||
main/java/…/error/GrpcFailureCategory.java:1-64
|
||||
main/java/…/deadline/GrpcDeadlineProfile.java:1-59
|
||||
main/java/…/ledger/GrpcOperationLedgerRecord.java:1-59
|
||||
main/java/…/context/GrpcClientIdentity.java:1-55
|
||||
main/java/…/core/GrpcMethodName.java:1-55
|
||||
main/java/…/core/{GrpcStableBuildInvariant:1-53, RpcType:1-53, GrpcStatusCode:1-52,
|
||||
GrpcIdentifiers:1-47, GrpcServiceName:1-37, GrpcChannelProfileName:1-24}
|
||||
main/java/…/policy/{RpcIdempotencyProfile:1-49, WaitForReadyPolicy:1-22}
|
||||
main/java/…/ledger/{GrpcOperationLedger:1-50, GrpcOperationIdentity:1-39, GrpcOperationLedgerState:1-28}
|
||||
main/java/…/error/GrpcPlatformException.java:1-44 (§17.6)
|
||||
main/java/…/evidence/{GrpcTransportEvidence:1-41, GrpcBusinessEvidence:1-31}
|
||||
main/java/…/deadline/GrpcDeadlineExceededException.java:1-26
|
||||
test/java/…/ 7파일 926줄 (GrpcMethodPolicyCatalogTest:183 · GrpcFailureContextTest:182 ·
|
||||
GrpcMetadataBudgetTest:174 · GrpcDeadlineBudgetTest:124 · GrpcExecutionEvidenceTest:120 ·
|
||||
GrpcCoreIdentifiersTest:83 · GrpcStableModuleCatalogTest:60)
|
||||
src/config/architecture/modules.json (§17.2 — 테스트가 읽지 않는 SSOT)
|
||||
grpc-spring-boot-starter/…/GrpcPlatformStartupValidator.java:176 (GrpcStableBuildInvariant 실사용)
|
||||
grpc-advanced/grpc-advanced-bootstrap/…/GrpcAdvancedModuleGuard.java:76 (같은 불변식의 반대편)
|
||||
```
|
||||
@@ -0,0 +1,239 @@
|
||||
# grpc-discovery 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 — `src/main` production 7파일 409줄, test 2파일 220줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc/grpc-discovery`
|
||||
> SSOT owner: `grpc-discovery`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `allowed_dependencies`: `["grpc-core-api", "grpc-client"]`
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
|
||||
| 파일 | LOC |
|
||||
|---|---:|
|
||||
| `GrpcKubernetesProfile` | 90 |
|
||||
| `GrpcDiscoveryPolicyValidator` | 67 |
|
||||
| `GrpcResolverProfile` | 63 |
|
||||
| `GrpcKubernetesProfileValidator` | 61 |
|
||||
| `GrpcResolverType` · `GrpcKubernetesRoutingMode` | 45 · 45 |
|
||||
| `GrpcStableLoadBalancer` | 38 |
|
||||
| **main 합계 (7파일)** | **409** |
|
||||
| `GrpcKubernetesProfileTest` · `GrpcDiscoveryPolicyValidatorTest` | 127 · 93 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 7 | `FULL_READ` | 409줄 전 본문 |
|
||||
| `test/java/**` | 2 | `FULL_READ` | 220줄 전 본문 · 테스트 15개 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 9줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-5
|
||||
// Stable discovery: Static/DNS resolvers, pick_first/round_robin load balancing, and the
|
||||
// Kubernetes VIP / headless / mesh routing profiles. Custom resolvers, custom load balancers and
|
||||
// xDS are Advanced and are refused here by GrpcDiscoveryPolicyValidator.
|
||||
```
|
||||
|
||||
## 2. 이 리프가 붙드는 한 가지 짝
|
||||
|
||||
세 타입이 같은 사실을 다른 각도에서 말한다.
|
||||
|
||||
- `GrpcResolverType` — 각 리졸버가 주소를 여럿 돌려줄 수 있는가. `STATIC`·`DNS` 는 예, `UNIX` 는 아니오.
|
||||
- `GrpcStableLoadBalancer` — 주소 수에 맞는 정책. 1개면 `PICK_FIRST`, 여럿이면 `ROUND_ROBIN`.
|
||||
- `GrpcKubernetesRoutingMode` — 누가 균형을 잡는가. VIP 는 kube-proxy, headless 는 클라이언트, MESH 는 사이드카.
|
||||
|
||||
세 javadoc 이 같은 실패를 다르게 서술한다.
|
||||
|
||||
> `GrpcResolverType` — "A resolver that returns one address makes `round_robin` a no-op, and the
|
||||
> pairing is the most common way a deployment has load balancing on paper and none in practice."
|
||||
|
||||
> `GrpcStableLoadBalancer` — "`pick_first` over a headless record pins every request from this
|
||||
> client to one pod, which shows up as one instance at capacity while the rest are idle."
|
||||
|
||||
> `GrpcKubernetesRoutingMode` — "A Service VIP balances per connection in kube-proxy, which for a
|
||||
> long-lived HTTP/2 connection means it does not balance at all after the first request."
|
||||
|
||||
## 3. 두 검증기가 다른 질문에 답한다
|
||||
|
||||
`GrpcKubernetesProfileValidator` javadoc 이 분리 이유를 적는다.
|
||||
|
||||
> "The resolver validator asks whether a load-balancing policy does anything over the addresses it
|
||||
> will see; this one asks whether the deployment shape, the retry owner and the stream obligations
|
||||
> agree with each other. A deployment can have a perfectly coherent resolver profile and still have
|
||||
> put retries in two places."
|
||||
|
||||
| 검사 | 어디 |
|
||||
|---|---|
|
||||
| 균형 정책이 주소 수에 대해 무의미한가 | `GrpcDiscoveryPolicyValidator` |
|
||||
| 재시도 소유자가 라우팅 모드가 요구하는 것과 다른가 | `GrpcKubernetesProfileValidator` |
|
||||
| VIP 인데 긴 스트림을 싣는가 | 〃 |
|
||||
| 배수 유예가 재접속 예산보다 짧은가 | 〃 |
|
||||
|
||||
## 4. 생성자가 거부하는 것과 검증기가 보고하는 것
|
||||
|
||||
`GrpcResolverProfile` 정규 생성자가 네 조합을 아예 만들 수 없게 한다 — 주소 0 이하, 단일 엔드포인트 리졸버에 복수 주소, 음수 갱신 주기, DNS 인데 갱신 주기 0.
|
||||
|
||||
`GrpcKubernetesProfile` 정규 생성자는 셋을 막는다 — 메시 라우팅에 in-process 재시도 소유자, 긴 스트림인데 재접속 예산 0, 긴 스트림인데 배수 유예 0.
|
||||
|
||||
두 겹의 역할 분담이 이 저장소의 다른 곳에 적힌 규칙과 같다 — 위험한 조합은 정책이 아니라 생성자가 거부하게 만든다.
|
||||
|
||||
그리고 그 분담 때문에 검증기의 재시도 소유자 규칙은 일부 조합에서만 발화한다. `MESH` + `GRPC_PLATFORM` 은 생성자가 먼저 던지므로(둘 다 in-process 재시도) 검증기까지 오지 않고, `MESH` + `NONE` 이나 `K8S_VIP` + `SERVICE_MESH` 는 생성자를 통과해 검증기가 잡는다. 도달 불가 분기가 아니라 역할 분담이다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
두 테스트 220줄 · 15개.
|
||||
|
||||
`GrpcDiscoveryPolicyValidatorTest` 7개 — Stable 리졸버 셋의 열거, Advanced 스킴과 미지 스킴 거부, 주소 수에 따른 권고, 단일 주소 위의 round_robin 보고, 올바른 짝의 통과, 갱신 주기 0 인 DNS 거부, 단일 엔드포인트 리졸버의 복수 주소 거부.
|
||||
|
||||
`GrpcKubernetesProfileTest` 8개 — 라우팅 모드별 균형자·재시도 소유자, 메시 + in-process 재시도의 생성자 거부, 긴 스트림의 두 필수 값, 세 팩토리의 통과, headless 인데 주소 1개, VIP 인데 긴 스트림, 배수 유예 < 재접속 예산, 라우팅 모드가 함의하는 리졸버 프로파일.
|
||||
|
||||
**레인에 없는 것 하나.** §4 가 "생성자를 통과해 검증기가 잡는다" 고 설명한 분기 — `retryOwner != routingMode.requiredRetryOwner()` — 를 실제로 발화시키는 테스트가 없다.
|
||||
|
||||
```java
|
||||
// GrpcKubernetesProfileValidator:204
|
||||
if (profile.retryOwner() != profile.routingMode().requiredRetryOwner()) { violations.add(…); }
|
||||
```
|
||||
|
||||
`routingModesImplyTheirOwners` 는 열거형의 `requiredRetryOwner()` 값만 단언하고 검증기를 부르지 않는다. `aMeshProfileMayNotAlsoRetryInProcess` 는 생성자 쪽을 친다. `MESH` + `NONE` 이나 `K8S_VIP` + `SERVICE_MESH` — 두 검증기 분담을 실증하는 조합 — 은 어느 테스트에도 없다. 규칙은 있고 그것을 붙드는 단언이 없다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** 이 리프 밖의 production 소비자는 하나뿐이다.
|
||||
|
||||
| 타입 | leaf 밖 main 참조 |
|
||||
|---|---:|
|
||||
| `GrpcDiscoveryPolicyValidator` | 1 — `GrpcPlatformStartupValidator.validateChannels` |
|
||||
| 나머지 6종 | **0** |
|
||||
|
||||
그리고 그 하나의 소비자인 시작 검증기는 시작 시 실행되지 않는다(`grpc-spring-boot-starter` §17.1). 그러므로 Advanced 스킴 거부(`requireStableScheme`)에 도달하는 production 경로가 없다.
|
||||
|
||||
**12.2 거절 목록은 안전이 아니라 메시지를 위해 있다.**
|
||||
|
||||
```java
|
||||
private static final List<String> ADVANCED_SCHEMES = List.of("xds", "consul", "etcd", "eureka");
|
||||
…
|
||||
if (ADVANCED_SCHEMES.contains(scheme)) { throw new IllegalArgumentException("… Advanced capability …"); }
|
||||
return GrpcResolverType.forScheme(scheme).orElseThrow(() -> new IllegalArgumentException("unknown resolver scheme …"));
|
||||
```
|
||||
|
||||
두 번째 줄이 이미 허용 목록이다 — `GrpcResolverType` 이 아는 것은 `static`·`dns`·`unix` 셋뿐이고, 그 밖은 전부 `orElseThrow` 로 떨어진다. 그러므로 `xds` 는 거절 목록이 없어도 거부된다.
|
||||
|
||||
거절 목록이 하는 일은 **거부 사유를 바꾸는 것**이다 — "unknown resolver scheme" 대신 "Advanced capability with its own control plane and promotion gate". 클래스 javadoc 이 그 구분을 명시한다.
|
||||
|
||||
> "`xds:///` in a Stable profile is not a configuration mistake to warn about — it is a capability
|
||||
> with its own control plane, its own failure modes and its own promotion gate."
|
||||
|
||||
읽는 사람에게 중요한 함의: 다섯 번째 Advanced 스킴 이름을 이 목록에 넣지 않아도 **안전은 유지된다.** 빠지면 나빠지는 것은 메시지의 정확도뿐이고, 그것이 이 목록이 감당하는 유일한 부채다. 기본 거절이 바깥을 지킨다.
|
||||
|
||||
**12.4 드리프트.** build.gradle 이 서술한 범위(Static/DNS, pick_first/round_robin, VIP/headless/mesh, xDS 거부)가 전부 코드에 있다. 드리프트 없음.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 실제 DNS 리졸버로 헤드리스 레코드를 조회해 주소 수를 확인하지 않았다. 이 리프는 그 수를 입력으로 받는다.
|
||||
- 시작 검증기를 통한 스킴 거부를 실행으로 확인하지 않았다. 그 검증기가 돌지 않는다.
|
||||
- 테스트를 실행하지 않았다. 15개 전부 본문으로만 확인했다.
|
||||
- §17.3 의 `violations(profile, 0)` 을 실행으로 재현하지 않았다. `resolverProfile` → `GrpcResolverProfile` 정규 생성자 경로로 판정했다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P3 — 프로파일이 스트림 재접속 예산을 선언하는데 그것이 함의하는 DNS 갱신 주기를 정하지 않는다
|
||||
|
||||
`GrpcKubernetesProfile` 은 세 시간 값을 다룬다.
|
||||
|
||||
```java
|
||||
streamReconnectBudget // 프로파일이 선언
|
||||
readinessDrainGrace // 프로파일이 선언
|
||||
refreshInterval // resolverProfile(...) 이 30초로 하드코딩
|
||||
```
|
||||
|
||||
```java
|
||||
public GrpcResolverProfile resolverProfile(int expectedAddressCount) {
|
||||
return new GrpcResolverProfile(
|
||||
GrpcResolverType.DNS, routingMode.loadBalancingPolicy(), Duration.ofSeconds(30), expectedAddressCount);
|
||||
}
|
||||
```
|
||||
|
||||
검증기는 앞의 둘만 비교한다 — 배수 유예가 재접속 예산보다 짧으면 위반. 셋째는 비교 대상에 없다.
|
||||
|
||||
그래서 `headlessStreaming()`(재접속 예산 5초, 배수 유예 30초)에서 갱신 주기는 여전히 30초다. 롤아웃으로 스트림이 끊긴 클라이언트가 5초 예산 안에 재접속하려 할 때, 그 클라이언트의 DNS 캐시는 최대 30초 동안 사라진 파드 주소를 들고 있을 수 있다.
|
||||
|
||||
그 실패가 `GrpcResolverProfile` 자신의 javadoc 이 서술한 것이다 — "A channel that resolved once at startup keeps sending to addresses that stopped existing an hour ago; the calls fail with `UNAVAILABLE` and the deployment looks unhealthy long after it finished."
|
||||
|
||||
수정은 갱신 주기를 재접속 예산에서 파생시키거나(예: 예산 이하), 검증기에 세 값의 순서 규칙을 추가하는 것이다.
|
||||
|
||||
### 17.2 P3 — 리졸버 검증기의 규칙이 하나뿐인데 javadoc 은 복수형으로 서술한다
|
||||
|
||||
```java
|
||||
public static List<String> violations(GrpcResolverProfile profile) { … } // 규칙 1개
|
||||
```
|
||||
|
||||
javadoc 은 "Checks a discovery configuration for **the things** that look right and are not" 라고 적는다. 실제로 담긴 규칙은 균형 정책의 무의미함 하나다.
|
||||
|
||||
나머지 위험 조합은 `GrpcResolverProfile` 정규 생성자가 이미 거부하므로 결과적으로 빈틈은 아니다. 다만 목록으로 보고하는 API 형태와 규칙 하나라는 내용이 어긋나 있어, 다음 사람이 여기에 규칙을 더할 자리로 읽거나 이미 여러 규칙이 있다고 읽는다. §17.1 이 실제로 그 자리다.
|
||||
|
||||
### 17.3 P3 — 목록으로 보고하는 검증기가 주소 수 0 에서 던진다
|
||||
|
||||
```java
|
||||
public static List<String> violations(GrpcKubernetesProfile profile, int expectedAddressCount) {
|
||||
…
|
||||
List<String> violations = new ArrayList<>(
|
||||
GrpcDiscoveryPolicyValidator.violations(profile.resolverProfile(expectedAddressCount)));
|
||||
```
|
||||
|
||||
`profile.resolverProfile(n)` 이 `new GrpcResolverProfile(DNS, …, n)` 을 만들고, 그 정규 생성자가 거부한다.
|
||||
|
||||
```java
|
||||
if (expectedAddressCount < 1) {
|
||||
throw new IllegalArgumentException("a target resolves to at least one address");
|
||||
}
|
||||
```
|
||||
|
||||
그래서 `violations(profile, 0)` 은 빈 목록도 위반 목록도 아닌 `IllegalArgumentException` 이다. 같은 메서드가 `profile == null` 에는 명시적으로 던지고 나머지는 목록으로 답하므로, 호출자는 이 API 를 "던지지 않고 보고한다" 로 읽는다.
|
||||
|
||||
**왜 0 이 실제 값인가.** `expectedAddressCount` 는 이 리프가 계산하지 않고 입력으로 받는 값이고(§16), 그 출처는 헤드리스 레코드의 DNS 조회 결과다. 롤아웃 중 파드가 모두 교체되는 순간이나 셀렉터가 어긋난 서비스에서 그 답은 0 이다. 그것은 이 리프가 다루는 문제 영역 안의 상태이지 프로그래밍 오류가 아니다 — 그리고 운영자가 가장 보고받고 싶어 할 상태다.
|
||||
|
||||
`GrpcResolverProfile` 쪽 거부 자체는 옳다. 값 객체가 "주소 0 개인 목표"를 표현하지 않는 것은 §4 의 두 겹 분담과 일치한다. 어긋난 것은 그 위에 얹힌 검증기가 그 예외를 그대로 통과시킨다는 점이다.
|
||||
|
||||
**수정.** `violations` 가 `expectedAddressCount < 1` 을 먼저 보고 위반 문자열로 보고한 뒤 나머지 검사를 건너뛴다. 그러면 이 리프가 답할 수 있는 가장 중요한 배포 상태 하나가 예외가 아니라 목록의 한 줄이 된다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **리졸버의 다중 주소 가능성을 열거형 속성으로 둔 것** — 짝이 맞지 않는 조합을 타입 수준에서 판정할 수 있다.
|
||||
- **위험한 조합을 생성자가 거부하고 애매한 조합만 검증기가 보고하는 두 겹.**
|
||||
- **두 검증기를 분리하고 그 이유를 적은 것.**
|
||||
- **Advanced 스킴을 이름으로 거부하고 그 근거를 적은 것** — 통제 평면과 승격 게이트가 따로 있는 능력이다.
|
||||
- **알 수 없는 스킴을 기본 거절로 둔 것.**
|
||||
- **긴 스트림을 싣는 프로파일에 재접속 예산과 배수 유예를 필수로 만든 것.**
|
||||
- **세 팩토리(`virtualIp` · `headlessStreaming` · `mesh`)가 각자 일관된 조합을 들고 있고, 테스트가 셋 다 위반 0 임을 확인하는 것** — 기본으로 고르는 값이 스스로의 규칙을 만족한다.
|
||||
- **`GrpcRetryOwner.NONE` 이 "아직 정하지 않았다" 와 구분되는 것** — 그 열거형 javadoc 이 "A method whose owner is NONE has been looked at" 라고 적고, 이 리프의 두 겹 분담이 그 값 덕분에 의미를 갖는다(§4).
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc/grpc-discovery/build.gradle:1-9
|
||||
main/java/…/discovery/GrpcKubernetesProfile.java:1-90
|
||||
main/java/…/discovery/GrpcDiscoveryPolicyValidator.java:1-67
|
||||
main/java/…/discovery/GrpcResolverProfile.java:1-63
|
||||
main/java/…/discovery/GrpcKubernetesProfileValidator.java:1-61
|
||||
main/java/…/discovery/GrpcResolverType.java:1-45
|
||||
main/java/…/discovery/GrpcKubernetesRoutingMode.java:1-45
|
||||
main/java/…/discovery/GrpcStableLoadBalancer.java:1-38
|
||||
test/java/…/discovery/GrpcKubernetesProfileTest.java:1-127
|
||||
test/java/…/discovery/GrpcDiscoveryPolicyValidatorTest.java:1-93
|
||||
src/grpc/grpc-policy/…/resilience/GrpcRetryOwner.java:14-33
|
||||
```
|
||||
@@ -0,0 +1,277 @@
|
||||
# grpc-observability 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 — `src/main` production 4파일 354줄, test 1파일 172줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc/grpc-observability`
|
||||
> SSOT owner: `grpc-observability`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- `allowed_dependencies`: `["grpc-core-api"]`
|
||||
- `runtime_memberships`: **`[]`** (`EVD-325`)
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | **4** (354 LOC) |
|
||||
| test Java 파일 | 1 (172 LOC) |
|
||||
| build 파일 | `build.gradle` 12줄 |
|
||||
| test 메서드(실행 확인) | **10** (`EVD-325`) |
|
||||
| 선언된 의존 | project 1 + vendor 1 (`micrometer-core`) |
|
||||
|
||||
파일별 LOC:
|
||||
|
||||
| 파일 | LOC | 성격 |
|
||||
|---|---:|---|
|
||||
| `GrpcMetricCardinalityPolicy` | 123 | 태그 허용/거절 판정 (static 유틸) |
|
||||
| `GrpcObservationConvention` | 99 | Micrometer 등록 (유일한 상태 보유 클래스) |
|
||||
| `GrpcRpcObservation` | 78 | 논리 RPC 관측 record |
|
||||
| `GrpcStreamObservation` | 54 | 스트림 수명 관측 record |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `build.gradle` | 1 | `FULL_READ` | 12줄 전문 |
|
||||
| `main/…/observability/*.java` | 4 | `FULL_READ` | 4파일 전 본문 축자 확인 (cycle 2) |
|
||||
| `test/…/GrpcMetricCardinalityPolicyTest.java` | 1 | `FULL_READ` | 172줄, 10개 `@Test` 전부 단언 대상 확인 |
|
||||
|
||||
`STRUCTURAL_ONLY` 0 · `UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-5
|
||||
// Bounded observability: logical RPC vs physical attempt vs stream lifecycle, with a cardinality
|
||||
// policy that refuses payload, raw metadata and any actor/tenant/object/stream/idempotency
|
||||
// identifier as a tag.
|
||||
```
|
||||
|
||||
세 층위를 구별한다 — 논리 RPC, 물리 시도, 스트림 수명주기.
|
||||
|
||||
Micrometer 를 `api` 로 노출하는 이유도 build.gradle 에 적혀 있다 — "the observation convention's public signatures name Micrometer types, so wiring it requires naming them." 실제로 `GrpcObservationConvention` 의 생성자와 `boundedTags` 반환형이 Micrometer 타입(`MeterRegistry`, `Tags`)이므로 그 서술은 코드와 일치한다.
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
`grpc-core-api` 에서 쓰는 타입은 넷이다 — `GrpcMethodName`, `GrpcStatusCode`, `RpcType`, `GrpcCompletionOutcome`. 네 타입 모두 `GrpcRpcObservation` 의 record 성분이다. `GrpcStreamObservation` 은 `GrpcMethodName` 하나만 쓴다.
|
||||
|
||||
배선 없음(`EVD-325`). `runtime_memberships` 가 비어 있고, 저장소 어디에서도 `new GrpcObservationConvention(...)` 을 만드는 production 코드가 없다.
|
||||
|
||||
## 3. 컴포넌트 지도
|
||||
|
||||
```
|
||||
GrpcMetricCardinalityPolicy 태그 키 allowlist 8 · 명시적 거절 11 · 값 패턴 1
|
||||
GrpcObservationConvention meter 이름 7개 상수 · record 오버로드 2개
|
||||
GrpcRpcObservation 9성분 record · tags() 7태그
|
||||
GrpcStreamObservation 7성분 record · tags() 5태그
|
||||
```
|
||||
|
||||
## 4. 계약·불변식
|
||||
|
||||
### 4.1 allowlist 가 기본 거절이고 거절 목록은 메시지를 위한 것이다
|
||||
|
||||
`violations(Map)` 의 판정 순서가 셋이다.
|
||||
|
||||
```java
|
||||
if (FORBIDDEN_TAGS.contains(key)) → "its value space grows with traffic…"
|
||||
if (!ALLOWED_TAGS.contains(key)) → "not on the bounded allowlist [...]"
|
||||
if (UNBOUNDED_VALUE.matcher(value)) → "looks like an identifier or a credential"
|
||||
```
|
||||
|
||||
클래스 javadoc 이 두 목록이 겹치는 이유를 적는다 — "Everything unlisted is refused anyway; naming the dangerous ones gives the refusal a message that says why rather than just that." 즉 `FORBIDDEN_TAGS` 는 판정을 바꾸지 않고 진단만 바꾼다. 두 번째 분기가 이미 그것들을 거절한다.
|
||||
|
||||
허용 태그 8개: `grpc.service` · `grpc.method` · `grpc.rpc_type` · `grpc.status` · `grpc.channel_profile` · `grpc.completion_outcome` · `grpc.retry_bucket` · `grpc.stream_termination_reason`.
|
||||
|
||||
명시적 거절 11개: `actor_id` · `tenant_id` · `object_id` · `stream_id` · `idempotency_key` · `request` · `response` · `metadata` · `authorization` · `error_detail` · `trace_id`.
|
||||
|
||||
### 4.2 값 검사는 세 형태만 잡는다
|
||||
|
||||
```java
|
||||
Pattern.compile("(?i).*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|sha256:|bearer ).*")
|
||||
```
|
||||
|
||||
UUID · `sha256:` 접두 · `bearer ` 접두. 숫자 id, 이메일, 호스트명은 잡히지 않는다. 그리고 `.` 은 기본적으로 개행에 맞지 않으므로 값에 개행이 섞이면 `matches()` 가 거짓이 된다.
|
||||
|
||||
### 4.3 재시도는 값이 아니라 버킷이다
|
||||
|
||||
`retryBucket(int)` 이 1-based 시도 수를 받아 `0`/`1`/`2`/`3+` 로 접는다. 0 이하는 던진다. javadoc 이 이유를 적는다 — "an attempt count is unbounded in principle and the distinction anyone acts on is first attempt, one retry, several."
|
||||
|
||||
### 4.4 논리 호출과 물리 시도의 분리
|
||||
|
||||
`GrpcRpcObservation` javadoc:
|
||||
|
||||
> "A retried call is one observation with a retry bucket, and three attempt events beneath it;
|
||||
> recording three separate calls instead makes the success rate read as 33% when the caller in fact
|
||||
> got its answer."
|
||||
|
||||
그 분리가 `GrpcObservationConvention.record(GrpcRpcObservation)` 에서 실제로 그렇게 구현되어 있다 — `RPC_DURATION` 타이머는 1회, `RPC_ATTEMPTS` 카운터는 `attempts` 만큼 증가. 같은 태그 집합을 쓴다.
|
||||
|
||||
### 4.5 조건부 기록 둘
|
||||
|
||||
```java
|
||||
if (observation.completionOutcome().requiresReconciliation()) → COMPLETION_UNKNOWN 카운터
|
||||
if (!observation.queueWaitTime().isZero()) → QUEUE_WAIT 타이머
|
||||
```
|
||||
|
||||
대기 시간이 0 이면 타이머를 등록조차 하지 않는다. 즉 큐 대기가 없던 배포에서는 그 meter 가 생기지 않는다.
|
||||
|
||||
### 4.6 생성자 검증의 비대칭 — 의도된 쪽
|
||||
|
||||
`GrpcRpcObservation` 의 검증에서 `duration` 과 `queueWaitTime` 은 음수를 거부하고 `deadlineRemaining` 은 존재만 요구한다. 그리고 `unusedDeadline()` 이 음수일 때 빈 값을 돌려준다. 마감을 넘긴 호출을 표현하기 위한 것으로 읽히고, 두 메서드가 그 해석과 일관된다.
|
||||
|
||||
### 4.7 스트림은 지속 시간이 아니라 무엇이 움직였는지로 잰다
|
||||
|
||||
`GrpcStreamObservation` javadoc:
|
||||
|
||||
> "Duration percentiles are meaningless here — a healthy subscription lasts an hour and an unhealthy
|
||||
> one lasts an hour — so what is recorded instead is what actually distinguishes them: how many
|
||||
> messages moved, how often the writer stalled waiting for the transport, and how it ended."
|
||||
|
||||
그리고 `tags()` 주석이 "The stream id is deliberately absent" 라고 적는다. 실제로 `grpc.stream_id` 는 `FORBIDDEN_TAGS` 에도 있어 두 겹으로 막힌다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
**10 tests, 0 failures, 0 skipped.** 전부 `GrpcMetricCardinalityPolicyTest`(172줄).
|
||||
|
||||
| 테스트 | 붙드는 것 |
|
||||
|---|---|
|
||||
| `onlyBoundedTagsAreAllowed` | allowlist 원소 |
|
||||
| `unboundedIdentifierTagsAreRefused` | 식별자 5종 거절 + 메시지 문구 |
|
||||
| `contentBearingTagsAreRefused` | 페이로드·메타데이터·오류 상세 3종 |
|
||||
| `anIdentifierShapedValueIsRefused` | 허용 키 + UUID/`Bearer` 값 |
|
||||
| `unlistedTagsAreRefused` | 목록 밖 키 + 메시지에 allowlist |
|
||||
| `attemptsAreBucketed` | 1→`0`, 2→`1`, 4→`3+`, 99→`3+`, 0→예외 |
|
||||
| `aRetriedCallIsOneObservation` | 타이머 1 · 시도 카운터 3 |
|
||||
| `completionUnknownIsCountedSeparately` | 전용 카운터 |
|
||||
| `streamsAreMeasuredByMessagesAndStalls` | `grpc.stream_id` 부재 · 메시지·스톨 카운터 |
|
||||
| `anUnboundedTagThrowsRatherThanBeingDropped` | 등록 거부가 던지기 |
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** 블록 전체가 배선되지 않았다(`EVD-325`). `unusedDeadline()`·`retried()`·`consumerFellBehind()`·`allowedTags()`·`forbiddenTags()`·`retryBuckets()` 의 production 호출자 0.
|
||||
|
||||
리프 밖 참조도 0 이다.
|
||||
|
||||
```
|
||||
$ grep -rn "grpc.observability" --include=*.java src/ | grep -v /grpc-observability/
|
||||
grpc-core-api/…/GrpcStableModuleCatalog.java:30: "grpc-observability", ← 목록 안의 문자열
|
||||
```
|
||||
|
||||
그런데 **두 모듈이 이 리프를 `api` 로 노출한다.**
|
||||
|
||||
```groovy
|
||||
grpc/grpc-testkit/build.gradle:53 api project(':grpc:grpc-observability')
|
||||
grpc/grpc-spring-boot-starter/build.gradle:16 api project(':grpc:grpc-observability')
|
||||
```
|
||||
|
||||
`api` 는 그 모듈을 쓰는 쪽까지 Micrometer 를 포함한 이 리프의 타입을 물려받는다는 선언인데, 두 모듈 어느 자바 파일도 `dev.caskeleton.grpc.observability` 를 import 하지 않는다. 스타터 쪽은 같은 형태의 미사용 의존을 셋 더 들고 있다(`grpc-spring-boot-starter` §12.3).
|
||||
|
||||
이 리프의 build.gradle 은 Micrometer 를 `api` 로 두는 이유를 적어 두었다 — 공개 서명이 Micrometer 타입을 이름으로 부르므로 배선하려면 그것을 명명해야 한다. 그 논거는 **이 리프를 실제로 쓰는 모듈**에 대해 성립한다. 지금은 쓰지 않는 두 모듈이 그 전파를 받고 있다.
|
||||
|
||||
**12.2 대조군 — 세 개의 카디널리티/노출 정책.**
|
||||
|
||||
| 위치 | 막는 것 | 배선 |
|
||||
|---|---|---|
|
||||
| `messaging` `CardinalityGuard` | 지표 태그 폭발 | 없음 (`EVD-316`) |
|
||||
| `grpc-observability` `GrpcMetricCardinalityPolicy` | 태그 키 allowlist + 값 형태 | 없음 |
|
||||
| `grpc-policy` `GrpcErrorExposurePolicy` | 클라이언트에 보낼 수 없는 문자열 | 블록 미배선 |
|
||||
|
||||
**12.3 중복 장치.** `GrpcStreamTerminationReason` 이 `grpc-policy` 에 열거형으로 존재한다. 이 리프의 `GrpcStreamObservation.terminationReason` 은 `String` 이다. §17.2 참조.
|
||||
|
||||
**12.4 문서 드리프트.** build.gradle 주석이 거절 대상으로 든 다섯(actor·tenant·object·stream·idempotency)이 `FORBIDDEN_TAGS` 에 전부 있다. 드리프트 없음.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 이 리프를 실제 `MeterRegistry` 에 배선해 돌린 적이 없다. 배선 자체가 없으므로 런타임 관측이 불가능하다.
|
||||
- `UNBOUNDED_VALUE` 를 우회하는 값 형태(숫자 id·이메일 등)를 실행으로 확인하지 않았다. 정규식 형태로 판정했다.
|
||||
- §17.1-b 의 "마감 잔량에 해당하는 meter 가 없다" 는 meter 이름 상수 일곱 개와 두 `record` 오버로드 본문으로 판정했다. 다른 이름의 상수가 그 역할을 겸하는지는 이름만 보고 배제했다.
|
||||
- 두 모듈의 `api` 의존이 미사용이라는 것(§12.1)은 패키지 이름 grep 으로 판정했다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P3 — `queueHighWatermark` 는 요구되고 검증되지만 아무도 읽지 않는다
|
||||
|
||||
`GrpcStreamObservation` 의 7성분 중 `queueHighWatermark` 만 소비자가 없다.
|
||||
|
||||
```
|
||||
GrpcStreamObservation.java:23 long queueHighWatermark, ← 선언
|
||||
GrpcStreamObservation.java:31 … || queueHighWatermark < 0 ← 검증
|
||||
그 외 저장소 전체 매치 0
|
||||
```
|
||||
|
||||
`tags()` 에 없고, `GrpcObservationConvention.record(GrpcStreamObservation)` 이 등록하는 세 meter(`STREAM_LIFETIME`·`STREAM_MESSAGES`·`STREAM_FLOW_CONTROL_STALLS`) 어디에도 들어가지 않는다. 테스트도 `250L` 을 넘기고 그 값에 대해 아무것도 단언하지 않는다.
|
||||
|
||||
클래스 javadoc 이 "what is recorded instead is …" 로 세 가지를 열거하는데 그 목록에도 없다. 즉 서술과 구현은 일치하고, 어긋난 것은 **필수 생성자 인자**라는 점이다. 호출자는 측정해서 넘겨야 하고 그 값은 버려진다.
|
||||
|
||||
수정은 둘 중 하나다 — `STREAM_QUEUE_HIGH_WATERMARK` gauge/counter 를 추가하거나, 성분에서 뺀다. 큐 최고 수위는 소비자 지연의 직접 지표이므로 전자가 이 클래스의 목적에 맞는다.
|
||||
|
||||
### 17.1-b P3 — `deadlineRemaining` 도 meter 가 없다. javadoc 은 그것이 기록된다고 말한다
|
||||
|
||||
§17.1 과 같은 형태가 `GrpcRpcObservation` 에도 있고, 이쪽은 클래스 javadoc 이 명시적으로 어긋난다.
|
||||
|
||||
> "{@code deadlineRemaining} and {@code queueWaitTime} are **recorded** because they are the two
|
||||
> numbers that explain a latency change without being latency. A p99 that doubles during a rollout
|
||||
> is a different incident depending on whether callers were queueing."
|
||||
|
||||
두 값을 함께 들면서 "기록된다"고 단언하는데, `record(GrpcRpcObservation)` 이 등록하는 meter 는 넷이다.
|
||||
|
||||
```java
|
||||
Timer.builder(RPC_DURATION)…record(observation.duration());
|
||||
registry.counter(RPC_ATTEMPTS, tags).increment(observation.attempts());
|
||||
if (…requiresReconciliation()) registry.counter(COMPLETION_UNKNOWN, tags).increment();
|
||||
if (!observation.queueWaitTime().isZero()) Timer.builder(QUEUE_WAIT)…record(observation.queueWaitTime());
|
||||
```
|
||||
|
||||
`queueWaitTime` 은 `QUEUE_WAIT` 타이머로 나간다. `deadlineRemaining` 은 나가는 곳이 없다 — meter 이름 상수 일곱 개 중에도 마감 잔량에 해당하는 것이 없고, `tags()` 에도 들어가지 않는다(태그로 넣으면 카디널리티가 터지므로 그것이 옳다).
|
||||
|
||||
그래서 이 성분을 읽는 코드는 `unusedDeadline()` 하나이고, 그 메서드의 production 호출자는 0 이다(§12.1).
|
||||
|
||||
**§4.6 과의 관계.** §4.6 은 이 성분의 검증 비대칭(음수 허용)이 "마감을 넘긴 호출을 표현하기 위한 것" 이라고 읽었다. 그 해석은 그대로 유효하다 — 다만 그 표현이 도달하는 곳이 아직 없다. 관측값으로서는 §17.1 의 `queueHighWatermark` 와 같은 처지다.
|
||||
|
||||
**수정.** `queueWaitTime` 과 같은 형태로 타이머를 하나 더 둔다(마감을 넘긴 경우는 `unusedDeadline()` 이 이미 빈 값으로 구분해 주므로 기록 대상에서 빼면 된다). 아니면 javadoc 의 "recorded" 를 "carried" 로 낮춘다. 지금은 관측 대상 둘을 나란히 약속하고 하나만 내보낸다.
|
||||
|
||||
### 17.2 P3 — 허용 태그 8개 중 둘은 값이 자유 문자열이고, 그중 하나는 bounded 열거형이 이미 존재한다
|
||||
|
||||
값 검사는 키가 allowlist 를 통과한 뒤 `UNBOUNDED_VALUE` 세 형태만 본다. 그런데 태그 값의 출처는 균일하지 않다.
|
||||
|
||||
| 태그 | 값 출처 | 유계 |
|
||||
|---|---|---|
|
||||
| `grpc.service` · `grpc.method` | `GrpcMethodName` | 서비스/메서드 수만큼 |
|
||||
| `grpc.rpc_type` · `grpc.status` · `grpc.completion_outcome` | 열거형 | 예 |
|
||||
| `grpc.retry_bucket` | `retryBucket()` 4값 | 예 |
|
||||
| `grpc.channel_profile` | `String` (null 이면 `"server"`) | **아니오** |
|
||||
| `grpc.stream_termination_reason` | `String`, 비어 있지 않기만 하면 됨 | **아니오** |
|
||||
|
||||
`GrpcStreamObservation` 의 검증은 `terminationReason` 이 널이 아니고 공백이 아닌지만 본다. 호출자가 예외 메시지나 원격 상태 문자열을 그대로 넣으면 그 태그의 값 공간이 트래픽과 함께 자란다 — 이 클래스가 존재하는 이유로 든 바로 그 실패다.
|
||||
|
||||
그리고 그 개념의 bounded 열거형이 이미 저장소에 있다 — `grpc-policy` 의 `GrpcStreamTerminationReason`.
|
||||
|
||||
쓰지 않은 이유는 의존 방향으로 설명된다. 이 리프의 `allowed_dependencies` 는 `["grpc-core-api"]` 뿐이고 그 열거형은 `grpc-policy` 에 있다. 그래서 수정은 열거형을 `grpc-core-api` 로 옮기거나, `violations` 가 두 자유 문자열 태그에 대해 허용값 집합을 받도록 서명을 넓히는 것이다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **논리 RPC / 물리 시도 / 스트림 수명주기를 구별한 것.** 재시도가 있는 시스템에서 호출 한 번이 무엇인지가 층위마다 다르고, `record` 구현이 그 구별을 실제로 지킨다.
|
||||
- **거절이 드롭이 아니라 던지기인 것.** `boundedTags` 의 javadoc 이 이유를 적는다 — 드롭하면 넣은 쪽이 계속 쓰고 첫 증상이 프로덕션 백엔드의 시계열 거부가 된다.
|
||||
- **`FORBIDDEN_TAGS` 를 진단 전용으로 둔 것.** 판정은 allowlist 가 하고, 이 목록은 왜 거절인지만 바꾼다.
|
||||
- **스트림 id 를 두 겹으로 막은 것.** `tags()` 에서 빼고 `FORBIDDEN_TAGS` 에도 둔다.
|
||||
- **`long → double` 확대 변환을 명시하고 이유를 주석에 적은 것.**
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc/grpc-observability/build.gradle:1-12
|
||||
main/…/observability/GrpcMetricCardinalityPolicy.java:1-123
|
||||
main/…/observability/GrpcObservationConvention.java:1-99
|
||||
main/…/observability/GrpcRpcObservation.java:1-78
|
||||
main/…/observability/GrpcStreamObservation.java:1-54
|
||||
test/…/observability/GrpcMetricCardinalityPolicyTest.java:1-172
|
||||
src/grpc/grpc-policy/…/streaming/GrpcStreamTerminationReason.java (대비)
|
||||
src/messaging/messaging-observability/…/CardinalityGuard.java (대비)
|
||||
```
|
||||
@@ -0,0 +1,226 @@
|
||||
# grpc-operation-ledger-jpa 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 — `src/main` production 3파일과 마이그레이션 1개 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc/grpc-operation-ledger-jpa`
|
||||
> SSOT owner: `grpc-operation-ledger-jpa`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- `allowed_dependencies`: `["grpc-core-api"]`
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
|
||||
| 파일 | LOC | 성격 |
|
||||
|---|---:|---|
|
||||
| `GrpcOperationLedgerEntity` | 155 | JPA 엔티티 + 상태 전이 |
|
||||
| `JpaGrpcOperationLedger` | 93 | 포트 구현 (insert-first 주장) |
|
||||
| `GrpcOperationLedgerRepository` | 28 | Spring Data 인터페이스 (메서드 4개) |
|
||||
| `V001__create_grpc_operation_ledger.sql` | 39 | 테이블 + 제약 4 + 인덱스 1 |
|
||||
| `GrpcOperationLedgerRepositoryTest` | 208 | 테스트 (인메모리 이중) |
|
||||
| `build.gradle` | 17 | project 1 + vendor 2 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 3 | `FULL_READ` | 155+93+28 전 본문 |
|
||||
| `main/resources/db/migration/grpc/*.sql` | 1 | `FULL_READ` | 39줄 전문 |
|
||||
| `test/java/**` | 1 | `FULL_READ` | 208줄, 인메모리 이중 구현 포함 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 17줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-8
|
||||
// Durable mutation idempotency: the operation ledger entity, its state machine, the vendor-neutral
|
||||
// repository port and the Spring Data JPA binding, plus the migration that owns the unique
|
||||
// constraint the whole contract rests on.
|
||||
// … Tests here run against a hand-rolled in-memory port implementation — a real datastore is only
|
||||
// justified when vendor semantics are the thing under test, and the constraint is asserted by the migration.
|
||||
```
|
||||
|
||||
마지막 문장이 이 리프의 검증 전략을 규정한다. §17.1 이 그 전략의 경계를 다룬다.
|
||||
|
||||
## 2. 스키마가 계약이다
|
||||
|
||||
```sql
|
||||
CONSTRAINT pk_grpc_operation_ledger PRIMARY KEY (storage_key),
|
||||
CONSTRAINT uq_grpc_operation_ledger_identity
|
||||
UNIQUE (caller_fingerprint, full_method_name, idempotency_key_hash),
|
||||
CONSTRAINT ck_grpc_operation_ledger_state
|
||||
CHECK (state IN ('IN_PROGRESS', 'COMMITTED', 'FAILED_TERMINAL')),
|
||||
CONSTRAINT ck_grpc_operation_ledger_committed_has_outcome
|
||||
CHECK (state <> 'COMMITTED' OR outcome_reference IS NOT NULL),
|
||||
CONSTRAINT ck_grpc_operation_ledger_terminal_has_completion
|
||||
CHECK (state = 'IN_PROGRESS' OR completed_at IS NOT NULL)
|
||||
```
|
||||
|
||||
마이그레이션 헤더가 왜 애플리케이션 검사가 아니라 제약인지 적는다.
|
||||
|
||||
> "A uniqueness check in application code instead would be a read followed by a write, with a window
|
||||
> between them precisely as wide as the race it is meant to close."
|
||||
|
||||
커밋 행이 결과를 반드시 갖는다는 검사를 자바 record 와 DB 양쪽에 둔 이유도 적혀 있다 — 마이그레이션·백필·지원 스크립트가 쓴 행은 record 를 지나지 않는다.
|
||||
|
||||
전용 Flyway 위치(`db/migration/grpc`)를 쓰는 이유도 적혀 있다. gRPC 플랫폼을 채택하지 않은 배포가 이 테이블을 만들도록 강요받지 않기 위해서다.
|
||||
|
||||
## 3. 저장 키와 유니크 제약이 같은 행을 가리킨다
|
||||
|
||||
`GrpcOperationIdentity`(grpc-core-api):
|
||||
|
||||
```java
|
||||
public String storageKey() {
|
||||
return callerFingerprint + "|" + method.canonical() + "|" + idempotencyKeyHash;
|
||||
}
|
||||
```
|
||||
|
||||
즉 기본 키는 유니크 제약의 세 컬럼을 이어 붙인 파생값이다. 엔티티 javadoc 이 그 이중 저장을 설명한다 — 복합 쪽이 원자성을 주고, 파생 키가 조회에 단일 컬럼 기본 키를 준다.
|
||||
|
||||
같은 신원의 두 번째 청구는 **같은 기본 키 행**을 겨냥한다. §17.1 이 그 사실에서 나온다.
|
||||
|
||||
## 4. 좁은 저장소 인터페이스
|
||||
|
||||
`Repository` 를 확장하고 네 메서드만 이름 짓는다.
|
||||
|
||||
> "`JpaRepository` publishes `deleteAll`, `findAll` and `saveAll` on the table that decides whether a
|
||||
> payment runs twice."
|
||||
|
||||
## 5. 어댑터의 주장
|
||||
|
||||
`JpaGrpcOperationLedger` javadoc:
|
||||
|
||||
> "`claim` is insert-first, read-on-conflict — not read-then-insert. That ordering is the whole
|
||||
> adapter… A read-first implementation has a window between the read and the insert that is exactly
|
||||
> as wide as the race it is supposed to close, and it passes every test that does not run the two
|
||||
> attempts concurrently."
|
||||
|
||||
트랜잭션 애너테이션이 없는 이유도 적혀 있다 — 커밋은 호출자의 업무 트랜잭션 안에서 일어나야 하고 `REQUIRES_NEW` 는 변경이 내구적인데 청구는 아닌 창을 다시 만든다.
|
||||
|
||||
## 6. 상태 전이
|
||||
|
||||
`IN_PROGRESS` 에서만 전이할 수 있다(`requireInProgress`). 커밋은 결과 참조가 비면 거부한다. `EnumType.STRING` 을 쓰는 이유가 javadoc 에 있다 — 서수 컬럼은 열거형에 값이 끼어들면 저장된 모든 행을 조용히 다른 값으로 만든다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
11개 테스트. 인메모리 저장소 이중이 `putIfAbsent` 로 기존 행이 있으면 `DataIntegrityViolationException` 을 던진다 — INSERT + 유니크 제약의 동작을 모사한다.
|
||||
|
||||
마지막 테스트가 마이그레이션 파일을 직접 읽어 유니크 제약 문장이 있는지 단언한다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** build-only. `JpaGrpcOperationLedger` 를 만드는 production 코드가 없다.
|
||||
|
||||
**12.2 마이그레이션 적용 경로.** `db/migration/grpc` 를 가리키는 설정이 저장소에 없다. main 설정 어디에도 `spring.flyway.locations` 가 없고(app-bootstrap 의 네 프로파일 yml 전수 확인), 그 경로를 이름으로 부르는 것은 이 리프의 테스트 한 곳뿐이다. messaging 가족이 §7.2 에서 기록한 것과 같은 형태다.
|
||||
|
||||
**12.4 드리프트.** build.gradle 주석이 서술한 네 요소(엔티티·상태 기계·포트·Spring Data 바인딩)와 마이그레이션이 전부 존재한다. 드리프트 없음.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 실제 데이터베이스로 `claim` 을 두 번 돌려 §17.1 을 재현하지 않았다. Spring Data JPA 의 `save` 계약과 이 엔티티의 식별자 형태로 판정했다.
|
||||
- 동시 청구를 실제 커넥션 둘로 재현하지 않았다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P2 — insert-first 주장이 Spring Data 의 `save` 계약과 어긋난다. 그리고 테스트 이중이 그 차이를 가린다
|
||||
|
||||
어댑터는 이렇게 쓴다.
|
||||
|
||||
```java
|
||||
try {
|
||||
repository.save(GrpcOperationLedgerEntity.claim(identity, requestFingerprint, now));
|
||||
return Optional.empty(); // 내가 이겼다
|
||||
} catch (DataIntegrityViolationException alreadyClaimed) {
|
||||
return repository.findById(identity.storageKey()).map(entity -> entity.toRecord(identity));
|
||||
}
|
||||
```
|
||||
|
||||
전제는 `save` 가 INSERT 이고, 같은 신원의 두 번째 청구가 유니크 제약을 건드린다는 것이다.
|
||||
|
||||
그러나 이 엔티티의 식별자는 **호출자가 배정한다**. `claim(...)` 팩토리가 `storageKey` 를 `identity.storageKey()` 로 채우므로 `@Id` 가 널이 아니다. Spring Data JPA 의 `SimpleJpaRepository.save` 는 식별자가 널이 아닌 엔티티를 새 것으로 보지 않고 `EntityManager.merge` 로 보낸다.
|
||||
|
||||
그리고 §3 에서 확인했듯 기본 키는 유니크 제약의 세 컬럼에서 파생된다. 같은 신원의 두 번째 청구는 **같은 행**을 겨냥한다.
|
||||
|
||||
따라서 실제 JPA 에서 일어나는 일은 이렇다.
|
||||
|
||||
1. 두 번째 청구가 `merge` 로 들어간다. 그 행은 이미 존재한다.
|
||||
2. 유니크 제약이 발화하지 않는다. 새 행을 넣는 것이 아니라 같은 행을 갱신하기 때문이다.
|
||||
3. 분리 상태의 새 엔티티가 기존 행 위에 복사된다 — `state` 는 `IN_PROGRESS`, `outcome_reference` 는 널, `completed_at` 은 널, `claimed_at` 은 지금.
|
||||
4. 예외가 없으므로 `claim` 은 `Optional.empty()` 를 돌려준다. 호출자는 자기가 청구를 소유했다고 읽는다.
|
||||
|
||||
즉 이미 커밋된 연산의 결과 참조가 지워지고, 재시도가 그 변경을 다시 실행한다. 이 모듈이 존재하는 이유로 든 바로 그 결과다.
|
||||
|
||||
세 CHECK 제약도 이것을 막지 못한다. 갱신 후 상태는 `IN_PROGRESS` + `completed_at` 널이라 전부 합법이다.
|
||||
|
||||
덧붙여 `merge` 는 즉시 flush 하지 않으므로, 서로 다른 트랜잭션의 진짜 경합에서 제약 위반이 나더라도 그것은 flush 나 커밋 시점에 도착한다 — `try` 블록 밖이다.
|
||||
|
||||
**테스트가 이것을 볼 수 없는 이유.** 인메모리 이중의 `save` 는 키가 이미 있으면 예외를 던진다.
|
||||
|
||||
```java
|
||||
GrpcOperationLedgerEntity existing = rows.putIfAbsent(entity.getStorageKey(), entity);
|
||||
if (existing != null && existing != entity) { throw new DataIntegrityViolationException(...); }
|
||||
```
|
||||
|
||||
즉 이중은 INSERT 를, 실제 저장소는 UPSERT 를 한다. build.gradle 주석이 실제 데이터스토어를 쓰지 않는 근거로 "vendor semantics 가 시험 대상일 때만 정당하다" 고 적었는데, 여기서 어긋난 것이 정확히 vendor semantics 다.
|
||||
|
||||
**등급.** 이 리프는 build-only 이고 어떤 배포도 이 어댑터를 조립하지 않는다. 그래서 오늘의 사고는 아니다. 배선하는 순간 성립한다.
|
||||
|
||||
**수정.** 셋 중 하나다.
|
||||
|
||||
- 엔티티가 `Persistable<String>` 을 구현해 `isNew()` 를 명시한다. 신규 여부를 어댑터가 안다.
|
||||
- 저장소에 `@Modifying @Query` 로 명시적 INSERT 를 두고 `save` 를 청구 경로에서 쓰지 않는다.
|
||||
- 청구를 `INSERT … ON CONFLICT DO NOTHING` 의 영향 행 수로 판정한다.
|
||||
|
||||
어느 쪽이든 테스트 이중이 아니라 실제 데이터베이스에서 두 번 청구하는 계약 테스트가 함께 필요하다.
|
||||
|
||||
### 17.2 P3 — 낙관적 잠금 컬럼이 없어 전이 가드가 메모리 안에만 있다
|
||||
|
||||
`requireInProgress()` 가 두 번째 종결 전이를 막는다. 그 가드는 한 영속성 컨텍스트 안의 인스턴스 상태에만 적용된다. 엔티티에 `@Version` 이 없으므로 두 트랜잭션이 같은 행을 읽어 각각 전이하면 나중 쓰기가 앞의 것을 덮는다.
|
||||
|
||||
DB 의 세 CHECK 제약은 행의 모양을 지키지 지 전이 순서를 지키지 않는다. `COMMITTED` 행이 다른 결과 참조로 갱신되는 것을 막는 제약이 없다.
|
||||
|
||||
청구가 배타적이라는 설계 전제 아래서는 도달성이 낮다. 다만 §17.1 을 고치면 이 전제가 실제로 성립하는지가 함께 확인되어야 한다.
|
||||
|
||||
### 17.3 P3 — `markCommitted` 는 던지고 `markFailed` 는 조용히 넘어간다
|
||||
|
||||
```java
|
||||
markCommitted → findById(...).orElseThrow(IllegalStateException…) // 청구 없으면 실패
|
||||
markFailed → findById(...).ifPresent(entity -> …) // 청구 없으면 무동작
|
||||
```
|
||||
|
||||
커밋 쪽의 근거는 자바독에 있다 — 청구 없이 커밋하면 변경은 내구적이고 보호받지 못한다.
|
||||
|
||||
실패 쪽에는 근거가 없다. 청구가 사라진 뒤 도착한 종결 실패가 아무 흔적도 남기지 않는다. 회수가 청구를 지운 뒤 원래 소유자가 실패를 기록하려는 경우가 그 형태다. 의도라면 그 이유를 자바독에 적어야 하고, 아니라면 커밋 쪽과 같게 다뤄야 한다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **유니크 제약을 애플리케이션 검사 대신 쓰기로 한 판단과 그 근거.**
|
||||
- **커밋 행이 결과를 갖는다는 규칙을 record 와 DB 양쪽에 둔 것** — 마이그레이션·백필·지원 스크립트는 record 를 지나지 않는다.
|
||||
- **`Repository` 를 확장해 네 메서드만 노출한 것.**
|
||||
- **`EnumType.STRING`** — 서수 컬럼의 조용한 재지정을 피한다.
|
||||
- **전용 Flyway 위치** — 채택하지 않은 배포에 테이블을 강요하지 않는다.
|
||||
- **트랜잭션 애너테이션을 두지 않은 것과 그 근거.**
|
||||
- **상태·완료 시각 CHECK 제약** — 종결 상태는 완료 시각을 갖는다.
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc/grpc-operation-ledger-jpa/build.gradle:1-17
|
||||
main/java/…/ledger/JpaGrpcOperationLedger.java:1-93
|
||||
main/java/…/ledger/GrpcOperationLedgerEntity.java:1-155
|
||||
main/java/…/ledger/GrpcOperationLedgerRepository.java:1-28
|
||||
main/resources/db/migration/grpc/V001__create_grpc_operation_ledger.sql:1-39
|
||||
test/java/…/ledger/GrpcOperationLedgerRepositoryTest.java:1-208
|
||||
src/grpc/grpc-core-api/…/ledger/GrpcOperationIdentity.java:36-38
|
||||
src/app-bootstrap/src/main/resources/application*.yml (flyway locations 부재 확인)
|
||||
```
|
||||
@@ -0,0 +1,389 @@
|
||||
# grpc-policy 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 62파일 4,781줄 + `src/test` 18파일 2,800줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc/grpc-policy`
|
||||
> SSOT owner: `grpc-policy`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
- vendor: `io.grpc` BOM 을 **모듈 범위**로 가져온다
|
||||
|
||||
```groovy
|
||||
// build.gradle:6-8
|
||||
// io.grpc versions are NOT managed by the Spring Boot BOM and this repo has no version catalog, so
|
||||
// the grpc-bom is imported at MODULE scope from the root `ext.grpcVersion` SSOT — the same shape
|
||||
// `adapter:inbound:grpc` uses, keeping the strict-locking blast radius local.
|
||||
```
|
||||
|
||||
| 패키지 | 파일 | 줄 | 주제 |
|
||||
|---|---:|---:|---|
|
||||
| `streaming` | 19 | 1,440 | 봉투·재개 토큰·직렬 기록기·간극 탐지·흐름 제어·수명·승인·심박 |
|
||||
| `resilience` | 11 | 809 | 재시도 설정·예산·조정자·결정·자격·소유권·소유권 검증·서비스 설정·wait-for-ready 3종 |
|
||||
| `idempotency` | 8 | 627 | 결정·인터셉터·지문·결과 재생·완료 조정·완료 판정·연산 상태·상태 질의 |
|
||||
| `deadline` | 6 | 402 | 계산기·정책 검증기·취소 조정자·취소 가능 연산·취소 사유·의존 예산 |
|
||||
| `error` | 4 | 407 | 매퍼·노출 정책·리치 상세·상태 매핑 |
|
||||
| `policy` | 4 | 314 | 적재물 경계·메시지 크기·압축 프로파일·크기 위반 |
|
||||
| `security` | 4 | 329 | TLS 프로파일·인증 프로파일·자격 세대·회전 관리자 |
|
||||
| `context` | 3 | 229 | 문맥 결속기·전파 정책·스냅숏 |
|
||||
| `validation` | 3 | 284 | 전송 검증기·위반·protovalidate 인터셉터 |
|
||||
|
||||
main 총 **62파일 / 4,781줄**.
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 62 | `FULL_READ` | 4,781줄. 패키지 9개의 전 파일 본문 |
|
||||
| `test/java/**` | 18 | `FULL_READ` | 2,800줄. 패키지 9개 전부 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 23줄 전문 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물이고 의미 없는 좌표 반복 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
> 이 표는 2026-09-01 재통독에서 다시 세었다. 이전 판의 패키지 표는 합이 47이었다 — `streaming` 을 9, `resilience` 를 7 로 적고 `security`·`validation` 에 `3+` 라고 썼다. 전 파일을 열지 않은 채 적힌 수였고, 그 표가 곧 통독이 끝나지 않았다는 증거였다. 아래 §17.6–17.8 은 나머지 15파일을 읽고 나서야 나온 것이다.
|
||||
---
|
||||
|
||||
## 1. 오류 매퍼 — 클라이언트는 메시지 문자열을 읽지 않는다
|
||||
|
||||
> "The rule it exists to hold is that a client never reads a message string. Everything a caller
|
||||
> needs to branch on is a code, an `ErrorInfo.reason`, or a typed detail; the description is for a
|
||||
> human reading a log and is replaced wholesale whenever it is not provably safe."
|
||||
|
||||
> "An unrecognised exception becomes `INTERNAL` with an opaque execution id and nothing else. The id
|
||||
> is the entire bridge between what the client saw and what the operator can find, and it is
|
||||
> generated rather than derived so that it cannot accidentally encode a key or a row id."
|
||||
|
||||
실행 식별자 공급자가 주입되는 이유도 적혀 있다 — 무작위 값을 단언하지 않고도 그 식별자가 트레일러에 닿는 것을 테스트가 확인할 수 있게 하기 위해서다.
|
||||
|
||||
## 2. 적재물 경계 — 자원이 아니라 구조의 문제
|
||||
|
||||
> "The binary rule is the one with an architectural reason behind it rather than a resource one.
|
||||
> This repository already has a file server and an object store; a method that accepts a file as
|
||||
> bytes duplicates their responsibility, loses their resumability and lifecycle, and puts the file in
|
||||
> a request that has to be buffered whole to be parsed."
|
||||
|
||||
그리고 도달할 수 없는 설정을 생성자가 거부한다 — 인라인 이진 임계값이 메시지 상한보다 크면 결코 발화하지 않는다.
|
||||
|
||||
## 3. 재개 토큰 — 서명하고, 구분자를 봉인한다
|
||||
|
||||
`GrpcResumeToken` 의 아홉 성분 각각이 왜 필요한지가 javadoc 에 있다 — 스냅숏 판본 없이는 사라진 뷰의 위치에서 재개하고, 만료 없이는 이력이 사라진 커서에서 재개하고, 필터 지문 없이는 남의 필터를 자기 위치에서 재개해 요청하지 않은 행을 받는다.
|
||||
|
||||
그리고 문자열 성분이 구분자를 담지 못하게 생성자가 거부한다.
|
||||
|
||||
```java
|
||||
if (value.indexOf('|') >= 0) {
|
||||
throw new IllegalArgumentException(what + " must not contain '|', which separates the token's fields");
|
||||
}
|
||||
```
|
||||
|
||||
`GrpcResumeTokenCodec` 의 검증이 세 성질을 지킨다 — 상수 시간 비교(`MessageDigest.isEqual`), 알 수 없는 키 식별자 거부, 세 실패의 구분 불가.
|
||||
|
||||
> "A codec that retries verification with every key it holds turns key rotation into a window in
|
||||
> which a token signed by a compromised key still verifies."
|
||||
|
||||
> "The three are deliberately indistinguishable to a caller: telling them apart is a probing oracle."
|
||||
|
||||
## 4. 재시도 예산 — 이 가족의 원자성 정본
|
||||
|
||||
```java
|
||||
public boolean tryConsume() {
|
||||
while (true) {
|
||||
long observed = tokens.get();
|
||||
if (observed < tokensPerRetry) { return false; }
|
||||
if (tokens.compareAndSet(observed, observed - tokensPerRetry)) { return true; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
같은 문제를 이 리프의 `GrpcStreamAdmission` 과 `grpc-server` 의 `GrpcAdmissionController` 는 비원자적으로 푼다(§17.1).
|
||||
|
||||
## 5. 자격증명 회전 — 준비 후 교체 후 배수
|
||||
|
||||
> "Replacing the material in place is what produces the failure this exists to avoid: every call that
|
||||
> was mid-flight when the swap happened fails with an authentication error that looks, from the
|
||||
> client, exactly like a credential that was never valid."
|
||||
|
||||
같은 세대를 다시 적용하는 것은 무동작이다 — 재시도된 회전이 첫 회전이 받아들인 호출을 취소하면 안 되기 때문이다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
18파일 2,800줄. 패키지 9개 전부에 테스트가 있고, 배치는 균등하지 않다.
|
||||
|
||||
| 패키지 | 테스트 | 줄 |
|
||||
|---|---:|---:|
|
||||
| `streaming` | 5 | 675 |
|
||||
| `resilience` | 3 | 630 |
|
||||
| `idempotency` | 3 | 397 |
|
||||
| `validation` | 1 | 240 |
|
||||
| `error` | 1 | 182 |
|
||||
| `security` | 1 | 163 |
|
||||
| `context` | 1 | 156 |
|
||||
| `deadline` | 2 | 227 |
|
||||
| `policy` | 1 | 130 |
|
||||
|
||||
`idempotency` 의 세 번째 파일은 테스트가 아니라 손으로 쓴 원장 이중 `InMemoryOperationLedger` 이고, 그 javadoc 이 자기 존재 이유를 적어 둔다.
|
||||
|
||||
> "`putIfAbsent` on a concurrent map is the in-memory equivalent of the unique constraint the real
|
||||
> adapter relies on, so the claim is atomic here for the same reason it is there. A fake that read
|
||||
> and then wrote would let the policy tests pass while the property they exist to check does not
|
||||
> hold."
|
||||
|
||||
즉 이 리프의 멱등 테스트는 원장의 원자성을 **가정**한다. 그 가정이 실제 어댑터에서 성립하는지는 `grpc-operation-ledger-jpa` §17.1 의 주제이고, 그 리프의 판정은 성립하지 않는다는 것이다. 이중이 production 보다 엄격하다.
|
||||
|
||||
이 리프는 동시성 테스트를 쓸 줄 안다. `GrpcSerializedStreamWriterTest.concurrentProducersDoNotTouchTheTransport` 는 생산자 8개로 400회를 밀어 넣고 순서·중복 없음을 단언한다. 그래서 §17.1·§17.2·§17.6 의 경합이 단일 스레드로만 시험되는 것은 능력의 한계가 아니라 선택이다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** build-only. `grpc-spring-boot-starter` 가 이 리프의 타입 중 문맥 결속기와 오류 매퍼만 빈으로 만든다. 인터셉터 둘(`ProtovalidateGrpcInterceptor`·`GrpcIdempotencyInterceptor`)과 스트림 계열 19파일 전부는 조립되지 않는다. 이 리프의 모든 판정 등급이 그래서 한 칸 낮다 — 오늘의 사고가 아니라 배선하는 날의 사고다.
|
||||
|
||||
**12.2 대조군 — 원자성 셋.** 같은 저장소 안에 세 구현이 있다.
|
||||
|
||||
| 구현 | 형태 |
|
||||
|---|---|
|
||||
| `GrpcRetryBudget.tryConsume` | 비교 후 교체 루프 — 정확 |
|
||||
| `GrpcStreamAdmission.tryAdmit` | 읽고 비교한 뒤 별도 증가 — §17.1 |
|
||||
| `GrpcAdmissionController.tryAdmit`(grpc-server) | 같은 형태 |
|
||||
|
||||
**12.3 대조군 — 배수 플래그.** 저장소 전체에서 `volatile boolean` 은 정확히 둘이다.
|
||||
|
||||
```
|
||||
grpc-client/…/GrpcChannelRuntime.java:20 private volatile boolean draining;
|
||||
grpc-admin/…/GrpcServiceHealthRegistry.java:25 private volatile boolean draining;
|
||||
```
|
||||
|
||||
같은 뜻의 세 번째 플래그가 `GrpcStreamLifecycleCoordinator.drainSignalled` 인데 여기에는 `volatile` 이 없다(§17.6). 같은 저장소가 같은 문제를 두 번은 표시하고 한 번은 표시하지 않았다.
|
||||
|
||||
**12.4 드리프트.** build.gradle 이 서술한 아홉 주제가 전부 패키지로 존재한다. 파일 수의 분포는 균등하지 않다 — `streaming` 하나가 main 의 30%(19/62)다.
|
||||
|
||||
**12.5 테스트가 볼 수 없는 것.** 세 곳에서 테스트의 형태가 결함을 구조적으로 가린다.
|
||||
|
||||
| 결함 | 가리는 형태 |
|
||||
|---|---|
|
||||
| §17.4 DROP_OLDEST 바이트 계산 | `writer(policy, messageSize)` 가 `() -> messageSize` 상수 크기 공급자를 넘긴다. 모든 메시지가 같은 크기면 잘못 뺀 값과 옳은 값이 같다 |
|
||||
| §17.1 승인 경계 경합 | `streamAdmissionBoundsTotalAndPerCaller` 가 단일 스레드다 |
|
||||
| §17.6 배수 신호 가시성 | `aDrainOutranksTheTimers` 가 `signalDrain()` 과 `terminationDue()` 를 같은 스레드에서 부른다 |
|
||||
|
||||
**12.6 설정처럼 보이지만 상수인 것.** `GrpcContextPropagationPolicy.clearAfterTask` 는 두 값을 받는 성분인데 생성자가 `false` 를 무조건 거부한다. 합법 값이 하나뿐이고, 그 값을 읽는 production 코드도 없다(§17.8).
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 어떤 인터셉터도 실제 서버에 걸어 돌리지 않았다. 배선 경로가 없다.
|
||||
- 동시 회전·동시 해제·동시 승인을 실행으로 재현하지 않았다. 원자성 분석과 JMM 으로 판정했다.
|
||||
- §17.6 의 가시성 실패를 관측하지 않았다. `volatile` 부재와 두 호출자의 스레드 소속으로 판정했다. 관측하려면 배수 스레드와 스트림 틱 스레드를 분리한 반복 시험이 필요하고, 이런 실패는 재현되지 않는 것이 정상이다.
|
||||
- §17.7 의 IPv6 누출을 실제 예외 메시지로 재현하지 않았다. 거부 목록 아홉 패턴을 전부 읽고 IPv4 점표기 외에 주소 형태를 보는 패턴이 없음을 확인해 판정했다.
|
||||
- `gradle.lockfile` 은 읽지 않았다(`STRUCTURAL_ONLY`).
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P2 — 스트림 승인의 경계가 동시성 아래에서 새고, caller별 맵이 줄지 않는다
|
||||
|
||||
```java
|
||||
AtomicInteger callerCount = perCaller.computeIfAbsent(callerFingerprint, key -> new AtomicInteger());
|
||||
if (callerCount.get() >= maxStreamsPerCaller) { return false; }
|
||||
if (openStreams.get() >= maxConcurrentStreams) { return false; }
|
||||
callerCount.incrementAndGet();
|
||||
openStreams.incrementAndGet();
|
||||
```
|
||||
|
||||
읽고 비교한 뒤 별도로 증가한다. 경계에 있는 N 개 스레드가 모두 통과한다.
|
||||
|
||||
이 클래스의 javadoc 이 서술하는 실패 상황이 곧 고동시성이다 — "a client that reconnects on every error opens streams faster than the old ones close." 재접속 폭풍에서 경계가 가장 많이 샌다.
|
||||
|
||||
`release` 도 같은 형태라 음수로 갈 수 있다.
|
||||
|
||||
그리고 `perCaller` 에서 항목이 제거되지 않는다. `computeIfAbsent` 가 호출자 지문마다 계수기를 만들고 `release` 는 값만 줄인다. 서로 다른 호출자 수만큼 맵이 자란다 — `grpc-observability` 의 `GrpcMetricCardinalityPolicy` 가 지표 태그에 대해 명시적으로 막는 것과 같은 종류의 증가이고, 여기에는 그 가드가 없다.
|
||||
|
||||
정본이 같은 리프에 있다 — `GrpcRetryBudget.tryConsume` 의 비교 후 교체 루프.
|
||||
|
||||
### 17.2 P2 — 자격증명 회전이 비교 후 교체가 아니고, 배수 완료가 진행 중인 회전을 되돌릴 수 있다
|
||||
|
||||
```java
|
||||
State observed = state.get();
|
||||
…
|
||||
state.set(new State(next, observed.current(), deadline)); // rotate
|
||||
…
|
||||
public void completeDrain() {
|
||||
State observed = state.get();
|
||||
state.set(new State(observed.current(), null, null)); // completeDrain
|
||||
}
|
||||
```
|
||||
|
||||
`AtomicReference` 를 쓰면서 두 메서드 모두 읽고 나서 조건 없이 쓴다.
|
||||
|
||||
두 결과가 다르다.
|
||||
|
||||
**회전 경합.** 두 회전이 같은 `observed` 를 읽으면 둘 다 승계 검사를 통과할 수 있고, 나중 `set` 이 앞의 것을 덮는다. 덮인 회전이 배수 대상으로 기록해 둔 세대가 상태에서 사라진다. 그 세대 위의 호출은 아무도 배수하지 않는다.
|
||||
|
||||
javadoc 이 이 상황을 이미 알고 있다 — 승계 검사의 존재 이유로 "the usual reason for one is two rotators racing" 를 든다. 검사는 있고 원자성이 없다.
|
||||
|
||||
**배수 완료의 되돌림이 더 무겁다.** `completeDrain()` 이 자기가 읽은 `observed.current()` 로 새 상태를 만든다. 읽기와 쓰기 사이에 회전이 일어나면, 그 회전이 활성화한 세대가 지워지고 **이전 세대가 다시 현재가 된다.** 즉 방금 교체된 자격증명이 되살아난다.
|
||||
|
||||
클래스의 존재 이유가 "in-flight 작업을 떨어뜨리지 않고 자격 자재를 교체하는 것" 인데, 이 경로는 교체 자체를 되돌린다.
|
||||
|
||||
수정은 두 메서드를 비교 후 교체로 바꾸는 것이다. `rotate` 는 `compareAndSet(observed, next)` 가 실패하면 다시 읽어 판정하고, `completeDrain` 은 `updateAndGet(s -> new State(s.current(), null, null))` 로 현재 값을 원자적으로 읽어 쓰면 된다. 후자는 한 줄이다.
|
||||
|
||||
같은 형태가 `grpc-client` 의 `GrpcChannelRuntimeRegistry.rotate` 에도 있다. 두 리프가 같은 자료구조를 같은 방식으로 잘못 쓴다.
|
||||
|
||||
### 17.3 P2 — 결과 재생 저장소에 제거 경로가 없다
|
||||
|
||||
```java
|
||||
private final ConcurrentMap<String, byte[]> storedOutcomes = new ConcurrentHashMap<>();
|
||||
```
|
||||
|
||||
`maxInlineBytes` 는 항목 하나의 크기를 제한하고, 개수를 제한하는 것은 없다. `remove`·`clear`·축출·만료가 전부 없다. `size()` 만 있고 그 값을 읽는 곳도 없다.
|
||||
|
||||
`store` 는 멱등 키가 필요한 메서드가 커밋될 때마다 불리므로 프로세스 수명 동안 커밋 수만큼 쌓인다.
|
||||
|
||||
javadoc 이 이 저장소를 "a small inline store" 라 부르는데 작게 유지하는 장치가 없다. 크기를 넘는 응답은 거부하면서 개수는 거부하지 않는다.
|
||||
|
||||
이웃 리프의 자매 클래스가 같은 문제를 명시적으로 다룬다 — `GrpcClientMessageDeduplicator`(advanced-streaming)의 javadoc 이 "A set grows without bound for the life of a session" 을 집합 방식을 거부한 이유로 들고, `endSession()` 으로 세션 단위 정리를 한다.
|
||||
|
||||
다만 그 자매 클래스도 절반만 지킨다. 재통독에서 확인했다 — 체크포인트 맵은 세션당 항목 하나로 유지되지만, 형제인 `replayableOutcomes` 는 적용된 메시지마다 항목을 쌓고 `endSession` 전까지 줄지 않는다. 그 리프의 §17.1 이 그것을 자기 판정으로 기록한다. 그러므로 이 자리의 대조는 "저쪽은 풀었고 이쪽은 안 풀었다" 가 아니라 **"두 리프가 같은 형태의 무제한 증가를 갖고 있고, 한쪽만 세션 경계라는 부분적 상한을 갖는다"** 이다.
|
||||
|
||||
### 17.4 P2 — 직렬 스트림 기록기의 가장 오래된 것 버리기가 잘못된 메시지의 바이트를 뺀다
|
||||
|
||||
```java
|
||||
case DROP_OLDEST -> {
|
||||
GrpcStreamEnvelope<T> dropped = queue.pollFirst();
|
||||
if (dropped != null) {
|
||||
queuedBytes = Math.max(0L, queuedBytes - nextBytes); // ← 들어오는 메시지의 크기
|
||||
droppedMessages++;
|
||||
}
|
||||
enqueue(kind, payload, snapshotVersion, resumeToken, nextBytes);
|
||||
yield GrpcStreamWriteResult.DROPPED;
|
||||
}
|
||||
```
|
||||
|
||||
버려지는 것은 꺼낸 봉투인데 빼는 값은 새 메시지의 크기다. 봉투는 크기를 성분으로 담지 않으므로 이 지점에서 버려지는 크기를 알 방법이 없다.
|
||||
|
||||
계산을 따라가면 이렇다. 한 번의 DROP_OLDEST 마다 `queuedBytes` 는 `nextBytes` 만큼 빠졌다가 `enqueue` 에서 같은 값만큼 다시 더해진다 — **순변화 0**. 그런데 큐의 실제 내용은 `nextBytes - droppedBytes` 만큼 바뀐다. 그 차이가 매 낙차마다 쌓인다.
|
||||
|
||||
방향은 둘 다 틀렸다. 들어오는 메시지가 버려지는 것보다 크면 추적값이 실제보다 **낮아져** 바이트 경계가 늦게 발화한다(메모리). 반대면 실제보다 **높아져** 경계가 이르게 발화한다(불필요한 종료·낙차). 누적 바이트는 흐름 제어 정책의 판정 입력이고, 바이트 경계의 존재 이유가 javadoc 에 있다 — 개수 경계만 있으면 메모리 한도를 가장 큰 메시지가 정한다.
|
||||
|
||||
**범위는 flush 창 하나다.** `flush()` 가 큐를 비우면서 `queuedBytes = 0L` 로 되돌리므로 오차가 flush 를 건너 누적되지는 않는다. 그래서 이것은 영구 드리프트가 아니라 한 flush 주기 안의 폭주 구간에서 바이트 경계를 잘못 판정하는 결함이다. 낙차가 일어나는 상황이 곧 소비자가 못 따라가는 상황이고, 그때 flush 간격이 가장 길어진다.
|
||||
|
||||
### 17.5 P2 — 완료 조정자가 요청 경로에서 동기화 없는 가변 리스트를 변경한다
|
||||
|
||||
```java
|
||||
private final List<PendingCase> pending = new ArrayList<>();
|
||||
…
|
||||
pending.add(new PendingCase(...)); // reconcile(...) — 요청 경로
|
||||
List.copyOf(pending); // pendingCases()
|
||||
pending.remove(resolved); // clearPending(...)
|
||||
```
|
||||
|
||||
`synchronized`·`Concurrent*`·`volatile`·`Lock` 전부 0 이고 단일 스레드 전용 표기도 없다. 같은 리프의 `GrpcSerializedStreamWriter` 는 아홉 마커로 제대로 닫혀 있어, 이 리프가 동시성을 인지하고 있음을 보여 준다.
|
||||
|
||||
`reconcile` 은 완료 결과가 불확실한 호출마다 불린다 — 장애 상황에서 동시에 몰리는 경로다. 그리고 `pending` 이 담는 것은 사람이 조정해야 하는 연산 목록이므로, 유실은 조정되지 않은 채 잊히는 연산이 된다.
|
||||
|
||||
### 17.6 P2 — 스트림 수명 조정자의 배수 신호가 스레드를 건너면서 `volatile` 이 아니다
|
||||
|
||||
```java
|
||||
private boolean drainSignalled;
|
||||
|
||||
public void signalDrain() { drainSignalled = true; }
|
||||
|
||||
public Optional<GrpcStreamTerminationReason> terminationDue(Instant now, Instant credentialExpiry) {
|
||||
…
|
||||
if (drainSignalled) { return Optional.of(GrpcStreamTerminationReason.SERVER_DRAIN); }
|
||||
```
|
||||
|
||||
두 메서드의 호출자가 다른 스레드다. `signalDrain()` 은 서버가 내려갈 때 종료 훅이 부르고, `terminationDue(...)` 는 스트림 자신의 틱에서 불린다 — 클래스 javadoc 이 검사 순서를 "then drain, because a server that has been told to stop should stop before its own timers fire" 로 규정한 그 틱이다.
|
||||
|
||||
평범한 `boolean` 이고 `volatile`·`synchronized`·`AtomicBoolean` 어느 것도 없다. 자바 메모리 모델 아래서 틱 스레드가 이 쓰기를 관측할 보장이 없다. 관측하지 못하면 스트림은 배수 명령을 받고도 계속 돌고, 최대 수명(기본 1시간)이 차야 끝난다.
|
||||
|
||||
같은 저장소가 같은 뜻의 플래그를 두 번은 `volatile` 로 적었다(§12.3). 세 번째만 빠졌다.
|
||||
|
||||
수정은 `volatile boolean` 한 단어다. `heartbeat` 의 `lastActivity` 는 같은 문제가 아니다 — 스트림 틱 스레드만 만진다.
|
||||
|
||||
### 17.7 P3 — 오류 노출 거부 목록의 "호스트와 포트" 규칙이 IPv4 점표기만 본다
|
||||
|
||||
```java
|
||||
Pattern.compile("\\b\\d{1,3}(\\.\\d{1,3}){3}(:\\d{1,5})?\\b"),
|
||||
```
|
||||
|
||||
아홉 패턴을 전부 읽으면 주소 형태를 보는 것은 이 하나다. 클래스 javadoc 은 거부 대상을 "a stack frame, a SQL fragment, a JDBC URL, a bearer token, **a host and port**, a file path" 로 서술하는데, 실제로 걸리는 host 는 IPv4 점표기뿐이다.
|
||||
|
||||
통과하는 것들:
|
||||
|
||||
- IPv6 리터럴 — `fe80::1`, `[2001:db8::1]:5432`
|
||||
- DNS 이름과 포트 — `documents-db.internal:5432`, `kafka-0.kafka-headless:9092`
|
||||
|
||||
`jdbc:postgresql://db/app` 이 막히는 것은 host 규칙이 아니라 `jdbc:` 규칙 때문이다. 즉 이 구멍은 테스트에도 없다 — `exposurePolicyRefusesLeakyStrings` 의 아홉 사례 중 주소는 `upstream 10.0.3.14:5432 refused` 하나이고 IPv4 다.
|
||||
|
||||
닿는 경로는 `mapUnknown` 이다. 인식되지 않은 예외의 메시지를 `safeToExpose` 가 통과시키면 그대로 클라이언트로 간다. IPv6 클러스터나 쿠버네티스 서비스 이름을 쓰는 배포에서 상류 좌표가 밖으로 나간다.
|
||||
|
||||
등급이 P3 인 이유는 두 가지다. 이 리프가 build-only 라 오늘 닿지 않고, 노출되는 것이 자격증명이 아니라 내부 좌표다. 다만 이 정책이 존재하는 이유 자체가 "부분 마스킹이 아니라 통째 교체" 이므로, 목록에 빠진 형태는 통째로 통과한다.
|
||||
|
||||
### 17.8 P3 — `clearAfterTask` 는 합법 값이 하나뿐인 성분이고, 아무도 읽지 않는다
|
||||
|
||||
```java
|
||||
public record GrpcContextPropagationPolicy(
|
||||
boolean failClosedWithoutContext, boolean clearAfterTask) {
|
||||
…
|
||||
public GrpcContextPropagationPolicy {
|
||||
if (!clearAfterTask) { throw new IllegalArgumentException("context must be cleared after every task; …"); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`false` 를 무조건 거부하므로 이 성분이 가질 수 있는 값은 `true` 하나다. 그리고 저장소 전체에서 `clearAfterTask()` 를 읽는 production 코드가 없다 — 호출처는 이 생성자의 가드와 테스트의 단언 한 줄뿐이다.
|
||||
|
||||
읽지 않아도 되는 이유는 `GrpcContextBinder` 가 옳게 쓰였기 때문이다. `runWith`·`callWith`·`wrap` 이 전부 `finally` 에서 detach 한다. 불변식이 이미 구조로 지켜진다.
|
||||
|
||||
그래서 이 성분은 설정처럼 보이지만 설정이 아니다. 읽는 사람은 정책으로 끌 수 있는 것이라고 읽고, 테스트는 그 가드를 시험한다.
|
||||
|
||||
수정은 성분을 지우고 javadoc 에 "always cleared" 를 남기는 것이다. 그러면 `backgroundWork()`·`stable()` 이 인자 하나가 되고, 불변식은 검증이 아니라 구조가 된다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **클라이언트가 메시지 문자열을 읽지 않는다는 규칙과, 인식되지 않은 예외의 불투명 처리.**
|
||||
- **실행 식별자를 파생이 아니라 생성으로 만든 것** — 키나 행 식별자를 우연히 담을 수 없다.
|
||||
- **적재물 경계의 근거를 자원이 아니라 책임 중복으로 든 것.**
|
||||
- **도달할 수 없는 임계값 설정을 생성자가 거부한 것.**
|
||||
- **재개 토큰의 아홉 성분 각각에 이유를 붙인 것.**
|
||||
- **토큰 문자열 성분이 구분자를 담지 못하게 한 것** — 같은 저장소의 web 지문이 이 프레이밍을 하지 않는 것과 대비된다.
|
||||
- **토큰 검증의 상수 시간 비교·키 식별자 거부·실패 구분 불가.**
|
||||
- **재시도 예산의 비교 후 교체 루프.**
|
||||
- **자격증명 회전의 준비-교체-배수 순서와 같은 세대 재적용의 무동작 처리.**
|
||||
- **BOM 을 모듈 범위로 가져와 잠금 파급을 지역화한 것.**
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc/grpc-policy/build.gradle:1-23
|
||||
main/java/…/context/{GrpcContextBinder:1-122, GrpcContextPropagationPolicy:1-37, GrpcContextSnapshot:1-70}
|
||||
main/java/…/deadline/{GrpcCancellableOperation:1-23, GrpcCancellationCoordinator:1-122, GrpcCancellationReason:1-48,
|
||||
GrpcDeadlineCalculator:1-68, GrpcDeadlinePolicyValidator:1-93, GrpcDependencyBudget:1-48}
|
||||
main/java/…/error/{GrpcErrorExposurePolicy:1-82, GrpcErrorMapper:1-179, GrpcRichErrorDetail:1-93, GrpcStatusMapping:1-53}
|
||||
main/java/…/idempotency/{GrpcCompletionReconciler:1-93, GrpcCompletionResolution:1-52, GrpcIdempotencyDecision:1-95,
|
||||
GrpcIdempotencyInterceptor:1-148, GrpcOperationStatus:1-32, GrpcOperationStatusQuery:1-90,
|
||||
GrpcOutcomeReplay:1-62, GrpcRequestFingerprint:1-55}
|
||||
main/java/…/policy/{GrpcCompressionProfile:1-50, GrpcMessageSizeProfile:1-57, GrpcPayloadBoundaryPolicy:1-161,
|
||||
GrpcSizeViolation:1-46}
|
||||
main/java/…/resilience/{GrpcMethodRetryConfig:1-99, GrpcRetryBudget:1-76, GrpcRetryCoordinator:1-126,
|
||||
GrpcRetryDecision:1-64, GrpcRetryEligibility:1-103, GrpcRetryOwner:1-39,
|
||||
GrpcRetryOwnershipValidator:1-79, GrpcServiceConfigPolicy:1-73,
|
||||
GrpcWaitForReadyDecision:1-40, GrpcWaitForReadyProfile:1-54, GrpcWaitForReadyValidator:1-56}
|
||||
main/java/…/security/{GrpcAuthenticationProfile:1-59, GrpcCredentialGeneration:1-47,
|
||||
GrpcCredentialRotationManager:1-122, GrpcTlsProfile:1-101}
|
||||
main/java/…/streaming/{GrpcFlowControlDecision:1-43, GrpcFlowControlPolicy:1-88, GrpcResumeDecision:1-57,
|
||||
GrpcResumeToken:1-91, GrpcResumeTokenCodec:1-142, GrpcSerializedStreamWriter:1-176,
|
||||
GrpcSlowConsumerPolicy:1-26, GrpcStreamAdmission:1-76, GrpcStreamEnvelope:1-128,
|
||||
GrpcStreamGapDetector:1-104, GrpcStreamHeartbeat:1-62, GrpcStreamId:1-41,
|
||||
GrpcStreamLifecycleCoordinator:1-82, GrpcStreamLifetimePolicy:1-71, GrpcStreamProfile:1-47,
|
||||
GrpcStreamSequence:1-48, GrpcStreamTerminationReason:1-51, GrpcStreamWriteResult:1-25,
|
||||
GrpcStreamWriterState:1-22}
|
||||
main/java/…/validation/{GrpcTransportValidator:1-134, GrpcValidationViolation:1-35, ProtovalidateGrpcInterceptor:1-115}
|
||||
test/java/…/{context:1, deadline:2, error:1, idempotency:3, policy:1, resilience:3, security:1, streaming:5, validation:1} — 18파일 2,800줄
|
||||
grpc-client/…/GrpcChannelRuntime.java:20 (§12.3 대조)
|
||||
grpc-admin/…/GrpcServiceHealthRegistry.java:25 (§12.3 대조)
|
||||
```
|
||||
@@ -0,0 +1,310 @@
|
||||
# grpc-proto-contract 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 — `src/main` production 3파일 605줄, 스키마/설정 리소스 5개, test 1파일 324줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc/grpc-proto-contract`
|
||||
> SSOT owner: `grpc-proto-contract`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- `allowed_dependencies`: `["grpc-core-api"]`
|
||||
- `runtime_memberships`: **`[]`**
|
||||
|
||||
| 파일 | LOC | 성격 |
|
||||
|---|---:|---|
|
||||
| `GrpcProtoContractValidator` | 447 | 라인 스캐너 + 9개 규칙 판정 |
|
||||
| `GrpcProtoStyleManifest` | 120 | 규칙을 데이터로 둔 record |
|
||||
| `GrpcProtoRuleViolation` | 38 | 위반 1건 record |
|
||||
| **main java 합계** | **605** | |
|
||||
| `error.proto` | 66 | 리치 오류 상세 5 메시지 + 열거형 1 |
|
||||
| `stream.proto` | 57 | 스트림 공통 스키마 |
|
||||
| `buf.yaml` · `buf.gen.yaml` · `buf.lock` | — | 생성 설정(이 리프는 protoc 을 돌리지 않는다) |
|
||||
| `GrpcProtoContractValidatorTest` | 324 | 테스트 |
|
||||
| `build.gradle` | 12 | 의존 project 1 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 3 | `FULL_READ` | 447+120+38 전 본문 축자 확인 |
|
||||
| `main/resources/proto/**/*.proto` | 2 | `FULL_READ` | 66+57 전문 |
|
||||
| `main/resources/proto/buf.*` | 3 | `FULL_READ` | 테스트가 단언하는 키 전수 확인 |
|
||||
| `test/java/**` | 1 | `FULL_READ` | 324줄 · 테스트 13개 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 12줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일; 선언 의존은 project 1 뿐임을 build.gradle 에서 확인 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-9
|
||||
// Schema source of truth: the `.proto` files plus the rule engine that judges them.
|
||||
// No protobuf plugin and no protoc invocation here — … running protoc is a separate, gated decision.
|
||||
```
|
||||
|
||||
이 리프는 스키마 원본과 그것을 판정하는 규칙 엔진을 함께 담는다. protoc 은 돌지 않는다.
|
||||
|
||||
검증기 javadoc 이 그 한계를 스스로 규정한다.
|
||||
|
||||
> "A line scanner, not a Protobuf parser, and that is a deliberate limit rather than a shortcut.
|
||||
> Everything this checks is a property of the source text a reviewer reads… Semantics that need a
|
||||
> compiled descriptor belong to `grpc-codegen`'s descriptor artifact."
|
||||
|
||||
## 2. 규칙 9개
|
||||
|
||||
| 상수 | 판정 |
|
||||
|---|---|
|
||||
| `PROTO3_SYNTAX` | `syntax = "proto3"` 필수 |
|
||||
| `PACKAGE_VERSIONED` | `<org>.<domain>.v<major>` — 접두 일치 + `.*\.v[1-9]\d*$` |
|
||||
| `JAVA_MULTIPLE_FILES` | `option java_multiple_files = true` 필수 |
|
||||
| `JAVA_PACKAGE_SEPARATE` | `java_package` 가 손으로 쓴 패키지 안이면 위반 |
|
||||
| `ENUM_ZERO_UNSPECIFIED` | 0 값 이름이 `_UNSPECIFIED` 로 끝나야 함 |
|
||||
| `RESERVED_HISTORY` | 삭제 이력의 번호·이름이 `reserved` 에 있어야 함 |
|
||||
| `WELL_KNOWN_TYPE_ALLOWLIST` | `google/protobuf/` import 는 allowlist 에만 |
|
||||
| `MAP_ALLOWLIST` | `map` 필드는 `message.field` 단위 허용 |
|
||||
| `EXPLICIT_PRESENCE` | 매니페스트가 지정한 필드는 `optional` 선언 필수 |
|
||||
|
||||
## 3. 세 가지 설계 판단
|
||||
|
||||
### 3.1 금지가 아니라 allowlist
|
||||
|
||||
`GrpcProtoStyleManifest` javadoc:
|
||||
|
||||
> "Three of the five fields are allowlists, and that shape is the decision: `Any`, `Struct` and
|
||||
> `map` are not banned, they are things you have to ask for by name. A ban gets worked around; an
|
||||
> allowlist entry gets read by the next person to open the manifest and carries the field it was
|
||||
> granted for."
|
||||
|
||||
`caSkeleton()` 의 기본값은 조직 `hyeonworks`, 손으로 쓴 패키지 `dev.caskeleton`, WKT allowlist 5개(timestamp·duration·field_mask·empty·wrappers), map allowlist 빈 집합, presence 요구 빈 맵이다.
|
||||
|
||||
`allowingWellKnownTypes` · `allowingMapFields` · `requiringPresence` 세 메서드가 매니페스트를 넓힌 사본을 만든다. 정규 생성자가 모든 컬렉션을 복사하므로 리뷰를 통과한 매니페스트가 나중에 넓어지지 않는다.
|
||||
|
||||
### 3.2 던지지 않고 목록으로 돌려준다
|
||||
|
||||
`GrpcProtoRuleViolation` javadoc:
|
||||
|
||||
> "Returned rather than thrown, and carrying a line number, because a schema review is a list. A
|
||||
> validator that throws on the first violation turns 'this file breaks four rules' into four
|
||||
> separate runs, and the author fixes them one at a time without ever seeing the shape of the
|
||||
> problem."
|
||||
|
||||
### 3.3 삭제 이력은 추론하지 않고 입력으로 받는다
|
||||
|
||||
> "a field that is simply gone from the current source is indistinguishable from one that never
|
||||
> existed. Recording removals and checking them against `reserved` is the only way the 'do not reuse
|
||||
> a field number' rule survives the commit that deletes the field."
|
||||
|
||||
`SchemaHistory(removedFieldNumbers, removedFieldNames)` 가 그 입력이고, 키는 파일에 적힌 메시지 이름이며 중첩은 점으로 한정한다.
|
||||
|
||||
## 4. 스캔 절차
|
||||
|
||||
한 줄씩 읽으면서 `//` 이후를 지우고, 빈 줄을 건너뛰고, 순서대로 시도한다 — syntax → package → import → option(파일 수준만) → 스코프 열기 → 닫기 → 스코프 안 멤버.
|
||||
|
||||
스코프는 `message`·`enum`·`service`·`oneof` 넷을 열고 이름을 점으로 한정해 스택에 쌓는다. 열거형 안에서는 0 값 이름을, 메시지와 `oneof` 안에서는 `reserved`·`map`·필드를 본다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
`GrpcProtoContractValidatorTest` 324줄. 첫 두 테스트가 이 리프의 게이트다.
|
||||
|
||||
- `committedSchemaIsCompliant` — 커밋된 두 스키마를 실제로 검증기에 넣어 위반 0 을 단언한다.
|
||||
- `theBufConfigurationAgreesWithTheValidator` — `buf.yaml` 의 `FILE`·`STANDARD` 범주, 생성 경로가 `build/generated/...` 이고 `out: src/` 가 아님, 버전 리터럴 부재, `deps: []` 를 단언한다.
|
||||
|
||||
나머지는 규칙별 거부 사례다 — proto2 거부, 패키지 형식, 자바 패키지 충돌, `java_multiple_files` 부재, 열거형 0 값, 삭제 이력, WKT allowlist, map allowlist, explicit presence, 중첩 한정, `describe()` 렌더링. 전부 13개.
|
||||
|
||||
거부 사례 셋은 **넓힌 매니페스트로 같은 소스를 다시 돌려** 통과까지 확인한다 — `allowingWellKnownTypes` · `allowingMapFields` · `requiringPresence`. allowlist 라는 설계가 실제로 넓혀지는지까지 붙드는 형태다.
|
||||
|
||||
`enumZeroValueNeedsTheUnspecifiedSuffix` 는 줄 번호 7까지 단언한다 — 위반이 줄을 정확히 가리키는지가 이 리프의 산출물 형태(`file:line rule — detail`)에 직결되기 때문이다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** 이 리프의 production 소비자는 0 이다. `GrpcProtoContractValidator`·`GrpcProtoStyleManifest`·`GrpcProtoRuleViolation` 을 부르는 코드는 자기 테스트뿐이다.
|
||||
|
||||
리프 밖 참조는 두 종류다.
|
||||
|
||||
| 참조 | 형태 |
|
||||
|---|---|
|
||||
| `grpc-codegen/…/GrpcBufPolicy.java:10` | javadoc 언급 |
|
||||
| `grpc-testkit` · `grpc-codegen` · `grpc-spring-boot-starter` · `grpc-advanced-edition` 의 build.gradle | project 의존 선언 |
|
||||
|
||||
네 모듈이 의존을 선언하지만 그중 어느 자바 파일도 이 리프의 타입을 import 하지 않는다. §17.4 가 그 결과를 다룬다.
|
||||
|
||||
**12.3 아홉 규칙 중 둘은 이 저장소의 매니페스트에서 사실상 비활성이다.**
|
||||
|
||||
```java
|
||||
public static GrpcProtoStyleManifest caSkeleton() {
|
||||
return new GrpcProtoStyleManifest("hyeonworks", Set.of("dev.caskeleton"),
|
||||
ALWAYS_ALLOWED_WELL_KNOWN_TYPES, Set.of(), Map.of());
|
||||
// ^^^^^^^^ ^^^^^^^
|
||||
// mapFieldAllowlist presenceRequiredFields
|
||||
}
|
||||
```
|
||||
|
||||
- `MAP_ALLOWLIST` — 허용 목록이 비었으므로 실제 판정은 "map 전면 금지"다. §3.1 이 설명하는 "금지가 아니라 이름으로 요청" 이라는 형태는 매니페스트를 넓히는 호출자가 있어야 성립하는데, `allowingMapFields` 를 부르는 곳은 테스트뿐이다.
|
||||
- `EXPLICIT_PRESENCE` — 요구 맵이 비었으므로 어떤 필드도 `optional` 을 강제받지 않는다. 커밋된 두 스키마가 `optional` 을 다섯 곳에 쓰지만(예: `FieldViolation.description`, `StreamEnvelope.resume_token`) 그것을 요구하는 규칙은 없다. 규율이 코드가 아니라 저자의 손에 있다.
|
||||
|
||||
나머지 일곱은 기본 매니페스트에서도 실제로 판정한다.
|
||||
|
||||
**12.2 대조군.** 저장소에 `.proto` 파일이 넷 있다.
|
||||
|
||||
| 파일 | 이 검증기가 판정하는가 |
|
||||
|---|---|
|
||||
| `grpc-proto-contract/.../common/v1/error.proto` | 예 (테스트 목록) |
|
||||
| `grpc-proto-contract/.../common/v1/stream.proto` | 예 (테스트 목록) |
|
||||
| `messaging-schema-protobuf/src/test/proto/order_created_v1.proto` | 아니오 |
|
||||
| `grpc-advanced-edition/.../edition2024/compatibility.proto` | 아니오 |
|
||||
|
||||
**12.4 드리프트.** build.gradle 주석이 규칙으로 든 다섯(proto3 + explicit optional, 패키지 버전, reserved 이력, 열거형 0 접미, WKT allowlist)이 전부 상수로 존재한다. 드리프트 없음.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- `reserved` 범위 문법의 오탐(§17.1)을 실행으로 재현하지 않았다. 정규식과 수집 코드로 판정했다.
|
||||
- 블록 주석(`/* */`) 안의 선언이 스캔되는지 실행으로 확인하지 않았다. `LINE_COMMENT` 가 `//` 만 지우므로 그 형태가 남는다.
|
||||
- 테스트를 실행하지 않았다. 13개 전부 본문으로만 확인했다.
|
||||
- §17.4 의 "부르는 빌드가 없다"는 `*.gradle` · `*.kts` · `*.yml` 세 확장자와 자바 타입 이름 grep 으로 판정했다. 리플렉션이나 서비스 로더로 부르는 형태라면 잡히지 않는다.
|
||||
- 열거형 `reserved` 오탐(§17.5)을 실행으로 재현하지 않았다. 스코프 분기 코드로 판정했다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P3 — `reserved 2 to 5;` 범위가 개별 숫자로만 수집되어 `RESERVED_HISTORY` 오탐이 된다
|
||||
|
||||
```java
|
||||
RESERVED_NUMBERS = Pattern.compile("^\\s*reserved\\s+([^\";]*\\d[^\";]*);");
|
||||
NUMBER = Pattern.compile("\\d+");
|
||||
…
|
||||
Matcher number = NUMBER.matcher(reservedNumbers.group(1));
|
||||
while (number.find()) { scan.reservedNumbers…add(Integer.valueOf(number.group())); }
|
||||
```
|
||||
|
||||
`reserved 2 to 5;` 는 그룹이 `"2 to 5"` 이고 수집되는 것은 `{2, 5}` 다. `3`·`4` 는 들어가지 않는다. `reserved 9 to max;` 는 `{9}` 만 남는다.
|
||||
|
||||
그러면 삭제 이력이 `3` 을 담고 스키마가 `reserved 2 to 5;` 로 정확히 예약했는데도 `RESERVED_HISTORY` 위반이 보고된다. 범위 예약은 표준 문법이고 여러 필드를 한 번에 지울 때 쓰는 형태이므로 도달 가능하다.
|
||||
|
||||
수정은 `to` 를 인식해 범위를 펼치는 것이다. `max` 는 상한 상수로 다루거나 그 메시지에 대해 검사를 통과시킨다.
|
||||
|
||||
### 17.2 P3 — 반환 목록이 자바독이 약속한 source order 가 아니다
|
||||
|
||||
```java
|
||||
Scan scan = scan(fileName, source, violations); // import·enum·map·presence 위반이 여기서 append
|
||||
checkFileHeader(fileName, scan, violations); // syntax·package·java_* 위반이 그 뒤에 append
|
||||
checkRemovalHistory(fileName, scan, history, violations);
|
||||
```
|
||||
|
||||
`validate` 의 javadoc 은 "@return every violation found, **in source order**" 라고 적는다. 실제로는 파일 앞머리의 `syntax`·`package` 위반이 40번째 줄의 `map` 위반보다 뒤에 온다.
|
||||
|
||||
`describe()` 가 `file:line rule — detail` 형태를 만들고 그 형태의 목적이 빌드 로그를 읽는 것이므로, 정렬이 어긋나면 리뷰 목록으로서의 값이 줄어든다. 수정은 반환 직전에 `line` 으로 안정 정렬하는 것이다.
|
||||
|
||||
### 17.3 P3 — 커밋 스키마 게이트가 파일 목록을 하드코딩한다
|
||||
|
||||
```java
|
||||
List<String> files = List.of(
|
||||
"proto/hyeonworks/grpc/common/v1/error.proto",
|
||||
"proto/hyeonworks/grpc/common/v1/stream.proto");
|
||||
```
|
||||
|
||||
리소스 디렉터리를 훑지 않는다. 이 리프에 세 번째 `.proto` 를 추가하면 이 테스트를 함께 고치기 전까지 판정되지 않고, 빌드는 초록으로 남는다.
|
||||
|
||||
같은 저장소가 다른 곳에서 이 형태를 이미 경계했다 — 빠뜨림이 통과가 되는 게이트다. 수정은 `proto/**` 아래 `.proto` 를 전부 열거해 돌리는 것이다.
|
||||
|
||||
### 기록 — `oneof` 도 스코프 이름을 밀어 넣는다 (현재 무해)
|
||||
|
||||
`SCOPE_OPEN` 이 `oneof` 를 스코프로 열고 이름을 점으로 한정한다. 그러면 `message Foo { oneof kind { … } }` 안의 필드는 `Foo.kind.<field>` 로 한정되고, `SchemaHistory` javadoc 이 말하는 키 규약(메시지 이름)과 어긋난다.
|
||||
|
||||
지금은 도달하지 않는다. protobuf 가 `oneof` 안에서 `map` 과 `optional` 을 모두 금지하므로 `MAP_ALLOWLIST`·`EXPLICIT_PRESENCE` 판정이 그 자리에서 발생하지 않고, `reserved` 도 `oneof` 안에 올 수 없다. 규칙을 넓힐 때 다시 볼 자리로 남긴다.
|
||||
|
||||
### 17.4 P2 — 두 파일이 이 검증기를 "빌드를 실패시키는 것" 이라고 단언하는데, 어떤 빌드도 그것을 부르지 않는다
|
||||
|
||||
같은 주장이 두 곳에 있다.
|
||||
|
||||
```java
|
||||
// grpc-codegen/…/GrpcBufPolicy.java:8-10
|
||||
* Buf's CLI is not part of this toolchain (adaptation D5), so the four lifecycle task names
|
||||
* below are the contract a CI environment fulfils and {@code GrpcProtoContractValidator} is what
|
||||
* actually fails a build here.
|
||||
```
|
||||
|
||||
```yaml
|
||||
# grpc-proto-contract/…/proto/buf.yaml:3-5
|
||||
# The rules named here are also implemented in GrpcProtoContractValidator, which is what actually
|
||||
# fails this repository's build: the Buf CLI is not part of this toolchain, and a gate that silently
|
||||
# passes when a binary is missing is worse than one that computes the same judgement from the
|
||||
# committed schema.
|
||||
```
|
||||
|
||||
두 문장이 같은 논증을 편다 — CLI 가 없으므로 이 자바 검증기가 그 자리를 대신한다는 것. 그런데 그 검증기를 부르는 빌드 코드가 없다.
|
||||
|
||||
```
|
||||
$ grep -rn "GrpcProtoContractValidator" --include=*.gradle --include=*.kts --include=*.yml .
|
||||
(매치 없음)
|
||||
$ grep -rn "GrpcProtoContractValidator" --include=*.java src/ | grep -v grpc-proto-contract/
|
||||
grpc-codegen/…/GrpcBufPolicy.java:10: * … {@code GrpcProtoContractValidator} is what
|
||||
```
|
||||
|
||||
Gradle 태스크도, 검증 훅도, 다른 모듈의 호출도 없다. 실제로 이 규칙 아홉 개를 실행하는 것은 `GrpcProtoContractValidatorTest` 하나이고, 그 테스트가 판정하는 대상은 §17.3 이 지적한 대로 **하드코딩된 두 파일**이다.
|
||||
|
||||
**그래서 지금 성립하는 것과 성립하지 않는 것.**
|
||||
|
||||
- 성립: 이 리프에 커밋된 `error.proto` · `stream.proto` 는 매 빌드마다 아홉 규칙에 걸린다(테스트가 그것을 돌린다).
|
||||
- 성립하지 않음: "이 저장소의 빌드를 실패시킨다"는 범위. 리프 밖의 `.proto` 는 판정되지 않고(§12.2), 이 리프에 새로 추가되는 `.proto` 도 테스트 목록에 손으로 넣기 전까지 판정되지 않는다.
|
||||
|
||||
**왜 P2 인가.** 오작동이 아니라 **주장과 배선의 불일치**다. 그리고 그 주장이 CLI 부재를 정당화하는 논거로 쓰이고 있다 — "바이너리가 없을 때 조용히 통과하는 게이트보다 낫다"고 말하면서, 실제로 만든 것도 조용히 통과하는 게이트다. `grpc-codegen` §17.1 이 같은 형태를 반대편에서 기록했다(Buf 태스크 이름 넷이 어떤 빌드 파일에도 없다). 두 리프가 서로를 가리키며 상대가 게이트라고 말하는 모양이다.
|
||||
|
||||
**수정.** 두 가지 중 하나다.
|
||||
|
||||
1. 배선한다 — `check` 에 물리는 Gradle 태스크가 `proto/**` 를 훑어 `validate` 를 돌리고 위반이 있으면 실패한다. §17.3 의 하드코딩도 함께 해소된다.
|
||||
2. 문장을 사실에 맞춘다 — "빌드를 실패시킨다"를 "이 리프의 테스트가 커밋된 스키마에 대해 실행한다"로 낮춘다. buf.yaml 과 GrpcBufPolicy 두 곳을 함께 고쳐야 한다.
|
||||
|
||||
낮추는 쪽을 고르더라도 §12.3 이 남는다 — 규칙 아홉 중 둘은 기본 매니페스트에서 판정할 것이 없다.
|
||||
|
||||
### 17.5 P3 — 열거형 안의 `reserved` 는 수집되지 않는다
|
||||
|
||||
`scan` 은 스코프 종류로 갈라진다.
|
||||
|
||||
```java
|
||||
if ("enum".equals(scopeKind)) {
|
||||
scanEnumValue(fileName, line, lineNumber, scopeName, violations);
|
||||
} else if ("message".equals(scopeKind) || "oneof".equals(scopeKind)) {
|
||||
scanMessageMember(fileName, line, lineNumber, scopeName, scan, violations);
|
||||
}
|
||||
```
|
||||
|
||||
`reserved` 수집은 `scanMessageMember` 안에만 있다. proto3 는 열거형에도 `reserved 2, 15;` 와 `reserved "OLD_VALUE";` 를 허용하고, 열거형 값을 지울 때 번호를 예약하는 것은 필드와 같은 이유로 필요하다 — 예약하지 않고 재사용하면 옛 클라이언트가 보낸 정수가 다른 뜻으로 해석된다.
|
||||
|
||||
지금 `SchemaHistory` 에 열거형 이름으로 삭제 이력을 넣으면, 스키마가 정확히 예약했더라도 `scan.reservedNumbers` 에 그 이름이 없으므로 `RESERVED_HISTORY` 오탐이 난다. §17.1 의 범위 문법 문제와 같은 방향(fail-closed)이고 같은 자리에서 고칠 수 있다.
|
||||
|
||||
**수정.** `reserved` 수집을 스코프 종류와 무관하게 먼저 시도한 뒤 나머지 판정을 갈래로 보낸다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **라인 스캐너라는 한계를 스스로 규정하고 그 경계 밖을 다른 리프로 넘긴 것.**
|
||||
- **금지 대신 allowlist 를 고르고 그 이유를 적은 것** — 금지는 우회되고 allowlist 항목은 다음 사람이 읽는다.
|
||||
- **위반을 던지지 않고 목록으로 돌려주는 것** — 스키마 리뷰는 목록이다.
|
||||
- **삭제 이력을 입력으로 받는 것** — 사라진 필드는 없던 필드와 구분되지 않으므로 추론할 수 없다.
|
||||
- **커밋된 스키마 자체를 테스트가 검증기에 넣는 것** — 규칙이 자기 스키마에 실제로 적용된다.
|
||||
- **buf 설정과 검증기가 같은 것을 말하는지 테스트가 붙드는 것.**
|
||||
- **넓힌 매니페스트로 같은 소스를 다시 돌려 통과까지 확인하는 테스트 형태** — allowlist 라는 설계가 거부만이 아니라 허용도 실제로 하는지 붙든다.
|
||||
- **`buf.lock` 을 빈 채로 커밋한 것과 그 근거** — "adding a first dependency is a visible diff in a file that already exists, instead of a new file nobody reviews."
|
||||
- **`buf.gen.yaml` 에 판본 리터럴을 두지 않은 것** — 관리 플랫폼이 플러그인 판본을 소유한다는 `GrpcCodegenManifest` 의 규칙과 같은 결정이고, 테스트가 `version: v1` 부재로 그것을 붙든다.
|
||||
- **리치 오류 상세를 `google.rpc.*` 대신 자기 메시지로 소유한 것과 그 근거** — "the Stable contract is that a client branches on a code, a reason and a typed detail — never on a message string", 그리고 모양을 `google.rpc` 에 맞춰 두어 나중의 이전이 재설계가 아니라 이름 바꾸기가 되게 한 것.
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc/grpc-proto-contract/build.gradle:1-12
|
||||
main/java/…/contract/GrpcProtoContractValidator.java:1-447
|
||||
main/java/…/contract/GrpcProtoStyleManifest.java:1-120
|
||||
main/java/…/contract/GrpcProtoRuleViolation.java:1-38
|
||||
main/resources/proto/hyeonworks/grpc/common/v1/error.proto:1-66
|
||||
main/resources/proto/hyeonworks/grpc/common/v1/stream.proto:1-57
|
||||
main/resources/proto/buf.yaml:1-17 · buf.gen.yaml:1-24 · buf.lock:1-12
|
||||
grpc/grpc-codegen/…/GrpcBufPolicy.java:8-10 (게이트라고 주장하는 두 자리 중 하나)
|
||||
test/java/…/contract/GrpcProtoContractValidatorTest.java:1-324
|
||||
```
|
||||
@@ -0,0 +1,277 @@
|
||||
# grpc-server 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 — `src/main` production 17파일 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc/grpc-server`
|
||||
> SSOT owner: `grpc-server`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
- vendor: `grpc-api`(BOM). Netty 의존 없음 — 프로파일은 설정 모델이지 배선이 아니다
|
||||
|
||||
| 파일 | LOC |
|
||||
|---|---:|
|
||||
| `GrpcServerInterceptorChain` | 110 |
|
||||
| `GrpcRawApiImportRule` | 108 |
|
||||
| `GrpcAdmissionController` | 103 |
|
||||
| `GrpcServiceAdapter` · `GrpcApplicationBoundaryRules` | 92 · 92 |
|
||||
| `GrpcServerInterceptorOrder` | 90 |
|
||||
| `GrpcServerProfile` · `GrpcNettyParityContract` · `GrpcExecutorProfile` | 84 · 69 · 66 |
|
||||
| `GrpcNettyVariantSelector` · `GrpcServerInterceptorStage` · `GrpcServiceAdapterDescriptor` | 52 · 51 · 48 |
|
||||
| `GrpcServerTransport` · `GrpcNettyVariant` · `GrpcApplicationInvocation` · `GrpcServiceAdapterMarker` · `GrpcResponseMapper` | 38 · 32 · 28 · 25 · 18 |
|
||||
| test 5파일 | 730 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 17 | `FULL_READ` | 1,106줄 전 본문 |
|
||||
| `test/java/**` | 5 | `FULL_READ` | 730줄 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 전문 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-6
|
||||
// Server boundary: the ArchUnit-shaped application boundary rules, the typed service adapter SPI,
|
||||
// the interceptor order contract, and the Netty server/executor/admission profiles.
|
||||
//
|
||||
// The Netty profiles are configuration models, not Netty wiring — no netty dependency here. Real
|
||||
// Netty lives in `grpc-testkit`'s certification lane, which is where transport evidence is produced.
|
||||
```
|
||||
|
||||
## 2. 인터셉터 순서 계약
|
||||
|
||||
열 단계이고 선언 순서가 계약이다. 각 위치의 이유가 열거형 javadoc 에 있다.
|
||||
|
||||
```
|
||||
EXCEPTION_BOUNDARY → TRACE → AUTHENTICATION → ACTOR_TENANT → AUTHORIZATION
|
||||
→ ADMISSION → DEADLINE_CANCELLATION → IDEMPOTENCY → VALIDATION → SERVICE_ADAPTER
|
||||
```
|
||||
|
||||
- 예외 경계가 가장 바깥 — 이후 단계의 실패가 매핑되지 않은 상태로 새지 않는다
|
||||
- 인증 → 행위자·소속 → 인가 — 각 단계가 앞 단계의 답을 필요로 한다
|
||||
- 승인이 마감보다 먼저 — 부하 중 서버가 일을 쓰기 전에 흘려보낸다
|
||||
- 멱등이 검증보다 먼저 — 재생된 요청이 이미 받아들인 본문을 다시 검증하지 않고 저장된 결과를 돌려준다
|
||||
- 검증이 어댑터 직전 — 사용 사례는 믿을 수 있는 메시지를 받는다
|
||||
|
||||
필수가 아닌 단계는 멱등 하나다 — 상태 변경 키 메서드가 없는 서버에는 할 일이 없기 때문이다.
|
||||
|
||||
## 3. 뒤집기가 이 클래스의 존재 이유다
|
||||
|
||||
> "`ServerInterceptors.intercept` wraps each interceptor around the previous one, so the last one
|
||||
> passed is the outermost at runtime — the opposite of how the order reads. Every codebase that
|
||||
> builds this list by hand gets it backwards at least once, and the symptom is an exception boundary
|
||||
> that catches nothing."
|
||||
|
||||
`inStableOrder()` 와 `inGrpcRegistrationOrder()` 를 나누고, 후자가 전자의 역순임을 테스트가 붙든다.
|
||||
|
||||
## 4. 순서 검증의 근거
|
||||
|
||||
> "A chain with validation before authentication lets an anonymous caller probe the schema through
|
||||
> error messages; one with the exception boundary in the middle lets a throwable from an earlier
|
||||
> stage escape as `UNKNOWN`. Neither shows up in a test of the happy path."
|
||||
|
||||
네 규칙이다 — 중복 단계, 필수 단계 누락, 역순, 예외 경계가 최외곽이 아님.
|
||||
|
||||
## 5. 원시 API 차단 규칙
|
||||
|
||||
금지 타입 열네 개가 채널·서버·호출을 손으로 만드는 구성 API 다. 금지가 아니라 허용 패키지 목록을 받는다.
|
||||
|
||||
> "They are legitimate inside the platform and inside generated code, which is why this rule takes
|
||||
> an allowlist of packages rather than banning them outright."
|
||||
|
||||
그리고 허용 목록이 비면 생성자가 거부한다 — 플랫폼 자신은 어딘가에서 채널을 만들어야 한다.
|
||||
|
||||
## 6. 응용 경계 규칙
|
||||
|
||||
금지 접두 열세 개와 금지 타입 셋. 접두만으로 너무 넓은 경우를 위해 정확한 타입 목록을 따로 둔다.
|
||||
|
||||
> "a transport adapter that calls a repository has moved the use case into the transport, and the
|
||||
> next caller of that use case — a scheduled job, a message consumer — either duplicates it or
|
||||
> reaches through the controller."
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
다섯 테스트. 순서 계약(등록 역순 포함), 프로파일 거부(무제한 큐·in-process production·킵얼라이브·연결 수명), 승인 경계, 어댑터의 매핑, 경계 규칙과 원시 API 규칙을 확인한다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** 이 리프의 다섯 타입은 저장소 어디에서도(자기 리프 밖) 참조되지 않는다.
|
||||
|
||||
```
|
||||
GrpcApplicationBoundaryRules · GrpcRawApiImportRule · GrpcServiceAdapterMarker
|
||||
GrpcServerInterceptorChain · GrpcNettyParityContract → leaf 밖 참조 0
|
||||
```
|
||||
|
||||
`GrpcAdmissionController`·`GrpcExecutorProfile`·`GrpcServerProfile` 은 `grpc-spring-boot-starter` 가 빈으로 만들고, `GrpcAdmissionController` 는 `grpc-admin` 의 배수 조정자가 협력자로 받는다.
|
||||
|
||||
다만 **만들어지는 것과 불리는 것은 다르다.** 재통독에서 다시 세었다.
|
||||
|
||||
```
|
||||
tryAdmit() production 호출 0 (테스트 3곳)
|
||||
release() production 호출 0
|
||||
promoteFromQueue() production 호출 0
|
||||
```
|
||||
|
||||
즉 승인 제어기는 빈으로 존재하고 아무 호출도 승인받지 않는다. 그것을 부를 자리인 `ADMISSION` 인터셉터 단계의 구현이 이 가족에 없기 때문이다(§12.1 의 `GrpcServerInterceptorChain` 미참조와 같은 원인). §17.4 가 그 첫 호출자가 만나게 될 것을 다룬다.
|
||||
|
||||
**12.4 드리프트.** build.gradle 이 서술한 네 요소가 전부 존재하고, Netty 의존이 없다는 서술도 맞다.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 실제 서버를 세워 인터셉터 사슬을 돌리지 않았다. 이 리프에 서버를 만드는 코드가 없다.
|
||||
- `GrpcNettyParityContract` 가 서술하는 두 변형의 동등성을 실행으로 확인하지 않았다. 그 클래스의 `unproven(...)` 을 부르는 코드도 저장소에 없다 — grpc-testkit §17.5 와 같은 형태의 평가기다.
|
||||
- §17.4 의 경합을 실행으로 재현하지 않았다. 읽기와 증가가 분리되어 있다는 것과 하한 가드가 없다는 것으로 판정했다.
|
||||
- `gradle.lockfile` 은 읽지 않았다(`STRUCTURAL_ONLY`).
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P2 — 두 아키텍처 규칙이 저장소 소스에 적용되지 않는다
|
||||
|
||||
`GrpcApplicationBoundaryRules` javadoc:
|
||||
|
||||
> "The list is package prefixes rather than a prose rule, **so it can be applied by an architecture
|
||||
> test, by a source scan and by a review checklist** without three people deciding what 'must not use
|
||||
> a repository' covers."
|
||||
|
||||
세 적용처 중 저장소에 존재하는 것이 없다.
|
||||
|
||||
```
|
||||
GrpcApplicationBoundaryRules leaf 밖 참조 0
|
||||
GrpcRawApiImportRule leaf 밖 참조 0
|
||||
GrpcServiceAdapterMarker leaf 밖 참조 0 (규칙이 어댑터를 열거하려고 만든 마커)
|
||||
```
|
||||
|
||||
그리고 이 리프의 테스트는 저장소 파일을 훑지 않는다. 인라인 소스 문자열을 넣는다.
|
||||
|
||||
```java
|
||||
assertThat(rule.violations("DocumentClient.java", applicationSource)).isNotEmpty();
|
||||
assertThat(rule.violations("ChannelFactory.java", platformSource)).isEmpty();
|
||||
```
|
||||
|
||||
즉 규칙의 판정 로직은 검증되지만, 저장소의 어떤 파일도 그 판정을 받지 않는다. `GrpcServiceAdapterMarker` 는 규칙이 어댑터를 런타임에 열거할 수 있도록 만든 애너테이션인데, 그것을 붙인 타입도 그것을 읽는 코드도 없다.
|
||||
|
||||
**수정.** 이 리프의 테스트에 저장소 소스를 훑는 검사를 추가한다 — `src/**/*.java` 를 읽어 `GrpcRawApiImportRule.violations` 를 돌리고 비어 있음을 단언하는 형태다. 규칙이 이미 파일 이름과 소스 텍스트를 받는 서명이므로 재료는 갖춰져 있다.
|
||||
|
||||
### 17.2 P3 — 원시 API 규칙이 import 문만 보므로 완전 수식 사용과 와일드카드를 놓친다
|
||||
|
||||
```java
|
||||
private static final Pattern IMPORT = Pattern.compile("^\\s*import\\s+(?:static\\s+)?([\\w.]+)\\s*;", MULTILINE);
|
||||
…
|
||||
if (RAW_API_TYPES.contains(imported)) { violations.add(…); }
|
||||
```
|
||||
|
||||
두 형태가 빠진다.
|
||||
|
||||
```java
|
||||
io.grpc.ManagedChannelBuilder.forAddress("h", 1).build(); // import 없이 완전 수식
|
||||
import io.grpc.*; // 정확 일치 실패
|
||||
```
|
||||
|
||||
이것이 가정에 그치지 않는 이유는 이 저장소 자신의 문체다. 같은 가족의 여러 파일이 완전 수식 참조를 본문에 그대로 쓴다.
|
||||
|
||||
```
|
||||
GrpcConsumerFixture java.util.regex.Pattern.compile(...)
|
||||
GrpcAdvancedSupportMatrix java.util.stream.Collectors.toUnmodifiableMap(...)
|
||||
GrpcProtoStyleManifest java.util.Set / java.util.LinkedHashSet 인라인
|
||||
```
|
||||
|
||||
즉 이 코드베이스에서 완전 수식 사용은 예외가 아니라 흔한 형태다.
|
||||
|
||||
규칙 클래스의 자바독은 "there is nothing to reach for" 를 목표로 든다. 지금 형태는 손이 닿는 경로 하나만 본다.
|
||||
|
||||
수정은 정규식을 타입 이름의 등장 자체로 넓히거나(오탐이 생기므로 주석·문자열 제거가 필요), 바이트코드 기반 검사로 옮기는 것이다. 후자가 이 저장소의 다른 아키텍처 게이트와 형태가 같다.
|
||||
|
||||
### 17.3 P3 — 빌더 경로에서 순서 규칙 넷 중 셋이 발화할 수 없다
|
||||
|
||||
```java
|
||||
public GrpcServerInterceptorChain build() {
|
||||
GrpcServerInterceptorOrder.requireStableOrder(List.copyOf(byStage.keySet()));
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
`byStage` 는 `EnumMap` 이므로 `keySet()` 은 언제나 열거형 선언 순서다. 그리고 `stage(...)` 가 같은 단계의 두 번째 등록을 이미 거부한다.
|
||||
|
||||
따라서 빌더가 만드는 목록에서는 중복도, 역순도, 예외 경계가 최외곽이 아닌 경우도 발생할 수 없다. 발화 가능한 규칙은 필수 단계 누락 하나다.
|
||||
|
||||
결함은 아니다 — 나머지 셋은 `violations(List)` 를 직접 부르는 외부 호출자를 위한 것이고, 테스트가 그 경로로 셋을 모두 확인한다. 기록하는 이유는 빌더를 쓰는 조립 코드가 그 셋의 보호를 받는다고 읽기 쉽기 때문이다. 실제 보호는 자료구조가 준다.
|
||||
|
||||
### 17.4 P2 — 승인 제어기의 세 메서드가 원자적이지 않고, 큐 계수기를 되돌리는 경로가 없다
|
||||
|
||||
이 리프가 SSOT 이므로 여기에 적는다. `grpc-policy` §17.1 이 이 클래스를 대조군으로 지목하는데, 지목된 쪽 문서에 판정이 없었다.
|
||||
|
||||
**첫째, 읽고 나서 따로 증가시킨다.**
|
||||
|
||||
```java
|
||||
public Decision tryAdmit() {
|
||||
int running = inFlight.get();
|
||||
if (running < maxConcurrentCalls) {
|
||||
inFlight.incrementAndGet(); // ← 읽기와 증가 사이에 다른 스레드가 들어온다
|
||||
return new Decision(true, …);
|
||||
}
|
||||
int waiting = queued.get();
|
||||
if (waiting < maxQueuedCalls) {
|
||||
queued.incrementAndGet(); // ← 같은 형태
|
||||
…
|
||||
```
|
||||
|
||||
경계에 있는 N 개 스레드가 모두 통과한다. `AtomicInteger` 를 쓰면서 비교와 증가를 나눈 형태이고, 같은 가족의 정본이 `GrpcRetryBudget.tryConsume` 의 비교 후 교체 루프다.
|
||||
|
||||
`release()`·`promoteFromQueue()` 도 같다 — `get() > 0` 을 확인한 뒤 별도로 감소시키므로, 두 스레드가 같은 마지막 하나를 보고 둘 다 감소시켜 음수가 될 수 있다. 클래스가 `Math.max(0, …)` 같은 하한도 두지 않는다.
|
||||
|
||||
**둘째, 큐 계수기를 되돌리는 경로가 없다.**
|
||||
|
||||
큐에 들어간 호출도 `admitted=true` 를 받는다. 그런데 그 경로는 `queued` 만 올리고 `inFlight` 는 올리지 않는다. 그리고 끝난 호출을 반납하는 메서드는 하나뿐이다.
|
||||
|
||||
```java
|
||||
public void release() {
|
||||
if (inFlight.get() > 0) { inFlight.decrementAndGet(); } // ← queued 는 건드리지 않는다
|
||||
}
|
||||
```
|
||||
|
||||
따라서 호출자가 `promoteFromQueue()` 를 정확히 한 번 끼워 넣지 않으면 계수기가 어긋난다 — 큐에서 실행된 호출이 끝나면 `queued` 는 그대로이고 `inFlight` 만 줄어든다. `releaseQueued()` 같은 메서드도, 그 짝짓기를 요구하는 서술도 없다.
|
||||
|
||||
**시험이 이것을 볼 수 없는 이유.** 두 시험 모두 단일 스레드이고, `releaseAndPromotionTrackCapacity` 는 `release()` 와 `promoteFromQueue()` 를 **짝지어** 부른다. 짝짓지 않는 경로는 시험되지 않는다.
|
||||
|
||||
**등급.** 오늘 호출자가 없으므로(§12.1) P2. 승인 단계를 배선하는 순간 P1 이다 — 부하 아래에서 경계가 새는 것과, 큐 계수기가 단조 증가해 `at capacity` 가 영구히 참이 되는 것이 함께 온다.
|
||||
|
||||
**수정.** 세 메서드를 비교 후 교체 루프로 바꾸고, 큐 경로에 대응하는 반납 메서드를 두거나 `promoteFromQueue` 를 `release` 안으로 접는다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **열 단계의 순서와 각 위치의 이유를 열거형 javadoc 에 적은 것.**
|
||||
- **등록 순서 뒤집기를 클래스로 분리하고 그 이유를 적은 것.**
|
||||
- **멱등만 선택 단계로 둔 것.**
|
||||
- **순서 위반의 증상이 정상 경로 테스트에 나타나지 않는다는 근거.**
|
||||
- **원시 API 를 금지가 아니라 허용 패키지 목록으로 다룬 것.**
|
||||
- **허용 목록이 빈 규칙을 거부한 것.**
|
||||
- **접두 목록으로 너무 넓은 경우를 위해 정확한 타입 목록을 따로 둔 것.**
|
||||
- **Netty 의존 없이 프로파일만 두고, 실제 전송 증거를 테스트킷 인증 레인으로 넘긴 것.**
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc/grpc-server/build.gradle:1-14
|
||||
main/java/…/server/GrpcServerInterceptorChain.java:1-110
|
||||
main/java/…/server/GrpcServerInterceptorOrder.java:1-90
|
||||
main/java/…/server/GrpcServerInterceptorStage.java:1-51
|
||||
main/java/…/architecture/GrpcRawApiImportRule.java:1-108
|
||||
main/java/…/architecture/GrpcApplicationBoundaryRules.java:1-92
|
||||
main/java/…/architecture/GrpcServiceAdapterMarker.java:1-25
|
||||
main/java/…/server/(GrpcAdmissionController · GrpcServiceAdapter · GrpcServerProfile · GrpcExecutorProfile · GrpcNettyParityContract · GrpcNettyVariantSelector · GrpcServiceAdapterDescriptor · GrpcServerTransport · GrpcNettyVariant · GrpcApplicationInvocation · GrpcResponseMapper)
|
||||
test/java/…/(architecture/GrpcApplicationBoundaryRulesTest · server/GrpcServerInterceptorOrderTest · server/GrpcServerProfileTest · server/GrpcServiceAdapterTest · server/GrpcNettyVariantSelectorTest)
|
||||
```
|
||||
@@ -0,0 +1,295 @@
|
||||
# grpc-spring-boot-starter 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 — `src/main` production 4파일 468줄, 등록 파일 1개, test 1파일 267줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc/grpc-spring-boot-starter`
|
||||
> SSOT owner: `grpc-spring-boot-starter`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
- 선언 의존: `api` project 7 · `implementation` project 3 + vendor 2
|
||||
|
||||
| 파일 | LOC | 성격 |
|
||||
|---|---:|---|
|
||||
| `GrpcPlatformStartupValidator` | 188 | 5개 검증 묶음, static 유틸 |
|
||||
| `GrpcPlatformProperties` | 139 | `ca-skeleton.grpc.platform.*` 결속 |
|
||||
| `GrpcPlatformAutoConfiguration` | 106 | 빈 9개 |
|
||||
| `GrpcPlatformConfigurationException` | 35 | 위반 목록 예외 |
|
||||
| **main java 합계** | **468** | |
|
||||
| `AutoConfiguration.imports` | 1 | 자동 설정 1개 등록 |
|
||||
| `GrpcPlatformStartupValidatorTest` | 267 | 테스트 |
|
||||
| `build.gradle` | 24 | 의존 선언 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 4 | `FULL_READ` | 188+139+106+35 전 본문 |
|
||||
| `main/resources/META-INF/spring/*.imports` | 1 | `FULL_READ` | 1줄 |
|
||||
| `test/java/**` | 1 | `FULL_READ` | 267줄 · 테스트 12개 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 24줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 격리 규칙
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-7
|
||||
// The platform's composition boundary: typed properties, auto-configuration and the startup
|
||||
// validator that refuses a deployment whose configuration contradicts a Stable invariant.
|
||||
//
|
||||
// It must never reach `:grpc-advanced:*`. That is not a comment — the registry's
|
||||
// allowed_dependencies for this leaf omits every advanced id, `verifyCleanArchitectureDependencies`
|
||||
// enforces it, and GrpcPlatformStartupValidatorTest asserts the same rule from the Java side.
|
||||
```
|
||||
|
||||
격리 규칙은 세 겹이다 — 레지스트리, 빌드 검증 태스크, 그리고 자바 쪽 단언. 세 번째는 `validateAdvancedIsolation` 이 `GrpcStableBuildInvariant.requireNoAdvancedDependency` 를 부르는 형태다.
|
||||
|
||||
## 2. 자동 설정이 만드는 것
|
||||
|
||||
`@ConditionalOnProperty(prefix = "ca-skeleton.grpc.platform", name = "enabled", havingValue = "true", matchIfMissing = false)` — 기본 꺼짐.
|
||||
|
||||
| 빈 | 만들어지는 값 |
|
||||
|---|---|
|
||||
| `GrpcExecutorProfile` | `boundedPool(maxPoolSize, queueCapacity)` |
|
||||
| `GrpcServerProfile` | `stableNetty(executorProfile)` |
|
||||
| `GrpcAdmissionController` | `forExecutor(executorProfile)` |
|
||||
| `GrpcServiceHealthRegistry` | `new …(GrpcHealthPolicy.standalone())` |
|
||||
| `GrpcReflectionPolicy` | 설정 값이 없으면 `defaultFor(environment)` |
|
||||
| `GrpcAdminExposurePolicy` | `standard()` |
|
||||
| `GrpcDrainPolicy` | `stable()` |
|
||||
| `GrpcContextBinder` | `new …(GrpcContextPropagationPolicy.stable())` |
|
||||
| `GrpcErrorMapper` | `new …("grpc-platform", UUID::randomUUID)` |
|
||||
|
||||
전부 정책·프로파일·레지스트리다. 서버도, 인터셉터 사슬도, 서비스 어댑터 등록도 없다.
|
||||
|
||||
## 3. 설정 표면
|
||||
|
||||
`@ConfigurationProperties(prefix = "ca-skeleton.grpc.platform", ignoreUnknownFields = false)`.
|
||||
|
||||
두 판단이 javadoc 에 적혀 있다.
|
||||
|
||||
> "Off by default, like every other optional capability in this repository. A platform that starts
|
||||
> because its jar is on the classpath is a platform that opens a port on a deployment nobody decided
|
||||
> to give one to."
|
||||
|
||||
> "`ignoreUnknownFields = false` so a misspelled key fails startup rather than silently leaving a
|
||||
> setting at its default."
|
||||
|
||||
## 4. 검증기가 담은 규칙
|
||||
|
||||
javadoc 이 선정 기준을 적는다.
|
||||
|
||||
> "Every rule here is a mistake whose runtime symptom is either silence or a misattributed failure…
|
||||
> None of them fails a smoke test."
|
||||
|
||||
다섯 묶음이다.
|
||||
|
||||
- 전송·보안 — production 전송이 아니면 거부, 배포 환경에서 TLS 미사용·trust-all·반사 전체 공개 거부
|
||||
- 실행기 — 큐 용량 1 미만(무제한) 거부, 풀 크기 양수 요구
|
||||
- 메서드 — 단항인데 사용 가능한 마감이 0, 명시적 재시도가 멱등 프로파일과 모순, 멱등 키 필수인데 원장 비활성, Stable 범위 밖 RPC 종류
|
||||
- 채널 — Stable 스킴 요구, 두 재시도 소유자가 동시에 in-process 재시도
|
||||
- 고급 격리 — Stable 스타터가 advanced 의존을 끌면 위반
|
||||
|
||||
그리고 한 번에 전부 모아 실패한다 — "so a deployment learns the whole list in one restart."
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
`GrpcPlatformStartupValidatorTest` 267줄 · 12개. 검증기의 규칙별 거부와 통과를 직접 호출로 확인한다. **자동 설정 컨텍스트를 세우는 테스트는 없다** — `ApplicationContextRunner` 도, 슬라이스 테스트도 없다. 빈 아홉 개가 실제로 조립되는지는 이 레인이 답하지 않는다.
|
||||
|
||||
`aCoherentConfigurationStarts` 가 통과 쪽을, 나머지 아홉이 규칙별 거부 쪽을 잡는다 — in-process 전송, TLS 둘, 반사, 무제한 실행기, 원장 없는 멱등 키, client-streaming, 재시도 소유자 둘, advanced 누출. `aRefusalNamesEveryViolation` 이 세 개를 동시에 깨뜨려 목록이 한 번에 나오는지 본다.
|
||||
|
||||
마지막 하나가 형태로 특이하다.
|
||||
|
||||
```java
|
||||
void theAutoConfigurationIsRegisteredAndStable() {
|
||||
assertThat(read(Path.of("src/main/resources/META-INF/spring/…imports")).strip())
|
||||
.isEqualTo("dev.caskeleton.grpc.boot.GrpcPlatformAutoConfiguration");
|
||||
// The dependency declaration, not the word: the build file's own comment says it must never
|
||||
// reach an advanced module, and matching on the prose would fail on the sentence stating the rule.
|
||||
assertThat(read(Path.of("build.gradle"))).doesNotContain("project(':grpc-advanced");
|
||||
}
|
||||
```
|
||||
|
||||
단위 테스트가 자기 모듈의 `build.gradle` 을 파일로 읽어 의존 선언을 단언한다. 주석이 왜 낱말이 아니라 선언 문법에 맞추는지까지 적어 두었다 — 규칙을 서술한 문장 자체가 낱말 검색에 걸리기 때문이다. `verifyCleanArchitectureDependencies` 가 도는 것과 별개로 이 레인 안에서도 격리가 붙들린다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** 리프 밖에서 이 리프의 타입을 부르는 코드가 0 이고, **이 리프를 의존하는 모듈도 0 이다.**
|
||||
|
||||
```
|
||||
$ grep -rn "grpc-spring-boot-starter" --include=*.gradle src/
|
||||
(매치 없음)
|
||||
$ grep -rn "ca-skeleton.grpc.platform" --include=*.yml --include=*.yaml --include=*.properties .
|
||||
(매치 없음 — 이 리프 자신을 빼고)
|
||||
$ grep -rn "GrpcPlatformAutoConfiguration\|GrpcPlatformProperties\|GrpcPlatformStartupValidator" --include=*.java src/ | grep -v grpc-spring-boot-starter
|
||||
(매치 없음)
|
||||
```
|
||||
|
||||
| 타입 | production 호출자 |
|
||||
|---|---|
|
||||
| `GrpcPlatformAutoConfiguration` | 등록 파일 1줄 — 그러나 이 스타터를 클래스패스에 올리는 모듈이 없다 |
|
||||
| `GrpcPlatformStartupValidator` | **0** |
|
||||
| `GrpcPlatformConfigurationException` | 검증기 안에서만 |
|
||||
|
||||
`enabled=true` 를 쓰는 설정 파일도 저장소에 없다. 즉 `@ConditionalOnProperty` 가 참이 되는 배포가 지금 하나도 없고, 아홉 빈은 아직 한 번도 만들어진 적이 없다. §17.1 의 등급을 P2 로 둔 근거가 이것이다 — 오늘의 사고가 아니라, 이 스타터를 처음 채택하는 배포가 맞을 상태다.
|
||||
|
||||
**12.3 선언만 있고 쓰이지 않는 의존 셋.**
|
||||
|
||||
```groovy
|
||||
implementation project(':grpc:grpc-proto-contract')
|
||||
implementation project(':grpc:grpc-codegen')
|
||||
implementation project(':grpc:grpc-operation-ledger-jpa')
|
||||
```
|
||||
|
||||
이 리프의 자바 4파일 어디에도 `dev.caskeleton.grpc.contract` · `…grpc.codegen` · 운영 원장 타입의 import 가 없다. `operation-ledger-enabled` 는 `boolean` 프로퍼티일 뿐 원장 타입을 참조하지 않는다.
|
||||
|
||||
세 의존 모두 build-only 판정 도구다 — 스키마 규칙 엔진, 코드 생성 거버넌스, JPA 원장. 스타터가 그것들을 **런타임 조립에 쓰지 않으면서 클래스패스에 끌고 온다.** 이 리프의 존재 이유가 "구성 경계"이므로, 경계가 끌어오는 것이 실제로 필요한 것인지가 다른 리프보다 더 중요하다.
|
||||
|
||||
같은 사실을 반대편에서도 기록해 두었다 — `grpc-codegen` §12.1, `grpc-proto-contract` §12.1.
|
||||
|
||||
**12.2 설정 키별 소비자.**
|
||||
|
||||
| 키 | 읽는 곳 |
|
||||
|---|---|
|
||||
| `enabled` | `@ConditionalOnProperty` |
|
||||
| `executor-queue-capacity` · `executor-max-pool-size` | 자동 설정 + 검증기 |
|
||||
| `environment` | 자동 설정(반사 정책) + 검증기 |
|
||||
| `reflection-mode` | 자동 설정 + 검증기 |
|
||||
| `transport` | **검증기뿐** |
|
||||
| `tls-enabled` · `trust-all-certificates` | **검증기뿐** |
|
||||
| `operation-ledger-enabled` | **검증기뿐** |
|
||||
| `default-unary-deadline` | **없음** |
|
||||
|
||||
**12.4 드리프트.** build.gradle 이 서술한 세 요소(타입 있는 설정·자동 설정·시작 검증기)가 전부 존재한다. 어긋난 것은 세 번째가 시작 시 돌지 않는다는 점이고 §17.1 이다.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 스타터를 실제 애플리케이션에 올려 컨텍스트를 세우지 않았다. build-only 이고, 이 스타터를 의존하는 모듈이 저장소에 없다(§12.1).
|
||||
- `verifyCleanArchitectureDependencies` 태스크를 이 리비전에서 실행하지 않았다.
|
||||
- 테스트를 실행하지 않았다. 12개 전부 본문으로만 확인했다.
|
||||
- 세 `implementation` 의존이 쓰이지 않는다는 것(§12.3)은 패키지 이름 grep 으로 판정했다. 상수나 문자열을 통한 간접 사용이라면 잡히지 않는다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P2 — 시작 검증기가 시작 시 실행되지 않는다
|
||||
|
||||
`GrpcPlatformStartupValidator` 를 이름으로 부르는 파일은 둘뿐이다 — 자기 자신과 자기 테스트.
|
||||
|
||||
```
|
||||
src/grpc/grpc-spring-boot-starter/src/main/java/…/GrpcPlatformStartupValidator.java
|
||||
src/grpc/grpc-spring-boot-starter/src/test/java/…/GrpcPlatformStartupValidatorTest.java
|
||||
```
|
||||
|
||||
`GrpcPlatformAutoConfiguration` 은 빈 9개를 만들고 `requireValid` 를 부르지 않는다. 초기화 콜백도, `@PostConstruct` 도, `ApplicationRunner` 도 없다.
|
||||
|
||||
그래서 클래스 javadoc 이 약속한 성질이 성립하지 않는다 — "Refuses to start on a configuration that would be wrong in a way nobody would notice." 지금은 그 설정으로 그냥 시작한다.
|
||||
|
||||
**함께 사라지는 것.** 검증기가 유일한 소비자인 설정 키가 넷이다.
|
||||
|
||||
- `transport` — production 이 아닌 전송을 거부할 곳이 없다. 게다가 자동 설정은 이 값을 보지 않고 `GrpcServerProfile.stableNetty(...)` 를 하드코딩한다(§17.2).
|
||||
- `tls-enabled` · `trust-all-certificates` — 배포 환경의 TLS 바닥을 강제할 곳이 없다.
|
||||
- `operation-ledger-enabled` — 멱등 키 필수 메서드가 원장 없이 열리는 것을 막을 곳이 없다.
|
||||
|
||||
같은 저장소가 이 형태를 두 번 기록했다 — `WebPlatformStartupValidator` 가 시작 시 실행되지 않고, `BrokerAclManifest` 의 시작 자기점검이 없다. 반대로 messaging 의 `StartupProfileValidation` 은 `InitializingBean.afterPropertiesSet` 으로 돌려 그 문제를 이미 한 번 해결했고, fileserver 는 `attestMapping()` 을 app-bootstrap 의 `@Bean` 으로 연결했다. 정본이 저장소 안에 둘 있다.
|
||||
|
||||
**왜 배선되지 않았는지가 서명에 보인다.** `violations` 는 넷을 받는다.
|
||||
|
||||
```java
|
||||
public static List<String> violations(
|
||||
GrpcPlatformProperties properties, // 자동 설정이 @EnableConfigurationProperties 로 가진다
|
||||
GrpcMethodPolicyCatalog catalog, // 이 자동 설정에 빈 정의 없음
|
||||
List<GrpcNamedChannelProfile> channelProfiles, // 빈 정의 없음
|
||||
Set<String> stableModuleDependencies) // 이것을 런타임에 계산하는 코드가 저장소에 없음
|
||||
```
|
||||
|
||||
넷 중 셋에 생산자가 없다. 특히 마지막은 "스타터가 해석한 모듈 id 집합" 인데, 그것을 실행 중에 산출하는 코드가 저장소 어디에도 없다 — 테스트는 `Set.of("grpc-core-api", "grpc-policy", "grpc-server", "grpc-client")` 리터럴을 넣는다. 검증기가 요구하는 입력을 구성 경계가 만들지 않으므로, 지금 형태로는 부를 수가 없다.
|
||||
|
||||
**수정.** 자동 설정에 검증기를 부르는 `InitializingBean`(또는 `SmartInitializingSingleton`) 빈을 하나 추가하되, 세 입력의 생산자를 함께 정한다.
|
||||
|
||||
- `GrpcMethodPolicyCatalog` · `List<GrpcNamedChannelProfile>` — `ObjectProvider` 로 받고 비어 있을 때의 동작(건너뛸지, 그 자체를 위반으로 볼지)을 정한다.
|
||||
- `stableModuleDependencies` — 런타임에 계산할 방법이 없다면 `GrpcStableModuleCatalog` 가 아는 정적 목록으로 대체하거나, 이 규칙을 빌드 태스크 쪽에만 남기고 검증기 서명에서 뺀다. 지금은 같은 불변식을 세 겹으로 둔다고 §1 이 말하지만, 세 번째 겹이 실행되려면 아무도 만들지 않는 입력이 필요하다.
|
||||
|
||||
### 17.2 P3 — 자동 설정이 `transport` 를 읽지 않고 전송을 하드코딩한다
|
||||
|
||||
```java
|
||||
@Bean @ConditionalOnMissingBean
|
||||
public GrpcServerProfile grpcServerProfile(GrpcExecutorProfile executorProfile) {
|
||||
return GrpcServerProfile.stableNetty(executorProfile);
|
||||
}
|
||||
```
|
||||
|
||||
`GrpcPlatformProperties.transport` 는 `GrpcServerTransport` 열거형이고 기본값이 `NETTY_SHADED` 다. 그 값을 자동 설정이 보지 않으므로 다른 값을 설정해도 만들어지는 프로파일은 같다.
|
||||
|
||||
지금은 무해에 가깝다 — 기본값이 하드코딩된 것과 같고, 다른 값은 §17.1 때문에 거부되지도 않지만 반영되지도 않는다. 그러나 설정 키가 존재하고 문서화되어 있으므로 운영자는 그것이 전송을 고른다고 읽는다.
|
||||
|
||||
수정은 프로파일 팩토리를 `transport` 로 분기시키거나, 그 키를 검증 전용임을 자바독에 명시하는 것이다.
|
||||
|
||||
### 17.3 P3 — `default-unary-deadline` 은 읽는 코드가 저장소에 없다
|
||||
|
||||
```java
|
||||
/** The default deadline applied to a Stable unary method that declares none. */
|
||||
private Duration defaultUnaryDeadline = Duration.ofSeconds(2);
|
||||
```
|
||||
|
||||
`getDefaultUnaryDeadline()` 의 호출자가 0 이다. 검증기도 이 값을 쓰지 않는다 — 검증기가 보는 것은 정책 목록의 `policy.deadline().usable()` 이고 그 값이 0 이면 위반을 낸다. 즉 자바독이 말하는 "선언하지 않은 메서드에 적용되는 기본 마감" 을 적용하는 코드가 없다.
|
||||
|
||||
`ignoreUnknownFields = false` 라서 이 키를 설정하는 것은 성공하고 아무 효과가 없다.
|
||||
|
||||
수정은 그 기본값을 실제로 적용하는 지점을 만들거나(정책 목록 조립 시), 필드를 제거하는 것이다.
|
||||
|
||||
### 17.4 P3 — 반사 모드를 명시하면 서비스·역할 허용 목록이 조용히 하드코딩으로 바뀐다
|
||||
|
||||
```java
|
||||
@Bean @ConditionalOnMissingBean
|
||||
public GrpcReflectionPolicy grpcReflectionPolicy(GrpcPlatformProperties properties) {
|
||||
return properties.getReflectionMode() == null
|
||||
? GrpcReflectionPolicy.defaultFor(properties.getEnvironment())
|
||||
: new GrpcReflectionPolicy(
|
||||
properties.getReflectionMode(),
|
||||
java.util.Set.of("admin"), // ← 리터럴
|
||||
java.util.Set.of("ROLE_PLATFORM_ADMIN")); // ← 리터럴
|
||||
}
|
||||
```
|
||||
|
||||
두 갈래가 만드는 것이 같은 종류의 값이 아니다.
|
||||
|
||||
- 설정하지 않으면 `defaultFor(environment)` — 환경이 서비스 목록과 역할 목록을 함께 결정한다.
|
||||
- 설정하면 모드만 운영자 것이고, **허용 서비스와 허용 역할은 이 자동 설정에 박힌 리터럴이 된다.**
|
||||
|
||||
운영자가 조정한다고 생각하는 것은 노출 수위 하나인데, 실제로는 노출 대상 집합까지 바뀐다. 그리고 그 두 리터럴은 설정 표면에 노출되어 있지 않으므로 되돌릴 방법이 `reflection-mode` 를 다시 비우는 것뿐이다.
|
||||
|
||||
`ca-skeleton.grpc.platform` 은 `ignoreUnknownFields = false` 를 걸어 "오타가 조용히 기본값으로 남지 않게" 한 설정 표면이다. 같은 규율로 보면, 값을 하나 설정했을 때 설정하지 않은 두 값이 함께 바뀌는 것도 같은 종류의 침묵이다.
|
||||
|
||||
**수정.** 허용 서비스·역할을 `GrpcPlatformProperties` 에 올리거나, 명시 모드에서도 `defaultFor(environment)` 가 만든 정책의 모드만 바꾼 사본을 쓴다. 후자가 이 저장소의 다른 곳에서 쓰는 형태다(`GrpcProtoStyleManifest.allowingWellKnownTypes` 처럼 넓힌 사본).
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **기본 꺼짐과 그 근거** — 클래스패스에 있다는 이유로 포트를 여는 플랫폼이 되지 않는다.
|
||||
- **`ignoreUnknownFields = false`** — 오타가 조용히 기본값으로 남지 않는다.
|
||||
- **고급 격리를 세 겹으로 둔 것** — 레지스트리·빌드 태스크·자바 단언.
|
||||
- **검증기가 한 번에 전부 보고하는 것** — 재시작 한 번으로 목록 전체를 배운다.
|
||||
- **검증 규칙 선정 기준** — 증상이 침묵이거나 오귀인인 실수만 담는다.
|
||||
- **`@ConditionalOnMissingBean` 을 아홉 빈 전부에 둔 것** — 채택자가 개별 정책을 갈아끼울 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc/grpc-spring-boot-starter/build.gradle:1-24
|
||||
main/java/…/boot/GrpcPlatformAutoConfiguration.java:1-106
|
||||
main/java/…/boot/GrpcPlatformStartupValidator.java:1-188
|
||||
main/java/…/boot/GrpcPlatformProperties.java:1-139
|
||||
main/java/…/boot/GrpcPlatformConfigurationException.java:1-35
|
||||
main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:1
|
||||
test/java/…/boot/GrpcPlatformStartupValidatorTest.java:1-267
|
||||
```
|
||||
@@ -0,0 +1,320 @@
|
||||
# grpc-testkit 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 26파일 2,313줄 + `src/test` 8파일 1,339줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/grpc/grpc-testkit`
|
||||
> SSOT owner: `grpc-testkit`
|
||||
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `runtime_memberships`: **`[]`** — build-only
|
||||
- 이 리프만 실제 전송을 싣는다 — `grpc-inprocess`, `grpc-netty-shaded`
|
||||
|
||||
| 파일 | LOC | 구획 |
|
||||
|---|---:|---|
|
||||
| `testkit/netty/GrpcTlsTestMaterial` | 239 | netty |
|
||||
| `testkit/inprocess/GrpcInProcessContractFixture` | 145 | inprocess |
|
||||
| `testkit/netty/GrpcNettyTestServer` | 137 | netty |
|
||||
| `testkit/netty/GrpcNettyTestClient` | 116 | netty |
|
||||
| `testkit/fault/GrpcTransportEvidenceClassifier` | 107 | fault |
|
||||
| `release/GrpcStableReleaseGate` | 96 | release |
|
||||
| `performance/GrpcPerformanceGate` | 95 | performance |
|
||||
| `testkit/inprocess/GrpcInProcessTestServer` | 94 | inprocess |
|
||||
| `release/GrpcCompatibilityMatrix` | 93 | release |
|
||||
| `testkit/netty/GrpcNettyContractProfile` | 87 | netty |
|
||||
| `testkit/inprocess/GrpcInProcessTestClient` | 83 | inprocess |
|
||||
| `testkit/GrpcUnaryScenario` | 82 | testkit |
|
||||
| `testkit/GrpcStreamingContractResult` · `testkit/GrpcUnaryReliabilityContract` | 81 · 81 | testkit |
|
||||
| `testkit/GrpcEvidenceGrade` | 80 | testkit |
|
||||
| `testkit/GrpcStreamingScenario` | 79 | testkit |
|
||||
| `performance/GrpcPerformanceBudget` | 78 | performance |
|
||||
| `testkit/GrpcUnaryContractResult` | 69 | testkit |
|
||||
| `performance/GrpcPerformanceResult` · `testkit/fault/GrpcFaultScenario` | 66 · 66 | performance / fault |
|
||||
| `release/GrpcReleaseEvidence` | 63 | release |
|
||||
| `testkit/GrpcTextCodec` | 62 | testkit |
|
||||
| `testkit/GrpcServerStreamingContract` | 61 | testkit |
|
||||
| `testkit/fault/GrpcFaultResult` | 58 | fault |
|
||||
| `testkit/fault/GrpcFaultPoint` | 53 | fault |
|
||||
| `release/GrpcReleaseDecision` | 42 | release |
|
||||
|
||||
구획별: `testkit` 8파일 595줄 · `netty` 4파일 579줄 · `inprocess` 3파일 322줄 · `release` 4파일 294줄 · `fault` 4파일 284줄 · `performance` 3파일 239줄.
|
||||
|
||||
main 총 **26파일 / 2,313줄**.
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 26 | `FULL_READ` | 2,313줄. 위 표가 전부 |
|
||||
| `test/java/**` | 8 | `FULL_READ` | 1,339줄 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 66줄. 레인 선언 포함 전문 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
> 이 표는 2026-09-01 재통독에서 파일 단위로 다시 세었다. 이전 판은 구획 다섯의 근사치(`189+`·`3+`)로 적었고, 그 근사 안에 §17.3–§17.6 이 있었다.
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 네 레인이 모듈 넷을 대신한다
|
||||
|
||||
```groovy
|
||||
// build.gradle:3-7
|
||||
// Certification. The Stable plan splits this across four modules (core / in-process / netty /
|
||||
// fault); this repository already expresses "these two runs are not the same kind of evidence" with
|
||||
// strict test lanes rather than with module boundaries, so the four become four lanes over one
|
||||
// leaf. A lane that discovers nothing fails, and none of them can serve an up-to-date result —
|
||||
// which is the property the split was protecting.
|
||||
```
|
||||
|
||||
| 레인 | 태그 | 증거 |
|
||||
|---|---|---|
|
||||
| `grpcInProcessContractTest` | `grpc-inprocess` | 어댑터·인터셉터 순서·상태·멱등 재생. HTTP/2·TLS·전송 한도는 절대 아님 |
|
||||
| `grpcNettyContractTest` | `grpc-netty` | 실제 소켓의 HTTP/2, TLS·상호 TLS, 메타데이터·메시지 하드 한도, GOAWAY, 킵얼라이브, 배수 |
|
||||
| `grpcFaultTest` | `grpc-fault` | 증거 경계마다의 연결 손실과, 관측되지 않은 상태에서 미전송을 추론하기를 거부하는 분류기 |
|
||||
| `grpcPerformanceTest` | `grpc-performance` | 지연 백분위·스트림 포화·실행기 포화·배수 예산 |
|
||||
|
||||
## 2. 증거 등급이 코드 안에서 구분을 유지한다
|
||||
|
||||
`GrpcEvidenceGrade` javadoc:
|
||||
|
||||
> "a claim about TLS backed by `CONTRACT` evidence is refused, because in-process transport never
|
||||
> negotiated one."
|
||||
|
||||
`certifies()` 를 필드가 아니라 계산으로 둔 이유도 적혀 있다 — 컬렉션 필드를 가진 열거형은 어떤 정적 분석에도 가변 열거형으로 보인다.
|
||||
|
||||
## 3. 성능 레인이 기본 test 에서 빠진 이유
|
||||
|
||||
```groovy
|
||||
// The performance lane is excluded from the default `test` task. It measures a running server
|
||||
// under load, and a measurement in the release gate is a flaky test on a shared CI runner; it runs
|
||||
// when somebody asks for it, by name.
|
||||
tasks.named('test') { useJUnitPlatform { excludeTags 'grpc-performance' } }
|
||||
```
|
||||
|
||||
측정을 릴리스 게이트에 넣지 않는다는 판단이 명시적이고, 그 대신 `GrpcPerformanceGate` 가 기록된 기준선과 대조하는 형태로 남는다.
|
||||
|
||||
## 4. 릴리스 게이트 — 문서가 후속이 아니라 차단 사유다
|
||||
|
||||
> "A platform whose failure modes are `COMPLETION_UNKNOWN` and a stream that needs a full resync is
|
||||
> a platform whose on-call has to be told what to do about them; shipping the behaviour and writing
|
||||
> the runbook afterwards means the first person to meet it is the one who has to work it out at
|
||||
> three in the morning."
|
||||
|
||||
차단 사유가 다섯 갈래다 — 호환성 표의 누락 결과, 생산되지 않은 증거 등급, 스키마 발행 거부, 런북 부재, 결정 기록 부재, 지원 표 부재.
|
||||
|
||||
`requireCertified` 는 능력이 이번 릴리스가 낸 증거로 인증되지 않으면 던지고, 메시지에 실제로 돈 등급을 나열한다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
여덟 테스트 1,339줄. 전송 증거 분류기(272줄)가 가장 크고, 그다음이 Netty 계약 프로파일과 단항 신뢰성 계약이다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** 릴리스 게이트·성능 게이트·증거 타입의 소비자는 이 리프의 테스트뿐이다. 그리고 저장소 전체에서 `dev.caskeleton.grpc.testkit` 를 import 하는 파일이 이 리프 밖에 **하나도 없다** — build.gradle 이 grpc 리프 열을 `api` 로 노출하는데, 그 픽스처를 쓰는 리프가 없다. 각 리프가 자기 픽스처를 따로 만든다.
|
||||
|
||||
**12.2 대조군 — 이 저장소의 다른 인증 지형.** messaging 가족은 인증 워크플로를 갖고 있고(`messaging-certification`), 이 가족은 갖고 있지 않다. §17.1.
|
||||
|
||||
**12.3 "레인" 이 두 뜻으로 쓰인다.**
|
||||
|
||||
| 출처 | 이름 | 개수 |
|
||||
|---|---|---:|
|
||||
| `build.gradle`의 `strictTestLanes` | `grpcInProcessContractTest`·`grpcNettyContractTest`·`grpcFaultTest`·`grpcPerformanceTest` | 4 |
|
||||
| `GrpcCompatibilityMatrix.caSkeleton()` | `boot-managed-platform`·`proto3-explicit-optional`·`netty-shaded`·`netty-unshaded`·`upstream-grpc-java-override`·`protobuf-edition-2024`·`protobuf-edition-2026` | 7 |
|
||||
|
||||
교집합이 없다. `missingResults(laneResults)` 가 요구하는 키는 둘째 목록의 것이고, 그것을 만드는 코드는 자기 테스트뿐이다(§17.4).
|
||||
|
||||
**12.4 드리프트.** build.gradle 이 서술한 네 레인이 전부 등록되어 있고, 각각 정확히 하나의 `@Tag` 붙은 테스트 클래스를 갖는다 — `grpc-inprocess`→`GrpcInProcessContractFixtureTest`, `grpc-netty`→`GrpcNettyContractProfileTest`, `grpc-fault`→`GrpcTransportEvidenceClassifierTest`, `grpc-performance`→`GrpcPerformanceLaneTest`.
|
||||
|
||||
**12.5 실제로 소켓을 여는 것과 리터럴로 만드는 것.**
|
||||
|
||||
| 등급 | 실제 실행 | 결과 객체의 출처 |
|
||||
|---|---|---|
|
||||
| CONTRACT | in-process 서버·클라이언트 왕복 ✓ | `GrpcUnaryContractResult`·`GrpcStreamingContractResult` 는 **전부 리터럴**(§17.5) |
|
||||
| TRANSPORT | Netty 소켓·TLS·mTLS·한도·GOAWAY ✓ | 결과 타입 없음. `profile.grade()` 만 단언한다 |
|
||||
| FAULT | 소켓 하나를 작업 중에 죽인다 ✓ | `GrpcExecutionEvidence` 는 **리터럴**(§17.3) |
|
||||
| PERFORMANCE | 200회 측정 ✓ | 측정값은 실제, 예산은 임시값·기준선 없음 |
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 성능 레인을 돌리지 않았다. 기본 `test` 에서 제외되어 있고 부하 측정이 필요하다.
|
||||
- 세 레인은 이전 분석에서 직접 실행해 통과를 확인했다(계약 7 · Netty 9 · 고장 9, 실패 0). 이번 재통독에서는 다시 돌리지 않았다.
|
||||
- §17.3 의 `observedFailure` 경로를 실행으로 확인하지 않았다. 대입과 단언이 같은 메서드 안에 있고 그 사이에 재대입이 없다는 것으로 판정했다.
|
||||
- `keytool` 명령줄 노출(§17.6)을 실제로 `ps` 로 관측하지 않았다. `ProcessBuilder` 인자 목록에 비밀번호가 들어간다는 것으로 판정했다.
|
||||
- `gradle.lockfile` 은 읽지 않았다(`STRUCTURAL_ONLY`).
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P2 — 네 레인이 `check` 에 붙지 않고, 이 가족을 이름으로 부르는 워크플로가 없다
|
||||
|
||||
`ca.strict-test-lane.gradle` 은 레인을 `verification` 그룹의 `Test` 태스크로 **등록만** 한다. `check` 에 연결하는 줄이 없다.
|
||||
|
||||
```
|
||||
tasks.register(lane.name, Test) { group = 'verification'; … }
|
||||
check dependsOn 관련 라인 → 0건
|
||||
```
|
||||
|
||||
그리고 CI 워크플로에서 이 가족을 이름으로 부르는 것이 없다. `ci-quality-gates.yml` 이 `./gradlew check` 를 돌리므로 각 리프의 기본 `test` 는 돈다(이번에 확인: classes=71 tests=579 failures=0). 네 증거 레인은 그 밖에 있다.
|
||||
|
||||
결과적으로 이 플랫폼의 CONTRACT·TRANSPORT·FAULT 등급을 뒷받침하는 것은 25개 테스트이고, 그 25개는 누군가 명령을 직접 입력할 때만 돈다.
|
||||
|
||||
build.gradle 자신이 그 위험을 적는다 — "A lane that discovers nothing fails, and **none of them can serve an up-to-date result**". 첫 성질은 레인 규약이 지킨다. 둘째 성질은 아무도 돌리지 않으면 무의미하다.
|
||||
|
||||
같은 저장소가 이 형태를 두 번 기록했다 — 모듈 18 의 "붉은 게이트는 마지막으로 돌린 사람이 본 것을 보고한다" 와 mongo 가족의 릴리스 게이트 지형. 차이는 이쪽 레인이 오늘 초록이라는 것이고, 그것을 확인한 방법이 이번 분석에서 직접 돌린 것이라는 점이다.
|
||||
|
||||
수정은 세 레인(성능 제외)을 `check` 에 붙이거나, messaging 가족처럼 전용 워크플로를 두는 것이다. 성능 레인을 빼는 판단은 이미 근거와 함께 코드에 있으므로 그대로 두면 된다.
|
||||
|
||||
### 17.2 P3 — 릴리스 게이트의 입력이 전부 호출자가 손으로 만드는 값이다
|
||||
|
||||
```java
|
||||
public GrpcReleaseDecision evaluate(
|
||||
GrpcReleaseEvidence evidence,
|
||||
Map<String, Boolean> laneResults,
|
||||
GrpcSchemaArtifactPublisher.PublishDecision schemaDecision)
|
||||
```
|
||||
|
||||
세 입력 중 어느 것도 실제 레인 결과나 실제 산출물에서 오지 않는다. 게이트를 부르는 곳은 자기 테스트 하나뿐이고, 그 테스트가 세 값을 리터럴로 만든다.
|
||||
|
||||
이 형태 자체는 이 저장소의 다른 게이트와 다르다. mongo 가족의 증거 검증기는 테스트 결과 XML 을 읽고 파일의 수정 시각까지 본다. 이쪽 게이트는 그런 산출물 판독기를 갖지 않는다.
|
||||
|
||||
지금은 무해하다 — 릴리스 절차가 이 게이트를 부르지 않기 때문이다. 기록하는 이유는 §17.1 을 고쳐 레인을 자동으로 돌리게 되면, 그 결과를 이 게이트에 넣어 주는 코드가 함께 필요하다는 점이다.
|
||||
|
||||
### 17.3 P2 — 고장 레인의 유일한 실소켓 시험이 자기가 관측한 것을 버리고 리터럴로 증거를 만든다
|
||||
|
||||
`GrpcTransportEvidenceClassifierTest.aRealConnectionLossAfterAppStartIsCompletionUnknown` 은 이 리프에서 유일하게 실제 연결을 작업 중에 끊는다. 서버 핸들러가 래치로 멈춰 있는 동안 `server.close()` 를 부른다. 거기까지는 진짜 고장이다.
|
||||
|
||||
그런데 그 고장이 만들어 낸 관측이 어디에도 남지 않는다.
|
||||
|
||||
```java
|
||||
StatusRuntimeException observedFailure;
|
||||
try (GrpcNettyTestClient client = …) {
|
||||
Thread caller = new Thread(() -> {
|
||||
try { client.callUnary(CREATE_DESCRIPTOR, "create"); }
|
||||
catch (StatusRuntimeException expected) {
|
||||
// The connection dies underneath this call; the exception is the observation.
|
||||
} // ← 그 "observation" 을 버린다
|
||||
});
|
||||
…
|
||||
observedFailure = null; // ← 무조건 null 을 대입한다
|
||||
}
|
||||
…
|
||||
assertThat(observedFailure).isNull(); // ← 방금 대입한 null 을 단언한다
|
||||
```
|
||||
|
||||
주석이 "the exception is the observation" 이라고 말하는데 그 예외는 `catch` 안에서 사라지고, 변수는 `null` 로 고정되고, 단언은 자기 대입을 확인한다.
|
||||
|
||||
그리고 `GrpcFaultResult` 에 들어가는 증거는 방금 일어난 호출에서 오지 않는다.
|
||||
|
||||
```java
|
||||
GrpcExecutionEvidence evidence = GrpcTransportEvidenceClassifier.classify(
|
||||
CREATE, RpcType.UNARY,
|
||||
GrpcTransportEvidenceClassifier.ClientObservation.sentAndSilent(), // ← 리터럴 팩토리
|
||||
false, GrpcBusinessEvidence.ATTEMPTED);
|
||||
```
|
||||
|
||||
즉 소켓은 실제로 죽었고, 그 죽음에서 읽어 낸 값은 하나도 쓰이지 않는다. 이 시험이 실제로 증명하는 것은 `applicationStarted == true` 하나다. 나머지는 분류기의 산술이고, 그것은 같은 파일의 다른 일곱 시험이 이미 소켓 없이 증명한다.
|
||||
|
||||
이 형태를 이 리프 자신이 이름 붙여 두었다.
|
||||
|
||||
> "a release cannot cite an in-process run as transport evidence. That substitution is the easiest
|
||||
> one to make under time pressure and the hardest to spot afterwards: the suite name says
|
||||
> 'contract', the report says the platform is certified, and nothing in between records that no
|
||||
> socket was opened." — `GrpcReleaseEvidence`
|
||||
|
||||
여기서는 소켓이 열렸다. 그런데 등급을 뒷받침해야 할 증거가 여전히 손으로 쓴 값이다. 한 단계 아래의 같은 치환이다.
|
||||
|
||||
**수정.** `callUnary` 를 부른 스레드가 잡은 예외와 그 시점의 진행 상태를 밖으로 넘겨(`AtomicReference`), 그것으로 `ClientObservation` 을 구성한다. 그러면 `sendCompleted`·`responseHeadersReceived` 가 관측값이 되고, 이 시험이 FAULT 등급을 실제로 뒷받침한다.
|
||||
|
||||
### 17.4 P3 — 호환성 표의 레인 이름과 빌드의 레인 이름이 서로 다른 집합이다
|
||||
|
||||
`GrpcStableReleaseGate.evaluate` 의 둘째 인자는 `Map<String, Boolean> laneResults` 이고, `GrpcCompatibilityMatrix.missingResults` 가 그 키를 자기 목록과 대조한다.
|
||||
|
||||
그 목록은 배포 조합의 이름이다 — `boot-managed-platform`, `netty-shaded`, `upstream-grpc-java-override`, `protobuf-edition-2024` …
|
||||
|
||||
빌드가 등록하는 레인의 이름은 증거 종류다 — `grpcInProcessContractTest`, `grpcNettyContractTest`, `grpcFaultTest`, `grpcPerformanceTest`.
|
||||
|
||||
두 집합의 교집합이 비어 있다. 그래서 §17.1 을 고쳐 네 Gradle 레인을 `check` 에 붙이더라도, 그 결과가 이 게이트의 `laneResults` 를 채우지는 못한다 — 이름이 다른 축을 가리키기 때문이다. 게이트가 요구하는 것은 "Boot 관리 플랫폼 조합에서 돌았는가" 이고, 레인이 답할 수 있는 것은 "전송 증거를 냈는가" 다.
|
||||
|
||||
두 축이 다 필요하다는 것 자체는 옳다. 기록하는 이유는 §17.1·§17.2 의 수정이 이것까지 함께 다루지 않으면 게이트가 여전히 손으로 만든 값을 먹는다는 점이다.
|
||||
|
||||
### 17.5 P3 — 계약 스위트 둘이 결과를 만드는 코드를 갖지 않는다
|
||||
|
||||
`GrpcUnaryReliabilityContract` 와 `GrpcServerStreamingContract` 는 순수 평가기다 — `List<Result>` 를 받아 위반을 돌려준다. 시나리오 정의(단항 3 · 스트리밍 5)와 그 정합성 검사는 훌륭하다. 스위트가 자기 커버리지를 열거하고, 돌지 않은 시나리오를 침묵이 아니라 위반으로 만든다.
|
||||
|
||||
빠진 것은 그 시나리오를 **돌리는** 쪽이다. `GrpcUnaryContractResult`·`GrpcStreamingContractResult` 를 만드는 코드는 저장소 전체에서 두 테스트뿐이고, 둘 다 리터럴로 만든다.
|
||||
|
||||
```java
|
||||
private static GrpcUnaryContractResult result(
|
||||
GrpcUnaryScenario scenario, int attempts, int invocations, GrpcCompletionOutcome outcome) {
|
||||
return new GrpcUnaryContractResult(scenario, GrpcEvidenceGrade.CONTRACT, attempts, invocations, outcome);
|
||||
}
|
||||
```
|
||||
|
||||
그래서 "이 플랫폼은 비멱등 변경을 재시도하지 않는다" 를 뒷받침하는 것은, 그 문장을 리터럴로 적은 뒤 평가기가 그것을 읽고 위반이 없다고 답하는 절차다. 평가기의 산술은 옳고, 대상이 관측이 아니다.
|
||||
|
||||
in-process 픽스처(§1)는 이 시나리오들을 돌릴 재료를 이미 갖고 있다 — 인터셉터를 끼운 서버, 상태 매핑, 스트리밍 핸들러. 수정은 픽스처 위에서 세 시나리오를 실행해 `attempts`·`businessInvocations` 를 세는 러너를 두는 것이다.
|
||||
|
||||
### 17.6 P3 — 던져 버릴 비밀번호를 만들어 놓고 외부 프로세스의 명령줄에 싣는다
|
||||
|
||||
`GrpcTlsTestMaterial` 이 상수 비밀번호를 피하는 이유를 세 줄로 적는다.
|
||||
|
||||
> "A literal password in source is a literal password in source, and a scanner that flags it is
|
||||
> right to — the cost of being correct here is three lines."
|
||||
|
||||
그리고 같은 클래스가 그 값을 `keytool` 인자로 넘긴다.
|
||||
|
||||
```java
|
||||
runKeytool(List.of("-genkeypair", …, "-storepass", new String(password), "-keypass", new String(password)));
|
||||
…
|
||||
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||
```
|
||||
|
||||
프로세스 명령줄은 같은 호스트의 다른 사용자가 `ps` 나 `/proc/<pid>/cmdline` 로 읽을 수 있다. 소스 리터럴보다 관측 가능성이 오히려 높다.
|
||||
|
||||
영향은 작다 — 값이 매번 새로 만들어지고, 키스토어는 임시 디렉터리에 있으며 `close()` 가 지운다. 기록하는 이유는 이 클래스가 정확히 그 위험 계층을 스스로 논증했다는 점이다. 완화와 노출이 같은 메서드 안에 있다.
|
||||
|
||||
`keytool` 은 `-storepass:file` 과 `-keypass:file` 을 받는다. 임시 파일 하나면 명령줄에서 값이 사라진다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **모듈 넷 대신 레인 넷으로 증거 종류를 분리하고, 그 대체의 근거를 적은 것.**
|
||||
- **증거 등급을 열거형으로 두고 각 등급이 무엇을 인증할 수 있는지 계산으로 답한 것.**
|
||||
- **`certifies()` 를 필드가 아니라 계산으로 둔 것과 그 근거.**
|
||||
- **성능 레인을 기본 `test` 에서 제외하고 그 이유를 적은 것** — 공유 러너의 측정은 흔들리는 테스트다.
|
||||
- **문서(런북·결정 기록·지원 표)를 후속이 아니라 차단 사유로 둔 것.**
|
||||
- **`requireCertified` 가 실패 메시지에 실제로 돈 등급을 나열하는 것.**
|
||||
- **실제 전송 의존을 이 리프에만 둔 것** — 다른 리프는 전송 설정 모델만 갖는다.
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/grpc/grpc-testkit/build.gradle:1-66 (레인 넷 · 성능 제외 · api 리프 열)
|
||||
main/java/…/testkit/netty/GrpcTlsTestMaterial.java:1-239 (§17.6 runKeytool:212-239)
|
||||
main/java/…/testkit/inprocess/GrpcInProcessContractFixture.java:1-145
|
||||
main/java/…/testkit/netty/GrpcNettyTestServer.java:1-137
|
||||
main/java/…/testkit/netty/GrpcNettyTestClient.java:1-116
|
||||
main/java/…/testkit/fault/GrpcTransportEvidenceClassifier.java:1-107
|
||||
main/java/…/release/GrpcStableReleaseGate.java:1-96 (§17.2 evaluate:36-72 · §17.4)
|
||||
main/java/…/performance/GrpcPerformanceGate.java:1-95
|
||||
main/java/…/testkit/inprocess/GrpcInProcessTestServer.java:1-94
|
||||
main/java/…/release/GrpcCompatibilityMatrix.java:1-93 (§17.4 caSkeleton:148-158)
|
||||
main/java/…/testkit/netty/GrpcNettyContractProfile.java:1-87
|
||||
main/java/…/testkit/inprocess/GrpcInProcessTestClient.java:1-83
|
||||
main/java/…/testkit/{GrpcUnaryScenario:1-82, GrpcStreamingContractResult:1-81,
|
||||
GrpcUnaryReliabilityContract:1-81, GrpcEvidenceGrade:1-80,
|
||||
GrpcStreamingScenario:1-79, GrpcUnaryContractResult:1-69,
|
||||
GrpcTextCodec:1-62, GrpcServerStreamingContract:1-61} (§17.5)
|
||||
main/java/…/performance/{GrpcPerformanceBudget:1-78, GrpcPerformanceResult:1-66}
|
||||
main/java/…/testkit/fault/{GrpcFaultScenario:1-66, GrpcFaultResult:1-58, GrpcFaultPoint:1-53}
|
||||
main/java/…/release/{GrpcReleaseEvidence:1-63, GrpcReleaseDecision:1-42}
|
||||
test/java/…/testkit/GrpcTransportEvidenceClassifierTest.java:186-273 (§17.3)
|
||||
test/java/…/ 8파일 1,339줄
|
||||
src/config/gradle/ca.strict-test-lane.gradle (레인 등록 · check 미연결)
|
||||
```
|
||||
@@ -0,0 +1,581 @@
|
||||
# messaging-claim-check 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-claim-check`
|
||||
> SSOT owner: `messaging-claim-check`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- registered leaf id: `messaging-claim-check`
|
||||
- canonical state `analysisFile`: `analysis/messaging/messaging-claim-check.md`
|
||||
- source path: `src/messaging/messaging-claim-check`
|
||||
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-reliability-api"]`
|
||||
- registry `runtime_memberships`: **`["app-bootstrap"]`**
|
||||
|
||||
### 숫자
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | 6 |
|
||||
| production LOC | 418 |
|
||||
| 패키지 | 1 (`dev.caskeleton.messaging.claimcheck`) |
|
||||
| test 파일 | 3 |
|
||||
| test 메서드(실행 확인) | 22 |
|
||||
| 외부(비프로젝트) 의존성 | **0** |
|
||||
|
||||
여섯 타입:
|
||||
|
||||
| 타입 | 종류 | 역할 | leaf 밖 참조 |
|
||||
|---|---|---|---:|
|
||||
| `ClaimCheckStore` | interface | payload 저장·조회·삭제 port | **0** |
|
||||
| `ClaimCheckPolicy` | record | 문턱과 보존 규칙 | **0** |
|
||||
| `ClaimCheckPublisher` | class | 발행 측 오프로드 결정 | **0** |
|
||||
| `ClaimCheckResolver` | class | 소비 측 조회 + 검증 | **0** |
|
||||
| `ClaimCheckIntegrityGuard` | class | digest·크기·만료 검사 | **0** |
|
||||
| `ClaimCheckIntegrityException` | exception | digest 불일치 | **0** |
|
||||
|
||||
**여섯 전부 leaf 밖 참조가 0이다.**
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `src/test/java/**` (3) | 3 | `FULL_READ` | 테스트명·fake 구현 확인 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 6줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
**Claim Check 패턴** — 브로커 한계를 넘는 payload를 객체 저장소에 두고 메시지는 참조만 나른다.
|
||||
|
||||
`messaging-reliability-api`의 `ClaimCheckReference`(storageKey·sizeBytes·sha256·expiresAt)를 값 타입으로 쓰고, 이 leaf가 그것을 만들고 검증하는 동작을 소유한다.
|
||||
|
||||
경계 진술이 두 클래스에 있다.
|
||||
|
||||
```java
|
||||
// ClaimCheckIntegrityGuard.java:14-17
|
||||
* <p>A claim check turns one message into two systems that can drift. The payload store has its own
|
||||
* retention, its own replication, and its own access control, and none of them are coordinated with
|
||||
* the broker's. So a consumer that fetches bytes and decodes them without checking is trusting
|
||||
* something the message never proved.
|
||||
```
|
||||
|
||||
```java
|
||||
// ClaimCheckResolver.java:11-15
|
||||
* <p>Verification is not optional and cannot be skipped by a caller. An object store key is a
|
||||
* string, and a message carrying the wrong one — through a bug, a replay against a rotated bucket,
|
||||
* or a deliberate tamper — fetches bytes that decode perfectly into the wrong object. The digest is
|
||||
* the only thing standing between that and a handler acting on someone else's data.
|
||||
```
|
||||
|
||||
**"decode perfectly into the wrong object"**가 이 leaf의 위협 모델이다 — 실패가 아니라 잘못된 성공.
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
들어오는 것: `messaging-core-api`(api), `messaging-reliability-api`(api).
|
||||
|
||||
나가는 것: `messaging-spring-boot-starter`의 `allowed_dependencies`에 포함된다.
|
||||
|
||||
**배선: 없다.** `ClaimCheckStore`의 production 구현이 0이고(유일한 구현은 테스트의 `FakeStore`), `ClaimCheckPublisher`·`ClaimCheckResolver`·`ClaimCheckPolicy` 생성이 leaf 밖에서 0건이다.
|
||||
|
||||
그런데 **`runtime_memberships`가 `["app-bootstrap"]`이다.** starter closure를 통해 배포 아티팩트에 실린다.
|
||||
|
||||
`messaging-cloudevents`와 같은 조합이다 — **싣고 쓰지 않는다**(`analysis/messaging/messaging-cloudevents.md` §12.1).
|
||||
|
||||
---
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
```
|
||||
발행 측
|
||||
ClaimCheckPublisher(store, policy)
|
||||
└── offload(byte[]) → Offloaded(payload, Optional<ClaimCheckReference>)
|
||||
├── policy.shouldOffload(len) == false → Offloaded(payload.clone(), empty)
|
||||
└── true → store.put(payload, retention) → Offloaded(new byte[0], reference)
|
||||
|
||||
소비 측
|
||||
ClaimCheckResolver(store)
|
||||
└── resolve(inline, Optional<reference>, now)
|
||||
├── reference 없음 → inline.clone()
|
||||
├── reference.isExpired(now) → CLAIM_CHECK_EXPIRED
|
||||
├── store.get(reference) == null → CLAIM_CHECK_NOT_FOUND
|
||||
└── guard.verify(...) → 검증된 바이트
|
||||
└── *_MISMATCH → ClaimCheckIntegrityException으로 승격
|
||||
|
||||
정책
|
||||
ClaimCheckPolicy(thresholdBytes, retention, brokerRetention, maxRedeliveryWindow)
|
||||
└── 생성자가 retention >= brokerRetention + maxRedeliveryWindow를 강제
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
### 4.1 `ClaimCheckPolicy` — 보존이 생성자 불변식이다
|
||||
|
||||
```java
|
||||
Duration required = brokerRetention.plus(maxRedeliveryWindow);
|
||||
if (retention.compareTo(required) < 0) {
|
||||
throw new MessagingConfigurationException("CLAIM_CHECK_RETENTION_TOO_SHORT", ...);
|
||||
}
|
||||
```
|
||||
|
||||
javadoc이 이유를 적는다.
|
||||
|
||||
```java
|
||||
// :10-14
|
||||
* <p>The retention rule is the one that matters. A claim check object deleted while its message is
|
||||
* still deliverable turns a large message into an undeliverable one — the consumer fetches, gets
|
||||
* nothing, and the message dead-letters for a reason that has nothing to do with the message. So
|
||||
* retention must exceed the broker's own retention plus the full retry and dead-letter window, and
|
||||
* the constructor refuses a configuration where it does not.
|
||||
```
|
||||
|
||||
**이것이 `messaging-reliability-api`의 `InboxRepository.purgeProcessedBefore` javadoc이 요구하고 강제하지 않는 것과 같은 형태의 규칙인데, 이쪽은 생성자가 강제한다.** 같은 저장소에서 같은 종류의 시간 관계 규칙을 한 곳은 강제하고 한 곳은 문서로만 둔다 — 그 leaf §17이 소유한다.
|
||||
|
||||
문턱과 목적지 payload 상한을 분리한 이유도 명시돼 있다.
|
||||
|
||||
```java
|
||||
// :16-18
|
||||
* <p>The threshold is separate from the destination's payload limit. Offloading starts well below
|
||||
* the limit, because the limit is where the broker refuses the message and the threshold is where
|
||||
* carrying it inline stops being a good idea.
|
||||
```
|
||||
|
||||
`DEFAULT_THRESHOLD_BYTES = 262,144` = 1 MiB의 1/4이고 javadoc이 그렇게 부른다.
|
||||
|
||||
`defaults()`가 브로커 1일 보존 + 1일 재시도 경로에 대해 3일 보존을 준다 — 요구치(2일)보다 1일 여유.
|
||||
|
||||
### 4.2 `ClaimCheckPublisher` — 순서와 미삭제
|
||||
|
||||
```java
|
||||
// :9-17
|
||||
* <p>The object is written <em>before</em> the message is published, and that order is the whole
|
||||
* design. Publishing first would let a consumer receive a reference to an object that does not
|
||||
* exist yet — a race that is rare in a test and routine under load, because the broker hop is
|
||||
* faster than the object store write.
|
||||
*
|
||||
* <p>Nothing here deletes on failure. If the publish is rejected the object is left behind, and the
|
||||
* retention sweep reclaims it; deleting eagerly would delete the object out from under a publish
|
||||
* that turned out to be ambiguous rather than rejected.
|
||||
```
|
||||
|
||||
두 번째가 `messaging-core-api`의 3상태와 직접 연결된다 — `REJECTED`와 `AMBIGUOUS`를 구분할 수 없는 시점에 삭제하면 모호한 발행의 payload를 지운다.
|
||||
|
||||
오프로드된 메시지는 payload를 **아예 갖지 않는다**.
|
||||
|
||||
```java
|
||||
// The published message carries no payload bytes at all, only the reference. Carrying both
|
||||
// would double the transfer for no benefit and let the two disagree.
|
||||
return new Offloaded(new byte[0], Optional.of(reference));
|
||||
```
|
||||
|
||||
`Offloaded` record가 양방향 방어 복사를 한다(생성자 `payload.clone()`, 접근자 `payload.clone()`) — `EncodedMessage`(schema-api)·`OutboxRecord`(reliability-api)와 같은 패턴이다.
|
||||
|
||||
**`ClaimCheckStore.delete`가 이 leaf에서 호출되지 않는다.** 인터페이스에 선언돼 있고 publisher가 의도적으로 안 부른다("Nothing here deletes on failure"). 보존 sweep이 부를 것을 전제하는데 그 sweep이 이 leaf에 없다.
|
||||
|
||||
### 4.3 `ClaimCheckIntegrityGuard` — 세 검사, 전부 fail-closed
|
||||
|
||||
| 순서 | 검사 | 코드 |
|
||||
|---:|---|---|
|
||||
| 1 | `reference.isExpired(now)` | `CLAIM_CHECK_EXPIRED` |
|
||||
| 2 | `payload.length != reference.sizeBytes()` | `CLAIM_CHECK_SIZE_MISMATCH` |
|
||||
| 3 | `sha256(payload) != reference.sha256()` | `CLAIM_CHECK_DIGEST_MISMATCH` |
|
||||
|
||||
```java
|
||||
// :19-22
|
||||
* <p>Both checks fail closed. An expired reference is reported before the fetch, because a
|
||||
* not-found from the store is ambiguous between "reaped" and "never written". A digest mismatch is
|
||||
* reported as validation rather than deserialization, because the bytes are not corrupt JSON — they
|
||||
* are the wrong bytes.
|
||||
```
|
||||
|
||||
크기 검사가 digest보다 먼저인 것이 합리적이다 — 크기 불일치는 SHA-256 계산 없이 즉시 판정된다.
|
||||
|
||||
`sha256(byte[])`가 `HexFormat.of().formatHex(...)`로 **소문자** hex를 만든다. `ClaimCheckReference`의 정규식이 `[a-f0-9]{64}`이므로 두 쪽이 맞는다.
|
||||
|
||||
`verify`가 검증된 payload의 **복사본**을 반환한다.
|
||||
|
||||
### 4.4 `ClaimCheckResolver` — 만료를 fetch 전에 본다
|
||||
|
||||
```java
|
||||
if (claimCheck.isExpired(now)) {
|
||||
// Checked before fetching. A store that still returns the object past its retention would
|
||||
// otherwise hide a misconfiguration until the day the sweep caught up.
|
||||
throw new MessageValidationException("CLAIM_CHECK_EXPIRED", ...);
|
||||
}
|
||||
```
|
||||
|
||||
**저장소가 아직 반환하더라도 거절한다.** 보존 sweep이 늦게 도는 저장소에서 잘못된 설정이 숨는 것을 막는다.
|
||||
|
||||
`fetch`가 `null`을 `CLAIM_CHECK_NOT_FOUND`로 번역하고 메시지가 두 원인을 나열한다 — "it was either reaped early or never written".
|
||||
|
||||
**예외 승격이 코드 접미사로 판정된다.**
|
||||
|
||||
```java
|
||||
} catch (MessageValidationException validation) {
|
||||
// A size or digest mismatch is a poison message, not a validation failure to be retried:
|
||||
// fetching the same key again returns the same wrong bytes.
|
||||
if (validation.failure().code().endsWith("_MISMATCH")) {
|
||||
throw new ClaimCheckIntegrityException(
|
||||
validation.failure().code(), validation.failure().sanitizedMessage());
|
||||
}
|
||||
throw validation;
|
||||
}
|
||||
```
|
||||
|
||||
`endsWith("_MISMATCH")` — **문자열 접미사로 분기한다.** guard가 코드 이름을 바꾸거나 `_MISMATCH`로 끝나는 다른 코드를 추가하면 분류가 조용히 달라진다. §17.
|
||||
|
||||
### 4.5 `ClaimCheckIntegrityException` — 카테고리가 `POISON_MESSAGE`
|
||||
|
||||
```java
|
||||
// :12-18
|
||||
* <p>Not retryable. A digest mismatch means the object at that key is not the object the producer
|
||||
* wrote — the key was reused, the object was overwritten, or something truncated it — and fetching
|
||||
* it again returns the same wrong bytes. Retrying would only delay the dead-letter.
|
||||
*
|
||||
* <p>Deliberately distinct from "the object is gone". An expired claim check is an operational
|
||||
* problem with a known cause and a known fix; a digest mismatch means something wrote data nobody
|
||||
* expected, and the two must not be diagnosed as one.
|
||||
```
|
||||
|
||||
`FailureCategory.POISON_MESSAGE`, `retryable = false`. `messaging-core-api`의 `FailureDescriptor.defaultRetryable`이 `POISON_MESSAGE`를 false로 두는 것과 일치한다.
|
||||
|
||||
**이 예외가 `MessagingException`을 확장하는 저장소 내 두 곳 중 하나다**(다른 하나는 core-api 자신의 23개). `analysis/messaging/messaging-core-api.md` §12.1(b)가 그 사실을 관측했다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
**발행:** `publisher.offload(encodedPayload)` → 문턱 이하면 인라인 → 초과면 `store.put` → `Offloaded(빈 바이트, reference)`
|
||||
|
||||
**소비:** `resolver.resolve(inline, reference, now)` → reference 없으면 인라인 → 만료 확인 → `store.get` → null이면 NOT_FOUND → `guard.verify`(만료·크기·digest) → `_MISMATCH`면 `ClaimCheckIntegrityException`
|
||||
|
||||
두 경로 모두 production에서 호출되지 않는다(§12.1).
|
||||
|
||||
---
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
| 코드 | 예외 | 카테고리 | retryable | 조건 |
|
||||
|---|---|---|:---:|---|
|
||||
| `CLAIM_CHECK_RETENTION_TOO_SHORT` | `MessagingConfigurationException` | `CONFIGURATION` | false | 정책 생성 시 |
|
||||
| `CLAIM_CHECK_EXPIRED` | `MessageValidationException` | `PERMANENT_BUSINESS` | false | 만료 |
|
||||
| `CLAIM_CHECK_NOT_FOUND` | `MessageValidationException` | `PERMANENT_BUSINESS` | false | 객체 없음 |
|
||||
| `CLAIM_CHECK_SIZE_MISMATCH` | `ClaimCheckIntegrityException` | **`POISON_MESSAGE`** | false | 크기 불일치 |
|
||||
| `CLAIM_CHECK_DIGEST_MISMATCH` | `ClaimCheckIntegrityException` | **`POISON_MESSAGE`** | false | digest 불일치 |
|
||||
|
||||
**분류가 두 단계로 정확하다.** 만료·부재는 운영 문제(`PERMANENT_BUSINESS`), 크기·digest 불일치는 오염(`POISON_MESSAGE`). 두 예외 클래스와 두 카테고리가 그 구분을 담는다.
|
||||
|
||||
`ClaimCheckIntegrityGuard.sha256`이 `NoSuchAlgorithmException`을 `IllegalStateException("Java runtime does not provide SHA-256")`으로 감싼다 — 복구 불가능한 환경 문제이므로 메시지 실패가 아니다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
트랜잭션 없음.
|
||||
|
||||
`ClaimCheckPublisher`·`ClaimCheckResolver`는 final 필드만 갖는 불변 객체다. `ClaimCheckIntegrityGuard`는 상태가 없고 `ClaimCheckResolver`가 인스턴스를 필드로 하나 만든다.
|
||||
|
||||
`MessageDigest.getInstance("SHA-256")`이 **호출마다** 새 인스턴스를 만든다 — `MessageDigest`는 스레드 안전하지 않으므로 이것이 옳다. 재사용했다면 동시 호출이 서로의 상태를 오염시킨다.
|
||||
|
||||
`ClaimCheckStore` 구현의 스레드 안전성 요구는 인터페이스 javadoc에 없다.
|
||||
|
||||
수명주기 참여 없음.
|
||||
|
||||
---
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
| 상수/기본값 | 값 |
|
||||
|---|---|
|
||||
| `ClaimCheckPolicy.DEFAULT_THRESHOLD_BYTES` | 262,144 (1 MiB의 1/4) |
|
||||
| `ClaimCheckPolicy.defaults()` | 문턱 256 KiB, 보존 3일, 브로커 보존 1일, 재전달 창 1일 |
|
||||
|
||||
설정 파일 없음. 모든 값이 생성자 인자다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
`ClaimCheckStore`가 객체 저장소를 가리키는 port다. **구현이 없다** — production에도, 다른 messaging leaf에도.
|
||||
|
||||
저장소의 `adapter/outbound/objectstorage` leaf가 후보 구현처이지만 두 leaf가 연결되지 않는다(`messaging-claim-check`의 `allowed_dependencies`에 없고, 반대 방향도 없다).
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
레인: `./gradlew :messaging:messaging-claim-check:test`. **BUILD SUCCESSFUL, 22 tests, 0 skipped, 0 failures**.
|
||||
|
||||
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|
||||
|---|---:|---|---|
|
||||
| `ClaimCheckIntegrityGuardTest` | 6 | 만료·크기·digest 세 검사 | 실제 저장소 |
|
||||
| `ClaimCheckResolverTest` | 8 | 인라인 통과, 만료 사전 거절, NOT_FOUND, `_MISMATCH` 승격 | **production 호출 여부** |
|
||||
| `ClaimCheckRetentionValidatorTest` | 8 | 보존 불변식과 문턱 판정 | — |
|
||||
|
||||
`ClaimCheckStore`의 유일한 구현이 `ClaimCheckResolverTest:22`의 `FakeStore`다. 즉 **이 leaf의 테스트가 자기 port의 유일한 구현을 제공한다.**
|
||||
|
||||
`ClaimCheckPublisher`를 겨냥한 테스트 클래스가 **없다.** 오프로드 결정·객체 선기록 순서·`Offloaded`의 방어 복사가 이 레인에서 검증되지 않는다. 세 테스트 클래스 이름에 publisher가 없다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
| 게이트 | 이 leaf에 대해 |
|
||||
|---|---|
|
||||
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-reliability-api"]` |
|
||||
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
|
||||
| vendor `api` 규칙 | 벤더 의존성 0 |
|
||||
| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |
|
||||
| ArchUnit | 전용 규칙 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 실제 사용 여부와 negative-space probes
|
||||
|
||||
원시 증거: `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt`.
|
||||
|
||||
### 12.1 Public surface reachability
|
||||
|
||||
**여섯 타입 전부 leaf 밖 참조 0이다.**
|
||||
|
||||
| 타입 | leaf 밖 |
|
||||
|---|---:|
|
||||
| `ClaimCheckStore` | 0 |
|
||||
| `ClaimCheckPolicy` | 0 |
|
||||
| `ClaimCheckPublisher` | 0 |
|
||||
| `ClaimCheckResolver` | 0 |
|
||||
| `ClaimCheckIntegrityGuard` | 0 |
|
||||
| `ClaimCheckIntegrityException` | 0 |
|
||||
|
||||
`ClaimCheckStore` 구현은 테스트 fake 하나뿐이고, 세 클래스의 생성이 leaf 밖에서 0건이다.
|
||||
|
||||
**그런데 이 leaf는 배포 아티팩트에 실린다.**
|
||||
|
||||
```
|
||||
messaging-claim-check runtime_memberships=['app-bootstrap']
|
||||
messaging-spring-boot-starter runtime_memberships=['app-bootstrap']
|
||||
starter deps include claim-check: True
|
||||
```
|
||||
|
||||
`messaging-cloudevents`와 같은 조합이다. 형제 비교:
|
||||
|
||||
| leaf | 소비자 | membership | 정합 |
|
||||
|---|:---:|---|---|
|
||||
| `messaging-schema-avro` | 0 | `[]` | o |
|
||||
| `messaging-schema-protobuf` | 0 | `[]` | o |
|
||||
| `messaging-kafka-share-experimental` | 0 | `[]` | o |
|
||||
| **`messaging-cloudevents`** | **0** | **`["app-bootstrap"]`** | **x** |
|
||||
| **`messaging-claim-check`** | **0** | **`["app-bootstrap"]`** | **x** |
|
||||
|
||||
**"싣고 쓰지 않는" leaf가 둘이다.** 오늘 실행되는 코드가 없으므로 사고는 아니다.
|
||||
|
||||
**한 가지 정황이 이 leaf를 다르게 만든다.** `messaging-policy`의 `PayloadPolicy`가 `claimCheckThresholdBytes` 필드를 갖고, `DestinationProfileValidator`가 그 값을 검사한다(`:49`). 즉 **목적지 프로파일은 claim check를 상정하고 있는데 그 상정을 실현하는 코드가 배선되지 않았다.** payload가 문턱을 넘어도 오프로드되지 않고, `PayloadLimitGuard`가 상한 초과로 거절한다 — `MessageTooLargeException("PAYLOAD_LIMIT_EXCEEDED", "... use claim check")`. **에러 메시지가 존재하지 않는 경로를 권한다.**
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
Spring 주석 0개, bean 없음. starter가 이 leaf의 타입으로 만드는 bean도 없다.
|
||||
|
||||
`messaging-reliability-api`의 세 port 중 둘(`OutboxRepository`, `InboxRepository`)은 구현 leaf와 starter bean을 갖고 `ClaimCheckStore`는 둘 다 없다 — 같은 계열의 port 셋 중 하나만 미완이다.
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
**(a) claim check 문턱이 두 곳에 있고 서로를 모른다**
|
||||
|
||||
| 위치 | 필드 | 검사 |
|
||||
|---|---|---|
|
||||
| `messaging-policy` `PayloadPolicy` | `claimCheckThresholdBytes` | `DestinationProfileValidator:49`가 `<= maxBytes` 확인 |
|
||||
| 이 leaf `ClaimCheckPolicy` | `thresholdBytes` | 생성자가 `>= 1` 확인 |
|
||||
|
||||
**두 값을 대조하는 코드가 없다.** 목적지 프로파일이 문턱 512 KiB를 선언하고 `ClaimCheckPolicy`가 256 KiB를 쓰면 둘 다 유효한 구성이고 실제 동작은 후자를 따른다. 오늘은 후자가 배선되지 않아 전자만 존재하므로 충돌하지 않는다.
|
||||
|
||||
**(b) 보존/시간 관계 규칙이 두 곳에 있고 강제 강도가 다르다**
|
||||
|
||||
| 규칙 | 위치 | 강제 |
|
||||
|---|---|---|
|
||||
| claim check 보존 ≥ 브로커 보존 + 재전달 창 | `ClaimCheckPolicy` 생성자 | **강제됨** |
|
||||
| inbox 보존 > 브로커 최대 재전달 창 | `InboxRepository` javadoc | **문서만** |
|
||||
|
||||
같은 종류의 규칙(“보존이 재전달 창보다 길어야 한다”)을 한 leaf는 생성자로 막고 다른 leaf는 문서로만 둔다. `analysis/messaging/messaging-reliability-api.md` §17이 후자를 소유한다.
|
||||
|
||||
**(c) digest 계산이 저장소에 여럿 있는가**
|
||||
|
||||
`MessageDigest.getInstance("SHA-256")`을 쓰는 곳이 저장소에 여럿 있다(objectstorage, fileserver 등). 그러나 책임이 다르고(무결성 검증 vs 콘텐츠 주소화) runtime eligibility가 겹치지 않는다. 중복 경쟁 아님.
|
||||
|
||||
**(d) `_MISMATCH` 접미사 분기**
|
||||
|
||||
`ClaimCheckResolver.verify`가 `validation.failure().code().endsWith("_MISMATCH")`로 예외를 승격한다. `ClaimCheckIntegrityGuard`의 코드 셋 중 둘이 그 접미사를 갖고 하나(`CLAIM_CHECK_EXPIRED`)가 갖지 않는다. **문자열 규약이 두 클래스 사이의 계약이 되어 있고 그것이 어디에도 선언되지 않았다.** §17.
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
| 문서 주장 | 재측정 | 결과 |
|
||||
|---|---|---|
|
||||
| `ClaimCheckPolicy` javadoc: 문턱이 "a quarter of the portable payload limit" | 262,144 = 1,048,576 / 4 | **일치** |
|
||||
| `ClaimCheckPublisher` javadoc: 실패 시 삭제하지 않고 보존 sweep이 회수 | 이 leaf에 sweep 없음 | **미실현** |
|
||||
| `ClaimCheckIntegrityGuard` javadoc: 두 검사가 fail closed | 세 검사 전부 예외 | **일치**(검사가 셋인데 javadoc은 "Both") |
|
||||
| `PayloadLimitGuard` 에러 메시지: "use claim check" | claim check 경로 미배선 | **불일치** |
|
||||
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) |
|
||||
|
||||
세 번째가 작은 표현 drift다 — javadoc이 "Both checks fail closed"라고 하는데 `verify`는 만료·크기·digest 셋을 검사한다. 크기 검사가 나중에 추가된 것으로 보인다.
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
이 leaf의 javadoc은 이전 결함을 서술하지 않는다 — 대신 **막으려는 사고**를 서술한다.
|
||||
|
||||
| 위치 | 막으려는 것 |
|
||||
|---|---|
|
||||
| `ClaimCheckPublisher` | 발행 후 저장 순서 → 존재하지 않는 객체의 참조를 소비자가 받음. "rare in a test and routine under load" |
|
||||
| `ClaimCheckPublisher` | 실패 시 즉시 삭제 → 모호한 발행의 payload를 지움 |
|
||||
| `ClaimCheckResolver` | 검증 없는 fetch → 잘못된 키가 완벽히 디코딩되는 다른 객체를 반환 |
|
||||
| `ClaimCheckResolver` | fetch 후 만료 확인 → sweep이 늦은 저장소에서 오설정이 숨음 |
|
||||
| `ClaimCheckPolicy` | 짧은 보존 → 메시지와 무관한 이유로 dead-letter |
|
||||
| `ClaimCheckIntegrityException` | 만료와 불일치를 한 진단으로 합침 |
|
||||
|
||||
**"rare in a test and routine under load"**가 이 저장소 전반의 주제다 — `messaging-observability`의 카디널리티, `messaging-security`의 회전 경합, `messaging-transport-spi`의 자원 누수가 같은 형태다.
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` §A·§B | 여섯 타입 참조 0, `ClaimCheckStore` 구현이 테스트 fake뿐, membership과 starter 의존, 두 문턱과 검사 위치 | 정적 검색 |
|
||||
| EVD-291 | command | `./gradlew :messaging:messaging-claim-check:test --rerun-tasks` | BUILD SUCCESSFUL, 22 / 0 / 0 | 저장소가 fake. publisher 미검증 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적**
|
||||
|
||||
- 두 시스템이 drift한다는 위협 모델 — `ClaimCheckIntegrityGuard` javadoc
|
||||
- 검증이 선택 불가인 이유 — `ClaimCheckResolver` javadoc
|
||||
- 저장이 발행보다 먼저인 이유 — `ClaimCheckPublisher` javadoc
|
||||
- 실패 시 삭제하지 않는 이유 — 같은 javadoc
|
||||
- payload와 참조를 함께 나르지 않는 이유 — 인라인 주석
|
||||
- 만료를 fetch 전에 보는 이유 — `resolve` 인라인 주석
|
||||
- 보존 규칙과 그것을 생성자가 강제하는 이유 — `ClaimCheckPolicy` javadoc
|
||||
- 문턱과 목적지 상한이 다른 이유 — 같은 javadoc
|
||||
- digest 불일치가 재시도 불가인 이유, 만료와 구분하는 이유 — `ClaimCheckIntegrityException` javadoc
|
||||
- `_MISMATCH` 승격이 poison message인 이유 — `verify` 인라인 주석
|
||||
|
||||
**추론**
|
||||
|
||||
- 배선되지 않은 것이 미완인지 확장점인지 → **미상**. `ClaimCheckStore` 구현이 없다는 관측만 있다.
|
||||
- `_MISMATCH` 접미사 규약이 의도인지 → **미상**. 선언된 곳이 없다.
|
||||
- javadoc의 "Both checks"가 세 검사가 되기 전 표현인지 → **추론**.
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- 6개 타입 418줄 전문의 계약
|
||||
- 22개 테스트가 통과하고 무엇을 단언하는지, 그리고 `ClaimCheckPublisher`가 미검증이라는 것
|
||||
- 여섯 타입 전부 leaf 밖 참조 0이고 `ClaimCheckStore` 구현이 테스트 fake뿐이라는 것
|
||||
- `runtime_memberships`가 `["app-bootstrap"]`이라 배포 아티팩트에 실린다는 것
|
||||
- `PayloadLimitGuard`의 에러 메시지가 배선되지 않은 경로를 권한다는 것
|
||||
- 문턱이 두 곳에 있고 대조되지 않는다는 것
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- **`ClaimCheckStore`를 구현할 계획이 있는지.** `adapter/outbound/objectstorage`가 후보이지만 두 leaf가 registry에서 연결되지 않는다.
|
||||
- 보존 sweep을 누가 도는지 — `ClaimCheckStore.delete`의 호출자가 없다.
|
||||
- 실제 객체 저장소에서 `store.get`이 만료 후에도 반환하는지 — `resolve`의 사전 만료 검사가 그 경우를 상정한다.
|
||||
- 두 문턱이 실제 배포에서 어긋나는지 — 한쪽이 배선되지 않아 관측 불가.
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P2 — 배포 아티팩트가 싣지만 아무도 부르지 않고, 다른 곳의 에러 메시지가 이 경로를 권한다
|
||||
|
||||
- **사실.** 여섯 타입 전부 leaf 밖 참조 0, `ClaimCheckStore` 구현이 테스트 fake뿐, 조립 0건. 그런데 `runtime_memberships`가 `["app-bootstrap"]`이고 starter의 `allowed_dependencies`에 포함된다. 그리고 `messaging-policy`의 `PayloadLimitGuard`가 상한 초과 payload를 거절하며 `"payload of %d bytes exceeds the %d byte limit for %s; use claim check"`라고 안내한다.
|
||||
- **근거.** `evidence/raw/290` §A. `PayloadLimitGuard.java:46-49`.
|
||||
- **왜 문제인가.** 운영자가 상한 초과 오류를 보고 안내대로 claim check를 켜려 해도 켤 것이 없다 — 저장소 구현도, bean도, 오프로드를 부르는 발행 경로도 없다. 그리고 `DestinationProfile`이 `claimCheckThresholdBytes`를 선언하고 검증까지 하므로 **설정 표면은 존재한다.** 설정할 수 있고 아무 효과가 없는 값이다.
|
||||
- **확인 방법.** `evidence/raw/290` §A 재실행. `git grep -n 'use claim check' -- src`.
|
||||
- **후보.** (a) `ClaimCheckStore` 구현(objectstorage 어댑터 경유)과 발행 경로 배선. (b) 배선 전까지 membership을 `[]`로 되돌리고 `PayloadLimitGuard` 메시지에서 안내를 뺀다. (c) 미완임을 `support-matrix.md`에 표시한다.
|
||||
- **다음 단계.** **CASE 후보.** `messaging-cloudevents` §17의 "싣고 쓰지 않는다"와 같은 계열이지만, 여기서는 **다른 컴포넌트가 이 경로를 권한다**는 점이 추가된다.
|
||||
|
||||
### P3 — claim check 문턱이 두 곳에서 독립적으로 정해진다
|
||||
|
||||
- **사실.** `messaging-policy`의 `PayloadPolicy.claimCheckThresholdBytes`(목적지별, `DestinationProfileValidator:49`가 검사)와 이 leaf의 `ClaimCheckPolicy.thresholdBytes`(전역). 두 값을 대조하는 코드가 없다.
|
||||
- **근거.** `evidence/raw/290` §B.
|
||||
- **왜 문제인가.** 배선되면 실제 동작은 후자를 따르고 전자는 선언만 남는다. 목적지별로 다른 문턱을 두려던 설계가 전역 정책 하나에 덮인다.
|
||||
- **확인 방법.** 두 필드와 검증기 확인.
|
||||
- **후보.** `ClaimCheckPublisher`가 목적지 프로파일의 값을 읽거나, `PayloadPolicy`에서 그 필드를 제거한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다).
|
||||
|
||||
### P3 — 예외 승격이 에러 코드 문자열 접미사에 의존한다
|
||||
|
||||
- **사실.** `ClaimCheckResolver.verify`가 `validation.failure().code().endsWith("_MISMATCH")`로 `ClaimCheckIntegrityException` 승격을 결정한다. `ClaimCheckIntegrityGuard`의 세 코드 중 둘이 그 접미사를 갖는다.
|
||||
- **근거.** `ClaimCheckResolver.java:84`.
|
||||
- **왜 문제인가.** 두 클래스 사이의 계약이 **문자열 명명 규약**이고 어디에도 선언되지 않았다. guard가 코드를 바꾸면(예: `CLAIM_CHECK_DIGEST_INVALID`) 승격이 조용히 멈추고 poison message가 `PERMANENT_BUSINESS`로 분류된다 — 재시도 정책이 달라진다.
|
||||
- **확인 방법.** `git grep -n '_MISMATCH' -- src/messaging/messaging-claim-check`
|
||||
- **후보.** guard가 두 종류의 예외를 직접 던지거나, 코드 집합을 상수로 선언하고 그것과 비교한다.
|
||||
- **다음 단계.** **CASE 후보 + REFERENCE 후보**(타입 사이의 계약을 문자열 명명 규약으로 표현하지 않는다).
|
||||
|
||||
### P3 — `ClaimCheckPublisher`가 이 leaf의 테스트에 등장하지 않는다
|
||||
|
||||
- **사실.** 세 테스트 클래스가 guard·resolver·policy를 겨냥한다. publisher 전용 테스트가 없다.
|
||||
- **근거.** `find src/test -name '*Test.java'` → 셋.
|
||||
- **왜 문제인가.** publisher가 소유한 결정 셋이 미검증이다 — 오프로드 판정(`shouldOffload`), 오프로드 시 payload를 비우는 것, `Offloaded`의 양방향 방어 복사. 특히 "저장이 발행보다 먼저"라는 순서는 publisher의 계약인데 그것을 확인하는 테스트가 없다.
|
||||
- **확인 방법.** 세 테스트 클래스 이름 확인.
|
||||
- **후보.** `ClaimCheckPublisherTest`를 추가한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다).
|
||||
|
||||
### P3 — 보존 sweep이 없다
|
||||
|
||||
- **사실.** `ClaimCheckStore.delete`가 선언돼 있고 이 leaf에서 호출되지 않는다. `ClaimCheckPublisher` javadoc이 "the retention sweep reclaims it"이라고 그 존재를 전제한다.
|
||||
- **근거.** `git grep -n 'delete(' -- src/messaging/messaging-claim-check` → 인터페이스 선언만.
|
||||
- **왜 문제인가.** 실패한 발행이 남긴 객체를 회수할 주체가 없다. 저장소 자체의 lifecycle 정책(예: S3 object expiration)이 대신할 수 있으나 `ClaimCheckPolicy.retention`이 그것과 연결되지 않는다.
|
||||
- **확인 방법.** `delete` 호출자 검색.
|
||||
- **후보.** sweep 작업을 만들거나, 저장소 lifecycle에 위임함을 javadoc에 명시한다.
|
||||
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 `ClaimCheckStore` 구현 계획에 걸린다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- 보존 규칙(보존 ≥ 브로커 보존 + 재전달 창)을 생성자가 강제하는 것
|
||||
- 문턱과 목적지 상한을 분리하고 그 이유를 적은 것
|
||||
- 객체를 발행보다 먼저 저장하는 순서
|
||||
- 실패 시 삭제하지 않아 모호한 발행의 payload를 지키는 것
|
||||
- 오프로드 시 payload를 아예 비워 둘이 어긋날 여지를 없앤 것
|
||||
- 만료를 fetch 전에 확인해 저장소의 늦은 sweep이 오설정을 숨기지 않게 하는 것
|
||||
- 크기 검사를 digest보다 먼저 두는 것
|
||||
- 만료·부재와 크기·digest 불일치를 다른 카테고리로 분류하는 것
|
||||
- `MessageDigest`를 호출마다 새로 만드는 것
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MCC-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 2개, memberships `["app-bootstrap"]` | 선언 |
|
||||
| MCC-002 | build | `messaging-claim-check/build.gradle` | same | 벤더 의존성 0 | — |
|
||||
| MCC-003 | code | `.../claimcheck/ClaimCheckPolicy.java` | same | §4.1 보존 불변식과 문턱 | — |
|
||||
| MCC-004 | code | `.../claimcheck/ClaimCheckPublisher.java` | same | §4.2 순서·미삭제·빈 payload | 전용 테스트 없음 |
|
||||
| MCC-005 | code | `.../claimcheck/ClaimCheckIntegrityGuard.java` | same | §4.3 세 검사 | — |
|
||||
| MCC-006 | code | `.../claimcheck/ClaimCheckResolver.java` | same | §4.4 사전 만료 확인, 접미사 승격 | 접미사 의존(§17) |
|
||||
| MCC-007 | code | `.../claimcheck/{ClaimCheckStore,ClaimCheckIntegrityException}.java` | same | port 계약, POISON_MESSAGE 분류 | 구현 없음 |
|
||||
| MCC-008 | test | 3 클래스 / 22 테스트 | same | §10 표 | fake 저장소. publisher 미검증 |
|
||||
| MCC-009 | cross-leaf code | `messaging-policy/.../PayloadLimitGuard.java:46-49` | same | "use claim check" 안내 | 해당 leaf SSOT가 소유 |
|
||||
| MCC-010 | cross-leaf code | `messaging-policy/.../PayloadPolicy.java:14`, `DestinationProfileValidator.java:49` | same | 두 번째 문턱과 그 검증 | 해당 leaf SSOT가 소유 |
|
||||
| EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` | same | §12.1·§12.3 | 정적 검색 |
|
||||
| EVD-291 | command | `./gradlew :messaging:messaging-claim-check:test --rerun-tasks` | same | 22 / 0 / 0 | — |
|
||||
@@ -0,0 +1,594 @@
|
||||
# messaging-cloudevents 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-cloudevents`
|
||||
> SSOT owner: `messaging-cloudevents`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- registered leaf id: `messaging-cloudevents`
|
||||
- canonical state `analysisFile`: `analysis/messaging/messaging-cloudevents.md`
|
||||
- source path: `src/messaging/messaging-cloudevents`
|
||||
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api"]`
|
||||
- registry `runtime_memberships`: **`["app-bootstrap"]`**
|
||||
|
||||
### 숫자
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | 3 |
|
||||
| production LOC | 228 |
|
||||
| 패키지 | 1 (`dev.caskeleton.messaging.cloudevents`) |
|
||||
| test 파일 | 1 |
|
||||
| test 메서드(실행 확인) | 7 |
|
||||
| 외부 의존성 | 2 (`cloudevents-api:4.0.1` **api**, `cloudevents-core:4.0.1` implementation) |
|
||||
|
||||
세 타입: `CloudEventMapper`(인터페이스), `DefaultCloudEventMapper`(구현), `CloudEventExtensions`(확장 속성 이름 4개).
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `.../cloudevents/CloudEventMapper.java` | 1 | `FULL_READ` | 33줄 전문 |
|
||||
| `.../cloudevents/DefaultCloudEventMapper.java` | 1 | `FULL_READ` | 171줄 전문 |
|
||||
| `.../cloudevents/CloudEventExtensions.java` | 1 | `FULL_READ` | 24줄 전문 |
|
||||
| `src/test/java/**` | 1 | `FULL_READ` | 162줄 전문 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 주석 포함 17줄 |
|
||||
| `gradle.lockfile` | 1 | `FULL_READ` | cloudevents 좌표 2건 확인 |
|
||||
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
플랫폼 봉투와 CloudEvents 1.0.2 사이의 양방향 매퍼.
|
||||
|
||||
적용 범위를 인터페이스 javadoc이 한정한다.
|
||||
|
||||
```java
|
||||
// CloudEventMapper.java:11-13
|
||||
* <p>Offered for domain and integration events only. Commands and work items are not forced through
|
||||
* CloudEvents: they are internal contracts where the interoperability the specification buys does
|
||||
* not pay for the attributes it requires.
|
||||
```
|
||||
|
||||
의존성 선언에 이 저장소에서 가장 자세한 근거 주석이 붙어 있다.
|
||||
|
||||
```groovy
|
||||
// api, because CloudEventMapper's public signatures return io.cloudevents.CloudEvent.
|
||||
//
|
||||
// Declared `implementation`, the type appeared in this module's public API while the
|
||||
// dependency was hidden from consumers: an adopter calling the documented method could not
|
||||
// name its return type without adding CloudEvents to their own build, and Gradle gave them no
|
||||
// hint why. A type in a public signature is part of the artifact's contract.
|
||||
api 'io.cloudevents:cloudevents-api:4.0.1'
|
||||
implementation 'io.cloudevents:cloudevents-core:4.0.1'
|
||||
```
|
||||
|
||||
**둘의 scope가 다른 것이 정확하다.** `cloudevents-api`(`CloudEvent`, `CloudEventData`)는 public 시그니처에 나오므로 `api`, `cloudevents-core`(`CloudEventBuilder`, `BytesCloudEventData`)는 구현 안에서만 쓰이므로 `implementation`이다. `src/messaging/CLAUDE.md:40-43`의 게이트가 잡는 구분을 두 좌표로 나눠 지켰다.
|
||||
|
||||
**이 leaf의 위치가 형제들과 다르다.** `runtime_memberships`가 `["app-bootstrap"]`이다 — 배포 아티팩트가 싣는다. 그런데 소비자가 하나도 없다(§12.1). Avro·Protobuf는 "싣지도 않고 쓰지도 않는다"로 정합하지만, 이 leaf는 **싣고 쓰지 않는다.**
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `cloudevents-api:4.0.1`(api), `cloudevents-core:4.0.1`(implementation).
|
||||
|
||||
나가는 것: `messaging-spring-boot-starter`의 `allowed_dependencies`에 포함된다. 그래서 `app-bootstrap` → starter → 이 leaf 경로로 런타임 classpath에 오른다.
|
||||
|
||||
**그러나 어떤 코드도 이 leaf의 타입을 부르지 않는다.** starter의 어느 `@Bean`도 `CloudEventMapper`를 만들지 않고, 어느 클래스도 import하지 않는다(§12.1).
|
||||
|
||||
bean 없음(Spring 주석 0개).
|
||||
|
||||
---
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
```
|
||||
CloudEventMapper (interface)
|
||||
├── toCloudEvent(MessageEnvelope<?>, URI) → CloudEvent
|
||||
└── fromCloudEvent(CloudEvent) → MessageEnvelope<EncodedMessage>
|
||||
|
||||
DefaultCloudEventMapper (구현)
|
||||
├── toCloudEvent : occurredAt 필수, payload는 이미 인코딩된 것만
|
||||
├── fromCloudEvent : time 필수, schemaversion 확장 필수
|
||||
├── stringExtension / intExtension
|
||||
└── producerFrom(URI) : 마지막 세그먼트를 producer id로
|
||||
|
||||
CloudEventExtensions (상수 4개)
|
||||
correlationid · causationid · schemaversion · tenantcontext
|
||||
```
|
||||
|
||||
`CloudEventExtensions`의 javadoc이 이름이 봉투 필드명과 다른 이유를 적는다 — "CloudEvents requires extension names to be lowercase alphanumeric".
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
### 4.1 매핑 표
|
||||
|
||||
**봉투 → CloudEvent**
|
||||
|
||||
| 봉투 | CloudEvent | 비고 |
|
||||
|---|---|---|
|
||||
| `messageId.value()` | `id` (String) | UUID 문자열 |
|
||||
| — | `source` | 호출자가 인자로 준다 |
|
||||
| `messageType.value()` | `type` | |
|
||||
| `occurredAt` | `time` | **필수** — 없으면 거절 |
|
||||
| `contentType.value()` | `datacontenttype` | |
|
||||
| `schemaVersion.value()` | 확장 `schemaversion` | 문자열로 |
|
||||
| `correlationId` | 확장 `correlationid` | 있을 때만 |
|
||||
| `causationId` | 확장 `causationid` | 있을 때만 |
|
||||
| `tenantContext.tenantId()` | 확장 `tenantcontext` | 있을 때만 |
|
||||
| `payload`의 `schemaReference.schemaUri` | `dataschema` | 있을 때만 |
|
||||
| `payload` | `data` | `EncodedMessage` 또는 `byte[]`만 |
|
||||
|
||||
**CloudEvent → 봉투**
|
||||
|
||||
| CloudEvent | 봉투 | 비고 |
|
||||
|---|---|---|
|
||||
| `id` | `messageId` | `UUID.fromString` → `MessageId`(**UUIDv7 강제**) |
|
||||
| `type` | `messageType` | |
|
||||
| `time` | `producedAt` **및** `occurredAt` | 같은 값이 둘에 들어간다 |
|
||||
| `source` | `producer` | 마지막 세그먼트만 |
|
||||
| 확장 `schemaversion` | `schemaVersion` | **필수** |
|
||||
| 확장 `correlationid` | `correlationId` | |
|
||||
| 확장 `causationid` | `causationId` | `UUID.fromString` → `MessageId` |
|
||||
| 확장 `tenantcontext` | `tenantContext` | |
|
||||
| `datacontenttype` (없으면 `application/json`) | `contentType` | |
|
||||
| `data` (없으면 `new byte[0]`) | `EncodedMessage` | |
|
||||
| — | `partitionKey`, `orderingKey` | 항상 empty |
|
||||
| — | `traceContext` | 항상 `TraceContext.none()` |
|
||||
| — | `headers` | 항상 `MessageHeaders.empty()` |
|
||||
|
||||
### 4.2 두 가지 명시적 매핑 결정
|
||||
|
||||
```java
|
||||
// DefaultCloudEventMapper.java:31-35
|
||||
* <p>Two mapping decisions are deliberate. An event without {@code occurredAt} is rejected rather
|
||||
* than defaulted to the production instant, because {@code time} is read downstream as when the
|
||||
* fact happened, not when the platform got around to serialising it. And an event with no data maps
|
||||
* to an envelope with empty bytes, never to a Kafka null value: a tombstone deletes a key, and
|
||||
* inventing one from an absent CloudEvent payload would turn an empty notification into a deletion.
|
||||
```
|
||||
|
||||
두 번째는 `messaging-core-api`의 `MessageEnvelope` javadoc과 정확히 짝을 이룬다 — "A null Kafka value is a tombstone, which is a distinct broker-native operation with different retention semantics." 봉투가 payload를 non-null로 강제한 이유가 여기서 실제 매핑 규칙으로 나타난다.
|
||||
|
||||
### 4.3 `producerFrom`: 무한 URI를 유한 이름으로
|
||||
|
||||
```java
|
||||
// :158-163
|
||||
* <p>The last path or scheme-specific segment is used so that a long URI does not become an
|
||||
* unbounded producer name, which would leak straight into metric tags.
|
||||
private static String producerFrom(URI source) {
|
||||
String text = source.toString();
|
||||
int separator = Math.max(text.lastIndexOf('/'), text.lastIndexOf(':'));
|
||||
String candidate =
|
||||
separator >= 0 && separator + 1 < text.length() ? text.substring(separator + 1) : text;
|
||||
return candidate.isBlank() ? "unknown" : candidate;
|
||||
}
|
||||
```
|
||||
|
||||
`ProducerId`가 "deployment-independent service name, not a host, pod, or connection identity, so that it stays a bounded value safe for metric tags"라고 선언한 것과 같은 관심사다.
|
||||
|
||||
**다만 이 방어는 완전하지 않다.** 마지막 세그먼트가 여전히 120 UTF-8 바이트를 넘거나 제어문자를 담을 수 있다. 그 경우 `ProducerId` 생성자가 `IllegalArgumentException`을 던진다 — §4.5.
|
||||
|
||||
`urn:service:order-api` → `order-api`(테스트가 쓰는 형태). `https://a.example/very/long/path/x` → `x`.
|
||||
|
||||
### 4.4 `time`이 두 필드로 복제된다
|
||||
|
||||
```java
|
||||
Instant occurredAt = time.toInstant();
|
||||
return new MessageEnvelope<>(
|
||||
..., occurredAt, // producedAt
|
||||
Optional.of(occurredAt), // occurredAt
|
||||
...);
|
||||
```
|
||||
|
||||
CloudEvents에는 `time` 하나뿐이므로 봉투의 두 시각(플랫폼이 봉투를 만든 때 / 사실이 일어난 때)을 구분할 수 없다. 같은 값을 넣는 것은 합리적 선택이지만 **정보 손실이 기록되지 않았다** — 왕복 후 `producedAt`은 원래 값이 아니다. 테스트의 왕복 검증(`roundTripsBackToAnEnvelopeWithoutInventingATombstone`)이 `messageId`·`messageType`·`schemaVersion`·`correlationId`·`tenantContext`·`payload`만 비교하고 `producedAt`은 비교하지 않는다. fixture에서 `producedAt`은 `09:15:01Z`, `occurredAt`은 `09:15:00Z`로 **일부러 다르게** 설정돼 있으므로, 비교했다면 실패했을 것이다.
|
||||
|
||||
### 4.5 왕복에서 소실되는 것
|
||||
|
||||
`fromCloudEvent`가 항상 비우는 필드가 다섯이다.
|
||||
|
||||
| 필드 | 결과 |
|
||||
|---|---|
|
||||
| `partitionKey` | `Optional.empty()` |
|
||||
| `orderingKey` | `Optional.empty()` |
|
||||
| `traceContext` | `TraceContext.none()` |
|
||||
| `headers` | `MessageHeaders.empty()` |
|
||||
| `producedAt` | `occurredAt`으로 덮임 |
|
||||
|
||||
**`traceContext`의 소실이 가장 무겁다.** `messaging-core-api`의 `TraceContext` javadoc이 그 필드를 봉투에 둔 이유를 적는다 — "Keeping them on the envelope rather than only in headers means a trace survives an Outbox round trip through the database, where broker headers do not exist yet." CloudEvents 왕복은 그 보존을 깨뜨린다. CloudEvents는 분산 추적 확장(`traceparent`를 distributed-tracing extension으로)을 정의하는데 이 매퍼는 그것을 읽지도 쓰지도 않는다.
|
||||
|
||||
`toCloudEvent`도 `traceContext`·`headers`·`partitionKey`·`orderingKey`를 쓰지 않는다. 즉 소실은 양방향이다.
|
||||
|
||||
### 4.6 `id`의 UUIDv7 강제 — 이 leaf에서 가장 중요한 계약
|
||||
|
||||
```java
|
||||
new MessageId(UUID.fromString(event.getId()))
|
||||
```
|
||||
|
||||
CloudEvents 1.0.2는 `id`를 **"Type: String; Constraints: REQUIRED, MUST be a non-empty string"**으로 정의한다. UUID 형식 요구가 없다.
|
||||
|
||||
`MessageId`(messaging-core-api)는 UUID이면서 **version 7 · variant 2**를 요구한다.
|
||||
|
||||
두 계약이 만나는 지점의 실제 동작을 런타임 probe로 확인했다(`evidence/raw/273-cloudevents-inbound-id-probe.txt`).
|
||||
|
||||
```
|
||||
--- spec-conformant opaque string id
|
||||
id = A234-1234-1234
|
||||
result = REJECTED
|
||||
thrown = java.lang.IllegalArgumentException
|
||||
message = Invalid UUID string: A234-1234-1234
|
||||
is a MessagingException (carries FailureDescriptor) = false
|
||||
|
||||
--- UUIDv4 id
|
||||
id = 9c1f1f2e-6a1a-4d3b-8f0e-2b0d5b2f6c11
|
||||
result = REJECTED
|
||||
thrown = java.lang.IllegalArgumentException
|
||||
message = a message identity is UUIDv7 (time-ordered); this is version 4
|
||||
is a MessagingException (carries FailureDescriptor) = false
|
||||
|
||||
--- UUIDv7 id (what this platform mints)
|
||||
result = ACCEPTED
|
||||
```
|
||||
|
||||
`A234-1234-1234`는 CloudEvents 명세 자신의 예시가 쓰는 id다.
|
||||
|
||||
**의도는 문서화돼 있다.** 테스트에 주석이 있다.
|
||||
|
||||
```java
|
||||
// CloudEventMappingTest.java:90-91
|
||||
// A v7 id: MessageId enforces the version it documents, so a v4 arriving from a foreign
|
||||
// producer is refused here exactly as it would be on the wire.
|
||||
```
|
||||
|
||||
즉 "외부 producer의 v4를 거절한다"는 것은 알고 내린 결정이다. 그러나 두 가지가 그 결정과 별개다.
|
||||
|
||||
1. **비UUID id는 명세 위반이 아니다.** v4 거절은 정책 선택이지만, `A234-1234-1234` 거절은 CloudEvents 상호운용성 자체를 포기하는 것이다. 그리고 그 경우는 어디에도 언급되지 않았다.
|
||||
2. **실패가 플랫폼 어휘 밖이다.** 이 매퍼의 다른 모든 검증 실패는 `MessageValidationException`(→ `FailureDescriptor`, `PERMANENT_BUSINESS`, 안정 코드)이다. id 실패만 raw `IllegalArgumentException`이다. 분류도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드 집계가 이 실패를 못 본다.
|
||||
|
||||
§17에서 다룬다.
|
||||
|
||||
### 4.7 `schemaversion` 확장이 필수다
|
||||
|
||||
```java
|
||||
private static int intExtension(CloudEvent event, String name) {
|
||||
return stringExtension(event, name)
|
||||
.map(value -> { try { return Integer.valueOf(value); }
|
||||
catch (NumberFormatException e) {
|
||||
throw new MessageValidationException("CLOUDEVENT_SCHEMA_VERSION_INVALID", ...); } })
|
||||
.orElseThrow(() -> new MessageValidationException("CLOUDEVENT_SCHEMA_VERSION_REQUIRED",
|
||||
"schemaversion extension is required by this profile"));
|
||||
}
|
||||
```
|
||||
|
||||
에러 메시지가 "**by this profile**"이라고 적어 이것이 명세 요구가 아니라 이 프로파일의 요구임을 밝힌다. 좋은 표현이다 — `id`의 UUIDv7 요구에는 그런 표시가 없다.
|
||||
|
||||
이 확장을 쓰지 않는 외부 producer의 CloudEvent는 전부 거절된다. `id`와 합치면 **이 매퍼가 받아들이는 CloudEvent는 사실상 이 플랫폼이 만든 것뿐이다.**
|
||||
|
||||
### 4.8 `toCloudEvent`의 payload 계약
|
||||
|
||||
```java
|
||||
if (envelope.payload() instanceof EncodedMessage encoded) { ... }
|
||||
else if (envelope.payload() instanceof byte[] bytes) { builder.withData(BytesCloudEventData.wrap(bytes.clone())); }
|
||||
else { throw new MessageValidationException("CLOUDEVENT_PAYLOAD_NOT_ENCODED", ...); }
|
||||
```
|
||||
|
||||
이미 인코딩된 것만 받는다 — 매퍼가 codec 역할을 하지 않는다. `byte[]` 분기에서 `clone()`하는 것도 `EncodedMessage.bytes()`가 이미 복사본을 주는 것과 대칭이다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
**나가는 방향:** `occurredAt` 확인(없으면 거절) → `CloudEventBuilder.v1()`에 id·source·type·time·datacontenttype·schemaversion → 선택 확장 셋 → payload 종류 판정 → `dataschema`(있을 때) → `build()`
|
||||
|
||||
**들어오는 방향:** `time` 확인(없으면 거절) → `datacontenttype`(기본 `application/json`) → `data`(없으면 빈 배열) → `MessageId`·`MessageType`·`SchemaVersion`·`ProducerId`·확장 셋 → `MessageEnvelope` 조립
|
||||
|
||||
---
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
| 코드 | 예외 | 방향 | 조건 |
|
||||
|---|---|---|---|
|
||||
| `CLOUDEVENT_TIME_REQUIRED` | `MessageValidationException` | 양방향 | `occurredAt` 없음 / `time` 없음 |
|
||||
| `CLOUDEVENT_PAYLOAD_NOT_ENCODED` | `MessageValidationException` | 나가는 | payload가 `EncodedMessage`도 `byte[]`도 아님 |
|
||||
| `CLOUDEVENT_SCHEMA_VERSION_REQUIRED` | `MessageValidationException` | 들어오는 | 확장 없음 |
|
||||
| `CLOUDEVENT_SCHEMA_VERSION_INVALID` | `MessageValidationException` | 들어오는 | 확장이 정수가 아님 |
|
||||
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `id`가 UUID가 아니거나 v7이 아님 |
|
||||
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `causationid`가 UUID가 아니거나 v7이 아님 |
|
||||
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `type`이 `MessageType` 제약 위반(240바이트·제어문자) |
|
||||
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `correlationid`가 160바이트 초과 |
|
||||
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `tenantcontext`가 슬러그 패턴 위반 |
|
||||
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | 유도된 producer 이름이 120바이트 초과 또는 제어문자 |
|
||||
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `datacontenttype`이 미디어 타입 문법 위반 |
|
||||
| **(코드 없음)** | `IllegalArgumentException` | 들어오는 | `schemaversion`이 0 이하 |
|
||||
|
||||
**분류된 실패 넷, 분류되지 않은 실패 여덟.** 매퍼가 직접 던지는 것은 전부 `MessageValidationException`이지만, 값 객체 생성자에 위임한 검증은 전부 raw `IllegalArgumentException`이다. `fromCloudEvent`는 **외부에서 온 데이터**를 다루는 유일한 진입점인데, 그 진입점의 실패 대부분이 플랫폼 실패 어휘 밖이다.
|
||||
|
||||
`messaging-core-api`의 `FailureDescriptor` 설계 전체가 "예외 클래스로 분기하지 말고 선언된 분류로 판단하라"였다. 이 경로는 그 분류를 만들지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
트랜잭션 없음.
|
||||
|
||||
`DefaultCloudEventMapper`는 **상태가 없다** — 필드가 `SPEC_CONTENT_TYPE_FALLBACK` 상수 하나뿐이고 모든 메서드가 인자만 쓴다. 스레드 안전하다. 다만 그 사실이 javadoc에 적혀 있지 않다.
|
||||
|
||||
`CloudEventExtensions`는 상수 홀더이고 private 생성자를 갖는다.
|
||||
|
||||
`CloudEventBuilder`는 호출마다 새로 만들어진다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
설정 없음.
|
||||
|
||||
| 상수 | 값 | 위치 |
|
||||
|---|---|---|
|
||||
| `SPEC_CONTENT_TYPE_FALLBACK` | `"application/json"` | `DefaultCloudEventMapper.java:39` (private) |
|
||||
| `CloudEventExtensions.CORRELATION_ID` | `"correlationid"` | public |
|
||||
| `CloudEventExtensions.CAUSATION_ID` | `"causationid"` | public |
|
||||
| `CloudEventExtensions.SCHEMA_VERSION` | `"schemaversion"` | public |
|
||||
| `CloudEventExtensions.TENANT_CONTEXT` | `"tenantcontext"` | public |
|
||||
|
||||
CloudEvents 버전은 `4.0.1`로 고정(lockfile 확인). CloudEvents **명세** 버전은 `CloudEventBuilder.v1()`이 고정한다 — javadoc은 1.0.2를 명시한다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
없다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
레인: `./gradlew :messaging:messaging-cloudevents:test`. **BUILD SUCCESSFUL, 7 tests, 0 skipped, 0 failures**.
|
||||
|
||||
| 테스트 | 증명하는 것 |
|
||||
|---|---|
|
||||
| `mapsLogicalIdentityAndExtensions` | id·type·schemaversion·source·datacontenttype |
|
||||
| `mapsCorrelationAndTenantAsExtensions` | 두 확장 |
|
||||
| `mapsOccurredAtToEventTime` | `occurredAt` → `time` |
|
||||
| `rejectsAnEventEnvelopeWithoutOccurredAt` | 나가는 방향의 `time` 필수 |
|
||||
| `roundTripsBackToAnEnvelopeWithoutInventingATombstone` | 왕복 시 6개 필드 보존 |
|
||||
| `aCloudEventWithNoDataBecomesAnEmptyPayloadNotANullValue` | 빈 data → 빈 바이트(tombstone 아님) |
|
||||
| `rejectsAnUnencodedPayload` | 인코딩되지 않은 payload 거절 |
|
||||
|
||||
**이 레인의 결정적 한계: 모든 입력이 이 플랫폼이 만든 것이다.**
|
||||
|
||||
`fromCloudEvent`를 부르는 두 테스트 중 하나는 `mapper.toCloudEvent(original, SOURCE)`의 출력을 되돌리고, 다른 하나는 `MessageId.newId()`로 v7 id를 만들어 CloudEvent를 조립한다. 후자에는 주석이 붙어 있다 — "A v7 id: MessageId enforces the version it documents".
|
||||
|
||||
즉 **외부 producer가 만든 CloudEvent를 이 매퍼에 넣는 경로가 한 번도 테스트되지 않았다.** 이 leaf의 존재 이유가 상호운용성인데, 상호운용 방향이 검증 공백이다. §4.6의 probe가 그 공백을 실제로 실행해 본 결과다.
|
||||
|
||||
**왕복 검증의 선택적 비교.** `roundTripsBackToAnEnvelopeWithoutInventingATombstone`이 `producedAt`·`traceContext`·`headers`·`partitionKey`·`orderingKey`를 비교하지 않는다. fixture는 `producedAt`(`09:15:01Z`)과 `occurredAt`(`09:15:00Z`)을 다르게 두었으므로, 비교했다면 실패했을 것이다. 테스트 이름이 "roundTrips"인데 실제로는 6개 필드의 부분 보존을 확인한다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
| 게이트 | 이 leaf에 대해 |
|
||||
|---|---|
|
||||
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-schema-api"]` |
|
||||
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` — 편입이 강제됨 |
|
||||
| vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | `cloudevents-api`는 public 시그니처에 등장 → `api`. `cloudevents-core`는 구현 전용 → `implementation`. **통과** |
|
||||
| ArchUnit | 전용 규칙 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 실제 사용 여부와 negative-space probes
|
||||
|
||||
원시 증거: `evidence/raw/272-schema-family-reachability.txt`, `evidence/raw/273-cloudevents-inbound-id-probe.txt`.
|
||||
|
||||
### 12.1 Public surface reachability
|
||||
|
||||
| 타입 | leaf 밖 참조 | 판정 |
|
||||
|---|---:|---|
|
||||
| `CloudEventMapper` | **0** | 소비자 없음 |
|
||||
| `DefaultCloudEventMapper` | **0** | 소비자 없음 |
|
||||
| `CloudEventExtensions` | **0** | 소비자 없음 |
|
||||
|
||||
세 타입 모두 `git grep` exit 1.
|
||||
|
||||
**형제와 다른 조합이다.**
|
||||
|
||||
| leaf | 소비자 | starter codec 등록 | `runtime_memberships` | 정합 |
|
||||
|---|:---:|:---:|---|---|
|
||||
| `messaging-schema-json` | 1 | o | `["app-bootstrap"]` | o |
|
||||
| `messaging-schema-avro` | 0 | x | `[]` | o |
|
||||
| `messaging-schema-protobuf` | 0 | x | `[]` | o |
|
||||
| **`messaging-cloudevents`** | **0** | 해당 없음 | **`["app-bootstrap"]`** | **x** |
|
||||
|
||||
Avro·Protobuf는 "싣지 않고 쓰지 않는다"로 정합한다. 이 leaf는 **싣고 쓰지 않는다.** `messaging-spring-boot-starter`의 `allowed_dependencies`에 들어 있어 배포 아티팩트가 `cloudevents-api`와 `cloudevents-core` 두 jar를 함께 싣는다.
|
||||
|
||||
지금 그것이 사고는 아니다 — 아무도 부르지 않으므로 코드가 실행되지 않는다. 비용은 아티팩트 크기와, "이 의존성이 왜 여기 있지?"를 나중에 조사할 사람의 시간이다.
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
Spring 주석 0개, bean 없음.
|
||||
|
||||
**조립 비대칭은 starter 쪽에서 관측된다.** `MessagingCoreAutoConfiguration`이 `JacksonMessageCodec`으로 codec registry를 만드는 `@Bean`을 갖는데, `CloudEventMapper`를 만드는 `@Bean`은 없다. 두 leaf 모두 starter의 의존 목록에 있고 한쪽만 배선된다. 상세는 `messaging-spring-boot-starter` leaf SSOT가 소유한다.
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
**(a) 다른 CloudEvents 구현이 있는가 — 없다**
|
||||
|
||||
`git grep -l 'io.cloudevents' -- src`가 이 leaf 밖에서 맞추는 것이 없다. 저장소에 CloudEvents를 다루는 코드는 이 세 파일뿐이다.
|
||||
|
||||
**(b) 봉투 ↔ 외부 표현 매핑이 다른 곳에도 있는가 — 있다, 그러나 책임이 다르다**
|
||||
|
||||
`messaging-kafka`의 `KafkaHeaderMapper`/`KafkaDeliveryMapper`, `messaging-rabbit`의 `RabbitDeliveryMapper`가 봉투를 브로커 표현으로 옮긴다. 그러나 그들은 **transport 매핑**이고 이것은 **interchange 포맷 매핑**이다. runtime eligibility가 겹치지 않는다(브로커 매퍼는 항상 실행되고 이것은 명시 호출이 필요하다).
|
||||
|
||||
다만 겹치는 관심사가 하나 있다 — `traceContext`. 브로커 매퍼들은 `traceparent`/`tracestate`/`baggage`를 예약 헤더로 실어 나르고(`ReservedHeaders`가 세 이름을 갖는다), 이 매퍼는 그것을 버린다(§4.5). 같은 봉투 필드를 두 경로가 다르게 취급한다.
|
||||
|
||||
**(c) UUID 파싱** — `UUID.fromString`을 통한 외부 문자열 → 식별자 변환이 이 leaf에서 두 곳(id, causationid)에 있고 둘 다 방어가 없다. 저장소의 다른 곳에서는 대체로 값 객체가 그 방어를 갖는다.
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
| 문서 주장 | 재측정 | 결과 |
|
||||
|---|---|---|
|
||||
| build.gradle 주석: `cloudevents-api`가 public 시그니처에 등장 | `CloudEventMapper`의 두 메서드가 `CloudEvent`를 반환/수취 | **일치** |
|
||||
| build.gradle 주석: `cloudevents-core`는 구현 전용 | `CloudEventBuilder`·`BytesCloudEventData`가 `DefaultCloudEventMapper` 안에서만 | **일치** |
|
||||
| 클래스 javadoc: "CloudEvents 1.0.2 compatible profile" | `id` 제약이 명세보다 엄격(§4.6). `schemaversion` 확장 필수 | **부분 불일치** — 아래 참조 |
|
||||
| `CloudEventMapper` javadoc: domain/integration event 전용 | 코드에 그 구분을 강제하는 것 없음 | **미강제** — 정책 진술이고 게이트가 없다 |
|
||||
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치** — family drift의 사례(`messaging-core-api` §12.4) |
|
||||
|
||||
**"compatible profile"의 정확한 의미.** 명세는 `id`를 임의의 비어 있지 않은 문자열로 정의하고, 이 프로파일은 UUIDv7만 받는다. **나가는 방향은 명세를 만족한다**(UUID 문자열은 유효한 id다). **들어오는 방향은 명세 준수 이벤트의 부분집합만 받는다.** javadoc의 "compatible"이 어느 방향을 말하는지 밝히지 않는다. `schemaversion` 에러 메시지는 "required by this profile"이라고 정확히 적는 반면 `id` 제약에는 그런 표시가 없다 — 같은 파일 안에서 표현의 정밀도가 다르다.
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
build.gradle 주석이 이전 결함 하나를 보존한다.
|
||||
|
||||
> Declared `implementation`, the type appeared in this module's public API while the dependency was hidden from consumers: an adopter calling the documented method could not name its return type without adding CloudEvents to their own build, and Gradle gave them no hint why. A type in a public signature is part of the artifact's contract.
|
||||
|
||||
이것이 `src/messaging/CLAUDE.md:40-43`의 게이트를 만든 사례군에 속한다 — "source에서 public/protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf의 `build.gradle`이 `api`로 선언했는지 대조". 이 leaf는 그 게이트를 두 좌표로 나눠 통과한 모범 사례다.
|
||||
|
||||
코드 주석이 남긴 두 매핑 결정(§4.2)도 실패 이력의 성격을 갖는다 — "defaulted to the production instant"와 "inventing a tombstone"은 하지 않기로 한 것들이다.
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` §D, §E | 세 타입의 소비자 0, membership `["app-bootstrap"]` | 정적 검색 |
|
||||
| **EVD-273** | **runtime probe** | `evidence/raw/273-cloudevents-inbound-id-probe.txt` | 명세 예시 id·UUIDv4·UUIDv7 세 경우의 실제 결과와 예외 타입, `MessagingException` 여부 | 저장소 소스를 수정하지 않은 별도 probe. 세 id 형태만 확인 |
|
||||
| EVD-278 | command | `./gradlew :messaging:messaging-cloudevents:test --rerun-tasks` | BUILD SUCCESSFUL, 7 / 0 / 0 | 외부 producer 입력 없음 |
|
||||
|
||||
EVD-273의 실행 방법: `:messaging:messaging-cloudevents` test runtimeClasspath에 대해 `/tmp/CeProbe.java`를 컴파일·실행. 저장소 파일은 읽기만 했다.
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적**
|
||||
|
||||
- domain/integration event 전용인 이유 — `CloudEventMapper` javadoc
|
||||
- `occurredAt` 없는 이벤트를 거절하는 이유 — `DefaultCloudEventMapper` javadoc
|
||||
- 빈 data를 tombstone으로 만들지 않는 이유 — 같은 javadoc
|
||||
- producer 이름을 마지막 세그먼트로 자르는 이유 — `producerFrom` javadoc
|
||||
- 확장 이름이 봉투 필드명과 다른 이유 — `CloudEventExtensions` javadoc
|
||||
- `cloudevents-api`가 `api`여야 하는 이유 — build.gradle 주석
|
||||
- v4 id를 거절하는 것이 의도라는 것 — 테스트 주석(`CloudEventMappingTest.java:90-91`)
|
||||
|
||||
**추론**
|
||||
|
||||
- 비UUID id 거절이 의도인지 → **미상**. 테스트 주석은 v4만 언급하고 비UUID는 언급하지 않는다. 두 경우는 다른 판단이다.
|
||||
- `traceContext`·`headers`를 버리는 것이 의도인지 → **미상**. 어디에도 언급이 없다.
|
||||
- `producedAt`을 `occurredAt`으로 덮는 것이 의도인지 → **추론**. CloudEvents에 `time`이 하나뿐이라는 제약에서 나온 것으로 보이지만 주석이 없다.
|
||||
- membership이 있고 소비자가 없는 이유 → **미상**.
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- 세 타입 228줄 전문의 매핑 계약, 양방향 필드 대응표
|
||||
- 7개 테스트가 통과하고 무엇을 단언하는지, 그리고 무엇을 비교하지 않는지
|
||||
- 소비자 0인데 `runtime_memberships`가 `["app-bootstrap"]`이라는 비정합
|
||||
- **명세 예시 id와 UUIDv4가 분류되지 않은 `IllegalArgumentException`으로 거절된다는 것 — 런타임 probe로 실행 확인**
|
||||
- 왕복에서 다섯 필드가 소실된다는 것
|
||||
- `api`/`implementation` 분리가 정확하다는 것
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- 실제 외부 CloudEvents producer(예: Knative, Azure Event Grid)의 id 형식 분포. 명세가 제약하지 않으므로 UUID가 아닐 가능성이 높지만 측정하지 않았다.
|
||||
- 이 leaf가 starter 의존 목록에 들어간 시점과 이유. 커밋이 4개뿐이고 전부 대량 커밋이다.
|
||||
- `dataschema`가 실제로 쓰이는지 — `EncodedMessage.schemaReference().schemaUri()`가 채워지는 경로가 이 저장소에 없다(세 codec 모두 `SchemaReference.of(subject, version)`로 URI 없이 만든다). 즉 `dataschema`는 현재 항상 비어 있다.
|
||||
- CloudEvents distributed-tracing extension을 쓸 계획이 있는지.
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P2 — 상호운용을 위한 매퍼가 명세 준수 이벤트를 분류되지 않은 예외로 거절한다
|
||||
|
||||
- **사실.** `fromCloudEvent`가 `new MessageId(UUID.fromString(event.getId()))`로 id를 파싱한다. CloudEvents 1.0.2는 `id`를 비어 있지 않은 문자열로만 제약한다. 런타임 probe 결과: 명세 예시 id `A234-1234-1234` → `java.lang.IllegalArgumentException: Invalid UUID string`, UUIDv4 → `java.lang.IllegalArgumentException: a message identity is UUIDv7`. **둘 다 `MessagingException`이 아니다.**
|
||||
- **근거.** `evidence/raw/273-cloudevents-inbound-id-probe.txt` (실행 확인). `DefaultCloudEventMapper.java:116`.
|
||||
- **왜 문제인가.** 두 층이다.
|
||||
- **(1) 범위.** v4 거절은 의도이고 테스트 주석이 그렇게 적는다. 그러나 **비UUID 거절은 어디에도 언급되지 않았고** 그것은 다른 판단이다 — v4 거절은 "우리 정책", 비UUID 거절은 "CloudEvents 상호운용 포기"다. 이 leaf의 존재 이유가 상호운용인데 명세 예시조차 받지 못한다.
|
||||
- **(2) 실패 어휘.** 같은 메서드의 다른 검증 실패 넷은 전부 `MessageValidationException`이고 안정 코드(`CLOUDEVENT_TIME_REQUIRED` 등)를 갖는다. id 실패만 raw `IllegalArgumentException`이라 `FailureDescriptor`가 없다 — 카테고리도, 코드도, retryable 판정도 없다. DLQ 라우팅과 대시보드가 이 실패를 분류하지 못한다. 같은 문제가 `causationid`·`type`·`correlationid`·`tenantcontext`·`producer`·`datacontenttype`·`schemaversion` 값 범위에도 있다(§6의 "코드 없음" 여덟 행).
|
||||
- **확인 방법.** `evidence/raw/273`의 probe 재실행. 또는 `MessageId` 생성자와 `UUID.fromString`의 계약 대조.
|
||||
- **후보.** (a) `fromCloudEvent`의 값 객체 생성을 전부 감싸 `MessageValidationException`으로 번역하고 각각 안정 코드를 준다. (b) 비UUID id에 대해 결정한다 — 거절하되 명시적으로 하거나, `id`를 그대로 보존하는 필드를 두거나, 결정론적 UUIDv5/v7으로 유도한다. (c) javadoc의 "compatible profile"이 나가는 방향만 뜻함을 밝힌다.
|
||||
- **다음 단계.** **CASE 후보.** 재현이 실행 evidence로 확정됐고 결론이 leaf 경계 안에서 닫힌다. (b)의 선택은 별도 **DECISION 후보**이며 지금은 근거가 없으므로 `NEEDS_DECISION`이다.
|
||||
|
||||
### P2 — 배포 아티팩트가 싣지만 아무도 부르지 않는다
|
||||
|
||||
- **사실.** 세 타입의 leaf 밖 참조가 0인데 `runtime_memberships`가 `["app-bootstrap"]`이다. `messaging-spring-boot-starter`의 의존 목록에 있어 `cloudevents-api`·`cloudevents-core` 두 jar가 런타임 classpath에 오른다. starter에 `CloudEventMapper`를 만드는 `@Bean`이 없다.
|
||||
- **근거.** `evidence/raw/272` §D·§E. `MessagingCoreAutoConfiguration` 전수(`CloudEvent` 참조 0).
|
||||
- **왜 문제인가.** 형제 Avro·Protobuf는 소비자 0과 membership `[]`이 일치하는 정합적 incubating 상태다. 이 leaf만 어긋난다. 오늘 실행되는 코드가 없으므로 사고는 아니지만, 아티팩트 크기와 "이 의존성이 왜 있지"의 조사 비용이 남는다. 그리고 `support-matrix.md:23`이 "모든 messaging leaf가 unwired"라고 적고 있어 문서에서도 이 사실을 알 수 없다.
|
||||
- **확인 방법.** `git grep -l -w CloudEventMapper -- src ':!src/messaging/messaging-cloudevents'` → exit 1. registry의 membership 확인.
|
||||
- **후보.** (a) starter에서 `@ConditionalOnClass`/`@ConditionalOnProperty`로 mapper bean을 배선한다. (b) starter 의존에서 빼고 membership을 `[]`로 되돌려 Avro·Protobuf와 같은 상태로 만든다.
|
||||
- **다음 단계.** **CASE 후보.** "장치는 있고 회로가 닫히지 않았다"의 변형 — 여기서는 회로가 닫히지 않았는데 **부품은 배송됐다.**
|
||||
|
||||
### P3 — 왕복이 다섯 필드를 버리고, 테스트가 그 필드를 비교하지 않는다
|
||||
|
||||
- **사실.** `fromCloudEvent`가 `partitionKey`·`orderingKey`를 empty로, `traceContext`를 `none()`으로, `headers`를 `empty()`로 두고, `producedAt`을 `occurredAt` 값으로 덮는다. 왕복 테스트는 6개 필드만 비교하고 이 다섯은 비교하지 않는다. fixture의 `producedAt`(`09:15:01Z`)과 `occurredAt`(`09:15:00Z`)이 다르므로 비교했다면 실패했을 것이다.
|
||||
- **근거.** `DefaultCloudEventMapper.java:115-131`, `CloudEventMappingTest.java:72-84, 143-161`.
|
||||
- **왜 문제인가.** `traceContext` 소실이 가장 무겁다. `messaging-core-api`의 `TraceContext` javadoc이 그 필드를 봉투에 둔 이유를 "a trace survives an Outbox round trip through the database, where broker headers do not exist yet"이라고 적는다. CloudEvents 왕복이 그 보존을 깨뜨리고, CloudEvents 자신이 정의하는 distributed-tracing extension을 쓰지 않는다. 그리고 테스트 이름이 `roundTrips…`인데 실제로는 부분 보존 확인이다.
|
||||
- **확인 방법.** 왕복 테스트에 `producedAt`·`traceContext` 비교를 추가하면 실패한다.
|
||||
- **후보.** (a) 소실 필드를 javadoc에 명시한다. (b) `traceparent`/`tracestate`/`baggage`를 CloudEvents distributed-tracing extension으로 왕복시킨다. (c) 테스트 이름을 실제 보장에 맞춘다.
|
||||
- **다음 단계.** **REFERENCE 후보**(왕복이라 부르는 테스트는 무엇을 보존하지 않는지도 적는다).
|
||||
|
||||
### P3 — `dataschema`가 채워질 경로가 없다
|
||||
|
||||
- **사실.** `toCloudEvent`가 `encoded.schemaReference().flatMap(SchemaReference::schemaUri).ifPresent(builder::withDataSchema)`로 `dataschema`를 채운다. 그런데 세 codec(JSON·Avro·Protobuf) 모두 `SchemaReference.of(subject, version)`로 만들고, 그 factory는 `schemaUri`를 `Optional.empty()`로 둔다.
|
||||
- **근거.** `DefaultCloudEventMapper.java:81-86`, `SchemaReference.java:36-38`, 세 codec의 `encode`.
|
||||
- **왜 문제인가.** `dataschema`는 CloudEvents 소비자가 페이로드를 해석하는 데 쓰는 표준 속성이다. 항상 비어 있으므로 이 프로파일이 만드는 CloudEvent는 스키마 위치를 알리지 않는다. `schemaversion` 확장이 그 자리를 대신하지만 그것은 비표준 확장이다.
|
||||
- **확인 방법.** `git grep -n 'new SchemaReference(' -- 'src/messaging/**/*.java'` — 3인자 생성자를 부르는 production 코드가 있는지 확인.
|
||||
- **후보.** schema registry URI를 갖는 배포에서 `SchemaReference`의 3인자 생성자를 쓰게 하거나, `dataschema` 분기가 현재 도달 불가임을 주석으로 남긴다.
|
||||
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 "이 저장소가 외부 schema registry를 쓸 것인가"에 걸리고, 그 질문은 `messaging-schema-api`의 `SchemaRegistry` port가 구현 0인 것과 같은 뿌리다.
|
||||
|
||||
### P3 — `CloudEventMapper` javadoc의 범위 제한이 강제되지 않는다
|
||||
|
||||
- **사실.** "Offered for domain and integration events only. Commands and work items are not forced through CloudEvents." 코드에 `DestinationKind`를 보는 분기가 없다.
|
||||
- **근거.** `CloudEventMapper.java:11-13`, `DefaultCloudEventMapper` 전문.
|
||||
- **왜 문제인가.** 소비자가 0이므로 지금은 무해하다. 배선되면 `ASYNC_COMMAND`·`WORK_QUEUE` 봉투도 이 매퍼를 통과한다.
|
||||
- **확인 방법.** `git grep -n 'DestinationKind' -- 'src/messaging/messaging-cloudevents/**'` → 매치 없음.
|
||||
- **후보.** 진술을 유지하되 "호출자 책임"임을 명시하거나, `toCloudEvent`가 `DestinationKind`를 받아 검사한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(문서가 범위를 제한하면 그 제한을 누가 강제하는지 같이 적는다).
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- `occurredAt` 없는 이벤트를 production 시각으로 기본값 처리하지 않고 거절하는 것
|
||||
- 빈 data를 tombstone(Kafka null value)으로 만들지 않는 것 — `MessageEnvelope`의 non-null payload 계약과 정확히 짝을 이룸
|
||||
- producer 이름을 마지막 세그먼트로 잘라 메트릭 카디널리티를 막는 것
|
||||
- `cloudevents-api`를 `api`로, `cloudevents-core`를 `implementation`으로 나눈 것과 그 근거 주석
|
||||
- `schemaversion` 에러 메시지가 "by this profile"이라고 밝히는 것
|
||||
- `byte[]` payload를 `clone()`해서 넘기는 것
|
||||
- 매퍼가 상태를 갖지 않는 것
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MCE-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps, `runtime_memberships: ["app-bootstrap"]` | 선언 |
|
||||
| MCE-002 | build | `messaging-cloudevents/build.gradle` | same | `api`/`implementation` 분리와 그 근거 | — |
|
||||
| MCE-003 | build | `messaging-cloudevents/gradle.lockfile:33-34` | same | cloudevents 4.0.1 두 좌표 | — |
|
||||
| MCE-004 | code | `.../cloudevents/CloudEventMapper.java` 전문 | same | 계약과 적용 범위 진술 | 범위 미강제(§17) |
|
||||
| MCE-005 | code | `.../cloudevents/DefaultCloudEventMapper.java` 전문 | same | §4 전체 매핑표와 두 명시적 결정 | — |
|
||||
| MCE-006 | code | `.../cloudevents/CloudEventExtensions.java` | same | 확장 이름 4개와 명명 이유 | — |
|
||||
| MCE-007 | test | `CloudEventMappingTest` (7) | same | §10 표 | 외부 producer 입력 없음. 왕복이 5개 필드 미비교 |
|
||||
| MCE-008 | cross-leaf code | `messaging-core-api/.../MessageId.java:20-32` | same | UUIDv7 강제의 출처 | 해당 leaf SSOT가 소유 |
|
||||
| MCE-009 | cross-leaf code | `messaging-core-api/.../TraceContext.java:11-13` | same | 봉투가 trace를 갖는 이유(§17 왕복 소실) | 해당 leaf SSOT가 소유 |
|
||||
| MCE-010 | cross-leaf code | `messaging-schema-api/.../SchemaReference.java:36-38` | same | `of`가 URI를 비움 → `dataschema` 도달 불가 | 해당 leaf SSOT가 소유 |
|
||||
| MCE-011 | external spec | CloudEvents 1.0.2, `id` 속성 정의 | — | `id`는 비어 있지 않은 String이며 형식 제약 없음 | 외부 표준. 저장소 밖 지식으로 명시 분리 |
|
||||
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | 세 타입 소비자 0, membership | 정적 검색 |
|
||||
| EVD-273 | runtime probe | `evidence/raw/273-cloudevents-inbound-id-probe.txt` | same | 세 id 형태의 실제 결과와 예외 타입 | 세 형태만. 저장소 소스 미수정 |
|
||||
| EVD-278 | command | `./gradlew :messaging:messaging-cloudevents:test --rerun-tasks` | same | 7 / 0 / 0 | — |
|
||||
@@ -0,0 +1,924 @@
|
||||
# messaging-core-api 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-core-api`
|
||||
> SSOT owner: `messaging-core-api`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
> **성격.** 정책 문서가 아니라 읽기 기록이다. 이 leaf가 무엇을 선언했고, 그 선언 중 무엇이 실제로 소비되며, 무엇이 소비되지 않는지를 source anchor와 함께 적는다. cycle 1의 family 문서(`analysis/19-messaging-platform.md`)는 25개 leaf를 하나의 문서로 다뤘고 새 계약에서 secondary evidence로 강등됐다. 이 문서가 `messaging-core-api`의 canonical SSOT다.
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- registered leaf id: `messaging-core-api`
|
||||
- canonical state `analysisFile`: `analysis/messaging/messaging-core-api.md`
|
||||
- source path: `src/messaging/messaging-core-api`
|
||||
- leaf-owned subdocuments: 없음
|
||||
- related family/integration documents: `analysis/19-messaging-platform.md` (secondary)
|
||||
- registry `allowed_dependencies`: `[]` — 이 저장소에서 의존성이 하나도 없는 두 leaf 중 하나(다른 하나는 `grpc-core-api`)
|
||||
- registry `runtime_memberships`: `["app-bootstrap"]`
|
||||
|
||||
### 숫자
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | 85 |
|
||||
| production LOC | 3,948 |
|
||||
| 패키지 | 7 |
|
||||
| test 파일 | 8 |
|
||||
| test 메서드(실행 확인) | 79 |
|
||||
| build/config 파일 | `build.gradle` 1, `gradle.lockfile` 1 |
|
||||
| migration | 0 |
|
||||
| 외부 의존성 | **0** |
|
||||
|
||||
패키지 7개와 그 안의 타입 수:
|
||||
|
||||
| 패키지 | 타입 | 성격 |
|
||||
|---|---:|---|
|
||||
| `api` (root) | 12 | 봉투와 그 안의 값 객체 |
|
||||
| `api.header` | 5 | 헤더 이름·값·맵·예약 네임스페이스 |
|
||||
| `api.destination` | 7 | 논리 목적지와 capability |
|
||||
| `api.publish` | 17 | 발행 요청·결과·증거 |
|
||||
| `api.delivery` | 13 | 수신·핸들러 결과 |
|
||||
| `api.settlement` | 5 | 수동 정산 |
|
||||
| `api.error` | 26 | 실패 분류와 예외 계층 |
|
||||
| 합계 | **85** | |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `src/main/java/**/api/*.java` (root 12) | 12 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `src/main/java/**/api/header/*.java` | 5 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `src/main/java/**/api/destination/*.java` | 7 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `src/main/java/**/api/publish/*.java` | 17 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `src/main/java/**/api/delivery/*.java` | 13 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `src/main/java/**/api/settlement/*.java` | 5 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `api/error/FailureCategory·FailureDescriptor·MessagingException` | 3 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `api/error/Message*Exception` 나머지 | 23 | `STRUCTURAL_ONLY` | 전부 동일 형태 — 3개 생성자, 고정 `CATEGORY` 상수, `retryable` 리터럴. 시그니처·카테고리·retryable 값을 전수 대조했고 그 외 본문이 없다 |
|
||||
| `src/test/java/**` | 8 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 4줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일; 선언 의존성 0을 build.gradle에서 이미 확인 |
|
||||
| `build/**` | — | `EXCLUDED` | 빌드 산출물. source가 아니다 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
이 leaf는 **브로커 중립 공개 계약**을 소유한다. 여기에는 구현이 거의 없다 — 85개 타입 중 인터페이스 11개, enum 12개, record 46개, 유틸리티 final class 5개, 예외 26개이고, 실행 가능한 로직은 `UuidV7.next()`, `WireSafeText.require`, `MessageHeaders.validateAndCopy`, 그리고 record 생성자의 검증뿐이다.
|
||||
|
||||
**무엇이 아닌가**가 이 leaf에서는 무엇인가만큼 중요하고, 코드가 그것을 직접 말한다.
|
||||
|
||||
`build.gradle` 전문:
|
||||
|
||||
```groovy
|
||||
apply plugin: 'java-library'
|
||||
|
||||
dependencies {
|
||||
}
|
||||
```
|
||||
|
||||
`src/main/java` 전체에서 `java.*`와 자기 패키지 밖 import는 **0개**다(`evidence/raw/269` §F). Spring도, Kafka·AMQP 클라이언트도, Reactor도 없다. 이것은 우연이 아니라 원래 계획이 명시한 제약이고(`docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md:13` — "`messaging-core-api`에는 Spring Kafka, Spring AMQP, Pulsar, NATS, Spring `Message<?>`, Reactor 의존성을 넣지 않는다"), 현재 소스에서 재측정해도 참이다.
|
||||
|
||||
경계는 세 방향으로 그어져 있다.
|
||||
|
||||
**브로커 쪽으로.** `MessageDestination`은 논리 이름·카탈로그 타입·payload 클래스만 갖고 topic/exchange/queue/subject를 갖지 않는다(`destination/MessageDestination.java:9-11`). `DestinationName`의 패턴 `[a-z0-9][a-z0-9.-]{0,159}`은 `:`과 `/`와 공백을 배제해서 `topic://orders` 같은 물리 주소를 논리 이름으로 밀어 넣는 것을 생성자에서 막는다(`destination/DestinationName.java:16`). 주석이 이유를 적는다 — "otherwise the physical mapping owned by the destination profile could be bypassed from application code."
|
||||
|
||||
**프로그래밍 모델 쪽으로.** 핵심 계약은 `CompletionStage`다. blocking facade(`BlockingMessagePublisher`)는 인터페이스만 여기 두고 구현을 다른 모듈로 밀어냈으며, Reactor facade는 아예 없다(`publish/MessagePublisher.java:10-11`).
|
||||
|
||||
**애플리케이션 쪽으로.** 이 경계는 이 leaf가 아니라 ArchUnit이 긋는다. `CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`(`CleanArchitectureTest.java:229-240`)은 `..application..` 패키지가 `dev.caskeleton.messaging..`에 의존하는 것을 금지한다. 이유가 규칙 본문에 적혀 있다:
|
||||
|
||||
> the application owns its publish port and outbox model; a bridge adapter translates, and the two outbox status models mean opposite things under the same names
|
||||
|
||||
이 규칙은 §12의 reachability 결과를 읽을 때 반드시 같이 봐야 한다. 이 leaf의 공개 타입 중 다수가 `..application..`에서 참조 0인 것은 **금지되어 있기 때문**이지 잊혀서가 아니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
### 2.1 source 의존성
|
||||
|
||||
들어오는 것: 없음. registry `allowed_dependencies: []`이고 `build.gradle`에 선언이 없다.
|
||||
|
||||
나가는 것(이 leaf를 의존하는 messaging leaf, registry 기준): `messaging-schema-api`, `messaging-schema-json`, `messaging-schema-avro`, `messaging-schema-protobuf`, `messaging-cloudevents`, `messaging-policy`, `messaging-transport-spi`, `messaging-runtime-core`, `messaging-observability`, `messaging-security`, `messaging-kafka`, `messaging-kafka-share-experimental`, `messaging-rabbit`, `messaging-reliability-api`, `messaging-outbox-jdbc-postgresql`, `messaging-inbox-jdbc-postgresql`, `messaging-claim-check`, `messaging-admin-api`, `messaging-admin-runtime`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-cloud-stream-bridge`, `messaging-spring-boot-starter`, `messaging-testkit` — messaging family의 나머지 **24개 전부**.
|
||||
|
||||
### 2.2 런타임 배선
|
||||
|
||||
`runtime_memberships: ["app-bootstrap"]`이고, 그 편입은 직접 선언이 아니라 **전이(transitive)**로 일어난다. `src/app-bootstrap/build.gradle:87`이 선언하는 것은 하나다:
|
||||
|
||||
```groovy
|
||||
implementation project(':messaging:messaging-spring-boot-starter')
|
||||
```
|
||||
|
||||
starter의 `allowed_dependencies`가 17개 leaf를 끌고 오고 그 closure에 `messaging-core-api`가 있다. 즉 **배포 아티팩트가 이 leaf를 싣는다.** 실행 여부는 별개이고 master switch `app.messaging.enabled`(기본 `false`)가 결정한다(`src/messaging/CLAUDE.md:56-57`).
|
||||
|
||||
이 leaf 자체는 bean을 하나도 만들지 않는다. Spring stereotype·`@Bean`·`@Conditional`·`@Profile` 주석이 leaf 전체에 0개다(`evidence/raw/269` §F, `git grep` exit=1). 따라서 §12.2의 conditional sibling 비교는 이 leaf에 **적용 대상이 없다** — 비교할 sibling bean이 존재하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
### 3.1 `api` — 봉투와 값 객체 (12)
|
||||
|
||||
`MessageEnvelope<T>`가 중심이고 나머지 11개가 그 필드 타입이다.
|
||||
|
||||
```
|
||||
MessageEnvelope<T>
|
||||
├── MessageId UUIDv7만 허용
|
||||
├── MessageType 카탈로그 이름, 240 UTF-8 bytes
|
||||
├── SchemaVersion 1 이상
|
||||
├── producedAt Instant
|
||||
├── occurredAt Optional<Instant>
|
||||
├── ProducerId 서비스 이름, 120 bytes
|
||||
├── CorrelationId 워크플로 상관값, 160 bytes
|
||||
├── CausationId → MessageId
|
||||
├── ContentType media type, 160자
|
||||
├── partitionKey Optional<String>, 1024 bytes
|
||||
├── orderingKey Optional<String>, 1024 bytes
|
||||
├── TenantContext [a-z0-9][a-z0-9._-]{0,63}
|
||||
├── TraceContext W3C traceparent/tracestate/baggage
|
||||
├── MessageHeaders ≤64개, ≤32,768 bytes
|
||||
└── payload T, non-null
|
||||
```
|
||||
|
||||
부속: `UuidV7`(생성기), `WireSafeText`(검증 유틸).
|
||||
|
||||
봉투는 불변이고 네 가지 파생 메서드가 있다 — `withPayload`, `withContentType`, `withTenant`, `withHeaders`. 넷 다 `messageId`를 복사한다. `withPayload`의 javadoc이 그 이유를 적는다: "Encoding, decoding, Claim Check offloading, and DLQ forwarding all need this, and every one of them must keep `messageId()` intact — which is exactly what this method guarantees by construction"(`MessageEnvelope.java:80-82`).
|
||||
|
||||
### 3.2 `api.header` — 헤더 (5)
|
||||
|
||||
`HeaderName`, `HeaderValue`, `MessageHeaders`, `ReservedHeaders`, `CanonicalEnvelopeHeaders`.
|
||||
|
||||
`ReservedHeaders`는 23개 이름 상수와 `msg.` **prefix 전체**를 소유한다. `CanonicalEnvelopeHeaders`는 그 예약 네임스페이스를 둘로 쪼갠다 — 봉투 필드가 이미 갖고 있는 15개(`ENVELOPE_FIELDS`)와, 봉투에 대응 필드가 없어서 헤더로만 이동할 수 있는 나머지 8개(`REDRIVE_ID`, `REDRIVE_COUNT`, `RETRY_ATTEMPT`, `FIRST_FAILURE_AT`, `LAST_FAILURE_AT`, `FAILURE_CATEGORY`, `FAILURE_CODE`, `ORIGIN_DESTINATION`).
|
||||
|
||||
### 3.3 `api.destination` — 목적지 (7)
|
||||
|
||||
`MessageDestination<T>`, `DestinationName`, `DestinationKind`(7), `MessagingCapabilities`(boolean 12), `DestinationCapabilities`, `ConfirmationRequirement`(3), `CapabilityRegistry`.
|
||||
|
||||
### 3.4 `api.publish` — 발행 (17)
|
||||
|
||||
퍼블리셔 4종(`MessagePublisher`, `BlockingMessagePublisher`, `BatchMessagePublisher`, `DelayedMessagePublisher`), 요청 3종, 결과 5종, 증거 3종, enum 3종(`PublishCompletion`, `ConfirmationLevel`, `RoutingOutcome`, `TransmissionEvidence` — 4종), `BrokerPosition`.
|
||||
|
||||
### 3.5 `api.delivery` — 수신 (13)
|
||||
|
||||
`MessageDelivery<T>`, `DeliveryMetadata`, `DeliveryContext`, `MessageHandler<T>`, `BatchMessageDelivery<T>`, `BatchDeliveryMetadata`, `BatchMessageHandler<T>`, `HandleResult`(sealed, 4 변형), `PauseResumeController`, enum 4종.
|
||||
|
||||
### 3.6 `api.settlement` — 수동 정산 (5)
|
||||
|
||||
`ManualMessageHandler<T>`, `SettlementController`, `SettlementResult`, `SettlementEvidence`, `SettlementCompletion`.
|
||||
|
||||
### 3.7 `api.error` — 실패 (26)
|
||||
|
||||
`FailureCategory`(10), `FailureDescriptor`, `MessagingException`(abstract) + 구체 예외 23종.
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
이 leaf의 실질은 여기 있다. **표현할 수 없는 상태를 생성자에서 거절하는 것**이 설계의 축이다.
|
||||
|
||||
### 4.1 발행 결과: 3상태와 12개 금지 조합
|
||||
|
||||
`PublishCompletion`은 boolean이 아니라 3상태다.
|
||||
|
||||
| 값 | 의미 | 호출자가 할 수 있는 것 |
|
||||
|---|---|---|
|
||||
| `CONFIRMED` | 요구 수준으로 브로커가 수락 | 완료 |
|
||||
| `REJECTED` | 확실히 저장되지 않음 | 이 시도를 버려도 안전 |
|
||||
| `AMBIGUOUS` | 브로커가 갖고 있을 수도 있음 | **같은 `messageId`로만** 재발행 |
|
||||
|
||||
enum javadoc이 왜 셋인지 적는다: "Collapsing 'the broker refused this' and 'we never learned what the broker did' into one failure is what produces duplicate orders"(`publish/PublishCompletion.java:6-8`).
|
||||
|
||||
`PublishResult` 생성자(`publish/PublishResult.java:39-101`)가 거절하는 조합 12가지:
|
||||
|
||||
| # | 거절 조건 | 이유(코드/주석 기준) |
|
||||
|---:|---|---|
|
||||
| 1 | `attempts < 1` | 첫 시도가 1 |
|
||||
| 2 | `elapsed < 0` | — |
|
||||
| 3 | `CONFIRMED` + `!brokerAccepted` | 확인은 브로커 수락을 전제 |
|
||||
| 4 | `CONFIRMED` + `confirmationLevel == NONE` | 확인 수준 없는 확인은 확인이 아님 |
|
||||
| 5 | `CONFIRMED` + `UNROUTABLE` | 라우팅 실패를 성공으로 읽히게 함 |
|
||||
| 6 | `AMBIGUOUS` + `confirmationLevel != NONE` | 모호한데 확인을 주장 |
|
||||
| 7 | `AMBIGUOUS` + `brokerAccepted` | 같은 이유 |
|
||||
| 8 | `AMBIGUOUS` + `NOT_TRANSMITTED` | 나가지 않은 것은 모호가 아니라 거절 |
|
||||
| 9 | `!CONFIRMED` + `failure.isEmpty()` | 실패 서술 없는 실패 |
|
||||
| 10 | `CONFIRMED` + `failure.isPresent()` | 성공에 실패 서술 |
|
||||
| 11 | `REJECTED` + `brokerAccepted` | **"한 주문이 둘이 되는 조합"** |
|
||||
| 12 | `CONFIRMED` + `UNKNOWN` routing | 확인해 준 응답이 라우팅도 말한다 |
|
||||
| 13 | `AMBIGUOUS` + `ROUTED` | 라우팅을 보고한 브로커는 답한 것 |
|
||||
| 14 | `position.isPresent()` + `NOT_TRANSMITTED` | 나가지 않은 메시지의 좌표는 남의 것 |
|
||||
|
||||
11번과 14번에는 코드 주석이 직접 달려 있다.
|
||||
|
||||
```java
|
||||
if (completion == PublishCompletion.REJECTED && evidence.brokerAccepted()) {
|
||||
// A broker that acknowledged the message did not reject it. Left representable, this is the
|
||||
// combination that turns a delivered message into one the caller re-publishes as if it had
|
||||
// never been sent.
|
||||
throw new IllegalArgumentException("rejected publish cannot claim broker acceptance");
|
||||
}
|
||||
```
|
||||
|
||||
record가 public이고 모든 adapter가 이것을 만들기 때문에 호출부를 믿지 않고 여기서 검증한다는 것도 javadoc에 적혀 있다(`PublishResult.java:18-20`).
|
||||
|
||||
### 4.2 증거는 결론보다 먼저 기록된다
|
||||
|
||||
`PublishEvidence`(`publish/PublishEvidence.java`)는 `queuedLocally`, `transmission`, `brokerAccepted`, `confirmationLevel` 넷을 갖고, javadoc이 순서를 못 박는다 — "Evidence is recorded before a completion is chosen, not derived from it. That ordering is what lets an operator answer 'could the broker be holding this message?' from a stored result."
|
||||
|
||||
`TransmissionEvidence`가 3상태(`NOT_TRANSMITTED` / `MAY_HAVE_BEEN_TRANSMITTED` / `TRANSMITTED`)인 것이 그 순서를 가능하게 한다.
|
||||
|
||||
### 4.3 정산: 같은 3상태 규율
|
||||
|
||||
`SettlementResult`(`settlement/SettlementResult.java:23-36`)도 같은 형태다.
|
||||
|
||||
- `SETTLED`인데 `!brokerConfirmed` → 거절
|
||||
- `SETTLED`인데 `redeliveryPossible` → 거절
|
||||
- `!SETTLED`인데 `failure.isEmpty()` → 거절
|
||||
|
||||
`SettlementEvidence`는 `brokerConfirmed && !transmitted`를 거절한다. javadoc: "Treating an unconfirmed acknowledgement as settled is the classic route to a message that looks processed in logs and is processed again minutes later."
|
||||
|
||||
### 4.4 없는 것으로 말하는 계약
|
||||
|
||||
세 enum이 **일부러 비어 있는 자리**를 갖는다.
|
||||
|
||||
| enum | 없는 값 | 코드가 적은 이유 |
|
||||
|---|---|---|
|
||||
| `DeliveryGuarantee` | `EXACTLY_ONCE` | "No broker delivers exactly-once across an external side effect... Naming a guarantee the platform cannot honour would push that responsibility out of sight, so the enum stops where the evidence stops." |
|
||||
| `OrderingScope` | `GLOBAL` | "Ordering is a property of a partition, a key mapping, or a single consumer — never of a whole destination." |
|
||||
| `PublishOptions` | 자유형 hint map | "One existed for a native surface that does not read it... an escape hatch around destination policy that never opened." |
|
||||
|
||||
이 셋은 테스트로 붙들려 있다 — `CoreValueTypesTest.guaranteeEnumsDoNotAdvertiseUnsupportedSemantics`가 `values()`에 `EXACTLY_ONCE`와 `GLOBAL`이 없음을 단언한다(`CoreValueTypesTest.java:25-29`). 이름이 다시 추가되면 테스트가 깨진다.
|
||||
|
||||
### 4.5 wire 안전성: 한 곳에 모은 규칙
|
||||
|
||||
`WireSafeText`(`WireSafeText.java`)가 두 가지를 한다.
|
||||
|
||||
```java
|
||||
public static void requireNoControls(String value, String what) {
|
||||
for (int index = 0; index < value.length(); index++) {
|
||||
char character = value.charAt(index);
|
||||
if (character < 0x20 || character == 0x7F) { throw ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **바이트로 센다.** javadoc: "A `char` count bounds nothing on a wire: a 240-character string is up to 960 UTF-8 bytes."
|
||||
- **제어문자를 정제하지 않고 거절한다.** "Silently stripping a CR turns a caller's two-line value into a one-line value that no longer means what they wrote, and the caller never learns."
|
||||
- **탭도 거절한다.** HTTP 필드 값에서는 합법이지만 "a header carried over a line-folding binding and the same header carried over a length-prefixed one disagree about whether a tab ends the value."
|
||||
|
||||
호출자: `CorrelationId`(160), `MessageType`(240), `ProducerId`(120), `HeaderValue`(4096), `MessageEnvelope`의 partitionKey/orderingKey(1024), `TraceContext.baggage`.
|
||||
|
||||
`HeaderName`은 `WireSafeText`를 쓰지 않고 자체 정규식 `[a-zA-Z0-9!#$%&'*+._|~-]+`(HTTP token)을 쓴다. 더 엄격하다 — 공백·콜론·비ASCII를 전부 배제한다. 그리고 trim하지 않고 **선행/후행 공백을 거절**한다. 주석이 이유를 적는다:
|
||||
|
||||
```java
|
||||
if (!value.equals(value.strip())) {
|
||||
// Trimming would mean `Authorization ` and `Authorization` are the same name to the
|
||||
// denylist and different names on the wire, which is precisely how the check was bypassed.
|
||||
```
|
||||
|
||||
### 4.6 자격증명 헤더 차단: 정확 일치 → 세그먼트 매칭
|
||||
|
||||
`MessageHeaders.carriesACredential`(`header/MessageHeaders.java:142-160`)은 두 단계다.
|
||||
|
||||
1. `SECRET_NAMES` 9개 정확 일치(`authorization`, `cookie`, `access_token`, …)
|
||||
2. `SECRET_SEGMENTS` 10개를 `[._\-]+`로 쪼갠 **세그먼트** 단위로 검사, 그리고 **인접 세그먼트를 붙여서** 한 번 더 검사
|
||||
|
||||
```java
|
||||
// Adjacent segments are also tested joined, because the same word is written both ways:
|
||||
// `api_key` is one segment to a reader and two to a splitter, and `x-api-key` is two of
|
||||
// three. Joining only neighbouring pairs is what keeps `routing-key` accepted.
|
||||
```
|
||||
|
||||
두 방향 다 테스트가 있다. `x-api-key`·`auth-token`·`db_password`·`request.signature`·`Cookie`는 거절되고(`WireBoundaryRejectionTest.java:166-175`), `tokenizer-version`·`secretariat-id`는 통과한다(`:177-188`). 부분문자열 매칭이었으면 후자가 오탐이 된다.
|
||||
|
||||
거절 메시지는 **이름만** 담고 값은 절대 담지 않는다. 주석: "an error message is written to a log that is exactly as readable as the broker storage this check exists to keep the value out of."
|
||||
|
||||
### 4.7 예약 네임스페이스: 이름 목록 → prefix 소유
|
||||
|
||||
`ReservedHeaders.isReserved`(`header/ReservedHeaders.java:135-141`)는 23개 이름 집합 **또는** `msg.` prefix로 판정한다.
|
||||
|
||||
```java
|
||||
// The check used to be exact membership of NAMES, so `msg.anything` that this
|
||||
// release has not defined was an ordinary application header — until a later release defined it,
|
||||
// at which point every application already writing it silently started overwriting envelope
|
||||
// metadata. Owning the prefix means a new platform header is a compatible change.
|
||||
```
|
||||
|
||||
테스트가 이 성질을 직접 붙든다 — `ReservedHeaders.isReserved("msg.not-defined-in-this-release")`가 `true`이고, 애플리케이션이 `msg.not-defined-yet`을 쓰면 거절되며, platform factory는 여전히 쓸 수 있다(`WireBoundaryRejectionTest.java:190-207`).
|
||||
|
||||
### 4.8 `MessageHeaders`의 두 factory
|
||||
|
||||
| factory | 예약 이름 | 자격증명 이름 | 호출자 |
|
||||
|---|---|---|---|
|
||||
| `application(Map)` | 거절 | 거절 | 업무 코드 |
|
||||
| `platform(Map)` | **허용** | 거절 | wire에서 봉투를 복원하는 adapter |
|
||||
|
||||
자격증명은 양쪽 다 거절이다. javadoc: "a credential that reaches a header ends up in broker storage, DLQ dumps, and operator tooling, and no downstream redaction can undo that."
|
||||
|
||||
### 4.9 `MessageId`: 타입 이름과 실제 검증의 정렬
|
||||
|
||||
```java
|
||||
if (value.version() != VERSION_7) {
|
||||
throw new IllegalArgumentException(
|
||||
"a message identity is UUIDv7 (time-ordered); this is version " + value.version());
|
||||
}
|
||||
if (value.variant() != 2) {
|
||||
throw new IllegalArgumentException("a message identity must use the RFC 4122 variant");
|
||||
}
|
||||
```
|
||||
|
||||
주석이 왜 이 검증이 생겼는지 적는다: "The type says UUIDv7 and the constructor accepted any UUID, including v4 and the nil UUID. Version 7 is what makes the identity time-ordered, which is what the outbox index and every 'oldest first' claim depend on; a v4 stored in the same column silently defeats both."
|
||||
|
||||
테스트가 그 문장을 그대로 단언한다 — `new MessageId(UUID.randomUUID())`는 거절되고 이유 문자열에 `UUIDv7`이 포함된다(`WireSafeValueObjectTest.java:73-80`, `as("a v4 in the same column defeats every 'oldest first' claim the outbox makes")`).
|
||||
|
||||
> **주의.** 이것은 **이 leaf의** `MessageId`에만 해당한다. 저장소의 다른 UUIDv7 구현들은 별개이고 §12.3에서 다룬다.
|
||||
|
||||
### 4.10 `UuidV7`: 밀리초 내 단조성
|
||||
|
||||
`UuidV7.advance`(`UuidV7.java:54-61`)는 48비트 타임스탬프와 12비트 카운터를 하나의 `AtomicLong`에 packing하고 `updateAndGet`으로 CAS 루프를 돈다.
|
||||
|
||||
```java
|
||||
private static long advance(long previous) {
|
||||
long now = System.currentTimeMillis();
|
||||
long previousTimestamp = previous >>> COUNTER_BITS;
|
||||
if (now > previousTimestamp) {
|
||||
return now << COUNTER_BITS;
|
||||
}
|
||||
return previous + 1;
|
||||
}
|
||||
```
|
||||
|
||||
RFC 9562의 `rand_a` 12비트를 난수가 아니라 **밀리초 내 단조 카운터**로 쓴다. 시계가 뒤로 가도 `previous + 1`이므로 중복이나 역행이 나오지 않고 "미래에서 빌려올" 뿐이다. 카운터가 넘치면 타임스탬프 필드로 자연히 carry된다.
|
||||
|
||||
이 성질은 `CoreValueTypesTest.newMessageIdIsVersionSevenAndTimeOrdered`가 두 연속 호출의 `compareTo`가 음수임을 단언해서 붙든다. 다만 **단일 스레드 2회 호출**이므로 경합 하 단조성은 이 테스트가 증명하지 않는다(§16 참조).
|
||||
|
||||
### 4.11 `TraceContext`: 표준을 실제로 검사한다
|
||||
|
||||
세 값이 전부 `Optional<String>`이고 non-null 검사만 있던 시절의 기록이 javadoc에 남아 있다 — "which made this record a general-purpose string carrier wearing the name of a standard."
|
||||
|
||||
현재 검사:
|
||||
|
||||
| 필드 | 규칙 |
|
||||
|---|---|
|
||||
| `traceparent` | `[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}` 정확 일치, `ff` 버전 거절, all-zero trace id 거절, all-zero span id 거절 |
|
||||
| `tracestate` | ≤512 bytes, ≤32 list member, 각 member가 `key=value` 또는 `tenant@vendor=value` 문법, 빈 member는 허용(전방호환) |
|
||||
| `baggage` | ≤8,192 bytes, ≤64 member, 제어문자 없음, 각 member `key=value` |
|
||||
| 조합 | `tracestate`가 있는데 `traceparent`가 없으면 거절 |
|
||||
|
||||
대문자 hex를 접는 대신 **거절**하는 이유도 적혀 있다: "the standard defines the field as lowercase, and a receiver comparing trace IDs as strings — which collectors do — would treat the two cases as two different traces."
|
||||
|
||||
`tracestate` 단독 거절 이유: "vendor state belonging to no trace. Propagating it hands the next hop a key it will attribute to whatever trace that hop starts."
|
||||
|
||||
7개 무효 traceparent가 파라미터 테스트로 전부 커버된다(`WireBoundaryRejectionTest.java:71-90`).
|
||||
|
||||
### 4.12 실패 분류와 기본 재시도 정책
|
||||
|
||||
`FailureCategory` 10개, `FailureDescriptor.defaultRetryable`(`error/FailureDescriptor.java:67-79`)이 그 중 3개만 재시도 가능으로 본다.
|
||||
|
||||
| retryable = true | retryable = false |
|
||||
|---|---|
|
||||
| `TRANSIENT_INFRASTRUCTURE` | `PERMANENT_BUSINESS`, `POISON_MESSAGE`, `DESERIALIZATION`, `AUTHENTICATION`, `AUTHORIZATION`, **`AMBIGUOUS`**, `CONFIGURATION` |
|
||||
| `THROTTLED` | |
|
||||
| `PROCESSING_TRANSIENT` | |
|
||||
|
||||
`AMBIGUOUS`가 false인 것은 모순이 아니라 설계다. 모호한 발행은 **자동** 재시도 대상이 아니고, 호출자가 같은 `messageId`로 재발행할지를 결정한다(`MessagePublishAmbiguousException` javadoc).
|
||||
|
||||
`FailureDescriptor`는 DLQ까지 이동하므로 payload·스택트레이스·자격증명·실제 메시지 키를 담지 않고, `sanitizedMessage`는 512자에서 **잘린다**(거절이 아니라 절단). javadoc: "Stack traces belong in secure log storage; a DLQ is read by more people than the log is."
|
||||
|
||||
### 4.13 `HandleResult`: sealed 4변형
|
||||
|
||||
`Success` / `Retry(FailureDescriptor)` / `DeadLetter(FailureDescriptor)` / `Reject(FailureDescriptor)`. 어떤 변형도 브로커 ack 핸들을 갖지 않는다. javadoc: "The handler states an intent; the platform performs the settlement."
|
||||
|
||||
`ConsumerContractTest.handleResultPermitsExactlyTheFourDeclaredOutcomes`가 `getPermittedSubclasses()`로 이 집합을 고정한다.
|
||||
|
||||
### 4.14 배치는 트랜잭션이 아니다
|
||||
|
||||
`BatchPublishResult`는 항목별 결과를 제출 인덱스와 함께 보존하고 배치 수준 boolean으로 접지 않는다. `BatchPublishOptions`에는 **retry 설정이 없다**. javadoc: "retrying the batch would resubmit entries that already confirmed."
|
||||
|
||||
`BatchDeliveryMetadata.isSafeForOrderedDestination()`은 `orderingUnit.isPresent()`다 — 두 파티션에서 끌어온 배치는 순서 보장 목적지에 넘길 수 없다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
이 leaf에는 실행 경로가 거의 없다. 실제로 코드가 도는 지점은 넷이다.
|
||||
|
||||
1. **봉투 생성** — `new MessageEnvelope<>(...)` → 14개 non-null 검사 + partitionKey/orderingKey wire 검사
|
||||
2. **헤더 생성** — `MessageHeaders.application/platform(Map)` → 개수(≤64) → 이름별 예약/자격증명/중복 검사 → 총 바이트(≤32,768)
|
||||
3. **식별자 생성** — `MessageId.newId()` → `UuidV7.next()` → `AtomicLong.updateAndGet(advance)`
|
||||
4. **결과 조립** — `new PublishResult(...)` / `new SettlementResult(...)` → 조합 검증
|
||||
|
||||
나머지는 전부 인터페이스 선언이고, 구현은 `messaging-runtime-core`·`messaging-kafka`·`messaging-rabbit` 등 다른 leaf가 소유한다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
### 6.1 계층
|
||||
|
||||
`MessagingException`(abstract) → 23개 구체 예외. 기반 타입이 `FailureDescriptor`를 갖고 `category()`·`retryable()`를 위임한다. javadoc이 목적을 적는다 — "a caller catching the base type can still classify and route the failure without matching on exception classes."
|
||||
|
||||
### 6.2 23개 예외의 카테고리·재시도 전수표
|
||||
|
||||
| 예외 | category | retryable | leaf 밖 참조 |
|
||||
|---|---|:---:|:---:|
|
||||
| `MessageAuthenticationException` | `AUTHENTICATION` | false | **0** |
|
||||
| `MessageAuthorizationException` | `AUTHORIZATION` | false | 16 |
|
||||
| `MessageBackpressureException` | `TRANSIENT_INFRASTRUCTURE` | true | 4 |
|
||||
| `MessageBrokerUnavailableException` | `TRANSIENT_INFRASTRUCTURE` | true | **0** |
|
||||
| `MessageConsumerException` | `PROCESSING_TRANSIENT` | true | **0** |
|
||||
| `MessageDeadLetterException` | `TRANSIENT_INFRASTRUCTURE` | true | **0** |
|
||||
| `MessageHandlerTimeoutException` | `PROCESSING_TRANSIENT` | true | **0** |
|
||||
| `MessageHeaderRejectedException` | `PERMANENT_BUSINESS` | false | **0** |
|
||||
| `MessagePublishAmbiguousException` | `AMBIGUOUS` | false | **0** |
|
||||
| `MessagePublishRejectedException` | `PERMANENT_BUSINESS` | false | **0** |
|
||||
| `MessagePublishTimeoutException` | `AMBIGUOUS` | false | 2 |
|
||||
| `MessageRedriveException` | `TRANSIENT_INFRASTRUCTURE` | true | **0** |
|
||||
| `MessageRetryExhaustedException` | `PERMANENT_BUSINESS` | false | **0** |
|
||||
| `MessageRoutingException` | `PERMANENT_BUSINESS` | false | **0** |
|
||||
| `MessageSchemaIncompatibleException` | `DESERIALIZATION` | false | 4 |
|
||||
| `MessageSerializationException` | `DESERIALIZATION` | false | 8 |
|
||||
| `MessageSettlementException` | `TRANSIENT_INFRASTRUCTURE` | true | 1 |
|
||||
| `MessageSettlementUnknownException` | `AMBIGUOUS` | false | **0** |
|
||||
| `MessageTooLargeException` | `PERMANENT_BUSINESS` | false | 17 |
|
||||
| `MessageTopologyException` | `CONFIGURATION` | false | 2 |
|
||||
| `MessageValidationException` | `PERMANENT_BUSINESS` | false | 16 |
|
||||
| `MessagingCapabilityUnavailableException` | `CONFIGURATION` | false | 13 |
|
||||
| `MessagingConfigurationException` | `CONFIGURATION` | false | 59 |
|
||||
|
||||
**23개 중 12개가 leaf 밖에서 한 번도 참조되지 않는다**(`evidence/raw/269` §B, 12개 전부 `git grep` exit=1). §12.1에서 다룬다.
|
||||
|
||||
### 6.3 조용한 성능 저하를 막는 설계
|
||||
|
||||
`MessagingCapabilityUnavailableException` javadoc: "Downgrading replication evidence to a bare ack, or ordered delivery to unordered, produces a system that looks healthy right up to the moment the guarantee actually mattered."
|
||||
|
||||
`MessageBackpressureException` javadoc: "Blocking the caller until a slot frees turns producer-side saturation into thread exhaustion in the calling application, which is a far worse failure than a fast rejection." 그리고 "Nothing was transmitted when this is thrown, so the message has no ambiguity."
|
||||
|
||||
---
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
트랜잭션 개념이 이 leaf에는 두 가지 형태로만 등장하고 둘 다 **선언**이다.
|
||||
|
||||
- `MessagingCapabilities.brokerTransaction` — 브로커가 트랜잭션 스코프를 제공하는가
|
||||
- `ProcessingGuarantee.BROKER_TRANSACTIONAL` — "Atomicity holds only inside the transaction scope the broker itself defines"
|
||||
- `ExternalSideEffectGuarantee.INBOX_TRANSACTIONAL` — "An Inbox row and the side effect commit inside the same database transaction"
|
||||
|
||||
DB 트랜잭션은 이 leaf가 만지지 않는다.
|
||||
|
||||
동시성 지점은 **하나**다: `UuidV7.STATE`(`AtomicLong`). `updateAndGet`이 CAS 루프이므로 다중 스레드에서도 각 호출이 서로 다른 packed state를 얻는다. `RANDOM`(`SecureRandom`)은 thread-safe다.
|
||||
|
||||
`MessageHeaders`는 생성 시 `LinkedHashMap`에 복사하고 `Collections.unmodifiableMap`으로 감싸 반환하므로 공유 안전하다. 다만 `find(String)`이 `values.entrySet().stream()` 선형 탐색이다 — 최대 64개이므로 실용상 문제는 아니지만 hot path에서 반복 호출되면 O(n)이다.
|
||||
|
||||
수명주기 개념은 `DeliveryContext.shutdownRequested`뿐이고, javadoc이 목적을 적는다 — "during a graceful drain the platform stops creating new retry attempts, and a long-running handler that can wind down early shortens the drain instead of being cancelled at the deadline." **이 필드는 production에서 도달 불가능하다**(§12.1).
|
||||
|
||||
---
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
이 leaf에는 설정이 **없다**. properties·yaml·환경변수·시스템 프로퍼티를 읽는 코드가 0이다. 모든 값은 컴파일 타임 상수다.
|
||||
|
||||
경계값 전수:
|
||||
|
||||
| 상수 | 값 | 위치 |
|
||||
|---|---:|---|
|
||||
| `ContentType.MAX_LENGTH` | 160자 | `ContentType.java:12` |
|
||||
| `CorrelationId.MAX_BYTES` | 160 | `CorrelationId.java:16` |
|
||||
| `MessageType.MAX_BYTES` | 240 | `MessageType.java:13` |
|
||||
| `ProducerId.MAX_BYTES` | 120 | `ProducerId.java:13` |
|
||||
| `MessageEnvelope.MAX_KEY_BYTES` | 1,024 | `MessageEnvelope.java:75` |
|
||||
| `HeaderName.MAX_BYTES` | 128 | `HeaderName.java:16` |
|
||||
| `HeaderValue.MAX_BYTES` | 4,096 | `HeaderValue.java:18` |
|
||||
| `MessageHeaders.MAX_COUNT` | 64 | `MessageHeaders.java:22` |
|
||||
| `MessageHeaders.MAX_TOTAL_BYTES` | 32,768 | `MessageHeaders.java:23` |
|
||||
| `TenantContext` 패턴 | `[a-z0-9][a-z0-9._-]{0,63}` | `TenantContext.java:16` |
|
||||
| `DestinationName` 패턴 | `[a-z0-9][a-z0-9.-]{0,159}` | `DestinationName.java:16` |
|
||||
| `TraceContext.MAX_TRACESTATE_BYTES` | 512 | `TraceContext.java:53` |
|
||||
| `TraceContext.MAX_TRACESTATE_MEMBERS` | 32 | `TraceContext.java:51` |
|
||||
| `TraceContext.MAX_BAGGAGE_BYTES` | 8,192 | `TraceContext.java:56` |
|
||||
| `TraceContext.MAX_BAGGAGE_MEMBERS` | 64 | `TraceContext.java:58` |
|
||||
| `FailureDescriptor.MAX_MESSAGE_LENGTH` | 512자(절단) | `FailureDescriptor.java:26` |
|
||||
| `FailureDescriptor.MAX_CODE_LENGTH` | 120자(거절) | `FailureDescriptor.java:27` |
|
||||
| `PublishOptions.DEFAULT_TIMEOUT` | 5초 | `PublishOptions.java:25` |
|
||||
| `BatchPublishOptions.DEFAULT_TIMEOUT` | 30초 | `BatchPublishOptions.java:21` |
|
||||
| `BatchPublishOptions.DEFAULT_MAX_BATCH_SIZE` | 500 | `BatchPublishOptions.java:22` |
|
||||
|
||||
`PublishOptions.defaults()`가 요구하는 확인 수준은 `REPLICATION_OR_PERSISTENCE_ACK`다 — 기본값이 가장 강한 보장이고, 약하게 쓰려면 명시해야 한다.
|
||||
|
||||
단위가 섞인 곳이 하나 있다. `ContentType`은 **문자** 160, 다른 문자열 값 객체는 **바이트**다. `ContentType`은 미디어 타입 정규식이 ASCII만 허용하므로 실질 차이가 없지만, 이 leaf에서 유일하게 `WireSafeText`를 쓰지 않는 문자열 값이다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
없다. 이 leaf는 DB·브로커·파일시스템·네트워크를 만지지 않는다. `SecureRandom`(엔트로피)과 `System.currentTimeMillis()`(시계)가 유일한 외부 접촉이고 둘 다 `UuidV7` 안에 있다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
레인은 하나다: `./gradlew :messaging:messaging-core-api:test`. 실행 결과 **BUILD SUCCESSFUL**, 79 tests, 0 skipped, 0 failures (`--rerun-tasks`, revision `21234e38`).
|
||||
|
||||
| 테스트 클래스 | 수 | 무엇을 실제로 증명하는가 | 무엇을 증명하지 않는가 |
|
||||
|---|---:|---|---|
|
||||
| `CoreValueTypesTest` | 7 | 값 객체 거절 조건, `MessageId` v7/variant 2, 연속 2회 시간순, `EXACTLY_ONCE`/`GLOBAL` 부재 | 경합 하 `UuidV7` 단조성 |
|
||||
| `MessageEnvelopeTest` | 11 | 예약/비밀 헤더 거절(대소문자 무관), platform factory의 예약 쓰기 허용, 개수·바이트·이름·값 상한, `withPayload`의 identity 보존 | 실제 브로커가 이 값을 받아들이는지 |
|
||||
| `WireSafeValueObjectTest` | 7 | 헤더 이름 CRLF·NUL·콜론·후행공백 거절, 메시지 타입 개행 거절, 바이트 경계, v4 거절 | — |
|
||||
| `WireBoundaryRejectionTest` | 27 | 제어문자 6종 파라미터화, 바이트 경계, traceparent 무효 7종, tracestate/baggage 경계, 자격증명 이름 5종 거절 + 오탐 2종 통과, `msg.` prefix 소유 | 실제 collector/브로커 동작 |
|
||||
| `ConsumerContractTest` | 8 | `HandleResult` 4변형 고정, attempt 1 규칙, redelivered 모순 거절, `SETTLED` 불변식, `DeliveryContext.isExpired` 경계 | production이 `DeliveryContext`를 만드는지 |
|
||||
| `DestinationCapabilityTest` | 5 | 논리 이름에 브로커 주소 불가, 대문자 거절, `MessagingCapabilities.none()`, `DestinationKind` 7종 고정 | capability 선언이 실제 브로커와 맞는지 |
|
||||
| `PublishResultTest` | 13 | §4.1의 금지 조합 중 9가지를 직접 단언 | 실제 adapter가 이 조합을 만들지 않는지 |
|
||||
| `ModuleSmokeTest` | 1 | 패키지 이름 | 사실상 아무것도 |
|
||||
|
||||
**이 레인이 증명하는 것의 성격.** 전부 `new`로 값을 만들고 예외를 기대하는 순수 단위 테스트다. 브로커도, Spring 컨텍스트도, 네트워크도 없다. 그래서 "계약이 자기 자신과 모순되지 않는다"는 증명되고, "adapter가 이 계약을 지킨다"는 증명되지 않는다. 후자는 `messaging-kafka`·`messaging-rabbit`의 contract harness가 소유하고 이 leaf 밖이다.
|
||||
|
||||
`ConsumerContractTest.deliveryContextReportsHandlerDeadlineExpiry`가 특히 그렇다 — 경계 동작은 정확히 검증되지만, §12.1이 보이듯 production 코드는 `DeliveryContext`를 만들지 않으므로 그 검증이 실행 경로를 보호하고 있지는 않다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
| 게이트 | 위치 | 이 leaf에 대해 실제로 무엇을 막는가 | 실패 지점 |
|
||||
|---|---|---|---|
|
||||
| registry fail-closed | `src/config/architecture/modules.json` + `ca.architecture-registry.settings.gradle` | 등록되지 않은 leaf는 settings에 포함되지 않음 | Gradle configuration |
|
||||
| `verifyCleanArchitectureDependencies` | `src/build.gradle` | 실제 project 의존 edge를 `allowed_dependencies: []`와 대조 — 이 leaf에 의존성을 하나라도 추가하면 실패 | Gradle task |
|
||||
| `verifyRuntimeModuleMembership` | `src/build.gradle` | 코드만 추가해서 런타임에 들어가는 것을 막음. registry를 먼저 고쳐야 함 | Gradle task |
|
||||
| `APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM` | `CleanArchitectureTest.java:229` | `..application..`이 `dev.caskeleton.messaging..`을 참조하는 것을 금지 | ArchUnit |
|
||||
| checkstyle / spotbugs | convention plugin | `build/reports/{checkstyle,spotbugs}` 생성 확인 | Gradle |
|
||||
|
||||
**이 leaf에 직접 걸리는 messaging 전용 ArchUnit 규칙은 없다.** `MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS`(`CleanArchitectureTest.java:2068`)는 `..adapter.outbound.messaging..`을 대상으로 하고 이 leaf(`dev.caskeleton.messaging.api`)가 아니다.
|
||||
|
||||
`src/build.gradle:65-110`의 `messagingVerificationSkeletons`(9개 `verifyMessaging*` task)는 전부 `app-bootstrap/build/messaging-evidence/**/manifest.json`을 요구하는 fail-closed 자격 게이트이고, 이 leaf의 산출물을 요구하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 12. 실제 사용 여부와 negative-space probes
|
||||
|
||||
원시 증거: `evidence/raw/269-messaging-core-api-reachability.txt`, `evidence/raw/270-messaging-runtime-membership-doc-drift.txt`.
|
||||
|
||||
검색 명령(전부 revision `21234e38`에서 실행):
|
||||
|
||||
```bash
|
||||
git grep -n -w '<PublicType>' -- src ':!src/messaging/messaging-core-api'
|
||||
```
|
||||
|
||||
`git grep`은 무매치에서 exit 1을 반환하므로, 아래의 "0"은 전부 exit 1로 확인한 값이다.
|
||||
|
||||
### 12.1 Public surface reachability
|
||||
|
||||
85개 타입 중 leaf 밖 참조가 0인 것은 **21개**다. 성격이 다른 세 묶음으로 나뉜다.
|
||||
|
||||
**(a) 내부 헬퍼 — 문제 없음 (1)**
|
||||
|
||||
`WireSafeText`. 이 leaf의 값 객체들이 내부적으로만 부른다. public인 것은 패키지가 나뉘어 있어서다.
|
||||
|
||||
**(b) 소비자 없는 예외 어휘 (12)**
|
||||
|
||||
`MessageAuthenticationException`, `MessageBrokerUnavailableException`, `MessageConsumerException`, `MessageDeadLetterException`, `MessageHandlerTimeoutException`, `MessageHeaderRejectedException`, `MessagePublishAmbiguousException`, `MessagePublishRejectedException`, `MessageRedriveException`, `MessageRetryExhaustedException`, `MessageRoutingException`, `MessageSettlementUnknownException`.
|
||||
|
||||
기반 타입 `MessagingException`은 살아 있다 — leaf 밖 3곳이 쓴다:
|
||||
|
||||
- `DefaultMessagingAdminService.java:223` — `instanceof`로 분류
|
||||
- `ClaimCheckIntegrityException.java:20` — `extends`
|
||||
- `PublishResults.java:37` — `instanceof`로 sanitized descriptor 추출
|
||||
|
||||
즉 **계층은 쓰이고 잎은 쓰이지 않는다.** 특히 `MessagePublishAmbiguousException`은 이 설계 전체의 중심 개념(`AMBIGUOUS`)에 이름을 준 타입인데 아무도 던지지 않는다. adapter들은 예외 대신 `PublishResult`를 반환하는 경로를 쓰고(§6.2에서 `MessagingConfigurationException` 59회, `MessageTooLargeException` 17회처럼 실제로 쓰이는 것들은 대부분 **설정/검증** 계열이다), 발행·정산의 실패는 결과 record로 흐른다.
|
||||
|
||||
**(c) 소비자 없는 consumer-side 계약 (8)**
|
||||
|
||||
| 타입 | 선언된 역할 | leaf 밖 참조 |
|
||||
|---|---|:---:|
|
||||
| `MessageHandler<T>` | "The M1 typed handler implemented by ordinary business code" | 0 |
|
||||
| `BatchMessageHandler<T>` | "The M2 batch consume entry point" | 0 |
|
||||
| `BatchMessageDelivery<T>` | 배치 핸들러에 넘겨지는 배치 | 0 |
|
||||
| `ManualMessageHandler<T>` | "The M2 handler that settles its own deliveries" | 0 |
|
||||
| `PauseResumeController` | "The M2 consumer flow-control entry point" | 0 |
|
||||
| `ProcessingGuarantee` | 중복 처리 무력화 방식 | 0 |
|
||||
| `DelayedMessagePublisher` | "The M2 scheduled-delivery entry point" | 0 |
|
||||
| `CapabilityRegistry` | 목적지별 capability 해석 | 0 |
|
||||
|
||||
이 중 `MessageHandler<T>`가 가장 무겁다. **선언된 핸들러 계약과 실제로 배선된 핸들러 계약이 다르다.**
|
||||
|
||||
`messaging-core-api`가 선언하는 것:
|
||||
|
||||
```java
|
||||
// delivery/MessageHandler.java:14-22
|
||||
public interface MessageHandler<T> {
|
||||
CompletionStage<HandleResult> handle(MessageDelivery<T> delivery);
|
||||
}
|
||||
```
|
||||
|
||||
`MessageDelivery<T>`는 `MessageEnvelope<T>` + `DeliveryMetadata` + `DeliveryContext`를 묶는다.
|
||||
|
||||
핸들러 결과를 정산으로 바꾸는 **유일한** 지점(`messaging-runtime-core.DefaultDeliveryProcessor`)이 실제로 받는 것:
|
||||
|
||||
```java
|
||||
// DefaultDeliveryProcessor.java:40, 47
|
||||
private final Function<MessageEnvelope<EncodedMessage>, HandleResult> handler;
|
||||
```
|
||||
|
||||
세 가지가 다르다.
|
||||
|
||||
1. **동기다.** `CompletionStage`가 아니라 `Function`이므로 핸들러가 비동기일 수 없다.
|
||||
2. **`MessageDelivery`가 없다.** 봉투만 받는다. 따라서 `DeliveryMetadata.deliveryAttempt`(몇 번째 시도인가)와 `redelivered`가 핸들러에 도달하지 않는다.
|
||||
3. **`DeliveryContext`가 없다.** `handlerDeadline`·`isExpired(now)`·`shutdownRequested`가 도달하지 않는다.
|
||||
|
||||
세 번째는 독립적으로도 확인된다. `DeliveryContext`의 leaf 밖 참조 4건은 **전부 테스트 파일**이다 — `KafkaContractHarness.java:7,216`과 `DeadLetterOrchestratorTest.java:12,226`. production 소스에서 `DeliveryContext`를 만드는 코드는 저장소에 없다. `DeliveryContext`의 javadoc이 설명하는 graceful drain 협력("a long-running handler that can wind down early shortens the drain")은 현재 배선으로는 일어날 수 없다.
|
||||
|
||||
한편 `MessageDelivery`와 `DeliveryMetadata`는 production에서 **쓰인다** — 다만 핸들러에 넘기기 위해서가 아니라 DLQ·retry 경로에서 쓰인다:
|
||||
|
||||
- `MessageDelivery`: `KafkaDeadLetterPublisher:44`, `KafkaRetryExecutor:72`, `KafkaRetryTopicPublisher:59`, `RabbitDeadLetterPublisher:67`, `DeadLetterOrchestrator:69`, `TransactionalInboxHandler:49`
|
||||
- `DeliveryMetadata`: `KafkaDeliveryMapper:105`, `RabbitDeliveryMapper:107`, `policy/RetryContext:23`, `transport-spi/TransportDelivery:21`
|
||||
|
||||
그리고 핸들러 계약은 저장소에 **셋**이 있다:
|
||||
|
||||
| 인터페이스 | 소유 leaf | 시그니처 | 구현체 |
|
||||
|---|---|---|---|
|
||||
| `MessageHandler<T>` | `messaging-core-api` | `CompletionStage<HandleResult> handle(MessageDelivery<T>)` | **없음** |
|
||||
| `IdempotentMessageHandler<T>` | `messaging-reliability-api` | `CompletionStage<HandleResult> handleOnce(String, MessageDelivery<T>, TransactionalMessageAction<T>)` | `TransactionalInboxHandler` |
|
||||
| (익명) `Function<MessageEnvelope<EncodedMessage>, HandleResult>` | `messaging-runtime-core` | 동기, 봉투만 | 생성자 인자 |
|
||||
|
||||
**(d) 배치 경로: 만들어진 metadata를 받을 곳이 없다**
|
||||
|
||||
`BatchDeliveryMetadata`는 leaf 밖 참조가 **있다**(0이 아니다). 두 registrar가 만든다:
|
||||
|
||||
- `KafkaBatchConsumerRegistrar.java:104` — `metadataFor(partition, slice, now)`
|
||||
- `RabbitBatchConsumerRegistrar.java:139` — `release(now)`
|
||||
|
||||
그리고 둘 다 자기 브로커 전용 record에 담는다(`PartitionBatch`, `AmqpBatch`). 두 record의 javadoc이 같은 문장을 쓴다:
|
||||
|
||||
```
|
||||
* @param metadata the batch-wide metadata handed to the handler
|
||||
```
|
||||
|
||||
그런데 `new BatchMessageDelivery`는 저장소 전체에서 **0건**이고(`git grep` exit=1), `BatchMessageHandler`를 구현하거나 참조하는 코드도 0건이다. 즉 두 registrar는 배치 metadata를 정확히 계산해서(Kafka는 파티션 단위라 `settlableAsBatch=true`, Rabbit은 multiple-ack이 in-flight까지 정산하므로 `false`) 브로커별 record에 넣고, **javadoc이 말하는 handler로의 전달은 존재하지 않는다.**
|
||||
|
||||
**(e) 한계**
|
||||
|
||||
`git grep` 기반 정적 검색이므로 다음을 덮지 못한다: 리플렉션 조회, `ServiceLoader`, 애노테이션 프로세서 생성 코드, 문자열로 조립한 클래스 이름, 이 저장소 밖의 소비자. 다만 이 leaf에는 애노테이션이 0개이고 `META-INF/services`도 없으며(`find` 결과 resources 디렉터리 자체가 없다 — Gradle이 `processResources NO-SOURCE`를 보고한다), 이 저장소는 라이브러리 배포 저장소가 아니라 템플릿이므로 "저장소 밖 소비자"가 유일하게 남는 가능성이다. §17에서 그 갈래를 다룬다.
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
**적용 대상 없음.** 이 leaf에는 Spring stereotype·`@Bean`·`@Conditional`·`@Profile`이 0개이고(`git grep` exit=1), bean을 하나도 만들지 않는다. 비교할 sibling이 존재하지 않는다.
|
||||
|
||||
이 leaf의 활성화 비대칭은 다른 축에서 일어난다 — registry `runtime_memberships`. §12.4 참조.
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
**(a) UUIDv7 생성기**
|
||||
|
||||
저장소에 UUIDv7을 다루는 production 구현이 여럿이다.
|
||||
|
||||
| 위치 | 성격 |
|
||||
|---|---|
|
||||
| `messaging-core-api/.../api/UuidV7.java` | 이 leaf. `AtomicLong` packing, 밀리초 내 단조 카운터 |
|
||||
| `adapter/outbound/notification/.../dispatch/UuidV7Generator.java` | notification 플랫폼 전용 |
|
||||
| `adapter/outbound/persistence-jpa/src/testkit/.../id/UuidV7Generator.java` | testkit source set |
|
||||
| `adapter/outbound/persistence-mongo/.../mapping/DomainDocumentId.java` | Mongo 문서 id |
|
||||
| `application-core/.../notification/platform/api/NotificationId.java` 외 | 애플리케이션 식별자 |
|
||||
| `sample-portfolio/.../identifier/Uuid*Factory.java` (3종) | 샘플 |
|
||||
|
||||
`messaging-core-api.UuidV7`의 leaf 밖 참조는 **1건**이다(`messaging-testkit`의 JMH 벤치마크). 즉 messaging 밖에서는 아무도 이 구현을 쓰지 않고 각자 만들었다.
|
||||
|
||||
이것이 자동으로 결함은 아니다 — 모듈 경계가 의존을 금지하는 구조(`APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`)에서는 중복이 **의도된 비용**일 수 있다. 다만 §4.10의 밀리초 내 단조성 같은 성질이 구현마다 같은지는 이 leaf가 답할 수 없고, family 밖이므로 cross-scope가 소유한다.
|
||||
|
||||
**(b) wire-safe 텍스트 검증**
|
||||
|
||||
`WireSafeText`의 leaf 밖 참조는 **0**이다. 그런데 제어문자·인코딩 경계를 각자 검사하는 곳이 저장소에 최소 15개 있다 — `web/conditional/EntityTag`, `web/http/WebUriPolicy`, `cache-redis/.../codec/RedisEnvelope`, `fileserver/FileserverControlRecordCodec`, `mongo/changestream/MongoChangeEventIdentity`, `application-core/cache/CacheRefreshOwnerToken`, `application-core/objectstorage/model/ObjectMediaType`, `grpc-core-api/core/GrpcIdentifiers`, `shared-contract/ratelimit/EdgeRateLimitSubject` 등.
|
||||
|
||||
`WireSafeText`의 javadoc은 그 존재 이유를 "Each copy of this check that lived in its own record was one more place for the rule to drift"라고 적는데, 그 통합은 **이 leaf 안에서만** 일어났다. 저장소 수준에서는 여전히 각자 검사한다. 다시 말해 규칙은 옳게 진술됐고 적용 범위가 leaf 경계에서 멈춘다.
|
||||
|
||||
**(c) 헤더 네임스페이스**
|
||||
|
||||
`msg.` 리터럴을 이 leaf 밖에서 쓰는 production 코드는 **1곳**뿐이다 — `messaging-observability/.../MessagingRedactor.java:24`가 `"msg.id"`를 문자열 리터럴로 갖는다. 나머지 매치는 Kafka 테스트다. 상수(`ReservedHeaders.MESSAGE_ID`)가 있는데 리터럴을 쓴 것이므로, 상수가 바뀌면 redactor가 조용히 어긋난다. 작지만 실재하는 drift 표면이다.
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
**확인된 drift 1건.** 원시 증거 `evidence/raw/270-messaging-runtime-membership-doc-drift.txt`.
|
||||
|
||||
`docs/messaging/support-matrix.md:23-24`가 이렇게 말한다:
|
||||
|
||||
> 또한 registry의 messaging leaf는 모두 `runtime_memberships`가 비어 있다. 이는 **build-only / incubating** — 어느 composition root에도 편입되지 않았다는 뜻이며…
|
||||
|
||||
현재 revision에서 registry를 다시 세면:
|
||||
|
||||
```
|
||||
messaging leaves : 25
|
||||
runtime_memberships empty : 7
|
||||
runtime_memberships wired : 18
|
||||
```
|
||||
|
||||
`messaging-core-api` 자신이 wired 18개에 포함된다. 즉 이 문장은 **이 leaf에 대해 직접 틀렸다.**
|
||||
|
||||
같은 문단의 마지막 문장은 "자세한 규칙은 `src/messaging/CLAUDE.md`가 소유한다"고 가리키는데, 그 파일은 이미 정정을 기록해 두었다(`src/messaging/CLAUDE.md:46-59`):
|
||||
|
||||
> **이 절은 한동안 사실이 아닌 채로 남아 있었다.** "registry의 모든 messaging leaf는 `runtime_memberships`가 비어 있고 따라서 build-only"라고 쓰여 있었는데, 다섯 어댑터 remediation이 `messaging-spring-boot-starter`를 `app-bootstrap` 의존성으로 넣으면서 그 closure 전체가 런타임 classpath에 올라갔다. 정확한 목록은 registry가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift한다.
|
||||
|
||||
그래서 이것은 단순한 오래된 문서가 아니다. **같은 저장소의 두 문서가 같은 revision에서 서로 모순되고, 틀린 쪽이 옳은 쪽을 권위로 지목하고 있다.** 그리고 틀린 쪽이 운영자가 읽는 지원 매트릭스다. `CLAUDE.md`가 도달한 결론("세는 순간 다시 drift한다")이 정확히 support-matrix에는 적용되지 않았다.
|
||||
|
||||
영향 방향이 중요하다 — 문서는 실제보다 **약하게** 진술한다. "아무것도 배선되지 않았다"고 읽은 운영자는 배포 아티팩트가 이 leaf들을 싣고 있고 `app.messaging.enabled` 하나로 켜진다는 사실을 모른다. 과대 진술보다는 낫지만, 사고 시 조사 범위를 좁히는 방향의 오류다.
|
||||
|
||||
**나머지 문서 주장은 재측정에서 일치했다.**
|
||||
|
||||
- `docs/superpowers/plans/…:13` "`messaging-core-api`에는 Spring/broker/Reactor 의존성을 넣지 않는다" → 참(import 0개, `build.gradle` 빈 dependencies)
|
||||
- `docs/messaging/experimental-policy.md:44` "`messaging-core-api`의 타입을 바꾸지 않는다" → 정책 문장이며 이번 revision에서 위반 근거를 찾지 못함
|
||||
|
||||
**측정하지 않은 것.** `docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md`의 경로(`modules/messaging/…`)와 패키지(`io.backend.skeleton.messaging.api`)는 현재 소스(`src/messaging/…`, `dev.caskeleton.messaging.api`)와 다르다. 다만 이것은 계획 문서이고 실행 후 이름이 바뀐 것으로 보이므로 "drift"로 분류하지 않고 §13의 역사로 기록한다.
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
이 leaf를 건드린 커밋은 4개다.
|
||||
|
||||
```
|
||||
a24ece9c feat: web, websocket 어댑터 추가 구현
|
||||
01372634 refactor: 각 어댑터터별 리펙토링 진행
|
||||
2f5d2fc2 feat: jpa, messaging, notification, mongo, graphql 어댑터터 구현체 추가
|
||||
d646c2f1 feat(messaging): 브로커 중립 메시징 플랫폼 24개 leaf 추가
|
||||
```
|
||||
|
||||
최초 커밋 메시지는 **24개 leaf**라고 적었고 현재 registry의 messaging leaf는 **25개**다. 이후 커밋에서 하나가 늘었다는 뜻이며, 커밋 메시지는 그 시점의 사실이므로 drift로 분류하지 않는다.
|
||||
|
||||
**코드 주석이 보존한 실패 이력**이 이 leaf의 가장 밀도 높은 사료다. 아래는 전부 "예전에는 이랬고 그래서 무엇이 깨졌다"를 현재 코드가 직접 적어 둔 것이다.
|
||||
|
||||
| 위치 | 이전 상태 | 그것이 만든 실패 |
|
||||
|---|---|---|
|
||||
| `WireSafeText` 클래스 javadoc | 각 값 객체가 Java `char`로만 길이 검사 | 240자 = 최대 960바이트. 바이트를 세는 브로커가 발행 시점에 거절 |
|
||||
| `HeaderName.TOKEN` 주석 | "not blank, at most 128 bytes" | CR/LF/NUL/콜론이 통과 → 헤더 인젝션, 이름 절단, 레코드 분할 |
|
||||
| `HeaderName` 공백 검사 주석 | `strip()`으로 trim | `Authorization `이 denylist에는 같은 이름, wire에는 다른 이름 → 우회 |
|
||||
| `HeaderValue` javadoc | 길이 상한만 | 값 안의 CRLF가 line-oriented 바인딩에서 헤더를 끝내고 새 헤더 시작 |
|
||||
| `CorrelationId` javadoc | 160 **문자** 상한 | 640바이트 값이 흐름 중간에 거절됨 — 다른 identity로 재전송할 수 없는 메시지에서 |
|
||||
| `MessageEnvelope` 생성자 주석 | partitionKey/orderingKey 무제한 | orderingKey는 여러 바인딩이 wire에 싣는다 → 헤더와 같은 인젝션 표면 |
|
||||
| `MessageId` 생성자 주석 | 아무 UUID나 허용 | v4가 같은 컬럼에 들어가 outbox의 "oldest first"를 무력화 |
|
||||
| `TraceContext` javadoc | non-null 검사만 | 파싱 불가 `traceparent`를 collector가 **드롭** → 조사 중인 바로 그 hop에서 trace 소실 |
|
||||
| `ReservedHeaders.PLATFORM_PREFIX` 주석 | `NAMES` 정확 일치 | 다음 릴리스가 `msg.x`를 정의하는 순간 기존 애플리케이션이 봉투 메타데이터를 덮어씀 |
|
||||
| `ReservedHeaders.TENANT` javadoc | 헤더 이름 자체가 없었음 | 소비된 메시지가 전부 빈 tenant로 재구성됨 — 하위 authorization/파티셔닝이 읽는 필드 |
|
||||
| `MessageHeaders.SECRET_SEGMENTS` 주석 | 정확 이름 매칭만 | `x-api-key`·`auth-token`·`db_password`가 전부 통과 |
|
||||
| `PublishOptions` javadoc | 자유형 hint map 존재 | 읽는 쪽이 없어서 런타임 거절만 유발하는 escape hatch |
|
||||
| `CanonicalEnvelopeHeaders` javadoc | 예약 네임스페이스를 통째로 "위조 가능한 내용"으로 취급 | `msg.retry-attempt`까지 드롭 → attempt 카운터가 1로 재시작, retry 예산이 아무것도 제한하지 못함 |
|
||||
| `DefaultDeliveryProcessor` javadoc (다른 leaf, 이 계약 관련) | `HandleResult`를 정산에 연결하는 곳이 없었음 | 각 브로커 adapter가 retry/dead-letter의 뜻을 각자 결정 |
|
||||
|
||||
이 목록 자체가 이 leaf의 성격을 말한다 — **13개 이상의 wire 경계 결함을 한 번에 정리한 흔적**이고, 대부분이 "검사가 없었다"가 아니라 "검사가 잘못된 단위(문자 vs 바이트, 정확일치 vs 세그먼트, 이름목록 vs prefix)로 되어 있었다"이다.
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-269 | command | `evidence/raw/269-messaging-core-api-reachability.txt` | 21개 타입의 leaf 밖 참조 0(exit=1), `DeliveryContext`의 test-only 성격, 세 핸들러 계약, `new BatchMessageDelivery` 0건, leaf의 무의존성 | `git grep` 정적 검색. 리플렉션·서비스로더·저장소 밖 소비자 미포함 |
|
||||
| EVD-270 | command | `evidence/raw/270-messaging-runtime-membership-doc-drift.txt` | support-matrix.md:23의 주장과 registry 재측정(25/7/18), `CLAUDE.md`의 정정 기록, starter 조립 edge | 한 시점 registry snapshot |
|
||||
| EVD-271 | command | `:messaging:messaging-core-api:test --rerun-tasks` | BUILD SUCCESSFUL, 79 tests / 0 skipped / 0 failures | 순수 단위 테스트 레인. 브로커·Spring 없음 |
|
||||
|
||||
`evidence/raw/`에는 primary output만 둔다. 위 해석은 전부 이 문서가 소유한다.
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적(코드 주석·javadoc·테스트 이름·설계 문서가 직접 말함)**
|
||||
|
||||
- `EXACTLY_ONCE`·`GLOBAL` 부재 — `DeliveryGuarantee`/`OrderingScope` javadoc + `CoreValueTypesTest`
|
||||
- 3상태 발행 결과 — `PublishCompletion` javadoc
|
||||
- 증거가 결론보다 먼저 — `PublishEvidence` javadoc
|
||||
- 정제 대신 거절 — `WireSafeText` javadoc
|
||||
- `msg.` prefix 소유 — `ReservedHeaders.PLATFORM_PREFIX` 주석
|
||||
- 세그먼트 매칭 + 인접 결합 — `MessageHeaders.carriesACredential` 주석
|
||||
- 예약 네임스페이스 2분할 — `CanonicalEnvelopeHeaders` javadoc
|
||||
- `messageId` 보존이 `withPayload`의 목적 — `MessageEnvelope.withPayload` javadoc
|
||||
- `rand_a`를 카운터로 — `UuidV7` javadoc
|
||||
- 애플리케이션이 이 플랫폼을 참조하지 않는 이유 — `CleanArchitectureTest:229` `.because(...)`
|
||||
- `runtime_memberships`의 현재 의미 — `src/messaging/CLAUDE.md:46-70`
|
||||
|
||||
**추론(이 문서의 판단이며 코드가 직접 말하지 않음)**
|
||||
|
||||
- `MessageHandler<T>`가 미사용인 것은 `DefaultDeliveryProcessor`가 다른 시그니처를 택했기 때문이다 → **추론**. 두 사실(선언 존재, 다른 시그니처 사용)은 관측이고, 인과는 추론이다. 커밋 메시지나 ADR에서 이 선택의 근거를 찾지 못했다.
|
||||
- 12개 예외가 미사용인 것은 adapter들이 예외 대신 결과 record 경로를 택했기 때문이다 → **추론**. `MessagingConfigurationException`(59회)처럼 실제 쓰이는 것들이 설정/검증 계열에 몰려 있다는 관측에서 나온 설명이다.
|
||||
- 저장소 밖 소비자가 있을 가능성 → **가설**. 확인 수단이 이 저장소 안에 없다.
|
||||
|
||||
**관측했으나 원인을 모름**
|
||||
|
||||
- `MessagingRedactor.java:24`가 상수 대신 `"msg.id"` 리터럴을 쓰는 이유
|
||||
- `ContentType`만 바이트가 아니라 문자로 상한을 두는 이유
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- production 85파일 전부의 계약·불변식·경계값 (§4, §8)
|
||||
- 79개 테스트가 실제로 통과하고 무엇을 단언하는지 (§10)
|
||||
- 21개 타입의 leaf 밖 참조 0 — 재현 가능한 명령과 exit code로 (§12.1)
|
||||
- 선언된 핸들러 계약과 배선된 핸들러 계약의 불일치 (§12.1)
|
||||
- 배치 metadata를 만드는 두 지점과, 그것을 받을 `BatchMessageDelivery`가 0건이라는 사실 (§12.1)
|
||||
- `support-matrix.md:23`의 주장이 현재 registry와 어긋난다는 것 (§12.4)
|
||||
- 이 leaf가 외부 의존성 0이라는 것 (§1, §12.2)
|
||||
- 코드 주석이 보존한 13건 이상의 이전 결함 이력 (§13)
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- **경합 하 `UuidV7` 단조성.** `updateAndGet`의 CAS 성질에서 추론되지만 다중 스레드 테스트가 없다. `CoreValueTypesTest`는 단일 스레드 2회 호출만 본다.
|
||||
- **저장소 밖 소비자.** 이 템플릿을 가져다 쓰는 파생 프로젝트가 `MessageHandler`·`CapabilityRegistry` 등을 구현하는지 확인할 방법이 이 저장소 안에 없다. §12.1(c)와 §17의 판단이 이 미지수에 걸려 있다.
|
||||
- **실제 브로커가 이 경계값을 받아들이는지.** 128바이트 헤더 이름, 32,768바이트 헤더 총량, 1,024바이트 ordering key가 Kafka·RabbitMQ에서 실제로 통과하는지는 이 leaf의 레인이 증명하지 않는다. `messaging-kafka`/`messaging-rabbit`의 컨테이너 레인이 소유하고, 그 레인들은 이번 분석에서 실행하지 않았다.
|
||||
- **`MessagingRedactor`의 리터럴이 실제로 어긋난 적이 있는지.** 현재는 `ReservedHeaders.MESSAGE_ID`와 값이 같다.
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P2 — 선언된 핸들러 계약이 배선된 것과 다르다
|
||||
|
||||
- **사실.** `MessageHandler<T>`(`delivery/MessageHandler.java:14`)의 저장소 전체 참조가 0이다. 핸들러 결과를 정산으로 바꾸는 유일한 지점 `DefaultDeliveryProcessor`는 `Function<MessageEnvelope<EncodedMessage>, HandleResult>`를 받는다.
|
||||
- **근거.** `evidence/raw/269` §A, §D.
|
||||
- **왜 문제인가.** `MessageDelivery`가 빠지면서 `deliveryAttempt`·`redelivered`·`handlerDeadline`·`shutdownRequested`가 핸들러에 도달할 수 없다. `DeliveryContext`의 javadoc이 설명하는 graceful drain 협력은 현재 배선으로는 성립하지 않는다. 그리고 새 소비자를 붙이는 사람은 공개 API에서 `MessageHandler`를 먼저 보게 되는데, 그것을 구현해도 아무 데도 꽂히지 않는다.
|
||||
- **확인 방법.** `git grep -n -w MessageHandler -- src ':!src/messaging/messaging-core-api'` → exit 1. `DefaultDeliveryProcessor.java:40,47` 확인.
|
||||
- **후보.** (a) `DefaultDeliveryProcessor`가 `MessageHandler<T>`를 받도록 시그니처를 맞춘다 — `MessageDelivery`를 조립해야 하므로 `DeliveryContext` 생성 책임을 runtime에 준다. (b) `MessageHandler`·`DeliveryContext`를 이 leaf에서 제거하고 실제 계약만 남긴다. (c) 파생 프로젝트가 구현하는 확장점이라면 그 사실을 javadoc과 `support-matrix.md`에 명시한다.
|
||||
- **다음 단계.** 세 선택지는 "저장소 밖 소비자가 있는가"라는 미지수에 걸린다(§16). 그 답을 먼저 정해야 한다 → **OPEN QUESTION 후보.** 답이 정해지면 CASE 승격 가능.
|
||||
|
||||
### P2 — 배치 metadata를 만들고 넘길 곳이 없다
|
||||
|
||||
- **사실.** `KafkaBatchConsumerRegistrar:104`와 `RabbitBatchConsumerRegistrar:139`가 `BatchDeliveryMetadata`를 만들고, 두 javadoc 다 "the batch-wide metadata handed to the handler"라고 적는다. `new BatchMessageDelivery`는 저장소 전체에서 0건이고 `BatchMessageHandler` 참조도 0건이다.
|
||||
- **근거.** `evidence/raw/269` §E (`git grep 'new BatchMessageDelivery'` exit=1).
|
||||
- **왜 문제인가.** 두 registrar는 브로커별로 다른 정확한 계산을 한다 — Kafka는 파티션 단위 커밋이라 `settlableAsBatch=true`, Rabbit은 multiple-ack이 in-flight까지 정산하므로 `false`. 이 판단이 계산되어 어디에도 전달되지 않는다. javadoc은 존재하지 않는 수신자를 가리킨다.
|
||||
- **확인 방법.** `git grep -n 'new BatchMessageDelivery' -- 'src/**/*.java'` → exit 1.
|
||||
- **후보.** 배치 경로를 완성하거나(handler 인터페이스를 registrar에 연결), 미완성임을 javadoc과 `support-matrix.md`에 표시하거나, `BatchMessageHandler`/`BatchMessageDelivery`를 제거한다.
|
||||
- **다음 단계.** **CASE 후보.** 재현이 정적 검색으로 끝나고 결론이 경계 안에서 닫힌다.
|
||||
|
||||
### P2 — 운영자용 지원 매트릭스가 런타임 편입을 반대로 적는다
|
||||
|
||||
- **사실.** `docs/messaging/support-matrix.md:23`이 "registry의 messaging leaf는 모두 `runtime_memberships`가 비어 있다 … 어느 composition root에도 편입되지 않았다"고 적는다. 현재 registry는 25개 중 **18개**가 `["app-bootstrap"]`이고 `messaging-core-api`가 그 안에 있다.
|
||||
- **근거.** `evidence/raw/270`.
|
||||
- **왜 문제인가.** 같은 문단이 권위로 지목하는 `src/messaging/CLAUDE.md:46-59`는 이미 정정을 기록했고 "정확한 목록은 registry가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift한다"는 결론까지 적었다. 그 결론이 support-matrix에는 적용되지 않았다. 배포 아티팩트가 실제로 이 leaf들을 싣고 `app.messaging.enabled` 하나로 켜진다는 사실을 운영자가 문서에서 알 수 없다.
|
||||
- **확인 방법.** `evidence/raw/270`의 python 블록 재실행.
|
||||
- **후보.** support-matrix의 해당 문장을 삭제하고 `CLAUDE.md`로 위임하거나(문장이 이미 그렇게 하고 있다), registry에서 파생하는 생성 문서로 바꾼다.
|
||||
- **다음 단계.** **CASE 후보 + REFERENCE 후보**("숫자는 세지 말고 소유자에게 위임하거나 게이트로 붙든다"). 두 문서가 같은 revision에서 모순되고 틀린 쪽이 옳은 쪽을 가리킨다는 형태 자체가 재사용 가능한 기준이다.
|
||||
|
||||
### P3 — 12개 예외가 선언만 되어 있다
|
||||
|
||||
- **사실.** 23개 구체 예외 중 12개가 leaf 밖 참조 0이다(§6.2 표).
|
||||
- **근거.** `evidence/raw/269` §B.
|
||||
- **왜 문제인가.** 지금 당장 깨지는 것은 없다. 다만 `MessagePublishAmbiguousException`처럼 설계의 중심 개념에 이름을 준 타입이 던져지지 않으면, 그 개념이 실제로 어떤 경로로 표현되는지(결과 record)를 읽는 사람이 스스로 알아내야 한다. 그리고 `src/messaging/CLAUDE.md:44` — "새 public 타입은 그 모듈의 계약이다. 삭제·시그니처 변경은 breaking change로 취급한다" — 때문에 나중에 정리하는 비용이 계속 커진다.
|
||||
- **확인 방법.** `evidence/raw/269` §B 재실행.
|
||||
- **후보.** adapter들이 결과 record 대신 예외를 던져야 하는 지점을 정하거나, 미사용 예외를 제거하거나, "이것은 파생 프로젝트용 어휘"임을 명시한다.
|
||||
- **다음 단계.** P2 첫 항목과 같은 미지수(저장소 밖 소비자)를 공유한다 → 그 OPEN QUESTION에 **MERGED** 후보.
|
||||
|
||||
### P3 — `MessagingRedactor`가 상수 대신 문자열 리터럴을 쓴다
|
||||
|
||||
- **사실.** `messaging-observability/.../MessagingRedactor.java:24`가 `"msg.id"`를 리터럴로 갖는다. `ReservedHeaders.MESSAGE_ID` 상수가 있다.
|
||||
- **근거.** `git grep '"msg\.'` — production 매치는 이 한 곳뿐.
|
||||
- **왜 문제인가.** 상수가 바뀌면 redaction이 조용히 대상을 잃는다. 컴파일러가 잡지 않는다.
|
||||
- **확인 방법.** `git grep -n '"msg\.' -- 'src/**/*.java' | grep -v messaging-core-api`
|
||||
- **후보.** 리터럴을 `ReservedHeaders.MESSAGE_ID`로 교체.
|
||||
- **다음 단계.** `messaging-observability` leaf SSOT가 소유한다. 여기서는 교차 참조만 남긴다.
|
||||
|
||||
### P3 — `WireSafeText`의 규칙이 leaf 경계에서 멈춘다
|
||||
|
||||
- **사실.** `WireSafeText`의 leaf 밖 참조 0. 제어문자·인코딩 경계를 각자 검사하는 곳이 저장소에 최소 15개.
|
||||
- **근거.** §12.3(b).
|
||||
- **왜 문제인가.** javadoc이 "Each copy of this check ... was one more place for the rule to drift"라고 적었고 그 통합을 leaf 안에서만 했다. 저장소 수준에서는 같은 drift가 그대로 남아 있다.
|
||||
- **확인 방법.** `git grep -l -E 'requireNoControls|control character|0x7F' -- 'src/**/*.java'`
|
||||
- **후보.** 규칙을 공유 위치(`shared-contract`)로 올리거나, leaf 경계를 이유로 중복을 명시적으로 수용한다고 적는다.
|
||||
- **다음 단계.** 저장소 전역 판단이므로 **cross-scope 소유.** 여기서는 관측만 기록한다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- 외부 의존성 0 — 계획 문서의 제약이 현재 소스에서 성립
|
||||
- `EXACTLY_ONCE`/`GLOBAL` 부재가 테스트로 고정됨
|
||||
- `PublishResult`의 14개 금지 조합 중 9개가 테스트로 커버됨
|
||||
- 자격증명 세그먼트 매칭의 양방향(거절/오탐 회피) 테스트 존재
|
||||
- `msg.` prefix 소유가 테스트로 고정됨
|
||||
- W3C traceparent 무효 7종이 파라미터 테스트로 커버됨
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path / command | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MCA-001 | registry | `src/config/architecture/modules.json` | `21234e38` | leaf id, `allowed_dependencies: []`, `runtime_memberships: ["app-bootstrap"]` | 선언이며 런타임 실행 자체는 아님 |
|
||||
| MCA-002 | build | `src/messaging/messaging-core-api/build.gradle` | same | 선언 의존성 0 | convention plugin의 test 의존성은 별개 |
|
||||
| MCA-003 | code | `src/main/java/**/api/*.java` (12) | same | 봉투와 값 객체 불변식, 바이트 경계, UUIDv7 검증 | — |
|
||||
| MCA-004 | code | `src/main/java/**/api/header/*.java` (5) | same | 헤더 문법, 예약 prefix 소유, 자격증명 세그먼트 매칭, 두 factory 분리 | 실제 브로커 수용 여부는 미포함 |
|
||||
| MCA-005 | code | `src/main/java/**/api/publish/*.java` (17) | same | 3상태 완료, 14개 금지 조합, 증거 우선 순서 | adapter가 이를 지키는지는 별개 |
|
||||
| MCA-006 | code | `src/main/java/**/api/delivery/*.java` (13) | same | `HandleResult` sealed 4변형, attempt 1 규칙, 선언된 핸들러 계약 | 배선 여부는 §12가 답함 |
|
||||
| MCA-007 | code | `src/main/java/**/api/settlement/*.java` (5) | same | 정산 3상태와 불변식 | — |
|
||||
| MCA-008 | code | `src/main/java/**/api/error/*.java` (26) | same | 10개 카테고리, 기본 retryable 정책, 23개 예외의 카테고리 전수 | — |
|
||||
| MCA-009 | code | `src/main/java/**/api/destination/*.java` (7) | same | 논리 목적지, 12개 capability boolean | capability 선언이 실제 브로커와 맞는지는 별개 |
|
||||
| MCA-010 | test | `src/test/java/**` (8 클래스 / 79 테스트) | same | §10 표의 단언 | 순수 단위. 브로커·Spring 없음 |
|
||||
| MCA-011 | architecture test | `src/app-bootstrap/.../CleanArchitectureTest.java:229-240` | same | `..application..` → `dev.caskeleton.messaging..` 금지와 그 이유 | 정적 분석. 헬퍼/AOP 우회는 별도 |
|
||||
| MCA-012 | assembly | `src/app-bootstrap/build.gradle:87` | same | starter를 통한 전이 편입 경로 | 실행 활성화는 `app.messaging.enabled`가 결정 |
|
||||
| MCA-013 | module policy | `src/messaging/CLAUDE.md:44, 46-70` | same | public 타입 = 계약, runtime membership의 현재 의미와 정정 기록 | 정책 문서 |
|
||||
| MCA-014 | doc | `docs/messaging/support-matrix.md:18-27` | same | 운영자용 등급표와 런타임 편입 주장 | 23행이 registry와 어긋남(§12.4) |
|
||||
| MCA-015 | design doc | `docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md:7,13` | same | 무의존성 제약의 원래 근거 | 계획 문서. 경로/패키지는 이후 변경됨 |
|
||||
| MCA-016 | cross-leaf code | `messaging-runtime-core/.../DefaultDeliveryProcessor.java:22-99` | same | 핸들러 결과 → 정산의 유일한 지점과 그 시그니처 | 해당 leaf SSOT가 소유 |
|
||||
| MCA-017 | cross-leaf code | `messaging-kafka/.../KafkaBatchConsumerRegistrar.java:102-124`, `messaging-rabbit/.../RabbitBatchConsumerRegistrar.java:133-160` | same | 배치 metadata 생성 지점과 "handed to the handler" javadoc | 해당 leaf SSOT가 소유 |
|
||||
| MCA-018 | cross-leaf code | `messaging-reliability-api/.../IdempotentMessageHandler.java:21-32` | same | 세 번째 핸들러 계약의 존재 | 해당 leaf SSOT가 소유 |
|
||||
| EVD-269 | command | `evidence/raw/269-messaging-core-api-reachability.txt` | same | §12.1·§12.2 전부, exit code 포함 | 정적 `git grep`. 리플렉션/서비스로더/저장소 밖 미포함 |
|
||||
| EVD-270 | command | `evidence/raw/270-messaging-runtime-membership-doc-drift.txt` | same | §12.4의 drift, registry 재측정 25/7/18 | 한 시점 snapshot |
|
||||
| EVD-271 | command | `./gradlew :messaging:messaging-core-api:test --rerun-tasks` | same | BUILD SUCCESSFUL, 79 / 0 skipped / 0 failures | 순수 단위 레인 |
|
||||
@@ -0,0 +1,734 @@
|
||||
# messaging-inbox-jdbc-postgresql 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-inbox-jdbc-postgresql`
|
||||
> SSOT owner: `messaging-inbox-jdbc-postgresql`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- registered leaf id: `messaging-inbox-jdbc-postgresql`
|
||||
- canonical state `analysisFile`: `analysis/messaging/messaging-inbox-jdbc-postgresql.md`
|
||||
- source path: `src/messaging/messaging-inbox-jdbc-postgresql`
|
||||
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-reliability-api"]`
|
||||
- registry `runtime_memberships`: `["app-bootstrap"]`
|
||||
|
||||
### 숫자
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | 6 |
|
||||
| production LOC | 542 |
|
||||
| 패키지 | 1 (`dev.caskeleton.messaging.inbox`) |
|
||||
| migration | 1 (`V2__messaging_inbox.sql`) |
|
||||
| test 파일 | 4 |
|
||||
| test 메서드(실행 확인) | **25** |
|
||||
| 외부 의존성 | `spring-jdbc`, `spring-tx`(implementation) · testcontainers·postgresql·messaging-testkit(test) |
|
||||
|
||||
여섯 타입:
|
||||
|
||||
| 타입 | 역할 | leaf 밖 참조 |
|
||||
|---|---|---:|
|
||||
| `JdbcInboxRepository` | `InboxRepository` 구현 | 0 |
|
||||
| `IdempotentConsumer` | 예약+부작용을 한 트랜잭션에 | 1 |
|
||||
| `TransactionalInboxHandler` | `IdempotentMessageHandler` 구현 | 1 |
|
||||
| `InboxCleanupJob` | 보존 스윕 | 1 |
|
||||
| `InboxRetentionPolicy` | 보존 규칙 | 1 |
|
||||
| `InboxOutcome` | 처리/중복 결과 | 0 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `src/main/resources/db/migration/messaging/V2__messaging_inbox.sql` | 1 | `FULL_READ` | 18줄 전문 |
|
||||
| `src/test/java/**` (4) | 4 | `FULL_READ` | fake 구현·테스트명·단언 확인 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 주석 포함 17줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
`messaging-reliability-api`의 `InboxRepository`·`IdempotentMessageHandler` 포트를 PostgreSQL로 구현한다. 이름이 기술을 드러낸다 — `docs/messaging/support-matrix.md`가 그 개명 이유를 적는다(MSG-023).
|
||||
|
||||
**메커니즘 전체가 하나의 SQL 문장에 있다.**
|
||||
|
||||
```sql
|
||||
INSERT INTO messaging_inbox (message_id, consumer_id, processed_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (message_id, consumer_id) DO NOTHING
|
||||
```
|
||||
|
||||
```java
|
||||
// JdbcInboxRepository.java:20-23
|
||||
* <p>Reservation is an {@code INSERT ... ON CONFLICT DO NOTHING} whose affected-row count is the
|
||||
* answer: one means first delivery, zero means already processed. The composite primary key does
|
||||
* the work, so there is no read-then-write race — two concurrent deliveries of the same message
|
||||
* cannot both see "not processed" and both proceed.
|
||||
```
|
||||
|
||||
migration이 같은 사실을 반대편에서 적는다.
|
||||
|
||||
```sql
|
||||
-- The composite primary key is the deduplication mechanism: reserving a message is an INSERT that
|
||||
-- either succeeds or violates the key, inside the same transaction as the handler's side effect.
|
||||
-- Two independent consumers of the same event each get their own row, so one cannot suppress the
|
||||
-- other.
|
||||
```
|
||||
|
||||
`build.gradle` 주석이 테스트 전략을 명시한다.
|
||||
|
||||
```groovy
|
||||
// Live-database certification. The reliability patterns are claims about transaction
|
||||
// boundaries and uniqueness constraints, and only a real database can settle them.
|
||||
testImplementation 'org.testcontainers:testcontainers-postgresql'
|
||||
```
|
||||
|
||||
**그리고 실제로 실행된다** — `InboxPostgresIT` 6개가 기본 `test` 태스크에서 통과한다(§10).
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
들어오는 것: `messaging-core-api`(api), `messaging-reliability-api`(api), `spring-jdbc`·`spring-tx`(implementation).
|
||||
|
||||
나가는 것: `messaging-spring-boot-starter`.
|
||||
|
||||
**배선됨.** starter의 `MessagingReliabilityAutoConfiguration`이 셋을 만든다.
|
||||
|
||||
| bean | 이 leaf의 타입 |
|
||||
|---|---|
|
||||
| `InboxRetentionPolicy` | o |
|
||||
| `InboxCleanupJob` | o |
|
||||
| `TransactionalInboxHandler<Object>` | o (`IdempotentConsumer`를 받음) |
|
||||
|
||||
`JdbcInboxRepository`는 그 목록에 없다 — `InboxRepository` bean을 누가 만드는지는 starter leaf가 답한다.
|
||||
|
||||
Spring 타입을 두 곳에서 쓴다 — `DataSourceUtils`와 `TransactionSynchronizationManager`. 둘 다 `implementation` scope이고 public 시그니처에 나오지 않으므로 vendor `api` 규칙에 맞는다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
```
|
||||
TransactionalInboxHandler<T> (IdempotentMessageHandler<T> 구현)
|
||||
└── handleOnce(consumerName, delivery, action)
|
||||
└── IdempotentConsumer.runOnce(messageId, consumerId, now, sideEffect)
|
||||
└── TransactionRunner.inTransaction(...) ← 호출자가 제공
|
||||
├── InboxRepository.reserve(...) == false → InboxOutcome.duplicate()
|
||||
└── true → sideEffect.get() → InboxOutcome.processed(...)
|
||||
|
||||
JdbcInboxRepository (InboxRepository 구현)
|
||||
├── reserve(MessageId, String, Instant) ← requireActiveTransaction 3검사 후 위임
|
||||
├── reserve(Connection, ...) ← package-private, 실제 INSERT
|
||||
├── isProcessed(...) ← 자기 커넥션
|
||||
├── purgeProcessedBefore(Instant, int) ← LIMIT + FOR UPDATE SKIP LOCKED. 호출자 0 (§12.1)
|
||||
└── purgeProcessedBefore(Instant) ← 무제한 DELETE. 이것이 불린다
|
||||
|
||||
InboxCleanupJob(inbox, policy, maxBatches)
|
||||
├── 생성자가 policy.validate()
|
||||
└── runOnce(now) → maxBatches회 루프, 매회 무제한 purge
|
||||
|
||||
InboxRetentionPolicy(retention, maximumRedeliveryWindow)
|
||||
├── REQUIRED_SAFETY_FACTOR = 2.0
|
||||
└── validate() → retention >= window * 2 아니면 INBOX_RETENTION_TOO_SHORT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
### 4.1 `requireActiveTransaction` — 세 겹 검사
|
||||
|
||||
이 leaf에서 가장 중요한 안전 장치이고 이전 결함이 javadoc에 있다.
|
||||
|
||||
```java
|
||||
// JdbcInboxRepository.java:53-57
|
||||
* <p>Package-private. It used to be public and was the only path that actually joined the
|
||||
* caller's transaction, while the interface method — the one {@code IdempotentConsumer} calls —
|
||||
* opened a raw connection that auto-commits. A reservation that commits on its own while the
|
||||
* business side effect rolls back is a message that will never be redelivered and whose work
|
||||
* never happened.
|
||||
```
|
||||
|
||||
**두 개의 오버로드가 있었고 호출되는 쪽이 틀린 쪽이었다.** 현재는 interface 메서드가 세 가지를 확인한다.
|
||||
|
||||
| 검사 | 실패 시 메시지의 핵심 |
|
||||
|---|---|
|
||||
| `isActualTransactionActive()` | "a reservation that commits alone marks a message processed whose work may still roll back" |
|
||||
| `!isCurrentTransactionReadOnly()` | "the current one is read-only" |
|
||||
| `hasResource(dataSource)` | "it is bound to another, so the reservation and the side effect would commit independently" |
|
||||
|
||||
세 번째가 특히 정교하다 — **트랜잭션이 활성이어도 다른 DataSource에 묶여 있으면 거절한다.** 멀티 데이터소스 배포에서 실제로 발생하는 형태이고, 그 경우 예약과 부작용이 서로 다른 트랜잭션에 들어간다.
|
||||
|
||||
세 검사 전부 같은 코드 `INBOX_TRANSACTION_REQUIRED`를 쓴다 — 메시지만 다르다.
|
||||
|
||||
```java
|
||||
// requireActiveTransaction javadoc:96-99
|
||||
* <p>The reservation and the side effect it guards have to commit or roll back together. Running
|
||||
* the reservation on its own connection breaks that on the rollback path only — which is the path
|
||||
* nobody exercises before production, and the one where the message is lost for good.
|
||||
```
|
||||
|
||||
**"the path nobody exercises before production"**가 이 leaf의 테스트 전략을 설명한다 — `InboxPostgresIT.aRolledBackTransactionLeavesNoReservationAndNoSideEffect`가 정확히 그 경로를 실 DB에서 돈다.
|
||||
|
||||
### 4.2 `IdempotentConsumer` — 트랜잭션을 열지 않는다
|
||||
|
||||
```java
|
||||
// :12-15
|
||||
* <p>The reservation and the side effect must share one transaction. This class does not open that
|
||||
* transaction itself — the caller supplies a runner that does — because the boundary belongs to the
|
||||
* application's data access layer, and a nested or separate transaction here would silently break
|
||||
* the guarantee while still looking correct.
|
||||
```
|
||||
|
||||
`TransactionRunner`가 함수형 인터페이스이고 `<T> T inTransaction(Supplier<T> work)` 하나다. 즉 이 leaf는 Spring `@Transactional`에 의존하지 않고 **경계 제공을 호출자에게 위임**한다. `JdbcInboxRepository.requireActiveTransaction`이 그 위임이 지켜졌는지를 런타임에 확인한다 — **위임과 검증이 짝을 이룬다.**
|
||||
|
||||
중복이 정상 결과라는 것도 명시돼 있다 — "A duplicate is not an error. It is the expected consequence of at-least-once delivery, so the skip path is a normal outcome rather than an exception."
|
||||
|
||||
### 4.3 `TransactionalInboxHandler` — 세 가지를 할 수 없다
|
||||
|
||||
```java
|
||||
// :20-23
|
||||
* <p>Reservation and effect commit together, in the runner's single transaction. Everything else
|
||||
* about this class follows from that: it cannot settle the message (settlement is not
|
||||
* transactional), it cannot publish (the publish would survive a rollback), and it cannot catch and
|
||||
* swallow the action's exception (the rollback is how the reservation is undone).
|
||||
```
|
||||
|
||||
세 금지가 `messaging-reliability-api`의 `TransactionalMessageAction` javadoc이 구현자에게 요구한 것과 대칭이다 — 그쪽은 action에게, 이쪽은 handler에게.
|
||||
|
||||
예외 처리가 그 세 번째를 지킨다.
|
||||
|
||||
```java
|
||||
try {
|
||||
action.apply(delivery);
|
||||
} catch (Exception failure) {
|
||||
// Wrapped, not swallowed: the transaction runner has to see a throw to roll the
|
||||
// reservation back along with the effect.
|
||||
throw new ActionFailedException(failure);
|
||||
}
|
||||
```
|
||||
|
||||
`ActionFailedException`이 private `RuntimeException`이고, 바깥에서 잡아 `HandleResult.Retry`로 번역한다. **checked exception을 트랜잭션 runner를 통과시키기 위한 캐리어**다.
|
||||
|
||||
중복은 성공으로 보고한다.
|
||||
|
||||
```java
|
||||
private static HandleResult duplicateIsSuccess() {
|
||||
// The effect already ran in an earlier delivery. Settling is correct; redelivering is not.
|
||||
return HandleResult.success();
|
||||
}
|
||||
```
|
||||
|
||||
실패는 `TRANSIENT_INFRASTRUCTURE` + `retryable = true` + `exceptionType`에 원인 클래스 단순명 — `FailureDescriptor`의 `Optional<String> exceptionType`을 실제로 채우는 저장소 내 드문 지점이다.
|
||||
|
||||
### 4.4 `InboxRetentionPolicy` — 곱셈 안전계수
|
||||
|
||||
```java
|
||||
// :11-18
|
||||
* <p>Retention must exceed the broker's maximum redelivery window. That is not a tuning preference:
|
||||
* a row pruned while the broker can still redeliver its message turns the inbox into a no-op for
|
||||
* exactly that message, and the side effect runs a second time. The failure is silent, rare, and
|
||||
* only happens under the conditions that already made the day bad.
|
||||
*
|
||||
* <p>The safety margin is multiplicative rather than additive so that it scales with the window
|
||||
* itself. A stream whose redelivery window is measured in days needs more slack than one measured
|
||||
* in minutes, for the same reason: the estimate of that window is proportionally less certain.
|
||||
```
|
||||
|
||||
`REQUIRED_SAFETY_FACTOR = 2.0`, `DEFAULT_RETENTION = 7일`.
|
||||
|
||||
**`messaging-reliability-api`의 `InboxRepository.purgeProcessedBefore` javadoc이 요구하고 강제하지 않은 규칙을 이 leaf가 강제한다.** 그 leaf §17이 "미강제"로 기록한 것이 여기서 `validate()`가 된다 — 다만 `validate()`는 `InboxCleanupJob` 생성자만 부른다. 즉 **cleanup job을 만들지 않는 배포에서는 여전히 검사되지 않는다.**
|
||||
|
||||
`required()`가 `Math.round(window.toMillis() * 2.0)`이다. 곱셈 이유가 적혀 있고, `theRequiredRetentionScalesWithTheWindow` 테스트가 2일 창 → 4일 요구를 확인한다.
|
||||
|
||||
### 4.5 `InboxCleanupJob` — 선언과 구현이 어긋난다
|
||||
|
||||
javadoc이 두 가지를 약속한다.
|
||||
|
||||
```java
|
||||
// :10-16
|
||||
* <p>Deletes in bounded batches. A single unbounded {@code DELETE} over a table that has been
|
||||
* accumulating for weeks holds locks long enough to block the very reservations the inbox exists to
|
||||
* serve, so the cleanup would cause the outage it is meant to prevent.
|
||||
*
|
||||
* <p>The policy is validated before the first deletion. Running a cleanup under a retention that is
|
||||
* shorter than the redelivery window would actively create the duplicate-processing bug, so the job
|
||||
* refuses to start rather than dutifully deleting the rows.
|
||||
```
|
||||
|
||||
**두 번째는 지켜진다** — 생성자가 `policy.validate()`를 부르고 테스트가 확인한다.
|
||||
|
||||
**첫 번째는 지켜지지 않는다.**
|
||||
|
||||
```java
|
||||
public static final int DEFAULT_BATCH_SIZE = 1_000; // ← 선언되고 어디서도 쓰이지 않음
|
||||
...
|
||||
for (int batch = 0; batch < maxBatches; batch++) {
|
||||
int deleted = inbox.purgeProcessedBefore(cutoff); // ← 무제한 overload
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`InboxRepository`에는 두 오버로드가 있다.
|
||||
|
||||
| 오버로드 | 구현 |
|
||||
|---|---|
|
||||
| `purgeProcessedBefore(Instant, int)` | `WITH expired AS (SELECT … LIMIT ? FOR UPDATE SKIP LOCKED) DELETE …` |
|
||||
| `purgeProcessedBefore(Instant)` | `DELETE FROM messaging_inbox WHERE processed_at < ?` |
|
||||
|
||||
job은 후자를 부른다. 첫 호출이 컷오프 이전 **전부**를 한 문장으로 지우고, 두 번째 호출이 0을 반환해 루프가 끊긴다. `maxBatches`는 사실상 의미가 없고 `DEFAULT_BATCH_SIZE`는 죽은 상수다.
|
||||
|
||||
즉 **javadoc이 "cleanup would cause the outage it is meant to prevent"라고 서술한 바로 그 동작을 한다.** §12.1·§17.
|
||||
|
||||
### 4.6 `InboxOutcome` — 두 상태
|
||||
|
||||
`(boolean processed, Optional<T> result)`. `processed(value)`와 `duplicate()` 두 factory.
|
||||
|
||||
`TransactionalInboxHandler`가 `T = InboxResult`로 쓰고 항상 `InboxResult.APPLIED`를 넣는다 — §12.3.
|
||||
|
||||
### 4.7 migration
|
||||
|
||||
```sql
|
||||
CREATE TABLE messaging_inbox
|
||||
(
|
||||
message_id UUID NOT NULL,
|
||||
consumer_id VARCHAR(160) NOT NULL,
|
||||
processed_at TIMESTAMPTZ NOT NULL,
|
||||
CONSTRAINT pk_messaging_inbox PRIMARY KEY (message_id, consumer_id)
|
||||
);
|
||||
CREATE INDEX ix_messaging_inbox_processed_at ON messaging_inbox (processed_at);
|
||||
```
|
||||
|
||||
`message_id`가 `UUID` 타입이다 — `MessageId`가 UUIDv7만 허용하므로(`messaging-core-api` §4.9) 컬럼 타입이 그 제약과 맞는다.
|
||||
|
||||
`consumer_id VARCHAR(160)` — `IdempotentConsumer`가 공백만 거절하고 길이를 보지 않는다. **160자를 넘는 consumerId는 DB가 거절한다.** 애플리케이션 층에 대응 검증이 없다. §17.
|
||||
|
||||
인덱스 주석이 보존 규칙을 다시 적는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
**수신 처리:** `handleOnce(name, delivery, action)` → `consumer.runOnce(messageId, name, now, () -> { action.apply(delivery); return APPLIED; })` → runner가 트랜잭션 열기 → `repository.reserve(...)` → 세 검사 → `INSERT … ON CONFLICT DO NOTHING` → 1행이면 부작용 실행, 0행이면 `duplicate()` → 커밋 → `HandleResult.success()`
|
||||
|
||||
**실패:** action 예외 → `ActionFailedException` → runner가 롤백(예약도 함께) → `HandleResult.Retry("INBOX_ACTION_FAILED")`
|
||||
|
||||
**보존:** `cleanupJob.runOnce(now)` → `policy.cutoff(now)` → 무제한 DELETE 1회 → 두 번째 호출 0 → 종료
|
||||
|
||||
---
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
| 코드 | 예외 | 조건 |
|
||||
|---|---|---|
|
||||
| `INBOX_TRANSACTION_REQUIRED` | `MessagingConfigurationException` | 트랜잭션 없음/읽기전용/다른 DataSource |
|
||||
| `INBOX_RESERVE_FAILED` | `MessagingConfigurationException` | 예약 SQL 실패 |
|
||||
| `INBOX_QUERY_FAILED` | `MessagingConfigurationException` | 조회 SQL 실패 |
|
||||
| `INBOX_PURGE_FAILED` | `MessagingConfigurationException` | 스윕 SQL 실패 |
|
||||
| `INBOX_RETENTION_TOO_SHORT` | `MessagingConfigurationException` | 보존 < 창 × 2 |
|
||||
| `INBOX_ACTION_FAILED` | `HandleResult.Retry`(예외 아님) | action 실패 |
|
||||
|
||||
**SQL 실패 셋이 전부 `MessagingConfigurationException`이다.** 그 예외의 카테고리는 `CONFIGURATION`이고 `retryable = false`다. 그런데 `SQLException`의 원인은 대부분 **일시적 인프라 문제**(연결 끊김, 데드락, 타임아웃)다. 즉 재시도 가능한 실패가 재시도 불가로 분류된다. §17.
|
||||
|
||||
`INBOX_ACTION_FAILED`만 `TRANSIENT_INFRASTRUCTURE`/`retryable = true`이고 예외가 아니라 `HandleResult`로 흐른다 — 분류가 정확하다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
**이 leaf의 주제 자체가 트랜잭션이다.**
|
||||
|
||||
| 지점 | 메커니즘 |
|
||||
|---|---|
|
||||
| 중복 제거 | 복합 PK + `ON CONFLICT DO NOTHING`의 영향 행 수 |
|
||||
| 예약·부작용 원자성 | 호출자의 `TransactionRunner` + `requireActiveTransaction` 3검사 |
|
||||
| 커넥션 참여 | `DataSourceUtils.getConnection/releaseConnection` — Spring 트랜잭션 동기화 커넥션을 얻는다 |
|
||||
| 스윕 격리 | bounded overload가 `FOR UPDATE SKIP LOCKED` — **호출되지 않음** |
|
||||
|
||||
`DataSourceUtils.getConnection`은 활성 트랜잭션에 묶인 커넥션이 있으면 그것을 주고, 없으면 새로 연다. 그래서 `requireActiveTransaction`이 **먼저** 도는 것이 필수다 — 없으면 새 커넥션이 열리고 자동 커밋된다. 그것이 §4.1의 이전 결함이다.
|
||||
|
||||
`isProcessed`와 두 `purge*`는 `dataSource.getConnection()`을 직접 쓴다 — 트랜잭션에 참여하지 않는다. javadoc이 그것을 명시한다("The no-argument overload is provided only for retention sweeps and read-only queries").
|
||||
|
||||
동시성 원시 요소는 DB에 있다. Java 쪽에 락이나 원자 변수가 없다.
|
||||
|
||||
수명주기 참여 없음 — `InboxCleanupJob`을 스케줄링하는 것은 starter다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
| 상수 | 값 | 사용 |
|
||||
|---|---:|---|
|
||||
| `InboxCleanupJob.DEFAULT_BATCH_SIZE` | 1,000 | **없음** |
|
||||
| `InboxRetentionPolicy.REQUIRED_SAFETY_FACTOR` | 2.0 | `required()` |
|
||||
| `InboxRetentionPolicy.DEFAULT_RETENTION` | 7일 | starter가 참조할 수 있음 |
|
||||
| `consumer_id` 컬럼 폭 | 160자 | migration |
|
||||
|
||||
설정 파일 없음. `maxBatches`와 두 `Duration`이 생성자 인자다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
**PostgreSQL 전용이다.** 세 SQL이 벤더 기능을 쓴다.
|
||||
|
||||
| 구문 | 용도 |
|
||||
|---|---|
|
||||
| `ON CONFLICT (…) DO NOTHING` | 예약. PostgreSQL 고유 |
|
||||
| `FOR UPDATE SKIP LOCKED` | bounded 스윕. PostgreSQL 9.5+ |
|
||||
| `WITH … DELETE … USING` | bounded 스윕. CTE + USING |
|
||||
| `TIMESTAMPTZ` | 컬럼 타입 |
|
||||
|
||||
leaf 이름이 그 사실을 드러낸다.
|
||||
|
||||
`statement.setObject(1, messageId.value())`가 `java.util.UUID`를 그대로 넘긴다 — PostgreSQL JDBC 드라이버가 `UUID` ↔ `uuid` 매핑을 지원한다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
레인: `./gradlew :messaging:messaging-inbox-jdbc-postgresql:test`. **BUILD SUCCESSFUL, 25 tests, 0 skipped, 0 failures.**
|
||||
|
||||
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|
||||
|---|---:|---|---|
|
||||
| `InboxPostgresIT` | **6** | **실 PostgreSQL**에서: 첫 예약 성공/둘째 실패, 두 소비자 각각 1회, 조회 가시성, 재전달이 부작용을 두 번 실행하지 않음, **롤백이 예약도 부작용도 남기지 않음**, 보존 삭제 | bounded 스윕(무제한 overload를 부른다) |
|
||||
| `JdbcInboxTransactionRequirementTest` | 4 | 트랜잭션 없음/읽기전용/다른 DataSource 거절이 **커넥션 요청 전에** 일어남, 코드가 검색 가능 | — |
|
||||
| `IdempotentConsumerTest` | 6 | 첫 실행/재전달 스킵/두 소비자/한 트랜잭션 공유/조회 가시성/보존 삭제 | in-memory fake |
|
||||
| `InboxOperationsTest` | 9 | 보존 규칙 4개, cleanup 루프 2개, 소비자별 1회, 재전달 억제, `InboxResult` 세 값의 `isSafeToSettle` | **bounded 배치**(§10.2) |
|
||||
|
||||
### 10.1 컨테이너 레인이 실제로 돈다
|
||||
|
||||
`InboxPostgresIT`가 `@Testcontainers`이고 **기본 `test` 태스크에서 6개가 통과했다.** 이 저장소의 다른 컨테이너 레인 중 일부는 별도 태스크에 격리돼 있는데 이것은 아니다.
|
||||
|
||||
`aRolledBackTransactionLeavesNoReservationAndNoSideEffect`가 §4.1이 말한 "the path nobody exercises before production"을 실 DB에서 검증한다. `build.gradle` 주석의 주장("only a real database can settle them")이 실현된 지점이다.
|
||||
|
||||
### 10.2 `cleanupDeletesInBoundedBatches`가 증명하지 않는 것
|
||||
|
||||
테스트 이름이 속성을 주장한다. 실제 단언은 이렇다.
|
||||
|
||||
```java
|
||||
@Test
|
||||
void cleanupDeletesInBoundedBatches() {
|
||||
InMemoryInbox inbox = new InMemoryInbox(List.of(1000, 500));
|
||||
int removed = new InboxCleanupJob(inbox, policy(7일, 1일), 10).runOnce(NOW);
|
||||
assertThat(removed).isEqualTo(1500);
|
||||
assertThat(inbox.cutoffs).hasSize(3);
|
||||
}
|
||||
```
|
||||
|
||||
`InMemoryInbox`는 **대본을 읽는 fake**다.
|
||||
|
||||
```java
|
||||
@Override
|
||||
public int purgeProcessedBefore(Instant processedBefore) {
|
||||
cutoffs.add(processedBefore);
|
||||
return pass < deletions.size() ? deletions.get(pass++) : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int purgeProcessedBefore(Instant processedBefore, int limit) {
|
||||
return Math.min(purgeProcessedBefore(processedBefore), limit);
|
||||
}
|
||||
```
|
||||
|
||||
무제한 메서드가 미리 준 목록(`1000, 500`)을 순서대로 반환하고 이후 0을 준다. **아무것도 삭제하지 않고 아무것도 제한하지 않는다.**
|
||||
|
||||
그래서 이 테스트가 통과로 증명하는 것은 "job이 0을 받을 때까지 루프를 돈다"이고, **"삭제가 배치로 제한된다"는 아니다.** 1000과 500은 배치처럼 보이는 숫자일 뿐이다.
|
||||
|
||||
bounded overload(`purgeProcessedBefore(Instant, int)`)는 fake에도 구현돼 있지만 **job이 부르지 않으므로 실행되지 않는다.**
|
||||
|
||||
`cleanupHonoursTheBatchCeilingSoItCannotRunForever`는 다른 성질(루프 상한)을 정확히 검증한다 — `maxBatches=2`에 6개 대본을 주고 호출이 2회임을 확인한다.
|
||||
|
||||
### 10.3 `anAlreadyAppliedMessageIsSafeToSettleButAClaimedOneIsNot`
|
||||
|
||||
```java
|
||||
assertThat(InboxResult.APPLIED.isSafeToSettle()).isTrue();
|
||||
assertThat(InboxResult.ALREADY_APPLIED.isSafeToSettle()).isTrue();
|
||||
assertThat(InboxResult.CLAIMED_ELSEWHERE.isSafeToSettle()).isFalse();
|
||||
```
|
||||
|
||||
**enum 상수의 boolean 필드를 단언한다.** 동작이 아니라 선언이다 — `messaging-transport-spi`의 `MessagingLifecycleTest`가 enum 선언 순서를 단언하는 것(그쪽 §10.2)과 같은 형태다. 그리고 §12.3이 보이듯 `CLAIMED_ELSEWHERE`는 production에서 생성되지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
| 게이트 | 이 leaf에 대해 |
|
||||
|---|---|
|
||||
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-reliability-api"]` |
|
||||
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
|
||||
| vendor `api` 규칙 | Spring 타입이 public 시그니처에 없음 → `implementation`. **통과** |
|
||||
| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |
|
||||
| Flyway migration | `V2__messaging_inbox.sql` — 네이밍이 `messaging` 네임스페이스 |
|
||||
| ArchUnit | 전용 규칙 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 실제 사용 여부와 negative-space probes
|
||||
|
||||
원시 증거: `evidence/raw/294-bounded-purge-never-called.txt`.
|
||||
|
||||
### 12.1 Public surface reachability
|
||||
|
||||
| 타입 | leaf 밖 | 판정 |
|
||||
|---|---:|---|
|
||||
| `IdempotentConsumer` | 1 | starter |
|
||||
| `InboxCleanupJob` | 1 | starter |
|
||||
| `InboxRetentionPolicy` | 1 | starter |
|
||||
| `TransactionalInboxHandler` | 1 | starter |
|
||||
| `JdbcInboxRepository` | **0** | — |
|
||||
| `InboxOutcome` | **0** | 내부 반환 타입 |
|
||||
|
||||
`JdbcInboxRepository`의 0이 주목된다 — starter가 `InboxRepository` bean을 만들지 않는다(§2). `InboxCleanupJob`·`TransactionalInboxHandler` bean이 `InboxRepository`/`IdempotentConsumer`를 인자로 받으므로 **누군가 그 bean을 공급해야 하고, 이 leaf의 구현이 그 후보인데 연결이 없다.** 그 판정은 starter leaf가 소유한다.
|
||||
|
||||
**메서드 수준 도달성: bounded 스윕이 호출되지 않는다**
|
||||
|
||||
`InboxRepository`와 `OutboxRepository` 둘 다 `purge*Before(Instant, int)` 오버로드를 선언하고, 두 JDBC 구현이 실제로 `LIMIT`를 쓰는 SQL로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 전부 **선언·구현·테스트 fake override**이고 **호출 지점이 하나도 없다**.
|
||||
|
||||
```
|
||||
2 port declarations + 2 production implementations + 5 test fake overrides = 9
|
||||
None of them is a call site.
|
||||
```
|
||||
|
||||
두 cleanup job이 무제한 오버로드를 부른다.
|
||||
|
||||
```java
|
||||
// InboxCleanupJob.java:56
|
||||
int deleted = inbox.purgeProcessedBefore(cutoff);
|
||||
// OutboxCleanupJob.java:50
|
||||
int deleted = outbox.purgePublishedBefore(cutoff);
|
||||
```
|
||||
|
||||
**`OutboxRepository`의 bounded 오버로드 javadoc이 그 상황을 정확히 예고한다.**
|
||||
|
||||
> The unbounded version deletes everything before the cutoff in one statement. On a table that has been accumulating published rows since the last sweep that is a single long transaction holding locks and generating WAL in proportion to the backlog, which shows up as the relay and the business writes stalling behind retention. **The cleanup jobs describe themselves as bounded by batch size; this is the parameter that makes that true.**
|
||||
|
||||
그 파라미터를 부르는 코드가 없다. 두 cleanup job은 여전히 "bounded by batch size"라고 자기를 서술한다.
|
||||
|
||||
`InboxCleanupJob.DEFAULT_BATCH_SIZE = 1_000`은 저장소 전체에서 **자기 선언 한 줄**만 등장한다.
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
Spring 주석 0개. starter의 세 bean이 이 leaf 타입을 만든다.
|
||||
|
||||
**형제 비교가 결정적이다.** `messaging-outbox-jdbc-postgresql`이 같은 구조를 갖는다.
|
||||
|
||||
| | inbox | outbox |
|
||||
|---|---|---|
|
||||
| bounded purge 구현 | o (`LIMIT` + `SKIP LOCKED`) | o |
|
||||
| cleanup job이 부르는 것 | 무제한 | 무제한 |
|
||||
| batch size 상수 | `DEFAULT_BATCH_SIZE`(미사용) | (outbox leaf가 답함) |
|
||||
|
||||
**두 leaf가 같은 결함을 갖는다.** 우연이 아니라 같은 리팩터가 두 곳에 같은 형태로 적용되고 호출부 갱신이 빠진 것으로 보인다 — 추론이며 커밋 근거는 없다.
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
**(a) `InboxResult`의 세 값 중 하나만 생성된다**
|
||||
|
||||
`TransactionalInboxHandler:70`이 `InboxResult.APPLIED`를 반환하는 것이 production의 유일한 생성 지점이다. `ALREADY_APPLIED`·`CLAIMED_ELSEWHERE`는 `InboxOperationsTest`의 단언에만 등장한다.
|
||||
|
||||
**구조적 이유가 있다.** `InboxRepository.reserve`가 `boolean`을 반환하므로 세 갈래를 표현할 수 없다. `messaging-reliability-api`의 `InboxResult` javadoc이 세 값이 필요한 이유를 이렇게 적는다.
|
||||
|
||||
> Three outcomes, not two. Collapsing `ALREADY_APPLIED` and `CLAIMED_ELSEWHERE` into a single "duplicate" would settle a message whose effect is still only half-written by another instance: if that instance then rolls back, the effect is lost and the broker will never redeliver, because this instance already acknowledged it.
|
||||
|
||||
**포트의 반환 타입이 그 구분을 표현 불가능하게 만든다.** `reserve`가 false를 주면 `IdempotentConsumer`는 `duplicate()`를 만들고 `TransactionalInboxHandler`는 `HandleResult.success()`를 반환한다 — 즉 **정산한다.** javadoc이 정산하면 안 된다고 한 경우와 해도 되는 경우가 같은 false로 들어온다.
|
||||
|
||||
**이 leaf에서 그 구분이 실제로 필요한지는 PostgreSQL의 `ON CONFLICT DO NOTHING` 동시성 동작에 달려 있고, 그것을 확인하지 않았다.** 미커밋 충돌 행이 있을 때 `DO NOTHING`이 대기하는지 즉시 0을 반환하는지에 따라 `CLAIMED_ELSEWHERE` 상황이 발생 가능한지가 갈린다. §16·§17.
|
||||
|
||||
**(b) 보존 규칙이 세 곳에 있다**
|
||||
|
||||
| 위치 | 형태 | 강제 |
|
||||
|---|---|---|
|
||||
| `InboxRepository.purgeProcessedBefore` javadoc | "Retention must outlive the broker's maximum redelivery window" | 없음 |
|
||||
| 이 leaf `InboxRetentionPolicy.validate()` | `retention >= window × 2.0` | **강제**(단 `InboxCleanupJob` 생성 시에만) |
|
||||
| `messaging-claim-check` `ClaimCheckPolicy` 생성자 | `retention >= brokerRetention + maxRedeliveryWindow` | **강제**(항상) |
|
||||
|
||||
세 곳이 같은 종류의 시간 관계를 다루고 **강제 시점과 공식이 다르다** — 곱셈(×2.0) vs 덧셈(brokerRetention + window). 두 leaf가 서로를 참조하지 않는다.
|
||||
|
||||
**(c) 커넥션 획득 방식이 둘**
|
||||
|
||||
| 메서드 | 방식 | 트랜잭션 참여 |
|
||||
|---|---|---|
|
||||
| `reserve(...)` | `DataSourceUtils.getConnection` | o |
|
||||
| `isProcessed`, `purge*` | `dataSource.getConnection()` | x |
|
||||
|
||||
의도된 구분이고 javadoc이 명시한다. 중복 아님.
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
| 문서 주장 | 재측정 | 결과 |
|
||||
|---|---|---|
|
||||
| `InboxCleanupJob` javadoc: "Deletes in bounded batches" | 무제한 오버로드 호출, `DEFAULT_BATCH_SIZE` 미사용 | **불일치** |
|
||||
| 같은 javadoc: 정책을 첫 삭제 전에 검증 | 생성자가 `policy.validate()` | **일치** |
|
||||
| `JdbcInboxRepository` javadoc: 예약이 `ON CONFLICT DO NOTHING`의 영향 행 수 | SQL 확인 | **일치** |
|
||||
| 같은 javadoc: 무인자 오버로드는 "only for retention sweeps and read-only queries" | 그 스윕이 무인자를 부르므로 문장은 맞다. 다만 그 스윕이 bounded여야 한다는 다른 javadoc과 충돌 | **부분 불일치** |
|
||||
| `OutboxRepository` javadoc: "this is the parameter that makes that true" | 그 파라미터 호출자 0 | **불일치** |
|
||||
| migration 주석: 보존 창이 재전달 지연보다 길어야 함 | `InboxRetentionPolicy`가 강제 | **일치** |
|
||||
| `build.gradle` 주석: 실 DB 인증 | `InboxPostgresIT` 6개 통과 | **일치** |
|
||||
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) |
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
| 위치 | 이전 상태 | 그것이 만든 실패 |
|
||||
|---|---|---|
|
||||
| `JdbcInboxRepository.reserve(Connection,…)` javadoc | 그 메서드가 public이고, interface 메서드는 **raw 커넥션을 열어 자동 커밋** | 부작용이 롤백돼도 예약은 커밋됨 → **메시지는 처리됨으로 남고 작업은 일어나지 않았으며 재전달이 거부됨** |
|
||||
| `JdbcInboxTransactionRequirementTest` javadoc | 같은 결함을 테스트 쪽에서 서술 | "the message counts as processed, the work never happened, and redelivery is refused because the inbox row is already there" |
|
||||
|
||||
**한 결함이 두 파일에 기록돼 있고, 그중 하나가 그것을 막는 테스트다.** 그리고 그 테스트가 "hermetic: the refusal has to happen before any connection is requested, and the data source below fails the test by being asked for one"이라고 자기 설계를 적는다 — **DataSource가 요청받으면 테스트가 실패하도록** 만들어 검사 순서까지 고정한다.
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-294 | command | `evidence/raw/294-bounded-purge-never-called.txt` | 두 포트의 bounded 오버로드 선언과 이유, 두 구현의 SQL, 시그니처 9회 등장이 전부 비호출, 두 cleanup job의 실제 호출, `DEFAULT_BATCH_SIZE` 단일 등장, 무제한 구현의 SQL, 테스트 fake의 대본, 컨테이너 레인도 무제한 호출 | 정적 검색 |
|
||||
| EVD-295 | command | `./gradlew :messaging:messaging-inbox-jdbc-postgresql:test --rerun-tasks` | BUILD SUCCESSFUL, 25 / 0 / 0. **`InboxPostgresIT` 6개 포함** | Testcontainers 환경 의존 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적**
|
||||
|
||||
- 복합 PK가 중복 제거 메커니즘인 이유 — 클래스 javadoc + migration 주석
|
||||
- 예약이 호출자 트랜잭션에 참여해야 하는 이유와 이전 결함 — `reserve(Connection,…)` javadoc
|
||||
- 세 검사가 커넥션 요청 전에 일어나야 하는 이유 — `requireActiveTransaction` javadoc + 테스트 javadoc
|
||||
- 트랜잭션 경계를 호출자에게 위임하는 이유 — `IdempotentConsumer` javadoc
|
||||
- 중복이 오류가 아닌 이유 — 같은 javadoc + `duplicateIsSuccess` 주석
|
||||
- 예외를 감싸되 삼키지 않는 이유 — 인라인 주석
|
||||
- 안전계수가 곱셈인 이유 — `InboxRetentionPolicy` javadoc
|
||||
- 정책을 첫 삭제 전에 검증하는 이유 — `InboxCleanupJob` javadoc
|
||||
- 실 DB 인증이 필요한 이유 — `build.gradle` 주석
|
||||
|
||||
**추론**
|
||||
|
||||
- 두 cleanup job이 같은 형태로 무제한 오버로드를 부르는 것은 bounded 오버로드가 나중에 추가되고 호출부가 갱신되지 않았기 때문이다 → **추론**. 두 곳의 동일한 형태는 관측이고 인과는 추론이다.
|
||||
- `CLAIMED_ELSEWHERE`가 생성되지 않는 것은 포트가 `boolean`을 반환하기 때문이다 → **관측에 가까운 추론**. 반환 타입은 관측이다.
|
||||
- `consumer_id` 길이 검증이 없는 것이 의도인지 → **미상**.
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- 6개 타입 542줄과 migration 전문
|
||||
- 25개 테스트가 통과하고 **컨테이너 레인 6개가 실 PostgreSQL에서 돈다**는 것
|
||||
- bounded purge 오버로드가 두 포트·두 구현에 있고 **호출 지점이 0**이라는 것
|
||||
- 두 cleanup job이 무제한 오버로드를 부르고 `DEFAULT_BATCH_SIZE`가 죽은 상수라는 것
|
||||
- `cleanupDeletesInBoundedBatches`가 대본 fake 위에서 통과한다는 것
|
||||
- `InboxResult` 세 값 중 하나만 production에서 생성된다는 것과 그 구조적 이유
|
||||
- 세 겹 트랜잭션 검사와 그것이 막는 이전 결함
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- **PostgreSQL의 `ON CONFLICT DO NOTHING`이 미커밋 충돌 행에 대해 대기하는지 즉시 0을 반환하는지.** `CLAIMED_ELSEWHERE` 상황의 발생 가능성이 여기에 달려 있고, 이 저장소의 테스트가 그것을 재현하지 않는다.
|
||||
- `InboxRepository` bean을 누가 만드는지 — starter leaf가 소유한다.
|
||||
- `consumer_id`가 160자를 넘는 배포가 있는지.
|
||||
- 무제한 DELETE가 실제 규모의 테이블에서 얼마나 오래 락을 잡는지 — 측정하지 않았다.
|
||||
- `InboxCleanupJob`을 스케줄링하는 주기 — starter가 소유한다.
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P1 — bounded purge가 구현돼 있고 호출되지 않아, cleanup이 스스로 막겠다고 한 장애를 일으킨다
|
||||
|
||||
- **사실.** `InboxRepository`·`OutboxRepository` 둘 다 `purge*Before(Instant, int)` 오버로드를 선언하고, `JdbcInboxRepository:141`·`JdbcOutboxRepository:486`이 `LIMIT` + `FOR UPDATE SKIP LOCKED`로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 **선언 2 + 구현 2 + 테스트 fake override 5**이고 **호출 지점이 0**이다. `InboxCleanupJob:56`과 `OutboxCleanupJob:50`이 무제한 오버로드를 부른다. `InboxCleanupJob.DEFAULT_BATCH_SIZE = 1_000`은 자기 선언 한 줄만 존재한다.
|
||||
- **근거.** `evidence/raw/294` §C·§D·§E.
|
||||
- **왜 문제인가.** `InboxCleanupJob`의 javadoc이 스스로 적는다 — *"A single unbounded DELETE over a table that has been accumulating for weeks holds locks long enough to block the very reservations the inbox exists to serve, so the cleanup would cause the outage it is meant to prevent."* 실행되는 코드가 정확히 그 문장이 서술하는 동작이다. `OutboxRepository`의 bounded 오버로드 javadoc은 한 발 더 나간다 — *"The cleanup jobs describe themselves as bounded by batch size; **this is the parameter that makes that true**."* 그 파라미터를 아무도 넘기지 않는다. 그리고 두 leaf가 **동일한 형태로** 그렇다.
|
||||
- **왜 P1인가.** 두 leaf 다 `runtime_memberships: ["app-bootstrap"]`이고 두 cleanup job이 starter에서 bean으로 만들어진다(`MessagingReliabilityAutoConfiguration`의 `inboxCleanupJob`·`outboxCleanupJob`). 즉 **출하 구성에서 실행되는 경로**이며, 백로그가 쌓인 뒤 첫 스윕에서 발현한다. 다른 미배선 발견들과 성격이 다르다.
|
||||
- **확인 방법.** `evidence/raw/294` 재실행. 또는 `git grep -n -E 'purge(Processed|Published)Before\s*\([^)]*,' -- 'src/**/*.java'`로 호출 지점이 없음을 확인.
|
||||
- **후보.** 두 job이 bounded 오버로드에 배치 크기를 넘기게 한다 — `InboxCleanupJob`은 이미 `DEFAULT_BATCH_SIZE`를 갖고 있다.
|
||||
- **다음 단계.** **CASE 후보.** 정적 재현이 완결되고, "장치는 있고 회로가 닫히지 않았다"의 변형 중 **닫히지 않은 회로가 실행 경로 위에 있는** 유일한 사례다. `messaging-outbox-jdbc-postgresql` leaf와 공동 소유.
|
||||
|
||||
### P2 — 속성을 이름으로 주장하는 테스트가 그 속성을 보일 수 없는 fake 위에서 통과한다
|
||||
|
||||
- **사실.** `InboxOperationsTest.cleanupDeletesInBoundedBatches`가 `InMemoryInbox(List.of(1000, 500))`에 대해 `removed == 1500`과 `cutoffs.hasSize(3)`을 단언한다. 그 fake의 무제한 메서드는 미리 준 목록을 순서대로 반환하는 **대본**이고 아무것도 삭제하거나 제한하지 않는다. bounded 오버로드는 fake에도 있지만 job이 부르지 않아 실행되지 않는다.
|
||||
- **근거.** `evidence/raw/294` §G.
|
||||
- **왜 문제인가.** 이 테스트가 통과로 증명하는 것은 "0을 받을 때까지 루프를 돈다"이고 이름이 주장하는 "배치로 제한된다"가 아니다. 1000·500은 배치처럼 보이는 숫자다. **P1이 이 테스트를 통과한 채로 존재할 수 있었던 이유**다. 그리고 컨테이너 레인(`InboxPostgresIT.retentionRemovesOldRows`)도 무제한 오버로드를 한 행에 대해 부르므로 실 DB에서도 드러나지 않는다.
|
||||
- **확인 방법.** `evidence/raw/294` §G·§H.
|
||||
- **후보.** fake의 무제한 메서드가 실제로 컬렉션에서 삭제하게 하고, bounded 메서드가 `limit`를 존중하게 한다. 그러면 테스트가 P1을 잡는다.
|
||||
- **다음 단계.** **CASE 후보 + REFERENCE 후보.** `messaging-transport-spi` §10.2(enum 순서를 단언하는 종료 테스트)와 같은 계열이고, "이름이 주장하는 속성을 fake가 표현할 수 있는지 먼저 확인한다"가 재사용 가능한 기준이다.
|
||||
|
||||
### P2 — SQL 실패가 재시도 불가로 분류된다
|
||||
|
||||
- **사실.** `INBOX_RESERVE_FAILED`·`INBOX_QUERY_FAILED`·`INBOX_PURGE_FAILED` 셋 다 `MessagingConfigurationException`이고, 그 예외의 카테고리는 `CONFIGURATION`, `retryable = false`다.
|
||||
- **근거.** `JdbcInboxRepository.java:77-80, 134-137, 165-168, 179-182`. `MessagingConfigurationException.java`의 `CATEGORY` 상수.
|
||||
- **왜 문제인가.** `SQLException`의 원인 대부분은 구성 오류가 아니라 **일시적 인프라**다 — 연결 끊김, 데드락, 락 타임아웃, 커넥션 풀 고갈. `FailureCategory`는 "the stable classification a retry engine, DLQ router, and dashboard all agree on"이고 `retryable = false`는 재시도 엔진이 즉시 파킹한다는 뜻이다. 같은 leaf의 `INBOX_ACTION_FAILED`는 `TRANSIENT_INFRASTRUCTURE`/`retryable = true`로 정확히 분류된다 — 같은 파일 안에서 기준이 갈린다.
|
||||
- **확인 방법.** 네 catch 블록과 `MessagingConfigurationException`의 카테고리 대조.
|
||||
- **후보.** SQL 실패를 `MessageBrokerUnavailableException`류(또는 `TRANSIENT_INFRASTRUCTURE` 카테고리를 갖는 예외)로 바꾸고, 진짜 구성 오류(테이블 없음 등)만 `CONFIGURATION`으로 남긴다.
|
||||
- **다음 단계.** **CASE 후보.** 재시도 정책이 실제로 갈리는 지점이다.
|
||||
|
||||
### P3 — 세 갈래 판정이 포트의 `boolean`에서 두 갈래로 접힌다
|
||||
|
||||
- **사실.** `InboxResult`가 세 값과 `isSafeToSettle()`을 갖는데 production은 `APPLIED`만 만든다. `InboxRepository.reserve`가 `boolean`을 반환하므로 `ALREADY_APPLIED`와 `CLAIMED_ELSEWHERE`가 같은 `false`로 들어온다. `TransactionalInboxHandler`는 그 경우 `HandleResult.success()`를 반환한다 — 정산한다.
|
||||
- **근거.** `evidence/raw/294` 범위 밖이나 §12.3(a)의 검색 결과. `InboxResult` javadoc.
|
||||
- **왜 문제인가.** `InboxResult` javadoc이 세 값이 필요한 이유로 정확히 그 정산을 든다 — "would settle a message whose effect is still only half-written by another instance". **다만 그 상황이 PostgreSQL에서 실제로 발생 가능한지 확인하지 않았다**(§16). `ON CONFLICT DO NOTHING`이 미커밋 충돌에 대해 대기한다면 `CLAIMED_ELSEWHERE`는 도달 불가능한 상태이고 enum이 과설계인 것이며, 즉시 0을 반환한다면 이것은 실제 결함이다.
|
||||
- **확인 방법.** 두 커넥션에서 같은 (message, consumer)를 예약하고 한쪽을 커밋하지 않은 채 다른 쪽의 `executeUpdate()` 반환을 관측한다 — `InboxPostgresIT`에 추가 가능하다.
|
||||
- **후보.** 먼저 확인한다. 발생 가능하면 포트 반환 타입을 `InboxResult`로 바꾼다.
|
||||
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 확인하지 않은 DB 동작에 걸린다.
|
||||
|
||||
### P3 — `consumer_id` 길이 제약이 애플리케이션 층에 없다
|
||||
|
||||
- **사실.** migration이 `consumer_id VARCHAR(160)`이다. `IdempotentConsumer`·`TransactionalInboxHandler`·`JdbcInboxRepository`가 공백만 거절하고 길이를 보지 않는다.
|
||||
- **근거.** `V2__messaging_inbox.sql:10`, 세 클래스의 검증.
|
||||
- **왜 문제인가.** 긴 consumerId가 DB에서 `SQLException`으로 실패하고, §17의 다른 항목대로 그것이 `INBOX_RESERVE_FAILED`/`CONFIGURATION`/`retryable=false`가 된다 — 즉 **설정 실수가 메시지 파킹으로 나타난다.** `messaging-core-api`의 값 객체들이 바이트 상한을 생성자에서 강제하는 것(그쪽 §4.5)과 대비된다.
|
||||
- **확인 방법.** 161자 consumerId로 `reserve` 호출.
|
||||
- **후보.** consumerId를 값 객체로 만들거나 길이 검증을 추가한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(컬럼 폭은 애플리케이션 검증과 짝을 이룬다).
|
||||
|
||||
### P3 — 보존 규칙이 세 곳에 있고 공식이 다르다
|
||||
|
||||
- **사실.** `InboxRepository` javadoc(강제 없음), 이 leaf `InboxRetentionPolicy`(`× 2.0`, `InboxCleanupJob` 생성 시에만), `messaging-claim-check` `ClaimCheckPolicy`(`brokerRetention + maxRedeliveryWindow`, 항상).
|
||||
- **근거.** 세 위치.
|
||||
- **왜 문제인가.** 같은 종류의 시간 관계를 곱셈과 덧셈으로 다르게 표현하고, 강제 시점도 다르다. 그리고 이 leaf의 `validate()`는 **cleanup job을 만들 때만** 불린다 — cleanup을 배선하지 않은 배포는 보존 검사를 받지 않는다.
|
||||
- **확인 방법.** 세 위치의 공식 대조.
|
||||
- **후보.** 공식을 하나로 정하고 정책 생성자에서 강제한다(claim-check처럼).
|
||||
- **다음 단계.** **REFERENCE 후보**(같은 안전 규칙은 한 공식과 한 강제 시점을 갖는다).
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- 복합 PK + `ON CONFLICT DO NOTHING`의 영향 행 수를 판정으로 쓰는 것
|
||||
- 트랜잭션 경계를 호출자에게 위임하고 그 위임이 지켜졌는지 런타임에 세 겹으로 확인하는 것
|
||||
- 세 검사가 커넥션 요청 **전에** 일어나고, 그것을 DataSource가 요청받으면 실패하는 테스트로 고정한 것
|
||||
- 다른 DataSource에 묶인 트랜잭션을 거절하는 것
|
||||
- action 예외를 감싸되 삼키지 않아 롤백이 예약까지 되돌리게 하는 것
|
||||
- 중복을 성공으로 보고해 완료된 작업을 DLQ로 보내지 않는 것
|
||||
- 안전계수를 곱셈으로 둔 것과 그 이유
|
||||
- 정책을 첫 삭제 전에 검증하는 것
|
||||
- 실 PostgreSQL 컨테이너 레인이 기본 test 태스크에서 도는 것과, 롤백 경로를 그 레인이 검증하는 것
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MIJ-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 2개, memberships `["app-bootstrap"]` | 선언 |
|
||||
| MIJ-002 | build | `messaging-inbox-jdbc-postgresql/build.gradle` | same | 실 DB 인증 의도 | — |
|
||||
| MIJ-003 | code | `.../inbox/JdbcInboxRepository.java` 전문 | same | §4.1 세 검사, 두 오버로드의 SQL | 무제한만 호출됨 |
|
||||
| MIJ-004 | code | `.../inbox/IdempotentConsumer.java` | same | §4.2 트랜잭션 위임 | — |
|
||||
| MIJ-005 | code | `.../inbox/TransactionalInboxHandler.java` | same | §4.3 세 금지와 예외 캐리어 | `APPLIED`만 생성 |
|
||||
| MIJ-006 | code | `.../inbox/InboxRetentionPolicy.java` | same | §4.4 곱셈 안전계수 | `validate()` 호출 시점 제한 |
|
||||
| MIJ-007 | code | `.../inbox/InboxCleanupJob.java` | same | §4.5 선언과 구현의 불일치 | — |
|
||||
| MIJ-008 | migration | `.../db/migration/messaging/V2__messaging_inbox.sql` | same | 복합 PK, 인덱스, 컬럼 폭 | — |
|
||||
| MIJ-009 | test | `InboxPostgresIT` (6) | same | 실 PostgreSQL 롤백·중복·보존 | bounded 스윕 미검증 |
|
||||
| MIJ-010 | test | `JdbcInboxTransactionRequirementTest` (4) | same | 세 거절이 커넥션 전에 | — |
|
||||
| MIJ-011 | test | `IdempotentConsumerTest` (6), `InboxOperationsTest` (9) | same | §10 표 | fake가 대본(§10.2) |
|
||||
| MIJ-012 | cross-leaf code | `messaging-reliability-api/.../InboxRepository.java:36-52`, `OutboxRepository.java:132-151` | same | 두 오버로드 선언과 bounded의 존재 이유 | 해당 leaf SSOT가 소유 |
|
||||
| MIJ-013 | cross-leaf code | `messaging-outbox-jdbc-postgresql/.../OutboxCleanupJob.java:50`, `JdbcOutboxRepository.java:486` | same | 같은 결함이 형제 leaf에도 | 해당 leaf SSOT가 소유 |
|
||||
| EVD-294 | command | `evidence/raw/294-bounded-purge-never-called.txt` | same | §12.1 전부 | 정적 검색 |
|
||||
| EVD-295 | command | `./gradlew :messaging:messaging-inbox-jdbc-postgresql:test --rerun-tasks` | same | 25 / 0 / 0, 컨테이너 6개 포함 | Testcontainers 환경 의존 |
|
||||
@@ -0,0 +1,546 @@
|
||||
# messaging-kafka-share-experimental 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-kafka-share-experimental`
|
||||
> SSOT owner: `messaging-kafka-share-experimental`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- registered leaf id: `messaging-kafka-share-experimental`
|
||||
- canonical state `analysisFile`: `analysis/messaging/messaging-kafka-share-experimental.md`
|
||||
- source path: `src/messaging/messaging-kafka-share-experimental`
|
||||
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-policy", "messaging-transport-spi", "messaging-kafka"]`
|
||||
- registry `runtime_memberships`: **`[]`** — build-only / incubating
|
||||
|
||||
### 숫자
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | **4** |
|
||||
| production LOC | **190** — messaging family에서 가장 작다 |
|
||||
| 패키지 | 1 (`dev.caskeleton.messaging.kafka.share`) |
|
||||
| test 파일 | 1 |
|
||||
| test 메서드(실행 확인) | 6 |
|
||||
| 선언된 외부 의존성 | 1 (`org.apache.kafka:kafka-clients`, `implementation`) |
|
||||
| **실제 사용된 외부 의존성** | **0**(§12.4) |
|
||||
|
||||
네 타입:
|
||||
|
||||
| 타입 | 종류 | LOC | leaf 밖 참조 |
|
||||
|---|---|---:|---:|
|
||||
| `KafkaShareGroupRegistrar` | class | 88 | **0** |
|
||||
| `KafkaShareProfileValidator` | class | 42 | **0** |
|
||||
| `KafkaShareProfile` | record | 33 | **0** |
|
||||
| `KafkaShareWorkQueueCapability` | class | 27 | **0** |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `src/main/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `src/test/java/**` (1) | 1 | `FULL_READ` | 6개 테스트 확인 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 10줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
Kafka **Share Group**(KIP-932, 경쟁 소비자 work queue)을 실험적 어댑터로 감싼다. `runtime_memberships: []`이고 이름 자체가 `-experimental`이다.
|
||||
|
||||
이 leaf의 실질은 **거절**이다. 190줄 중 실제 동작을 하는 코드는 거의 없고, 세 가지를 거절한다.
|
||||
|
||||
| 거절 | 코드 | 이유 |
|
||||
|---|---|---|
|
||||
| 비활성 상태의 사용 | `KAFKA_SHARE_DISABLED` | experimental이 기본 켜지지 않게 |
|
||||
| 순서 보장 목적지 | `IllegalArgumentException` | share group이 순서를 줄 수 없음 |
|
||||
| pause/resume | `KAFKA_SHARE_NO_PAUSE`/`_NO_RESUME` | 일시정지할 파티션 할당이 없음 |
|
||||
|
||||
핵심 진술이 validator javadoc에 있다.
|
||||
|
||||
```java
|
||||
// KafkaShareProfileValidator.java:10-17
|
||||
* <p>A share group hands individual records to competing consumers and acknowledges them
|
||||
* individually. That is a work queue, and it is fundamentally incompatible with partition ordering:
|
||||
* two consumers in the same share group can process records from one partition concurrently and
|
||||
* finish in either order. Configuring an ordered destination on a share group would therefore
|
||||
* advertise a guarantee the broker is not providing, so it is refused rather than degraded.
|
||||
*
|
||||
* <p>The adapter is also off unless explicitly enabled, so an Experimental capability cannot drift
|
||||
* into a Stable deployment by default.
|
||||
```
|
||||
|
||||
두 번째 문단이 이 저장소의 experimental 정책을 한 문장으로 담는다 — **기본 꺼짐이 drift 방지 수단이다.**
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
들어오는 것(project): `messaging-core-api`, `messaging-policy`, `messaging-transport-spi`, `messaging-kafka` — 넷 다 `api`.
|
||||
|
||||
들어오는 것(vendor): `org.apache.kafka:kafka-clients`(`implementation`) — **어떤 소스도 import하지 않는다**(§12.4).
|
||||
|
||||
나가는 것: **없다.** 어떤 leaf의 `allowed_dependencies`에도 이 leaf가 없고 starter 목록에도 없다.
|
||||
|
||||
런타임 배선: 없음. `runtime_memberships: []`. bean 없음(Spring 주석 0개).
|
||||
|
||||
**소비자 없음·membership 없음·조립 없음의 삼중 정합**이다 — `messaging-schema-avro`·`messaging-schema-protobuf`와 같은 형태이고, incubating leaf의 올바른 상태다.
|
||||
|
||||
**`messaging-policy`와 `messaging-kafka` 의존이 실제로 쓰이는가.**
|
||||
|
||||
| 의존 | 사용 |
|
||||
|---|---|
|
||||
| `messaging-core-api` | `OrderingScope`, `DestinationName`, `MessagingCapabilities`, `MessagingCapabilityUnavailableException` — **사용** |
|
||||
| `messaging-transport-spi` | `TransportConsumerRegistration`, `TransportConsumerSpec` — **사용** |
|
||||
| `messaging-policy` | 어떤 타입도 import하지 않음 — **미사용** |
|
||||
| `messaging-kafka` | 어떤 타입도 import하지 않음 — **미사용** |
|
||||
|
||||
네 project 의존 중 둘, 벤더 의존 하나가 미사용이다. §17.
|
||||
|
||||
---
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
```
|
||||
KafkaShareProfile (record)
|
||||
destination · shareGroup · orderingScope · enabled · maxDeliveryCount
|
||||
↓
|
||||
KafkaShareProfileValidator.validate(profile)
|
||||
├── !enabled → MessagingCapabilityUnavailableException(KAFKA_SHARE_DISABLED)
|
||||
└── orderingScope != NONE → IllegalArgumentException
|
||||
↓
|
||||
KafkaShareGroupRegistrar.register(profile, spec)
|
||||
└── ShareRegistration implements TransportConsumerRegistration
|
||||
├── pause(scope) → failedFuture(KAFKA_SHARE_NO_PAUSE)
|
||||
├── resume(scope) → failedFuture(KAFKA_SHARE_NO_RESUME)
|
||||
├── isActive() → true until close()
|
||||
└── close() → active = false
|
||||
|
||||
KafkaShareWorkQueueCapability.capabilities() → MessagingCapabilities(12 booleans)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
### 4.1 `KafkaShareProfile`
|
||||
|
||||
다섯 필드. 생성자가 `shareGroup` 공백과 `maxDeliveryCount < 1`을 거절한다.
|
||||
|
||||
`maxDeliveryCount`가 javadoc에서 "how many times a record may be re-acquired before it is released"라고 정의된다 — Share Group의 재획득 한계다. **이 필드를 읽는 코드가 이 leaf에 없다.** validator도 registrar도 쓰지 않는다.
|
||||
|
||||
### 4.2 `KafkaShareProfileValidator` — 두 거절
|
||||
|
||||
```java
|
||||
if (!profile.enabled()) {
|
||||
throw new MessagingCapabilityUnavailableException(
|
||||
"KAFKA_SHARE_DISABLED",
|
||||
"the Kafka Share Group adapter is experimental and disabled unless "
|
||||
+ "backend.messaging.experimental.kafka-share=true");
|
||||
}
|
||||
if (profile.orderingScope() != OrderingScope.NONE) {
|
||||
throw new IllegalArgumentException(
|
||||
"a Kafka share group cannot provide ordered delivery: " + profile.destination().value());
|
||||
}
|
||||
```
|
||||
|
||||
**두 거절의 예외 타입이 다르다.** 첫째는 `MessagingCapabilityUnavailableException`(카테고리 `CONFIGURATION`, 안정 코드 있음), 둘째는 `IllegalArgumentException`(코드 없음). 둘 다 설정 오류인데 하나만 플랫폼 실패 어휘를 쓴다. §17.
|
||||
|
||||
에러 메시지가 **프로퍼티 키를 직접 적는다** — `backend.messaging.experimental.kafka-share=true`. 그 키를 읽는 코드가 이 저장소에 없다(§12.4).
|
||||
|
||||
### 4.3 `KafkaShareGroupRegistrar` — spec을 받고 쓰지 않는다
|
||||
|
||||
```java
|
||||
public TransportConsumerRegistration register(
|
||||
KafkaShareProfile profile, TransportConsumerSpec spec) {
|
||||
Objects.requireNonNull(spec, "spec must not be null");
|
||||
validator.validate(profile);
|
||||
return new ShareRegistration(profile);
|
||||
}
|
||||
```
|
||||
|
||||
`spec`은 **null 검사만 받는다.** `ShareRegistration`은 `profile`과 `AtomicBoolean active` 둘만 갖는다.
|
||||
|
||||
`TransportConsumerSpec`은 `(DestinationProfile profile, Function<TransportDelivery, CompletionStage<Void>> sink)`이고, `sink`가 플랫폼이 전달마다 부르는 콜백이다(`messaging-transport-spi` §4.5). 그 sink가 저장되지 않으므로 **어떤 메시지도 전달되지 않는다.**
|
||||
|
||||
Kafka 소비자도 만들어지지 않는다 — `kafka-clients`를 import하는 코드가 없다.
|
||||
|
||||
즉 `register(...)`는 **아무것도 등록하지 않고** `isActive() == true`인 객체를 반환한다. §17.
|
||||
|
||||
### 4.4 `ShareRegistration` — pause/resume은 실패 stage
|
||||
|
||||
```java
|
||||
@Override
|
||||
public CompletionStage<Void> pause(String scope) {
|
||||
return CompletableFuture.failedFuture(
|
||||
new MessagingCapabilityUnavailableException("KAFKA_SHARE_NO_PAUSE", ...));
|
||||
}
|
||||
```
|
||||
|
||||
registrar javadoc이 이유를 적는다.
|
||||
|
||||
```java
|
||||
// :12-15
|
||||
* <p>Pause and resume are refused rather than silently ignored. A share group has no partition
|
||||
* assignment to pause, so accepting the call would let a retry policy that depends on pausing
|
||||
* appear to work while doing nothing.
|
||||
```
|
||||
|
||||
**예외를 던지지 않고 실패한 `CompletionStage`를 반환한다** — `TransportConsumerRegistration.pause`의 반환 타입이 `CompletionStage<Void>`이므로 비동기 계약을 지킨다. `messaging-runtime-core`의 `DefaultDeliveryProcessor.OneShotSettlement`가 이중 정산을 `failedFuture`로 보고하는 것과 같은 규율이다.
|
||||
|
||||
이 거절이 `messaging-policy`의 `RetryMode.PAUSE_PARTITION`과 맞물린다 — 그 모드를 share group 목적지에 설정하면 `DefaultRetryDecisionEngine`이 `PauseAndRetry`를 고르고 이 registration이 그것을 거절한다. **두 leaf가 같은 사실을 양쪽에서 안다.**
|
||||
|
||||
`close()`가 `active`를 false로 바꾸는 것 외에 아무것도 하지 않는다 — 해제할 자원이 없기 때문이다.
|
||||
|
||||
### 4.5 `KafkaShareWorkQueueCapability` — 12개 boolean
|
||||
|
||||
```java
|
||||
return new MessagingCapabilities(
|
||||
true, true, true, false, false, false, false, false, false, false, false, false);
|
||||
```
|
||||
|
||||
`MessagingCapabilities`의 필드 순서에 대입하면:
|
||||
|
||||
| # | capability | 값 |
|
||||
|---:|---|:---:|
|
||||
| 1 | `brokerAcknowledgement` | **true** |
|
||||
| 2 | `replicationOrPersistenceEvidence` | **true** |
|
||||
| 3 | `perMessageSettlement` | **true** |
|
||||
| 4 | `batchSettlement` | false |
|
||||
| 5 | `orderedStream` | false |
|
||||
| 6 | `keyedOrdering` | false |
|
||||
| 7 | `replay` | false |
|
||||
| 8 | `delayedDelivery` | false |
|
||||
| 9 | `brokerTransaction` | false |
|
||||
| 10 | `deduplicatedPublish` | false |
|
||||
| 11 | `nativeDeadLetter` | false |
|
||||
| 12 | `topologyManagement` | false |
|
||||
|
||||
javadoc이 요약한다 — "Per-record settlement, yes. Ordering, replay, and transactions, no — a share group gives up exactly those to gain competing-consumer throughput."
|
||||
|
||||
**세 true가 정확히 3·1·2번**이고 javadoc이 "per-record settlement"만 언급한다. 1·2번(브로커 ack, 복제 증거)은 언급되지 않는다.
|
||||
|
||||
선언 목적도 적혀 있다 — "Declared as a capability rather than assumed, so that the shared validators refuse an ordered or replayed destination on this adapter before a message is ever produced." 즉 `messaging-policy`의 검증기와 `DefaultRetryDecisionEngine`이 이 값을 읽을 것을 전제한다. **그 전달 경로가 없다**(§12.1).
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
**등록:** `registrar.register(profile, spec)` → `validator.validate(profile)` → 통과하면 `ShareRegistration(profile)` 반환 → **이후 아무 일도 일어나지 않는다**
|
||||
|
||||
**pause:** `registration.pause(scope)` → 즉시 실패 stage
|
||||
|
||||
이 leaf에 메시지가 흐르는 경로가 없다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
| 코드 | 예외 | 카테고리 | 조건 |
|
||||
|---|---|---|---|
|
||||
| `KAFKA_SHARE_DISABLED` | `MessagingCapabilityUnavailableException` | `CONFIGURATION` | `enabled == false` |
|
||||
| (코드 없음) | `IllegalArgumentException` | — | `orderingScope != NONE` |
|
||||
| `KAFKA_SHARE_NO_PAUSE` | `MessagingCapabilityUnavailableException` | `CONFIGURATION` | `pause(...)` |
|
||||
| `KAFKA_SHARE_NO_RESUME` | `MessagingCapabilityUnavailableException` | `CONFIGURATION` | `resume(...)` |
|
||||
| (코드 없음) | `IllegalArgumentException` | — | `shareGroup` 공백, `maxDeliveryCount < 1` |
|
||||
|
||||
`MessagingCapabilityUnavailableException`의 javadoc이 이 leaf의 태도와 정확히 일치한다 — "Thrown instead of quietly degrading. Downgrading … ordered delivery to unordered, produces a system that looks healthy right up to the moment the guarantee actually mattered."
|
||||
|
||||
---
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
트랜잭션 없음.
|
||||
|
||||
`ShareRegistration.active`가 `AtomicBoolean`이다. `close()`가 `set(false)`이고 CAS가 아니므로 두 번 닫아도 무해하다(멱등).
|
||||
|
||||
`KafkaShareProfileValidator`·`KafkaShareWorkQueueCapability`는 상태가 없다. `KafkaShareGroupRegistrar`는 validator 참조 하나만 갖는다.
|
||||
|
||||
수명주기 참여 없음 — `TransportConsumerRegistration`이 `AutoCloseable`이지만 이 구현은 닫을 자원을 갖지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 프로퍼티 키(에러 메시지에만 등장) | `backend.messaging.experimental.kafka-share` |
|
||||
| `enabled` | `KafkaShareProfile`의 필드 — 호출자가 채운다 |
|
||||
|
||||
**그 프로퍼티를 읽는 코드가 저장소에 없다.** `enabled`는 `KafkaShareProfile` 생성자 인자이고 그 profile을 만드는 production 코드도 없다. 즉 키는 문서로만 존재한다. §17.
|
||||
|
||||
상수 없음.
|
||||
|
||||
---
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
**없다.** Kafka Share Group을 감싼다고 선언하지만 Kafka 클라이언트를 사용하지 않는다.
|
||||
|
||||
`build.gradle`이 `implementation 'org.apache.kafka:kafka-clients'`를 선언하고 `import org.apache.kafka`가 소스에 0건이다(§12.4).
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
레인: `./gradlew :messaging:messaging-kafka-share-experimental:test`. **BUILD SUCCESSFUL, 6 tests, 0 skipped, 0 failures**.
|
||||
|
||||
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|
||||
|---|---:|---|---|
|
||||
| `KafkaShareProfileValidatorTest` | 6 | 두 거절 조건과 통과 조건 | **registrar·capability가 검증되지 않음** |
|
||||
|
||||
**네 타입 중 하나만 테스트된다.**
|
||||
|
||||
- `KafkaShareGroupRegistrar` — 테스트 없음. `register`가 spec을 무시하는 것, pause/resume이 실패 stage를 반환하는 것, `close`가 `isActive`를 바꾸는 것이 전부 미검증
|
||||
- `KafkaShareWorkQueueCapability` — 테스트 없음. 12개 boolean 중 어느 것도 단언되지 않음
|
||||
- `KafkaShareProfile` — 생성자 거절 둘이 validator 테스트를 통해 간접적으로만
|
||||
|
||||
`messaging-schema-avro`가 3개 테스트 클래스로 2개 production 타입을 덮는 것과 대비된다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
| 게이트 | 이 leaf에 대해 |
|
||||
|---|---|
|
||||
| `verifyCleanArchitectureDependencies` | 네 project 의존 — **미사용 둘을 포함해 통과한다**(허용 목록은 상한이지 하한이 아니다) |
|
||||
| `verifyRuntimeModuleMembership` | `[]` — 런타임 편입 없음이 강제됨 |
|
||||
| vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | public 시그니처에 Kafka 타입이 없으므로 `implementation`이 맞다. **다만 아예 쓰이지 않는다**(§12.4) |
|
||||
| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |
|
||||
| ArchUnit | 전용 규칙 없음 |
|
||||
|
||||
첫 행이 이 leaf의 §17 항목 중 하나다 — `allowed_dependencies`가 **실제 사용을 요구하지 않는다.**
|
||||
|
||||
---
|
||||
|
||||
## 12. 실제 사용 여부와 negative-space probes
|
||||
|
||||
원시 증거: `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` §C·§D·§E.
|
||||
|
||||
### 12.1 Public surface reachability
|
||||
|
||||
**네 타입 전부 leaf 밖 참조 0이다.**
|
||||
|
||||
```
|
||||
KafkaShareGroupRegistrar 0
|
||||
KafkaShareProfile 0
|
||||
KafkaShareProfileValidator 0
|
||||
KafkaShareWorkQueueCapability 0
|
||||
```
|
||||
|
||||
`runtime_memberships: []`, starter 미포함, 조립 0건 — **삼중 정합**이다. `messaging-schema-avro`·`messaging-schema-protobuf`와 같은 상태이고, incubating leaf가 이래야 하는 형태다.
|
||||
|
||||
`messaging-claim-check`·`messaging-cloudevents`와 대비된다 — 그 둘은 소비자 0인데 membership이 있다.
|
||||
|
||||
**`KafkaShareWorkQueueCapability`의 0이 다른 의미를 갖는다.** 이 클래스의 javadoc은 "so that the shared validators refuse an ordered or replayed destination on this adapter before a message is ever produced"라고 한다. 즉 **공유 검증기가 이 값을 읽을 것을 전제한다.** `messaging-policy`의 `RetryContext.capabilities`와 `DefaultRetryDecisionEngine`이 그 소비자인데, 그것에 이 값을 넘기는 경로가 없다. `MessagingTransport.capabilities(DestinationName)`가 그 경로여야 하는데 이 leaf는 `MessagingTransport`를 구현하지 않는다.
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
Spring 주석 0개, bean 없음.
|
||||
|
||||
**`MessagingTransport` 구현 sibling과의 비교가 유의미하다.**
|
||||
|
||||
| 어댑터 leaf | `MessagingTransport` 구현 | membership |
|
||||
|---|:---:|---|
|
||||
| `messaging-kafka` | o (`KafkaMessagingTransport`) | `["app-bootstrap"]` |
|
||||
| `messaging-rabbit` | o (`RabbitMessagingTransport`) | `["app-bootstrap"]` |
|
||||
| `messaging-pulsar-experimental` | o (`PulsarMessagingTransport`) | `[]` |
|
||||
| `messaging-nats-experimental` | o (`NatsJetStreamTransport`) | `[]` |
|
||||
| **`messaging-kafka-share-experimental`** | **x** | `[]` |
|
||||
|
||||
**네 형제 어댑터가 전부 SPI를 구현하고 이 leaf만 구현하지 않는다.** 두 experimental 형제(pulsar, nats)도 구현한다. 그래서 이 leaf는 "experimental이라서 미완"이 아니라 **형제와 다른 형태**다 — `TransportConsumerRegistration`만 부분 구현하고 `MessagingTransport`는 건드리지 않는다.
|
||||
|
||||
결과: capability 선언(§12.1)도, 발행 경로도, 소비 경로도 플랫폼에 연결될 지점이 없다.
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
**(a) 순서 거절이 두 곳에 있다**
|
||||
|
||||
| 위치 | 검사 |
|
||||
|---|---|
|
||||
| 이 leaf `KafkaShareProfileValidator` | `orderingScope != NONE` → 거절 |
|
||||
| `messaging-policy` `DestinationProfileValidator:78-82` | `orderingScope == DESTINATION && consumer.concurrency > 1` → 거절 |
|
||||
| `messaging-policy` `DestinationProfileValidator:83-87` | `isOrdered() && maxInFlightPerOrderingUnit > 1` → 거절 |
|
||||
|
||||
세 검사가 같은 관심사(순서와 동시성의 양립 불가)를 다룬다. 이 leaf의 것이 가장 강하다 — **순서 자체를 금지**한다. policy 쪽은 순서를 허용하되 동시성을 1로 묶는다.
|
||||
|
||||
두 정책이 만나는 지점이 없다 — 이 leaf가 `DestinationProfile`을 받지 않고 자기 `KafkaShareProfile`을 쓴다. 즉 **목적지 프로파일 하나가 두 검증기를 통과하는 경로가 없다.** 중복이 아니라 **연결되지 않은 두 모델**이다.
|
||||
|
||||
**(b) `enabled` 플래그 패턴**
|
||||
|
||||
experimental leaf 셋(`kafka-share`, `pulsar`, `nats`) 중 이 leaf만 `enabled`를 profile 필드로 갖는다. 나머지 둘의 활성화 방식은 각 leaf SSOT가 답한다.
|
||||
|
||||
**(c) `MessagingCapabilities` 선언이 어댑터마다**
|
||||
|
||||
각 어댑터가 자기 capability 집합을 선언한다. 이 leaf는 정적 메서드 하나, `messaging-kafka`는 `KafkaMessagingTransport.CAPABILITIES` 상수. 형태가 다르지만 중복 경쟁은 아니다 — 각자 자기 브로커를 서술한다.
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
| 문서 주장 | 재측정 | 결과 |
|
||||
|---|---|---|
|
||||
| `build.gradle`: `kafka-clients` 의존 | `import org.apache.kafka` **0건** | **미사용 의존** |
|
||||
| registry: `messaging-policy`·`messaging-kafka` 의존 | 두 패키지에서 import 0건 | **미사용 의존** |
|
||||
| `KafkaShareProfileValidator` 에러 메시지: `backend.messaging.experimental.kafka-share=true` | 그 키를 읽는 코드 0건 | **미실현** |
|
||||
| `KafkaShareWorkQueueCapability` javadoc: "the shared validators refuse … before a message is ever produced" | capability를 검증기로 넘기는 경로 없음 | **미실현** |
|
||||
| `KafkaShareGroupRegistrar` javadoc: "Registers a share group consumer" | 소비자를 만들지 않음 | **불일치** |
|
||||
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 실제로 `[]` | **이 leaf에 한해 참** |
|
||||
|
||||
다섯 번째가 이 leaf의 가장 무거운 drift다 — 클래스 이름과 메서드 이름이 하지 않는 일을 서술한다.
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
이 leaf의 javadoc에 **이전 결함 서술이 없다.** 다른 messaging leaf 대부분이 "X used to …" 형태의 기록을 갖는 것과 대비된다.
|
||||
|
||||
대신 **막으려는 것**을 셋 적는다.
|
||||
|
||||
| 위치 | 막으려는 것 |
|
||||
|---|---|
|
||||
| `KafkaShareProfileValidator` | 순서 목적지를 share group에 설정 → 브로커가 주지 않는 보장을 광고 |
|
||||
| 같은 곳 | experimental이 기본 켜져 Stable 배포로 drift |
|
||||
| `KafkaShareGroupRegistrar` | pause를 조용히 무시 → pause에 의존하는 retry 정책이 동작하는 것처럼 보이며 아무것도 하지 않음 |
|
||||
|
||||
세 번째가 이 leaf에서 가장 성숙한 판단이다 — **거절이 무시보다 낫다**는 원칙이고, `messaging-core-api`의 `MessagingCapabilityUnavailableException` javadoc과 같은 계열이다.
|
||||
|
||||
역설적으로 **그 원칙이 `register(...)`에는 적용되지 않았다** — spec을 받아 무시하고 성공을 반환한다(§17).
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` §C·§D·§E | 네 타입 참조 0, `kafka-clients` 선언과 import 0(exit=1), `register`가 spec을 무시하는 코드와 `ShareRegistration` 필드 | 정적 검색 |
|
||||
| EVD-293 | command | `./gradlew :messaging:messaging-kafka-share-experimental:test --rerun-tasks` | BUILD SUCCESSFUL, 6 / 0 / 0 | validator만 검증 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적**
|
||||
|
||||
- share group이 순서와 양립 불가인 이유 — `KafkaShareProfileValidator` javadoc
|
||||
- experimental이 기본 꺼짐인 이유 — 같은 javadoc
|
||||
- pause/resume을 무시하지 않고 거절하는 이유 — `KafkaShareGroupRegistrar` javadoc
|
||||
- capability를 선언으로 두는 이유 — `KafkaShareWorkQueueCapability` javadoc
|
||||
- share group이 포기한 것(순서·replay·트랜잭션)과 얻은 것(경쟁 소비자 처리량) — 같은 javadoc
|
||||
|
||||
**추론**
|
||||
|
||||
- `register`가 spec을 쓰지 않는 것이 미완인지 의도인지 → **미상**. 다른 형제 어댑터는 전부 실제 소비자를 만든다.
|
||||
- `kafka-clients`·`messaging-policy`·`messaging-kafka` 의존이 선언만 된 이유 → **추론**. 완성된 구현을 상정하고 미리 선언한 것으로 보인다.
|
||||
- `maxDeliveryCount`를 읽는 코드가 없는 이유 → **미상**. 같은 추론이 적용된다.
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- 4개 타입 190줄 전문
|
||||
- 6개 테스트가 통과하고 validator만 덮는다는 것
|
||||
- 네 타입 전부 참조 0이고 membership `[]`과 정합한다는 것
|
||||
- `kafka-clients` 의존 선언과 import 0건
|
||||
- `messaging-policy`·`messaging-kafka` 의존이 사용되지 않는다는 것
|
||||
- `register(...)`가 `TransportConsumerSpec`을 null 검사만 하고 버린다는 것
|
||||
- 형제 어댑터 넷이 전부 `MessagingTransport`를 구현하고 이 leaf만 하지 않는다는 것
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- 이 leaf를 완성할 계획이 있는지 — 커밋이 대량 커밋뿐이고 기록이 없다.
|
||||
- Kafka Share Group(KIP-932)이 이 저장소가 고정한 Kafka 버전에서 사용 가능한지 — `kafka-clients` 버전이 lockfile에 있으나 확인하지 않았다.
|
||||
- `backend.messaging.experimental.kafka-share` 키가 어딘가 문서화돼 있는지 — `docs/messaging/experimental-policy.md`가 후보다.
|
||||
- `maxDeliveryCount`가 어떤 값을 갖도록 의도됐는지.
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P2 — "등록"이 아무것도 등록하지 않고 성공을 반환한다
|
||||
|
||||
- **사실.** `KafkaShareGroupRegistrar.register(profile, spec)`이 `spec`을 `Objects.requireNonNull`로만 처리하고 버린다. `ShareRegistration`은 `profile`과 `AtomicBoolean` 둘만 갖는다. Kafka 소비자가 만들어지지 않고(`import org.apache.kafka` 0건), `spec.sink`가 저장되지 않으므로 어떤 전달도 일어나지 않는다. 반환된 registration은 `isActive() == true`를 보고한다.
|
||||
- **근거.** `evidence/raw/290` §D·§E.
|
||||
- **왜 문제인가.** 같은 클래스의 javadoc이 pause를 조용히 무시하는 것을 거절한 이유로 "would let a retry policy that depends on pausing appear to work while doing nothing"을 든다. `register` 자체가 정확히 그 형태다 — 성공을 반환하고 아무것도 하지 않으며 `isActive()`가 true다. 오늘 호출자가 없으므로 사고는 아니지만, 이 leaf를 배선하는 사람이 가장 먼저 부를 메서드다.
|
||||
- **확인 방법.** `evidence/raw/290` §E 재실행.
|
||||
- **후보.** (a) 실제 share group 소비자를 만든다. (b) 미구현임을 명시하고 `MessagingCapabilityUnavailableException`으로 거절한다 — 이 leaf 자신의 원칙과 일관된다. (c) `register`를 제거하고 validator와 capability만 남긴다.
|
||||
- **다음 단계.** **CASE 후보.** "무시보다 거절"을 명시한 클래스가 자기 주 메서드에서는 무시한다는 형태가 그 자체로 가치가 있다.
|
||||
|
||||
### P3 — 선언된 의존 셋이 사용되지 않는다
|
||||
|
||||
- **사실.** `build.gradle`이 `org.apache.kafka:kafka-clients`를 선언하고 `import org.apache.kafka`가 0건. registry가 `messaging-policy`·`messaging-kafka` 의존을 허용하고 두 패키지의 import가 0건.
|
||||
- **근거.** `evidence/raw/290` §D. import 전수.
|
||||
- **왜 문제인가.** `verifyCleanArchitectureDependencies`는 `allowed_dependencies`를 **상한**으로 검사하므로 미사용 의존을 잡지 못한다. 결과: 이 leaf의 build closure가 실제 필요보다 넓고, `messaging-kafka`(34파일)와 그 전이 의존이 딸려 온다. 그리고 의존 선언이 "이 leaf가 Kafka를 쓴다"는 인상을 준다.
|
||||
- **확인 방법.** `grep -rn 'import org.apache.kafka\|import dev.caskeleton.messaging.policy\|import dev.caskeleton.messaging.kafka\.' src/messaging/messaging-kafka-share-experimental/src`
|
||||
- **후보.** 구현 전까지 미사용 의존을 제거하거나, 미완 상태임을 build.gradle 주석에 적는다.
|
||||
- **다음 단계.** **REFERENCE 후보**(허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다).
|
||||
|
||||
### P3 — 형제 어댑터 넷이 구현하는 SPI를 이 leaf만 구현하지 않는다
|
||||
|
||||
- **사실.** `KafkaMessagingTransport`·`RabbitMessagingTransport`·`PulsarMessagingTransport`·`NatsJetStreamTransport`가 전부 `MessagingTransport`를 구현한다. 이 leaf는 `TransportConsumerRegistration`만 부분 구현한다.
|
||||
- **근거.** `evidence/raw/280` §D(transport-spi probe)와 이 leaf의 소스.
|
||||
- **왜 문제인가.** `KafkaShareWorkQueueCapability`가 존재하는 이유("shared validators refuse … before a message is ever produced")가 실현되려면 `MessagingTransport.capabilities(DestinationName)`를 통해 값이 전달돼야 한다. 그 인터페이스를 구현하지 않으므로 capability는 아무도 읽지 않는 상수다. 두 experimental 형제(pulsar, nats)는 구현하므로 "experimental이라서"가 이유가 되지 않는다.
|
||||
- **확인 방법.** `git grep -n 'implements MessagingTransport' -- 'src/messaging/**/*.java'`
|
||||
- **후보.** `MessagingTransport`를 구현하거나, capability를 어떻게 전달할지 정한다.
|
||||
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 §17 첫 항목("완성할 것인가")에 걸린다.
|
||||
|
||||
### P3 — 두 거절이 다른 예외 계층을 쓴다
|
||||
|
||||
- **사실.** `!enabled`는 `MessagingCapabilityUnavailableException`(안정 코드 `KAFKA_SHARE_DISABLED`), `orderingScope != NONE`은 `IllegalArgumentException`(코드 없음).
|
||||
- **근거.** `KafkaShareProfileValidator.java:31-40`.
|
||||
- **왜 문제인가.** 둘 다 설정 오류이고 둘 다 시작 시점에 잡힌다. 한쪽만 `FailureDescriptor`를 갖는다. `messaging-security`의 `MessageSecurityValidator`(전부 `IllegalArgumentException`)와 `BrokerTlsPolicy`(전부 `MessagingConfigurationException`)가 갈라진 것과 같은 형태다.
|
||||
- **확인 방법.** 두 throw 문 대조.
|
||||
- **후보.** 둘 다 `MessagingConfigurationException`으로 통일하고 안정 코드를 준다.
|
||||
- **다음 단계.** `messaging-security` §17의 같은 항목과 함께 **REFERENCE 후보**(구성 오류는 한 예외 타입과 안정 코드로 보고한다).
|
||||
|
||||
### P3 — 네 타입 중 하나만 테스트된다
|
||||
|
||||
- **사실.** `KafkaShareProfileValidatorTest`만 존재한다. registrar·capability에 테스트가 없다.
|
||||
- **근거.** `find src/test -name '*Test.java'` → 하나.
|
||||
- **왜 문제인가.** `register`가 spec을 버리는 것(§17 첫 항목)이 테스트가 있었다면 드러났을 형태다 — sink가 호출되는지 확인하는 테스트가 실패했을 것이다. capability 12개 boolean도 미검증이라 순서를 true로 바꿔도 아무것도 깨지지 않는다.
|
||||
- **확인 방법.** 테스트 클래스 목록.
|
||||
- **후보.** registrar와 capability에 테스트를 추가한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다) — `messaging-claim-check` §17과 같은 기준.
|
||||
|
||||
### P3 — 활성화 프로퍼티 키가 에러 메시지에만 존재한다
|
||||
|
||||
- **사실.** `backend.messaging.experimental.kafka-share=true`가 `KAFKA_SHARE_DISABLED` 메시지에 적혀 있다. 그 키를 읽는 코드가 저장소에 없다.
|
||||
- **근거.** `git grep -n 'kafka-share' -- src` → 이 leaf의 문자열 하나.
|
||||
- **왜 문제인가.** 운영자가 메시지를 보고 그 프로퍼티를 설정해도 효과가 없다. `enabled`는 `KafkaShareProfile` 생성자 인자이고 그 profile을 만드는 production 코드가 없다.
|
||||
- **확인 방법.** 키 문자열 검색.
|
||||
- **후보.** 배선될 때 프로퍼티 바인딩을 함께 만들거나, 메시지에서 키를 빼고 "이 profile의 `enabled`를 설정하라"로 바꾼다.
|
||||
- **다음 단계.** **REFERENCE 후보**(에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다) — `messaging-claim-check` §17의 "use claim check"와 같은 형태.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- 순서 목적지를 degrade하지 않고 거절하는 것과 그 이유
|
||||
- experimental을 기본 꺼짐으로 두는 것
|
||||
- pause/resume을 조용히 무시하지 않고 실패 stage로 거절하는 것
|
||||
- capability를 가정이 아니라 선언으로 두는 것
|
||||
- `close()`가 멱등인 것
|
||||
- 소비자 0·membership `[]`·조립 0의 삼중 정합
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MKS-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 4개, `runtime_memberships: []` | 선언 |
|
||||
| MKS-002 | build | `messaging-kafka-share-experimental/build.gradle` | same | `kafka-clients` 선언 | 사용되지 않음(§12.4) |
|
||||
| MKS-003 | code | `.../kafka/share/KafkaShareProfileValidator.java` | same | §4.2 두 거절과 experimental 정책 | 예외 계층 불일치(§17) |
|
||||
| MKS-004 | code | `.../kafka/share/KafkaShareGroupRegistrar.java` | same | §4.3 spec 무시, §4.4 pause 거절 | 테스트 없음 |
|
||||
| MKS-005 | code | `.../kafka/share/KafkaShareProfile.java` | same | 다섯 필드와 두 거절 | `maxDeliveryCount` 미사용 |
|
||||
| MKS-006 | code | `.../kafka/share/KafkaShareWorkQueueCapability.java` | same | 12 boolean과 선언 목적 | 전달 경로 없음 |
|
||||
| MKS-007 | test | `KafkaShareProfileValidatorTest` (6) | same | 두 거절과 통과 | 네 타입 중 하나만 |
|
||||
| MKS-008 | cross-leaf code | 4개 `*MessagingTransport.java` | same | 형제 넷이 SPI 구현 | 각 leaf SSOT가 소유 |
|
||||
| MKS-009 | cross-leaf code | `messaging-policy/.../DestinationProfileValidator.java:78-87` | same | 연결되지 않은 두 순서 정책 | 해당 leaf SSOT가 소유 |
|
||||
| EVD-290 | command | `evidence/raw/290-claimcheck-and-kafkashare-unconsumed.txt` §C·§D·§E | same | §12.1·§12.4 | 정적 검색 |
|
||||
| EVD-293 | command | `./gradlew :messaging:messaging-kafka-share-experimental:test --rerun-tasks` | same | 6 / 0 / 0 | validator만 |
|
||||
@@ -0,0 +1,443 @@
|
||||
# messaging-kafka 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 34파일 3,427줄 + `src/test` 24파일 4,087줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-kafka`
|
||||
> SSOT owner: `messaging-kafka`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `runtime_memberships`: **`["app-bootstrap"]`** — 출하
|
||||
- 등급: Stable · 이 가족에서 실제로 선택 가능한 유일한 브로커(§12.1)
|
||||
|
||||
| 파일 | LOC | 조립되나 |
|
||||
|---|---:|---|
|
||||
| `KafkaConsumerRegistrar` | 621 | **아니오** — 테스트만 |
|
||||
| `KafkaMessagingTransport` | 215 | 예(발행 전용 생성자) |
|
||||
| `KafkaTransactionalPublisher` | 178 | 아니오 |
|
||||
| `KafkaSecurityConfigurer` | 173 | 빈으로만 — 호출처 없음 |
|
||||
| `KafkaHeaderMapper` | 169 | 예(전송 경유) |
|
||||
| `KafkaDeliveryMapper` | 167 | 아니오 |
|
||||
| `KafkaBatchConsumerRegistrar` | 146 | **아니오** — 저장소 전체에 참조 0 |
|
||||
| `PartitionWorkCoordinator` | 128 | 아니오 |
|
||||
| `KafkaRetryExecutor` | 126 | 아니오 |
|
||||
| `ContiguousPartitionOffsetTracker` | 119 | 아니오 |
|
||||
| `KafkaPublishFailureClassifier` | 116 | 예(전송 경유) |
|
||||
| `KafkaPublishMapper` | 111 | 예(전송 경유) |
|
||||
| `KafkaRetryMetadataMapper` | 93 | 아니오 |
|
||||
| `KafkaPartitionRetryScheduler` | 90 | 아니오 |
|
||||
| `KafkaReplayCapability` | 82 | 아니오 |
|
||||
| `KafkaRetryTopicPublisher` | 75 | 아니오 |
|
||||
| `SpringKafkaTransactionalProcessor` | 68 | 아니오 |
|
||||
| `KafkaOffsetResetExecutor` | 63 | 아니오 |
|
||||
| `KafkaProfileValidator` | 60 | 예(시작 검증) |
|
||||
| `KafkaReplayPlanner` | 58 | 아니오 |
|
||||
| `KafkaBrokerProfile` | 56 | 예(설정 컴파일) |
|
||||
| `KafkaSettlementQueue` · `KafkaTransactionProfileValidator` | 52 · 52 | 아니오 / 빈만(§17.2) |
|
||||
| `KafkaSettlementCommand` | 50 | 아니오 |
|
||||
| `KafkaDeadLetterPublisher` | 49 | 아니오 |
|
||||
| `PartitionOffsetTracker` | 48 | 아니오 |
|
||||
| `KafkaTopologyInspector` | 45 | 아니오 |
|
||||
| `KafkaRetryOutcome` | 40 | 아니오 |
|
||||
| `KafkaPosition` | 38 | 예(발행 결과) |
|
||||
| `KafkaReplayPlan` | 36 | 아니오 |
|
||||
| `KafkaQuarantinePublisher` | 31 | 기본 구현만 |
|
||||
| `KafkaTransactionalProcessor` | 29 | 아니오 |
|
||||
| `KafkaTransactionalDelivery` · `KafkaTransactionalOutput` | 22 · 21 | 아니오 |
|
||||
|
||||
main 총 **34파일 / 3,427줄**.
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 34 | `FULL_READ` | 3,427줄. 위 표가 전부 |
|
||||
| `test/java/**` | 24 | `FULL_READ` | 4,087줄. 인증 레인·Toxiproxy 레인 포함 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 전문 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
> 이 표는 2026-09-01 재통독에서 다시 세었다. 이전 판은 `main/java/**` 를 **29** 로 적었다. 실제는 34 이고, 빠져 있던 다섯 안에 §17.4 의 `KafkaRetryMetadataMapper` 가 있었다. "조립되나" 열도 이번에 추가했다 — 이 리프의 판정 등급이 전부 그 열에 달려 있다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 소비자 런타임 — 스레드 규율이 설계다
|
||||
|
||||
> "Every call into `Consumer` — poll, pause, resume, seek, commit — happens on the poll thread and
|
||||
> nowhere else. `KafkaConsumer` is documented as not thread-safe, and a worker that committed
|
||||
> directly would corrupt the client's internal state under concurrency in ways that surface much
|
||||
> later as skipped offsets. Workers therefore enqueue a `KafkaSettlementCommand` and the poll thread
|
||||
> applies it at the top of the next cycle."
|
||||
|
||||
공개 API 인 `pause`/`resume` 도 제어 큐를 통해 폴 스레드로 넘어가고, 반환된 단계는 **다음 폴 주기** 에 완료된다.
|
||||
|
||||
> "so a caller that awaits it knows the consumer is paused rather than merely asked to pause… there
|
||||
> is no safe way to touch the consumer from another thread, so 'paused' cannot be true until the loop
|
||||
> says so."
|
||||
|
||||
`close()` 만 예외이고 그 예외에 근거가 붙어 있다 — 이후 폴 루프가 멈추므로 큐에 넣으면 영원히 배수되지 않는다.
|
||||
|
||||
이 규율은 실제로 지켜진다. 작업자 람다가 만지는 것은 `settlements`·`coordinator`·`shutdown`·`retries` 뿐이고 `consumer` 는 한 번도 없다. 통독으로 확인했다.
|
||||
|
||||
## 2. 커밋은 연속 워터마크로만 전진한다
|
||||
|
||||
> "A Kafka offset commit is a watermark, not a set: committing offset 13 declares that everything
|
||||
> below it is done. With concurrent handlers, offsets finish out of order — 10 and 12 may complete
|
||||
> while 11 is still running — and committing 13 at that moment would silently discard 11."
|
||||
|
||||
그 대가도 적혀 있다 — 느린 메시지 하나가 그 파티션의 워터마크를 붙든다. 그것이 옳은 교환이라는 근거는 대안이 메시지를 잃는다는 것이고, 지연은 소비자 랙으로 보인다는 것이다.
|
||||
|
||||
그리고 등록만 되고 제출되지 않은 오프셋을 되돌리는 경로가 있다.
|
||||
|
||||
> "A delivered offset with no worker behind it holds the contiguous watermark back forever: nothing
|
||||
> will ever complete it, so the partition stops committing while continuing to consume."
|
||||
|
||||
## 3. 이미 고쳐진 결함 네 개가 코드에 주석으로 남아 있다
|
||||
|
||||
이 리프의 서술 방식이다 — 고친 자리마다 이전 상태를 적어 둔다.
|
||||
|
||||
**커밋 순서.** 지역 맵과 트래커를 `commitSync` **뒤에** 갱신한다. 이전 순서는 실패한 커밋 뒤에 브로커가 받은 적 없는 오프셋을 커밋된 것으로 믿게 했고, 잘린 트래커가 그것을 다시 만들 수 없어 다음 커밋이 간극을 건너뛰었다.
|
||||
|
||||
**재조정 에폭.** 파티션 회수 시 에폭을 **먼저** 지운다.
|
||||
|
||||
> "Any settlement still in flight for these partitions now carries a number no live assignment has,
|
||||
> so applySettlements refuses it instead of moving a watermark this consumer no longer owns."
|
||||
|
||||
**전역 break 제거.** 한 파티션이 천장에 닿았을 때 배치 전체를 버리던 형태를 파티션별 처리로 바꿨다.
|
||||
|
||||
> "which abandoned every record the same poll had returned for *other* partitions… a processing gap
|
||||
> that nothing reported."
|
||||
|
||||
**정착의 단일 종결.** `acknowledge`/`requeue`/`discard` 가 하나의 CAS 를 두고 경쟁한다 — 핸들러가 둘 다 말하면 폴 스레드가 두 번째를 믿던 형태를 막는다.
|
||||
|
||||
거부된 정착 수는 조용히 세지 않고 `staleSettlements()` 로 노출한다. 0 이 아니면 핸들러가 자기 할당보다 오래 살고 있다는 뜻이고, 운영자가 행동할 수 있는 신호다.
|
||||
|
||||
## 4. 배압은 버퍼가 아니라 일시정지로 준다
|
||||
|
||||
> "A partition at its in-flight ceiling stops being fetched, so unprocessed records stay in the
|
||||
> broker instead of in the heap."
|
||||
|
||||
파티션 단위로만 멈추고, 재개 지점은 그 파티션의 가장 이른 미제출 오프셋이다. 재개 자체가 없는 것이 §17.3 이다.
|
||||
|
||||
## 5. 발행 실패 분류
|
||||
|
||||
> "The split is between failures that prove the record was not stored and failures that prove
|
||||
> nothing… The default is deliberately ambiguous rather than rejected. Guessing 'rejected' on an
|
||||
> unknown error is what turns one lost confirmation into two orders."
|
||||
|
||||
일곱 예외 타입만 단정적 거부이고, 그중 셋(`AuthenticationException`·`AuthorizationException`·`SerializationException`)은 각각 전용 범주로 간다.
|
||||
|
||||
## 6. 트랜잭션 조건
|
||||
|
||||
`KafkaTransactionProfileValidator` 가 넷을 요구한다 — 트랜잭션 식별자 접두, 멱등 생산자, `acks=all`, 수동 오프셋 커밋. 그리고 다섯째가 핵심이다.
|
||||
|
||||
> "A destination that declares `INBOX_TRANSACTIONAL` is telling the platform its side effect lives in
|
||||
> a database, and a Kafka transaction cannot span that. Allowing both to be configured together would
|
||||
> let a team read 'transactional' twice and conclude the whole path is atomic when the two halves can
|
||||
> still diverge."
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
24파일 4,087줄. 세 층이다.
|
||||
|
||||
| 층 | 파일 | 무엇을 붙드나 |
|
||||
|---|---|---|
|
||||
| 결정적 | `KafkaConsumerRegistrarTest`(440), `KafkaTransactionOrderingTest`(192), `KafkaProfileValidatorTest`(167), `KafkaEnvelopeRoundTripTest`(154), `ReservedHeaderForgeryTest`(123), `KafkaHeaderMapperTest`(118), `ContiguousPartitionOffsetTrackerTest`(94), `KafkaReplayPlannerTest`(88), `PartitionWorkCoordinatorTest`(77), `KafkaProducerContractTest`(24) | `MockConsumer`·`MockProducer` 로 폴 주기·트랜잭션 호출 순서·헤더 왕복·워터마크 산술 |
|
||||
| 실브로커 IT | `KafkaBrokerIT`(292), `KafkaConsumerSettlementIT`(218), `KafkaAmbiguityChaosIT`(173), `KafkaTransactionIT`(168), `KafkaReadCommittedIT`(155), `KafkaTransactionFencingIT`(150), `KafkaTopologyValidationIT`(144), `KafkaContainerSmokeTest`(66) | Testcontainers `apache/kafka:4.1.0` |
|
||||
| 인증 레인 | `KafkaBrokerCertificationIT`(466) | Toxiproxy 로 소켓 단위 결함 주입 |
|
||||
|
||||
인증 레인의 판단이 이 가족에서 가장 강하다.
|
||||
|
||||
> "No `@EnabledIf` on Docker, deliberately… a certification lane that skips reports success for a
|
||||
> broker nobody started, which is the exact failure the evidence exists to rule out."
|
||||
|
||||
그리고 커버하지 못하는 시나리오를 숨기지 않는다 — `connection-refused` 는 Kafka 생산자가 연결 성립 전에 레코드를 버퍼링하므로 전송에 대해 아무것도 증명하지 못하는 배달 마감으로만 나타난다. 그래서 그것을 `knownGaps` 로 남긴다.
|
||||
|
||||
`KafkaReadCommittedIT.abortATransactionCarrying` 의 주석도 같은 종류다 — `flush()` 가 없으면 abort 가 클라이언트 측에서 레코드를 버리므로 빈 토픽에 대해 시험이 무의미하게 통과한다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성 — 이 리프의 절반이 조립되지 않는다.** 스타터는 이렇게 만든다.
|
||||
|
||||
```java
|
||||
return new dev.caskeleton.messaging.kafka.KafkaMessagingTransport("kafka", 1L, producer);
|
||||
```
|
||||
|
||||
인자 셋짜리 생성자다. 그 생성자는 소비자 팩토리를 이렇게 채운다.
|
||||
|
||||
```java
|
||||
spec -> { throw new MessagingCapabilityUnavailableException(
|
||||
"KAFKA_CONSUMER_NOT_CONFIGURED", "this Kafka transport was created without a consumer factory"); }
|
||||
```
|
||||
|
||||
그리고 저장소 전체에서 `new KafkaConsumerRegistrar` 는 **테스트 5곳에만** 있다. 소비 경로 전체 — 폴 루프(621), 정착 큐, 오프셋 트래커, 재시도 스케줄러, 파티션 조정자, 배달 매퍼, 재시도 실행기 — 가 배포에 조립되지 않는다.
|
||||
|
||||
조립되는 것은 발행 경로다. 전송·발행 매퍼·헤더 매퍼·실패 분류기·`KafkaPosition`, 그리고 시작 검증기 하나.
|
||||
|
||||
이 사실이 §17.3·§17.4·§17.5 의 등급을 한 칸 낮춘다. 오늘의 사고가 아니라 소비를 배선하는 날의 사고다.
|
||||
|
||||
**12.2 참조가 0인 production 파일.** `KafkaBatchConsumerRegistrar` 146줄은 저장소 전체에서 자기 파일 밖의 참조가 없다 — production 도 테스트도 아니다. 배치 소비의 규칙(파티션을 넘지 않는 배치, `DESTINATION` 순서와의 비양립)을 정확하게 서술하고 아무도 부르지 않는다.
|
||||
|
||||
**12.3 대조군 — 능력 선언 방식.** 세 어댑터가 모두 능력을 상수로 둔다. Pulsar 는 전송과 검증기가 서로 다른 값을 답하고, NATS 는 프로파일과 무관하게 중복 제거를 참으로 둔다. Kafka 는 §17.2 의 형태다.
|
||||
|
||||
**12.4 테스트가 볼 수 없는 것.** 소비 경로의 세 결함이 전부 같은 이유로 시험에서 벗어난다.
|
||||
|
||||
| 결함 | 가리는 형태 |
|
||||
|---|---|
|
||||
| §17.3 천장 일시정지 후 재개 없음 | 결정적 시험은 천장에 닿은 **그 주기**까지만 단언한다(`anOrderedDestinationDispatchesOneRecordAtATime`). 실브로커 IT 는 전부 핸들러 풀이 `Runnable::run`(인라인)이라 천장에 닿지 않고, 전부 레코드 1건만 발행한다 |
|
||||
| §17.4 재시도 헤더 오염 | `ReservedHeaderForgeryTest.aMalformedRetryAttemptIsQuarantined` 가 `attemptOf` 의 **던짐만** 단언한다. 소비자가 그 던짐을 어떻게 다루는지는 어떤 시험도 보지 않는다 |
|
||||
| §17.5 벽시계 | `pollOnce(Instant)` 는 시계를 주입받는데 재시도 등록만 `Instant.now()` 를 읽는다. 지연 재개를 결정적으로 시험할 수 없다 |
|
||||
|
||||
**12.5 고쳐진 메서드와 증명된 메서드가 다르다.** §17.6.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 실제 브로커로 재조정 중 정착 거부를 재현하지 않았다. 인증 레인이 그 자리이고 컨테이너가 필요하다.
|
||||
- §17.3 을 실행으로 재현하지 않았다. `consumer.resume(...)` 호출처가 둘(`applyDueResumes`·공개 `resume(scope)`)뿐이고 천장 경로가 `retries` 에 아무것도 등록하지 않는다는 것으로 판정했다.
|
||||
- §17.4 를 실행으로 재현하지 않았다. `attemptOf` 가 `dispatch` 의 두 번째 `try` 안에 있고 그 `catch` 가 `requeueAfterFailure()` 라는 것, `MessagingConfigurationException` 이 `RuntimeException` 을 상속한다는 것으로 판정했다.
|
||||
- Toxiproxy 인증 레인을 직접 돌리지 않았다. 코드와 그 레인이 기록하는 증거 형식만 읽었다.
|
||||
- `gradle.lockfile` 은 읽지 않았다(`STRUCTURAL_ONLY`).
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P1 — 지원 문서가 `deduplicatedPublish` 를 지원으로 적고, 코드는 거짓이며, 그 차이가 정확히 코드가 경고한 피해다
|
||||
|
||||
```java
|
||||
private static final MessagingCapabilities CAPABILITIES =
|
||||
new MessagingCapabilities(true, true, true, true, true, true, true, false, true, false, false, true);
|
||||
// ^^^^^ deduplicatedPublish
|
||||
```
|
||||
|
||||
코드의 판정이 옳고 그 근거가 javadoc 에 있다.
|
||||
|
||||
> "Producer idempotence deduplicates *sequence retries within one producer session*: the producer id
|
||||
> is reassigned on restart, so the same logical message published again after a crash is a new
|
||||
> sequence and the broker stores it twice."
|
||||
|
||||
`docs/messaging/support-matrix.md:55` 의 능력 표는 이 칸을 `O` 로 적는다.
|
||||
|
||||
그 차이가 무거운 이유는 이 플랫폼에서 이 플래그가 특별하기 때문이다. 능력 열둘 중 **부재가 예외를 만드는 유일한 플래그**다.
|
||||
|
||||
```java
|
||||
// DefaultMessagePublisher:249-252
|
||||
if (options.deduplication().isPresent()
|
||||
&& !transport.capabilities(profile.name()).capabilities().deduplicatedPublish()) {
|
||||
throw new …("PUBLISH_DEDUPLICATION_UNSUPPORTED", …);
|
||||
```
|
||||
|
||||
그래서 표를 읽고 중복 제거를 전제한 목적지를 설계한 팀은 실행 시점에 능력 예외를 만난다. 반대로 표를 읽고 "중복 제거가 있으니 모호를 그냥 재시도해도 된다" 고 결론지으면, 실제로는 중복이 저장된다.
|
||||
|
||||
`MessagingCapabilities` 의 클래스 javadoc 이 그 피해를 미리 적는다 — "a silently weakened guarantee is indistinguishable from a working one until the incident."
|
||||
|
||||
수정은 문서 쪽이다. 코드가 이미 옳다.
|
||||
|
||||
### 17.2 P2 — 브로커 트랜잭션을 무조건 참으로 선언하고, 그 조건을 검사하는 검증기는 시작 시 돌지 않는다
|
||||
|
||||
능력 상수의 아홉 번째가 `brokerTransaction = true` 다. 프로파일과 무관한 상수다.
|
||||
|
||||
그런데 Kafka 트랜잭션이 실제로 성립하려면 `KafkaTransactionProfileValidator` 가 요구하는 넷이 모두 참이어야 한다 — 트랜잭션 식별자 접두, 멱등 생산자, `acks=all`, 수동 커밋.
|
||||
|
||||
그 검증기는 스타터가 빈으로 만들지만 `StartupProfileValidation` 으로 감싸지 않는다.
|
||||
|
||||
```java
|
||||
// KafkaMessagingAutoConfiguration
|
||||
@Bean public KafkaProfileValidator kafkaProfileValidator() { … }
|
||||
@Bean public StartupProfileValidation<KafkaBrokerProfile> kafkaProfileStartupValidation(…) { … } // ← 감싼다
|
||||
@Bean public KafkaTransactionProfileValidator kafkaTransactionProfileValidator() { … } // ← 감싸지 않는다
|
||||
```
|
||||
|
||||
즉 두 겹이 함께 비어 있다. 능력은 조건과 무관하게 참을 답하고, 조건을 검사할 검증기는 발행되기만 하고 주입되지 않는다.
|
||||
|
||||
`StartupProfileValidation` 의 javadoc 이 서술한 이전 결함이 정확히 그 형태다 — "the context published a validator per broker and validated nothing."
|
||||
|
||||
수정은 두 갈래를 함께 한다.
|
||||
|
||||
- 스타터에서 `kafkaProfileStartupValidation` 형태를 복사해 트랜잭션 검증기를 감싼다(스타터 SSOT §17.2 와 같은 수정).
|
||||
- 능력을 프로파일에서 파생시킨다 — `enableIdempotence && "all".equals(acks) && 접두 존재`.
|
||||
|
||||
두 번째가 없으면 검증기가 돌더라도 능력 조회는 여전히 프로파일과 무관하게 답한다.
|
||||
|
||||
### 17.3 P2 — 천장에 닿아 일시정지된 파티션을 재개하는 경로가 없다
|
||||
|
||||
`pollOnce` 의 파티션 루프는 세 경우에 그 파티션을 멈춘다.
|
||||
|
||||
```java
|
||||
if (!coordinator.tryAcquire(partition)) { seekBackTo = record.offset(); continue; } // 천장
|
||||
if (!shutdown.tryBeginWork()) { … continue; } // 배수 시작
|
||||
if (!dispatch(record, partition, now, epoch)) { … continue; } // 풀 거부
|
||||
…
|
||||
if (seekBackTo >= 0) {
|
||||
consumer.pause(Set.of(partition));
|
||||
consumer.seek(partition, seekBackTo);
|
||||
}
|
||||
```
|
||||
|
||||
이 세 경로 중 어느 것도 `retries.pauseUntil(...)` 을 부르지 않는다. 그런데 폴 루프가 파티션을 재개하는 곳은 하나뿐이다.
|
||||
|
||||
```java
|
||||
private void applyDueResumes(Instant now) {
|
||||
Map<TopicPartition, Long> due = retries.dueForResume(now); // ← retries 에 등록된 것만
|
||||
due.forEach((partition, seekTo) -> { consumer.seek(...); coordinator.resume(...); consumer.resume(...); });
|
||||
}
|
||||
```
|
||||
|
||||
`retries` 에 항목을 넣는 곳은 `QueuedSettlement.enqueueRequeue` 하나이고, 그것은 핸들러 실패·타임아웃·명시적 requeue 경로다. 천장·배수·풀 거부 경로는 등록하지 않는다.
|
||||
|
||||
따라서 천장 때문에 멈춘 파티션은 **폴 루프가 스스로 재개하지 않는다.** 재개할 수 있는 것은 외부에서 부른 `resume(scope)` 이나 재조정뿐이다.
|
||||
|
||||
**도달 조건이 좁지 않다.** `maxInFlightPerOrderingUnit` 의 기본값은 1 이다(`DestinationSettings.Consumer`). 한 폴이 같은 파티션의 레코드를 둘 이상 돌려주는 순간 두 번째에서 `tryAcquire` 가 거짓이 되고, 그 파티션이 멈춘다. 그 뒤 작업자가 끝나 `coordinator.release` 로 슬롯이 비어도 `consumer` 는 여전히 일시정지 상태다.
|
||||
|
||||
**대조.** 같은 파일이 `coordinator.pause(...)` 와 `consumer.pause(...)` 를 구분해서 쓴다 — `applySettlements` 의 `PAUSE_AND_SEEK` 는 둘 다 부르고, 천장 경로는 `consumer` 쪽만 부른다. 그래서 조정자는 그 파티션을 멈춘 것으로 알지 못하고, 결과적으로 `tryAcquire` 는 계속 참을 답하는데 브로커에서 레코드가 오지 않는다.
|
||||
|
||||
**수정.** 천장 경로가 `retries.pauseUntil(partition, seekBackTo, Duration.ZERO, now)` 를 등록하면 다음 주기의 `applyDueResumes` 가 즉시 재개한다. 지연이 0 이므로 `dueForResume` 이 곧바로 돌려준다. 배수 경로는 재개하지 않는 것이 맞고, 풀 거부 경로는 천장과 같다.
|
||||
|
||||
**등급.** 소비 경로가 조립되지 않으므로(§12.1) P2. 배선하는 순간 P1 이다 — 파티션이 조용히 멈추고, 커밋 워터마크도 함께 멈추므로 소비자 랙만 늘어난다.
|
||||
|
||||
### 17.4 P2 — 오염된 재시도 헤더가 격리되지 않고 무한 pause-and-seek 을 만든다
|
||||
|
||||
`KafkaRetryMetadataMapper.attemptOf` 는 읽을 수 없는 `msg.retry.attempt` 에 대해 fail-closed 를 택하고, 그 이유를 정확하게 적는다.
|
||||
|
||||
```java
|
||||
} catch (NumberFormatException malformed) {
|
||||
// Returning 1 for an unreadable header restarts the retry budget on every redelivery… and the
|
||||
// header is caller-influenced, which makes "unreadable" a way to defeat the cap rather than an
|
||||
// accident.
|
||||
throw new MessagingConfigurationException("RETRY_ATTEMPT_MALFORMED",
|
||||
"the retry attempt header is not a positive integer; the message is quarantined rather"
|
||||
+ " than restarting its retry budget");
|
||||
}
|
||||
```
|
||||
|
||||
메시지가 "quarantined" 라고 말한다. 소비자는 그렇게 하지 않는다.
|
||||
|
||||
```java
|
||||
try {
|
||||
envelope = deliveryMapper.toEnvelope(record);
|
||||
} catch (RuntimeException undecodable) {
|
||||
if (quarantine.quarantine(record, undecodable)) { settlement.acknowledgeAfterQuarantine(); }
|
||||
else { settlement.requeueAfterFailure(); }
|
||||
… return; // ← 격리 경로는 여기까지다
|
||||
}
|
||||
try {
|
||||
int attempt = retryMetadataMapper.attemptOf(envelope); // ← 던지는 자리는 여기다
|
||||
…
|
||||
} catch (ExecutionException | RuntimeException failure) {
|
||||
settlement.requeueAfterFailure(); // ← pause-and-seek
|
||||
}
|
||||
```
|
||||
|
||||
격리 경로는 **디코딩 실패에만** 걸려 있다. `attemptOf` 는 디코딩이 끝난 뒤 두 번째 블록에서 던지고, `MessagingConfigurationException` 은 `MessagingException` 을 통해 `RuntimeException` 이므로 두 번째 `catch` 가 잡는다. 결과는 `requeueAfterFailure()` → `PAUSE_AND_SEEK` → 같은 오프셋 재읽기 → 같은 헤더 → 같은 예외다.
|
||||
|
||||
즉 fail-closed 가 막으려던 것(재시도 예산 무력화)보다 나쁜 것을 만든다 — 그 파티션이 영구히 그 레코드에서 멈춘다. 그리고 javadoc 이 지적한 대로 이 헤더는 호출자가 쓸 수 있는 값이므로, 숫자가 아닌 값 하나로 파티션 하나를 정지시킬 수 있다.
|
||||
|
||||
**테스트가 보지 못하는 이유.** `ReservedHeaderForgeryTest.aMalformedRetryAttemptIsQuarantined` 는 `attemptOf` 가 던지는 것만 단언한다. 이름은 "quarantined" 인데 격리를 확인하지 않는다.
|
||||
|
||||
**수정.** `attemptOf` 호출을 디코딩과 같은 블록으로 옮겨 격리 경로에 태우거나, 두 번째 `catch` 가 예외 종류를 나누게 한다 — `MessagingConfigurationException` 은 재시도로 회복되지 않는 종류이므로 격리 대상이고, 핸들러 실패는 재시도 대상이다.
|
||||
|
||||
### 17.5 P3 — 시계를 주입받는 클래스가 한 곳에서만 벽시계를 읽는다
|
||||
|
||||
`KafkaConsumerRegistrar` 의 설계 성질이 javadoc 에 적혀 있다.
|
||||
|
||||
> "`pollOnce(Instant)` is one full cycle and is public so the whole loop — commit ordering, pause,
|
||||
> seek, rebalance — is testable against `MockConsumer` without threads or sleeps."
|
||||
|
||||
주기마다 `Instant now` 를 받아 `applyDueResumes(now)` 로 넘긴다. 그런데 그 짝인 등록 쪽은 이렇다.
|
||||
|
||||
```java
|
||||
private SettlementResult enqueueRequeue(Duration delay) {
|
||||
retries.pauseUntil(partition, offset, delay, Instant.now()); // ← 주입된 시계가 아니다
|
||||
```
|
||||
|
||||
이 리프에서 `Instant.now()` 를 읽는 유일한 자리다. 그리고 그 호출은 작업자 스레드에서 일어나므로 폴 스레드의 `now` 와 다른 순간이다.
|
||||
|
||||
결과는 두 가지다. 지연 재시도(`requeue(Duration)`)의 재개 시점을 고정 시계로 시험할 수 없고, 시험이 `Duration.ZERO` 밖의 지연을 다루지 못한다 — 실제로 어떤 시험도 다루지 않는다.
|
||||
|
||||
수정은 생성자에 `Supplier<Instant>` 를 하나 더 받는 것이다. 같은 저장소의 `MessagingShutdownLifecycle` 이 정확히 그 형태로 두 생성자를 둔다.
|
||||
|
||||
### 17.6 P3 — 결함으로 판정된 메서드가 남아 있고, 실브로커 증명이 그것 위에서 돈다
|
||||
|
||||
`KafkaTransactionalPublisher` 에 같은 일을 하는 메서드가 둘 있다.
|
||||
|
||||
```java
|
||||
public <T> T inTransaction(delivery, profiles, inputOffsets, Supplier<T> body) // begin → body → send → commit
|
||||
public void sendInTransaction(delivery, profiles, inputOffsets) // begin → send → commit (body 없음)
|
||||
```
|
||||
|
||||
`inTransaction` 의 javadoc 이 둘째를 결함으로 지목한다.
|
||||
|
||||
> "The processor used to run the handler and only afterwards hand the delivery here — so
|
||||
> `beginTransaction` happened *after* the handler had already finished… a handler that succeeded and
|
||||
> a commit that then failed left the handler's work applied with its input offsets unsent."
|
||||
|
||||
`sendInTransaction` 은 public 이고 production 호출자가 없다. 호출하는 것은 시험 다섯 자리뿐이다 — 그리고 그 다섯이 실브로커 트랜잭션 증명 전부다(`KafkaTransactionIT`·`KafkaTransactionFencingIT`·`KafkaReadCommittedIT`).
|
||||
|
||||
고쳐진 `inTransaction` 을 시험하는 것은 `KafkaTransactionOrderingTest` 하나이고 `MockProducer` 다. 즉 실브로커에서 커밋·중단·펜싱이 증명된 것은 옛 모양이고, 새 모양은 목 위에서만 증명됐다.
|
||||
|
||||
기능적 차이는 크지 않다(`body` 가 비어 있으면 두 메서드는 같은 호출열을 만든다). 그래도 두 가지가 남는다 — 결함으로 판정된 순서를 만드는 public 진입점이 여전히 열려 있다는 것, 그리고 실브로커 증거가 production 경로가 아닌 것 위에 있다는 것.
|
||||
|
||||
수정은 ITs 를 `inTransaction(..., () -> null)` 로 옮기고 `sendInTransaction` 을 지우는 것이다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **모든 소비자 호출을 폴 스레드로 모으고, 그 이유를 클라이언트의 문서화된 비스레드안전성에서 끌어온 것.** 통독으로 실제 준수를 확인했다.
|
||||
- **공개 제어 API 도 큐를 지나게 하고, 반환 단계가 다음 주기에 완료된다는 것을 정직하게 서술한 것.**
|
||||
- **`close()` 만 큐를 지나지 않게 하고 그 예외에 근거를 붙인 것.**
|
||||
- **연속 워터마크로만 커밋하고, 그 대가를 소비자 랙으로 받아들인 것.**
|
||||
- **제출되지 않은 배달 등록을 되돌리는 경로.**
|
||||
- **커밋 뒤에 지역 상태를 갱신하도록 순서를 고치고, 이전 순서가 만든 결함을 주석에 남긴 것.**
|
||||
- **재조정에서 에폭을 먼저 지워 늦은 정착을 거부하는 것, 그리고 할당에서도 에폭을 파생시켜 수동 할당을 덮은 것.**
|
||||
- **거부된 정착 수를 지표로 노출한 것.**
|
||||
- **정착을 단일 종결(CAS)로 만든 것.**
|
||||
- **파티션별 처리로 바꿔 전역 break 이 만들던 처리 간극을 없앤 것.**
|
||||
- **알 수 없는 발행 실패의 기본값을 모호로 둔 것.**
|
||||
- **격리 기본 구현이 `false` 를 답해 커밋을 막는 것** — 쓸 곳이 없으면 오프셋을 넘기지 않는다.
|
||||
- **Kafka 트랜잭션이 데이터베이스 부수효과를 덮지 못한다는 것을 검증기가 거부로 표현한 것.**
|
||||
- **재생 기본값을 격리된 임시 그룹으로 두고, 운영 그룹 재생에 승인을 요구한 것.**
|
||||
- **오프셋 재설정의 승인 술어를 생성자 인자로 둔 것** — 승인 출처 없이 조립된 런타임은 물리적으로 재설정할 수 없다.
|
||||
- **JAAS 값 이스케이프 순서(역슬래시 먼저)와 제어문자 거부.**
|
||||
- **OAuth 를 절반만 설정하는 대신 거부한 것.**
|
||||
- **예약 헤더 위조 거부를 "envelope 필드를 재진술하는 이름" 으로만 좁힌 것** — 재시도·사후처리 재발행이 그 가드에 걸리지 않는다.
|
||||
- **인증 레인이 Docker 조건부 skip 을 쓰지 않는 것, 그리고 커버 못 하는 시나리오를 `knownGaps` 로 남긴 것.**
|
||||
- **`pollOnce` 를 공개해 전체 주기를 스레드 없이 검증 가능하게 만든 것.**
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/messaging/messaging-kafka/build.gradle
|
||||
main/java/…/kafka/KafkaConsumerRegistrar.java:1-621 (§17.3 pollOnce:216-257 · dispatch:264-333 · applyDueResumes:375-383)
|
||||
main/java/…/kafka/KafkaMessagingTransport.java:1-215 (능력 상수 62-64)
|
||||
main/java/…/kafka/KafkaTransactionalPublisher.java:1-178 (§17.6 inTransaction:96-119 · sendInTransaction:144-178)
|
||||
main/java/…/kafka/KafkaSecurityConfigurer.java:1-173
|
||||
main/java/…/kafka/KafkaHeaderMapper.java:1-169
|
||||
main/java/…/kafka/KafkaDeliveryMapper.java:1-167
|
||||
main/java/…/kafka/KafkaBatchConsumerRegistrar.java:1-146 (§12.2 참조 0)
|
||||
main/java/…/kafka/PartitionWorkCoordinator.java:1-128
|
||||
main/java/…/kafka/KafkaRetryExecutor.java:1-126
|
||||
main/java/…/kafka/ContiguousPartitionOffsetTracker.java:1-119
|
||||
main/java/…/kafka/KafkaPublishFailureClassifier.java:1-116
|
||||
main/java/…/kafka/KafkaPublishMapper.java:1-111
|
||||
main/java/…/kafka/KafkaRetryMetadataMapper.java:1-93 (§17.4 attemptOf:725-748)
|
||||
main/java/…/kafka/KafkaPartitionRetryScheduler.java:1-90
|
||||
main/java/…/kafka/KafkaReplayCapability.java:1-82
|
||||
main/java/…/kafka/KafkaRetryTopicPublisher.java:1-75
|
||||
main/java/…/kafka/SpringKafkaTransactionalProcessor.java:1-68
|
||||
main/java/…/kafka/KafkaOffsetResetExecutor.java:1-63
|
||||
main/java/…/kafka/KafkaProfileValidator.java:1-60
|
||||
main/java/…/kafka/KafkaReplayPlanner.java:1-58
|
||||
main/java/…/kafka/KafkaBrokerProfile.java:1-56
|
||||
main/java/…/kafka/{KafkaSettlementQueue:1-52, KafkaTransactionProfileValidator:1-52, KafkaSettlementCommand:1-50,
|
||||
KafkaDeadLetterPublisher:1-49, PartitionOffsetTracker:1-48, KafkaTopologyInspector:1-45,
|
||||
KafkaRetryOutcome:1-40, KafkaPosition:1-38, KafkaReplayPlan:1-36, KafkaQuarantinePublisher:1-31,
|
||||
KafkaTransactionalProcessor:1-29, KafkaTransactionalDelivery:1-22, KafkaTransactionalOutput:1-21}
|
||||
test/java/…/kafka/ 24파일 4,087줄 (KafkaBrokerCertificationIT:466 · KafkaConsumerRegistrarTest:440 · KafkaContractHarness:370 …)
|
||||
messaging-spring-boot-starter/…/KafkaMessagingAutoConfiguration.java:100-125 (§12.1 발행 전용 조립 · §17.2)
|
||||
messaging-runtime-core/…/DefaultMessagePublisher.java:249-252 (§17.1 능력 부재가 예외를 만드는 유일한 자리)
|
||||
docs/messaging/support-matrix.md:55 (§17.1 능력 표 대조)
|
||||
```
|
||||
@@ -0,0 +1,291 @@
|
||||
# messaging-nats-experimental 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 — `src/main` production 7파일 755줄 축자 통독 완료. test 2파일 460줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-nats-experimental`
|
||||
> SSOT owner: `messaging-nats-experimental`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- 선언 의존: messaging 계열 project 7 + vendor `jnats:2.26.2`
|
||||
- `runtime_memberships`: **`[]`** — build-only · 등급 EXPERIMENTAL
|
||||
|
||||
| 파일 | LOC |
|
||||
|---|---:|
|
||||
| `NatsJetStreamTransport` | 295 |
|
||||
| `NatsJetStreamProfile` | 103 |
|
||||
| `NatsMaxDeliverParkingWorkflow` | 85 |
|
||||
| `NatsJetStreamProfileValidator` · `NatsStreamPosition` | 75 · 75 |
|
||||
| `NatsPreSendRejection` | 65 |
|
||||
| `NatsAckMode` | 57 |
|
||||
| **main 합계** | **755** |
|
||||
| `NatsAdapterContractTest` · `NatsMaxDeliverParkingTest` | 337 · 123 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 7 | `FULL_READ` | 755줄 전 본문 |
|
||||
| `test/java/**` | 2 | `FULL_READ` | 460줄 전 본문 · 테스트 28개 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 전문 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 이 어댑터의 판단 셋
|
||||
|
||||
**JetStream 만 쓴다.**
|
||||
|
||||
> "A core publish returns as soon as the bytes are written to the socket, with no persistence and no
|
||||
> acknowledgement, so an adapter using it would report success for messages that were never stored —
|
||||
> the failure is total and silent."
|
||||
|
||||
거부 코드는 `NatsJetStreamProfileValidator.validate` 에 있다 — 최소 한 번 배달 목적지에 코어 NATS 는 안 된다. 다만 그 검증기를 호출하는 곳이 저장소에 하나도 없다(§17.3). 이 절이 서술하는 것은 판단이 코드로 적혀 있다는 사실이지, 그 판단이 실행 경로에 걸려 있다는 사실이 아니다.
|
||||
|
||||
**확인은 지속 증거다.** 발행 승인이 메시지가 안착한 스트림과 순번을 이름 짓는다. 소켓에 바이트를 쓴 영수증이 아니다.
|
||||
|
||||
**기본 실패는 모호다.** 사전 거절 타입만 확실히 전송되지 않음으로 다루고 나머지는 전부 모호다.
|
||||
|
||||
> "a caller that reads `REJECTED` may republish under a new identity and duplicate a message the
|
||||
> server already stored."
|
||||
|
||||
## 2. 죽은 편지가 없는 브로커에서 죽은 편지를 만든다
|
||||
|
||||
`NatsMaxDeliverParkingWorkflow` javadoc:
|
||||
|
||||
> "JetStream has no dead-letter queue. When a message hits `maxDeliver` the server terminates it: no
|
||||
> redelivery, no routing, no record beyond an advisory. Every other broker in this platform parks a
|
||||
> poison message somewhere an operator can find it, and this workflow is what makes NATS behave the
|
||||
> same way."
|
||||
|
||||
핵심은 시점이다.
|
||||
|
||||
> "The parking therefore happens on the delivery **before** the limit, not on the limit itself.
|
||||
> Acting at `maxDeliver` would mean acting on the delivery JetStream is about to discard, so any
|
||||
> failure in the dead-letter publish would lose the message outright."
|
||||
|
||||
그래서 프로파일이 `maxDeliver < 2` 를 거부한다 — 플랫폼이 주차할 여유 배달이 최소 하나 있어야 한다.
|
||||
|
||||
그리고 정착은 죽은 편지 발행이 확인된 뒤에만 허용된다.
|
||||
|
||||
> "Terminating first would discard the message on a broker that cannot redeliver it, which is the
|
||||
> one irreversible mistake available here."
|
||||
|
||||
세 번째 결과 `ALREADY_TERMINATED` 는 살아 있는 소비자 아래에서 프로파일이 바뀐 경우에만 도달한다. 회복할 것이 없고, 재배달로 오인되지 않도록 결과로 남긴다.
|
||||
|
||||
## 3. 능력 선언
|
||||
|
||||
```java
|
||||
CAPABILITIES = (true, true, true, true, true, false, true, false, false, true, false, true);
|
||||
```
|
||||
|
||||
`nativeDeadLetter=false` 의 근거가 클래스 javadoc 과 검증기 javadoc 양쪽에 있다 — 없는 큐를 찾아 나서게 만들지 않는다.
|
||||
|
||||
`keyedOrdering=false` 도 검증기가 강제한다 — 키 순서를 요구하는 목적지를 거부한다.
|
||||
|
||||
`deduplicatedPublish=true` 는 §17.1 이 다룬다.
|
||||
|
||||
## 4. 프로파일이 스스로 거부하는 것
|
||||
|
||||
`NatsJetStreamProfile` 은 record 이고, 압축 생성자가 이 어댑터의 불변식을 전부 들고 있다. 검증기가 호출되지 않는 지금, **실제로 실행되는 유일한 게이트가 여기다.**
|
||||
|
||||
```java
|
||||
if (!ackMode.supportsAtLeastOnce()) throw …; // NONE · ALL 거부
|
||||
if (ackWait.isNegative() || ackWait.isZero()) throw …;
|
||||
if (maxDeliver < 2) throw …; // "headroom"
|
||||
if (deduplicationWindow.isPresent() && …isZero()) throw …; // 설정했으면 양수
|
||||
```
|
||||
|
||||
`ackMode` 거부 사유는 `NatsAckMode` 자신이 문장으로 들고 있고(`rejectionReason()`), 프로파일이 그 문장을 예외 메시지에 그대로 싣는다. `NONE` 은 "forgotten", `ALL` 은 "still in flight" — 테스트가 그 두 낱말로 각각 걸어 잠근다.
|
||||
|
||||
`PARKING_HEADROOM = 1` 상수와 `parkAtDelivery() = maxDeliver - PARKING_HEADROOM` 가 §2 의 시점 선택을 숫자로 못 박는다. `NatsMaxDeliverParkingWorkflow.parkingThreshold()` 는 이 값을 그대로 위임한다 — 임계값의 정의가 한 곳에만 있다.
|
||||
|
||||
**주의할 비대칭.** 편의 팩토리 `durable(subject, stream, durableName)` 는 중복 제거 창을 `Optional.of(2분)` 으로 채운다. 즉 팩토리를 거친 프로파일은 §17.1 의 구멍에 빠지지 않는다. 그러나 팩토리에도 호출자가 없고(저장소 전역 grep 0건), 정규 생성자는 빈 창을 정상값으로 받는다. 기본 경로가 안전하다는 사실이 그 구멍을 닫아 주지 않는다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
두 테스트 460줄 · 28개.
|
||||
|
||||
`NatsAdapterContractTest` 17개 — 지속 증거(`REPLICATION_OR_PERSISTENCE_ACK`), 위치 반환, 시간 초과의 모호 판정, 사전 거절만이 `NOT_TRANSMITTED` 라는 것, 감싸인 미지 실패의 모호 판정, 호출자 마감의 유효성, 중복 제거 식별자 유무, 초과 페이로드 거절, 능력 두 개, 닫힌 전송, 재배달 인식, 순번 하한, 실패 범주.
|
||||
|
||||
전송은 `(subject, deduplicationId, request) -> CompletionStage<NatsStreamPosition>` 람다로 주입된다. 실제 JetStream 클라이언트는 이 리프에 없고, 테스트가 성공·실패·영영 안 끝남을 직접 만든다.
|
||||
|
||||
두 테스트가 회귀를 이름으로 기록한다 — `aFailureNamedLikeAKnownOneIsStillAmbiguous` 는 "예외 클래스 이름이 분류자였던" 과거를, `aPublishThatNeverCompletesIsBoundedByTheCallersTimeout` 은 "호출자 마감이 아예 무시되던" 과거를 주석에 남긴다. 셋째 회귀 기록은 어셈블이 비어 있다(§17.4).
|
||||
|
||||
`NatsMaxDeliverParkingTest` 11개 — 한계 직전 주차, 한계 자체도 주차, 한계 초과의 `ALREADY_TERMINATED`, 확인 뒤 정착, `maxDeliver=1` 거부, 배달 수 하한, 임계값, `ackMode` 세 값.
|
||||
|
||||
`NatsJetStreamProfileValidator` 를 세우는 테스트는 없다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** `dev.caskeleton.messaging.nats` 를 import 하는 코드가 리프 밖에 없다. 리프 밖에서 이 모듈이 등장하는 곳은 세 군데인데 전부 **이름 문자열**이다 — `config/architecture/modules.json` 의 등록, `messaging-testkit/CompatibilityMatrix` 의 `("messaging-nats-experimental", List.of("2.14"), Tier.EXPERIMENTAL, false, false)` 항목, 그리고 그 표를 문서와 대조하는 `MessagingDocumentationContractTest`. 즉 등급표가 이 어댑터를 알고 있을 뿐, 어떤 실행 경로도 이 클래스들에 닿지 않는다. build-only · experimental 표기 그대로다.
|
||||
|
||||
**12.2 대조군 — 자매 실험 어댑터.** `messaging-pulsar-experimental` 과 구조가 같다 — 주입되는 전송 연산, 타입 있는 사전 거절, 기본 모호, 실험 등급 게이트. 차이는 능력 선언의 출처다. Pulsar 는 전송과 검증기가 서로 다른 값을 답하고(그쪽 §17.1), NATS 는 두 곳이 같은 값을 답한다.
|
||||
|
||||
다만 그 일치는 공유가 아니라 **복사**다. `NatsJetStreamTransport.CAPABILITIES` 상수와 `NatsJetStreamProfileValidator.capabilities()` 가 열두 개 불리언 리터럴을 각자 손으로 적어 두었고, 둘을 묶는 것은 아무것도 없다. 오늘 같은 값인 것이 내일도 같으리라는 보장은 코드에 없다 — Pulsar 가 이미 그 갈라짐의 실물이다.
|
||||
|
||||
이쪽의 문제는 따로 있다. 그 값이 프로파일에서 파생되지 않는다는 것이다(§17.1).
|
||||
|
||||
**12.4 드리프트.** 실험 등급 표기와 코드가 일치한다.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 실제 JetStream 서버를 띄우지 않았다. 클라이언트 브리지를 싣지 않는 리프다.
|
||||
- 중복 제거 창이 없는 프로파일로 모호 재발행을 재현하지 않았다. 능력 상수와 `deduplicationId` 구현으로 판정했다.
|
||||
- 검증기를 부르는 조립 지점이 다른 형태(설정 클래스 · 스타터)로 어딘가에 있을 가능성은 클래스 이름 · 패키지 이름 두 가지 grep 으로만 배제했다. 리플렉션이나 문자열 기반 조립이라면 잡히지 않는다.
|
||||
- 테스트를 실행하지 않았다. §17.4 의 "항상 통과"는 어셈블 의미론으로 판정한 것이다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P2 — `deduplicatedPublish` 를 무조건 참으로 선언하는데 실제 중복 제거는 프로파일에 창이 있을 때만 일어난다
|
||||
|
||||
능력은 상수다.
|
||||
|
||||
```java
|
||||
private static final MessagingCapabilities CAPABILITIES =
|
||||
new MessagingCapabilities(true, true, true, true, true, false, true, false, false, true, false, true);
|
||||
// ^^^^ deduplicatedPublish
|
||||
```
|
||||
|
||||
검증기의 `capabilities()` 도 같은 값을 돌려준다.
|
||||
|
||||
그런데 중복 제거 식별자는 프로파일에 창이 있을 때만 만들어진다.
|
||||
|
||||
```java
|
||||
private Optional<String> deduplicationId(TransportPublishRequest request) {
|
||||
return profile.deduplicationWindow().map(window -> request.envelope().messageId().value().toString());
|
||||
}
|
||||
```
|
||||
|
||||
`NatsJetStreamProfile.deduplicationWindow` 는 `Optional<Duration>` 이고, 비어 있는 것이 정상 상태다 — 프로파일 생성자도 검증기도 창을 요구하지 않는다. 창이 없으면 `Nats-Msg-Id` 가 실리지 않고 서버는 중복을 제거하지 않는다.
|
||||
|
||||
즉 능력 선언이 프로파일과 무관하게 참이다.
|
||||
|
||||
**왜 이 플래그인가.** 이 저장소에서 능력 열두 개 중 부재가 예외를 만드는 유일한 것이 `deduplicatedPublish` 다(`DefaultMessagePublisher:250`). 나머지는 읽히지 않거나 분기에 쓰인다. 그러므로 이 플래그의 과대 선언은 다른 어느 플래그의 과대 선언보다 직접적이다 — 창 없는 목적지가 그 가드를 통과한다.
|
||||
|
||||
**그리고 어댑터 자신이 그 조건을 알고 있다.** 클래스 javadoc:
|
||||
|
||||
> "A publish that times out is `AMBIGUOUS`: JetStream may have stored it and lost only the
|
||||
> acknowledgement, and **the deduplication window is what makes retrying it safe when the profile
|
||||
> enables one.**"
|
||||
|
||||
"when the profile enables one" 이 정확히 능력이 담지 않은 조건이다. 창이 없는 목적지에서 모호를 재시도하면 스트림에 같은 메시지가 두 번 들어간다.
|
||||
|
||||
`MessagingCapabilities` 의 클래스 javadoc 이 이 상황을 미리 서술한다 — "a silently weakened guarantee is indistinguishable from a working one until the incident."
|
||||
|
||||
**테스트가 두 쪽을 동시에 못 박는다.** `NatsAdapterContractTest` 안에서, 같은 빈 창 프로파일(`confirming(Optional.empty())`)에 대해:
|
||||
|
||||
```java
|
||||
void theAdapterAdvertisesDeduplicatedPublish() {
|
||||
assertThat(confirming(Optional.empty()).capabilities(…).capabilities()
|
||||
.deduplicatedPublish()).isTrue(); // 능력은 참이라고 한다
|
||||
}
|
||||
|
||||
void noDeduplicationWindowSendsNoDeduplicationId() {
|
||||
confirming(Optional.empty()).publish(request(64))…;
|
||||
assertThat(capturedDeduplicationIds).singleElement()
|
||||
.satisfies(id -> assertThat(id).isEmpty()); // 선에는 아무것도 안 실린다
|
||||
}
|
||||
```
|
||||
|
||||
둘 다 통과한다. 모순이 우연히 남은 것이 아니라 **테스트로 고정되어** 있다는 뜻이고, 수정할 때 함께 고쳐야 할 지점이 어디인지도 이 두 개가 알려 준다.
|
||||
|
||||
**팩토리는 이 구멍을 메우지 않는다.** `NatsJetStreamProfile.durable(...)` 는 창을 2분으로 채워 주지만 호출자가 없고, 정규 생성자는 빈 창을 정상값으로 받는다(§4).
|
||||
|
||||
**수정.** 능력을 프로파일에서 파생시킨다.
|
||||
|
||||
```java
|
||||
new MessagingCapabilities(…, profile.deduplicationWindow().isPresent(), …)
|
||||
```
|
||||
|
||||
또는 검증기가 최소 한 번 배달 목적지에 중복 제거 창을 요구한다. 후자는 코어 NATS 거부와 같은 형태의 시작 시점 거부다.
|
||||
|
||||
### 17.2 P3 — 닫힌 전송의 거절이 영구 업무 실패로 분류된다
|
||||
|
||||
`rejectedLocally` 가 `FailureCategory.PERMANENT_BUSINESS` 를 고정으로 쓰고, 두 호출자 중 하나가 `NATS_TRANSPORT_CLOSED` 다.
|
||||
|
||||
자매 어댑터(Pulsar)와 같은 형태이고 같은 판단이다 — 종료 중이라는 것은 이 세대의 사정이지 업무의 영구 실패가 아니다. 같은 파일의 `classify` 는 범주를 신중히 나눈다.
|
||||
|
||||
두 리프가 같은 형태를 공유하므로 수정도 함께 하는 편이 낫다.
|
||||
|
||||
### 17.3 P2 — `NatsJetStreamProfileValidator` 를 호출하는 곳이 저장소에 없다. javadoc 링크 하나가 유일한 흔적이다
|
||||
|
||||
75줄짜리 검증기가 이 어댑터의 시작 시점 판단 넷을 들고 있다 — 실험 스위치가 꺼져 있으면 거부, 최소 한 번 배달에 코어 NATS 거부, 순서 있는 소비자와 경쟁 작업자 동시 사용 거부, 키 순서 목적지 거부.
|
||||
|
||||
저장소 전역에서 이 클래스 이름이 나오는 곳은 두 줄뿐이다.
|
||||
|
||||
```
|
||||
NatsJetStreamTransport.java:35: * is why {@link NatsJetStreamProfileValidator} refuses the combination at startup.
|
||||
NatsJetStreamProfileValidator.java:21: public final class NatsJetStreamProfileValidator {
|
||||
```
|
||||
|
||||
하나는 선언이고 하나는 **javadoc 링크**다. 코드 호출자 0, 테스트 0.
|
||||
|
||||
`validate` 는 `jetStreamEnabled` · `orderedConsumer` · `competingWorkers` · `enabled` 를 전부 인자로 받는다. 즉 스스로 아무것도 관찰하지 않고, 호출자가 이미 알고 있는 사실을 넘겨 줘야만 판단한다. 그런 호출자가 없으니 이 판단들은 한 번도 실행된 적이 없다.
|
||||
|
||||
**왜 P2 인가.** 전송의 클래스 javadoc 이 "그래서 검증기가 시작 시 그 조합을 거부한다"고 단언한다. 읽는 사람에게 이 어댑터는 코어 NATS 오설정으로부터 보호되는 것처럼 보이는데, 실제로는 아무 게이트도 걸려 있지 않다. 실험 등급이라 지금 당장 사고가 나지는 않지만, 이 어댑터를 실전에 붙이는 사람이 가장 먼저 신뢰할 문장이 지금 사실이 아니다.
|
||||
|
||||
같은 형태를 이 저장소에서 여러 번 봤다 — 채점기는 있는데 그 채점기에 값을 넣어 주는 생산자가 없는 구조(`GrpcRawApiImportRule` · `GrpcApplicationBoundaryRules` · `GrpcNettyParityContract` 등). 이쪽이 더 나쁜 쪽인 이유는 그 리프들에서는 최소한 테스트가 리터럴을 먹여 판단 자체는 실행해 보는데, 여기서는 그것조차 없다는 점이다.
|
||||
|
||||
**수정.** 어댑터 조립 지점에서 `validate` 를 부르거나, 그럴 지점이 아직 없다면 최소한 프로파일 생성 시점에 걸리도록 옮긴다(§4 의 압축 생성자가 이미 실행되는 유일한 게이트다). 어느 쪽도 못 하겠다면 전송 javadoc 의 "refuses ... at startup" 을 사실에 맞게 고친다.
|
||||
|
||||
### 17.4 P3 — 경과 시간 회귀를 막으려는 어셈블이 항상 참이다
|
||||
|
||||
```java
|
||||
@Test
|
||||
void theReportedElapsedTimeIsMeasuredRatherThanZero() {
|
||||
PublishResult result = await(failingWith(new TimeoutException("no ack")).publish(request(64)));
|
||||
|
||||
assertThat(result.elapsed())
|
||||
.as("every outcome reported Duration.ZERO, so latency evidence was fiction")
|
||||
.isGreaterThanOrEqualTo(Duration.ZERO);
|
||||
}
|
||||
```
|
||||
|
||||
`as(...)` 가 막으려는 회귀는 "모든 결과가 `Duration.ZERO` 를 보고하던 것"이다. 그런데 어셈블은 `>= Duration.ZERO` 다. `Duration.ZERO` 는 이 조건을 통과한다. 경과 시간은 시작 시점에서 잰 값이라 음수가 될 수 없으므로, 이 어셈블은 **구현이 무엇을 하든 통과한다.**
|
||||
|
||||
이름과 `as` 메시지가 정확히 짚은 회귀를, 어셈블만 못 잡는다. 그래서 이 테스트는 회귀 방지가 아니라 회귀 방지의 표시다.
|
||||
|
||||
**수정.** `isGreaterThan(Duration.ZERO)` 로 바꾼다. 시간 분해능이 불안하면 전송 람다에 관측 가능한 지연을 넣고 그 하한과 비교한다 — 같은 클래스의 `aPublishThatNeverCompletesIsBoundedByTheCallersTimeout` 가 이미 50밀리초 마감으로 그 방식을 쓴다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **코어 NATS 를 최소 한 번 배달에 쓰지 못하게 시작 시 거부한 것과 그 근거.**
|
||||
- **확인을 지속 증거로 기록한 것** — 스트림과 순번을 이름 짓는 승인이다.
|
||||
- **알 수 없는 실패의 기본값을 모호로 둔 것.**
|
||||
- **한계 직전 배달에서 주차하는 것과 그 시점 선택의 근거.**
|
||||
- **`maxDeliver < 2` 를 거부해 주차 여유를 강제한 것.**
|
||||
- **죽은 편지 발행이 확인된 뒤에만 원본을 정착시키는 것.**
|
||||
- **`ALREADY_TERMINATED` 를 별도 결과로 남겨 재배달과 구분한 것.**
|
||||
- **`nativeDeadLetter=false` 를 선언하고 그 이유를 두 곳에 적은 것.**
|
||||
- **순서 있는 소비자와 경쟁 작업자의 배타성을 검증기가 강제한 것.**
|
||||
- **중복 제거 식별자로 논리 메시지 식별자를 쓰는 것** — 시도마다 새 식별자를 만들면 창이 필요한 상황에서 쓸모가 없어진다.
|
||||
- **예외 클래스 이름으로 실패를 분류하던 것을 걷어내고 타입으로 옮긴 것** — 테스트가 그 회귀를 주석으로 남겨 두었다.
|
||||
- **주차 임계값의 정의를 프로파일 한 곳에만 둔 것** — 워크플로는 `parkAtDelivery()` 를 위임만 한다.
|
||||
- **`NatsStreamPosition` 이 스트림 순번과 소비자 순번을 따로 들고 있는 것** — 재배달 인식이 둘의 차이에서 나오고, 재생은 스트림 순번으로만 되돌아간다.
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/messaging/messaging-nats-experimental/build.gradle
|
||||
main/java/…/nats/NatsJetStreamTransport.java:1-295
|
||||
main/java/…/nats/NatsJetStreamProfile.java:1-103
|
||||
main/java/…/nats/NatsMaxDeliverParkingWorkflow.java:1-85
|
||||
main/java/…/nats/NatsJetStreamProfileValidator.java:1-75
|
||||
main/java/…/nats/NatsStreamPosition.java:1-75
|
||||
main/java/…/nats/NatsPreSendRejection.java:1-65
|
||||
main/java/…/nats/NatsAckMode.java:1-57
|
||||
test/java/…/nats/NatsAdapterContractTest.java:1-337
|
||||
test/java/…/nats/NatsMaxDeliverParkingTest.java:1-123
|
||||
src/messaging/messaging-core-api/…/destination/MessagingCapabilities.java (성분 의미)
|
||||
src/messaging/messaging-testkit/…/CompatibilityMatrix.java:107-109 (등급표의 이름 항목)
|
||||
src/config/architecture/modules.json (등록)
|
||||
```
|
||||
@@ -0,0 +1,775 @@
|
||||
# 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
|
||||
* <p>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
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<FailureCategory>`를 받아 **enum에서 문자열을 파생**한다 — 호출자가 철자를 정하지 않는다.
|
||||
|
||||
이 클래스는 소비자가 0이다(§12.1).
|
||||
|
||||
### 4.3 `CardinalityGuard` — 실패가 점진적이지 않다
|
||||
|
||||
```java
|
||||
// :9-17
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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
|
||||
* <p>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
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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
|
||||
* <p>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.
|
||||
*
|
||||
* <p>The consume side is deliberately a <em>link</em> 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
|
||||
* <p>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
|
||||
* <p>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
|
||||
* <p>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
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<FailureCategory>)`는 정확히 그 값을 채우려고 있다.
|
||||
|
||||
결과: 메트릭이 배선되더라도 **실패한 발행의 실패 분류가 기록되지 않는다.** `MessagingTags`가 6차원을 선언하고 실제로 채워지는 것은 4차원이다. `retryStage`도 마찬가지이지만 그쪽은 소비 경로가 없으므로 채울 주체 자체가 없다.
|
||||
|
||||
**(c) 추적과 감사 sink는 소비자가 없다**
|
||||
|
||||
`MessagingTracer`는 브로커 홉을 건너는 추적의 유일한 수단인데 참조가 0이다. 어댑터(`messaging-kafka`, `messaging-rabbit`)가 헤더를 매핑하지만 `MessagingTracer`를 쓰지 않는다 — 각 leaf SSOT가 무엇을 대신 하는지 답해야 한다.
|
||||
|
||||
`MessagingAuditSink`는 인터페이스 참조가 0이다. 그런데 `MessagingAuditEvent`는 `messaging-admin-runtime`이 **production에서 쓴다**(`RedriveService:126`, `ReplayService:73`). 즉 이벤트 타입은 쓰고 sink 인터페이스는 안 쓴다 — §12.3(c).
|
||||
|
||||
**한계.** 정적 검색이다. 파생 프로젝트가 `MessagingObservation` 구현을 제공할 수 있으나, `DefaultMessagePublisher`의 6인자 조립을 대체하려면 publisher bean 전체를 바꿔야 한다(`@ConditionalOnMissingBean(MessagePublisher.class)`).
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
이 leaf에 bean은 없다. starter 쪽 sibling 셋을 비교하면 비대칭이 드러난다.
|
||||
|
||||
| starter가 만드는 것 | 조건 | 이 leaf 소속 | 주입처 |
|
||||
|---|---|---|---|
|
||||
| `MessagingRedactor` (`:253`) | `@ConditionalOnMissingBean` | o | **0** |
|
||||
| `CardinalityGuard` (`:264`) | `@ConditionalOnMissingBean` | o | **0** |
|
||||
| `MessagingMetrics` | — | o | **만들지 않음** |
|
||||
|
||||
**두 재료는 만들고 그것을 쓰는 것은 만들지 않는다.** 조건은 동일하고 결과가 다르다. `messaging-policy`의 `RetryDecisionEngine`/`DeadLetterOrchestrator`(그쪽 §12.2)와 같은 형태이되, 여기서는 **만들어진 것조차 주입처가 없다** — 더 이른 단계에서 끊겼다.
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
**(a) 자격증명 판정이 두 강도로 존재한다**
|
||||
|
||||
| 위치 | 방식 | 예 |
|
||||
|---|---|---|
|
||||
| `messaging-core-api` `MessageHeaders.carriesACredential` | 정확 일치 9개 + **세그먼트 매칭 10개 + 인접 결합** | `x-api-key` 거절, `tokenizer-version` 통과 |
|
||||
| 이 leaf `MessagingRedactor.isDenied` | **정확 일치 27개만** | `x-api-key` **통과**(목록에 없음) |
|
||||
|
||||
`MessagingRedactor`의 denylist에 `api_key`와 `apikey`는 있지만 `x-api-key`는 없다. core-api가 세그먼트 매칭으로 잡는 형태를 이쪽은 놓친다. 두 곳이 다른 표면을 보호하므로(헤더 vs 진단 맵) 같은 규칙일 필요는 없지만, **더 약한 쪽이 더 자유로운 입력을 받는다** — 진단 맵은 "the one place where a caller can pass arbitrary keys"라고 이 leaf 자신이 적는다. §17.
|
||||
|
||||
**(b) 정적 스캐너 분류기가 두 파일에 복제돼 있다**
|
||||
|
||||
`SecretLeakStaticScanTest`의 private static 분류기(5개 `Pattern` + 판정 로직)가 `SecretLeakScannerCharacterizationTest`에 **글자 그대로 복사**돼 있다. 후자의 javadoc이 그 사실과 제거 조건을 명시한다 — "The copy is deleted in the same change that proves the extracted classifier agrees with it." 그 change("Wave 2")는 일어나지 않았다.
|
||||
|
||||
의도된 임시 중복이고 조건이 문서화돼 있으므로 결함으로 분류하지 않는다. 다만 두 복사본이 갈라지면 특성화 테스트가 실제 스캐너와 다른 것을 고정하게 된다.
|
||||
|
||||
**(c) 감사 sink 인터페이스가 사용처에서 다시 선언된다**
|
||||
|
||||
```java
|
||||
// messaging-admin-runtime/RedriveService.java:208
|
||||
void record(dev.caskeleton.messaging.observation.MessagingAuditEvent event);
|
||||
```
|
||||
|
||||
`MessagingAuditSink.record(MessagingAuditEvent)`와 같은 시그니처다. `messaging-admin-runtime`의 `allowed_dependencies`에 `messaging-observability`가 **포함돼 있으므로** 인터페이스를 쓸 수 있는데 쓰지 않는다.
|
||||
|
||||
결과: `MessagingAuditSink.inMemory()`가 제공하는 구현을 admin-runtime이 쓸 수 없고, 두 인터페이스가 구조적으로 호환되지만 타입 수준에서는 무관하다.
|
||||
|
||||
**(d) 관측 seam이 family 밖에도 있다**
|
||||
|
||||
notification 플랫폼이 자기 `CardinalityGuard`·`NotificationObservationConvention`·`MicrometerNotificationMetrics`를 갖는다. 같은 문제(카디널리티 경계 + 태그 어휘 + Micrometer 바인딩)를 두 family가 각자 푼다. 책임 경계가 다르므로 중복 경쟁은 아니지만, `CardinalityGuard`라는 **이름이 겹쳐** reachability 판정에 오탐을 만들었다(§12.1). 저장소 전역 판단이므로 cross-scope가 소유한다.
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
| 문서 주장 | 재측정 | 결과 |
|
||||
|---|---|---|
|
||||
| `DefaultMessagingObservationConvention` javadoc: "The conversion lives here, once, rather than at each call site" | 소비자 0, 유일한 호출부가 리터럴 사용 | **불일치** |
|
||||
| `MessagingAuditEvent` javadoc: "the details are passed through `MessagingRedactor`" | 생성자가 redactor를 부르지 않음 | **미강제** — 호출자 책임 |
|
||||
| `MessagingMetrics` javadoc: 태그가 guard를 먼저 통과 | `admitted(tags)`가 모든 record 메서드의 첫 단계 | **일치** |
|
||||
| `MessagingRedactor` javadoc: denylist인 이유 | 27키 정확 일치 | **일치** |
|
||||
| `MessagingTracer` javadoc: platform 헤더로 기록 | `MessageHeaders.platform` 사용 | **일치** |
|
||||
| build.gradle 주석: Micrometer가 public 생성자에 등장 | `MessagingMetrics(MeterRegistry, …)` | **일치** |
|
||||
| `SecretLeakScannerCharacterizationTest` javadoc: Wave 2에서 복사본 제거 | 복사본 존재 | **미실현** |
|
||||
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) |
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
| 위치 | 이전 상태 | 그것이 만든 실패 |
|
||||
|---|---|---|
|
||||
| `CardinalityGuard.admit` 주석 | size-then-add가 비원자적 | N개 스레드가 각각 `size == limit-1`을 읽고 각각 추가 → 설정된 상한이 경계가 아니라 **평균**. "A guard that can be exceeded under load is no guard — load is when it matters." |
|
||||
| `CardinalityGuard.admit(tags)` 주석 | 차원을 순회하며 즉시 커밋 | 마지막 차원에서 거절된 태그 세트가 앞 차원의 예산을 **영구히** 소비 — 방출된 적 없는 series에 |
|
||||
| `MessagingMetrics.recordDiagnostics` 주석 | 진단 키와 **값**을 전부 카운터 태그로 | 요청당 고유 message id/예외 메시지/URL 하나가 요청당 미터 series 하나를 영구 생성. redactor는 아는 키만 마스킹하므로 자유형 텍스트는 그대로 |
|
||||
| `SecretLeakScannerCharacterizationTest` javadoc | 스캐너가 메서드 호출 접미사와 `+ 1` 산술을 구분 못 함 | 전체 `test` 실행이 red |
|
||||
|
||||
세 번째가 가장 무겁다 — **경계가 있었는데 기본 차원만 보호했고 진단 값은 그 밖이었다.** 현재는 값이 태그가 되지 않고 키만 별도 guard 차원(`"diagnostic"`)을 통과한다.
|
||||
|
||||
첫 두 개는 같은 주제의 두 형태다 — **경계는 예산을 정확히 소비할 때만 경계다.** `messaging-policy`의 슬롯 누수 방지, `messaging-transport-spi`의 `endWork` clamp와 같은 계열이고 각 leaf §13이 소유한다.
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-285 | command | `evidence/raw/285-observability-tag-vocabulary-bypass.txt` | 9타입 단어검색 원본값, `CardinalityGuard` 동명 클래스 둘과 import별 실제 소유자, 소비자 0인 네 타입, convention의 `publish()`와 `MessagingTags.of()`와 유일한 호출부 나란히, 감사 sink 재선언과 admin-runtime의 허용 의존 | 정적 검색. 파생 프로젝트 미포함 |
|
||||
| EVD-286 | command | `./gradlew :messaging:messaging-observability:test --rerun-tasks` | BUILD SUCCESSFUL, 42 / 0 / 0 | `SimpleMeterRegistry` 사용. 실제 backend 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적**
|
||||
|
||||
- 태그를 닫힌 record로 두는 이유와 무엇을 뺐는지 — `MessagingTags` javadoc
|
||||
- 태그 값이 공개 계약인 이유 — `DefaultMessagingObservationConvention` javadoc
|
||||
- 카디널리티 실패가 점진적이지 않은 이유, 조용한 대체보다 시끄러운 거절이 나은 이유 — `CardinalityGuard` javadoc
|
||||
- 두 개의 이전 경합/예산 결함 — 두 인라인 주석
|
||||
- denylist를 고른 이유와 두 범주 — `MessagingRedactor` javadoc
|
||||
- guard가 미터 생성보다 먼저인 이유 — `MessagingMetrics` javadoc
|
||||
- 논리 메시지와 물리 시도를 분리한 이유 — 같은 javadoc + `MessagingObservation` javadoc
|
||||
- 진단 값이 태그가 되지 않는 이유 — `recordDiagnostics` 주석
|
||||
- 메시징이 in-process 추적을 끊는 이유, W3C를 쓰는 이유 — `MessagingTracer` javadoc
|
||||
- 배치가 링크인 이유 — 같은 javadoc
|
||||
- 추적 헤더를 platform 헤더로 쓰는 이유 — `inject` javadoc
|
||||
- 감사를 메트릭·로그와 분리한 이유 — `MessagingAuditSink` javadoc
|
||||
- 정적 스캔이 필요한 이유 — `SecretLeakStaticScanTest` javadoc
|
||||
- 특성화 테스트의 복사본이 임시인 이유와 제거 조건 — 그 javadoc
|
||||
- Micrometer를 `api`로 선언한 이유 — build.gradle 주석
|
||||
|
||||
**추론**
|
||||
|
||||
- `MessagingMetrics` bean이 없는 것이 미완인지 → **미상**. 두 재료가 bean으로 있다는 점이 미완을 시사한다.
|
||||
- `DefaultMessagePublisher`가 convention을 쓰지 않는 것이 의도인지 → **미상**.
|
||||
- 어댑터가 `MessagingTracer` 대신 무엇을 쓰는지 → **미확인**(각 어댑터 leaf 소유).
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- 9개 타입 838줄 전문의 계약
|
||||
- 42개 테스트가 통과하고 무엇을 단언하는지, 정적 스캔 테스트가 무엇을 검사하는지
|
||||
- `CardinalityGuard`가 동명의 다른 클래스와 혼동된다는 것과 import 기준 실제 소비자가 1개라는 것
|
||||
- `MessagingMetrics`·`MessagingTracer`·`MessagingAuditSink`·`DefaultMessagingObservationConvention` 넷이 소비자 0이라는 것
|
||||
- 태그 어휘가 존재하고 유일한 호출부가 리터럴과 4인자 factory로 우회하며, 그 결과 `failureCategory`가 항상 `none`이 된다는 것
|
||||
- starter가 `MessagingMetrics`의 두 재료만 bean으로 만든다는 것
|
||||
- `MessagingAuditEvent`는 admin-runtime이 쓰고 `MessagingAuditSink`는 재선언된다는 것
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- **`MessagingMetrics` bean이 없는 것이 미완인지 확장점인지.** 저장소 안에 답이 없다.
|
||||
- 어댑터들이 추적 헤더를 어떻게 다루는지 — `MessagingTracer`를 쓰지 않는 것은 확인했고 무엇을 대신 하는지는 각 leaf가 답한다.
|
||||
- `SecretLeakStaticScanTest`의 스캔 루트가 어떤 디렉터리 집합을 실제로 덮는지 — 코드상 `messaging-core-api`를 찾아 올라가지만 실행 시 파일 수를 남기지 않았다.
|
||||
- `extract`가 손상된 `traceparent`를 만났을 때의 실제 빈도.
|
||||
- `CardinalityGuard` 상한 200이 실제 배포에서 충분한지.
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P2 — 태그 어휘가 존재하고 유일한 호출부가 우회해, 실패 분류가 기록되지 않는다
|
||||
|
||||
- **사실.** `DefaultMessagingObservationConvention`은 소비자가 0이다. 유일한 production 호출부(`DefaultMessagePublisher.observe:260-269`)가 `"publish"` 리터럴과 **4인자** `MessagingTags.of(...)`를 쓴다. 그 factory는 `failureCategory`와 `retryStage`를 `NONE`으로 고정한다. convention의 `publish(broker, dest, completion, Optional<FailureCategory>)`는 정확히 `failureCategory`를 채우려고 존재한다.
|
||||
- **근거.** `evidence/raw/285` §C·§D.
|
||||
- **왜 문제인가.** convention javadoc이 "an adapter inventing its own spelling of 'rejected' silently breaks every alert that was watching for it"를 이유로 중앙화를 선언했고, 첫 호출부가 그것을 지나쳤다. 그리고 결과가 철자 문제에 그치지 않는다 — **`MessagingTags`가 선언한 6차원 중 4개만 채워진다.** 메트릭이 배선되더라도(§다음 항목) 실패한 발행이 `failureCategory=none`으로 기록되어, "왜 실패했는가"를 메트릭에서 나눌 수 없다. `PublishResult.failure()`에 `FailureDescriptor`가 이미 있으므로 값은 손에 있다.
|
||||
- **확인 방법.** `evidence/raw/285` §D 재실행. 또는 `git grep -n -w DefaultMessagingObservationConvention -- src`.
|
||||
- **후보.** `observe(...)`가 convention의 `publish(profile.broker(), profile.name().value(), result.completion(), result.failure().map(FailureDescriptor::category))`를 호출하게 바꾼다.
|
||||
- **다음 단계.** **CASE 후보.** 그리고 "중앙 어휘는 첫 호출부가 쓸 때만 어휘다"가 **REFERENCE 후보**다.
|
||||
|
||||
### P2 — 관측 구현이 조립되지 않고, 그 재료 둘만 bean으로 존재한다
|
||||
|
||||
- **사실.** `MessagingMetrics`는 `MessagingObservation`의 유일한 구현이고 저장소 전체에서 자기 테스트에서만 생성된다. starter는 그 생성자 인자 둘(`MessagingRedactor:253`, `CardinalityGuard:264`)을 bean으로 만들고 `MessagingMetrics` bean은 만들지 않는다. `DefaultMessagePublisher`는 `NO_OBSERVATION`을 쓰는 6인자 생성자로 조립된다.
|
||||
- **근거.** `evidence/raw/285` §A·§C. `evidence/raw/283` §D(runtime-core 쪽 증거).
|
||||
- **왜 문제인가.** 재료·구현·seam·호출부가 전부 있고 조립 한 줄이 없다. 그리고 두 재료 bean은 주입처가 0이므로 컨텍스트에 앉아만 있다 — bean 존재 검사는 통과한다.
|
||||
- **확인 방법.** `git grep -n -E 'new ([a-zA-Z0-9_.]+\.)?MessagingMetrics\s*\(' -- src` → 테스트만.
|
||||
- **다음 단계.** `analysis/messaging/messaging-runtime-core.md` §17 첫 항목과 **동일 사건**이다. 그 leaf가 CASE를 소유하고 여기서는 이 leaf 쪽 사실(재료만 bean, 구현 미조립)을 기여한다.
|
||||
|
||||
### P3 — 브로커 홉 추적기가 소비자를 갖지 않는다
|
||||
|
||||
- **사실.** `MessagingTracer`의 leaf 밖 참조 0. 이 클래스가 존재하는 이유는 "the only way the two spans meet is if the context travels in the message headers"다.
|
||||
- **근거.** `evidence/raw/285` §C.
|
||||
- **왜 문제인가.** `messaging-core-api`의 `TraceContext`가 봉투 필드로 있고(그쪽 §4.11), 어댑터가 헤더를 매핑한다. 그런데 `traceparent`/`tracestate`/`baggage`를 헤더로 옮기는 **명시된 수단**을 아무도 쓰지 않는다. 어댑터가 각자 하고 있다면 `MessageHeaders.platform` 사용 여부와 빈 추적 처리가 어댑터마다 다를 수 있다.
|
||||
- **확인 방법.** `git grep -n -w MessagingTracer -- src` → 이 leaf만. 어댑터의 헤더 매퍼가 세 이름을 어떻게 다루는지 확인 필요.
|
||||
- **후보.** 어댑터가 `MessagingTracer`를 쓰게 하거나, 어댑터가 대신 하고 있음을 확인하고 이 클래스를 정리한다.
|
||||
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 `messaging-kafka`·`messaging-rabbit` leaf의 사실에 걸린다.
|
||||
|
||||
### P3 — 감사 sink 인터페이스가 사용처에서 다시 선언된다
|
||||
|
||||
- **사실.** `MessagingAuditSink.record(MessagingAuditEvent)`와 같은 시그니처를 `RedriveService:208`이 자기 중첩 인터페이스로 선언한다. `messaging-admin-runtime`은 `messaging-observability`에 의존할 수 있다(registry 확인).
|
||||
- **근거.** `evidence/raw/285` §E.
|
||||
- **왜 문제인가.** `MessagingAuditSink.inMemory()`가 제공하는 구현을 admin-runtime이 쓸 수 없다. 그리고 감사 sink의 계약(분리된 보존·접근·무결성 요구)이 문서화된 곳과 실제로 구현되는 곳이 다르다.
|
||||
- **확인 방법.** 두 시그니처 대조.
|
||||
- **후보.** `RedriveService`가 `MessagingAuditSink`를 받게 한다.
|
||||
- **다음 단계.** **CASE 후보.** `messaging-admin-runtime` leaf SSOT와 공동 소유.
|
||||
|
||||
### P3 — 자격증명 판정이 core-api보다 약하다
|
||||
|
||||
- **사실.** `MessagingRedactor.isDenied`는 27키 **정확 일치**다. `messaging-core-api`의 `MessageHeaders.carriesACredential`은 세그먼트 매칭 + 인접 결합으로 `x-api-key`·`auth-token`·`db_password`를 잡는다. redactor의 denylist에 `api_key`·`apikey`는 있으나 `x-api-key`는 없다.
|
||||
- **근거.** 두 구현 대조. `MessagingRedactor.java:21-50`, `MessageHeaders.java:142-160`.
|
||||
- **왜 문제인가.** 두 표면이 다르지만 **더 자유로운 입력을 받는 쪽이 더 약하다.** 이 leaf 자신이 진단 맵을 "the one place where a caller can pass arbitrary keys"라고 부른다. 그리고 `recordDiagnostics`가 redaction을 첫 단계로 두는 이유가 바로 그것이다.
|
||||
- **확인 방법.** `redactor.isDenied("x-api-key")`가 false임을 확인.
|
||||
- **후보.** core-api의 세그먼트 매칭을 공유하거나 이쪽 denylist를 같은 방식으로 바꾼다.
|
||||
- **다음 단계.** **CASE 후보 + REFERENCE 후보**(같은 규칙을 두 강도로 구현하면 자유로운 입력 쪽을 강한 것으로 맞춘다).
|
||||
|
||||
### P3 — 감사 이벤트가 redaction을 강제하지 않는다
|
||||
|
||||
- **사실.** `MessagingAuditEvent` javadoc이 "the details are passed through `MessagingRedactor`"라고 하지만 생성자는 `Map.copyOf`만 한다.
|
||||
- **근거.** `MessagingAuditEvent.java:30-38`.
|
||||
- **왜 문제인가.** 감사 기록은 "often retained far longer than the source topic"이고 운영자가 읽는다. redaction이 호출자 책임이면 새 호출부가 그것을 잊을 수 있다. `messaging-core-api`의 `FailureDescriptor`가 512자 절단을 생성자에서 하는 것과 대비된다.
|
||||
- **확인 방법.** 생성자 본문 확인. `RedriveService:126`·`ReplayService:73`이 redactor를 부르는지 확인.
|
||||
- **후보.** 생성자가 `MessagingRedactor.sanitize`를 적용하거나, javadoc을 "호출자가 통과시켜야 한다"로 고친다.
|
||||
- **다음 단계.** **REFERENCE 후보**(타입이 문서화한 불변식은 타입이 강제한다).
|
||||
|
||||
### P3 — `extract`가 손상된 추적 헤더에 분류되지 않은 예외를 던진다
|
||||
|
||||
- **사실.** `MessagingTracer.extract`가 `new TraceContext(...)`를 부르고, 그 생성자는 W3C 문법·바이트 상한·all-zero를 검사해 `IllegalArgumentException`을 던진다. `extract`는 잡지 않는다.
|
||||
- **근거.** `MessagingTracer.java:67-75`, `TraceContext.java:60-72`.
|
||||
- **왜 문제인가.** 다른 시스템이 보낸 메시지의 헤더는 신뢰할 수 없는 입력이다. 손상된 `traceparent` 하나가 `MessagingException`이 아닌 예외로 소비 경로를 끊는다 — 추적이 없어야 할 자리에서 메시지 처리가 실패한다. `messaging-cloudevents`의 id 파싱과 같은 형태다(그쪽 §17).
|
||||
- **확인 방법.** `tracer.extract`에 잘못된 `traceparent` 헤더를 넣어 확인.
|
||||
- **후보.** `extract`가 검증 실패를 `TraceContext.none()`으로 강등한다 — 추적 손실이 메시지 손실보다 낫다.
|
||||
- **다음 단계.** **CASE 후보.** 다만 `MessagingTracer` 소비자가 0이므로 오늘의 사고는 아니다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- 태그를 닫힌 6차원 record로 두고 message id·partition key·tenant·offset을 명시적으로 배제한 것
|
||||
- guard가 미터 생성보다 먼저이고, 거절을 고정 이름 미터로만 기록하는 것
|
||||
- 논리 메시지 카운터를 첫 시도에만 증가시키는 것
|
||||
- 진단의 **값**을 태그로 만들지 않고 키만 별도 차원으로 세는 것, redaction을 첫 단계로 두는 것
|
||||
- 경합 하에서도 상한이 경계로 유지되는 double-checked 구조와, 그것을 재현하는 테스트
|
||||
- 태그 세트를 사전 확인 후 커밋해 거절된 세트가 예산을 안 먹게 하는 것
|
||||
- 추적을 platform 헤더로 써서 애플리케이션이 덮지 못하게 하는 것
|
||||
- 배치 소비를 부모가 아니라 링크로 두는 것
|
||||
- 감사를 메트릭·로그와 분리하고 승인 티켓을 필수로 둔 것
|
||||
- 소스 트리를 정적 스캔하는 테스트와, 그 스캔이 실제로 파일을 읽었는지 확인하는 테스트
|
||||
- Micrometer를 `api`로 선언하고 seam은 벤더 중립으로 유지한 것
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MOB-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 1개, memberships `["app-bootstrap"]` | 선언 |
|
||||
| MOB-002 | build | `messaging-observability/build.gradle` | same | Micrometer `api`와 그 이유 | — |
|
||||
| MOB-003 | code | `.../observation/MessagingTags.java` | same | 닫힌 6차원, 두 factory의 차이 | — |
|
||||
| MOB-004 | code | `.../observation/DefaultMessagingObservationConvention.java` | same | 태그 어휘 중앙화 의도 | 소비자 0(§12.1b) |
|
||||
| MOB-005 | code | `.../observation/CardinalityGuard.java` | same | 상한 강제와 두 이전 결함 | 동명 클래스 존재(§12.1) |
|
||||
| MOB-006 | code | `.../observation/MessagingRedactor.java` | same | 27키 denylist, `sanitize`/`mask` | core-api보다 약함(§17) |
|
||||
| MOB-007 | code | `.../observation/MessagingMetrics.java` | same | 여섯 미터, guard 우선 순서, 진단 값 배제 | 조립되지 않음 |
|
||||
| MOB-008 | code | `.../observation/MessagingObservation.java` | same | seam 5메서드 | — |
|
||||
| MOB-009 | code | `.../observation/MessagingTracer.java` | same | W3C 왕복, platform 헤더, 배치 링크 | 소비자 0 |
|
||||
| MOB-010 | code | `.../observation/{MessagingAuditSink,MessagingAuditEvent}.java` | same | 감사 분리와 필수 필드 | sink 소비자 0, redaction 미강제 |
|
||||
| MOB-011 | test | `MessagingSecretLeakTest` (9) | same | 비밀·신원이 meter registry에 도달 못 함, 경합 하 상한 | `SimpleMeterRegistry` |
|
||||
| MOB-012 | test | `MessagingMetricCardinalityTest` (8) | same | 상한 동작과 거절 카운트 | — |
|
||||
| MOB-013 | test | `MessagingTraceLinkTest` (8) | same | 추적 왕복과 링크 판정 | 실제 collector 없음 |
|
||||
| MOB-014 | test | `MessagingRedactorTest` (6) | same | denylist 동작 | — |
|
||||
| MOB-015 | test | `SecretLeakStaticScanTest` (6) | same | messaging 소스 전체 정적 스캔 + 스캔 도달 확인 | 런타임 유출 미포함 |
|
||||
| MOB-016 | test | `SecretLeakScannerCharacterizationTest` (5) | same | 분류기 판정 고정 | 분류기 복사본(§12.3b) |
|
||||
| MOB-017 | assembly | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:253,264` | same | 두 재료 bean, `MessagingMetrics` 부재 | 해당 leaf SSOT가 소유 |
|
||||
| MOB-018 | cross-leaf code | `messaging-runtime-core/.../DefaultMessagePublisher.java:258-269` | same | 유일한 관측 호출부와 그 우회 | 해당 leaf SSOT가 소유 |
|
||||
| MOB-019 | cross-leaf code | `messaging-admin-runtime/.../RedriveService.java:126,208`, `ReplayService.java:73` | same | 이벤트 사용, sink 재선언 | 해당 leaf SSOT가 소유 |
|
||||
| EVD-285 | command | `evidence/raw/285-observability-tag-vocabulary-bypass.txt` | same | §12.1·§12.3(c) | 정적 검색 |
|
||||
| EVD-286 | command | `./gradlew :messaging:messaging-observability:test --rerun-tasks` | same | 42 / 0 / 0 | `SimpleMeterRegistry` |
|
||||
@@ -0,0 +1,880 @@
|
||||
# messaging-policy 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-policy`
|
||||
> SSOT owner: `messaging-policy`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- registered leaf id: `messaging-policy`
|
||||
- canonical state `analysisFile`: `analysis/messaging/messaging-policy.md`
|
||||
- source path: `src/messaging/messaging-policy`
|
||||
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api"]`
|
||||
- registry `runtime_memberships`: `["app-bootstrap"]`
|
||||
|
||||
### 숫자
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | 26 |
|
||||
| production LOC | 1,738 |
|
||||
| 패키지 | 1 (`dev.caskeleton.messaging.policy`) |
|
||||
| test 파일 | 4 |
|
||||
| test 메서드(실행 확인) | 42 |
|
||||
| 외부(비프로젝트) 의존성 | **0** |
|
||||
|
||||
26개 타입을 관심사로 나누면 다섯이다.
|
||||
|
||||
| 축 | 타입 |
|
||||
|---|---|
|
||||
| **목적지 정의** (8) | `DestinationProfile` · `PhysicalDestination` · `SchemaPolicy` · `ProducerPolicy` · `ConsumerPolicy` · `PayloadPolicy` · `DeadLetterPolicy` · `CapabilityTier` |
|
||||
| **시작 검증** (1) | `DestinationProfileValidator` |
|
||||
| **발행 관문** (3) | `MessagingAdmissionController` · `PayloadLimitGuard` · `InFlightLimiter` |
|
||||
| **재시도 판단** (8) | `RetryPolicy` · `RetryMode` · `OrderingImpact` · `RetryContext` · `RetryDecision` · `RetryDecisionEngine` · `DefaultRetryDecisionEngine` · `BackoffCalculator` |
|
||||
| **DLQ 조정** (6) | `DeadLetterOrchestrator` · `DeadLetterEnvelopeFactory` · `DeadLetterMetadata` · `DeadLetterResult` · `SourceSettlement` · `FailureDescriptorDefaults`(package-private) |
|
||||
|
||||
**다섯 축의 배선 상태가 서로 다르다.** 목적지 정의·시작 검증·발행 관문은 출하 컨텍스트에서 실제로 실행되고, 재시도 판단과 DLQ 조정은 bean으로 생성되지만 주입되는 곳이 없다(§12.1).
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `src/main/java/**` (26) | 26 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 및 단언 확인 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 6줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
이 leaf는 **"이 목적지는 무엇을 약속하는가"**를 소유한다. 브로커를 만지지 않고 벤더 의존성이 0이며, 대신 브로커 어댑터가 따라야 할 판단을 미리 계산한다.
|
||||
|
||||
경계 규칙 하나가 leaf 전체를 관통한다: **모순은 부팅 실패여야 한다.**
|
||||
|
||||
```java
|
||||
// DestinationProfileValidator.java:20-24
|
||||
* <p>Every rule here exists because the alternative is a production surprise. A profile that asks
|
||||
* for ordered delivery and configures a reordering retry does not fail on the happy path; it fails
|
||||
* the first time a message is retried, months later, in a way that looks like a data bug rather
|
||||
* than a configuration one. Making the contradiction a boot failure moves that discovery to the
|
||||
* deploy that introduced it.
|
||||
```
|
||||
|
||||
두 번째 경계는 **물리 주소의 격리**다.
|
||||
|
||||
```java
|
||||
// PhysicalDestination.java:9-11
|
||||
* <p>Held here and nowhere else. Once a topic name reaches application code the logical destination
|
||||
* stops being a boundary, and swapping the broker under a service becomes a code change instead of
|
||||
* a configuration change.
|
||||
```
|
||||
|
||||
`messaging-core-api`의 `DestinationName`이 `:`과 `/`를 정규식으로 막고(그쪽 §4), 이 leaf가 물리 주소를 독점한다. 두 leaf가 같은 경계를 양쪽에서 지킨다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api). 둘 다 `api`인 이유는 `DestinationProfile`이 `DeliveryGuarantee`·`OrderingScope`·`DestinationKind`·`DestinationName`(core-api)와 `SchemaCompatibility`(schema-api)를 필드로 갖기 때문이다.
|
||||
|
||||
나가는 것: `messaging-transport-spi`, `messaging-runtime-core`, `messaging-kafka`, `messaging-kafka-share-experimental`, `messaging-rabbit`, `messaging-outbox-jdbc-postgresql`, `messaging-admin-api`, `messaging-admin-runtime`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-cloud-stream-bridge`, `messaging-spring-boot-starter`, `messaging-testkit`.
|
||||
|
||||
**실제 배선 지점 넷**(전부 `messaging-spring-boot-starter/MessagingCoreAutoConfiguration`):
|
||||
|
||||
| 지점 | 라인 | 상태 |
|
||||
|---|---:|---|
|
||||
| `new DestinationProfileValidator().validateAll(registered)` | 134 | **실행됨** — 시작 시 전체 registry 검증 |
|
||||
| `DestinationProfileValidator` bean | 145–146 | 생성 |
|
||||
| `MessagingAdmissionController` bean | 407–417 | 생성 + `DefaultMessagePublisher`·`MessagingEndpoint`·`MessagingShutdownLifecycle`이 주입받음 |
|
||||
| `RetryDecisionEngine` bean | 167–169 | 생성, **주입처 없음**(§12.1) |
|
||||
| `DeadLetterOrchestrator` bean | 179–181 | 생성, **주입처 없음**(§12.1) |
|
||||
|
||||
이 leaf 자체는 Spring 주석을 갖지 않는다 — bean 정의는 전부 starter 쪽에 있다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
```
|
||||
[목적지 정의]
|
||||
DestinationProfile ─┬─ PhysicalDestination (topic/exchange/routingKey/queue/subject/stream)
|
||||
├─ SchemaPolicy (codec, compatibility, 닫힌 messageTypes)
|
||||
├─ ProducerPolicy (confirmation, timeout, mandatoryRouting, idempotent)
|
||||
├─ ConsumerPolicy (group, concurrency, maxInFlightPerUnit, prefetch, timeout, manual)
|
||||
├─ RetryPolicy (mode, maxAttempts, backoff, orderingImpact, 카테고리 오버라이드)
|
||||
├─ DeadLetterPolicy (enabled, destination, maxRedriveCount)
|
||||
├─ PayloadPolicy (maxBytes, claimCheckThreshold)
|
||||
└─ CapabilityTier (M1/M2/M3)
|
||||
|
||||
[시작 검증] DestinationProfileValidator
|
||||
├─ validate(profile) : 프로파일 내부 모순 15가지
|
||||
└─ validateAll(profiles) : 중복 이름 + retry/DLQ 그래프 사이클
|
||||
|
||||
[발행 관문] MessagingAdmissionController
|
||||
├─ PayloadLimitGuard ── PayloadPolicy
|
||||
└─ InFlightLimiter (Semaphore, fair)
|
||||
|
||||
[재시도 판단] RetryContext ─→ RetryDecisionEngine ─→ RetryDecision (sealed 5)
|
||||
↑
|
||||
DefaultRetryDecisionEngine ── BackoffCalculator
|
||||
|
||||
[DLQ 조정] DeadLetterOrchestrator ─┬─ DeadLetterEnvelopeFactory ── DeadLetterMetadata
|
||||
└─ SourceSettlement → DeadLetterResult
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
### 4.1 `DestinationProfileValidator.validate` — 15가지 모순 거절
|
||||
|
||||
프로파일 하나에 대해 순서대로 검사한다.
|
||||
|
||||
| # | 거절 조건 | 왜 |
|
||||
|---:|---|---|
|
||||
| 1 | `retry.orderingImpact == PRESERVE && retry.reorders()` | 정책이 자기 자신과 모순 |
|
||||
| 2 | `isOrdered() && retry.orderingImpact == ALLOW_REORDER` | 순서 목적지가 재정렬 재시도를 허용 |
|
||||
| 3 | `payload.maxBytes > 8,388,608` | 절대 상한 초과 |
|
||||
| 4 | `claimCheckThreshold > payload.maxBytes` | 오프로드 문턱이 상한보다 큼 |
|
||||
| 5 | DLQ가 자기 자신을 가리킴 | 무한 루프 |
|
||||
| 6 | retry 목적지가 자기 자신을 가리킴 | 무한 루프 |
|
||||
| 7 | `orderingScope == KEY && !keyResolverConfigured` | 키 기반 순서인데 키 추출기 없음 |
|
||||
| 8 | `tier == M1 && consumer.manualSettlement` | M1이 수동 정산을 쓰면 정산 순서가 앱으로 새 나감 |
|
||||
| 9 | `AT_LEAST_ONCE && producer.confirmation == NONE` | 확인 없는 at-least-once는 보장이 아님 |
|
||||
| 10 | `production && topologyAutoCreate` | 운영에서 앱이 토폴로지를 만듦 |
|
||||
| 11 | `orderingScope == DESTINATION && consumer.concurrency > 1` | 목적지 전체 순서는 동시성 1을 요구 |
|
||||
| 12 | `isOrdered() && maxInFlightPerOrderingUnit > 1` | 순서 단위 안 동시 처리 |
|
||||
| 13 | `physical.isEmpty()` | 물리 주소 없음 |
|
||||
| 14 | `retry.mode == NONE && maxAttempts > 1` | 모드와 횟수 모순 |
|
||||
| 15 | `retry.mode == RETRY_DESTINATION && retryDestination.isEmpty()` | 목적지 없는 재시도 목적지 모드 |
|
||||
| 16 | `maxAttempts > 1 && mode != NONE && !deadLetter.enabled` | 재시도하는데 소진 후 갈 곳 없음 |
|
||||
|
||||
11번과 12번이 짝이다 — 전자는 목적지 수준 동시성, 후자는 순서 단위 안 동시성. 둘 다 있어야 "순서 보장"이 실제로 성립한다.
|
||||
|
||||
### 4.2 `validateAll` — 두 종류의 간선을 하나의 그래프로
|
||||
|
||||
이 leaf에서 가장 정교한 판단이다.
|
||||
|
||||
```java
|
||||
// :131-136
|
||||
// One graph carrying both edge kinds, not two walks.
|
||||
//
|
||||
// Walking retry and dead-letter separately misses a cycle that alternates between them: A's
|
||||
// retry points at B and B's dead letter points back at A. Neither single-edge walk revisits a
|
||||
// node, both pass, and a poison message loops between the two destinations forever. The label
|
||||
// is kept per edge so the reported path still says which kind each hop was.
|
||||
```
|
||||
|
||||
`Edge` enum이 `RETRY`와 `DEAD_LETTER` 둘을 갖고, `walk`가 두 간선을 동시에 따라간다.
|
||||
|
||||
**`onPath`가 전역 방문 집합이 아니라 현재 경로다.**
|
||||
|
||||
```java
|
||||
// :164-169
|
||||
* <p>{@code onPath} is the current walk rather than everything ever seen, so a diamond — two
|
||||
* destinations that both forward to a third — is not mistaken for a loop.
|
||||
walk(nextProfile, byName, new LinkedHashSet<>(onPath), branch);
|
||||
```
|
||||
|
||||
각 분기마다 `new LinkedHashSet<>(onPath)`로 복사하므로 형제 분기가 서로의 방문 기록을 오염시키지 않는다. 다이아몬드(A→C, B→C)는 사이클이 아니고, 그것을 사이클로 판정하면 정상 구성이 부팅에 실패한다.
|
||||
|
||||
테스트가 두 경우를 각각 붙든다 — `aMixedEdgeCycleIsRejected`(retry/DLQ 교대 사이클 거절)와 `aSharedDeadLetterIsNotACycle`(다이아몬드 허용).
|
||||
|
||||
미등록 목적지도 여기서 잡힌다 — `anUnregisteredRetryDestinationIsRejected`.
|
||||
|
||||
**비용 주의.** 매 분기마다 `onPath`와 `path`를 복사하므로 시간·공간이 경로 수에 지수적이다. 목적지 수가 수십 개인 정상 구성에서는 문제가 없지만, 이 성질이 어디에도 기록되지 않았다 — §17의 P3.
|
||||
|
||||
### 4.3 `MessagingAdmissionController` — 순서가 계약이다
|
||||
|
||||
```java
|
||||
// :13-16
|
||||
* <p>Order matters and is fixed here rather than left to each adapter: the payload limit is checked
|
||||
* <em>before</em> a permit is taken. An oversized message can never succeed, so letting it occupy a
|
||||
* scarce in-flight permit while it is being rejected would let a stream of bad messages starve the
|
||||
* good ones.
|
||||
```
|
||||
|
||||
`admit`의 실제 순서:
|
||||
|
||||
1. `payloadGuard.checkPayload` → 초과면 `MessageTooLargeException`
|
||||
2. `acceptingNewWork` 확인 → 종료 중이면 `MessageBackpressureException("SHUTTING_DOWN")`
|
||||
3. `reserve(destination)` — 목적지별 CAS 루프 → 초과면 `DESTINATION_IN_FLIGHT_LIMIT_EXCEEDED`
|
||||
4. `limiter.tryAcquire()` — 프로세스 전역 semaphore, 유한 대기 → 실패면 목적지 슬롯 **반납 후** `IN_FLIGHT_LIMIT_EXCEEDED`
|
||||
|
||||
**두 개의 천장이 있는 이유**도 명시돼 있다.
|
||||
|
||||
```java
|
||||
// :23-26
|
||||
* <p>Two ceilings, because one is not enough. The per-destination ceiling stops a single slow
|
||||
* downstream from consuming every permit in the process, and the process-wide ceiling stops the sum
|
||||
* of well-behaved destinations from exhausting memory — without it, adding a destination silently
|
||||
* raises what the process can be holding at once.
|
||||
```
|
||||
|
||||
**거절이 모호하지 않은 것이 설계의 핵심**이다 — "Both refusals happen before transmission, so neither is ambiguous — the caller may resubmit under the same message id without risking a duplicate." `messaging-core-api`의 3상태 발행 결과와 직접 연결된다.
|
||||
|
||||
**세 가지 누수 방지**가 코드에 있다.
|
||||
|
||||
```java
|
||||
} catch (InterruptedException interrupted) {
|
||||
// The destination slot was taken a moment ago and no publish will use it, so it goes back
|
||||
// here: a slot leaked per interruption shrinks the destination's ceiling until it is zero.
|
||||
release(destination);
|
||||
```
|
||||
|
||||
```java
|
||||
public void complete(String destination) {
|
||||
if (!release(destination)) {
|
||||
// A completion for a destination that holds nothing: either it names the wrong destination or
|
||||
// it is a second completion for the same publish. Returning the process permit anyway frees
|
||||
// one nobody took, and the process-wide ceiling then reads below what is really in flight and
|
||||
// admits more work than the process can carry.
|
||||
return;
|
||||
}
|
||||
limiter.release();
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// release():195-197
|
||||
// Drop the entry at zero, atomically, so the map does not accumulate one counter per
|
||||
// destination ever published to for the life of the process.
|
||||
perDestination.computeIfPresent(destination, (key, value) -> value.get() == 0 ? null : value);
|
||||
```
|
||||
|
||||
세 번째는 장기 실행 누수 방지다 — 목적지 이름이 동적이면(예: 테넌트별) 맵이 무한히 자란다.
|
||||
|
||||
`InFlightLimiter`가 **fair semaphore**를 쓰는 이유도 적혀 있다 — "an unfair semaphore lets a late arrival barge ahead of a caller that has already been waiting, which turns a bounded wait into an unbounded one for the unlucky."
|
||||
|
||||
`release()`가 `availablePermits() < limit`를 확인하고 반납한다 — "an unbalanced release would raise the ceiling silently and the limiter would stop limiting anything."
|
||||
|
||||
### 4.4 `DefaultRetryDecisionEngine` — 고정된 판단 순서
|
||||
|
||||
```java
|
||||
// :10-15
|
||||
* <p>The order is fixed and evaluated top to bottom. Retryability is checked before the attempt
|
||||
* budget so that a deserialization failure is parked on its first delivery instead of being
|
||||
* replayed three more times against a payload that cannot change. The ordering-preserving strategy
|
||||
* is checked before the re-publishing one so that an ordered destination can never fall through to
|
||||
* a strategy that reorders it, even if both are technically configured.
|
||||
```
|
||||
|
||||
실제 순서:
|
||||
|
||||
| # | 조건 | 결정 |
|
||||
|---:|---|---|
|
||||
| 1 | `!isRetryable(...)` | `park(context)` — DLQ가 있으면 `DeadLetter`, `AT_MOST_ONCE`이고 DLQ 없으면 `Reject`, 그 외 `DeadLetter` |
|
||||
| 2 | `attempt >= maxAttempts` | `DeadLetter` |
|
||||
| 3 | `orderingImpact == PRESERVE && isOrdered() && capabilities.orderedStream()` | `PauseAndRetry(delay)` |
|
||||
| 4 | `mode == PAUSE_PARTITION` | `PauseAndRetry(delay)` |
|
||||
| 5 | `mode == RETRY_DESTINATION && ALLOW_REORDER && retryDestination.isPresent()` | `PublishToRetryDestination` |
|
||||
| 6 | `mode == INLINE \|\| BLOCKING` | `RetryInline(delay)` |
|
||||
| 7 | `mode == BROKER_DELAYED && capabilities.delayedDelivery()` | `PublishToRetryDestination` |
|
||||
| 8 | (그 외) | `DeadLetter` |
|
||||
|
||||
**capability가 입력이다.**
|
||||
|
||||
```java
|
||||
// RetryContext.java:11-13
|
||||
* <p>Capabilities are an input rather than an assumption: the same policy resolves to
|
||||
* pause-and-retry on a partitioned Kafka topic and to a retry destination on a queue that cannot
|
||||
* pause, and the engine must not pick a strategy the adapter cannot actually carry out.
|
||||
```
|
||||
|
||||
3번과 7번이 그것을 쓴다 — `orderedStream()`이 false면 pause 전략이 선택되지 않고, `delayedDelivery()`가 false면 `BROKER_DELAYED`가 8번으로 떨어져 DLQ가 된다. **조용한 성능 저하 대신 명시적 파킹**이다.
|
||||
|
||||
`isRetryable`의 3단 판정:
|
||||
|
||||
```java
|
||||
if (policy.nonRetryableCategories().contains(category)) return false; // 명시적 제외 최우선
|
||||
if (policy.retryableCategories().contains(category)) return true; // 명시적 허용
|
||||
return descriptorRetryable && FailureDescriptorDefaults.retryable(category); // 둘 다 만족해야
|
||||
```
|
||||
|
||||
마지막 줄이 **AND**다 — descriptor가 retryable이라 해도 카테고리 기본값이 false면 재시도하지 않는다. `RetryPolicy` 생성자가 두 집합의 교집합을 거절하므로(§4.5) 1·2번이 동시에 참일 수 없다.
|
||||
|
||||
`FailureDescriptorDefaults`는 package-private 위임자다 — "kept in one place so policy and engine cannot disagree". 실제로는 `FailureDescriptor.defaultRetryable`(core-api)를 그대로 부른다. 한 줄 짜리 간접층이지만 정책 쪽에서 기본값을 바꿔야 할 때 바꿀 지점을 명시한다.
|
||||
|
||||
### 4.5 `RetryPolicy` — 기본값이 "재시도 없음"
|
||||
|
||||
```java
|
||||
// :13-15
|
||||
* <p>Automatic retry is opt-in. The default for an ordinary destination is zero attempts, because a
|
||||
* retry that reorders a stream, multiplies a non-idempotent side effect, or hammers a throttled
|
||||
* downstream is worse than a visible failure.
|
||||
```
|
||||
|
||||
`none()`이 `mode=NONE, maxAttempts=1, delays=ZERO, multiplier=1.0, jitter=false, orderingImpact=PRESERVE, 두 집합 비어 있음`이다.
|
||||
|
||||
생성자 검증 여섯:
|
||||
- `maxAttempts >= 1` (첫 전달 포함)
|
||||
- 두 지연 음수 아님
|
||||
- `maxDelay >= initialDelay`
|
||||
- `multiplier >= 1.0`
|
||||
- 두 카테고리 집합을 `Set.copyOf`로 복사
|
||||
- **두 집합의 교집합 거절** — "a failure category cannot be both retryable and non-retryable"
|
||||
|
||||
`reorders()`가 `RETRY_DESTINATION || BROKER_DELAYED`다 — 이 둘만 메시지를 원래 순서 단위 밖으로 옮긴다. `RetryMode` javadoc이 같은 사실을 반대편에서 적는다.
|
||||
|
||||
### 4.6 `BackoffCalculator` — full jitter
|
||||
|
||||
```java
|
||||
// :11-14
|
||||
* <p>The delay is {@code min(maxDelay, initialDelay * multiplier^(attempt-1))}. Full jitter then
|
||||
* picks uniformly from {@code [0, delay]} rather than shaving a small percentage off. That matters
|
||||
* when a downstream recovers: without jitter every consumer that failed in the same second retries
|
||||
* in the same second, and the recovery is immediately undone by the retry storm.
|
||||
```
|
||||
|
||||
`randomFraction`이 `DoubleSupplier`로 주입 가능해서 테스트가 결정론적이다. 테스트가 두 각도를 본다 — `backoffGrowsExponentiallyAndIsCappedByMaxDelay`와 `fullJitterSpreadsRetriesAcrossTheWholeWindow`.
|
||||
|
||||
`capped <= 0`이면 `Duration.ZERO`를 반환하므로 `initialDelay=0`인 정책에서 곱셈이 무의미해지는 경우를 방어한다.
|
||||
|
||||
### 4.7 `DeadLetterOrchestrator` — 하나의 불변식
|
||||
|
||||
```java
|
||||
// :21-29
|
||||
* <p>This ordering is the single invariant that stops dead lettering from becoming data loss. If
|
||||
* the source were acknowledged first, a failed dead letter publish would leave no copy of the
|
||||
* message anywhere: the broker has released it and the dead letter destination never received it.
|
||||
* So the source stays unsettled on anything other than a confirmed publish, including an ambiguous
|
||||
* one, and the message is redelivered instead of disappearing.
|
||||
*
|
||||
* <p>An ambiguous dead letter publish therefore produces a duplicate rather than a loss. That is
|
||||
* the intended trade: the dead letter destination is read by humans who can spot a duplicate, and
|
||||
* it is the only side of the trade that is recoverable.
|
||||
```
|
||||
|
||||
구현이 그 문장 그대로다.
|
||||
|
||||
```java
|
||||
.thenCompose(result -> {
|
||||
if (result.completion() != PublishCompletion.CONFIRMED) {
|
||||
return CompletableFuture.completedFuture(new DeadLetterResult(result, false));
|
||||
}
|
||||
return settleAfterConfirmation(result, settlement);
|
||||
});
|
||||
```
|
||||
|
||||
`CONFIRMED`가 아니면 — `REJECTED`든 `AMBIGUOUS`든 — 원본을 정산하지 않는다. `messaging-core-api`의 3상태가 여기서 실제 분기가 된다.
|
||||
|
||||
`SourceSettlement`이 콜백으로 주입되는 이유도 적혀 있다 — "so that the ordering constraint … lives in one place instead of being re-implemented by every adapter."
|
||||
|
||||
### 4.8 `DeadLetterEnvelopeFactory` — 예약 헤더 6개, payload 불변
|
||||
|
||||
```java
|
||||
// :16-21
|
||||
* <p>The payload and the logical {@code messageId} are carried through untouched. That is what
|
||||
* makes a redrive a genuine replay rather than a new message: an Inbox downstream still recognises
|
||||
* it, and an operator can correlate the dead letter with the original publish.
|
||||
*
|
||||
* <p>Failure context is written into reserved headers, never into the payload, so redriving does
|
||||
* not require unwrapping a platform-specific structure.
|
||||
```
|
||||
|
||||
쓰는 헤더: `FAILURE_CATEGORY`, `FAILURE_CODE`, `ORIGIN_DESTINATION`, `RETRY_ATTEMPT`, `FIRST_FAILURE_AT`, `LAST_FAILURE_AT`. 전부 `ReservedHeaders`의 상수를 쓴다(리터럴 아님).
|
||||
|
||||
`MessageHeaders.platform(headers)`를 쓴다 — 예약 이름을 쓸 수 있는 factory다(`messaging-core-api` §4.8). 이것이 core-api의 두 factory 분리가 실제로 필요한 이유를 보여주는 유일한 production 사용처다.
|
||||
|
||||
여섯 헤더 중 `RETRY_ATTEMPT`·`FIRST_FAILURE_AT`·`LAST_FAILURE_AT`·`FAILURE_CATEGORY`·`FAILURE_CODE`·`ORIGIN_DESTINATION`은 전부 `CanonicalEnvelopeHeaders`가 "platform bookkeeping"으로 분류한 8개에 속한다 — 봉투 필드가 없어서 헤더로만 이동할 수 있는 것들이다. 두 leaf의 분류가 정확히 맞물린다.
|
||||
|
||||
### 4.9 `DeadLetterMetadata` — 일부러 작다
|
||||
|
||||
```java
|
||||
// :11-13
|
||||
* <p>Deliberately small. A dead letter destination is read by operators, exported to tickets, and
|
||||
* often retained far longer than the source topic, so it holds a category, a code, and timing — not
|
||||
* a stack trace, not the exception message, and not the original headers.
|
||||
```
|
||||
|
||||
`messaging-core-api`의 `FailureDescriptor` javadoc("a DLQ is read by more people than the log is")과 같은 판단을 다른 층에서 반복한다.
|
||||
|
||||
**한 가지 관측.** `DeadLetterOrchestrator`가 `DeadLetterMetadata`를 만들 때 `firstFailureAt`과 `lastFailureAt`에 **같은 값**(`delivery.metadata().receivedAt()`)을 넣는다.
|
||||
|
||||
```java
|
||||
Instant failedAt = delivery.metadata().receivedAt();
|
||||
DeadLetterMetadata metadata = new DeadLetterMetadata(..., failedAt, failedAt);
|
||||
```
|
||||
|
||||
즉 두 필드가 구분되어 선언됐지만 현재 유일한 생산 경로에서는 항상 같다. 첫 실패 시각을 이전 시도에서 이어받는 코드가 없다 — §17의 P3.
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
**시작:** `MessagingCoreAutoConfiguration:134` → `validateAll(registered)` → 프로파일별 15검사 + 중복 이름 + 사이클 그래프 → 실패 시 `IllegalArgumentException`으로 부팅 중단
|
||||
|
||||
**발행:** `DefaultMessagePublisher` → `admission.admit(destination, bytes)` → 크기 → 종료 여부 → 목적지 슬롯 → 프로세스 permit → (발행) → `admission.complete(destination)`
|
||||
|
||||
**재시도 판단:** `RetryContext(profile, deliveryMetadata, failure, capabilities, ...)` → `engine.decide(...)` → `RetryDecision` 5종 중 하나 — **이 경로는 출하 컨텍스트에서 호출되지 않는다**(§12.1)
|
||||
|
||||
**DLQ:** `orchestrator.deadLetter(profile, delivery, failure, settlement)` → 헤더 6개 추가 → 발행 → CONFIRMED면 원본 정산 — **이 경로도 호출되지 않는다**(§12.1)
|
||||
|
||||
---
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
| 코드 | 예외 | 위치 | 조건 |
|
||||
|---|---|---|---|
|
||||
| `PAYLOAD_LIMIT_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 목적지 상한 초과 |
|
||||
| `BATCH_COUNT_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 배치 항목 수 초과 |
|
||||
| `BATCH_BYTES_EXCEEDED` | `MessageTooLargeException` | `PayloadLimitGuard` | 배치 총 바이트 초과 |
|
||||
| `SHUTTING_DOWN` | `MessageBackpressureException` | `MessagingAdmissionController` | 종료 중 |
|
||||
| `DESTINATION_IN_FLIGHT_LIMIT_EXCEEDED` | `MessageBackpressureException` | 같음 | 목적지 천장 |
|
||||
| `IN_FLIGHT_LIMIT_EXCEEDED` | `MessageBackpressureException` | 같음 | 프로세스 천장 |
|
||||
| `ADMISSION_INTERRUPTED` | `MessageBackpressureException` | 같음 | 대기 중 인터럽트 |
|
||||
| `DEAD_LETTER_NOT_CONFIGURED` | `MessagingConfigurationException` | `DeadLetterOrchestrator` | DLQ 미설정 목적지를 DLQ하려 함 |
|
||||
|
||||
**배치 상한이 두 축인 이유**가 적혀 있다.
|
||||
|
||||
```java
|
||||
// PayloadLimitGuard.java:16-18
|
||||
* <p>Batches are limited by count <em>and</em> bytes. A count limit alone lets a handful of large
|
||||
* messages exceed the broker's frame; a byte limit alone lets a huge number of tiny messages exceed
|
||||
* its request timeout.
|
||||
```
|
||||
|
||||
`checkBatch`가 각 항목에 대해 `checkPayload`도 부르므로 **개별 상한 · 개수 상한 · 총합 상한** 셋이 함께 적용된다.
|
||||
|
||||
프로파일 검증 실패는 `IllegalArgumentException`이다 — `MessagingException` 계층 밖이다. 시작 시점의 구성 오류이지 메시지 실패가 아니므로 일관적이다. 다만 `MessagingConfigurationException`("Raised at startup wherever possible")이 존재하는데 쓰이지 않는다 — §17의 P3.
|
||||
|
||||
---
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
트랜잭션 없음.
|
||||
|
||||
동시성 지점은 `MessagingAdmissionController`와 `InFlightLimiter` 둘이다.
|
||||
|
||||
| 지점 | 도구 | 보호 |
|
||||
|---|---|---|
|
||||
| `perDestination` 맵 | `ConcurrentHashMap` + `computeIfAbsent` | 목적지 카운터 생성 |
|
||||
| 목적지 카운터 증가 | `AtomicInteger` CAS 루프 | 천장 초과 방지 |
|
||||
| 목적지 카운터 감소 | `getAndUpdate` + 0 clamp | 음수 방지 |
|
||||
| 맵 항목 제거 | `computeIfPresent` (원자) | 0일 때만 제거, 누수 방지 |
|
||||
| `acceptingNewWork` | `volatile boolean` | 종료 플래그 가시성 |
|
||||
| permit | `Semaphore(limit, true)` — **fair** | 유한 대기 보장 |
|
||||
| permit 반납 | `availablePermits() < limit` 확인 | 천장 상승 방지 |
|
||||
|
||||
`reserve`의 CAS 루프는 `AtomicInteger.updateAndGet`으로 쓸 수 있었지만 조건부 실패(`return false`)가 필요해서 직접 루프를 돈다.
|
||||
|
||||
`release`에 **미세한 경합**이 있다. `getAndUpdate`로 감소한 뒤 `computeIfPresent`로 0인 항목을 제거하는데, 그 사이에 다른 스레드가 `computeIfAbsent`로 같은 키를 만들고 증가시킬 수 있다. 그러면 `computeIfPresent`의 람다가 `value.get() == 0`을 보지 못해 제거하지 않는다 — 안전한 방향의 경합이다(누수가 아니라 제거 실패). 반대 순서였다면 살아 있는 카운터를 지울 수 있었다.
|
||||
|
||||
`DefaultRetryDecisionEngine`·`BackoffCalculator`·`DeadLetterOrchestrator`·`DeadLetterEnvelopeFactory`·`DestinationProfileValidator`는 전부 상태가 없거나 불변이다. `BackoffCalculator`의 기본 생성자가 `ThreadLocalRandom`을 쓰므로 스레드 안전하다.
|
||||
|
||||
수명주기 참여는 `stopAcceptingNewWork()` 하나이고, `MessagingShutdownLifecycle`(starter)이 종료 1단계에서 부른다(`messaging-transport-spi` §12.1 참조).
|
||||
|
||||
---
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
설정 파일 없음. 상수와 기본값:
|
||||
|
||||
| 상수/기본값 | 값 | 위치 |
|
||||
|---|---:|---|
|
||||
| `PayloadPolicy.DEFAULT_MAX_BYTES` | 1,048,576 | `PayloadPolicy.java:17` (public) |
|
||||
| `PayloadPolicy.HARD_MAX_BYTES` | 8,388,608 | `:20` (public) |
|
||||
| `ProducerPolicy.defaults()` | `REPLICATION_OR_PERSISTENCE_ACK`, 5초, mandatoryRouting, idempotent | `:34-37` |
|
||||
| `ConsumerPolicy.defaults(group)` | concurrency 1, maxInFlightPerUnit 1, prefetch 16, timeout 30초, manual false | `:52-54` |
|
||||
| `RetryPolicy.none()` | mode NONE, 1회, 지연 0, PRESERVE | `:115-125` |
|
||||
| `DeadLetterPolicy.disabled()` / `.to(dest)` | maxRedrive 0 / 1 | `:32-44` |
|
||||
|
||||
**모든 기본값이 보수적이다** — 재시도 없음, 동시성 1, 순서 보존, 확인 최대, DLQ 비활성. 켜는 것이 명시적 선택이다.
|
||||
|
||||
`PayloadPolicy.HARD_MAX_BYTES = 8 MiB`의 근거도 적혀 있다 — "Raising a broker's frame limit to carry large payloads trades a bounded, testable failure for an unbounded one: it degrades broker memory, replication latency, and consumer recovery all at once."
|
||||
|
||||
`PayloadPolicy.DEFAULT_MAX_BYTES`는 이 저장소에서 1 MiB 상한을 선언하는 다섯 곳 중 하나이고 **정책 축의 자연스러운 주인**이다. 그런데 starter는 이것 대신 `JacksonMessageCodec.DEFAULT_MAX_BYTES`를 참조한다 — `analysis/messaging/messaging-schema-json.md` §17이 소유한다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
없다. 브로커·DB·파일시스템을 만지지 않는다. `ThreadLocalRandom`(jitter)과 `Semaphore`가 유일한 런타임 자원이다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
레인: `./gradlew :messaging:messaging-policy:test`. **BUILD SUCCESSFUL, 42 tests, 0 skipped, 0 failures**.
|
||||
|
||||
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|
||||
|---|---:|---|---|
|
||||
| `DestinationProfileValidatorTest` | 13 | 순서/페이로드/DLQ 자기참조/키 리졸버/M1 수동정산/확인/토폴로지/DLQ 필요, **retry↔DLQ 교대 사이클 거절**, **다이아몬드 허용**, 미등록 목적지 거절 | 실제 부팅에서 이 검증이 호출되는지(→ starter가 부른다, §2) |
|
||||
| `MessagingAdmissionControllerTest` | 13 | permit 점유/반납, 초과 시 큐잉 대신 거절, backpressure가 retryable, 초과 payload가 permit을 안 먹음, 종료 시 기존 permit 유지, 불균형 반납이 천장을 못 올림, 한 목적지가 전부 못 먹음, 거절이 슬롯을 안 남김, 완료가 둘 다 반납, 미지 목적지 완료가 permit을 안 품, 이중 완료, 배치 두 축, 대기 후 승인 | 실제 부하에서의 공정성 |
|
||||
| `RetryDecisionEngineTest` | 10 | 역직렬화 실패 즉시 파킹, 인증/구성 실패 미재시도, 순서 Kafka는 pause, 소진은 DLQ, 비순서 재시도목적지 재발행, blocking은 inline, **지수 증가와 상한**, **full jitter 분포**, 프로파일 오버라이드, at-most-once DLQ 없으면 discard | **이 엔진이 production에서 호출되는지** |
|
||||
| `DeadLetterOrchestratorTest` | 6 | 확인 후에만 원본 정산, 모호하면 미정산, 거절되면 미정산, 헤더 부착 | **이 orchestrator가 production에서 호출되는지** |
|
||||
|
||||
**두 축의 증명 성격이 다르다.** 검증기와 관문은 배선까지 확인되지만(§2), 재시도 엔진과 DLQ 조정자는 로직만 증명되고 배선은 §12.1이 부정한다. 테스트가 통과한다는 것이 그 코드가 실행된다는 뜻이 아닌 전형적인 예다.
|
||||
|
||||
`MessagingAdmissionControllerTest`의 `as(...)` 문구들이 특히 구체적이다 — "a slot leaked per refusal shrinks the destination's ceiling until it is zero", "a permit nobody took cannot be given back; doing so makes the ceiling fiction". 각 테스트가 어떤 이전 결함을 붙들고 있는지 이름 자체가 말한다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
| 게이트 | 이 leaf에 대해 |
|
||||
|---|---|
|
||||
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-schema-api"]` |
|
||||
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
|
||||
| vendor `api` 규칙 | 벤더 의존성 0 |
|
||||
| **부팅 검증** | `MessagingCoreAutoConfiguration:134`가 `validateAll`을 호출 — 이 leaf의 규칙이 실제로 부팅을 막는 유일한 지점 |
|
||||
| ArchUnit | 전용 규칙 없음 |
|
||||
|
||||
§4.1의 15가지 규칙은 **ArchUnit이 아니라 런타임 시작 시점**에 강제된다. `verifyCleanArchitectureDependencies`가 빌드 타임에 도는 것과 대비된다. 잘못된 프로파일은 컴파일되고, 부팅에서 막힌다.
|
||||
|
||||
---
|
||||
|
||||
## 12. 실제 사용 여부와 negative-space probes
|
||||
|
||||
원시 증거: `evidence/raw/281-messaging-policy-retry-engine-unwired.txt`.
|
||||
|
||||
> **방법 주의.** 이 절의 조립 판정은 `new ([a-zA-Z0-9_.]+\.)?<Type>\s*\(` 패턴으로 재확인한 것이다. 처음에 `new <Type>(`로만 검색해 **오탐**을 냈다 — 이 저장소는 `new dev.caskeleton.messaging.runtime.TransportMessagingRuntime(`처럼 정규화된 이름으로 생성하는 곳이 있고, 그 패턴은 그것을 놓친다. 아래 결과는 전부 수정된 패턴의 것이다.
|
||||
|
||||
### 12.1 Public surface reachability
|
||||
|
||||
leaf 밖 참조가 0인 것은 둘이고 성격이 다르다.
|
||||
|
||||
| 타입 | leaf 밖 | 판정 |
|
||||
|---|---:|---|
|
||||
| `DeadLetterEnvelopeFactory` | 0 | **내부 협력자** — `DeadLetterOrchestrator`가 쓴다. 문제 아님 |
|
||||
| `DeadLetterMetadata` | 0 | 같음 |
|
||||
|
||||
나머지 24개는 전부 외부 참조가 있다. `DestinationProfile` 43파일, `RetryDecision` 23, `RetryContext` 18, `SchemaPolicy` 17, `PayloadPolicy` 15, `PhysicalDestination` 13.
|
||||
|
||||
**참조 수는 이 leaf에서 오해를 낳는다.** 참조가 있어도 실행되지 않을 수 있고, 여기가 정확히 그렇다.
|
||||
|
||||
**(a) `RetryDecisionEngine` bean은 만들어지고 아무 데도 주입되지 않는다**
|
||||
|
||||
```java
|
||||
// MessagingCoreAutoConfiguration.java:165-169
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public RetryDecisionEngine retryDecisionEngine() {
|
||||
return new DefaultRetryDecisionEngine(new BackoffCalculator());
|
||||
}
|
||||
```
|
||||
|
||||
이 타입을 받는 코드는 저장소 전체에서 **하나**다 — `KafkaRetryExecutor`의 필드와 생성자 인자(`KafkaRetryExecutor.java:32,46`).
|
||||
|
||||
그리고 `KafkaRetryExecutor`는 **한 번도 생성되지 않는다.**
|
||||
|
||||
```
|
||||
## D. is each of those dependents ever constructed?
|
||||
KafkaRetryExecutor NEVER CONSTRUCTED
|
||||
```
|
||||
|
||||
즉 5개 `@Bean` 설정 클래스가 만드는 51개 bean 중 어느 것도 `RetryDecisionEngine`을 인자로 받지 않는다. bean은 매 시작마다 생성되고 컨텍스트에 앉아 있다.
|
||||
|
||||
**(b) `DeadLetterOrchestrator` bean도 같다**
|
||||
|
||||
```java
|
||||
// :177-181
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public DeadLetterOrchestrator deadLetterOrchestrator(MessagePublisher publisher) {
|
||||
return new DeadLetterOrchestrator(publisher);
|
||||
}
|
||||
```
|
||||
|
||||
이 타입을 받는 production 코드는 둘 — `KafkaDeadLetterPublisher`(:29)와 `RabbitDeadLetterPublisher`(:47). 둘 다 **NEVER CONSTRUCTED**.
|
||||
|
||||
**(c) 왜 그런가 — 소비 경로 전체에 production 조립이 없다**
|
||||
|
||||
```
|
||||
## F. control: the consume path is constructed only in tests
|
||||
KafkaConsumerRegistrar src/main=0 src/test=4
|
||||
RabbitConsumerRegistrar src/main=0 src/test=1
|
||||
KafkaBatchConsumerRegistrar src/main=0 src/test=0
|
||||
RabbitBatchConsumerRegistrar src/main=0 src/test=1
|
||||
DefaultDeliveryProcessor src/main=0 src/test=1
|
||||
```
|
||||
|
||||
대조군으로 발행 경로를 같은 패턴으로 확인하면 전부 production에서 생성된다.
|
||||
|
||||
```
|
||||
## E. control: the publish path IS constructed in production
|
||||
DefaultMessagePublisher MessagingCoreAutoConfiguration.java:446
|
||||
TransportMessagingRuntime MessagingCoreAutoConfiguration.java:476
|
||||
DefaultRetryDecisionEngine MessagingCoreAutoConfiguration.java:168
|
||||
DeadLetterOrchestrator MessagingCoreAutoConfiguration.java:180
|
||||
```
|
||||
|
||||
**즉 출하 컨텍스트는 발행할 수 있고 소비할 수 없다.** 재시도와 DLQ는 소비 경로에만 존재하는 개념이므로, 이 leaf의 두 축이 배선되지 않은 것은 그 결과다.
|
||||
|
||||
이 사실은 `analysis/messaging/messaging-core-api.md` §12.1이 관측한 것 — `MessageHandler<T>`의 저장소 참조 0 — 에 조립 쪽 설명을 준다. 핸들러를 받을 소비자 런타임이 조립되지 않으므로 핸들러 계약에 소비자가 없다.
|
||||
|
||||
**(d) `RetryDecision`을 실제로 실행하는 코드는 하나뿐이다**
|
||||
|
||||
```
|
||||
## G. every file that acts on a RetryDecision variant
|
||||
messaging-kafka/.../KafkaRetryExecutor.java (생성되지 않음)
|
||||
messaging-policy/.../DefaultRetryDecisionEngine.java (생산자)
|
||||
messaging-policy/.../RetryDecision.java (선언)
|
||||
messaging-policy/.../RetryDecisionEngineTest.java (테스트)
|
||||
```
|
||||
|
||||
`messaging-rabbit`은 production 코드에서 `RetryDecision`·`RetryDecisionEngine`·`BackoffCalculator`·`RetryPolicy`를 전혀 참조하지 않는다(테스트 fixture 한 곳 제외). Rabbit에는 `RabbitRetryQueueTopology`가 있는데 그것은 **토폴로지 서술**(TTL 큐 + DLX)이고 `RetryDecision`을 소비하지 않는다. Pulsar·NATS도 0이다.
|
||||
|
||||
즉 브로커 중립 재시도 엔진의 실행자가 저장소에 **한 브로커 분량**만 있고, 그마저 조립되지 않았다.
|
||||
|
||||
**(e) 배선된 축은 확실히 배선됐다**
|
||||
|
||||
- `DestinationProfileValidator` → `MessagingCoreAutoConfiguration:134`에서 `validateAll(registered)` 호출. 부팅을 실제로 막는다.
|
||||
- `MessagingAdmissionController` → `DefaultMessagePublisher`(발행 관문)·`MessagingEndpoint`(관측)·`MessagingShutdownLifecycle`(종료 1단계) 셋이 주입받는다.
|
||||
- `PayloadLimitGuard`·`InFlightLimiter`·`PayloadPolicy` → admission controller 안에서 실행된다.
|
||||
|
||||
**한계.** 정적 `git grep`이다. 리플렉션·`ObjectProvider` 지연 조회·`@Autowired` 필드 주입은 덮지 못한다. 다만 이 저장소의 messaging 자동설정은 전부 생성자 주입 `@Bean` 메서드이고(51개 전수 확인), `ObjectProvider`는 `MessageContracts`와 `MessagingTransport` 두 곳에만 쓰인다.
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
이 leaf에는 bean이 없다. 그러나 **starter 쪽 sibling 비교가 결정적이다.**
|
||||
|
||||
`MessagingCoreAutoConfiguration`의 27개 `@Bean` 중 이 leaf의 타입을 만드는 것은 셋이고, 조건이 전부 같다(`@ConditionalOnMissingBean`).
|
||||
|
||||
| bean | 조건 | 주입처 |
|
||||
|---|---|---|
|
||||
| `DestinationProfileValidator` | `@ConditionalOnMissingBean` | (직접 호출도 있음, :134) |
|
||||
| `MessagingAdmissionController` | `@ConditionalOnMissingBean` | **3곳** |
|
||||
| `RetryDecisionEngine` | `@ConditionalOnMissingBean` | **0곳** |
|
||||
| `DeadLetterOrchestrator` | `@ConditionalOnMissingBean` | **0곳** |
|
||||
|
||||
**조건은 같고 결과가 다르다.** 활성화 비대칭이 아니라 **소비 비대칭**이다 — 넷 다 똑같이 만들어지고 둘만 쓰인다. `@ConditionalOnMissingBean`은 "이미 있으면 만들지 마라"를 뜻할 뿐 "쓰이는지"를 말하지 않는다.
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
**(a) 재시도 메커니즘이 둘이고, 정교한 쪽이 배선되지 않았다**
|
||||
|
||||
| | `messaging-policy` | `messaging-runtime-core` |
|
||||
|---|---|---|
|
||||
| 구현 | `DefaultRetryDecisionEngine` | `DefaultDeliveryProcessor` |
|
||||
| 입력 | `RetryContext`(프로파일 + 전달 메타 + 실패 + capability) | `HandleResult` |
|
||||
| 재시도 판단 | 6개 모드, 8단 우선순위 | `Retry` → 무조건 requeue |
|
||||
| 지연 | `BackoffCalculator` — 지수 + full jitter + 상한 | 생성자로 받은 **고정 `retryDelay`** |
|
||||
| 시도 횟수 | `attempt >= maxAttempts` 확인 | **확인하지 않음** |
|
||||
| 순서 인식 | `orderingImpact`·`isOrdered()`·`capabilities` | 없음 |
|
||||
| DLQ | 5개 결정 중 하나 | `DeadLetter` → 발행 후 확인되면 ack |
|
||||
| **production 조립** | **없음** | **없음**(테스트만) |
|
||||
|
||||
둘 다 조립되지 않았으므로 오늘 경쟁하지 않는다. 그러나 소비 경로를 배선하려는 사람은 **두 개의 서로 다른 재시도 의미론** 중 하나를 골라야 하고, 어느 쪽이 정본인지 코드가 말하지 않는다. `DefaultDeliveryProcessor`의 javadoc은 자기가 "the platform decides when and in what order the settlement happens"를 실현한다고 말하고, `DefaultRetryDecisionEngine`의 javadoc은 자기 순서가 "fixed and evaluated top to bottom"이라고 말한다.
|
||||
|
||||
**(b) DLQ 경로가 둘**
|
||||
|
||||
| | `messaging-policy` | `messaging-runtime-core` |
|
||||
|---|---|---|
|
||||
| 구현 | `DeadLetterOrchestrator` | `DefaultDeliveryProcessor`의 `DeadLetterPublisher` 함수형 인터페이스 |
|
||||
| 순서 보장 | 확인 후 정산 (명시) | 확인 후 ack, 미확인이면 requeue (명시) |
|
||||
| 헤더 | 6개 예약 헤더 부착 | **부착하지 않음** |
|
||||
| 결과 | `DeadLetterResult(publishResult, sourceSettled)` | `SettlementResult` |
|
||||
|
||||
같은 불변식(확인 전 정산 금지)을 두 곳이 각자 구현한다. 그리고 **한쪽만 실패 컨텍스트를 헤더에 남긴다** — `DefaultDeliveryProcessor` 경로로 DLQ된 메시지는 왜 거기 있는지 알 수 없다.
|
||||
|
||||
**(c) 1 MiB 상한** — `PayloadPolicy.DEFAULT_MAX_BYTES`가 이 저장소 다섯 곳 중 정책 축의 주인인데 starter가 참조하지 않는다. `analysis/messaging/messaging-schema-json.md` §17이 소유한다.
|
||||
|
||||
**(d) 프로파일 검증기가 브로커별로 또 있다**
|
||||
|
||||
`RabbitProfileValidator`, `KafkaProfileValidator`, `KafkaTransactionProfileValidator`가 각 어댑터 leaf에 있고 starter가 bean으로 만든다. 이들은 **브로커 고유 제약**(exchange/queue 조합, 트랜잭션 설정)을 보므로 `DestinationProfileValidator`의 브로커 중립 규칙과 책임이 다르다. 중복이 아니라 계층이다. 다만 호출 순서가 어디에도 명시되지 않았다 — 중립 검증이 먼저인지 브로커 검증이 먼저인지는 starter leaf가 답한다.
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
| 문서 주장 | 재측정 | 결과 |
|
||||
|---|---|---|
|
||||
| `DestinationProfileValidator` javadoc: 모순은 부팅 실패 | `:134`에서 `validateAll` 호출 확인 | **일치** |
|
||||
| `MessagingAdmissionController` javadoc: "The single gate every publish passes" | `DefaultMessagePublisher`가 주입받아 호출 | **일치** |
|
||||
| `PhysicalDestination` javadoc: 물리 주소를 여기서만 보관 | leaf 밖 13파일이 참조하나 전부 `PhysicalDestination` 타입 경유 | **일치** |
|
||||
| `RetryPolicy` javadoc: 자동 재시도는 opt-in | `none()`이 `maxAttempts=1, mode=NONE` | **일치** |
|
||||
| `InFlightLimiter` javadoc: "Section 40.3 of the design specifies…" | 그 설계 문서를 이 저장소에서 찾지 못함 | **미확인** — 아래 참조 |
|
||||
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) |
|
||||
|
||||
**`InFlightLimiter`의 "Section 40.3"이 가리키는 문서를 찾지 못했다.** `docs/messaging/` 아래 10개 파일과 `docs/superpowers/plans/2026-08-10-messaging-platform-implementation-plan.md`에 절 번호 40.3이 없다. 저장소 밖 설계 문서이거나 이전 버전의 흔적이다. 인용된 문구("bounded wait, then `MessageBackpressureException`")는 코드와 일치하므로 내용 drift는 아니고, **참조가 해소되지 않는다**는 것이 관측이다.
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
이 leaf의 주석은 이전 결함보다 **왜 이 형태여야 하는가**를 더 많이 적는다. 그중 이전 상태를 직접 서술하는 것은 셋이다.
|
||||
|
||||
| 위치 | 이전 상태 | 그것이 만든 실패 |
|
||||
|---|---|---|
|
||||
| `validateAll` 주석 | retry 그래프와 DLQ 그래프를 따로 순회 | A의 retry가 B를, B의 DLQ가 A를 가리키는 교대 사이클을 둘 다 통과시킴 → poison 메시지가 두 목적지 사이를 영원히 순환 |
|
||||
| `admit`의 `InterruptedException` 주석 | 인터럽트 시 목적지 슬롯 미반납 | 인터럽트마다 슬롯이 새서 목적지 천장이 0까지 줄어듦 |
|
||||
| `complete` 주석 | 미보유 목적지에도 프로세스 permit 반납 | 아무도 안 가져간 permit을 돌려줘 전역 천장이 실제 in-flight보다 낮게 읽힘 → 감당 못 할 만큼 승인 |
|
||||
| `release` 주석 | 0인 카운터를 맵에 잔류 | 발행한 적 있는 모든 목적지의 카운터가 프로세스 수명 동안 누적 |
|
||||
| `InFlightLimiter.release` 주석 | 불균형 반납 허용 | 천장이 조용히 올라가 limiter가 아무것도 제한하지 않음 |
|
||||
|
||||
세 번째와 다섯 번째가 같은 형태다 — **반납이 획득보다 많으면 제한이 사라진다.** `messaging-transport-spi`의 `GracefulShutdownCoordinator.endWork` clamp와 `DefaultMessagingRuntimeRegistry`의 "정확히 한 번 close"도 같은 계열이고, 그 leaf §13이 소유한다. 저장소 전체에서 반복되는 주제다.
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-281 | command | `evidence/raw/281-messaging-policy-retry-engine-unwired.txt` | 26개 타입 참조 수, 두 bean의 선언, 그 두 타입을 받는 코드 전수, 해당 dependent가 NEVER CONSTRUCTED, 발행 경로 대조군, 소비 경로 src/main=0, `RetryDecision` 실행자 목록, 호출되는 시작 게이트 | 정적 `git grep`. 리플렉션·지연 조회 미포함. **정규화된 생성자 이름을 포함하는 패턴으로 재실행한 결과** |
|
||||
| EVD-282 | command | `./gradlew :messaging:messaging-policy:test --rerun-tasks` | BUILD SUCCESSFUL, 42 / 0 / 0 | 순수 단위. 브로커·Spring 컨텍스트 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적**
|
||||
|
||||
- 모순을 부팅 실패로 옮기는 이유 — `DestinationProfileValidator` javadoc
|
||||
- 두 간선을 한 그래프로 순회하는 이유와 다이아몬드 오탐 방지 — `validateAll`/`walk` 주석
|
||||
- payload 검사가 permit 획득보다 먼저인 이유 — `MessagingAdmissionController` javadoc
|
||||
- 천장이 둘인 이유 — 같은 javadoc
|
||||
- 거절이 모호하지 않은 이유 — 같은 javadoc
|
||||
- 세 가지 누수 방지 각각의 이유 — 세 개의 인라인 주석
|
||||
- fair semaphore와 불균형 반납 방지 — `InFlightLimiter` 주석
|
||||
- 재시도 판단 순서가 고정된 이유 — `DefaultRetryDecisionEngine` javadoc
|
||||
- capability가 입력인 이유 — `RetryContext` javadoc
|
||||
- 자동 재시도가 opt-in인 이유 — `RetryPolicy` javadoc
|
||||
- full jitter를 쓰는 이유 — `BackoffCalculator` javadoc
|
||||
- DLQ 발행 후 정산 순서와 그 trade — `DeadLetterOrchestrator` javadoc
|
||||
- DLQ 메타데이터를 작게 두는 이유 — `DeadLetterMetadata` javadoc
|
||||
- 물리 주소를 이 leaf에 가두는 이유 — `PhysicalDestination` javadoc
|
||||
- Pulsar 구독명·NATS 스트림이 주소의 일부인 이유 — 두 factory javadoc
|
||||
|
||||
**추론**
|
||||
|
||||
- 재시도 엔진과 DLQ 조정자가 미배선인 것은 소비 경로 전체에 조립이 없기 때문이다 → **추론**. 조립 부재는 관측이고 인과는 추론이다. 커밋 메시지나 ADR에 소비 경로를 나중으로 미룬 기록이 없다.
|
||||
- `firstFailureAt`과 `lastFailureAt`을 같은 값으로 채우는 것이 임시인지 → **미상**.
|
||||
- 브로커별 검증기와 중립 검증기의 호출 순서 → **미상**(starter leaf가 소유).
|
||||
|
||||
**관측했으나 원인을 모름**
|
||||
|
||||
- `InFlightLimiter` javadoc이 인용하는 "Section 40.3"의 출처
|
||||
- `MessagingConfigurationException`이 존재하는데 프로파일 검증이 `IllegalArgumentException`을 쓰는 이유
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- 26개 타입 1,738줄 전문의 계약과 불변식
|
||||
- 42개 테스트가 통과하고 무엇을 단언하는지
|
||||
- 다섯 축 중 셋(목적지 정의·시작 검증·발행 관문)이 출하 컨텍스트에서 실제로 실행된다는 것과 그 정확한 배선 지점
|
||||
- 두 축(재시도 판단·DLQ 조정)이 bean으로 생성되고 주입처가 0이라는 것 — 그리고 그 이유가 소비 경로 전체의 조립 부재라는 것
|
||||
- `RetryDecision`을 실행하는 코드가 저장소에 하나뿐이며 그것이 생성되지 않는다는 것
|
||||
- 재시도와 DLQ 각각에 대해 두 개의 서로 다른 구현이 존재한다는 것
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- **소비 경로를 배선할 계획이 있는지.** 저장소 안에 답이 없다. 두 재시도 구현 중 어느 쪽이 정본인지도 이 미지수에 걸린다.
|
||||
- 실제 부팅에서 `validateAll`이 어떤 프로파일 집합을 받는지 — `ValidatedDestinationRegistry`가 무엇을 채우는지는 starter leaf가 소유한다.
|
||||
- `walk`의 지수적 복사 비용이 실제 구성에서 문제가 되는 규모. 목적지 수가 큰 배포를 관측하지 못했다.
|
||||
- `InFlightLimiter`의 fair semaphore가 실제 부하에서 주는 처리량 손실.
|
||||
- "Section 40.3"이 가리키는 문서.
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P2 — 재시도 엔진과 DLQ 조정자가 bean으로 만들어지고 주입되는 곳이 없다
|
||||
|
||||
- **사실.** `MessagingCoreAutoConfiguration`이 `RetryDecisionEngine`(:167)과 `DeadLetterOrchestrator`(:179)를 `@Bean @ConditionalOnMissingBean`으로 만든다. 두 타입을 받는 production 코드는 각각 `KafkaRetryExecutor`와 `KafkaDeadLetterPublisher`/`RabbitDeadLetterPublisher`뿐이고, **셋 다 저장소 어디에서도 생성되지 않는다.** 같은 설정의 51개 bean 중 두 타입을 인자로 받는 `@Bean` 메서드가 없다.
|
||||
- **근거.** `evidence/raw/281` §B·§C·§D.
|
||||
- **왜 문제인가.** 컨텍스트에 두 bean이 앉아 있고 `MessagingAutoConfigurationTest`류의 `hasSingleBean` 검사는 통과한다 — 즉 **bean 존재 검사가 배선을 증명하지 않는다.** 그리고 이 leaf가 가장 공들인 두 축(6개 재시도 모드·8단 판단 순서·full jitter·capability 인식, DLQ 발행-후-정산 불변식·예약 헤더 6개)이 실행되지 않는다. 42개 테스트 중 16개가 이 두 축을 검증한다.
|
||||
- **확인 방법.** `git grep -n -E 'new ([a-zA-Z0-9_.]+\.)?KafkaRetryExecutor\s*\(' -- src` → 매치 없음. `evidence/raw/281` §D 재실행.
|
||||
- **후보.** (a) 소비 경로를 조립한다(§17 다음 항목과 같은 작업). (b) 배선되기 전까지 두 bean을 만들지 않는다 — `@ConditionalOnBean`으로 실제 소비자에 매단다. (c) 미완임을 `support-matrix.md`에 표시한다.
|
||||
- **다음 단계.** **CASE 후보.** 재현이 정적이고 결론이 닫힌다. "bean이 있다"와 "배선됐다"의 구분이 그대로 **REFERENCE 후보**이기도 하다.
|
||||
|
||||
### P2 — 출하 컨텍스트가 발행은 하고 소비는 하지 못한다
|
||||
|
||||
- **사실.** `KafkaConsumerRegistrar`·`RabbitConsumerRegistrar`·`KafkaBatchConsumerRegistrar`·`RabbitBatchConsumerRegistrar`·`DefaultDeliveryProcessor`·`KafkaRetryExecutor`·`KafkaDeadLetterPublisher`·`RabbitDeadLetterPublisher`가 전부 `src/main` 생성 0이다. 대조군인 발행 경로(`DefaultMessagePublisher`·`TransportMessagingRuntime`)는 `MessagingCoreAutoConfiguration:446,476`에서 생성된다.
|
||||
- **근거.** `evidence/raw/281` §E·§F.
|
||||
- **왜 문제인가.** `messaging-policy`의 두 축이 미배선인 근본 원인이고, `analysis/messaging/messaging-core-api.md` §12.1이 관측한 `MessageHandler<T>` 참조 0의 조립 쪽 설명이다. 그리고 `docs/messaging/support-matrix.md`의 브로커 등급표가 소비 측 보장(순서·정산·재시도)을 서술하는데, 그 보장을 수행할 코드가 조립되지 않는다.
|
||||
- **확인 방법.** `evidence/raw/281` §F 재실행.
|
||||
- **후보.** 소비자 등록을 자동설정에 추가하거나, 소비 경로가 파생 프로젝트의 조립 책임임을 문서화한다.
|
||||
- **다음 단계.** **이 leaf가 아니라 cross-scope 또는 `messaging-spring-boot-starter` leaf가 소유해야 한다.** 여기서는 관측과 교차 참조만 남긴다. **OPEN QUESTION 후보**(소비 경로 조립이 미완인가, 의도적 확장점인가).
|
||||
|
||||
### P3 — 재시도와 DLQ 각각에 두 개의 구현이 있고 정본이 표시되지 않았다
|
||||
|
||||
- **사실.** 재시도: `DefaultRetryDecisionEngine`(6모드·백오프·순서 인식) vs `DefaultDeliveryProcessor`(고정 지연·시도 횟수 미확인). DLQ: `DeadLetterOrchestrator`(예약 헤더 6개 부착) vs `DefaultDeliveryProcessor.DeadLetterPublisher`(헤더 없음). 둘 다 조립되지 않았다.
|
||||
- **근거.** §12.3(a)(b). `DefaultDeliveryProcessor.java:38-99`.
|
||||
- **왜 문제인가.** 오늘 경쟁하지 않지만, 소비 경로를 배선하는 사람이 둘 중 하나를 고르게 되고 코드가 어느 쪽이 정본인지 말하지 않는다. 두 javadoc이 각각 자기가 플랫폼 규칙의 구현이라고 서술한다. 그리고 선택 결과가 다르다 — `DefaultDeliveryProcessor` 경로로 DLQ된 메시지에는 실패 카테고리·코드·원본 목적지·시도 횟수가 붙지 않는다.
|
||||
- **확인 방법.** 두 클래스의 javadoc과 분기 대조.
|
||||
- **후보.** `DefaultDeliveryProcessor`가 `RetryDecisionEngine`과 `DeadLetterOrchestrator`를 위임받도록 합치거나, 한쪽을 제거한다.
|
||||
- **다음 단계.** **CASE 후보**(같은 책임의 두 구현이 서로를 모른다). `messaging-runtime-core` leaf SSOT와 공동 소유.
|
||||
|
||||
### P3 — DLQ 메타데이터의 두 시각이 항상 같다
|
||||
|
||||
- **사실.** `DeadLetterMetadata`가 `firstFailureAt`과 `lastFailureAt`을 별도 필드로 선언하는데, 유일한 생산 지점인 `DeadLetterOrchestrator:89-97`이 둘 다 `delivery.metadata().receivedAt()`으로 채운다.
|
||||
- **근거.** 해당 라인.
|
||||
- **왜 문제인가.** 두 헤더(`msg.first-failure-at`, `msg.last-failure-at`)가 DLQ 메시지에 붙는데 항상 같은 값이다. 운영자가 "이 메시지가 얼마나 오래 실패해 왔는가"를 헤더에서 알 수 없다. `ReservedHeaders`가 두 이름을 따로 정의한 목적이 실현되지 않는다.
|
||||
- **확인 방법.** `DeadLetterOrchestrator.java:89` 확인.
|
||||
- **후보.** 이전 시도의 `msg.first-failure-at` 헤더가 있으면 그것을 이어받는다.
|
||||
- **다음 단계.** **CASE 후보.** 단, §17 첫 항목대로 이 코드는 실행되지 않으므로 오늘의 사고가 아니다.
|
||||
|
||||
### P3 — 사이클 검사가 경로마다 집합을 복사한다
|
||||
|
||||
- **사실.** `walk`가 각 분기마다 `new LinkedHashSet<>(onPath)`와 `new ArrayList<>(path)`를 만든다. 비용이 경로 수에 비례하고, 경로 수는 분기 계수에 지수적이다.
|
||||
- **근거.** `DestinationProfileValidator.java:196-198`.
|
||||
- **왜 문제인가.** 정상 구성(목적지 수십 개, 목적지당 간선 0–2개)에서는 무해하다. 다만 이 성질이 어디에도 기록되지 않았고, `validateAll`은 **부팅 경로**다. 목적지가 수백 개인 배포에서 부팅이 느려지면 원인을 찾기 어렵다.
|
||||
- **확인 방법.** 코드 검토. 목적지 수를 늘려가며 `validateAll` 시간을 측정.
|
||||
- **후보.** 방문 상태를 색칠(white/gray/black)로 바꾸면 복사 없이 O(V+E)가 된다.
|
||||
- **다음 단계.** **REFERENCE 후보**(부팅 경로의 알고리즘 복잡도는 문서화한다).
|
||||
|
||||
### P3 — 프로파일 검증 실패가 플랫폼 예외 계층 밖이다
|
||||
|
||||
- **사실.** `DestinationProfileValidator`의 16개 거절이 전부 `IllegalArgumentException`이다. `MessagingConfigurationException`이 존재하고 그 javadoc이 "Raised at startup wherever possible"이라고 적는다.
|
||||
- **근거.** `DestinationProfileValidator` 전문, `MessagingConfigurationException` javadoc.
|
||||
- **왜 문제인가.** 부팅 실패이므로 실무 영향은 낮다. 다만 `FailureDescriptor`가 없어 코드·카테고리가 붙지 않고, 같은 leaf의 `DeadLetterOrchestrator`는 `MessagingConfigurationException("DEAD_LETTER_NOT_CONFIGURED")`을 쓴다 — 같은 leaf 안에서 구성 오류를 두 방식으로 보고한다.
|
||||
- **확인 방법.** 두 클래스의 throw 문 대조.
|
||||
- **후보.** 검증 실패를 `MessagingConfigurationException`으로 통일하고 규칙별 안정 코드를 준다.
|
||||
- **다음 단계.** **REFERENCE 후보**(구성 오류는 한 예외 타입과 안정 코드로 보고한다).
|
||||
|
||||
### P3 — javadoc이 해소되지 않는 설계 문서를 인용한다
|
||||
|
||||
- **사실.** `InFlightLimiter` javadoc이 "Section 40.3 of the design specifies 'bounded wait, then `MessageBackpressureException`'"이라고 적는다. 그 절 번호를 가진 문서를 이 저장소에서 찾지 못했다.
|
||||
- **근거.** `InFlightLimiter.java:11-13`. `docs/messaging/*.md` 10개와 계획 문서에 절 40.3 없음.
|
||||
- **왜 문제인가.** 인용된 내용은 코드와 일치하므로 내용 drift는 아니다. 다만 근거를 확인하려는 사람이 도달할 수 없다.
|
||||
- **확인 방법.** `git grep -n '40\.3' -- docs`
|
||||
- **후보.** 참조를 실제 문서로 바꾸거나 인용만 남기고 절 번호를 뺀다.
|
||||
- **다음 단계.** **REFERENCE 후보**(저장소 밖 문서를 절 번호로 인용하지 않는다).
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- 모순을 부팅 실패로 옮기는 16가지 규칙과, 그것이 실제로 시작 시 호출된다는 것
|
||||
- retry와 DLQ 간선을 하나의 그래프로 순회하고 다이아몬드를 오탐하지 않는 것
|
||||
- payload 검사를 permit 획득보다 먼저 두는 것
|
||||
- 두 개의 천장과 세 가지 슬롯 누수 방지
|
||||
- fair semaphore와 불균형 반납 차단
|
||||
- capability를 재시도 판단의 입력으로 두어 수행 불가능한 전략을 고르지 않는 것
|
||||
- 모든 기본값이 보수적인 것(재시도 없음·동시성 1·순서 보존·확인 최대)
|
||||
- DLQ 발행이 확인되기 전에는 원본을 정산하지 않는 것과 그 trade를 명시한 것
|
||||
- DLQ 헤더에 `ReservedHeaders` 상수를 쓰고 `MessageHeaders.platform`을 쓰는 것
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MPO-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 2개, memberships `["app-bootstrap"]` | 선언 |
|
||||
| MPO-002 | build | `messaging-policy/build.gradle` | same | 벤더 의존성 0 | — |
|
||||
| MPO-003 | code | `.../policy/DestinationProfileValidator.java` 전문 | same | §4.1 16규칙, §4.2 이중 간선 그래프 | 복잡도 미문서화(§17) |
|
||||
| MPO-004 | code | `.../policy/MessagingAdmissionController.java` 전문 | same | §4.3 순서·두 천장·세 누수 방지 | — |
|
||||
| MPO-005 | code | `.../policy/InFlightLimiter.java` | same | fair semaphore, 불균형 반납 차단 | "Section 40.3" 미해소 |
|
||||
| MPO-006 | code | `.../policy/DefaultRetryDecisionEngine.java` | same | §4.4 8단 판단 순서, capability 입력 | production 호출 없음(§12.1) |
|
||||
| MPO-007 | code | `.../policy/{RetryPolicy,RetryMode,RetryDecision,RetryContext,BackoffCalculator,OrderingImpact}.java` | same | 재시도 어휘 전체 | — |
|
||||
| MPO-008 | code | `.../policy/DeadLetterOrchestrator.java` | same | §4.7 발행-후-정산 불변식 | production 호출 없음(§12.1) |
|
||||
| MPO-009 | code | `.../policy/{DeadLetterEnvelopeFactory,DeadLetterMetadata,DeadLetterPolicy,DeadLetterResult,SourceSettlement}.java` | same | DLQ 봉투와 메타데이터 | 두 시각이 항상 같음(§17) |
|
||||
| MPO-010 | code | `.../policy/{DestinationProfile,PhysicalDestination,SchemaPolicy,ProducerPolicy,ConsumerPolicy,PayloadPolicy,CapabilityTier}.java` | same | 목적지 정의 8타입과 기본값 | — |
|
||||
| MPO-011 | test | `DestinationProfileValidatorTest` (13) | same | 규칙별 거절, 교대 사이클, 다이아몬드 | — |
|
||||
| MPO-012 | test | `MessagingAdmissionControllerTest` (13) | same | 관문 동작 전수 | 실부하 아님 |
|
||||
| MPO-013 | test | `RetryDecisionEngineTest` (10) | same | 판단 순서와 백오프/지터 | 배선 미증명 |
|
||||
| MPO-014 | test | `DeadLetterOrchestratorTest` (6) | same | 정산 순서 불변식 | 배선 미증명 |
|
||||
| MPO-015 | assembly | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:134,145,167,179,407,446,476` | same | 배선된 것과 만들어지기만 한 것 | 해당 leaf SSOT가 소유 |
|
||||
| MPO-016 | cross-leaf code | `messaging-kafka/.../KafkaRetryExecutor.java` | same | `RetryDecision`의 유일한 실행자 | 생성되지 않음 |
|
||||
| MPO-017 | cross-leaf code | `messaging-runtime-core/.../DefaultDeliveryProcessor.java` | same | 경쟁하는 재시도/DLQ 구현 | 해당 leaf SSOT가 소유 |
|
||||
| EVD-281 | command | `evidence/raw/281-messaging-policy-retry-engine-unwired.txt` | same | §12.1 전부 | 정적 검색. 정규화 생성자 패턴 사용 |
|
||||
| EVD-282 | command | `./gradlew :messaging:messaging-policy:test --rerun-tasks` | same | 42 / 0 / 0 | 순수 단위 |
|
||||
@@ -0,0 +1,296 @@
|
||||
# messaging-pulsar-experimental 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 — `src/main` production 8파일 663줄, test 2파일 414줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-pulsar-experimental`
|
||||
> SSOT owner: `messaging-pulsar-experimental`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- 선언 의존: messaging 계열 project 7 + vendor `pulsar-client:4.0.3`
|
||||
- `runtime_memberships`: **`[]`** — build-only · 등급 EXPERIMENTAL
|
||||
|
||||
| 파일 | LOC |
|
||||
|---|---:|
|
||||
| `PulsarMessagingTransport` | 275 |
|
||||
| `PulsarProfile` | 80 |
|
||||
| `PulsarProfileValidator` | 66 |
|
||||
| `PulsarPreSendRejection` | 65 |
|
||||
| `PulsarSubscriptionMode` | 62 |
|
||||
| `PulsarTransactionCapability` · `PulsarMessagePosition` | 49 · 49 |
|
||||
| `PulsarSubscriptionType` | 17 |
|
||||
| **main 합계** | **663** |
|
||||
| `PulsarAdapterContractTest` · `PulsarSubscriptionGuardTest` | 289 · 125 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 8 | `FULL_READ` | 663줄 전 본문 |
|
||||
| `test/java/**` | 2 | `FULL_READ` | 414줄 전 본문 · 테스트 27개 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 전문 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 이 어댑터가 무엇이고 무엇이 아닌가
|
||||
|
||||
> "This is an Experimental contract seam, not a Stable adapter. It exercises the transport SPI
|
||||
> against a send operation the application supplies; it does not ship a Pulsar client bridge,
|
||||
> producer lifecycle, or reconnection."
|
||||
|
||||
전송은 `PulsarSendOperation` 함수형 인터페이스로 주입된다 — 브로커 없이 검증 가능하게 만든 격리다.
|
||||
|
||||
## 2. 실패 분류 — 타입 있는 신호만 본다
|
||||
|
||||
```java
|
||||
if (cause instanceof PulsarPreSendRejection rejection) → REJECTED (CONFIGURATION)
|
||||
boolean timedOut = cause instanceof TimeoutException;
|
||||
→ 나머지 전부 AMBIGUOUS (TRANSIENT_INFRASTRUCTURE)
|
||||
```
|
||||
|
||||
javadoc 이 이전 구현과 그 결함을 적는다.
|
||||
|
||||
> "Classification used to read the exception's class simple name: `"Timeout"` meant ambiguous,
|
||||
> anything else meant rejected. A class name is not part of Pulsar's contract — it changes between
|
||||
> client versions — and defaulting the unknown case to `REJECTED` tells the caller nothing was
|
||||
> transmitted, which is how the same entry is published to the bookies twice."
|
||||
|
||||
기본값이 모호로 바뀐 것이 핵심이다. 알 수 없는 실패에서 안전한 방향은 모호다.
|
||||
|
||||
확인된 성공은 복제 증거로 기록된다 — 전송 미래가 설정된 수의 저장 노드에 기록된 뒤에야 해소되므로 영수증이 아니라 복제 증거다.
|
||||
|
||||
## 3. 호출자의 마감을 존중한다
|
||||
|
||||
```java
|
||||
send.send(profile.topic(), request).toCompletableFuture()
|
||||
.orTimeout(request.options().timeout().toMillis(), MILLISECONDS)
|
||||
```
|
||||
|
||||
주석이 이유를 적는다 — 멈춘 전송이 호출자가 요청한 마감이 아니라 SDK 기본값만큼 호출자를 붙들고 있었다.
|
||||
|
||||
## 4. 구독 형태가 보장을 결정한다
|
||||
|
||||
`PulsarSubscriptionMode` 가 구독 종류와 확인 방식을 함께 묶고 두 조합을 생성자에서 거부한다.
|
||||
|
||||
> "A `Key_Shared` subscription with cumulative acknowledgement is not keyed ordering with a faster
|
||||
> ack — cumulative ack over interleaved keys acknowledges messages from keys the consumer has not
|
||||
> finished, so the combination silently loses the property the subscription type was chosen for."
|
||||
|
||||
그리고 검증기가 목적지의 순서 범위와 구독 종류의 합의를 요구한다. 목적지 전체 순서는 아예 거부한다.
|
||||
|
||||
## 5. 트랜잭션은 주석이 아니라 클래스로 거절한다
|
||||
|
||||
> "Pulsar has transactions. The platform does not offer them, and the distinction matters enough to
|
||||
> be a class rather than a comment: an operator reading the capability matrix needs to know the
|
||||
> answer is 'not proven here', not 'the broker cannot do it'."
|
||||
|
||||
그리고 거절을 던지지 않고 값으로 돌려준다 — 호출부에서 `throw` 가 보이게 하기 위해서다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
두 테스트 414줄 · 27개.
|
||||
|
||||
`PulsarAdapterContractTest` 14개 — 복제 증거로서의 확인, 위치 반환, 시간 초과의 모호, 타입 있는 사전 거절만이 `NOT_TRANSMITTED`, 미인식 실패의 모호, 감싸인 실패의 모호, 호출자 마감, 적재물 상한, 닫힘, `register` 인자 검사, 능력 세 개.
|
||||
|
||||
`PulsarSubscriptionGuardTest` 13개 — 누적 확인 조합 거부 둘, 순서 범위 둘, 영 지연 거부, 확인 시간 초과 하한, 기본 프로파일이 확인 시간 초과를 끄는 것, 트랜잭션 미승격 둘, 위치 렌더링 셋, 그리고 §17.3 이 다루는 마지막 하나.
|
||||
|
||||
전송은 `(topic, request) -> CompletionStage<PulsarMessagePosition>` 람다로 주입된다. 성공·실패·영영 안 끝남을 테스트가 직접 만든다.
|
||||
|
||||
**레인에 없는 것 둘.** `orderedStream()` 을 확인하는 단언이 하나도 없다 — §17.1 의 어긋남이 살아남은 자리다. 그리고 `register(spec)` 를 실제 spec 으로 부르는 테스트가 없어서, 기본 소비자 팩토리가 던지는 `PULSAR_CONSUMER_NOT_CONFIGURED` 는 한 번도 실행되지 않는다(§17.3).
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** build-only · experimental. `PulsarMessagingTransport` 는 자기 테스트에서만 만들어진다.
|
||||
|
||||
리프 밖에서 `dev.caskeleton.messaging.pulsar` 가 등장하는 곳은 전부 **이름 문자열**이다 — `config/architecture/modules.json`, `messaging-testkit/CompatibilityMatrix`, 그리고 그것을 읽는 두 테스트. 그중 `CrossBrokerContractSuite:110-113` 이 이 어댑터의 상태를 명시적으로 못 박는다.
|
||||
|
||||
```java
|
||||
assertThat(matrix.isComplete("messaging-pulsar-experimental")) … ;
|
||||
assertThat(matrix.gapsFor("messaging-pulsar-experimental")).isNotEmpty();
|
||||
```
|
||||
|
||||
즉 플랫폼의 호환성 표가 이 어댑터를 "빈칸이 있는 상태"로 기록하고 있고, 그것을 테스트가 지킨다. 등급 표기와 실제 상태가 어긋나면 저 테스트가 깨진다.
|
||||
|
||||
**12.2 `PulsarProfileValidator` 는 선언 말고 아무 데도 없다.**
|
||||
|
||||
```
|
||||
$ grep -rn PulsarProfileValidator --include=*.java src/
|
||||
src/…/pulsar/PulsarProfileValidator.java:20: public final class PulsarProfileValidator {
|
||||
```
|
||||
|
||||
한 줄. 자기 선언뿐이다 — 리프 밖 참조가 없는 정도가 아니라 **리프 안 참조도, 테스트도 없다.** 그래서 §4 가 서술하는 "검증기가 목적지의 순서 범위와 구독 종류의 합의를 요구한다"는 판단은 코드로 적혀 있을 뿐 한 번도 실행된 적이 없다.
|
||||
|
||||
자매 어댑터(NATS)의 검증기도 같은 상태다(그쪽 §17.3). 다만 그쪽은 전송 javadoc 이 `{@link}` 로 가리키기라도 하는데, 이쪽은 그것조차 없다.
|
||||
|
||||
**12.3 `cumulativeAcknowledgement = true` 를 만들 수 있는 조합이 없다.**
|
||||
|
||||
```java
|
||||
if (cumulativeAcknowledgement && subscriptionType == KEY_SHARED) throw …;
|
||||
if (cumulativeAcknowledgement && subscriptionType == SHARED) throw …;
|
||||
```
|
||||
|
||||
`PulsarSubscriptionType` 의 값은 그 둘뿐이다. 그러므로 이 record 의 두 번째 성분은 `false` 만 가질 수 있다.
|
||||
|
||||
의도의 흔적은 남아 있다 — `PulsarSubscriptionType` javadoc 이 `Exclusive` 와 `Failover` 를 "의도적으로 뺐다"고 적는데, Pulsar 에서 누적 확인이 정당한 것이 정확히 그 두 종류다. 즉 종류를 둘로 줄인 결정이 이 성분을 죽였다.
|
||||
|
||||
§4 는 이 짝지음을 "두 값이 함께 보장을 결정한다"고 서술한다. 지금 코드에서는 한 값이 다른 값을 언제나 결정한다. 두 거부 메시지가 서로 다른 이유를 대므로 문서로서는 살아 있고, 그래서 §17 이 아니라 여기에 적는다.
|
||||
|
||||
**12.4 드리프트.** 실험 등급 표기가 코드와 문서에서 일치한다. `PulsarTransactionCapability.PROMOTED = false` 와 두 능력 상수의 `brokerTransaction=false` 도 일치한다.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 실제 Pulsar 브로커를 띄우지 않았다. 이 리프가 클라이언트 브리지를 싣지 않으므로 그럴 대상도 없다.
|
||||
- §17.1 의 두 능력 답이 실제 재시도 선택을 어떻게 가르는지 실행으로 재현하지 않았다.
|
||||
- 테스트를 실행하지 않았다. 27개 전부 본문으로만 확인했다.
|
||||
- §17.3 의 두 테스트가 실제로 무엇을 통과시키는지 디버거로 확인하지 않았다. `register` 의 첫 줄 널 검사와 `assertThatThrownBy` 가 단언하는 예외 타입으로 판정했다.
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P2 — 같은 어댑터의 능력을 두 곳이 다르게 답하고, 런타임이 쓰는 쪽이 record 의 문서화된 의미와 어긋난다
|
||||
|
||||
전송이 답하는 값:
|
||||
|
||||
```java
|
||||
SHARED_CAPABILITIES = (true, true, true, true, false, false, true, true, false, false, true, true);
|
||||
KEY_SHARED_CAPABILITIES = (true, true, true, true, false, true, true, true, false, false, true, true);
|
||||
```
|
||||
|
||||
검증기가 답하는 값:
|
||||
|
||||
```java
|
||||
public MessagingCapabilities capabilities(PulsarSubscriptionType subscriptionType) {
|
||||
boolean keyed = subscriptionType == PulsarSubscriptionType.KEY_SHARED;
|
||||
return new MessagingCapabilities(true, true, true, true, keyed, keyed, true, true, false, false, true, true);
|
||||
}
|
||||
```
|
||||
|
||||
다섯 번째 성분이 갈린다.
|
||||
|
||||
| Key_Shared 에서 | `orderedStream` | `keyedOrdering` |
|
||||
|---|---|---|
|
||||
| `PulsarMessagingTransport.capabilities(...)` | **false** | true |
|
||||
| `PulsarProfileValidator.capabilities(...)` | **true** | true |
|
||||
|
||||
`MessagingCapabilities` 의 성분 문서가 판정 기준이다.
|
||||
|
||||
```
|
||||
@param orderedStream the destination preserves order inside an ordering unit
|
||||
@param keyedOrdering order is preserved per key
|
||||
```
|
||||
|
||||
Key_Shared 의 순서 단위는 키다. 그 단위 안에서 순서가 보존되므로 검증기 쪽이 문서화된 의미와 맞고, 전송 쪽은 `keyedOrdering=true` 이면서 `orderedStream=false` 라 자기 안에서 모순이다.
|
||||
|
||||
그리고 어긋난 쪽이 런타임이 읽는 쪽이다. `capabilities(DestinationName)` 이 SPI 메서드이고, `orderedStream` 은 이 저장소에서 production 코드가 실제로 읽는 세 능력 중 하나다 — `DefaultRetryDecisionEngine` 이 그 값이 있으면 순서 보존 재시도를 고른다.
|
||||
|
||||
결과적으로 Key_Shared 목적지가 키 단위 순서를 약속하면서 순서 보존 재시도를 받지 못한다.
|
||||
|
||||
**등급.** 리프가 미배선이라 오늘의 사고는 아니다. 두 답 중 하나를 고르는 것이 먼저이고, 그 다음이 한 곳에서만 답하게 만드는 것이다. 검증기의 `capabilities` 는 리프 밖 소비자가 없으므로 전송이 그것을 부르게 하는 쪽이 자연스럽다.
|
||||
|
||||
### 17.2 P3 — 닫힌 전송의 거절이 영구 업무 실패로 분류된다
|
||||
|
||||
```java
|
||||
private static TransportPublishResult rejectedLocally(String code, String message) {
|
||||
return new TransportPublishResult(new PublishResult(
|
||||
PublishCompletion.REJECTED, PublishEvidence.notTransmitted(), RoutingOutcome.NOT_APPLICABLE,
|
||||
Optional.empty(), 1, Duration.ZERO,
|
||||
Optional.of(FailureDescriptor.of(FailureCategory.PERMANENT_BUSINESS, code, message))));
|
||||
}
|
||||
```
|
||||
|
||||
두 호출자가 이 메서드를 쓴다.
|
||||
|
||||
```
|
||||
PAYLOAD_TOO_LARGE — 적재물이 상한을 넘음
|
||||
PULSAR_TRANSPORT_CLOSED — "the transport is shutting down"
|
||||
```
|
||||
|
||||
첫째는 영구 업무 실패가 맞다. 둘째는 아니다. 종료 중이라는 것은 이 세대의 사정이고, 다음 세대나 다른 인스턴스에서는 같은 메시지가 발행된다.
|
||||
|
||||
같은 파일의 `classify` 가 분류를 신중히 나눈다 — 사전 거절은 `CONFIGURATION`, 모호는 `TRANSIENT_INFRASTRUCTURE`. 닫힘만 그 규율 밖에 있다.
|
||||
|
||||
전송되지 않았다는 증거(`notTransmitted`)는 옳다. 어긋난 것은 범주뿐이다.
|
||||
|
||||
수정은 닫힘에 `TRANSIENT_INFRASTRUCTURE` 를 주거나, 두 호출자가 범주를 인자로 받게 하는 것이다.
|
||||
|
||||
### 17.3 P3 — 이름이 검사하지 않는 것을 검사한다고 말하는 테스트 둘
|
||||
|
||||
**하나.**
|
||||
|
||||
```java
|
||||
@Test
|
||||
void theValidatorAcceptsAKeyedProfileOnKeyShared() {
|
||||
assertThatCode(() -> new PulsarProfile(…, PulsarSubscriptionMode.keyShared(), …))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
```
|
||||
|
||||
본문에 `PulsarProfileValidator` 가 없다. 만들지도, 부르지도 않는다. 확인하는 것은 `PulsarProfile` 생성자가 키 공유 모드를 거부하지 않는다는 사실뿐이다.
|
||||
|
||||
이 리프에서 검증기를 언급하는 유일한 테스트 이름이 이것이고(§12.2), 그래서 이름만 읽으면 검증기에 커버리지가 있다고 읽힌다.
|
||||
|
||||
**둘.**
|
||||
|
||||
```java
|
||||
@Test
|
||||
void aTransportWithoutAConsumerFactoryRefusesToRegisterRatherThanReturningNothing() {
|
||||
assertThatThrownBy(() -> confirming().register(null)).isInstanceOf(NullPointerException.class);
|
||||
}
|
||||
```
|
||||
|
||||
이름이 말하는 것은 "소비자 팩토리 없이 만든 전송이 등록을 거절한다"이다. 그 거절은 4-인자 생성자가 심어 두는 기본 팩토리에 있다.
|
||||
|
||||
```java
|
||||
spec -> { throw new MessagingCapabilityUnavailableException(
|
||||
"PULSAR_CONSUMER_NOT_CONFIGURED", "this Pulsar transport was created without a consumer factory"); }
|
||||
```
|
||||
|
||||
그런데 테스트는 `register(null)` 을 부른다. `register` 첫 줄의 `Objects.requireNonNull(spec, …)` 에서 `NullPointerException` 이 나고, 팩토리까지 가지 않는다. 단언하는 예외 타입도 `NullPointerException` 이지 `MessagingCapabilityUnavailableException` 이 아니다.
|
||||
|
||||
결과적으로 `PULSAR_CONSUMER_NOT_CONFIGURED` 는 이 저장소에서 한 번도 실행되지 않는 코드다.
|
||||
|
||||
**왜 P3 인가.** 어느 쪽도 잘못된 동작을 통과시키지 않는다 — 두 테스트가 확인하는 것은 사실이다. 문제는 커버리지 지도가 틀렸다는 것이고, 그래서 §12.2 의 "검증기에 호출자가 없다"가 지금까지 눈에 띄지 않았다.
|
||||
|
||||
**수정.** 첫째는 `new PulsarProfileValidator().validate(profile, KEY_SHARED, true)` 를 부르고, 키 순서 목적지를 `SHARED` 로 넘겼을 때 거부되는 짝 테스트를 붙인다. 둘째는 유효한 `TransportConsumerSpec` 을 넘겨 `MessagingCapabilityUnavailableException` 과 그 코드를 단언한다. 두 수정 모두 새 production 코드를 요구하지 않는다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **알 수 없는 실패의 기본값을 모호로 둔 것과, 이전 구현의 결함을 javadoc 에 남긴 것.**
|
||||
- **클래스 이름이 아니라 타입 있는 신호로 분류하는 것** — 클래스 이름은 클라이언트 판본 사이에서 바뀐다.
|
||||
- **확인을 복제 증거로 기록한 것** — 영수증과 구분한다.
|
||||
- **호출자의 마감을 `orTimeout` 으로 존중하는 것.**
|
||||
- **구독 종류와 확인 방식을 한 record 로 묶고 두 조합을 생성자에서 거부한 것.**
|
||||
- **트랜잭션 미승격을 클래스로 표현하고, 거절을 던지지 않고 값으로 돌려주는 것.**
|
||||
- **전송 연산을 함수형 인터페이스로 분리해 브로커 없이 검증 가능하게 만든 것.**
|
||||
- **확인 시간 초과를 기본에서 끄고 그 이유를 적은 것** — "an ack timeout redelivers messages from handlers that are merely slow." 테스트가 기본값이 비어 있음을 지킨다.
|
||||
- **음수 확인 재배달 지연이 곧 백오프라는 것을 밝히고 0 을 거부한 것** — 0 은 실패하는 핸들러를 브로커 대상 스핀 루프로 바꾼다.
|
||||
- **확인 시간 초과 하한을 Pulsar 자신의 하한(10초)으로 둔 것** — 브로커가 어차피 거부할 값을 시작 시점에 거부한다.
|
||||
- **메시지 위치를 불투명 문자열이 아니라 네 조각으로 분해해 들고 있는 것** — 배치 메시지는 id 를 공유하므로 `batchIndex` 가 개별 메시지를 주소 지정 가능하게 만드는 유일한 조각이다.
|
||||
- **`Exclusive` · `Failover` 구독을 노출하지 않은 것과 그 근거** — 목적지 프로파일이 이미 소유한 토폴로지 결정을 두 곳에서 설정하게 만들지 않는다. (그 결정의 부작용은 §12.3.)
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/messaging/messaging-pulsar-experimental/build.gradle
|
||||
main/java/…/pulsar/PulsarMessagingTransport.java:1-275
|
||||
main/java/…/pulsar/PulsarProfileValidator.java:1-66
|
||||
main/java/…/pulsar/PulsarSubscriptionMode.java:1-62
|
||||
main/java/…/pulsar/PulsarTransactionCapability.java:1-49
|
||||
main/java/…/pulsar/PulsarProfile.java:1-80
|
||||
main/java/…/pulsar/PulsarPreSendRejection.java:1-65
|
||||
main/java/…/pulsar/PulsarMessagePosition.java:1-49
|
||||
main/java/…/pulsar/PulsarSubscriptionType.java:1-17
|
||||
test/java/…/pulsar/PulsarAdapterContractTest.java:1-289
|
||||
test/java/…/pulsar/PulsarSubscriptionGuardTest.java:1-125
|
||||
src/messaging/messaging-testkit/…/CrossBrokerContractSuite.java:110-113 (호환성 표의 미완 기록)
|
||||
src/messaging/messaging-core-api/…/destination/MessagingCapabilities.java:11-36 (성분 의미)
|
||||
src/messaging/messaging-policy/…/DefaultRetryDecisionEngine.java (orderedStream 소비)
|
||||
```
|
||||
@@ -0,0 +1,404 @@
|
||||
# messaging-rabbit 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 20파일 2,443줄 + `src/test` 10파일 1,727줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-rabbit`
|
||||
> SSOT owner: `messaging-rabbit`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `runtime_memberships`: **`["app-bootstrap"]`** — 클래스패스에 올라간다
|
||||
- 도달성: **없다.** 제공자 선택이 `rabbit` 을 이름으로 거부한다(§12.1)
|
||||
|
||||
| 파일 | LOC | 참조 |
|
||||
|---|---:|---|
|
||||
| `RabbitConfirmCoordinator` | 279 | 전송 + 테스트 |
|
||||
| `RabbitMessagingTransport` | 246 | 테스트만 |
|
||||
| `RabbitConsumerRegistrar` | 238 | 테스트만 |
|
||||
| `RabbitDeliveryMapper` | 195 | 소비자 + 테스트 |
|
||||
| `RabbitHeaderMapper` | 180 | 매퍼 둘 + 테스트 |
|
||||
| `RabbitSecurityConfigurer` | 179 | 스타터 빈만 — 호출처 없음 |
|
||||
| `RabbitBatchConsumerRegistrar` | 165 | 테스트만 |
|
||||
| `RabbitTopologyProfile` | 137 | 네이티브 DLQ 능력 + 테스트 |
|
||||
| `RabbitDeadLetterPublisher` | 130 | **자기 파일 밖 참조 0** |
|
||||
| `RabbitPublishFailureClassifier` | 117 | 전송 + 테스트 |
|
||||
| `RabbitSettlementController` | 84 | 소비자 + 테스트 |
|
||||
| `RabbitProfileValidator` | 83 | 스타터 시작 검증 |
|
||||
| `RabbitPublishMapper` | 72 | 전송 |
|
||||
| `RabbitNativeDeadLetterCapability` | 70 | DLQ 발행자 + 테스트 |
|
||||
| `RabbitRetryQueueTopology` | 57 | 테스트만 |
|
||||
| `RabbitSettlementOperations` | 50 | 인터페이스 — 구현은 테스트 셋뿐 |
|
||||
| `RabbitBrokerProfile` | 50 | 설정 컴파일 |
|
||||
| `RabbitChannelPublisher` | 42 | 인터페이스 — **production 구현 0** |
|
||||
| `RabbitPublishReference` | 37 | 좌표 |
|
||||
| `RabbitRequestReply` | 32 | **자기 파일 밖 참조 0** |
|
||||
|
||||
main 총 **20파일 / 2,443줄**.
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 20 | `FULL_READ` | 2,443줄. 위 표가 전부 |
|
||||
| `test/java/**` | 10 | `FULL_READ` | 1,727줄 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 전문 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
> "참조" 열은 2026-09-01 재통독에서 저장소 전체 grep 으로 채웠다. 이전 판의 Source anchors 는 절반을 괄호 하나로 묶어 두었고, 그 괄호 안에 §17.2·§17.3·§17.4 가 있었다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 이 어댑터의 중심 — 확인과 반환은 다른 질문에 답한다
|
||||
|
||||
> "AMQP delivers a return *before* the confirm for an unroutable message, so a naive adapter that
|
||||
> completes on the confirm reports success for a message the broker threw away. The coordinator
|
||||
> therefore keeps each publish pending until the confirm arrives, and remembers whether a return was
|
||||
> seen first."
|
||||
|
||||
네 결과가 나온다 — 확인+미반환은 `CONFIRMED`, 확인+반환은 `UNROUTABLE` 로 `REJECTED`, 부정 확인은 `REJECTED`, 확인 미도착은 `AMBIGUOUS`.
|
||||
|
||||
이 리프에서 가장 중요한 판단이고, 실브로커 시험(`RabbitBrokerIT.anUnroutablePublishIsRejectedEvenThoughTheExchangeConfirmedIt`)이 그것을 붙든다. 그리고 그 시험이 production 에 없는 조각을 스스로 채워 넣는다(§17.2).
|
||||
|
||||
## 2. 자료구조 선택이 결함 수정이다
|
||||
|
||||
```java
|
||||
private final ConcurrentSkipListMap<Long, PendingPublish> pending = new ConcurrentSkipListMap<>();
|
||||
```
|
||||
|
||||
> "Ordered because a Rabbit confirm carries a `multiple` flag meaning 'everything up to and including
|
||||
> this tag'. Resolving one sequence per confirm — which is what a hash map forces — leaves every
|
||||
> earlier publish pending forever: the caller's stage never completes and the entry is never removed,
|
||||
> so the map grows for the life of the connection."
|
||||
|
||||
`confirmed(sequence, multiple=true, …)` 가 `headMap(sequence, true)` 로 범위를 해소한다. 전용 시험이 있다.
|
||||
|
||||
## 3. 부정 확인의 증거를 전송됨으로 기록한다
|
||||
|
||||
```java
|
||||
// A NACK is the broker's answer to a frame it received. Recording it as never sent contradicts the
|
||||
// very evidence that produced it, and a caller reading the evidence would conclude the message can
|
||||
// be re-sent freely.
|
||||
```
|
||||
|
||||
`REJECTED` + `TRANSMITTED` + `ConfirmationLevel.NONE`. 완결 상태와 전송 증거를 분리해서 다루는 곳이 이 가족에서 여기와 Kafka 뿐이다.
|
||||
|
||||
## 4. 소비·정착·죽은 편지의 세 규율
|
||||
|
||||
**좁은 catch.** `RabbitConsumerRegistrar.onMessage` 가 디코딩만 감싸는 안쪽 `try` 를 따로 둔다.
|
||||
|
||||
> "One catch around decode, the handler and the settlement meant a business failure or an ACK that
|
||||
> could not be written was recorded as an undecodable payload and discarded — a message that should
|
||||
> have been retried, deleted instead."
|
||||
|
||||
**정착하지 않은 핸들러.** 완료했는데 정착하지 않으면 대신 ack 하지 않고 requeue 한다 — "acknowledging on its behalf would silently drop it".
|
||||
|
||||
**네이티브 죽은 편지.** `RabbitNativeDeadLetterCapability` 가 두 조건을 모두 요구한다.
|
||||
|
||||
> "If the dead-letter exchange is unroutable — nobody bound a queue to it, or the binding was removed
|
||||
> — the broker discards the message silently and the reject still succeeds."
|
||||
|
||||
## 5. 자격증명은 연결 시도마다 해석된다
|
||||
|
||||
> "RabbitMQ client connections are long-lived and reconnect on their own, so a factory holding a
|
||||
> credential from startup will happily reconnect with a revoked one for as long as the process runs —
|
||||
> the reconnect is exactly the moment a rotated credential should take effect."
|
||||
|
||||
`AmqpCredentials` 가 record 가 아니라 class 인 이유도 적혀 있다 — 비밀을 지우려면 가변이어야 하고, record 가 `char[]` 를 동등성에 쓰면 같은 자재를 가진 둘이 서로 다르다고 판정된다.
|
||||
|
||||
## 6. 시작 검증
|
||||
|
||||
`RabbitProfileValidator` 가 여덟을 요구한다 — Stable 에 확인·반환·mandatory, 소비자 auto-ack 금지, prefetch ≥ 1, 확인 마감 양수, 운영에 TLS·인증. 그리고 목적지 검증이 둘 더 — 작업 큐에 쿼럼 큐, 교환기나 큐 중 하나.
|
||||
|
||||
Kafka 쪽과 달리 이 검증기는 스타터에서 `StartupProfileValidation` 으로 **감싸여 있다**. 다만 그 자동 설정 자체가 도달하지 않는다(§12.1).
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
10파일 1,727줄.
|
||||
|
||||
| 파일 | 줄 | 무엇을 붙드나 |
|
||||
|---|---:|---|
|
||||
| `RabbitRuntimeTest` | 309 | 전송 4경로 + 소비자 7경로(일시정지·배수·미디코딩·핸들러 실패·미정착·close) |
|
||||
| `RabbitContractHarness` | 304 | 공유 어댑터 계약을 production 조정자·정착 제어기 위에서 |
|
||||
| `RabbitBrokerIT` | 232 | 실브로커 `rabbitmq:4.3-management` — 반환-먼저-확인 |
|
||||
| `RabbitTopologyAndBatchTest` | 218 | 쿼럼 요구·DLX 논리·실패 분류·배치 누적 |
|
||||
| `RabbitProfileValidatorTest` | 184 | 검증기 여덟 규칙 |
|
||||
| `RabbitConfirmCoordinatorTest` | 167 | 상태 기계 12경로(다중 확인·채널 종료 포함) |
|
||||
| `RabbitEnvelopeRoundTripTest` | 152 | 헤더 왕복·위조 거부 |
|
||||
| `RabbitSettlementControllerTest` | 105 | 일회 종결·지연 재시도 큐 인자 |
|
||||
| `RabbitAdapterContractTest` · `RabbitFixtureProfiles` | 24 · 32 | 계약 실행·픽스처 |
|
||||
|
||||
`RabbitAdapterContractTest` 의 javadoc 이 이 레인의 요점을 적는다.
|
||||
|
||||
> "Two brokers with completely different machinery — offsets and commits versus delivery tags and
|
||||
> confirms — answering the same seven questions the same way is what makes the logical destination
|
||||
> abstraction real rather than aspirational."
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성 — 리프 전체가 production 호출자를 갖지 않는다.**
|
||||
|
||||
전송을 만들려면 `RabbitChannelPublisher` 구현이 필요하다. 저장소 전체에서 그 인터페이스의 구현은 **테스트의 익명 클래스 둘**(`RabbitRuntimeTest:49`, `RabbitBrokerIT:709`)뿐이다. 따라서 `new RabbitMessagingTransport(...)` 도 테스트에만 있고, `RabbitConsumerRegistrar`·`RabbitBatchConsumerRegistrar` 도 마찬가지다.
|
||||
|
||||
그리고 그 사실이 플랫폼 쪽에 이름으로 기록되어 있다.
|
||||
|
||||
```java
|
||||
// MessagingProviderSelection
|
||||
static final Map<String, String> BROKERS_WITHOUT_A_TRANSPORT = Map.of(
|
||||
"rabbit",
|
||||
"the Rabbit adapter ships its validators and security configuration but no MessagingTransport: "
|
||||
+ "its native channel publisher is not implemented, so a publish has nothing to travel on");
|
||||
```
|
||||
|
||||
`app.messaging.broker=rabbit` 은 시작 오류이고, 전용 시험이 그 메시지를 단언한다. 그래서 `RabbitMessagingAutoConfiguration` 98줄도 도달하지 않는다.
|
||||
|
||||
이 리프의 품질과 도달성이 정반대다. 코드는 이 가족에서 가장 정교한 축이고 — 반환-먼저-확인 상태 기계, `multiple` 범위 해소, 정착 일회성, 네이티브 DLQ 의 조건부 신뢰 — 실행 경로는 없다.
|
||||
|
||||
**12.2 리프 자체 기준으로도 죽은 둘.**
|
||||
|
||||
| 파일 | LOC | 상태 |
|
||||
|---|---:|---|
|
||||
| `RabbitDeadLetterPublisher` | 130 | 자기 파일 밖 참조 0 — production 도 테스트도 부르지 않는다 |
|
||||
| `RabbitRequestReply` | 32 | 인터페이스. 구현 0, 테스트 0, 호출 0 |
|
||||
|
||||
`RabbitDeadLetterPublisher` 가 담고 있는 것이 §4 의 세 번째 규율 — 네이티브 경로와 플랫폼 발행 중 어느 쪽을 쓸지 한 곳에서 결정한다는 판단 — 인데, 그 결정을 내리는 코드를 아무도 부르지 않는다. 그 판단의 근거가 되는 `RabbitNativeDeadLetterCapability` 는 테스트가 있다. 즉 **판단의 재료는 시험되고 판단 자체는 시험되지 않는다.**
|
||||
|
||||
`RabbitRequestReply` 는 M2 능력의 인터페이스 선언이다. javadoc 이 왜 제한적으로 제공하는지를 적는데("a synchronous call wearing an asynchronous costume"), 제공되는 것이 없다.
|
||||
|
||||
**12.3 대조군 — 재시도 헤더 오염의 처리가 두 어댑터에서 갈린다.** 두 어댑터의 `attemptOf` 는 같은 fail-closed 결정을 같은 문구로 적는다.
|
||||
|
||||
```java
|
||||
// "the message is quarantined rather than restarting its retry budget"
|
||||
throw new MessagingConfigurationException("RETRY_ATTEMPT_MALFORMED", …);
|
||||
```
|
||||
|
||||
그런데 소비자가 그 던짐을 받는 위치가 다르다.
|
||||
|
||||
| 어댑터 | `attemptOf` 호출 위치 | 결과 |
|
||||
|---|---|---|
|
||||
| Rabbit | `toMetadata` 안 → 디코딩 실패 `catch` 안쪽 | `operations.discard(tag, …)` — DLX 가 있으면 죽은 편지로 |
|
||||
| Kafka | 디코딩 `catch` **바깥**의 두 번째 블록 | `requeueAfterFailure()` → 무한 pause-and-seek(messaging-kafka §17.4) |
|
||||
|
||||
같은 판단, 반대 결과다. Rabbit 쪽이 javadoc 이 약속한 것에 가깝다.
|
||||
|
||||
**12.4 대조군 — `pause` 의 뜻이 SPI 하나 뒤에서 두 가지다.**
|
||||
|
||||
| 어댑터 | 반환 시점 | 의미 |
|
||||
|---|---|---|
|
||||
| Kafka | 다음 폴 주기 | `consumer.pause()` — 브로커에서 더 가져오지 않는다 |
|
||||
| Rabbit | 즉시 완료 | `onMessage` 가 `false` 를 답한다 — 리스너 컨테이너가 계속 밀고, 미확인으로 재배달된다 |
|
||||
|
||||
Kafka 쪽 javadoc 은 왜 즉시 완료하지 않는지를 명시한다("'paused' cannot be true until the loop says so"). Rabbit 쪽에는 그 대비 서술이 없다. §17.4.
|
||||
|
||||
**12.5 드리프트.** 검증기가 강제하는 항목과 어댑터가 실제로 보내는 플래그(`mandatory`)가 일치한다.
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 실제 브로커로 반환-먼저-확인 순서를 재현하지 않았다. `RabbitBrokerIT` 가 그 레인이고 컨테이너가 필요하다.
|
||||
- 지연 재시도 큐 토폴로지를 실제로 선언해 보지 않았다.
|
||||
- §17.2 를 실행으로 재현하지 않았다. `RabbitHeaderMapper.toProperties` 전문에 순번 헤더가 없다는 것과, `RabbitBrokerIT` 가 자기 publish 람다에서 `x-seq` 를 붙인다는 것으로 판정했다.
|
||||
- `RABBIT-CR-DEMO`(§17.3)를 실제 브로커에 붙여 보지 않았다. 이름과 RabbitMQ 의 기본 활성 상태로 판정했다.
|
||||
- `gradle.lockfile` 은 읽지 않았다(`STRUCTURAL_ONLY`).
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P3 — 확인 등급이 요구에서 파생되고, 그 요구를 뒷받침하는 강제는 목적지 종류 하나에만 걸린다
|
||||
|
||||
```java
|
||||
ConfirmationLevel level =
|
||||
requirement == ConfirmationRequirement.REPLICATION_OR_PERSISTENCE_ACK
|
||||
? ConfirmationLevel.REPLICATION_OR_PERSISTENCE_ACK
|
||||
: ConfirmationLevel.BROKER_ACK;
|
||||
```
|
||||
|
||||
증거의 등급이 브로커가 무엇을 했는지가 아니라 프로파일이 무엇을 **요구했는지** 에서 나온다.
|
||||
|
||||
대부분의 경우 이 파생은 성립한다. 두 강제가 그것을 받쳐 준다.
|
||||
|
||||
- `RabbitHeaderMapper.toProperties` 가 배달 모드를 무조건 `PERSISTENT` 로 둔다. RabbitMQ 는 지속 메시지를 디스크에 쓴 뒤에 확인한다.
|
||||
- `RabbitProfileValidator.validateDestination` 이 내구 작업 큐에 쿼럼 큐를 요구한다. 쿼럼 큐의 확인은 다수 복제 뒤에 온다.
|
||||
|
||||
빈틈은 둘째 강제의 범위다.
|
||||
|
||||
```java
|
||||
if (destination.kind() == DestinationKind.WORK_QUEUE && !broker.quorumQueues()) { throw …; }
|
||||
```
|
||||
|
||||
작업 큐가 아닌 목적지에는 쿼럼 요구가 없다. 교환기로 발행하는 목적지가 `REPLICATION_OR_PERSISTENCE_ACK` 를 요구하면, 그 교환기에 바인딩된 큐가 고전 큐여도 어댑터는 그 등급을 보고한다. 지속 모드 덕분에 디스크 기록은 보장되지만 복제는 보장되지 않는다.
|
||||
|
||||
이 저장소의 규율은 증거가 관측에서 나와야 한다는 것이다 — `MessagingCapabilities` 의 javadoc 이 "a silently weakened guarantee is indistinguishable from a working one until the incident" 라고 적는다.
|
||||
|
||||
수정은 쿼럼 요구를 목적지 종류가 아니라 **요구된 확인 등급** 에 걸거나, 작업 큐가 아닌 목적지에서는 등급을 `BROKER_ACK` 로 낮추는 것이다.
|
||||
|
||||
### 17.2 P2 — 반환을 순번에 맞추는 조각이 production 에 없고, 시험이 그 자리를 스스로 메운다
|
||||
|
||||
이 어댑터의 핵심 보장(§1)은 반환과 확인을 **같은 발행** 에 묶는 데 달려 있다. 묶는 열쇠는 순번이다.
|
||||
|
||||
```java
|
||||
public void returned(long sequence) { … } // RabbitConfirmCoordinator
|
||||
public void onReturn(long sequence) { … } // RabbitMessagingTransport
|
||||
```
|
||||
|
||||
그런데 AMQP 의 `basic.return` 콜백은 순번을 주지 않는다. 교환기·라우팅 키·속성·본문만 온다. 그래서 발행자가 순번을 메시지에 실어 보내고 반환에서 되읽어야 한다.
|
||||
|
||||
`RabbitHeaderMapper.toProperties` 전문에 그런 헤더가 없다. 쓰는 것은 `msg.*` 예약 헤더들과 AMQP 의 `messageId`·`correlationId`·`timestamp`·`deliveryMode` 뿐이다.
|
||||
|
||||
그 조각이 존재하는 곳은 시험 하나다.
|
||||
|
||||
```java
|
||||
// RabbitBrokerIT
|
||||
channel.addReturnListener(returned ->
|
||||
transport.onReturn(Long.parseLong(returned.getProperties().getHeaders().get("x-seq").toString())));
|
||||
…
|
||||
private static Map<String, Object> withSequence(MessageProperties source, long sequence) {
|
||||
headers.put("x-seq", Long.toString(sequence)); // ← 시험이 직접 붙인다
|
||||
}
|
||||
```
|
||||
|
||||
그 메서드의 javadoc 이 문제를 정확히 서술한다.
|
||||
|
||||
> "A returned message arrives without its publish sequence number, so the adapter has to carry one
|
||||
> itself to correlate the return with the pending publish."
|
||||
|
||||
"the adapter has to" 인데 어댑터는 하지 않는다. `RabbitChannelPublisher` 의 javadoc 은 **등록 경합**(확인이 `basicPublish` 반환보다 먼저 올 수 있다)만 설명하고 이 상관 문제는 언급하지 않는다.
|
||||
|
||||
결과는 이렇다. 언젠가 `RabbitChannelPublisher` 를 구현하는 사람은 이 헤더 규약을 다시 발명해야 하고, 발명하지 않으면 `onReturn` 이 호출되지 않아 unroutable 발행이 **`CONFIRMED` 로 보고된다** — 이 어댑터가 존재하는 이유로 든 바로 그 실패다.
|
||||
|
||||
수정은 순번 헤더를 `RabbitHeaderMapper` 나 `RabbitPublishMapper` 로 올려 production 계약으로 만들고, 그 이름을 `RabbitChannelPublisher` javadoc 에 적는 것이다. 지금은 그 규약이 시험 파일 20줄에만 있다.
|
||||
|
||||
### 17.3 P3 — SCRAM 자격을 RabbitMQ 의 데모 기구로 조용히 매핑한다
|
||||
|
||||
```java
|
||||
case BrokerCredentialProfile.SaslScram scram -> {
|
||||
CredentialRuntime resolved = credentials.resolve(scram.credentialId(), now);
|
||||
yield new AmqpCredentials("RABBIT-CR-DEMO", scram.credentialId(), resolved.material(), profile.tlsEnabled());
|
||||
}
|
||||
```
|
||||
|
||||
`RABBIT-CR-DEMO` 는 RabbitMQ 의 시연용 challenge-response 인증 기구(`rabbit_auth_mechanism_cr_demo`)의 이름이고 기본 활성이 아니다. RabbitMQ 는 SCRAM-SHA 를 구현하지 않으므로 `SaslScram` 에 대응하는 AMQP 기구가 없다는 것 자체는 사실이다.
|
||||
|
||||
문제는 그 사실을 다루는 방식이 같은 파일 안에서 일관되지 않다는 것이다.
|
||||
|
||||
```java
|
||||
case BrokerCredentialProfile.Nkey ignored ->
|
||||
throw new IllegalArgumentException("NKey credentials are a NATS concept, not an AMQP one"); // ← 거부
|
||||
case BrokerCredentialProfile.OAuth2 oauth -> {
|
||||
// RabbitMQ's OAuth 2 plugin takes the token in the password field of a PLAIN exchange.
|
||||
yield new AmqpCredentials("PLAIN", …); // ← 주석으로 근거
|
||||
}
|
||||
case BrokerCredentialProfile.SaslScram scram -> yield new AmqpCredentials("RABBIT-CR-DEMO", …); // ← 둘 다 없다
|
||||
```
|
||||
|
||||
그리고 이웃 어댑터의 같은 클래스가 정확히 이 상황에 대한 규범을 적어 두었다.
|
||||
|
||||
> "Refused rather than half-configured. Setting the mechanism name without a callback handler
|
||||
> produces a client that authenticates with nothing and fails at connect time, which is later and
|
||||
> harder to attribute than failing here." — `KafkaSecurityConfigurer`
|
||||
|
||||
`SaslScram` 에도 그 규범이 적용되어야 한다. 플러그인이 없는 브로커에서는 handshake 가 알아보기 어려운 오류로 실패하고, 있는 브로커에서는 시연용 기구로 인증한다.
|
||||
|
||||
수정은 `Nkey` 와 같이 거부하거나, `PLAIN` 으로 매핑하고 그 이유를 주석으로 남기는 것이다. 어느 쪽이든 지금처럼 말없이 데모 기구를 고르는 것보다 낫다.
|
||||
|
||||
### 17.4 P3 — 능력 상수의 `delayedDelivery` 가 무조건 참이고, 그 지연을 제공할 토폴로지는 조립되지 않는다
|
||||
|
||||
```java
|
||||
private static final MessagingCapabilities CAPABILITIES =
|
||||
new MessagingCapabilities(true, true, true, false, false, false, false, true, false, false, true, true);
|
||||
// ^^^^ delayedDelivery
|
||||
```
|
||||
|
||||
이 플래그는 읽힌다.
|
||||
|
||||
```java
|
||||
// DefaultRetryDecisionEngine:64
|
||||
if (policy.mode() == RetryMode.BROKER_DELAYED && context.capabilities().delayedDelivery()) { … }
|
||||
```
|
||||
|
||||
그런데 지연을 실제로 만드는 것은 `RabbitRetryQueueTopology` 이고, 그 클래스는 자기 파일과 시험 하나 밖에서 참조되지 않는다. 어떤 production 코드도 그 큐를 선언하지 않는다.
|
||||
|
||||
그리고 그 클래스의 javadoc 이 이 지연의 성질을 정확히 적는다.
|
||||
|
||||
> "TTL expiry is evaluated at the head of the queue, so mixed delays in one retry queue do not expire
|
||||
> independently."
|
||||
|
||||
즉 제공되는 것은 "메시지별 지연" 이 아니라 "재시도 큐 하나당 TTL 하나" 다. 능력 모델에는 그 구분을 표현하는 자리가 없고, 상수는 프로파일과 무관하게 참을 답한다.
|
||||
|
||||
Kafka 는 같은 칸을 `false` 로 둔다. 그래서 이 플래그의 두 값이 "지연 있음/없음" 이 아니라 "지연을 흉내낼 토폴로지를 선언할 수 있음/없음" 을 뜻하게 된다.
|
||||
|
||||
수정은 능력을 전송 상수가 아니라 목적지의 재시도 큐 선언에서 파생시키는 것이다. 이 리프가 조립되지 않는 동안에는 P3 이고, `RabbitChannelPublisher` 구현이 생기는 날 함께 봐야 한다.
|
||||
|
||||
### 17.5 P3 — `pause` 의 의미가 SPI 하나 뒤에서 두 브로커에 다르게 구현된다
|
||||
|
||||
```java
|
||||
@Override public CompletionStage<Void> pause(String scope) {
|
||||
pausedScopes.add(scope == null ? "" : scope);
|
||||
return CompletableFuture.completedFuture(null); // ← 즉시 완료
|
||||
}
|
||||
```
|
||||
|
||||
호출자가 이 단계를 기다리고 나면 "일시정지되었다" 고 읽는다. 실제로 일어난 것은 `onMessage` 가 이후 배달에 `false` 를 답하기 시작한 것뿐이고, 리스너 컨테이너는 계속 배달을 밀며 그 배달들은 미확인 상태로 재배달된다. 즉 정지가 아니라 거부-재배달 루프다.
|
||||
|
||||
Kafka 쪽은 같은 SPI 를 정반대로 구현하고 그 이유를 적는다.
|
||||
|
||||
> "The returned stage completes after the poll loop has actually applied the change, so a caller that
|
||||
> awaits it knows the consumer is paused rather than merely asked to pause… there is no safe way to
|
||||
> touch the consumer from another thread, so 'paused' cannot be true until the loop says so."
|
||||
|
||||
AMQP 에는 대응하는 수단이 있다 — `basicCancel` 로 소비자를 취소하거나 컨테이너를 멈추는 것. 지금 구현이 그것을 하지 않는 이유는 어디에도 없다.
|
||||
|
||||
전용 시험(`aPausedQueueRefusesDeliveriesSoTheBrokerRedeliversThem`)의 이름이 이미 실제 동작을 정확히 말한다. 그러므로 수정은 둘 중 하나다 — 컨테이너를 실제로 멈추거나, SPI 의 javadoc 에 "브로커에 따라 정지가 거부-재배달일 수 있다" 를 명시하는 것.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **확인과 반환을 두 질문으로 나누고, 반환-먼저 순서를 상태 기계로 다룬 것.**
|
||||
- **정렬된 맵을 골라 `multiple` 확인의 범위 해소를 가능하게 한 것과, 해시 맵이 만들었을 누수를 javadoc 에 남긴 것.**
|
||||
- **부정 확인의 증거를 전송됨으로 기록한 것과 그 근거.**
|
||||
- **채널 종료를 모호로 완결시킨 것** — 보류로 남기면 호출자가 매달린다.
|
||||
- **순번 예약을 발행과 분리한 것** — 확인이 `basicPublish` 반환을 앞지를 수 있다.
|
||||
- **동기 발행 실패를 던지지 않고 분류기를 거쳐 스테이지로 돌려주는 것.**
|
||||
- **적재물 크기와 종료 상태를 채널 앞에서 검사해 미전송 증거로 실패시키는 것.**
|
||||
- **정착의 일회성과 재사용된 배달 태그의 위험을 명시한 것.**
|
||||
- **디코딩만 감싸는 좁은 `catch`** — 넓은 catch 가 재시도 가능한 실패를 삭제로 바꾸던 형태를 고쳤다.
|
||||
- **정착하지 않은 핸들러를 대신 ack 하지 않고 requeue 하는 것.**
|
||||
- **요구 재큐 대신 지연 재시도 큐를 쓴 것과, TTL 이 큐 머리에서 평가된다는 한계를 javadoc 에 남긴 것.**
|
||||
- **배수 중 진행 배달을 끝내게 한 것.**
|
||||
- **네이티브 죽은 편지를 검증된 곳에서만 쓰고 나머지는 공유 조율자에 위임한 것, 그리고 네이티브 경로의 증거를 `BROKER_ACK` 로만 주장한 것.**
|
||||
- **자격증명을 연결 시도마다 해석하고 짧은 수명 객체로 넘긴 것, `AmqpCredentials` 를 record 가 아니라 class 로 둔 것과 그 근거.**
|
||||
- **내구 작업 큐에 쿼럼 큐를 요구한 것과 그 근거.**
|
||||
- **배치 누적에 나이 경계를 필수로 만든 것** — 조용한 큐가 마지막 메시지를 미확인으로 붙들지 않게.
|
||||
- **prefetch 가 배치 크기보다 작으면 교착이라는 것을 거부로 표현한 것.**
|
||||
- **배치를 `settlableAsBatch=false` 로 보고한 것** — AMQP multiple-ack 은 진행 중인 작업까지 정착시킨다.
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/messaging/messaging-rabbit/build.gradle
|
||||
main/java/…/rabbit/RabbitConfirmCoordinator.java:1-279 (§17.2 returned:74-79)
|
||||
main/java/…/rabbit/RabbitMessagingTransport.java:1-246 (능력 상수 41-43 · §17.4)
|
||||
main/java/…/rabbit/RabbitConsumerRegistrar.java:1-238 (§17.5 pause:694-697)
|
||||
main/java/…/rabbit/RabbitDeliveryMapper.java:1-195 (§12.3 attemptOf:133-153)
|
||||
main/java/…/rabbit/RabbitHeaderMapper.java:1-180 (§17.1 배달 모드 235 · §17.2 순번 헤더 부재)
|
||||
main/java/…/rabbit/RabbitSecurityConfigurer.java:1-179 (§17.3 switch 438-463)
|
||||
main/java/…/rabbit/RabbitBatchConsumerRegistrar.java:1-165
|
||||
main/java/…/rabbit/RabbitTopologyProfile.java:1-137
|
||||
main/java/…/rabbit/RabbitDeadLetterPublisher.java:1-130 (§12.2 참조 0)
|
||||
main/java/…/rabbit/RabbitPublishFailureClassifier.java:1-117
|
||||
main/java/…/rabbit/RabbitSettlementController.java:1-84
|
||||
main/java/…/rabbit/RabbitProfileValidator.java:1-83 (§17.1 validateDestination:543-555)
|
||||
main/java/…/rabbit/RabbitPublishMapper.java:1-72
|
||||
main/java/…/rabbit/RabbitNativeDeadLetterCapability.java:1-70
|
||||
main/java/…/rabbit/RabbitRetryQueueTopology.java:1-57 (§17.4)
|
||||
main/java/…/rabbit/{RabbitSettlementOperations:1-50, RabbitBrokerProfile:1-50, RabbitChannelPublisher:1-42,
|
||||
RabbitPublishReference:1-37, RabbitRequestReply:1-32}
|
||||
test/java/…/rabbit/ 10파일 1,727줄 (RabbitRuntimeTest:309 · RabbitContractHarness:304 · RabbitBrokerIT:232 …)
|
||||
test/java/…/rabbit/RabbitBrokerIT.java:726-733, 786-813 (§17.2 시험이 메우는 x-seq 규약)
|
||||
messaging-spring-boot-starter/…/MessagingProviderSelection.java:64-69 (§12.1 rabbit 거부)
|
||||
messaging-policy/…/DefaultRetryDecisionEngine.java:64 (§17.4 delayedDelivery 소비처)
|
||||
```
|
||||
@@ -0,0 +1,793 @@
|
||||
# messaging-reliability-api 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-reliability-api`
|
||||
> SSOT owner: `messaging-reliability-api`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- registered leaf id: `messaging-reliability-api`
|
||||
- canonical state `analysisFile`: `analysis/messaging/messaging-reliability-api.md`
|
||||
- source path: `src/messaging/messaging-reliability-api`
|
||||
- registry `allowed_dependencies`: `["messaging-core-api"]`
|
||||
- registry `runtime_memberships`: `["app-bootstrap"]`
|
||||
|
||||
### 숫자
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | 13 |
|
||||
| production LOC | 817 |
|
||||
| 패키지 | 1 (`dev.caskeleton.messaging.reliability`) |
|
||||
| **test 파일** | **0 — `src/test` 디렉터리가 없다** |
|
||||
| 외부(비프로젝트) 의존성 | **0** |
|
||||
|
||||
13개 타입:
|
||||
|
||||
| 축 | 타입 | leaf 밖 참조 |
|
||||
|---|---|---:|
|
||||
| **Outbox** | `OutboxRepository` · `OutboxRecord` · `OutboxCanonicalMetadata` · `OutboxStatus` · `OutboxLease` · `OutboxTransitionResult` | 7 · 13 · 8 · 7 · 6 · 6 |
|
||||
| **Inbox** | `InboxRepository` · `InboxRecord` · `InboxResult` · `IdempotentMessageHandler` · `TransactionalMessageAction` | 6 · **0** · 2 · 1 · 1 |
|
||||
| **기타** | `ClaimCheckReference` · `ReliableMessagePublisher` | 6 · **0** |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `src/main/java/**` (13) | 13 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `src/test/**` | 0 | — | **존재하지 않음**(§10) |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 5줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
이 leaf는 **effectively-once 처리의 계약**을 소유한다. 구현이 없다 — 13개 중 인터페이스 5개, record 5개, enum 3개이고 실행 가능한 로직은 record 생성자 검증과 `isExpired`/`expiredAt` 술어 정도다. 벤더 의존성 0, 저장소 기술 중립이다.
|
||||
|
||||
세 개의 독립적인 메커니즘을 담는다.
|
||||
|
||||
**Outbox** — dual-write 문제의 답.
|
||||
|
||||
```java
|
||||
// ReliableMessagePublisher.java:14-15
|
||||
* <p>This is the answer to the dual-write problem. Writing to the database and publishing to the
|
||||
* broker in the same method cannot be made atomic; writing both to the database can.
|
||||
```
|
||||
|
||||
**Inbox** — 소비 측 중복 제거.
|
||||
|
||||
```java
|
||||
// InboxRepository.java:9-13
|
||||
* <p>{@link #reserve} must run inside the same database transaction as the handler's side effect.
|
||||
* That is the entire mechanism: the uniqueness constraint on the inbox row and the business write
|
||||
* commit together, so a redelivered message either finds the row already present and skips, or
|
||||
* writes both. Reserving in a separate transaction reintroduces exactly the gap the Inbox exists to
|
||||
* close.
|
||||
```
|
||||
|
||||
**Claim Check** — 브로커 밖 payload 참조.
|
||||
|
||||
그리고 셋의 관계를 `OutboxRecord`가 명시한다.
|
||||
|
||||
```java
|
||||
// OutboxRecord.java:21-24
|
||||
* <p>What the outbox does not do is remove duplicates. A relay that cannot confirm a publish will
|
||||
* retry it, and the same message may reach the broker twice. Effectively-once processing comes from
|
||||
* this row carrying a stable {@code messageId} and the consumer having an Inbox — not from the
|
||||
* outbox alone.
|
||||
```
|
||||
|
||||
**Outbox 하나로는 부족하다는 것을 타입의 javadoc이 직접 말한다.** 이 저장소에서 반복되는 "보장을 과대 진술하지 않는다"의 예다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
들어오는 것: `messaging-core-api`(api) 하나.
|
||||
|
||||
나가는 것: `messaging-outbox-jdbc-postgresql`, `messaging-inbox-jdbc-postgresql`, `messaging-claim-check`, `messaging-spring-boot-starter`.
|
||||
|
||||
**구현 leaf가 셋 있고 전부 배선된다.**
|
||||
|
||||
| 포트 | 구현 | 조립 |
|
||||
|---|---|---|
|
||||
| `OutboxRepository` | `messaging-outbox-jdbc-postgresql/JdbcOutboxRepository` | starter `MessagingReliabilityAutoConfiguration` |
|
||||
| `InboxRepository` | `messaging-inbox-jdbc-postgresql/JdbcInboxRepository` | 같음 |
|
||||
| `IdempotentMessageHandler` | `messaging-inbox-jdbc-postgresql/TransactionalInboxHandler` | `transactionalInboxHandler` bean |
|
||||
| `ReliableMessagePublisher` | **없음** | — |
|
||||
|
||||
`ReliableMessagePublisher`는 구현도 소비자도 0이다(§12.1). Outbox에 행을 쓰는 애플리케이션 측 진입점인데, 그 진입점이 없다.
|
||||
|
||||
이 leaf 자체는 Spring 주석을 갖지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
```
|
||||
Outbox
|
||||
ReliableMessagePublisher.addToOutbox(dest, envelope) ← 구현 0
|
||||
↓ (쓰기)
|
||||
OutboxRecord ─┬─ messageId / destination / type / version / contentType / payload / headers
|
||||
├─ OutboxCanonicalMetadata (provenance 10필드)
|
||||
└─ status / attempts / leaseExpiresAt / lastFailureCode
|
||||
↓ (릴레이)
|
||||
OutboxRepository ─┬─ append
|
||||
├─ [구세대] leaseBatch → List<OutboxRecord>
|
||||
│ markPublished/markAmbiguous/markFailed/releaseLease(MessageId) → void
|
||||
└─ [신세대] claimBatch → List<OutboxLease>
|
||||
markPublished/markAmbiguous/markExhausted/markFailed/releaseLease(OutboxLease)
|
||||
→ OutboxTransitionResult {APPLIED, STALE_LEASE}
|
||||
OutboxStatus {PENDING, IN_FLIGHT, PUBLISHED, AMBIGUOUS, FAILED, EXHAUSTED}
|
||||
|
||||
Inbox
|
||||
IdempotentMessageHandler.handleOnce(consumerName, delivery, TransactionalMessageAction)
|
||||
InboxRepository.reserve(messageId, consumerId, now) → boolean
|
||||
InboxRecord (messageId + consumerId + processedAt) ← 참조 0
|
||||
InboxResult {APPLIED, ALREADY_APPLIED, CLAIMED_ELSEWHERE}
|
||||
|
||||
Claim Check
|
||||
ClaimCheckReference (storageKey, sizeBytes, sha256, expiresAt)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
### 4.1 `OutboxLease` — fencing token
|
||||
|
||||
이 leaf에서 가장 중요한 안전 장치이고, 이전 결함이 javadoc에 통째로 있다.
|
||||
|
||||
```java
|
||||
// OutboxLease.java:8-16
|
||||
* <p>The port used to take a {@code MessageId} for every terminal transition, so a write said which
|
||||
* row to change and nothing about which claim it belonged to. A relay that stalled past its lease
|
||||
* could still record {@code AMBIGUOUS} over the {@code PUBLISHED} another relay had already
|
||||
* written, and the row became claimable again — one message, published twice, by a system whose
|
||||
* whole purpose is to publish it once.
|
||||
*
|
||||
* <p>The token is the part that makes staleness detectable. It increases on every claim, so a
|
||||
* superseded relay holds a number the row no longer has and its update matches zero rows.
|
||||
```
|
||||
|
||||
`token < 1`을 거절하는 이유도 적혀 있다 — `"a claim's token starts at 1; 0 is the value of a row nobody has claimed"`.
|
||||
|
||||
`expiredAt(now)`가 `!now.isBefore(expiresAt)`다.
|
||||
|
||||
### 4.2 `OutboxTransitionResult` — void가 삼킨 것
|
||||
|
||||
```java
|
||||
// :5-9
|
||||
* <p>The transitions returned {@code void}, so an update that matched zero rows was
|
||||
* indistinguishable from one that matched one. That is precisely the stale-lease case: the relay
|
||||
* believes it recorded the outcome, the row still says something else, and nothing anywhere counts
|
||||
* the disagreement.
|
||||
```
|
||||
|
||||
두 값이고 `STALE_LEASE`의 javadoc이 운영 의미까지 적는다.
|
||||
|
||||
```java
|
||||
* <p>Another relay claimed it after the lease expired. Not an error to throw — the message is
|
||||
* being handled by somebody else — but never a success either: it is the signal that this
|
||||
* worker's publish attempt may have produced a duplicate, and it belongs on a metric.
|
||||
```
|
||||
|
||||
**"belongs on a metric"** — 그 메트릭이 존재하는지는 outbox leaf가 답한다.
|
||||
|
||||
### 4.3 `OutboxStatus` — 여섯 상태와 두 개의 구분
|
||||
|
||||
`PENDING` → `IN_FLIGHT` → `PUBLISHED` / `AMBIGUOUS` / `FAILED` / `EXHAUSTED`.
|
||||
|
||||
**두 쌍의 구분이 각각 이유를 갖는다.**
|
||||
|
||||
`AMBIGUOUS` vs `FAILED`:
|
||||
|
||||
```java
|
||||
// :6-9
|
||||
* <p>{@link #AMBIGUOUS} is a distinct state rather than a flavour of failure. A record whose
|
||||
* publish timed out may already be on the broker; retrying it is correct, but only under the same
|
||||
* logical message id, and an operator looking at the table needs to be able to tell those rows
|
||||
* apart from ones that definitely never landed.
|
||||
```
|
||||
|
||||
`EXHAUSTED` vs `FAILED`:
|
||||
|
||||
```java
|
||||
// :31-34
|
||||
* <p>Distinct from {@link #FAILED}, which means the broker refused the message: this one means
|
||||
* nobody ever got an answer. Collapsing the two loses the difference between "this message is
|
||||
* invalid" and "the broker was unreachable for an hour", and those need different operator
|
||||
* actions — the first a fix, the second a redrive.
|
||||
```
|
||||
|
||||
`OutboxRepository.markExhausted`의 javadoc이 같은 말을 반복한다 — "The first needs a fix, the second a redrive."
|
||||
|
||||
**`FAILED`의 의미가 애플리케이션 쪽 동명 enum과 반대다.** `CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`의 `.because(...)`가 그것을 ArchUnit 규칙의 근거로 든다 — "its `OutboxStatus.FAILED` means the opposite of the legacy `OutboxEventStatus.FAILED`, so the two models cannot be mixed by name without inverting retryable and terminal." 즉 **이 enum의 의미가 저장소 규칙 하나의 존재 이유다.**
|
||||
|
||||
### 4.4 `InboxResult` — 두 개가 아니라 세 개
|
||||
|
||||
```java
|
||||
// :6-9
|
||||
* <p>Three outcomes, not two. Collapsing {@link #ALREADY_APPLIED} and {@link #CLAIMED_ELSEWHERE}
|
||||
* into a single "duplicate" would settle a message whose effect is still only half-written by
|
||||
* another instance: if that instance then rolls back, the effect is lost and the broker will never
|
||||
* redeliver, because this instance already acknowledged it.
|
||||
```
|
||||
|
||||
`safeToSettle` 플래그가 상수에 붙어 있다.
|
||||
|
||||
| 값 | safeToSettle | 뜻 |
|
||||
|---|:---:|---|
|
||||
| `APPLIED` | true | 이 트랜잭션에서 효과 실행 |
|
||||
| `ALREADY_APPLIED` | true | 커밋된 예약 존재 — 이미 실행됨 |
|
||||
| `CLAIMED_ELSEWHERE` | **false** | 다른 인스턴스가 **미커밋** 예약 보유 |
|
||||
|
||||
세 번째의 javadoc이 결론을 적는다 — "Do *not* settle. The other transaction may still roll back, and this delivery is the only remaining copy that could re-apply the effect."
|
||||
|
||||
**세 값 모두 필요한 이유가 명확하고, `isSafeToSettle()`이 그 판단을 하나로 모은다.**
|
||||
|
||||
### 4.5 `InboxRepository` — 키가 (message, consumer)다
|
||||
|
||||
```java
|
||||
// InboxRecord.java:9-12
|
||||
* <p>Keyed by message id <em>and</em> consumer id, because two independent consumers of the same
|
||||
* event must each process it once — deduplicating on the message alone would let the first consumer
|
||||
* suppress the second.
|
||||
```
|
||||
|
||||
`IdempotentMessageHandler`의 javadoc이 같은 이유를 API 형태로 반복한다 — `consumerName`이 파라미터인 이유.
|
||||
|
||||
`purgeProcessedBefore`의 javadoc이 보존 기간 규칙을 적는다.
|
||||
|
||||
```java
|
||||
* <p>Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery
|
||||
* arrives after its inbox row was pruned and is processed a second time.
|
||||
```
|
||||
|
||||
**이 규칙을 강제하는 코드가 없다.** 보존 기간과 브로커 재전달 창을 비교하는 검증이 이 leaf에도, `messaging-policy`의 프로파일 검증기에도 없다. §17.
|
||||
|
||||
### 4.6 `TransactionalMessageAction` — 트랜잭션 경계의 소유권
|
||||
|
||||
```java
|
||||
// :8-16
|
||||
* <p>Sharing one transaction is the entire mechanism. If the effect committed separately from the
|
||||
* "I have handled this message" marker, a crash between the two would either replay the effect or
|
||||
* suppress a message that was never handled — and which of those you get would depend on the order
|
||||
* the two commits happened to be written in.
|
||||
*
|
||||
* <p>Implementations must not settle the message, publish, or start their own transaction. The
|
||||
* runtime owns the transaction boundary precisely so that the action cannot accidentally commit
|
||||
* half of it.
|
||||
```
|
||||
|
||||
세 금지("settle하지 마라, publish하지 마라, 자기 트랜잭션을 시작하지 마라")가 **문서로만 표현된다.** 함수형 인터페이스이므로 타입이 강제할 수 없다. §17.
|
||||
|
||||
### 4.7 `OutboxCanonicalMetadata` — 컬럼이어야 하는 이유
|
||||
|
||||
이 leaf에서 가장 긴 javadoc이고, 이전 결함과 설계 대안을 함께 적는다.
|
||||
|
||||
```java
|
||||
// :14-28
|
||||
* <p>They used to live nowhere. A row held identity, type, version, content type, payload and an
|
||||
* arbitrary header map, so producer, tenant, correlation, causation, trace and schema were either
|
||||
* invented when the envelope was rebuilt — {@code Optional.empty()} for every one of them — or
|
||||
* smuggled through the header map under reserved names the platform was supposed to own.
|
||||
*
|
||||
* <p>Both routes fail in the same direction. A relay cannot filter, route or diagnose by tenant
|
||||
* without decoding the payload, so the operational question "which tenant is backed up" has no
|
||||
* answer; and a message that crossed the outbox arrived at its consumer with a different tenant,
|
||||
* trace and correlation than the one that was published, which makes the publish path — direct,
|
||||
* polling or CDC — part of the message's meaning.
|
||||
*
|
||||
* <p>Columns rather than a blob, because the point is that the database can answer questions about
|
||||
* them. A versioned envelope encoding would round-trip just as faithfully and would still leave the
|
||||
* relay unable to select rows for one tenant.
|
||||
```
|
||||
|
||||
**세 번째 문단이 고려된 대안을 명시적으로 기각한다** — 버전 있는 봉투 인코딩이 왕복 충실도는 같지만 테넌트별 조회를 못 한다는 것. 이 저장소에서 대안을 이름 붙여 기각한 드문 예다.
|
||||
|
||||
불변식 하나: `schemaUri.isPresent() && schemaSubject.isEmpty()`를 거절한다 — "a reader would have a URI and no way to know what it is a schema for".
|
||||
|
||||
`traceContext`만 `Optional`이 아니고 `TraceContext.none()`이라는 자체 빈 형태를 갖는다. javadoc이 그 이유를 적는다 — 컬럼이 생기기 전에 쓰인 행과, 진짜로 correlation이 없는 행을 구분할 필요가 없다는 것("the reader's behaviour is the same: carry what is there and invent nothing").
|
||||
|
||||
### 4.8 `OutboxRecord` — 두 반쪽의 소유자가 다르다
|
||||
|
||||
```java
|
||||
// :26-29
|
||||
* <p>{@link OutboxCanonicalMetadata} is a separate component rather than more fields here because
|
||||
* the two halves answer to different owners. Identity, payload, status, attempts and lease are the
|
||||
* relay's bookkeeping; the metadata is the message's own provenance, and it is the half that has to
|
||||
* survive the round trip through the database unchanged.
|
||||
```
|
||||
|
||||
`payload`가 양방향 방어 복사(`payload.clone()` 생성 시와 접근 시), `headers`가 `Map.copyOf` — `messaging-schema-api`의 `EncodedMessage`(그쪽 §4.3)와 같은 패턴이다.
|
||||
|
||||
`withStatus`가 `messageId`를 파라미터로 받지 않는다 — "The message id is never a parameter, so no state transition can change it." 타입이 불변식을 강제하는 예다.
|
||||
|
||||
`equals`/`hashCode`가 **다섯 필드 중 넷만** 본다 — `messageId`, `status`, `attempts`, `payload`. `destination`·`metadata`·`createdAt`·`leaseExpiresAt`·`lastFailureCode`는 비교하지 않는다. record 기본 동작을 의도적으로 좁혔는데 **그 이유가 어디에도 적혀 있지 않다.** §17.
|
||||
|
||||
`toString`이 payload를 담지 않는다.
|
||||
|
||||
### 4.9 `ClaimCheckReference` — digest가 선택이 아니다
|
||||
|
||||
```java
|
||||
// :10-16
|
||||
* <p>The digest is part of the reference, not an optional extra. A claim check splits a message
|
||||
* into two systems with independent retention and replication, so a consumer that fetches the
|
||||
* payload has to be able to prove it got the bytes the producer stored — otherwise a truncated or
|
||||
* replaced object is indistinguishable from a valid one.
|
||||
*
|
||||
* <p>The expiry is carried for the same reason: a claim check whose payload has been reaped is a
|
||||
* dead message, and detecting that at fetch time is better than a mysterious not-found.
|
||||
```
|
||||
|
||||
`sha256`이 `[a-f0-9]{64}` 정확 일치다 — 대문자 hex를 거절한다. `messaging-core-api`의 `TraceContext`가 대문자 traceparent를 거절하는 것(그쪽 §4.11)과 같은 규율이지만, 여기서는 그 이유가 적혀 있지 않다.
|
||||
|
||||
`expiresAt`이 `Optional`이 아니다 — 모든 claim check가 만료를 갖는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
**Outbox 쓰기:** 애플리케이션 트랜잭션 안에서 `ReliableMessagePublisher.addToOutbox(...)` → `OutboxRepository.append(record)` — **진입점 구현이 없다**(§12.1)
|
||||
|
||||
**Outbox 릴레이:** `claimBatch(owner, size, lease, now, maxAttempts)` → `List<OutboxLease>` → 각 lease에 대해 발행 → 결과에 따라 `markPublished`/`markAmbiguous`/`markExhausted`/`markFailed`(lease 기반) → `APPLIED`면 정상, `STALE_LEASE`면 다른 릴레이가 가져감
|
||||
|
||||
**Inbox:** `handleOnce(consumerName, delivery, action)` → 한 트랜잭션 안에서 `reserve(messageId, consumerId, now)` → true면 `action.apply(delivery)` → 커밋
|
||||
|
||||
---
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
**이 leaf는 `MessagingException`을 하나도 던지지 않는다.** 실패를 상태와 반환값으로 표현한다.
|
||||
|
||||
| 표현 | 값 |
|
||||
|---|---|
|
||||
| 릴레이 전이 결과 | `OutboxTransitionResult.{APPLIED, STALE_LEASE}` |
|
||||
| Outbox 행 상태 | `OutboxStatus` 6개 |
|
||||
| Inbox 판정 | `InboxResult` 3개 + `isSafeToSettle()` |
|
||||
| claim check 만료 | `ClaimCheckReference.isExpired(now)` |
|
||||
| lease 만료 | `OutboxLease.expiredAt(now)` |
|
||||
|
||||
`IllegalArgumentException`을 던지는 곳은 record 생성자 여섯이다 — 전부 호출자의 프로그래밍 오류다.
|
||||
|
||||
`TransactionalMessageAction.apply`가 `throws Exception`이다 — javadoc: "rolling back both it and the inbox reservation". 즉 예외가 롤백 신호이고, 그 처리는 구현 leaf가 소유한다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
**이 leaf 전체가 트랜잭션 계약이다.** 그런데 코드에는 트랜잭션이 없다 — 전부 javadoc이 요구하는 규약이다.
|
||||
|
||||
| 계약 | 표현 위치 | 강제 |
|
||||
|---|---|---|
|
||||
| `OutboxRepository.append`가 호출자 트랜잭션 안 | 인터페이스 javadoc | **없음** |
|
||||
| 나머지 메서드는 릴레이 자기 트랜잭션 | 같은 javadoc | 없음 |
|
||||
| `InboxRepository.reserve`가 핸들러 부작용과 같은 트랜잭션 | 인터페이스 javadoc | 없음 |
|
||||
| `TransactionalMessageAction`이 자기 트랜잭션을 시작하지 않음 | javadoc | 없음 |
|
||||
| `ReliableMessagePublisher.addToOutbox`가 `void`인 것 | javadoc | **타입이 강제** |
|
||||
|
||||
마지막 하나만 타입이 강제한다.
|
||||
|
||||
```java
|
||||
// ReliableMessagePublisher.java:9-12
|
||||
* <p>The return type is {@code void}, and that is the contract. There is no publish outcome to
|
||||
* report yet: the row is written inside the caller's transaction, so if the transaction rolls back
|
||||
* the message never existed, and if it commits the relay will publish it later. Handing back a
|
||||
* {@code PublishResult} here would be a lie about work that has not happened.
|
||||
```
|
||||
|
||||
동시성 원시 요소는 하나 — **fencing token**. 그것이 `OutboxLease.token`이고 검사는 구현의 SQL `WHERE`에 있다(§12.1).
|
||||
|
||||
모든 record가 불변이다. 상태를 가진 클래스가 하나도 없다.
|
||||
|
||||
수명주기 참여 없음.
|
||||
|
||||
---
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
설정 없음. 상수도 없다 — `ClaimCheckReference.SHA256` 정규식 하나가 private이다.
|
||||
|
||||
`OutboxRepository`의 두 `purge*` 메서드가 `limit` 파라미터를 갖는 것이 유일한 튜닝 지점이고, 그 이유가 javadoc에 있다.
|
||||
|
||||
```java
|
||||
// :143-147
|
||||
* <p>The unbounded version deletes everything before the cutoff in one statement. On a table that
|
||||
* has been accumulating published rows since the last sweep that is a single long transaction
|
||||
* holding locks and generating WAL in proportion to the backlog, which shows up as the relay and
|
||||
* the business writes stalling behind retention. The cleanup jobs describe themselves as bounded
|
||||
* by batch size; this is the parameter that makes that true.
|
||||
```
|
||||
|
||||
`InboxRepository`도 같은 쌍을 갖는다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
없다 — 포트만 정의한다. 다만 **포트가 저장소 기술을 전제한다.**
|
||||
|
||||
- `InboxRepository.reserve`의 메커니즘이 "the uniqueness constraint on the inbox row"다 — 유니크 제약이 있는 저장소를 전제
|
||||
- `OutboxRepository.claimBatch`의 의미가 "a record claimed by one relay is invisible to the others"다 — 행 잠금 또는 그에 준하는 것을 전제
|
||||
- `OutboxTransitionResult.STALE_LEASE`가 "its update matches zero rows"에서 나온다 — 조건부 UPDATE의 영향 행 수를 셀 수 있는 저장소를 전제
|
||||
|
||||
세 전제 모두 javadoc에 있고 인터페이스 이름에는 없다. 구현 leaf 이름(`*-jdbc-postgresql`)이 실제 선택을 드러낸다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
**이 leaf에는 테스트가 없다.** `src/test` 디렉터리 자체가 존재하지 않는다 — `src` 아래에 `main`만 있다.
|
||||
|
||||
13개 타입 중 record 생성자 검증이 있는 것이 여섯(`ClaimCheckReference`, `InboxRecord`, `OutboxCanonicalMetadata`, `OutboxLease`, `OutboxRecord`, `OutboxTransitionResult`는 enum), 술어가 있는 것이 셋(`isExpired`, `expiredAt`, `isSafeToSettle`)이다. 그중 어느 것도 이 leaf의 레인에서 검증되지 않는다.
|
||||
|
||||
**검증은 전부 구현 leaf에서 일어난다.**
|
||||
|
||||
| 검증 위치 | 무엇을 |
|
||||
|---|---|
|
||||
| `messaging-outbox-jdbc-postgresql` 테스트 4개 | `OutboxRepository` 구현, 릴레이 |
|
||||
| `messaging-inbox-jdbc-postgresql` 테스트 4개 | `InboxRepository` 구현, 멱등 핸들러 |
|
||||
| `messaging-claim-check` 테스트 3개 | claim check |
|
||||
| starter `MessagingOutboxRelayLifecycleTest` | 릴레이 수명주기 |
|
||||
|
||||
그 결과 이 leaf의 **계약 불변식**(예: `OutboxCanonicalMetadata`의 `schemaUri` 없이 `schemaSubject` 금지, `OutboxLease`의 `token >= 1`, `InboxResult.isSafeToSettle`의 세 값)은 구현이 우연히 그 경로를 지나갈 때만 실행된다.
|
||||
|
||||
**그리고 §12.1(c)가 보이듯, 실제 PostgreSQL 컨테이너 테스트는 production이 쓰지 않는 API 세대를 검증한다.**
|
||||
|
||||
---
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
| 게이트 | 이 leaf에 대해 |
|
||||
|---|---|
|
||||
| `verifyCleanArchitectureDependencies` | `["messaging-core-api"]` |
|
||||
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
|
||||
| vendor `api` 규칙 | 벤더 의존성 0 |
|
||||
| **`APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`** | `..application..`이 이 leaf를 포함한 `dev.caskeleton.messaging..`을 참조하는 것을 금지. **규칙의 근거가 이 leaf의 `OutboxStatus.FAILED` 의미다** |
|
||||
| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |
|
||||
| ArchUnit 전용 규칙 | 없음 |
|
||||
|
||||
네 번째가 특이하다 — ArchUnit 규칙 하나가 **이 leaf의 enum 상수 의미**를 근거로 든다. 즉 이 leaf의 어휘가 저장소 경계 규칙의 일부다.
|
||||
|
||||
---
|
||||
|
||||
## 12. 실제 사용 여부와 negative-space probes
|
||||
|
||||
원시 증거: `evidence/raw/289-reliability-api-two-generations.txt`.
|
||||
|
||||
### 12.1 Public surface reachability
|
||||
|
||||
| 타입 | leaf 밖 파일 | 판정 |
|
||||
|---|---:|---|
|
||||
| `OutboxRecord` | 13 | 활발 |
|
||||
| `OutboxCanonicalMetadata` | 8 | 활발 |
|
||||
| `OutboxRepository` | 7 | 구현 1 + 릴레이 + 테스트 |
|
||||
| `OutboxStatus` | 7 | 활발 |
|
||||
| `OutboxLease` | 6 | 활발 |
|
||||
| `OutboxTransitionResult` | 6 | 활발 |
|
||||
| `InboxRepository` | 6 | 구현 1 + 테스트 |
|
||||
| `ClaimCheckReference` | 6 | 활발 |
|
||||
| `InboxResult` | 2 | |
|
||||
| `IdempotentMessageHandler` | 1 | `TransactionalInboxHandler` |
|
||||
| `TransactionalMessageAction` | 1 | 같음 |
|
||||
| **`InboxRecord`** | **0** | |
|
||||
| **`ReliableMessagePublisher`** | **0** | |
|
||||
|
||||
**(a) Outbox 쓰기 진입점에 구현이 없다**
|
||||
|
||||
`ReliableMessagePublisher`는 애플리케이션이 outbox에 행을 넣는 유일한 선언된 방법이다. 구현이 0이고 참조도 0이다.
|
||||
|
||||
`OutboxRepository.append`는 존재하지만 그것은 저장소 포트다 — javadoc이 "must be callable inside the caller's business transaction"이라고 하므로 애플리케이션이 직접 부를 수도 있다. 그러나 `ReliableMessagePublisher`가 존재하는 이유는 애플리케이션이 저장소 포트를 직접 만지지 않게 하는 것이고, 그 층이 비어 있다.
|
||||
|
||||
**그리고 애플리케이션은 이 leaf를 참조할 수 없다** — `APPLICATION_DOES_NOT_DEPEND_ON_THE_MESSAGING_PLATFORM`이 금지한다. 즉 `ReliableMessagePublisher`를 애플리케이션이 쓰려면 브리지 어댑터가 필요하고, 그 어댑터가 없다. `messaging-spring-cloud-stream-bridge`가 후보 이름이지만 그 leaf는 `runtime_memberships: []`다.
|
||||
|
||||
**(b) `InboxRecord`가 쓰이지 않는다**
|
||||
|
||||
`InboxRepository`의 어느 메서드도 `InboxRecord`를 주고받지 않는다 — `reserve`는 `boolean`, `isProcessed`는 `boolean`, `purge*`는 `int`다. record는 "One row of the consumer inbox"를 서술하지만 그 행을 반환하는 API가 없다.
|
||||
|
||||
같은 leaf의 `OutboxRecord`는 정반대다 — `leaseBatch`/`find`가 반환하고 13개 파일이 쓴다. 두 record의 역할이 비대칭이다.
|
||||
|
||||
**(c) 컨테이너 테스트가 production이 쓰지 않는 API 세대를 검증한다**
|
||||
|
||||
`OutboxRepository`는 같은 다섯 전이에 대해 **두 세대**를 갖는다.
|
||||
|
||||
| 전이 | 구세대 (MessageId) | 신세대 (OutboxLease) |
|
||||
|---|---|---|
|
||||
| 배치 획득 | `leaseBatch(size, lease, now)` → `List<OutboxRecord>` | `claimBatch(owner, size, lease, now[, maxAttempts])` → `List<OutboxLease>` |
|
||||
| 발행 확정 | `markPublished(MessageId, Instant)` → `void` | `markPublished(OutboxLease, Instant)` → `OutboxTransitionResult` |
|
||||
| 모호 | `markAmbiguous(MessageId, String, Instant)` → `void` | `markAmbiguous(OutboxLease, ...)` → `OutboxTransitionResult` |
|
||||
| 실패 | `markFailed(MessageId, String, Instant)` → `void` | `markFailed(OutboxLease, ...)` → `OutboxTransitionResult` |
|
||||
| 반납 | `releaseLease(MessageId)` → `void` | `releaseLease(OutboxLease)` → `OutboxTransitionResult` |
|
||||
| 소진 | — | `markExhausted(OutboxLease, String, Instant)` |
|
||||
|
||||
**production 릴레이는 신세대만 쓴다.**
|
||||
|
||||
```
|
||||
OutboxRelay.java:158 repository.claimBatch(owner, batchSize, leaseDuration, now, scheduler.maxAttempts())
|
||||
OutboxRelay.java:171 repository.markPublished(lease, now) == OutboxTransitionResult.APPLIED
|
||||
OutboxRelay.java:189 repository.markExhausted(lease, reason, now)
|
||||
OutboxRelay.java:192 repository.markAmbiguous(...)
|
||||
OutboxRelay.java:205 repository.markFailed(...)
|
||||
```
|
||||
|
||||
**실제 PostgreSQL 컨테이너 테스트는 구세대만 쓴다.**
|
||||
|
||||
```
|
||||
OutboxPostgresIT.java:92,111,112,121,124,133,148,161 repository.leaseBatch(...)
|
||||
OutboxPostgresIT.java:135 repository.markAmbiguous(record.messageId(), "CONFIRM_TIMEOUT", NOW)
|
||||
OutboxPostgresIT.java:150,200 repository.markPublished(record.messageId(), NOW)
|
||||
OutboxPostgresIT.java:163 repository.markFailed(record.messageId(), "INVALID_TOPIC", NOW)
|
||||
```
|
||||
|
||||
즉 **fencing token 경로가 실제 데이터베이스에 대해 한 번도 실행되지 않는다.** 그 경로의 정확성은 구현의 SQL `WHERE ... AND token = ?`이 영향 행 수를 정확히 세는지에 달려 있는데, 그것을 검증할 수 있는 유일한 레인이 다른 세대를 쓴다. 나머지 검증은 `InMemoryOutboxRepository`(`OutboxRelayTest:223`)와 `RecordingRepository`(`OutboxOperationsTest:23`) — 둘 다 SQL이 없는 fake다.
|
||||
|
||||
`OutboxLease` javadoc이 fencing token을 만든 이유로 든 사고("one message, published twice")가 정확히 그 SQL이 막는 것이다.
|
||||
|
||||
**이 판정의 소유권.** API 형태(두 세대 공존, `@Deprecated` 부재)는 이 leaf가 소유하고, **테스트 커버리지 판정은 `messaging-outbox-jdbc-postgresql` leaf가 소유한다.** 여기서는 관측과 교차 참조를 남긴다.
|
||||
|
||||
**(d) 구세대가 prose로만 deprecated다**
|
||||
|
||||
```java
|
||||
// OutboxRepository.java:41-43
|
||||
* <p>The token is what a terminal write is checked against. {@link #leaseBatch} returns records
|
||||
* without one, so its callers cannot prove a write belongs to their claim; it remains for
|
||||
* inspection paths and is deprecated for the relay's use.
|
||||
```
|
||||
|
||||
`@Deprecated` 애노테이션이 **이 leaf 전체에 하나도 없다**(`git grep '@Deprecated' -- src/messaging/messaging-reliability-api` exit 1).
|
||||
|
||||
결과: 새 구현자가 17개 메서드를 전부 구현해야 하고, 그중 다섯은 fencing이 없는 형태다. 컴파일러가 경고하지 않으므로 새 호출자가 구세대를 고를 수 있고, 실제로 컨테이너 테스트가 그렇게 했다.
|
||||
|
||||
**(e) bounded purge 오버로드가 두 포트에 선언·구현돼 있고 호출 지점이 0이다**
|
||||
|
||||
> 이 항목은 `messaging-inbox-jdbc-postgresql` 분석 중에 확인됐다. 이 문서의 초판은 §17의 "확인된 설계"에 "purge에 `limit` 파라미터를 둔 것"을 넣었는데, 그것은 파라미터의 **존재**만 본 판정이었다. 호출 여부를 재측정해 정정한다.
|
||||
|
||||
`InboxRepository.purgeProcessedBefore(Instant, int)`와 `OutboxRepository.purgePublishedBefore(Instant, int)`가 선언돼 있고 두 JDBC 구현이 `LIMIT`(inbox는 `FOR UPDATE SKIP LOCKED`까지)로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 **선언 2 + 구현 2 + 테스트 fake override 5**이고 **호출 지점이 하나도 없다**. 두 cleanup job이 무제한 오버로드를 부른다 — `InboxCleanupJob:56`, `OutboxCleanupJob:50`.
|
||||
|
||||
`OutboxRepository:140-151`의 javadoc이 그 상황을 예고한다.
|
||||
|
||||
> The unbounded version deletes everything before the cutoff in one statement. … which shows up as the relay and the business writes stalling behind retention. The cleanup jobs describe themselves as bounded by batch size; **this is the parameter that makes that true.**
|
||||
|
||||
그 파라미터를 아무도 넘기지 않는다. 판정은 `analysis/messaging/messaging-inbox-jdbc-postgresql.md` §17(P1)이 소유하고, 이 문서는 **포트가 두 오버로드를 나란히 노출했다는 것**을 기여한다 — (a)의 두 세대 전이와 같은 형태다.
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
이 leaf에 bean은 없다. **구현 leaf 셋의 sibling 비교가 유의미하다.**
|
||||
|
||||
| 포트 | 구현 leaf | membership | starter bean |
|
||||
|---|---|---|---|
|
||||
| `OutboxRepository` | `messaging-outbox-jdbc-postgresql` | `["app-bootstrap"]` | `MessagingReliabilityAutoConfiguration` |
|
||||
| `InboxRepository` | `messaging-inbox-jdbc-postgresql` | `["app-bootstrap"]` | 같음 |
|
||||
| `IdempotentMessageHandler` | `messaging-inbox-jdbc-postgresql` | 같음 | `transactionalInboxHandler` bean |
|
||||
| `ReliableMessagePublisher` | **없음** | — | — |
|
||||
|
||||
네 포트 중 셋이 구현·편입·조립을 모두 갖고 하나가 셋 다 없다. 비대칭이 명확하다.
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
**(a) 같은 전이의 두 세대** — §12.1(c). 한 인터페이스 안의 중복이라는 점에서 이 저장소의 다른 중복(두 클래스, 두 leaf)과 형태가 다르다.
|
||||
|
||||
**(b) outbox 개념이 저장소에 둘 있다**
|
||||
|
||||
| | 이 leaf | `application-core` |
|
||||
|---|---|---|
|
||||
| 상태 enum | `OutboxStatus` | `OutboxEventStatus` |
|
||||
| `FAILED`의 뜻 | 브로커가 확정적으로 거절 — **재시도 안 함** | (반대 의미, ArchUnit javadoc이 명시) |
|
||||
| 행 타입 | `OutboxRecord` | `NewOutboxEvent` 등 |
|
||||
| 사용처 | messaging family | application + persistence-jpa |
|
||||
|
||||
**의도된 분리다.** ArchUnit 규칙이 둘을 섞지 못하게 하고, 그 규칙의 `.because(...)`가 이유를 적는다 — "the two outbox status models mean opposite things under the same names". 중복 경쟁이 아니라 **명시적으로 격리된 두 모델**이다.
|
||||
|
||||
다만 그 결과 `ReliableMessagePublisher`가 쓰일 자리가 없다(§12.1a) — 애플리케이션은 자기 outbox 모델을 쓰고, 이 leaf의 진입점은 브리지 없이는 도달 불가다.
|
||||
|
||||
**(c) 이름 충돌 주의**
|
||||
|
||||
`markPublished`·`markFailed`·`releaseLease`라는 메서드 이름이 저장소의 **완전히 다른 인터페이스** 여러 곳에 있다 — `persistence-jpa`의 `OutboxStoreAdapter`·`JpaCleanupQueue`·`JpaUploadSessionStore`, `cache-redis`의 `RedisIdempotencyStoreAdapter`, `notification`의 `JpaProviderEventLedger`. 단어 검색으로 이 leaf의 사용처를 세면 오탐이 대량 발생한다. §12.1(c)의 측정은 `src/messaging/**`로 범위를 좁혀 얻은 것이다.
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
| 문서 주장 | 재측정 | 결과 |
|
||||
|---|---|---|
|
||||
| `OutboxRepository:43`: `leaseBatch`가 "deprecated for the relay's use" | `@Deprecated` 0건, 컨테이너 테스트가 사용 | **미강제** |
|
||||
| `OutboxRecord` javadoc: outbox만으로는 중복 제거 안 됨 | `InboxRepository`가 별도 존재 | **일치** |
|
||||
| `InboxRepository.purge*` javadoc: 보존이 브로커 재전달 창보다 길어야 함 | 그 비교를 하는 코드 없음 | **미강제** |
|
||||
| `TransactionalMessageAction` javadoc: 구현이 settle/publish/트랜잭션 시작 금지 | 타입이 강제하지 않음 | **미강제** |
|
||||
| `ReliableMessagePublisher` javadoc: dual-write의 답 | 구현 0 | **미실현** |
|
||||
| `OutboxTransitionResult.STALE_LEASE` javadoc: "it belongs on a metric" | 이 leaf에 메트릭 없음. outbox leaf가 답함 | **미확인** |
|
||||
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) |
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
이 leaf의 javadoc은 **세 개의 서로 다른 결함**을 보존한다.
|
||||
|
||||
| 위치 | 이전 상태 | 그것이 만든 실패 |
|
||||
|---|---|---|
|
||||
| `OutboxLease` javadoc | 모든 terminal 전이가 `MessageId`만 받음 | lease를 넘긴 릴레이가 다른 릴레이의 `PUBLISHED` 위에 `AMBIGUOUS`를 기록 → 행이 다시 claim 가능해짐 → **한 메시지가 두 번 발행됨, 한 번만 발행하는 것이 목적인 시스템에서** |
|
||||
| `OutboxTransitionResult` javadoc | 전이가 `void` 반환 | 0행 매치와 1행 매치가 구별 불가 → 릴레이는 기록했다고 믿고 행은 다른 상태이며 **그 불일치를 아무도 세지 않음** |
|
||||
| `OutboxCanonicalMetadata` javadoc | provenance가 어디에도 없음 | 봉투 재구성 시 producer·tenant·correlation·causation·trace·schema가 전부 `Optional.empty()`가 되거나 헤더 맵에 예약 이름으로 밀반입 → **outbox를 지난 메시지가 다른 tenant·trace·correlation으로 도착**, 즉 발행 경로가 메시지의 의미의 일부가 됨 |
|
||||
| `OutboxRepository.purgePublishedBefore` javadoc | 무제한 삭제 | 백로그에 비례하는 단일 긴 트랜잭션이 락과 WAL을 생성 → **릴레이와 업무 쓰기가 보존 작업 뒤에서 멈춤** |
|
||||
|
||||
첫 둘이 같은 사건의 두 측면이다 — fencing token(감지 수단)과 반환값(감지 결과의 전달 수단). 둘 다 있어야 stale lease가 관측된다.
|
||||
|
||||
세 번째의 마지막 문장이 이 저장소에서 가장 날카로운 진술 중 하나다 — **"which makes the publish path — direct, polling or CDC — part of the message's meaning."** 전달 경로가 메시지 내용을 바꾸면 그것은 더 이상 전달이 아니다.
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-294 | command | `evidence/raw/294-bounded-purge-never-called.txt` | bounded 오버로드의 호출 지점 0, 두 cleanup job의 실제 호출 | 정적 검색. `messaging-inbox-jdbc-postgresql`이 판정 소유 |
|
||||
| EVD-289 | command | `evidence/raw/289-reliability-api-two-generations.txt` | `src/test` 부재, 13타입 정규화 참조 수, 소비자 0인 둘, 네 포트의 구현자, `OutboxRepository`의 두 세대 시그니처 전수, `@Deprecated` 0건, production 릴레이와 컨테이너 테스트가 쓰는 세대, ArchUnit 규칙의 근거 문구 | 정적 검색. 이 leaf에 실행할 테스트 레인이 없음 |
|
||||
|
||||
**이 leaf에는 test lane evidence가 없다** — `src/test`가 존재하지 않으므로 `:messaging:messaging-reliability-api:test`는 실행할 소스가 없다.
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적**
|
||||
|
||||
- outbox만으로 중복이 제거되지 않는 이유 — `OutboxRecord` javadoc
|
||||
- fencing token이 필요한 이유와 이전 이중 발행 — `OutboxLease` javadoc
|
||||
- 전이가 결과를 반환해야 하는 이유 — `OutboxTransitionResult` javadoc
|
||||
- `AMBIGUOUS`가 실패의 한 종류가 아닌 이유, `EXHAUSTED`가 `FAILED`와 다른 이유 — `OutboxStatus` javadoc
|
||||
- provenance가 컬럼이어야 하는 이유와 기각된 대안(버전 봉투 인코딩) — `OutboxCanonicalMetadata` javadoc
|
||||
- 두 반쪽의 소유자가 다른 이유 — `OutboxRecord` javadoc
|
||||
- inbox 키가 (message, consumer)인 이유 — `InboxRecord`·`IdempotentMessageHandler` javadoc
|
||||
- `InboxResult`가 셋인 이유 — 그 javadoc
|
||||
- 예약이 부작용과 같은 트랜잭션이어야 하는 이유 — `InboxRepository`·`TransactionalMessageAction` javadoc
|
||||
- `addToOutbox`가 `void`인 이유 — `ReliableMessagePublisher` javadoc
|
||||
- claim check digest와 만료가 필수인 이유 — `ClaimCheckReference` javadoc
|
||||
- purge에 `limit`이 필요한 이유 — `OutboxRepository` javadoc
|
||||
- inbox 보존이 재전달 창보다 길어야 하는 이유 — `InboxRepository` javadoc
|
||||
|
||||
**추론**
|
||||
|
||||
- `ReliableMessagePublisher` 구현이 없는 것은 애플리케이션이 자기 outbox 모델을 쓰고 브리지가 없기 때문이다 → **추론**. ArchUnit 금지와 두 모델의 공존은 관측이고 인과는 추론이다.
|
||||
- `OutboxRecord.equals`가 다섯 필드만 보는 이유 → **미상**.
|
||||
- `sha256`이 소문자만 받는 이유 → **미상**(다른 곳의 같은 규율에서 유추 가능하나 여기엔 없음).
|
||||
- 구세대를 남긴 이유 → **부분 명시**("remains for inspection paths"). 제거 시점은 미상.
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- 13개 타입 817줄 전문의 계약과 불변식
|
||||
- 이 leaf에 테스트가 하나도 없다는 것(`src/test` 부재)
|
||||
- `ReliableMessagePublisher`와 `InboxRecord`의 참조 0
|
||||
- `OutboxRepository`가 같은 다섯 전이의 두 세대를 갖고 `@Deprecated`가 하나도 없다는 것
|
||||
- production 릴레이가 신세대만, PostgreSQL 컨테이너 테스트가 구세대만 쓴다는 것
|
||||
- 세 개의 이전 결함(fencing 부재, void 반환, provenance 부재)과 각각의 실패 형태
|
||||
- `OutboxStatus.FAILED`의 의미가 저장소 ArchUnit 규칙의 근거라는 것
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- **fencing token SQL이 실제 PostgreSQL에서 정확한지.** 그것을 검증할 레인이 다른 세대를 쓴다. `messaging-outbox-jdbc-postgresql` leaf가 이 판정을 소유한다.
|
||||
- `STALE_LEASE`가 실제로 메트릭으로 나가는지 — 같은 leaf가 답한다.
|
||||
- inbox 보존 기간이 실제 배포에서 브로커 재전달 창보다 긴지 — 비교하는 코드가 없다.
|
||||
- `ReliableMessagePublisher`를 구현할 계획이 있는지, 아니면 애플리케이션 outbox 모델이 정본인지.
|
||||
- `OutboxRecord.equals`의 좁은 비교가 어떤 코드에 의존되는지 — 컬렉션 연산에서 의미가 달라질 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P2 — 한 인터페이스가 같은 전이의 두 세대를 갖고, 안전하지 않은 쪽에 `@Deprecated`가 없다
|
||||
|
||||
- **사실.** `OutboxRepository`가 다섯 전이 각각에 대해 `MessageId` 기반(반환 `void`)과 `OutboxLease` 기반(반환 `OutboxTransitionResult`) 두 형태를 선언한다. javadoc이 전자를 "deprecated for the relay's use"라고 부르지만 `@Deprecated` 애노테이션이 이 leaf 전체에 **0건**이다.
|
||||
- **근거.** `evidence/raw/289` §E·§F.
|
||||
- **왜 문제인가.** 전자에는 fencing이 없다 — `OutboxLease` javadoc이 그 부재가 만든 이중 발행 사고를 기록한다. 컴파일러가 경고하지 않으므로 새 호출자가 그것을 고를 수 있고, **실제로 PostgreSQL 컨테이너 테스트가 그렇게 했다**(§12.1c). 그리고 새 구현자는 17개 메서드를 전부 구현해야 하며 그중 다섯은 안전하지 않은 형태다.
|
||||
- **확인 방법.** `git grep -n '@Deprecated' -- src/messaging/messaging-reliability-api` → 없음. `evidence/raw/289` §E.
|
||||
- **후보.** (a) 구세대 다섯에 `@Deprecated`를 붙인다. (b) 검사 경로가 정말 필요하면 별도 인터페이스(`OutboxInspection`)로 분리한다. (c) 구세대를 제거하고 호출자를 옮긴다.
|
||||
- **다음 단계.** **CASE 후보 + REFERENCE 후보.** "prose deprecation은 컴파일러가 읽지 않는다"가 재사용 가능한 기준이다.
|
||||
|
||||
### P2 — fencing token 경로가 실제 데이터베이스에 대해 실행되지 않는다
|
||||
|
||||
- **사실.** `OutboxRelay`는 `claimBatch`/lease 기반 전이만 쓴다. `OutboxPostgresIT`는 `leaseBatch`/`MessageId` 기반 전이만 쓴다. 신세대를 쓰는 다른 테스트는 `InMemoryOutboxRepository`와 `RecordingRepository` — SQL이 없는 fake다.
|
||||
- **근거.** `evidence/raw/289` §G.
|
||||
- **왜 문제인가.** fencing의 정확성은 구현의 조건부 UPDATE가 영향 행 수를 정확히 세는지에 달려 있다. `OutboxTransitionResult.STALE_LEASE`는 "its update matches zero rows"에서 나오고, 그것은 SQL의 성질이지 Java의 성질이 아니다. in-memory fake는 그 SQL을 실행하지 않는다. 즉 **이중 발행을 막는 장치가 그것을 검증할 수 있는 유일한 환경에서 실행되지 않는다.**
|
||||
- **확인 방법.** `evidence/raw/289` §G 재실행. `OutboxPostgresIT`에서 `claimBatch` 검색 → 없음.
|
||||
- **후보.** 컨테이너 테스트를 신세대로 옮기고, stale lease 시나리오(두 릴레이, 만료 후 재claim)를 실제 DB에서 재현한다.
|
||||
- **다음 단계.** **판정은 `messaging-outbox-jdbc-postgresql` leaf가 소유한다.** 여기서는 API 형태가 그 혼동을 가능하게 했다는 관측을 기여한다. **CASE 후보**(그 leaf).
|
||||
|
||||
### P2 — dual-write의 답이라고 선언한 진입점에 구현이 없다
|
||||
|
||||
- **사실.** `ReliableMessagePublisher`가 구현 0, 참조 0이다. javadoc은 "This is the answer to the dual-write problem"이라고 한다.
|
||||
- **근거.** `evidence/raw/289` §B·§C·§D.
|
||||
- **왜 문제인가.** `OutboxRepository.append`가 있으므로 outbox에 행을 넣을 방법이 없는 것은 아니다. 그러나 그 포트는 저장소 계약이고, `ReliableMessagePublisher`는 애플리케이션이 저장소를 직접 만지지 않게 하려고 존재한다. 그리고 **애플리케이션은 ArchUnit 규칙 때문에 이 leaf를 참조할 수 없으므로** 브리지 어댑터가 필요한데 그것이 없다. 즉 이 leaf의 Outbox 절반은 "릴레이가 읽는 쪽"만 배선돼 있고 "애플리케이션이 쓰는 쪽"이 비어 있다.
|
||||
- **확인 방법.** `git grep -n -E 'implements .*ReliableMessagePublisher' -- src` → 없음.
|
||||
- **후보.** (a) 브리지 어댑터를 만든다. (b) 애플리케이션 outbox 모델이 정본이면 이 인터페이스를 제거하거나 "파생 프로젝트가 구현하는 확장점"임을 명시한다.
|
||||
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 "두 outbox 모델 중 어느 쪽이 정본인가"에 걸리고, 그 질문은 `application-core`와 cross-scope가 함께 답한다.
|
||||
|
||||
### P3 — 이 leaf에 테스트가 없다
|
||||
|
||||
- **사실.** `src/test` 디렉터리가 존재하지 않는다. 13개 타입의 record 생성자 검증 여섯과 술어 셋이 이 leaf의 레인에서 실행되지 않는다.
|
||||
- **근거.** `evidence/raw/289` §A.
|
||||
- **왜 문제인가.** 계약 불변식 중 일부는 구현이 우연히 지나가지 않으면 실행되지 않는다 — 예: `OutboxCanonicalMetadata`가 `schemaUri` 있고 `schemaSubject` 없는 조합을 거절하는 것, `OutboxLease`가 `token < 1`을 거절하는 것, `InboxResult.isSafeToSettle`의 세 값. 형제 leaf들은 전부 자기 테스트를 갖는다(`messaging-core-api` 79개, `messaging-policy` 42개 등).
|
||||
- **확인 방법.** `ls src/messaging/messaging-reliability-api/src` → `main`만.
|
||||
- **후보.** record 불변식과 세 술어를 겨냥한 단위 테스트를 추가한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다).
|
||||
|
||||
### P3 — inbox 보존 규칙이 문서로만 있다
|
||||
|
||||
- **사실.** `InboxRepository.purgeProcessedBefore` javadoc이 "Retention must outlive the broker's maximum redelivery window, otherwise a late redelivery arrives after its inbox row was pruned and is processed a second time"라고 한다. 그 비교를 하는 코드가 이 leaf에도 `messaging-policy`의 프로파일 검증기에도 없다.
|
||||
- **근거.** 해당 javadoc. `DestinationProfileValidator` 16규칙 전수(재전달 창 관련 없음).
|
||||
- **왜 문제인가.** 위반의 결과가 **부작용의 이중 실행**이다 — Inbox가 존재하는 이유 그 자체가 무효화된다. 그리고 위반이 조용하다: 짧은 보존은 정상 동작처럼 보이고 늦은 재전달이 올 때만 드러난다.
|
||||
- **확인 방법.** `git grep -n -i 'redelivery window\|retention' -- 'src/messaging/**/*.java'`
|
||||
- **후보.** 보존 설정과 브로커 재전달 창을 시작 시 비교하는 검증을 `messaging-policy`나 starter에 추가한다.
|
||||
- **다음 단계.** **CASE 후보 + REFERENCE 후보**(두 시간 상수가 순서 관계를 가지면 그 관계를 시작 시 검사한다).
|
||||
|
||||
### P3 — 트랜잭션 계약 셋이 타입으로 강제되지 않는다
|
||||
|
||||
- **사실.** `OutboxRepository.append`가 호출자 트랜잭션 안, `InboxRepository.reserve`가 부작용과 같은 트랜잭션, `TransactionalMessageAction`이 자기 트랜잭션을 시작하지 않을 것 — 셋 다 javadoc 요구다.
|
||||
- **근거.** 세 javadoc.
|
||||
- **왜 문제인가.** `ReliableMessagePublisher`는 `void` 반환으로 계약의 일부를 타입에 담았다("Handing back a `PublishResult` here would be a lie"). 나머지 셋에는 그런 장치가 없고, 위반의 결과가 조용하다 — `InboxRepository.reserve`를 별도 트랜잭션에서 부르면 "exactly the gap the Inbox exists to close"가 다시 열린다.
|
||||
- **확인 방법.** 세 javadoc과 구현의 `@Transactional` 배치 대조 — 구현 leaf가 소유한다.
|
||||
- **후보.** 구현 leaf가 트랜잭션 참여를 검증하는 테스트를 두거나, ArchUnit으로 `append`/`reserve` 호출부의 트랜잭션 컨텍스트를 검사한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다).
|
||||
|
||||
### P3 — `OutboxRecord.equals`가 다섯 필드만 비교하고 이유가 없다
|
||||
|
||||
- **사실.** `equals`/`hashCode`가 `messageId`·`status`·`attempts`·`payload` 넷만 본다. `destination`·`metadata`·`createdAt`·`leaseExpiresAt`·`lastFailureCode`는 무시한다.
|
||||
- **근거.** `OutboxRecord.java:114-126`.
|
||||
- **왜 문제인가.** record 기본 동작을 좁힌 것이고, 배열 필드 때문에 재정의가 필요한 것까지는 명확하다(`messaging-schema-api`의 `EncodedMessage`도 같다). 그러나 `EncodedMessage`는 **모든 필드**를 비교하고 이쪽은 아니다. 같은 `messageId`·`status`·`attempts`·`payload`를 가진 두 행이 다른 목적지·다른 provenance를 가져도 같다고 판정된다. 컬렉션 연산이나 테스트 단언에서 의미가 달라진다.
|
||||
- **확인 방법.** 두 record의 `equals` 대조.
|
||||
- **후보.** 전 필드 비교로 바꾸거나 좁힌 이유를 javadoc에 적는다.
|
||||
- **다음 단계.** **REFERENCE 후보**(record의 `equals`를 좁히면 이유를 적는다).
|
||||
|
||||
### P3 — 포트가 bounded/unbounded purge 두 오버로드를 나란히 노출하고, 호출자가 무제한 쪽을 고른다
|
||||
|
||||
- **사실.** `InboxRepository`와 `OutboxRepository`가 각각 `purge*Before(Instant)`와 `purge*Before(Instant, int)`를 선언한다. 후자에 호출 지점이 0이고 두 cleanup job이 전자를 부른다.
|
||||
- **근거.** `evidence/raw/294-bounded-purge-never-called.txt`.
|
||||
- **왜 문제인가.** §12.1(a)의 두 세대 전이와 같은 형태다 — **한 인터페이스가 안전한 형태와 그렇지 않은 형태를 나란히 두고, `@Deprecated`도 이름 차이도 없으며, 호출자가 짧은 쪽을 골랐다.** 두 경우 모두 포트의 형태가 오용을 가능하게 했다.
|
||||
- **확인 방법.** `git grep -n -E 'purge(Processed|Published)Before\s*\([^)]*,' -- 'src/**/*.java'`
|
||||
- **다음 단계.** 판정은 `analysis/messaging/messaging-inbox-jdbc-postgresql.md` §17(P1)이 소유한다. 여기서는 포트 형태의 기여만 남긴다. §12.1(a)와 **같은 CASE로 묶을 후보**다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- outbox만으로 중복이 제거되지 않는다는 것을 타입 javadoc이 직접 말하는 것
|
||||
- fencing token과 전이 결과 반환값이 함께 있어야 stale lease가 관측된다는 설계
|
||||
- `AMBIGUOUS`/`FAILED`/`EXHAUSTED` 세 상태의 구분과 각각의 운영 행동 차이
|
||||
- `InboxResult`가 셋이고 `isSafeToSettle()`이 그 판단을 모으는 것
|
||||
- inbox 키가 (message, consumer)인 것
|
||||
- provenance를 컬럼으로 두고 대안(버전 봉투 인코딩)을 명시적으로 기각한 것
|
||||
- `withStatus`가 `messageId`를 파라미터로 받지 않아 전이가 신원을 바꿀 수 없는 것
|
||||
- `addToOutbox`의 `void` 반환이 계약인 것
|
||||
- claim check의 digest와 만료가 필수인 것
|
||||
- 두 outbox 모델을 ArchUnit으로 격리한 것
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MRA-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 1개, memberships `["app-bootstrap"]` | 선언 |
|
||||
| MRA-002 | build | `messaging-reliability-api/build.gradle` | same | 벤더 의존성 0 | — |
|
||||
| MRA-003 | code | `.../reliability/OutboxRepository.java` 전문 | same | 두 세대 17메서드, purge limit 이유 | `@Deprecated` 없음 |
|
||||
| MRA-004 | code | `.../reliability/OutboxLease.java` | same | fencing token과 이중 발행 이력 | — |
|
||||
| MRA-005 | code | `.../reliability/OutboxTransitionResult.java` | same | void 반환이 삼킨 것 | — |
|
||||
| MRA-006 | code | `.../reliability/OutboxStatus.java` | same | 여섯 상태와 두 구분의 이유 | — |
|
||||
| MRA-007 | code | `.../reliability/OutboxCanonicalMetadata.java` | same | provenance 결함 이력, 기각된 대안 | — |
|
||||
| MRA-008 | code | `.../reliability/OutboxRecord.java` | same | 두 반쪽 분리, 방어 복사, 좁은 equals | equals 이유 없음(§17) |
|
||||
| MRA-009 | code | `.../reliability/{InboxRepository,InboxRecord,InboxResult}.java` | same | 트랜잭션 계약, (message,consumer) 키, 세 판정 | `InboxRecord` 참조 0 |
|
||||
| MRA-010 | code | `.../reliability/{IdempotentMessageHandler,TransactionalMessageAction}.java` | same | 멱등 핸들러 계약과 세 금지 | 금지 미강제 |
|
||||
| MRA-011 | code | `.../reliability/{ReliableMessagePublisher,ClaimCheckReference}.java` | same | dual-write 답, digest 필수 | publisher 구현 0 |
|
||||
| MRA-012 | cross-leaf code | `messaging-outbox-jdbc-postgresql/.../OutboxRelay.java:158-205` | same | production이 신세대만 사용 | 해당 leaf SSOT가 소유 |
|
||||
| MRA-013 | cross-leaf test | `messaging-outbox-jdbc-postgresql/.../OutboxPostgresIT.java:92-200` | same | 컨테이너 테스트가 구세대만 사용 | 해당 leaf SSOT가 소유 |
|
||||
| MRA-014 | architecture test | `src/app-bootstrap/.../CleanArchitectureTest.java:229-240` | same | `OutboxStatus.FAILED` 의미가 규칙의 근거 | 정적 분석 |
|
||||
| EVD-289 | command | `evidence/raw/289-reliability-api-two-generations.txt` | same | §12.1 전부, `src/test` 부재 | 정적 검색. 이 leaf에 테스트 레인 없음 |
|
||||
@@ -0,0 +1,807 @@
|
||||
# messaging-runtime-core 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-runtime-core`
|
||||
> SSOT owner: `messaging-runtime-core`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- registered leaf id: `messaging-runtime-core`
|
||||
- canonical state `analysisFile`: `analysis/messaging/messaging-runtime-core.md`
|
||||
- source path: `src/messaging/messaging-runtime-core`
|
||||
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api", "messaging-policy", "messaging-transport-spi", "messaging-security", "messaging-observability"]` — messaging family에서 두 번째로 많은 의존
|
||||
- registry `runtime_memberships`: `["app-bootstrap"]`
|
||||
|
||||
### 숫자
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | **6** |
|
||||
| production LOC | 787 |
|
||||
| 패키지 | 1 (`dev.caskeleton.messaging.runtime`) |
|
||||
| test 파일 | 4 (테스트 3 + fixture 1) |
|
||||
| test 메서드(실행 확인) | 21 |
|
||||
| 외부(비프로젝트) 의존성 | **0** |
|
||||
|
||||
여섯 클래스:
|
||||
|
||||
| 클래스 | LOC | 역할 | 출하 조립 |
|
||||
|---|---:|---|---|
|
||||
| `DefaultMessagePublisher` | 366 | **유일한 발행 경로** | o (`:446`) |
|
||||
| `DefaultDeliveryProcessor` | 155 | 핸들러 결과 → 정산 | **x** |
|
||||
| `RegisteredMessageCodecs` | 89 | content type → codec | o (`:363`) |
|
||||
| `DestinationProfileRegistry` | 62 | 논리 이름 → 프로파일 | o (`:377`) |
|
||||
| `TransportMessagingRuntime` | 67 | transport를 세대로 포장 | o (`:476`) |
|
||||
| `DeclaredDestinationAccess` | 48 | 기본 접근 정책 | o |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 및 단언 확인 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 주석 포함 17줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
**이 leaf는 조립 결함 하나를 고치기 위해 만들어졌다.** 여섯 파일 중 다섯의 javadoc이 "X was an interface with no implementation" 형태로 시작한다. `build.gradle`이 그 사정을 파일 맨 위에 적는다.
|
||||
|
||||
```groovy
|
||||
// The central publish and delivery orchestration.
|
||||
//
|
||||
// MessagePublisher was an interface with no implementation anywhere in the new platform: the
|
||||
// brokers implemented MessagingTransport, the core auto-configuration built dead-letter and facade
|
||||
// beans on top of a publisher bean that nothing supplied, and admission, security, runtime leases
|
||||
// and observation existed as beans that no publish path ever called. A starter that filled the gap
|
||||
// with an application-supplied fake would pass a context test while running none of them.
|
||||
```
|
||||
|
||||
이 진단의 마지막 문장이 핵심이다 — **컨텍스트 테스트를 통과하면서 아무것도 실행하지 않는 조립**이 가능했다는 것. 이 저장소가 반복해서 만나는 형태다.
|
||||
|
||||
six 파일이 메운 구멍:
|
||||
|
||||
| 인터페이스(소유 leaf) | 구현이 없었음 | 이 leaf가 채운 것 |
|
||||
|---|---|---|
|
||||
| `MessagePublisher` (core-api) | 어디에도 없음 | `DefaultMessagePublisher` |
|
||||
| `MessageCodecRegistry` (schema-api) | 어디에도 없음 | `RegisteredMessageCodecs` |
|
||||
| `MessagingRuntime` (transport-spi) | 어디에도 없음 | `TransportMessagingRuntime` |
|
||||
| (없음) 논리이름→프로파일 해석 | 아무도 하지 않음 | `DestinationProfileRegistry` |
|
||||
| `DestinationAccessPolicy` 기본값 (security) | `denyAll()`뿐 | `DeclaredDestinationAccess` |
|
||||
| `HandleResult` → 정산 (core-api) | 어댑터가 각자 결정 | `DefaultDeliveryProcessor` |
|
||||
|
||||
여섯 중 다섯은 배선됐고 마지막 하나(`DefaultDeliveryProcessor`)는 배선되지 않았다(§12.1).
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
들어오는 것: 여섯 project 의존, 전부 `api`. `DefaultMessagePublisher` 한 클래스가 그중 다섯을 생성자로 받으므로 `api`가 맞다.
|
||||
|
||||
나가는 것: `messaging-spring-boot-starter`만.
|
||||
|
||||
**배선 지점 다섯**(전부 `MessagingCoreAutoConfiguration`):
|
||||
|
||||
| 라인 | 무엇 |
|
||||
|---:|---|
|
||||
| 363 | `RegisteredMessageCodecs.of(JacksonMessageCodec.of(...))` |
|
||||
| 377 | `DestinationProfileRegistry.of(destinations.all())` |
|
||||
| 446 | `new DefaultMessagePublisher(destinations, access, codecs, admission, runtimes, transport)` |
|
||||
| 476 | `new TransportMessagingRuntime(selected.brokerName(), 1L, selected)` — `InitializingBean` 안 |
|
||||
| — | `DeclaredDestinationAccess.of(...)`로 접근 정책 bean |
|
||||
|
||||
446의 인자가 **여섯 개**라는 것이 §12.1의 관측 지점이다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
```
|
||||
발행 (조립됨)
|
||||
DefaultMessagePublisher
|
||||
├── DestinationProfileRegistry 논리 이름 → DestinationProfile
|
||||
├── DestinationAccessPolicy ← DeclaredDestinationAccess.of(profiles)
|
||||
├── MessageCodecRegistry ← RegisteredMessageCodecs
|
||||
├── MessagingAdmissionController (policy)
|
||||
├── MessagingRuntimeRegistry (transport-spi) → TransportMessagingRuntime
|
||||
├── MessagingTransport (transport-spi) → Kafka/Rabbit/…
|
||||
└── MessagingObservation ← NO_OBSERVATION (§12.1)
|
||||
|
||||
소비 (조립 안 됨)
|
||||
DefaultDeliveryProcessor
|
||||
├── Function<MessageEnvelope<EncodedMessage>, HandleResult>
|
||||
├── DeadLetterPublisher (내부 함수형 인터페이스)
|
||||
└── OneShotSettlement → TransportSettlement
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
### 4.1 `DefaultMessagePublisher` — 순서가 계약이다
|
||||
|
||||
```java
|
||||
// :40-49
|
||||
* <p>The order below is fixed, not composed from a map of interceptors. Each stage's position is a
|
||||
* decision:
|
||||
*
|
||||
* <ul>
|
||||
* <li>destination and access first, so an unauthorized publish never encodes a payload;
|
||||
* <li>encoding before admission, because the admission bound is on bytes and the byte count is
|
||||
* not known until the payload is encoded;
|
||||
* <li>the runtime lease last before the send, so a rotation cannot swap the transport underneath
|
||||
* a message that has already been counted against the in-flight limit.
|
||||
* </ul>
|
||||
```
|
||||
|
||||
실제 순서 여덟 단계:
|
||||
|
||||
| # | 단계 | 실패 시 |
|
||||
|---:|---|---|
|
||||
| 1 | `destinations.require(name)` | `DESTINATION_NOT_REGISTERED` → `REJECTED` |
|
||||
| 2 | `requireSupportedOptions(profile, options)` | `PUBLISH_DEDUPLICATION_UNSUPPORTED` → `REJECTED` |
|
||||
| 3 | `access.mayPublish(name)` | `PUBLISH_FORBIDDEN` → `REJECTED` (**인코딩 전**) |
|
||||
| 4 | `encode(message)` | `PUBLISH_PREPARATION_FAILED` → `REJECTED` |
|
||||
| 5 | 남은 예산 확인 | `PUBLISH_DEADLINE_EXCEEDED` → `REJECTED` |
|
||||
| 6 | `admission.admit(name, bytes)` | 예외 전파(`MessageTooLargeException`/`MessageBackpressureException`) |
|
||||
| 7 | `runtimes.acquire(broker)` | `PUBLISH_RUNTIME_UNAVAILABLE` → `REJECTED` |
|
||||
| 8 | `transport.publish(...)` + 마감 | 타임아웃 → `AMBIGUOUS` / 그 외 예외 → `AMBIGUOUS` |
|
||||
|
||||
**1–7은 전부 `REJECTED`, 8만 `AMBIGUOUS`다.** 그 경계가 정확히 "바이트가 프로세스를 떠났는가"다.
|
||||
|
||||
```java
|
||||
} catch (RuntimeException beforeTheWire) {
|
||||
// Nothing left this process, so the outcome is definite. Reporting it as ambiguous would send
|
||||
// the caller into reconciliation for a message no broker ever saw.
|
||||
return rejected("PUBLISH_PREPARATION_FAILED", sanitized(beforeTheWire), startedAt);
|
||||
}
|
||||
```
|
||||
|
||||
`messaging-core-api`의 3상태(§4.1)가 여기서 실제 분기가 된다. 그리고 `rejected(...)`가 만드는 `PublishResult`는 `PublishEvidence.notTransmitted()`를 쓰므로 `PublishResult` 생성자의 14가지 금지 조합 검증을 자연히 통과한다.
|
||||
|
||||
**3번이 4번보다 먼저인 이유**가 인라인 주석에 있다.
|
||||
|
||||
```java
|
||||
// Before encoding: an unauthorized publish must not serialise the payload, because the
|
||||
// encoded bytes are what a claim-check or a log would then be holding.
|
||||
```
|
||||
|
||||
### 4.2 예산은 호출 시점부터 센다
|
||||
|
||||
```java
|
||||
// :131-138
|
||||
* <p>Measured from the call, not from the send. {@code PublishOptions.timeout()} is documented as
|
||||
* the publish operation's deadline, so a slow destination lookup or a large encode spends the
|
||||
* same budget the broker wait does; timing only the transport call would let the total exceed the
|
||||
* deadline by however long preparation took.
|
||||
```
|
||||
|
||||
`remainingBudget`이 `timeout - elapsedSince(startedAt)`이고, 0 이하면 전송 전에 `REJECTED`로 끝낸다 — "Sending anyway would start a message the caller has already stopped waiting for."
|
||||
|
||||
### 4.3 마감을 복사본에 건다
|
||||
|
||||
```java
|
||||
// :143-154
|
||||
* <p>The bound is applied to a copy so that expiry never completes the transport's own stage: the
|
||||
* adapter still owns its in-flight publish and its own bookkeeping. The permit and the runtime
|
||||
* lease are released when the copy completes, which is deliberate — holding them until a stalled
|
||||
* broker answers is how a rotation waits forever on a generation nobody is using.
|
||||
private static CompletableFuture<TransportPublishResult> withDeadline(
|
||||
CompletionStage<TransportPublishResult> inFlight, Duration remaining) {
|
||||
return inFlight.toCompletableFuture().copy()
|
||||
.orTimeout(remaining.toMillis(), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
```
|
||||
|
||||
`.copy()`가 핵심이다. `orTimeout`을 원본에 걸면 만료가 어댑터의 stage를 완료시켜 어댑터의 자기 정리가 깨진다. 복사본에 걸면 만료는 이쪽 경로만 끝내고 어댑터는 자기 in-flight를 계속 소유한다.
|
||||
|
||||
그 대가도 명시돼 있다 — permit과 lease는 **복사본이 완료될 때** 반납되므로, 브로커가 나중에 응답해도 이미 반납된 상태다. 그것이 의도다("holding them until a stalled broker answers is how a rotation waits forever").
|
||||
|
||||
### 4.4 획득한 것은 모든 경로에서 정확히 한 번 반납된다
|
||||
|
||||
```java
|
||||
// :51-53
|
||||
* <p>Everything acquired is released exactly once, on every path — success, failure, exception and
|
||||
* cancellation. A permit or lease that leaks on the failure path is a limiter that shrinks by one
|
||||
* per failure until it stops accepting anything.
|
||||
```
|
||||
|
||||
두 경로가 있다.
|
||||
|
||||
```java
|
||||
.handle((result, failure) -> {
|
||||
// One release per acquisition, whatever happened.
|
||||
held.close();
|
||||
admission.complete(destination.name().value());
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
```java
|
||||
} catch (RuntimeException beforeTheSend) {
|
||||
if (lease != null) { lease.close(); }
|
||||
admission.complete(destination.name().value());
|
||||
return rejected("PUBLISH_RUNTIME_UNAVAILABLE", ...);
|
||||
}
|
||||
```
|
||||
|
||||
`handle`은 `whenComplete`와 달리 실패를 삼키고 값을 반환하므로 두 경우가 한 블록에서 처리된다. `lease.close()`는 `MessagingRuntimeLease` 계약상 멱등이고(`transport-spi` §4.1), `admission.complete`도 미보유 목적지에 대해 무해하다(`messaging-policy` §4.3).
|
||||
|
||||
**한 가지 비대칭.** 6번(`admit`)이 예외를 던지면 그 예외가 그대로 호출자에게 전파된다 — `try` 블록 밖이다. 다른 모든 실패는 `PublishResult`로 정규화되는데 admission 실패만 예외다. `MessageTooLargeException`·`MessageBackpressureException`은 `MessagingException`이므로 호출자가 `FailureDescriptor`를 얻을 수 있지만, 반환 타입이 `CompletionStage<PublishResult>`인 메서드가 **동기적으로 throw**한다. §17.
|
||||
|
||||
### 4.5 `requireSupportedOptions` — 조용한 no-op을 막는다
|
||||
|
||||
```java
|
||||
// :65-72
|
||||
* <p>The transports accept {@code request.options()} and read nothing from it, so an option this
|
||||
* destination cannot honour has to be refused here or it is honoured nowhere. A caller asking for
|
||||
* broker-side deduplication got a publish with no deduplication and no error, and then skipped
|
||||
* the idempotency it would otherwise have written — which is exactly the case {@code
|
||||
* PublishDeduplication}'s own javadoc says must be a startup failure rather than a silent no-op.
|
||||
```
|
||||
|
||||
`messaging-core-api`의 `PublishDeduplication` javadoc("Requesting this on a broker without the `deduplicatedPublish` capability is a startup failure, not a silent no-op")이 여기서 실제 검사가 된다. 다만 **startup이 아니라 publish 시점**이다 — javadoc이 요구한 시점과 실제 시점이 다르다. §17.
|
||||
|
||||
그리고 "The transports accept `request.options()` and read nothing from it"은 이 leaf가 관측한 어댑터 쪽 사실이다. 어댑터 leaf SSOT들이 그것을 확인해야 한다.
|
||||
|
||||
### 4.6 `encode` — 폴백이 기본 codec이다
|
||||
|
||||
```java
|
||||
private <T> MessageEnvelope<EncodedMessage> encode(MessageEnvelope<T> message) {
|
||||
MessageCodec codec = codecs.find(message.contentType()).orElseGet(codecs::defaultCodec);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
봉투의 content type에 맞는 codec이 없으면 기본 codec으로 인코딩한다. **content type을 무시하는 폴백**이다 — 봉투가 `application/avro`를 선언해도 registry에 Avro codec이 없으면 JSON으로 인코딩되고, `EncodedMessage`의 content type은 codec이 정하므로(`ContentType.JSON`) 봉투 선언과 실제 인코딩이 갈라진다. 그리고 출하 registry에는 JSON 하나뿐이다(`analysis/messaging/messaging-schema-json.md` §2). §17.
|
||||
|
||||
`RegisteredMessageCodecs.defaultCodec()`이 raw bytes일 수 없다는 것은 그 클래스가 생성자에서 강제한다(§4.8).
|
||||
|
||||
### 4.7 `DestinationProfileRegistry` — 폴백 없는 조회
|
||||
|
||||
```java
|
||||
// :13-18
|
||||
* <p>Nothing resolved a logical destination to a profile before this: the brokers took an
|
||||
* already-resolved {@code DestinationProfile} and the publisher that would have produced one did
|
||||
* not exist. A registry rather than a lookup with a fallback, because a destination nobody declared
|
||||
* has no physical name, no ordering guarantee and no payload bound — publishing to it would mean
|
||||
* inventing all three at the call site.
|
||||
```
|
||||
|
||||
`require`가 미등록 목적지에 `MessagingConfigurationException("DESTINATION_NOT_REGISTERED")`을 던지고 메시지가 세 가지 부재를 나열한다. `empty()` factory도 있다 — "every publish is refused until a destination is declared".
|
||||
|
||||
### 4.8 `RegisteredMessageCodecs` — 기본 codec은 명시 선택
|
||||
|
||||
```java
|
||||
// :18-27
|
||||
* <p>The default codec is a deliberate choice rather than "the first one registered". Selecting one
|
||||
* by iteration order means the encoding a message is written with depends on how the map was
|
||||
* populated, which is a wire-format decision made by accident. The registry takes it explicitly and
|
||||
* refuses to be constructed without it.
|
||||
*
|
||||
* <p>The raw-bytes codec is never eligible as the default — that is the contract's own rule, and
|
||||
* the reason is that raw bytes silently disable schema validation for every destination that forgot
|
||||
* to declare an encoding.
|
||||
```
|
||||
|
||||
두 가지를 생성자에서 거절한다.
|
||||
|
||||
```java
|
||||
if (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) { throw ... }
|
||||
...
|
||||
MessageCodec existing = into.putIfAbsent(codec.contentType(), codec);
|
||||
if (existing != null && existing != codec) {
|
||||
// Two codecs for one content type is not a preference to resolve at runtime: whichever wins
|
||||
// decides how bytes on the wire are read by a consumer that was compiled against the other.
|
||||
throw new IllegalArgumentException("two codecs claim content type " + ...);
|
||||
}
|
||||
```
|
||||
|
||||
**클래스가 아니라 content type으로 raw-bytes를 거절**하는 것이 `messaging-schema-api`의 규칙보다 넓다 — 그 leaf §12.2가 소유한다.
|
||||
|
||||
### 4.9 `TransportMessagingRuntime` — 얇은 포장
|
||||
|
||||
`MessagingRuntime` 구현으로 `brokerName`·`generation`·`transport` 셋을 들고 `close()`가 CAS로 멱등이다.
|
||||
|
||||
```java
|
||||
// close():61-62
|
||||
// Idempotent: the registry closes a drained generation, and a context shutdown may close it
|
||||
// again. Closing a transport twice is not an error worth propagating into shutdown.
|
||||
```
|
||||
|
||||
`DefaultMessagingRuntimeRegistry`(transport-spi)도 자체 `closed` CAS를 갖는다 — **두 층이 각각 멱등**이다. 중복 방어이지만 `transport-spi`의 `Generation.forceClose()`가 이미 한 번만 부르므로 이쪽 CAS는 컨텍스트 종료 경로를 위한 것이다.
|
||||
|
||||
**generation이 항상 `1L`이다.** starter의 유일한 설치 지점(`:476`)이 리터럴 `1L`을 넘긴다. `MessagingRuntime.generation()` javadoc은 "increasing with each replacement"라고 하고, `TransportMessagingRuntime` javadoc은 "the credential generation a rotation increments"라고 한다. 회전 코드가 없으므로 항상 1이다. §17.
|
||||
|
||||
### 4.10 `DeclaredDestinationAccess` — 기본값의 세 번째 선택지
|
||||
|
||||
```java
|
||||
// :13-32
|
||||
* <p>{@link DestinationAccessPolicy} is three sets of destination names and has a {@code denyAll()}
|
||||
* factory. Neither is a usable default on its own:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Deny everything</b> and the platform assembles, starts, and refuses every publish …
|
||||
* <li><b>Allow everything</b> and the check is decoration. …
|
||||
* </ul>
|
||||
*
|
||||
* <p>So the default is neither: <b>a deployment may publish to the destinations it declared.</b>
|
||||
* … a message to a destination nobody declared is not an access-control edge case, it is a typo or
|
||||
* a module reaching past its own contract.
|
||||
*
|
||||
* <p>Consume and administer stay empty. A publisher's default has no business granting either, and
|
||||
* a deployment that needs them replaces this bean — which is the point of it being a bean.
|
||||
```
|
||||
|
||||
**publish만 허용하고 consume·administer는 빈 집합**이다. 이것이 §12.1의 소비 경로 미조립과 정합적이다 — 기본 접근 정책이 소비를 허용하지 않는다.
|
||||
|
||||
### 4.11 `DefaultDeliveryProcessor` — 두 규칙 (미조립)
|
||||
|
||||
```java
|
||||
// :27-36
|
||||
* <li><strong>One terminal call.</strong> A delivery is acknowledged, requeued or discarded once.
|
||||
* A second call is a programming error … acknowledging after a requeue tells the broker the
|
||||
* message is done while a copy is already in flight.
|
||||
* <li><strong>Dead-letter before acknowledgement.</strong> The source is acknowledged only after
|
||||
* the dead-letter publish is confirmed. …
|
||||
```
|
||||
|
||||
`OneShotSettlement`이 `AtomicBoolean` CAS로 한 번을 강제하고, 두 번째 호출은 `CompletableFuture.failedFuture(IllegalStateException)`을 반환한다 — 예외를 던지지 않고 stage로 보고한다.
|
||||
|
||||
핸들러 예외 처리에 이전 결함이 기록돼 있다.
|
||||
|
||||
```java
|
||||
} catch (RuntimeException handlerFailed) {
|
||||
// A handler that threw is a retry, not a discard. Treating an exception as "this message is
|
||||
// undeliverable" is how a transient bug in one consumer silently drops a day of traffic —
|
||||
// and it is exactly what the Rabbit consumer did by folding handler exceptions into its
|
||||
// deserialization-failure path.
|
||||
return settlement.requeue(retryDelay);
|
||||
}
|
||||
```
|
||||
|
||||
`result == null`도 requeue다. 그런데 그것을 서술하는 `missingResult()` 정적 메서드가 있고 **아무도 부르지 않는다** — `HANDLER_RETURNED_NOTHING` 코드가 만들어지지만 어떤 경로도 그 descriptor를 사용하지 않는다. §17.
|
||||
|
||||
DLQ 분기의 두 주석이 trade를 명시한다.
|
||||
|
||||
```java
|
||||
? settlement.acknowledge() // Confirmed: the message exists somewhere else, so removing it here is safe.
|
||||
: settlement.requeue(retryDelay); // Not confirmed — rejected or ambiguous. Requeueing risks a
|
||||
// duplicate; acknowledging loses the message outright, and a
|
||||
// duplicate is the recoverable half of that choice.
|
||||
```
|
||||
|
||||
`messaging-policy`의 `DeadLetterOrchestrator`가 같은 불변식을 다른 형태로 구현한다(§12.3).
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
**발행(조립됨):** §4.1의 8단계.
|
||||
|
||||
**소비(미조립):** `TransportDelivery` → `handler.apply(envelope)` → `HandleResult` 4분기 → `OneShotSettlement`로 정확히 한 번 정산.
|
||||
|
||||
**세대 설치(조립됨):** `InitializingBean` → `transport.getIfAvailable()` → null이면 조용히 반환(이유가 주석에 있음) → `new TransportMessagingRuntime(brokerName, 1L, transport)` → `runtimes.install(...)`.
|
||||
|
||||
---
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
`DefaultMessagePublisher`가 만드는 결과:
|
||||
|
||||
| 코드 | completion | category | 언제 |
|
||||
|---|---|---|---|
|
||||
| `PUBLISH_FORBIDDEN` | `REJECTED` | `CONFIGURATION` | 접근 정책 거부 |
|
||||
| `PUBLISH_PREPARATION_FAILED` | `REJECTED` | `CONFIGURATION` | 해석·인코딩 중 예외 |
|
||||
| `PUBLISH_DEADLINE_EXCEEDED` | `REJECTED` | `CONFIGURATION` | 전송 전 예산 소진 |
|
||||
| `PUBLISH_RUNTIME_UNAVAILABLE` | `REJECTED` | `CONFIGURATION` | lease 획득 실패 |
|
||||
| `PUBLISH_DEADLINE_EXCEEDED` | `AMBIGUOUS` | `AMBIGUOUS` | 전송 후 마감 |
|
||||
| `PUBLISH_OUTCOME_UNKNOWN` | `AMBIGUOUS` | `AMBIGUOUS` | 전송 후 그 외 실패 |
|
||||
|
||||
같은 코드 `PUBLISH_DEADLINE_EXCEEDED`가 **두 completion에 쓰인다.** 전송 전이면 `REJECTED`, 후면 `AMBIGUOUS`다. 코드만 보는 대시보드는 두 경우를 구분할 수 없다 — completion을 함께 봐야 한다. §17.
|
||||
|
||||
`sanitized(Throwable)`가 메시지가 아니라 **타입 이름만** 남긴다.
|
||||
|
||||
```java
|
||||
// :175-180
|
||||
* <p>A driver message can carry a routing key, a payload fragment or a connection string, and a
|
||||
* {@code FailureDescriptor} is designed to be logged and exported.
|
||||
return cause.getClass().getSimpleName();
|
||||
```
|
||||
|
||||
`messaging-core-api`의 `FailureDescriptor` javadoc("no payload, no stack trace, no credential")과 같은 관심사다.
|
||||
|
||||
`isDeadline`과 `sanitized` 둘 다 `CompletionException`을 한 겹 벗긴다 — 비동기 경로에서 원인이 감싸지기 때문이다.
|
||||
|
||||
`DefaultDeliveryProcessor`는 예외를 던지지 않는다. 이중 정산만 `failedFuture`로 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
트랜잭션 없음.
|
||||
|
||||
| 지점 | 도구 | 보호 |
|
||||
|---|---|---|
|
||||
| `OneShotSettlement.settled` | `AtomicBoolean` CAS | 정확히 한 번 정산 |
|
||||
| `TransportMessagingRuntime.closed` | `AtomicBoolean` CAS | 정확히 한 번 transport close |
|
||||
| `RegisteredMessageCodecs.byContentType` | `Map.copyOf` | 불변 |
|
||||
| `DestinationProfileRegistry.profiles` | `Map.copyOf` | 불변 |
|
||||
| `withDeadline`의 `.copy()` | `CompletableFuture` | 어댑터 stage와 이쪽 경로 분리 |
|
||||
|
||||
`DefaultMessagePublisher` 자체는 불변이고 상태를 갖지 않는다 — 필드 여덟이 전부 final 협력자다. `lease`만 메서드 지역 변수이고 `handle` 람다가 `held`라는 effectively-final 복사본으로 캡처한다.
|
||||
|
||||
수명주기 참여는 `TransportMessagingRuntime.close()`뿐이고, 그것을 부르는 것은 registry(회전 시)와 컨텍스트 종료 두 경로다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
설정 없음. 이 leaf의 모든 값은 생성자 인자다.
|
||||
|
||||
**주입 가능한 두 지점**이 테스트 가능성을 만든다.
|
||||
|
||||
| 인자 | 기본 | 목적 |
|
||||
|---|---|---|
|
||||
| `LongSupplier nanoTime` | `System::nanoTime` | 경과 시간을 sleep 없이 테스트 |
|
||||
| `MessagingObservation observation` | `NO_OBSERVATION` | 관측 주입 |
|
||||
|
||||
두 번째의 기본값이 §12.1의 발견 지점이다.
|
||||
|
||||
`TransportMessagingRuntime`의 `generation`은 생성자 인자이고 유일한 호출자가 `1L`을 넘긴다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
없다. 브로커 접촉은 `MessagingTransport` 인터페이스 뒤에 있다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
레인: `./gradlew :messaging:messaging-runtime-core:test`. **BUILD SUCCESSFUL, 21 tests, 0 skipped, 0 failures**.
|
||||
|
||||
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|
||||
|---|---:|---|---|
|
||||
| `DefaultMessagePublisherTest` | 10 | 8단계 순서, 각 실패의 completion·code, 마감 전후 구분, permit/lease 반납, 관측 호출 | 실제 브로커. **출하 조립이 관측을 넘기는지** |
|
||||
| `DefaultDeliveryProcessorTest` | 7 | `HandleResult` 4분기 → 정산, 핸들러 예외 → requeue, DLQ 확인 후 ack / 미확인 시 requeue, 이중 정산 거절 | **production에서 호출되는지**(§12.1) |
|
||||
| `RegisteredMessageCodecsTest` | 4 | raw-bytes 기본 거절, content type 충돌 거절, 조회 | — |
|
||||
|
||||
`DefaultMessagePublisherTest:271`이 익명 `MessagingObservation`을 만들어 관측 호출을 확인한다. 즉 **테스트는 8인자 생성자를 쓰고 출하는 6인자를 쓴다.** 테스트가 검증하는 경로와 출하되는 경로가 이 인자 하나만큼 다르다.
|
||||
|
||||
`RecordingTransport`(`:426`)가 `MessagingTransport`를 구현해 전송을 대체한다. 그래서 이 레인은 "발행 오케스트레이션이 옳다"를 증명하고 "어댑터가 계약을 지킨다"는 증명하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
| 게이트 | 이 leaf에 대해 |
|
||||
|---|---|
|
||||
| `verifyCleanArchitectureDependencies` | 여섯 project 의존 |
|
||||
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
|
||||
| vendor `api` 규칙 | 벤더 의존성 0 |
|
||||
| ArchUnit | 전용 규칙 없음 |
|
||||
|
||||
`MessagingStarterOffContractTest`(starter leaf)가 이 leaf의 조립 이력을 문자열로 언급한다 — "DeadLetterOrchestrator had nothing to depend on. DefaultMessagePublisher …". 그 테스트가 무엇을 실제로 강제하는지는 starter leaf SSOT가 소유한다.
|
||||
|
||||
---
|
||||
|
||||
## 12. 실제 사용 여부와 negative-space probes
|
||||
|
||||
원시 증거: `evidence/raw/283-runtime-core-observation-noop.txt`.
|
||||
|
||||
### 12.1 Public surface reachability
|
||||
|
||||
| 타입 | leaf 밖 파일 | 출하 조립 |
|
||||
|---|---:|---|
|
||||
| `DefaultMessagePublisher` | 2 | **o** — `MessagingCoreAutoConfiguration:446` |
|
||||
| `TransportMessagingRuntime` | 1 | **o** — `:476` |
|
||||
| `RegisteredMessageCodecs` | 1 | **o** — `:363` |
|
||||
| `DestinationProfileRegistry` | 1 | **o** — `:377` |
|
||||
| `DeclaredDestinationAccess` | 1 | **o** |
|
||||
| `DefaultDeliveryProcessor` | **0** | **x** — `src/main` 생성 0, `src/test` 1 |
|
||||
|
||||
**(a) 소비 경로의 유일한 오케스트레이터가 조립되지 않는다**
|
||||
|
||||
`DefaultDeliveryProcessor`는 leaf 밖 참조가 0이고 `src/main`에서 생성되지 않는다. 이것이 `analysis/messaging/messaging-policy.md` §12.1이 관측한 "소비 경로 전체 미조립"의 중심이다 — 어댑터의 consumer registrar들도, 재시도 실행자도, DLQ 발행자도 전부 조립되지 않는다.
|
||||
|
||||
이 클래스의 javadoc은 자기가 **고친** 문제를 서술한다 — "Each broker adapter decided for itself what a retry or a dead-letter meant, so '_the platform decides when and in what order the settlement happens_' … described a decision nobody made in one place." 그 결정을 한 곳에 모았고, 그 한 곳이 배선되지 않았다.
|
||||
|
||||
**(b) 관측이 구현·호출부·인자를 모두 갖추고도 no-op이다**
|
||||
|
||||
네 조각이 있다.
|
||||
|
||||
| 조각 | 상태 |
|
||||
|---|---|
|
||||
| `MessagingObservation` 인터페이스 (observability) | 존재 |
|
||||
| `MessagingMetrics implements MessagingObservation` | 존재 |
|
||||
| `DefaultMessagePublisher.observe(...)` 호출부 | 존재, 모든 발행 결과를 기록 |
|
||||
| 8인자 생성자 (관측 주입) | 존재 |
|
||||
| **출하 조립** | **6인자 생성자 → `NO_OBSERVATION`** |
|
||||
| **`MessagingMetrics` bean** | **없음** |
|
||||
|
||||
```java
|
||||
// MessagingCoreAutoConfiguration.java:446-447
|
||||
return new dev.caskeleton.messaging.runtime.DefaultMessagePublisher(
|
||||
destinations, access, codecs, admission, runtimes, transport);
|
||||
```
|
||||
|
||||
그리고 `MessagingMetrics`는 저장소 전체에서 **자기 테스트에서만** 생성된다(`MessagingMetricCardinalityTest`, `MessagingSecretLeakTest`).
|
||||
|
||||
starter는 `MessagingMetrics`의 **두 협력자를 bean으로 만든다** — `MessagingRedactor`(:253)와 `CardinalityGuard`(:264). `MessagingMetrics`의 생성자는 `(registry, CardinalityGuard, MessagingRedactor)`를 받는다(테스트가 그렇게 호출한다). 즉 **재료 둘은 배선됐고 그것을 조립하는 bean이 없다.**
|
||||
|
||||
이 클래스의 javadoc이 그 상황을 예언한다.
|
||||
|
||||
```java
|
||||
// DefaultMessagePublisher.java:74-78
|
||||
* <p>{@code MessagingObservation} existed as a bean and no publish path called it, so the
|
||||
* platform's own metrics described nothing. It is a constructor argument rather than an optional
|
||||
* decorator because an unobserved publish path is how "the dashboards were empty during the
|
||||
* incident" happens.
|
||||
```
|
||||
|
||||
**이전 상태:** bean은 있고 호출하는 경로가 없었다.
|
||||
**현재 상태:** 호출하는 경로는 있고 bean이 없다.
|
||||
|
||||
두 상태의 관측 결과는 같다 — 메트릭이 비어 있다. 고침이 간극을 닫은 것이 아니라 **반대편으로 옮겼다.** 그리고 "constructor argument rather than an optional decorator"라는 선택이 그것을 막지 못했다 — 인자를 기본값으로 채우는 짧은 생성자가 함께 존재하기 때문이다.
|
||||
|
||||
**(c) 배선된 것은 확실히 배선됐다**
|
||||
|
||||
발행 경로 다섯이 전부 `src/main`에서 생성된다(§2 표). 대조군으로서 이 사실이 (a)와 (b)의 판정을 뒷받침한다 — 검색 방법이 조립을 놓치는 것이 아니라 실제로 조립되지 않은 것이다.
|
||||
|
||||
**한계.** 정적 검색이다. `ObjectProvider` 지연 조회는 `MessageContracts`와 `MessagingTransport` 두 곳에만 쓰이고 둘 다 확인했다. 파생 프로젝트가 `MessagingObservation` bean을 제공하면 `@ConditionalOnMissingBean(MessagePublisher.class)` 때문에 publisher bean 자체를 대체해야 한다 — 관측만 끼워 넣을 수는 없다.
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
이 leaf에 bean은 없다. starter 쪽 sibling 비교가 유의미하다.
|
||||
|
||||
`MessagingCoreAutoConfiguration`이 이 leaf의 타입을 만드는 지점 다섯의 조건:
|
||||
|
||||
| 대상 | 조건 |
|
||||
|---|---|
|
||||
| `RegisteredMessageCodecs` | `@ConditionalOnMissingBean(MessageCodecRegistry.class)` |
|
||||
| `DestinationProfileRegistry` | `@ConditionalOnMissingBean` |
|
||||
| `DefaultMessagePublisher` | `@ConditionalOnMissingBean(MessagePublisher.class)` |
|
||||
| `TransportMessagingRuntime` | 조건 없음 — `InitializingBean` 안, `transport.getIfAvailable()` null 검사 |
|
||||
| `DeclaredDestinationAccess` | `@ConditionalOnMissingBean` |
|
||||
|
||||
**네 번째만 조건 대신 런타임 null 검사를 쓴다.** 그 이유가 주석에 있다.
|
||||
|
||||
```java
|
||||
// Not a silent skip of a check: MessagingProviderSelection is what guarantees a transport
|
||||
// when a broker is selected, and it refuses startup by name when one is not. This
|
||||
// configuration is also loadable on its own — an adopter composing the policy primitives
|
||||
// without a transport — and demanding one here would refuse that.
|
||||
```
|
||||
|
||||
즉 "transport 없이도 로드 가능해야 한다"가 명시적 요구이고, 그 요구가 `@ConditionalOnBean` 대신 런타임 분기를 쓰게 했다. 부재 시 조용히 반환하지만 그것이 조용한 스킵이 아님을 주석이 다른 게이트(`MessagingProviderSelection`)로 설명한다. 그 게이트의 실제 동작은 starter leaf SSOT가 확인해야 한다.
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
**(a) DLQ 순서 불변식이 두 곳에 구현돼 있다**
|
||||
|
||||
| | `messaging-policy` `DeadLetterOrchestrator` | 이 leaf `DefaultDeliveryProcessor` |
|
||||
|---|---|---|
|
||||
| 불변식 | 확인 후에만 원본 정산 | 확인 후에만 ack |
|
||||
| 미확인 시 | 정산하지 않음(`sourceSettled=false`) | **requeue** |
|
||||
| 헤더 | 예약 헤더 6개 부착 | 없음 |
|
||||
| 발행 주체 | `MessagePublisher` | `DeadLetterPublisher` 함수형 인터페이스 |
|
||||
|
||||
**미확인 시 동작이 다르다.** policy 쪽은 "정산하지 않는다"(브로커가 알아서 재전달), 이쪽은 "명시적으로 requeue한다". 둘 다 메시지를 잃지 않지만 `requeue(delay)`는 지연을 지정하고 무정산은 브로커의 기본 재전달 타이밍을 따른다.
|
||||
|
||||
둘 다 조립되지 않았으므로 오늘 충돌하지 않는다. `analysis/messaging/messaging-policy.md` §12.3(b)가 같은 사건을 반대편에서 기록한다.
|
||||
|
||||
**(b) 재시도 지연이 두 출처**
|
||||
|
||||
`DefaultDeliveryProcessor`의 `retryDelay`는 **생성자 인자 하나**다. 시도 횟수를 세지 않고 백오프도 없다. `messaging-policy`의 `BackoffCalculator`(지수 + full jitter + 상한)와 대비된다. 같은 leaf 문서 §12.3(a)가 소유한다.
|
||||
|
||||
**(c) 멱등 종료가 두 층**
|
||||
|
||||
`TransportMessagingRuntime.close()`와 `DefaultMessagingRuntimeRegistry.Generation.forceClose()`(transport-spi) 둘 다 CAS로 한 번을 보장한다. 중복이지만 **의도된 중복**이다 — 이쪽 주석이 "the registry closes a drained generation, and a context shutdown may close it again"이라고 두 경로를 명시한다. 결함 아님.
|
||||
|
||||
**(d) content type 폴백**
|
||||
|
||||
`encode`가 `codecs.find(contentType).orElseGet(codecs::defaultCodec)`으로 폴백한다. `RegisteredMessageCodecs.find`는 미등록이면 `Optional.empty()`를 주고, `defaultCodec()`은 JSON이다. 즉 **선언된 content type과 실제 인코딩이 갈라질 수 있는 유일한 지점**이고, 그 갈라짐이 조용하다. §17.
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
| 문서 주장 | 재측정 | 결과 |
|
||||
|---|---|---|
|
||||
| build.gradle 주석: `MessagePublisher`에 구현이 없었다 | 현재 이 leaf가 구현하고 `:446`에서 조립 | **해소됨** |
|
||||
| `TransportMessagingRuntime` javadoc: registry가 비어 있어 모든 발행이 실패했다 | 현재 `:476`이 설치 | **해소됨** |
|
||||
| `DefaultMessagePublisher` javadoc: 관측 bean이 있고 호출 경로가 없었다 | 현재 호출 경로가 있고 bean이 없다 | **반전됨**(§12.1b) |
|
||||
| `DefaultDeliveryProcessor` javadoc: 어댑터가 각자 결정했다 | 한 곳에 모았으나 조립되지 않음 | **부분 해소** |
|
||||
| `MessagingRuntime.generation()` javadoc: "increasing with each replacement" | 유일한 설치가 리터럴 `1L` | **미실현** |
|
||||
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) |
|
||||
|
||||
세 번째와 다섯 번째가 이 leaf의 §17 항목이 된다.
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
이 leaf는 **통째로 하나의 수정**이다. MSG-INT-003이라는 식별자가 세 파일의 javadoc에 나온다(`DeclaredDestinationAccess`, `TransportMessagingRuntime`, `MessagingCoreAutoConfiguration:461`).
|
||||
|
||||
| 위치 | 이전 상태 | 그것이 만든 실패 |
|
||||
|---|---|---|
|
||||
| `build.gradle` 주석 | `MessagePublisher` 구현 없음 | 자동설정이 없는 bean 위에 DLQ·facade bean을 쌓음. admission·security·lease·observation이 bean으로 존재하되 어떤 발행도 부르지 않음 |
|
||||
| `TransportMessagingRuntime` javadoc | `MessagingRuntime` 구현 없음 | registry가 빈 채로 만들어져 모든 발행이 `PUBLISH_RUNTIME_UNAVAILABLE` — 목적지 해석·접근 확인·인코딩을 **전부 마친 뒤에** |
|
||||
| `DestinationProfileRegistry` javadoc | 논리 이름→프로파일 해석 없음 | 어댑터는 해석된 프로파일을 받는데 그것을 만들 publisher가 없었음 |
|
||||
| `DefaultDeliveryProcessor` javadoc | `HandleResult`→정산 연결 없음 | 각 어댑터가 retry/dead-letter의 뜻을 각자 결정 |
|
||||
| `DefaultDeliveryProcessor` 핸들러 예외 주석 | Rabbit consumer가 핸들러 예외를 역직렬화 실패 경로로 접음 | 한 consumer의 일시적 버그가 하루치 트래픽을 조용히 버림 |
|
||||
| `requireSupportedOptions` javadoc | transport가 `options`를 읽지 않음 | 중복 억제를 요청한 호출자가 억제도 오류도 못 받고, 그래서 쓸 idempotency를 건너뜀 |
|
||||
| `withDeadline` javadoc | transport가 마감을 무시 | 확인이 오지 않는 Rabbit publish에 마감이 없어 호출자 스레드가 완료 불가능한 stage에 묶임 |
|
||||
|
||||
`build.gradle` 주석의 마지막 문장이 이 leaf 전체의 교훈이다 — "A starter that filled the gap with an application-supplied fake would pass a context test while running none of them."
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-283 | command | `evidence/raw/283-runtime-core-observation-noop.txt` | 여섯 타입 참조 수, 발행 경로 조립 지점, `DefaultDeliveryProcessor` src/main=0, 관측 4조각과 끊긴 한 지점, `MessagingMetrics`가 테스트에서만 생성됨, starter가 만드는 관측 bean 둘 | 정적 검색. 파생 프로젝트의 대체 조립 미포함 |
|
||||
| EVD-284 | command | `./gradlew :messaging:messaging-runtime-core:test --rerun-tasks` | BUILD SUCCESSFUL, 21 / 0 / 0 | 브로커 대체(`RecordingTransport`) |
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적**
|
||||
|
||||
- 이 leaf가 존재하는 이유와 이전 결함 — `build.gradle` 주석
|
||||
- 발행 8단계의 순서가 고정된 이유와 각 위치의 근거 — `DefaultMessagePublisher` javadoc
|
||||
- 접근 확인이 인코딩보다 먼저인 이유 — 인라인 주석
|
||||
- 전송 전 실패가 `REJECTED`인 이유 — 인라인 주석
|
||||
- 예산을 호출 시점부터 세는 이유 — `remainingBudget` javadoc
|
||||
- 마감을 복사본에 거는 이유와 그 대가 — `withDeadline` javadoc
|
||||
- 모든 경로에서 정확히 한 번 반납하는 이유 — 클래스 javadoc + 인라인 주석
|
||||
- 지원하지 않는 옵션을 거절하는 이유 — `requireSupportedOptions` javadoc
|
||||
- 기본 codec을 명시 인자로 받는 이유, raw-bytes 금지 이유 — `RegisteredMessageCodecs` javadoc
|
||||
- 폴백 없는 목적지 조회 이유 — `DestinationProfileRegistry` javadoc
|
||||
- 기본 접근 정책이 deny도 allow도 아닌 이유 — `DeclaredDestinationAccess` javadoc
|
||||
- 핸들러 예외가 retry인 이유 — 인라인 주석
|
||||
- DLQ 미확인 시 requeue를 고른 이유 — 인라인 주석
|
||||
- transport 부재를 조용히 넘기는 것이 조용한 스킵이 아닌 이유 — `InitializingBean` 안 주석
|
||||
- 관측을 생성자 인자로 둔 이유 — `observation` 필드 javadoc
|
||||
|
||||
**추론**
|
||||
|
||||
- 출하 조립이 6인자 생성자를 쓰는 것이 의도인지 → **추론이 아니라 미상.** 어디에도 근거가 없고, 8인자 생성자와 `MessagingMetrics`가 둘 다 존재한다는 점이 미완을 시사한다.
|
||||
- `generation`이 항상 1인 것은 회전 코드가 없기 때문이다 → **추론**. 회전 코드 부재는 관측이다.
|
||||
- `DefaultDeliveryProcessor` 미조립이 미완인지 확장점인지 → **미상**.
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- 6개 클래스 787줄 전문의 계약과 순서 결정
|
||||
- 21개 테스트가 통과하고 무엇을 단언하는지
|
||||
- 다섯 클래스가 출하 컨텍스트에서 조립되고 정확히 어느 라인인지
|
||||
- `DefaultDeliveryProcessor`가 `src/main`에서 생성되지 않는다는 것
|
||||
- 관측의 네 조각 중 마지막 하나(bean)가 없고, 출하가 no-op 생성자를 쓴다는 것
|
||||
- `MessagingMetrics`가 자기 테스트에서만 생성되고, 그 협력자 둘은 bean으로 존재한다는 것
|
||||
- `generation`이 유일한 설치 지점에서 리터럴 `1L`이라는 것
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- **6인자 생성자 선택이 의도인지.** 커밋이 대량 커밋 4개뿐이고 이 선택을 설명하는 기록이 없다.
|
||||
- `MessagingProviderSelection`이 실제로 transport 부재를 이름으로 거절하는지 — starter leaf가 소유한다.
|
||||
- 어댑터들이 `request.options()`를 정말 읽지 않는지 — 이 leaf의 javadoc이 그렇게 주장하고, 각 어댑터 leaf가 확인해야 한다.
|
||||
- 실제 브로커에서 `withDeadline`의 `.copy()` 전략이 어댑터 정리와 어떻게 상호작용하는지. 컨테이너 레인이 있으나 실행하지 않았다.
|
||||
- 파생 프로젝트가 publisher bean 전체를 대체해 관측을 넣는지.
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P2 — 관측이 구현·호출부·주입 자리를 모두 갖추고도 출하에서 no-op이다
|
||||
|
||||
- **사실.** `DefaultMessagePublisher`가 모든 발행 결과를 `observation.recordPublish(...)`로 기록하고, 관측을 "constructor argument rather than an optional decorator"로 받는다. `MessagingMetrics`가 `MessagingObservation`을 구현한다. 그런데 출하 조립(`MessagingCoreAutoConfiguration:446`)은 **6인자 생성자**를 써서 `NO_OBSERVATION`을 넣고, `MessagingMetrics`는 저장소 전체에서 자기 테스트에서만 생성된다. starter는 `MessagingMetrics`의 협력자 둘(`MessagingRedactor:253`, `CardinalityGuard:264`)을 bean으로 만든다.
|
||||
- **근거.** `evidence/raw/283` §D.
|
||||
- **왜 문제인가.** 이 필드의 javadoc이 정확히 이 상황을 막으려고 쓰였다 — "an unobserved publish path is how 'the dashboards were empty during the incident' happens". 그리고 같은 javadoc이 **이전 결함**을 "bean은 있고 호출 경로가 없었다"로 기록한다. 지금은 반대다 — 호출 경로가 있고 bean이 없다. 관측 결과는 같다. **고침이 간극을 닫은 게 아니라 반대편으로 옮겼다.** "decorator가 아니라 생성자 인자"라는 선택도 막지 못했는데, 인자를 기본값으로 채우는 짧은 생성자가 함께 있기 때문이다.
|
||||
- **확인 방법.** `evidence/raw/283` §D 재실행. 또는 `:446`의 인자 수와 `:138-146` 생성자 시그니처 대조.
|
||||
- **후보.** (a) `MessagingMetrics` bean을 만들고 publisher가 8인자 생성자를 쓰게 한다. (b) 6인자 생성자를 제거해 관측을 명시 인자로 강제한다. (c) 관측이 배선되지 않았음을 `support-matrix.md`에 표시한다.
|
||||
- **다음 단계.** **CASE 후보.** 재현이 정적이고, "장치는 있고 회로가 닫히지 않았다"의 변형 중 **회로가 반대편에서 끊긴** 사례라 독립적으로 가치가 있다. 그리고 "생성자 기본값이 있는 필수 협력자는 필수가 아니다"가 **REFERENCE 후보**다.
|
||||
|
||||
### P2 — 소비 오케스트레이터가 조립되지 않는다
|
||||
|
||||
- **사실.** `DefaultDeliveryProcessor`는 leaf 밖 참조 0, `src/main` 생성 0, `src/test` 생성 1이다.
|
||||
- **근거.** `evidence/raw/283` §A·§C.
|
||||
- **왜 문제인가.** 이 클래스가 고친 문제("각 어댑터가 retry/dead-letter의 뜻을 각자 결정")가 배선 없이는 그대로 남는다. 그리고 `DeclaredDestinationAccess`가 consume 권한을 빈 집합으로 두는 것과 정합적이다 — 기본 구성은 소비를 상정하지 않는다.
|
||||
- **확인 방법.** `git grep -n -E 'new ([a-zA-Z0-9_.]+\.)?DefaultDeliveryProcessor\s*\(' -- src`
|
||||
- **다음 단계.** `analysis/messaging/messaging-policy.md` §17의 "출하 컨텍스트가 발행은 하고 소비는 하지 못한다"와 **동일 사건**이다. 소유는 cross-scope 또는 starter leaf. 여기서는 교차 참조만 남긴다.
|
||||
|
||||
### P3 — 선언된 content type과 실제 인코딩이 조용히 갈라질 수 있다
|
||||
|
||||
- **사실.** `encode`가 `codecs.find(message.contentType()).orElseGet(codecs::defaultCodec)`으로 폴백한다. 출하 registry에는 JSON codec 하나만 등록된다. 봉투가 `application/avro`를 선언해도 JSON으로 인코딩되고, `EncodedMessage`의 content type은 codec이 정하므로 `application/json`이 된다.
|
||||
- **근거.** `DefaultMessagePublisher.java:97-102`, `RegisteredMessageCodecs.find`, `MessagingCoreAutoConfiguration:363`(varargs 비어 있음).
|
||||
- **왜 문제인가.** 실패하지 않고 **다른 포맷으로 성공**한다. 소비 측이 봉투의 원래 선언을 믿고 디코더를 고르면 어긋난다. `DestinationProfile.schema().codec()`이 목적지의 codec을 선언하는데 그 값과 대조하는 코드가 이 경로에 없다.
|
||||
- **확인 방법.** 등록되지 않은 content type의 봉투를 발행해 `EncodedMessage.contentType()`을 확인.
|
||||
- **후보.** 미등록 content type을 `MessagingConfigurationException`으로 거절하거나, `profile.schema().codec()`과 대조한다.
|
||||
- **다음 단계.** **CASE 후보.** 조용한 성공이라는 형태가 `messaging-core-api`의 "조용한 성능 저하 금지" 설계와 정면으로 어긋난다.
|
||||
|
||||
### P3 — 같은 실패 코드가 두 completion에 쓰인다
|
||||
|
||||
- **사실.** `PUBLISH_DEADLINE_EXCEEDED`가 전송 전이면 `REJECTED`(`:16-21`), 전송 후면 `AMBIGUOUS`(`:42-47`)로 붙는다.
|
||||
- **근거.** 두 위치.
|
||||
- **왜 문제인가.** 두 경우의 운영자 행동이 정반대다 — 전자는 버려도 안전, 후자는 같은 `messageId`로만 재발행. `FailureDescriptor.code`가 "stable, machine-readable code"이고 대시보드가 그것으로 집계하는데, 이 코드는 completion을 함께 보지 않으면 판단을 뒤집는다.
|
||||
- **확인 방법.** `git grep -n 'PUBLISH_DEADLINE_EXCEEDED' -- src/messaging/messaging-runtime-core`
|
||||
- **후보.** 전송 전을 `PUBLISH_DEADLINE_BEFORE_SEND`처럼 분리한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다).
|
||||
|
||||
### P3 — admission 실패만 예외로 전파된다
|
||||
|
||||
- **사실.** 8단계 중 admission(`:24`)만 `try` 블록 밖이고, `MessageTooLargeException`·`MessageBackpressureException`이 그대로 던져진다. 나머지 실패는 전부 `CompletionStage<PublishResult>`로 정규화된다.
|
||||
- **근거.** `DefaultMessagePublisher.java:198`(admit 호출 위치)과 그 앞뒤 try 블록 범위.
|
||||
- **왜 문제인가.** 반환 타입이 `CompletionStage`인 메서드가 동기적으로 throw한다. `.publish(...).exceptionally(...)`로만 처리하는 호출자는 이 두 예외를 놓친다. 두 예외 다 `MessagingException`이라 `FailureDescriptor`는 있지만 전달 방식이 다른 실패들과 다르다.
|
||||
- **확인 방법.** 상한 초과 payload로 `publish`를 호출하고 반환 stage가 아니라 호출 자체가 던지는지 확인.
|
||||
- **후보.** admission을 `try` 안으로 넣어 `rejected(...)`로 정규화하거나, javadoc에 동기 throw를 명시한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(`CompletionStage`를 반환하는 메서드는 동기적으로 던지지 않는다).
|
||||
|
||||
### P3 — `generation`이 항상 1이다
|
||||
|
||||
- **사실.** 유일한 설치 지점(`MessagingCoreAutoConfiguration:476`)이 리터럴 `1L`을 넘긴다. `MessagingRuntime.generation()` javadoc은 "increasing with each replacement", `TransportMessagingRuntime` javadoc은 "the credential generation a rotation increments"라고 한다.
|
||||
- **근거.** `:476`, 두 javadoc.
|
||||
- **왜 문제인가.** 오늘 회전 코드가 없으므로 무해하다. 다만 `DefaultMessagingRuntimeRegistry`의 세대 드레인 로직(transport-spi §4.2)이 세대 구분을 전제하고, 진단에서 generation을 읽는 사람은 항상 1을 본다. 회전을 붙일 때 이 리터럴이 잊히면 두 세대가 같은 번호를 갖는다.
|
||||
- **확인 방법.** `git grep -n 'TransportMessagingRuntime(' -- src/main`
|
||||
- **후보.** 자격증명 회전 카운터에서 값을 가져오거나, 회전이 없음을 주석으로 남긴다.
|
||||
- **다음 단계.** **REFERENCE 후보**(증가한다고 문서화한 값이 리터럴이면 그 사실을 적는다).
|
||||
|
||||
### P3 — `missingResult()`가 아무 데도 쓰이지 않는다
|
||||
|
||||
- **사실.** `DefaultDeliveryProcessor.missingResult()`(package-private static)가 `HANDLER_RETURNED_NOTHING` descriptor를 만든다. `result == null` 분기는 그것을 쓰지 않고 바로 `settlement.requeue(retryDelay)`를 부른다.
|
||||
- **근거.** `DefaultDeliveryProcessor.java:77-79`, `:146-154`.
|
||||
- **왜 문제인가.** 핸들러가 null을 반환한 경우와 `HandleResult.Retry`를 반환한 경우가 정산 수준에서 구분되지 않는다. 전자는 프로그래밍 오류이고 후자는 정상 흐름인데 같은 requeue가 된다. descriptor는 만들어졌으나 흐르지 않는다.
|
||||
- **확인 방법.** `git grep -n 'missingResult' -- src`
|
||||
- **후보.** null 분기에서 descriptor를 관측이나 로그로 흘리거나, 메서드를 제거한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(만들어 두고 흘리지 않는 진단값은 진단이 아니다).
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- 발행 8단계의 고정 순서와 각 위치의 명시된 근거
|
||||
- 전송 전/후 경계가 `REJECTED`/`AMBIGUOUS`를 가르는 것
|
||||
- 예산을 호출 시점부터 세는 것
|
||||
- 마감을 복사본에 걸어 어댑터의 stage를 완료시키지 않는 것과, permit/lease를 그 시점에 반납한다는 명시적 trade
|
||||
- 성공·실패·예외 모든 경로에서 lease와 permit을 정확히 한 번 반납하는 것
|
||||
- 지원하지 않는 발행 옵션을 조용히 무시하지 않고 거절하는 것
|
||||
- 기본 codec을 명시 인자로 받고 raw-bytes를 content type 기준으로 거절하는 것
|
||||
- 폴백 없는 목적지 조회
|
||||
- 기본 접근 정책이 "선언한 목적지에만 발행"인 것과 consume·administer를 비워 두는 것
|
||||
- 정확히 한 번 정산(CAS)과 핸들러 예외를 retry로 취급하는 것
|
||||
- 실패 서술에 예외 메시지가 아니라 타입 이름만 남기는 것
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MRC-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 6개, memberships `["app-bootstrap"]` | 선언 |
|
||||
| MRC-002 | build | `messaging-runtime-core/build.gradle` | same | 이 leaf가 존재하는 이유(MSG-INT-003 진단) | — |
|
||||
| MRC-003 | code | `.../runtime/DefaultMessagePublisher.java` 전문 | same | §4.1–4.6, §6 | 브로커 대체 테스트만 |
|
||||
| MRC-004 | code | `.../runtime/DefaultDeliveryProcessor.java` 전문 | same | §4.11 두 규칙, 핸들러 예외 이력 | 조립되지 않음(§12.1a) |
|
||||
| MRC-005 | code | `.../runtime/RegisteredMessageCodecs.java` | same | §4.8 기본 codec 규칙과 충돌 거절 | — |
|
||||
| MRC-006 | code | `.../runtime/DestinationProfileRegistry.java` | same | §4.7 폴백 없는 조회 | — |
|
||||
| MRC-007 | code | `.../runtime/TransportMessagingRuntime.java` | same | §4.9 멱등 종료, generation 인자 | 항상 1(§17) |
|
||||
| MRC-008 | code | `.../runtime/DeclaredDestinationAccess.java` | same | §4.10 기본 접근 정책의 세 번째 선택지 | — |
|
||||
| MRC-009 | test | `DefaultMessagePublisherTest` (10) | same | 8단계와 실패 정규화, 관측 호출 | **8인자 생성자 사용** |
|
||||
| MRC-010 | test | `DefaultDeliveryProcessorTest` (7) | same | 4분기 정산, 이중 정산 거절 | 배선 미증명 |
|
||||
| MRC-011 | test | `RegisteredMessageCodecsTest` (4) | same | 기본 codec 규칙 | — |
|
||||
| MRC-012 | assembly | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:363,377,446,461-478` | same | 다섯 조립 지점과 6인자 생성자 선택 | 해당 leaf SSOT가 소유 |
|
||||
| MRC-013 | cross-leaf code | `messaging-observability/.../MessagingMetrics.java:29` | same | `MessagingObservation`의 유일한 구현 | 테스트에서만 생성 |
|
||||
| MRC-014 | cross-leaf code | `messaging-policy/.../DeadLetterOrchestrator.java` | same | 경쟁하는 DLQ 구현 | 해당 leaf SSOT가 소유 |
|
||||
| EVD-283 | command | `evidence/raw/283-runtime-core-observation-noop.txt` | same | §12.1 전부 | 정적 검색 |
|
||||
| EVD-284 | command | `./gradlew :messaging:messaging-runtime-core:test --rerun-tasks` | same | 21 / 0 / 0 | `RecordingTransport` 대체 |
|
||||
@@ -0,0 +1,547 @@
|
||||
# messaging-schema-api 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-schema-api`
|
||||
> SSOT owner: `messaging-schema-api`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
> **성격.** 읽기 기록이다. 이 leaf가 선언한 codec/schema 계약과, 그 중 무엇이 실제로 호출되는지를 source anchor와 함께 적는다.
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- registered leaf id: `messaging-schema-api`
|
||||
- canonical state `analysisFile`: `analysis/messaging/messaging-schema-api.md`
|
||||
- source path: `src/messaging/messaging-schema-api`
|
||||
- leaf-owned subdocuments: 없음
|
||||
- registry `allowed_dependencies`: `["messaging-core-api"]`
|
||||
- registry `runtime_memberships`: `["app-bootstrap"]`
|
||||
|
||||
### 숫자
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | 10 |
|
||||
| production LOC | 630 |
|
||||
| 패키지 | 1 (`dev.caskeleton.messaging.schema`) |
|
||||
| test 파일 | 3 |
|
||||
| test 메서드(실행 확인) | 19 |
|
||||
| 외부(비프로젝트) 의존성 | **0** |
|
||||
|
||||
10개 타입의 성격:
|
||||
|
||||
| 타입 | 종류 | 역할 |
|
||||
|---|---|---|
|
||||
| `MessageCodec` | interface | 한 wire 포맷의 인코딩/디코딩 |
|
||||
| `MessageCodecRegistry` | interface | content type → codec, 그리고 기본 codec |
|
||||
| `SchemaRegistry` | interface | subject/version → schema, 그리고 compatibility mode |
|
||||
| `MessageContractKey` | record | `(MessageType, SchemaVersion)` — registry 키 |
|
||||
| `SchemaReference` | record | subject + version + 선택적 URI |
|
||||
| `EncodedMessage` | record | 바이트 + content type + schema reference |
|
||||
| `SchemaCompatibility` | enum(7) | 진화 모드 |
|
||||
| `SchemaCompatibilityValidator` | class | 포맷 독립 진화 규칙 |
|
||||
| `BoundedByteSink` | class | 한도 초과 바이트를 **쓰기 시점에** 거절하는 OutputStream |
|
||||
| `RawBytesMessageCodec` | class | 스키마 없는 M2 escape hatch |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `src/main/java/**` (10) | 10 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `src/test/java/**` (3) | 3 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 5줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
이 leaf는 **"바이트를 어떻게 만들고 읽는가"의 계약**을 소유한다. 실제 포맷 구현은 갖지 않는다 — 단 하나의 예외가 `RawBytesMessageCodec`이고, 그것은 포맷이 아니라 포맷의 부재를 구현한다.
|
||||
|
||||
경계 규칙 하나가 모든 곳에 반복된다: **codec은 닫힌 registry에 대해서만 동작한다.**
|
||||
|
||||
```java
|
||||
// MessageCodec.java:10-12
|
||||
* <p>Implementations operate against a closed message-type registry. Accepting an unregistered type
|
||||
* would let a producer introduce a wire contract nothing has reviewed, which is the same class of
|
||||
* problem that makes Java serialization unsupported here.
|
||||
```
|
||||
|
||||
`build.gradle`는 `api project(':messaging:messaging-core-api')` 하나뿐이고 vendor 의존성이 없다. 포맷별 vendor(`jackson`, `avro`, `protobuf`)는 각자 leaf가 갖는다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
들어오는 것: `messaging-core-api`(api 노출).
|
||||
|
||||
나가는 것: `messaging-schema-json`, `messaging-schema-avro`, `messaging-schema-protobuf`, `messaging-cloudevents`, `messaging-policy`, `messaging-transport-spi`, `messaging-runtime-core`, `messaging-kafka`, `messaging-rabbit`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-boot-starter`, `messaging-testkit`.
|
||||
|
||||
런타임 편입은 `messaging-core-api`와 같은 경로다 — `app-bootstrap`이 `messaging-spring-boot-starter`를 선언하고 그 closure가 이 leaf를 끌어온다.
|
||||
|
||||
이 leaf는 bean을 만들지 않는다. Spring 주석 0개.
|
||||
|
||||
---
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
패키지 하나에 10개 타입이 평평하게 있다. 관심사로 나누면 셋이다.
|
||||
|
||||
```
|
||||
codec 축 MessageCodec ── MessageCodecRegistry
|
||||
│
|
||||
└── RawBytesMessageCodec (유일한 구현)
|
||||
|
||||
식별 축 MessageContractKey (type, version)
|
||||
SchemaReference (subject, version, uri?)
|
||||
EncodedMessage (bytes, contentType, schemaReference?)
|
||||
|
||||
진화 축 SchemaRegistry ── SchemaCompatibility(7)
|
||||
│
|
||||
└── SchemaCompatibilityValidator
|
||||
|
||||
경계 축 BoundedByteSink
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
### 4.1 `MessageContractKey`: 버전을 키에 넣는 이유
|
||||
|
||||
이 leaf에서 가장 밀도 높은 javadoc이다.
|
||||
|
||||
```java
|
||||
// MessageContractKey.java:10-17
|
||||
* <p>Keying on the message type alone is what let an unregistered version decode. The version
|
||||
* travels in the envelope and in {@link SchemaReference}, so a consumer receiving {@code
|
||||
* order.created v999} would look up {@code order.created}, find the v1 class or parser, decode
|
||||
* against it, and then keep the v999 label on the result. Nothing failed, and every downstream
|
||||
* compatibility gate and audit record then described a version that was never registered.
|
||||
```
|
||||
|
||||
핵심은 "Nothing failed"다. 타입만으로 키를 잡으면 실패가 발생하지 않고 **잘못된 성공**이 발생한다. 그리고 그 결과에는 등록된 적 없는 버전 라벨이 붙어 하위 감사 기록까지 오염된다.
|
||||
|
||||
이 결정은 세 codec에 전부 반영돼 있다 — `JacksonMessageCodec.requireRegistered`, `AvroMessageCodec.schemaFor`, `ProtobufMessageCodec.requireRegistered`가 모두 "타입은 아는데 버전을 모른다"와 "타입 자체를 모른다"를 **다른 에러 코드**로 구분한다(`SCHEMA_VERSION_NOT_REGISTERED` vs `UNKNOWN_MESSAGE_TYPE`). 그 구분이 있어야 운영자가 "등록을 빠뜨렸다"와 "오타다"를 나눌 수 있다.
|
||||
|
||||
### 4.2 `BoundedByteSink`: 보고 임계값 → 할당 경계
|
||||
|
||||
```java
|
||||
// BoundedByteSink.java:11-15
|
||||
* <p>Every codec here used to serialize into an unbounded buffer and compare {@code bytes.length}
|
||||
* to the configured maximum afterwards. That makes the maximum a reporting threshold rather than an
|
||||
* allocation bound: a payload whose graph expands to hundreds of megabytes exhausts the heap while
|
||||
* being written, and the check that would have rejected it never runs. Under a broker consumer that
|
||||
* is a process-wide outage caused by one message.
|
||||
```
|
||||
|
||||
세 가지 설계 결정이 붙어 있다.
|
||||
|
||||
1. **버퍼를 한도로 미리 잡지 않는다.** `new ByteArrayOutputStream(Math.min(maxBytes, 8_192))` — 주석: "a 1 GiB bound must not pre-allocate 1 GiB."
|
||||
2. **codec의 에러 코드를 그대로 던진다.** `errorCode`가 생성자 인자다. 그래서 Avro는 `AVRO_PAYLOAD_TOO_LARGE`, JSON은 `PAYLOAD_TOO_LARGE`가 나온다. 테스트가 이 성질을 직접 단언한다(`BoundedByteSinkTest.java:69-77`, `as("the sink reports the codec's own code, not a generic one")`).
|
||||
3. **`requireFits(size)`는 예산을 소비하지 않는다.** Protobuf는 직렬화 크기를 미리 알므로 첫 바이트 전에 거절할 수 있다. 그리고 그 뒤의 쓰기도 여전히 경계 안이다 — 주석: "this is a cheaper refusal, not a replacement for the bound."
|
||||
|
||||
`refuseIfBeyondLimit`가 `size > maxBytes - written`으로 비교하는 것도 의도적이다. `written + size > maxBytes`였다면 `int` 오버플로가 가능하다.
|
||||
|
||||
테스트가 실제 시나리오를 재현한다 — 10 MiB를 1 KiB씩 제공하고, `written()`이 한도(64) 이하로 유지되며 `toByteArray()`가 비어 있음을 확인한다(`BoundedByteSinkTest.java:34-53`).
|
||||
|
||||
### 4.3 `EncodedMessage`: 양방향 방어 복사
|
||||
|
||||
```java
|
||||
public EncodedMessage {
|
||||
...
|
||||
bytes = bytes.clone(); // 생성 시
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] bytes() {
|
||||
return bytes.clone(); // 접근 시
|
||||
}
|
||||
```
|
||||
|
||||
javadoc이 이유를 적는다 — "These bytes travel through retry, DLQ, and redrive paths where a shared mutable array would let one stage corrupt another's copy of the same logical message."
|
||||
|
||||
`equals`/`hashCode`는 `Arrays.equals`/`Arrays.hashCode`로 재정의된다(record 기본은 배열 참조 비교라 항상 불일치). `toString`은 바이트를 찍지 않고 크기만 찍는다 — payload가 로그에 새지 않는다.
|
||||
|
||||
`size()`가 복사 없이 길이를 반환하는 별도 메서드로 있는 것도 의도적이다. `bytes().length`는 전체 복사를 유발한다.
|
||||
|
||||
### 4.4 `SchemaCompatibility`: 7개 모드와 transitive의 의미
|
||||
|
||||
```java
|
||||
// SchemaCompatibility.java:6-8
|
||||
* <p>Transitive modes check every historical version, not just the immediate predecessor. That
|
||||
* matters for integration events, where a consumer may be several releases behind and a chain of
|
||||
* individually-compatible changes can still be collectively breaking.
|
||||
```
|
||||
|
||||
`NONE_EXPERIMENTAL`은 "M2 raw bytes에만 허용"이라고 enum 상수 javadoc이 적는다.
|
||||
|
||||
### 4.5 `SchemaRegistry`: 포트이고, 순서가 계약이다
|
||||
|
||||
```java
|
||||
// SchemaRegistry.java:16-17
|
||||
* <p>{@link #history} returns oldest first. Transitive compatibility checks read the whole list, so
|
||||
* an ordering mistake here silently converts a transitive check into a pairwise one.
|
||||
```
|
||||
|
||||
이것은 문서화된 함정이다. `history`가 newest-first로 구현되면 `versionsToCheck`가 `reversed()`한 뒤 `history.get(0)`을 취하므로 **가장 오래된 버전 하나**만 비교하게 된다 — transitive가 pairwise로 조용히 축소되는 것이 아니라 아예 엉뚱한 버전을 비교한다.
|
||||
|
||||
`latest(subject)`가 default 메서드로 `versions.get(versions.size() - 1)`인 것도 같은 순서 계약에 의존한다. 테스트가 이 성질을 직접 단언한다(`SchemaCompatibilityValidatorTest.theLatestVersionIsTheNewestNotTheFirstListed`).
|
||||
|
||||
port로 둔 이유도 적혀 있다 — "A hosted registry, a classpath directory of schema files, and a static in-process map are all legitimate sources … Binding to a vendor client here would make the rules untestable without that vendor running."
|
||||
|
||||
### 4.6 `SchemaCompatibilityValidator`: 포맷 독립 규칙
|
||||
|
||||
두 가지를 한다.
|
||||
|
||||
**(a) 비교할 버전 목록**
|
||||
|
||||
```java
|
||||
public List<SchemaVersion> versionsToCheck(String subject) {
|
||||
SchemaCompatibility mode = registry.compatibilityOf(subject);
|
||||
if (mode == SchemaCompatibility.NONE_EXPERIMENTAL) return List.of();
|
||||
List<SchemaVersion> history = registry.history(subject).reversed();
|
||||
if (history.isEmpty()) return List.of();
|
||||
return isTransitive(mode) ? history : List.of(history.get(0));
|
||||
}
|
||||
```
|
||||
|
||||
**(b) production 목적지 게이트**
|
||||
|
||||
```java
|
||||
public void requireProductionMode(String subject, String destination) {
|
||||
if (registry.compatibilityOf(subject) == SchemaCompatibility.NONE_EXPERIMENTAL) {
|
||||
throw new MessageSchemaIncompatibleException(
|
||||
"UNCHECKED_SCHEMA_ON_PRODUCTION_DESTINATION", ...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
javadoc이 이유를 적는다 — "A mode that checks nothing is useful while a message type is being designed and actively dangerous once a retained log exists, because the log outlives every consumer that could still read it."
|
||||
|
||||
그리고 **분리 자체의 이유**를 명시한다:
|
||||
|
||||
```java
|
||||
// SchemaCompatibilityValidator.java:12-14
|
||||
* <p>Split from the per-format gates on purpose. Whether v3 must be checked against v1 as well as
|
||||
* v2 is a property of the compatibility mode, not of Avro or Protobuf, and duplicating that
|
||||
* reasoning in each codec is how the two formats drift apart.
|
||||
```
|
||||
|
||||
§12.1과 §12.3이 이 문장을 다시 다룬다.
|
||||
|
||||
### 4.7 `RawBytesMessageCodec`: 부재를 구현한다
|
||||
|
||||
```java
|
||||
// RawBytesMessageCodec.java:12-16
|
||||
* <p>It still enforces the byte limit, and it is deliberately excluded from default codec
|
||||
* selection: schema-free publishing has to be an explicit, auditable choice per destination, never
|
||||
* something a destination falls back to because its codec was misconfigured.
|
||||
```
|
||||
|
||||
`encode`는 `byte[]`가 아닌 payload를 `MessageSerializationException("RAW_BYTES_PAYLOAD_REQUIRED")`로 거절하고, `decode`는 `byte[].class`가 아닌 대상을 `RAW_BYTES_TARGET_REQUIRED`로 거절한다. `decode`는 `encoded.clone()`을 반환한다 — 호출자가 원본을 건드릴 수 없다.
|
||||
|
||||
`DEFAULT_MAX_BYTES = 1_048_576`(1 MiB)은 세 Stable codec이 공유하는 값이다.
|
||||
|
||||
**주의:** 이 codec은 `BoundedByteSink`를 쓰지 않는다. 이미 `byte[]`를 받으므로 스트리밍 경계가 의미 없고, `bytes.length > maxBytes` 비교로 충분하다. 다른 codec에서는 그 비교가 §4.2가 지적하는 "보고 임계값"이지만 여기서는 할당이 이미 끝난 입력이라 성격이 다르다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
세 개다.
|
||||
|
||||
1. **경계 있는 인코딩** — codec이 `BoundedByteSink.of(maxBytes, code)`를 만들고 → 포맷 라이브러리가 sink에 쓰고 → 한도를 넘는 write에서 `MessageTooLargeException` → 아니면 `sink.toByteArray()`로 `EncodedMessage` 조립
|
||||
2. **계약 조회** — `new MessageContractKey(type, version)` → registry lookup → 미스면 "타입 미등록" vs "버전 미등록" 구분
|
||||
3. **진화 검사** — `registry.compatibilityOf(subject)` → `versionsToCheck` → (포맷별 게이트가 실제 비교)
|
||||
|
||||
3번은 이 저장소에서 실행되지 않는다(§12.1).
|
||||
|
||||
---
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
이 leaf가 던지는 예외는 셋이고 전부 `messaging-core-api` 소유다.
|
||||
|
||||
| 예외 | 코드 | 조건 |
|
||||
|---|---|---|
|
||||
| `MessageTooLargeException` | codec별(`PAYLOAD_TOO_LARGE`, `AVRO_PAYLOAD_TOO_LARGE`, …) | sink 한도 초과 |
|
||||
| `MessageTooLargeException` | `RAW_BYTES_TOO_LARGE` | raw codec 한도 초과 |
|
||||
| `MessageSerializationException` | `RAW_BYTES_PAYLOAD_REQUIRED` / `RAW_BYTES_TARGET_REQUIRED` | 타입 불일치 |
|
||||
| `MessageSchemaIncompatibleException` | `UNCHECKED_SCHEMA_ON_PRODUCTION_DESTINATION` | `NONE_EXPERIMENTAL`이 production 목적지에 |
|
||||
|
||||
`IllegalArgumentException`도 던진다 — `BoundedByteSink` 생성자의 `maxBytes < 1`, `requireFits`의 음수, `SchemaReference`의 빈 subject. 이들은 **호출자의 프로그래밍 오류**이고 메시지 실패가 아니므로 `MessagingException` 계층 밖인 것이 일관적이다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
트랜잭션 없음.
|
||||
|
||||
동시성: `BoundedByteSink`가 **의도적으로 thread-safe가 아니다.** javadoc이 명시한다 — "Not thread-safe, and not meant to be: an instance belongs to a single encode call." 실제로 codec들이 매 `encode` 호출마다 새로 만든다.
|
||||
|
||||
`EncodedMessage`, `MessageContractKey`, `SchemaReference`는 불변이다. `SchemaCompatibilityValidator`는 registry 참조만 갖고 상태가 없다.
|
||||
|
||||
`MessageCodecRegistry`/`SchemaRegistry` 구현의 스레드 안전성은 이 leaf가 규정하지 않는다 — port javadoc에 그에 대한 요구가 없다. 이것은 §17의 P3 항목이다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
설정 없음. 상수 하나:
|
||||
|
||||
| 상수 | 값 | 위치 |
|
||||
|---|---:|---|
|
||||
| `RawBytesMessageCodec.DEFAULT_MAX_BYTES` | 1,048,576 | `RawBytesMessageCodec.java:21` |
|
||||
|
||||
`BoundedByteSink`의 초기 버퍼 상한 8,192는 private다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
없다. `SchemaRegistry`가 외부 registry를 가리킬 수 있는 port지만, 이 leaf에는 구현이 없다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
레인: `./gradlew :messaging:messaging-schema-api:test`. **BUILD SUCCESSFUL, 19 tests, 0 skipped, 0 failures** (`--rerun-tasks`, revision `21234e38`).
|
||||
|
||||
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|
||||
|---|---:|---|---|
|
||||
| `BoundedByteSinkTest` | 4 | 한도 포함/초과 경계, 10 MiB 스트림이 한도에서 멈춤, pre-flight가 예산을 안 먹음, codec 에러 코드 전달 | 실제 codec들이 이 sink를 쓰는지(각 codec leaf가 소유) |
|
||||
| `RawBytesMessageCodecTest` | 6 | round trip, content type, 비-byte[] 거절 양방향, 한도, `EncodedMessage` 방어 복사 | — |
|
||||
| `SchemaCompatibilityValidatorTest` | 9 | pairwise vs transitive 목록, `NONE_EXPERIMENTAL` 빈 목록, 빈 history, production 게이트 양방향, `checksBackward`/`checksForward` 조합, `latest`가 newest | **production 코드가 이 validator를 호출하는지** |
|
||||
|
||||
마지막 칸이 핵심이다. `SchemaCompatibilityValidatorTest`는 9개 단언으로 규칙을 정확히 고정하지만, §12.1이 보이듯 그 규칙을 실행 경로에서 부르는 코드가 없다. 테스트는 **규칙이 옳다**를 증명하고 **규칙이 적용된다**를 증명하지 않는다.
|
||||
|
||||
테스트가 쓰는 `FixedRegistry`는 `SchemaRegistry`의 유일한 구현이다(production 구현 0개, §12.1).
|
||||
|
||||
---
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
| 게이트 | 이 leaf에 대해 |
|
||||
|---|---|
|
||||
| registry fail-closed | 등록됨 |
|
||||
| `verifyCleanArchitectureDependencies` | `allowed_dependencies: ["messaging-core-api"]`와 실제 project edge 대조 |
|
||||
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
|
||||
| `src/messaging/CLAUDE.md`의 vendor `api` 규칙 | 이 leaf는 vendor 의존성이 없으므로 대상 없음 |
|
||||
| ArchUnit | 이 leaf 전용 규칙 없음 |
|
||||
|
||||
`src/messaging/CLAUDE.md:40-43`이 기술하는 게이트 — "source에서 public/protected 시그니처에 등장하는 vendor 라이브러리를 뽑아 그 leaf의 `build.gradle`이 `api`로 선언했는지 대조" — 는 이 leaf에서 확인할 것이 없다. 형제 leaf(`schema-avro`, `schema-protobuf`, `cloudevents`)는 이 규칙 때문에 vendor를 `api`로 선언했고 build.gradle 주석이 그 이유를 적는다.
|
||||
|
||||
---
|
||||
|
||||
## 12. 실제 사용 여부와 negative-space probes
|
||||
|
||||
원시 증거: `evidence/raw/272-schema-family-reachability.txt`.
|
||||
|
||||
### 12.1 Public surface reachability
|
||||
|
||||
leaf 밖 참조를 파일 수로 세면:
|
||||
|
||||
| 타입 | leaf 밖 파일 수 | 판정 |
|
||||
|---|---:|---|
|
||||
| `EncodedMessage` | 50 | 널리 쓰임 — 사실상 이 leaf의 주력 수출품 |
|
||||
| `SchemaCompatibility` | 15 | 세 codec leaf + policy가 씀 |
|
||||
| `MessageContractKey` | 7 | 세 codec leaf가 씀 |
|
||||
| `MessageCodec` | 7 | 세 codec + runtime-core |
|
||||
| `MessageCodecRegistry` | 5 | runtime-core가 구현 |
|
||||
| `SchemaReference` | 4 | codec들이 만듦 |
|
||||
| `BoundedByteSink` | 3 | JSON·Avro·Protobuf codec |
|
||||
| `RawBytesMessageCodec` | **0** | 자기 테스트만 |
|
||||
| `SchemaCompatibilityValidator` | **0** | 자기 테스트만 |
|
||||
| `SchemaRegistry` | **0** | 아래 참조 |
|
||||
|
||||
**`SchemaRegistry`의 "0"은 확인이 필요했다.** 단순 이름 검색은 2개 파일을 맞췄지만 둘 다 다른 타입이다:
|
||||
|
||||
```
|
||||
src/adapter/outbound/messaging/.../LocalJsonSchemaRegistry.java:6: import com.networknt.schema.SchemaRegistry;
|
||||
src/adapter/outbound/notification/.../JsonSchemaVariableValidator.java:5: import com.networknt.schema.SchemaRegistry;
|
||||
```
|
||||
|
||||
`import dev.caskeleton.messaging.schema.SchemaRegistry` 검색은 exit 1이다. 즉 **이 플랫폼의 `SchemaRegistry` port를 import하는 파일이 저장소에 하나도 없다.** 이름 충돌이 우연히 검색을 오염시킨 사례이고, `-w` 단어 매칭만으로 reachability를 판정하면 안 되는 이유이기도 하다.
|
||||
|
||||
**`SchemaCompatibilityValidator`의 "0"이 이 leaf에서 가장 무거운 사실이다.** 검색 결과 전체가 자기 선언과 자기 테스트다. 다시 말해:
|
||||
|
||||
- 어떤 버전들을 비교해야 하는가 → 아무도 묻지 않는다
|
||||
- `NONE_EXPERIMENTAL`이 production 목적지를 뒷받침할 수 있는가 → 아무도 묻지 않는다
|
||||
|
||||
`requireProductionMode`는 "retained log outlives every consumer"라는 이유로 만들어졌고, 그 게이트가 호출되는 지점이 없다.
|
||||
|
||||
`RawBytesMessageCodec`의 "0"은 성격이 다르다. 이 클래스가 없어도 그 **규칙**은 살아 있다 — §12.2 참조.
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
Spring 주석 0개이므로 bean 활성화 비대칭은 없다.
|
||||
|
||||
대신 이 leaf에는 **다른 형태의 sibling 비대칭**이 있고 결과가 좋다. `RawBytesMessageCodec`의 javadoc이 "deliberately excluded from default codec selection"이라고 선언하는 규칙을, 실제로 강제하는 코드는 다른 leaf에 있다:
|
||||
|
||||
```java
|
||||
// messaging-runtime-core/RegisteredMessageCodecs.java:52-56
|
||||
if (ContentType.OCTET_STREAM.equals(defaultCodec.contentType())) {
|
||||
throw new IllegalArgumentException(
|
||||
"the raw bytes codec must not be the default: every destination that has not declared an "
|
||||
+ "encoding would silently skip schema validation");
|
||||
}
|
||||
```
|
||||
|
||||
**클래스가 아니라 content type으로 판정한다.** 그래서 `RawBytesMessageCodec`을 아무도 쓰지 않아도, 그리고 누가 `ContentType.OCTET_STREAM`을 내놓는 다른 codec을 새로 만들어도 규칙이 유지된다. 선언된 규칙과 강제하는 코드가 다른 leaf에 있으면서 **강제 쪽이 더 넓은** 드문 경우다. 결함이 아니라 확인된 설계로 기록한다.
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
**`SchemaCompatibilityValidator`가 막으려던 중복이 실제로 존재한다.**
|
||||
|
||||
`AvroCompatibilityGate`(다른 leaf)가 같은 판단을 private static으로 다시 구현했다.
|
||||
|
||||
| 판단 | schema-api (`SchemaCompatibilityValidator`) | schema-avro (`AvroCompatibilityGate`) |
|
||||
|---|---|---|
|
||||
| transitive인가 | `mode == BACKWARD_TRANSITIVE \|\| FORWARD_TRANSITIVE \|\| FULL_TRANSITIVE` (:107-112) | **같은 식을 그대로** (:49-53) |
|
||||
| 후방 검사하나 | `mode == BACKWARD \|\| BACKWARD_TRANSITIVE \|\| FULL \|\| FULL_TRANSITIVE` — **허용목록** (:79-85) | `mode != FORWARD && mode != FORWARD_TRANSITIVE` — **거부목록** (:55-57) |
|
||||
| 전방 검사하나 | `mode == FORWARD \|\| FORWARD_TRANSITIVE \|\| FULL \|\| FULL_TRANSITIVE` — **허용목록** (:93-99) | `mode != BACKWARD && mode != BACKWARD_TRANSITIVE` — **거부목록** (:59-61) |
|
||||
|
||||
`isTransitive`는 글자까지 동일한 복사본이다. 방향 판정 둘은 **형태가 반대**다.
|
||||
|
||||
현재 enum 7개 값에 대해 두 구현의 결과를 대조하면 일치한다. `NONE_EXPERIMENTAL`만 다른데(validator는 둘 다 false, gate는 둘 다 true) `AvroCompatibilityGate.check:34`가 그 모드에서 먼저 return하므로 가려진다.
|
||||
|
||||
**문제는 오늘의 불일치가 아니라 형태다.** 허용목록은 새 모드가 추가되면 "검사 안 함"으로 기본값이 잡히고, 거부목록은 "양방향 검사"로 잡힌다. `SchemaCompatibility`에 값이 하나 추가되는 순간 두 구현은 **반대 방향으로** 갈라진다. javadoc이 예고한 "how the two formats drift apart"가 바로 이 형태이고, 그것을 막으려고 만든 클래스는 §12.1에서 보듯 호출되지 않는다.
|
||||
|
||||
`isTransitive`는 `SchemaCompatibilityValidator`에서 **public static**이다. Avro 게이트가 그것을 부를 수 있었고 부르지 않았다.
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
이 leaf를 직접 이름으로 언급하는 문서 주장을 재측정했다.
|
||||
|
||||
| 문서 주장 | 재측정 | 결과 |
|
||||
|---|---|---|
|
||||
| 계획 문서: codec은 닫힌 registry에 대해 동작 | `MessageCodec` javadoc + 세 구현의 `requireRegistered`/`schemaFor` | **일치** |
|
||||
| `RawBytesMessageCodec` javadoc: 기본 codec 선택에서 제외됨 | `RegisteredMessageCodecs.of` 생성자 검사 | **일치**(더 넓게 강제) |
|
||||
| `SchemaRegistry` javadoc: history는 oldest-first | 유일한 구현이 테스트 fixture이고 그 계약을 지킴 | 일치하나 production 구현 없음 |
|
||||
|
||||
§12.4의 family 전체 drift(`support-matrix.md:23`의 runtime membership 주장)는 `analysis/messaging/messaging-core-api.md` §12.4가 소유한다. 이 leaf도 그 18개 wired 목록에 포함된다.
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
코드 주석이 보존한 이전 결함:
|
||||
|
||||
| 위치 | 이전 상태 | 그것이 만든 실패 |
|
||||
|---|---|---|
|
||||
| `BoundedByteSink` javadoc | 각 codec이 무제한 버퍼에 직렬화 후 길이 비교 | 한도가 **보고 임계값**일 뿐 할당 경계가 아님 → 팽창하는 payload 하나가 consumer 프로세스를 죽임 |
|
||||
| `MessageContractKey` javadoc | 타입만으로 registry 키 | v999가 v1 클래스로 디코딩되고 v999 라벨을 유지 → 하위 게이트·감사 기록이 등록된 적 없는 버전을 서술 |
|
||||
|
||||
두 사례 다 형태가 같다 — **검사가 없었던 게 아니라 검사의 위치/키가 틀렸다.** `messaging-core-api` §13의 "문자 vs 바이트, 정확일치 vs 세그먼트" 목록과 같은 계열이다.
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | `SchemaCompatibilityValidator` 호출자 전무, allowlist/denylist 두 형태 나란히, `SchemaRegistry` port import 0(exit=1)과 이름 충돌, codec별 소비자 | 정적 `git grep` |
|
||||
| EVD-274 | command | `./gradlew :messaging:messaging-schema-api:test --rerun-tasks` | BUILD SUCCESSFUL, 19 / 0 / 0 | 순수 단위 레인 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적**
|
||||
|
||||
- 버전을 registry 키에 넣는 이유 — `MessageContractKey` javadoc
|
||||
- 할당 경계 vs 보고 임계값 — `BoundedByteSink` javadoc
|
||||
- codec 에러 코드를 sink에 넘기는 이유 — `BoundedByteSink` javadoc + 테스트 `as(...)`
|
||||
- 포맷 독립 규칙을 분리한 이유 — `SchemaCompatibilityValidator` javadoc
|
||||
- `NONE_EXPERIMENTAL`을 production에서 막는 이유 — 같은 javadoc
|
||||
- `SchemaRegistry`를 port로 둔 이유, history 순서가 계약인 이유 — `SchemaRegistry` javadoc
|
||||
- raw codec을 기본에서 제외하는 이유 — `RawBytesMessageCodec` javadoc + `RegisteredMessageCodecs` javadoc
|
||||
- `EncodedMessage` 양방향 복사 이유 — `EncodedMessage` javadoc
|
||||
|
||||
**추론**
|
||||
|
||||
- `SchemaCompatibilityValidator`가 미호출인 것은 이 저장소에 schema registry를 실제로 운영하는 배포가 없기 때문이다 → **추론**. `SchemaRegistry` production 구현이 0인 것은 관측이고, 인과는 추론이다.
|
||||
- Avro 게이트가 자기 복사본을 쓴 이유 → **미상**. 커밋 메시지에 근거가 없다.
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- 10개 타입 전부의 계약과 불변식
|
||||
- 19개 테스트가 통과하고 무엇을 단언하는지
|
||||
- `SchemaCompatibilityValidator`·`RawBytesMessageCodec`·`SchemaRegistry`의 leaf 밖 참조 0 (`SchemaRegistry`는 이름 충돌을 배제한 뒤)
|
||||
- Avro 게이트의 중복 구현과 두 형태의 차이
|
||||
- raw-bytes 기본 금지 규칙이 content type 기준으로 더 넓게 강제된다는 것
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- `SchemaCompatibility` enum이 실제로 확장될 계획이 있는지. §12.3의 위험은 그때 실현된다.
|
||||
- port 구현의 스레드 안전성 요구. javadoc에 없고 이 저장소에 production 구현이 없어 관측할 대상이 없다.
|
||||
- `BoundedByteSink`의 경계가 실제 Jackson/Avro/Protobuf 인코더에서 기대대로 동작하는지 — 각 codec leaf의 테스트가 소유하고 이 문서 범위 밖이다.
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P2 — 포맷 독립 진화 규칙이 호출되지 않고, 그것이 막으려던 중복이 실제로 생겼다
|
||||
|
||||
- **사실.** `SchemaCompatibilityValidator`의 저장소 전체 참조가 자기 선언과 자기 테스트뿐이다. 동시에 `AvroCompatibilityGate`가 `isTransitive`를 글자 그대로 복사했고 방향 판정 둘은 허용목록/거부목록으로 형태가 반대다.
|
||||
- **근거.** `evidence/raw/272` §A, §B.
|
||||
- **왜 문제인가.** 오늘은 7개 모드 전부에서 두 구현의 결과가 같다(`NONE_EXPERIMENTAL`은 gate의 early return이 가린다). 그러나 enum에 값이 하나 추가되면 허용목록은 "검사 안 함", 거부목록은 "양방향 검사"로 **반대 방향** 기본값을 갖는다. 그리고 `requireProductionMode` — 검사 없는 스키마가 보존 로그를 뒷받침하는 것을 막는 게이트 — 는 호출되는 곳이 없다.
|
||||
- **확인 방법.** `git grep -n -E 'requireProductionMode|versionsToCheck|SchemaCompatibilityValidator' -- 'src/**/*.java'`
|
||||
- **후보.** (a) Avro 게이트가 `SchemaCompatibilityValidator`의 public static을 부르게 한다. (b) validator를 CI 게이트에 배선한다. (c) 둘 다 쓰지 않을 거라면 validator를 제거하고 규칙 소유권을 게이트로 옮긴다.
|
||||
- **다음 단계.** **CASE 후보 + REFERENCE 후보**. "중복을 막으려고 만든 추상이 호출되지 않으면 중복은 그대로 생긴다"는 형태가 재사용 가능하다. 그리고 "허용목록과 거부목록은 enum이 자라는 순간 반대로 갈라진다"도 별도 기준이다.
|
||||
|
||||
### P3 — port 구현의 스레드 안전성 요구가 문서화되어 있지 않다
|
||||
|
||||
- **사실.** `SchemaRegistry`와 `MessageCodecRegistry` javadoc에 동시성 요구가 없다. `BoundedByteSink`만 "not thread-safe"를 명시한다.
|
||||
- **근거.** 세 타입의 javadoc 전문.
|
||||
- **왜 문제인가.** `MessageCodecRegistry`의 유일한 구현 `RegisteredMessageCodecs`는 `Map.copyOf`로 불변이라 안전하지만, 그것은 구현의 성질이지 계약이 아니다. 외부 registry를 감싸는 `SchemaRegistry` 구현은 브로커 소비자 스레드들에서 동시에 호출된다.
|
||||
- **확인 방법.** 세 인터페이스의 javadoc 확인.
|
||||
- **후보.** port javadoc에 "구현은 스레드 안전해야 한다"를 명시.
|
||||
- **다음 단계.** **REFERENCE 후보**(port 계약은 동시성 요구를 적는다).
|
||||
|
||||
### P3 — `SchemaRegistry`라는 이름이 저장소에서 두 가지를 가리킨다
|
||||
|
||||
- **사실.** `dev.caskeleton.messaging.schema.SchemaRegistry`(이 leaf의 port)와 `com.networknt.schema.SchemaRegistry`(JSON Schema 라이브러리)가 공존하고, 후자만 실제로 import된다.
|
||||
- **근거.** `evidence/raw/272` §C.
|
||||
- **왜 문제인가.** 지금 깨지는 것은 없다. 다만 reachability 판정에서 실제로 오탐을 만들었다 — 단어 검색이 2건을 맞췄고 둘 다 다른 타입이었다. 사람이 같은 실수를 한다.
|
||||
- **확인 방법.** `git grep -n 'import .*\.SchemaRegistry;' -- src`
|
||||
- **후보.** 이름 변경 없이 두는 것이 합리적일 수 있다. 기록만 남긴다.
|
||||
- **다음 단계.** **REFERENCE 후보**(도달성 판정은 단어가 아니라 import로 확인한다).
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- `BoundedByteSink`가 codec의 에러 코드를 전달하고, pre-flight가 예산을 소비하지 않는 것 — 테스트가 양쪽을 고정
|
||||
- `EncodedMessage`의 양방향 방어 복사와 payload를 찍지 않는 `toString`
|
||||
- 버전을 registry 키에 포함하고 "타입 미등록"과 "버전 미등록"을 다른 코드로 구분하는 것
|
||||
- raw-bytes 기본 금지가 클래스가 아니라 content type으로 강제되는 것
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MSA-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps `["messaging-core-api"]`, memberships `["app-bootstrap"]` | 선언 |
|
||||
| MSA-002 | build | `messaging-schema-api/build.gradle` | same | vendor 의존성 0 | — |
|
||||
| MSA-003 | code | `.../schema/MessageContractKey.java` | same | 버전 키 결정과 그 이유 | — |
|
||||
| MSA-004 | code | `.../schema/BoundedByteSink.java` | same | 할당 경계, 에러 코드 전달, pre-flight | 실제 인코더 동작은 각 codec leaf |
|
||||
| MSA-005 | code | `.../schema/EncodedMessage.java` | same | 양방향 복사, 배열 equals, 안전한 toString | — |
|
||||
| MSA-006 | code | `.../schema/SchemaCompatibilityValidator.java` | same | 포맷 독립 규칙과 분리 이유 | 호출자 없음(§12.1) |
|
||||
| MSA-007 | code | `.../schema/SchemaRegistry.java` | same | port 계약, history oldest-first | production 구현 없음 |
|
||||
| MSA-008 | code | `.../schema/RawBytesMessageCodec.java` | same | escape hatch 계약 | 외부 사용 0 |
|
||||
| MSA-009 | code | `.../schema/{MessageCodec,MessageCodecRegistry,SchemaReference,SchemaCompatibility}.java` | same | codec/식별/모드 계약 | — |
|
||||
| MSA-010 | test | `src/test/java/**` (3 클래스 / 19 테스트) | same | §10 표 | 순수 단위 |
|
||||
| MSA-011 | cross-leaf code | `messaging-runtime-core/.../RegisteredMessageCodecs.java:29-77` | same | raw-bytes 기본 금지의 실제 강제 지점, 중복 content type 거절 | 해당 leaf SSOT가 소유 |
|
||||
| MSA-012 | cross-leaf code | `messaging-schema-avro/.../AvroCompatibilityGate.java:34-61` | same | 중복 구현과 두 형태의 차이 | 해당 leaf SSOT가 소유 |
|
||||
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | §12.1·§12.3 전부 | 정적 검색 |
|
||||
| EVD-274 | command | `./gradlew :messaging:messaging-schema-api:test --rerun-tasks` | same | 19 / 0 skipped / 0 failures | 순수 단위 |
|
||||
@@ -0,0 +1,597 @@
|
||||
# messaging-schema-avro 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-schema-avro`
|
||||
> SSOT owner: `messaging-schema-avro`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- registered leaf id: `messaging-schema-avro`
|
||||
- canonical state `analysisFile`: `analysis/messaging/messaging-schema-avro.md`
|
||||
- source path: `src/messaging/messaging-schema-avro`
|
||||
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api"]`
|
||||
- registry `runtime_memberships`: **`[]`** — build-only / incubating
|
||||
|
||||
### 숫자
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | 2 |
|
||||
| production LOC | 345 |
|
||||
| 패키지 | 1 (`dev.caskeleton.messaging.schema.avro`) |
|
||||
| test 파일 | 3 |
|
||||
| test 메서드(실행 확인) | 16 |
|
||||
| test resource | `/schemas/order.created/v1.avsc` |
|
||||
| 외부 의존성 | 1 (`org.apache.avro:avro:1.12.0`, **`api`**) |
|
||||
|
||||
두 클래스: `AvroMessageCodec`(런타임 인코딩/디코딩), `AvroCompatibilityGate`(CI용 진화 검사).
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `.../avro/AvroMessageCodec.java` | 1 | `FULL_READ` | 272줄 전문 |
|
||||
| `.../avro/AvroCompatibilityGate.java` | 1 | `FULL_READ` | 73줄 전문 |
|
||||
| `src/test/java/**` | 3 | `FULL_READ` | 전문 |
|
||||
| `src/test/resources/schemas/order.created/v1.avsc` | 1 | `STRUCTURAL_ONLY` | fixture 스키마; 필드 구성만 확인 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 주석 포함 11줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
선택적(optional) Avro codec. Stable이 아니고 registry membership이 비어 있다 — **build-only / incubating**이며, `docs/messaging/support-matrix.md`의 등급과는 다른 축이다.
|
||||
|
||||
Avro를 `api`로 선언한 이유가 build.gradle 주석에 있다.
|
||||
|
||||
```groovy
|
||||
// api: AvroMessageCodec's constructors take a registry of org.apache.avro.Schema and
|
||||
// AvroCompatibilityGate.check takes and compares them. A consumer cannot build that
|
||||
// registry without naming the type, so hiding the dependency only stops them compiling.
|
||||
api 'org.apache.avro:avro:1.12.0'
|
||||
```
|
||||
|
||||
`src/messaging/CLAUDE.md:40-43`이 기술하는 게이트 — public/protected 시그니처에 나오는 vendor 라이브러리가 `api`로 선언됐는지 대조 — 를 이 leaf가 통과한다. 형제 `messaging-schema-json`은 Jackson 타입이 시그니처에 없으므로 `implementation`이고, 그 판정 차이가 규칙이 실제로 작동한다는 증거다.
|
||||
|
||||
**클래스 둘의 실행 시점이 다르다.**
|
||||
|
||||
| 클래스 | 언제 도는가 | 근거 |
|
||||
|---|---|---|
|
||||
| `AvroMessageCodec` | 런타임(메시지마다) | `MessageCodec` 구현 |
|
||||
| `AvroCompatibilityGate` | **CI** | 클래스 javadoc: "Run in CI rather than at runtime" |
|
||||
|
||||
게이트의 javadoc이 그 이유를 적는다 — "By the time a producer has published one incompatible record, the damage is durable: the record sits in a retained log that every current and future consumer must be able to read."
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `avro:1.12.0`(api).
|
||||
|
||||
나가는 것: **없다.** 어떤 leaf의 `allowed_dependencies`에도 `messaging-schema-avro`가 없다. `messaging-spring-boot-starter`의 17개 의존 목록에도 없다.
|
||||
|
||||
런타임 배선: 없음. `runtime_memberships: []`이므로 배포 아티팩트에 실리지 않는다. bean도 없다(Spring 주석 0개).
|
||||
|
||||
**소비자 없음과 membership 없음이 일치한다.** 이것이 정합적인 incubating 상태다 — `messaging-cloudevents`와 대비된다(그쪽은 membership이 있고 소비자가 없다).
|
||||
|
||||
---
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
```
|
||||
AvroMessageCodec (MessageCodec 구현)
|
||||
├── encode(type, version, GenericRecord) → EncodedMessage
|
||||
├── decode(type, version, byte[], Class) → GenericRecord (writer == reader)
|
||||
├── decodeEvolved(type, writerV, readerV, byte[]) → GenericRecord (writer != reader)
|
||||
├── schemaFor(type, version) → 등록 조회, 2단 에러
|
||||
├── boundedReader(writer, reader) → newArray 오버라이드
|
||||
└── flatten(nested registry) → (type, version) 평탄화 + 깊은 복사
|
||||
|
||||
AvroCompatibilityGate (CI)
|
||||
└── check(candidate, history, mode)
|
||||
├── isTransitive / readsBackward / readsForward (private, 자체 구현)
|
||||
└── requireCompatible → org.apache.avro.SchemaCompatibility
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
### 4.1 Avro 바이너리에는 스키마가 없다 — 그래서 registry가 계약이다
|
||||
|
||||
```java
|
||||
// AvroMessageCodec.java:33-37
|
||||
* <p>Decoding uses an explicit writer schema and reader schema pair. Avro binary carries no schema
|
||||
* of its own, so decoding with the wrong schema does not fail — it produces plausible garbage. The
|
||||
* registry is what makes the writer schema knowable, and passing both schemas to the reader is what
|
||||
* makes evolution work: Avro resolves added, removed, and defaulted fields only when it can see
|
||||
* both sides.
|
||||
```
|
||||
|
||||
"does not fail — it produces plausible garbage"가 이 leaf의 모든 방어의 전제다. JSON이나 Protobuf와 달리 Avro는 잘못된 스키마로 디코딩해도 예외를 던지지 않는 경우가 있다.
|
||||
|
||||
single-object encoding에 헤더를 붙이지 않는 것도 명시적 결정이다 — "The framing that would carry a schema fingerprint belongs to the transport headers, where the platform already carries schema identity for every format, rather than being duplicated inside the Avro payload for this one format."
|
||||
|
||||
### 4.2 `flatten`: 얕은 복사가 만든 구멍
|
||||
|
||||
생성자가 받는 것은 중첩 맵 `Map<MessageType, Map<SchemaVersion, Schema>>`이고, `Map.copyOf`는 **바깥 레벨만** 복사한다.
|
||||
|
||||
```java
|
||||
// AvroMessageCodec.java:78-82
|
||||
* <p>{@code Map.copyOf} on the outer map is a shallow copy: every inner {@code Map<SchemaVersion,
|
||||
* Schema>} stayed the caller's own object, so a caller that kept a reference could add, replace,
|
||||
* or remove a schema version after construction and the codec would silently start encoding
|
||||
* against it. Flattening to {@code (type, version)} keys copies both levels and makes the version
|
||||
* part of the identity the lookup uses rather than a second hop.
|
||||
```
|
||||
|
||||
이 결함이 위험한 이유는 §4.1과 곱해진다 — 스키마가 바뀌어도 디코딩이 실패하지 않고 그럴듯한 쓰레기를 낸다.
|
||||
|
||||
`AvroRegistryBoundsTest.mutatingTheCallersMapAfterConstructionChangesNothing`이 세 가지를 한 번에 확인한다: 생성 후 추가한 버전은 미등록, 생성 후 추가한 타입도 미등록, 원래 등록한 스키마는 그대로.
|
||||
|
||||
평탄화가 `MessageContractKey`(schema-api)를 키로 쓰므로 §4.5의 2단 에러 구분도 자연히 따라온다.
|
||||
|
||||
### 4.3 인코딩: direct encoder를 쓰는 이유
|
||||
|
||||
```java
|
||||
// AvroMessageCodec.java:122-124
|
||||
// A direct encoder, not the buffering one: the buffering encoder holds bytes back until flush,
|
||||
// which would let a large record allocate freely before the sink ever sees a write. Direct
|
||||
// encoding makes the bound apply to the record as it is written.
|
||||
BinaryEncoder encoder = EncoderFactory.get().directBinaryEncoder(sink, null);
|
||||
```
|
||||
|
||||
`BoundedByteSink`(schema-api)의 경계가 실제로 작동하려면 인코더가 증분적으로 써야 한다. `EncoderFactory.get().binaryEncoder(...)`는 버퍼링하므로 sink가 첫 write를 보기 전에 큰 레코드가 이미 할당된다. 즉 **schema-api의 방어가 이 한 줄에 의존한다.**
|
||||
|
||||
인코딩 전 검사 둘:
|
||||
- payload가 `GenericRecord`인가 → `AVRO_PAYLOAD_NOT_A_RECORD`
|
||||
- `schema.equals(record.getSchema())`인가 → `AVRO_SCHEMA_MISMATCH`
|
||||
|
||||
두 번째는 테스트가 이유를 적는다 — `as("encoding v2 data under the v1 version would produce bytes nothing can decode")`.
|
||||
|
||||
### 4.4 `boundedReader`: 다섯 바이트 공격
|
||||
|
||||
이 leaf에서 가장 깊은 방어다.
|
||||
|
||||
```java
|
||||
// AvroMessageCodec.java:222-235
|
||||
* <p>Avro writes an array as a declared element count followed by the elements. The count is a
|
||||
* variable-length integer, so five bytes can claim four hundred million elements, and the generic
|
||||
* reader allocates the backing array from that claim before reading a single element. Bounding
|
||||
* the input length does not help: the whole hostile payload is five bytes, well under any limit,
|
||||
* and the failure is an {@code OutOfMemoryError} rather than an exception the codec could report
|
||||
* — on a consumer thread that is the process, not the message.
|
||||
*
|
||||
* <p>The ceiling is the byte limit itself. Every element costs at least one byte on the wire even
|
||||
* when it is empty, so a payload of at most {@code maxBytes} bytes cannot honestly contain more
|
||||
* than {@code maxBytes} elements, and any larger claim is a lie the reader should refuse rather
|
||||
* than reserve memory for.
|
||||
```
|
||||
|
||||
구현은 익명 서브클래스의 `newArray` 오버라이드다.
|
||||
|
||||
```java
|
||||
return new GenericDatumReader<>(writerSchema, readerSchema) {
|
||||
@Override
|
||||
protected Object newArray(Object old, int size, Schema schema) {
|
||||
if (size > maxElements) {
|
||||
throw new MessageTooLargeException("AVRO_COLLECTION_TOO_LARGE", ...);
|
||||
}
|
||||
return super.newArray(old, size, schema);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**상한 선택의 논리가 정확하다.** 원소 하나가 wire에서 최소 1바이트를 쓰므로, `maxBytes` 바이트짜리 payload가 정직하게 담을 수 있는 원소는 `maxBytes`개를 넘을 수 없다. 별도 튜닝 상수를 만들지 않고 이미 있는 경계에서 파생시켰다.
|
||||
|
||||
`AvroHostileInputTest`가 이 공격을 손으로 만든 zigzag varint로 재현한다.
|
||||
|
||||
```java
|
||||
// AvroHostileInputTest.java:118-123
|
||||
* <p>Hand-written rather than taken from an encoder because the point is to write a count with no
|
||||
* elements behind it, which no encoder will do.
|
||||
```
|
||||
|
||||
그리고 공격의 크기를 직접 단언한다 — `assertThat(hostile).as("the whole attack is five bytes, so no byte limit stands between it and the allocation").hasSizeLessThan(16)`.
|
||||
|
||||
테스트 클래스 javadoc이 **왜 corpus가 좁은지**까지 적는다.
|
||||
|
||||
```java
|
||||
// AvroHostileInputTest.java:30-33
|
||||
* <p>Strings, byte arrays and maps were already safe: Avro validates those lengths against the
|
||||
* bytes actually remaining. Arrays were the one shape that allocated on trust, which is why the
|
||||
* corpus below is narrow rather than exhaustive — it pins the case that failed, and the two cases
|
||||
* that must keep working around it.
|
||||
```
|
||||
|
||||
이것은 "좁은 테스트"를 정당화한 드문 예다 — 다른 형태는 라이브러리가 이미 방어하므로 재확인이 아니라 잡음이 된다.
|
||||
|
||||
### 4.5 `schemaFor`: 2단 에러
|
||||
|
||||
`AVRO_TYPE_NOT_REGISTERED`(타입 미등록)와 `AVRO_VERSION_NOT_REGISTERED`(버전 미등록)를 구분한다. JSON codec의 `UNKNOWN_MESSAGE_TYPE`/`SCHEMA_VERSION_NOT_REGISTERED`와 같은 형태이지만 **코드 문자열이 다르다.** 두 codec이 같은 판단을 다른 어휘로 보고한다 — §12.3.
|
||||
|
||||
### 4.6 `decodeEvolved`: 나중에 붙은 경계
|
||||
|
||||
```java
|
||||
// AvroMessageCodec.java:199-201
|
||||
// The same bound the ordinary decode applies. It was missing here, so the evolution path — the
|
||||
// one a consumer takes for every message written by a newer producer — accepted input of any
|
||||
// size.
|
||||
requireWithinLimit(encoded.length);
|
||||
```
|
||||
|
||||
테스트가 두 각도에서 붙든다 — `AvroRegistryBoundsTest.theEvolutionDecodeAppliesTheSameBound`(`as("decodeEvolved accepted input of any size")`)와 `AvroHostileInputTest.theEvolutionDecodeAppliesTheSameCollectionBound`(`as("a consumer reading a newer producer takes this path for every message")`).
|
||||
|
||||
즉 `decodeEvolved`는 **가장 흔한 경로인데 가장 늦게 보호됐다.** 진화 경로는 producer가 앞서 나간 순간부터 모든 메시지가 지나는 길이다.
|
||||
|
||||
### 4.7 `AvroCompatibilityGate`
|
||||
|
||||
```java
|
||||
public void check(Schema candidate, List<Schema> history, SchemaCompatibility mode) {
|
||||
if (mode == SchemaCompatibility.NONE_EXPERIMENTAL || history.isEmpty()) return;
|
||||
List<Schema> checked = isTransitive(mode) ? history : history.subList(0, 1);
|
||||
for (Schema previous : checked) {
|
||||
if (readsBackward(mode)) requireCompatible(candidate, previous, "backward");
|
||||
if (readsForward(mode)) requireCompatible(previous, candidate, "forward");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`history`는 **newest first**를 요구한다(javadoc `@param history the previously registered schemas, newest first`). 이것은 `messaging-schema-api`의 `SchemaRegistry.history`가 **oldest first**를 계약으로 삼는 것과 반대다. 두 계약을 잇는 코드가 없으므로 오늘은 충돌하지 않지만, 잇는 순간 `reversed()`를 빠뜨리면 조용히 잘못된 버전을 비교한다. `SchemaCompatibilityValidator.versionsToCheck`가 정확히 그 `reversed()`를 수행하고, 그 클래스는 호출되지 않는다(§12.3).
|
||||
|
||||
에러 코드는 방향에서 파생된다 — `"AVRO_" + direction.toUpperCase(Locale.ROOT) + "_INCOMPATIBLE"` → `AVRO_BACKWARD_INCOMPATIBLE` / `AVRO_FORWARD_INCOMPATIBLE`.
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
**encode:** `schemaFor` → `GenericRecord` 확인 → 스키마 동일성 확인 → `BoundedByteSink` + direct encoder → `writer.write` + `flush` → `EncodedMessage(bytes, AVRO, SchemaReference)`
|
||||
|
||||
**decode(동일 버전):** `requireWithinLimit` → `schemaFor` → 대상 타입이 `GenericRecord` 계열인지 → `boundedReader(writer, writer)` → `reader.read`
|
||||
|
||||
**decodeEvolved:** `requireWithinLimit` → `schemaFor(writer)` + `schemaFor(reader)` → `boundedReader(writer, reader)` → `reader.read`
|
||||
|
||||
**CI 게이트:** `check(candidate, history, mode)` → 모드에 따라 비교 대상 선정 → 방향별 `checkReaderWriterCompatibility`
|
||||
|
||||
---
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
| 코드 | 예외 | 조건 |
|
||||
|---|---|---|
|
||||
| `AVRO_TYPE_NOT_REGISTERED` | `MessageValidationException` | 타입 미등록 |
|
||||
| `AVRO_VERSION_NOT_REGISTERED` | `MessageValidationException` | 버전 미등록 |
|
||||
| `AVRO_PAYLOAD_NOT_A_RECORD` | `MessageValidationException` | encode/decode 대상이 `GenericRecord`가 아님 |
|
||||
| `AVRO_SCHEMA_MISMATCH` | `MessageValidationException` | payload 스키마 ≠ 등록 스키마 |
|
||||
| `AVRO_PAYLOAD_TOO_LARGE` | `MessageTooLargeException` | 인코딩 중 또는 디코딩 입력 상한 초과 |
|
||||
| `AVRO_COLLECTION_TOO_LARGE` | `MessageTooLargeException` | 배열 원소 수 주장 > `maxBytes` |
|
||||
| `AVRO_ENCODE_FAILED` | `MessageSerializationException` | 그 외 인코딩 실패 |
|
||||
| `AVRO_DECODE_FAILED` | `MessageSerializationException` | 그 외 디코딩 실패 |
|
||||
| `AVRO_EVOLUTION_FAILED` | `MessageSerializationException` | 진화 해석 실패 |
|
||||
| `AVRO_BACKWARD_INCOMPATIBLE` / `AVRO_FORWARD_INCOMPATIBLE` | `MessageSchemaIncompatibleException` | CI 게이트 |
|
||||
|
||||
**예외 재던지기 패턴이 세 곳에 반복된다.**
|
||||
|
||||
```java
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
if (failure instanceof MessageTooLargeException tooLarge) {
|
||||
throw tooLarge;
|
||||
}
|
||||
throw new MessageSerializationException("AVRO_*_FAILED", ..., failure);
|
||||
}
|
||||
```
|
||||
|
||||
`BoundedByteSink`가 던지는 `MessageTooLargeException`은 `RuntimeException`이므로 catch에 걸린다. 그것을 그대로 통과시키지 않으면 크기 실패가 인코딩 실패로 접힌다 — JSON codec의 `unwrapTooLarge`와 같은 문제를 다른 방식(원인 사슬 탐색이 아니라 즉시 `instanceof`)으로 푼다. §12.3.
|
||||
|
||||
`AvroHostileInputTest.aCountBeyondIntRangeFailsWhileReadingRatherThanWhileReserving`가 흥미로운 경계를 잡는다 — 2³²을 주장하면 int로 잘려 무해한 값이 되고, 그 다음 읽기가 입력 부족으로 실패해 `MessageSerializationException`이 된다. 즉 `newArray` 방어를 우회하는 값이 존재하지만 그 우회는 할당이 아니라 읽기 실패로 끝난다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
트랜잭션 없음.
|
||||
|
||||
`AvroMessageCodec`은 불변이다 — `schemas`가 `Map.copyOf`된 평탄 맵, `maxBytes`는 int. `BoundedByteSink`·`BinaryEncoder`·`DatumReader`·`BinaryDecoder`는 전부 호출마다 새로 만들어진다.
|
||||
|
||||
`EncoderFactory.get()`/`DecoderFactory.get()`은 Avro의 싱글턴 팩토리이고 스레드 안전하다. 다만 `binaryDecoder(encoded, null)`의 두 번째 인자가 재사용 decoder 자리인데 항상 `null`을 넘긴다 — 재사용하지 않으므로 공유 상태가 없다. 성능을 버리고 안전을 택한 형태다.
|
||||
|
||||
`AvroCompatibilityGate`는 상태가 없다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
설정 없음.
|
||||
|
||||
| 상수 | 값 | 가시성 |
|
||||
|---|---:|---|
|
||||
| `AvroMessageCodec.DEFAULT_MAX_BYTES` | 1,048,576 | **private** |
|
||||
|
||||
private이므로 §12.3의 "1 MiB가 다섯 곳에 복사됨" 문제에서 이 leaf는 외부에 값을 노출하지 않는다. 대신 공유 상수를 읽지도 않는다.
|
||||
|
||||
Avro 버전은 `1.12.0`으로 build.gradle에 고정돼 있다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
없다. 외부 schema registry를 쓰지 않는다 — 스키마는 생성자 인자로 받는다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
레인: `./gradlew :messaging:messaging-schema-avro:test`. **BUILD SUCCESSFUL, 16 tests, 0 skipped, 0 failures**.
|
||||
|
||||
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|
||||
|---|---:|---|---|
|
||||
| `AvroCompatibilityTest` | 8 | round trip, schema reference, v1→v2 default를 통한 진화, defaulted 필드 추가는 backward 호환, default 없는 추가는 거절, payload 스키마 불일치 사전 거절, 미등록 버전/타입 거절 | transitive 모드 실제 동작(테스트가 `BACKWARD`만 씀) |
|
||||
| `AvroHostileInputTest` | 4 | 4억 원소 주장이 할당 전에 거절됨, 진화 경로도 같은 방어, int 범위 초과는 읽기 실패로 끝남, 정직한 배열은 정상 | 문자열·맵·바이트 배열(라이브러리가 이미 방어한다고 javadoc이 명시) |
|
||||
| `AvroRegistryBoundsTest` | 4 | 생성 후 맵 변경이 무효, 인코딩 중 거절(`refused at byte`), 진화 경로 상한, 정확히 상한인 payload 허용 | — |
|
||||
|
||||
**증명 공백 하나.** `AvroCompatibilityGate`의 transitive 모드가 테스트되지 않는다. 8개 중 게이트를 부르는 것은 둘이고 둘 다 `SchemaCompatibility.BACKWARD`(pairwise)다. `isTransitive`가 true인 경로 — `history` 전체를 순회하는 분기 — 는 실행되지 않는다. 그 분기는 §12.3이 지적하는 중복 구현의 핵심이기도 하다.
|
||||
|
||||
세 테스트 클래스 중 둘이 클래스 javadoc으로 **이전 결함을 서술한다**(`AvroHostileInputTest`, `AvroRegistryBoundsTest`). 이 저장소의 일관된 습관이다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
| 게이트 | 이 leaf에 대해 |
|
||||
|---|---|
|
||||
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-schema-api"]` |
|
||||
| `verifyRuntimeModuleMembership` | `[]` — 런타임 편입 없음이 강제됨 |
|
||||
| vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | Avro가 public 시그니처에 등장 → `api` 선언 필요. **통과** |
|
||||
| ArchUnit | 전용 규칙 없음 |
|
||||
|
||||
`AvroCompatibilityGate`가 "Run in CI"라고 선언하지만, **이 저장소의 CI에서 그것을 실행하는 task가 없다.** `src/build.gradle`의 9개 `verifyMessaging*` task는 전부 `app-bootstrap/build/messaging-evidence/**/manifest.json`을 요구하는 자격 게이트이고 스키마 진화 검사를 부르지 않는다. §12.1.
|
||||
|
||||
---
|
||||
|
||||
## 12. 실제 사용 여부와 negative-space probes
|
||||
|
||||
원시 증거: `evidence/raw/272-schema-family-reachability.txt`.
|
||||
|
||||
### 12.1 Public surface reachability
|
||||
|
||||
| 타입 | leaf 밖 참조 | 판정 |
|
||||
|---|---:|---|
|
||||
| `AvroMessageCodec` | **0** | 소비자 없음 |
|
||||
| `AvroCompatibilityGate` | **0** | 소비자 없음 |
|
||||
|
||||
`git grep -l -w AvroMessageCodec -- src ':!src/messaging/messaging-schema-avro'` exit 1, `AvroCompatibilityGate`도 동일.
|
||||
|
||||
**두 클래스의 "0"은 성격이 다르다.**
|
||||
|
||||
`AvroMessageCodec`의 0은 정합적이다 — `runtime_memberships: []`이고 starter의 codec registry에도 등록되지 않는다(`RegisteredMessageCodecs.of(JacksonMessageCodec.of(...))`, varargs 비어 있음). 소비자 없음과 배포 없음이 일치한다.
|
||||
|
||||
`AvroCompatibilityGate`의 0은 다르다. 이 클래스는 **런타임이 아니라 CI에서 도는 것을 전제로 설계됐다.** javadoc이 그렇게 선언한다. 그런데 그것을 부르는 CI task가 없다. 즉 "런타임에 안 쓰이는 건 당연하다"가 이 클래스에는 적용되지 않는다 — 이 클래스는 애초에 런타임 소비자를 가질 계획이 없었고, 계획된 소비자(CI)도 없다.
|
||||
|
||||
이 구분이 중요한 이유: 배포 게이트가 생겨 `messaging-schema-avro`가 런타임에 편입되면 `AvroMessageCodec`은 자연히 배선되지만 `AvroCompatibilityGate`는 여전히 아무 데도 붙지 않는다. 두 문제는 함께 풀리지 않는다.
|
||||
|
||||
**한계.** 이 저장소는 템플릿이고, 파생 프로젝트가 `AvroCompatibilityGate`를 자기 CI에서 부를 수 있다. 그것을 확인할 수단이 저장소 안에 없다.
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
Spring 주석 0개. bean 없음. 비교 대상 없음.
|
||||
|
||||
**codec sibling 비교는 가능하고 결과가 유의미하다.**
|
||||
|
||||
| codec | `MessageCodec` 구현 | starter 등록 | membership | 정합성 |
|
||||
|---|:---:|:---:|---|---|
|
||||
| `JacksonMessageCodec` | o | o | `["app-bootstrap"]` | 일치 |
|
||||
| `AvroMessageCodec` | o | x | `[]` | **일치** |
|
||||
| `ProtobufMessageCodec` | o | x | `[]` | 일치 |
|
||||
| `RawBytesMessageCodec` | o | x | `["app-bootstrap"]`(schema-api 소속) | 불일치 |
|
||||
|
||||
Avro는 세 축이 전부 "없음"으로 정렬돼 있다. incubating leaf가 이래야 하는 형태다.
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
**(a) 진화 판단 중복 — 확인됨**
|
||||
|
||||
`AvroCompatibilityGate`의 private `isTransitive`/`readsBackward`/`readsForward`가 `messaging-schema-api`의 `SchemaCompatibilityValidator`의 public static `isTransitive`/`checksBackward`/`checksForward`와 같은 판단을 다시 구현한다.
|
||||
|
||||
| 판단 | schema-api | 이 leaf |
|
||||
|---|---|---|
|
||||
| `isTransitive` | public static, 허용목록 | private static, **글자까지 동일한 복사본** |
|
||||
| 후방 검사 | `checksBackward`, 허용목록 | `readsBackward`, **거부목록** |
|
||||
| 전방 검사 | `checksForward`, 허용목록 | `readsForward`, **거부목록** |
|
||||
|
||||
현재 enum 7개 값에서 두 구현의 결과는 같다(`NONE_EXPERIMENTAL`은 `check:34`의 early return이 가린다). 형태가 반대이므로 enum이 자라면 갈라진다 — 허용목록은 새 모드를 "검사 안 함"으로, 거부목록은 "양방향 검사"로 기본 처리한다.
|
||||
|
||||
schema-api의 javadoc이 이 중복을 정확히 예고했다 — "duplicating that reasoning in each codec is how the two formats drift apart". 그리고 그것을 막을 클래스는 호출되지 않는다. 상세는 `analysis/messaging/messaging-schema-api.md` §12.3이 소유한다.
|
||||
|
||||
**(b) history 순서 계약이 반대다**
|
||||
|
||||
| 위치 | 요구 |
|
||||
|---|---|
|
||||
| `SchemaRegistry.history` (schema-api) | **oldest first** |
|
||||
| `AvroCompatibilityGate.check`의 `history` 파라미터 | **newest first** |
|
||||
|
||||
둘을 잇는 코드가 없어 오늘은 충돌하지 않는다. 잇는 순간 `reversed()`를 빠뜨리면 `history.subList(0, 1)`이 가장 오래된 스키마를 "직전 버전"으로 비교한다. 실패하지 않고 **엉뚱한 비교를 통과시킬 수 있다.**
|
||||
|
||||
**(c) 크기 예외 통과 패턴이 codec마다 다르다**
|
||||
|
||||
| codec | 방식 |
|
||||
|---|---|
|
||||
| `JacksonMessageCodec` | `unwrapTooLarge` — 원인 사슬을 끝까지 훑음 |
|
||||
| `AvroMessageCodec` | `catch` 안에서 즉시 `instanceof` (3곳 반복) |
|
||||
| `ProtobufMessageCodec` | 해당 없음 — `requireFits`로 사전 거절 |
|
||||
|
||||
같은 문제(`BoundedByteSink`의 `MessageTooLargeException`이 포맷 라이브러리 예외에 삼켜지는 것)를 세 가지로 푼다. Jackson은 예외를 감싸므로 사슬 탐색이 필요하고, Avro는 감싸지 않으므로 즉시 검사로 충분하다 — 즉 차이가 라이브러리 동작에서 나온 정당한 것이다. 다만 그 이유가 어디에도 적혀 있지 않다.
|
||||
|
||||
**(d) 에러 코드 어휘가 codec마다 다르다**
|
||||
|
||||
같은 판단에 다른 문자열:
|
||||
|
||||
| 판단 | JSON | Avro | Protobuf |
|
||||
|---|---|---|---|
|
||||
| 타입 미등록 | `UNKNOWN_MESSAGE_TYPE` | `AVRO_TYPE_NOT_REGISTERED` | `UNKNOWN_MESSAGE_TYPE` |
|
||||
| 버전 미등록 | `SCHEMA_VERSION_NOT_REGISTERED` | `AVRO_VERSION_NOT_REGISTERED` | `SCHEMA_VERSION_NOT_REGISTERED` |
|
||||
| 타입 불일치 | `PAYLOAD_TYPE_MISMATCH` | `AVRO_PAYLOAD_NOT_A_RECORD` / `AVRO_SCHEMA_MISMATCH` | `PAYLOAD_TYPE_MISMATCH` |
|
||||
|
||||
JSON과 Protobuf는 어휘를 공유하고 Avro만 접두사를 붙인다. 대시보드가 코드로 집계하면 Avro만 별도 계열이 된다.
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
| 문서 주장 | 재측정 | 결과 |
|
||||
|---|---|---|
|
||||
| `AvroCompatibilityGate` javadoc: "Run in CI rather than at runtime" | 저장소 CI에 호출 지점 없음 | **미실현** — 진술이 틀린 게 아니라 계획이 실행되지 않음 |
|
||||
| build.gradle 주석: Avro가 public 시그니처에 등장하므로 `api` | 두 클래스의 public 시그니처에 `org.apache.avro.Schema` 등장 확인 | **일치** |
|
||||
| `docs/messaging/support-matrix.md`: Avro가 Stable이 아님 | membership `[]`, starter 미등록 | **일치** |
|
||||
| `docs/messaging/support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 실제로 `[]` — **이 leaf에 한해서는 맞다** | family 전체로는 틀림(`messaging-core-api` §12.4) |
|
||||
|
||||
마지막 행이 흥미롭다. 잘못된 일반화가 우연히 이 leaf에서는 참이 된다. 그래서 이 문서만 읽으면 drift를 발견할 수 없다 — family 수준에서 세야 보인다.
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
테스트 클래스 javadoc이 세 결함을 보존한다.
|
||||
|
||||
| 위치 | 이전 상태 | 그것이 만든 실패 |
|
||||
|---|---|---|
|
||||
| `AvroRegistryBoundsTest` javadoc | 중첩 맵에 `Map.copyOf`(얕은 복사) | 호출자가 생성 후 스키마 교체 가능 → Avro는 실패하지 않고 그럴듯한 쓰레기를 만듦 |
|
||||
| `AvroRegistryBoundsTest` javadoc | `decodeEvolved`에 크기 검사 없음 | producer가 앞서 나간 뒤 **모든 메시지**가 지나는 경로가 무제한 입력을 수용 |
|
||||
| `AvroHostileInputTest` javadoc | 배열 원소 수 주장을 신뢰하고 할당 | 5바이트로 4억 원소 배열 → `OutOfMemoryError`, codec이 분류할 수 없는 실패, consumer 스레드에서 프로세스 사망 |
|
||||
| `AvroMessageCodec.decodeEvolved` 주석 | 같은 내용 | — |
|
||||
|
||||
세 번째가 형태상 가장 흥미롭다 — **바이트 상한이라는 올바른 도구가 잘못된 공격에 적용되어 있었다.** 테스트 javadoc이 그것을 한 문장으로 적는다: "The byte limit is the wrong instrument for this attack and was the only one in place."
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` §B, §D, §E | 두 형태의 진화 판단 나란히, codec별 소비자 0, membership `[]` | 정적 검색 |
|
||||
| EVD-276 | command | `./gradlew :messaging:messaging-schema-avro:test --rerun-tasks` | BUILD SUCCESSFUL, 16 / 0 / 0 | 실제 Avro 브로커 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적**
|
||||
|
||||
- Avro 바이너리에 스키마가 없어 registry가 계약이 되는 이유 — 클래스 javadoc
|
||||
- single-object encoding에 헤더를 안 붙이는 이유 — 클래스 javadoc
|
||||
- 얕은 복사가 만든 구멍과 평탄화로 고친 이유 — `flatten` javadoc
|
||||
- direct encoder를 쓰는 이유 — encode 주석
|
||||
- 배열 원소 상한을 `maxBytes`로 잡은 논리 — `boundedReader` javadoc
|
||||
- 적대적 입력 corpus가 좁은 이유 — `AvroHostileInputTest` javadoc
|
||||
- 게이트가 CI용인 이유 — `AvroCompatibilityGate` javadoc
|
||||
- Avro를 `api`로 선언한 이유 — build.gradle 주석
|
||||
|
||||
**추론**
|
||||
|
||||
- 크기 예외 통과 방식이 JSON과 다른 것은 Jackson이 예외를 감싸고 Avro는 감싸지 않기 때문이다 → **추론**. 두 코드의 형태는 관측이고 인과는 추론이다.
|
||||
- 에러 코드에 `AVRO_` 접두사를 붙인 것이 의도인지 → **미상**.
|
||||
- 게이트가 `newest first`를 요구하는 것과 port가 `oldest first`인 것 중 어느 쪽이 나중인지 → **미상**. 커밋이 4개뿐이고 둘 다 같은 커밋에 들어왔다.
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- 두 클래스 345줄 전문의 계약과 방어
|
||||
- 16개 테스트가 통과하고 무엇을 단언하는지
|
||||
- 소비자 0과 membership `[]`이 정합적이라는 것
|
||||
- 진화 판단이 schema-api와 중복이고 형태가 반대라는 것
|
||||
- `history` 순서 계약이 schema-api와 반대라는 것
|
||||
- CI 실행을 전제한 게이트를 부르는 CI task가 없다는 것
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- **transitive 모드의 실제 동작.** 테스트가 `BACKWARD`만 쓴다. `history` 전체 순회 분기가 실행된 적이 없다.
|
||||
- 파생 프로젝트가 `AvroCompatibilityGate`를 자기 CI에서 부르는지. 저장소 안에 확인 수단이 없다.
|
||||
- 실제 Avro 스키마 진화 사례에서 `checkReaderWriterCompatibility`의 판정이 이 게이트의 방향 매핑과 맞는지 — 테스트는 defaulted 필드 추가/미추가 두 경우만 본다.
|
||||
- `decodeEvolved`가 실제 다중 버전 배포에서 어떤 빈도로 쓰이는지. 소비자가 없어 관측할 수 없다.
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P2 — CI에서 돈다고 선언한 게이트를 부르는 CI가 없다
|
||||
|
||||
- **사실.** `AvroCompatibilityGate` javadoc이 "Run in CI rather than at runtime"이라고 선언한다. 저장소 전체에서 이 클래스 참조는 자기 선언과 자기 테스트뿐이고, `src/build.gradle`의 9개 `verifyMessaging*` task 중 스키마 진화를 검사하는 것이 없다.
|
||||
- **근거.** `evidence/raw/272` §D. `src/build.gradle:65-110`.
|
||||
- **왜 문제인가.** 게이트의 존재 이유가 "한 번 발행되면 보존 로그에 영구히 남는다"인데, 그 보호가 어느 파이프라인에도 붙어 있지 않다. `AvroMessageCodec`의 미사용과 달리 이것은 membership으로 설명되지 않는다 — 런타임 편입 여부와 무관하게 CI 게이트는 붙었어야 한다.
|
||||
- **확인 방법.** `git grep -n -w AvroCompatibilityGate -- src` · `git grep -n 'verifyMessaging' -- src/build.gradle`
|
||||
- **후보.** (a) 스키마 디렉터리를 읽어 게이트를 돌리는 Gradle task를 만든다. (b) 파생 프로젝트가 붙이는 확장점이라면 javadoc이 그렇게 말하도록 고친다.
|
||||
- **다음 단계.** **CASE 후보.** "장치는 있고 회로가 닫히지 않았다"의 전형이고, 재현이 정적 검색으로 끝난다.
|
||||
|
||||
### P2 — 진화 판단이 두 곳에 있고 형태가 반대다
|
||||
|
||||
- **사실.** `isTransitive`는 `SchemaCompatibilityValidator`(public static)와 이 leaf(private static)에 글자까지 같은 복사본이 있다. 방향 판정은 전자가 허용목록, 후자가 거부목록이다.
|
||||
- **근거.** `evidence/raw/272` §B에 두 형태가 나란히 출력된다.
|
||||
- **왜 문제인가.** 오늘 7개 모드에서 결과는 같지만 형태가 반대이므로 `SchemaCompatibility`에 값이 추가되는 순간 갈라진다 — 허용목록은 "검사 안 함", 거부목록은 "양방향 검사". 그리고 이 중복은 schema-api의 javadoc이 명시적으로 막으려던 것이다.
|
||||
- **확인 방법.** `evidence/raw/272` §B 재실행.
|
||||
- **후보.** `AvroCompatibilityGate`가 `SchemaCompatibilityValidator`의 public static을 부르게 한다. 세 메서드 다 이미 public static이다.
|
||||
- **다음 단계.** `messaging-schema-api` §17의 같은 항목과 **동일 사건**이다. 그 leaf가 소유하고 여기서는 교차 참조만 남긴다.
|
||||
|
||||
### P3 — `history` 순서 계약이 port와 게이트에서 반대다
|
||||
|
||||
- **사실.** `SchemaRegistry.history` javadoc은 oldest first, `AvroCompatibilityGate.check`의 `@param history`는 newest first.
|
||||
- **근거.** 두 javadoc.
|
||||
- **왜 문제인가.** 둘을 잇는 코드가 없어 지금은 무해하다. 이으면서 `reversed()`를 빠뜨리면 pairwise 모드가 **가장 오래된** 스키마를 직전 버전으로 비교한다. 실패하지 않고 통과할 수 있는 오류다. port javadoc이 이미 같은 위험을 경고한다 — "an ordering mistake here silently converts a transitive check into a pairwise one."
|
||||
- **확인 방법.** 두 javadoc 대조.
|
||||
- **후보.** 게이트도 oldest-first를 받게 통일하고 내부에서 뒤집는다.
|
||||
- **다음 단계.** **REFERENCE 후보**(컬렉션 순서가 계약이면 양쪽에서 같은 방향으로 적는다).
|
||||
|
||||
### P3 — transitive 분기가 테스트되지 않는다
|
||||
|
||||
- **사실.** `AvroCompatibilityTest`의 게이트 호출 2건이 모두 `SchemaCompatibility.BACKWARD`다. `isTransitive`가 true인 경로가 실행되지 않는다.
|
||||
- **근거.** `AvroCompatibilityTest.java:134-150`.
|
||||
- **왜 문제인가.** transitive 모드는 "여러 릴리스 뒤처진 consumer"를 위한 것이고 그것이 이 게이트의 존재 이유 중 절반이다. 그리고 그 분기가 §12.3의 중복 구현이 갈라질 지점이다.
|
||||
- **확인 방법.** 두 테스트의 모드 인자 확인.
|
||||
- **후보.** v1·v2·v3 세 스키마로 `BACKWARD_TRANSITIVE` 케이스를 추가한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(모드 enum을 분기 조건으로 쓰면 각 분기에 테스트를 둔다).
|
||||
|
||||
### P3 — 에러 코드 어휘가 형제 codec과 갈라진다
|
||||
|
||||
- **사실.** 같은 판단에 JSON/Protobuf는 `UNKNOWN_MESSAGE_TYPE`·`SCHEMA_VERSION_NOT_REGISTERED`, Avro는 `AVRO_TYPE_NOT_REGISTERED`·`AVRO_VERSION_NOT_REGISTERED`를 쓴다.
|
||||
- **근거.** 세 codec의 `requireRegistered`/`schemaFor`.
|
||||
- **왜 문제인가.** `FailureDescriptor.code`는 "stable, machine-readable code"이고 대시보드·재시도 정책이 이것으로 집계한다. 같은 판단이 두 어휘로 나뉘면 Avro만 별도 계열이 된다.
|
||||
- **확인 방법.** `git grep -n 'NOT_REGISTERED' -- 'src/messaging/**/*.java'`
|
||||
- **후보.** 공통 코드를 쓰고 포맷은 `sanitizedMessage`로 구분한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(안정 코드는 판단 단위로 정하고 구현 단위로 정하지 않는다).
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- 중첩 registry를 `(type, version)`으로 평탄화해 양쪽 레벨을 복사하는 것
|
||||
- direct encoder 선택 — `BoundedByteSink`의 경계가 실제로 작동하기 위한 전제
|
||||
- 배열 원소 상한을 별도 튜닝 값이 아니라 `maxBytes`에서 파생시킨 것
|
||||
- `decodeEvolved`에 같은 상한을 적용한 것과, 그것을 두 각도에서 붙드는 테스트
|
||||
- 적대적 입력 corpus를 좁게 두고 그 이유를 적은 것
|
||||
- Avro를 `api`로 선언한 것(형제 JSON과 반대 판정이고, 그것이 맞다)
|
||||
- 소비자 0과 membership `[]`이 정합적인 것
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MSV-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps, `runtime_memberships: []` | 선언 |
|
||||
| MSV-002 | build | `messaging-schema-avro/build.gradle` | same | Avro `api` 선언과 그 이유, 버전 1.12.0 | — |
|
||||
| MSV-003 | code | `.../avro/AvroMessageCodec.java` 전문 | same | §4.1–4.6 | — |
|
||||
| MSV-004 | code | `.../avro/AvroCompatibilityGate.java` 전문 | same | §4.7, §12.3(a) | — |
|
||||
| MSV-005 | test | `AvroCompatibilityTest` (8) | same | round trip·진화·게이트 pairwise | transitive 미검증 |
|
||||
| MSV-006 | test | `AvroHostileInputTest` (4) | same | 5바이트 4억 원소 공격과 방어, 진화 경로 동일 방어 | 문자열·맵은 범위 밖(javadoc이 이유를 적음) |
|
||||
| MSV-007 | test | `AvroRegistryBoundsTest` (4) | same | 생성 후 맵 변경 무효, 인코딩 중 거절, 진화 경로 상한 | — |
|
||||
| MSV-008 | cross-leaf code | `messaging-schema-api/.../SchemaCompatibilityValidator.java:79-112` | same | 중복의 다른 쪽 | 해당 leaf SSOT가 소유 |
|
||||
| MSV-009 | cross-leaf code | `messaging-schema-api/.../SchemaRegistry.java:16-17` | same | oldest-first 계약 | 해당 leaf SSOT가 소유 |
|
||||
| MSV-010 | cross-leaf code | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:360-366` | same | codec registry에 Avro 미등록 | 해당 leaf SSOT가 소유 |
|
||||
| MSV-011 | build policy | `src/build.gradle:65-110`, `src/messaging/CLAUDE.md:40-43` | same | `verifyMessaging*` 9개가 스키마 진화를 부르지 않음, vendor `api` 규칙 | — |
|
||||
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | §12.1·§12.3 | 정적 검색 |
|
||||
| EVD-276 | command | `./gradlew :messaging:messaging-schema-avro:test --rerun-tasks` | same | 16 / 0 / 0 | 실제 브로커 없음 |
|
||||
@@ -0,0 +1,511 @@
|
||||
# messaging-schema-json 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-schema-json`
|
||||
> SSOT owner: `messaging-schema-json`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- registered leaf id: `messaging-schema-json`
|
||||
- canonical state `analysisFile`: `analysis/messaging/messaging-schema-json.md`
|
||||
- source path: `src/messaging/messaging-schema-json`
|
||||
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api"]`
|
||||
- registry `runtime_memberships`: `["app-bootstrap"]`
|
||||
|
||||
### 숫자
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | **1** |
|
||||
| production LOC | 226 |
|
||||
| 패키지 | 1 (`dev.caskeleton.messaging.schema.json`) |
|
||||
| test 파일 | 3 |
|
||||
| test 메서드(실행 확인) | 18 |
|
||||
| 외부 의존성 | 1 (`tools.jackson.core:jackson-databind`, `implementation`) |
|
||||
|
||||
이 leaf는 클래스 하나다: `JacksonMessageCodec`. **그리고 messaging 플랫폼에서 production 소비자를 가진 유일한 codec이다**(§12.1).
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `.../json/JacksonMessageCodec.java` | 1 | `FULL_READ` | 226줄 전문 |
|
||||
| `src/test/java/**` | 3 | `FULL_READ` | 전문 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 8줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
Stable JSON codec 하나. `MessageCodec`(schema-api)을 구현하고 Jackson 3(`tools.jackson.*` 네임스페이스)을 쓴다.
|
||||
|
||||
javadoc이 "기본 codec으로 노출해도 안전한 이유" 셋을 명시한다.
|
||||
|
||||
```java
|
||||
// JacksonMessageCodec.java:29-37
|
||||
* <p>Three things make this safe to expose as the default. The message-type registry is closed, so
|
||||
* a payload class only becomes reachable when someone registered it. The parser is constrained on
|
||||
* depth, document length, and duplicate keys, so a hostile document cannot exhaust the consumer
|
||||
* before the handler ever runs. And the encoded size is checked against the destination limit here
|
||||
* rather than at the broker, so an oversized payload fails locally with {@code NOT_TRANSMITTED}
|
||||
* evidence instead of ambiguously mid-flight.
|
||||
*
|
||||
* <p>Polymorphic default typing is never enabled. It is the mechanism behind most JSON
|
||||
* deserialization gadget chains, and no legitimate message contract needs it.
|
||||
```
|
||||
|
||||
세 번째가 `messaging-core-api`의 3상태 발행 결과와 직접 연결된다 — 크기 초과를 브로커가 아니라 여기서 잡으면 `NOT_TRANSMITTED` 증거가 붙은 `REJECTED`가 되고, 브로커에서 잡히면 `AMBIGUOUS`가 된다. 전자는 버려도 안전하고 후자는 아니다.
|
||||
|
||||
Jackson 의존성은 `implementation`이다 — public 시그니처에 Jackson 타입이 없기 때문이다. 형제 leaf(`schema-avro`, `schema-protobuf`, `cloudevents`)는 vendor 타입이 public 시그니처에 나오므로 `api`로 선언했고 build.gradle에 그 이유를 주석으로 적었다. `src/messaging/CLAUDE.md:40-43`의 게이트가 이 구분을 강제한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `jackson-databind`(implementation).
|
||||
|
||||
나가는 것: `messaging-spring-boot-starter`(registry `allowed_dependencies`에 포함).
|
||||
|
||||
**실제 배선 지점이 하나 있다** — 이 플랫폼에서 유일하게 조립되는 codec이다.
|
||||
|
||||
```java
|
||||
// messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:360-366
|
||||
@ConditionalOnMissingBean(dev.caskeleton.messaging.schema.MessageCodecRegistry.class)
|
||||
public dev.caskeleton.messaging.runtime.RegisteredMessageCodecs messagingCodecs(
|
||||
ObjectProvider<MessageContracts> contracts) {
|
||||
return dev.caskeleton.messaging.runtime.RegisteredMessageCodecs.of(
|
||||
dev.caskeleton.messaging.schema.json.JacksonMessageCodec.of(
|
||||
contracts.getIfAvailable(MessageContracts::none).byKey()));
|
||||
}
|
||||
```
|
||||
|
||||
`RegisteredMessageCodecs.of(defaultCodec, codecs...)`의 varargs 자리가 비어 있다. 즉 **출하 구성의 codec registry에는 JSON 하나만 들어간다.** Avro·Protobuf·raw bytes는 등록되지 않는다.
|
||||
|
||||
두 번째 배선 지점은 상수 참조다.
|
||||
|
||||
```java
|
||||
// 같은 파일 :410-413
|
||||
new dev.caskeleton.messaging.policy.PayloadPolicy(
|
||||
JacksonMessageCodec.DEFAULT_MAX_BYTES,
|
||||
JacksonMessageCodec.DEFAULT_MAX_BYTES / 2),
|
||||
```
|
||||
|
||||
payload 정책의 상한이 **JSON codec의 상수에서 파생된다.** 포맷 중립이어야 할 admission 정책이 한 포맷의 클래스 상수를 참조한다 — §17에서 다룬다.
|
||||
|
||||
`contracts.getIfAvailable(MessageContracts::none)`이 기본값이므로, 애플리케이션이 `MessageContracts` bean을 내놓지 않으면 **빈 registry**로 codec이 만들어진다. 그 codec은 모든 `encode`/`decode`를 `UNKNOWN_MESSAGE_TYPE`으로 거절한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
클래스 하나, 공개 표면 6개.
|
||||
|
||||
| 멤버 | 종류 | 용도 |
|
||||
|---|---|---|
|
||||
| `DEFAULT_MAX_BYTES` = 1,048,576 | public 상수 | starter의 payload 정책이 참조 |
|
||||
| `MAX_NESTING_DEPTH` = 100 | public 상수 | 파서 깊이 상한 |
|
||||
| `of(Map)` | factory | 기본 1 MiB |
|
||||
| `of(Map, int)` | factory | 명시 상한 |
|
||||
| `testingDefault(MessageType, Class)` | factory | 단일 계약, v1 |
|
||||
| `testingDefault(MessageType, SchemaVersion, Class)` | factory | 단일 계약, 명시 버전 |
|
||||
|
||||
private 상수 둘: `MAX_STRING_CHARACTERS` = 5,000,000, `MAX_NUMBER_DIGITS` = 1,000.
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
### 4.1 파서 강화 — `strictMapper`
|
||||
|
||||
```java
|
||||
// JacksonMessageCodec.java:207-225
|
||||
JsonFactory factory =
|
||||
JsonFactory.builder()
|
||||
.streamReadConstraints(
|
||||
StreamReadConstraints.builder()
|
||||
.maxNestingDepth(MAX_NESTING_DEPTH) // 100
|
||||
.maxDocumentLength(maxBytes) // = codec 상한
|
||||
.maxNumberLength(MAX_NUMBER_DIGITS) // 1,000
|
||||
.maxStringLength(MAX_STRING_CHARACTERS) // 5,000,000
|
||||
.maxNameLength(MAX_STRING_CHARACTERS)
|
||||
.build())
|
||||
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
|
||||
.build();
|
||||
return JsonMapper.builder(factory)
|
||||
.enable(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY)
|
||||
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
|
||||
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.build();
|
||||
```
|
||||
|
||||
여섯 가지 방어가 한 곳에 있다.
|
||||
|
||||
| 설정 | 막는 것 |
|
||||
|---|---|
|
||||
| `maxNestingDepth(100)` | 중첩 폭탄으로 파서 스택 소진 |
|
||||
| `maxDocumentLength(maxBytes)` | 문서 길이 — codec 상한과 동일 |
|
||||
| `maxNumberLength(1000)` | 초대형 `BigDecimal` 파싱 비용 |
|
||||
| `maxStringLength`/`maxNameLength` | 단일 토큰 메모리 |
|
||||
| `STRICT_DUPLICATE_DETECTION` + `FAIL_ON_READING_DUP_TREE_KEY` | 중복 키 — 파서마다 "먼저/나중 승리"가 달라 파싱 차이 공격이 됨 |
|
||||
| `FAIL_ON_TRAILING_TOKENS` | 문서 뒤 추가 JSON — 두 번째 문서를 조용히 무시하는 것 |
|
||||
| `FAIL_ON_UNKNOWN_PROPERTIES` | 미등록 필드 |
|
||||
|
||||
그리고 **polymorphic default typing을 켜지 않는다.** javadoc이 그것이 대부분의 JSON gadget chain의 기반이라고 적는다.
|
||||
|
||||
`maxDocumentLength`가 `maxBytes`와 같다는 점이 중요하다 — 인코딩 상한과 디코딩 파서 상한이 하나의 값에서 나온다. 따로 두면 둘이 갈라진다.
|
||||
|
||||
### 4.2 인코딩 — 스트리밍 경계
|
||||
|
||||
```java
|
||||
BoundedByteSink sink = BoundedByteSink.of(maxBytes, "PAYLOAD_TOO_LARGE");
|
||||
try {
|
||||
mapper.writeValue(sink, payload);
|
||||
} catch (JacksonException exception) {
|
||||
throw unwrapTooLarge(exception);
|
||||
}
|
||||
```
|
||||
|
||||
주석이 이유를 적는다 — "Jackson writes incrementally, so a payload whose serialized form is far larger than the limit stops at the limit instead of after the whole graph has been rendered into a buffer nobody bounded."
|
||||
|
||||
`unwrapTooLarge`가 필요한 이유도 명시돼 있다.
|
||||
|
||||
```java
|
||||
// :190-196
|
||||
* <p>Jackson wraps stream failures, so the size refusal would otherwise reach the caller as
|
||||
* {@code JSON_ENCODE_FAILED} — indistinguishable from a payload the mapper genuinely could not
|
||||
* render, and the two need different operator responses.
|
||||
```
|
||||
|
||||
`for (Throwable cause = exception; cause != null; cause = cause.getCause())` — 원인 사슬을 끝까지 훑어 `MessageTooLargeException`을 찾는다. 못 찾으면 `MessageSerializationException("JSON_ENCODE_FAILED")`.
|
||||
|
||||
### 4.3 registry 조회 — 세 갈래 결과
|
||||
|
||||
```java
|
||||
private Class<?> requireRegistered(MessageType type, SchemaVersion version) {
|
||||
MessageContractKey key = new MessageContractKey(type, version);
|
||||
Class<?> registered = registry.get(key);
|
||||
if (registered != null) return registered;
|
||||
boolean typeIsKnown = registry.keySet().stream().anyMatch(known -> known.type().equals(type));
|
||||
if (typeIsKnown) {
|
||||
// Deliberately not falling back to another version's class: decoding v999 bytes with the v1
|
||||
// class is exactly the silent type confusion the version-keyed registry exists to stop.
|
||||
throw new MessageValidationException("SCHEMA_VERSION_NOT_REGISTERED", ...);
|
||||
}
|
||||
throw new MessageValidationException("UNKNOWN_MESSAGE_TYPE", ...);
|
||||
}
|
||||
```
|
||||
|
||||
`SCHEMA_VERSION_NOT_REGISTERED` 메시지에는 `registeredVersions(type)`가 정렬되어 포함된다. 테스트가 그 내용을 직접 단언한다 — `hasMessageContaining("order.created v999").hasMessageContaining("[1, 2]")`(`JsonContractRegistryTest.java:58-61`). 운영자가 "1과 2는 있고 999는 없다"를 에러 메시지만으로 알 수 있다.
|
||||
|
||||
### 4.4 인코딩·디코딩의 타입 검사 비대칭
|
||||
|
||||
| 방향 | 검사 |
|
||||
|---|---|
|
||||
| `encode` | `registered.isInstance(payload)` — **하위 타입 허용** |
|
||||
| `decode` | `registered.equals(payloadType)` — **정확 일치 요구** |
|
||||
|
||||
비대칭이 합리적이다. 인코딩에서 `OrderCreated`의 하위 타입을 넘기면 Jackson이 등록된 형태로 직렬화한다. 디코딩에서 하위 타입을 허용하면 등록된 계약과 다른 클래스로 역직렬화되므로 정확 일치여야 한다. 다만 이 비대칭은 주석으로 설명되지 않았다 — §15의 추론 항목이다.
|
||||
|
||||
### 4.5 디코딩의 이중 상한
|
||||
|
||||
```java
|
||||
if (encoded.length > maxBytes) { throw new MessageTooLargeException("PAYLOAD_TOO_LARGE", ...); }
|
||||
...
|
||||
return mapper.readValue(encoded, payloadType);
|
||||
```
|
||||
|
||||
명시 검사 하나(`encoded.length`)와 파서 내부 검사 하나(`maxDocumentLength`)가 겹친다. 중복이지만 둘의 실패 형태가 다르다 — 전자는 `MessageTooLargeException`, 후자는 `JacksonException` → `MessageSerializationException`. 명시 검사가 있어야 크기 초과가 크기 초과로 보고된다.
|
||||
|
||||
### 4.6 `EncodedMessage`에 붙는 schema reference
|
||||
|
||||
```java
|
||||
return new EncodedMessage(
|
||||
sink.toByteArray(), ContentType.JSON, Optional.of(SchemaReference.of(type.value(), version)));
|
||||
```
|
||||
|
||||
subject가 message type 값이고 URI는 없다. 즉 이 codec은 외부 schema registry를 쓰지 않고 "타입 이름 + 버전"을 스키마 신원으로 삼는다. 테스트가 확인한다(`JacksonMessageCodecTest.encodedMessageCarriesTheSchemaReference`).
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
**encode:** `requireRegistered(type, version)` → payload가 등록 타입의 인스턴스인지 → `BoundedByteSink` 생성 → `mapper.writeValue(sink, payload)` → 실패 시 `unwrapTooLarge` → `EncodedMessage(bytes, JSON, SchemaReference)`
|
||||
|
||||
**decode:** `requireRegistered(type, version)` → 요청 클래스가 등록 클래스와 정확히 같은지 → `encoded.length` 상한 → `mapper.readValue` → `JacksonException`이면 `JSON_DECODE_FAILED`
|
||||
|
||||
---
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
| 코드 | 예외 | 조건 | retryable |
|
||||
|---|---|---|:---:|
|
||||
| `UNKNOWN_MESSAGE_TYPE` | `MessageValidationException` | 타입 자체 미등록 | false |
|
||||
| `SCHEMA_VERSION_NOT_REGISTERED` | `MessageValidationException` | 타입은 알고 버전 미등록 | false |
|
||||
| `PAYLOAD_TYPE_MISMATCH` | `MessageValidationException` | encode: 인스턴스 아님 / decode: 클래스 불일치 | false |
|
||||
| `PAYLOAD_TOO_LARGE` | `MessageTooLargeException` | 인코딩 중 한도 초과 또는 디코딩 입력 초과 | false |
|
||||
| `JSON_ENCODE_FAILED` | `MessageSerializationException` | 그 외 Jackson 인코딩 실패 | false |
|
||||
| `JSON_DECODE_FAILED` | `MessageSerializationException` | 파싱 실패(깊이·중복키·trailing·미지 필드 포함) | false |
|
||||
|
||||
전부 `retryable = false`다 — `PERMANENT_BUSINESS`와 `DESERIALIZATION` 카테고리다. 같은 바이트를 다시 디코딩해도 같은 결과이므로 일관적이다.
|
||||
|
||||
**진단 손실 하나.** 파서 강화가 잡는 여섯 가지(깊이, 중복 키, trailing token, 미지 필드, 문서 길이, 토큰 길이)가 전부 하나의 코드 `JSON_DECODE_FAILED`로 접힌다. 운영자는 "JSON 디코딩 실패"만 보고 원인 여섯 갈래를 구분할 수 없다. 원인 예외가 `cause`로 붙지만 `FailureDescriptor`는 `exceptionType`을 `Optional.empty()`로 둔다(`MessageSerializationException`의 3인자 생성자 경로). §17 참조.
|
||||
|
||||
---
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
트랜잭션 없음.
|
||||
|
||||
동시성: `JacksonMessageCodec`은 불변이다 — `registry`는 `Map.copyOf`, `maxBytes`는 int, `mapper`는 빌드 후 재구성되지 않는 Jackson `ObjectMapper`(스레드 안전). `BoundedByteSink`는 매 `encode`마다 새로 만들어지므로 공유되지 않는다.
|
||||
|
||||
`PlatformOverheadPerformanceTest.aRoundTripDoesNotAllocateAGrowingRetainedSet`이 codec이 메시지별 상태를 보유하지 않음을 간접 확인한다(메시지당 유지 메모리 64바이트 미만).
|
||||
|
||||
---
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
설정 파일 없음. 상수:
|
||||
|
||||
| 상수 | 값 | 가시성 |
|
||||
|---|---:|---|
|
||||
| `DEFAULT_MAX_BYTES` | 1,048,576 | public — starter가 참조 |
|
||||
| `MAX_NESTING_DEPTH` | 100 | public |
|
||||
| `MAX_STRING_CHARACTERS` | 5,000,000 | private |
|
||||
| `MAX_NUMBER_DIGITS` | 1,000 | private |
|
||||
|
||||
`maxBytes`는 생성자 인자로 재정의 가능하고 파서의 `maxDocumentLength`가 그 값을 따라간다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
없다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
레인: `./gradlew :messaging:messaging-schema-json:test`. **BUILD SUCCESSFUL, 18 tests, 0 skipped, 0 failures**.
|
||||
|
||||
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|
||||
|---|---:|---|---|
|
||||
| `JacksonMessageCodecTest` | 9 | round trip, 1 MiB 초과 거절, 미등록 타입, payload 타입 불일치, trailing token, 미지 필드, 중복 키, 깊이 200 거절, schema reference | 등록 registry가 실제 배포에서 채워지는지 |
|
||||
| `JsonContractRegistryTest` | 6 | 버전별 클래스 분리, v999 거절 + 등록 버전 목록 노출, 클래스/버전 짝 검사, 타입 미등록과 버전 미등록 구분, 20 MiB payload가 1,024 상한에서 멈춤, 정확히 상한인 payload 허용 | — |
|
||||
| `PlatformOverheadPerformanceTest` | 3 | 봉투 생성 < 20µs/건, JSON 인코딩 < 50µs/건, round trip 유지 메모리 < 64 B/건 | 실제 처리량. 의도적으로 브로커 없음 |
|
||||
|
||||
성능 테스트의 자기 규정이 명확하다.
|
||||
|
||||
```java
|
||||
// PlatformOverheadPerformanceTest.java:22-30
|
||||
* <p>This measures what the platform adds — identity, validation, encoding — and nothing else.
|
||||
* There is no broker in the loop, deliberately: broker throughput is a property of the deployment
|
||||
* and varies by an order of magnitude between a laptop and a cluster, so asserting on it produces a
|
||||
* test that fails for reasons nobody can act on.
|
||||
*
|
||||
* <p>The budgets are generous on purpose. The regression worth catching here is structural — an
|
||||
* accidental per-message reflection call, a defensive copy that became a deep copy, a validator
|
||||
* that started compiling a regex per invocation — and those cost orders of magnitude, not
|
||||
* percentages. A tight budget would instead catch a busy CI agent.
|
||||
```
|
||||
|
||||
이것은 성능 테스트가 무엇을 잡으려는지 명시한 드문 예다 — 퍼센트가 아니라 자릿수 회귀. 다만 `aRoundTripDoesNotAllocateAGrowingRetainedSet`이 `System.gc()`와 `totalMemory() - freeMemory()`에 의존하므로 JVM이 GC 힌트를 무시하면 잡음이 낀다. 64 B/건이라는 여유가 그것을 흡수한다.
|
||||
|
||||
`JsonContractRegistryTest`의 클래스 javadoc이 이 codec에서 만난 두 결함을 기록한다 — 타입만으로 키를 잡았던 것과, 완성된 배열에 크기 제한을 적용했던 것.
|
||||
|
||||
---
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
| 게이트 | 이 leaf에 대해 |
|
||||
|---|---|
|
||||
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-schema-api"]` |
|
||||
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
|
||||
| vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | Jackson이 public 시그니처에 없으므로 `implementation`이 맞음 — 형제 leaf와 반대 판정 |
|
||||
| ArchUnit | 전용 규칙 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 실제 사용 여부와 negative-space probes
|
||||
|
||||
원시 증거: `evidence/raw/272-schema-family-reachability.txt`.
|
||||
|
||||
### 12.1 Public surface reachability
|
||||
|
||||
`JacksonMessageCodec`의 leaf 밖 참조는 **1개 파일**이다 — `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java`.
|
||||
|
||||
이 하나가 messaging codec 전체에서 유일한 production 소비다. 형제 비교:
|
||||
|
||||
| codec | 소비자 | registry membership |
|
||||
|---|---|---|
|
||||
| `JacksonMessageCodec` | `MessagingCoreAutoConfiguration` | `["app-bootstrap"]` |
|
||||
| `AvroMessageCodec` | **없음** | `[]` |
|
||||
| `ProtobufMessageCodec` | **없음** | `[]` |
|
||||
| `RawBytesMessageCodec` | **없음** | (schema-api 소속, `["app-bootstrap"]`) |
|
||||
| `DefaultCloudEventMapper` | **없음** | `["app-bootstrap"]` |
|
||||
|
||||
Avro·Protobuf는 소비자 없음과 membership 없음이 **일치한다** — 정합적인 incubating 상태다. `RawBytesMessageCodec`과 CloudEvents는 어긋난다(각 leaf 문서 참조).
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
이 leaf에는 bean이 없다. 그러나 이 leaf가 조립되는 지점의 조건은 확인했다.
|
||||
|
||||
```java
|
||||
@ConditionalOnMissingBean(dev.caskeleton.messaging.schema.MessageCodecRegistry.class)
|
||||
```
|
||||
|
||||
즉 애플리케이션이 자기 `MessageCodecRegistry`를 내놓으면 JSON codec 조립이 통째로 대체된다. 그 경우 `PayloadPolicy`가 참조하는 `JacksonMessageCodec.DEFAULT_MAX_BYTES`는 **그대로 남는다** — 정책 상한만 JSON codec의 값을 유지한다. §17 참조.
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
JSON 인코딩/디코딩을 하는 다른 지점이 저장소에 여럿 있다(web adapter의 응답 직렬화, redis codec, fileserver 저널, mongo cursor 등). 그러나 그들은 **다른 책임**(HTTP 응답, 캐시 봉투, 로컬 저널)이고 messaging 계약을 구현하지 않는다. runtime eligibility가 겹치지 않으므로 중복 경쟁으로 분류하지 않는다.
|
||||
|
||||
같은 `messaging` family 안에서 `MessageCodec`을 구현하는 것은 넷이고(JSON·Avro·Protobuf·raw) content type이 서로 달라 `RegisteredMessageCodecs.register`가 충돌을 거절한다. 책임 분리가 명확하다.
|
||||
|
||||
**한 가지 실질 중복이 있다.** 1 MiB payload 상한이 messaging family의 production 코드 **다섯 곳**에서 독립적으로 선언된다.
|
||||
|
||||
| 위치 | 가시성 | 값 |
|
||||
|---|---|---:|
|
||||
| `messaging-policy/PayloadPolicy.DEFAULT_MAX_BYTES:17` | **public** | 1,048,576 |
|
||||
| `messaging-schema-api/RawBytesMessageCodec.DEFAULT_MAX_BYTES:21` | public | 1,048,576 |
|
||||
| `messaging-schema-json/JacksonMessageCodec.DEFAULT_MAX_BYTES:42` | public | 1,048,576 |
|
||||
| `messaging-schema-avro/AvroMessageCodec.DEFAULT_MAX_BYTES:46` | private | 1,048,576 |
|
||||
| `messaging-schema-protobuf/ProtobufMessageCodec.DEFAULT_MAX_BYTES:35` | private | 1,048,576 |
|
||||
|
||||
테스트에도 네 곳(`ClaimCheckRetentionValidatorTest:47`, `DestinationProfileValidatorTest:225`, `RabbitContractHarness:40`, `InMemoryMessagingHarness:31`)이 같은 리터럴을 갖는다.
|
||||
|
||||
`schema-api`의 `RawBytesMessageCodec` javadoc은 이 값을 "The default encoded byte limit **shared with** the Stable codecs"라고 부르는데, 실제로는 공유되지 않고 복사돼 있다. 그리고 **정책 쪽에 이미 주인이 있다** — `messaging-policy`의 `PayloadPolicy.DEFAULT_MAX_BYTES`가 public 상수로 존재한다. 그런데 starter는 그것을 쓰지 않고 `JacksonMessageCodec.DEFAULT_MAX_BYTES`를 참조한다(§2). 같은 값의 후보가 둘 있고 배선이 덜 적절한 쪽을 골랐다.
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
| 문서 주장 | 재측정 | 결과 |
|
||||
|---|---|---|
|
||||
| `RawBytesMessageCodec` javadoc: 1 MiB가 "Stable codec들과 공유되는" 기본 상한 | 네 codec에 각자 리터럴 존재, 공유 상수 없음 | **표현 drift** — 값은 일치, "shared"는 사실이 아님 |
|
||||
| `JacksonMessageCodec` javadoc: polymorphic default typing 미사용 | `strictMapper`에 `activateDefaultTyping` 호출 없음 | **일치** |
|
||||
| `docs/messaging/support-matrix.md`의 JSON Stable 등급 | 이 leaf가 유일하게 조립되는 codec인 것과 정합 | **일치** |
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
`JsonContractRegistryTest` 클래스 javadoc이 이 codec에서 만난 두 결함을 남겼다.
|
||||
|
||||
```java
|
||||
// JsonContractRegistryTest.java:20-24
|
||||
* <p>Two defects met in this codec. The registry was keyed on message type alone, so a message
|
||||
* labelled v999 was decoded with the v1 class and kept its v999 label — the compatibility gate and
|
||||
* the audit record then both described a contract that was never registered. And the size limit was
|
||||
* applied to the finished byte array, which reports an oversized payload rather than preventing
|
||||
* one.
|
||||
```
|
||||
|
||||
두 결함 다 `messaging-schema-api`가 소유하는 타입(`MessageContractKey`, `BoundedByteSink`)으로 고쳐졌다. 즉 **이 leaf에서 발견된 문제가 상위 leaf의 타입을 만들어냈다.**
|
||||
|
||||
`MessagingCoreAutoConfiguration:420-427`의 주석은 이 codec이 아니라 publisher 조립 결함(MSG-INT-003)을 기록하는데, 같은 configuration 안에 있으므로 조립 이력의 맥락으로 참조할 가치가 있다 — "no configuration produced one … the starter did not depend on that leaf."
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` §D, §E | codec별 소비자와 registry membership | 정적 검색 |
|
||||
| EVD-275 | command | `./gradlew :messaging:messaging-schema-json:test --rerun-tasks` | BUILD SUCCESSFUL, 18 / 0 / 0 | 브로커 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적**
|
||||
|
||||
- 기본 codec으로 안전한 이유 셋 — 클래스 javadoc
|
||||
- polymorphic default typing 금지 — 클래스 javadoc
|
||||
- 크기 초과를 로컬에서 잡아야 `NOT_TRANSMITTED`가 된다 — 클래스 javadoc
|
||||
- `unwrapTooLarge`가 필요한 이유 — 메서드 javadoc
|
||||
- 다른 버전 클래스로 폴백하지 않는 이유 — `requireRegistered` 주석
|
||||
- 성능 예산이 느슨한 이유 — `PlatformOverheadPerformanceTest` javadoc
|
||||
- 이 codec에서 만난 두 결함 — `JsonContractRegistryTest` javadoc
|
||||
|
||||
**추론**
|
||||
|
||||
- encode는 `isInstance`, decode는 `equals`로 비대칭인 이유 → **추론**. 방향별 안전성으로 설명되지만 주석이 없다.
|
||||
- 파서 실패 여섯 갈래가 한 코드로 접힌 것이 의도인지 → **미상**.
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- 226줄 전문의 계약과 파서 강화 설정 전수
|
||||
- 18개 테스트가 통과하고 무엇을 단언하는지
|
||||
- 이 codec이 유일하게 조립되는 codec이라는 것과 그 조립 코드의 정확한 형태
|
||||
- payload 정책 상한이 이 codec의 public 상수에서 파생된다는 것
|
||||
- 1 MiB 상한이 네 codec에 복사돼 있다는 것
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- 실제 배포에서 `MessageContracts` bean이 채워지는지. 채워지지 않으면 codec은 모든 메시지를 `UNKNOWN_MESSAGE_TYPE`으로 거절한다. 이 저장소에 `MessageContracts` production 구현이 있는지는 starter leaf가 소유한다.
|
||||
- Jackson 3의 `StreamReadConstraints`가 이 값들에서 실제로 어떻게 실패하는지 — 테스트는 깊이 200과 중복 키만 확인했고 `maxNumberLength`·`maxStringLength`는 검증하지 않았다.
|
||||
- 성능 예산이 실제 CI 하드웨어에서 얼마나 여유 있는지 — 이번 실행은 통과했으나 측정값을 남기지 않았다.
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P2 — 포맷 중립 payload 정책이, 자기 상수를 두고 JSON codec의 상수를 참조한다
|
||||
|
||||
- **사실.** `MessagingCoreAutoConfiguration:410-413`이 `new PayloadPolicy(JacksonMessageCodec.DEFAULT_MAX_BYTES, JacksonMessageCodec.DEFAULT_MAX_BYTES / 2)`를 만든다. 그런데 `PayloadPolicy` 자신이 같은 값의 public 상수 `PayloadPolicy.DEFAULT_MAX_BYTES`(`messaging-policy/PayloadPolicy.java:17`)를 갖고 있다.
|
||||
- **근거.** 두 라인, 그리고 `git grep -n '1_048_576' -- 'src/messaging/**/*.java'`의 production 5건.
|
||||
- **왜 문제인가.** `MessagingAdmissionController`는 목적지의 codec이 무엇이든 지나는 관문이다. 그 상한이 **한 포맷 클래스**의 상수에서 나오면 두 가지가 깨진다. (1) `@ConditionalOnMissingBean`이 허용하는 대로 애플리케이션이 자기 `MessageCodecRegistry`를 내놓아 JSON codec을 대체해도, 정책은 여전히 JSON codec의 값을 읽는다. (2) 다섯 곳의 리터럴 중 하나만 바뀌면 조용히 갈라지고, `RawBytesMessageCodec` javadoc이 이미 "shared with the Stable codecs"라고 사실과 다르게 부르고 있다. 정책 소유자가 이미 존재하는데 배선이 그것을 지나쳤다.
|
||||
- **확인 방법.** `git grep -n '1_048_576' -- 'src/messaging/**/*.java'` · `grep -n 'DEFAULT_MAX_BYTES' src/messaging/messaging-policy/src/main/java/dev/caskeleton/messaging/policy/PayloadPolicy.java`
|
||||
- **후보.** starter가 `PayloadPolicy.DEFAULT_MAX_BYTES`를 참조하게 바꾸고, 네 codec의 기본값도 그 상수(또는 설정 프로퍼티)에서 파생시킨다.
|
||||
- **다음 단계.** **CASE 후보.** 조립 지점이 한 줄이고 재현이 정적이며, "값은 맞는데 출처가 틀렸다"는 형태가 명확하다.
|
||||
|
||||
### P3 — 파서 방어 여섯 갈래가 하나의 실패 코드로 접힌다
|
||||
|
||||
- **사실.** 깊이 초과·중복 키·trailing token·미지 필드·문서 길이·토큰 길이가 전부 `JSON_DECODE_FAILED`가 된다.
|
||||
- **근거.** `decode`의 `catch (JacksonException)` 단일 분기(`JacksonMessageCodec.java:155-158`).
|
||||
- **왜 문제인가.** 여섯 중 셋(중복 키, trailing token, 깊이)은 **적대적 입력의 신호**이고 나머지는 계약 불일치다. DLQ에 쌓인 메시지를 보는 운영자가 그 둘을 구분할 수 없다. `FailureDescriptor.exceptionType`도 비어 있다.
|
||||
- **확인 방법.** `JacksonMessageCodecTest`의 네 케이스가 전부 같은 예외 타입을 기대하는 것으로 확인 가능.
|
||||
- **후보.** `JacksonException` 하위 타입별로 코드를 나누거나, 최소한 `exceptionType`에 원인 클래스 단순명을 채운다.
|
||||
- **다음 단계.** **REFERENCE 후보**(실패 코드는 운영자의 다음 행동이 갈리는 지점마다 나눈다).
|
||||
|
||||
### P3 — 빈 registry로 조립되면 모든 메시지가 거절된다
|
||||
|
||||
- **사실.** `contracts.getIfAvailable(MessageContracts::none)`이 기본값이므로 `MessageContracts` bean이 없으면 빈 registry로 codec이 만들어진다.
|
||||
- **근거.** `MessagingCoreAutoConfiguration:362-365`.
|
||||
- **왜 문제인가.** 그 codec은 시작에 성공하고 첫 publish에서 `UNKNOWN_MESSAGE_TYPE`으로 실패한다. `messaging-core-api` 계열의 다른 leaf에서 관측된 것과 같은 형태다 — "시작은 하고 첫 쓰기에서 실패한다."
|
||||
- **확인 방법.** `MessageContracts` production 구현의 존재 여부를 starter leaf에서 확인해야 한다.
|
||||
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 이 leaf 밖(`messaging-spring-boot-starter`)의 사실에 걸린다. 그 leaf SSOT가 답을 갖는다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- 파서 상한 여섯 가지와 polymorphic typing 금지
|
||||
- `maxDocumentLength`가 codec 상한과 같은 값에서 나오는 것
|
||||
- `unwrapTooLarge`가 원인 사슬을 훑어 크기 실패를 크기 실패로 보고하는 것
|
||||
- 미등록 버전 에러가 등록된 버전 목록을 포함하는 것
|
||||
- Jackson을 `implementation`으로 선언한 것(형제 leaf와 반대이고, 그것이 맞다)
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MSJ-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps, memberships `["app-bootstrap"]` | 선언 |
|
||||
| MSJ-002 | build | `messaging-schema-json/build.gradle` | same | Jackson이 `implementation` | — |
|
||||
| MSJ-003 | code | `.../json/JacksonMessageCodec.java` 전문 | same | §4 전체 | — |
|
||||
| MSJ-004 | test | `JacksonMessageCodecTest` (9) | same | 파서 방어와 registry 거절 | 브로커 없음 |
|
||||
| MSJ-005 | test | `JsonContractRegistryTest` (6) | same | 버전 키 동작, 20 MiB가 1 KiB 상한에서 멈춤 | — |
|
||||
| MSJ-006 | test | `PlatformOverheadPerformanceTest` (3) | same | 구조적 회귀 예산 | 처리량 아님. `System.gc()` 의존 |
|
||||
| MSJ-007 | assembly | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java:358-366, 408-417` | same | 유일한 codec 조립 지점, varargs 비어 있음, payload 정책의 상수 출처 | 해당 leaf SSOT가 소유 |
|
||||
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | codec별 소비자와 membership | 정적 검색 |
|
||||
| EVD-275 | command | `./gradlew :messaging:messaging-schema-json:test --rerun-tasks` | same | 18 / 0 / 0 | — |
|
||||
@@ -0,0 +1,589 @@
|
||||
# messaging-schema-protobuf 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-schema-protobuf`
|
||||
> SSOT owner: `messaging-schema-protobuf`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- registered leaf id: `messaging-schema-protobuf`
|
||||
- canonical state `analysisFile`: `analysis/messaging/messaging-schema-protobuf.md`
|
||||
- source path: `src/messaging/messaging-schema-protobuf`
|
||||
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api"]`
|
||||
- registry `runtime_memberships`: **`[]`** — build-only / incubating
|
||||
|
||||
### 숫자
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | 2 |
|
||||
| production LOC | 199 |
|
||||
| 패키지 | 1 (`dev.caskeleton.messaging.schema.protobuf`) |
|
||||
| test 파일 | 1 |
|
||||
| test 메서드(실행 확인) | 12 |
|
||||
| test 리소스 | `src/test/proto/order_created_v1.proto` (**컴파일되지 않음**) |
|
||||
| 외부 의존성 | 1 (`com.google.protobuf:protobuf-java:4.29.3`, **`api`**) |
|
||||
|
||||
두 타입: `ProtobufMessageCodec`(codec), `ProtobufMessageContract`(record — 클래스와 parser의 검증된 짝).
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `.../protobuf/ProtobufMessageCodec.java` | 1 | `FULL_READ` | 146줄 전문 |
|
||||
| `.../protobuf/ProtobufMessageContract.java` | 1 | `FULL_READ` | 53줄 전문 |
|
||||
| `src/test/java/**` | 1 | `FULL_READ` | 255줄 전문 |
|
||||
| `src/test/proto/order_created_v1.proto` | 1 | `FULL_READ` | 23줄 전문. 어느 빌드도 컴파일하지 않음(§12.4) |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 주석 포함 11줄 |
|
||||
| `gradle.lockfile` | 1 | `FULL_READ` | protobuf 좌표 2건 확인 |
|
||||
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
선택적 Protobuf codec. `runtime_memberships: []`이고 starter의 codec registry에도 등록되지 않는다 — build-only / incubating.
|
||||
|
||||
protobuf를 `api`로 선언한 이유가 build.gradle 주석에 있다.
|
||||
|
||||
```groovy
|
||||
// api: ProtobufMessageContract is a public record over com.google.protobuf.Message and
|
||||
// Parser, and registering a contract is the first thing a consumer of this codec does.
|
||||
api 'com.google.protobuf:protobuf-java:4.29.3'
|
||||
```
|
||||
|
||||
`src/messaging/CLAUDE.md:40-43`의 vendor `api` 게이트를 통과한다 — `ProtobufMessageContract(Class<? extends Message>, Parser<? extends Message>)`가 public record이므로 소비자가 그 타입을 이름 부르지 않고는 계약을 등록할 수 없다.
|
||||
|
||||
**이 leaf의 핵심 문제 인식**은 클래스 javadoc이 한 문장으로 적는다.
|
||||
|
||||
```java
|
||||
// ProtobufMessageCodec.java:25-27
|
||||
* <p>Bound to a closed registry of generated parsers. Protobuf's own wire format will happily
|
||||
* decode almost any bytes into almost any message, so without the registry a type confusion is
|
||||
* silent — the consumer gets a populated object built from the wrong schema rather than an error.
|
||||
```
|
||||
|
||||
`messaging-schema-avro`의 "does not fail — it produces plausible garbage"와 같은 성질이다. **JSON은 틀린 스키마로 디코딩하면 대개 실패하고, Avro와 Protobuf는 실패하지 않는다.** 그래서 두 leaf 모두 registry를 계약의 중심에 둔다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `protobuf-java:4.29.3`(api).
|
||||
|
||||
나가는 것: **없다.** 어떤 leaf의 `allowed_dependencies`에도 없고 starter 목록에도 없다.
|
||||
|
||||
런타임 배선: 없음. bean 없음(Spring 주석 0개).
|
||||
|
||||
lockfile이 확인하는 실제 해석:
|
||||
|
||||
```
|
||||
com.google.protobuf:protobuf-java:4.29.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
```
|
||||
|
||||
컴파일/런타임은 4.29.3, annotation processor 경로만 4.33.2다. §12.4에서 저장소 전체의 protobuf 버전 지형을 다룬다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
```
|
||||
ProtobufMessageContract (record)
|
||||
├── payloadType : Class<? extends Message>
|
||||
├── parser : Parser<? extends Message>
|
||||
└── compact 생성자가 빈 입력을 파싱해 짝을 증명
|
||||
|
||||
ProtobufMessageCodec (MessageCodec 구현)
|
||||
├── encode(type, version, Message) → requireFits + writeTo(sink)
|
||||
├── decode(type, version, byte[], Class) → parser.parseFrom
|
||||
├── requireRegistered(type, version) → 2단 에러
|
||||
└── registeredVersions(type) → 에러 메시지용 정렬 목록
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
### 4.1 `ProtobufMessageContract`: 생성 시점에 짝을 증명한다
|
||||
|
||||
이 leaf에서 가장 밀도 높은 결정이다.
|
||||
|
||||
```java
|
||||
// ProtobufMessageContract.java:10-20
|
||||
* <p>They used to live in two parallel maps. Nothing checked that the two agreed, so a registry
|
||||
* that paired {@code OrderCreated.class} with {@code OrderCancelled}'s parser was accepted at
|
||||
* construction and produced a {@code ClassCastException} at decode time — on a broker thread, for
|
||||
* one message type, in production. Worse, a type present in one map and absent from the other made
|
||||
* {@code parsers.get(type)} return null and the decode fail with a {@code NullPointerException}
|
||||
* rather than the registry error the operator needed to read.
|
||||
*
|
||||
* <p>Binding them in one value makes the mismatch impossible to express, and the constructor proves
|
||||
* the pairing by parsing empty input: the parser's default instance must be an instance of the
|
||||
* declared class.
|
||||
```
|
||||
|
||||
증명 방법이 영리하다.
|
||||
|
||||
```java
|
||||
public ProtobufMessageContract {
|
||||
Message defaultInstance;
|
||||
try {
|
||||
defaultInstance = parser.parseFrom(new byte[0]);
|
||||
} catch (Exception failure) {
|
||||
throw new MessagingConfigurationException("PROTOBUF_CONTRACT_UNUSABLE", ..., failure);
|
||||
}
|
||||
if (!payloadType.isInstance(defaultInstance)) {
|
||||
throw new MessagingConfigurationException("PROTOBUF_CONTRACT_MISMATCH", ...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
proto3에서 모든 필드가 wire상 optional이므로 **빈 바이트는 항상 유효한 메시지**다. 그것을 파싱하면 default instance가 나오고 그 클래스가 곧 parser의 산출 타입이다. 별도 리플렉션 없이 짝을 확인한다.
|
||||
|
||||
에러 메시지가 실패 지점을 명시한다 — "a mismatched pairing fails at decode time on a broker thread, not here". 즉 **여기서 실패하는 것이 목적**임을 메시지가 스스로 말한다.
|
||||
|
||||
두 코드가 다르다: `PROTOBUF_CONTRACT_UNUSABLE`(파싱 자체 실패)과 `PROTOBUF_CONTRACT_MISMATCH`(파싱은 되는데 타입이 다름). 카테고리는 둘 다 `CONFIGURATION`이다.
|
||||
|
||||
테스트가 이 성질을 붙든다 — `aParserThatDoesNotProduceTheDeclaredClassIsRejectedAtConstruction`, `as("the mismatch used to surface as a ClassCastException on a broker thread")`.
|
||||
|
||||
### 4.2 인코딩: 크기를 미리 알 수 있다
|
||||
|
||||
```java
|
||||
BoundedByteSink sink = BoundedByteSink.of(maxBytes, "PAYLOAD_TOO_LARGE");
|
||||
sink.requireFits(message.getSerializedSize());
|
||||
try {
|
||||
message.writeTo(sink);
|
||||
}
|
||||
```
|
||||
|
||||
주석이 이유를 적는다.
|
||||
|
||||
```java
|
||||
// :77-79
|
||||
// Protobuf knows its serialized size exactly before writing a byte, so the limit is checked
|
||||
// against that estimate first and enforced again by the sink. `toByteArray` allocated the whole
|
||||
// encoding before anything could object.
|
||||
```
|
||||
|
||||
**세 codec 중 유일하게 사전 거절이 가능한 포맷이다.** `BoundedByteSink.requireFits`가 이 leaf를 위해 존재하고, schema-api의 javadoc이 그것을 명시한다 — "Protobuf knows its serialized size exactly, so the whole encode can be refused before the first byte is written."
|
||||
|
||||
그리고 사전 검사가 사후 경계를 대체하지 않는다 — `writeTo(sink)`가 여전히 sink를 통과하므로 이중 방어다. schema-api javadoc: "this is a cheaper refusal, not a replacement for the bound."
|
||||
|
||||
### 4.3 인코딩 타입 검사: 이중 조건
|
||||
|
||||
```java
|
||||
if (!(payload instanceof Message message) || !contract.payloadType().isInstance(payload)) {
|
||||
throw new MessageValidationException("PAYLOAD_TYPE_MISMATCH", ...);
|
||||
}
|
||||
```
|
||||
|
||||
`Message`인지와 등록된 클래스의 인스턴스인지를 함께 본다. 후자만으로 충분해 보이지만 전자가 `writeTo`를 부를 수 있음을 보장한다.
|
||||
|
||||
### 4.4 디코딩: 정확 일치와 상한
|
||||
|
||||
```java
|
||||
if (!contract.payloadType().equals(payloadType)) { throw ... PAYLOAD_TYPE_MISMATCH ... }
|
||||
if (encoded.length > maxBytes) { throw ... PAYLOAD_TOO_LARGE ... }
|
||||
return payloadType.cast(contract.parser().parseFrom(encoded));
|
||||
```
|
||||
|
||||
JSON codec과 같은 비대칭이다 — encode는 `isInstance`(하위 타입 허용), decode는 `equals`(정확 일치).
|
||||
|
||||
### 4.5 `requireRegistered`: 2단 에러, JSON과 같은 어휘
|
||||
|
||||
```java
|
||||
// :123-127
|
||||
if (typeIsKnown) {
|
||||
// Protobuf will happily decode almost any bytes with almost any parser, so falling back to
|
||||
// another version's parser does not fail — it returns a populated object built from a schema
|
||||
// nobody registered for this version.
|
||||
throw new MessageValidationException("SCHEMA_VERSION_NOT_REGISTERED", ...);
|
||||
}
|
||||
throw new MessageValidationException("UNKNOWN_MESSAGE_TYPE", ...);
|
||||
```
|
||||
|
||||
코드 문자열이 `JacksonMessageCodec`과 동일하다(`SCHEMA_VERSION_NOT_REGISTERED`, `UNKNOWN_MESSAGE_TYPE`). `AvroMessageCodec`만 `AVRO_` 접두사를 붙여 어휘가 갈라진다 — `analysis/messaging/messaging-schema-avro.md` §12.3(d)가 소유한다.
|
||||
|
||||
에러 메시지에 `registeredVersions(type)`가 정렬되어 포함되는 것도 JSON과 같다.
|
||||
|
||||
### 4.6 unknown field 보존
|
||||
|
||||
```java
|
||||
// :29-31
|
||||
* <p>Unknown fields are preserved by the generated types, which is what makes forward compatibility
|
||||
* work: an old consumer round-tripping a message written by a newer producer does not silently drop
|
||||
* the fields it does not understand.
|
||||
```
|
||||
|
||||
이것은 이 codec이 하는 일이 아니라 **protobuf-java 생성 타입의 성질**이다. 테스트가 그 성질을 직접 확인한다 — `aNewWriterIsStillReadableByAnOldReader`가 `asV1.getUnknownFields().hasField(5)`를 단언하고 `as("the unrecognised field is retained, not dropped, so a round trip does not lose it")`라고 적는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
**계약 등록:** `new ProtobufMessageContract(class, parser)` → 빈 입력 파싱 → 클래스 일치 확인 → 실패 시 `MessagingConfigurationException`
|
||||
|
||||
**encode:** `requireRegistered` → `Message`이고 등록 클래스인지 → `requireFits(getSerializedSize())` → `writeTo(sink)` → `EncodedMessage(bytes, PROTOBUF, SchemaReference)`
|
||||
|
||||
**decode:** `requireRegistered` → 요청 클래스 정확 일치 → `encoded.length` 상한 → `parser.parseFrom`
|
||||
|
||||
---
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
| 코드 | 예외 | 카테고리 | 조건 |
|
||||
|---|---|---|---|
|
||||
| `PROTOBUF_CONTRACT_UNUSABLE` | `MessagingConfigurationException` | `CONFIGURATION` | parser가 빈 입력을 파싱하지 못함 |
|
||||
| `PROTOBUF_CONTRACT_MISMATCH` | `MessagingConfigurationException` | `CONFIGURATION` | parser 산출 클래스 ≠ 선언 클래스 |
|
||||
| `UNKNOWN_MESSAGE_TYPE` | `MessageValidationException` | `PERMANENT_BUSINESS` | 타입 미등록 |
|
||||
| `SCHEMA_VERSION_NOT_REGISTERED` | `MessageValidationException` | `PERMANENT_BUSINESS` | 버전 미등록 |
|
||||
| `PAYLOAD_TYPE_MISMATCH` | `MessageValidationException` | `PERMANENT_BUSINESS` | 타입 불일치(양방향) |
|
||||
| `PAYLOAD_TOO_LARGE` | `MessageTooLargeException` | `PERMANENT_BUSINESS` | 크기 초과 |
|
||||
| `PROTOBUF_ENCODE_FAILED` | `MessageSerializationException` | `DESERIALIZATION` | `IOException` |
|
||||
| `PROTOBUF_DECODE_FAILED` | `MessageSerializationException` | `DESERIALIZATION` | `InvalidProtocolBufferException` |
|
||||
|
||||
**Avro와 다른 점 하나.** Avro는 `catch (IOException | RuntimeException)` 안에서 `MessageTooLargeException`을 `instanceof`로 통과시킨다. Protobuf는 `catch (IOException failure)`만 잡으므로 sink가 던지는 `MessageTooLargeException`(`RuntimeException`)이 그대로 전파된다. 별도 통과 로직이 필요 없다 — protobuf-java가 예외를 감싸지 않기 때문이다. 세 codec이 같은 문제를 세 가지로 푸는데(JSON은 원인 사슬 탐색, Avro는 즉시 `instanceof`, Protobuf는 아무것도 안 함) 각각 라이브러리 동작에 맞는 최소 해법이다. 다만 그 이유가 코드에 적혀 있지 않다.
|
||||
|
||||
**계약 위반은 `CONFIGURATION`이고 메시지 실패가 아니다.** `ProtobufMessageContract` 생성 실패는 registry를 조립하는 시점, 즉 시작 시점에 난다. `MessagingConfigurationException` javadoc이 그 의도를 적는다 — "Raised at startup wherever possible."
|
||||
|
||||
---
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
트랜잭션 없음.
|
||||
|
||||
`ProtobufMessageCodec`은 불변이다 — `contracts`는 `Map.copyOf`, `maxBytes`는 int. `ProtobufMessageContract`는 record이고 `Class`/`Parser` 둘 다 protobuf-java에서 스레드 안전하다.
|
||||
|
||||
`BoundedByteSink`는 매 encode마다 새로 만들어진다.
|
||||
|
||||
`Map.copyOf`가 여기서는 **얕은 복사 문제가 없다** — `Map<MessageContractKey, ProtobufMessageContract>`가 이미 평탄한 한 레벨이다. `AvroMessageCodec`이 중첩 맵을 받아 `flatten`이 필요했던 것과 대비된다(§`messaging-schema-avro` §4.2). 두 codec이 같은 registry 개념을 다른 형태로 받았고, 평탄한 쪽이 결함을 만들지 않았다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
설정 없음.
|
||||
|
||||
| 상수 | 값 | 가시성 |
|
||||
|---|---:|---|
|
||||
| `ProtobufMessageCodec.DEFAULT_MAX_BYTES` | 1,048,576 | **private** |
|
||||
|
||||
protobuf-java 버전은 `4.29.3`으로 build.gradle에 직접 고정돼 있다 — §12.4.
|
||||
|
||||
---
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
없다. 외부 schema registry를 쓰지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
레인: `./gradlew :messaging:messaging-schema-protobuf:test`. **BUILD SUCCESSFUL, 12 tests, 0 skipped, 0 failures**.
|
||||
|
||||
테스트 하나가 모든 것을 덮는다: `ProtobufCompatibilityTest`.
|
||||
|
||||
| 테스트 | 증명하는 것 |
|
||||
|---|---|
|
||||
| `aRoundTripPreservesEveryField` | 인코딩/디코딩 왕복, content type |
|
||||
| `renamingAFieldKeepsItsValueBecauseTheTagNumberIsTheContract` | 태그 4의 이름을 `currency`→`currency_code`로 바꿔도 값 보존 |
|
||||
| `anAddedFieldDecodesAsItsDefaultForAnOldWriter` | v1이 쓴 바이트를 v2로 읽으면 새 필드가 기본값 `""` |
|
||||
| `aNewWriterIsStillReadableByAnOldReader` | v2가 쓴 것을 v1로 읽어도 태그 4 보존, 태그 5는 unknown field로 유지 |
|
||||
| `reusingATagNumberCorruptsTheReadWhichIsWhyTagsAreNeverRecycled` | 태그 4를 string→int64로 재사용하면 값이 `0L`로 소실 |
|
||||
| `anUnregisteredTypeIsRejectedRatherThanGuessed` | 타입 미등록 거절 |
|
||||
| `anUnregisteredVersionIsRejectedRatherThanDecodedWithAnotherVersionsParser` | v2 요청이 v1 parser로 폴백하지 않음, 메시지에 `order.created v2` 포함 |
|
||||
| `aParserThatDoesNotProduceTheDeclaredClassIsRejectedAtConstruction` | 짝 검증 |
|
||||
| `anOversizedPayloadIsRefusedBeforeItIsSerialized` | 16바이트 상한에서 `refused at byte` |
|
||||
| `aPayloadAtExactlyTheLimitIsAccepted` | 정확히 상한인 payload 허용 |
|
||||
| `aLengthPrefixNoPayloadOfThisSizeCouldHonourIsADecodeFailure` | 4억 바이트를 주장하는 6바이트 메시지가 할당이 아니라 디코딩 실패로 끝남 |
|
||||
| `theEncodedMessageCarriesItsSchemaReference` | schema reference의 버전 |
|
||||
|
||||
**테스트 설계의 핵심 결정**이 클래스 javadoc에 있다.
|
||||
|
||||
```java
|
||||
// ProtobufCompatibilityTest.java:30-33
|
||||
* <p>Descriptors are built at runtime rather than generated by protoc. The properties under test —
|
||||
* that a reader keyed on tag numbers survives a rename, that an added field decodes as its default,
|
||||
* and that reusing a tag corrupts the read — are properties of the wire format, so proving them
|
||||
* without a code-generation step keeps the test honest and the build free of a protoc toolchain.
|
||||
```
|
||||
|
||||
`DescriptorProto`/`FileDescriptor`/`DynamicMessage`로 런타임에 스키마를 만든다. 그래서 이 leaf의 빌드에 protoc 툴체인이 없다.
|
||||
|
||||
**`aLengthPrefixNoPayloadOfThisSizeCouldHonourIsADecodeFailure`가 Avro와의 대비를 만든다.** 같은 형태의 공격(작은 바이트로 큰 길이를 주장)이 Avro에서는 `newArray` 오버라이드가 필요했고 Protobuf에서는 라이브러리가 알아서 막는다.
|
||||
|
||||
```java
|
||||
// 테스트 주석 :238-240
|
||||
// Tag 1, wire type 2 (length-delimited), then a varint claiming four hundred million bytes
|
||||
// follow. The whole message is six bytes, so it passes the size limit; what must not happen is
|
||||
// the parser reserving the claimed length before discovering there is nothing behind it.
|
||||
```
|
||||
|
||||
결과가 `MessageSerializationException`이다 — 즉 protobuf-java는 길이 주장을 신뢰해 미리 할당하지 않는다. Avro의 `GenericDatumReader.newArray`는 신뢰한다. **같은 공격에 두 라이브러리의 기본 방어가 다르고, 이 저장소는 그 차이를 각 leaf에서 다르게 처리했다.**
|
||||
|
||||
**증명 공백.** `ProtobufMessageCodec.decode`의 상한 검사(`encoded.length > maxBytes`)를 직접 겨냥한 테스트가 없다. 인코딩 상한은 두 테스트가 덮지만 디코딩 상한은 덮이지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
| 게이트 | 이 leaf에 대해 |
|
||||
|---|---|
|
||||
| `verifyCleanArchitectureDependencies` | `["messaging-core-api","messaging-schema-api"]` |
|
||||
| `verifyRuntimeModuleMembership` | `[]` |
|
||||
| vendor `api` 규칙(`src/messaging/CLAUDE.md:40-43`) | protobuf가 public record 시그니처에 등장 → `api` 필요. **통과** |
|
||||
| Gradle dependency locking | `gradle.lockfile`이 4.29.3/4.33.2를 고정 |
|
||||
| ArchUnit | 전용 규칙 없음 |
|
||||
| protoc 툴체인 | **없음** — 의도적(§10) |
|
||||
|
||||
---
|
||||
|
||||
## 12. 실제 사용 여부와 negative-space probes
|
||||
|
||||
원시 증거: `evidence/raw/272-schema-family-reachability.txt`.
|
||||
|
||||
### 12.1 Public surface reachability
|
||||
|
||||
| 타입 | leaf 밖 참조 | 판정 |
|
||||
|---|---:|---|
|
||||
| `ProtobufMessageCodec` | **0** | 소비자 없음 |
|
||||
| `ProtobufMessageContract` | **0** | 소비자 없음 |
|
||||
|
||||
`git grep -l -w ProtobufMessageCodec -- src ':!src/messaging/messaging-schema-protobuf'` exit 1.
|
||||
|
||||
**정합적이다.** `runtime_memberships: []`, starter 미등록, 소비자 0 — 세 축이 모두 "없음"이다. `messaging-schema-avro`와 같은 형태이고, 이것이 incubating leaf의 올바른 상태다.
|
||||
|
||||
**한계.** 이 저장소는 템플릿이므로 파생 프로젝트가 이 codec을 쓸 수 있다. 그것을 확인할 수단이 저장소 안에 없다. 다만 이 leaf는 그 경우를 위해 준비돼 있다 — vendor를 `api`로 노출했고, 계약 등록이 첫 단계임을 build.gradle 주석이 명시한다.
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
Spring 주석 0개. bean 없음.
|
||||
|
||||
codec sibling 비교는 `analysis/messaging/messaging-schema-avro.md` §12.2의 표가 소유한다. 이 leaf는 Avro와 같은 행(구현 o / starter 등록 x / membership `[]` / 정합)이다.
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
**(a) registry 조회 로직이 세 codec에 복제돼 있다**
|
||||
|
||||
`requireRegistered`(JSON), `schemaFor`(Avro), `requireRegistered`(Protobuf)가 같은 구조다.
|
||||
|
||||
```
|
||||
key = (type, version)
|
||||
if 등록됨 → 반환
|
||||
typeIsKnown = 키들 중 type이 같은 것이 있는가
|
||||
if typeIsKnown → "버전 미등록" + 등록 버전 목록
|
||||
else → "타입 미등록"
|
||||
```
|
||||
|
||||
JSON과 Protobuf는 `registeredVersions(type)` 헬퍼까지 사실상 동일하다(스트림 필터 → 버전 추출 → 정렬 → 리스트). Avro는 등록 버전 목록을 메시지에 넣지 않는다.
|
||||
|
||||
이 중복은 `messaging-schema-api`가 흡수할 수 있었다 — `MessageContractKey`가 이미 그 leaf에 있고, "타입은 알고 버전을 모른다"는 판단은 키의 성질이지 포맷의 성질이 아니다. `SchemaCompatibilityValidator`가 진화 규칙에 대해 정확히 그 일을 하려 했던 것과 같은 구조이고, 그쪽은 호출되지 않았다(`analysis/messaging/messaging-schema-api.md` §12.1).
|
||||
|
||||
**(b) 크기 예외 통과 방식이 세 codec에 셋**
|
||||
|
||||
| codec | 방식 | 필요한 이유 |
|
||||
|---|---|---|
|
||||
| JSON | `unwrapTooLarge` 원인 사슬 탐색 | Jackson이 스트림 예외를 감쌈 |
|
||||
| Avro | `catch` 안 즉시 `instanceof`(3곳) | Avro가 감싸지 않지만 `IOException`과 함께 잡힘 |
|
||||
| Protobuf | **없음** | `catch (IOException)`만 잡으므로 그대로 전파 |
|
||||
|
||||
셋 다 라이브러리 동작에 맞는 최소 해법이고 결과는 같다. 중복 경쟁이 아니라 **불가피한 분기**로 분류한다. 다만 세 코드 어디에도 "왜 우리는 다른가"가 적혀 있지 않아, 넷째 codec을 추가하는 사람이 어느 형태를 골라야 하는지 알 수 없다.
|
||||
|
||||
**(c) 1 MiB 상한** — `analysis/messaging/messaging-schema-json.md` §12.3이 소유한다. 이 leaf의 `DEFAULT_MAX_BYTES`는 private이므로 외부에 값을 노출하지 않는다.
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
**(a) `.proto` fixture를 컴파일하는 빌드가 없다**
|
||||
|
||||
`src/test/proto/order_created_v1.proto`가 존재하고 v1 계약을 서술한다.
|
||||
|
||||
```proto
|
||||
message OrderCreated {
|
||||
string order_id = 1;
|
||||
string customer_id = 2;
|
||||
int64 total_minor_units = 3;
|
||||
string currency = 4;
|
||||
// v2 adds `channel = 5`. ...
|
||||
}
|
||||
```
|
||||
|
||||
테스트는 이것을 읽지 않는다. `DescriptorProto`로 손수 만든 `V1_DESCRIPTOR`가 같은 네 필드를 같은 태그로 선언하고, `V2_DESCRIPTOR`가 태그 4를 `currency_code`로 개명하고 태그 5 `channel`을 추가한다.
|
||||
|
||||
**오늘은 둘이 일치한다.** 필드 이름·태그·타입을 전수 대조했고 `.proto`의 주석이 예고하는 v2 변경도 테스트의 `V2_DESCRIPTOR`와 맞는다. 그러나 일치를 강제하는 것이 아무것도 없다 — protoc 툴체인이 없고, 테스트가 파일을 읽지 않으며, 게이트도 없다. 테스트 javadoc이 `.proto`를 "the fixture documents"라고 부르는데, 문서와 테스트가 각자 진실을 갖고 있다.
|
||||
|
||||
이 판단은 신중해야 한다. protoc를 뺀 것은 명시적 설계 결정이고 그 이유(테스트를 정직하게, 빌드를 가볍게)가 적혀 있다. 문제는 protoc의 부재가 아니라 **`.proto`가 남아 있으면서 아무도 검증하지 않는다는 것**이다.
|
||||
|
||||
**(b) protobuf-java 버전이 저장소에 셋 있다**
|
||||
|
||||
| 위치 | 버전 | 성격 |
|
||||
|---|---|---|
|
||||
| `src/build.gradle:180` `ext.protobufVersion` | **3.25.5** | 주석이 "the single SSOT"라 부름 |
|
||||
| `messaging-schema-protobuf/build.gradle:9` | **4.29.3** | 이 leaf가 직접 고정 |
|
||||
| `adapter/inbound/websocket/build.gradle:44,46` | **4.33.2** | compileOnly / testImplementation |
|
||||
| 다수 lockfile의 `annotationProcessor` 경로 | 4.33.2 | 전이 |
|
||||
|
||||
`src/build.gradle:174-180`의 주석을 정확히 읽어야 한다.
|
||||
|
||||
```
|
||||
// Inbound gRPC adapter (adapter:inbound:grpc) — the Spring Boot BOM does NOT manage io.grpc:* or
|
||||
// protobuf versions, and this repo has no version catalog. Pin them here as the single SSOT so the
|
||||
// grpc module (and the future sample grpc feature) import io.grpc:grpc-bom + protobuf-bom as
|
||||
// platforms at MODULE scope (not the shared dependencyManagement block below) — keeping the
|
||||
// strict-locking blast radius to the grpc module alone.
|
||||
```
|
||||
|
||||
**"single SSOT"의 범위가 문장 안에서 grpc 모듈로 한정된다** — "keeping the strict-locking blast radius to the grpc module alone". 따라서 이 leaf가 4.29.3을 쓰는 것은 그 SSOT를 위반한 것이 아니다. 정확한 사실은 이렇다: **저장소에 protobuf 버전 정책이 전역으로 존재하지 않고, 세 곳이 독립적으로 고정한다.** 그리고 "single SSOT"라는 표현이 전역 정책의 존재를 시사하는 반면 실제 범위는 한 모듈이다.
|
||||
|
||||
오늘 이것이 사고가 아닌 이유: 이 leaf의 `runtime_memberships`가 `[]`이므로 4.29.3이 4.33.2·3.25.5와 같은 classpath에 오르지 않는다. **채택 시점의 부채이지 지금의 결함이 아니다.** 이 leaf를 런타임에 편입시키면 그때 버전 충돌 판정이 필요해진다.
|
||||
|
||||
**(c) 일치하는 주장들**
|
||||
|
||||
| 문서 주장 | 재측정 | 결과 |
|
||||
|---|---|---|
|
||||
| build.gradle 주석: protobuf가 public 시그니처에 등장하므로 `api` | `ProtobufMessageContract`가 public record over `Message`/`Parser` | **일치** |
|
||||
| 클래스 javadoc: unknown field가 보존됨 | 테스트가 `getUnknownFields().hasField(5)` 확인 | **일치** |
|
||||
| `support-matrix.md`: Protobuf가 Stable 아님 | membership `[]`, starter 미등록 | **일치** |
|
||||
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 실제로 `[]` | 이 leaf에 한해 참(family 전체로는 틀림) |
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
`ProtobufMessageContract` javadoc이 두 결함을 보존한다.
|
||||
|
||||
| 이전 상태 | 그것이 만든 실패 |
|
||||
|---|---|
|
||||
| 클래스와 parser를 **두 개의 병렬 맵**에 보관, 일치 검사 없음 | `OrderCreated.class`와 `OrderCancelled`의 parser 짝이 생성 시 통과 → 디코딩 시점의 `ClassCastException`, **브로커 스레드에서, 한 메시지 타입에 대해, production에서** |
|
||||
| 한쪽 맵에만 존재하는 타입 | `parsers.get(type)`이 null → `NullPointerException`. 운영자가 읽어야 할 registry 에러 대신 NPE |
|
||||
|
||||
두 번째가 특히 이 저장소의 반복 주제다 — **실패의 종류가 바뀌면 운영자가 읽을 정보가 사라진다.** `messaging-core-api`의 `FailureDescriptor` 설계, `MessageContractKey`의 2단 에러, JSON codec의 `unwrapTooLarge`가 전부 같은 관심사다.
|
||||
|
||||
`.proto` 파일의 주석도 설계 이유를 남긴다 — "Field numbers are the contract, not the field names ... Tags are never reused, and removed fields are reserved so that a later edit cannot take the number back." 이 규칙 셋 중 둘(개명 안전, 태그 재사용 위험)이 테스트로 증명되고 하나(reserved)는 증명되지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` §D, §E | 두 타입의 소비자 0, membership `[]` | 정적 검색 |
|
||||
| EVD-277 | command | `./gradlew :messaging:messaging-schema-protobuf:test --rerun-tasks` | BUILD SUCCESSFUL, 12 / 0 / 0 | protoc 없음. 런타임 descriptor |
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적**
|
||||
|
||||
- 닫힌 registry가 없으면 타입 혼동이 조용하다 — 클래스 javadoc
|
||||
- 두 병렬 맵이 만든 두 결함과 짝 증명 방식 — `ProtobufMessageContract` javadoc
|
||||
- 크기를 미리 알 수 있어 사전 거절한다 — encode 주석
|
||||
- 다른 버전 parser로 폴백하지 않는 이유 — `requireRegistered` 주석
|
||||
- unknown field 보존이 forward compatibility의 기반 — 클래스 javadoc
|
||||
- descriptor를 런타임에 만드는 이유(protoc 툴체인 회피) — 테스트 javadoc
|
||||
- 태그 번호가 계약인 이유 — `.proto` 주석
|
||||
- protobuf를 `api`로 선언한 이유 — build.gradle 주석
|
||||
- `ext.protobufVersion`의 범위가 grpc 모듈로 한정된 이유 — `src/build.gradle:174-178`
|
||||
|
||||
**추론**
|
||||
|
||||
- 크기 예외 통과 로직이 없는 것은 protobuf-java가 예외를 감싸지 않기 때문이다 → **추론**. 코드 형태는 관측, 인과는 추론.
|
||||
- 4.29.3을 고른 이유 → **미상**. 주석도 커밋 메시지도 없다.
|
||||
- `.proto`를 남겨 둔 이유 → **미상**. 문서용으로 보이지만 명시되지 않았다.
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- 두 타입 199줄 전문의 계약
|
||||
- 12개 테스트가 통과하고 무엇을 단언하는지
|
||||
- 소비자 0 / starter 미등록 / membership `[]`의 삼중 정합
|
||||
- `.proto` fixture와 테스트 descriptor가 오늘 일치한다는 것(전수 대조)과 그것을 강제하는 것이 없다는 것
|
||||
- 저장소에 protobuf 버전이 셋 있고 "single SSOT"의 범위가 한 모듈이라는 것
|
||||
- 길이 주장 공격에 대해 protobuf-java가 Avro와 달리 사전 할당하지 않는다는 것(테스트로 확인)
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- **디코딩 상한을 겨냥한 테스트가 없다.** `encoded.length > maxBytes` 분기가 실행된 적이 없다.
|
||||
- `.proto` 주석이 말하는 `reserved` 규칙 — 테스트가 없다.
|
||||
- 파생 프로젝트가 이 codec을 쓰는지.
|
||||
- 4.29.3과 3.25.5·4.33.2가 한 classpath에 올랐을 때 무슨 일이 생기는지. 오늘은 그 조합이 존재하지 않는다.
|
||||
- 실제 protoc 생성 타입(`GeneratedMessage` 서브클래스)에서 `ProtobufMessageContract`의 빈 입력 파싱 증명이 동작하는지 — 테스트는 `DynamicMessage`만 쓴다.
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P3 — `.proto` fixture와 테스트 descriptor의 일치를 아무도 강제하지 않는다
|
||||
|
||||
- **사실.** `src/test/proto/order_created_v1.proto`가 v1 계약을 서술하고, 테스트는 그 파일을 읽지 않고 `DescriptorProto`로 같은 스키마를 손수 만든다. 오늘 둘은 일치한다(필드 4개, 태그 1–4, 타입 전수 대조).
|
||||
- **근거.** `.proto` 전문 vs `ProtobufCompatibilityTest.java:41-66`.
|
||||
- **왜 문제인가.** protoc를 뺀 것은 명시적 설계 결정이고 이유가 적혀 있다. 문제는 `.proto`가 남아 있으면서 검증되지 않는다는 것이다. 테스트 javadoc이 그것을 "the fixture documents"라 부르므로, 읽는 사람은 그 파일이 테스트의 근거라고 믿는다. 한쪽만 수정되면 조용히 갈라진다.
|
||||
- **확인 방법.** 두 파일의 필드/태그/타입 대조. `find src/messaging/messaging-schema-protobuf -name '*.proto'`
|
||||
- **후보.** (a) `.proto`를 읽어 descriptor를 만드는 테스트 헬퍼를 쓴다(protoc 없이 `protobuf-java`의 파서로는 불가하므로 실제로는 어렵다). (b) `.proto`를 삭제하고 규칙 주석을 테스트로 옮긴다. (c) `.proto`에 "이 파일은 문서이며 테스트는 descriptor를 손수 만든다"를 명시한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(검증되지 않는 스키마 파일은 문서임을 파일 안에 적는다).
|
||||
|
||||
### P3 — 디코딩 상한 분기가 테스트되지 않는다
|
||||
|
||||
- **사실.** `decode`의 `if (encoded.length > maxBytes)` 분기를 겨냥한 테스트가 없다. 인코딩 상한은 두 테스트가 덮는다.
|
||||
- **근거.** `ProtobufMessageCodec.java:104-108`, `ProtobufCompatibilityTest` 12개 전수.
|
||||
- **왜 문제인가.** 디코딩은 **신뢰할 수 없는 입력**을 받는 쪽이다. 브로커에서 온 바이트에 대한 방어가 자기 코드가 만든 바이트에 대한 방어보다 덜 검증됐다. 형제 leaf는 반대다 — `AvroRegistryBoundsTest.theEvolutionDecodeAppliesTheSameBound`가 정확히 이 각도를 덮는다.
|
||||
- **확인 방법.** 12개 테스트 중 `decode`에 큰 입력을 주는 것이 없음.
|
||||
- **후보.** `maxBytes`보다 큰 `byte[]`로 `decode`를 부르는 테스트 추가.
|
||||
- **다음 단계.** **REFERENCE 후보**(신뢰할 수 없는 입력 쪽 경계를 먼저 테스트한다).
|
||||
|
||||
### P3 — protobuf-java 버전이 저장소에 셋이고 전역 정책이 없다
|
||||
|
||||
- **사실.** `ext.protobufVersion = 3.25.5`(grpc 모듈 범위로 한정), 이 leaf `4.29.3`, websocket `4.33.2`. lockfile들이 세 값을 모두 고정한다.
|
||||
- **근거.** `src/build.gradle:174-180` · `messaging-schema-protobuf/build.gradle:9` · `adapter/inbound/websocket/build.gradle:44,46` · 각 `gradle.lockfile`.
|
||||
- **왜 문제인가.** 오늘은 사고가 아니다 — 이 leaf의 `runtime_memberships`가 `[]`이라 세 버전이 한 classpath를 공유하지 않는다. **채택 시점의 부채다.** 이 leaf를 런타임에 편입시키는 순간 버전 판정이 필요해지고, 그때 참조할 전역 정책이 없다. 그리고 `src/build.gradle`의 "the single SSOT"라는 표현이 전역 정책의 존재를 시사하는데 실제 범위는 그 문장 안에서 grpc 모듈로 한정된다.
|
||||
- **확인 방법.** `git grep -n 'protobuf-java\|protobufVersion' -- src --include='*.gradle'`
|
||||
- **후보.** (a) 편입 전까지 현 상태 유지하되 `src/messaging/CLAUDE.md`에 "편입 시 버전 정합을 먼저 판정한다"를 적는다. (b) `ext.protobufVersion`의 범위를 넓히고 주석의 "single SSOT" 표현을 실제 범위에 맞춘다.
|
||||
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 "이 leaf를 런타임에 편입할 것인가"에 걸린다. 저장소 안에 답이 없다.
|
||||
|
||||
### P3 — registry 조회 로직이 세 codec에 복제돼 있다
|
||||
|
||||
- **사실.** `requireRegistered`(JSON/Protobuf)와 `schemaFor`(Avro)가 같은 3단 판단을 각자 구현한다. JSON과 Protobuf는 `registeredVersions` 헬퍼까지 사실상 동일하다.
|
||||
- **근거.** 세 codec의 해당 메서드.
|
||||
- **왜 문제인가.** 판단은 `MessageContractKey`의 성질이지 포맷의 성질이 아니다. 그리고 실제로 갈라졌다 — Avro만 `AVRO_` 접두 코드를 쓰고 등록 버전 목록을 메시지에 넣지 않는다. `messaging-schema-api`가 흡수할 수 있는 형태다.
|
||||
- **확인 방법.** 세 메서드 대조.
|
||||
- **후보.** `messaging-schema-api`에 `ContractLookup`류 헬퍼를 두고 세 codec이 부른다.
|
||||
- **다음 단계.** `messaging-schema-api` §17의 "포맷 독립 규칙" 항목과 같은 계열이다. 그 leaf가 소유하고 여기서는 교차 참조만 남긴다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- 클래스와 parser를 한 값에 묶고 빈 입력 파싱으로 짝을 증명하는 것
|
||||
- 직렬화 크기를 미리 알아 사전 거절하고, sink 경계를 여전히 통과시키는 이중 방어
|
||||
- 다른 버전 parser로 폴백하지 않고 등록 버전 목록을 에러에 넣는 것
|
||||
- descriptor를 런타임에 만들어 protoc 툴체인 없이 wire 성질을 증명하는 것
|
||||
- 소비자 0 / starter 미등록 / membership `[]`의 삼중 정합
|
||||
- 크기 예외 통과 로직이 없는 것(protobuf-java가 감싸지 않으므로 불필요)
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MSP-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps, `runtime_memberships: []` | 선언 |
|
||||
| MSP-002 | build | `messaging-schema-protobuf/build.gradle` | same | protobuf `api` 선언과 이유, 버전 4.29.3 | — |
|
||||
| MSP-003 | build | `messaging-schema-protobuf/gradle.lockfile:26-27` | same | 4.29.3(compile/runtime), 4.33.2(annotationProcessor) | 이 leaf 범위 |
|
||||
| MSP-004 | code | `.../protobuf/ProtobufMessageContract.java` 전문 | same | §4.1 짝 증명과 두 이전 결함 | `DynamicMessage`로만 검증됨 |
|
||||
| MSP-005 | code | `.../protobuf/ProtobufMessageCodec.java` 전문 | same | §4.2–4.6 | — |
|
||||
| MSP-006 | test | `ProtobufCompatibilityTest` (12) | same | §10 표 전부 | protoc 없음. decode 상한 미검증 |
|
||||
| MSP-007 | fixture | `src/test/proto/order_created_v1.proto` | same | 태그 규칙 서술 | 컴파일되지 않음(§12.4a) |
|
||||
| MSP-008 | build policy | `src/build.gradle:174-180` | same | `ext.protobufVersion = 3.25.5`와 그 범위가 grpc 모듈로 한정됨 | — |
|
||||
| MSP-009 | cross-leaf build | `adapter/inbound/websocket/build.gradle:44,46` | same | 세 번째 protobuf 버전 4.33.2 | 해당 leaf SSOT가 소유 |
|
||||
| MSP-010 | cross-leaf code | `messaging-schema-api/.../BoundedByteSink.java:66-80` | same | `requireFits`가 이 codec을 위해 존재 | 해당 leaf SSOT가 소유 |
|
||||
| EVD-272 | command | `evidence/raw/272-schema-family-reachability.txt` | same | §12.1 | 정적 검색 |
|
||||
| EVD-277 | command | `./gradlew :messaging:messaging-schema-protobuf:test --rerun-tasks` | same | 12 / 0 / 0 | — |
|
||||
@@ -0,0 +1,741 @@
|
||||
# messaging-security 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-security`
|
||||
> SSOT owner: `messaging-security`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- registered leaf id: `messaging-security`
|
||||
- canonical state `analysisFile`: `analysis/messaging/messaging-security.md`
|
||||
- source path: `src/messaging/messaging-security`
|
||||
- registry `allowed_dependencies`: `["messaging-core-api"]`
|
||||
- registry `runtime_memberships`: `["app-bootstrap"]`
|
||||
|
||||
### 숫자
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | 12 |
|
||||
| production LOC | 954 |
|
||||
| 패키지 | 1 (`dev.caskeleton.messaging.security`) |
|
||||
| test 파일 | 3 |
|
||||
| test 메서드(실행 확인) | 24 |
|
||||
| 외부(비프로젝트) 의존성 | **0** |
|
||||
|
||||
12개 타입을 세 축으로:
|
||||
|
||||
| 축 | 타입 | leaf 밖 소비 파일 |
|
||||
|---|---|---:|
|
||||
| **자격증명 수명주기** | `CredentialProvider` · `CredentialRuntime` · `CredentialRuntimeRegistry` · `CredentialRotationPlan` · `CredentialIds`(package-private) | 4 · 2 · 6 · **0** · 0 |
|
||||
| **연결 posture** | `BrokerSecurityProfile` · `BrokerCredentialProfile` · `BrokerTlsPolicy` · `MessageSecurityValidator` | 8 · 5 · 6 · 1 |
|
||||
| **권한** | `DestinationAccessPolicy` · `DestinationAccessValidator` · `BrokerAclManifest` | 7 · **0** · **0** |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `src/main/java/**` (12) | 12 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `src/test/java/**` (3) | 3 | `FULL_READ` | 테스트명·단언 전수 확인 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 5줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
이 leaf는 **"브로커에 연결하기 전에 무엇이 참이어야 하는가"**를 소유한다. 벤더 의존성이 0이고 브로커를 만지지 않는다 — 어댑터의 security configurer가 이 leaf의 타입을 받아 실제 클라이언트 설정을 만든다.
|
||||
|
||||
세 가지 원칙이 코드 전반에 반복된다.
|
||||
|
||||
**(a) 비밀은 참조로만 다룬다.**
|
||||
|
||||
```java
|
||||
// BrokerCredentialProfile.java:5-8
|
||||
* <p>No variant carries a secret. The platform stores an identifier and resolves the material
|
||||
* through a {@link CredentialProvider} at connect time, so a rotation is a provider concern and a
|
||||
* heap dump or configuration print never yields a usable credential.
|
||||
```
|
||||
|
||||
`BrokerCredentialProfile`의 다섯 변형 전부가 `credentialId` 하나만 갖는다 — `SaslScram`, `OAuth2`, `MutualTls`, `UsernamePassword`, `Nkey`. sealed interface이므로 여섯 번째를 만들려면 이 파일을 고쳐야 한다.
|
||||
|
||||
**(b) 타입이 통제의 일부다.**
|
||||
|
||||
```java
|
||||
// CredentialRuntime.java:13-15
|
||||
* <p>Holds the material in a {@code char[]} that {@link #clear()} overwrites. A {@code String}
|
||||
* cannot be erased — it stays in the constant pool and in every heap dump taken until the next GC
|
||||
* decides otherwise — so the type of the field is itself part of the control.
|
||||
```
|
||||
|
||||
`CredentialProvider.resolve`가 `char[]`을 반환하고 `CredentialRuntime`이 그것을 참조로 보관하며 `clear()`가 `Arrays.fill(material, '\0')` 후 빈 배열로 교체한다.
|
||||
|
||||
**(c) 역할 분리가 강제된다.** `BrokerSecurityProfile`이 producer·consumer·admin 세 자격증명을 **별도 필드**로 갖는다.
|
||||
|
||||
```java
|
||||
// BrokerSecurityProfile.java:9-11
|
||||
* <p>Producer, consumer, and admin credentials are separate fields rather than one connection
|
||||
* credential. That separation is what makes "an application cannot purge a topic" enforceable: the
|
||||
* runtime never holds admin material, so a compromised handler has nothing to escalate with.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
들어오는 것: `messaging-core-api`(api) 하나.
|
||||
|
||||
나가는 것: `messaging-runtime-core`, `messaging-kafka`, `messaging-rabbit`, `messaging-admin-runtime`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-boot-starter`.
|
||||
|
||||
**이 leaf는 messaging family에서 배선이 가장 잘 된 축에 속한다.** 어댑터 두 곳이 직접 소비한다.
|
||||
|
||||
| 소비자 | 무엇을 쓰는가 |
|
||||
|---|---|
|
||||
| `messaging-kafka/KafkaSecurityConfigurer` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry`, `CredentialProvider` |
|
||||
| `messaging-rabbit/RabbitSecurityConfigurer` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry` |
|
||||
| `messaging-runtime-core/DefaultMessagePublisher` | `DestinationAccessPolicy` |
|
||||
| `messaging-runtime-core/DeclaredDestinationAccess` | `DestinationAccessPolicy` |
|
||||
| starter `MessagingCoreAutoConfiguration` | `MessageSecurityValidator`·`BrokerTlsPolicy`·`CredentialRuntimeRegistry` bean |
|
||||
| starter `MessagingCredentialRequirementValidator` | `CredentialProvider` |
|
||||
| starter `Kafka/RabbitMessagingAutoConfiguration` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry` |
|
||||
|
||||
이 leaf 자체는 Spring 주석을 갖지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
```
|
||||
자격증명 수명주기
|
||||
CredentialProvider (port)
|
||||
↓ resolve(id) → char[] / expiresAt(id) → Optional<Instant>
|
||||
CredentialRuntimeRegistry ──compute(single-flight)──> CredentialRuntime
|
||||
│ ├── material() → clone
|
||||
│ ├── isDueForRotation(now)
|
||||
│ ├── isExpired(now)
|
||||
└── dueForRotation / expired / clearAll └── clear() → 덮어쓰기
|
||||
|
||||
CredentialRotationPlan ← 같은 술어를 다시 구현, 소비자 0 (§12.3)
|
||||
|
||||
연결 posture
|
||||
BrokerSecurityProfile ─┬─ BrokerCredentialProfile (sealed, 5변형) ── CredentialIds
|
||||
└─ DestinationAccessPolicy
|
||||
BrokerTlsPolicy.validate(profile, protocols) ← 어댑터가 호출
|
||||
MessageSecurityValidator.validate(profile) ← starter bean, 검사 범위가 겹침 (§12.3)
|
||||
|
||||
권한
|
||||
DestinationAccessPolicy (publishable / consumable / administrable)
|
||||
DestinationAccessValidator ← 소비자 0 (§12.1)
|
||||
BrokerAclManifest ← 소비자 0 (§12.1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
### 4.1 `CredentialRuntimeRegistry.resolve` — key별 single-flight
|
||||
|
||||
이 leaf에서 가장 조밀한 동시성 코드이고, 이전 결함이 주석에 통째로 남아 있다.
|
||||
|
||||
```java
|
||||
// :62-70
|
||||
// Single-flight, keyed by credential id.
|
||||
//
|
||||
// get → fetch → put → clear had no synchronization at all. Two callers rotating the same
|
||||
// credential both read the same old runtime and both fetched a replacement: one replacement
|
||||
// was lost from the map without ever being cleared — a secret left in memory that nothing owns
|
||||
// — and the caller that lost the race could clear material the winner was still using.
|
||||
//
|
||||
// compute holds the bin lock for this key, so exactly one fetch publishes and the previous
|
||||
// generation is retired by that same caller.
|
||||
```
|
||||
|
||||
**두 개의 서로 다른 결함이 한 경합에서 나왔다.**
|
||||
1. 진 쪽의 교체본이 맵에서 사라지고 `clear()`도 안 됨 → **소유자 없는 비밀이 힙에 남음**
|
||||
2. 진 쪽이 이긴 쪽이 쓰고 있는 material을 `clear()`할 수 있음 → **사용 중인 자격증명이 지워짐**
|
||||
|
||||
현재 구현:
|
||||
|
||||
```java
|
||||
CredentialRuntime current = resolved.get(credentialId);
|
||||
if (current != null && !current.isDueForRotation(now)) {
|
||||
return current; // 락 없는 빠른 경로
|
||||
}
|
||||
return resolved.compute(credentialId, (key, existing) -> {
|
||||
if (existing != null && !existing.isDueForRotation(now)) {
|
||||
return existing; // 대기 중 다른 스레드가 회전함
|
||||
}
|
||||
CredentialRuntime replacement = fetch(key, now);
|
||||
if (existing != null) {
|
||||
existing.clear(); // 설치 후에만, 그리고 교체한 스레드만
|
||||
}
|
||||
return replacement;
|
||||
});
|
||||
```
|
||||
|
||||
`ConcurrentHashMap.compute`가 해당 bin의 락을 잡으므로 fetch가 정확히 한 번 일어난다. 그리고 **`clear()`가 `replacement` 생성 후에 온다** — 주석이 그 순서의 이유를 적는다: "no reader sees a window with no usable credential — and only by the thread that replaced it, so the material a concurrent reader holds is never wiped underneath it."
|
||||
|
||||
**대가.** `compute`의 람다 안에서 `provider.resolve(...)`가 호출된다. 즉 **외부 I/O가 맵 bin 락을 잡은 채로 일어난다.** 같은 credential id를 요청하는 다른 스레드는 그 동안 막히고, `ConcurrentHashMap` 문서는 compute 람다 안에서 같은 맵을 갱신하지 말라고 요구한다(여기서는 지켜진다). 다른 키는 다른 bin이면 막히지 않지만 해시 충돌 시 같은 bin이면 막힌다. §17.
|
||||
|
||||
### 4.2 `CredentialRuntime` — material의 세 가지 통제
|
||||
|
||||
| 통제 | 구현 |
|
||||
|---|---|
|
||||
| 저장 | `char[]`, `String` 아님 |
|
||||
| 반환 | `material.clone()` — "A copy, so a caller that clears its own array cannot blind every other holder" |
|
||||
| 소거 | `Arrays.fill(material, '\0')` 후 `material = new char[0]` |
|
||||
| 소거 후 접근 | `IllegalStateException("credential X has already been cleared")` |
|
||||
| 표현 | `toString()`이 id와 expiry만 — material 없음 |
|
||||
|
||||
소거 판정이 `material.length == 0`이다. 생성자가 빈 배열을 거절하므로(`"credential material must not be empty"`) 길이 0은 소거된 상태를 뜻한다 — 별도 플래그 없이 같은 필드로 상태를 표현한다.
|
||||
|
||||
**`material` 필드가 `volatile`이 아니다.** `clear()`가 다른 스레드에서 호출되면 `material()`이 옛 참조를 볼 수 있다. 실제 경로에서는 `compute` 안에서만 `clear()`가 불리고 그 전에 `replacement`가 맵에 들어가므로 위험이 낮지만, `clearAll()`은 락 없이 순회한다. §17.
|
||||
|
||||
### 4.3 회전 시점 — 만료가 아니라 만료 이전
|
||||
|
||||
```java
|
||||
// CredentialRuntime.java:17-18
|
||||
* <p>Rotation is driven from the expiry, ahead of it. Waiting for the broker to start refusing
|
||||
* connections turns a scheduled, invisible rotation into an outage.
|
||||
```
|
||||
|
||||
`DEFAULT_ROTATION_LEAD = 30분`. `isDueForRotation(now)`가 `!now.isBefore(expiry.minus(rotationLead))`다 — 만료 30분 전부터 참이고 만료 후에도 참이다.
|
||||
|
||||
`expiresAt`이 비어 있으면 **둘 다 false**다 — 만료를 모르는 자격증명은 회전 대상도 만료 대상도 아니다. `orElse(false)`가 그 선택을 명시한다.
|
||||
|
||||
### 4.4 `BrokerTlsPolicy` — 허용목록과 두 단계 실패
|
||||
|
||||
```java
|
||||
// :15-17
|
||||
* <p>Disabling hostname verification is treated as a separate, worse failure than disabling TLS.
|
||||
* Plaintext is at least obviously insecure, whereas TLS without hostname verification looks
|
||||
* encrypted in every dashboard while accepting any certificate a man in the middle presents.
|
||||
```
|
||||
|
||||
네 가지 거절:
|
||||
|
||||
| 코드 | 조건 |
|
||||
|---|---|
|
||||
| `TLS_REQUIRED` | TLS 꺼짐 && (production \|\| 평문 비허용) |
|
||||
| `HOSTNAME_VERIFICATION_REQUIRED` | TLS 켜짐 && hostname 검증 꺼짐 |
|
||||
| `TLS_PROTOCOL_NOT_ACCEPTED` | 프로토콜이 `{TLSv1.2, TLSv1.3}` 밖 |
|
||||
| `TLS_PROTOCOL_UNSPECIFIED` | TLS 켜짐인데 프로토콜 목록이 비어 있음 |
|
||||
|
||||
**허용목록을 고른 이유가 적혀 있다.**
|
||||
|
||||
```java
|
||||
// :72-78
|
||||
// An allowlist, not a denylist.
|
||||
//
|
||||
// The denylist named the old versions somebody thought of, so `SSL`, `TLSv0.9`, `PLAINTEXT`
|
||||
// and any typo passed — and a protocol string the JVM does not recognise is negotiated as
|
||||
// whatever the JVM defaults to, which is the outcome this policy exists to prevent. Naming the
|
||||
// two acceptable versions means an unknown string fails here rather than at connect time on a
|
||||
// production broker.
|
||||
```
|
||||
|
||||
`messaging-schema-api`의 `SchemaCompatibilityValidator`가 허용목록이고 `AvroCompatibilityGate`가 거부목록인 것(그쪽 §12.3)과 같은 축의 판단이며, 여기서는 허용목록을 고른 이유가 명시돼 있다.
|
||||
|
||||
**네 번째 검사에 순서 문제가 있다.** `TLS_PROTOCOL_UNSPECIFIED`가 `TLS_PROTOCOL_NOT_ACCEPTED` **뒤에** 있는데, 빈 목록은 `filter`를 통과하는 요소가 없으므로 `unsupported`가 비어 있어 앞 검사를 지나간다. 결과적으로 빈 목록은 네 번째에서 잡힌다 — 동작은 맞다. 다만 읽는 순서와 논리 순서가 다르다.
|
||||
|
||||
### 4.5 `MessageSecurityValidator` — 시작 시 네 가지
|
||||
|
||||
```java
|
||||
// :9-12
|
||||
* <p>These checks are boot failures rather than warnings. An unencrypted production broker
|
||||
* connection or a shared producer/admin credential is not a degraded mode the platform can run in
|
||||
* safely; both are the kind of misconfiguration that stays invisible until it is exploited.
|
||||
```
|
||||
|
||||
| # | 거절 조건 |
|
||||
|---:|---|
|
||||
| 1 | production && TLS 꺼짐 |
|
||||
| 2 | production && hostname 검증 꺼짐 |
|
||||
| 3 | producer와 consumer가 같은 credential id |
|
||||
| 4 | admin이 producer/consumer와 같은 credential id |
|
||||
| 5 | production && admin 존재 |
|
||||
|
||||
3·4번을 `LinkedHashSet.add`의 반환값으로 구현한다 — 추가에 실패하면 중복이다. 간결하고 정확하다.
|
||||
|
||||
5번이 (c) 원칙을 강제하는 지점이다 — **운영 런타임은 admin 자격증명을 아예 갖지 못한다.**
|
||||
|
||||
1·2번이 `BrokerTlsPolicy`와 겹친다(§12.3).
|
||||
|
||||
### 4.6 `BrokerAclManifest` — 초과가 발견이다
|
||||
|
||||
```java
|
||||
// :113-118
|
||||
* <p>Excess is the finding, not the shortfall: a missing grant fails loudly on first use, while
|
||||
* an undeclared extra one sits unnoticed until it is abused.
|
||||
```
|
||||
|
||||
`undeclared(observed)`가 관측 − 선언, `missing(observed)`가 선언 − 관측이다. 두 방향을 모두 계산하지만 javadoc이 어느 쪽이 발견인지 정한다.
|
||||
|
||||
`Operation` enum이 파괴적 여부를 상수에 담는다 — `ALTER`, `DELETE`, `PURGE`가 `destructive=true`.
|
||||
|
||||
```java
|
||||
// :17-20
|
||||
* <p>Destructive permissions are named separately from ordinary ones. {@code DELETE_TOPIC} and
|
||||
* {@code PURGE} are not "write, but more"; they destroy data an application can never restore, so
|
||||
* an application runtime declaring one is rejected outright.
|
||||
```
|
||||
|
||||
`requireApplicationRuntime()`이 파괴적 grant가 하나라도 있으면 `MessagingConfigurationException("APPLICATION_HOLDS_DESTRUCTIVE_GRANT")`을 던진다.
|
||||
|
||||
**이 클래스 전체가 소비자 0이다**(§12.1).
|
||||
|
||||
`undeclared`/`missing`이 `Set<Grant>`를 받는데, `Grant`는 record이므로 equals가 세 필드 전부를 비교한다. 즉 `pattern`이 문자열 정확 일치여야 한다 — 와일드카드 패턴(`orders.*`)을 브로커가 다르게 표현하면 오탐이 난다. javadoc에 언급 없음.
|
||||
|
||||
### 4.7 `CredentialIds` — 참조 자리에 비밀을 붙여넣는 사고
|
||||
|
||||
```java
|
||||
// :9-13
|
||||
* <p>The bounded slug pattern is not cosmetic. Credential ids reach log lines and metric tags, so
|
||||
* an unbounded id is a cardinality problem, and an id that looks like a secret is a leak. The
|
||||
* heuristic check rejects the most common accident: pasting the secret itself where the reference
|
||||
* belongs.
|
||||
```
|
||||
|
||||
패턴 `[a-z0-9][a-z0-9._-]{1,63}` — 최소 2자, 최대 64자.
|
||||
|
||||
휴리스틱 접두사 다섯: `bearer `, `basic `, `sk-`, `-----begin`, `eyj`. 각각 HTTP Authorization, OpenAI 키, PEM 블록, base64 JWT 헤더(`{"` → `eyJ`)를 노린다.
|
||||
|
||||
**패턴이 이미 대부분을 막는다.** `[a-z0-9._-]`만 허용하므로 공백이 있는 `bearer `·`basic `는 패턴에서 이미 거절되고, `-----begin`은 첫 글자가 `-`라 거절된다. 실제로 휴리스틱만이 잡는 것은 `sk-`와 `eyj`뿐이다. 중복 방어이고 해롭지 않다.
|
||||
|
||||
### 4.8 `DestinationAccessPolicy` — 세 역할, 세 집합
|
||||
|
||||
`publishable`/`consumable`/`administrable` 셋이 전부 `Set.copyOf`로 불변화된다. `denyAll()`이 세 빈 집합이다.
|
||||
|
||||
```java
|
||||
// :10-12
|
||||
* <p>The platform checks this before the broker does. Relying only on broker ACLs means an
|
||||
* accidental publish surfaces as a generic authorization error at runtime, in the adapter, with no
|
||||
* record of which application module attempted it.
|
||||
```
|
||||
|
||||
`DestinationAccessValidator`가 세 `require*` 메서드로 그 검사를 예외로 바꾼다 — 그리고 소비자가 0이다(§12.1).
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
**자격증명 해석:** 어댑터의 security configurer → `registry.resolve(credentialId, now)` → 캐시 유효하면 반환 → 아니면 `compute` 안에서 `provider.resolve` + `provider.expiresAt` → 새 `CredentialRuntime` 설치 → 옛 것 `clear()`
|
||||
|
||||
**시작 검증(1):** starter가 `MessageSecurityValidator` bean 생성 → `validate(profile)` 호출 지점은 starter가 소유
|
||||
|
||||
**시작 검증(2):** 어댑터 configurer가 `BrokerTlsPolicy.validate(profile, enabledProtocols)` 호출
|
||||
|
||||
**발행 권한:** `DefaultMessagePublisher` → `access.mayPublish(name)` → false면 `PublishResult(REJECTED, PUBLISH_FORBIDDEN)`
|
||||
|
||||
---
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
| 코드 | 예외 | 위치 |
|
||||
|---|---|---|
|
||||
| `TLS_REQUIRED` | `MessagingConfigurationException` | `BrokerTlsPolicy` |
|
||||
| `HOSTNAME_VERIFICATION_REQUIRED` | `MessagingConfigurationException` | 같음 |
|
||||
| `TLS_PROTOCOL_NOT_ACCEPTED` | `MessagingConfigurationException` | 같음 |
|
||||
| `TLS_PROTOCOL_UNSPECIFIED` | `MessagingConfigurationException` | 같음 |
|
||||
| `APPLICATION_HOLDS_DESTRUCTIVE_GRANT` | `MessagingConfigurationException` | `BrokerAclManifest`(미사용) |
|
||||
| `DESTINATION_PUBLISH_DENIED` | `MessageAuthorizationException` | `DestinationAccessValidator`(미사용) |
|
||||
| `DESTINATION_CONSUME_DENIED` | `MessageAuthorizationException` | 같음(미사용) |
|
||||
| `DESTINATION_ADMIN_DENIED` | `MessageAuthorizationException` | 같음(미사용) |
|
||||
| (코드 없음) | `IllegalArgumentException` × 5 | `MessageSecurityValidator` |
|
||||
| (코드 없음) | `IllegalArgumentException` | `CredentialIds`, 각 생성자 |
|
||||
| (코드 없음) | `IllegalStateException` | `CredentialRuntime.material()` 소거 후 |
|
||||
|
||||
**보안 판정이 두 예외 계층으로 나뉜다.** `BrokerTlsPolicy`는 안정 코드가 붙은 `MessagingConfigurationException`을 쓰고, `MessageSecurityValidator`는 코드 없는 `IllegalArgumentException`을 쓴다. 둘이 같은 두 검사(TLS·hostname)를 공유하는데도 그렇다 — §12.3, §17.
|
||||
|
||||
---
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
트랜잭션 없음.
|
||||
|
||||
| 지점 | 도구 | 보호 |
|
||||
|---|---|---|
|
||||
| `CredentialRuntimeRegistry.resolved` | `ConcurrentHashMap` | 맵 자체 |
|
||||
| `resolve` | `compute`(bin 락) | key별 single-flight, fetch 정확히 한 번 |
|
||||
| `CredentialRuntime.material` | **동기화 없음** | (§17) |
|
||||
|
||||
레코드 여섯(`BrokerSecurityProfile`, `BrokerCredentialProfile` 5변형, `DestinationAccessPolicy`, `BrokerAclManifest`, `CredentialRotationPlan`)은 전부 불변이다. `BrokerTlsPolicy`·`MessageSecurityValidator`·`DestinationAccessValidator`는 상태가 없거나 불변 참조만 갖는다.
|
||||
|
||||
수명주기 참여는 `clearAll()`뿐이고 "for shutdown"이라고 javadoc이 적는다. **그것을 부르는 코드가 저장소에 없다** — 종료 시 자격증명이 소거되지 않는다. §17.
|
||||
|
||||
---
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
| 상수/기본값 | 값 | 위치 |
|
||||
|---|---|---|
|
||||
| `CredentialRuntime.DEFAULT_ROTATION_LEAD` | 30분 | public |
|
||||
| `BrokerTlsPolicy.MINIMUM_PROTOCOL` | `"TLSv1.2"` | public |
|
||||
| `BrokerTlsPolicy.ACCEPTED_PROTOCOLS` | `{TLSv1.2, TLSv1.3}` | private |
|
||||
| `BrokerTlsPolicy()` 기본 | `allowPlaintextOutsideProduction = true` | — |
|
||||
| `CredentialIds.VALID` | `[a-z0-9][a-z0-9._-]{1,63}` | private |
|
||||
|
||||
`production` 플래그가 세 클래스의 분기 조건이다 — `BrokerTlsPolicy`, `MessageSecurityValidator`, 그리고 `BrokerSecurityProfile`의 필드. 그 값을 정하는 곳은 이 leaf 밖이다.
|
||||
|
||||
**`MINIMUM_PROTOCOL`이 public이고 아무도 쓰지 않는다.** `ACCEPTED_PROTOCOLS`가 private이므로 외부에서 허용 집합을 알려면 `isAcceptable(String)`을 부르거나 이 상수를 보는데, 상수는 최소값만 알려준다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
없다. `CredentialProvider`가 외부 비밀 저장소를 가리킬 수 있는 port이고 이 leaf에 구현이 없다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
레인: `./gradlew :messaging:messaging-security:test`. **BUILD SUCCESSFUL, 24 tests, 0 skipped, 0 failures**.
|
||||
|
||||
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|
||||
|---|---:|---|---|
|
||||
| `CredentialRuntimeRegistryTest` | 13 | 해석·캐시·회전·소거·경합 하 single-flight | 실제 비밀 저장소 |
|
||||
| `MessageSecurityValidatorTest` | 7 | 다섯 거절 조건 | 실제 부팅에서 호출되는지(→ starter가 bean 생성) |
|
||||
| `CredentialRotationContractTest` | 4 | 회전 시점 술어 | **`CredentialRotationPlan`이 쓰이는지** |
|
||||
|
||||
**커버리지 공백 셋.**
|
||||
|
||||
- `BrokerTlsPolicy`를 겨냥한 테스트 클래스가 **없다.** 네 거절 조건과 허용목록 판정이 이 leaf의 테스트로 검증되지 않는다. 어댑터 쪽 `KafkaSecurityConfigurerTest`가 간접적으로 지나갈 수 있으나 그것은 다른 leaf의 레인이고 다른 것을 목표로 한다.
|
||||
- `BrokerAclManifest`를 겨냥한 테스트가 **없다.** `undeclared`/`missing`/`requireApplicationRuntime` 셋 다 미검증이다.
|
||||
- `DestinationAccessValidator`·`DestinationAccessPolicy`를 겨냥한 테스트가 **없다.**
|
||||
|
||||
즉 **12개 타입 중 5개가 이 leaf의 테스트에 등장하지 않는다.** 그리고 그중 셋은 §12.1의 소비자 0 목록과 겹친다 — 쓰이지도 않고 테스트되지도 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
| 게이트 | 이 leaf에 대해 |
|
||||
|---|---|
|
||||
| `verifyCleanArchitectureDependencies` | `["messaging-core-api"]` |
|
||||
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
|
||||
| vendor `api` 규칙 | 벤더 의존성 0 |
|
||||
| `SecretLeakStaticScanTest`(observability leaf) | **이 leaf의 소스도 스캔 대상** — 콘솔 출력·민감 식별자 문자열 연결 금지 |
|
||||
| ArchUnit | 전용 규칙 없음 |
|
||||
|
||||
네 번째가 이 leaf에 실질적이다. `CredentialRuntime.toString()`이 material을 빼고 id와 expiry만 담는 것, `MessagingRedactor`가 credential 키를 지우는 것과 함께 **세 층의 방어**를 이룬다 — 타입(`char[]`), 표현(`toString`), 정적 스캔.
|
||||
|
||||
---
|
||||
|
||||
## 12. 실제 사용 여부와 negative-space probes
|
||||
|
||||
원시 증거: `evidence/raw/287-messaging-security-duplicate-checks.txt`.
|
||||
|
||||
> **방법.** 정규화된 이름(`import dev.caskeleton.messaging.security.<Type>;` 또는 `dev.caskeleton.messaging.security.<Type>`)으로 측정했다. `messaging-observability`의 `CardinalityGuard`처럼 동명 클래스가 있는 경우를 배제하기 위해서다.
|
||||
|
||||
### 12.1 Public surface reachability
|
||||
|
||||
| 타입 | leaf 밖 파일 | 판정 |
|
||||
|---|---:|---|
|
||||
| `BrokerSecurityProfile` | 8 | 활발 |
|
||||
| `DestinationAccessPolicy` | 7 | 활발 |
|
||||
| `BrokerTlsPolicy` | 6 | 어댑터 둘 + starter 셋 |
|
||||
| `CredentialRuntimeRegistry` | 6 | 같음 |
|
||||
| `BrokerCredentialProfile` | 5 | 활발 |
|
||||
| `CredentialProvider` | 4 | 활발 |
|
||||
| `CredentialRuntime` | 2 | |
|
||||
| `MessageSecurityValidator` | 1 | starter bean |
|
||||
| **`DestinationAccessValidator`** | **0** | |
|
||||
| **`BrokerAclManifest`** | **0** | |
|
||||
| **`CredentialRotationPlan`** | **0** | |
|
||||
| `CredentialIds` | 0 | package-private — 구조상 내부. 결함 아님 |
|
||||
|
||||
**(a) 접근 검증기가 쓰이지 않고, 같은 검사가 다른 형태로 인라인돼 있다**
|
||||
|
||||
`DestinationAccessValidator.requirePublish`는 예외를 던진다.
|
||||
|
||||
```java
|
||||
if (!policy.mayPublish(destination)) {
|
||||
throw new MessageAuthorizationException(
|
||||
"DESTINATION_PUBLISH_DENIED",
|
||||
"the producer credential may not publish to " + destination.value());
|
||||
}
|
||||
```
|
||||
|
||||
발행 경로는 정책을 직접 묻고 결과를 반환한다.
|
||||
|
||||
```java
|
||||
// DefaultMessagePublisher.java:170-176
|
||||
if (!access.mayPublish(destination.name())) {
|
||||
return rejected(
|
||||
"PUBLISH_FORBIDDEN",
|
||||
"this application may not publish to '" + destination.name().value() + '\'',
|
||||
startedAt);
|
||||
}
|
||||
```
|
||||
|
||||
**같은 판단, 다른 코드, 다른 실패 형태.** `DESTINATION_PUBLISH_DENIED`(`AUTHORIZATION` 카테고리, 예외) vs `PUBLISH_FORBIDDEN`(`CONFIGURATION` 카테고리, `PublishResult`). 대시보드가 권한 거부를 세려면 두 어휘를 모두 알아야 하는데, 실제로 발생하는 것은 후자뿐이다. 그리고 **`FailureCategory`가 다르다** — 권한 거부가 `AUTHORIZATION`이 아니라 `CONFIGURATION`으로 기록된다.
|
||||
|
||||
`requireConsume`/`requireAdminister`도 소비자가 없다 — 소비 경로가 조립되지 않고(`analysis/messaging/messaging-policy.md` §17) admin 경로는 `messaging-admin-runtime`이 자체 검사를 할 수 있다.
|
||||
|
||||
**(b) ACL 매니페스트 전체가 미사용이다**
|
||||
|
||||
`BrokerAclManifest`는 선언·비교·거절 셋을 모두 갖춘 메커니즘이다 — `requireApplicationRuntime()`이 파괴적 grant를 가진 애플리케이션을 거절하고, `undeclared(observed)`가 브로커가 실제로 준 초과 권한을 찾는다. javadoc이 그 목적을 "The manifest is what the platform checks itself against at startup"이라고 적는다.
|
||||
|
||||
그 startup 검사를 하는 코드가 없다. 그리고 `observed` 집합을 만들려면 브로커에서 ACL을 읽어야 하는데, 그 읽기를 하는 코드도 없다 — `messaging-admin-api`의 `BrokerTopologyInspector`가 후보이지만 이 leaf와 연결되지 않는다. 즉 **미사용의 이유가 단순한 배선 누락이 아니라 관측 소스의 부재**일 수 있다. 그것은 admin leaf가 답한다.
|
||||
|
||||
**(c) 회전 계획 record가 미사용이고 그 술어가 다른 곳에 복제돼 있다** — §12.3.
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
이 leaf에 bean은 없다. starter 쪽 sibling 셋의 조건은 동일(`@ConditionalOnMissingBean`)하고 소비가 다르다.
|
||||
|
||||
| bean | 주입처 |
|
||||
|---|---|
|
||||
| `BrokerTlsPolicy` | `KafkaMessagingAutoConfiguration`, `RabbitMessagingAutoConfiguration` |
|
||||
| `CredentialRuntimeRegistry` | 같음 |
|
||||
| `MessageSecurityValidator` | **없음** — bean만 존재 |
|
||||
|
||||
세 번째가 `messaging-policy`의 `RetryDecisionEngine`(그쪽 §12.1)과 같은 형태다. 다만 차이가 있다 — `MessageSecurityValidator`는 **직접 호출** 지점이 있을 수 있다(starter가 bean을 만들면서 같은 파일에서 부를 수 있다). 그 확인은 starter leaf가 소유한다.
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
**(a) TLS posture 검사가 두 클래스에 있고 엄격도가 다르다**
|
||||
|
||||
| | `MessageSecurityValidator` | `BrokerTlsPolicy` |
|
||||
|---|---|---|
|
||||
| TLS 필수 | `production && !tlsEnabled` | `!tlsEnabled && (production \|\| !allowPlaintextOutsideProduction)` |
|
||||
| hostname 검증 | **`production && !hostnameVerification`** | **`tlsEnabled && !hostnameVerification`** |
|
||||
| 프로토콜 버전 | 없음 | 허용목록 + 빈 목록 거절 |
|
||||
| 예외 | `IllegalArgumentException` | `MessagingConfigurationException` |
|
||||
| 안정 코드 | 없음 | 4개 |
|
||||
| 호출자 | starter bean(주입처 없음) | 어댑터 둘 |
|
||||
|
||||
**hostname 검증의 조건이 다르다.** `MessageSecurityValidator`는 production에서만 요구하고, `BrokerTlsPolicy`는 **TLS가 켜져 있으면 언제나** 요구한다. 즉 비운영에서 TLS를 켜고 hostname 검증을 끈 구성은 후자가 거절하고 전자는 통과시킨다. 후자가 더 엄격하고, 후자가 실제로 호출되는 쪽이다.
|
||||
|
||||
두 클래스가 같은 `BrokerSecurityProfile`을 받는다. 어느 쪽이 정본인지 코드가 말하지 않는다.
|
||||
|
||||
**(b) 회전 술어가 두 번 구현돼 있다**
|
||||
|
||||
```java
|
||||
// CredentialRotationPlan.isDue(now) — 소비자 0
|
||||
return expiresAt.map(expiry -> !now.isBefore(expiry.minus(rotateBefore))).orElse(false);
|
||||
|
||||
// CredentialRuntime.isDueForRotation(now) — 사용됨
|
||||
return expiresAt.map(expiry -> !now.isBefore(expiry.minus(rotationLead))).orElse(false);
|
||||
```
|
||||
|
||||
`isExpired`도 같다.
|
||||
|
||||
```java
|
||||
// CredentialRotationPlan.isExpired(now)
|
||||
return expiresAt.map(expiry -> !now.isBefore(expiry)).orElse(false);
|
||||
// CredentialRuntime.isExpired(now)
|
||||
return expiresAt.map(expiry -> !now.isBefore(expiry)).orElse(false);
|
||||
```
|
||||
|
||||
**글자까지 동일하다.** 필드 이름만 `rotateBefore` vs `rotationLead`로 다르다. `CredentialRotationPlan`은 material을 갖지 않는 순수 계획 record이고 `CredentialRuntime`은 material을 갖는 런타임 상태다 — 관심사 분리로는 말이 되지만, 술어가 복제된 채로 한쪽만 쓰인다.
|
||||
|
||||
`CredentialRotationContractTest`가 `CredentialRotationPlan`을 테스트한다. 즉 **쓰이지 않는 쪽이 테스트되고 쓰이는 쪽의 같은 술어는 그 테스트가 덮지 않는다.** (`CredentialRuntimeRegistryTest`가 간접적으로 덮는다.)
|
||||
|
||||
**(c) 권한 검사 두 형태** — §12.1(a).
|
||||
|
||||
**(d) 자격증명 참조 검증이 다른 family에도 있는가**
|
||||
|
||||
`git grep`으로 credential id 패턴 검증을 저장소 전역에서 찾으면 이 leaf의 `CredentialIds`가 유일하다. notification·grpc family는 자기 자격증명 모델을 갖지만 messaging의 것을 쓰지 않는다 — 경계가 분명하므로 중복 경쟁이 아니다.
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
| 문서 주장 | 재측정 | 결과 |
|
||||
|---|---|---|
|
||||
| `BrokerCredentialProfile` javadoc: 어떤 변형도 비밀을 담지 않음 | 다섯 record 전부 `credentialId` 하나 | **일치** |
|
||||
| `CredentialRuntime` javadoc: `char[]`로 보관하고 `clear()`가 덮어씀 | 확인 | **일치** |
|
||||
| `BrokerAclManifest` javadoc: "what the platform checks itself against at startup" | 호출자 0 | **불일치** |
|
||||
| `DestinationAccessPolicy` javadoc: "The platform checks this before the broker does" | `mayPublish`가 발행 경로에서 호출됨 | **일치**(다만 validator 경유 아님) |
|
||||
| `CredentialRuntimeRegistry.clearAll` javadoc: "for shutdown" | 호출자 0 | **불일치** |
|
||||
| `MessageSecurityValidator` javadoc: "boot failures rather than warnings" | starter가 bean 생성. 호출 지점은 starter가 소유 | **미확인** |
|
||||
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift) |
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
| 위치 | 이전 상태 | 그것이 만든 실패 |
|
||||
|---|---|---|
|
||||
| `CredentialRuntimeRegistry.resolve` 주석 | `get → fetch → put → clear`, 동기화 없음 | 두 스레드가 같은 자격증명을 회전 → **진 쪽 교체본이 맵에서 사라지고 소거도 안 됨(소유자 없는 비밀이 힙에 잔류)**, 그리고 **진 쪽이 이긴 쪽이 사용 중인 material을 소거** |
|
||||
| `BrokerTlsPolicy` 프로토콜 검사 주석 | 거부목록 | `SSL`·`TLSv0.9`·`PLAINTEXT`·오타가 전부 통과 → JVM이 인식 못 하는 문자열은 **JVM 기본값으로 협상**, 즉 이 정책이 막으려던 결과 |
|
||||
|
||||
두 번째가 `messaging-schema-api` §12.3의 허용목록/거부목록 축과 같은 주제이고, 여기서는 **거부목록이 실제로 뚫린 기록**이 남아 있다.
|
||||
|
||||
첫 번째는 이 저장소가 반복하는 "정확히 한 번" 주제의 보안 판본이다 — `messaging-transport-spi`의 세대 close, `messaging-policy`의 permit 반납과 같은 계열이며, 여기서는 실패의 결과가 **비밀 잔류**다.
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-287 | command | `evidence/raw/287-messaging-security-duplicate-checks.txt` | 12타입 정규화 이름 기준 참조 수, 소비자 0인 넷, 접근 검사 두 형태 나란히, TLS 검사 두 클래스의 조건 차이, 회전 술어 두 복사본, 실제 소비자 목록 | 정적 검색. 리플렉션·파생 프로젝트 미포함 |
|
||||
| EVD-288 | command | `./gradlew :messaging:messaging-security:test --rerun-tasks` | BUILD SUCCESSFUL, 24 / 0 / 0 | `BrokerTlsPolicy`·`BrokerAclManifest`·접근 정책 미검증 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적**
|
||||
|
||||
- 어떤 변형도 비밀을 담지 않는 이유 — `BrokerCredentialProfile` javadoc
|
||||
- `char[]`이 타입 수준 통제인 이유 — `CredentialRuntime` javadoc
|
||||
- material을 복사해 반환하는 이유 — `material()` javadoc
|
||||
- 만료 이전에 회전하는 이유 — `CredentialRuntime`·`CredentialRotationPlan` javadoc
|
||||
- single-flight가 필요한 이유와 두 개의 이전 결함 — `resolve` 주석
|
||||
- 소거 순서(설치 후, 교체한 스레드가) 이유 — 같은 주석
|
||||
- hostname 검증 부재가 평문보다 나쁜 이유 — `BrokerTlsPolicy` javadoc
|
||||
- 허용목록을 고른 이유와 거부목록이 뚫린 기록 — 같은 파일 주석
|
||||
- 보안 검사가 경고가 아니라 부팅 실패인 이유 — `MessageSecurityValidator` javadoc
|
||||
- 세 자격증명을 분리하는 이유 — `BrokerSecurityProfile` javadoc
|
||||
- 초과 권한이 발견인 이유 — `BrokerAclManifest` javadoc
|
||||
- 파괴적 연산을 따로 이름 붙인 이유 — 같은 javadoc
|
||||
- credential id를 슬러그로 제한하는 이유 — `CredentialIds` javadoc
|
||||
- 플랫폼이 브로커보다 먼저 검사하는 이유 — `DestinationAccessPolicy`·`DestinationAccessValidator` javadoc
|
||||
|
||||
**추론**
|
||||
|
||||
- `DestinationAccessValidator`가 미사용인 것은 발행 경로가 예외 대신 `PublishResult`를 반환하기로 했기 때문이다 → **추론**. 두 형태의 존재는 관측이고 인과는 추론이다.
|
||||
- `BrokerAclManifest`가 미사용인 것은 브로커에서 ACL을 읽는 코드가 없기 때문이다 → **추론**. 읽기 코드 부재는 관측이다.
|
||||
- `CredentialRotationPlan`이 미사용인 것이 `CredentialRuntime`으로 흡수된 결과인지 → **미상**.
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- 12개 타입 954줄 전문의 계약
|
||||
- 24개 테스트가 통과하고 무엇을 단언하는지, 그리고 5개 타입이 테스트에 등장하지 않는다는 것
|
||||
- 정규화 이름 기준 참조 수와, 소비자 0인 셋(+package-private 하나)
|
||||
- 어댑터 둘이 `BrokerTlsPolicy`·`CredentialRuntimeRegistry`를 실제로 쓴다는 것
|
||||
- 같은 판단이 두 형태로 존재하는 세 쌍(접근 검사, TLS posture, 회전 술어)과 그중 TLS는 **엄격도가 실제로 다르다**는 것
|
||||
- `clearAll()`의 호출자가 없다는 것
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- **`MessageSecurityValidator.validate`가 실제로 호출되는지.** starter가 bean을 만들고, 같은 파일에서 직접 호출할 가능성이 있다. starter leaf가 답한다.
|
||||
- 브로커에서 ACL을 읽는 경로가 존재하는지 — `messaging-admin-api`의 `BrokerTopologyInspector`가 후보다.
|
||||
- `compute` 안에서 `provider.resolve`가 실제 저장소를 호출할 때의 지연. 구현이 없어 관측할 수 없다.
|
||||
- `CredentialRuntime.material` 필드의 가시성 문제가 실제로 발생하는지 — 현재 경로에서는 창이 좁다.
|
||||
- `BrokerAclManifest.Grant`의 `pattern` 정확 일치가 실제 브로커 표현과 맞는지.
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P2 — 같은 TLS posture를 두 클래스가 다른 엄격도로 검사한다
|
||||
|
||||
- **사실.** `MessageSecurityValidator`는 hostname 검증을 `production && !hostnameVerification`일 때만 요구하고, `BrokerTlsPolicy`는 `tlsEnabled && !hostnameVerification`일 때 요구한다. 전자는 코드 없는 `IllegalArgumentException`, 후자는 안정 코드가 붙은 `MessagingConfigurationException`을 던진다. 둘 다 같은 `BrokerSecurityProfile`을 받고, 후자만 어댑터에서 실제로 호출된다.
|
||||
- **근거.** `evidence/raw/287` §D.
|
||||
- **왜 문제인가.** 비운영에서 TLS를 켜고 hostname 검증을 끈 구성을 두 검사가 다르게 판정한다. 그리고 이 leaf 자신의 javadoc이 그 구성을 "looks encrypted in every dashboard while accepting any certificate a man in the middle presents"라고 부른다 — 즉 더 느슨한 쪽이 그 위험을 통과시킨다. 실패 형태도 달라서 운영자가 두 어휘를 알아야 한다.
|
||||
- **확인 방법.** `evidence/raw/287` §D 재실행. 또는 두 `validate` 메서드 대조.
|
||||
- **후보.** (a) `MessageSecurityValidator`가 `BrokerTlsPolicy`에 위임한다. (b) 두 클래스의 책임을 나눈다 — TLS는 후자, 자격증명 분리는 전자.
|
||||
- **다음 단계.** **CASE 후보 + REFERENCE 후보.** "같은 불변식을 두 곳에서 검사하면 느슨한 쪽이 통과 경로가 된다"가 재사용 가능한 기준이다.
|
||||
|
||||
### P2 — 권한 거부가 `AUTHORIZATION`이 아니라 `CONFIGURATION`으로 기록된다
|
||||
|
||||
- **사실.** `DestinationAccessValidator.requirePublish`는 `MessageAuthorizationException("DESTINATION_PUBLISH_DENIED")`을 던지고 그 카테고리는 `AUTHORIZATION`이다. 소비자가 0이다. 실제 발행 경로는 `access.mayPublish`를 직접 묻고 `rejected("PUBLISH_FORBIDDEN", ...)`을 반환하는데, `rejected(...)`는 `FailureCategory.CONFIGURATION`을 붙인다.
|
||||
- **근거.** `evidence/raw/287` §C. `DefaultMessagePublisher.java:104-116`(`rejected`의 카테고리).
|
||||
- **왜 문제인가.** `FailureCategory`는 "stable classification a retry engine, DLQ router, and dashboard all agree on"이다(`messaging-core-api` §4.12). 권한 거부가 구성 오류로 분류되면 보안 대시보드가 그것을 보지 못하고, 구성 오류 알림이 권한 거부로 오염된다. 그리고 `AUTHORIZATION` 카테고리를 쓰는 유일한 코드가 미사용 클래스에 있다.
|
||||
- **확인 방법.** `MessageAuthorizationException`의 `CATEGORY` 상수와 `DefaultMessagePublisher.rejected`의 카테고리 대조.
|
||||
- **후보.** 발행 경로가 권한 거부에 `AUTHORIZATION` 카테고리를 붙이거나, `DestinationAccessValidator`를 쓰고 예외를 `PublishResult`로 번역한다.
|
||||
- **다음 단계.** **CASE 후보.** `messaging-runtime-core` leaf와 공동 소유.
|
||||
|
||||
### P3 — ACL 매니페스트 전체가 쓰이지 않는다
|
||||
|
||||
- **사실.** `BrokerAclManifest`의 세 메서드(`requireApplicationRuntime`, `undeclared`, `missing`)와 두 enum이 소비자 0이다. javadoc은 "The manifest is what the platform checks itself against at startup"이라고 한다.
|
||||
- **근거.** `evidence/raw/287` §A·§B.
|
||||
- **왜 문제인가.** "애플리케이션 런타임은 파괴적 권한을 갖지 않는다"는 이 leaf의 핵심 원칙 중 하나이고, `MessageSecurityValidator`가 admin **자격증명**의 부재만 검사한다. 브로커가 producer 자격증명에 `DELETE`를 준 경우는 아무도 보지 않는다.
|
||||
- **확인 방법.** `git grep -l 'BrokerAclManifest' -- src ':!src/messaging/messaging-security'` → 없음.
|
||||
- **후보.** startup 검사에 배선하거나, 브로커 ACL 읽기가 없으면 그 사실을 javadoc에 적는다.
|
||||
- **다음 단계.** **OPEN QUESTION 후보.** 판정이 "브로커 ACL을 읽는 경로가 있는가"에 걸리고, 그것은 `messaging-admin-api`가 답한다.
|
||||
|
||||
### P3 — 종료 시 자격증명 소거가 호출되지 않는다
|
||||
|
||||
- **사실.** `CredentialRuntimeRegistry.clearAll()`의 javadoc이 "Clears every held credential, for shutdown"이라고 하고, 호출자가 저장소에 없다.
|
||||
- **근거.** `git grep -n 'clearAll' -- src`.
|
||||
- **왜 문제인가.** 이 leaf 전체가 "비밀이 힙에 남지 않게 한다"를 목적으로 하고(`char[]`, `clear()`, 회전 시 즉시 소거), 종료 경로에서 그 마지막 단계가 빠져 있다. 프로세스가 끝나면 힙도 사라지지만, 종료가 느리거나 힙 덤프가 뜨는 경우가 정확히 이 통제가 노리는 상황이다.
|
||||
- **확인 방법.** `git grep -n 'clearAll' -- src` → 선언과 테스트만.
|
||||
- **후보.** `MessagingShutdownLifecycle`이나 `DisposableBean`에 연결한다.
|
||||
- **다음 단계.** **CASE 후보.** `messaging-transport-spi` §12.1의 8단계 종료 계약과 같은 맥락이다.
|
||||
|
||||
### P3 — 회전 술어가 두 번 구현돼 있고, 쓰이지 않는 쪽이 테스트된다
|
||||
|
||||
- **사실.** `CredentialRotationPlan.isDue`/`isExpired`와 `CredentialRuntime.isDueForRotation`/`isExpired`가 글자까지 같다. 전자는 소비자 0이고 전용 테스트(`CredentialRotationContractTest`, 4개)가 있다.
|
||||
- **근거.** `evidence/raw/287` §E.
|
||||
- **왜 문제인가.** 테스트가 고정하는 것과 실행되는 것이 다른 객체다. 한쪽만 고치면 다른 쪽은 조용히 다른 시점에 회전한다.
|
||||
- **확인 방법.** 두 메서드 본문 대조.
|
||||
- **후보.** `CredentialRuntime`이 `CredentialRotationPlan`을 필드로 갖고 위임하거나, 계획 record를 제거한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(같은 술어가 두 타입에 있으면 하나가 다른 하나를 부른다).
|
||||
|
||||
### P3 — 자격증명 해석이 맵 bin 락 안에서 외부 I/O를 한다
|
||||
|
||||
- **사실.** `resolve`가 `resolved.compute(credentialId, (key, existing) -> { ... provider.resolve(key) ... })` 형태다. `CredentialProvider.resolve`는 외부 비밀 저장소를 호출할 수 있는 port다.
|
||||
- **근거.** `CredentialRuntimeRegistry.java:71-86`.
|
||||
- **왜 문제인가.** single-flight를 얻은 대가다 — 같은 credential id를 요청하는 다른 스레드는 저장소 왕복 동안 막힌다. 그것이 의도이고 옳다. 다만 **`ConcurrentHashMap`의 bin은 키가 공유하므로** 해시가 충돌하는 다른 credential id도 함께 막힌다. 그리고 저장소가 느려지면 그 지연이 발행 경로로 전파된다 — 타임아웃이 없다.
|
||||
- **확인 방법.** `provider.resolve` 호출 위치가 람다 안임을 확인.
|
||||
- **후보.** 현 구조를 유지하되 `CredentialProvider` javadoc에 "구현은 유한 시간 안에 반환해야 한다"를 명시한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(맵 갱신 함수 안에서 I/O를 하면 그 지연이 락 범위가 된다).
|
||||
|
||||
### P3 — 다섯 타입이 이 leaf의 테스트에 등장하지 않는다
|
||||
|
||||
- **사실.** `BrokerTlsPolicy`·`BrokerAclManifest`·`DestinationAccessPolicy`·`DestinationAccessValidator`·`BrokerCredentialProfile`을 겨냥한 테스트가 없다.
|
||||
- **근거.** 세 테스트 클래스 전수.
|
||||
- **왜 문제인가.** `BrokerTlsPolicy`는 **실제로 배선된** 클래스다 — 어댑터 둘이 호출한다. 네 거절 조건과 허용목록 판정이 이 leaf의 레인에서 검증되지 않는다. 어댑터 테스트가 간접적으로 지나가더라도 그것은 다른 목표를 가진 레인이다.
|
||||
- **확인 방법.** `find src/test -name '*Test.java'` → 셋.
|
||||
- **후보.** `BrokerTlsPolicy`의 네 거절 조건과 허용/거부 경계를 겨냥한 테스트를 추가한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(배선된 게이트는 자기 leaf 레인에서 검증한다).
|
||||
|
||||
### P3 — `CredentialRuntime.material`이 동기화되지 않는다
|
||||
|
||||
- **사실.** `private char[] material`이 `volatile`이 아니고 `clear()`가 그것을 교체한다. `clearAll()`은 락 없이 순회한다.
|
||||
- **근거.** `CredentialRuntime.java:29,129-132`, `CredentialRuntimeRegistry.java:129-132`.
|
||||
- **왜 문제인가.** 정상 경로(`compute` 안 소거)에서는 `ConcurrentHashMap`이 happens-before를 준다. `clearAll()` 경로에는 그 보장이 없다 — 다른 스레드가 소거된 배열의 옛 참조를 보고 이미 지워진 material을 읽을 수 있다(0으로 채워진 값). 실질 위험은 낮고 방향도 안전(비밀 유출이 아니라 잘못된 값)하다.
|
||||
- **확인 방법.** 필드 선언 확인.
|
||||
- **후보.** `material`을 `volatile`로 하거나 `clearAll()`을 `compute` 기반으로 바꾼다.
|
||||
- **다음 단계.** **REFERENCE 후보**(가변 필드로 상태 전이를 표현하면 가시성을 함께 정한다).
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- 어떤 자격증명 프로파일 변형도 비밀을 담지 않고 참조만 갖는 것, 그리고 sealed로 닫은 것
|
||||
- material을 `char[]`로 보관하고 반환 시 복사하며 소거 시 덮어쓰는 세 통제
|
||||
- `toString()`이 material을 담지 않는 것
|
||||
- key별 single-flight와 "설치 후 소거, 교체한 스레드만" 순서
|
||||
- 만료가 아니라 만료 이전에 회전하는 것, 만료를 모르면 회전 대상이 아닌 것
|
||||
- TLS 프로토콜을 허용목록으로 판정한 것과 그 이유가 실패 이력으로 남은 것
|
||||
- hostname 검증 부재를 평문보다 나쁜 실패로 분류한 것
|
||||
- producer·consumer·admin 자격증명 분리와 운영에서 admin 금지
|
||||
- credential id 슬러그 제한과 비밀-모양 접두사 휴리스틱
|
||||
- 파괴적 연산을 enum 상수에 표시한 것
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MSC-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 1개, memberships `["app-bootstrap"]` | 선언 |
|
||||
| MSC-002 | build | `messaging-security/build.gradle` | same | 벤더 의존성 0 | — |
|
||||
| MSC-003 | code | `.../security/CredentialRuntime.java` 전문 | same | §4.2 세 통제, 회전 술어 | `material` 미동기화(§17) |
|
||||
| MSC-004 | code | `.../security/CredentialRuntimeRegistry.java` 전문 | same | §4.1 single-flight와 두 이전 결함 | `clearAll` 호출자 없음 |
|
||||
| MSC-005 | code | `.../security/BrokerTlsPolicy.java` 전문 | same | §4.4 네 거절과 허용목록 이력 | 전용 테스트 없음 |
|
||||
| MSC-006 | code | `.../security/MessageSecurityValidator.java` | same | §4.5 다섯 거절 | TLS 검사가 §4.4와 겹침 |
|
||||
| MSC-007 | code | `.../security/BrokerAclManifest.java` | same | §4.6 초과=발견, 파괴적 연산 분리 | 소비자 0 |
|
||||
| MSC-008 | code | `.../security/{DestinationAccessPolicy,DestinationAccessValidator}.java` | same | §4.8 세 역할, 검증기의 세 코드 | 검증기 소비자 0 |
|
||||
| MSC-009 | code | `.../security/{BrokerSecurityProfile,BrokerCredentialProfile,CredentialIds,CredentialProvider,CredentialRotationPlan}.java` | same | 역할 분리, sealed 5변형, id 검증, port | 계획 record 소비자 0 |
|
||||
| MSC-010 | test | `CredentialRuntimeRegistryTest` (13) | same | 해석·회전·소거·경합 | 실제 저장소 없음 |
|
||||
| MSC-011 | test | `MessageSecurityValidatorTest` (7) | same | 다섯 거절 조건 | — |
|
||||
| MSC-012 | test | `CredentialRotationContractTest` (4) | same | 회전 시점 술어 | **미사용 타입을 테스트** |
|
||||
| MSC-013 | cross-leaf code | `messaging-kafka/.../KafkaSecurityConfigurer.java`, `messaging-rabbit/.../RabbitSecurityConfigurer.java` | same | `BrokerTlsPolicy`·`CredentialRuntimeRegistry`의 실제 소비 | 각 leaf SSOT가 소유 |
|
||||
| MSC-014 | cross-leaf code | `messaging-runtime-core/.../DefaultMessagePublisher.java:170-176` | same | 인라인 권한 검사와 그 코드·카테고리 | 해당 leaf SSOT가 소유 |
|
||||
| MSC-015 | assembly | `messaging-spring-boot-starter/.../MessagingCoreAutoConfiguration.java` | same | 세 bean 생성 | 해당 leaf SSOT가 소유 |
|
||||
| EVD-287 | command | `evidence/raw/287-messaging-security-duplicate-checks.txt` | same | §12.1·§12.3 전부 | 정적 검색 |
|
||||
| EVD-288 | command | `./gradlew :messaging:messaging-security:test --rerun-tasks` | same | 24 / 0 / 0 | 5개 타입 미검증 |
|
||||
@@ -0,0 +1,456 @@
|
||||
# messaging-spring-boot-starter 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 28파일 3,528줄 + `src/test` 10파일 2,349줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-spring-boot-starter`
|
||||
> SSOT owner: `messaging-spring-boot-starter`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지
|
||||
|
||||
- `runtime_memberships`: **`["app-bootstrap"]`** — 출하. 이 리프가 messaging 폐포 전체를 실행 클래스패스에 올린다
|
||||
- 자동 설정 등록: `MessagingPlatformRootAutoConfiguration` 하나
|
||||
- 다만 `app-bootstrap` 의 `application.yml` 어디에도 `app.messaging.enabled` 가 없다. 클래스패스에는 있고 꺼져 있다
|
||||
|
||||
| 파일 | LOC | 역할 |
|
||||
|---|---:|---|
|
||||
| `MessagingCoreAutoConfiguration` | 480 | 정책·전송·관측 빈 26개 + 발행자 + 런타임 설치 |
|
||||
| `MessagingConfigurationCompiler` | 331 | 문서화된 설정 → 플랫폼 프로파일 |
|
||||
| `MessagingSettings` | 301 | `app.messaging` 바인딩 + 중첩 4클래스 |
|
||||
| `DefaultBatchMessagePublisher` | 254 | 배치 팬아웃 + 마감 |
|
||||
| `MessagingConfigurationKeyValidator` | 227 | 바인딩되지 않는 키 거부 |
|
||||
| `MessagingReliabilityAutoConfiguration` | 185 | 발신함·수신함 운영 빈 |
|
||||
| `DestinationSettings` | 177 | 목적지 한 항목(중첩 record 7) |
|
||||
| `KafkaMessagingAutoConfiguration` | 170 | Kafka 검증기·보안 설정기·생산자·전송 |
|
||||
| `MessagingProviderSelection` | 150 | 닫힌 레지스트리에서 전송 하나 선택 |
|
||||
| `MessagingShutdownLifecycle` | 124 | 승인 차단 → 배수 |
|
||||
| `RabbitMessagingAutoConfiguration` | 98 | 검증기·분류기·보안 설정기 (전송 없음) |
|
||||
| `MessagingCredentialRequirementValidator` | 92 | 운영 프로파일에 자격 출처 요구 |
|
||||
| `MessagingAdminAutoConfiguration` | 86 | 관리 평면(별도 스위치) |
|
||||
| `MessagingPrefixMigrationValidator` | 83 | 죽은 접두 거부 |
|
||||
| `MessagingEndpoint` | 78 | 읽기 전용 actuator |
|
||||
| `PublishResults` | 71 | 예외 → 결과 변환 |
|
||||
| `BrokerSettings` | 69 | 브로커 한 항목(두 가족 한 record) |
|
||||
| `MessagingOutboxRelayLifecycle` | 69 | 중계 구동 |
|
||||
| `MessagingAdminDurabilityValidator` | 68 | 비내구 저널 위 운영 프로파일 거부 |
|
||||
| `DefaultBlockingMessagePublisher` | 65 | 블로킹 파사드 |
|
||||
| `ValidatedDestinationRegistry` | 59 | 검증 통과 목적지 |
|
||||
| `CompiledMessagingConfiguration` | 53 | 컴파일 결과 4묶음 |
|
||||
| `BrokerSecuritySettings` | 50 | 보안 한 항목(비밀 없음) |
|
||||
| `StartupProfileValidation` | 46 | 검증기를 실제로 부르는 어댑터 |
|
||||
| `DefaultReactiveMessagePublisher` | 39 | Reactor 파사드 |
|
||||
| `MessagingPlatformRootAutoConfiguration` | 36 | 마스터 조건 소유 |
|
||||
| `MessageContracts` | 35 | 메시지 계약 홀더 |
|
||||
| `ReactiveMessagePublisher` | 32 | Reactor 인터페이스 |
|
||||
|
||||
main 총 **28파일 / 3,528줄**.
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `main/java/**` | 28 | `FULL_READ` | 3,528줄. 위 표가 전부 |
|
||||
| `main/resources/META-INF/spring/*.imports` | 1 | `FULL_READ` | 1줄 |
|
||||
| `test/java/**` | 10 | `FULL_READ` | 2,349줄 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 66줄 전문 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
> 이 표는 2026-09-01 재통독에서 다시 세었다. 이전 판은 큰 파일 아홉만 적고 "나머지 18파일 — " 로 닫았다. 그 "나머지" 안에 §17.4 가 있었다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 하나의 뿌리가 조건을 소유한다
|
||||
|
||||
```java
|
||||
@AutoConfiguration
|
||||
@ConditionalOnProperty(prefix = MessagingSettings.PREFIX, name = "enabled", havingValue = "true")
|
||||
@EnableConfigurationProperties(MessagingSettings.class)
|
||||
@Import({MessagingCoreAutoConfiguration.class, MessagingProviderSelection.class,
|
||||
MessagingReliabilityAutoConfiguration.class, MessagingAdminAutoConfiguration.class})
|
||||
public class MessagingPlatformRootAutoConfiguration {}
|
||||
```
|
||||
|
||||
javadoc 이 이전 상태와 수정을 적는다.
|
||||
|
||||
> "The starter registered five auto-configurations directly, and not one carried a messaging master
|
||||
> condition — putting the starter on the classpath assembled the platform… one root owning the
|
||||
> condition, importing children that carry none, so a bean added to any child next month is gated
|
||||
> without anyone remembering to repeat a condition."
|
||||
|
||||
그리고 제공자 선택의 이전 상태도 적는다.
|
||||
|
||||
> "Kafka and Rabbit were each conditioned on their client class being present, so an application that
|
||||
> happened to have both libraries — a transitive dependency is enough — assembled both providers and
|
||||
> published through whichever bean won. Selection now reads `app.messaging.broker` against a closed
|
||||
> registry, and a value outside it is a startup error rather than a context with no provider at all."
|
||||
|
||||
꺼진 상태의 계약도 명시된다 — 빈도, 클라이언트도, 스레드도, 결속된 상세 이름공간도 없다. `MessagingStarterOffContractTest` 가 그것을 빈 이름과 **살아 있는 스레드** 로 붙든다.
|
||||
|
||||
## 2. 선택은 닫힌 레지스트리이고, 등록과 조립은 다르다
|
||||
|
||||
`MessagingProviderSelection` 에 지도가 셋이다.
|
||||
|
||||
```java
|
||||
REGISTERED_BROKERS = {kafka: org.apache.kafka.clients.producer.Producer,
|
||||
rabbit: com.rabbitmq.client.Channel}
|
||||
PROVIDER_CONFIGURATIONS = {kafka: KafkaMessagingAutoConfiguration,
|
||||
rabbit: RabbitMessagingAutoConfiguration}
|
||||
BROKERS_WITHOUT_A_TRANSPORT = {rabbit: "…ships its validators and security configuration but no
|
||||
MessagingTransport…"}
|
||||
```
|
||||
|
||||
셋째 지도가 이 클래스의 판단이다. 등록되어 있다는 것과 조립할 수 있다는 것을 분리했고, 그 이유를 적었다 — Rabbit 을 고르면 핵심 설정 깊은 곳에서 `MessagingTransport` 빈이 없다는 오류가 나는데, 그것은 운영자에게 빈이 없다고만 말하지 고른 전송이 완성되지 않았다고는 말하지 않는다.
|
||||
|
||||
결과로 오늘 조립 가능한 전송은 `kafka` 하나다. `RabbitMessagingAutoConfiguration` 98줄은 선택 단계에서 거부되므로 **어떤 경로로도 도달하지 않는다**(§12.3).
|
||||
|
||||
## 3. 설정이 프로파일이 된다
|
||||
|
||||
`MessagingConfigurationCompiler` 가 닫는 것은 기능이 아니라 바인더의 부재다.
|
||||
|
||||
> "`docs/messaging/configuration-reference.md` described destination, broker and security sections;
|
||||
> the only thing that bound was four flags… So a deployment that followed the documentation
|
||||
> configured nothing, and nothing said so — which is the worst of the three possible outcomes, the
|
||||
> other two being 'it works' and 'it refuses to start'."
|
||||
|
||||
컴파일과 검증을 나눈 이유도 적혀 있다. 컴파일은 객체 모델이 표현할 수 없는 것만 본다 — 목적지의 브로커가 존재하는지, 사후 처리 목적지가 선언되었는지, 보안 항목이 실재하는 브로커를 지키는지. 프로파일이 자체로 정합한지는 `DestinationProfileValidator` 의 질문이고 레지스트리 전체에 대해 던져진다. 그래서 설정으로 만든 프로파일과 빈으로 선언한 프로파일이 **같은 규칙**을 받는다.
|
||||
|
||||
그리고 모든 거부가 키를 부른다. 타입을 부르는 오류는 운영자가 고칠 줄을 알려 주지 않기 때문이다.
|
||||
|
||||
## 4. 시작 프로파일 검증
|
||||
|
||||
`StartupProfileValidation` 이 이 가족에서 이미 한 번 고쳐진 결함을 기록한다.
|
||||
|
||||
> "The Kafka, RabbitMQ and security validators were all beans and none of them was injected
|
||||
> anywhere: the context published a validator per broker and validated nothing."
|
||||
|
||||
수정의 두 판단이 적혀 있다 — `afterPropertiesSet` 으로 돌려 컨텍스트 구성 중에 실패하게 한 것, 그리고 프로파일을 `Supplier` 로 받아 애플리케이션 선언 빈과 설정에서 컴파일된 프로파일 **두 출처** 를 모두 보게 한 것.
|
||||
|
||||
> "a validator that saw only one of the two would leave the other half of a deployment's
|
||||
> configuration unchecked. Which half went unchecked would depend on how the deployment happened to
|
||||
> be written, which is the worst possible rule."
|
||||
|
||||
## 5. 신뢰성 배선의 원칙
|
||||
|
||||
> "Every bean here is conditional on the application having supplied the corresponding repository.
|
||||
> The platform cannot provide those: they write inside the application's own transaction, against the
|
||||
> application's own datasource, and a default implementation would silently write to the wrong place
|
||||
> — or to nowhere at all, which is worse because the outbox would look healthy while nothing was ever
|
||||
> staged."
|
||||
|
||||
정리 작업과 중계의 처리가 갈리고 그 이유도 적혀 있다.
|
||||
|
||||
> "The cleanup jobs are beans but no scheduler is registered for them. Scheduling is the
|
||||
> application's decision: a service running several replicas usually wants one of them to run
|
||||
> cleanup, and auto-registering a fixed-rate task would have every replica delete the same rows."
|
||||
|
||||
> "The relay is the opposite case and is driven here. Its claims are fenced by owner and token under
|
||||
> `SKIP LOCKED`, so every replica running one is safe, while nobody running one is a table that fills
|
||||
> up behind a business transaction that reported success."
|
||||
|
||||
## 6. 종료 순서가 두 수명 주기의 phase 로 표현된다
|
||||
|
||||
```java
|
||||
MessagingOutboxRelayLifecycle.getPhase() = Integer.MAX_VALUE
|
||||
MessagingShutdownLifecycle.getPhase() = Integer.MAX_VALUE - 1024
|
||||
```
|
||||
|
||||
`SmartLifecycle` 은 내림차순으로 멈추므로 중계가 먼저, 승인 차단과 배수가 다음이다. 두 클래스의 javadoc 이 서로를 근거로 든다 — 중계가 발행 중일 때 승인을 닫으면 그 회차의 행이 모호해지고, 그 모호함이야말로 배수가 없애려는 것이다. 그리고 브로커 연결을 쥔 빈(`@Bean(destroyMethod = "close")` 인 생산자)은 `Lifecycle` 이 아니므로 컨텍스트가 `destroyBeans()` 에 도달할 때, 즉 두 수명 주기가 모두 끝난 뒤에 닫힌다. 순서가 맞는다.
|
||||
|
||||
## 10. 테스트 레인
|
||||
|
||||
10파일 2,349줄.
|
||||
|
||||
| 파일 | 줄 | 무엇을 붙드나 |
|
||||
|---|---:|---|
|
||||
| `MessagingAutoConfigurationTest` | 546 | 빈 조립·바인딩·모순 프로파일 거부·접두 이관·저널 내구성·자격 요구 |
|
||||
| `MessagingConfigurationBindingTest` | 320 | **문서를 실행한다** — `docs/messaging/configuration-reference.md` 의 YAML 블록을 꺼내 컨텍스트를 띄운다. 그리고 거부 9종 |
|
||||
| `BatchPublisherTest` | 316 | 인덱스별 결과·동기 실패·마감·지연된 거부 |
|
||||
| `MessagingStarterOffContractTest` | 254 | 꺼짐=빈 0·스레드 0, 선택 계약, Rabbit 거부 |
|
||||
| `MessagingLiveRoundTripQualificationTest` | 217 | Testcontainers Kafka 4.1.0 에 실제로 바이트를 보내고 읽어 온다 |
|
||||
| `MessagingOutboxRelayLifecycleTest` | 203 | 컨텍스트가 중계를 실제로 돌리는지 |
|
||||
| `BlockingFacadeTest` · `ReactiveFacadeTest` | 148 · 134 | 마감·모호 처리 / 차가운 `Mono` |
|
||||
| `MessagingEndpointTest` | 133 | 보고 내용·쓰기 연산 0 |
|
||||
| `MessagingShutdownLifecycleTest` | 78 | 승인 차단이 배수보다 먼저 |
|
||||
|
||||
두 테스트가 이 리프의 검증 태도를 규정한다.
|
||||
|
||||
**문서를 실행한다.** `MessagingConfigurationBindingTest.documented()` 가 마크다운에서 ```` ```yaml ```` 블록을 뽑아 `YamlPropertySourceLoader` 로 올린다. 문서를 고쳐 바인더가 감당 못 하면 여기서 깨지고, 바인더를 고쳐 문서가 없는 모양을 서술하게 되어도 깨진다.
|
||||
|
||||
**가짜가 결함을 가리는 것을 막는다.** `selectingRabbitIsRefused` 의 주석이 자기 이전 판을 기록한다.
|
||||
|
||||
> "This test used to run under `withAPublisher()` and assert the context started. The fake
|
||||
> MessagePublisher tripped @ConditionalOnMissingBean and removed the very bean whose missing
|
||||
> dependency is the defect — so a configuration that cannot start in any deployment passed as
|
||||
> 'assembles Rabbit and not Kafka'."
|
||||
|
||||
그리고 그 교훈을 지키는 가드 테스트(`aFakePublisherDoesNotHideAnUnassemblableTransport`)를 따로 둔다.
|
||||
|
||||
## 12. negative-space probes
|
||||
|
||||
**12.1 도달성.** 이 리프는 `app-bootstrap` 에 출하되고 자동 설정이 등록된다. 그런데 `app-bootstrap` 의 `application.yml`·`application-{local,dev,prod}.yml` 어디에도 `app.messaging.enabled` 가 없다. 클래스패스에 있고 꺼져 있다. 그래서 이 리프의 판정은 전부 "속성 하나를 켜는 날" 의 것이다 — 그리고 그 속성을 켜는 것이 곧 이 스타터를 채택하는 행위다.
|
||||
|
||||
**12.2 대조군 — 검증기를 부르는가.** `grpc-spring-boot-starter` 는 시작 검증기를 만들어 놓고 부르지 않는다. 이쪽은 `StartupProfileValidation` 으로 실제로 부른다 — 다만 셋 중 하나가 빠져 있다(§17.2).
|
||||
|
||||
**12.3 도달하지 않는 설정 클래스.** `RabbitMessagingAutoConfiguration` 98줄은 `PROVIDER_CONFIGURATIONS` 에 등록되어 있지만 `selectedBroker` 가 `rabbit` 을 먼저 거부하므로 `Selector.selectImports` 가 이 클래스 이름을 돌려주는 경로가 없다. 죽은 코드이되 **의도된** 죽은 코드다 — 전송이 생기는 날 `BROKERS_WITHOUT_A_TRANSPORT` 에서 항목이 빠지면 살아난다. 그 의도가 지도 이름과 javadoc 에 적혀 있다.
|
||||
|
||||
**12.4 드리프트.** 등록 파일이 뿌리 하나만 담고, 그 뿌리가 넷을 가져온다. 서술과 일치한다.
|
||||
|
||||
**12.5 설정처럼 보이지만 상수인 것.** `MessagingConfigurationCompiler.credential(...)` 의 넷째 매개변수 `Supplier<Boolean> required` 는 호출처 셋 모두 `() -> true` 다(§17.4).
|
||||
|
||||
**12.6 두 설정 경로의 비대칭.** 이 리프는 "빈으로 선언한 프로파일과 설정으로 만든 프로파일이 같은 규칙을 받아야 한다" 를 반복해서 근거로 든다. 그런데 `DestinationSettings.Retry` 에는 `retryableCategories`·`nonRetryableCategories` 에 대응하는 키가 없다(§17.5).
|
||||
|
||||
**12.7 보안 설정기를 부르는 곳이 없다.** 저장소 전체에서 `KafkaSecurityConfigurer` 를 언급하는 production 코드는 이 리프의 빈 선언 한 줄뿐이다. 나머지는 자기 자신과 자기 테스트다(§17.1).
|
||||
|
||||
## 16. 확인하지 못한 것
|
||||
|
||||
- 애플리케이션이 저장소 빈을 공급한 상태로 컨텍스트를 세우지 않았다. 저장소에 그런 애플리케이션이 없다.
|
||||
- `@ConditionalOnBean` 의 평가 순서를 실제 컨텍스트로 재현하지 않았다(§17.3). 스프링의 문서화된 제약으로 판정했다.
|
||||
- §17.1 을 TLS·SASL 을 요구하는 실제 브로커에 붙여 재현하지 않았다. 조립되는 생산자 설정 맵의 성분 전부(`bootstrap.servers`·직렬화기 둘·`acks`·`enable.idempotence`)와 `KafkaSecurityConfigurer.configure` 가 만드는 성분 다섯(`security.protocol`·`ssl.enabled.protocols`·`ssl.endpoint.identification.algorithm`·`sasl.mechanism`·`sasl.jaas.config`)이 교집합 0 이라는 것으로 판정했다.
|
||||
- `gradle.lockfile` 은 읽지 않았다(`STRUCTURAL_ONLY`).
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### 17.1 P1 — 운영 배포에 TLS 와 인증을 **선언하라고 요구한 뒤**, 그 둘이 없는 생산자를 만든다
|
||||
|
||||
두 사실을 나란히 놓으면 보인다.
|
||||
|
||||
**검증기가 요구한다.** `KafkaProfileValidator`:
|
||||
|
||||
```java
|
||||
if (profile.production() && !profile.tlsEnabled()) {
|
||||
throw new IllegalArgumentException("a production Kafka connection requires TLS: " + profile.broker());
|
||||
}
|
||||
if (profile.production() && !profile.authenticationEnabled()) {
|
||||
throw new IllegalArgumentException("a production Kafka connection requires broker authentication: " + profile.broker());
|
||||
}
|
||||
```
|
||||
|
||||
그리고 이 리프의 `kafkaProfileStartupValidation` 이 그것을 설정에서 컴파일된 프로파일에도 실제로 돌린다. 전용 테스트가 있다 — `aProductionKafkaBrokerWithoutTransportSecurityFailsStartup`.
|
||||
|
||||
**조립되는 생산자에는 그 둘이 없다.** `KafkaMessagingAutoConfiguration.messagingKafkaProducer`:
|
||||
|
||||
```java
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put(BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
||||
config.put(KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class);
|
||||
config.put(VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class);
|
||||
config.put(ACKS_CONFIG, "all");
|
||||
config.put(ENABLE_IDEMPOTENCE_CONFIG, true);
|
||||
return new KafkaProducer<>(config);
|
||||
```
|
||||
|
||||
다섯 항목이 전부다. `security.protocol` 이 없으므로 Kafka 클라이언트의 기본값 `PLAINTEXT` 로 접속한다.
|
||||
|
||||
**그 둘을 만드는 코드는 있고, 아무도 부르지 않는다.** `KafkaSecurityConfigurer.configure(...)` 가 정확히 다섯을 만든다.
|
||||
|
||||
```java
|
||||
properties.put(SECURITY_PROTOCOL, securityProtocol(profile, credential)); // SASL_SSL | SSL | SASL_PLAINTEXT | PLAINTEXT
|
||||
if (profile.tlsEnabled()) {
|
||||
properties.put(ENABLED_PROTOCOLS, String.join(",", enabledProtocols));
|
||||
properties.put(ENDPOINT_IDENTIFICATION, "https");
|
||||
}
|
||||
… properties.put(SASL_MECHANISM, "SCRAM-SHA-512");
|
||||
properties.put(SASL_JAAS_CONFIG, scramJaas(scram.credentialId(), resolved));
|
||||
```
|
||||
|
||||
이 클래스를 언급하는 production 코드는 저장소 전체에서 이 리프의 빈 선언 한 줄뿐이다. 나머지 참조는 자기 자신과 `KafkaSecurityConfigurerTest` 다.
|
||||
|
||||
**그래서 배포가 겪는 것.**
|
||||
|
||||
1. `app.messaging.brokers.k.production=true` 를 쓴다.
|
||||
2. 검증기가 `tls-enabled=true` 와 `authentication-enabled=true` 를 요구한다.
|
||||
3. 운영자가 둘을 켜고, `app.messaging.security.k` 에 SASL 자격 식별자를 적고, `CredentialProvider` 빈을 공급한다. 시작이 통과한다.
|
||||
4. 만들어진 생산자는 평문·무인증으로 접속한다.
|
||||
|
||||
세 검증(`KafkaProfileValidator`·`MessagingCredentialRequirementValidator`·`BrokerTlsPolicy`)이 전부 통과하고, 통과의 대상이 실제 연결이 아니다. 보안을 요구하지 않는 브로커에는 인증 없이 붙고, 요구하는 브로커에는 첫 발행에서 실패한다 — 어느 쪽도 "선언한 대로 접속했다" 가 아니다.
|
||||
|
||||
**테스트가 이것을 볼 수 없는 이유.** 조립을 확인하는 두 테스트(`selectingKafkaAssemblesOnlyKafka`·`aSelectedTransportAssemblesAPublisher`)는 빈의 존재만 단언한다. 유일한 실 브로커 시험 `MessagingLiveRoundTripQualificationTest` 는 보안 없는 `KafkaContainer` 에 `production=false` 프로파일로 붙는다. 즉 이 플랫폼이 실제로 증명한 왕복은 평문 왕복 하나다.
|
||||
|
||||
**수정.** `messagingKafkaProducer` 가 `KafkaSecurityConfigurer` 와 선택된 브로커의 `BrokerSecurityProfile` 을 받아 `config.putAll(configurer.configure(profile, profile.producerCredential(), protocols, now))` 를 하는 것이다. 자격 회전이 목적이라면 생산자 하나를 고정 설정으로 만드는 형태 자체를 다시 봐야 한다 — `KafkaSecurityConfigurer` 의 javadoc 이 그 이유를 이미 적어 두었다.
|
||||
|
||||
> "a client configured from a value read once at startup holds that value until the process
|
||||
> restarts, so the rotation the credential store performs never reaches the broker connection."
|
||||
|
||||
지금 조립되는 생산자가 정확히 그 형태이고, 심지어 한 번 읽지도 않는다.
|
||||
|
||||
### 17.2 P2 — 같은 자동 설정 안에서 검증기 하나만 감싸이지 않는다
|
||||
|
||||
`KafkaMessagingAutoConfiguration` 은 검증기 셋을 만든다.
|
||||
|
||||
```java
|
||||
@Bean public KafkaProfileValidator kafkaProfileValidator() { … }
|
||||
@Bean public StartupProfileValidation<KafkaBrokerProfile> kafkaProfileStartupValidation(…) { … } // ← 감싼다
|
||||
@Bean public KafkaTransactionProfileValidator kafkaTransactionProfileValidator() { … }
|
||||
@Bean public KafkaPublishFailureClassifier kafkaPublishFailureClassifier() { … }
|
||||
```
|
||||
|
||||
`KafkaTransactionProfileValidator` 에는 대응하는 `StartupProfileValidation` 이 없다. 즉 컨텍스트가 그 검증기를 발행하고 아무도 주입하지 않는다 — `StartupProfileValidation` 의 javadoc 이 서술한 이전 상태와 정확히 같은 형태다.
|
||||
|
||||
`RabbitMessagingAutoConfiguration` 은 검증기 하나이고 그것을 감싼다. 그러므로 이 가족에서 감싸이지 않은 검증기는 이 하나다.
|
||||
|
||||
트랜잭션 프로파일 검증이 무엇을 막는지는 그 클래스가 안다 — 비트랜잭션 생산자 위의 정확히 한 번 주장 같은 조합이다. 그 검증이 지금 돌지 않는다.
|
||||
|
||||
수정은 한 블록이다. 같은 파일의 `kafkaProfileStartupValidation` 형태를 복사해 세 번째 검증기를 감싼다.
|
||||
|
||||
### 17.3 P2 — 출고되는 신뢰성 체인 전체가 아무도 공급하지 않는 빈 뒤에 있고, 그 사슬이 자기 클래스 안을 가리킨다
|
||||
|
||||
```java
|
||||
@Bean @ConditionalOnBean({OutboxRepository.class, OutboxEnvelopeFactory.class}) public OutboxRelay outboxRelay(…)
|
||||
@Bean @ConditionalOnBean(OutboxRelay.class) public OutboxRelayWorker outboxRelayWorker(…)
|
||||
@Bean @ConditionalOnBean(OutboxRelayWorker.class) public MessagingOutboxRelayLifecycle outboxRelayLifecycle(…)
|
||||
@Bean @ConditionalOnBean(OutboxRepository.class) public OutboxCleanupJob outboxCleanupJob(…)
|
||||
@Bean @ConditionalOnBean(InboxRepository.class) public InboxCleanupJob inboxCleanupJob(…)
|
||||
@Bean @ConditionalOnBean(IdempotentConsumer.class) public TransactionalInboxHandler<Object> transactionalInboxHandler(…)
|
||||
```
|
||||
|
||||
**공급자가 없다.** 여섯 빈 전부가 애플리케이션이 공급해야 하는 타입에 걸려 있다. 조건 자체는 옳고 근거도 정확하다 — 플랫폼이 기본 구현을 주면 조용히 엉뚱한 곳에, 또는 아무 데도 쓰지 않게 된다. 문제는 저장소 안에 그 타입을 공급하는 코드가 없다는 것이다. `messaging-outbox-jdbc-postgresql` 의 `JdbcOutboxRepository` 는 스프링 스테레오타입도 `@Bean` 선언도 없고, `new JdbcOutboxRepository` 가 main 에 0 건이다. 그래서 이 스타터를 켠 배포는 발행 경로는 얻고 발신함 경로는 얻지 못하며, 그 사실이 시작 시점에 어떤 신호도 내지 않는다.
|
||||
|
||||
**사슬이 자기 클래스 안을 가리킨다.** 둘째와 셋째가 같은 설정 클래스 안에서 방금 선언된 빈의 존재를 조건으로 삼는다. 스프링은 `@ConditionalOnBean` 을 자동 설정 클래스에서만, 그리고 등록 순서에 의존하는 방식으로만 신뢰할 수 있다고 문서화한다. 지금은 첫 조건이 이미 거짓이라 결과가 드러나지 않는다. 발신함을 배선하는 순간 이 사슬이 실제로 평가된다.
|
||||
|
||||
같은 가족의 다른 결정과 대비된다. 관리 평면은 스위치가 켜졌을 때 만들어지지 **않는** 타입의 부재를 javadoc 에 명시한다(`DestructiveMessagingAdmin` 하나). 이쪽은 여섯이 조용히 빠진다.
|
||||
|
||||
수정은 둘이다. 발신함을 요구하는 설정에서 저장소 빈이 없으면 시작을 거부하는 검증(이 가족의 `StartupProfileValidation` 형태), 그리고 중계·작업자·수명을 하나의 `@Bean` 으로 합치거나 조건을 전부 최초 두 타입으로 표현하는 것.
|
||||
|
||||
### 17.4 P3 — 죽은 매개변수 하나가 유일한 비기본값에서 NPE 를 낳는다
|
||||
|
||||
```java
|
||||
private static BrokerCredentialProfile credential(
|
||||
String broker, String role, BrokerSecuritySettings.Credential credential, Supplier<Boolean> required) {
|
||||
if (credential == null && Boolean.TRUE.equals(required.get())) {
|
||||
throw configurationError(key("security", broker, role), "a configured broker needs a %s credential; …");
|
||||
}
|
||||
String type = credential.type() == null ? "" : credential.type().toUpperCase(Locale.ROOT);
|
||||
```
|
||||
|
||||
호출처가 셋이고 전부 `() -> true` 다.
|
||||
|
||||
```java
|
||||
credential(name, "producer", security.producer(), () -> true),
|
||||
credential(name, "consumer", security.consumer(), () -> true),
|
||||
Optional.ofNullable(security.admin()).map(admin -> credential(name, "admin", admin, () -> true))
|
||||
```
|
||||
|
||||
그래서 이 매개변수는 값을 하나만 갖는다. 그리고 그것이 죽어 있다는 것보다 나쁜 성질이 있다 — 이 매개변수가 존재하는 이유("이 역할은 선택적이다")대로 `() -> false` 를 넘기면 `credential == null` 인 경로가 가드를 지나 다음 줄의 `credential.type()` 에서 NPE 로 죽는다. 즉 이 매개변수의 유일한 비기본값이 의도한 동작이 아니라 널 역참조다.
|
||||
|
||||
수정은 매개변수를 지우고 널 검사를 무조건으로 만드는 것이다. 선택적 역할이 필요해지는 날에는 `Optional` 을 돌려주는 별도 메서드가 그 자리다 — `admin` 이 이미 호출처에서 그렇게 다뤄진다.
|
||||
|
||||
### 17.5 P3 — 설정 경로의 재시도가 예외 분류를 표현할 수 없다
|
||||
|
||||
`RetryPolicy` 는 성분 열이고 그중 둘이 분류 집합이다.
|
||||
|
||||
```java
|
||||
Set<FailureCategory> retryableCategories, // "categories added to the retryable set"
|
||||
Set<FailureCategory> nonRetryableCategories, // "categories removed from the retryable set"
|
||||
```
|
||||
|
||||
`DestinationSettings.Retry` 에는 이 둘에 대응하는 키가 없고, 컴파일러가 상수로 채운다.
|
||||
|
||||
```java
|
||||
return new RetryPolicy(retry.mode(), retry.maxAttempts(), retry.initialDelay(), retry.maxDelay(),
|
||||
retry.multiplier(), retry.jitter(), retry.orderingImpact(),
|
||||
Set.of(), Set.of(), // ← 설정으로 표현할 수 없다
|
||||
Optional.ofNullable(blankToNull(retry.destination())).map(DestinationName::new));
|
||||
```
|
||||
|
||||
빈 집합은 "기본 분류 그대로" 라는 중립값이므로 오동작은 아니다. 문제는 비대칭이다. `DestinationProfile` 을 자바로 선언한 배포는 두 집합을 조정할 수 있고, 문서대로 YAML 로 설정한 배포는 할 수 없다. 이 리프가 반복해서 근거로 든 규칙이 정확히 그 비대칭을 금지한다.
|
||||
|
||||
> "Which half went unchecked would depend on how the deployment happened to be written, which is the
|
||||
> worst possible rule."
|
||||
|
||||
수정은 `Retry` 에 두 키를 더하는 것이다. `FailureCategory` 는 열거이므로 relaxed binding 이 그대로 처리한다.
|
||||
|
||||
### 17.6 P3 — 배치 발행자가 `CompletionStage` 를 돌려주면서 동기 예외를 던진다
|
||||
|
||||
```java
|
||||
public CompletionStage<BatchPublishResult> publish(List<PublishRequest<?>> requests, BatchPublishOptions options) {
|
||||
…
|
||||
if (requests.size() > options.maxBatchSize()) {
|
||||
throw new MessageTooLargeException("BATCH_COUNT_EXCEEDED", …); // ← 스테이지가 아니라 던진다
|
||||
}
|
||||
```
|
||||
|
||||
같은 클래스가 자기 의존 대상에 대해서는 정확히 이 형태를 방어한다.
|
||||
|
||||
```java
|
||||
} catch (RuntimeException synchronousFailure) {
|
||||
// A publisher that validates eagerly throws instead of returning a failed stage. Converting
|
||||
// it here keeps the "one result per index" contract that the caller resubmits from.
|
||||
```
|
||||
|
||||
즉 "게으르게 검증하고 실패한 스테이지를 돌려준다" 가 이 클래스가 아는 계약인데, 자기 호출자에게는 그것을 지키지 않는다. 비동기 파이프라인으로 배치를 부르는 코드는 `.exceptionally(...)` 로 잡히지 않는 예외를 만난다.
|
||||
|
||||
등급이 P3 인 이유는 이것이 프로그래밍 오류(배치 크기 초과)이고 결과가 손실이 아니라 예외 형태의 불일치이기 때문이다. 전용 테스트(`aBatchLargerThanItsLimitIsRefusedBeforeAnythingIsPublished`)가 `assertThatThrownBy` 로 현재 동작을 고정하고 있으므로, 고치려면 그 테스트도 함께 바꾼다.
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- **하나의 뿌리가 마스터 조건을 소유하고 자식은 조건을 갖지 않는 것.**
|
||||
- **제공자 선택을 클래스패스 사고가 아니라 닫힌 레지스트리의 속성으로 만든 것.**
|
||||
- **등록과 조립 가능을 분리하고, 조립 못 하는 전송을 선택 단계에서 이유와 함께 거부한 것.**
|
||||
- **꺼진 상태의 계약을 빈 이름과 살아 있는 스레드로 붙든 것** — 빈 목록만으로는 "꺼짐" 이 증명되지 않는다.
|
||||
- **시작 검증을 `afterPropertiesSet` 으로 돌린 것과 그 이유.**
|
||||
- **프로파일을 두 출처에서 모으는 `Supplier` 를 쓴 것과 그 근거.**
|
||||
- **모든 설정 거부가 타입이 아니라 키를 부르는 것.**
|
||||
- **바인딩되지 않는 키를 record 성분에서 파생해 거부한 것** — 목록을 손으로 적으면 쓰는 날에만 맞는다.
|
||||
- **환경변수를 키 검증에서 제외하고 그 이유를 적은 것** — 밑줄 경계를 되돌릴 방법이 없고, 추측은 정상 배포를 거부한다.
|
||||
- **설정 참조 문서를 실행 가능한 진술로 만든 것.**
|
||||
- **가짜 발행자가 조립 불가를 가린 사례를 테스트 주석에 남기고 가드 테스트를 붙인 것.**
|
||||
- **저장소 기본 구현을 제공하지 않기로 한 판단과 그 근거.**
|
||||
- **정리 작업은 스케줄러를 등록하지 않고 중계는 구동하는 비대칭과 각각의 이유.**
|
||||
- **두 수명 주기의 phase 로 종료 순서를 표현한 것과 서로를 근거로 든 javadoc.**
|
||||
- **배수 예산을 임차 기간으로 둔 것** — 그보다 오래 기다려도 증명되는 것이 없다.
|
||||
- **`MessageContracts` 를 맨 `Map` 빈이 아니라 홀더로 만든 것** — 스프링에서 `Map` 은 중립적인 주입 타입이 아니다.
|
||||
- **메시지 계약 기본값을 빈 것으로 두어 fail-closed 로 만든 것.**
|
||||
- **actuator 끝점을 읽기 전용으로 두고 그것을 리플렉션으로 붙든 것.**
|
||||
- **JAAS 값 이스케이프와 제어문자 거부**(`KafkaSecurityConfigurer`) — 지금은 아무도 부르지 않지만 코드 자체는 옳다.
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
```
|
||||
src/messaging/messaging-spring-boot-starter/build.gradle:1-66
|
||||
main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:1
|
||||
main/java/…/autoconfigure/MessagingCoreAutoConfiguration.java:1-480
|
||||
main/java/…/autoconfigure/MessagingConfigurationCompiler.java:1-331
|
||||
main/java/…/autoconfigure/MessagingSettings.java:1-301
|
||||
main/java/…/autoconfigure/DefaultBatchMessagePublisher.java:1-254
|
||||
main/java/…/autoconfigure/MessagingConfigurationKeyValidator.java:1-227
|
||||
main/java/…/autoconfigure/MessagingReliabilityAutoConfiguration.java:1-185
|
||||
main/java/…/autoconfigure/DestinationSettings.java:1-177
|
||||
main/java/…/autoconfigure/KafkaMessagingAutoConfiguration.java:1-170
|
||||
main/java/…/autoconfigure/MessagingProviderSelection.java:1-150
|
||||
main/java/…/autoconfigure/MessagingShutdownLifecycle.java:1-124
|
||||
main/java/…/autoconfigure/RabbitMessagingAutoConfiguration.java:1-98
|
||||
main/java/…/autoconfigure/MessagingCredentialRequirementValidator.java:1-92
|
||||
main/java/…/autoconfigure/MessagingAdminAutoConfiguration.java:1-86
|
||||
main/java/…/autoconfigure/MessagingPrefixMigrationValidator.java:1-83
|
||||
main/java/…/autoconfigure/MessagingEndpoint.java:1-78
|
||||
main/java/…/autoconfigure/PublishResults.java:1-71
|
||||
main/java/…/autoconfigure/BrokerSettings.java:1-69
|
||||
main/java/…/autoconfigure/MessagingOutboxRelayLifecycle.java:1-69
|
||||
main/java/…/autoconfigure/MessagingAdminDurabilityValidator.java:1-68
|
||||
main/java/…/autoconfigure/DefaultBlockingMessagePublisher.java:1-65
|
||||
main/java/…/autoconfigure/ValidatedDestinationRegistry.java:1-59
|
||||
main/java/…/autoconfigure/CompiledMessagingConfiguration.java:1-53
|
||||
main/java/…/autoconfigure/BrokerSecuritySettings.java:1-50
|
||||
main/java/…/autoconfigure/StartupProfileValidation.java:1-46
|
||||
main/java/…/autoconfigure/DefaultReactiveMessagePublisher.java:1-39
|
||||
main/java/…/autoconfigure/MessagingPlatformRootAutoConfiguration.java:1-36
|
||||
main/java/…/autoconfigure/MessageContracts.java:1-35
|
||||
main/java/…/autoconfigure/ReactiveMessagePublisher.java:1-32
|
||||
test/java/…/autoconfigure/{MessagingAutoConfigurationTest:546, MessagingConfigurationBindingTest:320,
|
||||
BatchPublisherTest:316, MessagingStarterOffContractTest:254, MessagingLiveRoundTripQualificationTest:217,
|
||||
MessagingOutboxRelayLifecycleTest:203, BlockingFacadeTest:148, ReactiveFacadeTest:134,
|
||||
MessagingEndpointTest:133, MessagingShutdownLifecycleTest:78}
|
||||
messaging-kafka/…/KafkaSecurityConfigurer.java:1-173 (§17.1 — 부르는 곳 없음)
|
||||
messaging-kafka/…/KafkaProfileValidator.java:47-56 (§17.1 — 운영 TLS·인증 요구)
|
||||
messaging-kafka/…/KafkaMessagingTransport.java:1-215 (§17.1 — 생산자를 감싸기만 한다)
|
||||
messaging-policy/…/RetryPolicy.java:28-37 (§17.5)
|
||||
app-bootstrap/src/main/resources/application*.yml (§12.1 — app.messaging.enabled 부재)
|
||||
messaging-outbox-jdbc-postgresql/…/JdbcOutboxRepository.java (§17.3 — 공급자 부재)
|
||||
```
|
||||
@@ -0,0 +1,635 @@
|
||||
# messaging-spring-cloud-stream-bridge 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-spring-cloud-stream-bridge`
|
||||
> SSOT owner: `messaging-spring-cloud-stream-bridge`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- registered leaf id: `messaging-spring-cloud-stream-bridge`
|
||||
- canonical state `analysisFile`: `analysis/messaging/messaging-spring-cloud-stream-bridge.md`
|
||||
- source path: `src/messaging/messaging-spring-cloud-stream-bridge`
|
||||
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-policy", "messaging-transport-spi"]`
|
||||
- registry `runtime_memberships`: **`[]`** — build-only
|
||||
|
||||
### 숫자
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | 6 |
|
||||
| production LOC | 507 |
|
||||
| 패키지 | 1 (`dev.caskeleton.messaging.streambridge`) |
|
||||
| test 파일 | 2 |
|
||||
| test 메서드(실행 확인) | **20** |
|
||||
| 선언된 의존 | project 3 + vendor 1 |
|
||||
| **실제 import되는 의존** | **project 2** (§12.4) |
|
||||
|
||||
여섯 타입:
|
||||
|
||||
| 타입 | 종류 | 역할 | leaf 밖 참조 |
|
||||
|---|---|---|---:|
|
||||
| `MessagingBindingBridge` | interface | 논리 목적지 ↔ Stream 바인딩 | 0 |
|
||||
| `SpringCloudStreamPublisherBridge` | class | 발행 측 + 위 인터페이스 구현 | 0 |
|
||||
| `SpringCloudStreamConsumerBridge` | class | 수신 측 | 0 |
|
||||
| `StreamBridgePolicyGuard` | class | 목적지가 브리지 대상인가 | 0 |
|
||||
| `BindingProfileValidator` | class | 바인딩 구성이 일관적인가 | 0 |
|
||||
| `BindingCapabilityReport` | record | 무엇을 보장하지 **않는가** | 0 |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `src/main/java/**` (6) | 6 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `src/test/java/**` (2) | 2 | `FULL_READ` | 20개 테스트명·단언 확인 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 9줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
Spring Cloud Stream 바인딩을 이미 쓰는 서비스가 같은 논리 목적지에 닿게 하는 **상호운용 seam**이다.
|
||||
|
||||
```java
|
||||
// MessagingBindingBridge.java:8-15
|
||||
* <p>The bridge is an interoperability seam, not a second messaging API. Its whole reason to exist
|
||||
* is that a service already has Stream bindings and needs to reach the same destinations without a
|
||||
* rewrite.
|
||||
*
|
||||
* <p>Binder semantics are never promoted to platform guarantees. Stream's binder has its own retry,
|
||||
* its own dead-letter, and its own acknowledgement mode, and they look enough like the platform's
|
||||
* to be mistaken for them — so a destination that actually relies on the platform's versions is
|
||||
* refused by {@link StreamBridgePolicyGuard} rather than served with the binder's.
|
||||
```
|
||||
|
||||
**"look enough like the platform's to be mistaken for them"**이 이 leaf 전체의 위협 모델이다. 브리지는 기능을 추가하지 않고 **차이를 드러낸다.**
|
||||
|
||||
세 층으로 그것을 한다.
|
||||
|
||||
| 층 | 무엇을 |
|
||||
|---|---|
|
||||
| `StreamBridgePolicyGuard` | 플랫폼 보장에 의존하는 목적지를 아예 거절 |
|
||||
| `BindingProfileValidator` | 바인더 확장 속성이 프로파일 결정을 덮는 것을 거절 |
|
||||
| `BindingCapabilityReport` | 남은 차이를 **문장으로** 기록 |
|
||||
|
||||
세 번째가 특이하다 — 거절할 수 없는 차이를 문서화 가능한 값으로 만든다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
**선언된 것과 쓰이는 것이 다르다.**
|
||||
|
||||
| 선언 | scope | 실제 import |
|
||||
|---|---|---|
|
||||
| `messaging-core-api` | api | **o** — `DestinationName`, `MessagingConfigurationException`, publish 6타입 |
|
||||
| `messaging-policy` | api | **o** — `DestinationProfile`, `RetryMode` |
|
||||
| `messaging-transport-spi` | api | **x** |
|
||||
| `org.springframework:spring-context` | implementation | **x** |
|
||||
|
||||
`grep -rn 'import dev.caskeleton.messaging.transport\|import org.springframework'` → exit 1.
|
||||
|
||||
**Spring Cloud Stream 브리지가 Spring을 import하지 않는다.** 바인더 접촉면 전체가 두 함수형 인터페이스로 추상화돼 있다 — `SpringCloudStreamPublisherBridge.ChannelSend`와 `SpringCloudStreamConsumerBridge.BridgedHandler`. javadoc이 그 목적을 적는다 — "isolated so the bridge is testable without a binder".
|
||||
|
||||
즉 **`spring-context` 의존은 실제 통합 코드가 있어야 필요했을 것**인데 그 코드가 없다. §12.4.
|
||||
|
||||
나가는 것: 없다. 어떤 leaf의 `allowed_dependencies`에도 이 leaf가 없고 starter 목록에도 없다.
|
||||
|
||||
런타임 배선: 없음. `runtime_memberships: []`. bean 없음.
|
||||
|
||||
**소비자 0 · membership `[]` · 조립 0의 삼중 정합** — `messaging-kafka-share-experimental`·`messaging-schema-avro`와 같은 상태다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
```
|
||||
게이트 (2단)
|
||||
StreamBridgePolicyGuard.validate(profile, enabled)
|
||||
├── !enabled → STREAM_BRIDGE_DISABLED
|
||||
├── isOrdered() → STREAM_BRIDGE_ORDERING_UNSUPPORTED
|
||||
├── retry != NONE → STREAM_BRIDGE_RETRY_UNSUPPORTED
|
||||
└── deadLetter on → STREAM_BRIDGE_DLQ_UNSUPPORTED
|
||||
↓ (통과 후)
|
||||
BindingProfileValidator.validate(profile, bindingName, extendedProperties, enabled)
|
||||
├── guard.validate(...) ← 위임
|
||||
├── 바인딩 이름 패턴 → INVALID_BINDING_NAME
|
||||
├── 충돌 확장 속성 8개 → BINDING_OVERRIDES_PLATFORM_POLICY
|
||||
├── profile.production() → BRIDGE_ON_PRODUCTION_DESTINATION
|
||||
└── → BindingCapabilityReport.bridged(...) ← 네 보장 전부 false
|
||||
|
||||
발행
|
||||
SpringCloudStreamPublisherBridge(ChannelSend) implements MessagingBindingBridge
|
||||
├── bindPublisher / bindConsumer ← 두 맵
|
||||
└── publish(dest, payload, headers)
|
||||
├── 바인딩 없음 → NO_OUTPUT_BINDING
|
||||
├── send == true → AMBIGUOUS (STREAM_BRIDGE_NO_BROKER_EVIDENCE)
|
||||
└── send == false → REJECTED (STREAM_BRIDGE_SEND_REFUSED)
|
||||
|
||||
수신
|
||||
SpringCloudStreamConsumerBridge ← MessagingBindingBridge를 구현하지 않음
|
||||
├── register(dest, binding, BridgedHandler)
|
||||
└── dispatch(binding, payload, headers)
|
||||
├── 미등록 → NO_BRIDGED_HANDLER
|
||||
└── handler.handle(...) ← 예외를 잡지 않음
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
### 4.1 `StreamBridgePolicyGuard` — 의존하는 순간 거절
|
||||
|
||||
```java
|
||||
// :10-17
|
||||
* <p>The bridge exists for interoperability with existing Spring Cloud Stream bindings, and its
|
||||
* risk is specific: Stream owns its own binder configuration, so a binding can quietly acquire its
|
||||
* own serializer, its own error handling, and its own acknowledgement mode — none of which the
|
||||
* destination profile knows about.
|
||||
*
|
||||
* <p>So the bridge is only permitted where the platform's guarantees are not the thing being relied
|
||||
* on: a destination that declares an ordering scope, a retry policy, or a dead letter destination
|
||||
* must go through the native adapter, where those are actually enforced.
|
||||
```
|
||||
|
||||
**세 거절이 `DestinationProfile`의 세 필드를 직접 본다.**
|
||||
|
||||
| 조건 | 코드 |
|
||||
|---|---|
|
||||
| `profile.isOrdered()` — `orderingScope != NONE` | `STREAM_BRIDGE_ORDERING_UNSUPPORTED` |
|
||||
| `profile.retry().mode() != RetryMode.NONE` | `STREAM_BRIDGE_RETRY_UNSUPPORTED` |
|
||||
| `profile.deadLetter().enabled()` | `STREAM_BRIDGE_DLQ_UNSUPPORTED` |
|
||||
|
||||
즉 **`messaging-policy`가 정의한 세 보장 각각에 대해 "이것을 선언했으면 브리지를 쓸 수 없다"**를 강제한다. 세 코드 전부 `MessagingConfigurationException`이고 안정 코드를 갖는다 — `messaging-kafka-share-experimental`이 두 거절에 다른 예외 타입을 쓴 것(그쪽 §17)과 대비된다.
|
||||
|
||||
`!enabled`도 같은 예외 타입이다 — 일관적이다.
|
||||
|
||||
### 4.2 `BindingProfileValidator` — 확장 속성을 병합하지 않는다
|
||||
|
||||
```java
|
||||
// :16-20
|
||||
* <p>The binder's extended properties are the sharp edge. Stream lets a binding override the
|
||||
* serializer, the acknowledgement mode, and the concurrency, and each of those silently replaces
|
||||
* something the destination profile already decided. Rather than merging the two — which produces a
|
||||
* configuration nobody can read — a conflicting extended property is rejected and the operator is
|
||||
* told which side to remove.
|
||||
```
|
||||
|
||||
거절 목록 8개:
|
||||
|
||||
| 속성 | 무엇을 덮는가 |
|
||||
|---|---|
|
||||
| `autoBindDlq`, `republishToDlq` | DLQ 정책 |
|
||||
| `maxAttempts`, `backOffInitialInterval` | 재시도 정책 |
|
||||
| `autoCommitOffset`, `ackMode` | 정산 |
|
||||
| `useNativeEncoding`, `contentType` | codec |
|
||||
|
||||
에러 메시지가 **두 선택지를 명시한다** — "remove it from the binding or move the destination to the native adapter". 무엇을 하라고만 하지 않고 어느 쪽을 포기할지를 준다.
|
||||
|
||||
**production 목적지는 무조건 거절한다.**
|
||||
|
||||
```java
|
||||
if (profile.production()) {
|
||||
throw new MessagingConfigurationException(
|
||||
"BRIDGE_ON_PRODUCTION_DESTINATION",
|
||||
"destination %s is marked production; the bridge does not carry the platform's publish "
|
||||
+ "evidence, retry, or confirmed dead lettering");
|
||||
}
|
||||
```
|
||||
|
||||
guard의 세 조건을 통과한 목적지(순서 없음·재시도 없음·DLQ 없음)라도 production이면 막는다. **네 번째 게이트**다.
|
||||
|
||||
바인딩 이름 패턴 `[a-zA-Z][a-zA-Z0-9-]{0,63}` — 언더스코어와 점을 배제한다.
|
||||
|
||||
### 4.3 `BindingCapabilityReport` — 부재를 값으로
|
||||
|
||||
```java
|
||||
// :8-14
|
||||
* <p>An explicit report rather than silence. The binder does provide retry and dead-lettering of
|
||||
* its own, so a binding looks like it has them; what it does not have is the platform's versions —
|
||||
* bounded attempts under the destination's retry policy, and a dead-letter publish confirmed before
|
||||
* the source is settled. An operator comparing a bridged binding to a native one needs that
|
||||
* difference written down, because nothing at runtime will show it.
|
||||
```
|
||||
|
||||
**"nothing at runtime will show it"**이 이 record가 존재하는 이유다.
|
||||
|
||||
네 boolean과 두 factory:
|
||||
|
||||
| factory | 네 값 |
|
||||
|---|---|
|
||||
| `bridged(destination, bindingName)` | 전부 `false` |
|
||||
| `nativeAdapter(destination, bindingName)` (`BindingProfileValidator`의 static) | 전부 `true` |
|
||||
|
||||
`gaps()`가 각 `false`마다 **문장 하나**를 만든다.
|
||||
|
||||
| 결여 | 문장 |
|
||||
|---|---|
|
||||
| publish evidence | "the binder reports a send, not a broker confirmation, so an ambiguous publish is indistinguishable from a confirmed one" |
|
||||
| retry | "the binder's own retry runs instead of the destination's retry policy, with its own attempt budget and backoff" |
|
||||
| dead letter | "the binder settles the source without waiting for the dead-letter publish to confirm, so a dead-letter outage loses the message" |
|
||||
| ordering | "the binder's concurrency settings decide ordering, not the profile" |
|
||||
|
||||
**각 문장이 결과까지 적는다** — "indistinguishable", "loses the message". 상태 플래그가 아니라 운영자가 읽는 진술이다.
|
||||
|
||||
`isFullyGuaranteed()`가 `gaps().isEmpty()`다 — 매 호출마다 네 문장을 다시 만든다. 성능 문제는 아니지만 순수 조회가 문자열을 할당한다.
|
||||
|
||||
### 4.4 `SpringCloudStreamPublisherBridge` — 가장 정직한 결과
|
||||
|
||||
```java
|
||||
// :20-27
|
||||
* <p>The result is deliberately {@code AMBIGUOUS} rather than {@code CONFIRMED}. A Stream {@code
|
||||
* send} returns a boolean from the message channel — it says the binder accepted the message, not
|
||||
* that a broker did. Reporting that as confirmed would put the platform's strongest word on the
|
||||
* binder's weakest evidence, and a caller reading {@code CONFIRMED} would stop worrying about a
|
||||
* message that may never have left the process.
|
||||
*
|
||||
* <p>A caller that needs real publish evidence has to use the native adapter. That is the honest
|
||||
* trade the bridge exists to make visible.
|
||||
```
|
||||
|
||||
`accepted == true`일 때의 결과:
|
||||
|
||||
```java
|
||||
PublishCompletion.AMBIGUOUS,
|
||||
new PublishEvidence(true, TransmissionEvidence.MAY_HAVE_BEEN_TRANSMITTED, false, ConfirmationLevel.NONE),
|
||||
RoutingOutcome.UNKNOWN,
|
||||
...
|
||||
FailureDescriptor.of(FailureCategory.AMBIGUOUS, "STREAM_BRIDGE_NO_BROKER_EVIDENCE", ...)
|
||||
```
|
||||
|
||||
**`messaging-core-api`의 `PublishResult` 14개 금지 조합을 전부 통과하도록 정확히 구성돼 있다** — `AMBIGUOUS`는 `confirmationLevel == NONE`, `brokerAccepted == false`, `transmission != NOT_TRANSMITTED`, `routingOutcome != ROUTED`, `failure.isPresent()`를 요구하고 다섯 다 만족한다.
|
||||
|
||||
`accepted == false`는 `REJECTED` + `notTransmitted()` + `TRANSIENT_INFRASTRUCTURE` — 채널이 거부했으므로 아무것도 나가지 않았고, 일시적 문제일 수 있으므로 재시도 가능하다.
|
||||
|
||||
**두 결과가 core-api의 3상태를 정확히 쓴다.** 이 저장소에서 `AMBIGUOUS`를 의도적으로 생성하는 몇 안 되는 지점이다.
|
||||
|
||||
`Duration.ZERO`를 elapsed로 넣는다 — 측정하지 않는다. `PublishResult`가 음수만 거절하므로 통과한다.
|
||||
|
||||
### 4.5 `SpringCloudStreamConsumerBridge` — 정산하지 않는다
|
||||
|
||||
```java
|
||||
// :12-18
|
||||
* <p>Settlement stays with the binder. The bridge cannot acknowledge, retry, or dead-letter a
|
||||
* message itself, because Stream's binder already owns the acknowledgement for that binding and two
|
||||
* things settling one message is worse than either doing it alone.
|
||||
*
|
||||
* <p>What the bridge does own is the translation and the honesty about it: a handler failure is
|
||||
* rethrown so the binder's error channel sees it, rather than being converted into a platform
|
||||
* {@code HandleResult} that nothing downstream would act on.
|
||||
```
|
||||
|
||||
`dispatch`가 핸들러 예외를 잡지 않는다.
|
||||
|
||||
```java
|
||||
// Not caught. The binder's error channel is what retries and dead-letters this binding, and
|
||||
// swallowing the failure here would acknowledge a message nothing handled.
|
||||
handler.handle(destination, payload, headers);
|
||||
```
|
||||
|
||||
**`HandleResult`를 만들지 않는 것이 결정이다.** javadoc이 "nothing downstream would act on"이라고 적는데, 이것은 `messaging-runtime-core`의 `DefaultDeliveryProcessor`가 조립되지 않았다는 사실과 정합한다(`analysis/messaging/messaging-runtime-core.md` §12.1a) — 이 leaf가 그 사실을 알고 쓰였다.
|
||||
|
||||
두 `ConcurrentHashMap`(handlers, destinations)이 바인딩 이름을 키로 한다. **두 맵이 함께 갱신되지만 원자적이지 않다** — `register`가 `handlers.put` 후 `destinations.put`을 한다. 그 사이에 `dispatch`가 들어오면 handler는 있고 destination은 없어 `NO_BRIDGED_HANDLER`가 난다. 안전한 방향이다(잘못된 목적지로 전달하지 않는다). §17.
|
||||
|
||||
### 4.6 `MessagingBindingBridge` — 구현이 한쪽뿐
|
||||
|
||||
인터페이스가 `bindPublisher`와 `bindConsumer` 둘을 선언한다. **`SpringCloudStreamPublisherBridge`가 둘 다 구현하고, `SpringCloudStreamConsumerBridge`는 이 인터페이스를 구현하지 않는다.**
|
||||
|
||||
결과: `bindConsumer`가 publisher 쪽 `inputBindings` 맵에 기록되고, 실제 수신 등록(`register`)은 consumer 쪽에서 따로 일어난다. 두 클래스가 같은 바인딩에 대해 각자 상태를 갖는다. §17.
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
**검증:** `validator.validate(profile, bindingName, extendedProperties, enabled)` → guard 4검사 → 이름 → 속성 8개 → production → `BindingCapabilityReport.bridged(...)`
|
||||
|
||||
**발행:** `bridge.bindPublisher(dest, binding)` → `bridge.publish(dest, payload, headers)` → `send.send(...)` → true면 `AMBIGUOUS`, false면 `REJECTED`
|
||||
|
||||
**수신:** `consumerBridge.register(dest, binding, handler)` → 바인더가 `dispatch(binding, payload, headers)` → `handler.handle(...)` (예외 그대로 전파)
|
||||
|
||||
---
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
| 코드 | 예외 | 위치 |
|
||||
|---|---|---|
|
||||
| `STREAM_BRIDGE_DISABLED` | `MessagingConfigurationException` | guard |
|
||||
| `STREAM_BRIDGE_ORDERING_UNSUPPORTED` | 같음 | guard |
|
||||
| `STREAM_BRIDGE_RETRY_UNSUPPORTED` | 같음 | guard |
|
||||
| `STREAM_BRIDGE_DLQ_UNSUPPORTED` | 같음 | guard |
|
||||
| `INVALID_BINDING_NAME` | 같음 | validator |
|
||||
| `BINDING_OVERRIDES_PLATFORM_POLICY` | 같음 | validator |
|
||||
| `BRIDGE_ON_PRODUCTION_DESTINATION` | 같음 | validator |
|
||||
| `NO_OUTPUT_BINDING` | 같음 | publisher bridge |
|
||||
| `NO_BRIDGED_HANDLER` | 같음 | consumer bridge |
|
||||
| `STREAM_BRIDGE_NO_BROKER_EVIDENCE` | (예외 아님) `PublishResult` `AMBIGUOUS` | publisher bridge |
|
||||
| `STREAM_BRIDGE_SEND_REFUSED` | (예외 아님) `PublishResult` `REJECTED` | publisher bridge |
|
||||
|
||||
**아홉 개의 구성 실패가 전부 `MessagingConfigurationException` + 안정 코드다.** 이 저장소 messaging family에서 예외 어휘가 가장 일관된 leaf다 — `messaging-security`(두 계층 혼용)·`messaging-kafka-share-experimental`(두 계층 혼용)·`messaging-policy`(검증기가 `IllegalArgumentException`)와 대비된다.
|
||||
|
||||
발행 결과 둘은 예외가 아니라 값이다 — `messaging-core-api`의 설계를 그대로 따른다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
트랜잭션 없음.
|
||||
|
||||
| 지점 | 도구 |
|
||||
|---|---|
|
||||
| `SpringCloudStreamPublisherBridge.outputBindings`/`inputBindings` | `ConcurrentHashMap` |
|
||||
| `SpringCloudStreamConsumerBridge.handlers`/`destinations` | `ConcurrentHashMap` |
|
||||
|
||||
각 맵은 스레드 안전하지만 **두 맵의 갱신이 원자적이지 않다**(§4.5). 정산이나 자원 해제가 없으므로 다른 동시성 지점은 없다.
|
||||
|
||||
`StreamBridgePolicyGuard`·`BindingProfileValidator`는 상태가 없다(`BindingProfileValidator`가 guard 인스턴스를 필드로 하나 갖지만 그것도 무상태).
|
||||
|
||||
수명주기 참여 없음 — `close()`나 `stop()`이 없다. 등록된 핸들러를 해제하는 방법이 없다. §17.
|
||||
|
||||
---
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 프로퍼티 키(에러 메시지에만) | `backend.messaging.bridge.spring-cloud-stream` |
|
||||
| 바인딩 이름 패턴 | `[a-zA-Z][a-zA-Z0-9-]{0,63}` |
|
||||
| 충돌 확장 속성 | 8개 |
|
||||
|
||||
**그 프로퍼티를 읽는 코드가 저장소에 없다.** `enabled`는 `validate(...)`의 인자다. `messaging-kafka-share-experimental`의 `backend.messaging.experimental.kafka-share`와 같은 형태다(그쪽 §17).
|
||||
|
||||
상수 없음 — 두 패턴과 한 집합이 전부 private.
|
||||
|
||||
---
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
**없다.** Spring Cloud Stream 자체를 만지지 않는다 — 바인더 접촉면이 두 함수형 인터페이스(`ChannelSend`, `BridgedHandler`)로 추상화돼 있고 구현은 이 leaf 밖의 책임이다.
|
||||
|
||||
그래서 이 leaf는 **바인더 없이 전부 테스트 가능하다** — 20개 테스트가 실제로 그렇게 한다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
레인: `./gradlew :messaging:messaging-spring-cloud-stream-bridge:test`. **BUILD SUCCESSFUL, 20 tests, 0 skipped, 0 failures**.
|
||||
|
||||
| 클래스 | 수 | 무엇을 증명하는가 |
|
||||
|---|---:|---|
|
||||
| `BindingProfileValidatorTest` | 10 | 허용 목적지, 비활성 거절, 순서/DLQ/production 거절, 충돌 속성 거절, 무해한 속성 통과, 이름 거절, **브리지 리포트가 네 결여를 전부 보고**, native 리포트는 결여 없음 |
|
||||
| `BridgePublishEvidenceTest` | 10 | accepted → `AMBIGUOUS`, transmission unknown, descriptor가 결여를 이름, refused → `REJECTED`, 미바인딩 목적지 거절, payload 도달, 양방향 조회, **핸들러 실패가 바인더 error channel로 재던져짐**, 미등록 바인딩 거절, 핸들러가 바인딩된 목적지를 받음 |
|
||||
|
||||
**여섯 타입 전부가 테스트에 등장한다.** 이 leaf는 messaging family에서 **타입 대비 테스트 커버리지가 가장 고른** 축이다 — `messaging-kafka-share-experimental`(4타입 중 1개만)·`messaging-claim-check`(publisher 미검증)·`messaging-security`(12 중 5개 미검증)와 대비된다.
|
||||
|
||||
`aHarmlessBinderPropertyIsAllowedThrough`가 특히 중요하다 — 거절 목록이 **과잉 차단하지 않는다**는 반대 방향 확인이다. `messaging-core-api`의 자격증명 세그먼트 매칭 테스트(`aNameThatMerelyContainsTheLettersIsAccepted`)와 같은 규율이다.
|
||||
|
||||
**증명하지 않는 것:** 실제 Spring Cloud Stream 바인더와의 통합. `ChannelSend`·`BridgedHandler`가 fake이므로 바인더가 실제로 이 계약대로 동작하는지는 이 레인 밖이다. 그리고 그 통합 코드 자체가 이 저장소에 없다(§12.1).
|
||||
|
||||
---
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
| 게이트 | 이 leaf에 대해 |
|
||||
|---|---|
|
||||
| `verifyCleanArchitectureDependencies` | 세 project 의존 — **미사용 하나를 포함해 통과**(허용 목록은 상한) |
|
||||
| `verifyRuntimeModuleMembership` | `[]` |
|
||||
| vendor `api` 규칙 | Spring 타입이 public 시그니처에 없음 → `implementation`이 맞다. **다만 아예 쓰이지 않는다** |
|
||||
| `SecretLeakStaticScanTest`(observability leaf) | 이 leaf 소스도 스캔 대상 |
|
||||
| ArchUnit | 전용 규칙 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 실제 사용 여부와 negative-space probes
|
||||
|
||||
원시 증거: `evidence/raw/296-stream-bridge-unconsumed-and-unused-deps.txt`.
|
||||
|
||||
### 12.1 Public surface reachability
|
||||
|
||||
**여섯 타입 전부 leaf 밖 참조 0이다.**
|
||||
|
||||
`runtime_memberships: []`, starter 미포함, 조립 0건 — **삼중 정합**이다. incubating leaf가 이래야 하는 형태이고, `messaging-claim-check`·`messaging-cloudevents`가 어긋난 것과 대비된다.
|
||||
|
||||
**다만 이 leaf는 미완의 성격이 다르다.** 바인더 접촉면이 두 함수형 인터페이스로 추상화돼 있고 그 구현이 없다 — 즉 **Spring Cloud Stream과 실제로 연결하는 코드가 존재하지 않는다.** 이 leaf는 "브리지의 정책과 정직성"을 완성했고 "브리지 자체"는 없다.
|
||||
|
||||
그 사실이 `spring-context` 의존과 맞물린다(§12.4).
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
Spring 주석 0개, bean 없음.
|
||||
|
||||
**`MessagingTransport` 구현 sibling과의 비교:**
|
||||
|
||||
| leaf | 브로커 접촉 | membership |
|
||||
|---|---|---|
|
||||
| `messaging-kafka`·`messaging-rabbit` | `MessagingTransport` 구현 | `["app-bootstrap"]` |
|
||||
| `messaging-pulsar-experimental`·`messaging-nats-experimental` | `MessagingTransport` 구현 | `[]` |
|
||||
| `messaging-kafka-share-experimental` | 부분 구현(`TransportConsumerRegistration`) | `[]` |
|
||||
| **이 leaf** | **구현 없음 — 자체 인터페이스** | `[]` |
|
||||
|
||||
이 leaf는 `MessagingTransport`를 구현하지 **않는** 것이 의도다. 브리지는 transport가 아니라 **다른 프레임워크로의 seam**이고, 그래서 `MessagingBindingBridge`라는 자기 인터페이스를 갖는다. `messaging-transport-spi` 의존이 선언만 되고 쓰이지 않는 것이 그 판단과 정합한다 — 처음에 transport로 만들려다 방향을 바꾼 흔적으로 보인다(**추론**).
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
**(a) 활성화 플래그 패턴이 세 leaf에 있다**
|
||||
|
||||
| leaf | 키 | 전달 방식 |
|
||||
|---|---|---|
|
||||
| 이 leaf | `backend.messaging.bridge.spring-cloud-stream` | `validate(..., boolean enabled)` |
|
||||
| `messaging-kafka-share-experimental` | `backend.messaging.experimental.kafka-share` | `KafkaShareProfile.enabled` 필드 |
|
||||
| (pulsar·nats) | — | 각 leaf SSOT가 답함 |
|
||||
|
||||
두 키 모두 **에러 메시지에만 존재**하고 읽는 코드가 없다. 같은 형태의 미완이다.
|
||||
|
||||
**(b) capability 보고가 두 형태**
|
||||
|
||||
| 위치 | 형태 |
|
||||
|---|---|
|
||||
| `messaging-core-api` `MessagingCapabilities` | boolean 12개, 브로커가 **할 수 있는 것** |
|
||||
| 이 leaf `BindingCapabilityReport` | boolean 4개 + 문장, 브리지가 **하지 않는 것** |
|
||||
|
||||
**방향이 반대다.** 전자는 능력 선언이고 후자는 결여 진술이다. 그리고 후자만 사람이 읽는 문장을 만든다. 중복이 아니라 서로 다른 질문에 답한다 — 다만 `BindingCapabilityReport`의 네 boolean이 `MessagingCapabilities`의 어느 필드와도 대응하지 않아, 두 모델을 잇는 코드가 생기면 매핑을 새로 정해야 한다.
|
||||
|
||||
**(c) 순서·재시도·DLQ 거절이 여러 곳에**
|
||||
|
||||
| 위치 | 무엇을 거절 |
|
||||
|---|---|
|
||||
| `messaging-policy` `DestinationProfileValidator` | 프로파일 **내부** 모순(순서 + 재정렬 재시도 등) |
|
||||
| `messaging-kafka-share-experimental` `KafkaShareProfileValidator` | 순서 목적지를 share group에 |
|
||||
| 이 leaf `StreamBridgePolicyGuard` | 순서·재시도·DLQ를 **선언한** 목적지를 브리지에 |
|
||||
|
||||
셋이 다른 질문에 답한다 — 내부 일관성 / 어댑터 능력 / seam 적격성. 중복 아니다. 다만 셋 다 `DestinationProfile`의 같은 필드를 읽고 **서로를 참조하지 않는다.**
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
| 문서 주장 | 재측정 | 결과 |
|
||||
|---|---|---|
|
||||
| build.gradle: `messaging-transport-spi` 의존 | import 0건 | **미사용 의존** |
|
||||
| build.gradle: `spring-context` 의존 | `org.springframework` import 0건 | **미사용 의존** |
|
||||
| `MessagingBindingBridge` javadoc: "an interoperability seam" | 바인더 연결 코드 없음 | **미실현** |
|
||||
| `StreamBridgePolicyGuard` 에러 메시지: `backend.messaging.bridge.spring-cloud-stream=true` | 그 키를 읽는 코드 0건 | **미실현** |
|
||||
| `BindingCapabilityReport` javadoc: 운영자가 native와 비교할 수 있어야 함 | `nativeAdapter(...)` 호출자가 테스트뿐 | **부분 미실현** |
|
||||
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 실제로 `[]` | **이 leaf에 한해 참** |
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
이 leaf의 javadoc에 **이전 결함 서술이 없다.** 대신 막으려는 것을 다섯 적는다.
|
||||
|
||||
| 위치 | 막으려는 것 |
|
||||
|---|---|
|
||||
| `MessagingBindingBridge` | 바인더 의미론이 플랫폼 보장으로 승격되는 것 |
|
||||
| `StreamBridgePolicyGuard` | 바인딩이 자기 serializer·error handling·ack mode를 조용히 획득하는 것 |
|
||||
| `BindingProfileValidator` | 확장 속성과 프로파일을 병합해 "아무도 읽을 수 없는 구성"을 만드는 것 |
|
||||
| `BindingCapabilityReport` | 차이를 침묵으로 두는 것 — "nothing at runtime will show it" |
|
||||
| `SpringCloudStreamPublisherBridge` | 바인더의 가장 약한 증거에 플랫폼의 가장 강한 단어를 붙이는 것 |
|
||||
| `SpringCloudStreamConsumerBridge` | 두 주체가 한 메시지를 정산하는 것 |
|
||||
|
||||
**여섯 파일 중 여섯이 "하지 않는 것"을 서술한다.** 이 leaf는 기능이 아니라 **경계**로 구성돼 있다.
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-296 | command | `evidence/raw/296-stream-bridge-unconsumed-and-unused-deps.txt` | 여섯 타입 참조 0, membership `[]`, 선언 의존 4개와 실제 import 목록, transport-spi·spring-context import 0(exit=1), 인터페이스 구현이 publisher뿐, `nativeAdapter` 호출자가 테스트뿐 | 정적 검색 |
|
||||
| EVD-297 | command | `./gradlew :messaging:messaging-spring-cloud-stream-bridge:test --rerun-tasks` | BUILD SUCCESSFUL, 20 / 0 / 0 | 바인더 없이 fake로 검증 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적**
|
||||
|
||||
- 브리지가 두 번째 messaging API가 아닌 이유 — `MessagingBindingBridge` javadoc
|
||||
- 바인더 의미론을 승격하지 않는 이유 — 같은 javadoc
|
||||
- 플랫폼 보장에 의존하는 목적지를 거절하는 이유 — `StreamBridgePolicyGuard` javadoc
|
||||
- 확장 속성을 병합하지 않고 거절하는 이유 — `BindingProfileValidator` javadoc
|
||||
- 결여를 명시적 리포트로 만드는 이유 — `BindingCapabilityReport` javadoc
|
||||
- `AMBIGUOUS`가 유일하게 정직한 답인 이유 — `SpringCloudStreamPublisherBridge` javadoc
|
||||
- 정산이 바인더에 남는 이유, 예외를 재던지는 이유 — `SpringCloudStreamConsumerBridge` javadoc
|
||||
- `ChannelSend`를 분리한 이유("testable without a binder") — 그 인터페이스 javadoc
|
||||
|
||||
**추론**
|
||||
|
||||
- `messaging-transport-spi` 의존이 선언만 된 것은 처음에 transport로 만들려다 방향을 바꿨기 때문이다 → **추론**. 의존 선언과 미사용은 관측이고 인과는 추론이다.
|
||||
- `spring-context` 의존이 선언만 된 것은 바인더 통합 코드를 상정했기 때문이다 → **추론**.
|
||||
- `SpringCloudStreamConsumerBridge`가 `MessagingBindingBridge`를 구현하지 않는 것이 의도인지 → **미상**.
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- 6개 타입 507줄 전문
|
||||
- 20개 테스트가 통과하고 **여섯 타입 전부를 덮는다**는 것
|
||||
- 여섯 타입 전부 참조 0이고 membership `[]`과 정합한다는 것
|
||||
- `messaging-transport-spi`와 `spring-context`가 선언되고 import 0건이라는 것
|
||||
- 바인더 접촉면이 두 함수형 인터페이스로 추상화돼 있고 그 구현이 저장소에 없다는 것
|
||||
- 아홉 구성 실패가 전부 같은 예외 타입과 안정 코드를 쓴다는 것
|
||||
- 두 `PublishResult`가 core-api의 14개 금지 조합을 정확히 만족한다는 것
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- 실제 Spring Cloud Stream 바인더가 `ChannelSend`의 boolean 계약대로 동작하는지 — 바인더가 저장소에 없다.
|
||||
- `backend.messaging.bridge.spring-cloud-stream` 키가 어딘가 문서화돼 있는지.
|
||||
- `SpringCloudStreamConsumerBridge`에 해제 경로가 필요한지 — 바인더 수명주기를 모른다.
|
||||
- 이 leaf를 완성할 계획이 있는지.
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P3 — 선언된 의존 둘이 사용되지 않는다
|
||||
|
||||
- **사실.** registry가 `messaging-transport-spi`를 허용하고 `build.gradle`이 `spring-context`를 선언한다. main 소스의 비-JDK import 9개는 전부 `messaging-core-api`와 `messaging-policy`에서 온다. `import dev.caskeleton.messaging.transport` · `import org.springframework` 검색이 exit 1이다.
|
||||
- **근거.** `evidence/raw/296` §B.
|
||||
- **왜 문제인가.** `verifyCleanArchitectureDependencies`가 허용 목록을 **상한**으로 검사하므로 잡히지 않는다. 그리고 `spring-context` 선언이 "이 leaf가 Spring과 통합돼 있다"는 인상을 주는데 실제로는 Spring 타입을 한 번도 이름 부르지 않는다 — 바인더 접촉면 전체가 자체 함수형 인터페이스다.
|
||||
- **확인 방법.** `evidence/raw/296` §B 재실행.
|
||||
- **후보.** 두 의존을 제거하거나, 완성 시 필요함을 build.gradle 주석에 적는다.
|
||||
- **다음 단계.** `messaging-kafka-share-experimental` §17의 같은 항목과 **동일 형태**다. 두 incubating leaf가 같은 방식으로 미사용 의존을 선언한다 → **REFERENCE 후보**(허용 의존 목록은 상한이므로 미사용을 잡지 않는다).
|
||||
|
||||
### P3 — 브리지의 바인더 쪽 절반이 없다
|
||||
|
||||
- **사실.** `ChannelSend`·`BridgedHandler` 두 함수형 인터페이스가 바인더 접촉면이고, 그 구현이 저장소에 없다. `MessagingBindingBridge` javadoc은 "a service already has Stream bindings and needs to reach the same destinations without a rewrite"를 존재 이유로 든다.
|
||||
- **근거.** `evidence/raw/296` §A·§B.
|
||||
- **왜 문제인가.** 정책·검증·정직성 세 층이 완성돼 있고 그것들을 실제 바인딩에 연결하는 코드가 없다. `runtime_memberships: []`와 정합하므로 오늘의 결함은 아니지만, 이 leaf의 이름이 약속하는 것("spring-cloud-stream-bridge")이 절반만 존재한다.
|
||||
- **확인 방법.** `git grep -n 'ChannelSend\|BridgedHandler' -- src` → 이 leaf와 그 테스트만.
|
||||
- **후보.** 바인더 어댑터를 만들거나, 두 인터페이스가 파생 프로젝트의 구현점임을 javadoc에 명시한다.
|
||||
- **다음 단계.** **OPEN QUESTION 후보.** `messaging-kafka-share-experimental` §17 첫 항목과 같은 질문("완성할 것인가")이다.
|
||||
|
||||
### P3 — 인터페이스를 publisher만 구현하고 두 클래스가 같은 바인딩에 각자 상태를 갖는다
|
||||
|
||||
- **사실.** `MessagingBindingBridge`가 `bindPublisher`·`bindConsumer` 둘을 선언한다. `SpringCloudStreamPublisherBridge`가 둘 다 구현하고 `inputBindings` 맵에 기록한다. `SpringCloudStreamConsumerBridge`는 이 인터페이스를 구현하지 않고 자기 `handlers`·`destinations` 맵에 기록한다.
|
||||
- **근거.** `evidence/raw/296` §C.
|
||||
- **왜 문제인가.** 한 바인딩에 대해 두 객체가 각자 등록을 갖고 서로를 모른다. `bindConsumer`를 부르고 `register`를 부르지 않으면 publisher 쪽은 바인딩이 있다고 보고하고 실제 전달은 `NO_BRIDGED_HANDLER`로 실패한다. `consumerBinding(dest)`가 그 불일치를 드러내지 않는다.
|
||||
- **확인 방법.** 두 클래스의 필드와 인터페이스 구현 확인.
|
||||
- **후보.** consumer bridge가 `MessagingBindingBridge`를 구현하고 publisher가 `bindConsumer`를 위임하거나, 인터페이스를 발행·수신으로 나눈다.
|
||||
- **다음 단계.** **REFERENCE 후보**(한 개념의 등록 상태를 두 객체가 나눠 갖지 않는다).
|
||||
|
||||
### P3 — 두 맵 갱신이 원자적이지 않다
|
||||
|
||||
- **사실.** `SpringCloudStreamConsumerBridge.register`가 `handlers.put(...)` 후 `destinations.put(...)`을 한다. 같은 형태가 publisher의 두 맵에도 있다(다만 각각 독립 키).
|
||||
- **근거.** `SpringCloudStreamConsumerBridge.java:38-39`.
|
||||
- **왜 문제인가.** 그 사이에 `dispatch`가 들어오면 `destination == null`이 되어 `NO_BRIDGED_HANDLER`가 난다. **안전한 방향**이다 — 잘못된 목적지로 전달하지 않는다. 다만 에러 코드가 "핸들러가 없다"인데 실제로는 핸들러가 있고 목적지가 아직 없다.
|
||||
- **확인 방법.** 두 `put` 사이의 창.
|
||||
- **후보.** 한 record로 묶어 한 번에 put한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(함께 읽히는 두 맵은 한 값으로 묶는다).
|
||||
|
||||
### P3 — 등록 해제 경로가 없다
|
||||
|
||||
- **사실.** `SpringCloudStreamConsumerBridge`에 `unregister`나 `close`가 없다. `SpringCloudStreamPublisherBridge`도 마찬가지다.
|
||||
- **근거.** 두 클래스의 public 메서드 전수.
|
||||
- **왜 문제인가.** 바인딩이 재구성되거나 컨텍스트가 종료될 때 맵이 비워지지 않는다. 오늘은 조립되지 않아 무해하다. `messaging-transport-spi`의 `TransportConsumerRegistration`이 `AutoCloseable`인 것과 대비된다.
|
||||
- **확인 방법.** public 메서드 목록.
|
||||
- **후보.** `unregister(bindingName)` 또는 `AutoCloseable` 구현.
|
||||
- **다음 단계.** **REFERENCE 후보**(등록을 받는 컴포넌트는 해제도 제공한다).
|
||||
|
||||
### P3 — 활성화 프로퍼티 키가 에러 메시지에만 존재한다
|
||||
|
||||
- **사실.** `backend.messaging.bridge.spring-cloud-stream=true`가 `STREAM_BRIDGE_DISABLED` 메시지에 적혀 있다. 그 키를 읽는 코드가 없다.
|
||||
- **근거.** `git grep -n 'spring-cloud-stream=true' -- src` → 이 leaf의 문자열 하나.
|
||||
- **왜 문제인가.** `messaging-kafka-share-experimental`·`messaging-claim-check`와 같은 형태다 — 메시지가 지시하는 설정에 대응 코드가 없다.
|
||||
- **다음 단계.** 그 두 leaf의 같은 항목과 함께 **REFERENCE 후보**(에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다).
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- 플랫폼 보장에 의존하는 목적지를 브리지에서 아예 거절하는 4단 게이트
|
||||
- 확장 속성을 병합하지 않고 거절하며 어느 쪽을 지울지 알려 주는 것
|
||||
- 무해한 확장 속성은 통과시키고 그것을 테스트로 고정한 것
|
||||
- 결여를 boolean이 아니라 **결과가 적힌 문장**으로 만드는 것
|
||||
- 바인더의 boolean send를 `AMBIGUOUS`로 보고하고 그 이유를 적은 것
|
||||
- 두 `PublishResult`가 core-api의 금지 조합을 정확히 만족하는 것
|
||||
- 정산을 바인더에 남기고 핸들러 예외를 재던지는 것
|
||||
- 바인더 접촉면을 함수형 인터페이스로 분리해 바인더 없이 전부 테스트 가능하게 한 것
|
||||
- 아홉 구성 실패가 한 예외 타입과 안정 코드를 쓰는 것
|
||||
- 소비자 0 · membership `[]` · 조립 0의 삼중 정합
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MSB-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 3개, `runtime_memberships: []` | 선언 |
|
||||
| MSB-002 | build | `messaging-spring-cloud-stream-bridge/build.gradle` | same | 네 의존 선언 | 둘은 미사용(§12.4) |
|
||||
| MSB-003 | code | `.../streambridge/StreamBridgePolicyGuard.java` | same | §4.1 네 거절 | — |
|
||||
| MSB-004 | code | `.../streambridge/BindingProfileValidator.java` | same | §4.2 8속성 거절, production 거절 | — |
|
||||
| MSB-005 | code | `.../streambridge/BindingCapabilityReport.java` | same | §4.3 결여를 문장으로 | `nativeAdapter` 호출자 테스트뿐 |
|
||||
| MSB-006 | code | `.../streambridge/SpringCloudStreamPublisherBridge.java` | same | §4.4 AMBIGUOUS 결정과 두 결과 | — |
|
||||
| MSB-007 | code | `.../streambridge/SpringCloudStreamConsumerBridge.java` | same | §4.5 정산 미소유, 예외 재던짐 | 두 맵 비원자(§17) |
|
||||
| MSB-008 | code | `.../streambridge/MessagingBindingBridge.java` | same | seam 선언과 위협 모델 | 구현이 publisher뿐 |
|
||||
| MSB-009 | test | `BindingProfileValidatorTest` (10), `BridgePublishEvidenceTest` (10) | same | §10 표, 여섯 타입 전부 | 실제 바인더 없음 |
|
||||
| MSB-010 | cross-leaf code | `messaging-core-api/.../PublishResult.java:39-101` | same | 두 결과가 만족하는 금지 조합 | 해당 leaf SSOT가 소유 |
|
||||
| MSB-011 | cross-leaf code | `messaging-policy/.../DestinationProfile.java`, `RetryMode.java` | same | 게이트가 읽는 세 필드 | 해당 leaf SSOT가 소유 |
|
||||
| EVD-296 | command | `evidence/raw/296-stream-bridge-unconsumed-and-unused-deps.txt` | same | §12.1·§12.4 | 정적 검색 |
|
||||
| EVD-297 | command | `./gradlew :messaging:messaging-spring-cloud-stream-bridge:test --rerun-tasks` | same | 20 / 0 / 0 | fake 바인더 |
|
||||
@@ -0,0 +1,716 @@
|
||||
# messaging-transport-spi 완전 해부
|
||||
|
||||
> 상태: COMPLETE
|
||||
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
|
||||
> 분석 범위: `src/messaging/messaging-transport-spi`
|
||||
> SSOT owner: `messaging-transport-spi`
|
||||
> integration/family document: `analysis/19-messaging-platform.md` (secondary, INTEGRATION_ONLY)
|
||||
|
||||
---
|
||||
|
||||
## 0. SSOT identity / 커버리지와 숫자 지도
|
||||
|
||||
- registered leaf id: `messaging-transport-spi`
|
||||
- canonical state `analysisFile`: `analysis/messaging/messaging-transport-spi.md`
|
||||
- source path: `src/messaging/messaging-transport-spi`
|
||||
- registry `allowed_dependencies`: `["messaging-core-api", "messaging-schema-api", "messaging-policy"]`
|
||||
- registry `runtime_memberships`: `["app-bootstrap"]`
|
||||
|
||||
### 숫자
|
||||
|
||||
| 항목 | 수 |
|
||||
|---|---:|
|
||||
| production Java 파일 | 13 |
|
||||
| production LOC | 776 |
|
||||
| 패키지 | 1 (`dev.caskeleton.messaging.transport`) |
|
||||
| test 파일 | 4 |
|
||||
| test 메서드(실행 확인) | 24 |
|
||||
| 외부(비프로젝트) 의존성 | **0** |
|
||||
|
||||
13개 타입:
|
||||
|
||||
| 타입 | 종류 | 역할 |
|
||||
|---|---|---|
|
||||
| `MessagingTransport` | interface | **브로커 어댑터가 구현하는 SPI** |
|
||||
| `TransportPublishRequest` | record | 이미 인코딩된 발행 요청 |
|
||||
| `TransportPublishResult` | record | `PublishResult` 래퍼 |
|
||||
| `TransportConsumerSpec` | record | 프로파일 + 콜백 |
|
||||
| `TransportConsumerRegistration` | interface | 살아 있는 구독 |
|
||||
| `TransportDelivery` | record | 아직 인코딩된 수신 |
|
||||
| `TransportSettlement` | interface | 어댑터 측 정산 핸들 |
|
||||
| `MessagingRuntime` | interface | 한 세대의 연결·자격증명·토폴로지 |
|
||||
| `MessagingRuntimeLease` | interface | 세대 참조 대여 |
|
||||
| `MessagingRuntimeRegistry` | interface | 브로커별 현재 세대 |
|
||||
| `DefaultMessagingRuntimeRegistry` | class | 참조 계수 + 원자 교체 구현 |
|
||||
| `GracefulShutdownCoordinator` | class | 드레인 조정자 |
|
||||
| `MessagingLifecycle` | interface | **8단계 종료 순서 계약 — 구현체 없음(§12.1)** |
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| `src/main/java/**` (13) | 13 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `src/test/java/**` (4) | 4 | `FULL_READ` | 전 파일 본문 확인 |
|
||||
| `build.gradle` | 1 | `FULL_READ` | 7줄 |
|
||||
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
|
||||
| `build/**` | — | `EXCLUDED` | 빌드 산출물 |
|
||||
|
||||
`UNCLASSIFIED` 0.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
브로커 어댑터가 구현할 **SPI**와, 그 어댑터들의 **수명주기·세대 관리**를 소유한다. 벤더 의존성이 0이다.
|
||||
|
||||
가장 중요한 경계 규칙이 `MessagingTransport`의 javadoc에 있다.
|
||||
|
||||
```java
|
||||
// MessagingTransport.java:10-12
|
||||
* <p>No method returns a native client object. Handing back a raw producer or channel would let an
|
||||
* application bypass destination policy, payload limits, and the settlement ordering in one call,
|
||||
* and the resulting code would silently stop working the moment the broker changed.
|
||||
```
|
||||
|
||||
13개 타입 중 어느 것도 브로커 네이티브 타입을 시그니처에 노출하지 않는다. `BrokerPosition`(core-api)이 `Map<String,String> diagnosticAttributes()`로 좌표를 문자열로만 내보내는 것과 같은 규율이다.
|
||||
|
||||
두 번째 경계는 **인코딩 위치**다.
|
||||
|
||||
```java
|
||||
// TransportDelivery.java:11-13
|
||||
* <p>Decoding happens above the transport so that a payload the consumer cannot parse is classified
|
||||
* as a schema failure by the platform, and parked, rather than being turned into an
|
||||
* adapter-specific exception each broker reports differently.
|
||||
```
|
||||
|
||||
`TransportPublishRequest`도 대칭이다 — "The payload arrives already encoded and the profile arrives already validated, so an adapter never chooses a codec or a limit for itself. That is what keeps two adapters from disagreeing about what 'the same message' means."
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
들어오는 것: `messaging-core-api`(api), `messaging-schema-api`(api), `messaging-policy`(api). 셋 다 `api`인 이유는 세 leaf의 타입이 이 leaf의 public 시그니처에 직접 등장하기 때문이다 — `TransportPublishRequest`가 `DestinationProfile`(policy)·`MessageEnvelope`(core-api)·`EncodedMessage`(schema-api)를 필드로 갖는다.
|
||||
|
||||
나가는 것: `messaging-runtime-core`, `messaging-kafka`, `messaging-kafka-share-experimental`, `messaging-rabbit`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-admin-runtime`, `messaging-spring-cloud-stream-bridge`, `messaging-spring-boot-starter`, `messaging-testkit`.
|
||||
|
||||
런타임 편입은 starter closure를 통해서다. 이 leaf 자체는 bean을 만들지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
세 축이 한 패키지에 있다.
|
||||
|
||||
```
|
||||
[SPI] MessagingTransport
|
||||
├── publish(TransportPublishRequest) → TransportPublishResult
|
||||
├── register(TransportConsumerSpec) → TransportConsumerRegistration
|
||||
├── capabilities(DestinationName) → DestinationCapabilities
|
||||
└── brokerName / generation / close
|
||||
↑ 구현: Kafka · Rabbit · Pulsar · NATS (4)
|
||||
|
||||
[세대] MessagingRuntime ── MessagingRuntimeLease ── MessagingRuntimeRegistry
|
||||
↑
|
||||
DefaultMessagingRuntimeRegistry (구현)
|
||||
|
||||
[종료] GracefulShutdownCoordinator (사용됨: 11개 파일)
|
||||
MessagingLifecycle.ShutdownPhase(8) (구현 없음, 소비자 0)
|
||||
```
|
||||
|
||||
세 축이 **다른 정도로 살아 있다.** SPI는 4개 어댑터가 구현하고, 세대 관리는 구현이 하나 있고, 종료 계약은 절반만 실현됐다(§12.1).
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
### 4.1 세대 모델: 회전은 변경이 아니라 교체다
|
||||
|
||||
```java
|
||||
// MessagingRuntime.java:5-8
|
||||
* <p>Credential rotation and topology reload replace a whole generation rather than mutating a live
|
||||
* one. In-flight publishes keep the generation they started on, which is what makes a rotation
|
||||
* invisible to callers instead of a burst of authentication failures.
|
||||
```
|
||||
|
||||
세 타입이 그 모델을 이룬다.
|
||||
|
||||
| 타입 | 불변식 |
|
||||
|---|---|
|
||||
| `MessagingRuntime` | 불변. `close()`는 **멱등이어야 한다**(javadoc이 명시) |
|
||||
| `MessagingRuntimeLease` | 참조를 pin. `close()`는 **멱등이어야 한다** |
|
||||
| `MessagingRuntimeRegistry` | 브로커당 현재 세대 하나 |
|
||||
|
||||
### 4.2 `DefaultMessagingRuntimeRegistry`: 참조 계수와 원자 교체
|
||||
|
||||
이 leaf의 유일한 실질 구현이고 동시성 설계가 조밀하다.
|
||||
|
||||
**설치(교체)**
|
||||
|
||||
```java
|
||||
Generation retired = current.put(runtime.brokerName(), new Generation(runtime));
|
||||
if (retired == null) return;
|
||||
retired.retire(now);
|
||||
if (!retired.closeIfIdle()) {
|
||||
synchronized (draining) { draining.add(retired); }
|
||||
}
|
||||
```
|
||||
|
||||
`ConcurrentHashMap.put`이 원자적이므로 호출자는 옛 세대 또는 새 세대만 본다 — javadoc: "never a half-rebuilt connection pool".
|
||||
|
||||
**대여**
|
||||
|
||||
```java
|
||||
Generation generation = current.computeIfPresent(brokerName, (key, value) -> {
|
||||
value.leases.incrementAndGet();
|
||||
return value;
|
||||
});
|
||||
```
|
||||
|
||||
`computeIfPresent`의 리맵 함수가 **버킷 잠금 안에서** 실행되므로, 조회와 증가가 원자적이다. `get` 후 증가였다면 그 사이에 `install`이 세대를 교체해 이미 은퇴한 세대의 계수를 올릴 수 있다.
|
||||
|
||||
**해제**
|
||||
|
||||
```java
|
||||
void release() {
|
||||
if (leases.decrementAndGet() == 0) { closeIfIdle(); }
|
||||
}
|
||||
boolean closeIfIdle() {
|
||||
if (retired.get() && leases.get() == 0) { return forceClose(); }
|
||||
return false;
|
||||
}
|
||||
boolean forceClose() {
|
||||
if (closed.compareAndSet(false, true)) { runtime.close(); return true; }
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
`closed`가 CAS로 보호되므로 **정확히 한 번만** `runtime.close()`가 불린다. 테스트가 그것을 직접 단언한다(`aRetiredGenerationIsClosedExactlyOnce`, `as("a second close on a real connection pool throws from a shutdown hook")`).
|
||||
|
||||
`Lease.close()`도 자체 `AtomicBoolean released`로 멱등이다 — 두 층의 멱등성이다.
|
||||
|
||||
**세대별 은퇴 시각**
|
||||
|
||||
```java
|
||||
// Generation.retiredAt javadoc:180-183
|
||||
* <p>Each generation carries its own. The deadline check took one {@code retiredAt} from the
|
||||
* caller and applied it to every draining generation, so a rotation during a drain either
|
||||
* force-closed a generation that had just retired or gave an old one a fresh deadline —
|
||||
* depending on which timestamp the caller happened to pass.
|
||||
```
|
||||
|
||||
이전 결함의 기록이다. 하나의 타임스탬프를 전체 목록에 적용하면 회전이 겹칠 때 판정이 호출자가 우연히 넘긴 값에 좌우된다.
|
||||
|
||||
**닫힌 세대의 목록 제거**
|
||||
|
||||
```java
|
||||
// closeExpiredDraining:112-113
|
||||
// Anything already closed leaves the list too: it is not draining, and leaving it there is
|
||||
// what made drainingCount report work that had finished.
|
||||
draining.removeIf(Generation::isClosed);
|
||||
```
|
||||
|
||||
`drainingCount()`가 관측 지표이므로, 이미 닫힌 세대가 목록에 남으면 지표가 영원히 0으로 안 떨어진다.
|
||||
|
||||
**`close()`가 현재 세대까지 닫는다**
|
||||
|
||||
```java
|
||||
// close() javadoc:131-134
|
||||
* <p>Nothing closed the current generation. The registry only ever closed what a rotation had
|
||||
* retired, so a process that shut down without rotating left its broker connections to the JVM's
|
||||
* exit — which drops unflushed producer batches and leaves consumer sessions to time out on the
|
||||
* broker instead of leaving the group.
|
||||
```
|
||||
|
||||
이것도 이전 결함이다. 회전 없이 종료하는 프로세스(=대부분의 프로세스)가 연결을 정리하지 않았다.
|
||||
|
||||
**동시성 미세 결함 하나.** `close()`가 `draining`은 `synchronized`로 비우지만 `current`는 `List.copyOf(current.keySet())` 후 하나씩 `remove`한다. 그 사이에 `install`이 새 세대를 넣으면 그 세대는 닫히지 않는다. 종료 중 설치는 정상 시나리오가 아니므로 실질 위험은 낮다 — §17의 P3.
|
||||
|
||||
### 4.3 `GracefulShutdownCoordinator`: 세 단계와 그 이유
|
||||
|
||||
```java
|
||||
// GracefulShutdownCoordinator.java:12-22
|
||||
* <p>Shutdown has three phases, in order: stop accepting new work, let what is running finish, then
|
||||
* close. Skipping the middle phase is what produces the classic shutdown bug — a handler is
|
||||
* interrupted between its side effect and its settlement, so the message is redelivered and the
|
||||
* effect happens twice.
|
||||
*
|
||||
* <p>The deadline exists because draining cannot be unbounded: a stuck handler would otherwise hold
|
||||
* the process open forever. Work still running at the deadline is abandoned <em>unsettled</em>, so
|
||||
* the broker redelivers it rather than the platform pretending it completed.
|
||||
*
|
||||
* <p>No retry attempt is created once draining begins. Starting a fresh attempt during shutdown
|
||||
* guarantees it will be abandoned at the deadline.
|
||||
```
|
||||
|
||||
`tryBeginWork`가 **이중 검사**다.
|
||||
|
||||
```java
|
||||
public boolean tryBeginWork() {
|
||||
if (draining.get()) return false;
|
||||
inFlight.incrementAndGet();
|
||||
if (draining.get()) { inFlight.decrementAndGet(); return false; }
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
증가 후 다시 확인해서, 증가와 `beginDrain` 사이의 경합에서 계수를 되돌린다. 이 패턴이 없으면 드레인 시작 직후 시작된 작업이 계수에 남아 `isDrained`가 영원히 false가 된다.
|
||||
|
||||
`endWork`가 0에서 clamp한다.
|
||||
|
||||
```java
|
||||
// :65-67
|
||||
* <p>Clamped at zero. A double release used to drive the count negative, and a negative in-flight
|
||||
* count reports the drain as complete while work is still running — which is exactly when the
|
||||
* process shuts down underneath it.
|
||||
public void endWork() {
|
||||
inFlight.updateAndGet(current -> current > 0 ? current - 1 : current);
|
||||
}
|
||||
```
|
||||
|
||||
`isDrained(now)`가 세 갈래다 — 드레인 전이면 false, 계수 0이면 true, 아니면 마감 경과 여부. `abandonedWorkAtDeadline`이 "마감으로 끝났는가"를 별도로 답해서, 완주한 드레인과 포기한 드레인을 구분할 수 있다.
|
||||
|
||||
### 4.4 `MessagingLifecycle`: 8단계 순서 계약
|
||||
|
||||
```java
|
||||
// MessagingLifecycle.java:8-15
|
||||
* <p>The order in {@link ShutdownPhase} is the contract, not an implementation detail. Closing
|
||||
* connections before settlements have been transmitted loses the settlements, and pausing consumers
|
||||
* after draining lets fresh deliveries arrive into a runtime that is already shutting down. Each
|
||||
* adapter implements the phases; none of them chooses the order.
|
||||
*
|
||||
* <p>Implementations are driven by the Spring lifecycle rather than a JVM shutdown hook alone. A
|
||||
* shutdown hook runs after the context has already begun disposing beans, so a handler mid-drain
|
||||
* can find its datasource closed underneath it.
|
||||
```
|
||||
|
||||
여덟 단계:
|
||||
|
||||
| # | 단계 | 뜻 |
|
||||
|---:|---|---|
|
||||
| 1 | `STOP_PUBLISH_ADMISSION` | 새 발행 거부 |
|
||||
| 2 | `STOP_NEW_HANDLERS` | 새 핸들러 시작 거부 |
|
||||
| 3 | `PAUSE_CONSUMERS` | 브로커에 전달 중단 요청 |
|
||||
| 4 | `DRAIN_HANDLERS` | 실행 중 핸들러 완료 대기 |
|
||||
| 5 | `FLUSH_SETTLEMENTS` | 그 핸들러들이 만든 정산 전송 |
|
||||
| 6 | `AWAIT_PRODUCER_CONFIRMS` | 미확인 발행이 모호로 남지 않게 |
|
||||
| 7 | `RELEASE_OUTBOX_LEASES` | 다른 relay가 즉시 claim 가능하게 |
|
||||
| 8 | `CLOSE_CONNECTIONS` | 연결·채널 종료 |
|
||||
|
||||
`shutdown(Duration)`이 마감 시점에 실행 중이던 단계를 반환한다 — 완주하면 `CLOSE_CONNECTIONS`.
|
||||
|
||||
**이 인터페이스를 구현하는 것이 저장소에 없다.** §12.1.
|
||||
|
||||
### 4.5 `TransportConsumerRegistration`: 순서 단위별 pause
|
||||
|
||||
```java
|
||||
// :8-10
|
||||
* <p>Pause and resume operate on an ordering unit rather than the whole consumer, because that is
|
||||
* what makes {@code PAUSE_PARTITION} retry possible: one stuck key must not stall every other
|
||||
* partition on the same connection.
|
||||
```
|
||||
|
||||
`scope`가 빈 문자열이면 전체다. `core-api`의 `PauseResumeController`는 `"*"`를 전체로 쓴다 — 두 인터페이스가 같은 개념에 **다른 sentinel**을 쓴다. `PauseResumeController`는 소비자가 0이므로(`analysis/messaging/messaging-core-api.md` §12.1) 오늘 충돌하지 않지만, 그것을 배선하려는 사람이 두 규약을 이어야 한다.
|
||||
|
||||
### 4.6 `TransportSettlement`: 애플리케이션에 노출되지 않는다
|
||||
|
||||
```java
|
||||
// :10-11
|
||||
* <p>Deliberately not exposed to application code. Handlers state an intent; the platform decides
|
||||
* when and in what order the settlement happens, and this is the seam it uses to do that.
|
||||
```
|
||||
|
||||
`acknowledge` / `requeue(delay)` / `discard` 셋이고, `core-api`의 `SettlementController`(`ack`/`retry`/`deadLetter`/`reject`)와 **이름도 개수도 다르다.** 전자는 어댑터 측 원시 연산, 후자는 M2 수동 정산 API다. `deadLetter`가 전자에 없는 것이 핵심이다 — DLQ 발행은 플랫폼(`DefaultDeliveryProcessor`)이 하고 어댑터는 `acknowledge`만 받는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
**발행:** 상위(`DefaultMessagePublisher`)가 `TransportPublishRequest`를 만들어 `MessagingTransport.publish` → 어댑터가 `TransportPublishResult(PublishResult)` 반환
|
||||
|
||||
**수신:** 상위가 `TransportConsumerSpec(profile, sink)`로 `register` → 어댑터가 메시지마다 `sink.apply(TransportDelivery)` → 상위가 `TransportSettlement`으로 정산
|
||||
|
||||
**회전:** 새 `MessagingRuntime` 생성 → `registry.install(runtime, now)` → 옛 세대 `retire` → lease가 0이면 즉시 close, 아니면 `draining`에 적재 → 스케줄러가 `closeExpiredDraining(now)` 호출
|
||||
|
||||
**종료:** (실제 경로) `MessagingShutdownLifecycle.stop()` → `admission.stopAcceptingNewWork()` → `drain.beginDrain(now)` → 50 ms 폴링으로 `isDrained` 대기 → 마감 도달 시 중단
|
||||
|
||||
---
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
이 leaf가 직접 던지는 예외는 **하나**다.
|
||||
|
||||
| 코드 | 예외 | 조건 |
|
||||
|---|---|---|
|
||||
| `RUNTIME_NOT_INSTALLED` | `MessagingConfigurationException` | `acquire(brokerName)`인데 그 브로커의 세대가 없음 |
|
||||
|
||||
나머지는 `IllegalArgumentException`(생성자 인자 검증)과 `NullPointerException`(`Objects.requireNonNull`)이다. 이 leaf가 다루는 실패의 대부분은 **예외가 아니라 상태**다 — 드레인 마감 초과는 `abandonedWorkAtDeadline(now)`가 true를 반환하는 것이고, 세대 강제 종료는 `closeExpiredDraining`의 반환 계수다.
|
||||
|
||||
**포기가 조용하지 않다는 것이 설계다.** 마감에 도달한 작업은 정산되지 않은 채 버려지고, 브로커가 재전달한다. `GracefulShutdownCoordinator` javadoc: "rather than the platform pretending it completed."
|
||||
|
||||
---
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
이 leaf는 messaging family에서 **동시성 밀도가 가장 높다.**
|
||||
|
||||
| 지점 | 도구 | 보호하는 것 |
|
||||
|---|---|---|
|
||||
| `current` 맵 | `ConcurrentHashMap` | 세대 교체의 원자성 |
|
||||
| lease 증가 | `computeIfPresent` 리맵 | 조회-증가 사이의 교체 |
|
||||
| `leases` | `AtomicInteger` | 참조 계수 |
|
||||
| `retired`, `closed` | `AtomicBoolean` + CAS | 정확히 한 번 close |
|
||||
| `Lease.released` | `AtomicBoolean` + CAS | 이중 close 방지 |
|
||||
| `retiredAt` | `volatile Instant` | 세대별 마감 가시성 |
|
||||
| `draining` 리스트 | `synchronized` 블록 | `ArrayList` 보호 |
|
||||
| `inFlight` | `AtomicInteger` + 이중 검사 + clamp | 드레인 계수 |
|
||||
| `draining`(coordinator) | `AtomicBoolean` CAS | 드레인 시작 한 번 |
|
||||
| `drainStartedAt` | `volatile Instant` | 마감 가시성 |
|
||||
|
||||
**주목할 비대칭:** `DefaultMessagingRuntimeRegistry`가 `current`는 lock-free(`ConcurrentHashMap`)로, `draining`은 `synchronized ArrayList`로 다룬다. `draining`은 회전 때만 접근하므로 경합이 없다 — 합리적 선택이지만 주석이 없다.
|
||||
|
||||
수명주기는 §4.4의 8단계가 **선언**이고 §12.1이 실현 상태를 다룬다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
설정 없음.
|
||||
|
||||
| 상수 | 값 | 위치 |
|
||||
|---|---|---|
|
||||
| `DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE` | 30초 | `:28` (private) |
|
||||
| `MessagingLifecycle.DEFAULT_DRAIN_DEADLINE` | 30초 | `:40` (public, 인터페이스 상수) |
|
||||
|
||||
**같은 값이 두 곳에 있다.** 그리고 `MessagingShutdownLifecycle`(starter)은 셋 중 어느 것도 참조하지 않고 생성자 인자로 받는다. 세 번째 값이 프로퍼티에서 올 수 있다는 뜻이다 — 그 배선은 starter leaf가 소유한다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
없다. 이 leaf는 브로커를 만지지 않는다 — 만지는 방법의 **모양**만 정의한다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
레인: `./gradlew :messaging:messaging-transport-spi:test`. **BUILD SUCCESSFUL, 24 tests, 0 skipped, 0 failures**.
|
||||
|
||||
| 클래스 | 수 | 실제로 증명하는 것 | 증명하지 않는 것 |
|
||||
|---|---:|---|---|
|
||||
| `MessagingRuntimeRegistryTest` | 9 | 세대 설치·대여·은퇴·드레인 계수 | 실제 브로커 연결 |
|
||||
| `ResourceLeakGateTest` | 4 | 20세대 연속 회전 후 현재 세대만 열림, 누수 lease가 마감에 강제 종료, 막힌 작업도 드레인 종료, 은퇴 세대가 **정확히 한 번** close | 며칠 단위 실행 |
|
||||
| `GracefulShutdownTest` | 5 | 드레인이 새 작업만 막고 실행 중은 완료, 재시도 금지, 마감 경계(29초 false / 30초 true), 유휴 코디네이터, 이중 `endWork` clamp | — |
|
||||
| `MessagingLifecycleTest` | 6 | **enum 선언 순서와 상수 값** | **아무 종료 동작도 증명하지 않는다** |
|
||||
|
||||
### 10.1 `ResourceLeakGateTest`의 자기 규정
|
||||
|
||||
```java
|
||||
// :12-18
|
||||
* <p>Every resource the platform holds is bounded by something that must eventually release it: a
|
||||
* runtime generation by its last lease, an in-flight slot by its handler finishing, a drain by its
|
||||
* deadline. Each of those has a failure mode that is invisible in a short test and fatal over days
|
||||
* — a retired generation whose credential never gets revoked, a partition that never accepts work
|
||||
* again, a shutdown that never completes.
|
||||
```
|
||||
|
||||
세 자원과 각각의 해제 조건을 명시하고, "짧은 테스트에서 안 보이고 며칠이면 치명적"이라는 실패 성격까지 적는다. 20세대 회전 루프가 그 형태를 압축한 것이다.
|
||||
|
||||
### 10.2 `MessagingLifecycleTest`가 실제로 단언하는 것
|
||||
|
||||
여섯 테스트 중 다섯이 이 형태다.
|
||||
|
||||
```java
|
||||
List<ShutdownPhase> order = List.of(ShutdownPhase.values());
|
||||
assertThat(order.indexOf(ShutdownPhase.DRAIN_HANDLERS))
|
||||
.as("flushing before the handlers finish would lose the settlements they produce")
|
||||
.isLessThan(order.indexOf(ShutdownPhase.FLUSH_SETTLEMENTS));
|
||||
```
|
||||
|
||||
`ShutdownPhase.values()`는 **소스에 상수가 적힌 순서**를 반환한다. 이 단언이 검증하는 것은 "누군가 enum 상수를 이 순서로 타이핑했다"이다. 여섯 번째는 상수 값 비교(`DEFAULT_DRAIN_DEADLINE == 30초`)다.
|
||||
|
||||
`as(...)` 문구들은 실제 시스템 동작을 서술한다 — "flushing before the handlers finish would lose the settlements", "a confirm that arrives after close cannot be observed". 그러나 그 동작을 수행하는 코드가 없다(§12.1). 테스트 이름(`handlersDrainBeforeTheirSettlementsAreFlushed`)과 실제 단언(enum 인덱스 비교) 사이의 거리가 이 레인에서 가장 큰 항목이다.
|
||||
|
||||
이 여섯 테스트는 **enum 상수 순서를 바꾸면 실패한다.** 그리고 순서를 바꿔도 시스템 동작은 바뀌지 않는다 — 아무도 그 순서를 읽지 않기 때문이다. 게이트가 지키는 것과 게이트가 지킨다고 이름 붙인 것이 다르다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
| 게이트 | 이 leaf에 대해 |
|
||||
|---|---|
|
||||
| `verifyCleanArchitectureDependencies` | 세 project 의존 |
|
||||
| `verifyRuntimeModuleMembership` | `["app-bootstrap"]` |
|
||||
| vendor `api` 규칙 | 벤더 의존성 0이므로 대상 없음. 세 project 의존은 전부 `api`이고 시그니처에 실제로 등장 |
|
||||
| ArchUnit | 전용 규칙 없음 |
|
||||
| `MessagingLifecycle` 구현 강제 | **없음** — 인터페이스는 컴파일 타임 강제를 만들지 않는다 |
|
||||
|
||||
마지막 행이 §12.1의 구조적 이유다. `MessagingTransport`는 어댑터가 구현하지 않으면 `TransportMessagingRuntime`이 컴파일되지 않는다. `MessagingLifecycle`은 아무도 받지 않으므로 구현하지 않아도 아무것도 깨지지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 12. 실제 사용 여부와 negative-space probes
|
||||
|
||||
원시 증거: `evidence/raw/280-transport-spi-lifecycle-unimplemented.txt`.
|
||||
|
||||
### 12.1 Public surface reachability
|
||||
|
||||
| 타입 | leaf 밖 파일 수 | 판정 |
|
||||
|---|---:|---|
|
||||
| `TransportPublishRequest` | 18 | 활발 |
|
||||
| `TransportConsumerSpec` | 14 | 활발 |
|
||||
| `MessagingTransport` | 12 | 4개 어댑터가 구현 |
|
||||
| `GracefulShutdownCoordinator` | 11 | 활발 |
|
||||
| `TransportPublishResult` | 10 | 활발 |
|
||||
| `TransportDelivery` | 9 | 활발 |
|
||||
| `TransportConsumerRegistration` | 8 | 활발 |
|
||||
| `TransportSettlement` | 4 | 활발 |
|
||||
| `MessagingRuntime` | 3 | `TransportMessagingRuntime`이 구현 |
|
||||
| `MessagingRuntimeRegistry` | 3 | |
|
||||
| `MessagingRuntimeLease` | 2 | |
|
||||
| `DefaultMessagingRuntimeRegistry` | 2 | |
|
||||
| **`MessagingLifecycle`** | **0** | **구현 없음, 소비자 없음** |
|
||||
|
||||
**이 leaf는 messaging family에서 가장 잘 쓰이는 leaf 중 하나다.** 13개 중 12개가 실제 소비자를 갖는다. 그래서 나머지 하나가 두드러진다.
|
||||
|
||||
**`MessagingLifecycle`의 세 겹 부재**
|
||||
|
||||
1. `git grep -E 'implements .*MessagingLifecycle'` → exit 1. **구현체 없음.**
|
||||
2. `git grep -w ShutdownPhase -- src ':!src/messaging/messaging-transport-spi'` → exit 1. **8단계 enum의 외부 소비자 없음.**
|
||||
3. `MessagingLifecycle`의 저장소 전체 언급이 자기 선언과 자기 테스트 두 줄뿐.
|
||||
|
||||
한편 `MessagingTransport`는 넷이 구현한다 — `KafkaMessagingTransport`, `RabbitMessagingTransport`, `PulsarMessagingTransport`, `NatsJetStreamTransport`. **네 어댑터 중 어느 것도 `MessagingLifecycle`을 구현하지 않는다.** javadoc이 "Each adapter implements the phases"라고 적은 그 어댑터들이다.
|
||||
|
||||
**실제 종료 경로는 존재하고 다른 타입으로 되어 있다.**
|
||||
|
||||
`messaging-spring-boot-starter`의 `MessagingShutdownLifecycle implements SmartLifecycle`이 종료를 수행한다.
|
||||
|
||||
```java
|
||||
public void stop() {
|
||||
if (!running.compareAndSet(true, false)) return;
|
||||
admission.stopAcceptingNewWork(); // ≈ phase 1
|
||||
Instant startedAt = clock.get();
|
||||
drain.beginDrain(startedAt); // ≈ phase 2
|
||||
Instant deadline = startedAt.plus(drainDeadline);
|
||||
while (!drain.isDrained(clock.get()) && clock.get().isBefore(deadline)) { ... } // ≈ phase 4
|
||||
}
|
||||
```
|
||||
|
||||
선언된 8단계와 대조:
|
||||
|
||||
| # | 선언 단계 | 실제 수행 |
|
||||
|---:|---|---|
|
||||
| 1 | `STOP_PUBLISH_ADMISSION` | **수행** — `admission.stopAcceptingNewWork()` |
|
||||
| 2 | `STOP_NEW_HANDLERS` | **수행** — `beginDrain` 이후 `tryBeginWork()`가 false |
|
||||
| 3 | `PAUSE_CONSUMERS` | 명시적 호출 없음. 어댑터의 registrar가 자체 처리 |
|
||||
| 4 | `DRAIN_HANDLERS` | **수행** — 폴링 루프 |
|
||||
| 5 | `FLUSH_SETTLEMENTS` | 명시적 단계 없음 |
|
||||
| 6 | `AWAIT_PRODUCER_CONFIRMS` | 명시적 단계 없음 |
|
||||
| 7 | `RELEASE_OUTBOX_LEASES` | 명시적 단계 없음 — `getPhase()` javadoc이 outbox relay와의 상대 순서만 언급 |
|
||||
| 8 | `CLOSE_CONNECTIONS` | Spring bean 소멸에 위임 — `getPhase()`가 `Integer.MAX_VALUE - 1024`로 transport보다 먼저 멈춤 |
|
||||
|
||||
즉 **8단계 중 셋이 명시적으로 수행되고, 하나는 Spring 단계 순서에 위임되며, 넷은 명시적 단계가 없다.** 그리고 순서를 결정하는 것은 `ShutdownPhase` enum이 아니라 Spring의 `getPhase()` 정수다.
|
||||
|
||||
`MessagingShutdownLifecycle`의 javadoc이 자기 순서를 스스로 설명한다 — "The order is admission first, drain second. Reversed, the drain waits for a count that new work keeps topping up." 두 단계에 대해서만 순서를 논한다.
|
||||
|
||||
**한계.** `PAUSE_CONSUMERS`·`FLUSH_SETTLEMENTS`·`AWAIT_PRODUCER_CONFIRMS`가 어댑터 내부에서 다른 이름으로 수행될 수 있다. `KafkaConsumerRegistrar`와 `RabbitConsumerRegistrar`가 `GracefulShutdownCoordinator`를 쓰므로 그 leaf들이 답을 갖는다. 이 문서는 **`ShutdownPhase`가 그 순서를 결정하지 않는다**만 주장한다.
|
||||
|
||||
### 12.2 Conditional sibling comparison
|
||||
|
||||
Spring 주석 0개, bean 없음.
|
||||
|
||||
**`MessagingTransport` 구현 sibling 넷의 비대칭이 관측된다.**
|
||||
|
||||
| 어댑터 | `MessagingTransport` | registry membership |
|
||||
|---|:---:|---|
|
||||
| `KafkaMessagingTransport` | o | `["app-bootstrap"]` |
|
||||
| `RabbitMessagingTransport` | o | `["app-bootstrap"]` |
|
||||
| `PulsarMessagingTransport` | o | `[]` |
|
||||
| `NatsJetStreamTransport` | o | `[]` |
|
||||
|
||||
넷 다 같은 SPI를 구현하고 둘만 편입된다 — `docs/messaging/support-matrix.md`의 experimental 구분과 정합한다. 각 어댑터의 조건부 활성화는 해당 leaf SSOT가 소유한다.
|
||||
|
||||
### 12.3 Duplicate mechanism sweep
|
||||
|
||||
**(a) 드레인 마감 30초가 세 곳에 있다**
|
||||
|
||||
| 위치 | 가시성 |
|
||||
|---|---|
|
||||
| `MessagingLifecycle.DEFAULT_DRAIN_DEADLINE` | public 인터페이스 상수 |
|
||||
| `DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE` | private |
|
||||
| `MessagingShutdownLifecycle`(starter) | 생성자 인자 |
|
||||
|
||||
public 상수가 있는데 같은 leaf의 다른 클래스가 자기 private 복사본을 쓴다. `MessagingLifecycleTest`의 여섯 번째 테스트가 public 쪽만 고정한다 — private 쪽이 바뀌어도 통과한다.
|
||||
|
||||
**(b) 정산 인터페이스가 둘**
|
||||
|
||||
| 인터페이스 | leaf | 연산 |
|
||||
|---|---|---|
|
||||
| `TransportSettlement` | 이 leaf | `acknowledge` / `requeue(delay)` / `discard` |
|
||||
| `SettlementController` | `messaging-core-api` | `ack` / `retry(delay)` / `deadLetter(failure)` / `reject(failure)` |
|
||||
|
||||
책임이 다르다 — 전자는 어댑터 원시 연산, 후자는 M2 수동 정산 API이고 `deadLetter`가 추가돼 있다. 중복이 아니라 계층이다. 다만 `SettlementController`는 소비자가 0이므로(`messaging-core-api` §12.1) 오늘 계층의 위쪽이 비어 있다.
|
||||
|
||||
**(c) pause scope sentinel이 둘**
|
||||
|
||||
| 인터페이스 | 전체를 뜻하는 값 |
|
||||
|---|---|
|
||||
| `TransportConsumerRegistration.pause(String scope)` | **빈 문자열** |
|
||||
| `PauseResumeController.pause(dest, String scope)` (core-api) | **`"*"`** |
|
||||
|
||||
두 javadoc이 각각 명시한다. 이으려면 변환이 필요하고, 그 변환 코드는 없다(`PauseResumeController` 소비자 0).
|
||||
|
||||
**(d) 드레인 조정 로직**
|
||||
|
||||
`GracefulShutdownCoordinator`가 유일하다. 저장소의 다른 곳에서 in-flight 계수 + 마감 패턴을 다시 만든 곳은 messaging family 안에 없다. 다른 family(grpc의 admission controller 등)와의 비교는 cross-scope가 소유한다.
|
||||
|
||||
### 12.4 Documentation / measured-count drift
|
||||
|
||||
| 문서 주장 | 재측정 | 결과 |
|
||||
|---|---|---|
|
||||
| `MessagingLifecycle` javadoc: "Each adapter implements the phases" | 4개 어댑터 중 0개 구현 | **불일치** |
|
||||
| `MessagingLifecycle` javadoc: "The order in ShutdownPhase is the contract" | 그 순서를 읽는 코드 0 | **불일치** |
|
||||
| `MessagingTransport` javadoc: 네이티브 클라이언트 미반환 | 13개 타입 시그니처 전수 확인 | **일치** |
|
||||
| `TransportDelivery` javadoc: 디코딩이 transport 위에서 | `TransportDelivery.envelope`이 `MessageEnvelope<EncodedMessage>` | **일치** |
|
||||
| `TransportSettlement` javadoc: 애플리케이션에 미노출 | 이 leaf가 `..application..`에서 참조 0(ArchUnit이 금지) | **일치** |
|
||||
| `support-matrix.md:23`: 모든 messaging leaf가 unwired | 이 leaf는 `["app-bootstrap"]` | **불일치**(family drift, `messaging-core-api` §12.4가 소유) |
|
||||
|
||||
---
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
코드 주석이 네 결함을 보존한다. 전부 **장기 실행에서만 드러나는** 종류다.
|
||||
|
||||
| 위치 | 이전 상태 | 그것이 만든 실패 |
|
||||
|---|---|---|
|
||||
| `Generation.retiredAt` javadoc | 호출자가 넘긴 하나의 `retiredAt`을 전체 draining 목록에 적용 | 드레인 중 회전이 겹치면, 방금 은퇴한 세대를 강제 종료하거나 오래된 세대에 새 마감을 주거나 — 호출자가 우연히 넘긴 타임스탬프에 좌우 |
|
||||
| `closeExpiredDraining` 주석 | 이미 닫힌 세대가 목록에 잔류 | `drainingCount()`가 끝난 작업을 영원히 보고 |
|
||||
| `close()` javadoc | 회전이 은퇴시킨 것만 닫음 | 회전 없이 종료한 프로세스가 브로커 연결을 JVM 종료에 맡김 → 미전송 producer 배치 소실, consumer 세션이 그룹을 떠나지 않고 브로커에서 타임아웃 |
|
||||
| `endWork` javadoc | clamp 없음 | 이중 해제가 계수를 음수로 → 작업이 도는 중에 드레인 완료로 보고 |
|
||||
|
||||
네 번째와 `LeakTrackingRuntime.closeCount()` javadoc("Closing twice is as much a defect as never closing")이 같은 주제를 반대편에서 말한다 — **해제는 정확히 한 번이어야 하고, 0번도 2번도 결함이다.**
|
||||
|
||||
---
|
||||
|
||||
## 14. 런타임·터미널 Evidence
|
||||
|
||||
| id | 종류 | 파일 | 무엇을 보여주는가 | 한계 |
|
||||
|---|---|---|---|---|
|
||||
| EVD-280 | command | `evidence/raw/280-transport-spi-lifecycle-unimplemented.txt` | 13개 타입 참조 수, `MessagingLifecycle` 구현 0(exit=1)·`ShutdownPhase` 외부 소비자 0(exit=1), 순서 테스트가 실제로 단언하는 것, 배선된 종료 경로와 그 4개 호출 | 정적 `git grep`. 어댑터 내부의 pause/flush 수행 여부는 각 leaf가 답함 |
|
||||
| EVD-279 | command | `./gradlew :messaging:messaging-transport-spi:test --rerun-tasks` | BUILD SUCCESSFUL, 24 / 0 / 0 | 실제 브로커 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
**명시적**
|
||||
|
||||
- 네이티브 클라이언트를 반환하지 않는 이유 — `MessagingTransport` javadoc
|
||||
- 디코딩이 transport 위에서 일어나는 이유 — `TransportDelivery` javadoc
|
||||
- 어댑터가 codec/limit을 고르지 않는 이유 — `TransportPublishRequest` javadoc
|
||||
- 회전이 세대 교체인 이유 — `MessagingRuntime` javadoc
|
||||
- lease가 세대를 pin하는 이유 — `MessagingRuntimeLease` javadoc
|
||||
- 드레인 마감이 필요한 이유, 재시도 금지 이유 — `GracefulShutdownCoordinator` javadoc
|
||||
- 종료 3단계 중 중간 단계를 건너뛰면 생기는 일 — 같은 javadoc
|
||||
- 순서 단위별 pause가 필요한 이유 — `TransportConsumerRegistration` javadoc
|
||||
- `TransportSettlement`을 애플리케이션에 노출하지 않는 이유 — 그 javadoc
|
||||
- 네 개의 이전 결함 — §13
|
||||
|
||||
**추론**
|
||||
|
||||
- `MessagingLifecycle`이 미구현인 것은 `MessagingShutdownLifecycle`이 Spring `SmartLifecycle`로 같은 일을 다르게 하기로 했기 때문이다 → **추론**. 두 타입의 존재와 후자의 배선은 관측이고, 전자를 버린 결정은 어디에도 기록되지 않았다.
|
||||
- `current`는 lock-free, `draining`은 `synchronized`인 이유 → **추론**(경합 빈도 차이). 주석 없음.
|
||||
- pause sentinel이 둘인 이유 → **미상**.
|
||||
|
||||
---
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
**확인한 것**
|
||||
|
||||
- 13개 타입 776줄 전문의 계약과 불변식
|
||||
- 24개 테스트가 통과하고 무엇을 단언하는지, 그리고 `MessagingLifecycleTest`가 enum 선언 순서만 단언한다는 것
|
||||
- `MessagingLifecycle` 구현 0, `ShutdownPhase` 외부 소비자 0 (둘 다 exit 1로 확인)
|
||||
- 실제 배선된 종료 경로(`MessagingShutdownLifecycle`)가 8단계 중 셋을 명시적으로 수행하고 하나를 Spring 단계에 위임한다는 것
|
||||
- 참조 계수·CAS·이중 검사·clamp의 동시성 설계와 그것을 만든 네 개의 이전 결함
|
||||
|
||||
**확인하지 못한 것**
|
||||
|
||||
- **어댑터가 `PAUSE_CONSUMERS`·`FLUSH_SETTLEMENTS`·`AWAIT_PRODUCER_CONFIRMS`를 다른 이름으로 수행하는지.** `KafkaConsumerRegistrar`·`RabbitConsumerRegistrar`가 `GracefulShutdownCoordinator`를 쓰는 것은 확인했으나 그 내부는 각 leaf가 소유한다.
|
||||
- 실제 종료에서 이 순서가 지켜지는지. 컨테이너 레인(`KafkaBrokerIT`, `KafkaConsumerSettlementIT`)이 있으나 이번 분석에서 실행하지 않았다.
|
||||
- `MessagingLifecycle`을 남겨 둔 것이 의도인지, 미완인지.
|
||||
- `close()`와 `install()`이 동시에 일어나는 경우의 실제 빈도. 코드상 창은 존재한다(§4.2).
|
||||
|
||||
---
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
### P2 — 8단계 종료 순서 계약을 구현하는 것이 없고, 그것을 검증한다는 테스트는 enum 선언 순서만 본다
|
||||
|
||||
- **사실.** `MessagingLifecycle`은 8단계 종료 순서를 선언하고 javadoc이 "The order in ShutdownPhase is the contract, not an implementation detail. … Each adapter implements the phases; none of them chooses the order"라고 적는다. 저장소에 구현체가 없고(`git grep -E 'implements .*MessagingLifecycle'` exit 1), `ShutdownPhase`의 외부 소비자도 없다(exit 1). `MessagingTransport`를 구현하는 네 어댑터 중 어느 것도 이 인터페이스를 구현하지 않는다. `MessagingLifecycleTest`의 다섯 순서 테스트는 전부 `List.of(ShutdownPhase.values()).indexOf(A) < indexOf(B)` 형태로, **소스에 상수가 적힌 순서**를 단언한다.
|
||||
- **근거.** `evidence/raw/280-transport-spi-lifecycle-unimplemented.txt` §B·§C.
|
||||
- **왜 문제인가.** 세 겹이다.
|
||||
- 실제 종료는 `MessagingShutdownLifecycle`(starter)이 하고, 8단계 중 **셋만 명시적으로 수행**한다(admission 정지 · 새 핸들러 정지 · 드레인). 나머지는 Spring `getPhase()` 정수와 bean 소멸 순서에 위임되거나 명시 단계가 없다. 순서를 결정하는 것은 `ShutdownPhase`가 아니다.
|
||||
- 테스트 이름과 `as(...)` 문구가 시스템 동작을 서술한다("flushing before the handlers finish would lose the settlements they produce"). 통과하는 것은 그 동작이 아니라 타이핑 순서다. **이 여섯 테스트는 enum 상수를 재배열하면 실패하고, 재배열해도 시스템은 바뀌지 않는다** — 게이트가 지키는 것과 이름이 어긋난다.
|
||||
- 인터페이스는 컴파일 강제를 만들지 않는다. `MessagingTransport`는 구현 안 하면 빌드가 깨지고, 이것은 아무것도 깨지지 않는다.
|
||||
- **확인 방법.** `evidence/raw/280` 재실행. 또는 `git grep -n -w MessagingLifecycle -- src` → 두 줄(자기 선언, 자기 테스트).
|
||||
- **후보.** (a) 네 어댑터가 `MessagingLifecycle`을 구현하고 `MessagingShutdownLifecycle`이 `shutdown(deadline)`을 호출하게 한다. (b) 인터페이스를 제거하고 순서 규칙을 `MessagingShutdownLifecycle`과 각 registrar의 계약으로 옮긴다. (c) 인터페이스를 "미실현 설계"로 표시하고 테스트가 enum 순서만 본다는 것을 이름과 javadoc에 반영한다.
|
||||
- **다음 단계.** **CASE 후보 + REFERENCE 후보.** Case는 "선언된 순서 계약과 실제 종료 경로의 불일치"이고, Reference는 "enum 선언 순서를 단언하는 테스트는 그 순서를 읽는 코드가 있을 때만 게이트다"이다.
|
||||
|
||||
### P3 — 드레인 마감 30초가 세 곳에서 독립적으로 결정된다
|
||||
|
||||
- **사실.** `MessagingLifecycle.DEFAULT_DRAIN_DEADLINE`(public), `DefaultMessagingRuntimeRegistry.DEFAULT_DRAIN_DEADLINE`(private), `MessagingShutdownLifecycle`의 생성자 인자.
|
||||
- **근거.** 세 위치.
|
||||
- **왜 문제인가.** public 상수가 같은 leaf 안에 있는데 다른 클래스가 자기 private 복사본을 쓴다. `MessagingLifecycleTest.theDefaultDrainDeadlineMatchesTheDesign`이 public 쪽만 고정하므로 private 쪽이 바뀌어도 통과한다. 그리고 §17 첫 항목대로 public 상수가 있는 인터페이스는 구현체가 없다 — 즉 살아 있는 값(private)이 죽은 인터페이스의 값(public)을 참조하지 않는다.
|
||||
- **확인 방법.** `git grep -n 'DEFAULT_DRAIN_DEADLINE' -- 'src/messaging/**/*.java'`
|
||||
- **후보.** registry가 `MessagingLifecycle.DEFAULT_DRAIN_DEADLINE`를 참조하거나, 값의 주인을 한 곳으로 정한다.
|
||||
- **다음 단계.** 첫 항목과 같은 사건의 일부다 → 그 CASE에 **MERGED** 후보.
|
||||
|
||||
### P3 — 종료 중 `install`이 닫히지 않는 창
|
||||
|
||||
- **사실.** `close()`가 `draining`은 `synchronized`로 비우고, `current`는 `List.copyOf(current.keySet())` 후 개별 `remove`한다. 그 사이 `install`이 새 세대를 넣으면 그 세대는 닫히지 않는다.
|
||||
- **근거.** `DefaultMessagingRuntimeRegistry.java:141-156`.
|
||||
- **왜 문제인가.** 종료 중 회전은 정상 시나리오가 아니므로 실질 위험이 낮다. 다만 이 클래스의 다른 모든 경로가 "정확히 한 번 close"를 CAS로 보장하는 것과 대비되고, 남는 것은 닫히지 않은 브로커 연결이다 — §13의 세 번째 결함과 같은 결과다.
|
||||
- **확인 방법.** 코드 검토. 테스트로 재현하려면 `close()` 중 `install`을 끼워 넣어야 한다.
|
||||
- **후보.** `close()`에 종료 플래그를 두고 `install`이 그 이후에는 즉시 `runtime.close()`하도록 한다.
|
||||
- **다음 단계.** **REFERENCE 후보**(멱등 종료를 보장하는 컴포넌트는 종료 이후의 등록도 정의한다).
|
||||
|
||||
### P3 — pause scope sentinel이 두 인터페이스에서 다르다
|
||||
|
||||
- **사실.** `TransportConsumerRegistration.pause`는 빈 문자열이 전체, `PauseResumeController.pause`(core-api)는 `"*"`가 전체.
|
||||
- **근거.** 두 javadoc.
|
||||
- **왜 문제인가.** `PauseResumeController`가 소비자 0이므로 오늘 충돌하지 않는다. 그것을 배선하려는 사람이 변환을 넣어야 하고, 빠뜨리면 `"*"`가 이름이 `"*"`인 파티션을 가리키게 된다 — 실패하지 않고 아무것도 일시정지하지 않는다.
|
||||
- **확인 방법.** 두 javadoc 대조.
|
||||
- **후보.** sentinel을 통일하거나 `Optional<String>`으로 바꾼다.
|
||||
- **다음 단계.** **REFERENCE 후보**(같은 개념의 sentinel은 계층을 넘어 하나로 정한다).
|
||||
|
||||
### 확인된 설계(문제 아님)
|
||||
|
||||
- 네이티브 클라이언트를 반환하지 않는 SPI 경계
|
||||
- 인코딩/디코딩을 transport 밖에 두어 실패 분류를 플랫폼이 소유하는 것
|
||||
- 세대 교체 + 참조 계수 + CAS로 "정확히 한 번 close"를 보장하는 것과, 그것을 20세대 회전으로 확인하는 테스트
|
||||
- `computeIfPresent`로 조회-증가를 원자화한 것
|
||||
- `tryBeginWork`의 이중 검사와 `endWork`의 clamp
|
||||
- 마감 도달 작업을 **정산하지 않고** 버려 브로커가 재전달하게 하는 것
|
||||
- 세대별 `retiredAt`과 닫힌 세대의 목록 제거
|
||||
|
||||
---
|
||||
|
||||
## Source anchors
|
||||
|
||||
| id | kind | path | revision | what it proves | limitations |
|
||||
|---|---|---|---|---|---|
|
||||
| MTS-001 | registry | `src/config/architecture/modules.json` | `21234e38` | deps 3개, memberships `["app-bootstrap"]` | 선언 |
|
||||
| MTS-002 | build | `messaging-transport-spi/build.gradle` | same | 벤더 의존성 0, 세 project 의존이 전부 `api` | — |
|
||||
| MTS-003 | code | `.../transport/MessagingTransport.java` | same | SPI 경계와 네이티브 미노출 | — |
|
||||
| MTS-004 | code | `.../transport/DefaultMessagingRuntimeRegistry.java` 전문 | same | §4.2 동시성 설계 전부와 세 개의 이전 결함 | 종료 중 install 창(§17) |
|
||||
| MTS-005 | code | `.../transport/GracefulShutdownCoordinator.java` 전문 | same | §4.3 드레인 계약과 clamp 결함 이력 | — |
|
||||
| MTS-006 | code | `.../transport/MessagingLifecycle.java` | same | 8단계 선언과 "order is the contract" 진술 | 구현 없음(§12.1) |
|
||||
| MTS-007 | code | `.../transport/Transport*.java` (6) | same | 발행·수신·정산 계약 | — |
|
||||
| MTS-008 | test | `MessagingRuntimeRegistryTest` (9) | same | 세대 관리 | 실제 브로커 없음 |
|
||||
| MTS-009 | test | `ResourceLeakGateTest` (4) | same | 20세대 회전, 누수 lease 강제 종료, 정확히 한 번 close | 며칠 단위 아님 |
|
||||
| MTS-010 | test | `GracefulShutdownTest` (5) | same | 드레인 경계 29/30초, 이중 endWork clamp | — |
|
||||
| MTS-011 | test | `MessagingLifecycleTest` (6) | same | **enum 선언 순서와 상수 값만** | 종료 동작 미증명(§10.2) |
|
||||
| MTS-012 | cross-leaf code | `messaging-spring-boot-starter/.../MessagingShutdownLifecycle.java` 전문 | same | 실제 배선된 종료 경로와 그것이 수행하는 3단계, `getPhase()` 위임 | 해당 leaf SSOT가 소유 |
|
||||
| MTS-013 | cross-leaf code | 4개 `*MessagingTransport.java` | same | SPI 구현 넷, `MessagingLifecycle` 구현 0 | 각 leaf SSOT가 소유 |
|
||||
| EVD-280 | command | `evidence/raw/280-transport-spi-lifecycle-unimplemented.txt` | same | §12.1 전부, exit code 포함 | 정적 검색 |
|
||||
| EVD-279 | command | `./gradlew :messaging:messaging-transport-spi:test --rerun-tasks` | same | 24 / 0 / 0 | 브로커 없음 |
|
||||
@@ -0,0 +1,60 @@
|
||||
# <module/subsystem> 완전 해부
|
||||
|
||||
> 상태: IN_PROGRESS | COMPLETE
|
||||
> 기준 revision: <git-sha>
|
||||
> 분석 범위: <path/module>
|
||||
|
||||
## 0. 커버리지와 숫자 지도
|
||||
|
||||
- production files:
|
||||
- production LOC:
|
||||
- packages/directories:
|
||||
- tests by lane:
|
||||
- migrations/config/build files:
|
||||
- runtime membership:
|
||||
|
||||
### Coverage ledger
|
||||
|
||||
| scope/file group | count | disposition | reason |
|
||||
|---|---:|---|---|
|
||||
| | | FULL_READ / STRUCTURAL_ONLY / EXCLUDED | |
|
||||
|
||||
## 1. 모듈의 정체와 경계
|
||||
|
||||
## 2. 의존성과 런타임 배선
|
||||
|
||||
## 3. 패키지/컴포넌트 지도
|
||||
|
||||
## 4. 계약·불변식·상태 모델
|
||||
|
||||
## 5. 주요 실행 경로
|
||||
|
||||
## 6. 실패 경로와 복구/번역
|
||||
|
||||
## 7. 트랜잭션·동시성·수명주기
|
||||
|
||||
## 8. 설정·기능 플래그·환경 차이
|
||||
|
||||
## 9. 퍼시스턴스/외부 시스템 세부
|
||||
|
||||
## 10. 테스트 레인과 실제 증명 범위
|
||||
|
||||
## 11. 빌드/ArchUnit/CI 강제 지점
|
||||
|
||||
## 12. 실제 사용 여부와 dead/unwired/duplicate 경로
|
||||
|
||||
## 13. Git/설계 문서에서 확인한 변화와 실패 기록
|
||||
|
||||
## 14. 런타임·터미널·브라우저 Evidence
|
||||
|
||||
## 15. 명시적 설계 이유와 추론을 구분한 정리
|
||||
|
||||
## 16. 확인한 것 / 확인하지 못한 것
|
||||
|
||||
## 17. 손볼 것
|
||||
|
||||
각 항목: 우선순위 → 사실 → 근거 → 왜 문제인가 → 확인 방법 → 후보/다음 단계.
|
||||
|
||||
## Source anchors
|
||||
|
||||
모든 핵심 주장에 source-index의 파일/심볼/테스트/evidence anchor를 연결한다.
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="366" viewBox="0 0 880 366" role="img">
|
||||
<title>요청 예산에서 파생되는 계층</title>
|
||||
<desc>배선된 요청 데드라인 상자에서 나가는 화살표가 없다. 네 파생 계층은 메서드로만 존재하고 프로덕션 호출자가 0 이라, 파생 전이 자체가 일어나지 않는다.</desc>
|
||||
<rect x="0" y="0" width="880" height="366" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">요청 예산에서 파생되는 계층</text>
|
||||
<rect x="28" y="181.0" width="262" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="159.0" y="215.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">요청 데드라인 — 배선됨</text>
|
||||
<rect x="458" y="76" width="394" height="268" rx="14" fill="none" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="474" y="100" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">파생 없음 — 프로덕션 호출자 0</text>
|
||||
<rect x="474" y="114" width="362" height="50" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="655.0" y="144.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">리졸버 예산</text>
|
||||
<rect x="474" y="172" width="362" height="50" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="655.0" y="202.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">DataLoader 배치 타임아웃</text>
|
||||
<rect x="474" y="230" width="362" height="50" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="655.0" y="260.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">다운스트림 데드라인</text>
|
||||
<rect x="474" y="288" width="362" height="50" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="655.0" y="318.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">구독 데드라인</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="230" viewBox="0 0 880 230" role="img">
|
||||
<title>응답을 실제로 만드는 쪽</title>
|
||||
<desc>상태 코드와 미디어 타입 규칙을 담은 http 패키지가 조립되지 않고, 실제 응답은 프레임워크가 만든다. 노출이 아니라 통제권의 문제다.</desc>
|
||||
<rect x="0" y="0" width="880" height="230" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">응답을 실제로 만드는 쪽</text>
|
||||
<rect x="28" y="66" width="560" height="134" rx="14" fill="none" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="44" y="88" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">Spring GraphQL</text>
|
||||
<rect x="44.0" y="100" width="255.0" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="171.5" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">상태 코드 규칙</text>
|
||||
<rect x="317.0" y="100" width="255.0" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="444.5" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">미디어 타입 협상</text>
|
||||
<rect x="624" y="100" width="228" height="54" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="738.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">http 패키지 — 미배선</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="394" viewBox="0 0 880 394" role="img">
|
||||
<title>패키지별 도달 경로</title>
|
||||
<desc>자동설정과 배치 로더 등록기에서 나가는 경로는 dataloader 에만 닿는다. fetch·pagination·mutation 으로는 화살표가 없다 — 어떤 배선 경로에도 놓여 있지 않기 때문이다.</desc>
|
||||
<rect x="0" y="0" width="880" height="394" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">패키지별 도달 경로</text>
|
||||
<rect x="28" y="74.0" width="262" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="159.0" y="108.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">자동설정 · 배치 로더 등록기</text>
|
||||
<rect x="474" y="76" width="378" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="663.0" y="108.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">dataloader</text>
|
||||
<line x1="296" y1="103.0" x2="468" y2="103.0" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="384" y="95.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">registrar 경유</text>
|
||||
<rect x="458" y="162" width="394" height="210" rx="14" fill="none" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="474" y="186" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">배선 경로 없음 — 화살표 없음</text>
|
||||
<rect x="474" y="200" width="362" height="50" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="655.0" y="230.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">fetch</text>
|
||||
<rect x="474" y="258" width="362" height="50" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="655.0" y="288.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">pagination</text>
|
||||
<rect x="474" y="316" width="362" height="50" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="655.0" y="346.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">mutation</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="366" viewBox="0 0 880 366" role="img">
|
||||
<title>인터셉터 순서</title>
|
||||
<desc>인증과 예외 처리의 순서가 곧 계약이다. 예외 처리가 인증보다 앞서면 인증 실패가 업무 오류로 보고된다.</desc>
|
||||
<rect x="0" y="0" width="880" height="366" fill="#ffffff"/>
|
||||
<text x="28" y="42" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">인터셉터 순서</text>
|
||||
<defs><marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="h" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<rect x="28" y="74" width="824" height="64" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="440" y="111" text-anchor="middle" font-family="system-ui,sans-serif" font-size="15" fill="#111827">인증</text>
|
||||
<rect x="28" y="164" width="824" height="64" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="440" y="201" text-anchor="middle" font-family="system-ui,sans-serif" font-size="15" fill="#111827">업무 처리</text>
|
||||
<rect x="28" y="254" width="824" height="64" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="440" y="291" text-anchor="middle" font-family="system-ui,sans-serif" font-size="15" fill="#111827">예외 변환</text>
|
||||
<line x1="440" y1="140" x2="440" y2="162" stroke="#374151" stroke-width="1.6" marker-end="url(#a)"/>
|
||||
<line x1="440" y1="230" x2="440" y2="252" stroke="#374151" stroke-width="1.6" marker-end="url(#a)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="248" viewBox="0 0 880 248" role="img">
|
||||
<title>타입이 고르는 자동설정</title>
|
||||
<desc>두 자동설정은 모두 기본 켜짐이지만 활성화를 가르는 것은 프로퍼티가 아니라 애플리케이션 타입이다. 두 아티팩트가 클래스패스에 함께 있어도 한쪽만 활성화된다.</desc>
|
||||
<rect x="0" y="0" width="880" height="248" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">타입이 고르는 자동설정</text>
|
||||
<rect x="28" y="109" width="262" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="159.0" y="143.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">웹 애플리케이션 타입</text>
|
||||
<rect x="474" y="76" width="378" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="663.0" y="108.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">MVC 자동설정</text>
|
||||
<line x1="296" y1="138" x2="468" y2="103" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="384" y="70" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">SERVLET</text>
|
||||
<rect x="474" y="142" width="378" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="663.0" y="174.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">WebFlux 자동설정</text>
|
||||
<line x1="296" y1="138" x2="468" y2="169" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="384" y="162" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">REACTIVE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="230" viewBox="0 0 880 230" role="img">
|
||||
<title>신원 모델이 닿는 범위</title>
|
||||
<desc>security 패키지의 타입들은 서로를 부르지만 패키지 바깥에서 들어오는 프로덕션 호출자가 없다. 교차 테넌트 가드도 그 안에만 있다.</desc>
|
||||
<rect x="0" y="0" width="880" height="230" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">신원 모델이 닿는 범위</text>
|
||||
<rect x="28" y="66" width="560" height="134" rx="14" fill="none" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="44" y="88" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">security 패키지</text>
|
||||
<rect x="44.0" y="100" width="164.0" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="126.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">WebSecurityContextBridge</text>
|
||||
<rect x="226.0" y="100" width="164.0" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="308.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">AuthenticationView</text>
|
||||
<rect x="408.0" y="100" width="164.0" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="490.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">rejectTenantInput</text>
|
||||
<rect x="624" y="100" width="228" height="54" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="738.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">바깥 프로덕션 호출자 없음</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="196" viewBox="0 0 880 196" role="img">
|
||||
<title>매처가 등록되는 순서</title>
|
||||
<desc>Spring Security 는 먼저 일치한 매처가 이긴다. 공개 경로가 먼저 등록되므로 제한 경로 규칙보다 앞선다.</desc>
|
||||
<rect x="0" y="0" width="880" height="196" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">매처가 등록되는 순서</text>
|
||||
<rect x="28.0" y="104" width="225.3" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="140.7" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">publicPaths permitAll</text>
|
||||
<rect x="327.3" y="104" width="225.3" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="440" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">RestrictedPathRule</text>
|
||||
<rect x="626.7" y="104" width="225.3" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="739.3" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">anyRequest authenticated</text>
|
||||
<line x1="259.3" y1="133" x2="321.3" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="290.3" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">먼저 등록</text>
|
||||
<line x1="558.7" y1="133" x2="620.7" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="589.7" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">다음 등록</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="344" viewBox="0 0 880 344" role="img">
|
||||
<title>설정 접두사와 소비자</title>
|
||||
<desc>두 접두사에서 그것을 읽는 Configuration 으로 화살표가 간다. 세 번째 접두사에는 화살표가 없다 — 그것을 읽어 조립하는 Configuration 이 저장소에 없기 때문이다.</desc>
|
||||
<rect x="0" y="0" width="880" height="344" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">설정 접두사와 소비자</text>
|
||||
<rect x="28" y="107.0" width="262" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="159.0" y="141.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">설정 접두사</text>
|
||||
<rect x="474" y="76" width="378" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="663.0" y="108.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">WebSocketConfig</text>
|
||||
<line x1="296" y1="136.0" x2="468" y2="103.0" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="384" y="95.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">ca-skeleton.websocket</text>
|
||||
<rect x="474" y="142" width="378" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="663.0" y="174.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">SockJs · Stomp</text>
|
||||
<line x1="296" y1="136.0" x2="468" y2="169.0" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="384" y="161.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">app.websocket-platform.advanced</text>
|
||||
<rect x="458" y="228" width="394" height="94" rx="14" fill="none" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="474" y="252" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">읽는 Configuration 없음</text>
|
||||
<rect x="474" y="266" width="362" height="50" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="655.0" y="296.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">backend.websocket</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="196" viewBox="0 0 880 196" role="img">
|
||||
<title>조립이 고정된 순서</title>
|
||||
<desc>설정을 묶고 교차 필드 규칙을 돌린 뒤에야 클라이언트와 연결이 만들어진다. 검증이 bean factory 메서드 안에 있어서 검증되지 않은 설정을 쓰는 bean 이 생기지 않는다.</desc>
|
||||
<rect x="0" y="0" width="880" height="196" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">조립이 고정된 순서</text>
|
||||
<rect x="28.0" y="104" width="225.3" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="140.7" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">설정 바인딩</text>
|
||||
<rect x="327.3" y="104" width="225.3" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="440" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">교차 필드 검증</text>
|
||||
<rect x="626.7" y="104" width="225.3" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="739.3" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">클라이언트 · 연결 생성</text>
|
||||
<line x1="259.3" y1="133" x2="321.3" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="290.3" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">bound settings</text>
|
||||
<line x1="558.7" y1="133" x2="620.7" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="589.7" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">검증 통과</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="276" viewBox="0 0 880 276" role="img">
|
||||
<title>부재를 지키는 세 자리</title>
|
||||
<desc>없는 명령이라는 선언이 API 문서에만 있지 않다. 구현 계층과 명령 정책 파일이 같은 부재를 각각 다시 막는다.</desc>
|
||||
<rect x="0" y="0" width="880" height="276" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">부재를 지키는 세 자리</text>
|
||||
<rect x="28" y="70" width="700" height="48" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="378.0" y="99.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">sdk/api 선언</text>
|
||||
<rect x="28" y="130" width="700" height="48" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="378.0" y="159.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">Lettuce 구현 계층</text>
|
||||
<rect x="28" y="190" width="700" height="48" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="378.0" y="219.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">redis-command-policy.yml</text>
|
||||
<line x1="796" y1="118" x2="796" y2="202" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="796" y="62" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">강제 방향</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="276" viewBox="0 0 880 276" role="img">
|
||||
<title>검사기에 대한 메타 검사</title>
|
||||
<desc>두 진입점의 대칭을 반사 검사가 강제하고, 그 검사기가 고장 나 항상 통과하는 상태를 잡는 메타 테스트가 위에 하나 더 있다.</desc>
|
||||
<rect x="0" y="0" width="880" height="276" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">검사기에 대한 메타 검사</text>
|
||||
<rect x="28" y="70" width="700" height="48" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="378.0" y="99.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">두 진입점 인터페이스</text>
|
||||
<rect x="28" y="130" width="700" height="48" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="378.0" y="159.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">ApiParityTest</text>
|
||||
<rect x="28" y="190" width="700" height="48" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="378.0" y="219.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">검사기에 대한 메타 테스트</text>
|
||||
<line x1="796" y1="118" x2="796" y2="202" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="796" y="62" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">검사 방향</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="248" viewBox="0 0 880 248" role="img">
|
||||
<title>두 모델이 공유하는 빌더</title>
|
||||
<desc>동기 표면과 반응형 표면이 같은 request builder 를 만들어 쓴다. 그래서 permit·예산·인코딩·명령 선택의 변경이 한쪽에만 적용될 수 없다.</desc>
|
||||
<rect x="0" y="0" width="880" height="248" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">두 모델이 공유하는 빌더</text>
|
||||
<rect x="28" y="109" width="262" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="159.0" y="143.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">ValueOperationRequests</text>
|
||||
<rect x="474" y="76" width="378" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="663.0" y="108.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">동기 표면</text>
|
||||
<line x1="296" y1="138" x2="468" y2="103" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="384" y="70" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">같은 요청 객체</text>
|
||||
<rect x="474" y="142" width="378" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="663.0" y="174.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">반응형 표면</text>
|
||||
<line x1="296" y1="138" x2="468" y2="169" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="384" y="162" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">같은 요청 객체</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="196" viewBox="0 0 880 196" role="img">
|
||||
<title>고정된 검증 순서</title>
|
||||
<desc>뒤 단계일수록 비싸므로 명백히 부적격한 명령은 인코딩도 전송도 하기 전에 거절된다.</desc>
|
||||
<rect x="0" y="0" width="880" height="196" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">고정된 검증 순서</text>
|
||||
<rect x="28.0" y="104" width="105.6" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="80.8" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">capability</text>
|
||||
<rect x="207.6" y="104" width="105.6" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="260.4" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">risk · permit</text>
|
||||
<rect x="387.2" y="104" width="105.6" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="440.0" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">namespace</text>
|
||||
<rect x="566.8" y="104" width="105.6" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="619.6" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">slot</text>
|
||||
<rect x="746.4" y="104" width="105.6" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="799.2" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">request budget</text>
|
||||
<line x1="139.6" y1="133" x2="201.6" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="170.6" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">버전 대조</text>
|
||||
<line x1="319.2" y1="133" x2="381.2" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="350.2" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">위험 등급</text>
|
||||
<line x1="498.8" y1="133" x2="560.8" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="529.8" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">키 소유</text>
|
||||
<line x1="678.4" y1="133" x2="740.4" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="709.4" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">단일 슬롯</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="196" viewBox="0 0 880 196" role="img">
|
||||
<title>원시 명령이 지나야 하는 두 문</title>
|
||||
<desc>조직이 정한 카탈로그 분류와 배포가 등록한 승인 둘 다를 통과해야 한다. 어느 쪽도 요청 시점에 결정되지 않는다.</desc>
|
||||
<rect x="0" y="0" width="880" height="196" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">원시 명령이 지나야 하는 두 문</text>
|
||||
<rect x="28.0" y="104" width="225.3" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="140.7" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">정책 카탈로그 RAW_ONLY</text>
|
||||
<rect x="327.3" y="104" width="225.3" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="440" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">ApprovedRawCommand 등록</text>
|
||||
<rect x="626.7" y="104" width="225.3" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="739.3" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">RedisRawGateway</text>
|
||||
<line x1="259.3" y1="133" x2="321.3" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="290.3" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">조직의 결정</text>
|
||||
<line x1="558.7" y1="133" x2="620.7" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="589.7" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">배포의 결정</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="196" viewBox="0 0 880 196" role="img">
|
||||
<title>등록과 실행의 분리</title>
|
||||
<desc>등록되지 않은 스크립트는 digest 가 없어서 서버에 닿을 방법이 없다. 그래서 검토된 것만 실행된다는 성질이 관례가 아니라 구조가 된다.</desc>
|
||||
<rect x="0" y="0" width="880" height="196" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">등록과 실행의 분리</text>
|
||||
<rect x="28.0" y="104" width="225.3" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="140.7" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">배포 시 스크립트 등록</text>
|
||||
<rect x="327.3" y="104" width="225.3" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="440" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">digest 대조</text>
|
||||
<rect x="626.7" y="104" width="225.3" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="739.3" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">EVALSHA 실행</text>
|
||||
<line x1="259.3" y1="133" x2="321.3" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="290.3" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">등록본 저장</text>
|
||||
<line x1="558.7" y1="133" x2="620.7" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="589.7" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">본문 일치</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="244" viewBox="0 0 880 244" role="img">
|
||||
<title>두 종착 상태</title>
|
||||
<desc>비terminal 상태에서는 정상 종료로 가거나 격리로 이탈할 수 있고, 두 종착 상태에서 나가는 전이는 없다.</desc>
|
||||
<rect x="0" y="0" width="880" height="244" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">두 종착 상태</text>
|
||||
<rect x="28.0" y="120" width="210.7" height="56" rx="26" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="133.3" y="153.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">비terminal 상태</text>
|
||||
<rect x="334.7" y="120" width="210.7" height="56" rx="26" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="440" y="153.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">PUBLISHED</text>
|
||||
<rect x="641.3" y="120" width="210.7" height="56" rx="26" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="746.7" y="153.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">QUARANTINED</text>
|
||||
<line x1="244.7" y1="148" x2="328.7" y2="148" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="286.7" y="138" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">정상 종료</text>
|
||||
<path d="M133.3,178 C133.3,232 746.7,232 746.7,178" fill="none" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4" marker-end="url(#ar)"/>
|
||||
<text x="440.0" y="228" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">격리로 이탈</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="230" viewBox="0 0 880 230" role="img">
|
||||
<title>선언된 것과 선언되지 않은 것</title>
|
||||
<desc>이 계층은 자기가 막는 것과 막지 못하는 것을 함께 적는다. 잔여 경로 연산은 문서에 선언되고 identity 검사로 감싸인다.</desc>
|
||||
<rect x="0" y="0" width="880" height="230" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">선언된 것과 선언되지 않은 것</text>
|
||||
<rect x="28" y="66" width="560" height="134" rx="14" fill="none" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="44" y="88" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">LocalPersistentPayloadOperations</text>
|
||||
<rect x="44.0" y="100" width="164.0" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="126.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">fileKey 재확인</text>
|
||||
<rect x="226.0" y="100" width="164.0" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="308.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">소유자 · 권한 재확인</text>
|
||||
<rect x="408.0" y="100" width="164.0" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="490.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">FileStore 재확인</text>
|
||||
<rect x="624" y="100" width="228" height="54" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="738.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">서술자 상대 대응물 없음</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="196" viewBox="0 0 880 196" role="img">
|
||||
<title>재시도 결정의 순서</title>
|
||||
<desc>싼 절대 차단이 먼저 오고 그다음 모호성, 그다음 상태별 규칙이 온다. 뒤 규칙이 앞 규칙이 금지한 것을 다시 허용할 수 없다.</desc>
|
||||
<rect x="0" y="0" width="880" height="196" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">재시도 결정의 순서</text>
|
||||
<rect x="28.0" y="104" width="150.5" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="103.2" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">절대 차단 여섯</text>
|
||||
<rect x="252.5" y="104" width="150.5" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="327.8" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">영구 실패 범주</text>
|
||||
<rect x="477.0" y="104" width="150.5" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="552.2" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">증거</text>
|
||||
<rect x="701.5" y="104" width="150.5" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="776.8" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">상태 · 실패별 규칙</text>
|
||||
<line x1="184.5" y1="133" x2="246.5" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="215.5" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">통과</text>
|
||||
<line x1="409.0" y1="133" x2="471.0" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="440.0" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">통과</text>
|
||||
<line x1="633.5" y1="133" x2="695.5" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="664.5" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">통과</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="196" viewBox="0 0 880 196" role="img">
|
||||
<title>시도마다 지나는 가드 순서</title>
|
||||
<desc>열린 회로가 요금 토큰이나 벌크헤드 permit 을 쓰기 전에 먼저 거절한다. 해제는 역순이다.</desc>
|
||||
<rect x="0" y="0" width="880" height="196" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">시도마다 지나는 가드 순서</text>
|
||||
<rect x="28.0" y="104" width="150.5" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="103.2" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">Circuit Breaker</text>
|
||||
<rect x="252.5" y="104" width="150.5" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="327.8" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">Rate Limiter</text>
|
||||
<rect x="477.0" y="104" width="150.5" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="552.2" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">Bulkhead</text>
|
||||
<rect x="701.5" y="104" width="150.5" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="776.8" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">HTTP 호출</text>
|
||||
<line x1="184.5" y1="133" x2="246.5" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="215.5" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">허가</text>
|
||||
<line x1="409.0" y1="133" x2="471.0" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="440.0" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">토큰</text>
|
||||
<line x1="633.5" y1="133" x2="695.5" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="664.5" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">permit</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="336" viewBox="0 0 880 336" role="img">
|
||||
<title>응답 크기 예산이 강제되는 자리</title>
|
||||
<desc>와이어 바이트 예산, 읽는 도중의 상한 강제, 디코드 바이트 예산, 오류 본문 제한 읽기가 읽는 순서대로 쌓여 있고 오른쪽에 읽는 방향 화살표가 있다.</desc>
|
||||
<rect x="0" y="0" width="880" height="336" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">응답 크기 예산이 강제되는 자리</text>
|
||||
<rect x="28" y="70" width="700" height="48" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="378.0" y="99.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">와이어 바이트 예산</text>
|
||||
<rect x="28" y="130" width="700" height="48" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="378.0" y="159.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">읽는 도중 상한 강제</text>
|
||||
<rect x="28" y="190" width="700" height="48" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="378.0" y="219.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">디코드 바이트 예산</text>
|
||||
<rect x="28" y="250" width="700" height="48" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="378.0" y="279.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">오류 본문 제한 읽기</text>
|
||||
<line x1="796" y1="118" x2="796" y2="262" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="796" y="62" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">읽는 순서</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="230" viewBox="0 0 880 230" role="img">
|
||||
<title>레지스트리가 닫힌 방식</title>
|
||||
<desc>LocalJsonSchemaRegistry 안에 넘겨받은 바이트, 동봉된 메타스키마, 허용 어휘가 놓이고 바깥에 원격 참조 로더와 동적 키워드가 빗금으로 놓인다.</desc>
|
||||
<rect x="0" y="0" width="880" height="230" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">레지스트리가 닫힌 방식</text>
|
||||
<rect x="28" y="66" width="560" height="134" rx="14" fill="none" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="44" y="88" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">LocalJsonSchemaRegistry</text>
|
||||
<rect x="44.0" y="100" width="164.0" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="126.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">넘겨받은 바이트</text>
|
||||
<rect x="226.0" y="100" width="164.0" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="308.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">동봉된 메타스키마</text>
|
||||
<rect x="408.0" y="100" width="164.0" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="490.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">허용 어휘</text>
|
||||
<rect x="624" y="100" width="228" height="54" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="738.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">원격 참조 로더</text>
|
||||
<rect x="624" y="166" width="228" height="54" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="738.0" y="198.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">동적 · 앵커 키워드</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="302" viewBox="0 0 880 302" role="img">
|
||||
<title>이름 없던 상태에 이름 붙이기</title>
|
||||
<desc>세 자리에서 이전에는 구분되지 않던 두 상황이 각각 이름을 얻었다. 왼쪽은 이름이 없던 상태이고 오른쪽은 지금의 표현이다.</desc>
|
||||
<rect x="0" y="0" width="880" height="302" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">이름 없던 상태에 이름 붙이기</text>
|
||||
<text x="28" y="82" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">이전</text>
|
||||
<text x="468" y="82" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">지금</text>
|
||||
<rect x="28" y="96" width="380" height="50" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="218.0" y="126.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">공급자 0인 플랫폼</text>
|
||||
<rect x="468" y="96" width="384" height="50" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="660.0" y="126.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">INGEST_ONLY 명시 선택</text>
|
||||
<rect x="28" y="158" width="380" height="50" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="218.0" y="188.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">자유 문자열 타입</text>
|
||||
<rect x="468" y="158" width="384" height="50" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="660.0" y="188.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">닫힌 ProviderType enum</text>
|
||||
<rect x="28" y="220" width="380" height="50" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="218.0" y="250.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">여덟 키를 항상 요구</text>
|
||||
<rect x="468" y="220" width="384" height="50" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="660.0" y="250.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">네 키 + 능력별 키</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="196" viewBox="0 0 880 196" role="img">
|
||||
<title>컴파일과 생성의 순서</title>
|
||||
<desc>설정 컴파일이 전부 끝난 뒤에야 provider 가 디렉터리·클라이언트·스레드·스케줄러·자격 조회를 만든다. 생성 도중 실패하면 이미 만든 것을 역순으로 닫는다.</desc>
|
||||
<rect x="0" y="0" width="880" height="196" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">컴파일과 생성의 순서</text>
|
||||
<rect x="28.0" y="104" width="225.3" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="140.7" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">compiler.compile(settings)</text>
|
||||
<rect x="327.3" y="104" width="225.3" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="440" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">contribution.create(provider)</text>
|
||||
<rect x="626.7" y="104" width="225.3" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="739.3" y="138.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">AssembledCapability</text>
|
||||
<line x1="259.3" y1="133" x2="321.3" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="290.3" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">검증된 바인딩</text>
|
||||
<line x1="558.7" y1="133" x2="620.7" y2="133" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="589.7" y="92" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">수명주기 소유권</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="380" viewBox="0 0 880 380" role="img">
|
||||
<title>어디서든 들어가고 나올 수 없는 분기</title>
|
||||
<desc>정상 사슬의 어느 단계에서도 분기 전이로 빠질 수 있고, 그 네 상태에서 나오는 전이는 없다.</desc>
|
||||
<rect x="0" y="0" width="880" height="380" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">어디서든 들어가고 나올 수 없는 분기</text>
|
||||
<rect x="28" y="175" width="262" height="58" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="159.0" y="209.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">정상 사슬의 어느 단계</text>
|
||||
<rect x="474" y="76" width="378" height="54" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="663.0" y="108.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">EXPIRED</text>
|
||||
<line x1="296" y1="204" x2="468" y2="103" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="384" y="70" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">분기 전이</text>
|
||||
<rect x="474" y="142" width="378" height="54" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="663.0" y="174.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">ABORTED</text>
|
||||
<line x1="296" y1="204" x2="468" y2="169" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<rect x="474" y="208" width="378" height="54" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="663.0" y="240.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">FAILED</text>
|
||||
<line x1="296" y1="204" x2="468" y2="235" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<rect x="474" y="274" width="378" height="54" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="663.0" y="306.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">CORRUPT</text>
|
||||
<line x1="296" y1="204" x2="468" y2="301" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="276" viewBox="0 0 880 276" role="img">
|
||||
<title>보고 가능한 계약이 만들어지는 층</title>
|
||||
<desc>능력 이름은 어휘일 뿐이고 지원 등급은 별도 타입이 붙이며 실제 보고는 조립이 구성한다. 코드가 classpath 에 있다는 것과 기본 지원한다는 것을 같은 말로 두지 않는다.</desc>
|
||||
<rect x="0" y="0" width="880" height="276" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">보고 가능한 계약이 만들어지는 층</text>
|
||||
<rect x="28" y="70" width="700" height="48" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="378.0" y="99.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">JpaCapability — 어휘</text>
|
||||
<rect x="28" y="130" width="700" height="48" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="378.0" y="159.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">CapabilitySupport — 등급 결합</text>
|
||||
<rect x="28" y="190" width="700" height="48" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="378.0" y="219.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">app-bootstrap composition — 실제 보고</text>
|
||||
<line x1="796" y1="118" x2="796" y2="202" stroke="#374151" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||
<text x="796" y="62" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">보고 구성</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="880" height="230" viewBox="0 0 880 230" role="img">
|
||||
<title>질의 API가 표현하지 않는 것</title>
|
||||
<desc>keyset 질의 API 안에 정렬 키와 size 더하기 1과 서명된 커서가 놓이고 바깥에 offset과 total count 질의가 빗금으로 놓인다.</desc>
|
||||
<rect x="0" y="0" width="880" height="230" fill="#ffffff"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#374151"/></marker><pattern id="hx" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="7" stroke="#9ca3af" stroke-width="2"/></pattern></defs>
|
||||
<text x="28" y="40" font-family="system-ui,sans-serif" font-size="19" font-weight="600" fill="#111827">질의 API가 표현하지 않는 것</text>
|
||||
<rect x="28" y="66" width="560" height="134" rx="14" fill="none" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="44" y="88" font-family="system-ui,sans-serif" font-size="13" fill="#6b7280">keyset 질의 API</text>
|
||||
<rect x="44.0" y="100" width="164.0" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="126.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">KeysetTerm 정렬 키</text>
|
||||
<rect x="226.0" y="100" width="164.0" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="308.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">요청 size + 1</text>
|
||||
<rect x="408.0" y="100" width="164.0" height="54" rx="10" fill="#eef2ff" stroke="#374151" stroke-width="1.6"/>
|
||||
<text x="490.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">서명된 nextCursor</text>
|
||||
<rect x="624" y="100" width="228" height="54" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="738.0" y="132.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">offset · page number</text>
|
||||
<rect x="624" y="166" width="228" height="54" rx="10" fill="url(#hx)" stroke="#374151" stroke-width="1.6" stroke-dasharray="6 4"/>
|
||||
<text x="738.0" y="198.0" text-anchor="middle" font-family="system-ui,sans-serif" font-size="13" fill="#111827">total count 질의</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |