실무 운영 환경에서 이 못지않게 자주 호출되면서 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)

핵심 개선 포인트

  1. Covering index lookup의 위력: 인라인 뷰(target) 내부에서는 PK인 order_id만 조회하므로 데이터 블록에 전혀 접근하지 않고 세컨더리 인덱스 블록만 스캔합니다. 10만 건을 스킵하는 시간이 838ms에서 32.1ms로 줄어들었습니다.
  2. 최종 20건만 PK Lookup: 바깥쪽 쿼리에서는 Materialize(구체화)된 20건의 order_id를 가지고 orders 테이블의 PK를 단 20번만(loops=20) 찌릅니다(0.011ms × 20 = 0.22ms).
  3. 전체 실행 시간이 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;
  1. 조인 루프 제거: 기존에는 categories 테이블을 35만 번(loops=350000) 찔렀지만, 선집계를 수행하면 50개의 카테고리 요약 결과에 대해서만 조인하므로 PK 룩업이 350,000회 $\rightarrow$ 50회로 줄어듭니다.
  2. 인덱스 기반 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가지 질문에 답하는 과정입니다.

  1. 예측과 실제가 맞는가? (rows 비교): 옵티마이저 예측 rows와 실제 rows가 크게 다르면 ANALYZE TABLE로 통계를 갱신하거나 히스토그램을 점검합니다.
  2. 어디서 파이프라인이 막히는가? (First Row Time 추적): actual time=A..B에서 시작 시간 A가 갑자기 크게 치솟는 노드(Hash, Sort, Temporary, Materialize)가 바로 전체 응답 시간을 깎아먹는 블로킹 병목 구간입니다.
  3. 불필요한 데이터를 얼마나 읽고 버리는가? (Filter & Loops): 스캔 건수 대비 최종 반환 건수가 극단적으로 적거나, 내부 테이블 loops가 수십만 회 반복된다면 복합 인덱스 재설계나 지연 조인(Deferred Join), 선집계 아키텍처로 쿼리를 재작성해야 합니다.

실무에서 대용량 배치를 돌리거나 복잡한 집계 쿼리를 튜닝할 때 가장 빈번하게 마주치는 성능 저하 원인은 조인 시 인덱스를 타지 못해 발생하는 해시 조인(Hash Join)과 인덱스 순서와 무관한 정렬로 인한 소트(Sort) 병목입니다. 두 연산 모두 메모리 버퍼를 초과하는 순간 디스크 I/O(Spill to Disk)를 유발하며 응답 시간을 기하급수적으로 늦춥니다.

EXPLAIN ANALYZE 출력 트리에서 이 두 가지 병목이 어떤 패턴으로 나타나는지, 그리고 데이터 아키텍처 관점에서 어떻게 튜닝해야 하는지 정리합니다.

 


1. 해시 조인(Hash Join)의 작동 원리와 병목 패턴

MySQL 8.0.18부터 기존의 비효율적인 Block Nested Loop(BNL)를 대체하기 위해 Hash Join이 도입되었습니다. 조인 조건 칼럼에 적절한 인덱스가 없거나, 읽어야 할 데이터 모수가 너무 많아 인덱스 랜덤 액세스 비용이 오히려 높다고 판단될 때 옵티마이저가 선택합니다.

작동 구조: Build 단계와 Probe 단계

  1. Build Phase (해시 테이블 생성): 조인에 참여하는 두 테이블 중 크기가 작은 테이블(Build Input)을 읽어 메모리(join_buffer_size)에 해시 테이블을 생성합니다.
  2. Probe Phase (탐색 및 매칭): 상대적으로 큰 테이블(Probe Input)을 스캔하면서 해시 함수를 적용해 메모리상의 해시 테이블과 매칭되는 레코드를 찾습니다.

EXPLAIN ANALYZE 출력 예시 (Hash Join 발생)

 
EXPLAIN ANALYZE
SELECT o.order_id, o.total_amount, c.region_name
FROM orders o
JOIN campaign_logs c ON o.promo_code = c.promo_code
WHERE o.order_date >= '2026-01-01';
 
-> Inner hash join (o.promo_code = c.promo_code)  (cost=485200.50 rows=450000) (actual time=145.210..892.440 rows=380000 loops=1)
    -> Table scan on o  (cost=120.40 rows=500000) (actual time=0.095..310.120 rows=500000 loops=1)
    -> Hash
        -> Filter: (c.created_at >= TIMESTAMP'2026-01-01 00:00:00')  (cost=15400.20 rows=120000) (actual time=0.450..138.900 rows=120000 loops=1)
            -> Table scan on c  (cost=15400.20 rows=150000) (actual time=0.080..95.300 rows=150000 loops=1)

