본문 바로가기

B+Tree 인덱스

보조 인덱스가 없는 게시글 검색이 테이블 전체를 읽는 과정을 1만 행의 실습 데이터로 재현하고, B+Tree 인덱스가 시작점과 범위를 정해 읽을 후보를 줄이는 원리를 일곱 개의 키로 따라갑니다. 복합 인덱스를 만든 뒤 실행 계획과 실제 행 수로 전후를 비교하는 방법을 다룹니다.

인덱스는 조회를 무조건 빠르게 만드는 장치가 아니라, 정렬된 보조 구조를 따로 유지하기로 하는 선택입니다. 어떤 행을 찾으려는지 설명하지 못한 채 인덱스를 늘리면 쓰기의 비용과 저장 공간만 커집니다. 그래서 먼저 인덱스가 없는 검색에서 데이터베이스가 얼마나 많은 후보를 확인하는지 살펴봐야 합니다.

이 글에서는 author_id와 status, created_at으로 한 작성자의 최근 공개 글을 찾는 요구 하나를 끝까지 따라갑니다. 결과의 행이 같아도 테이블 스캔과 인덱스 범위 스캔은 읽는 후보의 수가 다르다는 점이 중심입니다. 실습용 post_search_demo는 누적 스키마를 건드리지 않도록 별도의 테이블로 만들고, 정확히 1만 행 가운데 4천 행을 author_id = 42에 몰아 둡니다.

이렇게 특정 값에 행이 몰린 편향된 분포(skewed distribution)를 만들어야 인덱스의 선택도(selectivity)와 조회 비용을 비교할 수 있습니다. 선택도는 조건이 전체 행 가운데 얼마나 적은 행만 골라내는지를 나타내며, 고르는 행이 적을수록 선택도가 높다고 말하고 인덱스로 후보를 빨리 줄일 수 있습니다. 열의 서로 다른 값의 수를 전체 행 수로 나눈 비율은 값마다 행이 고르게 나뉜다고 보고 낸 평균이므로, 작성자 42처럼 한 값에 행이 몰리면 그 값으로 거는 조건은 평균보다 훨씬 많은 행을 골라 선택도가 낮습니다. 작은 데이터에서 2밀리초가 나왔다는 숫자보다, 접근 방식과 검사한 행의 수를 읽는 습관이 목적입니다.

보조 인덱스가 없으면 전체를 읽는다

WHERE가 있으니 필요한 행만 읽으리라 생각하기 쉽습니다. 그러나 정렬된 진입점이 없으면 InnoDB는 행마다 작성자와 상태를 확인해야 합니다. 데이터가 100건일 때 숨어 있던 비용은 1천만 건에서 지연과 버퍼의 오염으로 드러납니다.

보조 인덱스 없이 최근 공개 게시글 조회
DROP TABLE IF EXISTS post_search_demo;
CREATE TABLE post_search_demo (
  post_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  author_id BIGINT UNSIGNED NOT NULL,
  status VARCHAR(16) NOT NULL,
  created_at DATETIME(6) NOT NULL,
  view_count SMALLINT UNSIGNED NOT NULL,
  title VARCHAR(120) NOT NULL,
  PRIMARY KEY (post_id)
) ENGINE = InnoDB;

-- 0~9 숫자 10개를 비재귀 CTE로 정의한다.
INSERT INTO post_search_demo (
  author_id, status, created_at, view_count, title
)
WITH fixture_digit (digit) AS (
  SELECT 0 UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3
  UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6
  UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9
)
SELECT
  CASE WHEN seq.n <= 4000 THEN 42 ELSE 100 + MOD(seq.n, 200) END,
  CASE WHEN MOD(seq.n, 4) = 0 THEN 'DRAFT' ELSE 'PUBLISHED' END,
  TIMESTAMP('2026-01-01 00:00:00') + INTERVAL seq.n MINUTE,
  10 + MOD(seq.n, 111),
  CONCAT('post ', LPAD(seq.n, 5, '0'))
FROM (
  SELECT
    1 + ones.digit
      + tens.digit * 10
      + hundreds.digit * 100
      + thousands.digit * 1000 AS n
  FROM fixture_digit AS ones
  CROSS JOIN fixture_digit AS tens
  CROSS JOIN fixture_digit AS hundreds
  CROSS JOIN fixture_digit AS thousands
) AS seq;

-- 성능 비교가 의미 있는 동일 fixture인지 먼저 확인한다.
SELECT
  COUNT(*) AS total_rows,
  SUM(author_id = 42) AS author_42_rows,
  SUM(author_id = 42 AND status = 'PUBLISHED')
    AS author_42_published_rows,
  COUNT(DISTINCT author_id) AS distinct_authors
FROM post_search_demo;

EXPLAIN FORMAT=TREE
SELECT post_id, title, created_at
FROM post_search_demo
WHERE author_id = 42
  AND status = 'PUBLISHED'
ORDER BY created_at DESC
LIMIT 20;
total_rows | author_42_rows | author_42_published_rows | distinct_authors
10000      | 4000           | 3000                     | 201

생성 번호 n은 1부터 10000까지입니다. fixture_digit의 0부터 9까지 열 행을 5장 4편의 CROSS JOIN으로 네 번 조합하면 10×10×10×10=10,000개의 숫자가 나옵니다. n이 4000 이하인 행은 작성자 42이고 그 가운데 4의 배수인 1000행이 초안이므로 공개 글은 3000행이며, 나머지 6000행은 작성자 100부터 299까지의 200명에게 나뉘어 작성자는 모두 201명입니다. 값이 다르면 뒤의 실행 계획을 비교하지 말고, DROP TABLE post_search_demo부터 이 블록을 다시 실행합니다.

같은 일을 임시 테이블로 하지 않은 것은, MySQL이 한 쿼리에서 같은 TEMPORARY 테이블을 여러 번 참조하면 오류 1137을 내기 때문입니다. 여기서는 재귀가 아닌 CTE를 여러 번 쓰므로 재귀의 깊이 제한과도 관계가 없습니다. 생성된 시각은 2026년 1월 1일 00:01부터 1월 7일 22:40까지이므로, 뒤의 글에서 7월을 검색하는 예제는 시험용 행을 따로 넣기 전까지 결과가 없습니다. 또 SELECT에 ORDER BY가 없어 행이 들어가는 순서가 정해지지 않았으므로, post_id가 생성 번호 n과 같다고 가정하지 않습니다.

