데이터베이스 인덱스가 하는 일: B-tree, 조회 비용, 쓰기의 대가
데이터베이스가 느려질 때와 API 진단에서 반복해 등장한 처방이 인덱스였습니다. 이번에는 그 인덱스 자체를 다룹니다. 무엇이길래 400만 행 스캔을 몇 번의 접근으로 바꾸고, 대신 무엇을 대가로 받아 가는지, 원리부터 실무 기준까지 정리합니다. 예시는 관계형 데이터베이스의 표준 인덱스인 B-tree 기준입니다.
인덱스가 없을 때: 모든 조회는 전수 조사 #
인덱스 없는 테이블에서 WHERE email = '...'을 찾는 방법은 하나뿐입니다. 첫 행부터 끝 행까지 전부 읽으며 비교하는 풀 스캔입니다. 100만 행이면 100만 번 비교이고, 테이블이 디스크에서 차지하는 페이지 전부를 읽어야 합니다. 데이터가 2배가 되면 비용도 2배, 성장에 정비례해 느려지는 구조입니다.
인덱스는 이 구조를 바꾸는 별도의 정렬된 자료구조입니다. 책 뒤의 색인처럼 “값 → 위치"의 대응을 값 순서로 정렬해 따로 관리하고, 조회는 이 색인을 먼저 탐색해 본문(테이블)의 정확한 위치로 점프합니다.
B-tree: 왜 하필 이 구조인가 #
정렬된 목록에서의 빠른 탐색이라면 이진 탐색이 떠오르지만, 데이터베이스는 디스크 위에서 삽입·삭제가 끊임없이 일어나는 환경입니다. B-tree는 이 환경에 맞춘 선택입니다.
- 넓고 얕은 트리: 노드 하나가 디스크 페이지 하나에 대응하고, 페이지 하나에 키가 수백 개 들어가므로 가지가 수백 갈래로 뻗습니다. 그 결과 100만 행이든 10억 행이든 트리 높이는 보통 3〜4단에 그칩니다. 조회 비용이 “행 수에 비례"에서 “트리 높이만큼(사실상 상수)“으로 바뀌는 것, 이것이 인덱스 효과의 본질입니다.
- 정렬을 유지: 리프 노드들이 값 순서로 연결돼 있어, 등호 조회만이 아니라 범위 조회(
BETWEEN,>,ORDER BY, 접두사LIKE 'kim%')도 시작점을 찾은 뒤 옆으로 읽으면 됩니다. - 삽입·삭제에 견딤: 페이지가 가득 차면 쪼개지며 균형을 유지합니다. 정렬 배열처럼 삽입 때마다 전체를 밀어내는 비용이 없습니다.
100만 행 테이블에서 풀 스캔이 페이지 수천 장을 읽을 때, B-tree 조회는 페이지 3〜4장을 읽습니다. 데이터베이스 편에서 본 “임계 돌파” 현상도 이 구조로 설명됩니다. 테이블이 캐시를 넘는 순간 풀 스캔은 디스크 수천 번이 되지만, 인덱스 조회는 여전히 몇 번입니다.
복합 인덱스: 컬럼 순서가 전부입니다 #
여러 컬럼을 묶은 복합 인덱스 (team_id, created_at)은 “team_id로 정렬하고, 같은 값 안에서 created_at으로 정렬"한 구조입니다. 전화번호부가 (성, 이름) 순인 것과 같아서, 규칙도 같습니다.
WHERE team_id = 3 AND created_at > ...: 완벽하게 씁니다.WHERE team_id = 3: 앞 컬럼만도 씁니다(성만으로 찾기).WHERE created_at > ...: 못 씁니다. 이름만 알고 전화번호부를 뒤지는 격입니다.
그래서 복합 인덱스의 컬럼 순서는 “등호 조건으로 자주 쓰는 컬럼을 앞에, 범위 조건을 뒤에"가 기본 규칙입니다. (A, B) 인덱스가 있으면 (A) 인덱스는 대개 중복이라는 점도 함께 기억해 둘 만합니다.
여기서 한 발 더 나간 것이 커버링 인덱스입니다. 조회에 필요한 컬럼이 전부 인덱스 안에 있으면 본문 테이블로 점프할 필요조차 없어져(인덱스 온리 스캔), 자주 도는 목록 쿼리를 한 단계 더 줄일 수 있습니다.
공짜가 아닙니다: 인덱스의 청구서 #
인덱스가 만능이면 모든 컬럼에 만들면 될 텐데, 그러지 않는 이유가 있습니다.
- 쓰기마다 세금: INSERT는 모든 인덱스에 항목을 추가하고, UPDATE는 바뀐 컬럼이 걸린 인덱스를 갱신하고, DELETE도 마찬가지입니다. 인덱스 10개짜리 테이블의 INSERT는 본문 1번 + 인덱스 10번의 쓰기입니다. 쓰기 많은 테이블의 과도한 인덱스는 그 자체가 성능 문제입니다.
- 저장 공간: 인덱스도 디스크와 캐시 메모리를 먹습니다. 인덱스가 테이블보다 커진 시스템도 드물지 않습니다.
- 낮은 카디널리티에는 효과가 작음: 값의 종류가 적은 컬럼(성별, 불리언 상태)은 인덱스로 좁혀도 절반이 남으므로, 옵티마이저가 인덱스를 버리고 풀 스캔을 고르는 것이 오히려 합리적입니다.
그래서 실무 기준은 이렇게 요약됩니다. WHERE·JOIN·ORDER BY에 반복해서 등장하는, 값이 충분히 다양한 컬럼에 겁니다. 그리고 실제로 쓰이는지(각 DB의 인덱스 사용 통계), 쓰기 부하 대비 남는 장사인지 주기적으로 봅니다. 안 쓰이는 인덱스는 세금만 나가는 인덱스입니다.
인덱스를 만들었는데 사용되지 않을 때: 마지막 관문 #
인덱스가 있어도 옵티마이저가 사용하지 않는 경우(컬럼 가공, 형변환, 낡은 통계)는 데이터베이스 편에서 다뤘습니다. 판정 도구는 언제나 EXPLAIN이고, ORM 관점의 활용은 장고 고급 #3을 참고하면 됩니다. “인덱스를 만들었다"와 “쿼리 실행에 인덱스가 사용된다"는 다른 이야기라는 점을 여기서 다시 강조해 둡니다.
정리 #
- 인덱스는 “값 → 위치"를 정렬 상태로 유지하는 별도 자료구조이고, 조회 비용을 행 수 비례에서 트리 높이(3〜4단)로 바꿉니다.
- B-tree는 넓고 얕아서 디스크 환경에 맞고, 정렬을 유지해 범위 조회와 ORDER BY까지 커버합니다.
- 복합 인덱스는 컬럼 순서가 전부입니다. 등호 조건을 앞에, 범위를 뒤에 놓고, 앞 컬럼 없는 조건은 인덱스를 못 씁니다.
- 인덱스는 쓰기마다 세금을 걷고 공간을 먹습니다. 자주 조회되고 값이 다양한 컬럼에만 만들고, 사용되지 않는 인덱스는 정리합니다.
- 인덱스를 만들었다고 끝이 아닙니다. EXPLAIN으로 실제 실행에 사용되는지 확인하는 것까지가 한 사이클입니다.