SQL 성능 튜닝 정리

Q1. Execution Plan (실행 계획)은 어떻게 읽나요?

답변

**Execution Plan (실행 계획)**은 DB 옵티마이저가 쿼리를 어떻게 실행할지 계획한 내용입니다.

실행 계획 확인 명령어:

-- MySQL
EXPLAIN SELECT * FROM users WHERE age = 25;
EXPLAIN ANALYZE SELECT * FROM users WHERE age = 25;  -- 실제 실행 + 통계

-- PostgreSQL
EXPLAIN SELECT * FROM users WHERE age = 25;
EXPLAIN ANALYZE SELECT * FROM users WHERE age = 25;

-- Oracle
EXPLAIN PLAN FOR SELECT * FROM users WHERE age = 25;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

실행 계획 예시 (MySQL):

EXPLAIN SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.age = 25;

출력:

+----+-------------+-------+------+---------------+---------+---------+-------+------+-------------+
| id | select_type | table | type | possible_keys | key     | key_len | ref   | rows | Extra       |
+----+-------------+-------+------+---------------+---------+---------+-------+------+-------------+
|  1 | SIMPLE      | u     | ref  | PRIMARY,idx   | idx_age | 4       | const | 5000 | Using where |
|  1 | SIMPLE      | o     | ref  | idx_user_id   | idx_uid | 4       | u.id  | 10   | NULL        |
+----+-------------+-------+------+---------------+---------+---------+-------+------+-------------+

주요 컬럼 설명

1. type (접근 방법):

type설명성능
system테이블에 Row 1개최고 ⭐⭐⭐⭐⭐
constPK/Unique로 1개 조회최고 ⭐⭐⭐⭐⭐
eq_refJoin 시 PK/Unique매우 좋음 ⭐⭐⭐⭐
refNon-Unique 인덱스좋음 ⭐⭐⭐
range인덱스 범위 검색보통 ⭐⭐
index인덱스 풀 스캔나쁨 ⭐
ALL테이블 풀 스캔매우 나쁨 ❌
-- ✅ const (최고)
SELECT * FROM users WHERE id = 123;
-- type: const (PK로 1개 조회)

-- ✅ ref (좋음)
SELECT * FROM users WHERE age = 25;
-- type: ref (Non-Unique 인덱스)

-- ⚠️ range (보통)
SELECT * FROM users WHERE age BETWEEN 20 AND 30;
-- type: range (인덱스 범위 검색)

-- ❌ ALL (나쁨)
SELECT * FROM users WHERE YEAR(created_at) = 2025;
-- type: ALL (함수로 인해 인덱스 무효화)

2. possible_keys vs key:

possible_keys: 사용 가능한 인덱스 목록
key: 실제로 사용된 인덱스

possible_keys: idx_age, idx_city
key: idx_age
→ 옵티마이저가 idx_age를 선택함

3. rows (예상 스캔 Row 수):

-- ❌ rows가 많으면 느림
EXPLAIN SELECT * FROM orders WHERE status = 'PENDING';
-- rows: 500000 (50만 건 스캔 예상) ⚠️

-- ✅ rows가 적으면 빠름
EXPLAIN SELECT * FROM orders WHERE order_id = 12345;
-- rows: 1 (1건만 스캔) ✅

4. Extra (추가 정보):

Extra의미성능
Using indexCovering Index매우 좋음 ✅
Using whereWHERE 필터 사용보통
Using filesortORDER BY 정렬나쁨 ⚠️
Using temporary임시 테이블 생성매우 나쁨 ❌
-- ✅ Using index (Covering Index)
EXPLAIN SELECT id, name FROM users WHERE age = 25;
-- Extra: Using index
-- → 인덱스만으로 처리 (빠름!)

-- ⚠️ Using filesort
EXPLAIN SELECT * FROM users ORDER BY created_at;
-- Extra: Using filesort
-- → 정렬을 위한 추가 작업 (느림)

-- ❌ Using temporary
EXPLAIN SELECT city, COUNT(*)
FROM users
GROUP BY city
ORDER BY COUNT(*) DESC;
-- Extra: Using temporary; Using filesort
-- → 임시 테이블 + 정렬 (매우 느림!)

꼬리 질문 1: EXPLAIN vs EXPLAIN ANALYZE 차이는?

EXPLAIN: 실행 계획만 보여줌 (실제 실행 X) EXPLAIN ANALYZE: 실제 실행 + 실행 시간 통계

