회원 3천 명일 때는 아무 문제 없던 목록 페이지가, 회원이 30만 명이 되자 갑자기 8초씩 걸리기 시작한다. 서버 사양을 올려도 잠깐 나아질 뿐이다. 이런 상황의 원인은 대개 하나다. 인덱스가 없거나, 있어도 데이터베이스가 그 인덱스를 못 쓰고 있다.

인덱스는 어렵고 신비한 기술이 아니다. 책 뒤에 붙은 '찾아보기'와 정확히 같은 물건이다. 오늘은 그 찾아보기가 언제 동작하고 언제 무시당하는지, 그리고 내 쿼리가 지금 어느 쪽인지 확인하는 법을 정리한다.

인덱스는 책 뒤의 '찾아보기'다

500쪽짜리 책에서 '트랜잭션'이라는 단어를 찾는다고 하자. 방법은 두 가지다. 1쪽부터 500쪽까지 넘기며 눈으로 훑거나, 뒤쪽 찾아보기에서 '트랜잭션 …… 213'을 보고 곧장 213쪽을 펴거나.

데이터베이스도 똑같다. 앞에서부터 전부 훑는 것을 풀 스캔(full table scan), 찾아보기를 거쳐 바로 가는 것을 인덱스 스캔이라고 부른다. 행이 1천 개일 때는 둘의 차이가 눈에 안 보인다. 100만 개가 되면 0.002초와 4초의 차이가 된다.

인덱스가 필요해지는 순간은 데이터가 많아진 순간이 아니라, 데이터가 많아지기 직전이다.

대신 공짜는 아니다. 찾아보기를 만들려면 종이가 더 들고, 본문이 수정될 때마다 찾아보기도 고쳐야 한다. 인덱스도 저장 공간을 쓰고, INSERT·UPDATE·DELETE 때마다 함께 갱신된다. 그래서 "일단 전부 걸어두자"는 잘못된 전략이다.

어디에 걸어야 하나 — WHERE, JOIN, ORDER BY

인덱스를 걸 자리를 고르는 기준은 단순하다. 자주 조회되는 조건 컬럼이다. 구체적으로는 세 곳을 본다.

  • WHERE 절에서 반복적으로 쓰이는 컬럼 (예: status, user_id, created_at)
  • JOIN의 연결 고리가 되는 컬럼 (외래키 자리)
  • ORDER BY·GROUP BY에서 정렬 기준이 되는 컬럼

반대로 걸어봐야 소용없는 자리도 있다. 값의 종류가 두세 가지뿐인 컬럼이 대표적이다. is_deleted처럼 0과 1만 들어가는 컬럼은 인덱스를 타도 절반을 읽어야 하므로 이득이 거의 없다. 값이 고르게 퍼져 있을수록(=카디널리티가 높을수록) 인덱스는 강력해진다.

-- 게시글 목록: 특정 카테고리의 공개글을 최신순
CREATE INDEX idx_posts_cat_status_date
  ON posts (category_id, status, created_at);

복합 인덱스의 순서는 '왼쪽부터'

컬럼 여러 개를 묶은 것을 복합 인덱스라고 한다. 여기서 가장 많이 실수하는 지점이 순서다. 복합 인덱스는 왼쪽 컬럼부터 차례로만 쓸 수 있다. 전화번호부가 '성 → 이름' 순으로 정렬돼 있으면 성을 알 때는 빠르지만, 이름만 알고 있으면 결국 처음부터 뒤져야 하는 것과 같다.

위 예시의 (category_id, status, created_at) 인덱스는 이런 쿼리에서 동작한다.

  • WHERE category_id = 3 → 사용됨
  • WHERE category_id = 3 AND status = 'published' → 사용됨
  • WHERE status = 'published'사용 안 됨 (왼쪽 컬럼을 건너뜀)

그래서 순서를 정할 때는 등호(=)로 비교되는 컬럼을 앞에, 범위(>, <, BETWEEN)로 비교되는 컬럼을 뒤에 두는 것이 기본이다. 범위 조건이 나오는 순간 그 뒤 컬럼은 정렬 이점을 잃기 때문이다.

인덱스를 걸어도 무시당하는 경우

인덱스를 분명히 만들었는데 쿼리가 여전히 느리다면, 대개 아래 넷 중 하나다.

첫째, 컬럼에 함수를 씌운 경우. WHERE DATE(created_at) = '2026-08-26' 처럼 쓰면 컬럼 값이 가공되므로 인덱스를 쓸 수 없다. 범위로 바꿔 쓴다.

-- 나쁨
WHERE DATE(created_at) = '2026-08-26'
-- 좋음
WHERE created_at >= '2026-08-26 00:00:00'
  AND created_at <  '2026-08-27 00:00:00'

둘째, 앞을 열어둔 LIKE. LIKE '%검색어%'는 찾아보기의 첫 글자를 모르는 것과 같아서 인덱스가 무력하다. LIKE '검색어%'(뒤만 열기)는 사용 가능하다. 본문 전체 검색이 필요하면 전문검색(FULLTEXT)이나 별도 검색엔진을 검토하는 편이 낫다.

셋째, 타입이 어긋난 비교. 문자열 컬럼에 숫자를 그대로 넣어 비교하면(WHERE code = 1001, code는 VARCHAR) 내부 변환이 일어나며 인덱스를 놓친다. 따옴표를 붙여 타입을 맞춘다.

넷째, 조건이 너무 넓은 경우. 전체의 30% 이상을 가져와야 한다면 옵티마이저는 "어차피 대부분 읽을 거면 그냥 순서대로 훑는 게 빠르다"고 판단해 일부러 풀 스캔을 고른다. 이건 버그가 아니라 정상 동작이다.

EXPLAIN — 추측 대신 확인하기

가장 중요한 습관은 이것이다. 느린 쿼리 앞에 EXPLAIN을 붙여 보는 것.

EXPLAIN SELECT id, title FROM posts
 WHERE category_id = 3 AND status = 'published'
 ORDER BY created_at DESC LIMIT 20;

결과에서 볼 곳은 네 칸이다.

확인할 것
typeALL이면 풀 스캔(경고). ref·range면 인덱스 사용
key실제로 선택된 인덱스 이름. NULL이면 안 쓴 것
rows훑을 것으로 예상되는 행 수. 작을수록 좋음
ExtraUsing filesort, Using temporary가 보이면 정렬 비용 발생

MySQL 8에서는 EXPLAIN ANALYZE로 실제 실행 시간까지 볼 수 있다. 그리고 어떤 쿼리가 느린지 모르겠다면 슬로우 쿼리 로그부터 켠다.

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;   -- 1초 넘는 쿼리 기록

순서를 지키면 대부분 해결된다

정리하면 이렇다. 느린 화면을 발견하면 ① 슬로우 쿼리 로그로 범인을 특정하고 ② EXPLAIN으로 풀 스캔인지 확인하고 ③ WHERE·JOIN·ORDER BY 컬럼에 순서를 고려한 인덱스를 만들고 ④ 다시 EXPLAIN으로 key가 채워졌는지 본다. 서버 증설은 이 네 단계를 다 해본 다음에 고민할 일이다.

인덱스 튜닝이 기분 좋은 이유는 결과가 즉각적이기 때문이다. 어제 8초 걸리던 페이지가 오늘 0.05초에 뜨는 걸 보면, 그날 하루가 통째로 괜찮아진다. 오늘 여러분의 프로젝트에서 가장 느린 화면 하나만 골라 EXPLAIN을 붙여보시길. 대개는 인덱스 한 줄로 끝난다.