1. 서론: 인덱스는 왜 백엔드 성능의 알파이자 오메가인가?

데이터베이스 기반의 웹 애플리케이션에서 서비스가 느려지는 원인의 90% 이상은 **‘잘못 작성된 느린 SQL 쿼리(Slow Query)’**와 **‘부적절한 인덱스 설계’**에서 비롯됩니다.

개발 초기나 로컬 테스트 환경에서는 테이블에 데이터가 수백, 수천 건에 불과하므로 인덱스가 없어도 모든 쿼리가 0.01초 만에 실행됩니다. 하지만 서비스가 성장하여 주문, 결제, 회원 데이터가 수백만, 수천만 건으로 불어나는 순간, 단 하나의 무인덱스 쿼리가 수십 초 동안 DB CPU를 100% 점유하며 전체 웹사이트를 셧다운(Crash)으로 몰고 갑니다.

인덱스는 두꺼운 전공 서적 맨 뒤에 실려 있는 **‘색인(Index)’**과 같습니다. 1,000페이지 책에서 특정 단어를 찾기 위해 1페이지부터 끝까지 다 넘겨보는 Full Table Scan을 방지하고, 단 몇 번의 포인터 점프만으로 원하는 레코드 위치에 직행할 수 있도록 돕는 핵심 자료구조입니다.

이번 포스팅에서는 InnoDB의 B+Tree 인덱스 구조, 클러스터링 인덱스와 커버링 인덱스의 차이, 복합 인덱스의 ‘최좌측 접두사 원칙(Leftmost Prefix)’, EXPLAIN 실행 계획 분석, 그리고 인덱스가 타지 않는 5대 안티패턴까지 실무 관점에서 총정리합니다.


2. InnoDB B+Tree 인덱스의 내부 동작 원리

MySQL의 기본 스토리지 엔진인 InnoDB는 인덱스 자료구조로 B+Tree를 채택하고 있습니다.

flowchart TB Root["Root Node (메모리 상주 페이지)"] Branch1["Branch Node (1 ~ 500)"] Branch2["Branch Node (501 ~ 1000)"]
Leaf1["Leaf Node 1 (데이터 레코드 / PK 포인터)"]
Leaf2["Leaf Node 2 (양방향 연결 리스트)"]
Leaf3["Leaf Node 3 (순차 범위 스캔 최적화)"]

Root --> Branch1
Root --> Branch2
Branch1 --> Leaf1
Branch1 --> Leaf2
Branch2 --> Leaf3
Leaf1 <==> Leaf2 <==> Leaf3
  • 균형 트리(Balanced Tree): 모든 리프 노드가 동일한 깊이(Depth)에 위치하므로, 데이터가 수천만 건이어도 탐색 시간 복잡도는 항상 $O(\log N)$을 유지합니다.
  • 리프 노드 간 양방향 연결 리스트(Doubly Linked List): 범위 검색(BETWEEN, <, >)이나 정렬(ORDER BY) 시 트리를 다시 거슬러 올라가지 않고 리프 노드만 선형으로 순회하므로 범위 스캔 속도가 극도로 빠릅니다.

3. 클러스터링 인덱스 vs 세컨더리 인덱스

InnoDB 인덱스를 이해할 때 가장 중요한 개념은 **클러스터링 인덱스(Clustered Index)**와 **세컨더리 인덱스(Secondary Index, 비클러스터링 인덱스)**의 물리적 차이입니다.

구분 클러스터링 인덱스 (Clustered) 세컨더리 인덱스 (Secondary)
생성 기준 테이블당 오직 1개 (기본적으로 Primary Key) 필요에 따라 여러 개 생성 가능
리프 노드 저장 데이터 실제 행(Row)의 모든 컬럼 데이터 원본 인덱스 컬럼 값 + 해당 행의 PK 값
정렬 상태 물리적 디스크 상에 PK 순서로 정렬되어 저장 인덱스 컬럼 순서로만 정렬됨

더블 룩업(Double Lookup)과 커버링 인덱스(Covering Index)

세컨더리 인덱스로 검색할 경우:

  1. 세컨더리 인덱스 B+Tree에서 원하는 키를 찾아 PK 값을 얻습니다.
  2. 얻어낸 PK 값을 가지고 **클러스터링 인덱스 B+Tree를 한 번 더 탐색(Double Lookup)**하여 실제 행 데이터를 가져옵니다.

[!TIP] 커버링 인덱스(Covering Index)의 위력: 만약 쿼리가 요구하는 모든 컬럼이 세컨더리 인덱스 안에 다 들어있다면(예: SELECT user_id, created_at FROM orders WHERE user_id = 1234), 클러스터링 인덱스를 재조회하는 더블 룩업 과정을 완전히 건너뜁니다! 실행 계획의 Extra 컬럼에 **Using index**가 표시되며 쿼리 속도가 수십 배 이상 빨라집니다.


4. 복합 인덱스 설계의 핵심: 최좌측 접두사 원칙 (Leftmost Prefix)

두 개 이상의 컬럼을 묶어 생성하는 복합 인덱스(Composite Index)는 선언된 컬럼의 순서가 인덱스 사용 여부를 결정짓습니다.

-- 복합 인덱스 생성
CREATE INDEX idx_orders_user_status_date ON orders (user_id, status, created_at);

