fos-blog/study
01 / 홈02 / 카테고리
01 / 홈02 / 카테고리

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • langgraph 페이지로 이동
    • agents.md
    • BMAD Method — AI 에이전트로 애자일 개발하는 방법론
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • Claude Code를 5주 더 쓴 결과 — 스킬·CLAUDE.md를 키워가는 방식
    • Claude Code를 11일 동안 쓴 결과 — 데이터로 본 나의 사용 패턴
    • Claude Code 멀티 에이전트 — Teams
    • AI 에이전트와 디자인의 새 컨벤션 — DESIGN.md, Google Stitch, Claude Design
    • 하네스 엔지니어링 실전 — 4인 에이전트 팀으로 코딩 파이프라인 구축하기
    • 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계
    • 멀티모달 LLM (Multimodal Large Language Model)
    • AI 에이전트와 함께 MVP 만들기 — dooray-cli 사례
  • ai 페이지로 이동
    • agent 페이지로 이동
  • algorithm 페이지로 이동
    • live-coding 페이지로 이동
    • 분산 계산을 위한 알고리즘
  • architecture 페이지로 이동
    • [초안] 시니어 백엔드를 위한 API 설계 실전 스터디 팩 — REST · 멱등성 · 페이지네이션 · 버전 전략
    • [초안] API Versioning과 Backward Compatibility: 시니어 백엔드 관점 정리
    • 캐시 설계 전략 총정리
    • [초안] CJ푸드빌 커머스/F&B 도메인 설계 면접 대비 — 슬롯 경험을 주문·결제·쿠폰·매장 상태 설계로 번역하기
    • [초안] 커머스 Spring 서비스에 Clean/Hexagonal Architecture를 실용적으로 적용하기
    • [초안] 커머스 주문 상태와 데이터 정합성 기본기 — CJ푸드빌 면접 대비
    • [초안] 쿠폰/프로모션 동시성과 정합성 기본기 — 선착순·중복 사용 방지·발급/사용/복구
    • [초안] DDD와 도메인 모델링: 시니어 백엔드 관점의 전술/전략 패턴 실전 가이드
    • [초안] Decorator & Chain of Responsibility — 행동을 체인으로 조립하는 두 가지 방식
    • 디자인 패턴
    • [초안] 분산 아키텍처 완전 정복: Java 백엔드 시니어 인터뷰 대비 실전 가이드
    • [초안] 분산 트랜잭션과 Outbox 패턴 — 왜 2PC를 피하고 어떻게 대신할 것인가
    • 분산 트랜잭션
    • [초안] e-Commerce 주문·결제 도메인 모델링: 상태머신, 멱등성, Outbox/Saga 실전 정리
    • [초안] F&B 쿠폰·프로모션·멤버십·포인트 설계
    • [초안] F&B · e-Commerce 디지털 채널 도메인 한 장 정리 — CJ푸드빌 디지털 채널 백엔드 면접 대비
    • [초안] F&B 주문/매장/픽업 상태머신 설계 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] F&B 이커머스 결제·환불·정산 운영 가이드
    • [초안] Hexagonal / Clean Architecture를 Spring 백엔드에 적용하기
    • [초안] 대규모 커머스 트래픽 처리 패턴 — 1,600만 고객과 올영세일을 버티는 설계
    • [초안] 레거시 JSP/jQuery 화면과 신규 API가 공존하는 백엔드 운영 전략
    • [초안] MSA 서비스 간 통신: Redis [Cache-Aside](../database/redis/cache-aside.md) × Kafka 이벤트 하이브리드 설계
    • [초안] Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • [초안] Outbox / Inbox Pattern 심화 — 분산 메시징의 정합성 문제를 DB 트랜잭션으로 풀어내기
    • [초안] 결제 도메인 멱등성과 트랜잭션 재시도 기본기
    • [초안] 시니어 백엔드를 위한 Resilience 패턴 실전 가이드 — Timeout, Retry, Circuit Breaker, Bulkhead, Backpressure
    • [초안] REST API 버저닝과 모바일 앱 하위 호환성 — CJ푸드빌 디지털 채널 백엔드 관점
    • [초안] Strategy Pattern — 분기문을 없애는 설계, 시니어 백엔드 인터뷰 핵심 패턴
    • [초안] 시니어 백엔드를 위한 시스템 설계 입문 스터디 팩
    • [초안] 템플릿 메서드 패턴 - 백엔드 처리 골격을 강제하는 가장 오래되고 가장 위험한 패턴
    • [초안] 대규모 트래픽 중 무중단 마이그레이션 — Feature Flag + Shadow Mode 실전
  • database 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • redis 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • 인덱스 - DB 성능 최적화의 핵심
    • [초안] JPA N+1과 커머스 조회 모델: 주문/메뉴/쿠폰 도메인에서 살아남기
    • [초안] MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • [초안] MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 역정규화 (Denormalization)
    • 데이터 베이스 정규화
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • observability 페이지로 이동
    • [초안] 커머스/F&B 채널 장애 첫 5분과 관측성 기본기
    • Envoy Proxy
    • [초안] F&B / e-Commerce 운영 장애 대응과 모니터링 — 백엔드 관점 정리
    • Graceful Shutdown
  • finance 페이지로 이동
    • industry-cycle 페이지로 이동
    • investing 페이지로 이동
    • stock-notes 페이지로 이동
  • http 페이지로 이동
    • HTTP Connection Pool
  • interview 페이지로 이동
    • [초안] AI 서비스 팀 경험 기반 시니어 백엔드 면접 질문 뱅크 — Spring Batch RAG / gRPC graceful shutdown / 전략 패턴 / 12일 AI 웹툰 MVP
    • [초안] CJ푸드빌 디지털 채널 Back-end 개발자 직무 분석
    • [초안] CJ푸드빌 디지털 채널 Back-end 면접 답변집 — 슬롯 도메인 경험을 커머스/F&B 설계로 번역하기
    • [초안] F&B / e-Commerce 운영 모니터링과 장애 대응 인터뷰 정리
    • Observability — 면접 답변 프레임
    • [초안] 시니어 Java 백엔드 면접 마스터 플레이북 — 김병태
    • [초안] NSC 슬롯팀 경험 기반 질문 은행 — 도메인 모델링·동시성·성능·AI 협업
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • 더_자바_코드를_조작하는_다양한_방법 페이지로 이동
    • [초안] Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • [초안] JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • npm vs pnpm — 어떤 기준으로 선택했나
    • `setImmediate()`
  • kafka 페이지로 이동
    • [초안] Kafka 기본 개념 — 토픽, 파티션, 오프셋, 복제
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • [초안] Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • 메시지 전송 신뢰성
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • tmux — Terminal Multiplexer
  • network 페이지로 이동
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • rabbitmq 페이지로 이동
    • [초안] RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • [초안] RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • security 페이지로 이동
    • [초안] 시니어 백엔드를 위한 보안 / 인증 스터디 팩 — Spring Security, JWT, OAuth2, OWASP Top 10
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • testing 페이지로 이동
    • [초안] 시니어 Java 백엔드를 위한 테스트 전략 완전 정리 — 피라미드부터 TestContainers, 마이크로벤치, Contract까지
  • travel 페이지로 이동
    • 오사카 3박 4일 일정표: 우메다 쇼핑, USJ, 난바·도톤보리, 오사카성
  • web 페이지로 이동
    • [초안] HTTP / Cookie / Session / Token 인증 기본기 — 레거시 JSP와 모바일 API가 공존하는 백엔드 관점
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/database/[초안] MySQL 복합 인덱스 완전 정복:…
db

