블록체인 학습
0 XP

Chapter 10: 이벤트

이벤트로 상태 변화 추적하기

이벤트로 상태 변화 추적하기

이벤트는 컨트랙트가 읽을 수 없습니다. 그렇다면 대체 누가 읽는 걸까요? 답은 체인 바깥입니다. 웹 페이지, 모바일 앱, 데이터 분석 서버가 이벤트를 읽습니다.

여러분이 써 본 모든 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 가 거의 항상 그 대상이고, 금액이나 설명 문자열은 대개 아닙니다.

시작 전 준비물

  • Hardhat 3 프로젝트와 실행 중인 로컬 노드
  • 터미널 창을 세 개 동시에 열 수 있는 환경
  • indexed 매개변수의 개념(Chapter 10)

진행 확인

0 / 5

각 단계를 직접 수행하고, 아래 ‘확인 기준’이 실제로 보이면 체크하세요. 모두 체크하면 완료할 수 있습니다.