은행이 보낸 결과와 내 원장을 매일 대조했다
트랜잭션이 보장되지 않던 회수 배치를 Java로 옮기면서 외부 출금과 내부 잔액이 어긋나는 경로를 막고, 하루 단위 대사를 따로 세운 과정을 적었다.
판매자에게 먼저 준 돈을 나중에 돌려받는 회수 배치가 있었다. 매달 다섯에서 스무 건씩 금액이 맞지 않았고, 맞지 않는 건은 사람이 하나씩 확인해 손으로 맞췄다.
규모가 큰 사고는 아니었다. 다만 매달 반복됐고, 보정하는 사람이 매번 같은 판단을 다시 했다. 그 판단이 기록으로 남지 않으니 다음 달에 비슷한 건이 나와도 처음부터 다시 봐야 했다.
맞지 않는 건은 항상 중간에서 끊겨 있었다
불일치 건을 모아 보니 형태가 하나였다. 은행 쪽에서는 출금이 됐는데 내부 데이터는 일부만 반영돼 있었다. 반대 방향, 그러니까 내부만 반영되고 은행은 안 된 경우는 없었다.
회수 한 건이 성공하려면 내부에서 바꿔야 할 것이 여럿이다. 회수 원장, 회수 대상의 상태, 판매자 잔액, 한도, 지갑 이력이 함께 움직여야 한다. 이 중 앞의 둘까지만 반영되고 멈춘 흔적이 남아 있었다.
외부 호출이 트랜잭션 안에 있었다
기존 배치는 은행 출금을 요청하는 호출과 내부 데이터를 바꾸는 작업이 하나의 흐름 안에 섞여 있었다. 외부 응답이 늦거나 중간에 끊기면 어디까지 반영됐는지 알 수 없었다.
flowchart TD A["회수 대상 산정"] --> B["은행 출금 요청"] B --> C["회수 원장 반영"] C --> D["판매자 잔액 차감"] D --> E["한도 복구"] E --> F["지갑 이력"] B -. "여기서 끊기면" .-> X["출금은 됐고 내부는 일부만"]
더 나빴던 것은 외부 호출이 트랜잭션 경계 안에 있었다는 점이다. 은행 응답을 기다리는 동안 DB 커넥션을 붙잡고 있었고, 응답이 실패로 돌아와도 이미 반영된 내부 변경을 되돌릴 기준이 명확하지 않았다.
순서를 먼저 정했다
옮기기 전에 규칙 하나를 정했다. 외부에 요청하는 일과 내부를 바꾸는 일을 같은 트랜잭션에 두지 않는다는 것이었다. 외부는 되돌릴 수 없고 내부는 되돌릴 수 있으니, 되돌릴 수 없는 쪽을 먼저 확정하고 그 결과를 보고 내부를 바꾼다.
변경 전
[ 트랜잭션 ] 출금 요청 -> 원장 -> 잔액 -> 한도 -> 이력 [ 커밋 ]
변경 후
출금 요청 -> 결과 조회로 성공 확정
[ 트랜잭션 ] 원장 -> 잔액 -> 한도 -> 이력 [ 커밋 ]
출금 요청과 결과 조회를 나눈 것도 같은 이유다. 요청이 타임아웃돼도 은행에서는 처리됐을 수 있다. 응답이 없다는 것을 실패로 단정하지 않고, 결과를 다시 조회해 성공인지 확인한 뒤에만 내부를 건드렸다.
내부 변경은 건별 트랜잭션으로 묶었다. 원장부터 지갑 이력까지가 한 건에 대해 전부 반영되거나 전부 안 되거나 둘 중 하나가 되게 했다. 부분 반영이 사라지니 손보정할 형태 자체가 없어졌다.
그래도 대사는 필요했다
트랜잭션 하나로 끝났다고 생각했는데 아니었다. 내부를 아무리 잘 묶어도 외부 기관과의 차이는 검출되지 않는다. 출금 요청이 유실되거나, 결과 조회가 실패한 채로 남거나, 은행 쪽 처리가 하루 늦게 반영되는 경우가 있다.
내부 정합성과 외부 정합성은 다른 문제였다. 앞의 것은 트랜잭션이 풀고, 뒤의 것은 대조로만 풀린다. 내부 데이터베이스 두 개를 맞추는 대사는 최종적 일관성을 믿지 않고 결제와 정산을 대조했다에 따로 적었는데, 여기서 다루는 것은 상대가 우리 시스템이 아니라 외부 기관인 경우다.
기준을 어느 쪽에 둘지부터 정했다
대사는 두 집계를 비교하는 일이라 어느 쪽이 기준인지 먼저 정해야 한다. 돈이 실제로 움직인 근거는 외부 기관의 처리 결과이므로 그쪽을 기준으로 두고, 내부 원장이 그것을 따라왔는지 확인하는 방향으로 잡았다.
기준을 정하지 않으면 차이가 났을 때 어느 쪽을 고칠지도 정해지지 않는다. 양쪽을 대등하게 두면 매번 판단이 필요해지고 그 판단이 사람마다 달라진다.
| 맞춰야 할 기준 | 어긋나면 생기는 일 |
|---|---|
| 날짜를 자르는 시각 | 자정을 걸친 출금이 양쪽에서 다른 날에 들어간다 |
| 집계에 넣는 상태 | 한쪽만 처리 중인 건을 세어 건수가 어긋난다 |
| 취소·반환의 반영 시점 | 금액이 서로 다른 단계의 값으로 비교된다 |
차이를 사람에게 바로 알렸다
대사를 하루 한 번 돌려 건수와 금액을 대조하고, 차이가 나면 슬랙으로 즉시 알렸다. 결과를 화면 어딘가에 쌓아 두고 누가 보러 오기를 기다리는 방식은 쓰지 않았다.
이유는 단순하다. 대사 결과를 보러 가는 일이 누군가의 일과에 들어 있지 않으면, 대사는 돌지만 아무도 안 보는 상태가 된다. 이전 방식이 정확히 그랬다.
알림에는 어떤 건이 얼마나 차이 나는지와 다음에 확인할 위치를 함께 넣었다. 숫자만 던지면 받은 사람이 다시 조회부터 시작해야 한다.
한 번에 옮기지 않았다
기존 배치를 끄고 새 배치를 켜는 방식은 쓰지 않았다. 회수는 판매자의 잔액과 한도를 건드리기 때문에 틀리면 바로 문의가 들어온다.
flowchart LR A["1단계 · 회수 대상 산정"] --> B["2단계 · 출금 요청"] B --> C["3단계 · 원장과 잔액 반영"] A -.-> D["기존 배치 결과와 대조"] B -.-> D C -.-> D
단계를 셋으로 나누고 하나씩 옮겼다. 각 단계마다 새 코드의 결과와 기존 배치의 결과를 대조해 같은지 확인한 뒤 다음으로 넘어갔다. 대상 산정이 같은지 확인되기 전에는 출금을 옮기지 않았다.
이 방식은 느렸다. 대신 어느 단계에서 어긋났는지가 항상 분명했고, 되돌릴 범위도 그 단계로 한정됐다.
이관 뒤 넉 달
옮기고 나서 넉 달 동안 금액 불일치와 수동 보정이 나오지 않았다. 매달 다섯에서 스무 건씩 손보던 작업이 사라졌다.
다만 숫자보다 의미 있었던 것은 불일치가 났을 때 무엇을 볼지가 정해진 것이었다. 이전에는 불일치를 발견하는 것부터가 사람의 몫이었고, 발견해도 어느 쪽이 맞는지 판단할 기준이 없었다.
대사가 제대로 도는지는 누가 보나
대사는 차이를 발견하는 장치지 차이를 만들지 않는 장치가 아니다. 넉 달 동안 걸린 게 없다는 것은 좋은 신호지만, 대사 자체가 제대로 돌고 있는지는 별도로 확인해야 한다. 집계 기준이 한쪽만 바뀌면 멀쩡한 데이터를 차이로 보고하거나 진짜 차이를 놓친다.
외부 결과가 끝까지 불명확한 건도 남는다. 요청은 갔는데 성공인지 실패인지 조회로도 확정되지 않는 경우가 드물게 있고, 그건 사람이 기관에 확인해야 한다. 자동으로 판단하면 원장을 틀리게 만든다.
회수를 옮기며 얻은 결론은 하나다. 트랜잭션은 내부의 부분 반영을 막지만 외부와의 차이는 막지 못한다. 외부를 부르는 순간 대조는 선택이 아니라 함께 만들어야 하는 짝이다.