느린 쿼리라는 말부터 조금 나눠서 봐야 한다

애플리케이션에서 응답이 늦어지면 흔히 “쿼리가 느리다”는 말부터 나온다. 다만 실제로는 실행 시간이 조금 긴 쿼리 하나가 문제일 수도 있고, 짧은 쿼리가 아주 자주 반복되는 패턴이 더 부담이 될 수도 있다. 그래서 느린 쿼리를 볼 때는 특정 SQL 문장 하나를 바로 고치려 하기보다, 어떤 쿼리가 어떤 방식으로 시스템 부하를 만들고 있는지 먼저 나눠 보는 편이 도움이 될 때가 많다.

그래서 느린 쿼리를 볼 때는 SQL 문장 하나만 붙잡기보다, 호출 빈도와 읽는 행 수, 정렬과 그룹화 여부, 같은 요청 안에서 반복 호출되는 패턴을 같이 보는 편이 낫다.


느린 쿼리를 볼 때 먼저 좁혀야 하는 것들

실행 시간만으로 우선순위를 정하기는 어렵다

느린 쿼리를 진단할 때 가장 먼저 보는 숫자는 보통 실행 시간이다. 물론 중요하다. 다만 실행 시간이 길다는 사실만으로 우선순위를 정하기는 어렵다. 예를 들어 관리자 화면에서 하루 몇 번 실행되는 2초짜리 쿼리와, 메인 화면에서 초당 수십 번 호출되는 80ms짜리 쿼리는 운영 관점의 무게가 다르다.

예를 들어 메인 뉴스 목록을 가져오는 API가 아래처럼 되어 있다고 해 보자.

이 글은 MySQL 8.0 버전을 기준으로 한다.

SELECT
    a.id,
    a.title,
    a.published_at,
    a.office_id
FROM
    article a
WHERE
    a.status = 'PUBLISHED'
ORDER BY
    a.published_at DESC
LIMIT 20;

겉으로 보면 단순한 목록 조회다. 하지만 status로 걸러진 결과가 대부분이고, published_at 정렬에 맞는 인덱스가 없다면 MySQL은 많은 행을 읽은 뒤 정렬해서 20건만 반환할 수 있다. 반환 결과는 20건이어도 실제로는 훨씬 많은 행을 읽고 버리는 셈이다. 그래서 사용자가 받는 20건이라는 결과 건수보다, MySQL이 그 결과를 만들기 위해 사전에 어느 범위까지 읽고 정렬해야 하는지를 먼저 의심하는 편이 낫다. 특히 기사 발행 수가 많은 서비스에서는 이 차이가 매우 커진다.


느린 쿼리 로그는 출발점이지 결론이 아니다

운영 환경에서는 보통 느린 쿼리 로그에서 후보를 찾지만, 이 로그에 찍혔다는 사실만으로 항상 최우선 문제라고 단정하기는 어렵다. 특정 시간대에만 급증하는 쿼리인지, 배치 작업 때문에 일시적으로 늘어난 것인지, 아니면 애플리케이션 코드에서 불필요하게 반복 호출된 것인지까지 같이 봐야 하기 때문이다.

예를 들어 메인 화면에서 기사 20건을 가져온 뒤 각 기사마다 언론사 이름과 조회 수를 따로 읽는 코드는 아래처럼 쉽게 생긴다.

SELECT
    name
FROM
    office
WHERE
    id = ?;

SELECT
    article_id,
    view_count
FROM
    article_view
WHERE
    article_id = ?;

각 SQL은 짧고 빠를 수 있다. 하지만 기사 20건마다 언론사와 조회 수를 각각 따로 읽으면 한 요청에서 수십 개의 쿼리가 추가된다. 이런 경우에는 개별 SQL의 실행 시간보다 호출 패턴 자체가 병목을 만드는지를 먼저 보는 편이 낫다. 흔히 말하는 N+1 문제는 ORM을 쓸 때만 생기는 것이 아니라, 직접 SQL을 작성하는 코드에서도 얼마든지 나타난다.

느린 쿼리 로그를 볼 때도 같은 관점이 필요하다. 어떤 SQL이 1초 걸렸다는 사실보다, 같은 구문이 1분 동안 몇 번 반복됐는지, 특정 API 호출 직후 몰려 나오는지, 배치 시간과 겹치는지 같은 맥락을 같이 붙여 봐야 실제 우선순위가 보인다.


