Skip to content

필사 모드: Database Query Optimizer 완전 가이드 2025: Volcano, Cascades, Cost-Based, Cardinality Estimation — PostgreSQL/MySQL 실전 분석

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며: SQL 한 줄의 마법

같은 쿼리, 1000배 차이

다음 쿼리를 보자:

SELECT u.name, o.total

FROM users u JOIN orders o ON u.id = o.user_id

WHERE u.country = 'KR' AND o.created_at > '2025-01-01';

이 한 줄짜리 SQL은 최소 **수십 가지 방법**으로 실행될 수 있다:

1. **users를 먼저 스캔** → filter → orders와 nested loop join

2. **orders를 먼저 스캔** → filter → users와 hash join

3. **양쪽 인덱스 사용** → merge join

4. **country 인덱스 + date 인덱스** 조합...

각 방식의 성능 차이는 **수백~수천 배**일 수 있다. 같은 쿼리, 같은 데이터, 완전히 다른 실행 시간.

그 선택을 하는 주인공이 **query optimizer**다. 당신이 SQL을 입력하면, 옵티마이저는:

1. 쿼리를 **관계 대수**로 변환

2. 가능한 **실행 계획들**을 생성

3. 각 계획의 **비용을 추정**

4. 가장 싼 것을 선택

이 글은 이 전 과정을 해부한다. Volcano에서 Cascades까지, cost-based optimization의 원리부터 현대 DB의 실전 구현까지.

왜 이걸 알아야 하는가?

- **느린 쿼리 디버깅**: EXPLAIN 결과를 정확히 해석하려면 옵티마이저를 알아야 한다.

- **인덱스 설계**: 옵티마이저가 어떻게 선택하는지 알면 인덱스가 왜 "안 쓰이는지" 안다.

- **통계 관리**: 왜 `ANALYZE`가 필요한지, 통계가 어떻게 성능을 좌우하는지.

- **DB 간 차이**: PostgreSQL, MySQL, Oracle의 옵티마이저는 서로 크게 다르다. 이식할 때 중요.

- **최신 NewSQL**: TiDB, CockroachDB의 옵티마이저는 어떻게 다른가?

1. 옵티마이저의 세 가지 단계

1단계: Parsing

SQL 텍스트를 **AST (Abstract Syntax Tree)** 로 변환한다.

SELECT * FROM t WHERE id = 5

Select

├── Projection: *

├── From: t

└── Where: Equal(id, 5)

parsing 단계에선 주로 문법 검사만 한다. 이후 catalog 검증(테이블/컬럼 존재 여부)도 한다.

2단계: Rewriting / Logical Optimization

AST를 **관계 대수(Relational Algebra)** 로 변환하고, **논리적 변환**을 적용한다:

- **Predicate pushdown**: WHERE 조건을 최대한 안쪽으로.

- **Projection pushdown**: 필요한 컬럼만 읽기.

- **Constant folding**: `1 + 2`를 `3`으로.

- **Subquery unnesting**: 서브쿼리를 조인으로 변환.

- **View expansion**: 뷰를 원래 쿼리로 확장.

이 단계는 **의미를 바꾸지 않는** 변환들이다.

3단계: Physical Optimization / Cost-Based

가능한 **물리적 실행 계획**들을 생성하고, 비용을 추정해 가장 싼 것을 고른다. 이 단계가 옵티마이저의 진짜 핵심이다.

- 어떤 **access method** 쓸까? (full scan, index scan, index only scan)

- 어떤 **join 알고리즘**? (nested loop, hash join, merge join)

- 어떤 **join 순서**?

- 어디서 **정렬**할까?

2. 관계 대수와 논리적 최적화

관계 대수 기본

관계 대수는 집합 이론에 기반한 쿼리 언어의 수학적 모델이다:

- **σ (selection)**: WHERE (행 필터링).

- **π (projection)**: SELECT 컬럼 (열 선택).

- **⋈ (join)**: 테이블 결합.

- **∪ (union)**, **∩ (intersection)**, **- (difference)**.

- **γ (group)**: GROUP BY.

SQL:

SELECT name FROM users WHERE age > 30

관계 대수:

π_name(σ_age>30(users))

대수적 동등 변환

관계 대수는 **변환 규칙**이 있다. 예를 들어:

- **Selection 분배**: `σ_p∧q(R) = σ_p(σ_q(R))`

- **Selection push through join**: `σ_p(R ⋈ S) = σ_p(R) ⋈ S` (p가 R의 컬럼만 사용할 때)

- **Projection push through selection**: `π_A(σ_p(R)) = π_A(σ_p(π_A∪필요컬럼(R)))`

- **Join 교환**: `R ⋈ S = S ⋈ R`

- **Join 결합**: `(R ⋈ S) ⋈ T = R ⋈ (S ⋈ T)`

이 규칙들을 적용하면 **같은 결과를 내는 다른 쿼리**가 된다.