[초안] MySQL 복합 인덱스 완전 정복: 좌측 접두사 규칙부터 커버링 인덱스까지

단일 컬럼 인덱스는 직관적이다. userid에 인덱스를 걸면 WHERE userid = 42가 빠르다. 그런데 실제 서비스 쿼리는 대부분 조건이 2개 이상이다. WHERE userid = 42 AND status = 'ACTIVE', WHERE categoryid = 5 AND createdat >= '2025-01-01' ORDER BY createdat...

2026.04.18·13 min read·52 views

왜 복합 인덱스를 깊게 알아야 하는가

단일 컬럼 인덱스는 직관적이다. user_id에 인덱스를 걸면 WHERE user_id = 42가 빠르다. 그런데 실제 서비스 쿼리는 대부분 조건이 2개 이상이다. WHERE user_id = 42 AND status = 'ACTIVE', WHERE category_id = 5 AND created_at >= '2025-01-01' ORDER BY created_at DESC 같은 형태다.

이때 단일 인덱스를 두 개 만들어도 MySQL 옵티마이저는 둘 중 하나만 사용한다(Index Merge가 발생하기도 하지만 그것도 비용이 크다). 복합 인덱스 하나를 올바르게 설계하면 두 조건을 동시에 인덱스 레인지 스캔으로 처리하거나, 심지어 테이블 접근 자체를 없애는 커버링 인덱스로 만들 수 있다.

시니어 백엔드 면접에서 "인덱스 최적화 경험"을 물어볼 때 단일 인덱스 이야기만 한다면 감점 요인이 된다. 복합 인덱스의 좌측 접두사 규칙, 선택도 기반 컬럼 순서 결정, EXPLAIN 해석, 커버링 인덱스 설계까지 설명할 수 있어야 실전 경험이 있다는 인상을 준다.


복합 인덱스의 내부 구조

복합 인덱스는 B+Tree 구조에서 여러 컬럼을 하나의 키로 결합하여 저장한다. 예를 들어 INDEX idx_user_status (user_id, status)를 생성하면 인덱스 리프 노드에는 (user_id, status) 쌍이 user_id 오름차순 → 동일 user_id 내에서 status 오름차순 으로 정렬되어 저장된다.

plaintext
leaf node 예시:
(1, 'ACTIVE')   → row pointer
(1, 'INACTIVE') → row pointer
(2, 'ACTIVE')   → row pointer
(2, 'ACTIVE')   → row pointer
(3, 'DELETED')  → row pointer

이 구조에서 핵심은 정렬 순서다. 인덱스는 첫 번째 컬럼을 기준으로 먼저 정렬되고, 그 안에서 두 번째 컬럼이 정렬된다. 세 번째 컬럼이 있다면 두 번째 컬럼 안에서 정렬된다.

이 물리적 구조가 좌측 접두사 규칙(Leftmost Prefix Rule) 의 근거다.


좌측 접두사 규칙 (Leftmost Prefix Rule)

INDEX idx (a, b, c)가 있다고 하자. 이 인덱스가 사용 가능한 조건은 항상 왼쪽 컬럼부터 순서대로 포함해야 한다.

쿼리 조건인덱스 사용 여부설명
WHERE a = 1사용 (a만)a로 범위 스캔 가능
WHERE a = 1 AND b = 2사용 (a, b)a로 좁히고 b로 추가 필터
WHERE a = 1 AND b = 2 AND c = 3사용 (a, b, c)세 컬럼 모두 활용
WHERE b = 2미사용a가 없으면 정렬 순서가 의미 없음
WHERE b = 2 AND c = 3미사용a가 누락
WHERE a = 1 AND c = 3부분 사용 (a만)b를 건너뜀, c는 인덱스 활용 안 됨

마지막 케이스가 가장 흔한 실수다. a와 c로 조건을 걸었을 때 a까지만 인덱스를 타고 c는 테이블에서 필터링한다. b를 인덱스에서 건너뛸 수 없기 때문이다.

범위 조건이 오면 그 뒤는 인덱스를 타지 않는다

이것이 복합 인덱스 설계에서 가장 중요한 실용 규칙이다.

sql
INDEX idx (a, b, c)
 
-- a = 동등, b = 범위 → c는 인덱스 미사용
WHERE a = 1 AND b > 10 AND c = 5

b > 10은 범위 조건이다. 인덱스에서 a = 1로 좁히고 b > 10으로 레인지 스캔은 하지만, 그 안에서 c는 다시 정렬 보장이 없다. b가 달라질 때마다 c의 순서가 다를 수 있기 때문이다.

실용적 결론: 동등 조건(=) 컬럼을 앞에, 범위 조건 컬럼을 뒤에 배치해야 인덱스를 최대한 활용한다.

sql
-- 좋은 설계: status(동등) 먼저, created_at(범위) 나중
INDEX idx (user_id, status, created_at)
WHERE user_id = 42 AND status = 'ACTIVE' AND created_at >= '2025-01-01'
-- → user_id, status, created_at 세 컬럼 모두 인덱스 활용
 
-- 나쁜 설계: created_at(범위)이 중간에 끼면
INDEX idx (user_id, created_at, status)
WHERE user_id = 42 AND status = 'ACTIVE' AND created_at >= '2025-01-01'
-- → user_id, created_at까지만 인덱스 활용, status는 필터링

선택도(Selectivity)와 컬럼 순서 결정

선택도는 유니크한 값의 수 / 전체 행 수다. 1에 가까울수록 선택도가 높다(성별은 낮고, UUID는 높다).

복합 인덱스에서 컬럼 순서를 결정할 때 선택도 외에도 쿼리 패턴을 반드시 고려해야 한다. 흔히 "선택도가 높은 컬럼을 앞에"라고 말하지만, 이것만 따르면 잘못된 설계가 된다.

잘못된 단순 법칙: "선택도 높은 것을 앞에"

sql
-- users 테이블: user_id(매우 높은 선택도), status(낮은 선택도)
-- 쿼리: WHERE status = 'ACTIVE' ORDER BY created_at
 
-- 단순 선택도 기준이면 user_id를 앞에 놓고 싶지만
-- 이 쿼리에 user_id 조건이 없다면 user_id를 앞에 놓으면 인덱스를 못 탄다

올바른 기준: 쿼리 패턴이 우선

실제 설계 순서는 다음과 같다.

  1. 항상 동등 조건(=)으로 사용되는 컬럼을 맨 앞에 배치
  2. 동등 조건 컬럼이 여럿이면 그 안에서 선택도가 높은 것을 앞으로
  3. 범위 조건 컬럼을 그 뒤에
  4. ORDER BY 컬럼을 범위 조건 뒤에 (또는 범위 조건 없으면 동등 조건 뒤에)

예시: 상품 목록 API, category_id = 5 AND status = 'ON_SALE' ORDER BY created_at DESC

sql
-- 나쁜 순서 (선택도만 고려):
INDEX idx (created_at, status, category_id)
 
-- 좋은 순서 (동등 조건 먼저, ORDER BY 마지막):
INDEX idx (category_id, status, created_at)

category_id = 5 AND status = 'ON_SALE'로 정확히 범위를 좁힌 뒤 created_at 정렬을 인덱스 순서로 처리할 수 있어서 filesort가 없어진다.


커버링 인덱스 (Covering Index)

커버링 인덱스는 쿼리가 필요로 하는 모든 컬럼이 인덱스에 포함되어 있어서 실제 테이블 행을 읽을 필요가 없는 인덱스다.

InnoDB에서 테이블 데이터는 클러스터드 인덱스(PK 기준)에 저장된다. 세컨더리 인덱스의 리프 노드에는 인덱스 컬럼 값과 함께 PK 값이 저장된다. 커버링 인덱스가 아닐 때는 세컨더리 인덱스로 PK를 얻은 뒤 클러스터드 인덱스를 다시 조회한다(이중 조회, 랜덤 I/O).

커버링 인덱스가 되면 세컨더리 인덱스만 읽고 끝나므로 I/O가 절반 이하로 줄어든다.

EXPLAIN에서 커버링 인덱스 확인

sql
EXPLAIN SELECT user_id, status, created_at
FROM orders
WHERE user_id = 42 AND status = 'ACTIVE';

Extra 컬럼에 Using index 가 나오면 커버링 인덱스다. Using index condition은 다르다 — 인덱스를 활용하지만 테이블 접근이 있다는 의미다.

커버링 인덱스 설계 예시

sql
-- orders 테이블의 자주 사용하는 목록 쿼리
SELECT order_id, user_id, status, total_amount, created_at
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20;
 
-- 이 쿼리를 커버링 인덱스로 처리하려면:
CREATE INDEX idx_orders_covering
ON orders (user_id, created_at, status, total_amount);
-- order_id는 PK이므로 세컨더리 인덱스에 자동 포함

주의: SELECT에 컬럼이 많아질수록 인덱스 크기가 커지고 INSERT/UPDATE 비용도 증가한다. 커버링 인덱스는 읽기가 압도적으로 많고 자주 실행되는 쿼리에 적용해야 효과적이다.


EXPLAIN으로 복합 인덱스 검증하기

이론을 알아도 실제로 쿼리 플랜을 보지 않으면 추측에 불과하다. EXPLAIN 출력에서 복합 인덱스와 관련된 핵심 컬럼은 다음과 같다.

컬럼의미
type접근 방식. ref, range는 인덱스 사용. ALL은 풀스캔
key실제 선택된 인덱스 이름
key_len사용된 인덱스 바이트 수. 몇 개 컬럼이 사용됐는지 역산 가능
ref인덱스와 비교되는 값의 출처
rows옵티마이저가 읽을 것으로 예상하는 행 수
ExtraUsing index(커버링), Using filesort(정렬 인덱스 미사용), Using where(테이블 필터)

key_len으로 컬럼 몇 개가 사용됐는지 확인

sql
CREATE TABLE orders (
    order_id    BIGINT       NOT NULL,
    user_id     BIGINT       NOT NULL,   -- 8 bytes
    status      VARCHAR(20)  NOT NULL,   -- 20 * 4 + 2 = 82 bytes (utf8mb4, nullable아니면 +0)
    created_at  DATETIME     NOT NULL,   -- 8 bytes
    PRIMARY KEY (order_id),
    INDEX idx (user_id, status, created_at)
);
 
EXPLAIN SELECT * FROM orders WHERE user_id = 42 AND status = 'ACTIVE';
-- key_len = 8 + 82 = 90 → user_id + status 두 컬럼 사용
 
EXPLAIN SELECT * FROM orders WHERE user_id = 42;
-- key_len = 8 → user_id 하나만 사용

VARCHAR에서 NULL 허용이면 +1 바이트, 문자셋 utf8mb4이면 최대 4배이므로 VARCHAR(20) NOT NULL utf8mb4 는 20*4 + 2(length prefix) = 82다.

EXPLAIN ANALYZE (MySQL 8.0.18+)

실행 계획뿐 아니라 실제 실행 통계까지 보여준다.

sql
EXPLAIN ANALYZE
SELECT order_id, user_id, status
FROM orders
WHERE user_id = 42 AND status = 'ACTIVE'
ORDER BY created_at DESC
LIMIT 10;

출력 예:

plaintext
-> Limit: 10 row(s)  (actual time=0.123..0.130 rows=10 loops=1)
    -> Index scan on orders using idx (reverse)  (cost=2.51 rows=10)
       (actual time=0.120..0.126 rows=10 loops=1)

actual time과 rows가 옵티마이저 예측과 크게 다르면 통계가 오래됐거나 인덱스 설계가 잘못된 것이다.


나쁜 예 vs 개선된 예

사례 1: 범위 조건 위치 실수

sql
-- 나쁜 예
CREATE INDEX idx_bad ON orders (user_id, created_at, status);
 
SELECT * FROM orders
WHERE user_id = 42
  AND created_at >= '2025-01-01'
  AND status = 'ACTIVE';
-- created_at 범위 이후 status는 인덱스 활용 안 됨
-- Extra: Using index condition, Using where
 
-- 개선된 예
CREATE INDEX idx_good ON orders (user_id, status, created_at);
-- status가 동등 조건이므로 앞에, created_at 범위 조건은 뒤에
-- user_id + status로 범위를 좁힌 뒤 created_at으로 레인지 스캔

사례 2: ORDER BY를 고려하지 않은 설계

sql
-- 나쁜 예: filesort 발생
CREATE INDEX idx_bad ON products (category_id, status);
 
SELECT product_id, name, price
FROM products
WHERE category_id = 10 AND status = 'ON_SALE'
ORDER BY price ASC;
-- Extra: Using index condition; Using filesort
 
-- 개선된 예: price를 인덱스에 포함
CREATE INDEX idx_good ON products (category_id, status, price);
-- Extra: Using index condition (filesort 없음)

사례 3: 커버링 인덱스 미활용

sql
-- 나쁜 예: SELECT에 필요한 컬럼이 인덱스에 없어서 테이블 접근
CREATE INDEX idx_bad ON users (status);
 
SELECT user_id, email, status FROM users WHERE status = 'ACTIVE';
-- type: ref, Extra: Using where
-- user_id(PK)는 자동 포함되지만 email이 없어서 테이블 접근 필요
 
-- 개선된 예: email을 인덱스에 추가
CREATE INDEX idx_good ON users (status, email);
-- SELECT user_id, email, status → 모두 인덱스에 있음
-- Extra: Using index (커버링 인덱스!)

MySQL 8 로컬 실습 환경

Docker로 MySQL 8 실행

bash
docker run --name mysql8-practice \
  -e MYSQL_ROOT_PASSWORD=practice \
  -e MYSQL_DATABASE=testdb \
  -p 3306:3306 \
  -d mysql:8.0

실습용 테이블 및 데이터 생성

sql
USE testdb;
 
CREATE TABLE orders (
    order_id   BIGINT       NOT NULL AUTO_INCREMENT,
    user_id    BIGINT       NOT NULL,
    status     VARCHAR(20)  NOT NULL DEFAULT 'PENDING',
    total_amt  DECIMAL(12,2) NOT NULL DEFAULT 0,
    created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (order_id)
) ENGINE=InnoDB;
 
-- 100만 건 데이터 생성 (프로시저 이용)
DELIMITER $$
CREATE PROCEDURE gen_orders(IN n INT)
BEGIN
    DECLARE i INT DEFAULT 0;
    DECLARE statuses VARCHAR(100) DEFAULT 'PENDING,ACTIVE,COMPLETED,CANCELLED,REFUNDED';
    WHILE i < n DO
        INSERT INTO orders (user_id, status, total_amt, created_at)
        VALUES (
            FLOOR(RAND() * 10000) + 1,
            ELT(FLOOR(RAND() * 5) + 1, 'PENDING', 'ACTIVE', 'COMPLETED', 'CANCELLED', 'REFUNDED'),
            ROUND(RAND() * 500000, 2),
            DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY)
        );
        SET i = i + 1;
    END WHILE;
END$$
DELIMITER ;
 
CALL gen_orders(1000000);
ANALYZE TABLE orders;

인덱스 없이 기준선 측정

sql
-- 기준선: 인덱스 없는 상태에서 실행 계획
EXPLAIN SELECT order_id, user_id, status, total_amt, created_at
FROM orders
WHERE user_id = 42 AND status = 'ACTIVE'
ORDER BY created_at DESC
LIMIT 20;
-- type: ALL, rows: ~1000000, Extra: Using where; Using filesort
 
-- 실제 시간 측정
SET profiling = 1;
SELECT order_id, user_id, status, total_amt, created_at
FROM orders
WHERE user_id = 42 AND status = 'ACTIVE'
ORDER BY created_at DESC LIMIT 20;
SHOW PROFILES;

복합 인덱스 추가 및 비교

sql
-- 인덱스 추가
CREATE INDEX idx_user_status_created
ON orders (user_id, status, created_at);
 
-- 동일 쿼리 다시 실행
EXPLAIN SELECT order_id, user_id, status, total_amt, created_at
FROM orders
WHERE user_id = 42 AND status = 'ACTIVE'
ORDER BY created_at DESC
LIMIT 20;
-- type: ref, key: idx_user_status_created, Extra: Using index condition
-- (total_amt가 인덱스에 없어서 커버링 인덱스 아님)
 
-- 커버링 인덱스 시도
CREATE INDEX idx_covering
ON orders (user_id, status, created_at, total_amt);
 
EXPLAIN SELECT order_id, user_id, status, total_amt, created_at
FROM orders
WHERE user_id = 42 AND status = 'ACTIVE'
ORDER BY created_at DESC
LIMIT 20;
-- Extra: Using index (커버링 인덱스 성공)

key_len으로 컬럼 사용 수 확인

sql
-- user_id만 사용
EXPLAIN SELECT * FROM orders WHERE user_id = 42;
-- key_len: 8 (BIGINT 8바이트)
 
-- user_id + status 사용
EXPLAIN SELECT * FROM orders WHERE user_id = 42 AND status = 'ACTIVE';
-- key_len: 8 + (20*4 + 2) = 90
-- VARCHAR(20) NOT NULL utf8mb4: 20*4 + 2바이트(length prefix) = 82
-- 8 + 82 = 90
 
-- user_id + status + created_at 사용
EXPLAIN SELECT * FROM orders
WHERE user_id = 42 AND status = 'ACTIVE' AND created_at >= '2025-01-01';
-- key_len: 90 + 5 = 95  (DATETIME NOT NULL: 5바이트)

범위 조건 위치에 따른 차이 확인

sql
-- 두 개의 인덱스를 만들어 비교
CREATE INDEX idx_range_middle
ON orders (user_id, created_at, status);  -- created_at 중간에
 
CREATE INDEX idx_range_last
ON orders (user_id, status, created_at);  -- created_at 마지막에
 
-- 동일 쿼리
EXPLAIN SELECT * FROM orders
WHERE user_id = 42
  AND status = 'ACTIVE'
  AND created_at >= '2025-01-01';
 
-- 옵티마이저가 idx_range_last를 선택하고 key_len이 더 크면 세 컬럼 활용
-- idx_range_middle 강제 사용시:
EXPLAIN SELECT * FROM orders USE INDEX (idx_range_middle)
WHERE user_id = 42
  AND status = 'ACTIVE'
  AND created_at >= '2025-01-01';
-- key_len이 작으면 status가 인덱스에서 사용 안 됨을 확인 가능

자주 저지르는 실수

실수 1: GROUP BY나 ORDER BY 컬럼을 인덱스 설계에서 빠뜨린다

쿼리에 ORDER BY가 있으면 마지막 컬럼이 ORDER BY 컬럼과 일치해야 filesort를 피할 수 있다. 특히 DESC 정렬이 필요하면 MySQL 8에서 CREATE INDEX idx (a, b DESC)처럼 내림차순 인덱스를 명시할 수 있다.

실수 2: 인덱스 컬럼에 함수를 적용한다

sql
-- 인덱스 무력화
WHERE YEAR(created_at) = 2025
WHERE LOWER(email) = 'test@example.com'
 
-- 인덱스 활용
WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01'
WHERE email = 'test@example.com'  -- 애플리케이션에서 소문자 변환 후 저장

실수 3: 암묵적 타입 변환

sql
-- user_id가 BIGINT인데 문자열로 비교하면 인덱스를 못 탄다
WHERE user_id = '42'   -- 문자열 '42'를 BIGINT로 캐스팅 → 인덱스 무력화 가능
 
-- 반드시 타입을 맞춰서
WHERE user_id = 42

Java/Spring에서 String을 Long 타입 PK 컬럼에 바인딩할 때 이런 문제가 생길 수 있다. JPA에서 파라미터 타입을 정확히 지정해야 한다.

실수 4: 인덱스를 많이 만들면 항상 좋다고 생각한다

인덱스가 많을수록 INSERT/UPDATE/DELETE 시 모든 인덱스를 갱신해야 하므로 쓰기 비용이 증가한다. 또한 OPTIMIZE TABLE 시간과 버퍼 풀 사용량도 늘어난다. 인덱스는 읽기 패턴과 쓰기 빈도를 균형 있게 고려해서 꼭 필요한 것만 만들어야 한다.

실수 5: FORCE INDEX를 남용한다

FORCE INDEX는 옵티마이저를 무시하므로 통계가 갱신되거나 데이터 분포가 변하면 오히려 더 느려질 수 있다. 옵티마이저 선택이 잘못됐다면 ANALYZE TABLE로 통계를 갱신하거나 인덱스 자체를 재설계해야 한다.


시니어 백엔드 면접 답변 프레임

예상 질문: "복합 인덱스 설계 시 컬럼 순서를 어떻게 결정하나요?"

잘못된 답변: "선택도가 높은 컬럼을 앞에 놓습니다."

좋은 답변 구조:

"컬럼 순서는 쿼리 패턴을 기준으로 결정합니다. 첫째로 동등 조건(=)으로 사용되는 컬럼을 앞에 배치합니다. 동등 조건 컬럼이 여럿이면 그 안에서 선택도가 높은 것을 앞으로 당깁니다. 둘째로 범위 조건 컬럼을 그 뒤에 배치합니다. 범위 조건 이후 컬럼은 인덱스 레인지 스캔에서 활용되지 않기 때문입니다. 마지막으로 ORDER BY 컬럼을 범위 조건 뒤에 배치하면 filesort를 피할 수 있습니다. 실무에서는 이 이론적 순서를 정한 뒤 EXPLAIN으로 key_len과 Extra 컬럼을 확인해서 실제로 의도한 대로 인덱스가 사용되는지 반드시 검증합니다."

예상 질문: "커버링 인덱스는 언제 사용하나요?"

"커버링 인덱스는 읽기 빈도가 매우 높고 SELECT 컬럼이 고정적인 쿼리에 적용합니다. InnoDB에서 세컨더리 인덱스는 리프 노드에 PK를 포함하므로 SELECT 대상 컬럼이 모두 인덱스에 있으면 클러스터드 인덱스 재조회(랜덤 I/O)가 없어집니다. EXPLAIN의 Extra에 Using index가 나오면 커버링 인덱스가 적용된 것입니다. 다만 인덱스에 컬럼이 많아질수록 쓰기 비용과 인덱스 크기가 증가하므로, 트래픽이 높은 조회 API에 한정해 적용하고 쓰기 비용 증가를 모니터링해야 합니다."

예상 질문: "N+1 문제와 인덱스는 어떤 관계인가요?"

"N+1 자체는 애플리케이션 레벨 문제이지만, 인덱스 설계가 N+1 임팩트를 크게 다르게 만듭니다. N+1로 N번의 쿼리가 발생할 때 각 쿼리가 인덱스를 타지 않으면 풀스캔이 N번 일어납니다. 반대로 FK 컬럼에 복합 인덱스가 있고 커버링 인덱스로 설계되면 N번의 쿼리가 모두 빠르게 처리됩니다. 따라서 페치 조인이나 배치 로딩으로 N+1을 해결하는 것이 우선이지만, 해결하기 어려운 케이스라면 인덱스 설계로 피해를 최소화할 수 있습니다."


체크리스트

  • 쿼리의 WHERE 조건에서 동등 조건 컬럼을 인덱스 앞부분에 배치했는가
  • 범위 조건 컬럼이 동등 조건 컬럼보다 뒤에 있는가
  • ORDER BY 컬럼이 인덱스 마지막에 포함되어 filesort를 피하는가
  • EXPLAIN의 type이 ALL이 아닌지 확인했는가
  • key_len을 계산해서 의도한 컬럼 수만큼 인덱스가 사용되는지 확인했는가
  • 커버링 인덱스가 필요한 쿼리에서 EXPLAIN Extra에 Using index가 나오는가
  • 인덱스 컬럼에 함수 적용이나 암묵적 타입 변환이 없는가
  • 인덱스 수가 테이블의 쓰기 빈도와 균형을 이루는가
  • ANALYZE TABLE을 실행해서 통계가 최신 상태인가
  • 운영 환경에 인덱스 추가 시 ALTER TABLE ... ALGORITHM=INPLACE, LOCK=NONE으로 온라인 DDL 사용 여부를 확인했는가
on this page
  • 01왜 복합 인덱스를 깊게 알아야 하는가
  • 02복합 인덱스의 내부 구조
  • 03좌측 접두사 규칙 (Leftmost Prefix Rule)
  • 범위 조건이 오면 그 뒤는 인덱스를 타지 않는다
  • 04선택도(Selectivity)와 컬럼 순서 결정
  • 잘못된 단순 법칙: "선택도 높은 것을 앞에"
  • 올바른 기준: 쿼리 패턴이 우선
  • 05커버링 인덱스 (Covering Index)
  • EXPLAIN에서 커버링 인덱스 확인
  • 커버링 인덱스 설계 예시
  • 06EXPLAIN으로 복합 인덱스 검증하기
  • key_len으로 컬럼 몇 개가 사용됐는지 확인
  • EXPLAIN ANALYZE (MySQL 8.0.18+)
  • 07나쁜 예 vs 개선된 예
  • 사례 1: 범위 조건 위치 실수
  • 사례 2: ORDER BY를 고려하지 않은 설계
  • 사례 3: 커버링 인덱스 미활용
  • 08MySQL 8 로컬 실습 환경
  • Docker로 MySQL 8 실행
  • 실습용 테이블 및 데이터 생성
  • 인덱스 없이 기준선 측정
  • 복합 인덱스 추가 및 비교
  • key_len으로 컬럼 사용 수 확인
  • 범위 조건 위치에 따른 차이 확인
  • 09자주 저지르는 실수
  • 10시니어 백엔드 면접 답변 프레임
  • 예상 질문: "복합 인덱스 설계 시 컬럼 순서를 어떻게 결정하나요?"
  • 예상 질문: "커버링 인덱스는 언제 사용하나요?"
  • 예상 질문: "N+1 문제와 인덱스는 어떤 관계인가요?"
  • 11체크리스트

댓글 (0)