이벤트 없이는 블록체인 서비스를 만들 수 없다
스마트컨트랙트는 외부에서 상태를 직접 “구독”할 수 없다. 데이터베이스처럼 변경을 감지하는 트리거나 pub/sub이 없다. 대신 컨트랙트는 중요한 일이 발생했을 때 이벤트(Event)를 발행하고, 외부 시스템은 이 이벤트를 조회하거나 구독해 상태 변화를 파악한다.
ERC-20 Transfer 추적, NFT 민팅 감지, DeFi 청산 모니터링 — 모든 것이 이벤트에서 시작한다.
이벤트의 실체 — 블록체인 로그(Log)
Solidity에서 emit한 이벤트는 EVM 실행 중 LOG opcode로 처리되어 트랜잭션 receipt의 logs 배열에 저장된다. 이것이 “로그(Log)”다.
// Solidity: 이벤트 선언과 발행
contract ERC20Token {
// 이벤트 선언
event Transfer(
address indexed from, // indexed: 필터링 가능
address indexed to, // indexed: 필터링 가능
uint256 value // non-indexed: ABI 디코딩 필요
);
function transfer(address to, uint256 amount) external returns (bool) {
_balances[msg.sender] -= amount;
_balances[to] += amount;
emit Transfer(msg.sender, to, amount); // 이벤트 발행
return true;
}
}
indexed vs non-indexed — 차이가 중요하다
| 구분 | indexed 파라미터 | non-indexed 파라미터 |
|---|---|---|
| 저장 위치 | topics 배열 | data 필드 (ABI 인코딩) |
| 필터링 | 가능 (getLogs filter) | 불가 (직접 디코딩 후 필터) |
| 최대 개수 | 3개 (topics[1~3]) | 제한 없음 |
| Gas 비용 | topic당 375 gas | data 바이트당 8 gas |
topics[0]는 항상 이벤트 시그니처 해시다:
// Transfer 이벤트의 topics[0]
keccak256("Transfer(address,address,uint256)")
// = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
이벤트 조회 — getLogs 필터링
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider(RPC_URL);
const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// ERC-20 Transfer 이벤트 ABI
const ERC20_ABI = [
"event Transfer(address indexed from, address indexed to, uint256 value)"
];
const contract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
// 특정 주소로의 Transfer 이벤트 조회
const filter = contract.filters.Transfer(
null, // from: 모든 주소
"0xTargetAddress" // to: 특정 주소 (indexed이므로 필터 가능)
);
const events = await contract.queryFilter(
filter,
-10000, // fromBlock: 최근 10,000블록
"latest" // toBlock
);
for (const event of events) {
const { from, to, value } = event.args;
console.log(`Transfer: ${from} → ${to}, ${ethers.formatUnits(value, 6)} USDC`);
console.log(`블록: ${event.blockNumber}, TX: ${event.transactionHash}`);
}
실시간 이벤트 구독
// WebSocket 연결로 실시간 구독 (WebSocket RPC 필요)
const wsProvider = new ethers.WebSocketProvider(WS_RPC_URL);
const contract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, wsProvider);
// Transfer 이벤트 실시간 구독
contract.on("Transfer", (from, to, value, event) => {
console.log(`실시간 Transfer 감지!`);
console.log(`From: ${from}`);
console.log(`To: ${to}`);
console.log(`Amount: ${ethers.formatUnits(value, 6)} USDC`);
console.log(`TX Hash: ${event.log.transactionHash}`);
});
// 구독 해제
// contract.off("Transfer", handler);
// WebSocket 연결 종료 처리
wsProvider.on("error", (error) => {
console.error("WebSocket 오류:", error);
// 재연결 로직
});
wsProvider.websocket.onclose = () => {
console.log("WebSocket 연결 종료, 재연결 시도...");
reconnect();
};
getLogs 직접 호출 — 고급 필터링
// 여러 컨트랙트의 이벤트를 동시에 조회
const logs = await provider.getLogs({
fromBlock: 19000000,
toBlock: 19001000,
address: [USDC_ADDRESS, USDT_ADDRESS, DAI_ADDRESS], // 여러 주소
topics: [
// Transfer 이벤트 시그니처
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
null, // from: 모든 주소
// to: 특정 주소 (32바이트로 패딩)
ethers.zeroPadValue("0xTargetAddress", 32),
],
});
// ABI 디코딩
const iface = new ethers.Interface(ERC20_ABI);
for (const log of logs) {
const parsed = iface.parseLog(log);
console.log(parsed?.args);
}
이벤트 인덱싱 시스템 설계
대규모 서비스에서는 getLogs를 매번 호출하는 것은 비효율적이다. 이벤트를 DB에 인덱싱해두는 패턴이 표준이다.
// 이벤트 인덱서 (단순화된 버전)
class EventIndexer {
private lastProcessedBlock: number;
async start() {
// DB에서 마지막 처리 블록 로드
this.lastProcessedBlock = await db.getLastProcessedBlock() ?? DEPLOY_BLOCK;
// 블록 단위로 폴링
setInterval(async () => {
await this.processNewBlocks();
}, 12000); // 12초(1 슬롯)마다
}
async processNewBlocks() {
const finalizedBlock = await provider.getBlock('finalized');
const toBlock = Math.min(
finalizedBlock!.number,
this.lastProcessedBlock + 1000 // 한 번에 최대 1000블록
);
if (toBlock <= this.lastProcessedBlock) return;
const logs = await provider.getLogs({
fromBlock: this.lastProcessedBlock + 1,
toBlock,
address: CONTRACT_ADDRESS,
topics: [TRANSFER_TOPIC],
});
// DB에 저장
await db.insertLogs(logs);
await db.setLastProcessedBlock(toBlock);
this.lastProcessedBlock = toBlock;
}
}
주요 DeFi 이벤트 패턴
// Uniswap V3 Swap 이벤트
event Swap(
address indexed sender,
address indexed recipient,
int256 amount0, // token0 변화량 (음수=출금, 양수=입금)
int256 amount1, // token1 변화량
uint160 sqrtPriceX96, // 실행 후 가격
uint128 liquidity,
int24 tick
);
// Aave V3 LiquidationCall 이벤트
event LiquidationCall(
address indexed collateralAsset,
address indexed debtAsset,
address indexed user,
uint256 debtToCover,
uint256 liquidatedCollateralAmount,
address liquidator,
bool receiveAToken
);
핵심 정리
- 이벤트 = EVM LOG opcode → receipt.logs에 저장
- indexed 파라미터는 topics에 저장 → 필터링 가능 (최대 3개)
- non-indexed는 data에 ABI 인코딩 → 필터 불가, 디코딩 필요
- 실시간 구독은 WebSocket RPC 필요
- 대규모 서비스는 이벤트 인덱서로 DB에 저장 후 조회
- Reorg 대응: finalized 블록 기준으로만 처리, 블록 해시 검증