Skip to main content
Summary

인덱스는 데이터를 찾는 경로다

인덱스는 테이블의 모든 행을 처음부터 끝까지 읽지 않기 위해 데이터베이스가 따로 유지하는 검색용 자료구조다. 책의 색인처럼 특정 단어가 어느 페이지에 있는지 알려 주지만, 데이터베이스 인덱스는 단순한 목록보다 더 많은 일을 한다. 조건에 맞는 값을 빠르게 찾고, 정렬된 순서로 읽고, 일정 범위의 값을 이어서 탐색한다.

중요한 점은 인덱스가 데이터를 "공짜로" 빠르게 만드는 장치가 아니라는 것이다. 인덱스는 읽기 성능을 높이는 대신 저장 공간을 사용하고, 데이터가 변경될 때마다 함께 갱신되어야 한다. 그래서 느린 쿼리를 만났을 때의 답은 곧바로 "인덱스를 더 만들자"가 아니라 "이 쿼리가 어떤 경로로 데이터를 읽고 있는가"를 확인하는 것이다.

예를 들어 주문 테이블이 있다고 하자.

CREATE TABLE orders (
  id           BIGINT PRIMARY KEY,
  user_id      BIGINT NOT NULL,
  status       VARCHAR(20) NOT NULL,
  created_at   TIMESTAMP NOT NULL,
  total_amount NUMERIC(12, 2) NOT NULL
);

다음 쿼리는 특정 사용자의 최근 결제 완료 주문 20건을 조회한다.

SELECT id, created_at, total_amount
FROM orders
WHERE user_id = 42
  AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;

인덱스가 없다면 데이터베이스는 많은 행을 읽은 뒤 user_id와 status 조건을 검사하고, 남은 결과를 created_at 기준으로 정렬한 다음 20건을 반환해야 한다. 테이블이 작을 때는 문제가 드러나지 않는다. 하지만 주문이 수백만 건으로 늘어나면 "조건 검사 + 정렬" 비용이 응답 시간을 지배한다.

인덱스의 원리와 쿼리 성능AI 생성 이미지

B-Tree가 빠른 이유

관계형 데이터베이스에서 가장 널리 쓰이는 기본 인덱스는 B-Tree 계열 인덱스다. PostgreSQL과 MySQL 모두 일반적인 인덱스에서 B-Tree 계열 구조를 널리 사용한다. B-Tree는 값을 정렬된 상태로 유지한다. 검색할 때는 루트에서 시작해 비교를 반복하면서 필요한 하위 노드로 내려간다. 모든 행을 순서대로 확인하는 대신, 각 단계에서 탐색 범위를 크게 줄인다.

B-Tree 계열 구조는 데이터베이스의 페이지 단위 저장 방식과 잘 맞는다. 데이터베이스는 보통 디스크나 메모리에서 데이터를 한 행씩이 아니라 페이지 단위로 읽는다. B-Tree의 노드는 여러 키를 담을 수 있어 트리 높이를 낮게 유지하고, 검색은 각 높이에서 필요한 페이지만 따라가면 된다. B+Tree 구조에서는 실제 데이터나 행 위치를 찾는 단서가 리프 노드에 모이고, 리프 노드들이 정렬 순서로 이어져 있어 범위 검색에도 유리하다1.

B-Tree 인덱스가 잘하는 대표적인 작업은 세 가지다.

첫째, 동등 조건 검색이다.

WHERE user_id = 42

정렬된 키를 따라가면 user_id = 42가 있는 위치를 빠르게 찾을 수 있다.

둘째, 범위 검색이다.

WHERE created_at >= '2026-01-01'
  AND created_at < '2026-02-01'

시작 지점을 찾은 뒤 정렬된 리프 노드를 따라가며 범위 안의 값을 읽을 수 있다.

셋째, 정렬된 결과 반환이다.

ORDER BY created_at DESC

인덱스가 이미 원하는 순서와 맞으면 별도의 정렬 작업을 줄이거나 피할 수 있다. 특히 LIMIT과 결합하면 효과가 크다. 필요한 순서대로 읽다가 20건을 찾으면 멈출 수 있기 때문이다.

복합 인덱스는 컬럼 순서가 성능을 결정한다

실무에서 자주 쓰는 인덱스는 하나의 컬럼만 담은 단일 인덱스보다 여러 컬럼을 묶은 복합 인덱스다.

앞의 주문 조회에는 다음 인덱스가 잘 맞는다.

CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);

이 인덱스는 대략 다음 순서로 정렬된 목록이라고 볼 수 있다.

user_id → status → created_at DESC

