정기 결제에서 같은 계약이 두 번 승인될 수 있었다
월 15,000건 정기 결제에서 중복 승인, 과거 정산 금액이 달라지는 문제, 외부 장애 복구 시점에 재시도가 몰리는 문제를 각각 다른 방법으로 막은 과정을 적었다.
정기 결제는 매달 같은 계약으로 같은 금액을 청구한다. 월 15,000건 규모로 돌고 있었고, 사람이 요청을 보내는 것이 아니라 배치와 재시도가 요청을 만든다.
사람이 누르는 결제와 다른 점이 여기 있다. 사용자는 결제 버튼을 두 번 누르면 화면에서 이상을 느끼지만, 배치는 같은 요청을 몇 번 만들어도 아무도 눈치채지 못한다.
세 가지 위험이 서로 다른 곳에 있었다
정기 결제를 손보면서 확인한 위험은 셋이었다. 원인도 해법도 달라서 하나로 묶어 풀 수 없었다.
| 위험 | 언제 생기나 | 결과 |
|---|---|---|
| 중복 승인 | 같은 계약의 요청이 겹칠 때 | 한 달치가 두 번 청구됨 |
| 과거 금액 변동 | 계약 조건이 바뀐 뒤 과거를 다시 계산할 때 | 이미 끝난 정산 금액이 달라짐 |
| 재시도 집중 | 외부 장애가 복구되는 시점 | 복구된 쪽이 다시 넘어감 |
조회로 확인하고 승인하면 늦는다
중복 승인부터 봤다. 기존 흐름은 결제 전에 이 계약의 이번 달 건이 이미 결제됐는지 조회하고, 안 됐으면 승인을 요청하는 형태였다.
요청이 하나일 때는 맞다. 둘이 동시에 들어오면 둘 다 조회를 통과한다. 조회하는 시점에는 둘 다 미결제이기 때문이다. 그 사이에 아무것도 바뀌지 않았으니 둘 다 승인으로 넘어간다.
flowchart TD A["요청 A · 조회: 미결제"] --> C["승인 요청"] B["요청 B · 조회: 미결제"] --> D["승인 요청"] C --> E["외부 승인 성공"] D --> F["외부 승인 성공"] E --> G["같은 달 두 번 청구"] F --> G
상태를 먼저 선점했다
순서를 바꿨다. 조회해서 확인하는 대신, 조건을 붙인 갱신으로 상태를 먼저 차지하게 했다.
UPDATE 결제
SET 상태 = '진행중'
WHERE 계약 = :계약
AND 청구월 = :청구월
AND 상태 = '미결제';
-- 갱신된 행이 1이면 내가 잡은 것, 0이면 남이 이미 잡은 것
이 방식이 안전한 이유는 판단을 데이터베이스가 하기 때문이다. 두 요청이 정확히 같은 순간에 들어와도 갱신은 하나만 성공한다. 애플리케이션이 조회하고 판단하는 사이의 틈이 사라진다.
갱신된 행 수가 0인 요청은 아무 일도 하지 않고 끝낸다. 예외로 만들지 않았다. 배치와 재시도가 같은 건을 다시 만드는 것은 이 시스템에서 정상 흐름이라, 예외로 처리하면 매 실행마다 실패 로그가 쌓인다.
허용된 전이만 외부로 나가게 했다
선점만으로는 부족했다. 이미 취소된 건이나 실패로 확정된 건에 대해서도 요청이 만들어질 수 있었기 때문이다.
상태 전이를 명시하고, 허용된 전이일 때만 외부 승인으로 넘어가게 했다. 어떤 상태에서 어떤 명령을 받을 수 있는지를 코드 여기저기의 조건문이 아니라 한 곳에서 정했다.
stateDiagram-v2 [*] --> 미결제 미결제 --> 진행중 진행중 --> 승인완료 진행중 --> 실패 실패 --> 진행중 승인완료 --> 취소
여기서 중요한 것은 승인완료에서 진행중으로 돌아가는 화살표가 없다는 점이다. 이미 승인된 건은 어떤 경로로 요청이 다시 와도 외부로 나가지 않는다.
과거 정산이 조용히 달라지고 있었다
두 번째 위험은 성격이 완전히 달랐다. 동시성과 상관없고, 정산을 다시 계산할 때 나타났다.
정산 금액을 계산하는 코드가 계약 정보를 현재 값으로 읽고 있었다. 지난달 정산을 다시 조회하면 그 시점의 계약 조건이 아니라 지금의 계약 조건으로 계산된다. 계약이 바뀌지 않았으면 같은 값이 나오니 오래 드러나지 않았다.
수수료율이 바뀐 계약에서 처음 보였다. 이미 지급이 끝난 지난달 정산 금액이 조회할 때마다 다른 값으로 나왔다.
산정 시점의 조건을 붙잡아 뒀다
계약 조건을 정산 시점에 복사해 함께 저장했다. 이후에 계약이 바뀌어도 과거 정산은 그때 붙잡아 둔 값으로 계산된다.
| 이전 | 이후 | |
|---|---|---|
| 계약 조건 | 계산할 때 현재 값을 조회 | 산정 시점의 값을 함께 저장 |
| 과거 정산 재조회 | 값이 달라질 수 있음 | 항상 같은 값 |
| 계약 변경의 영향 | 과거까지 소급 | 변경 이후 산정분부터 |
집계 방식도 함께 정리했다. 승인과 취소를 각각 따로 더하고 빼는 대신, 계약 단위로 상계한 결과를 정산 대상으로 삼았다. 중간 항목이 늘어나도 최종 금액이 흔들리지 않게 하려는 것이었다.
이 변경으로 잃은 것도 있다. 계약 조건을 고치면 과거 정산에 반영되기를 기대하는 요청이 가끔 들어오는데, 이제는 그게 안 된다. 그럴 때는 보정을 별도 항목으로 만들어야 한다. 과거가 조용히 바뀌는 것보다는 낫다고 봤다.
재시도가 복구 시점에 몰렸다
세 번째는 외부 결제사 쪽이 잠시 불안정할 때 나타났다. 실패한 요청을 일정한 간격으로 다시 보내고 있었는데, 그 간격이 고정값이었다.
장애가 10분간 이어지면 그동안 실패한 요청들이 전부 같은 주기에 맞춰 대기한다. 복구되는 순간 그 요청들이 한꺼번에 나간다. 겨우 살아난 쪽에 평소보다 큰 부하가 몰리고, 다시 넘어가면 같은 일이 반복된다.
고정 간격
실패 -> 30초 -> 30초 -> 30초 ...
장애 구간의 요청들이 같은 시점에 정렬됨
간격 확대 + 흔들기
실패 -> 30초 전후 -> 60초 전후 -> 120초 전후 ...
같은 시점에 정렬되지 않음
재시도 간격을 점점 늘리고, 거기에 약간의 무작위를 섞었다. 간격만 늘리면 여전히 같은 시점에 모인다. 흔들어야 흩어진다.
상한도 함께 뒀다. 무한정 늘리면 복구된 뒤에도 한참 지나서야 처리된다. 일정 횟수를 넘으면 자동 재시도를 멈추고 확인 대상으로 넘겼다.
셋을 따로 푼 이유
처음에는 셋을 하나의 안전장치로 묶고 싶었다. 전부 중복이나 반복과 관련돼 보였기 때문이다.
실제로는 층이 달랐다. 중복 승인은 동시성 문제라 저장소가 판단해야 하고, 과거 금액 변동은 데이터를 언제 읽느냐의 문제라 저장 시점에 고정해야 하며, 재시도 집중은 부하 분산 문제라 시간에 손을 대야 한다. 하나로 묶었으면 어느 것도 제대로 못 막았을 것이다.
선점한 채로 죽으면 어떻게 되나
선점 방식은 진행중 상태로 잡아 놓고 프로세스가 죽는 경우를 만든다. 그 건은 아무도 다시 잡지 못한 채 남는다. 일정 시간이 지난 진행중 건을 찾아 되돌리는 처리를 뒀지만, 그 시간 동안은 결제가 늦어진다.
계약 조건을 붙잡아 두는 방식도 저장 용량을 쓴다. 매 산정마다 조건을 복사하니 데이터가 늘어난다. 지금 규모에서는 문제가 아니지만 계약과 항목이 늘면 다시 볼 일이다.
조회해서 확인하고 행동하는 순서는 읽기 쉽고 자연스럽다. 그런데 요청을 사람이 만들지 않는 흐름에서는 그 사이에 거의 항상 틈이 생긴다. 가장 값이 나간 판단은 그 순서를 버린 것이었다.