Skip to main content
Summary

튜닝은 추측이 아니라 관찰에서 시작한다

MySQL 성능 튜닝을 시작할 때 가장 흔한 실수는 설정값부터 바꾸는 것이다. innodb_buffer_pool_size를 늘리거나 인덱스를 여러 개 추가하면 빨라질 것 같지만, 원인을 확인하지 않은 변경은 다른 문제를 만들 수 있다. 인덱스가 늘면 조회는 빨라질 수 있지만 INSERT, UPDATE, DELETE는 느려질 수 있다. 메모리 설정을 과하게 키우면 운영체제 메모리가 부족해져 전체 서버가 불안정해질 수 있다.

성능 문제를 다룰 때는 먼저 질문을 나누어야 한다.

  • 어떤 SQL이 느린가?

  • 얼마나 자주 실행되는가?

  • 쿼리가 읽은 행 수는 많은가?

  • 인덱스를 사용하고 있는가?

  • CPU, 메모리, 디스크, 커넥션 중 어디가 막혀 있는가?

  • 잠금 대기 때문에 느린 것인가?

즉, 쿼리와 서버 상태를 함께 봐야 한다. 느린 SQL 하나가 디스크 I/O를 폭발시킬 수도 있고, 반대로 디스크가 느려져 평소 문제없던 SQL이 모두 느려질 수도 있다.

MySQL 성능 튜닝과 운영 점검AI 생성 이미지

Slow Query Log로 느린 쿼리 수집하기

느린 쿼리 분석의 출발점은 Slow Query Log다. Slow Query Log는 설정한 시간보다 오래 걸린 SQL을 기록하는 로그다. 운영 중 감으로 "이 API가 느린 것 같다"라고 판단하는 대신, 실제로 오래 걸린 SQL을 모아 우선순위를 정할 수 있다.

먼저 현재 설정을 확인한다.

SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
SHOW VARIABLES LIKE 'slow_query_log_file';

slow_query_log는 로그 사용 여부다. long_query_time은 몇 초 이상 걸린 쿼리를 기록할지 정하는 값이다. slow_query_log_file은 로그 파일 위치다. MySQL의 기본 long_query_time은 비교적 길 수 있으므로, 운영 점검에서는 1~2초 정도로 낮춰 문제 쿼리를 더 적극적으로 수집하기도 한다.1

전역으로 켜려면 다음처럼 설정할 수 있다.

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;

특정 세션에서만 기준을 바꾸고 싶다면 다음처럼 설정한다.

SET SESSION long_query_time = 1;

인덱스를 사용하지 않는 쿼리까지 보고 싶다면 다음 설정도 사용할 수 있다.

SET GLOBAL log_queries_not_using_indexes = 'ON';

다만 운영 환경에서는 주의해야 한다. 이 설정을 켜면 로그가 급격히 늘어날 수 있다. 로그 파일이 디스크를 차지하고, 분석해야 할 양도 많아진다. 따라서 짧은 기간만 켜거나, 트래픽이 적은 시간대에 수집하거나, 스테이징 환경에서 먼저 확인하는 방식이 안전하다.

또 하나 중요한 점은 SET GLOBAL로 바꾼 값이 재시작 후에도 항상 유지되는 것은 아니라는 것이다. 운영 설정으로 확정하려면 MySQL 설정 파일이나 영구 설정 방식에 반영해야 한다.

느린 쿼리의 우선순위 정하기

Slow Query Log에 기록된 SQL을 모두 고칠 필요는 없다. 실무에서는 효과가 큰 것부터 고친다. 기준은 보통 네 가지다.

첫째, 실행 시간이 긴 쿼리다. 한 번 실행에 30초가 걸리는 쿼리는 사용자 요청을 직접 막을 수 있다.

둘째, 호출 빈도가 높은 쿼리다. 한 번은 0.2초밖에 걸리지 않아도 초당 수백 번 실행되면 전체 DB 시간을 많이 사용한다. 애플리케이션에서 흔히 말하는 N+1 쿼리도 이 범주에 들어간다. 목록을 한 번 조회한 뒤 각 행마다 추가 조회를 반복하는 패턴이다.

