본문으로 건너뛰기
Previous
Next
SQL 쿼리 최적화 실전 가이드 | 인덱스·실행 계획

SQL 쿼리 최적화 실전 가이드 | 인덱스·실행 계획

SQL 쿼리 최적화 실전 가이드 | 인덱스·실행 계획

이 글의 핵심

SQL 쿼리 최적화 실전 가이드에 대해 정리한 개발 블로그 글입니다. > TL;DR: SQL 쿼리 성능을 10배 향상시키는 방법을 배웁니다. 인덱스 설계부터 EXPLAIN 분석, N+1 문제 해결까지 실무에서 바로 쓸 수 있는 최적화 기법을 마스터합니다. 이 글을 읽으면: - ✅ 인덱스 설계… 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 구성했습니다. 관련…

🎯 이 글을 읽으면 (읽는 시간: 30분)

TL;DR: SQL 쿼리 성능을 10배 향상시키는 방법을 배웁니다. 인덱스 설계부터 EXPLAIN 분석, N+1 문제 해결까지 실무에서 바로 쓸 수 있는 최적화 기법을 마스터합니다. 이 글을 읽으면:

  • ✅ 인덱스 설계 원칙과 실전 적용 방법 마스터
  • ✅ EXPLAIN으로 쿼리 실행 계획 분석 능력 습득
  • ✅ N+1 문제, 조인 최적화 등 안티패턴 해결
  • ✅ 실전 최적화 사례로 성능 개선 노하우 습득 실무 활용:
  • 🔥 느린 쿼리 10배 빠르게 만들기
  • 🔥 데이터베이스 부하 감소 (서버 비용 절감)
  • 🔥 대용량 데이터 처리 최적화
  • 🔥 API 응답 시간 개선 난이도: 중급 | 실습 예제: 15개 | 성능 비교: 포함

이 글의 핵심

한 번의 스캔을 줄이는 일이 곧 비용을 줄이는 일이다. 인덱스·EXPLAIN·대표적인 안티패턴(N+1 등)을 중심으로 짚는다.

사전 지식 (초보자를 위한 기초)

1. 데이터베이스 테이블 구조

테이블은 행과 열로 데이터를 저장하는 단위다.

-- users 테이블
CREATE TABLE users (
  id INT PRIMARY KEY,      -- 고유 번호
  name VARCHAR(100),       -- 이름
  email VARCHAR(100),      -- 이메일
  created_at TIMESTAMP     -- 가입일
);
-- 데이터 예시
+----+--------+-------------------+---------------------+
| id | name   | email             | created_at          |
+----+--------+-------------------+---------------------+
|  1 | Alice  | [email protected]  | 2026-01-01 10:00:00 |
|  2 | Bob    | [email protected]    | 2026-01-02 11:00:00 |
|  3 | Carol  | [email protected] | 2026-01-03 12:00:00 |
+----+--------+-------------------+---------------------+

2. 기본 SQL 쿼리

-- 데이터 조회 (SELECT)
SELECT name, email FROM users WHERE id = 1;
-- 데이터 삽입 (INSERT)
INSERT INTO users (name, email) VALUES ('Dave', '[email protected]');
-- 데이터 수정 (UPDATE)
UPDATE users SET name = 'David' WHERE id = 4;
-- 데이터 삭제 (DELETE)
DELETE FROM users WHERE id = 4;

3. 쿼리 실행 과정

1. 쿼리 작성
   SELECT * FROM users WHERE email = '[email protected]';
2. 데이터베이스가 실행
   - 테이블 스캔 (모든 행 확인)
   - 조건에 맞는 행 찾기
   - 결과 반환
3. 문제: 데이터가 100만 개면?
   - 100만 행을 훑게 될 수 있다
   - 인덱스 없으면 수 초~수 분 걸림

4. 인덱스란?

인덱스는 책 목차처럼 찾을 위치를 바로 잡게 해 준다.

인덱스 없이 찾기:
- 책의 첫 페이지부터 끝까지 읽으며 찾기
- 1000페이지 책 → 평균 500페이지 읽어야 함
인덱스로 찾기:
- 목차에서 페이지 번호 확인
- 해당 페이지로 바로 이동
- 1000페이지 책 → 1초 만에 찾음!

SQL 인덱스:

