트랜잭션과 nonce 충돌을 줄이는 법: 다중 Relayer
하나의 릴레이어에 몰리는 트랜잭션 때문에 nonce가 꼬이는 문제를, 다중 릴레이어와 Firebase를 사용해 저비용으로 풀어본 설계 노트입니다.
블록체인에서 ‘쓰기’ 기능을 실행하려면 항상 가스비라는 비용이 따라옵니다.
보통 이 가스비는 트랜잭션을 보낸 사용자가 직접 지불하지만, 서비스 운영자가 대신 부담해 드리는 구조도 만들 수 있습니다.
이때, 가스비를 대신 내주는 EOA를 저는 릴레이어(Relayer) 라고 부르기로 했습니다.
문제는, 여러 사용자가 동시에 하나의 릴레이어를 공유할 때 발생합니다.
운이 나쁘면 nonce가 서로 부딪히는 상황, 이른바 “nonce가 꼬였다”라는 문제가 실제로 발생합니다.
이 글은 그 문제를 어떻게 이해했고, 왜 제가 메시지 큐 대신 다중 릴레이어 + Firebase 조합을 택하게 되었는지에 대한 기록입니다.
nonce는 무엇이고, 왜 꼬이는가?
EVM 환경에서 모든 EOA는 nonce라는 숫자를 하나씩 가지고 있습니다.
이 nonce는 말 그대로 “이번 트랜잭션이 몇 번째 시도인가” 를 나타내는 카운터입니다.
- 최초 트랜잭션:
nonce = 0 - 다음 트랜잭션:
nonce = 1 - 그다음:
nonce = 2… 이런 식으로 1씩 증가합니다.
이게 중요한 이유는 다음과 같습니다.
- 중복 트랜잭션(재전송, 재생 공격)을 막기 위해서이고
- 트랜잭션 순서를 보장하기 위해서입니다.
nonce의 개념에 대한 기본적인 배경은
비트코인 vs 이더리움 을 참고해 주시면 이해가 더 쉽습니다.

하나의 릴레이어에 트랜잭션이 몰리면?
EVM 블록체인에서 한 EOA(= 하나의 릴레이어) 는 동시에 같은 nonce로 여러 트랜잭션을 보낼 수 없습니다.
예를 들어:
- 사용자 A의 요청을 릴레이어 R이 처리 →
nonce = 10인 트랜잭션 전송 - 거의 동시에 사용자 B의 요청도 R이 처리 → 마찬가지로
nonce = 10으로 보내려고 시도
이때 노드는 “같은 계정에서 nonce 10이 두 개?”라고 판단하고 한쪽을 실패시키거나,
더 늦게 들어온 쪽은 nonce 불일치/충돌 관련 에러를 내며 처리하지 못하게 됩니다.
현업에서는 이런 문제 때문에 nonce 관리 로직을 따로 설계할 정도로 중요하게 다룹니다.
특히 “가스비 대납형 서비스 + 다중 사용자 + 하나의 릴레이어” 라는 조합에서는
거의 필연적으로 고민하게 되는 이슈입니다.
nonce 충돌을 줄이는 두 가지 접근
저는 크게 두 가지 옵션을 두고 비교했습니다.
- 메시지 큐(Message Queue) 기반 “동기적” 처리
- 다중 릴레이어 기반 “비동기 분산” 처리