기본 키의 순서는 검색 조건과 관계가 없으므로, 실행 계획에는 테이블 스캔과 별도의 정렬이 나타납니다. EXPLAIN FORMAT=TREE의 출력은 대략 다음과 같은 모양이며, 괄호 안에 붙는 비용과 추정 행 수는 환경과 통계에 따라 달라지므로 뺐습니다.

-> Limit: 20 row(s)
    -> Sort: post_search_demo.created_at DESC, limit input to 20 row(s) per chunk
        -> Filter: ((post_search_demo.author_id = 42) and (post_search_demo.status = 'PUBLISHED'))
            -> Table scan on post_search_demo

트리는 안쪽부터 읽습니다. 가장 안쪽의 Table scan이 1만 행을 모두 읽고, Filter가 조건에 맞는 행을 거르며, Sort가 최신순으로 정렬한 뒤 Limit이 20행을 잘라 냅니다. 같은 쿼리를 표 형식의 전통적인 EXPLAIN으로 보면 type 열이 테이블 전체를 읽는다는 ALL, key 열이 쓸 인덱스가 없다는 NULL로 나타납니다.

LIMIT 20은 돌려줄 행의 수를 제한할 뿐입니다. 이처럼 따로 정렬해야 하면, 상위 20개를 고르기 위해 조건에 맞는 후보를 모두 읽어야 합니다. 기본 키 B+Tree의 리프에는 행 전체가 있지만 author_id의 순서로 모여 있지 않으므로, 조건의 열을 앞에서부터 정렬한 보조 인덱스가 있어야 검색의 시작점과 끝점을 계산할 수 있습니다.

실행 계획을 읽을 때는 네 가지를 차례로 확인합니다. 돌려준 20행과 검사한 1만 행을 같은 숫자로 보지 않고, 트리의 가장 안쪽 노드가 테이블 스캔인지 인덱스 조회인지 찾습니다. 이어서 ORDER BY를 인덱스의 순서로 해결하지 못해 Sort 노드가 생겼는지 보고, 행이 열 배가 되면 검사량도 그만큼 늘어날지 예측합니다.

고유 인덱스와 무결성

회원 가입에서 이메일을 찾는 조회는 빠르기만 해서는 부족합니다. 동시에 들어온 두 가입 요청도 같은 이메일을 저장하지 못하도록, members.email의 UNIQUE 제약이 최종 판단을 맡아야 합니다. UNIQUE 제약은 내부적으로 고유 인덱스를 만들지만, 그 목적은 검색의 성능보다 업무의 무결성에 있습니다.

회원 이메일 고유 인덱스와 조회 계획 확인
SHOW INDEX FROM members
WHERE Key_name = 'uq_members_email';

EXPLAIN
SELECT id, email, name
FROM members
WHERE email = '[email protected]';

SHOW INDEX의 결과에서 Non_unique가 0이면 고유 인덱스이고, Column_name이 email인지 확인합니다. EXPLAIN에서는 key가 uq_members_email이고, type이 고유 인덱스로 많아야 한 행을 찾는다는 const인지 봅니다. 다만 SELECT로 먼저 확인하고 INSERT하는 순서만으로는 중복 가입을 막을 수 없으며, 실제 경합은 8장에서 제약의 오류와 트랜잭션으로 다룹니다.

B+Tree를 따라 내려가는 원리

InnoDB의 보조 인덱스는 리프의 항목마다 인덱스 키와 기본 키의 값을 저장합니다. 데이터베이스는 루트에서 키를 비교하며 분기를 따라 조건의 시작 리프를 찾은 뒤, 필요한 범위만 순서대로 읽습니다. 비교할 때마다 나머지 부분 트리를 건너뛰는 원리는 이진 탐색 트리의 검색과 같지만, B+Tree의 노드 하나는 자식을 훨씬 많이 가집니다. 그래서 행이 늘어도 탐색의 높이는 천천히 늘지만, 리프의 범위가 넓으면 여전히 많은 항목을 읽습니다.

인덱스가 있다는 것보다, 첫 열이 자주 쓰는 동등 조건인지, 뒤의 열이 범위와 정렬을 이어받는지가 중요합니다. 선택도가 낮은 상태 열 하나만 인덱스로 만들면 전체의 절반 이상을 읽게 되어, 테이블 스캔이 오히려 쌀 수도 있습니다.

검색 문장을 B+Tree의 순서로 옮겨 봅니다. 먼저 "작성자 한 명의 공개 글을 최신순으로 20건"이라고 질문을 고정합니다. author_id와 status는 값 하나로 범위를 좁히므로 앞쪽에 두고, created_at DESC를 그 뒤에 두면 같은 작성자와 상태 안에서 최신순으로 리프를 읽을 수 있습니다. 목록이 제목까지 요구하므로, 원래의 행에 접근할지 커버링으로 해결할지는 7장 3편에서 판단합니다.

일곱 개의 키로 리프에서 원래 행까지

보조 인덱스에서 행까지 가는 길을 작은 키 목록으로 따라가 보겠습니다. 같은 작성자의 공개 글은 최신순으로 모여 있지만 제목은 이 인덱스에 없으므로, 먼저 리프에서 후보의 기본 키를 얻고 그 기본 키로 원래 행의 제목을 찾습니다.

다음 절에서 만들 (author_id, status, created_at DESC) 인덱스의 순서를 가상의 항목 일곱 개에 적용합니다. 작성자와 상태는 오름차순, 같은 작성자와 상태 안의 시각은 내림차순이고, 키가 나열된 끝 노드인 리프는 이 순서대로 이어집니다. 아래의 시각은 모두 2026-01-07이며, 앞의 1만 행 데이터에서 뽑은 것이 아닙니다.

