[DB 완전 정복 #3] 느린 쿼리를 잡아라 — EXPLAIN 읽는 법과 페이징 최적화

EXPLAIN 실행 계획에서 type, key, Extra가 뭘 의미하는지, OFFSET 페이징이 느린 이유와 커버링 인덱스로 해결하는 방법을 정리한다.

-

쿼리가 느리다. 어디서부터 봐야 할까

개발 환경에서는 데이터가 몇 백 건이라 빠르다. 그런데 운영 환경에 올라가면 특정 페이지가 버벅인다. 로그를 보면 DB 쿼리가 범인이다.

이때 시작점은 하나다. EXPLAIN.


EXPLAIN — 옵티마이저의 속마음을 보는 방법

EXPLAIN은 MySQL 옵티마이저가 쿼리를 어떻게 실행할지 보여주는 명령이다.

EXPLAIN SELECT o.*, m.name
FROM orders o
JOIN members m ON o.member_id = m.id
WHERE o.status = 'PENDING'
ORDER BY o.created_at DESC
LIMIT 20;

출력에서 봐야 할 컬럼 네 가지:

컬럼의미좋은 값나쁜 값
type테이블 접근 방식const, ref, rangeALL (풀스캔)
key실제 사용한 인덱스인덱스명NULL
rows예상 스캔 행 수작을수록클수록
Extra부가 정보Using indexUsing filesort, Using temporary

type 값 — 빠른 순서

const       PK로  1  조회 (가장 빠름)
eq_ref      조인에서 PK로 1  조회
ref         일반 인덱스로 여러  조회
range       인덱스 범위 스캔 (BETWEEN, >, <)
index       인덱스  스캔 (테이블보다는 낫지만 느림)
ALL         테이블  스캔 (가장 느림, 즉시 개선 필요)

type: ALL이 보이면 인덱스가 없거나 무력화된 것이다.

Extra 해석

Using index       → 커버링 인덱스. 테이블 접근 없음 (좋음)
Using where       → 인덱스 후 WHERE 필터링 (보통)
Using filesort    → ORDER BY를 정렬 버퍼에서 처리 (느림, 인덱스로 해결 가능)
Using temporary   → 임시 테이블 사용 (느림, GROUP BY/DISTINCT 최적화 필요)
Using index condition → ICP (인덱스 레벨에서 조건 필터링, 개선)

Using filesortUsing temporary가 함께 나오면 위험 신호다. ORDER BY나 GROUP BY 컬럼에 인덱스를 추가하는 것을 검토해야 한다.


슬로우 쿼리 로그 — 운영에서 느린 쿼리 찾기

EXPLAIN은 알고 있는 쿼리를 분석할 때 쓴다. 운영에서 어떤 쿼리가 느린지 모를 때는 슬로우 쿼리 로그를 켠다.

-- 슬로우 쿼리 로그 설정
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;  -- 1초 이상 걸리는 쿼리 기록
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';

로그에 쌓인 쿼리를 mysqldumpslow로 집계하면 가장 자주 느린 쿼리 순으로 정렬할 수 있다.

Spring Boot에서는 p6spy 라이브러리를 추가하면 JPA가 실행하는 쿼리의 실행 시간을 콘솔에서 볼 수 있다.


조인 최적화 — 드라이빙 테이블이 결과를 결정한다

MySQL은 조인 시 **드라이빙 테이블(Driving Table)**을 먼저 읽고, 그 결과로 드리븐 테이블(Driven Table)을 검색한다.

SELECT o.*, m.name
FROM orders o
JOIN members m ON o.member_id = m.id
WHERE o.status = 'PENDING';

옵티마이저는 어떤 테이블을 드라이빙으로 할지 결정한다. 일반적으로 결과 행 수가 적은 테이블이 드라이빙이 된다.

핵심 원칙: 드리븐 테이블의 조인 조건 컬럼에는 반드시 인덱스가 있어야 한다. 없으면 드라이빙 테이블의 행마다 드리븐 테이블 풀스캔이 반복된다.

-- members.id에 인덱스가 없으면
-- orders 행 수만큼 members 풀스캔 반복 → O(N*M)
-- members.id에 인덱스 있으면
-- 각 orders 행에서 members를 O(log M)으로 조회 → O(N*log M)

OFFSET 페이징의 함정

가장 많이 쓰는 페이징 쿼리다.

SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 100000;

문제는 OFFSET 100000이다. MySQL은 첫 번째 행부터 100,020번째 행까지 읽어서, 앞 100,000개를 버리고 20개만 반환한다. 페이지 번호가 높아질수록 버리는 데이터가 늘어난다.

페이지 1: 20개 읽기
페이지 5000: 100,020개 읽고 100,000개 버리기

해결책 1: 커버링 인덱스 + 서브쿼리

-- 1단계: 인덱스만으로 PK 목록 조회 (테이블 접근 없음)
SELECT id FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;

-- 2단계: PK로 실제 데이터 조회
SELECT o.* FROM orders o
JOIN (
    SELECT id FROM orders
    ORDER BY created_at DESC
    LIMIT 20 OFFSET 100000
) AS sub ON o.id = sub.id;

서브쿼리는 인덱스만 읽으니 빠르고, 최종 데이터는 PK 20개로만 조회한다.

해결책 2: 커서 기반 페이징 (No Offset)

-- 첫 페이지
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20;
-- 마지막으로 받은 created_at이 '2026-05-01 12:00:00'이라면

-- 다음 페이지 (OFFSET 없이 조건으로)
SELECT * FROM orders
WHERE created_at < '2026-05-01 12:00:00'
ORDER BY created_at DESC LIMIT 20;

OFFSET 없이 항상 인덱스 범위 스캔으로 처리된다. 몇 백만 페이지가 되어도 속도가 일정하다. 단, 임의 페이지 이동(페이지 번호 클릭)이 필요하면 쓸 수 없다. 무한 스크롤에 최적이다.


마치며

쿼리 최적화의 순서:

1. EXPLAIN으로 실행 계획 확인
2. type: ALL → 인덱스 추가
3. Using filesort → ORDER BY 컬럼에 인덱스
4. 조인: 드리븐 테이블 조인 조건에 인덱스
5. 페이징: OFFSET 높으면 커버링 인덱스 또는 커서 기반으로 전환

다음 편에서는 테이블 설계의 기초인 정규화와, 실무에서 의도적으로 정규화를 깨는 경우를 다룬다.

개인정보처리방침