Search

그룹별 마지막 한 건, 효율적으로 찾기

— Astra Wins, Human Expert Loses
공동 작성 | SweetBoss · GPT-6 Astra
문제 제시·대체방법 1~3·실험·검수: SweetBoss / 추가 대안·본문 작성: GPT-6 Astra
실습 준비: 이 글의 SQL을 직접 실습하려면 『SQL TUNER for PostgreSQL 기본원리편』에서 제공하는 실습 데이터베이스를 먼저 구성해야 합니다.
매장별 마지막 주문 한 건을 찾는 SQL은 어렵지 않게 작성할 수 있다. 하지만 주문이 수천만 건이라면, 마지막 한 건을 찾기 위해 어디까지 읽는지가 중요해진다.
이번에는 SweetBoss가 제시한 세 가지 대안에 GPT-6 Astra가 도전했다. 실행시간도 함께 기록하되, 승부의 기준은 공유 버퍼 접근량인 shared hit + read로 정했다. 마지막에 남은 차이는 불과 8회다.

먼저, 어떤 마지막 주문인가?

조회하려는 것은 매장별 전체 주문 중 주문번호가 가장 큰 주문을 찾고, 그 주문이 2025년 1월에 해당하면 반환하는 것이다. 1월 주문 중 마지막 주문을 찾는 것과는 다르다.
예를 들어 같은 매장에 1월 주문과 그보다 주문번호가 큰 2월 주문이 있다면, 이 매장은 결과에서 제외되어야 한다. 따라서 날짜 조건을 어디에 적용하는지가 중요하다. 여기서 ‘마지막’의 기준은 주문일시가 아니라 ord_no다.
비교는 ord_no가 PK이고 shop_id에 NULL이 없으며, 주문의 매장이 매장 마스터에 빠짐없이 등록되어 있고 마스터의 shop_id가 유일하다는 전제다. 원본의 rno는 순번 확인용 컬럼이므로, 대체 SQL과는 이를 제외한 주문 데이터를 비교한다.

출발점: 마지막 한 건을 위해 전체 주문 정렬

먼저 ROW_NUMBER로 매장별 순번을 구하고, 마지막 주문이 1월에 해당하는지 확인해 보자.
•
Buffers: shared hit=8,534 read=342,023 / 합계 350,557회
•
임시 파일: temp read=849,507 written=850,287 / 실행시간: 105,744.273ms
EXPLAIN (ANALYZE,BUFFERS,COSTS OFF) SELECT t2.* FROM ( SELECT t1.* ,ROW_NUMBER() OVER(PARTITION BY t1.shop_id ORDER BY t1.ord_no DESC) rno FROM startdbpg.tr_ord_big t1 ) t2 WHERE t2.ord_dtm >= '2025-01-01' AND t2.ord_dtm < '2025-02-01' AND t2.rno = 1;
SQL
복사
Subquery Scan on t2 (actual time=95893.186..105479.057 rows=289 loops=1) Filter: ((t2.ord_dtm >= '2025-01-01 00:00:00'::timestamp without time zone) AND (t2.ord_dtm < '2025-02-01 00:00:00'::timestamp without time zone) AND (t2.rno = 1)) Rows Removed by Filter: 9 Buffers: shared hit=8534 read=342023, temp read=849507 written=850287 -> WindowAgg (actual time=95893.159..105478.258 rows=298 loops=1) Run Condition: (row_number() OVER (?) <= 1) Buffers: shared hit=8534 read=342023, temp read=849507 written=850287 -> Sort (actual time=95892.949..102776.639 rows=26291772 loops=1) Sort Key: t1.shop_id, t1.ord_no DESC Sort Method: external merge Disk: 2265376kB Buffers: shared hit=8534 read=342023, temp read=849507 written=850287 -> Seq Scan on tr_ord_big t1 (actual time=225.551..13880.336 rows=26291772 loops=1) Buffers: shared hit=8534 read=342023 Planning Time: 0.536 ms Execution Time: 105744.273 ms
Plain Text
복사
최종 결과는 289건이지만, 아래에서는 주문 26,291,772건을 전부 읽고 정렬했다. 정렬은 메모리 안에서 끝나지 못해 external merge로 처리되었다. Run Condition으로 WindowAgg의 출력은 298건으로 줄었지만, 그 아래의 전체 스캔과 정렬은 그대로 수행됐다.
인덱스로 정렬 비용을 줄일 여지는 있다. 그러나 여기서는 SQL 자체를 바꾸어, 마지막 주문을 확인하기 위해 처리하는 데이터의 양을 줄여보고자 한다.
이후 실험에 사용할 인덱스를 생성한다. 기존 주문 PK 인덱스는 유지한다.
CREATE INDEX x_tr_ord_big_ord_dtm ON startdbpg.tr_ord_big(ord_dtm); CREATE INDEX x_tr_ord_big_ord_shop_ord_no ON startdbpg.tr_ord_big(shop_id, ord_no);
SQL
복사