따라서 다음 쿼리는 인덱스를 효과적으로 사용할 가능성이 높다.

SELECT id, created_at, total_amount
FROM orders
WHERE user_id = 42
  AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;

데이터베이스는 먼저 user_id = 42 구간으로 이동하고, 그 안에서 status = 'PAID' 구간을 찾고, 이미 created_at DESC로 정렬된 순서대로 20건을 읽을 수 있다. 조건 검색, 정렬, LIMIT이 하나의 접근 경로로 이어진다.

반대로 다음 쿼리는 같은 인덱스를 충분히 활용하기 어렵다.

SELECT id, created_at, total_amount
FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;

인덱스의 첫 번째 컬럼인 user_id 조건이 없기 때문이다. 복합 인덱스는 보통 왼쪽부터 연속된 컬럼 조건을 잘 활용한다. (user_id, status, created_at) 인덱스는 user_id로 시작하는 조회에는 강하지만, status만으로 시작하는 조회에는 맞지 않을 수 있다.

또 하나의 예를 보자.

WHERE user_id = 42
  AND created_at >= '2026-01-01'
  AND status = 'PAID'

SQL에서 WHERE 조건을 어떤 순서로 적었는지는 중요하지 않다. 중요한 것은 인덱스 안에서 컬럼이 어떤 순서로 배치되어 있느냐이다. 이 쿼리에 대해 (user_id, created_at, status) 인덱스를 만들면 user_id로 좁힌 뒤 created_at 범위를 읽을 수 있다. 하지만 created_at이 범위 조건으로 사용되면 그 뒤의 status는 인덱스 탐색 조건으로 충분히 활용되지 못하고 필터로 처리될 수 있다. 반대로 (user_id, status, created_at) 인덱스라면 user_id와 status를 동등 조건으로 좁힌 뒤 created_at 범위를 읽을 수 있다.

그래서 복합 인덱스를 설계할 때는 단순히 "카디널리티가 높은 컬럼을 앞에 둔다"로 끝내면 안 된다. 더 중요한 질문은 이것이다.

  • 쿼리가 항상 함께 사용하는 조건은 무엇인가?

  • 인덱스 안에서 동등 조건으로 먼저 좁힐 수 있는 컬럼은 무엇인가?

  • 범위 조건이나 ORDER BY가 그 뒤에 자연스럽게 이어지는가?

  • LIMIT으로 일찍 멈출 수 있는가?

  • 이 인덱스가 한 쿼리만이 아니라 여러 핵심 쿼리에 재사용되는가?

대체로 동등 조건으로 강하게 좁히는 컬럼을 앞에 두고, 그다음 범위 조건이나 정렬 컬럼을 배치한다. 하지만 정답은 테이블의 데이터 분포와 실제 쿼리 패턴에 따라 달라진다.

카디널리티와 선택도

카디널리티는 어떤 컬럼이 실제로 가지는 서로 다른 값의 수를 뜻한다. email처럼 대부분의 행이 서로 다른 값을 가지면 카디널리티가 높다. is_deleted처럼 true와 false 정도만 가지면 카디널리티가 낮다.

선택도는 조건이 전체 행 중 얼마나 적은 행을 골라내는지를 나타낸다. 이 책에서는 조건이 적은 행만 반환할수록 선택도가 높다고 표현하겠다. 예를 들어 사용자 1천만 명 중 이메일 하나를 찾는 조건은 선택도가 높다.

WHERE email = '[email protected]'

반면 주문의 대부분이 status = 'PAID'라면 다음 조건은 많은 행을 반환한다.

WHERE status = 'PAID'

이런 조건에 단독 인덱스를 만들어도 효과가 작을 수 있다. 인덱스를 통해 많은 행 위치를 찾은 뒤 테이블을 다시 많이 읽어야 하기 때문이다. 이 경우 데이터베이스는 차라리 테이블을 순차적으로 읽는 편이 더 싸다고 판단할 수 있다.

하지만 낮은 카디널리티 컬럼이 항상 쓸모없는 것은 아니다. 예를 들어 status = 'FAILED'가 전체의 0.1%뿐이고 장애 분석 화면에서 자주 조회된다면 인덱스가 도움이 될 수 있다. PostgreSQL이라면 특정 조건에만 적용되는 부분 인덱스를 고려할 수도 있다.

CREATE INDEX idx_orders_failed_created
ON orders (created_at DESC)
WHERE status = 'FAILED';

