[Real MySQL 8.0 (1권)] 9장 - 옵티마이저와 힌트
Real MySQL 8.0 1권 9장의 내용을 정리합니다.
본 글은 MySQL 8.0 기준으로 설명한다.
9장에서 던지는 질문
아래 질문들은 실무에서 실행 계획을 확인하거나 튜닝 방향을 잡을 때 자주 등장한다.
- 쿼리 실행은 어떤 단계로 나뉘며, 각 단계에서 어떤 정보가 만들어지는가?
- 비용 기반 옵티마이저는 어떤 통계를 참고하고, 그 통계가 틀리면 어떤 문제가 생기는가?
- 풀 테이블 스캔과 풀 인덱스 스캔 중 어느 쪽이 유리한지 판단 기준은 무엇인가?
- ORDER BY 가 인덱스로 해결되지 않는 경우 filesort 가 언제/왜 발생하는가?
- 소트 버퍼 크기를 늘리면 항상 빨라지는가? 디스크 임시 파일은 언제 생기는가?
- GROUP BY 가 인덱스로 처리되는 조건은 무엇이며, 루스 인덱스 스캔은 언제 가능한가?
- DISTINCT 는 GROUP BY 와 어떻게 동일하게 처리되는가? 집합 함수와 결합되면 무엇이 달라지는가?
- 내부 임시 테이블이 메모리에서 디스크로 전환되는 조건은 무엇인가?
- MRR/BKA 는 어떤 I/O 패턴을 개선하며, 조인 버퍼는 어떤 상황에서 효과적인가?
- 인덱스 컨디션 푸시다운이 없으면 어떤 종류의 불필요한 테이블 읽기가 생기는가?
- 인덱스 머지(교집합/합집합/정렬 후 합집합)는 각각 어떤 쿼리 패턴에서 유리한가?
- 세미 조인 최적화(테이블 풀-아웃/퍼스트 매치/루스 스캔/구체화/중복 제거)의 차이는 무엇인가?
- 해시 조인이 빠른데도 응답 시간이 느려지는 이유는 무엇인가?
- 조인 최적화 알고리즘(Exhaustive/Greedy)의 시간 복잡도는 어떻게 다른가?
- 힌트를 넣었는데도 무시되는 경우는 어떤 제약 때문인가?
이어질 수 있는 질문
- 9.2 정렬/그루핑 파트만 더 깊게 확장해도 될까?
- 각 최적화 옵션별 실제 EXPLAIN 예시도 추가할까?
- 힌트 사용 시점과 회피 시점을 표로 정리할까?
- 내부 임시 테이블 디스크 전환 조건을 버전별로 분리할까?
정리
9.1 개요
- 쿼리 실행 절차는 대략 3단계로 구분된다.
- SQL 파싱: 문법 검증, 파스 트리 생성.
- 최적화 및 실행 계획 수립: 접근 경로/조인 순서/임시 테이블 사용 여부 결정.
- 실행: 스토리지 엔진에 읽기 요청, 결과 반환.
- 옵티마이저의 종류
- 규칙 기반: 내장된 우선순위에 따라 동일한 실행 계획을 선택.
- 비용 기반: 통계 정보로 다양한 계획의 비용을 계산해 최적 선택(현행 MySQL 기본).
9.2 기본 데이터 처리
9.2.1 풀 테이블 스캔과 풀 인덱스 스캔
- 인덱스를 활용할 수 없거나, 대부분 레코드를 읽어야 할 때 풀 테이블 스캔이 선택될 수 있다.
- SELECT COUNT(*) 같은 경우에는 풀 인덱스 스캔이 더 유리할 수 있다.
9.2.2 병렬 처리
- 대량 스캔 작업을 여러 스레드로 분할해 처리 시간을 줄이는 방식이다.
- 스레드 수가 늘수록 감소 폭은 체감되며, 병렬화 비용도 고려해야 한다.
9.2.3 ORDER BY 처리(Using filesort)
9.2.3.1 소트 버퍼
- 정렬이 필요한 순간에만 할당되는 메모리 영역이며, 크기는 가변적이다.
- 정렬 대상이 크면 디스크 임시 파일을 사용해 멀티 머지를 수행한다.
- 정렬 버퍼가 커질수록 메모리 압박이 커지고, 동시 정렬 쿼리가 많으면 역효과가 난다.
9.2.3.2 정렬 알고리즘
- 싱글 패스: 정렬 키와 모든 칼럼을 소트 버퍼에 담아 1회 정렬.
- 투 패스: 정렬 키와 PK만 정렬 후, PK로 다시 테이블을 읽는다.
9.2.3.3 정렬 처리 방법
- 인덱스를 이용한 정렬: ORDER BY 칼럼이 선행 인덱스와 일치할 때.
- 드라이빙 테이블만 정렬: 조인 결과 폭증을 피하고자 먼저 정렬.
- 임시 테이블 정렬: 드리븐 테이블 칼럼 정렬이 필요할 때 사용.
9.2.3.3.1 인덱스를 이용한 정렬
- ORDER BY 절 칼럼이 인덱스 선행 칼럼 순서와 일치해야 한다.
- WHERE 절 조건이 인덱스 사용을 깨뜨리면 filesort가 발생한다.
예시(인덱스 정렬)
1
2
3
4
5
EXPLAIN
SELECT emp_no, last_name
FROM employees
WHERE last_name >= 'A'
ORDER BY last_name;
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——— | —– | ————- | ———— | ——- | —- | —– | ———————— | | 1 | SIMPLE | employees | range | ix_last_name | ix_last_name | 16 | NULL | 20000 | Using where; Using index |
9.2.3.3.2 조인의 드라이빙 테이블만 정렬
- 조인 전 드라이빙 테이블을 먼저 정렬해 결과 폭증을 줄인다.
- ORDER BY 칼럼이 드라이빙 테이블에만 있어야 가능하다.
9.2.3.3.3 임시 테이블을 이용한 정렬
- 드리븐 테이블 칼럼으로 정렬할 때 주로 사용된다.
- 임시 테이블 크기가 커지면 디스크 I/O가 급증한다.
예시(드리븐 테이블 칼럼 정렬)
1
2
3
4
5
EXPLAIN
SELECT d.dept_no, e.hire_date
FROM dept_emp d
JOIN employees e ON e.emp_no = d.emp_no
ORDER BY e.hire_date;
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | —– | —— | ————- | ——- | ——- | ——– | —— | ——————————- | | 1 | SIMPLE | d | index | PRIMARY | PRIMARY | 8 | NULL | 300000 | Using index | | 1 | SIMPLE | e | eq_ref | PRIMARY | PRIMARY | 4 | d.emp_no | 1 | Using temporary; Using filesort |
9.2.3.3.4 정렬 처리 방법의 성능 비교
- 인덱스 정렬은 추가 정렬 비용이 없어 가장 빠르다.
- 드라이빙 테이블 정렬은 차선책이며, 임시 테이블 정렬은 최후의 선택이다.
- 스트리밍 방식: 레코드를 찾는 즉시 결과를 전달해 응답이 빠르다.
- 버퍼링 방식: 전체 레코드를 모아 정렬/그루핑 후 전달한다.
9.2.4 GROUP BY 처리
- 타이트 인덱스 스캔: 드라이빙 테이블의 인덱스를 순서대로 읽어 그루핑.
- 루스 인덱스 스캔: 인덱스 레코드를 건너뛰어 유니크 그룹만 추출.
- 임시 테이블 사용: 인덱스로 처리 불가하거나 드리븐 테이블 기준일 때.
9.2.4.1 인덱스 스캔을 이용하는 GROUP BY(타이트 인덱스 스캔)
- GROUP BY 칼럼이 인덱스 선행 칼럼과 정렬이 맞아야 한다.
- Extra 컬럼에 별도의 정렬/임시 테이블 코멘트가 표시되지 않는다.
예시(인덱스 조건과 실행 계획)
1
2
3
4
EXPLAIN
SELECT dept_no, COUNT(*)
FROM dept_emp
GROUP BY dept_no;
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——– | —– | ————- | ——- | ——- | —- | —— | ———– | | 1 | SIMPLE | dept_emp | index | ix_dept | ix_dept | 4 | NULL | 300000 | Using index |
9.2.4.2 루스 인덱스 스캔을 이용하는 GROUP BY
- 인덱스 레코드를 건너뛰며 그룹 대표값만 읽는다.
- Extra 컬럼에 “Using index for group-by”가 표시된다.
예시(인덱스 조건과 실행 계획)
- 가정: dept_emp에 복합 인덱스
KEY ix_dept_emp (dept_no, emp_no)가 존재한다.
1
2
3
4
EXPLAIN
SELECT dept_no, MIN(emp_no) AS min_emp_no
FROM dept_emp
GROUP BY dept_no;
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——– | —– | ————- | ———– | ——- | —- | —— | ———————— | | 1 | SIMPLE | dept_emp | range | ix_dept_emp | ix_dept_emp | 8 | NULL | 300000 | Using index for group-by |
9.2.4.3 임시 테이블을 사용하는 GROUP BY
- 드리븐 테이블의 칼럼으로 그룹핑하거나 인덱스가 없을 때 사용한다.
- 그룹 함수가 포함되면 임시 테이블로 처리되는 경우가 많다.
예시(인덱스 부재로 임시 테이블 사용)
1
2
3
4
5
EXPLAIN
SELECT e.hire_date, COUNT(*) AS cnt
FROM dept_emp de
JOIN employees e ON e.emp_no = de.emp_no
GROUP BY e.hire_date;
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | —– | —— | ————- | ——- | ——- | ——— | —— | ——————————- | | 1 | SIMPLE | de | index | PRIMARY | PRIMARY | 8 | NULL | 300000 | Using index | | 1 | SIMPLE | e | eq_ref | PRIMARY | PRIMARY | 4 | de.emp_no | 1 | Using temporary; Using filesort |
9.2.5 DISTINCT 처리
- SELECT DISTINCT: 내부적으로 GROUP BY 와 유사한 흐름으로 처리된다.
- 집합 함수 내 DISTINCT: 함수 인자로 전달된 칼럼 값만 유니크 처리된다.
9.2.5.1 SELECT DISTINCT…
- DISTINCT는 레코드 집합을 유니크하게 만든다.
- 선택한 칼럼 조합 전체가 유니크 비교 대상이 된다.
예시(SELECT DISTINCT)
1
2
3
4
EXPLAIN
SELECT DISTINCT first_name, last_name
FROM employees
WHERE last_name LIKE 'A%';
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——— | —– | ————- | ———— | ——- | —- | —– | —————————- | | 1 | SIMPLE | employees | range | ix_last_name | ix_last_name | 16 | NULL | 15000 | Using where; Using temporary |
예시(인덱스가 다른 경우: 복합 인덱스)
- 가정: employees에
KEY ix_last_first (last_name, first_name)가 존재한다.
1
2
3
4
EXPLAIN
SELECT DISTINCT first_name, last_name
FROM employees
WHERE last_name LIKE 'A%';
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——— | —– | ————- | ————- | ——- | —- | —– | ———————— | | 1 | SIMPLE | employees | range | ix_last_first | ix_last_first | 30 | NULL | 15000 | Using where; Using index |
예시(인덱스 없음)
- 가정: employees에 last_name 관련 인덱스가 없다.
1
2
3
4
EXPLAIN
SELECT DISTINCT first_name, last_name
FROM employees
WHERE last_name LIKE 'A%';
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——— | —- | ————- | —- | ——- | —- | —— | —————————- | | 1 | SIMPLE | employees | ALL | NULL | NULL | NULL | NULL | 300000 | Using where; Using temporary |
예시(인덱스 없음 + 디스크 임시 테이블 가능성)
- 가정: last_name 인덱스가 없고, 결과가 커서 정렬/임시 테이블이 디스크로 전환될 수 있다.
1
2
3
4
EXPLAIN
SELECT DISTINCT first_name, last_name
FROM employees
ORDER BY last_name;
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——— | —- | ————- | —- | ——- | —- | —— | ——————————- | | 1 | SIMPLE | employees | ALL | NULL | NULL | NULL | NULL | 300000 | Using temporary; Using filesort |
9.2.5.2 집합 함수와 함께 사용된 DISTINCT
- 집계 함수 인자만 유니크 대상으로 처리한다.
- 내부적으로는 임시 테이블을 사용하지만 표시되지 않을 수 있다.
예시(집합 함수 DISTINCT)
1
2
3
4
EXPLAIN
SELECT COUNT(DISTINCT emp_no) AS cnt
FROM salaries
WHERE salary >= 120000;
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——– | —– | ————- | ——— | ——- | —- | —- | ———————— | | 1 | SIMPLE | salaries | range | ix_salary | ix_salary | 4 | NULL | 5000 | Using where; Using index |
예시(DISTINCT + 집합 함수 + 디스크 임시 테이블 가능성)
- 가정: salaries에 salary 관련 인덱스가 없고, 중복 제거와 정렬로 임시 테이블이 커질 수 있다.
1
2
3
4
5
EXPLAIN
SELECT emp_no, COUNT(DISTINCT salary) AS cnt
FROM salaries
GROUP BY emp_no
ORDER BY cnt DESC;
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——– | —- | ————- | —- | ——- | —- | —— | ——————————- | | 1 | SIMPLE | salaries | ALL | NULL | NULL | NULL | NULL | 300000 | Using temporary; Using filesort |
9.2.6 내부 임시 테이블 활용
- 메모리 임시 테이블이 기본이지만, 크기 제한/데이터 타입 제약 등으로 디스크로 전환될 수 있다.
- 임시 테이블 사용은 정렬, 그루핑, DISTINCT, 일부 서브쿼리에서 흔하다.
- 상태 변수로 생성 횟수/디스크 전환 횟수를 추적할 수 있다.
9.2.6.1 메모리 임시 테이블과 디스크 임시 테이블
- 메모리 임시 테이블은 빠르지만 크기 제한이 있다.
- 디스크 임시 테이블은 I/O 비용이 커지고 정렬/그루핑 비용이 상승한다.
임시 테이블 관련 변수(MySQL 8.0) | 변수 | 설명 | 비고 | 예시 값 | | ——————————- | ——————————— | ————————————————– | ——— | | tmp_table_size | 메모리 임시 테이블 최대 크기 | max_heap_table_size와 함께 최소값이 상한이 된다. | 256M | | max_heap_table_size | MEMORY 엔진 임시 테이블 최대 크기 | tmp_table_size보다 크더라도 상한은 최소값이다. | 256M | | internal_tmp_mem_storage_engine | 내부 임시 테이블 메모리 엔진 | 기본값은 TempTable. | TempTable | | temptable_max_ram | TempTable 메모리 한도 | 초과 시 디스크로 스필된다. | 1G |
튜닝 기준(권장 범위 예시)
- tmp_table_size와 max_heap_table_size는 동일하게 맞춰 관리한다.
- temptable_max_ram은 전체 메모리의 10~25% 내에서 시작한다.
- 디스크 전환 비율이 높으면 정렬/그루핑 쿼리를 먼저 재작성한다.
9.2.6.2 임시 테이블이 필요한 쿼리
- ORDER BY, GROUP BY, DISTINCT가 조합된 쿼리.
- UNION, 서브쿼리 구체화, DISTINCT + 집계 조합.
9.2.6.3 임시 테이블이 디스크에 생성되는 경우
- 임시 테이블 크기가 임계값을 넘을 때.
- BLOB/TEXT 등 메모리 임시 테이블에 부적합한 타입이 포함될 때.
예시(디스크 임시 테이블 유도)
1
2
3
4
5
EXPLAIN
SELECT dept_no, COUNT(*) AS cnt
FROM dept_emp
GROUP BY dept_no
ORDER BY cnt DESC;
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——– | —– | ————- | ——- | ——- | —- | —— | ——————————- | | 1 | SIMPLE | dept_emp | index | ix_dept | ix_dept | 4 | NULL | 300000 | Using temporary; Using filesort |
9.2.6.4 임시 테이블 관련 상태 변수
- Created_tmp_tables, Created_tmp_disk_tables 등으로 빈도를 파악한다.
- 디스크 전환 비율이 높으면 정렬/그루핑 설계를 재검토한다.
예시(상태 변수 확인)
1
SHOW GLOBAL STATUS LIKE 'Created_tmp%';
예시 출력 | Variable_name | Value | | ———————– | —– | | Created_tmp_tables | 1200 | | Created_tmp_disk_tables | 180 |
9.3 고급 최적화
예시 스키마(Real MySQL 샘플 employees 기반)
아래 예시는 책에서 자주 사용되는 employees 샘플 스키마를 간소화한 형태이며, 이후 EXPLAIN 설명의 기준이 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
CREATE TABLE employees (
emp_no INT NOT NULL,
first_name VARCHAR(14) NOT NULL,
last_name VARCHAR(16) NOT NULL,
hire_date DATE NOT NULL,
PRIMARY KEY (emp_no),
KEY ix_last_name (last_name),
KEY ix_first_last (first_name, last_name)
);
CREATE TABLE dept (
dept_no CHAR(4) NOT NULL,
dept_name VARCHAR(40) NOT NULL,
PRIMARY KEY (dept_no)
);
CREATE TABLE dept_emp (
emp_no INT NOT NULL,
dept_no CHAR(4) NOT NULL,
from_date DATE NOT NULL,
to_date DATE NOT NULL,
PRIMARY KEY (emp_no, dept_no),
KEY ix_dept (dept_no),
KEY ix_fromdate (from_date)
);
CREATE TABLE dept_emp_no_idx (
emp_no INT NOT NULL,
dept_no CHAR(4) NOT NULL,
from_date DATE NOT NULL,
to_date DATE NOT NULL
);
CREATE TABLE salaries (
emp_no INT NOT NULL,
salary INT NOT NULL,
from_date DATE NOT NULL,
to_date DATE NOT NULL,
PRIMARY KEY (emp_no, from_date),
KEY ix_salary (salary)
);
예시 실행 계획은 실행 환경(데이터 분포/버퍼/통계)에 따라 달라질 수 있다.
옵티마이저 스위치 옵션
- optimizer_switch로 제어하며 세션/글로벌 단위 설정 가능.
- 특정 옵션을 켜거나 끌 때는 EXPLAIN으로 반드시 실행 계획을 확인한다.
1
SET SESSION optimizer_switch='mrr=on,batched_key_access=on';
MRR과 배치 키 액세스(BKA)
- 조인 버퍼에 드라이빙 테이블 레코드를 모아, 데이터 페이지 접근 순서를 개선한다.
- 랜덤 I/O를 줄이는 것이 핵심이며, 드리븐 테이블 인덱스 접근이 많은 경우 효과가 크다.
1
2
3
4
5
EXPLAIN
SELECT /*+ BKA(e) */ d.dept_no, e.emp_no
FROM dept_emp AS d
JOIN employees AS e ON e.emp_no = d.emp_no
WHERE d.dept_no BETWEEN 'd001' AND 'd005';
설명
- d 테이블은 ix_dept로 범위를 좁히고, 조인 버퍼에 emp_no 목록을 모은다.
- e 테이블은 PK(emp_no)로 빠르게 탐색하며, Extra에 “Using join buffer (Batched Key Access)”가 보일 수 있다.
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | —– | —— | ————- | ——- | ——- | ——– | —- | ————————————– | | 1 | SIMPLE | d | range | ix_dept | ix_dept | 4 | NULL | 5000 | Using where | | 1 | SIMPLE | e | eq_ref | PRIMARY | PRIMARY | 4 | d.emp_no | 1 | Using join buffer (Batched Key Access) |
힌트 비교(BKA Before/After) Before | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | —– | —— | ————- | ——- | ——- | ——– | —- | ———– | | 1 | SIMPLE | d | range | ix_dept | ix_dept | 4 | NULL | 5000 | Using where | | 1 | SIMPLE | e | eq_ref | PRIMARY | PRIMARY | 4 | d.emp_no | 1 | NULL | 주석: 드리븐 테이블 인덱스 접근이 적거나 조인 건수가 작으면 BKA 이점이 거의 없다.
After | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | —– | —— | ————- | ——- | ——- | ——– | —- | ————————————– | | 1 | SIMPLE | d | range | ix_dept | ix_dept | 4 | NULL | 5000 | Using where | | 1 | SIMPLE | e | eq_ref | PRIMARY | PRIMARY | 4 | d.emp_no | 1 | Using join buffer (Batched Key Access) | 주석: 조인 버퍼가 커지고 정렬 순서가 바뀔 수 있으므로 결과 순서 의존 쿼리에는 주의한다.
블록 네스티드 루프 조인
- 조인 버퍼 사용 시 조인 순서가 역전된 것처럼 동작할 수 있다.
- 드리븐 테이블에 적절한 인덱스가 없으면 조인 버퍼를 크게 써도 느리다.
- 예시에서는 조인 버퍼 동작을 보여주기 위해 STRAIGHT_JOIN으로 순서를 고정한다.
1
2
3
4
EXPLAIN
SELECT STRAIGHT_JOIN *
FROM employees e
JOIN dept_emp_no_idx d ON d.emp_no = e.emp_no;
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | —– | —– | ————- | ——- | ——- | —- | —— | ————————————- | | 1 | SIMPLE | e | index | PRIMARY | PRIMARY | 4 | NULL | 300000 | Using index | | 1 | SIMPLE | d | ALL | NULL | NULL | NULL | NULL | 300000 | Using join buffer (Block Nested Loop) |
인덱스 컨디션 푸시다운
- 인덱스 단계에서 추가 조건을 평가해 불필요한 테이블 읽기를 줄인다.
- Extra에 “Using index condition”이 표시되면 ICP가 적용된 경우다.
1
2
3
4
5
EXPLAIN
SELECT *
FROM employees
WHERE last_name = 'Action'
AND first_name LIKE '%sal';
설명
- ix_last_name으로 1차 후보를 찾은 뒤, first_name 조건을 인덱스 레벨에서 함께 적용한다.
- ICP가 꺼져 있다면 후보 레코드를 더 많이 테이블에서 읽게 된다.
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——— | —– | ————- | ———— | ——- | —- | —- | ——————— | | 1 | SIMPLE | employees | range | ix_last_name | ix_last_name | 16 | NULL | 1000 | Using index condition |
인덱스 확장
- 세컨더리 인덱스에 내장된 PK를 활용할지를 결정한다.
- 중복 인덱스가 많을 때는 인덱스 확장이 실행 계획에 영향 줄 수 있다.
인덱스 머지
- 교집합: 여러 인덱스 결과의 공통 부분만 유지.
- 합집합: OR 조건을 서로 다른 인덱스로 병합.
- 정렬 후 합집합: 중복 제거를 위해 정렬이 필요한 경우.
1
2
3
4
5
EXPLAIN
SELECT *
FROM employees
WHERE last_name = 'Lee'
OR emp_no BETWEEN 10010 AND 10100;
설명
- ix_last_name과 PK(emp_no) 결과를 합집합으로 병합한다.
- Extra에 “Using sort_union” 또는 “Using union”이 표시될 수 있다.
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——— | ———– | ——————– | ——————– | ——- | —- | —- | ——————————— | | 1 | SIMPLE | employees | index_merge | ix_last_name,PRIMARY | ix_last_name,PRIMARY | 16,4 | NULL | 1500 | Using union(ix_last_name,PRIMARY) |
세미 조인 최적화
- 테이블 풀-아웃, 퍼스트 매치, 루스 스캔, 구체화, 중복 제거 등으로 구성된다.
- EXISTS/IN 서브쿼리를 조인으로 변환해 레코드 탐색을 줄인다.
1
2
3
4
5
6
7
EXPLAIN
SELECT *
FROM dept d
WHERE EXISTS (
SELECT 1 FROM dept_emp de
WHERE de.dept_no = d.dept_no
);
설명
- 조건을 만족하는 de 레코드가 존재하는지만 확인한다.
- 옵티마이저는 상황에 따라 퍼스트 매치 또는 구체화 전략을 선택한다.
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | —– | —– | ————- | ——- | ——- | ——— | —- | ————————– | | 1 | SIMPLE | d | index | PRIMARY | PRIMARY | 4 | NULL | 9 | Using index | | 1 | SIMPLE | de | ref | ix_dept | ix_dept | 4 | d.dept_no | 3000 | Using where; FirstMatch(d) |
기타 최적화
- 컨디션 팬아웃: 조인 순서 결정을 위한 카디널리티 추정 강화.
- 파생 테이블 머지: 서브쿼리 결과를 외부 쿼리와 병합해 불필요한 임시 테이블을 줄인다.
- 인비저블 인덱스: 인덱스 사용 여부를 제어해 검증한다.
- 스킵 스캔: 선행 칼럼 없이도 후행 칼럼 조건을 제한적으로 활용한다.
- 해시 조인: 처리량은 높지만 첫 레코드 응답이 늦어질 수 있다.
- 인덱스 정렬 선호: ORDER BY/GROUP BY 가 인덱스로 처리되면 가중치가 높아진다.
1
2
3
4
5
6
7
8
EXPLAIN
SELECT /*+ NO_MERGE(subq) */ *
FROM (
SELECT dept_no, COUNT(*) AS cnt
FROM dept_emp
GROUP BY dept_no
) AS subq
WHERE cnt > 10;
설명
- NO_MERGE 힌트로 파생 테이블 머지를 막아 임시 테이블 동작을 확인할 수 있다.
- 쿼리 튜닝 전후에 EXPLAIN 비교가 필요하다.
예시 실행 계획 | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——– | —– | ————- | ——- | ——- | —- | —— | —————————- | | 1 | PRIMARY | subq | ALL | NULL | NULL | NULL | NULL | 20 | Using where | | 2 | DERIVED | dept_emp | index | ix_dept | ix_dept | 4 | NULL | 300000 | Using temporary; Using index |
힌트 비교(NO_MERGE Before/After) Before | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——– | —– | ————- | ——- | ——- | —- | —— | ———– | | 1 | SIMPLE | dept_emp | index | ix_dept | ix_dept | 4 | NULL | 300000 | Using where | 주석: 파생 테이블이 자연스럽게 머지되는 상황에서는 NO_MERGE가 오히려 느려질 수 있다.
After | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——– | —– | ————- | ——- | ——- | —- | —— | —————————- | | 1 | PRIMARY | subq | ALL | NULL | NULL | NULL | NULL | 20 | Using where | | 2 | DERIVED | dept_emp | index | ix_dept | ix_dept | 4 | NULL | 300000 | Using temporary; Using index | 주석: 임시 테이블이 디스크로 전환되면 성능이 크게 저하될 수 있다.
조인 최적화 알고리즘
- Exhaustive 검색: 모든 조합의 비용을 계산(테이블 수 n이면 n!).
- Greedy 검색: 단계별로 최적 조합을 선택해 비용을 줄인다.
힌트 비교: STRAIGHT_JOIN
1
2
3
4
5
EXPLAIN
SELECT e.emp_no, d.dept_no
FROM employees e
JOIN dept_emp d ON d.emp_no = e.emp_no
WHERE d.dept_no = 'd005';
힌트 비교(STRAIGHT_JOIN Before/After) Before | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | —– | —— | ————- | ——- | ——- | ——– | —- | ———– | | 1 | SIMPLE | d | ref | ix_dept | ix_dept | 4 | const | 5000 | Using where | | 1 | SIMPLE | e | eq_ref | PRIMARY | PRIMARY | 4 | d.emp_no | 1 | NULL | 주석: 통계가 충분히 신뢰 가능하면 STRAIGHT_JOIN은 과도한 고정일 수 있다.
After(STRAIGHT_JOIN 적용)
1
2
3
4
5
EXPLAIN
SELECT STRAIGHT_JOIN e.emp_no, d.dept_no
FROM employees e
JOIN dept_emp d ON d.emp_no = e.emp_no
WHERE d.dept_no = 'd005';
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | —– | —– | ————— | ——- | ——- | ——– | —— | ———– | | 1 | SIMPLE | e | index | PRIMARY | PRIMARY | 4 | NULL | 300000 | NULL | | 1 | SIMPLE | d | ref | PRIMARY,ix_dept | PRIMARY | 4 | e.emp_no | 1 | Using where | 주석: 조인 순서를 잘못 고정하면 카디널리티가 큰 테이블이 먼저 읽혀 느려진다.
힌트 비교: FORCE INDEX
1
2
3
4
EXPLAIN
SELECT emp_no, first_name, last_name
FROM employees
WHERE first_name = 'Mary' AND last_name = 'Smith';
힌트 비교(FORCE INDEX Before/After) Before | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——— | —- | ————————– | ————- | ——- | ———– | —- | ———– | | 1 | SIMPLE | employees | ref | ix_first_last,ix_last_name | ix_first_last | 32 | const,const | 10 | Using where | 주석: 옵티마이저가 이미 적절한 복합 인덱스를 선택한다면 FORCE는 불필요하다.
After(FORCE INDEX 적용)
1
2
3
4
EXPLAIN
SELECT emp_no, first_name, last_name
FROM employees FORCE INDEX (ix_last_name)
WHERE first_name = 'Mary' AND last_name = 'Smith';
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——— | —- | ————- | ———— | ——- | —– | —- | ———– | | 1 | SIMPLE | employees | ref | ix_last_name | ix_last_name | 16 | const | 200 | Using where | 주석: 덜 선택적인 인덱스를 강제하면 rows가 증가해 전체가 느려질 수 있다.
힌트 비교: USE INDEX
1
2
3
4
EXPLAIN
SELECT emp_no, last_name
FROM employees
WHERE last_name = 'Lee';
힌트 비교(USE INDEX Before/After) Before | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——— | —- | ————- | ———— | ——- | —– | —- | ———– | | 1 | SIMPLE | employees | ref | ix_last_name | ix_last_name | 16 | const | 200 | Using where | 주석: 특정 인덱스를 권장하되, 통계 변화가 잦으면 효과가 불안정할 수 있다.
After(USE INDEX 적용)
1
2
3
4
EXPLAIN
SELECT emp_no, last_name
FROM employees USE INDEX (ix_last_name)
WHERE last_name = 'Lee';
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——— | —- | ————- | ———— | ——- | —– | —- | ———– | | 1 | SIMPLE | employees | ref | ix_last_name | ix_last_name | 16 | const | 200 | Using where | 주석: WHERE 조건이 바뀌면 더 나은 인덱스를 막는 부작용이 생길 수 있다.
힌트 비교: IGNORE INDEX
1
2
3
4
EXPLAIN
SELECT emp_no, last_name
FROM employees
WHERE last_name = 'Lee';
힌트 비교(IGNORE INDEX Before/After) Before | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——— | —- | ————- | ———— | ——- | —– | —- | ———– | | 1 | SIMPLE | employees | ref | ix_last_name | ix_last_name | 16 | const | 200 | Using where | 주석: 특정 인덱스가 나쁘다고 확신할 때만 제한적으로 사용한다.
After(IGNORE INDEX 적용)
1
2
3
4
EXPLAIN
SELECT emp_no, last_name
FROM employees IGNORE INDEX (ix_last_name)
WHERE last_name = 'Lee';
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | | — | ———– | ——— | —- | ————- | —- | ——- | —- | —— | ———– | | 1 | SIMPLE | employees | ALL | NULL | NULL | NULL | NULL | 300000 | Using where | 주석: 풀 스캔을 유도하므로 OLTP 쿼리에는 특히 위험하다.
9.4 쿼리 힌트
인덱스 힌트
- STRAIGHT_JOIN: 조인 순서 고정.
- USE INDEX / FORCE INDEX / IGNORE INDEX: 인덱스 선택 유도/강제/제외.
- SQL_CALC_FOUND_ROWS: LIMIT 없이 전체 건수를 계산(현재는 비권장 경향).
옵티마이저 힌트
- 힌트는 영향 범위 기준으로 구분되며, 인덱스/테이블/쿼리 블록 단위로 적용된다.
- 인덱스 힌트보다 옵티마이저 힌트를 우선 고려하는 것이 일반적이다.
힌트 적용 범위 표
| 범위 | 대상 | 대표 힌트 | 설명 | | ——— | ——————– | —————– | ————————————————- | | 쿼리 블록 | SELECT/서브쿼리 단위 | QB_NAME, NO_MERGE | 특정 블록에만 힌트를 적용한다. | | 테이블 | 특정 테이블 | BKA, NO_BKA | 조인 방식이나 접근 방식을 테이블 단위로 제어한다. | | 인덱스 | 인덱스 단위 | INDEX, NO_INDEX | 특정 인덱스를 사용/배제한다. |
힌트 사용/회피 판단 체크리스트
힌트를 쓰기 전
- 최신 통계와 히스토그램이 반영됐는지 확인한다.
- 인덱스 설계로 해결 가능한 문제인지 먼저 점검한다.
- EXPLAIN으로 후보 실행 계획을 2~3개 비교한다.
- SELECTIVITY와 rows 추정치가 실제 분포와 맞는지 확인한다.
힌트를 써도 되는 경우
- 동일 쿼리가 반복 실행되며 실행 계획이 흔들리는 경우.
- 옵티마이저가 명백히 잘못된 인덱스를 선택하는 경우.
- 특정 조인 순서가 지속적으로 더 안정적인 경우.
힌트를 피해야 하는 경우
- 데이터 분포 변화가 잦고 계획 안정성이 낮은 경우.
- 인덱스/통계 개선으로 해결 가능한 경우.
- 힌트가 다른 쿼리 블록에 부작용을 주는 경우.
힌트 적용 후 확인
- EXPLAIN의 type, key, rows, filtered 변화 확인.
- 응답 시간과 CPU/IO 변화 확인.
- 회귀 테스트로 다른 쿼리 영향 확인.
- 통계 갱신 후 재검증 일정 확보.
실전 체크리스트
- EXPLAIN에서 접근 방식, rows, filtered, Extra를 먼저 확인한다.
- ORDER BY/GROUP BY가 인덱스로 처리되는지 확인한다.
- 임시 테이블/Filesort 발생 여부와 디스크 전환 비율을 확인한다.
- 조인 순서와 드라이빙/드리븐 테이블 선택이 합리적인지 점검한다.
- 힌트는 최후의 선택으로 두고, 통계/인덱스 설계를 먼저 개선한다.
복잡도 메모
- 정렬: 일반적으로 O(n log n), 추가 버퍼는 O(n) 수준.
- 인덱스 머지 교집합: 각 인덱스 결과 길이를 a, b라 하면 O(a + b).
- 해시 조인: 빌드 O(n), 프로브 O(m), 메모리 O(n).
- Exhaustive 조인 최적화: O(n!) 수준으로 폭증.
- Greedy 조인 최적화: 대략 O(n^2) 근사(단계별 선택 비용에 비례).
주의사항
- 힌트는 통계/데이터 분포 변경에 취약해 장기적으로 역효과 가능하다.
- ORDER BY/GROUP BY는 스트리밍을 막아 응답 시간을 늘릴 수 있다.
- 임시 테이블 디스크 전환은 성능 하락의 대표 원인이다.
- 조인 버퍼 사용 시 정렬 결과가 기대와 달라질 수 있다.
대안과 선택지
- 힌트 대신 통계 갱신/히스토그램/인덱스 재설계로 우선 해결하기.
- DISTINCT 대신 GROUP BY 또는 집계 재작성으로 처리량 줄이기.
- 해시 조인 대신 네스티드 루프 조인으로 응답 지연 최소화하기.
- 임시 테이블 회피를 위한 인덱스 정렬 유도.
