diff --git a/.agents/skills/analyzing-codebase-for-tech-log/SKILL.md b/.agents/skills/analyzing-codebase-for-tech-log/SKILL.md new file mode 100644 index 0000000..55984df --- /dev/null +++ b/.agents/skills/analyzing-codebase-for-tech-log/SKILL.md @@ -0,0 +1,51 @@ +--- +name: analyzing-codebase-for-tech-log +description: Use when a project under <분석 대상 저장소> must be deeply analyzed and documented under docs, especially when the repository is too large for one pass and analysis must proceed by bounded module or subsystem. +--- + +# Analyzing Codebase For Tech Log + +## Goal + +Produce a highly detailed, source-traceable engineering analysis. This stage discovers facts and evidence; it does **not** write Tech Log records yet. + +## Required sequence + +1. **First read `<분석 대상 저장소>/analysis-queue.yaml`.** Before inspecting any project contents, apply `references/queue-contract.md`: reconcile newly discovered project directories, preserve queue order, and determine the single active project. +2. If an `IN_PROGRESS` project exists, analyze only that project. If none exists, activate the first `PENDING` project in queue order. Never preempt an active project because a new project appeared. +3. For the selected `<분석 대상 저장소>`, check the nearest `AGENTS.md` or equivalent repository instructions. +4. Record Git revision and `git status` when Git is available. Never modify or reset user source as part of analysis. +5. Read `docs/<프로젝트>/state.json` if it exists; otherwise initialize from `docs/_templates`. +6. Map repository/build/module boundaries before choosing a scope. +7. If the repository is large, select one bounded unanalysed module/subsystem and analyze it completely. Do not skim the whole repository and call that detailed analysis. +8. Update `source-index.md`, the bounded analysis file, coverage ledger, evidence, and `state.json`. +9. Capture runtime evidence only where it resolves a material uncertainty or verifies a significant claim. +10. Continue the same project across runs until all intended scopes are complete. Then synthesize `final/document.md` without dropping provenance or limitations, mark its queue entry `COMPLETE`, and clear `activeProject`. Do not start the next project before this completion transition. + +Read `references/queue-contract.md`, `references/analysis-contract.md`, `references/deep-analysis-standard.md`, and `references/evidence-contract.md` before analysis. + +## Evidence vocabulary + +Label statements internally as: + +- **observed:** directly seen in code/config/test/runtime/git evidence; +- **inferred:** conclusion logically derived from observed sources; +- **hypothesis:** plausible explanation not yet verified; +- **unknown:** material information not available; +- **external:** knowledge from outside the codebase, clearly separated from project observation. + +Do not turn inference into observation in the final document. + +## Depth rule + +A selected bounded scope is an **exhaustive-reading unit**, not a representative-sampling unit. Build an inventory first, then account for every production source/config/build/migration/test file that materially belongs to that scope. Each item must be marked `FULL_READ`, `STRUCTURAL_ONLY`, or `EXCLUDED` with a reason. `EXCLUDED` is allowed only when dependency/import/ownership evidence shows it does not contribute to the scope being documented. + +For the selected scope, trace representative behavior end-to-end where applicable: entry point → application policy → domain/state → persistence/external adapter → observable result. Also trace failure paths, transactions, concurrency, lifecycle, configuration, tests, build-time enforcement, runtime wiring, dead/unwired paths, and historical bug/decision evidence when they materially affect the architecture. + +Do not stop at "what classes exist". Explain **why the shape exists** only when code comments, tests, design docs, Git history, runtime evidence, or a clearly labeled inference supports the explanation. + +There is no target document length. A 3,000+ line module analysis is acceptable when the source warrants it; artificial verbosity is not. Completeness is judged by the coverage ledger and source traceability, not by prose length. + +## Stop conditions + +Do not run destructive/state-changing commands merely to create evidence. Do not expose secrets. If a runtime check would alter production or shared external state, leave it as an evidence task instead. diff --git a/.agents/skills/analyzing-codebase-for-tech-log/references/analysis-contract.md b/.agents/skills/analyzing-codebase-for-tech-log/references/analysis-contract.md new file mode 100644 index 0000000..6350ed8 --- /dev/null +++ b/.agents/skills/analyzing-codebase-for-tech-log/references/analysis-contract.md @@ -0,0 +1,67 @@ +# Detailed Analysis Contract + +## First pass: project map + +Before interpreting architecture, establish: + +- repository revision/snapshot; +- build system and top-level build graph; +- modules and their declared dependencies; +- runtime applications/entry points; +- persistence/messaging/cache/object storage/external service adapters; +- test modules and test types; +- configuration sources and environment boundaries. + +The map is evidence, not a taxonomy exercise. Do not infer module responsibilities from names alone; inspect representative source and wiring. + +## Bounded analysis + +A bounded scope should be small enough that one run can answer all of these: + +1. What public/consumed surface does the scope expose? +2. What does it depend on and why? +3. How is it wired into a running application? +4. What state/data does it read or change? +5. What are its main success and failure paths? +6. What tests exercise it, and what do those tests actually prove? +7. What behavior/configuration is declared but not actually connected? +8. What operational or build-time constraints affect it? +9. Which observations could become Case/Reference/Question/Decision material later? + +## Code tracing + +Prefer symbol/path-based traces over broad summaries. Record exact source anchors in `source-index.md` and analysis prose. + +Where useful, trace: + +- inbound request/event/command; +- DTO/contract mapping; +- application use case/port; +- domain invariants/state transitions; +- transaction boundary; +- outbound port and adapter; +- ORM/query behavior; +- cache/messaging semantics; +- error translation; +- logs/metrics/tracing; +- response/event side effect. + +## Tests + +Do not equate a green test suite with a verified architectural claim. For each important test, state what input is exercised, which real components are replaced, and what assertion proves. When a rule is enforced by build configuration or architecture tests, identify the failure point that would catch a violation. + +## Git history + +Use history when current code cannot explain why a boundary/decision exists or when a Case depends on an evolution sequence. A current code shape alone proves existence, not historical motivation. + +## Final synthesis + +`final/document.md` is not a shortened executive summary. It is the detailed project analysis assembled from bounded documents. Preserve: + +- measured/observed behavior; +- failed approaches when evidenced; +- alternatives actually considered; +- unresolved questions; +- explicit decisions; +- limitations of the analysis; +- evidence/source references. diff --git a/.agents/skills/analyzing-codebase-for-tech-log/references/deep-analysis-standard.md b/.agents/skills/analyzing-codebase-for-tech-log/references/deep-analysis-standard.md new file mode 100644 index 0000000..bc93457 --- /dev/null +++ b/.agents/skills/analyzing-codebase-for-tech-log/references/deep-analysis-standard.md @@ -0,0 +1,133 @@ +# Deep Analysis Standard + +This is the completion standard for one bounded module/subsystem. A run may stop mid-scope, but it may not mark the scope complete until every gate below is satisfied. + +## 1. Quantified scope map + +Measure before interpreting. Record at least when applicable: + +- module/leaf path and declared dependencies; +- production file count and approximate LOC; +- package/directory count and top-level children; +- public interfaces/ports; +- entities/tables/repositories; +- migrations and independent migration streams; +- unit/integration/contract/performance/architecture test counts; +- runtime memberships/entry applications; +- major feature flags/config namespaces. + +Do not claim "fully read" without a countable denominator. + +## 2. Coverage ledger + +Create a ledger for the selected scope. Every materially relevant file or coherent file group receives one disposition: + +- `FULL_READ` — contents read and incorporated into analysis; +- `STRUCTURAL_ONLY` — structure/signature inspected because internals add no further information; +- `EXCLUDED` — intentionally outside this document, with concrete dependency/ownership evidence explaining why. + +Record counts by disposition. A scope cannot be `COMPLETE` while relevant files are `UNCLASSIFIED`. + +## 3. Architecture reconstruction + +Reconstruct from code rather than names: + +- why the module exists and what it explicitly is not; +- package/component ownership; +- allowed and observed dependency edges; +- source dependency versus runtime call flow; +- composition-root wiring and conditional activation; +- build/ArchUnit/registry gates and the exact failure point; +- optional/experimental/stable boundaries. + +If a design document says one thing and the current code says another, record the drift; do not silently reconcile it. + +## 4. Contract and invariant extraction + +For important types/functions/configuration, identify: + +- constructor/type invariants; +- bounds, allowlists, fail-closed behavior; +- state machines and legal transitions; +- transaction/retry/idempotency semantics; +- concurrency and lifecycle assumptions; +- security/privacy/redaction constraints; +- database/provider-specific behavior. + +Prefer the exact enforcing code path over a generic explanation. + +## 5. Success and failure mechanics + +For each major capability, cover both: + +- normal execution path; +- material failure paths and their translation/recovery behavior. + +Look for silent failure modes: configuration that looks enabled but is not wired, green tests that replace the real failing component, unused beans/classes, fallback behavior that hides typos, provider differences, partial migration states, cache coherence assumptions, or retry paths that production never calls. + +## 6. Tests as evidence + +Inventory test lanes and explain what they prove. For important claims, identify: + +- test class/path; +- real versus replaced components; +- input/fixture; +- decisive assertion; +- what remains unproved. + +Run safe tests or focused probes when needed. Do not infer production behavior solely from test names. + +## 7. History and rationale + +Use comments/docs/Git history when needed to distinguish: + +- current fact; +- explicit historical rationale; +- inferred rationale; +- old behavior/bug; +- fix and regression guard. + +Preserve useful failure history. A bug fix is more valuable when the document explains why the bug was possible and what now prevents recurrence. + +## 8. Static reachability and wiring checks + +For major abstractions and registered components, check whether production code actually consumes them. Search injection sites, references, registration, conditional imports, and runtime membership. Record: + +- active path; +- duplicate path; +- dead/unwired path; +- configuration mismatch; +- declared capability with insufficient evidence. + +## 9. Runtime / database evidence + +When safe and useful, verify claims with the real engine/runtime rather than an in-memory substitute. Preserve command/output in raw evidence and render terminal UI only from that captured output. State the environment and what the result does **not** generalize to. + +## 10. Improvement backlog + +End each completed bounded analysis with findings discovered while reading. Prioritize `P0/P1/P2/P3` (or justify another scheme). Each item should contain: + +- fact/evidence; +- why it matters; +- exact verification command/test; +- candidate options when supported; +- whether it belongs to Case, Reference, Open Question, or Decision later. + +Do not invent a defect merely to fill this section. An empty backlog is allowed if the evidence supports it. + +## 11. Completion gate + +A bounded scope is complete only when: + +- quantified scope denominator exists; +- coverage ledger has no unclassified relevant items; +- top-level package/component map is accounted for; +- build/runtime wiring is traced; +- important invariants and failure paths are documented; +- tests and evidence are mapped to claims; +- explicit rationale is separated from inference; +- dead/unwired/duplicate paths were checked; +- limitations and excluded areas are stated; +- improvement backlog was considered. + +If this cannot fit reliably in one run, keep the scope `IN_PROGRESS` and continue it on the next scheduled run. Never lower the depth standard to finish on schedule. diff --git a/.agents/skills/analyzing-codebase-for-tech-log/references/evidence-contract.md b/.agents/skills/analyzing-codebase-for-tech-log/references/evidence-contract.md new file mode 100644 index 0000000..915b767 --- /dev/null +++ b/.agents/skills/analyzing-codebase-for-tech-log/references/evidence-contract.md @@ -0,0 +1,39 @@ +# Evidence Contract + +## Primary evidence first + +Store command output, logs, generated query plans, benchmark results, browser observations, and other primary material under `docs/<프로젝트>/evidence/raw/` before creating presentation assets. + +## Terminal evidence + +For a command used as evidence, retain: + +- exact command (without embedded credentials); +- working directory; +- executed timestamp; +- exit code; +- raw stdout/stderr; +- source revision when relevant. + +Render terminal UI with `./tools/terminal-evidence/render_terminal.py`. + +The visual asset is explanatory. The raw evidence is the provenance. + +## Browser evidence + +A screenshot record should state: + +- URL/origin; +- code revision/environment; +- setup state; +- action performed; +- what visible/network fact the screenshot demonstrates; +- what it does not demonstrate. + +## Diagram evidence + +Architecture/sequence/state SVGs are derived explanations. Their nodes/edges must be traceable to code/config/runtime evidence. Do not draw a desired architecture and present it as the current architecture. + +## Measurements + +Every metric needs a context: dataset/workload, environment, relevant configuration, measurement method, and comparison condition. Preserve raw measurement results where practical. diff --git a/.agents/skills/analyzing-codebase-for-tech-log/references/queue-contract.md b/.agents/skills/analyzing-codebase-for-tech-log/references/queue-contract.md new file mode 100644 index 0000000..d736f4e --- /dev/null +++ b/.agents/skills/analyzing-codebase-for-tech-log/references/queue-contract.md @@ -0,0 +1,87 @@ +# Analysis Queue Contract + +`<분석 대상 저장소>/analysis-queue.yaml` is the first control file read by every detailed-analysis run. It is the SSOT for project order, active-project ownership, and explicit reanalysis requests. + +## State model + +Allowed project states: + +- `PENDING` — queued, never started for the current analysis history; +- `IN_PROGRESS` — the project currently owned by the 09:00 analysis run; +- `COMPLETE` — all intended scopes and `final/document.md` are complete for the recorded source snapshot; +- `REANALYZE` — a previously completed project explicitly queued for another analysis cycle after source changes or a requested re-review; +- `BLOCKED` — the active project cannot continue because required source, instructions, or environment are unavailable; +- `SKIPPED` — explicitly excluded by the user or queue owner. + +At most one project may be `IN_PROGRESS`. `activeProject` must be `null`, or name the project currently owned by the analysis worker. An owned project may be `IN_PROGRESS` or `BLOCKED`. A `REANALYZE` entry is queued, not active, until a scheduled run activates it. + +## Mandatory run order + +1. **Read `analysis-queue.yaml` before scanning project contents.** +2. Discover direct project directories under `<분석 대상 저장소>`. Ignore control files and hidden infrastructure directories. +3. Append newly discovered, unlisted projects to the **end** of `projects` as `PENDING`. Never insert them ahead of existing entries automatically. +4. If `activeProject` names an `IN_PROGRESS` project, continue only that project. +5. If `activeProject` names a `BLOCKED` project, do not start another project. Re-check only the blocking prerequisite; resume as `IN_PROGRESS` when resolved, otherwise leave it blocked and stop. +6. If there is no active project, scan queue entries from top to bottom and choose the first actionable entry whose state is `PENDING` or `REANALYZE`. +7. For `PENDING`, set it to `IN_PROGRESS`, set `activeProject`, initialize/continue `docs/<프로젝트>/state.json`, and run the normal exhaustive analysis cycle. +8. For `REANALYZE`, execute the reanalysis activation procedure below, then set it to `IN_PROGRESS` and set `activeProject`. +9. Continue the active project across scheduled runs until its required scopes are complete and `final/document.md` is synthesized for the target source snapshot. +10. Only then mark the entry `COMPLETE`, update the completed source revision, clear `activeProject`, and allow a later scheduled run to select the next actionable entry. + +Do not begin another project in the same run after completing one. Completion creates a clean scheduling boundary. + +## Reanalysis activation + +`REANALYZE` is an explicit user request to analyze a project again **without deleting the previous detailed analysis**. + +Before changing `REANALYZE` to `IN_PROGRESS`: + +1. Read the existing `docs/<프로젝트>/state.json` and `final/document.md`. +2. Resolve the previous completed source snapshot. Prefer `finalDocument.sourceRevision`; fall back only to another explicitly recorded completed revision. If no trustworthy baseline exists, mark the project `BLOCKED` with a note rather than pretending this is incremental reanalysis. +3. Resolve the current target source revision. For Git repositories, record `git rev-parse HEAD` and working-tree status. Do not modify/reset source. +4. Compare baseline → target before reopening scopes. Record changed paths and the evidence used to map those paths to bounded scopes. +5. Set `reanalysis.baselineRevision`, `reanalysis.targetRevision`, `reanalysis.changedPaths`, `reanalysis.impactedScopes`, increment `analysisCycle`, and set `reanalysis.requestedAt`. +6. Choose a reanalysis mode: + - `IMPACTED_SCOPES` when changed paths can be mapped confidently to bounded scopes and project/module boundaries remain stable; + - `FULL_PROJECT` when module/build boundaries, shared contracts, architecture rules, cross-cutting configuration, migration ownership, generated sources, or scope mapping itself changed, or when impact cannot be bounded confidently. +7. Reopen only the impacted scopes for `IMPACTED_SCOPES`, but keep previous analysis as historical baseline. Rebuild the project-wide synthesis after those scopes complete. +8. For `FULL_PROJECT`, re-establish the project inventory and coverage ledger from the target snapshot and revalidate every intended scope. + +A reanalysis cycle must never silently overwrite the fact that earlier documents described an earlier source snapshot. Preserve revision provenance in state and analysis prose where it matters. + +## Reanalysis completion + +When reanalysis finishes: + +- update every reopened scope to complete for the target revision; +- synthesize `final/document.md` again, including material changes from the prior snapshot when relevant; +- set `finalDocument.sourceRevision` to the target revision; +- set `reanalysis.completedAt`; +- mark the queue entry `COMPLETE`; +- clear `activeProject`. + +The 10:00 root-tree stage will see the changed final document and may then update decomposition/readiness. The 11:00 generation stage remains grounded in that updated tree. + +## New projects and ordering + +A new directory may appear while another project is being analyzed. Append it as `PENDING`; **do not preempt the active project**. `REANALYZE` also does not preempt the active project. The user may reorder queued `PENDING` and `REANALYZE` entries manually. Automatic runs never reorder existing entries. + +## Blocked projects + +If the active project disappears, cannot be read, lacks a trustworthy reanalysis baseline, or requires an unavailable prerequisite, mark it `BLOCKED`, retain it as `activeProject`, record the reason, and stop. Do not silently jump to the next project. Resuming means returning the same entry to `IN_PROGRESS`; skipping requires explicit `SKIPPED` and clearing `activeProject`. + +## Example: explicit reanalysis request + +```yaml +version: 1 +activeProject: null +projects: + - name: backend-clean-architecture + status: REANALYZE + - name: tech-log-backend + status: PENDING + - name: ca-tmpl + status: PENDING +``` + +On the next run, if the first project has a trustworthy completed baseline, it becomes the active `IN_PROGRESS` project and begins a new analysis cycle. diff --git a/.agents/skills/deriving-tech-log-root-tree/SKILL.md b/.agents/skills/deriving-tech-log-root-tree/SKILL.md new file mode 100644 index 0000000..3a7ca7c --- /dev/null +++ b/.agents/skills/deriving-tech-log-root-tree/SKILL.md @@ -0,0 +1,38 @@ +--- +name: deriving-tech-log-root-tree +description: Use when a completed or substantially completed docs project analysis must be decomposed into grounded Tech Log Topics and candidate Case, Reference, Open Question, and Decision records. +--- + +# Deriving Tech Log Root Tree + +## Core rule + +**Discover record candidates from evidence already present in the detailed analysis. Do not brainstorm a content calendar.** + +## Required sequence + +1. Read project `state.json`, `final/document.md`, and `source-index.md`. +2. Read bounded analysis files when the final document's anchor is not enough to judge classification. +3. Identify coherent Topics from shared engineering problem spaces, not merely folder/module names. +4. Within each Topic, identify concrete incidents first (Case), then reusable rules (Reference), unresolved unknowns (Open Question), and explicit project choices (Decision). +5. Write the human-readable PROJECT/TOPIC tree. +6. Add a Node Specification for every title with source anchors, readiness, relations, and kind-specific metadata. +7. Run `references/decomposition-checklist.md`. +8. Hash the source detailed document and record the project revision so downstream generation can detect staleness. + +Use `.agents/skills/writing-tech-log-records/references/root-tree-contract.md` as the output contract. + +## Topic boundary + +A Topic is a stable problem/decision area whose records share terminology, evidence, and relations. It should be broad enough to connect several records when the evidence supports them, but narrow enough that its References and Decisions remain coherent. + +Do not create one Topic per source file. Do not force unrelated incidents into one Topic because they use the same framework. + +## Classification discipline + +- Case title names the concrete engineering problem/verification, not a generic technology lesson. +- Reference title names a reusable criterion/distinction. +- Open Question title states an uncertainty that is still unresolved. +- Decision title states an actual/proposed project direction evidenced in sources. + +Branches may be empty. Symmetry is not a quality goal. diff --git a/.agents/skills/deriving-tech-log-root-tree/references/decomposition-checklist.md b/.agents/skills/deriving-tech-log-root-tree/references/decomposition-checklist.md new file mode 100644 index 0000000..042e5aa --- /dev/null +++ b/.agents/skills/deriving-tech-log-root-tree/references/decomposition-checklist.md @@ -0,0 +1,43 @@ +# Root Tree Decomposition Checklist + +## Source integrity + +- [ ] The tree records the detailed document hash and project revision/snapshot. +- [ ] Every node has at least one source anchor. +- [ ] Source anchors actually contain the material implied by the title. +- [ ] Runtime-dependent claims name evidence or use `NEEDS_EVIDENCE`. + +## Topic quality + +- [ ] Topic is a coherent engineering problem space rather than a directory name. +- [ ] Two Topics do not merely split the same causal chain arbitrarily. +- [ ] A large Topic is split when its records no longer share useful relations/criteria. + +## Case + +- [ ] There is a specific incident, experiment, failure, diagnosis, or verification sequence. +- [ ] The title can be understood without inventing a historical story. +- [ ] The conclusion is bounded by actual evidence. + +## Reference + +- [ ] The rule is reusable beyond the originating incident. +- [ ] It is not the Case summary rewritten declaratively. +- [ ] Scope and exception can be stated from sources. + +## Open Question + +- [ ] The answer is not already in the analysis. +- [ ] Known/unknown/next verification are separable. +- [ ] Candidate options are included only when sources really considered them. + +## Decision + +- [ ] A project choice is explicitly recorded or user-supplied. +- [ ] `technology is present` is not being treated as rationale. +- [ ] `NEEDS_DECISION` is used if the direction is only a recommendation. + +## Duplication + +- [ ] No two nodes have the same primary purpose. +- [ ] Relations are used instead of copying one record's entire content into another. diff --git a/.agents/skills/refactoring-from-analysis/SKILL.md b/.agents/skills/refactoring-from-analysis/SKILL.md new file mode 100644 index 0000000..0ec7244 --- /dev/null +++ b/.agents/skills/refactoring-from-analysis/SKILL.md @@ -0,0 +1,34 @@ +--- +name: refactoring-from-analysis +description: Use when a completed codebase analysis should be turned into one bounded, evidence-backed refactoring WorkItem and implemented in isolation. +--- + +# Refactoring From Analysis + +## Goal + +Use `docs/<프로젝트>` as high-value context for a bounded refactor while treating the current `<분석 대상 저장소>` as source truth. One execution handles at most one WorkItem. + +## Required sequence + +1. Read `<분석 대상 저장소>/refactor-queue.yaml` and the selected `docs/<프로젝트>/refactor///work-item.json`. +2. Read the project's completed `document-detail` analysis and source anchors cited by the WorkItem. +3. Confirm the analysis queue entry is `COMPLETE`, its completed source revision equals current repository HEAD, and the source working tree is clean. Otherwise do not refactor. +4. Re-check the finding against current code. If it no longer exists, mark the item `REJECTED` with evidence; do not force a change. +5. Read `references/refactor-queue-contract.md`, `references/work-item-contract.md`, `references/type-strategies.md`, and `references/evidence-contract.md`. For `PERFORMANCE`, also read `references/performance-evidence-contract.md`. +6. Create/use an isolated Git worktree/branch for the WorkItem. Never implement directly in the analysis source checkout. +7. Follow the strategy selected by `type` and `scope`. +8. Preserve raw verification evidence under `docs/<프로젝트>/refactor///`. +9. Run `scripts/verify-refactor-work-item.py ` before moving to `WAITING_APPROVAL`. + +## Performance ordering rule + +For `PERFORMANCE`, the state/operation sequence is mandatory: + +`READY → BASELINING → baseline capture at analysisRevision → IN_PROGRESS → source change → VERIFYING → after capture under the same measurement contract → comparison → functional regression checks → WAITING_APPROVAL`. + +`IN_PROGRESS` means source-changing work is now allowed, so a performance item may not enter it until baseline raw evidence and baseline metadata exist. No source-changing refactor begins before that baseline exists. If comparable measurement conditions cannot be maintained, mark the item `BLOCKED` or the comparison `INCOMPARABLE`; never claim improvement. + +## Bounded change rule + +If a WorkItem expands beyond its declared scope or uncovers an independent problem, stop expanding the diff. Create another candidate WorkItem instead. A large project or module is not permission for a large refactor item. diff --git a/.agents/skills/refactoring-from-analysis/references/evidence-contract.md b/.agents/skills/refactoring-from-analysis/references/evidence-contract.md new file mode 100644 index 0000000..99d23a5 --- /dev/null +++ b/.agents/skills/refactoring-from-analysis/references/evidence-contract.md @@ -0,0 +1,20 @@ +# Refactoring Evidence Contract + +Every WorkItem retains enough evidence for another reviewer or later Tech Log reanalysis to answer: + +1. What source revision was analyzed? +2. What exact problem was confirmed in current code? +3. What changed? +4. What commands/tests/measurements were run? +5. What raw outputs support the result? +6. What conditions or limitations apply? + +Store: + +- execution/measurement environment; +- raw baseline and after output when the type requires comparison; +- functional/architecture/integration verification output; +- changed-file/diff summary; +- explicit acceptance result and limitations. + +Evidence is append-only for a completed cycle where practical. Do not replace a failed raw run with only the successful run; retain material failed attempts when they explain the final result. Never store secrets. diff --git a/.agents/skills/refactoring-from-analysis/references/performance-evidence-contract.md b/.agents/skills/refactoring-from-analysis/references/performance-evidence-contract.md new file mode 100644 index 0000000..b9530b3 --- /dev/null +++ b/.agents/skills/refactoring-from-analysis/references/performance-evidence-contract.md @@ -0,0 +1,37 @@ +# Performance Evidence Contract + +A performance refactor is not complete because code looks faster. It needs a comparable before/after experiment. + +## Lifecycle + +A performance item uses `READY → BASELINING → IN_PROGRESS → VERIFYING → WAITING_APPROVAL`. `BASELINING` is measurement-only. Source changes are forbidden until the baseline at `analysisRevision` has been retained and the item moves to `IN_PROGRESS`. + +## Before code changes + +Freeze the measurement contract in `work-item.json`: + +- exact command or reproducible procedure; +- cwd; +- environment record; +- dataset/fixture/load profile; +- warmup and iteration policy when relevant; +- metrics and units; +- acceptance criteria. + +Capture baseline raw output under `evidence/baseline/raw/` and fill `evidence/baseline/metadata.json` with source revision, command, cwd, exit code, dataset, metrics, and raw file references. + +## After code changes + +Use the same measurement contract. Capture raw output under `evidence/after/raw/` and the matching metadata file. + +## Comparison + +`evidence/comparison.md` must state whether conditions are materially equivalent and list baseline, after, delta, acceptance result, functional regression checks, conclusion, and limitations. + +Allowed conclusions: `IMPROVED`, `NEUTRAL`, `REGRESSED`, `INCOMPARABLE`. + +If command/procedure, metric definition, dataset/load profile, or material environment differs enough to invalidate comparison, use `INCOMPARABLE`. Do not convert incomparable measurements into an improvement claim. + +## Evidence quality + +Retain raw output. A hand-written summary alone is insufficient. Do not fabricate missing runs. Avoid secrets at command construction time rather than relying on later redaction. diff --git a/.agents/skills/refactoring-from-analysis/references/refactor-queue-contract.md b/.agents/skills/refactoring-from-analysis/references/refactor-queue-contract.md new file mode 100644 index 0000000..d607658 --- /dev/null +++ b/.agents/skills/refactoring-from-analysis/references/refactor-queue-contract.md @@ -0,0 +1,28 @@ +# Refactor Queue Contract + +`<분석 대상 저장소>/refactor-queue.yaml` is the ordering SSOT for bounded refactoring work. + +## Responsibility split + +- Queue: order, active item, summary state. +- `docs/<프로젝트>/refactor///work-item.json`: detailed problem, goal, type, scope, acceptance criteria, evidence references. + +## Selection + +1. Ignore items whose analysis snapshot is no longer `COMPLETE`, revision-current, and clean. +2. Among actionable `READY` items, lower priority number wins: `P0` → `P1` → `P2` → `P3`. +3. For equal priority, file order wins. +4. At most one WorkItem is active. Do not start another while the active item is baselining, changing source, verifying, or waiting for approval. +5. One scheduled execution works on at most one bounded item. + +## Summary entry shape + +```yaml +- project: backend-clean-architecture + id: RF-001 + priority: P1 + status: READY + detail: docs/<프로젝트>/refactor/backend-clean-architecture/RF-001/work-item.json +``` + +The queue does not duplicate detailed analysis or evidence. If queue summary and `work-item.json` disagree, stop and reconcile instead of guessing. diff --git a/.agents/skills/refactoring-from-analysis/references/type-strategies.md b/.agents/skills/refactoring-from-analysis/references/type-strategies.md new file mode 100644 index 0000000..a1d7624 --- /dev/null +++ b/.agents/skills/refactoring-from-analysis/references/type-strategies.md @@ -0,0 +1,28 @@ +# Refactoring Type Strategies + +Type selects mandatory work and verification. Scope controls how broadly dependencies and regressions must be checked. + +| Type | Mandatory strategy / evidence | +|---|---| +| PERFORMANCE | Baseline first, same-contract after measurement, before/after comparison, functional regression checks | +| BUILD | Baseline/after build measurement when speed/size improvement is claimed; build correctness and task/configuration evidence | +| ARCHITECTURE | Boundary/dependency evidence, architecture rules, build and affected integration paths | +| MODULE_STRUCTURE | Module/package dependency graph or rule evidence plus affected build/integration tests | +| DEPENDENCY | Before/after dependency graph, conflict/API impact, build/test evidence | +| DATA_ACCESS | Query/SQL/row/query-count/plan evidence as relevant plus real DB integration when vendor behavior matters | +| TRANSACTION | Transaction boundary, commit/rollback/failure contract tests; real provider/DB where semantics depend on it | +| CONCURRENCY | Deterministic concurrency/contention tests and failure evidence; do not infer safety from sequential tests | +| RELIABILITY | Failure-path/recovery/retry/timeout evidence and regression tests | +| SECURITY | Positive and negative-path security tests/config evidence; never retain credentials in evidence | +| CODE_STRUCTURE | Behavior-preserving tests, reference scan, build; public contract impact explicitly checked | +| CLEANUP | Reference/usage scan proving removal is safe plus build/tests | +| TESTABILITY | Demonstrate the targeted behavior is now directly verifiable; preserve production behavior | +| CONFIGURATION | Binding/default/conditional-loading tests and affected runtime startup/config evidence | +| OPERABILITY | Logs/metrics/health/runtime behavior evidence appropriate to the operational claim | + +## Scope expansion + +- `LOCAL`: verify immediate callers/contract plus relevant tests. +- `MODULE`: verify module API, internal dependency edges, module tests/build. +- `CROSS_MODULE`: verify all touched module contracts/dependency direction and integration paths. +- `PROJECT`: exceptional; requires project-wide impact inventory and broad verification. Prefer decomposition when possible. diff --git a/.agents/skills/refactoring-from-analysis/references/work-item-contract.md b/.agents/skills/refactoring-from-analysis/references/work-item-contract.md new file mode 100644 index 0000000..d1999d3 --- /dev/null +++ b/.agents/skills/refactoring-from-analysis/references/work-item-contract.md @@ -0,0 +1,35 @@ +# Refactoring WorkItem Contract + +`<분석 대상 저장소>/refactor-queue.yaml` determines execution order. `docs/<프로젝트>/refactor///work-item.json` owns detail. + +## Required fields + +- `id`, `project`, `analysisRevision` +- `priority`: P0, P1, P2, P3 +- `type`: one supported strategy type +- `scope`: LOCAL, MODULE, CROSS_MODULE, PROJECT +- `target`, `problem`, `goal` +- `acceptanceCriteria` +- `status` +- evidence references + +Priority decides order. Type and scope decide how work is executed and verified. + +## Statuses + +Allowed statuses are `CANDIDATE`, `READY`, `BASELINING`, `IN_PROGRESS`, `VERIFYING`, `WAITING_APPROVAL`, `APPROVED`, `MERGED`, `REJECTED`, `BLOCKED`, and `COMPLETE`. + +`BASELINING` is used by performance work before source modification. Other types normally move from `READY` directly to `IN_PROGRESS`. + +## Eligibility + +Before changing code: + +- analysis queue status is `COMPLETE`; +- `analysisRevision` equals the completed analysis source revision; +- current repository HEAD equals that revision; +- source checkout is clean; +- the finding still exists in current code; +- item is bounded enough to review independently. + +If any source revision differs, the refactor is not eligible; reanalysis is required first. diff --git a/.agents/skills/rewriting-technical-prose-naturally/SKILL.md b/.agents/skills/rewriting-technical-prose-naturally/SKILL.md index 9ef4ddb..ce04c7d 100644 --- a/.agents/skills/rewriting-technical-prose-naturally/SKILL.md +++ b/.agents/skills/rewriting-technical-prose-naturally/SKILL.md @@ -33,6 +33,37 @@ Before the first rewrite in a task, read both references: Read the complete source and the nearby context needed to interpret pronouns, comparisons, and causes. Do not rewrite an isolated paragraph when its protected meaning depends on the surrounding section. +## Required sequence + +This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, +and technical depth are the contract. Work in this order — the steps that have their own section are +named here so the spine stays visible. + +1. **Read the whole record once without editing.** A paragraph fixed before you know how the piece ends + is fixed against the wrong context. +2. **Mark the protected literals and the sentences that carry technical weight** — `Establish the meaning + contract` below, and `references/protected-content.md`. +3. **Name the stylistic problems, and only those.** Repeated sentence frames, forced conversational tone, + abstract filler, too many headings, translated constructions, a conclusion restated in three places, + paragraphs of identical length, bolding that has stopped meaning anything. +4. **Edit diction, sentence order inside a paragraph, paragraph boundaries, and headings.** Do not move a + fact between sections while doing it. +5. **Re-read against the original** and restore every technical fact, limitation, condition, and evidence + detail that fell out. This step finds more than it seems it should. +6. **Close with `Mechanical pass` then `Final check`.** The checkers read surface patterns; the read-aloud + test and the preservation questions decide. + +### Hard rules for this pass + +- Do not shorten merely to look more human. +- **Do not remove implementation detail because the paragraph feels dense.** Density is not a style defect. +- Do not create `처음에는`, `해보니`, `놀랍게도`, `저희는` or other experience language unless the source + records that experience. +- **Do not imitate one company's or one author's voice.** `style_profile.mjs` measures against five + Woowahan articles because they are a *sample of engineering writing*, not a target to sound like. Apply + the cross-source patterns in `references/editorial-rules.md`; do not adopt a house style. +- Do not make every section equally polished, equally long, or structurally symmetric. + ## Establish the meaning contract Make an internal claim ledger before editing. Do not print it unless asked. Record: @@ -444,6 +475,20 @@ remove. Clean output does not mean the rewrite is good. Both tools read surface patterns and cannot see meaning; every rule above still applies, and the read-aloud test below is the one that decides. +## 참조 + +- `references/regression-examples.md` — 고친 예와 실패 이유 +- `references/protected-content.md` — 옮길 때 한 글자도 바꾸면 안 되는 것 +- `references/editorial-rules.md` — 편집 규칙 +- `references/research-method.md` — 문체 기준값을 다시 재는 방법 +- `references/corpus/` — 한국어 기술 글 코퍼스 조사 노트 + +문장 규칙 두 개는 `writing-tech-log-records` 스킬에 있다. 두 스킬이 같은 규칙을 쓰므로 사본을 +만들지 않는다. + +- `../writing-tech-log-records/references/explaining.md` — 설명하는 법 +- `../writing-tech-log-records/references/ai-tells.md` — 문서군 전체의 리듬 + ## Final check First, read every rewritten sentence aloud and ask: **would a Korean-speaking developer say this to a diff --git a/.agents/skills/rewriting-technical-prose-naturally/references/corpus/2026-08-28-corpus-notes.md b/.agents/skills/rewriting-technical-prose-naturally/references/corpus/2026-08-28-corpus-notes.md new file mode 100644 index 0000000..1de1ae3 --- /dev/null +++ b/.agents/skills/rewriting-technical-prose-naturally/references/corpus/2026-08-28-corpus-notes.md @@ -0,0 +1,78 @@ +# 2026-08-28 Korean Engineering Blog Corpus Notes + +## 조사 범위 + +공개된 한국 기술 블로그 중 실제 서비스/시스템 개발 경험을 다루는 글을 중심으로 보았다. + +- NAVER D2 — 「네이버 통합 검색의 웹 성능 - 모니터링과 성능 개선」, 「생성형 AI 기반 실시간 검색 결과 재순위화 1편」, 검색 SRE 관련 글 +- 토스 기술 블로그 — 토스증권 실시간 데이터 파이프라인/Observability 시리즈, Gateway 관련 글 +- 우아한형제들 기술블로그 — 장시간 비동기 작업을 Kafka에서 RDB Task Queue로 재설계한 글, Kafka/분산락/개발환경 관련 글 +- 카카오테크 — 서버 개발자를 위한 운영툴 개발 등 실제 개발/운영 경험 글 + +이 문서는 표현을 복사하는 자료가 아니라 공통 구조를 추출한 메모다. + +## 반복해서 보인 서술 관행 + +### 1. 기술 이름보다 서비스 상황과 제약이 먼저 나온다 + +글이 곧바로 프레임워크 장점을 나열하기보다 현재 시스템 규모, 기존 구조, 실제 운영 요구나 실패 상황을 먼저 설명한다. 기술 선택은 그 뒤의 문제 해결 수단으로 등장한다. + +편집 기준: 초안이 `X는 무엇인가`로 길게 시작하지만 실제 사건이 뒤에 있다면, 필요한 개념 설명만 남기고 사건/제약을 앞쪽으로 이동한다. + +### 2. 해결책 전에 요구조건을 명시한다 + +토스의 Observability 글과 우아한형제들의 장시간 작업 재설계 글처럼 해결안이 만족해야 할 조건을 목록/표로 먼저 고정하는 패턴이 자주 보인다. 이러면 선택 이유가 일반론이 아니라 제약과 연결된다. + +편집 기준: 선택을 설명할 때 자료에 있는 `제약 → 요구조건 → 선택` 연결을 살린다. 자료에 없는 요구조건을 새로 만들지는 않는다. + +### 3. 문제는 관측된 현상과 원인 추적으로 이어진다 + +NAVER D2의 웹 성능 글은 배포 시점의 변화, 지표, 원인 후보를 실제 관측 흐름에 연결한다. 우아한형제들의 Kafka 작업 글도 처리시간 증가 → poll 공백 → rebalance → 중복 처리로 사건을 이어 간다. + +편집 기준: `문제가 있었다` 뒤에 추상 평가를 늘리지 말고, 입력/상태 변화/관측 결과/원인 근거를 이어 쓴다. + +### 4. 수치는 주장 장식이 아니라 비교 축이다 + +성능 수치나 규모가 나오면 무엇과 무엇을 비교하는지, 어느 시점/조건에서 측정했는지가 같이 나온다. 숫자만 굵게 강조해 결론을 대신하지 않는다. + +편집 기준: 수치 근처에 측정 조건과 비교 대상을 유지한다. 근거 없는 정량 표현은 삭제한다. + +### 5. 표와 그림은 산문을 대신할 축이 있을 때 쓴다 + +요구사항, 전/후 비교, 구성요소, 여러 후보의 동일한 비교축처럼 행/열이 자연스러운 경우 표를 쓴다. 그림은 구조나 흐름을 보여주고, 본문은 그림에서 읽어야 할 변화나 의미를 설명한다. + +### 6. 실제 글은 모든 절의 길이와 문형이 같지 않다 + +배경은 짧고 핵심 실패 원인은 길 수 있으며, 자명한 결과는 한두 문장으로 끝난다. 모든 절을 `문제 → 원인 → 해결 → 장점` 네 문장으로 맞추지 않는다. + +### 7. 1인칭은 실제 경험을 담을 때만 자연스럽다 + +기업 기술 블로그는 `저희는`, `우리는`을 자주 쓰지만 이는 실제 작성자가 겪은 프로젝트 경험이 있기 때문이다. 코드 분석에서 그런 기록이 없는데 같은 장치를 흉내 내면 오히려 가짜 경험이 된다. + +편집 기준: source에 1인칭 경험이 없으면 객관적 관측 문장으로 쓴다. + +### 8. 한계를 숨기지 않는다 + +새 구조가 해결한 범위와 아직 남은 문제를 분리하는 글이 많다. 이것이 기술 선택을 과장하지 않게 만든다. + +편집 기준: 상세 분석의 `확인하지 않은 것`, `남은 질문`, `운영에서 별도 검증할 것`을 삭제하지 않는다. + +### 9. 개념 설명은 현재 문제를 읽는 데 필요한 만큼만 끼워 넣는다 + +Kafka, Gateway, LCP 같은 용어를 설명하더라도 백과사전식 장문이 아니라 이후 구조/문제를 이해하는 데 필요한 수준으로 제한한다. + +### 10. 제목은 읽을 이유를 주되 본문보다 앞서 결론을 과장하지 않는다 + +문제/구조/변화를 드러내는 제목은 많지만, 모든 절을 질문형이나 자극적인 카피로 만들지는 않는다. 핵심 기술 문단은 비교적 직접적이다. + +## 조사한 공개 글 + +- NAVER D2 — 네이버 통합 검색의 웹 성능 - 모니터링과 성능 개선: https://d2.naver.com/helloworld/8113611 +- NAVER D2 — 생성형 AI 기반 실시간 검색 결과 재순위화 1편: https://d2.naver.com/helloworld/2380720 +- 토스 기술 블로그 — 토스증권의 수 천개 실시간 데이터 파이프라인 운영방법 #2: https://toss.tech/article/MSA-observability +- 토스 기술 블로그 — 토스는 Gateway 이렇게 씁니다: https://toss.tech/article/22910 +- 우아한형제들 기술블로그 — 장시간 비동기 작업, Kafka 대신 RDB 기반 Task Queue로 해결하기: https://techblog.woowahan.com/23625/ +- 우아한형제들 기술블로그 — 표준 개발 환경 개선 되돌아보기: https://techblog.woowahan.com/15572/ +- 카카오테크 — 서버 개발자를 위한 운영툴 개발: https://tech.kakao.com/posts/528 + +URL은 조사 provenance를 남기기 위한 것이다. 특정 문장을 복사하거나 특정 필자의 어조를 목표로 삼지 않는다. diff --git a/.agents/skills/rewriting-technical-prose-naturally/references/corpus/README.md b/.agents/skills/rewriting-technical-prose-naturally/references/corpus/README.md new file mode 100644 index 0000000..29fdd8e --- /dev/null +++ b/.agents/skills/rewriting-technical-prose-naturally/references/corpus/README.md @@ -0,0 +1,7 @@ +# Korean Technical Writing Research + +한국어 엔지니어링 글의 편집 기준을 만들기 위한 공개 자료 조사 기록을 둔다. + +목적은 특정 회사나 필자의 문체를 복제하는 것이 아니다. 여러 기술 블로그에서 반복되는 **문제 제시, 근거 전개, 요구사항 명시, 측정 결과 연결, 한계 표기, 표/그림 사용 방식**을 추출해 `humanizing-korean-tech-writing` Skill에 반영한다. + +새 조사 결과는 날짜별 corpus note에 먼저 기록하고, 여러 출처에서 반복되는 패턴만 Skill의 durable rule로 승격한다. diff --git a/.agents/skills/rewriting-technical-prose-naturally/references/document-skeleton.md b/.agents/skills/rewriting-technical-prose-naturally/references/document-skeleton.md index c3e95ec..9c25e09 100644 --- a/.agents/skills/rewriting-technical-prose-naturally/references/document-skeleton.md +++ b/.agents/skills/rewriting-technical-prose-naturally/references/document-skeleton.md @@ -19,7 +19,7 @@ - [검색 성능 개선을 위한 Elasticsearch 인덱스 구조와 쿼리 최적화](https://techblog.woowahan.com/20161/) — `20161` - [나 4년 차 서버개발자, 배달의민족의 지리 체계를 뒤흔들다](https://techblog.woowahan.com/11238/) — `11238` -이 다섯 편은 `scripts/style_profile.mjs`가 쓰는 기준선이기도 하다. 원문을 다시 받으려면 +이 다섯 편은 `scripts/style_profile.mjs`가 문체 수치를 재는 기준값이기도 하다. 원문을 다시 받으려면 `node scripts/fetch_reference.mjs <디렉터리>`를 쓴다. 사이트가 curl과 리더 프록시를 403으로 막으므로 실제 브라우저가 필요하고, `playwright-core`가 있어야 한다. 받은 뒤 `node scripts/style_profile.mjs --baseline <디렉터리>/*.md`로 값을 다시 잰다. diff --git a/.agents/skills/rewriting-technical-prose-naturally/references/editorial-rules.md b/.agents/skills/rewriting-technical-prose-naturally/references/editorial-rules.md new file mode 100644 index 0000000..9b59eb2 --- /dev/null +++ b/.agents/skills/rewriting-technical-prose-naturally/references/editorial-rules.md @@ -0,0 +1,72 @@ +# Editorial Rules From Korean Engineering Writing + +These rules were distilled from multiple public Korean engineering blogs. They describe broad technical-writing habits, not a target author's style. + +## Lead with the engineering situation + +When a draft spends several paragraphs defining a technology before stating why it appears, move the concrete service/code situation, constraint, failure, or measurement earlier. Keep only the concept explanation needed to follow that situation. + +Do not fabricate a `background story` merely to create an opening. + +## Keep constraint → requirement → choice connected + +A credible choice is usually readable from its constraints. When the source contains requirements, put them close to the solution they rule in/out. Do not add generic benefits such as maintainability, scalability, security, or performance unless the source demonstrates that they mattered here. + +## Write the observed chain, not a summary slogan + +Prefer: + +```text +input/condition → state change → observable result → evidence-backed cause +``` + +over a paragraph that repeatedly says the architecture has a problem or responsibility. + +## Make metrics carry context + +Keep the dataset, request shape, time window, before/after condition, or comparison axis next to a number. Do not turn a measurement into an adjective such as `크게`, `압도적으로`, `획기적으로` unless the source justifies that interpretation. + +## Use tables only when there is a real axis + +Good table candidates: + +- requirement → reason; +- before → after; +- option → same comparison dimensions; +- environment → observed result; +- defined → not defined verification coverage. + +If every row needs a paragraph of caveats, prose may be clearer. + +## Let section lengths differ + +Human engineering reports spend space where the difficult reasoning occurred. A two-line setup next to a long failure analysis is fine. Do not normalize all sections to the same number of paragraphs or bullets. + +## Do not manufacture first person + +Public engineering blogs often use first person because their authors participated in the project. A code-derived document has no such license. Use `코드에서는`, `실행 결과에서는`, `이 구성에서는`, or the concrete component name unless source material contains a real first-person account. + +## Preserve limitations + +Do not polish away `확인하지 않은 것`, `운영에서는 별도 검증`, failed attempts, excluded scope, or competing explanations. Those boundaries make the technical claim credible. + +## Explain concepts at the point of use + +Introduce PKCE, LCP, Kafka rebalance, keyset pagination, etc. only to the depth needed for the next piece of reasoning. Avoid detached encyclopedia sections unless the record itself is a Reference that requires them. + +## Prefer direct headings over forced questions + +Use headings that name the actual event, boundary, measurement, or change. Do not convert every heading to `왜 ...일까?`, `...해보자`, or rhetorical copy. A question heading is appropriate only when the section genuinely resolves that question. + +## Vary rhythm by content, not by randomization + +Do not mechanically alternate short and long sentences. Instead: + +- keep a consequence close to its cause; +- split a sentence when it contains two independently important actions; +- keep a short sentence when a measured fact can stand alone; +- combine fragments that only make sense together. + +## Remove AI scaffolding + +Review expressions such as `중요한 점은`, `핵심은`, `결국`, `즉`, `다시 말해`, `이 지점에서`, `한편`, `정리하면`. They are not forbidden, but repeated use often means the previous sentence already said the same thing. Delete the scaffold before rewriting the substance. diff --git a/.agents/skills/rewriting-technical-prose-naturally/references/protected-content.md b/.agents/skills/rewriting-technical-prose-naturally/references/protected-content.md new file mode 100644 index 0000000..a5c9b8a --- /dev/null +++ b/.agents/skills/rewriting-technical-prose-naturally/references/protected-content.md @@ -0,0 +1,33 @@ +# Protected Content + +Editorial work must preserve these exactly unless the source itself is being corrected with new evidence: + +- numeric values and signs; +- dates and times; +- versions; +- units; +- commands and command arguments; +- code and configuration; +- file/module/class/method names when used as identifiers; +- URLs and paths; +- HTTP status/error codes; +- test names and assertions; +- observed terminal/browser output; +- direct quotations; +- decision/question status; +- distinctions between observed fact, inference, assumption, unknown, and recommendation. + +## Meaning-preservation checks + +After editing, compare original and revised record and ask: + +1. Did any condition (`when`, `only if`, environment, topology, dataset) disappear? +2. Did `can/may` become `does/will`? +3. Did a local/test observation become a production/general claim? +4. Did an unresolved question sound answered? +5. Did a proposed decision sound adopted? +6. Did a measured value lose its measurement context? +7. Did a failure/limitation disappear because it made the paragraph less tidy? +8. Did the rewrite invent a motivation or personal history? + +If yes, restore the lost distinction before further stylistic changes. diff --git a/.agents/skills/rewriting-technical-prose-naturally/references/research-method.md b/.agents/skills/rewriting-technical-prose-naturally/references/research-method.md new file mode 100644 index 0000000..cc74441 --- /dev/null +++ b/.agents/skills/rewriting-technical-prose-naturally/references/research-method.md @@ -0,0 +1,10 @@ +# Research Method + +Use this when updating the editorial skill from public Korean engineering writing. + +1. Sample several companies and several technical domains; do not build rules from one writer. +2. Prefer implementation/incident/performance/operations articles over recruiting or marketing posts. +3. Record structural observations: how the problem is introduced, where constraints appear, how measurements are tied to claims, how alternatives and limitations are handled, and when tables/diagrams replace prose. +4. Do not copy distinctive phrases, jokes, metaphors, or signature expressions. +5. Promote a pattern into `editorial-rules.md` only when it appears useful across multiple sources and is compatible with evidence-preserving Tech Log writing. +6. Keep source notes under `./research/korean-tech-writing/`; runtime editing must work even if the websites are unavailable later. diff --git a/.agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs b/.agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs index c188bfc..48eb183 100644 --- a/.agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs +++ b/.agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs @@ -62,7 +62,12 @@ const ACRONYM_OK = new Set([ function strip(src) { return src .replace(/```[\s\S]*?```/g, (m) => m.replace(/[^\n]/g, ' ')) - .replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length)); + .replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length)) + // 표와 인용은 「쓰지 않는다」 예시가 사는 자리다. 규칙을 적은 문서가 그 규칙을 어긴 것으로 + // 잡히지 않게 줄을 통째로 비운다. 줄 번호는 유지한다. + .split('\n') + .map((line) => (/^\s*(\||>)/.test(line) ? ' '.repeat(line.length) : line)) + .join('\n'); } function positiveChecks(text, lines, docMode, rulesMode) { diff --git a/.agents/skills/writing-tech-log-records/README.md b/.agents/skills/writing-tech-log-records/README.md index ab7705e..264dd4d 100644 --- a/.agents/skills/writing-tech-log-records/README.md +++ b/.agents/skills/writing-tech-log-records/README.md @@ -11,6 +11,7 @@ Tech Log Studio에 올릴 기록을 쓰는 스킬. Case · Concept · Reference | `references/record-kinds.md` | 다섯 종류의 칸·상한·게시 조건 | | `references/from-ssot-to-records.md` | 긴 글에서 글감을 뽑는 기준과 `tech-log-tree.json` | | `references/writing-each-kind.md` | 종류마다 무엇을 어떤 순서로 쓰나 | +| `templates/*.md` | 종류별 빈 틀. 복사해서 채운다 | | `references/body-syntax.md` | Case 본문의 허용·금지 문법 | | `references/code-tables-diagrams.md` | 코드블록·표·SVG·이미지 | | `references/explaining.md` | 설명의 깊이와 말투 | diff --git a/.agents/skills/writing-tech-log-records/references/review-checklist.md b/.agents/skills/writing-tech-log-records/references/review-checklist.md index 5d451a7..8ab9174 100644 --- a/.agents/skills/writing-tech-log-records/references/review-checklist.md +++ b/.agents/skills/writing-tech-log-records/references/review-checklist.md @@ -108,3 +108,16 @@ - [ ] 게시 후 공개 페이지를 열어 표·코드·그림이 의도대로 나오는지 봤다 마지막 항목을 건너뛰지 않는다. 저장은 통과해도 공개 화면에서 다르게 보이는 경우가 있다. + +## 분석에서 뽑아 쓸 때 (document-detail 계약) + +- [ ] 이 글감의 `readiness` 가 글을 써도 되는 상태인가 +- [ ] 모든 실질 주장이 분석·출처·증거 앵커 하나로 되짚어지는가 +- [ ] 추론을 관측한 것처럼 적지 않았는가 +- [ ] 로컬·테스트에서 본 것을 운영 사실로 올리지 않았는가 +- [ ] Case 가 개념 설명이 아니라 구체적인 사건·검증 절차인가 +- [ ] Reference 가 짝이 되는 Case 의 서사를 통째로 되풀이하지 않는가 +- [ ] Decision 에 근거가 하나 이상 있고, 무엇을 보고 정했는지가 적혀 있는가 +- [ ] 지어낸 경험·실패·동기·감정이 없는가 +- [ ] 그림이 실제 asset 파일을 가리키고, 있어야 할 이유가 있는가 +- [ ] 그림이 관측하지 않은 사건을 만들어 내지 않았는가 diff --git a/.agents/skills/writing-tech-log-records/references/root-tree-contract.md b/.agents/skills/writing-tech-log-records/references/root-tree-contract.md new file mode 100644 index 0000000..521709e --- /dev/null +++ b/.agents/skills/writing-tech-log-records/references/root-tree-contract.md @@ -0,0 +1,119 @@ +# Root Tree Contract + +The root tree is the explicit boundary between deep project analysis and Tech Log record generation. + +## Required document header + +A root tree records: + +- `schemaVersion` +- `project` +- `sourceDocument` +- `sourceDocumentSha256` +- `sourceRevision` +- `generatedAt` + +The hash/revision prevents a scheduled generator from treating a tree derived from old code as current. + +## Required human-readable tree + +Each Topic has a title, slug, and four branches: + +```text +PROJECT + + +TOPIC + + + +├── CASE +├── REFERENCE +├── OPEN QUESTION +└── DECISION +``` + +Empty branches are allowed. Do not manufacture nodes to fill all four kinds. + +## Node source contract + +Every candidate includes a specification after the human-readable tree. + +### Case + +Required: + +- `slug` +- `readiness` +- one or more `source` anchors +- `classification` explaining the concrete incident/experiment/diagnosis +- relevant code/evidence when the conclusion depends on them +- `missing-verification` +- `relations` + +A Case with `NEEDS_EVIDENCE`, `BLOCKED`, or `REJECTED` is not generated. + +### Reference + +Required: + +- `slug` +- `readiness` +- `source` +- `classification` explaining the reusable criterion +- `scope` +- `exceptions` +- `relations` + +A Reference must be useful beyond retelling one Case. If removing the originating project's names leaves no rule, it is probably still a Case. + +### Open Question + +Required: + +- `slug` +- `readiness: OPEN` +- `source` +- `known` +- `unknown` +- `next-verification` +- `decision-criterion` +- `relations` + +Do not generate a Question when the detailed analysis already contains a verified answer. Move the material to Case/Reference/Decision as appropriate and update the tree first. + +### Decision + +Required: + +- `slug` +- `readiness` +- `decision-status` +- `source` +- `decision-evidence` +- `grounds` +- `classification` +- `relations` + +`decision-status` is one of `PROPOSED`, `ADOPTED`, `SUPERSEDED`, `NOT_DECIDED`. A `NOT_DECIDED` candidate uses `NEEDS_DECISION` and is not generated as a Decision. + +## Readiness semantics + +| readiness | meaning | generation | +|---|---|---| +| `READY` | grounded enough for the kind | allowed | +| `NEEDS_EVIDENCE` | material assertion still lacks verification | blocked | +| `NEEDS_DECISION` | direction sounds plausible but project has not decided | blocked | +| `OPEN` | legitimate unresolved Question | allowed as Open Question | +| `BLOCKED` | sources are incomplete or contradictory | blocked | +| `REJECTED` | should not become a record | blocked | + +## Derivation rules + +1. Start from sections and evidence already present in detailed analysis; do not begin by brainstorming titles. +2. Prefer several narrowly grounded Cases over one broad Case that combines unrelated incidents. +3. Extract References only after identifying the invariant/selection criterion that survives outside the incident. +4. Extract Questions from explicit uncertainty, missing verification, operational unknowns, or conflicting constraints. +5. Extract Decisions only from explicit project choice evidence: ADR, commit/history, configuration plus recorded rationale, issue/PR decision, or user-supplied decision record. +6. A node may relate to several siblings, but each record has one primary purpose. +7. If new runtime evidence changes the answer, update detailed analysis and regenerate/review the tree before editing downstream records. diff --git a/.agents/skills/writing-tech-log-records/templates/case.md b/.agents/skills/writing-tech-log-records/templates/case.md new file mode 100644 index 0000000..82562fc --- /dev/null +++ b/.agents/skills/writing-tech-log-records/templates/case.md @@ -0,0 +1,49 @@ +--- +id: +kind: CASE +slug: +title: <제목> +topic: <주제 이름> +project: <프로젝트 이름> +status: 게시 전 +studio: "<편집 화면 주소. 아직 없으면 빈 값>" +lastVerifiedOn: <실제로 확인한 날 또는 빈 값> +assets: + - key: <본문의 :::evidence key 와 같은 값> + file: <../../../final/assets/… 상대 경로> +evidence: + - <../../../final/evidence/raw/… 상대 경로> +--- + +# + +<summary> + +## 관계 + +- **<related record>** + <why related> + +## 문제 + +<concrete observed problem and scope> + +## 결론 + +<bounded conclusion supported by evidence> + +## 검증 환경 + +<plain text exact environment> + +## 재현 조건 + +<ordered concrete conditions/steps> + +## 본문 + +<!-- body:start --> + +<rich Case body> + +<!-- body:end --> diff --git a/.agents/skills/writing-tech-log-records/templates/concept.md b/.agents/skills/writing-tech-log-records/templates/concept.md new file mode 100644 index 0000000..e388d66 --- /dev/null +++ b/.agents/skills/writing-tech-log-records/templates/concept.md @@ -0,0 +1,43 @@ +--- +id: <Studio 가 준 uuid. 아직 없으면 빈 값> +kind: CONCEPT +slug: <slug> +title: <제목> +topic: <주제 이름> +project: <프로젝트 이름> +status: 게시 전 +studio: "<편집 화면 주소. 아직 없으면 빈 값>" +basisVersion: <무엇을 보고 썼는지. 예 Keycloak 26.7.0 · oidc-client-ts 3.3.0> +assets: + - key: <본문의 :::evidence key 와 같은 값> + file: <../../../final/assets/… 상대 경로> +evidence: + - <../../../final/evidence/raw/… 상대 경로> +--- + +# <제목> + +<요약. 이 개념이 무엇을 어떻게 하는지 한 문단> + +## 관계 + +- **<이어지는 기록>** + <왜 이어지는지> + +## 본문 + +<!-- body:start --> + +## <무엇이 무엇을 주고받나> + +<첫 절은 참여자와 오가는 것을 세운다> + +## <단계마다 실제로 일어나는 일> + +## <그 설계가 막지 않는 것> + +## <지금 확인한 범위> + +<규격이 정한 것과 이 구현이 그렇게 한 것을 구분한다. 「확인했다」는 Case 의 말이라 쓰지 않는다> + +<!-- body:end --> diff --git a/.agents/skills/writing-tech-log-records/templates/decision.md b/.agents/skills/writing-tech-log-records/templates/decision.md new file mode 100644 index 0000000..4ab6197 --- /dev/null +++ b/.agents/skills/writing-tech-log-records/templates/decision.md @@ -0,0 +1,37 @@ +--- +id: <Studio 가 준 uuid. 아직 없으면 빈 값> +kind: PROJECT_DECISION +slug: <slug> +title: <제목> +topic: <주제 이름> +project: <프로젝트 이름> +status: 게시 전 +studio: "<편집 화면 주소. 아직 없으면 빈 값>" +decisionStatus: PROPOSED +assets: + - key: <본문의 :::evidence key 와 같은 값> + file: <../../../final/assets/… 상대 경로> +evidence: + - <../../../final/evidence/raw/… 상대 경로> +--- + +# <title> + +<summary> + +## 근거 + +- **<Case or Reference>** + <how it grounds this decision> + +## 결정문 + +<the actual project choice> + +## 판단 이유 + +<only rationale explicitly supported by decision evidence> + +## 영향 + +- <grounded consequence/trade-off> diff --git a/.agents/skills/writing-tech-log-records/templates/question.md b/.agents/skills/writing-tech-log-records/templates/question.md new file mode 100644 index 0000000..2d5de8b --- /dev/null +++ b/.agents/skills/writing-tech-log-records/templates/question.md @@ -0,0 +1,51 @@ +--- +id: <Studio 가 준 uuid. 아직 없으면 빈 값> +kind: QUESTION +slug: <slug> +title: <제목> +topic: <주제 이름> +project: <프로젝트 이름> +status: 게시 전 +studio: "<편집 화면 주소. 아직 없으면 빈 값>" +questionStatus: OPEN +assets: + - key: <본문의 :::evidence key 와 같은 값> + file: <../../../final/assets/… 상대 경로> +evidence: + - <../../../final/evidence/raw/… 상대 경로> +--- + +# <title> + +<summary of unresolved issue> + +## 관계 + +- **<related record>** + <why related> + +## 사실 + +- <grounded fact> + +## 가정 + +- <explicit assumption, if any> + +## 미지수 + +- <unknown> + +## 제약 + +- <constraint> + +## 선택지 + +### 1. <grounded candidate, only when it really exists> + +<what is known and what must be checked> + +## 다음 검증 + +1. <next concrete verification> diff --git a/.agents/skills/writing-tech-log-records/templates/reference.md b/.agents/skills/writing-tech-log-records/templates/reference.md new file mode 100644 index 0000000..0ba8b34 --- /dev/null +++ b/.agents/skills/writing-tech-log-records/templates/reference.md @@ -0,0 +1,46 @@ +--- +id: <Studio 가 준 uuid. 아직 없으면 빈 값> +kind: REFERENCE +slug: <slug> +title: <제목> +topic: <주제 이름> +project: <프로젝트 이름> +status: 게시 전 +studio: "<편집 화면 주소. 아직 없으면 빈 값>" +assets: + - key: <본문의 :::evidence key 와 같은 값> + file: <../../../final/assets/… 상대 경로> +evidence: + - <../../../final/evidence/raw/… 상대 경로> +--- + +# <title> + +<summary> + +## 관계 + +- **<related record>** + <why related> + +## 목적 + +<why this reusable criterion exists> + +## 규칙 + +### 1. <rule title> + +<plain-text rule> + +## 적용 조건 + +- <condition> + +## 예외 + +- <exception or explicitly none> + +## 예시 + +- <plain-text example> diff --git a/.claude/skills/analyzing-codebase-for-tech-log b/.claude/skills/analyzing-codebase-for-tech-log new file mode 120000 index 0000000..fa636bd --- /dev/null +++ b/.claude/skills/analyzing-codebase-for-tech-log @@ -0,0 +1 @@ +../../.agents/skills/analyzing-codebase-for-tech-log \ No newline at end of file diff --git a/.claude/skills/deriving-tech-log-root-tree b/.claude/skills/deriving-tech-log-root-tree new file mode 120000 index 0000000..738bd65 --- /dev/null +++ b/.claude/skills/deriving-tech-log-root-tree @@ -0,0 +1 @@ +../../.agents/skills/deriving-tech-log-root-tree \ No newline at end of file diff --git a/.claude/skills/refactoring-from-analysis b/.claude/skills/refactoring-from-analysis new file mode 120000 index 0000000..e7fc878 --- /dev/null +++ b/.claude/skills/refactoring-from-analysis @@ -0,0 +1 @@ +../../.agents/skills/refactoring-from-analysis \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 0356b1d..91f5c94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,18 @@ # CLAUDE.md -이 저장소는 Tech Log Studio에 올릴 기록을 쓰고 보관하는 작업 공간이다. 기록을 쓰거나 고칠 때는 -`.claude/skills/writing-tech-log-records`를 사용한다. 문장이 AI가 쓴 것처럼 읽히면 -`.claude/skills/rewriting-technical-prose-naturally`로 다시 쓴다. 다이어그램은 -`.claude/skills/technical-visualizer`로 만든다. +이 저장소는 코드베이스를 분석해 근거를 쌓고, 거기서 Tech Log Studio에 올릴 기록을 뽑아 쓰는 +작업 공간이다. 단계마다 스킬이 있다. + +| 단계 | 스킬 | +|---|---| +| 코드베이스를 모듈 단위로 분석해 `analysis/`와 `final/`을 쌓는다 | `analyzing-codebase-for-tech-log` | +| SSOT에서 글감을 뽑아 트리를 만든다 | `deriving-tech-log-root-tree` | +| 글감 하나를 기록으로 쓴다 | `writing-tech-log-records` | +| 문장이 AI가 쓴 것처럼 읽히면 다시 쓴다 | `rewriting-technical-prose-naturally` | +| 그림을 만든다 | `technical-visualizer` | +| 분석에서 리팩터링 작업 항목을 뽑는다 | `refactoring-from-analysis` | + +구조가 갖춰졌는지는 `python3 scripts/verify-pipeline.py`가 검사한다. ## 실행 순서 @@ -60,14 +69,19 @@ SVG로 컴파일한다. 손으로 SVG를 그리지 않는다. **그림 안에는 ```text docs/<프로젝트>/ ├── source/ 밖에서 가져온 원본. 고치지 않는다 +├── state.json 분석 상태 — 어디까지 봤나, 어느 리비전을 봤나 +├── source-index.md 분석한 코드의 목록 +├── analysis/ 모듈·서브시스템 단위 분석. 큰 저장소는 여기서 누적한다 +├── root-tree.md 글감 분해 계약 (선택 — json 을 쓰면 없어도 된다) ├── final/ SSOT — 이 프로젝트에 대해 아는 것 전부 │ ├── document.md 상세한 글 │ ├── assets/ svg, drawio, 그림 │ ├── .techviz/ 그림의 정본 (context, spec, prompt) -│ └── evidence/ 증거. 아래 셋으로만 나눈다 -│ ├── terminal/ 명령을 돌려 얻은 출력 (테스트·빌드·EXPLAIN·curl·가드) -│ ├── metrics/ 잰 값 (csv) -│ └── screens/ 캡처 (Playwright MCP 스크린샷 포함) +│ └── evidence/ 증거. 원문이 정본이고 그림은 표현물이다 +│ ├── raw/ 명령 출력·csv·덤프 원문 — 정본 +│ ├── meta/ 그 실행의 command·cwd·executedAt·exitCode·revision +│ ├── rendered/ raw 에서 만든 터미널 SVG (표현물) +│ └── browser/ 브라우저 캡처 (Playwright MCP) └── tech-log-studio/ Studio 에 올릴 글만 ├── tech-log-tree.json 글감 목록. 항상 최신으로 둔다 └── <주제 slug>/ @@ -77,10 +91,23 @@ docs/<프로젝트>/ `final/` 이 정본이고 `tech-log-studio/` 는 거기서 뽑아낸 글이다. 증거는 `final/evidence/` 에만 두고 기록에서는 그 파일을 가리킨다. 같은 파일을 양쪽에 두지 않는다. -증거 폴더는 프로젝트마다 같다. `terminal/` 아래에는 하위 폴더를 자유롭게 둔다 -(`terminal/explain/`, `terminal/guards/`). 캡처와 출력에는 무엇을 담았는지 한 줄을 같은 폴더의 +증거 폴더는 프로젝트마다 같다. `raw/` 아래에는 하위 폴더를 자유롭게 둔다 +(`raw/explain/`, `raw/guards/`). 캡처와 출력에는 무엇을 담았는지 한 줄을 같은 폴더의 `README.txt` 에 적는다 — 파일 이름만으로는 6개월 뒤에 못 읽는다. +**SVG 는 정본이 아니다.** 실행한 명령의 원문과 메타데이터가 정본이고 터미널 SVG 는 문서에 넣기 +위한 표현물이다. 원문 없이 SVG 만 남기지 않는다. + +```bash +python3 scripts/terminal-evidence/render_terminal.py \ + docs/<프로젝트>/final/evidence/raw/<이름>.txt \ + docs/<프로젝트>/final/evidence/rendered/<이름>.svg \ + --command "./gradlew test" --cwd <경로> --exit-code 0 --executed-at <ISO-8601> +``` + +렌더는 Bearer·Cookie·token·password·client_secret 을 `[REDACTED]` 로 바꾼다. 그래도 **원문에 +secret 이 들어가지 않도록 명령을 짜는 것이 먼저다.** + 기록은 frontmatter 로 잇는다. `assets` 는 Studio 에 올릴 그림이고 `evidence` 는 인용한 측정 자료다. Studio 에 넣을 때 `assets` 를 보고 Asset 을 올린 뒤 본문의 `:::evidence key` 를 서버가 준 키로 바꾼다. diff --git a/docs/TechLog/final/document.md b/docs/TechLog/final/document.md index 2eeaf47..b260c47 100644 --- a/docs/TechLog/final/document.md +++ b/docs/TechLog/final/document.md @@ -23,16 +23,16 @@ OpenAPI 계약을 소유하고, 백엔드가 그것을 반입해 구현하고, > > | 파일 | 무엇 | > |---|---| -> | [`evidence/terminal/db/topic-variant-rows.txt`](./evidence/terminal/db/topic-variant-rows.txt) | 주제·축의 실제 행 | -> | [`evidence/terminal/db/record-variant-links.txt`](./evidence/terminal/db/record-variant-links.txt) | 축에 걸린 기록과 공통 기록 | -> | [`evidence/terminal/db/decision-path-after-v15.txt`](./evidence/terminal/db/decision-path-after-v15.txt) | 결정 주소가 앵커로 고쳐진 상태 · V15 적용 확인 | -> | [`evidence/terminal/db/delete-blocked-by-project-link.txt`](./evidence/terminal/db/delete-blocked-by-project-link.txt) | 삭제를 막던 참조와 그 해소 | -> | [`evidence/terminal/api/decision-anchor-fixed.txt`](./evidence/terminal/api/decision-anchor-fixed.txt) | 그 링크가 실제로 200 인가 | -> | [`evidence/terminal/audit/dead-link-sweep.txt`](./evidence/terminal/audit/dead-link-sweep.txt) | 서버가 내보내는 주소 35개 전수 감사 | -> | [`evidence/terminal/audit/link-audit.py`](./evidence/terminal/audit/link-audit.py) | 그 감사를 다시 돌리는 스크립트 | -> | [`evidence/terminal/guards/guards-actually-fail.txt`](./evidence/terminal/guards/guards-actually-fail.txt) | 가드 셋을 되돌려 실제로 빨개지는 것을 확인 | -> | [`evidence/terminal/guards/kind-tables-now.txt`](./evidence/terminal/guards/kind-tables-now.txt) | 손 목록이 표로 바뀌었는지 · **남은 구멍 둘** | -> | [`evidence/screens/`](./evidence/screens/) | 홈 주제 탭 세 단계의 화면과 실측값 | +> | [`evidence/raw/db/topic-variant-rows.txt`](./evidence/raw/db/topic-variant-rows.txt) | 주제·축의 실제 행 | +> | [`evidence/raw/db/record-variant-links.txt`](./evidence/raw/db/record-variant-links.txt) | 축에 걸린 기록과 공통 기록 | +> | [`evidence/raw/db/decision-path-after-v15.txt`](./evidence/raw/db/decision-path-after-v15.txt) | 결정 주소가 앵커로 고쳐진 상태 · V15 적용 확인 | +> | [`evidence/raw/db/delete-blocked-by-project-link.txt`](./evidence/raw/db/delete-blocked-by-project-link.txt) | 삭제를 막던 참조와 그 해소 | +> | [`evidence/raw/api/decision-anchor-fixed.txt`](./evidence/raw/api/decision-anchor-fixed.txt) | 그 링크가 실제로 200 인가 | +> | [`evidence/raw/audit/dead-link-sweep.txt`](./evidence/raw/audit/dead-link-sweep.txt) | 서버가 내보내는 주소 35개 전수 감사 | +> | [`evidence/raw/audit/link-audit.py`](./evidence/raw/audit/link-audit.py) | 그 감사를 다시 돌리는 스크립트 | +> | [`evidence/raw/guards/guards-actually-fail.txt`](./evidence/raw/guards/guards-actually-fail.txt) | 가드 셋을 되돌려 실제로 빨개지는 것을 확인 | +> | [`evidence/raw/guards/kind-tables-now.txt`](./evidence/raw/guards/kind-tables-now.txt) | 손 목록이 표로 바뀌었는지 · **남은 구멍 둘** | +> | [`evidence/browser/`](./evidence/browser/) | 홈 주제 탭 세 단계의 화면과 실측값 | > > 도식 셋은 `assets/diagrams/` 아래에 SVG·`.drawio` 편집 원본·`.alt.md`·`.manifest.json` 로 > 있고, `.techviz/<id>/spec.json` 이 각 도식이 답하는 질문과 근거 목록을 적어 둡니다. @@ -222,7 +222,7 @@ const PATH_PREFIX_KINDS: Record<PublicRecord["kind"], string> = { enum"을 전부 뽑아** 확인했습니다 (`2c25ccc`). 눈으로 찾을 일이 아니었습니다. > **근거** — 지금 코드에서 표로 바뀐 자리와 **아직 남은 구멍 둘**: -> [`evidence/terminal/guards/kind-tables-now.txt`](./evidence/terminal/guards/kind-tables-now.txt). +> [`evidence/raw/guards/kind-tables-now.txt`](./evidence/raw/guards/kind-tables-now.txt). > `PublicSql.pathOf` 는 sealed enum 이 아니라 String 으로 switch 하므로 여전히 `default -> null` > 이 남아 있고, `validate-working-copy.ts` 의 `stringFields` 도 아직 삼항 사슬입니다. @@ -723,9 +723,9 @@ FE-GATE-009 는 **설치된 라우트마다 수동 접근성 증거를 하나씩 확인했습니다. > **근거** — -> [`evidence/terminal/db/decision-path-after-v15.txt`](./evidence/terminal/db/decision-path-after-v15.txt) (저장된 주소가 앵커로 바뀌고 V15 가 적용된 것) · -> [`evidence/terminal/api/decision-anchor-fixed.txt`](./evidence/terminal/api/decision-anchor-fixed.txt) (그 링크가 실제로 200) · -> [`evidence/terminal/audit/dead-link-sweep.txt`](./evidence/terminal/audit/dead-link-sweep.txt) (35개 전수 200) +> [`evidence/raw/db/decision-path-after-v15.txt`](./evidence/raw/db/decision-path-after-v15.txt) (저장된 주소가 앵커로 바뀌고 V15 가 적용된 것) · +> [`evidence/raw/api/decision-anchor-fixed.txt`](./evidence/raw/api/decision-anchor-fixed.txt) (그 링크가 실제로 200) · +> [`evidence/raw/audit/dead-link-sweep.txt`](./evidence/raw/audit/dead-link-sweep.txt) (35개 전수 200) ### 9.3 주제가 없는 기록이 죽은 링크를 달았다 (`23efcf0`) @@ -1134,9 +1134,9 @@ topic (주제) ### 14.3 축이 무엇을 기준으로 묶이나 (실제 데이터) -> **근거** — [`evidence/terminal/db/topic-variant-rows.txt`](./evidence/terminal/db/topic-variant-rows.txt) · -> [`evidence/terminal/db/record-variant-links.txt`](./evidence/terminal/db/record-variant-links.txt) · -> 화면과 실측값은 [`evidence/screens/`](./evidence/screens/) +> **근거** — [`evidence/raw/db/topic-variant-rows.txt`](./evidence/raw/db/topic-variant-rows.txt) · +> [`evidence/raw/db/record-variant-links.txt`](./evidence/raw/db/record-variant-links.txt) · +> 화면과 실측값은 [`evidence/browser/`](./evidence/browser/) 두 주제가 **같은 구조**를 씁니다. 다만 내용의 양이 달라 다르게 보입니다. @@ -1172,7 +1172,7 @@ jpa-feed-query-performance 축 이름 「조회 전략」 축 3개 > **근거** — 가드 셋(`public-path-reachability` · `section-heading-rank` · `route-chunk-names`)을 > 각각 결함으로 되돌려 실제로 빨개지는 것을 확인한 기록: -> [`evidence/terminal/guards/guards-actually-fail.txt`](./evidence/terminal/guards/guards-actually-fail.txt) +> [`evidence/raw/guards/guards-actually-fail.txt`](./evidence/raw/guards/guards-actually-fail.txt) ### 15.1 프론트엔드 @@ -1240,7 +1240,7 @@ python3 scripts/check-openapi.py && python3 scripts/check-consistency.py \ ### 16.1 삭제를 막는 이유를 문구가 말하지 않는다 -> **근거** — [`evidence/terminal/db/delete-blocked-by-project-link.txt`](./evidence/terminal/db/delete-blocked-by-project-link.txt) +> **근거** — [`evidence/raw/db/delete-blocked-by-project-link.txt`](./evidence/raw/db/delete-blocked-by-project-link.txt) > (진단 당시 캡처 + 사용자가 조치한 뒤의 사후 확인) 작업본 삭제 실패는 다섯 가지 이유가 **전부 같은 한 문장**으로 나옵니다: @@ -1299,7 +1299,7 @@ UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id ### 16.7 종류 열거 두 곳이 아직 컴파일러의 보호를 못 받는다 이 문서를 쓰며 근거를 모으다가 새로 확인한 것입니다 -([`evidence/terminal/guards/kind-tables-now.txt`](./evidence/terminal/guards/kind-tables-now.txt)). +([`evidence/raw/guards/kind-tables-now.txt`](./evidence/raw/guards/kind-tables-now.txt)). - **`PublicSql.pathOf`** — `RecordKind` 가 아니라 `String`(공개 투영의 `resource_type`)으로 switch 합니다. 그 칸은 `RecordKind` 에 없는 값(`PROJECT`, `RELEASE`)도 담기 때문입니다. @@ -1317,7 +1317,7 @@ UNION ALL SELECT 1 FROM project_decision WHERE source_case_id = :id `3bb724b`·`2b2f443` 에서 `git add -A` 로 홈 탭 검토용 PNG 를 프론트 저장소 루트에 커밋했습니다 — `home-tabs-keycloak.png`, `home-topic-tabs.png`, `home-topic-tabs-2.png`. 소스에 들어갈 -파일이 아닙니다. (이 문서의 `evidence/screens/` 에는 사본을 뒀습니다.) +파일이 아닙니다. (이 문서의 `evidence/browser/` 에는 사본을 뒀습니다.) ### 16.9 주제 논지·축 결론의 출처 diff --git a/docs/TechLog/final/evidence/screens/README.txt b/docs/TechLog/final/evidence/browser/README.txt similarity index 100% rename from docs/TechLog/final/evidence/screens/README.txt rename to docs/TechLog/final/evidence/browser/README.txt diff --git a/docs/TechLog/final/evidence/screens/home-tabs-grouped.png b/docs/TechLog/final/evidence/browser/home-tabs-grouped.png similarity index 100% rename from docs/TechLog/final/evidence/screens/home-tabs-grouped.png rename to docs/TechLog/final/evidence/browser/home-tabs-grouped.png diff --git a/docs/TechLog/final/evidence/screens/home-tabs-keycloak.png b/docs/TechLog/final/evidence/browser/home-tabs-keycloak.png similarity index 100% rename from docs/TechLog/final/evidence/screens/home-tabs-keycloak.png rename to docs/TechLog/final/evidence/browser/home-tabs-keycloak.png diff --git a/docs/TechLog/final/evidence/screens/home-topic-tabs-2.png b/docs/TechLog/final/evidence/browser/home-topic-tabs-2.png similarity index 100% rename from docs/TechLog/final/evidence/screens/home-topic-tabs-2.png rename to docs/TechLog/final/evidence/browser/home-topic-tabs-2.png diff --git a/docs/TechLog/final/evidence/screens/home-topic-tabs.png b/docs/TechLog/final/evidence/browser/home-topic-tabs.png similarity index 100% rename from docs/TechLog/final/evidence/screens/home-topic-tabs.png rename to docs/TechLog/final/evidence/browser/home-topic-tabs.png diff --git a/docs/TechLog/final/evidence/screens/tab-metrics.txt b/docs/TechLog/final/evidence/browser/tab-metrics.txt similarity index 100% rename from docs/TechLog/final/evidence/screens/tab-metrics.txt rename to docs/TechLog/final/evidence/browser/tab-metrics.txt diff --git a/docs/TechLog/final/evidence/terminal/api/decision-anchor-fixed.txt b/docs/TechLog/final/evidence/raw/api/decision-anchor-fixed.txt similarity index 100% rename from docs/TechLog/final/evidence/terminal/api/decision-anchor-fixed.txt rename to docs/TechLog/final/evidence/raw/api/decision-anchor-fixed.txt diff --git a/docs/TechLog/final/evidence/terminal/audit/dead-link-sweep.txt b/docs/TechLog/final/evidence/raw/audit/dead-link-sweep.txt similarity index 100% rename from docs/TechLog/final/evidence/terminal/audit/dead-link-sweep.txt rename to docs/TechLog/final/evidence/raw/audit/dead-link-sweep.txt diff --git a/docs/TechLog/final/evidence/terminal/audit/link-audit.py b/docs/TechLog/final/evidence/raw/audit/link-audit.py similarity index 100% rename from docs/TechLog/final/evidence/terminal/audit/link-audit.py rename to docs/TechLog/final/evidence/raw/audit/link-audit.py diff --git a/docs/TechLog/final/evidence/terminal/db/decision-path-after-v15.txt b/docs/TechLog/final/evidence/raw/db/decision-path-after-v15.txt similarity index 100% rename from docs/TechLog/final/evidence/terminal/db/decision-path-after-v15.txt rename to docs/TechLog/final/evidence/raw/db/decision-path-after-v15.txt diff --git a/docs/TechLog/final/evidence/terminal/db/delete-blocked-by-project-link.txt b/docs/TechLog/final/evidence/raw/db/delete-blocked-by-project-link.txt similarity index 100% rename from docs/TechLog/final/evidence/terminal/db/delete-blocked-by-project-link.txt rename to docs/TechLog/final/evidence/raw/db/delete-blocked-by-project-link.txt diff --git a/docs/TechLog/final/evidence/terminal/db/record-variant-links.txt b/docs/TechLog/final/evidence/raw/db/record-variant-links.txt similarity index 100% rename from docs/TechLog/final/evidence/terminal/db/record-variant-links.txt rename to docs/TechLog/final/evidence/raw/db/record-variant-links.txt diff --git a/docs/TechLog/final/evidence/terminal/db/topic-variant-rows.txt b/docs/TechLog/final/evidence/raw/db/topic-variant-rows.txt similarity index 100% rename from docs/TechLog/final/evidence/terminal/db/topic-variant-rows.txt rename to docs/TechLog/final/evidence/raw/db/topic-variant-rows.txt diff --git a/docs/TechLog/final/evidence/terminal/guards/guards-actually-fail.txt b/docs/TechLog/final/evidence/raw/guards/guards-actually-fail.txt similarity index 100% rename from docs/TechLog/final/evidence/terminal/guards/guards-actually-fail.txt rename to docs/TechLog/final/evidence/raw/guards/guards-actually-fail.txt diff --git a/docs/TechLog/final/evidence/terminal/guards/kind-tables-now.txt b/docs/TechLog/final/evidence/raw/guards/kind-tables-now.txt similarity index 100% rename from docs/TechLog/final/evidence/terminal/guards/kind-tables-now.txt rename to docs/TechLog/final/evidence/raw/guards/kind-tables-now.txt diff --git a/docs/_templates/README.md b/docs/_templates/README.md new file mode 100644 index 0000000..13eb96f --- /dev/null +++ b/docs/_templates/README.md @@ -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을 우선한다. diff --git a/docs/_templates/analysis/00-project-overview.md b/docs/_templates/analysis/00-project-overview.md new file mode 100644 index 0000000..a3ee3ec --- /dev/null +++ b/docs/_templates/analysis/00-project-overview.md @@ -0,0 +1,25 @@ +# Project Overview + +## 분석 기준 revision + +- repository: `/shared/codebase/<project>` +- revision: `<git revision or non-git snapshot note>` + +## Build and module map + +## Dependency direction + +## Runtime entry points + +## Persistence / messaging / external systems + +## Test topology + +## Configuration and operational surfaces + +## 분석할 bounded scopes + +| scope | why separate | status | analysis file | +|---|---|---|---| + +## 아직 단정하지 않는 것 diff --git a/docs/_templates/analysis/module.md b/docs/_templates/analysis/module.md new file mode 100644 index 0000000..6ba2e1d --- /dev/null +++ b/docs/_templates/analysis/module.md @@ -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를 연결한다. diff --git a/docs/_templates/evidence/meta/evidence.json b/docs/_templates/evidence/meta/evidence.json new file mode 100644 index 0000000..9a0d7b1 --- /dev/null +++ b/docs/_templates/evidence/meta/evidence.json @@ -0,0 +1,13 @@ +{ + "id": "<evidence-id>", + "kind": "terminal|browser|query-plan|benchmark|other", + "sourceRevision": "<git revision or snapshot>", + "executedAt": "<ISO-8601>", + "command": "<command without secrets or null>", + "cwd": "<working directory or null>", + "exitCode": null, + "rawPath": "evidence/raw/<file>", + "presentationPath": "evidence/terminal|browser|svg/<file>", + "proves": "<bounded claim>", + "doesNotProve": "<important limitation>" +} diff --git a/docs/_templates/final/document.md b/docs/_templates/final/document.md new file mode 100644 index 0000000..12cae8c --- /dev/null +++ b/docs/_templates/final/document.md @@ -0,0 +1,27 @@ +# <project> 상세 분석 + +> 이 문서는 `analysis/`의 완료된 bounded analysis를 통합한 정본이다. 새로운 사실을 이 문서에서 처음 만들어내지 않는다. + +## 1. Project map + +## 2. Architectural boundaries + +## 3. Representative execution paths + +## 4. Data and state + +## 5. Failure and operational behavior + +## 6. Tests and verification coverage + +## 7. Confirmed problems / incidents + +## 8. Reusable criteria and rules + +## 9. Unresolved questions + +## 10. Explicit project decisions + +## 11. Evidence index + +## 12. Limits of this analysis diff --git a/docs/_templates/root-tree.md b/docs/_templates/root-tree.md new file mode 100644 index 0000000..98375a8 --- /dev/null +++ b/docs/_templates/root-tree.md @@ -0,0 +1,85 @@ +--- +schemaVersion: 1 +project: <project> +sourceDocument: final/document.md +sourceDocumentSha256: <sha256> +sourceRevision: <git-revision-or-snapshot> +generatedAt: <ISO-8601> +--- + +# Root Tree + +PROJECT +<project> + +TOPIC +<Topic title> +<topic-slug> + +├── CASE +│ └── <Case title> +├── REFERENCE +│ └── <Reference title> +├── OPEN QUESTION +│ └── <Open Question title> +└── DECISION + └── <Decision title> + +# Node Specifications + +## CASE — <Case title> + +- slug: `<slug>` +- readiness: `READY | NEEDS_EVIDENCE | BLOCKED | REJECTED` +- source: + - `final/document.md#<anchor>` +- code: + - `/shared/codebase/<project>/<path>:<line-or-symbol>` +- evidence: + - `evidence/raw/<file>` +- classification: `<specific incident/experiment/diagnosis that makes this a Case>` +- missing-verification: `<none or concrete missing check>` +- relations: + - `<kind>:<slug> — <reason>` + +## REFERENCE — <Reference title> + +- slug: `<slug>` +- readiness: `READY | NEEDS_EVIDENCE | BLOCKED | REJECTED` +- source: + - `final/document.md#<anchor>` +- classification: `<reusable criterion rather than a retelling of one Case>` +- scope: `<where the rule applies>` +- exceptions: `<known exceptions or none>` +- relations: + - `<kind>:<slug> — <reason>` + +## OPEN QUESTION — <Open Question title> + +- slug: `<slug>` +- readiness: `OPEN | BLOCKED | REJECTED` +- source: + - `final/document.md#<anchor>` +- known: + - `<grounded fact>` +- unknown: + - `<unresolved fact>` +- next-verification: `<what would reduce the uncertainty>` +- decision-criterion: `<what would allow this question to close>` +- relations: + - `<kind>:<slug> — <reason>` + +## DECISION — <Decision title> + +- slug: `<slug>` +- readiness: `READY | NEEDS_DECISION | BLOCKED | REJECTED` +- decision-status: `PROPOSED | ADOPTED | SUPERSEDED | NOT_DECIDED` +- source: + - `final/document.md#<anchor>` +- decision-evidence: + - `<commit/ADR/config/history/user-provided decision record>` +- grounds: + - `<case/reference relation>` +- classification: `<why this is an actual project decision, not advice>` +- relations: + - `<kind>:<slug> — <reason>` diff --git a/docs/_templates/source-index.md b/docs/_templates/source-index.md new file mode 100644 index 0000000..0f14e92 --- /dev/null +++ b/docs/_templates/source-index.md @@ -0,0 +1,7 @@ +# Source Index + +상세 문서의 주장과 근거를 다시 찾을 수 있게 code/config/test/git/runtime source를 색인한다. + +| id | kind | path / command / URL | revision or time | what it proves | limitations | +|---|---|---|---|---|---| +| SRC-001 | code | `<path>` | `<revision>` | `<grounded fact>` | `<limit>` | diff --git a/docs/_templates/state.json b/docs/_templates/state.json new file mode 100644 index 0000000..5e882c4 --- /dev/null +++ b/docs/_templates/state.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 2, + "project": "<project>", + "codebasePath": "/shared/codebase/<project>", + "gitRevision": null, + "analysisStatus": "NOT_STARTED", + "analysisCycle": 1, + "scopes": [], + "reanalysis": { + "baselineRevision": null, + "targetRevision": null, + "mode": null, + "changedPaths": [], + "impactedScopes": [], + "requestedAt": null, + "completedAt": null + }, + "finalDocument": { + "path": "final/document.md", + "status": "NOT_STARTED", + "sourceRevision": null + }, + "rootTree": { + "path": "root-tree.md", + "status": "NOT_STARTED", + "sourceDocumentHash": null + }, + "evidenceTasks": [], + "lastRunAt": null +} diff --git a/docs/clean-architecture-backend-template/tech-log-studio/tech-log-tree.json b/docs/clean-architecture-backend-template/tech-log-studio/tech-log-tree.json index 238b1f1..a5868b0 100644 --- a/docs/clean-architecture-backend-template/tech-log-studio/tech-log-tree.json +++ b/docs/clean-architecture-backend-template/tech-log-studio/tech-log-tree.json @@ -1,7 +1,15 @@ { + "schemaVersion": 2, "project": "clean-architecture-backend-template", "ssot": "final/document.md", + "ssotSha256": "57ce748b3dc8fb3f2b11539aced2666fd7715a2f774e2727949aa5a1c02748b6", "generatedAt": "2026-09-04", - "note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다.", + "note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다. ssotSha256 이 지금 final/document.md 와 다르면 SSOT 가 바뀐 뒤 트리를 다시 보지 않은 것이다.", + "readinessValues": [ + "READY", + "NEEDS_EVIDENCE", + "BLOCKED", + "REJECTED" + ], "topics": {} } diff --git a/docs/keycloak/tech-log-studio/tech-log-tree.json b/docs/keycloak/tech-log-studio/tech-log-tree.json index dc162e6..d8a09e7 100644 --- a/docs/keycloak/tech-log-studio/tech-log-tree.json +++ b/docs/keycloak/tech-log-studio/tech-log-tree.json @@ -1,8 +1,16 @@ { + "schemaVersion": 2, "project": "keycloak", "ssot": "final/document.md", + "ssotSha256": "0625bc875ab31ca6f331e6f64bd93f26397d54f3ac6e0162f4128a53a1d97e2d", "generatedAt": "2026-09-04", - "note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다.", + "note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다. ssotSha256 이 지금 final/document.md 와 다르면 SSOT 가 바뀐 뒤 트리를 다시 보지 않은 것이다.", + "readinessValues": [ + "READY", + "NEEDS_EVIDENCE", + "BLOCKED", + "REJECTED" + ], "topics": { "oauth-oidc-auth-boundary": { "topic": "oauth-oidc-auth-boundary", @@ -12,37 +20,74 @@ "title": "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출", "slug": "split-custody-access-token", "file": "oauth-oidc-auth-boundary/case/case-ap2-split-custody.md", + "readiness": "READY", "status": "게시 중", "studioId": "488ce49b-afa4-42a5-a2ce-de2e0653cd82", - "assets": 1, - "evidence": 0 + "assets": [ + "ap2-split-custody-779cb791" + ], + "evidence": [], + "relations": [ + "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계", + "Public Client와 Confidential Client 구분 기준", + "OAuth Token과 Application Session을 구분하는 기준", + "OAuth/OIDC 인증 패턴 선택 기준", + "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" + ] }, { "title": "BFF에서 Browser Token을 제거하고 Session과 CSRF를 처리한 방식", "slug": "bff-session-csrf-responsibility", "file": "oauth-oidc-auth-boundary/case/case-ap3-bff-session-csrf.md", + "readiness": "READY", "status": "게시 중", "studioId": "d85bd6af-7599-4ef7-9407-6609927d5b5c", - "assets": 2, - "evidence": 0 + "assets": [ + "ap3-bff-custody-82fa18bd", + "ap3-csrf-split-501dd1f7" + ], + "evidence": [], + "relations": [ + "BFF 인증 구조 설계 기준", + "OAuth Token과 Application Session을 구분하는 기준", + "OAuth/OIDC 인증 패턴 선택 기준", + "BFF가 OAuth Token을 관리하는 조건", + "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가", + "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" + ] }, { "title": "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유", "slug": "identity-header-trust", "file": "oauth-oidc-auth-boundary/case/case-ap4-identity-header-trust.md", + "readiness": "READY", "status": "게시 중", "studioId": "a0e1cc05-92b3-4dac-bce1-513ab8cd862b", - "assets": 1, - "evidence": 0 + "assets": [ + "ap4-edge-trust-1cff2399" + ], + "evidence": [], + "relations": [ + "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건", + "OAuth Token과 Application Session을 구분하는 기준", + "OAuth/OIDC 인증 패턴 선택 기준", + "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" + ] }, { "title": "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우", "slug": "spa-browser-credential-boundary", "file": "oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md", + "readiness": "READY", "status": "게시 중", "studioId": "bf675775-4f3e-4744-8014-f0efff51422a", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Authorization Code Flow의 Endpoint와 Credential 이동 기준", + "Public Client와 Confidential Client 구분 기준", + "OAuth Token과 Application Session을 구분하는 기준" + ] } ], "concept": [ @@ -50,55 +95,91 @@ "title": "Authorization Code와 PKCE가 보호하는 구간", "slug": "authorization-code-and-pkce", "file": "oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md", + "readiness": "READY", "status": "게시 전", "studioId": "75c6c657-3e03-47a0-a9d0-5637fce9dd3f", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Authorization Code Flow의 Endpoint와 Credential 이동 기준", + "Public Client와 Confidential Client 구분 기준", + "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" + ] }, { "title": "Bearer JWT가 인증된 principal이 되기까지", "slug": "bearer-jwt-validation-chain", "file": "oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md", + "readiness": "READY", "status": "게시 전", "studioId": "87000d59-b69f-4010-9481-0b71c8bde32d", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "OAuth Token과 Application Session을 구분하는 기준", + "Authorization Code Flow의 Endpoint와 Credential 이동 기준", + "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" + ] }, { "title": "브라우저가 credential을 보관하는 위치와 그 성질", "slug": "browser-credential-storage", "file": "oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md", + "readiness": "READY", "status": "게시 전", "studioId": "bb5c37ae-2d94-48f7-ad4e-a37c61c3fd07", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "OAuth Token과 Application Session을 구분하는 기준", + "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계", + "BFF 인증 구조 설계 기준" + ] }, { "title": "Cookie로 인증하는 요청에서 CSRF token이 하는 일", "slug": "cookie-auth-csrf", "file": "oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md", + "readiness": "READY", "status": "게시 전", "studioId": "5c8f12d5-1ead-469b-8e91-2de69401df48", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "BFF 인증 구조 설계 기준", + "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정", + "OAuth Token과 Application Session을 구분하는 기준" + ] }, { "title": "Forward-Auth와 Nginx auth_request의 동작", "slug": "forward-auth-and-auth-request", "file": "oauth-oidc-auth-boundary/concept/concept-forward-auth-and-auth-request.md", + "readiness": "READY", "status": "게시 전", "studioId": "a3493786-d3fb-4b01-b1c5-ecb23c3d5497", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건", + "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유", + "OAuth/OIDC 인증 패턴 선택 기준" + ] }, { "title": "외부 IdP Brokering의 동작", "slug": "idp-brokering", "file": "oauth-oidc-auth-boundary/concept/concept-idp-brokering.md", + "readiness": "READY", "status": "게시 전", "studioId": "d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "외부 IdP 연동과 Application 인증 구조의 경계", + "OAuth Token과 Application Session을 구분하는 기준", + "Authorization Code Flow의 Endpoint와 Credential 이동 기준" + ] } ], "reference": [ @@ -106,64 +187,109 @@ "title": "Authorization Code Flow의 Endpoint와 Credential 이동 기준", "slug": "authorization-code-endpoint-credential-movement", "file": "oauth-oidc-auth-boundary/reference/reference-authorization-code-endpoints.md", + "readiness": "READY", "status": "게시 중", "studioId": "39fdf472-82c4-43ed-abec-73de672f08ae", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계", + "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출", + "Public Client와 Confidential Client 구분 기준" + ] }, { "title": "BFF 인증 구조 설계 기준", "slug": "bff-authentication-design-criteria", "file": "oauth-oidc-auth-boundary/reference/reference-bff-auth-design.md", + "readiness": "READY", "status": "게시 중", "studioId": "97eddd97-1096-426a-a2c6-a6c5bf1cd09f", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정", + "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가", + "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가", + "BFF가 OAuth Token을 관리하는 조건" + ] }, { "title": "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건", "slug": "forward-auth-identity-header-trust", "file": "oauth-oidc-auth-boundary/reference/reference-forward-auth-header-trust.md", + "readiness": "READY", "status": "게시 중", "studioId": "004dd0a2-5fb3-4f25-80c9-576f709de331", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유", + "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가", + "OAuth Token과 Application Session을 구분하는 기준" + ] }, { "title": "외부 IdP 연동과 Application 인증 구조의 경계", "slug": "external-idp-federation-application-boundary", "file": "oauth-oidc-auth-boundary/reference/reference-idp-federation-boundary.md", + "readiness": "READY", "status": "게시 중", "studioId": "1a00a640-8987-4075-a9e4-7ec023cdffbb", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다.", + "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계", + "Authorization Code Flow의 Endpoint와 Credential 이동 기준" + ] }, { "title": "OAuth/OIDC 인증 패턴 선택 기준", "slug": "oauth-oidc-pattern-selection-criteria", "file": "oauth-oidc-auth-boundary/reference/reference-pattern-selection.md", + "readiness": "READY", "status": "게시 중", "studioId": "3f886154-1b85-407b-bda4-57d28370e745", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계", + "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출", + "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정", + "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" + ] }, { "title": "Public Client와 Confidential Client 구분 기준", "slug": "public-confidential-client-boundary", "file": "oauth-oidc-auth-boundary/reference/reference-public-confidential-client.md", + "readiness": "READY", "status": "게시 중", "studioId": "ede6b9ce-eeed-40c8-9175-9e8116029395", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계", + "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출", + "Authorization Code Flow의 Endpoint와 Credential 이동 기준" + ] }, { "title": "OAuth Token과 Application Session을 구분하는 기준", "slug": "oauth-token-application-session-boundary", "file": "oauth-oidc-auth-boundary/reference/reference-token-vs-session.md", + "readiness": "READY", "status": "게시 중", "studioId": "66c18e42-116c-459f-86bd-b7e4bf394866", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계", + "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출", + "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정", + "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" + ] } ], "question": [ @@ -171,37 +297,64 @@ "title": "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가", "slug": "bff-session-authorized-client-store", "file": "oauth-oidc-auth-boundary/question/question-bff-state-store.md", + "readiness": "READY", "status": "게시 중", "studioId": "18a5cde2-dd1e-4bff-9f1c-997577ae438f", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가", + "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정", + "BFF 인증 구조 설계 기준", + "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" + ] }, { "title": "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가", "slug": "edge-authorization-scope", "file": "oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md", + "readiness": "READY", "status": "게시 중", "studioId": "7ff40767-a00b-4db2-98f6-0cdfce8c8936", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유", + "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건", + "BFF 인증 구조 설계 기준" + ] }, { "title": "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가", "slug": "server-session-pattern-multi-instance", "file": "oauth-oidc-auth-boundary/question/question-multi-instance-session.md", + "readiness": "READY", "status": "게시 중", "studioId": "c72656b5-842d-45d9-b5f6-82b66b09d0b9", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정", + "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출", + "BFF 인증 구조 설계 기준", + "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" + ] }, { "title": "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가", "slug": "refresh-rotation-replica-contention", "file": "oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md", + "readiness": "READY", "status": "게시 중", "studioId": "9ae4ec71-a32e-49a7-88c2-f7368541c28d", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가", + "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출", + "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가", + "BFF 인증 구조 설계 기준" + ] } ], "decision": [ @@ -209,19 +362,32 @@ "title": "BFF가 OAuth Token을 관리하는 조건", "slug": "bff-owns-token-when-browser-must-not", "file": "oauth-oidc-auth-boundary/decision/decision-bff-owns-token.md", + "readiness": "READY", "status": "게시 중", "studioId": "19b55c39-c583-4161-9775-df954280a568", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정", + "BFF 인증 구조 설계 기준", + "OAuth/OIDC 인증 패턴 선택 기준", + "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출" + ] }, { "title": "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다.", "slug": "federation-is-not-an-application-pattern", "file": "oauth-oidc-auth-boundary/decision/decision-federation-not-a-pattern.md", + "readiness": "READY", "status": "게시 중", "studioId": "8c1ebea7-204e-445c-9812-0421d9eb0e9c", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "외부 IdP 연동과 Application 인증 구조의 경계", + "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계", + "OAuth Token과 Application Session을 구분하는 기준" + ] } ] } diff --git a/docs/n+1liner/final/document.md b/docs/n+1liner/final/document.md index 04b9883..7d2dce7 100755 --- a/docs/n+1liner/final/document.md +++ b/docs/n+1liner/final/document.md @@ -238,7 +238,7 @@ highlightCount(i) = max(1, round(500 / (i+1)^1.15)) // 상한 500, 하한 1 Zipf의 법칙은 "순위 `r`인 항목의 빈도 ∝ `1/r^s`"이고 고전적 지프는 지수 `s=1`이라 1위가 2위의 두 배입니다. 저는 조금 더 가파르게 줄어들도록 `s=1.15`를 사용했습니다. 이때 1위는 2위의 `2^1.15≈2.2`배가 됩니다. 단어 빈도·도시 인구·웹페이지 조회 수 같은 heavy-tailed 편중이 이 계열입니다. 다만 이 분포가 실제 라이너 데이터와 같다고 주장하는 것은 아닙니다. "일부 페이지에 하이라이트가 매우 많을 수 있음"을 통제된 방식으로 재현하려고 만든 스트레스 분포입니다. `max(1, …)`로 바닥값을 두었으므로 전 구간이 순수한 멱법칙을 따르지는 않고 floor를 적용한 truncated Zipf-like 분포에 가깝습니다. -공식을 대입한 순위별 실제 생성 개수(원본: [`evidence/metrics/l1-skew-distribution.csv`](./evidence/metrics/l1-skew-distribution.csv)): +공식을 대입한 순위별 실제 생성 개수(원본: [`evidence/raw/metrics/l1-skew-distribution.csv`](./evidence/raw/metrics/l1-skew-distribution.csv)): | 순위(rank) | 1 | 2 | 3 | 5 | 10 | 50 | 100 | 꼬리(≈150위~) | |---|---|---|---|---|---|---|---|---| @@ -553,7 +553,7 @@ EAGER의 secondary SELECT 구조가 추가 조회의 가능성을 만듭니다. ### 6.4 각 조회는 "빠르다" — 그런데도 느리다 반복되는 하이라이트 조회 하나를 실행계획으로 확인했습니다. 아래는 **Plan A — 대량 시드 직후, -`ANALYZE` 실행 전**의 계획입니다(원문: [`evidence/terminal/explain/highlights-child-plan-A.txt`](./evidence/terminal/explain/highlights-child-plan-A.txt)). +`ANALYZE` 실행 전**의 계획입니다(원문: [`evidence/raw/explain/highlights-child-plan-A.txt`](./evidence/raw/explain/highlights-child-plan-A.txt)). ```text Index Scan using ix_highlights_feed_items_created on highlights @@ -634,7 +634,7 @@ EXPLAIN 수치를 읽을 때 주의할 두 가지가 더 있습니다. | 100 | **100** | 20 | 120 | 100 | 222 | | 1,000 | **1,000** | 20 | 1,020 | 1,000 | 2,022 | -성격: 측정값(직접) — 출처 `FeedPersistenceIT.l2ToOneEagerHiddenNPlusOneCurve`(콘솔 `>>> LAB L2 [eager toOne curve …]`, 리포트 `app-bootstrap/build/lab-results/feed-nplus1.md`). 원본: [`evidence/metrics/l2-toone-split.csv`](./evidence/metrics/l2-toone-split.csv). +성격: 측정값(직접) — 출처 `FeedPersistenceIT.l2ToOneEagerHiddenNPlusOneCurve`(콘솔 `>>> LAB L2 [eager toOne curve …]`, 리포트 `app-bootstrap/build/lab-results/feed-nplus1.md`). 원본: [`evidence/raw/metrics/l2-toone-split.csv`](./evidence/raw/metrics/l2-toone-split.csv). 검산(6.3절 파생과 일치): `entityFetch = pageFetch + userFetch` → `10+3=13` · `100+20=120` · `1000+20=1020` ✓. 회계 항등식으로도 `총 PreparedStatement − 컬렉션 N − content(1) − count(1) = entityFetch` → `25−10−2=13` · `222−100−2=120` · `2022−1000−2=1020` ✓. 앞서 6.3절에서 역산했던 13 / 120 / 1,020을 직접 측정이 그대로 재현했습니다 — 파생 예측이 실측으로 확정됐습니다. @@ -669,7 +669,7 @@ EAGER 기본값 때문에 생긴 N+1이었습니다. 같은 조건에서 LAZY 6.4절에서 자식 컬렉션 쿼리를 확인한 것처럼, 이번에는 N2를 만드는 **반복되는 ToOne 부모 쿼리** (`SELECT * FROM pages WHERE id = ?`, `… FROM users WHERE id = ?`)를 실행계획으로 -확인했습니다. 아래는 seed(100) 직후의 계획입니다(원문: [`evidence/terminal/explain/toone-pages-plan.txt`](./evidence/terminal/explain/toone-pages-plan.txt) · [`toone-users-plan.txt`](./evidence/terminal/explain/toone-users-plan.txt)). +확인했습니다. 아래는 seed(100) 직후의 계획입니다(원문: [`evidence/raw/explain/toone-pages-plan.txt`](./evidence/raw/explain/toone-pages-plan.txt) · [`toone-users-plan.txt`](./evidence/raw/explain/toone-users-plan.txt)). ```text -- pages @@ -805,7 +805,7 @@ java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchExcep > `SELECT count(*) FROM feed_items fi JOIN highlights h ON h.feed_item_id = fi.id`를 > 측정했습니다. 이 문제는 EXPLAIN actual rows나 조인 count로 확인해야 합니다. -**측정값(직접 측정).** 출처 `FeedPersistenceIT.l3SingleCollectionFetchJoinExplodesTransferredRows`(N=10/100/1000). 원본: [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv). +**측정값(직접 측정).** 출처 `FeedPersistenceIT.l3SingleCollectionFetchJoinExplodesTransferredRows`(N=10/100/1000). 원본: [`evidence/raw/metrics/l3-cartesian.csv`](./evidence/raw/metrics/l3-cartesian.csv). | N | 전송 행수(★조인 카디널리티) | 리스트 크기(Hib6 dedup) | distinct 아이템 | 시드 하이라이트 | 폭발 배수 | 총 PreparedStatement | |---:|---:|---:|---:|---:|---:|---:| @@ -842,7 +842,7 @@ fetch join한 쿼리는 **121개**였습니다. 쿼리 수만 보면 개선처 앞서 6.4절에서는 반복되는 자식 단건 쿼리를, 7.4절에서는 부모 단건 쿼리를 확인했습니다. 이번 차례는 fetch join이 만든 조인 하나입니다. 아래는 seed(100) 직후 같은 형태의 쿼리를 -EXPLAIN한 결과입니다(원문: [`evidence/terminal/explain/l3-cartesian-join-plan.txt`](./evidence/terminal/explain/l3-cartesian-join-plan.txt)). +EXPLAIN한 결과입니다(원문: [`evidence/raw/explain/l3-cartesian-join-plan.txt`](./evidence/raw/explain/l3-cartesian-join-plan.txt)). ```text Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 rows=1961 loops=1) @@ -906,7 +906,7 @@ join한 상태에서 페이징을 적용해 보았습니다. **N개 전부** 로드되었습니다. `EntityStatistics.getLoadCount()`로 FeedItem 로드 수를 따로 읽어 응답 크기와 실제 적재량을 비교했습니다. -**측정값(직접 측정·파생).** `returned`·`feedItemLoaded`는 결정적(리스트 크기·Hibernate 통계로 확정), over-fetch 배수는 `feedItemLoaded / returned`로 파생합니다. 출처 `FeedPersistenceIT.l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory`. 원본: [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv). +**측정값(직접 측정·파생).** `returned`·`feedItemLoaded`는 결정적(리스트 크기·Hibernate 통계로 확정), over-fetch 배수는 `feedItemLoaded / returned`로 파생합니다. 출처 `FeedPersistenceIT.l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory`. 원본: [`evidence/raw/metrics/l4-inmemory-paging.csv`](./evidence/raw/metrics/l4-inmemory-paging.csv). | N | returned(페이지) | feedItemLoaded(★ = N) | over-fetch 배수 | 시드 하이라이트 | |---:|---:|---:|---:|---:| @@ -942,7 +942,7 @@ join한 상태에서 페이징을 적용해 보았습니다. 응답은 한 페이지인데 비용은 N에 비례하는지 측정했습니다. 아래 값은 문서 첫머리에서 밝힌 대로 **단일 스레드·warm-cache 상대값**입니다. 절대값이 아니라 N에 따른 변화 방향만 비교했습니다 -(원본: [`evidence/metrics/l4-cost-curve.csv`](./evidence/metrics/l4-cost-curve.csv)). +(원본: [`evidence/raw/metrics/l4-cost-curve.csv`](./evidence/raw/metrics/l4-cost-curve.csv)). | N | 지연 중앙값(5회) | 지연 최댓값(5회) | 스레드 누적 할당 | |---:|---:|---:|---:| @@ -972,7 +972,7 @@ join한 상태에서 페이징을 적용해 보았습니다. ### 10.4 발행 SQL엔 LIMIT이 없다 — 인메모리 페이징의 스모킹건 인메모리 페이징을 실행계획에서도 확인했습니다. fetch join이 발행한 SQL(a)과 엔티티만 페이징한 -SQL(b)을 seed(100)에서 EXPLAIN으로 비교했습니다(원문: [`evidence/terminal/explain/l4-collection-join-no-limit.txt`](./evidence/terminal/explain/l4-collection-join-no-limit.txt) · [`l4-entity-paging-limit.txt`](./evidence/terminal/explain/l4-entity-paging-limit.txt)). +SQL(b)을 seed(100)에서 EXPLAIN으로 비교했습니다(원문: [`evidence/raw/explain/l4-collection-join-no-limit.txt`](./evidence/raw/explain/l4-collection-join-no-limit.txt) · [`l4-entity-paging-limit.txt`](./evidence/raw/explain/l4-entity-paging-limit.txt)). ```text -- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음 @@ -1035,7 +1035,7 @@ LAZY 연관에 접근합니다. 앞서 N+1을 만들었던 그 코드가 이 설 ### 11.2 실측 — 배치 적용 전후의 쿼리 수 -`loadFeed(0, n)`(기준선과 정확히 같은 호출)을 배치 세션에서 재면 SQL 총량이 순진의 `1+N`에서 급감합니다. before = 기준선 실측, after = `FeedBatchFetchIT.l5BatchFetchCollapsesQueryCount`. 원본: [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv). +`loadFeed(0, n)`(기준선과 정확히 같은 호출)을 배치 세션에서 재면 SQL 총량이 순진의 `1+N`에서 급감합니다. before = 기준선 실측, after = `FeedBatchFetchIT.l5BatchFetchCollapsesQueryCount`. 원본: [`evidence/raw/metrics/l5-batch-resolution.csv`](./evidence/raw/metrics/l5-batch-resolution.csv). | N | before: 순진 총 PreparedStatement | after: 배치 총 PreparedStatement | 붕괴 | before: 컬렉션 fetch | after: 컬렉션 fetch | |---:|---:|---:|---:|---:|---:| @@ -1069,7 +1069,7 @@ fetch join에서는 `feedItemLoaded`가 N까지 늘었지만 배치 적용 뒤 ### 11.4 EXPLAIN — 페이징엔 Limit 노드, 배치 IN엔 곱셈 없음 (카테시안·인메모리 페이징 둘 다 해소) -앞 절의 스모킹건은 "(a) fetch join 조인 SQL엔 Limit 노드가 없다"였습니다. 이번에는 정반대 — 엔티티만 페이징하니 Limit 노드가 붙고 자식은 `IN` 배치라 행을 안 곱한다(seed(100), 원문: [`evidence/terminal/explain/l5-entity-paging-limit.txt`](./evidence/terminal/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/terminal/explain/l5-batch-in-semijoin.txt)). +앞 절의 스모킹건은 "(a) fetch join 조인 SQL엔 Limit 노드가 없다"였습니다. 이번에는 정반대 — 엔티티만 페이징하니 Limit 노드가 붙고 자식은 `IN` 배치라 행을 안 곱한다(seed(100), 원문: [`evidence/raw/explain/l5-entity-paging-limit.txt`](./evidence/raw/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/raw/explain/l5-batch-in-semijoin.txt)). ```text -- (a) 엔티티만 페이징 — Limit 노드 존재 (fetch join 조인엔 없었다) @@ -1102,7 +1102,7 @@ join 대신 배치를 사용하기로 했습니다. 배치로 쿼리 수와 페이징 문제는 풀었지만 엔티티는 여전히 통째로 하이드레이트했습니다. `FeedBatchFetchIT.l5ProbeBatchStillHydratesFullEntities`에서 seed 1,000의 첫 페이지 20건을 조회하자 FeedItem·User·Page·Highlight를 합해 **1,569개 엔티티**가 영속 객체로 올라왔습니다 -(원본: [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv)). +(원본: [`evidence/raw/metrics/l5-hydration-probe.csv`](./evidence/raw/metrics/l5-hydration-probe.csv)). 화면에는 일부 컬럼만 필요했으므로 다음에는 DTO 프로젝션으로 적재 대상을 줄였습니다. --- @@ -1142,7 +1142,7 @@ CQRS-lite 계약으로 노출합니다. seed 1,000에서 `loadFeedProjection(0, 20)`을 실행하고 앞 절의 배치 조회와 비교했습니다. 프로젝션은 하이드레이트한 엔티티가 0개였습니다. 원본: -[`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv). +[`evidence/raw/metrics/l6-projection-resolution.csv`](./evidence/raw/metrics/l6-projection-resolution.csv). | 지표 | before: 배치 | after: 프로젝션 | |---|---:|---:| @@ -1172,7 +1172,7 @@ N을 10, 100, 1,000으로 바꿔 다시 측정해도 prepared는 **항상 2개** ### 12.4 EXPLAIN — Limit·semi-join은 있으나 width는 좁아지지 않는다 (★ 실측 정정) -앞서 11절의 D2는 "엔티티 페이징엔 Limit 노드"였습니다. 프로젝션도 (a) 부모 페이징에 `Limit`이 있고 (b) 자식 IN은 semi-join이라 행을 안 곱한다(원문: [`evidence/terminal/explain/l6-parent-projection.txt`](./evidence/terminal/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/terminal/explain/l6-child-projection.txt)). +앞서 11절의 D2는 "엔티티 페이징엔 Limit 노드"였습니다. 프로젝션도 (a) 부모 페이징에 `Limit`이 있고 (b) 자식 IN은 semi-join이라 행을 안 곱한다(원문: [`evidence/raw/explain/l6-parent-projection.txt`](./evidence/raw/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/raw/explain/l6-child-projection.txt)). ```text -- (a) 부모 스칼라 프로젝션 — Limit 존재하나 width=2088 (users·pages 조인이 행폭에 흘러든다) @@ -1187,7 +1187,7 @@ Hash Semi Join (... rows=1509 loops=1) ← 페이지 20 부모의 하이라 > **★ 실측 정정** — 필요한 컬럼만 선택하면 EXPLAIN의 `width`도 줄어들 것으로 예상했지만 > 부모 프로젝션의 width는 2088로 엔티티 조회의 1194보다 컸습니다(원본: -> [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv)). +> [`evidence/raw/metrics/l6-explain-width.csv`](./evidence/raw/metrics/l6-explain-width.csv)). > `users`와 `pages` 조인의 행폭이 반영되고 PostgreSQL의 `width`가 실제 전송 바이트가 아니라 > 컬럼 타입의 평균폭 추정치이기 때문입니다. 프로젝션의 효과는 SQL 플랜의 width가 아니라 > `Statistics.getEntityLoadCount()`에서 확인했습니다. @@ -1204,7 +1204,7 @@ Carrier(f.id, u.name, …)`는 영속 엔티티를 만들지 않으므로 1차 프로젝션은 엔티티 과적재를 없앴지만 자식 IN 쿼리는 페이지 부모의 하이라이트를 **전부** 가져왔습니다. seed 1,000의 첫 페이지 20건에서 자식 행은 1,509개였습니다(원본: -[`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)). +[`evidence/raw/metrics/l6-projection-resolution.csv`](./evidence/raw/metrics/l6-projection-resolution.csv)). 화면에는 부모당 최신 3개, 최대 60개만 필요했습니다. 단순한 `IN` 쿼리의 `LIMIT`은 부모별로 적용되지 않으므로 다음 단계에서 Top-N-per-group을 SQL로 구현했습니다. @@ -1248,7 +1248,7 @@ SELECT h.feed_item_id, h.color, h.text, h.created_at FROM highlights h ### 13.2 실측 — 세 방법의 결과와 단순 LIMIT의 오작동 -`FeedTopNIT.l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14TransferAcrossStrategies`(seed 1,000, page 20). 원본: [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv). +`FeedTopNIT.l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14TransferAcrossStrategies`(seed 1,000, page 20). 원본: [`evidence/raw/metrics/l14-topn-resolution.csv`](./evidence/raw/metrics/l14-topn-resolution.csv). | 전략 | 반환 행 | 커버한 부모 | 부모당 최대 | |---|---:|---:|---:| @@ -1264,10 +1264,10 @@ SELECT h.feed_item_id, h.color, h.text, h.created_at FROM highlights h ### 13.3 결과는 같지만 I/O는 달랐다 세 SQL은 캐시 상태를 맞추기 위해 같은 테스트 실행에서 `EXPLAIN (ANALYZE, BUFFERS)`로 -측정했습니다. 원문: [`l14-window-plan.txt`](./evidence/terminal/explain/l14-window-plan.txt) · -[`l14-lateral-plan.txt`](./evidence/terminal/explain/l14-lateral-plan.txt) · -[`l14-twostep-plan.txt`](./evidence/terminal/explain/l14-twostep-plan.txt). 요약: -[`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv). +측정했습니다. 원문: [`l14-window-plan.txt`](./evidence/raw/explain/l14-window-plan.txt) · +[`l14-lateral-plan.txt`](./evidence/raw/explain/l14-lateral-plan.txt) · +[`l14-twostep-plan.txt`](./evidence/raw/explain/l14-twostep-plan.txt). 요약: +[`evidence/raw/metrics/l14-plan-compare.csv`](./evidence/raw/metrics/l14-plan-compare.csv). | 전략 | 최상위 노드 (스캔·조인) | 반환 행 | buffers shared hit | exec | |---|---|---:|---:|---:| @@ -1297,8 +1297,8 @@ WindowAgg Run Condition: (row_number() OVER (?) <= 3) Buffers: shared hit=43 LATERAL의 buffers가 작은 이유가 복합 인덱스인지 확인했습니다. 같은 쿼리를 두고 인덱스를 제거한 뒤 다시 만들면서 측정했습니다. 원본: -[`l14-lateral-no-index.txt`](./evidence/terminal/explain/l14-lateral-no-index.txt) · -[`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv). +[`l14-lateral-no-index.txt`](./evidence/raw/explain/l14-lateral-no-index.txt) · +[`evidence/raw/metrics/l14-index-toggle.csv`](./evidence/raw/metrics/l14-index-toggle.csv). | variant | 자식 접근 | buffers shared hit | exec | |---|---|---:|---:| @@ -1314,7 +1314,7 @@ buffers는 168에서 4,446으로 약 26배, 실행시간은 0.336 ms에서 5.472 ### 13.5 그룹 크기가 승자를 가른다 — K 곡선 세 방식의 차이가 그룹 크기에 따라 달라지는지도 확인했습니다. seed 1,000에서 top-K를 -3·50·500으로 바꿔 측정했습니다(원본: [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv)). +3·50·500으로 바꿔 측정했습니다(원본: [`evidence/raw/metrics/l14-group-size.csv`](./evidence/raw/metrics/l14-group-size.csv)). | K | 윈도우 반환 | 윈도우 buffers | LATERAL 반환 | LATERAL buffers | |---:|---:|---:|---:|---:| @@ -1377,7 +1377,7 @@ SELECT fi.id, fi.first_highlighted_at FROM feed_items fi ### 14.2 실측 — OFFSET은 깊이에 비례하고 keyset은 일정하다 seed 2,000에서 두 방식에 같은 정렬키 인덱스를 사용했습니다. "훑은 행"은 `Limit` 하위의 -actual rows로 계산했습니다(원본: [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv)). +actual rows로 계산했습니다(원본: [`evidence/raw/metrics/l15-depth-curve.csv`](./evidence/raw/metrics/l15-depth-curve.csv)). | 페이지 (offset) | OFFSET 훑은 행 | keyset 훑은 행 | |---:|---:|---:| @@ -1391,7 +1391,7 @@ keyset은 20행만 읽었습니다. 무한 스크롤의 뒤쪽 페이지가 느 ### 14.3 EXPLAIN — scan-then-discard vs index seek, 그리고 정렬키 인덱스가 전제 -`FeedKeysetIT.l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`(깊은 페이지 offset 1980, 한 실행). 원문: [`l15-offset-deep-page.txt`](./evidence/terminal/explain/l15-offset-deep-page.txt) · [`l15-keyset-index-seek.txt`](./evidence/terminal/explain/l15-keyset-index-seek.txt) · [`l15-keyset-no-index.txt`](./evidence/terminal/explain/l15-keyset-no-index.txt). 요약: [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv). +`FeedKeysetIT.l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`(깊은 페이지 offset 1980, 한 실행). 원문: [`l15-offset-deep-page.txt`](./evidence/raw/explain/l15-offset-deep-page.txt) · [`l15-keyset-index-seek.txt`](./evidence/raw/explain/l15-keyset-index-seek.txt) · [`l15-keyset-no-index.txt`](./evidence/raw/explain/l15-keyset-no-index.txt). 요약: [`evidence/raw/metrics/l15-deep-page-compare.csv`](./evidence/raw/metrics/l15-deep-page-compare.csv). | 변형 | 플랜 | 훑은 행 | buffers | exec | |---|---|---:|---:|---:| @@ -1426,7 +1426,7 @@ range scan합니다. `first_highlighted_at`이 같은 행도 안정적으로 넘 ### 14.5 keyset이 못 푸는 것 — 가시성 OR -keyset은 페이지 깊이를 풀었지만 실서비스 피드는 가시성으로 필터해야 한다(`public` + 내가 멘션된 것 + 내 비공개). 그 필터를 keyset과 같은 쿼리에 얹으면(`FeedKeysetIT.l15ProbeVisibilityOrBreaksKeysetIndex`) 플래너는 정렬키 인덱스 `ix_feed_items_keyset`를 **더 이상 쓰지 못하고** 가시성 3분기를 각각 인덱스로 스캔한 `BitmapOr`로 떨어집니다. 원문: [`l15-visibility-or-probe.txt`](./evidence/terminal/explain/l15-visibility-or-probe.txt). +keyset은 페이지 깊이를 풀었지만 실서비스 피드는 가시성으로 필터해야 한다(`public` + 내가 멘션된 것 + 내 비공개). 그 필터를 keyset과 같은 쿼리에 얹으면(`FeedKeysetIT.l15ProbeVisibilityOrBreaksKeysetIndex`) 플래너는 정렬키 인덱스 `ix_feed_items_keyset`를 **더 이상 쓰지 못하고** 가시성 3분기를 각각 인덱스로 스캔한 `BitmapOr`로 떨어집니다. 원문: [`l15-visibility-or-probe.txt`](./evidence/raw/explain/l15-visibility-or-probe.txt). ```text -- 가시성 OR 을 얹으면: 정렬키 Index Only Scan 이 사라지고 BitmapOr + 별도 Sort 로 @@ -1477,7 +1477,7 @@ SELECT fi.id, fi.first_highlighted_at FROM feed_items fi seed 2,000에서 user008이 볼 수 있는 피드를 조회했습니다. 세 방식이 같은 20개 feed_item을 반환하는지는 `l16ThreeApproachesReturnSameVisibleSet`으로 먼저 확인한 뒤 가시성 조건을 처리하는 실행계획을 비교했습니다(원본: -[`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv)). +[`evidence/raw/metrics/l16-plan-compare.csv`](./evidence/raw/metrics/l16-plan-compare.csv)). | 안 | 최상위/스캔 | Sort | 멘션 | 훑는 후보 | buffers | |---|---|---|---|---:|---:| @@ -1503,7 +1503,7 @@ Limit -> Merge Append Limit -> Index Only Scan using ix_feed_visible (Index Cond: viewer_id=:me) Heap Fetches: 20 ``` -원문: [`l16-single-or-plan.txt`](./evidence/terminal/explain/l16-single-or-plan.txt) · [`l16-union-decompose-plan.txt`](./evidence/terminal/explain/l16-union-decompose-plan.txt) · [`l16-precompute-plan.txt`](./evidence/terminal/explain/l16-precompute-plan.txt) · [`l16-union-branches.txt`](./evidence/terminal/explain/l16-union-branches.txt). +원문: [`l16-single-or-plan.txt`](./evidence/raw/explain/l16-single-or-plan.txt) · [`l16-union-decompose-plan.txt`](./evidence/raw/explain/l16-union-decompose-plan.txt) · [`l16-precompute-plan.txt`](./evidence/raw/explain/l16-precompute-plan.txt) · [`l16-union-branches.txt`](./evidence/raw/explain/l16-union-branches.txt). ### 15.4 UNION과 사전계산의 차이 @@ -1552,7 +1552,7 @@ SELECT p.pid, top3.color, top3.text, top3.created_at ### 16.2 실측 — 세 기법을 합친 실행계획 -`FeedCrownIT.crownUnifiedPlanStacksVisibilityKeysetAndTopN`(seed 2,000, 뷰어 user008, page 1). 사전계산 부모선택 위의 통합 쿼리는 세 기법을 재정렬 없이 한 플랜에 겹칩니다. 원본: [`crown-unified-precompute-plan.txt`](./evidence/terminal/explain/crown-unified-precompute-plan.txt). +`FeedCrownIT.crownUnifiedPlanStacksVisibilityKeysetAndTopN`(seed 2,000, 뷰어 user008, page 1). 사전계산 부모선택 위의 통합 쿼리는 세 기법을 재정렬 없이 한 플랜에 겹칩니다. 원본: [`crown-unified-precompute-plan.txt`](./evidence/raw/explain/crown-unified-precompute-plan.txt). ```text Nested Loop (rows=60) ← LATERAL (상관 조인) @@ -1568,7 +1568,7 @@ Nested Loop (rows=60) ← LATERAL ### 16.3 간섭 시험 — 사전계산 위에선 겹치고, 단일 OR 위에선 매 페이지 재해소 -`crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`(가장 깊은 페이지, 커서 = visible−20). user008에게 보이는 `1,500` 중 마지막 페이지에서 부모선택을 사전계산으로 두느냐 단일 OR로 두느냐가 갈립니다. 원본: [`crown-deep-keyset-precompute.txt`](./evidence/terminal/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/terminal/explain/crown-deep-keyset-single-or.txt). +`crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`(가장 깊은 페이지, 커서 = visible−20). user008에게 보이는 `1,500` 중 마지막 페이지에서 부모선택을 사전계산으로 두느냐 단일 OR로 두느냐가 갈립니다. 원본: [`crown-deep-keyset-precompute.txt`](./evidence/raw/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/raw/explain/crown-deep-keyset-single-or.txt). | 부모선택 | 최상위 | 훑는 행 | feed_visible | 부모 buffers | |---|---|---:|---|---:| @@ -1673,76 +1673,76 @@ cd src ``` - 곡선(N1): `l1CollectionNPlusOneGrowsLinearlyWithN` (N=10/100/1000), `collectionFetches == N` 확인. -- 실행계획(N1): `l1ExplainRepeatedHighlightChildQuery`, 반복되는 하이라이트 조회의 Index Scan 확인(→ [`evidence/terminal/explain/highlights-child-plan-A.txt`](./evidence/terminal/explain/highlights-child-plan-A.txt)). +- 실행계획(N1): `l1ExplainRepeatedHighlightChildQuery`, 반복되는 하이라이트 조회의 Index Scan 확인(→ [`evidence/raw/explain/highlights-child-plan-A.txt`](./evidence/raw/explain/highlights-child-plan-A.txt)). - 곡선(N2): `l2ToOneEagerHiddenNPlusOneCurve` (N=10/100/1000), `pageFetch == N`(선형)·`userFetch ≤ 20`(평탄)·`entityFetch == pageFetch + userFetch` 확인. - 접근 0 증명(N2): `l2EagerToOneFiresEvenWithZeroFieldAccess`, 접근 0인데 `pageFetch == 100`·`collectionFetch == 0`(EAGER는 나가고 LAZY는 안 나감). -- 실행계획(N2): `l2ExplainRepeatedPageToOneQuery`, pages·users의 pk Index Scan 확인(→ [`evidence/terminal/explain/toone-pages-plan.txt`](./evidence/terminal/explain/toone-pages-plan.txt) · [`toone-users-plan.txt`](./evidence/terminal/explain/toone-users-plan.txt)). +- 실행계획(N2): `l2ExplainRepeatedPageToOneQuery`, pages·users의 pk Index Scan 확인(→ [`evidence/raw/explain/toone-pages-plan.txt`](./evidence/raw/explain/toone-pages-plan.txt) · [`toone-users-plan.txt`](./evidence/raw/explain/toone-users-plan.txt)). - 다중 컬렉션 실패(9절): `l3TwoBagFetchJoinThrowsMultipleBagFetchException`, 두 bag 동시 fetch join이 `MultipleBagFetchException`(`IllegalArgumentException`으로 래핑)을 던지는 것 확인. -- 카테시안(9절): `l3SingleCollectionFetchJoinExplodesTransferredRows` (N=10/100/1000), 리스트 크기 = N(Hibernate 6+ dedup)인데 조인 카디널리티 = Σ highlights로 폭발하는 것 확인(→ [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv)). -- 실행계획(9절): `l3ExplainCollectionJoinRowMultiplication`, 조인(Hash Join) 노드 actual rows = Σ highlights 확인(→ [`evidence/terminal/explain/l3-cartesian-join-plan.txt`](./evidence/terminal/explain/l3-cartesian-join-plan.txt)). -- 인메모리 페이징(10절): `l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory` (N=10/100/1000), `returned == min(20, N)`인데 `feedItemLoaded == N`(전체 로드)임을 확인(→ [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv)). +- 카테시안(9절): `l3SingleCollectionFetchJoinExplodesTransferredRows` (N=10/100/1000), 리스트 크기 = N(Hibernate 6+ dedup)인데 조인 카디널리티 = Σ highlights로 폭발하는 것 확인(→ [`evidence/raw/metrics/l3-cartesian.csv`](./evidence/raw/metrics/l3-cartesian.csv)). +- 실행계획(9절): `l3ExplainCollectionJoinRowMultiplication`, 조인(Hash Join) 노드 actual rows = Σ highlights 확인(→ [`evidence/raw/explain/l3-cartesian-join-plan.txt`](./evidence/raw/explain/l3-cartesian-join-plan.txt)). +- 인메모리 페이징(10절): `l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory` (N=10/100/1000), `returned == min(20, N)`인데 `feedItemLoaded == N`(전체 로드)임을 확인(→ [`evidence/raw/metrics/l4-inmemory-paging.csv`](./evidence/raw/metrics/l4-inmemory-paging.csv)). - HHH000104 경고(10절): `l4EmitsHhh000104InMemoryPagingWarning`, `HHH90003004: ... collection fetch; applying in memory` WARN을 ListAppender로 캡처(코드 번호가 아니라 문구로 매칭). -- EXPLAIN 대조(10절): `l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes`, (a) 조인 SQL엔 Limit 노드 없음 / (b) 엔티티 페이징엔 있음 확인(→ [`evidence/terminal/explain/l4-collection-join-no-limit.txt`](./evidence/terminal/explain/l4-collection-join-no-limit.txt) · [`l4-entity-paging-limit.txt`](./evidence/terminal/explain/l4-entity-paging-limit.txt)). -- 배치 해결(11절): `FeedBatchFetchIT`(신규, 격리 클래스 `default_batch_fetch_size=100`) `l5BatchFetchCollapsesQueryCount` (N=10/100/1000), `prepared < N`(순진 `1+N`에서 붕괴)·`collectionFetch == ceil(N/batch)` 확인(→ [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv)). -- 페이징 정상(11절): `l5EntityPagingLoadsOnlyThePageNotWholeDataset`, `feedItemLoaded == min(20, N)`(10절 over-fetch 소멸). EXPLAIN `l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication`, (a) 엔티티 페이징엔 Limit 노드 존재 / (b) 배치 IN은 semi-join(행 안 곱함)(→ [`evidence/terminal/explain/l5-entity-paging-limit.txt`](./evidence/terminal/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/terminal/explain/l5-batch-in-semijoin.txt)). -- 잔여 비용(11절): `l5ProbeBatchStillHydratesFullEntities`, 페이지 20건인데 `entitiesLoaded == 1,569`(엔티티 과적재 → 프로젝션 단계)(→ [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv)). -- 프로젝션 해결(12절): `FeedProjectionIT`(신규, 격리 클래스, 배치 설정 없음) `l6ProjectionHydratesZeroEntities` (N=10/100/1000), `entitiesLoaded == 0`(11절의 1,569 소멸)·`prepared == 2`(N 무관 상수)·`collectionFetch == 0` 확인. 형태 동치 `l6ProjectionReturnsSameShapeAsNaiveLoadFeed`(프로젝션 vs 순진 loadFeed 같은 결과)(→ [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)). -- EXPLAIN·width 정정(12절): `l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth`, (a) 부모 프로젝션 Limit 노드 존재하나 width 안 좁아짐(2088 > 엔티티 1194) / (b) 자식 IN semi-join(행 안 곱함). 프로젝션 이득은 EXPLAIN 아니라 ORM 층(→ [`evidence/terminal/explain/l6-parent-projection.txt`](./evidence/terminal/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/terminal/explain/l6-child-projection.txt) · [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv)). -- 잔여 비용(12절): `l6ProbeProjectionStillFetchesAllHighlightsNotTopN`, 페이지 20건인데 자식 행 `1,509`(부모당 전량, top-3 아님 → Top-N 단계)(→ [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)). -- 정확성·전송(13절): **별도 클래스 `FeedTopNIT`**(IT-only, native SQL) `l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14TransferAcrossStrategies`, 윈도우·LATERAL은 부모당 3개(반환 60·부모 20), 2단계는 앱컷 전 전량 `1,509`, 순진 `LIMIT 3`은 전체 3행(부모 1개만 = 오작동) 확인(→ [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv)). -- 플랜 대조(13절): `l14ExplainThreeWayPlanCompareIsTheCrownJewel`, 세 방법 `EXPLAIN (ANALYZE, BUFFERS)` — LATERAL은 `Index Scan`(buffers 204)·윈도우/2단계는 같은 `Hash Semi Join`(buffers 430, 전량 1,509) 확인(→ [`l14-lateral-plan.txt`](./evidence/terminal/explain/l14-lateral-plan.txt) · [`l14-window-plan.txt`](./evidence/terminal/explain/l14-window-plan.txt) · [`l14-twostep-plan.txt`](./evidence/terminal/explain/l14-twostep-plan.txt) · [`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv)). -- 인덱스 토글(13절): `l14LateralDependsOnCompositeIndex`, 같은 LATERAL을 `ix_highlights_feed_items_created` DROP 후 측정→`finally` 복구 — 인덱스 없으면 `Seq Scan`(Rows Removed by Filter 2842/loop)으로 buffers 168→4446(약 26배) 확인(→ [`l14-lateral-no-index.txt`](./evidence/terminal/explain/l14-lateral-no-index.txt) · [`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv)). -- 그룹 크기 곡선(13절): `l14GroupSizeCurveWindowVsLateral`(K=3/50/500), 반환 60/695/1,509이고 LATERAL buffers가 모든 K에서 윈도우보다 작음(작은 K일수록 격차↑) 확인(→ [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv)). +- EXPLAIN 대조(10절): `l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes`, (a) 조인 SQL엔 Limit 노드 없음 / (b) 엔티티 페이징엔 있음 확인(→ [`evidence/raw/explain/l4-collection-join-no-limit.txt`](./evidence/raw/explain/l4-collection-join-no-limit.txt) · [`l4-entity-paging-limit.txt`](./evidence/raw/explain/l4-entity-paging-limit.txt)). +- 배치 해결(11절): `FeedBatchFetchIT`(신규, 격리 클래스 `default_batch_fetch_size=100`) `l5BatchFetchCollapsesQueryCount` (N=10/100/1000), `prepared < N`(순진 `1+N`에서 붕괴)·`collectionFetch == ceil(N/batch)` 확인(→ [`evidence/raw/metrics/l5-batch-resolution.csv`](./evidence/raw/metrics/l5-batch-resolution.csv)). +- 페이징 정상(11절): `l5EntityPagingLoadsOnlyThePageNotWholeDataset`, `feedItemLoaded == min(20, N)`(10절 over-fetch 소멸). EXPLAIN `l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication`, (a) 엔티티 페이징엔 Limit 노드 존재 / (b) 배치 IN은 semi-join(행 안 곱함)(→ [`evidence/raw/explain/l5-entity-paging-limit.txt`](./evidence/raw/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/raw/explain/l5-batch-in-semijoin.txt)). +- 잔여 비용(11절): `l5ProbeBatchStillHydratesFullEntities`, 페이지 20건인데 `entitiesLoaded == 1,569`(엔티티 과적재 → 프로젝션 단계)(→ [`evidence/raw/metrics/l5-hydration-probe.csv`](./evidence/raw/metrics/l5-hydration-probe.csv)). +- 프로젝션 해결(12절): `FeedProjectionIT`(신규, 격리 클래스, 배치 설정 없음) `l6ProjectionHydratesZeroEntities` (N=10/100/1000), `entitiesLoaded == 0`(11절의 1,569 소멸)·`prepared == 2`(N 무관 상수)·`collectionFetch == 0` 확인. 형태 동치 `l6ProjectionReturnsSameShapeAsNaiveLoadFeed`(프로젝션 vs 순진 loadFeed 같은 결과)(→ [`evidence/raw/metrics/l6-projection-resolution.csv`](./evidence/raw/metrics/l6-projection-resolution.csv)). +- EXPLAIN·width 정정(12절): `l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth`, (a) 부모 프로젝션 Limit 노드 존재하나 width 안 좁아짐(2088 > 엔티티 1194) / (b) 자식 IN semi-join(행 안 곱함). 프로젝션 이득은 EXPLAIN 아니라 ORM 층(→ [`evidence/raw/explain/l6-parent-projection.txt`](./evidence/raw/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/raw/explain/l6-child-projection.txt) · [`evidence/raw/metrics/l6-explain-width.csv`](./evidence/raw/metrics/l6-explain-width.csv)). +- 잔여 비용(12절): `l6ProbeProjectionStillFetchesAllHighlightsNotTopN`, 페이지 20건인데 자식 행 `1,509`(부모당 전량, top-3 아님 → Top-N 단계)(→ [`evidence/raw/metrics/l6-projection-resolution.csv`](./evidence/raw/metrics/l6-projection-resolution.csv)). +- 정확성·전송(13절): **별도 클래스 `FeedTopNIT`**(IT-only, native SQL) `l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14TransferAcrossStrategies`, 윈도우·LATERAL은 부모당 3개(반환 60·부모 20), 2단계는 앱컷 전 전량 `1,509`, 순진 `LIMIT 3`은 전체 3행(부모 1개만 = 오작동) 확인(→ [`evidence/raw/metrics/l14-topn-resolution.csv`](./evidence/raw/metrics/l14-topn-resolution.csv)). +- 플랜 대조(13절): `l14ExplainThreeWayPlanCompareIsTheCrownJewel`, 세 방법 `EXPLAIN (ANALYZE, BUFFERS)` — LATERAL은 `Index Scan`(buffers 204)·윈도우/2단계는 같은 `Hash Semi Join`(buffers 430, 전량 1,509) 확인(→ [`l14-lateral-plan.txt`](./evidence/raw/explain/l14-lateral-plan.txt) · [`l14-window-plan.txt`](./evidence/raw/explain/l14-window-plan.txt) · [`l14-twostep-plan.txt`](./evidence/raw/explain/l14-twostep-plan.txt) · [`evidence/raw/metrics/l14-plan-compare.csv`](./evidence/raw/metrics/l14-plan-compare.csv)). +- 인덱스 토글(13절): `l14LateralDependsOnCompositeIndex`, 같은 LATERAL을 `ix_highlights_feed_items_created` DROP 후 측정→`finally` 복구 — 인덱스 없으면 `Seq Scan`(Rows Removed by Filter 2842/loop)으로 buffers 168→4446(약 26배) 확인(→ [`l14-lateral-no-index.txt`](./evidence/raw/explain/l14-lateral-no-index.txt) · [`evidence/raw/metrics/l14-index-toggle.csv`](./evidence/raw/metrics/l14-index-toggle.csv)). +- 그룹 크기 곡선(13절): `l14GroupSizeCurveWindowVsLateral`(K=3/50/500), 반환 60/695/1,509이고 LATERAL buffers가 모든 K에서 윈도우보다 작음(작은 K일수록 격차↑) 확인(→ [`evidence/raw/metrics/l14-group-size.csv`](./evidence/raw/metrics/l14-group-size.csv)). - 잔여 비용(13절): `l14ProbeParentPagingStillUsesOffsetNotKeyset`, 부모 페이징이 아직 `OFFSET 900`이라 앞 900행 scan-then-discard(→ keyset 페이징 단계). -- 깊이 곡선(14절): **별도 클래스 `FeedKeysetIT`**(IT-only, native SQL) `l15DeepPageOffsetOverScansButKeysetStaysFlat`(offset 0/980/1980), OFFSET 훑은 행 = offset+20(20/`1,000`/`2,000`)인데 keyset은 20으로 일정함(page 100에서 100× over-scan) 확인(→ [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv)). -- EXPLAIN·인덱스 유무(14절): `l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`, OFFSET `Seq Scan`+`Sort`(2,000, buffers 141) vs keyset `Index Only Scan`(20, buffers 1); 인덱스 없으면 keyset도 `Seq Scan`(buffers 141) 확인(→ [`l15-offset-deep-page.txt`](./evidence/terminal/explain/l15-offset-deep-page.txt) · [`l15-keyset-index-seek.txt`](./evidence/terminal/explain/l15-keyset-index-seek.txt) · [`l15-keyset-no-index.txt`](./evidence/terminal/explain/l15-keyset-no-index.txt) · [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv)). +- 깊이 곡선(14절): **별도 클래스 `FeedKeysetIT`**(IT-only, native SQL) `l15DeepPageOffsetOverScansButKeysetStaysFlat`(offset 0/980/1980), OFFSET 훑은 행 = offset+20(20/`1,000`/`2,000`)인데 keyset은 20으로 일정함(page 100에서 100× over-scan) 확인(→ [`evidence/raw/metrics/l15-depth-curve.csv`](./evidence/raw/metrics/l15-depth-curve.csv)). +- EXPLAIN·인덱스 유무(14절): `l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`, OFFSET `Seq Scan`+`Sort`(2,000, buffers 141) vs keyset `Index Only Scan`(20, buffers 1); 인덱스 없으면 keyset도 `Seq Scan`(buffers 141) 확인(→ [`l15-offset-deep-page.txt`](./evidence/raw/explain/l15-offset-deep-page.txt) · [`l15-keyset-index-seek.txt`](./evidence/raw/explain/l15-keyset-index-seek.txt) · [`l15-keyset-no-index.txt`](./evidence/raw/explain/l15-keyset-no-index.txt) · [`evidence/raw/metrics/l15-deep-page-compare.csv`](./evidence/raw/metrics/l15-deep-page-compare.csv)). - 정확성(14절): `l15KeysetWalkMatchesOffsetPages`, keyset 커서로 넘긴 page 2 == OFFSET page 2(같은 20 id·같은 순서). -- 가시성 probe(14절 → 가시성 인덱싱 단계): `l15ProbeVisibilityOrBreaksKeysetIndex`, keyset에 가시성 `OR`+`EXISTS`를 얹으면 정렬키 인덱스 미사용·`BitmapOr`+`Sort` 재등장(순서 seek 이점 소멸) 확인(→ [`l15-visibility-or-probe.txt`](./evidence/terminal/explain/l15-visibility-or-probe.txt)). -- 정확성(15절): **별도 클래스 `FeedVisibilityIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `l16ThreeApproachesReturnSameVisibleSet`, 단일 OR == UNION 분해 == 사전계산이 같은 20 feed_item(답 동일, 플랜만 다름) 확인(→ [`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv)). -- 3안 플랜 대조(15절): `l16ExplainThreeWayPlanCompare`, 단일 OR(`BitmapOr`+top-N `Sort`+hashed SubPlan, 후보 `1,500`, buffers 122) vs UNION(`Merge Append`+`Hash Join`, buffers 200) vs 사전계산(`Index Only Scan` on feed_visible, Sort 없음, buffers 1) 확인(→ [`l16-single-or-plan.txt`](./evidence/terminal/explain/l16-single-or-plan.txt) · [`l16-union-decompose-plan.txt`](./evidence/terminal/explain/l16-union-decompose-plan.txt) · [`l16-precompute-plan.txt`](./evidence/terminal/explain/l16-precompute-plan.txt)). -- 분기별 인덱스(15절): `l16LowSelectivityBranchesRideTheirIndex`, mentioned 분기=`ix_mentions_user` 조인·private 분기=`ix_feed_items_private` partial의 `Index Only Scan` 확인(→ [`l16-union-branches.txt`](./evidence/terminal/explain/l16-union-branches.txt)). -- 사전계산=CQRS(15절 → CQRS-lite 읽기 모델 단계): `l16PrecomputeIsSingleIndexScanNoOrNoSort`, `feed_visible` 단일 `Index Only Scan`·Sort 없음·buffers 1 확인(→ [`l16-precompute-plan.txt`](./evidence/terminal/explain/l16-precompute-plan.txt)). -- 통합 정확성·shape(16절): **별도 클래스 `FeedCrownIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `crownUnifiedReturnsSameShapeAcrossParentPaths`, 세 부모선택(단일 OR/UNION 분해/사전계산)이 같은 20 부모(unionEq·precomputeEq 참)·통합 결과 부모 20·총 60행·부모당 top-3 확인(→ [`evidence/metrics/crown-unified-plan.csv`](./evidence/metrics/crown-unified-plan.csv)). -- 한 플랜 세 기법(16절): `crownUnifiedPlanStacksVisibilityKeysetAndTopN`, 사전계산 부모선택 통합 쿼리가 `Index Only Scan`(ix_feed_visible) + `Nested Loop` LATERAL `Index Scan`(ix_highlights_feed_items_created)로 세 기법을 재정렬(Sort) 없이 한 플랜에 겹침 확인(→ [`crown-unified-precompute-plan.txt`](./evidence/terminal/explain/crown-unified-precompute-plan.txt)). -- 간섭 시험(16절): `crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`, 가장 깊은 페이지(보이는 `1,500` 중 마지막)에서 사전계산 부모선택은 `ix_feed_visible` 인덱스 range 로 19 행만, 단일 OR 부모선택은 feed_visible 미사용·`BitmapOr`+멘션 hashed SubPlan 으로 200 행 훑음(★ 실측정정: 깊은 커서에선 둘 다 남은 19 행 작은 Sort) 확인(→ [`crown-deep-keyset-precompute.txt`](./evidence/terminal/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/terminal/explain/crown-deep-keyset-single-or.txt)). +- 가시성 probe(14절 → 가시성 인덱싱 단계): `l15ProbeVisibilityOrBreaksKeysetIndex`, keyset에 가시성 `OR`+`EXISTS`를 얹으면 정렬키 인덱스 미사용·`BitmapOr`+`Sort` 재등장(순서 seek 이점 소멸) 확인(→ [`l15-visibility-or-probe.txt`](./evidence/raw/explain/l15-visibility-or-probe.txt)). +- 정확성(15절): **별도 클래스 `FeedVisibilityIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `l16ThreeApproachesReturnSameVisibleSet`, 단일 OR == UNION 분해 == 사전계산이 같은 20 feed_item(답 동일, 플랜만 다름) 확인(→ [`evidence/raw/metrics/l16-plan-compare.csv`](./evidence/raw/metrics/l16-plan-compare.csv)). +- 3안 플랜 대조(15절): `l16ExplainThreeWayPlanCompare`, 단일 OR(`BitmapOr`+top-N `Sort`+hashed SubPlan, 후보 `1,500`, buffers 122) vs UNION(`Merge Append`+`Hash Join`, buffers 200) vs 사전계산(`Index Only Scan` on feed_visible, Sort 없음, buffers 1) 확인(→ [`l16-single-or-plan.txt`](./evidence/raw/explain/l16-single-or-plan.txt) · [`l16-union-decompose-plan.txt`](./evidence/raw/explain/l16-union-decompose-plan.txt) · [`l16-precompute-plan.txt`](./evidence/raw/explain/l16-precompute-plan.txt)). +- 분기별 인덱스(15절): `l16LowSelectivityBranchesRideTheirIndex`, mentioned 분기=`ix_mentions_user` 조인·private 분기=`ix_feed_items_private` partial의 `Index Only Scan` 확인(→ [`l16-union-branches.txt`](./evidence/raw/explain/l16-union-branches.txt)). +- 사전계산=CQRS(15절 → CQRS-lite 읽기 모델 단계): `l16PrecomputeIsSingleIndexScanNoOrNoSort`, `feed_visible` 단일 `Index Only Scan`·Sort 없음·buffers 1 확인(→ [`l16-precompute-plan.txt`](./evidence/raw/explain/l16-precompute-plan.txt)). +- 통합 정확성·shape(16절): **별도 클래스 `FeedCrownIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `crownUnifiedReturnsSameShapeAcrossParentPaths`, 세 부모선택(단일 OR/UNION 분해/사전계산)이 같은 20 부모(unionEq·precomputeEq 참)·통합 결과 부모 20·총 60행·부모당 top-3 확인(→ [`evidence/raw/metrics/crown-unified-plan.csv`](./evidence/raw/metrics/crown-unified-plan.csv)). +- 한 플랜 세 기법(16절): `crownUnifiedPlanStacksVisibilityKeysetAndTopN`, 사전계산 부모선택 통합 쿼리가 `Index Only Scan`(ix_feed_visible) + `Nested Loop` LATERAL `Index Scan`(ix_highlights_feed_items_created)로 세 기법을 재정렬(Sort) 없이 한 플랜에 겹침 확인(→ [`crown-unified-precompute-plan.txt`](./evidence/raw/explain/crown-unified-precompute-plan.txt)). +- 간섭 시험(16절): `crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`, 가장 깊은 페이지(보이는 `1,500` 중 마지막)에서 사전계산 부모선택은 `ix_feed_visible` 인덱스 range 로 19 행만, 단일 OR 부모선택은 feed_visible 미사용·`BitmapOr`+멘션 hashed SubPlan 으로 200 행 훑음(★ 실측정정: 깊은 커서에선 둘 다 남은 19 행 작은 Sort) 확인(→ [`crown-deep-keyset-precompute.txt`](./evidence/raw/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/raw/explain/crown-deep-keyset-single-or.txt)). - CQRS-lite 읽기 모델(17절): **프로덕션 경로**(시리즈 첫 프로덕션 코드, IT-only 아님) `GetFeedReadModelUseCase` → `FeedReadModelQueryPort` → `FeedReadModelQueryAdapter`(신규). `FeedReadModelUseCaseIT`(seed N∈{10, 100})가 유스케이스 경로에서 엔티티 로드 0·발행 쿼리 상수 2(N 무관)·부모당 top-3(12절 프로젝션 + 13절 window 결합, 12절 잔여 `1,509` → ≤60 해소) 반환 확인. ArchUnit `query_ports_do_not_leak…`·의존 방향·`./gradlew check` GREEN. > 개별 테스트만 돌릴 때는 Gradle 와일드카드가 `*`임에 주의(`...`은 매칭 0). 예) `--tests '*FeedPersistenceIT.l2*'`. 초록불을 다시 돌리려면 `--rerun-tasks`(안 그러면 UP-TO-DATE로 건너뜀). 콘솔 측정 라인(`>>> LAB …`)은 `build/lab-results/feed-nplus1.md`에도 표로 적재됩니다. 원시 데이터 자산: -- [`evidence/metrics/l1-query-growth.csv`](./evidence/metrics/l1-query-growth.csv) — N, 초기화 컬렉션, 총 PreparedStatement, ToOne 몫. -- [`evidence/metrics/l1-skew-distribution.csv`](./evidence/metrics/l1-skew-distribution.csv) — 순위별 하이라이트 수. -- [`evidence/metrics/l2-toone-split.csv`](./evidence/metrics/l2-toone-split.csv) — N, Page·User·entity fetch, 초기화 컬렉션, 총 PreparedStatement(N2 직접 측정). -- [`evidence/terminal/explain/highlights-child-plan-A.txt`](./evidence/terminal/explain/highlights-child-plan-A.txt) — N1 Plan A EXPLAIN 원문. -- [`evidence/terminal/explain/toone-pages-plan.txt`](./evidence/terminal/explain/toone-pages-plan.txt) · [`evidence/terminal/explain/toone-users-plan.txt`](./evidence/terminal/explain/toone-users-plan.txt) — N2 반복 ToOne 부모 쿼리 EXPLAIN 원문. -- [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv) — N, 전송 행수(조인 카디널리티), 리스트 크기(Hib6 dedup), distinct, 시드 하이라이트, 폭발 배수, 총 PreparedStatement(9절 카테시안). -- [`evidence/terminal/explain/l3-cartesian-join-plan.txt`](./evidence/terminal/explain/l3-cartesian-join-plan.txt) — 9절 컬렉션 fetch join 조인의 EXPLAIN 원문(Hash Join actual rows = Σ highlights). -- [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv) — N, returned(페이지), feedItemLoaded(=N), over-fetch 배수, 시드 하이라이트(10절 인메모리 페이징, 결정적·hash-anchor). -- [`evidence/metrics/l4-cost-curve.csv`](./evidence/metrics/l4-cost-curve.csv) — N, 지연 p50/p99(ms), 스레드 누적 할당(KB). 측정 범위상 환경 의존 상대값이라 anchor가 아니라 whitelist(N에 따른 방향만 읽음). -- [`evidence/terminal/explain/l4-collection-join-no-limit.txt`](./evidence/terminal/explain/l4-collection-join-no-limit.txt) · [`evidence/terminal/explain/l4-entity-paging-limit.txt`](./evidence/terminal/explain/l4-entity-paging-limit.txt) — 10절 (a) 조인 SQL(Limit 노드 부재) / (b) 엔티티 페이징(Limit 노드 존재) EXPLAIN 원문. -- [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv) — N, before/after PreparedStatement·컬렉션 fetch, feedItemLoaded(페이지), 붕괴 배수(11절 배치 해결, 결정적·hash-anchor). -- [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv) — 페이지 20건 조회의 엔티티 하이드레이트 총수(11절 잔여 과적재 → 프로젝션 단계). -- [`evidence/terminal/explain/l5-entity-paging-limit.txt`](./evidence/terminal/explain/l5-entity-paging-limit.txt) · [`evidence/terminal/explain/l5-batch-in-semijoin.txt`](./evidence/terminal/explain/l5-batch-in-semijoin.txt) — 11절 (a) 엔티티 페이징(Limit 노드 존재) / (b) 배치 IN(semi-join, 곱셈 없음) EXPLAIN 원문. -- [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv) — before(11절 배치)/after(12절 프로젝션) 엔티티 로드·PreparedStatement·컬렉션 fetch·자식 행수(12절 프로젝션 해결, 결정적·hash-anchor). -- [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv) — 부모 프로젝션 width vs 엔티티 페이징 width(12.4절 실측 정정: 프로젝션이 오히려 넓습니다). -- [`evidence/terminal/explain/l6-parent-projection.txt`](./evidence/terminal/explain/l6-parent-projection.txt) · [`evidence/terminal/explain/l6-child-projection.txt`](./evidence/terminal/explain/l6-child-projection.txt) — 12절 (a) 부모 스칼라 프로젝션(Limit 존재, width 2088) / (b) 자식 스칼라 IN(semi-join, 행 안 곱함) EXPLAIN 원문. -- [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv) — 전략별(윈도우/LATERAL/2단계/순진) 반환 행·커버 부모·부모당 최대(13절 정확성·전송, 결정적·hash-anchor). -- [`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv) — 3안 최상위 노드·반환 행·buffers(shared hit)·exec(13절 플랜 대조). buffers·exec는 워밍 캐시 상대값이라 anchor가 아니라 whitelist(같은 실행 내 상대 대조로만). -- [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv) — K∈{3, 50, 500}별 윈도우/LATERAL 반환 행·buffers(13절 그룹 크기 곡선; 반환은 결정적, buffers는 whitelist). -- [`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv) — LATERAL 인덱스 유무 buffers·exec(13절 인덱스 의존; 환경 의존 상대값 whitelist). -- [`evidence/terminal/explain/l14-lateral-plan.txt`](./evidence/terminal/explain/l14-lateral-plan.txt) · [`evidence/terminal/explain/l14-window-plan.txt`](./evidence/terminal/explain/l14-window-plan.txt) · [`evidence/terminal/explain/l14-twostep-plan.txt`](./evidence/terminal/explain/l14-twostep-plan.txt) — 13절 세 해법 EXPLAIN 원문(LATERAL Index Scan / 윈도우 WindowAgg / 2단계 Hash Semi Join). -- [`evidence/terminal/explain/l14-lateral-no-index.txt`](./evidence/terminal/explain/l14-lateral-no-index.txt) — 13절 인덱스 DROP 후 같은 LATERAL EXPLAIN 원문(부모별 Seq Scan, buffers 폭증). -- [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv) — 페이지 깊이(offset)별 OFFSET/keyset 훑은 행·buffers(14절 깊이 곡선; OFFSET=offset+20 결정적·hash-anchor, buffers는 whitelist). -- [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv) — 깊은 페이지(offset 1980) OFFSET/keyset(+인덱스)/keyset(−인덱스) 최상위 노드·훑은 행·buffers·exec(14절; buffers·exec는 환경 의존 whitelist). -- [`evidence/terminal/explain/l15-offset-deep-page.txt`](./evidence/terminal/explain/l15-offset-deep-page.txt) · [`evidence/terminal/explain/l15-keyset-index-seek.txt`](./evidence/terminal/explain/l15-keyset-index-seek.txt) · [`evidence/terminal/explain/l15-keyset-no-index.txt`](./evidence/terminal/explain/l15-keyset-no-index.txt) — 14절 OFFSET(Seq Scan+Sort) / keyset(Index Only Scan) / keyset 인덱스 없음(Seq Scan) EXPLAIN 원문. -- [`evidence/terminal/explain/l15-visibility-or-probe.txt`](./evidence/terminal/explain/l15-visibility-or-probe.txt) — 14절 keyset + 가시성 OR/EXISTS EXPLAIN 원문(BitmapOr + Sort, 정렬키 인덱스 미사용 → 가시성 조건 인덱싱 단계). -- [`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv) — 가시성 3안(단일 OR/UNION 분해/사전계산) 최상위 노드·Sort·멘션 처리·훑는 후보·buffers·exec(15절; 훑는 후보 1500은 결정적·hash-anchor, buffers·exec는 환경 의존 whitelist). -- [`evidence/terminal/explain/l16-single-or-plan.txt`](./evidence/terminal/explain/l16-single-or-plan.txt) · [`evidence/terminal/explain/l16-union-decompose-plan.txt`](./evidence/terminal/explain/l16-union-decompose-plan.txt) · [`evidence/terminal/explain/l16-precompute-plan.txt`](./evidence/terminal/explain/l16-precompute-plan.txt) — 15절 단일 OR(BitmapOr+Sort+hashed SubPlan) / UNION 분해(Merge Append+Hash Join) / 사전계산(단일 Index Only Scan) EXPLAIN 원문. -- [`evidence/terminal/explain/l16-union-branches.txt`](./evidence/terminal/explain/l16-union-branches.txt) — 15절 UNION 각 분기(mentioned=ix_mentions_user 조인 / private=partial 인덱스 / public=고선택도 bitmap) EXPLAIN 원문. -- [`evidence/metrics/crown-unified-plan.csv`](./evidence/metrics/crown-unified-plan.csv) — 통합(16절/Task 4) 부모선택별(사전계산/단일 OR) page 1·깊은 페이지 부모 수·행수·훑는 행·buffers·뷰어 가시 집합(부모/행/훑는 행은 결정적, buffers 는 환경 의존 whitelist). -- [`evidence/terminal/explain/crown-unified-precompute-plan.txt`](./evidence/terminal/explain/crown-unified-precompute-plan.txt) — 16절 사전계산 부모선택 통합 쿼리 EXPLAIN 원문(Index Only Scan feed_visible + Nested Loop LATERAL, Sort 없음 — 한 플랜 세 기법). -- [`evidence/terminal/explain/crown-deep-keyset-precompute.txt`](./evidence/terminal/explain/crown-deep-keyset-precompute.txt) · [`evidence/terminal/explain/crown-deep-keyset-single-or.txt`](./evidence/terminal/explain/crown-deep-keyset-single-or.txt) — 16절 깊은 페이지 keyset 간섭 시험 EXPLAIN 원문(사전계산 인덱스 range 19행 vs 단일 OR BitmapOr+멘션 SubPlan 200행). +- [`evidence/raw/metrics/l1-query-growth.csv`](./evidence/raw/metrics/l1-query-growth.csv) — N, 초기화 컬렉션, 총 PreparedStatement, ToOne 몫. +- [`evidence/raw/metrics/l1-skew-distribution.csv`](./evidence/raw/metrics/l1-skew-distribution.csv) — 순위별 하이라이트 수. +- [`evidence/raw/metrics/l2-toone-split.csv`](./evidence/raw/metrics/l2-toone-split.csv) — N, Page·User·entity fetch, 초기화 컬렉션, 총 PreparedStatement(N2 직접 측정). +- [`evidence/raw/explain/highlights-child-plan-A.txt`](./evidence/raw/explain/highlights-child-plan-A.txt) — N1 Plan A EXPLAIN 원문. +- [`evidence/raw/explain/toone-pages-plan.txt`](./evidence/raw/explain/toone-pages-plan.txt) · [`evidence/raw/explain/toone-users-plan.txt`](./evidence/raw/explain/toone-users-plan.txt) — N2 반복 ToOne 부모 쿼리 EXPLAIN 원문. +- [`evidence/raw/metrics/l3-cartesian.csv`](./evidence/raw/metrics/l3-cartesian.csv) — N, 전송 행수(조인 카디널리티), 리스트 크기(Hib6 dedup), distinct, 시드 하이라이트, 폭발 배수, 총 PreparedStatement(9절 카테시안). +- [`evidence/raw/explain/l3-cartesian-join-plan.txt`](./evidence/raw/explain/l3-cartesian-join-plan.txt) — 9절 컬렉션 fetch join 조인의 EXPLAIN 원문(Hash Join actual rows = Σ highlights). +- [`evidence/raw/metrics/l4-inmemory-paging.csv`](./evidence/raw/metrics/l4-inmemory-paging.csv) — N, returned(페이지), feedItemLoaded(=N), over-fetch 배수, 시드 하이라이트(10절 인메모리 페이징, 결정적·hash-anchor). +- [`evidence/raw/metrics/l4-cost-curve.csv`](./evidence/raw/metrics/l4-cost-curve.csv) — N, 지연 p50/p99(ms), 스레드 누적 할당(KB). 측정 범위상 환경 의존 상대값이라 anchor가 아니라 whitelist(N에 따른 방향만 읽음). +- [`evidence/raw/explain/l4-collection-join-no-limit.txt`](./evidence/raw/explain/l4-collection-join-no-limit.txt) · [`evidence/raw/explain/l4-entity-paging-limit.txt`](./evidence/raw/explain/l4-entity-paging-limit.txt) — 10절 (a) 조인 SQL(Limit 노드 부재) / (b) 엔티티 페이징(Limit 노드 존재) EXPLAIN 원문. +- [`evidence/raw/metrics/l5-batch-resolution.csv`](./evidence/raw/metrics/l5-batch-resolution.csv) — N, before/after PreparedStatement·컬렉션 fetch, feedItemLoaded(페이지), 붕괴 배수(11절 배치 해결, 결정적·hash-anchor). +- [`evidence/raw/metrics/l5-hydration-probe.csv`](./evidence/raw/metrics/l5-hydration-probe.csv) — 페이지 20건 조회의 엔티티 하이드레이트 총수(11절 잔여 과적재 → 프로젝션 단계). +- [`evidence/raw/explain/l5-entity-paging-limit.txt`](./evidence/raw/explain/l5-entity-paging-limit.txt) · [`evidence/raw/explain/l5-batch-in-semijoin.txt`](./evidence/raw/explain/l5-batch-in-semijoin.txt) — 11절 (a) 엔티티 페이징(Limit 노드 존재) / (b) 배치 IN(semi-join, 곱셈 없음) EXPLAIN 원문. +- [`evidence/raw/metrics/l6-projection-resolution.csv`](./evidence/raw/metrics/l6-projection-resolution.csv) — before(11절 배치)/after(12절 프로젝션) 엔티티 로드·PreparedStatement·컬렉션 fetch·자식 행수(12절 프로젝션 해결, 결정적·hash-anchor). +- [`evidence/raw/metrics/l6-explain-width.csv`](./evidence/raw/metrics/l6-explain-width.csv) — 부모 프로젝션 width vs 엔티티 페이징 width(12.4절 실측 정정: 프로젝션이 오히려 넓습니다). +- [`evidence/raw/explain/l6-parent-projection.txt`](./evidence/raw/explain/l6-parent-projection.txt) · [`evidence/raw/explain/l6-child-projection.txt`](./evidence/raw/explain/l6-child-projection.txt) — 12절 (a) 부모 스칼라 프로젝션(Limit 존재, width 2088) / (b) 자식 스칼라 IN(semi-join, 행 안 곱함) EXPLAIN 원문. +- [`evidence/raw/metrics/l14-topn-resolution.csv`](./evidence/raw/metrics/l14-topn-resolution.csv) — 전략별(윈도우/LATERAL/2단계/순진) 반환 행·커버 부모·부모당 최대(13절 정확성·전송, 결정적·hash-anchor). +- [`evidence/raw/metrics/l14-plan-compare.csv`](./evidence/raw/metrics/l14-plan-compare.csv) — 3안 최상위 노드·반환 행·buffers(shared hit)·exec(13절 플랜 대조). buffers·exec는 워밍 캐시 상대값이라 anchor가 아니라 whitelist(같은 실행 내 상대 대조로만). +- [`evidence/raw/metrics/l14-group-size.csv`](./evidence/raw/metrics/l14-group-size.csv) — K∈{3, 50, 500}별 윈도우/LATERAL 반환 행·buffers(13절 그룹 크기 곡선; 반환은 결정적, buffers는 whitelist). +- [`evidence/raw/metrics/l14-index-toggle.csv`](./evidence/raw/metrics/l14-index-toggle.csv) — LATERAL 인덱스 유무 buffers·exec(13절 인덱스 의존; 환경 의존 상대값 whitelist). +- [`evidence/raw/explain/l14-lateral-plan.txt`](./evidence/raw/explain/l14-lateral-plan.txt) · [`evidence/raw/explain/l14-window-plan.txt`](./evidence/raw/explain/l14-window-plan.txt) · [`evidence/raw/explain/l14-twostep-plan.txt`](./evidence/raw/explain/l14-twostep-plan.txt) — 13절 세 해법 EXPLAIN 원문(LATERAL Index Scan / 윈도우 WindowAgg / 2단계 Hash Semi Join). +- [`evidence/raw/explain/l14-lateral-no-index.txt`](./evidence/raw/explain/l14-lateral-no-index.txt) — 13절 인덱스 DROP 후 같은 LATERAL EXPLAIN 원문(부모별 Seq Scan, buffers 폭증). +- [`evidence/raw/metrics/l15-depth-curve.csv`](./evidence/raw/metrics/l15-depth-curve.csv) — 페이지 깊이(offset)별 OFFSET/keyset 훑은 행·buffers(14절 깊이 곡선; OFFSET=offset+20 결정적·hash-anchor, buffers는 whitelist). +- [`evidence/raw/metrics/l15-deep-page-compare.csv`](./evidence/raw/metrics/l15-deep-page-compare.csv) — 깊은 페이지(offset 1980) OFFSET/keyset(+인덱스)/keyset(−인덱스) 최상위 노드·훑은 행·buffers·exec(14절; buffers·exec는 환경 의존 whitelist). +- [`evidence/raw/explain/l15-offset-deep-page.txt`](./evidence/raw/explain/l15-offset-deep-page.txt) · [`evidence/raw/explain/l15-keyset-index-seek.txt`](./evidence/raw/explain/l15-keyset-index-seek.txt) · [`evidence/raw/explain/l15-keyset-no-index.txt`](./evidence/raw/explain/l15-keyset-no-index.txt) — 14절 OFFSET(Seq Scan+Sort) / keyset(Index Only Scan) / keyset 인덱스 없음(Seq Scan) EXPLAIN 원문. +- [`evidence/raw/explain/l15-visibility-or-probe.txt`](./evidence/raw/explain/l15-visibility-or-probe.txt) — 14절 keyset + 가시성 OR/EXISTS EXPLAIN 원문(BitmapOr + Sort, 정렬키 인덱스 미사용 → 가시성 조건 인덱싱 단계). +- [`evidence/raw/metrics/l16-plan-compare.csv`](./evidence/raw/metrics/l16-plan-compare.csv) — 가시성 3안(단일 OR/UNION 분해/사전계산) 최상위 노드·Sort·멘션 처리·훑는 후보·buffers·exec(15절; 훑는 후보 1500은 결정적·hash-anchor, buffers·exec는 환경 의존 whitelist). +- [`evidence/raw/explain/l16-single-or-plan.txt`](./evidence/raw/explain/l16-single-or-plan.txt) · [`evidence/raw/explain/l16-union-decompose-plan.txt`](./evidence/raw/explain/l16-union-decompose-plan.txt) · [`evidence/raw/explain/l16-precompute-plan.txt`](./evidence/raw/explain/l16-precompute-plan.txt) — 15절 단일 OR(BitmapOr+Sort+hashed SubPlan) / UNION 분해(Merge Append+Hash Join) / 사전계산(단일 Index Only Scan) EXPLAIN 원문. +- [`evidence/raw/explain/l16-union-branches.txt`](./evidence/raw/explain/l16-union-branches.txt) — 15절 UNION 각 분기(mentioned=ix_mentions_user 조인 / private=partial 인덱스 / public=고선택도 bitmap) EXPLAIN 원문. +- [`evidence/raw/metrics/crown-unified-plan.csv`](./evidence/raw/metrics/crown-unified-plan.csv) — 통합(16절/Task 4) 부모선택별(사전계산/단일 OR) page 1·깊은 페이지 부모 수·행수·훑는 행·buffers·뷰어 가시 집합(부모/행/훑는 행은 결정적, buffers 는 환경 의존 whitelist). +- [`evidence/raw/explain/crown-unified-precompute-plan.txt`](./evidence/raw/explain/crown-unified-precompute-plan.txt) — 16절 사전계산 부모선택 통합 쿼리 EXPLAIN 원문(Index Only Scan feed_visible + Nested Loop LATERAL, Sort 없음 — 한 플랜 세 기법). +- [`evidence/raw/explain/crown-deep-keyset-precompute.txt`](./evidence/raw/explain/crown-deep-keyset-precompute.txt) · [`evidence/raw/explain/crown-deep-keyset-single-or.txt`](./evidence/raw/explain/crown-deep-keyset-single-or.txt) — 16절 깊은 페이지 keyset 간섭 시험 EXPLAIN 원문(사전계산 인덱스 range 19행 vs 단일 OR BitmapOr+멘션 SubPlan 200행). ### B. 측정 환경·출처(provenance) diff --git a/docs/n+1liner/final/evidence/terminal/explain/crown-deep-keyset-precompute.txt b/docs/n+1liner/final/evidence/raw/explain/crown-deep-keyset-precompute.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/crown-deep-keyset-precompute.txt rename to docs/n+1liner/final/evidence/raw/explain/crown-deep-keyset-precompute.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/crown-deep-keyset-single-or.txt b/docs/n+1liner/final/evidence/raw/explain/crown-deep-keyset-single-or.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/crown-deep-keyset-single-or.txt rename to docs/n+1liner/final/evidence/raw/explain/crown-deep-keyset-single-or.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/crown-unified-precompute-plan.txt b/docs/n+1liner/final/evidence/raw/explain/crown-unified-precompute-plan.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/crown-unified-precompute-plan.txt rename to docs/n+1liner/final/evidence/raw/explain/crown-unified-precompute-plan.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/highlights-child-plan-A.txt b/docs/n+1liner/final/evidence/raw/explain/highlights-child-plan-A.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/highlights-child-plan-A.txt rename to docs/n+1liner/final/evidence/raw/explain/highlights-child-plan-A.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l14-lateral-no-index.txt b/docs/n+1liner/final/evidence/raw/explain/l14-lateral-no-index.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l14-lateral-no-index.txt rename to docs/n+1liner/final/evidence/raw/explain/l14-lateral-no-index.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l14-lateral-plan.txt b/docs/n+1liner/final/evidence/raw/explain/l14-lateral-plan.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l14-lateral-plan.txt rename to docs/n+1liner/final/evidence/raw/explain/l14-lateral-plan.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l14-twostep-plan.txt b/docs/n+1liner/final/evidence/raw/explain/l14-twostep-plan.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l14-twostep-plan.txt rename to docs/n+1liner/final/evidence/raw/explain/l14-twostep-plan.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l14-window-plan.txt b/docs/n+1liner/final/evidence/raw/explain/l14-window-plan.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l14-window-plan.txt rename to docs/n+1liner/final/evidence/raw/explain/l14-window-plan.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l15-keyset-index-seek.txt b/docs/n+1liner/final/evidence/raw/explain/l15-keyset-index-seek.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l15-keyset-index-seek.txt rename to docs/n+1liner/final/evidence/raw/explain/l15-keyset-index-seek.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l15-keyset-no-index.txt b/docs/n+1liner/final/evidence/raw/explain/l15-keyset-no-index.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l15-keyset-no-index.txt rename to docs/n+1liner/final/evidence/raw/explain/l15-keyset-no-index.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l15-offset-deep-page.txt b/docs/n+1liner/final/evidence/raw/explain/l15-offset-deep-page.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l15-offset-deep-page.txt rename to docs/n+1liner/final/evidence/raw/explain/l15-offset-deep-page.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l15-visibility-or-probe.txt b/docs/n+1liner/final/evidence/raw/explain/l15-visibility-or-probe.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l15-visibility-or-probe.txt rename to docs/n+1liner/final/evidence/raw/explain/l15-visibility-or-probe.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l16-precompute-plan.txt b/docs/n+1liner/final/evidence/raw/explain/l16-precompute-plan.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l16-precompute-plan.txt rename to docs/n+1liner/final/evidence/raw/explain/l16-precompute-plan.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l16-single-or-plan.txt b/docs/n+1liner/final/evidence/raw/explain/l16-single-or-plan.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l16-single-or-plan.txt rename to docs/n+1liner/final/evidence/raw/explain/l16-single-or-plan.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l16-union-branches.txt b/docs/n+1liner/final/evidence/raw/explain/l16-union-branches.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l16-union-branches.txt rename to docs/n+1liner/final/evidence/raw/explain/l16-union-branches.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l16-union-decompose-plan.txt b/docs/n+1liner/final/evidence/raw/explain/l16-union-decompose-plan.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l16-union-decompose-plan.txt rename to docs/n+1liner/final/evidence/raw/explain/l16-union-decompose-plan.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l3-cartesian-join-plan.txt b/docs/n+1liner/final/evidence/raw/explain/l3-cartesian-join-plan.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l3-cartesian-join-plan.txt rename to docs/n+1liner/final/evidence/raw/explain/l3-cartesian-join-plan.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l4-collection-join-no-limit.txt b/docs/n+1liner/final/evidence/raw/explain/l4-collection-join-no-limit.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l4-collection-join-no-limit.txt rename to docs/n+1liner/final/evidence/raw/explain/l4-collection-join-no-limit.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l4-entity-paging-limit.txt b/docs/n+1liner/final/evidence/raw/explain/l4-entity-paging-limit.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l4-entity-paging-limit.txt rename to docs/n+1liner/final/evidence/raw/explain/l4-entity-paging-limit.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l5-batch-in-semijoin.txt b/docs/n+1liner/final/evidence/raw/explain/l5-batch-in-semijoin.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l5-batch-in-semijoin.txt rename to docs/n+1liner/final/evidence/raw/explain/l5-batch-in-semijoin.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l5-entity-paging-limit.txt b/docs/n+1liner/final/evidence/raw/explain/l5-entity-paging-limit.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l5-entity-paging-limit.txt rename to docs/n+1liner/final/evidence/raw/explain/l5-entity-paging-limit.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l6-child-projection.txt b/docs/n+1liner/final/evidence/raw/explain/l6-child-projection.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l6-child-projection.txt rename to docs/n+1liner/final/evidence/raw/explain/l6-child-projection.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/l6-parent-projection.txt b/docs/n+1liner/final/evidence/raw/explain/l6-parent-projection.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/l6-parent-projection.txt rename to docs/n+1liner/final/evidence/raw/explain/l6-parent-projection.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/toone-pages-plan.txt b/docs/n+1liner/final/evidence/raw/explain/toone-pages-plan.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/toone-pages-plan.txt rename to docs/n+1liner/final/evidence/raw/explain/toone-pages-plan.txt diff --git a/docs/n+1liner/final/evidence/terminal/explain/toone-users-plan.txt b/docs/n+1liner/final/evidence/raw/explain/toone-users-plan.txt similarity index 100% rename from docs/n+1liner/final/evidence/terminal/explain/toone-users-plan.txt rename to docs/n+1liner/final/evidence/raw/explain/toone-users-plan.txt diff --git a/docs/n+1liner/final/evidence/metrics/crown-unified-plan.csv b/docs/n+1liner/final/evidence/raw/metrics/crown-unified-plan.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/crown-unified-plan.csv rename to docs/n+1liner/final/evidence/raw/metrics/crown-unified-plan.csv diff --git a/docs/n+1liner/final/evidence/metrics/l1-query-growth.csv b/docs/n+1liner/final/evidence/raw/metrics/l1-query-growth.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l1-query-growth.csv rename to docs/n+1liner/final/evidence/raw/metrics/l1-query-growth.csv diff --git a/docs/n+1liner/final/evidence/metrics/l1-skew-distribution.csv b/docs/n+1liner/final/evidence/raw/metrics/l1-skew-distribution.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l1-skew-distribution.csv rename to docs/n+1liner/final/evidence/raw/metrics/l1-skew-distribution.csv diff --git a/docs/n+1liner/final/evidence/metrics/l14-group-size.csv b/docs/n+1liner/final/evidence/raw/metrics/l14-group-size.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l14-group-size.csv rename to docs/n+1liner/final/evidence/raw/metrics/l14-group-size.csv diff --git a/docs/n+1liner/final/evidence/metrics/l14-index-toggle.csv b/docs/n+1liner/final/evidence/raw/metrics/l14-index-toggle.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l14-index-toggle.csv rename to docs/n+1liner/final/evidence/raw/metrics/l14-index-toggle.csv diff --git a/docs/n+1liner/final/evidence/metrics/l14-plan-compare.csv b/docs/n+1liner/final/evidence/raw/metrics/l14-plan-compare.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l14-plan-compare.csv rename to docs/n+1liner/final/evidence/raw/metrics/l14-plan-compare.csv diff --git a/docs/n+1liner/final/evidence/metrics/l14-topn-resolution.csv b/docs/n+1liner/final/evidence/raw/metrics/l14-topn-resolution.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l14-topn-resolution.csv rename to docs/n+1liner/final/evidence/raw/metrics/l14-topn-resolution.csv diff --git a/docs/n+1liner/final/evidence/metrics/l15-deep-page-compare.csv b/docs/n+1liner/final/evidence/raw/metrics/l15-deep-page-compare.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l15-deep-page-compare.csv rename to docs/n+1liner/final/evidence/raw/metrics/l15-deep-page-compare.csv diff --git a/docs/n+1liner/final/evidence/metrics/l15-depth-curve.csv b/docs/n+1liner/final/evidence/raw/metrics/l15-depth-curve.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l15-depth-curve.csv rename to docs/n+1liner/final/evidence/raw/metrics/l15-depth-curve.csv diff --git a/docs/n+1liner/final/evidence/metrics/l16-plan-compare.csv b/docs/n+1liner/final/evidence/raw/metrics/l16-plan-compare.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l16-plan-compare.csv rename to docs/n+1liner/final/evidence/raw/metrics/l16-plan-compare.csv diff --git a/docs/n+1liner/final/evidence/metrics/l2-toone-split.csv b/docs/n+1liner/final/evidence/raw/metrics/l2-toone-split.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l2-toone-split.csv rename to docs/n+1liner/final/evidence/raw/metrics/l2-toone-split.csv diff --git a/docs/n+1liner/final/evidence/metrics/l3-cartesian.csv b/docs/n+1liner/final/evidence/raw/metrics/l3-cartesian.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l3-cartesian.csv rename to docs/n+1liner/final/evidence/raw/metrics/l3-cartesian.csv diff --git a/docs/n+1liner/final/evidence/metrics/l4-cost-curve.csv b/docs/n+1liner/final/evidence/raw/metrics/l4-cost-curve.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l4-cost-curve.csv rename to docs/n+1liner/final/evidence/raw/metrics/l4-cost-curve.csv diff --git a/docs/n+1liner/final/evidence/metrics/l4-inmemory-paging.csv b/docs/n+1liner/final/evidence/raw/metrics/l4-inmemory-paging.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l4-inmemory-paging.csv rename to docs/n+1liner/final/evidence/raw/metrics/l4-inmemory-paging.csv diff --git a/docs/n+1liner/final/evidence/metrics/l5-batch-resolution.csv b/docs/n+1liner/final/evidence/raw/metrics/l5-batch-resolution.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l5-batch-resolution.csv rename to docs/n+1liner/final/evidence/raw/metrics/l5-batch-resolution.csv diff --git a/docs/n+1liner/final/evidence/metrics/l5-hydration-probe.csv b/docs/n+1liner/final/evidence/raw/metrics/l5-hydration-probe.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l5-hydration-probe.csv rename to docs/n+1liner/final/evidence/raw/metrics/l5-hydration-probe.csv diff --git a/docs/n+1liner/final/evidence/metrics/l6-explain-width.csv b/docs/n+1liner/final/evidence/raw/metrics/l6-explain-width.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l6-explain-width.csv rename to docs/n+1liner/final/evidence/raw/metrics/l6-explain-width.csv diff --git a/docs/n+1liner/final/evidence/metrics/l6-projection-resolution.csv b/docs/n+1liner/final/evidence/raw/metrics/l6-projection-resolution.csv similarity index 100% rename from docs/n+1liner/final/evidence/metrics/l6-projection-resolution.csv rename to docs/n+1liner/final/evidence/raw/metrics/l6-projection-resolution.csv diff --git a/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-collection-fetch-join-in-memory-paging.md b/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-collection-fetch-join-in-memory-paging.md index 5b22e41..69442d7 100644 --- a/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-collection-fetch-join-in-memory-paging.md +++ b/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-collection-fetch-join-in-memory-paging.md @@ -11,8 +11,8 @@ assets: - key: in-memory-paging file: ../../../final/assets/tech-log-studio/in-memory-paging.svg evidence: - - ../../../final/evidence/terminal/explain/l4-entity-paging-limit.txt - - ../../../final/evidence/terminal/explain/l5-entity-paging-limit.txt + - ../../../final/evidence/raw/explain/l4-entity-paging-limit.txt + - ../../../final/evidence/raw/explain/l5-entity-paging-limit.txt --- # Collection Fetch Join Pagination의 In-memory Paging diff --git a/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-eager-toone-nplus1-without-access.md b/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-eager-toone-nplus1-without-access.md index e4ea0e4..0dd8731 100644 --- a/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-eager-toone-nplus1-without-access.md +++ b/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-eager-toone-nplus1-without-access.md @@ -11,9 +11,9 @@ assets: - key: eager-lazy-query-sequence file: ../../../final/assets/tech-log-studio/eager-lazy-query-sequence.svg evidence: - - ../../../final/evidence/terminal/explain/highlights-child-plan-A.txt - - ../../../final/evidence/terminal/explain/toone-pages-plan.txt - - ../../../final/evidence/terminal/explain/toone-users-plan.txt + - ../../../final/evidence/raw/explain/highlights-child-plan-A.txt + - ../../../final/evidence/raw/explain/toone-pages-plan.txt + - ../../../final/evidence/raw/explain/toone-users-plan.txt --- # Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1 diff --git a/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-fetch-join-multibag-and-row-explosion.md b/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-fetch-join-multibag-and-row-explosion.md index a7e67d7..1bee144 100644 --- a/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-fetch-join-multibag-and-row-explosion.md +++ b/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-fetch-join-multibag-and-row-explosion.md @@ -11,7 +11,7 @@ assets: - key: cartesian-row-multiplication file: ../../../final/assets/tech-log-studio/cartesian-row-multiplication.svg evidence: - - ../../../final/evidence/terminal/explain/l3-cartesian-join-plan.txt + - ../../../final/evidence/raw/explain/l3-cartesian-join-plan.txt --- # Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증 diff --git a/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-projection-row-over-fetch.md b/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-projection-row-over-fetch.md index ee9df3a..3c0d36e 100644 --- a/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-projection-row-over-fetch.md +++ b/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-projection-row-over-fetch.md @@ -11,7 +11,7 @@ assets: - key: projection-row-over-fetch file: ../../../final/assets/tech-log-studio/projection-row-over-fetch.svg evidence: - - ../../../final/evidence/terminal/explain/l6-parent-projection.txt + - ../../../final/evidence/raw/explain/l6-parent-projection.txt --- # Projection 이후에도 1,509행을 읽은 Row Over-fetch diff --git a/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-visibility-or-breaks-keyset-index.md b/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-visibility-or-breaks-keyset-index.md index 522c720..64de517 100644 --- a/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-visibility-or-breaks-keyset-index.md +++ b/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-visibility-or-breaks-keyset-index.md @@ -11,8 +11,8 @@ assets: - key: keyset-vs-offset file: ../../../final/assets/tech-log-studio/keyset-vs-offset.svg evidence: - - ../../../final/evidence/terminal/explain/l15-keyset-no-index.txt - - ../../../final/evidence/terminal/explain/l15-offset-deep-page.txt + - ../../../final/evidence/raw/explain/l15-keyset-no-index.txt + - ../../../final/evidence/raw/explain/l15-offset-deep-page.txt --- # Visibility OR이 Keyset Index를 깨뜨린 문제 diff --git a/docs/n+1liner/tech-log-studio/tech-log-tree.json b/docs/n+1liner/tech-log-studio/tech-log-tree.json index 68c4bf7..71a59be 100644 --- a/docs/n+1liner/tech-log-studio/tech-log-tree.json +++ b/docs/n+1liner/tech-log-studio/tech-log-tree.json @@ -1,8 +1,16 @@ { + "schemaVersion": 2, "project": "n+1liner", "ssot": "final/document.md", + "ssotSha256": "a32d9e8ba07129fc26deeef2ce07e3624ce6f34888d6cf503d0b7f226bbb4b6b", "generatedAt": "2026-09-04", - "note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다.", + "note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다. ssotSha256 이 지금 final/document.md 와 다르면 SSOT 가 바뀐 뒤 트리를 다시 보지 않은 것이다.", + "readinessValues": [ + "READY", + "NEEDS_EVIDENCE", + "BLOCKED", + "REJECTED" + ], "topics": { "jpa-feed-query-performance": { "topic": "jpa-feed-query-performance", @@ -12,46 +20,100 @@ "title": "Collection Fetch Join Pagination의 In-memory Paging", "slug": "collection-fetch-join-in-memory-paging", "file": "jpa-feed-query-performance/case/case-collection-fetch-join-in-memory-paging.md", + "readiness": "READY", "status": "게시 전", "studioId": "c1158754-e3d2-47b8-bb41-81787c0ca84b", - "assets": 1, - "evidence": 2 + "assets": [ + "in-memory-paging" + ], + "evidence": [ + "../../../final/evidence/raw/explain/l4-entity-paging-limit.txt", + "../../../final/evidence/raw/explain/l5-entity-paging-limit.txt" + ], + "relations": [ + "Collection Fetch Join과 Pagination을 같이 사용하지 않는다", + "Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증", + "Fetch Join · Batch · Projection 선택 기준" + ] }, { "title": "Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1", "slug": "eager-toone-nplus1-without-access", "file": "jpa-feed-query-performance/case/case-eager-toone-nplus1-without-access.md", + "readiness": "READY", "status": "게시 전", "studioId": "32d0be7d-d88e-4760-8d91-35d3a233a99a", - "assets": 1, - "evidence": 3 + "assets": [ + "eager-lazy-query-sequence" + ], + "evidence": [ + "../../../final/evidence/raw/explain/highlights-child-plan-A.txt", + "../../../final/evidence/raw/explain/toone-pages-plan.txt", + "../../../final/evidence/raw/explain/toone-users-plan.txt" + ], + "relations": [ + "Fetch Type과 Fetch Strategy 구분", + "Projection 이후에도 1,509행을 읽은 Row Over-fetch", + "JPA N+1 정량 진단 기준" + ] }, { "title": "Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증", "slug": "fetch-join-multibag-and-row-explosion", "file": "jpa-feed-query-performance/case/case-fetch-join-multibag-and-row-explosion.md", + "readiness": "READY", "status": "게시 전", "studioId": "7ed75172-fd56-42bf-956a-8f9fc1cca235", - "assets": 1, - "evidence": 1 + "assets": [ + "cartesian-row-multiplication" + ], + "evidence": [ + "../../../final/evidence/raw/explain/l3-cartesian-join-plan.txt" + ], + "relations": [ + "Fetch Join · Batch · Projection 선택 기준", + "Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1", + "Collection Fetch Join Pagination의 In-memory Paging" + ] }, { "title": "Projection 이후에도 1,509행을 읽은 Row Over-fetch", "slug": "projection-row-over-fetch", "file": "jpa-feed-query-performance/case/case-projection-row-over-fetch.md", + "readiness": "READY", "status": "게시 전", "studioId": "4c9c3b90-bc89-4300-9334-088ea95d37d8", - "assets": 1, - "evidence": 1 + "assets": [ + "projection-row-over-fetch" + ], + "evidence": [ + "../../../final/evidence/raw/explain/l6-parent-projection.txt" + ], + "relations": [ + "화면 조회는 Read Projection을 사용한다", + "Top-N-per-group 선택 기준", + "Fetch Join · Batch · Projection 선택 기준" + ] }, { "title": "Visibility OR이 Keyset Index를 깨뜨린 문제", "slug": "visibility-or-breaks-keyset-index", "file": "jpa-feed-query-performance/case/case-visibility-or-breaks-keyset-index.md", + "readiness": "READY", "status": "게시 전", "studioId": "e6715e81-6dbd-4287-8e19-946c334f38fb", - "assets": 1, - "evidence": 2 + "assets": [ + "keyset-vs-offset" + ], + "evidence": [ + "../../../final/evidence/raw/explain/l15-keyset-no-index.txt", + "../../../final/evidence/raw/explain/l15-offset-deep-page.txt" + ], + "relations": [ + "Feed Visibility Query Pattern", + "Keyset Pagination 설계 기준", + "feed_visible을 Production CQRS로 승격할 것인가" + ] } ], "concept": [], @@ -60,64 +122,105 @@ "title": "Feed Visibility Query Pattern", "slug": "feed-visibility-query-pattern", "file": "jpa-feed-query-performance/reference/reference-feed-visibility-query-pattern.md", + "readiness": "READY", "status": "게시 전", "studioId": "635fcedd-d402-4297-bcf3-9fcdf4200d28", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Visibility OR이 Keyset Index를 깨뜨린 문제", + "feed_visible을 Production CQRS로 승격할 것인가", + "Keyset Pagination 설계 기준" + ] }, { "title": "Fetch Join · Batch · Projection 선택 기준", "slug": "fetch-strategy-selection", "file": "jpa-feed-query-performance/reference/reference-fetch-strategy-selection.md", + "readiness": "READY", "status": "게시 전", "studioId": "db99cbc5-9123-4599-b368-39ff3170e81d", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증", + "Collection Fetch Join Pagination의 In-memory Paging", + "Projection 이후에도 1,509행을 읽은 Row Over-fetch" + ] }, { "title": "Fetch Type과 Fetch Strategy 구분", "slug": "fetch-type-vs-fetch-strategy", "file": "jpa-feed-query-performance/reference/reference-fetch-type-vs-fetch-strategy.md", + "readiness": "READY", "status": "게시 전", "studioId": "51095f6e-2cc8-439c-8648-065033614215", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "필드 접근 없이 발생한 EAGER ToOne N+1", + "Fetch Join · Batch · Projection 선택 기준", + "JPA N+1 정량 진단 기준" + ] }, { "title": "Keyset Pagination 설계 기준", "slug": "keyset-pagination-design", "file": "jpa-feed-query-performance/reference/reference-keyset-pagination-design.md", + "readiness": "READY", "status": "게시 전", "studioId": "06788903-3dfa-4f70-b159-f1224384fd0b", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Visibility OR이 Keyset Index를 깨뜨린 문제", + "Feed Pagination은 Keyset을 사용한다", + "PostgreSQL Query Plan 측정 기준" + ] }, { "title": "JPA N+1 정량 진단 기준", "slug": "nplus1-quantitative-diagnosis", "file": "jpa-feed-query-performance/reference/reference-nplus1-quantitative-diagnosis.md", + "readiness": "READY", "status": "게시 전", "studioId": "b0b55ac9-c0a3-4c01-ba84-0aa478923ace", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1", + "PostgreSQL Query Plan 측정 기준" + ] }, { "title": "PostgreSQL Query Plan 측정 기준", "slug": "postgresql-query-plan-measurement", "file": "jpa-feed-query-performance/reference/reference-postgresql-query-plan-measurement.md", + "readiness": "READY", "status": "게시 전", "studioId": "e8c2e9ea-cd87-46f8-9469-849dbd433d86", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Query Plan은 실제 PostgreSQL에서 측정한다", + "JPA N+1 정량 진단 기준", + "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가" + ] }, { "title": "Top-N-per-group 선택 기준", "slug": "top-n-per-group-selection", "file": "jpa-feed-query-performance/reference/reference-top-n-per-group-selection.md", + "readiness": "READY", "status": "게시 전", "studioId": "bf5f2462-0e94-4723-bdc8-f7dd709b2dbb", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Projection 이후에도 1,509행을 읽은 Row Over-fetch", + "PostgreSQL Query Plan 측정 기준", + "Fetch Join · Batch · Projection 선택 기준" + ] } ], "question": [ @@ -125,46 +228,72 @@ "title": "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가", "slug": "cardinality-estimate-after-analyze", "file": "jpa-feed-query-performance/question/question-cardinality-estimate-after-analyze.md", + "readiness": "READY", "status": "게시 전", "studioId": "e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "PostgreSQL Query Plan 측정 기준", + "Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1" + ] }, { "title": "실제 동시 트래픽에서도 이 구조가 안정적인가", "slug": "concurrency-stability", "file": "jpa-feed-query-performance/question/question-concurrency-stability.md", + "readiness": "READY", "status": "게시 전", "studioId": "6cbe963f-86f8-4df6-be5a-900712970d01", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "PostgreSQL Query Plan 측정 기준", + "feed_visible을 Production CQRS로 승격할 것인가" + ] }, { "title": "Round Trip과 Row Volume을 독립 측정할 것인가", "slug": "isolate-round-trip-and-row-volume", "file": "jpa-feed-query-performance/question/question-isolate-round-trip-and-row-volume.md", + "readiness": "READY", "status": "게시 전", "studioId": "5159c415-232d-424a-970a-b0db52746767", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "JPA N+1 정량 진단 기준", + "Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1" + ] }, { "title": "Highlight 없는 FeedItem을 허용할 것인가", "slug": "nullable-first-highlighted-at", "file": "jpa-feed-query-performance/question/question-nullable-first-highlighted-at.md", + "readiness": "READY", "status": "게시 전", "studioId": "b099ca65-bf9f-4d61-814c-74722453fa3c", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Keyset Pagination 설계 기준", + "Visibility OR이 Keyset Index를 깨뜨린 문제" + ] }, { "title": "feed_visible을 Production CQRS로 승격할 것인가", "slug": "promote-feed-visible-to-cqrs", "file": "jpa-feed-query-performance/question/question-promote-feed-visible-to-cqrs.md", + "readiness": "READY", "status": "게시 전", "studioId": "5088ce14-b096-41d3-abba-64b7afb48bb9", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Feed Visibility Query Pattern", + "현재 Read Model은 CQRS-lite로 유지한다", + "Visibility OR이 Keyset Index를 깨뜨린 문제" + ] } ], "decision": [ @@ -172,64 +301,106 @@ "title": "Entity Graph 조회에는 Batch Fetch를 사용한다", "slug": "batch-fetch-for-entity-graph", "file": "jpa-feed-query-performance/decision/decision-batch-fetch-for-entity-graph.md", + "readiness": "READY", "status": "게시 전", "studioId": "08a74b35-10c3-4874-8fbc-209b0b6e942e", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Fetch Join · Batch · Projection 선택 기준", + "Collection Fetch Join Pagination의 In-memory Paging", + "Projection 이후에도 1,509행을 읽은 Row Over-fetch" + ] }, { "title": "현재 Read Model은 CQRS-lite로 유지한다", "slug": "keep-read-model-as-cqrs-lite", "file": "jpa-feed-query-performance/decision/decision-keep-read-model-as-cqrs-lite.md", + "readiness": "READY", "status": "게시 전", "studioId": "7f248f68-ce2b-43ec-94ce-82324d0bd1a7", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "feed_visible을 Production CQRS로 승격할 것인가", + "화면 조회는 Read Projection을 사용한다", + "Feed Visibility Query Pattern" + ] }, { "title": "Feed Pagination은 Keyset을 사용한다", "slug": "keyset-for-feed-pagination", "file": "jpa-feed-query-performance/decision/decision-keyset-for-feed-pagination.md", + "readiness": "READY", "status": "게시 전", "studioId": "1dbce381-f0dc-4d49-ad68-bd31d205677e", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Keyset Pagination 설계 기준", + "Visibility OR이 Keyset Index를 깨뜨린 문제", + "Highlight 없는 FeedItem을 허용할 것인가" + ] }, { "title": "Query Plan은 실제 PostgreSQL에서 측정한다", "slug": "measure-plan-on-real-postgresql", "file": "jpa-feed-query-performance/decision/decision-measure-plan-on-real-postgresql.md", + "readiness": "READY", "status": "게시 전", "studioId": "ae6c9bea-d3a3-46e1-bbd4-8d580d336394", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "PostgreSQL Query Plan 측정 기준", + "Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1", + "Visibility OR이 Keyset Index를 깨뜨린 문제" + ] }, { "title": "Collection Fetch Join과 Pagination을 같이 사용하지 않는다", "slug": "no-collection-fetch-join-with-pagination", "file": "jpa-feed-query-performance/decision/decision-no-collection-fetch-join-with-pagination.md", + "readiness": "READY", "status": "게시 전", "studioId": "5e4d033c-d6fe-4257-a4dc-1ade44473c72", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Collection Fetch Join Pagination의 In-memory Paging", + "Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증", + "Fetch Join · Batch · Projection 선택 기준" + ] }, { "title": "Query Strategy는 FeedQueryPort 뒤에서 소유한다", "slug": "query-strategy-behind-port", "file": "jpa-feed-query-performance/decision/decision-query-strategy-behind-port.md", + "readiness": "READY", "status": "게시 전", "studioId": "4e3200c8-eff5-4442-ae84-ae7b7fa92c8b", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Fetch Join · Batch · Projection 선택 기준", + "Collection Fetch Join Pagination의 In-memory Paging", + "화면 조회는 Read Projection을 사용한다" + ] }, { "title": "화면 조회는 Read Projection을 사용한다", "slug": "read-projection-for-screen-query", "file": "jpa-feed-query-performance/decision/decision-read-projection-for-screen-query.md", + "readiness": "READY", "status": "게시 전", "studioId": "30a37f34-b406-4061-b924-e22e0be0c3bf", - "assets": 0, - "evidence": 0 + "assets": [], + "evidence": [], + "relations": [ + "Projection 이후에도 1,509행을 읽은 Row Over-fetch", + "Fetch Join · Batch · Projection 선택 기준", + "Query Strategy는 FeedQueryPort 뒤에서 소유한다" + ] } ] } diff --git a/docs/review/prose-rewrite/cache-after.md b/docs/review/prose-rewrite/cache-after.md deleted file mode 100644 index 893ca47..0000000 --- a/docs/review/prose-rewrite/cache-after.md +++ /dev/null @@ -1,141 +0,0 @@ -# Redis 캐시 한 요청의 전 생애: Generation·Envelope·Soft/Hard TTL - -> **Redis 코드 상세 시리즈 13/20** · [전체 지도](./redis-backend-policy-boundary.md) · 이전: [Timeout 뒤 쓰였는지 모를 때: Executor와 실행 확실성 모델](./redis-execution-failure-certainty.md) · 다음: [세 가지 Redis Rate Limit Lua를 코드로 추적하기](./redis-rate-limit-code-walkthrough.md) - -`ca-skeleton.capabilities.cache.bindings.default=redis`로 켠 애플리케이션에서 캐시 조회 한 번이 어디에서 시작해 어떤 Redis 명령을 거치고 언제 원본 저장소로 내려가는지를 코드로 따라간 기록입니다. Redis와 Spring은 알지만 이 저장소의 캐시 코드는 처음 보는 분을 대상으로 합니다. Spring이 조립하는 `CacheRegionPort<String, byte[]>` 빈과 애플리케이션 쪽 `CacheAsideExecutor`를 함께 읽습니다. - -값 하나를 감싸는 봉투와 리전 세대를 먼저 정의하고, 빈이 만들어지는 조건, 조회 한 번의 호출 순서, 무효화, 갱신과 실패 분기, 테스트가 고정한 범위 순서로 살펴보겠습니다. - -## 값을 감싸는 봉투와 리전 세대 - -`CacheEnvelope`는 캐시에 넣을 값을 그대로 저장하지 않고 앞에 머리말을 붙여 감싸는 형식입니다. 머리말과 페이로드는 `|` 경계 여섯 개로 나뉘고, 스키마 버전·원본 리비전·세대·소프트 만료 시각·하드 만료 시각·부재 표시가 차례로 들어간 뒤 마지막에 페이로드 바이트가 옵니다. 두 만료 시각은 절대 에폭 밀리초로 적습니다. 현행 스키마는 v1입니다. [`CacheEnvelope`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/CacheEnvelope.java:29), [`CacheEnvelope.encode`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/CacheEnvelope.java:104) - -세대(generation)는 리전마다 Redis에 두는 카운터입니다. 값을 기록할 때 그 시점의 세대를 봉투에 함께 적어 두고, 나중에 읽을 때 봉투의 세대가 현재 세대와 다르면 그 값을 지나간 값으로 처리합니다. - -만료는 두 단계로 나뉩니다. 소프트 TTL이 지나면 값은 아직 남아 있되 갱신 후보가 되고, 하드 TTL이 지나면 만료로 처리됩니다. 이 글에서는 소프트와 하드 사이에 있는 값을 '묵은 값'이라고 부르겠습니다. 원본에 값이 없다는 사실 자체를 적어 두는 항목은 부재 표시를 켜서 기록하고, 여기에는 별도의 네거티브 TTL을 씁니다. - -기본값은 소프트 TTL 30초, 하드 TTL 5분, 네거티브 TTL 10초, 명령 타임아웃 200ms입니다. 시작 시점에 `positiveSoftTtl <= positiveHardTtl`, 설정된 하드 TTL 하한, 양수 명령 타임아웃, 양수 키 버전을 검사합니다. [`RedisCapabilitySettings.Cache.validate`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilitySettings.java:70) - -## 캐시 리전 빈이 만들어지는 조건 - -전역 `app.redis.enabled=true`이고 기본 캐시 바인딩이 `redis`일 때만 `redisDefaultCacheRegion` 빈이 생깁니다. 이 메서드는 `RedisRuntimeOwner`, 네임스페이스, 캐시 설정, `Secret`, `Clock`을 받아 캐시 설정을 검증하고, 공통 `app.redis.namespace` 아래의 `CacheKeys`를 만든 다음, `RedisCacheRegionAdapter` 생성자에 넘겨 `CacheRegionPort<String, byte[]>` 빈을 내놓습니다. [`RedisCapabilityConfig.redisDefaultCacheRegion`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:95) - -`CacheRegionPort`는 애플리케이션이 넘긴 원래 키와 값을 받아 조회·기록·무효화 결과를 타입으로 구분해 돌려주는 계약입니다. 실제 동작은 공급자 어댑터가 맡습니다. [`CacheRegionPort`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java:7) - -Redis 키에는 원래 키가 들어가지 않습니다. 빈을 만들 때 원래 키를 `HMAC-SHA-256`으로 바꾸는 함수를 함께 주입하는데, 해시 재료에 환경·서비스·도메인이 같이 들어가기 때문에 같은 식별자라도 네임스페이스가 다르면 다이제스트도 달라집니다. 출력은 `hv1:<hex>`입니다. [`KeyDigest.of`와 `of`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:312) 실제로 Redis에 들어가는 항목 키는 공통 네임스페이스, `cache` 기능 이름, 키 배치 버전, 리전, 다이제스트를 이어 붙여 만듭니다. [`CacheKeys.entryKey`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:383) - -`CacheAsideExecutor`는 생성 시점에 리전별 정책으로 `CacheSingleFlight`와 `CacheSourceBulkhead`를 만듭니다. 둘 다 프로세스 안에서만 돕니다. 2인자 생성자는 갱신 조정자를 주입하지 않고, 4인자 생성자만 조정자와 `CacheRefreshCoordinationPolicy`를 받습니다. [`CacheAsideExecutor` 생성자](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:25) - -Redis 조회와 기록은 여기까지 운영 빈으로 조립됩니다. [`RedisCapabilityCompositionTest.cacheBindingComposesTheCacheRegion`](src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/redis/RedisCapabilityCompositionTest.java:67)은 Redis에 연결하지 않고 캐시 빈이 한 개만 생기는지를 검사합니다. 다만 이 빈과 `CacheAsideExecutor`를 묶어 실제 유스케이스에 주입하는 운영 조립은 찾지 못했으므로, 아래 호출 순서는 두 클래스를 이어 읽은 결과입니다. - -## 조회 한 번의 호출 순서 - -```mermaid -sequenceDiagram - participant U as Use case - participant E as CacheAsideExecutor - participant C as RedisCacheRegionAdapter - participant R as Redis - participant S as Source loader - U->>E: getOrLoad(key, region, loader) - E->>C: lookup(key) - opt 이 CacheKeys의 generation이 unresolved - C->>R: INCRBY generation 0 - end - C->>R: GET entryKey(HMAC(key)) - alt fresh 또는 negative hit - C-->>E: Hit / NegativeHit - E-->>U: 즉시 결과 - else future schema - C-->>E: QUARANTINE_AND_RELOAD + unusable token - E-->>U: FAIL_FAST (source 미호출) - else stale/miss/unavailable - C-->>E: typed lookup - E->>E: local single-flight + source bulkhead - E->>S: load(key, cancellation) - S-->>E: loaded / absent / failure - E->>C: record 또는 recordAbsent - C->>R: SET envelope [NX/none] PX hardTTL - E-->>U: LoadedFromSource 등 typed result - end -``` - -### 1단계: 세대를 한 번만 읽습니다 - -`lookup`은 `REGULAR` 갈래를 빌린 뒤 `resolveGeneration`을 부릅니다. 서버 값을 읽는 시점은 `CacheKeys`마다 최초 접근 한 번뿐이어서, `resolved`가 `true`가 되면 이후 조회와 기록은 Redis 카운터를 다시 읽지 않고 프로세스 안에 남은 세대 값을 씁니다. 최초 호출의 `INCRBY generationKey 0`은 키가 없으면 0을 만들고 그 시점의 출발값을 맞추지만, 그 뒤 다른 인스턴스가 올린 값까지 가져오지는 않습니다. [`resolveGeneration`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:322), [`CacheKeys.resolved`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:358) - -다른 인스턴스가 세대를 올리면 어떻게 될까요? 인스턴스 A와 B가 모두 세대 0을 읽어 둔 뒤 A가 리전 세대를 1로 올리면 A의 `CacheKeys`만 1로 갱신됩니다. B는 계속 0을 쓰기 때문에 세대 0으로 적힌 항목을 그대로 맞히거나, 세대 0으로 다시 기록할 수 있습니다. 지금의 리전 무효화를 모든 인스턴스에 즉시 반영되는 무효화로 읽을 수 없는 이유입니다. - -이 상황을 재현하는 테스트도 없습니다. [`regionInvalidationBumpsTheGeneration`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:165)은 어댑터 하나와 `CacheKeys` 하나로 기록→무효화→조회만 검사하고, 어댑터 둘이 세대를 따로 읽어 둔 뒤 한쪽만 무효화하는 회귀 테스트는 없습니다. - -### 2단계: `GET` 결과를 다섯 갈래로 나눕니다 - -`lookup`이 원래 키 하나를 받아 돌려주는 값은 `Hit`, `NegativeHit`, `Miss`, `IncompatibleSchema`, `Unavailable` 다섯 가지입니다. [`RedisCacheRegionAdapter.lookup`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:118) 저장된 값이 없으면 `Miss(ABSENT)`입니다. 값이 있으면 `CacheEnvelope.decode`가 `|` 경계 여섯 개를 찾아 스키마 버전, 원본 리비전, 세대, 소프트·하드 만료 시각, 부재 표시와 페이로드를 되살립니다. - -해석 순서는 [`interpret`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:144)에 그대로 드러납니다. - -1. 지금보다 높은 스키마 버전은 어댑터에서 `QUARANTINE_AND_RELOAD`로 분류합니다. 다만 여기에 쓰는 2인자 `IncompatibleSchema`에는 쓸 수 있는 관측 토큰과 쓰기 조건이 없습니다. -2. 폐기된 버전, 모르는 버전, 깨진 봉투는 `FAIL_FAST`입니다. -3. 봉투의 세대가 현재 세대와 다르면 `Miss(INVALIDATED)`입니다. -4. 하드 만료 시각이 지났으면 `Miss(EXPIRED)`입니다. -5. 부재 표시가 있으면 `NegativeHit`입니다. -6. 그 밖에는 소프트 만료 전이면 `FRESH`, 소프트와 하드 사이면 `STALE`입니다. - -높은 스키마 버전을 보통의 미스로 바꾸지 않는 이유는 구버전 인스턴스가 신버전 값을 덮어쓰는 일을 막기 위해서입니다. - -`CacheAsideExecutor`까지 따라가면 결과가 달라집니다. 어댑터가 높은 스키마 버전에 쓰는 2인자 생성자는 관측 토큰과 쓰기 조건을 모두 `unavailable()`로 채우고, 실행기는 정책이 `QUARANTINE_AND_RELOAD`여도 관측 토큰을 쓸 수 없으면 정책을 `FAIL_FAST`로 바꾼 `IncompatibleSchema`를 즉시 돌려줍니다. 원본 로더는 부르지 않습니다. 지금 조합에서 실제로 일어나는 일은 `FUTURE_VERSION` → `QUARANTINE_AND_RELOAD` 라벨 → 실행기의 `FAIL_FAST`입니다. [`CacheLookup.IncompatibleSchema`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheLookup.java:94), [`getOrLoad`의 스키마 분기](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:81) - -[`aFutureSchemaIsQuarantined`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:130)는 어댑터의 분류와 정책 라벨만 검사하고, 어댑터와 실행기를 결합해 원본을 다시 읽는지는 확인하지 않습니다. - -### 3단계: 신선한 값과 부재 표시는 원본을 부르지 않습니다 - -`CacheAsideExecutor.getOrLoad`는 키, 리전, 원본 로더를 받아 `CacheResult<V>`를 돌려줍니다. [`CacheAsideExecutor.getOrLoad`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:53) `FRESH`는 `FreshHit`로 바꾸고 `NegativeHit`는 그대로 돌려주므로 원본 호출이 없습니다. 묵은 값은 하드 만료 시각과 관측 토큰을 가진 후보로 남겨 두고, 미스와 `Unavailable`은 원본에서 다시 채울 대상으로 넘깁니다. [`getOrLoad` 분기](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:59) - -같은 프로세스에서 같은 키를 요청하면 `CacheSingleFlight`가 하나로 합칩니다. 동시에 진행할 수 있는 키 수, 키 하나당 대기자 수, 대기 시간을 넘기면 각각 `MAXIMUM_IN_FLIGHT_KEYS`, `MAXIMUM_WAITERS`, `WAIT_TIMEOUT`으로 거절됩니다. `CacheSourceBulkhead`가 차면 `SOURCE_OVERLOADED`, 마감 시각을 넘기면 `LOAD_TIMEOUT`입니다. - -### 4단계: 원본 결과를 봉투에 담아 기록합니다 - -원본이 `Loaded`를 주면 `region.record`를, `AuthoritativeAbsent`를 주면 `recordAbsent`를 부릅니다. 일시적 실패와 영구 실패는 캐시에 쓰지 않습니다. 원본 결과는 `RetryableNoEffect` 같은 멱등성 의미를 가져다 쓰지 않고 캐시 전용 `SourceLoadOutcome`으로 나뉘어 있습니다. [`invokeSourceDirect`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:206) - -`write`는 값 또는 부재, 원본 리비전, 쓰기 의도를 받아 조건을 확인한 뒤 `SET`에 TTL을 붙여 실행하고 `CacheRecordOutcome`을 돌려줍니다. 새로 쓰는 항목에는 `CacheEnvelope.CURRENT_SCHEMA_VERSION`, 원본 리비전, 현재 세대, `now + effectiveSoft`, `now + ttl`, 부재 여부, 페이로드가 들어갑니다. Redis에 거는 실제 TTL은 하드 TTL과 같고, 값이 있는 항목은 하드 TTL을, 부재를 적는 항목은 별도의 네거티브 TTL을 씁니다. [`write`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:230) - -`ONLY_IF_ABSENT`는 `SET ... NX`에 대응합니다. `ONLY_IF_OBSERVED`에서는 조회 시점의 항목 바이트로 `CacheObservationToken`과 `CacheWriteCondition`을 모두 만들고, 실행기도 두 값을 `CacheRecordMetadata`에 실어 보냅니다. 그런데 Redis 어댑터의 `write`는 `metadata.writeCondition()`을 읽지 않고, 지금 저장된 항목 바이트의 `SHA-256` 앞 16바이트와 `metadata.observedToken()`만 비교합니다. [`CacheRecordMetadata`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordMetadata.java:6), [실행기가 넘기는 `metadata`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:231), [`write`의 조건 비교](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:208) - -그래서 이 비교가 잡아내는 것은 항목 바이트가 통째로 바뀐 경우뿐입니다. 리전 세대를 올려도 기존 항목 바이트는 바뀌지 않기 때문에, 원본을 읽는 도중에 무효화가 일어나도 비교는 통과합니다. 같은 어댑터라면 새 세대로 원본 결과를 써서 무효화 직후 값을 다시 채울 수 있고, 다른 인스턴스라면 앞서 읽어 둔 이전 세대로 쓸 수 있습니다. 세대와 바이트 관측을 한 번의 원자적 비교·교환(`CAS`)으로 묶지 않았고, 비교용 `GET`과 최종 `SET`도 Lua나 트랜잭션으로 묶지 않았습니다. - -[`onlyIfObservedRefusesAStaleWrite`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:207)는 항목 바이트 자체가 바뀐 경우를 검사하고, 세대 올리기와 진행 중인 `ONLY_IF_OBSERVED`를 함께 놓지는 않습니다. [`invalidationDuringLoadRejectsTheOldCapturedWriteCondition`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:302)은 조건을 직접 바꿔 놓고 `metadata.writeCondition()`을 확인하는 가짜 리전으로 `application-core` 쪽 계약을 고정한 것이어서, Redis 어댑터가 이 조건을 실제로 읽는다는 근거는 되지 않습니다. - -## 무효화는 키를 지우거나 세대를 올립니다 - -키 하나를 무효화할 때는 값을 읽으면서 그 자리에서 지우는 `GETDEL`을 부르는데, 지워진 값이 있었으면 `INVALIDATED`를, 처음부터 없었으면 `ALREADY_ABSENT`를 돌려줍니다. 리전 무효화는 `KEYS`나 `SCAN`으로 항목을 훑어 지우지 않고, 세대 키에 `INCRBY 1`을 적용한 뒤 이 호출에 쓰인 `CacheKeys`만 반환값으로 갱신합니다. 기존 항목은 Redis에 그대로 남아서 하드 TTL이 지나야 사라집니다. 무효화를 실행한 인스턴스에서는 다음 조회가 세대 불일치가 되지만, 이미 이전 세대를 읽어 둔 다른 인스턴스에는 이 결론이 적용되지 않습니다. [`invalidateRegion`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:290), [`observeGeneration`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:412) - -## 묵은 값 갱신과 실패 분기 - -묵은 값을 갱신하러 간 원본 호출이 일시적 실패로 끝나고 정책이 허용하며 하드 만료 전이면 `CacheAsideExecutor`는 `StaleFallbackAfterTransientFailure`를 돌려줍니다. 영구 실패에는 묵은 값을 쓰지 않습니다. [`toResult`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:346) - -갱신 조정자를 주입한 경우에는 묵은 값이거나 설정된 하드 미스일 때 선점을 시도합니다. `CacheRefreshCoordinationPort`는 키, 시도, 리스 TTL을 받아 `claimed`·`contended`·`unavailable`·`indeterminate` 중 하나를 돌려주고, 실행기는 그 결과로 원본 갱신을 허용할지 정합니다. [`CacheRefreshCoordinationPort`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshCoordinationPort.java:13) `Indeterminate`는 같은 시도로 한 번만 다시 부릅니다. 선점에 밀린 쪽이거나 `unavailable`·`indeterminate`를 받은 쪽이 묵은 값을 갖고 있으면 원본을 부르지 않고 `StaleRefreshDeferred`를 돌려줍니다. 선점한 쪽은 캐시를 다시 읽어 다른 인스턴스가 이미 채웠는지 확인하고, 자기 원본 호출을 마친 뒤 `finally`에서 반납합니다. [`invokeSource`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:144) - -이 경로는 계약과 테스트 대역까지만 있습니다. `CacheRefreshCoordinationPort`의 운영 구현은 없고 `DisabledCacheRefreshCoordinationPort`와 테스트 안의 가짜 조정자만 확인됩니다. [`distributedSoftLeaseLetsOnePodRefreshWhileAContenderReturnsStale`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:341)도 실행기 둘과 테스트용 조정자로 한쪽만 원본을 부르는 계약을 고정할 뿐 Redis 구현을 검증하지는 않습니다. 그래서 분산 갱신이 Redis 리스로 동작한다고 말할 근거는 없습니다. - -이 실행기는 비동기 백그라운드 갱신 스케줄러가 아닙니다. 선점한 쪽이 동기로 갱신하고 밀린 쪽만 묵은 값을 즉시 받습니다. 하드 미스에서 정해진 시간만 기다리는 부분도 `Thread.sleep` 뒤에 한 번 다시 읽는 구현입니다. [`boundedWait`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:300) `CacheEnvelope` 주석에는 백그라운드 갱신이라는 표현이 있지만, 실제로 도는 것은 메서드 본문에 있는 동기 갱신입니다. - -캐시에서는 Redis 장애를 성능 저하로 다룹니다. `lookup`은 `Unavailable(UNAVAILABLE, NOT_APPLIED)`를, 기록과 무효화는 `DEGRADED_UNAVAILABLE`을 돌려주고, 부른 쪽은 캐시 미스처럼 원본으로 내려가도 된다는 정책입니다. `CacheRecordOutcome`과 `CacheInvalidationOutcome`에는 `INDETERMINATE`도 정의되어 있지만, 이 어댑터에서 예외를 모두 받는 자리는 이 값을 돌려주지 않습니다. [`unavailable`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:335) - -## 테스트가 고정한 범위 - -- [`RedisCacheRegionAdapterTest`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:72)는 인메모리 게이트웨이 위에서 부재→기록→신선한 적중, 소프트·하드 만료, 네거티브 만료, 스키마 라벨, 같은 어댑터 안에서의 세대 무효화, 항목 바이트 조건부 기록, Redis 장애 시 성능 저하 처리를 고정합니다. -- [`CacheAsideExecutorTest`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:29)는 신선한 값과 부재 표시가 원본을 건너뛰는 것과 타입으로 구분된 원본 결과를 검사합니다. -- [`LiveRedisSemanticPortsTest`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LiveRedisSemanticPortsTest.java:138)는 `standalone`·`cluster` 실서버 갈래에서 애플리케이션 ACL 계정으로 기록과 읽기가 동작하는지 확인하도록 태그되어 있습니다. - -다만 이번 문서 작업에서는 실서버 갈래를 돌리지 않았습니다. 위 설명은 코드와 이전에 남겨 둔 근거의 범위이지 지금 `HEAD`에서 다시 실행한 결과가 아닙니다. - -## 마무리 - -지금까지 캐시 조회 한 번이 `CacheAsideExecutor.getOrLoad`에서 시작해 세대 확인과 `GET`을 거쳐 봉투 해석으로 갈래가 나뉘고, 신선하지 않은 값만 원본으로 내려간 뒤 `SET`으로 다시 적히는 과정을 살펴봤습니다. 세대는 인스턴스마다 한 번만 읽고, 조건부 기록은 항목 바이트만 보며, 갱신 조정은 계약과 테스트 대역까지만 있습니다. Redis 조회와 기록은 운영 빈으로 조립되어 있지만, 이 빈과 실행기를 묶는 유스케이스 조립과 분산 갱신 구현은 코드에서 확인되지 않습니다. - -## 시리즈에서 이어 읽기 - -- 이전 글: [Timeout 뒤 쓰였는지 모를 때: Executor와 실행 확실성 모델](./redis-execution-failure-certainty.md) -- 다음 글: [세 가지 Redis Rate Limit Lua를 코드로 추적하기](./redis-rate-limit-code-walkthrough.md) -- 전체 흐름: [Redis를 범용 클라이언트가 아니라 정책 경계로 다루기](./redis-backend-policy-boundary.md) -- 운영 흐름: [Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지](./redis-platform-sre-operations.md) diff --git a/docs/review/prose-rewrite/cache-before.md b/docs/review/prose-rewrite/cache-before.md deleted file mode 100644 index fecb1bc..0000000 --- a/docs/review/prose-rewrite/cache-before.md +++ /dev/null @@ -1,159 +0,0 @@ -# Redis 캐시 한 요청의 전 생애: Generation·Envelope·Soft/Hard TTL - -> **Redis 코드 상세 시리즈 13/20** · [전체 지도](./redis-backend-policy-boundary.md) · 이전: [Timeout 뒤 쓰였는지 모를 때: Executor와 실행 확실성 모델](./redis-execution-failure-certainty.md) · 다음: [세 가지 Redis Rate Limit Lua를 코드로 추적하기](./redis-rate-limit-code-walkthrough.md) - -## 이 글이 답하는 코드 질문 - -`ca-skeleton.capabilities.cache.bindings.default=redis`인 애플리케이션에서 캐시 조회 한 번은 어디에서 시작하고, 어떤 Redis 명령을 거쳐, 언제 원본 저장소로 내려갑니까? 이 글은 Spring이 만드는 `CacheRegionPort<String, byte[]>`와 애플리케이션의 `CacheAsideExecutor`를 함께 읽습니다. - -먼저 결론을 구분해야 합니다. - -- Redis cache region adapter는 production bean으로 조립됩니다. -- `CacheAsideExecutor`의 local single-flight, source bulkhead, stale fallback도 구현되어 있습니다. -- 그러나 두 객체를 묶는 production use-case bean은 확인되지 않습니다. -- 분산 refresh용 `CacheRefreshCoordinationPort`는 계약과 테스트 대역만 있고 Redis production 구현·bean은 확인되지 않습니다. -- adapter 안에서도 region generation은 instance-local로 한 번만 읽고, conditional write는 generation과 `CacheWriteCondition`을 보존하지 않습니다. future schema의 `QUARANTINE_AND_RELOAD`도 executor에서는 실제 reload가 아니라 `FAIL_FAST`로 끝납니다. - -따라서 아래 흐름 중 Redis 조회·기록은 현재 조립된 capability이고, distributed refresh 흐름은 구현된 오케스트레이션 계약이지만 production 조립은 미완성입니다. - -## 먼저 보는 클래스·리소스 지도 - -| 코드 | 입력 | 출력 | 다음 호출 | -| --- | --- | --- | --- | -| [`RedisCapabilityConfig.redisDefaultCacheRegion`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:95) | `RedisRuntimeOwner`, namespace, cache 설정, Secret, `Clock` | `CacheRegionPort<String, byte[]>` bean | `RedisCacheRegionAdapter` 생성자 | -| [`CacheRegionPort`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java:7) | semantic key/value | typed lookup·record·invalidate 결과 | provider adapter | -| [`CacheAsideExecutor.getOrLoad`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:53) | key, region, source loader | `CacheResult<V>` | lookup, single-flight, source load, record | -| [`RedisCacheRegionAdapter.lookup`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:118) | semantic key | `Hit`, `NegativeHit`, `Miss`, `IncompatibleSchema`, `Unavailable` | generation 확인, `GET`, envelope 해석 | -| [`RedisCacheRegionAdapter.write`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:208) | value/absence, source revision, write intent | `CacheRecordOutcome` | 조건 확인 후 `SET` + TTL | -| [`CacheEnvelope`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/CacheEnvelope.java:29) | schema, revision, generation, 두 expiry, absence, payload | pipe header + payload bytes | `interpret` | -| [`CacheRefreshCoordinationPort`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshCoordinationPort.java:13) | key, attempt, lease TTL | claimed/contended/unavailable/indeterminate | source refresh admission | - -## 객체가 만들어지는 시점 - -전역 `app.redis.enabled=true`이고 default cache binding이 `redis`일 때만 `redisDefaultCacheRegion` bean이 생깁니다. 이 메서드는 cache 설정을 검증하고, 공통 `app.redis.namespace` 아래의 `CacheKeys`를 만들며, semantic key를 HMAC-SHA-256으로 바꾸는 함수를 주입합니다. HMAC material에는 environment/service/domain이 함께 들어가므로 같은 identifier라도 namespace가 다르면 digest도 달라집니다. 출력은 `hv1:<hex>`입니다. 근거는 [`KeyDigest.of`와 `of`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:312)에서 확인할 수 있습니다. - -기본 설정은 soft TTL 30초, hard TTL 5분, negative TTL 10초, command timeout 200ms입니다. `positiveSoftTtl <= positiveHardTtl`, hard TTL의 configured floor, 양수 command timeout, 양수 key version을 startup에 검사합니다. [`RedisCapabilitySettings.Cache.validate`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilitySettings.java:70) - -`CacheAsideExecutor`는 생성 시 region별 정책으로 local `CacheSingleFlight`와 `CacheSourceBulkhead`를 만듭니다. 2인자 생성자는 refresh coordinator를 주입하지 않습니다. 4인자 생성자만 coordinator와 `CacheRefreshCoordinationPolicy`를 받습니다. [`CacheAsideExecutor` 생성자](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:25) - -## 요청 시 호출 순서 - -```mermaid -sequenceDiagram - participant U as Use case - participant E as CacheAsideExecutor - participant C as RedisCacheRegionAdapter - participant R as Redis - participant S as Source loader - U->>E: getOrLoad(key, region, loader) - E->>C: lookup(key) - opt 이 CacheKeys의 generation이 unresolved - C->>R: INCRBY generation 0 - end - C->>R: GET entryKey(HMAC(key)) - alt fresh 또는 negative hit - C-->>E: Hit / NegativeHit - E-->>U: 즉시 결과 - else future schema - C-->>E: QUARANTINE_AND_RELOAD + unusable token - E-->>U: FAIL_FAST (source 미호출) - else stale/miss/unavailable - C-->>E: typed lookup - E->>E: local single-flight + source bulkhead - E->>S: load(key, cancellation) - S-->>E: loaded / absent / failure - E->>C: record 또는 recordAbsent - C->>R: SET envelope [NX/none] PX hardTTL - E-->>U: LoadedFromSource 등 typed result - end -``` - -### 1. generation을 먼저 확정합니다 - -`lookup`은 REGULAR lane을 빌린 뒤 `resolveGeneration`을 호출합니다. 다만 서버 값을 읽는 시점은 각 `CacheKeys`의 최초 접근 한 번뿐입니다. `resolved`가 `true`가 되면 이후 lookup과 write는 Redis counter를 다시 읽지 않고 process-local `generation`을 사용합니다. 최초 호출의 `INCRBY generationKey 0`은 키가 없을 때 0을 만들고 그 시점의 출발값을 맞추지만, instance 사이의 이후 변경을 전파하지는 않습니다. [`resolveGeneration`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:322), [`CacheKeys.resolved`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:358) - -예를 들어 instance A와 B가 모두 generation 0을 resolve한 뒤 A가 region을 1로 올리면, A의 `CacheKeys`만 1로 갱신됩니다. B는 계속 0을 사용하므로 generation-0 entry를 hit하거나 generation 0으로 다시 기록할 수 있습니다. 현행 region invalidation을 multi-instance 전체에 즉시 적용되는 semantic invalidation으로 읽을 수 없는 이유입니다. - -entry key는 공통 namespace, capability `cache`, key layout version, region, HMAC digest로 렌더링됩니다. 원래 semantic key는 Redis key에 들어가지 않습니다. [`CacheKeys.entryKey`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:383) - -### 2. `GET` 결과를 다섯 종류로 나눕니다 - -저장값이 없으면 `Miss(ABSENT)`입니다. 값이 있으면 `CacheEnvelope.decode`가 여섯 개의 `|` 경계를 찾고 schema version, source revision, generation, soft/hard absolute epoch millis, absence marker와 payload를 복원합니다. 현행 schema는 v1입니다. [`CacheEnvelope.encode`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/CacheEnvelope.java:104) - -해석 순서는 다음과 같습니다. - -1. future schema는 adapter에서 `QUARANTINE_AND_RELOAD`로 분류합니다. 그러나 이 2인자 `IncompatibleSchema`에는 usable observation token과 write condition이 없습니다. -2. retired, unknown, corrupt envelope는 `FAIL_FAST`입니다. -3. envelope generation이 현재 generation과 다르면 `Miss(INVALIDATED)`입니다. -4. hard expiry가 지났으면 `Miss(EXPIRED)`입니다. -5. absence marker가 있으면 `NegativeHit`입니다. -6. 그 밖에는 soft expiry 전이면 `FRESH`, soft와 hard 사이면 `STALE`입니다. - -이 순서는 [`interpret`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:144)에 그대로 드러납니다. future schema를 보통 miss로 바꾸지 않는 이유는 구버전 instance가 신버전 값을 덮어쓰는 일을 막기 위해서입니다. - -여기서 typed label과 end-to-end 동작을 구분해야 합니다. `CacheAsideExecutor`는 policy가 `QUARANTINE_AND_RELOAD`여도 observation token이 usable하지 않으면 policy를 `FAIL_FAST`로 바꾼 `IncompatibleSchema`를 즉시 반환합니다. source loader는 호출하지 않습니다. Redis adapter가 future schema에 쓰는 2인자 생성자는 observation token과 write condition을 모두 `unavailable()`로 채우므로, 현행 조합의 실제 흐름은 `FUTURE_VERSION` → `QUARANTINE_AND_RELOAD` label → executor `FAIL_FAST`입니다. [`CacheLookup.IncompatibleSchema`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheLookup.java:94), [`getOrLoad`의 schema 분기](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:81) - -### 3. fresh와 negative는 source를 호출하지 않습니다 - -`CacheAsideExecutor.getOrLoad`는 `FRESH`를 `FreshHit`로, `NegativeHit`를 그대로 반환합니다. stale 값은 hard expiry와 observation token을 가진 후보로 보존합니다. miss와 unavailable은 source refill 대상으로 넘어갑니다. [`getOrLoad` 분기](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:59) - -같은 process의 같은 key는 local single-flight로 합쳐집니다. maximum in-flight key, key당 waiter, wait duration을 넘으면 각각 `MAXIMUM_IN_FLIGHT_KEYS`, `MAXIMUM_WAITERS`, `WAIT_TIMEOUT`으로 거절됩니다. source bulkhead가 차면 `SOURCE_OVERLOADED`, deadline을 넘으면 `LOAD_TIMEOUT`입니다. - -### 4. source 결과에 따라 positive 또는 negative를 기록합니다 - -`Loaded`는 `region.record`, `AuthoritativeAbsent`는 `recordAbsent`를 호출합니다. transient/permanent failure는 캐시에 쓰지 않습니다. source가 `RetryableNoEffect` 같은 idempotency 의미를 주는 구조가 아니라, cache 전용 `SourceLoadOutcome`으로 분리되어 있습니다. [`invokeSourceDirect`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:206) - -새 entry는 `CacheEnvelope.CURRENT_SCHEMA_VERSION`, source revision, 현재 generation, `now + effectiveSoft`, `now + ttl`, absence, payload를 가집니다. physical Redis TTL은 hard TTL과 같습니다. positive entry는 hard TTL, negative entry는 별도 negative TTL을 사용합니다. [`write`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:230) - -`ONLY_IF_ABSENT`는 `SET ... NX`에 대응합니다. `ONLY_IF_OBSERVED`에서는 lookup 시점의 entry bytes로 `CacheObservationToken`과 `CacheWriteCondition`을 모두 만듭니다. executor도 두 값을 `CacheRecordMetadata`에 실어 보냅니다. 그러나 Redis adapter의 write는 `metadata.writeCondition()`을 읽지 않고, 현재 entry bytes의 SHA-256 앞 16바이트와 `metadata.observedToken()`만 비교합니다. [`CacheRecordMetadata`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordMetadata.java:6), [`executor의 metadata 전달`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:231), [`write`의 조건 비교](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:208) - -따라서 감지 범위는 entry bytes 교체에 한정됩니다. region generation bump는 기존 entry bytes를 바꾸지 않으므로 source load 중 invalidate가 일어나도 비교가 통과합니다. 같은 adapter라면 새 local generation으로 load 결과를 써서 invalidation 직후 값을 다시 채울 수 있고, 다른 instance라면 앞서 캐시한 이전 generation으로 쓸 수 있습니다. generation과 byte observation을 하나의 atomic CAS에 넣지 않았고, bytes 비교용 `GET`과 최종 `SET`도 Lua나 transaction으로 묶지 않았습니다. - -## invalidation은 삭제와 세대 교체로 나뉩니다 - -단일 key invalidation은 `GETDEL`을 호출해 `INVALIDATED`와 `ALREADY_ABSENT`를 구분합니다. region invalidation은 `KEYS`나 `SCAN`으로 entry를 지우지 않고 generation key에 `INCRBY 1`을 적용한 뒤, 이 호출에 사용된 `CacheKeys`만 반환값으로 갱신합니다. 기존 entry는 Redis에 남아 hard TTL로 사라집니다. invalidate를 수행한 instance에서는 다음 lookup이 generation mismatch가 되지만, 이미 이전 generation을 resolve한 다른 instance에는 이 결론이 적용되지 않습니다. [`invalidateRegion`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:290), [`observeGeneration`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:412) - -## stale refresh와 실패 분기 - -`CacheAsideExecutor`는 stale source load가 transient failure이고 policy가 허용하며 hard expiry 전이면 `StaleFallbackAfterTransientFailure`를 반환합니다. permanent failure에는 stale을 쓰지 않습니다. [`toResult`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:346) - -optional refresh coordinator가 주입된 경우에는 stale 또는 configured hard miss에서 claim을 시도합니다. `Indeterminate` claim은 같은 attempt로 한 번만 다시 호출합니다. contender나 unavailable/indeterminate가 stale을 갖고 있으면 source를 호출하지 않고 `StaleRefreshDeferred`를 반환합니다. owner는 claim 후 cache를 다시 읽어 다른 instance가 이미 채웠는지 확인하고, 자기 source load를 마친 뒤 `finally`에서 release합니다. [`invokeSource`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:144) - -이 executor는 비동기 background refresh scheduler가 아닙니다. owner가 동기 refresh를 수행하고 contender만 stale을 즉시 받습니다. hard miss의 bounded wait는 `Thread.sleep` 뒤 한 번 다시 읽는 구현입니다. [`boundedWait`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:300) - -Redis 장애는 cache에 한해 degraded로 처리됩니다. lookup은 `Unavailable(UNAVAILABLE, NOT_APPLIED)`, record와 invalidation은 `DEGRADED_UNAVAILABLE`을 반환합니다. cache miss처럼 source로 내려갈 수 있다는 정책입니다. 다만 `CacheRecordOutcome`과 `CacheInvalidationOutcome`에는 `INDETERMINATE`가 정의되어 있어도 이 adapter의 catch-all은 이를 반환하지 않습니다. [`unavailable`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:335) - -## 테스트가 고정하는 계약 - -- [`RedisCacheRegionAdapterTest`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:72)는 absent→record→fresh hit, soft/hard expiry, negative expiry, schema label, 같은 adapter의 generation invalidation, entry-byte 조건부 기록과 Redis 장애 degradation을 in-memory gateway에서 고정합니다. -- 같은 테스트의 [`regionInvalidationBumpsTheGeneration`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:165)는 하나의 adapter와 하나의 `CacheKeys`로 record→invalidate→lookup을 검사합니다. 두 adapter가 generation을 각각 resolve한 뒤 한쪽만 invalidate하는 regression test는 없습니다. -- [`onlyIfObservedRefusesAStaleWrite`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:207)는 entry bytes 자체가 바뀐 경우를 검사합니다. generation bump와 in-flight `ONLY_IF_OBSERVED`를 결합하지 않습니다. -- [`aFutureSchemaIsQuarantined`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:130)는 adapter의 category와 policy label만 검사합니다. 실제 adapter와 executor를 결합해 source reload를 확인하지 않습니다. -- [`CacheAsideExecutorTest`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:29)는 fresh/negative의 source bypass와 typed source 결과를 검사합니다. -- 같은 테스트의 [`invalidationDuringLoadRejectsTheOldCapturedWriteCondition`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:302)는 condition을 직접 교체하고 `metadata.writeCondition()`을 검사하는 fake region의 application-core 계약입니다. Redis adapter가 이 condition을 소비한다는 증거는 아닙니다. -- 같은 테스트의 [`distributedSoftLeaseLetsOnePodRefreshWhileAContenderReturnsStale`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:341)는 두 executor와 test coordinator로 owner 하나만 source를 호출하는 계약을 고정합니다. Redis 구현을 검증하는 테스트는 아닙니다. -- [`LiveRedisSemanticPortsTest`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LiveRedisSemanticPortsTest.java:138)는 standalone/cluster real-server lane에서 application ACL account로 record/read가 동작함을 확인하도록 태그되어 있습니다. -- [`RedisCapabilityCompositionTest.cacheBindingComposesTheCacheRegion`](src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/redis/RedisCapabilityCompositionTest.java:67)는 연결하지 않고 cache bean 한 개만 생기는지를 검사합니다. - -## 현재 구현 공백과 잘못 읽기 쉬운 지점 - -1. semantic Redis composition은 cache, rate-limit, lease, idempotency V2 네 개가 있고 Session이 빠진 4/5입니다. -2. `CacheRegionPort` bean은 있지만 `CacheAsideExecutor`를 이 bean과 묶어 실제 use case에 주입하는 production 조립은 검색되지 않습니다. -3. `CacheRefreshCoordinationPort` production 구현은 없습니다. `DisabledCacheRefreshCoordinationPort`와 테스트 내부 fake coordinator만 확인됩니다. 따라서 “분산 refresh가 Redis lease로 동작한다”고 말할 근거는 없습니다. -4. 각 instance는 region generation을 최초 한 번만 읽습니다. 다른 instance의 bump를 관찰하지 못하므로 multi-instance semantic invalidation은 완성되지 않았고, 이를 재현하는 test도 없습니다. -5. Redis adapter의 `ONLY_IF_OBSERVED`는 `CacheWriteCondition`과 generation을 조건에 포함하지 않습니다. entry-byte 비교만 하며 `GET`과 `SET`도 원자적이지 않습니다. application-core의 invalidation-during-load fake test를 Redis 구현 증거로 확대할 수 없습니다. -6. future schema의 `QUARANTINE_AND_RELOAD`는 adapter label입니다. unusable observation 때문에 executor는 `FAIL_FAST`를 반환하고 source를 호출하지 않습니다. -7. `CacheEnvelope` 주석에는 background refresh 표현이 있으나 executor 구현은 동기 owner refresh입니다. 현행 method body가 우선 근거입니다. -8. 이번 문서 작업에서는 real-server lane을 실행하지 않았습니다. 위 live test 설명은 코드와 historical evidence의 범위이며 현재 HEAD 재실행 결과가 아닙니다. - -## 다음에 열어볼 source 순서 - -다음 읽기 순서는 `RedisCapabilityConfig` → `CacheAsideExecutor` → `RedisCacheRegionAdapter` → `CacheEnvelope` → 두 test class가 적절합니다. SDK의 command admission과 connection lane은 별도 문서가 소유할 범위입니다. - -## 시리즈에서 이어 읽기 - -- 이전 글: [Timeout 뒤 쓰였는지 모를 때: Executor와 실행 확실성 모델](./redis-execution-failure-certainty.md) -- 다음 글: [세 가지 Redis Rate Limit Lua를 코드로 추적하기](./redis-rate-limit-code-walkthrough.md) -- 전체 흐름: [Redis를 범용 클라이언트가 아니라 정책 경계로 다루기](./redis-backend-policy-boundary.md) -- 운영 흐름: [Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지](./redis-platform-sre-operations.md) - diff --git a/scripts/build-tech-log-tree.py b/scripts/build-tech-log-tree.py index f19636d..6854a02 100755 --- a/scripts/build-tech-log-tree.py +++ b/scripts/build-tech-log-tree.py @@ -1,6 +1,10 @@ #!/usr/bin/env python3 """tech-log-tree.json 을 다시 만든다. +노드 필드는 document-detail 의 root-tree 계약을 따른다 — readiness, source, code, +evidence, classification, missing-verification, relations. 제목만 보고 기록을 만들지 +못하게 하려는 것이다. + SSOT(final/document.md)에서 뽑은 글감과 이미 쓴 기록을 한 파일에 모은다. 기록 파일이 정본이므로 이 스크립트는 그것을 읽어 채우고, 아직 글이 없는 글감은 사람이 적은 항목을 그대로 둔다. @@ -8,7 +12,7 @@ SSOT(final/document.md)에서 뽑은 글감과 이미 쓴 기록을 한 파일 python3 scripts/build-tech-log-tree.py [프로젝트 ...] """ from __future__ import annotations -import json, re, sys, glob, os, datetime +import json, re, sys, glob, os, datetime, hashlib KINDS = ["case", "concept", "reference", "question", "decision"] ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -45,15 +49,24 @@ def build(project: str) -> dict: for f in sorted(glob.glob(os.path.join(topic_dir, kind, "*.md"))): fm = front_matter(f) text = open(f, encoding="utf-8").read() - items.append({ + node = { "title": fm.get("title", os.path.basename(f)), "slug": fm.get("slug", ""), "file": os.path.relpath(f, studio), + "readiness": "READY" if fm.get("id") else "NEEDS_EVIDENCE", "status": fm.get("status", "미작성"), "studioId": fm.get("id", ""), - "assets": len(re.findall(r"^ - key: ", text, re.M)), - "evidence": len(re.findall(r"^ - \.\./", text, re.M)), - }) + "assets": re.findall(r"^ - key: (\S+)", text, re.M), + "evidence": re.findall(r"^ - (\.\./\S+)", text, re.M), + "relations": re.findall(r"^- \*\*(.+?)\*\*$", text, re.M), + } + # 이미 쓴 글감은 지난 트리의 사람이 적은 칸을 잃지 않는다 + for old in previous.get("topics", {}).get(topic, {}).get("kinds", {}).get(kind, []): + if old.get("slug") == node["slug"]: + for key in ("classification", "missing-verification", "source", "code"): + if old.get(key): + node[key] = old[key] + items.append(node) # 아직 글이 없는 글감은 지난 tree 에서 가져와 유지한다 written = {i["slug"] for i in items if i["slug"]} for old in previous.get("topics", {}).get(topic, {}).get("kinds", {}).get(kind, []): @@ -62,11 +75,20 @@ def build(project: str) -> dict: entry["kinds"][kind] = items topics[topic] = entry + ssot_path = os.path.join(base, ssot) if ssot else None + digest = None + if ssot_path and os.path.exists(ssot_path): + digest = hashlib.sha256(open(ssot_path, "rb").read()).hexdigest() + return { + "schemaVersion": 2, "project": project, "ssot": ssot, + "ssotSha256": digest, "generatedAt": datetime.date.today().isoformat(), - "note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다.", + "note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다. " + "ssotSha256 이 지금 final/document.md 와 다르면 SSOT 가 바뀐 뒤 트리를 다시 보지 않은 것이다.", + "readinessValues": ["READY", "NEEDS_EVIDENCE", "BLOCKED", "REJECTED"], "topics": topics, } diff --git a/scripts/terminal-evidence/README.md b/scripts/terminal-evidence/README.md new file mode 100644 index 0000000..9bb9d21 --- /dev/null +++ b/scripts/terminal-evidence/README.md @@ -0,0 +1,51 @@ +# Terminal Evidence Renderer + +실제 명령 출력 원문을 보존한 뒤, 그 원문을 terminal UI 형태의 SVG로 렌더링한다. + +## Evidence rule + +`SVG`가 정본이 아니다. **실행한 명령의 raw output과 metadata가 정본**이고 SVG는 문서에 넣기 위한 표현이다. + +```text +command execution + ├── raw/<name>.txt # stdout/stderr 원문 + ├── meta/<name>.json # command, cwd, executedAt, exitCode, revision + └── terminal/<name>.svg # raw에서 생성 +``` + +시각화 단계에서는 Authorization Bearer, Cookie, token/password/client_secret/API key 형태의 값을 `[REDACTED]`로 치환한다. 그래도 raw 파일에 secret이 들어간 채 보관하면 안 된다. **명령 자체를 secret이 출력되지 않도록 구성하고, raw 저장 전에도 검사한다.** + +## Render + +```bash +python3 /shared/Tech-Log-Document/tools/terminal-evidence/render_terminal.py \ + evidence/raw/gradle-test.txt \ + evidence/terminal/gradle-test.svg \ + --command './gradlew test' \ + --cwd '/shared/codebase/my-project' \ + --exit-code 0 \ + --executed-at '2026-08-28T15:00:00+09:00' +``` + +## Capture pattern + +Agent가 명령을 실제로 실행할 때 stdout/stderr, exit code, 실행 시간, cwd를 함께 기록한다. 실패 명령도 Evidence가 될 수 있으므로 exit code를 버리지 않는다. + +예시 shell pattern: + +```bash +set +e +executed_at=$(date --iso-8601=seconds) +pwd_value=$(pwd) +./gradlew test > evidence/raw/gradle-test.txt 2>&1 +exit_code=$? +set -e +``` + +그 뒤 metadata JSON을 쓰고 renderer를 호출한다. command에 credential을 직접 넣지 않는다. + +## Tests + +```bash +python3 -m unittest discover -s tests -v +``` diff --git a/scripts/terminal-evidence/fixtures/meta/toolchain.json b/scripts/terminal-evidence/fixtures/meta/toolchain.json new file mode 100644 index 0000000..5092ce6 --- /dev/null +++ b/scripts/terminal-evidence/fixtures/meta/toolchain.json @@ -0,0 +1,8 @@ +{ + "command": "python3 --version; git --version; printf workspace=<cwd>", + "cwd": "/shared/Tech-Log-Document/tools/terminal-evidence", + "executedAt": "2026-08-28T06:03:38+00:00", + "exitCode": 0, + "raw": "../raw/toolchain.txt", + "rendered": "../terminal/toolchain.svg" +} diff --git a/scripts/terminal-evidence/fixtures/raw/toolchain.txt b/scripts/terminal-evidence/fixtures/raw/toolchain.txt new file mode 100644 index 0000000..e48fe2e --- /dev/null +++ b/scripts/terminal-evidence/fixtures/raw/toolchain.txt @@ -0,0 +1,3 @@ +Python 3.12.3 +git version 2.43.0 +workspace=/shared/Tech-Log-Document/tools/terminal-evidence diff --git a/scripts/terminal-evidence/fixtures/terminal/toolchain.svg b/scripts/terminal-evidence/fixtures/terminal/toolchain.svg new file mode 100644 index 0000000..9694100 --- /dev/null +++ b/scripts/terminal-evidence/fixtures/terminal/toolchain.svg @@ -0,0 +1,18 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="242" viewBox="0 0 1200 242" role="img"> +<title>terminal evidence +Terminal-style rendering generated from retained command output. Sensitive-looking values are redacted in the visual asset. + + + + + + +terminal evidence +$ python3 --version; git --version; printf workspace=<cwd> +cwd: /shared/Tech-Log-Document/tools/terminal-evidence +time: 2026-08-28T06:03:38+00:00 · exit 0 + +Python 3.12.3 +git version 2.43.0 +workspace=/shared/Tech-Log-Document/tools/terminal-evidence + diff --git a/scripts/terminal-evidence/render_terminal.py b/scripts/terminal-evidence/render_terminal.py new file mode 100755 index 0000000..a00699a --- /dev/null +++ b/scripts/terminal-evidence/render_terminal.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import html +import re +from pathlib import Path + +ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + +_REDACTION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + (re.compile(r"(?i)^(\s*authorization\s*:\s*bearer\s+).*$"), r"\1[REDACTED]"), + (re.compile(r"(?i)^(\s*(?:cookie|set-cookie)\s*:\s*).*$"), r"\1[REDACTED]"), + ( + re.compile( + r"(?i)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|client[_-]?secret|api[_-]?key|secret|aws_secret_access_key)\b\s*[=:]\s*)([^\s,;]+)" + ), + r"\1[REDACTED]", + ), + ( + re.compile( + r'(?i)(["\'](?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|client[_-]?secret|api[_-]?key|secret)["\']\s*:\s*["\'])(.*?)(["\'])' + ), + r"\1[REDACTED]\3", + ), +) + + +def strip_ansi(value: str) -> str: + return ANSI_RE.sub("", value) + + +def redact_line(line: str) -> str: + redacted = strip_ansi(line) + for pattern, replacement in _REDACTION_PATTERNS: + redacted = pattern.sub(replacement, redacted) + return redacted + + +def _escape(value: str) -> str: + return html.escape(value, quote=True) + + +def _display_lines(output: str, max_lines: int) -> list[str]: + if max_lines < 1: + raise ValueError("max_lines must be >= 1") + + source_lines = output.splitlines() + if not source_lines and output == "": + source_lines = [""] + + visible = source_lines[:max_lines] + rendered = [redact_line(line) for line in visible] + omitted = len(source_lines) - len(visible) + if omitted > 0: + rendered.append(f"[{omitted} more lines omitted]") + return rendered + + +def render_svg( + output: str, + *, + command: str, + cwd: str, + exit_code: int, + executed_at: str, + max_lines: int = 120, + width: int = 1200, +) -> str: + lines = _display_lines(output, max_lines=max_lines) + + line_height = 22 + top_bar = 44 + metadata_height = 86 + output_top = top_bar + metadata_height + 18 + bottom_padding = 28 + height = output_top + max(1, len(lines)) * line_height + bottom_padding + + safe_command = _escape(redact_line(command)) + safe_cwd = _escape(redact_line(cwd)) + safe_time = _escape(executed_at) + safe_exit = _escape(str(exit_code)) + + parts = [ + f'', + "terminal evidence", + "Terminal-style rendering generated from retained command output. Sensitive-looking values are redacted in the visual asset.", + f'', + f'', + f'', + '', + '', + '', + 'terminal evidence', + f'$ {safe_command}', + f'cwd: {safe_cwd}', + f'time: {safe_time} · exit {safe_exit}', + f'', + ] + + for index, line in enumerate(lines): + y = output_top + (index + 1) * line_height + escaped = _escape(line) + # Keep all text derived from output. The SVG viewport clips extreme-width lines + # rather than inventing wrapped content or changing the raw evidence. + fill = "#d2a8ff" if line.startswith("[") and line.endswith("more lines omitted]") else "#e6edf3" + parts.append( + f'' + f"{escaped}" + ) + + parts.append("") + return "\n".join(parts) + "\n" + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Render retained command output as a terminal-style SVG evidence asset." + ) + parser.add_argument("input", type=Path, help="UTF-8 raw command output file") + parser.add_argument("output", type=Path, help="Destination SVG") + parser.add_argument("--command", required=True, help="Command that produced the raw output") + parser.add_argument("--cwd", required=True, help="Working directory of the command") + parser.add_argument("--exit-code", type=int, required=True, help="Actual process exit code") + parser.add_argument("--executed-at", required=True, help="Actual ISO-8601 execution timestamp") + parser.add_argument("--max-lines", type=int, default=120, help="Maximum raw lines to render") + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + raw = args.input.read_text(encoding="utf-8", errors="replace") + svg = render_svg( + raw, + command=args.command, + cwd=args.cwd, + exit_code=args.exit_code, + executed_at=args.executed_at, + max_lines=args.max_lines, + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(svg, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/terminal-evidence/tests/__pycache__/test_render_terminal.cpython-312.pyc b/scripts/terminal-evidence/tests/__pycache__/test_render_terminal.cpython-312.pyc new file mode 100644 index 0000000..84b2461 Binary files /dev/null and b/scripts/terminal-evidence/tests/__pycache__/test_render_terminal.cpython-312.pyc differ diff --git a/scripts/terminal-evidence/tests/test_render_terminal.py b/scripts/terminal-evidence/tests/test_render_terminal.py new file mode 100644 index 0000000..ed3e1ac --- /dev/null +++ b/scripts/terminal-evidence/tests/test_render_terminal.py @@ -0,0 +1,79 @@ +import re +import unittest +import xml.etree.ElementTree as ET + +from render_terminal import redact_line, render_svg + + +class RenderTerminalTest(unittest.TestCase): + def test_svg_is_valid_xml_and_escapes_output(self): + svg = render_svg( + "& value\nsecond", + command="printf '& value'", + cwd="/shared/codebase/demo", + exit_code=0, + executed_at="2026-08-28T06:00:00Z", + ) + ET.fromstring(svg) + self.assertIn("<tag>& value", svg) + self.assertNotIn("& value", svg) + + def test_metadata_is_rendered(self): + svg = render_svg( + "BUILD SUCCESSFUL", + command="./gradlew test", + cwd="/shared/codebase/demo", + exit_code=0, + executed_at="2026-08-28T06:00:00Z", + ) + self.assertIn("./gradlew test", svg) + self.assertIn("/shared/codebase/demo", svg) + self.assertIn("exit 0", svg) + self.assertIn("2026-08-28T06:00:00Z", svg) + + def test_obvious_secrets_are_redacted(self): + cases = { + "Authorization: Bearer abc.def.ghi": "Authorization: Bearer [REDACTED]", + "TOKEN=super-secret": "TOKEN=[REDACTED]", + "PASSWORD=hunter2": "PASSWORD=[REDACTED]", + "client_secret: abc123": "client_secret: [REDACTED]", + "Cookie: SESSION=abcdef": "Cookie: [REDACTED]", + } + for raw, expected in cases.items(): + with self.subTest(raw=raw): + self.assertEqual(expected, redact_line(raw)) + + def test_normal_output_is_not_changed_by_redaction(self): + line = "GET /api/me -> 200 in 14ms" + self.assertEqual(line, redact_line(line)) + + def test_truncation_marker_is_rendered_without_fabricating_hidden_lines(self): + output = "\n".join(f"line-{i}" for i in range(8)) + svg = render_svg( + output, + command="demo", + cwd="/tmp", + exit_code=1, + executed_at="2026-08-28T06:00:00Z", + max_lines=3, + ) + self.assertIn("line-0", svg) + self.assertIn("line-2", svg) + self.assertNotIn("line-3", svg) + self.assertIn("[5 more lines omitted]", svg) + + def test_terminal_chrome_and_monospace_are_present(self): + svg = render_svg( + "ok", + command="echo ok", + cwd="/tmp", + exit_code=0, + executed_at="2026-08-28T06:00:00Z", + ) + self.assertGreaterEqual(len(re.findall(r" list[str]: + errors: list[str] = [] + text = path.read_text(encoding="utf-8", errors="replace") + for token in REFACTOR_QUEUE_TOKENS: + if token not in text: + errors.append(f"refactor queue missing token: {token}") + return errors + + +def _verify_analysis_queue(path: Path) -> list[str]: + errors = [] + text = path.read_text(encoding="utf-8", errors="replace") + for token in QUEUE_TOKENS: + if token not in text: + errors.append(f"analysis queue missing token: {token}") + if errors: + return errors + + active, projects = _parse_analysis_queue(text) + names = [p["name"] for p in projects] + if len(names) != len(set(names)): + errors.append("analysis queue contains duplicate project names") + + for project in projects: + status = project.get("status") + if status not in ALLOWED_QUEUE_STATUSES: + errors.append(f"analysis queue invalid status for {project['name']}: {status}") + + in_progress = [p["name"] for p in projects if p.get("status") == "IN_PROGRESS"] + if len(in_progress) > 1: + errors.append("analysis queue has multiple IN_PROGRESS projects") + + owned = [p["name"] for p in projects if p.get("status") in {"IN_PROGRESS", "BLOCKED"}] + if len(owned) > 1: + errors.append("analysis queue has multiple active-owned projects") + if active is None: + if owned: + errors.append("activeProject does not match active-owned project") + elif owned != [active]: + errors.append("activeProject does not match active-owned project") + return errors + + +FORBIDDEN_LITERAL = "document-" + "haness" +TEXT_SUFFIXES = {".md", ".json", ".py", ".sh", ".txt", ".yaml", ".yml", ".toml"} + + +def _iter_pipeline_text_files(shared_root: Path): + for rel_root in (".agents", "docs/_templates", "scripts"): + root = shared_root / rel_root + if not root.exists(): + continue + for path in root.rglob("*"): + if not path.is_file() or path.suffix.lower() not in TEXT_SUFFIXES: + continue + if "__pycache__" in path.parts: + continue + yield path + + +def verify_pipeline(shared_root: Path) -> list[str]: + shared_root = Path(shared_root) + errors: list[str] = [] + + for rel in REQUIRED_PATHS: + if not (shared_root / rel).exists(): + errors.append(f"missing required path: {rel}") + + queue = shared_root / "docs/analysis-queue.yaml" + if queue.exists(): + errors.extend(_verify_analysis_queue(queue)) + + refactor_queue = shared_root / "docs/refactor-queue.yaml" + if refactor_queue.exists(): + errors.extend(_verify_refactor_queue(refactor_queue)) + + state_template = shared_root / "docs/_templates/state.json" + if state_template.exists(): + state_text = state_template.read_text(encoding="utf-8", errors="replace") + for token in STATE_REANALYSIS_TOKENS: + if token not in state_text: + errors.append(f"state template missing reanalysis token: {token}") + + root_tree = shared_root / "docs/_templates/root-tree.md" + if root_tree.exists(): + text = root_tree.read_text(encoding="utf-8", errors="replace") + for token in ROOT_TREE_TOKENS: + if token not in text: + errors.append(f"root-tree template missing token: {token}") + + for path in _iter_pipeline_text_files(shared_root): + text = path.read_text(encoding="utf-8", errors="replace") + if FORBIDDEN_LITERAL in text: + rel = path.relative_to(shared_root) + errors.append(f"forbidden legacy dependency in {rel}: {FORBIDDEN_LITERAL}") + if path.is_symlink(): + try: + target = str(path.resolve()) + except OSError: + target = "" + if FORBIDDEN_LITERAL in target: + rel = path.relative_to(shared_root) + errors.append(f"forbidden legacy symlink target in {rel}: {target}") + + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify the Tech Log documentation pipeline workspace.") + parser.add_argument("shared_root", nargs="?", type=Path, + default=Path(__file__).resolve().parent.parent) + args = parser.parse_args() + + errors = verify_pipeline(args.shared_root) + if errors: + print("PIPELINE VERIFICATION: FAIL") + for error in errors: + print(f"- {error}") + return 1 + + print("PIPELINE VERIFICATION: PASS") + print(f"- required paths: {len(REQUIRED_PATHS)}") + print("- analysis queue contract: valid") + print("- root-tree contract: present") + print("- forbidden legacy dependency: absent") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify-refactor-work-item.py b/scripts/verify-refactor-work-item.py new file mode 100755 index 0000000..e16f04f --- /dev/null +++ b/scripts/verify-refactor-work-item.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ALLOWED_TYPES = { + "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"} +ALLOWED_STATUSES = {"CANDIDATE", "READY", "BASELINING", "IN_PROGRESS", "VERIFYING", "WAITING_APPROVAL", "APPROVED", "MERGED", "REJECTED", "BLOCKED", "COMPLETE"} +PERFORMANCE_BASELINE_STATUSES = {"IN_PROGRESS", "VERIFYING", "WAITING_APPROVAL", "APPROVED", "MERGED", "COMPLETE"} +PERFORMANCE_COMPLETE_EVIDENCE_STATUSES = {"WAITING_APPROVAL", "APPROVED", "MERGED", "COMPLETE"} + + +def _load(item_dir: Path) -> dict: + path = item_dir / "work-item.json" + if not path.exists(): + raise FileNotFoundError(path) + return json.loads(path.read_text(encoding="utf-8")) + + +def _resolve(item_dir: Path, rel: str | None) -> Path | None: + if not rel: + return None + return item_dir / rel + + +def _comparison_fields(text: str) -> dict[str, str]: + labels = ( + "Same measurement command/procedure", + "Same metric definitions", + "Same dataset/load profile", + "Environment materially equivalent", + "Result", + "Acceptance criteria satisfied", + ) + values: dict[str, str] = {} + for raw in text.splitlines(): + stripped = raw.strip().lstrip("- ") + for label in labels: + prefix = label + ":" + if stripped.startswith(prefix): + values[label] = stripped[len(prefix):].strip().upper() + return values + + +def verify_work_item(item_dir: Path) -> list[str]: + item_dir = Path(item_dir) + errors: list[str] = [] + try: + data = _load(item_dir) + except (FileNotFoundError, json.JSONDecodeError) as exc: + return [f"invalid work-item.json: {exc}"] + + required = ( + "schemaVersion", "id", "project", "analysisRevision", "type", "scope", + "target", "priority", "status", "problem", "goal", "acceptanceCriteria", "evidence", + ) + for key in required: + if key not in data: + errors.append(f"missing work item field: {key}") + + item_type = data.get("type") + if item_type not in ALLOWED_TYPES: + errors.append(f"invalid type: {item_type}") + scope = data.get("scope") + if scope not in ALLOWED_SCOPES: + errors.append(f"invalid scope: {scope}") + status = data.get("status") + if status not in ALLOWED_STATUSES: + errors.append(f"invalid status: {status}") + + if item_type == "PERFORMANCE": + contract = data.get("measurementContract") + if not isinstance(contract, dict): + errors.append("performance baseline measurement contract is required before refactoring") + else: + for key in ("command", "cwd", "environment", "dataset", "metrics"): + value = contract.get(key) + if value in (None, "", []): + errors.append(f"performance baseline measurement contract missing: {key}") + environment = _resolve(item_dir, contract.get("environment")) + if environment is not None and not environment.exists(): + errors.append(f"performance environment evidence missing: {contract.get('environment')}") + + evidence = data.get("evidence") or {} + baseline = evidence.get("baseline") or [] + after = evidence.get("after") or [] + comparison = evidence.get("comparison") + + def validate_metadata(phase: str, require: bool) -> dict | None: + meta_path = item_dir / f"evidence/{phase}/metadata.json" + if not meta_path.exists(): + if require: + errors.append(f"performance {phase} metadata is required") + return None + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + errors.append(f"performance {phase} metadata invalid: {exc}") + return None + for key in ("sourceRevision", "command", "cwd", "exitCode", "dataset", "metrics", "rawFiles"): + if key not in meta or meta.get(key) in (None, "", []): + if key == "exitCode" and meta.get(key) == 0: + continue + errors.append(f"performance {phase} metadata missing: {key}") + if isinstance(contract, dict): + for key in ("command", "cwd", "dataset", "metrics"): + if meta.get(key) != contract.get(key): + errors.append(f"performance {phase} metadata {key} differs from measurement contract") + if meta.get("exitCode") not in (0,): + errors.append(f"performance {phase} measurement exitCode is not zero") + for rel in meta.get("rawFiles") or []: + if not (meta_path.parent / rel).exists(): + errors.append(f"performance {phase} metadata raw file missing: {rel}") + return meta + + if status in PERFORMANCE_BASELINE_STATUSES: + if not baseline: + errors.append("performance baseline evidence is required before source changes") + else: + for rel in baseline: + if not (item_dir / rel).exists(): + errors.append(f"performance baseline evidence missing: {rel}") + baseline_meta = validate_metadata("baseline", True) + if baseline_meta is not None and baseline_meta.get("sourceRevision") != data.get("analysisRevision"): + errors.append("performance baseline metadata sourceRevision differs from analysisRevision") + + if status in PERFORMANCE_COMPLETE_EVIDENCE_STATUSES: + if not after: + errors.append("performance after evidence is required") + else: + for rel in after: + if not (item_dir / rel).exists(): + errors.append(f"performance after evidence missing: {rel}") + validate_metadata("after", True) + if not comparison: + errors.append("performance comparison evidence is required") + elif not (item_dir / comparison).exists(): + errors.append(f"performance comparison evidence missing: {comparison}") + else: + comparison_text = (item_dir / comparison).read_text(encoding="utf-8", errors="replace") + fields = _comparison_fields(comparison_text) + required_comparison_fields = ( + "Same measurement command/procedure", + "Same metric definitions", + "Same dataset/load profile", + "Environment materially equivalent", + "Result", + "Acceptance criteria satisfied", + ) + for field in required_comparison_fields: + if not fields.get(field): + errors.append(f"performance comparison missing field: {field}") + for field in required_comparison_fields[:4]: + value = fields.get(field) + if value and value not in {"YES", "NO"}: + errors.append(f"performance comparison invalid equivalence value for {field}: {value}") + result = fields.get("Result") + if result and result not in {"IMPROVED", "NEUTRAL", "REGRESSED", "INCOMPARABLE"}: + errors.append(f"performance comparison invalid result: {result}") + acceptance = fields.get("Acceptance criteria satisfied") + if acceptance and acceptance not in {"YES", "NO"}: + errors.append(f"performance comparison invalid acceptance value: {acceptance}") + equivalent = all(fields.get(field) == "YES" for field in required_comparison_fields[:4]) + if not equivalent and result and result != "INCOMPARABLE": + errors.append("performance incomparable conditions cannot claim a comparable result") + + return errors + + +def main(argv: list[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else argv + if len(argv) != 1: + print("usage: verify_refactor_work_item.py ") + return 2 + errors = verify_work_item(Path(argv[0])) + if errors: + print("REFACTOR WORK ITEM VERIFICATION: FAIL") + for error in errors: + print(f"- {error}") + return 1 + print("REFACTOR WORK ITEM VERIFICATION: PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())