결과를 모르는 이체를 다시 보내지 않게 막기
은행 이체 요청의 답에 결과가 없을 때 같은 돈을 두 번 보내지 않으려면 어떻게 해야 하는지를 두 주 동안 정했다. 복잡한 판정을 만들었다가 걷어내고, 보내기 전에 먼저 적는 규칙 하나로 줄인 과정을 적었다.
은행에 이체 요청을 보냈더니 답이 "접수됐다"뿐이었다. 돈이 나갔는지는 그 답에 없다. 알고 싶으면 결과확인이라는 API를 따로 불러야 하는데, 그 결과확인마저 답을 못 주는 때가 있다. 새 은행 채널을 붙이는 작업에서 이 상태를 어떻게 다룰지 정하는 데 두 주가 걸렸다.
결과를 모르는 이체는 다시 보내지 않는다. 처음부터 그렇게 정했는데, "모른다"를 어디에 적어 두고 언제 풀지를 정하는 데 생각보다 오래 걸렸다. 규칙을 하루 동안 잔뜩 쌓았다가 이튿날 거의 다 지웠다.
WRITE BEFORE SEND / LOOK UP THE SAME TRANSFER
미확정이면 새 이체를 막고, 같은 번호로 결과만 묻는다다시 묻는 배치는 T1의 결과만 확인한다
다음 결과확인에서도 결론을 못 받았다. 같은 번호로 조회하고 UNKNOWN을 유지한다.
셀러 계좌의 돈을 걷고, 돌려주고, 옮긴다
우리 서비스를 쓰는 판매자를 셀러라고 한다. 셀러 이름으로 된 정산 계좌가 은행에 있고, 그 계좌에 들어온 돈을 우리와 셀러가 나눠 갖는다. 그래서 이체가 세 종류 있다.
회수는 정산 계좌에서 우리 몫을 걷는 것이다. 배치가 매시간 돈다. 환급은 남은 돈을 셀러의 일반 계좌로 보내는 것이다. 셀러가 화면에서 신청할 때 나간다. 반환은 걷은 돈이 은행 쪽 모계좌에 모이기 때문에 그것을 우리 계좌로 옮기는 것이고, 회수가 성공할 때마다 한 번씩 나간다.
셋 다 은행 API로 이체를 일으킨다는 점은 같고, 결과를 모를 수 있다는 점도 같다. 환급은 셀러가 직접 누른다는 점이 달라서 뒤에서 따로 다룬다.
답을 받아도 모른다
기존 채널은 이체를 요청하면 그 답으로 성공인지 실패인지 알 수 있었다. 답이 안 올 때만 모르는 상태가 됐고, 그때는 결과를 다시 조회해서 성공이 확인된 뒤에만 장부를 건드렸다. 펌뱅킹 출금과 내부 원장을 맞추는 하루 단위 대사에서 그 순서를 정했다.
새 채널은 답을 받아도 모른다. 요청의 답에는 접수됐다는 것만 온다. 접수된 뒤에 은행 안에서 거절될 수 있고, 그 결과는 결과확인 API를 따로 불러야 나온다. 이체 한 번이 두 단계다. 요청을 보내고, 결과를 묻는다.
그리고 요청마다 새 거래번호를 받는다. 결과를 모르는 요청을 혹시 몰라서 한 번 더 보내면, 은행은 그것을 새 거래로 처리한다. 첫 요청이 사실은 성공했다면 돈이 두 번 나간다. 반대로 접수 응답만 보고 성공으로 적으면, 은행이 거절한 돈을 나간 것으로 장부에 남긴다. 어느 쪽으로도 단정하면 안 되는 상태가 미확정이다.
결제 쪽에서도 같은 상태가 있었다. 그쪽은 혹시나 해서 둔 배치에 진짜로 걸렸다에 적어 뒀다.
처음엔 Redis에 보류 표시를 뒀다
첫 안은 단순했다. 결과확인을 해도 결론이 안 나면 그 셀러에게 보류 표시를 Redis에 걸어 둔다. 표시가 있는 동안은 그 셀러의 새 회수를 보내지 않고, 사람이 결과를 확인해서 표시를 풀면 다시 보낸다. 배치는 30초마다 돌면서 보류 중인 셀러의 결과확인을 다시 불렀다.
돌려 보니 두 가지가 걸렸다. 하나는 횟수다. 한 셀러가 하루 종일 보류 상태면 결과확인을 1,400번 부른다. 답은 매번 같다. 묻는 횟수가 많다고 답이 빨리 오지 않는다.
다른 하나는 기록이다. 은행과 주고받은 통신 기록을 남기는 테이블이 있는데, 운영에서는 이 테이블을 매일 비운다. 거래번호를 거기에만 적어 두면 자정을 넘긴 미확정 건의 번호가 사라진다. 다음 날 은행에 어제 그 거래가 어떻게 됐는지 물으려 해도 물을 번호가 없다.
그리고 Redis 자체가 불안했다. 장애로 Redis가 바뀌거나 비워지면 보류 표시가 사라진다. 그러면 결과 모르는 요청이 있는데도 새 요청이 나간다. 보류 표시가 막아야 할 바로 그 일이, 표시가 사라지는 순간 일어난다. 세 서비스에 흩어진 분산락을 하나로 통일하기에서 키 이름이 조용히 비어 버리는 버그를 본 뒤라, 돈이 두 번 나가는 것을 막는 마지막 선을 Redis 키 하나에 걸어 두고 싶지 않았다.
응답코드를 하나하나 분류해 봤다
10월 28일 낮에 은행 응답코드표를 펴 놓고 판정을 촘촘하게 만들었다. 요청의 답으로 오는 코드 중 결과를 확인하라는 뜻인 다섯 개는 거절이 아니라 미확정으로 넘기게 했다. 결과확인의 답은 성공 코드가 아니면 전부 실패로 보되, 아직 처리 중이라는 것처럼 결론이 안 난 코드 26개만 미확정으로 남기게 했다. 결과확인을 다시 묻는 간격도 요청 뒤 5분까지는 매번, 그 뒤엔 1시간에 한 번으로 줄였다. 그래도 애매하면 거래내역조회라는 다른 API로 그 거래가 있는지 한 번 더 보게 했다. Redis가 비워질 때를 대비해 통신 기록 테이블에 처리 여부 표시를 남기는 가드도 넣었다.
하나하나는 맞는 말이었다. 응답코드표에 원거래 결과를 확인하라고 적힌 코드를 거절로 읽으면 안 되고, 은행이 실패라고 알려 줬는데 우리 목록에 없다고 사람이 풀 때까지 묶어 두는 것도 이상하다. 1,400번 묻는 것도 줄여야 했다.
그런데 다 넣고 보니 성공인지 실패인지를 판단하는 근거가 세 곳이 됐다. 요청의 답, 결과확인의 답, 거래내역조회. 셋이 서로 다른 말을 하면 어느 것을 믿을지 규칙이 또 필요했다. 26개 목록은 은행 명세가 바뀌면 같이 고쳐야 하고, 목록에 없는 새 코드가 오면 그때마다 사람이 판단해야 한다. 가드는 Redis가 사라질 수 있다는 것을 인정하고 그 위에 또 얹은 것인데, 가드가 보는 테이블이 매일 비워진다는 문제는 그 가드로 풀리지 않았다.
잠금 대신 표에 한 줄을 적기로 했다
같은 날 밤에 Redis 보류 표시를 걷어내고 이체 기록 테이블을 만들었다. 이체 한 건이 한 줄이다. 줄에는 거래번호, 금액, 상태가 들어간다. 상태는 넷뿐이다.
stateDiagram-v2 [*] --> REQUESTED REQUESTED --> SUCCESS REQUESTED --> FAILED REQUESTED --> UNKNOWN UNKNOWN --> SUCCESS UNKNOWN --> FAILED SUCCESS --> [*] FAILED --> [*]
REQUESTED는 보냈다는 뜻이고, SUCCESS와 FAILED는 결론이 났다는 뜻이고, UNKNOWN은 물어봤는데 모른다는 뜻이다. UNKNOWN에서 나가는 길은 다시 물어보는 것뿐이고, SUCCESS와 FAILED에서는 나가는 길이 없다.
이 줄이 보류 표시 역할을 한다. 어떤 셀러에게 REQUESTED나 UNKNOWN 줄이 하나라도 있으면 그 셀러의 새 회수를 보내지 않는다. 이 줄은 거래번호의 영구 기록이기도 하다. 매일 비워지는 테이블이 아니니 다음 날에도 은행에 물을 번호가 남아 있다. Redis가 사라질 걱정, 그것을 막으려고 넣은 가드, 통신 기록을 뒤지는 코드가 전부 필요 없어졌다.
다시 물어보는 일은 별도 배치로 뗐다. REQUESTED나 UNKNOWN 줄을 하나씩 집어 그 거래번호로 결과확인만 한다. 새 회수는 보내지 않는다.
보내기 전에 먼저 적는다
여기서 순서가 중요하다. 줄을 적는 것이 먼저고 은행에 보내는 것이 나중이다.
이유는 서버가 죽는 순간 때문이다. 은행이 요청을 접수한 직후에 우리 서버가 죽으면 답도 못 받고 결과확인도 못 한다. 그래도 REQUESTED 줄은 이미 저장돼 있다. 그래서 다음 배치가 그 셀러에게 새 요청을 보내지 못하고, 다시 묻는 배치가 그 번호로 결과를 확인한다. 반대로 줄을 적는 데 실패하면 보내지 않는다. 기록은 없는데 돈은 나간 경우가 생기지 않는다.
public void transfer(Member member, BigDecimal amount) {
Prepared p = tx.execute(s -> prepare(member, amount)); // 1. REQUESTED 줄을 먼저 저장
send(p); // 2. 은행에 보낸다. 답은 기록만 한다
Outcome outcome = checkWithRetry(p); // 3. 결과확인 (2초 간격, 최대 3번)
switch (outcome) {
case SUCCESS -> record(p.id(), Status.SUCCESS);
case FAILURE -> { record(p.id(), Status.FAILED); throw new TransferRejected(); }
case PENDING -> { record(p.id(), Status.UNKNOWN); throw new TransferPending(); }
}
}
private Prepared prepare(Member member, BigDecimal amount) {
memberRepository.findWithLockById(member.id()); // 같은 셀러는 한 번에 하나만
if (transferRepository.existsInProgress(member.id())) { // 결론 안 난 줄이 있으면 안 보낸다
throw new AnotherTransferInProgress();
}
String txNo = issueTransactionNo();
Transfer saved = transferRepository.save(Transfer.requested(member.id(), txNo, amount));
return new Prepared(saved.id(), txNo, amount);
}
결과를 적을 때도 줄을 잠그고, 아직 결론이 안 난 줄만 바꾼다. 이미 SUCCESS나 FAILED가 된 줄을 UNKNOWN으로 되돌리는 일이 없게 하기 위해서다. 이게 왜 필요한지는 환급에서 나온다.
private void record(Long id, Status status) {
tx.executeWithoutResult(s -> transferRepository.findWithLockById(id)
.filter(t -> !t.status().isFinal()) // 결론 난 줄은 건드리지 않는다
.ifPresent(t -> t.mark(status, now())));
}
이튿날, 판정은 결과확인 하나만 보기로 했다
10월 29일에 낮에 쌓았던 판정을 다시 봤다. 남길 것이 거의 없었다.
요청의 답은 판정에 쓰지 않는다. 성공 코드가 오든 오류가 오든 통신이 끊기든 전부 결과확인으로 넘긴다. 결과확인의 답이 제대로 왔으면 성공 코드일 때만 성공이고 나머지는 전부 실패다. 코드별로 나누지 않는다. 결과확인의 답을 못 받았거나, 답이 그런 거래 없음인데 요청한 지 10분이 안 됐으면 미확정이다. 요청 직후에는 은행에 아직 도착하지 않은 요청일 수 있어서 10분은 기다려 준다.
enum Outcome { SUCCESS, FAILURE, PENDING }
static Outcome judge(String headerCode, String resultCode,
LocalDateTime requestedAt, LocalDateTime now) {
if (OK.equals(headerCode)) {
// 은행이 그 거래를 찾아서 결과를 줬다. 성공이 아니면 돈은 안 나갔다
if (resultCode == null || resultCode.isBlank()) {
return Outcome.PENDING;
}
return OK.equals(resultCode) ? Outcome.SUCCESS : Outcome.FAILURE;
}
if (ORIGINAL_NOT_FOUND.contains(headerCode)
&& Duration.between(requestedAt, now).toMinutes() >= 10) {
return Outcome.FAILURE; // 10분이 지났는데도 없으면 안 간 것이다
}
return Outcome.PENDING;
}
| 결과확인의 답 | 판정 | 그 뒤 |
|---|---|---|
| 제대로 왔고 성공 코드 | 성공 | 장부에 반영하고 SUCCESS |
| 제대로 왔고 그 밖의 코드 | 실패 | FAILED. 다음 시간에 새 번호로 다시 |
| 그런 거래 없음, 요청한 지 10분 넘음 | 실패 | FAILED |
| 그런 거래 없음, 10분 안 | 미확정 | UNKNOWN. 다시 묻는 배치가 본다 |
| 답을 못 받음, 나중에 다시 확인하라는 코드 | 미확정 | UNKNOWN. 다시 묻는 배치가 본다 |
낮에 만든 요청 응답 분류, 26개 목록, 거래내역조회, 다시 묻는 간격 계산은 전부 지웠다. 얼마나 자주 다시 물을지는 코드가 아니라 배치 스케줄이 정한다. 코드는 돌 때마다 묻는다.
왜 하루 만에 뒤집었나. 판단 근거가 하나여야 모르는 채로 남는 경우를 손으로 셀 수 있기 때문이다. 결과확인만 보면 미확정은 표의 아래 두 줄뿐이다. 거절 코드는 전부 실패로 닫히니 사람이 봐야 하는 경우는 은행 장애가 길어질 때뿐이다. 26개 목록이 있을 때는 이 수를 셀 수 없었다.
회수 한 건이 지나가는 길을 그리면 이렇다.
flowchart TD
A["회수 한 건"] --> B{"이 셀러에게 결론 안 난 줄이 있나"}
B -- "있다" --> C["안 보낸다. 다시 묻는 배치가 처리"]
B -- "없다" --> D["잔액 조회, 금액 결정"]
D --> E["REQUESTED 줄 저장"]
E -- "저장 실패" --> F["안 보낸다"]
E -- "저장 성공" --> G["은행에 요청"]
G --> H["결과확인 5초 간격 최대 6번"]
H -- "성공" --> I["장부 반영 + SUCCESS"]
H -- "실패" --> J["FAILED. 다음 시간에 새 번호로"]
H -- "모름" --> K["UNKNOWN. 다시 묻는 배치로"]
환급은 셀러가 버튼을 누른다
회수는 배치가 셀러 한 명씩 차례로 처리한다. 환급은 셀러가 화면에서 누른다. 같은 셀러가 두 번 누를 수 있고, 두 번째 요청이 첫 번째 결과를 기다려 주지 않는다. 그래서 회수에 없던 문제가 셋 나왔다.
같은 돈을 두 번 보낼 수 있는 길이 있었다. 환급액은 잔액에서 아직 못 걷은 회수액을 뺀 금액인데, 이 계산이 잠금 밖에 있었다. 두 신청이 같은 잔액을 읽으면, 앞 신청이 보내고 끝난 뒤에 뒤 신청이 진행 중인 신청 없음으로 통과해 같은 금액을 한 번 더 보낼 수 있었다. 통장에 못 걷은 회수액이 남아 있으면 은행은 그것을 보내 준다. 우리가 걷어야 할 돈이 셀러에게 가는 것이다. 신청 전체를 셀러 단위 잠금으로 감쌌다. 잠금을 못 잡으면 기다리지 않고 바로 거절한다. 다시 누르면 새 잔액으로 다시 계산하니 금액이 달라져서 막힌다.
결론 난 줄을 되돌릴 수 있는 길도 있었다. 셀러가 다시 신청하면 먼저 이전 미확정 건의 결과를 다시 묻는데, 이 과정이 진행 중인 다른 신청의 SUCCESS를 UNKNOWN으로 덮을 수 있었다. 돈이 잘못 나가지는 않지만 끝난 일이 다시 모름이 된다. 위의 record가 결론 안 난 줄만 바꾸는 이유다.
금액 상한도 걸렸다. 은행과 우리 사이에서 통신을 대신하는 중계 서버는 한 번에 보낼 수 있는 상한을 넘는 금액이면 은행을 부르지 않고 거절한다. 그러면 결과확인은 그런 거래 없음을 돌려주고, 요청한 지 10분이 안 됐으니 미확정이다. 10분 뒤에 다시 신청해도 또 상한 초과라 같은 일이 반복된다. 그 셀러는 환급을 영영 못 받을 수 있었다. 기존 채널의 올해 환급 기록을 세어 보니 상한을 넘는 건이 네 건 있었다. 천 건에 한 건이 안 되지만 있다. 환급액을 상한씩 나눠 차례로 보내고, 조각마다 줄 하나를 남긴다. 앞 조각이 성공해야 다음 조각을 보낸다. 뒤 조각이 거절되면 일부만 환급됐다고 알려 잔액을 다시 조회하게 한다.
같은 요청이 두 번 와도 이체는 한 번이다
테스트는 API 서버 쪽 1,884건, 배치 쪽 1,101건이 통과했다. 숫자보다 통합 테스트의 시나리오가 중요했다. 각 단계가 실제처럼 따로 커밋하게 해 두고 확인했다.
같은 회수 요청이 두 번 와도 은행에는 한 번만 보낸다. 결과를 모르면 UNKNOWN이 되고, 그 셀러의 다음 회수는 나가지 않고, 다시 물어서 성공이 확인되면 장부에 반영되고 남은 금액이 0이 된다. 요청의 답이 오류여도 결과확인을 하고, 요청 직후에 그런 거래 없음이 오면 UNKNOWN으로 남겨 두고 다시 보내지 않는다. 같은 성공을 두 번 반영하려 해도 장부에는 한 번만 들어간다.
테스트가 버그 하나를 잡았다. 응답코드가 비어서 오면 Set.of(...).contains(null)이 예외를 던졌다. 미확정으로 판정해야 할 답이 예외로 빠지는 문제라 null을 먼저 거르게 고쳤다.
타임아웃도 늘렸다. 중계 서버는 은행 토큰을 새로 받아야 할 때 발급에 최대 20초, 그 뒤 은행 호출에 최대 30초를 쓴다. 우리 쪽 읽기 타임아웃이 40초면 중계 서버가 답을 주기 전에 끊길 수 있다. 끊겨도 결과확인으로 판정하니 돈이 잘못되지는 않지만, 끊기지 않을 수 있는 것을 끊을 이유가 없어 60초로 맞췄다.
다시 묻는 배치는 회수 배치가 매시 정각에 돌 때 매시 30분에 돌게 했다. 정각 직후에 바로 물으면 그런 거래 없음이 와도 아직 10분이 안 돼 미확정이고, 그러면 한 시간을 더 기다린다. 30분 뒤에 물으면 그 셀러가 묶이는 시간이 최대 두 시간에서 한 시간으로 준다.
반환은 아직 사람 손을 탄다
회수 한 건이 처음 성공으로 기록될 때 반환을 한 번 보낸다. 판정은 회수와 같은 함수를 쓴다. 다른 점은 실패 처리다. 반환은 상태를 남기지 않고, 실패하거나 결과를 모르면 다시 보내지 않는다. 알림 채널로 보내고 사람이 처리한다.
그래서 빈 자리가 하나 남았다. 반환 요청은 서버 안의 전용 대기열에서 차례를 기다리는데, 그 사이에 서버가 내려가면 요청은 메모리와 함께 사라진다. 이번에는 그것을 자동으로 다시 보내는 길까지 만들지 않았다. 대신 하루가 끝난 뒤 그날 회수에 성공한 합계와 우리 계좌에 들어온 합계를 맞춰 보는 절차를 뒀다. 펌뱅킹 대사 글에서 세운 하루 단위 대조에 한 줄이 더 늘었다.
미확정이 길어질 때도 마찬가지다. 코드가 스스로 닫을 수 있는 미확정은 표의 아래 두 줄뿐이고, 그 둘이 오래 가는 것은 은행 장애가 길어질 때다. 그때는 은행에 그 거래를 직접 문의한다. 회수 요청을 꺼낼 때 셀러 자격을 다시 확인하는 코드는 넣었다가 뺐다. 큐가 밀려 날짜를 넘긴 요청을 어떻게 볼지는 기존 채널과 같은 판단을 따르기로 했고, 그 확인이 막아 주는 것보다 들어온 요청은 그대로 처리한다는 단순함이 더 값있다고 봤다. 그 판단이 맞는지는 큐가 밀리는 날 안다.