SQL 쿼리 최적화 실전 가이드 | 인덱스·실행 계획
🎯 이 글을 읽으면 (읽는 시간: 30분)
TL;DR: SQL 쿼리 성능을 10배 향상시키는 방법을 배웁니다. 인덱스 설계부터 EXPLAIN 분석, N+1 문제 해결까지 실무에서 바로 쓸 수 있는 최적화 기법을 마스터합니다.
이 글을 읽으면:
- ✅ 인덱스 설계 원칙과 실전 적용 방법 마스터
- ✅ EXPLAIN으로 쿼리 실행 계획 분석 능력 습득
- ✅ N+1 문제, 조인 최적화 등 안티패턴 해결
- ✅ 실전 최적화 사례로 성능 개선 노하우 습득
실무 활용:
- 🔥 느린 쿼리 10배 빠르게 만들기
- 🔥 데이터베이스 부하 감소 (서버 비용 절감)
- 🔥 대용량 데이터 처리 최적화
- 🔥 API 응답 시간 개선
난이도: 중급 | 실습 예제: 15개 | 성능 비교: 포함
이 글의 핵심
한 번의 스캔을 줄이는 일이 곧 비용을 줄이는 일이다. 인덱스·EXPLAIN·대표적인 안티패턴(N+1 등)을 중심으로 짚는다.
목차
사전 지식 (초보자를 위한 기초)
1. 데이터베이스 테이블 구조
테이블은 행과 열로 데이터를 저장하는 단위다.
다음은 sql를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- 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 쿼리
아래 코드는 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. 쿼리 실행 과정
아래 코드는 text를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
1. 쿼리 작성
SELECT * FROM users WHERE email = '[email protected]';
2. 데이터베이스가 실행
- 테이블 스캔 (모든 행 확인)
- 조건에 맞는 행 찾기
- 결과 반환
3. 문제: 데이터가 100만 개면?
- 100만 행을 훑게 될 수 있다
- 인덱스 없으면 수 초~수 분 걸림
4. 인덱스란?
인덱스는 책 목차처럼 찾을 위치를 바로 잡게 해 준다.
아래 코드는 text를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
인덱스 없이 찾기:
- 책의 첫 페이지부터 끝까지 읽으며 찾기
- 1000페이지 책 → 평균 500페이지 읽어야 함
인덱스로 찾기:
- 목차에서 페이지 번호 확인
- 해당 페이지로 바로 이동
- 1000페이지 책 → 1초 만에 찾음!
SQL 인덱스:
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- 인덱스 생성
CREATE INDEX idx_email ON users(email);
-- 이제 email로 검색 시 빠름!
SELECT * FROM users WHERE email = '[email protected]';
-- 100만 건 → 0.001초
1. 쿼리 최적화가 중요한 이유
느린 쿼리의 영향
아래 코드는 text를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
쿼리 실행 시간: 5초
사용자 1명: 5초 대기 (불편)
사용자 100명: 서버 과부하
사용자 1000명: 서버에 부하 집중
최적화 후: 0.05초
→ 100배 빠름!
→ 1000명이 동시 접속해도 문제 없음
실제 사례
최적화 전:
다음은 간단한 sql 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- 실행 시간: 8.5초
SELECT * FROM orders
WHERE user_id = 123
AND created_at >= '2026-01-01';
최적화 후:
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- 인덱스 추가
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 (인덱스 없음)
아래 코드는 text를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
SELECT * FROM users WHERE email = '[email protected]';
┌────┬───────┬──────────────────┐
│ id │ name │ email │
├────┼───────┼──────────────────┤
│ 1 │ Alice │ [email protected] │ ← 확인
│ 2 │ Bob │ [email protected] │ ← 확인
│ 3 │ Carol │ [email protected]│ ← 확인
│... │ ... │ ... │ ← 확인
│100만│ ... │ ... │ ← 확인
모든 행을 확인! (느림)
Index Scan (인덱스 있음)
아래 코드는 text를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
CREATE INDEX idx_email ON users(email);
인덱스 (B-Tree 구조):
[email protected]
/ \\
[email protected] [email protected]
/ \\ / \\
alice@... bob@... carol@... dave@...
이진 탐색으로 빠르게 찾음!
100만 건 → 약 20번 비교로 찾음
인덱스 생성
아래 코드는 sql를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- 단일 컬럼 인덱스
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;
인덱스 사용 예시
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- ✅ 인덱스 사용 (빠름)
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 예시:
아래 코드는 text를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
| 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 (접근 방식)
아래 코드는 text를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
성능 순서 (빠름 → 느림):
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 ← 인덱스 사용 (빠름!)
실전 예제
최적화 전:
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
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
최적화 후:
아래 코드는 sql를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
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. 인덱스 설계 전략
복합 인덱스 순서
중요: 인덱스 컬럼 순서가 성능에 큰 영향!
아래 코드는 sql를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- 쿼리
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);
인덱스 컬럼 순서 원칙:
아래 코드는 text를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
1. = 조건 (가장 앞)
2. 범위 조건 (가장 뒤)
3. 선택도가 높은 컬럼 우선
예시:
WHERE user_id = 123 ← = 조건 (1순위)
AND status = 'completed' ← = 조건 (2순위)
AND created_at >= '2026-01-01' ← 범위 조건 (3순위)
인덱스: (user_id, status, created_at)
인덱스 사용 조건
✅ 인덱스 사용됨:
다음은 sql를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- 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; -- 인덱스 사용
❌ 인덱스 사용 안됨:
다음은 sql를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- 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번 추가로 떼어 오는 패턴이다.
아래 코드는 javascript를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 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
아래 코드는 sql를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- ✅ 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 절
다음은 javascript를 활용한 상세한 구현 코드입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ✅ 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)
아래 코드는 javascript를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 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)
아래 코드는 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 종류
아래 코드는 sql를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- 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) 작은 테이블을 먼저
아래 코드는 sql를 사용한 구현 예제입니다. 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- ❌ 큰 테이블 먼저
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 조건에 인덱스
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- 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 조건 먼저 필터링
아래 코드는 sql를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- ❌ 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. 서브쿼리 최적화
비효율적인 서브쿼리
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- ❌ 상관 서브쿼리 (Correlated Subquery)
-- 외부 쿼리의 각 행마다 서브쿼리 실행 (느림!)
SELECT
u.name,
(SELECT COUNT(*) FROM posts WHERE user_id = u.id) as post_count
FROM users u;
-- users가 1000명이면 서브쿼리 1000번 실행!
JOIN으로 최적화
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- ✅ 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
다음은 sql를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- 큰 테이블에서 존재 여부만 확인
-- ✅ 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 사용 (느림)
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- 100만 번째 페이지 조회
SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 1000000;
-- 문제: 100만 개를 건너뛰기 위해 모두 읽음!
-- 실행 시간: 5초
✅ Cursor 기반 페이지네이션 (빠름)
아래 코드는 sql를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- 첫 페이지
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초
✅ 근사치 사용 (빠름)
아래 코드는 sql를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- MySQL
SELECT TABLE_ROWS
FROM information_schema.TABLES
WHERE TABLE_NAME = 'posts';
-- 실행 시간: 0.001초
-- PostgreSQL
SELECT reltuples::bigint
FROM pg_class
WHERE relname = 'posts';
✅ 캐싱 사용
아래 코드는 javascript를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 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)
아래 코드는 sql를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- 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 사용
아래 코드는 javascript를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 대용량 텍스트 검색은 Elasticsearch 추천
const result = await esClient.search({
index: 'users',
body: {
query: {
match: { name: 'alice' }
}
}
});
9. 쿼리 최적화 체크리스트
필수 체크 항목
아래 코드는 text를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
✅ EXPLAIN으로 실행 계획 확인
✅ type이 ALL이면 인덱스 추가
✅ rows가 크면 WHERE 조건 개선
✅ JOIN 조건에 인덱스 있는지 확인
✅ N+1 문제 없는지 확인
✅ 불필요한 SELECT * 제거
✅ LIMIT 사용 (필요한 만큼만)
✅ 서브쿼리 대신 JOIN 사용
✅ 함수 사용 최소화
✅ 복합 인덱스 순서 최적화
쿼리 작성 원칙
1) 필요한 컬럼만 조회
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- ❌ 모든 컬럼 조회
SELECT * FROM users;
-- ✅ 필요한 컬럼만
SELECT id, name, email FROM users;
2) DISTINCT 신중하게 사용
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- ❌ DISTINCT (정렬 필요, 느림)
SELECT DISTINCT user_id FROM orders;
-- ✅ GROUP BY (인덱스 활용)
SELECT user_id FROM orders GROUP BY user_id;
3) UNION vs UNION ALL
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- ❌ 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:
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- 인덱스 사용 통계
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:
아래 코드는 sql를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- 사용되지 않는 인덱스
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;
인덱스 크기 확인
다음은 sql를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- 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 활성화:
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- slow query log 활성화
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 1초 이상 쿼리 기록
-- 로그 파일 위치 확인
SHOW VARIABLES LIKE 'slow_query_log_file';
애플리케이션 로깅:
아래 코드는 javascript를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 쿼리 실행 시간 측정
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 분석
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
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단계: 재측정
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
EXPLAIN SELECT * FROM orders
WHERE user_id = 123
AND created_at >= '2026-01-01';
-- 개선 확인
5단계: 프로덕션 적용
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- 인덱스 생성은 테이블 락 발생 가능
-- 온라인 인덱스 생성 (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 최적화
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- 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 최적화
아래 코드는 sql를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- 1. 통계 업데이트
ANALYZE users;
-- 2. VACUUM (공간 회수)
VACUUM ANALYZE users;
-- 3. 설정 최적화
-- postgresql.conf
shared_buffers = 2GB
effective_cache_size = 6GB
work_mem = 50MB
13. 모니터링 도구
쿼리 프로파일링
MySQL:
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- 프로파일링 활성화
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 도구
아래 코드는 text를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
추천 도구:
- New Relic: 종합 모니터링
- Datadog: 실시간 모니터링
- Percona Monitoring: MySQL 전문
- pgBadger: PostgreSQL 로그 분석
14. 실전 예제: 대시보드 쿼리 최적화
최적화 전 (8초)
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- 대시보드 통계 조회
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초)
아래 코드는 sql를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
-- 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배 빠름!
추가 최적화: 캐싱
아래 코드는 javascript를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 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 조건에 인덱스를 만들어야 하나요?
아닙니다. 선택도가 높은 컬럼에만 만드세요.
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- ❌ 선택도 낮음 (인덱스 효과 없음)
CREATE INDEX idx_gender ON users(gender); -- 값이 2개뿐 (M/F)
-- ✅ 선택도 높음 (인덱스 효과 있음)
CREATE INDEX idx_email ON users(email); -- 값이 모두 다름
Q3. 복합 인덱스 vs 단일 인덱스?
쿼리 패턴에 따라 다릅니다.
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- 항상 함께 사용하면 복합 인덱스
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. 인덱스를 추가했는데도 느려요
아래 코드는 sql를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
-- 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으로 해결
최적화 순서:
- 느린 쿼리 찾기
- EXPLAIN 분석
- 인덱스 추가
- 재측정
성능 개선 효과
아래 코드는 text를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
최적화 전 → 최적화 후
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