MySQL slow query 가이드: 느린 SQL을 가장 빨리 좁히는 순서
DB
마지막 업데이트

MySQL slow query 가이드: 느린 SQL을 가장 빨리 좁히는 순서


MySQL을 운영하다 보면 결국 한 번은 “왜 이 API만 이렇게 느리지?”를 만나게 됩니다. 처음에는 흔히 SQL 문장을 다시 쓰거나 인덱스를 하나 더 만들고 싶어집니다. 실제로 한 프로젝트에서 “주문 목록 API가 3초 걸린다”는 보고가 왔는데, 느린 SQL은 하나도 없었습니다. ORM이 주문마다 배송 정보를 개별 쿼리로 가져오는 N+1 문제였고, eager loading 한 줄로 200ms로 떨어졌습니다. 그런데 실제로 느린 쿼리 대응에서 더 중요한 건 무작정 고치는 것보다, 느린 이유의 종류를 먼저 구분하는 것입니다.

왜냐하면 “slow query”라는 말 아래에는 꽤 다른 문제가 섞여 있기 때문입니다.

  • SQL 한 문장이 자체적으로 너무 무거운 경우
  • 하나하나는 평범한데 같은 요청 안에서 너무 많이 실행되는 경우
  • 정렬과 조인이 큰 중간 결과를 만드는 경우
  • lock wait나 트랜잭션 때문에 쿼리가 기다리는 경우
  • 결과 row는 적은데 읽는 row가 과도하게 많은 경우

즉 slow query는 SQL 문장 자체의 문법 문제라기보다, MySQL이 데이터를 어떤 경로로 얼마나 읽고, 어디서 기다리고, 어떤 중간 결과를 만드는지의 문제에 가깝습니다.

이 글에서는 아래를 한 번에 정리합니다.

  • 느린 쿼리를 볼 때 가장 먼저 무엇을 확인해야 하는지
  • 단일 쿼리 비용과 쿼리 수 폭증을 어떻게 구분하는지
  • EXPLAIN 에서 무엇부터 읽어야 하는지
  • 인덱스, row read, 정렬, join, 결과 크기, lock wait를 어떤 순서로 좁히는지
  • 튜닝 후 무엇으로 전후를 비교해야 하는지

한 줄 요약은 이렇습니다. slow query 대응의 핵심은 “이 SQL이 느린가?”보다 “어디서 읽기 비용이 커지고, 어디서 기다림이 생기고, 어디서 쿼리 수가 불어나는가?”를 먼저 구분하는 것입니다.


Quick answer

실무에서는 아래 순서로 보면 가장 빠릅니다.

  1. 진짜 느린 SQL이 무엇인지 먼저 식별합니다.
  2. 한 문장이 느린 건지, 같은 요청에서 쿼리가 너무 많이 나가는 건지 구분합니다.
  3. EXPLAIN 으로 type, key, rows, Extra 를 확인합니다.
  4. 인덱스 미스인지, row read 과다인지, 정렬/임시 테이블 비용인지 좁힙니다.
  5. 조인이 있다면 어느 단계에서 row가 폭발하는지 봅니다.
  6. write 쿼리라면 lock wait나 긴 트랜잭션이 섞였는지도 확인합니다.
  7. 튜닝 후에는 rows, latency, 호출 횟수, 결과 크기까지 전후를 비교합니다.

즉 느린 쿼리 분석은 감으로 SQL을 다시 쓰는 일이 아니라, 병목 유형을 먼저 분류한 뒤 그에 맞는 축으로 들어가는 작업입니다.


1. 먼저 “느린 한 문장”인지 “너무 많은 문장”인지부터 구분해야 한다

API가 2초 걸린다고 해서 항상 한 SQL이 2초인 것은 아닙니다. 실제로는 아래처럼 꽤 다른 경우가 섞여 있습니다.

  • 하나의 쿼리가 2초
  • 20개의 쿼리가 각각 100ms
  • DB는 300ms인데 나머지가 애플리케이션 로직

