Search

REMINDER 08-06. 스칼라 서브쿼리는 페이징 후에 실행하자

08-05와의 연계: 08-05는 JOIN 전에 페이징, 08-06은 스칼라 서브쿼리 실행 전에 페이징한다. 둘 다 주문 목록의 필요한 30건을 먼저 확정하고, 부가정보 조회를 그 30건으로 제한하는 작성 원칙이다. 스칼라 서브쿼리를 무조건 JOIN으로 바꾸라는 뜻은 아니다.
상황: 2023년 3월 주문의 6,001~6,030번째 행을 주문일시·회원ID·주문번호 순으로 조회하면서 매장명과 회원 닉네임도 출력한다. 실제 일반 테이블 startdbpg.tr_ord, startdbpg.ms_shop, startdbpg.ms_mbr를 사용한다. shop_id, mbr_id는 매장·회원 테이블 각각의 PK를 참조한다.

BAD SQL — 스칼라 서브쿼리가 페이징 이전 행에 반복 실행될 수 있는 구조

SELECT o.ord_no, o.ord_dtm, o.shop_id, o.mbr_id, o.ord_amt, (SELECT s.shop_nm FROM startdbpg.ms_shop s WHERE s.shop_id = o.shop_id) shop_nm, (SELECT m.nick_nm FROM startdbpg.ms_mbr m WHERE m.mbr_id = o.mbr_id) nick_nm 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;
SQL
복사

GOOD SQL — 주문 30건을 확정한 다음 스칼라 서브쿼리 실행

SELECT p.ord_no, p.ord_dtm, p.shop_id, p.mbr_id, p.ord_amt, (SELECT s.shop_nm FROM startdbpg.ms_shop s WHERE s.shop_id = p.shop_id) shop_nm, (SELECT m.nick_nm FROM startdbpg.ms_mbr m WHERE m.mbr_id = p.mbr_id) 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 ORDER BY p.ord_dtm, p.mbr_id, p.ord_no;
SQL
복사
실측 결과 — EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
비교 항목
BAD
GOOD
최종 반환 행 수
30
30
주문 인덱스가 읽은 행 수
6,050
6,050
매장명 스칼라 SubPlan 1 실행
6,030회
30회
회원명 스칼라 SubPlan 2 실행
6,030회
30회
Buffers: shared hit
30,255
255
실행시간 (각 1회)
62.277ms
12.999ms

BAD SQL — 실제 실행계획 (핵심 노드)

Limit (actual time=61.756..62.087 rows=30 loops=1) Buffers: shared hit=30255 -> Result (actual time=0.294..61.625 rows=6030 loops=1) -> Incremental Sort (actual time=0.240..11.403 rows=6030 loops=1) Sort Key: o.ord_dtm, o.mbr_id, o.ord_no Presorted Key: o.ord_dtm -> Index Scan using tr_ord_x51 on tr_ord o (actual time=0.051..2.238 rows=6050 loops=1) SubPlan 1 -> Index Scan using ms_shop_pk on ms_shop s (actual time=0.003..0.003 rows=1 loops=6030) Buffers: shared hit=12060 SubPlan 2 -> Index Scan using ms_mbr_pk on ms_mbr m (actual time=0.004..0.004 rows=1 loops=6030) Buffers: shared hit=18090 Execution Time: 62.277 ms
Plain Text
복사

GOOD SQL — 실제 실행계획 (핵심 노드)

Subquery Scan on p (actual time=12.310..12.861 rows=30 loops=1) Buffers: shared hit=255 -> Limit (actual time=12.196..12.296 rows=30 loops=1) -> Incremental Sort (actual time=0.208..11.852 rows=6030 loops=1) Sort Key: o.ord_dtm, o.mbr_id, o.ord_no Presorted Key: o.ord_dtm -> Index Scan using tr_ord_x51 on tr_ord o (actual time=0.042..2.523 rows=6050 loops=1) SubPlan 1 -> Index Scan using ms_shop_pk on ms_shop s (actual time=0.005..0.005 rows=1 loops=30) Buffers: shared hit=60 SubPlan 2 -> Index Scan using ms_mbr_pk on ms_mbr m (actual time=0.011..0.011 rows=1 loops=30) Buffers: shared hit=90 Execution Time: 12.999 ms
Plain Text
복사
실행계획은 핵심 노드 발췌본이다. 각 SQL을 직접 EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)로 실행하면 인덱스 조건 등 전체 출력을 확인할 수 있다.
결과 동등성: BAD와 GOOD 모두 30건이며, 결과 전체 컬럼에 대한 EXCEPT ALL 양방향 비교 차이 0건. 최종 정렬 순서에 행 번호를 부여한 비교도 차이 0건이다.
실행계획 해석: BAD에서는 OFFSET 6000을 통과시키기 위한 상위 Result가 6,030행에 대해 두 스칼라 서브쿼리를 반복 평가했다. GOOD에서는 주문을 먼저 30행으로 제한한 후 스칼라 서브쿼리 두 개를 각 30회 평가했다. 두 방식 모두 주문 후보 6,050행은 읽었으므로, 이 개선은 OFFSET 비용 제거가 아니라 부가 서브쿼리의 반복 실행 횟수 감소다.

핵심

•
스칼라 서브쿼리가 페이징 결과의 출력용 부가정보만 조회한다면, 필요한 N건을 먼저 확정하고 그 N건에 대해서만 스칼라 서브쿼리가 실행되도록 SQL 구조와 실제 실행계획을 확인하자.
💡
참고·예외
•
PostgreSQL은 SQL 형태·버전·최적화 방식에 따라 스칼라 표현식을 이미 LIMIT 이후로 평가할 수도 있다. 단순히 SELECT 절에 스칼라 서브쿼리가 있다는 이유만으로 반드시 많은 횟수 실행된다고 단정하지 말고 실제 SubPlan loops를 확인한다.
•
서브쿼리 결과를 WHERE나 ORDER BY에서 사용한다면 페이징 이전 평가가 필요할 수 있으므로 임의로 후처리하면 결과가 달라진다.
•
상관 스칼라 서브쿼리의 반환값은 최대 한 행이어야 한다. 매장·회원 PK처럼 단일 행이 보장되는 관계에서 사용한다.
•
측정값은 각각 1회 실측이다. 일반적인 성능 배수로 단정하지 않는다.