@samcoding

ethers.js에서 viem으로 마이그레이션할 때 마주치는 3가지 함정과 해결법
웹3 프런트엔드 생태계에서 번들 크기 경량화와 엄격한 타입 안정성은 이제 타협할 수 없는 기본 스펙이 되었습니다. 오랫동안 이더리움 디앱 개발의 업계 표준이자 든든한 버팀목이었던 ethers.js를 뒤로하고, 수많은 빌더가 viem으로 눈을 돌리는 이유도 바로 여기에 있습니다.
단순히 번들 크기를 수십 킬로바이트 줄이는 성능 최적화 관점만으로 viem을 바라보는 것은 이 변화의 본질을 놓치는 일입니다. 이 마이그레이션의 핵심은 객체지향 기반의 무거운 상태 추상화에서 탈피하여, 트리 셰이킹이 극대화된 무상태 함수형 패러다임으로 전환하는 설계 철학의 변화에 있습니다. 네트워크 연결과 서명자 상태를 가득 품은 거대한 클래스 인스턴스 대신, 순수하게 작동하는 작고 날렵한 함수 조합을 사용하는 방식으로 코딩 방식 자체가 완전히 바뀌게 됩니다.
하지만 아름다운 설계 철학의 이면에는 언제나 실전에서 부딪히는 뾰족한 가시들이 존재합니다. ethers.js의 유연한 런타임 처리에 익숙해진 상태에서 viem으로 마이그레이션을 시도하면, 독할 정도로 꼼꼼한 TypeScript 컴파일러 에러와 마주하며 긴 좌절의 시간을 보내기 십상입니다.
이번 글에서는 단순히 API 대응 표를 나열하는 수준을 넘어, 클래스 기반에서 무상태 함수형으로 전환할 때 마주하는 설계 차이를 명확히 짚어보겠습니다. 그리고 실제 프로덕션 환경에서 마이그레이션할 때 개발자들을 가장 당황하게 만드는 TypeScript 타입 추론의 한계, 미배포 카운터팩추얼 스마트 계정의 서명 검증을 해결하는 ERC-6492 처리 방식, 그리고 멀티체인 유틸리티 설계 시 마주치는 무시무시한 제네릭 지옥을 극복하는 실전적인 함정 탈출법을 공유하고자 합니다.
1. 클래스 기반 추상화에서 무상태 함수형 패러다임으로의 전환
기존 웹3 프론트엔드 생태계의 표준이었던 ethers.js와 신흥 강자인 viem의 가장 큰 차이는 라이브러리를 설계하는 철학적 기저에 있습니다.
ethers.js는 객체 지향 프로그래밍에 익숙한 개발자에게 친숙한 클래스 기반의 상태 중심 추상화를 채택했습니다. Provider는 네트워크와의 연결 상태를, Signer는 계정의 비밀키와 서명 상태를, Contract는 컨트랙트 주소와 ABI 정보를 내부에 품고 작동합니다. 개발자는 이 클래스들의 인스턴스를 만들고, 인스턴스에 내장된 메서드를 호출해 온체인 데이터를 읽거나 트랜잭션을 전송합니다.
반면 viem은 완전히 다른 길을 갑니다. viem의 핵심 철학은 무상태 함수형 기본형입니다. viem에는 무거운 상태를 가진 거대한 클래스가 존재하지 않습니다. 대신 데이터를 처리하고 네트워크와 통신하는 데 꼭 필요한 최소한의 결합만을 정의하며, 그 중심에는 기능별로 정교하게 분리된 클라이언트가 있습니다.
이 클라이언트는 명확하게 역할이 나뉩니다. 온체인 데이터를 읽는 데 특화된 Public Client, 트랜잭션을 서명하고 전송하는 역할을 담당하는 Wallet Client, 그리고 로컬 테스트 환경을 통제하는 Test Client 등으로 물리적인 경계가 구분됩니다. 여기에 네트워크 요청을 실제로 수행하는 전송 계층인 Transport가 명시적으로 주입되는 구조입니다.
이러한 구조적 차이는 실제 코드를 작성할 때 다음과 같이 극명하게 나타납니다.
// ethers.js: Provider와 Signer 클래스의 인스턴스를 통해 상태를 유지하며 동작
import { ethers } from 'ethers';
const provider = new ethers.JsonRpcProvider('https://eth-mainnet.g.alchemy.com/v2/your-api-key');
const signer = new ethers.Wallet('0x_your_private_key', provider);
// 메서드가 인스턴스에 강하게 결합되어 있음
const balance = await provider.getBalance('0x_user_address');
const tx = await signer.sendTransaction({
to: '0x_recipient',
value: ethers.parseEther('1.0')
});// viem: 무상태 클라이언트 인터페이스와 명시적 전송 계층의 조합
import { createPublicClient, createWalletClient, http, parseEther } from 'viem';
import { mainnet } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';
// 온체인 조회 전용 클라이언트
const publicClient = createPublicClient({
chain: mainnet,
transport: http('https://eth-mainnet.g.alchemy.com/v2/your-api-key')
});
// 서명 전용 클라이언트
const account = privateKeyToAccount('0x_your_private_key');
const walletClient = createWalletClient({
account,
chain: mainnet,
transport: http('https://eth-mainnet.g.alchemy.com/v2/your-api-key')
});
// 클라이언트와 함수형 API를 활용한 무상태 호출
const balance = await publicClient.getBalance({ address: '0x_user_address' });
const hash = await walletClient.sendTransaction({
to: '0x_recipient',
value: parseEther('1.0')
});이러한 패러다임의 전환이 실무에서 중요한 진짜 이유는 바로 트리 쉐이킹을 통한 프론트엔드 최적화에 있습니다.
ethers.js의 클래스 기반 아키텍처는 코드 스플리팅과 트리 쉐이킹의 강력한 걸림돌이었습니다. 클래스 내부의 수많은 메서드가 긴밀히 얽혀 있기 때문에, 개발자가 단순히 잔액을 조회하는 getBalance 메서드 하나만 호출하더라도 빌드 도구는 Provider 클래스 전체와 여기에 묶인 가스비 추정, 트랜잭션 파싱 등의 방대한 내부 로직을 번들에 모두 포함해야 했습니다. 이 때문에 ethers.js를 사용하는 디앱은 최소 130kB 이상의 번들 크기를 떠안고 시작해야 했습니다.
이와 달리 viem은 트리 쉐이킹을 극대화하도록 정교하게 설계되었습니다. viem의 모든 액션과 내부 유틸리티들은 독립된 함수 단위로 구성되어 있습니다. publicClient.getBalance나 walletClient.sendTransaction과 같은 호출은 내부적으로 트리 쉐이킹이 가능한 독립적인 기능 모듈로 환원됩니다. 만약 프로젝트에서 복잡한 가스 예측이나 특정 이종 체인 전송 함수를 사용하지 않는다면, 해당 코드는 빌드 시점의 번들에서 흔적도 없이 사라집니다.
결과적으로 viem으로의 전환은 단순한 코드 미학의 문제를 넘어, 프론트엔드 라이브러리 용량을 기존의 4분의 1 이하인 약 35kB 수준으로 드라마틱하게 다이어트하는 직접적인 성능 향상으로 이어집니다. 모바일 브라우저나 저성능 네트워크 환경의 사용자에게 초기 로딩 속도 단축이라는 실질적인 가치를 제공하게 되는 것입니다.
2. TypeScript 타입 추론과 'as const' 컴파일러 에러
ethers.js는 런타임에 느슨한 JSON 데이터를 받아 처리하는 경향이 있어 TypeScript 타입 검사가 비교적 유연하게 넘어갑니다. 반면, viem은 컴파일 단계에서 완벽한 정적 타입 추론을 보장하는 방향을 지향합니다. ABI나 EIP-712 구조를 컴파일 타임에 재귀적으로 완벽히 분석하여 잘못된 파라미터 전달을 원천적으로 차단하려 합니다.
이 강력한 정적 타입 시스템은 개발자를 안전하게 보호해 주지만, 마이그레이션 과정에서 가장 악명 높은 함정을 만들어내기도 합니다. 바로 as const 선언 누락입니다.
TypeScript에서 객체를 선언할 때 특별한 조치를 취하지 않으면 컴파일러는 객체의 필드 값을 언제든 바뀔 수 있는 범용적인 타입으로 간주합니다. 예를 들어 "string"이라는 값은 리터럴 타입 "string"이 아니라 범용적인 string 타입으로 추론됩니다. viem은 이 문자열 타입을 기반으로 이더리움 표준 타입을 파싱하고 매핑을 수행하므로, 컴파일러가 타입을 넓게 추론해 버리는 순간 내부 매핑 레이어가 완전히 무너집니다.
가장 대표적인 사례가 EIP-712 서명을 정의할 때입니다.
// ❌ 잘못된 예시: 'as const'가 없어 컴파일러 에러 발생
const domain = {
name: 'Ether Mail',
version: '1',
chainId: 1,
verifyingContract: '0xCcCCccccCCCCcCCCCCcCcCccCcCCCcCcccccccC',
};
const types = {
Person: [
{ name: 'name', type: 'string' },
{ name: 'wallet', type: 'address' },
],
Mail: [
{ name: 'from', type: 'Person' },
{ name: 'to', type: 'Person' },
{ name: 'contents', type: 'string' },
],
};
// walletClient.signTypedData를 호출할 때 타입 에러가 발생합니다.
// TypeScript는 types와 domain을 고정된 명세가 아닌 일반 객체로 인식하기 때문입니다.
await walletClient.signTypedData({
account,
domain,
types,
primaryType: 'Mail',
message: {
from: { name: 'Cow', wallet: '0xCcCCccccCCCCcCCCCCcCcCccCcCCCcCcccccccC' },
to: { name: 'Bob', wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB' },
contents: 'Hello!',
},
});위 코드처럼 작성하면 IDE 화면 전체를 뒤덮는 수백 줄의 난해한 에러 메시지를 마주하게 됩니다. TypeScript 내부 제네릭 연산 과정에서 넓은 타입 추론으로 인해 never 형식으로 평가되거나 일치하는 오버로드를 찾을 수 없다는 복잡한 메시지가 출력되기 때문입니다.
이를 해결하려면 컴파일러에게 "이 객체는 읽기 전용 리터럴 타입"이라는 사실을 알려주어야 합니다. 선언부 끝에 as const만 붙여주면 타입 시스템이 정확하게 작동합니다.
// ✅ 올바른 예시: 'as const'로 읽기 전용 리터럴 타입 명시
const domain = {
name: 'Ether Mail',
version: '1',
chainId: 1,
verifyingContract: '0xCcCCccccCCCCcCCCCCcCcCccCcCCCcCcccccccC',
} as const;
const types = {
Person: [
{ name: 'name', type: 'string' },
{ name: 'wallet', type: 'address' },
] as const,
Mail: [
{ name: 'from', type: 'Person' },
{ name: 'to', type: 'Person' },
{ name: 'contents', type: 'string' },
] as const,
} as const;
// 이제 컴파일러가 message의 구조를 정확하게 파악하고 자동 완성 및 엄격한 타입 검사를 지원합니다.
await walletClient.signTypedData({
account,
domain,
types,
primaryType: 'Mail',
message: {
from: { name: 'Cow', wallet: '0xCcCCccccCCCCcCCCCCcCcCccCcCCCcCcccccccC' },
to: { name: 'Bob', wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB' },
contents: 'Hello!',
},
});as const를 추가하면 TypeScript는 domain과 types 객체의 원소들을 고정된 값 자체로 엄격하게 추론합니다. 덕분에 viem 내부의 강력한 제네릭 파서가 각 필드의 유효성 검사뿐만 아니라 message 매개변수에 들어갈 내부 객체의 필드 타입까지 똑똑하게 자동 완성해 줍니다. ABI 정의도 이와 완벽히 동일한 방식으로 동작하므로, viem으로 작업할 때는 스마트 컨트랙트 규격을 정의한 모든 메타데이터 객체 끝에 as const를 기본적으로 명시하는 습관을 들이는 것이 좋습니다.
3. 미배포 계정을 위한 ERC-6492 서명 검증의 이점
계정 추상화가 대중화되면서 스마트 컨트랙트 지갑을 활용한 서명 검증은 이제 디앱 개발의 필수 요소가 되었습니다. 하지만 이 과정에서 백엔드나 프론트엔드 개발자들이 가장 까다로워하는 문제가 하나 있습니다. 바로 첫 온체인 트랜잭션을 실행하기 전까지 네트워크상에 실제로 배포되지 않는 미배포 상태의 지갑, 즉 카운터팩추얼 지갑의 서명을 처리하는 일입니다.
가스비를 절약하기 위해 스마트 컨트랙트 지갑은 사용자가 처음으로 실제 트랜잭션을 전송하기 전까지는 주소만 선점하고 코드는 없는 미배포 상태를 유지합니다. 이 상황에서 사용자가 오프라인 서명을 생성하고 디앱에 로그인하려고 시도하면, 기존의 스마트 지갑 서명 검증 규격인 ERC-1271은 작동하지 않습니다. ERC-1271은 해당 지갑 주소에 배포된 온체인 코드를 직접 호출하여 서명 유효성을 검증하는 방식인데, 온체인에 코드 자체가 없으니 검증 호출이 무조건 실패로 돌아가기 때문입니다.
ethers.js 환경에서 이 문제를 해결하려면 개발자가 상당히 복잡한 우회로를 직접 만들어야 했습니다. 미배포 지갑의 서명 검증 표준인 ERC-6492 규격에 맞춰 서명 데이터 끝부분에 포함된 매직 바이트를 수작업으로 파싱하고, 팩토리 컨트랙트 주소와 생성 데이터를 발라내야 했습니다. 그 후 별도의 가상 검증용 도우미 컨트랙트를 통해 오프-체인 시뮬레이션을 수행하거나 가볍지 않은 외부 라이브러리를 연동해야 했기에 프로젝트의 아키텍처가 불필요하게 복잡해지곤 했습니다.
반면 viem은 이 까다로운 ERC-6492 검증 흐름을 라이브러리 코어 수준에서 완벽하게 지원합니다. viem의 Public Client가 제공하는 verifyMessage나 verifyTypedData 액션은 내부적으로 대상 주소의 온체인 코드가 존재하는지 먼저 감지합니다. 만약 온체인에 코드가 존재하지 않는 스마트 지갑 주소라면, 전달받은 서명 데이터가 ERC-6492 규격으로 래핑되어 있는지 자동으로 인지하여 노드와의 통신 단계에서 가상 배포 시뮬레이션 검증을 알아서 수행합니다.
import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
const publicClient = createPublicClient({
chain: mainnet,
transport: http(),
})
// 스마트 지갑의 배포 여부와 상관없이 동일한 인터페이스로 서명을 신뢰성 있게 검증합니다.
const isValid = await publicClient.verifyMessage({
address: '0xYourUndeployedWalletAddress...',
message: 'Sign-In with Ethereum',
signature: '0x...', // ERC-6492 형식으로 패킹된 오프라인 서명 데이터
})
console.log(isValid) // true 또는 false이처럼 개발자는 스마트 지갑의 구체적인 배포 시점이나 라이프사이클을 프론트엔드 레벨에서 구구절절 추적할 필요가 없습니다. 단 한 번의 함수 호출만으로 지갑 상태에 무관한 범용 서명 검증이 가능해집니다. 소셜 로그인 기반의 스마트 계정이나 웹3 온보딩 솔루션을 다루는 개발자라면 이 내장 기능 하나만으로도 불필요한 서드파티 의존성과 복잡한 보일러플레이트 코드를 일시에 제거하는 혜택을 누릴 수 있습니다.
4. 유틸리티 래퍼 작성 시 발생하는 복잡한 제네릭 지옥
여러 네트워크를 동시에 지원해야 하는 멀티체인 디앱 환경이나 프레임워크 수준의 SDK 라이브러리를 만들 때, viem의 엄격하고 꼼꼼한 타입 시스템은 양날의 검이 됩니다. 모든 온체인 동작의 파라미터와 반환 데이터 타입을 빌드 타임에 완벽하게 잡아내려고 설계된 탓에, 아주 사소한 유틸리티 함수나 래퍼 코드 하나를 작성하려 해도 끝없는 제네릭 수렁에 빠지기 일쑤입니다.
예를 들어, ethers.js에서는 지갑 전송이나 트랜잭션 호출을 래핑하기 위해 단순히 Signer 클래스 하나만 파라미터 타입으로 정의하면 그만이었습니다. 하지만 viem에서는 전달받을 클라이언트가 어떤 전송 프로토콜을 쓰는지, 어떤 체인에 연결되어 있는지, 로컬 계정인지 브라우저 외부 지갑인지에 따라 주입해야 할 타입 인자가 기하급수적으로 늘어납니다.
import { WalletClient, Transport, Chain, Account } from 'viem';
// 단순해 보이는 트랜잭션 전송 유틸리티 함수
export async function sendNativeToken<
TTransport extends Transport = Transport,
TChain extends Chain | undefined = Chain | undefined,
TAccount extends Account | undefined = Account | undefined
>(
client: WalletClient<TTransport, TChain, TAccount>,
to: `0x${string}`,
value: bigint
) {
if (!client.account) {
throw new Error('계정이 활성화되지 않았습니다.');
}
return client.sendTransaction({
to,
value,
account: client.account,
chain: client.chain,
});
}위 코드에서 볼 수 있듯이, 단 하나의 전송 유틸리티 함수를 추상화하기 위해서도 TTransport, TChain, TAccount라는 세 가지 서로 다른 제네릭 매개변수를 선언하고 각 인자의 기본값을 설정해야 합니다. 이 유틸리티를 한 단계 더 깊은 비즈니스 로직 래퍼로 감싸려고 하면 타입 정의가 재귀적으로 복잡해지며, 결국 컴파일 속도 저하와 알 수 없는 가독성 저해를 유발하는 주범이 됩니다.
또 다른 까다로운 영역은 Solidity 컨트랙트 내의 구조체와 매핑하기 위해 복잡한 중첩 구조를 가지는 EIP-712 메시지를 해싱하고 서명할 때 발생합니다. ethers.js는 런타임에 유연한 JSON 개체를 허용했던 반면, viem은 중첩된 타입 정의의 모든 키와 정렬 구조까지 철저하게 대조합니다.
이때 많은 빌더가 맞닥뜨리는 실수는 Solidity 내부에서 해싱을 수행하는 오픈제플린의 EIP-712 컨트랙트 규격과 프론트엔드의 viem 간에 구조체 배열 정렬 방식이나 타입 해석 우선순위가 미세하게 어긋나는 경우입니다. Solidity의 ECDSA.recover 검증 로직은 구조체 필드의 정의 순서에 고도로 민감합니다. 만약 viem의 중첩 구조체를 직렬화할 때 순서가 뒤바뀌거나 누락되면 프론트엔드에서는 정상적으로 서명된 것처럼 보여도 온체인 검증 단계에서 영문도 모른 채 트랜잭션이 리버트되는 디버깅 지옥을 겪게 됩니다.
이를 예방하기 위해 프로덕션 환경에서는 가급적 EIP-712 메시지 구조체를 평탄화하여 중첩 깊이를 최소화하는 설계가 권장됩니다. 만약 복잡한 다중 계정 환경에서 크로스 월렛 리플레이 공격을 방어하기 위해 ERC-7739 같은 정밀한 구조의 메시지 서명을 다뤄야 한다면, 타입을 무리하게 제네릭으로 추론하려 들기보다는 viem이 제공하는 하위 레벨 해싱 프리미티브를 활용해 필요한 부분만 타입을 고정하여 명시적으로 단언하는 것이 유지보수 측면에서 훨씬 현명한 선택입니다.
점진적인 마이그레이션을 위한 실천적 조언
ethers.js에서 viem으로의 전환은 단순한 패키지 교체나 번들 크기 다이어트 그 이상의 의미를 가집니다. 이는 객체지향 기반의 무거운 상태 추상화에서 벗어나, 웹 애플리케이션의 뼈대를 가볍고 예측 가능한 무상태 지향 패러다임으로 재설계하는 여정입니다.
대규모 프로덕션 서비스를 운영 중인 개발팀이라면 모든 코드를 한 번에 갈아엎고 싶은 유혹을 내려놓아야 합니다. viem의 강력한 타입 검사와 무상태 함수형 디자인은 처음에 다소 낯설게 느껴질 수 있고, 무리한 전체 전환은 예상치 못한 빌드 에러의 늪으로 이어지기 쉽습니다. 가장 안전하고 현명한 길은 점진적인 마이그레이션입니다.
우선 온체인 데이터를 단순히 읽어오는 조회 영역이나 독립적인 서명 검증 모듈부터 viem의 클라이언트 패턴을 도입해 보는 것을 권장합니다. 특히 스마트 컨트랙트 지갑을 위한 ERC-6492 서명 검증처럼 ethers.js에서 구현하기 까다로웠던 신규 기능이나, 사이드 프로젝트, 혹은 내부 단위 테스트 코드부터 viem을 적용하면서 타입 시스템의 특성에 익숙해지는 것이 좋습니다.
처음에는 as const 선언을 깜빡해 발생하는 빨간 줄이나 유틸리티 함수를 만들 때 마주치는 제네릭의 복잡함에 당황할 수도 있습니다. 하지만 이 엄격한 컴파일 단계의 제약을 견디고 나면, 런타임 에러가 극적으로 줄어들고 코드의 자동 완성 수준이 한 차원 높아지는 짜릿한 경험을 하게 될 것입니다. 웹3 생태계의 프론트엔드 아키텍처가 더 견고하고 가볍게 진화하는 흐름 속에서, viem은 훌륭한 나침반이 되어줄 것입니다. 지금 작은 유틸리티 함수 하나부터 viem으로 바꾸며 한층 더 가볍고 기민한 웹3 개발을 시작해 보세요.