-- EXPLAIN (예측만)
EXPLAIN SELECT * FROM users WHERE age = 25;
-- rows: 5000 (예측)

-- EXPLAIN ANALYZE (실제 실행)
EXPLAIN ANALYZE SELECT * FROM users WHERE age = 25;
-- rows: 5000 (예측), actual rows: 4823 (실제)
-- Planning Time: 0.5ms
-- Execution Time: 12.3ms

주의: EXPLAIN ANALYZE는 실제로 쿼리를 실행하므로 UPDATE/DELETE 시 데이터가 변경됩니다!

-- ⚠️ 주의! 실제로 삭제됨
EXPLAIN ANALYZE DELETE FROM users WHERE age < 18;
-- → 실제로 데이터 삭제! (롤백 필요)

-- ✅ 트랜잭션으로 보호
BEGIN;
EXPLAIN ANALYZE DELETE FROM users WHERE age < 18;
ROLLBACK;  -- 변경사항 취소

꼬리 질문 2: 실행 계획이 바뀌는 경우는?

1. 통계 정보 변경:

-- 데이터 대량 삽입 후 통계 업데이트
INSERT INTO users SELECT ... (100  삽입)

-- ❌ 오래된 통계로 잘못된 실행 계획
EXPLAIN SELECT * FROM users WHERE age = 25;
-- rows: 100 (예전 통계 기준)

-- ✅ 통계 업데이트
ANALYZE TABLE users;  -- MySQL
VACUUM ANALYZE users; -- PostgreSQL

EXPLAIN SELECT * FROM users WHERE age = 25;
-- rows: 50000 (최신 통계 기준)
-- → 실행 계획 변경됨! (Index Scan → Full Scan)

2. 인덱스 추가/삭제:

-- Before: Full Table Scan
EXPLAIN SELECT * FROM users WHERE city = 'Seoul';
-- type: ALL

-- 인덱스 생성
CREATE INDEX idx_city ON users(city);

-- After: Index Scan
EXPLAIN SELECT * FROM users WHERE city = 'Seoul';
-- type: ref

Q2. Join 전략의 종류와 차이는?

답변

3가지 주요 Join 알고리즘:

1. Nested Loop Join (중첩 루프)

동작 원리: 외부 테이블의 각 Row마다 내부 테이블을 스캔

for each row in table1:
    for each row in table2:
        if join_condition:
            return row

예시:

SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.age = 25;

-- Nested Loop Join 실행:
-- 1. users에서 age = 25인 Row 5,000건 가져옴
-- 2. 각 user마다 orders 테이블에서 user_id로 조회 (5,000번)
-- 3. 총 조인 연산: 5,000번

실행 계획:

Nested Loop  (cost=0..1000 rows=5000)
  -> Index Scan on users using idx_age  (cost=0..100 rows=5000)
        Index Cond: (age = 25)
  -> Index Scan on orders using idx_user_id  (cost=0..0.2 rows=1)
        Index Cond: (user_id = users.id)

특징:

항목설명
적합한 경우외부 테이블이 작고, 내부 테이블에 인덱스 있음
부적합한 경우외부 테이블이 크고, 내부 테이블에 인덱스 없음
시간 복잡도O(N × M) (인덱스 없으면)
메모리적음

성능:

-- ✅ Nested Loop가 좋은 경우
SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.id = 123;  -- 외부 테이블 Row 1개
-- → Nested Loop Join: 매우 빠름! ✅
-- → 1번만 조인

-- ❌ Nested Loop가 나쁜 경우
SELECT u.name, o.total
FROM users u
JOIN orders o ON u.city = o.city;  -- 인덱스 없음
-- → Nested Loop Join: 매우 느림! ❌
-- → 100만 × 100만 = 1조 번 비교

2. Hash Join (해시 조인)

동작 원리: 작은 테이블로 해시 테이블을 만들고, 큰 테이블을 해시로 조회

1. Build Phase: 작은 테이블로 해시 테이블 생성
2. Probe Phase: 큰 테이블의 각 Row를 해시로 조회

예시:

SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id;

-- Hash Join 실행:
-- 1. Build: users 테이블로 해시 테이블 생성 (id → name)
--    Hash Table: {123: "John", 456: "Jane", ...}
-- 2. Probe: orders 테이블을 스캔하며 해시 조회
--    order(user_id=123) → Hash[123] → "John"

