Skip to content
isdnetworks
Go back

잔액 복구가 수수료 환불에 매달려 있었다

(수정됨)

출금을 취소했는데 잔액이 안 돌아온다는 문의가 왔다. 우리 쪽 상태부터 봤다.

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 이 아니면 같은 케이스가 쌓이고 있다는 뜻이다. 앞서 본 것들도 같은 모양이었다. 실패한 중지 위에 삭제가 얹혀 사라진 건이나 승인이 필터에 걸린 건이 전부 오류가 아니라 미발생이었다.

정리


Share this post on:

Previous Post
다섯으로 수렴한 실패 사유
Next Post
두 갱신이 서로를 기다렸다