성능

29초 통계 API 개선: 병렬 쿼리와 날짜 범위 조건

29초 걸린 관리자 통계에서 독립 쿼리의 직렬 대기와 날짜 컬럼 함수 조건을 찾아 Promise.all과 반개구간 범위로 바꾼 과정을 설명한다.

이 글의 목차기다리는 순서부터 겹칠 수 있었다5
  1. 기다리는 순서부터 겹칠 수 있었다
  2. 같은 요청 안에서 날짜 조건도 살펴봤다
  3. 29초에서 분리해 낸 두 가지 병목
  4. 결론
  5. 참고한 공식 문서

관리자 통계 화면을 조회하는 데 약 29초가 걸렸다.
처음에는 느린 SQL 한 문장을 찾으면 될 거라고 봤다.
쿼리를 펼친 뒤 호출 코드를 따라가면서 생각이 바뀌었다.
데이터베이스가 각 쿼리를 처리하는 시간뿐 아니라, 애플리케이션이 여러 쿼리를 기다리는 순서도 한 요청의 시간을 만든다.

화면에는 서로 다른 통계 두 개가 함께 나왔다.
두 번째 통계는 첫 번째 결과를 사용하지 않았지만, 코드는 첫 번째가 끝난 뒤에야 두 번째를 시작했다.

SQL 쿼리는 데이터베이스에 어떤 데이터를 읽거나 계산할지 요청하는 문장이다.
통계 API 하나도 전체 건수, 평균값, 기간별 합계처럼 여러 쿼리를 실행할 수 있다.

식당에서 서로 다른 두 메뉴를 주문하면서 첫 메뉴를 다 먹은 뒤에야 두 번째 메뉴를 주문하면 주방이 비어 있어도 대기시간이 더해진다.
두 집계가 서로의 결과를 쓰지 않는다면 동시에 요청하고 더 늦게 끝나는 쪽까지만 기다릴 수 있다.

다만 모든 느린 API를 병렬화해서는 안 된다.
먼저 각 쿼리가 왜 오래 걸리는지와 두 결과가 정말 독립적인지를 나눠 봐야 한다.

관찰한 병목먼저 검토할 방법주의할 점
한 쿼리가 너무 많은 행을 읽는다인덱스와 조건, 실행 계획 개선병렬화해도 느린 쿼리 자체는 그대로다
독립 쿼리를 순서대로 기다린다제한된 수만 Promise.all()로 함께 실행연결 풀과 DB CPU 경합을 확인한다
같은 통계를 요청마다 다시 계산한다짧은 캐시나 미리 계산한 읽기 모델값이 늦게 갱신돼도 되는지 정한다
두 번째 쿼리가 첫 결과를 사용한다직렬 실행 유지 또는 쿼리 구조 재설계억지로 병렬화하면 결과가 달라진다

Promise.all()은 여러 JavaScript 비동기 작업을 먼저 시작하고 모두 끝날 때까지 함께 기다리는 기능이다.
데이터베이스가 실제로 동시에 처리하려면 각 쿼리가 사용할 연결과 서버 자원도 있어야 한다.

기다리는 순서부터 겹칠 수 있었다

두 집계 중 하나의 결과가 다른 쪽 조건으로 쓰이는지부터 확인했다.
의존 관계가 없었기 때문에 애플리케이션에서는 두 database.query() 호출을 먼저 만들고 Promise.all()로 함께 기다리도록 바꿨다.
이 변경은 직렬 await를 없애지만, 데이터베이스에서 두 쿼리가 실제로 겹쳐 실행되는지는 연결 풀과 드라이버 동작에 달려 있다.
그래서 쿼리 시작·종료 시각과 사용한 연결도 함께 확인해야 한다.

독립된 집계 A와 B를 차례로 기다리는 방식과 같은 시각에 시작해 더 늦게 끝나는 집계까지만 기다리는 방식 비교
병렬 실행은 두 쿼리의 계산을 없애는 것이 아니라, 서로 의존하지 않는 대기 시간을 겹치며 실제 단축 폭은 측정이 필요하다.
당시 병렬화한 조회 흐름TypeScript단순화 예시
type AggregateRow = {
  value?: string | number | null;
};

const [countRows, averageRows] = await Promise.all([
  database.query<AggregateRow[]>(countSql, {
    replacements: { targetId },
  }),
  database.query<AggregateRow[]>(averageSql, {
    replacements: { targetId, startAt, endAt },
  }),
]);

return {
  count: Number(countRows[0]?.value) || 0,
  average: Number(averageRows[0]?.value) || 0,
};