SweetBoss의 대체방법 1: 더 큰 주문번호가 없으면 마지막 주문

1월 주문부터 가져오고, 같은 매장에 더 큰 주문번호가 존재하는지 확인한다. 더 큰 주문번호가 없다면 전체 주문 중 마지막 주문이다.
•
Buffers: shared hit=2,067,203 / 실행시간: 4,902.492ms
EXPLAIN (ANALYZE,BUFFERS,COSTS OFF) SELECT t1.* FROM startdbpg.tr_ord_big t1 WHERE t1.ord_dtm >= '2025-01-01' AND t1.ord_dtm < '2025-02-01' AND NOT EXISTS( SELECT * FROM startdbpg.tr_ord_big x WHERE x.shop_id = t1.shop_id AND x.ord_no > t1.ord_no);
SQL
복사
Nested Loop Anti Join (actual time=2241.175..4901.226 rows=289 loops=1) Buffers: shared hit=2067203 -> Index Scan using x_tr_ord_big_ord_dtm on tr_ord_big t1 (actual time=0.036..76.358 rows=514440 loops=1) Index Cond: ((ord_dtm >= '2025-01-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2025-02-01 00:00:00'::timestamp without time zone)) Buffers: shared hit=7483 -> Index Only Scan using x_tr_ord_big_ord_shop_ord_no on tr_ord_big x (actual time=0.009..0.009 rows=1 loops=514440) Index Cond: ((shop_id = (t1.shop_id)::text) AND (ord_no > t1.ord_no)) Heap Fetches: 0 Buffers: shared hit=2059720 Planning: Buffers: shared hit=28 Planning Time: 0.532 ms Execution Time: 4902.492 ms
Plain Text
복사
날짜 인덱스로 1월 주문 514,440건을 가져온 뒤, (shop_id, ord_no) 인덱스로 더 큰 주문번호의 존재 여부를 검사한다. NOT EXISTS 안에는 날짜 조건이 없으므로, 다른 기간의 주문까지 포함하여 마지막 주문인지 확인한다.
전체 주문의 대량 정렬은 없어졌지만, 존재 여부 확인이 514,440번 반복된다. 한 번의 탐색은 작아도 반복 횟수가 많아 주문 확인 쪽에서만 2,059,720회의 버퍼 접근이 발생했다.
실행시간은 크게 줄었지만 공유 버퍼 접근량은 원본보다 많다. 원본에는 대량의 임시 파일 I/O도 있으므로, 두 방식의 비용을 shared 수치 하나로 모두 설명할 수는 없다. 다음 목표는 이 반복 접근을 줄이는 것이다.

SweetBoss의 대체방법 2: 매장별 후보 한 건만 남겨 확인

