카테고리 : MySQL/기술노트

MySQL Optimizer 개요: parser, resolver, optimizer, executor 처리 단계

MySQL이 SQL을 parser, resolver, optimizer, executor 단계로 처리하는 흐름과 실행 계획 해석의 핵심 포인트를 운영 관점에서 정리한다.

저자: MySQL 기술 노트 작성: 2026.06.29 약 13분 7,224자
다운로드

1. 왜 Optimizer 처리 단계를 DBA가 알아야 하는가

운영 현장에서 “쿼리가 느리다”는 보고는 흔하지만, 그 원인이 항상 같은 층에 있지는 않다. 어떤 문제는 SQL 문법 구조가 비효율적이어서 발생하고, 어떤 문제는 객체 이름 해석과 타입 변환에서 이미 비용이 커지며, 또 어떤 문제는 옵티마이저가 잘못된 접근 경로를 선택해서 발생한다. 실행 엔진 자체는 옵티마이저가 넘긴 계획을 충실히 수행했을 뿐인데, 결과적으로는 전체 서비스 지연으로 보이는 경우도 많다.

따라서 MySQL 성능을 안정적으로 다루려면 단순히 EXPLAIN 결과 한 줄만 보는 습관에서 벗어나야 한다. MySQL이 SQL을 어떤 단계로 처리하고, 각 단계에서 무엇이 결정되며, 어디서 잘못된 가정이 들어갈 수 있는지를 이해해야 한다. 이 관점이 있으면 다음 판단이 쉬워진다.

  • 느린 원인이 문법 구조 자체인지, 객체 해석인지, 비용 추정 실패인지 구분할 수 있다.
  • 인덱스가 있는데도 왜 full scan이 발생했는지 설명할 수 있다.
  • 함수 적용, 암묵적 형변환, 잘못된 조인 순서 같은 전형적인 실수를 사전에 차단할 수 있다.
  • Aurora MySQL 환경에서도 “스토리지가 다르니 계획도 완전히 다를 것”이라는 오해를 줄일 수 있다.
  • 장애 시점에 EXPLAIN, 통계, optimizer_switch, 히스토그램, 인덱스 설계를 어떤 순서로 봐야 하는지 정리할 수 있다.

결론적으로 Optimizer는 “SQL을 빠르게 만드는 마법 상자”가 아니다. 파싱된 SQL과 메타데이터와 통계를 바탕으로 가장 싸다고 판단한 경로를 고르는 의사결정기이며, DBA는 그 입력과 가정을 관리하는 사람에 가깝다.

2. 큰 그림: parser → resolver → optimizer → executor

MySQL은 SQL을 받으면 곧바로 디스크를 읽기 시작하지 않는다. 먼저 SQL 문자열을 내부 표현으로 바꾸고, 참조 대상을 해석하고, 가능한 실행 경로를 평가한 뒤, 마지막에 실제 실행기로 넘긴다.

flowchart LR
    A[SQL text 수신] --> B[Parser]
    B --> C[Parse Tree 생성]
    C --> D[Resolver]
    D --> E[테이블/컬럼/타입/권한 해석]
    E --> F[Optimizer]
    F --> G[접근 경로 선택<br/>조인 순서 결정<br/>정렬/집계 비용 추정]
    G --> H[Executor]
    H --> I[스토리지 엔진 호출 및 결과 반환]

이 흐름에서 기억해야 할 핵심은 다음과 같다.

  1. Parser 는 SQL이 문법적으로 유효한지 판단하고 내부 트리로 바꾼다.
  2. Resolver 는 이 식별자가 어느 테이블의 어느 컬럼인지, 타입은 무엇인지, 별칭은 어떻게 연결되는지 확정한다.
  3. Optimizer 는 가능한 접근 경로와 조인 순서를 비교해 비용이 가장 낮다고 본 계획을 선택한다.
  4. Executor 는 선택된 계획에 따라 실제 row를 읽고, 조인하고, 정렬하고, 결과를 반환한다.

