← 모든 글
PG 플랫폼

월 15,000건 정기 결제의 중복 승인·과거 정산 변동·재시도 쏠림 막기

월 15,000건 정기 결제에서 중복 승인, 과거 정산 금액이 달라지는 문제, 외부 장애 복구 시점에 재시도가 몰리는 문제를 각각 다른 방법으로 막은 과정을 적었다.

정기 결제는 매달 같은 계약으로 같은 금액을 청구한다. 한 달에 15,000건 정도가 이렇게 나갔다. 이 결제는 사람이 버튼을 눌러서 시작되지 않는다. 배치가 정해진 시각에 요청을 만들고, 실패하면 재시도가 다시 만든다.

그래서 실수가 잘 안 보인다. 사람이 결제 버튼을 두 번 누르면 화면을 보고 바로 이상하다는 걸 안다. 배치가 같은 요청을 두 번 만들면 누가 따로 들여다보기 전까지 아무도 모른다.

배치는 Spring Batch로 돌렸다

먼저 요청을 만드는 배치부터 적는다. Spring Batch 잡 하나에 단계(step)도 하나다. 오늘 청구할 계약을 읽고, 한 건씩 청구하고, 결과를 남긴다.

어떤 계약을 읽을지는 두 조건으로 정한다. 계약이 활성 상태일 것, 그리고 다음 청구 시각이 기준 시각을 지났을 것. 이 기준 시각은 잡을 시작할 때 파라미터로 넘겨 고정했다. 실행 중에 "지금"을 매번 새로 계산하면 배치가 오래 돌수록 기준이 뒤로 밀려 대상이 늘어난다. 뒤에 나오는 정산 문제도 같은 생각으로 풀었다.

Java
new StepBuilder(STEP_NAME, jobRepository)
    .<Contract, ChargeResult>chunk(CHUNK_SIZE, transactionManager)
    .reader(dueContractReader)    // 기준 시각까지 청구일이 지난 계약
    .processor(chargeProcessor)   // 한 건 청구, 자체 트랜잭션으로 커밋
    .writer(noOpWriter)           // 처리 단계에서 이미 끝남
    .faultTolerant()
    .skip(Exception.class)
    .skipLimit(SKIP_LIMIT)
    .build();

Spring Batch는 보통 여러 건을 묶은 청크 단위로 커밋한다. 결제에는 이게 맞지 않았다. 여러 건을 묶어 처리하다 중간에서 실패하면 청크 전체가 롤백되는데, 앞의 건들은 이미 외부 결제사로 승인 요청이 나간 뒤다. 데이터베이스는 되돌릴 수 있어도 나간 승인은 되돌릴 수 없다.

그래서 한 건의 청구는 processor 안에서 자기 트랜잭션을 열고 커밋까지 끝내게 했다. writer는 할 일이 없어 비워 뒀다. 청크 트랜잭션에는 어디까지 읽었는지 같은 배치 메타 정보를 갱신하는 일만 남았다.

한 건이 실패했다고 그날 청구를 전부 멈출 수는 없다. 그래서 예외가 나면 그 건을 건너뛰게(skip) 하고, 건너뛴 수에 상한을 뒀다. 상한을 넘을 만큼 실패가 많으면 계약 하나하나의 문제가 아니라 공통 원인이 있다는 뜻이라 잡을 실패로 끝낸다. 건너뛴 건은 리스너가 받아서 어느 실행에서 어느 계약이 왜 빠졌는지 기록했다. 나중에 다시 돌릴 건을 고르려면 이 기록이 있어야 한다.

Spring Batch를 고른 이유의 절반은 이력이었다. 언제, 어떤 기준 시각으로 돌았고, 몇 건을 읽고 몇 건을 건너뛰었는지가 메타 테이블에 저절로 남는다. 사람이 요청을 만들지 않는 흐름에서는 무엇이 언제 나갔는지 되짚을 수 있어야 하는데, 그 기록을 직접 만들지 않아도 됐다.

위험은 세 가지였다

정기 결제를 손보면서 찾은 위험은 세 가지였다. 원인도 다르고 막는 방법도 달라서 하나씩 따로 풀었다.

