인덱스는 왜 성능의 핵심인가
데이터가 적을 때는 어떤 쿼리도 빠르게 실행됩니다. students 테이블에 학생이 20명뿐이라면 MySQL이 모든 행을 하나씩 확인해도 큰 문제가 없습니다. 하지만 데이터가 10만 건, 100만 건으로 늘어나면 이야기가 달라집니다.
다음 쿼리를 생각해 봅시다.
SELECT *
FROM students
WHERE email = '[email protected]';인덱스가 없다면 MySQL은 students 테이블의 첫 행부터 마지막 행까지 확인해야 합니다. 이것을 테이블 전체 스캔이라고 합니다. 원하는 이메일이 중간에 있든 마지막에 있든, MySQL은 많은 행을 읽어야 합니다.
인덱스가 있으면 MySQL은 모든 행을 읽지 않고도 원하는 위치를 빠르게 찾을 수 있습니다. 책 뒤쪽의 찾아보기에서 단어를 찾는 것과 비슷합니다. 본문 전체를 처음부터 읽는 대신, 정렬된 목록에서 단어를 찾고 해당 페이지로 바로 이동하는 방식입니다.
인덱스의 목적은 단순합니다.
읽어야 할 데이터의 양을 줄이는 것.
MySQL 성능 문제의 상당수는 이 원리를 이해하는 것만으로도 해결의 실마리를 찾을 수 있습니다.