Predicate Pushdown 예시

SELECT u.name, o.total

FROM users u JOIN orders o ON u.id = o.user_id

WHERE u.country = 'KR';

초기 계획:

π_{name,total}(σ_{country='KR'}(users ⋈ orders))

users와 orders를 먼저 조인하고 나서 country 필터링 → **비효율**.

Predicate pushdown 적용:

π_{name,total}(σ_{country='KR'}(users) ⋈ orders)

users에서 KR만 먼저 필터링하고 orders와 조인 → **훨씬 효율**.

현대 옵티마이저는 이런 변환을 수백 가지 자동 적용한다.

Projection Pushdown

SELECT name FROM users JOIN orders ON users.id = orders.user_id;

초기 계획: users와 orders의 **모든 컬럼**을 가져와서 조인 후 name만 뽑기.

최적화: **조인 전에** users에서 `(id, name)`, orders에서 `user_id`만 가져오기. 네트워크와 메모리 대폭 절약.

Subquery Unnesting

SELECT * FROM orders

WHERE user_id IN (SELECT id FROM users WHERE country = 'KR');

이 쿼리는 두 가지 방식으로 실행될 수 있다:

1. **Correlated**: orders의 각 행마다 서브쿼리 실행 → N번의 users 스캔 → 매우 느림.

2. **Unnested**: 조인으로 변환.

-- 옵티마이저가 이렇게 변환

SELECT o.* FROM orders o JOIN users u ON o.user_id = u.id

WHERE u.country = 'KR';

좋은 옵티마이저는 이런 변환을 자동으로 한다. 나쁜 옵티마이저(또는 오래된 것)는 correlated execution을 그대로 실행해서 극도로 느린 결과가 나온다.

3. 물리 연산자: 실행의 기본 단위

Access Method: 테이블 읽기

**Sequential Scan (Full Table Scan)**:

[Page 1][Page 2][Page 3]...

↓ ↓ ↓

모든 행을 순차적으로 읽음

- 큰 테이블 + 조건 없음: 빠름 (순차 I/O).

- 작은 결과 + 조건 있음: 느림 (모든 행을 버려야 함).

**Index Scan**:

B-Tree

↓ (조건에 맞는 키 찾기)

Heap → 해당 행 읽기

- 작은 결과: 매우 빠름.

- 큰 결과 (선택도 높음): random I/O 많아서 느림.

- **보통 전체의 10% 이상을 반환하면 full scan이 더 빠르다**.

**Index Only Scan**:

B-Tree

↓ (인덱스만으로 답변)

Heap 접근 없음

필요한 모든 컬럼이 인덱스에 있으면 heap을 건드리지 않는다. 가장 빠른 접근 방식.

**Bitmap Index Scan**:

Index 1 → bitmap 1

Index 2 → bitmap 2

AND/OR 결합

Heap scan (정렬된 순서)

여러 인덱스의 결과를 결합. 순차 접근 패턴으로 random I/O를 줄인다.

Join Algorithms

**Nested Loop Join (NLJ)**:

for r in R:

for s in S:

if r.key == s.key:

output(r, s)

- 복잡도: O(|R| × |S|).

- 인덱스가 있으면: O(|R| × log|S|).

- **작은 outer + 인덱스 있는 inner**에 최적.

**Hash Join**:

Build phase

hash_table = {}

for r in R:

hash_table[r.key] = r

Probe phase

for s in S:

if s.key in hash_table:

output(hash_table[s.key], s)

- 복잡도: O(|R| + |S|).

- 메모리 필요.

- **큰 테이블 + 인덱스 없음**에 최적.

- Parallel 친화적.

**Sort-Merge Join**:

sort(R, by=key)

sort(S, by=key)

i, j = 0, 0

while i < len(R) and j < len(S):

if R[i].key == S[j].key:

output(R[i], S[j])

중복 처리

elif R[i].key < S[j].key:

i += 1

else:

j += 1

- 복잡도: O(|R| log|R| + |S| log|S|).

- 이미 정렬되어 있으면: O(|R| + |S|).

- **ORDER BY나 인덱스로 이미 정렬된 경우**에 최적.

- Range join 지원.

선택의 기준

어떤 조인을 고를까? 옵티마이저는 **비용 함수**로 비교한다:

- NLJ cost: `|R| × cost_per_probe`

- Hash join cost: `|R| × hash_cost + |S| × probe_cost + build_memory`

- Merge join cost: `sort(R) + sort(S) + scan(R) + scan(S)`

여기서 핵심은 **|R|, |S|를 모른다는 것**이다. 옵티마이저는 이를 **추정**해야 한다.

4. Cost-Based Optimization: 비용의 수학

비용 함수

각 물리 연산자는 비용 공식을 가진다:

**Sequential Scan**:

cost = seq_page_cost × num_pages + cpu_tuple_cost × num_tuples

**Index Scan**:

cost = random_page_cost × (index_pages + heap_pages)

+ cpu_index_tuple_cost × num_matched

+ cpu_tuple_cost × num_matched

파라미터의 의미

PostgreSQL의 기본값:

seq_page_cost = 1.0 # 순차 디스크 페이지 I/O

random_page_cost = 4.0 # 랜덤 디스크 페이지 I/O

cpu_tuple_cost = 0.01 # 행 처리 CPU 비용

cpu_index_tuple_cost = 0.005 # 인덱스 엔트리 처리

cpu_operator_cost = 0.0025 # 연산자 실행 (=, <, ...)

이 값들은 **상대적**이다. `seq_page_cost = 1.0`은 단지 기준일 뿐이다.

SSD 시대의 조정

HDD 시대엔 random I/O가 sequential I/O보다 훨씬 느렸다 (~100배). 그래서 `random_page_cost = 4.0`으로 설정되어 있다. 하지만 SSD에선 차이가 작다. NVMe는 거의 같다.

SSD 환경에선

random_page_cost = 1.1

이 하나의 변경만으로도 옵티마이저가 **인덱스를 더 많이 사용**하게 된다.

effective_cache_size

effective_cache_size = 4GB

이 파라미터는 "OS page cache + PostgreSQL shared buffers에 몇 MB가 캐시될 수 있는가"를 알려준다. 옵티마이저는 이 값을 참고해 **인덱스 스캔의 예상 I/O**를 조정한다. 크면 인덱스 선호, 작으면 full scan 선호.

Join Cost 예시

SELECT * FROM A JOIN B ON A.id = B.a_id

A: 10,000행, B: 100,000행, B에 a_id 인덱스 있음.

**NLJ (A outer)**:

10,000 × (index_lookup_cost + matched_rows_cost)

≈ 10,000 × (4 + 0.01 × 10) (각 A 행마다 평균 10 매치)

≈ 40,000 + 1000 = 41,000

**Hash Join (B as build)**:

build: 100,000 × cpu_tuple_cost = 1,000

probe: 10,000 × (hash_cost + matched_cost) ≈ 10,000 × 0.02 = 200

total: 1,200

Hash가 훨씬 싸다. 옵티마이저는 hash join을 선택한다.

**주의**: 비용은 **상대적 비교용**이지 절대 시간이 아니다. "cost=1,000"이 "1초"가 아니다. 같은 쿼리 내에서 대안들의 순위를 매기는 용도다.

5. Cardinality Estimation: 옵티마이저의 생명

"얼마나 많은 행이 나올까?"

옵티마이저의 모든 비용 계산은 **각 연산자의 결과 행 수(cardinality)**에 의존한다. 추정이 틀리면 전체 계획이 틀린다.

SELECT * FROM users WHERE country = 'KR' AND age > 30;

질문:

- 전체 users 수: 100만

- country='KR'의 선택도: 20% → 20만

- age > 30의 선택도: 40% → 40만

- 둘 다 만족: ?

**선택도 독립 가정**: 20% × 40% = 8% → 8만 행 추정.

이 추정이 실제(예: 5만 행)와 비슷하면 좋은 계획. 완전히 틀리면(예: 50만 행) 재앙.

통계: 옵티마이저의 감각

통계가 정확해야 추정도 정확하다. PostgreSQL의 `pg_statistic`에 저장되는 정보:

1. **n_distinct**: 컬럼의 고유값 수.

2. **null_frac**: NULL 비율.

3. **most_common_vals (MCV)**: 가장 자주 나오는 값들.

4. **most_common_freqs**: MCV들의 빈도.

5. **histogram_bounds**: 값 분포 히스토그램 (균등 버킷).

6. **correlation**: 물리 순서와 논리 순서의 상관관계.

히스토그램

컬럼 age의 히스토그램 (100개 버킷)

[0-5][5-12][12-20][20-25][25-28]...[70-120]

5% 5% 5% 5% 5% 5%

각 버킷은 전체의 균등 비율(예: 1%)을 담는다. 버킷 범위가 좁을수록 값이 밀집된 구간.

쿼리 `WHERE age > 30`:

- 30 이상 버킷이 몇 개인가?

- 각 버킷은 전체의 1%.

- 추정: `버킷수 × 1% × 총행수`.

MCV (Most Common Values)

값이 매우 편중된 경우 (예: `status` 컬럼의 90%가 'active'):

- MCV: `['active', 'inactive', 'pending']`

- MCV freqs: `[0.9, 0.08, 0.02]`

`WHERE status = 'active'` → 추정: 0.9 × 총행수.

MCV에 없는 값은 히스토그램으로 추정.

선택도 계산의 함정

**함정 1: 독립 가정의 오류**

WHERE city = 'Seoul' AND country = 'Korea'

옵티마이저: `P(city='Seoul') × P(country='Korea') = 0.1 × 0.2 = 0.02`