매장별로 1월 주문 중 가장 큰 주문번호만 남기면 어떨까? 이 후보보다 더 큰 주문번호가 없다면, 해당 후보가 전체 주문 중에서도 마지막 주문이다.
•
Buffers: shared hit=9,795 / 실행시간: 221.093ms
EXPLAIN (ANALYZE,BUFFERS,COSTS OFF) SELECT t2.* FROM ( SELECT * FROM ( SELECT a.shop_id ,MAX(a.ord_no) last_ord_no FROM startdbpg.tr_ord_big a WHERE a.ord_dtm >= '2025-01-01' AND a.ord_dtm < '2025-02-01' GROUP BY a.shop_id ) b WHERE NOT EXISTS( SELECT * FROM startdbpg.tr_ord_big c WHERE c.shop_id = b.shop_id AND c.ord_no > b.last_ord_no) ) t1 INNER JOIN startdbpg.tr_ord_big t2 ON (t2.ord_no = t1.last_ord_no) ;
SQL
복사
Nested Loop (actual time=218.924..221.026 rows=289 loops=1) Buffers: shared hit=9795 -> Nested Loop Anti Join (actual time=218.914..220.246 rows=289 loops=1) Buffers: shared hit=8639 -> HashAggregate (actual time=218.879..218.925 rows=289 loops=1) Group Key: a.shop_id Batches: 1 Memory Usage: 61kB Buffers: shared hit=7483 -> Index Scan using x_tr_ord_big_ord_dtm on tr_ord_big a (actual time=0.014..71.771 rows=514440 loops=1) Index Cond: ((ord_dtm >= '2025-01-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2025-02-01 00:00:00'::timestamp without time zone)) Buffers: shared hit=7483 -> Index Only Scan using x_tr_ord_big_ord_shop_ord_no on tr_ord_big c (actual time=0.004..0.004 rows=0 loops=289) Index Cond: ((shop_id = (a.shop_id)::text) AND (ord_no > (max(a.ord_no)))) Heap Fetches: 0 Buffers: shared hit=1156 -> Index Scan using tr_ord_big_pk on tr_ord_big t2 (actual time=0.002..0.002 rows=1 loops=289) Index Cond: (ord_no = (max(a.ord_no))) Buffers: shared hit=1156 Planning: Buffers: shared hit=8 Planning Time: 0.376 ms Execution Time: 221.093 ms
Plain Text
복사
같은 1월 주문 514,440건을 읽지만, 먼저 매장별 최대 주문번호로 집계하여 후보를 289건으로 줄였다. 그 결과 더 큰 주문번호가 있는지 확인하는 인덱스 접근도 514,440회에서 289회로 줄었다. 확인을 통과한 주문은 PK로 상세 데이터를 가져온다.
이번 데이터에서는 후보 289건이 모두 통과했다. 그렇더라도 NOT EXISTS는 필요하다. 기간 안의 최대 주문번호가 전체 기간의 최대 주문번호라는 보장은 없기 때문이다.
집계로 후속 확인 대상을 줄였다. 51만 건을 하나씩 확인하던 작업이 매장별 후보 289건을 확인하는 작업으로 바뀌었다.

SweetBoss의 대체방법 3: 작은 마스터에서 마지막 주문으로 바로 접근

매장 마스터가 작다면, 한 달치 주문을 읽어 후보를 만드는 과정도 생략할 수 있다. 매장별로 주문 인덱스를 역방향으로 읽고 마지막 한 건만 가져오자.
•
Buffers: shared hit=1,502 / 실행시간: 3.748ms
EXPLAIN (ANALYZE,BUFFERS,COSTS OFF) SELECT t2.* FROM startdbpg.ms_shop_big t1 INNER JOIN LATERAL ( SELECT x.* FROM startdbpg.tr_ord_big x WHERE x.shop_id = t1.shop_id ORDER BY x.ord_no DESC LIMIT 1 ) t2 ON TRUE WHERE t2.ord_dtm >= '2025-01-01' AND t2.ord_dtm < '2025-02-01' ;
SQL
복사
Nested Loop (actual time=0.053..3.698 rows=289 loops=1) Buffers: shared hit=1502 -> Seq Scan on ms_shop_big t1 (actual time=0.012..0.056 rows=300 loops=1) Buffers: shared hit=4 -> Subquery Scan on t2 (actual time=0.011..0.012 rows=1 loops=300) Filter: ((t2.ord_dtm >= '2025-01-01 00:00:00'::timestamp without time zone) AND (t2.ord_dtm < '2025-02-01 00:00:00'::timestamp without time zone)) Rows Removed by Filter: 0 Buffers: shared hit=1498 -> Limit (actual time=0.011..0.011 rows=1 loops=300) Buffers: shared hit=1498 -> Index Scan Backward using x_tr_ord_big_ord_shop_ord_no on tr_ord_big x (actual time=0.011..0.011 rows=1 loops=300) Index Cond: ((shop_id)::text = (t1.shop_id)::text) Buffers: shared hit=1498 Planning Time: 0.205 ms Execution Time: 3.748 ms
Plain Text
복사
매장 마스터 300건을 읽고, 각 매장의 마지막 주문을 찾는다. shop_id를 고정한 상태에서 (shop_id, ord_no) 인덱스를 역방향으로 읽으므로, LIMIT 1에 필요한 주문을 찾으면 해당 스캔을 멈출 수 있다.
이제 한 달치 51만 건을 읽거나 전체 주문을 정렬할 필요가 없다. 버퍼 접근량은 1,502회까지 줄었다. 날짜 조건은 마지막 주문을 찾은 뒤 적용한다. 이를 LATERAL 안으로 옮기면 ‘1월 주문 중 마지막 주문’으로 조회 의미가 달라진다.
여기까지가 SweetBoss의 대안이다. 이보다 적게 읽는 방법이 있을까? Astra에게 다른 방법을 요청했다.