리프author_idstatuscreated_at의 시각리프에 있는 PK(post_id)
L141PUBLISHED09:0090
L142DRAFT13:0014
L142PUBLISHED12:0018
L142PUBLISHED11:007
L242PUBLISHED10:0025
L242PUBLISHED09:003
L243DRAFT13:002

루트는 어느 리프로 내려갈지 알려 주는 분기 노드입니다. 이 모형에서는 L2의 첫 키 (42, PUBLISHED, 10:00, PK 25)를 분기의 기준으로 두어, 인덱스의 순서에서 이 키보다 앞인 항목은 L1에, 이 키부터의 항목은 L2에 둡니다. 시각만이 아니라 작성자, 상태, 시각의 순서로 비교한다는 점이 중요합니다.

author_id = 42 AND status = 'PUBLISHED'에 맞는 구간은 루트의 분기를 따라 L1에서 시작하며, 첫 일치 항목은 12:00의 PK 18입니다. L1에서 다음 일치 항목인 11:00의 PK 7을 얻고, 리프의 연결을 따라 L2로 넘어가 10:00의 PK 25와 09:00의 PK 3을 얻습니다. 그다음 항목은 작성자 43이므로 조건의 구간이 끝납니다.

고른 순서는 18, 7, 25, 3이고, 이 작은 모형에서 일치하는 행은 네 개뿐입니다. LIMIT 20은 돌려줄 수의 상한이므로 네 행을 돌려주며, 언제나 20행을 채우는 규칙이 아닙니다.

보조 인덱스의 리프에서 기본 키로 원본 행 찾기

복합 정렬 기준으로 L1에 내려간다. 선택한 PK로 원본 제목을 채운다. 각 단계의 값과 전환 이유를 비교합니다.

보조 인덱스 · 리프에서 원본 행까지

리프의 시각 순서와 원본 행의 PK 순서는 다릅니다

2026-01-07의 가상 키 7개입니다. 작성자·상태는 ASC, 같은 작성자·상태의 시각은 DESC입니다. 실제 10,000행 fixture나 InnoDB 물리 페이지를 발췌한 그림이 아닙니다.

작성자 42의 공개 글을 최신순으로 최대 20건 찾습니다

인덱스 순서: (author_id, status, created_at DESC). 보조 리프에는 기본 키 post_id도 있으며, 제목 title은 없습니다.

루트의 두 리프 분기와 별도 리프 연결 루트는 L1과 L2로 각각 분기합니다. 검색은 L1에서 PK18과7을 읽고 별도의 리프 연결을 따라 L2에서 PK25와3을 읽습니다. 다음 작성자43에서 구간이 끝납니다. 회색 분기선은 루트의 자식 관계이고 초록 화살표는 L1에서 L2로 이어 읽는 연결입니다. 가상 키7개와 선택 순서는 데스크톱과 모바일에 같습니다. 분기 키보다 앞 분기 키부터 뒤 리프 연결: L1 → L2 루트 · 두 리프로 분기 분기 기준 = L2의 첫 키 (42, PUBLISHED, 10:00) PK 25 L1 · 구간의 시작 리프 41 · PUBLISHED 09:00 → PK 90 42 · DRAFT 13:00 → PK 14 42 · PUBLISHED 12:00 → PK 18 42 · PUBLISHED 11:00 → PK 7 선택: PK 18 → 7 L2 · 다음 리프 42 · PUBLISHED 10:00 → PK 25 42 · PUBLISHED 09:00 → PK 3 43 · DRAFT 13:00 → PK 2 선택: PK 25 → 3 작성자 43에서 구간 끝
루트의 두 리프 분기와 별도 리프 연결 루트는 L1과 L2로 각각 분기합니다. 검색은 L1에서 PK18과7을 읽고 별도의 리프 연결을 따라 L2에서 PK25와3을 읽습니다. 다음 작성자43에서 구간이 끝납니다. 회색 분기선은 루트의 자식 관계이고 초록 화살표는 L1에서 L2로 이어 읽는 연결입니다. 가상 키7개와 선택 순서는 데스크톱과 모바일에 같습니다. 앞 구간 뒤 구간 리프 연결 루트 · 두 리프 기준 = L2의 첫 키 (42, PUBLISHED) 10:00 · PK 25 L1 · 시작 리프 41 · PUBLISHED 09:00 → PK 90 42 · DRAFT 13:00 → PK 14 42 · PUBLISHED 12:00 → PK 18 42 · PUBLISHED 11:00 → PK 7 선택: PK 18 → 7 L2 · 다음 리프 42 · PUBLISHED 10:00 → PK 25 42 · PUBLISHED 09:00 → PK 3 43 · DRAFT 13:00 → PK 2 선택: PK 25 → 3 작성자 43에서 구간 끝

L1과 L2는 루트 아래의 두 리프이며, 두 리프 사이 화살표는 이어 읽는 연결입니다. 선택 순서는 18 → 7 → 25 → 3이고 네 행만 반환합니다. LIMIT 20은 반환 상한입니다. 그림의 항목 수를 실제 페이지 읽기 횟수로 해석하지 않습니다.

리프의 PK로 원본 행을 찾아 제목을 채웁니다

아래는 선택한 원본 행을 PK 순서로 발췌했습니다. 각 PK로 제목을 얻어도 최종 반환 순서는 시각 내림차순인 18 → 7 → 25 → 3입니다.

PK 순서의 원본 행과 시각 순서의 반환 위치 대응
PK로 찾는 원본 행얻는 title시각 순서의 반환 위치
PK 3원본 행4번째 · 09:00
PK 7범위 탐색2번째 · 11:00
PK 18인덱스 시작1번째 · 12:00
PK 25리프 이동3번째 · 10:00
PK 3 → 원본 행
반환 4번째 · 09:00
PK 7 → 범위 탐색
반환 2번째 · 11:00
PK 18 → 인덱스 시작
반환 1번째 · 12:00
PK 25 → 리프 이동
반환 3번째 · 10:00

제목이 필요 없는 좁은 투영은 경로가 달라질 수 있습니다

