목록으로

DB는 동기, 블록체인은 비동기 — 인센티브 2단계 패턴

3

DB는 동기, 블록체인은 비동기 — 인센티브 2단계 패턴

2026년 2월 9일 — 인센티브 지급 비동기 처리, 지갑 잔액 조회 비동기 처리 구현

문제: 인센티브 때문에 결제가 느리다

사용자가 10,000원 결제하면, 결제 처리 후 인센티브(캐시백)를 지급해야 한다. 인센티브 지급도 블록체인 트랜잭션이니까, 결제 응답이 돌아오기까지 "결제 온체인 처리 + 인센티브 온체인 처리" 두 번의 블록체인 대기가 필요했다. → 온체인(On-chain)이란 블록체인 위에서 직접 처리되는 것을 뜻한다. 반대로 오프체인(Off-chain)은 블록체인 밖에서 처리되는 것이다.

Before:
결제 요청 → 온체인 결제 (1~2초) → 온체인 인센티브 (1~2초) → 응답
= 총 2~4초

인센티브 지급이 결제 응답을 블로킹하고 있었다. → 블로킹(Blocking)이란 앞선 작업이 끝날 때까지 다음 작업이 멈춰서 기다리는 것이다. 반대로 논블로킹(Non-blocking)은 기다리지 않고 다음 작업을 바로 시작하는 것이다. 사용자 입장에서 캐시백은 "지금 바로" 받을 필요가 없는데, 결제 버튼 누르고 4초를 기다리게 하고 있었다.

해결: 2단계 분리

핵심 아이디어는 단순하다. DB 기록은 즉시, 블록체인 전송은 나중에.

After:
결제 요청 → 온체인 결제 (1~2초) → DB에 PENDING 기록 → 응답 (1~2초)
                                       ↓ (백그라운드)
                                   온체인 인센티브 → COMPLETED로 갱신
private fun prepareAndAsyncPayIncentiveForUser(...) {
    // ==========================================
    // Step 1: 동기 — DB에 즉시 기록
    // ==========================================

    // PENDING 상태로 인센티브 트랜잭션 저장
    val incentiveTransaction = userTransactionRepository.save(
        UserTransaction(
            user = user,
            type = TransactionType.INCENTIVE,
            amount = earnableIncentive,
            status = TransactionStatus.PENDING,  // 아직 블록체인 미처리
            ...
        )
    )

    // 월간 한도 즉시 업데이트 (다음 결제에서 정확한 남은 한도 계산)
    val userLimit = userLimitRepository.findByUserIdAndMonthAndYear(userId, month, year)
        ?: UserLimit(user = user, earnedIncentive = BigDecimal.ZERO, month = month, year = year)
    userLimit.earnedIncentive = userLimit.earnedIncentive.add(earnableIncentive)
    userLimitRepository.save(userLimit)

    // ==========================================
    // Step 2: 비동기 — 블록체인 전송
    // ==========================================
    val capturedTxId = incentiveTransaction.id

    // → CompletableFuture.runAsync는 별도 스레드에서 작업을 비동기로 실행한다. 여기서는 블록체인 전송을 백그라운드에서 처리한다.
    CompletableFuture.runAsync({
        for (attempt in 1..3) {
            try {
                val txHash = tokenContractService.transferWithAuthorization(
                    fromAddress = adminAddress,
                    toAddress = userIncentiveAddress,
                    amount = earnableIncentive,
                    encryptedPrivateKey = adminEncryptedPrivateKey
                )

                // 성공 → COMPLETED로 갱신
                TransactionTemplate(transactionManager).execute {
                    val savedTx = userTransactionRepository.findById(capturedTxId).orElse(null)
                    if (savedTx != null) {
                        savedTx.status = TransactionStatus.COMPLETED
                        userTransactionRepository.save(savedTx)
                    }
                }
                break
            } catch (e: Exception) {
                if (attempt == 3) {
                    // 최종 실패 → FAILED로 갱신
                    TransactionTemplate(transactionManager).execute {
                        val savedTx = userTransactionRepository.findById(capturedTxId).orElse(null)
                        if (savedTx != null) {
                            savedTx.status = TransactionStatus.FAILED
                            userTransactionRepository.save(savedTx)
                        }
                    }
                } else {
                    Thread.sleep(1000L * attempt)  // 1초, 2초, 3초 대기 후 재시도
                }
            }
        }
    }, ioExecutor)  // 전용 I/O 스레드풀에서 실행
}

왜 한도를 동기로 먼저 업데이트하나?

비동기로 보내면 블록체인 처리 전에 다음 결제가 들어올 수 있다. 한도를 나중에 업데이트하면:

1번 결제: 인센티브 700원 (한도 잔여 300,000원) → 비동기 전송 시작
2번 결제: 인센티브 700원 (한도 잔여 300,000원 ← 아직 안 깎임!) → 중복 지급!

한도를 먼저 깎아두면:

1번 결제: 인센티브 700원 (한도 잔여 300,000 → 299,300) → 비동기 전송 시작
2번 결제: 인센티브 700원 (한도 잔여 299,300 → 298,600) → 정확!

TransactionTemplate을 쓴 이유

비동기 블록(CompletableFuture.runAsync) 안에서는 부모의 @Transactional이 적용되지 않는다. 다른 스레드니까. 그래서 TransactionTemplate으로 수동으로 트랜잭션을 열어야 한다. → @Transactional은 Spring이 메서드 단위로 DB 트랜잭션을 자동 관리해주는 어노테이션이다. 하지만 이건 같은 스레드 안에서만 동작한다. → TransactionTemplate@Transactional 대신 코드로 직접 트랜잭션 시작과 종료를 제어하는 방식이다. 비동기 스레드처럼 어노테이션이 안 먹히는 상황에서 쓴다.

TransactionTemplate(transactionManager).execute {
    // 이 블록 안이 하나의 DB 트랜잭션
    val savedTx = userTransactionRepository.findById(capturedTxId).orElse(null)
    savedTx?.status = TransactionStatus.COMPLETED
    userTransactionRepository.save(savedTx)
}

남은 고민: 블록체인 실패 시 한도 롤백

현재는 블록체인 전송이 실패해도 한도가 이미 차감된 상태다. FAILED로 표시만 하고 한도 롤백은 안 한다. 실제 운영에서는 이게 "보수적으로 안전한" 방향이지만(초과 지급 방지), 사용자 입장에서는 한도가 줄어든 것처럼 보일 수 있다.

배운 점

  • "사용자 응답 속도"와 "데이터 정합성"은 트레이드오프가 아니라, 패턴으로 둘 다 잡을 수 있다
  • 동기/비동기 경계를 어디에 두느냐가 시스템 설계의 핵심이다
  • 비동기 스레드에서는 Spring의 @Transactional이 안 먹힌다. TransactionTemplate 필수
  • 한도 같은 "누적 상태"는 반드시 동기로 먼저 업데이트해야 race condition을 방지할 수 있다 → Race condition(경쟁 조건)이란 두 개 이상의 작업이 같은 데이터를 동시에 수정하려 할 때, 실행 순서에 따라 결과가 달라지는 버그다.