일기 slecs

결제 웹훅 원장·잔액 대조 배치로 정산 분쟁 추적 가능해짐

목차

결제 관련 분쟁이 터졌을 때 제일 먼저 듣는 말이 "그때 웹훅 정말 왔어요?"다. 문제는 이 질문에 자신 있게 답할 수 있는 팀이 거의 없다는 것. 처리 로그는 있지만 웹훅 자체가 왔는지, 어떤 페이로드였는지, 서명이 통과됐는지는 별도로 안 남기는 경우가 많다. 우리도 그랬고, 그 대가를 분쟁으로 치렀다.

웹훅 원장 기록부터 깔았음

정산 분쟁 한 번 터지고 나서 방향이 확정됐다. 웹훅이 들어올 때 처리만 하고 흘려보내던 구조를 바꿔서 헤더·페이로드·서명검증 결과·처리 결과까지 전부 원장으로 적재하기로 했다.

설계할 때 기준을 두 가지로 잡았음.

원본 불변성이 첫째다. 들어온 raw payload는 가공 없이 그대로 저장한다. 정산 분쟁은 "우리가 받은 게 뭐냐"부터 시작하기 때문에 파싱한 결과물이 아니라 수신한 원본 그대로가 증거가 돼야 한다. 가공한 데이터는 변환 로직이 틀렸을 수도 있어서 원장으로서 가치가 떨어진다. 헤더도 같이 저장하는 이유가 여기 있다. 서명검증은 헤더 값과 페이로드를 조합해서 하기 때문에, 나중에 검증 자체가 올바르게 됐는지 재현할 수 있어야 한다.

단계별 상태 코드 분리가 둘째다. 수신완료 / 검증완료 / 반영완료 / 실패 네 단계로 나눴다. 실패 사유는 enum으로 떨어뜨려서 집계가 되도록. 상태 코드 없이 처리 결과만 있으면 중간 어디서 깨졌는지 역추적이 안 된다.

CREATE TABLE webhook_ledger (
  id              BIGSERIAL PRIMARY KEY,
  received_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  provider        TEXT NOT NULL,
  event_type      TEXT NOT NULL,
  raw_header      JSONB NOT NULL,
  raw_payload     JSONB NOT NULL,
  sig_verified    BOOLEAN NOT NULL,
  status          TEXT NOT NULL,  -- received | verified | applied | failed
  fail_reason     TEXT,           -- SIG_MISMATCH | IDEMPOTENCY_CONFLICT | DB_ERROR ...
  idempotency_key TEXT UNIQUE,
  processed_at    TIMESTAMPTZ
);

idempotency_key는 처음부터 잡아두는 게 맞다. 결제대행사는 응답을 못 받으면 재전송한다. 웹훅이 두 번 오면 두 번 처리되는 구조라면, 그게 멱등성 깨진 재처리의 시작이고 나중에 잔액 불일치의 원인 중 하나가 된다. UNIQUE 제약으로 DB 레벨에서 차단하는 게 애플리케이션 레벨 중복 체크보다 안전하다. 레이스 컨디션 여지가 없어짐.

이 원장 하나 깔고 나서 grep으로 한 시간씩 로그 뒤지던 게 사라졌다. "이번 건 웹훅은 왔는데 verified 단계에서 sig_verified=false로 끊겼음"이라고 2분 만에 리포트할 수 있게 됐음. 팀 입장에서는 이게 가장 체감이 컸다.

잔액 동기화 보정 알림

파트너 잔액이 결제대행사 측이랑 가끔 어긋난다. 원인은 크게 세 갈래다. 환불 처리 타이밍 차이, 수수료 차감 라운딩 오차, 멱등성 깨진 재처리. 셋 다 단독으로도 발생하고 복합으로도 발생한다. 복합이면 원인 추적이 훨씬 귀찮아짐.

매번 사람이 발견해서 보정 쿼리 돌리는 게 비효율이라 배치를 깔았는데, 설계하면서 제일 오래 논의한 게 "자동 보정을 넣느냐 마느냐"였다.

결론은 안 넣는 것.

차이 구간 판단 액션
정상 내부 = 외부 무동작
경고 차이 1만원 미만 알림만
즉시 차이 1만원 이상 알림 + 보정 일시중지

