본문으로 이동
·10분 읽기

프로덕션에서의 온체인 데이터: 아무도 말해주지 않는 것들

블록체인 데이터는 깨끗하지도, 신뢰할 수 있지도, 쉽지도 않습니다. RPC 속도 제한, 체인 재구성, BigInt 버그, 인덱싱 트레이드오프 — 실제 DeFi 제품을 출시하며 얻은 뼈아픈 교훈들.

공유:X / TwitterLinkedIn

온체인 데이터가 본질적으로 신뢰할 수 있다는 환상이 있습니다. 불변의 원장. 투명한 상태. 그냥 읽으면 끝.

저도 그렇게 믿었습니다. 그러다가 DeFi 대시보드를 프로덕션에 출시하고 토큰 잔액이 왜 틀린지, 이벤트 이력에 왜 빈틈이 있는지, 데이터베이스에 더 이상 존재하지 않는 블록의 트랜잭션이 왜 있는지 알아내는 데 3주를 보냈습니다.

온체인 데이터는 날것이고, 적대적이며, 사용자가 버그 리포트를 제출하기 전까지 눈치채지 못할 방식으로 애플리케이션을 망가뜨리는 엣지 케이스로 가득합니다. 이 포스트는 제가 힘들게 배운 모든 것을 다룹니다.

신뢰할 수 있는 데이터라는 환상#

아무도 말해주지 않는 첫 번째 사실: 블록체인은 데이터를 주지 않습니다. 상태 전이를 줍니다. SELECT * FROM transfers WHERE user = '0x...' 같은 건 없습니다. 로그, 영수증, 스토리지 슬롯, 콜 트레이스가 있을 뿐입니다 — 모두 해독하려면 컨텍스트가 필요한 형식으로 인코딩되어 있습니다.

Transfer 이벤트 로그는 from, to, value를 줍니다. 토큰 심볼은 알려주지 않습니다. 소수점 자릿수도 알려주지 않습니다. 이것이 정당한 전송인지 수수료-온-전송 토큰이 상단에서 3%를 빼가는 것인지도 알려주지 않습니다. 이 블록이 30초 후에도 존재할지도 알려주지 않습니다.

"불변"이라는 부분은 사실입니다 — 확정된 후에. 하지만 확정은 즉시 이루어지지 않습니다. 그리고 RPC 노드에서 돌아오는 데이터가 반드시 확정된 블록에서 온 것은 아닙니다. 대부분의 개발자는 latest를 쿼리하고 진실로 취급합니다. 그것은 기능이 아니라 버그입니다.

그리고 인코딩 문제가 있습니다. 모든 것이 hex입니다. 주소는 대소문자 혼합 체크섬이 적용되어 있거나 아닙니다. 토큰 금액은 10^decimals을 곱한 정수입니다. $100의 USDC 전송은 온체인에서 100000000으로 보입니다. USDC는 18이 아닌 6 소수점이기 때문입니다. 모든 ERC-20 토큰에 18 소수점을 가정한 프로덕션 코드를 본 적이 있습니다. 결과 잔액은 10^12 배 차이가 났습니다.

RPC 속도 제한이 주말을 망칠 것입니다#

모든 프로덕션 Web3 앱은 RPC 엔드포인트와 통신합니다. 그리고 모든 RPC 엔드포인트는 예상보다 훨씬 공격적인 속도 제한이 있습니다.

중요한 수치들:

  • Alchemy 무료: 월 ~30M 컴퓨트 유닛, 분당 40 요청. 넉넉하게 들리지만 넓은 블록 범위에 대한 단일 eth_getLogs 호출이 수백 CU를 소모할 수 있습니다. 하루 인덱싱으로 월간 할당량을 소진할 수 있습니다.
  • Infura 무료: 일 100K 요청, 대략 초당 1.15 요청. 그 속도로 500K 블록의 이벤트 로그를 페이지네이션해 보세요.
  • QuickNode 무료: Infura와 비슷합니다 — 일 100K 요청.

