Web3 프론트엔드에서 숫자를 다루는 법

Web3JavaScriptBigNumberBigIntBlockChain
shmoon
·
·5 min read

여는 글

일반적인 web2 환경에서 프론트엔드 개발에서 숫자를 다룰 때 대부분은 JavaScript의 Number 타입만으로 충분하다. 그래서 Bigint 타입을 알고는 있었지만 실무에서 쓸 일은 없었다.

하지만 블록체인에서는 숫자를 정수로만 다룬다. 부동소수점 오차 없이 매우 작은 수와 큰 수를 다루기 위함인데, 그래서 Solidity 같은 스마트 컨트랙트 언어에서는 정수 기반의 uint256 타입을 사용한다. 이로 인해, 프론트엔드에서도 블록체인에서 가져온 수치를 안전하게 다루려면 자바스크립트에서도 Number가 아니라 BigInt를 사용해야 한다.

여기서는 내가 처음 Web3 개발을 하면서 직접 겪고 배운 숫자 처리 방식이나 개념들을 정리해보려고 한다.

블록체인에서는 왜 정수형을 사용해야할까?

이더리움과 같은 플록체인 플랫폼의 스마트 컨트랙트 언어인 Solidity에서는 uint256이라는 256비트 크기의 부호 없는 정수(양수 및 0) 타입의 자료형을 사용한다. 정수형이기 때문에 우리가 흔히 아는 소수점은 존재하지 않고 0 이상의 정수만을 다룬다.

소수를 직접 지원하지 않으니, 정수 + 자리수(decimals) 스케일링 방식으로 처리한다.

예를 들면,

  • uint256 값 = 1500000000000000000
  • decimals = 18
  • 실제 의미: 1.5

즉, 실제 값 = 정수 값 ÷ (10^decimals)이 된다.

블록체인에서 정수형으로만 숫자를 표현하는 이유는 부동소수점의 오차를 방지하기 위해서다. JavaScript의 Number 또는 C, C++의 float, double과 같은 자료형은 부동소수점 방식을 사용한다.

이더리움 기준으로 1 ETH = 10^18wei 와 같이 아주 작은 단위도 표현해야하는데, 부동소수점으로 표현하면 정밀도 오차가 발생할 수 있다. 이는 곧 자산 손실로 이어질 수 있기 때문에 블록체인에서는 정수형만 사용한다.

따라서 토큰 잔액이나 가스비 등을 체인 상에서 가져와서 UI에 보여주기 위해서 JavaScript에서는 Number가 아닌 BigInt로 표현된다.

블록체인 데이터를 JS로 표현하는 예시

토큰 잔액 (balanceOf)

  • Solidity (온체인)
function balanceOf(address owner) public view returns (uint256);
  • JavaScript (클라이언트)
const balance = await client.readContract({
  address: tokenAddress as Address,
  abi: erc20Abi as Abi,
  functionName: 'balanceOf',
  args: [userAddress],
})
// balance: BigInt (예: 1500000000000000000n)
  • 사람이 보는 값으로 변환
import { formatUnits } from 'viem'
 
console.log(formatUnits(balance, 18)) // "1.5"

Bigint에 대해

BigInt는 자바스크립트에서 Number 타입이 안전하게 표현할 수 있는 최대 정수 (2^53 - 1) 보다 더 큰 정수를 표현할 수 있게 해주는 내장형 타입이다. 리터럴로 표현하려면 숫자 끝에 n을 붙이거나, BigInt() 생성자를 통해서 생성 가능하다.

  • BigInt("12345678901234567890")
  • 12345678901234567890n

BigInt는 직렬화할 수 없기 때문에 JSON.stringify()BigInt를 포함한 값을 전달하면 TypeError가 발생한다. 필요한 경우 toJSON 메서드를 만들어 문자열로 반환할 수 있다.

BigInt.prototype.toJSON = function () {
  return this.toString();
};
 
