DB는 동기, 블록체인은 비동기 — 인센티브 2단계 패턴
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(경쟁 조건)이란 두 개 이상의 작업이 같은 데이터를 동시에 수정하려 할 때, 실행 순서에 따라 결과가 달라지는 버그다.