Ethers.js 없이 ERC-20 토큰 단위 변환하기
2026. 1. 26. 22:34

최근 프로젝트에서 USDC 같은 ERC-20 토큰을 지원해야 하는 상황이 생겼습니다. Web3.js의 fromWei/toWei를 사용하고 있었는데, 이 함수들이 임의의 decimals를 다루기에는 너무 불편하다는 걸 깨달았죠. Ethers.js를 추가할까 고민하다가, 결국 직접 구현하기로 결정했습니다. 그 과정과 이유를 공유합니다.

Wei란 무엇인가?

블록체인을 다루다 보면 항상 마주치는 개념이 바로 "최소 단위"입니다. 이더리움에서는 이를 Wei라고 부르는데, 1 Ether는 정확히 (10^{18}) Wei입니다. 왜 이렇게 큰 숫자를 사용할까요? 소수점 연산의 부정확성을 피하고, 아주 작은 금액도 정확하게 표현하기 위해서입니다.

이더리움만의 이야기는 아닙니다. 다른 블록체인들도 각자의 최소 단위를 가지고 있습니다.

  • 비트코인 (8 decimals): Satoshi
  • 카르다노/ADA (6 decimals): Lovelace
  • 폴카닷/DOT (10 decimals): Planck
  • 솔라나/SOL (9 decimals): Lamport

재미있는 건 각 블록체인의 창시자나 중요 인물의 이름을 따서 명명한다는 점입니다. 이더리움의 Wei는 암호학자 Wei Dai, 비트코인의 Satoshi는 사토시 나카모토에서 따왔죠.

프로젝트에서 마주친 문제

기존에는 Web3.js를 사용해서 Ether와 Wei를 변환하고 있었습니다. 사용법도 간단했죠.

web3.utils.fromWei('1000000000000000000', 'ether') // '1'
web3.utils.toWei('1.5', 'ether') // '1500000000000000000'

그런데 새로운 요구사항이 들어왔습니다. USDC를 지원해야 한다는 거였어요. USDC는 6 decimals를 사용하는데, Web3.js로 이걸 처리하려니 문제가 생겼습니다.

// 방법 1: 미리 정의된 단위명 사용
web3.utils.fromWei('1000000', 'mwei') // '1' (mwei = 10^6)

// 방법 2: 숫자로 직접 지정
web3.utils.fromWei('1000000', 1000000) // '1' 
// ❌ decimal 값 6이 아니라 10^6 = 1000000을 넣어야 함!

Web3.js는 기술적으로는 임의의 decimals를 지원하지만, decimal 값을 그대로 넣는 게 아니라 10의 거듭제곱 값을 넣어야 합니다. 매번 Math.pow(10, decimals)를 계산해야 하는 거죠.

더 큰 문제는 미리 정의된 단위명(unitMap)에는 특정 값들만 있다는 점입니다.

// unitMap에 있는 것들
'mwei': '1000000'    // 10^6 
'gwei': '1000000000' // 10^9 
'ether': '1000000000000000000' // 10^18 

// WBTC (8 decimals)나 DOT (10 decimals)는?
// unitMap에 없음! ❌

WBTC (8 decimals)나 SOL (9 decimals), DOT (10 decimals) 같은 토큰을 처리하려면 복잡한 workaround가 필요합니다.

// 8 decimals를 처리하려면...
const decimals = 8;
const divisor = Math.pow(10, decimals); // 100000000을 직접 계산
web3.utils.fromWei('100000000', 100000000) // 불편함!

이건 직관적이지 않고, 실수하기 쉽습니다. "decimal이 8이면 8을 넣으면 되는 거 아냐?"라고 생각하기 쉬운데, 실제로는 100000000을 넣어야 하니까요.

해결책 검토: Ethers.js를 추가할까?

가장 먼저 떠오른 해결책은 Ethers.js였습니다. Ethers.js의 formatUnits/parseUnits는 직관적으로 decimal 값을 받습니다.