이 구분이 중요한 이유는 해결 방향이 완전히 달라지기 때문입니다.

  • 한 문장이 느리면 EXPLAIN, 인덱스, row read, sort, join을 봐야 합니다
  • 쿼리 수가 많으면 N+1, 반복 호출, ORM relation loading을 의심해야 합니다

즉 slow query 대응의 첫 질문은:

  • “어떤 SQL이 느린가?”

만이 아니라,

  • “정말 한 SQL이 느린 건가?”

입니다.

쿼리 수 폭증 문제는 MySQL N+1 Query 가이드와 바로 연결됩니다.


2. 느리다는 말 뒤에 숨어 있는 대표 유형

slow query는 대개 아래 유형 중 하나이거나, 둘 이상이 겹쳐 있습니다.

인덱스를 타지 못하는 경우

필터 조건, 정렬 조건, join 조건이 인덱스와 잘 맞지 않으면 너무 많은 row를 읽게 됩니다.

읽는 row 수가 과도한 경우

결과는 20건인데, 그 20건을 만들기 위해 수만 row를 읽는 경우가 대표적입니다.

정렬과 임시 작업 비용이 큰 경우

ORDER BY, GROUP BY, DISTINCT 가 큰 입력 집합 위에서 동작하면 느려지기 쉽습니다.

join에서 중간 결과가 커지는 경우

one-to-many fan-out, late filtering, broad join order가 있으면 row가 급격히 불어납니다.

반환 결과가 과한 경우

SELECT *, 큰 JSON/본문 컬럼, 과도한 result set 자체가 문제일 수 있습니다.

실제로는 wait 문제인 경우

write path에서는 lock wait, 긴 트랜잭션, blocker 세션 때문에 쿼리가 “느린 것처럼” 보일 수 있습니다.

즉 slow query는 하나의 원인이 아니라, 읽기 비용과 대기 비용이 어디서 커지는지 찾아가는 문제입니다.


3. 무작정 SQL부터 바꾸지 말고 EXPLAIN 부터 본다

느린 SQL을 찾았다면, 보통 다음 단계는 쿼리 재작성보다 EXPLAIN 입니다.

EXPLAIN
SELECT id, created_at, total_amount
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20;

초보자에게 특히 가치가 큰 컬럼은 아래 네 가지입니다.

  • type
  • key
  • rows
  • Extra

그리고 여기서 가장 먼저 보는 신호는 대략 이렇습니다.

  • type = ALL: 큰 테이블이라면 풀스캔 가능성
  • key = NULL: 기대한 인덱스를 못 타고 있을 가능성
  • rows 가 큼: 읽는 양이 과도할 가능성
  • Using filesort, Using temporary: 정렬/임시 작업 비용 가능성

중요한 건 EXPLAIN이 “이 SQL이 예쁜가?”를 알려주는 게 아니라, MySQL이 어떤 경로로 얼마나 넓게 읽고 있는지를 보여준다는 점입니다.

실행 계획 자체를 읽는 법은 MySQL EXPLAIN 가이드에서 더 자세히 이어집니다.


4. slow query에서 가장 자주 먹히는 질문은 “얼마나 많이 읽는가”다

실무에서 가장 강한 질문은 의외로 단순합니다.

  • 결과가 몇 건 나오는가
  • 그 결과를 만들기 위해 몇 row를 읽는가

느린 쿼리는 CPU 문제처럼 보일 때도 많지만, 실제로는 너무 많이 읽고 너무 많이 버리는 구조인 경우가 훨씬 많습니다. 운영하면서 가장 많이 본 패턴은, 인덱스가 있는데도 rows가 수만 건인 경우입니다. 대부분 WHERE 조건의 첫 번째 컬럼이 인덱스 leading column과 맞지 않아서였습니다.