핵심 해석 포인트

  • -> Hash 노드의 정체: 이 노드의 하위에 있는 campaign_logs (c) 테이블이 메모리에 해시 테이블로 올라가는 Build 테이블입니다. 해시 테이블을 다 만들 때까지는 상위 노드로 단 한 건의 데이터도 반환할 수 없는 블로킹 연산(Blocking Operation)입니다.
  • Inner hash join의 actual time=145.210..892.440:
    • 첫 번째 행이 반환된 시간(145.210ms)이 하위 Hash 노드의 완료 시간(138.900ms) 직후입니다. 즉, Build 테이블 12만 건을 모두 메모리에 적재한 뒤에야 비로소 orders (o) 테이블을 읽으며 조인 결과를 내보내기 시작했음을 보여줍니다.
  • 디스크 스필(On-Disk Hash Join) 주의: 만약 Build 테이블의 크기가 세션의 join_buffer_size를 초과하면, MySQL은 디스크에 임시 청크 파일(Chunk Files)을 생성하여 쪼개서 처리합니다. 이때 actual time의 끝 시간(B)이 수 초~수십 초 단위로 급증합니다.

튜닝 가이드

  1. OLTP 환경 (단건/소량 조회): 해시 조인이 발생했다는 것은 조인 연결고리(ON 절)에 인덱스가 누락되었다는 신호입니다. promo_code 칼럼에 인덱스를 생성하여 Nested Loop Join으로 유도하고, First Row Time을 0ms 단위로 낮춰야 합니다.
  2. 배치/DW 환경 (대량 데이터 조인): 수십만 건 이상을 전수 조인할 때는 Hash Join이 Nested Loop보다 훨씬 빠릅니다. 인덱스를 억지로 태우기보다 세션 레벨에서 SET SESSION join_buffer_size = 64 * 1024 * 1024; (예: 64MB)처럼 버퍼를 충분히 늘려 디스크 I/O(Chunk 분할)가 발생하지 않도록 인메모리 해시 조인을 유도합니다.

2. 정렬 병목(Filesort)의 작동 원리와 진단법

ORDER BY나 GROUP BY 절을 처리할 때 인덱스의 정렬 순서를 그대로 활용할 수 없다면, 옵티마이저는 메모리(sort_buffer_size)나 디스크 임시 영역을 사용해 데이터를 직접 정렬하는 Filesort를 수행합니다.

EXPLAIN ANALYZE 출력 예시 (Sort 병목 발생)

EXPLAIN ANALYZE
SELECT user_id, order_date, total_amount
FROM orders
WHERE status = 'COMPLETED'
ORDER BY order_date DESC
LIMIT 100;
 
-> Limit: 100 row(s)  (cost=32150.80 rows=100) (actual time=412.550..412.580 rows=100 loops=1)
    -> Sort: o.order_date DESC, limit input to 100 row(s) per chunk  (cost=32150.80 rows=300000) (actual time=412.548..412.565 rows=100 loops=1)
        -> Index lookup on o using idx_status (status='COMPLETED')  (cost=32150.80 rows=300000) (actual time=0.120..285.400 rows=300000 loops=1)

핵심 해석 포인트

  • First Row Time(412.548ms)과 All Rows Time(412.565ms)의 일치:
    • Sort 연산은 대표적인 전체 블로킹(Full Blocking) 연산입니다. 하위 idx_status 인덱스에서 30만 건을 다 읽어들여(285.400ms 소요) 정렬을 완전히 끝마치기 전까지는 LIMIT 100이 있어도 단 1건의 레코드도 상위 노드로 올려보내지 못합니다.
    • 그 결과, 첫 번째 행을 꺼내는 데만 412.548ms가 걸렸고, 이미 정렬된 상태에서 100건을 꺼내는 데는 불과 0.017ms(412.565 - 412.548)밖에 걸리지 않았습니다.
  • Sort Buffer 초과 시 다중 병합(Multi-pass Merge): 정렬 대상 데이터가 sort_buffer_size보다 크면 데이터를 잘라 디스크 임시 파일에 기록한 뒤 다시 병합 정렬(Merge Sort)을 수행합니다. 특히 SELECT *처럼 불필요한 대용량 칼럼(TEXT, 긴 VARCHAR)까지 조회 대상에 포함하면 소트 버퍼가 금방 차버려 디스크 I/O 병목이 극심해집니다.

