이벤트로 상태 변화 추적하기
이벤트는 컨트랙트가 읽을 수 없습니다. 그렇다면 대체 누가 읽는 걸까요? 답은 체인 바깥입니다. 웹 페이지, 모바일 앱, 데이터 분석 서버가 이벤트를 읽습니다.
여러분이 써 본 모든 dApp 은 이렇게 동작합니다. 화면의 거래 내역 목록도, 전송이 완료되자마자 뜨는 알림도, 컨트랙트에 물어본 결과가 아니라 이벤트 로그를 읽은 결과입니다. 이번 랩에서 그 구조를 직접 만들어 봅니다.
1단계 — 활동 기록 컨트랙트
contracts/ActivityLog.sol 을 만듭니다. 상태 변수는 하나도 없고 이벤트만 발행하는 컨트랙트입니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract ActivityLog {
// actor 에만 indexed 를 붙였다 — kind 와 value 는 붙이지 않았다
event Action(address indexed actor, string kind, uint256 value);
function doAction(string calldata _kind, uint256 _value) public {
emit Action(msg.sender, _kind, _value);
}
}indexed 를 actor 에만 붙인 것이 뒤에서 중요해집니다. 4단계에서 그 차이를 직접 확인합니다.
컴파일하고, 노드를 켜 둔 채 배포합니다.
npx hardhat compile
npx hardhat run scripts/deployLog.js --network localhost// scripts/deployLog.js
import { network } from 'hardhat'
const { ethers } = await network.getOrCreate()
const log = await ethers.deployContract('ActivityLog')
await log.waitForDeployment()
console.log('ActivityLog 주소:', await log.getAddress())2단계 — 실시간 구독 스크립트
이번 스크립트는 Hardhat 없이 순수 ethers.js 로 작성합니다. 브라우저의 dApp 이 하는 일과 똑같은 방식이기 때문입니다.
scripts/watch.js 를 만듭니다.
// scripts/watch.js
import { ethers } from 'ethers'
// 로컬 노드에 직접 붙는다 — 웹 페이지가 지갑에 붙는 것과 같은 구조다
const provider = new ethers.JsonRpcProvider('http://127.0.0.1:8545')
const ADDRESS = '여기에_배포된_주소'
// 사람이 읽을 수 있는 형태의 ABI — 필요한 항목만 적으면 된다
const abi = [
'event Action(address indexed actor, string kind, uint256 value)',
'function doAction(string kind, uint256 value)',
]
const log = new ethers.Contract(ADDRESS, abi, provider)
console.log('구독 시작 — 이벤트를 기다리는 중입니다. 종료하려면 Ctrl+C')
log.on('Action', (actor, kind, value, event) => {
console.log('이벤트 수신!')
console.log(' actor:', actor)
console.log(' kind :', kind)
console.log(' value:', value)
console.log(' block:', event.log.blockNumber)
})실행합니다.
node scripts/watch.js구독 시작 이 출력된 뒤 터미널이 종료되지 않고 그대로 멈춰 있습니다. 정상입니다. 이벤트를 기다리는 중이기 때문입니다. 이 터미널은 그대로 두세요.
ethers 를 찾을 수 없다는 오류가 나면 프로젝트 폴더에서 npm install ethers 를 실행하세요. 또
import 구문이 오류를 내면 package.json 에 "type": "module" 항목이 있는지 확인하세요.
3단계 — 트랜잭션을 발생시켜 콜백 확인
세 번째 터미널을 열고 트랜잭션을 보냅니다. scripts/fireActions.js 를 만듭니다.
// scripts/fireActions.js
import { network } from 'hardhat'
const { ethers } = await network.getOrCreate()
const ADDRESS = '여기에_배포된_주소'
const log = await ethers.getContractAt('ActivityLog', ADDRESS)
const signers = await ethers.getSigners()
const actions = [
{ who: signers[0], kind: 'login', value: 1n },
{ who: signers[1], kind: 'purchase', value: 500n },
{ who: signers[0], kind: 'logout', value: 0n },
]
for (const a of actions) {
const tx = await log.connect(a.who).doAction(a.kind, a.value)
await tx.wait()
console.log('보냄:', a.kind)
}npx hardhat run scripts/fireActions.js --network localhost이제 2단계 터미널을 보세요. 트랜잭션 세 건에 맞춰 이벤트 수신! 이 세 번 찍혀 있습니다. 구독 스크립트는 아무것도 조회하지 않았습니다. 트랜잭션이 발생한 순간 노드가 알려 준 것입니다.
이것이 dApp 이 화면을 갱신하는 방식입니다. 사용자가 버튼을 누르면 트랜잭션이 나가고, 그것이 블록에 담기는 순간 구독 중이던 화면이 스스로 바뀝니다. 컨트랙트에 "혹시 뭐 바뀐 거 있어?" 하고 반복해서 물어볼 필요가 없습니다.
4단계 — 과거 로그 조회와 필터링
실시간 구독은 지금부터 일어나는 일만 알려 줍니다. 사용자가 페이지를 처음 열었을 때 보여 줄 과거 내역은 따로 가져와야 합니다. queryFilter 가 그 일을 합니다.
scripts/queryLogs.js 를 만듭니다.
// scripts/queryLogs.js
import { ethers } from 'ethers'
const provider = new ethers.JsonRpcProvider('http://127.0.0.1:8545')
const ADDRESS = '여기에_배포된_주소'
const abi = ['event Action(address indexed actor, string kind, uint256 value)']
const log = new ethers.Contract(ADDRESS, abi, provider)
// (1) 전체 로그 — 0번 블록부터 최신까지
const all = await log.queryFilter('Action', 0, 'latest')
console.log('전체 이벤트 수:', all.length)
for (const e of all) {
console.log(` ${e.args.actor} / ${e.args.kind} / ${e.args.value}`)
}node scripts/queryLogs.js전체 이벤트 수: 3 과 세 건의 내용이 출력됩니다. 구독을 켜지 않았는데도 지난 일을 전부 읽어 왔습니다. 로그는 블록에 영구히 남아 있기 때문입니다.
이제 특정 사용자의 활동만 뽑아 봅니다. 스크립트 끝에 이어 붙입니다.
// (2) indexed 인자로 필터링 — 노드가 걸러서 준다
const target = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' // Account #0
const mine = log.filters.Action(target)
const filtered = await log.queryFilter(mine, 0, 'latest')
console.log(`\n${target} 의 활동:`, filtered.length, '건')
for (const e of filtered) {
console.log(` ${e.args.kind} / ${e.args.value}`)
}Account #0 이 보낸 두 건(login, logout)만 나옵니다.
filters.Action(target) 이 가능한 이유는 actor 에 indexed 를 붙였기 때문입니다. indexed 인자는 로그의 검색 가능한 자리(topic)에 따로 저장되어, 노드가 직접 걸러서 필요한 것만 보내 줍니다. 반면 kind 는 indexed 가 아니므로 이런 식으로 필터를 걸 수 없고, 전부 받아 온 뒤 자바스크립트에서 걸러야 합니다.
5단계 — indexed 가 아닌 값으로 걸러 보기
차이를 직접 확인해 봅니다. 스크립트 끝에 붙입니다.
// kind 는 indexed 가 아니므로 노드에 필터를 맡길 수 없다.
// 전부 받아 온 뒤 자바스크립트에서 직접 걸러야 한다.
const logins = all.filter((e) => e.args.kind === 'login')
console.log('\nlogin 이벤트:', logins.length, '건')결과는 나옵니다. 하지만 방식이 다릅니다. 이번에는 모든 로그를 네트워크로 받아 온 뒤 클라이언트에서 걸렀습니다. 이벤트가 세 건일 때는 차이가 없지만, 수만 건이 쌓인 실제 컨트랙트에서는 이야기가 완전히 달라집니다.
indexed 를 어디에 붙일지는 이렇게 정합니다. 나중에 "누구의 것만" 또는 "어떤 대상의 것만" 골라 보게 될 인자에 붙이세요. 주소와 ID 가 거의 항상 그 대상이고, 금액이나 설명 문자열은 대개 아닙니다.