Published on

MySQL·MongoDB 느린쿼리, 인덱스 미적중 7가지

Authors

서버가 바쁘지 않은데도 특정 API만 유독 느리다면, 높은 확률로 "인덱스가 있는데도 못 타는" 쿼리 형태가 숨어 있습니다. 문제는 인덱스를 추가하는 것보다, 왜 옵티마이저가 인덱스를 못 쓰는지를 정확히 진단하는 데 있습니다.

이 글은 MySQL과 MongoDB에서 실무에서 자주 만나는 인덱스 미적중 7가지 패턴을 정리하고, 각각에 대해 재현 가능한 예시 쿼리, 실행계획 확인 방법, 그리고 안전한 개선 방향을 제시합니다.

진단 과정에서 애플리케이션 장애 대응 흐름(로그, 재현, 롤백 전략)이 함께 필요하다면 systemd 서비스 재시작 루프 10분 진단 가이드처럼 "증상-원인-확인" 루틴을 먼저 잡아두는 것도 도움이 됩니다.

0) 먼저 확인: 실행계획이 진실이다

MySQL

인덱스를 탔는지 보려면 EXPLAIN과 실제 실행 통계를 주는 EXPLAIN ANALYZE를 같이 봅니다.

EXPLAIN FORMAT=TRADITIONAL
SELECT *
FROM orders
WHERE user_id = 42 AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 50;

EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 42 AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 50;
  • typeALL이면 풀스캔 가능성이 큽니다.
  • rows가 과도하게 크면 필터링이 늦게 일어나는 중입니다.
  • Using filesort, Using temporary는 정렬/그룹에서 인덱스가 못 맞는 신호일 때가 많습니다.

MongoDB

explain("executionStats")실제 검사 문서 수를 확인합니다.

db.orders.find(
  { userId: 42, createdAt: { $gte: ISODate("2026-01-01") } }
).sort({ createdAt: -1 }).limit(50)
 .explain("executionStats")
  • totalDocsExamined가 크고 nReturned가 작으면 인덱스 미적중 또는 낮은 선택도 문제입니다.
  • IXSCAN이 떠도 FETCH에서 대량 문서 접근이 일어나면 커버링이 안 되는 상태일 수 있습니다.

1) 컬럼에 함수/가공을 씌워 SARGable이 깨짐

인덱스는 대개 "원본 값"에 대해 정렬된 구조라서, 조건절에서 컬럼을 가공하면 범위 탐색이 어려워집니다.

MySQL: DATE(created_at) 같은 패턴

-- 느린 형태: 컬럼에 함수 적용
SELECT *
FROM orders
WHERE DATE(created_at) = '2026-02-01';

-- 개선: 범위 조건으로 바꾸기
SELECT *
FROM orders
WHERE created_at >= '2026-02-01 00:00:00'
  AND created_at <  '2026-02-02 00:00:00';

추가로, LOWER(email) = 'a@b.com' 같은 형태도 동일합니다. MySQL 8에서는 함수 기반 인덱스(Functional Index)를 고려할 수 있습니다.

CREATE INDEX idx_users_email_lower ON users ((LOWER(email)));

MongoDB: $expr로 필드에 연산을 걸면 인덱스가 깨지기 쉬움

// 느린 형태: $expr 내부에서 필드 가공
db.orders.find({
  $expr: { $eq: [ { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } }, "2026-02-01" ] }
})

// 개선: 범위 조건으로 바꾸기
db.orders.find({
  createdAt: {
    $gte: ISODate("2026-02-01T00:00:00Z"),
    $lt:  ISODate("2026-02-02T00:00:00Z")
  }
})

2) 복합 인덱스의 선행(prefix) 조건이 빠짐

복합 인덱스는 보통 왼쪽부터 순서대로(선행 컬럼부터) 잘 맞춰줘야 효율이 나옵니다.

MySQL 예시

인덱스가 (user_id, status, created_at)인데 status만 걸면 기대만큼 못 탑니다.

CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at);

-- 인덱스 선행 조건(user_id)이 빠져 비효율 가능
SELECT *
FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC
LIMIT 50;

개선 방향은 두 가지입니다.

  1. 실제 질의 패턴에 맞는 인덱스를 추가
CREATE INDEX idx_orders_status_created
ON orders (status, created_at);
  1. 가능하다면 질의 조건을 선행 컬럼부터 타도록 변경(비즈니스적으로 가능할 때)

MongoDB 예시

MongoDB도 복합 인덱스는 "좌측 prefix"가 중요합니다.

db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 })

// userId 없이 status만 검색하면 인덱스 효율이 떨어질 수 있음
db.orders.find({ status: "PAID" }).sort({ createdAt: -1 }).limit(50)

