- Published on
MySQL 쿼리 최적화 실전 가이드: EXPLAIN 분석부터 인덱스 설계·슬로우 쿼리 튜닝까지
- Authors
- Name
- 들어가며
- EXPLAIN 분석 완전 가이드
- 인덱스 유형과 특성
- 복합 인덱스 설계 전략
- 쿼리 재작성 패턴
- 옵티마이저 힌트
- 슬로우 쿼리 로그 설정과 분석
- InnoDB 버퍼 풀 튜닝
- MySQL 8.0 vs 8.4 옵티마이저 개선 비교
- 실패 사례와 복구 절차
- 프로덕션 최적화 체크리스트
- 운영 주의사항
- 참고자료

들어가며
MySQL은 전 세계에서 가장 널리 사용되는 관계형 데이터베이스 중 하나다. 하지만 데이터가 수천만 건을 넘어가고 동시 접속이 증가하면, 한 줄의 비효율적인 쿼리가 서비스 전체를 마비시킬 수 있다. 이 글에서는 EXPLAIN 명령어를 사용하여 쿼리 실행 계획을 분석하는 방법부터, 인덱스 설계 전략, 쿼리 재작성 패턴, 옵티마이저 힌트 활용, 슬로우 쿼리 로그 분석, 그리고 InnoDB 버퍼 풀 튜닝까지 MySQL 쿼리 최적화에 필요한 모든 내용을 실전 예제와 함께 다룬다.
이 가이드는 이미 MySQL 기본 문법에 익숙한 개발자와 DBA를 대상으로 하며, MySQL 8.0 이상(특히 8.4의 개선사항)을 기준으로 작성되었다.
EXPLAIN 분석 완전 가이드
EXPLAIN 기본 사용법
EXPLAIN은 MySQL 옵티마이저가 쿼리를 어떤 방식으로 실행할지 미리 확인하는 필수 도구다. 기본 사용법은 간단하다.
EXPLAIN SELECT u.name, o.total_amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
AND o.created_at >= '2026-01-01';
출력 결과는 쿼리의 각 테이블 접근 방식에 대한 행(row)으로 나타난다.
type 컬럼 해석
type 컬럼은 테이블 접근 방식을 나타내며, 성능에 가장 직접적인 영향을 미친다. 좋은 순서(빠른 순서)부터 나열하면 아래와 같다.
| type 값 | 설명 | 성능 수준 |
|---|---|---|
| const | 기본 키 또는 유니크 인덱스로 단일 행 조회 | 최고 |
| eq_ref | JOIN에서 기본 키/유니크 인덱스 매칭 | 매우 좋음 |
| ref | 비유니크 인덱스로 동일 값 조회 | 좋음 |
| range | 인덱스 범위 스캔 (BETWEEN, IN 등) | 보통 |
| index | 전체 인덱스 스캔 (인덱스 트리 전체 읽기) | 나쁨 |
| ALL | 전체 테이블 스캔 (풀 스캔) | 최악 |
-- const: 기본 키로 단일 행 조회
EXPLAIN SELECT * FROM users WHERE id = 1;
-- type: const, rows: 1
-- ref: 비유니크 인덱스 조회
EXPLAIN SELECT * FROM orders WHERE user_id = 100;
-- type: ref (user_id에 인덱스가 있는 경우)
-- ALL: 풀 스캔 - 반드시 개선 필요
EXPLAIN SELECT * FROM orders WHERE YEAR(created_at) = 2026;
-- type: ALL (함수 적용으로 인덱스 사용 불가)
Extra 컬럼 해석
Extra 컬럼에는 옵티마이저의 추가적인 실행 전략이 표시된다. 주의해야 할 값들은 다음과 같다.
| Extra 값 | 의미 | 조치 필요 여부 |
|---|---|---|
| Using index | 커버링 인덱스로 해결 | 좋음 (유지) |
| Using where | WHERE 절 필터링 수행 | 보통 |
| Using filesort | 정렬을 위한 추가 작업 필요 | 개선 필요 |
| Using temporary | 임시 테이블 생성 필요 | 개선 필요 |
| Using index condition | 인덱스 컨디션 푸시다운 적용 | 좋음 |
EXPLAIN ANALYZE로 실제 실행 시간 확인
MySQL 8.0.18부터 EXPLAIN ANALYZE를 사용할 수 있다. EXPLAIN과 달리 쿼리를 실제로 실행하고 실행 시간과 실제 행 수를 보여준다.
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id) as order_count
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.created_at >= '2026-01-01'
GROUP BY u.id
HAVING order_count > 5
ORDER BY order_count DESC
LIMIT 10;
출력에는 estimated rows, actual rows, actual time 등이 포함되어 옵티마이저 추정값과 실제값 간의 괴리를 확인할 수 있다. 추정 행수(estimated)와 실제 행수(actual) 차이가 10배 이상이면 통계 정보 업데이트가 필요하다.
-- 통계 정보 수동 갱신
ANALYZE TABLE users;
ANALYZE TABLE orders;
인덱스 유형과 특성
B-Tree 인덱스
MySQL InnoDB의 기본 인덱스 유형이다. 등호 비교, 범위 검색, 정렬, 그리고 최좌선 접두사(Leftmost Prefix) 매칭에 최적화되어 있다.
-- B-Tree 인덱스 생성
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);
-- 이 인덱스가 효과적인 쿼리 예시
SELECT * FROM orders WHERE user_id = 100 AND created_at >= '2026-01-01';
SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC;
SELECT * FROM orders WHERE user_id IN (100, 200, 300);
Hash 인덱스
MEMORY 엔진에서만 명시적으로 사용할 수 있으며, InnoDB에서는 Adaptive Hash Index 형태로 자동 관리된다. 등호(=) 검색에만 사용할 수 있고 범위 검색이나 정렬에는 사용할 수 없다.
-- MEMORY 엔진 테이블에서 Hash 인덱스 사용
CREATE TABLE session_cache (
session_id VARCHAR(64) NOT NULL,
user_id INT NOT NULL,
data JSON,
INDEX USING HASH (session_id)
) ENGINE = MEMORY;
-- InnoDB Adaptive Hash Index 상태 확인
SHOW ENGINE INNODB STATUS\G
-- Adaptive Hash Index 섹션에서 hit rate 확인
Full-text 인덱스
자연어 텍스트 검색을 위한 전문 인덱스다. InnoDB에서 MySQL 5.6부터 지원하며, MATCH ... AGAINST 구문과 함께 사용한다.
-- Full-text 인덱스 생성
ALTER TABLE articles ADD FULLTEXT INDEX ft_idx_content (title, body);
-- 자연어 모드 검색
SELECT * FROM articles
WHERE MATCH(title, body) AGAINST('MySQL 성능 최적화' IN NATURAL LANGUAGE MODE);
-- 불리언 모드 검색 (AND, OR, NOT 등)
SELECT * FROM articles
WHERE MATCH(title, body) AGAINST('+MySQL +성능 -PostgreSQL' IN BOOLEAN MODE);
Spatial 인덱스
공간 데이터(위치 정보 등)를 위한 R-Tree 기반 인덱스다. GEOMETRY, POINT 등의 컬럼에 사용한다.
-- Spatial 인덱스 생성
CREATE TABLE locations (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100),
position POINT NOT NULL SRID 4326,
SPATIAL INDEX (position)
);
-- 반경 내 검색
SELECT name, ST_Distance_Sphere(position, ST_SRID(POINT(127.0276, 37.4979), 4326)) AS distance
FROM locations
WHERE ST_Distance_Sphere(position, ST_SRID(POINT(127.0276, 37.4979), 4326)) < 1000;
복합 인덱스 설계 전략
최좌선 접두사 규칙 (Leftmost Prefix Rule)
복합 인덱스는 정의된 컬럼 순서에서 왼쪽부터 연속된 접두사만 활용할 수 있다. 이것은 MySQL 인덱스 설계에서 가장 중요한 원칙이다.
-- 복합 인덱스: (a, b, c)
CREATE INDEX idx_abc ON orders (status, user_id, created_at);
-- 인덱스 활용 가능한 쿼리
SELECT * FROM orders WHERE status = 'completed'; -- (a) 사용
SELECT * FROM orders WHERE status = 'completed' AND user_id = 100; -- (a, b) 사용
SELECT * FROM orders WHERE status = 'completed' AND user_id = 100
AND created_at >= '2026-01-01'; -- (a, b, c) 모두 사용
-- 인덱스 활용 불가능한 쿼리
SELECT * FROM orders WHERE user_id = 100; -- (b)만 사용 - 인덱스 미활용
SELECT * FROM orders WHERE user_id = 100 AND created_at >= '2026-01-01'; -- (b, c) - 인덱스 미활용
SELECT * FROM orders WHERE created_at >= '2026-01-01'; -- (c)만 사용 - 인덱스 미활용
커버링 인덱스 (Covering Index)
쿼리에서 필요한 모든 컬럼이 인덱스에 포함되면, InnoDB는 테이블 데이터(클러스터드 인덱스)에 접근하지 않고 세컨더리 인덱스만으로 결과를 반환할 수 있다. EXPLAIN의 Extra에 "Using index"가 표시된다.
-- 커버링 인덱스 설계
CREATE INDEX idx_covering ON orders (user_id, status, total_amount);
-- 이 쿼리는 인덱스만으로 결과 반환 가능 (Using index)
EXPLAIN SELECT user_id, status, total_amount
FROM orders
WHERE user_id = 100 AND status = 'completed';
-- 아래 쿼리는 커버링 인덱스 미적용 (SELECT *로 인해 테이블 데이터 접근 필요)
EXPLAIN SELECT * FROM orders
WHERE user_id = 100 AND status = 'completed';
인덱스 설계 실전 패턴
인덱스 컬럼 순서를 결정할 때는 다음 원칙을 따른다.
- 등호(=) 조건 컬럼을 앞에 배치
- 범위 조건 컬럼을 뒤에 배치 (범위 조건 이후 컬럼은 인덱스 활용 불가)
- 카디널리티가 높은 컬럼을 우선 배치 (일반적 권장사항이지만 쿼리 패턴이 더 중요)
- ORDER BY / GROUP BY 컬럼 고려
-- 나쁜 설계: 낮은 카디널리티 컬럼만으로 인덱스
CREATE INDEX idx_bad ON orders (status); -- status는 5~10가지 값뿐
-- 좋은 설계: 쿼리 패턴에 맞춘 복합 인덱스
CREATE INDEX idx_good ON orders (user_id, status, created_at);
-- 정렬까지 고려한 설계
-- WHERE user_id = ? ORDER BY created_at DESC 패턴이 빈번할 때
CREATE INDEX idx_sort ON orders (user_id, created_at DESC);
쿼리 재작성 패턴
서브쿼리를 JOIN으로 변환
MySQL 옵티마이저는 상관 서브쿼리(Correlated Subquery)를 비효율적으로 처리하는 경우가 많다. JOIN으로 재작성하면 성능이 크게 개선된다.
-- 나쁨: 상관 서브쿼리 (각 행마다 서브쿼리 실행)
SELECT u.name, u.email,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u
WHERE u.status = 'active';
-- 좋음: LEFT JOIN + GROUP BY
SELECT u.name, u.email, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
GROUP BY u.id, u.name, u.email;
-- 또는: 파생 테이블(Derived Table) 사용
SELECT u.name, u.email, COALESCE(oc.cnt, 0) AS order_count
FROM users u
LEFT JOIN (
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
) oc ON u.id = oc.user_id
WHERE u.status = 'active';
OR 조건을 UNION으로 변환
OR 조건은 인덱스를 비효율적으로 사용하거나 아예 풀 스캔을 유발할 수 있다.
-- 나쁨: OR 조건으로 인한 인덱스 활용 어려움
SELECT * FROM products
WHERE category_id = 10 OR brand_id = 20;
-- 좋음: UNION ALL로 분리 (각 조건에 대해 인덱스 활용 가능)
SELECT * FROM products WHERE category_id = 10
UNION ALL
SELECT * FROM products WHERE brand_id = 20 AND category_id != 10;
인덱스 컬럼에 함수 사용 회피
인덱스 컬럼에 함수를 적용하면 인덱스를 사용할 수 없다. MySQL 8.0부터는 함수 기반 인덱스(Expression Index)를 생성할 수 있으나, 가능하면 쿼리를 재작성하는 것이 좋다.
-- 나쁨: 인덱스 컬럼에 함수 적용
SELECT * FROM orders WHERE YEAR(created_at) = 2026;
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';
-- 좋음: 범위 조건으로 재작성
SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';
-- MySQL 8.0+: 함수 기반 인덱스 (Expression Index)
CREATE INDEX idx_email_lower ON users ((LOWER(email)));
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';
암묵적 형변환 방지
컬럼 타입과 비교 값의 타입이 다르면 MySQL은 암묵적 형변환을 수행하며, 이때 인덱스가 무시된다.
-- 나쁨: phone_number가 VARCHAR인데 숫자로 비교
SELECT * FROM users WHERE phone_number = 01012345678;
-- MySQL이 phone_number를 숫자로 변환 -> 인덱스 미사용
-- 좋음: 타입 일치
SELECT * FROM users WHERE phone_number = '01012345678';
옵티마이저 힌트
인덱스 힌트
-- USE INDEX: 특정 인덱스 사용 권장 (옵티마이저가 무시할 수 있음)
SELECT * FROM orders USE INDEX (idx_user_created)
WHERE user_id = 100 AND created_at >= '2026-01-01';
-- FORCE INDEX: 특정 인덱스 강제 사용 (풀 스캔 대신 반드시 인덱스 사용)
SELECT * FROM orders FORCE INDEX (idx_user_created)
WHERE user_id = 100 AND created_at >= '2026-01-01';
-- IGNORE INDEX: 특정 인덱스 무시
SELECT * FROM orders IGNORE INDEX (idx_status)
WHERE status = 'completed' AND user_id = 100;
MySQL 8.0+ 옵티마이저 힌트 구문
MySQL 8.0부터 도입된 새로운 힌트 구문은 주석 형태로 작성한다.
-- JOIN 순서 고정
SELECT /*+ JOIN_ORDER(u, o) */
u.name, o.total_amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active';
-- 특정 테이블 인덱스 지정
SELECT /*+ INDEX(o idx_user_created) */
o.*
FROM orders o
WHERE o.user_id = 100;
-- Hash Join 강제
SELECT /*+ HASH_JOIN(u, o) */
u.name, o.total_amount
FROM users u
JOIN orders o ON u.id = o.user_id;
-- 병렬 쿼리 실행 (MySQL 8.0.14+)
SELECT /*+ SET_VAR(innodb_parallel_read_threads=4) */
COUNT(*) FROM large_table;
optimizer_switch 시스템 변수
-- 현재 설정 확인
SHOW VARIABLES LIKE 'optimizer_switch';
-- 세션 레벨에서 특정 최적화 비활성화
SET SESSION optimizer_switch = 'index_merge_intersection=off';
SET SESSION optimizer_switch = 'derived_merge=off';
SET SESSION optimizer_switch = 'batched_key_access=on';
-- MySQL 8.0.31+: 히스토그램 기반 최적화
ANALYZE TABLE orders UPDATE HISTOGRAM ON user_id, status WITH 100 BUCKETS;
슬로우 쿼리 로그 설정과 분석
슬로우 쿼리 로그 설정
-- 슬로우 쿼리 로그 활성화
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 1초 이상 걸리는 쿼리 기록
SET GLOBAL log_queries_not_using_indexes = 'ON'; -- 인덱스 미사용 쿼리도 기록
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow-query.log';
-- my.cnf에 영구 설정
-- [mysqld]
-- slow_query_log = 1
-- long_query_time = 1
-- log_queries_not_using_indexes = 1
-- slow_query_log_file = /var/log/mysql/slow-query.log
-- min_examined_row_limit = 1000
mysqldumpslow로 분석
# 가장 느린 쿼리 Top 10
mysqldumpslow -s t -t 10 /var/log/mysql/slow-query.log
# 가장 빈번한 슬로우 쿼리 Top 10
mysqldumpslow -s c -t 10 /var/log/mysql/slow-query.log
# 특정 패턴 필터링
mysqldumpslow -s t -t 10 -g "orders" /var/log/mysql/slow-query.log
pt-query-digest로 상세 분석
Percona Toolkit의 pt-query-digest는 더 강력한 분석 도구다.
# 슬로우 로그 분석
pt-query-digest /var/log/mysql/slow-query.log
# 특정 시간 범위 분석
pt-query-digest --since="2026-03-10 00:00:00" --until="2026-03-11 00:00:00" \
/var/log/mysql/slow-query.log
# 결과를 파일로 저장
pt-query-digest /var/log/mysql/slow-query.log > /tmp/slow-query-report.txt
출력에는 쿼리별 실행 횟수, 평균/최대 실행 시간, 검사 행 수, 반환 행 수 등이 포함된다. 검사 행 수(Rows examined)와 반환 행 수(Rows sent) 비율이 100:1 이상이면 인덱스 개선이 필요하다.
InnoDB 버퍼 풀 튜닝
버퍼 풀 크기 설정
InnoDB 버퍼 풀은 데이터와 인덱스를 메모리에 캐싱하는 핵심 구성 요소다. 일반적으로 전체 메모리의 70~80%를 할당한다.
-- 현재 버퍼 풀 상태 확인
SHOW VARIABLES LIKE 'innodb_buffer_pool%';
SHOW STATUS LIKE 'Innodb_buffer_pool%';
-- 버퍼 풀 적중률 계산
SELECT
(1 - (
(SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads') /
(SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests')
)) * 100 AS buffer_pool_hit_rate;
-- 99% 이상이 바람직
버퍼 풀 인스턴스 분리
동시성이 높은 환경에서는 버퍼 풀을 여러 인스턴스로 분리하여 mutex 경합을 줄인다.
# my.cnf 설정 예시
[mysqld]
innodb_buffer_pool_size = 32G
innodb_buffer_pool_instances = 8 # 버퍼 풀 크기 / 인스턴스 = 4GB씩
innodb_buffer_pool_chunk_size = 1G # 온라인 리사이징 단위
innodb_log_file_size = 4G
innodb_log_buffer_size = 64M
innodb_io_capacity = 2000
innodb_io_capacity_max = 4000
워밍업 (Buffer Pool Dump/Load)
MySQL 재시작 시 버퍼 풀이 비어서 성능이 저하되는 콜드 스타트 문제를 방지한다.
-- 셧다운 시 버퍼 풀 덤프, 시작 시 로드
SET GLOBAL innodb_buffer_pool_dump_at_shutdown = ON;
SET GLOBAL innodb_buffer_pool_load_at_startup = ON;
-- 수동 덤프/로드
SET GLOBAL innodb_buffer_pool_dump_now = ON;
SET GLOBAL innodb_buffer_pool_load_now = ON;
-- 로드 진행 상태 확인
SHOW STATUS LIKE 'Innodb_buffer_pool_load_status';
MySQL 8.0 vs 8.4 옵티마이저 개선 비교
| 기능 | MySQL 8.0 | MySQL 8.4 |
|---|---|---|
| EXPLAIN FORMAT | TRADITIONAL, JSON, TREE | TRADITIONAL, JSON, TREE (개선) |
| Hash Join | 8.0.18부터 지원 | 성능 개선 및 메모리 관리 향상 |
| Window Functions | 기본 지원 | 실행 계획 최적화 개선 |
| Derived Table Merge | 일부 지원 | 더 넓은 범위 지원 |
| Invisible Index | 지원 | 지원 (관리 기능 개선) |
| Functional Index | 지원 | 지원 (Expression Index 개선) |
| Histogram Statistics | 지원 | 자동 갱신 기능 강화 |
| Parallel Query | 제한적 | InnoDB 병렬 읽기 확대 |
| Cost Model | 기본 비용 모델 | 비용 상수 재조정, SSD 반영 |
| Subquery Optimization | 기본 세미조인 | 안티조인 및 세미조인 전략 확대 |
| EXPLAIN ANALYZE | 8.0.18 도입 | 출력 형식 개선, 메모리 사용량 표시 |
-- MySQL 8.4: EXPLAIN 개선 예시
EXPLAIN FORMAT=TREE
SELECT u.name, COUNT(o.id)
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.id;
-- Invisible Index 활용 (인덱스 삭제 전 테스트)
ALTER TABLE orders ALTER INDEX idx_status INVISIBLE;
-- 성능 테스트 후 문제가 없으면 삭제
-- DROP INDEX idx_status ON orders;
-- 문제가 발생하면 다시 활성화
ALTER TABLE orders ALTER INDEX idx_status VISIBLE;
실패 사례와 복구 절차
사례 1: 암묵적 형변환으로 인덱스 미사용
-- 문제 상황: VARCHAR 컬럼에 숫자 비교
-- account_no 컬럼이 VARCHAR(20)인데 애플리케이션에서 숫자로 전달
EXPLAIN SELECT * FROM accounts WHERE account_no = 123456;
-- type: ALL (전체 테이블 스캔!)
-- 원인: MySQL이 account_no 컬럼을 숫자로 변환하므로 인덱스 사용 불가
-- 해결: 타입 일치시키기
EXPLAIN SELECT * FROM accounts WHERE account_no = '123456';
-- type: ref (인덱스 사용)
사례 2: 카디널리티 부족으로 인덱스 건너뜀
-- 문제 상황: 인덱스가 있지만 옵티마이저가 풀 스캔을 선택
-- status 컬럼에 인덱스가 있으나 'active'가 전체의 90%를 차지
EXPLAIN SELECT * FROM users WHERE status = 'active';
-- type: ALL (옵티마이저가 풀 스캔이 더 효율적이라고 판단)
-- 원인: 카디널리티가 낮아 인덱스 효율이 떨어짐
-- 해결 1: 다른 조건과 복합 인덱스 구성
CREATE INDEX idx_status_created ON users (status, created_at);
-- 해결 2: 특정 값에 대해서만 인덱스가 유용한 경우
-- MySQL은 Partial Index를 직접 지원하지 않으므로, 쿼리 패턴 변경 고려
사례 3: 잘못된 복합 인덱스 순서
-- 문제 상황: 인덱스 (created_at, user_id)로 생성
-- 빈번한 쿼리: WHERE user_id = ? AND created_at >= ?
EXPLAIN SELECT * FROM orders
WHERE user_id = 100 AND created_at >= '2026-01-01';
-- 인덱스를 비효율적으로 사용 (range scan on created_at first)
-- 해결: 등호 조건 컬럼을 앞에 배치
DROP INDEX idx_created_user ON orders;
CREATE INDEX idx_user_created ON orders (user_id, created_at);
-- type: range (효율적인 범위 스캔)
사례 4: 대량 DELETE 후 인덱스 단편화
-- 문제 상황: 500만 건 DELETE 후 쿼리 성능 저하
-- 원인: 인덱스 페이지에 빈 공간이 많아짐 (단편화)
-- 진단
SELECT
TABLE_NAME,
INDEX_LENGTH,
DATA_LENGTH,
DATA_FREE
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_NAME = 'orders';
-- 해결: 인덱스 재구축
ALTER TABLE orders ENGINE=InnoDB; -- 테이블 + 인덱스 재구축
-- 또는
OPTIMIZE TABLE orders;
프로덕션 최적화 체크리스트
인덱스 점검
- 모든 외래 키(Foreign Key) 컬럼에 인덱스가 있는지 확인
- 주요 쿼리에 대해 EXPLAIN 결과를 확인하고 type이 ALL인 것이 없는지 검증
- 사용되지 않는 인덱스 식별 및 제거
- 중복 인덱스(Duplicate Index) 제거
- 복합 인덱스의 컬럼 순서가 쿼리 패턴에 맞는지 확인
-- 사용되지 않는 인덱스 찾기
SELECT
s.TABLE_SCHEMA,
s.TABLE_NAME,
s.INDEX_NAME,
s.COLUMN_NAME
FROM INFORMATION_SCHEMA.STATISTICS s
LEFT JOIN performance_schema.table_io_waits_summary_by_index_usage p
ON s.TABLE_SCHEMA = p.OBJECT_SCHEMA
AND s.TABLE_NAME = p.OBJECT_NAME
AND s.INDEX_NAME = p.INDEX_NAME
WHERE p.COUNT_STAR = 0
AND s.TABLE_SCHEMA NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys')
AND s.INDEX_NAME != 'PRIMARY'
ORDER BY s.TABLE_SCHEMA, s.TABLE_NAME;
-- 중복 인덱스 찾기 (sys 스키마 활용)
SELECT * FROM sys.schema_redundant_indexes;
쿼리 점검
- 슬로우 쿼리 로그 활성화 및 주기적 분석
- 인덱스 컬럼에 함수를 적용하는 쿼리가 없는지 확인
- 암묵적 형변환이 발생하는 쿼리가 없는지 확인
- SELECT * 대신 필요한 컬럼만 선택
- N+1 쿼리 패턴 제거
서버 설정 점검
- innodb_buffer_pool_size가 전체 메모리의 70~80% 수준인지 확인
- innodb_buffer_pool_instances가 적절한지 확인 (최소 8, 버퍼 풀 1GB당 1인스턴스)
- innodb_log_file_size가 충분한지 확인 (1~4GB 권장)
- innodb_flush_log_at_trx_commit 값 확인 (1: 안전, 2: 타협)
- innodb_buffer_pool_dump_at_shutdown/load_at_startup 활성화
모니터링
- 버퍼 풀 적중률 99% 이상 유지
- 슬로우 쿼리 발생 빈도 모니터링
- 인덱스 사용률 주기적 점검
- 테이블 통계 정보 자동 갱신 확인
- 잠금 대기(Lock Wait) 및 데드락(Deadlock) 모니터링
운영 주의사항
대용량 테이블 인덱스 변경 시 주의점
MySQL 8.0에서는 대부분의 ALTER TABLE ... ADD INDEX가 온라인(Instant 또는 In-place)으로 처리되지만, 프로덕션 환경에서는 반드시 사전 테스트를 수행해야 한다.
-- 인덱스 추가 시 잠금 모드 확인
ALTER TABLE orders ADD INDEX idx_new (col1, col2), ALGORITHM=INPLACE, LOCK=NONE;
-- pt-online-schema-change 사용 (대용량 테이블에 권장)
-- 원본 테이블을 트리거로 동기화하며 스키마 변경
pt-online-schema-change \
--alter "ADD INDEX idx_new (col1, col2)" \
--execute \
D=mydb,t=orders
쿼리 캐시 관련 참고
MySQL 8.0부터 쿼리 캐시(Query Cache)가 완전히 제거되었다. 애플리케이션 레벨 캐싱(Redis, Memcached 등)으로 대체해야 한다.
히스토그램 통계 활용
MySQL 8.0에서 도입된 히스토그램은 컬럼의 값 분포를 옵티마이저에게 제공하여 더 나은 실행 계획을 선택하도록 돕는다.
-- 히스토그램 생성
ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 100 BUCKETS;
ANALYZE TABLE orders UPDATE HISTOGRAM ON total_amount WITH 254 BUCKETS;
-- 히스토그램 확인
SELECT
SCHEMA_NAME,
TABLE_NAME,
COLUMN_NAME,
JSON_EXTRACT(HISTOGRAM, '$.histogram-type') AS histogram_type,
JSON_EXTRACT(HISTOGRAM, '$.number-of-buckets-specified') AS buckets
FROM INFORMATION_SCHEMA.COLUMN_STATISTICS;
-- 히스토그램 삭제
ANALYZE TABLE orders DROP HISTOGRAM ON status;