운영 문제는 종종 이 네 단계를 하나로 뭉뚱그려 보면서 발생한다. 예를 들어 인덱스를 못 탔다고 해서 무조건 옵티마이저만 비난하면 안 된다. 실제 원인은 resolver 단계에서 발생한 암묵적 형변환이거나, parser 이후 표현식 구조가 인덱스 친화적이지 않아서 range access 후보 자체가 약해졌을 수 있다.

3. Parser: SQL을 내부 트리로 바꾸는 단계

Parser의 역할은 겉으로는 단순하다. SQL 문장을 읽고 토큰으로 분해한 뒤, 문법 규칙에 맞는지 확인하고 parse tree 또는 내부 query block 구조를 만든다. 그러나 이 단계는 이후 모든 최적화의 출발점이므로 매우 중요하다.

3.1 Parser 단계에서 운영자가 주목할 포인트

  • 문법 오류와 예약어 충돌: 애플리케이션 배포 직후 특정 버전에서만 실패하는 쿼리는 parser 단계에서 이미 막힐 수 있다.
  • 표현식 구조: DATE(created_at) = '2026-06-01' 같은 식은 문법적으로는 문제없지만, 이후 최적화 단계에서 index range 후보를 약하게 만든다.
  • OR, 서브쿼리, 파생 테이블, CTE 사용 형태: 문법은 허용되더라도 query block이 어떻게 쪼개지는지가 달라진다.
  • 함수와 연산자의 배치: 컬럼을 함수로 감싼 조건, 복잡한 CASE 식, 암묵적 문자열/숫자 비교는 뒤 단계에 부담을 준다.

Parser는 비용 기반 판단을 하지 않는다. 따라서 parser 단계에서 좋은 구조를 만들어 주는 것이 중요하다. 동일한 의미를 표현하더라도 더 단순하고 SARGable한 형태가 뒤 단계에 더 유리한 입력을 제공한다.

3.2 실무 해석

운영자가 SQL 리뷰를 할 때 parser 관점에서 먼저 볼 질문은 의외로 단순하다.

  • 인덱스 컬럼이 함수 내부에 들어가 있지 않은가?
  • 조건식이 불필요하게 복잡하지 않은가?
  • CTE, 서브쿼리, 뷰가 너무 많이 중첩되어 query block이 복잡해지지 않는가?
  • OR 조건을 UNION ALL 또는 다른 구조로 바꾸는 편이 후보 계획을 단순화하지 않는가?

이 질문은 아직 “계획이 무엇인가”가 아니라 “좋은 계획이 나오기 쉬운 입력인가”를 보는 단계다.

4. Resolver: 이름 해석, 타입 확정, 메타데이터 연결

Resolver는 parse tree 안의 식별자를 실제 객체와 연결한다. orders.created_at이 어느 테이블의 어느 컬럼인지, status가 별칭인지 실제 컬럼인지, 문자열과 숫자 비교 시 어떤 타입 변환이 필요한지, 집계와 비집계 컬럼 조합이 문법적으로 허용되는지 등을 정리한다.

4.1 Resolver 단계의 핵심 역할

  • 테이블/뷰/CTE 참조 대상 확정
  • 컬럼 소속 결정과 별칭 해석
  • 데이터 타입 및 collation 정리
  • 권한 확인
  • generated column, 함수, 표현식의 결과 타입 계산

4.2 Resolver 단계에서 흔한 성능 함정

4.2.1 암묵적 형변환

예를 들어 varchar 컬럼과 숫자 리터럴을 비교하거나, 문자열 collation이 다른 값을 함께 비교하면 resolver 이후 표현식 평가 비용이 늘고, 인덱스 사용 가능성도 흔들릴 수 있다.

4.2.2 모호한 컬럼 참조

조인 SQL에서 id, status, created_at 같은 공통 컬럼명을 테이블 접두어 없이 사용하면, 유지보수 중에 의도치 않은 해석 변화가 생길 수 있다. 실행 자체는 되더라도 리뷰와 디버깅이 어려워진다.