post_id, created_at만 조회하면 필요한 값이 이 리프에 있습니다. 제목을 위한 원본 조회는 생략될 수 있지만 원래 쿼리의 제목 요구를 없앤 것은 아닙니다. 현재 트랜잭션에서 볼 수 있는 행인지 확인하는 MVCC 검사로 원본 행을 확인할 수도 있으므로 원본 접근 0회를 보장하지 않습니다.

복합 키의 분기·리프 이동·PK와 원본 행의 대응을 나타낸 논리 모형입니다. 트리 높이, 페이지 배치, 물리 I/O 횟수나 성능을 측정한 결과가 아닙니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 시작 리프부터 제목 조회까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목보조 인덱스의 리프에서 기본 키로 원본 행 찾기
설명·조건인덱스 순서: (author_id, status, created_at DESC). 보조 리프에는 기본 키 post_id도 있으며, 제목 title은 없습니다.
관계 설명루트는 L1과 L2로 각각 분기합니다. 검색은 L1에서 PK 18과 7을 읽고 별도의 리프 연결을 따라 L2에서 PK 25와 3을 읽습니다. 다음 작성자 43에서 구간이 끝납니다. 회색 분기선은 루트의 자식 관계이고 초록 화살표는 L1에서 L2로 이어 읽는 연결입니다. 가상 키 7개와 선택 순서는 데스크톱과 모바일에 같습니다.
설명·조건L1과 L2는 루트 아래의 두 리프이며, 두 리프 사이 화살표는 이어 읽는 연결입니다. 선택 순서는 18 → 7 → 25 → 3이고 네 행만 반환합니다. LIMIT 20은 반환 상한입니다. 그림의 항목 수를 실제 페이지 읽기 횟수로 해석하지 않습니다.
설명·조건아래는 선택한 원본 행을 PK 순서로 발췌했습니다. 각 PK로 제목을 얻어도 최종 반환 순서는 시각 내림차순인 18 → 7 → 25 → 3입니다.
표의 값PK 3 → 원본 행. 반환 4번째 · 09:00. PK 7 → 범위 탐색. 반환 2번째 · 11:00. PK 18 → 인덱스 시작. 반환 1번째 · 12:00. PK 25 → 리프 이동. 반환 3번째 · 10:00.
설명·조건post_id, created_at만 조회하면 필요한 값이 이 리프에 있습니다. 제목을 위한 원본 조회는 생략될 수 있지만 원래 쿼리의 제목 요구를 없앤 것은 아닙니다. 현재 트랜잭션에서 볼 수 있는 행인지 확인하는 MVCC 검사로 원본 행을 확인할 수도 있으므로 원본 접근 0회를 보장하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
시작 리프

복합 정렬 기준으로 L1에 내려간다.

  • 리프·선택 PK 1: target42/PUBLISHED
  • 리프·선택 PK 2: root split42/PUBLISHED10:00
  • L1 시작점 찾기
L1 범위

조건에 맞는 두 키를 얻는다.

  • 리프·선택 PK 1: PK18 12:00
  • 리프·선택 PK 2: PK7 11:00
  • 선택 18→7
리프 이어 읽기

L1→L2 연결을 따른다.

  • 리프·선택 PK 1: PK25 10:00
  • 리프·선택 PK 2: PK3 09:00
  • 선택 18→7→25→3
범위 종료

다음 작성자의 ID는 43이다.

  • 리프·선택 PK 1: 일치 4행
  • 리프·선택 PK 2: LIMIT 20 상한
  • 조건 구간 종료
제목 조회

선택한 PK로 원본 제목을 채운다.

  • 리프·선택 PK 1: PK 원본 순 3,7,18,25
  • 리프·선택 PK 2: 반환 순 18,7,25,3
  • 가상 논리 모형; MVCC/커버링 원본 0회 보장 없음

보조 인덱스의 리프에서 기본 키로 원본 행 찾기

복합 정렬 기준으로 L1에 내려간다. 선택한 PK로 원본 제목을 채운다. 각 단계의 값과 전환 이유를 비교합니다.

보조 인덱스 · 리프에서 원본 행까지

리프의 시각 순서와 원본 행의 PK 순서는 다릅니다

2026-01-07의 가상 키 7개입니다. 작성자·상태는 ASC, 같은 작성자·상태의 시각은 DESC입니다. 실제 10,000행 fixture나 InnoDB 물리 페이지를 발췌한 그림이 아닙니다.

작성자 42의 공개 글을 최신순으로 최대 20건 찾습니다

인덱스 순서: (author_id, status, created_at DESC). 보조 리프에는 기본 키 post_id도 있으며, 제목 title은 없습니다.

루트의 두 리프 분기와 별도 리프 연결 루트는 L1과 L2로 각각 분기합니다. 검색은 L1에서 PK18과7을 읽고 별도의 리프 연결을 따라 L2에서 PK25와3을 읽습니다. 다음 작성자43에서 구간이 끝납니다. 회색 분기선은 루트의 자식 관계이고 초록 화살표는 L1에서 L2로 이어 읽는 연결입니다. 가상 키7개와 선택 순서는 데스크톱과 모바일에 같습니다. 분기 키보다 앞 분기 키부터 뒤 리프 연결: L1 → L2 루트 · 두 리프로 분기 분기 기준 = L2의 첫 키 (42, PUBLISHED, 10:00) PK 25 L1 · 구간의 시작 리프 41 · PUBLISHED 09:00 → PK 90 42 · DRAFT 13:00 → PK 14 42 · PUBLISHED 12:00 → PK 18 42 · PUBLISHED 11:00 → PK 7 선택: PK 18 → 7 L2 · 다음 리프 42 · PUBLISHED 10:00 → PK 25 42 · PUBLISHED 09:00 → PK 3 43 · DRAFT 13:00 → PK 2 선택: PK 25 → 3 작성자 43에서 구간 끝
루트의 두 리프 분기와 별도 리프 연결 루트는 L1과 L2로 각각 분기합니다. 검색은 L1에서 PK18과7을 읽고 별도의 리프 연결을 따라 L2에서 PK25와3을 읽습니다. 다음 작성자43에서 구간이 끝납니다. 회색 분기선은 루트의 자식 관계이고 초록 화살표는 L1에서 L2로 이어 읽는 연결입니다. 가상 키7개와 선택 순서는 데스크톱과 모바일에 같습니다. 앞 구간 뒤 구간 리프 연결 루트 · 두 리프 기준 = L2의 첫 키 (42, PUBLISHED) 10:00 · PK 25 L1 · 시작 리프 41 · PUBLISHED 09:00 → PK 90 42 · DRAFT 13:00 → PK 14 42 · PUBLISHED 12:00 → PK 18 42 · PUBLISHED 11:00 → PK 7 선택: PK 18 → 7 L2 · 다음 리프 42 · PUBLISHED 10:00 → PK 25 42 · PUBLISHED 09:00 → PK 3 43 · DRAFT 13:00 → PK 2 선택: PK 25 → 3 작성자 43에서 구간 끝