(1) 메시지 큐: 일렬로 세워 처리하기
여러 요청이 동시에 들어오더라도,
메시지 큐(Message Queue) 를 두고 “줄 세우기”를 하면 nonce 충돌을 거의 없앨 수 있습니다.
- 사용자 요청이 동시에 들어와도
- 큐에 차곡차곡 쌓아두고
- 한 번에 하나씩 릴레이어를 통해 트랜잭션을 보내면
- nonce는 항상
0,1,2,3…순서대로 안전하게 증가합니다.
놀이공원에서 줄 서는 구조와 비슷합니다.
“한 명씩만 입장 가능”이라는 룰을 시스템으로 강제하는 방식입니다.
다만, 대부분의 메시지 큐는 클라우드 서비스(= 과금 가능성) 를 사용하게 되고,
제 프로젝트 목표가 “비용 최소화” 이기 때문에 이 선택지는 우선순위에서 밀렸습니다.
(2) 다중 릴레이어: 릴레이어를 여러 개로 나누기
두 번째 방법은 아예 릴레이어를 여러 개 두는 것입니다.
Relayer1,Relayer2,Relayer3, …
동시에 여러 요청이 들어오면:
- 어떤 요청은
Relayer1이 처리하고 - 어떤 요청은
Relayer2가 처리하고 - 또 다른 요청은
Relayer3가 처리하는 식입니다.
각 릴레이어는 자신의 nonce만 관리하면 되기 때문에,
동시에 트랜잭션을 보내더라도 서로 다른 계정의 nonce이므로 충돌이 줄어듭니다.
문제는 그다음입니다.
“그러면 각 릴레이어가 지금 사용 가능한 상태인지는 어디에, 어떻게 저장하지?”
라는 질문이 자연스럽게 따라옵니다.
나의 선택은? 메시지 큐 대신 ‘다중 릴레이어 + 상태 관리’
결국 저는 메시지 큐를 쓰지 않고,
여러 개의 EOA를 릴레이어로 두는 다중 릴레이어 전략을 택했습니다.
이유는 단순합니다.
- 메시지 큐는 대부분 유료 서비스이거나, 사용량에 따라 과금될 수 있음
- 제 블로그/서비스는 최대한 저비용으로 운영하는 것이 목표
- 애초에 이 프로젝트는 실험적인 성격이 강하고, 고도 트래픽을 전제로 한 구조는 아님
그래서 다음과 같은 방향으로 정리했습니다.
- 릴레이어를 여러 개 만들고
- “지금 쓸 수 있는 릴레이어”를 고르는 로직을 별도로 설계하며
- 이때 필요한 상태 저장은 블록체인이 아니라 다른 스토리지로 해결하는 것
상태 저장은 어디에 할 것인가?
다중 릴레이어를 쓰려면, 최소한 이런 상태를 어딘가에 저장해야 합니다.

