MySQL 느린 쿼리 후보 찾기: Performance Schema 실행 횟수·총 시간 분석

쿼리 지연을 조사할 때는 한 번 실행하는 데 가장 오래 걸린 SQL부터 찾기 쉽습니다. 하지만 서버 전체에서 시간을 가장 많이 쓰는 SQL은 한 번에 느린 SQL이 아니라 자주 호출되는 SQL인 경우가 많습니다. 실행 시간 10ms짜리 SQL도 1초에 100번 호출되면 1초 내내 서버를 쓰게 됩니다.
이 글에서는 MySQL 8.4.11의 별도 테스트 스키마에서 성격이 다른 세 가지 조회를 정해진 횟수만큼 반복하고, Performance Schema의 statement digest 집계로 개선 후보를 골랐습니다. 실제 서비스 장애가 아니라 10만 행으로 재현한 실험이며, 인덱스 추가 전후를 같은 횟수와 순서로 다시 측정했습니다.
결과를 먼저 요약하면, 평균 시간이 가장 긴 SQL은 전체 합계(14.265ms)였지만 총 시간의 74.8%는 평균이 더 짧은 고객별 합계에서 나왔습니다. 고객 번호 인덱스를 추가한 뒤 고객별 합계의 총 시간은 102.117ms에서 5.318ms로 줄었습니다. 아래에서는 이 결론에 이르는 판단 과정과, 운영 서버에서 같은 방식으로 후보를 찾는 쿼리를 함께 정리합니다.
테스트 환경과 측정 설계
| 항목 | 실험 조건 |
|---|---|
| MySQL 버전 | 8.4.11 (Windows, MySQL Workbench) |
| 스키마·테이블 | lab_stmt43.ps_orders (InnoDB) |
| 전체 데이터 | 100,000행 |
| 고객별 데이터 | 고객 1,000명, 고객당 100행으로 균등 분포 |
| 버퍼 풀 | 128MiB |
| 초기 인덱스 | PRIMARY KEY (id)만 존재 |
| Performance Schema·autocommit | 모두 활성화 |
| 측정 방식 | 같은 연결에서 순차 실행, 변경 전후 각각 워밍업 후 측정 |
테이블과 데이터는 아래 SQL로 만들었습니다. id는 순번에 1을 더한 값, customer_id는 순번을 1,000으로 나눈 나머지에 1을 더한 값입니다. 따라서 고객 번호마다 정확히 100행씩 들어갑니다.
CREATE DATABASE lab_stmt43;
USE lab_stmt43;
CREATE TABLE ps_orders (
id BIGINT UNSIGNED NOT NULL,
customer_id INT UNSIGNED NOT NULL,
amount DECIMAL(12,2) NOT NULL,
padding VARCHAR(100) NOT NULL,
PRIMARY KEY (id)
) ENGINE = InnoDB;
INSERT INTO ps_orders (id, customer_id, amount, padding)
WITH
digits AS (
SELECT 0 AS d UNION ALL SELECT 1 UNION ALL SELECT 2
UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8
UNION ALL SELECT 9
),
seq AS (
SELECT
a.d + 10*b.d + 100*c.d + 1000*d.d + 10000*e.d AS n
FROM digits AS a
CROSS JOIN digits AS b
CROSS JOIN digits AS c
CROSS JOIN digits AS d
CROSS JOIN digits AS e
)
SELECT
n + 1,
MOD(n, 1000) + 1,
100 + MOD(n, 10000) / 100,
REPEAT('x', 100)
FROM seq;
COMMIT;
측정 전에 서버 설정과 데이터 상태를 한 번에 확인했습니다. Performance Schema가 꺼져 있으면 이후 집계 자체가 쌓이지 않으므로 이 확인을 먼저 합니다.
USE lab_stmt43;
SELECT
VERSION() AS mysql_version,
@@performance_schema AS ps_enabled,
ROUND(
@@innodb_buffer_pool_size / 1024 / 1024, 0
) AS buffer_pool_mb,
@@autocommit AS autocommit_mode,
(
SELECT COUNT(*)
FROM ps_orders
) AS test_rows,
(
SELECT COUNT(*)
FROM ps_orders
WHERE customer_id = 42
) AS customer_rows,
(
SELECT GROUP_CONCAT(
CONCAT(INDEX_NAME, ':', COLUMN_NAME)
ORDER BY INDEX_NAME, SEQ_IN_INDEX
SEPARATOR ', '
)
FROM information_schema.statistics
WHERE TABLE_SCHEMA = 'lab_stmt43'
AND TABLE_NAME = 'ps_orders'
) AS index_columns;

