인덱스와 InnoDB 스토리지 엔진
왜 인덱스와 엔진을 같이 봐야 할까
MySQL 성능 문제를 볼 때 가장 자주 듣는 말은 “인덱스를 타나요?”다. 그런데 이 질문은 절반만 맞다. 인덱스를 타는지뿐 아니라, 그 인덱스가 어떤 엔진 구조 위에서 어떻게 row를 찾는지까지 봐야 실제 비용을 설명할 수 있다.
예를 들어 WHERE cve_id = ? 조건이 있고 cve_id 인덱스도 있다고 해보자. 쿼리는 인덱스를 사용할 수 있다. 하지만 결과 row가 너무 많거나, secondary index에서 primary key를 따라 다시 clustered index를 많이 찾아가야 하거나, 정렬 때문에 filesort가 생기면 여전히 느릴 수 있다.
이 장의 목표는 하나다.
쿼리가 느릴 때 “인덱스가 있다/없다”가 아니라 “MySQL이 몇 row를 어떤 경로로 읽는지”를 설명할 수 있게 되는 것.
MySQL이 인덱스를 쓰는 이유
인덱스가 없으면 MySQL은 조건에 맞는 row를 찾기 위해 테이블을 처음부터 끝까지 읽어야 한다. 테이블이 작으면 괜찮지만, 수백만 row가 넘어가면 이 방식은 금방 한계에 부딪힌다.
SELECT *
FROM vulnerability
WHERE cve_id = 'CVE-2026-0001';cve_id에 인덱스가 있으면 MySQL은 전체 row를 훑지 않고, 인덱스 자료구조에서 해당 값이 있는 위치를 찾아간다. MySQL 공식 문서는 인덱스의 가장 기본 목적을 “특정 컬럼 값을 가진 row를 빠르게 찾는 것”으로 설명한다.
하지만 인덱스는 공짜가 아니다.
INSERT,UPDATE,DELETE때 인덱스도 같이 갱신해야 한다.- 인덱스가 많으면 디스크와 메모리를 더 쓴다.
- 중복 인덱스가 많아지면 optimizer가 선택할 후보도 늘어난다.
- 선택도가 낮은 인덱스는 있어도 별 효과가 없을 수 있다.
그래서 인덱스 설계의 핵심은 “많이 만들기”가 아니라 자주 실행되는 쿼리가 적게 읽도록 만들기다.
B+Tree 인덱스가 잘하는 것과 못하는 것
MySQL의 일반적인 PRIMARY KEY, UNIQUE, INDEX는 B-tree 계열 구조를 사용한다. InnoDB 관점에서는 보통 B+Tree로 이해하면 된다. B+Tree는 정렬된 key를 기준으로 탐색하기 때문에 다음 작업에 강하다.
- 동등 조건 검색
- 범위 검색
- 정렬된 순서대로 읽기
- 복합 인덱스의 leftmost prefix 활용
WHERE package_name = 'openssl'
WHERE created_at >= '2026-01-01'
ORDER BY created_at DESC반대로 시작점을 잡기 어려운 조건에는 약하다.
-- 앞부분을 알 수 없어서 일반 B+Tree 인덱스에 불리하다.
WHERE package_name LIKE '%ssl'아래처럼 앞부분이 고정되어 있으면 상대적으로 유리하다.
WHERE package_name LIKE 'open%'컬럼에 함수를 적용하는 조건도 주의해야 한다.
-- created_at 인덱스를 그대로 쓰기 어렵다.
WHERE DATE(created_at) = '2026-06-15'보통은 범위 조건으로 바꾼다.
WHERE created_at >= '2026-06-15 00:00:00'
AND created_at < '2026-06-16 00:00:00'복합 인덱스와 leftmost prefix
복합 인덱스는 여러 컬럼을 정해진 순서로 묶은 인덱스다.
CREATE INDEX idx_component_name_version
ON component(name, version);이 인덱스는 (name), (name, version) 조건에는 유리하다.
WHERE name = 'openssl'
WHERE name = 'openssl'
AND version = '3.0.0'하지만 아래 조건만으로는 인덱스를 충분히 활용하기 어렵다.
WHERE version = '3.0.0'인덱스가 name으로 먼저 정렬되고, 그 안에서 version으로 정렬되기 때문이다. 공식 문서도 multi-column index에서 MySQL이 사용할 수 있는 조합은 leftmost prefix라고 설명한다.
실무에서는 컬럼 순서를 이렇게 생각하면 쉽다.
- 먼저 쿼리를 본다. 테이블이 아니라 쿼리가 기준이다.
- 동등 조건(
=)으로 많이 줄어드는 컬럼을 앞쪽에 둔다. - 범위 조건(
>,<,BETWEEN)은 그 뒤에 둔다. ORDER BY,GROUP BY가 인덱스 순서와 맞는지 본다.SELECT컬럼까지 포함해 covering index가 가능한지 검토한다.- 기존 인덱스와 중복되는지 확인한다.
ORDER BY와 LIMIT은 인덱스 설계의 단골 문제다
아래 쿼리를 보자.
SELECT id, status, created_at
FROM scan_job
WHERE status = 'FAILED'
ORDER BY created_at DESC
LIMIT 50;status만 인덱스가 있으면 FAILED row는 빨리 찾을 수 있지만, 그 결과를 다시 created_at으로 정렬해야 할 수 있다. created_at만 인덱스가 있으면 최신순으로 읽기는 좋지만, FAILED가 아닌 row를 많이 버릴 수 있다.
이 쿼리는 보통 아래 인덱스를 후보로 둔다.
CREATE INDEX idx_scan_job_status_created_at
ON scan_job(status, created_at);그러면 MySQL은 status = 'FAILED' 범위 안에서 created_at 순서를 활용할 가능성이 생긴다. 물론 실제 선택 여부는 cardinality, 통계, row 수, MySQL 버전에 따라 달라지므로 EXPLAIN으로 확인해야 한다.
EXPLAIN에서 먼저 볼 것
쿼리 튜닝은 감으로 하면 안 된다. 먼저 실행 계획을 확인한다.
EXPLAIN
SELECT id, status, created_at
FROM scan_job
WHERE status = 'FAILED'
ORDER BY created_at DESC
LIMIT 50;자주 보는 컬럼은 다음과 같다.
| 컬럼 | 의미 | 볼 것 |
|---|---|---|
type | 접근 방식 | ALL이면 full scan 가능성을 의심한다. ref, range, const 등인지 본다. |
possible_keys | 후보 인덱스 | MySQL이 쓸 수 있다고 본 인덱스 목록이다. |
key | 실제 선택된 인덱스 | 기대한 인덱스가 선택됐는지 본다. |
rows | 예상 읽기 row 수 | 너무 크면 조건이나 인덱스 설계를 다시 본다. |
filtered | 조건 통과 비율 추정 | 많이 읽고 많이 버리는 구조인지 판단한다. |
Extra | 부가 정보 | Using filesort, Using temporary, Using index 등을 확인한다. |
key가 잡혔다고 항상 좋은 것은 아니다. 인덱스를 타더라도 rows가 너무 크면 결국 많이 읽는 쿼리다. 반대로 작은 테이블에서는 ALL이 나와도 문제가 아닐 수 있다. 중요한 것은 실제 비용과 영향 범위다.
InnoDB는 MySQL의 기본 엔진이다
MySQL은 SQL을 해석하고 최적화하는 layer와, 실제 데이터를 저장하고 읽는 storage engine layer가 분리되어 있다. MySQL 8.4에서 기본 storage engine은 InnoDB다.
InnoDB의 핵심 특징은 다음과 같다.
- ACID transaction
- commit / rollback / crash recovery
- row-level locking
- MVCC 기반 consistent read
- foreign key 지원
- clustered index 구조
운영 DB를 다룰 때 특별한 이유가 없다면 InnoDB를 기준으로 생각하면 된다.
Clustered index: primary key가 곧 데이터의 길이다
InnoDB 테이블에는 clustered index라는 특별한 인덱스가 있다. 보통 primary key가 clustered index가 된다. clustered index의 leaf page에는 실제 row 데이터가 함께 저장된다.
primary key로 row를 찾는 것은 빠르다. 검색이 곧 row 위치로 이어지기 때문이다.
반대로 secondary index는 조금 다르다. InnoDB의 secondary index record에는 secondary key뿐 아니라 해당 row의 primary key 값도 들어 있다. 그래서 secondary index로 row를 찾은 뒤, 필요한 컬럼이 인덱스에 없으면 primary key를 사용해 clustered index를 다시 찾아간다.
이 구조 때문에 primary key가 너무 크면 secondary index도 같이 커진다. 공식 문서도 primary key가 길면 secondary index가 더 많은 공간을 사용하므로 짧은 primary key가 유리하다고 설명한다.
Covering index가 빠른 이유
아래 쿼리를 보자.
SELECT name, version
FROM component
WHERE ecosystem = 'npm'
AND name = 'lodash';아래 인덱스가 있다면:
CREATE INDEX idx_component_lookup
ON component(ecosystem, name, version);쿼리에 필요한 컬럼이 모두 인덱스 안에 있다. 이 경우 MySQL은 테이블 row를 다시 읽지 않고 인덱스만으로 결과를 만들 수 있다. 이를 covering index라고 한다. EXPLAIN의 Extra에 Using index가 보이면 이런 가능성을 확인할 수 있다.
하지만 covering index를 위해 모든 컬럼을 인덱스에 넣는 것은 좋지 않다. 인덱스가 커질수록 쓰기 비용과 메모리 부담이 커진다. covering index는 아주 자주 호출되고 latency가 중요한 쿼리에 제한적으로 검토한다.
인덱스를 추가하기 전 체크리스트
인덱스 추가는 운영 변경이다. 아래를 확인한 뒤 반영한다.
- 느린 쿼리 원문을 확보했는가?
- 실행 빈도와 영향 범위를 확인했는가?
EXPLAIN에서type,key,rows,Extra를 봤는가?- 기존 인덱스와 중복되지 않는가?
- 복합 인덱스 컬럼 순서가 쿼리 조건과 맞는가?
ORDER BY,GROUP BY,JOIN까지 같이 고려했는가?- 쓰기 성능과 디스크 사용량 증가를 감당할 수 있는가?
- staging 또는 replica에서 검증했는가?
- rollback 방법을 생각했는가?
- 변경 이유와 검증 결과를 Jira/문서에 남겼는가?
실습: 어떤 인덱스가 맞을까
다음 쿼리를 보고 인덱스를 설계해보자.
SELECT id, ecosystem, name, version
FROM component
WHERE ecosystem = 'npm'
AND name = 'lodash'
AND version = '4.17.21';후보 인덱스:
CREATE INDEX idx_component_ecosystem_name_version
ON component(ecosystem, name, version);이유:
- 세 조건이 모두 동등 조건이다.
ecosystem으로 먼저 큰 범주를 줄인다.- 그 안에서
name,version으로 좁힌다. - select column 일부가 인덱스에 포함되어 covering index 가능성도 생긴다.
다음 쿼리는 어떨까?
SELECT id, created_at
FROM scan_result
WHERE DATE(created_at) = '2026-06-15'
ORDER BY created_at DESC;먼저 조건을 바꾼다.
SELECT id, created_at
FROM scan_result
WHERE created_at >= '2026-06-15 00:00:00'
AND created_at < '2026-06-16 00:00:00'
ORDER BY created_at DESC;그리고 후보 인덱스를 생각한다.
CREATE INDEX idx_scan_result_created_at
ON scan_result(created_at);흔한 오해
WHERE에 있으니 인덱스를 탄다
아니다. 함수 적용, 타입 변환, character set 차이, 낮은 선택도, 복합 인덱스 순서, optimizer 판단 때문에 안 탈 수 있다.
인덱스는 많을수록 좋다
아니다. 쓰기 성능, 저장공간, buffer pool 효율, 운영 복잡도 비용이 생긴다.
Using filesort는 무조건 나쁘다
아니다. 작은 결과셋에서는 괜찮을 수 있다. 다만 큰 결과셋에서 반복되면 문제가 된다.
primary key는 아무거나 잡아도 된다
InnoDB에서는 primary key가 clustered index이고 secondary index에도 primary key 값이 포함된다. 너무 길거나 랜덤성이 강한 primary key는 저장공간과 쓰기 패턴에 영향을 줄 수 있다.
한 줄 정리
MySQL 인덱스 튜닝은 “인덱스가 있냐 없냐”가 아니라, optimizer가 어떤 인덱스를 선택했고, InnoDB가 그 경로로 몇 row를 읽고, secondary index에서 clustered index로 얼마나 많이 되돌아가는지를 확인하는 일이다.
References
- https://dev.mysql.com/doc/refman/8.4/en/mysql-indexes.html
- https://dev.mysql.com/doc/refman/8.4/en/innodb-index-types.html
- https://dev.mysql.com/doc/refman/8.4/en/explain-output.html
- https://dev.mysql.com/doc/refman/8.4/en/innodb-introduction.html