읽는 행 수와 중간 작업을 먼저 봐야 한다

반환 건수보다 읽는 행 수를 먼저 상상해야 한다

뉴스 서비스의 기사 목록은 대부분 최신 기사 몇 건만 보여준다. 그래서 개발 단계에서는 데이터베이스도 비슷하게 몇 건만 읽고 끝날 것처럼 느껴진다. 하지만 실제로는 정렬 조건과 필터 조건 때문에 훨씬 넓은 범위를 읽을 수 있다.

SELECT
    a.id,
    a.title,
    a.published_at
FROM
    article a
WHERE
    a.status = 'PUBLISHED'
    AND a.office_id IN (3, 5, 8)
ORDER BY
    a.published_at DESC
LIMIT 20;

이 쿼리는 언론사 세 곳의 기사만 보여주는 목록처럼 보인다. 그런데 status, office_id, published_at에 맞는 인덱스가 없다면 MySQL은 조건에 맞는 후보를 넓게 읽은 뒤 정렬해서 20건을 잘라낼 수 있다. 반환 행은 20건이어도 읽은 행은 수천, 수만 건일 수 있다는 뜻이다.

이 차이를 감각적으로 이해하고 있으면, 성능 문제를 볼 때 자연스럽게 “LIMIT는 작은데 왜 느리지?”라는 질문을 넘어서게 된다. 실제로는 LIMIT보다 앞단의 탐색 비용이 훨씬 큰 경우가 많기 때문이다.


정렬과 집계가 붙는 순간부터 비용 구조가 달라진다

MySQL에서 비용이 빠르게 커지는 쿼리는 대개 두 부류다. 첫째는 많은 행을 읽은 뒤 일부만 반환하는 쿼리이고, 둘째는 읽은 결과를 다시 정렬하거나 집계해야 하는 쿼리다. 기사 목록, 많이 본 기사 집계, 언론사별 발행 현황 화면이 자주 이런 형태를 띤다.

SELECT
    a.office_id,
    COUNT(*) AS published_count
FROM
    article a
WHERE
    a.published_at >= '2022-03-01 00:00:00'
GROUP BY
    a.office_id
ORDER BY
    published_count DESC
LIMIT 10;

이 쿼리는 단순 조회보다 더 많은 판단이 필요할 수 있다. 범위 조건으로 얼마나 많은 행을 읽는지, 집계 후 정렬이 필요한지, 제한된 10건을 얻기 위해 중간 결과를 얼마나 크게 만들었는지까지 함께 보는 편이 낫다. 그래서 느린 SQL을 만났을 때는 “문장이 복잡한가”보다 읽는 행 수와 중간 작업이 큰가를 먼저 보는 편이 더 도움이 될 때가 많다.

특히 집계 쿼리는 결과만 보면 단순하다. 언론사별 기사 수 10건이면 끝이기 때문이다. 하지만 데이터베이스는 그 10건을 만들기 위해 기간 조건에 맞는 기사 전체를 읽고 묶고 정렬해야 할 수 있다. 그래서 집계 화면의 느림은 인덱스 하나만 추가해서 끝나지 않는 경우가 많다.


무엇부터 손댈지를 정하는 기준

실무에서는 SQL을 전부 한꺼번에 고치기 어렵다. 그래서 병목의 모양을 기준으로 우선순위를 나누는 편이 좋다.

호출이 너무 많은 쿼리부터 줄여야 하는 경우

목록 한 번에 수십 개의 보조 조회가 따라붙는다면, 개별 SQL을 더 빠르게 만드는 일보다 먼저 쿼리 수 자체를 줄일 여지가 있는지 보는 편이 낫다. 같은 요청 안에서 반복 조회가 일어난다면 조인으로 한 번에 가져오거나, 여러 번의 단건 조회를 IN 조건으로 묶어 한 번에 읽는 방식이 더 잘 맞을 수 있다.

해결 방향도 이 지점에서는 비교적 단순하다. 화면 하나를 그릴 때 실제로 몇 번의 SQL이 실행되는지부터 세어 보고, 같은 키로 반복 조회되는 부분을 먼저 줄여 보면 된다. 느린 쿼리 로그도 이런 상황에서는 개별 실행 시간보다 반복 횟수와 호출 패턴을 함께 보는 편이 더 도움이 된다.