L1과 L2는 루트 아래의 두 리프이며, 두 리프 사이 화살표는 이어 읽는 연결입니다. 선택 순서는 18 → 7 → 25 → 3이고 네 행만 반환합니다. LIMIT 20은 반환 상한입니다. 그림의 항목 수를 실제 페이지 읽기 횟수로 해석하지 않습니다.

리프의 PK로 원본 행을 찾아 제목을 채웁니다

아래는 선택한 원본 행을 PK 순서로 발췌했습니다. 각 PK로 제목을 얻어도 최종 반환 순서는 시각 내림차순인 18 → 7 → 25 → 3입니다.

PK 순서의 원본 행과 시각 순서의 반환 위치 대응
PK로 찾는 원본 행얻는 title시각 순서의 반환 위치
PK 3원본 행4번째 · 09:00
PK 7범위 탐색2번째 · 11:00
PK 18인덱스 시작1번째 · 12:00
PK 25리프 이동3번째 · 10:00
PK 3 → 원본 행
반환 4번째 · 09:00
PK 7 → 범위 탐색
반환 2번째 · 11:00
PK 18 → 인덱스 시작
반환 1번째 · 12:00
PK 25 → 리프 이동
반환 3번째 · 10:00

제목이 필요 없는 좁은 투영은 경로가 달라질 수 있습니다

post_id, created_at만 조회하면 필요한 값이 이 리프에 있습니다. 제목을 위한 원본 조회는 생략될 수 있지만 원래 쿼리의 제목 요구를 없앤 것은 아닙니다. 현재 트랜잭션에서 볼 수 있는 행인지 확인하는 MVCC 검사로 원본 행을 확인할 수도 있으므로 원본 접근 0회를 보장하지 않습니다.

복합 키의 분기·리프 이동·PK와 원본 행의 대응을 나타낸 논리 모형입니다. 트리 높이, 페이지 배치, 물리 I/O 횟수나 성능을 측정한 결과가 아닙니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 시작 리프부터 제목 조회까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목보조 인덱스의 리프에서 기본 키로 원본 행 찾기
설명·조건인덱스 순서: (author_id, status, created_at DESC). 보조 리프에는 기본 키 post_id도 있으며, 제목 title은 없습니다.
관계 설명루트는 L1과 L2로 각각 분기합니다. 검색은 L1에서 PK 18과 7을 읽고 별도의 리프 연결을 따라 L2에서 PK 25와 3을 읽습니다. 다음 작성자 43에서 구간이 끝납니다. 회색 분기선은 루트의 자식 관계이고 초록 화살표는 L1에서 L2로 이어 읽는 연결입니다. 가상 키 7개와 선택 순서는 데스크톱과 모바일에 같습니다.
설명·조건L1과 L2는 루트 아래의 두 리프이며, 두 리프 사이 화살표는 이어 읽는 연결입니다. 선택 순서는 18 → 7 → 25 → 3이고 네 행만 반환합니다. LIMIT 20은 반환 상한입니다. 그림의 항목 수를 실제 페이지 읽기 횟수로 해석하지 않습니다.
설명·조건아래는 선택한 원본 행을 PK 순서로 발췌했습니다. 각 PK로 제목을 얻어도 최종 반환 순서는 시각 내림차순인 18 → 7 → 25 → 3입니다.
표의 값PK 3 → 원본 행. 반환 4번째 · 09:00. PK 7 → 범위 탐색. 반환 2번째 · 11:00. PK 18 → 인덱스 시작. 반환 1번째 · 12:00. PK 25 → 리프 이동. 반환 3번째 · 10:00.
설명·조건post_id, created_at만 조회하면 필요한 값이 이 리프에 있습니다. 제목을 위한 원본 조회는 생략될 수 있지만 원래 쿼리의 제목 요구를 없앤 것은 아닙니다. 현재 트랜잭션에서 볼 수 있는 행인지 확인하는 MVCC 검사로 원본 행을 확인할 수도 있으므로 원본 접근 0회를 보장하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
시작 리프

복합 정렬 기준으로 L1에 내려간다.

  • 리프·선택 PK 1: target42/PUBLISHED
  • 리프·선택 PK 2: root split42/PUBLISHED10:00
  • L1 시작점 찾기
L1 범위

조건에 맞는 두 키를 얻는다.

  • 리프·선택 PK 1: PK18 12:00
  • 리프·선택 PK 2: PK7 11:00
  • 선택 18→7
리프 이어 읽기

L1→L2 연결을 따른다.

  • 리프·선택 PK 1: PK25 10:00
  • 리프·선택 PK 2: PK3 09:00
  • 선택 18→7→25→3
범위 종료

다음 작성자의 ID는 43이다.

  • 리프·선택 PK 1: 일치 4행
  • 리프·선택 PK 2: LIMIT 20 상한
  • 조건 구간 종료
제목 조회

선택한 PK로 원본 제목을 채운다.

  • 리프·선택 PK 1: PK 원본 순 3,7,18,25
  • 리프·선택 PK 2: 반환 순 18,7,25,3
  • 가상 논리 모형; MVCC/커버링 원본 0회 보장 없음

