Theme:

MySQL에 쿼리를 보내면 실행되기 전에 "어떻게 실행할지" 계획을 세웁니다. 이 계획은 누가, 어떤 기준으로 결정할까요?

옵티마이저란

옵티마이저(Optimizer)는 SQL 쿼리를 받아서 가장 효율적인 실행 방법을 찾는 MySQL 내부 컴포넌트입니다.

PLAINTEXT
사용자 쿼리 → 파서 → 옵티마이저 → 실행 엔진 → 결과

                  "어떤 인덱스를 쓸까?"
                  "어떤 순서로 조인할까?"
                  "서브쿼리를 풀어야 할까?"

MySQL의 옵티마이저는 **비용 기반 옵티마이저(Cost-Based Optimizer, CBO)**입니다. 여러 가능한 실행 계획 중에서 비용이 가장 낮은 것을 선택합니다.

비용(Cost)이란

옵티마이저가 말하는 "비용"은 실행 시간이 아니라, 내부적으로 계산한 추상적인 수치입니다.

비용의 구성 요소

PLAINTEXT
총 비용 = I/O 비용 + CPU 비용
  • I/O 비용: 디스크에서 데이터 페이지를 읽는 비용
  • CPU 비용: 행을 비교하고, 조건을 평가하고, 정렬하는 비용

MySQL 8.0에서는 mysql.server_costmysql.engine_cost 테이블에 비용 상수가 정의되어 있습니다.

SQL
-- 비용 상수 확인
SELECT * FROM mysql.server_cost;
SELECT * FROM mysql.engine_cost;
비용 상수기본값설명
row_evaluate_cost0.1행 하나를 평가하는 CPU 비용
memory_temptable_create_cost1.0메모리 임시 테이블 생성 비용
io_block_read_cost1.0디스크에서 페이지 읽기 비용
memory_block_read_cost0.25Buffer Pool에서 페이지 읽기 비용

통계 정보 — 옵티마이저의 판단 근거

옵티마이저가 비용을 계산하려면 "테이블에 행이 몇 개인지", "인덱스 값이 얼마나 다양한지" 등의 정보가 필요합니다. 이것이 통계 정보입니다.

테이블 통계

SQL
-- 테이블 통계 확인
SELECT * FROM mysql.innodb_table_stats WHERE table_name = 'orders';
통계설명
n_rows테이블의 예상 행 수
clustered_index_size클러스터드 인덱스 크기 (페이지 수)

인덱스 통계

SQL
-- 인덱스 통계 확인
SELECT * FROM mysql.innodb_index_stats WHERE table_name = 'orders';

-- SHOW INDEX로도 확인 가능
SHOW INDEX FROM orders;

핵심 지표는 **카디널리티(Cardinality)**입니다. 인덱스 컬럼의 고유 값 수를 추정한 값입니다.

PLAINTEXT
카디널리티가 높은 컬럼: email (거의 모든 값이 다름) → 인덱스 효과 좋음
카디널리티가 낮은 컬럼: gender ('M', 'F' 두 값) → 인덱스 효과 낮음

통계 갱신

통계 정보는 자동으로 갱신되지만 정확하지 않을 수 있습니다.

SQL
-- 수동 갱신
ANALYZE TABLE orders;

-- 통계 수집 방식 설정
SHOW VARIABLES LIKE 'innodb_stats_persistent';       -- ON: 영구 통계 (기본)
SHOW VARIABLES LIKE 'innodb_stats_persistent_sample_pages';  -- 샘플 페이지 수 (기본 20)

영구 통계(innodb_stats_persistent = ON)는 통계를 디스크에 저장하여 서버 재시작 후에도 유지됩니다. 샘플 페이지 수를 늘리면 통계가 더 정확해지지만 ANALYZE TABLE 시간이 길어집니다.

Histogram (MySQL 8.0)

MySQL 8.0에서 도입된 Histogram은 인덱스가 없는 컬럼의 데이터 분포를 파악하는 데 사용됩니다.

왜 필요한가

인덱스가 없는 컬럼에 대해 옵티마이저는 데이터가 균등하게 분포한다고 가정합니다. 하지만 실제로는 편향된 경우가 많습니다.

SQL
-- status 컬럼에 인덱스가 없다고 가정
-- 'active': 95%, 'inactive': 4%, 'banned': 1%

SELECT * FROM users WHERE status = 'banned';
-- 옵티마이저는 33%로 추정 (3개 값이 균등하다고 가정)
-- 실제로는 1%만 해당

Histogram 생성

SQL
-- 히스토그램 생성
ANALYZE TABLE users UPDATE HISTOGRAM ON status WITH 100 BUCKETS;

-- 히스토그램 확인
SELECT * FROM information_schema.COLUMN_STATISTICS
WHERE table_name = 'users' AND column_name = 'status'\G

-- 히스토그램 삭제
ANALYZE TABLE users DROP HISTOGRAM ON status;

Histogram을 생성하면 옵티마이저가 실제 데이터 분포를 알 수 있어 더 정확한 비용을 추정합니다.

Histogram 유형

  • Singleton: 각 값과 빈도를 정확히 기록 (고유 값이 적을 때)
  • Equi-height: 같은 높이의 버킷으로 분포를 근사 (고유 값이 많을 때)

조인 순서 결정

조인이 포함된 쿼리에서 테이블 접근 순서는 성능에 큰 영향을 미칩니다.

