Search

REMINDER 02-08. 페이징에는 반드시 ORDER BY를 사용하자

상황: 주문 목록을 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건은 특정 실측 사례다.