목록으로

블록체인 Nonce 충돌, DB 락으로 잡았다

2

블록체인 Nonce 충돌, DB 락으로 잡았다

2025년 12월 22일 — 최초 발생 및 수정 2026년 1월 13일 — nonce high 이슈 재발, 추가 보완

동시 결제가 들어오면 터지는 버그

결제 시스템을 운영하다 보면 당연히 동시 요청이 들어온다. 일반 DB라면 트랜잭션 격리 수준으로 해결되지만, 블록체인은 좀 다르다. → 트랜잭션 격리 수준(Isolation Level)이란 여러 트랜잭션이 동시에 실행될 때 서로의 데이터를 어느 정도까지 볼 수 있게 할지를 정하는 DB 설정이다.

블록체인에서는 모든 거래에 Nonce라는 일련번호가 붙는다. → Nonce(넌스)란 블록체인에서 각 지갑이 보낸 거래의 순서를 추적하기 위한 숫자다. 0부터 시작해서 거래를 보낼 때마다 1씩 증가한다. 은행의 거래 순번이라고 생각하면 된다. 1번, 2번, 3번... 순서대로 처리되며, 같은 번호를 두 번 쓰면 하나는 무조건 거부된다.

문제는 이거였다. A 결제와 B 결제가 거의 동시에 들어오면:

A 결제 → 블록체인에서 현재 nonce 조회 → 5번 받음 → 5번으로 전송
B 결제 → 블록체인에서 현재 nonce 조회 → 5번 받음 → 5번으로 전송 (충돌!)

B 결제는 "replacement transaction underpriced" 또는 "nonce too low" 에러로 실패한다. → "replacement transaction underpriced"는 같은 nonce의 거래가 이미 있는데 수수료가 더 낮아서 대체가 안 된다는 뜻이고, "nonce too low"는 이미 사용된 nonce를 다시 쓰려 했다는 뜻이다. 운이 나쁘면 사용자한테 결제 실패가 뜨고, 돈은 빠지고, 상태는 꼬이고...

해결: DB를 Nonce의 단일 진실 공급원으로

→ 단일 진실 공급원(Single Source of Truth)이란 특정 데이터의 "진짜 값"을 한 곳에서만 관리하겠다는 설계 원칙이다. 여기서는 nonce 값을 블록체인이 아니라 DB 한 곳에서만 관리한다는 뜻이다.

블록체인에서 nonce를 조회하는 대신, DB에 각 지갑의 현재 nonce를 저장하고, SELECT FOR UPDATE로 한 번에 하나씩만 가져가게 했다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT w FROM WalletNonce w WHERE w.address = :address")
fun findByAddressForUpdate(@Param("address") address: String): WalletNonce?

PESSIMISTIC_WRITE = MySQL의 SELECT ... FOR UPDATE. 이 쿼리를 실행하면 해당 행에 배타적 락이 걸린다. → 배타적 락(Exclusive Lock)이란 한 트랜잭션이 데이터를 점유하는 동안 다른 트랜잭션이 그 데이터를 읽거나 수정하지 못하게 막는 것이다. 화장실 문을 잠그는 것과 비슷하다. 다른 트랜잭션이 같은 행을 읽으려 하면, 앞선 트랜잭션이 끝날 때까지 대기한다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
fun getAndIncrementNonce(address: String): BigInteger {
    // SELECT FOR UPDATE → 락 걸고 조회
    var walletNonce = walletNonceRepository.findByAddressForUpdate(normalizedAddress)

    val currentNonce = walletNonce.nonce

    // 즉시 +1 업데이트 (다른 TX가 같은 nonce 못 가져감)
    walletNonce.nonce = currentNonce + 1
    walletNonceRepository.save(walletNonce)

    return BigInteger.valueOf(currentNonce)
}

핵심은 REQUIRES_NEW다. → REQUIRES_NEW는 Spring의 트랜잭션 전파 옵션으로, 현재 진행 중인 트랜잭션과 무관하게 완전히 새로운 트랜잭션을 만들어 독립적으로 커밋/롤백되게 한다. 부모 트랜잭션과 별도의 DB 트랜잭션을 열어서, nonce 획득 → +1 업데이트가 즉시 커밋된다. 부모 트랜잭션이 나중에 실패해도, nonce는 이미 증가한 상태라 다음 요청이 안전하게 다음 번호를 받는다.

그래도 충돌이 날 때가 있다

서버가 재시작되거나, 외부에서 지갑을 직접 사용하면 DB의 nonce와 블록체인의 실제 nonce가 어긋난다. 이걸 위해 세 가지 안전장치를 뒀다.

  1. 에러 감지 + 자동 동기화
private fun isNonceConflictError(errorMessage: String): Boolean {
    val lowerMessage = errorMessage.lowercase()
    return lowerMessage.contains("replacement transaction underpriced") ||
            lowerMessage.contains("nonce too low") ||
            lowerMessage.contains("already known")
}

이 에러가 나면 블록체인에서 최신 nonce를 가져와 DB를 갱신하고, 점진적으로 대기 후 재시도한다 (500ms → 1s → 1.5s, 최대 3회).

  1. 1분 주기 스케줄러
@Scheduled(fixedRate = 60000)
fun scheduledNonceSync() {
    systemWallets.forEach { address ->
        nonceManagerService.syncWithChain(address)
    }
}
  1. 서버 시작 시 초기화
@EventListener(ApplicationReadyEvent::class)
fun initializeNonces() {
    nonceManagerService.initializeNonces(systemWallets)
}

배운 점

  • 블록체인의 상태를 직접 질의하는 것보다, DB를 단일 진실 공급원(Single Source of Truth)으로 두는 것이 동시성 제어에 유리하다
  • SELECT FOR UPDATE는 단순하지만 강력하다. 복잡한 분산 락보다 현실적인 해결책이었다
  • REQUIRES_NEW로 nonce 트랜잭션을 격리하면, 비즈니스 로직 실패와 nonce 관리를 분리할 수 있다
  • 그래도 "완벽"은 없다. 에러 감지 + 재시도 + 주기적 동기화라는 3단 방어선이 필요했다
블록체인 Nonce 충돌, DB 락으로 잡았다 | KYUDORI