MySQL 쿼리가 느려졌을 때 바로 확인하는 실전 체크리스트 (인덱스, N+1, 데드락)
Dev

MySQL 쿼리가 느려졌을 때 바로 확인하는 실전 체크리스트 (인덱스, N+1, 데드락)


서비스 초기에는 아무 문제 없이 10ms 만에 뚝딱 끝나던 조회가, 데이터가 수십만 건을 넘어가고 동시 접속자가 늘어나는 순간 갑자기 수 초씩 걸리며 DB 커넥션을 바닥내기 시작합니다.

백엔드 장애의 80% 이상은 결국 데이터베이스(DB) 병목에서 터집니다. 하지만 무작정 DB 스펙을 올리기(Scale-up) 전에 쿼리와 인덱스를 조금만 손봐도 성능이 수십 배에서 수백 배까지 개선되는 경우가 대부분입니다.

이번 글에서는 서비스 운영 중에 쿼리 속도가 느려졌을 때 당황하지 않고 가장 먼저 확인해봐야 할 실전 최적화 체크리스트들을 차근차근 정리해 보았습니다.


1. EXPLAIN 실행 계획에서 꼭 봐야 할 3가지

쿼리 튜닝의 첫걸음은 MySQL이 내 쿼리를 어떻게 실행하는지 들여다보는 것입니다.

EXPLAIN ANALYZE 
SELECT id, user_id, amount, created_at 
FROM transactions 
WHERE user_id = 1042 AND created_at >= '2026-01-01' 
ORDER BY created_at DESC;

실행 계획 결과에서 아래 3개 항목을 집중해서 확인하세요.

  1. type (접근 방식):
    • 🚨 ALL (Full Table Scan): 테이블 전체를 처음부터 끝까지 다 뒤지는 상태입니다. 인덱스가 아예 안 타고 있다는 뜻이므로 즉각 조치가 필요합니다.
    • ⚠️ index (Full Index Scan): 인덱스 트리를 전부 훑는 상태입니다.
    • ✅ range, ref, eq_ref, const: 인덱스를 타서 원하는 데이터 범위만 콕 집어 읽는 이상적인 상태입니다.
  2. rows & filtered:
    • MySQL이 쿼리를 만족시키기 위해 대략 몇 줄을 읽어야 하는지 나타냅니다. rows 수가 실제 반환되는 행보다 너무 크다면 인덱스 필터링이 덜 되고 있다는 신호입니다.
  3. Extra:
    • Using filesort: 정렬할 인덱스가 없어서 메모리나 임시 디스크에서 별도로 소팅(sorting)하고 있다는 경고입니다.
    • Using index: **커버링 인덱스(Covering Index)**가 적용되어 테이블 데이터 블록을 열어보지도 않고 인덱스만으로 조회를 끝냈다는 최고의 상태입니다.

2. 복합 인덱스(Composite Index)의 황금률

컬럼이 여러 개인 쿼리에는 인덱스도 여러 컬럼을 묶어 걸어주어야 합니다. 이때 컬럼의 **순서(Ordering)**가 성능을 좌우합니다.

인덱스 컬럼 배치 공식

[동등 조건(=) 컬럼] ➔ [범위 조건(>, <, BETWEEN, LIKE) 컬럼] ➔ [ORDER BY 정렬 컬럼]

예를 들어 WHERE status = 'PAID' AND created_at >= '2026-01-01' 형태의 쿼리가 잦다면, 인덱스는 (created_at, status)가 아니라 (status, created_at) 순서로 만들어야 status로 범위를 단숨에 압축한 뒤 일자별 범위를 깔끔하게 스캔할 수 있습니다.


3. ORM의 단골 함정: N+1 문제와 지연 로딩