유료 티어가 도움이 되지만 문제를 제거하지는 않습니다. Alchemy Growth 플랜에 월 $200을 써도 무거운 인덱싱 작업은 처리량 제한에 걸립니다. 그리고 제한에 걸리면 우아한 성능 저하를 얻는 게 아닙니다. 429 에러를 받습니다. 때로는 도움이 안 되는 메시지와 함께, 때로는 retry-after 헤더 없이.

해결책은 폴백 프로바이더, 재시도 로직, 그리고 어떤 호출을 할지 매우 신중하게 결정하는 것의 조합입니다. viem을 사용한 견고한 RPC 설정은 이렇습니다:

typescript
import { createPublicClient, fallback, http } from "viem";
import { mainnet } from "viem/chains";
 
const client = createPublicClient({
  chain: mainnet,
  transport: fallback(
    [
      http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
        retryCount: 3,
        retryDelay: 1500,
        timeout: 15_000,
      }),
      http("https://mainnet.infura.io/v3/YOUR_KEY", {
        retryCount: 3,
        retryDelay: 1500,
        timeout: 15_000,
      }),
      http("https://rpc.ankr.com/eth", {
        retryCount: 2,
        retryDelay: 2000,
        timeout: 20_000,
      }),
    ],
    { rank: true }
  ),
});

rank: true 옵션이 핵심입니다. viem에게 각 트랜스포트의 레이턴시와 성공률을 측정하고 가장 빠르고 안정적인 것을 자동으로 선호하라고 알려줍니다. Alchemy가 속도 제한을 걸기 시작하면 viem이 트래픽을 Infura로 이동합니다. Infura가 다운되면 Ankr로 폴백합니다.

하지만 미묘한 점이 있습니다: viem의 기본 재시도 로직은 지수 백오프를 사용하는데, 대개 원하는 동작입니다. 그러나 2025년 초 기준으로 배치 모드가 활성화되었을 때 retryCount가 RPC 레벨 에러(429 등)를 제대로 재시도하지 않는 알려진 이슈가 있습니다. 요청을 배치하고 있다면 재시도 동작을 명시적으로 테스트하세요. 작동한다고 믿지 마세요.

리오그: 눈에 보이지 않는 버그#

체인 재구성(reorganization)은 네트워크가 어떤 블록이 정규인지 일시적으로 합의하지 못할 때 발생합니다. 노드 A는 트랜잭션 [A, B, C]를 가진 블록 1000을 봅니다. 노드 B는 트랜잭션 [A, D]를 가진 다른 블록 1000을 봅니다. 결국 네트워크가 수렴하고 한 버전이 승리합니다.

작업증명 체인에서 이것은 흔했습니다 — 1-3 블록 리오그가 하루에 여러 번 발생했습니다. 머지 후 Ethereum은 더 나아졌습니다. 성공적인 리오그 공격은 이제 밸리데이터의 약 50%의 조율이 필요합니다. 하지만 "더 나아짐"은 "불가능"이 아닙니다. 2022년 5월 비콘 체인에서 프로포저 부스트 포크의 불일치한 클라이언트 구현으로 인한 주목할 만한 7블록 리오그가 있었습니다.

그리고 Ethereum 메인넷에서 리오그가 얼마나 드문지는 중요하지 않습니다. L2나 사이드체인 — Polygon, Arbitrum, Optimism — 에서 빌드하고 있다면 리오그가 더 빈번합니다. Polygon은 역사적으로 10+ 블록의 리오그가 있었습니다.

실질적인 문제는 이것입니다: 블록 18,000,000을 인덱싱했습니다. 이벤트를 데이터베이스에 기록했습니다. 그리고 블록 18,000,000이 리오그되었습니다. 이제 데이터베이스에 정규 체인에 존재하지 않는 블록의 이벤트가 있습니다. 그 이벤트는 일어나지 않은 트랜잭션을 참조할 수 있습니다. 사용자는 유령 전송을 봅니다.

수정 방법은 아키텍처에 따라 다릅니다:

옵션 1: 컨펌 지연. N 블록의 컨펌이 지날 때까지 데이터를 인덱싱하지 마세요. Ethereum 메인넷의 경우 64 블록(2 에포크)이 최종성 보장을 제공합니다. L2의 경우 특정 체인의 최종성 모델을 확인하세요. 단순하지만 지연이 추가됩니다 — Ethereum에서 약 13분.

옵션 2: 리오그 감지 및 롤백. 적극적으로 인덱싱하되 블록 해시를 추적합니다. 새 블록마다 부모 해시가 이전에 인덱싱한 블록과 일치하는지 확인합니다. 일치하지 않으면 리오그를 감지한 것입니다: 고아 블록의 모든 것을 삭제하고 정규 체인을 다시 인덱싱합니다.

typescript
interface IndexedBlock {
  number: bigint;
  hash: `0x${string}`;
  parentHash: `0x${string}`;
}
 
async function detectReorg(
  client: PublicClient,
  lastIndexed: IndexedBlock
): Promise<{ reorged: boolean; depth: number }> {
  const currentBlock = await client.getBlock({
    blockNumber: lastIndexed.number,
  });
 
  if (currentBlock.hash === lastIndexed.hash) {
    return { reorged: false, depth: 0 };
  }
 
  // 체인이 분기된 지점을 찾기 위해 뒤로 탐색
  let depth = 1;
  let checkNumber = lastIndexed.number - 1n;
 
  while (checkNumber > 0n && depth < 128) {
    const onChain = await client.getBlock({ blockNumber: checkNumber });
    const inDb = await getIndexedBlock(checkNumber); // DB 조회
 
    if (onChain.hash === inDb?.hash) {
      return { reorged: true, depth };
    }
 
    depth++;
    checkNumber--;
  }
 
  return { reorged: true, depth };
}

이건 가상의 이야기가 아닙니다. 리오그 감지 없이 체인 팁에서 이벤트를 인덱싱한 프로덕션 시스템이 있었습니다. 3주간 잘 작동했습니다. 그러다가 Polygon에서 2블록 리오그가 데이터베이스에 중복 NFT 민트 이벤트를 발생시켰습니다. 프론트엔드에서 사용자가 소유하지 않은 토큰을 소유하고 있는 것으로 표시되었습니다. 아무도 리오그를 근본 원인으로 보지 않았기 때문에 디버깅하는 데 이틀이 걸렸습니다.

인덱싱 문제: 고통을 선택하세요#

구조화된 온체인 데이터를 애플리케이션에 가져오는 데 실질적인 옵션이 세 가지 있습니다.

직접 RPC 호출#

getLogs, getBlock, getTransaction을 직접 호출하면 됩니다. 이것은 소규모 읽기에 작동합니다 — 사용자 잔액 확인, 단일 컨트랙트의 최근 이벤트 가져오기. 과거 인덱싱이나 여러 컨트랙트에 걸친 복잡한 쿼리에는 작동하지 않습니다.

문제는 조합적입니다. 지난 30일간의 모든 Uniswap V3 스왑을 원하시나요? 약 200K 블록입니다. Alchemy의 getLogs 호출당 2K 블록 범위 제한에서 최소 100개의 페이지네이션 요청입니다. 각각이 속도 제한에 카운트됩니다. 그리고 어떤 호출이든 실패하면 재시도 로직, 커서 추적, 중단된 지점에서 재개하는 방법이 필요합니다.

The Graph (서브그래프)#

The Graph가 원조 솔루션이었습니다. 스키마를 정의하고, AssemblyScript로 매핑을 작성하고, 배포하고, GraphQL로 쿼리합니다. 호스팅 서비스는 폐기되었습니다 — 모든 것이 이제 탈중앙 Graph Network에 있으며, 쿼리에 GRT 토큰으로 비용을 지불합니다.

좋은 점: 표준화되어 있고, 문서가 잘 되어 있으며, 포크할 수 있는 기존 서브그래프의 대규모 생태계가 있습니다.

