연락처 송금 수수료 분배
목차
연락처 송금은 일반 결제와 구조가 좀 다름. 사용자가 직접 앱에서 계좌를 치는 게 아니라, Android 기기에서 입금 알림을 수신해서 서버로 원본 메시지를 넘기는 방식이다. 그 메시지를 금액 + 발신자 + 수신 시간 세 가지 기준으로 주문과 매칭시키고, 매칭이 성공하면 Playwright로 작성된 은행 핸들러가 실제 이체를 처리한다.
흐름을 정리하면 이렇다:
입금 알림 수신 (Android 앱)
→ 서버로 원본 메시지 전송
→ 주문 매칭 (금액 + 발신자 + 시간)
→ 은행 핸들러 실행 (Playwright)
→ 로그인 → 수취인 입력 → 이체 → 완료 감지
→ 주문 상태 업데이트
→ 알림 발송
이 흐름에서 병목이 생기기 쉬운 지점은 주문 매칭이다. 금액만 보면 같은 금액이 같은 시간대에 들어오는 경우가 꽤 있어서, 발신자와 시간 윈도우를 조합해야 충돌을 줄일 수 있다. 매칭 로직이 느슨하면 한 주문에 여러 입금이 붙거나, 반대로 입금이 있는데 매칭이 안 되는 케이스가 생김.
은행 핸들러는 추상 클래스를 상속받아 은행별로 구현했다. 공통 인터페이스를 고정해두고 UI 구조가 달라지는 부분만 각 핸들러에서 오버라이드하는 패턴인데, 셀렉터가 뱅킹 앱 업데이트마다 바뀌는 게 제일 골치임. 이걸 코드에 하드코딩하면 앱 업데이트 때마다 배포를 해야 해서, 셀렉터를 DB에 캐시해두고 성공한 케이스를 기반으로 자가학습하는 구조를 붙였다.
class BankHandler(ABC):
@abstractmethod
def login(self) -> bool: ...
@abstractmethod
def input_recipient(self, account: str) -> bool: ...
@abstractmethod
def confirm_transfer(self, amount: int) -> bool: ...
@abstractmethod
def detect_completion(self) -> TransferResult: ...
class KBHandler(BankHandler):
def login(self) -> bool:
selector = self.selector_cache.get("login_btn", fallback=".kb-login")
# fallback 후보 순서대로 시도, 성공 시 캐시 갱신
...
셀렉터 캐시는 최근 성공한 케이스의 셀렉터를 우선 시도하고, 실패하면 후보 목록을 순서대로 탐색하는 방식이다. 성공하면 그 셀렉터를 캐시에 올려두니까, 앱 업데이트로 UI가 바뀌었을 때 첫 번째 시도는 실패하더라도 자동으로 복구됨. 후보 셀렉터 목록을 사람이 관리해야 한다는 전제가 있긴 한데, 배포 없이 DB 업데이트만으로 대응할 수 있다는 게 운영 입장에서 큰 차이다.
수수료 분배 구조
수수료 분배는 유통 계층 구조를 따른다. 최상위 요율에서 각 단계의 요율 차이가 해당 계층의 수익이 됨. 구조 자체는 단순한데, 계층이 늘어날수록 원 단위 올림/버림이 쌓여서 실제 정산 결과가 계산식 결과와 미세하게 달라지는 케이스가 생긴다.
계층별 분배 구조를 예시로 표현하면 이런 형태다:
| 계층 | 적용 요율 | 차익 (이 계층 수익) |
|---|---|---|
| 최상위 | 3.0% | 1.0% |
| 중간 | 2.0% | 0.8% |
| 말단 | 1.2% | 1.2% |
반올림 방향을 명확히 정해두지 않으면 분쟁이 생김. 일반적으로 수수료를 내는 쪽은 내림, 받는 쪽은 올림 없이 정확한 금액을 받는 방향이 맞다. 사용자에게 불리한 쪽으로 오차가 쌓이면 소액이더라도 신뢰 손상이 생기고, 한 번 의심이 생기면 정산 전체를 의심하게 됨.
코드 레벨에서는 float 으로 수수료를 계산하면 안 된다. 0.1 + 0.2 같은 부동소수점 오차가 바로 금전 오류로 이어지기 때문에, Python이라면 Decimal, 서버사이드 언어라면 정수 연산으로 처리하는 게 기본임.
from decimal import Decimal, ROUND_DOWN
def calc_fee(amount: int, rate_bps: int) -> int:
"""rate_bps: 수수료율을 basis point 단위로 표현 (1bp = 0.01%)"""
fee = Decimal(amount) * Decimal(rate_bps) / Decimal(10000)
return int(fee.to_integral_value(rounding=ROUND_DOWN))
요율을 basis point 단위로 저장하면 DB와 로직 양쪽에서 소수점을 아예 없앨 수 있어서 관리가 편해진다. 3.0%를 300bp로, 0.5%를 50bp로 저장하는 식이라 비교나 정렬도 정수 연산으로 해결됨.
정산 배치와 멱등성
정산 화면에서 사용자가 실제로 보는 건 세 숫자다. 총 거래액, 수수료, 실수령액. 이 세 가지가 명확하게 보이지 않으면 나머지 기능이 아무리 잘 돌아도 신뢰를 얻기 어려움. 천 단위 구분자, '원' 단위 표기, 마이너스 금액 빨간색 처리는 기본이고, 계층별 내역을 접을 수 있는 구조로 두면 복잡한 분배 구조도 단순하게 보여줄 수 있다.
정산 배치는 멱등성이 핵심이다. 같은 기간을 두 번 실행했을 때 결과가 달라지면 안 됨. 이걸 보장하는 가장 간단한 방법은 정산 실행 자체를 idempotency key로 관리하는 것이다. 이미 완료된 정산 ID가 들어오면 저장된 결과를 그대로 반환하고, 새로 계산하지 않는 방식.
INSERT INTO settlements (id, period_start, period_end, result, created_at)
VALUES (%(id)s, %(start)s, %(end)s, %(result)s, NOW())
ON CONFLICT (id) DO NOTHING;
배치가 중간에 실패했을 때도 재실행이 안전해야 한다. 부분 완료 상태가 남아있으면 다음 실행에서 중복 정산이 생길 수 있어서, 배치 단위로 트랜잭션을 묶거나 실패 시 명시적으로 롤백하는 흐름을 둬야 함. 정산 배치는 재시도가 쉬워야 운영이 편하고, 재시도가 편하려면 멱등성이 먼저 잡혀있어야 한다.
작업 규모 자체는 크지 않았다. 그런데 금전 로직은 작은 수정도 영향 범위를 꼼꼼히 봐야 해서, 케이스를 하나씩 짚어가며 처리했음. 반올림 방향 하나, 충돌 처리 한 줄 이런 것들이 쌓여서 전체 정산 흐름의 신뢰도를 높이는 거라 흘려보낼 수가 없다.
댓글 0
첫 댓글 달아줘.