-- 인덱스 생성
CREATE INDEX idx_email ON users(email);
-- 이제 email로 검색 시 빠름!
SELECT * FROM users WHERE email = '[email protected]';
-- 100만 건 → 0.001초

1. 쿼리 최적화가 중요한 이유

느린 쿼리의 영향

쿼리 실행 시간: 5초
사용자 1명: 5초 대기 (불편)
사용자 100명: 서버 과부하
사용자 1000명: 서버에 부하 집중
최적화 후: 0.05초
→ 100배 빠름!
→ 1000명이 동시 접속해도 문제 없음

실제 사례

최적화 전:

-- 실행 시간: 8.5초
SELECT * FROM orders 
WHERE user_id = 123 
  AND created_at >= '2026-01-01';

최적화 후:

-- 인덱스 추가
CREATE INDEX idx_user_created ON orders(user_id, created_at);
-- 실행 시간: 0.02초 (425배 빠름!)
SELECT * FROM orders 
WHERE user_id = 123 
  AND created_at >= '2026-01-01';

2. 인덱스 기초

인덱스 동작 원리

Full Table Scan (인덱스 없음)

SELECT * FROM users WHERE email = '[email protected]';
┌────┬───────┬──────────────────┐
│ id │ name  │ email            │
├────┼───────┼──────────────────┤
│ 1  │ Alice │ [email protected] │ ← 확인
│ 2  │ Bob   │ [email protected]   │ ← 확인
│ 3  │ Carol │ [email protected]│ ← 확인
│....│ ....  │ ....             │ ← 확인
│100만│ ....  │ ....             │ ← 확인
모든 행을 확인! (느림)

성능:
- 시간 복잡도: O(n)
- 100만 행: 평균 50만 행 읽음
- 디스크 I/O: 수천 번

B+Tree 인덱스 내부 메커니즘:

B+Tree 구조 (MySQL/PostgreSQL 인덱스):

                     [Root Node]
                   [m] [t] [z]
                 /   |    |    \
              /     |    |      \
           /       |    |        \
     [Internal Node] [Internal Node] [Internal Node]
     [a] [d] [g]     [m] [p] [s]     [t] [w] [z]
      ↓   ↓   ↓       ↓   ↓   ↓       ↓   ↓   ↓
   [Leaf Node]      [Leaf Node]      [Leaf Node]
   alice@.. → Row 1  mike@.. → Row 5  tom@.. → Row 9
   bob@.. → Row 2    peter@.. → Row 6 will@.. → Row 10
   carol@.. → Row 3  sam@.. → Row 7   zoe@.. → Row 11
   dave@.. → Row 4   → next leaf →     → null

B+Tree 특징:
1. 모든 데이터는 리프 노드에만 저장
2. 리프 노드는 연결 리스트 (범위 검색 빠름)
3. 내부 노드는 키만 저장 (검색 경로)
4. 균형 트리 (모든 리프 노드 깊이 동일)

검색 과정 (email = '[email protected]'):

