GROUP BY/HAVING: “이상 징후”를 집계로 찾아내는 방법

SUM/COUNT로 리포트를 만들 수 있게 되면 다음 단계로 자연스럽게 넘어갑니다. “숫자를 보는 것”에서 끝나지 않고, 이상한 패턴을 자동으로 걸러내는 것입니다. 포인트 시스템에서는 특히 아래 같은 상황이 자주 문제로 이어집니다.

  • 특정 사용자에게 적립 건수가 갑자기 폭증한다
  • 사용(차감)이 짧은 시간에 몰려 들어온다
  • 만료(EXPIRE)가 특정 기간에 비정상적으로 많다(정책/배치 오류 가능성)
  • ref_id가 없는 이벤트가 예상보다 많다(추적성 저하)

이런 패턴은 개별 행을 보는 SELECT만으로는 잘 안 보입니다. 그룹으로 묶어서 요약해야 드러납니다. 이번 글은 GROUP BY로 “요약 테이블을 만들고”, HAVING으로 “요약 결과에 조건을 거는” 흐름을 익히는 데 집중합니다.

이 단원의 목적: “원장 → 요약 → 필터”로 운영 질문에 답하기

HAVING은 WHERE와 비슷해 보이지만, 역할이 다릅니다. WHERE는 행을 고르는 조건, HAVING은 그룹(집계 결과)을 고르는 조건입니다. 이번 글의 목표는 아래 3가지입니다.

  1. GROUP BY로 유저/기간/행위별 요약을 만든다
  2. HAVING으로 “집계값 기준 필터링”을 정확히 적용한다
  3. 실제 포인트 운영에서 의미 있는 탐지 예시를 여러 형태로 만든다

1) WHERE vs HAVING: 역할이 다르다

WHERE는 집계 이전에 적용됩니다. 즉, 어떤 행을 집계에 포함시킬지 결정합니다. HAVING은 집계 이후에 적용됩니다. 즉, 집계 결과가 특정 조건을 만족하는 그룹만 남깁니다.

예시: 최근 30일 내역만 대상으로 “행위별 건수”를 본다

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

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;

여기서 “cnt가 10 이상인 action_type만 보고 싶다”는 요구가 생기면, 집계 결과(cnt)를 조건으로 걸어야 하므로 HAVING을 씁니다.

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 HAVING COUNT(*) >= 10 ORDER BY cnt DESC;

COUNT(*)는 SELECT에서 별칭(cnt)으로 썼더라도, DBMS에 따라 HAVING에서 별칭 사용이 제한될 수 있어 안전하게는 COUNT(*)로 직접 쓰는 편이 좋습니다. (MySQL 계열은 별칭 허용이 비교적 넓지만, 습관적으로 함수식을 쓰면 이식성이 올라갑니다.)

2) “활동이 많은 사용자” 찾기: uid별 사용(USE) 건수 상위

포인트에서 이상 징후를 찾는 가장 단순한 방법은 “건수”를 보는 것입니다. 예를 들어 최근 7일 동안 사용(USE) 요청이 지나치게 많은 사용자를 뽑아보면, 자동화/오남용/버그 가능성을 빠르게 점검할 수 있습니다.

SELECT uid, COUNT(*) AS use_cnt, SUM(amount) AS use_amount_sum FROM point_history WHERE hide = 0 AND action_type = 'USE' AND created_at >= NOW() - INTERVAL 7 DAY GROUP BY uid HAVING COUNT(*) >= 20 ORDER BY use_cnt DESC;

여기서 SUM(amount)은 음수 합계가 됩니다. 사용량을 양수로 보고 싶다면 -SUM(amount)로 표현을 바꿀 수 있습니다.

SELECT uid, COUNT(*) AS use_cnt, -SUM(amount) AS use_amount FROM point_history WHERE hide = 0 AND action_type = 'USE' AND created_at >= NOW() - INTERVAL 7 DAY GROUP BY uid HAVING COUNT(*) >= 20 ORDER BY use_amount DESC;

3) “짧은 시간에 몰린 사용” 찾기: 시간 버킷으로 그룹핑

어떤 이벤트가 “분 단위로 몰린다”는 건 흔히 이상 신호입니다. 예를 들어 10분 동안 USE가 50건 발생하면 사람이 직접 클릭했다기보다 자동화 가능성이 커집니다. 이런 탐지는 “시간을 일정 단위로 잘라서” 그룹핑하는 방식으로 만들 수 있습니다.

MySQL 기준으로 분(minute) 버킷을 만들려면 DATE_FORMAT을 사용할 수 있습니다. (DBMS마다 함수가 다르므로, 운영 환경에 맞게 조정하면 됩니다.)