4.2.3 함수 결과 타입 오해

날짜/문자열/숫자 함수의 결과 타입을 잘못 이해하면 옵티마이저가 조건을 단순 비교로 취급하지 못하고 표현식 평가로 넘길 수 있다.

4.3 운영 관점의 의미

Resolver 단계의 문제는 흔히 “왜 인덱스를 안 타지?”라는 표면적 질문으로 나타난다. 그러나 실제로는 옵티마이저가 잘못 판단한 것이 아니라, 해석이 끝난 표현식 자체가 이미 인덱스 친화적이지 않게 변한 경우가 많다. 따라서 실행 계획을 보기 전에 스키마 타입, 비교 타입, 문자셋, collation, 함수 사용 여부를 먼저 점검해야 한다.

5. Optimizer: 비용 기반으로 실행 계획을 선택하는 단계

Optimizer는 MySQL SQL 처리 흐름의 중심이다. 이 단계에서는 가능한 접근 경로(access path), 조인 순서(join order), 인덱스 사용 방식, 정렬 수행 방식, 임시 테이블 필요 여부 등을 비교한 뒤 최종 실행 계획을 만든다.

5.1 Optimizer가 주로 결정하는 것

  • table access method: const, ref, range, index, ALL
  • 어떤 인덱스를 사용할지, 또는 인덱스를 쓰지 않을지
  • 조인 순서와 join buffer 사용 가능성
  • WHERE 조건을 어느 시점에 적용할지
  • ORDER BY, GROUP BY, DISTINCT 처리를 인덱스로 해결할지, 정렬/임시 테이블로 넘길지
  • 파생 테이블 merge/materialize 여부
  • semijoin, subquery transformation 같은 rewrite 적용 여부

5.2 Optimizer의 입력은 완벽하지 않다

중요한 점은 Optimizer가 미래를 정확히 아는 존재가 아니라는 점이다. 옵티마이저는 다음 정보를 바탕으로 비용을 추정할 뿐이다.

  • 인덱스 cardinality
  • 테이블 통계와 히스토그램
  • 조건식 selectivity 추정
  • 조인 대상 row 수 추정
  • 사용 가능한 접근 경로 집합

따라서 통계가 오래되었거나, 데이터 분포가 치우쳐 있거나, 조건식이 비SARGable하면 “논리적으로는 가능한 좋은 계획”이 있어도 선택되지 않을 수 있다.

5.3 DBA가 이해해야 할 핵심 원칙

  1. Optimizer는 인덱스 존재 자체보다 예상 비용을 본다.
  2. 인덱스가 있어도 row 추정 비용이 높으면 table scan을 선택할 수 있다.
  3. 잘못된 row estimate는 잘못된 조인 순서로 이어질 수 있다.
  4. 정렬 회피를 위한 복합 인덱스와 필터링 인덱스가 서로 경쟁할 수 있다.
  5. 함수, 표현식, 타입 변환은 후보 계획을 좁힌다.

6. Executor: 선택된 계획을 실제로 수행하는 단계

Executor는 옵티마이저가 결정한 계획을 따라 row를 읽고, 조인하고, 필터링하고, 정렬하고, 결과를 반환한다. InnoDB 환경에서는 Executor가 스토리지 엔진 API를 호출하면서 실제 페이지 접근, 버퍼 풀 조회, MVCC visibility 체크, 필요 시 디스크 I/O를 유발한다.

운영 관점에서는 다음 오해를 피해야 한다.

  • 실행 시간이 길다고 해서 항상 executor의 문제는 아니다. 계획이 나쁘면 executor는 느릴 수밖에 없다.
  • 반대로 계획이 좋아 보여도 데이터가 캐시에 없거나 락 대기가 있으면 executor 단계에서 지연이 발생한다.
  • EXPLAIN은 계획을 보여 주지만, 실제 executor가 만난 row 수와 시간은 EXPLAIN ANALYZE, Performance Schema, Rows_examined류 지표가 더 잘 보여 준다.

