집계 기초: SUM/COUNT로 잔액·기간별 적립/사용 리포트 만들기

포인트 시스템의 “진짜 쓸모”는 원장(point_history)에 기록된 사실을 사람이 이해할 수 있는 형태로 요약하는 데서 나옵니다. 화면에서는 잔액 한 줄로 보이지만, 그 뒤에는 적립/사용/만료/취소가 뒤섞인 수많은 기록이 존재합니다.

그래서 포인트 시스템에서 SQL의 핵심은 단순 조회(SELECT)보다 집계(aggregation)입니다. 집계는 원장 데이터를 합치고(SUM), 개수를 세고(COUNT), 기준별로 묶어서(GROUP BY) “의미 있는 숫자”로 바꾸는 과정입니다.

이 단원의 목적: 원장 데이터로 ‘요약 결과’를 만드는 습관 만들기

이번 글에서는 아래 3가지를 목표로 합니다.

  1. SUM으로 잔액을 만들고, 기간별 합계를 안정적으로 계산한다
  2. COUNT로 이벤트 건수를 집계해 “양”을 파악한다
  3. 집계를 할 때 자주 발생하는 실수(NULL, 숨김 데이터, 중복 기준)를 피한다

준비: 집계를 위한 최소 데이터 점검

집계는 데이터가 적으면 티가 안 나지만, 데이터가 쌓이면 작은 실수가 큰 오차로 확대됩니다. 그래서 집계 전에 “필수 조건”을 먼저 확인해 두면 좋습니다.

  • amount가 NULL이 아닌가
  • 숨김 데이터(hide=1)가 섞여 있다면 집계에서 제외할 것인가
  • 부호 정책(적립 + / 차감 -)이 일관적인가

아래 예시는 hide 컬럼이 있다고 가정하고, 집계에서는 hide=0만 포함하는 패턴으로 진행합니다.

SET @uid := '11111111-1111-1111-1111-111111111111';

1) 잔액 계산의 기본: SUM(amount)

가장 기본적인 잔액 조회는 “해당 사용자의 amount를 전부 더하는 것”입니다. 이 방식의 장점은 단순함과 신뢰성입니다. 원장이 올바르면, 잔액은 항상 재현 가능합니다.

SELECT uid, SUM(amount) AS balance FROM point_history WHERE uid = @uid AND hide = 0 GROUP BY uid;

여기서 GROUP BY가 꼭 필요한 이유는 “사용자별”로 묶기 위해서입니다. uid 조건을 특정 사용자로 고정했더라도, 습관적으로 GROUP BY를 넣으면 나중에 “여러 사용자 잔액을 한 번에 뽑는 쿼리”로 확장하기 쉬워집니다.

2) 기간별 합계: “최근 30일 동안 얼마나 변했나”

잔액은 누적 결과이고, 기간별 합계는 “변화량”입니다. 운영에서는 보통 잔액보다 변화량을 더 자주 봅니다. 예를 들어 “이번 달에 적립이 비정상적으로 늘었나?” 같은 질문이 여기에 해당합니다.

SELECT uid, SUM(amount) AS delta_30d FROM point_history WHERE uid = @uid AND hide = 0 AND created_at >= NOW() - INTERVAL 30 DAY GROUP BY uid;

주의: created_at의 의미가 정책에 따라 달라질 수 있다

created_at이 “기록 시간”인지 “이벤트 발생 시간”인지에 따라, 기간 집계 결과는 달라집니다. 시스템에서 두 시간이 다를 수 있다면 별도 컬럼을 두는 설계도 고려할 수 있습니다. 지금 단계에서는 created_at을 기준으로 통일하고, 이후 만료 배치/정산 단원에서 “시간 컬럼 설계”를 더 다룹니다.

3) 적립/차감만 따로 집계하기: CASE로 분리하기

amount의 부호 정책을 썼다면, 단순히 amount > 0, amount < 0으로 나눌 수도 있습니다. 다만 포인트 시스템은 정책상 예외가 생기기 쉬워서, action_type 기준으로 분리하는 편이 더 명확한 경우가 많습니다.

action_type 기준으로 “적립 합계”와 “차감 합계”를 한 번에

SELECT uid, SUM(CASE WHEN action_type IN ('CHARGE','USE_CANCEL','ADJUST') THEN amount ELSE 0 END) AS earned, SUM(CASE WHEN action_type IN ('USE','REFUND','EXPIRE') THEN amount ELSE 0 END) AS spent FROM point_history WHERE uid = @uid AND hide = 0 GROUP BY uid;

여기서 spent는 음수 합계가 됩니다. 화면에 “사용/만료로 빠져나간 총액”을 양수로 보여주고 싶다면, ABS를 씌우거나 -1을 곱하는 방식으로 표현을 맞출 수 있습니다. 단, DB에 저장된 원장 데이터 자체를 바꾸는 것이 아니라, 표현 단계에서만 바꾸는 편이 안전합니다.

SELECT uid, SUM(CASE WHEN action_type IN ('USE','REFUND','EXPIRE') THEN -amount ELSE 0 END) AS spent_positive FROM point_history WHERE uid = @uid AND hide = 0 GROUP BY uid;

