코스트 모델이란?
MySQL 옵티마이저는 여러 실행 계획 중에서 가장 비용이 낮은 계획을 선택합니다. 이때 비용을 계산하는 기준이 코스트 모델(Cost Model)입니다.
쿼리 실행에 필요한 작업들
SELECT *
FROM orders o
INNER JOIN customers c ON o.customer_id = c.id
WHERE o.amount > 10000
ORDER BY o.created_at;이 쿼리를 실행하려면:
- 디스크에서 데이터 페이지 읽기
- InnoDB 버퍼 풀에서 데이터 페이지 읽기
- 인덱스 키 비교
- WHERE 조건 레코드 평가
- 정렬을 위한 임시 테이블 생성
- 조인 수행
각 작업마다 비용이 다릅니다. 디스크 읽기는 메모리 읽기보다 느리고, 정렬은 단순 비교보다 비쌉니다.
코스트 모델의 역할
실행 계획 A = (디스크 읽기 × 100) + (메모리 읽기 × 1000) + (키 비교 × 500)
실행 계획 B = (디스크 읽기 × 10) + (메모리 읽기 × 5000) + (키 비교 × 2000)옵티마이저는 이런 식으로 각 실행 계획의 총 비용을 계산하고, 가장 비용이 낮은 계획을 선택합니다.
MySQL 5.7 이전 vs 5.7+
MySQL 5.7 이전: 하드코딩된 비용
각 단위 작업의 비용이 MySQL 소스 코드에 상수로 고정되어 있었습니다.
// MySQL 소스 코드 (의사 코드)
const double DISK_READ_COST = 1.0;
const double MEMORY_READ_COST = 0.25;
const double KEY_COMPARE_COST = 0.05;문제점
- 서버 하드웨어가 바뀌어도 비용은 동일
- 사용자가 조정 불가능
MySQL 5.7+: 조정 가능한 코스트 모델
비용 정보를 시스템 테이블에 저장하여 관리자가 조정할 수 있게 되었습니다.
-- 코스트 모델 테이블 확인
SELECT * FROM mysql.server_cost;
SELECT * FROM mysql.engine_cost;MySQL 8.0에서는 히스토그램과 버퍼 풀 메모리 적재 비율까지 반영하여 정확도가 더 높아졌습니다.
코스트 모델 구성
server_cost 테이블
쿼리 처리 과정의 일반적인 작업 비용을 관리합니다.
SELECT
cost_name,
default_value,
cost_value,
comment
FROM mysql.server_cost;| cost_name | default_value | 설명 |
|---|---|---|
| disk_temptable_create_cost | 20.00 | 디스크 임시 테이블 생성 |
| disk_temptable_row_cost | 0.50 | 디스크 임시 테이블 레코드 읽기 |
| key_compare_cost | 0.05 | 인덱스 키 비교 |
| memory_temptable_create_cost | 1.00 | 메모리 임시 테이블 생성 |
| memory_temptable_row_cost | 0.10 | 메모리 임시 테이블 레코드 읽기 |
| row_evaluate_cost | 0.10 | 레코드 조건 평가 |
engine_cost 테이블
스토리지 엔진별 작업 비용을 관리합니다.
SELECT
engine_name,
cost_name,
default_value,
cost_value,
comment
FROM mysql.engine_cost;| engine_name | cost_name | default_value | 설명 |
|---|---|---|---|
| default | io_block_read_cost | 1.00 | 디스크 데이터 페이지 읽기 |
| default | memory_block_read_cost | 0.25 | 메모리 데이터 페이지 읽기 |
engine_name
- default: 모든 스토리지 엔진에 적용되는 기본값
- InnoDB, MyISAM, MEMORY: 특정 엔진별로 다른 값 설정 가능
현재는 InnoDB에서만 코스트 모델이 의미 있게 작동합니다.
단위 작업 비용이 미치는 영향
각 비용을 조정하면 옵티마이저의 선택이 달라집니다. 하지만 절대 함부로 변경하지 마세요.
row_evaluate_cost
레코드 조건 평가 비용입니다.
값을 높이면
-- 많은 레코드를 평가하는 풀 테이블 스캔이 비쌈
-- 적은 레코드를 평가하는 인덱스 레인지 스캔이 선호됨풀 테이블 스캔보다 인덱스 레인지 스캔을 선택할 가능성이 높아집니다.
key_compare_cost
인덱스 키 비교 비용입니다.
값을 높이면
-- 정렬(ORDER BY)처럼 키 비교가 많은 작업이 비쌈
-- 가능하면 정렬을 피하는 실행 계획 선택정렬을 수행하지 않는 방향의 실행 계획을 선택할 가능성이 높아집니다.
io_block_read_cost
디스크 읽기 비용입니다.
값을 높이면
-- 디스크 읽기가 많은 인덱스는 비쌈
-- InnoDB 버퍼 풀에 많이 적재된 인덱스를 선호버퍼 풀 적중률이 높은 인덱스를 사용할 가능성이 높아집니다.
memory_block_read_cost
메모리 읽기 비용입니다.
값을 높이면
-- 메모리 읽기도 비싸다고 판단
-- 버퍼 풀 적중률이 낮아도 해당 인덱스를 사용 가능버퍼 풀에 적재되지 않았어도 효율적인 인덱스라면 사용할 가능성이 높아집니다.
disk_temptable_create_cost
디스크 임시 테이블 생성 비용입니다.
값을 높이면
-- 디스크 임시 테이블 생성을 회피
-- GROUP BY, ORDER BY를 인덱스로 처리하려 시도임시 테이블을 만들지 않는 방향의 실행 계획을 선택할 가능성이 높아집니다.
기본 EXPLAIN
일반적인 EXPLAIN은 비용을 보여주지 않습니다.
EXPLAIN
SELECT *
FROM employees
WHERE first_name = 'Matt';| id | select_type | table | type | key | rows | filtered |
|---|---|---|---|---|---|---|
| 1 | SIMPLE | employees | ref | ix_firstname | 233 | 100.00 |
예상 레코드 수(rows)만 표시되고 비용은 표시되지 않습니다.
EXPLAIN FORMAT=TREE
간단하게 비용을 확인할 수 있습니다.
EXPLAIN FORMAT=TREE
SELECT *
FROM employees
WHERE first_name = 'Matt';결과
-> Index lookup on employees using ix_firstname (first_name='Matt')
(cost=256.10 rows=233)해석
- cost=256.10: 예상 비용
- rows=233: 예상 레코드 수
더 복잡한 쿼리 예시
EXPLAIN FORMAT=TREE
SELECT *
FROM employees
WHERE first_name = 'Matt'
AND hire_date > '1990-01-01'
ORDER BY birth_date;결과
-> Sort: employees.birth_date (cost=285.33 rows=77)
-> Filter: (employees.hire_date > '1990-01-01') (cost=256.10 rows=77)
-> Index lookup on employees using ix_firstname (first_name='Matt')
(cost=256.10 rows=233)해석
각 단계별로 들여쓰기되어 표시됩니다.
- 인덱스 조회 (cost=256.10): ix_firstname 인덱스로 first_name='Matt' 검색
- 필터링 (cost=256.10): hire_date 조건으로 233건 → 77건 필터링
- 정렬 (cost=285.33): birth_date로 정렬
비용이 증가하는 것을 볼 수 있습니다 (256.10 → 285.33).
EXPLAIN FORMAT=JSON
상세한 비용 정보를 확인할 수 있습니다.
EXPLAIN FORMAT=JSON
SELECT *
FROM employees
WHERE first_name = 'Matt'{
"query_block": {
"select_id": 1,
"cost_info": {
"query_cost": "255.08"
},
"table": {
"table_name": "employees",
"access_type": "ref",
"key": "ix_firstname",
"rows_examined_per_scan": 233,
"rows_produced_per_join": 233,
"filtered": "100.00",
"cost_info": {
"read_cost": "231.78",
"eval_cost": "23.30",
"prefix_cost": "255.08"
}
}
}
}비용 항목
- read_cost: 인덱스/테이블 읽기 비용 (231.78)
- eval_cost: 레코드 평가 비용 (23.30)
- prefix_cost: 전체 비용 (255.08 = read_cost + eval_cost)
- query_cost: 쿼리 전체 비용
EXPLAIN ANALYZE로 실제 성능 측정
MySQL 8.0.18부터 지원하는 기능으로, 실제 쿼리를 실행하여 예상 비용과 실제 소요 시간을 함께 보여줍니다.
EXPLAIN vs EXPLAIN ANALYZE
| 구분 | EXPLAIN | EXPLAIN ANALYZE |
|---|---|---|
| 실행 방식 | 실행 계획만 수립 | 실제 쿼리 실행 |
| 소요 시간 | 즉시 | 쿼리 완료까지 대기 |
| 제공 정보 | 예상 비용, 예상 rows | 예상 + 실제 시간, 실제 rows |
| 용도 | 빠른 계획 확인 | 실제 성능 측정 |
기본 사용법
EXPLAIN ANALYZE
SELECT *
FROM employees
WHERE first_name = 'Matt';결과
-> Index lookup on employees using ix_firstname (first_name='Matt')
(cost=256.10 rows=233)
(actual time=0.348..1.046 rows=233 loops=1)필드 설명
- cost=256.10: 예상 비용
- rows=233: 예상 레코드 수
- actual time=0.348..1.046: 실제 소요 시간 (밀리초)
- 첫 번째 값 (0.348): 첫 레코드를 가져오는 데 걸린 시간
- 두 번째 값 (1.046): 마지막 레코드를 가져오는 데 걸린 시간
- rows=233: 실제 처리한 레코드 수
- loops=1: 반복 실행 횟수
TREE 포맷 읽는 법
EXPLAIN ANALYZE는 항상 TREE 포맷으로 결과를 표시합니다. 들여쓰기로 실행 순서를 파악할 수 있습니다.
EXPLAIN ANALYZE
SELECT e.emp_no, AVG(s.salary)
FROM employees e
INNER JOIN salaries s ON s.emp_no = e.emp_no
AND s.salary > 50000
AND s.from_date <= '1990-01-01'
AND s.to_date > '1990-01-01'
WHERE e.first_name = 'Matt'
GROUP BY e.hire_date;결과
A) -> Table scan on <temporary>
(actual time=0.001..0.004 rows=48 loops=1)
B) -> Aggregate using temporary table
(actual time=3.799..3.808 rows=48 loops=1)
C) -> Nested loop inner join (cost=685.24 rows=135)
(actual time=0.367..3.602 rows=48 loops=1)
D) -> Index lookup on e using ix_firstname (first_name='Matt')
(cost=215.08 rows=233)
(actual time=0.348..1.046 rows=233 loops=1)
E) -> Filter: ((s.salary > 50000) and (s.from_date <= '1990-01-01')
and (s.to_date > '1990-01-01'))
(actual time=0.009..0.011 rows=0 loops=233)
F) -> Index lookup on s using PRIMARY (emp_no=e.emp_no)
(cost=0.98 rows=10)
(actual time=0.007..0.009 rows=10 loops=233)실행 순서 규칙
- 들여쓰기가 같은 레벨: 상단에 위치한 라인이 먼저 실행
- 들여쓰기가 다른 레벨: 가장 안쪽에 위치한 라인이 먼저 실행
위 쿼리의 실제 실행 순서
- D) Index lookup on e using ix_firstname - employees 테이블 인덱스로 first_name='Matt' 조회
- F) Index lookup on s using PRIMARY - salaries 테이블 프라이머리 키로 emp_no 조회
- E) Filter - salary, from_date, to_date 조건 필터링
- C) Nested loop inner join - 조인 수행
- B) Aggregate using temporary table - GROUP BY를 위한 임시 테이블 생성 및 집계
- A) Table scan on temporary - 임시 테이블 결과 반환
한글로 풀어쓴 실행 계획
- employees 테이블의 ix_firstname 인덱스를 통해 first_name='Matt' 조건에 일치하는 레코드 검색 (233건)
- salaries 테이블의 PRIMARY 키를 통해 emp_no가 1번 결과의 emp_no와 동일한 레코드 검색 (평균 10건씩)
- salary > 50000, from_date <= '1990-01-01', to_date > '1990-01-01' 조건에 일치하는 건만 필터링
- 1번과 3번의 결과를 조인
- 임시 테이블에 결과를 저장하면서 hire_date로 GROUP BY 집계 실행
- 임시 테이블의 결과를 읽어서 최종 반환 (48건)
loops의 의미
F) -> Index lookup on s using PRIMARY (emp_no=e.emp_no)
(actual time=0.007..0.009 rows=10 loops=233)- loops=233: 이 작업을 233번 반복 실행
- rows=10: 매번 평균 10건의 레코드 처리
- actual time=0.007..0.009: 첫 레코드는 평균 0.007ms, 마지막 레코드를 읽는데는 평균 0.009ms 소요
즉, employees 테이블에서 233명을 찾았고, 각 사람마다 salaries 테이블을 조회(10건씩)하여 총 233번 반복했다는 의미입니다.
활용 방법
-- 1. 먼저 EXPLAIN으로 실행 계획 확인
EXPLAIN
SELECT ...;
-- 2. 계획이 합리적이면 EXPLAIN ANALYZE로 실제 성능 측정
EXPLAIN ANALYZE
SELECT ...;주의사항
- EXPLAIN ANALYZE는 실제로 쿼리를 실행합니다
- 느린 쿼리는 완료될 때까지 기다려야 결과를 볼 수 있습니다
- 대용량 테이블에서는 먼저 EXPLAIN으로 계획을 확인하고 튜닝한 후 사용하세요
- UPDATE, DELETE 쿼리도 실행되므로 프로덕션 환경에서는 주의가 필요합니다
댓글