컬렉션 하나만 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와 같은 통계 이슈).