chore: 문서를 작성할 때 한국어의 표현 작성 스킬 추가 및 1인칭 관점의 글 작성 검증 테스트 추가

This commit is contained in:
DongHyeonka
2026-07-29 16:48:03 +09:00
parent c39406bbdd
commit 41501b5d06
520 changed files with 95494 additions and 2231 deletions
@@ -0,0 +1,29 @@
-- Crown Task 4 — deep-page keyset on the precompute parent path (index-range seek, ~19 rows)
-- FeedCrownIT.crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr (seed 2000, user008, deepest page)
-- 읽기 포인트 (★ 실측 정정): 깊은 커서에선 사전계산 경로도 작은 Sort 가 붙는다 — Bitmap Index Scan on
-- ix_feed_visible 이 남은 19행만 인덱스 range(Index Cond 에 ROW(...) < ROW(cursor))로 훑고, Bitmap 은
-- 정렬 출력을 안 하므로 19행을 quicksort(26kB). 핵심: 훑는 행수 19, 부모 buffers 3 — 페이지 근방만 만진다
-- (OFFSET 의 scan-then-discard 도, 단일 OR 의 전체 가시성 재해소도 아니다).
Nested Loop (cost=22.44..299.62 rows=60 width=686) (actual time=0.023..0.059 rows=19 loops=1)
Buffers: shared hit=60
-> Limit (cost=22.17..22.22 rows=20 width=24) (actual time=0.013..0.015 rows=19 loops=1)
Buffers: shared hit=3
-> Sort (cost=22.17..22.22 rows=21 width=24) (actual time=0.013..0.014 rows=19 loops=1)
Sort Key: feed_visible.first_highlighted_at DESC, feed_visible.feed_item_id DESC
Sort Method: quicksort Memory: 26kB
Buffers: shared hit=3
-> Bitmap Heap Scan on feed_visible (cost=4.49..21.71 rows=21 width=24) (actual time=0.005..0.006 rows=19 loops=1)
Recheck Cond: ((viewer_id = 'e6a2f6da-c975-41ef-9b48-a4a6c1a72cc0'::uuid) AND (ROW(first_highlighted_at, feed_item_id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid)))
Heap Blocks: exact=1
Buffers: shared hit=3
-> Bitmap Index Scan on ix_feed_visible (cost=0.00..4.49 rows=21 width=0) (actual time=0.003..0.003 rows=19 loops=1)
Index Cond: ((viewer_id = 'e6a2f6da-c975-41ef-9b48-a4a6c1a72cc0'::uuid) AND (ROW(first_highlighted_at, feed_item_id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid)))
Buffers: shared hit=2
-> Limit (cost=0.28..13.83 rows=3 width=670) (actual time=0.002..0.002 rows=1 loops=19)
Buffers: shared hit=57
-> Index Scan using ix_highlights_feed_items_created on highlights h (cost=0.28..36.42 rows=8 width=670) (actual time=0.002..0.002 rows=1 loops=19)
Index Cond: (feed_item_id = feed_visible.feed_item_id)
Buffers: shared hit=57
Planning Time: 0.075 ms
Execution Time: 0.118 ms
@@ -0,0 +1,47 @@
-- Crown Task 4 — deep-page keyset on the single-OR parent path (re-resolves visibility every page)
-- FeedCrownIT.crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr (seed 2000, user008, deepest page)
-- 읽기 포인트: 단일 OR 부모선택은 사전계산 읽기 모델(ix_feed_visible)을 못 쓴다(구조적). 매 페이지 가시성
-- 3분기를 BitmapOr 로 다시 풀고(public/mentioned = ix_feed_items_visibility_sort, private = ix_feed_items_private),
-- 멘션 EXISTS 는 hashed SubPlan 2 로 user008 의 멘션 200행을 materialize 한다 → 훑는 행수 200(사전계산 19 대비).
-- 커서는 세 분기 Index Cond 에 ROW(...) < ROW(cursor) 로 들어가 seek 은 하나, 페이지마다 전체 가시성을 재계산한다.
Nested Loop (cost=94.10..163.18 rows=15 width=686) (actual time=0.128..0.161 rows=19 loops=1)
Buffers: shared hit=88
-> Limit (cost=93.82..93.83 rows=5 width=24) (actual time=0.118..0.120 rows=19 loops=1)
Buffers: shared hit=31
-> Sort (cost=93.82..93.83 rows=5 width=24) (actual time=0.117..0.119 rows=19 loops=1)
Sort Key: fi.first_highlighted_at DESC, fi.id DESC
Sort Method: quicksort Memory: 26kB
Buffers: shared hit=31
-> Bitmap Heap Scan on feed_items fi (cost=12.90..93.76 rows=5 width=24) (actual time=0.027..0.112 rows=19 loops=1)
Recheck Cond: ((((visibility)::text = 'PUBLIC'::text) AND (ROW(first_highlighted_at, id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid))) OR (((visibility)::text = 'MENTIONED'::text) AND (ROW(first_highlighted_at, id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid))) OR ((user_id = 'e6a2f6da-c975-41ef-9b48-a4a6c1a72cc0'::uuid) AND (ROW(first_highlighted_at, id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid)) AND ((visibility)::text = 'PRIVATE'::text)))
Filter: (((visibility)::text = 'PUBLIC'::text) OR (((visibility)::text = 'MENTIONED'::text) AND (hashed SubPlan 2)) OR (((visibility)::text = 'PRIVATE'::text) AND (user_id = 'e6a2f6da-c975-41ef-9b48-a4a6c1a72cc0'::uuid)))
Rows Removed by Filter: 4
Heap Blocks: exact=6
Buffers: shared hit=31
-> BitmapOr (cost=12.90..12.90 rows=7 width=0) (actual time=0.019..0.019 rows=0 loops=1)
Buffers: shared hit=6
-> Bitmap Index Scan on ix_feed_items_visibility_sort (cost=0.00..4.31 rows=3 width=0) (actual time=0.013..0.014 rows=48 loops=1)
Index Cond: (((visibility)::text = 'PUBLIC'::text) AND (ROW(first_highlighted_at, id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid)))
Buffers: shared hit=2
-> Bitmap Index Scan on ix_feed_items_visibility_sort (cost=0.00..4.31 rows=3 width=0) (actual time=0.003..0.003 rows=18 loops=1)
Index Cond: (((visibility)::text = 'MENTIONED'::text) AND (ROW(first_highlighted_at, id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid)))
Buffers: shared hit=2
-> Bitmap Index Scan on ix_feed_items_private (cost=0.00..4.28 rows=1 width=0) (actual time=0.001..0.001 rows=1 loops=1)
Index Cond: ((user_id = 'e6a2f6da-c975-41ef-9b48-a4a6c1a72cc0'::uuid) AND (ROW(first_highlighted_at, id) < ROW('2026-04-17 21:00:00+00'::timestamp with time zone, '759efcc1-efd0-4a24-94d6-7e83001fabb1'::uuid)))
Buffers: shared hit=2
SubPlan 2
-> Bitmap Heap Scan on feed_item_mentions m (cost=4.33..24.04 rows=7 width=16) (actual time=0.014..0.038 rows=200 loops=1)
Recheck Cond: (mentioned_user_id = 'e6a2f6da-c975-41ef-9b48-a4a6c1a72cc0'::uuid)
Heap Blocks: exact=16
Buffers: shared hit=19
-> Bitmap Index Scan on ix_mentions_user (cost=0.00..4.33 rows=7 width=0) (actual time=0.011..0.011 rows=200 loops=1)
Index Cond: (mentioned_user_id = 'e6a2f6da-c975-41ef-9b48-a4a6c1a72cc0'::uuid)
Buffers: shared hit=3
-> Limit (cost=0.28..13.83 rows=3 width=670) (actual time=0.002..0.002 rows=1 loops=19)
Buffers: shared hit=57
-> Index Scan using ix_highlights_feed_items_created on highlights h (cost=0.28..36.42 rows=8 width=670) (actual time=0.002..0.002 rows=1 loops=19)
Index Cond: (feed_item_id = fi.id)
Buffers: shared hit=57
Planning Time: 0.153 ms
Execution Time: 0.250 ms
@@ -0,0 +1,24 @@
-- Crown Task 4 — unified feed query (precompute parent path): visibility + keyset + Top-N in ONE plan
-- FeedCrownIT.crownUnifiedPlanStacksVisibilityKeysetAndTopN (seed 2000, viewer user008, page 1)
-- 읽기 포인트: 세 기법이 한 플랜에 재정렬(Sort) 없이 겹쳐 있다 —
-- (1) 가시성+keyset = feed_visible 커버링 인덱스의 Index Only Scan (viewer_id 조건, Heap Fetches 20),
-- (2) Top-N = 부모 20건당 ix_highlights_feed_items_created 로 top-3 index seek (Nested Loop = LATERAL),
-- (3) Sort 노드 없음 — 두 순서(부모 keyset·자식 created_at)가 모두 인덱스에서 나온다.
Nested Loop (cost=0.56..271.10 rows=60 width=686) (actual time=0.036..0.092 rows=60 loops=1)
Buffers: shared hit=65 read=2
-> Limit (cost=0.28..1.84 rows=20 width=24) (actual time=0.025..0.028 rows=20 loops=1)
Buffers: shared hit=1 read=2
-> Index Only Scan using ix_feed_visible on feed_visible (cost=0.28..117.22 rows=1500 width=24) (actual time=0.025..0.027 rows=20 loops=1)
Index Cond: (viewer_id = '9ed28556-7ab3-4f5a-b327-7e0dd9536bf8'::uuid)
Heap Fetches: 20
Buffers: shared hit=1 read=2
-> Limit (cost=0.28..13.42 rows=3 width=670) (actual time=0.003..0.003 rows=3 loops=20)
Buffers: shared hit=64
-> Index Scan using ix_highlights_feed_items_created on highlights h (cost=0.28..48.47 rows=11 width=670) (actual time=0.002..0.003 rows=3 loops=20)
Index Cond: (feed_item_id = feed_visible.feed_item_id)
Buffers: shared hit=64
Planning:
Buffers: shared hit=11 read=1
Planning Time: 0.122 ms
Execution Time: 0.120 ms
@@ -0,0 +1,20 @@
Plan A — 반복되는 하이라이트 자식 쿼리의 실행계획
출처: FeedPersistenceIT.l1ExplainRepeatedHighlightChildQuery 콘솔 출력
조건: 대량 시드 직후, ANALYZE 미실행. warm buffer cache(shared read=0).
쿼리: SELECT * FROM highlights WHERE feed_item_id = ? (ORDER BY / LIMIT 없음)
Index Scan using ix_highlights_feed_items_created on highlights
(cost=0.27..8.29 rows=1 width=710) (actual time=0.026..0.123 rows=500 loops=1)
Index Cond: (feed_item_id = '2b5b931f-...'::uuid)
Buffers: shared hit=14
Planning Time: 0.086 ms
Execution Time: 0.173 ms
주의(문서 §6.4 caveat와 동일):
- 플래너 추정 rows=1 vs 실제 rows=500 → 500배 오추정. 시드 후 ANALYZE 미실행으로 통계가
feed_item_id별 편중을 반영하지 못한 것으로 보임. EXPLAIN 전 `ANALYZE highlights` 필요.
- Buffers: shared hit=14, read=0 → warm buffer cache 결과. cold 디스크 I/O 실행시간으로 읽지 말 것.
- Execution Time 0.173 ms는 PostgreSQL executor 내부 시간. ORM 엔티티 생성·JDBC 결과 전달·
DTO 매핑·직렬화·HTTP를 포함하지 않으므로 애플리케이션 지연(§6.2)과 같은 지표가 아니다.
Plan B (`ANALYZE highlights` 실행 후 재측정) — 예정(pending). 아직 미실행이므로 값 없음.
@@ -0,0 +1,29 @@
-- L14 index toggle — the SAME LATERAL query with ix_highlights_feed_items_created DROPPED
-- FeedTopNIT.l14LateralDependsOnCompositeIndex (seed 1000, page 20, K=3; index dropped then restored)
-- 읽기 포인트: 인덱스가 없으면 부모마다 highlights 를 Seq Scan 하고(Rows Removed by Filter: 2842/loop)
-- top-N heapsort 로 3개를 고른다 → child 쪽 buffers shared hit=4340, 전체 4446 (인덱스판 168 의 ~26배),
-- Execution 5.472 ms (인덱스판 0.336 ms 의 ~16배). 결론: LATERAL 이 빠른 건 LATERAL 이 아니라
-- (feed_item_id, created_at DESC) 인덱스 seek 덕. 인덱스가 없으면 LATERAL 도 무너진다.
Nested Loop (cost=376.28..5070.14 rows=60 width=686) (actual time=0.599..5.451 rows=60 loops=1)
Buffers: shared hit=4446
-> Limit (cost=129.28..129.33 rows=20 width=24) (actual time=0.218..0.221 rows=20 loops=1)
Buffers: shared hit=106
-> Sort (cost=129.28..130.87 rows=636 width=24) (actual time=0.218..0.219 rows=20 loops=1)
Sort Key: fi.first_highlighted_at DESC, fi.id
Sort Method: top-N heapsort Memory: 26kB
Buffers: shared hit=106
-> Seq Scan on feed_items fi (cost=0.00..112.36 rows=636 width=24) (actual time=0.086..0.150 rows=1000 loops=1)
Buffers: shared hit=106
-> Limit (cost=246.99..247.00 rows=3 width=670) (actual time=0.261..0.261 rows=3 loops=20)
Buffers: shared hit=4340
-> Sort (cost=246.99..247.02 rows=12 width=670) (actual time=0.259..0.259 rows=3 loops=20)
Sort Key: h.created_at DESC
Sort Method: top-N heapsort Memory: 25kB
Buffers: shared hit=4340
-> Seq Scan on highlights h (cost=0.00..246.84 rows=12 width=670) (actual time=0.171..0.249 rows=75 loops=20)
Filter: (feed_item_id = fi.id)
Rows Removed by Filter: 2842
Buffers: shared hit=4340
Planning Time: 0.079 ms
Execution Time: 5.472 ms
@@ -0,0 +1,26 @@
-- L14 (b) LATERAL top-3 per parent — the winning strategy (index seek)
-- FeedTopNIT.l14ExplainThreeWayPlanCompareIsTheCrownJewel (seed 1000, page 20, K=3)
-- SELECT p.id, top3.color, top3.text, top3.created_at
-- FROM (SELECT fi.id FROM feed_items fi ORDER BY fi.first_highlighted_at DESC, fi.id ASC LIMIT 20) p
-- CROSS JOIN LATERAL (SELECT h.color, h.text, h.created_at FROM highlights h
-- WHERE h.feed_item_id = p.id ORDER BY h.created_at DESC LIMIT 3) top3
-- 읽기 포인트: 부모마다 ix_highlights_feed_items_created 를 Index Scan 하고 Limit 3 에서 멈춘다
-- (loops=20, 각 rows=3). buffers shared hit=204 로 세 해법 중 최소.
Nested Loop (cost=172.25..432.10 rows=60 width=686) (actual time=0.257..0.310 rows=60 loops=1)
Buffers: shared hit=204
-> Limit (cost=171.97..172.02 rows=20 width=24) (actual time=0.239..0.240 rows=20 loops=1)
Buffers: shared hit=141
-> Sort (cost=171.97..174.09 rows=846 width=24) (actual time=0.238..0.239 rows=20 loops=1)
Sort Key: fi.first_highlighted_at DESC, fi.id
Sort Method: top-N heapsort Memory: 26kB
Buffers: shared hit=141
-> Seq Scan on feed_items fi (cost=0.00..149.46 rows=846 width=24) (actual time=0.111..0.175 rows=1000 loops=1)
Buffers: shared hit=141
-> Limit (cost=0.28..12.96 rows=3 width=670) (actual time=0.003..0.003 rows=3 loops=20)
Buffers: shared hit=63
-> Index Scan using ix_highlights_feed_items_created on highlights h (cost=0.28..80.61 rows=19 width=670) (actual time=0.003..0.003 rows=3 loops=20)
Index Cond: (feed_item_id = fi.id)
Buffers: shared hit=63
Planning Time: 0.068 ms
Execution Time: 0.323 ms
@@ -0,0 +1,26 @@
-- L14 (c) two-step IN + app-side cut — correct result but transfers ALL page-parent highlights
-- FeedTopNIT.l14ExplainThreeWayPlanCompareIsTheCrownJewel (seed 1000, page 20)
-- SELECT h.feed_item_id, h.color, h.text, h.created_at FROM highlights h
-- WHERE h.feed_item_id IN (<page-20 ids>) ORDER BY h.feed_item_id, h.created_at DESC
-- 읽기 포인트: window 와 동일한 Hash Semi Join(rows=1509) — 다만 위에 WindowAgg 가 없어 1509행을 전량
-- 애플리케이션으로 전송한다(앱에서 부모별 top-3 컷). buffers shared hit=430 (window 과 동일 = 같은 스캔).
-- = L6 프로젝션이 남긴 잔여(childRows=1509)의 정체. 전송 낭비: 60행이면 되는데 1509행.
Sort (cost=531.52..532.49 rows=388 width=686) (actual time=1.586..1.625 rows=1509 loops=1)
Sort Key: h.feed_item_id, h.created_at DESC
Sort Method: quicksort Memory: 155kB
Buffers: shared hit=430
-> Hash Semi Join (cost=172.47..514.84 rows=388 width=686) (actual time=0.493..0.833 rows=1509 loops=1)
Hash Cond: (h.feed_item_id = "ANY_subquery".id)
Buffers: shared hit=430
-> Seq Scan on highlights h (cost=0.00..327.85 rows=3885 width=686) (actual time=0.239..0.363 rows=2917 loops=1)
Buffers: shared hit=289
-> Hash (cost=172.22..172.22 rows=20 width=16) (actual time=0.251..0.252 rows=20 loops=1)
Buffers: shared hit=141
-> Subquery Scan on "ANY_subquery" (cost=171.97..172.22 rows=20 width=16) (actual time=0.239..0.241 rows=20 loops=1)
-> Limit (cost=171.97..172.02 rows=20 width=24) (actual time=0.238..0.240 rows=20 loops=1)
-> Sort (cost=171.97..174.09 rows=846 width=24) (actual time=0.238..0.238 rows=20 loops=1)
Sort Key: fi.first_highlighted_at DESC, fi.id
-> Seq Scan on feed_items fi (cost=0.00..149.46 rows=846 width=24) (actual time=0.112..0.175 rows=1000 loops=1)
Planning Time: 0.060 ms
Execution Time: 1.686 ms
@@ -0,0 +1,33 @@
-- L14 (a) window row_number() <= 3 — cuts in the DB but scans the whole partition
-- FeedTopNIT.l14ExplainThreeWayPlanCompareIsTheCrownJewel (seed 1000, page 20, K=3)
-- SELECT t.feed_item_id, t.color, t.text, t.created_at FROM (
-- SELECT h.feed_item_id, h.color, h.text, h.created_at,
-- row_number() OVER (PARTITION BY h.feed_item_id ORDER BY h.created_at DESC) AS rn
-- FROM highlights h WHERE h.feed_item_id IN (<page-20 ids>)) t WHERE t.rn <= 3
-- 읽기 포인트: Hash Semi Join 이 페이지 부모들의 하이라이트 전량(rows=1509)을 읽고 Sort 한 뒤 WindowAgg 가
-- 순번을 매긴다. PG 15+ 는 rn<=3 을 WindowAgg 의 Run Condition 으로 밀어넣지만, 파티션 정렬은
-- 이미 1509행 전량을 훑는다. 반환은 60행이지만 buffers shared hit=430 (two-step 과 같다 = 같은 스캔).
Subquery Scan on t (cost=531.52..543.16 rows=388 width=686) (actual time=1.376..1.486 rows=60 loops=1)
Buffers: shared hit=430
-> WindowAgg (cost=531.52..539.28 rows=388 width=694) (actual time=1.375..1.482 rows=60 loops=1)
Run Condition: (row_number() OVER (?) <= 3)
Buffers: shared hit=430
-> Sort (cost=531.52..532.49 rows=388 width=686) (actual time=1.369..1.406 rows=1509 loops=1)
Sort Key: h.feed_item_id, h.created_at DESC
Sort Method: quicksort Memory: 155kB
Buffers: shared hit=430
-> Hash Semi Join (cost=172.47..514.84 rows=388 width=686) (actual time=0.590..0.941 rows=1509 loops=1)
Hash Cond: (h.feed_item_id = "ANY_subquery".id)
Buffers: shared hit=430
-> Seq Scan on highlights h (cost=0.00..327.85 rows=3885 width=686) (actual time=0.289..0.421 rows=2917 loops=1)
Buffers: shared hit=289
-> Hash (cost=172.22..172.22 rows=20 width=16) (actual time=0.287..0.288 rows=20 loops=1)
Buffers: shared hit=141
-> Subquery Scan on "ANY_subquery" (cost=171.97..172.22 rows=20 width=16) (actual time=0.277..0.280 rows=20 loops=1)
-> Limit (cost=171.97..172.02 rows=20 width=24) (actual time=0.277..0.279 rows=20 loops=1)
-> Sort (cost=171.97..174.09 rows=846 width=24) (actual time=0.276..0.277 rows=20 loops=1)
Sort Key: fi.first_highlighted_at DESC, fi.id
-> Seq Scan on feed_items fi (cost=0.00..149.46 rows=846 width=24) (actual time=0.146..0.212 rows=1000 loops=1)
Planning Time: 0.123 ms
Execution Time: 1.552 ms
@@ -0,0 +1,16 @@
-- L15 keyset WITH sort-key index (range seek) — same deep page, offset 1980 equivalent cursor
-- FeedKeysetIT.l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex (seed 2000)
-- SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
-- WHERE (fi.first_highlighted_at, fi.id) < (TIMESTAMPTZ '...', '...'::uuid) -- cursor = 이전 페이지 마지막 행
-- ORDER BY fi.first_highlighted_at DESC, fi.id DESC LIMIT 20
-- 읽기 포인트: Index Only Scan(커버링) 으로 커서 이후 20행만 seek — actual rows=20, Heap Fetches=20, buffers 1(+2 read).
-- 순서가 인덱스로 보장돼 Sort 노드가 없다. 페이지 깊이와 무관하게 상수(vs OFFSET 의 2000).
Limit (cost=0.28..18.14 rows=20 width=24) (actual time=0.054..0.060 rows=20 loops=1)
Buffers: shared hit=1 read=2
-> Index Only Scan using ix_feed_items_keyset on feed_items fi (cost=0.28..595.95 rows=667 width=24) (actual time=0.054..0.057 rows=20 loops=1)
Index Cond: (ROW(first_highlighted_at, id) < ROW('2026-04-17 13:00:00+00'::timestamp with time zone, '17ab2b68-0981-43cc-a673-5757f7214899'::uuid))
Heap Fetches: 20
Buffers: shared hit=1 read=2
Planning Time: 0.052 ms
Execution Time: 0.076 ms
@@ -0,0 +1,18 @@
-- L15 keyset WITHOUT the sort-key index — same query, ix_feed_items_keyset absent
-- FeedKeysetIT.l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex (seed 2000)
-- 읽기 포인트: 결과 행(20)은 필터로 같지만, 정렬키 인덱스가 없어 Seq Scan 으로 2000 heap 행을 훑고
-- (Rows Removed by Filter: 1980) Sort 한다 → buffers shared hit=141 (Index Only Scan 판의 ~140배).
-- OFFSET(141)과 같은 buffers = 둘 다 전량 heap 접근. 정렬키 인덱스가 keyset 의 전제라는 증거.
Limit (cost=204.50..204.55 rows=20 width=24) (actual time=0.338..0.341 rows=20 loops=1)
Buffers: shared hit=141
-> Sort (cost=204.50..206.72 rows=887 width=24) (actual time=0.337..0.339 rows=20 loops=1)
Sort Key: first_highlighted_at DESC, id DESC
Sort Method: quicksort Memory: 26kB
Buffers: shared hit=141
-> Seq Scan on feed_items fi (cost=0.00..180.90 rows=887 width=24) (actual time=0.314..0.317 rows=20 loops=1)
Filter: (ROW(first_highlighted_at, id) < ROW('2026-04-17 13:00:00+00'::timestamp with time zone, '17ab2b68-0981-43cc-a673-5757f7214899'::uuid))
Rows Removed by Filter: 1980
Buffers: shared hit=141
Planning Time: 0.074 ms
Execution Time: 0.373 ms
@@ -0,0 +1,19 @@
-- L15 OFFSET deep page (scan-then-discard) — page 100 of 100, offset 1980
-- FeedKeysetIT.l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex (seed 2000, keyset index present)
-- SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
-- ORDER BY fi.first_highlighted_at DESC, fi.id DESC OFFSET 1980 LIMIT 20
-- 읽기 포인트: 정렬키 인덱스가 있어도 깊은 페이지에선 Seq Scan(2000)+Sort(2000) 로 전량을 훑고 20만 남긴다.
-- Limit 하위 actual rows=2000 = 결과 20행을 위해 훑은 행(over-scan = offset+20). buffers shared hit=141.
Limit (cost=275.61..275.66 rows=20 width=24) (actual time=0.945..0.949 rows=20 loops=1)
Buffers: shared hit=141
-> Sort (cost=270.66..275.66 rows=2000 width=24) (actual time=0.759..0.865 rows=2000 loops=1)
Sort Key: first_highlighted_at DESC, id DESC
Sort Method: quicksort Memory: 189kB
Buffers: shared hit=141
-> Seq Scan on feed_items fi (cost=0.00..161.00 rows=2000 width=24) (actual time=0.124..0.369 rows=2000 loops=1)
Buffers: shared hit=141
Planning:
Buffers: shared hit=5 read=1
Planning Time: 0.137 ms
Execution Time: 0.996 ms
@@ -0,0 +1,33 @@
-- L15 probe (→ L16): keyset + visibility OR/EXISTS — the sort-key index is lost
-- FeedKeysetIT.l15ProbeVisibilityOrBreaksKeysetIndex (seed 2000, ix_feed_items_keyset present)
-- SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
-- WHERE (fi.visibility='PUBLIC'
-- OR (fi.visibility='MENTIONED' AND EXISTS(SELECT 1 FROM feed_item_mentions m WHERE m.feed_item_id=fi.id AND m.mentioned_user_id=:me))
-- OR (fi.visibility='PRIVATE' AND fi.user_id=:me))
-- AND (fi.first_highlighted_at, fi.id) < (:cursor)
-- ORDER BY fi.first_highlighted_at DESC, fi.id DESC LIMIT 20
-- 읽기 포인트: ix_feed_items_keyset(정렬키) 를 못 탄다. 대신 BitmapOr(visibility 3분기 각각 ix_feed_items_visibility_sort)
-- + BitmapAnd(private = visibility ∩ user_id) + SubPlan(mentions EXISTS). bitmap 은 순서를 안 주므로
-- Sort 노드가 재등장 = keyset 의 "순서 seek, Sort 없음" 이점 소멸 → L16(UNION 분해로 각 분기를 정렬 보장 인덱스로).
Limit (cost=100.32..100.34 rows=5 width=24) (actual time=0.189..0.193 rows=13 loops=1)
Buffers: shared hit=26
-> Sort (cost=100.32..100.34 rows=5 width=24) (actual time=0.188..0.190 rows=13 loops=1)
Sort Key: fi.first_highlighted_at DESC, fi.id DESC
-> Bitmap Heap Scan on feed_items fi (actual time=0.114..0.178 rows=13 loops=1)
Recheck Cond: (((visibility='PUBLIC') AND (ROW(first_highlighted_at, id) < ROW(cursor)))
OR ((visibility='MENTIONED') AND (ROW(first_highlighted_at, id) < ROW(cursor)))
OR ((visibility='PRIVATE') AND (ROW(first_highlighted_at, id) < ROW(cursor)) AND (user_id = :me)))
Filter: ((visibility='PUBLIC') OR ((visibility='MENTIONED') AND (SubPlan 1)) OR ((visibility='PRIVATE') AND (user_id = :me)))
Rows Removed by Filter: 3
-> BitmapOr (actual time=0.093..0.094 rows=0 loops=1)
-> Bitmap Index Scan on ix_feed_items_visibility_sort (Index Cond: visibility='PUBLIC' AND ROW(...) < ROW(cursor))
-> Bitmap Index Scan on ix_feed_items_visibility_sort (Index Cond: visibility='MENTIONED' AND ROW(...) < ROW(cursor))
-> BitmapAnd
-> Bitmap Index Scan on ix_feed_items_visibility_sort (Index Cond: visibility='PRIVATE' AND ROW(...) < ROW(cursor))
-> Bitmap Index Scan on uq_feed_items_user_page (Index Cond: user_id = :me)
SubPlan 1
-> Index Only Scan using uq_feed_item_mentions on feed_item_mentions m (loops=4)
Index Cond: ((feed_item_id = fi.id) AND (mentioned_user_id = :me))
Planning Time: 0.319 ms
Execution Time: 0.325 ms
@@ -0,0 +1,18 @@
-- L16 (c) precompute (CQRS read model) — per-viewer feed_visible table, single covering index scan
-- FeedVisibilityIT.l16ExplainThreeWayPlanCompare / l16PrecomputeIsSingleIndexScanNoOrNoSort (seed 2000)
-- CREATE TABLE feed_visible AS SELECT :me AS viewer_id, fi.id AS feed_item_id, fi.first_highlighted_at
-- FROM feed_items fi WHERE <visible to me>;
-- CREATE INDEX ix_feed_visible ON feed_visible (viewer_id, first_highlighted_at DESC, feed_item_id DESC);
-- SELECT feed_item_id AS id, first_highlighted_at FROM feed_visible
-- WHERE viewer_id=:me ORDER BY first_highlighted_at DESC, feed_item_id DESC LIMIT 20
-- 읽기 포인트: 단일 Index Only Scan(커버링) — OR 도 조인도 Sort 도 없다. buffers shared hit=1(+2 read),
-- 훑는 행 20. 단일 OR(122)·UNION(200) 대비 order-of-magnitude 적음 = CQRS 읽기 모델의 정체.
Limit (cost=0.28..1.84 rows=20 width=24) (actual time=0.021..0.025 rows=20 loops=1)
Buffers: shared hit=1 read=2
-> Index Only Scan using ix_feed_visible on feed_visible (cost=0.28..117.22 rows=1500 width=24) (actual time=0.021..0.023 rows=20 loops=1)
Index Cond: (viewer_id = :me)
Heap Fetches: 20
Buffers: shared hit=1 read=2
Planning Time: 0.102 ms
Execution Time: 0.034 ms
@@ -0,0 +1,30 @@
-- L16 (a) single OR — the naive visibility filter: BitmapOr + top-N Sort + hashed SubPlan
-- FeedVisibilityIT.l16ExplainThreeWayPlanCompare (seed 2000, viewer user008)
-- SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
-- WHERE (fi.visibility='PUBLIC'
-- OR (fi.visibility='MENTIONED' AND EXISTS(SELECT 1 FROM feed_item_mentions m WHERE m.feed_item_id=fi.id AND m.mentioned_user_id=:me))
-- OR (fi.visibility='PRIVATE' AND fi.user_id=:me))
-- ORDER BY fi.first_highlighted_at DESC, fi.id DESC LIMIT 20
-- 읽기 포인트: seq scan 이 아니라 BitmapOr(3분기 인덱스)로 후보 1500 을 heap scan → top-N Sort(순서 손실)
-- + 멘션 EXISTS 는 hashed SubPlan(후보마다 반복 아님). buffers shared hit=122.
Limit (cost=224.45..224.49 rows=15 width=24) (actual time=0.713..0.716 rows=20 loops=1)
Buffers: shared hit=122
-> Sort (cost=224.45..224.49 rows=15 width=24) (actual time=0.712..0.713 rows=20 loops=1)
Sort Key: fi.first_highlighted_at DESC, fi.id DESC
Sort Method: top-N heapsort Memory: 26kB
Buffers: shared hit=122
-> Bitmap Heap Scan on feed_items fi (actual time=0.256..0.590 rows=1500 loops=1)
Recheck Cond: ((visibility='PUBLIC') OR (visibility='MENTIONED') OR ((user_id=:me) AND (visibility='PRIVATE')))
Filter: ((visibility='PUBLIC') OR ((visibility='MENTIONED') AND (hashed SubPlan 2)) OR ((visibility='PRIVATE') AND (user_id=:me)))
Rows Removed by Filter: 200
Buffers: shared hit=122
-> BitmapOr (actual time=0.185..0.185 rows=0 loops=1)
-> Bitmap Index Scan on ix_feed_items_visibility_sort (Index Cond: visibility='PUBLIC') rows=2400
-> Bitmap Index Scan on ix_feed_items_visibility_sort (Index Cond: visibility='MENTIONED') rows=800
-> Bitmap Index Scan on ix_feed_items_private (Index Cond: user_id=:me) rows=100
SubPlan 2
-> Bitmap Heap Scan on feed_item_mentions m (Recheck Cond: mentioned_user_id=:me) rows=200
-> Bitmap Index Scan on ix_mentions_user (Index Cond: mentioned_user_id=:me) rows=200
Planning Time: 0.144 ms
Execution Time: 0.808 ms
@@ -0,0 +1,20 @@
-- L16 union branches — each visibility branch rides its own optimal plan (what single-OR can't)
-- FeedVisibilityIT.l16LowSelectivityBranchesRideTheirIndex (seed 2000, viewer user008)
-- 읽기 포인트: 저선택도 분기는 자기 인덱스를 탄다 — mentioned=ix_mentions_user 조인, private=ix_feed_items_private
-- partial 의 Index Only Scan. public(60% 고선택도)은 Bitmap Heap Scan+top-N Sort 가 최적.
-- 단일 OR 은 3분기를 하나의 bitmap 으로 묶어 분기별 최적 플랜을 못 가진다.
== mentioned branch (JOIN feed_item_mentions on ix_mentions_user) ==
Limit -> Sort (top-N) -> Hash Join (fi.id = m.feed_item_id)
-> Bitmap Heap Scan on feed_items fi (visibility='MENTIONED')
-> Hash -> Bitmap Heap Scan on feed_item_mentions m
-> Bitmap Index Scan on ix_mentions_user (Index Cond: mentioned_user_id = :me) rows=200
== private branch (partial index ix_feed_items_private WHERE visibility='PRIVATE') ==
Limit -> Incremental Sort (Presorted Key: first_highlighted_at)
-> Index Only Scan using ix_feed_items_private on feed_items fi (Index Cond: user_id = :me) Heap Fetches: 21
== public branch (60% selectivity -> seq/bitmap + top-N is optimal, not an index range) ==
Limit -> Sort (top-N heapsort)
-> Bitmap Heap Scan on feed_items fi (Recheck Cond: visibility='PUBLIC')
-> Bitmap Index Scan on ix_feed_items_visibility_sort (Index Cond: visibility='PUBLIC')
@@ -0,0 +1,27 @@
-- L16 (b) UNION decompose — 3 visibility branches, each index-ordered, Merge Append + Hash Join
-- FeedVisibilityIT.l16ExplainThreeWayPlanCompare (seed 2000, viewer user008)
-- (public branch) UNION ALL (mentioned branch: JOIN feed_item_mentions) UNION ALL (private branch: partial idx)
-- ORDER BY first_highlighted_at DESC, id DESC LIMIT 20
-- 읽기 포인트: Merge Append 가 미리 정렬된 분기 스트림을 병합(전체 재정렬 없음). 멘션 EXISTS 가 Hash Join(집합 기반)
-- 으로, private 는 Index Only Scan(partial)+Incremental Sort 로. 구조는 우수하나 buffers 200(분기별 스캔).
Limit (cost=97.16..97.40 rows=13 width=24) (actual time=0.655..0.661 rows=20 loops=1)
Buffers: shared hit=200
-> Merge Append (cost=97.16..97.40 rows=13 width=24) (actual time=0.654..0.659 rows=20 loops=1)
Sort Key: fi.first_highlighted_at DESC, fi.id DESC
Buffers: shared hit=200
-> Limit (rows=17) -- public branch
-> Sort (top-N heapsort)
-> Bitmap Heap Scan on feed_items fi (Recheck Cond: visibility='PUBLIC')
-> Bitmap Index Scan on ix_feed_items_visibility_sort
-> Limit (rows=3) -- mentioned branch: EXISTS -> Hash Join
-> Sort (top-N heapsort)
-> Hash Join (Hash Cond: fi_1.id = m.feed_item_id)
-> Bitmap Heap Scan on feed_items fi_1 (visibility='MENTIONED')
-> Hash -> Bitmap Heap Scan on feed_item_mentions m
-> Bitmap Index Scan on ix_mentions_user (mentioned_user_id=:me)
-> Limit (rows=2) -- private branch: partial index, index-only
-> Incremental Sort (Presorted Key: fi_2.first_highlighted_at)
-> Index Only Scan using ix_feed_items_private on feed_items fi_2 (user_id=:me) Heap Fetches: 21
Planning Time: 0.398 ms
Execution Time: 0.780 ms
@@ -0,0 +1,29 @@
컬렉션 하나만 fetch join한 조인의 실행계획 (Fetch Join 시도 — 카테시안 행 곱)
출처: FeedPersistenceIT.l3ExplainCollectionJoinRowMultiplication 콘솔 출력
조건: seed(100) 직후. warm buffer cache(shared read=0).
쿼리: EXPLAIN (ANALYZE, BUFFERS)
SELECT fi.id, h.id FROM feed_items fi JOIN highlights h ON h.feed_item_id = fi.id
(fetch join `select f from FeedItemJpaEntity f join fetch f.highlights`가 발행하는 조인과 같은 shape)
Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 rows=1961 loops=1)
Hash Cond: (h.feed_item_id = fi.id)
Buffers: shared hit=450
-> Seq Scan on highlights h (cost=0.00..424.02 rows=4202 width=32) (actual time=0.471..0.570 rows=1961 loops=1)
Buffers: shared hit=382
-> Hash (cost=72.08..72.08 rows=408 width=16) (actual time=0.112..0.112 rows=100 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 13kB
Buffers: shared hit=68
-> Seq Scan on feed_items fi (cost=0.00..72.08 rows=408 width=16) (actual time=0.084..0.092 rows=100 loops=1)
Buffers: shared hit=68
Planning Time: 0.099 ms
Execution Time: 0.959 ms
관찰(문서 §9):
- §6.4는 반복되는 자식 단건 쿼리를, §7.4는 반복되는 부모 단건 쿼리를 봤다. 여기서는 조인 한 방을 본다 —
Hash Join 노드의 actual rows=1961이 카테시안의 실체다. 부모 feed_items는 100행(Hash 노드)인데,
조인 결과는 1,961행(= Σ highlights)으로 부푼다. 쿼리는 하나인데 그 하나가 실어 나르는 행이 곱이다.
- 이 1,961이 N2 랩(§7)의 아이템 수 100이 아니라 자식 총량(1,961)과 같다는 게 핵심 — 전송 비용이
'왕복 수'에서 '전송 행수'로 옮겨갔다.
- Buffers: shared read=0 → warm buffer cache. cold 디스크 I/O 실행시간으로 읽지 말 것.
- Execution Time 0.959 ms는 executor 내부 시간(§6.4 caveat와 동일). 애플리케이션 지연이 아니다.
- rows=4202(추정) vs rows=1961(실제)의 오차는 대량 시드 직후 ANALYZE 미실행 탓(§6.4 Plan A와 같은 통계 이슈).
@@ -0,0 +1,35 @@
컬렉션 fetch join + 페이징이 발행하는 조인의 실행계획 (a) — LIMIT 노드 없음
출처: FeedPersistenceIT.l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes 콘솔 출력 (a)
조건: seed(100) 직후. warm buffer cache(shared read=0).
쿼리: EXPLAIN (ANALYZE, BUFFERS)
SELECT fi.*, h.* FROM feed_items fi JOIN highlights h ON h.feed_item_id = fi.id
ORDER BY fi.first_highlighted_at DESC, fi.id ASC
(fetch join `select f from FeedItemJpaEntity f join fetch f.highlights order by ...`가
페이징(setMaxResults) 시 발행하는 조인과 같은 shape — 단, SQL에 LIMIT이 붙지 않는다)
Sort (cost=293.30..297.76 rows=1782 width=1904) (actual time=1.219..1.266 rows=1961 loops=1)
Sort Key: fi.first_highlighted_at DESC, fi.id
Sort Method: quicksort Memory: 445kB
Buffers: shared hit=173
-> Hash Join (cost=12.48..197.08 rows=1782 width=1904) (actual time=0.279..0.636 rows=1961 loops=1)
Hash Cond: (h.feed_item_id = fi.id)
Buffers: shared hit=173
-> Seq Scan on highlights h (cost=0.00..179.82 rows=1782 width=710) (actual time=0.231..0.330 rows=1961 loops=1)
Buffers: shared hit=162
-> Hash (cost=11.66..11.66 rows=66 width=1194) (actual time=0.035..0.036 rows=100 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 22kB
Buffers: shared hit=11
-> Seq Scan on feed_items fi (cost=0.00..11.66 rows=66 width=1194) (actual time=0.018..0.023 rows=100 loops=1)
Buffers: shared hit=11
Planning Time: 0.135 ms
Execution Time: 1.369 ms
관찰(문서 §10):
- 계획 어디에도 Limit 노드가 없다 = DB가 페이징을 하지 않았다. 조인 결과 전체(actual rows=1961 = Σ highlights)를
quicksort로 445kB 정렬한 뒤 그대로 반환한다. 페이지 크기(20)로 자르는 일은 SQL 밖 — Hibernate가 메모리에서 한다.
- 부모 feed_items는 100행(Hash 노드)인데 Hash Join 노드 actual rows=1961(= Σ highlights, §9.3)로 부푼다 —
컬렉션 fetch join의 카테시안이 그대로다. 그 곱해진 행에 DB LIMIT을 걸면 "20개 부모"가 아니라 "20개 조인 행"을
잘라 어떤 부모는 하이라이트가 잘린 반쪽으로 로드될 위험 → 그래서 Hibernate가 LIMIT을 빼고 인메모리 페이징한다.
- Buffers: shared read=0 → warm buffer cache. cold 디스크 I/O 실행시간으로 읽지 말 것.
- Execution Time 1.369 ms는 executor 내부 시간(§6.4 caveat와 동일). 애플리케이션 지연이 아니다.
- 대조군은 l4-entity-paging-limit.txt (엔티티만 페이징 → Limit 노드 존재).
@@ -0,0 +1,25 @@
엔티티만 페이징한 SQL의 실행계획 (b) — Limit 노드 존재 (대조군)
출처: FeedPersistenceIT.l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes 콘솔 출력 (b)
조건: seed(100) 직후. warm buffer cache(shared read=0).
쿼리: EXPLAIN (ANALYZE, BUFFERS)
SELECT fi.* FROM feed_items fi ORDER BY fi.first_highlighted_at DESC, fi.id ASC LIMIT 20
(fetch join 없이 엔티티만 페이징한 SQL — DB가 정상적으로 페이징하는 모습)
Limit (cost=13.42..13.47 rows=20 width=1194) (actual time=0.038..0.040 rows=20 loops=1)
Buffers: shared hit=11
-> Sort (cost=13.42..13.58 rows=66 width=1194) (actual time=0.038..0.038 rows=20 loops=1)
Sort Key: first_highlighted_at DESC, id
Sort Method: top-N heapsort Memory: 28kB
Buffers: shared hit=11
-> Seq Scan on feed_items fi (cost=0.00..11.66 rows=66 width=1194) (actual time=0.015..0.019 rows=100 loops=1)
Buffers: shared hit=11
Planning Time: 0.029 ms
Execution Time: 0.050 ms
관찰(문서 §10):
- 계획 최상단에 Limit 노드가 있고 그 아래 Sort가 top-N heapsort(28kB)로 상위 20행만 취한다 = DB가 페이징을 했다.
(a) l4-collection-join-no-limit.txt는 Limit 노드가 없어 전체 1961행을 quicksort(445kB)로 정렬했다 — 대조가 요점.
- 28kB(top-N heapsort, 20행) vs 445kB(quicksort, 1961행): "DB 페이징 vs 인메모리 페이징"의 메모리 비용 차이가
계획 레벨로 드러난다. (a)에 Limit이 없다는 것 자체가 "DB가 페이징을 안 했다 → Hibernate가 메모리에서 했다"의 증거.
- 컬럼명·리터럴 하드코딩이라 인젝션 무관. PG 계획 문구는 버전·통계에 따라 흔들릴 수 있어 강가드 대신 눈 대조로 둔다.
- Buffers: shared read=0 → warm buffer cache. Execution Time 0.050 ms는 executor 내부 시간(§6.4 caveat와 동일).
@@ -0,0 +1,38 @@
배치 IN 조회의 실행계획 (b) — 행을 곱하지 않는다 (카테시안 소멸)
출처: FeedBatchFetchIT.l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication 콘솔 출력 (b)
조건: seed(100) 직후, default_batch_fetch_size=100 세션. warm buffer cache.
쿼리: EXPLAIN (ANALYZE, BUFFERS)
SELECT h.* FROM highlights h
WHERE h.feed_item_id IN (SELECT fi.id FROM feed_items fi
ORDER BY fi.first_highlighted_at DESC, fi.id ASC LIMIT 20)
(배치 페치가 페이지 부모 20개의 highlights 를 IN 한 방으로 채우는 것과 같은 shape)
Hash Semi Join (cost=72.46..317.64 rows=234 width=710) (actual time=0.295..0.541 rows=1509 loops=1)
Hash Cond: (h.feed_item_id = "ANY_subquery".id)
Buffers: shared hit=272
-> Seq Scan on highlights h (cost=0.00..236.43 rows=2343 width=710) (actual time=0.207..0.286 rows=1961 loops=1)
Buffers: shared hit=213
-> Hash (cost=72.21..72.21 rows=20 width=16) (actual time=0.084..0.085 rows=20 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
Buffers: shared hit=59
-> Subquery Scan on "ANY_subquery" (cost=71.96..72.21 rows=20 width=16) (actual time=0.077..0.080 rows=20 loops=1)
Buffers: shared hit=59
-> Limit (cost=71.96..72.01 rows=20 width=24) (actual time=0.077..0.078 rows=20 loops=1)
Buffers: shared hit=59
-> Sort (cost=71.96..72.84 rows=354 width=24) (actual time=0.076..0.077 rows=20 loops=1)
Sort Key: fi.first_highlighted_at DESC, fi.id
Sort Method: top-N heapsort Memory: 26kB
Buffers: shared hit=59
-> Seq Scan on feed_items fi (cost=0.00..62.54 rows=354 width=24) (actual time=0.053..0.059 rows=100 loops=1)
Buffers: shared hit=59
Planning:
Buffers: shared hit=28
Planning Time: 0.206 ms
Execution Time: 0.592 ms
관찰(문서 §11):
- Semi Join 이 반환하는 행 = 1509(페이지 20개 부모의 highlights). 부모 M행 × 자식 = M×K 로 곱하지 않는다.
L3 카테시안(조인이 feed_items ⋈ highlights 를 1961행으로 곱함)과 정반대 — 자식 K행만 반환(합, 곱 아님).
- 배치 페치가 하는 일이 이 shape다: 페이지 부모 키를 모아 WHERE feed_item_id IN (…) 로 한 방에 채운다.
Hibernate 는 이를 default_batch_fetch_size 만큼 쪼개 ceil(pageItems/batch) 번 발행한다.
- Buffers: read≈0 → warm buffer cache. Execution Time 0.592 ms 는 executor 내부 시간(§6.4 caveat와 동일).
@@ -0,0 +1,26 @@
엔티티만 페이징한 SQL의 실행계획 (a) — Limit 노드 존재 (배치 페치 해법의 페이징)
출처: FeedBatchFetchIT.l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication 콘솔 출력 (a)
조건: seed(100) 직후, default_batch_fetch_size=100 세션. warm buffer cache.
쿼리: EXPLAIN (ANALYZE, BUFFERS)
SELECT fi.* FROM feed_items fi ORDER BY fi.first_highlighted_at DESC, fi.id ASC LIMIT 20
(배치 해법은 fetch join을 버리고 엔티티만 페이징한다 → DB가 정상 페이징)
Limit (cost=71.96..72.01 rows=20 width=1194) (actual time=0.108..0.109 rows=20 loops=1)
Buffers: shared hit=65
-> Sort (cost=71.96..72.84 rows=354 width=1194) (actual time=0.107..0.108 rows=20 loops=1)
Sort Key: first_highlighted_at DESC, id
Sort Method: top-N heapsort Memory: 28kB
Buffers: shared hit=65
-> Seq Scan on feed_items fi (cost=0.00..62.54 rows=354 width=1194) (actual time=0.075..0.080 rows=100 loops=1)
Buffers: shared hit=59
Planning:
Buffers: shared hit=14 read=1
Planning Time: 0.085 ms
Execution Time: 0.118 ms
관찰(문서 §11):
- 계획 최상단에 Limit 노드가 있다 = DB가 페이징을 했다. top-N heapsort 28kB로 상위 20행만 취한다.
- L4 (a)(컬렉션 fetch join)는 Limit 노드가 없어 전체 1961행을 quicksort(445kB)로 정렬했다 — 정반대.
fetch join을 버리니(엔티티만 페이징) 페이징이 DB로 내려간다(HHH000104 인메모리 페이징 소멸).
- 대조군: l5-batch-in-semijoin.txt (페이지 부모들의 highlights 를 IN 한 방으로 — 행을 곱하지 않는다).
- Buffers: read≈0 → warm buffer cache. Execution Time 0.118 ms 는 executor 내부 시간(§6.4 caveat와 동일).
@@ -0,0 +1,27 @@
-- L6 (b) child scalar IN projection — SELECT new carrier(h.feedItem.id, h.color, h.text, h.createdAt)
-- FeedProjectionIT.l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth (seed 100, page size 20)
-- SELECT h.feed_item_id, h.color, h.text, h.created_at FROM highlights h
-- WHERE h.feed_item_id IN (SELECT fi.id FROM feed_items fi ORDER BY fi.first_highlighted_at DESC, fi.id ASC LIMIT 20)
-- 읽기 포인트: Hash Semi Join 이라 자식 행(1509)만 반환 — 부모 M × 자식 K 로 곱하지 않는다(카테시안 없음).
-- L5 배치 IN (b)와 같은 shape/행수(1509). 프로젝션은 필요 컬럼만(color/text/created_at) 읽는다.
Hash Semi Join (cost=50.51..235.83 rows=177 width=686) (actual time=0.218..0.452 rows=1509 loops=1)
Hash Cond: (h.feed_item_id = "ANY_subquery".id)
Buffers: shared hit=202
-> Seq Scan on highlights h (cost=0.00..178.71 rows=1771 width=686) (actual time=0.148..0.226 rows=1961 loops=1)
Buffers: shared hit=161
-> Hash (cost=50.26..50.26 rows=20 width=16) (actual time=0.067..0.067 rows=20 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
Buffers: shared hit=41
-> Subquery Scan on "ANY_subquery" (cost=50.01..50.26 rows=20 width=16) (actual time=0.060..0.062 rows=20 loops=1)
Buffers: shared hit=41
-> Limit (cost=50.01..50.06 rows=20 width=24) (actual time=0.059..0.060 rows=20 loops=1)
Buffers: shared hit=41
-> Sort (cost=50.01..50.62 rows=246 width=24) (actual time=0.059..0.059 rows=20 loops=1)
Sort Key: fi.first_highlighted_at DESC, fi.id
Sort Method: top-N heapsort Memory: 26kB
Buffers: shared hit=41
-> Seq Scan on feed_items fi (cost=0.00..43.46 rows=246 width=24) (actual time=0.035..0.041 rows=100 loops=1)
Buffers: shared hit=41
Planning Time: 0.064 ms
Execution Time: 0.497 ms
@@ -0,0 +1,34 @@
-- L6 (a) parent scalar projection — SELECT new carrier(f.id, u.name, u.username, p.url, p.title, f.firstHighlightedAt)
-- FeedProjectionIT.l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth (seed 100, page size 20)
-- SELECT fi.id, u.name, u.username, p.url, p.title, fi.first_highlighted_at
-- FROM feed_items fi JOIN users u ON u.id = fi.user_id JOIN pages p ON p.id = fi.page_id
-- ORDER BY fi.first_highlighted_at DESC, fi.id ASC LIMIT 20
-- 읽기 포인트: Limit 노드 존재(페이징 정상)이나 width=2088 로 엔티티 SELECT fi.*(L5 (a) width 1194)보다 넓다
-- — users/pages 조인 + PG varchar 추정치 탓. 프로젝션 이득은 EXPLAIN 아니라 ORM 층(entityLoadCount 0).
Limit (cost=102.56..102.61 rows=20 width=2088) (actual time=0.187..0.190 rows=20 loops=1)
Buffers: shared hit=81
-> Sort (cost=102.56..103.17 rows=246 width=2088) (actual time=0.187..0.188 rows=20 loops=1)
Sort Key: fi.first_highlighted_at DESC, fi.id
Sort Method: top-N heapsort Memory: 27kB
Buffers: shared hit=81
-> Hash Join (cost=51.24..96.01 rows=246 width=2088) (actual time=0.137..0.161 rows=100 loops=1)
Hash Cond: (fi.page_id = p.id)
Buffers: shared hit=81
-> Hash Join (cost=10.68..54.79 rows=246 width=1072) (actual time=0.072..0.087 rows=100 loops=1)
Hash Cond: (fi.user_id = u.id)
Buffers: shared hit=43
-> Seq Scan on feed_items fi (cost=0.00..43.46 rows=246 width=56) (actual time=0.053..0.057 rows=100 loops=1)
Buffers: shared hit=41
-> Hash (cost=10.30..10.30 rows=30 width=1048) (actual time=0.010..0.010 rows=20 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 10kB
Buffers: shared hit=2
-> Seq Scan on users u (cost=0.00..10.30 rows=30 width=1048) (actual time=0.003..0.004 rows=20 loops=1)
Buffers: shared hit=2
-> Hash (cost=39.14..39.14 rows=114 width=1048) (actual time=0.059..0.059 rows=100 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 17kB
Buffers: shared hit=38
-> Seq Scan on pages p (cost=0.00..39.14 rows=114 width=1048) (actual time=0.040..0.045 rows=100 loops=1)
Buffers: shared hit=38
Planning Time: 0.134 ms
Execution Time: 0.228 ms
@@ -0,0 +1,20 @@
반복되는 Page ToOne 부모 쿼리의 실행계획 (N2 — 선형 범인)
출처: FeedPersistenceIT.l2ExplainRepeatedPageToOneQuery 콘솔 출력
조건: seed(100) 직후. warm buffer cache(shared read=0). id는 시드된 실제 pages.id 1건.
쿼리: SELECT * FROM pages WHERE id = ? (@ManyToOne EAGER가 행마다 반복하는 2차 SELECT)
Index Scan using pk_pages on pages
(cost=0.14..8.15 rows=1 width=2104) (actual time=0.009..0.009 rows=1 loops=1)
Index Cond: (id = 'a069f5ac-fa46-41f8-bcde-174153789467'::uuid)
Buffers: shared hit=2
Planning Time: 0.031 ms
Execution Time: 0.021 ms
주의(문서 §7.3 / §6.4 caveat와 동일):
- PK 조회라 pk_pages Index Scan으로 1건 0.021 ms. "쿼리가 느려서"가 아니다 — page는 아이템당
고유(dedup 없음)라 이 빠른 계획이 정확히 N번 반복되는 게 문제다(왕복 N회).
- users 계획(toone-users-plan.txt)과 실행계획이 사실상 동일하다. 비용을 가르는 것은 계획이 아니라
반복 횟수(page=N vs user=distinct≤20)다 — 카디널리티가 곡선을 가른다.
- Buffers: shared hit=2, read=0 → warm buffer cache. cold 디스크 I/O 실행시간으로 읽지 말 것.
- Execution Time 0.021 ms는 executor 내부 시간. 애플리케이션 지연(§6.2)과 같은 지표가 아니다.
- 인덱스로 안 풀린다(계획이 이미 PK Index Scan). 왕복 횟수 자체를 줄이는 fetch 전략이 필요(§9).
@@ -0,0 +1,19 @@
반복되는 User ToOne 부모 쿼리의 실행계획 (N2 — 평탄, 1차 캐시 dedup)
출처: FeedPersistenceIT.l2ExplainRepeatedPageToOneQuery 콘솔 출력
조건: seed(100) 직후. warm buffer cache(shared read=0). id는 시드된 실제 users.id 1건.
쿼리: SELECT * FROM users WHERE id = ? (@ManyToOne EAGER가 반복하는 2차 SELECT)
Index Scan using pk_users on users
(cost=0.14..8.15 rows=1 width=2104) (actual time=0.013..0.014 rows=1 loops=1)
Index Cond: (id = '0a2a85ed-f8f3-47f0-b957-c477f4b077ab'::uuid)
Buffers: shared hit=2
Planning Time: 0.027 ms
Execution Time: 0.022 ms
주의(문서 §7.3):
- 단건 실행계획은 pages(toone-pages-plan.txt)와 사실상 동일하다: 둘 다 pk Index Scan, ~0.02 ms.
- 그러나 반복 횟수가 다르다. user는 소수 풀(≤20)을 재사용하고 한 번 로드된 대상은 영속성
컨텍스트(1차 캐시)에 남아 재조회되지 않으므로, 서로 다른 대상(distinct target) 수만큼만
나간다 → N과 무관하게 ≤20에서 평탄. page는 아이템당 고유라 N번.
- 결론: 같은 @ManyToOne(EAGER)·같은 실행계획인데 곡선이 갈리는 원인은 계획이 아니라
데이터 분포(카디널리티)다. EXPLAIN만 보면 둘이 똑같아 보이는 것이 '숨은' N+1의 얼굴이다.