인덱스(Index)
Series: 데이터베이스
들어가며
데이터베이스를 사용하다 보면 데이터가 적을 때는 별문제 없이 동작하던 쿼리가, 데이터가 많아지는 순간 갑자기 느려지는 상황을 자주 마주하게 된다. 예를 들어 회원 테이블에 데이터가 100개 있을 때는 이메일로 사용자를 조회해도 빠르게 응답한다. 하지만 회원이 100만 명, 1000만 명으로 늘어나면 같은 쿼리도 점점 느려질 수 있다.
SELECT
*
FROM users
WHERE email = 'user@example.com';이때 데이터베이스가 users 테이블의 모든 행을 처음부터 끝까지 확인한다면, 데이터가 많아질수록 조회 성능은 떨어질 수밖에 없다. 이런 문제를 해결하기 위해 사용하는 것이 바로 인덱스(Index)이다. 인덱스는 쉽게 말해 데이터베이스의 목차 또는 찾아보기와 같은 역할을 한다. 책에서 원하는 내용을 찾을 때 처음부터 끝까지 모든 페이지를 읽는 대신, 목차나 색인을 보고 바로 해당 위치로 이동하는 것과 비슷하다. 이번 글에서는 인덱스가 왜 필요한지, 어떤 방식으로 동작하는지, 어떤 쿼리에서 효과가 있는지, 그리고 인덱스를 사용할 때 주의해야 할 점에 대해 정리해보겠다.
1. 인덱스가 필요한 이유
1.1. Full Scan 문제
먼저 인덱스가 없는 상황을 생각해보자. 다음과 같은 users 테이블이 있다고 가정한다.
CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(255),
name VARCHAR(100),
age INT,
created_at DATETIME
);그리고 이메일로 사용자를 조회한다.
SELECT
*
FROM users
WHERE email = 'user@example.com';만약 email 컬럼에 인덱스가 없다면, 데이터베이스는 원하는 값을 찾기 위해 테이블의 모든 데이터를 확인해야 한다. 이를 Full Table Scan이라고 한다.
flowchart LR
Q[WHERE email = user@example.com] --> R1[1번 row 확인]
R1 --> R2[2번 row 확인]
R2 --> R3[3번 row 확인]
R3 --> R4[...]
R4 --> R5[마지막 row까지 확인]데이터가 100개라면 큰 문제가 아닐 수 있다. 하지만 데이터가 100만 개라면 최악의 경우 100만 개의 row를 모두 확인해야 한다. 이런 상황에서는 데이터가 많아질수록 조회 속도가 느려진다. 특히 로그인처럼 이메일로 사용자를 자주 조회하는 기능에서는 성능 문제가 쉽게 발생할 수 있다.
1.2. 인덱스를 사용한 조회
이번에는 email 컬럼에 인덱스를 추가해보자.
CREATE INDEX idx_users_email ON users (email);이제 데이터베이스는 email 값을 기준으로 정렬된 별도의 자료구조를 사용할 수 있다. 원하는 이메일을 찾기 위해 테이블 전체를 뒤지는 것이 아니라, 인덱스를 먼저 탐색하고 해당 row의 위치를 찾아간다.
flowchart LR
Q[WHERE email = user@example.com] --> IDX[email 인덱스 탐색]
IDX --> PTR[해당 row 위치 찾기]
PTR --> ROW[users 테이블 row 조회]이렇게 하면 데이터가 많아져도 훨씬 빠르게 원하는 데이터를 찾을 수 있다. 즉, 인덱스의 핵심은 검색 범위를 줄이는 것이다. 모든 데이터를 확인하지 않고, 정렬된 구조를 이용해 필요한 데이터가 있는 위치로 빠르게 접근한다.
2. 인덱스의 동작 방식
2.1. 인덱스는 별도의 자료구조이다
인덱스는 테이블 데이터 자체가 아니라, 조회를 빠르게 하기 위해 따로 만들어지는 자료구조이다. 예를 들어 email 컬럼에 인덱스를 만들면, 데이터베이스는 email 값을 기준으로 정렬된 구조를 별도로 관리한다. 그리고 각 인덱스 항목은 실제 테이블 row를 가리키는 정보를 가지고 있다.
email index
a@example.com → users row 위치
b@example.com → users row 위치
user@example.com → users row 위치
z@example.com → users row 위치이 구조 덕분에 데이터베이스는 특정 이메일을 빠르게 찾을 수 있다. 대부분의 관계형 데이터베이스에서는 일반적인 인덱스를 구현할 때 B-Tree 또는 그 변형 구조를 사용한다. B-Tree는 데이터를 정렬된 상태로 유지하면서도, 삽입·삭제·검색이 효율적으로 이루어지도록 설계된 자료구조이다.
2.2. B-Tree 인덱스
B-Tree 인덱스는 값을 정렬된 구조로 보관한다. 그래서 특정 값을 찾을 때 처음부터 순차적으로 확인하지 않고, 트리 구조를 따라가면서 검색 범위를 점점 좁혀간다.
flowchart TB
Root[Root<br/>M]
L[Left Node<br/>A ~ L]
R[Right Node<br/>N ~ Z]
Root --> L
Root --> R
L --> LA[A ~ F]
L --> LB[G ~ L]
R --> RA[N ~ S]
R --> RB[T ~ Z]예를 들어 user@example.com을 찾는다고 하면, 데이터베이스는 루트 노드부터 시작해서 해당 값이 어느 범위에 속하는지 판단한다. 그리고 그 범위에 해당하는 하위 노드로 이동한다. 이 과정을 반복하면 전체 데이터를 모두 확인하지 않고도 원하는 값에 도달할 수 있다. 이 구조는 특히 다음과 같은 조건에서 효과적이다.
WHERE email = 'user@example.com'WHERE age > 30WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31'B-Tree는 값이 정렬되어 있기 때문에, 동등 비교뿐 아니라 범위 조회에도 강하다.
3. 인덱스가 효과적인 쿼리
3.1. WHERE 조건
가장 대표적으로 인덱스가 효과적인 경우는 WHERE 조건이다. 예를 들어 로그인 시 이메일로 사용자를 찾는 쿼리를 생각해보자.
SELECT
*
FROM users
WHERE email = 'user@example.com';이 쿼리는 email 컬럼에 인덱스가 있으면 빠르게 처리될 수 있다.
CREATE INDEX idx_users_email ON users (email);이메일은 보통 중복되지 않는 값에 가깝기 때문에, 인덱스를 사용했을 때 검색 범위를 크게 줄일 수 있다. 반대로 성별처럼 값의 종류가 적은 컬럼은 인덱스 효과가 크지 않을 수 있다.
SELECT
*
FROM users
WHERE gender = 'MALE';만약 전체 사용자 중 절반이 MALE이라면, 인덱스를 타더라도 결국 많은 row를 읽어야 한다. 이런 경우 데이터베이스는 오히려 테이블 전체를 읽는 것이 더 낫다고 판단할 수도 있다. 즉, 인덱스는 조건에 해당하는 데이터가 적을수록 효과가 좋다.
3.2. ORDER BY
인덱스는 정렬에도 도움을 줄 수 있다. 예를 들어 최신 가입자 순으로 조회하는 쿼리를 보자.
SELECT
*
FROM users
ORDER BY created_at DESC
LIMIT 20;만약 created_at 컬럼에 인덱스가 없다면, 데이터베이스는 전체 데이터를 읽고 정렬한 다음 상위 20개를 반환해야 한다. 하지만 created_at 컬럼에 인덱스가 있다면, 이미 정렬된 인덱스를 활용할 수 있다.
CREATE INDEX idx_users_created_at ON users (created_at);이 경우 데이터베이스는 인덱스를 역순으로 읽으면서 상위 20개만 빠르게 가져올 수 있다.
flowchart LR
IDX[created_at 인덱스<br/>정렬된 상태] --> R1[최신 row]
R1 --> R2[다음 row]
R2 --> R3[...]
R3 --> LIMIT[LIMIT 20개 반환]특히 ORDER BY와 LIMIT이 함께 사용되는 쿼리는 인덱스를 잘 활용하면 성능 차이가 크게 난다.
3.3. JOIN
인덱스는 JOIN 성능에도 큰 영향을 준다. 예를 들어 게시글과 사용자를 조인하는 쿼리를 보자.
SELECT
p.id,
p.title,
u.name
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE p.user_id = 10;이 경우 posts.user_id에 인덱스가 있으면 특정 사용자가 작성한 게시글을 빠르게 찾을 수 있다.
CREATE INDEX idx_posts_user_id ON posts (user_id);또한 users.id는 보통 Primary Key이므로 이미 인덱스가 존재한다. JOIN은 결국 한 테이블의 값을 기준으로 다른 테이블에서 관련 데이터를 찾는 과정이다. 따라서 조인 조건에 사용되는 컬럼에 인덱스가 없으면, 데이터가 많아질수록 성능이 급격히 나빠질 수 있다.
4. 복합 인덱스
4.1. 복합 인덱스란?
복합 인덱스는 두 개 이상의 컬럼을 함께 사용하는 인덱스이다. 예를 들어 게시글 목록을 조회할 때, 특정 사용자별 게시글을 최신순으로 가져오는 쿼리가 자주 사용된다고 해보자.
SELECT
*
FROM posts
WHERE user_id = 10
ORDER BY created_at DESC
LIMIT 20;이 경우 다음과 같은 복합 인덱스를 만들 수 있다.
CREATE INDEX idx_posts_user_id_created_at ON posts (user_id, created_at DESC);이 인덱스는 먼저 user_id 기준으로 정렬되고, 같은 user_id 안에서는 created_at 기준으로 정렬된다.
(user_id, created_at)
user_id=1 → 2026-04-10
user_id=1 → 2026-04-01
user_id=2 → 2026-04-12
user_id=2 → 2026-04-03
user_id=10 → 2026-04-25
user_id=10 → 2026-04-20이 구조에서는 user_id = 10인 구간을 빠르게 찾고, 그 안에서 이미 최신순으로 정렬된 데이터를 읽을 수 있다.
4.2. 복합 인덱스의 컬럼 순서
복합 인덱스에서 가장 중요한 것은 컬럼 순서이다. 다음 인덱스가 있다고 해보자.
CREATE INDEX idx_posts_user_id_status ON posts (user_id, status);이 인덱스는 user_id를 먼저 기준으로 정렬하고, 같은 user_id 안에서 status를 기준으로 정렬한다. 따라서 다음 쿼리는 인덱스를 잘 사용할 수 있다.
SELECT
*
FROM posts
WHERE user_id = 10;SELECT
*
FROM posts
WHERE user_id = 10
AND status = 'PUBLISHED';하지만 다음 쿼리는 인덱스를 제대로 활용하기 어렵다.
SELECT
*
FROM posts
WHERE status = 'PUBLISHED';왜냐하면 인덱스가 user_id를 먼저 기준으로 정렬되어 있기 때문이다. status만으로는 전체 인덱스에서 원하는 구간을 바로 찾기 어렵다. 이것을 흔히 왼쪽 접두어 원칙(Leftmost Prefix Rule)이라고 한다.
flowchart TB
IDX["복합 인덱스 (user_id, status, created_at)"]
IDX --> Q1["WHERE user_id = ?<br/>사용 가능"]
IDX --> Q2["WHERE user_id = ? AND status = ?<br/>사용 가능"]
IDX --> Q3["WHERE user_id = ? AND status = ? ORDER BY created_at<br/>사용 가능"]
IDX --> Q4["WHERE status = ?<br/>효율적으로 사용 어려움"]즉, (A, B, C)로 만든 인덱스는 보통 A, A+B, A+B+C 조건에서 효과적이다. 하지만 B만 사용하거나 C만 사용하는 조건에서는 기대한 만큼 효과가 나오지 않을 수 있다.
5. 인덱스가 오히려 손해가 되는 경우
5.1. 쓰기 성능 저하
인덱스는 조회를 빠르게 해주지만, 항상 좋은 것은 아니다. 인덱스는 별도의 자료구조이기 때문에 데이터를 삽입, 수정, 삭제할 때마다 인덱스도 함께 갱신되어야 한다. 예를 들어 users 테이블에 인덱스가 5개 있다면, 새로운 사용자가 가입할 때 테이블에 row를 추가하는 것뿐만 아니라 5개의 인덱스에도 새로운 값이 반영되어야 한다.
flowchart LR
Insert[INSERT users] --> Table[테이블 row 추가]
Insert --> Index1[email 인덱스 갱신]
Insert --> Index2[created_at 인덱스 갱신]
Insert --> Index3[age 인덱스 갱신]따라서 인덱스가 많을수록 쓰기 작업은 느려질 수 있다. 특히 로그 테이블처럼 데이터 삽입이 매우 빈번한 테이블에서는 불필요한 인덱스를 많이 만들면 오히려 성능이 나빠질 수 있다.
5.2. 저장 공간 증가
인덱스는 별도의 저장 공간을 사용한다. 테이블 데이터와 별개로 인덱스 자료구조를 저장해야 하므로, 인덱스를 많이 만들수록 디스크 사용량이 증가한다. 데이터가 적을 때는 크게 체감되지 않을 수 있지만, 수천만 건 이상의 데이터가 쌓이는 테이블에서는 인덱스 크기 자체도 상당히 커질 수 있다. 따라서 '일단 모든 컬럼에 인덱스를 걸자'는 방식은 좋지 않다.
5.3. 선택도가 낮은 컬럼
인덱스는 조건에 해당하는 데이터가 적을수록 효과가 크다. 예를 들어 email처럼 대부분의 값이 고유한 컬럼은 인덱스 효과가 좋다. 반면 gender, is_deleted, status처럼 값의 종류가 적은 컬럼은 단독 인덱스로는 효과가 크지 않을 수 있다.
SELECT
*
FROM posts
WHERE status = 'PUBLISHED';만약 전체 게시글의 90%가 PUBLISHED 상태라면, 인덱스를 사용해도 대부분의 row를 읽어야 한다. 이 경우 데이터베이스는 인덱스를 사용하는 것보다 테이블 전체를 읽는 것이 낫다고 판단할 수 있다. 다만 이런 컬럼도 복합 인덱스의 일부로는 유용할 수 있다.
CREATE INDEX idx_posts_user_status_created ON posts (user_id, status, created_at DESC);SELECT
*
FROM posts
WHERE user_id = 10
AND status = 'PUBLISHED'
ORDER BY created_at DESC
LIMIT 20;이처럼 단독으로는 효과가 낮은 컬럼도, 다른 조건과 함께 사용되면 좋은 인덱스 구성 요소가 될 수 있다.
6. 인덱스를 사용하지 못하는 경우
6.1. 컬럼에 함수 적용
인덱스가 있어도 쿼리 작성 방식에 따라 사용하지 못할 수 있다. 예를 들어 created_at에 인덱스가 있다고 해보자.
CREATE INDEX idx_users_created_at ON users (created_at);그런데 다음처럼 컬럼에 함수를 적용하면 인덱스를 제대로 사용하기 어렵다.
SELECT
*
FROM users
WHERE DATE(created_at) = '2026-04-25';이 경우 데이터베이스는 각 row의 created_at 값을 DATE() 함수로 변환한 뒤 비교해야 한다. 인덱스에 저장된 원래 값을 그대로 활용하기 어렵다. 이럴 때는 다음처럼 범위 조건으로 바꾸는 것이 좋다.
SELECT
*
FROM users
WHERE created_at >= '2026-04-25 00:00:00'
AND created_at < '2026-04-26 00:00:00';이렇게 작성하면 created_at 인덱스를 범위 조회에 사용할 수 있다.
6.2. LIKE 검색
문자열 검색에서도 인덱스 사용 여부가 달라진다.
CREATE INDEX idx_users_name ON users (name);다음 쿼리는 인덱스를 사용할 가능성이 높다.
SELECT
*
FROM users
WHERE name LIKE 'kim%';이 쿼리는 kim으로 시작하는 범위를 찾는 것이기 때문에, 정렬된 인덱스에서 범위를 좁힐 수 있다. 반면 다음 쿼리는 인덱스를 사용하기 어렵다.
SELECT
*
FROM users
WHERE name LIKE '%kim%';앞에 %가 있으면 문자열의 시작 위치를 알 수 없기 때문에, 정렬된 인덱스를 활용하기 어렵다. 결국 많은 데이터를 확인해야 한다. 즉, 일반적인 B-Tree 인덱스는 앞에서부터 일치하는 검색에는 강하지만, 중간에 포함된 문자열 검색에는 약하다.
7. 실행 계획 확인
인덱스를 만들었다고 해서 데이터베이스가 반드시 그 인덱스를 사용하는 것은 아니다. 데이터베이스는 쿼리를 실행하기 전에 여러 실행 방법을 비교하고, 비용이 가장 낮다고 판단되는 방법을 선택한다. 따라서 인덱스를 만들었더라도 데이터 분포나 조건에 따라 사용하지 않을 수 있다. 이를 확인하기 위해 사용하는 것이 실행 계획이다. MySQL에서는 다음처럼 확인할 수 있다.
EXPLAIN
SELECT
*
FROM users
WHERE email = 'user@example.com';PostgreSQL에서는 다음처럼 실행 계획과 실제 수행 시간을 함께 볼 수 있다.
EXPLAIN ANALYZE
SELECT
*
FROM users
WHERE email = 'user@example.com';실행 계획을 보면 데이터베이스가 어떤 인덱스를 사용하는지, 몇 개의 row를 읽을 것으로 예상하는지, 실제로 얼마나 시간이 걸렸는지 등을 확인할 수 있다. 인덱스 튜닝을 할 때는 단순히 '인덱스를 만들었으니 빨라지겠지'라고 생각하면 안 된다. 반드시 실행 계획을 보고 실제로 인덱스가 사용되는지 확인해야 한다.
8. 실무 예시
8.1. 로그인 조회
로그인에서는 이메일이나 아이디로 사용자를 조회하는 경우가 많다.
SELECT
*
FROM users
WHERE email = 'user@example.com';이 경우 email은 중복되지 않는 값이어야 하므로 Unique Index를 사용하는 것이 좋다.
CREATE UNIQUE INDEX uq_users_email ON users (email);Unique Index는 조회 성능뿐만 아니라 데이터 무결성도 보장한다. 같은 이메일로 두 명의 사용자가 가입하는 것을 막을 수 있기 때문이다.
8.2. 게시글 목록 조회
게시글 목록에서는 보통 최신순 정렬과 페이지네이션이 함께 사용된다.
SELECT
*
FROM posts
WHERE board_id = 1
ORDER BY created_at DESC
LIMIT 20;이 경우 다음과 같은 복합 인덱스가 도움이 될 수 있다.
CREATE INDEX idx_posts_board_created ON posts (board_id, created_at DESC);이 인덱스는 특정 게시판의 게시글을 찾고, 그 안에서 최신순으로 정렬된 데이터를 빠르게 가져오는 데 유리하다.
8.3. 커서 기반 페이지네이션
offset 기반 페이지네이션은 뒤쪽 페이지로 갈수록 성능이 나빠질 수 있다.
SELECT
*
FROM posts
WHERE board_id = 1
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;이 쿼리는 100000개를 건너뛰고 그 다음 20개를 가져와야 한다. 데이터베이스는 건너뛰는 데이터도 상당 부분 읽어야 하므로 비용이 커질 수 있다. 이럴 때는 커서 기반 페이지네이션을 사용할 수 있다.
SELECT
*
FROM posts
WHERE board_id = 1
AND created_at < '2026-04-25 10:00:00'
ORDER BY created_at DESC
LIMIT 20;이 쿼리는 다음 인덱스와 잘 맞는다.
CREATE INDEX idx_posts_board_created ON posts (board_id, created_at DESC);커서 기반 페이지네이션은 마지막으로 조회한 값을 기준으로 다음 데이터를 가져오기 때문에, 대량 데이터에서 offset보다 효율적인 경우가 많다.
마치며
인덱스는 데이터베이스 조회 성능을 높이기 위한 매우 중요한 도구이다. 테이블의 모든 데이터를 확인하지 않고, 정렬된 자료구조를 이용해 필요한 데이터에 빠르게 접근할 수 있게 해준다. 하지만 인덱스는 무조건 많이 만든다고 좋은 것이 아니다. 인덱스는 조회 성능을 높이는 대신, 쓰기 성능과 저장 공간 측면에서 비용을 가진다. 데이터가 삽입, 수정, 삭제될 때마다 인덱스도 함께 갱신되어야 하기 때문이다. 따라서 인덱스를 설계할 때는 실제 쿼리 패턴을 기준으로 판단해야 한다. 어떤 컬럼으로 자주 조회하는지, 어떤 조건으로 필터링하는지, 어떤 정렬이 자주 발생하는지, JOIN 조건은 무엇인지 등을 함께 고려해야 한다. 결국 인덱스 설계의 핵심은 '어떤 컬럼에 인덱스를 만들 것인가?'가 아니라, '어떤 쿼리를 빠르게 만들 것인가?'라고 생각한다.