실행 계획:

Hash Join  (cost=1000..5000 rows=100000)
  Hash Cond: (o.user_id = u.id)
  -> Seq Scan on orders o  (cost=0..3000 rows=100000)
  -> Hash  (cost=1000..1000 rows=10000)
        -> Seq Scan on users u  (cost=0..1000 rows=10000)

특징:

항목설명
적합한 경우큰 테이블 조인, 등호 조인 (=)
부적합한 경우메모리 부족, 범위 조인 (>, <)
시간 복잡도O(N + M)
메모리많음 (해시 테이블)

성능:

-- ✅ Hash Join이 좋은 경우
SELECT u.name, o.total
FROM users u  -- 10만 건
JOIN orders o ON u.id = o.user_id  -- 100만 건
-- → Hash Join: 빠름! ✅
-- → 시간: O(100,000 + 1,000,000) = 110만

-- ❌ Hash Join이 나쁜 경우 (메모리 부족)
SELECT u.name, o.total
FROM users u  -- 1억 건 (해시 테이블이 메모리 초과!)
JOIN orders o ON u.id = o.user_id
-- → Hash Join 시도 → 디스크 사용 → 느림! ⚠️

3. Merge Join (병합 조인)

동작 원리: 양쪽 테이블을 정렬한 후, 순차적으로 병합

1. Sort: 양쪽 테이블을 Join Key로 정렬
2. Merge: 정렬된 두 테이블을 병합

예시:

SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
ORDER BY u.id;

-- Merge Join 실행:
-- 1. users를 id로 정렬: [1, 2, 3, ...]
-- 2. orders를 user_id로 정렬: [1, 1, 2, 3, ...]
-- 3. 두 테이블을 순차적으로 병합

실행 계획:

Merge Join  (cost=2000..5000 rows=100000)
  Merge Cond: (u.id = o.user_id)
  -> Sort  (cost=1000..1100 rows=10000)
        Sort Key: u.id
        -> Seq Scan on users u
  -> Sort  (cost=1000..1100 rows=100000)
        Sort Key: o.user_id
        -> Seq Scan on orders o

특징:

항목설명
적합한 경우이미 정렬된 데이터, ORDER BY 있음
부적합한 경우정렬 비용 높음
시간 복잡도O(N log N + M log M)
메모리중간 (정렬 버퍼)

성능:

-- ✅ Merge Join이 좋은 경우 (이미 정렬된 인덱스)
CREATE INDEX idx_id ON users(id);
CREATE INDEX idx_user_id ON orders(user_id);

SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
ORDER BY u.id;
-- → 정렬 불필요 (인덱스 사용)
-- → Merge Join: 매우 빠름! ✅

Join 전략 비교

Join Type외부 테이블내부 인덱스메모리시간 복잡도적합
Nested Loop작음 (수백)있음 ✅적음O(N × log M)OLTP
Hash Join큼 (수십만+)없음 ❌많음O(N + M)OLAP
Merge Join정렬됨정렬됨중간O(N + M)정렬 필요

꼬리 질문: Join 전략을 강제할 수 있나요?

Hint 사용:

-- MySQL
SELECT /*+ NO_HASH_JOIN(users, orders) */ u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id;
-- → Hash Join 비활성화

-- PostgreSQL
SET enable_hashjoin = off;
SET enable_mergejoin = off;
-- → Nested Loop만 사용

-- Oracle
SELECT /*+ USE_NL(u o) */ u.name, o.total
FROM users u, orders o
WHERE u.id = o.user_id;
-- → Nested Loop 강제

Q3. N+1 문제란 무엇이고, 어떻게 해결하나요?

답변

N+1 문제1번의 쿼리 결과마다 추가 쿼리가 N번 실행되는 성능 문제입니다.

문제 상황 (JPA/Hibernate 예시):

// ❌ N+1 문제 발생
List<User> users = userRepository.findAll();  // 1번 쿼리

for (User user : users) {
    List<Order> orders = user.getOrders();    // N번 쿼리 (각 user마다)
    System.out.println(orders.size());
}

// 실행된 쿼리:
// 1. SELECT * FROM users;  (100명)
// 2. SELECT * FROM orders WHERE user_id = 1;
// 3. SELECT * FROM orders WHERE user_id = 2;
// ...
// 101. SELECT * FROM orders WHERE user_id = 100;
// → 총 101번 쿼리! ⚠️