즉, 성능 분석은 Optimizer가 무엇을 선택했는가Executor가 실제로 얼마만큼의 row와 시간을 소비했는가를 함께 봐야 한다.

7. 실행 계획 해석의 기본: Optimizer 입력과 출력 확인

아래 SQL은 현재 MySQL 8.0 계열에서 optimizer 관련 기본 상태를 확인하는 출발점이다.

SELECT VERSION() AS mysql_version;

SHOW VARIABLES
WHERE Variable_name IN (
  'optimizer_switch',
  'eq_range_index_dive_limit',
  'range_optimizer_max_mem_size'
);

실행 결과(MySQL 8.0.x):

mysql> SELECT VERSION() AS mysql_version;

+---------------+
| mysql_version |
+---------------+
| 8.0.46        |
+---------------+
1 row in set (0.00 sec)

mysql> SHOW VARIABLES
    -> WHERE Variable_name IN (
    ->   'optimizer_switch',
    ->   'eq_range_index_dive_limit',
    ->   'range_optimizer_max_mem_size'
    -> );

+------------------------------+--------------------------------------------------+
| Variable_name                | Value                                            |
+------------------------------+--------------------------------------------------+
| eq_range_index_dive_limit    | 200                                              |
| optimizer_switch             | index_merge=on, ... derived_condition_pushdown=on |
| range_optimizer_max_mem_size | 8388608                                          |
+------------------------------+--------------------------------------------------+
3 rows in set (0.00 sec)

optimizer_switch 값은 매우 길기 때문에 위 결과 블록에서는 핵심 형태만 남기고 중간 구간을 줄여 적었다.

이 조회는 세부 튜닝을 바로 하려는 목적보다, 이 인스턴스가 어떤 Optimizer 동작 스위치와 메모리 한도 위에서 계획을 선택하는지를 확인하는 기초 점검에 가깝다. 특히 버전 업그레이드 직후에는 기본값 차이 때문에 과거와 다른 계획이 보일 수 있으므로, 먼저 현재 값을 확인하는 습관이 필요하다.

8. 예제 1: 인덱스 친화적인 조건과 비친화적인 조건의 차이

아래 예제는 같은 테이블이라도 조건식 구조에 따라 Optimizer 후보 계획이 얼마나 달라지는지 보여 준다. 핵심은 “인덱스가 있느냐”가 아니라 “옵티마이저가 그 인덱스를 유효한 range access로 볼 수 있느냐”다.

DROP TABLE IF EXISTS optimizer_demo;

CREATE TABLE optimizer_demo (
  order_id BIGINT PRIMARY KEY,
  customer_id BIGINT NOT NULL,
  status VARCHAR(20) NOT NULL,
  created_at DATETIME NOT NULL,
  amount DECIMAL(10,2) NOT NULL,
  KEY idx_customer_created (customer_id, created_at),
  KEY idx_status_created (status, created_at)
);

INSERT INTO optimizer_demo (order_id, customer_id, status, created_at, amount) VALUES
  (1, 100, 'PAID',    '2026-06-01 09:00:00', 120.00),
  (2, 100, 'PAID',    '2026-06-01 10:00:00',  80.00),
  (3, 100, 'PENDING', '2026-06-02 09:00:00',  50.00),
  (4, 101, 'PAID',    '2026-06-01 09:30:00', 300.00),
  (5, 101, 'FAILED',  '2026-06-03 11:00:00',  40.00),
  (6, 102, 'PAID',    '2026-06-02 14:00:00', 220.00),
  (7, 100, 'PAID',    '2026-06-03 08:00:00',  35.00),
  (8, 103, 'PAID',    '2026-06-03 12:30:00', 410.00),
  (9, 100, 'FAILED',  '2026-06-04 16:00:00',  15.00),
  (10, 104, 'PENDING','2026-06-05 18:00:00',  99.00);