튜닝 가이드

  1. 결합 인덱스(Composite Index) 재설계 (Pipelines):
    • 정렬 연산 자체를 없애는 것이 최선입니다. 위 쿼리의 경우 (status, order_date) 순서로 복합 인덱스를 구성하면, status = 'COMPLETED' 조건을 만족하는 구간이 이미 order_date 순으로 정렬되어 있습니다.
    • 이 경우 Sort 노드가 아예 사라지고 인덱스를 역순 스캔(Backward Index Scan)하며 딱 100건만 읽고 즉시 종료하므로 실행 시간이 412ms $\rightarrow$ 0.5ms 이하로 단축됩니다.
  2. 소트 버퍼 효율화 (조회 칼럼 다이어트):
    • 인덱스 추가가 어려운 비정형 정렬 쿼리라면, 정렬 단계에서 메모리에 올리는 행당 크기(Row Width)를 줄여야 합니다. 모든 칼럼을 가져온 뒤 정렬하지 말고, PK만 먼저 정렬해서 LIMIT으로 건수를 자른 뒤 조인하는 지연 조인(Deferred Join / Late Row Lookup) 기법을 적용하면 소트 버퍼 효율을 극대화할 수 있습니다.

3. 한눈에 보는 병목 진단 요약표

트리 노드 표시 의미 및 내부 동작 주요 병목 증상 (actual time=A..B) 1순위 해결책
-> Hash 조인 전 메모리 해시 테이블 생성 (Build) 조인 노드의 시작 시간(A)이 Hash 완료 시간(B)만큼 지연됨 OLTP는 조인 키 인덱스 추가, 배치는 join_buffer_size 증설
-> Sort 메모리/디스크 공간에서 강제 정렬 (Filesort) A(첫 행 반환)와 B(전체 완료)가 거의 동일한 큰 수치로 찍힘 WHERE 조건 + ORDER BY 칼럼 결합 인덱스 생성 또는 지연 조인
-> Temporary 중간 집계 결과를 임시 테이블에 적재 하위 스캔 완료 후 임시 테이블 기록 시간만큼 상위 노드 대기 GROUP BY 최적화 인덱스 적용 또는 불필요한 DISTINCT 제거

EXPLAIN ANALYZE를 볼 때는 단순히 전체 실행 시간만 볼 것이 아니라, 어느 노드에서 First Row Time(A)이 급격히 점프하는지를 추적해야 합니다. 시작 시간 A가 갑자기 커지는 지점이 바로 파이프라인이 끊기고 메모리/디스크 버퍼링이 일어나는 튜닝의 핵심 타겟입니다.

기존의 EXPLAIN 명령어는 옵티마이저가 수집된 통계 정보를 기반으로 "어떻게 실행할 것인가(예측치)"만을 보여줍니다. 하지만 데이터 분포가 편향되었거나 통계 정보가 최신 상태가 아니라면, 옵티마이저가 산출한 비용(Cost)과 실제 실행 시 발생하는 리소스 소모 간에 큰 괴리가 발생합니다.

 

MySQL 8.0.18부터 도입된 EXPLAIN ANALYZE는 쿼리를 엔진에서 실제로 실행하면서 단계별 소요 시간과 처리 건수를 트리 형태로 측정합니다. 옵티마이저의 비용 모델이 실행 계획을 결정하는 원리와 EXPLAIN ANALYZE 결과의 핵심 해석 지점을 정리합니다.

 


1. 옵티마이저 비용 모델(Cost Model)의 작동 원리

MySQL 옵티마이저는 비용 기반 옵티마이저(CBO, Cost-Based Optimizer)입니다. 여러 가능한 실행 경로(인덱스 레인지 스캔, 테이블 풀 스캔, 해시 조인 등) 중 예상 연산 비용(Cost Value)이 가장 낮은 플랜을 선택합니다.

비용 계산은 mysql.server_cost 및 mysql.engine_cost 시스템 테이블에 정의된 가중치 상수를 기반으로 이루어집니다.

  • 디스크 I/O 비용 (engine_cost):
    • io_block_read_cost: 디스크에서 데이터/인덱스 블록을 1개 읽을 때의 비용 (기본값: 1.0)
    • memory_block_read_cost: 버퍼 풀 메모리에서 블록을 읽을 때의 비용 (기본값: 0.25)
  • CPU 연산 비용 (server_cost):
    • row_evaluate_cost: 조건절(WHERE) 검증을 위해 1개의 레코드를 평가하는 비용 (기본값: 0.1)
    • key_compare_cost: 정렬 또는 조인 시 두 인덱스 키를 비교하는 비용 (기본값: 0.05)
총 예상 비용 ≈ (디스크 블록 읽기 수 × 1.0) 
             + (메모리 블록 읽기 수 × 0.25) 
             + (평가 대상 레코드 수 × 0.1)

