본문 바로가기
데이터베이스

MySQL 집계 쿼리를 요약 테이블로 빼면서 정확도를 안 버리는 법

by 꼼냥냥 2026. 9. 13.
728x90

회원 등급 대상자를 세는 화면이 큰 점포에서 5.6초 걸렸다. 조건을 바꿀 때마다 몇 년치 구매 이력을 처음부터 다시 더하기 때문이다. 산식은 그대로 두고 결과만 미리 담는 요약 테이블로 뺐다. 4,932ms가 508ms가 됐다. 핵심은 속도가 아니라 BASE_DATE 컬럼이다. 과거는 요약본, 오늘은 실시간으로 나누면 정확도를 안 버리면서 범위만 줄일 수 있다.

왜 느린가

회원 등급을 자동으로 매기는 화면이 있다. "누적 결제금액이 얼마 이상인 회원을 VIP로 올린다" 같은 조건을 걸면 대상자 수를 보여준다.

산식이 이렇다.

  • 일반 서비스 → 상품 정보 테이블의 정가
  • 그 외(패키지·이벤트·쿠폰) → 이벤트 상품 정보 테이블의 정가
  • 패키지 한 건이 항목 수만큼 부풀지 않도록 예약 단위로 먼저 접는다
  • 그걸 회원별로 합산한 뒤 조건과 비교

문제는 조건을 바꿀 때마다 이걸 처음부터 다시 센다는 것이다. 회원 한 명의 누적 금액은 몇 년치 구매 이력을 전부 훑어야 나온다.

점포 하나의 상품 이력이 수십만 행이다. 조건은 금액 하한 하나 바뀌었을 뿐인데 매번 전부를 다시 더한다.

요약 테이블로 뺐다

산식은 그대로 두고 결과만 미리 담아 두기로 했다. 그러면 조회가 인덱스 조회로 바뀐다.

CREATE TABLE IF NOT EXISTS USER_GRADE_TOTAL
(
    STORE_CODE VARCHAR(5)  NOT NULL COMMENT '점포코드',
    USER_ID     VARCHAR(45) NOT NULL COMMENT '고객아이디',
    TOTAL_PAY   BIGINT      NOT NULL DEFAULT 0 COMMENT '누적 결제금액',
    BASE_DATE   VARCHAR(8)  NOT NULL COMMENT '집계 기준일(이 날까지 반영)',
    CREATE_DATE DATETIME    NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (STORE_CODE, USER_ID),
    KEY IDX_USER_GRADE_TOTAL_PAY (STORE_CODE, TOTAL_PAY)
);

결과는 이랬다.

이전 이후
큰 점포 조회 4,932ms 508ms

약 10배다.

BASE_DATE가 이 설계의 핵심이다

요약 테이블을 쓸 때 제일 흔한 반박이 "그럼 오늘 산 건 반영이 안 되잖아"다. 실제로 그렇다. 배치가 새벽에 도니 오늘 구매분은 내일 아침에야 들어간다.

그래서 컬럼 하나를 뒀다. BASE_DATE — 이 값이 어느 시점까지 반영됐는지를 적는다. 배치는 이걸 어제 날짜로 넣는다.

그리고 조회할 때 그 이후에 생긴 것만 실시간으로 더한다.

SELECT G.TOTAL_PAY + IFNULL(D.TODAY_PAY, 0) AS TOTAL_PAY
  FROM USER_GRADE_TOTAL G
  LEFT JOIN ( /* 오늘 생긴 상품만 집계 */ ) D
    ON D.STORE_CODE = G.STORE_CODE AND D.USER_ID = G.USER_ID

과거는 요약본, 오늘은 실시간. 정확도를 안 버리면서 범위를 줄인다.

오늘 하루치는 몇 건 안 되니 실시간으로 세도 싸다. 비싼 건 몇 년치를 다시 세는 쪽이다.

같은 패턴을 대시보드에도 쓰고 있었다. 지난 날짜는 스냅샷 테이블, 오늘은 실시간 쿼리를 UNION ALL로 붙인다. 요약 테이블을 넣을 때 이미 있던 패턴을 그대로 따라간 것이다.