위험언제 생기나결과
중복 승인같은 계약의 같은 달 결제 요청이 겹칠 때한 달치가 두 번 청구됨
과거 정산 금액 변동계약 조건이 바뀐 뒤 지난 정산을 다시 계산할 때이미 끝난 정산 금액이 달라짐
재시도 쏠림외부 결제사 장애가 복구되는 순간막 살아난 결제사가 다시 넘어감

확인하고 승인하는 사이에 틈이 있었다

중복 승인부터 봤다. 기존 코드는 승인 전에 먼저 확인했다. "이 계약의 이번 달 결제가 이미 됐나?"를 조회하고, 안 됐으면 승인을 요청한다.

요청이 하나뿐이면 문제없다. 그런데 같은 계약의 요청 둘이 거의 동시에 들어오면 이렇게 된다. A가 조회하니 미결제다. B가 조회해도 아직 미결제다. A가 승인을 요청하고, B도 승인을 요청한다. 둘 다 조회 결과만 보고 움직였으니 서로가 있는 줄 모른다.

sequenceDiagram
  participant A as 요청 A
  participant B as 요청 B
  participant DB as 결제 DB
  participant PG as 외부 결제사
  par 요청 A가 상태 조회
    A->>DB: 이번 달 결제 상태 조회
    DB-->>A: 미결제
  and 요청 B가 상태 조회
    B->>DB: 이번 달 결제 상태 조회
    DB-->>B: 미결제
  end
  par 요청 A 승인
    A->>PG: 승인 요청
    PG-->>A: 승인 성공
  and 요청 B 승인
    B->>PG: 승인 요청
    PG-->>B: 승인 성공
  end
  Note over A,B: 같은 계약의 한 달치가 두 번 청구됨

조회 대신 상태를 먼저 차지하게 했다

순서를 뒤집었다. 조회해서 확인하는 대신 "미결제면 진행중으로 바꿔라"라는 조건부 UPDATE를 먼저 보낸다. 바꾸는 데 성공한 요청만 승인으로 넘어간다. 이 글에서는 이걸 선점이라고 부른다.

SQL
UPDATE 결제
   SET 상태 = '진행중'
 WHERE 계약 = :계약
   AND 청구월 = :청구월
   AND 상태 IN ('미결제', '실패');

-- 갱신된 행이 1이면 내가 잡은 것, 0이면 남이 이미 잡은 것

핵심은 UPDATE가 바꾼 행 수다. 1이면 이 결제를 내가 잡은 것이고, 0이면 다른 요청이 이미 잡아 갔다는 뜻이다. 두 요청이 정확히 같은 순간에 들어와도 데이터베이스는 같은 행을 한 번에 하나씩 갱신하므로 한쪽만 1을 받는다. 애플리케이션이 조회하고 판단하는 사이의 틈이 아예 없어진다.

조건에 실패 상태를 넣은 건 재시도 때문이다. 실패한 건을 다시 보내는 것도 결국 같은 결제를 외부로 내보내는 일이라, 재시도 둘이 겹쳐도 하나만 진행중을 차지해야 한다. 이미 진행중이거나 승인완료인 건은 조건에 맞지 않으니 다시 잡히지 않는다.

0을 받은 요청은 예외를 던지지 않고 아무것도 하지 않은 채 끝낸다. 배치와 재시도가 같은 건을 다시 만드는 건 이 시스템에서 늘 있는 일이다. 그걸 예외로 처리하면 실행할 때마다 실패 로그가 쌓인다.

락이 있는데도 선점이 필요했던 이유

사실 이 배치에는 Redis 락이 이미 두 개 걸려 있었다. 하나는 잡 전체에 걸어 같은 배치가 동시에 두 번 돌지 않게 했다. 다른 하나는 계약마다 걸어 같은 계약을 두 워커가 동시에 처리하지 않게 했다.

그런데도 선점이 필요했던 건 락이 막지 못하는 경우가 세 가지 있었기 때문이다.