JSON.stringify(BigInt(1));
// '"1"'

또한, +*-**% 연산자를 BigInt나 객체로 감싼 BigInt에서 사용할 수 있으나 / 연산자는 정수 연산에서 기대하는 대로 동작하고, 소수점 이하는 버린다.

  • 5n / 2n = 2n (2.5n이 아님)

하지만 실무에서는 온체인 값을 BigInt로 받아와서 보여주는 것 뿐만 아니라 프론트에서 BigInt를 직접 계산해야하는 경우가 많다.(대출 플랫폼을 예로 예상 LTV를 보여준다던가, 최대 대출 가능 금액 등) 이 때, 소수점 이하를 버린다면 데이터의 정합성을 보장하지 못하는 문제가 있다.

💡

BigNumber.js vs BigInt
Web3 초기에는 자바스크립트에 큰 정수를 다룰 네이티브 타입이 없어서 ethers.js가 내부적으로 BigNumber.js를 사용했다고 한다.

현재는 JS 표준 BigInt가 보급되면서, 큰 정수 연산(+, -, *, **, %)은 네이티브로 처리 가능하다. 최신 Web3 프론트엔드에서는 BigInt를 직접 쓰는 추세이고, 소수가 필요한 경우에는 고정소수점 패턴(WAD, RAY 등)을 활용한다.

정수형으로 소수를 표현하는 방법

그렇다면 정수만 다루는 자료형들은 소수점 계산을 어떻게 처리할까? 이를 해결하기 위해선 고정 소수점 패턴을 사용한다.

  • 먼저 스케일(예: 10^18 = WAD) 을 곱해 정수 자릿수를 확보한 뒤,
  • 나눗셈을 하고,
  • 마지막에 스케일을 나눠 사람이 읽을 수 있는 소수 형태로 변환한다.

대부분 DeFi 프로젝트에서는 이러한 고정소수점 유틸 함수를 만들어 사용한다. 예를 들어 (x * y) / d 같은 계산을 한 줄로 쓰면 코드마다 실수하기 쉽고, 반올림 정책(내림/올림)도 제각각이 될 수 있다. 그래서 이를 표준화한 함수들이 mulDivDown, mulDivUp, mulWadDown 같은 이름으로 구현돼 있다.

mulDivDown, mulDivUp

export const mulDivDown = (x: bigint, y: bigint, d: bigint): bigint => (x * y) / d;
export const mulDivUp   = (x: bigint, y: bigint, d: bigint): bigint => (x * y + (d - 1n)) / d;
  • mulDivDown(x, y, d)
    • (x * y) / d (내림)
    • 곱셈 후 나눗셈, 결과 소수점 버림
  • mulDivUp(x, y, d)
    • (x * y + (d - 1)) / d (올림)
    • 분자에 (d - 1)을 더해서 올림 효과

이렇게 정의된 mulDivDown / mulDivUp은 단순히 곱하고 나누면서 올림/내림을 결정하는 유틸 함수다. 여기서 WAD를 기준으로 래핑하여 사용할 수 있다.

mulWadDown, divWadDown, divWadUp

const WAD = 10n ** 18n;
 
export const mulWadDown = (x: bigint, y: bigint): bigint => mulDivDown(x, y, WAD);
export const divWadDown = (x: bigint, y: bigint): bigint => mulDivDown(x, WAD, y);
export const divWadUp   = (x: bigint, y: bigint): bigint => mulDivUp(x, WAD, y);
  • mulWadDown
    • 두 값 x, yWAD 스케일로 표현된 값이라고 할 때, 실제 의미는 (실제 x * 실제 y).
    • 스케일이 두 번 곱해졌으니(WAD * WAD), 한 번 나눠서 정규화
  • divWadDown
    • 두 값 x, y가 WAD 스케일일 때, 실제 의미는 (실제 x / 실제 y)
    • 분자가 더 작은 경우 정수 나눗셈에서 0으로 날아가는 걸 방지하려고, WAD를 곱해 정밀도를 확보한 뒤 나눈다.
  • divWadUp
    • divWadDown과 같지만 소수점 올림 처리

