Skip to content
isdnetworks
Go back

실패한 것을 찾아 원인을 고치고 다시 돌렸다

출금 처리가 몇 건 안 됐다는 것을 뒤늦게 알았다. 다시 돌리려는데 어느 것인지 찾을 수가 없었다.

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());
}

markDonemarkFailed 로 결과가 표에 남는다.

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 가 셋인 시도에서 성공했다면 그것도 그대로 보인다. 문의가 왔을 때 언제 왜 실패했고 언제 다시 해서 됐는지를 이것으로 답했다.

정리


Share this post on:

Previous Post
허용만 적어 둔 목록은 아무것도 막지 않았다
Next Post
남아 있는 것과 백업된 것