당시 변경에서는 집계값을 Number(value) || 0으로 숫자화했다.
작업을 다시 정리하며 보니 잘못된 문자열이나 빈 결과까지 0으로 숨길 수 있었다.
값이 없을 때 정말 0으로 볼지는 통계의 의미에 맞춰 별도로 정해야 한다.

통계의 의미에 맞춰 빈 값을 구분하는 보완안TypeScript후속 설계
function parseRequiredFinite(
  value: string | number | null | undefined,
  field: string,
) {
  if (
    value === null
    || value === undefined
    || (typeof value === 'string' && value.trim() === '')
  ) {
    throw new Error(`${field} 집계값이 비어 있습니다.`);
  }

  const number = Number(value);
  if (!Number.isFinite(number)) {
    throw new Error(`${field} 집계값을 숫자로 바꿀 수 없습니다.`);
  }

  return number;
}

function parseNullableFinite(
  value: string | number | null | undefined,
  field: string,
) {
  if (value === null || value === undefined) {
    return null;
  }

  return parseRequiredFinite(value, field);
}

const count = parseRequiredFinite(countRows[0]?.value, 'count');
const average = parseNullableFinite(averageRows[0]?.value, 'average');

COUNT()는 조건에 맞는 행이 없으면 보통 0을 돌려주지만, AVG()는 평균을 낼 행이 없으면 NULL이 된다.
둘을 모두 0으로 바꾸면 “데이터가 없음”과 “평균이 0임”을 구별할 수 없다.
그래서 필수 집계는 잘못된 값을 오류로 드러내고, 빈 결과가 정상인 평균은 null을 유지했다.

Promise.all() 안에서 하나가 실패하면 전체 요청도 실패한다.
다만 먼저 실패한 뒤에도 이미 시작한 다른 쿼리를 자동으로 취소하지는 않는다.
남은 DB 작업의 중단 여부는 드라이버와 별도로 구현한 취소 처리에 달려 있다.
통계 하나만 성공해도 화면에 보여 줘야 한다면 오류 정책을 다르게 설계해야 한다.
동시에 실행하는 쿼리가 많아지면 연결 풀, 곧 애플리케이션이 재사용하려고 일정 개수만 열어 둔 DB 연결과 데이터베이스 CPU가 더 바빠질 수도 있다.
이 사례에서는 서로 독립된 두 집계만 겹쳤고, 실제 실행이 겹쳤는지는 연결 풀의 사용량과 쿼리 타임라인으로 검증 대상으로 남겼다.

같은 요청 안에서 날짜 조건도 살펴봤다

실행 순서를 확인한 뒤 각 쿼리의 조건으로 내려갔다.
날짜 컬럼에 DATE_FORMAT()을 적용해 문자열로 만든 다음 기간을 비교하고 있었다.

인덱스는 정리된 전화번호부와 비슷하다.
성으로 정렬된 책이 있어도 모든 성을 다른 형식으로 바꾼 뒤 비교하면 정렬된 순서를 바로 활용하기 어려울 수 있다.
당시에는 날짜 컬럼의 함수를 제거하고 바인딩된 범위 비교로 바꿨다.
지금 다시 구현한다면 종료 경계의 중복까지 피할 수 있도록 아래처럼 반개구간을 사용한다.

날짜 컬럼을 가공하지 않는 범위 조건SQL후속 설계
SELECT AVG(metric_value) AS value
FROM sample_metrics
WHERE target_id = :targetId
  AND created_at >= :startAt
  AND created_at < :endAt;

:startAt:endAt은 SQL 문자열에 직접 값을 이어 붙이지 않고 데이터만 따로 전달하는 바인딩 파라미터다.
먼저 “하루”가 서비스 기준 시각인지 UTC인지 정하고, 그 기준에서 구한 시작·종료 시각을 데이터베이스 컬럼의 타임존에 맞춰 전달해야 한다.
예시는 시작 시각은 포함하고 다음 구간의 시작은 제외하는 반개구간을 사용했다.
이렇게 하면 자정 경계의 같은 데이터를 두 번 세는 일을 피하기 쉽고, 여름·겨울에 시계가 바뀌는 지역에서도 하루를 무조건 24시간으로 계산하는 실수를 줄일 수 있다.

범위 조건은 MySQL이 인덱스를 검토하기 좋은 형태를 만들 뿐, 인덱스 사용을 보장하지 않는다.
데이터 분포와 복합 인덱스의 순서에 따라 선택은 달라진다.
어느 인덱스를 쓸 계획이고 몇 행을 읽을 것으로 예상하는지는 쿼리의 실행 계획을 보여 주는 EXPLAIN으로 확인해야 한다.