실제론 어떻게 사용할까?

DeFi 프로토콜에서는 사용자의 잔액을 직접 자산 수량으로 관리하지 않고, shares라는 비율 단위로 관리하는 경우가 많다. 예를 들어 공급 풀에 1000개의 shares가 있고, 그에 대응하는 자산이 500 ETH라면, 1 share = 0.5 ETH로 계산되는 식이다. 따라서 프론트에서는 사용자가 가진 shares를 실제 자산 단위로 환산하는 로직이 필요하다.

이를 위해 (shares * totalAssets) / totalShares 같은 수식을 사용하며, 올림/내림 정책을 반영하기 위해 mulDivDown / mulDivUp 같은 유틸 함수를 활용한다. (아래는 설명을 위해 간소화한 함수)

// shares → assets (내림)
// assets = floor(shares * totalAssets / totalShares)
export function toAssetsDown(shares: bigint, totalAssets: bigint, totalShares: bigint): bigint {
  if (totalShares === 0n) return 0n; // 분모 0 가드
  return mulDivDown(shares, totalAssets, totalShares);
}
 
// shares → assets (올림)
// assets = ceil(shares * totalAssets / totalShares)
export function toAssetsUp(shares: bigint, totalAssets: bigint, totalShares: bigint): bigint {
  if (totalShares === 0n) return 0n; // 분모 0 가드
  return mulDivUp(shares, totalAssets, totalShares);
}

쉬운 이해를 위해 예시 상황을 살펴보자.

ETH 대출 마켓에서 shares → assets 변환

대출 마켓에서도 마찬가지로 사용자의 부채는 shares 단위로 관리된다. 예를 들어 대출 풀에서 총 차입 자산이 1,000 ETH이고, 발행된 총 borrow shares가 2,000이라면,

  • 1 borrow share = 0.5 ETH
  • 내가 50 borrow shares를 보유하고 있다면 ≈ 25 ETH를 빌린 상태다.
import { parseUnits, formatUnits } from "viem";
 
// 풀 상태 (온체인 데이터에서 읽어옴)
const totalBorrowAssets = parseUnits("1000", 18); // 1,000 ETH (18 decimals)
const totalBorrowShares = 2000n;
 
// 내 차입 shares
const myBorrowShares = 50n;
 
// shares → assets 변환
const myDebt = toAssetsUp(myBorrowShares, totalBorrowAssets, totalBorrowShares);
 
console.log(formatUnits(myDebt, 18));   // "25" 또는 "25.000000000000000001" 수준

일반적으로 부채 계산은 부채 계산은 보수적으로 올림, 예치 이자 계산은 사용자에게 유리하게 내림 처리를 하기 때문에 반대로 예치 계산은 toAssetsDown 함수를 사용할 수 있을 것이다.

이처럼 바로 온체인에서 읽을 수 없거나 사용자 입력에 따라 바뀌는 값이라면 프론트에서 계산하는게 훨씬 효율적이다. 이외에도 슬리피지 한도, LTV 계산 등 다양한 가정 시나리오를 프론트에서 시뮬레이션할 수도 있다.

마치며

처음 DeFi 프론트엔드를 시작했을 땐 이런 숫자 처리 개념을 전혀 몰랐다. 어떤 데이터는 컨트랙트에서 그대로 가져오면 되고, 어떤 데이터는 프론트에서 직접 계산해야 하는지 구분하는 것도 처음엔 헷갈렸다.

이번 글에서 다룬 내용들은 사실 기본적인 개념이지만, 실무에선 정말 자주 부딪히는 부분이다. 나처럼 처음 접하는 사람이라면 분명 혼란스러울 수 있는데, 누군가에겐 작은 도움이 되었으면 한다.

참고 문서