출금 처리가 몇 건 안 됐다는 것을 뒤늦게 알았다. 다시 돌리려는데 어느 것인지 찾을 수가 없었다.
Table of contents
Open Table of contents
증상 — 실패한 것이 안 남아 있었다
실패를 다루는 자리를 봤다.
foreach ($requests as $req) {
try {
$this->withdraw->process($req);
} catch (Throwable $e) {
log_message('error', "출금 실패: {$req->no} - {$e->getMessage()}");
}
}
log_message 로 로그에만 남는데 로그는 하루 지나면 회전해서 사라진다.
grep 으로 번호를 뽑아도 그 뒤에 무엇을 했는지는 알 수가 없다. 그래서 다시 돌린 것과 아직 안 돌린 것을 구분할 방법이 없었다.
조치 — 상태를 남기게 했다
표에 상태 컬럼을 더했다.
ALTER TABLE withdraw_request
ADD COLUMN process_state varchar(20) NOT NULL DEFAULT 'PENDING',
ADD COLUMN fail_reason varchar(200) DEFAULT NULL,
ADD COLUMN fail_count int NOT NULL DEFAULT 0,
ADD COLUMN last_tried_at datetime DEFAULT NULL;
try {
$this->withdraw->process($req);
$this->repo->markDone($req->no);
} catch (Throwable $e) {
$this->repo->markFailed($req->no, $e->getMessage());
}
markDone 과 markFailed 로 결과가 표에 남는다.
SELECT * FROM withdraw_request WHERE process_state = 'FAILED';
process_state 하나로 실패한 것이 뽑힌다.
로그는 왜 실패했는지를 알려 주고 process_state 는 지금 어떤지를 알려 준다. 둘이 서로 다른 물음에 답하는 것이므로 양쪽 다 있어야 했다.
판단 기준 — 실패 이유의 종류
이유가 문자열로만 있으면 세기가 어려웠다.
class WithdrawFailure extends Exception {
public function __construct(
public readonly string $code, // INSUFFICIENT / NODE_DOWN / INVALID_ADDRESS
string $message,
) { parent::__construct($message); }
}
code 를 두니 종류별로 셀 수 있게 됐다.
SELECT fail_code, COUNT(*) FROM withdraw_request WHERE process_state='FAILED' GROUP BY fail_code;
NODE_DOWN 41
INSUFFICIENT 8
INVALID_ADDRESS 2
NODE_DOWN 은 다시 돌리면 되고 INVALID_ADDRESS 는 다시 돌려도 실패한다.
fail_code 가 다르면 사람이 해야 할 일도 달라진다. 그래서 다시 돌려도 되는 것만 자동으로 돌리게 했다.
private const RETRYABLE = ['NODE_DOWN', 'TIMEOUT', 'RATE_LIMITED'];
public function retryFailed(): void {
$rows = $this->repo->findFailed(self::RETRYABLE);
foreach ($rows as $r) {
if ($r->fail_count >= 5) { continue; }
$this->process($r);
}
}
RETRYABLE 에 든 것만 돌리고 fail_count 가 다섯이면 멈춘다.
주의 — 두 번 나가지 않게
재시도가 있으니 이미 나간 것이 또 나갈 수 있었다.
public function process(WithdrawRequest $req): void {
if ($this->node->hasTransaction($req->idempotencyKey)) {
$this->repo->markDone($req->no);
return;
}
$txid = $this->node->send($req->address, $req->amount, $req->idempotencyKey);
$this->repo->markDone($req->no, $txid);
}
idempotencyKey 로 이미 보낸 것이 있으면 안 보낸다.
idempotencyKey 는 요청마다 만들어 저장해 두고 재시도할 때 같은 것을 쓴다. 실패한 것과 실패한 줄 알았는데 실제로 나간 것을 구분해야 했기 때문이다.
제약 — 응답을 못 받은 것
응답을 기준으로 하면 셋으로 갈린다.
성공 응답 받음 나갔다
실패 응답 받음 안 나갔다
응답 못 받음 모른다 ←
셋째가 가장 어려워서 UNKNOWN 이라는 상태를 따로 뒀다.
catch (TimeoutException $e) {
$this->repo->markUnknown($req->no); // FAILED 가 아니다
}
UNKNOWN 은 자동으로 재시도하지 않고 확인한 뒤에 판단한다.
// 확인 배치
foreach ($this->repo->findUnknown() as $r) {
if ($this->node->hasTransaction($r->idempotencyKey)) {
$this->repo->markDone($r->no);
} else if (time() - strtotime($r->last_tried_at) > 3600) {
$this->repo->markFailed($r->no, 'TIMEOUT_CONFIRMED_NOT_SENT');
}
}
한 시간이 지나도 없으면 안 나간 것으로 보고 그때부터 재시도 대상이 된다.
FAILED 로 보고 다시 보내면 중복이 되고 성공으로 보면 누락이 된다. 모르는 상태를 모른다고 두는 자리가 따로 필요했다.
결과 — 시도마다 남긴 기록
다시 돌린 기록도 표로 따로 남겼다.
CREATE TABLE withdraw_attempt (
no bigint AUTO_INCREMENT,
request_no bigint NOT NULL,
attempt_no int NOT NULL,
result varchar(20) NOT NULL,
fail_code varchar(30),
message varchar(500),
tried_at datetime NOT NULL,
PRIMARY KEY (no),
KEY ix_req (request_no, attempt_no)
);
withdraw_attempt 에 시도마다 한 줄이 남는다.
attempt_no 가 셋인 시도에서 성공했다면 그것도 그대로 보인다. 문의가 왔을 때 언제 왜 실패했고 언제 다시 해서 됐는지를 이것으로 답했다.
정리
- 실패한 것을 로그에만 남기면 다시 돌릴 때 못 찾는다
- 로그는 회전하면 사라진다
- 상태를 표에 남기면 다시 돌린 것과 안 돌린 것이 구분된다
- 로그는 왜인지 알려 주고 상태는 지금 어떤지 알려 준다
- 실패 이유를 종류로 나눈다
- 종류마다 대응이 다르다
- 다시 돌려도 되는 것만 자동으로 돌리고 횟수 상한을 둔다
- 재시도가 두 번 나가지 않게 같은 키를 쓴다
- 응답을 못 받은 것은 실패와 다르다
- 따로 상태를 두고 확인한 뒤에 판단한다
- 시도마다 기록을 남기면 문의에 답할 수 있다