--- 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` ## 부모 - [[raw/project-notes/nplus1-presentation-prep]] - **substrate 위치**: ~~별도 lab 프로젝트~~ → **ca-tmpl 프로덕션 모듈에 실제 제품 도메인**(2026-07-08 재결정, 아래 섹션). CLAUDE.md Template reuse #4와 일치. - 형제(예정): keycloak 계열(주제3), 아키텍처 3종 비교(주제2) ## 브랜치 계약 패킷 - **생성 시 프로젝트 개정**: `1` - **패킷 스키마**: `contract_packet: 1` - **완료 조건**: L2 ToOne EAGER 격리 측정값을 확정하고, L1~L6·L14~L16·Crown·L12의 증거 등급과 사용자 commit-range review를 본문 Closure에 반영한다. ### 상속한 프로젝트 결정 | 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]] | ### 브랜치 지역 결정 기존 branch-local 결정은 아래 `## Decision Evidence Map`의 D-01~D-10이 소유한다. ### 선언한 예외 - 없음. ## 목표 선배 의도 = "많이 에러 내보고 시행착오"하는 **실패주도 체화(딸깍 금지)**. 지식량이 아니라 **방법**이 딸깍과 체화를 가른다. 그래서 모든 랩은 루프로 돈다: > 측정(Measure) → 고의로 부순다(Break) → 진단(Diagnose) → 고친다(Fix) → 재측정(Re-measure) → 일반화(Generalize) 핵심 질문: - N+1 "정전 6종"을 **구성+테스트만** 하면 깊이있게 다룬 것인가? → **아니다.** 그건 바닥(재현)이지 천장이 아니다. 깊이 = 측정치 + 해법→문제 사슬 + 시그니처 난제 + 일반화. - "다시 딥하게"의 정체(선배 넘는 지점) = 과제 시그니처 난제 3개: **Top-N-per-group(페이지당 3) / keyset vs OFFSET / 가시성 술어 인덱싱** → Video 2 왕관. ## 범위 ### 포함 범위 - 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().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된 N−page개가 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 (...)` 스칼라만). 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 (...)` 스칼라만)이 엔티티를 **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(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된 N−page개가 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 (...)` 스칼라만 뽑는 실제 쿼리). 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 (...)`는 스칼라만 뽑아 영속 엔티티를 인스턴스화하지 않음(조인은 컬럼 접근용, 하이드레이션 아님) → 영속성 컨텍스트 미적재·더티체킹 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=visible−20)에서 — 사전계산은 `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 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에서 파생된 자료) - [[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]] ### 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몫(=preparedStmts−1−collFetch) = 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`으로 유지한다.