// 훨씬 직관적함!
ethers.formatUnits('1000000', 6) // '1.0' - decimal 값 6을 그대로 사용
ethers.parseUnits('1.5', 6) // '1500000'
ethers.formatUnits('100000000', 8) // '1.0' - WBTC도 쉽게 처리

완벽해 보이죠? 하지만 우리 프로젝트는 이미 Web3.js를 사용하고 있었습니다. 여기서 고민이 시작됐습니다.

단 하나의 기능을 위해 Ethers.js를 추가해야 할까?

몇 가지 우려사항이 있었습니다.

  • 번들 크기가 약 300KB 증가합니다
  • 팀원들이 새로운 라이브러리의 API를 학습해야 합니다
  • Web3.js와 Ethers.js를 혼용하면 코드베이스가 복잡해집니다
  • 두 라이브러리의 타입 시스템이 다르면 타입 변환도 필요할 수 있습니다

실제로 팀 회의에서도 이야기가 나왔습니다. "이미 Web3.js로 잘 쓰고 있는데, Ethers.js를 추가하면 나중에 혼란스럽지 않을까?" 합리적인 우려였습니다.

결정: 직접 구현하기로

고민 끝에 내린 결론은 "직접 만들자"였습니다. 생각해보니 필요한 건 단순한 산술 연산이었거든요.

  • BigNumber 라이브러리는 이미 프로젝트에서 사용 중이었습니다 (없다면 추가해도 약 30KB)
  • 50줄 미만의 코드로 충분히 구현 가능했습니다
  • decimal 값을 직관적으로 사용할 수 있습니다 - 6이면 6, 8이면 8을 그대로 전달
  • 우리 프로젝트의 요구사항에 딱 맞게 커스터마이징할 수 있었습니다

무엇보다 "왜 이렇게 동작하는지" 팀 전체가 정확히 이해할 수 있다는 점이 매력적이었습니다. 블랙박스가 아니라 우리가 완전히 제어 가능한 코드니까요.

JavaScript Number의 치명적인 한계

"그냥 곱하기/나누기 하면 되는 거 아냐?"라고 생각할 수 있습니다. 저도 처음엔 그렇게 생각했어요.

// 이렇게 하면 안 됨!
const wrong = 1000000000000000000 / Math.pow(10, 18) // 정밀도 손실

문제는 JavaScript의 Number 타입이 IEEE 754 부동소수점을 사용한다는 점입니다. 이게 왜 문제일까요?

// 정밀도 손실 예시
0.1 + 0.2 // 0.30000000000000004 (!)

// 큰 숫자 처리 시 손실
9007199254740992 + 1 // 9007199254740992 (1이 사라짐!)

블록체인에서 다루는 숫자는 매우 큽니다. 1 ETH는 1,000,000,000,000,000,000 Wei인데, JavaScript Number로는 이런 큰 숫자를 정확하게 처리할 수 없습니다. 금융 애플리케이션에서 정밀도 손실은 치명적이죠.

그래서 BigNumber 라이브러리가 필수입니다. BigNumber는 문자열 기반으로 임의 정밀도 연산을 지원합니다. 실제로 Web3.js와 Ethers.js도 내부적으로 BigNumber를 사용합니다.

구현: formatUnits & parseUnits

함수명을 고민했습니다. weiToEther/etherToWei도 직관적이지만, 우리는 이더뿐만 아니라 다양한 토큰을 지원해야 했죠. 결국 Ethers.js와 동일한 네이밍인 formatUnits/parseUnits를 선택했습니다.

  • "단위 포맷팅"과 "단위 파싱"이라는 의미가 명확합니다
  • 이더리움에 국한되지 않는 범용적인 이름입니다
  • Ethers.js에 익숙한 개발자들도 바로 이해할 수 있습니다

코드는 다음과 같습니다.