SQL
SELECT * FROM A
JOIN B ON A.id = B.a_id
JOIN C ON B.id = C.b_id
WHERE A.status = 'active';

가능한 조인 순서: A→B→C, A→C→B, B→A→C, B→C→A, C→A→B, C→B→A (6가지)

옵티마이저는 각 순서의 비용을 계산하여 가장 낮은 것을 선택합니다.

optimizer_search_depth

테이블이 많아지면 모든 순서를 탐색하는 것이 불가능합니다 (N개 테이블 → N! 조합).

SQL
-- 탐색 깊이 설정 (기본 62, 0이면 자동)
SHOW VARIABLES LIKE 'optimizer_search_depth';

이 값을 낮추면 옵티마이저가 더 빠르게(하지만 덜 최적으로) 계획을 세웁니다.

옵티마이저 힌트

옵티마이저가 잘못된 판단을 할 때, 힌트로 실행 계획을 유도할 수 있습니다.

MySQL 8.0 스타일 힌트 (권장)

SQL
-- 인덱스 사용 강제
SELECT /*+ INDEX(orders idx_customer) */ *
FROM orders WHERE customer_id = 42;

-- 인덱스 사용 금지
SELECT /*+ NO_INDEX(orders idx_customer) */ *
FROM orders WHERE customer_id = 42;

-- 조인 순서 강제
SELECT /*+ JOIN_ORDER(customers, orders) */ *
FROM customers c JOIN orders o ON c.id = o.customer_id;

-- 조인 방식 강제
SELECT /*+ HASH_JOIN(orders) */ *
FROM customers c JOIN orders o ON c.id = o.customer_id;

-- 서브쿼리 전략 지정
SELECT /*+ SEMIJOIN(@subq MATERIALIZATION) */ *
FROM orders WHERE customer_id IN (
    SELECT /*+ QB_NAME(subq) */ id FROM customers WHERE city = '서울'
);

전통적 힌트 (비권장)

SQL
-- FORCE INDEX
SELECT * FROM orders FORCE INDEX (idx_customer)
WHERE customer_id = 42;

-- IGNORE INDEX
SELECT * FROM orders IGNORE INDEX (idx_customer)
WHERE customer_id = 42;

-- STRAIGHT_JOIN (FROM절 순서대로 조인)
SELECT STRAIGHT_JOIN * FROM A JOIN B ON ...;

전통적 힌트보다 /*+ ... */ 형식이 유연하고 MySQL이 이해하지 못해도 에러가 발생하지 않으므로 권장됩니다.

optimizer_trace — 옵티마이저의 사고 과정 엿보기

옵티마이저가 왜 그런 결정을 내렸는지 상세히 추적할 수 있습니다.

SQL
-- optimizer_trace 활성화
SET optimizer_trace = "enabled=on";

-- 분석할 쿼리 실행
SELECT * FROM orders WHERE customer_id = 42 AND status = 'completed';

-- 트레이스 결과 확인
SELECT * FROM information_schema.OPTIMIZER_TRACE\G

-- 비활성화
SET optimizer_trace = "enabled=off";

트레이스 결과에서 확인할 수 있는 정보는 다음과 같습니다.

  • 어떤 인덱스 후보가 있었는지
  • 각 인덱스의 예상 비용
  • 왜 특정 인덱스를 선택/배제했는지
  • 조인 순서 결정 과정
JSON
{
  "rows_estimation": [
    {
      "table": "orders",
      "range_analysis": {
        "table_scan": { "rows": 1000000, "cost": 102345.6 },
        "potential_range_indexes": [
          { "index": "idx_customer", "usable": true },
          { "index": "idx_status", "usable": true }
        ],
        "chosen_range_access_summary": {
          "type": "range_scan",
          "index": "idx_customer",
          "rows": 100,
          "cost": 31.02
        }
      }
    }
  ]
}

옵티마이저가 실수하는 경우

1. 통계 정보가 부정확할 때

SQL
-- 대량 데이터 변경 후 통계가 낡은 경우
ANALYZE TABLE orders;  -- 통계 갱신으로 해결

2. 데이터 분포가 편향된 경우

SQL
-- 99%가 status='active'인데 옵티마이저가 균등 분포로 가정
-- Histogram으로 해결
ANALYZE TABLE orders UPDATE HISTOGRAM ON status;

3. 복잡한 조건에서 선택도 추정 실패

옵티마이저는 컬럼 간 상관관계를 모릅니다.

SQL
-- city='서울'이고 district='강남'인 비율을 독립적으로 추정
-- 실제로는 강남은 서울에만 있으므로 과소 추정
WHERE city = '서울' AND district = '강남'

정리

  • MySQL 옵티마이저는 통계 정보와 비용 모델을 바탕으로 가장 효율적인 실행 계획을 선택합니다
  • 통계가 부정확하면 옵티마이저가 잘못된 판단을 하므로, ANALYZE TABLE로 갱신이 중요합니다
  • MySQL 8.0의 Histogram은 인덱스 없는 컬럼의 분포를 파악하여 옵티마이저의 정확도를 높입니다
  • optimizer_trace를 사용하면 옵티마이저의 결정 과정을 상세히 확인할 수 있습니다
  • 옵티마이저 힌트는 최후의 수단이며, 먼저 통계 갱신과 인덱스 개선을 시도해야 합니다
댓글 로딩 중...