Astra의 도전 1: DISTINCT ON으로 인덱스만 읽기

Astra의 첫 제안은 주문 전체 컬럼 대신 인덱스의 shop_id, ord_no만 읽어 매장별 마지막 주문번호를 구한 뒤, PK로 주문 데이터를 가져오는 방법이다.
•
Buffers: shared hit=4,741 read=100,174 / 합계 104,915회
•
실행시간: 33,425.212ms
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT t2.* FROM ( SELECT DISTINCT ON (a.shop_id) a.shop_id, a.ord_no FROM startdbpg.tr_ord_big a ORDER BY a.shop_id DESC, a.ord_no DESC ) t1 INNER JOIN startdbpg.tr_ord_big t2 ON t2.ord_no = t1.ord_no WHERE t2.ord_dtm >= '2025-01-01' AND t2.ord_dtm < '2025-02-01';
SQL
복사
Nested Loop (actual time=196.293..33420.948 rows=289 loops=1) Buffers: shared hit=4741 read=100174 -> Unique (actual time=0.114..33179.663 rows=298 loops=1) Buffers: shared hit=3657 read=100066 -> Index Only Scan Backward using x_tr_ord_big_ord_shop_ord_no on tr_ord_big a (actual time=0.111..31322.570 rows=26291772 loops=1) Heap Fetches: 0 Buffers: shared hit=3657 read=100066 -> Index Scan using tr_ord_big_pk on tr_ord_big t2 (actual time=0.143..0.143 rows=1 loops=298) Index Cond: (ord_no = a.ord_no) Filter: ((ord_dtm >= '2025-01-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2025-02-01 00:00:00'::timestamp without time zone)) Rows Removed by Filter: 0 Buffers: shared hit=1084 read=108 Planning: Buffers: shared hit=8 read=5 Planning Time: 5.063 ms Execution Time: 33425.212 ms
Plain Text
복사
두 컬럼을 모두 DESC로 정렬하여 기존 인덱스의 역방향 스캔을 활용했다. 의도대로 별도 정렬 없이 Index Only Scan Backward와 Unique가 선택되었고, Heap Fetches도 0이다.
하지만 인덱스에서 26,291,772건을 모두 읽은 뒤에야 매장별 298건이 남았다. 테이블 접근과 정렬 비용은 줄였지만, 인덱스 전체를 읽는 비용이 그대로 남은 것이다. 1,502회로 끝나는 SweetBoss의 LATERAL에는 미치지 못했다.

Astra의 도전 2: 커버링 인덱스로 기간 조회 개선