증분이 아니라 전체 재적재다

"어제까지 넣어 두고 매일 하루치만 더하면 되지 않나"는 자연스러운 생각인데, 안 했다.

소급 수정과 삭제가 잦기 때문이다. 지난달 결제를 취소하거나 금액을 고치는 일이 실제로 있다. 증분으로 쌓으면 그 변경이 반영되지 않고, 한 번 어긋나면 영원히 어긋난 채로 간다.

그래서 매일 지우고 다시 넣는다.

DELETE FROM USER_GRADE_TOTAL;
INSERT INTO USER_GRADE_TOTAL (...) SELECT ... ;

전 점포 194만 행을 다시 만드는 데 58초다. 하루 한 번이면 감당할 수 있는 값이고, 틀릴 수 없다는 보장을 그 값에 산다.

증분은 빠르지만 검증이 어렵다. 전체 재적재는 느리지만 결과가 항상 산식과 일치한다.

새벽에 도는 배치가 낮 업무를 막으면 안 된다

원본 테이블을 통째로 읽어 넣는 작업이다. 1편의 그 문제다.

REPEATABLE READ로 두면 읽는 원본 행에 공유 락이 걸리고, 그동안 예약 확인완료 같은 쓰기 작업이 대기에 걸린다.

프로시저 첫 줄에서 격리수준을 내렸다.

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

운영에서 실측한 값이다.

항목 값
읽은 원본 194만 행
소요 58초
락 대기 0건
잠근 행 373,463 (쓰는 쪽 테이블만)

읽는 쪽에는 락이 하나도 안 걸렸다. 잠긴 건 자기가 쓰는 테이블뿐이다.

배포 직후 한 번은 손으로 채워야 한다

배치는 매일 새벽에 돈다. 그런데 배포한 날은 테이블이 비어 있다. 그 상태로 화면을 열면 대상자가 0명으로 보인다. 장애로 오해하기 딱 좋다.

그래서 배포 직후 한 번 손으로 채운다. 이때 배치와 같은 프로시저를 부른다.

CALL SP_USER_GRADE_TOTAL();

초기 적재용 스크립트를 따로 만들지 않은 이유가 있다. 따로 만들면 구현이 두 벌이 되고, 한쪽만 고치는 날이 온다.

이벤트도 본문 없이 호출만 한다.

CREATE EVENT ES_USER_GRADE_TOTAL ON SCHEDULE EVERY '1' DAY
    STARTS '2026-08-27 05:30:00'
    ON COMPLETION PRESERVE ENABLE
DO
BEGIN
    CALL SP_USER_GRADE_TOTAL();
END;

구현은 한 곳에, 호출은 여러 곳에서.

바꾸기 전에 결과가 같은지 확인한다

성능 개선의 진짜 위험은 느린 게 아니라 답이 달라지는 것이다.

옛 쿼리와 새 쿼리를 점포 × 조건 조합으로 돌려 대조했다. 금액 하한과 담당자 배정 여부를 바꿔 가며 40개 조합을 비교했고, 전부 일치했다.

그리고 기준값 몇 개를 스크립트에 적어 뒀다.

-- 아래 기준값과 맞아야 정상이다
--   점포 A / 누적 100만원 / 담당자 미배정 → 4,823명
--   점포 B / 누적 100만원 / 담당자 미배정 → 1,748명

적어 두지 않으면 다음에 만질 때 무엇과 비교해야 하는지 모른다.

정리

  • 조건이 바뀔 때마다 전부를 다시 세는 집계는 요약 테이블로 뺀다. 산식은 그대로 두고 결과만 담는다.
  • BASE_DATE로 "어디까지 반영됐는지"를 적고, 그 이후만 실시간으로 더한다.
  • 소급 수정이 있는 도메인에서는 증분이 아니라 전체 재적재가 맞다. 느린 대신 안 틀린다.
  • 배치가 원본에 락을 걸지 않게 READ COMMITTED로 내린다.
  • 초기 적재와 배치는 같은 프로시저를 부른다. 구현을 두 벌로 만들지 않는다.
  • 바꾸기 전에 옛 결과와 대조하고, 기준값을 적어 남긴다.

댓글