자동화 slecs

입금 웹훅 구조를 레지스트리 패턴으로 전환해 은행 추가 용이성 개선

목차

연락처 송금 기능에서 입금 통보를 받는 은행이 하나 더 늘었다. 인터넷전문은행 한 곳 추가 요청이었는데, 기존 구조를 보니 진입 컨트롤러에 은행 코드별 if-else 분기가 쭉 늘어서 있었다. 은행 하나 붙일 때마다 같은 파일을 열고, 같은 자리를 건드리고, 검토 받고, 배포하는 사이클이 반복됐던 것. 이번에도 똑같이 하기 싫어서, 이 참에 레지스트리 패턴으로 갈아엎기로 했다.


분기가 컨트롤러에 쌓이는 건 처음엔 별 것 아닌 것처럼 보인다. 은행 두셋 수준이면 if-else도 충분하다. 문제는 연동 대상이 하나씩 늘 때 진입점 파일의 책임이 같이 커진다는 거다. 컨트롤러가 라우팅도 하고, 파싱도 하고, 후처리도 묶어서 알고 있게 되면 - 어느 시점부터 "이 파일 건드리면 뭐가 깨질지 모른다"는 분위기가 팀에 생기기 시작한다. 실제로 이번 건 이전에도 비슷한 이유로 수정이 조심스러워졌던 흔적이 코드에 남아 있었다.

레지스트리 패턴은 이 문제를 꽤 깔끔하게 분리한다. 진입점은 "어떤 은행인지 알아내서 해당 핸들러로 넘긴다"만 한다. 핸들러는 각자 자기 은행 페이로드만 책임진다. 공통 후처리는 유틸로 뺀다. 새 은행 추가할 때 기존 코드를 건드릴 이유가 사라진다.

구조 변경 내용

결제대행사 웹훅 수신 진입점 자체는 그대로 뒀다. 바꾼 건 그 안에서 은행 코드를 추출해 핸들러를 찾는 부분이다.

웹훅 수신
 → 은행코드 추출
 → 레지스트리.find(은행코드)
 → 핸들러.parse(raw body + 헤더)
 → 공통유틸.처리(표준 입금 이벤트)

핸들러 인터페이스를 구현하면 자동 등록되게 했고, 공통 후처리 - 파트너 잔액 갱신, 송금 큐 상태 전이, 알림 발송 - 는 유틸 한 곳으로 모았다.

변경 전 변경 후
컨트롤러에서 은행별 분기 진입점은 위임만
후처리 로직 중복 산재 공통 유틸로 통합
은행 추가 = 컨트롤러 수정 핸들러 클래스 하나 추가
파싱·검증 로직이 분기마다 따로 핸들러 단위로 격리

예상보다 오래 걸린 페이로드 표준화

처음 설계할 때는 인터페이스 하나 빼고 빈 등록 구조만 만들면 반나절 안에 끝날 줄 알았다. 막상 은행별 페이로드를 펼쳐보니 그게 아니었다.

입금 통보 페이로드가 은행마다 미묘하게 다르다. 금액 필드 이름이 다르고, 입금자명 인코딩 방식이 다르고, 가상계좌 번호 포함 여부도 다르다. 처음엔 공통 DTO 하나로 받으려 했는데, 그렇게 하면 옵셔널 필드가 폭증하고 - "이 필드는 A은행만 보낸다"는 주석이 DTO에 줄줄이 달리게 된다. 그 순간 if-else가 컨트롤러에서 DTO 파싱 로직 안으로 이사 간 것에 불과하다.

결국 각 핸들러가 자기 은행 raw body를 직접 파싱하고, 표준 입금 이벤트 구조체로 변환한 다음 유틸에 넘기는 방식으로 정리했다. 컨트롤러는 raw body와 헤더만 던진다. 핸들러 안에서 뭘 어떻게 뜯든 외부에서 알 필요가 없다.