락이 못 막는 경우이유
배치 밖에서 들어온 요청수동 재시도, 관리자 화면, API도 같은 결제를 만든다
락이 풀린 뒤에 온 요청락이 만료되거나 해제된 다음에 온 재시도는 겹치는 요청으로 보이지 않는다
Redis가 죽었을 때락을 판단할 근거가 사라져도 중복 승인은 나면 안 된다

정리하면 락은 같은 시간에, 같은 배치 경로로, Redis가 살아 있을 때 들어온 요청끼리만 막는다. 셋 중 하나라도 어긋나면 락 입장에서는 부딪친 적이 없는 요청이다.

조건부 UPDATE에는 이런 빈틈이 없다. 어느 경로로 오든, 언제 오든, Redis가 어떤 상태든 결제 행이 이미 진행중이나 승인완료면 UPDATE는 0을 돌려준다. 판단 근거가 락이 아니라 결제 데이터 자체에 있어서다.

그래서 둘의 역할을 나눴다. 락은 애초에 부딪치는 일을 줄여서 쓸데없는 처리와 외부 호출을 아낀다. 선점은 그래도 뚫렸을 때 마지막에 막는다. 락을 다 걷어내도 돈이 틀리지는 않고, 락이 있으면 덜 부딪칠 뿐이다. 가볍게 거르는 앞단과 확실하게 막는 뒷단을 겹쳐 두는 방식은 결제와 정산 사이에도 썼다. 그쪽에서는 Redis 멱등 키가 앞단이고 거래 키의 유일 제약이 뒷단이다.

갈 수 있는 상태를 미리 정했다

선점만으로는 부족했다. 이미 취소된 건이나 실패로 확정된 건에 대해서도 요청이 만들어질 수 있었다. 그래서 어떤 상태에서 어떤 상태로 갈 수 있는지를 상태 머신으로 정해 두고, 허용된 전이일 때만 외부 승인으로 넘어가게 했다. 이 규칙을 코드 곳곳의 조건문에 흩어 두지 않고 한 곳에 모았다.

stateDiagram-v2
  [*] --> 미결제
  미결제 --> 진행중
  진행중 --> 승인완료
  진행중 --> 실패
  실패 --> 진행중
  승인완료 --> 취소

눈여겨볼 것은 그려지지 않은 화살표다. 승인완료에서 진행중으로 돌아가는 화살표가 없다. 그래서 이미 승인된 건은 어떤 경로로 요청이 다시 와도 외부로 나가지 않는다.

과거 정산이 조용히 달라지고 있었다

두 번째 위험은 성격이 전혀 달랐다. 동시성과는 상관없고, 지난 정산을 다시 계산할 때 나타났다.

정산 금액을 계산하는 코드가 계약 정보를 늘 현재 값으로 읽고 있었다. 그러니 지난달 정산을 다시 계산하면 지난달의 계약 조건이 아니라 오늘의 계약 조건이 들어간다. 예를 들어 이번 달에 수수료율을 내리면, 지난달 정산을 다시 열었을 때 내린 수수료율로 계산된 금액이 나온다. 계약이 그대로면 결과도 같으니 오랫동안 드러나지 않았다.

처음 드러난 건 수수료율이 바뀐 계약에서였다. 이미 지급까지 끝난 지난달 정산 금액이 조회할 때마다 다른 값으로 나왔다.

정산할 때의 계약 조건을 같이 저장했다

정산 금액을 계산하는 순간 그때의 계약 조건을 복사해 정산 데이터와 함께 저장했다. 나중에 계약이 바뀌어도 지난 정산은 저장해 둔 조건으로 계산하니 값이 달라지지 않는다. 배치의 기준 시각을 파라미터로 고정한 것과 같은 생각이다. 계산에 쓰는 값은 계산하는 순간에 정해 두고, 나중에 다시 읽지 않는다.

이전이후
계약 조건계산할 때마다 현재 값을 읽음정산할 때의 값을 함께 저장
지난 정산 다시 조회값이 달라질 수 있음항상 같은 값
계약 변경의 영향지난 정산까지 바뀜변경 이후 정산부터 반영

