연락처 송금 정산
목차
연락처 송금이라는 흐름 자체는 단순해 보이지만, 실제로 구현해보면 연결 지점이 생각보다 많다. 입금 알림이 Android 앱에 떨어지고, 그게 서버로 넘어가서 주문을 매칭하고, Playwright로 실제 이체를 처리하고, 완료가 감지되면 상태를 업데이트하고 알림을 보내는 것까지. 각 단계가 실패했을 때 어디서 재시도할지, 어떤 상태를 남겨둬야 하는지가 전체 흐름의 핵심이다.
전체 흐름을 정리하면 이렇다:
입금 알림 수신 (Android 앱)
→ 서버로 원본 메시지 전송
→ 주문 매칭 (금액 + 발신자 + 시간)
→ 은행 핸들러 실행 (Playwright)
→ 로그인 → 수취인 입력 → 이체 → 완료 감지
→ 주문 상태 업데이트
→ 알림 발송
매칭 단계가 의외로 까다롭다. 동일 금액을 같은 발신자가 짧은 시간 안에 두 번 보내는 케이스가 드물지 않게 생긴다. 이때 어떤 기준으로 어느 주문에 붙일지 명확하게 정해두지 않으면 수동 처리 건이 쌓인다. 시간 윈도우를 얼마나 넓게 잡을지, 발신자 이름 매칭을 어느 정도까지 허용할지는 운영 데이터를 보면서 계속 조정해야 하는 부분이다.
은행 핸들러를 추상 클래스로 분리한 이유
은행마다 인터넷뱅킹 UI가 완전히 다르다. 버튼 위치도 다르고, 수취인 입력 방식도 다르고, 이체 완료 감지 방식도 제각각이다. 처음에 은행별로 ad hoc으로 처리하면 코드가 금방 지저분해지고, 새 은행 추가할 때마다 기존 코드를 건드려야 한다. 그래서 공통 흐름은 추상 클래스로 잡아두고, 은행별로 셀렉터와 세부 동작만 오버라이드하는 구조로 갔다.
class BaseBankHandler(ABC):
def __init__(self, config: BankConfig):
self.config = config
self.selectors = self._load_selectors_from_cache()
@abstractmethod
async def login(self, page) -> bool: ...
@abstractmethod
async def fill_recipient(self, page, recipient: str) -> None: ...
@abstractmethod
async def confirm_transfer(self, page, amount: int) -> bool: ...
async def run(self, page, order: TransferOrder) -> TransferResult:
if not await self.login(page):
raise LoginFailedError(self.config.bank_code)
await self.fill_recipient(page, order.recipient_account)
success = await self.confirm_transfer(page, order.amount)
if success:
self._save_selector_success()
return TransferResult(success=success, order_id=order.id)
셀렉터를 DB에 캐시해두는 건 Playwright 특성상 어쩔 수 없는 선택이었다. 은행 UI가 가끔씩 업데이트되면 셀렉터가 틀어지는데, 이걸 배포 없이 DB에서 수정할 수 있어야 한다. 거기에 성공한 케이스의 셀렉터를 자가학습 형태로 쌓아두면, 실패율이 올라갈 때 최근 성공 셀렉터로 빠르게 롤백하는 것도 가능해진다.
트레이드오프는 있다. DB에 셀렉터를 두면 코드와 설정이 분리되어서 버전 관리가 애매해진다. 어떤 배포 시점에 어떤 셀렉터가 사용됐는지 추적하려면 셀렉터 변경 이력도 같이 남겨야 하는데, 이 부분이 아직 완전히 정리된 건 아니다. 셀렉터를 코드로 관리하면 PR 리뷰 흐름에 자연스럽게 들어오지만, 핫픽스가 필요할 때 배포 없이 못 고친다는 게 운영 관점에서는 부담이다. 이건 케이스바이케이스로 판단할 수밖에 없는 문제 같다.
수수료 계층 분배와 정산 검증
수수료 분배 구조는 유통 계층 구조를 그대로 따른다. 각 계층이 서로 다른 요율을 갖고 있고, 하위 계층이 실제 적용하는 요율과 상위 계층이 설정한 요율의 차이가 그 계층의 수익이 된다. 구조 자체는 단순한데, 계층이 깊어질수록 올림/버림 처리가 누적 오차를 만든다.
원 단위 처리 기준은 이렇게 잡았다:
| 항목 | 처리 방식 | 이유 |
|---|---|---|
| 사용자 수수료 | 내림 (floor) | 사용자에게 유리하게 |
| 계층별 지급액 | 정확한 정수 | 오차 없이 |
| 계층 수익 | 나머지로 확정 | 전체 합산이 맞도록 |
"사용자에게 유리한 방향으로 내림"이라는 원칙을 갖고 가면 분쟁이 줄어든다. 수수료를 내는 쪽은 계산된 것보다 조금 덜 내고, 그 차이를 운영 측에서 흡수하는 방식이다. 금액이 작을 때는 오차가 미미하지만, 거래 건수가 많아지면 누적 차이를 주기적으로 모니터링할 필요가 생긴다.
정산 배치 작업에서 멱등성은 협상 대상이 아니다. 같은 기간을 두 번 돌렸을 때 결과가 달라지면, 배치 실패 후 재실행하거나 수동으로 재처리할 때 데이터가 꼬인다. 구현 방법은 여러 가지인데, 가장 단순한 건 정산 레코드에 (period, tier_id) 유니크 키를 걸고 ON CONFLICT DO NOTHING으로 처리하는 것이다. 계산 결과 자체가 바뀔 수 있는 상황이라면, 배치 실행 전에 "이미 정산 확정된 기간인지" 체크하는 가드를 두는 게 낫다. 확정 상태로 바뀐 정산은 덮어쓸 수 없도록 상태 전이에 제약을 거는 편이 안전하다.
정산 화면에서 숫자가 전부다
관리자나 파트너가 정산 화면에서 보는 건 결국 세 가지다. 총 거래액, 수수료, 실수령액. 이 숫자가 직관적으로 읽히지 않으면 문의가 들어온다.
금액 표기에서 챙길 것들:
- 천 단위 구분자는 모든 금액 필드에 일관되게 적용. 어떤 필드는
1000000이고 어떤 필드는1,000,000이면 읽는 사람이 혼란스럽다. - 마이너스 금액은 색상으로도 구분. 텍스트만으로는 빠르게 스캔할 때 놓치기 쉽다.
- 합계 행은 시각적으로 분리. 일반 행과 스타일이 같으면 눈에 잘 안 들어온다.
- 기간 필터 변경 시 로딩 상태를 명확히. 숫자가 갱신 중인지 아닌지 모르면 사용자가 새로고침을 반복한다.
숫자를 보여주는 방식 자체가 신뢰와 직결된다는 게 금전 관련 화면의 특성이다. 디자인적으로 정교하지 않아도, 숫자만 명확하게 읽히면 문의가 현저히 줄어든다는 걸 운영하면서 몸으로 배웠다.
작업 규모는 크지 않았지만, 송금이나 정산처럼 금전이 오가는 흐름은 작은 버그 하나가 신뢰 문제로 직결된다. 핸들러 추상화, 셀렉터 캐시, 멱등 배치, 표기 일관성 같은 것들이 겉으로 잘 안 보이는 안전장치들인데, 이런 게 하나씩 쌓이면서 운영 중에 불필요하게 소모하는 시간이 줄어든다.
댓글 0
첫 댓글 달아줘.