옵티마이저는 인덱스 카디널리티와 테이블 통계(Stats)를 바탕으로 스캔할 블록과 레코드 수를 추정하고, 이 공식에 대입해 최소 점수를 얻은 플랜을 최종 확정합니다.

2. EXPLAIN vs EXPLAIN ANALYZE

구분 일반 EXPLAIN EXPLAIN ANALYZE
실행 여부 쿼리를 실제로 실행하지 않음 (시뮬레이션) 실제 쿼리를 끝까지 실행함 (DML 실행 시 주의)
출력 형식 테이블 형태 (Tabular) 트리 계층 구조 (Tree Text)
제공 지표 예상 Row 수, 예상 조인 방식, 정적 인덱스 정보 실제 실행 시간(ms), 실제 반환 Row 수, 루프(Loop) 횟수

주의: EXPLAIN ANALYZE는 실제로 쿼리를 구동하므로, 대용량 데이터를 처리하는 무거운 쿼리나 쓰기 쿼리에 적용할 때는 운영 DB에 직접적인 부하를 줄 수 있습니다.

3. EXPLAIN ANALYZE 출력 결과 분석법

다음은 복합 조건 조회를 EXPLAIN ANALYZE로 실행했을 때의 전형적인 결과입니다.

EXPLAIN ANALYZE
SELECT o.order_id, u.user_name
FROM orders o
JOIN users u ON o.user_id = u.user_id
WHERE o.order_date >= '2026-10-01'
  AND o.status = 'CANCELLED';

출력 예시

-> Nested loop inner join  (cost=125.40 rows=150) (actual time=0.852..14.320 rows=20 loops=1)
    -> Filter: ((o.status = 'CANCELLED') and (o.order_date >= TIMESTAMP'2026-10-01 00:00:00'))  (cost=42.15 rows=150) (actual time=0.082..1.450 rows=20 loops=1)
        -> Index range scan on o using idx_order_date  (cost=42.15 rows=450) (actual time=0.075..1.120 rows=450 loops=1)
    -> Single-row index lookup on u using PRIMARY (user_id=o.user_id)  (cost=0.45 rows=1) (actual time=0.062..0.063 rows=1 loops=20)

핵심 수치 읽는 법

트리 구조는 가장 안쪽(가장 깊게 들여쓰기된 노드)에서 바깥쪽으로 실행됩니다.

  1. actual time=A..B:
    • A (First Row Time): 해당 연산 단계에서 첫 번째 레코드를 찾는 데 걸린 시간(ms).
    • B (All Rows Time): 해당 연산 단계에서 모든 레코드를 처리하고 완료한 시간(ms).
  2. rows=N (실제 반환 행 수):
    • 예측된 rows와 실제 rows의 괴리를 비교합니다.
    • 위 예시에서 idx_order_date는 450건을 읽었으나, 최종 필터 후 남은 행은 20건뿐입니다. 이는 날짜 인덱스 단독 스캔으로 인해 불필요한 행을 430건이나 더 읽었음을 뜻하며, (order_date, status) 복합 인덱스로 개선해야 할 명확한 근거가 됩니다.
  3. loops=M (반복 실행 횟수):
    • 중첩 루프 조인(Nested Loop Join)에서 내부 테이블 조회가 몇 번 반복되었는지를 나타냅니다.
    • 개별 단계의 총 소요 시간은 (actual time × loops)로 계산해야 정확합니다.

4. 실무 최적화 체크포인트

  1. 예측 Row 수 vs 실제 Row 수 불일치 진단:
    • 예상 Row 수와 실제 Row 수가 10배 이상 차이 난다면 인덱스 통계 정보가 왜곡된 상태입니다. ANALYZE TABLE <table_name>; 명령어로 통계 테이블을 최신화해야 옵티마이저가 정상적인 판단을 내릴 수 있습니다.
  2. First Row와 All Rows 간격 확인:
    • actual time의 시작 시간(A)과 끝 시간(B)의 차이가 크다면 디스크 임시 테이블 생성이나 인덱스 범위 스캔 구간이 넓어 I/O 지연이 발생하고 있다는 의미입니다.
  3. 루프 폭발(Loop Multiplier) 감지:
    • 외부 드라이빙 테이블의 추출 건수가 많을 때 내부 테이블 탐색 루프(loops)가 수만~수십만 회로 치솟으면 인덱스 룩업이라도 병목이 됩니다. 드라이빙 테이블을 교체하거나 해시 조인(Hash Join)을 유도하도록 힌트나 조인 순서를 조정해야 합니다.

+ Recent posts