BE-commerce 프로젝트
약 4분 분량 결제·정산데이터베이스

현재 잔액과 거래 사건을 분리해야 하는 이유

목차

1만 원짜리 결제를 승인하고 PG 정산 파일과 대사를 돌렸습니다. 내부 기록도 10,000원, PG 기록도 10,000원이라 대사는 MATCHED로 끝났습니다. 그 뒤 고객이 3,000원을 부분취소했습니다.

대사 당시 내부 10,000 PG 10,000 판정 MATCHED
↓ 3,000원 부분취소
지금 내부 7,000 PG 10,000 판정 MATCHED (그대로)

이미 끝난 판정은 그대로인데 그 판정을 만든 근거가 바뀌어 있었습니다.

결론부터

  • 현재 잔액은 최신 상태를 보여 주지만, 과거 거래일의 대사 근거를 대신할 수 없습니다.
  • 승인과 부분취소를 한 행에 덮어쓰면 이미 확정한 MATCHED의 근거가 사라집니다.
  • 승인·취소·환불을 사건별 행으로 쌓고 현재 잔액은 그 사건을 합산해 계산해야, 과거 판정과 현재 상태를 함께 보존할 수 있습니다.

대사에서 필요한 것은 “지금 얼마가 남았는가”와 “그날 어떤 사건이 있었는가”를 같은 값으로 표현하지 않는 데이터 모델입니다.

취소가 승인 행을 덮어쓰고 있었다

원인은 취소 이벤트를 받을 때 승인 행의 금액을 덮어쓰고 있었던 것입니다.

// 대사 모듈 — 취소 이벤트를 받으면
record.applySettleableBalance(남은금액);

처음에는 자연스러운 구현이라고 생각했습니다. 결제의 현재 잔액은 7,000원이 맞으니까요. 그런데 대사가 필요한 것은 “지금 이 결제에 얼마가 남았는가”가 아니었습니다. 대사는 특정 거래일에 무슨 거래가 발생했는가를 PG 기록과 맞추는 작업입니다.

로컬에 띄워 재현했습니다. 1만 원 승인 → PG 정산 파일 업로드 → 대사 MATCHED 확정 → 3,000원 부분취소.

내부 스냅샷(지금) 7000
대사 판정(확정된 것) 10000

갈라졌습니다. 대사는 “이 거래는 맞았다”고 결론을 내리는 작업이고, 그 결론이 나간 뒤 근거가 조용히 바뀌면 확인하러 온 사람에게 판정과 데이터가 안 맞는데 다시 돌릴 계기가 없습니다. 취소가 사흘 뒤 오면 그 사흘 전 날짜를 아무도 다시 보지 않으니까요. 틀린 것이 아니라, 틀린 것을 아무도 모르는 상태가 됩니다.

파고드니 갈라진 자리가 하나가 아니었습니다.

  • 환불은 원 거래 행 수정이 아니라 취소일 파일에 음수 행으로 오는데, 우리는 원 거래일 행을 덮어써 취소일 대사에서 정상 환불이 예외 큐에 쌓였습니다
  • 원장은 취소를 역분개로 쌓는데 대사 스냅샷만 덮어써, 같은 사건을 두 모듈이 다르게 기록했습니다
  • 취소 이벤트에 취소 시각이 없어 소비 시점 현재 시각을 쓰면, 아웃박스가 밀릴 때 기간 경계 문제가 그대로 생깁니다

고친 방향: 현재 상태가 아니라 사건을 쌓는다

승인과 취소를 각각의 거래일에 별도 행으로 쌓았습니다. 원 거래 행은 손대지 않으니 이미 나간 판정이 유효하고, 취소일 대사에서 그 날의 음수 행끼리 대조돼 PG 파일 모양과 같아집니다.

seq 0 : +10,000 (승인일)
seq 1 : -3,000 (취소일)

기존 유니크는 order_no 하나(주문당 한 행)였습니다. 없애면 될 것 같지만 그 제약은 아웃박스 재시도로 같은 취소가 두 번 들어오는 것을 막던 장치이기도 했습니다. 없애는 대신 넓혔습니다. 멱등성을 코드만으로 지키면 언젠가 뚫리니 DB가 마지막 방어선이어야 합니다.

ALTER TABLE internal_records ADD COLUMN seq INT NOT NULL DEFAULT 0;
ALTER TABLE internal_records DROP INDEX uk_internal_record_order;
ALTER TABLE internal_records ADD CONSTRAINT uk_internal_record_order_seq
UNIQUE (order_no, seq);

취소 순번은 결제 도메인이 이미 부여하던 값을 그대로 씁니다. 대사가 새로 매기면 같은 취소인지 판단할 근거가 사라집니다.

그리고 취소 반영 코드는 승인 스냅샷이 없으면 그대로 넘어갔습니다.

if (internalRecords.findByOrderNo(event.orderNo()).isEmpty()) {
return; // 승인 스냅샷이 없는 결제 — 대사 대상이 아닙니다
}

의도는 “대사 대상이 아닌 결제”를 거르는 것인데 이벤트 순서가 역전되면 취소가 조용히 버려집니다. 외부 리뷰가 이 지점을 짚어줘서 버리지 않고 예외를 던지게 고쳤습니다. 리스너가 예외를 던지면 이벤트가 미완료로 남아 아웃박스가 다시 배달하고 그때는 스냅샷이 있습니다. 별도 보류 큐를 만들 필요가 없었습니다. 취소는 승인된 결제에만 발행되니 스냅샷이 영원히 안 생기는 경우는 도메인상 없습니다.

같은 이름의 메서드가 정산에도 있었다

작업 중 정산 모듈에도 applySettleableBalance가 있는 것을 보고 전역 검색해서 한꺼번에 고칠 뻔했습니다. 정산 쪽은 덮어쓰는 것이 맞았습니다.

정산은 “지금 얼마를 지급할 것인가”라는 현재 상태라 값이 하나뿐이고, 대사는 “무슨 일이 있었나”라는 사건 이력이라 쌓아야 합니다. 일괄 수정했으면 멀쩡한 정산을 망가뜨렸을 것입니다.

대상고치기 전고친 후
승인 행10,000 → 7,000으로 덮어씀10,000 그대로 (seq=0)
취소기록 없음−3,000 별도 행 (seq=1)
확정된 판정10,000, 스냅샷과 갈라짐10,000, 일치 유지

같은 취소 이벤트를 두 방식으로 기록한 대조. 승인 행을 10,000에서 7,000으로 덮어쓰면 확정된 판정 10,000과 갈라지고, 취소일 음수 행(seq=1, −3,000)을 따로 쌓으면 승인 행 10,000 그대로에 판정과 일치가 유지된다

검증

테스트 5건으로 고정했습니다. 음수 행으로 쌓이는지, 거래일이 취소일 기준인지, 같은 취소가 두 번 와도 한 번만 들어가는지, 승인 스냅샷이 없으면 예외를 던져 재배달로 넘기는지, 같은 날 승인과 취소가 다 있으면 합산되는지입니다. 전체 스위트는 이 시점 591개 통과했습니다.

마무리

화면에 보이는 값 하나를 고치는 일로 시작했는데, 고칠 것은 값이 아니라 그 테이블이 무엇을 표현하는가였습니다. 현재 상태를 담는 자리에 사건 이력을 덮어쓰면 판정이 나간 뒤에도 근거가 계속 바뀌고, 바뀐 것을 아무도 모릅니다.

이어서 읽기

이 글에서 이어진 작업은 따로 적었습니다.

참고

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.