전체 100,000행, 고객 42번 100행, 인덱스는 PRIMARY:id뿐입니다. 고객 번호로 행을 찾아갈 수 있는 경로가 없는 상태에서 측정을 시작합니다.
비교할 세 가지 조회와 실행 횟수
세 조회는 “가볍지만 자주”, “중간 무게로 여러 번”, “무겁지만 드물게”라는 서로 다른 성격을 갖도록 골랐습니다. 평균 시간 순위와 총 시간 순위가 엇갈리는 상황을 만들기 위해서입니다.
USE lab_stmt43;
-- PK 단건 조회
SELECT amount AS pk_amount
FROM ps_orders
WHERE id = 42001;
-- 고객별 합계 조회
SELECT SUM(amount) AS customer_total
FROM ps_orders
WHERE customer_id = 42;
-- 전체 합계 조회
SELECT SUM(amount) AS all_total
FROM ps_orders;
| 조회 유형 | 측정 구간당 실행 | 조건 값 |
|---|---|---|
| PK 단건 조회 | 20회 | id 42001~42020 |
| 고객별 합계 | 10회 | customer_id 42~51 |
| 전체 합계 | 2회 | 조건 없음 |
조건 값을 바꿔 가며 실행했는데도 결과가 유형별로 한 줄씩 집계되는 이유는 digest 정규화 때문입니다. Performance Schema는 SQL의 상수를 ?로 바꾸고 공백과 대소문자를 정리한 뒤 해시값(DIGEST)을 만듭니다. 고객별 합계는 고객 번호와 관계없이 SELECT SUM ( `amount` ) AS `customer_total` FROM `ps_orders` WHERE `customer_id` = ?와 같은 형태 하나로 묶입니다. events_statements_summary_by_digest는 이 DIGEST와 SCHEMA_NAME을 기준으로 실행 횟수와 시간을 누적합니다.
주의할 점은 DIGEST가 SQL 문장의 형태를 나타낼 뿐 실행계획을 구분하지 않는다는 것입니다. 인덱스를 추가해 접근 방식이 바뀌어도 같은 digest에 계속 누적되므로, 변경 전후는 반드시 구간을 나누어 측정해야 합니다.
누적 집계에서 구간 값을 뽑는 방법
이번 분석에 사용한 집계 컬럼은 다섯 개입니다.
| 컬럼 | 의미 | 판단에 쓰는 방법 |
|---|---|---|
COUNT_STAR | 실행 횟수 | 호출 빈도 확인 |
SUM_TIMER_WAIT | 실행 시간 누적 합계(피코초) | 서버가 이 SQL에 쓴 총 시간 |
AVG_TIMER_WAIT | 누적 평균 실행 시간(피코초) | 요청 1건의 지연 확인 |
SUM_ROWS_EXAMINED | 검사한 행 수 누적 합계 | 실행당 작업량 계산 |
SUM_NO_INDEX_USED | 인덱스 없이 스캔한 실행 횟수 | 전체 스캔 여부의 단서 |
시간 값은 피코초 단위이므로 1,000,000,000으로 나누면 밀리초가 됩니다. SUM_TIMER_WAIT는 서버에서 문장을 실행하는 데 걸린 시간이며 CPU 시간만 뜻하지 않고, 네트워크 전송과 클라이언트 화면 표시까지 포함한 체감 시간과도 다릅니다.
구간 측정에는 집계 테이블을 TRUNCATE로 초기화하는 방법도 있지만, 이번에는 초기화하지 않고 시작과 종료 시점의 누적값을 저장해 차이를 계산했습니다. 운영 서버에서 집계를 초기화하면 모니터링 도구나 다른 담당자가 보던 누적값까지 사라지기 때문에, 스냅샷 차이 방식이 실무에 그대로 옮기기 쉽습니다.
구간 실행 횟수 = 종료 COUNT_STAR - 시작 COUNT_STAR
구간 총 시간 = 종료 SUM_TIMER_WAIT - 시작 SUM_TIMER_WAIT
구간 평균 시간 = 구간 총 시간 / 구간 실행 횟수
실행당 검사 행 수 = SUM_ROWS_EXAMINED 증가분 / 구간 실행 횟수
평균은 누적 AVG_TIMER_WAIT끼리 빼서 구하면 안 됩니다. 누적 평균은 측정 이전의 실행까지 섞인 값이므로, 구간 총 시간을 구간 실행 횟수로 나누어 다시 계산합니다.
스냅샷 테이블 lab_snapshot과 차이 계산 뷰 lab_delta는 실험용으로 만든 객체이며 MySQL이 기본 제공하는 것이 아닙니다. 아래는 같은 컬럼 구성을 재현하는 예시입니다.
CREATE TABLE lab_stmt43.lab_snapshot (
phase VARCHAR(10) NOT NULL, -- 'before' / 'after'
snap VARCHAR(5) NOT NULL, -- 'start' / 'end'
digest VARCHAR(64) NOT NULL,
digest_text LONGTEXT,
count_star BIGINT UNSIGNED NOT NULL,
sum_timer_wait BIGINT UNSIGNED NOT NULL,
sum_rows_examined BIGINT UNSIGNED NOT NULL,
sum_no_index_used BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (phase, snap, digest)
) ENGINE = InnoDB;
-- 스냅샷 저장 (phase와 snap 값만 바꿔 네 번 실행)
INSERT INTO lab_stmt43.lab_snapshot
SELECT
'before', 'start',
DIGEST, DIGEST_TEXT,
COUNT_STAR, SUM_TIMER_WAIT,
SUM_ROWS_EXAMINED, SUM_NO_INDEX_USED
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME = 'lab_stmt43'
AND DIGEST IS NOT NULL;
-- 구간 차이 계산 뷰 (ps_orders 대상 SQL만 남김)
CREATE VIEW lab_stmt43.lab_delta AS
SELECT
e.phase,
e.digest,
e.digest_text,
e.count_star - COALESCE(s.count_star, 0) AS exec_count,
e.sum_timer_wait - COALESCE(s.sum_timer_wait, 0) AS total_ps,
e.sum_rows_examined - COALESCE(s.sum_rows_examined, 0) AS examined,
e.sum_no_index_used - COALESCE(s.sum_no_index_used, 0) AS no_index_calls
FROM lab_stmt43.lab_snapshot AS e
LEFT JOIN lab_stmt43.lab_snapshot AS s
ON s.phase = e.phase
AND s.snap = 'start'
AND s.digest = e.digest
WHERE e.snap = 'end'
AND e.digest_text LIKE '%`ps_orders`%';
측정 순서는 다음과 같이 고정했습니다. 순서가 바뀌면 워밍업이나 실행계획 확인용 실행이 구간 집계에 섞입니다.
| 순서 | 작업 | 이유 |
|---|---|---|
| 1 | 워밍업 실행 | 첫 실행의 디스크 읽기를 측정에서 제외 |
| 2 | 시작 스냅샷 저장 | 구간의 기준값 확보 |
| 3 | 32개 SELECT 실행 | PK 20회, 고객별 합계 10회, 전체 합계 2회 |
| 4 | 종료 스냅샷 저장 | 구간 차이 계산 |
| 5 | EXPLAIN ANALYZE 확인 | 실제 실행이 구간 집계에 섞이지 않도록 마지막에 수행 |
변경 전 집계: 평균 1위와 총 시간 1위가 다릅니다
변경 전 구간의 결과를 총 시간 순으로 조회했습니다. 같은 digest가 변경 후에도 유지되는지 확인하기 위해 digest 앞 12자리도 함께 출력했습니다.
SELECT
CASE
WHEN digest_text LIKE '%`customer_total`%'
THEN 'customer_sum'
WHEN digest_text LIKE '%`pk_amount`%'
THEN 'pk_lookup'
ELSE 'all_sum'
END AS query_type,
exec_count,
ROUND(
total_ps / 1000000000, 3
) AS total_ms,
ROUND(
total_ps / NULLIF(exec_count, 0) / 1000000000, 3
) AS avg_ms,
ROUND(
examined / NULLIF(exec_count, 0), 1
) AS examined_per_exec,
no_index_calls,
LEFT(digest, 12) AS digest_id
FROM lab_stmt43.lab_delta
WHERE phase = 'before'
AND exec_count > 0
ORDER BY total_ps DESC;

