상황: 주문 목록을 1,000건씩 나누어 조회한다. 정렬 기준 없이 LIMIT / OFFSET만 사용하면 서로 다른 페이지에 같은 주문이 나타날 수 있다.
BAD는 실행계획에 따라 행 순서가 달라져 중복이 생길 수 있고, GOOD은 주문일시와 PK로 정렬 기준을 확정한다. 도식의 주문번호는 설명용이며, 실제 실습에서 확인된 중복은 5건이다.
BAD SQL
16페이지 — OFFSET 15,000
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT o.ord_no
,o.mbr_id
,m.nick_nm
FROM startdbpg.tr_ord o
JOIN startdbpg.ms_mbr m
ON m.mbr_id = o.mbr_id
LIMIT 1000 OFFSET 15000;
SQL
복사
실측 실행계획 (16페이지)
Limit (actual time=26.907..28.017 rows=1000 loops=1)
Buffers: shared hit=15358
-> Merge Join (actual time=0.047..26.932 rows=16000 loops=1)
Merge Cond: ((o.mbr_id)::text = (m.mbr_id)::text)
Buffers: shared hit=15358
-> Index Scan using tr_ord_fk01 on tr_ord o (actual time=0.021..22.227 rows=16000 loops=1)
Buffers: shared hit=15355
-> Index Scan using ms_mbr_pk on ms_mbr m (actual time=0.022..0.026 rows=8 loops=1)
Buffers: shared hit=3
Planning:
Buffers: shared hit=315
Planning Time: 1.507 ms
Execution Time: 28.207 ms
Plain Text
복사
17페이지 — OFFSET 16,000
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT o.ord_no
,o.mbr_id
,m.nick_nm
FROM startdbpg.tr_ord o
JOIN startdbpg.ms_mbr m
ON m.mbr_id = o.mbr_id
LIMIT 1000 OFFSET 16000;
SQL
복사
실측 실행계획 (17페이지)
Limit (actual time=54.295..58.859 rows=1000 loops=1)
Buffers: shared hit=253
-> Hash Join (actual time=4.538..57.833 rows=17000 loops=1)
Hash Cond: ((o.mbr_id)::text = (m.mbr_id)::text)
Buffers: shared hit=253
-> Seq Scan on tr_ord o (actual time=0.031..19.386 rows=17000 loops=1)
Buffers: shared hit=211
-> Hash (actual time=4.391..4.393 rows=9999 loops=1)
Buckets: 16384 Batches: 1 Memory Usage: 583kB
Buffers: shared hit=42
-> Index Only Scan using ms_mbr_x51 on ms_mbr m (actual time=0.033..1.690 rows=9999 loops=1)
Heap Fetches: 0
Buffers: shared hit=42
Planning:
Buffers: shared hit=315
Planning Time: 1.541 ms
Execution Time: 59.074 ms
Plain Text
복사
•
ORDER BY가 없어 결과 순서를 보장할 수 없다. 원문 실측에서는 16페이지는 Merge Join, 17페이지는 Hash Join이 선택되었다.
•
같은 데이터에서 두 페이지의 주문번호를 비교한 결과 5건이 중복됐다. 앞선 측정에서는 8건이 중복되었다.
GOOD SQL
-- 16페이지
SELECT o.ord_no
,o.mbr_id
,m.nick_nm
FROM startdbpg.tr_ord o
JOIN startdbpg.ms_mbr m
ON m.mbr_id = o.mbr_id
ORDER BY o.ord_dtm DESC, o.ord_no DESC
LIMIT 1000 OFFSET 15000;
-- 17페이지
SELECT o.ord_no
,o.mbr_id
,m.nick_nm
FROM startdbpg.tr_ord o
JOIN startdbpg.ms_mbr m
ON m.mbr_id = o.mbr_id
ORDER BY o.ord_dtm DESC, o.ord_no DESC
LIMIT 1000 OFFSET 16000;
SQL
복사
원문 실측 검증 — 두 페이지 간 중복 0건
•
두 페이지 모두 동일한 ORDER BY 조건을 지정했다.
•
ord_no는 PK이므로 마지막 정렬 기준에 포함하면 동률 없는 순서를 만들 수 있다.
•
원문에는 GOOD SQL의 별도 실측 실행계획은 없고, 두 페이지의 주문번호 교집합이 0건이라는 검증 결과가 있다.
핵심
•
페이징에는 반드시 ORDER BY를 사용하자.
•
정렬 컬럼에서 동률이 발생할 수 있다면 PK 등 유일한 컬럼까지 정렬 기준에 포함하자. 조회 순서는 물리적 저장 순서나 JOIN 방식에 맡기지 않는다.
참고
•
ORDER BY를 넣어도 조회 사이에 데이터가 추가·삭제·수정되면 OFFSET 페이징에서 중복이나 누락이 발생할 수 있다. 안정적인 탐색이 중요하면 Keyset Pagination 또는 일관된 스냅샷을 검토한다.
•
정렬하지 않은 페이지 간 중복 건수와 주문번호는 실행 시점·실행계획에 따라 달라질 수 있다. 원문의 5건은 특정 실측 사례다.