SELECT uid, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i') AS minute_bucket, COUNT(*) AS use_cnt FROM point_history WHERE hide = 0 AND action_type = 'USE' AND created_at >= NOW() - INTERVAL 1 DAY GROUP BY uid, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i') HAVING COUNT(*) >= 10 ORDER BY use_cnt DESC;

이 쿼리는 “같은 분(minute_bucket) 안에서 USE가 10건 이상 발생한 사용자”를 뽑습니다. 실제 운영에서는 1분이 너무 민감할 수 있어 5분/10분 버킷으로 완화하는 경우도 많습니다.

4) “만료가 과도한 사용자” 찾기: EXPIRE 비중 점검

만료(EXPIRE)는 보통 배치로 발생합니다. 만료가 급증하면 정책 변경, 배치 오류, 유효기간 데이터 이상 같은 원인을 의심할 수 있습니다.

SELECT uid, COUNT(*) AS expire_cnt, -SUM(amount) AS expire_amount FROM point_history WHERE hide = 0 AND action_type = 'EXPIRE' AND created_at >= NOW() - INTERVAL 30 DAY GROUP BY uid HAVING -SUM(amount) >= 1000 ORDER BY expire_amount DESC;

위 쿼리는 “최근 30일 만료 금액이 1000 이상인 사용자”를 뽑습니다. 금액 기준을 바꾸면 “건수 기준” 탐지로도 사용할 수 있습니다.

5) “ref_id가 없는 기록이 많다” 찾기: 추적성 점검

ref_id는 주문/이벤트/요청을 추적하는 근거입니다. ref_id가 NULL인 기록이 과도하게 많아지면, 나중에 특정 이슈가 생겼을 때 “무엇 때문에 발생했는지” 역추적이 어렵습니다. 그래서 ref_id 누락 비율을 주기적으로 보는 것도 도움이 됩니다.

SELECT uid, COUNT(*) AS total_rows, SUM(CASE WHEN ref_id IS NULL THEN 1 ELSE 0 END) AS null_ref_rows FROM point_history WHERE hide = 0 AND created_at >= NOW() - INTERVAL 30 DAY GROUP BY uid HAVING SUM(CASE WHEN ref_id IS NULL THEN 1 ELSE 0 END) >= 10 ORDER BY null_ref_rows DESC;

“NULL이 10건 이상” 같은 기준은 서비스 규모에 따라 조정해야 합니다. 중요한 건 “기준을 갖고 보는 습관”입니다.

6) HAVING에서 자주 하는 실수 3가지

  1. WHERE에 집계 조건을 넣는다
    WHERE COUNT(*) > 10은 동작하지 않습니다. 집계 결과 조건은 HAVING입니다.
  2. 그룹 기준이 부족하다
    시간 버킷 탐지에서 uid 없이 minute_bucket만 그룹핑하면 “전체 시스템”만 보게 됩니다. 목적이 사용자별 탐지라면 uid를 그룹 키에 포함해야 합니다.
  3. 숨김 데이터 포함
    hide=1이 섞이면 탐지 결과가 왜곡됩니다. 집계 대상(WHERE)을 먼저 고정하세요.

7) 실전 확장: “상위 N개”만 보고 싶을 때

운영에서는 전부 보는 것보다 “문제가 될 가능성이 높은 상위 몇 개”만 빠르게 보는 경우가 많습니다. 이때는 ORDER BY + LIMIT를 붙입니다.

SELECT uid, COUNT(*) AS use_cnt, -SUM(amount) AS use_amount FROM point_history WHERE hide = 0 AND action_type = 'USE' AND created_at >= NOW() - INTERVAL 7 DAY GROUP BY uid ORDER BY use_cnt DESC LIMIT 20;

“상위 20명”을 먼저 보고, 필요하면 기준(HAVING)을 추가해 “조건 기반 탐지”로 확장하는 방식이 흔합니다.

다음 글 예고: JOIN 기초로 users와 point_history를 합쳐 ‘회원별 요약’ 만들기

지금까지는 point_history만으로 집계했습니다. 다음 글에서는 users 테이블을 JOIN해서, “회원 정보 + 포인트 요약(잔액/최근 활동)”을 한 번에 뽑는 쿼리를 만들겠습니다. 실제 서비스 화면이나 관리자 페이지에서는 단독 테이블 조회보다 JOIN 기반 조회가 훨씬 더 자주 등장합니다.

댓글

이 블로그의 인기 게시물

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

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

키 설계 기초: PRIMARY KEY/UNIQUE로 ‘중복 적립’과 ‘중복 차감’을 구조적으로 막는 방법