차등수수료 정산 로직에 건당 수수료 합산
목차
차등 수수료 구조는 처음 보면 단순해 보이지만, 막상 코드로 구현하려고 앉으면 생각보다 신경 쓸 부분이 많다. "유통 계층마다 요율이 다르고, 하위 계층이 더 내고, 차액이 위로 흘러간다" - 이 한 문장이 코드 레벨로 내려오면 요율 순서 검증, 반올림 방향, 건당 수수료 합산 타이밍, 배치 멱등성까지 파생 문제가 줄줄이 딸려온다.
이번 작업은 기존에 비율 수수료만 처리하던 정산 로직에 건당 고정 수수료를 합산하는 변경이었다. 규모 자체는 작았지만 금전 계산이라 검토에 더 시간이 들었다.
건당 수수료가 왜 필요한가
비율 수수료만 있으면 소액 거래에서 수수료가 지나치게 낮아지는 문제가 생긴다. 1만 원 거래에 1% 적용하면 100원인데, 이 거래를 처리하는 인프라 비용이 100원보다 클 수 있다. 반대로 고액 거래는 비율만 적용해도 충분히 커버가 된다. 그래서 현실에서는 비율과 무관하게 건당 고정 금액을 별도로 부과하는 구조가 꽤 흔하다.
문제는 두 가지를 합산할 때 집계 단위가 어긋난다는 점이다. 비율 수수료는 거래 금액에 비례하고, 건당 수수료는 거래 건수에 비례하기 때문이다. 배치가 기간 내 거래를 묶어서 처리할 때 어느 시점에 건당을 더하느냐에 따라 합계가 달라질 수 있다. 계층별로 따로 계산하든, 최종 합산 후에 더하든 결과가 같아야 한다. 이 일관성 검증이 이번 작업에서 제일 신경 쓴 부분이었다.
취소된 거래가 건수에 포함되면 안 되고, 부분 취소 처리 기준도 별도로 잡아야 한다. 집계 쿼리 하나 추가하는 것보다 이 예외 케이스 목록 정리가 더 시간이 걸렸다.
계층 요율 구조와 음수 마진 방지
이번 시스템의 수수료 구조는 아래처럼 생겼다.
| 계층 | 요율 | 계층 간 차액 |
|---|---|---|
| 최하위 | 1.0% | - (최대 부담) |
| 중간 | 0.8% | 0.2% 수익 |
| 최상위 | 0.6% | 0.2% 수익 |
하위 계층이 상위보다 높은 요율을 부담하고, 그 차액이 상위 계층 수익이 된다. 이 구조에서 요율 배열이 잘못 설정되면 마진이 음수가 된다. 상위 계층이 하위보다 낮아야 하는데 실수로 순서가 뒤집히면 상위가 오히려 손해를 보는 상황이 생긴다. 설정 UI가 있다면 더더욱 이 케이스를 놓치기 쉽다.
그래서 계층 목록을 순회하면서 인접 계층 간 요율 순서가 올바른지 체크하는 validation을 추가했다. 배치가 실행되기 전에 잘못된 설정을 사전에 잡아내는 게 목적이다. 수수료 계산 자체는 구조가 단순하다.
// 비율 수수료 + 건당 고정 수수료 합산
long feeAmount = (long)(txAmount * feeRate) + perTxFee;
다만 (long) 캐스팅 위치가 중요하다. txAmount * feeRate 연산에서 소수점이 발생할 때 어디서 버리느냐가 최종 수수료에 영향을 준다. 통상적으로 수수료를 내는 쪽에 유리하게, 내림(floor) 방향으로 처리한다. 부과 주체 입장에서는 조금 덜 걷히지만 분쟁 소지가 줄고 예측 가능성이 높아진다.
Math.round를 쓰면 동일한 금액이라도 거래 시점이나 계층 조합에 따라 결과가 달라질 수 있어서 설명하기가 어려워진다.
// 피해야 할 패턴 - 반올림은 결과가 맥락에 따라 달라진다
long feeAmount = Math.round(txAmount * feeRate) + perTxFee;
// 명시적 floor - 수수료 내는 쪽 기준으로 일관성 보장
long feeAmount = (long) Math.floor(txAmount * feeRate) + perTxFee;
부동소수점 연산 자체를 피하려면 BigDecimal로 처리하는 방식도 있다. 거래 금액과 요율 모두 BigDecimal로 넘기고 setScale로 반올림 모드를 명시하면 정밀도를 완전히 통제할 수 있다. 다만 성능 오버헤드가 있어서 거래량이 많은 배치라면 트레이드오프를 고려해야 한다. 이번 케이스는 long 캐스팅 방식으로 처리했는데, 요율 소수점 자릿수와 거래 금액 범위를 확인한 후 정밀도 손실이 실질적으로 문제없다고 판단해서다.
배치 정산의 멱등성 설계
정산 배치는 같은 기간을 두 번 돌려도 결과가 같아야 한다. 멱등성이 보장되지 않으면 재처리 시 이중 청구가 발생하고, 그걸 수동으로 정정하는 비용이 훨씬 크다. 운영 중에 배치가 실패해서 재실행할 일은 생각보다 자주 생긴다.
멱등성 구현 방식은 크게 두 갈래로 나뉜다.
- 실행 전 해당 기간 결과를 조회해서 이미 정산됐으면 스킵
- 정산 결과를 upsert로 저장해서 동일 키로 덮어쓰도록 설계
두 방식 모두 "이 기간 이 계층의 정산 결과"를 유일하게 식별하는 키가 필요하다. 기간 + 계층 식별자를 복합 유니크 키로 쓰는 게 일반적이고, 이 키 설계를 처음에 잘 잡아야 재처리 로직 짤 때 삽질을 줄일 수 있다. 나중에 정산 단위가 세분화되거나 계층 구조가 변경되면 키 구조도 영향을 받기 때문에, 어느 정도 확장을 고려한 설계가 낫다.
이번에는 건당 수수료가 추가되면서 해당 기간 유효 거래 건수를 별도로 집계해야 했다. 멱등성 관점에서 이 건수가 항상 같은 결과를 내야 하므로, 집계 기준 - 어떤 상태의 거래를 포함하는지 - 을 명확하게 정의하고 코드에 주석으로 남겨뒀다. 암묵적인 기준은 나중에 혼란의 씨앗이 된다.
작업 자체는 코드 변경 규모가 크지 않았지만, 리뷰 체크리스트가 길어지는 종류의 작업이었다. 수수료 계산 오류는 수치가 틀린 걸 인지하기 전까지 조용하게 쌓이다가, 감지하는 순간 신뢰 문제로 번지기 때문에 이런 로직은 늘 손을 한 번 더 보게 된다.
댓글 0
첫 댓글 달아줘.