Chapter 10: 이벤트
데이터 구조 컨트랙트 배포하기
시작 전 준비물
- Chapter 5 랩에서 만든 Hardhat 3 프로젝트
- `npx hardhat node` 로 로컬 노드를 띄울 수 있는 상태
- 배열·구조체·매핑 문법(Chapter 7~9)
진행 확인
0 / 5각 단계를 직접 수행하고, 아래 ‘확인 기준’이 실제로 보이면 체크하세요. 모두 체크하면 완료할 수 있습니다.
Chapter 7 부터 9 까지 배열, 구조체, 매핑을 배웠습니다. 브라우저에서는 문법이 맞는지까지만 확인했습니다. 하지만 이 자료구조들은 일반 프로그래밍 언어의 자료구조와 결정적으로 다른 성질이 있고, 그 차이는 실제 체인 위에 올려 봐야 드러납니다.
이번 랩에서는 셋을 한 컨트랙트에 모아 로컬 노드에 배포하고, 스크립트로 데이터를 넣어 가며 그 성질을 확인합니다. Chapter 5 의 랩에서 만든 Hardhat 프로젝트를 그대로 사용합니다. 새 프로젝트를 만들었다면 npx hardhat --init --template mocha-ethers 부터 다시 진행하세요.
contracts/UserRegistry.sol 을 만듭니다. 구조체로 사용자 정보를 묶고, 매핑으로 주소마다 찾아가고, 배열로 전체 목록을 관리하는 전형적인 조합입니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract UserRegistry {
// 구조체: 관련 있는 값을 하나로 묶는다
struct User {
string name;
uint256 age;
uint256 joinedAt;
}
// 매핑: 주소로 바로 찾아간다 (조회가 빠르다)
mapping(address => User) public users;
// 배열: 전체 목록을 순회할 수 있게 한다
address[] public userList;
event UserRegistered(address indexed account, string name);
function register(string calldata _name, uint256 _age) public {
users[msg.sender] = User({ name: _name, age: _age, joinedAt: block.timestamp });
userList.push(msg.sender);
emit UserRegistered(msg.sender, _name);
}
function totalUsers() public view returns (uint256) {
return userList.length;
}
}joinedAt 에 넣은 block.timestamp 는 이 트랜잭션이 담긴 블록의 시각을 초 단위 정수로 알려 주는
전역 변수입니다. 지금은 "등록한 시각을 기록한다" 정도로만 이해하고 넘어가세요. block 전역 변수는
Chapter 11 에서 자세히 다룹니다.
매핑과 배열을 함께 둔 이유가 중요합니다. 매핑은 주소 하나로 즉시 찾아갈 수 있지만, "등록된 사용자 전체" 를 훑을 방법이 없습니다. 매핑에는 길이도, 키 목록도 없기 때문입니다. 그래서 순회가 필요하면 배열을 따로 유지해야 합니다.
npx hardhat compile한 터미널에서 노드를 켭니다.
npx hardhat node다른 터미널에서 scripts/deployRegistry.js 를 만들고 배포합니다.
// scripts/deployRegistry.js
import { network } from 'hardhat'
const { ethers } = await network.getOrCreate()
const registry = await ethers.deployContract('UserRegistry')
await registry.waitForDeployment()
console.log('UserRegistry 주소:', await registry.getAddress())npx hardhat run scripts/deployRegistry.js --network localhost출력된 주소를 메모해 둡니다.
register 는 msg.sender 를 키로 쓰므로, 같은 계정으로 여러 번 부르면 덮어써질 뿐입니다. 사용자를 여럿 만들려면 계정을 바꿔 가며 호출해야 합니다.
scripts/fillRegistry.js 를 만듭니다.
// scripts/fillRegistry.js
import { network } from 'hardhat'
const { ethers } = await network.getOrCreate()
const ADDRESS = '여기에_배포된_주소'
const registry = await ethers.getContractAt('UserRegistry', ADDRESS)
// 노드가 출력했던 테스트 계정 20개를 그대로 가져온다
const signers = await ethers.getSigners()
const people
registry.connect(signer) 는 컨트랙트에 연결하는 계정을 바꾼 새 객체를 돌려줍니다. 이 계정이 트랜잭션에 서명하고, 그 주소가 컨트랙트 안에서 msg.sender 가 됩니다.
npx hardhat run scripts/fillRegistry.js --network localhost세 줄이 차례로 출력되며, 각각 다른 블록 번호를 갖습니다. 트랜잭션 하나가 블록 하나라는 사실이 여기서도 확인됩니다.
scripts/readRegistry.js 를 만듭니다.
// scripts/readRegistry.js
import { network } from 'hardhat'
const { ethers } = await network.getOrCreate()
const ADDRESS = '여기에_배포된_주소'
const registry = await ethers.getContractAt('UserRegistry', ADDRESS)
const signers = await ethers.getSigners()
// 등록한 계정
const found =
두 번째 출력이 이 랩에서 가장 중요한 장면입니다. 등록한 적 없는 주소를 조회했는데도 오류가 나지 않습니다. 대신 빈 문자열과 0n 이 돌아옵니다.
매핑은 "모든 가능한 키가 이미 기본값으로 채워져 있는 무한한 표" 처럼 동작합니다. 그래서 존재하지 않는 키란 없고, 없는 키를 물어보면 그 타입의 기본값을 돌려줍니다. 자바스크립트 객체가 없는 속성에 undefined 를 돌려주는 것과는 다릅니다. 여기서는 값이 없는 것과 값이 0 인 것을 구분할 수 없습니다.
이 성질 때문에 실제 컨트랙트에는 보통 bool exists 같은 필드를 따로 둡니다. 그것이 없으면 "가입한
적 없는 사람" 과 "나이를 0 으로 등록한 사람" 을 영영 구별할 수 없습니다.
스크립트 끝에 이어서 작성합니다.
const total = await registry.totalUsers()
console.log('총 사용자 수:', total)
for (let i = 0n; i < total; i++) {
const addr = await registry.userList(i)
const user = await registry.users(addr)
console.log(`${i
총 사용자 수: 3n 과 함께 세 명이 등록한 순서 그대로 출력됩니다. 매핑만으로는 절대 만들 수 없었던 목록입니다. 배열은 순서를 갖는 대신 조회가 느리고, 매핑은 조회가 빠른 대신 순서를 모릅니다. 그 트레이드오프를 이제 코드 두 줄의 차이로 확인한 셈입니다.