Skip to content
hello tis
Go back

[26.09.22] 챌린지 API의 쿼리 지연 분석을 위한 실행 정보 수집 방식 비교하기

상황

출시 후 출석 API의 평균 응답 시간은 2 ~ 24ms였지만, 9월 17일 일부 요청은 수백 ms가 걸렸습니다. trace에서는 두 요청 모두 attendance_history 조회 두 번이 시간의 대부분을 차지했고, 결과를 읽는 result-set은 1ms 미만이었습니다.

/v1/pc/challenge/today/v1/web/challenge/board
전체 486.41ms 중 connection span 473.7ms, SELECT attendance_history 두 번이 312.9ms와 149.56ms인 traceBFF 전체 751.99ms 중 API 호출 362.65ms, SELECT attendance_history 두 번이 163.97ms와 182.17ms인 trace
  • API 구간: 486.41ms
  • 테이블 조회: 312.9ms, 149.56ms
  • API 구간: 362.65ms
  • 테이블 조회: 163.97ms, 182.17ms

원인

trace로는 어느 SQL이 오래 걸렸는지까지만 알 수 있었습니다.

느린 쿼리의 실행 정보를 남기는 두 방법을 로컬 MySQL 8.4에서 비교했습니다.

해결

전제

느린 쿼리의 실행 정보를 뽑는 위치를 두 곳으로 나눠 비교했습니다.

실험 조건

어제 낙서장의 합성 횟수 원장 300만 행에 인덱스가 없는 note 컬럼을 추가했습니다. 코드와 측정값은 실험 디렉터리에 있습니다.

이름쿼리재현하려는 조건
index_lookup사용자, 기능, 시각 범위로 COUNT(*)일일 횟수 조회, 인덱스 탐색
full_scannote LIKE '%7f%' 조건의 COUNT(*)인덱스 없는 컬럼의 전체 스캔
full_join_no_index인덱스 없는 feature_dim과 조인 후 GROUP BY f.name인덱스 없는 조인과 디스크 임시 테이블
sort_spillnote 정렬 후 100만 행 파생 테이블의 COUNT(*)정렬 버퍼 초과
scan_groupnote LIKE 조건과 GROUP BY user_id, 상위 10건스캔과 집계, watchdog 대상

DB에서 지표 뽑기: slow log와 performance_schema의 실행 통계

log_slow_extra를 켜면 slow log 항목에 스토리지 엔진 핸들러 호출 횟수와 정렬, 임시 테이블 카운터가 추가됩니다. 같은 값이 events_statements_history_long에 쿼리 단위로 기록됩니다. (MySQL slow log 문서)

# Time: 2026-09-22T02:39:45.836571Z
# User@Host: root[root] @ localhost []  Id:    23
# Query_time: 3.014766  Lock_time: 0.000002 Rows_sent: 20  Rows_examined: 3000020
# Thread_id: 23 Errno: 0 Killed: 0 Bytes_received: 155 Bytes_sent: 532
# Read_first: 3 Read_last: 0 Read_key: 3000004 Read_next: 3000000 Read_prev: 0 Read_rnd: 0 Read_rnd_next: 63
# Sort_merge_passes: 0 Sort_range_count: 0 Sort_rows: 0 Sort_scan_count: 0
# Created_tmp_disk_tables: 1 Created_tmp_tables: 1
SELECT /* traceId=p1-full_join_no_index-1 */ f.name, COUNT(*) FROM feature_dim f JOIN usage_history h ON h.feature_id = f.feature_id GROUP BY f.name;slow.log

표의 시간은 3회 중앙값이고, 카운터는 3회 모두 같았습니다.

쿼리TIMER_WAIT (ms)CPU_TIME (ms)Rows_examinedRows_sent
index_lookup0.10.101
full_scan477.3477.33,000,0001
full_join_no_index3,023.43,022.83,000,02020
sort_spill1,471.01,265.01,000,0001
scan_group3,528.03,527.73,000,01010
쿼리Read_keyRead_nextRead_rnd_nextSort_merge_passes디스크 임시 테이블
full_join_no_index3,000,0043,000,0006301
sort_spill204,020,0272,0581
scan_group13,000,000000

