3편에서 DID Auth를 다뤘다. 이번엔 트랜잭션 안정성이다. 문제는 단순했다 — TX를 동시에 여러 건 보내면 실패한다.
블록체인에서 TX는 nonce라는 순서 번호를 가진다. 0, 1, 2… 순차적이어야 하고, 중복이나 gap이 생기면 TX가 처리되지 않는다. 단일 TX를 보낼 땐 문제없지만, 동시에 3건을 보내면 같은 nonce가 할당돼서 2건이 실패한다. 여기에 가스비 추정과 실패 시 재시도까지 얹어야 실서비스에서 쓸 수 있다.
세 개의 모듈
TX 안정성을 세 모듈로 나눴다. 각각 독립적으로 쓸 수 있고, TxQueue가 나머지 둘을 조합한다.
graph LR
A[NonceManager] --> C[TxQueue]
B[GasEstimator] --> C
C --> D[블록체인]
- NonceManager — nonce 순차 할당 + 동시성 제어
- GasEstimator — EIP-1559 가스비 추정 + 우선순위 조정
- TxQueue — 위 둘을 조합해서 순차 전송 + 실패 시 재시도
NonceManager — 동시 요청에서 충돌 방지
핵심은 Promise 기반 락이다.
// nonce/create-nonce-manager.ts (요약)
export function createNonceManager(publicClient: PublicClient): NonceManager {
const nonceMap = new Map<string, number>();
const locks = new Map<string, Promise<void>>();
return {
async getNextNonce(address) {
return withLock(address, async () => {
if (!nonceMap.has(key)) {
const onChainNonce = await publicClient.getTransactionCount({ address });
nonceMap.set(key, onChainNonce);
}
const nonce = nonceMap.get(key)!;
nonceMap.set(key, nonce + 1);
return nonce;
});
},
async resetNonce(address) {
const onChainNonce = await fetchOnChainNonce(address);
nonceMap.set(key, onChainNonce);
},
};
}
첫 호출 시 온체인에서 현재 nonce를 조회하고, 이후로는 내부에서 +1씩 할당한다. withLock으로 같은 주소에 대한 동시 호출을 직렬화해서, 5건이 동시에 getNextNonce를 호출해도 0, 1, 2, 3, 4가 순차적으로 반환된다.
TX가 실패하면 resetNonce로 온체인 nonce를 다시 조회한다. 내부 카운터만 올려놨는데 실제로 TX가 처리 안 됐을 수 있으니까.
GasEstimator — EIP-1559 가스비 추정
EIP-1559에서 가스비는 baseFee + priorityFee다. baseFee는 네트워크가 정하고, priorityFee(팁)는 보내는 쪽이 정한다.
// gas/estimate-gas.ts (요약)
const PRIORITY_MULTIPLIER = { low: 80n, medium: 100n, high: 120n };
export async function estimateDetailedGas(
client: PublicClient,
tx: GasTxRequest,
config: GasConfig = {},
): Promise<GasEstimate> {
const [gasLimit, feeData] = await Promise.all([
client.estimateGas({ account: tx.from, to: tx.to, value: tx.value }),
client.estimateFeesPerGas(),
]);
const maxPriorityFeePerGas = (rawPriority * multiplier) / 100n;
const maxFeePerGas = baseFee + maxPriorityFeePerGas;
if (maxGasPrice && maxFeePerGas > maxGasPrice) {
throw new GasExceededError(maxFeePerGas, maxGasPrice);
}
return { maxFeePerGas, maxPriorityFeePerGas, gasLimit, estimatedCost: gasLimit * maxFeePerGas };
}
gasLimit과 feeData를 병렬로 조회한다. 순차로 하면 RPC 왕복이 2번인데, 병렬이면 1번이다.
우선순위별 배율은 단순하다:
- low (80%) — 느려도 되는 TX. 블록 포함까지 시간이 걸릴 수 있음
- medium (100%) — 기본값
- high (120%) — 빨리 처리되어야 하는 TX
maxGasPrice를 설정하면 가스비가 상한선을 넘을 때 GasExceededError를 던진다. 예상치 못한 가스비 폭등에 대한 안전장치다.
TxQueue — 순차 전송 + 재시도
TxQueue는 NonceManager와 GasEstimator를 조합한다.
// tx-queue/create-tx-queue.ts (요약)
export function createTxQueue(
walletClient: WalletClient,
publicClient: PublicClient,
config: TxQueueConfig = {},
): TxQueue {
const { maxRetries = 3, retryGasMultiplier = 1.2, maxGasPrice, onBalanceLow } = config;
const nonceManager = createNonceManager(publicClient);
// enqueue → 큐에 넣고 → processQueue가 순차 처리
}
enqueue로 TX를 넣으면 내부 큐에 쌓이고, processQueue가 하나씩 꺼내서 처리한다. 동시에 여러 건을 enqueue해도 순차적으로 전송된다.
재시도 전략
TX가 실패하면 가스비를 올려서 다시 보낸다.
1차 시도: gasMultiplier = 1.0 (기본 가스비)
→ 실패
2차 시도: gasMultiplier = 1.2 (+20%)
→ 실패
3차 시도: gasMultiplier = 1.44 (+44%)
→ 실패
4차 시도: 최대 재시도 초과 → 에러
retryGasMultiplier가 기본 1.2이므로 매 재시도마다 20%씩 가스비를 올린다. 네트워크가 혼잡해서 실패한 경우, 더 높은 팁을 제시하면 블록에 포함될 확률이 올라간다.
단, 잔액 부족이면 재시도하지 않는다. 가스비를 올려봤자 의미가 없으니까.
// 잔액 부족 → 즉시 실패, 재시도 안 함
if (balance < txValue) {
onBalanceLow?.(balance);
throw new TransactionError(`Insufficient balance: ${balance} < ${txValue}`);
}
onBalanceLow 콜백으로 잔액 부족을 알리고 즉시 실패 처리한다.
동시 전송 예시
import { createTxQueue } from '@trust-core/wallet';
const queue = createTxQueue(walletClient, publicClient, {
maxRetries: 3,
maxGasPrice: 100000000000n,
onBalanceLow: (bal) => console.warn('잔액 부족:', bal),
});
// 동시에 3건 — nonce 충돌 없이 순차 처리
const results = await Promise.all([
queue.enqueue({ to: '0xAAA...', value: 0n }),
queue.enqueue({ to: '0xBBB...', value: 0n }),
queue.enqueue({ to: '0xCCC...', value: 0n }),
]);
// → nonce 0, 1, 2로 순차 전송
Promise.all로 동시에 3건을 넣어도, 내부에서 NonceManager가 nonce를 순차 할당하고, processQueue가 하나씩 처리한다.
회고
TX 하나 보내는 건 쉽다. TX 열 개를 동시에 안전하게 보내는 게 어렵다.
NonceManager, GasEstimator, TxQueue 세 모듈을 분리한 이유는 각각 독립적으로 쓸 수 있어야 했기 때문이다. 단순히 가스비만 추정하고 싶으면 GasEstimator만, nonce만 관리하고 싶으면 NonceManager만 쓰면 된다. TxQueue는 이 둘을 조합한 편의 레이어다. 모듈을 작게 나누면 테스트도 쉬워지고, 필요한 것만 골라 쓸 수 있다. 실제로 NonceManager는 단위 테스트에서 동시 요청 5건을 보내 nonce 중복이 없는지 검증했고, GasEstimator는 우선순위별 배율이 정확히 적용되는지 독립적으로 테스트할 수 있었다.
이전 편: Wallet SDK 고도화 #3 — DID Auth 챌린지-응답 인증 다음 편: Wallet SDK 고도화 #5 — M-of-N 오프체인 멀티시그