핵심은 컬럼 자체의 카디널리티보다 "이 조건이 실제로 몇 행까지 줄이는가"다. 데이터베이스 옵티마이저도 통계 정보를 바탕으로 예상 행 수를 계산하고, 인덱스 스캔과 테이블 스캔 중 더 싸다고 판단한 경로를 선택한다.

인덱스 스캔, 테이블 스캔, 커버링 인덱스

테이블 스캔은 테이블의 많은 행을 순서대로 읽는 방식이다. 이름 때문에 항상 나쁜 것처럼 보이지만 그렇지 않다. 전체 행의 상당 부분을 읽어야 한다면 테이블 스캔이 더 빠를 수 있다. 순차적으로 읽는 작업은 많은 랜덤 접근보다 효율적일 때가 많다.

인덱스 스캔은 인덱스에서 조건에 맞는 키를 찾고, 필요하면 해당 행을 테이블에서 다시 읽는다. 반환 행이 적을 때 강력하다. 그러나 많은 행을 찾아야 한다면 인덱스와 테이블을 오가며 읽는 비용이 커질 수 있다.

커버링 인덱스는 쿼리에 필요한 컬럼을 인덱스만으로 모두 제공할 수 있는 인덱스다. 이 경우 데이터베이스는 테이블 본문을 읽지 않고 인덱스만 읽는 실행 계획을 선택할 수 있다. PostgreSQL에서는 INCLUDE를 사용해 검색 키는 아니지만 결과 반환에 필요한 컬럼을 인덱스에 포함할 수 있고, MySQL에서는 필요한 컬럼을 복합 인덱스에 함께 넣어 커버링 효과를 얻는 방식이 자주 쓰인다2.

예를 들어 다음 쿼리가 매우 자주 실행된다고 하자.

SELECT created_at, total_amount
FROM orders
WHERE user_id = 42
  AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;

PostgreSQL에서는 다음과 같은 인덱스를 고려할 수 있다.

CREATE INDEX idx_orders_user_status_created_include_amount
ON orders (user_id, status, created_at DESC)
INCLUDE (total_amount);

이 인덱스는 검색과 정렬에는 user_id, status, created_at을 사용하고, 결과에 필요한 total_amount도 인덱스에서 바로 읽을 수 있게 한다. 단, 커버링 인덱스는 인덱스 크기를 키운다. 자주 실행되는 핵심 조회에는 유용하지만, 모든 조회를 커버하려고 컬럼을 계속 추가하면 쓰기 성능과 캐시 효율이 나빠진다.

인덱스는 쓰기 성능의 비용이 된다

인덱스는 읽기 성능을 위해 별도의 정렬된 구조를 유지하는 것이다. 따라서 INSERT, UPDATE, DELETE가 발생하면 테이블뿐 아니라 관련 인덱스도 함께 변경해야 한다.

새 주문을 추가하면 주문 테이블에 행을 넣고, 기본 키 인덱스와 user_id 인덱스, created_at 인덱스도 갱신해야 한다. 인덱스가 2개일 때보다 8개일 때 쓰기 비용은 커진다. 저장 공간도 늘고, 버퍼 캐시에 올려야 할 데이터도 늘며, 변경 로그의 양도 증가한다.

특히 UPDATE는 주의해야 한다. 인덱스에 포함된 컬럼이 바뀌면 데이터베이스는 기존 인덱스 항목을 제거하고 새 항목을 넣는 작업을 해야 한다. 상태값이 자주 바뀌는 테이블에 상태 컬럼을 포함한 인덱스를 많이 만들면 조회는 조금 빨라질 수 있지만 쓰기 처리량이 떨어질 수 있다.

그래서 인덱스 설계에는 다음 기준이 필요하다.

  • 실제로 느린 쿼리나 중요한 쿼리에서 출발한다.

  • 단일 컬럼 인덱스를 여러 개 늘리기보다 쿼리 패턴에 맞는 복합 인덱스를 우선 검토한다.

  • 외래 키 컬럼처럼 조인과 참조 확인에 자주 쓰이는 컬럼은 인덱스 필요성을 확인한다.

  • 사용되지 않는 인덱스는 모니터링 후 제거한다.

  • 쓰기 빈도가 높은 테이블에는 인덱스 하나의 비용도 더 엄격하게 본다.

인덱스는 자산이면서 부채다. 읽기에서는 자산이고, 쓰기와 운영에서는 부채가 된다.

EXPLAIN으로 접근 경로 읽기