두 번째 제안은 SweetBoss의 대체방법 2에 커버링 인덱스를 추가하는 것이다. 기간 조회와 집계에 필요한 컬럼을 인덱스에 담아, 1월 주문을 Index Only Scan으로 읽겠다는 의도였다.
CREATE INDEX x_tr_ord_big_dtm_cover ON startdbpg.tr_ord_big(ord_dtm) INCLUDE (shop_id, ord_no);
SQL
복사
•
Buffers: shared hit=1,801 read=7,994 / 합계 9,795회
•
실행시간: 466.613ms
EXPLAIN (ANALYZE,BUFFERS,COSTS OFF) SELECT t2.* FROM ( SELECT * FROM ( SELECT a.shop_id ,MAX(a.ord_no) last_ord_no FROM startdbpg.tr_ord_big a WHERE a.ord_dtm >= '2025-01-01' AND a.ord_dtm < '2025-02-01' GROUP BY a.shop_id ) b WHERE NOT EXISTS( SELECT * FROM startdbpg.tr_ord_big c WHERE c.shop_id = b.shop_id AND c.ord_no > b.last_ord_no) ) t1 INNER JOIN startdbpg.tr_ord_big t2 ON (t2.ord_no = t1.last_ord_no) ;
SQL
복사
Nested Loop (actual time=246.318..466.285 rows=289 loops=1) Buffers: shared hit=1801 read=7994 -> Nested Loop Anti Join (actual time=246.019..439.324 rows=289 loops=1) Buffers: shared hit=702 read=7937 -> HashAggregate (actual time=244.808..245.423 rows=289 loops=1) Group Key: a.shop_id Batches: 1 Memory Usage: 61kB Buffers: shared hit=80 read=7403 -> Index Scan using x_tr_ord_big_ord_dtm on tr_ord_big a (actual time=3.392..116.081 rows=514440 loops=1) Index Cond: ((ord_dtm >= '2025-01-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2025-02-01 00:00:00'::timestamp without time zone)) Buffers: shared hit=80 read=7403 -> Index Only Scan using x_tr_ord_big_ord_shop_ord_no on tr_ord_big c (actual time=0.669..0.669 rows=0 loops=289) Index Cond: ((shop_id = (a.shop_id)::text) AND (ord_no > (max(a.ord_no)))) Heap Fetches: 0 Buffers: shared hit=622 read=534 -> Index Scan using tr_ord_big_pk on tr_ord_big t2 (actual time=0.089..0.089 rows=1 loops=289) Index Cond: (ord_no = (max(a.ord_no))) Buffers: shared hit=1099 read=57 Planning: Buffers: shared hit=27 read=6 dirtied=2 Planning Time: 3.987 ms Execution Time: 466.613 ms
Plain Text
복사
그런데 실행계획을 보면 새 인덱스가 사용되지 않았다. 기간 조회에는 여전히 Index Scan using x_tr_ord_big_ord_dtm이 선택되었다. 버퍼 접근량도 기존 대체방법 2와 동일한 9,795회다.
따라서 이 결과로 커버링 인덱스의 효과가 없다고 결론 내릴 수는 없다. 제안한 접근 방식이 실제로 선택되지 않았기 때문이다. 기존 측정과 hit/read 구성도 다르므로, 실행시간 증가를 새 인덱스 탓으로 해석하지 않는다. 이번 도전에서는 의도한 개선을 확인하지 못했다.

Astra의 도전 3: 다음 매장의 마지막 주문으로 건너뛰기

두 번의 도전 후, SweetBoss가 다시 요청했다.
인간을 이길 방법을 생각해 봐라.
이번 제안은 매장 마스터도 읽지 않는 방법이다. 주문 인덱스에서 한 매장의 마지막 주문을 찾은 뒤, 해당 매장의 나머지 주문을 건너뛰고 다음 매장의 마지막 주문으로 이동한다.
•
Buffers: shared hit=1,494 / 실행시간: 5.986ms
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) WITH RECURSIVE last_orders AS ( ( -- 가장 큰 shop_id의 마지막 주문 SELECT x.* FROM startdbpg.tr_ord_big x ORDER BY x.shop_id DESC, x.ord_no DESC LIMIT 1 ) UNION ALL -- 직전 매장을 건너뛰고, 다음 매장의 마지막 주문 SELECT n.* FROM last_orders p CROSS JOIN LATERAL ( SELECT x.* FROM startdbpg.tr_ord_big x WHERE x.shop_id < p.shop_id ORDER BY x.shop_id DESC, x.ord_no DESC LIMIT 1 ) n ) SELECT t.* FROM last_orders t WHERE t.ord_dtm >= '2025-01-01' AND t.ord_dtm < '2025-02-01';
SQL
복사
CTE Scan on last_orders t (actual time=0.055..5.856 rows=289 loops=1) Filter: ((ord_dtm >= '2025-01-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2025-02-01 00:00:00'::timestamp without time zone)) Rows Removed by Filter: 9 Buffers: shared hit=1494 CTE last_orders -> Recursive Union (actual time=0.049..5.465 rows=298 loops=1) Buffers: shared hit=1494 -> Limit (actual time=0.047..0.049 rows=1 loops=1) Buffers: shared hit=5 -> Index Scan Backward using x_tr_ord_big_ord_shop_ord_no on tr_ord_big x (actual time=0.045..0.046 rows=1 loops=1) Buffers: shared hit=5 -> Nested Loop (actual time=0.016..0.017 rows=1 loops=298) Buffers: shared hit=1489 -> WorkTable Scan on last_orders p (actual time=0.000..0.000 rows=1 loops=298) -> Limit (actual time=0.015..0.015 rows=1 loops=298) Buffers: shared hit=1489 -> Index Scan Backward using x_tr_ord_big_ord_shop_ord_no on tr_ord_big x_1 (actual time=0.015..0.015 rows=1 loops=298) Index Cond: ((shop_id)::text < (p.shop_id)::text) Buffers: shared hit=1489 Planning Time: 0.534 ms Execution Time: 5.986 ms
Plain Text
복사
처음에는 (shop_id, ord_no) 인덱스를 역방향으로 읽어 가장 큰 매장 ID의 마지막 주문 한 건을 구한다. 그다음부터는 직전에 구한 매장 ID보다 작은 구간으로 탐색 범위를 옮긴다.
WHERE x.shop_id < p.shop_id ORDER BY x.shop_id DESC, x.ord_no DESC LIMIT 1
SQL
복사
이 조건으로 현재 매장의 나머지 주문을 제외하고, 다음 매장의 마지막 주문을 찾는다. 다음 매장이 없으면 재귀가 종료된다. 날짜 조건은 이 탐색 바깥에서 검사하므로, 마지막 주문이 1월이 아닌 매장을 만나도 다음 매장으로 계속 진행한다.
실행계획에서도 shop_id < p.shop_id가 Index Cond에 들어갔다. 첫 탐색에서 1건을 구하고, 재귀 부분은 마지막 종료 확인까지 포함해 298번 실행되어 총 298개 매장의 마지막 주문을 찾았다. 그중 날짜 조건을 통과한 결과는 289건이다.
이것은 재귀 CTE로 Loose Index Scan을 구현한 방식이다. 앞선 DISTINCT ON처럼 모든 인덱스 항목을 읽는 대신, 매장 경계를 넘어가며 필요한 주문만 가져온다. PostgreSQL 위키의 Loose Indexscan에 소개된 발상을 이번 조회에 응용했다.

