개발 slecs

차등 수수료 모델 도입으로 정산 로직 개선

목차

차등 수수료 모델은 단순히 "수수료율 하나를 DB에서 읽어오면 되는" 구조가 아니다. 조건에 따라 율이 달라지면 정산 로직 전체가 그 분기를 알아야 한다. 기존에 단일 율로 굴러가던 계산 흐름에 차등 조건을 얹는 작업이었고, 내부 클래스 수준에서 비즈니스 로직을 손봤다. 생각보다 고려할 지점이 많았다.

정산 로직에 분기를 얹을 때 생기는 일

정산 시스템에서 수수료 계산은 전형적인 "건드리기 무서운 코드" 영역이다. 이미 운영 중인 데이터와 연동되어 있고, 기존 로직이 여러 경로로 얽혀 있는 경우가 많아서 조건 하나 추가했다가 다른 케이스에서 엉뚱한 율이 적용되는 상황이 생길 수 있다.

이번 작업에서 신경 쓴 건 크게 세 가지였다.

조건 분기 범위. 차등 수수료 조건이 어느 레이어에서 결정되어야 하는가. 컨트롤러에서 분기해서 서비스에 넘기는 방식도 있고, 서비스 내부에서 전부 판단하는 방식도 있다. Spring MVC + MyBatis 구조에서 내부 클래스가 요청 처리와 비즈니스 로직을 같이 담고 있을 때, 책임을 어떻게 나눌지가 첫 번째 고민이었다. 결국 조건 판단 로직은 내부 클래스에 두고, SQL 레벨에서는 파라미터를 받아 계산된 율을 돌려주는 형태로 정리했다.

쿼리 분기 vs 애플리케이션 분기. 수수료 율 조회 쿼리를 하나로 두고 WHERE 조건을 동적으로 구성할지, 케이스별로 별도 쿼리를 둘지. MyBatis의 <choose>, <when> 태그로 SQL 내 분기를 처리하면 XML 파일 하나에서 관리할 수 있지만, 복잡도가 올라가면 금방 읽기 어려워진다. 이번엔 조건이 단순한 편이라 하나의 SQL에 동적 조건을 얹는 방향을 택했다.

<!-- fee_mapper.xml -->
<select id="selectFeeRate" parameterType="FeeRequestDto" resultType="BigDecimal">
    SELECT fee_rate
    FROM fee_policy
    <where>
        use_yn = 'Y'
        <choose>
            <when test="feeType != null and feeType != ''">
                AND fee_type = #{feeType}
            </when>
            <otherwise>
                AND fee_type = 'DEFAULT'
            </otherwise>
        </choose>
        <if test="targetGrade != null">
            AND target_grade = #{targetGrade}
        </if>
    </where>
    ORDER BY priority ASC
    LIMIT 1
</select>

이렇게 두면 파라미터 조합이 바뀌어도 XML 한 곳만 수정하면 된다. 단, 조건이 더 복잡해지면 그냥 쿼리를 분리하는 게 낫다. XML 분기가 세 겹 넘어가는 순간 유지보수가 오히려 더 어려워진다.

기존 정산 흐름과의 호환. 이미 동작 중인 단일 율 기반 정산 케이스를 깨지 않아야 했다. 파라미터에 차등 조건값이 없으면 기존과 동일한 율로 폴백되도록 했고, null·빈 값 처리는 DTO 바인딩 단계에서 먼저 걸러냈다. 여기서 빠뜨리면 SQL까지 내려가서 NPE가 터지거나 엉뚱한 조건으로 쿼리가 실행되는 경우가 생긴다.

트랜잭션 범위, 예외 처리, 그리고 배포

정산 로직은 DB 쓰기가 포함되는 순간 트랜잭션 범위를 어디까지 묶을지가 달라진다. 이번 작업에서 수수료 율 조회는 읽기 전용이었지만, 계산 결과를 정산 테이블에 기록하는 흐름이 함께 있었다. @Transactional 범위를 서비스 메서드 단위로 잡았고, 조회-계산-기록을 하나의 트랜잭션 안에 뒀다.

예외 처리 판단은 매번 고민이다. RuntimeException을 그대로 전파할 경우 트랜잭션 롤백이 자동으로 걸리지만, 호출하는 쪽에서 예외를 그냥 받아버리면 사용자한테 500이 그대로 내려간다. 이번엔 서비스 레이어에서 체크드 예외를 잡아 커스텀 예외로 변환하고, 컨트롤러(내부 클래스)에서 그걸 받아 응답 코드와 메시지를 정리하는 흐름으로 갔다.

public BigDecimal calculateFee(FeeRequestDto dto) {
    validate(dto); // null/빈값 선검증
    BigDecimal rate = feeMapper.selectFeeRate(dto);
    if (rate == null) {
        throw new FeeRateNotFoundException("수수료 율 조회 실패: " + dto.getFeeType());
    }
    return dto.getBaseAmount().multiply(rate).setScale(0, RoundingMode.HALF_UP);
}

로그는 입력 파라미터와 조회된 율을 INFO로 남겼다. 운영에서 정산 오류 추적할 때 "어떤 조건으로 들어와서 어떤 율이 적용됐는지"를 로그로 역추적할 수 있어야 하기 때문이다. DEBUG로 두면 운영 로그 레벨에서 안 찍히는 경우가 많아, 정산 관련 중요 입출력은 INFO로 고정하는 편이다.

배포 흐름은 로컬 기동 확인 → 개발 DB 반영 → 배포 순서로 처리했다. CI/CD 과정에서 자주 걸리는 지점들을 정리해두면:

문제 원인 대응
SSH 타임아웃 빌드가 길어지면 연결이 끊김 command_timeout 명시, 빌드 단계 분리
키 인증 오류 개행 문자 포함 여부 키 복사 시 trailing newline 확인
Gradle 캐시 충돌 CI 환경 캐시 오염 --no-daemon 또는 캐시 디렉토리 클리어
환경변수 누락 Secrets 미설정 배포 후 앱 헬스체크 엔드포인트로 확인

Gradle 캐시 충돌은 특히 골치다. 로컬에서 멀쩡히 빌드되던 게 CI에서 이상한 클래스 버전 오류로 터지는 경우, 대부분 캐시 문제였다. --no-daemon으로 돌리거나 캐시 디렉토리를 통째로 날리면 해결되지만, 매번 클린 빌드하면 시간이 2-3배 늘어난다. 실용적인 타협은 "주기적으로 캐시 무효화" + "의존성 변경 있을 때는 명시적으로 클리어"다.

환경변수 누락은 배포가 성공으로 찍혔는데 앱이 안 뜨는 상황을 만든다. 배포 파이프라인 마지막에 헬스체크 엔드포인트를 찌르는 단계를 두면 이런 무음 실패를 잡아낼 수 있다.

작업 규모는 크지 않았지만 정산 영역이다 보니 검증을 꼼꼼하게 했다. 차등 수수료 조건이 늘어날수록 이 구조가 버텨줘야 하는 상황이 올 수 있어서, 확장 포인트를 열어두는 방향으로 설계했다. 코드는 지금 단순하게 유지하되, 조건 추가가 XML 한 줄로 끝나는 구조가 목표였다. 이런 작은 수정들이 쌓여서 정산 시스템 전체의 신뢰도가 올라간다.

댓글 0

첫 댓글 달아줘.