ANALYZE TABLE optimizer_demo;

EXPLAIN FORMAT=TREE
SELECT order_id, customer_id, created_at
FROM optimizer_demo
WHERE customer_id = 100
  AND created_at >= '2026-06-01 00:00:00'
ORDER BY created_at DESC
LIMIT 3;

EXPLAIN FORMAT=TREE
SELECT order_id, customer_id, created_at
FROM optimizer_demo
WHERE DATE(created_at) = '2026-06-01'
ORDER BY created_at DESC;

실행 결과(MySQL 8.0.x):

mysql> DROP TABLE IF EXISTS optimizer_demo;

Query OK, 0 rows affected (0.00 sec)

mysql> CREATE TABLE optimizer_demo (...);

Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO optimizer_demo (...);

Query OK, 10 rows affected (0.01 sec)
Records: 10  Duplicates: 0  Warnings: 0

mysql> ANALYZE TABLE optimizer_demo;

+--------------------------------+---------+----------+----------+
| Table                          | Op      | Msg_type | Msg_text |
+--------------------------------+---------+----------+----------+
| mysql_tech_note.optimizer_demo | analyze | status   | OK       |
+--------------------------------+---------+----------+----------+
1 row in set (0.00 sec)

mysql> EXPLAIN FORMAT=TREE
    -> SELECT order_id, customer_id, created_at
    -> FROM optimizer_demo
    -> WHERE customer_id = 100
    ->   AND created_at >= '2026-06-01 00:00:00'
    -> ORDER BY created_at DESC
    -> LIMIT 3;

+------------------------------------------------------------------+
| EXPLAIN                                                          |
+------------------------------------------------------------------+
| -> Limit: 3 row(s)  (cost=1.26 rows=3)                           |
|     -> Filter: ((customer_id = 100) and (created_at >= ...))     |
|         -> Covering index range scan using idx_customer_created  |
+------------------------------------------------------------------+
1 row in set (0.00 sec)

mysql> EXPLAIN FORMAT=TREE
    -> SELECT order_id, customer_id, created_at
    -> FROM optimizer_demo
    -> WHERE DATE(created_at) = '2026-06-01'
    -> ORDER BY created_at DESC;

+--------------------------------------------------------------+
| EXPLAIN                                                      |
+--------------------------------------------------------------+
| -> Sort: created_at DESC  (cost=1.25 rows=10)                |
|     -> Filter: (cast(created_at as date) = '2026-06-01')     |
|         -> Index scan on optimizer_demo using idx_customer_created |
+--------------------------------------------------------------+
1 row in set (0.00 sec)

실행 계획 문자열은 매우 길기 때문에 위 결과 블록에서는 핵심 노드만 줄여 적었다. 중요한 차이는 첫 번째 쿼리가 Covering index range scan을 사용한 반면, 두 번째 쿼리는 DATE() 적용 때문에 정렬과 추가 필터 단계를 거친다는 점이다.

첫 번째 쿼리는 (customer_id, created_at) 복합 인덱스를 비교적 자연스럽게 사용할 수 있다. 반면 두 번째 쿼리는 created_at 컬럼을 DATE() 함수로 감싸서 비교하므로, 인덱스 range 조건으로 해석하기가 훨씬 어려워진다. 실제 운영에서는 이런 패턴 하나 때문에 동일한 비즈니스 조회가 수 ms에서 수 초로 벌어질 수 있다.

실무에서는 두 번째 조건을 다음과 같이 바꾸는 편이 일반적이다.

  • 나쁜 예: DATE(created_at) = '2026-06-01'
  • 더 나은 예: created_at >= '2026-06-01 00:00:00' AND created_at < '2026-06-02 00:00:00'

이 차이는 단순 코딩 스타일이 아니라, Optimizer가 후보 계획을 얼마나 잘 세울 수 있는가의 차이다.

9. 예제 2: 정렬 요구와 인덱스 순서가 맞지 않을 때

