웹 개발자를 위한 Ethereum: 과대광고 없는 스마트 컨트랙트
모든 웹 개발자가 알아야 할 Ethereum 개념: 계정, 트랜잭션, 스마트 컨트랙트, ABI 인코딩, ethers.js, WAGMI, 자체 노드 없이 온체인 데이터 읽기.
대부분의 "개발자를 위한 Ethereum" 콘텐츠는 두 가지 범주로 나뉩니다: 무언가를 만드는 데 도움이 되지 않는 지나치게 단순화된 비유, 또는 Merkle Patricia Trie가 무엇인지 이미 알고 있다고 가정하는 깊은 프로토콜 명세. 토큰 잔액을 읽거나, 사용자가 트랜잭션에 서명하게 하거나, React 앱에서 NFT 메타데이터를 표시하려는 웹 개발자에게는 둘 다 유용하지 않습니다.
이 글은 그 실용적인 중간 지점입니다. 프론트엔드가 Ethereum과 통신할 때 정확히 어떤 일이 일어나는지, 움직이는 부품이 무엇인지, 그리고 현대 도구(ethers.js, viem, WAGMI)가 웹 애플리케이션 구축에서 이미 이해하고 있는 개념과 어떻게 대응하는지 설명하겠습니다.
자판기에 대한 비유는 없습니다. "이런 세상을 상상해 보세요..."도 없습니다. 기술적 모델과 코드만 있습니다.
멘탈 모델#
Ethereum은 복제된 상태 머신입니다. 네트워크의 모든 노드는 상태의 동일한 사본을 유지합니다 — 주소를 계정 데이터에 매핑하는 거대한 키-값 저장소입니다. "트랜잭션을 보낸다"는 것은 상태 전환을 제안하는 것입니다. 충분한 검증자가 유효하다고 동의하면 상태가 업데이트됩니다. 그게 전부입니다.
상태 자체는 간단합니다. 20바이트 주소에서 계정 객체로의 매핑입니다. 모든 계정에는 네 개의 필드가 있습니다:
- nonce: 이 계정이 보낸 트랜잭션 수(EOA의 경우) 또는 생성한 컨트랙트 수(컨트랙트 계정의 경우). 리플레이 공격을 방지합니다.
- balance: wei로 표시된 ETH 양 (1 ETH = 10^18 wei). 항상 큰 정수입니다.
- codeHash: EVM 바이트코드의 해시. 일반 지갑(EOA)의 경우 빈 바이트의 해시입니다. 컨트랙트의 경우 배포된 코드의 해시입니다.
- storageRoot: 계정 스토리지 트라이의 루트 해시. 컨트랙트만 의미 있는 스토리지를 가집니다.
두 종류의 계정이 있으며, 이 구분은 이후 모든 것에 중요합니다:
**외부 소유 계정(EOA)**은 개인 키로 제어됩니다. MetaMask가 관리하는 것입니다. 트랜잭션을 시작할 수 있습니다. 코드가 없습니다. 누군가 "지갑"이라고 말하면 EOA를 의미합니다.
컨트랙트 계정은 코드로 제어됩니다. 트랜잭션을 시작할 수 없습니다 — 호출에 대한 응답으로만 실행할 수 있습니다. 코드와 스토리지를 가집니다. 누군가 "스마트 컨트랙트"라고 말하면 이것을 의미합니다. 코드는 배포 후 변경 불가능합니다(프록시 패턴을 통한 일부 예외가 있지만, 그것은 완전히 다른 논의입니다).
핵심 인사이트: Ethereum의 모든 상태 변경은 EOA가 트랜잭션에 서명하는 것으로 시작됩니다. 컨트랙트는 다른 컨트랙트를 호출할 수 있지만, 실행 체인은 항상 개인 키를 가진 사람(또는 봇)으로부터 시작됩니다.
가스: 연산에는 가격이 있다#
EVM의 모든 연산에는 가스 비용이 듭니다. 두 수를 더하는 데 3가스가 듭니다. 32바이트 워드를 저장하는 데 20,000가스(최초) 또는 5,000가스(업데이트)가 듭니다. 스토리지를 읽는 데 2,100가스(콜드) 또는 100가스(웜, 이미 이 트랜잭션에서 접근한 경우)가 듭니다.
"가스 단위"로 가스를 지불하지 않습니다. ETH로 지불합니다. 총 비용은:
totalCost = gasUsed * gasPrice
EIP-1559(런던 업그레이드) 이후, 가스 가격은 두 부분 시스템이 되었습니다:
totalCost = gasUsed * (baseFee + priorityFee)
- baseFee: 네트워크 혼잡도에 따라 프로토콜이 설정합니다. 소각(파괴)됩니다.
- priorityFee (팁): 검증자에게 갑니다. 더 높은 팁 = 더 빠른 포함.
- maxFeePerGas: 가스 단위당 지불할 의향이 있는 최대 금액.
- maxPriorityFeePerGas: 가스 단위당 최대 팁.
baseFee + priorityFee > maxFeePerGas이면, 트랜잭션은 baseFee가 떨어질 때까지 대기합니다. 이것이 높은 혼잡도 시에 트랜잭션이 "멈추는" 이유입니다.
웹 개발자에게 실질적인 의미: 데이터 읽기는 무료입니다. 데이터 쓰기는 비용이 듭니다. 이것이 Web2와 Web3 사이의 가장 중요한 아키텍처 차이입니다. 모든 SELECT는 무료입니다. 모든 INSERT, UPDATE, DELETE는 실제 돈이 듭니다. 이에 맞게 dApp을 설계하세요.
트랜잭션#
트랜잭션은 서명된 데이터 구조입니다. 중요한 필드는 다음과 같습니다:
interface Transaction {
// 이 트랜잭션을 받는 사람 — EOA 주소 또는 컨트랙트 주소
to: string; // 20바이트 16진수 주소, 또는 컨트랙트 배포 시 null
// 보낼 ETH 양 (wei 단위)
value: bigint; // 순수 컨트랙트 호출의 경우 0n이 될 수 있음
// 인코딩된 함수 호출 데이터, 또는 단순 ETH 전송의 경우 비어 있음
data: string; // 16진수 인코딩된 바이트, 단순 전송의 경우 "0x"
// 순차적 카운터, 리플레이 공격 방지
nonce: number; // 발신자의 현재 nonce와 정확히 일치해야 함
// 가스 한도 — 이 트랜잭션이 소비할 수 있는 최대 가스
gasLimit: bigint;
// EIP-1559 수수료 매개변수
maxFeePerGas: bigint;
maxPriorityFeePerGas: bigint;
// 체인 식별자 (1 = 메인넷, 11155111 = Sepolia, 137 = Polygon)
chainId: number;
}트랜잭션의 생명주기#
-
구성: 앱이 트랜잭션 객체를 만듭니다. 컨트랙트 함수를 호출하는 경우,
data필드에 ABI 인코딩된 함수 호출이 포함됩니다(아래에서 자세히 설명). -
서명: 개인 키가 RLP 인코딩된 트랜잭션에 서명하여
v,r,s서명 구성 요소를 생성합니다. 이것은 발신자가 이 특정 트랜잭션을 승인했음을 증명합니다. 발신자 주소는 서명에서 파생됩니다 — 트랜잭션에 명시적으로 포함되지 않습니다. -
브로드캐스팅: 서명된 트랜잭션이
eth_sendRawTransaction을 통해 RPC 노드로 전송됩니다. 노드는 유효성을 검사하고(올바른 nonce, 충분한 잔액, 유효한 서명) 멤풀에 추가합니다. -
멤풀: 트랜잭션이 대기 중인 트랜잭션 풀에 있습니다. 검증자는 다음 블록에 포함할 트랜잭션을 선택하며, 일반적으로 더 높은 팁을 선호합니다. 여기서 프론트러닝이 발생합니다 — 다른 행위자가 대기 중인 트랜잭션을 보고 더 높은 팁으로 자신의 트랜잭션을 제출하여 먼저 실행할 수 있습니다.
-
포함: 검증자가 트랜잭션을 블록에 포함합니다. EVM이 실행합니다. 성공하면 상태 변경이 적용됩니다. 되돌리면(revert) 상태 변경이 롤백됩니다 — 하지만 되돌림 지점까지 소비된 가스는 여전히 지불합니다.
-
최종성: 지분 증명 Ethereum에서 블록은 두 에포크(~12.8분) 후에 "확정"됩니다. 최종성 이전에는 체인 재구성이 이론적으로 가능합니다(드물지만). 대부분의 앱은 중요하지 않은 작업에 대해 1-2 블록 확인을 "충분히 좋다"고 취급합니다.
ethers.js v6으로 간단한 ETH 전송을 보내는 방법은 다음과 같습니다:
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const wallet = new ethers.Wallet("0xYOUR_PRIVATE_KEY", provider);
const tx = await wallet.sendTransaction({
to: "0xRecipientAddress...",
value: ethers.parseEther("0.1"), // "0.1"을 wei로 변환 (100000000000000000n)
});
console.log("Tx hash:", tx.hash);
// 블록에 포함될 때까지 대기
const receipt = await tx.wait();
console.log("Block number:", receipt.blockNumber);
console.log("Gas used:", receipt.gasUsed.toString());
console.log("Status:", receipt.status); // 1 = 성공, 0 = 되돌림viem으로 동일한 작업:
import { createWalletClient, http, parseEther } from "viem";
import { mainnet } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY");
const client = createWalletClient({
account,
chain: mainnet,
transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
const hash = await client.sendTransaction({
to: "0xRecipientAddress...",
value: parseEther("0.1"),
});
console.log("Tx hash:", hash);차이점에 주목하세요: ethers는 .wait() 메서드가 있는 TransactionResponse 객체를 반환합니다. viem은 해시만 반환합니다 — 확인을 기다리려면 별도의 publicClient.waitForTransactionReceipt({ hash }) 호출을 사용합니다. 이 관심사의 분리는 viem 설계에서 의도적입니다.
스마트 컨트랙트#
스마트 컨트랙트는 특정 주소에 배포된 바이트코드와 영구 스토리지입니다. 컨트랙트를 "호출"할 때, data 필드가 인코딩된 함수 호출로 설정된 트랜잭션을 보내거나 읽기 전용 호출을 수행합니다.
바이트코드와 ABI#
바이트코드는 컴파일된 EVM 코드입니다. 직접 상호작용하지 않습니다. EVM이 실행하는 것입니다.
ABI(Application Binary Interface)는 컨트랙트 인터페이스에 대한 JSON 설명입니다. 클라이언트 라이브러리에 함수 호출을 인코딩하고 반환 값을 디코딩하는 방법을 알려줍니다. 컨트랙트의 OpenAPI 스펙이라고 생각하세요.
다음은 ERC-20 토큰 ABI의 일부입니다:
const ERC20_ABI = [
// 읽기 전용 함수 (view/pure — 외부에서 호출 시 가스 비용 없음)
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address owner) view returns (uint256)",
"function allowance(address owner, address spender) view returns (uint256)",
// 상태 변경 함수 (트랜잭션 필요, 가스 비용 발생)
"function transfer(address to, uint256 amount) returns (bool)",
"function approve(address spender, uint256 amount) returns (bool)",
"function transferFrom(address from, address to, uint256 amount) returns (bool)",
// 이벤트 (실행 중 발생, 트랜잭션 로그에 저장)
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Approval(address indexed owner, address indexed spender, uint256 value)",
] as const;ethers.js는 이 "사람이 읽을 수 있는 ABI" 형식을 허용합니다. viem도 사용할 수 있지만, 종종 Solidity 컴파일러가 생성한 전체 JSON ABI로 작업하게 됩니다. 둘 다 동등합니다 — 사람이 읽을 수 있는 형식이 일반적인 인터페이스에 더 편리할 뿐입니다.
함수 호출이 인코딩되는 방법#
대부분의 튜토리얼이 건너뛰는 부분이며, 디버깅 시간을 절약해 줄 부분입니다.
transfer("0xBob...", 1000000)을 호출하면, 트랜잭션의 data 필드는 다음과 같이 설정됩니다:
0xa9059cbb // 함수 선택자
0000000000000000000000000xBob...000000000000000000000000 // 주소, 32바이트로 패딩
00000000000000000000000000000000000000000000000000000000000f4240 // uint256 금액 (16진수로 1000000)
함수 선택자는 함수 시그니처의 Keccak-256 해시의 처음 4바이트입니다:
keccak256("transfer(address,uint256)") = 0xa9059cbb...
선택자 = 처음 4바이트 = 0xa9059cbb
나머지 바이트는 ABI 인코딩된 인수이며, 각각 32바이트로 패딩됩니다. 이 인코딩 방식은 결정론적입니다 — 같은 함수 호출은 항상 같은 calldata를 생성합니다.
이것이 왜 중요할까요? Etherscan에서 원시 트랜잭션 데이터가 0xa9059cbb로 시작하면, transfer 호출이라는 것을 알 수 있기 때문입니다. 트랜잭션이 되돌리고 오류 메시지가 16진수 블롭일 때, ABI를 사용하여 디코딩할 수 있습니다. 그리고 트랜잭션 배치를 구성하거나 multicall 컨트랙트와 상호작용할 때, calldata를 수동으로 인코딩하게 됩니다.
ethers.js로 수동 인코딩/디코딩하는 방법:
import { ethers } from "ethers";
const iface = new ethers.Interface(ERC20_ABI);
// 함수 호출 인코딩
const calldata = iface.encodeFunctionData("transfer", [
"0xBobAddress...",
1000000n,
]);
console.log(calldata);
// 0xa9059cbb000000000000000000000000bob...000000000000000000000000000f4240
// calldata를 함수 이름과 인수로 디코딩
const decoded = iface.parseTransaction({ data: calldata });
console.log(decoded.name); // "transfer"
console.log(decoded.args[0]); // "0xBobAddress..."
console.log(decoded.args[1]); // 1000000n (BigInt)
// 함수의 반환 데이터 디코딩
const returnData = "0x0000000000000000000000000000000000000000000000000000000000000001";
const result = iface.decodeFunctionResult("transfer", returnData);
console.log(result[0]); // true스토리지 슬롯#
컨트랙트 스토리지는 키와 값이 모두 32바이트인 키-값 저장소입니다. Solidity는 0부터 순차적으로 스토리지 슬롯을 할당합니다. 첫 번째 선언된 상태 변수는 슬롯 0, 다음은 슬롯 1, 이런 식으로. 매핑과 동적 배열은 해시 기반 체계를 사용합니다.
모든 컨트랙트의 스토리지를 직접 읽을 수 있습니다. Solidity에서 변수가 private로 표시되어 있어도 말입니다. "Private"은 다른 컨트랙트가 읽을 수 없다는 의미일 뿐 — 누구나 eth_getStorageAt를 통해 읽을 수 있습니다:
// 컨트랙트의 스토리지 슬롯 0 읽기
const slot0 = await provider.getStorage(
"0xContractAddress...",
0
);
console.log(slot0); // 원시 32바이트 16진수 값블록 탐색기가 "내부" 컨트랙트 상태를 보여주는 방법입니다. 스토리지 읽기에 대한 접근 제어가 없습니다. 퍼블릭 블록체인에서의 프라이버시는 근본적으로 제한적입니다.
이벤트와 로그#
이벤트는 컨트랙트가 트랜잭션 로그에 저장되지만 컨트랙트 스토리지에는 저장되지 않는 구조화된 데이터를 발생시키는 방법입니다. 스토리지 쓰기보다 저렴하며(첫 번째 토픽에 375가스 + 데이터 바이트당 8가스, vs 스토리지 쓰기 20,000가스) 효율적으로 쿼리할 수 있도록 설계되었습니다.
이벤트는 최대 3개의 indexed 매개변수("토픽"으로 저장)와 임의 수의 인덱스되지 않은 매개변수("데이터"로 저장)를 가질 수 있습니다. 인덱스된 매개변수는 필터링할 수 있습니다 — "to가 이 주소인 모든 Transfer 이벤트를 주세요"라고 요청할 수 있습니다. 인덱스되지 않은 매개변수는 필터링할 수 없습니다; 모든 매칭 이벤트를 가져와서 클라이언트 측에서 필터링해야 합니다.
// ethers.js로 실시간 Transfer 이벤트 리스닝
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
contract.on("Transfer", (from, to, value, event) => {
console.log(`${from} -> ${to}: ${ethers.formatUnits(value, 18)} 토큰`);
console.log("Block:", event.log.blockNumber);
console.log("Tx hash:", event.log.transactionHash);
});
// 과거 이벤트 쿼리
const filter = contract.filters.Transfer(null, "0xMyAddress..."); // from=아무나, to=특정 주소
const events = await contract.queryFilter(filter, 19000000, 19100000); // 블록 범위
for (const event of events) {
console.log("From:", event.args.from);
console.log("Value:", event.args.value.toString());
}viem으로 동일한 작업:
import { createPublicClient, http, parseAbiItem } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
// 과거 로그
const logs = await client.getLogs({
address: "0xTokenAddress...",
event: parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
),
args: {
to: "0xMyAddress...",
},
fromBlock: 19000000n,
toBlock: 19100000n,
});
for (const log of logs) {
console.log("From:", log.args.from);
console.log("To:", log.args.to);
console.log("Value:", log.args.value);
}
// 실시간 감시
const unwatch = client.watchEvent({
address: "0xTokenAddress...",
event: parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
),
onLogs: (logs) => {
for (const log of logs) {
console.log(`Transfer: ${log.args.from} -> ${log.args.to}`);
}
},
});
// 리스닝을 중지하려면 unwatch()를 호출온체인 데이터 읽기#
이것이 Ethereum이 웹 개발자에게 실용적이 되는 부분입니다. 노드를 실행할 필요가 없습니다. 채굴할 필요도 없습니다. 지갑조차 필요 없습니다. Ethereum에서 데이터를 읽는 것은 무료이며, 허가가 필요 없으며, 간단한 JSON-RPC API를 통해 작동합니다.
JSON-RPC: Ethereum의 HTTP API#
모든 Ethereum 노드는 JSON-RPC API를 노출합니다. 말 그대로 JSON 본문을 사용한 HTTP POST입니다. 전송 계층에 블록체인 특유의 것은 없습니다.
// 라이브러리가 내부적으로 하는 것
const response = await fetch("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_call",
params: [
{
to: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
data: "0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
// balanceOf(vitalik.eth)
},
"latest",
],
}),
});
const result = await response.json();
console.log(result);
// { jsonrpc: "2.0", id: 1, result: "0x000000000000000000000000000000000000000000000000000000174876e800" }이것은 원시 eth_call입니다. 실제로 제출하지 않고 트랜잭션 실행을 시뮬레이션합니다. 가스 비용 없음. 상태 변경 없음. 반환 값만 읽습니다. view와 pure 함수가 외부에서 작동하는 방식입니다 — eth_sendRawTransaction 대신 eth_call을 사용합니다.
두 가지 핵심 RPC 메서드#
eth_call: 실행을 시뮬레이션합니다. 무료. 상태 변경 없음. 모든 읽기 작업에 사용됩니다 — 잔액 확인, 가격 읽기, view 함수 호출. "latest" 대신 블록 번호를 지정하여 모든 과거 블록에서 호출할 수 있습니다.
eth_sendRawTransaction: 서명된 트랜잭션을 블록 포함을 위해 제출합니다. 가스 비용 발생. 상태를 변경합니다(성공 시). 모든 쓰기 작업에 사용됩니다 — 전송, 승인, 스왑, 민팅.
JSON-RPC API의 다른 모든 것은 이 두 가지의 변형이거나 유틸리티 메서드(eth_blockNumber, eth_getTransactionReceipt, eth_getLogs 등)입니다.
프로바이더: 체인으로의 게이트웨이#
자체 노드를 실행하지 않습니다. 애플리케이션 개발에서는 거의 아무도 하지 않습니다. 대신 프로바이더 서비스를 사용합니다:
- Alchemy: 가장 인기 있음. 훌륭한 대시보드, 웹훅 지원, NFT와 토큰 메타데이터를 위한 향상된 API. 무료 티어: ~3억 컴퓨트 유닛/월.
- Infura: 원조. ConsenSys 소유. 안정적. 무료 티어: 10만 요청/일.
- QuickNode: 멀티체인에 좋음. 약간 다른 가격 모델.
- 퍼블릭 RPC 엔드포인트:
https://rpc.ankr.com/eth,https://cloudflare-eth.com. 무료지만 속도 제한이 있고 가끔 불안정. 개발에는 괜찮지만 프로덕션에는 위험함. - Tenderly: 시뮬레이션과 디버깅에 탁월. RPC에 내장 트랜잭션 시뮬레이터 포함.
프로덕션에서는 항상 최소 두 개의 프로바이더를 폴백으로 구성하세요. RPC 다운타임은 실제로 발생하며 최악의 시점에 일어날 것입니다.
import { ethers } from "ethers";
// ethers.js v6 폴백 프로바이더
const provider = new ethers.FallbackProvider([
{
provider: new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/KEY1"),
priority: 1,
stallTimeout: 2000,
weight: 2,
},
{
provider: new ethers.JsonRpcProvider("https://mainnet.infura.io/v3/KEY2"),
priority: 2,
stallTimeout: 2000,
weight: 1,
},
]);무료로 컨트랙트 상태 읽기#
대부분의 Web2 개발자가 모르는 강력한 기능입니다: Ethereum의 모든 컨트랙트에서 모든 공개 데이터를 지갑 없이, 아무것도 지불하지 않고, RPC 프로바이더의 API 키 외에 인증 없이 읽을 수 있습니다.
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
// ERC-20 인터페이스 — 읽기 함수만
const erc20 = new ethers.Contract(
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
[
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address) view returns (uint256)",
],
provider // 참고: signer가 아닌 provider. 읽기 전용.
);
const [name, symbol, decimals, totalSupply] = await Promise.all([
erc20.name(),
erc20.symbol(),
erc20.decimals(),
erc20.totalSupply(),
]);
console.log(`${name} (${symbol})`); // "USD Coin (USDC)"
console.log(`Decimals: ${decimals}`); // 6 (18이 아닙니다!)
console.log(`Total supply: ${ethers.formatUnits(totalSupply, decimals)}`);
// 특정 주소의 잔액 확인
const balance = await erc20.balanceOf("0xSomeAddress...");
console.log(`Balance: ${ethers.formatUnits(balance, decimals)} USDC`);지갑 없음. 가스 없음. 트랜잭션 없음. 내부적으로는 JSON-RPC eth_call일 뿐입니다. 이것은 개념적으로 REST API에 GET 요청을 보내는 것과 동일합니다. 블록체인이 데이터베이스이고, 컨트랙트가 API이며, eth_call이 SELECT 쿼리입니다.
ethers.js v6#
ethers.js는 Web3의 jQuery입니다 — 대부분의 개발자가 가장 먼저 배운 라이브러리이며, 여전히 가장 널리 사용됩니다. 버전 6은 v5에 비해 상당한 개선으로, 네이티브 BigInt 지원(드디어), ESM 모듈, 더 깔끔한 API를 갖추고 있습니다.
세 가지 핵심 추상화#
Provider: 블록체인에 대한 읽기 전용 연결. view 함수를 호출하고, 블록을 가져오고, 로그를 읽을 수 있습니다. 서명하거나 트랜잭션을 보낼 수 없습니다.
import { ethers } from "ethers";
// 노드에 연결
const provider = new ethers.JsonRpcProvider("https://...");
// 기본 쿼리
const blockNumber = await provider.getBlockNumber();
const balance = await provider.getBalance("0xAddress...");
const block = await provider.getBlock(blockNumber);
const txCount = await provider.getTransactionCount("0xAddress...");Signer: 개인 키에 대한 추상화. 트랜잭션과 메시지에 서명할 수 있습니다. Signer는 항상 Provider에 연결되어 있습니다.
// 개인 키에서 (서버 측, 스크립트)
const wallet = new ethers.Wallet("0xPrivateKey...", provider);
// 브라우저 지갑에서 (클라이언트 측)
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
// 주소 가져오기
const address = await signer.getAddress();Contract: 배포된 컨트랙트에 대한 JavaScript 프록시. Contract 객체의 메서드는 ABI의 함수에 대응합니다. view 함수는 값을 반환합니다. 상태 변경 함수는 TransactionResponse를 반환합니다.
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
// 읽기 (무료, 값을 직접 반환)
const balance = await usdc.balanceOf("0xSomeAddress...");
// balance는 bigint: 1000000000n (6 decimals로 1000 USDC)
// 쓰기 위해 signer로 연결
const usdcWithSigner = usdc.connect(signer);
// 쓰기 (가스 비용 발생, TransactionResponse 반환)
const tx = await usdcWithSigner.transfer("0xRecipient...", 1000000n);
const receipt = await tx.wait(); // 블록 포함 대기
if (receipt.status === 0) {
throw new Error("트랜잭션이 되돌아갔습니다");
}타입 안전성을 위한 TypeChain#
원시 ABI 상호작용은 문자열 타입입니다. 함수 이름을 잘못 입력하거나, 잘못된 인수 타입을 전달하거나, 반환 값을 잘못 해석할 수 있습니다. TypeChain은 ABI 파일에서 TypeScript 타입을 생성합니다:
// TypeChain 없이 — 타입 검사 없음
const balance = await contract.balanceOf("0x...");
// balance는 'any'. 자동완성 없음. 잘못 사용하기 쉬움.
// TypeChain 사용 — 완전한 타입 안전성
import { USDC__factory } from "./typechain";
const usdc = USDC__factory.connect(USDC_ADDRESS, provider);
const balance = await usdc.balanceOf("0x...");
// balance는 BigNumber. 자동완성이 작동함. 컴파일 타임에 타입 오류 포착.새 프로젝트의 경우, 별도의 코드 생성 단계 없이 동일한 결과를 달성하는 viem의 내장 ABI 타입 추론을 사용하는 것이 좋습니다.
이벤트 리스닝#
실시간 이벤트 스트리밍은 반응형 dApp에 필수적입니다. ethers.js는 이를 위해 WebSocket 프로바이더를 사용합니다:
// 실시간 이벤트를 위한 WebSocket
const wsProvider = new ethers.WebSocketProvider("wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const contract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, wsProvider);
// 모든 Transfer 이벤트 리스닝
contract.on("Transfer", (from, to, value, event) => {
console.log(`Transfer: ${from} -> ${to}`);
console.log(`Amount: ${ethers.formatUnits(value, 6)} USDC`);
});
// 특정 주소로의 전송 리스닝
const filter = contract.filters.Transfer(null, "0xMyAddress...");
contract.on(filter, (from, to, value) => {
console.log(`수신 전송: ${ethers.formatUnits(value, 6)} USDC from ${from}`);
});
// 완료 시 정리
contract.removeAllListeners();WAGMI + Viem: 현대적 스택#
WAGMI(We're All Gonna Make It)는 Ethereum을 위한 React 훅 라이브러리입니다. viem은 이것이 사용하는 기반 TypeScript 클라이언트입니다. 이 둘은 프론트엔드 dApp 개발의 표준 스택으로 ethers.js + web3-react를 대체했습니다.
왜 전환되었을까요? 세 가지 이유: ABI에서의 완전한 TypeScript 추론(코드 생성 불필요), 더 작은 번들 크기, 지갑 상호작용의 복잡한 비동기 상태 관리를 처리하는 React 훅.
설정#
// wagmi.config.ts
import { createConfig, http } from "wagmi";
import { mainnet, sepolia } from "wagmi/chains";
import { injected, walletConnect } from "wagmi/connectors";
export const config = createConfig({
chains: [mainnet, sepolia],
connectors: [
injected(),
walletConnect({ projectId: "YOUR_WALLETCONNECT_PROJECT_ID" }),
],
transports: {
[mainnet.id]: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
[sepolia.id]: http("https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY"),
},
});// app/providers.tsx
"use client";
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "./wagmi.config";
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</WagmiProvider>
);
}컨트랙트 데이터 읽기#
useReadContract는 가장 많이 사용할 훅입니다. React Query 캐싱, 리페칭, 로딩/에러 상태와 함께 eth_call을 래핑합니다:
"use client";
import { useReadContract } from "wagmi";
import { formatUnits } from "viem";
const ERC20_ABI = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "owner", type: "address" }],
outputs: [{ name: "balance", type: "uint256" }],
},
] as const;
function TokenBalance({ address }: { address: `0x${string}` }) {
const { data: balance, isLoading, error } = useReadContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
abi: ERC20_ABI,
functionName: "balanceOf",
args: [address],
});
if (isLoading) return <span>로딩 중...</span>;
if (error) return <span>오류: {error.message}</span>;
// ABI가 uint256이라고 했으므로 balance는 bigint으로 타입됩니다
return <span>{formatUnits(balance ?? 0n, 6)} USDC</span>;
}ABI의 as const에 주목하세요. 이것은 중요합니다. 없으면 TypeScript가 리터럴 타입을 잃고 balance가 bigint 대신 unknown이 됩니다. 전체 타입 추론 시스템은 const 단언에 의존합니다.
컨트랙트에 쓰기#
useWriteContract는 전체 생명주기를 처리합니다: 지갑 프롬프트, 서명, 브로드캐스팅, 확인 추적.
"use client";
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { parseUnits } from "viem";
function SendTokens() {
const { writeContract, data: hash, isPending, error } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash,
});
function handleSend() {
writeContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: ERC20_ABI,
functionName: "transfer",
args: [
"0xRecipientAddress...",
parseUnits("100", 6), // 100 USDC
],
});
}
return (
<div>
<button onClick={handleSend} disabled={isPending}>
{isPending ? "지갑에서 확인 중..." : "100 USDC 보내기"}
</button>
{hash && <p>트랜잭션: {hash}</p>}
{isConfirming && <p>확인 대기 중...</p>}
{isSuccess && <p>전송 확인됨!</p>}
{error && <p>오류: {error.message}</p>}
</div>
);
}이벤트 감시#
useWatchContractEvent는 실시간 이벤트 모니터링을 위한 WebSocket 구독을 설정합니다:
"use client";
import { useWatchContractEvent } from "wagmi";
import { useState } from "react";
import { formatUnits } from "viem";
interface TransferEvent {
from: string;
to: string;
value: string;
}
function LiveTransfers() {
const [transfers, setTransfers] = useState<TransferEvent[]>([]);
useWatchContractEvent({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: ERC20_ABI,
eventName: "Transfer",
onLogs(logs) {
const newTransfers = logs.map((log) => ({
from: log.args.from as string,
to: log.args.to as string,
value: formatUnits(log.args.value as bigint, 6),
}));
setTransfers((prev) => [...newTransfers, ...prev].slice(0, 50));
},
});
return (
<ul>
{transfers.map((t, i) => (
<li key={i}>
{t.from.slice(0, 8)}... → {t.to.slice(0, 8)}...: {t.value} USDC
</li>
))}
</ul>
);
}지갑 연결 패턴#
사용자의 지갑을 연결하는 것은 Web3의 "로그인"입니다. 다만 로그인이 아닙니다. 세션도 없고, 쿠키도 없고, 서버 측 상태도 없습니다. 지갑 연결은 앱에 사용자의 주소를 읽고 트랜잭션 서명을 요청할 권한을 줍니다. 그게 전부입니다.
EIP-1193 프로바이더 인터페이스#
모든 지갑은 EIP-1193으로 정의된 표준 인터페이스를 노출합니다. request 메서드가 있는 객체입니다:
interface EIP1193Provider {
request(args: { method: string; params?: unknown[] }): Promise<unknown>;
on(event: string, handler: (...args: unknown[]) => void): void;
removeListener(event: string, handler: (...args: unknown[]) => void): void;
}MetaMask는 이것을 window.ethereum으로 주입합니다. 다른 지갑은 자체 속성을 주입하거나 window.ethereum도 사용합니다(이로 인해 충돌이 발생 — "지갑 전쟁" 문제, EIP-6963으로 부분적으로 해결).
// 저수준 지갑 상호작용 (직접 하면 안 되지만, 이해하는 데 유용)
// 계정 접근 요청
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
console.log("연결된 주소:", accounts[0]);
// 현재 체인 가져오기
const chainId = await window.ethereum.request({
method: "eth_chainId",
});
console.log("Chain ID:", parseInt(chainId, 16)); // "0x1" -> 1 (메인넷)
// 계정 변경 리스닝 (사용자가 MetaMask에서 계정 전환)
window.ethereum.on("accountsChanged", (accounts: string[]) => {
if (accounts.length === 0) {
console.log("지갑 연결 끊김");
} else {
console.log("전환됨:", accounts[0]);
}
});
// 체인 변경 리스닝 (사용자가 네트워크 전환)
window.ethereum.on("chainChanged", (chainId: string) => {
// 권장 접근 방식은 페이지를 새로고침하는 것
window.location.reload();
});EIP-6963: 다중 지갑 발견#
기존 window.ethereum 접근 방식은 사용자가 여러 지갑을 설치했을 때 깨집니다. 어떤 것이 window.ethereum을 가져갈까요? 마지막에 주입한 것? 첫 번째? 경쟁 상태입니다.
EIP-6963은 브라우저 이벤트 기반 발견 프로토콜로 이 문제를 해결합니다:
// 사용 가능한 모든 지갑 발견
interface EIP6963ProviderDetail {
info: {
uuid: string;
name: string;
icon: string;
rdns: string; // 역방향 도메인 이름, 예: "io.metamask"
};
provider: EIP1193Provider;
}
const wallets: EIP6963ProviderDetail[] = [];
window.addEventListener("eip6963:announceProvider", (event: CustomEvent) => {
wallets.push(event.detail);
});
// 모든 지갑에 자신을 알리도록 요청
window.dispatchEvent(new Event("eip6963:requestProvider"));
// 이제 'wallets'에 이름과 아이콘이 포함된 모든 설치된 지갑이 있습니다
// 지갑 선택 UI를 보여줄 수 있습니다WAGMI가 이 모든 것을 처리합니다. injected() 커넥터를 사용하면, 사용 가능한 경우 자동으로 EIP-6963을 사용하고 window.ethereum으로 폴백합니다.
WalletConnect#
WalletConnect는 릴레이 서버를 통해 모바일 지갑을 데스크톱 dApp에 연결하는 프로토콜입니다. 사용자가 모바일 지갑으로 QR 코드를 스캔하여 암호화된 연결을 수립합니다. 트랜잭션 요청은 dApp에서 휴대폰으로 릴레이됩니다.
WAGMI에서는 또 다른 커넥터일 뿐입니다:
import { walletConnect } from "wagmi/connectors";
const connector = walletConnect({
projectId: "YOUR_PROJECT_ID", // cloud.walletconnect.com에서 가져오기
showQrModal: true,
});체인 전환 처리#
사용자는 종종 잘못된 네트워크에 있습니다. dApp은 메인넷에 있는데, 사용자는 Sepolia에 연결되어 있습니다. 또는 Polygon에 있는데 메인넷이 필요합니다. WAGMI는 useSwitchChain을 제공합니다:
"use client";
import { useAccount, useSwitchChain } from "wagmi";
import { mainnet } from "wagmi/chains";
function NetworkGuard({ children }: { children: React.ReactNode }) {
const { chain } = useAccount();
const { switchChain, isPending } = useSwitchChain();
if (!chain) return <p>지갑을 연결해 주세요</p>;
if (chain.id !== mainnet.id) {
return (
<div>
<p>Ethereum 메인넷으로 전환해 주세요</p>
<button
onClick={() => switchChain({ chainId: mainnet.id })}
disabled={isPending}
>
{isPending ? "전환 중..." : "네트워크 전환"}
</button>
</div>
);
}
return <>{children}</>;
}IPFS와 메타데이터#
NFT는 이미지를 온체인에 저장하지 않습니다. 블록체인은 JSON 메타데이터 파일을 가리키는 URI를 저장하며, 이 파일은 다시 이미지에 대한 URL을 포함합니다. ERC-721의 tokenURI 함수로 정의된 표준 패턴:
Contract.tokenURI(42) → "ipfs://QmXyz.../42.json"
그 JSON 파일은 표준 스키마를 따릅니다:
{
"name": "Cool NFT #42",
"description": "매우 멋진 NFT",
"image": "ipfs://QmImageHash...",
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Rarity", "value": "Legendary" }
]
}IPFS CID vs URL#
IPFS 주소는 콘텐츠 식별자(CID) — 콘텐츠 자체의 해시를 사용합니다. ipfs://QmXyz...는 "해시가 QmXyz...인 콘텐츠"를 의미합니다. 이것은 콘텐츠 주소 지정 저장소입니다: URI가 콘텐츠에서 파생되므로, URI를 변경하지 않고는 콘텐츠를 변경할 수 없습니다. NFT가 의존하는 불변성 보장입니다(실제로 IPFS를 사용하는 경우 — 많은 것들이 중앙화된 URL을 대신 사용하며, 이것은 경고 신호입니다).
브라우저에서 IPFS 콘텐츠를 표시하려면, IPFS URI를 HTTP로 변환하는 게이트웨이가 필요합니다:
function ipfsToHttp(uri: string): string {
if (uri.startsWith("ipfs://")) {
const cid = uri.replace("ipfs://", "");
return `https://ipfs.io/ipfs/${cid}`;
// 또는 전용 게이트웨이 사용:
// return `https://YOUR_PROJECT.mypinata.cloud/ipfs/${cid}`;
}
return uri;
}
// NFT 메타데이터 가져오기
async function getNftMetadata(
contractAddress: string,
tokenId: bigint,
provider: ethers.Provider
) {
const contract = new ethers.Contract(
contractAddress,
["function tokenURI(uint256 tokenId) view returns (string)"],
provider
);
const tokenUri = await contract.tokenURI(tokenId);
const httpUri = ipfsToHttp(tokenUri);
const response = await fetch(httpUri);
const metadata = await response.json();
return {
name: metadata.name,
description: metadata.description,
image: ipfsToHttp(metadata.image),
attributes: metadata.attributes,
};
}피닝 서비스#
IPFS는 피어투피어 네트워크입니다. 콘텐츠는 누군가가 호스팅("피닝")하는 동안에만 사용 가능합니다. NFT 이미지를 IPFS에 업로드한 다음 노드를 종료하면, 콘텐츠가 사라집니다.
피닝 서비스가 콘텐츠를 사용 가능하게 유지합니다:
- Pinata: 가장 인기 있음. 간단한 API. 넉넉한 무료 티어(1GB). 빠른 로딩을 위한 전용 게이트웨이.
- NFT.Storage: 무료, Protocol Labs(IPFS 제작자)가 지원. NFT 메타데이터를 위해 특별히 설계됨. 장기 지속성을 위해 Filecoin 사용.
- Web3.Storage: NFT.Storage와 유사하지만 더 범용적.
// Pinata에 업로드
async function pinToIpfs(file: File): Promise<string> {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
method: "POST",
headers: {
Authorization: `Bearer ${PINATA_JWT}`,
},
body: formData,
});
const result = await response.json();
return `ipfs://${result.IpfsHash}`; // CID 반환
}인덱싱 문제#
블록체인 개발의 숨겨진 비밀이 있습니다: RPC 노드에서 과거 데이터를 효율적으로 쿼리할 수 없습니다.
지난 1년간 토큰의 모든 Transfer 이벤트를 원하시나요? 수백만 개의 블록을 eth_getLogs로 스캔해야 하며, 2,000-10,000 블록 단위로 페이지네이션합니다(최대값은 프로바이더에 따라 다름). 수천 개의 RPC 호출이 필요합니다. 몇 분에서 몇 시간이 걸리고 API 할당량을 소진합니다.
특정 주소가 소유한 모든 토큰을 원하시나요? 이를 위한 단일 RPC 호출이 없습니다. 모든 가능한 ERC-20 컨트랙트의 모든 Transfer 이벤트를 스캔하여 잔액을 추적해야 합니다. 실현 가능하지 않습니다.
지갑의 모든 NFT를 원하시나요? 같은 문제입니다. 모든 NFT 컨트랙트의 모든 ERC-721 Transfer 이벤트를 스캔해야 합니다.
블록체인은 쓰기 최적화 데이터 구조입니다. 새로운 트랜잭션을 처리하는 데는 탁월합니다. 과거 쿼리에 답하는 데는 형편없습니다. 이것이 dApp UI가 필요로 하는 것과 체인이 네이티브로 제공하는 것 사이의 근본적인 불일치입니다.
The Graph 프로토콜#
The Graph는 탈중앙화 인덱싱 프로토콜입니다. "서브그래프"를 작성합니다 — 스키마와 이벤트 핸들러 세트 — 그러면 The Graph가 체인을 인덱싱하고 GraphQL API를 통해 데이터를 서빙합니다.
# 서브그래프 스키마 (schema.graphql)
type Transfer @entity {
id: Bytes!
from: Bytes!
to: Bytes!
value: BigInt!
blockNumber: BigInt!
timestamp: BigInt!
}
type Account @entity {
id: Bytes!
balance: BigInt!
transfersFrom: [Transfer!]! @derivedFrom(field: "from")
transfersTo: [Transfer!]! @derivedFrom(field: "to")
}// 프론트엔드에서 서브그래프 쿼리
const SUBGRAPH_URL =
"https://api.studio.thegraph.com/query/YOUR_ID/YOUR_SUBGRAPH/v0.0.1";
async function getRecentTransfers(address: string) {
const query = `
query GetTransfers($address: Bytes!) {
transfers(
where: { from: $address }
orderBy: blockNumber
orderDirection: desc
first: 100
) {
id
from
to
value
blockNumber
timestamp
}
}
`;
const response = await fetch(SUBGRAPH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables: { address } }),
});
const { data } = await response.json();
return data.transfers;
}트레이드오프: The Graph는 지연(일반적으로 체인 헤드보다 1-2 블록 뒤처짐)과 또 다른 의존성을 추가합니다. 탈중앙화 네트워크에는 인덱싱 비용도 있습니다(GRT 토큰으로 지불). 소규모 프로젝트의 경우, 호스팅 서비스(Subgraph Studio)는 무료입니다.
Alchemy와 Moralis 향상 API#
서브그래프를 유지 관리하고 싶지 않다면, Alchemy와 Moralis 모두 일반적인 쿼리에 직접 답하는 사전 인덱싱된 API를 제공합니다:
// Alchemy: 주소의 모든 ERC-20 토큰 잔액 가져오기
const response = await fetch(
`https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "alchemy_getTokenBalances",
params: ["0xAddress...", "erc20"],
}),
}
);
// 한 번의 호출로 모든 ERC-20 토큰 잔액을 반환
// 모든 가능한 ERC-20 컨트랙트의 balanceOf()를 스캔하는 것과 비교// Alchemy: 주소가 소유한 모든 NFT 가져오기
const response = await fetch(
`https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY/getNFTs?owner=0xAddress...`
);
const { ownedNfts } = await response.json();
for (const nft of ownedNfts) {
console.log(`${nft.title} - ${nft.contract.address}#${nft.tokenId}`);
}이 API는 독점적이고 중앙화되어 있습니다. 탈중앙화를 개발자 경험과 교환하는 것입니다. 대부분의 dApp에서 이것은 가치 있는 트레이드오프입니다. 사용자는 포트폴리오 뷰가 서브그래프에서 오든 Alchemy의 데이터베이스에서 오든 상관하지 않습니다. 30초 대신 200ms에 로드되는 것을 중요하게 여깁니다.
흔한 함정#
여러 프로덕션 dApp을 출시하고 다른 팀의 코드를 디버깅한 후, 반복적으로 보이는 실수들입니다. 모든 하나가 저를 개인적으로 물었습니다.
어디서든 BigInt#
Ethereum은 매우 큰 숫자를 다룹니다. ETH 잔액은 wei(10^18) 단위입니다. 토큰 공급량은 10^27 이상이 될 수 있습니다. JavaScript의 Number는 2^53 - 1(약 9 * 10^15)까지의 정수만 안전하게 표현할 수 있습니다. wei 양에는 충분하지 않습니다.
// 잘못됨 — 정밀도 손실이 조용히 발생
const balance = 1000000000000000000; // wei로 1 ETH
const double = balance * 2;
console.log(double); // 2000000000000000000 — 맞아 보이지만...
const largeBalance = 99999999999999999999; // ~100 ETH
console.log(largeBalance); // 100000000000000000000 — 틀림! 반올림됨.
console.log(largeBalance === 100000000000000000000); // true — 데이터 손상
// 올바름 — BigInt 사용
const balance = 1000000000000000000n;
const double = balance * 2n;
console.log(double.toString()); // "2000000000000000000" — 정확
const largeBalance = 99999999999999999999n;
console.log(largeBalance.toString()); // "99999999999999999999" — 정확dApp 코드에서 BigInt 규칙:
- wei 금액을 절대
Number로 변환하지 마세요. 어디서든BigInt를 사용하고, 표시 목적으로만 사람이 읽을 수 있는 문자열로 변환하세요. - BigInt에
Math.floor,Math.round등을 절대 사용하지 마세요. 작동하지 않습니다. 정수 나눗셈을 사용하세요:amount / 10n ** 6n. - JSON은 BigInt를 지원하지 않습니다. BigInt가 포함된 상태를 직렬화하면, 커스텀 직렬화기가 필요합니다:
JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v). - 라이브러리 포맷팅 함수를 사용하세요.
ethers.formatEther(),ethers.formatUnits(),viem의formatEther(),formatUnits(). 변환을 올바르게 처리합니다.
import { formatUnits, parseUnits } from "viem";
// 표시: BigInt → 사람이 읽을 수 있는 문자열
const weiAmount = 1500000000000000000n; // 1.5 ETH
const display = formatUnits(weiAmount, 18); // "1.5"
// 입력: 사람이 읽을 수 있는 문자열 → BigInt
const userInput = "1.5";
const wei = parseUnits(userInput, 18); // 1500000000000000000n
// USDC는 18이 아닌 6 decimals
const usdcAmount = 100000000n; // 100 USDC
const usdcDisplay = formatUnits(usdcAmount, 6); // "100.0"비동기 지갑 작업#
모든 지갑 상호작용은 비동기이며 앱이 우아하게 처리해야 하는 방식으로 실패할 수 있습니다:
// 사용자는 어떤 지갑 프롬프트도 거부할 수 있습니다
try {
const tx = await writeContract({
address: contractAddress,
abi: ERC20_ABI,
functionName: "approve",
args: [spenderAddress, amount],
});
} catch (error) {
if (error.code === 4001) {
// 사용자가 지갑에서 트랜잭션을 거부
// 이것은 정상 — 보고할 오류가 아님
showToast("트랜잭션 취소됨");
} else if (error.code === -32603) {
// 내부 JSON-RPC 오류 — 종종 트랜잭션이 되돌릴 것을 의미
showToast("트랜잭션이 실패할 것입니다. 잔액을 확인하세요.");
} else {
// 예상치 못한 오류
console.error("트랜잭션 오류:", error);
showToast("문제가 발생했습니다. 다시 시도해 주세요.");
}
}주요 비동기 함정:
- 지갑 프롬프트는 사용자 측에서 차단됩니다. 코드의
await는 사용자가 MetaMask에서 트랜잭션 세부 정보를 읽는 동안 30초가 걸릴 수 있습니다. 무언가가 고장났다고 생각하게 하는 로딩 스피너를 보여주지 마세요. - 사용자가 상호작용 중에 계정을 전환할 수 있습니다. 계정 A에서 승인을 요청했는데, 사용자가 계정 B로 전환한 후 승인합니다. 이제 계정 B가 승인했지만 계정 A에서 트랜잭션을 보내려 합니다. 중요한 작업 전에 항상 연결된 계정을 재확인하세요.
- 2단계 쓰기 패턴이 일반적입니다. 많은 DeFi 작업에는
approve+execute가 필요합니다. 사용자는 두 개의 트랜잭션에 서명해야 합니다. 승인은 했지만 실행하지 않으면, 다음에는 allowance 상태를 확인하고 승인 단계를 건너뛰어야 합니다.
네트워크 불일치 오류#
이것은 다른 어떤 문제보다 더 많은 디버깅 시간을 낭비합니다. 컨트랙트는 메인넷에 있습니다. 지갑은 Sepolia에 있습니다. RPC 프로바이더는 Polygon을 가리킵니다. 세 개의 서로 다른 네트워크, 세 개의 서로 다른 상태, 세 개의 완전히 관련 없는 블록체인. 그리고 오류 메시지는 대개 도움이 되지 않습니다 — "execution reverted" 또는 "contract not found."
// 방어적 체인 확인
import { useAccount, useChainId } from "wagmi";
function useRequireChain(requiredChainId: number) {
const chainId = useChainId();
const { isConnected } = useAccount();
if (!isConnected) {
return { ready: false, error: "지갑을 연결해 주세요" };
}
if (chainId !== requiredChainId) {
return {
ready: false,
error: `${getChainName(requiredChainId)}으로 전환해 주세요. 현재 ${getChainName(chainId)}에 있습니다.`,
};
}
return { ready: true, error: null };
}DeFi에서의 프론트러닝#
DEX에서 스왑을 제출하면, 대기 중인 트랜잭션이 멤풀에서 보입니다. 봇이 거래를 보고, 가격을 올려 프론트런하고, 더 나쁜 가격에서 거래가 실행되게 한 후, 즉시 팔아 이익을 냅니다. 이것을 "샌드위치 공격"이라고 합니다.
프론트엔드 개발자로서 이것을 완전히 방지할 수 없지만, 완화할 수 있습니다:
// Uniswap 스타일 스왑에서 슬리피지 허용 범위 설정
const amountOutMin = expectedOutput * 995n / 1000n; // 0.5% 슬리피지 허용
// 장기 대기 트랜잭션을 방지하기 위한 데드라인 사용
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 20); // 20분
await router.swapExactTokensForTokens(
amountIn,
amountOutMin, // 최소 허용 출력 — 이보다 적으면 되돌림
[tokenA, tokenB],
userAddress,
deadline, // 20분 내에 실행되지 않으면 되돌림
);고가치 트랜잭션의 경우, 퍼블릭 멤풀 대신 블록 빌더에 직접 트랜잭션을 보내는 Flashbots Protect RPC 사용을 고려하세요. 봇이 대기 중인 트랜잭션을 볼 수 없으므로 샌드위치 공격을 완전히 방지합니다:
// Flashbots Protect를 RPC 엔드포인트로 사용
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");Decimal 혼동#
모든 토큰이 18 decimals를 가지는 것은 아닙니다. USDC와 USDT는 6입니다. WBTC는 8입니다. 일부 토큰은 0, 2, 또는 임의의 decimals를 가집니다. 금액을 포맷하기 전에 항상 컨트랙트에서 decimals()를 읽으세요:
async function formatTokenAmount(
tokenAddress: string,
rawAmount: bigint,
provider: ethers.Provider
): Promise<string> {
const contract = new ethers.Contract(
tokenAddress,
["function decimals() view returns (uint8)", "function symbol() view returns (string)"],
provider
);
const [decimals, symbol] = await Promise.all([
contract.decimals(),
contract.symbol(),
]);
return `${ethers.formatUnits(rawAmount, decimals)} ${symbol}`;
}
// formatTokenAmount(USDC, 1000000n, provider) → "1.0 USDC"
// formatTokenAmount(WETH, 1000000000000000000n, provider) → "1.0 WETH"
// formatTokenAmount(WBTC, 100000000n, provider) → "1.0 WBTC"가스 추정 실패#
estimateGas가 실패하면, 대개 트랜잭션이 되돌릴 것을 의미합니다. 하지만 오류 메시지는 종종 이유 없이 "cannot estimate gas"일 뿐입니다. eth_call을 사용하여 트랜잭션을 시뮬레이션하고 실제 되돌림 이유를 가져오세요:
import { createPublicClient, http, decodeFunctionResult } from "viem";
async function simulateAndGetError(client: ReturnType<typeof createPublicClient>, tx: object) {
try {
await client.call({
account: tx.from,
to: tx.to,
data: tx.data,
value: tx.value,
});
return null; // 오류 없음 — 트랜잭션이 성공할 것
} catch (error) {
// 되돌림 이유 디코딩
if (error.data) {
// 일반적인 되돌림 문자열
if (error.data.startsWith("0x08c379a0")) {
// Error(string) — 메시지가 있는 표준 되돌림
const reason = decodeAbiParameters(
[{ type: "string" }],
`0x${error.data.slice(10)}`
);
return `Revert: ${reason[0]}`;
}
}
return error.message;
}
}종합하기#
지갑을 연결하고, 토큰 잔액을 읽고, 전송을 보내는 완전하고 최소한의 React 컴포넌트입니다. 이것이 모든 dApp의 뼈대입니다:
"use client";
import { useAccount, useConnect, useDisconnect, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { injected } from "wagmi/connectors";
import { formatUnits, parseUnits } from "viem";
import { useState } from "react";
const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const USDC_ABI = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
{
name: "transfer",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
] as const;
export function TokenDashboard() {
const { address, isConnected } = useAccount();
const { connect } = useConnect();
const { disconnect } = useDisconnect();
const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
// 잔액 읽기 — address가 정의되었을 때만 실행
const { data: balance, refetch: refetchBalance } = useReadContract({
address: USDC_ADDRESS,
abi: USDC_ABI,
functionName: "balanceOf",
args: address ? [address] : undefined,
query: { enabled: !!address },
});
// 쓰기: 토큰 전송
const {
writeContract,
data: txHash,
isPending: isSigning,
error: writeError,
} = useWriteContract();
// 확인 대기
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
// 확인 후 잔액 재조회
if (isSuccess) {
refetchBalance();
}
if (!isConnected) {
return (
<button onClick={() => connect({ connector: injected() })}>
지갑 연결
</button>
);
}
return (
<div>
<p>연결됨: {address}</p>
<p>
USDC 잔액:{" "}
{balance !== undefined ? formatUnits(balance, 6) : "로딩 중..."}
</p>
<div>
<input
placeholder="수신자 주소 (0x...)"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
<input
placeholder="금액 (예: 100)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<button
onClick={() => {
writeContract({
address: USDC_ADDRESS,
abi: USDC_ABI,
functionName: "transfer",
args: [recipient as `0x${string}`, parseUnits(amount, 6)],
});
}}
disabled={isSigning || isConfirming}
>
{isSigning
? "지갑에서 확인 중..."
: isConfirming
? "확인 중..."
: "USDC 보내기"}
</button>
</div>
{writeError && <p style={{ color: "red" }}>{writeError.message}</p>}
{isSuccess && <p style={{ color: "green" }}>전송 확인됨!</p>}
{txHash && (
<a
href={`https://etherscan.io/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
>
Etherscan에서 보기
</a>
)}
<button onClick={() => disconnect()}>연결 끊기</button>
</div>
);
}다음 단계#
이 글은 Ethereum에 입문하는 웹 개발자를 위한 필수 개념과 도구를 다루었습니다. 각 영역에 더 깊은 내용이 있습니다:
- Solidity: 컨트랙트와 상호작용하는 것뿐 아니라 직접 작성하고 싶다면. 공식 문서와 Patrick Collins의 강좌가 최고의 출발점입니다.
- ERC 표준: ERC-20(대체 가능 토큰), ERC-721(NFT), ERC-1155(다중 토큰), ERC-4626(토큰화된 볼트). 각각은 해당 카테고리의 모든 컨트랙트가 구현하는 표준 인터페이스를 정의합니다.
- 레이어 2: Arbitrum, Optimism, Base, zkSync. 동일한 개발자 경험, 더 낮은 가스 비용, 약간 다른 신뢰 가정. ethers.js와 viem 코드가 동일하게 작동합니다 — 체인 ID와 RPC URL만 변경하면 됩니다.
- 계정 추상화 (ERC-4337): 지갑 UX의 다음 진화. 가스 후원, 소셜 복구, 배치 트랜잭션을 지원하는 스마트 컨트랙트 지갑. "지갑 연결" 패턴이 향하는 방향입니다.
- MEV와 트랜잭션 순서: DeFi를 구축한다면, 최대 추출 가능 가치(MEV)를 이해하는 것은 선택 사항이 아닙니다. Flashbots 문서가 표준 자료입니다.
블록체인 생태계는 빠르게 움직이지만, 이 글의 기본 사항 — 계정, 트랜잭션, ABI 인코딩, RPC 호출, 이벤트 인덱싱 — 은 2015년 이후 변하지 않았으며 곧 변하지도 않을 것입니다. 이것들을 잘 배우면 나머지는 모두 API 표면일 뿐입니다.