자동으로 보정하면 "보정이 맞다"는 전제가 필요하다. 그 전제가 틀렸을 때 사람이 개입할 타이밍을 놓친다. 잔액이 어긋난 이유가 버그라면 자동 보정 배치가 그 버그를 덮는 방향으로 계속 돌게 된다. 디버깅 시점을 놓치고, 틀린 상태가 정상처럼 보이기 시작한다. 그리고 나중에 감사 시점에 누적된 보정 이력이 원인 파악을 더 어렵게 만든다.

이건 정산 도메인에서 꽤 알려진 패턴이다. 자동화는 감지에 쓰고, 보정은 사람이 한다. 자동 보정이 필요한 케이스는 패턴이 충분히 쌓이고 원인이 명확하게 특정된 이후에 만드는 게 순서다. 지금 단계에서 자동 보정을 넣었다가 원인을 잘못 특정하면 그때부터 데이터가 조용히 틀려나간다. 데이터 무결성은 자동화할수록 발견이 늦어진다는 게 이번 설계의 핵심 판단이었음.

임계치 1만원은 일단 이 정도로 잡았는데, 라운딩 오차 누적이라면 건당 몇 원 차이도 시간이 지나면 쌓이기 때문에 절대 금액 기준만 보면 안 되고 기간 누적치도 같이 봐야 할 수 있다. 내일 실제 트래픽 패턴 보면서 튜닝할 예정.

거래 대조 배치

매일 새벽 결제대행사 일별 명세를 내려받아 내부 거래내역과 한 줄씩 매칭한다. 매칭 결과는 네 케이스로 떨어진다.

[1] 양쪽 모두 존재 + 금액 일치     → OK
[2] 외부만 존재                    → 누락 의심 (웹훅 유실?)
[3] 내부만 존재                    → 미정산 또는 대행사 미반영
[4] 양쪽 모두 존재 + 금액 불일치   → 즉시 에스컬레이션

[2]번이 생각보다 자주 나온다. 이때 웹훅 원장이랑 교차 조회하면 딱 갈린다.

  • 원장에 status = applied 기록 있음 → 웹훅 왔고 처리됐는데 대조 매칭 로직 버그일 가능성
  • 원장에 status = failed 기록 있음 → 웹훅은 왔는데 처리 단계에서 실패, 재처리 필요
  • 원장에 기록 자체 없음 → 웹훅이 아예 안 왔음, 대행사 측 확인 요청

이 세 갈래가 대응 방법이 완전히 다르다. 원장 없이는 구분이 안 되고, 결국 대행사한테 "그때 웹훅 보내셨나요?" 물어보는 수밖에 없다. 대행사 측 담당자 응답 대기만 며칠 걸리는 경우도 있음. 원장 깔길 잘했다 싶었던 게 딱 이 순간이었다.

[3]번은 라벨링 룰이 아직 덜 다듬어졌다. 단순 타이밍 차이인지, 대행사 측 미정산인지, 내부 반영을 빠뜨린 건지 — 이 분류 기준이 없으면 케이스만 쌓이고 처리가 밀린다. 내일 이 부분 마저 정리할 계획.

웹훅 원장과 대조 배치가 한 사이클에서 같이 들어간 게 결과적으로 맞는 순서였다. 원장 없이 대조 배치만 있었으면 [2]번 케이스에서 결국 수동 조사로 다시 내려갔을 거다. 그리고 원장 깔고 나서 대조 배치를 뒤늦게 붙이는 것보다, 처음부터 같이 설계해야 교차 조회 구조가 자연스럽게 나온다. 두 시스템이 서로를 보완하는 구조로 처음부터 맞물려 있어야 한다는 것.

원장은 비싸 보인다. 스토리지 먹고, 스키마 설계 시간 먹고, 인덱스 관리 필요하다. 근데 분쟁 하나 터지면 그 원장이 가장 싼 자산이 된다. 원장 없이 사람 시간으로 때우는 비용이 훨씬 크다는 걸 한 번 겪고 나면 다음 시스템 만들 때 첫 번째로 깔게 된다.

  • 원장은 분쟁 비용 대비 가장 저렴한 보험
  • 자동 보정보다 자동 감지가 먼저, 보정은 사람이
  • 대조 배치는 웹훅 원장이랑 짝일 때 위력이 두 배

내일은 알람 임계치 튜닝이랑 [3]번 케이스 라벨링 룰 마저 다듬는 걸로.

댓글 0

첫 댓글 달아줘.