출금 요청을 모아서 승인하는 배치를 만들고 있었다. 요청 수집과 위탁 지갑 승인 호출 사이에 간격이 있는 흐름이다.
Table of contents
Open Table of contents
배경 — 수집과 승인 사이의 간격
흐름은 이렇다.
출금 요청 수집 → 한도·이상거래 검토 (간격) → withdraw/approve 호출
출금 요청 수집 시점과 withdraw/approve 호출 시점이 다르다.
검토가 배치라서 그 사이가 수십 분이 될 수 있다. 그 간격 동안 사용자가 위탁 지갑 쪽에서 무엇을 하는지는 우리가 모른다. 코인 지갑은 우리가 직접 운영하지 않고 위탁 지갑 서비스의 JSON-RPC 를 부르는 구조였고, 출금 확인 메일과 그 메일의 「취소」 링크도 위탁 지갑 쪽이 보낸다.
원인 — 호출 하나에 딸린 둘
질의응답을 뒤지다 걸린 것이 있었다. cancel_requested 상태인 출금에 withdraw/approve 를 부르면 이렇게 된다.
출금 승인 (서명·브로드캐스트)
+ 사용자의 취소 요청 철회
출금 승인 만 되는 것이 아니라 취소 요청 철회 가 함께 실행된다.
우리 배치에 대입하면 이런 순서가 나온다.
10:00 출금 요청 수집 — 취소 요청 없음
10:05 사용자가 확인 메일의 「취소」를 누름
10:30 withdraw/approve 호출
↓
취소 요청이 자동 철회됨
트랜잭션이 실제 브로드캐스트됨
10:05 에 취소했는데 코인이 나가고 그 취소는 기록에서 사라진다. 브로드캐스트된 트랜잭션은 되돌릴 수 없어서 쇼핑몰의 출고보다 무거운 결과다.
막을 수 없었다
취소 철회 여부 를 끄는 파라미터가 있는지 찾았지만 없었다. 공급자 답변은 이랬다.
현재 withdraw/approve 에 취소 철회 여부를 설정하는 파라미터를 제공하고 있지 않습니다.
... 승인 직전에 상태를 조회하시면 그 간격이 매우 짧아 이슈가 발생할 가능성은 낮을 것으로 보입니다.
파라미터를 제공하고 있지 않습니다 라는 말과 확률이 낮다는 말이 전부다.
여기서 확률이 낮다는 것은 조건이 붙은 말이다. 요청 수집과 승인 호출이 거의 붙어 있는 실시간 승인을 전제로 한 판단으로 읽힌다.
[실시간 승인] 간격 수 초
[검토 배치] 간격 수십 분
우리 쪽은 검토 배치 라서 같은 말을 그대로 받을 수 없었다.
제약 — 두 동작이 같은 경로
왜 분리가 안 되는지에는 이유가 있었다.
거래소 운영자가 사용자 취소를 거부하는 수단이 애초에 이 경로로만 제공된다. 서명이 이미 시작된 출금은 취소를 받아 줄 수 없으니 그 거부가 withdraw/approve 에 실려 있는 것이다. 취소 거부 와 출금 승인 이 같은 호출인 셈이다.
그러면 분리는 구조적으로 불가능하다. 파라미터를 안 만든 것이 아니라 두 동작이 하나로 설계돼 있어서 뗄 자리가 없다.
우리 쪽에서 할 수 있는 것은 호출을 안 하는 것뿐이고 그러려면 cancel_requested 를 미리 알아야 했다.
대응 — 직전 재확인
방어 수단이 하나 남아 있었다.
수집 → 검토 → [withdraw/status 재확인] → withdraw/approve
↑
여기서 취소면 건너뜀
부르기 직전에 withdraw/status 로 상태 재확인 을 넣고 cancel_requested 면 그 건은 건너뛴다.
$state = $this->wallet->call('withdraw/status', ['ids' => $ids]);
$approve = [];
foreach ($state['result'] as $row) {
if ($row['status'] === 'cancel_requested') {
$this->withdrawals->markSkipped($row['id'], 'cancel_requested_at_recheck');
continue;
}
$approve[] = $row['id'];
}
$this->wallet->call('withdraw/approve', ['ids' => $approve]);
이것도 틈은 남는다.
withdraw/status (취소 없음)
↓ 이 사이
취소 요청 들어옴
↓
withdraw/approve → 철회됨
상태 재확인 과 withdraw/approve 사이가 남고 그 간격이 짧아서 확률이 낮아질 뿐이다.
공급자가 말한 상황으로 돌아온 셈인데 그래도 안 넣는 대신 넣는 쪽을 골랐다. 이유는 안 넣으면 수십 분 간격의 취소가 전부 철회되고 넣으면 수 초 간격의 취소만 위험해져서다.
없앨 수는 없지만 위험 구간을 수십 분에서 수 초로 줄이는 것이 이 한 번의 조회가 하는 일이었다.
검증 — 판정 시각을 남긴다
막지 못하는 위험이 남으면 그것이 일어났는지 알 수 있어야 한다.
withdraw/status 에서 취소 없음으로 판정한 시각과 그때의 출금 ID 목록을 withdrawal_approvals 에 남기게 했다. 나중에 문의가 오면 그 시각과 위탁 지갑 쪽 취소 시각을 대조할 수 있다.
기록이 없으면 부수 효과로 철회된 것인지 사용자가 취소를 안 누른 것인지 가릴 수 없고 사용자 문의로만 알게 된다. 막을 수 없는 위험은 최소한 사후에 식별할 수 있어야 했다.
이 건에서 남은 것은 형태 쪽이다. 호출 하나가 문서에 없는 부수 효과를 함께 실행한다는 형태를 알고 나면 cancel_requested 와 승인을 함께 다루는 다른 자리도 의심하게 된다.
같은 질의응답에서 다른 제약도 함께 나왔다.
호출당 최대 20개 출금 ID
부분 승인(금액 일부만 전송) 미지원
이미 브로드캐스트된 출금 재승인 시 오류 코드 -32001
-32001 은 JSON-RPC 2.0 명세가 -32000 부터 -32099 까지를 구현 정의 서버 오류로 예약해 둔 구간의 값이라, 우리 클라이언트는 그 구간을 「공급자 정의 오류」로 따로 받게 했다. 최대 20개 와 부분 승인 미지원 과 재승인 오류가 묶음 크기와 부분 전송과 중복 방지를 여기서 갈랐다. 만들다가 하나씩 만났으면 그때마다 앞으로 돌아가야 했을 것이다.
정리
- 호출 하나가 문서에 없는 부수 효과를 함께 실행할 수 있다
- 막는 파라미터가 없고 두 동작이 같은 경로면 분리가 불가능하다
- 취소 거부와 출금 승인이 같은 호출이라 뗄 자리가 없었다
- 확률이 낮다는 답은 조건이 붙은 답이다
- 간격이 있는 배치에서 그 조건이 깨진다
- 직전에 다시 확인하면 위험 구간이 수십 분에서 수 초가 된다
- 재확인과 호출 사이는 그대로 남는다
- 없앨 수 없으면 남는 부분을 알고 둔다
- 판정 시각과 대상 목록을 남기면 사후에 대조된다
- 같은 형태의 함정을 비슷한 자리에도 의심한다
- 제약을 한 번에 알고 설계하는 편이 훨씬 싸다