판정: Astra Wins, Human Expert Loses

각 실행계획의 최상위 노드를 기준으로 공유 버퍼 접근량을 비교하면 다음과 같다. hit와 read를 합산하되, 두 접근의 비용이 같다는 의미는 아니다. 실행시간은 참고값으로 함께 남겼다.
방식
shared hit
shared read
합계
실행시간
원본: ROW_NUMBER
8,534
342,023
350,557
105,744.273ms
SweetBoss 1: NOT EXISTS
2,067,203
0
2,067,203
4,902.492ms
SweetBoss 2: 집계 후 확인
9,795
0
9,795
221.093ms
SweetBoss 3: 마스터 + LATERAL
1,502
0
1,502
3.748ms
Astra 1: DISTINCT ON
4,741
100,174
104,915
33,425.212ms
Astra 2: 커버링 인덱스 추가 시도
1,801
7,994
9,795
466.613ms
Astra 3: 재귀 CTE
1,494
0
1,494
5.986ms
원본에는 표의 shared 수치 외에 temp read=849,507, temp written=850,287이 발생했다. 다른 실행계획에는 temp 사용이 표시되지 않았다. Astra의 커버링 인덱스 추가 시도는 새 인덱스가 선택되지 않은 결과다.
최종 승부는 SweetBoss 1,502회, Astra 1,494회다. 이번에 정한 공유 버퍼 접근량 기준에서는 Astra가 8회 차이로 이겼다. 마스터를 읽지 않고 주문이 있는 매장을 인덱스에서 직접 찾아가는 방식으로, 전체 접근량을 조금 더 줄였다.
다만 실행시간은 SweetBoss의 LATERAL이 더 짧았다. 버퍼 차이도 약 0.5%에 불과하다. 이 승패가 재귀 CTE의 보편적인 우위를 뜻하지는 않는다. 작은 매장 마스터가 있다면 간결한 LATERAL도 충분히 좋은 선택이며, 마스터가 없거나 주문이 없는 매장이 많다면 재귀 방식의 장점이 커질 수 있다.

마무리

이번 실험에서 큰 차이를 만든 것은 마지막 한 건을 표현하는 문법보다, 그 한 건을 얻기 위해 읽고 확인하는 데이터의 양이었다. 전체 정렬을 없애고, 존재 여부를 확인할 후보를 줄이고, 마지막에는 인덱스에서 필요한 위치로 직접 접근했다.
특히 재귀 방식은 SQL이 복잡해지는 만큼 그대로 복사해서 적용할 방법은 아니다. 데이터의 전제와 결과가 맞는지 확인하고, 인덱스 조건으로 매장 사이를 실제로 건너뛰는 계획이 나오는지 살펴보아야 한다.
마지막 한 건을 찾는 SQL이라면, 반환 건수만 보지 말고 그 한 건을 찾기 위해 얼마나 읽었는지 확인하자.