/**
 * 최소 단위를 사람이 읽을 수 있는 단위로 변환
 * @param value - 최소 단위 값 (wei, satoshi 등)
 * @param decimals - 소수점 자리수 (기본값: 18)
 * @returns 변환된 값 (문자열)
 * 
 * @example
 * formatUnits('1000000000000000000', 18) // '1.000000000000000000' (ETH)
 * formatUnits('1000000', 6) // '1.000000' (USDC)
 * formatUnits('100000000', 8) // '1.00000000' (BTC)
 */
export const formatUnits = (
  value: string | number = 0,
  decimals: string | number = 18,
): string => {
  const valueBN = new BigNumber(value);
  const divisor = new BigNumber(Math.pow(10, Number(decimals)));

  return valueBN.dividedBy(divisor).toFixed(Number(decimals));
};

/**
 * 사람이 읽을 수 있는 단위를 최소 단위로 변환
 * @param value - 변환할 값 (1.5 ETH, 100 USDC 등)
 * @param decimals - 소수점 자리수 (기본값: 18)
 * @returns 최소 단위 값 (정수 문자열)
 * 
 * @example
 * parseUnits('1', 18) // '1000000000000000000' (1 ETH -> wei)
 * parseUnits('1.5', 6) // '1500000' (1.5 USDC -> 최소 단위)
 * parseUnits('0.1', 8) // '10000000' (0.1 BTC -> satoshi)
 */
export const parseUnits = (
  value: string | number = 0,
  decimals: string | number = 18,
): string => {
  const valueBN = new BigNumber(value);
  const multiplier = new BigNumber(Math.pow(10, Number(decimals)));

  return valueBN.multipliedBy(multiplier).toFixed(0);
};

코드를 하나씩 살펴보겠습니다.

formatUnits 함수는 최소 단위를 읽기 쉬운 단위로 변환합니다. Wei를 ETH로, USDC의 최소 단위를 실제 USDC 금액으로 바꾸는 거죠. 핵심은 Math.pow(10, Number(decimals))로 나눗셈에 사용할 값을 만들고, toFixed(Number(decimals))로 지정된 소수점 자리수를 유지한다는 점입니다. 이게 중요한 이유는 UI에서 표시할 때 일관된 포맷을 유지하기 위해서입니다.

parseUnits 함수는 그 반대입니다. '1.5 USDC'를 '1500000'으로 변환합니다. 여기서는 toFixed(0)를 사용하는데, 최소 단위는 항상 정수여야 하기 때문입니다. 소수점이 있으면 안 되죠.

Web3.js와의 가장 큰 차이점: decimal 값을 직관적으로 사용할 수 있습니다. Web3.js는 fromWei('1000000', 1000000)처럼 10의 거듭제곱 값을 넣어야 하지만, 우리 함수는 formatUnits('1000000', 6)처럼 decimal 값을 그대로 넣으면 됩니다.

실제 사용 예시를 보면 이해가 쉽습니다.

// 다양한 토큰 지원 - decimal 값을 직관적으로 사용
formatUnits('1000000000000000000', 18) // '1.000000000000000000' (ETH)
formatUnits('1500000', 6)              // '1.500000' (USDC)
formatUnits('100000000', 8)            // '1.00000000' (WBTC)
formatUnits('1000000000', 9)           // '1.000000000' (SOL)

parseUnits('1.5', 18)  // '1500000000000000000'
parseUnits('100', 6)   // '100000000'
parseUnits('0.5', 8)   // '50000000'

이 부분에서 주의할 점은 항상 문자열을 반환한다는 겁니다. Number로 변환하면 정밀도 손실이 발생할 수 있기 때문이죠. 실제로 스마트 컨트랙트와 통신할 때도 문자열로 값을 전달합니다.

세 가지 방식 비교

이제 세 가지 접근 방식을 비교해볼까요?