SQL 로그:

-- 1번째 쿼리
SELECT * FROM users;
-- 100 rows

-- 2번째 쿼리
SELECT * FROM orders WHERE user_id = 1;
-- 10 rows

-- 3번째 쿼리
SELECT * FROM orders WHERE user_id = 2;
-- 5 rows

-- ...

-- 101번째 쿼리
SELECT * FROM orders WHERE user_id = 100;
-- 8 rows

-- → 총 101번 쿼리 실행 (DB 부하!)

해결 방법 1: Fetch Join (즉시 로딩)

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

List<User> users = userRepository.findAllWithOrders();  // 1번 쿼리

for (User user : users) {
    List<Order> orders = user.getOrders();  // 추가 쿼리 없음!
    System.out.println(orders.size());
}

// 실행된 쿼리:
// SELECT u.*, o.*
// FROM users u
// JOIN orders o ON u.id = o.user_id;
// → 총 1번 쿼리! ✅

주의: Cartesian Product:

-- ⚠️ 여러 컬렉션 JOIN 시 데이터 중복
SELECT u.*, o.*, r.*
FROM users u
JOIN orders o ON u.id = o.user_id      -- 10개
JOIN reviews r ON u.id = r.user_id;    -- 5개

-- 결과:
-- user1의 Row 수: 10 × 5 = 50개 (중복!) ⚠️

해결 방법 2: Batch Fetch (배치 로딩)

// ✅ Batch Size 설정
@Entity
public class User {
    @OneToMany(mappedBy = "user")
    @BatchSize(size = 100)  // 100개씩 배치 로딩
    private List<Order> orders;
}

List<User> users = userRepository.findAll();  // 1번 쿼리

for (User user : users) {
    List<Order> orders = user.getOrders();    // 100명마다 1번 쿼리
    System.out.println(orders.size());
}

// 실행된 쿼리:
// 1. SELECT * FROM users;  (100명)
// 2. SELECT * FROM orders WHERE user_id IN (1, 2, ..., 100);  (100명 분)
// → 총 2번 쿼리! ✅

SQL:

-- 1번째 쿼리
SELECT * FROM users;

-- 2번째 쿼리 (IN 절로 배치 조회)
SELECT * FROM orders
WHERE user_id IN (1, 2, 3, ..., 100);
-- → 100명의 주문을 한 번에 가져옴

해결 방법 3: Subquery (서브쿼리)

-- ✅ Subquery로 집계
SELECT
    u.id,
    u.name,
    (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u;

-- → 1번 쿼리로 완료 ✅
-- → orders 테이블은 서브쿼리로 인덱스 사용

해결 방법 비교

방법쿼리 수메모리중복적합
Fetch Join1번많음가능 ⚠️1:N (단일 컬렉션)
Batch FetchN/100번적음없음 ✅1:N (여러 컬렉션)
Subquery1번적음없음 ✅집계만 필요

꼬리 질문: Lazy Loading vs Eager Loading은?

Lazy Loading (지연 로딩):

  • 실제 사용 시점에 쿼리 실행
  • N+1 문제 발생 가능

Eager Loading (즉시 로딩):

  • 최초 조회 시 함께 로딩
  • 불필요한 데이터도 로딩 가능
// Lazy Loading (기본값)
@Entity
public class User {
    @OneToMany(fetch = FetchType.LAZY)  // 기본값
    private List<Order> orders;
}

User user = userRepository.findById(1);  // orders는 로딩 안 됨
user.getOrders().size();  // 이 시점에 쿼리 실행

// Eager Loading
@Entity
public class User {
    @OneToMany(fetch = FetchType.EAGER)
    private List<Order> orders;
}

User user = userRepository.findById(1);  // orders도 함께 로딩
// → 사용하지 않아도 항상 로딩 (비효율) ⚠️

권장:

  • 기본: Lazy Loading
  • 필요 시: Fetch Join으로 명시적 로딩

Q4. 서브쿼리 최적화 방법은?

답변

서브쿼리 성능 문제는 주로 **Correlated Subquery (상관 서브쿼리)**에서 발생합니다.

문제 1: Correlated Subquery

-- ❌ Correlated Subquery (느림)
SELECT u.name,
       (SELECT COUNT(*)
        FROM orders o
        WHERE o.user_id = u.id) AS order_count
FROM users u;

-- 실행:
-- users의 각 Row마다 서브쿼리 실행 (N번)
-- → users 100만 건 → 서브쿼리 100만 번 실행! ⚠️

해결 1: JOIN으로 변환:

-- ✅ JOIN 사용 (빠름)
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

-- 실행:
-- 1번의 JOIN + GROUP BY
-- → 훨씬 빠름! ✅

성능 비교:

방법쿼리 실행실행 시간 (100만 건)
Correlated Subquery100만 번300초
JOIN1번3초

문제 2: IN Subquery

-- ⚠️ IN Subquery (성능 주의)
SELECT *
FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 1000);

