본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
7장 : 인덱스와 실행 계획

복합 인덱스 설계

왼쪽 접두어와 첫 범위 규칙을 실제 조회에 적용하고 중복 인덱스와 DML 비용까지 포함해 최종 구성을 고릅니다.

복합 인덱스의 컬럼 순서는 WHERE 절에 적힌 순서가 아니라 B+Tree가 정렬되는 계층을 결정합니다.

(A, B, C)는 A 안에서 B, 같은 A·B 안에서 C 순입니다.

A를 모르면 B의 한 구간을 바로 찾을 수 없고, 범위가 시작되면 뒤 컬럼으로 하나의 연속 구간을 더 좁히기 어렵습니다.

회원 게시판 운영 화면은 작성자·상태·시각으로 찾고, 관리자 화면은 상태·시각으로 찾습니다.

한 인덱스로 두 화면을 모두 최적화하려다 선두 컬럼을 타협하면 어느 쪽도 만족하지 못할 수 있습니다.

조회 빈도와 쓰기 비율을 포함한 워크로드로 결정합니다.

후보 인덱스는 (status, created_at, author_id)(author_id, status, created_at)입니다.

사용자 화면은 author_id와 상태가 동등 조건이고 created_at이 범위·정렬입니다.

관리자 화면은 상태와 created_at만 사용합니다.

두 SQL을 같은 비중으로 취급하지 않고 호출량과 지연 예산을 적습니다.


범위 열 선두 배치의 문제

created_at의 값이 다양하므로 무조건 선두에 두고, author_id는 범위 뒤에 배치합니다.

특정 작성자의 한 달 기록을 찾을 때 먼저 전체 서비스의 한 달 범위를 읽은 뒤 작성자를 필터하므로 사용자가 늘수록 검사량이 커집니다.

범위 컬럼이 앞선 복합 인덱스
CREATE INDEX ix_bad_post_created_author_status
  ON post_search_demo (
    created_at,
    author_id,
    status
  );

EXPLAIN ANALYZE
SELECT post_id, created_at, view_count
FROM post_search_demo
WHERE author_id = 42
  AND status = 'PUBLISHED'
  AND created_at >= '2026-07-01'
  AND created_at <  '2026-08-01'
ORDER BY created_at DESC;

시각 한 달 범위는 사용할 수 있지만 그 안의 모든 작성자를 읽으며 작성자와 상태를 잔여 필터로 평가합니다.

오류 실행 결과
index range: created_at in July
rows read from index: 6,400
rows after author/status filter: 64
useful ratio: 1%
author_id key boundary: not used as leading equality

높은 카디널리티라는 단일 규칙만 적용한 결과입니다.

인덱스의 목적은 컬럼 자체를 잘 구별하는 것이 아니라 대표 쿼리의 후보를 빨리 줄이는 것입니다.

사용자 화면에서는 author_id 동등 조건이 가장 먼저 전체를 개인 범위로 나눕니다.

SQL 텍스트에서 작성자 조건을 앞에 써도 인덱스 물리 순서는 바뀌지 않습니다.

옵티마이저는 조건 순서를 재배치할 수 있지만 존재하지 않는 왼쪽 접두어를 만들 수는 없습니다.

왼쪽 접두어와 첫 범위를 표시하기

  1. 왼쪽 접두 — 선두 컬럼부터 연속으로 어떤 조건을 사용할 수 있는지 표시합니다.
  2. 첫 범위 — BETWEEN, >, LIKE 접두사가 처음 등장하는 컬럼 뒤를 구분합니다.
  3. 읽은 행 비율 — 행 수 읽기와 반환한 행 수를 비교해 잔여 필터 비용을 찾습니다.
  4. 다른 화면 — 현재 쿼리 하나의 최적화가 관리자 쿼리를 어떻게 바꾸는지 따로 측정합니다.

복합 인덱스 열 순서

일반적인 출발점은 자주 함께 쓰는 동등 조건을 앞에 두고, 범위 조건과 ORDER BY 컬럼을 그 뒤에 두는 것입니다.

동등 조건끼리 순서는 워크로드와 재사용 가능한 왼쪽 접두어, 분포를 함께 봅니다.

무조건 카디널리티가 높은 컬럼부터라는 규칙은 충분하지 않습니다.

(author_id, status, created_at)는 작성자만, 작성자+상태, 작성자+상태+시작 범위를 지원합니다.

상태만 조회하는 관리자 화면은 이 인덱스의 선두를 건너뛰므로 별도 (status, created_at) 후보가 필요할 수 있습니다.

쿼리 유형을 인덱스 후보로 묶기

  1. 쿼리 유형 — 사용자 최근 목록과 관리자 상태 목록을 서로 다른 유형으로 묶습니다.
  2. 호출량 — 분당 호출 수와 p95 예산으로 어느 유형이 핵심인지 정합니다.
  3. 최소 인덱스 — 각 유형을 만족하는 가장 좁은 후보부터 시작합니다.
  4. 중복 제거 — 새 복합 인덱스가 기존 단일 인덱스 선두를 대체하는지 검증합니다.

