Files
llm-wiki/raw/branch-notes/experiment-nplus1-highlight-feed.md

379 lines
75 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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`으로 유지한다.