이 인덱스는 먼저 user_id로 정렬되고, user_id가 같을 때 status로 정렬되며, status마저 같을 때 비로소 created_at 순으로 정렬됩니다.

  • 인덱스를 타는 쿼리:
    • WHERE user_id = 10 (선두 컬럼 일치 ➔ O)
    • WHERE user_id = 10 AND status = 'PAID' (선두 2개 일치 ➔ O)
    • WHERE user_id = 10 AND status = 'PAID' AND created_at >= '2026-01-01' (전체 컬럼 일치 ➔ O)
  • 인덱스를 전혀 타지 못하거나 비효율적인 쿼리:
    • WHERE status = 'PAID' (선두 컬럼 user_id 누락 ➔ 인덱스 사용 불가, Full Scan)
    • WHERE status = 'PAID' AND created_at >= '2026-01-01' (선두 컬럼 누락 ➔ Full Scan)
    • WHERE user_id = 10 AND created_at >= '2026-01-01' (user_id만 인덱스 탐색에 사용되고 created_at은 필터링으로만 동작)

5. 실행 계획(EXPLAIN)을 통한 쿼리 진단법

쿼리 앞에 EXPLAIN 키워드를 붙이면 옵티마이저가 수립한 실행 계획을 확인할 수 있습니다.

EXPLAIN SELECT * FROM orders WHERE user_id = 12345 AND created_at >= '2026-01-01';

1) type 컬럼 (접근 방식, 아래로 갈수록 최악)

  • const: PK나 Unique 키로 1건만 조회 (최고 속도)
  • ref: 고유하지 않은 인덱스로 동등(=) 매칭 조회 (우수)
  • range: 인덱스를 통한 범위 검색 (BETWEEN, <, >, IN) (실무 권장)
  • index: 인덱스 풀 스캔 (데이터 파일 대신 인덱스 전체를 훑음)
  • ALL: 테이블 전체 풀 스캔 (Full Table Scan) ➔ 반드시 인덱스 추가 필요!

2) Extra 컬럼 핵심 지표

  • Using index: 커버링 인덱스 동작 (매우 이상적).
  • Using where: 인덱스 검색 후 스토리지 엔진에서 필터링 발생.
  • Using filesort: 인덱스로 정렬하지 못해 메모리/디스크에서 별도 정렬 수행 (지연 요인).
  • Using temporary: 쿼리 처리를 위해 임시 테이블을 생성함 (심각한 성능 저하 요인).

6. 인덱스가 작동하지 않는 5대 안티패턴

인덱스를 분명히 만들어 두었는데도 옵티마이저가 인덱스를 외면하는 대표적인 나쁜 쿼리 패턴입니다.

1) 인덱스 컬럼을 가공하거나 함수를 씌우는 경우

-- 나쁜 예: 컬럼에 YEAR 함수 적용 -> 인덱스 불가 (Full Scan)
SELECT * FROM orders WHERE YEAR(created_at) = 2026;

-- 좋은 예: 원본 컬럼을 보존하고 범위를 지정
SELECT * FROM orders WHERE created_at >= '2026-01-01 00:00:00' AND created_at < '2027-01-01 00:00:00';

2) 암시적 형변환(Implicit Type Conversion)

문자열(VARCHAR) 타입 컬럼을 숫자로 비교하면 MySQL이 모든 레코드 컬럼을 숫자로 형변환하느라 인덱스가 무력화됩니다.

-- 나쁜 예 (phone_number가 VARCHAR인데 숫자로 조회)
SELECT * FROM members WHERE phone_number = 01012345678;

-- 좋은 예 (따옴표로 감싸서 문자열 타입 일치)
SELECT * FROM members WHERE phone_number = '01012345678';

3) 선두 와일드카드(%)를 사용하는 LIKE 쿼리

LIKE '%검색어'는 B+Tree의 정렬 특성을 이용할 수 없습니다. 접두사 검색(LIKE '검색어%')으로 변경하거나, Full Text Search(전문 검색)를 도입해야 합니다.

4) 카디널리티(Cardinality)가 극단적으로 낮은 컬럼

성별(남/여), 활성여부(Y/N)처럼 고유값 개수가 적은 컬럼에 인덱스를 걸면, 옵티마이저는 인덱스를 타는 비용(랜덤 I/O)이 풀 스캔(순차 I/O)보다 비싸다고 판단하여 인덱스를 무시합니다 (전체 데이터의 20~25% 이상을 읽을 경우 풀 스캔 선택).


7. 결론: 인덱스는 다다익선이 아니다

인덱스는 SELECT 조회 성능을 극적으로 높여주지만, 공짜가 아닙니다.

  • 레코드가 INSERT, UPDATE, DELETE될 때마다 관련된 모든 B+Tree 인덱스를 재정렬하고 분할(Page Split)해야 하므로 쓰기 성능이 급격히 저하됩니다.
  • 불필요한 인덱스가 메모리(InnoDB Buffer Pool)를 잠식하여 캐시 효율을 떨어뜨립니다.

sys.schema_unused_indexes 뷰를 주기적으로 조회하여 실제로 쓰이지 않는 유령 인덱스는 과감히 제거하고, 슬로우 쿼리 로그와 EXPLAIN을 습관화하여 최적의 인덱스 전략을 유지하시기 바랍니다.