티스토리 뷰

운영 DB에서 특정 화면이 느리다는 제보를 받으면, 저는 제일 먼저 쿼리부터 봅니다. 그런데 쿼리를 열어보면 십중팔구 이런 반응이 나옵니다. "어? 이거 인덱스 있는데?" 인덱스가 있다는 것과 인덱스를 탄다는 것은 완전히 다른 얘기입니다. 이 차이를 모르면 인덱스를 아무리 추가해도 쿼리는 계속 느립니다.

이번 글은 제가 실무에서 반복적으로 만난 "인덱스가 있는데도 안 타는" 케이스들을 MySQL 기준으로 정리한 것입니다. 대부분 EXPLAIN 한 번이면 원인이 보이는 것들인데, 처음 겪으면 한참 헤매게 되는 유형들입니다.

사진: Unsplash의 Growtika

먼저 EXPLAIN 읽는 법부터, 딱 세 컬럼만

실행계획을 처음 보면 컬럼이 많아서 부담스러운데, 실무에서 문제를 잡을 때 제가 먼저 보는 건 딱 세 개입니다.

EXPLAIN SELECT * FROM orders WHERE user_id = 12345;
  • type: 접근 방식입니다. const, eq_ref, ref, range까지는 양호하고, index는 애매하고, ALL이 나오면 풀스캔입니다. 느린 쿼리의 범인은 대부분 ALL입니다.
  • rows: 옵티마이저가 "이 정도 읽어야겠다"고 추정한 행 수입니다. 실제 결과가 10건인데 rows가 200만이면, 200만 건을 훑어서 10건을 골라내고 있다는 뜻입니다.
  • Extra: Using filesort, Using temporary가 보이면 정렬이나 임시 테이블에서 비용이 나가고 있다는 신호입니다.

key 컬럼에 인덱스 이름이 찍혀 있다고 안심하면 안 됩니다. 인덱스를 "쓰긴 쓰는데" 범위를 못 좁혀서 사실상 풀스캔에 가까운 경우도 많습니다. 그래서 type과 rows를 같이 봐야 합니다.

사례 1. 컬럼을 함수로 감싸면 인덱스는 무용지물

제일 흔한 유형입니다. 날짜 조건 걸 때 이렇게 쓰는 경우가 많습니다.

-- created_at에 인덱스가 있어도 풀스캔
SELECT * FROM orders
WHERE DATE(created_at) = '2026-08-19';

인덱스는 created_at의 원본 값으로 정렬돼 있습니다. 그런데 DATE()로 가공한 값과 비교하면, MySQL은 모든 행에 대해 함수를 실행해본 뒤에야 조건을 판단할 수 있습니다. 인덱스 정렬 순서를 활용할 수 없으니 풀스캔이 됩니다.

고치는 방법은 컬럼을 그대로 두고 조건 쪽을 범위로 바꾸는 겁니다.

-- 인덱스 range 스캔
SELECT * FROM orders
WHERE created_at >= '2026-08-19 00:00:00'
  AND created_at < '2026-08-20 00:00:00';

같은 원리로 WHERE SUBSTRING(code, 1, 3) = 'ABC', WHERE amount * 1.1 > 10000 같은 것도 전부 인덱스를 못 탑니다. 규칙은 하나입니다. WHERE 절에서 인덱스 컬럼은 절대 가공하지 않는다. 가공은 항상 반대편, 즉 상수 쪽에서 합니다.

사례 2. 자료형이 다르면 조용히 풀스캔이 된다

이건 저도 운영에서 한 번 제대로 당한 적이 있습니다. 사용자 식별 코드를 VARCHAR로 저장한 테이블이 있었는데, 애플리케이션에서 숫자로 조건을 넘겼습니다.

-- user_code는 VARCHAR, 인덱스 있음
SELECT * FROM members WHERE user_code = 12345;   -- 풀스캔
SELECT * FROM members WHERE user_code = '12345'; -- 인덱스 사용

문자열 컬럼과 숫자를 비교하면 MySQL은 컬럼 쪽을 숫자로 암묵적 형변환합니다. 컬럼이 변환된다는 건 사례 1과 똑같이 "컬럼을 가공한" 상황이라 인덱스를 못 탑니다. 무서운 점은 에러가 안 난다는 겁니다. 결과는 똑같이 나오고, 데이터가 적을 땐 느린지도 모릅니다. 데이터가 수백만 건 쌓인 뒤에야 터집니다.