나쁜 점: AssemblyScript는 고통스럽습니다. 디버깅이 제한적입니다. 배포에 수분에서 수시간이 걸립니다. 서브그래프에 버그가 있으면 재배포하고 처음부터 다시 동기화될 때까지 기다려야 합니다. 탈중앙 네트워크는 레이턴시를 추가하고 때때로 인덱서가 체인 팁보다 뒤처집니다.

저는 30-60초의 데이터 신선도가 허용되는 읽기 중심 대시보드에 The Graph를 사용했습니다. 거기서 잘 작동합니다. 실시간 데이터나 매핑 내 복잡한 비즈니스 로직이 필요한 것에는 사용하지 않을 것입니다.

커스텀 인덱서 (Ponder, Envio)#

여기가 생태계가 크게 성숙한 부분입니다. Ponder와 Envio는 AssemblyScript가 아닌 TypeScript로 인덱싱 로직을 작성할 수 있게 해주고, 개발 중에 로컬로 실행하며, 독립 서비스로 배포합니다.

Ponder는 최대의 제어권을 제공합니다. TypeScript로 이벤트 핸들러를 정의하고, 인덱싱 파이프라인을 관리하며, SQL 데이터베이스를 출력으로 얻습니다. 트레이드오프: 인프라를 직접 소유합니다. 스케일링, 모니터링, 리오그 처리 — 모두 여러분의 몫입니다.

Envio는 동기화 속도에 최적화합니다. 벤치마크에서 The Graph 대비 상당히 빠른 초기 동기화 시간을 보여줍니다. 리오그를 네이티브로 처리하고 더 빠른 데이터 페칭을 위한 전문 프로토콜인 HyperSync를 지원합니다. 트레이드오프: 그들의 인프라와 API에 종속됩니다.

제 추천: 프로덕션 DeFi 앱을 구축하고 있고 엔지니어링 역량이 있다면 Ponder를 사용하세요. 가능한 가장 빠른 동기화가 필요하고 인프라를 관리하고 싶지 않다면 Envio를 평가하세요. 빠른 프로토타입이 필요하거나 커뮤니티가 유지관리하는 서브그래프를 원한다면 The Graph도 여전히 괜찮습니다.

getLogs는 보이는 것보다 더 위험하다#

eth_getLogs RPC 메서드는 기만적으로 단순합니다. 블록 범위와 몇 가지 필터를 주면 매칭하는 이벤트 로그를 돌려줍니다. 프로덕션에서 실제로 일어나는 일은 이렇습니다:

블록 범위 제한은 프로바이더마다 다릅니다. Alchemy는 2K 블록(무제한 로그) 또는 무제한 블록(최대 10K 로그)으로 제한합니다. Infura는 다른 제한이 있습니다. QuickNode은 다른 제한이 있습니다. 퍼블릭 RPC는 1K 블록으로 제한될 수 있습니다. 여러분의 코드는 이 모든 것을 처리해야 합니다.

응답 크기 제한이 존재합니다. 블록 범위 내에서도 인기 있는 컨트랙트가 블록당 수천 개의 이벤트를 발행하면 응답이 프로바이더의 페이로드 제한(Alchemy의 경우 150MB)을 초과할 수 있습니다. 호출은 부분 결과를 반환하지 않습니다. 실패합니다.

빈 범위도 무료가 아닙니다. 매칭하는 로그가 없어도 프로바이더는 여전히 블록 범위를 스캔합니다. 이것은 컴퓨트 유닛에 카운트됩니다.

이 제약 사항들을 처리하는 페이지네이션 유틸리티입니다:

typescript
import type { PublicClient, Log, AbiEvent } from "viem";
 
