왜 느린 쿼리를 먼저 구조로 확인해야 하나

서비스가 느려졌을 때 가장 먼저 하는 실수는 서버 사양을 올리거나 캐시를 붙이는 것입니다. 물론 트래픽이 급증했거나 메모리가 부족한 상황도 있지만, 실제 운영에서는 하나의 조회 조건이 인덱스를 제대로 타지 못해 전체 테이블을 훑는 경우가 많습니다. 특히 주문, 회원, 게시글, 정산처럼 데이터가 계속 쌓이는 테이블은 개발 초기에 빠르던 쿼리가 몇 달 뒤 장애 원인이 되기 쉽습니다. 이 문서는 MySQL과 MariaDB에서 느린 쿼리를 발견했을 때 EXPLAIN으로 실행 계획을 읽고, 복합 인덱스를 설계하며, 배포 전후에 확인할 항목을 정리하는 실전 절차입니다.

1단계: 느린 쿼리 로그로 실제 문제부터 잡기

튜닝은 추측으로 시작하면 오래 걸립니다. 애플리케이션 로그에서 느린 API를 찾는 것도 중요하지만, 데이터베이스가 실제로 오래 실행한 SQL을 확인해야 합니다. 운영 환경에서는 slow query log를 켜고, 기준 시간을 너무 길게 잡지 않는 것이 좋습니다. 처음에는 1초 또는 500ms 정도로 시작하고, 서비스 특성에 따라 조정합니다. 관리자 화면처럼 사용 빈도는 낮지만 복잡한 조회가 많은 기능은 별도 기준을 둘 수도 있습니다.

-- 현재 설정 확인
SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';

-- 운영 정책에 맞게 설정 파일에 반영하는 것을 권장
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5;
SET GLOBAL log_queries_not_using_indexes = 'ON';

단, log_queries_not_using_indexes는 인덱스를 쓰지 않는 모든 쿼리를 기록할 수 있어 로그가 급격히 늘 수 있습니다. 짧은 점검 시간에만 켜거나, 스테이징 환경에서 먼저 확인하는 방식이 안전합니다. 핵심은 많이 느린 쿼리 하나보다 자주 실행되는 중간 속도 쿼리가 전체 응답 시간을 더 크게 망칠 수 있다는 점입니다.

2단계: EXPLAIN에서 반드시 볼 항목

문제가 되는 SQL을 찾았다면 EXPLAIN으로 실행 계획을 확인합니다. 처음부터 모든 항목을 외울 필요는 없습니다. 운영 점검에서는 type, possible_keys, key, rows, filtered, Extra를 우선 봅니다. type이 ALL이면 전체 테이블 스캔 가능성이 크고, key가 NULL이면 실제로 선택된 인덱스가 없다는 뜻입니다. rows가 실제 테이블 규모에 비해 지나치게 크면 조건이 충분히 좁혀지지 않은 것입니다.

EXPLAIN
SELECT id, customer_id, status, created_at, total_amount
FROM orders
WHERE customer_id = 1204
  AND status = 'PAID'
  AND created_at >= '2026-07-01 00:00:00'
ORDER BY created_at DESC
LIMIT 20;

Extra에 Using filesort가 보인다고 항상 장애는 아닙니다. 하지만 대량 후보 행을 정렬한 뒤 LIMIT을 적용한다면 위험합니다. Using temporary도 집계나 정렬에서 자주 보이지만, 큰 테이블의 관리자 검색 화면에서 반복된다면 인덱스나 조회 방식을 다시 봐야 합니다. EXPLAIN은 단순히 인덱스를 쓰는지 확인하는 도구가 아니라, 데이터베이스가 어떤 순서로 후보 행을 줄이는지 보는 도구입니다.

3단계: 복합 인덱스는 조건 순서보다 사용 패턴으로 설계하기

복합 인덱스를 만들 때 WHERE 절에 적힌 순서 그대로 컬럼을 나열하는 경우가 많습니다. 하지만 중요한 것은 쿼리의 사용 패턴입니다. 일반적으로 동등 조건으로 자주 좁히는 컬럼을 앞에 두고, 범위 조건이나 정렬에 쓰는 컬럼을 뒤에 둡니다. 예를 들어 특정 고객의 결제 완료 주문을 최신순으로 가져오는 쿼리라면 customer_id, status, created_at 순서가 자연스럽습니다.

CREATE INDEX idx_orders_customer_status_created
ON orders (customer_id, status, created_at DESC);

EXPLAIN
SELECT id, customer_id, status, created_at, total_amount
FROM orders
WHERE customer_id = 1204
  AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;

반대로 status처럼 값 종류가 적은 컬럼만 단독 인덱스로 만들면 효과가 작을 수 있습니다. PAID, CANCELLED, READY처럼 몇 가지 값만 있는 컬럼은 전체 데이터 중 많은 비율을 가리키기 때문입니다. 그러나 customer_id와 함께 쓰이면 후보 행을 크게 줄일 수 있습니다. 인덱스는 컬럼 하나의 특성보다 실제 조회 조합을 기준으로 판단해야 합니다.