날짜 컬럼을 함수로 가공하는 조건과 created_at에서 startAt은 포함하고 endAt은 제외하는 범위를 바로 비교하는 조건의 구조 비교
반개구간은 종료 경계 중복을 피하고 인덱스 범위 접근을 검토하기 좋은 조건을 만든다. 실제 인덱스 선택은 EXPLAIN으로 확인한다.

29초에서 분리해 낸 두 가지 병목

변경직접 없앤 것별도로 확인할 것
독립 집계를 Promise.all()로 실행두 번째 쿼리가 첫 번째를 기다리던 시간연결 풀과 DB CPU 경합
날짜 컬럼 함수 제거와 범위 비교컬럼을 매 행 가공하던 조건실제 인덱스 선택과 읽은 행 수
집계값 검증잘못된 값이 조용히 0이 되는 경로통계별 빈 값 정책

실제로 한 해결은 두 가지였다.
서로 의존하지 않는 집계 두 개를 Promise.all()로 함께 요청했고, 날짜 컬럼을 DATE_FORMAT()으로 가공하던 조건을 원본 날짜의 범위 비교로 바꿨다.
전자는 애플리케이션의 불필요한 직렬 대기를 없애고, 후자는 MySQL이 범위 접근과 인덱스를 검토하기 쉬운 조건을 만든다.
Promise.all()은 두 쿼리 중 하나라도 실패하면 전체가 실패한다는 오류 정책까지 포함한다.

두 쿼리의 시간을 A, B라고 하면 직렬 대기는 A + B, 병렬 대기는 경합이 없을 때 대략 max(A, B)다.
관측한 29초가 두 쿼리 대기시간의 합이라면 절감할 수 있는 대기는 min(A, B)이고, 두 시간이 같을 때 상한은 14.5초·50%다.
한 쿼리가 대부분을 차지하면 이득은 작아지고, 동시 실행이 연결 풀이나 DB CPU 경합을 만들면 오히려 느려질 수 있다.
그래서 이 값은 성과 수치가 아니라 병렬화만으로 기대할 수 있는 범위를 정하는 계산이다.

날짜 범위 조건은 먼저 일반 EXPLAIN으로 예상 실행 계획과 선택한 인덱스를 확인한다.
EXPLAIN ANALYZE는 이름과 달리 쿼리를 실제로 실행하므로, 운영 데이터베이스에서 무심코 누르면 안 된다.
스테이징이나 읽기 전용 복제본처럼 부하를 통제할 수 있는 환경에서 변경 전후의 실제 읽은 행 수와 실행 시간을 비교한다.
전체 요청은 직렬 버전, 병렬 버전, 병렬 실행과 범위 조건을 모두 적용한 버전으로 나눠 측정하면 두 변경의 효과가 섞이지 않는다.
p95는 요청 100개를 빠른 순서로 세웠을 때 약 95번째 요청의 시간으로, 평균에 가려진 느린 구간을 확인할 때 함께 본다.

이 방법은 결과가 서로 독립되고, 두 작업을 동시에 실행해도 연결 풀과 DB CPU가 감당할 수 있을 때 적용한다.
두 번째 쿼리가 첫 번째 결과에 의존하거나 동시 실행이 데이터베이스 경합을 키운다면 직렬 실행이 맞다.
날짜 컬럼을 범위로 비교하는 방식은 기간 조회의 기본 후보지만, 최종 판단은 운영과 같은 데이터로 실행 계획과 결과 동일성을 확인한 뒤 내려야 한다.

결론

당시 확인한 문제는 한 가지가 아니었다.
서로 독립적인 두 집계를 애플리케이션이 차례로 기다렸고, 날짜 컬럼을 함수로 가공한 조건은 데이터베이스가 범위 접근을 검토하기 어려운 모양이었다.

나는 독립 쿼리를 Promise.all()로 함께 시작하고, 날짜 조건을 created_at >= :startAt AND created_at < :endAt 형태의 반개구간으로 바꿨다.

29초가 최종적으로 몇 초가 됐는지는 남아 있는 측정값이 없어 성과 수치로 만들지 않았다.
대신 두 쿼리 시간이 같다는 가장 좋은 가정에서도 병렬화만으로 줄일 수 있는 대기는 최대 50%라는 계산 경계를 남겼다.

느린 API에서는 SQL 한 줄만 고치기 전에 요청 안의 대기 순서를 그리고, 쿼리 자체의 실행 계획과 애플리케이션의 직렬 대기를 따로 측정해야 한다.

참고한 공식 문서