셋째, 잠금 대기가 긴 쿼리다. SQL 자체는 단순해 보여도 다른 트랜잭션이 잡고 있는 락을 기다리느라 느려질 수 있다.

넷째, 스캔한 행 수가 많은 쿼리다. 결과는 10건인데 내부적으로 수십만 행을 읽었다면 비효율 가능성이 높다. 특히 Rows_examined가 Rows_sent보다 지나치게 크면 인덱스 누락이나 조건식 문제를 의심해야 한다.

로그가 많을 때는 mysqldumpslow 같은 도구로 요약할 수 있다.

mysqldumpslow -s t -t 10 /path/to/slow.log

이 명령은 실행 시간이 긴 상위 쿼리를 보는 데 유용하다. 더 정교하게 보려면 pt-query-digest 같은 도구로 쿼리를 형태별로 묶어 총 실행 시간, 평균 시간, 호출 횟수 등을 함께 확인할 수 있다.

EXPLAIN으로 실행 계획 읽기

느린 쿼리를 찾았다면 다음 단계는 실행 계획을 확인하는 것이다. 실행 계획은 MySQL이 쿼리를 어떻게 처리하려고 하는지 보여 준다.

EXPLAIN
SELECT *
FROM orders
WHERE customer_id = 42
  AND status = 'pending'
ORDER BY created_at DESC;

처음에는 모든 컬럼을 이해하려고 하기보다 다음 항목부터 보면 된다.

  • type: 접근 방식이다. ALL이면 테이블 전체 스캔을 의미한다.

  • key: 실제 사용한 인덱스다. NULL이면 적절한 인덱스를 쓰지 못한 것이다.

  • rows: MySQL이 읽을 것으로 예상한 행 수다.

  • Extra: 추가 작업을 보여 준다. Using filesort, Using temporary는 정렬이나 임시 테이블 비용을 의심할 신호다.

예를 들어 customer_id와 created_at 조건으로 자주 조회한다면 다음과 같은 복합 인덱스를 검토할 수 있다.

ALTER TABLE orders
ADD INDEX idx_customer_created (customer_id, created_at);

인덱스를 추가한 뒤에는 반드시 다시 확인해야 한다.

EXPLAIN
SELECT id, customer_id, status, created_at
FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC;

인덱스는 만들었다고 끝이 아니다. 실제로 사용되는지, 읽는 행 수가 줄었는지, 정렬 비용이 사라졌는지 확인해야 한다. MySQL 8.0 이상에서는 EXPLAIN ANALYZE로 예상 실행 계획과 실제 실행 결과를 함께 볼 수 있다. 예상 행 수와 실제 행 수가 크게 다르면 통계가 오래되었을 수 있으므로 ANALYZE TABLE도 검토한다.1

자주 느려지는 SQL 패턴

실무에서 반복해서 등장하는 느린 쿼리 패턴이 있다.

첫째는 SELECT *다. 모든 컬럼을 가져오면 네트워크 전송량이 늘고, 디스크나 메모리에서 더 많은 데이터를 읽게 된다. 특히 TEXT, JSON, 큰 문자열 컬럼이 포함된 테이블에서는 차이가 크다. 목록 화면에 필요한 값이 id, title, created_at뿐이라면 그 컬럼만 조회해야 한다.

SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20;

둘째는 과도한 조인이다. 조인 자체가 나쁜 것은 아니다. 문제는 조인 전에 범위를 줄이지 않거나, 조인 조건에 인덱스가 없거나, 필요 없는 테이블까지 습관적으로 붙이는 경우다. 큰 테이블끼리 넓은 범위로 조인하면 읽어야 할 행 수가 급격히 늘어난다.

셋째는 큰 OFFSET 페이징이다.

SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;

이 쿼리는 100001번째 행부터 바로 찾아가는 것처럼 보이지만, 실제로는 앞쪽 많은 행을 지나가야 한다. 페이지가 뒤로 갈수록 느려진다. 최신순 목록이나 무한 스크롤에서는 커서 기반 페이지네이션이 더 적합하다.

SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-05-01 10:00:00', 1000)
ORDER BY created_at DESC, id DESC
LIMIT 20;