실제: `P(city='Seoul' AND country='Korea') = 0.1` (Seoul은 Korea에만 있음)

5배 차이. 잘못된 계획으로 이어진다.

**해결**: **확장 통계(Extended Statistics)**. PostgreSQL 10+에서:

CREATE STATISTICS ON (city, country) FROM addresses;

ANALYZE addresses;

이제 옵티마이저가 두 컬럼의 **상관관계**를 안다.

**함정 2: LIKE 패턴**

WHERE name LIKE 'A%'

선택도 추정이 어렵다. 옵티마이저는 MCV와 히스토그램으로 시도하지만 종종 틀린다.

**함정 3: 조인 결과 추정**

SELECT * FROM A JOIN B ON A.key = B.key

결과 행 수 추정:

|R ⋈ S| ≈ |R| × |S| / max(n_distinct(A.key), n_distinct(B.key))

이 공식은 "균등 분포" 가정에 기반한다. 실제론 치우쳐 있으면 오차가 크다.

ANALYZE의 중요성

통계는 `ANALYZE` 명령으로 업데이트된다:

ANALYZE users; -- 특정 테이블

ANALYZE; -- 전체

PostgreSQL은 **autovacuum**이 주기적으로 ANALYZE를 실행한다. 그러나:

- 대량 INSERT/UPDATE 후엔 수동 ANALYZE 권장.

- 통계가 오래되면 옵티마이저가 잘못된 계획을 고른다.

- 새 테이블에 ANALYZE 없이 대량 조회하면 끔찍한 계획이 나올 수 있다.

6. Volcano Optimizer

Goetz Graefe의 혁신

1993년 Goetz Graefe가 발표한 **Volcano Optimizer**는 현대 query optimizer의 시작점이다. 핵심 아이디어:

> **"변환 규칙의 집합으로 모든 가능한 계획을 생성하고, 가장 싼 것을 고른다."**

Operator Tree

쿼리는 **logical operator**의 트리로 표현된다:

Join

├── Scan(A)

└── Scan(B)

옵티마이저의 목표는 이를 **physical operator**의 트리로 변환하면서 비용을 최소화하는 것:

HashJoin

├── SeqScan(A)

└── IndexScan(B)

Transformation Rules

Volcano는 **변환 규칙**들의 집합으로 탐색한다:

- **Logical → Logical**: Join commutativity, associativity.

- **Logical → Physical**: `Join → HashJoin`, `Join → NestedLoopJoin` 등.

- **Enforcer**: 정렬이 필요하면 Sort 추가.

Top-down 탐색

Volcano는 탐색을 **top-down**으로 진행한다:

1. 루트 연산자부터 시작.

2. 각 연산자에 가능한 물리 구현을 나열.

3. 자식들도 재귀적으로 최적화.

4. 모든 하위 트리의 비용을 계산 후 최적 선택.

Memoization

같은 하위 문제를 여러 번 만나면 재계산하면 비싸다. Volcano는 **memoization**(계산 결과 캐싱)을 사용한다. 같은 하위 식은 한 번만 최적화.

문제점

Volcano는 훌륭하지만 몇 가지 한계가 있었다:

1. **Exhaustive search**: 모든 계획을 생성하려 해서 조인 수가 많으면 폭발.

2. **Top-down의 비효율**: 상위에서 결정이 하위로 전파되며 여러 번 재방문.

이를 개선한 것이 **Cascades**다.

7. Cascades Optimizer

Graefe의 차기작

**Cascades** (1995)도 Goetz Graefe의 작품이다. SQL Server, CockroachDB, Apache Calcite, Greenplum의 옵티마이저가 모두 Cascades 기반.

주요 개선

1. **Unified logical/physical transformations**: 규칙을 통일된 방식으로 표현.

2. **Memoization 구조 개선**: "memo" 데이터 구조로 중복 최적화 방지.

3. **Property propagation**: 정렬, 분포 같은 속성을 전파.

4. **Branch-and-bound pruning**: 명백히 나쁜 계획은 일찍 포기.

Memo 구조

Memo는 **동등한 식들의 그룹** 집합이다:

Group 1: σ_p(R)

Group 2: σ_p(R) ⋈ S, (R ⋈ S with σ_p pushed)

Group 3: A ⋈ B, B ⋈ A (commutativity)

각 group은 **동일한 결과**를 내는 여러 표현 방식을 담는다. 옵티마이저는 group 수준에서 비용 비교.

Property

**Physical Properties**는 계획이 가지는 특성이다:

- **Ordering**: 결과가 정렬되어 있는가?

- **Distribution**: 분산 DB에서 데이터가 어떻게 파티션되어 있나?

- **Uniqueness**: 결과에 중복이 없는가?

부모 연산자가 특정 property를 요구하면(예: Merge Join이 정렬된 입력 요구), 자식이 그걸 제공하거나 **enforcer**를 추가한다(Sort).

Pruning