테이블 전체 스캔과 인덱스 검색
다음과 같은 students 테이블이 있다고 하겠습니다.
CREATE TABLE students (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
email VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at DATETIME NOT NULL
);email로 학생을 자주 찾는다면 다음처럼 인덱스를 만들 수 있습니다.
CREATE INDEX idx_students_email
ON students (email);이제 MySQL은 email 값을 기준으로 정렬된 별도의 자료 구조를 유지합니다. 쿼리가 WHERE email = ... 조건을 사용하면 테이블 전체를 뒤지는 대신 idx_students_email 인덱스에서 값을 먼저 찾습니다.
인덱스가 없을 때의 흐름은 다음과 같습니다.
students테이블의 첫 번째 행을 읽습니다.email값이 조건과 같은지 확인합니다.아니면 다음 행으로 이동합니다.
마지막 행까지 반복합니다.
인덱스가 있을 때의 흐름은 다릅니다.
idx_students_email인덱스에서jiwoo@example.com위치를 찾습니다.해당 인덱스 항목이 가리키는 실제 행을 읽습니다.
필요한 결과를 반환합니다.
데이터가 많을수록 차이는 커집니다. 인덱스는 쿼리를 마법처럼 빠르게 만드는 기능이 아닙니다. 단지 불필요하게 읽는 행을 줄여서 빠르게 만듭니다.
B-Tree 인덱스의 기본 원리
MySQL에서 일반적으로 사용하는 인덱스 구조는 B-Tree 계열 인덱스입니다. B-Tree는 데이터를 정렬된 상태로 보관하면서, 특정 값을 빠르게 찾을 수 있게 해 주는 트리 구조입니다.
트리 구조라고 해서 어렵게 생각할 필요는 없습니다. 핵심은 다음 세 가지입니다.
인덱스 값이 정렬되어 있습니다.
위에서 아래로 좁혀 가며 원하는 값을 찾습니다.
특정 값뿐 아니라 범위 검색에도 유리합니다.
예를 들어 email 인덱스가 있다면 MySQL은 이메일 값을 정렬된 상태로 관리합니다. 그래서 다음 조건을 빠르게 처리할 수 있습니다.
SELECT *
FROM students
WHERE email = '[email protected]';동등 조건뿐 아니라 범위 조건에도 인덱스가 도움이 됩니다.
SELECT *
FROM students
WHERE created_at >= '2026-01-01'
AND created_at < '2026-02-01';created_at에 인덱스가 있다면 MySQL은 2026년 1월 1일이 시작되는 지점을 찾고, 2월 1일 전까지 이어서 읽으면 됩니다. 정렬되어 있기 때문에 범위의 시작과 끝을 찾기 쉽습니다.
CREATE INDEX idx_students_created_at
ON students (created_at);B-Tree 인덱스는 특히 다음 상황에서 자주 효과를 냅니다.
WHERE email = ...같은 정확한 값 검색WHERE created_at >= ...같은 범위 검색ORDER BY created_at같은 정렬GROUP BY course_id같은 그룹화조인 조건에 사용되는 외래키 검색
다만 인덱스가 있다고 모든 쿼리가 빨라지는 것은 아닙니다. MySQL이 인덱스를 효과적으로 사용하려면 조건식과 인덱스 설계가 잘 맞아야 합니다.
단일 컬럼 인덱스
단일 컬럼 인덱스는 하나의 열에만 만드는 인덱스입니다.
CREATE INDEX idx_students_email
ON students (email);이 인덱스는 다음 쿼리에 잘 맞습니다.
SELECT *
FROM students
WHERE email = '[email protected]';email은 보통 중복이 거의 없습니다. 특정 이메일 하나를 찾으면 결과가 한 명으로 좁혀질 가능성이 높습니다. 이런 열은 인덱스 효과가 큽니다.
반대로 status 열은 어떨까요?
SELECT *
FROM students
WHERE status = 'active';status 값이 active, inactive, blocked 정도밖에 없다면 중복이 매우 많습니다. 전체 학생 100만 명 중 80만 명이 active라면, status 인덱스를 사용해도 결국 많은 행을 읽어야 합니다.
CREATE INDEX idx_students_status
ON students (status);이 인덱스가 항상 나쁜 것은 아닙니다. 하지만 status 하나만으로 많은 행이 선택된다면 효과가 작을 수 있습니다. 인덱스는 값이 많이 좁혀질수록 유리합니다.
복합 인덱스는 열의 순서가 중요하다
복합 인덱스는 두 개 이상의 열을 묶어 만든 인덱스입니다.
CREATE INDEX idx_students_status_created_at
ON students (status, created_at);이 인덱스는 먼저 status로 정렬하고, 같은 status 안에서 다시 created_at으로 정렬합니다.
따라서 다음 쿼리에 잘 맞습니다.
SELECT *
FROM students
WHERE status = 'active'
AND created_at >= '2026-01-01'
AND created_at < '2026-02-01';MySQL은 status = 'active'인 구간을 먼저 찾고, 그 안에서 created_at 범위를 찾을 수 있습니다.
하지만 다음 쿼리는 같은 인덱스를 충분히 활용하기 어렵습니다.
SELECT *
FROM students
WHERE created_at >= '2026-01-01'
AND created_at < '2026-02-01';인덱스가 (status, created_at) 순서로 만들어졌기 때문입니다. 이 인덱스는 status가 먼저 정렬 기준입니다. status 조건 없이 created_at만 찾으면, 전체 인덱스에서 날짜만 깔끔하게 좁히기 어렵습니다.
복합 인덱스에는 중요한 규칙이 있습니다.
인덱스는 왼쪽 열부터 차례대로 사용할 때 가장 효과적입니다.
이것을 보통 왼쪽부터 사용 규칙이라고 부릅니다.
(status, created_at, name) 인덱스가 있다고 하겠습니다.
CREATE INDEX idx_students_status_created_name
ON students (status, created_at, name);이 인덱스를 잘 사용할 수 있는 조건은 다음과 같습니다.
WHERE status = 'active'WHERE status = 'active'
AND created_at >= '2026-01-01'WHERE status = 'active'
AND created_at >= '2026-01-01'
AND name = '김지우'반대로 다음 조건은 앞쪽 열인 status를 건너뛰었기 때문에 효율이 떨어질 수 있습니다.
WHERE created_at >= '2026-01-01'WHERE name = '김지우'복합 인덱스는 단순히 여러 열을 넣는다고 좋은 것이 아닙니다. 실제 쿼리에서 어떤 열이 자주 함께 사용되는지, 어떤 순서로 조건이 좁혀지는지를 보고 설계해야 합니다.
수강 신청 테이블로 보는 복합 인덱스
인덱스 설계는 테이블 하나만 볼 때보다, 실제 조회 패턴을 함께 볼 때 더 잘 이해됩니다. 다음과 같은 수강 신청 테이블을 예로 들어 보겠습니다.
CREATE TABLE enrollments (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
student_id BIGINT NOT NULL,
course_id BIGINT NOT NULL,
enrolled_at DATETIME NOT NULL
);특정 학생이 신청한 강의를 자주 조회한다면 다음 쿼리가 많을 것입니다.
SELECT *
FROM enrollments
WHERE student_id = 10;이 경우에는 student_id 인덱스가 필요합니다.
CREATE INDEX idx_enrollments_student
ON enrollments (student_id);그런데 특정 학생이 특정 강의를 신청했는지 자주 확인한다면 조건이 두 개입니다.
SELECT *
FROM enrollments
WHERE student_id = 10
AND course_id = 3;이때는 복합 인덱스가 더 적절할 수 있습니다.
CREATE INDEX idx_enrollments_student_course
ON enrollments (student_id, course_id);이 인덱스는 다음 조건에도 잘 맞습니다.
WHERE student_id = 10WHERE student_id = 10
AND course_id = 3하지만 다음 조건에는 적합하지 않을 수 있습니다.
WHERE course_id = 3course_id만으로 강의별 수강생을 자주 조회한다면 별도의 인덱스가 필요할 수 있습니다.
CREATE INDEX idx_enrollments_course
ON enrollments (course_id);또는 강의별 수강 신청 목록을 최신순으로 자주 본다면 다음 인덱스가 더 유용할 수 있습니다.
CREATE INDEX idx_enrollments_course_enrolled_at
ON enrollments (course_id, enrolled_at);이 인덱스는 다음 쿼리에 잘 맞습니다.
SELECT *
FROM enrollments
WHERE course_id = 3
ORDER BY enrolled_at DESC;MySQL은 course_id = 3인 구간을 찾고, 그 안에서 enrolled_at 순서를 활용할 수 있습니다.
카디널리티와 선택도
인덱스를 설계할 때 자주 나오는 개념이 카디널리티와 선택도입니다.
카디널리티는 특정 열에 서로 다른 값이 얼마나 많은지를 뜻합니다.
| 열 | 예시 값 | 카디널리티 |
|---|---|---|
email | 사용자마다 거의 다름 | 높음 |
student_id | 학생 수만큼 다양함 | 높음 |
status | active, inactive 정도 | 낮음 |
gender | 값 종류가 적음 | 낮음 |
카디널리티가 높은 열은 인덱스 효과가 큰 경우가 많습니다. 값 하나로 검색했을 때 결과가 적게 나오기 때문입니다.
선택도는 조건을 적용했을 때 전체 데이터 중 얼마나 적은 행이 선택되는지를 나타냅니다. 쉽게 말해, 조건이 데이터를 얼마나 잘 좁히는지입니다.
예를 들어 전체 학생이 1,000,000명이라고 하겠습니다.
| 조건 | 예상 결과 | 선택도 관점 |
|---|---|---|
email = '[email protected]' | 1명 | 매우 좋음 |
student_id = 10 | 몇 건 | 좋음 |
status = 'active' | 800,000명 | 나쁨 |
status = 'active' 조건은 인덱스가 있어도 많은 행을 읽어야 합니다. 이런 경우 MySQL은 인덱스를 사용하는 것보다 테이블을 직접 읽는 편이 낫다고 판단할 수도 있습니다.
중요한 점은 인덱스의 존재 여부보다 얼마나 적은 행으로 좁힐 수 있느냐입니다.
복합 인덱스의 열 순서 잡기
복합 인덱스의 열 순서를 정할 때는 다음 기준을 함께 봐야 합니다.
자주 사용하는
WHERE조건인가?동등 조건인지, 범위 조건인지?
정렬이나 그룹화에 사용되는가?
조건을 적용했을 때 결과가 충분히 줄어드는가?
다음 쿼리를 보겠습니다.
SELECT *
FROM students
WHERE status = 'active'
AND created_at >= '2026-01-01'
AND created_at < '2026-02-01'
ORDER BY created_at;이 쿼리에는 status 동등 조건과 created_at 범위 조건이 있습니다. 이 경우 다음 인덱스가 자연스럽습니다.
CREATE INDEX idx_students_status_created_at
ON students (status, created_at);status로 먼저 구간을 좁히고, 그 안에서 created_at 범위를 읽습니다. 또한 created_at 순서가 인덱스에 포함되어 있어 정렬에도 도움이 될 수 있습니다.
일반적으로는 동등 조건에 쓰이는 열을 앞에 두고, 범위 조건에 쓰이는 열을 그 뒤에 두는 방식이 자주 사용됩니다.
WHERE status = 'active'
AND created_at >= '2026-01-01'이 경우 (status, created_at)이 (created_at, status)보다 적절한 경우가 많습니다. 날짜 범위로 먼저 넓게 읽은 뒤 상태를 확인하는 것보다, 상태로 먼저 나누고 그 안에서 날짜 범위를 읽는 편이 효율적일 수 있기 때문입니다.
하지만 항상 이 규칙만으로 결정하면 안 됩니다. 실제 데이터 분포와 쿼리 패턴을 함께 봐야 합니다. 예를 들어 status = 'active'가 전체의 99%라면 status는 거의 데이터를 줄이지 못합니다. 이때는 다른 인덱스가 더 나을 수 있습니다.
EXPLAIN으로 실행 계획 읽기
인덱스를 만들었다고 해서 MySQL이 반드시 그 인덱스를 사용하는 것은 아닙니다. MySQL은 여러 실행 방법 중 비용이 낮다고 판단한 방법을 선택합니다. 이 선택 결과를 확인하는 명령이 EXPLAIN입니다.
EXPLAIN
SELECT *
FROM students
WHERE email = '[email protected]';EXPLAIN 결과에는 여러 열이 나오지만, 처음에는 다음 세 가지를 중심으로 보면 됩니다.
| 항목 | 의미 |
|---|---|
type | 테이블을 어떤 방식으로 읽는지 |
key | 실제로 사용한 인덱스 |
rows | MySQL이 읽을 것으로 예상한 행 수 |
type
type은 쿼리가 테이블을 얼마나 효율적으로 읽는지 보여 줍니다. 자주 보는 값은 다음과 같습니다.
| type | 의미 | 대략적인 평가 |
|---|---|---|
ALL | 테이블 전체 스캔 | 주의 필요 |
index | 인덱스 전체를 읽음 | 상황에 따라 주의 |
range | 인덱스의 일정 범위를 읽음 | 보통 양호 |
ref | 인덱스로 여러 행을 찾음 | 좋음 |
const | 기본키나 고유 인덱스로 한 행을 찾음 | 매우 좋음 |
ALL이 항상 나쁜 것은 아닙니다. 작은 테이블은 전체를 읽는 편이 더 빠를 수 있습니다. 하지만 큰 테이블에서 자주 실행되는 쿼리가 ALL이라면 인덱스 설계를 확인해야 합니다.
key
key는 실제로 사용된 인덱스 이름입니다.
key: idx_students_email이렇게 나온다면 idx_students_email 인덱스를 사용했다는 뜻입니다.
반대로 다음처럼 나온다면 사용한 인덱스가 없다는 뜻입니다.
key: NULL인덱스를 만들었는데도 key가 NULL이라면 조건식이 인덱스와 맞지 않거나, MySQL이 전체 스캔이 더 낫다고 판단했을 수 있습니다.
rows
rows는 MySQL이 읽을 것으로 예상한 행 수입니다. 실제 행 수와 완전히 같지는 않지만, 쿼리 비용을 이해하는 데 도움이 됩니다.
예를 들어 인덱스가 없을 때 다음과 비슷한 결과가 나올 수 있습니다.
type: ALL
key: NULL
rows: 1000000이 결과는 MySQL이 약 100만 행을 읽을 것으로 예상했다는 뜻입니다.
인덱스를 잘 사용하면 다음처럼 바뀔 수 있습니다.
type: ref
key: idx_students_email
rows: 1읽어야 할 행이 크게 줄었습니다. 이것이 인덱스 튜닝의 핵심입니다.
인덱스를 못 쓰게 만드는 흔한 조건
인덱스가 있어도 조건식을 잘못 쓰면 MySQL이 인덱스를 효과적으로 사용하지 못할 수 있습니다.
컬럼에 함수를 적용하는 경우
다음 쿼리는 created_at 인덱스를 제대로 활용하기 어렵습니다.
SELECT *
FROM students
WHERE DATE(created_at) = '2026-01-01';created_at 값을 그대로 비교하는 것이 아니라, 각 행마다 DATE(created_at)을 계산해야 하기 때문입니다. 가능하면 다음처럼 범위 조건으로 바꾸는 편이 좋습니다.
SELECT *
FROM students
WHERE created_at >= '2026-01-01'
AND created_at < '2026-01-02';이렇게 쓰면 created_at 인덱스의 정렬 구조를 활용할 수 있습니다.
앞쪽에 와일드카드를 쓰는 LIKE
다음 조건도 인덱스를 사용하기 어렵습니다.
SELECT *
FROM students
WHERE email LIKE '%@example.com';%가 앞에 있으면 문자열의 시작 지점을 알 수 없습니다. 정렬된 인덱스에서 어디부터 찾아야 할지 애매합니다.
반면 다음 조건은 상대적으로 인덱스를 활용하기 쉽습니다.
SELECT *
FROM students
WHERE email LIKE 'jiwoo%';문자열이 jiwoo로 시작한다는 것을 알 수 있으므로, 인덱스에서 해당 구간을 찾을 수 있습니다.
복합 인덱스의 앞쪽 열을 건너뛰는 경우
다음 인덱스가 있다고 하겠습니다.
CREATE INDEX idx_students_status_created_at
ON students (status, created_at);이 인덱스는 다음 조건에는 잘 맞습니다.
WHERE status = 'active'
AND created_at >= '2026-01-01'하지만 다음 조건은 앞쪽 열인 status를 사용하지 않습니다.
WHERE created_at >= '2026-01-01'이 경우에는 (created_at) 또는 다른 적절한 인덱스가 더 필요할 수 있습니다.
인덱스는 많을수록 좋은가
인덱스는 조회 성능을 높일 수 있지만 공짜는 아닙니다. 인덱스를 만들면 MySQL은 테이블 데이터와 별도로 인덱스 데이터도 관리해야 합니다.
인덱스가 늘어나면 다음 비용이 생깁니다.
저장 공간을 더 사용합니다.
INSERT가 느려질 수 있습니다.UPDATE가 느려질 수 있습니다.DELETE가 느려질 수 있습니다.비슷한 인덱스가 많으면 관리가 어려워집니다.
예를 들어 students 테이블에 새 학생을 추가하면 테이블에만 행을 넣는 것이 아닙니다. email 인덱스, status 인덱스, created_at 인덱스가 있다면 각 인덱스도 함께 갱신해야 합니다.
따라서 인덱스는 많이 만드는 것이 아니라 필요한 쿼리에 맞게 만드는 것이 중요합니다.
좋은 기준은 다음과 같습니다.
자주 실행되는 쿼리인가?
실행 시간이 실제로 문제인가?
조건이 많은 행을 적게 좁히는가?
EXPLAIN에서 읽는 행 수가 줄어드는가?기존 인덱스와 중복되지 않는가?
사용하지 않는 인덱스는 오히려 쓰기 성능을 떨어뜨릴 수 있습니다.
실무적인 인덱스 설계 순서
인덱스를 설계할 때는 테이블부터 보지 말고 쿼리부터 봐야 합니다. 인덱스는 쿼리를 빠르게 실행하기 위한 도구이기 때문입니다.
다음 순서로 접근하면 좋습니다.
1. 느린 쿼리를 찾는다
먼저 어떤 쿼리가 느린지 확인합니다. 모든 쿼리에 인덱스를 만들 필요는 없습니다. 자주 실행되고, 실제로 느리고, 사용자 경험이나 서버 비용에 영향을 주는 쿼리부터 봅니다.
2. WHERE, JOIN, ORDER BY, GROUP BY를 확인한다
인덱스는 주로 다음 부분에 영향을 줍니다.
WHERE student_id = 10JOIN courses ON enrollments.course_id = courses.idORDER BY enrolled_at DESCGROUP BY course_id조건과 정렬에 어떤 열이 반복적으로 사용되는지 확인합니다.
3. 후보 인덱스를 만든다
쿼리 패턴에 맞게 단일 인덱스나 복합 인덱스를 설계합니다.
CREATE INDEX idx_enrollments_student_course
ON enrollments (student_id, course_id);4. EXPLAIN으로 확인한다
인덱스를 만든 뒤에는 반드시 실행 계획을 봅니다.
EXPLAIN
SELECT *
FROM enrollments
WHERE student_id = 10
AND course_id = 3;type, key, rows를 확인합니다.
5. 불필요한 인덱스를 줄인다
비슷한 인덱스가 중복되어 있다면 정리해야 합니다.
예를 들어 다음 두 인덱스가 있다고 하겠습니다.
CREATE INDEX idx_enrollments_student
ON enrollments (student_id);
CREATE INDEX idx_enrollments_student_course
ON enrollments (student_id, course_id);(student_id, course_id) 인덱스는 왼쪽부터 사용 규칙에 따라 student_id 조건에도 사용할 수 있습니다. 따라서 idx_enrollments_student가 항상 필요한 것은 아닐 수 있습니다. 단, 실제 쿼리와 실행 계획을 확인한 뒤 결정해야 합니다.
이번 장의 핵심 정리
인덱스는 MySQL 성능 개선의 출발점입니다. 핵심은 많은 데이터를 빠르게 읽는 것이 아니라, 읽지 않아도 되는 데이터를 읽지 않게 만드는 것입니다.
이번 장에서 배운 내용을 정리하면 다음과 같습니다.
인덱스는 테이블 전체 스캔을 줄여 조회 성능을 높입니다.
MySQL의 일반적인 인덱스는 B-Tree 계열 구조를 사용합니다.
단일 컬럼 인덱스는 한 열로 검색할 때 유용합니다.
복합 인덱스는 여러 열을 함께 사용할 때 유용하지만 열 순서가 중요합니다.
복합 인덱스는 왼쪽 열부터 차례대로 사용할 때 효과가 큽니다.
카디널리티가 높고 선택도가 좋은 열일수록 인덱스 효과가 커지는 경우가 많습니다.
EXPLAIN의type,key,rows를 보면 인덱스가 제대로 쓰이는지 확인할 수 있습니다.인덱스는 조회를 빠르게 하지만 쓰기 비용과 저장 공간을 증가시킵니다.
다음 장에서는 데이터를 안전하게 다루기 위한 중요한 개념인 트랜잭션과 동시성 제어를 배웁니다. 인덱스가 데이터를 빠르게 찾는 방법이라면, 트랜잭션은 여러 작업을 안전하게 묶고 데이터의 일관성을 지키는 방법입니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.