쿼리 성능을 개선하려면 실행 계획을 읽어야 한다. EXPLAIN은 데이터베이스가 어떤 방식으로 쿼리를 실행하려고 하는지 보여 준다. PostgreSQL 문서에서는 실행 계획을 여러 노드로 이루어진 트리로 설명한다. 아래쪽 노드는 테이블이나 인덱스에서 원천 데이터를 읽고, 위쪽 노드는 조인, 정렬, 집계 같은 작업을 수행한다3.

PostgreSQL에서는 다음처럼 확인한다.

EXPLAIN
SELECT id, created_at, total_amount
FROM orders
WHERE user_id = 42
  AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;

실행 계획을 볼 때는 적어도 다섯 가지를 확인한다.

첫째, 어떤 스캔을 하는가. Seq Scan은 테이블을 순차적으로 읽는 방식이고, Index Scan은 인덱스를 따라 읽는 방식이다. Index Only Scan은 필요한 값을 인덱스만으로 읽을 수 있을 때 나타날 수 있다. MySQL에서는 type, key, rows, Extra 항목을 함께 본다. key는 선택된 인덱스, rows는 예상으로 읽을 행 수, Extra의 Using index는 커버링 인덱스를 사용한다는 신호로 볼 수 있다.

둘째, 조건이 인덱스 조건인지 필터인지 본다. PostgreSQL 실행 계획에서 Index Cond는 인덱스 탐색에 사용된 조건이다. 반면 Filter는 행을 읽은 뒤 추가로 검사한 조건이다. 같은 WHERE 조건이라도 인덱스 접근에 쓰인 조건과 나중에 걸러진 조건은 비용이 다르다.

셋째, 예상 행 수를 본다. rows가 실제 반환 행 수와 크게 다르면 통계가 오래되었거나 데이터 분포를 옵티마이저가 잘못 추정하고 있을 수 있다. 이 경우 인덱스가 있어도 잘못된 조인 순서나 스캔 방식이 선택될 수 있다.

넷째, 정렬이 발생하는지 본다. ORDER BY가 인덱스 순서와 맞지 않으면 별도의 Sort가 생길 수 있다. MySQL에서는 Using filesort가 보일 수 있다. 정렬 대상 행이 많고 메모리에 담기지 않으면 디스크 임시 작업으로 이어질 수 있다.

다섯째, 조인 순서를 본다. 조인은 어느 테이블을 먼저 읽고, 그 결과로 다른 테이블을 어떻게 찾는지에 따라 비용이 크게 달라진다. 작은 결과를 먼저 만들고 인덱스로 다음 테이블을 찾으면 빠르지만, 큰 테이블끼리 넓게 조인한 뒤 나중에 필터링하면 느려질 수 있다.

EXPLAIN ANALYZE는 예상 계획뿐 아니라 실제 실행 결과를 함께 보여 준다. PostgreSQL에서는 실제로 쿼리를 실행하므로 변경 쿼리에 사용할 때는 주의해야 한다. 운영 환경에서는 부하와 잠금, 데이터 변경 가능성을 고려해 안전한 방식으로 확인해야 한다.

느린 쿼리를 고치는 순서

느린 쿼리를 만나면 다음 순서로 접근하는 것이 좋다.

먼저 쿼리의 목적을 확인한다. 정말 필요한 컬럼만 조회하는지, 불필요하게 많은 행을 가져오고 애플리케이션에서 자르는 것은 아닌지 본다.

다음으로 실행 계획을 확인한다. 테이블 스캔이 문제인지, 인덱스는 쓰지만 너무 많은 행을 읽는지, 정렬이나 조인이 비용을 만드는지 구분한다.

그다음 인덱스 후보를 설계한다. WHERE의 동등 조건, 범위 조건, ORDER BY, LIMIT이 하나의 흐름으로 이어지도록 복합 인덱스를 검토한다. 커버링 인덱스는 조회 빈도와 쓰기 비용을 함께 따져 적용한다.

마지막으로 실제 데이터에서 검증한다. 개발 환경의 작은 데이터에서는 인덱스 효과가 보이지 않거나 반대로 과장될 수 있다. 운영과 비슷한 데이터량, 데이터 분포, 동시 요청 조건에서 확인해야 한다.

인덱스 튜닝의 핵심은 인덱스를 많이 만드는 것이 아니다. 데이터베이스가 어떤 순서로, 얼마나 많은 데이터를, 어떤 방식으로 읽는지 이해하는 것이다. 실행 계획을 읽을 수 있으면 느린 쿼리는 더 이상 감이 아니라 근거로 고칠 수 있다.

참고

1B-trees and database indexes

2Understanding Database Indexing for Better Performance

3PostgreSQL Using EXPLAIN

책 목록

개발자가 알아야 할 데이터베이스 지식

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기