개발 slecs

가맹점 수수료 요율 검증으로 마진 역전 버그 차단

목차

파트너 관리 화면에서 가맹점 충전 수수료를 0.5%로 낮춰달라는 요청을 받았다. 별다른 의심 없이 값 바꾸고 저장했는데, 5분도 안 돼서 운영팀 슬랙이 울렸다. "이거 총판이 0.7% 받고 있는데, 가맹점 0.5%면 총판이 -0.2% 먹는 거 아님?" 맞는 말이었다. 수수료 마진이 뒤집혀버린 거다.

솔직히 말하면 잘못은 내 실수가 아니라 시스템이 그걸 막아주지 않았다는 데 있었다. 가맹점 수수료를 뭘로 바꾸든 저장 버튼은 아무 저항 없이 눌렸고, 도메인 정책은 운영팀 머릿속에만 있었다. 이 구조에서 사람이 실수 안 하길 기대하는 건 욕심이다.

수수료 계층 구조를 다시 정리해보면 이렇다.

  • 가맹점이 가장 높은 요율을 부담
  • 위로 올라갈수록(대리점 - 총판 - 본사) 요율이 낮아짐
  • 각 계층의 수익 = 하위 요율 - 자기 요율의 차액

즉 상위 노드의 요율이 하위보다 높거나 같아지는 순간, 그 계층은 수익이 0이 되거나 손실을 본다. 이 조건 하나를 코드에 박지 않으면 누구든 화면에서 의도치 않게 마진을 뒤집을 수 있다. 이번에 내가 그랬듯이.

검증을 두 단계로 분리한 이유

처음엔 한 메서드에 검증 전부를 넣었다. 그랬더니 테스트 케이스가 10개가 넘어가면서 뭘 테스트하는 건지 의도 파악이 힘들어졌다. 결국 두 관심사로 쪼갰다.

단계 검증 내용 실패 시
상한 검증 결제대행사 계약 상한 초과 금지 즉시 차단
상위 요율 비교 신규 요율이 직상위 파트너 요율보다 낮으면 차단 즉시 차단
// 1단계: 계약 상한 초과 여부
validateFeeCap(newRate, contractCap);

// 2단계: 직상위 파트너 요율과 비교
validateAgainstParentRate(partnerId, newRate);

분리하니까 실패 메시지도 정확히 쪼개서 줄 수 있게 됐다. 상한 초과인지, 계층 역전인지에 따라 운영팀이 즉시 원인을 알 수 있게. 메서드 하나가 두 가지 이유로 실패할 수 있으면 에러 메시지가 어쩔 수 없이 뭉개진다.

적용 위치는 두 군데다. 화면 제출 시 컨트롤러 레이어에서 한 번, 서비스 레이어에서 또 한 번. 이중 검증이 중복처럼 느껴질 수 있지만, 화면을 거치지 않는 API 직접 호출은 언제든 생긴다. 관리자 스크립트, 배치 수동 조정, 개발 중 직접 curl, 뭐든. 서비스 레이어 검증이 없으면 그 경로는 다 열려 있는 거다.

삽질 세 가지

첫 번째. 초안에서는 하위 파트너까지 재귀 검사를 넣으려 했다. 내가 요율을 올리면 내 하위들이 역전되는 게 아닌지. 논리는 맞다. 근데 파트너 트리가 깊어지면 한 건 수정에 N+1 쿼리가 폭발한다. 결국 상위 비교만 즉시 막고, 하위 정합성은 별도 배치 잡으로 주기적으로 확인하는 방식으로 바꿨다. 즉시 검증과 정합성 검증을 분리한 거다. 즉시 검증은 빨라야 한다. 느린 검증을 실시간으로 끼워넣으면 결국 타임아웃으로 UX만 망가진다.

두 번째. 결제대행사 계약 상한값이 대행사마다 다 달랐다. 파트너 화면 열 때마다 이걸 조회하면 DB 조회가 그냥 늘어난다. 파트너 화면 진입 시점에 한 번만 끌어오고 세션에 물려두는 방식으로 묶었다. 이런 기준값은 변경 주기도 길어서 캐싱 비용 대비 효과가 크다.

세 번째. 코드리뷰에서 잡혔는데, 파트너 본인이 자기 요율을 자기 상위보다 높게 올리는 케이스를 빼먹었다. 내리는 방향은 챙겼는데 올리는 방향을 놓친 것. 결국 validateAgainstParentRate 안에 방향 무관하게 단순 비교로 처리했다. 방향별로 분기 처리하려다 오히려 케이스를 빠뜨리는 일이 생겨서, 단순하게 "신규 요율 < 직상위 요율이면 무조건 차단"으로 정리했다.

도메인 이해가 코드보다 먼저였다

수수료 정책의 핵심은 한 줄이다. "상위 계층이 더 적은 요율을 가져가고, 그 차액이 수익이다." 이걸 머릿속에 박고 나니 검증 로직이 자명해졌다. 코드는 이 한 줄을 기계어로 옮긴 것뿐이었다.

반대로 이 한 줄 없이 코드를 보면 왜 상위와 비교하는지 이해하기 힘들다. 그래서 validateAgainstParentRate 위에 한 줄 주석을 달았다.

// 수수료 구조 규칙: 하위 파트너 요율 > 상위 파트너 요율. 상위 계층 수익 = 하위 요율 - 상위 요율 차액.
// 이 조건이 역전되면 상위 계층이 손실을 본다.
private void validateAgainstParentRate(Long partnerId, BigDecimal newRate) { ... }

이 주석이 없으면 6개월 뒤에 다른 사람이 "왜 상위랑 비교하지?" 하고 지울 수도 있다. 정책 주석은 코드 설명이 아니라 도메인 맥락을 박아두는 거다. 이게 다음 개발자한테 가장 빠른 온보딩이기도 하다.

하나 더. 화면에서 막으면 끝이라는 생각은 위험하다. API가 열려 있는 한, 화면을 거치지 않는 경로는 항상 존재한다고 가정하는 게 맞다. 서비스 레이어 검증이 진짜 마지노선이고, 화면 검증은 UX 편의일 뿐이다. 이 순서를 바꾸면 언젠가 같은 버그가 다른 경로에서 또 터진다.

댓글 0

첫 댓글 달아줘.