1. Root Node 읽기 (Disk I/O #1):
   mike < m < t < z
   → 왼쪽 서브트리로

2. Internal Node 읽기 (Disk I/O #2):
   m < mike < p
   → 중간 서브트리로

3. Leaf Node 읽기 (Disk I/O #3):
   [mike@.., peter@.., sam@..]
   → mike@.. 발견!
   → Row Pointer (Row 5) 반환

4. 데이터 행 읽기 (Disk I/O #4):
   Row 5 데이터 페이지 읽기
   → 결과 반환

성능:
- 시간 복잡도: O(log n)
- 100만 행: 약 3~4번 디스크 I/O
- 1억 행: 약 5~6번 디스크 I/O

Full Scan vs Index Scan:
100만 행:
  Full Scan: 100만 번 비교
  Index Scan: 20번 비교 (50,000배 빠름!)

B+Tree vs B-Tree (차이):
B-Tree:
  - 모든 노드에 데이터 저장
  - 범위 검색 비효율적

B+Tree:
  - 리프 노드에만 데이터
  - 리프 노드 연결 (범위 검색 최적화)
  - 내부 노드 작아짐 → 메모리 효율

인덱스 페이지 구조:

MySQL InnoDB 페이지 (16KB):

┌────────────────────────────────┐
│ Page Header (38 bytes)         │
│ - Page Number                  │
│ - LSN (Log Sequence Number)    │
│ - Page Type (INDEX, DATA)      │
└────────────────────────────────┘
┌────────────────────────────────┐
│ Infimum Record (최소값 마커)   │
└────────────────────────────────┘
┌────────────────────────────────┐
│ User Records                   │
│ [alice@..] → Pointer to Row 1  │
│ [bob@..] → Pointer to Row 2    │
│ [carol@..] → Pointer to Row 3  │
│ ...                            │
└────────────────────────────────┘
┌────────────────────────────────┐
│ Supremum Record (최대값 마커)  │
└────────────────────────────────┘
┌────────────────────────────────┐
│ Page Directory                 │
│ - Record Offsets               │
└────────────────────────────────┘
┌────────────────────────────────┐
│ File Trailer (8 bytes)         │
│ - Checksum                     │
└────────────────────────────────┘

한 페이지에 들어가는 레코드 수:
- 평균 키 크기: 50 bytes
- Row Pointer: 6 bytes
- 16KB 페이지
→ 약 200~300개 레코드

B+Tree 높이 계산:
1억 행 테이블:
- Leaf Nodes: 100,000,000 / 250 = 400,000
- Height 3 B+Tree:
  * Root: 1 노드
  * Internal: 400 노드
  * Leaf: 400,000 노드
- 검색: 3~4 I/O

클러스터드 인덱스 vs 논클러스터드 인덱스:

Clustered Index (Primary Key):
데이터가 인덱스 순서로 저장

[B+Tree Leaf Nodes = 실제 데이터]
Leaf Node 1: [Row 1] [Row 2] [Row 3]
Leaf Node 2: [Row 4] [Row 5] [Row 6]

장점:
- Range Scan 빠름 (데이터가 순차적)
- 추가 디스크 I/O 불필요

단점:
- 테이블당 1개만 가능
- INSERT 느림 (정렬 유지 필요)

Non-Clustered Index (Secondary Index):
인덱스와 데이터 분리

[B+Tree Leaf Nodes = Pointer]
Leaf Node 1: [email] → Primary Key 1
Leaf Node 2: [email] → Primary Key 2

검색 과정:
1. 인덱스 트리 탐색
2. Primary Key 획득
3. 클러스터드 인덱스 탐색
4. 데이터 행 읽기

→ 2번의 B+Tree 탐색 필요!

Index Scan (인덱스 있음)

CREATE INDEX idx_email ON users(email);
인덱스 (B+Tree 구조):
              [email protected]
            /              \
    [email protected]        [email protected]
   /        \            /        \
[email protected]@.... [email protected]@...

이진 탐색으로 빠르게 찾음!
100만 건 → 약 20번 비교로 찾음

인덱스 생성

-- 단일 컬럼 인덱스
CREATE INDEX idx_email ON users(email);
-- 복합 인덱스 (여러 컬럼)
CREATE INDEX idx_user_created ON orders(user_id, created_at);
-- 유니크 인덱스
CREATE UNIQUE INDEX idx_email_unique ON users(email);
-- 인덱스 삭제
DROP INDEX idx_email ON users;
-- 인덱스 목록 확인
SHOW INDEX FROM users;

인덱스 사용 예시

-- ✅ 인덱스 사용 (빠름)
SELECT * FROM users WHERE email = '[email protected]';
-- ❌ 인덱스 사용 안됨 (느림)
SELECT * FROM users WHERE LOWER(email) = '[email protected]';
-- 이유: 함수 사용 시 인덱스 무효화
-- ✅ 해결: 함수 기반 인덱스
CREATE INDEX idx_email_lower ON users(LOWER(email));

3. EXPLAIN으로 쿼리 분석

EXPLAIN이란?

EXPLAIN은 옵티마이저가 어떤 순서로 읽을지 보여 주는 명령이다.

EXPLAIN SELECT * FROM users WHERE email = '[email protected]';

EXPLAIN 결과 읽기

MySQL 예시:

+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key  | key_len | ref  | rows | Extra       |
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
|  1 | SIMPLE      | users | ALL  | NULL          | NULL | NULL    | NULL | 1000 | Using where |
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+

주요 컬럼 설명: 1) type (접근 방식)

성능 순서 (빠름 → 느림):
system > const > eq_ref > ref > range > index > ALL
✅ const: PRIMARY KEY나 UNIQUE로 1개 행 찾음 (최고)
✅ ref: 인덱스로 여러 행 찾음 (좋음)
✅ range: 인덱스 범위 스캔 (괜찮음)
❌ index: 인덱스 전체 스캔 (느림)
❌ ALL: 테이블 전체 스캔 (매우 느림)

2) rows (예상 행 수)

rows = 1000000  ← 100만 행 스캔 (느림!)
rows = 10       ← 10행만 스캔 (빠름!)

3) key (사용된 인덱스)

key = NULL      ← 인덱스 사용 안함 (느림!)
key = idx_email ← 인덱스 사용 (빠름!)

실전 예제

최적화 전:

EXPLAIN SELECT * FROM orders WHERE user_id = 123;
+------+-------+------+---------------+------+------+----------+
| type | table | key  | possible_keys | rows | Extra           |
+------+-------+------+---------------+------+------+----------+
| ALL  | orders| NULL | NULL          | 500K | Using where     |
+------+-------+------+---------------+------+------+----------+
문제: type = ALL (전체 스캔), rows = 500,000

최적화 후:

CREATE INDEX idx_user_id ON orders(user_id);
EXPLAIN SELECT * FROM orders WHERE user_id = 123;
+------+-------+------------+---------------+------+-------+
| type | table | key        | possible_keys | rows | Extra |
+------+-------+------------+---------------+------+-------+
| ref  | orders| idx_user_id| idx_user_id   | 50   |       |
+------+-------+------------+---------------+------+-------+
개선: type = ref (인덱스 사용), rows = 50

4. 인덱스 설계 전략

복합 인덱스 순서

중요: 인덱스 컬럼 순서가 성능에 큰 영향!

-- 쿼리
SELECT * FROM orders 
WHERE user_id = 123 
  AND status = 'completed'
  AND created_at >= '2026-01-01';
-- ❌ 비효율적
CREATE INDEX idx_bad ON orders(created_at, status, user_id);
-- ✅ 효율적
CREATE INDEX idx_good ON orders(user_id, status, created_at);

인덱스 컬럼 순서 원칙:

1. = 조건 (가장 앞)
2. 범위 조건 (가장 뒤)
3. 선택도가 높은 컬럼 우선
예시:
WHERE user_id = 123        ← = 조건 (1순위)
  AND status = 'completed' ← = 조건 (2순위)
  AND created_at >= '2026-01-01' ← 범위 조건 (3순위)
인덱스: (user_id, status, created_at)

인덱스 사용 조건

✅ 인덱스 사용됨:

-- 1. = 조건
SELECT * FROM users WHERE id = 1;
-- 2. IN 조건
SELECT * FROM users WHERE id IN (1, 2, 3);
-- 3. 범위 조건
SELECT * FROM users WHERE created_at >= '2026-01-01';
-- 4. LIKE (접두사 매칭)
SELECT * FROM users WHERE email LIKE 'alice%';
-- 5. 복합 인덱스 왼쪽부터 사용
CREATE INDEX idx_abc ON table(a, b, c);
SELECT * FROM table WHERE a = 1 AND b = 2;  -- 인덱스 사용

❌ 인덱스 사용 안됨:

-- 1. 함수 사용
SELECT * FROM users WHERE YEAR(created_at) = 2026;
-- 2. 계산식
SELECT * FROM users WHERE id + 1 = 2;
-- 3. LIKE (중간/끝 매칭)
SELECT * FROM users WHERE email LIKE '%@gmail.com';
-- 4. OR 조건 (인덱스 없는 컬럼과)
SELECT * FROM users WHERE id = 1 OR name = 'Alice';
-- 5. 복합 인덱스 중간 컬럼만 사용
CREATE INDEX idx_abc ON table(a, b, c);
SELECT * FROM table WHERE b = 2;  -- 인덱스 사용 안됨!

5. N+1 문제 해결

N+1 문제란?

N+1 문제는 한 번에 가져올 수 있는 것을 N번 추가로 떼어 오는 패턴이다.

// ❌ N+1 문제 (101개 쿼리)
// 1. 사용자 목록 조회 (1개 쿼리)
const users = await db.query('SELECT * FROM users LIMIT 100');
// 2. 각 사용자의 게시글 수 조회 (100개 쿼리)
for (const user of users) {
  const posts = await db.query(
    'SELECT COUNT(*) FROM posts WHERE user_id = ?',
    [user.id]
  );
  user.postCount = posts[0].count;
}
// 총 101개 쿼리! (매우 느림)

해결 방법 1: JOIN

-- ✅ 1개 쿼리로 해결
SELECT 
  u.id,
  u.name,
  COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.id, u.name
LIMIT 100;
-- 101개 쿼리 → 1개 쿼리 (100배 빠름!)

해결 방법 2: IN 절

// ✅ 2개 쿼리로 해결
// 1. 사용자 목록 조회
const users = await db.query('SELECT * FROM users LIMIT 100');
const userIds = users.map(u => u.id);
// 2. 모든 사용자의 게시글 수 한번에 조회
const postCounts = await db.query(`
  SELECT user_id, COUNT(*) as count
  FROM posts
  WHERE user_id IN (?)
  GROUP BY user_id
`, [userIds]);
// 3. 결과 매핑
const countMap = Object.fromEntries(
  postCounts.map(p => [p.user_id, p.count])
);
users.forEach(user => {
  user.postCount = countMap[user.id] || 0;
});
// 101개 쿼리 → 2개 쿼리 (50배 빠름!)

ORM에서 N+1 문제

Sequelize (Node.js)

// ❌ N+1 문제
const users = await User.findAll();
for (const user of users) {
  const posts = await user.getPosts();  // N개 쿼리
}
// ✅ Eager Loading
const users = await User.findAll({
  include: [{ model: Post }]  // 1개 쿼리 (JOIN)
});

Django (Python)

# ❌ N+1 문제
users = User.objects.all()
for user in users:
    posts = user.posts.all()  # N개 쿼리
# ✅ select_related (1:1, N:1)
users = User.objects.select_related('profile').all()
# ✅ prefetch_related (1:N, N:N)
users = User.objects.prefetch_related('posts').all()

6. 조인 최적화

JOIN 종류

-- INNER JOIN (교집합)
SELECT u.name, o.order_id
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
-- LEFT JOIN (왼쪽 테이블 전체)
SELECT u.name, o.order_id
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
-- RIGHT JOIN (오른쪽 테이블 전체)
SELECT u.name, o.order_id
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;

JOIN 최적화 팁

1) 작은 테이블을 먼저

