목록 페이지가 갑자기 느려진 적 있으신가요? 어제까지 멀쩡하던 화면이 오늘따라 빙글빙글 돌기만 합니다. 코드는 하나도 안 바꿨는데 말이죠. 저도 처음 이 상황을 겪었을 때 서버를 의심하고, 네트워크를 의심하고, 심지어 컴퓨터를 한 번 껐다 켰습니다. 범인은 엉뚱한 곳에 있었습니다. 데이터가 쌓이면서 인덱스 없는 쿼리가 테이블 전체를 훑기 시작한 것이죠.
오늘은 "왜 느려졌는가"를 추측이 아니라 증거로 잡는 방법을 이야기해 보려 합니다. MySQL이 친절하게 알려주는 신호들을 읽을 줄만 알면, 막연한 불안이 구체적인 작업 목록으로 바뀝니다.
풀 테이블 스캔, 도서관에서 책을 한 권씩 펴보는 일
상황을 그림으로 그려봅시다. 회원이 10명일 때는 WHERE email = 'a@test.com' 같은 조건이 순식간에 끝납니다. 10줄을 위에서 아래로 읽는 데 시간이 걸릴 리 없으니까요. 그런데 회원이 50만 명이 되면 이야기가 달라집니다. 인덱스가 없으면 MySQL은 50만 줄을 처음부터 끝까지 한 줄씩 비교합니다. 이걸 풀 테이블 스캔(Full Table Scan)이라고 부릅니다.
비유하자면 이렇습니다. 색인이 없는 도서관에서 특정 책을 찾는다고 해봅시다. 사서는 1번 책장부터 마지막 책장까지 모든 책을 한 권씩 꺼내 제목을 확인해야 합니다. 책이 100권이면 견딜 만하지만, 50만 권이면 하루로도 모자랍니다. 인덱스는 바로 이 도서관의 '청구기호 색인표'입니다. 어느 책장 몇 번째 칸인지를 미리 정리해 둔 표죠.
데이터가 적을 때 빠른 건 코드가 좋아서가 아니라, 단지 데이터가 적어서다.
이 한 문장을 기억해 두면 좋습니다. 개발할 때는 테스트 데이터가 몇십 건뿐이라 모든 게 빨라 보입니다. 진짜 시험대는 데이터가 쌓인 운영 환경이고, 그때 인덱스의 유무가 0.01초와 8초의 차이를 만듭니다.
EXPLAIN, 쿼리의 속마음을 들여다보기
추측을 멈추고 증거를 봅시다. MySQL에는 쿼리 앞에 단어 하나만 붙이면 실행 계획을 보여주는 기능이 있습니다. 바로 EXPLAIN입니다.
-- 실제 데이터를 가져오지 않고, '어떻게 찾을 계획인지'만 보여준다
EXPLAIN SELECT * FROM members WHERE email = 'hong@example.com';결과 표에서 가장 먼저 볼 칸은 type과 rows입니다. type이 ALL이면 풀 테이블 스캔, 즉 "전부 다 뒤지겠다"는 뜻입니다. rows는 그 과정에서 훑어볼 예상 행 수입니다. 이 숫자가 테이블 전체 행 수와 비슷하다면 빨간불입니다.
인덱스를 잘 타면 type이 ref나 const로 바뀌고, rows가 수십만에서 한 자릿수로 뚝 떨어집니다. before와 after를 직접 비교해 보면 감이 옵니다.
| 구분 | type | rows | 체감 속도 |
|---|---|---|---|
| 인덱스 없음 | ALL | 500,000 | 8초, 화면 멈춤 |
| 인덱스 있음 | ref | 1 | 0.01초, 즉시 |
같은 쿼리, 같은 데이터인데 결과가 이렇게 갈립니다. 차이를 만든 건 단 한 줄의 인덱스 선언이었습니다.
인덱스, 어디에 걸어야 효과가 있나
그럼 인덱스를 아무 데나 많이 걸면 될까요? 그렇지 않습니다. 핵심은 자주 검색·정렬·조인에 쓰이는 컬럼입니다. 다음 세 곳을 우선순위로 보면 거의 맞습니다.
첫째, WHERE 조건에 자주 등장하는 컬럼입니다. 로그인할 때 매번 조회하는 email, 주문을 회원별로 찾는 member_id 같은 것이죠. 둘째, ORDER BY로 정렬하는 컬럼입니다. 목록을 최신순으로 보여준다면 created_at에 인덱스가 있으면 정렬 비용이 크게 줄어듭니다. 셋째, 테이블을 잇는 조인 키입니다.
-- 단일 컬럼 인덱스: 이메일로 자주 조회한다면
ALTER TABLE members ADD INDEX idx_email (email);
-- 복합 인덱스: '회원별 + 최신순'으로 자주 본다면 순서가 중요하다
ALTER TABLE orders ADD INDEX idx_member_created (member_id, created_at);복합 인덱스에서는 컬럼 순서가 성능을 좌우합니다. (member_id, created_at)는 "특정 회원의 주문을 최신순으로"에는 잘 들지만, created_at 단독 조건만으로는 효율이 떨어집니다. 전화번호부가 '성 → 이름' 순으로 정렬돼 있어 성을 알면 빠르지만, 이름만으로는 처음부터 뒤져야 하는 것과 같은 이치입니다.
인덱스가 '안 먹는' 흔한 함정
인덱스를 분명히 걸었는데도 여전히 느린 경우가 있습니다. 십중팔구 쿼리가 인덱스를 못 쓰게 작성돼 있어서입니다. 대표적인 세 가지를 짚어 보겠습니다.
하나, 컬럼을 함수로 감싸는 경우입니다. WHERE DATE(created_at) = '2026-06-24'처럼 컬럼에 함수를 씌우면 인덱스가 무력화됩니다. MySQL이 매 행마다 함수를 계산해야 하기 때문이죠. 대신 범위 조건으로 바꿔 줍니다.
-- 나쁜 예: 인덱스를 못 탄다
WHERE DATE(created_at) = '2026-06-24'
-- 좋은 예: 범위로 바꾸면 인덱스를 탄다
WHERE created_at >= '2026-06-24 00:00:00'
AND created_at < '2026-06-25 00:00:00'둘, LIKE '%검색어%'처럼 앞에 %가 붙는 경우입니다. 앞부분이 고정되지 않으면 인덱스의 정렬 순서를 활용할 수 없습니다. 본문 전체 검색이 정말 필요하다면 전문 검색(FULLTEXT)이나 별도 검색엔진을 고려해야 합니다. 셋, 숫자 컬럼에 문자열을 비교하는 등 타입이 어긋나는 경우입니다. 묵시적 형변환이 일어나며 인덱스를 놓칩니다. 컬럼 타입과 비교 값의 타입을 일치시키는 것만으로 해결될 때가 많습니다.
무작정 추가하기 전에, 비용도 보자
마지막으로 균형 이야기입니다. 인덱스는 공짜가 아닙니다. 조회는 빨라지지만, INSERT·UPDATE·DELETE는 느려집니다. 데이터가 바뀔 때마다 인덱스도 함께 갱신해야 하니까요. 또 인덱스는 디스크 공간을 차지합니다.
그래서 실무 원칙은 단순합니다. "느린 쿼리를 먼저 찾고, 그 쿼리에 필요한 인덱스만 정확히 건다." 막연히 모든 컬럼에 인덱스를 거는 건 오히려 쓰기 성능을 갉아먹습니다. MySQL의 슬로우 쿼리 로그를 켜 두면 어떤 쿼리가 실제로 느린지 데이터로 남으니, 거기서부터 시작하면 헛수고를 줄일 수 있습니다.
정리하겠습니다. 첫째, 화면이 느려지면 코드를 의심하기 전에 EXPLAIN으로 실행 계획부터 확인하세요. 둘째, type=ALL과 큰 rows가 보이면 자주 쓰는 조건·정렬·조인 컬럼에 인덱스를 거세요. 셋째, 함수 감싸기와 앞쪽 %는 인덱스를 무력화하니 쿼리 모양을 바꿔 주세요.
느린 화면 앞에서 발만 동동 구르던 그 새벽을, 저는 EXPLAIN 한 줄로 끝냈습니다. 추측은 사람을 불안하게 하지만 증거는 사람을 차분하게 만듭니다. 오늘 여러분의 가장 느린 쿼리 하나에 EXPLAIN을 붙여 보세요. 생각보다 답이 가까이 있을지도 모릅니다.