혹시나 해서 둔 배치에 진짜로 걸렸다
응답을 못 받아 미확정으로 남은 결제를 결제사 노티가 풀어 주고 있었는데, 그 노티를 보내지 않는 결제사가 있었다. 혹시 몰라 둔 복구 배치를 결제사마다 나눠 만든 과정을 적었다.
결제는 대부분 요청한 자리에서 끝난다. 승인을 요청하면 결제사가 성공인지 실패인지 그 응답으로 알려주고, 그 값으로 상태를 확정한다. 남는 것은 응답을 받지 못했을 때다.
그렇게 결과를 모르는 채로 남은 건은 결제사가 따로 보내 주는 노티로 대개 풀렸다. 그 노티가 오지 않는 경우도 있을까 싶어 직접 물어보는 복구 배치를 하나 뒀는데, 한참을 빈손으로 돌던 그 배치에 건이 남기 시작했다.
응답을 못 받으면 결과를 모르는 채로 남았다
승인 요청에 응답이 오면 거기서 끝난다. 성공이면 성공, 실패면 실패로 바로 정해지고 다음으로 넘어간다. 결제의 대부분이 이 경로로 지나간다.
문제는 응답이 오지 않을 때다. 타임아웃이 나면 우리 쪽에는 요청을 보냈다는 사실만 남고, 돈이 실제로 움직였는지는 알 수 없다.
이 상태를 미확정으로 저장했다. 실패로 단정하면 재시도가 중복 승인을 만들고, 성공으로 단정하면 존재하지 않는 결제가 원장에 남는다. 어느 쪽으로도 기울이지 않는 것이 유일하게 안전한 선택이었다. 실패와 Timeout까지 포함한 금융 결제 테스트 범위에서 이 미확정 상태를 테스트 기준으로 먼저 못 박아 뒀다.
미확정을 푸는 길은 둘이었다. 결제사가 뒤늦게 보내 주는 노티를 받거나, 우리가 다시 묻거나. 노티가 오면 그걸로 확정하면 그만이라 편했고, 실제로 대부분 그렇게 풀렸다.
승인 요청 -> 응답 있음 -> 그 자리에서 확정
승인 요청 -> 응답 없음 -> 미확정으로 저장
-> 노티가 오면 확정
-> 오지 않으면?
안 올 수도 있다고 보고 배치를 하나 뒀다
노티는 결제 흐름의 본선이 아니다. 요청과 응답으로 끝나는 것이 정상이고, 노티는 그 밖에서 따로 오는 알림이다. 본선이 아닌 만큼 언제 무엇을 보낼지도 결제사가 각자 정할 수 있다고 봤다.
거기에 오지 않았을 때 알아챌 방법이 없다는 것이 걸렸다. 도착한 노티는 로그에 남지만 오지 않은 노티는 아무 데도 남지 않는다. 미확정 건이 며칠 그대로 있어도 그게 노티가 늦는 것인지 영영 안 오는 것인지 구분할 수 없었다.
그래서 미확정으로 오래 남아 있는 건을 주기적으로 훑어 결제사에 직접 묻는 배치를 뒀다. 노티가 제때 오는 동안에는 이 배치가 집을 것이 없다. 빈손으로 도는 것이 정상이라고 생각했고, 실제로 그랬다.
사람이 만들지 않는 결제부터 걸렸다
정기 결제처럼 배치가 승인을 만드는 쪽이 먼저였다. 미확정으로 들어간 건이 시간이 지나도 확정되지 않고 그대로 있었다. 사람이 결제 화면을 보고 있으면 결과가 안 나오는 것을 누군가 알아채지만, 배치가 만든 요청은 아무도 지켜보지 않는다.
배치가 결제사에 물어보니 결과는 이미 정해져 있었다. 승인이 난 건은 승인이었고 실패한 건은 실패였다. 상태가 애매한 것이 아니라 우리만 모르고 있었다.
한 결제사에서만 나왔다. 다른 곳은 같은 상황에서 노티가 정상적으로 왔다. 연동 규격이 비슷해 보여도 언제 무엇을 알려줄지는 각자 정하고 있었고, 그 정책은 문서에 다 적혀 있지도 않았다.
하나로 묶으려다 포기했다
복구를 하나 만들어 모든 결제사에 쓰려고 했다. 미확정 건을 조회해서 확정하는 일이니 상대가 달라도 같은 모양일 것이라고 봤다.
아니었다. 조회에 넣는 값이 달랐고 돌아오는 상태 코드 체계가 달랐다. 무엇보다 어떤 값을 최종으로 볼 것인지가 달랐다. 한쪽에서 확정으로 읽는 상태가 다른 쪽에서는 아직 진행 중이라는 뜻이었다.
하나로 묶으려면 이 차이를 전부 조건문으로 흡수해야 했다. 결제사가 하나 늘 때마다 분기가 늘고, 어느 분기가 어느 상대를 위한 것인지 읽을 수 없게 된다. 같은 코드처럼 보이는데 실제로는 상대마다 다르게 도는 상태가 되는 것이다.
공통으로 만드는 것이 항상 이득은 아니었다. 상대가 우리 통제 밖에 있고 그 규칙이 각자 바뀔 수 있다면, 억지로 합친 코드는 나중에 어느 쪽 규칙이 바뀌었는지 짚기 어렵게 만든다.
결제사마다 무엇이 달랐나
| 항목 | 어떻게 갈렸나 |
|---|---|
| 노티를 보내는 조건 | 결과가 정해지면 보내는 곳, 일부 상태만 보내는 곳, 보내지 않는 곳 |
| 조회 요청 | 식별자로 무엇을 받는지와 응답 구조가 각자 다름 |
| 상태 코드 | 같은 뜻을 서로 다른 이름으로 표현 |
| 최종으로 볼 상태 | 어디까지를 확정으로 볼지의 기준이 다름 |
이 중에서 가장 위험한 것이 마지막 줄이었다. 앞의 셋은 틀리면 에러가 나거나 값이 비어서 곧 드러나지만, 최종 판정 기준을 잘못 맞추면 아직 안 끝난 결제를 끝난 것으로 반영한다. 조용히 틀리고, 대사에서야 걸린다.
뼈대는 같게 두고 묻는 부분만 갈랐다
그렇다고 전부 따로 만드는 것도 답이 아니었다. 상대가 달라도 복구가 하는 일의 순서는 같았다. 미확정 건을 고르고, 결제사에 묻고, 답을 우리 상태로 옮기고, 결과를 기록하고, 풀리지 않으면 넘긴다.
이 순서를 공통으로 두고 결제사에 묻는 부분과 답을 해석하는 부분만 결제사마다 구현하게 했다.
for (건 : 미확정건_조회(결제사)) {
응답 = 어댑터.조회(건); // 결제사마다 다름
상태 = 어댑터.해석(응답); // 결제사마다 다름
확정하거나_보류(건, 상태); // 공통
기록(건, 응답); // 공통
}
flowchart TD
A["미확정 결제"] --> B{"노티가 오는 결제사인가"}
B -- "온다" --> C["노티를 기다림"]
B -- "안 온다" --> D["복구 배치 대상"]
D --> E["결제사별 어댑터로 조회"]
E --> F{"답이 나왔나"}
F -- "예" --> G["승인 또는 취소로 확정"]
F -- "아니오" --> H["다음 회차로 보류"]
새 결제사가 붙으면 두 자리만 채우면 됐다. 상태 전이 규칙과 기록, 알람 조건은 건드리지 않았다. 결제사마다 다른 것은 그 결제사 파일 안에만 있게 했고, 그래서 한 곳의 규칙이 바뀌어도 다른 곳을 다시 확인할 필요가 없었다.
공통 뼈대에 결제사 이름으로 분기하는 코드를 넣지 않는 것을 규칙으로 뒀다. 한 줄만 넣으면 편한 순간이 몇 번 있었는데, 그 한 줄이 생기면 다음 사람이 같은 자리에 두 번째 줄을 넣는다.
조회해도 답이 안 나오는 건이 남았다
물어봐도 계속 확정되지 않는 건이 있었다. 이건 두 갈래로 뺐다.
확정됨 -> 승인 또는 취소로 반영
미확정 · 횟수 이내 -> 다음 회차에 다시 조회
미확정 · 횟수 초과 -> 알람 · 대사 대상으로 이관
하나는 대사다. 자동 확정을 포기하고 대사 대상으로 남겨 최종 금액을 다른 기준으로 맞추게 했다. 결제·정산 대사가 사람이 봐야 한다고 가른 셋 중 하나가 여기서 넘어간 건들이다.
다른 하나는 알람이다. 같은 건을 정해진 횟수 이상 조회했는데도 답이 없으면 알리고 사람이 확인했다. 배치가 같은 건을 조용히 영원히 집고 있는 상태를 만들지 않기 위해서였다. 자동 처리가 포기하는 지점을 정해 두지 않으면 그 건은 실패로도 성공으로도 세어지지 않는다.
안 보내는 목록은 우리가 들고 있다
이 구조는 어느 결제사가 노티를 보내고 어디가 보내지 않는지를 우리가 안다는 전제 위에 있다. 그 목록은 우리 쪽에 적혀 있고, 상대의 정책이 바뀌어도 저절로 따라가지 않는다.
보내던 곳이 보내지 않게 되면 그 건은 복구 대상에서 빠진 채 미확정으로 남는다. 대사가 결국 잡겠지만 그때는 이미 하루가 지나 있다. 반대 방향은 낭비로 끝나서 덜 위험하다. 안 보내던 곳이 보내기 시작하면 배치가 이미 확정된 건을 한 번 더 물어볼 뿐이다.
이 비대칭을 알고도 목록을 자동으로 맞추는 장치는 만들지 않았다. 결제사가 소수였고 물량도 한쪽에 몰려 있어서, 정책이 바뀌면 연동 담당자를 통해 먼저 알게 되는 구조였기 때문이다. 상대가 늘면 다시 볼 판단으로 남겼다.
노티에 기대는 것은 편했다. 받아서 반영하면 끝이고 우리가 다시 물을 일이 없다. 다만 그건 본선이 아니라서, 상대가 보내지 않기로 해도 규격을 어기는 것이 아니다. 응답으로 끝나지 못한 결제의 뒤처리를 그런 채널에 맡겨 두고 있었던 셈이다. 혹시나 해서 둔 배치가 값을 한 것은 무언가를 고쳐서가 아니라, 우리가 무엇에 기대고 있었는지 알려줬기 때문이다.