당시엔 배포 후 특정 API만 응답이 3초씩 걸려서 한참 뒤졌는데, 원인은 ORM 쪽에서 파라미터 타입이 int로 바뀐 커밋 하나였습니다. 이후로 저는 코드 리뷰에서 WHERE 조건의 타입 일치를 습관적으로 봅니다.

사진: Unsplash의 MARCO

사례 3. 복합 인덱스는 순서가 전부다

복합 인덱스 (a, b, c)는 전화번호부와 같습니다. 성으로 정렬하고, 같은 성 안에서 이름으로 정렬한 구조입니다. 그래서 왼쪽 컬럼부터 조건이 있어야 인덱스가 제 역할을 합니다.

-- INDEX idx_status_created (status, created_at)

-- 인덱스 잘 탐
WHERE status = 'PAID' AND created_at >= '2026-08-01'

-- status 조건이 없으면 이 인덱스로 범위를 못 좁힘
WHERE created_at >= '2026-08-01'

또 하나 자주 놓치는 규칙이 있습니다. 범위 조건을 만난 뒤의 컬럼은 인덱스 정렬을 활용하지 못한다는 점입니다.

-- INDEX (status, created_at, amount)
WHERE status = 'PAID'
  AND created_at >= '2026-08-01'   -- 범위 조건
  AND amount = 50000               -- 여기부터는 필터링으로만 동작

그래서 복합 인덱스를 설계할 땐 동등 비교(=) 컬럼을 앞에, 범위 조건 컬럼을 뒤에 두는 게 기본입니다. 인덱스를 새로 팔 때 저는 항상 "이 테이블에서 제일 자주 나오는 WHERE 조합이 뭔가"부터 쿼리 로그로 확인하고 순서를 정합니다.

사례 4. LIKE '%키워드%'는 인덱스가 못 돕는다

검색 기능 만들 때 단골로 나오는 문제입니다.

SELECT * FROM products WHERE name LIKE '%노트북%';  -- 풀스캔
SELECT * FROM products WHERE name LIKE '노트북%';   -- range 스캔

인덱스는 앞에서부터 정렬돼 있어서, 앞부분이 와일드카드면 어디서 시작할지 정할 수가 없습니다. 전방 일치(키워드%)만 인덱스를 탑니다. 중간 일치 검색이 정말 필요하면 MySQL 풀텍스트 인덱스(ngram parser)를 쓰거나, 검색 트래픽이 크면 Elasticsearch 같은 검색 엔진으로 빼는 게 맞습니다. 어중간하게 LIKE '%...%'를 운영 테이블에 그대로 두면 데이터가 커질수록 조용히 전체 서비스를 갉아먹습니다.

사례 5. 인덱스는 탔는데 여전히 느린 경우

EXPLAIN에서 type도 ref고 key도 잘 찍혀 있는데 느린 경우가 있습니다. 이때 의심할 건 두 가지입니다.

첫째, 카디널리티가 낮은 컬럼. status 같은 컬럼에 값이 3종류밖에 없고 그중 'DONE'이 전체의 90%라면, 'DONE'으로 조회할 때 인덱스를 타는 게 오히려 손해일 수 있습니다. 인덱스로 위치를 찾고 다시 테이블로 가서 행을 읽는 왕복 비용이, 그냥 처음부터 쭉 읽는 것보다 비싸지기 때문입니다. 옵티마이저가 알아서 풀스캔을 선택하기도 하는데, 그게 오히려 정상 판단인 경우입니다.

둘째, 커버링 인덱스가 아닌 경우의 테이블 왕복. 조회 컬럼이 전부 인덱스 안에 있으면 테이블까지 안 가고 인덱스만 읽고 끝납니다. EXPLAIN의 Extra에 Using index가 뜨면 커버링으로 동작한다는 뜻입니다.

-- INDEX (user_id, status, created_at)
SELECT status, created_at        -- 인덱스만 읽고 끝 (Using index)
FROM orders WHERE user_id = 12345;

SELECT *                         -- 행마다 테이블 왕복 발생
FROM orders WHERE user_id = 12345;

목록 화면처럼 자주 호출되는 쿼리는 SELECT * 를 걷어내고 필요한 컬럼만 남긴 뒤, 그 컬럼들이 인덱스에 포함되도록 설계하면 눈에 띄게 빨라집니다. 실제로 페이지네이션 쿼리 하나를 커버링 인덱스로 바꿔서 평균 응답이 수백 ms에서 수십 ms로 내려간 적도 있습니다.

사진: Unsplash의 Albert Stoynov

정렬이 느리면 ORDER BY와 인덱스 방향을 보라