| 조회 유형 | 실행 횟수 | 총 시간(ms) | 평균 시간(ms) | 실행당 검사 행 수 | 인덱스 미사용 횟수 |
|---|---|---|---|---|---|
| 고객별 합계 | 10 | 102.117 | 10.212 | 100,000 | 10 |
| 전체 합계 | 2 | 28.530 | 14.265 | 100,000 | 2 |
| PK 단건 조회 | 20 | 5.959 | 0.298 | 1 | 0 |
세 가지 순위가 서로 다릅니다. 실행 횟수 1위는 PK 조회(20회), 평균 시간 1위는 전체 합계(14.265ms), 총 시간 1위는 고객별 합계(102.117ms)입니다. 세 유형의 총 시간 합계 136.606ms 중 102.117ms, 약 74.8%가 고객별 합계에서 발생했습니다. 이 비율은 이번 실험 대상 세 유형 안에서의 비중이며 서버 전체 부하나 CPU 점유율을 뜻하지는 않습니다.
평균 시간만 보고 전체 합계를 먼저 튜닝했다면 전체의 약 21%를 차지하는 SQL을 고친 셈이 됩니다. 실행 횟수만 봤다면 이미 1행만 읽는 PK 조회에 시간을 썼을 것입니다. 총 시간은 “실행 횟수 × 평균 시간”이므로 두 요소를 함께 반영한 지표이고, 반복 비용을 줄일 후보를 고를 때 가장 먼저 볼 값입니다.
고객별 합계와 전체 합계는 둘 다 실행당 100,000행을 검사했는데도 평균 시간이 다릅니다. 읽는 행 수는 같지만 합산해야 하는 행이 100행과 100,000행으로 다르기 때문으로 볼 수 있습니다. 반대로 고객별 합계는 결과 1행, 실제로 필요한 데이터 100행을 얻기 위해 100,000행을 읽고 있으므로, 검사 행 수를 줄일 여지가 가장 큰 SQL이기도 합니다. 그래서 고객별 합계를 우선 개선 후보로 정하고 실행계획으로 원인을 확인했습니다.
실행계획으로 원인 확인
digest 집계는 “어떤 SQL이 시간을 많이 썼는지”까지만 알려줍니다. “왜 많이 썼는지”는 실행계획으로 확인해야 합니다.
EXPLAIN ANALYZE
SELECT SUM(amount) AS customer_total
FROM ps_orders
WHERE customer_id = 42;