다음 예제는 필터링은 잘 되더라도 ORDER BY 대상이 인덱스 순서와 맞지 않으면 별도 정렬 비용이 추가될 수 있음을 보여 준다.

EXPLAIN FORMAT=TREE
SELECT order_id, customer_id, amount
FROM optimizer_demo
WHERE status = 'PAID'
ORDER BY amount DESC
LIMIT 3;

DROP TABLE IF EXISTS optimizer_demo;

실행 결과(MySQL 8.0.x):

mysql> EXPLAIN FORMAT=TREE
    -> SELECT order_id, customer_id, amount
    -> FROM optimizer_demo
    -> WHERE status = 'PAID'
    -> ORDER BY amount DESC
    -> LIMIT 3;

+---------------------------------------------------------------+
| EXPLAIN                                                       |
+---------------------------------------------------------------+
| -> Limit: 3 row(s)  (cost=1.1 rows=3)                         |
|     -> Sort: optimizer_demo.amount DESC, limit input to 3 row |
|         -> Index lookup on optimizer_demo using idx_status_created |
+---------------------------------------------------------------+
1 row in set (0.00 sec)

mysql> DROP TABLE IF EXISTS optimizer_demo;

Query OK, 0 rows affected (0.00 sec)

이 결과는 status='PAID' 조건을 인덱스로 찾은 뒤에도 amount DESC 정렬을 위해 별도 sort 단계가 남는다는 사실을 보여 준다.

status 조건은 idx_status_created 후보를 열어 주지만, 정렬 키는 amount다. 따라서 옵티마이저는 “조건 필터링용 인덱스 + 추가 정렬”과 “다른 접근 경로”를 비교하게 된다. 데이터 규모가 커질수록 이 선택은 훨씬 민감해진다.

여기서 DBA가 얻어야 할 교훈은 단순하다.

  • WHERE 절에 맞는 인덱스와 ORDER BY 절에 맞는 인덱스는 다를 수 있다.
  • 복합 인덱스 설계는 조회 패턴의 필터링 + 정렬 + LIMIT 조합을 함께 봐야 한다.
  • LIMIT가 작을수록 정렬 회피형 인덱스가 더 큰 가치를 가질 수 있다.

10. 실행 계획을 볼 때 흔히 놓치는 질문

많은 팀이 EXPLAIN 결과에서 type, key, rows, Extra만 빠르게 보고 끝낸다. 그러나 안정적인 운영을 위해서는 다음 질문을 함께 던져야 한다.

10.1 이 계획은 왜 이 인덱스를 골랐는가

  • cardinality가 더 높아서인가?
  • 정렬까지 동시에 만족해서인가?
  • 통계가 오래되어 실제보다 싸게 보였기 때문인가?

10.2 추정 row 수가 현실적인가

  • rows 추정치가 실제 데이터 분포와 크게 다르지 않은가?
  • 특정 값 쏠림(skew)이 심한 컬럼에서 히스토그램이 필요한 상태는 아닌가?

10.3 실행 엔진이 추가 작업을 하는가

  • filesort가 붙는가?
  • temporary table이 필요한가?
  • covering index가 가능한데 table lookup이 발생하는가?

10.4 SQL 자체를 더 단순하게 만들 수 있는가

  • 함수 제거가 가능한가?
  • OR 분해가 가능한가?
  • 파생 테이블 materialization을 피할 수 있는가?

11. Aurora MySQL에서의 해석 포인트

Aurora MySQL을 사용할 때도 parser, resolver, optimizer, executor라는 큰 흐름은 그대로 유지된다. 즉, Aurora라고 해서 Optimizer가 완전히 별개 제품으로 동작하는 것은 아니다. 대체로 해당 Aurora MySQL 호환 버전이 기반으로 삼는 MySQL major/minor 특성을 따른다.

다만 운영 해석에서는 몇 가지 차이를 기억할 필요가 있다.

11.1 스토리지 구조가 달라도 SQL 계층 판단은 여전히 중요하다