합산하는 방식도 같이 정리했다. 전에는 승인 금액을 더하고 취소 금액을 빼는 계산을 따로따로 했다. 이걸 계약 단위로 먼저 상계해서, 승인과 취소를 맞비긴 순액을 정산 대상으로 삼았다. 중간 항목이 늘어나도 최종 금액이 흔들리지 않게 하려는 것이었다.

대신 잃은 것도 있다. 계약 조건을 고치면 지난 정산에도 반영되길 바라는 요청이 가끔 들어온다. 이제는 그게 안 되고, 그럴 때는 보정 항목을 따로 만들어야 한다. 그래도 지난 정산이 소리 없이 바뀌는 것보다는 낫다고 봤다.

재시도가 복구 시점에 몰렸다

세 번째는 외부 결제사가 잠깐 불안정할 때 나타났다. 실패한 요청은 일정한 간격으로 다시 보내고 있었는데, 그 간격이 고정값이었다.

장애가 10분 이어졌다고 해 보자. 그동안 실패한 요청들은 모두 같은 간격으로 다시 시도하며 결제사가 살아나기를 기다린다. 결제사가 복구되는 순간 쌓여 있던 요청이 한꺼번에 들어간다. 막 살아난 결제사에 평소보다 훨씬 큰 부하가 걸리고, 그러다 다시 넘어가면 같은 일이 처음부터 반복된다.

고정 간격
  실패 -> 30초 -> 30초 -> 30초 ...
  장애 중에 실패한 요청들이 같은 박자로 다시 시도함

지수 백오프 + 지터
  실패 -> 약 30초 -> 약 60초 -> 약 120초 ...
  요청마다 조금씩 어긋나서 한 시점에 몰리지 않음

그래서 재시도 간격을 두 배씩 늘리는 지수 백오프(exponential backoff)로 바꾸고, 거기에 약간의 무작위 값(jitter)을 더했다. 간격을 늘리기만 하면 요청들이 여전히 같은 박자로 움직여 같은 시점에 모인다. 요청마다 간격을 조금씩 흔들어야 흩어진다.

간격에는 상한도 뒀다. 끝없이 늘리면 결제사가 복구된 뒤에도 한참 지나서야 처리되기 때문이다. 그리고 정해진 횟수를 넘으면 자동 재시도를 멈추고 사람이 확인할 목록으로 넘겼다.

셋을 따로 푼 이유

처음에는 셋을 안전장치 하나로 묶고 싶었다. 셋 다 중복이나 반복과 관련된 문제처럼 보였기 때문이다.

들여다보니 문제가 놓인 자리가 달랐다.

위험무슨 문제인가어디서 막았나
중복 승인동시성데이터베이스가 판단하게 했다 (조건부 UPDATE)
과거 정산 금액 변동데이터를 언제 읽느냐계산하는 순간의 값을 저장했다
재시도 쏠림부하 분산재시도 시각을 흩뜨렸다 (백오프 + 지터)

하나로 묶었다면 어느 것도 제대로 막지 못했을 것이다.

선점한 채로 죽으면 어떻게 되나

선점 방식에는 새 구멍이 하나 생긴다. 진행중으로 잡아 놓은 뒤에 프로세스가 죽으면 그 건은 진행중인 채로 남고, 다른 요청은 조건에 맞지 않아 다시 잡지 못한다. 그래서 일정 시간이 지나도록 진행중인 건을 찾아 되돌리는 처리를 따로 뒀다. 다만 그 시간이 지날 때까지는 결제가 늦어진다.

계약 조건을 저장해 두는 방식은 저장 공간을 쓴다. 정산할 때마다 조건을 복사하니 데이터가 계속 늘어난다. 지금 규모에서는 문제가 아니지만, 계약과 항목이 늘어나면 다시 봐야 한다.

조회해서 확인한 다음 행동하는 순서는 읽기 쉽고 자연스럽다. 하지만 사람이 아니라 배치와 재시도가 요청을 만드는 흐름에서는 그 사이에 거의 언제나 틈이 생긴다. 이번 작업에서 가장 값진 판단은 그 순서를 버린 것이었다.