탐색이 폭발하는 걸 막기 위해 **pruning**:

- 이미 찾은 최적 비용보다 높은 하위 문제는 스킵.

- Branch-and-bound 알고리즘.

Cascades 기반 옵티마이저들

- **SQL Server**: 자체 Cascades 기반 구현.

- **PostgreSQL**: **Cascades 아님** (dynamic programming 기반).

- **CockroachDB**: Cascades 기반.

- **Apache Calcite**: Cascades 기반, Hive/Flink/Beam에서 사용.

- **Presto/Trino**: Calcite 영감 + 자체 구현.

8. PostgreSQL의 옵티마이저

구조

PostgreSQL은 Volcano/Cascades보다 조금 다른 접근을 쓴다:

- **Rewriting** → **Logical plan**

- **Plan generation**: Dynamic programming + Genetic algorithm.

- **Cost estimation**: 이산 비용 함수.

- **Plan selection**: 가장 싼 것.

Dynamic Programming Join Ordering

조인 순서 결정에 **Dynamic Programming (DP)** 를 쓴다. 각 부분집합의 최적 조인을 구하고 조합한다.

- 복잡도: O(3^n) 정도.

- **n ≤ 12**: DP로 정확한 최적.

- **n > 12**: `geqo_threshold` 초과 → Genetic Algorithm으로 전환.

Genetic Algorithm (GEQO)

많은 테이블의 조인(예: 15개)에서는 DP가 너무 느리므로:

1. 무작위 조인 순서 생성.

2. "적합도" (예상 비용) 평가.

3. 좋은 것을 교차/변이.

4. 여러 세대 진화.

5. 가장 좋은 것 선택.

정확한 최적은 아니지만 합리적인 해를 빠르게 찾는다. `geqo = on` (기본).

Generic vs Custom Plan

PostgreSQL은 prepared statement에 대해 특별한 동작을 한다:

- **Custom plan**: 매 실행마다 파라미터 기반 최적화.

- **Generic plan**: 한 번만 최적화, 이후 재사용.

PostgreSQL은 처음 몇 번은 custom, 이후 generic이 더 싸면 generic으로 전환. 이로 인해 **"처음엔 빠르다가 갑자기 느려지는"** 현상이 발생할 수 있다.

EXPLAIN

옵티마이저의 결정을 보려면 EXPLAIN:

EXPLAIN (ANALYZE, BUFFERS, VERBOSE)

SELECT u.name, o.total

FROM users u JOIN orders o ON u.id = o.user_id

WHERE u.country = 'KR';

Hash Join (cost=10.00..250.00 rows=500 width=64) (actual time=0.1..5.0 rows=487 loops=1)

Hash Cond: (o.user_id = u.id)

Buffers: shared hit=234

-> Seq Scan on orders o (cost=0.00..150.00 rows=10000 width=16)

-> Hash (cost=8.00..8.00 rows=100 width=56)

-> Index Scan using idx_users_country on users u

Index Cond: (country = 'KR')

해석:

- **cost**: 추정 비용 (시작_비용..전체_비용).

- **rows**: **추정** 행 수.

- **actual**: 실제 실행 시 측정값.

- **loops**: 이 노드가 실행된 횟수.

**추정 rows vs actual rows 차이가 크면 통계 문제**. ANALYZE 필요.

9. MySQL의 옵티마이저

역사

MySQL의 옵티마이저는 오랫동안 **heuristics 기반**이었다:

- 항상 테이블을 고정 순서로 조인 (왼쪽 깊은 트리).

- 인덱스 사용 여부를 규칙으로 결정.

5.6 이후 **cost-based optimizer**를 도입하고 지속적으로 개선 중이다.

Histograms

MySQL 8.0부터 히스토그램 지원:

ANALYZE TABLE users UPDATE HISTOGRAM ON country WITH 100 BUCKETS;

PostgreSQL보다 늦게 추가되었지만 효과적이다.

Optimizer Hints

MySQL은 옵티마이저에게 힌트를 줄 수 있다:

SELECT /*+ INDEX(users idx_country) */ *

FROM users WHERE country = 'KR';

이는 "country 인덱스 써"라는 지시다. PostgreSQL은 힌트를 공식 지원하지 않는다 (철학적 차이).

ICP (Index Condition Pushdown)

MySQL의 유용한 기능. WHERE 조건 중 일부를 **인덱스 수준**에서 평가:

SELECT * FROM users WHERE country = 'KR' AND age > 30;

인덱스 `(country, age)`가 있으면:

1. 일반적: country='KR' 인덱스 매치 → 행 fetch → age 검사.

2. ICP: country='KR' 인덱스 매치 → **age 조건을 인덱스 수준에서 체크** → 통과한 것만 fetch.

random I/O를 크게 줄인다.

Join Buffer (Block Nested Loop)

인덱스가 없을 때 MySQL의 기본 조인은 **Block Nested Loop**:

join_buffer_size 만큼 outer의 여러 행을 메모리에 모음

inner table을 한 번 스캔하면서 각 outer 블록과 매치

I/O 줄임

Hash join은 MySQL 8.0.18부터 추가되었다. 이전엔 block NLJ가 유일한 대안이었다.

10. 옵티마이저의 한계와 실전 기법

한계 1: 통계가 없거나 오래됨

**증상**: EXPLAIN의 rows 추정이 실제와 심하게 다름.

**해결**:

ANALYZE my_table; -- PostgreSQL

ANALYZE TABLE my_table; -- MySQL

자동 ANALYZE가 작동하는지 확인. 큰 업데이트 후엔 수동 실행.

한계 2: 조인 수 폭발

20개 테이블 조인은 옵티마이저를 질리게 한다. 시간이 기하급수적으로 늘어난다.

**해결**:

- **View로 분해**: 자주 쓰이는 조합을 뷰로.

- **Subquery로 분리**: 부분적으로 먼저 계산.

- **PostgreSQL**: `join_collapse_limit`, `from_collapse_limit` 조정.

- **MySQL**: optimizer_search_depth 조정.

한계 3: 상관 컬럼

WHERE city = 'Seoul' AND country = 'Korea'

독립 가정이 틀려 추정 오류. **Extended statistics**(PostgreSQL) 또는 **histograms** + correlation 정보 필요.

한계 4: Skewed data

특정 값이 극도로 많은 경우:

status 컬럼: 'active' 99%, 'deleted' 1%

"all-or-nothing" 쿼리에서 문제:

SELECT * FROM t WHERE status = 'deleted';

- 통계 없이: "행 수의 50%" 추정 → full scan.

- MCV로: "1%" 추정 → 인덱스 사용.

MCV가 정확하게 잡혀야 한다.

한계 5: 파라미터 의존 쿼리

PREPARE p AS SELECT * FROM t WHERE x = $1 AND y > $2;

`$1 = 'rare_value'`와 `$1 = 'common_value'`에 맞는 최적 계획이 다를 수 있다. Generic plan은 두 경우 모두에 최적일 수 없다.

**해결**:

- **Custom plan 강제**: PostgreSQL의 `plan_cache_mode = force_custom_plan`.

- **동적 SQL**: 파라미터화 포기.

실전 기법: Query Rewriting

옵티마이저가 못 푸는 문제를 **SQL 재작성**으로 해결:

**원본 (느림)**:

SELECT * FROM orders

WHERE EXISTS (

SELECT 1 FROM users

WHERE users.id = orders.user_id AND users.country = 'KR'

);

**재작성 (빠름)**:

SELECT o.* FROM orders o

INNER JOIN users u ON o.user_id = u.id

WHERE u.country = 'KR';

현대 옵티마이저는 대부분 자동 변환하지만, 항상은 아니다.

실전 기법: Materialized View

자주 실행되는 비싼 쿼리를 **미리 계산**해서 저장:

CREATE MATERIALIZED VIEW user_order_summary AS

SELECT u.id, u.name, COUNT(o.id) as order_count, SUM(o.total) as total_spent

FROM users u LEFT JOIN orders o ON u.id = o.user_id

GROUP BY u.id, u.name;

REFRESH MATERIALIZED VIEW user_order_summary;

옵티마이저는 (PostgreSQL 기본으론) 자동 활용하지 않지만, 애플리케이션에서 직접 사용 가능.

실전 기법: Hint or Plan Pinning

마지막 수단: 특정 계획 강제.

**Oracle**:

SELECT /*+ INDEX(t idx_t_name) */ * FROM t WHERE name = 'foo';

**PostgreSQL** (pg_hint_plan 확장):

/*+ IndexScan(t idx_t_name) */

SELECT * FROM t WHERE name = 'foo';

**MySQL**:

SELECT * FROM t USE INDEX (idx_t_name) WHERE name = 'foo';

힌트는 유혹적이지만 위험하다. 데이터 분포가 바뀌면 더 이상 최적이 아닐 수 있다. 최후의 수단으로.

11. 분산 DB의 옵티마이저

추가 고려사항

분산 DB(CockroachDB, TiDB, Spanner)의 옵티마이저는 추가로 고려할 것들:

- **데이터 위치(locality)**: 어떤 노드에 어떤 데이터가 있는가.

- **네트워크 비용**: 조인을 어디서 할까?

- **병렬성**: 여러 노드에서 동시 실행.

- **Exchange 연산자**: 노드 간 데이터 이동.

Distributed Join Strategies

**Broadcast Join**: 작은 테이블을 모든 노드에 복제.

Small table → broadcast → 각 노드에서 local join

**Shuffle Join (Hash Redistribution)**: 조인 키 해시로 노드에 재분배.

Both tables → 조인 키 해시 → 같은 키가 같은 노드로

**Collocated Join**: 두 테이블이 이미 같은 키로 파티션되어 있으면 로컬에서 바로.