-- ❌ 큰 테이블 먼저
SELECT *
FROM orders o  -- 100만 건
JOIN users u ON o.user_id = u.id  -- 1000건
WHERE u.country = 'KR';
-- ✅ 작은 테이블 먼저
SELECT *
FROM users u  -- 1000건
JOIN orders o ON u.id = o.user_id  -- 100만 건
WHERE u.country = 'KR';

2) JOIN 조건에 인덱스

-- JOIN 조건 컬럼에 인덱스 필수
CREATE INDEX idx_user_id ON orders(user_id);
CREATE INDEX idx_id ON users(id);  -- PRIMARY KEY면 자동 생성
SELECT *
FROM users u
JOIN orders o ON u.id = o.user_id;

3) WHERE 조건 먼저 필터링

-- ❌ JOIN 후 필터링
SELECT *
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.created_at >= '2026-01-01';
-- ✅ 서브쿼리로 먼저 필터링
SELECT *
FROM users u
JOIN (
  SELECT * FROM orders 
  WHERE created_at >= '2026-01-01'
) o ON u.id = o.user_id;

7. 서브쿼리 최적화

비효율적인 서브쿼리

-- ❌ 상관 서브쿼리 (Correlated Subquery)
-- 외부 쿼리의 각 행마다 서브쿼리 실행 (느림!)
SELECT 
  u.name,
  (SELECT COUNT(*) FROM posts WHERE user_id = u.id) as post_count
