연락처 송금 외부 연동과 계층별 수수료 정산 개선
목차
연락처 송금 흐름 자체는 이미 한동안 운영 중이었는데, 이번에 외부 연동 모드(EXTERNAL)를 별도로 지원하도록 분기 로직을 추가했다. 외부 연동이 붙으면 내부 은행 핸들러를 타지 않고 외부 시스템으로 이체 요청을 위임하는 구조인데, 그 경계를 코드 레벨에서 명확히 분리해두는 게 나중 유지보수에 훨씬 낫다고 판단해서 이번 기회에 정리했다.
전체 흐름은 이렇다:
입금 알림 수신 (Android 앱)
→ 서버로 원본 메시지 전송
→ 주문 매칭 (금액 + 발신자 + 시간)
→ 은행 핸들러 실행 (Playwright)
→ 로그인 → 수취인 입력 → 이체 → 완료 감지
→ 주문 상태 업데이트
→ 알림 발송
이 중 "은행 핸들러 실행" 단계가 INTERNAL/EXTERNAL로 분기된다. EXTERNAL 모드면 Playwright 기반 핸들러를 타지 않고 외부 엔드포인트로 페이로드를 넘긴 뒤 결과 콜백을 기다리는 방식이다. 핸들러 추상화 경계가 이미 잡혀 있어서 분기 추가 자체는 어렵지 않았는데, 외부 연동의 타임아웃과 재시도 정책이 내부 핸들러와 달라서 거기서 시간을 좀 썼다. 내부 핸들러는 Playwright 세션이 살아 있는 동안 재시도가 가능하지만, 외부 연동은 응답이 늦어질 때 중복 요청을 방지하는 幂등성 키를 반드시 같이 넘겨야 했다.
내부 핸들러는 추상 클래스를 상속받아 은행별로 구현하는 구조다. 은행 웹 UI가 제각각이라 셀렉터가 달라지고, 은행 측 배포가 있으면 DOM이 바뀌면서 셀렉터가 깨질 수 있다. 그래서 성공한 셀렉터를 DB에 캐시해두고, 실패 시 캐시를 무효화하고 재탐색하는 자가학습 구조를 이미 붙여둔 상태다. 이번 변경이 그 부분에 영향을 주지 않도록 핸들러 추상화 경계를 건드리지 않는 게 포인트였다. EXTERNAL 분기는 추상 클래스 바깥에서 라우팅하는 방식으로 처리해서 기존 핸들러 구현체들은 손대지 않았다.
수수료 계층 정산 - 숫자가 틀리면 신뢰가 먼저 날아간다
수수료 분배는 유통 계층 구조를 따른다. 각 계층마다 요율이 다르고, 하위 계층 요율과 상위 계층 요율의 차이가 해당 계층의 마진이 된다. 구조 자체는 단순한데, 반올림 처리를 잘못하면 계층이 깊어질수록 오차가 누적된다.
원 단위 처리는 기본적으로 수수료를 내는 쪽에서 내림(floor)을 적용한다. 받는 쪽이 예상보다 적게 받는 상황보다, 내는 쪽이 1원이라도 덜 내는 게 분쟁 여지가 작다. 반올림 방향은 사소해 보이지만, 분쟁 케이스를 들여다보면 꽤 자주 나오는 주제라 정책을 코드에 명시적으로 박아두는 게 낫다.
def calc_fee(amount: int, rate_bps: int) -> int:
# bps = basis points (1% = 100bps)
# 수수료는 내림 처리 - 내는 쪽 유리
return amount * rate_bps // 10000
def calc_margin(amount: int, upper_rate_bps: int, lower_rate_bps: int) -> int:
upper_fee = calc_fee(amount, upper_rate_bps)
lower_fee = calc_fee(amount, lower_rate_bps)
return upper_fee - lower_fee
계층별 마진 계산도 각각 floor를 적용한 뒤 뺄셈하는 방식을 쓴다. 전체 금액에 마진율 차이를 한 번에 곱하면 중간 계층에서 발생하는 내림 처리가 무시되기 때문에, 계층 순서대로 하나씩 계산해야 숫자가 맞다. 얼핏 같아 보여도 계층이 세 단계 이상 되면 결과가 달라지기 시작한다. 이 차이를 단위 테스트에 명확하게 박아두지 않으면 리팩터링할 때 조용히 깨지는 종류의 버그다.
정산 배치는 멱등성이 핵심이다. 같은 기간에 대해 두 번 실행해도 결과가 같아야 한다는 뜻인데, 실무에선 재처리 요청이 생각보다 자주 들어온다. 배치 실행 시 기존 정산 레코드가 있으면 skip하거나, 명시적 재실행 플래그를 받아서 delete-insert로 처리하는 방식이 안전하다. 묵시적 update는 어느 시점 기준인지 추적이 안 돼서 나중에 감사 로그를 봐야 할 때 골치 아파진다.
| 처리 방식 | 장점 | 단점 |
|---|---|---|
| skip 기본 + 재실행 플래그 명시 | 실수 재실행 방지, 의도 명확 | 운영 요청 시 플래그 추가 필요 |
| 항상 delete-insert | 단순, 멱등 보장 | 집계 중간 참조 시 순간 누락 가능 |
| update | 변경분만 반영 | 이력 추적 어렵고 논리 복잡해짐 |
이번엔 skip 기본 + 명시 재실행 플래그 방식으로 유지했다. "이 기간 다시 돌려줘"라는 운영 요청이 들어올 때 플래그 하나 켜고 돌리면 되니까 커뮤니케이션 비용이 오히려 낮다. delete-insert도 고민했는데, 집계를 중간에 참조하는 프로세스가 따로 있어서 패스했다.
정산 화면 - 숫자 표기는 일관성이 전부다
정산 UI에서 사용자가 실질적으로 보는 건 세 가지다: 총 거래액, 수수료 합계, 실수령액. 이 세 숫자가 다른 포맷으로 섞여 있으면 즉각 불신이 생긴다. 특히 금액이 클수록 천 단위 구분자 하나 빠진 것도 눈에 띄게 거슬린다.
표기 규칙을 컴포넌트마다 따로 짜지 않고 포맷터 유틸 한 곳에 모아두는 게 관리가 편하다. 나중에 규칙이 바뀌면 한 곳만 고치면 되고, 테스트도 거기에만 붙이면 된다.
function formatKRW(amount: number): string {
const abs = Math.abs(amount);
const formatted = abs.toLocaleString('ko-KR') + '원';
return amount < 0 ? `-${formatted}` : formatted;
}
function amountColorClass(amount: number): string {
return amount < 0 ? 'text-red-500' : 'text-foreground';
}
마이너스 표시 로직을 각 컴포넌트에서 조건부로 처리하다 보면 어느 순간 빨간색이 나와야 할 데서 안 나오는 버그가 생긴다. 색상 판단 기준이 여러 곳에 흩어지면 PR 리뷰에서도 놓치기 쉽다. 포맷터에서 문자열과 색상 클래스를 함께 내려주든지, amount 부호를 그대로 내려보내고 컴포넌트에서 클래스만 결정하든지, 방식은 팀 컨벤션에 맞게 고르면 된다. 어떤 방식이든 규칙을 한 곳에 모으는 게 핵심이다.
불릿으로 정리하면:
- 천 단위 구분자와 '원' 단위는 전체 금액 표기에 일관 적용
- 마이너스 금액은 빨간색으로 부호와 색상 모두 표시
- 포맷 로직은 유틸 한 곳에서 관리, 컴포넌트에서 직접 조건 분기 금지
- 정산 화면 숫자는 소수점 없이 원 단위 정수로 끊기
작업 규모 자체가 크지 않은 배포였다. 그래도 이런 류의 변경이 쌓여야 실제 운영 안정성이 올라간다. 수수료 계산 오류나 UI 표기 불일치는 사용자가 자주 보는 기능에서 터지고, 고치고 나서도 한동안 의심을 산다. 이번처럼 외부 연동 분기나 반올림 정책, 포맷터 단일화 같은 "당연한 것들"을 명시적으로 코드에 박아두는 작업이 결국 나중 야간 장애를 하나씩 줄이는 방법이다.
댓글 0
첫 댓글 달아줘.