옵티마이저는 이들의 비용을 비교해 선택한다.

TiDB의 옵티마이저

TiDB는 **Cost-Based + Rule-Based** 하이브리드:

- Rule-based: 항상 적용되는 변환 (predicate pushdown 등).

- Cost-based: 계획 선택에 cardinality + 비용 모델.

- Statistics: 히스토그램 + Count-Min Sketch for skew 처리.

분산 환경의 추가 복잡도를 극복하기 위해 많은 연구가 진행 중이다.

퀴즈로 복습하기

**A.** **통계(statistics)**, 특히 **정확한 cardinality 추정**이다. 옵티마이저의 모든 비용 계산은 각 단계에서 나오는 예상 행 수에 기반한다. 추정이 틀리면:

1. 선택한 access method가 부적절 (큰 결과에 인덱스 스캔, 작은 결과에 full scan).

2. 조인 알고리즘 잘못 선택 (nested loop이 최악).

3. 조인 순서 잘못 → 중간 결과 폭발.

통계가 없거나 오래되면 이런 일이 반드시 일어난다. 그래서 데이터베이스 튜닝의 첫 번째 단계는 항상 **"ANALYZE를 실행했는가?"** 를 확인하는 것이다. 대량 INSERT/UPDATE 후, 새 인덱스 추가 후엔 수동 ANALYZE가 필수다.

**A.** 옵티마이저가 **인덱스 스캔을 더 선호**하게 된다. 이유:

- Index Scan은 heap에 random I/O를 발생시킨다.

- 기본값 4.0은 random I/O가 sequential의 4배 느리다는 가정 — HDD 시대 유산.

- 현대 SSD/NVMe에선 차이가 거의 없다 (1.1이 더 현실적).

이 변경 후 변화:

1. 더 많은 쿼리가 인덱스 스캔으로 전환.

2. 선택도가 낮은 (적은 행 반환) 조건에서 full scan 회피.

3. 일반적으로 전체 성능 향상, 특히 random I/O가 저렴한 환경에서.

**주의**: 이 파라미터는 옵티마이저의 **추정**을 바꿀 뿐, 실제 성능을 직접 바꾸진 않는다. 환경에 맞지 않으면 잘못된 계획으로 이어질 수 있다. SSD에선 1.1~1.5, HDD에선 4.0 유지, 네트워크 스토리지는 그 중간이 적절하다.

**A.** **문제**: 옵티마이저는 `P(A and B) = P(A) × P(B)`로 가정한다. 두 조건이 독립이면 맞지만, 실제론 **상관관계**가 있는 경우가 많다:

WHERE city = 'Seoul' AND country = 'Korea'

- 추정: 10% × 20% = 2%

- 실제: 10% (Seoul은 Korea에만)

- **5배 과소 추정** → 잘못된 계획.

**PostgreSQL 해결책 (10+)**: **Extended Statistics**

CREATE STATISTICS stat_addr (dependencies) ON city, country FROM addresses;

CREATE STATISTICS stat_addr_ndist (ndistinct) ON city, country FROM addresses;

ANALYZE addresses;

세 가지 타입:

1. **dependencies**: 함수적 의존성 감지 (city → country).

2. **ndistinct**: 여러 컬럼의 고유 조합 수.

3. **MCV (multivariate)**: 여러 컬럼의 가장 흔한 값 조합.

이 정보로 옵티마이저는 상관관계를 고려할 수 있다. 매우 효과적이지만 추가 통계 유지 비용이 있으므로 **문제가 되는 쿼리에 선택적으로** 적용.

**A.**

**Volcano (1993)**:

- Top-down 탐색.

- Exhaustive한 계획 생성.

- Memoization 있음.

- 변환 규칙 기반.

- **한계**: 많은 조인에서 탐색 공간 폭발, property 처리 부족.

**Cascades (1995)**:

- Volcano의 개선판 (같은 저자).

- **Memo 데이터 구조**로 동등한 식들을 그룹화.

- **Physical properties 전파** (ordering, distribution).

- **Branch-and-bound pruning**.

- **Enforcer**: 필요한 속성을 제공하기 위해 Sort, Exchange 추가.

**비유**: Volcano가 "모든 계획을 만들고 골라보자"라면, Cascades는 "그룹 단위로 비교하고, 필요한 속성만 요구하며, 명백히 나쁜 건 일찍 버리자".

**사용처**: SQL Server, CockroachDB, Apache Calcite (Cascades 기반). PostgreSQL은 둘 다 아닌 자체 DP 기반. Volcano는 연구/강의에 주로 등장하고, Cascades가 실전의 표준이 되었다.

**A.** 이는 옵티마이저가 **잘못된 추정**을 했다는 신호다. 진단 순서:

**1. ANALYZE 실행 여부 확인**

SELECT last_analyze, last_autoanalyze FROM pg_stat_user_tables WHERE relname='mytable';