이 경우 status 중심 조회가 잦다면 별도 인덱스를 둡니다.

db.orders.createIndex({ status: 1, createdAt: -1 })

3) 타입 불일치로 인덱스가 사실상 무력화

"값은 같아 보이는데" 타입이 다르면 비교 방식이 달라져 인덱스 효율이 급락할 수 있습니다.

MySQL: 문자열과 숫자 비교, 콜레이션 불일치

-- user_id가 INT인데 문자열로 바인딩되는 경우(드라이버/ORM에서 종종 발생)
SELECT *
FROM orders
WHERE user_id = '42';

MySQL은 경우에 따라 암묵적 형변환을 수행하며, 그 과정에서 인덱스 활용이 비효율적이거나 예측 불가해질 수 있습니다. 애플리케이션 레벨에서 바인딩 타입을 확정하고, 컬럼 정의와 일치시키세요.

또한 문자열 비교는 콜레이션/문자셋이 섞이면 인덱스 효율이 깨질 수 있습니다. utf8mb4_general_ciutf8mb4_0900_ai_ci 혼용처럼 미묘한 차이도 원인이 됩니다.

MongoDB: ObjectId와 문자열 혼용

// _id가 ObjectId인데 문자열로 조회하면 인덱스를 못 타거나 결과가 안 나옴
const id = "65b1f2...";
db.users.find({ _id: id })

// 개선
const { ObjectId } = require("mongodb");
db.users.find({ _id: new ObjectId(id) })

실무에서는 "조인 대체"로 다른 컬렉션에 userId를 문자열로 저장해두고, 원본은 ObjectId인 상황이 자주 발생합니다. 스키마/타입 규약을 팀 차원에서 고정하세요.

4) LIKE/정규식이 앞에 와일드카드를 가져 인덱스가 못 탐

MySQL: LIKE '%keyword%'

-- 느린 형태
SELECT *
FROM products
WHERE name LIKE '%pro%';

-- 가능한 개선 1: 접두(prefix) 검색으로 바꾸기
SELECT *
FROM products
WHERE name LIKE 'pro%';

'%...%'를 반드시 지원해야 하면 MySQL에서는 Fulltext 인덱스나 별도 검색엔진(예: OpenSearch)을 고려합니다.

ALTER TABLE products ADD FULLTEXT INDEX ft_name (name);

SELECT *
FROM products
WHERE MATCH(name) AGAINST('pro' IN NATURAL LANGUAGE MODE);

MongoDB: /.*keyword.*/ 형태

// 느린 형태: 앞에 .* 이 붙는 정규식은 인덱스 효율이 매우 낮음
db.products.find({ name: /.*pro.*/i })

// 개선: 접두 정규식(anchored)
db.products.find({ name: /^pro/i })

MongoDB에서 케이스-인센서티브 정규식은 인덱스 활용이 제한적일 수 있습니다. 필요하면 정규화된 필드(예: nameLower)를 저장하고 거기에 인덱스를 거는 방식이 더 예측 가능합니다.

5) OR 조건이 인덱스 전략을 망가뜨림

OR는 각각의 분기가 서로 다른 인덱스를 요구하는 경우가 많아 옵티마이저가 타협(풀스캔)하는 일이 잦습니다.

MySQL

-- user_id 인덱스, email 인덱스가 각각 있어도 OR로 묶이면 비효율 가능
SELECT *
FROM users
WHERE user_id = 42 OR email = 'a@b.com';

개선 방법:

  • UNION ALL로 쪼개서 각 분기가 자기 인덱스를 타게 만들기
(SELECT * FROM users WHERE user_id = 42)
UNION ALL
(SELECT * FROM users WHERE email = 'a@b.com' AND user_id <> 42);
  • 또는 데이터 모델/요구사항을 조정해 단일 키로 조회되게 만들기

MongoDB

MongoDB도 $or는 인덱스를 쓸 수 있지만, 분기별 인덱스가 다르고 선택도가 낮으면 COLLSCAN으로 무너질 수 있습니다.

db.users.find({
  $or: [ { userId: 42 }, { email: "a@b.com" } ]
}).explain("executionStats")

$or 각 분기에 맞는 인덱스가 존재하는지, 그리고 결과 집합이 너무 커서 결국 많은 FETCH가 발생하는지 확인합니다.

6) 정렬/그룹이 인덱스 순서와 안 맞아 filesort/대량 메모리 사용

조회 자체는 인덱스를 타도, ORDER BYGROUP BY 때문에 정렬 단계가 병목이 되는 경우가 많습니다.

MySQL: ORDER BY가 인덱스와 불일치

-- (user_id, created_at) 인덱스가 있다고 가정
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);

-- created_at DESC 정렬이 잦다면 인덱스 방향/구성이 중요
SELECT *
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 50;

