서비스가 느려졌을 때 가장 먼저 나오는 처방 중 하나가 “인덱스를 추가하자”입니다. 실제로 적절한 인덱스 하나가 수 초 걸리던 조회를 수십 밀리초로 줄이기도 합니다. 하지만 느린 쿼리가 보일 때마다 인덱스를 하나씩 만들다 보면 어느 순간 조회는 별로 빨라지지 않고, 데이터 입력과 수정만 느려지며 저장 공간과 운영 부담이 커집니다.
인덱스는 SQL 문장에 붙이는 속도 옵션이 아닙니다. 어떤 질문을 자주 할 것인지 예상해 데이터를 별도의 순서로 한 번 더 정리해 두는 자료구조입니다. 따라서 좋은 인덱스는 테이블만 보고 설계할 수 없습니다. 실제 쿼리의 필터, 정렬, 반환 컬럼, 데이터 분포와 읽기·쓰기 비율을 함께 봐야 합니다.
인덱스는 책의 색인과 무엇이 같은가
천 페이지짜리 책에서 특정 용어를 찾을 때 첫 페이지부터 전부 읽는 것은 테이블 전체 스캔과 비슷합니다. 책 뒤의 색인은 용어를 정렬해 두고 해당 페이지 위치를 알려줍니다. 데이터베이스 인덱스도 키 값과 원본 행의 위치를 별도 구조에 저장해 검색 범위를 빠르게 줄입니다.
관계형 데이터베이스에서 가장 흔한 B-tree 계열 인덱스는 값을 정렬된 트리로 관리합니다. 트리의 루트에서 분기 조건을 따라가면 전체 행을 비교하지 않고 원하는 범위에 도달할 수 있습니다. 데이터가 커져도 트리 깊이는 비교적 천천히 증가하기 때문에 동등 조건, 범위 검색, 정렬에 폭넓게 쓰입니다.
하지만 색인에도 비용이 있습니다. 새로운 행이 들어오면 테이블뿐 아니라 관련 인덱스에도 키를 삽입해야 하고, 값이 바뀌거나 삭제되면 인덱스도 정리해야 합니다. 인덱스 페이지가 캐시를 차지하고 백업·복구 시간에도 영향을 줍니다. 읽기 속도를 얻는 대신 쓰기와 공간 비용을 지불하는 구조입니다.
첫 질문은 “이 쿼리는 얼마나 많은 행을 버리는가”
인덱스 효과를 판단할 때 중요한 개념이 선택도(selectivity)입니다. 조건을 적용했을 때 전체 행 중 얼마나 적은 행만 남는지를 말합니다. 천만 건 중 한 건을 찾는 주문번호는 선택도가 높고, 전체의 절반이 같은 값을 갖는 활성 여부는 선택도가 낮습니다.
선택도가 낮은 컬럼에 단독 인덱스를 만들어도 데이터베이스가 이를 사용하지 않을 수 있습니다. 인덱스를 따라 수백만 개의 원본 행을 무작위로 읽는 것보다 테이블을 한 번 순서대로 훑는 편이 더 저렴하다고 판단하기 때문입니다. “인덱스가 있는데 왜 전체 스캔을 하지?”라는 질문의 답이 종종 여기에 있습니다.
다만 카디널리티가 낮다고 언제나 쓸모없는 것은 아닙니다. status = 'FAILED'인 행이 전체의 0.1%뿐이라면 상태 값의 종류는 적어도 이 조건은 매우 선택적입니다. PostgreSQL의 부분 인덱스처럼 특정 조건의 행만 색인하거나, 다른 컬럼과 결합한 복합 인덱스로 실제 조회 패턴을 지원할 수 있습니다.
복합 인덱스는 컬럼의 집합이 아니라 순서다
다음과 같이 고객별 주문 목록을 최신순으로 조회한다고 가정해 보겠습니다.
SELECT id, ordered_at, total_amount, status
FROM orders
WHERE customer_id = :customer_id
AND status = :status
ORDER BY ordered_at DESC
LIMIT 20;
이 조회가 핵심 경로라면 (customer_id, status, ordered_at DESC) 같은 복합 인덱스를 검토할 수 있습니다. 먼저 고객과 상태라는 동등 조건으로 탐색 범위를 좁힌 뒤, 그 안에서 이미 시간순으로 정렬된 앞부분만 읽을 수 있기 때문입니다. 별도 정렬과 대량 행 검사를 줄일 가능성이 큽니다.