class StandardDepositEvent:
    bank_code: str
    amount: int
    depositor_name: str
    virtual_account_no: str
    received_at: datetime
    raw_ref: str  # 은행별 원본 트랜잭션 ID

class NewBankHandler(DepositHandler):
    bank_code = "NEW_BANK"

    def parse(self, raw_body: bytes, headers: dict) -> StandardDepositEvent:
        payload = json.loads(raw_body)
        return StandardDepositEvent(
            bank_code=self.bank_code,
            amount=payload["depositAmt"],
            depositor_name=payload["senderNm"].encode("euc-kr").decode("utf-8"),
            virtual_account_no=payload["vacctNo"],
            received_at=parse_bank_dt(payload["txDtime"]),
            raw_ref=payload["bankTxId"],
        )

이 구조의 핵심은 표준 이벤트 타입이다. 유틸 입장에서는 어느 은행에서 온 입금이든 동일한 타입을 받는다. 잔액 계산, 큐 상태 전이, 알림 발송 로직이 은행 코드를 몰라도 된다.

결제 도메인에서 이 패턴을 적용할 때 실감하는 게 있다. 패턴 자체보다 도메인 이벤트 표준화가 더 어렵다. 레지스트리 뼈대를 세우는 건 한 시간이면 된다. 그런데 "은행마다 다른 페이로드를 어떤 표준 이벤트로 수렴시킬 것인가"를 결정하는 게 실제 시간을 잡아먹는다. 표준 이벤트 필드 하나를 빠뜨리면 유틸 쪽에서 다시 은행 코드로 분기하게 되고, 그러면 분기를 뺐다고 할 수 없다.

검증 방식

기존 동작을 깨지 않는 게 최우선이었다.

  • 이커머스 파트너 두 곳 대상으로 입금 시뮬레이션을 돌려 잔액·큐 상태가 이전과 동일한지 비교했다
  • 기존 은행 회귀 케이스 8건 전부 통과 확인
  • 신규 인터넷전문은행 페이로드는 결제 플랫폼 샌드박스로 5건 받아서 처리 결과를 비교했다

샌드박스가 실제 운영 페이로드와 완전히 같진 않아서, 운영 투입 후 첫 수건은 로그 레벨을 올려서 표준 이벤트로 변환되는 과정을 눈으로 확인했다. 이 부분은 어떤 검증을 해도 실제 첫 트래픽 전까지 완전한 확신은 없다. 구조적으로 틀리지 않게 해두고, 첫 트래픽 때 빠르게 볼 수 있게 계측을 심어두는 게 현실적인 대응이었다.

남은 것들

구조는 정리됐는데 후속 작업이 두 가지 있다.

하나는 핸들러별 테스트 픽스처다. 지금은 각 핸들러 폴더에 흩어져 있어서, 픽스처 파일 한 번 모아두고 네이밍 규칙 잡아놓으면 다음 은행 추가할 때 픽스처 위치 찾는 시간을 아낄 수 있다.

다른 하나는 입금 실패·중복 통보 처리다. 현재는 유틸에서 일괄로 처리 중인데, 은행마다 재시도 정책이 달라서 문제가 된다. 어떤 은행은 타임아웃 시 동일 건을 재전송하고, 어떤 은행은 별도 조회 API로 확인 요청을 보낸다. 지금 구조에서는 유틸이 멱등성 처리를 통으로 들고 있는데, 재시도 정책 차이가 커지면 결국 핸들러 레벨로 내려야 할 것 같다. 이건 실제로 엣지 케이스가 운영에서 몇 번 튀어나오면 그때 분리하는 게 맞다고 봐서 일단 남겨뒀다.

입금 통보 연동처가 많아질수록 이 구조의 유지보수 비용이 낮다는 게 좀 더 분명해질 것 같다. 적어도 다음에 은행 하나 더 추가하는 요청이 오면, 컨트롤러 파일을 같이 보면서 설명할 일은 없어졌다.

댓글 0

첫 댓글 달아줘.