송금 대사 배치로 원장·결제·큐 불일치 자동 감지
목차
운영 들어간 지 한 달쯤 됐을 때 파트너 한 곳에서 "어제 송금 건 합계가 안 맞는다"는 문의가 들어왔다. 정확히는 우리 원장에 잔액 차감은 찍혀 있는데 결제대행사 출금 기록이 그보다 적게 잡혀 있다는 내용이었다. 큐에 쌓인 송금 요청, PG로 넘긴 실제 출금, 원장 잔액 차감 — 이 세 레이어가 가끔 따로 노는 순간이 있다는 걸 그제서야 발견했다. 그 전까지는 사람이 매일 아침 SQL 돌려서 수동으로 맞추고 있었는데, 이 방식의 근본적인 문제는 사람이 놓친 건 영영 안 보인다는 거다. 틀렸다는 사실 자체를 모르게 된다.
원래 이런 불일치가 생기는 구조적 이유는 뻔하다. 큐 - PG - 원장이 각자 다른 트랜잭션 경계에서 상태를 기록하기 때문이다. 이상적으로는 세 곳이 atomic하게 처리돼야 하는데, 네트워크 타임아웃이나 PG 응답 지연이 끼면 어느 한 쪽만 상태가 앞서가거나 뒤처진다. 분산 시스템에서 "세 곳 중 하나라도 다르면 감지"하는 대사 레이어는 있으면 좋은 게 아니라 사고 터지기 전에 박아둬야 하는 거다.
날짜 경계 처리가 예상보다 까다로웠다
처음 설계는 단순했다. 전날 00:00~23:59:59 사이 큐에 들어온 건 vs 결제 결과를 조인하면 되겠다 싶었는데, 이 기준이 무너지는 케이스가 생각보다 많았다.
- 자정 직전 요청이 들어와 새벽 1시에 처리 완료된 건 — 큐 기준이면 T일, 출금 기준이면 T+1일
- PG 응답이 늦어서 우리 쪽은 SUCCESS 처리했는데 PG 측은 아직 PENDING인 건
- 부분 환불로 원금이 쪼개진 건 (2건처럼 보이는데 사실 1건)
- 같은 연락처, 같은 금액이 짧은 시간 안에 두 번 들어와서 중복 의심으로 보류된 건
이걸 다 커버하려면 큐 접수 시각이 아니라 확정 시각 기준으로 윈도우를 잡아야 했다. 미확정 건은 T+1에 판정하지 않고 별도 버킷으로 빼서 T+2에 재검사하도록 바꿨다. 정책 한 줄 바꾸는 데 반나절 날아갔다. 코드보다 정의가 더 어렵다는 걸 다시 실감한 부분이다.
| 버킷 | 판정 시점 | 액션 |
|---|---|---|
| 정합 | T+1 06:00 | 패스, 카운트만 집계 |
| 불일치 | T+1 06:00 | 즉시 텔레그램 경보 |
| 미확정 | T+1 06:00 | 보류, T+2 재검사 |
| 미확정 2회 | T+2 06:00 | 담당자 에스컬레이션 |
확정 시각 기준으로 잡으면 재처리(retry)된 건도 자연스럽게 정리된다. 처음 실패했다가 재시도로 성공한 건은 성공 시각 기준으로만 잡히니까 중간 상태가 노이즈로 섞이지 않는다. 나중에 생각하면 당연한 얘기인데, 처음엔 놓쳤다.
배치 분류 로직은 대략 이런 형태로 잡았다:
def classify_transfer(transfer_id, confirmed_at, ledger_amount, pg_amount):
if pg_amount is None:
return Bucket.UNCONFIRMED
if abs(ledger_amount - pg_amount) < TOLERANCE:
return Bucket.MATCHED
return Bucket.MISMATCH
def run_reconciliation(target_date):
transfers = fetch_confirmed_transfers(target_date) # 확정 시각 기준
pending = fetch_carry_over_pendings() # 전일 미확정 이월
results = defaultdict(list)
for t in transfers + pending:
bucket = classify_transfer(**t)
results[bucket].append(t)
escalate_if_needed(results[Bucket.UNCONFIRMED])
alert_mismatches(results[Bucket.MISMATCH])
return summarize(results)
TOLERANCE는 원화 1원으로 잡았다. PG마다 소수점 반올림 방식이 달라서 완전 일치 비교를 하면 오탐이 너무 많이 나온다. 처음엔 0으로 했다가 노이즈가 폭발하고 나서 수정한 거다.
알림 채널은 "가장 거슬리는 곳"이 답이다
운영자가 메일 안 본다. 슬랙도 채널이 너무 많아서 묻힌다. 결국 텔레그램이 제일 빨리 눈에 들어온다는 결론을 냈다. 매일 아침 06:05에 요약 리포트 한 통, 불일치 발생 시 즉시 한 통, 두 종류로 분리했다.
[일일 대사] 2026-03-14
정합: 1,284건 / 4.2억원
미확정: 3건 (재검사 대기)
불일치: 0건
요약 메시지는 의도적으로 5줄 이내로 제한했다. 길어지면 안 읽는다. 불일치 알림은 건별로 따로 터뜨리되, 10건 이상 동시 발생하면 묶어서 "N건 일괄 발생, 상세 링크 확인" 형태로 바꿨다. 개별 알림이 폭탄처럼 쏟아지면 그것도 결국 무시하게 된다.
알림 레벨을 둘로 나눈 것도 잘한 결정이었다. 매일 아침 리포트는 운영팀 공용 채널, 즉시 경보는 당직자 개인 채널로 분리했다. 섞어놓으면 경보 피로가 온다. "불일치 알림이 또 왔네" 하고 넘기는 순간 대사 시스템의 의미가 사라진다.
코드 리뷰에서 두 건 잡혔다
하나는 수동 재실행 버튼 권한 문제였다. 관리자 화면에서 배치를 수동으로 다시 돌릴 수 있는 버튼을 붙였는데, 급하게 작업하다 보니 그 엔드포인트의 권한 레벨이 개발용 API와 똑같이 열려 있었다. 대사 재실행은 데이터 상태를 바꾸는 작업이라 읽기 전용 운영자 권한과 명확하게 구분해야 하는데, 놓쳤다. 운영 영향은 없었지만 코드 리뷰에서 잡혀서 바로 분리했다. 식겁했다.
다른 하나는 텔레그램 봇 토큰이었다. "일단 설정 파일에 박아두고 나중에 시크릿 매니저로 옮기자" 했다가 그 상태 그대로 PR이 올라갈 뻔했다. 다행히 PR 올리기 전 diff 훑다가 직접 발견해서 시크릿 매니저 경유로 정리한 뒤 올렸다. "일단 돌려보고 나중에 정리"는 항상 위험하다. 나중에 정리된 적이 없다.
배치 올라간 뒤 운영팀에서 아침마다 SQL 돌리는 루틴이 없어졌다. 파트너 문의 대응도 달라졌다. 전에는 "확인해볼게요" 하고 쿼리 돌려야 했는데 이제 아침 리포트 링크 하나 보내면 됐다. 대사 시스템 설계할 때 핵심은 뭘 체크하느냐보다 어느 시점을 기준으로 볼 것인가였고, 그 기준이 흔들리면 어떤 코드를 짜도 오탐이 나온다는 걸 이번에 확실히 새겼다.
댓글 0
첫 댓글 달아줘.