이제 제목을 채웁니다. 보조 리프에 있는 PK로, 클러스터형 인덱스(clustered index)인 기본 키 인덱스에서 원래 행을 찾습니다. 클러스터형 인덱스는 PK의 순서이므로 고른 네 행을 그 순서로 늘어놓으면 3, 7, 18, 25이지만, 요구한 결과의 순서는 시각의 내림차순인 18, 7, 25, 3입니다. PK로 행을 찾는다는 것이 결과를 PK의 순서로 바꾸라는 뜻은 아닙니다.

반환 순서PK로 찾은 원래 행원래 행에서 얻은 title이미 리프에 있는 시각
118인덱스 시작12:00
27범위 탐색11:00
325리프 이동10:00
43원본 행09:00

결과의 키 순서가 다르면 작성자와 상태의 동등 조건, 시각의 DESC 방향부터 확인하고, 제목이 다르면 리프의 PK와 원래 행의 대응을 확인합니다. 이 그림은 논리적인 탐색 경로일 뿐, 실제 InnoDB 페이지의 배치나 트리의 높이, 물리적인 I/O 횟수를 나타내지 않습니다. 시작점을 찾는 동안 다른 키와 비교할 수도 있으므로, 조건 밖의 항목을 물리적으로 전혀 읽지 않았다는 뜻도 아닙니다.

화면이 post_id와 created_at만 필요하다면, 필요한 값이 모두 이 보조 리프에 있습니다. 이렇게 조회에 필요한 열을 인덱스만으로 얻을 수 있는 경우를 커버링 인덱스(covering index)라고 하며, 이때는 제목을 얻으려고 원래 행을 찾는 단계가 생략될 수 있습니다. 다만 InnoDB는 바뀐 행의 이전 버전을 함께 보관해 트랜잭션마다 볼 수 있는 버전을 보여 주는 다중 버전 동시성 제어(Multi-Version Concurrency Control, MVCC)를 쓰며, 이 방식은 8장 3편에서 다룹니다. 보조 인덱스의 항목이 삭제 표시되어 있거나 그 페이지를 더 최근의 트랜잭션이 바꾸었다면 InnoDB는 원래 행을 확인하므로, 커버링이라고 원래 행의 접근이 언제나 0회는 아닙니다.

더 깊이: 실제 InnoDB가 만드는 B+Tree

InnoDB에서 트리의 노드 하나는 페이지 하나이고, 인덱스 페이지의 기본 크기는 16KB입니다. 이 크기는 MySQL 인스턴스를 처음 초기화할 때 innodb_page_size로 정해집니다. 키가 짧을수록 한 페이지에 더 많은 항목이 들어가 분기 노드 하나가 가리키는 자식이 많아지므로, 같은 행 수에서도 트리의 높이가 낮아집니다. 위의 모형에서 리프마다 항목을 몇 개만 둔 것과 달리, 실제 리프 페이지 하나에는 보통 훨씬 많은 항목이 들어갑니다.

클러스터형 인덱스로 쓸 키를 고르는 규칙도 정해져 있습니다. 기본 키가 있으면 그것을 쓰고, 없으면 모든 키 열이 NOT NULL인 첫 UNIQUE 인덱스를 씁니다. 둘 다 없으면 InnoDB는 행 ID를 담은 숨은 열로 GEN_CLUST_INDEX라는 클러스터형 인덱스를 만듭니다. 이 행 ID는 6바이트이고 행을 넣을 때마다 단조롭게 늘어나므로, 행은 넣은 순서대로 놓입니다.

보조 인덱스의 항목에는 인덱스로 정한 열과 함께 그 행의 기본 키 열이 들어 있고, 위의 모형처럼 그 값으로 클러스터형 인덱스를 다시 찾습니다. 그래서 기본 키가 길면 모든 보조 인덱스가 그만큼 커지므로, MySQL 8.4 매뉴얼은 기본 키를 짧게 두는 편이 유리하다고 적습니다. 회원 게시판이 BIGINT UNSIGNED의 숫자 대리 키를 기본 키로 쓰는 것도, 보조 인덱스마다 붙는 이 비용을 작게 유지하는 효과가 있습니다.

조회 조건에 맞춘 복합 인덱스

작성자와 상태를 같다고 비교한 뒤 시각의 순서로 읽도록 복합 인덱스를 만듭니다. 인덱스의 이름에는 테이블과 앞쪽 열을 드러내고, 만든 직후 같은 SQL로 계획을 다시 봅니다.

동등 조건 뒤에 정렬 컬럼을 둔 인덱스
CREATE INDEX ix_post_search_author_status_created
  ON post_search_demo (
    author_id,
    status,
    created_at DESC
  );

ANALYZE TABLE post_search_demo;

EXPLAIN FORMAT=TREE
SELECT post_id, title, created_at
FROM post_search_demo
WHERE author_id = 42
  AND status = 'PUBLISHED'
ORDER BY created_at DESC
LIMIT 20;

새 계획은 대략 다음의 모양이 됩니다. 이번에도 비용과 추정 행 수는 뺐습니다.

-> Limit: 20 row(s)
    -> Index lookup on post_search_demo using ix_post_search_author_status_created (author_id=42, status='PUBLISHED')

조건의 범위에서 곧바로 읽기 시작하고, 리프의 순서가 정렬의 요구와 같으므로 Sort 노드가 사라졌습니다. 전통적인 EXPLAIN으로 보면 key가 새 인덱스이고 type이 동등 조건으로 인덱스를 찾는 ref이며, Extra에 따로 정렬한다는 Using filesort가 없습니다. 트리의 문구와 비용의 숫자는 데이터의 분포와 통계에 따라 달라질 수 있지만, 인덱스의 이름과 접근 범위, 정렬 노드가 있는지는 비교할 수 있습니다.

옵티마이저가 여전히 테이블 스캔을 고른다면, 데이터가 너무 작은지나 조건이 대부분의 행을 고르는지 확인합니다. 인덱스는 결과의 정합성을 바꾸면 안 되므로, 만들기 전후로 post_id의 집합과 순서를 비교하고 성능은 EXPLAIN ANALYZE의 실제 행 수와 반복 횟수로 따로 확인합니다.