4) COUNT: “얼마나 자주” 발생했나

포인트 운영에서는 금액 합계뿐 아니라 “건수”도 중요합니다. 예를 들어 적립이 갑자기 늘었는데 금액이 아니라 건수가 늘었다면, 중복 이벤트가 발생했을 가능성을 의심할 수 있습니다.

SELECT action_type, COUNT(*) AS cnt FROM point_history WHERE uid = @uid AND hide = 0 AND created_at >= NOW() - INTERVAL 30 DAY GROUP BY action_type ORDER BY cnt DESC;

COUNT(*) vs COUNT(column)

COUNT(*)는 행 수를 셉니다(가장 일반적). COUNT(column)은 그 column이 NULL이 아닌 행만 셉니다. 예를 들어 ref_id가 NULL일 수 있다면, COUNT(ref_id)는 “ref_id가 있는 건수”만 계산합니다.

-- ref_id가 채워진 이벤트의 수만 보고 싶을 때 SELECT COUNT(ref_id) AS with_ref_id, COUNT(*) AS total_rows FROM point_history WHERE uid = @uid AND hide = 0;

5) 월별 리포트: GROUP BY로 “기간 단위”를 만든다

“월별 적립/사용/만료”는 포인트 운영의 기본 리포트 중 하나입니다. 핵심은 created_at을 “월” 단위로 잘라서 그룹핑하는 것입니다. DBMS별 함수가 조금씩 다르지만, MySQL 계열에서는 DATE_FORMAT이 흔히 쓰입니다.

SELECT DATE_FORMAT(created_at, '%Y-%m') AS ym, SUM(amount) AS net_delta, COUNT(*) AS rows_cnt FROM point_history WHERE uid = @uid AND hide = 0 GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY ym ASC;

net_delta는 월별 순변동(적립-차감)이 됩니다. 여기서 적립/차감을 분리하면 더 읽기 쉬운 리포트가 됩니다.

SELECT DATE_FORMAT(created_at, '%Y-%m') AS ym, SUM(CASE WHEN action_type IN ('CHARGE','USE_CANCEL','ADJUST') THEN amount ELSE 0 END) AS earned, SUM(CASE WHEN action_type IN ('USE','REFUND','EXPIRE') THEN -amount ELSE 0 END) AS spent, SUM(amount) AS net_delta FROM point_history WHERE uid = @uid AND hide = 0 GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY ym ASC;

6) 집계에서 자주 터지는 실수 4가지

  1. 숨김 데이터 포함
    hide=1이 집계에 섞이면 리포트가 왜곡됩니다. “집계 기준”을 고정해야 합니다.
  2. 기간 조건의 경계
    월별 집계에서 종료일 포함/미포함을 잘못 잡으면 중복/누락이 생깁니다.
  3. 부호 정책 흔들림
    어떤 이벤트는 +, 어떤 이벤트는 - 규칙이 깨지면 SUM 결과가 무의미해집니다.
  4. 그룹 기준 불일치
    화면은 action_type 기준인데, 쿼리는 amount 부호 기준이면 서로 해석이 달라질 수 있습니다.

7) 집계 결과를 바로 검증하는 “3종 세트”

집계 쿼리는 만들기보다 “검증”이 더 중요합니다. 아래 3개를 세트로 가지고 있으면, 리포트가 이상할 때 원인을 빠르게 좁힐 수 있습니다.

-- (1) 잔액 SELECT SUM(amount) AS balance FROM point_history WHERE uid = @uid AND hide = 0; -- (2) 최근 30일 변화량 SELECT SUM(amount) AS delta_30d FROM point_history WHERE uid = @uid AND hide = 0 AND created_at >= NOW() - INTERVAL 30 DAY; -- (3) 최근 30일 액션별 합계/건수 SELECT action_type, SUM(amount) AS total_amount, COUNT(*) AS cnt FROM point_history WHERE uid = @uid AND hide = 0 AND created_at >= NOW() - INTERVAL 30 DAY GROUP BY action_type ORDER BY cnt DESC;

이 세트를 반복해서 사용하다 보면, 데이터가 커져도 “내가 만든 숫자가 맞는지”를 스스로 확인할 수 있게 됩니다.

다음 글 예고: GROUP BY/HAVING으로 “이상 거래”와 “활동 많은 유저”를 뽑아내기

다음 글에서는 집계를 한 단계 더 확장합니다. GROUP BY로 묶은 결과에 조건을 걸고(HAVING), “사용이 너무 많은 유저”, “만료가 과도한 유저”, “특정 기간에 비정상적으로 이벤트가 폭증한 케이스”처럼 운영에서 실제로 필요한 이상 징후 탐지 쿼리를 만들어봅니다.

댓글

이 블로그의 인기 게시물

JOIN 기초: users와 point_history를 합쳐 ‘회원별 요약(잔액/최근 활동)’ 만들기

점검 SQL: “원장 합계(SUM) vs balance” 불일치를 찾아내고 원인을 좁히는 방법

EXPLAIN 기초: 점검/리포트 쿼리가 느려질 때 “왜 느린지” 확인하는 방법