Relayer1:READY / PROCESSING / SHUTDOWNRelayer2:READY / PROCESSING / SHUTDOWN- …
저는 여기서 두 가지 후보를 놓고 고민했습니다.
(1) 상태를 블록체인에 직접 저장하기
가장 단순한 방법은 릴레이어의 상태를 온체인에 기록하는 것입니다.
READYPROCESSINGSHUTDOWN(더 이상 가스비를 낼 수 없는 잔고 상태)
컨트랙트에 이런 상태를 저장해 두고,
트랜잭션을 보낼 때마다 “지금 이 릴레이어는 READY인가?” 를 체크하는 방식입니다.
하지만 여기에는 몇 가지 문제가 있습니다.
- 블록체인에 쓰기 위한 추가 가스비
- 상태 변경이 확정될 때까지의 블록 확정 시간
- 사용자 입장에서 느껴질 수 있는 지연감(답답함)
“가벼운 신호등 역할을 한다”는 목적치고는,
온체인에 굳이 쓰기에는 비용과 지연 측면에서 애매하다고 느꼈습니다.
(2) 상태를 잠깐 보관할 수 있는 스토리지 사용 (Firebase)
그래서 저는 Firebase Realtime Database를 택했습니다.
선택 이유는 크게 두 가지입니다.
-
쿼터를 조금만 써도 충분합니다.
- 이 시스템에서 Firebase는 “신호등 역할” 만 하면 됩니다.
- 고용량 데이터를 넣거나, 복잡한 쿼리를 할 필요가 없습니다.
- 따라서 무료 또는 저비용 구간 안에서 충분히 운영 가능하다고 판단했습니다.
-
이름 그대로 “리얼타임(Realtime)” 입니다.
- 상태 업데이트와 반영이 거의 실시간에 가깝습니다.
- “지금 사용 가능한 릴레이어가 누구냐”를 빠르게 판단해야 하는 신호등 용도로 적합합니다.
- 복수 인스턴스/서버가 늘어나더라도 실시간 동기화에 유리합니다.
결론적으로, 저는 다음과 같이 정리했습니다.
“DB는 쓰지 않는다”는 큰 방향은 유지하면서,
“다만 신호등 역할을 위한 최소한의 상태 저장은 Firebase에 맡긴다.”
라는 타협안을 선택했습니다.
다중 릴레이어를 이용한 구독 시스템 프로세스
이제 이 구조를 실제로 적용한 구독 시스템 프로세스를 정리해 보겠습니다.
키워드는 Next.js 서버 + 다중 릴레이어 + Subscribe 컨트랙트 + Firebase + Google API입니다.
(1) 사용자가 이메일을 입력하고, 인증 요청
- 사용자는 화면에서 자신의 이메일을 입력하고
- “인증 코드 전송” 버튼을 클릭합니다.
(2) Next.js 서버: PIN 코드 생성 및 블록체인 저장 준비
- Next 서버는 랜덤한 PIN 코드를 생성합니다.
- 이 PIN 코드를 블록체인에 저장하기 위해 트랜잭션을 만들 준비를 합니다.
여기서 Relayer1이 이미 다른 작업을 처리 중이라고 가정하겠습니다.
(2-1) Firebase에서 사용 가능한 릴레이어 고르기
서버는 Firebase Realtime Database에 저장된 릴레이어 상태를 조회합니다.
예를 들어 다음과 같은 구조라고 하면:
{
"relayers": {
"relayer1": "PROCESSING",
"relayer2": "READY",
"relayer3": "READY"
}
}
이 중에서 READY 상태인 릴레이어를 하나 뽑습니다.
예를 들어 relayer2입니다.
(2-2) 릴레이어2로 PIN 코드 저장 트랜잭션 보내기
서버는 relayer2의 프라이빗 키/서명 권한을 사용해
Subscribe 컨트랙트에 트랜잭션을 생성하여 PIN 코드를 저장합니다.
이때 Subscribe 컨트랙트에서는 다음과 같은 검증을 한다고 가정합니다.
msg.sender가 등록된 릴레이어인지 확인- 릴레이어가 아닐 경우 → 바로
revert - 릴레이어일 경우 → PIN 코드를 온체인에 저장
(3) 트랜잭션 확정 후, 이메일로 PIN 코드 발송
(2)의 트랜잭션이 블록에 포함되고 확정되면,
서버는 Google API를 이용해 사용자가 입력한 이메일 주소로 PIN 코드를 전송합니다.
이 타이밍에 맞춰, Firebase 상의 relayer2 상태를 다시 READY로 돌려놓는 방식으로
릴레이어 상태를 관리할 수 있습니다.
(4) 사용자가 PIN 코드를 입력
사용자는 이메일로 받은 PIN 코드를 페이지의 입력란에 입력합니다.
서버는 이 PIN 코드가 온체인에 저장된 값과 일치하는지 확인합니다.
(5) 이메일 인증 완료 및 구독자 이메일 저장
PIN 코드 검증이 완료되면, 이제 실제 구독자 이메일을 저장하는 단계로 넘어갑니다.
이때도 동일하게 다음 과정을 반복합니다.
(5-1) Firebase에서 다시 사용 가능한 릴레이어 선택
서버는 Firebase에서 현재 READY 상태인 릴레이어를 다시 조회합니다.
예를 들어 이번에는 relayer3가 선택되었다고 가정하겠습니다.
(5-2) Subscribe 컨트랙트에 암호화된 이메일 저장
서버는 구독자의 이메일을 암호화한 뒤,
relayer3를 통해 Subscribe 컨트랙트에 저장하는 트랜잭션을 전송합니다.
컨트랙트는 다시 한 번 msg.sender가 릴레이어인지 확인하고,
조건이 맞으면 암호화된 이메일을 온체인에 기록합니다.
이 과정을 통해 다음과 같은 효과를 얻을 수 있습니다.
- 실제 구독 데이터(이메일)는 블록체인에 암호화된 형태로 저장되고
- 릴레이어의 nonce 충돌은 다중 릴레이어 + Firebase 상태 관리로 완화되며
- 메시지 큐 같은 추가 유료 인프라 없이도 저비용 구조를 유지할 수 있습니다.
정리: nonce 관리, 결국 “상태 관리” 문제였습니다
이 글의 핵심을 한 줄로 요약하면 다음과 같습니다.
“nonce 충돌을 줄이려면, 결국 누가 언제 트랜잭션을 보낼지에 대한 상태 관리를 어떻게 할지의 문제다.”
- 메시지 큐는 이 문제를 “줄 세우기” 로 해결합니다.
- 다중 릴레이어는 “계정을 나누어 부하를 분산” 하는 방식으로 해결을 시도합니다.
저는 저비용을 최우선으로 두고,
다중 릴레이어 + Firebase Realtime Database 조합을 선택했습니다.
이 구조는 어디까지나 실험적인 설계이며,
고트래픽, 고가용성 환경에서는 또 다른 선택을 해야 할 수도 있습니다.
하지만 “인프라비를 최소화한 개인 프로젝트에서의 실전 구조” 라는 관점에서는
충분히 의미 있는 타협점이라고 생각합니다.