캡처의 실행계획을 노드별로 나누면 다음과 같습니다.
-> Aggregate: sum(ps_orders.amount)
(cost=11446 rows=1)
(actual time=16..16 rows=1 loops=1)
-> Filter: (ps_orders.customer_id = 42)
(cost=10436 rows=10107)
(actual time=0.912..16 rows=100 loops=1)
-> Table scan on ps_orders
(cost=10436 rows=101070)
(actual time=0.899..12.7 rows=100000 loops=1)
아래에서 위로 읽으면 Table scan이 100,000행을 모두 읽고, Filter가 그중 100행만 남기고, Aggregate가 1행으로 합산합니다. 집계에서 본 “실행당 검사 행 수 100,000″과 실행계획의 전체 스캔이 같은 문제를 가리킵니다. 결과는 1행이지만 그 1행을 만들기 위해 테이블 전체를 읽고 있었습니다.
Filter의 예상 행 수 10,107행과 실제 100행의 차이도 눈에 띕니다. 이 숫자는 통계가 오래되어 생긴 것이 아닙니다. customer_id에는 인덱스도 히스토그램도 없어 옵티마이저가 값의 분포를 알 수 없고, 이때 MySQL은 등호 조건에 고정된 기본 비율 10%를 적용합니다. 테이블 예상 행 수 101,070 × 0.1 = 10,107로 정확히 일치합니다. 테이블 예상 행 수가 100,000이 아니라 101,070인 것은 InnoDB가 일부 페이지를 샘플링해 행 수를 추정하기 때문입니다.
실행계획의 약 16ms가 집계의 평균 10.212ms와 다른 것은 이상 현상이 아닙니다. EXPLAIN ANALYZE는 별도 시점에 SQL을 실제로 실행하면서 노드마다 시간을 재는 계측 비용이 더해집니다. 또한 부모 노드의 시간에는 자식 노드의 시간이 이미 포함되어 있으므로 노드별 시간을 더하지 않습니다.
인덱스 추가 후 같은 조건으로 다시 측정
고객 번호 인덱스를 추가하고 통계를 갱신했습니다.
ALTER TABLE lab_stmt43.ps_orders
ADD INDEX idx_customer_id (customer_id);
ANALYZE TABLE lab_stmt43.ps_orders;
이 인덱스에는 amount가 없으므로 커버링 인덱스가 아닙니다. 인덱스로 고객 42번의 100건을 찾은 뒤, 건마다 클러스터드 인덱스(기본 키)를 다시 찾아가 amount를 읽습니다. 이번 변경의 목표는 이 추가 조회 비용을 감수하더라도 100,000행 스캔을 100건 접근으로 줄이는 것입니다.
워밍업 후 phase = 'after'로 새 시작 스냅샷을 저장하고, 변경 전과 같은 값과 순서로 32개 SELECT를 실행한 뒤 종료 스냅샷을 저장했습니다. 전후 결과는 digest를 기준으로 연결해 비교했습니다.
SELECT
CASE
WHEN b.digest_text LIKE '%`customer_total`%'
THEN 'customer_sum'
WHEN b.digest_text LIKE '%`pk_amount`%'
THEN 'pk_lookup'
ELSE 'all_sum'
END AS query_type,
b.exec_count AS before_n,
a.exec_count AS after_n,
ROUND(b.total_ps / 1000000000, 3) AS before_total_ms,
ROUND(a.total_ps / 1000000000, 3) AS after_total_ms,
ROUND(
b.total_ps / NULLIF(b.exec_count, 0)
/ 1000000000, 3
) AS before_avg_ms,
ROUND(
a.total_ps / NULLIF(a.exec_count, 0)
/ 1000000000, 3
) AS after_avg_ms,
ROUND(b.examined / NULLIF(b.exec_count, 0), 1) AS before_rows,
ROUND(a.examined / NULLIF(a.exec_count, 0), 1) AS after_rows
FROM lab_stmt43.lab_delta AS b
JOIN lab_stmt43.lab_delta AS a
ON a.digest = b.digest
AND a.phase = 'after'
WHERE b.phase = 'before'
AND b.exec_count > 0
AND a.exec_count > 0
ORDER BY b.total_ps DESC;