넷째는 불필요한 정렬과 그룹화다. ORDER BY, GROUP BY, DISTINCT는 내부적으로 정렬이나 임시 테이블을 만들 수 있다. 적절한 인덱스가 없으면 메모리와 디스크 사용량이 늘어난다. 실행 계획의 Using filesort, Using temporary는 이 부분을 확인하는 힌트다.

다섯째는 인덱스 컬럼에 함수를 적용하는 조건이다.

-- 좋지 않은 예
SELECT id, created_at
FROM orders
WHERE YEAR(created_at) = 2026;

이렇게 쓰면 created_at 인덱스를 효율적으로 쓰기 어렵다. 범위 조건으로 바꾸는 편이 낫다.

SELECT id, created_at
FROM orders
WHERE created_at >= '2026-01-01'
  AND created_at < '2027-01-01';

버퍼 풀과 디스크 I/O 이해하기

쿼리가 아무리 잘 작성되어도 서버 메모리가 부족하면 성능이 떨어진다. InnoDB에서 가장 중요한 메모리 영역은 버퍼 풀이다. 버퍼 풀은 테이블 데이터와 인덱스 페이지를 메모리에 캐시한다. 자주 읽는 데이터가 버퍼 풀에 있으면 디스크를 읽지 않아도 되므로 빠르다.

반대로 버퍼 풀이 너무 작으면 같은 데이터를 반복해서 디스크에서 읽어야 한다. 디스크 I/O는 메모리 접근보다 훨씬 느리다. 그래서 조회 지연이 늘고, 디스크 대기 시간이 길어지고, 전체 QPS가 떨어질 수 있다.

대표 설정은 다음 변수다.

SHOW VARIABLES LIKE 'innodb_buffer_pool_size';

전용 DB 서버라면 전체 메모리의 상당 부분을 버퍼 풀에 배정하는 경우가 많지만, 무조건 크게 잡는 것이 답은 아니다. 운영체제, MySQL 연결, 정렬 버퍼, 임시 테이블, 백업 작업, 모니터링 에이전트도 메모리를 사용한다. 또한 데이터 크기보다 과도하게 크게 잡을 필요도 없다. Percona는 실무 기준으로 전체 RAM의 50~70%에서 시작해 데이터 크기와 사용률을 보며 조정하는 접근을 제안한다.2

버퍼 풀 문제를 판단할 때는 MySQL 지표와 운영체제 지표를 함께 봐야 한다. 버퍼 풀 사용률은 높은데 디스크 읽기가 계속 많다면 메모리가 부족할 수 있다. 반대로 메모리 여유가 많고 디스크 I/O도 낮다면 병목은 SQL 구조나 잠금일 가능성이 높다.

운영 중 확인해야 할 MySQL 내부 지표

운영 점검은 장애가 난 뒤에만 하는 일이 아니다. 평소 기준선을 알아야 이상 징후를 빨리 찾을 수 있다. 다음 값들은 정기적으로 확인할 만하다.3

SHOW GLOBAL STATUS LIKE 'Uptime';
SHOW GLOBAL STATUS LIKE 'Questions';
SHOW GLOBAL STATUS LIKE '%Slow%';
SHOW GLOBAL STATUS LIKE 'Threads_connected';
SHOW GLOBAL STATUS LIKE 'Threads_running';
SHOW GLOBAL STATUS LIKE 'Max_used_connections';
SHOW GLOBAL STATUS LIKE '%Sort%';
SHOW GLOBAL STATUS LIKE 'Created_tmp%';

Uptime은 마지막 재시작 이후 지난 시간이다. 의도하지 않은 재시작이 있었는지 확인할 수 있다.

Questions는 서버가 처리한 SQL 요청 수를 보여 준다. 일정 시간 간격으로 차이를 계산하면 대략적인 QPS를 구할 수 있다.

Slow_queries는 느린 쿼리 누적 수다. 배포 이후 갑자기 증가했다면 새 코드나 새 인덱스 변경을 의심해야 한다.

Threads_connected는 현재 연결된 커넥션 수이고, Threads_running은 실제 실행 중인 스레드 수다. 연결 수가 많아도 실행 중인 작업이 적으면 단순 대기 커넥션일 수 있다. 반대로 Threads_running이 평소보다 높게 유지되면 쿼리가 밀리고 있을 가능성이 있다.