FROM users u;
-- users가 1000명이면 서브쿼리 1000번 실행!

JOIN으로 최적화

-- ✅ JOIN 사용 (빠름)
SELECT 
  u.name,
  COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.id, u.name;
-- 1개 쿼리로 해결!

EXISTS vs IN

-- 큰 테이블에서 존재 여부만 확인
-- ✅ EXISTS 사용 (빠름)
SELECT * FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o 
  WHERE o.user_id = u.id
);
-- ❌ IN 사용 (느림)
SELECT * FROM users u
WHERE u.id IN (
  SELECT user_id FROM orders
);
-- EXISTS는 첫 번째 매칭만 찾으면 중단
-- IN은 모든 결과를 메모리에 로드

8. 실전 최적화 사례

사례 1: 페이지네이션

❌ OFFSET 사용 (느림)

-- 100만 번째 페이지 조회
SELECT * FROM posts 
ORDER BY created_at DESC 
LIMIT 20 OFFSET 1000000;
-- 문제: 100만 개를 건너뛰기 위해 모두 읽음!
-- 실행 시간: 5초

✅ Cursor 기반 페이지네이션 (빠름)

-- 첫 페이지
SELECT * FROM posts 
ORDER BY created_at DESC 
LIMIT 20;
-- 다음 페이지 (마지막 created_at 이용)
SELECT * FROM posts 
WHERE created_at < '2026-03-31 10:00:00'
ORDER BY created_at DESC 
LIMIT 20;
-- 인덱스만 사용, 건너뛰기 없음!
-- 실행 시간: 0.01초 (500배 빠름!)