조회 유형별 최소 인덱스

사용자 화면에는 작성자·상태 동등 뒤 시각을 두고, 관리자 화면이 실제 SLA(Service Level Agreement, 서비스 수준 합의)를 가진 경우에만 상태·시각 인덱스를 별도로 둡니다.

무작정 모든 순열을 만들지 않습니다.

사용자용과 관리자용 접근 경로 분리
DROP INDEX ix_bad_post_created_author_status
  ON post_search_demo;

CREATE INDEX ix_post_user_status_created
  ON post_search_demo (
    author_id,
    status,
    created_at DESC
  );

CREATE INDEX ix_post_admin_status_created
  ON post_search_demo (
    status,
    created_at DESC
  );

EXPLAIN ANALYZE
SELECT post_id, created_at, view_count
FROM post_search_demo
WHERE author_id = 42
  AND status = 'PUBLISHED'
  AND created_at >= '2026-07-01'
  AND created_at <  '2026-08-01'
ORDER BY created_at DESC;

사용자 쿼리는 작성자·상태의 작은 연속 구간에서 7월 리프만 읽습니다.

관리자 쿼리는 별도 선두 상태 범위를 사용할 수 있습니다.

개선 실행 결과
user query:
  key = ix_post_user_status_created
  equality prefix = author_id, status
  range/order = created_at
  rows read = 64
  rows returned = 64

admin query candidate:
  key = ix_post_admin_status_created

두 인덱스는 created_at과 상태를 중복 저장하므로 쓰기·공간 비용이 생깁니다.

관리자 화면이 하루 몇 번만 실행된다면 사용자용 인덱스만 두고 관리자 쿼리의 스캔을 허용하거나 사전 집계·비동기 내보내기로 바꿀 수 있습니다.

복합 인덱스가 있다면 (author_id) 단일 인덱스는 대체 가능할 수 있지만 외래 키·다른 정렬·인덱스 크기를 검증하고 비가시 전환을 거칩니다.

이름만 비슷하다고 바로 삭제하지 않습니다.

읽기 이득과 DML 비용을 한 번에 대조하기

  1. 두 계획 수집 — 사용자·관리자 쿼리를 같은 데이터 스냅샷에서 각각 EXPLAIN ANALYZE합니다.
  2. 쓰기 성능 측정 — 1천 건 INSERT와 상태 UPDATE 전후 지연 시간·리두 로그·인덱스 크기를 비교합니다.
  3. 중복 후보 숨김 — 대체 가능한 단일 인덱스를 INVISIBLE로 두고 쿼리 다이제스트 회귀를 관찰합니다.
  4. 결정 기록 — 남긴 각 인덱스에 대표 쿼리와 제거 조건을 테이블 정의서에 연결합니다.

읽기와 쓰기 비용 비교

인덱스 선택은 한 쿼리의 계획 화면 캡처로 끝나지 않습니다.

information_schema의 테이블 크기, 대표 DML 처리량, 느린 로그의 검사한 행 수를 배포 전후로 비교합니다.

인덱스 목록·크기·대표 계획 점검
SHOW INDEX FROM post_search_demo;

SELECT
  table_name,
  table_rows,
  data_length,
  index_length
FROM information_schema.tables
WHERE table_schema = DATABASE()
  AND table_name = 'post_search_demo';

EXPLAIN FORMAT=TREE
SELECT post_id
FROM post_search_demo
WHERE status = 'DRAFT'
  AND created_at >= CURRENT_TIMESTAMP - INTERVAL 1 DAY
ORDER BY created_at DESC
LIMIT 100;

SHOW 인덱스에서 같은 선두 컬럼 순서를 가진 중복 후보를 찾고, index_length는 배포 전후 차이로 기록합니다.

통계 값은 근사치이므로 DML 성능 측정과 실제 디스크 사용을 함께 봅니다.

최소 인덱스 집합의 대안

선택얻는 효과감수할 비용적합한 조건
인덱스 1개쓰기·공간 최소화일부 쿼리의 스캔보조 화면 SLA가 느슨할 때
쿼리별 2개두 핵심 경로의 안정성중복 리프와 DML 증가두 화면 모두 빈번하고 중요할 때
넓은 범용 인덱스여러 투영 포함 가능캐시 밀도·변경 비용 악화읽기 모델이 매우 안정적일 때
사전 집계관리자 대량 조회 분리신선도·파이프라인 복잡도실시간 원본 조회가 필요 없을 때

ICP와 건너뛰기 스캔을 과신하지 않기

범위 뒤 컬럼도 인덱스 조건 푸시다운으로 리프에서 필터될 수 있어 완전히 무용한 것은 아닙니다.

그러나 탐색 구간 자체를 작성자별로 분리하지 못하므로 읽는 리프 수는 줄지 않을 수 있습니다.

탐색 경계와 잔여 필터를 구분합니다.

건너뛰기 스캔 같은 옵티마이저 기능이 선두 컬럼을 건너뛴 접근을 시도할 수 있지만 선두 DISTINCT 수와 비용에 민감합니다.

일반적인 핵심 쿼리 설계를 우연한 최적화에 맡기지 않고 계획으로 확인합니다.