너무 오래됐으면 수동 실행: `ANALYZE mytable;`

**2. 통계 품질 확인**

SELECT attname, n_distinct, most_common_vals, histogram_bounds

FROM pg_stats WHERE tablename = 'mytable';

- `n_distinct`가 이상하면 샘플 크기 부족 → `ALTER TABLE ... SET STATISTICS 1000`.

- MCV가 비어 있으면 데이터가 균등 분포가 아닐 수 있음.

**3. 상관 컬럼 의심**

여러 WHERE 조건이 있는데 모두 함께 나타나는 경우가 있다면 extended statistics:

CREATE STATISTICS s ON col1, col2 FROM mytable;

**4. 서브쿼리/함수 호출**

`WHERE col = get_default()` 같은 함수 호출은 선택도를 추정하지 못한다.

**5. 조인 결과 오추정**

중간 조인 결과가 이상하면 조인 참여 컬럼의 `n_distinct`가 부정확할 가능성.

**6. 힌트/재작성**

위 단계가 모두 실패하면 pg_hint_plan 확장으로 특정 계획 강제, 또는 쿼리 재작성 (예: CTE 사용, 서브쿼리 인라인).

핵심은 "통계 → 데이터 특성 → 쿼리 구조" 순서로 접근하는 것이다. 대부분의 문제는 ANALYZE 한 번으로 해결된다.

마치며: 옵티마이저는 협력자다

핵심 정리

1. **옵티마이저는 통계에 의존**한다. ANALYZE를 소홀히 하지 마라.

2. **Cost 기반**이다. 비용 파라미터는 환경에 맞춰 튜닝.

3. **Cardinality estimation**이 생명이다. 상관 컬럼엔 extended statistics.

4. **EXPLAIN을 읽어라**. estimated vs actual의 차이가 진단의 시작.

5. **조인 순서**는 옵티마이저의 어려운 과제. 테이블 수가 많으면 힌트나 재작성 고려.

6. **Cascades**가 현대의 표준이지만 PostgreSQL은 자체 DP.

7. **힌트는 최후의 수단**. 통계와 구조부터 고쳐라.

옵티마이저와의 협력

옵티마이저를 **적**이 아니라 **협력자**로 생각하자:

- **통계를 주어라**: ANALYZE, extended statistics.

- **명확한 쿼리를 써라**: 옵티마이저가 이해하기 쉬운 SQL.

- **인덱스를 제공하라**: 필요한 access path 확보.

- **측정하라**: EXPLAIN ANALYZE로 현실을 확인.

- **신뢰하라, 그러나 검증하라**: 대부분의 경우 옵티마이저가 맞다.

마지막 교훈

옵티마이저는 수십 년간의 연구와 수만 명의 엔지니어가 쏟아부은 결과다. 그것을 이기려 들지 말자. 대신 옵티마이저가 **올바른 결정을 내리게 도와주자**. 정확한 통계, 적절한 인덱스, 명료한 쿼리. 이 셋이 모이면 SQL은 마법처럼 빠르게 돈다.

다음에 느린 쿼리를 만나면, 먼저 물어보자:

- "통계는 최신인가?"

- "옵티마이저가 본 cardinality가 실제와 맞는가?"

- "내가 제공한 인덱스와 통계로 최적화할 수 있는 구조인가?"

답이 "그렇다"라면, 옵티마이저는 거의 항상 올바른 답을 준다.

참고 자료

- [The Volcano Optimizer Generator (Graefe, 1993)](https://www.cse.iitb.ac.in/infolab/Data/Courses/CS632/Papers/volcano-graefe.pdf)

- [The Cascades Framework for Query Optimization (Graefe, 1995)](https://www.cse.iitb.ac.in/infolab/Data/Courses/CS632/Papers/Cascades-graefe.pdf)

- [Access Path Selection in a Relational Database Management System (Selinger et al., 1979)](https://www2.cs.duke.edu/courses/compsci516/cps216/spring03/papers/selinger-etal-1979.pdf) - System R의 고전

- [PostgreSQL Query Planner](https://www.postgresql.org/docs/current/runtime-config-query.html)

- [How PostgreSQL's Query Planner Works (Interdb)](https://www.interdb.jp/pg/pgsql03.html)

- [MySQL Query Optimization](https://dev.mysql.com/doc/refman/8.0/en/optimization.html)

- [Apache Calcite Documentation](https://calcite.apache.org/docs/)

- [CockroachDB SQL Optimizer (Cascades-based)](https://www.cockroachlabs.com/blog/building-a-sql-optimizer/)

- [Database Internals (Alex Petrov), Chapter 12](https://www.databass.dev/)

- [Use the Index, Luke!](https://use-the-index-luke.com/) - 인덱스 사용법 완전 가이드

현재 단락 (1/497)

다음 쿼리를 보자:

작성 글자: 0원문 글자: 17,805작성 단락: 0/497