예를 들어:

  • 결과는 10건
  • 읽는 건 50,000 row

라면, 문법 미세 조정보다 읽는 양을 줄이는 편이 훨씬 중요합니다.

그래서 slow query 대응에서는 거의 항상 이 질문이 필요합니다.

  • 정말 필요한 row만 읽고 있는가
  • 필터가 충분히 선택적인가
  • 정렬 전에 집합을 줄일 수 있는가

즉 slow query를 “느린 SQL”이라고만 생각하면 안 되고, 과한 row read 문제로 보는 편이 훨씬 실전적입니다.


5. 인덱스는 “있느냐”보다 “이 쿼리 패턴에 맞느냐”가 중요하다

많은 팀이 “이미 인덱스가 있는데 왜 느리지?”를 묻습니다. 이때 중요한 건 인덱스 존재 여부보다 적합성입니다.

예를 들어 아래가 자주 문제입니다.

  • WHERE 조건과 인덱스 leading column이 맞지 않음
  • ORDER BY를 전혀 고려하지 않음
  • join key는 있지만 선행 필터가 약함
  • 함수나 형변환 때문에 인덱스를 제대로 못 탐

즉 인덱스는 테이블에 붙어 있다는 사실보다, 그 쿼리의 필터/정렬/join 순서를 얼마나 잘 지원하느냐가 핵심입니다.

인덱스 자체 설계는 MySQL 인덱스 설계 가이드MySQL covering index 가이드가 바로 연결됩니다.


6. 정렬과 임시 테이블 비용은 생각보다 자주 병목이 된다

필터가 어느 정도 괜찮아 보여도, 느린 이유가 정렬과 그룹화일 수 있습니다.

특히 아래는 자주 봅니다.

  • ORDER BY 가 큰 입력 집합 뒤에서 수행됨
  • GROUP BY, DISTINCT 때문에 Using temporary 가 뜸
  • pagination에서 깊은 OFFSET 과 정렬이 겹침

이 경우는 “인덱스 하나 더”보다 구조 질문이 더 중요합니다.

  • 정렬 전에 집합을 더 줄일 수 있는가
  • 정렬 키가 인덱스와 맞는가
  • 정말 전체를 정렬해야 하는가
  • 결과 shape를 더 작게 만들 수 있는가

즉 sort 병목은 CPU 한 번 도는 문제라기보다, 큰 입력을 비싸게 재배열하는 문제입니다.

pagination 문제는 MySQL pagination performance 가이드와 함께 보면 더 분명해집니다.


7. join이 있으면 “어느 단계에서 row가 폭발하는가”를 본다

join이 들어가면 느린 쿼리 분석이 어려워지는 이유는, 병목이 한 테이블이 아니라 중간 결과 집합에 숨어 있기 때문입니다.

특히 아래를 먼저 봅니다.

  • 어떤 테이블이 driver 역할을 하는가
  • 어느 단계의 rows 가 갑자기 커지는가
  • one-to-many fan-out이 있는가
  • 큰 집합을 붙인 뒤 마지막에 GROUP BY 로 다시 줄이고 있지 않은가

실무에서는 종종:

  • join key 인덱스는 있는데
  • join 전 filtering이 약해서
  • 중간 결과가 불어나며
  • 마지막 정렬과 집계가 붙어
  • 전체가 느려지는 구조

를 많이 봅니다.

즉 join slow query는 “테이블이 많아서”가 아니라, row 폭발을 통제하지 못해서 느려지는 경우가 많습니다.

이 축은 MySQL join 성능 가이드에서 더 자세히 이어집니다.


8. 결과 컬럼 수와 result size도 병목이 될 수 있다

쿼리 최적화라고 하면 필터와 인덱스만 떠올리기 쉽지만, 반환하는 데이터 자체가 너무 커도 느려질 수 있습니다.

예를 들어:

  • SELECT *
  • 큰 JSON 컬럼 포함
  • 본문/설명/메타데이터까지 목록 화면에 같이 읽음

