출금을 취소했는데 잔액이 안 돌아온다는 문의가 왔다. 우리 쪽 상태부터 봤다.
Table of contents
Open Table of contents
증상 — 취소는 완료, 잔액 복구는 실패
상태 컬럼 셋을 꺼냈다.
SELECT status, balance_restore_status, restore_job_id
FROM withdrawals WHERE id = ?;
status canceled
balance_restore_status fail
restore_job_id NULL
status 는 canceled 인데 balance_restore_status 만 fail 이다.
잔액 복구 작업(restore_balance)을 찾아봤더니 그 행 자체가 없었다.
SELECT * FROM jobs WHERE ... AND job = 'restore_balance';
jobs 에 실패한 행이 남은 것이 아니라 행이 아예 안 만들어진 상태였다. 다시 돌릴 대상이 없으니 재시도로 해결될 일이 아니었다.
원인 — 발행 지점이 수수료 환불에 있었다
이 작업을 누가 만드는지 역추적했다.
grep -rn "RestoreBalanceJob" application/
한 곳이 나왔다.
// application/libraries/Withdrawal/FeeRefund.php
public function completeFeeRefund($refund)
{
...
$this->jobs->enqueue(new RestoreBalanceJob($withdrawal));
}
RestoreBalanceJob 을 큐에 넣는 줄이 취소 처리부가 아니라 completeFeeRefund 안에 있었다.
[예상] 출금 취소 → 잔액 복구 작업 생성
[실제] 수수료 환불 완료 → 잔액 복구 작업 생성
잔액 복구가 취소가 아니라 수수료 환불에 매달려 있는 구조였다. 취소만 하고 completeFeeRefund 를 안 지나가면 그 작업은 만들어지지 않는다.
수수료 환불이 없었다
그 출금에 수수료 환불이 있는지 셌다.
SELECT COUNT(*) FROM withdrawal_fee_refunds WHERE withdrawal_id = ?;
0
수수료 환불이 없으니 completeFeeRefund 를 지날 일이 없었다.
왜 환불이 없는지도 따라갔다.
SELECT fee_amount, fee_policy FROM withdrawals WHERE id = ?;
0.00000000 waived
fee_policy 가 waived 인 수수료 면제 등급의 출금이라 fee_amount 가 0 이었고, 0 인 수수료에는 환불 레코드를 만들지 않는 것이 FeeRefund 의 첫 줄에 있었다.
제약 — 구조적으로 불가능한 경로
수수료가 없으면 환불할 것이 없고, 환불이 없으면 그 뒤에 달린 잔액 복구도 없다.
잔액을 복구하려면 수수료 환불 완료가 필요
↓
수수료 0 → 환불 레코드 안 생김
↓
잔액 복구 작업 안 생김
데이터를 손봐서 될 문제가 아니라 이 경로로는 도달할 수 없는 상태였다.
몇 번을 다시 돌려도 결과가 같은 종류다. withdrawal_fee_refunds 가 안 생기는 한 이 출금에서는 잔액 복구가 성립하지 않는다.
당장 이 회원의 잔액을 돌려놓는 방법은 두 가지 방법이 있었다. 관리 화면의 잔액 조정으로 손으로 복구하는 것과 0 원짜리 환불 레코드를 만들어 경로를 타게 하는 것이다. 뒤쪽을 버리고 앞쪽을 골랐다. 이유는 0 원 환불 레코드가 수수료 정산 집계에 한 건으로 잡혀서다. 고칠 수 있는 문제와 다른 경로로 넘겨야 하는 문제를 가르는 것이 여기서 필요했다.
조치 — 바뀐 확인 순서
이 건으로 같은 문의가 왔을 때의 순서가 정해졌다.
[전] 잔액 복구 작업 확인 → 없음 → 왜 없지 → 취소 이력 확인 → ...
[후] 수수료 환불 레코드 존재 확인 → 없으면 여기서 끝
jobs 부터 보면 없다는 사실만 알고 왜 없는지는 모른다.
SELECT COUNT(*) FROM withdrawal_fee_refunds WHERE withdrawal_id = ?;
이 한 줄로 구조적으로 불가한 건인지 아닌지가 갈린다.
조사에서 먼저 볼 것을 고르는 기준이 거기 있었다. 결과를 가장 크게 가르는 조회를 앞에 두면 나머지 단계를 안 밟게 된다.
검증 — 미발생을 찾는 점검
왜 수수료 환불에 매달았는지도 짚어 봤다.
[취소 시점에 복구] 취소했지만 수수료 환불이 안 될 수도 있음
[수수료 환불 완료에 복구] 수수료까지 돌아간 뒤에 잔액을 풀어 줌
수수료 처리가 끝난 뒤에 잔액을 푸는 것은 그 자체로 안전한 순서다.
먼저 잔액을 풀고 수수료 환불이 실패하면 회원은 출금 가능 잔액을 보는데 수수료는 안 돌아간 상태가 된다. 다만 이 설계는 수수료가 항상 있다는 전제 위에 서 있고 이번 건이 그 전제가 깨지는 경우였다.
전제가 깨지면 조용히 빠지는 것이 이 구조의 성질이다.
[에러] 작업이 실패로 남음 → 눈에 띔
[미생성] 작업이 없음 → 안 보임
jobs 의 실패 목록에 안 뜨니 문의가 오기 전까지 아무도 모른다.
없는 것을 찾으려면 있어야 할 것과 대조하는 수밖에 없다.
SELECT w.id FROM withdrawals w
WHERE w.status = 'canceled'
AND w.balance_restore_status <> 'success'
AND NOT EXISTS (SELECT 1 FROM withdrawal_fee_refunds r WHERE r.withdrawal_id = w.id);
status 는 취소 완료인데 잔액 복구가 안 됐고 withdrawal_fee_refunds 도 없는 건을 찾는다.
이 수가 0 이 아니면 같은 케이스가 쌓이고 있다는 뜻이다. 앞서 본 것들도 같은 모양이었다. 실패한 중지 위에 삭제가 얹혀 사라진 건이나 승인이 필터에 걸린 건이 전부 오류가 아니라 미발생이었다.
정리
- 잔액 복구 작업이 취소가 아니라 수수료 환불 완료 시점에 발행된다
- 수수료 환불이 없으면 그 발행 지점을 안 지난다
- 실패한 작업이 아니라 작업 자체가 없으면 재시도할 대상이 없다
- 수수료가 0 인 출금은 구조적으로 그 흐름을 못 탄다
- 고칠 수 있는 문제와 다른 경로로 넘길 문제를 가른다
- 확인은 수수료 환불 레코드가 있는지부터 한다
- 결과를 가장 크게 가르는 조회를 앞에 둔다
- 수수료 처리 뒤에 잔액을 푸는 순서 자체는 안전하다
- 다만 수수료가 항상 있다는 전제 위에 있다
- 오류는 목록에 남고 미발생은 안 남는다
- 미발생은 있어야 할 것과 대조하는 점검으로만 보인다