Prisma, TypeORM, JPA 등 편리한 ORM을 쓸 때 가장 흔히 저지르는 실수가 바로 N+1 쿼리입니다.

  • 문제 상황: 사용자 100명을 조회하는 쿼리 1번 실행 후, 반복문 돌며 각 사용자의 최근 거래 내역을 조회하는 쿼리 100번이 추가로 날아가 총 101번의 네트워크 왕복이 발생하는 현상.
  • 해결책:
    • 반드시 조인 페치(JOIN FETCH)나 ORM의 인클루드(include: { transactions: true }) 옵션을 통해 한 번의 조인 쿼리 혹은 WHERE user_id IN (...) 쿼리로 묶어서 가져오도록 코드를 수정해야 합니다.

4. 무거운 OFFSET 대신 Keyset(No-Offset) 페이지네이션

페이지 번호를 누르는 전형적인 페이지네이션은 뒤로 갈수록 쿼리가 치명적으로 느려집니다.

-- 1,000,000번째 레코드부터 20건을 읽으려면 앞의 100만 건을 다 읽고 버려야 합니다 (엄청난 낭비!)
SELECT * FROM orders ORDER BY id DESC LIMIT 20 OFFSET 1000000;

이를 해결하는 것이 바로 **Keyset 페이지네이션 (Seek Method)**입니다. 무한 스크롤이나 피드 형태의 UI에 완벽하게 어울립니다.

-- 직전 페이지의 마지막 id만 알고 있다면, OFFSET 없이 인덱스를 타고 즉시 점프합니다!
SELECT * FROM orders 
WHERE id < 154200 
ORDER BY id DESC 
LIMIT 20;

5. 대용량 Insert와 PK 설계: UUID v7 vs Auto-Increment

초당 수천 건 이상의 결제 로그나 센서 데이터를 밀어 넣을 때 PK를 무엇으로 잡느냐가 쓰기 성능을 결정합니다.

  • Auto-Increment (BigInt): 인덱스 리프 노드 맨 끝에 순차적으로 착착 쌓이므로 B-Tree 분할(Split)이 없어 쓰기 속도가 가장 빠릅니다. 하지만 샤딩 환경이나 ID 예측 공격에 취약합니다.
  • 랜덤 UUID (v4): 전 세계 어디서든 고유하지만, 무작위 문자열이라 B-Tree 인덱스 중간중간을 마구잡이로 비집고 들어가 인덱스 단편화(Fragmentation)와 심각한 쓰기 지연을 일으킵니다.
  • 추천 대안 (UUID v7): 타임스탬프가 앞쪽에 붙어 있어 시간 순서대로 정렬되는 최신 UUID 표준입니다. 분산 환경의 고유성을 지키면서도 Auto-Increment에 버금가는 순차 쓰기 성능을 챙길 수 있습니다.

대량 적재 시에는 쿼리를 건건이 날리지 말고 500~1,000건씩 묶어 INSERT INTO ... VALUES (...), (...) 형태의 **배치 인서트(Batch Insert)**로 처리하면 처리량이 10배 이상 치솟습니다.


6. 트랜잭션 데드락(Deadlock)과 리플리케이션 지연

  • 데드락 방지 팁: 복수의 자원을 업데이트할 때는 모든 트랜잭션이 동일한 순서로 테이블과 로우를 잠그도록(Lock) 코드 룰을 통일하세요. A는 1번 유저 ➔ 2번 유저 순으로 잠그고, B는 2번 유저 ➔ 1번 유저 순으로 잠그면 필연적으로 교착 상태가 터집니다.
  • 읽기 복제본(Read Replica) 지연: 무거운 통계/조회 쿼리를 복제본 DB로 분리했더라도, 방금 회원이 글을 쓰고 리다이렉트된 직후 읽을 때는 마스터(Primary) DB를 바라보게 해야 “방금 쓴 글이 안 보여요!” 같은 리플리케이션 지연(Replication Lag) 이슈를 피할 수 있습니다.

먼저 읽어볼 가이드

검색 유입이 많은 핵심 글부터 이어서 보세요.