배포 전후도 같은 조건으로 맞춰 봅니다. 인덱스를 만들기 전의 SQL과 계획, 대표 파라미터와 행 수를 함께 남기고, 온라인 DDL이 가능한지와 잠금의 영향을 확인한 뒤 이름이 겹치지 않게 만듭니다. 대량으로 적재한 직후라면 ANALYZE TABLE로 분포의 추정치를 새로 고치고, 개선 전후에 돌려준 키를 MySQL 8.4의 EXCEPT나 안티 조인으로 비교해 집합의 차이를 확인하며, 정렬의 순서는 따로 비교합니다.

추정한 행과 실제의 행

EXPLAIN ANALYZE는 쿼리를 실제로 실행하므로 읽기 전용의 대표 데이터에서 씁니다. 추정한 행 수와 실제 행 수의 차이가 크면, 인덱스 자체보다 통계와 열 사이의 상관관계, 파라미터의 분포를 의심합니다.

실제 반복과 반환 행 확인
EXPLAIN ANALYZE
SELECT post_id, title, created_at
FROM post_search_demo
WHERE author_id = 42
  AND status = 'PUBLISHED'
ORDER BY created_at DESC
LIMIT 20;

SELECT COUNT(*) AS matching_rows
FROM post_search_demo
WHERE author_id = 42
  AND status = 'PUBLISHED';
matching_rows
3000

조건에 맞는 행은 3000개지만, 인덱스의 순서가 정렬과 같으므로 인덱스 조회 노드의 실제 행 수(actual ... rows)는 LIMIT 때문에 20에서 멈추는 것이 기대하는 모습입니다. 계획에 표시된 실제 행 수가 matching_rows나 LIMIT과 어떤 관계인지 설명할 수 있어야 합니다. 시간의 숫자 한 번보다, 같은 데이터에서 여러 번 잰 분포를 기록합니다.

제목이 들어 있지 않은 보조 인덱스로 행을 찾으면, 리프의 기본 키로 클러스터형 인덱스에서 원래 행을 다시 읽습니다. 후보가 적으면 효율적이지만 수만 건이면 흩어진 접근이 쌓이므로, 모든 열을 인덱스에 넣기 전에 목록이 정말 제목을 바로 필요로 하는지와 쓰기의 비용을 비교합니다. 페이지 캐시에 데이터가 있는지, 동시에 쓰는 작업이 있는지, 디스크가 무엇인지에 따라 절대 시간은 달라지므로, 구조를 비교할 때는 접근 방식과 실제 행 수를 먼저 보고 시간은 같은 조건에서 보조 지표로 봅니다.

INDEX · READ BENEFIT / WRITE COST

필요한 후보를 줄이는 효과와 인덱스 유지비를 함께 본다

스캔·단일·복합·커버링은 읽기와 쓰기의 서로 다른 교환입니다. 인덱스 이름보다 같은 결과를 얻는 실제 검사량·정렬·원본 접근을 비교합니다.

검색 경로별 읽기 효과와 유지 비용
선택읽기에서 얻는 효과비용과 적용 조건
테이블 스캔페이지를 순서대로 읽고 조건을 평가선택 비율이 높거나 테이블이 작을 때 유리할 수 있음
단일 인덱스한 열의 검색 시작점다른 조건의 필터·정렬 비용이 남음
복합 인덱스여러 조건과 정렬 순서를 결합반복 쿼리가 안정적일 때 쓰기·저장·DDL 비용과 비교
커버링 인덱스필요한 값을 리프에서 얻어 원본 접근을 줄임좁은 읽기가 빈번할 때 리프 폭·캐시 밀도 저하와 비교
테이블 스캔
읽기에서 얻는 효과: 페이지를 순서대로 읽고 조건을 평가
비용과 적용 조건: 선택 비율이 높거나 테이블이 작을 때 유리할 수 있음
단일 인덱스
읽기에서 얻는 효과: 한 열의 검색 시작점
비용과 적용 조건: 다른 조건의 필터·정렬 비용이 남음
복합 인덱스
읽기에서 얻는 효과: 여러 조건과 정렬 순서를 결합
비용과 적용 조건: 반복 쿼리가 안정적일 때 쓰기·저장·DDL 비용과 비교
커버링 인덱스
읽기에서 얻는 효과: 필요한 값을 리프에서 얻어 원본 접근을 줄임
비용과 적용 조건: 좁은 읽기가 빈번할 때 리프 폭·캐시 밀도 저하와 비교

인덱스 선택만으로 실제 읽은 행 수나 경과 시간이 정해지지는 않습니다. 같은 입력의 실행 계획과 결과를 대조합니다.

이 실습의 입력 · 10,000행 중 작성자 42의 공개 글 3,000행

기본 PK만 있으면 작성자·상태를 행마다 확인합니다. (author_id, status, created_at DESC)는 동등 범위의 시작점과 최신순을 제공합니다.

LIMIT 20은 반환 수입니다. 실제 읽은 수·별도 정렬·제목의 원본 조회는 EXPLAIN ANALYZE로 측정하며 이 그림은 성능 계측 로그가 아닙니다.

분포와 운영에서 따질 것

같은 인덱스도 데이터의 분포가 바뀌면 선택도가 달라집니다. 작성자의 수와 상태의 비율을 바꾸어 가며 인덱스 전후의 FORMAT=TREE와 FORMAT=JSON 계획을 저장하고, 키와 행 수, 정렬을 표시합니다. 두 실행의 post_id를 임시 테이블에 담아 누락과 추가가 0인지 확인하고, 인덱스를 DROP했을 때 계획이 처음으로 돌아오는지도 확인하면 인과관계를 나눌 수 있습니다.

운영에서 인덱스를 더하면 메타데이터 잠금과 I/O가 생길 수 있습니다. MySQL 8.4가 그 DDL에 어떤 알고리즘을 지원하는지, 복제 서버가 얼마나 늦어지는지, 디스크의 여유가 있는지를 확인하고 되돌릴 계획을 준비합니다(2장 1편). 느린 쿼리의 파라미터 분포가 개발용 표본과 다르면 같은 SQL도 다른 계획을 고를 수 있으므로, 행이 많은 작성자와 중간인 작성자, 적은 작성자를 대표값으로 잽니다.

