실무 운영 환경에서 이 못지않게 자주 호출되면서 DB 서버의 디스크 I/O와 CPU를 고갈시키는 두 가지 주범이 더 있습니다. 바로 대용량 테이블의 깊은 페이지 번호 조회(Deep Pagination)와 GROUP BY / DISTINCT 연산 시 생성되는 내부 임시 테이블(Internal Temporary Table)입니다.
이번 글에서는 불필요한 디스크 랜덤 I/O를 획기적으로 줄이는 지연 조인(Deferred Join / Late Row Lookup) 기법과, 실행 계획 트리에서 나타나는 임시 테이블 병목의 해석 및 튜닝 방안을 정리합니다.
1. 지연 조인(Deferred Join)을 통한 대용량 페이징 튜닝
게시판이나 주문 내역 조회에서 LIMIT 100000, 20처럼 뒷부분 페이지를 조회할 때 쿼리 속도가 급격히 느려지는 현상을 흔히 겪습니다.
왜 느려질까? (Early Row Lookup의 한계)
인덱스를 통해 정렬 순서는 알고 있더라도, SELECT *처럼 인덱스에 포함되지 않은 칼럼(예: 배송지 주소, 상세 메모 등)을 조회해야 한다면 옵티마이저는 버려질 앞의 100,000건에 대해서도 일일이 데이터 페이지(Clustered Index)에 접근하는 랜덤 I/O를 발생시키거나, 아예 인덱스 스캔을 포기하고 테이블 풀 스캔 후 Filesort를 수행합니다.
튜닝 전 쿼리와 EXPLAIN ANALYZE 출력
EXPLAIN ANALYZE
SELECT order_id, user_id, order_date, shipping_address, order_memo
FROM orders
WHERE status = 'DELIVERED'
ORDER BY order_date DESC
LIMIT 100000, 20;
-> Limit/Offset: 20/100000 row(s) (cost=115420.50 rows=20) (actual time=845.120..845.310 rows=20 loops=1)
-> Index lookup on orders using idx_status_orderdate (status='DELIVERED') (reverse) (cost=115420.50 rows=250000) (actual time=0.085..838.400 rows=100020 loops=1)
- 병목 지점 해석: (status, order_date) 복합 인덱스가 존재하여 별도의 Sort 노드는 생기지 않았습니다. 하지만 Index lookup 단계에서 100,020건의 레코드를 읽는 동안, 인덱스에 없는 shipping_address, order_memo를 가져오기 위해 10만 번 이상의 디스크/버퍼풀 랜덤 액세스가 발생하여 838.400ms가 소요되었습니다.
튜닝 후: 지연 조인(Deferred Join) 적용
지연 조인의 핵심은 "인덱스만으로 가볍게 건수를 먼저 자른 뒤(Covering Index), 최종 반환할 20건에 대해서만 테이블 본체에 접근한다"는 아키텍처적 분리입니다.
EXPLAIN ANALYZE
SELECT o.order_id, o.user_id, o.order_date, o.shipping_address, o.order_memo
FROM (
-- 1. 커버링 인덱스로 PK(order_id)만 빠르게 추출
SELECT order_id
FROM orders
WHERE status = 'DELIVERED'
ORDER BY order_date DESC
LIMIT 100000, 20
) AS target
JOIN orders o ON target.order_id = o.order_id;
-> Nested loop inner join (cost=2850.40 rows=20) (actual time=38.210..38.450 rows=20 loops=1)
-> Table scan on target (cost=2840.20..2843.10 rows=20) (actual time=38.190..38.195 rows=20 loops=1)
-> Materialize (cost=2840.20..2840.20 rows=20) (actual time=38.188..38.188 rows=20 loops=1)
-> Limit/Offset: 20/100000 row(s) (cost=2838.20 rows=20) (actual time=38.150..38.175 rows=20 loops=1)
-> Filter: (orders.status = 'DELIVERED') (cost=2838.20 rows=250000) (actual time=0.042..32.110 rows=100020 loops=1)
-> Covering index lookup on orders using idx_status_orderdate (status='DELIVERED') (reverse) (cost=2838.20 rows=250000) (actual time=0.038..24.500 rows=100020 loops=1)
-> Single-row index lookup on o using PRIMARY (order_id=target.order_id) (cost=0.35 rows=1) (actual time=0.011..0.011 rows=1 loops=20)
핵심 개선 포인트
- Covering index lookup의 위력: 인라인 뷰(target) 내부에서는 PK인 order_id만 조회하므로 데이터 블록에 전혀 접근하지 않고 세컨더리 인덱스 블록만 스캔합니다. 10만 건을 스킵하는 시간이 838ms에서 32.1ms로 줄어들었습니다.
- 최종 20건만 PK Lookup: 바깥쪽 쿼리에서는 Materialize(구체화)된 20건의 order_id를 가지고 orders 테이블의 PK를 단 20번만(loops=20) 찌릅니다(0.011ms × 20 = 0.22ms).
- 전체 실행 시간이 845ms $\rightarrow$ 38ms로 약 22배 이상 빨라졌습니다.
2. 임시 테이블(Temporary Table) 병목 분석과 최적화
GROUP BY, DISTINCT, UNION, 또는 인라인 뷰(Derived Table)를 사용할 때 옵티마이저가 인덱스를 통해 즉시 결과를 만들어낼 수 없다면, 중간 결과를 저장하기 위해 내부 임시 테이블(Internal Temporary Table)을 생성합니다.
MySQL 8.0에서는 기본 메모리 스토리지 엔진으로 TempTable 엔진을 사용하며, 메모리 한도(temptable_max_ram, 기본 1GB)를 초과하면 디스크(MMAP 파일 또는 InnoDB 온디스크 임시 테이블)로 전환됩니다.
EXPLAIN ANALYZE 출력 예시 (Temporary + Sort 병목)
EXPLAIN ANALYZE
SELECT c.category_name, COUNT(o.order_id) AS order_cnt, SUM(o.total_amount) AS sum_amount
FROM orders o
JOIN categories c ON o.category_id = c.category_id
WHERE o.order_date >= '2026-01-01'
GROUP BY c.category_name
ORDER BY sum_amount DESC;
-> Sort: sum_amount DESC (cost=85400.20 rows=50) (actual time=615.420..615.430 rows=50 loops=1)
-> Table scan on <temporary> (actual time=615.310..615.340 rows=50 loops=1)
-> Aggregate using temporary table (actual time=615.305..615.305 rows=50 loops=1)
-> Nested loop inner join (cost=65200.10 rows=350000) (actual time=0.110..340.200 rows=350000 loops=1)
-> Index range scan on o using idx_order_date (cost=35100.00 rows=350000) (actual time=0.080..180.500 rows=350000 loops=1)
-> Single-row index lookup on c using PRIMARY (category_id=o.category_id) (cost=0.25 rows=1) (actual time=0.0003..0.0003 rows=1 loops=350000)
핵심 해석 포인트
- -> Aggregate using temporary table 노드:
- 하위 조인에서 넘어온 35만 건의 데이터를 모두 읽어들여(340.200ms 소요), 임시 테이블에 적재하면서 해시 기반 그룹핑 및 집계(COUNT, SUM)를 수행하는 블로킹 구간입니다.
- 조인이 끝난 시점(340.2ms)부터 집계가 완료된 시점(615.3ms)까지 약 275ms가 순수하게 임시 테이블에 데이터를 쓰고 갱신하는 데 소비되었습니다.
- -> Table scan on <temporary>:
- 집계가 완료된 임시 테이블(총 50개 카테고리)을 풀 스캔하여 상위 Sort 노드로 전달합니다.
튜닝 가이드: 느슨한 인덱스 스캔 유도 또는 선집계 후조인(Aggregate First, Join Later)
위 쿼리가 느린 가장 큰 이유는 35만 건을 먼저 조인한 뒤 문자열 칼럼(c.category_name)으로 그룹핑했기 때문입니다. 조인 전에 숫자형 외래키(o.category_id)로 먼저 그룹핑을 끝내면 조인 횟수와 임시 테이블 부하를 동시에 줄일 수 있습니다.
-- 튜닝 쿼리: 인라인 뷰에서 ID 기준으로 먼저 집계 후 마지막에 카테고리명 조인
SELECT c.category_name, agg.order_cnt, agg.sum_amount
FROM (
SELECT category_id, COUNT(order_id) AS order_cnt, SUM(total_amount) AS sum_amount
FROM orders
WHERE order_date >= '2026-01-01'
GROUP BY category_id
) agg
JOIN categories c ON agg.category_id = c.category_id
ORDER BY agg.sum_amount DESC;
- 조인 루프 제거: 기존에는 categories 테이블을 35만 번(loops=350000) 찔렀지만, 선집계를 수행하면 50개의 카테고리 요약 결과에 대해서만 조인하므로 PK 룩업이 350,000회 $\rightarrow$ 50회로 줄어듭니다.
- 인덱스 기반 Group By (Stream Aggregate) 유도: 만약 orders 테이블에 (category_id, order_date) 인덱스가 있고 조건절 범위에 따라 인덱스를 순서대로 읽을 수 있다면, MySQL은 임시 테이블(Aggregate using temporary table)을 아예 만들지 않고 읽으면서 바로 합산하는 -> Group aggregate (스트리밍 집계)로 실행 계획을 변경합니다.
3. EXPLAIN ANALYZE 집계 및 임시 영역 노드 비교
실행 계획 트리에서 자주 마주치는 임시 저장 및 집계 관련 노드의 차이점입니다.
| 트리 노드 명칭 | 내부 메커니즘 | 블로킹 여부 | 튜닝 포인트 |
| Group aggregate | 인덱스 정렬 순서대로 읽으며 즉시 집계 (임시 테이블 X) | Non-Blocking (그룹이 바뀔 때마다 즉시 반환) | 가장 이상적인 형태. GROUP BY 칼럼 순으로 인덱스 유지 |
| Aggregate using temporary table | 임시 테이블에 데이터를 쓰면서 중복 제거 및 집계 수행 | Full Blocking (전수 스캔 전까지 대기) | 조인 전 선집계(Subquery)로 모수를 줄이거나 커버링 인덱스 유도 |
| Materialize | 서브쿼리나 CTE 결과를 별도 임시 테이블로 구체화 | Full Blocking (구체화 완료 시까지 대기) | 지연 조인처럼 의도된 경우가 아니라면 SEMI JOIN이나 일반 조인으로 풀기 검토 |
4. 마무리: 실행 계획 분석의 3단계 프레임워크
3편에 걸친 EXPLAIN ANALYZE 분석법을 요약하면, 실무 쿼리 튜닝은 결국 아래 3가지 질문에 답하는 과정입니다.
- 예측과 실제가 맞는가? (rows 비교): 옵티마이저 예측 rows와 실제 rows가 크게 다르면 ANALYZE TABLE로 통계를 갱신하거나 히스토그램을 점검합니다.
- 어디서 파이프라인이 막히는가? (First Row Time 추적): actual time=A..B에서 시작 시간 A가 갑자기 크게 치솟는 노드(Hash, Sort, Temporary, Materialize)가 바로 전체 응답 시간을 깎아먹는 블로킹 병목 구간입니다.
- 불필요한 데이터를 얼마나 읽고 버리는가? (Filter & Loops): 스캔 건수 대비 최종 반환 건수가 극단적으로 적거나, 내부 테이블 loops가 수십만 회 반복된다면 복합 인덱스 재설계나 지연 조인(Deferred Join), 선집계 아키텍처로 쿼리를 재작성해야 합니다.