Extra에 Using filesort가 떠 있다면 정렬을 인덱스로 해결하지 못하고 있다는 뜻입니다. WHERE와 ORDER BY가 같은 인덱스를 타도록 맞춰주면 filesort가 사라집니다.

-- INDEX (user_id, created_at)
SELECT * FROM orders
WHERE user_id = 12345
ORDER BY created_at DESC
LIMIT 20;

이 쿼리는 인덱스를 뒤에서부터 20건만 읽고 끝납니다. filesort도 없고, LIMIT 덕에 읽는 양 자체가 적습니다. 반대로 WHERE 컬럼과 ORDER BY 컬럼이 서로 다른 인덱스에 있으면, 옵티마이저는 둘 중 하나를 포기해야 해서 어느 쪽이든 비용이 생깁니다. 목록+정렬+페이징이 한 세트로 나오는 화면은 처음부터 (필터 컬럼, 정렬 컬럼) 복합 인덱스를 한 벌로 설계하는 게 좋습니다.

인덱스를 많이 단다고 빨라지지 않는다

여기까지 읽으면 "그럼 의심되는 컴럼마다 인덱스를 다 걸면 되겠네"라는 결론으로 가기 쉽습니다. 저도 주니어 때 그렇게 했다가 다른 문제를 만들었습니다. 인덱스는 공짜가 아닙니다.

INSERT, UPDATE, DELETE가 일어날 때마다 해당 테이블의 모든 인덱스도 같이 갱신됩니다. 인덱스가 8개 법어 있는 테이블은 행 하나를 쓸 때마다 8군데를 같이 손봐야 합니다. 쓰기 트래픽이 많은 테이블에서는 이게 그대로 쓰기 지연과 락 경합으로 돌아옵니다. 게다가 옵티마이저 입장에서는 후보 인덱스가 많을수록 선택이 복잡해져서, 어느 날 갑자기 엉뚱한 인덱스를 타는 사고도 생깁니다.

그래서 인덱스를 추가하기 전에 두 가지를 먼저 확인합니다. 첫째, 기존 복합 인덱스로 커버되는 조건인지. (a, b) 인덱스가 있으면 (a) 단독 인덱스는 중복입니다. 둘째, 쓰이지 않는 인덱스가 있는지. MySQL 8.0이면 sys.schema_unused_indexes 뷰로 한 번도 안 쓰인 인덱스를 바로 뽑을 수 있습니다. 분기에 한 번씩 이 뷰를 확인해서 죽은 인덱스를 정리하는 것만으로도 쓰기 성능과 디스크 비용이 개선됩니다.

실무용 점검 순서 요약

느린 쿼리를 만났을 때 제가 실제로 밟는 순서입니다.

  1. EXPLAIN을 뜬다. type이 ALL인지, rows가 비정상적으로 큰지, Extra에 filesort/temporary가 있는지 본다.
  2. WHERE 절에서 인덱스 컬럼이 함수나 연산으로 가공되고 있는지 확인한다.
  3. 비교하는 값의 자료형이 컬럼과 일치하는지 확인한다. 특히 VARCHAR 컬럼 vs 숫자 파라미터.
  4. 복합 인덱스라면 선두 컬럼 조건이 있는지, 범위 조건 뒤에 동등 조건이 밀려 있지 않은지 본다.
  5. LIKE 패턴이 전방 일치인지 확인한다.
  6. 그래도 느리면 커버링 인덱스 여부와 카디널리티를 본다.

경험상 운영에서 만나는 느린 쿼리의 8할은 1~4번 안에서 끝납니다. 5~6번까지 가는 경우는 설계 자체를 다시 봐야 하는 경우가 많고요.

마치며

인덱스 튜닝은 거창한 기술이라기보다 습관에 가깝습니다. 쿼리를 짜면서 "이 조건이 인덱스 정렬을 활용할 수 있는 모양인가"를 한 번씩 되묻는 습관, 배포 전에 무거워질 만한 쿼리에 EXPLAIN을 한 번 떠보는 습관. 이 두 가지만 있어도 새벽에 DB CPU 알람 받고 일어나는 날이 확실히 줄어듭니다. 저처럼 애 셋 재우고 나서야 겨우 노트북을 여는 처지라면, 새벽 알람은 정말 피하고 싶으니까요.

다음에 기회가 되면 실행계획의 나머지 컬럼들(possible_keys, filtered, EXPLAIN ANALYZE)을 읽는 법도 따로 정리해보겠습니다.

댓글


최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday