데이터베이스의 성능을 좌우하는 가장 중요한 요소 중 하나가 바로 인덱스입니다. 이 글에서는 인덱스의 동작 원리부터 실전 최적화 기법까지 단계별로 알아봅니다.

1. 인덱스란?

인덱스는 데이터베이스 테이블의 검색 속도를 높이기 위한 자료구조입니다. 책의 색인(index)처럼 특정 데이터를 빠르게 찾을 수 있도록 도와줍니다.

B-Tree 구조

대부분의 RDBMS는 B-Tree(Balanced Tree) 구조를 사용합니다.

        [50]
       /    \
    [25]    [75]
   /   \    /   \
[10] [30] [60] [90]

B-Tree의 특징:

  • 균형 잡힌 트리 구조로 탐색 시간이 O(log N)
  • 루트 노드에서 시작해 리프 노드까지 탐색
  • 데이터가 정렬된 상태로 유지됨

왜 빠른가?

-- 인덱스 없을 때 (Full Table Scan)
-- 100만 건 중 1건 찾기: 100만 번 비교

-- 인덱스 있을 때 (Index Scan)
-- B-Tree 깊이가 4라면: 4번만 비교

인덱스는 전체 스캔을 피하고 필요한 데이터만 빠르게 찾을 수 있게 해줍니다.

2. 인덱스 종류

단일 인덱스 (Single Column Index)

하나의 컬럼에 대한 인덱스입니다.

CREATE INDEX idx_user_email ON users(email);

복합 인덱스 (Composite Index)

여러 컬럼을 조합한 인덱스입니다. 컬럼 순서가 중요합니다.

CREATE INDEX idx_user_name_age ON users(name, age);

-- 사용됨
SELECT * FROM users WHERE name = '홍길동' AND age = 30;
SELECT * FROM users WHERE name = '홍길동';  -- 첫 번째 컬럼만 사용

-- 사용 안됨 (첫 번째 컬럼이 WHERE에 없음)
SELECT * FROM users WHERE age = 30;

복합 인덱스 규칙:

  • 왼쪽부터 순서대로 사용됨 (Leftmost Prefix Rule)
  • 등호(=) 조건이 많은 컬럼을 앞에 배치
  • 범위 조건(>, <, BETWEEN) 컬럼은 뒤에 배치

유니크 인덱스 (Unique Index)

중복을 허용하지 않는 인덱스입니다.

CREATE UNIQUE INDEX idx_user_email ON users(email);

-- PK는 자동으로 유니크 인덱스 생성됨

커버링 인덱스 (Covering Index)

쿼리에 필요한 모든 컬럼을 인덱스가 포함하여 테이블 접근 없이 결과를 반환합니다.

-- 인덱스: (name, age, email)
SELECT name, age, email
FROM users
WHERE name = '홍길동';

-- 인덱스만으로 모든 데이터 조회 가능 (Using index)

3. 인덱스 생성/삭제

인덱스 생성

-- 기본 인덱스
CREATE INDEX idx_name ON users(name);

-- 복합 인덱스
CREATE INDEX idx_name_age ON users(name, age);

-- 유니크 인덱스
CREATE UNIQUE INDEX idx_email ON users(email);

-- 내림차순 인덱스
CREATE INDEX idx_created_desc ON posts(created_at DESC);

인덱스 조회

-- MySQL
SHOW INDEX FROM users;

-- PostgreSQL
\d users

인덱스 삭제

DROP INDEX idx_name ON users;  -- MySQL
DROP INDEX idx_name;            -- PostgreSQL

4. EXPLAIN - 실행 계획 읽는 법

실행 계획을 확인하여 쿼리가 인덱스를 제대로 사용하는지 확인합니다.

EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';

MySQL 실행 계획 주요 항목

+----+-------------+-------+------+---------------+------+---------+-------+------+-------+
| id | select_type | table | type | possible_keys | key  | key_len | ref   | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+-------+------+-------+

type (접근 방법) - 중요도 순:

type설명성능
constPK 또는 Unique 인덱스로 단건 조회⭐⭐⭐⭐⭐
eq_refJOIN 시 PK/Unique로 접근⭐⭐⭐⭐⭐
ref일반 인덱스로 여러 건 조회⭐⭐⭐⭐
range범위 조건 (BETWEEN, >, <)⭐⭐⭐
index인덱스 풀 스캔⭐⭐
ALL테이블 풀 스캔

Extra 항목:

-- 좋은 케이스
Using index       -- 커버링 인덱스 사용
Using where       -- WHERE 조건 필터링

-- 나쁜 케이스
Using filesort    -- 별도 정렬 작업 발생
Using temporary   -- 임시 테이블 생성

실습 예제

-- 테스트 데이터
CREATE TABLE orders (
  id INT PRIMARY KEY,
  user_id INT,
  status VARCHAR(20),
  created_at DATETIME
);

-- 인덱스 없을 때
EXPLAIN SELECT * FROM orders WHERE user_id = 100;
-- type: ALL (테이블 풀 스캔)

-- 인덱스 생성
CREATE INDEX idx_user_id ON orders(user_id);

-- 인덱스 있을 때
EXPLAIN SELECT * FROM orders WHERE user_id = 100;
-- type: ref (인덱스 사용)

5. 인덱스가 동작하지 않는 경우

인덱스를 만들어도 아래 경우엔 사용되지 않습니다.

1) 컬럼에 함수 적용

-- ❌ 인덱스 사용 안됨
SELECT * FROM users WHERE YEAR(created_at) = 2024;
SELECT * FROM users WHERE UPPER(name) = 'HONG';

-- ✅ 인덱스 사용됨
SELECT * FROM users
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';

SELECT * FROM users WHERE name = 'Hong';  -- 대소문자 구분 설정 확인

2) 타입 변환

-- user_id는 INT 타입
-- ❌ 문자열로 비교 (타입 변환 발생)
SELECT * FROM users WHERE user_id = '100';

-- ✅ 동일한 타입으로 비교
SELECT * FROM users WHERE user_id = 100;

3) LIKE 앞에 와일드카드

-- ❌ 인덱스 사용 안됨
SELECT * FROM users WHERE name LIKE '%홍길동';
SELECT * FROM users WHERE name LIKE '%홍%';

-- ✅ 인덱스 사용됨
SELECT * FROM users WHERE name LIKE '홍길동%';

4) OR 조건

-- ❌ 인덱스 사용 제한적
SELECT * FROM users WHERE name = '홍길동' OR age = 30;

-- ✅ UNION 사용 (각각 인덱스 활용)
SELECT * FROM users WHERE name = '홍길동'
UNION
SELECT * FROM users WHERE age = 30;

5) 복합 인덱스 순서 위반

-- 인덱스: (name, age, city)

-- ✅ 사용됨
SELECT * FROM users WHERE name = '홍길동';
SELECT * FROM users WHERE name = '홍길동' AND age = 30;

-- ❌ 사용 안됨 (첫 번째 컬럼 없음)
SELECT * FROM users WHERE age = 30;
SELECT * FROM users WHERE city = '서울';

6. 쿼리 최적화 기법

1) SELECT * 지양

-- ❌ 불필요한 데이터까지 조회
SELECT * FROM users WHERE id = 1;

-- ✅ 필요한 컬럼만 조회
SELECT id, name, email FROM users WHERE id = 1;

이유:

  • 네트워크 트래픽 감소
  • 메모리 사용량 감소
  • 커버링 인덱스 활용 가능

2) 적절한 JOIN

-- INNER JOIN: 양쪽 테이블에 모두 존재하는 데이터
SELECT u.name, o.order_date
FROM users u
INNER JOIN orders o ON u.id = o.user_id;

-- LEFT JOIN: 왼쪽 테이블 기준, 오른쪽은 NULL 허용
SELECT u.name, o.order_date
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;

최적화 팁:

  • JOIN 컬럼에 인덱스 생성
  • 작은 테이블을 먼저 조인 (Driving Table)
  • WHERE 조건으로 먼저 필터링 후 JOIN

3) 페이징 최적화

OFFSET 방식 (전통적)

-- 1페이지 (빠름)
SELECT * FROM posts ORDER BY id DESC LIMIT 10 OFFSET 0;

-- 1000페이지 (느림 - 10000건을 읽고 버림)
SELECT * FROM posts ORDER BY id DESC LIMIT 10 OFFSET 10000;

문제점: OFFSET이 클수록 읽어야 할 데이터가 많아져 느려짐

Cursor 방식 (최적화)

-- 첫 페이지
SELECT * FROM posts ORDER BY id DESC LIMIT 10;

-- 다음 페이지 (마지막 id = 100)
SELECT * FROM posts
WHERE id < 100
ORDER BY id DESC
LIMIT 10;

장점:

  • 항상 일정한 성능
  • 인덱스 스캔만으로 처리 가능

7. N+1 문제와 해결법

N+1 문제란?

-- 1. 사용자 목록 조회 (1번 쿼리)
SELECT * FROM users LIMIT 10;

-- 2. 각 사용자의 주문 조회 (N번 쿼리)
-- user_id = 1
SELECT * FROM orders WHERE user_id = 1;
-- user_id = 2
SELECT * FROM orders WHERE user_id = 2;
-- ... (10번 반복)

-- 총 11번의 쿼리 발생

해결법 1: JOIN 사용

-- 1번의 쿼리로 해결
SELECT u.*, o.*
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.id IN (1, 2, 3, ..., 10);

해결법 2: IN 절 사용

-- 1. 사용자 목록 조회
SELECT * FROM users LIMIT 10;  -- id: 1, 2, 3, ..., 10

-- 2. 해당 사용자들의 주문 일괄 조회
SELECT * FROM orders WHERE user_id IN (1, 2, 3, ..., 10);

-- 총 2번의 쿼리

JPA/Hibernate 최적화

// ❌ N+1 발생
@Entity
public class User {
    @OneToMany(mappedBy = "user")
    private List<Order> orders;
}

// ✅ Fetch Join
@Query("SELECT u FROM User u JOIN FETCH u.orders")
List<User> findAllWithOrders();

// ✅ Batch Size 설정
@BatchSize(size = 10)
@OneToMany(mappedBy = "user")
private List<Order> orders;

8. 트랜잭션과 격리 수준

격리 수준 (Isolation Level)

격리 수준은 동시에 실행되는 트랜잭션 간 데이터 일관성을 제어합니다.

격리 수준Dirty ReadNon-Repeatable ReadPhantom Read
READ UNCOMMITTED발생발생발생
READ COMMITTED방지발생발생
REPEATABLE READ방지방지발생
SERIALIZABLE방지방지방지

1) READ UNCOMMITTED

커밋되지 않은 데이터를 읽을 수 있습니다 (거의 사용 안 함).

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

-- Transaction A: UPDATE 후 커밋 전
UPDATE users SET balance = 1000 WHERE id = 1;

-- Transaction B: 커밋 안 된 데이터 읽음 (Dirty Read)
SELECT balance FROM users WHERE id = 1;  -- 1000

2) READ COMMITTED (MySQL InnoDB 기본)

커밋된 데이터만 읽습니다.

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

START TRANSACTION;
SELECT balance FROM users WHERE id = 1;  -- 500

-- 다른 트랜잭션에서 UPDATE & COMMIT
-- UPDATE users SET balance = 1000 WHERE id = 1; COMMIT;

SELECT balance FROM users WHERE id = 1;  -- 1000 (변경됨)
COMMIT;

Non-Repeatable Read 발생: 같은 트랜잭션 내에서 같은 데이터를 두 번 읽었을 때 값이 다름

3) REPEATABLE READ (MySQL 기본)

트랜잭션 시작 시점의 스냅샷을 읽습니다.

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

START TRANSACTION;
SELECT balance FROM users WHERE id = 1;  -- 500

-- 다른 트랜잭션에서 UPDATE & COMMIT
-- UPDATE users SET balance = 1000 WHERE id = 1; COMMIT;

SELECT balance FROM users WHERE id = 1;  -- 500 (일관성 유지)
COMMIT;

4) SERIALIZABLE

가장 엄격한 격리 수준. 모든 SELECT에 Lock이 걸립니다.

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

START TRANSACTION;
SELECT * FROM users WHERE age BETWEEN 20 AND 30;

-- 다른 트랜잭션에서 INSERT 시도 (대기)
-- INSERT INTO users (age) VALUES (25);  -- 블로킹됨

격리 수준 선택 기준

-- 실시간 집계/통계 (속도 중요)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 일반 서비스 (기본값 사용)
-- MySQL: REPEATABLE READ
-- PostgreSQL: READ COMMITTED

-- 금융 거래 (정확성 중요)
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

9. 실전 최적화 사례

문제 상황

사용자 주문 목록 조회가 5초 이상 걸립니다.

-- 초기 쿼리 (5초 소요)
SELECT
    o.*,
    u.name,
    u.email,
    p.name as product_name,
    p.price
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LEFT JOIN order_items oi ON o.id = oi.order_id
LEFT JOIN products p ON oi.product_id = p.id
WHERE o.created_at >= '2024-01-01'
ORDER BY o.created_at DESC
LIMIT 20;

1단계: 실행 계획 확인

EXPLAIN 위의 쿼리;

-- 결과:
-- orders: type=ALL (Full Table Scan)
-- users: type=ALL
-- order_items: type=ALL
-- products: type=ALL

문제: 모든 테이블이 풀 스캔

2단계: 인덱스 추가

-- orders 테이블
CREATE INDEX idx_orders_created ON orders(created_at);

-- JOIN 컬럼들
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_order_items_product_id ON order_items(product_id);

결과: 5초 → 2초

3단계: 불필요한 JOIN 제거

-- 실제로 product 정보는 나중에 별도로 조회해도 됨
SELECT
    o.*,
    u.name,
    u.email
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.created_at >= '2024-01-01'
ORDER BY o.created_at DESC
LIMIT 20;

-- 필요시 product 정보는 애플리케이션에서 조회

결과: 2초 → 0.5초

4단계: 커버링 인덱스 적용

-- 자주 조회하는 컬럼만 포함한 인덱스
CREATE INDEX idx_orders_covering
ON orders(created_at, user_id, id, status);

-- 쿼리 수정 (최소 컬럼만 SELECT)
SELECT
    o.id,
    o.created_at,
    o.status,
    o.user_id
FROM orders o
WHERE o.created_at >= '2024-01-01'
ORDER BY o.created_at DESC
LIMIT 20;

-- 사용자 정보는 IN 절로 일괄 조회
SELECT id, name, email
FROM users
WHERE id IN (/* 위에서 조회한 user_id 목록 */);

결과: 0.5초 → 0.1초

5단계: 페이징 최적화 (Cursor 방식)

-- 첫 페이지
SELECT
    o.id,
    o.created_at,
    o.status,
    o.user_id
FROM orders o
WHERE o.created_at >= '2024-01-01'
ORDER BY o.created_at DESC
LIMIT 20;

-- 다음 페이지 (마지막 created_at = '2024-06-15 10:30:00')
SELECT
    o.id,
    o.created_at,
    o.status,
    o.user_id
FROM orders o
WHERE o.created_at >= '2024-01-01'
  AND o.created_at < '2024-06-15 10:30:00'
ORDER BY o.created_at DESC
LIMIT 20;

최종 결과: 5초 → 0.1초 (50배 개선)

최적화 체크리스트

쿼리 성능 문제가 생겼을 때 순서대로 확인하세요:

  1. EXPLAIN으로 실행 계획 확인

    • type이 ALL인가? → 인덱스 추가
    • Extra에 filesort, temporary가 있는가? → 쿼리 개선
  2. 인덱스 확인

    • WHERE 조건 컬럼에 인덱스가 있는가?
    • JOIN 컬럼에 인덱스가 있는가?
    • 복합 인덱스 순서가 적절한가?
  3. 쿼리 패턴 개선

    • SELECT *를 사용하고 있는가?
    • 불필요한 JOIN이 있는가?
    • N+1 문제가 발생하는가?
  4. 페이징 방식

    • OFFSET이 큰가? → Cursor 방식으로 변경
  5. 데이터량 확인

    • 테이블 row 수가 너무 많은가? → 파티셔닝 고려
    • 오래된 데이터인가? → 아카이빙 고려

마무리

인덱스와 쿼리 최적화는 측정 → 분석 → 개선 → 검증의 반복입니다.

  • EXPLAIN으로 현재 상태를 정확히 파악하고
  • 병목 지점을 찾아 인덱스를 추가하며
  • 쿼리 패턴을 개선하고
  • 다시 측정하여 개선 효과를 확인하세요

성능 튜닝의 핵심은 추측이 아닌 데이터 기반의 개선입니다.