연락처 송금 정산 구조 개선과 수수료 계층 분배 안정화
목차
연락처 송금 관련 기능을 이번에 손봤다. 이미 돌아가고 있던 흐름인데, 데이터 구조랑 정산 UI 쪽에서 정리가 필요한 부분이 누적되어 있었음. 규모가 크진 않았지만 이런 것들 안 건드리면 나중에 상위 기능 붙일 때 훨씬 복잡해지는 게 뻔해서 이번에 같이 처리했다.
전체 송금 흐름은 이렇게 생겼다.
입금 알림 수신 (Android 앱)
→ 서버로 원본 메시지 전송
→ 주문 매칭 (금액 + 발신자 + 시간)
→ 은행 핸들러 실행 (Playwright)
→ 로그인 → 수취인 입력 → 이체 → 완료 감지
→ 주문 상태 업데이트
→ 알림 발송
입금 알림이 들어오면 금액, 발신자, 수신 시간 세 가지를 조합해서 주문을 찾는다. 셋 중 하나라도 조건에서 벗어나면 매칭 실패 처리하고 수동 확인 큐로 빠진다. 타임 윈도우가 항상 트레이드오프인데, 너무 짧으면 은행 입금 지연으로 놓치고 너무 넓으면 동일 금액 + 동일 발신자 충돌 가능성이 생긴다. 실제 운영 데이터 보면서 은행별로 윈도우를 조금씩 다르게 가져가는 게 결국 맞더라.
은행 핸들러 추상화와 셀렉터 자가학습
은행 핸들러는 추상 클래스를 상속받아 구현한다. 이 구조가 맞는 이유가, 이체 흐름 자체는 어느 은행이든 크게 다르지 않기 때문이다. 로그인 → 수취인 입력 → 금액 입력 → 확인 → 완료 감지. 순서는 같은데 각 단계의 DOM 구조가 은행마다 다르고, 같은 은행도 앱 업데이트 때마다 셀렉터가 조금씩 밀린다.
추상 클래스 구조는 대략 이렇다.
class BankHandlerBase:
async def login(self): ...
async def fill_recipient(self, account: str, bank_code: str): ...
async def fill_amount(self, amount: int): ...
async def confirm_transfer(self) -> bool: ...
async def detect_completion(self) -> TransferResult: ...
class KBHandler(BankHandlerBase):
async def fill_recipient(self, account: str, bank_code: str):
selector = await self.get_selector("recipient_input")
await self.page.fill(selector, account)
# ...
get_selector가 핵심이다. DB에 저장된 셀렉터 후보군 중 해당 은행-화면-필드 조합으로 조회하고, 성공한 트랜잭션에서 사용된 셀렉터는 weight를 올리는 방식으로 자가학습이 된다. 운영 중에 셀렉터가 바뀌어도, fallback 후보 중 하나가 성공하면 그쪽으로 자연스럽게 전환되는 구조. 완전히 새로운 화면이 생기면 사람이 한 번은 봐야 하지만, 기존 셀렉터가 조금씩 drift되는 케이스는 이걸로 꽤 커버된다.
운영하다 보면 Playwright 타임아웃이 특정 은행에서 갑자기 늘어나는 패턴이 생기는데, 그게 셀렉터 drift 신호라는 걸 경험으로 알게 됨. 모니터링 경보 기준으로 쓰기 좋은 지표이기도 하다.
완료 감지도 신경 쓸 부분이다. 이체 버튼 누른 뒤 완료 화면이 뜨는 타이밍이 은행마다 다르고, 네트워크 상황에 따라 늦게 응답하는 경우도 있다. 타임아웃을 너무 짧게 잡으면 실제로는 성공했는데 실패로 처리하는 오탐이 생기고, 너무 길면 진짜 실패 케이스에서 트랜잭션이 대기 상태로 걸려 후속 처리가 밀린다. 완료 여부를 화면 텍스트로만 판단하기보다 서버 콜백이나 DB 상태 변경과 이중으로 확인하는 게 안전하다.
수수료 계층 분배와 반올림 정책
수수료 구조는 유통 계층 간 요율 차이를 수익으로 가져가는 방식이다. 상위 계층이 설정한 요율에서 하위 계층의 요율을 뺀 차액이 해당 계층의 이익이 된다.
| 계층 | 적용 요율 | 차액 (해당 계층 수익) |
|---|---|---|
| A (최상위) | 3.0% | 0.5% |
| B | 2.5% | 0.8% |
| C (말단) | 1.7% | 1.7% |
계층이 늘어날수록 각자 독립적으로 계산을 하게 되는데, 반올림 정책이 통일되지 않으면 합산할 때 오차가 발생한다. 금액이 작을 때는 1원 차이라 그냥 넘어갈 수 있는데, 거래량이 쌓이면 누적 오차가 무시 못 할 수준이 되고, 정산 시점에 숫자 안 맞으면 원인 추적하는 데 시간이 많이 든다.
원 단위 반올림은 수수료를 내는 쪽 기준으로 내림 처리한다. 받는 쪽에서 조금 덜 받더라도 "계산한 것보다 더 냈다"는 말이 나오지 않게 하는 게 우선이다. 비슷한 정산 도메인 작업 하다 보면 대부분 이 방향으로 수렴하더라. 분쟁 소지를 줄이는 실용적인 선택이기도 하고.
정산 검증할 때 주의할 게 하나 있는데, 전체 합산이 맞는다고 안심하면 안 된다는 거다. 특정 계층에서 잘못 계산된 채로 다른 계층에서 상쇄되면 전체 합은 맞는데 배분이 틀린 상태가 된다. 검증은 계층별로 row by row 대조해야 한다.
계층별 요율을 코드에 하드코딩하면 요율 변경 때마다 배포를 새로 해야 한다. DB로 빼두면 배포 없이 조정이 가능하지만, 요율 변경 이력 관리와 effective date 처리가 필요해진다. 간단한 것 같아서 나중에 하려다 미루면 요율 히스토리 소급 추적이 안 되는 상황이 생김. 구조 잡으면서 이 부분도 같이 고려해뒀다.
정산 화면과 배치 멱등성
정산 화면에서 사람들이 보려는 건 결국 세 가지다. 총 거래액, 내가 낸 수수료, 실제로 받는 돈. 이 세 숫자를 위계 없이 나란히 놓으면 어디가 최종인지 한눈에 안 들어온다. 총액 → 수수료 차감 → 실수령 순서로 시각적으로 흘러가게 배치하는 게 직관적이고, 마이너스 금액은 색으로 구분해서 한 번 더 확인하게 해주는 게 낫다.
숫자 포맷 일관성도 생각보다 중요하다. 화면 한쪽에서 1,500,000원이고 다른 쪽에서 1500000이면 같은 금액인데도 순간 의심하게 된다. 특히 금액이 크거나 케이스별 단위가 혼재될 때 포맷이 달라지면 신뢰도 타격이 크다. 포맷 유틸리티 하나로 통일해두는 게 맞다.
배치 정산은 멱등성이 기본 전제다. 같은 기간을 두 번 실행해도 결과가 달라지면 안 된다. 정산 레코드를 생성할 때 기간 + 계층 조합 같은 고유 키로 upsert 처리하면 재실행해도 덮어쓰거나 건너뛰는 동작이 명확해진다. "두 번 실행 안 하면 되지"는 언젠가 사고가 난다.
배치가 중간에 실패했을 때도 어디까지 처리됐는지 알 수 있어야 재시작이 가능하다. 정산 단위를 작게 쪼개서 각각 커밋하고 실패 시 해당 단위부터 재처리하는 구조가 맞다. 배치 전체를 하나의 큰 트랜잭션으로 묶으면 안전해 보이지만, 처리량이 커지면 락 경합이랑 타임아웃 문제가 따라온다.
작업 규모로 보면 크지 않았는데, 수수료나 정산 도메인은 조금 어설프게 설계해두면 요율 구조 바꿀 때나 계층 추가할 때 생각보다 많은 코드를 건드리게 된다. 이번 작업이 그 기반을 조금 더 단단하게 해줬다고 보면 된다.
댓글 0
첫 댓글 달아줘.