개발 slecs

카드 충전 수수료 차감 로직 원자적 처리 구현

목차

결제 시스템에서 수수료 처리가 틀리면 그 피해는 조용히 쌓인다. 충전은 됐는데 수수료 행이 없거나, 수수료율이 엉뚱한 등급 기준으로 계산되거나. 데이터는 얼핏 정상처럼 보이다가 정산 시점에 폭발한다. 이번 작업은 그 구멍을 막는 작업이었다.

slecs 레포 기준으로 변경 파일은 3개. Java 컨트롤러/유틸 쪽 비즈니스 로직, 그리고 JSP 뷰 두 개. 파일 수는 적지만 레이어가 다 다르기 때문에 신경 쓸 포인트는 생각보다 많았다.

수수료 처리 타이밍이 방식마다 다른 이유

카드와 가상계좌는 충전 확정 시점이 구조적으로 다르다.

카드는 PG사에서 즉시 승인을 내리기 때문에, 콜백이 오는 그 순간이 곧 충전 완료 시점이다. 반면 가상계좌는 사용자가 은행 앱을 열어서 실제로 입금하기 전까지는 미확정 상태다. 입금 사실을 PG사가 Webhook으로 쏴줘야 그때서야 확정 처리를 한다.

이 차이를 코드 레벨에서 흐름으로 정리하면 이렇다.

[카드]
PG 승인 콜백 수신
  → 상태 업데이트 (pending → complete)
  → 수수료 INSERT  ← 같은 트랜잭션 안에서

[가상계좌]
입금 완료 Webhook 수신
  → 상태 업데이트 (pending → complete)
  → 수수료 INSERT  ← 같은 트랜잭션 안에서

이번 커밋 이전에는 이 흐름 어딘가에 빠진 부분이 있었거나 버그가 있었다. 콜백 처리 로직에 상태 업데이트는 있었는데 수수료 INSERT가 빠져 있는 경우가 전형적인 패턴이다.

원자적 처리가 핵심인 이유

수수료 INSERT를 기존 트랜잭션 바깥에 두면 이런 시나리오가 생긴다.

  1. 상태 업데이트 성공
  2. 수수료 INSERT 직전 서버 재기동 또는 예외 발생
  3. 충전은 완료 상태인데 수수료 행 없음

이걸 정산 사이클에서 발견하면 복원이 쉽지 않다. 어떤 건이 누락됐는지 로그를 뒤져서 일일이 맞춰야 한다. 트랜잭션 안에 같이 묶으면 상태 업데이트가 롤백될 때 수수료 INSERT도 같이 롤백되기 때문에 불일치 자체가 발생하지 않는다.

// 트랜잭션 경계 안에서 두 작업이 묶임
@Transactional
public void completeCharge(ChargeCallbackDto dto) {
    chargeMapper.updateStatus(dto.getOrderId(), ChargeStatus.COMPLETE);

    BigDecimal feeRate = feeRateMapper.selectByGrade(dto.getUserGrade());
    BigDecimal feeAmount = dto.getChargeAmount().multiply(feeRate);

    feeMapper.insertFeeRecord(dto.getOrderId(), feeAmount, feeRate);
}

실제 코드 구조는 프로젝트마다 다르지만 원칙은 같다. 상태 변경과 수수료 기록이 단일 트랜잭션으로 묶여야 한다.

수수료율은 DB에서 등급 기준으로 가져오는 구조로 돼 있다. 하드코딩이 아니기 때문에 등급별 정책이 바뀌어도 코드 배포 없이 DB 값만 수정하면 된다. 이런 설계의 단점은 조회 비용이 추가된다는 건데, 충전 시점은 어차피 PG사 콜백 처리라 응답 속도보다 정확성이 중요한 구간이라 트레이드오프로 감수할 만하다.

프론트에서 서버까지 연결되는 데이터 흐름

이번 작업에서 실제로 시간이 걸린 부분은 비즈니스 로직보다 레이어 간 데이터 바인딩 쪽이었다.

JSP에서 컨트롤러로 넘어오는 파라미터 이름이 하나라도 어긋나면 조용히 null이 된다. Spring MVC 기준으로 @RequestParam이나 커맨드 객체 필드명과 폼 파라미터 이름이 정확히 일치해야 하고, 타입 불일치도 마찬가지로 묵살된다. 예를 들어 수수료 금액을 문자열로 넘겼는데 컨트롤러에서 BigDecimal로 받으려 하면 바인딩 오류가 난다.

반대 방향도 마찬가지다. SQL에서 올라온 데이터가 뷰에 제대로 찍히는지는 브라우저에서 직접 확인했다. 특히 수수료율처럼 소수점이 붙는 값은 화면에서 어떻게 포맷되는지도 봐야 했다.

아래는 이런 흐름에서 자주 틀리는 포인트 정리다.

  • JSP 폼의 name 속성값과 컨트롤러 파라미터명 불일치
  • 숫자 타입 파라미터를 문자열로 받아서 수동 파싱하다 생기는 예외
  • MyBatis resultMap에서 컬럼명-필드명 매핑 누락으로 null 반환
  • JSTL <c:out> 또는 EL 표현식에서 객체 depth가 깊을 때 NullPointerException

수수료 관련 파라미터는 금액이 얽혀 있어서 null이 들어오는 순간 계산이 틀리거나 INSERT 자체가 실패한다. 그래서 컨트롤러 진입 시점에 필수 파라미터 존재 여부를 확인하는 게 방어 코드로 낫다.

검증 과정

테스트 환경에서 결제 시뮬레이션을 돌리고 수수료 테이블에 행이 올바르게 들어오는지 직접 쿼리로 확인했다. 등급별로 수수료율이 다르게 적용되는지도 봤고, 잔액 차감 금액이 계산된 수수료와 맞는지도 검산했다.

SELECT
    order_id,
    charge_amount,
    fee_rate,
    fee_amount,
    charge_amount * fee_rate AS expected_fee
FROM charge_fee_log
WHERE created_at >= '2026-03-17'
ORDER BY created_at DESC;

fee_amountexpected_fee가 일치하는지를 이런 식으로 직접 비교했다. 부동소수점 반올림 처리가 다르면 1원 단위로 차이가 날 수 있어서 반올림 정책도 확인했다.

엔드투엔드로 봤을 때 회귀는 없었다. 이미 처리된 건들에 영향이 없는지는 상태값 분포를 조회해서 이상치가 없다는 걸로 확인했다.


결제 쪽 코드는 건드릴 때마다 조심스럽다. 로직이 틀리면 데이터가 틀리고, 데이터가 틀리면 돈이 틀린다. 이번처럼 수수료 처리 같은 부분은 특히 조용히 망가지는 유형이라, 트랜잭션으로 묶어서 불일치 가능성을 원천 차단한 게 이번 작업에서 가장 의미 있는 결정이었다고 본다.

댓글 0

첫 댓글 달아줘.