MySQL 통계 정보: cardinality, index statistics, persistent statistics
MySQL 옵티마이저가 cardinality와 index statistics를 어떻게 사용하며 persistent statistics가 왜 실행 계획 안정성에 중요한지 운영 관점에서 정리한다.
1. 왜 통계 정보가 실행 계획의 품질을 좌우하는가
MySQL에서 인덱스를 잘 설계했는데도 실행 계획이 기대와 다르게 나오는 경우가 있다. 운영자는 흔히 “옵티마이저가 멍청하다”라고 표현하지만, 실제로는 옵티마이저가 판단에 사용한 통계 정보(statistics) 가 부정확하거나 오래되었기 때문에 잘못된 비용 추정을 한 경우가 많다. 이 문제는 단순히 몇 밀리초 느려지는 수준에서 끝나지 않는다.
- 배포 직후 특정 쿼리만 갑자기 full scan으로 바뀔 수 있다.
- 조인 순서가 뒤집히면서 CPU와 I/O가 동시에 증가할 수 있다.
- 인덱스는 존재하지만 cardinality 추정이 부정확해
range대신ALL또는 비효율적인ref접근이 선택될 수 있다. - Aurora MySQL처럼 스토리지는 분산되어 있어도, 실행 계획 선택은 여전히 MySQL 옵티마이저와 통계 품질에 강하게 의존한다.
결국 옵티마이저는 미래를 정확히 아는 존재가 아니라, 통계에 기반해 “이 경로가 더 쌀 것 같다”라고 추정하는 의사결정기다. 따라서 DBA가 cardinality, index statistics, persistent statistics를 이해하는 일은 단순 튜닝 기법이 아니라 실행 계획 안정성을 관리하는 기본 운영 역량에 가깝다.
2. 핵심 개념: cardinality와 index statistics는 무엇을 뜻하는가
2.1 cardinality의 실무적 의미
cardinality는 보통 “서로 다른 값의 개수”를 뜻한다. 다만 MySQL 실행 계획 문맥에서 cardinality는 수학적 정의를 엄밀하게 계산한 절대 진실이라기보다, 인덱스가 얼마나 많은 row를 걸러낼 수 있을지 추정하기 위한 근사치에 가깝다.
예를 들어 다음과 같은 컬럼이 있다고 하자.
country_code: 값 종류가 매우 적다.tenant_id: 고객 수만큼 다양하다.created_at: 사실상 매우 많은 구간 값을 가진다.
이때 일반적으로 tenant_id 또는 created_at 쪽이 country_code보다 선택도가 높고, 옵티마이저는 더 적은 row를 읽을 가능성이 큰 인덱스를 선호할 수 있다. 중요한 점은 옵티마이저가 실제 row를 모두 세어 보고 결정하지는 않는다는 사실이다. 통계에 근거한 추정으로 결론을 내린다.
2.2 index statistics가 담고 있는 것
index statistics는 단일 숫자 하나만 의미하지 않는다. 실무적으로는 다음 정보를 포함해 이해하는 편이 좋다.
- 인덱스 선행 컬럼(prefix)의 서로 다른 값 수 추정치
- 인덱스 페이지 수, leaf page 수 같은 내부 규모 정보
- 테이블 row 수 추정치
- 통계 마지막 갱신 시점
- 샘플링을 통해 얻은 근사치인지, 지속 보관되는 통계인지 여부
특히 복합 인덱스에서는 tenant_id 하나의 분포와 (tenant_id, status) 조합의 분포가 다르다. 따라서 “인덱스가 하나 있으니 충분하다”가 아니라, 옵티마이저가 어느 prefix 통계를 보고 어떤 필터 효과를 기대하는지를 봐야 한다.
2.3 persistent statistics의 의미
persistent statistics는 InnoDB 통계 정보를 메모리성 임시 상태로만 두지 않고, 시스템 테이블에 저장해 재시작 이후에도 유지하는 방식이다. 이것이 중요한 이유는 다음과 같다.
- 서버 재시작 때마다 통계가 크게 요동치지 않는다.
- 샘플링 결과가 어느 정도 안정적으로 유지되어 계획 변동성을 줄인다.
- 운영자가
ANALYZE TABLE시점을 통제하면서 계획 변화를 관리할 수 있다.
반대로 persistent statistics에 대한 이해 없이 대량 적재, 파티션 변화, 인덱스 추가 직후를 방치하면, 통계는 오래된 추정치를 유지하고 실행 계획은 현실과 멀어질 수 있다.
3. 통계가 옵티마이저로 들어가는 경로
옵티마이저는 테이블과 인덱스의 구조만 보고 계획을 고르지 않는다. 통계로부터 “이 조건이면 몇 row가 남을까?”를 추정하고, 그 추정치를 바탕으로 접근 경로와 조인 순서를 고른다.
flowchart LR
A[SQL 조건식] --> B[옵티마이저]
C[테이블 row 수 추정] --> B
D[인덱스 cardinality] --> B
E[복합 인덱스 prefix 통계] --> B
F[히스토그램/추가 분포 정보] --> B
B --> G[접근 경로 선택]
B --> H[조인 순서 결정]
B --> I[range / ref / scan 비용 추정]
이 흐름에서 실무적으로 기억해야 할 핵심은 다음과 같다.
- 좋은 인덱스 설계와 좋은 통계는 별개다. 인덱스가 좋아도 통계가 낡으면 계획이 흔들릴 수 있다.
- 통계는 근사치다. 작은 테이블에서는 오차가 잘 안 보이지만, 대형 테이블과 skewed distribution에서는 오차가 크게 증폭된다.
- 조인 계획은 연쇄적으로 망가질 수 있다. 첫 테이블 row 추정이 틀리면 다음 조인의 비용 계산도 함께 왜곡된다.
4. SHOW INDEX에서 보이는 cardinality를 어떻게 읽어야 하는가
운영 현장에서 가장 자주 보는 통계 관련 명령은 SHOW INDEX FROM ...이다. 여기서 Cardinality 컬럼은 해당 인덱스 또는 인덱스 prefix의 서로 다른 값 수를 대략적으로 보여 준다.
그러나 이 값을 볼 때 흔한 오해가 있다.
4.1 cardinality는 정확한 실측값이 아닐 수 있다
InnoDB는 통계를 샘플링으로 추정할 수 있으므로, Cardinality는 실제 distinct count와 정확히 일치하지 않을 수 있다. 특히 다음 상황에서는 오차가 커질 수 있다.
- 테이블이 매우 크고 데이터 분포가 한쪽으로 치우친 경우
- 최근 대량 적재 또는 삭제가 있었지만 아직 적절히 분석되지 않은 경우
- 인덱스 선행 컬럼의 값 분포가 hotspot 형태인 경우
- 파티션 구조가 바뀌었거나 재구성이 있었던 경우
4.2 낮은 cardinality가 항상 나쁜 것은 아니다
예를 들어 status가 ACTIVE, INACTIVE, DELETED 정도만 가진다면 cardinality는 낮다. 그렇다고 이 인덱스가 무조건 쓸모없다는 뜻은 아니다.
- 다른 고선택도 컬럼과 복합 인덱스를 구성하면 유용할 수 있다.
- 정렬 회피나 covering index 목적으로는 여전히 가치가 있다.
- 파티션 키, tenant 키와 결합하면 국소적으로 높은 선택도를 만들 수 있다.
즉, cardinality는 “좋다/나쁘다”의 도덕 판단 기준이 아니라, 옵티마이저가 얼마나 row를 줄일 수 있다고 기대할지를 해석하는 재료다.
4.3 복합 인덱스는 prefix 단위로 읽어야 한다
복합 인덱스 KEY idx_tenant_status (tenant_id, status)가 있다면, 옵티마이저는 보통 다음을 구분한다.
tenant_id = ?조건에 대한 선행 prefix 효과tenant_id = ? AND status = ?조건에 대한 더 좁은 prefix 효과status = ?만 있는 경우 선행 컬럼이 빠져서 효율이 낮아질 가능성
따라서 SHOW INDEX 결과를 볼 때는 단순히 인덱스 이름만 보지 말고, Seq_in_index, Column_name, Cardinality를 함께 해석해야 한다.
5. persistent statistics가 왜 계획 안정성에 중요한가
InnoDB는 innodb_stats_persistent를 통해 통계를 지속 저장할 수 있다. MySQL 8.0 계열에서는 이 기능이 사실상 기본 운영 전제에 가깝다. 이유는 명확하다.
5.1 재시작 후 계획이 덜 흔들린다
persistent statistics가 없거나 적절히 관리되지 않으면 재시작 이후 통계 재수집 타이밍에 따라 같은 SQL이 다른 계획을 택할 수 있다. 특히 큰 테이블이 많은 환경에서는 warm-up 과정과 겹쳐 성능 분석을 더 어렵게 만든다.
5.2 ANALYZE TABLE의 운영 의미가 분명해진다
ANALYZE TABLE은 단순 정리 명령이 아니라, 옵티마이저가 사용할 통계를 새로 계산해 저장하는 작업이다. 따라서 이 명령은 다음 상황에서 중요하다.
- 대량 적재나 대량 삭제 이후
- 인덱스 추가 또는 변경 이후
- 데이터 분포가 계절성/이벤트성으로 크게 바뀐 이후
- 계획이 갑자기 변하고, 통계 오염이 의심되는 경우
반대로 너무 자주 무의미하게 실행하면, 샘플링 결과의 작은 차이로 인해 오히려 계획 변동성을 키울 수 있다. 운영에서는 필요한 시점에 의도적으로 실행하고, 전후 계획을 비교하는 습관이 중요하다.
5.3 mysql.innodb_table_stats / mysql.innodb_index_stats를 읽을 수 있어야 한다
MySQL 8.0에서는 InnoDB persistent statistics가 내부 시스템 테이블에 저장된다. 대표적으로 다음 두 테이블이 중요하다.
mysql.innodb_table_statsmysql.innodb_index_stats
이 테이블은 단순 curiosity 용도가 아니라, 다음 질문에 답할 때 실무적으로 유용하다.
- 지금 옵티마이저가 참고하는 row 수 추정치는 얼마인가?
- 특정 인덱스 prefix의 distinct 추정치는 얼마인가?
- 마지막 통계 갱신 시점은 언제인가?
- 인덱스 leaf page 규모가 얼마나 되는가?
6. 예제 1: persistent statistics 관련 기본 변수 확인
아래 SQL은 MySQL 8.0 환경에서 통계 관련 기본 설정을 점검하는 안전한 출발점이다.
SELECT VERSION() AS mysql_version;
SHOW VARIABLES
WHERE Variable_name IN (
'innodb_stats_persistent',
'innodb_stats_auto_recalc',
'innodb_stats_persistent_sample_pages'
);
실행 결과(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 (
-> 'innodb_stats_persistent',
-> 'innodb_stats_auto_recalc',
-> 'innodb_stats_persistent_sample_pages'
-> );
+--------------------------------------+-------+
| Variable_name | Value |
+--------------------------------------+-------+
| innodb_stats_auto_recalc | ON |
| innodb_stats_persistent | ON |
| innodb_stats_persistent_sample_pages | 20 |
+--------------------------------------+-------+
3 rows in set (0.00 sec)
이 결과를 볼 때는 다음을 함께 해석해야 한다.
innodb_stats_persistent: persistent statistics 사용 여부innodb_stats_auto_recalc: 일정 수준의 변경 후 자동 재계산 허용 여부innodb_stats_persistent_sample_pages: 샘플링 페이지 수
작은 샘플링 페이지 수는 분석 비용을 줄이지만, 분포가 복잡한 대형 테이블에서는 통계 정확도가 떨어질 수 있다. 반대로 지나치게 크게 잡으면 ANALYZE TABLE 비용이 불필요하게 증가할 수 있다.
7. 예제 2: SHOW INDEX와 InnoDB 통계 테이블 함께 보기
다음 예제는 복합 인덱스를 가진 테이블을 만들고, ANALYZE TABLE 이후 SHOW INDEX와 InnoDB persistent statistics를 함께 확인하는 흐름이다. 작은 예제지만 “옵티마이저가 실제로 보는 숫자는 어디에 저장되는가”를 이해하는 데 도움이 된다.
DROP TABLE IF EXISTS stats_demo;
CREATE TABLE stats_demo (
id BIGINT PRIMARY KEY,
tenant_id INT NOT NULL,
status VARCHAR(16) NOT NULL,
region VARCHAR(16) NOT NULL,
created_at DATETIME NOT NULL,
amount DECIMAL(10,2) NOT NULL,
KEY idx_tenant_status (tenant_id, status),
KEY idx_created_at (created_at)
);
INSERT INTO stats_demo (id, tenant_id, status, region, created_at, amount) VALUES
(1, 1, 'ACTIVE', 'ap-northeast-2', '2026-06-01 09:00:00', 100.00),
(2, 1, 'ACTIVE', 'ap-northeast-2', '2026-06-01 09:10:00', 110.00),
(3, 1, 'ACTIVE', 'ap-northeast-2', '2026-06-01 09:20:00', 120.00),
(4, 1, 'INACTIVE', 'ap-northeast-2', '2026-06-02 09:00:00', 130.00),
(5, 1, 'ACTIVE', 'us-east-1', '2026-06-03 09:00:00', 140.00),
(6, 2, 'ACTIVE', 'us-east-1', '2026-06-01 10:00:00', 150.00),
(7, 2, 'ACTIVE', 'us-east-1', '2026-06-02 10:00:00', 160.00),
(8, 2, 'INACTIVE', 'us-east-1', '2026-06-03 10:00:00', 170.00),
(9, 3, 'ACTIVE', 'eu-central-1', '2026-06-01 11:00:00', 180.00),
(10, 3, 'ACTIVE', 'eu-central-1', '2026-06-02 11:00:00', 190.00),
(11, 3, 'INACTIVE', 'eu-central-1', '2026-06-03 11:00:00', 200.00),
(12, 4, 'ACTIVE', 'ap-southeast-1', '2026-06-04 12:00:00', 210.00);
ANALYZE TABLE stats_demo;
SHOW INDEX FROM stats_demo;
SELECT table_name, last_update, n_rows, clustered_index_size, sum_of_other_index_sizes
FROM mysql.innodb_table_stats
WHERE database_name = DATABASE()
AND table_name = 'stats_demo';
SELECT index_name, stat_name, stat_value
FROM mysql.innodb_index_stats
WHERE database_name = DATABASE()
AND table_name = 'stats_demo'
AND stat_name IN ('n_diff_pfx01', 'n_diff_pfx02', 'n_leaf_pages', 'size')
ORDER BY index_name, stat_name;
실행 결과(MySQL 8.0.x):
mysql> DROP TABLE IF EXISTS stats_demo;
Query OK, 0 rows affected (0.00 sec)
mysql> CREATE TABLE stats_demo (
-> id BIGINT PRIMARY KEY,
-> tenant_id INT NOT NULL,
-> status VARCHAR(16) NOT NULL,
-> region VARCHAR(16) NOT NULL,
-> created_at DATETIME NOT NULL,
-> amount DECIMAL(10,2) NOT NULL,
-> KEY idx_tenant_status (tenant_id, status),
-> KEY idx_created_at (created_at)
-> );
Query OK, 0 rows affected (0.00 sec)
mysql> INSERT INTO stats_demo (id, tenant_id, status, region, created_at, amount) VALUES
-> (... 12 rows ...);
Query OK, 12 rows affected (0.00 sec)
Records: 12 Duplicates: 0 Warnings: 0
mysql> ANALYZE TABLE stats_demo;
+----------------------------+---------+----------+----------+
| Table | Op | Msg_type | Msg_text |
+----------------------------+---------+----------+----------+
| mysql_tech_note.stats_demo | analyze | status | OK |
+----------------------------+---------+----------+----------+
1 row in set (0.01 sec)
mysql> SHOW INDEX FROM stats_demo;
+------------+------------+-------------------+--------------+-------------+-----------+-------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality |
+------------+------------+-------------------+--------------+-------------+-----------+-------------+
| stats_demo | 0 | PRIMARY | 1 | id | A | 12 |
| stats_demo | 1 | idx_tenant_status | 1 | tenant_id | A | 4 |
| stats_demo | 1 | idx_tenant_status | 2 | status | A | 7 |
| stats_demo | 1 | idx_created_at | 1 | created_at | A | 12 |
+------------+------------+-------------------+--------------+-------------+-----------+-------------+
4 rows in set (0.00 sec)
mysql> SELECT table_name, last_update, n_rows, clustered_index_size, sum_of_other_index_sizes
-> FROM mysql.innodb_table_stats
-> WHERE database_name = DATABASE()
-> AND table_name = 'stats_demo';
+------------+---------------------+--------+----------------------+--------------------------+
| table_name | last_update | n_rows | clustered_index_size | sum_of_other_index_sizes |
+------------+---------------------+--------+----------------------+--------------------------+
| stats_demo | 2026-06-30 00:06:29 | 12 | 1 | 2 |
+------------+---------------------+--------+----------------------+--------------------------+
1 row in set (0.00 sec)
mysql> SELECT index_name, stat_name, stat_value
-> FROM mysql.innodb_index_stats
-> WHERE database_name = DATABASE()
-> AND table_name = 'stats_demo'
-> AND stat_name IN ('n_diff_pfx01', 'n_diff_pfx02', 'n_leaf_pages', 'size')
-> ORDER BY index_name, stat_name;
+-------------------+--------------+------------+
| index_name | stat_name | stat_value |
+-------------------+--------------+------------+
| PRIMARY | n_diff_pfx01 | 12 |
| PRIMARY | n_leaf_pages | 1 |
| PRIMARY | size | 1 |
| idx_created_at | n_diff_pfx01 | 12 |
| idx_created_at | n_diff_pfx02 | 12 |
| idx_created_at | n_leaf_pages | 1 |
| idx_created_at | size | 1 |
| idx_tenant_status | n_diff_pfx01 | 4 |
| idx_tenant_status | n_diff_pfx02 | 7 |
| idx_tenant_status | n_leaf_pages | 1 |
| idx_tenant_status | size | 1 |
+-------------------+--------------+------------+
11 rows in set (0.00 sec)
이 예제에서 눈여겨볼 포인트는 다음과 같다.
SHOW INDEX의Cardinality는 인덱스 또는 prefix의 대략적인 distinct 추정치다.mysql.innodb_table_stats.n_rows는 테이블 row 수에 대한 옵티마이저 관점의 추정치다.mysql.innodb_index_stats의n_diff_pfx01,n_diff_pfx02는 복합 인덱스 prefix 단위의 distinct 추정을 보여 준다.- 이 값은 실제 row 개수나
COUNT(DISTINCT ...)와 완전히 같지 않을 수 있다.
8. 예제 3: cardinality가 계획 선택에 미치는 영향을 EXPLAIN으로 확인
통계의 최종 목적은 “숫자 보기”가 아니라 “계획 선택 이해”다. 아래 SQL은 앞서 만든 테이블에서 복합 인덱스의 선행 컬럼 조건과 범위 조건이 어떻게 해석되는지 확인하는 간단한 예제다.
EXPLAIN
SELECT id, tenant_id, status, created_at
FROM stats_demo
WHERE tenant_id = 1
AND status = 'ACTIVE';
EXPLAIN
SELECT id, tenant_id, status, created_at
FROM stats_demo
WHERE created_at >= '2026-06-02 00:00:00'
ORDER BY created_at;
DROP TABLE IF EXISTS stats_demo;
실행 결과(MySQL 8.0.x):
mysql> EXPLAIN
-> SELECT id, tenant_id, status, created_at
-> FROM stats_demo
-> WHERE tenant_id = 1
-> AND status = 'ACTIVE';
+----+-------------+------------+------------+------+-------------------+-------------------+---------+-------------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+------------+------------+------+-------------------+-------------------+---------+-------------+------+----------+-------+
| 1 | SIMPLE | stats_demo | NULL | ref | idx_tenant_status | idx_tenant_status | 70 | const,const | 4 | 100.00 | NULL |
+----+-------------+------------+------------+------+-------------------+-------------------+---------+-------------+------+----------+-------+
1 row in set (0.00 sec)
mysql> EXPLAIN
-> SELECT id, tenant_id, status, created_at
-> FROM stats_demo
-> WHERE created_at >= '2026-06-02 00:00:00'
-> ORDER BY created_at;
+----+-------------+------------+------------+-------+----------------+----------------+---------+------+------+----------+-----------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+------------+------------+-------+----------------+----------------+---------+------+------+----------+-----------------------+
| 1 | SIMPLE | stats_demo | NULL | range | idx_created_at | idx_created_at | 5 | NULL | 7 | 100.00 | Using index condition |
+----+-------------+------------+------------+-------+----------------+----------------+---------+------+------+----------+-----------------------+
1 row in set (0.00 sec)
mysql> DROP TABLE IF EXISTS stats_demo;
Query OK, 0 rows affected (0.00 sec)
이 결과를 읽을 때는 key, rows, filtered, Extra에 주목한다.
key: 어떤 인덱스를 선택했는가rows: 몇 row를 읽을 것으로 추정했는가filtered: 읽은 row 중 조건을 통과할 비율을 어떻게 추정했는가Extra:Using where,Using index,Using filesort등 추가 실행 특성
소규모 예제에서는 모든 차이가 극적으로 보이지 않을 수 있다. 그러나 대형 운영 테이블에서는 이 추정치 오차가 수천 배 I/O 차이로 이어질 수 있다.
9. cardinality를 해석할 때 자주 놓치는 실무 함정
9.1 row count와 distinct count를 혼동하는 실수
테이블 row 수가 많다고 해서 cardinality도 높은 것은 아니다. 예를 들어 10억 row 테이블이라도 status 값이 4개뿐이면 해당 컬럼 cardinality는 낮다. 이 인덱스만으로 강한 필터 효과를 기대하면 안 된다.
9.2 skewed distribution을 평균으로 오해하는 실수
한 tenant가 전체 row의 70%를 차지하는 multi-tenant 시스템에서는 평균적 cardinality만으로 실제 선택도를 설명하기 어렵다. 옵티마이저는 근사치를 보고 계획을 선택하므로, hotspot tenant에서만 느린 현상이 나타날 수 있다.
이 경우 운영자는 다음을 고민해야 한다.
- 히스토그램이 도움이 되는가
- 복합 인덱스 선행 컬럼 순서가 적절한가
- tenant별 대용량 편차를 흡수하도록 SQL 패턴을 바꿔야 하는가
- 특정 보고성 쿼리를 배치/요약 테이블로 우회해야 하는가
9.3 ANALYZE TABLE을 만능 치료제로 보는 실수
통계가 틀렸다고 의심될 때 ANALYZE TABLE은 유효한 대응이지만, 모든 계획 문제를 해결하지는 않는다.
- 인덱스 구조 자체가 잘못되었을 수 있다.
- 조건식이 비SARGable할 수 있다.
- 조인 SQL 구조가 옵티마이저에 불리할 수 있다.
- 통계가 맞더라도 비용 모델상 다른 계획이 더 싸다고 판단될 수 있다.
즉, ANALYZE TABLE은 계획 입력을 개선하는 도구이지, 잘못 설계된 SQL과 인덱스를 대신 고쳐 주는 기능은 아니다.
9.4 너무 작은 테스트 데이터로 운영 현상을 과신하는 실수
개발 환경에서 수백 row만 넣고 cardinality를 보면 대부분 너무 예쁘게 나온다. 그러나 운영에서는 수억 row, 특정 값 집중, 오래된 통계, 파티션 불균형이 함께 작용한다. 따라서 재현 테스트는 가능하되, 최종 판단은 운영 분포와 통계 상태를 기준으로 내려야 한다.
10. Aurora MySQL에서는 무엇이 같고 무엇이 다른가
Aurora MySQL은 스토리지 계층과 장애 복구 특성이 Community MySQL과 다르지만, SQL 최적화와 통계 해석의 기본 원리 자체는 크게 다르지 않다. 옵티마이저는 여전히 cardinality와 index statistics를 바탕으로 실행 계획을 선택한다.
다만 운영 관점에서는 몇 가지 차이를 염두에 두는 편이 좋다.
- 읽기 부하가 reader 인스턴스로 분산되더라도, 잘못된 계획은 각 인스턴스에서 동일하게 비효율을 낳을 수 있다.
- 대량 적재 직후 통계 갱신 전략을 느슨하게 두면, 애플리케이션은 Aurora의 스토리지 확장성과 무관하게 비싼 계획을 반복 실행할 수 있다.
- 장애 조치나 인스턴스 교체 이후 “스토리지는 같으니 계획도 무조건 같을 것”이라고 단정하면 안 된다. 버전, 파라미터 그룹, 통계 갱신 시점, 히스토그램 상태가 다르면 체감 성능이 달라질 수 있다.
- Performance Insights나 CloudWatch 지표에서 CPU 상승만 보고 원인을 추적할 때도, 결국 출발점은 느린 SQL의 실행 계획과 통계 상태 확인이다.
11. 운영 체크리스트
다음 체크리스트는 통계 문제를 의심할 때 실무에서 바로 사용할 수 있는 최소 점검 순서다.
- 문제 SQL의
EXPLAIN또는EXPLAIN ANALYZE에서rows,filtered,key -
SHOW INDEX FROM <table>로 관련 인덱스의Cardinality