상황: 2023년 3월 주문의 6,001~6,030번째 행을 주문일시·회원ID·주문번호 순으로 표시하면서 매장명과 회원 닉네임도 함께 출력한다. 정렬·페이징 기준은 주문 컬럼으로만 결정되고, 매장명·회원명으로 필터링하지 않는다.
실습 일반 테이블
•
startdbpg.tr_ord: 주문(PK ord_no)
•
startdbpg.ms_shop: 매장(PK shop_id)
•
startdbpg.ms_mbr: 회원(PK mbr_id)
•
tr_ord.shop_id, tr_ord.mbr_id는 각각 매장·회원 테이블에 대한 FK다. 각 JOIN 대상의 PK는 유일하므로 주문 1건이 JOIN 때문에 여러 행으로 늘지 않는다.
BAD SQL — 매장·회원을 먼저 JOIN하고 마지막에 페이징
SELECT o.ord_no, o.ord_dtm, o.shop_id, o.mbr_id,
o.ord_amt, s.shop_nm, m.nick_nm
FROM startdbpg.tr_ord o
LEFT JOIN startdbpg.ms_shop s
ON s.shop_id = o.shop_id
LEFT JOIN startdbpg.ms_mbr m
ON m.mbr_id = o.mbr_id
WHERE o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01'
ORDER BY o.ord_dtm, o.mbr_id, o.ord_no
LIMIT 30 OFFSET 6000;
SQL
복사
실제 실행계획에서 30건만 반환하는데도, Nested Loop Left Join이 6,050행을 처리한 다음 Incremental Sort와 LIMIT/OFFSET을 적용했다. ms_mbr 쪽 Memoize는 6,050번 접근하되 캐시 히트 2,538회·미스 3,512회였고, 실제 회원 PK Index Scan은 3,512회 실행됐다.
GOOD SQL — 주문 30건을 먼저 확정하고 부가 JOIN
SELECT p.ord_no, p.ord_dtm, p.shop_id, p.mbr_id,
p.ord_amt, s.shop_nm, m.nick_nm
FROM (
SELECT o.ord_no, o.ord_dtm, o.shop_id,
o.mbr_id, o.ord_amt
FROM startdbpg.tr_ord o
WHERE o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01'
ORDER BY o.ord_dtm, o.mbr_id, o.ord_no
LIMIT 30 OFFSET 6000
) p
LEFT JOIN startdbpg.ms_shop s
ON s.shop_id = p.shop_id
LEFT JOIN startdbpg.ms_mbr m
ON m.mbr_id = p.mbr_id
ORDER BY p.ord_dtm, p.mbr_id, p.ord_no;
SQL
복사
인라인뷰에서 주문 30건을 먼저 확정하므로, ms_shop과 ms_mbr의 PK Index Scan은 각각 30회만 실행된다. 바깥쪽 ORDER BY는 최종 출력의 정렬 순서를 명시하기 위한 것이다.
실제 실행 결과 (EXPLAIN (ANALYZE, BUFFERS, COSTS OFF))
항목 | BAD: JOIN 후 페이징 | GOOD: 페이징 후 JOIN |
최종 반환 행 | 30 | 30 |
tr_ord_x51 Index Scan 행 수 | 6,050 | 6,050 |
매장 PK Index Scan 실행 | 289회 (Memoize 경유) | 30회 |
회원 PK Index Scan 실행 | 3,512회 (Memoize 경유) | 30회 |
Buffers: shared hit | 11,219 | 255 |
실행시간 (각 1회) | 40.652ms | 11.830ms |
결과 동등성: BAD와 GOOD 모두 30건이며, 출력한 전체 컬럼에 대해 EXCEPT ALL 양방향 비교를 수행한 결과 차이 0건이다.
BAD SQL — 실제 실행계획
Limit (actual time=40.158..40.271 rows=30 loops=1)
Buffers: shared hit=11219
-> Incremental Sort (actual time=1.082..39.945 rows=6030 loops=1)
Sort Key: o.ord_dtm, o.mbr_id, o.ord_no
Presorted Key: o.ord_dtm
Buffers: shared hit=11219
-> Nested Loop Left Join (actual time=0.364..30.246 rows=6050 loops=1)
Buffers: shared hit=11207
-> Nested Loop Left Join (actual time=0.067..8.589 rows=6050 loops=1)
Buffers: shared hit=671
-> Index Scan using tr_ord_x51 on tr_ord o
(actual time=0.038..2.137 rows=6050 loops=1)
Buffers: shared hit=93
-> Memoize (actual time=0.001..0.001 rows=1 loops=6050)
Cache Key: o.shop_id
Hits: 5761 Misses: 289
-> Index Scan using ms_shop_pk on ms_shop s
(actual time=0.003..0.003 rows=1 loops=289)
-> Memoize (actual time=0.003..0.003 rows=1 loops=6050)
Cache Key: o.mbr_id
Hits: 2538 Misses: 3512
Buffers: shared hit=10536
-> Index Scan using ms_mbr_pk on ms_mbr m
(actual time=0.004..0.004 rows=1 loops=3512)
Planning Time: 2.174 ms
Execution Time: 40.652 ms
Plain Text
복사
GOOD SQL — 실제 실행계획
Nested Loop Left Join (actual time=11.193..11.627 rows=30 loops=1)
Buffers: shared hit=255
-> Nested Loop Left Join (actual time=11.155..11.313 rows=30 loops=1)
Buffers: shared hit=165
-> Limit (actual time=11.118..11.176 rows=30 loops=1)
Buffers: shared hit=105
-> Incremental Sort (actual time=0.190..10.853 rows=6030 loops=1)
Sort Key: o.ord_dtm, o.mbr_id, o.ord_no
Presorted Key: o.ord_dtm
Buffers: shared hit=105
-> Index Scan using tr_ord_x51 on tr_ord o
(actual time=0.040..2.207 rows=6050 loops=1)
Buffers: shared hit=93
-> Index Scan using ms_shop_pk on ms_shop s
(actual time=0.004..0.004 rows=1 loops=30)
Buffers: shared hit=60
-> Index Scan using ms_mbr_pk on ms_mbr m
(actual time=0.010..0.010 rows=1 loops=30)
Buffers: shared hit=90
Planning Time: 1.769 ms
Execution Time: 11.830 ms
Plain Text
복사
위 실행계획은 핵심 연산을 발췌했다. 인덱스 조건과 일부 중간 노드는 생략했으므로 SQL 실행 시 EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)로 전문을 확인할 수 있다.
실행계획 해석: 두 SQL 모두 OFFSET 6000 때문에 주문 인덱스에서 6,050행을 읽어야 했다. GOOD은 OFFSET 비용을 없앤 것이 아니라, 30건만 확정한 이후 부가 JOIN을 수행해 수천 번의 반복 조회를 줄인 것이다.
핵심
•
정렬·페이징 기준이 기준 테이블의 컬럼으로만 결정된다면 필요한 N건을 먼저 확정하고, 화면에 필요한 부가 정보를 그 N건에 대해서만 JOIN하자.
참고·예외
•
매장명 또는 회원등급 등의 JOIN 테이블 컬럼으로 필터링하거나 정렬한다면 JOIN 이후에 페이지를 결정해야 하므로 이 방식으로 무작정 변환하면 결과가 달라질 수 있다.
•
부가 JOIN 대상이 PK·유니크 키가 아닌 1:N 관계라면 중복이 생기므로 두 SQL의 결과가 같다고 보장할 수 없다.
•
LEFT JOIN은 매장·회원 정보가 누락돼도 주문을 유지한다. 외래키·NULL 여부와 INNER JOIN 전환 가능성은 별도 판단한다.
•
PostgreSQL 옵티마이저가 원래 SQL의 JOIN을 효율적으로 처리하는 경우도 있으므로 실제 실행계획을 검증해야 한다. 실행시간은 각 1회 실측 결과로 일반화하지 않는다.


