정산 흐름에 상태 단계·멱등성 처리 추가로 결제 정합성 강화
목차
settlement 영역은 건드릴 때마다 긴장된다. 숫자가 뒤따라오는 코드라서. 이번에는 정산 흐름 자체에 상태 단계를 추가하고, 중복 처리를 막는 멱등성 키를 도입했다. 동시에 가상계좌·간편결제 hold 시간을 2시간에서 2시간 10분으로 늘리고, 잔액 스냅샷 cron 실행 시각을 02:05에서 02:15로 뒤로 밀었다. 변경 파일은 내부 클래스 3개, 설정/문서 2개, SQL 매퍼 1개. 작은 수정들처럼 보이지만, 맥락을 알아야 왜 이렇게 됐는지 이해가 된다.
hold 시간과 cron 시각을 조정한 이유
가상계좌와 간편결제는 외부 PG의 콜백이 수 초에서 수 분까지 지연될 수 있다. hold 2시간은 운영 초기에 충분하다고 봤는데, 실제로 경계 케이스가 생겼다. hold가 정각 만료되는 시점에 콜백이 들어오면, hold 해제 로직과 콜백 처리가 겹쳐서 정산 상태가 애매한 구간이 발생한다. 10분 버퍼를 추가하면 그 구간이 사라진다.
cron 시각 변경도 같은 맥락이다. 잔액 스냅샷은 하루 중 트래픽이 가장 낮은 새벽 2시대에 돌리는데, 02:05에 실행하면 hold 해제가 완전히 끝나기 전에 스냅샷이 찍히는 케이스가 생긴다. 02:15로 밀면 hold 해제와 PG 콜백 후처리까지 다 마친 뒤에 스냅샷이 찍힌다.
"10분이면 충분한가"는 직감이 아니라 PG 콜백 SLA 기준으로 판단했다. 숫자 자체보다 그 숫자를 어디서 역산했는지가 중요하다. 근거 없이 "여유롭게 더 줬다"고 박으면 나중에 다시 건드릴 때 기준이 없어진다.
상태 머신 확장과 멱등성 처리
기존 settlement 흐름은 상태 구분이 단순했다. 이번에 PENDING → CONFIRMED 단계를 명시적으로 추가하고, 취소/환불 발생 시 정산 금액을 동기화하는 로직을 붙였다. 감사 이력 테이블도 연동했다.
상태를 명시적으로 관리하면 두 가지가 달라진다. 하나는 디버깅이다. 어떤 시점에 어떤 상태였는지 audit 테이블을 조회하면 바로 확인할 수 있다. 다른 하나는 중복 처리 방어. "이미 CONFIRMED면 다시 처리하지 않는다"는 guard를 상태 기반으로 자연스럽게 구현할 수 있다.
SQL 매퍼 수준에서는 상태 전환을 낙관적 잠금으로 처리했다.
UPDATE settlement
SET status = 'CONFIRMED',
confirmed_at = NOW(),
updated_at = NOW()
WHERE id = #{id}
AND status = 'PENDING' -- 이 조건이 낙관적 잠금 역할을 한다
WHERE status = 'PENDING' 조건이 없으면 이미 CONFIRMED된 정산에 다시 덮어쓰기가 들어갈 수 있다. rows affected = 0이면 이미 처리된 것으로 보고 상위 레이어에서 멱등하게 끝낸다. 별도 분산 락이나 SELECT FOR UPDATE 없이도 상태 전환 자체가 원자성을 보장해주는 구조다.
멱등성 키는 PG 콜백 재시도 시나리오에서 특히 필요하다. 같은 결제 이벤트가 두 번 들어왔을 때 정산이 두 번 생기면 안 되니까. 키 구성은 payment_id + event_type 조합으로 unique constraint를 걸었다. INSERT 시 duplicate key exception이 터지면 무시하거나 기존 row를 리턴하는 방식으로 처리한다. at-least-once delivery를 쓰는 웹훅이나 메시지 큐 환경에서는 이 패턴이 사실상 필수다.
취소/환불 동기화는 별도 상태를 두지 않고 정산 금액을 차감하는 방식을 택했다. 상태를 새로 만들면 전환 경우의 수가 기하급수적으로 늘어나는데, 지금 구조에서는 금액 보정이 더 깔끔했다. 상태 수가 늘어날수록 예외 처리 조합도 같이 늘어난다는 걸 과거에 한 번 겪었다.
설계할 때 확인한 항목들
결제/정산 도메인에서 새 기능을 끼워 넣을 때 반드시 따져보는 것들이 있다.
| 체크 항목 | 이번 결정 |
|---|---|
| 실시간 갱신 필요 여부 | 정산 확정은 배치로 충분, 폴링 불필요 |
| 쿼리 실행 계획 | status 단독 인덱스 대신 복합 인덱스(date + status) 확인 |
| NULL 방어 | 금액 집계 시 COALESCE 처리 |
| 권한 체크 | 서비스 레이어에서 소유자 검증 |
| 롤백 시나리오 | 상태 전환 실패 시 이전 상태 그대로 유지 |
쿼리 실행 계획은 꼭 본다. status 컬럼은 카디널리티가 낮아서 단독 인덱스를 타더라도 효율이 떨어지는 경우가 있다. 복합 인덱스로 바꿨을 때 실행 계획이 확연히 달라진 걸 경험해봤기 때문에, 이번에도 그쪽부터 확인했다.
구현 후에는 변경 전후 화면 숫자를 크로스체크했다. 취소/환불 케이스는 직접 만들어서 정산 금액이 예상대로 반영되는지 확인했고, 관련 화면이 여러 개면 같은 데이터가 모두 일치하는지도 본다. "대충 맞는 것 같다"로 넘기면 나중에 반드시 다시 돌아온다. 금융 도메인에서 이건 경험으로 증명이 됐다.
커밋은 논리적으로 독립된 단위로 쪼갰다. hold 시간 변경, cron 시각 변경, 상태 전환 로직, 멱등성 키, audit 연동을 각각 따로 커밋했다. 문제가 생겼을 때 어느 변경에서 깨졌는지 git bisect로 바로 추적할 수 있다. 커밋 메시지는 "무엇을"보다 "왜"를 담으려고 한다. hold 시간 변경보다 PG 콜백 지연 버퍼 확보를 위해 hold 연장이 몇 달 뒤에 훨씬 유용하다.
사내 서비스를 만들다 보면 기능 하나가 단순히 화면 버튼 추가로 끝나지 않는다는 걸 계속 체감한다. SQL 집계, 상태 머신, 예외 처리, 화면 렌더링, 권한 체크가 모두 엮여 있어서 어느 하나만 빠뜨려도 숫자가 맞지 않거나 특정 케이스에서 이상한 동작이 나온다. 특히 결제/정산 도메인은 숫자 하나가 틀리면 신뢰가 흔들릴 수 있어서, 꼼꼼함이 선택이 아니라 기본값이어야 한다.
댓글 0
첫 댓글 달아줘.