refactor: 분리되어 관리하고 있던 문서 시스템을 하나로 통일

This commit is contained in:
DongHyeonka
2026-09-04 18:56:01 +09:00
parent 4b9e7148b5
commit 43bccd08a8
121 changed files with 2861 additions and 534 deletions
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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/<project>/<item>/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/<project>/<item>/`.
9. Run `scripts/verify-refactor-work-item.py <item-dir>` 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.
@@ -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.
@@ -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.
@@ -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/<project>/<item>/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.
@@ -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.
@@ -0,0 +1,35 @@
# Refactoring WorkItem Contract
`<분석 대상 저장소>/refactor-queue.yaml` determines execution order. `docs/<프로젝트>/refactor/<project>/<item>/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.
@@ -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
@@ -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를 남기기 위한 것이다. 특정 문장을 복사하거나 특정 필자의 어조를 목표로 삼지 않는다.
@@ -0,0 +1,7 @@
# Korean Technical Writing Research
한국어 엔지니어링 글의 편집 기준을 만들기 위한 공개 자료 조사 기록을 둔다.
목적은 특정 회사나 필자의 문체를 복제하는 것이 아니다. 여러 기술 블로그에서 반복되는 **문제 제시, 근거 전개, 요구사항 명시, 측정 결과 연결, 한계 표기, 표/그림 사용 방식**을 추출해 `humanizing-korean-tech-writing` Skill에 반영한다.
새 조사 결과는 날짜별 corpus note에 먼저 기록하고, 여러 출처에서 반복되는 패턴만 Skill의 durable rule로 승격한다.
@@ -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`로 값을 다시 잰다.
@@ -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.
@@ -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.
@@ -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.
@@ -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) {
@@ -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` | 설명의 깊이와 말투 |
@@ -108,3 +108,16 @@
- [ ] 게시 후 공개 페이지를 열어 표·코드·그림이 의도대로 나오는지 봤다
마지막 항목을 건너뛰지 않는다. 저장은 통과해도 공개 화면에서 다르게 보이는 경우가 있다.
## 분석에서 뽑아 쓸 때 (document-detail 계약)
- [ ] 이 글감의 `readiness` 가 글을 써도 되는 상태인가
- [ ] 모든 실질 주장이 분석·출처·증거 앵커 하나로 되짚어지는가
- [ ] 추론을 관측한 것처럼 적지 않았는가
- [ ] 로컬·테스트에서 본 것을 운영 사실로 올리지 않았는가
- [ ] Case 가 개념 설명이 아니라 구체적인 사건·검증 절차인가
- [ ] Reference 가 짝이 되는 Case 의 서사를 통째로 되풀이하지 않는가
- [ ] Decision 에 근거가 하나 이상 있고, 무엇을 보고 정했는지가 적혀 있는가
- [ ] 지어낸 경험·실패·동기·감정이 없는가
- [ ] 그림이 실제 asset 파일을 가리키고, 있어야 할 이유가 있는가
- [ ] 그림이 관측하지 않은 사건을 만들어 내지 않았는가
@@ -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
<project>
TOPIC
<Topic title>
<topic-slug>
├── 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.
@@ -0,0 +1,49 @@
---
id: <Studio 가 준 uuid. 아직 없으면 빈 값>
kind: CASE
slug: <slug>
title: <제목>
topic: <주제 이름>
project: <프로젝트 이름>
status: 게시 전
studio: "<편집 화면 주소. 아직 없으면 빈 값>"
lastVerifiedOn: <실제로 확인한 날 또는 빈 값>
assets:
- key: <본문의 :::evidence key 와 같은 값>
file: <../../../final/assets/… 상대 경로>
evidence:
- <../../../final/evidence/raw/… 상대 경로>
---
# <title>
<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 -->
@@ -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 -->
@@ -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>
@@ -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>
@@ -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>
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/analyzing-codebase-for-tech-log
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/deriving-tech-log-root-tree
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/refactoring-from-analysis
+37 -10
View File
@@ -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` 를 서버가
준 키로 바꾼다.
+21 -21
View File
@@ -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 주제 논지·축 결론의 출처

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

+9
View File
@@ -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을 우선한다.
+25
View File
@@ -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 |
|---|---|---|---|
## 아직 단정하지 않는 것
+60
View File
@@ -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를 연결한다.
+13
View File
@@ -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>"
}
+27
View File
@@ -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
+85
View File
@@ -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>`
+7
View File
@@ -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>` |
+30
View File
@@ -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
}
@@ -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": {}
}
+213 -47
View File
@@ -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을 구분하는 기준"
]
}
]
}
+89 -89
View File
@@ -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``25102=13` · `2221002=120` · `202210002=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`(가장 깊은 페이지, 커서 = visible20). 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`(가장 깊은 페이지, 커서 = visible20). 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)

Some files were not shown because too many files have changed in this diff Show More