목록으로

3개 지갑에서 자동으로 차감하는 Waterfall 결제

3개 지갑에서 자동으로 차감하는 Waterfall 결제

→ Waterfall(폭포수) 패턴이란 우선순위가 높은 항목부터 순서대로 소진하고, 남은 금액을 다음 항목으로 넘기는 방식이다. 물이 위에서 아래로 흐르듯이 처리된다.

2025년 12월 3일 — 결제 기능 요구사항 변경, 복합 지갑 구조 도입 2025년 12월 9일 — 인센티브 지급 월간 한도 수정

요구사항: "정부지원금 먼저, 캐시백 다음, 나머지는 일반 지갑에서"

사용자에게 지갑이 3개 있다.

GOVERNMENT  - 정부지원금 (먼저 쓰이는 돈)
INCENTIVE   - 캐시백 적립금 (그 다음)
NORMAL      - 일반 충전금 (마지막)

10,000원을 결제할 때, 정부지원금 2,000원 + 캐시백 3,000원 + 일반 5,000원처럼 우선순위대로 자동 배분해야 한다. 사용자가 직접 "어떤 지갑에서 얼마"를 정하는 게 아니라, 시스템이 알아서 계산한다.

금액 배분 로직

private fun calculatePaymentBreakdown(
    totalAmount: BigDecimal,
    incentiveBalance: BigDecimal,
    normalBalance: BigDecimal,
    requestedIncentive: BigDecimal?  // null=전액 사용, 0=안 씀, 숫자=그만큼
): PaymentBreakdown {
    var incentiveAmount: BigDecimal
    var normalAmount: BigDecimal

    when {
        requestedIncentive == null -> {
            // null이면 캐시백 전액 사용 (결제금액 한도 내에서)
            incentiveAmount = incentiveBalance.min(totalAmount)
            normalAmount = totalAmount - incentiveAmount
        }
        requestedIncentive == BigDecimal.ZERO -> {
            // 0이면 캐시백 사용 안함
            incentiveAmount = BigDecimal.ZERO
            normalAmount = totalAmount
        }
        else -> {
            // 지정 금액만큼 캐시백 사용
            if (requestedIncentive > incentiveBalance)
                throw IllegalStateException("캐시백 잔액 부족")
            incentiveAmount = requestedIncentive
            normalAmount = totalAmount - incentiveAmount
        }
    }

    if (normalAmount > normalBalance)
        throw IllegalStateException("일반 지갑 잔액 부족")

    return PaymentBreakdown(
        governmentAmount = BigDecimal.ZERO,  // 현재 미사용
        incentiveAmount = incentiveAmount,
        normalAmount = normalAmount,
        totalAmount = totalAmount
    )
}

requestedIncentive를 nullable로 둔 이유는 프론트엔드 UX 때문이다.

  • null: "캐시백 자동 사용" (기본값, 대부분의 사용자)
  • 0: "캐시백 안 쓸래요" (사용자가 명시적으로 선택)
  • 3000: "캐시백 3,000원만 쓸래요" (부분 사용)

핵심: 여러 지갑 결제를 1건의 블록체인 거래로

3개 지갑에서 각각 차감해서 가맹점에 보내는 걸 개별 거래로 하면, "INCENTIVE에서는 성공했는데 NORMAL에서는 실패"하는 중간 상태가 생긴다. 이걸 방지하기 위해 배치 결제를 썼다. → 배치 결제란 여러 건의 결제를 하나로 묶어서 한 번에 처리하는 방식이다. 전부 성공하거나 전부 실패하는 원자적(Atomic) 처리가 보장된다.

val legs = mutableListOf<PaymentLeg>()

if (breakdown.incentiveAmount > BigDecimal.ZERO) {
    legs.add(PaymentLeg(
        fromAddress = incentiveWallet.address,
        toAddress = merchantWallet.address,      // 가맹점
        amount = breakdown.incentiveAmount,
        paymentId = paymentIdBytes,
        encryptedPrivateKey = incentiveWallet.encryptedPrivateKey
    ))
}
if (breakdown.normalAmount > BigDecimal.ZERO) {
    legs.add(PaymentLeg(
        fromAddress = normalWallet.address,
        toAddress = merchantWallet.address,      // 같은 가맹점
        amount = breakdown.normalAmount,
        paymentId = paymentIdBytes,
        encryptedPrivateKey = normalWallet.encryptedPrivateKey
    ))
}

// 1건의 블록체인 거래로 원자적 처리
paymentManagerService.batchPayment(batchPaymentId, legs)

스마트 컨트랙트의 batchPaymentBySig()가 여러 Leg를 한 번에 처리한다. → 스마트 컨트랙트(Smart Contract)란 블록체인 위에서 자동으로 실행되는 프로그램이다. 조건이 충족되면 코드에 정해진 대로 동작하며, 한번 배포되면 변경할 수 없다. → Leg란 배치 결제 안에서 개별 송금 건 하나를 가리킨다. "캐시백 지갑 → 가맹점 3,000원"이 하나의 Leg다. 하나라도 실패하면 전체가 롤백되므로, 중간 상태가 절대 생기지 않는다.

잔액 조회도 병렬로

3개 지갑의 잔액을 블록체인에서 조회해야 하는데, 순차로 하면 RPC 호출 3번 × 각 200ms = 600ms. 병렬로 하면 ~200ms. → RPC(Remote Procedure Call)란 원격 서버에 있는 함수를 마치 로컬 함수처럼 호출하는 통신 방식이다. 여기서는 블록체인 노드에 잔액 조회 요청을 보내는 것을 말한다.

fun getBalancesParallel(
    normalWallet: UserWallet, incentiveWallet: UserWallet, governmentWallet: UserWallet
): UserWalletBalance {
    val (normalBalance, incentiveBalance, governmentBalance) =
        listOf(normalWallet, incentiveWallet, governmentWallet)
            .map { wallet ->
                CompletableFuture.supplyAsync({
                    wallet.avalancheAddress?.let { tokenContractService.balanceOf(it) } ?: BigDecimal.ZERO
                }, ioExecutor)  // 전용 I/O 스레드풀 사용 (→ 스레드풀이란 미리 만들어둔 스레드 묶음으로, 작업이 들어오면 놀고 있는 스레드가 처리한다)
            }
            .map { it.get() }

    return UserWalletBalance(normalBalance, incentiveBalance, governmentBalance)
}

환불은 역방향

결제 취소 시에는 원래 결제에서 각 지갑별로 빠진 금액을 그대로 돌려보낸다.

결제: INCENTIVE 3,000원 + NORMAL 7,000원 → 가맹점
환불: 가맹점 → INCENTIVE 3,000원 + NORMAL 7,000원 (batchRefund)

원본 결제의 INCENTIVE_USE 트랜잭션에서 사용량을 역추적해서 정확히 같은 금액을 돌려보낸다.

배운 점

  • Waterfall 패턴은 복잡해 보이지만, when 분기로 깔끔하게 처리할 수 있다
  • nullable 파라미터(requestedIncentive)로 "기본값 / 명시적 비사용 / 부분 사용"을 표현하는 게 효과적이었다
  • 여러 지갑 차감을 1건의 블록체인 거래로 묶는 것이 데이터 정합성의 핵심이다