사례 2: COUNT(*) 최적화

❌ 전체 카운트 (느림)

-- 100만 건 테이블
SELECT COUNT(*) FROM posts;
-- 실행 시간: 2초

✅ 근사치 사용 (빠름)

-- MySQL
SELECT TABLE_ROWS 
FROM information_schema.TABLES 
WHERE TABLE_NAME = 'posts';
-- 실행 시간: 0.001초
-- PostgreSQL
SELECT reltuples::bigint 
FROM pg_class 
WHERE relname = 'posts';

✅ 캐싱 사용

// Redis에 캐싱
const cachedCount = await redis.get('posts:count');
if (cachedCount) {
  return cachedCount;
}
const count = await db.query('SELECT COUNT(*) FROM posts');
await redis.setex('posts:count', 3600, count); // 1시간 캐싱
return count;

사례 3: LIKE 검색 최적화

❌ 중간 매칭 (인덱스 사용 안됨)

SELECT * FROM users WHERE name LIKE '%alice%';
-- 전체 스캔! (느림)

✅ 전문 검색 인덱스 (Full-Text Search)

-- MySQL
CREATE FULLTEXT INDEX idx_name_fulltext ON users(name);
SELECT * FROM users 
WHERE MATCH(name) AGAINST('alice');
-- 전문 검색 인덱스 사용! (빠름)
-- PostgreSQL
CREATE INDEX idx_name_gin ON users USING gin(to_tsvector('english', name));
SELECT * FROM users 
WHERE to_tsvector('english', name) @@ to_tsquery('alice');

✅ Elasticsearch 사용

// 대용량 텍스트 검색은 Elasticsearch 추천
const result = await esClient.search({
  index: 'users',
  body: {
    query: {
      match: { name: 'alice' }
    }
  }
});

9. 쿼리 최적화 체크리스트

필수 체크 항목

✅ EXPLAIN으로 실행 계획 확인
✅ type이 ALL이면 인덱스 추가
✅ rows가 크면 WHERE 조건 개선
✅ JOIN 조건에 인덱스 있는지 확인
✅ N+1 문제 없는지 확인
✅ 불필요한 SELECT * 제거
✅ LIMIT 사용 (필요한 만큼만)
✅ 서브쿼리 대신 JOIN 사용
✅ 함수 사용 최소화
✅ 복합 인덱스 순서 최적화

쿼리 작성 원칙

1) 필요한 컬럼만 조회

-- ❌ 모든 컬럼 조회
SELECT * FROM users;
-- ✅ 필요한 컬럼만
SELECT id, name, email FROM users;

2) DISTINCT 신중하게 사용

-- ❌ DISTINCT (정렬 필요, 느림)
SELECT DISTINCT user_id FROM orders;
-- ✅ GROUP BY (인덱스 활용)
SELECT user_id FROM orders GROUP BY user_id;

3) UNION vs UNION ALL

-- ❌ UNION (중복 제거, 느림)
SELECT id FROM users
UNION
SELECT id FROM deleted_users;
-- ✅ UNION ALL (중복 허용, 빠름)
SELECT id FROM users
UNION ALL
SELECT id FROM deleted_users;