traceId 주석은 DIGEST_TEXT에서만 제거됩니다. digest 통계는 같은 쿼리로 합산되고, 개별 실행은 slow log와 history_long의 SQL_TEXT에서 주석으로 찾을 수 있습니다.

slow log를 log_output=FILE,TABLE로 파일과 테이블에 함께 기록하면 mysql.slow_log를 읽는 Grafana 대시보드 MySQL Slow queries로도 볼 수 있습니다. sql_query에 traceId 주석이 그대로 보입니다.

Grafana의 MySQL Slow queries 대시보드. Top query load, Most frequent queries, Slowest queries 패널에 traceId 주석이 붙은 두 쿼리가 실행 시간 4초와 0초, rows_examined 3,000,010과 3,000,000으로 표시됩니다.

잠금 대기도 확인했습니다. 100만 행 테이블에서 A 트랜잭션이 잠금을 3초 동안 보유할 때, B 트랜잭션의 쿼리가 어떻게 기록되는지 봤습니다. 시간은 performance_schema의 TIMER_WAIT와 LOCK_TIME입니다.

잠금B 트랜잭션TIMER_WAIT (ms)LOCK_TIME (ms)threads 상태
행 잠금같은 행 UPDATE2,470.92,470.6updating
메타데이터 잠금index_lookup SELECT2,160.00.002Waiting for table metadata lock

애플리케이션에서 지표 뽑기: EXPLAIN으로 실행 중인 쿼리의 실행계획 조회

EXPLAIN FOR CONNECTION은 다른 세션이 실행 중인 쿼리의 실행계획을 반환합니다. 절차는 다음과 같습니다.

회차Detection latency (ms)TREE (ms)JSON (ms)기본 형식 (ms)쿼리 전체 (ms)
1229.753.248.166.43,590.2
2387.062.860.151.53,604.7
3719.657.450.349.53,599.2

표의 시간에는 docker exec와 mysql 클라이언트 시작 시간 약 50ms가 포함되며, EXPLAIN FOR CONNECTION 자체는 수 ms 이내입니다.

실행 중에 TREE 형식으로 조회한 실행계획은 재실행 결과와 접근 경로가 같았지만 cost와 Filter의 추정 행 수가 달랐고, JSON 형식은 재실행 결과와 같았습니다.

-- EXPLAIN FORMAT=TREE FOR CONNECTION (실행 중)
-> Filter: (usage_history.note like '%7f%')  (cost=4.98e+6 rows=2.91e+6)
-- EXPLAIN FORMAT=TREE (종료 후 재실행)
-> Filter: (usage_history.note like '%7f%')  (cost=295566 rows=322794)

EXPLAIN FOR CONNECTION은 다시 최적화하지 않고 실행 중인 쿼리의 access path 트리에 저장된 값을 출력합니다. TREE 형식은 그 트리의 값을, JSON 형식은 옵티마이저가 filter_effect를 적용한 행 수를 보여줍니다. (opt_explain.cc, sql_executor.cc) 그래서 실행계획은 JSON 형식으로 저장하고, 접근 경로는 TREE 형식으로 읽습니다.

EXPLAIN FOR CONNECTION이 동작하지 않는 조건입니다.

조건결과
쿼리가 끝나고 세션이 종료된 뒤, 또는 없는 idERROR 1094 Unknown thread id
쿼리를 실행하지 않는 유휴 세션빈 결과, 오류 없음
PREPARE와 EXECUTE, Connector/J useServerPrepStmts=trueERROR 3012
Connector/J useServerPrepStmts=false(기본값)TREE 실행계획 반환

Spring Boot의 Connector/J 기본값은 클라이언트측 prepare라 동작합니다. 다른 사용자의 세션을 조회하려면 MySQL 8.4.11에서는 PROCESS가 아니라 GRANT OPTION을 뺀 모든 전역 권한이 필요했습니다. PROCESS만 가진 계정은 ERROR 1045를 받았고, 같은 사용자 이름으로 접속하면 추가 권한 없이 조회할 수 있습니다. (opt_explain.cc)