async function fetchLogsInChunks<T extends AbiEvent>(
  client: PublicClient,
  params: {
    address: `0x${string}`;
    event: T;
    fromBlock: bigint;
    toBlock: bigint;
    maxBlockRange?: bigint;
  }
): Promise<Log<bigint, number, false, T, true>[]> {
  const { address, event, fromBlock, toBlock, maxBlockRange = 2000n } = params;
  const allLogs: Log<bigint, number, false, T, true>[] = [];
 
  let currentFrom = fromBlock;
 
  while (currentFrom <= toBlock) {
    const currentTo =
      currentFrom + maxBlockRange - 1n > toBlock
        ? toBlock
        : currentFrom + maxBlockRange - 1n;
 
    try {
      const logs = await client.getLogs({
        address,
        event,
        fromBlock: currentFrom,
        toBlock: currentTo,
      });
 
      allLogs.push(...logs);
      currentFrom = currentTo + 1n;
    } catch (error) {
      // 범위가 너무 크면 (너무 많은 결과) 반으로 나누기
      if (isRangeTooLargeError(error) && currentTo > currentFrom) {
        const mid = currentFrom + (currentTo - currentFrom) / 2n;
        const firstHalf = await fetchLogsInChunks(client, {
          address,
          event,
          fromBlock: currentFrom,
          toBlock: mid,
          maxBlockRange,
        });
        const secondHalf = await fetchLogsInChunks(client, {
          address,
          event,
          fromBlock: mid + 1n,
          toBlock: currentTo,
          maxBlockRange,
        });
        allLogs.push(...firstHalf, ...secondHalf);
        currentFrom = currentTo + 1n;
      } else {
        throw error;
      }
    }
  }
 
  return allLogs;
}
 
function isRangeTooLargeError(error: unknown): boolean {
  const message = error instanceof Error ? error.message : String(error);
  return (
    message.includes("Log response size exceeded") ||
    message.includes("query returned more than") ||
    message.includes("exceed maximum block range")
  );
}

핵심 인사이트는 실패 시 이진 분할입니다. 2K 블록 범위가 너무 많은 로그를 반환하면 두 개의 1K 범위로 나눕니다. 1K가 여전히 너무 많으면 다시 나눕니다. 이벤트 밀도를 미리 알 필요 없이 활동이 많은 컨트랙트에 자동으로 적응합니다.

BigInt가 여러분을 겸손하게 만들 것입니다#

JavaScript의 Number 타입은 64비트 부동소수점입니다. 2^53 - 1까지의 정수를 표현할 수 있습니다 — 약 9경. 많아 보이지만 wei 단위의 1 ETH 토큰 금액이 1000000000000000000 — 0이 18개인 숫자라는 것을 깨닫게 됩니다. 이것은 10^18으로, Number.MAX_SAFE_INTEGER를 훨씬 넘습니다.

파이프라인 어디에서든 BigInt를 Number로 강제 변환하면 — JSON.parse, 데이터베이스 드라이버, 로깅 라이브러리 — 조용한 정밀도 손실이 발생합니다. 숫자는 대략 맞아 보이지만 마지막 몇 자리가 틀립니다. 테스트 금액이 작기 때문에 테스트에서는 잡히지 않습니다.

제가 프로덕션에 출시한 버그입니다:

typescript
// 버그: 무해해 보이지만 아닙니다
function formatTokenAmount(amount: bigint, decimals: number): string {
  return (Number(amount) / Math.pow(10, decimals)).toFixed(4);
}
 
// 작은 금액에서는 잘 작동합니다:
formatTokenAmount(1000000n, 6); // "1.0000" -- 정확
 
 
// 큰 금액에서는 조용히 깨집니다:
formatTokenAmount(123456789012345678n, 18);
// "0.1235"를 반환 -- 오류, 실제 정밀도가 손실됨
// Number(123456789012345678n) === 123456789012345680
// 마지막 두 자리가 IEEE 754에 의해 반올림됨

수정: 나누기 전에 절대로 Number로 변환하지 마세요. BigInt와 문자열로 작동하는 viem의 내장 유틸리티를 사용하세요:

typescript
import { formatUnits, parseUnits } from "viem";
 