Aurora의 분산 스토리지는 I/O 특성과 복구 특성에 영향을 주지만, 잘못된 SQL 구조나 부정확한 통계가 좋은 실행 계획을 자동으로 만들어 주지는 않는다. 풀스캔이 풀스캔인 사실은 변하지 않는다.

11.2 Reader 인스턴스도 각자 계획을 세운다

읽기 전용 트래픽을 여러 Reader로 분산하는 구조에서는 각 인스턴스가 자신의 세션 환경과 통계 상태를 기준으로 계획을 세운다. 파라미터 그룹 차이, 버전 차이, 통계 갱신 시점 차이가 있으면 동일 SQL도 체감 성능이 달라질 수 있다.

11.3 Aurora 전용 기능이 있어도 기본 원칙은 같다

Aurora 고유 기능이나 버전별 확장이 있더라도, SARGable 조건 작성, 적절한 복합 인덱스, 통계 유지, 실행 계획 검증이라는 기본 원칙은 달라지지 않는다. Aurora 운영에서 중요한 것은 “플랫폼 차이”를 과장하지 않고, 기본 MySQL 계획 해석 위에 Aurora 특성을 얹어 보는 것이다.

12. 자주 발생하는 오해와 실패 패턴

12.1 “인덱스가 있으니 당연히 빠르다”는 오해

인덱스가 존재해도 selectivity가 낮거나, 정렬 비용까지 포함하면 full scan이 더 싸다고 판단될 수 있다.

12.2 “EXPLAIN에서 key가 보이니 문제없다”는 오해

key가 잡혀도 실제 row 추정이 과도하거나, 추가 정렬과 임시 테이블 비용이 크면 전체 쿼리는 여전히 느릴 수 있다.

12.3 “옵티마이저가 틀렸다”는 단정

옵티마이저의 계획이 나빠 보일 때 실제 원인은 오래된 통계, 비SARGable 조건, 타입 불일치, 부적절한 인덱스 설계인 경우가 더 많다.

12.4 “힌트로 고정하면 끝난다”는 오해

힌트는 응급 처치일 수는 있지만, 데이터 분포와 버전이 바뀌면 오히려 장기 리스크가 된다. 가능한 한 SQL 구조와 인덱스, 통계를 먼저 바로잡아야 한다.

13. 운영 점검 체크리스트

13.1 느린 쿼리를 처음 볼 때

  • EXPLAIN 또는 EXPLAIN FORMAT=TREE

13.2 실행 계획이 예상과 다를 때

  • rows
  • ORDER BY, GROUP BY, DISTINCT

13.3 Aurora MySQL 환경이라면 추가로

14. 정리

MySQL의 SQL 처리는 parser, resolver, optimizer, executor라는 연속된 단계로 이해할 때 가장 명확해진다. parser는 구조를 만들고, resolver는 의미를 확정하며, optimizer는 비용 기반 계획을 선택하고, executor는 그것을 실제로 수행한다. 운영자가 자주 만나는 성능 문제는 이 중 한 단계만의 실패가 아니라, 앞 단계에서 나빠진 입력이 뒤 단계의 의사결정을 왜곡한 결과인 경우가 많다.

따라서 실행 계획 분석의 출발점은 “왜 이 계획이 나왔는가”를 단계별로 분해하는 것이다. 함수로 감싼 조건, 타입 불일치, 부정확한 통계, 정렬을 고려하지 않은 인덱스 설계 같은 기본 요소를 먼저 점검하면, 불필요한 힌트 의존을 줄이고 더 재현성 있는 튜닝을 할 수 있다.

다음 글들에서는 이 큰 그림 위에서 MySQL cost model, cardinality 추정, 히스토그램, join order, range optimizer 같은 세부 주제를 더 깊게 다룰 수 있다. 오늘 글의 목적은 그 출발점으로서, Optimizer를 단일 블랙박스가 아니라 여러 단계가 이어진 SQL 처리 파이프라인으로 보는 운영 관점을 정리하는 데 있다.