혼합 정렬·테넌트·FK 인덱스 예외

  • ORDER BY 방향이 혼합되면 인덱스 정의의 ASC/DESC 조합과 정확히 맞는지 확인합니다.
  • 낮은 카디널리티 상태도 작성자 동등 범위 안에서는 유용하게 구간을 나눌 수 있습니다.
  • 테넌트 시스템에서는 tenant_id가 모든 인덱스 선두에 필요한 보안·분할 경계인지 별도 판단합니다.
  • 인덱스 삭제 전 외래 키가 다른 적합한 인덱스를 사용할 수 있는지 검증하지 않으면 DDL이 거절되거나 제약 지원을 잃습니다.

계절성 쿼리와 복제본 비용

인덱스 수가 늘면 INSERT뿐 아니라 상태·created_at UPDATE가 모든 관련 B+Tree를 수정합니다.

변경이 많은 컬럼을 여러 인덱스에 중복할 때 잠금·리두 로그·복제 비용을 관찰합니다.

사용되지 않는 인덱스 통계는 관찰 기간과 워크로드 계절성을 고려합니다.

월말 관리자 쿼리처럼 드문 핵심 작업을 일주일 지표만 보고 삭제하지 않습니다.

순열 대신 가설 두 개만 검증하기

  1. 순열 비교 — 세 컬럼의 모든 순열을 만들지 말고 두 후보만 가설로 세워 계획을 비교합니다.
  2. IN 변환 — 상태 범위를 IN의 동등 목록으로 바꾸었을 때 범위 수와 정렬이 어떻게 변하는지 봅니다.
  3. 파일 정렬 허용 — 작은 결과의 파일 정렬과 넓은 인덱스 추가 비용을 수치로 비교합니다.
  4. 비가시 회귀 — 제거 후보 인덱스를 숨긴 뒤 주요 다이제스트의 키 변화와 지연 시간을 관찰합니다.

인덱스 장의 결론은 ‘많이 만들기’가 아니라 쿼리 유형별 최소 접근 경로와 검증 근거를 남기는 것입니다.

다음 장의 무결성 제약도 인덱스를 만들 수 있으므로 성능용 인덱스와 중복을 함께 관리합니다.


인덱스 조합 선택 기준

판단 축확인할 질문
유형각 인덱스에 대표 쿼리 유형이 연결되는가?
접두어동등 조건과 첫 범위가 컬럼 순서에 맞는가?
대체기존 단일·복합 인덱스와 중복되지 않는가?
쓰기DML·공간·복제본 비용을 측정했는가?
수명비가시 검증과 제거 조건을 기록했는가?

복합 인덱스는 컬럼 조합 문제가 아니라 워크로드 포트폴리오 결정입니다.

읽기 하나를 최적화한 뒤 전체 쓰기와 다른 쿼리 회귀를 반드시 다시 봅니다.


연습 문제

세 쿼리가 있습니다: A는 작성자+상태+기간, B는 작성자만 최신순, C는 상태+기간 관리자 목록입니다.

A 1,000회/분, B 100회/분, C 2회/일입니다.

최소 인덱스 집합과 C 처리 전략을 제안하세요.

해설과 예시 답안

A 중심 (author_id, status, created_at)는 작성자 단독 접두어를 통해 B 후보가 되지만 상태가 중간이라 B 정렬 효율은 계획으로 확인합니다.

C는 매우 드물어 별도 인덱스보다 비동기 내보내기나 허용 가능한 스캔을 먼저 고려합니다.

CREATE INDEX ix_post_workload_primary
  ON post_search_demo (
    author_id,
    status,
    created_at DESC
  );

EXPLAIN ANALYZE
SELECT post_id, created_at
FROM post_search_demo
WHERE author_id = 42
ORDER BY created_at DESC
LIMIT 20;

-- C는 별도 오프피크 export로 측정한 뒤 SLA가 깨질 때만
-- (status, created_at) 인덱스를 추가한다.

A뿐 아니라 B의 파일 정렬 크기와 C의 실제 실행 시간을 측정하고, 인덱스 하나를 추가했을 때 쓰기 비용까지 의사결정 기록에 포함합니다.


핵심 정리

  • 복합 인덱스는 왼쪽부터 계층적으로 정렬됩니다.
  • 동등 조건 뒤 첫 범위와 정렬 컬럼을 두는 것이 일반적 출발점입니다.
  • 쿼리 유형마다 인덱스가 필요하다고 가정하지 않고 호출량과 SLA를 봅니다.
  • 중복 인덱스 제거는 비가시 검증과 DML 비용 측정을 거칩니다.

7장 실습을 끝낸 뒤 운영 스키마와 분리된 fixture만 정리하려면 다음 문장을 실행합니다.

이후 실습을 다시 시작할 때는 ch7-1의 생성 블록부터 실행합니다.

인덱스 실습 fixture reset
DROP TABLE IF EXISTS post_search_demo;

다음 장에서는 빠른 조회보다 먼저 지켜야 할 데이터 무결성과 트랜잭션 단위를 다룹니다.