// 올바른 방법: BigInt로 작동하고 문자열을 반환
function formatTokenAmount(
  amount: bigint,
  decimals: number,
  displayDecimals: number = 4
): string {
  const formatted = formatUnits(amount, decimals);
 
  // formatUnits는 "0.123456789012345678" 같은 전체 정밀도 문자열을 반환
  // 원하는 표시 정밀도로 절삭 (반올림하지 않음)
  const [whole, fraction = ""] = formatted.split(".");
  const truncated = fraction.slice(0, displayDecimals).padEnd(displayDecimals, "0");
 
  return `${whole}.${truncated}`;
}
 
// 또한 중요: 사용자 입력에는 parseFloat 대신 parseUnits를 사용
function parseTokenInput(input: string, decimals: number): bigint {
  // parseUnits가 문자열-BigInt 변환을 올바르게 처리
  return parseUnits(input, decimals);
}

반올림 대신 절삭하는 것에 주목하세요. 이것은 의도적입니다. 금융 컨텍스트에서 실제 값이 "1.00009999..."인데 "1.0001 ETH"를 보여주는 것이 실제 값이 "1.00005001..."인데 반올림으로 "1.0001"을 보여주는 것보다 낫습니다. 사용자는 표시된 금액에 기반해 결정을 내립니다. 절삭이 보수적인 선택입니다.

또 다른 함정: JSON.stringify는 BigInt를 직렬화하는 방법을 모릅니다. 에러를 던집니다. 토큰 금액을 포함하는 API의 모든 단일 응답에는 직렬화 전략이 필요합니다. 저는 API 경계에서 문자열 변환을 사용합니다:

typescript
// API 응답 직렬화기
function serializeForApi(data: Record<string, unknown>): string {
  return JSON.stringify(data, (_, value) =>
    typeof value === "bigint" ? value.toString() : value
  );
}

캐싱 전략: 무엇을, 얼마나, 언제 무효화할 것인가#

모든 온체인 데이터가 동일한 신선도 요구사항을 가지는 것은 아닙니다. 제가 사용하는 계층 구조입니다:

영원히 캐시 (불변):

  • 트랜잭션 영수증 (채굴되면 변경되지 않음)
  • 확정된 블록 데이터 (블록 해시, 타임스탬프, 트랜잭션 목록)
  • 컨트랙트 바이트코드
  • 확정된 블록의 과거 이벤트 로그

수분에서 수시간 캐시:

  • 토큰 메타데이터 (이름, 심볼, 소수점) — 기술적으로 대부분의 토큰에서 불변이지만 프록시 업그레이드가 구현을 변경할 수 있음
  • ENS 해석 — 5분 TTL이 잘 작동
  • 토큰 가격 — 정확도 요구사항에 따라 30초에서 5분

수초 캐시 또는 캐시하지 않음:

  • 현재 블록 넘버
  • 계정 잔액과 논스
  • 보류 중인 트랜잭션 상태
  • 확정되지 않은 이벤트 로그 (다시 리오그 문제)

구현이 복잡할 필요는 없습니다. 인메모리 LRU와 Redis를 결합한 이중 티어 캐시가 대부분의 경우를 커버합니다:

typescript
import { LRUCache } from "lru-cache";
 
const memoryCache = new LRUCache<string, unknown>({
  max: 10_000,
  ttl: 1000 * 60, // 기본 1분
});
 
type CacheTier = "immutable" | "short" | "volatile";
 
const TTL_MAP: Record<CacheTier, number> = {
  immutable: 1000 * 60 * 60 * 24, // 메모리에서 24시간, Redis에서 영구
  short: 1000 * 60 * 5,            // 5분
  volatile: 1000 * 15,             // 15초
};
 
async function cachedRpcCall<T>(
  key: string,
  tier: CacheTier,
  fetcher: () => Promise<T>
): Promise<T> {
  // 메모리 먼저 확인
  const cached = memoryCache.get(key) as T | undefined;
  if (cached !== undefined) return cached;
 
  // 그다음 Redis (있는 경우)
  // const redisCached = await redis.get(key);
  // if (redisCached) { ... }
 
  const result = await fetcher();
  memoryCache.set(key, result, { ttl: TTL_MAP[tier] });
 
  return result;
}
 
