결제 수단별 계층 수수료 계산 로직 정확성 강화
목차
결제 수단별로 수수료 계층이 다르게 적용되는 구조, 언뜻 보면 단순해 보이는데 실제로 손대보면 예외 상황이 꽤 나온다. 2026-03-30에 이 계산 로직 쪽을 작업했고, 규모 자체는 크지 않았는데 오히려 그래서 꼼꼼하게 챙기게 됐다. 금전 관련 로직은 버그 하나가 바로 신뢰 문제로 이어지기 때문에, 작은 수정이라도 의도를 명확히 코드에 남기는 게 중요하다.
계층별 요율 구조와 마진 역전 방어
이번 수수료 구조는 유통 계층형 차등 모델이다. 하위 계층이 더 높은 요율을 부담하고, 계층 간 요율 차이가 상위 계층의 수익이 되는 방식이다. 여러 단계의 유통망을 운영하는 플랫폼에서 자주 쓰는 구조인데, 계층 수가 늘어날수록 "마진이 음수가 되는 상황"을 코드로 방어해야 할 필요성도 커진다.
| 계층 | 요율 | 상위 계층 수익(마진) |
|---|---|---|
| 최하위 | 1.0% | - |
| 중간 | 0.8% | 0.2% (1.0 - 0.8) |
| 최상위 | 0.6% | 0.2% (0.8 - 0.6) |
마진이 양수여야 한다는 전제는 당연한 것처럼 보이지만, 데이터 입력 실수나 설정값 변경 도중에 계층 간 요율 순서가 역전되면 중간 계층이 상위보다 높은 요율을 내는 상황이 생긴다. 결과는 플랫폼이 돈을 역으로 내주는 구조. 운영 중에 이걸 발견하면 이미 배치가 돌아간 후라 소급 정정이 필요해진다.
그래서 이번 작업에서 계층별 요율 순서를 검증하는 validation을 추가했다. "하위 계층 요율 > 상위 계층 요율" 조건이 모든 인접 계층 쌍에 걸쳐 성립하는지 저장 시점에 체크하는 방식이다. 런타임 계산 시점에 체크하면 이미 늦다. 잘못된 요율이 DB에 저장된 순간부터 배치가 그 값으로 돌아가버리기 때문에, 입력 레이어에서 막는 게 맞다.
수수료 계산 자체는 비율 수수료와 건당 고정 수수료를 합산하는 구조다.
// 계층별 수수료 계산 - 비율 수수료 + 건당 고정비 합산
// 원 단위 내림 처리 (수수료 납부자 유리 방향)
long feeAmount = (long)(txAmount * feeRate) + perTxFee;
여기서 (long) 캐스팅이 사실상 내림 처리인데, Java에서 double을 long으로 캐스팅하면 소수점 이하가 절사된다. 의도적으로 내림을 선택한 것이라면 Math.floor()로 명시하거나 주석이라도 달아야 한다. 그냥 두면 나중에 이 코드를 보는 사람이 의도인지 실수인지 구분 못한다. 금전 로직에서 "의도한 것처럼 보이는 실수"는 리뷰에서 잡기 제일 어렵다.
정산 배치의 멱등성
수수료 계산 자체보다 더 신경 쓴 부분이 정산 배치 쪽이다. 배치는 특정 기간의 거래를 집계해서 수수료를 확정하는데, 멱등성이 없으면 같은 기간을 두 번 실행했을 때 수수료가 두 번 쌓이는 사고가 난다. 장애 후 배치를 수동으로 재실행하는 상황은 언제든 생길 수 있고, 그 시점에 멱등성 없으면 수작업 정정이 필요해진다.
멱등성 보장 방법은 보통 두 갈래다.
- 처리 상태 플래그 방식: 거래 레코드에
settled,settlement_id같은 컬럼을 두고, 배치가 이미 처리된 건은 쿼리 조건에서 제외하는 방식. 구현이 단순하고 이해하기 쉽지만, 부분 실패 후 재시작 시 처리 경계를 어디로 잡을지 명확히 정의해둬야 한다. - 결과 upsert 방식: 정산 결과를 별도 테이블에
(period, tier_id)같은 복합 키 기준으로 upsert 처리. 같은 기간이 다시 들어와도 덮어쓰기가 되면서 중복 집계를 막는다. 집계 결과가 결정론적(같은 입력 → 같은 출력)이어야 제대로 동작한다.
두 방식을 섞는 경우도 있다. 어떤 방식을 쓰든 핵심은 "재실행해도 결과가 동일하다"는 성질을 코드 수준에서 보장하는 것이다. 주석이나 문서에만 "멱등하게 설계됐음"이라고 적어두는 건 보장이 아니다.
반올림 전략과 트레이드오프
원 단위를 어느 방향으로 끊느냐는 "누가 유리한가"의 문제다. 수수료를 내는 쪽 유리 방향(내림), 받는 쪽 유리 방향(올림), 또는 통계적으로 중립적인 은행가 반올림(Banker's Rounding) 중 하나를 선택해야 한다.
| 반올림 방향 | 수수료 납부자 | 수취자 | 누적 오차 귀속 |
|---|---|---|---|
| 내림(floor) | 유리 | 소액 손실 | 플랫폼 부담 |
| 올림(ceil) | 불리 | 소액 이득 | 사용자 부담 |
| 반올림(round) | 랜덤 | 랜덤 | 분산 |
| 은행가 반올림 | 중립 | 중립 | 통계적 균등 |
이번 로직에서는 수수료 납부자 유리 방향, 즉 내림으로 처리하고 있다. 건당 차이는 미미해도 거래 건수가 많으면 누적 금액 차이가 생기기 때문에, 이 정책이 코드 주석에만 있으면 나중에 찾기 어렵다. 정산 정책 문서나 서비스 약관에 명시가 돼 있어야 "왜 0.x원 차이가 나냐"는 문의에 근거를 갖고 답할 수 있다.
반올림 정책은 한번 정해지면 바꾸기 어렵다. 이미 정산된 이력과 새 정책 기준이 섞이면 비교가 안 된다. 처음 설계 시 방향을 잡고, 코드와 문서에 동시에 남겨두는 게 나중 유지보수 비용을 줄이는 방법이다.
이번 작업이 작았던 만큼, 사실 작업 자체보다 "이 코드를 다음에 보는 사람이 뭘 궁금해할까"를 더 오래 생각했다. 요율 순서 validation 하나, 반올림 주석 하나가 별것 아닌 것 같아도, 금전 로직에서는 그 의도가 명확하게 읽혀야 한다. 코드 리뷰에서 작은 수정이 가볍게 넘어가기 쉬운데, 오히려 이런 류의 수정이 제대로 검토돼야 한다.
댓글 0
첫 댓글 달아줘.