읽는 행 수가 많은 쿼리를 먼저 의심해야 하는 경우

LIMIT가 작아도 느린 목록 조회라면, 반환 건수보다 먼저 읽는 행 수를 줄일 수 있는지 살펴봐야 한다. 이런 경우에는 필터 조건과 정렬 조건을 함께 만족하는 인덱스가 있는지, 그리고 조건절이 인덱스 탐색에 잘 맞는 형태인지부터 확인하는 쪽이 더 잘 맞는 경우가 많다.

MySQL 공식 문서도 ORDER BYLIMIT가 함께 있을 때는 정렬에 맞는 인덱스를 활용하면 빠를 수 있지만, 정렬을 인덱스로 해결하지 못하면 많은 후보를 읽고 정렬한 뒤 앞부분만 돌려줄 수 있다고 설명한다. 그래서 해결 방향도 “결과가 몇 건인가”보다 “그 몇 건을 만들기 전에 어디까지 읽는가”를 기준으로 잡는 편이 좋다.


정렬이나 집계가 무거운 쿼리는 요구사항도 같이 봐야 한다

관리자 통계 화면이나 랭킹 화면처럼 정렬과 집계가 함께 붙는 쿼리는 인덱스 하나만 더한다고 바로 풀리지 않는 경우가 많다. 이럴 때는 실행 계획을 보는 것과 함께, 정말 실시간 집계가 필요한지, 사전 집계 테이블이나 배치 갱신으로 바꿀 수 있는지까지 같이 검토해 볼 만하다.

해결 방향도 보통 두 갈래다. 정렬 대상과 집계 범위를 더 줄일 수 있으면 그쪽이 먼저고, 요구사항상 계속 넓은 범위를 계산해야 한다면 조회 시점 계산을 줄이는 구조로 바꾸는 편이 더 현실적일 수 있다.


특정 시간대에만 느려진다면 쿼리 밖도 같이 봐야 한다

특정 시간대에만 갑자기 느려진다면 SQL 문장 하나보다, 배치 작업, 대량 적재, 재색인, 통계 갱신 같은 바깥 요인과 겹치는지 같이 보는 편이 낫다. 이런 문제는 쿼리 문장만 보면 잘 드러나지 않아서, 애플리케이션 요청 흐름과 운영 작업 시간대를 겹쳐 보는 쪽이 더 빠를 때가 많다.

MySQL의 느린 쿼리 로그도 이런 상황에서는 도움이 되지만, 인덱스를 쓰지 않는 쿼리까지 함께 기록하도록 설정하면 로그가 빠르게 커질 수 있다. 그래서 로그를 더 넓게 남기되, 어느 시간대에 어떤 종류의 쿼리가 몰리는지를 같이 읽는 편이 실제 원인을 좁히는 데 더 잘 맞는다.

결국 느린 쿼리를 본다는 것은 SQL 한 줄을 고립해서 보는 일이라기보다, 어떤 요청이 어떤 데이터 접근 패턴을 만들고 있는지를 함께 보는 일에 더 가깝다. 이 기준만 잡혀 있어도 다음 단계인 인덱스와 실행 계획을 훨씬 덜 헤매고 읽을 수 있다.

문제의 모양을 이렇게 구분해 두면 다음 단계도 조금 선명해진다. 호출이 너무 많은 쿼리라면 먼저 조회 패턴과 쿼리 수를 줄이는 쪽을 봐야 하고, 읽는 행 수가 많다면 인덱스와 조건절 구성을 다시 보는 편이 낫다. 정렬과 집계가 무거운 쿼리라면 실행 계획만 붙잡기보다, 조회 요구사항 자체를 조정할 수 있는지도 함께 검토해 볼 만하다.


정리하며

경험상 느린 쿼리를 다룰 때 SQL 한 줄을 고치는 기술보다, 문제의 모양을 구분하는 것이 중요했다. 뉴스 서비스의 기사 목록처럼 호출이 자주 일어나는 화면에서는 실행 시간 하나보다 호출 빈도, 읽는 행 수, 반복 조회 패턴이 더 중요한 단서가 될 때가 많았다.

그리고 처음부터 정답을 찾으려 하기보다 “이 쿼리는 왜 많이 불리는가”, “왜 적게 돌려주면서 많이 읽는가”를 먼저 생각하는 편이 편이 인덱스 설계와 실행 계획 해석에도 더 잘 이어졌다.


참조