Contents
see List운영 DB 튜닝은 느린 쿼리 하나를 고치는 일이 아니다
MySQL은 8.4부터 장기 지원이 필요한 운영 환경에 적합한 LTS 트랙을 제공한다. LTS 계열은 기능 변화보다 안정성과 보안 수정에 초점을 두므로, 서비스 데이터베이스를 오래 운영해야 하는 팀이라면 혁신 릴리스보다 예측 가능한 선택지가 된다. 하지만 버전이 안정적이라는 것과 쿼리가 자동으로 빨라진다는 것은 전혀 다른 문제다. 실무에서 장애를 만드는 쿼리는 대부분 문법이 틀린 쿼리가 아니라, 데이터가 늘어난 뒤 실행 계획이 바뀌거나 인덱스 선택도가 나빠진 쿼리다.
이 문서는 MySQL 8.4 LTS 기준으로 운영 환경에서 바로 적용할 수 있는 쿼리 튜닝 절차를 정리한다. 핵심은 세 가지다. 첫째, 느린 쿼리를 감으로 판단하지 않고 실행 계획과 실제 실행 시간을 확인한다. 둘째, WHERE, JOIN, ORDER BY, GROUP BY 조건을 기준으로 복합 인덱스를 설계한다. 셋째, 옵티마이저가 데이터 분포를 잘못 추정할 때 히스토그램과 통계를 갱신해 계획을 안정화한다.
1. 먼저 느린 쿼리의 형태를 고정한다
튜닝을 시작할 때 가장 흔한 실수는 애플리케이션 로그에 찍힌 SQL을 그대로 복사해 한 번 실행해 보고 끝내는 것이다. 운영 쿼리는 바인딩 값, 조회 기간, 정렬 조건, 페이징 위치에 따라 완전히 다른 비용을 가진다. 따라서 먼저 대표 입력값을 정해야 한다. 최근 7일 조회인지, 1년치 조회인지, 특정 고객 한 명인지, 전체 고객 대상인지에 따라 필요한 인덱스가 달라진다.
예를 들어 주문 목록 화면이 느리다면 단순히 orders 테이블에 인덱스를 추가하기 전에 화면의 실제 조건을 확인해야 한다. 대부분의 목록 API는 고객 ID, 상태, 기간, 최신순 정렬, LIMIT 조합을 사용한다. 이 조합을 기준으로 실행 계획을 확인해야 운영에서 같은 효과를 기대할 수 있다.
EXPLAIN ANALYZE
SELECT id, customer_id, status, total_amount, created_at
FROM orders
WHERE customer_id = 1024
AND status IN ('PAID', 'SHIPPING')
AND created_at >= '2026-01-01 00:00:00'
ORDER BY created_at DESC
LIMIT 50;
EXPLAIN만 보면 옵티마이저의 예상 비용과 예상 행 수를 볼 수 있다. EXPLAIN ANALYZE를 사용하면 실제 실행도 수행되므로 실제 소요 시간과 실제 읽은 행 수를 함께 확인할 수 있다. 운영 DB에서 사용할 때는 쓰기 쿼리에 조심해야 하며, SELECT라도 부하가 큰 쿼리는 복제본이나 점검 시간에 실행하는 편이 안전하다.
2. 복합 인덱스는 조건 순서가 아니라 사용 방식으로 설계한다
복합 인덱스를 만들 때 WHERE 절에 적힌 순서를 그대로 따라가는 경우가 많다. 하지만 인덱스 컬럼 순서는 SQL 문장 순서가 아니라 데이터 접근 방식으로 결정해야 한다. 일반적으로 동등 조건 컬럼을 앞에 두고, 그 다음 범위 조건이나 정렬 조건을 배치한다. 위 예시에서는 customer_id가 특정 고객으로 좁히는 동등 조건이고, status도 제한된 값으로 좁히는 조건이다. created_at은 기간 필터이면서 정렬에도 사용된다.
CREATE INDEX idx_orders_customer_status_created
ON orders (customer_id, status, created_at DESC);
이 인덱스는 특정 고객의 특정 상태 주문을 created_at 기준으로 탐색하기 좋다. LIMIT 50이 함께 있으면 필요한 행을 빠르게 찾고 중단할 수 있어 목록 API에 유리하다. 반대로 created_at만 앞에 둔 인덱스는 전체 주문 중 특정 기간을 먼저 훑은 뒤 고객과 상태를 필터링할 수 있어, 고객 단위 조회에서는 불필요한 스캔이 늘어날 수 있다.
단, 모든 조건을 하나의 인덱스에 무조건 넣는 것은 좋은 전략이 아니다. 인덱스가 많아지면 INSERT, UPDATE, DELETE 비용이 증가하고 버퍼 풀 공간도 더 사용한다. 화면별로 자주 호출되는 조회 패턴을 모아 중복 인덱스를 제거하고, 실제로 선택도가 높은 컬럼을 기준으로 설계해야 한다.
3. 커버링 인덱스는 읽기 성능과 쓰기 비용을 함께 본다
조회 컬럼까지 인덱스에 포함되면 테이블 레코드를 다시 읽지 않아도 되는 경우가 있다. 이를 커버링 인덱스라고 부른다. MySQL에서는 보조 인덱스에 기본 키 값이 함께 저장되므로, 필요한 컬럼이 인덱스에 모두 있으면 랜덤 I/O를 줄일 수 있다. 다만 커버링을 위해 너무 많은 컬럼을 넣으면 인덱스 크기가 커지고 캐시 효율이 떨어진다.
CREATE INDEX idx_orders_list_covering
ON orders (customer_id, status, created_at DESC, id, total_amount);
위 인덱스는 목록 화면에서 id, customer_id, status, total_amount, created_at만 보여주는 경우 효과가 있을 수 있다. 하지만 주문 상세 정보, 배송지, 메모, 결제 수단까지 모두 목록에서 가져오는 구조라면 커버링 인덱스보다 API 응답 설계를 먼저 고치는 편이 낫다. 목록은 목록에 필요한 최소 컬럼만 조회하고, 상세는 별도 API에서 가져오는 구조가 데이터베이스에도 애플리케이션에도 유리하다.
4. 옵티마이저 추정이 틀리면 히스토그램을 확인한다
쿼리 실행 계획은 통계에 의존한다. 특정 컬럼의 값 분포가 고르지 않으면 옵티마이저가 실제보다 훨씬 적거나 많은 행을 읽는다고 추정할 수 있다. 예를 들어 주문 상태가 대부분 PAID이고 CANCELLED는 매우 적은데, 옵티마이저가 이를 균등 분포로 가정하면 잘못된 인덱스를 선택할 수 있다. 이런 경우 히스토그램이 도움이 된다.
ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 16 BUCKETS;
SELECT *
FROM information_schema.COLUMN_STATISTICS
WHERE SCHEMA_NAME = DATABASE()
AND TABLE_NAME = 'orders'
AND COLUMN_NAME = 'status';
히스토그램은 인덱스를 대체하는 기능이 아니다. 인덱스가 없는 컬럼을 빠르게 검색하게 만드는 마법도 아니다. 역할은 옵티마이저가 값 분포를 더 정확히 이해하도록 돕는 것이다. 특히 상태값, 유형, 지역 코드처럼 값 종류는 적지만 분포가 치우친 컬럼에서 효과를 확인할 수 있다. 다만 데이터 분포가 자주 변하는 테이블에서는 주기적인 통계 갱신 절차도 함께 준비해야 한다.
5. 페이징이 깊어지면 OFFSET보다 키셋 페이지네이션을 검토한다
LIMIT 50 OFFSET 100000 같은 쿼리는 앞의 10만 건을 건너뛰어야 하므로 페이지가 깊어질수록 느려진다. 운영 목록에서 무한 스크롤이나 다음 페이지 방식이 가능하다면 마지막으로 본 정렬 키를 기준으로 다음 데이터를 가져오는 키셋 페이지네이션이 안정적이다.
SELECT id, customer_id, status, total_amount, created_at
FROM orders
WHERE customer_id = 1024
AND status IN ('PAID', 'SHIPPING')
AND (created_at, id) < ('2026-05-14 10:30:00', 987654)
ORDER BY created_at DESC, id DESC
LIMIT 50;
키셋 페이지네이션을 사용할 때는 정렬 기준이 고유하게 결정되도록 created_at과 id를 함께 쓰는 것이 안전하다. 같은 시각에 생성된 주문이 여러 건이면 created_at만으로는 페이지 경계가 흔들릴 수 있기 때문이다. 이 방식에 맞추려면 인덱스도 정렬 조건을 반영해야 한다.
CREATE INDEX idx_orders_seek_page
ON orders (customer_id, status, created_at DESC, id DESC);
6. 변경 전후를 같은 기준으로 비교한다
인덱스 추가 후에는 반드시 같은 바인딩 값, 같은 데이터 범위, 같은 LIMIT 조건으로 실행 계획을 다시 확인해야 한다. 단순히 개발 DB에서 빨라졌다는 결과는 신뢰하기 어렵다. 개발 DB는 데이터가 적고 분포가 다르며 캐시 상태도 운영과 다르다. 가능하면 운영 복제본에서 비교하고, 변경 전후의 실행 시간, 읽은 행 수, 임시 테이블 사용 여부, filesort 발생 여부를 기록한다.
또한 인덱스를 추가한 뒤 쓰기 지연이 늘어나지 않았는지도 확인해야 한다. 주문, 결제, 로그처럼 쓰기가 많은 테이블은 조회 인덱스 하나가 전체 처리량에 영향을 줄 수 있다. 성능 개선은 SELECT 하나의 평균 시간만 보는 것이 아니라, API 응답 시간, DB CPU, 버퍼 풀 적중률, 잠금 대기, 복제 지연까지 함께 확인해야 한다.
운영 적용 체크리스트
- 느린 쿼리는 실제 API 조건과 바인딩 값으로 재현한다.
- EXPLAIN ANALYZE로 예상 행 수와 실제 행 수 차이를 확인한다.
- 복합 인덱스는 동등 조건, 범위 조건, 정렬 조건의 사용 방식을 기준으로 설계한다.
- 목록 API는 필요한 컬럼만 조회하고, 커버링 인덱스는 쓰기 비용까지 계산한다.
- 상태값처럼 분포가 치우친 컬럼은 히스토그램으로 옵티마이저 추정을 보정한다.
- 깊은 OFFSET 페이지네이션은 키셋 페이지네이션으로 전환할 수 있는지 검토한다.
- 인덱스 추가 후에는 조회 성능뿐 아니라 쓰기 지연, 복제 지연, 캐시 사용량도 함께 관찰한다.