송금하고 차감하던 순서를 뒤집었다
원금 반영부터 중도상환과 잔액 환급까지 한 흐름에 묶여 있던 배치를 사용자 단위 이벤트로 쪼개고, 재시도해도 중복 환급되지 않도록 처리 순서를 바꾼 과정을 적었다.
회수금 정산과 잔액 환급을 처리하는 배치가 있었다. 원금과 수수료를 반영하고, 중도상환을 처리하고, 남은 잔액을 판매자에게 돌려주는 일이 한 흐름 안에 순서대로 들어 있었다.
문제는 그 흐름이 중간에서 끊길 때였다. 앞의 두 단계까지 반영되고 세 번째에서 실패하면 일부만 반영된 상태로 남았고, 다시 돌리면 앞 단계가 한 번 더 실행될 수 있었다.
한 번 더 실행되면 안 되는 단계가 있었다
재실행이 안전한 단계와 그렇지 않은 단계가 섞여 있었다. 계산을 다시 하는 것은 몇 번을 해도 결과가 같지만, 돈을 보내는 것은 두 번 하면 두 번 나간다. 기존 배치는 이 구분 없이 전체를 다시 돌리는 방식이었다. 실패한 건만 골라 다시 하려면 어디까지 됐는지 알아야 하는데, 그 정보가 남지 않았다.
레거시가 한 흐름으로 묶어 둔 이유가 있었다
쪼개기 전에 왜 하나로 묶여 있었는지부터 봤다. 원금 반영과 중도상환과 환급은 앞 단계의 결과가 뒤 단계의 입력이 된다. 한 흐름에 두면 중간 값을 어디에 저장할지 고민하지 않아도 된다.
그러니 묶어 둔 것이 게으름은 아니었지만, 그 편의는 실패가 없다는 가정 위에 서 있었다. 중간에서 끊기는 순간 저장되지 않은 중간 값이 사라지고, 어디까지 갔는지 알 방법도 함께 사라진다.
처리 단위를 사용자로 잡았다
가장 먼저 정한 것은 무엇을 한 건으로 볼지였다. 배치 전체를 한 건으로 보면 하나가 실패할 때 전부 다시 해야 한다. 반대로 너무 잘게 쪼개면 한 사용자의 정산이 여러 조각으로 나뉘어 중간 상태가 생긴다.
사용자 한 명을 한 건으로 잡았다. 한 사용자의 정산과 환급은 함께 끝나거나 함께 안 끝나야 하고, 사용자끼리는 서로 영향이 없다. 이 단위로 이벤트를 발행하고 건별 트랜잭션으로 묶었다.
flowchart TD
A["배치 · 대상 사용자 발행"] --> B[("이벤트 큐")]
B --> C["사용자 단위 소비"]
C --> D{"건별 트랜잭션"}
D -- "성공" --> E["완료"]
D -- "실패" --> F["그 건만 재처리"]
실패가 한 사용자에 갇히니 재처리 범위도 그 사용자로 좁아졌다. 전체를 다시 돌릴 이유가 없어졌다.
같은 사용자에 두 워커가 들어가지 않게 했다
사용자 단위로 나누자 새 문제가 생겼다. 배치가 여러 건을 동시에 처리하는데, 재처리와 정기 실행이 겹치면 같은 사용자에 두 흐름이 함께 들어갈 수 있었다.
여기에 분산락을 걸었다. 서비스가 여러 대로 떠 있어서 프로세스 안의 잠금으로는 막을 수 없었고, 사용자 식별자를 키로 삼아 같은 사용자에 대해서는 한 번에 하나만 진행되게 했다.
락을 못 얻었을 때의 동작도 정해서, 배치에서 들어온 중복 요청은 조용히 건너뛰게 했다. 중복 발행 자체가 정상 흐름의 일부라 예외로 만들면 매 실행마다 실패 로그가 쌓인다.
그런데 순서가 문제였다
여기까지 하고 나서도 중복 환급이 가능한 경로가 남아 있었다. 코드를 따라가다 발견했는데, 원인은 동시성이 아니라 두 동작의 순서였다.
기존 순서
1. 판매자에게 송금
2. 판매자 잔액에서 차감
1과 2 사이에서 중단 -> 재시도 -> 다시 송금
송금이 끝나고 잔액을 차감하기 전에 프로세스가 멈추면, 다음 실행이 볼 때 잔액은 그대로다. 아직 환급하지 않은 것으로 보이니 한 번 더 보내는데, 락도 트랜잭션도 이걸 막지 못한다. 둘 다 정상적으로 끝난 뒤에 벌어지는 일이기 때문이다.
차감을 앞으로 옮겼다
순서를 뒤집어 잔액을 먼저 차감하고 그다음에 송금하게 했다.
바꾼 순서
1. 판매자 잔액에서 차감
2. 송금
1과 2 사이에서 중단 -> 재시도 -> 잔액이 이미 없으므로 대상 아님
이렇게 하면 중단 지점이 어디든 중복 송금이 나오지 않는다. 차감 전에 멈추면 아무 일도 없었던 것이고, 차감 후 송금 전에 멈추면 다음 실행에서 대상으로 잡히지 않는다.
| 중단 지점 | 기존 순서 | 바꾼 순서 |
|---|---|---|
| 첫 동작 전 | 아무 일 없음 | 아무 일 없음 |
| 두 동작 사이 | 재시도 시 중복 송금 | 대상에서 빠짐 |
| 두 동작 후 | 정상 | 정상 |
대신 반대 방향의 위험이 생긴다. 차감은 됐는데 송금이 실패하면 판매자는 받지 못한 채 잔액만 줄어든다. 이건 중복 송금보다 낫다고 판단했다. 덜 준 것은 확인하고 더 주면 되지만, 더 준 것은 돌려받아야 한다.
그래서 송금 실패 건은 반드시 남기고 대사 대상에 넣었다. 순서를 바꾼 것이 실패를 없앤 것은 아니고, 실패했을 때 어느 쪽으로 틀리게 할지를 고른 것이다.
재처리가 안전한지를 테스트로 고정했다
순서를 바꾼 것이 실제로 중복을 막는지는 눈으로 확인할 수 없었다. 중단 지점을 임의로 만들어야 했기 때문이다.
차감 직후와 송금 직후에 각각 실패를 주입하고, 같은 사용자를 다시 처리했을 때 송금이 한 번만 일어나는지 확인했다. 호출 횟수만 세지 않고 그 뒤의 잔액과 이력이 어떻게 남았는지도 함께 봤다.
기존 배치와 나란히 돌렸다
전환은 한 번에 하지 않았다. 새 배치를 켜고 기존 배치를 끄는 대신, 결과를 대조하면서 단계적으로 옮겼다. 정산 금액과 환급 금액이 기존 배치와 같은 값을 내는지 먼저 확인했다. 계산이 같다는 것이 확인된 뒤에야 실제 송금을 새 배치가 맡게 했다.
이 방식은 이관 기간을 길게 만들었다. 대신 어긋난 값이 나올 때마다 어느 단계에서 갈라졌는지 바로 좁힐 수 있었고, 되돌릴 범위도 그 단계에 갇혔다.
이관 뒤 석 달
옮기고 나서 석 달 동안 이 배치에서 운영 장애가 나오지 않았다. 부분 반영으로 손보정하는 일도 없었다.
가장 크게 달라진 것은 실패했을 때의 대응이다. 이전에는 배치가 실패하면 전체를 다시 돌릴지 말지를 사람이 판단했다. 지금은 실패한 사용자만 다시 처리하면 되고, 그 재처리가 안전하다는 것이 순서로 보장된다.
순서를 바꾼 것은 이 배치 하나뿐이다
차감 후 송금 실패 건은 여전히 사람이 확인한다. 자동으로 다시 보내면 첫 송금이 실제로는 나갔을 가능성을 무시하게 되므로, 대행사 쪽 결과를 확인한 뒤에 판단한다.
그리고 이 순서 뒤집기는 이 배치에만 적용했다. 같은 형태로 돈을 보내는 경로가 더 있는지는 세어 보지 않았고, 새로 만들 때 차감을 먼저 두자는 기준만 남겼다.
락도 트랜잭션도 아니었다. 값이 나간 것은 두 동작의 순서를 바꾼 한 줄이었고, 동시성 문제처럼 보이던 것이 사실은 순서 문제였다는 걸 알아채는 데 시간이 오래 걸렸다.