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건의 블록체인 거래로 묶는 것이 데이터 정합성의 핵심이다