점검 SQL: “원장 합계(SUM) vs balance” 불일치를 찾아내고 원인을 좁히는 방법
포인트 시스템은 “정상 처리”만 잘 만들면 끝나는 것처럼 보이지만, 실제로는 시간이 지날수록 다양한 이유로 데이터가 어긋날 수 있습니다. 예를 들어 이런 상황이 생깁니다.
- 중간 장애로 트랜잭션이 일부만 반영된 것처럼 보이는 케이스(재처리/수동 개입 포함)
- 운영 정정(ADJUST)이나 소프트 삭제(hide) 처리 기준이 흔들린 케이스
- 배치 작업(만료/정산)이 예상과 다른 시간에 들어간 케이스
- 코드 변경 이후 특정 action_type이 누락/중복 집계된 케이스
그래서 “점검 쿼리”는 기능 쿼리만큼 중요합니다. 특히 balance 테이블(잔액 캐시)을 함께 쓰는 구조라면, 주기적으로 원장 합계와 balance가 같은지 확인해야 합니다.
이 단원의 목적: 불일치를 “발견”하고 “원인”을 좁힐 수 있는 쿼리 세트 만들기
이번 글에서는 아래 3가지를 목표로 합니다.
- 원장 합계(SUM)와 balance를 비교해 불일치 UID를 뽑는다
- 불일치가 발견되면 어디서부터 어긋났는지 범위를 좁힌다
- 자주 발생하는 유형(숨김 처리, 중복 요청, 배치 만료)을 점검 항목으로 만든다
1) 1차 점검: uid별 “원장 잔액”과 “balance 잔액” 비교
가장 기본은 uid별로 두 값을 나란히 놓고 비교하는 것입니다. 여기서는 hide=0만 원장 합계에 포함한다고 가정합니다.
SELECT b.uid, b.balance AS balance_cache, COALESCE(l.ledger_balance, 0) AS balance_ledger, b.balance - COALESCE(l.ledger_balance, 0) AS diff FROM point_balance b LEFT JOIN ( SELECT uid, SUM(amount) AS ledger_balance FROM point_history WHERE hide = 0 GROUP BY uid ) l ON l.uid = b.uid WHERE b.balance <> COALESCE(l.ledger_balance, 0) ORDER BY ABS(b.balance - COALESCE(l.ledger_balance, 0)) DESC; 이 쿼리의 결과가 “불일치 목록”입니다. diff가 0이면 일치, 0이 아니면 어긋난 것입니다. 운영에서는 diff의 절댓값이 큰 것부터 보는 편이 효율적입니다.
2) 반대 방향도 확인: 원장에는 있는데 balance에 없는 uid
point_balance가 “모든 사용자”를 보장하지 않는 설계라면, 원장에는 있지만 balance에 없는 사용자가 생길 수 있습니다. 이런 케이스를 따로 보고 싶다면 기준 테이블을 바꿔야 합니다.
SELECT l.uid, l.ledger_balance, b.balance AS balance_cache FROM ( SELECT uid, SUM(amount) AS ledger_balance FROM point_history WHERE hide = 0 GROUP BY uid ) l LEFT JOIN point_balance b ON b.uid = l.uid WHERE b.uid IS NULL ORDER BY ABS(l.ledger_balance) DESC; 이 결과는 “balance가 생성되지 않은 사용자”입니다. 정책상 정상일 수도 있고(잔액 캐시를 일부 사용자만 운영), 버그일 수도 있습니다(가입 시 balance row를 만들기로 했는데 누락).
3) 2차 점검: 불일치 uid 하나를 잡고 “언제부터” 틀어졌는지 좁히기
불일치 목록을 뽑았으면, 다음은 범위를 줄이는 단계입니다. 가장 빠른 방법은 “최근 이벤트부터” 역추적하는 것입니다.
(1) 최근 원장 내역 50건 확인
SET @uid := '11111111-1111-1111-1111-111111111111';
SELECT id, action_type, amount, ref_id, memo, hide, created_at
FROM point_history
WHERE uid = @uid
ORDER BY created_at DESC, id DESC
LIMIT 50;
(2) balance 값과 원장 합계를 동시에 확인
SELECT balance, updated_at FROM point_balance WHERE uid = @uid;
SELECT SUM(amount) AS ledger_balance
FROM point_history
WHERE uid = @uid AND hide = 0;
여기서 “최근 내역에는 정상처럼 보이는데 diff가 크다”면, 과거 어딘가에서 누락/중복이 누적됐을 가능성이 큽니다. 이런 경우는 기간을 나눠서 합계를 비교하면 범위를 빨리 좁힐 수 있습니다.
4) 기간 분할 점검: 월별/주별 누적 합계를 비교해서 분기점 찾기
불일치가 “언제부터 시작됐는지”를 찾는 실전 방법은 기간별 합계를 보고 이상한 구간을 찾는 것입니다. 여기서는 월별 순변동(net_delta)을 구해봅니다.
SELECT DATE_FORMAT(created_at, '%Y-%m') AS ym, SUM(CASE WHEN hide = 0 THEN amount ELSE 0 END) AS net_delta, COUNT(*) AS rows_cnt FROM point_history WHERE uid = @uid GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY ym ASC; 특정 달부터 숫자가 튀면, 그 달의 데이터(배치/정정/중복)를 집중적으로 보면 됩니다. 더 세밀하게 보고 싶다면 주별/일별로 버킷을 줄이면 됩니다.
5) 자주 나오는 원인 1: hide 처리 기준이 집계/갱신과 다르다
가장 흔한 불일치 유형 중 하나는 “원장 합계는 hide=0만 보는데, balance 갱신 로직에서는 hide를 고려하지 않았다” 같은 기준 불일치입니다.
hide=1인 내역이 많아졌다면, 아래처럼 규모를 확인해볼 수 있습니다.
SELECT SUM(CASE WHEN hide = 1 THEN amount ELSE 0 END) AS hidden_amount_sum, COUNT(CASE WHEN hide = 1 THEN 1 END) AS hidden_rows FROM point_history WHERE uid = @uid; 숨김 데이터가 존재한다면, “숨김 처리 시 balance도 같이 보정했는지”를 확인해야 합니다. 원장만 숨기고 balance를 그대로 두면 diff가 생기는 것은 자연스럽습니다.
6) 자주 나오는 원인 2: 중복 요청/재시도에 대한 처리 누락
요청 선점(point_requests)을 도입했다면, 원장에 기록된 내용과 요청 상태가 일관적인지 확인할 수 있습니다. 예를 들어 DONE인데 원장이 없다거나, 원장은 있는데 요청 상태가 RESERVED로 남아있다면, 중간 장애/재처리 과정에서 꼬였을 가능성이 있습니다.
요청 상태별 개수 확인(최근 7일)
SELECT status, COUNT(*) AS cnt FROM point_requests WHERE uid = @uid AND created_at >= NOW() - INTERVAL 7 DAY GROUP BY status ORDER BY cnt DESC; RESERVED가 오래 남아 있는 요청 찾기
SELECT request_id, request_type, amount, ref_id, status, created_at, updated_at FROM point_requests WHERE status = 'RESERVED' AND updated_at < NOW() - INTERVAL 10 MINUTE ORDER BY updated_at ASC LIMIT 100; RESERVED가 오래 남아 있으면 “중간 장애” 가능성이 큽니다. 이런 건을 어떤 정책으로 정리할지(FAILED 전환, 재처리, 수동 확인)는 시스템마다 다르지만, 최소한 “남아 있는 건이 있는지”를 보는 쿼리는 갖고 있어야 합니다.
7) 자주 나오는 원인 3: 만료 배치(EXPIRE) 중복/누락
만료는 배치로 들어오고, 배치 로직은 종종 “한 번 더 실행되는” 상황을 겪습니다. 그래서 EXPIRE가 특정 ref_id(배치 실행 단위)로 들어간다면, 같은 ref_id가 중복으로 실행됐는지 확인할 수 있습니다.
SELECT ref_id, COUNT(*) AS cnt, -SUM(amount) AS expire_amount FROM point_history WHERE action_type = 'EXPIRE' AND hide = 0 AND created_at >= NOW() - INTERVAL 30 DAY GROUP BY ref_id HAVING COUNT(*) > 1 ORDER BY cnt DESC; 이 쿼리는 “같은 ref_id로 EXPIRE가 여러 번 찍힌” 후보를 보여줍니다. ref_id를 배치 실행 단위로 설계해두면 이런 점검이 훨씬 쉬워집니다.
8) 불일치가 발견됐을 때 “복구”를 바로 하지 말고, 먼저 기준을 고정한다
diff가 발견되면 가장 먼저 해야 할 일은 “누가 기준인가”를 고정하는 것입니다. 보통은 원장이 기준이고(balance는 캐시), 원장을 기준으로 balance를 재생성하는 방향이 자연스럽습니다. 다만 시스템에 따라 원장을 일부 숨기거나 정정하는 정책이 있을 수 있으니, “원장 합계는 hide=0 기준” 같은 집계 기준을 먼저 확정해야 합니다.
이 기준이 흔들리면 점검 쿼리는 계속 결과가 바뀌고, 복구 작업도 오히려 데이터를 더 어지럽힐 수 있습니다.
다음 글 예고: 인덱스/실행계획(EXPLAIN)으로 점검 쿼리까지 빠르게 만들기
점검 쿼리는 데이터가 커질수록 느려지기 쉽습니다. 특히 uid별 SUM, 최근 N일 집계, ref_id 그룹핑은 원장 규모에 따라 비용이 크게 달라집니다. 다음 글에서는 EXPLAIN으로 실행 계획을 보고, 어떤 인덱스가 점검 쿼리에 직접적인 효과가 있는지, 그리고 “점검용 쿼리는 어떤 형태로 쪼개야 안전한지”를 정리합니다.
댓글
댓글 쓰기