// 사용 예:
const receipt = await cachedRpcCall(
  `receipt:${txHash}`,
  "immutable",
  () => client.getTransactionReceipt({ hash: txHash })
);

직관에 반하는 교훈: 가장 큰 성능 향상은 RPC 응답을 캐싱하는 것이 아닙니다. RPC 호출 자체를 피하는 것입니다. getBlock을 호출하려 할 때마다 자문하세요: 지금 정말 체인에서 데이터가 필요한가, 아니면 이미 가지고 있는 데이터에서 도출할 수 있는가? 폴링 대신 WebSocket으로 이벤트를 리슨할 수 있는가? 여러 읽기를 하나의 multicall로 배치할 수 있는가?

TypeScript와 컨트랙트 ABI: 올바른 방법#

ABIType으로 구동되는 viem의 타입 시스템은 컨트랙트 ABI에서 TypeScript 코드까지 완전한 엔드투엔드 타입 추론을 제공합니다. 하지만 올바르게 설정한 경우에만.

잘못된 방법:

typescript
// 타입 추론 없음 — args는 unknown[], 반환값은 unknown
const result = await client.readContract({
  address: "0x...",
  abi: JSON.parse(abiString), // 런타임에 파싱 = 타입 정보 없음
  functionName: "balanceOf",
  args: ["0x..."],
});

올바른 방법:

typescript
// 완전한 타입 추론을 위해 ABI를 const로 정의
const erc20Abi = [
  {
    name: "balanceOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "balance", type: "uint256" }],
  },
  {
    name: "transfer",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      { name: "to", type: "address" },
      { name: "amount", type: "uint256" },
    ],
    outputs: [{ name: "success", type: "bool" }],
  },
] as const;
 
// 이제 TypeScript가 알고 있습니다:
// - functionName이 "balanceOf" | "transfer"로 자동완성됨
// - balanceOf의 args는 [address: `0x${string}`]
// - balanceOf의 반환 타입은 bigint
const balance = await client.readContract({
  address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
  abi: erc20Abi,
  functionName: "balanceOf",
  args: ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
});
// typeof balance = bigint -- 완전히 타입 지정됨

as const 단언이 핵심입니다. 이것 없이는 TypeScript가 ABI 타입을 { name: string, type: string, ... }[]로 확장하고 모든 추론 기계가 무너집니다. 이것이 Web3 TypeScript 코드베이스에서 제가 보는 가장 흔한 실수입니다.

더 큰 프로젝트에서는 @wagmi/cli를 사용해 Foundry 또는 Hardhat 프로젝트에서 직접 타입이 지정된 컨트랙트 바인딩을 생성하세요. 컴파일된 ABI를 읽고 as const 단언이 이미 적용된 TypeScript 파일을 생성합니다. 수동 ABI 복사도 없고 타입 드리프트도 없습니다.

불편한 진실#

블록체인 데이터는 데이터베이스 문제로 위장한 분산 시스템 문제입니다. "그냥 또 다른 API"로 취급하는 순간 개발에서는 보이지 않고 프로덕션에서는 간헐적인 버그를 축적하기 시작합니다.

도구는 극적으로 좋아졌습니다. viem은 타입 안전성과 개발자 경험에서 ethers.js에 비해 엄청난 개선입니다. Ponder와 Envio는 커스텀 인덱싱을 접근 가능하게 만들었습니다. 하지만 근본적인 도전 — 리오그, 속도 제한, 인코딩, 최종성 — 은 프로토콜 레벨입니다. 어떤 라이브러리도 이것들을 추상화하지 못합니다.

RPC가 거짓말할 것이고, 블록이 재구성될 것이고, 숫자가 오버플로할 것이고, 캐시가 오래된 데이터를 제공할 것이라는 가정 위에서 구축하세요. 그리고 각 경우를 명시적으로 처리하세요.

이것이 프로덕션 수준의 온체인 데이터가 실제로 어떤 모습인지입니다.

관련 게시물