채널 파트너 일괄 등록과 요율 계층 검증 기능 추가
목차
채널 포털 쪽 작업이라 시작 전에 전체 구조를 머릿속에 다시 그려야 했다. 외부 채널 사업자가 접속해서 자기 잔액, 거래 내역, 하위 채널 현황을 확인하는 공간인데, 계층이 2단계 이상 붙어 있으면 데이터 흐름이 생각보다 복잡하다. 대시보드 한 장에서 상위-하위 채널 전체 잔액을 집계해서 보여주면서, 하위 채널별 신청 처리까지 같은 화면 안에 묶여 있다.
이번 작업의 발단은 단순했다. 채널을 한 번에 여러 개 등록할 방법이 없냐는 요청이 들어온 거다. 기존엔 채널마다 수동으로 하나씩 입력해야 했는데, 수십 개를 한꺼번에 등록해야 하는 상황이 생겼다. 작업 규모 자체는 작았지만, 그냥 엑셀 업로드만 받으면 안 됐다. 계층 요율이 뒤집혀서 들어오면 그게 더 큰 문제다.
계층 요율 검증을 업로드 시점에 걸어야 하는 이유
상위 채널의 요율은 하위 채널보다 낮아야 한다. 상위가 10%, 하위가 8%면 중간 마진이 2%p 생기는 구조다. 이 순서가 뒤집히면 마진이 음수가 된다. 계산 로직은 오류 없이 돌아가는데 결과만 이상하게 나오는 버그, 발견하기 제일 까다로운 종류다. 배치 돌고 나서 정산 금액 이상하다고 문의 들어올 때까지 모르고 지나갈 수 있다.
그래서 업로드 시 검증을 넣었다. 엑셀 파싱 후, DB에 저장하기 전에 계층 트리를 구성해서 상위-하위 요율 순서를 체크하고, 위반 시 해당 행 번호와 어떤 쌍이 문제인지 오류 메시지에 담아 반환하도록 처리했다.
def validate_rate_hierarchy(rows: list[dict]) -> list[str]:
errors = []
rate_map = {r["channel_id"]: r["rate"] for r in rows}
for r in rows:
parent_id = r.get("parent_channel_id")
if parent_id and parent_id in rate_map:
if rate_map[parent_id] >= r["rate"]:
errors.append(
f"행 {r['row_num']}: 상위 채널({parent_id}) 요율 "
f"{rate_map[parent_id]}% >= 하위 요율 {r['rate']}%"
)
return errors
주의할 점은, 엑셀 한 파일 안에서 상위-하위가 같이 있을 수 있다는 거다. 그래서 순차적으로 파싱하면서 검증을 같이 돌리면 아직 읽지 않은 상위 채널 행을 못 찾는 경우가 생긴다. 전체를 먼저 파싱해서 rate_map을 완성한 뒤 검증 패스를 별도로 돌려야 한다.
트레이드오프 측면에서, 업로드 시점 검증은 운영 편의를 높이는 대신 구현 비용이 조금 올라간다. 저장 시점까지 검증을 미루면 구현이 쉬운 대신, 잘못된 데이터가 부분적으로 저장된 채 오류가 나는 상황이 생긴다. 배치 등록 특성상 올-오어-낫씽이 맞다고 판단해서 전자를 택했다. 1000개를 올렸는데 500개만 들어가고 나머지는 실패하는 상황이면, 어디까지 들어갔는지 추적하는 게 더 큰 일이 된다.
정산 숫자 표기, 생각보다 신경 쓸 게 많다
정산 화면에서 사람들이 제일 먼저 보는 건 금액이다. 총 거래액, 수수료, 실수령액. 이 세 숫자가 한 화면에 있고, 그 아래로 세부 내역이 펼쳐지는 구조다. 숫자가 잘못 표기되거나 직관적이지 않으면 그 자체로 신뢰 손상이다.
이번에 정리한 표기 기준은 아래와 같다.
| 항목 | 처리 방식 |
|---|---|
| 금액 표기 | 천 단위 구분자, '원' suffix |
| 음수 금액 | 빨간색 텍스트, 마이너스(-) 부호 명시 |
| 수수료 반올림 | 내는 쪽 유리 방향 - 내림 |
| 실수령액 계산 | 올림 없이 내림 적용 |
반올림 정책이 의외로 분쟁 포인트가 된다. 계층별로 요율을 다르게 적용하면서 원 단위 처리를 빠뜨리면, 100원씩 계속 어디선가 사라지거나 쌓이는 일이 생긴다. "지난달이랑 왜 숫자가 다르냐"는 문의가 오면 로그 파고들어서 반올림 방식 차이를 설명하는 게 상당히 번거롭다. 정책을 코드 상수로 박아두고 주석으로 근거를 남기는 게 낫다. 나중에 코드 보는 사람이 왜 floor를 쓰는지 의아해하지 않도록.
import math
def calc_fee(amount: int, rate_pct: float) -> int:
# 사업자 유리 방향 = 내림 (수수료 내는 쪽 기준 분쟁 방지 정책)
return math.floor(amount * rate_pct / 100)
def calc_payout(amount: int, fee: int) -> int:
return amount - fee
올림/버림 어느 쪽을 쓰든 일관성이 핵심이다. 월별로 정책이 바뀌면 비교 자체가 불가능해진다.
정산 배치는 멱등하게
이번에 배치를 직접 건드린 건 아니지만, 검증 로직 추가하면서 배치 흐름을 다시 훑었다. 정산 배치는 같은 기간을 두 번 돌려도 결과가 달라지면 안 된다. 실패 후 재실행이 반드시 생기는데, 그때 이미 처리된 거래를 또 처리하는 사태가 나면 복구 비용이 크다. 금전 데이터라 더 그렇다.
멱등성을 보장하는 방법은 여러 가지다. DB 쪽에서 upsert로 처리하거나, 배치 실행 전에 해당 기간이 이미 정산 완료 상태인지 체크하고 스킵하거나. 어떤 방식이든 "같은 입력, 같은 결과"를 전제로 설계해야 한다. settlement_batch_log 같은 테이블에 기간과 실행 결과를 남겨두면 재실행 시 중복 방지와 감사 추적이 동시에 된다.
작업 규모는 크지 않은 날이었다. 그래도 일괄 등록 하나만 해도, 채널 신규 계약 때마다 들어오던 "이거 등록 좀 해주세요" 요청이 운영팀이 직접 처리하는 작업으로 바뀐다. 수수료 계산과 계층 검증은 금전 로직이라 오류가 나면 바로 신뢰 손상으로 이어진다. 이런 작업이 쌓여서 전체 시스템의 안정성이 올라간다고 생각한다.
댓글 0
첫 댓글 달아줘.