watchdog이 캡처한 실행계획을 파일과 함께 evidence.plan_capture 테이블에 저장하면, Grafana에서 slow log와 같은 MySQL 데이터 소스로 볼 수 있습니다. 위 패널은 watchdog 캡처, 아래 패널은 같은 traceId의 performance_schema 실행 통계입니다.

Grafana의 Slow query evidence 대시보드. 위 패널에는 watchdog이 캡처한 두 쿼리의 traceId, 실행 중 경과 1초, EXPLAIN 실행 시간 0.4ms와 0.5ms, TREE 실행계획이 있고, 아래 패널에는 traceId별 timer_ms, cpu_ms, rows_examined, select_scan 등 performance_schema 카운터가 있습니다.

애플리케이션에서 직접 EXPLAIN을 실행하는 방식과도 비교했습니다. HikariCP 6.3.0 풀(maximumPoolSize=10, connectionTimeout=2000)에 전체 스캔 스레드 12개와 인덱스 조회 스레드 4개를 40초 동안 실행하고, datasource-proxy 리스너에서 EXPLAIN을 실행하는 구조를 두 가지로 재현했습니다.

모드인덱스 조회 성공풀 시간 초과p50 (ms)p95 (ms)전체 스캔 성공
baseline2580273.61,554.4242
same_pool_sync34581,569.61,687.4120
separate_pool_async2491571.31,394.6248

전체 스캔 스레드를 8개로 줄여 커넥션이 남는 조건에서는 세 모드 모두 인덱스 조회 실패가 없었습니다.

실행계획을 애플리케이션 쪽에서 뽑는 방법은 두 가지이고, 둘 중 하나를 선택합니다.

항목datasource-proxy 비동기 캡처watchdog
잡는 쿼리임계값을 넘은 모든 쿼리1초 이상 실행 중인 쿼리
실행계획쿼리가 끝난 뒤 재실행한 계획실행 중이던 계획
실행 위치앱 안의 전용 풀 1개와 큐별도 프로세스
DB 계정앱 계정앱 계정, 또는 모든 전역 권한
파라미터, traceId리스너가 바인드 값과 traceId를 직접 받음PROCESSLIST_INFO에서 파싱

결과

두 방식의 장단점입니다.

방식장점단점
DB에서 지표 뽑기
  • 애플리케이션 변경 없음
  • 읽은 행 수, 정렬과 임시 테이블, 행 잠금 대기 기록
  • 실행계획 없음
  • 메타데이터 잠금 대기, 커넥션 대기 없음
애플리케이션에서 지표 뽑기
  • 실행 시점의 실행계획 확보
  • 서버측 prepared statement는 실행계획 추출 불가
  • 실행계획 수집 시 커넥션이 없으면 요청 실패

지표별로 기록되는 곳입니다. 비어 있는 칸은 그쪽에서 얻을 수 없다는 뜻입니다.

지표DB애플리케이션
읽은 행 수Rows_examined-
정렬 버퍼 초과Sort_merge_passes-
디스크 임시 테이블Created_tmp_disk_tables-
행 잠금 대기LOCK_TIME-
메타데이터 잠금 대기대기 중 threads 상태-
실행계획-datasource-proxy 비동기 캡처 또는 watchdog
커넥션 대기-JDBC span, HikariCP 메트릭

이 기준으로 정리한 구성입니다. docker compose로 만든 코드는 slow-query-evidence 저장소에 있습니다.

Spring Boot 애플리케이션은 SQL 주석에 traceId를 넣고 JDBC span만 기록합니다. MySQL은 slow log와 performance_schema에 쿼리별 카운터를 남기고, 별도 프로세스인 watchdog이 threads를 폴링해 실행 중인 쿼리에 EXPLAIN FOR CONNECTION을 실행하고 실행계획을 저장합니다.

남은 확인 사항입니다.


Share this post:

Previous Post
[26.09.23] 챌린지 API의 DB 호출 지연 분석을 위한 쿼리 실행과 대기 시간 구분하기
Next Post
[26.09.21] 챌린지 API의 횟수 원장 통합을 위한 보관과 조회 방식 비교하기