SQL 누적 합계가 틀릴 때: 같은 시각·취소 거래·기초잔액 검산
AI Editter
거래별 누적 잔액을 만들었는데 같은 시각의 두 거래에 동일한 잔액이 표시된다면 윈도 프레임과 정렬 기준을 확인해야 합니다. SUM OVER를 썼다는 사실만으로 거래 한 줄마다 원하는 순서의 잔액이 만들어지는 것은 아닙니다.
또 다른 흔한 문제는 조회 기간을 먼저 잘라 이전 거래를 누적 계산에서 빼는 것입니다. 같은 SQL이라도 “기간 내 증감 합계”와 “실제 계좌 잔액”은 서로 다른 결과를 요구합니다.
쿼리를 실행하기 전에 정답표를 만든다
기초잔액이 100이고 입금 50, 출금 20, 이전 입금의 취소 50이 순서대로 발생했다면 잔액은 150→130→80입니다. 마지막 거래만 보고 최종잔액 80이 맞는지 확인하는 것보다 각 행의 잔액을 비교해야 정렬 오류를 잡을 수 있습니다. 같은 시각의 거래에는 업무상 순서를 정하는 고유 키를 두고, 취소를 원거래 삭제로 처리할지 반대 부호 거래로 기록할지도 구분하세요.
네 건의 거래로 정답 만들기
기초 잔액은 500원이라고 가정합니다. t1은 입금 100원, t2는 출금 30원, t3은 입금 50원, t4는 t2의 취소로 30원을 되돌리는 거래입니다. t2와 t3의 시각은 같지만 업무 순서 번호가 다릅니다.
| 순서 | 거래 | 증감 | 기대 잔액 |
|---|---|---|---|
| 1 | t1 | +100 | 600 |
| 2 | t2 | -30 | 570 |
| 3 | t3 | +50 | 620 |
| 4 | t4, t2 취소 | +30 | 650 |
취소 행이 존재한다면 원거래를 누적 대상에서 동시에 제거하지 않습니다. t2를 제외하면서 t4도 더하면 취소 금액을 이중 반영해 최종 잔액이 680원이 됩니다.
ROWS와 고유한 순서를 명시한 예제
다음은 PostgreSQL에서 실행할 수 있는 조회 전용 예제입니다. 같은 시각에서는 seq가 업무 순서를 정하고, seq는 계좌 안에서 유일하다고 가정합니다.
WITH ledger(account_id, seq, txn_id, booked_at, amount) AS (
VALUES
('A', 1, 't1', TIMESTAMP '2026-01-01 09:00:00', 100),
('A', 2, 't2', TIMESTAMP '2026-01-01 09:01:00', -30),
('A', 3, 't3', TIMESTAMP '2026-01-01 09:01:00', 50),
('A', 4, 't4', TIMESTAMP '2026-01-02 10:00:00', 30)
),
balances AS (
SELECT *,
500 + sum(amount) OVER (
PARTITION BY account_id
ORDER BY booked_at, seq
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS balance
FROM ledger
)
SELECT txn_id, amount, balance
FROM balances
ORDER BY booked_at, seq;
예제의 500은 계좌 A의 기초 잔액입니다. 실제로 여러 계좌를 계산할 때는 계좌별 기초 잔액 테이블과 연결해야 하며 모든 계좌에 같은 숫자를 더하면 안 됩니다.
PostgreSQL 윈도 함수 문서는 기본 프레임과 동률 행의 관계를 설명합니다. 거래별 결과가 필요하면 프레임을 명시하는 것과 정렬 순서를 유일하게 만드는 것을 함께 처리합니다.
같은 시각의 순서를 어떻게 정할 것인가
시간 뒤에 임의 UUID를 붙이면 결과를 반복 가능하게 만들 수는 있습니다. 그러나 그것이 실제 업무 순서라는 보장은 없습니다. 원장에 확정 순서가 있다면 그 번호를 사용하고, 없다면 동일 시각의 거래를 묶은 잔액만 제공하는 편이 더 정확할 수 있습니다.
서로 다른 시스템에서 수집된 거래는 수집 시각과 발생 시각도 다릅니다. 지연 도착 거래를 과거 위치에 넣으면 과거 잔액들이 다시 계산됩니다. 이미 마감한 잔액을 고정할지, 정정 버전을 만들지 업무 규칙을 정해야 합니다.
기간 필터는 누적 후 적용해야 할 때가 있다
위 예제에서 1월 2일 거래만 보고 싶다면 balances를 만든 뒤 최종 SELECT에 다음 조건을 추가할 수 있습니다.
WHERE booked_at >= TIMESTAMP '2026-01-02 00:00:00'
AND booked_at < TIMESTAMP '2026-01-03 00:00:00'
최종 ORDER BY 앞에 넣습니다. 이 경우 t4의 잔액은 650원입니다. ledger에서 1월 1일 거래를 미리 제거하면 500+30=530원이 되어 실제 잔액과 다릅니다.
대용량 원장을 매번 처음부터 읽기 어렵다면 확정된 기준일 잔액과 그 이후 거래를 합산하는 방식을 사용합니다. 기준일 잔액이 이미 포함한 마지막 거래와 증분의 시작 경계를 맞춰 이중 계산을 방지합니다.
합계가 맞아도 검사할 예외
| 예외 | 필요한 검사 |
|---|---|
| txn_id 중복 수집 | 같은 거래가 두 번 들어왔는지 |
| NULL 금액 | 0원인지 오류인지 구분 |
| 부분 취소 | 원거래 금액보다 취소 누계가 큰지 |
| 통화 혼합 | 계좌·통화별 파티션 또는 환산 기준 |
| 정렬키 동률 | 거래별 순서를 결정할 수 있는지 |
| 기초 잔액 일자 오류 | 포함 구간 중복·누락 여부 |
금액은 부동소수점 오차가 생기지 않도록 업무에 맞는 정밀한 타입을 선택합니다. 최종 잔액이 맞아도 서로 반대인 두 오류가 상쇄될 수 있으므로 거래 수, 입금 합계, 출금 합계, 취소 연결까지 대조합니다.