여기서 중요한 것이 왼쪽 우선 원칙입니다. (customer_id, status, ordered_at) 인덱스는 customer_id만 사용하거나 customer_id와 status를 함께 쓰는 조회에는 유리하지만, 일반적으로 status만으로 시작하는 조회에는 효율적이지 않습니다. 전화번호부가 성과 이름 순으로 정렬되어 있을 때 이름만 알고 사람을 찾기 어려운 것과 같습니다.
흔히 “선택도가 높은 컬럼을 무조건 앞에 둔다”는 규칙을 외우지만 충분하지 않습니다. 실제로는 동등 조건, 범위 조건, 정렬, 그룹화가 어떻게 조합되는지 봐야 합니다. 한 컬럼에 범위 조건이 적용된 뒤에는 뒤쪽 컬럼을 탐색 범위 축소에 충분히 활용하지 못하는 경우가 많습니다. 데이터베이스 종류와 버전마다 skip scan 같은 최적화 차이도 있으므로 후보 인덱스는 반드시 실제 실행 계획으로 확인해야 합니다.
정렬과 LIMIT을 함께 보면 인덱스 가치가 달라진다
결과가 몇 건 안 되더라도 조건에 맞는 수백만 행을 모두 찾고 정렬한 뒤 LIMIT 20을 적용한다면 비싼 쿼리입니다. 필터와 정렬 순서가 인덱스에 맞으면 데이터베이스는 앞에서 20건을 찾는 순간 탐색을 멈출 수 있습니다. 피드, 알림, 주문 내역처럼 최신 N건을 자주 읽는 기능에서 특히 큰 차이가 납니다.
반대로 같은 컬럼을 정렬한다고 언제나 인덱스 정렬을 쓸 수 있는 것은 아닙니다. 앞선 인덱스 컬럼이 조건으로 고정되지 않았거나, 여러 컬럼의 ASC·DESC 방향이 쿼리와 맞지 않거나, 함수가 적용되어 있으면 별도 정렬이 발생할 수 있습니다. 페이지네이션도 큰 OFFSET을 계속 건너뛰는 방식보다 마지막으로 본 정렬 키를 조건에 넣는 keyset pagination이 대량 데이터에서 안정적입니다.
커버링 인덱스는 빠르지만 넓을수록 비싸다
인덱스에서 조건에 맞는 키를 찾은 뒤에도 화면에 보여줄 컬럼을 얻기 위해 원본 테이블을 다시 읽어야 할 수 있습니다. 쿼리에 필요한 컬럼이 인덱스 안에 모두 들어 있으면 데이터베이스가 테이블 접근을 줄이는 index-only scan 또는 covering index 전략을 선택할 수 있습니다.
PostgreSQL의 INCLUDE나 SQL Server의 included column처럼 탐색 키는 아니지만 반환에 필요한 컬럼을 리프에 포함하는 기능도 있습니다. MySQL InnoDB의 보조 인덱스는 기본 키 값을 함께 보관한다는 특성도 고려해야 합니다. 이런 차이는 “같은 SQL이면 같은 인덱스”라는 생각이 위험한 이유입니다.
화면에 쓰는 모든 컬럼을 인덱스에 넣으면 읽기는 빨라질 수 있지만 인덱스가 커지고 캐시 효율이 떨어집니다. 긴 문자열이나 자주 변경되는 컬럼은 쓰기 비용을 크게 만듭니다. 커버링은 호출 빈도가 높고 지연 시간에 민감하며 반환 컬럼이 안정적인 핵심 쿼리에 제한적으로 사용하는 편이 좋습니다.
함수와 암묵적 형변환은 좋은 인덱스를 숨긴다
WHERE DATE(created_at) = '2026-08-30'처럼 인덱스 컬럼에 함수를 적용하면 일반 created_at 인덱스를 그대로 활용하기 어려울 수 있습니다. 다음 날 이전까지의 범위 조건으로 바꾸면 같은 의미를 유지하면서 정렬된 원본 값을 탐색할 수 있습니다.
WHERE created_at >= '2026-08-30 00:00:00'
AND created_at < '2026-08-31 00:00:00'
대소문자 없는 이메일 검색처럼 함수 적용 자체가 업무 요구라면 함수 기반 인덱스나 정규화된 별도 컬럼을 고려할 수 있습니다. 문자열 컬럼과 숫자 파라미터를 비교하는 암묵적 형변환, 서로 다른 collation, 선행 와일드카드가 있는 LIKE '%keyword'도 B-tree 사용을 방해하는 대표 사례입니다. 전문 검색은 일반 인덱스에 억지로 맡기기보다 DB의 full-text 기능이나 검색 엔진을 검토해야 합니다.
EXPLAIN은 추측을 측정으로 바꾼다
인덱스 후보를 만들었다면 실행 계획을 비교해야 합니다. PostgreSQL의 EXPLAIN (ANALYZE, BUFFERS), MySQL의 EXPLAIN ANALYZE, SQL Server의 Actual Execution Plan 등은 데이터베이스가 어떤 순서로 테이블에 접근했고 예상 행 수와 실제 행 수가 얼마나 다른지 보여줍니다.