10. 인덱스 모니터링

사용되지 않는 인덱스 찾기

MySQL:

-- 인덱스 사용 통계
SELECT 
  TABLE_NAME,
  INDEX_NAME,
  SEQ_IN_INDEX,
  COLUMN_NAME
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'mydb'
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;

PostgreSQL:

-- 사용되지 않는 인덱스
SELECT 
  schemaname,
  tablename,
  indexname,
  idx_scan,
  idx_tup_read,
  idx_tup_fetch
FROM pg_stat_user_indexes
WHERE idx_scan = 0  -- 한 번도 사용 안됨
ORDER BY schemaname, tablename;

인덱스 크기 확인

-- MySQL
SELECT 
  TABLE_NAME,
  INDEX_NAME,
  ROUND(STAT_VALUE * @@innodb_page_size / 1024 / 1024, 2) as size_mb
FROM mysql.innodb_index_stats
WHERE DATABASE_NAME = 'mydb'
ORDER BY size_mb DESC;
-- PostgreSQL
SELECT
  tablename,
  indexname,
  pg_size_pretty(pg_relation_size(indexrelid)) as size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;

11. 실전 최적화 워크플로우

1단계: 느린 쿼리 찾기

MySQL Slow Query Log 활성화:

-- slow query log 활성화
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;  -- 1초 이상 쿼리 기록
-- 로그 파일 위치 확인
SHOW VARIABLES LIKE 'slow_query_log_file';

애플리케이션 로깅:

// 쿼리 실행 시간 측정
const start = Date.now();
const result = await db.query('SELECT ...');
const duration = Date.now() - start;
if (duration > 1000) {
  console.warn(`Slow query (${duration}ms):`, query);
}

2단계: EXPLAIN 분석

EXPLAIN SELECT * FROM orders 
WHERE user_id = 123 
  AND created_at >= '2026-01-01';
-- type, rows, key 확인

3단계: 인덱스 추가

CREATE INDEX idx_user_created ON orders(user_id, created_at);

4단계: 재측정

EXPLAIN SELECT * FROM orders 
WHERE user_id = 123 
  AND created_at >= '2026-01-01';
-- 개선 확인

5단계: 프로덕션 적용

-- 인덱스 생성은 테이블 락 발생 가능
-- 온라인 인덱스 생성 (MySQL 5.6+)
CREATE INDEX idx_user_created ON orders(user_id, created_at) ALGORITHM=INPLACE, LOCK=NONE;
-- PostgreSQL
CREATE INDEX CONCURRENTLY idx_user_created ON orders(user_id, created_at);

12. 데이터베이스별 최적화

MySQL 최적화

-- 1. 쿼리 캐시 (MySQL 5.7 이하)
SET GLOBAL query_cache_size = 268435456;  -- 256MB
-- 2. InnoDB 버퍼 풀 크기
SET GLOBAL innodb_buffer_pool_size = 2147483648;  -- 2GB
-- 3. 커넥션 풀
SET GLOBAL max_connections = 200;

PostgreSQL 최적화

-- 1. 통계 업데이트
ANALYZE users;
-- 2. VACUUM (공간 회수)
VACUUM ANALYZE users;
-- 3. 설정 최적화
-- postgresql.conf
shared_buffers = 2GB
effective_cache_size = 6GB
work_mem = 50MB

13. 모니터링 도구

쿼리 프로파일링

MySQL:

-- 프로파일링 활성화
SET profiling = 1;
-- 쿼리 실행
SELECT * FROM users WHERE email = '[email protected]';
-- 프로파일 확인
SHOW PROFILES;
SHOW PROFILE FOR QUERY 1;

PostgreSQL:

-- 실행 계획 + 실제 실행 시간
EXPLAIN ANALYZE 
SELECT * FROM users WHERE email = '[email protected]';

APM 도구

추천 도구:
- New Relic: 종합 모니터링
- Datadog: 실시간 모니터링
- Percona Monitoring: MySQL 전문
- pgBadger: PostgreSQL 로그 분석

14. 실전 예제: 대시보드 쿼리 최적화

최적화 전 (8초)

-- 대시보드 통계 조회
SELECT 
  (SELECT COUNT(*) FROM users) as total_users,
  (SELECT COUNT(*) FROM orders) as total_orders,
  (SELECT SUM(amount) FROM orders WHERE status = 'completed') as revenue,
  (SELECT COUNT(*) FROM orders WHERE created_at >= CURDATE()) as today_orders;
