Building an EAI Middleware Layer
Retransmission Is the Default
한국어 원문으로 표시합니다.
한 줄 요약
채널은 타임아웃이 나면 같은 GUID 로 다시 보낸다. 계정계가 멱등하지 않다면 중복을 막는 것은 중계 계층의 몫이고, 그 도구는 GUID 를 기본 키로 한 상태 원장이다. 원장은 "처리했다" 만이 아니라 "보냈는데 모른다" 와 "보내지 못했다" 를 구분해 적어야 하고, 재처리는 그 구분 위에서만 안전하다.
왜 이게 필요했나
4모듈의 중계는 받은 대로 부른다. 네트워크가 멀쩡하면 문제가 없지만, 채널과 허브 사이에서 응답 하나가 늦게 도착하면 채널은 실패로 알고 재전송하고 — 허브는 두 번째 요청도 계정계에 넘긴다. 이 코스의 계정계 픽스처는 일부러 멱등하지 않게 만들었다. 같은 이체를 두 번 받으면 두 번 뺀다. 현실의 원장 시스템도 "같은 요청인지" 를 스스로 판단하지 못하는 경우가 많고, 판단하더라도 그 근거(거래 고유번호)를 누군가 끝까지 실어 날라야 한다.
메시징의 전달 보장을 떠올리면 중복은 예외가 아니라 기본값이다. 요청을 확실히 전달하려면 응답이 없을 때 다시 보내야 하고(최소 한 번), 다시 보내는 순간 수신 쪽은 같은 것을 두 번 받을 수 있다. Enterprise Integration Patterns 는 이 문제를 받는 쪽에서 푸는 방법을 Idempotent Receiver 로 정리한다 — 같은 메시지를 여러 번 받아도 결과가 한 번 받은 것과 같게 만드는 것이다. 이 모듈에서 중계 계층이 계정계 앞에서 그 역할을 맡는다.
어떻게 동작하나
원장 한 줄 = GUID 하나. guid 를 기본 키로 두면 "같은 GUID 가 두 줄" 이라는 상태 자체가 불가능해진다. 원장에는 상태·응답코드·요청 원문·응답 원문·시도 횟수·갱신 시각을 둔다. 요청 원문을 저장하는 이유는 재처리 때문이고, 응답 원문을 저장하는 이유는 재전송에 첫 번째와 똑같은 답을 돌려주기 위해서다.
상태는 다섯 개다.
| 상태 | 뜻 | 같은 GUID 가 다시 오면 |
|---|---|---|
| SENT | 계정계에 보내는 중(선점함) | E903 — 처리 중, 기다리지 않고 바로 답 |
| DONE | 확정된 답이 있다(0000·B2xx·E500) | 저장한 응답을 그대로 |
| UNKNOWN | 보냈는데 답을 못 받았다(E901) | E901 — 계정계를 다시 부르지 않는다 |
| FAILED | 보내지 못한 것이 확실하다(E902) | 다시 보내도 된다 |
| RECEIVED | 받았지만 아직 보내기 전 | 구현에 따라 SENT 와 같이 다룬다 |
보내기 전에 적는다. 핵심은 순서다. 계정계를 부른 뒤에 원장에 적으면, 그 사이에 허브가 죽었을 때 원장에는 흔적이 없고 채널의 재전송은 새 거래로 처리된다. 그래서 먼저 SENT 로 한 줄을 선점하고, 그다음 부르고, 결과를 적는다. 선점은 원자적이어야 한다. 두 스레드가 같은 GUID 를 동시에 받았을 때 둘 다 "없네, 내가 처리하자" 로 판단하면 원장이 있어도 소용없다. SQLite 의 INSERT ... ON CONFLICT DO NOTHING(UPSERT 문서)은 기본 키 충돌이면 아무것도 넣지 않고, 영향받은 행 수로 "내가 선점했는가" 를 알려 준다. 조회하고 나서 넣는 두 단계가 아니라 넣기 한 번으로 판단한다.
UNKNOWN 은 조회로만 푼다. 결과를 모르는 거래를 다시 보내는 것은 도박이다. 계정계가 처리했다면 이중 이체가 된다. 대신 계정계의 조회 API 로 그 GUID 가 처리됐는지 묻는다. 처리됐으면 그 결과로 DONE, 기록이 없으면 FAILED(미처리 확정)로 바꾼다. 여기에 함정이 하나 있다. 방금 타임아웃 난 거래는 계정계가 아직 처리하는 중일 수 있다. 지금 조회해서 "없음" 을 받고 FAILED 로 바꾼 뒤 재처리하면, 1초 뒤 계정계가 첫 요청을 마저 처리해 두 번 빠진다. 그래서 조회는 대상의 최대 처리 시간보다 오래된 UNKNOWN 만 한다(--min-age).
DLQ 는 버리는 곳이 아니라 기다리는 곳이다. 보내지 못한 거래(E902)는 원인이 풀린 뒤 다시 보내면 된다. Dead Letter Channel 은 처리할 수 없는 메시지를 따로 모아 두는 채널이다. 이 실습에서는 FAILED/E902 행이 그 자리다. 재처리 원칙은 셋이다 — ① 같은 GUID 로 보낸다(새 번호를 따면 원장이 중복을 못 본다) ② 시도 횟수에 한도를 둔다(영원히 실패하는 거래가 매 주기 계정계를 두드리지 않게) ③ UNKNOWN 은 절대 재처리 대상이 아니다.
원장도 치운다. 원장은 무한히 자랄 수 없다. 하지만 지우는 규칙이 곧 중복 방지의 한계다. DONE 을 7일 뒤 지운다는 것은 "8일 뒤에 온 재전송은 막지 못한다" 는 뜻이므로, 보존 기간은 채널이 재전송할 수 있는 가장 긴 기간보다 길어야 한다. 그리고 확정되지 않은 거래(UNKNOWN·FAILED)는 기간과 무관하게 남긴다 — 지우는 순간 그 거래는 조사할 근거가 사라진다.
현장에서 만나는 모습
가장 흔한 사고는 재처리 배치가 "실패한 것 전부" 를 다시 보내는 것이다. 실패 목록에 타임아웃 건이 섞여 있고, 그중 일부는 계정계가 이미 처리한 건이다. 다음 날 아침 고객센터에 이중 출금 문의가 몰린다. 두 번째는 재처리할 때 GUID 를 새로 따는 것 — 원장이 같은 거래임을 알아볼 방법이 사라진다. 세 번째는 원장을 메모리(딕셔너리)에 두는 것이다. 허브를 재기동하는 순간 "무엇을 보냈는지" 를 잊는다. 네 번째는 원장 조회와 삽입 사이의 경쟁이다. 부하가 낮은 개발 환경에서는 절대 드러나지 않다가 채널이 짧은 간격으로 재전송하는 장애 상황에서만 이중 처리가 난다.
다음 실습에서 할 것
원장 스키마를 쓰고, 픽스처의 중계(relay_base.py, 중복 방지 없음)를 복사해 원장을 붙인다 — 끝난 거래의 재전송, 처리 중 재전송(E903), 타임아웃(UNKNOWN)과 연결 실패(FAILED)를 차례로. 그다음 UNKNOWN 을 조회로 확정하는 resolve.py, 미전송 확정 거래만 같은 GUID 로 다시 보내는 reprocess.py, 보존 기간 정리 purge.py 를 만든다. 채점기는 임시 원장과 계정계 호출 통계로 이중 호출을 잡는다.