상황: 회원의 가장 최근 주문 한 건에 대한 주문번호·주문일시·주문금액을 조회한다. 실제 일반 테이블 startdbpg.tr_ord의 회원 M0045를 선정했다. 이 회원은 주문 140건이 있고, 가장 최근 주문일시 2025-01-17 07:02:00이 같은 주문이 2건 존재한다.
BAD SQL — MAX 주문일시와 같은 행을 모두 조회
SELECT o.ord_no, o.ord_dtm, o.ord_amt
FROM startdbpg.tr_ord o
WHERE o.mbr_id = 'M0045'
AND o.ord_dtm = (
SELECT MAX(x.ord_dtm)
FROM startdbpg.tr_ord x
WHERE x.mbr_id = o.mbr_id
)
ORDER BY o.ord_no DESC;
SQL
복사
MAX(ord_dtm)는 가장 최근 시각만 결정한다. 그 시각에 주문이 2건이면 2행 모두 반환하므로, 한 건만 필요한 업무 요건을 충족하지 못한다.
GOOD SQL — 최신 일시로 정렬한 후 주문번호로 동률을 해소
SELECT o.ord_no, o.ord_dtm, o.ord_amt
FROM startdbpg.tr_ord o
WHERE o.mbr_id = 'M0045'
ORDER BY o.ord_dtm DESC, o.ord_no DESC
LIMIT 1;
SQL
복사
ord_dtm DESC로 최신 시각부터 조회하고, 같은 시각의 주문 중 ord_no가 큰 주문 1건을 선택한다. ord_no는 PK라서 정렬 순서가 유일하게 결정된다. 요청에 따라 NULLS LAST는 사용하지 않았다. 현재 실습 데이터에는 ord_dtm IS NULL인 행이 0건이다.
실제 결과 비교
구분 | 주문번호(ord_no) | 주문일시(ord_dtm) | 주문금액(ord_amt) |
BAD 첫 행 | 725809 | 2025-01-17 07:02:00 | 11,300 |
BAD 두 번째 행 | 725781 | 2025-01-17 07:02:00 | 4,500 |
GOOD 한 행 | 725809 | 2025-01-17 07:02:00 | 11,300 |
실제 실행계획 검증 — EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
항목 | BAD | GOOD |
결과 행 수 | 2 | 1 |
주요 방식 | 상관 MAX SubPlan 반복 + Index Scan | Index Scan Backward → Incremental Sort → Limit |
SubPlan 실행 횟수 | 140회 | 없음 |
Buffers: shared hit | 518 | 14 |
실행시간 (각 1회) | 2.517ms | 0.223ms |
BAD SQL — 실제 실행계획 (핵심 발췌)
Sort (actual time=2.385..2.386 rows=2 loops=1)
Sort Key: o.ord_no DESC
Buffers: shared hit=518
-> Index Scan using tr_ord_fk01 on tr_ord o
(actual time=2.340..2.353 rows=2 loops=1)
Index Cond: ((mbr_id)::text = 'M0045'::text)
Filter: (ord_dtm = (SubPlan 2))
Rows Removed by Filter: 138
SubPlan 2
-> Result (actual time=0.011..0.011 rows=1 loops=140)
InitPlan 1
-> Limit (actual time=0.010..0.011 rows=1 loops=140)
-> Index Only Scan Backward using tr_ord_x54 on tr_ord x
(actual time=0.010..0.010 rows=1 loops=140)
Heap Fetches: 0
Planning Time: 1.039 ms
Execution Time: 2.517 ms
Plain Text
복사
GOOD SQL — 실제 실행계획 (핵심 발췌)
Limit (actual time=0.135..0.136 rows=1 loops=1)
Buffers: shared hit=14
-> Incremental Sort (actual time=0.134..0.135 rows=1 loops=1)
Sort Key: ord_dtm DESC, ord_no DESC
Presorted Key: ord_dtm
Full-sort Groups: 1 Sort Method: quicksort
Peak Memory: 25kB
-> Index Scan Backward using tr_ord_x54 on tr_ord o
(actual time=0.063..0.076 rows=3 loops=1)
Index Cond: ((mbr_id)::text = 'M0045'::text)
Planning Time: 0.850 ms
Execution Time: 0.223 ms
Plain Text
복사
실행계획 해석: 09-02에서 실제 생성한 복합 인덱스 tr_ord_x54(mbr_id, ord_dtm)를 GOOD에서 사용했다. 인덱스를 역방향으로 조회하되, ord_no가 인덱스 키에 없으므로 같은 주문일시 안에서는 Incremental Sort로 동률을 정렬한다. 실제 스캔은 3행, 결과는 1행이었다. BAD에서는 상관 MAX가 140회 반복됐다. 실행시간은 각각 1회 실측값이며 데이터·캐시·계획에 따라 달라진다.
핵심
•
가장 최근 시각의 MAX만으로 가장 최근 주문 한 건이 결정되는 것은 아니다. 한 건이 필요하면 업무상 동률 해소 기준(예: 주문번호)을 포함한 ORDER BY ... LIMIT 1을 사용하자.
참고·예외
•
최신 주문일시의 동률 주문을 모두 반환해야 하는 업무라면 BAD의 여러 행은 오류가 아니며, LIMIT 1로 줄이면 결과가 달라진다.
•
주문번호 최댓값과 주문일시 최댓값은 같은 의미가 아니다. 먼저 업무상 최신의 기준을 정의한다.
•
이 예제의 ord_dtm은 실제 데이터에서 NULL이 없어서 NULLS LAST를 생략했다. PostgreSQL에서는 DESC 정렬의 기본 NULL 위치가 FIRST이므로, 향후 NULL이 허용·유입되면 별도 처리 여부를 결정해야 한다.
•
적절한 인덱스가 없을 때는 Sort 비용이 증가할 수 있다. 위 성능은 기존 tr_ord_x54 인덱스가 있는 현재 환경에서의 실측이다.