4단계: 커버링 인덱스는 선택적으로만 적용하기

조회 컬럼까지 인덱스에 포함되면 테이블 본문을 다시 읽지 않아도 되어 빠를 수 있습니다. 이를 커버링 인덱스라고 부릅니다. 하지만 모든 SELECT 컬럼을 인덱스에 넣는 방식은 쓰기 성능과 저장 공간을 해칠 수 있습니다. 목록 화면처럼 매우 자주 호출되고 반환 컬럼이 적은 쿼리에만 선택적으로 적용하는 것이 좋습니다.

CREATE INDEX idx_orders_list_cover
ON orders (customer_id, status, created_at DESC, id, total_amount);

커버링 인덱스를 검토할 때는 Extra에 Using index가 보이는지 확인합니다. 다만 이것만 보고 무조건 성공이라고 판단하면 안 됩니다. 인덱스가 너무 넓어지면 INSERT, UPDATE, DELETE 때마다 유지 비용이 늘고, 버퍼 풀 효율도 떨어질 수 있습니다. 운영에서는 자주 호출되는 상위 쿼리 몇 개에 집중하는 편이 더 안정적입니다.

5단계: 함수와 형 변환이 인덱스를 막는지 확인하기

인덱스가 있어도 컬럼에 함수를 적용하면 인덱스를 제대로 쓰지 못하는 경우가 많습니다. 날짜 검색에서 DATE(created_at) = '2026-07-01'처럼 작성하면 보기에는 편하지만, created_at 값을 행마다 변환해야 하므로 범위 검색보다 불리합니다. 문자열 숫자 비교, 다른 문자셋 비교, 앞쪽 와일드카드 LIKE도 같은 방식으로 성능을 떨어뜨릴 수 있습니다.

-- 피해야 할 형태
SELECT * FROM orders
WHERE DATE(created_at) = '2026-07-01';

-- 인덱스 범위 검색에 유리한 형태
SELECT * FROM orders
WHERE created_at >= '2026-07-01 00:00:00'
  AND created_at <  '2026-07-02 00:00:00';

검색어 기능에서 LIKE '%키워드%'가 반드시 필요하다면 일반 B-Tree 인덱스만으로 해결하기 어렵습니다. 이 경우에는 FULLTEXT 인덱스, 검색 엔진, 별도 검색 테이블, ngram 전략 등을 검토해야 합니다. 중요한 것은 일반 인덱스로 해결할 수 없는 패턴을 억지로 튜닝하려 하지 않는 것입니다.

6단계: 배포 전후 확인 절차

인덱스 추가는 단순한 DDL처럼 보이지만 운영 테이블에서는 영향이 큽니다. 테이블 크기, 락 방식, 복제 지연, 백업 시간대를 확인하고 배포해야 합니다. 가능하면 스테이징에 운영과 비슷한 데이터 분포를 넣고 EXPLAIN 결과를 비교합니다. 쿼리 시간이 줄었더라도 쓰기 지연이나 디스크 사용량 증가가 생길 수 있으므로 배포 후 모니터링도 필요합니다.

  • 느린 쿼리 로그에서 실제 SQL, 실행 횟수, 평균 실행 시간을 확인합니다.
  • EXPLAIN에서 key가 의도한 인덱스인지, rows가 충분히 줄었는지 봅니다.
  • 복합 인덱스는 동등 조건, 범위 조건, 정렬 순서를 함께 고려합니다.
  • 기존 인덱스와 새 인덱스가 거의 중복되는지 확인해 불필요한 유지 비용을 줄입니다.
  • 배포 후 API 응답 시간, 데이터베이스 CPU, 디스크 I/O, 복제 지연을 함께 확인합니다.

마무리 체크리스트

느린 쿼리 튜닝은 한 번의 인덱스 추가로 끝나는 작업이 아닙니다. 데이터가 늘고 기능이 바뀌면 같은 SQL도 다른 실행 계획을 선택할 수 있습니다. 운영에서는 느린 쿼리 로그를 주기적으로 확인하고, 주요 목록 조회와 관리자 검색 화면의 실행 계획을 릴리스 전 점검 항목에 넣는 것이 좋습니다. 특히 조건이 여러 개인 조회, 최신순 정렬, LIMIT 페이지네이션, 날짜 범위 검색은 인덱스 설계의 영향을 크게 받습니다. 마지막으로 인덱스는 읽기를 빠르게 만드는 대신 쓰기와 저장 공간 비용을 늘린다는 점을 항상 같이 봐야 합니다. 적용 전에는 실제 쿼리와 데이터 분포를 확인하고, 적용 후에는 수치로 개선 여부를 검증하는 습관이 가장 좋은 튜닝 전략입니다.