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

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

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

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

이 글을 읽으면:

  • ✅ 인덱스 설계 원칙과 실전 적용 방법 마스터
  • ✅ EXPLAIN으로 쿼리 실행 계획 분석 능력 습득
  • ✅ N+1 문제, 조인 최적화 등 안티패턴 해결
  • ✅ 실전 최적화 사례로 성능 개선 노하우 습득

실무 활용:

  • 🔥 느린 쿼리 10배 빠르게 만들기
  • 🔥 데이터베이스 부하 감소 (서버 비용 절감)
  • 🔥 대용량 데이터 처리 최적화
  • 🔥 API 응답 시간 개선

난이도: 중급 | 실습 예제: 15개 | 성능 비교: 포함


이 글의 핵심

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


목차

  1. 쿼리 최적화가 중요한 이유
  2. 인덱스 기초
  3. EXPLAIN으로 쿼리 분석
  4. 인덱스 설계 전략
  5. N+1 문제 해결
  6. 조인 최적화
  7. 서브쿼리 최적화
  8. 실전 최적화 사례

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

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으로 해결

최적화 순서:

  1. 느린 쿼리 찾기
  2. EXPLAIN 분석
  3. 인덱스 추가
  4. 재측정

성능 개선 효과

아래 코드는 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

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3