NULL이 들어갈 수 있는 앞쪽 열도 NULL의 범위까지 인덱스에 들어가지만, 찾을 때는 = NULL이 아니라 IS NULL을 씁니다. 내림차순 인덱스와 섞인 정렬 방향을 지원하는지 확인하고, 같은 시각의 행에는 post_id의 정렬을 더합니다. LIMIT 없이 전체를 내보내는 작업은 인덱스를 타도 원래 행을 많이 읽으므로 페이지의 나눔과 배치의 범위를 따로 설계하고, 외래 키가 자동으로 만든 단일 인덱스와 새 복합 인덱스가 겹치는지 SHOW INDEX로 확인합니다.

따져 볼 것질문
질문반복되는 조회 문장을 파라미터와 정렬까지 고정했는가
선택도전체 가운데 몇 행을 읽고 몇 행을 돌려주는가
순서동등 조건과 범위, 정렬의 순서가 인덱스의 열과 맞는가
쓰기INSERT와 UPDATE마다 유지할 가치가 있는가
확인전후의 결과가 같고 실제 행 수가 줄었는가

인덱스는 자주 쓰이는 열이 아니라 쿼리의 규칙에 맞춰 붙입니다. 대표 SQL이 없거나 검사한 행 수가 어떻게 줄었는지 설명할 수 없으면, 만들 후보에서 보류합니다. 이 글의 복합 인덱스는 다음 글에서 범위와 LIKE, 정렬 조건을 확인하는 기준이 됩니다.

정리

  • WHERE와 LIMIT이 있어도, 정렬된 진입점이 없으면 읽는 후보는 줄지 않습니다.
  • B+Tree 인덱스는 정렬된 키에서 검색의 시작점과 범위를 정해 줍니다.
  • InnoDB의 보조 인덱스 리프는 기본 키를 담고 있어, 그 키로 클러스터형 인덱스에서 원래 행을 찾습니다.
  • 실행 계획은 인덱스의 이름과 접근 노드, 실제 행 수, 정렬 노드가 있는지로 읽습니다.
  • 인덱스 전후로 결과가 같은지와 운영 DDL의 비용을 함께 확인합니다.

연습 문제

1. 작성자의 초안을 오래된 순서로

author_id = ?인 초안을 created_at ASC로 10건 찾는 화면을 설계해 보세요. 지금의 공개 글용 인덱스를 다시 쓸지 새 인덱스를 만들지 판단하고, 계획을 확인하는 SQL까지 씁니다. 인덱스의 선택을 보기 전에, 이 데이터에서 작성자 42의 처음 10건이 어떤 제목과 시각일지 먼저 예측해 봅니다.

해설 보기

상태도 동등 조건이므로 기존 (author_id, status, created_at DESC)의 왼쪽 접두어를 그대로 쓸 수 있습니다. 인덱스를 거꾸로 읽으면 created_at ASC를 얻을 수 있지만, InnoDB의 보조 인덱스 뒤에 붙은 기본 키도 함께 거꾸로 읽히므로 post_id ASC라는 동률 정렬까지 자동으로 맞추지는 못합니다. 실제 정렬과 비용을 확인한 뒤, 거의 같은 인덱스를 하나 더 만드는 것은 피합니다.

SELECT post_id, title, created_at
FROM post_search_demo
WHERE author_id = 42
  AND status = 'DRAFT'
ORDER BY created_at ASC, post_id ASC
LIMIT 10;

EXPLAIN ANALYZE
SELECT post_id, title, created_at
FROM post_search_demo
WHERE author_id = 42
  AND status = 'DRAFT'
ORDER BY created_at ASC, post_id ASC
LIMIT 10;

SHOW INDEX FROM post_search_demo;

작성자 42의 초안은 생성 번호가 4의 배수인 행이므로, 첫 SELECT는 다음 10행을 돌려줍니다. post_id는 적재 순서에 따라 달라질 수 있어 결과에서 뺐습니다.

title      | created_at
post 00004 | 2026-01-01 00:04:00.000000
post 00008 | 2026-01-01 00:08:00.000000
post 00012 | 2026-01-01 00:12:00.000000
post 00016 | 2026-01-01 00:16:00.000000
post 00020 | 2026-01-01 00:20:00.000000
post 00024 | 2026-01-01 00:24:00.000000
post 00028 | 2026-01-01 00:28:00.000000
post 00032 | 2026-01-01 00:32:00.000000
post 00036 | 2026-01-01 00:36:00.000000
post 00040 | 2026-01-01 00:40:00.000000

시각은 4분씩 늘어납니다. 결과를 확인한 뒤 계획이 기존 인덱스를 고르는지, 따로 정렬하는 단계가 생기는지 봅니다. 이 데이터에서는 작성 시각이 모두 달라 post_id의 정렬이 결과를 바꾸지 않지만, 같은 시각이 있을 수 있는 실제 데이터에서는 그 정렬이 결과를 하나로 정합니다.

2. 상태 하나만으로 만든 인덱스

status 열 하나로만 인덱스를 만들고 WHERE status = 'PUBLISHED'로 조회하면, 이 데이터에서 그 인덱스가 범위를 얼마나 줄여 주나요? 옵티마이저가 테이블 스캔을 고를 수도 있는 이유는 무엇인가요?

해설 보기

1만 행 가운데 생성 번호가 4의 배수가 아닌 7500행이 공개 글이므로, 이 인덱스는 전체의 75%를 고릅니다. 보조 인덱스로 7500개의 키를 읽은 뒤 기본 키로 원래 행을 하나씩 다시 찾는 것보다, 기본 키 순서로 테이블을 한 번 읽는 편이 쌀 수 있으므로 옵티마이저는 테이블 스캔을 고를 수 있습니다. 선택도가 낮은 열은 단독 인덱스보다, 본문처럼 선택도가 높은 동등 조건 뒤에 붙는 복합 인덱스의 한 부분으로 쓰일 때 효과가 있습니다.

다음 글 인덱스 검색 조건에서는 같은 인덱스도 열에 씌운 함수나 범위, LIKE의 모양에 따라 쓸 수 없게 되는 이유를 다룹니다.