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

75 KiB
Raw Blame History

title, source_type, status, id, kind, project, work_item, inherits, refines, overrides, depends_on, contract_packet, branch, parent_branch, git_branch, related_projects, tags, created, target_merge, status_label, contract_packet_sha256
title source_type status id kind project work_item inherits refines overrides depends_on contract_packet branch parent_branch git_branch related_projects tags created target_merge status_label contract_packet_sha256
branch / experiment-nplus1-highlight-feed (N+1 발표 준비 — 라이너 하이라이트 피드 랩) branch-note raw BR-NPLUS1-PRESENTATION-PREP-001 project-work-item nplus1-presentation-prep WI-NPLUS1-PRESENTATION-PREP-001
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
1 experiment-nplus1-highlight-feed lab/nplus1-highlight-feed
nplus1-presentation-prep
ca-skeleton
branch
nplus1-presentation-prep
persistence
testing
hibernate
postgresql
hands-on-lab
2026-07-08 in-progress 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 격리 측정값을 확정하고, L1L6·L14L16·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 피드 도메인에서 L0L6, L14L16, 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

  • L0·L1·L3~L6 재현 및 측정 — 등급: locally-verified
  • L14~L16과 Crown 통합 쿼리 비교 — 등급: locally-verified
  • 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), 핫스팟 H1H7, 시그니처 난제 5, 랩 L0L16 Phase 04, 발표 목차 6075분.
  • Video 1 랩 플랜: docs/superpowers/plans/2026-07-05-nplus1-video1-liner-feed-lab.md — Task 09(Foundation 3 + 측정 하네스 L0 + 정전 랩 L1L6).
  • 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 사전계산). 동일 D1D6, D2(EXPLAIN N안 대조)가 스타 지표. 왕관 사슬: L6 미해결 "페이지당3"→L14→피드페이징→L15→정렬키+가시성 동시인덱싱→L16→CQRS(L12). Task 0(EXPLAIN N안 비교 하네스+keyset 커서 유틸) + Task 13(랩) + 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종: 17=Video1 깊이, 812=Video2, 1317=미포함) — "ORM 조회 문제를 깊이 다루는가?"에 대한 자기감사. ② DB 심화 학습 포인트(ORM 아래 레이어: 인덱스 선두컬럼·커버링·partial, 플래너 EXPLAIN 노드, 조인 nested/hash/merge=N+1은 앱레벨 nested loop, LATERAL, keyset, 윈도우함수, Little's Law, MVCC, IDENTITY vs SEQUENCE, WAL/VACUUM). ★=랩에서 직접 / ◇=랩 밖 독립 심화. 프론티어 원본 목록 F1F9 중 채택 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 회귀 결과
FeedReadModelQueryPortGetFeedReadModelUseCaseFeedReadModelQueryAdapter 같은 DB에서 write aggregate와 read projection logic을 분리하고, 부모 page + top-3 자식의 2-query read model을 반환한다. locally-verifiedAZURE-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로만 참조(순수성).
  • 애그리거트 퍼시스턴스 포트 *Repositorydomain 패키지, 프로젝션 읽기 포트 *QueryPort는 application.
  • CQRS 갈래: 쓰기=FeedItem 애그리거트(N+1 재현), 읽기=FeedQueryPortFeedView 프로젝션(L6/L12 무대). 랩은 FeedQueryAdapter.loadFeed body만 교체(포트 고정).
  • 매퍼 hand-written static(ULID↔UUID). 어댑터 @Transactional 금지(트랜잭션=유즈케이스 TransactionPort). 감사=퍼시스턴스 AuditableEntity(@MappedSuperclass, 수동 stamp). highlights의 created_atAuditableEntity.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 밖)의 @ColumncolumnDefinition(예: "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) = DoNotIncludeTestsArchUnit은 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 멱함수 1500 = "수백 개" 재현, 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(D1D6) + 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 C11C14 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 = WindowAggHash Semi Join(전량 rows=1509), buffers 430. ⓒ 2단계 = SortHash 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 = LimitSort(2000)←Seq Scan(2000), buffers 141, 0.996ms. keyset+인덱스 = LimitIndex 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에서 파생된 자료)

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(L1L16 6개 IT + CleanArchitectureTest 57). 왕관(L14 Top-N·L15 keyset·L16 가시성) 세 보석 모두 완결. 남은 것 = (선택) Task 4 통합 쿼리+왕관 의사결정 매트릭스, L12 CQRS(주제2 브릿지). 성격 차이(정전 vs 왕관): L1L6은 "착상→단일 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, L3L6, L14L16, Crown, L12의 본문 실측 결과.
  • 추출하지 않을 항목: 미실행 L2, full CQRS 별도 read store, prod 성능 주장은 planned / needs-confirmation으로 유지한다.