| 조회 유형 | 실행 횟수 전→후 | 총 시간 전→후(ms) | 평균 시간 전→후(ms) | 실행당 검사 행 수 전→후 |
|---|---|---|---|---|
| 고객별 합계 | 10→10 | 102.117→5.318 | 10.212→0.532 | 100,000→100 |
| 전체 합계 | 2→2 | 28.530→24.760 | 14.265→12.380 | 100,000→100,000 |
| PK 단건 조회 | 20→20 | 5.959→4.853 | 0.298→0.243 | 1→1 |
전후 조인으로 세 유형이 모두 연결되었다는 것 자체가 하나의 확인 결과입니다. 인덱스를 추가해 고객별 합계의 접근 방식이 바뀌었는데도 digest는 그대로였습니다. 앞에서 설명한 대로 digest는 문장 형태의 식별자이며, 구간을 나누지 않았다면 변경 전후의 실행이 한 줄에 섞였을 것입니다.
고객별 합계의 총 시간은 약 94.8% 줄었고, 변경 전이 변경 후의 약 19.2배였습니다. 실행당 검사 행 수는 1,000분의 1이 되었습니다. 실행 횟수는 10회로 같으므로, 총 시간 감소는 호출이 줄어서가 아니라 1회 실행 비용이 줄어서 생긴 것입니다. 이 배율은 이번 조건에서 측정한 결과이며 운영 환경의 개선 폭을 보장하지는 않습니다.
전체 합계(약 13%)와 PK 조회(약 19%)도 시간이 줄었지만 검사 행 수는 그대로입니다. 두 조회는 새 인덱스를 사용하지 않으므로, 이 차이는 버퍼 풀 상태나 실행 시점의 부하 같은 측정 조건의 차이로 보는 것이 맞습니다. 이번 실험이 단회 측정이라는 점을 고려하면, 검사 행 수처럼 실행 조건에 흔들리지 않는 지표가 함께 바뀌었는지가 개선을 판단하는 더 확실한 근거입니다.
변경 후 같은 고객별 합계 SQL의 실행계획도 확인했습니다.
-> Aggregate: sum(ps_orders.amount)
(cost=45 rows=1)
(actual time=0.238..0.238 rows=1 loops=1)
-> Index lookup on ps_orders using idx_customer_id (customer_id=42)
(cost=35 rows=100)
(actual time=0.218..0.226 rows=100 loops=1)

