연락처 송금 수동 재처리
목차
연락처 송금 수동 재처리 기능을 추가하고, 신한은행 핸들러의 결과 메시지 파싱 로직을 개선했다. 변경 범위 자체는 크지 않았는데, 쓰고 보니 배경 설명이 꽤 필요한 작업이었음.
시스템 흐름 전체와 오늘 건드린 지점
전체 흐름은 이렇다.
입금 알림 수신 (Android 앱)
→ 서버로 원본 메시지 전송
→ 주문 매칭 (금액 + 발신자 + 시간)
→ 은행 핸들러 실행 (Playwright)
→ 로그인 → 수취인 입력 → 이체 → 완료 감지
→ 주문 상태 업데이트
→ 알림 발송
오늘 손댄 부분은 두 군데다. 하나는 핸들러 실행이 실패했을 때 운영자가 수동으로 재처리를 트리거할 수 있는 관리 API, 나머지 하나는 신한은행 이체 완료 화면에서 결과 메시지를 추출하는 파싱 정규식.
수동 재처리가 왜 필요했냐면, Playwright 기반 핸들러는 은행 앱 화면을 직접 조작하는 구조라 타임아웃이나 셀렉터 불일치로 실패하는 케이스가 간간이 생긴다. 자동 재시도 로직이 있긴 하지만 재시도 한도를 넘기거나, 이미 이체가 진행 중인 상태에서 중복 실행을 막아야 하는 상황에선 결국 사람이 판단해서 다시 돌려야 한다. 그 수단이 없으면 운영팀이 DB를 직접 건드리거나 서버를 재시작하는 방식으로 우회하게 되는데, 그게 오히려 더 위험함.
은행별 핸들러는 추상 클래스를 상속받아서 각 은행 UI에 맞게 구현하는 구조다. 은행마다 UI가 다르니 셀렉터가 달라질 수밖에 없는데, 그 셀렉터를 DB에 캐시해두고 성공한 케이스를 자가학습하는 구조를 붙여뒀음. 원리는 단순하다. 이체 성공 시 사용한 셀렉터를 "마지막 성공 셀렉터"로 기록해 두고, 다음 실행 때 캐시된 값을 먼저 시도한다. 실패하면 미리 정의된 후보 셀렉터 목록을 순서대로 시도하고, 그중 성공한 게 있으면 캐시를 갱신한다. 은행 앱이 업데이트로 UI를 바꿔도 어느 정도 자동으로 적응할 수 있는 구조임.
신한은행 쪽은 이체 완료 이후 결과 화면에서 텍스트를 긁어오는 파싱 정규식이 일부 메시지 포맷을 커버하지 못하는 걸 발견했다. 은행 앱 업데이트 이후 완료 메시지 포맷이 미세하게 바뀐 케이스였음. 패턴 추가하고 테스트 케이스 보완하는 게 작업의 대부분이었다.
재처리 기능은 단순해 보이지만 실제로는 고려할 게 꽤 된다. 금전 처리라 실수가 이중 이체로 직결되기 때문에 방어 로직을 여러 겹 깔아야 한다.
현재 상태 화이트리스트 - 재처리 가능한 주문 상태를 명시적으로 정의해야 한다. 이미 완료된 주문이나 취소된 주문은 재처리 대상이 아님. "실패" 또는 "타임아웃" 상태만 허용하는 식으로 화이트리스트를 코드에 박아두는 게 맞다.
동시 실행 방지 - 동일 주문에 대해 재처리 요청이 동시에 두 번 들어올 수 있다. DB 레벨에서 상태를 "처리 중"으로 전이시키고, 이미 그 상태면 거부하는 방식이 외부 의존성 없이 단순한 편.
감사 로그 - 누가, 언제, 어떤 주문을 재처리했는지 남겨야 한다. 금전 처리라 나중에 이력 추적이 필요한 케이스가 반드시 생긴다. operator_id, triggered_at, reason 정도는 행 단위로 기록하는 게 최소한.
알림 중복 방지 - 재처리 후 완료 알림이 사용자에게 또 나가면 안 된다. 재처리 플래그를 두고 해당 플래그가 있으면 알림 발송 단계를 스킵하도록 처리했음.
수수료 분배와 정산 로직의 정밀도 문제
수수료 분배는 유통 계층 구조를 따른다. 각 계층이 다른 요율을 갖고 있고, 하위 계층과의 차액이 해당 계층의 수익이 되는 방식임.
| 계층 | 역할 |
|---|---|
| 최상위 | 전체 거래 수수료 설정 |
| 중간 유통 | 하위 계층과의 요율 차이가 수익 |
| 말단 처리자 | 실제 이체 실행 |
이 구조에서 정산 검증이 중요한 이유는, 계층별 요율을 곱하고 나눌 때 원 단위 반올림이 중간에 끼면 결과가 달라질 수 있기 때문이다. 소수점 올림/버림 방향이 계층마다 달리 적용되면 합계가 1-2원씩 맞지 않는 현상이 생기고, 운영하다 보면 이게 누적돼서 티가 난다.
관례적으로 수수료를 내는 쪽에는 내림, 받는 쪽에는 올림 없이 정확한 수를 적용하는 게 분쟁을 줄인다. 코드에선 이걸 명시적으로 ROUND_DOWN으로 고정해두는 게 맞음.
from decimal import Decimal, ROUND_DOWN
def calculate_fee(amount: int, rate: str) -> int:
"""수수료 계산 - 항상 내림 적용"""
fee = Decimal(str(amount)) * Decimal(rate)
return int(fee.quantize(Decimal('1'), rounding=ROUND_DOWN))
부동소수점 연산을 피하고 Decimal로 처리하는 이유는, 0.1 + 0.2 != 0.3 같은 오차가 금액 계산에서 튀어나오면 디버깅이 꽤 성가시기 때문이다. 요율이 0.035 같은 3자리 소수가 들어가면 float 연산은 믿기 어렵다.
정산 배치 쪽은 멱등성이 핵심이다. 같은 기간을 두 번 돌려도 결과가 달라지면 안 된다. 해당 기간의 정산 레코드가 이미 있으면 삭제 후 재생성하는 방식이 단순하고 확실한데, 중간 실패 시 레코드가 없는 상태로 남을 수 있으니 트랜잭션 안에서 처리하거나 상태 컬럼으로 "계산 중" / "완료"를 구분해두는 게 좋다.
정산 UI 쪽도 같이 다듬었는데, 여기선 숫자를 얼마나 명확하게 보여주느냐가 거의 전부다. 총 거래액, 수수료, 실수령액을 한눈에 구분할 수 있어야 하고, 마이너스 금액은 색으로 구별되는 게 직관적으로 맞다. 천 단위 구분자와 '원' 단위는 일관성이 깨지는 순간 보는 사람이 헷갈리기 때문에 포맷 함수를 단일화해서 쓰는 게 낫다.
작업 규모 자체는 크지 않았는데, 이런 운영 보조 도구들이 하나씩 붙을 때마다 장애 대응 속도가 체감상 달라진다. "DB 직접 수정하고 재시작" 대신 "관리 화면에서 버튼 하나"로 바뀌면, 새벽에 핸들러 실패 알림을 받았을 때 처리할 수 있는 사람의 범위 자체가 달라진다. 소소한 변경이지만 그게 쌓이면서 전체 시스템의 운영 가능성이 높아진다.
댓글 0
첫 댓글 달아줘.