init: llm-wiki-haness 하네스 설계

This commit is contained in:
DongHyeonka
2026-07-24 14:21:35 +09:00
parent 42bf3db4fd
commit 6c53ded9cb
2436 changed files with 194486 additions and 1 deletions
@@ -0,0 +1,307 @@
---
title: branch / experiment-nplus1-feed-api-replay (11-stage real DB and HTTP replay)
source_type: branch-note
status: raw
id: BR-NPLUS1-PRESENTATION-PREP-002
kind: project-work-item
project: nplus1-presentation-prep
work_item: WI-NPLUS1-PRESENTATION-PREP-002
inherits:
- DEC-NPLUS1-PRESENTATION-PREP-LEARNING-001@1
- DEC-NPLUS1-PRESENTATION-PREP-SUBSTRATE-001@1
- DEC-NPLUS1-PRESENTATION-PREP-EVIDENCE-001@1
- DEC-NPLUS1-PRESENTATION-PREP-SCOPE-001@1
refines: []
overrides: []
depends_on: [WI-NPLUS1-PRESENTATION-PREP-001]
contract_packet: 1
branch: experiment-nplus1-feed-api-replay
git_branch: lab/nplus1-api-replay
parent_branch:
related_projects: [nplus1-presentation-prep, ca-skeleton]
tags: [branch, nplus1-presentation-prep, persistence, testing, api-design, postgresql, hands-on-lab]
created: 2026-07-15
target_merge:
status_label: review
evidence_grade: locally-verified
contract_packet_sha256: 88f5e6fd5ec219c6017f8213d076cde1803e63e62f978fe926f86ddcbc616f41
---
# branch: experiment-nplus1-feed-api-replay
> Layer: `raw/branch-notes/` — 기존 [[raw/branch-notes/experiment-nplus1-highlight-feed]]의 L1~L16/Crown/L12 결과를, 실제 PostgreSQL과 HTTP로 한 단계씩 재현할 수 있게 만든 11-checkpoint replay 브랜치다.
> 실제 Git branch는 `lab/nplus1-api-replay`다. wiki slug는 파일명 규칙에 맞춘 별도 식별자다.
<!-- section-id: branch-parent -->
## 부모 (필수)
- [[raw/project-notes/nplus1-presentation-prep]]
- 관련 선행 작업: [[raw/branch-notes/experiment-nplus1-highlight-feed]] — 각 랩의 원래 문제·측정·해법 사슬을 소유한다. 이 노트는 그 결과를 checkout 가능한 API/DB 학습 경로로 만드는 작업만 소유한다.
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: 11개 replay tag와 guide mapping을 고정하고, clean clone/worktree에서 Compose·HTTP·PostgreSQL smoke 및 full-check 상태를 재검증하며 lab profile의 deployment 비활성 증거를 기록한다.
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-NPLUS1-PRESENTATION-PREP-LEARNING-001@1` | Measure→Break→Diagnose→Fix→Re-measure→Generalize를 랩 완료 루프로 사용한다. | 11개 checkout point마다 동일한 reset→HTTP→DB 관찰 절차를 제공한다. | [[raw/project-notes/nplus1-presentation-prep]] |
| `DEC-NPLUS1-PRESENTATION-PREP-SUBSTRATE-001@1` | ca-tmpl production substrate를 사용하고 학습 API·측정 경로는 profile과 sibling 경로로 격리한다. | `/api/lab/**`와 marker-owned fixture를 `lab` profile에 한정한다. | [[raw/project-notes/nplus1-presentation-prep]] |
| `DEC-NPLUS1-PRESENTATION-PREP-EVIDENCE-001@1` | local·Testcontainers 결과는 locally-verified로만 기록하고 prod evidence로 승격하지 않는다. | Compose/Testcontainers 결과를 `locally-verified`로만 기록한다. | [[raw/project-notes/nplus1-presentation-prep]] |
| `DEC-NPLUS1-PRESENTATION-PREP-SCOPE-001@1` | same-store CQRS-lite까지를 현재 범위로 두고 full CQRS는 ca-tmpl contract escalation 이후에만 허용한다. | Crown 1-query와 L12 2-query endpoint를 병존시킨다. | [[raw/project-notes/nplus1-presentation-prep]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
기존 branch-local 결정은 아래 `## Decision Evidence Map`의 D1~D5가 소유한다.
<!-- section-id: declared-overrides -->
### 선언한 예외
- 없음.
<!-- GENERATED: branch-contract:end -->
<!-- section-id: branch-goal -->
## 목표
마지막 Crown/L12 상태만 남은 작업 트리에서는 L1의 컬렉션 N+1부터 Crown의 통합 쿼리까지를 HTTP와 실제 DB로 순서대로 관찰하기 어렵다. 이를 11개의 독립 checkout point로 고정한다.
- 각 tag에서 Docker PostgreSQL을 띄우고 lab fixture를 reset한 뒤 API 응답과 DB row를 직접 확인한다.
- 정상 `/api/feed` 동작은 바꾸지 않고, `lab` profile에서만 학습용 `/api/lab/**` 경로를 제공한다.
- Crown의 1-query read와 L12의 same-store CQRS-lite 2-query read를 같은 것으로 포장하지 않고, 별도 endpoint와 문서로 비교 가능하게 둔다.
- 이슈: 사용자 요청 — N+1 랩을 실 API/DB로 단계별 학습
- PR: 없음 (로컬 replay branch)
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- L1, L2, L3, L4, L5, L6, L14, L15, L16, Crown, L12 순서의 정확히 11개 commit/tag.
- lab profile, marker-safe fixture, HTTP 관찰 endpoint, PostgreSQL 확인 절차와 단계별 가이드.
- Crown 및 L12의 실제 PostgreSQL/Testcontainers 검증과 이력 tag의 L1 smoke 검증.
### 제외 범위
- `/api/feed`의 production 계약 또는 기본 보안 정책 변경.
- L12를 별도 read store·outbox 동기화가 있는 Full CQRS로 확장.
- 프로덕션 배포, 부하/latency SLA, 성능 수치의 운영 일반화.
## 근거
| Source | 정당화하는 결정 |
|---|---|
| [[raw/official-docs/cqrs-pattern-azure-architecture-center]] | D3: 같은 저장소에서 read/write 논리를 분리하는 CQRS-lite와 별도 저장소 CQRS를 구분한다. |
| [[raw/official-docs/spring-data-jpa-projections-spring-official]] | D3: application 반환용 DTO/projection을 통해 aggregate hydration과 read shape를 분리하는 선택지를 뒷받침한다. |
| [[raw/official-docs/test-taxonomy-testcontainers-official]] | D4: in-memory 대체물이 아닌 Docker의 실제 PostgreSQL로 integration evidence를 얻는 선택을 뒷받침한다. |
## TODO
- [x] 11개 replay commit/tag를 사용자 지정 학습 순서로 고정 — 등급: `actually-implemented`
- [x] `lab` profile에서 실제 DB reset 및 HTTP feed 관찰 경로 제공 — 등급: `actually-implemented`
- [x] Crown/L12 최신 상태의 Docker HTTP + PostgreSQL smoke 수행 — 등급: `locally-verified`
- [x] L1 historical tag의 독립 Docker HTTP smoke 수행 — 등급: `locally-verified`
- [x] 누적 focused Gradle suite 및 dependency/public-path/env verifier 수행 — 등급: `locally-verified`
- [ ] 전체 `./gradlew check`를 branch 변경과 무관한 base architecture failure 없이 통과 — 등급: `needs-confirmation` (아래 §검증 기록 참조)
## 진행 중 메모
- application repository의 replay branch는 `54cf7e7` / `nplus1-replay-l12`까지 tag가 완료된 상태다.
- full `check`의 유일한 base failure는 별도 수정 범위로 남겼다. 이 노트의 evidence grade는 local Docker/Gradle 검증까지만 나타낸다.
## 결정 사항
- 2026-07-15 D1: 학습 순서를 원래 구현 시간순이 아니라 `L1 → L2 → L3 → L4 → L5 → L6 → L14 → L15 → L16 → Crown → L12`의 11개 tag로 고정한다. / 이유: 사용자가 각 commit으로 이동해 API/DB를 직접 관찰해야 한다. / 검토한 대안: 마지막 코드 하나와 문서만 제공. / 근거: 사용자 요구; 구체 tag 순서는 외부 자료가 정하지 않으므로 `UNSUPPORTED_IMPL_DECISION`.
- 2026-07-15 D2: 공개 학습 reset은 `lab` profile의 `/api/lab/**`에만 두고, anonymous access는 `lab:reset` 하나에만 허용한다. / 이유: 실제 HTTP 재현은 가능해야 하지만 정상 profile의 feed/권한 정책을 약화하면 안 된다. / 검토한 대안: `/api/feed`에 reset/debug 파라미터 추가 또는 lab profile 전체 anonymous 허용. / 근거: D4 및 사용자 범위; profile/permission의 구체 모양은 `UNSUPPORTED_IMPL_DECISION`.
- 2026-07-15 D3: Crown은 1 native query API, L12는 same-store CQRS-lite 2-query read port API로 병존시킨다. / 이유: 쿼리 수 최소화와 application read-model 분리는 서로 다른 선택지다. / 검토한 대안: L12가 Crown endpoint를 조용히 대체. / 근거: `AZURE-CQRS-C2`, `AZURE-CQRS-C3`, `SPRING-PROJ-C4`.
- 2026-07-15 D4: Testcontainers 검증에 더해 fresh Docker Compose PostgreSQL에서 HTTP response와 SQL row count를 확인한다. / 이유: test-only assertion으로는 사용자가 직접 API/DB를 따라 보는 목표를 충족하지 못한다. / 검토한 대안: integration test 결과만 보관. / 근거: `TC-OFFICIAL-C1`, `TC-OFFICIAL-C3`, `TC-OFFICIAL-C5`.
- 2026-07-15 D5: Hibernate `addScalar`를 Java SQL compile-time checker로 설명하지 않는다. / 이유: 이는 native-query result extraction의 runtime type mapping이며 SQL 문법/컬럼 존재성은 실행 시점에 검증된다. / 검토한 대안: `addScalar`가 SQL 안전성을 보장한다고 문서화. / 근거: 구현 관찰; `UNSUPPORTED_IMPL_DECISION`.
## 결정-근거 매핑
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | 11개의 checkout 가능한 learning checkpoint | 사용자가 단계별 API/DB 관찰을 원할 때; 단일 현재 상태만 필요하면 하나의 branch 상태로 충분 | User request; `UNSUPPORTED_IMPL_DECISION` | user-scoped requirement | history를 rewrite하면 tag/문서 매핑도 함께 갱신해야 함 |
| D2 | lab-only reset/API/anonymous boundary | 로컬 학습 profile일 때만; 정상 runtime에서는 lab bean/route 자체를 등록하지 않음 | `TC-OFFICIAL-C1`, `TC-OFFICIAL-C3`; `UNSUPPORTED_IMPL_DECISION` | official-vendor-doc + unit/HTTP local verification | lab profile을 production에 실수로 활성화하지 않는 운영 절차는 별도 확인 필요 |
| D3 | Crown 1-query와 L12 2-query CQRS-lite 병존 | endpoint-specific optimization을 비교할 때; 별도 store가 필요하면 Full CQRS contract를 별도 설계 | `raw/official-docs/cqrs-pattern-azure-architecture-center.md#AZURE-CQRS-C2`, `#AZURE-CQRS-C3`, `raw/official-docs/spring-data-jpa-projections-spring-official.md#SPRING-PROJ-C4` | official-vendor-doc | L12은 keyset/visibility를 Crown처럼 모두 포함하지 않음 |
| D4 | real PostgreSQL HTTP+DB smoke | SQL dialect, container wiring, public response shape를 함께 확인할 때 | `raw/official-docs/test-taxonomy-testcontainers-official.md#TC-OFFICIAL-C1`, `#TC-OFFICIAL-C3`, `#TC-OFFICIAL-C5` | official-vendor-doc + local runtime evidence | production traffic/permissions을 검증한 것은 아님 |
| D5 | `addScalar` compile-time 보장 부정 | native query mapping 설명 시 항상 적용 | Local code/runtime behavior; `UNSUPPORTED_IMPL_DECISION` | locally verified implementation fact | native SQL의 syntax/plan error는 CI compile이 아니라 query execution에서 발견됨 |
## 구현 가이드
### 1. 고정 replay checkpoint
> **Trace**: D1. 단계 순서와 tag 이름은 사용자 학습 요구에서 정한 `UNSUPPORTED_IMPL_DECISION`이다. 각 checkpoint의 원래 N+1 원인/해결 설명은 [[raw/branch-notes/experiment-nplus1-highlight-feed]]를 참조한다.
| 순서 | Stage | Commit | Tag | checkout 후 주 관찰점 |
|---:|---|---|---|---|
| 1 | L1 | `e68dd67` | `nplus1-replay-l1` | lazy highlights 컬렉션 N+1 |
| 2 | L2 | `138eb67` | `nplus1-replay-l2` | EAGER ToOne fetch 수 |
| 3 | L3 | `dc7495c` | `nplus1-replay-l3` | two-bag fetch의 예상 실패 |
| 4 | L4 | `f257196` | `nplus1-replay-l4` | collection fetch join + paging의 in-memory paging |
| 5 | L5 | `be2a123` | `nplus1-replay-l5` | batch fetch paging |
| 6 | L6 | `cad3c15` | `nplus1-replay-l6` | DTO scalar projection, entity load 0 |
| 7 | L14 | `26b196c` | `nplus1-replay-l14` | window query로 parent별 Top-N |
| 8 | L15 | `8ff0771` | `nplus1-replay-l15` | keyset cursor |
| 9 | L16 | `3310897` | `nplus1-replay-l16` | viewer visibility + keyset |
| 10 | Crown Task 4 | `3f0b82e` | `nplus1-replay-crown` | Top-N + keyset + visibility one query |
| 11 | L12 | `54cf7e7` | `nplus1-replay-l12` | CQRS-lite projection port, parent/child two queries |
각 stage의 세부 실습은 application repository의 `docs/superpowers/plans/*nplus1*lab-guide.md`, `docs/notes/L*.md`, `docs/notes/crown.md`를 사용한다. `L12`를 마지막 tag로 둔 것은 원래 번호가 아니라 이 replay의 학습 순서다.
### 2. lab profile의 API/권한 경계
> **Trace**: D2, D4 / `TC-OFFICIAL-C1`, `TC-OFFICIAL-C3`.
>
> - **UNSUPPORTED_IMPL_DECISION**: `lab` profile과 `lab:reset` permission 이름, marker 소유 방식, anonymous allowlist의 구체 구현은 외부 자료가 정하지 않는다. 정상 profile과 분리된 학습 reset 및 최소 권한 허용이라는 사용자 범위를 우선했다.
| 항목 | replay 계약 |
|---|---|
| profile | lab controller/usecase/fixture는 local `lab` profile에만 등록된다. |
| normal runtime | 기존 advisor를 유지하고 lab usecase/route를 등록하지 않는다. `/api/feed`의 기존 계약을 바꾸지 않는다. |
| reset authorization | `POST /api/lab/feed:reset?count=N``lab:reset`을 선언한다. lab profile의 security advisor는 `AnonymousAuthenticationToken`을 인식해 anonymous에는 `lab:reset` 하나만 허용하고, 다른 permission은 거부한다. unit+HTTP로 확인했다. |
| fixture ownership | `created_by = nplus1-lab` marker 데이터만 삭제/재생성한다. |
| input guard | `page=10001`은 HTTP 400, `VALIDATION_FAILED`다. |
### 3. Crown과 L12의 의도적 차이
> **Trace**: D3 / `AZURE-CQRS-C2`, `AZURE-CQRS-C3`, `SPRING-PROJ-C4`.
| 경로 | 목적 | SQL 관찰값 | 포함 범위 |
|---|---|---|---|
| `GET /api/lab/feed` at Crown/L12 | Crown Task 4 최적화 | `CROWN_TASK4_ONE_QUERY`, prepared statement 1, entity load 0 | visible parent keyset + Top-3 child를 하나의 native query로 읽음 |
| `GET /api/lab/feed/read-model` at L12 | CQRS-lite read model | parent projection 1 + child Top-3 query 1, integration test에서 entity/collection hydration 0 | same store의 application query port; Crown을 대체하지 않음 |
L12의 native child mapping에 사용한 `addScalar`는 runtime 결과 타입 매핑이다. SQL 문자열의 문법, table/column 이름, plan을 Java compiler가 검증하게 만드는 기능은 아니다. 따라서 native SQL은 Testcontainers/실제 PostgreSQL 실행으로 검증한다.
## 검증 기록
### Docker HTTP + PostgreSQL smoke — final L12 tag
fresh `nplus1-final` Compose stack에서 `nplus1-replay-l12`(`54cf7e7`)을 실행했다.
| 수행 | 결과 | 등급 |
|---|---|---|
| `POST /api/lab/feed:reset?count=100` | `success=true`, feed item 100개, highlight 1,961개 | `locally-verified` |
| `GET /api/lab/feed?size=20&viewer=lab-user-008` | item 20개, `strategy=CROWN_TASK4_ONE_QUERY`, `prepared=1`, `entityLoads=0`, parent당 Top-3 최대 3개 | `locally-verified` |
| `GET /api/lab/feed/read-model?page=0&size=20` | `success=true`, item 20개, parent당 Top-3 최대 3개 | `locally-verified` |
| `GET` with `page=10001` | HTTP 400, `VALIDATION_FAILED` | `locally-verified` |
| PostgreSQL `psql` | `created_by = nplus1-lab` row count 100 | `locally-verified` |
| anonymous authorization | `AnonymousAuthenticationToken``lab:reset`만 허용하고 다른 permission은 거부하는 unit+HTTP 검증 | `locally-verified` |
### Historical L1 smoke
fresh stack에서 `nplus1-replay-l1`(`e68dd67`)을 별도로 실행했다.
| 수행 | 결과 | 등급 |
|---|---|---|
| reset `count=10` | `success=true` | `locally-verified` |
| feed request | item 10개, `strategy=L1_LAZY_HIGHLIGHTS`, `prepared=24`, `collectionFetch=10` | `locally-verified` |
### Focused regression suite
다음 filtered cumulative suite는 `BUILD SUCCESSFUL`, test result의 `failures=0`, `errors=0`이었다.
```bash
cd /home/donghyeon/workspace/ca-tmpl-nplus1-api/src
./gradlew spotlessApply :application-core:test --tests dev.caskeleton.application.feed.GetFeedReadModelUseCaseTest --tests dev.caskeleton.application.feed.lab.LabFeedUseCaseTest --tests dev.caskeleton.application.feed.lab.LabFeedCursorTest :adapter:inbound:web:test --tests dev.caskeleton.adapter.inbound.web.controller.lab.LabFeedControllerTest :app-bootstrap:test --tests dev.caskeleton.bootstrap.contract.DeveloperExperienceContractTest --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedReadModelUseCaseIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedCrownIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedVisibilityIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedKeysetIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedTopNIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedProjectionIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedBatchFetchIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedFetchJoinPagingIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedToOneEagerIT --tests dev.caskeleton.adapter.outbound.jpa.feed.FeedMultipleBagIT --tests dev.caskeleton.bootstrap.lab.LabFeedPersistenceIT
```
명령에 포함된 L1~L6, L14~L16, Crown, L12의 stage integration test class는 모두 통과했다.
다음 verifier도 통과했다.
```bash
./gradlew verifyCleanArchitectureDependencies verifyPublicPathSnapshot verifyEnvKeys
```
### Full check의 기준선 실패
`./gradlew check`에는 정확히 하나의 잔여 architecture failure가 있었다. 이는 이 replay branch가 수정하지 않은 base commit `6f0b0d6``IdempotencyRecordEntity.requestHash`에 있는 `columnDefinition = "char(64)"`가 vendor-neutral entity rule을 위반한 것이다. 따라서 이 노트는 full check를 green이라고 주장하지 않으며, 재현 브랜치의 실패로 귀속하지 않는다.
## 엣지·실패·의존
- **실패·엣지 경로**: lab profile 밖에서 lab reset을 사용하려 하면 endpoint/usecase가 등록되지 않아야 한다. lab profile에서도 anonymous access는 `lab:reset` 하나에만 한정되고, 다른 permission은 거부된다.
- **실패·엣지 경로**: native query의 `addScalar` 타입이 결과와 맞지 않거나 SQL이 잘못되면 compile이 아니라 integration/runtime 실행에서 실패한다.
- **실패·엣지 경로**: L12 read model이 Crown과 동등한 visibility/keyset solution이라고 가정하면 안 된다. L12은 same-store read port의 2-query projection이고 Crown 최적화 endpoint는 유지된다.
- **다른 계약 의존**: [[raw/branch-notes/experiment-nplus1-highlight-feed]]의 각 랩 의미와 [[raw/branch-notes/feature-application-query-bypass-contract]]의 CQRS-lite/read-port 경계를 소비한다. Full CQRS physical read store는 후자 D2의 escalation 범위다.
- **검증 환경 의존**: `DeveloperExperienceContractTest`가 root `AGENTS.md` 존재를 요구해 replay worktree에 일시적인 ignored bridge를 두고 test 후 제거했다. 이는 application commit에 포함되지 않는다.
## 검증해야 할 주장
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| 11개 tag가 다른 machine에서도 compose/API guide대로 재현된다 | 로컬 Docker, Gradle cache, port 상태에 의존 | 깨끗한 clone/worktree에서 tag별 compose smoke 수행 | `needs-confirmation` |
| lab profile이 배포 환경에서 활성화되지 않는다 | local profile boundary는 production deployment policy를 증명하지 않음 | deployment manifest/env registry audit | `needs-confirmation` |
| `IdempotencyRecordEntity` failure를 수정한 뒤 full `check`가 green이 된다 | 현재 branch가 해당 base failure를 고치지 않음 | 별도 base-fix branch에서 full check 실행 | `planned` |
## 마주친 문제
- `DeveloperExperienceContractTest`가 checkout worktree root의 `AGENTS.md`를 요구했다.
- 원인: replay worktree의 contract discovery 조건.
- 시도: test 실행 중 ignored bridge를 일시적으로 제공.
- 해결: test 통과 후 bridge를 삭제했고 application history에는 포함하지 않았다.
- 별도 오류 노트: 아래 Cluster의 raw error 노트.
- full `check``IdempotencyRecordEntity.requestHash` vendor-specific `columnDefinition`에서 멈췄다.
- 원인: base `6f0b0d6`에 이미 존재한 rule violation.
- 해결: replay scope 밖으로 남기고 base failure로 명시했다.
## 묶음
<!-- GENERATED: interviews:start -->
- [[raw/interviews/crown-one-query-vs-cqrs-lite-read-model]]
- [[raw/interviews/native-query-addscalar-runtime-validation]]
<!-- GENERATED: interviews:end -->
<!-- GENERATED: errors:start -->
- [[raw/errors/developer-experience-contract-agents-bridge-2026-07-15]]
- [[raw/errors/idempotency-column-definition-base-check-failure-2026-07-15]]
<!-- GENERATED: errors:end -->
<!-- GENERATED: blog-topics:start -->
- [[raw/blog-topics/nplus1-lab-checkoutable-api-replay-2026-07-15]]
<!-- GENERATED: blog-topics:end -->
아래 raw leaf는 생성되었고, 이 branch를 `## Parent` upward link로 가진다. 두 번째 블로그 주제는 별도 raw 문서로 추출하지 않았다.
### Sub-branches (세부 작업)
- 없음 — 11개 checkpoint는 하나의 replay branch history로 관리한다.
### 오류 기록 (이 branch 작업 중 발생)
- [[raw/errors/developer-experience-contract-agents-bridge-2026-07-15]] — replay worktree root discovery와 임시 ignored bridge.
- [[raw/errors/idempotency-column-definition-base-check-failure-2026-07-15]] — base `6f0b0d6`의 vendor-neutral entity rule failure.
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- [[raw/interviews/crown-one-query-vs-cqrs-lite-read-model]] — one-query optimization과 same-store CQRS-lite를 구분하는 기준.
- [[raw/interviews/native-query-addscalar-runtime-validation]] — native mapping type과 SQL compile-time validation의 차이.
### job-posting tie-ins (이 작업에서 파생된 글감)
- [[raw/blog-topics/nplus1-lab-checkoutable-api-replay-2026-07-15]] — 테스트 코드를 실제 API/DB 학습 환경으로 변환한 방법.
- `raw/blog-topics/crown-query-and-cqrs-lite-boundary-2026-07-15.md` — 이번 capture에서는 별도 raw 문서로 추출하지 않았다.
- derived blog: 생성 전. canonical 검증 후 `wiki/blog/` 후보를 결정한다.
## 관련 일일 노트
- 해당 없음 — 이 캡처 시점에는 별도 daily-note를 만들지 않았다.
## 완료 후 정리
- PR 링크: 없음.
- 리뷰 메모: 11개 replay tag와 L1/final Docker smoke, focused suite, architecture/public-path/env verifier를 local에서 확인했다.
- 머지 결과 / 배포 환경: local Docker Compose + local PostgreSQL만 검증. production 배포 검증 없음.
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
- `actually-implemented` 항목: lab-profile replay API, 11 stage tag catalog, Crown/L12 분리 경로.
- `locally-verified` 항목: L1 historical smoke, final Docker HTTP/PostgreSQL smoke, filtered Gradle suite/verifier.
- `prod-verified` 항목: 없음.
- **추출하지 않을 항목** (planned / documented-only / abandoned): base `IdempotencyRecordEntity` rule failure 해결, production profile/deployment verification.
@@ -0,0 +1,378 @@
---
title: branch / experiment-nplus1-highlight-feed (N+1 발표 준비 — 라이너 하이라이트 피드 랩)
source_type: branch-note
status: raw
id: BR-NPLUS1-PRESENTATION-PREP-001
kind: project-work-item
project: nplus1-presentation-prep
work_item: WI-NPLUS1-PRESENTATION-PREP-001
inherits:
- DEC-NPLUS1-PRESENTATION-PREP-LEARNING-001@1
- DEC-NPLUS1-PRESENTATION-PREP-SUBSTRATE-001@1
- DEC-NPLUS1-PRESENTATION-PREP-EVIDENCE-001@1
- DEC-NPLUS1-PRESENTATION-PREP-SCOPE-001@1
refines: []
overrides: []
depends_on: []
contract_packet: 1
branch: experiment-nplus1-highlight-feed
parent_branch:
git_branch: lab/nplus1-highlight-feed
related_projects: [nplus1-presentation-prep, ca-skeleton]
tags: [branch, nplus1-presentation-prep, persistence, testing, hibernate, postgresql, hands-on-lab]
created: 2026-07-08
target_merge:
status_label: in-progress
contract_packet_sha256: b4ade9d64e34469ba11ac46f670b6e7e3581de975e89799cf7b73e8ad516312b
---
# branch: experiment-nplus1-highlight-feed — N+1 발표 준비 (Video 1 랩)
> Layer: `raw/branch-notes/` — 선배 부여 3대주제 발표 준비(N+1 / 아키텍처 3종 / OAuth2-Keycloak) 중 **1번(N+1)**.
> 실제 git 브랜치: `lab/nplus1-highlight-feed` (ca-tmpl repo). 위키 파일명은 prefix 규칙상 `experiment-`.
> **목적**: 라이너 백엔드 사전과제 "하이라이트 피드 API"를 substrate로, N+1 정전(canon)을 **재현→측정→진단→해결**하며 "체화"한 발표 콘텐츠(Video 1)를 만든다. 내 역할 = 코치·설계자(스펙·랩 설계·측정 하네스; 실제 fix 코드·에러 경험은 학습자).
> `status_label`: `in-progress`
<!-- section-id: branch-parent -->
## 부모
- [[raw/project-notes/nplus1-presentation-prep]]
- **substrate 위치**: ~~별도 lab 프로젝트~~**ca-tmpl 프로덕션 모듈에 실제 제품 도메인**(2026-07-08 재결정, 아래 섹션). CLAUDE.md Template reuse #4와 일치.
- 형제(예정): keycloak 계열(주제3), 아키텍처 3종 비교(주제2)
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: L2 ToOne EAGER 격리 측정값을 확정하고, L1~L6·L14~L16·Crown·L12의 증거 등급과 사용자 commit-range review를 본문 Closure에 반영한다.
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-NPLUS1-PRESENTATION-PREP-LEARNING-001@1` | Measure→Break→Diagnose→Fix→Re-measure→Generalize를 랩 완료 루프로 사용한다. | 각 랩의 before/after·기전·다음 문제 연결을 기록한다. | [[raw/project-notes/nplus1-presentation-prep]] |
| `DEC-NPLUS1-PRESENTATION-PREP-SUBSTRATE-001@1` | ca-tmpl production substrate를 사용하고 학습 API·측정 경로는 profile과 sibling 경로로 격리한다. | feed production module과 별도 IT/sibling query 경로를 함께 유지한다. | [[raw/project-notes/nplus1-presentation-prep]] |
| `DEC-NPLUS1-PRESENTATION-PREP-EVIDENCE-001@1` | local·Testcontainers 결과는 locally-verified로만 기록하고 prod evidence로 승격하지 않는다. | 모든 측정의 환경과 evidence grade를 명시한다. | [[raw/project-notes/nplus1-presentation-prep]] |
| `DEC-NPLUS1-PRESENTATION-PREP-SCOPE-001@1` | same-store CQRS-lite까지를 현재 범위로 두고 full CQRS는 ca-tmpl contract escalation 이후에만 허용한다. | L12를 별도 physical read store 없이 구현한다. | [[raw/project-notes/nplus1-presentation-prep]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
기존 branch-local 결정은 아래 `## Decision Evidence Map`의 D-01~D-10이 소유한다.
<!-- section-id: declared-overrides -->
### 선언한 예외
- 없음.
<!-- GENERATED: branch-contract:end -->
<!-- section-id: branch-goal -->
## 목표
선배 의도 = "많이 에러 내보고 시행착오"하는 **실패주도 체화(딸깍 금지)**. 지식량이 아니라 **방법**이 딸깍과 체화를 가른다. 그래서 모든 랩은 루프로 돈다:
> 측정(Measure) → 고의로 부순다(Break) → 진단(Diagnose) → 고친다(Fix) → 재측정(Re-measure) → 일반화(Generalize)
핵심 질문:
- N+1 "정전 6종"을 **구성+테스트만** 하면 깊이있게 다룬 것인가? → **아니다.** 그건 바닥(재현)이지 천장이 아니다. 깊이 = 측정치 + 해법→문제 사슬 + 시그니처 난제 + 일반화.
- "다시 딥하게"의 정체(선배 넘는 지점) = 과제 시그니처 난제 3개: **Top-N-per-group(페이지당 3) / keyset vs OFFSET / 가시성 술어 인덱싱** → Video 2 왕관.
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- ca-tmpl 피드 도메인에서 L0~L6, L14~L16, Crown, L12의 재현·측정·해법 사슬을 기록한다.
- Hibernate Statistics, `EXPLAIN (ANALYZE, BUFFERS)`, Testcontainers 기반의 로컬 검증 결과와 실측 정정을 보존한다.
- L12의 same-store CQRS-lite 읽기 모델까지를 본 브랜치의 구현 경계로 둔다.
### 제외 범위
- 별도 물리 read store와 동기화 파이프라인을 갖는 full CQRS는 ca-tmpl 계약 개정 전에는 구현하지 않는다.
- prod 배포·운영 부하 검증은 수행하지 않았으며, 로컬·Testcontainers 결과를 prod 증거로 승격하지 않는다.
- 아직 실행하지 않은 L2 측정값은 L1 회계식에서 유도한 값으로만 유지하고 실측 완료로 간주하지 않는다.
## 근거 (필수, 최소 1개+)
| Source | 정당화하는 결정 |
|---|---|
| [[raw/official-docs/spring-data-jpa-projections-spring-official]] | D-10의 DTO constructor projection 경계와 nested join 한계를 뒷받침한다 (`SPRING-PROJ-C4`, `SPRING-PROJ-C6`). |
| [[raw/official-docs/cqrs-pattern-azure-architecture-center]] | L12에서 single database를 공유하면서 read/write logic을 분리한 CQRS-lite 경계를 뒷받침한다 (`AZURE-CQRS-C2`, `AZURE-CQRS-C3`). |
## TODO
- [x] L0·L1·L3~L6 재현 및 측정 — 등급: `locally-verified`
- [x] L14~L16과 Crown 통합 쿼리 비교 — 등급: `locally-verified`
- [x] L12 same-store CQRS-lite 읽기 모델 구현·회귀 검증 — 등급: `locally-verified`
- [ ] L2 ToOne EAGER 격리 측정을 실행해 유도값을 실측값으로 교체 — 등급: `planned`
- [ ] 사용자 커밋 뒤 spec/quality review와 발표 문서의 L12 절을 마감 — 등급: `needs-confirmation`
## 진행 중 메모
- 현재 가장 큰 미완료는 L2 실측과 사용자 커밋 이후 review다. 뒤 단계가 GREEN이어도 이 두 항목을 완료로 소급하지 않는다.
- L12는 별도 read store가 없는 CQRS-lite다. Crown의 `feed_visible` 실험 테이블을 곧바로 production full CQRS로 표현하지 않는다.
- 각 랩의 상세 수치·정정·산출물은 아래 날짜별 완료 기록이 소유하며, 이 섹션은 현재 상태만 요약한다.
## 결정 사항
- 2026-07-08 (D-01): Video 1은 L1~L6, 왕관 문제는 Video 2로 분리한다. 이유는 정전 재현·해결 사슬과 SQL/인덱스 대안 비교를 각각 독립 배송 단위로 유지하기 위해서다.
- 2026-07-08 (D-02): 랩 완료는 초록불 재현이 아니라 D1~D6 측정·기전·다음 문제 연결을 모두 충족할 때로 판정한다.
- 2026-07-20: full CQRS가 ca-tmpl의 escalation-only 계약과 충돌해, 사용자 재선택에 따라 same-store CQRS-lite로 구현 범위를 확정했다.
## 산출물 (ca-tmpl repo 내)
- 설계 스펙: `docs/superpowers/specs/2026-07-05-nplus1-presentation-prep-design.md` — 체화 엔진, 도메인/스키마(users·pages·highlights·feed_item·feed_item_mentions), 핫스팟 H1~H7, 시그니처 난제 5, 랩 L0~L16 Phase 0~4, 발표 목차 60~75분.
- Video 1 랩 플랜: `docs/superpowers/plans/2026-07-05-nplus1-video1-liner-feed-lab.md` — Task 0~9(Foundation 3 + 측정 하네스 L0 + 정전 랩 L1~L6).
- **Video 2 왕관 랩 플랜(2026-07-08 신규)**: `docs/superpowers/plans/2026-07-08-nplus1-video2-crown-topn-keyset-visibility.md` — Phase 4 시그니처 3난제 L14(Top-N-per-group: 윈도우함수 vs LATERAL vs 2단계배치)·L15(keyset vs OFFSET 깊은페이지)·L16(가시성 술어 OR vs UNION분해 vs 사전계산). 동일 D1~D6, **D2(EXPLAIN N안 대조)가 스타 지표**. 왕관 사슬: L6 미해결 "페이지당3"→L14→피드페이징→L15→정렬키+가시성 동시인덱싱→L16→CQRS(L12). Task 0(EXPLAIN N안 비교 하네스+keyset 커서 유틸) + Task 1~3(랩) + Task 4(통합 쿼리+왕관 매트릭스). Phase 2/3은 여전히 미작성(Video1 완료 후).
- 테스트 구성 체크리스트: `docs/superpowers/specs/2026-07-07-test-construction-checklist.md` (11항).
- 기존 테스트 품질 감사: `docs/superpowers/specs/2026-07-07-existing-tests-quality-audit-report.md`(Verdict PARTIAL, 성능/N+1 테스트 0개).
## 2026-07-08 개편: 깊이 게이트 D1~D6 + 해법→문제 사슬 (Video 1 플랜)
Video 1 플랜을 두 축으로 재구조화(사용자 요청):
- **축 A — 깊이 게이트 D1~D6**(랩마다 채워야 "완료"):
1. D1 before/after 측정치(쿼리수·p50/p99·전송 행/바이트·힙·(해당시)커넥션홀드)
2. D2 EXPLAIN(ANALYZE,BUFFERS) 캡처
3. D3 재현 커밋(git 브랜치=영상 챕터)
4. D4 "왜 터지고 왜 고쳐지나" 기전 1문단
5. D5 이 fix가 낳는 다음 문제(사슬 고리)
6. D6 N 스케일 곡선 {10,100,1k,10k}
- **측정 하네스(Task 3/L0)를 D1의 6 metric 전부 뽑도록 확장**: `MetricRow`(record) + `Bench.measure`(워밍업→GC→p50/p99 반복측정→직렬화 바이트 근사→힙 델타) + `runCurve`(N축 자동 표). 정직성: 쿼리수·행수·지연=정확, 바이트·힙=근사, 커넥션홀드=L7(OSIV) Video2.
- **축 B — 해법→다음문제 사슬(척추)**: 순서대로 하면 *한 랩의 해법이 다음 랩의 문제를 낳는다*.
- `순진한 조회 → L1 컬렉션 N+1 / L2 EAGER ToOne N+1 → (해법:전부 fetch join) → L3 MultipleBagFetchException/카테시안 → (해법:하나만 fetch) → L4 페이징 HHH000104 인메모리 → (해법:@BatchSize+배치IN) → L5 해결! 그러나 엔티티 과적재 → (해법:DTO 프로젝션) → L6 해결! 그러나 "페이지당 3"(Top-N) 미해결 → L14(Video2 왕관)/CQRS L12`
- L3·L4는 "성공한 해법"이 아니라 **순진한 fix 시도의 실패**이며, 그 실패가 다음 고리를 만든다. L5가 처음으로 제대로 풀지만 그조차 L6의 비용을 남긴다.
- **완료 공식**: `Video1 완료 = (모든 랩 D1~D6) AND (§0.2 사슬이 D5로 연결) AND (의사결정 매트릭스)`. "6랩 초록불 재현"만으론 미완료.
- **추가(§0.3/§0.4)**: ① ORM(JPA) 조회 문제 **전수 커버리지 맵**(17종: 1~7=Video1 깊이, 8~12=Video2, 13~17=미포함) — "ORM 조회 문제를 깊이 다루는가?"에 대한 자기감사. ② **DB 심화 학습 포인트**(ORM 아래 레이어: 인덱스 선두컬럼·커버링·partial, 플래너 EXPLAIN 노드, 조인 nested/hash/merge=N+1은 앱레벨 nested loop, LATERAL, keyset, 윈도우함수, Little's Law, MVCC, IDENTITY vs SEQUENCE, WAL/VACUUM). ★=랩에서 직접 / ◇=랩 밖 독립 심화. 프론티어 원본 목록 F1~F9 중 채택 4개(F1 쓰기N+1/F3 리액티브/F4 자작탐지기/F8 CQRS)=L9~L12(Video2), 미채택: F2 카테시안(→L3/L4로 흡수)·F5 바이트코드·F6 L2캐시·F7 커넥션풀(→L7로 흡수)·F9 다형성.
## 결정-근거 매핑
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|
| D-01 | Video 1 스코프 = 정전(canon) L1~L6만, 왕관(Top-N/keyset/가시성)은 Video 2로 분리 | 스펙 §8 안전밸브(Phase 0+1 = 무조건 배송 완결편), 플랜 line 245(Phase 4 = 선배 넘는 하이라이트→Video2) | Strong(설계 문서에 명시) | Video 1만으론 "주제 전체 깊이"가 아님 — 사용자에게 명확히 전달됨 |
| D-02 | 랩 완료 판정 = 깊이 게이트 D1~D6 전부 충족(초록불 아님) | 사용자 제공 6-체크리스트, 스펙 §1 DoD(재현 커밋+before/after+EXPLAIN), 테스트 체크리스트 8항(성능=행동) | Strong | 게이트가 형식적 체크로 전락하면 딸깍 회귀 — D4/D5(기전·사슬)가 방지 |
| D-03 | 발표 척추 = "해법→다음문제 사슬"(전이가 콘텐츠) | 스펙 §3.2 핫스팟, §6-5 해결 투어, 플랜 §0.2 사슬도 | Medium(논리적 인과는 견고, 실측 미완) | 각 전이가 실제로 그 순서로 터지는지는 랩 실행으로 증명 필요(→Claims) |
| D-04 | 측정 하네스(L0)를 6 metric 전부 뽑도록 확장 | 플랜 Task3 `Bench.measure`/`MetricRow`/`runCurve` | Medium(코드 골격만, 미실행) | 바이트·힙은 근사치라 신호대잡음비 미검증(→Claims) |
| D-05 | Substrate = ca-tmpl production module의 feed 도메인, 학습 측정은 sibling IT·query와 `lab` profile로 격리 | 2026-07-08 substrate 재결정, 본문 §SUBSTRATE 재결정, replay branch D2 | Strong(구현·local 검증) | 실험 경로가 production contract를 대체하지 않도록 기존 naive path와 profile 경계를 유지 |
| D-06 | L6(DTO)도 "페이지당 3(Top-N)"은 못 풂 → L14 진입점 | 플랜 line 226(Top-N 제한은 L14에서 제대로), 스펙 §3.3-1(fetch join은 그룹 아닌 행에 LIMIT), **L6 실측 childRows=1509(페이지 20 부모의 하이라이트 전량, top-3=60 훨씬 초과)** | **Strong(실측·GREEN 2026-07-13)** | 단순 `IN` 프로젝션은 그룹 아닌 행에 LIMIT을 못 걸어 전량 조회 확인 → L14(윈도우/LATERAL/2단계 배치)로 |
| D-07 | L2 격리 지표 = `getEntityFetchCount()` + 엔티티별 `getEntityStatistics(<E>).getFetchCount()`(page=선형 N / user=평탄 ≤20). fix 금지(EAGER→LAZY 토글은 되돌리는 probe). L1 note의 ToOne몫 14/121/1021은 Spring Data Page count를 섞은 값 → L2는 base+count를 `2`로 분리해 순수 ToOne = **13/120/1020** 으로 정밀화 | L1 실측(collFetch 10/100/1000·prepared 25/222/2022) 회계 항등식 유도, 플랜 Task5(L2), Hibernate Statistics API(`getEntityFetchCount`/`EntityStatistics.getFetchCount`) | Medium(코드 골격 + L1 실측 유도, L2 미실행) | `getEntityFetchCount()` 내부 집계가 Hibernate 버전에 따라 컬렉션 원소 포함할 여지 → 회귀가드는 세더 무관한 `pageFetches==N` 으로 못 박음 |
| D-08 | L4 격리 지표 = **부모 `EntityStatistics.getLoadCount()`(=N, 전체 하이드레이트)** vs `returned`(=min(20,N)) → over-fetch 배수 = N/pageSize. 비용 계기 = `getThreadAllocatedBytes`(GC 견고) + p99(환경의존 상대값), **Runtime 힙델타 금지**(trim된 Npage개가 GC돼 비용 은닉). `getCollectionFetchCount()`는 join 로드 컬렉션엔 안 잡혀 L4 신호 아님. fix 금지(엔티티페이징+@BatchSize는 L5) | L4 실측(`FeedPersistenceIT.l4*`, feedItemLoaded=10/100/1000·returned=10/20/20·over-fetch 1.0/5.0/50.0×), EXPLAIN (a)조인 Limit노드 부재/(b)엔티티 Limit노드 존재, Hibernate Statistics API, `:app-bootstrap:test` GREEN | **Strong(실측·GREEN 2026-07-13)** | 경고 코드가 예상 `HHH000104`가 아니라 **`HHH90003004`**(Hib7 재번호) → 회귀가드는 코드번호 아닌 문구(`collection fetch`)로도 매칭 |
| D-09 | L5 = **첫 fix**(착상→해결, before/after). fix = 세션 설정 한 줄 `default_batch_fetch_size=100`(순진 loadFeed 코드 무변경). **격리**: 세션 전역 설정이라 `FeedPersistenceIT`에 넣으면 L1~L4 깨짐 → **새 `FeedBatchFetchIT`** 클래스로 격리(회귀 0). 스타 = `prepared`·`collectionFetch` **둘 다** `1+N → 1+ceil(N/batch)·연관`으로 붕괴 + 페이징 정상(feedItemLoaded=pageSize, L4 over-fetch 소멸) + 카테시안 없음(semi-join). fix 금지 아님(L5가 fix 랩) | L5 실측(`FeedBatchFetchIT`: prepared 5/5/23 vs L1 25/222/2022 = 87.9× 붕괴, collectionFetch 1/1/10=ceil(N/100), feedItemLoaded 10/20/20 vs L4 N, entitiesLoaded 1569), EXPLAIN (a)엔티티페이징 Limit노드 존재/(b)배치 IN semi-join 곱셈 없음, `FeedPersistenceIT` 0 fail(회귀 없음) | **Strong(실측·GREEN 2026-07-13)** | batch 크기 스윕(10/100/1000)은 property 클래스 단위라 미측정(공식 `1+ceil(N/batch)`로 유도, 실측 시 3회 실행). `getCollectionFetchCount()`가 초기화 수(=N) 아니라 fetch 연산 수(=ceil)임이 문서모델 정정 |
| D-10 | L6 = **두 번째 fix**(착상→해결, before/after). fix = DTO 프로젝션(`SELECT new <carrier>(...)` 스칼라만). L5(왕복 축)와 **직교하는 "적재 형태 축"** — 엔티티를 아예 안 만든다. **격리**: fix가 실제 쿼리라 순진 `loadFeed` 고치면 L1~L5 깨짐 → `FeedQueryAdapter`에 sibling 메서드 `loadFeedProjection` 추가(loadFeed 무변경) + 새 `FeedProjectionIT`(배치 설정 없음 — 프로젝션은 배치와 직교). 프로덕션 1파일. 스타 = `getEntityLoadCount()` **1569→0**(과적재 소멸) + prepared **상수 2**(N 무관) + collectionFetch 0 | L6 실측(`FeedProjectionIT`: entitiesLoaded 0/0/0 vs L5 1569, prepared 2/2/2 vs L1 25/222/2022·L5 5/5/23, collectionFetch 0, 형태 동치 vs 순진 loadFeed), EXPLAIN (a)부모 프로젝션 Limit 존재/(b)자식 IN semi-join, `:app-bootstrap:test` 97/97 GREEN(FeedPersistenceIT·FeedBatchFetchIT·CleanArchitectureTest 회귀 0, `QUERY_PORTS_DO_NOT_LEAK` PASS, verifyCleanArchitectureDependencies GREEN) | **Strong(실측·GREEN 2026-07-13)** | **실측 정정**: 프로젝션 EXPLAIN width(2088)가 엔티티 SELECT fi.*(1194)보다 **오히려 넓다**(users·pages 조인+PG varchar 추정치) — 프로젝션 이득은 SQL 플랜 아니라 ORM 층(entityLoadCount 0). L6도 Top-N 못 풂(childRows 1509→L14) |
## 구현 가이드
### 1. 측정 경로와 production 읽기 경로의 격리
> **Trace**: D-09의 격리 결정과 D-10 + `SPRING-PROJ-C4`(DTO constructor projection)를 따른다.
>
> - **UNSUPPORTED_IMPL_DECISION**: 정확한 테스트 클래스·메서드 이름은 외부 source가 정하지 않는 ca-tmpl 내부 trade-off다. 기존 랩의 before 경로를 보존하고 회귀를 독립 실행하기 위해 현재 이름과 sibling 구조를 유지한다.
| Anchor | 구현 계약 | 현재 증거 |
|---|---|---|
| `FeedPersistenceIT` / `FeedBatchFetchIT` / `FeedProjectionIT` | L1~L6의 before/fix 경로를 서로 덮어쓰지 않고 sibling test와 sibling query로 격리한다. | `locally-verified` — 본문 L3~L6 회귀 결과 |
| `FeedReadModelQueryPort``GetFeedReadModelUseCase``FeedReadModelQueryAdapter` | 같은 DB에서 write aggregate와 read projection logic을 분리하고, 부모 page + top-3 자식의 2-query read model을 반환한다. | `locally-verified``AZURE-CQRS-C2`, `AZURE-CQRS-C3`; 본문 L12 4 tests GREEN |
### 2. 미완료 측정의 처리
> **Trace**: D-07과 Claims To Verify #7. Supporting raw claim은 없으며 L1 실측 회계식에서 도출된 프로젝트 가설이다.
>
> - **UNSUPPORTED_IMPL_DECISION**: L2의 예상값을 회귀 기준으로 먼저 고정하지 않는다. `FeedPersistenceIT`에서 실제 Hibernate Statistics를 캡처한 뒤에만 `locally-verified`로 승격한다.
| 입력 | 실행 | 완료 조건 |
|---|---|---|
| N = 10 / 100 / 1000 | `getEntityFetchCount()`와 entity별 fetch count를 독립 캡처 | `pageFetches`, `userFetches`, 순수 ToOne 회계식이 실측으로 일치하거나 불일치 원인이 기록됨 |
## 엣지·실패·의존
- **실패·엣지 경로**: 전역 batch 설정이나 production projection으로 기존 naive `loadFeed`를 대체하면 L1~L4 재현 경로가 사라진다. 기존 sibling 격리를 유지하고 각 랩 회귀를 함께 실행한다.
- **실패·엣지 경로**: L2 유도값을 실측처럼 기록하면 뒤 단계의 GREEN이 미검증 gap을 숨긴다. L2는 현재 `planned`이며 불일치도 결과로 보존한다.
- **다른 계약 의존**: 별도 branch decision을 consume하지 않는다. ca-tmpl application 계약이 full CQRS를 escalation-only로 유지하는 동안 본 구현 경계는 same-store CQRS-lite다.
## 검증해야 할 주장
랩 미실행 단계라 아래는 **실측으로 확정 전**(현재는 설계 가설):
1. L3에서 `MultipleBagFetchException`이 실제로 재현되고, 하나만 fetch 시 카테시안 곱으로 전송 행수 ≫ 엔티티 수가 관측되는가.
2.**확정(2026-07-13, L4 실측 GREEN)**: 컬렉션 fetch join+페이징 시 `feedItemLoaded`(=N) ≫ `returned`(=min(20,N)), over-fetch = N/pageSize(1.0/5.0/50.0×) **결정적** 확인. 경고 코드는 예상 `HHH000104`가 아니라 **`HHH90003004`**(Hib7, 문구 동일). 힙/지연은 `getThreadAllocatedBytes`(≈1.5→10MB)+p99로 N에 비례 확인(절대값은 환경의존 상대곡선; Runtime 힙델타는 trim GC로 은닉되어 부적합). N=10k 힙 실측은 옵션(§6 CI 부담)으로 남김.
3.**확정(2026-07-13, L5 실측 GREEN)**: `default_batch_fetch_size=100`가 쿼리 수를 `1+N → 1+ceil(N/batch)·연관`으로(prepared 25/222/2022 → **5/5/23**, N=1000에서 87.9× 붕괴), 페이징 정상(엔티티 페이징이라 SQL `LIMIT` 존재·`feedItemLoaded=min(20,N)`)으로 실제로 만든다. **정정**: `getCollectionFetchCount()`는 초기화 수(=N)가 아니라 **fetch SELECT 연산 수(=ceil(N/batch): 1/1/10)**로 접힌다. batch 크기 스윕은 property 클래스 단위라 미측정(공식 유도).
4. `Bench.measure`의 **바이트 근사(직렬화 크기)·힙 델타**가 랩 간 유의미한 신호를 주는가(GC 노이즈에 묻히지 않는가).
5.**확정(2026-07-13, L6 실측 GREEN)**: DTO 프로젝션(`SELECT new <carrier>(...)` 스칼라만)이 엔티티를 **0개** 하이드레이트(`getEntityLoadCount()` 1569→0, lazy 0회·영속성 컨텍스트 미적재·더티체킹 0) + 쿼리 **상수 2**(N 무관). "페이지당 3(Top-N)"은 **못 푼다** 확인 — 자식 IN 프로젝션이 페이지 부모의 하이라이트 전량(childRows=**1509**, top-3=60 훨씬 초과)을 가져옴(그룹 아닌 행에 LIMIT 불가) → L14. **정정**: 프로젝션 EXPLAIN width(2088)는 엔티티(1194)보다 **좁지 않고 오히려 넓다**(조인+PG varchar 추정) — 이득은 SQL 플랜 아니라 ORM 층.
6. 사슬(§0.2)의 각 화살표가 **주장한 순서대로** 터지는가(해법이 정말 다음 문제를 낳는가), 아니면 중간에 다른 실패가 끼어드는가.
7. **(L2)** §2.6 유도값 — `pageFetches`=N(선형)·`userFetches`=3/20/20(평탄)·`entityFetches`=13/120/1020 — 이 실제 `getEntityFetchCount()`·엔티티별 `getFetchCount()` 실측과 일치하는가. 특히 항등식 `entityFetches == preparedStmts collectionFetches 2` 와 "접근 0인데 `pageFetches==N`, `collectionFetches==0`"(§2.3 probe). (이 세션 Docker 미가용으로 **유도만**; §2.4 IT 실행으로 확정.)
## 2026-07-08 (2) SUBSTRATE 재결정 → ca-tmpl 프로덕션 모듈 + 파운데이션 빌드 가이드
한 번 별도 `liner-feed-lab/` 스캐폴드를 만들었다가 **되돌림**(사용자: "ca-tmpl에 실제 도메인이 붙는거라 app-bootstrap 그대로 쓰고 싶다"). 피드를 **ca-tmpl 프로덕션 모듈에 첫 제품 도메인**으로 구현하기로 재결정. 분업: 파운데이션은 사용자가 **직접 타이핑**(주제2 계층 이해 목적), 나는 "다시 안 묻게" 상세 빌드 가이드 작성.
**빌드 가이드**: `docs/superpowers/plans/2026-07-08-nplus1-feed-foundation-build-guide.md` (직접 구성용, 코드+설명+검증).
**서브에이전트 4개 병렬 조사(worklog/poster 실제 패턴)에서 나온 3가지 충격(가이드 §0)**:
1. **프로덕션 모듈엔 도메인 0개** — worklog·poster는 전부 `sample-portfolio`. feed = 첫 프로덕션 도메인. 레퍼런스 = poster 슬라이스(패키지 루트만 `sample.portfolio.*``dev.caskeleton.*`로 이동). 가드레일(ArchUnit·allowedProjectDependencies)은 **모듈/패키지-패턴 기반이라 서브패키지 feed 자동 커버** — build.gradle/ArchUnit 편집 불필요.
2. **★ ID 규약**: `@GeneratedValue`/SEQUENCE/IDENTITY **repo 전체에서 미사용**. ID = **ULID 값객체(`FeedItemId implements ResourceId`) → native `uuid`**(`@JdbcTypeCode(SqlTypes.UUID)`), 유즈케이스에서 IdFactory 민팅. 하드룰 `NO_LONG_ID_PK`. → 스펙의 `Long/SEQUENCE` 스키마를 **UUID PK로 수정**. **L9(쓰기 N+1: IDENTITY가 배치 무력화)는 클라할당 UUID라 재현 안 됨 → Video2에서 재설계.** L1~L6 무관.
3. **feed = repo 최초의 진짜 연관**(`@ManyToOne` user/page EAGER=L2씨앗, `@OneToMany` highlights=L1씨앗). poster/worklog는 연관 0개(스칼라/`@ElementCollection`만). 새 영역이라 빌드로 검증하며 진행.
**핵심 아키텍처 사실(가이드에 반영)**:
- ID 값객체 4개(`FeedItemId/UserId/PageId/HighlightId implements ResourceId`), 애그리거트는 **다른 애그리거트를 ID로만 참조**(순수성).
- 애그리거트 퍼시스턴스 포트 `*Repository`**domain 패키지**, 프로젝션 읽기 포트 `*QueryPort`는 application.
- **CQRS 갈래**: 쓰기=FeedItem 애그리거트(N+1 재현), 읽기=`FeedQueryPort``FeedView` 프로젝션(L6/L12 무대). **랩은 `FeedQueryAdapter.loadFeed` body만 교체**(포트 고정).
- 매퍼 hand-written static(ULID↔UUID). 어댑터 `@Transactional` 금지(트랜잭션=유즈케이스 `TransactionPort`). 감사=퍼시스턴스 `AuditableEntity`(`@MappedSuperclass`, 수동 stamp). **highlights의 `created_at`이 `AuditableEntity.created_at`과 충돌 → highlights는 AuditableEntity 미상속 권장**.
- 마이그레이션: 프로덕션 `db/migration/postgresql/`(V1·V3·V4·V5 존재)→**V6__feed.sql**. sample의 `db/sample-migration/`(V2·V6-poster)와 다른 classpath. 런타임 = 깨끗한 ca-app-pg :5433.
- 보안: `GET /api/feed` 기본 인증(deny-by-default). 측정은 HTTP 아닌 IT(Testcontainers `@ServiceConnection`, `ddl-auto=validate` 드리프트 게이트) → 인증 무관.
- 쿼리카운트 하네스 **repo에 없음** → Hibernate `generate_statistics`(`getPrepareStatementCount`)로 시작, 필요시 datasource-proxy(락 갱신).
- Gotchas: `spotlessApply` 항상 먼저, 한파일-한타입, STRICT 락(새 의존성 시 `resolveAndLockAll --write-locks`).
- **Gotcha(2026-07-09, 파운데이션 빌드 중):** rdbms-base 엔티티(`..adapter.outbound.persistence..`, `.postgresql` 밖)의 `@Column``columnDefinition`(예: `"uuid"`)을 달면 ArchUnit `PERSISTENCE_RDBMS_ENTITIES_DO_NOT_PIN_VENDOR_COLUMN_DEFINITIONS` 위반 → `check` FAIL. 물리타입은 vendor Flyway(V6)가 소유, 엔티티는 `@JdbcTypeCode(SqlTypes.UUID)` 표준 힌트만. **feed 가이드 §4.1 예제가 `columnDefinition = "uuid"`를 달고 있던 자기모순 → 삭제(가이드 수정 완료).** 해법: `columnDefinition` 제거, `@JdbcTypeCode`만 유지.
- **Gotcha(동일):** `CleanArchitectureTest``@AnalyzeClasses(importOptions = ProductionClassImportOption.class)` = `DoNotIncludeTests`**ArchUnit은 test 클래스를 스캔하지 않는다.** ∴ 퍼시스턴스 IT를 app-bootstrap에 두든 어댑터 모듈에 두든 ArchUnit 실패와 무관 — IT 위치는 **컨벤션 선택**(app-bootstrap=PG 통합테스트 repo 표준 홈, 의존·`PostgreSqlTestContainer` 헬퍼 완비, 락 0 / persistence-jpa=어댑터가 자기 IT 소유 컨벤션, sample-portfolio `PosterRepositoryAdapterIntegrationTest` 선례, 그 모듈에 testcontainers 의존+락 필요). "엔티티 어노테이션 무시로 룰 우회"는 HARD-STOP 방향 → 금지.
## L0 완료 (2026-07-10) — 기준선 + 첫 실 에러 + 외부 발표 문서
- **L0 구현 완료**(커밋됨): feed 도메인(4 애그리거트+ID값객체)·application(`FeedQueryPort`/`FeedSummary`/`GetFeedUseCase`)·persistence(`FeedItemJpaEntity` 등, `FeedQueryAdapter` 순진 구현)·`V6__feed.sql`·`FeedPersistenceIT`+`FeedSeedFixture`. IT는 **app-bootstrap test**(옵션 B). `check` 통과 전제로 L0 스모크 GREEN.
- **N+1 메커니즘 정밀화(발표 킬러 포인트)**: `@ManyToOne` 기본 EAGER는 **JPQL/`findAllBy` 리스트 쿼리에서 JOIN이 아니라 "2차 SELECT"** 로 나간다(`em.find(id)`만 JOIN). 쿼리 수 = `1 + distinct(user) + N(page) + N(highlights)`**1차 캐시가 공유 연관을 dedup**. 시더가 user는 풀(≤20)로 재사용/page는 아이템당 1개(distinct)라 **같은 EAGER인데 user는 dedup·page는 폭발** → "N+1 폭발계수는 애너테이션이 아니라 카디널리티". IT 주석 `1+3N`은 최악(전부 distinct) 케이스. (`collectionFetches==N`, `preparedStatements>N` 단언으로 하한 증명.)
- **문제 분리**: L0는 두 문제를 드러냄 — ⓐ N+1(fetch 전략) ⓑ 기준 쿼리 Seq Scan+Sort(인덱스/정렬, `ORDER BY first_highlighted_at DESC,id`). **원인·해법 축이 다름**(fetch join vs 인덱스/keyset). 섞지 말 것.
- **외부 발표 문서**: `/home/donghyeon/dev/topic-arrange/n+1liner/README.md` (단일 발표자료 — 2026-07-10 L0/L1 분리본(01/02) 병합·삭제). 사용자 지시로 (a) **측정 환경**(실제 PG16 Testcontainers·`ddl-auto=validate`·어댑터 직접 측정, why H2 아님/why HTTP 아님) (b) **데이터셋 구성+왜**(user 풀 재사용 vs page distinct = dedup 대비, highlight 멱함수 1~500 = "수백 개" 재현, visibility 6:2:2, N∈{10,100,1k}) — **생성 메커니즘 명시**(엔티티별 개수가 왜 다른지: feed_item=N 루프 / page=N 1:1 `pages[i]` / user=`max(3,min(20,N/5+1))` 라운드로빈 `users[i%size]` / highlight=`max(1,round(500/(i+1)^1.15))` 합) + **지프의 법칙** 설명(멱법칙 s=1.15, 왜 균일/정규 아닌지, 총량이 N에 sub-linear한 이유 = 머리 지배) 섹션 추가. **메타 문구 제거**(파일명 참조·"이 문서 세트는~" 금지 → "흔한 오해/실제" 콜아웃으로 전환, 발표자료 톤). 사용자 노션 초안을 코드 대조로 교정해 작성. 사용자 초안의 **ArchUnit 주장 3건 부정확 → 교정**: ① `@ValueObject` 실제 룰 = `VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR`(final은 record 특성, setter금지는 `@AggregateRoot` 룰). ② "VO를 엔티티/서비스 필드 금지"는 ArchUnit 아님(관례; 엔티티는 UUID 저장). ③ "엔티티 package-private를 archunit로 강제"는 부정확 — **연관 게터**만 package-private(클래스는 public)이고 **손 관례**(ArchUnit 룰 없음); 엔티티 누출은 `CONTROLLERS_DO_NOT_ACCESS/RETURN_...`·`QUERY_PORTS_DO_NOT_LEAK...`가 다른 층에서 막음.
## L3·L4 완료 (2026-07-13) — fetch join 착상의 이중 실패(카테시안 + 페이징 불가)
- **L3 완료(이전 세션, 커밋됨)**: 두 번째 컬렉션 `mentions`(bag) + `FeedItemMentionJpaEntity` + `V7__feed_mentions.sql` 추가 후 IT로 fetch join 착상을 터뜨림. 실측: ① 두 bag 동시 fetch join → `MultipleBagFetchException`(실측: `IllegalArgumentException`으로 래핑 → 테스트는 `causeChain` 문자열 매칭이 견고). ② 한 bag만 fetch join → 카테시안: 전송 행수(조인 카디널리티) = **1,285/1,961/2,917**(= Σhighlights) ≫ 리스트 크기 N. **Hibernate 6+/7 루트 자동 dedup**으로 리스트 크기가 N이 되어 카테시안이 이중으로 숨음 → 스타는 리스트 크기가 아니라 조인 count/EXPLAIN actual rows. (Claims #1 ✅ 확정.)
- **★ L4 완료(이 세션, 실측 GREEN)**: L3의 후퇴("컬렉션은 하나만 fetch join")에 페이징(`setMaxResults(20)`)을 걸어 세 번째 실패를 격리. **프로덕션 코드 0**(IT 측정만). `FeedPersistenceIT`에 L4 4메서드 추가 → `:app-bootstrap:test --tests '*FeedPersistenceIT'` **GREEN(0 fail)**, `CleanArchitectureTest` GREEN(프로덕션 무변경). L1/L2/L3 회귀 없음.
- **★ 스타 실측**: `returned`(=min(20,N)) = 10/20/20 **평탄**인데 `feedItemLoaded`(부모 `EntityStatistics.getLoadCount()`) = **10/100/1000**(=N, 전체 하이드레이트) → over-fetch = N/pageSize = **1.0/5.0/50.0×**. "페이지를 원했는데 데이터셋 전체를 로드"를 통계로 못 박음. N=10(<pageSize)에선 1.0×라 함정 불가시 = "dev 시드 통과, 운영 폭발"(§2.2 `if(n>PAGE_SIZE)` 강가드).
- **★ 실측 정정(발표/errors 소재)**: 경고 코드는 예상한 `HHH000104`가 아니라 **`HHH90003004`**(Hibernate ORM 7.1.8). 메시지 본문은 동일(`firstResult/maxResults specified with collection fetch; applying in memory`) — 6→7 코드 재번호. 회귀가드는 코드 번호가 아니라 **문구(`collection fetch`)로도 매칭**해야 견고(실제로 `|| contains("collection fetch")` 분기가 어서션을 통과시킴). → `raw/errors/` 승격.
- **EXPLAIN 대조(D2)**: (a) 컬렉션 조인 SQL엔 **Limit 노드 부재**(전체 1961행 quicksort 445kB) / (b) 엔티티만 페이징 SQL엔 **Limit 노드 존재**(top-N heapsort 28kB, 20행). (a)에 Limit 없음 = "DB가 페이징 안 함 → Hibernate가 메모리에서 함"의 계획 레벨 증거.
- **비용 계기 정정(honesty)**: Runtime 힙델타 금지(trim된 Npage개가 GC돼 비용 은닉) → `getThreadAllocatedBytes`(GC 견고, 할당 ≈1.5→10MB) + p99. **반전**: 이 fetch join 지연(p99 N=1000 ≈83.5ms)은 순진 조회(L1 max 238ms)보다 **오히려 낮음** → 지연만 보면 "빨라졌다" 착각, 진짜 비용은 메모리 과적재.
- **fix 금지 준수**: `@BatchSize`·엔티티페이징·`fail_on_pagination...=true`·`.distinct()` 커밋 안 함(다음 고리 L5 지우지 않게). §2.6 probe(엔티티페이징=LIMIT정상이나 L1 N+1 재현)도 커밋 제외. D5 고리 = fetch join 버리고 엔티티페이징(LIMIT 정상)+연관 IN 배치 → **L5 `@BatchSize`**.
- **산출물**: `ca-tmpl:docs/notes/L4.md`(D1~D6) + L4 실행 가이드 `docs/superpowers/plans/2026-07-13-nplus1-L4-fetchjoin-paging-hhh000104-lab-guide.md`. 외부 발표 문서(현 위치 `/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md`)에 **§10 신설**(evidence-map C11~C14 hash-anchor + `l4-inmemory-paging.csv`/`l4-cost-curve.csv` + EXPLAIN 원문 2건, validator 7종 GREEN, 옛 §10 다음단계→§11 재배치). 측정 코드는 working tree(사용자 커밋 대기).
## L5 완료 (2026-07-13) — 첫 fix: 엔티티 페이징 + 배치 IN이 L1~L4를 동시에 푼다
- **★ L5 완료(실측 GREEN)**: L4의 해법 착상("fetch join 버리고 엔티티 페이징 + 연관 IN 배치")을 실행 = **첫 fix 랩(착상→해결, before/after)**. fix = 세션 설정 한 줄 `hibernate.default_batch_fetch_size=100`**순진 `loadFeed` 코드는 한 글자도 안 고침**(같은 코드가 L1에선 N+1, L5에선 배치).
- **격리 설계**: `default_batch_fetch_size`는 세션 전역이라 `FeedPersistenceIT`에 넣으면 L1~L4 단언이 깨진다 → **새 클래스 `FeedBatchFetchIT`에 격리**(그 설정만 얹음). `FeedPersistenceIT`는 byte 단위 무변경 → **회귀 0**(실측: FeedPersistenceIT 0 fail, CleanArchitectureTest 0 fail). 시더·`LabReport`·Testcontainer 재사용.
- **★ 쿼리 붕괴(스타)**: `loadFeed(0, n)`(L1과 같은 호출) prepared = **5 / 5 / 23** vs L1 순진 **25 / 222 / 2022** → N=1000에서 **87.9× 붕괴**. 분해(N=1000): 1 루트 + 1 count + 10 highlights + 10 page + 1 user 배치(각 ceil(N/100)). ToOne(EAGER page/user)도 배치에 걸려 L2 선형 N+1 동반 소멸.
- **★ 실측 정정(errors 승격)**: `getCollectionFetchCount()`가 배치에서 N이 아니라 **1/1/10 = ceil(N/batch)**로 떨어진다 — 이 지표는 "초기화된 컬렉션 수"가 아니라 **컬렉션 fetch SELECT 연산 수**. 문서 모델(L4가이드 §0.4·발표 §6.1의 "배치를 켜도 N 유지") 정정. 배치 해결의 증인은 `prepared`·`collectionFetch` 둘 다. → raw/errors 승격.
- **페이징 정상(§10 대조)**: `loadFeed(0, 20)` feedItemLoaded = **10/20/20 = min(pageSize,N)** vs L4 fetch join의 N(10/100/1000). **L4 over-fetch 소멸** — 엔티티만 페이징이라 인메모리 페이징 없이 DB LIMIT이 정확히 페이지만 자름.
- **EXPLAIN(§9·§10 둘 다 해소)**: (a) 엔티티 페이징 SQL엔 **Limit 노드 존재**(top-N heapsort 28kB — L4 (a) fetch join엔 없었다). (b) 배치 IN은 **Hash Semi Join**으로 자식 행만 반환(1509, 합) — L3 카테시안(1961, 곱) 소멸.
- **잔여 비용(→ L6)**: 페이지 20건 조회(seed 1000)에도 `entitiesLoaded = 1569`(FeedItem+User+Page+Highlight 전 컬럼·영속성 컨텍스트·더티체킹) — 배치는 쿼리·페이징을 풀지만 엔티티 과적재는 남음 → **L6 DTO 프로젝션**. "페이지당 3"(Top-N)은 L6도 못 풂 → Video2 L14.
- **산출물**: L5 실행 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-14-nplus1-L5-batchsize-paging-resolution-lab-guide.md` + `docs/notes/L5.md`(D1~D6, before/after) + `FeedBatchFetchIT`(신규 격리 IT). 발표 문서(`/home/donghyeon/workspace/ai-tool/topic-arrange/n+1liner/n+1liner.md`)에 **§11 신설**(첫 해결 절, evidence-map C15/C16 hash-anchor + `l5-batch-resolution.csv`/`l5-hydration-probe.csv` + EXPLAIN 원문 2건, validator 7종 GREEN, 옛 §11 다음단계→§12). 측정 코드는 working tree(사용자 커밋 대기).
## L6 완료 (2026-07-13) — 두 번째 fix: DTO 프로젝션이 엔티티 과적재를 없앤다
- **★ L6 완료(실측 GREEN)**: L5가 남긴 잔여 비용(엔티티 과적재 `entitiesLoaded=1569`)을 **DTO 프로젝션**으로 제거 = **두 번째 fix 랩(착상→해결, before/after)**. fix = `FeedQueryAdapter.loadFeedProjection`(`SELECT new <carrier>(...)` 스칼라만 뽑는 실제 쿼리). L5(설정 한 줄)와 달리 실제 코드지만, **L5(왕복 축)와 직교하는 "적재 형태 축"** — 배치는 "몇 번 SQL", 프로젝션은 "무엇을 적재".
- **격리 설계**: fix가 실제 쿼리라 순진 `loadFeed`(L1~L5 측정 대상)를 고치면 그 랩들이 깨진다 → **sibling 메서드 `loadFeedProjection` 추가**(loadFeed byte 무변경) + **새 `FeedProjectionIT`**(배치 설정 **없음** — 프로젝션은 프록시/컬렉션을 안 만드니 배치와 직교). 프로덕션 1파일(`FeedQueryAdapter` + 캐리어 record 2 + EntityManager 주입). L5가 sibling IT로 격리한 것의 어댑터-메서드 판.
- **★ 엔티티 0(스타)**: `loadFeedProjection(0, n)` `getEntityLoadCount()` = **0 / 0 / 0** vs L5 배치 **1569**. `SELECT new <carrier>(...)`는 스칼라만 뽑아 영속 엔티티를 인스턴스화하지 않음(조인은 컬럼 접근용, 하이드레이션 아님) → 영속성 컨텍스트 미적재·더티체킹 0·lazy 0. prepared = **2 / 2 / 2**(부모 스칼라 + 자식 IN, **N 무관 상수** — L1 `1+N`·L5 `1+ceil(N/batch)`와 삼중 대조), collectionFetch = 0. 형태 동치(`l6ProjectionReturnsSameShapeAsNaiveLoadFeed`: 프로젝션 vs 순진 loadFeed 같은 결과 = fix가 결과 안 바꿈).
- **★ 실측 정정(errors 승격) — 프로젝션 EXPLAIN width는 좁아지지 않는다**: 초안 착상은 "프로젝션은 필요 컬럼만 읽어 width가 엔티티 `SELECT fi.*`보다 좁다"였으나 **실측은 정반대** — 부모 프로젝션 width = **2088 > 엔티티 1194**. 이유: 프로젝션이 users·pages 조인(그 행폭 흘러듦) + PG `width`는 varchar 평균폭 추정치(컬럼 수 아님). **결론: 프로젝션 이득은 SQL 플랜에 안 보인다** — 진짜 이득은 ORM/JVM 층(entityLoadCount 0), `Statistics`로만 관측. → raw/errors 승격(L3 dedup·L4 HHH90003004·L5 collectionFetch에 이은 **네 번째 실측 정정**).
- **EXPLAIN(D2)**: (a) 부모 스칼라 프로젝션엔 **Limit 노드 존재**(페이징 정상, top-N heapsort) — 단 width 2088. (b) 자식 스칼라 IN은 **Hash Semi Join**으로 자식 행(1509)만 반환(곱셈 없음, L5 배치와 동일 shape).
- **잔여 비용(→ L14)**: 페이지 20건(seed 1000)의 자식 행 `childRows = 1509`(부모당 전량) — 화면엔 부모당 top-3(≤60)면 충분한데도. 그룹당 LIMIT은 단순 `IN`으로 불가 → **Top-N-per-group(L14)**(윈도우 함수/LATERAL/2단계 배치). (Claims #5 ✅ 확정, D-06 Strong 승격.)
- **회귀·아키텍처 0**: `:app-bootstrap:test` **97/97 GREEN** — FeedProjectionIT 6/6 + FeedBatchFetchIT 8/8(L5) + FeedPersistenceIT 26/26(L1~L4) + CleanArchitectureTest 57/57(`QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES` PASS — 프로젝션은 application DTO `FeedSummary`만 반환, 캐리어 record는 persistence 내부 전용). `verifyCleanArchitectureDependencies` GREEN(경계·의존 방향 무변경). spotlessCheck GREEN.
- **산출물**: L6 실행 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-15-nplus1-L6-dto-projection-entity-overfetch-lab-guide.md`(width 실측 정정 포함) + `docs/notes/L6.md`(D1~D6, before/after) + `FeedQueryAdapter.loadFeedProjection`(프로덕션) + `FeedProjectionIT`(신규 격리 IT). 발표 문서(`topic-arrange/n+1liner/n+1liner.md`)에 **§12 신설**(두 번째 해결 절, evidence-map C17/C18/C19 hash-anchor + `l6-projection-resolution.csv`/`l6-explain-width.csv` + EXPLAIN 원문 2건, validator 7종 GREEN, 옛 §12 다음단계→§13). 측정·프로덕션 코드는 working tree(사용자 커밋 대기).
## L14 완료 (2026-07-16) — 왕관 첫 보석: Top-N-per-group 세 해법 대결 (실측 GREEN)
- **★ L14 완료(실측 GREEN)**: L6가 남긴 잔여(자식 IN 전량 `childRows=1509`)를 **그룹당 top-3**으로 접는 왕관 첫 랩. 정전(L1~L6, 단일 fix)과 달리 **SQL·인덱스 문제 + 세 해법 대결**(윈도우/LATERAL/2단계) → 스타 = **3안 EXPLAIN 플랜 대조**(쿼리 개수 아님). **IT-only**(`FeedTopNIT` 신규, native SQL을 `JdbcTemplate`으로 — `loadFeed`/`loadFeedProjection` 무변경, 프로덕션 0). **새 인덱스 없음** — V6 `ix_highlights_feed_items_created (feed_item_id, created_at DESC)` 재사용.
- **★ 3안 플랜 대조(seed 1000, page 20, K=3, 같은 실행=apples-to-apples)**: ⓑ LATERAL = `Nested Loop`+`Index Scan(ix_highlights…)`+`Limit 3`, buffers **204**·0.323ms — 최소·최속(부모별 3개만 seek, `loops=20 rows=3`). ⓐ window = `WindowAgg``Hash Semi Join`(전량 rows=1509), buffers 430. ⓒ 2단계 = `Sort``Hash Semi Join`, 반환 1509(앱컷 전 전량). **window·2단계 buffers 동일(430) = 같은 스캔** — window = 2단계 + DB측 컷(PG15+ `Run Condition: row_number()<=3`). LATERAL만 구조적으로 다른(인덱스 seek). 셋 다 같은 top-3(60행).
- **★ 인덱스 토글(인과 실증)**: 같은 LATERAL을 `ix_highlights_feed_items_created` DROP→측정→`finally` 복구. 인덱스 없으면 부모별 Seq Scan(`Rows Removed by Filter: 2842/loop`) → buffers 168→**4446(≈26배)**·exec 0.336→**5.472ms(≈16배)**. "LATERAL이 빠른 건 LATERAL이 아니라 인덱스 seek 덕" — 대부분 "LATERAL 쓰면 빠르다"에서 멈추는 지점을 실측 인과화(선배 넘는 차별점).
- **D6 그룹 크기 K 곡선(3/50/500)**: 반환 60/695/1509(결정적, K 컷). LATERAL buffers 모든 K에서 window보다 작음(114<162, 155<216, 171<269), 작은 K일수록 격차↑. 의사결정: 큰 그룹·작은 K → LATERAL, K≈그룹크기 → window 단순.
- **정확성·기전**: window·lateral 부모당 3(반환 60·부모 20), 순진 `LIMIT 3` = 전체 3행(부모 1개만 = 오작동, `LIMIT`엔 그룹당 없음). **왜 native**: 표준 JPQL엔 윈도우·LATERAL 없음(Hibernate 6+ HQL은 윈도우만 확장 지원, LATERAL 없음). 2단계만 JPQL(IN)+앱컷 가능 → A/B는 native로 내려감(왕관=SQL 레이어 논지).
- **잔여(→ L15)**: `l14ProbeParentPagingStillUsesOffsetNotKeyset` — 부모 페이징이 아직 `OFFSET 900`(앞 900행 scan-then-discard) → keyset/seek(L15) → keyset 인덱스에 가시성 술어 얹기(L16).
- **회귀·게이트 0**: `FeedTopNIT` GREEN. `loadFeed`/`loadFeedProjection` 무변경 → `FeedPersistenceIT`(L1~L4)·`FeedBatchFetchIT`(L5)·`FeedProjectionIT`(L6) 0 fail. IT-only라 `CleanArchitectureTest`/의존 매트릭스 무관(어댑터 메서드 미추가). 인덱스 토글 `finally` 복구로 후속 테스트 오염 0.
- **산출물**: L14 실행 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-16-nplus1-L14-topn-per-group-window-lateral-2step-lab-guide.md` + `docs/notes/L14.md`(실측) + `FeedTopNIT`(신규 IT). **발표 문서 `topic-arrange/n+1liner/n+1liner.md`에 §13 신설**(왕관 첫 절, §12.6 "→§13/L14" 예고 해소): 3안 플랜 대조표 + EXPLAIN 원문 4건(`l14-{lateral,window,twostep,lateral-no-index}-plan.txt`) + 인덱스 토글 + K 곡선; **evidence-map C20**(anchor `1,509` measured, l14-topn-resolution.csv#L4, hash `92ca611f5b10ae29`) + l14-*.csv 4종 + 매니페스트 `topn-per-group-resolution`; 옛 §13 다음단계→§14. **tooling 골든 검증 432/432 GREEN**(`verify_evidence` 백/포워드 커버리지·해시·매니페스트·링크). buffers·exec는 whitelist(환경 의존 상대값, l4-cost-curve와 같은 선). 측정·문서 커밋은 사용자 대기.
## L15 완료 (2026-07-17) — 왕관 둘째 보석: keyset vs OFFSET 깊은 페이지 페이징 (실측 GREEN)
- **★ L15 완료(실측 GREEN)**: L14의 부모 페이징 잔여(아직 `OFFSET`)를 **keyset(seek)**으로 없애는 왕관 둘째 랩. 단일 fix(before/after), 스타 = **페이지 깊이별 스캔량 곡선**. **IT-only**(`FeedKeysetIT` 신규, native SQL을 `JdbcTemplate`으로, 6 tests). **정렬키 인덱스 `(first_highlighted_at DESC, id DESC)`는 IT 안 CREATE/DROP 토글** — V6 `ix_feed_items_visibility_sort`는 선두 컬럼이 `visibility`라 가시성 없는 keyset을 못 받침(L14는 기존 인덱스 재사용, L15는 정렬키 전용 인덱스 도입이 차이).
- **★ 깊이 곡선(seed 2000, 같은 정렬키 인덱스)**: OFFSET이 `Limit` 하위로 훑는 행 = **offset+20**(page 1/50/100 = **20/1000/2000**, 깊이 정확 비례) vs **keyset = 20 평탄**. page 100에서 OFFSET 100× over-scan. 두 곡선 page 1 동일 출발 → 발산.
- **★ 깊은 페이지 플랜(offset 1980, 한 실행)**: OFFSET = `Limit``Sort`(2000)←`Seq Scan`(2000), buffers **141**, 0.996ms. keyset+인덱스 = `Limit`←**`Index Only Scan`**(커버링, `Heap Fetches: 20`), 훑은 행 **20**, buffers **1**, 0.076ms, **Sort 노드 없음**(순서 인덱스 보장). keyset−인덱스 = `Seq Scan`(`Rows Removed by Filter: 1980`)+`Sort`, 훑은 행 20이나 buffers **141**(=OFFSET, 전량 heap). → **keyset이 평탄한 건 keyset 문법이 아니라 정렬키 인덱스 덕**(§13.4 LATERAL 교훈과 같은 결).
- **정확성**: `l15KeysetWalkMatchesOffsetPages` — keyset 커서(page1 마지막 행)로 넘긴 page 2 == OFFSET page 2(같은 20 id·순서). row-value `(first_highlighted_at, id) < (:cursor)`의 tie-break `id`가 경계를 유일하게.
- **★ D5(→ L16, 실측 bridge)**: `l15ProbeVisibilityOrBreaksKeysetIndex` — keyset에 가시성 필터(`PUBLIC OR (MENTIONED AND EXISTS(mentions)) OR (PRIVATE AND user_id=me)`)를 얹으면 `ix_feed_items_keyset` **미사용**. 대신 `BitmapOr`(가시성 3분기 각각 `Bitmap Index Scan on ix_feed_items_visibility_sort`)+`BitmapAnd`(private)+`SubPlan`(mentions EXISTS), 그리고 **`Sort` 노드 재등장**(순서 seek 이점 소멸). 가시성 OR이 keyset을 "훑고 정렬"로 되돌린다 → **L16**(UNION 분해로 각 분기를 정렬 보장 인덱스로 만들어 merge / 부분·복합 인덱스 / 사전계산).
- **회귀·게이트 0**: `FeedKeysetIT` GREEN. `loadFeed`/`loadFeedProjection` 무변경 → `FeedPersistenceIT`·`FeedBatchFetchIT`·`FeedProjectionIT`·`FeedTopNIT`(L1~L14) 0 fail. IT-only → `CleanArchitectureTest`/의존 매트릭스 무관. 정렬키 인덱스 토글 `finally` DROP(DDL auto-commit 복구).
- **산출물**: L15 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-17-nplus1-L15-keyset-vs-offset-deep-page-pagination-lab-guide.md` + `docs/notes/L15.md`(실측) + `FeedKeysetIT`(신규 IT). **발표 문서 `n+1liner.md`에 §14 신설**(§13.7 "→L15" 예고 해소): 깊이 곡선표 + EXPLAIN 원문 4건 + 정렬키 인덱스 유무 + 가시성 probe; **evidence-map C21**(anchor `2,000` measured, l15-depth-curve.csv#L4, hash `e3e5307b9108f35d`) + l15-*.csv 2종/*.txt 4종 + 매니페스트 `keyset-vs-offset-deep-page`; 옛 §14 다음단계→§15. **tooling 골든 432/432 GREEN**. buffers·exec whitelist(환경 의존). 측정·문서 커밋은 사용자 대기.
- **다음(L16)**: 가시성 술어 인덱싱 — L14처럼 세 해법 대결(UNION 분해 / 부분·복합 인덱스 / 사전계산). L15의 가시성 OR probe(BitmapOr+Sort)가 진입점. 왕관 닫으면 CQRS(L12)로 일반화.
## L16 완료 (2026-07-18) — 왕관 셋째·닫힘: 가시성 술어 인덱싱 (실측 GREEN) ★ 왕관 완결
- **★ L16 완료(실측 GREEN)**: L15의 가시성 잔여(keyset에 OR 얹으면 인덱스 못 탐)를 세 해법으로 없애는 **왕관 셋째·마지막 랩**. 가시성 = `PUBLIC OR (MENTIONED AND EXISTS(mentions)) OR (PRIVATE AND author)`. **IT-only**(`FeedVisibilityIT` 신규, native SQL, 4 tests). 신규 인덱스(`ix_mentions_user (mentioned_user_id, feed_item_id)`, `ix_feed_items_private` partial `WHERE visibility='PRIVATE'`)·사전계산 테이블(`feed_visible`)은 IT 안 CREATE/DROP 토글. 뷰어 user008.
- **★ 3안 플랜 대조(seed 2000, 스타)**: ⓐ 단일 OR = `BitmapOr`(3분기)+top-N `Sort`+**hashed SubPlan**(멘션), 후보 **1500** 훑어 20, buffers **122**. ⓑ UNION 분해 = **`Merge Append`**(분기별 정렬 스트림)+`Hash Join`(멘션 EXISTS→집합)+private `Index Only Scan`(partial)+`Incremental Sort`, buffers **200**. ⓒ **사전계산 = `Index Only Scan`(feed_visible 커버링), Sort·OR·조인 전부 없음, buffers 1**. 셋 다 같은 20 feed_item(`l16ThreeApproachesReturnSameVisibleSet`).
- **★ 실측 정정(초안 2건 반증)**: (1) 단일 OR ≠ seq scan — V6·partial 인덱스가 있어 `BitmapOr`+`Sort`+hashed SubPlan(순수 seq scan 아님). (2) UNION 분해는 buffers를 **안 줄인다**(200 > 단일 OR 122) — 각 분기가 자기 스캔. **UNION은 구조를 고치고(상관 SubPlan→Hash Join, 전체 Sort→Merge Append, 분기별 인덱스), 사전계산이 자릿수를 바꾼다(buffers 1 ≪ 122/200)**. "쿼리 재작성=구조 개선, 모델 변경=규모 변경"이 L16의 결론(L3~L6·L14·L15 정정 계보). → raw/errors 승격 후보.
- **분기별 인덱스**(`l16LowSelectivityBranchesRideTheirIndex`): mentioned=`ix_mentions_user` Hash Join(V7 인덱스는 `(feed_item_id, …)`라 mentioned_user_id 조회 불가 → 신규 필요), private=`ix_feed_items_private` partial Index Only Scan, public(60% 고선택도)=Bitmap+top-N. **UNION의 값 = 각 분기가 자기 최적 플랜**(단일 OR은 하나의 bitmap으로 묶여 불가).
- **★ D5 왕관 닫힘 → L12 CQRS**: 사전계산(`feed_visible`)의 프로덕션 형태 = **CQRS 읽기 모델**(쓰기 모델=FeedItem 애그리거트·도메인 이벤트 → 읽기 모델=뷰어별 투영). Top-N(L14)+keyset(L15)+가시성(L16)을 한 조회로 → 주제2(아키텍처: 헥사고날·CQRS) 브릿지. "N+1은 쓰기 모델로 읽기를 한다는 신호"의 일반화 완결.
- **회귀·게이트 0**: `FeedVisibilityIT` GREEN. 기존 경로·IT 무변경 → `FeedPersistenceIT`(L1~L4)·`FeedBatchFetchIT`(L5)·`FeedProjectionIT`(L6)·`FeedTopNIT`(L14)·`FeedKeysetIT`(L15) 0 fail. IT-only → `CleanArchitectureTest`/의존 매트릭스 무관. 신규 인덱스·`feed_visible` `finally` `DROP … IF EXISTS`.
- **산출물**: L16 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-18-nplus1-L16-visibility-predicate-indexing-union-partial-precompute-lab-guide.md` + `docs/notes/L16.md`(실측) + `FeedVisibilityIT`(신규 IT). **발표 문서 `n+1liner.md`에 §15 신설**(§14.5 "→§15/L16" 예고 해소, 왕관 닫힘·CQRS 브릿지): 3안 플랜 대조표 + EXPLAIN 원문 4건 + 분기별 인덱스 + 실측 정정; **evidence-map C22**(anchor `1,500` measured, l16-plan-compare.csv#L2, hash `17cef9aa820b252d`) + l16-plan-compare.csv + l16-*.txt 4종 + 매니페스트 `visibility-predicate-indexing`; 옛 §15 다음단계→§16. **tooling 골든 446/446 GREEN**. buffers·exec whitelist(환경 의존). 측정·문서 커밋은 사용자 대기.
- **★ 왕관 완결(Video 2 코어)**: L14(Top-N)·L15(keyset)·L16(가시성) 세 보석 모두 실측 GREEN + 발표 §13/§14/§15 신설. 남은 것 = (선택) Task 4 통합 쿼리(3난제 한 조회) + 왕관 의사결정 매트릭스, 그리고 L12 CQRS(주제2).
## Crown Task 4 완료 (2026-07-19) — 통합: Top-N+keyset+가시성 한 쿼리 + 의사결정 매트릭스 (실측 GREEN) ★ 왕관 대관식
- **★ Task 4 완료(실측 GREEN)**: 왕관 세 보석(L14/L15/L16)을 **한 개의 피드 조회**로 합류 — `(가시성 필터 + keyset 부모) CROSS JOIN LATERAL (부모당 top-3)`. **IT-only**(`FeedCrownIT` 신규, native SQL, 4 tests). 신규 인덱스·`feed_visible`는 L16 setup 재사용(IT 안 CREATE/DROP 토글). 뷰어 user008(보이는 아이템 **1500**).
- **부모선택 3안**(= 매트릭스가 사는 자리): ⓐ 단일 OR(feed_items 직접) / ⓑ UNION 분해(분기별 keyset 인덱스) / ⓒ 사전계산(`feed_visible` + keyset). 셋 다 같은 20 부모(`unionEq`·`precomputeEq` 참, `crownUnifiedReturnsSameShapeAcrossParentPaths`) — 답 동일, 플랜만 다름.
- **★ 스타(한 플랜 세 기법, page 1)**: 사전계산 부모선택 통합 쿼리 = `Nested Loop`(LATERAL) → `Index Only Scan using ix_feed_visible`(가시성+keyset, Heap Fetches 20) + 부모 20마다 `Index Scan using ix_highlights_feed_items_created`(Top-N top-3). **Sort 노드 없음**(두 순서 모두 인덱스). 세 기법이 재정렬 없이 한 플랜에 겹친다.
- **★ 간섭 시험(핵심 발견)**: 가장 깊은 페이지(보이는 1500 중 마지막, cursor=visible20)에서 — 사전계산은 `ix_feed_visible` 인덱스 range 로 **19행**만(부모 buffers 3), 단일 OR 은 `feed_visible` 미사용(구조적) + `BitmapOr`(3분기) + 멘션 hashed SubPlan 으로 내 멘션 **200행** materialize(부모 buffers 31). **L16 발견이 통합 쿼리에서 재현** — 세 기법은 부모선택이 사전계산/UNION 일 때만 깨끗이 겹친다.
- **★ 실측 정정(초안 반증)**: 초안은 "사전계산 위 keyset 은 Sort 없이 seek, 단일 OR 은 Sort 로 깨진다"였다. **실측 정정**: 가장 깊은 커서에선 **둘 다** 남은 19행 작은 `Sort`(quicksort 26kB)가 붙는다(Bitmap 스캔이 정렬 출력을 안 함). **차이는 "Sort 유무"가 아니라 "페이지에 닿는 비용"(훑는 행 19 vs 200 + feed_visible 인덱스 사용 여부)**. page 1 에선 사전계산이 순수 `Index Only Scan`(Sort 전무). (L3~L6·L14·L15·L16 정정 계보 → raw/errors 승격 후보.)
- **왕관 의사결정 매트릭스(D5)**: Top-N→LATERAL(작은 K)/윈도우(큰 K) · 페이징→keyset · 가시성→UNION 분해/고트래픽이면 사전계산(=CQRS) · 통합→부모선택(가시성+keyset)×LATERAL. **핵심 = 부모선택**(사전계산/UNION 이면 매 페이지 재해소 없음).
- **회귀·게이트 0**: `FeedCrownIT` GREEN. 기존 경로·IT 무변경 → `FeedPersistenceIT`(L1~L4)·`FeedBatchFetchIT`(L5)·`FeedProjectionIT`(L6)·`FeedTopNIT`(L14)·`FeedKeysetIT`(L15)·`FeedVisibilityIT`(L16) 0 fail. IT-only → `CleanArchitectureTest`/의존 매트릭스 무관. 신규 인덱스·`feed_visible` `finally` `DROP … IF EXISTS`.
- **산출물**: Task 4 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-19-nplus1-crown-unified-topn-keyset-visibility-decision-matrix-lab-guide.md` + `docs/notes/crown.md`(실측) + `FeedCrownIT`(신규 IT). **발표 문서 `n+1liner.md`에 §16 신설**(왕관 통합, §15.5 "→§16 통합" 예고 해소, 왕관 완결·CQRS 브릿지): 통합 shape + 한 플랜 세 기법 EXPLAIN + 간섭 시험표 + 의사결정 매트릭스; **evidence-map C23**(anchor `1,500` measured, crown-unified-plan.csv#L7, hash `ff27d1902d444309`) + crown-unified-plan.csv + crown-*.txt 3종 + 매니페스트 `crown-unified-topn-keyset-visibility`; 옛 §16 다음단계→§17. **tooling 골든 448/448 GREEN**. buffers·exec whitelist(bare int, NUM_RE 미매칭). 측정·문서 커밋은 사용자 대기.
- **★ 왕관 대관식(Video 2 완성)**: L14+L15+L16 세 보석 + Task 4 통합 = 왕관 완성. 남은 것 = **L12 CQRS 읽기 모델(주제2 브릿지)** — 사전계산 부모선택이 그 진입점(feed_visible = 읽기 모델).
## L12 완료 (2026-07-20) — CQRS-lite 읽기 모델, 프로덕션 읽기 경로 승격 (실측 GREEN) ★ 주제2 브릿지
- **★ L12 완료(실측 GREEN)**: 왕관 결론을 **프로덕션 읽기 경로**로 승격. L6(엔티티 0, 측정용 sibling `loadFeedProjection`) + L14(top-3, IT native SQL)를 합쳐, 쓰기 애그리거트(`FeedItem`)와 분리된 **일급 읽기 모델**(전용 포트·유스케이스·프로젝션 DTO)로. 화면 shape 그대로(아이템당 top-3, 엔티티 0).
- **★ 범위 결정(계약 준수 = HARD-STOP 회피)**: 사용자가 처음엔 "실제 프로덕션 CQRS"(별도 읽기 저장소+동기화)를 골랐으나, **실측으로 `ca-tmpl:src/application-core/CLAUDE.md:162` D2 "Full CQRS with a separate physical read store = out of scope — escalation only"**를 발견 → 충돌 표면화(Prime Directive) → 사용자가 **CQRS-lite(계약 내, `ca-tmpl:src/application-core/CLAUDE.md:145` "Projection (CQRS-lite)")**로 재선택. 별도 테이블·마이그레이션·아웃박스 sync **없음**(같은 저장소, 읽기 최적 쿼리).
- **구현(다모듈, ca-implementer full-usecase)**: `FeedReadModelQueryPort`(`List<FeedSummary> loadReadModel(page,size)`, 이름이 `QueryPort`라 D1 강제) + `GetFeedReadModelQuery` + `GetFeedReadModelUseCase`(`QueryUseCase`, `@UseCaseCapability(READ_ONLY,IDEMPOTENT,READ_REPOSITORY)`, `tx.inRead`) [application-core] + `FeedReadModelQueryAdapter`(`@Repository`) [adapter-persistence-jpa] + `FeedReadModelUseCaseIT` [app-bootstrap test]. naive `loadFeed`·`loadFeedProjection`·L1~L16 ITs **무변경**.
- **읽기 모델 쿼리 = 상수 2쿼리(엔티티 0)**: ① 부모 페이지 JPQL `SELECT new FeedReadModelParentRow(...)`(L6 스타일), ② 자식 top-3 네이티브 `row_number() OVER (PARTITION BY feed_item_id ORDER BY created_at DESC) <= 3`(L14 window) `IN` 페이지 부모.
- **★ 설계 판단(→ raw/interviews 후보)**: 두 쿼리를 `JdbcTemplate`이 아니라 Hibernate `Session.createNativeQuery`/`EntityManager`로 발행. 이유 = `Statistics.getPrepareStatementCount()`/`getEntityLoadCount()`(IT 지표)는 Hibernate 자신의 JDBC coordinator를 거친 SQL만 관측 — 별도 `JdbcTemplate`이면 `prepared=0`으로 읽혀 "상수 2쿼리" 단언이 공허하게 참. Session 경유라 실제 2쿼리 증명.
- **실측(`FeedReadModelUseCaseIT` 4 tests GREEN)**: 반환 ≤20 items(fhl DESC) · `topHighlights` 부모당 ≤3(총 ≤60, L6 잔여 1509 해소) · `getEntityLoadCount()==0` · `getPrepareStatementCount()==2`(N∈{10,100} 동일, N 무관 상수).
- **아키텍처 검증**: **ca-architect-sentinel PASS**(pre-commit 워킹트리 감사, blocking 0/advisory 0) — 의존 방향·D1 포트 순수성·HARD-STOP·use-case 계약·CQRS-lite 범위 준수(별도 저장소/마이그레이션/아웃박스 없음 확인)·어댑터 @Transactional 없음·vendor-neutral 네이티브 SQL. `CleanArchitectureTest` 57/57(`QUERY_PORTS_DO_NOT_LEAK…` 포함)·`verifyCleanArchitectureDependencies` GREEN. 회귀 `FeedProjectionIT`·`FeedTopNIT`·`FeedCrownIT` 18/18. spec/quality 리뷰어는 사용자 커밋 후 range로 실행 예정.
- **산출물**: L12 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-20-nplus1-L12-cqrs-lite-read-model-projection-port-lab-guide.md` + `ca-tmpl:docs/notes/L12.md` + 프로덕션 6파일(포트/쿼리/유스케이스/유스케이스테스트/어댑터/IT). 발표 §(CQRS-lite 읽기 모델, 주제2 브릿지)는 리뷰 PASS 후 n+1liner에 신설 예정. 커밋은 사용자.
- **★ 주제2 브릿지**: "CQRS-lite = 별도 읽기 *모델*(같은 저장소), 풀 CQRS = 별도 읽기 *저장소*(에스컬레이션)". 별도 물리 저장소가 필요(고트래픽·가시성 사전계산 = Task 4 feed_visible)해지면 D2를 계약·가드레일과 개정 → 주제2(헥사고날·CQRS) 본격 진입.
- **파생 후보(raw/errors 없음 — 무실패)**: raw/interviews "왜 JdbcTemplate 대신 Hibernate Session native로 window를 실행했나 — Statistics 관측 범위" · raw/blog-topics "CQRS-lite 읽기 모델에서 JPQL SELECT new + Hibernate native를 섞은 이유" · 그리고 **"계약이 풀 CQRS를 에스컬레이션 전용으로 묶어둔 것을 실측으로 발견 → 충돌 표면화 → 범위 재협상"**(거버넌스 사례) — 캐논 추출은 사용자 요청 시.
- **★ check 게이트 선재 블로커 2건(L12 무관, 발견·해소 → raw/errors 후보)**: 사용자 요청으로 전체 `./gradlew check`를 (커밋 없이) 처음 돌리자 두 선재 문제가 표면화 — (1) domain-core `Page`/`User`/`FeedItem`의 checkstyle `NeedBraces` 3건(중괄호 없는 단문 `if`), (2) Flyway 버전 충돌: 공유 `V6__feed.sql`(피드 파운데이션 6f0b0d6)과 sample `V6__poster.sql`(post 도메인 d8cae2f)이 둘 다 V6인데, sample-portfolio가 `locations: db/migration/postgresql,db/sample-migration` 두 위치를 다 로드해 `Found more than one migration with version 6`. **랩 내내 `:app-bootstrap:test`(그 컨텍스트는 db/sample-migration 미로드)만 돌려 전체 게이트가 조용히 red였던 것이 여기서 처음 드러남.** 해소: (1) 중괄호 추가(동작 무변경), (2) 사용자가 "sample은 참고용이라 지워도 됨"이라 했으나 모듈 삭제는 settings·의존매트릭스·app-bootstrap sampleFixture·ArchUnit `..sample.portfolio..` 규칙·sample-isolation verify task 6곳 cascade → 대신 sample `V6__poster.sql → V10__poster.sql` 리넘버(피드 substrate 무변경, 격리). 결과 `./gradlew check` = 1565 tests 0 fail(8 skip) GREEN. **교훈 = "타깃 테스트만 돌리면 전체 게이트 회귀를 놓친다"** → raw/errors 승격 후보(제목: "타깃 테스트가 가린 전체 check 게이트 red — checkstyle + Flyway 멀티모듈 버전충돌").
## 마주친 문제
- Hibernate 7의 collection fetch pagination 경고가 예상한 `HHH000104`가 아니라 `HHH90003004`로 관측됐다. 코드 번호 고정 assertion 대신 메시지 의미를 함께 검사했고, [[raw/errors/hibernate7-hhh90003004-collection-fetch-paging-2026-07-13]]에 분리했다.
- `getCollectionFetchCount()`를 초기화된 컬렉션 수로 해석한 초기 모델이 batch fetch 실측과 어긋났다. fetch SELECT 횟수로 정정하고 [[raw/errors/hibernate-getcollectionfetchcount-batch-semantics-2026-07-13]]에 보존했다.
- target IT만 실행하는 동안 전체 `check`의 checkstyle·Flyway migration 충돌이 드러나지 않았다. 두 선재 문제를 해소한 뒤 전체 `check` 결과를 별도 근거로 기록했으며, target test GREEN만으로 전체 gate를 대체하지 않는다.
## 묶음 (이 branch에서 파생된 자료)
<!-- GENERATED: errors:start -->
- [[raw/errors/hibernate-dto-projection-explain-width-not-narrower-2026-07-13]]
- [[raw/errors/hibernate-getcollectionfetchcount-batch-semantics-2026-07-13]]
- [[raw/errors/hibernate7-hhh90003004-collection-fetch-paging-2026-07-13]]
<!-- GENERATED: errors:end -->
### L0 이후 갱신
- `raw/errors/` — ✅ **실발생(L0)**: `@DataJpaTest` "Unable to find a @SpringBootConfiguration" — IT가 `dev.caskeleton.adapter.outbound.jpa.feed`에 있어 부트앱 `dev.caskeleton.bootstrap.CaSkeletonApplication`(형제 패키지)을 자동 탐색 실패 → 해법 `@ContextConfiguration(classes = CaSkeletonApplication.class)`(`FeedPersistenceIT:42`). (topic-arrange 부록 A.1에 기록; raw/errors 단독 파일은 L1+ 에러와 묶어 승격 예정.) · ✅ **L3/L4 관측(2026-07-13)**: `MultipleBagFetchException`(L3, `IllegalArgumentException`로 래핑) · **`HHH90003004`**(L4 인메모리 페이징 — 예상 `HHH000104` 아님, Hib7 코드 드리프트) → [[raw/errors/hibernate7-hhh90003004-collection-fetch-paging-2026-07-13]] 승격. · ✅ **L5 정정(2026-07-13)**: `getCollectionFetchCount()`가 배치에서 초기화 수(N)가 아니라 fetch 연산 수(ceil(N/batch))로 접힘 → [[raw/errors/hibernate-getcollectionfetchcount-batch-semantics-2026-07-13]] 승격. · ✅ **L6 정정(2026-07-13)**: DTO 프로젝션 EXPLAIN `width`(2088)가 엔티티 `SELECT fi.*`(1194)보다 좁지 않고 오히려 넓음 — 프로젝션 이득은 SQL 플랜 아니라 ORM 층(entityLoadCount 0) → [[raw/errors/hibernate-dto-projection-explain-width-not-narrower-2026-07-13]] 승격. · 예정: IDENTITY 배치 무력화(Phase3 L9). ← 실 에러 메시지 캡처 후 생성.
- `raw/blog-topics/` — "N+1 해법이 다음 문제를 낳는 사슬"(fetch join→MultipleBag→HHH000104→BatchSize→DTO), "N+1은 관계형 문제가 아니다(패치 전략 문제)", "ToOne EAGER 숨은 N+1: 같은 @ManyToOne인데 카디널리티가 곡선을 가른다(page 선형 vs user 평탄) + 접근 0인데 나가는 N+1".
- `raw/interviews/` — "N+1을 깊이있게 다뤘다의 기준"(커버리지 vs 깊이), "Top-N-per-group 3가지 해법 트레이드오프".
## 다음 단계
1. ✅ Task 0~3(스캐폴드·스키마·시더·하네스) = **L0 완료**(위 "L0 완료" 섹션). 하네스 = Hibernate Statistics(`preparedStatementCount`/`collectionFetchCount`) + EXPLAIN.
2.**L1 실행 가이드 작성** = `ca-tmpl:docs/superpowers/plans/2026-07-10-nplus1-L1-collection-nplus1-lab-guide.md`(foundation guide 형식). 핵심: **`getCollectionFetchCount()`(=정확히 N)로 highlights 컬렉션 N+1을 격리** — `preparedStatementCount`(base+user/page EAGER 2차SELECT+highlights 섞임)와 분리. `@ParameterizedTest` N={10,100,1k}로 `collectionFetches==N` 선형 단언 + nanoTime p50/p99(의존성0). **L1=재현·측정 전용(fix 금지)**, 프로덕션 무변경(IT만 확장). 1차캐시/통계누적 gotcha 명시.
3.**L1 실행 완료 (2026-07-10, 실측)**: `FeedPersistenceIT`에 곡선(`@ParameterizedTest` N=10/100/1000)·지연(nanoTime p50/p99)·EXPLAIN 테스트 추가 → `:app-bootstrap:test` GREEN(8 tests, 0 fail). **실측**: collectionFetches = **10/100/1000**(정확히 N, 선형 ✓), preparedStmts = 25/222/2022, ToOne몫(=preparedStmts1collFetch) = 14/121/1021, p50 = 32.8/85.9/193.7ms. **★ 반전(발표 킬러)**: 자식 쿼리 EXPLAIN = `Index Scan using ix_highlights_feed_items_created ... Execution Time 0.173ms`(빠름) — **N+1은 "느린 쿼리"가 아니라 "빠른 쿼리 N번 왕복"**, 인덱스로 안 풀림. UUID `?` 바인딩 정상(`::uuid`, CAST fallback 불필요). 발표본 = 단일 `~/dev/topic-arrange/n+1liner/README.md`에 통합(실측 반영). 측정 코드는 working tree(사용자 커밋 대기).
4.**L2 실행 가이드 작성 (2026-07-11)** = `ca-tmpl:docs/superpowers/plans/2026-07-11-nplus1-L2-toone-eager-nplus1-lab-guide.md`(L1 가이드와 동형). 핵심: L1이 남긴 ToOne몫을 **`getEntityFetchCount()` + 엔티티별 `getEntityStatistics(...).getFetchCount()` 로 격리** → **page=선형 N(아이템당 고유) vs user=평탄 ≤20(풀 dedup)**, "같은 `@ManyToOne` EAGER인데 **카디널리티가 곡선을 가른다**"가 L2 킬러(D4). **정밀화**: L1 note의 ToOne몫 14/121/1021은 Spring Data `Page` count 쿼리를 몫에 섞은 값 — L2는 base(1)+count(1)을 `2`로 분리해 **순수 ToOne = 13/120/1020**(=distinct(user)+N). 측정 설계: ① §2.3 "접근 0" probe(`getUser/getPage/getHighlights` 호출 0인데 `pageFetches=N`·`collectionFetches=0` → "안 짠 N+1" 증명) ② §2.4 곡선(회귀가드 `pageFetches==N`·`userFetches≤20`) ③ §2.5 반복 ToOne 단건 EXPLAIN(PK Index Scan이라 1건 빠름 × N 반복) ④ §2.7 EAGER→LAZY 토글(되돌리는 probe·커밋 금지) + EAGER×접근 2×2 매트릭스. **honesty**: §2.6 수치는 L1 실측에서 회계 항등식으로 **유도**(Docker 미가용, L2 미실행). **L2도 재현·측정 전용(fix 금지)**, 프로덕션 무변경(IT만 확장). D5 고리 = L1+L2 동시 해결 착상(연관 전부 fetch join) → L3 `MultipleBagFetchException`.
5.**L2 실행**(측정): 위 가이드대로 `FeedPersistenceIT`에 L2 측정(§2.2~2.5) 추가 → 실측으로 §2.6 유도값 확정(Claims #7) → `test: lab2 ...` 커밋(영상 4b). §2.7 LAZY 토글은 되돌리고 커밋 제외.
6.**L3·L4·L5·L6 실행 완료 (2026-07-13, 각 섹션 참조)** — fetch join 이중 실패(L3 카테시안·L4 페이징) → L5 배치(첫 fix, 왕복 축) → L6 프로젝션(둘째 fix, 적재 형태 축). Video 1의 해결 투어(L5·L6) 완료. 남은 것 = 커밋(사용자) + 발표 슬라이드.
7. Video 1 완료 후 Phase 4 왕관(Top-N/keyset/가시성) = Video 2 별도 플랜. **진입점 = "L6가 못 푼 Top-N"**(L6 실측 childRows 1509 = 페이지 부모 전량, top-3 아님) → **L14**(윈도우 함수 `row_number() over (partition by ...) <= 3` vs LATERAL vs 2단계 배치).
8.**L14 실행 가이드 작성 + 실행 완료 (2026-07-16, measured GREEN — 위 "L14 완료" 섹션)** = 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-16-nplus1-L14-topn-per-group-window-lateral-2step-lab-guide.md`(L6 per-lab 형식으로 왕관 Task 1 분리·확장) + `FeedTopNIT`(신규 IT, 8 tests GREEN) + 발표 §13 신설(tooling 골든 432/432).
9.**L15 실행 가이드 작성 + 실행 완료 (2026-07-17, measured GREEN — 위 "L15 완료" 섹션)** = 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-17-nplus1-L15-keyset-vs-offset-deep-page-pagination-lab-guide.md`(왕관 Task 2) + `FeedKeysetIT`(신규 IT, 6 tests GREEN) + 발표 §14 신설(옛 §14 다음단계→§15, tooling 골든 432/432). 회귀 111 tests 0 fail(L1~L15 + CleanArchitectureTest 57). **남은 왕관 = L16 가시성 술어 인덱싱**(L15 가시성 OR probe가 진입점) → 왕관 닫으면 CQRS(L12).
10.**L16 실행 가이드 작성 + 실행 완료 (2026-07-18, measured GREEN — 위 "L16 완료" 섹션) ★ 왕관 완결** = 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-18-nplus1-L16-visibility-predicate-indexing-union-partial-precompute-lab-guide.md`(왕관 Task 3) + `FeedVisibilityIT`(신규 IT, 4 tests GREEN) + 발표 §15 신설(옛 §15 다음단계→§16, 왕관 닫힘·CQRS 브릿지, tooling 골든 446/446). 회귀 **115 tests 0 fail**(L1~L16 6개 IT + CleanArchitectureTest 57). **왕관(L14 Top-N·L15 keyset·L16 가시성) 세 보석 모두 완결.** 남은 것 = (선택) Task 4 통합 쿼리+왕관 의사결정 매트릭스, **L12 CQRS(주제2 브릿지)**. **성격 차이(정전 vs 왕관)**: L1~L6은 "착상→단일 fix"였으나 L14는 **"착상→세 해법 대결→트레이드오프 매트릭스"**이고 JPA 설정이 아니라 **SQL·인덱스·DB 설계** 문제 → **SQL은 shape만, 학습자가 직접 타이핑·튜닝**(크라운 철학). **스타 = D2 3안 `EXPLAIN (ANALYZE, BUFFERS)` 플랜 대조**(스캔타입 Index vs Seq·조인 알고리즘 WindowAgg/Nested Loop·buffers hit/read·actual time) — "쿼리 개수"가 아니라 "플랜 shape". 설계 골자: ① 격리 = 새 `FeedTopNIT`(native SQL을 jdbcTemplate EXPLAIN, `loadFeed`/`loadFeedProjection` 무변경) + 선택 sibling `loadFeedTopN`. ② **새 인덱스 불필요** — V6의 `ix_highlights_feed_items_created (feed_item_id, created_at DESC)`를 LATERAL 부모별 `LIMIT 3`이 탐(인덱스 신설 본질은 L16). ③ **인덱스 유무 토글**(`DROP/CREATE INDEX` + `finally` 복구)로 "LATERAL이 빠른 건 LATERAL이 아니라 인덱스 seek 덕"을 실증 = "선배 넘는" 인과. ④ D6 축 = N×**그룹 크기 K**{3,50,500}: 편중 시드 top 부모(500 하이라이트)에서 K=3은 LATERAL 압승(500 중 3 seek), K=500은 윈도우로 수렴. ⑤ **표준 JPQL로 윈도우·LATERAL 불가 → native**(Hibernate 6+ HQL은 윈도우만 확장 지원·LATERAL 없음 — 첫 실행 확인할 INFERENCE, D4). D5 고리 = 아이템 top-3 풀렸으나 **부모 피드 페이징**(OFFSET 깊은 페이지 붕괴) → **L15 keyset**. **실측 확정(가이드 예측과 일치)**: 반환 ⓐ=60/ⓑ=60/ⓒ=1509(=L6 childRows), ⓑ LATERAL buffers 최소(204 vs 430) + 인덱스 토글 168→4446(≈26배)로 인과 확정. 파생: `raw/interviews/`의 "Top-N-per-group 3가지 해법 트레이드오프" 인터뷰가 **실측으로 뒷받침됨**(캐논 추출은 사용자 요청 시).
11.**Crown Task 4 통합 실행 완료 (2026-07-19, measured GREEN — 위 "Crown Task 4 완료" 섹션) ★ 왕관 대관식** = 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-19-nplus1-crown-unified-topn-keyset-visibility-decision-matrix-lab-guide.md` + `docs/notes/crown.md` + `FeedCrownIT`(신규 IT, 4 tests GREEN) + 발표 §16 신설(옛 §16 다음단계→§17, tooling 골든 448/448). 회귀 **119 tests 0 fail**(L1~L16 6개 IT + `FeedCrownIT` + CleanArchitectureTest 57). **통합 = (가시성+keyset 부모) × LATERAL(top-3)**; 부모선택 3안 같은 20 부모, 사전계산 부모선택이 세 기법을 재정렬 없이 한 플랜에 겹침(page 1 Sort 없음). **핵심 발견 = 간섭 시험**: 깊은 페이지 keyset 이 사전계산 위에선 인덱스 range(19행)로, 단일 OR 위에선 매 페이지 가시성 재해소(BitmapOr+멘션 SubPlan 200행)로 — L16 발견의 통합 재현. **★ 실측정정**: 깊은 커서에선 둘 다 남은 19행 작은 Sort(차이는 Sort 유무가 아니라 훑는 행수+feed_visible 인덱스 사용). **왕관 대관식(Video 2 완성)**: L14+L15+L16+Task4 통합 완결. 남은 것 = **L12 CQRS 읽기 모델(주제2 브릿지)** — 사전계산 부모선택(feed_visible)이 진입점.
12.**L12 CQRS-lite 읽기 모델 구현 완료 (2026-07-20, measured GREEN — 위 "L12 완료" 섹션) ★ 주제2 브릿지** = 가이드 `ca-tmpl:docs/superpowers/plans/2026-07-20-nplus1-L12-cqrs-lite-read-model-projection-port-lab-guide.md` + `docs/notes/L12.md` + 프로덕션 6파일(`FeedReadModelQueryPort`/`GetFeedReadModelQuery`/`GetFeedReadModelUseCase`/`GetFeedReadModelUseCaseTest` [application-core], `FeedReadModelQueryAdapter` [adapter-persistence-jpa], `FeedReadModelUseCaseIT` [app-bootstrap]). **첫 프로덕션 코드 변경**(L1~Task4는 IT-only였음) → ca-implementer full-usecase + ca-architect-sentinel PASS(pre-commit). **범위 거버넌스**: 사용자 "실제 CQRS" 선택 → 실측으로 계약 D2("풀 CQRS 별도 저장소 = 에스컬레이션 전용") 발견 → 충돌 표면화 → CQRS-lite로 재선택(계약 내, 별도 저장소·아웃박스 없음). **읽기 모델 = L6 프로젝션(엔티티 0) + L14 window top-3**, 상수 2쿼리, 화면 shape 그대로. 실측: entitiesLoaded=0·prepared=2(N∈{10,100})·top-3. 회귀 18/18 + CleanArchitectureTest 57/57. **남은 것** = 사용자 커밋 → spec/quality 리뷰어(커밋 range) → 발표 §신설. **주제2(헥사고날·CQRS) 진입 시** 별도 물리 읽기 저장소(D2)는 계약·가드레일 개정 후.
## 관련 일일 노트
- 연결된 daily-note는 현재 없다. 날짜별 진행 증거는 본문의 2026-07-08~2026-07-20 완료 기록에 보존되어 있다.
## 완료 후 정리
- PR 링크: 없음 — ca-tmpl 로컬 작업이며 사용자 커밋 대기 상태다.
- 리뷰 메모: L12 pre-commit architecture 감사와 관련 회귀는 PASS; commit range 기반 spec/quality review는 아직 남아 있다.
- 머지 결과 / 배포 환경: 로컬·Testcontainers까지만 검증, staging/prod 배포 없음.
- **wiki 추출 대상** (review 이후 `wiki/projects/`로만 추출):
- `actually-implemented`: L12 same-store CQRS-lite read path.
- `locally-verified`: L1, L3~L6, L14~L16, Crown, L12의 본문 실측 결과.
- **추출하지 않을 항목**: 미실행 L2, full CQRS 별도 read store, prod 성능 주장은 `planned` / `needs-confirmation`으로 유지한다.
@@ -0,0 +1,282 @@
---
title: N+1 Presentation Preparation Contract
source_type: project-note
status: raw
confidence: medium
tags: [project-note, nplus1-presentation-prep, learning, hibernate, hands-on-lab]
related_projects: [nplus1-presentation-prep, ca-tmpl]
created: 2026-07-20
last_reviewed: 2026-07-20
diagrams: [nplus1-presentation-prep/architecture-lab-loop-2026-07-20.drawio, sequence-api-replay-lab-mermaid]
architecture_review: 2026-07-20
status_label: active
project_revision: 1
semantic_surface_exclusions:
- artifact-registry|legacy hub has no project-local Artifact Registry; harness/source/typed-contracts.json is authoritative until migration
- contract-gate-registry|legacy hub has no project-local Contract/Gate Registry; harness/source/typed-contracts.json is authoritative until migration
- flow-stage-registry|legacy hub has no project-local Flow/Stage Registry; harness/source/typed-contracts.json is authoritative until migration
---
# N+1 Presentation Preparation Contract
> Layer: `raw/project-notes/` (primary, hub) → `/ingest` 후 검증된 사실만 `wiki/projects/`로 추출한다.
> 이 문서는 N+1 재현·측정·발표 준비 작업의 최상위 project hub이자 project decision/work-item SSOT다.
> `status_label`: `active`
## 1. 프로젝트 개요
- **한 줄 요약**: ca-tmpl의 feed 조회를 단계별로 재현하고, HTTP·PostgreSQL·Hibernate 관찰값을 근거 등급과 함께 설명할 수 있는 N+1 학습 랩을 만든다.
- **기간**: 2026-07-08 ~ 진행 중
- **현재 상태**: `active`
- **나의 역할 / Role**: 학습 랩 설계자·검증자·발표 준비자
- **저장소 / Repo**: ca-tmpl 로컬 저장소의 `lab/nplus1-highlight-feed`, `lab/nplus1-api-replay` Git branch를 사용한다. 원격 URL은 이 문서에서 확인하지 않았다.
## 2. 문제 정의
### 2.1 현재 상태의 문제
- 마지막 최적화 상태만 보면 lazy collection N+1부터 one-query read까지의 원인·선택·관찰값 변화를 순서대로 재현하기 어렵다.
- 테스트 결과만 읽으면 학습자가 HTTP 응답과 실제 PostgreSQL row를 함께 관찰하는 실행 경로가 드러나지 않는다.
- 로컬 측정 결과를 production 성능·배포 증거로 확대 해석할 위험이 있다.
### 2.2 왜 지금 해결해야 하는가
- **트리거**: N+1 주제를 구현 결과 나열이 아니라 재현 가능한 발표·학습 흐름으로 준비해야 한다.
- **비용**: 단계별 checkpoint와 근거 등급이 없으면 어떤 해법이 어떤 문제를 해결했는지 다시 검증하기 어렵다.
- **기회**: 동일한 관찰 루프를 반복하면 쿼리 수 최적화와 read-model 분리를 서로 다른 선택으로 비교할 수 있다.
### 2.3 성공 기준
- L1, L2, L3, L4, L5, L6, L14, L15, L16, Crown, L12의 정확히 11개 replay checkpoint가 guide와 대응한다.
- 각 checkpoint가 reset → HTTP → PostgreSQL 관찰 순서와 기대 관찰점을 가진다.
- local·Testcontainers·production 증거가 같은 등급으로 섞이지 않고 각 결과에 evidence grade가 기록된다.
- 두 직접 자식 branch가 아래 Work Item Registry의 pinned decision refs와 dependency를 그대로 상속한다.
<!-- section-id: architecture-components -->
## 3. 시스템 아키텍처
### 3.1 아키텍처 다이어그램 (draw.io XML)
**질문**: 학습자가 checkout한 N+1 checkpoint는 어떤 경로를 거쳐 검토 가능한 관찰 기록이 되는가?
![[raw/diagrams/nplus1-presentation-prep/architecture-lab-loop-2026-07-20.drawio]]
다이어그램은 Learner → Lab Checkpoint → Feed Module → PostgreSQL → Evidence Record → Presentation 경로를 나타낸다. 이는 정적 구조와 증거 승격 경계를 설명하며, 시간 순서의 세부 호출은 §4 Mermaid가 소유한다.
### 3.2 컴포넌트 책임 분담
| 컴포넌트 | 역할 | 기술 스택 | 의존하는 외부 |
|---|---|---|---|
| Learner | checkpoint를 checkout하고 관찰 절차를 실행한다 | Git, HTTP client, psql | 로컬 실행 환경 |
| Lab Checkpoint | 학습용 reset·feed 경로를 profile 안에서 노출한다 | Spring profile, HTTP API | Feed Module |
| Feed Module | checkpoint별 조회 전략을 실행한다 | Spring Data JPA, Hibernate | PostgreSQL |
| PostgreSQL | fixture row와 SQL 실행 결과를 제공한다 | PostgreSQL, Docker Compose/Testcontainers | 없음 |
| Evidence Record | 쿼리·entity load·row 관찰값과 등급을 기록한다 | Markdown, test report | 각 checkpoint 결과 |
### 3.3 외부 의존성
| 외부 시스템 | 용도 | 통신 방식 | 장애 시 영향 |
|---|---|---|---|
| Docker runtime | 로컬 PostgreSQL과 Compose/Testcontainers 실행 | local container API | DB 기반 replay와 integration evidence를 수집할 수 없다 |
| PostgreSQL | fixture·native query·row 확인 | JDBC, psql | SQL 관찰 단계가 실패하며 in-memory 결과로 대체하지 않는다 |
### 3.4 배포 다이어그램
운영 배포 토폴로지는 이 프로젝트의 검증 범위가 아니다. `lab` profile이 운영 환경에서 비활성이라는 deployment-level 증거는 아직 `needs-confirmation`이다.
<!-- section-id: runtime-flow -->
## 4. 핵심 시퀀스
<!-- section-id: sequence -->
### 4.1 API replay lab flow
**시나리오**: 학습자가 checkpoint를 checkout한 뒤 lab fixture를 reset하고 feed·DB·Hibernate 관찰값을 기록한다.
```mermaid
sequenceDiagram
autonumber
actor Learner
participant API as Lab API
participant Feed as Feed Module
participant DB as PostgreSQL
participant Stats as Hibernate Statistics
Learner->>API: POST /api/lab/feed:reset {count}
alt lab profile active and input valid
API->>DB: replace marker-owned fixture
DB-->>API: row counts
API-->>Learner: 200 reset result
Learner->>API: GET /api/lab/feed
API->>Feed: execute checkpoint strategy
Feed->>DB: SELECT feed rows
DB-->>Feed: result rows
Feed->>Stats: read statement and load counts
Stats-->>Feed: observation values
Feed-->>API: feed and observations
API-->>Learner: 200 replay result
else lab profile inactive
API-->>Learner: 404 route not registered
else input outside guard
API-->>Learner: 400 VALIDATION_FAILED
end
```
성공 경로의 수치는 checkpoint마다 다르므로 프로젝트 문서가 하나의 고정 수치를 일반화하지 않는다. 관찰값은 각 branch의 Evidence 섹션에서 환경과 함께 판정한다.
## 5. 데이터 모델
별도 프로젝트 데이터 모델을 소유하지 않는다. ca-tmpl feed model과 `created_by = nplus1-lab` marker fixture를 사용하며, 이 문서는 단계·관찰·근거 등급 계약만 소유한다.
## 6. 기술 결정
| 결정 영역 | 선택 | 검토한 대안 | 채택 이유 | 트레이드오프 | 근거 자료 |
|---|---|---|---|---|---|
| 학습 루프 | Measure→Break→Diagnose→Fix→Re-measure→Generalize | 마지막 결과만 설명 | 각 단계의 원인·수정·재측정을 연결한다 | checkpoint와 관찰 기록 유지 비용이 생긴다 | [[raw/branch-notes/experiment-nplus1-highlight-feed]] |
| 실행 substrate | ca-tmpl production substrate + profile/sibling 격리 | 독립 예제 앱 | 실제 모듈 경계를 사용하면서 학습 경로를 정상 runtime과 분리한다 | profile 오활성 여부는 별도 배포 검증이 필요하다 | [[raw/branch-notes/experiment-nplus1-feed-api-replay]] |
| 증거 등급 | local·Testcontainers 결과는 `locally-verified` | 로컬 결과를 운영 결과로 표현 | 검증 환경이 증명하는 범위를 보존한다 | production 결론에는 추가 검증이 필요하다 | [[raw/official-docs/test-taxonomy-testcontainers-official]] |
| CQRS 범위 | same-store CQRS-lite까지 | 별도 physical read store를 즉시 도입 | 쿼리 최적화와 application read-model 분리를 현재 실습 범위에서 비교한다 | full CQRS의 동기화·운영 문제는 다루지 않는다 | [[raw/official-docs/cqrs-pattern-azure-architecture-center]] |
<!-- section-id: project-decisions -->
## 6.1 안정 결정 레지스트리
> 프로젝트가 소유하는 project-wide decision SSOT다. 두 branch packet의 `Project Summary`와 byte-equivalent한 요약을 유지한다.
| Decision ID | Revision | Domain | Decision Summary | Status | Owner | Evidence |
|---|---:|---|---|---|---|---|
| `DEC-NPLUS1-PRESENTATION-PREP-LEARNING-001` | 1 | `learning` | Measure→Break→Diagnose→Fix→Re-measure→Generalize를 랩 완료 루프로 사용한다. | `active` | [[raw/project-notes/nplus1-presentation-prep]] | [[raw/branch-notes/experiment-nplus1-highlight-feed]] |
| `DEC-NPLUS1-PRESENTATION-PREP-SUBSTRATE-001` | 1 | `substrate` | ca-tmpl production substrate를 사용하고 학습 API·측정 경로는 profile과 sibling 경로로 격리한다. | `active` | [[raw/project-notes/nplus1-presentation-prep]] | [[raw/branch-notes/experiment-nplus1-feed-api-replay]] |
| `DEC-NPLUS1-PRESENTATION-PREP-EVIDENCE-001` | 1 | `evidence` | local·Testcontainers 결과는 locally-verified로만 기록하고 prod evidence로 승격하지 않는다. | `active` | [[raw/project-notes/nplus1-presentation-prep]] | [[raw/official-docs/test-taxonomy-testcontainers-official]] |
| `DEC-NPLUS1-PRESENTATION-PREP-SCOPE-001` | 1 | `scope` | same-store CQRS-lite까지를 현재 범위로 두고 full CQRS는 ca-tmpl contract escalation 이후에만 허용한다. | `active` | [[raw/project-notes/nplus1-presentation-prep]] | [[raw/official-docs/cqrs-pattern-azure-architecture-center]] |
<!-- section-id: implementation-boundaries -->
## 7. 비기능 요구사항
- **성능**: production RPS·P99 목표는 설정하지 않는다. checkpoint별 쿼리·entity load 관찰값만 환경과 함께 기록한다.
- **가용성**: 운영 SLO는 이 프로젝트 범위가 아니다.
- **확장성**: 로컬 단일 학습 실행만 검증 범위로 둔다.
- **보안**: 학습 reset/API는 `lab` profile에 한정하고, 정상 profile에서는 route/use case가 등록되지 않아야 한다.
- **운영 / Observability**: Hibernate Statistics, HTTP response, PostgreSQL row 확인을 같은 checkpoint evidence에 연결한다.
- **재해 복구 / DR**: 해당 없음. marker-owned local fixture는 reset으로 재생성한다.
- **컴플라이언스**: 해당 없음. production 데이터는 이 랩의 입력으로 사용하지 않는다.
<!-- section-id: project-work-items -->
## 8.0 실행계획
> 두 직접 자식 branch의 stable handoff SSOT다. Applies Decisions는 revision 1 project decisions 네 개를 모두 pin한다.
| Work Item ID | branch slug | 완료 조건 (측정가능) | Applies Decisions | Dependencies | Status |
|---|---|---|---|---|---|
| `WI-NPLUS1-PRESENTATION-PREP-001` | `experiment-nplus1-highlight-feed` | L2 ToOne EAGER 격리 측정값을 확정하고, L1~L6·L14~L16·Crown·L12의 증거 등급과 사용자 commit-range review를 본문 Closure에 반영한다. | `DEC-NPLUS1-PRESENTATION-PREP-LEARNING-001@1`, `DEC-NPLUS1-PRESENTATION-PREP-SUBSTRATE-001@1`, `DEC-NPLUS1-PRESENTATION-PREP-EVIDENCE-001@1`, `DEC-NPLUS1-PRESENTATION-PREP-SCOPE-001@1` | - | `in-progress` |
| `WI-NPLUS1-PRESENTATION-PREP-002` | `experiment-nplus1-feed-api-replay` | 11개 replay tag와 guide mapping을 고정하고, clean clone/worktree에서 Compose·HTTP·PostgreSQL smoke 및 full-check 상태를 재검증하며 lab profile의 deployment 비활성 증거를 기록한다. | `DEC-NPLUS1-PRESENTATION-PREP-LEARNING-001@1`, `DEC-NPLUS1-PRESENTATION-PREP-SUBSTRATE-001@1`, `DEC-NPLUS1-PRESENTATION-PREP-EVIDENCE-001@1`, `DEC-NPLUS1-PRESENTATION-PREP-SCOPE-001@1` | `WI-NPLUS1-PRESENTATION-PREP-001` | `in-progress` |
## 8. 묶음 (이 프로젝트에 묶이는 모든 raw 자료)
<!-- GENERATED: blog-topics:start -->
- [[raw/blog-topics/nplus1-lab-checkoutable-api-replay-2026-07-15]]
<!-- GENERATED: blog-topics:end -->
### 8.1 브랜치 (project의 직접 자식 branch)
<!-- GENERATED: branches:start -->
- [[raw/branch-notes/experiment-nplus1-feed-api-replay]]
- [[raw/branch-notes/experiment-nplus1-highlight-feed]]
<!-- GENERATED: branches:end -->
> 아래 표는 사람이 빠르게 식별하기 위한 lookup view다. 완료 조건·decision pin·dependency의 SSOT는 `## 8.0 Work Item Registry / 실행계획`이다.
| Branch | Work Item | 현재 단계 |
|---|---|---|
| `experiment-nplus1-highlight-feed` | `WI-NPLUS1-PRESENTATION-PREP-001` | `in-progress` |
| `experiment-nplus1-feed-api-replay` | `WI-NPLUS1-PRESENTATION-PREP-002` | `in-progress` |
### 8.2 근거 자료 (프로젝트 전체 차원 foundational 조사)
프로젝트에 직접 매달린 source는 없다. 각 source는 자신이 정당화하는 branch의 `## Sources / 근거`에서 추적한다.
### 8.3 오류 기록 (branch 외 발생한 환경·운영 이슈)
프로젝트에 직접 매달린 error-note는 없다. replay 과정의 오류는 해당 branch cluster가 소유한다.
### 8.4 면접 준비
프로젝트에 직접 매달린 interview-prep 문서는 없다.
### 8.5 블로그·채용공고 연계 글감
직접 자식은 없다. checkout replay 글감은 [[raw/branch-notes/experiment-nplus1-feed-api-replay]]의 child로 관리한다.
### 8.6 파생 wiki 문서
아직 생성하지 않았다. `reviewed` 이상 canonical로 승급되기 전에는 interview·portfolio·blog를 파생하지 않는다.
## 9. 검증 등급
| 영역 | 등급 | 근거 |
|---|---|---|
| 아키텍처 다이어그램 | `documented-only` | [[raw/diagrams/nplus1-presentation-prep/architecture-lab-loop-2026-07-20.drawio]] |
| 시퀀스 다이어그램 | `documented-only` | §4는 branch의 lab API·DB 관찰 경계를 요약하며 별도 실행 증거를 주장하지 않는다 |
| 기술 결정 | `documented-only` | §6.1 stable registry와 두 branch packet이 동일한 revision 1 refs를 사용한다 |
| replay 구현·로컬 측정 | `locally-verified` | [[raw/branch-notes/experiment-nplus1-feed-api-replay]]의 Docker HTTP + PostgreSQL smoke 기록 |
| 전체 checkpoint closure | `needs-confirmation` | WI-001의 L2 실측과 WI-002의 clean clone/deployment 확인이 남아 있다 |
### 9.1 실제 구현 내용 (`actually-implemented`)
- 11개 replay commit/tag와 `lab` profile의 reset·feed 관찰 경로는 branch note에 코드 존재 근거와 함께 기록되어 있다.
### 9.2 로컬/dev 검증 (`locally-verified`)
- final L12와 historical L1 checkpoint의 Docker HTTP·PostgreSQL smoke 결과는 [[raw/branch-notes/experiment-nplus1-feed-api-replay]]에 환경·명령 경계와 함께 기록되어 있다.
### 9.3 운영 검증 (`prod-verified`)
- 없음. 이 프로젝트는 production 성능·권한·배포를 검증했다고 주장하지 않는다.
### 9.4 문서/계획만 존재 (`documented-only`
- 다른 clean machine/worktree의 전체 11-stage 재현과 deployment manifest의 `lab` profile 비활성 확인은 `needs-confirmation`이다.
## 10. 면접·외부 공개 답변 경계
### 10.1 자신 있게 답할 수 있는 범위
- 각 checkout 단계에서 무엇을 측정하고 다음 단계가 어떤 문제를 다루는지 branch evidence를 근거로 설명할 수 있다.
- Crown one-query 경로와 L12 same-store CQRS-lite two-query read-model이 같은 선택이 아님을 설명할 수 있다.
### 10.2 적당히 답할 수 있는 범위
- 로컬 Docker Compose/Testcontainers에서 관찰한 SQL·HTTP 결과는 환경과 evidence grade를 함께 제시할 때만 답한다.
### 10.3 답하면 안 되는 / 공식 자료를 다시 확인해야 하는 범위
- production latency·throughput·권한 경계·다중 인스턴스 동작은 검증하지 않았다.
- 다른 환경에서 11개 tag가 모두 같은 결과를 낸다고 단정하지 않는다.
### 10.4 과장 금지 지점
- local·Testcontainers 결과를 production evidence로 표현하지 않는다.
- `addScalar` runtime mapping을 SQL compile-time 검증으로 표현하지 않는다.
- same-store CQRS-lite를 별도 read store·동기화 파이프라인을 가진 full CQRS로 표현하지 않는다.
## 11. 아키텍처 검토 체크리스트
- [x] 한 줄 요약·상태·역할을 기록했다.
- [x] 11 checkpoint와 evidence grade의 측정 가능한 성공 기준을 기록했다.
- [x] 정적 구조는 draw.io, 시간축은 Mermaid sequence diagram으로 분리했다.
- [x] Mermaid에 happy path와 profile/input error path를 함께 넣었다.
- [x] project decision 4개와 Work Item 2개를 stable ID·pinned revision으로 고정했다.
- [x] 생성 children block이 두 직접 자식 branch와 일치한다.
- [ ] draw.io에 대한 독립 `wiki-diagram-reviewer` ≥95 판정은 이 문서 작성 범위에서 수행하지 않았다.
- [ ] WI-001·WI-002의 남은 완료 조건을 충족한 뒤 project status와 evidence grade를 재검토한다.
## 12. 다이어그램 파일 관리 가이드
- 정적 구조 SSOT: `raw/diagrams/nplus1-presentation-prep/architecture-lab-loop-2026-07-20.drawio`
- 시간축 SSOT: 이 문서 §4의 Mermaid `sequenceDiagram`
- 구조 또는 흐름이 바뀌면 새 날짜의 draw.io를 추가하고 `architecture_review``last_reviewed`를 함께 갱신한다.
- 검증 환경·수치 변경은 다이어그램 안이 아니라 branch evidence와 이 문서 §9에 기록한다.
## 13. 관련 개념
- [[raw/official-docs/spring-data-jpa-projections-spring-official]] — projection/read shape의 공식 경계.
- [[raw/official-docs/cqrs-pattern-azure-architecture-center]] — same-store read/write model 분리와 별도 store CQRS의 범위 구분.
- [[raw/official-docs/test-taxonomy-testcontainers-official]] — 실제 dependency를 사용하는 integration evidence의 근거.