Table scan과 Filter 두 단계가 Index lookup 한 단계로 바뀌었고, 최상위 cost는 11,446에서 45로 낮아졌습니다. 예상 행 수도 10,107행에서 100행으로 바뀌어 실제 100행과 일치합니다. 이제 옵티마이저가 인덱스 통계로 고객 번호당 행 수를 알 수 있어 10% 기본값을 쓰지 않기 때문입니다.
총 시간 감소, 검사 행 수 감소, 접근 방식 변경이라는 세 가지 근거가 함께 확인되었으므로 고객별 합계의 개선 원인을 인덱스로 설명할 수 있습니다. 여기서 더 줄이려면 (customer_id, amount) 복합 인덱스로 기본 키 재조회까지 없애는 방법을 검토할 수 있습니다. 다만 이번 실험에서는 측정하지 않았고, 인덱스가 커지는 만큼 쓰기 비용도 늘어나므로 별도로 검증해야 합니다.
운영 서버에서 후보를 찾는 쿼리와 판단 기준
운영 서버에서는 실험처럼 SQL을 직접 정해 실행할 수 없으므로, 이미 쌓여 있는 digest 집계에서 총 시간 상위 SQL을 먼저 뽑습니다. 실행당 검사 행 수와 함께 실행당 반환 행 수를 같이 보면 “적게 돌려주는데 많이 읽는” SQL이 바로 드러납니다.
SELECT
SCHEMA_NAME,
LEFT(DIGEST_TEXT, 80) AS query_sample,
COUNT_STAR AS exec_count,
ROUND(SUM_TIMER_WAIT / 1000000000, 1) AS total_ms,
ROUND(AVG_TIMER_WAIT / 1000000000, 3) AS avg_ms,
ROUND(SUM_ROWS_EXAMINED / NULLIF(COUNT_STAR, 0)) AS examined_per_exec,
ROUND(SUM_ROWS_SENT / NULLIF(COUNT_STAR, 0)) AS sent_per_exec,
SUM_NO_INDEX_USED AS no_index_calls,
FIRST_SEEN,
LAST_SEEN
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME IS NOT NULL
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
sys 스키마의 sys.statement_analysis 뷰도 같은 집계를 총 지연 시간 순으로 보여주므로 빠르게 훑어볼 때 유용합니다. 다만 위 결과는 서버 기동(또는 마지막 초기화) 이후의 누적값이므로, FIRST_SEEN과 LAST_SEEN으로 집계 기간을 확인하고 필요하면 이번 실험처럼 일정 간격의 스냅샷 차이로 비교합니다.
상위 목록을 얻은 뒤에는 지표 조합에 따라 다음 확인 항목이 달라집니다.
| 지표 조합 | 의미 | 다음 확인 |
|---|---|---|
| 총 시간 큼, 평균 짧음, 실행 횟수 많음 | 반복 비용이 쌓이는 SQL | 1회 비용 축소, 호출 횟수 자체를 줄일 수 있는지 |
| 평균 김, 실행 횟수 적음 | 요청 1건의 지연이 큰 SQL | 해당 요청이 사용자 응답이나 핵심 배치를 막는지 |
| 검사 행 수가 반환 행 수보다 훨씬 많음 | 필요 이상으로 읽는 SQL | 실행계획의 접근 방식, 조건 컬럼의 인덱스 |
| 인덱스 미사용이 기록되지만 전체 데이터가 필요함 | 전체 스캔이 목적에 맞는 SQL | 요약 테이블이나 실행 주기 조정 같은 다른 방법 |
이번 실험의 고객별 합계는 첫째와 셋째 줄에 함께 해당했고, 전체 합계는 넷째 줄에 해당합니다. 전체 합계도 인덱스 미사용 횟수가 기록되었지만 모든 주문의 amount가 필요하므로, 인덱스를 추가해도 읽어야 하는 행 수는 줄지 않습니다. 인덱스 미사용 횟수만으로 문제를 확정하지 않는 이유입니다. 또한 총 시간 순위는 반복 비용의 순위일 뿐이므로, 호출이 적어도 핵심 업무를 막는 SQL은 순위와 별개로 먼저 처리합니다.
집계 결과를 믿기 전에 확인할 점
digest 집계는 연결별 기록이 아닙니다. 다른 세션이 같은 스키마에서 같은 형태의 SQL을 실행하면 함께 합산되므로, 실험은 전용 스키마에서 진행하고 운영 분석은 스냅샷 시점의 호출량과 함께 해석합니다. 측정 구간 중간에 서버가 재시작되거나 누군가 집계를 초기화했다면 종료값이 시작값보다 작아지므로 그 구간의 차이는 버려야 합니다.
digest 집계 테이블의 행 수에는 상한이 있습니다. 상한을 넘으면 새로운 형태의 SQL은 DIGEST가 NULL인 한 행에 모두 합쳐지므로, 상위 목록에 보이지 않는 SQL이 실제로는 큰 시간을 쓰고 있을 수 있습니다. 다음 쿼리로 이 행이 커지고 있는지 확인합니다.
SELECT
COUNT_STAR,
ROUND(SUM_TIMER_WAIT / 1000000000, 1) AS total_ms
FROM performance_schema.events_statements_summary_by_digest
WHERE DIGEST IS NULL;
SHOW VARIABLES LIKE 'performance_schema_digests_size';
마지막으로, 같은 digest 안에서도 조건 값에 따라 처리량이 크게 다를 수 있습니다. 이번 데이터는 고객당 100행으로 균등했지만, 운영에서는 주문이 수만 건인 고객과 몇 건인 고객이 같은 digest로 묶입니다. digest의 평균값은 이 차이를 가리므로, 개선안을 검증할 때는 처리량이 큰 값과 작은 값을 나누어 실행계획을 확인해야 합니다.
이번 실험에서 평균 시간이 가장 긴 SQL은 전체 합계였지만, 서버 시간을 가장 많이 쓴 SQL은 평균이 더 짧은 고객별 합계였습니다. 총 시간으로 후보를 좁히고, 실행당 검사 행 수로 낭비를 확인하고, 실행계획으로 원인을 찾은 뒤, 같은 조건의 전후 비교로 개선을 검증하는 순서를 지키면 느린 쿼리 분석이 순위표에서 끝나지 않고 근거 있는 조치로 이어집니다.




댓글 0
첫 댓글을 남겨보세요.