계획에서 먼저 볼 것은 노드 이름보다 행 수의 흐름입니다. 한 행을 반환하려고 수백만 행을 읽었는지, 추정 행 수와 실제 행 수가 크게 어긋났는지, 정렬이 메모리를 넘어 디스크로 내려갔는지, 반복 루프 안쪽에서 같은 접근이 지나치게 많이 실행됐는지를 확인합니다. 인덱스 하나보다 잘못된 조인 순서나 오래된 통계 정보가 원인일 수도 있습니다.
ANALYZE가 붙은 명령은 쿼리를 실제 실행한다는 점도 주의해야 합니다. 운영 환경의 UPDATE나 DELETE를 무심코 분석하면 데이터가 변경될 수 있습니다. 읽기 전용 복제본, 트랜잭션 롤백, 스테이징 데이터 등 안전한 절차를 마련하고, 운영에서 가져온 파라미터 분포로 테스트해야 합니다.
개발 환경에서 빨랐는데 운영에서 느린 이유
개발 DB에는 데이터가 몇천 건뿐이고 값도 균등하게 생성된 경우가 많습니다. 운영 데이터는 특정 고객이나 날짜에 몰리고, 인기 상태 값이 대부분을 차지하며, 테이블과 인덱스가 메모리보다 큽니다. 같은 실행 계획도 캐시가 따뜻한지, 동시에 쓰기가 얼마나 발생하는지에 따라 결과가 달라집니다.
파라미터에 따라 최적 계획이 크게 달라지는 현상도 있습니다. 주문이 10건인 고객과 천만 건인 고객에게 같은 계획이 항상 좋지는 않습니다. 통계의 히스토그램, prepared statement의 일반 계획, 파라미터 스니핑 문제를 데이터베이스별로 살펴야 합니다. 평균 응답 시간만 보지 말고 p95·p99 지연과 실제 호출 빈도를 함께 봐야 핵심 병목을 찾을 수 있습니다.
중복 인덱스가 조용히 만드는 비용
(customer_id), (customer_id, status), (customer_id, status, ordered_at)가 모두 있다면 앞쪽 인덱스 일부가 더 긴 인덱스에 의해 대체될 가능성이 있습니다. 하지만 고유성, 인덱스 크기, 쿼리 형태와 DB 옵티마이저 특성 때문에 단순히 길이가 긴 것만 남기는 것도 위험합니다.
새 인덱스를 추가하기 전 기존 인덱스의 접두 관계와 사용량을 확인하고, 배포 후에는 실제 스캔 횟수와 쓰기 비용을 관찰해야 합니다. 제거할 때는 한 번에 삭제하기보다 PostgreSQL의 가상 가설 인덱스 도구나 MySQL의 invisible index처럼 계획 영향을 미리 평가할 수 있는 기능을 활용하면 안전합니다. 사용되지 않는다는 통계도 관측 기간이 월말 배치나 분기 업무를 포함하는지 확인해야 합니다.
인덱스가 해결하지 못하는 문제
N+1 쿼리처럼 애플리케이션이 작은 조회를 수천 번 보내는 문제, 필요 없는 대용량 컬럼을 모두 가져오는 SELECT *, 잠금 경합, 연결 풀 고갈, 네트워크 지연은 인덱스만으로 해결되지 않습니다. 쿼리 한 번이 빨라도 호출 횟수가 지나치게 많으면 전체 요청은 느립니다.
복잡한 리포트에서 원본 이벤트 수억 건을 매번 집계한다면 요약 테이블, materialized view, 캐시, 읽기 전용 복제본이나 분석 저장소가 더 적합할 수 있습니다. 인덱스는 접근 경로를 개선하지만 잘못된 데이터 모델과 시스템 경계를 대신 설계해 주지는 않습니다.
실무에서 사용하는 인덱스 설계 순서
- 느린 쿼리를 측정합니다. 호출 빈도, 평균·꼬리 지연, 읽은 행 수와 업무 중요도를 함께 수집합니다.
- 실제 SQL과 파라미터를 확보합니다. ORM이 만든 최종 SQL과 운영 데이터 분포를 확인합니다.
- 실행 계획의 병목을 찾습니다. 전체 스캔 자체보다 버려지는 행, 잘못된 추정, 정렬·조인 비용을 봅니다.
- 최소한의 후보 인덱스를 설계합니다. 동등 조건, 범위, 정렬, 반환 컬럼과 기존 인덱스를 함께 고려합니다.
- 읽기와 쓰기를 모두 측정합니다. 실제 규모의 데이터에서 INSERT·UPDATE·DELETE와 저장 공간 변화도 비교합니다.
- 안전하게 배포합니다. 큰 테이블은 온라인 또는 concurrent 생성 기능, 잠금 시간과 실패 복구 계획을 준비합니다.
- 배포 후 다시 관찰합니다. 계획이 실제로 바뀌었는지, p95가 개선됐는지, 다른 쿼리가 느려지지 않았는지 확인합니다.
좋은 인덱스는 적은 수로 중요한 질문에 답한다
인덱스 설계의 목표는 가능한 많은 인덱스를 만드는 것이 아닙니다. 서비스가 자주 묻는 중요한 질문에 가장 짧은 경로를 제공하면서 쓰기와 운영 비용을 통제하는 것입니다. 컬럼 이름만 보고 결정하기보다 쿼리 형태, 데이터 분포, 정렬과 LIMIT, 실제 실행 계획을 하나의 묶음으로 봐야 합니다.
다음에 느린 쿼리를 만났다면 곧바로 CREATE INDEX부터 실행하지 말고 세 가지를 먼저 물어보세요. 몇 행을 읽어 몇 행을 반환하는가, 이 순서가 실제 필터와 정렬을 지원하는가, 개선 효과가 추가 비용보다 큰가. 이 질문에 측정값으로 답할 수 있을 때 인덱스는 임시 처방이 아니라 지속 가능한 성능 설계가 됩니다.