-- 실행 계획:
-- Subquery가 먼저 실행 → 결과를 IN 절로 비교
-- → orders 테이블 전체 스캔 가능 ⚠️

해결 1: EXISTS 사용 (더 빠를 수 있음):

-- ✅ EXISTS 사용
SELECT *
FROM users u
WHERE EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
      AND o.total > 1000
);

-- 장점:
-- 1. 첫 번째 매칭되는 Row만 찾으면 중단 (Short-circuit)
-- 2. 인덱스 활용 가능

해결 2: JOIN 사용:

-- ✅ JOIN 사용
SELECT DISTINCT u.*
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.total > 1000;

-- 또는 (중복 제거 필요 없으면)
SELECT u.*
FROM users u
WHERE u.id IN (
    SELECT DISTINCT user_id
    FROM orders
    WHERE total > 1000
);

IN vs EXISTS 비교:

특징INEXISTS
Short-circuit❌ 전체 스캔✅ 첫 매칭 시 중단
NULL 처리NULL 무시NULL 무관
적합서브쿼리 결과 작음서브쿼리 결과 큼

문제 3: Scalar Subquery in SELECT

-- ❌ SELECT 절의 Scalar Subquery (느림)
SELECT
    u.id,
    u.name,
    (SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS order_count,
    (SELECT SUM(total) FROM orders WHERE user_id = u.id) AS total_amount,
    (SELECT MAX(created_at) FROM orders WHERE user_id = u.id) AS last_order_date
FROM users u;

-- 문제:
-- 각 컬럼마다 서브쿼리 실행
-- → users 100만 건 × 3개 서브쿼리 = 300만 번 실행! ⚠️

해결: 단일 JOIN + GROUP BY:

-- ✅ JOIN으로 한 번에 집계
SELECT
    u.id,
    u.name,
    COUNT(o.id) AS order_count,
    SUM(o.total) AS total_amount,
    MAX(o.created_at) AS last_order_date
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

-- 성능:
-- 서브쿼리: 300만 번 실행 (300초)
-- JOIN: 1번 실행 (3초)
-- → 100배 빠름! ✅

꼬리 질문: WITH 절 (CTE)은 언제 사용하나요?

**CTE (Common Table Expression)**는 복잡한 쿼리를 읽기 쉽게 분리합니다.

-- ✅ WITH 절 사용
WITH high_value_users AS (
    SELECT user_id, SUM(total) AS total_amount
    FROM orders
    GROUP BY user_id
    HAVING SUM(total) > 10000
),
recent_orders AS (
    SELECT user_id, COUNT(*) AS order_count
    FROM orders
    WHERE created_at >= '2025-01-01'
    GROUP BY user_id
)
SELECT
    u.name,
    h.total_amount,
    r.order_count
FROM users u
JOIN high_value_users h ON u.id = h.user_id
LEFT JOIN recent_orders r ON u.id = r.user_id;

-- 장점:
-- 1. 가독성 향상
-- 2. 재사용 가능
-- 3. 디버깅 쉬움

주의: CTE Materialization:

-- PostgreSQL 12+: CTE는 기본적으로 Materialized (물리적 저장)
WITH temp AS (
    SELECT * FROM large_table WHERE ...
)
SELECT * FROM temp WHERE ...;
-- → temp 결과가 메모리/디스크에 저장됨

-- ✅ Inline으로 최적화 (PostgreSQL)
WITH temp AS NOT MATERIALIZED (
    SELECT * FROM large_table WHERE ...
)
SELECT * FROM temp WHERE ...;
-- → 서브쿼리처럼 최적화됨

Q5. Slow Query 분석 및 최적화 사례는?

답변

장애 사례: 주문 목록 조회 쿼리 30초 → 0.3초

증상:

  • 주문 목록 API 응답 시간 30초
  • DB CPU 100%
  • Slow Query Log 발생

1단계: Slow Query 확인:

-- MySQL Slow Query Log
-- Time: 30.5s
-- Rows examined: 5,000,000
SELECT
    o.id,
    o.total,
    u.name,
    u.email,
    (SELECT COUNT(*) FROM order_items WHERE order_id = o.id) AS item_count,
    (SELECT SUM(quantity) FROM order_items WHERE order_id = o.id) AS total_quantity
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'PENDING'
  AND o.created_at >= '2025-01-01'
ORDER BY o.created_at DESC
LIMIT 100;

2단계: 실행 계획 분석:

EXPLAIN ANALYZE SELECT ...;

출력:

Limit  (cost=50000..50100 rows=100)
  -> Sort  (cost=50000..55000 rows=1000000)  ← 정렬 비용 높음 ⚠️
        Sort Key: o.created_at DESC
        -> Hash Join  (cost=10000..40000 rows=1000000)
              Hash Cond: (o.user_id = u.id)
              -> Seq Scan on orders o  (cost=0..20000 rows=1000000)  ← Full Scan ⚠️
                    Filter: (status = 'PENDING' AND created_at >= '2025-01-01')
              -> Hash  (cost=5000..5000 rows=100000)
                    -> Seq Scan on users u
  SubPlan 1  ← Correlated Subquery 실행 100번 ⚠️
    -> Aggregate
          -> Seq Scan on order_items
                Filter: (order_id = o.id)
  SubPlan 2  ← Correlated Subquery 실행 100번 ⚠️
    -> Aggregate
          -> Seq Scan on order_items
                Filter: (order_id = o.id)

문제점:

  1. orders 테이블 Full Scan (인덱스 없음)
  2. Using filesort (정렬 비용 높음)
  3. Correlated Subquery 200번 실행

3단계: 최적화:

-- ✅ 1. 인덱스 생성
CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC);
-- → Full Scan 제거
-- → ORDER BY 인덱스 활용 (filesort 제거)