Max_used_connections는 지금까지 사용된 최대 동시 연결 수다. 이 값이 max_connections에 가까우면 커넥션 포화를 경계해야 한다. 단순히 max_connections만 늘리면 더 많은 쿼리가 동시에 들어와 CPU와 디스크를 더 압박할 수 있다.

정렬과 임시 테이블 지표도 중요하다. Sort_merge_passes가 늘거나 디스크 기반 임시 테이블이 많아지면 정렬, 그룹화, 큰 결과 집합을 의심해야 한다.

서버 병목을 함께 해석하기

MySQL은 혼자 느려지지 않는다. 결국 CPU, 메모리, 스토리지, 네트워크 위에서 동작한다. 따라서 DB 지표만 보거나 서버 지표만 보면 원인을 놓치기 쉽다.

CPU 사용률이 높다면 먼저 어떤 SQL이 CPU를 많이 쓰는지 봐야 한다. 인덱스 없이 많은 행을 필터링하거나, 정렬과 집계를 반복하거나, 애플리케이션에서 같은 쿼리를 과도하게 호출할 수 있다.

메모리 여유가 부족하면 스왑 사용 여부를 확인해야 한다. 스왑은 메모리가 부족할 때 디스크를 메모리처럼 쓰는 방식인데, DB 서버에서는 심각한 지연을 만들 수 있다. 버퍼 풀을 너무 크게 잡았거나, 커넥션별 메모리 사용이 많거나, 임시 테이블이 커졌을 수 있다.

스토리지 IOPS와 지연 시간도 핵심이다. 쿼리 실행 시간이 갑자기 늘었는데 CPU는 낮고 디스크 대기 시간이 높다면 I/O 병목일 수 있다. 백업, 대량 배치, 인덱스 생성, 큰 정렬 작업이 같은 시간에 겹쳤는지도 확인해야 한다.

잠금 대기는 쿼리 자체의 실행 시간과 다르게 보일 수 있다. 간단한 UPDATE가 오래 걸린다면 다른 트랜잭션이 같은 행이나 범위를 잠그고 있을 가능성이 있다. 이때는 SHOW PROCESSLIST, InnoDB 상태, performance_schema를 함께 확인한다.

SHOW PROCESSLIST;
SHOW ENGINE INNODB STATUSG

중요한 것은 지표를 따로 보지 않는 것이다. 느린 쿼리 수 증가, Threads_running 증가, 디스크 지연 증가, 잠금 대기 증가는 서로 연결되어 나타난다. 운영자는 이 연결을 보고 우선순위를 정해야 한다.

변경은 작게, 검증은 반복해서

성능 개선은 한 번의 큰 변경보다 작은 변경을 반복하는 편이 안전하다. 느린 쿼리를 찾고, 실행 계획을 확인하고, 인덱스나 SQL을 바꾸고, 다시 측정한다. 서버 설정도 마찬가지다. 변경 전 현재 값과 지표를 기록하고, 변경 후 같은 기준으로 비교해야 한다.

운영 변경 전에는 최소한 다음을 확인한다.

  • 백업이 있는가?

  • 변경 대상 쿼리와 테이블을 알고 있는가?

  • 실행 계획을 비교했는가?

  • 쓰기 성능에 미칠 영향은 검토했는가?

  • 롤백 방법이 있는가?

  • 배포 후 모니터링할 지표를 정했는가?

MySQL 성능 튜닝의 핵심은 "빠르게 만드는 기술"이 아니라 "느려지는 이유를 찾아내는 절차"다. Slow Query Log로 문제 SQL을 수집하고, EXPLAIN으로 실행 계획을 확인하고, 서버 지표로 병목을 해석한다. 이 흐름을 반복할 수 있으면 운영 환경에서도 MySQL을 더 안정적으로 다룰 수 있다.

참고

1How to Fix Slow MySQL Queries: A Practical Guide

2MySQL Performance Tuning: Maximizing Database Efficiency and Speed

3MySQL Performance Monitoring and Key Performance Indicators (KPI) With PMM

책 목록

처음부터 실무까지 배우는 MySQL

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기