MySQL 8은 역순 스캔을 잘 처리하지만, 중간에 다른 조건/정렬 컬럼이 끼면 Using filesort가 쉽게 발생합니다. 다음을 검토하세요.

  • WHERE 조건과 ORDER BY 컬럼을 같은 인덱스에 묶기
  • 필요한 컬럼만 읽도록 커버링 인덱스 설계(아래 7번 참고)

MongoDB: sort가 인덱스와 안 맞음

// userId로 필터하고 createdAt으로 정렬한다면
// 복합 인덱스가 없으면 메모리 정렬이 발생할 수 있음

db.orders.find({ userId: 42 }).sort({ createdAt: -1 }).limit(50)

// 개선
db.orders.createIndex({ userId: 1, createdAt: -1 })

정렬이 커지고 allowDiskUse가 켜지는 순간부터는, 인덱스로 정렬을 해결하는 편이 비용/지연 모두에서 유리한 경우가 많습니다.

7) 커버링 실패로 인덱스를 타도 결국 테이블/문서를 과도하게 읽음

인덱스를 타는 것과 "빠른 것"은 다릅니다. 인덱스로 후보를 찾은 뒤 원본 레코드(테이블)나 문서(컬렉션)를 대량으로 읽으면 느립니다.

MySQL: SELECT *가 만드는 랜덤 I/O

-- 인덱스는 타지만, 필요한 컬럼이 많아 테이블 룩업이 대량 발생
SELECT *
FROM orders
WHERE user_id = 42 AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 100;

개선:

  • 필요한 컬럼만 선택
  • 자주 쓰는 조회라면 커버링 인덱스 고려
-- 예: 목록 화면에 필요한 컬럼이 id, created_at, total_amount, status 정도라면
CREATE INDEX idx_orders_cover
ON orders (user_id, created_at, status, total_amount, id);

SELECT id, created_at, status, total_amount
FROM orders
WHERE user_id = 42 AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 100;

커버링 인덱스는 쓰기 비용과 저장공간을 늘립니다. "핫패스" 조회에만 제한적으로 적용하는 것이 안전합니다.

MongoDB: 프로젝션 미사용으로 FETCH 비용 증가

MongoDB도 필요한 필드만 가져오면 I/O와 네트워크 비용이 줄어듭니다.

// 느린 형태: 문서 전체를 반환
db.orders.find({ userId: 42 }).sort({ createdAt: -1 }).limit(100)

// 개선: 필요한 필드만 projection
db.orders.find(
  { userId: 42 },
  { _id: 1, createdAt: 1, status: 1, totalAmount: 1 }
).sort({ createdAt: -1 }).limit(100)

또한 MongoDB는 "커버링 쿼리"가 되려면, 조회/정렬/프로젝션이 모두 인덱스만으로 해결되어야 합니다. explain에서 FETCH가 사라지는지 확인하세요.

실전 체크리스트: 느린쿼리에서 바로 보는 10가지

  • MySQL은 EXPLAIN ANALYZE로 실제 읽은 row 수가 폭증하는 지점을 찾는다.
  • MongoDB는 totalDocsExaminednReturned의 비율을 본다.
  • 컬럼에 함수/가공이 들어갔는지 확인한다.
  • 복합 인덱스의 선행 컬럼 조건이 빠졌는지 확인한다.
  • 타입 불일치(특히 ObjectId vs string, 숫자 vs 문자열 바인딩)를 의심한다.
  • 접두가 아닌 LIKE/정규식 검색인지 확인한다.
  • OR는 분해(UNION ALL) 또는 모델 변경을 검토한다.
  • ORDER BY/sort가 인덱스 순서와 일치하는지 본다.
  • SELECT *를 제거하고 커버링 가능성을 본다.
  • 인덱스 추가 전후로 쓰기 비용과 잠금/빌드 시간을 추정한다.

마무리: 인덱스는 "추가"가 아니라 "맞춤"이다

인덱스 미적중은 단순히 인덱스가 없어서가 아니라, 쿼리 형태, 타입, 정렬, 선택도가 맞지 않아 발생합니다. 위 7가지 패턴을 기준으로 실행계획을 읽으면, "왜 느린지"가 숫자로 보이기 시작합니다.

분산 환경에서 동일 요청이 중복 실행되며 DB 부하가 폭증하는 케이스라면, DB 튜닝과 함께 중복처리 전략도 같이 봐야 합니다. 이벤트 기반 처리라면 Kafka MSA 중복처리 막는 Outbox 패턴 구현, 트랜잭션 경계가 복잡하다면 MSA 사가(Saga) 중복처리·보상트랜잭션 설계 실전도 함께 참고하면 전체 지연을 더 크게 줄일 수 있습니다.