항목 Web3.js Ethers.js 추가 직접 구현
ERC-20 지원 제한적 (unitMap에 있는 것만) 지원 지원
decimal 사용 10의 거듭제곱 값 필요 직관적 (6, 8 등) 직관적 (6, 8 등)
추가 의존성 없음 (이미 사용중) ~300KB ~30KB (BigNumber)
커스터마이징 불가 제한적 완전 자유
학습 곡선 없음 새 라이브러리 학습 코드 이해만
유지보수 Web3.js 업데이트 의존 Ethers.js 업데이트 의존 직접 관리

실제 사용 코드를 비교해보겠습니다.

// Web3.js (기존) - 불편함
web3.utils.fromWei('1000000000000000000', 'ether') // '1'
web3.utils.fromWei('1000000', 'mwei') // '1' (USDC - 미리 정의된 단위)
web3.utils.fromWei('100000000', 100000000) // '1' (WBTC - 10^8을 직접 계산해야 함 😰)

// Ethers.js (추가 설치 필요) - 300KB 증가, 하지만 직관적
ethers.formatUnits('1000000', 6) // '1.0'
ethers.formatUnits('100000000', 8) // '1.0'

// 직접 구현 (BigNumber만 추가) - 30KB만 증가, 직관적
formatUnits('1000000000000000000', 18) // '1.000000000000000000'
formatUnits('1000000', 6) // '1.000000' (USDC)
formatUnits('100000000', 8) // '1.00000000' (WBTC)
formatUnits('1000000000', 9) // '1.000000000' (SOL)

직접 구현을 선택한 또 다른 이유는 커스터마이징입니다. 예를 들어, 나중에 "소수점 2자리까지만 표시하고 싶다"거나 "특정 토큰은 다른 포맷을 사용하고 싶다"는 요구사항이 생기면 바로 수정할 수 있습니다. 라이브러리를 사용하면 그런 유연성이 제한적이죠.

실제로 적용해보니 생각보다 훨씬 안정적으로 동작했습니다. 테스트 케이스도 작성하기 쉬웠고요. 무엇보다 팀원들이 "이 코드가 정확히 뭘 하는지" 바로 이해할 수 있다는 점이 좋았습니다.

마무리

300KB의 Ethers.js를 추가하는 대신 50줄의 코드를 작성했습니다. 어떤 선택이 정답일까요? 프로젝트마다 다릅니다.

만약 이미 Ethers.js를 사용하고 있다면? 당연히 기존 라이브러리를 활용하는 게 맞습니다. 하지만 Web3.js 프로젝트에서 단 하나의 기능을 위해 Ethers.js를 추가하는 건 과합니다. 특히 Web3.js도 기술적으로는 임의의 decimals를 지원하지만, fromWei('1000000', 1000000) 같은 방식은 실수하기 쉽고 직관적이지 않습니다.

이미 사용 중인 스택에 맞는 최소한의 해결책을 찾는 게 중요합니다. 라이브러리는 편리하지만, 번들 크기, 학습 곡선, 의존성 관리 같은 비용이 따라옵니다.

때로는 바퀴를 다시 발명하는 것이 더 현명합니다. 특히 그 바퀴가 50줄의 코드면 더더욱 그렇죠. 우리는 필요한 기능만 정확히 구현했고, decimal 값을 직관적으로 사용할 수 있으며, 팀 전체가 코드를 이해하고 있고, 언제든 수정할 수 있습니다.

핵심 교훈: 새로운 라이브러리를 추가하기 전에 "정말 필요한가?"라고 질문하세요. 간단한 기능이라면 직접 구현하는 것도 훌륭한 선택입니다. Ethers.js도 좋은 해결책이지만, 모든 프로젝트에 맞는 건 아닙니다. Web3.js의 fromWei/toWei도 쓸 수는 있지만, 10의 거듭제곱 값을 매번 계산해야 하는 불편함이 있습니다. 우리 프로젝트의 컨텍스트에서 최선의 결정을 내리는 게 중요합니다.