이런 패턴은 읽기 비용과 전송 비용을 같이 키웁니다.

특히 자주 호출되는 목록 쿼리에서는:

  • 필요한 컬럼만 선택하고
  • 가능하면 covering index 기회를 열고
  • 결과 자체를 작게 유지하는 것

이 꽤 큰 차이를 만듭니다.

즉 slow query는 row 수만이 아니라, 결과 shape가 얼마나 큰가의 문제이기도 합니다.


9. 느린 write 쿼리는 실제로 wait 문제일 수 있다

가끔은 쿼리가 느린 게 아니라, 잠겨서 기다리는 것입니다.

예를 들어:

  • 긴 트랜잭션이 lock을 오래 잡고 있고
  • 다른 update/delete가 뒤에서 기다리면
  • 애플리케이션에는 그 쿼리가 “느리다”로 보입니다

이때는 인덱스나 SQL 문장보다 먼저 봐야 할 게 있습니다.

  • blocker 세션이 누구인가
  • 긴 트랜잭션이 있는가
  • hotspot row 또는 range가 있는가
  • deadlock 또는 lock wait timeout이 반복되는가

즉 write path의 slow query는 read optimization 문제라기보다 concurrency와 waiting 문제일 수 있습니다.

이 경우 MySQL Lock Wait Timeout 가이드와 MySQL deadlock 가이드가 자연스럽게 이어집니다.


10. 자주 하는 실수

1. 느린 SQL을 보자마자 문장부터 다시 쓰는 것

실행 계획과 row read를 안 보면 방향이 틀릴 수 있습니다.

2. 인덱스를 하나 더 만들면 해결될 거라고 가정하는 것

인덱스가 맞지 않으면 효과가 없고, sort/join/result-size 문제가 남을 수 있습니다.

3. SELECT * 를 습관처럼 쓰는 것

느린 목록 조회의 절반은 필요한 것보다 더 많이 읽는 습관에서 나옵니다.

4. 느린 write 쿼리를 단순 read optimization 문제로 보는 것

실제로는 lock wait나 긴 트랜잭션일 수 있습니다.

5. 튜닝 후 전후 비교 없이 감으로 끝내는 것

latency, rows, 호출 수, 결과 크기 중 무엇이 좋아졌는지 확인해야 다음 판단이 쉬워집니다.


11. 튜닝 후에는 무엇을 비교해야 하나

슬로우 쿼리 대응은 “고쳤다”보다 “무엇이 얼마나 줄었는가”가 중요합니다.

최소한 아래를 같이 보세요.

  • 평균/상위 latency
  • EXPLAINrows
  • type, key, Extra 변화
  • 호출 횟수
  • 반환 결과 크기

예를 들어 latency만 줄었는데 호출 수가 그대로 많다면, 다음 단계는 N+1일 수 있습니다. 반대로 호출 수는 적은데 rows 가 아직 크다면 access path를 더 볼 차례입니다.

즉 slow query 튜닝은 단발성 수정이 아니라, 병목 유형을 한 단계씩 줄여 나가는 반복 작업입니다.


FAQ

Q. 인덱스만 추가하면 느린 쿼리가 항상 해결되나요?

아닙니다. 잘못된 인덱스는 효과가 거의 없고, join 구조, sort 비용, 결과 크기, wait 문제가 남을 수 있습니다.

Q. EXPLAIN 만 보면 충분한가요?

좋은 출발점이지만, 실제 데이터 분포와 호출 빈도, lock wait, 애플리케이션의 쿼리 수까지 같이 봐야 정확해집니다.

Q. 입문자는 무엇부터 익히면 좋을까요?

EXPLAIN, 인덱스 설계, row read 감각, join fan-out, pagination 비용 정도만 익혀도 slow query 대응 속도가 크게 올라갑니다.


먼저 읽어볼 가이드

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

광고