-- ✅ 2. Subquery를 JOIN으로 변환
SELECT
    o.id,
    o.total,
    u.name,
    u.email,
    COUNT(oi.id) AS item_count,
    SUM(oi.quantity) AS total_quantity
FROM orders o
JOIN users u ON o.user_id = u.id
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE o.status = 'PENDING'
  AND o.created_at >= '2025-01-01'
GROUP BY o.id, o.total, u.name, u.email
ORDER BY o.created_at DESC
LIMIT 100;

4단계: 개선 후 실행 계획:

EXPLAIN ANALYZE SELECT ...;

출력:

Limit  (cost=0..500 rows=100)
  -> Index Scan using idx_orders_status_created on orders o  ← Index Scan ✅
        Index Cond: (status = 'PENDING' AND created_at >= '2025-01-01')
        -> Hash Join
              Hash Cond: (o.user_id = u.id)
              -> Hash Join
                    Hash Cond: (oi.order_id = o.id)
                    -> Seq Scan on order_items oi

성능 결과:

항목BeforeAfter개선율
실행 시간30.5초0.3초100배
Rows Examined500만 건5,000건1,000배
CPU 사용률100%10%90% 감소

요약 체크리스트

Execution Plan 읽기

  • type: const > eq_ref > ref > range > index > ALL
  • key: 실제 사용된 인덱스 확인
  • rows: 예상 스캔 Row 수 (적을수록 좋음)
  • Extra: Using index (좋음), Using filesort/temporary (나쁨)

Join 전략

  • Nested Loop: 외부 테이블 작고, 내부에 인덱스 있을 때
  • Hash Join: 큰 테이블 조인, 등호 조건
  • Merge Join: 이미 정렬된 데이터

N+1 문제 해결

  • Fetch Join: 1:N 관계, 단일 컬렉션
  • Batch Fetch: 1:N 관계, 여러 컬렉션
  • Subquery: 집계만 필요

서브쿼리 최적화

  • Correlated Subquery → JOIN: 성능 크게 향상
  • IN → EXISTS: 큰 결과 집합일 때 유리
  • SELECT 절 서브쿼리 → JOIN: 한 번에 집계

Slow Query 최적화

  • 인덱스 추가: 자주 사용되는 WHERE, JOIN 컬럼
  • 실행 계획 분석: EXPLAIN ANALYZE로 병목 지점 찾기
  • 쿼리 재작성: Subquery → JOIN, 불필요한 컬럼 제거