-- 4개의 독립적인 쿼리 실행 (느림!)

최적화 후 (0.05초)

-- 1개 쿼리로 통합
SELECT 
  COUNT(DISTINCT u.id) as total_users,
  COUNT(o.id) as total_orders,
  SUM(CASE WHEN o.status = 'completed' THEN o.amount ELSE 0 END) as revenue,
  SUM(CASE WHEN o.created_at >= CURDATE() THEN 1 ELSE 0 END) as today_orders
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
-- 160배 빠름!

추가 최적화: 캐싱

// Redis 캐싱
async function getDashboardStats() {
  const cached = await redis.get('dashboard:stats');
  if (cached) {
    return JSON.parse(cached);
  }
  
  const stats = await db.query('SELECT ...');
  await redis.setex('dashboard:stats', 300, JSON.stringify(stats)); // 5분
  return stats;
}

FAQ

Q1. 인덱스는 많을수록 좋은가요? 아닙니다! 인덱스가 많으면:

  • INSERT/UPDATE/DELETE 느려짐 (인덱스도 업데이트)
  • 디스크 공간 사용 증가
  • 필요한 인덱스만 생성하세요 Q2. 모든 WHERE 조건에 인덱스를 만들어야 하나요? 아닙니다. 선택도가 높은 컬럼에만 만드세요.
-- ❌ 선택도 낮음 (인덱스 효과 없음)
CREATE INDEX idx_gender ON users(gender);  -- 값이 2개뿐 (M/F)
-- ✅ 선택도 높음 (인덱스 효과 있음)
CREATE INDEX idx_email ON users(email);  -- 값이 모두 다름

Q3. 복합 인덱스 vs 단일 인덱스? 쿼리 패턴에 따라 다릅니다.

-- 항상 함께 사용하면 복합 인덱스
WHERE user_id = 123 AND status = 'completed'
CREATE INDEX idx_user_status ON orders(user_id, status);
-- 따로 사용하면 단일 인덱스
WHERE user_id = 123  (쿼리 1)
WHERE status = 'completed'  (쿼리 2)
CREATE INDEX idx_user_id ON orders(user_id);
CREATE INDEX idx_status ON orders(status);

Q4. 인덱스를 추가했는데도 느려요

-- EXPLAIN으로 확인
EXPLAIN SELECT ...;
-- 인덱스 사용 안되는 이유:
-- 1. 함수 사용: WHERE YEAR(created_at) = 2026
-- 2. 타입 불일치: WHERE id = '123' (id가 INT인데 문자열)
-- 3. OR 조건: WHERE a = 1 OR b = 2
-- 4. 복합 인덱스 순서 안맞음

요약

핵심 정리

인덱스:

  • 책의 목차와 같음
  • WHERE, JOIN, ORDER BY 컬럼에 생성
  • 복합 인덱스 순서 중요 EXPLAIN:
  • 쿼리 실행 계획 확인
  • type = ALL이면 최적화 필요
  • rows가 크면 인덱스 추가 N+1 문제:
  • 1개 쿼리로 가능한 것을 N+1개로 실행
  • JOIN이나 IN으로 해결 최적화 순서:
  1. 느린 쿼리 찾기
  2. EXPLAIN 분석
  3. 인덱스 추가
  4. 재측정

성능 개선 효과

최적화 전 → 최적화 후
Full Scan:     5초 → 0.01초 (500배)
N+1 문제:      10초 → 0.1초 (100배)
복합 인덱스:   2초 → 0.02초 (100배)
페이지네이션:  3초 → 0.01초 (300배)

다음 글 추천


키워드: SQL, 쿼리 최적화, Database, Index, 인덱스, EXPLAIN, Performance, MySQL, PostgreSQL, N+1, JOIN

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「SQL 쿼리 최적화 실전 가이드 | 인덱스·실행 계획」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「SQL 쿼리 최적화 실전 가이드 | 인덱스·실행 계획」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

SQL, 쿼리 최적화, Database, Index, EXPLAIN, Performance, 인덱스, 데이터베이스, 성능, MySQL 등으로 검색하시면 이 글이 도움이 됩니다.