소개: Rust로 배우는 블록체인 개발
이 가이드북에 대하여
이 책은 Node.js/TypeScript 백엔드 개발자가 Rust를 배우고, 궁극적으로 블록체인 스마트 컨트랙트와 온체인 프로그램을 직접 작성할 수 있도록 안내하는 실전 교재입니다.
당신이 이미 알고 있는 것들—비동기 프로그래밍, 타입 시스템, 모듈화, 의존성 관리—을 출발점으로 삼아, Rust와 블록체인이 그것들을 어떻게 다르게 접근하는지 설명합니다. 프로그래밍 경험은 있다고 가정하지만, Rust와 블록체인은 처음이라고 가정합니다. 따라서 트랜잭션, 블록, 체인, 소유권, 빌림 같은 기본 단어도 처음 등장할 때 뜻을 짚고 넘어갑니다.
코드가 먼저 나오고 설명이 뒤따르는 방식으로 읽으면 초반에 쉽게 막힙니다. 이 책에서는 긴 예제를 보기 전에 “이 코드가 어떤 데이터를 다루는가”, “처음 보는 문법은 지금 완벽히 이해해야 하는가, 뒤에서 다시 배울 문법인가”를 먼저 표시합니다.
대상 독자
- Node.js 백엔드 개발 경력 3년 이상
- TypeScript, NestJS, 디자인 패턴에 익숙한 개발자
- Rust를 처음 배우거나, 한번 공부해봤지만 소유권과 빌림에서 막힌 개발자
- 블록체인 용어는 들어봤지만 트랜잭션, 블록, 합의, 가스가 정확히 무엇인지 아직 불명확한 개발자
- Ethereum Solidity, Solana Anchor, 또는 기타 블록체인 환경의 스마트 컨트랙트를 작성하고 싶은 개발자
먼저 읽을 것
본격적인 Rust 설치와 블록체인 설명으로 들어가기 전에 먼저 보는 용어와 코드 읽기 지도를 읽으세요. 이 장은 다음 두 가지를 미리 정리합니다.
- 트랜잭션, 블록, 체인, 노드, 지갑, 가스 같은 블록체인 기본 용어
fn,let mut,struct,impl,Vec<T>,Result<T, E>,?같은 Rust 코드 읽기 기호
본문을 읽다가 모르는 단어가 나오면 그 장으로 돌아가 확인하면 됩니다.
이 책의 구성
이 책은 Rust를 먼저 끝내고 나서 블록체인으로 넘어가는 방식이 아닙니다. 매주 Rust 문법과 블록체인 개념을 번갈아 배치합니다. 이유는 단순합니다. Rust만 연속으로 공부하면 소유권과 타입 문법에서 쉽게 지치고, 블록체인 이론만 연속으로 공부하면 실제 코드 감각이 늦게 붙습니다.
따라서 각 주차는 다음 리듬을 따릅니다.
Rust 문법을 하나 배운다
↓
그 문법이 필요한 블록체인 개념을 본다
↓
작은 코드 예제로 연결한다
↓
주차 말에 미니프로젝트로 묶는다
1주차: Rust 기초 + 블록체인 첫걸음
- Rust 설치, Cargo, 기본 코드 읽기
- 블록체인 기본 용어, 해시, 블록/체인 구조
- 소유권, 참조, 구조체, 열거형
- 미니 블록체인 구현
2주차: Rust 심화 + Ethereum/Solidity
- Result, ?, 트레이트, 제네릭
- 이더리움 계정/트랜잭션, EVM, 가스
- Solidity와 Foundry
- Token Vault 구현
3주차: 비동기 Rust + Solana + 컨트랙트 심화
- 컬렉션, 이터레이터, async/Tokio
- 컨트랙트 보안과 업그레이드
- Solana 계정 모델과 Anchor
- Solana 포인트 시스템 구현
4주차: 실무 통합 + Platform 프로젝트
- Rust에서 Ethereum 연동(Alloy)
- 프라이빗 체인과 Besu
- Mini Trace 서비스
- Platform 프로젝트 코드 리딩
현실적인 기대치
1달 안에 가능한 것
- Rust 문법과 소유권 시스템 이해
- 표준 라이브러리 주요 타입 활용 (Vec, HashMap, String, Result, Option)
- 간단한 CLI 툴 작성
- 기본적인 비동기 프로그램 작성 (Tokio 사용)
- SHA-256 해싱, 간단한 P2P 로직 등 블록체인 기초 개념 구현
- Rust로 작성된 오픈소스 코드를 읽고 이해
1달 안에 어려운 것
- Solana 온체인 프로그램(Anchor) 실전 배포
- 복잡한 수명(lifetime) 어노테이션이 있는 코드 작성
- 고급 매크로(proc-macro) 작성
- 멀티스레드 성능 최적화
- 실제 프로덕션 블록체인 노드 개발
현실적인 조언: Rust는 배움의 곡선이 가파릅니다. 특히 소유권과 빌림 검사기(borrow checker)는 처음에 매우 답답하게 느껴집니다. 이건 당신이 나쁜 개발자여서가 아닙니다—Rust를 만든 사람들도 배울 때 똑같이 느꼈습니다. 빌림 검사기와 싸우지 말고, 그것이 왜 그렇게 동작하는지 이해하려고 노력하세요.
4주 학습 일정표
1주차: Rust 기초 + 블록체인 첫걸음
| 날짜 | 내용 | 목표 |
|---|---|---|
| 1일 | 용어 지도, 환경설정, Hello Cargo | Rust 코드와 블록체인 용어를 읽을 준비 |
| 2일 | 블록체인이란 무엇인가, 해시 함수 | 트랜잭션/블록/체인/해시의 관계 이해 |
| 3일 | 소유권 규칙, Move vs Copy | Rust가 값을 옮기고 해제하는 방식 이해 |
| 4일 | 참조, 빌림, 슬라이스 | 데이터를 복사하지 않고 읽고 수정하는 패턴 익히기 |
| 5일 | 구조체, 열거형, 패턴 매칭 | Transaction, Block, Blockchain을 표현할 타입 준비 |
| 6일 | 블록과 체인 구조, 합의 알고리즘 | Rust 타입으로 표현할 블록체인 구조 이해 |
| 7일 | 미니 블록체인 프로젝트 | 해시, 블록 연결, 검증 흐름을 코드로 묶기 |
1주차 프로젝트: Rust로 미니 블록체인 구현
2주차: Rust 심화 + 이더리움/Solidity
| 날짜 | 내용 | 목표 |
|---|---|---|
| 8일 | panic!, Result<T, E>, ? | 실패를 타입으로 다루는 Rust 방식 이해 |
| 9일 | 이더리움 계정과 트랜잭션 | EOA/CA, nonce, 수수료 모델 이해 |
| 10일 | EVM, 가스, 스마트 컨트랙트 개요 | 온체인 실행 비용과 컨트랙트 실행 흐름 이해 |
| 11일 | 제네릭과 트레이트 | 공통 동작을 타입 안전하게 추상화 |
| 12일 | Solidity 타입, 함수, modifier | Rust/TypeScript와 비교하며 Solidity 문법 익히기 |
| 13일 | Foundry, ERC-20, ERC-721 | 테스트 가능한 스마트 컨트랙트 개발 흐름 익히기 |
| 14일 | Token Vault 프로젝트 | 에러 처리, 권한, 토큰 전송을 프로젝트로 묶기 |
2주차 프로젝트: Token Vault
3주차: 비동기 Rust + Solana + 컨트랙트 심화
| 날짜 | 내용 | 목표 |
|---|---|---|
| 15일 | Vec<T>, String, HashMap, 이터레이터 | 트랜잭션/계정 목록을 다루는 자료구조 감각 익히기 |
| 16일 | 컨트랙트 상속, 프록시, 보안 | Solidity 실무 위험 요소 이해 |
| 17일 | 클로저, async/await, Future | 비동기 흐름을 Rust 타입으로 이해 |
| 18일 | Tokio, channel, 공유 상태 | 노드/인덱서/백엔드식 동시 처리 패턴 익히기 |
| 19일 | Solana 아키텍처와 계정 모델 | 이더리움과 다른 상태 모델 이해 |
| 20일 | Solana 프로그램, PDA, CPI, Anchor | Anchor 코드 구조와 계정 검증 이해 |
| 21일 | Solana 포인트 시스템 프로젝트 | Anchor 프로그램과 TypeScript 테스트 연결 |
3주차 프로젝트: Solana 토큰/포인트 프로그램
4주차: 실무 통합 + Platform 프로젝트
| 날짜 | 내용 | 목표 |
|---|---|---|
| 22일 | Alloy provider와 컨트랙트 호출 | Rust 백엔드에서 이더리움 읽기 |
| 23일 | 트랜잭션 서명과 전송 | Rust에서 온체인 쓰기 수행 |
| 24일 | sol! 매크로와 ABI | Solidity ABI를 Rust 타입으로 연결 |
| 25일 | 프라이빗 체인과 Besu | 엔터프라이즈 체인 선택 기준 이해 |
| 26일 | Mini Trace 프로젝트 | DB와 체인 해시 기록을 연결 |
| 27일 | Platform 서비스 아키텍처 | Rust/Axum/NestJS 대응 구조 읽기 |
| 28일 | Platform 블록체인 연동 흐름 | 실무 코드 리딩으로 전체 흐름 정리 |
4주차 프로젝트: Mini Trace + Platform 분석
환경 설정
1. Rust 설치 (rustup)
rustup은 Rust의 버전 관리자입니다. Node.js의 nvm과 같은 역할입니다.
macOS / Linux:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
설치 중 옵션을 물어보면 1) Proceed with installation (default) 선택.
설치 후 쉘을 재시작하거나:
source "$HOME/.cargo/env"
Windows:
https://rustup.rs 에서 rustup-init.exe 다운로드 후 실행.
Visual Studio C++ Build Tools 설치가 필요할 수 있습니다.
설치 확인:
rustc --version # rustc 1.75.0 (82e1608df 2023-12-21) 같은 출력
cargo --version # cargo 1.75.0 (1d8b05cdd 2023-11-20) 같은 출력
rustup --version # rustup 1.26.0 (5af9b9484 2023-04-05) 같은 출력
안정 채널로 업데이트:
rustup update stable
2. Rust 컴포넌트 추가
# 코드 포매터 (prettier 같은 것)
rustup component add rustfmt
# 린터 (eslint 같은 것)
rustup component add clippy
3. VS Code 설정
필수 확장:
-
rust-analyzer (rustlang.rust-analyzer)
- 자동완성, 타입 힌트, 에러 표시, Go to definition
- 반드시 설치해야 합니다
-
Even Better TOML (tamasfe.even-better-toml)
- Cargo.toml 파일 하이라이팅
-
crates (serayuzgur.crates)
- Cargo.toml에서 크레이트 버전을 인라인으로 보여줌
VS Code settings.json 추천 설정:
{
"rust-analyzer.checkOnSave.command": "clippy",
"rust-analyzer.inlayHints.typeHints.enable": true,
"rust-analyzer.inlayHints.parameterHints.enable": true,
"editor.formatOnSave": true,
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
}
}
4. Foundry 설치 (Ethereum 스마트 컨트랙트 도구)
Foundry는 Ethereum 스마트 컨트랙트 개발 툴킷입니다 (Hardhat/Truffle의 Rust 버전).
curl -L https://foundry.paradigm.xyz | bash
foundryup
설치 확인:
forge --version # forge 0.2.0 같은 출력
cast --version
anvil --version
5. Solana CLI 설치 (Solana 프로그램 개발)
sh -c "$(curl -sSfL https://release.solana.com/stable/install)"
PATH 추가 (.zshrc 또는 .bashrc에):
export PATH="$HOME/.local/share/solana/install/active_release/bin:$PATH"
설치 확인:
solana --version # solana-cli 1.18.x 같은 출력
로컬 테스트 네트워크 설정:
solana config set --url localhost
solana-test-validator # 로컬 validator 실행 (별도 터미널)
6. Anchor CLI 설치 (Solana 스마트 컨트랙트 프레임워크)
Anchor는 Solana의 NestJS 같은 프레임워크입니다.
# avm (Anchor Version Manager) 설치
cargo install --git https://github.com/coral-xyz/anchor avm --locked --force
avm install latest
avm use latest
anchor --version
첫 번째 Rust 프로그램 확인
모든 환경이 갖춰졌는지 확인하는 간단한 테스트:
cargo new hello-blockchain
cd hello-blockchain
cargo run
출력:
Compiling hello-blockchain v0.1.0
Finished dev [unoptimized + debuginfo] target(s) in 0.50s
Running `target/debug/hello-blockchain`
Hello, world!
이 출력이 나오면 준비 완료입니다. 다음 챕터로 넘어가세요.
먼저 보는 용어와 코드 읽기 지도
이 책은 프로그래밍 경험은 있다고 가정하지만, Rust와 블록체인은 처음이라고 가정합니다. 앞으로 나올 코드는 바로 실행 가능한 예제를 목표로 하지만, 처음 보는 문법과 용어가 한꺼번에 나오면 구조를 이해하기 어렵습니다.
이 장은 본문을 읽기 위한 지도입니다. 세부 구현은 뒤에서 다시 배우므로, 여기서는 단어의 뜻과 코드의 모양만 먼저 잡으세요.
1. 블록체인 핵심 용어
트랜잭션(Transaction)
트랜잭션은 블록체인에 기록해 달라고 네트워크에 제출하는 요청입니다.
백엔드 개발자의 관점으로 보면 트랜잭션은 다음에 가깝습니다.
HTTP POST /transfers
Body: {
"from": "Alice",
"to": "Bob",
"amount": "1 ETH"
}
다만 일반 API 요청과 다른 점이 있습니다.
| 일반 API 요청 | 블록체인 트랜잭션 |
|---|---|
| 서버가 인증 세션이나 JWT를 확인 | 개인 키 서명을 확인 |
| DB에 바로 반영될 수 있음 | 블록에 포함되어야 반영 |
| 관리자가 롤백할 수 있음 | 확정 후 되돌리기 어려움 |
| 서버 시간이 기준 | 네트워크 합의가 기준 |
예를 들어 “Alice가 Bob에게 1 ETH를 보낸다”는 말은 실제로는 “Alice가 개인 키로 서명한 트랜잭션을 네트워크에 제출했고, 검증자가 그 트랜잭션을 블록에 넣었다”는 뜻입니다.
블록(Block)
블록은 여러 트랜잭션을 묶은 기록 묶음입니다.
블록 #1024
├─ 이전 블록 해시: 0xabc...
├─ 생성 시각: 2026-04-14 10:00:00
├─ 트랜잭션 목록
│ ├─ Alice -> Bob: 1 ETH
│ ├─ Carol -> Dave: 0.2 ETH
│ └─ Token.transfer(...)
└─ 이 블록의 해시: 0xdef...
일반 데이터베이스에 비유하면 블록은 transactions 테이블의 여러 row를 일정 시간마다 묶어 만든 변경 로그 배치에 가깝습니다. 차이는 이 배치가 이전 배치의 해시를 들고 있어서, 중간 기록을 바꾸면 뒤쪽 기록이 모두 어긋난다는 점입니다.
체인(Chain)
체인은 블록들이 이전 블록의 해시로 연결된 구조입니다.
블록 #0(제네시스) -> 블록 #1 -> 블록 #2 -> 블록 #3
각 블록은 previous_hash라는 필드에 바로 앞 블록의 해시를 저장합니다. 그래서 블록 #1의 내용을 바꾸면 블록 #1의 해시가 바뀌고, 블록 #2가 들고 있던 previous_hash와 맞지 않게 됩니다. 이 연결 구조 때문에 과거 데이터 변조가 어렵습니다.
제네시스 블록(Genesis Block)
제네시스 블록은 체인의 첫 번째 블록입니다. 앞 블록이 없으므로 previous_hash를 0000... 같은 특수 값으로 둡니다.
제네시스 블록 = 체인의 시작점
해시(Hash)
해시는 데이터를 고정 길이 지문으로 바꾸는 함수의 결과입니다.
SHA256("hello") = 2cf24dba5fb0a30e...
SHA256("hellO") = 185f8db32921bd46...
입력이 한 글자만 달라도 출력이 완전히 달라집니다. 블록체인은 이 성질을 이용해 “기록이 바뀌었는가?”를 빠르게 확인합니다.
노드(Node)
노드는 블록체인 네트워크에 참여하는 컴퓨터 프로그램입니다.
노드는 보통 다음 일을 합니다.
- 다른 노드와 연결한다
- 트랜잭션을 전달받고 검증한다
- 블록을 전달받고 검증한다
- 자기 로컬 디스크에 체인 데이터를 저장한다
- 경우에 따라 새 블록 생성에 참여한다
Node.js 서버 하나가 HTTP 요청을 처리하듯, 블록체인 노드는 P2P 네트워크 요청을 처리합니다.
합의(Consensus)
합의는 여러 노드가 어떤 블록을 정식 기록으로 인정할지 결정하는 규칙입니다.
중앙 DB에서는 주 서버가 쓰기를 결정합니다. 블록체인에서는 중앙 주인이 없기 때문에, 네트워크 참여자들이 같은 규칙으로 블록을 검증하고 같은 체인을 선택해야 합니다.
대표적인 합의 방식은 다음과 같습니다.
| 방식 | 직관적 설명 | 예시 |
|---|---|---|
| Proof of Work | 계산 문제를 먼저 푼 노드가 블록 제안 | Bitcoin |
| Proof of Stake | 예치한 지분을 기반으로 검증자 선정 | Ethereum |
| Proof of Authority | 허가된 검증자들이 순번/투표로 블록 확정 | Besu private chain |
멤풀(Mempool)
멤풀은 아직 블록에 포함되지 않은 트랜잭션 대기열입니다.
백엔드의 작업 큐와 비슷합니다.
사용자 트랜잭션 제출
↓
멤풀에서 대기
↓
검증자/채굴자가 선택
↓
블록에 포함
이더리움에서는 가스비를 더 많이 내는 트랜잭션이 더 빨리 선택될 가능성이 큽니다.
지갑(Wallet), 주소(Address), 개인 키(Private Key)
지갑은 코인을 “보관하는 앱”처럼 보이지만, 정확히는 개인 키를 관리하고 트랜잭션에 서명하는 도구입니다.
| 용어 | 뜻 |
|---|---|
| 개인 키 | 트랜잭션에 서명할 수 있는 비밀값 |
| 공개 키 | 개인 키에서 계산되는 공개 가능한 값 |
| 주소 | 공개 키에서 파생된 짧은 식별자 |
| 지갑 | 개인 키를 보관하고 서명하는 앱 또는 라이브러리 |
중요한 점은 코인이 지갑 앱 안에 들어 있는 것이 아니라, 체인 상태에 “이 주소가 얼마를 가진다”라고 기록되어 있다는 것입니다.
스마트 컨트랙트(Smart Contract)
스마트 컨트랙트는 블록체인 위에 배포된 프로그램입니다.
일반 백엔드 코드와 비교하면 다음과 같습니다.
| 백엔드 서비스 | 스마트 컨트랙트 |
|---|---|
| 서버에 배포 | 블록체인에 배포 |
| DB 상태를 변경 | 온체인 상태를 변경 |
| 관리자가 핫픽스 가능 | 배포 후 수정 어려움 |
| 서버 비용 지불 | 사용자 또는 호출자가 가스비 지불 |
ERC-20 토큰, NFT, DEX, DAO 같은 애플리케이션은 스마트 컨트랙트로 구현됩니다.
가스(Gas)
가스는 블록체인에서 계산과 저장에 매기는 실행 비용 단위입니다.
이더리움에서는 컨트랙트 함수 호출, ETH 전송, 스토리지 변경 모두 가스를 소비합니다. 가스는 무한 루프와 네트워크 남용을 막고, 검증자에게 처리 보상을 줍니다.
2. Rust 코드 읽기 지도
처음에는 Rust 코드를 모두 이해하려고 하지 않아도 됩니다. 아래 기호만 먼저 알아두면 본문 예제를 훨씬 덜 낯설게 읽을 수 있습니다.
fn main()
fn main() {
println!("Hello, world!");
}
fn은 함수를 정의한다는 뜻입니다. main은 실행 파일의 시작점입니다. TypeScript의 main() 함수나 Node.js 파일의 최상위 실행 코드와 비슷합니다.
println!
println!("Block height: {}", 100);
println!은 콘솔에 출력하는 매크로입니다. !가 붙으면 일반 함수가 아니라 **매크로(macro)**입니다. 처음에는 console.log와 비슷하다고 생각해도 됩니다.
let과 mut
#![allow(unused)]
fn main() {
let height = 100;
let mut nonce = 0;
nonce = nonce + 1;
}
let은 변수를 만듭니다. Rust 변수는 기본적으로 불변입니다. 값을 바꾸려면 mut를 붙입니다.
| TypeScript | Rust |
|---|---|
const height = 100 | let height = 100 |
let nonce = 0; nonce += 1 | let mut nonce = 0; nonce += 1 |
타입 표기 : u64
#![allow(unused)]
fn main() {
let block_height: u64 = 1024;
}
콜론 뒤는 타입입니다. u64는 “부호 없는 64비트 정수”입니다. 블록 높이, 잔액, nonce처럼 음수가 될 수 없는 큰 숫자에 자주 씁니다.
String과 &str
#![allow(unused)]
fn main() {
let owned: String = String::from("Alice");
let borrowed: &str = "Bob";
}
둘 다 문자열처럼 보이지만 역할이 다릅니다.
| 타입 | 의미 | 비유 |
|---|---|---|
String | 내가 소유한 가변 문자열 | 직접 들고 있는 데이터 |
&str | 어딘가에 있는 문자열을 빌려 읽는 참조 | 읽기 전용 view |
자세한 규칙은 소유권 장에서 배웁니다. 처음에는 String은 소유, &str은 빌림이라고 기억하세요.
struct
#![allow(unused)]
fn main() {
struct Block {
index: u64,
hash: String,
}
}
struct는 데이터를 묶는 타입입니다. TypeScript의 interface나 DTO와 비슷하지만, Rust에서는 메서드를 struct 안에 직접 넣지 않고 impl 블록에 따로 둡니다.
impl과 Self
impl Block {
fn new(index: u64, hash: String) -> Self {
Self { index, hash }
}
}
impl Block은 Block 타입에 함수를 붙이는 영역입니다. Self는 현재 타입, 여기서는 Block을 뜻합니다.
Vec<T>
let blocks: Vec<Block> = Vec::new();
Vec<T>는 Rust의 가변 길이 배열입니다. TypeScript의 T[]와 가장 비슷합니다.
| TypeScript | Rust |
|---|---|
Block[] | Vec<Block> |
blocks.push(block) | blocks.push(block) |
참조 &
#![allow(unused)]
fn main() {
fn print_hash(hash: &String) {
println!("{}", hash);
}
}
&String은 String을 소유하지 않고 빌려 읽겠다는 뜻입니다. TypeScript에서는 객체 참조 전달이 자연스럽지만, Rust는 소유권 이동과 빌림을 문법으로 구분합니다.
Result<T, E>와 ?
#![allow(unused)]
fn main() {
fn parse_height(input: &str) -> Result<u64, std::num::ParseIntError> {
let height = input.parse::<u64>()?;
Ok(height)
}
}
Result<T, E>는 성공하면 Ok(T), 실패하면 Err(E)를 담는 타입입니다. ?는 실패(Err)가 나오면 즉시 바깥 함수로 돌려보냅니다.
TypeScript의 throw/try-catch와 목적은 비슷하지만, Rust에서는 에러가 반환 타입에 드러납니다.
#[derive(...)]
#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
struct Transaction {
from: String,
to: String,
}
}
#[derive(...)]는 컴파일러에게 반복 코드를 자동 생성해 달라고 요청하는 속성입니다. 예를 들어 Debug는 println!("{:?}", value)로 출력할 수 있게 해주고, Clone은 명시적 복사를 가능하게 합니다.
3. 이 책의 코드 읽기 순서
처음 보는 긴 예제가 나오면 아래 순서로 읽으세요.
struct부터 찾습니다. 어떤 데이터를 다루는지 먼저 봅니다.impl을 봅니다. 그 데이터에 어떤 동작이 붙는지 봅니다.main을 봅니다. 실제 실행 흐름을 봅니다.Result,?,unwrap은 에러 처리 장에서 자세히 배울 문법이라고 표시만 해둡니다.&,clone,String,Vec은 소유권/컬렉션 장에서 다시 배울 문법이라고 표시만 해둡니다.
블록체인 예제도 마찬가지입니다.
데이터 구조 확인: Block, Transaction, Blockchain
↓
동작 확인: new, calculate_hash, mine, validate
↓
실행 흐름 확인: main에서 블록 생성, 추가, 검증
코드를 처음부터 한 줄씩 완벽히 이해하려고 하면 어렵습니다. 이 책에서는 먼저 “이 코드가 어떤 역할을 하는가”를 잡고, 문법은 각 장에서 반복해서 풀어갑니다.
4. 앞으로 자주 만날 단어 한 줄 요약
| 단어 | 한 줄 뜻 |
|---|---|
| 트랜잭션 | 체인 상태를 바꾸기 위해 서명해서 제출하는 요청 |
| 블록 | 트랜잭션 여러 개와 메타데이터를 묶은 기록 단위 |
| 체인 | 이전 블록 해시로 연결된 블록 목록 |
| 해시 | 데이터의 고정 길이 지문 |
| 노드 | 블록체인 네트워크에 참여하는 컴퓨터 프로그램 |
| 합의 | 여러 노드가 같은 기록을 정식으로 인정하는 규칙 |
| 멤풀 | 아직 블록에 들어가지 않은 트랜잭션 대기열 |
| 지갑 | 개인 키를 관리하고 트랜잭션에 서명하는 도구 |
| 주소 | 체인에서 계정이나 컨트랙트를 식별하는 값 |
| 스마트 컨트랙트 | 블록체인 위에서 실행되는 프로그램 |
| 가스 | 온체인 실행 비용 단위 |
| nonce | 한 번만 쓰는 숫자. 순서 보장이나 채굴 탐색에 사용 |
다음 장부터는 이 단어들이 반복해서 나옵니다. 헷갈리면 이 장으로 돌아와 먼저 뜻을 확인하세요.
1장: Rust 시작하기
Rust란 무엇인가?
Rust는 Mozilla Research에서 시작해 현재 Rust Foundation이 관리하는 시스템 프로그래밍 언어입니다. 2015년에 1.0이 출시되었고, 2016년부터 매년 Stack Overflow 설문에서 “가장 사랑받는 언어” 1위를 차지하고 있습니다 (2024년까지 9년 연속).
처음 읽을 때는 Rust를 “Node.js보다 빠른 언어” 정도로만 이해하면 부족합니다. Rust의 핵심 목표는 빠른 실행, 메모리 안전성, 동시성 안전성을 동시에 얻는 것입니다. 이 세 가지는 블록체인처럼 돈과 합의가 걸린 시스템에서 특히 중요합니다.
핵심 특징 세 가지:
- 메모리 안전성 — 가비지 컬렉터 없이도 메모리 오류(null dereference, use-after-free, buffer overflow)를 컴파일 타임에 방지
- 제로 비용 추상화 — 고수준 추상화를 써도 런타임 오버헤드가 없음 (C/C++ 수준 성능)
- 두려움 없는 동시성 — 데이터 레이스를 컴파일 타임에 방지
이 장에서 먼저 알아둘 블록체인 단어
이 장은 Rust 소개 장이지만 블록체인 예시가 함께 나옵니다. 다음 단어만 먼저 잡고 넘어가세요.
| 단어 | 지금은 이렇게 이해하세요 |
|---|---|
| 트랜잭션(Transaction) | 체인 상태를 바꾸기 위해 네트워크에 제출하는 서명된 요청 |
| 노드(Node) | 블록체인 네트워크에 참여해 트랜잭션과 블록을 검증하는 프로그램 |
| 검증자(Validator) | 새 블록을 제안하거나 검증하는 역할을 맡은 노드 |
| 합의(Consensus) | 여러 노드가 같은 블록을 정식 기록으로 인정하는 규칙 |
| 온체인 프로그램(On-chain Program) | Solana에서 블록체인 위에 배포되어 실행되는 프로그램 |
자세한 설명은 먼저 보는 용어와 코드 읽기 지도와 9장에서 다시 다룹니다.
왜 블록체인에서 Rust를 쓰는가?
성능이 곧 돈이다
블록체인 노드는 초당 수천~수만 건의 트랜잭션을 처리해야 합니다. 여기서 트랜잭션은 HTTP 요청처럼 “무언가를 처리해 달라”는 요청이지만, 개인 키 서명과 네트워크 검증을 거쳐 블록에 포함되어야 한다는 점이 다릅니다.
가비지 컬렉터가 있는 언어(Go, Java, Node.js)는 GC 일시 중지(stop-the-world pause)가 발생할 수 있는데, 블록체인 컨텍스트에서 이는 예측 불가능한 레이턴시를 만듭니다.
Rust는 GC가 없으므로 예측 가능한 성능을 보장합니다.
보안이 치명적이다
스마트 컨트랙트의 버그는 돌이킬 수 없는 금전적 손실로 이어집니다. 2016년 The DAO 해킹($60M), 2022년 Wormhole 해킹($320M) 등 역사적 사례들은 대부분 메모리 오류나 로직 버그에서 비롯되었습니다.
Rust의 타입 시스템과 빌림 검사기는 이런 종류의 버그를 원천 차단합니다.
주요 블록체인 프로젝트들의 선택
| 프로젝트 | Rust를 선택한 이유 |
|---|---|
| Solana | 초당 수만 건의 트랜잭션을 병렬 처리(Sealevel)하는 검증자 코드에 GC 일시정지가 허용되지 않음. 합의 임계 경로(PoH 해시 체인)의 예측 가능한 레이턴시가 필수. 스마트 컨트랙트(Program)를 WASM이 아닌 네이티브 BPF 바이트코드로 컴파일해 최대 성능 확보 |
| Near Protocol | 스마트 컨트랙트를 Rust → WASM으로 컴파일하여 샌드박스 실행. 금융 코드에서 정수 오버플로우·버퍼 오버플로우를 컴파일 타임에 방지. 결정론적 WASM 실행으로 모든 노드가 동일한 결과를 보장 |
| Polkadot / Substrate | 런타임(팔렛)을 Rust → WASM으로 컴파일하여 네트워크를 중단하지 않고 온체인 업그레이드(forkless upgrade) 가능. GC 없는 결정론적 실행으로 블록 생산 중 레이턴시 스파이크 방지. 강력한 타입 시스템으로 크로스체인 메시지(XCM) 포맷 오류를 런타임 전에 차단 |
| Reth (Ethereum 실행 클라이언트) | Go 기반 Geth와 성능으로 경쟁하면서 합의 임계 코드의 메모리 안전성 확보. tokio 비동기 런타임으로 수천 개의 P2P 피어 연결을 효율적으로 처리. 병렬 블록 처리(parallel block execution)를 데이터 레이스 없이 구현 |
| Lighthouse (Ethereum 합의 클라이언트) | PoS 검증자 서명과 슬래싱 방지 로직에서 메모리 오류는 스테이킹 자산 손실로 이어짐. Rust의 빌림 검사기가 이중 서명(double-vote) 버그 유발 가능한 공유 가변 상태를 원천 차단 |
| Bitcoin Dev Kit (BDK) | 개인키·UTXO 처리 코드에서 use-after-free·버퍼 오버플로우는 자산 도난으로 직결. 외부 라이브러리로 임베딩될 때 GC 없이 예측 가능한 메모리 사용량 유지 |
| Aptos / Sui | Move VM 자체를 Rust로 구현하여 VM 인터프리터 버그가 체인 자산에 영향을 미치지 않도록 안전성 확보. Move의 자원(Resource) 타입 시스템과 Rust의 소유권 모델이 개념적으로 일치하여 VM 구현이 자연스러움 |
특히 Solana는 모든 온체인 프로그램을 Rust로 작성합니다. Anchor 프레임워크를 사용하면 보일러플레이트가 줄어들지만, 기본은 여전히 Rust입니다.
Node.js 개발자 관점에서 보는 Rust
유사한 개념들
| Node.js/TypeScript | Rust | 비고 |
|---|---|---|
npm / package.json | cargo / Cargo.toml | 패키지 관리자 + 빌드 툴 |
node_modules/ | ~/.cargo/registry | 의존성 저장소 (글로벌 캐시) |
npm install | cargo add 또는 Cargo.toml 수정 | 의존성 추가 |
npm run build | cargo build | 빌드 |
npm test | cargo test | 테스트 실행 |
npx ts-node src/main.ts | cargo run | 실행 |
interface | trait | 타입 계약 (차이점 있음) |
class | struct + impl | 데이터와 메서드 묶음 |
enum (문자열 유니온) | enum | 대수적 데이터 타입 (훨씬 강력) |
Promise<T> | Future<Output = T> | 비동기 계산 |
async/await | async fn + .await | 비동기 문법 |
try/catch | Result<T, E> | 에러 처리 (패턴이 다름) |
null / undefined | Option<T> | 없는 값 표현 |
이 표는 “완전히 같다”는 뜻이 아닙니다. 첫 독해용 대응표입니다. 예를 들어 trait는 TypeScript interface처럼 타입 계약을 표현하지만, 구현 방식과 제네릭 제약에서 차이가 큽니다. 각 개념은 해당 장에서 다시 풀어갑니다.
근본적으로 다른 개념들
1. 가비지 컬렉터 없음
Node.js는 V8 엔진이 알아서 메모리를 회수합니다. 개발자는 메모리를 신경 쓸 필요가 없었습니다.
Rust는 소유권(ownership) 시스템으로 컴파일 타임에 메모리 해제 시점을 결정합니다. 런타임에 아무런 GC 비용이 없습니다.
2. 예외(exception)가 없음
Node.js는 throw와 try/catch를 씁니다. Rust는 Result<T, E>를 반환하며 에러를 값으로 처리합니다.
3. null이 없음
TypeScript에서 null과 undefined는 악명 높은 버그의 원천입니다. Rust는 Option<T> 타입으로 값이 없는 상황을 명시적으로 표현하며, 처리하지 않으면 컴파일 에러가 납니다.
4. 암묵적 복사가 없음
TypeScript에서 객체를 함수에 전달하면 참조가 공유됩니다. Rust에서 값을 전달하면 기본적으로 소유권이 이동(move)합니다. 이게 처음에 가장 어색한 부분입니다.
Rust 컴파일러와 친해지기
Rust의 컴파일러 rustc는 매우 친절합니다. 에러 메시지가 구체적이고, 종종 수정 방법까지 제안합니다.
error[E0382]: borrow of moved value: `s`
--> src/main.rs:5:20
|
3 | let s = String::from("hello");
| - move occurs because `s` has type `String`
4 | takes_ownership(s);
| - value moved here
5 | println!("{}", s); // 에러!
| ^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
|
4 | takes_ownership(s.clone());
| ++++++++
컴파일러가 에러를 설명하고, 해결책까지 제시합니다. 이 에러 메시지를 읽는 능력이 Rust 학습의 핵심입니다.
요약
Rust를 배우는 이유:
- 블록체인의 주요 플랫폼(Solana, Near, Polkadot)이 Rust를 사용
- 메모리 안전성으로 스마트 컨트랙트 버그 방지
- GC 없는 예측 가능한 고성능
- 강력한 타입 시스템으로 런타임 에러를 컴파일 타임에 잡기
다음 챕터에서 실제로 Rust를 설치하고 첫 프로젝트를 만들어봅시다.
1.1 설치와 개발 환경 구성
rustup: Rust 버전 관리자
rustup은 Node.js의 nvm과 동일한 역할을 합니다. Rust 컴파일러(rustc)와 패키지 관리자(cargo)를 설치하고 버전을 관리합니다.
macOS / Linux 설치
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
설치 스크립트가 실행되면 다음 메뉴가 나타납니다:
Current installation options:
default host triple: x86_64-apple-darwin
default toolchain: stable (default)
profile: default
modify PATH variable: yes
1) Proceed with installation (default)
2) Customize installation
3) Cancel installation
그냥 1을 누르고 Enter. 설치가 완료되면:
# 현재 쉘에 환경변수 적용 (설치 직후 한 번만)
source "$HOME/.cargo/env"
# 또는 터미널을 재시작
Windows 설치
- https://rustup.rs 접속
rustup-init.exe다운로드 및 실행- Visual Studio C++ Build Tools가 없으면 설치 요청이 뜸 → 설치
Note: Windows에서는 WSL2 (Windows Subsystem for Linux) 환경에서 개발하는 것을 강력히 권장합니다. Solana CLI 등 일부 도구가 WSL2에서만 정상 작동합니다.
설치 확인
rustc --version
# rustc 1.75.0 (82e1608df 2023-12-21)
cargo --version
# cargo 1.75.0 (1d8b05cdd 2023-11-20)
rustup --version
# rustup 1.26.0 (5af9b9484 2023-04-05)
구성 요소 설명
rustup이 설치하는 것들:
- rustc: Rust 컴파일러 (tsc에 해당)
- cargo: 빌드 시스템 + 패키지 관리자 (npm + webpack 합친 것)
- rustup: 버전 관리자 (nvm에 해당)
- std: 표준 라이브러리
추가 컴포넌트 설치
# rustfmt: 코드 포매터 (prettier에 해당)
rustup component add rustfmt
# clippy: 린터 (eslint에 해당)
rustup component add clippy
# rust-src: rust-analyzer가 표준 라이브러리 소스를 볼 수 있게
rustup component add rust-src
릴리스 채널
Rust는 세 가지 채널이 있습니다:
# stable: 프로덕션용 (기본값)
rustup toolchain install stable
# beta: 다음 stable의 RC 버전
rustup toolchain install beta
# nightly: 최신 기능 (일부 실험적 기능 필요시)
rustup toolchain install nightly
# 특정 버전 설치
rustup toolchain install 1.70.0
# 현재 사용 채널 확인
rustup show
블록체인 개발(특히 Solana Anchor)에서는 특정 nightly 버전이 필요한 경우가 있습니다. Anchor 프로젝트는 rust-toolchain.toml 파일로 버전을 고정합니다.
VS Code 설정
필수 확장 설치
방법 1: VS Code 내에서
Cmd+Shift+X(맥) 또는Ctrl+Shift+X(윈도우)rust-analyzer검색 → 설치
방법 2: 커맨드라인
code --install-extension rust-lang.rust-analyzer
code --install-extension tamasfe.even-better-toml
code --install-extension serayuzgur.crates
code --install-extension vadimcn.vscode-lldb # 디버거
rust-analyzer 설정
~/.config/Code/User/settings.json (또는 VS Code의 settings.json):
{
// 저장 시 clippy로 검사 (rustfmt만 하는 기본값보다 더 많은 경고)
"rust-analyzer.check.command": "clippy",
// 인라인 타입 힌트 표시 (매우 유용!)
"rust-analyzer.inlayHints.typeHints.enable": true,
"rust-analyzer.inlayHints.parameterHints.enable": true,
"rust-analyzer.inlayHints.chainingHints.enable": true,
// 저장 시 자동 포맷
"editor.formatOnSave": true,
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
},
// 파일 저장 시 자동 import 정리
"rust-analyzer.imports.granularity.group": "module"
}
rust-analyzer가 제공하는 기능
- 자동완성: 메서드, 필드, 트레이트 메서드 자동완성
- 타입 힌트: 변수 옆에 추론된 타입을 표시
- Go to Definition:
F12또는Cmd+클릭 - Find References:
Shift+F12 - Rename Symbol:
F2 - 코드 액션: 💡 아이콘 클릭으로 빠른 수정
- 에러 표시: 컴파일 에러를 실시간으로 에디터에 표시
Cargo 기본 명령어
새 프로젝트 생성
# 바이너리 프로젝트 (실행 가능한 프로그램)
cargo new my-project
# 또는
cargo new my-project --bin
# 라이브러리 프로젝트 (다른 프로젝트에서 가져다 쓰는 크레이트)
cargo new my-lib --lib
# 현재 디렉토리를 프로젝트로 초기화
cargo init
cargo init --lib
생성된 구조:
my-project/
├── Cargo.toml # 프로젝트 설정 (package.json)
├── Cargo.lock # 잠금 파일 (package-lock.json)
└── src/
└── main.rs # 진입점 (index.ts / main.ts)
라이브러리의 경우 src/lib.rs가 생성됩니다.
주요 명령어 비교
| npm/Node.js | Cargo | 설명 |
|---|---|---|
npm install | cargo build | 의존성 다운로드 + 빌드 |
npm run build | cargo build --release | 최적화 빌드 |
npm start | cargo run | 빌드 후 실행 |
npm test | cargo test | 테스트 실행 |
npm install <pkg> | cargo add <crate> | 의존성 추가 |
npx eslint . | cargo clippy | 린트 검사 |
npx prettier --write . | cargo fmt | 코드 포맷 |
npm run build -- --watch | cargo watch -x run | 변경 감지 + 재실행 |
자주 쓰는 cargo 명령어
# 빌드 (개발용, 빠른 컴파일, 디버그 심볼 포함)
cargo build
# 결과물: target/debug/my-project
# 빌드 (배포용, 최적화, 느린 컴파일)
cargo build --release
# 결과물: target/release/my-project
# 빌드 + 실행
cargo run
# 빌드 + 실행 (릴리스 모드)
cargo run --release
# 실행 인자 전달
cargo run -- --port 8080 --verbose
# 컴파일만 확인 (실행 파일 생성 안 함, 빠름)
cargo check
# 테스트 실행
cargo test
# 특정 테스트만 실행
cargo test test_block_hash
# 문서 생성 및 브라우저에서 열기
cargo doc --open
# 의존성 추가
cargo add serde
cargo add serde --features derive
cargo add tokio --features full
# 의존성 제거
cargo remove serde
# 사용하지 않는 의존성 확인
cargo +nightly udeps # cargo-udeps 설치 필요
# 린트
cargo clippy
cargo clippy -- -D warnings # 경고를 에러로 처리
# 포맷
cargo fmt
cargo fmt --check # CI에서 포맷 검사용
# 패키지 정보 확인
cargo metadata
cargo watch 설정 (파일 변경 감지)
# cargo-watch 설치
cargo install cargo-watch
# 파일 변경시 자동으로 cargo run
cargo watch -x run
# 파일 변경시 cargo check (더 빠름)
cargo watch -x check
# 파일 변경시 테스트 실행
cargo watch -x test
첫 번째 프로젝트 생성 및 실행
프로젝트 생성
cargo new hello-blockchain
cd hello-blockchain
src/main.rs 살펴보기
fn main() {
println!("Hello, world!");
}
처음 보는 Rust 코드이므로 한 줄씩 읽어봅시다.
| 코드 | 뜻 |
|---|---|
fn | 함수를 정의한다는 키워드 |
main | 실행 파일이 시작되는 함수 이름 |
{ ... } | 함수 본문 |
println! | 콘솔에 한 줄 출력하는 매크로 |
"Hello, world!" | 문자열 리터럴 |
; | 이 문장이 여기서 끝난다는 표시 |
TypeScript의 console.log에 해당하는 것이 println!입니다. 뒤에 !가 붙으면 매크로입니다. 매크로는 컴파일 시점에 코드를 만들어내는 Rust 기능인데, 지금은 그냥 “특별한 함수처럼 호출하는 출력 도구“라고 생각해도 됩니다.
실행
cargo run
출력:
Compiling hello-blockchain v0.1.0 (/Users/you/hello-blockchain)
Finished dev [unoptimized + debuginfo] target(s) in 0.50s
Running `target/debug/hello-blockchain`
Hello, world!
코드 수정해보기
src/main.rs를 수정합니다:
fn main() {
let name = "Blockchain Developer";
let year = 2024;
println!("Hello, {}!", name);
println!("Welcome to Rust in {}!", year);
// 기본 연산
let block_height: u64 = 100;
let reward: f64 = 6.25;
println!("Block #{}: reward = {} BTC", block_height, reward);
}
여기서 새로 나온 문법은 세 가지입니다.
| 문법 | 의미 | TypeScript 감각 |
|---|---|---|
let name = ... | 불변 변수 선언 | const name = ... |
let block_height: u64 = 100 | 타입을 직접 적은 변수 선언 | const blockHeight: number = 100 |
{} | 출력 문자열의 자리표시자 | template literal의 ${value} |
Rust의 변수는 기본적으로 다시 대입할 수 없습니다. 값을 바꾸려면 뒤에서 볼 let mut를 사용해야 합니다. 숫자 타입 u64는 부호 없는 64비트 정수입니다. 블록 높이처럼 음수가 될 수 없는 큰 정수에 자주 씁니다.
cargo run
출력:
Hello, Blockchain Developer!
Welcome to Rust in 2024!
Block #100: reward = 6.25 BTC
println! 포맷 문자열
fn main() {
let x = 42;
let pi = 3.14159;
// 기본 출력
println!("{}", x); // 42
// 디버그 출력 (Debug 트레이트 구현 필요)
println!("{:?}", x); // 42
println!("{:#?}", x); // 예쁜 출력 (pretty print)
// 소수점 자리수
println!("{:.2}", pi); // 3.14
println!("{:.4}", pi); // 3.1416
// 패딩
println!("{:10}", x); // " 42" (우측 정렬, 너비 10)
println!("{:<10}", x); // "42 " (좌측 정렬)
println!("{:0>5}", x); // "00042" (0으로 채움)
// 16진수, 2진수, 8진수
println!("{:x}", x); // 2a
println!("{:X}", x); // 2A
println!("{:b}", x); // 101010
println!("{:o}", x); // 52
// 여러 변수
let (a, b) = (10, 20);
println!("{a} + {b} = {}", a + b); // Rust 1.58+: 변수 직접 참조
}
eprintln!: stderr 출력
fn main() {
println!("이건 stdout으로"); // 정상 출력
eprintln!("이건 stderr로"); // 에러/로그 출력
}
Node.js의 console.log와 console.error에 해당합니다.
디렉토리 구조 이해
실제 프로젝트에서 자주 보게 될 구조:
my-project/
├── Cargo.toml # 프로젝트 메타데이터, 의존성
├── Cargo.lock # 버전 잠금 (커밋에 포함시킬 것)
├── src/
│ ├── main.rs # 바이너리 진입점
│ ├── lib.rs # 라이브러리 루트 (lib 크레이트)
│ ├── models/
│ │ ├── mod.rs # 모듈 선언
│ │ ├── block.rs # Block 구조체
│ │ └── transaction.rs # Transaction 구조체
│ └── utils/
│ ├── mod.rs
│ └── crypto.rs
├── tests/
│ └── integration_test.rs # 통합 테스트
├── examples/
│ └── basic_usage.rs # 예제 코드
└── target/ # 빌드 결과물 (gitignore)
├── debug/
└── release/
.gitignore에 추가할 것:
/target
요약
rustup: Rust 버전 관리자 (nvm)cargo: 빌드 + 패키지 관리 (npm + webpack)rustc: 컴파일러 (직접 쓸 일 별로 없음, cargo가 대신)rust-analyzer: VS Code 언어 서버 (필수)cargo run→ 빌드 + 실행cargo build --release→ 최적화 빌드cargo test→ 테스트
다음 챕터에서 Cargo.toml 구조와 의존성 관리를 자세히 배웁니다.
1.2 Cargo와 프로젝트 구조
Cargo.toml 구조
Cargo.toml은 Node.js의 package.json에 해당합니다. TOML(Tom’s Obvious, Minimal Language) 형식으로 작성됩니다.
기본 구조
[package]
name = "my-blockchain"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <you@example.com>"]
description = "A simple blockchain implementation in Rust"
license = "MIT"
repository = "https://github.com/you/my-blockchain"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10"
hex = "0.4"
tokio = { version = "1", features = ["full"] }
chrono = { version = "0.4", features = ["serde"] }
[dev-dependencies]
# 테스트에서만 사용하는 의존성
assert_eq = "1.0"
[build-dependencies]
# 빌드 스크립트(build.rs)에서 사용하는 의존성
[[bin]]
name = "blockchain"
path = "src/main.rs"
[[bin]]
name = "miner"
path = "src/bin/miner.rs"
[lib]
name = "blockchain_lib"
path = "src/lib.rs"
[profile.dev]
opt-level = 0 # 최적화 없음 (빠른 컴파일)
debug = true # 디버그 심볼 포함
[profile.release]
opt-level = 3 # 최대 최적화
debug = false
lto = true # Link Time Optimization
codegen-units = 1 # 단일 코드 생성 단위 (더 나은 최적화)
panic = "abort" # panic시 abort (블록체인에서 중요)
[features]
default = []
experimental = ["some-experimental-crate"]
package.json과 비교
// package.json (Node.js)
{
"name": "my-blockchain",
"version": "0.1.0",
"description": "A simple blockchain implementation",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"test": "jest"
},
"dependencies": {
"express": "^4.18.0",
"axios": "^1.0.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"@types/express": "^4.17.0"
}
}
# Cargo.toml (Rust)
[package]
name = "my-blockchain"
version = "0.1.0"
edition = "2021"
# "scripts"에 해당하는 것: cargo run, cargo test, cargo build
# main/entry point: src/main.rs (기본값)
[dependencies]
# "dependencies"에 해당
axum = "0.7" # express 대신
reqwest = { version = "0.11", features = ["json"] } # axios 대신
[dev-dependencies]
# "devDependencies"에 해당
주요 차이점:
| package.json | Cargo.toml |
|---|---|
"^4.18.0" (캐럿 범위) | "4" (메이저 버전 호환) |
scripts 섹션 있음 | 스크립트 없음 (cargo 명령어 사용) |
devDependencies | [dev-dependencies] |
| 없음 | [profile.dev], [profile.release] |
| 없음 | features (조건부 컴파일) |
의존성 관리
크레이트(Crate)란?
Rust의 패키지 단위를 **크레이트(crate)**라고 합니다. npm의 패키지와 같습니다. https://crates.io 에서 검색할 수 있습니다.
의존성 추가 방법
방법 1: cargo add 명령어 (권장)
# 최신 버전 추가
cargo add serde
# features 포함
cargo add serde --features derive
cargo add tokio --features full
# 특정 버전 지정
cargo add serde@1.0.195
# dev-dependency로 추가
cargo add --dev pretty_assertions
# 여러 개 동시에
cargo add sha2 hex chrono
방법 2: Cargo.toml 직접 수정
[dependencies]
# 버전 문자열만
serde_json = "1.0"
# 상세 설정
serde = { version = "1.0", features = ["derive"] }
# git 저장소에서 직접
my-crate = { git = "https://github.com/user/my-crate" }
my-crate = { git = "https://github.com/user/my-crate", branch = "main" }
my-crate = { git = "https://github.com/user/my-crate", rev = "abc1234" }
# 로컬 경로
my-local-crate = { path = "../my-local-crate" }
# 특정 조건에서만 포함
[target.'cfg(unix)'.dependencies]
nix = "0.27"
버전 지정 방식
[dependencies]
# 정확한 버전
exact = "=1.0.0"
# 1.x.x (하위 호환, npm의 ^ 와 동일)
compatible = "1"
compatible2 = "1.0"
compatible3 = "^1.0.0"
# 패치 버전만 (1.2.x)
patch = "~1.2"
patch2 = "~1.2.0"
# 모든 버전 (권장하지 않음)
any = "*"
# 범위
range = ">=1.0, <2.0"
팁: crates.io에서 크레이트 이름을 검색하면 “Install” 섹션에 Cargo.toml에 추가할 줄이 나옵니다.
Cargo.lock
Cargo.lock은 npm의 package-lock.json과 같습니다. 모든 의존성의 정확한 버전을 기록합니다.
중요한 규칙:
- 바이너리 프로젝트 (main.rs가 있는 프로젝트): Cargo.lock을 반드시 커밋
- 라이브러리 프로젝트 (lib.rs만 있는 프로젝트): Cargo.lock을 .gitignore에 추가 (사용자가 자신의 버전으로 결정하게)
# 의존성 업데이트
cargo update # 모든 의존성 업데이트 (semver 범위 내)
cargo update serde # 특정 크레이트만 업데이트
블록체인 프로젝트 Cargo.toml 예시
실제 이 책에서 만들 미니 블록체인의 Cargo.toml:
[package]
name = "mini-blockchain"
version = "0.1.0"
edition = "2021"
[dependencies]
# SHA-256 해싱
sha2 = "0.10"
# 바이트 배열 <-> 16진수 문자열 변환
hex = "0.4"
# 직렬화/역직렬화 (TypeScript의 JSON.stringify/parse와 유사)
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# 시간 처리
chrono = { version = "0.4", features = ["serde"] }
# 에러 처리 (커스텀 에러 타입 쉽게 만들기)
thiserror = "1.0"
[dev-dependencies]
# 테스트용 assertion 강화
pretty_assertions = "1.4"
빌드 시스템 이해
컴파일 과정
src/main.rs
↓
rustc (컴파일러)
↓
target/debug/mini-blockchain (실행 파일)
TypeScript와 비교:
src/main.ts
↓
tsc (TypeScript 컴파일러)
↓
dist/main.js (JavaScript)
↓
node (런타임)
↓
실행
Rust는 네이티브 바이너리로 컴파일됩니다. 별도의 런타임이 필요 없습니다.
개발 빌드 vs 릴리스 빌드
# 개발 빌드: 빠른 컴파일, 느린 실행, 디버그 정보 포함
cargo build
# → target/debug/mini-blockchain (수십 MB)
# 릴리스 빌드: 느린 컴파일, 빠른 실행, 최적화
cargo build --release
# → target/release/mini-blockchain (수 MB, 훨씬 빠름)
성능 차이: 릴리스 빌드는 개발 빌드보다 10배~100배 빠를 수 있습니다. 블록체인의 Proof of Work 같은 연산 집약적 작업은 반드시 --release로 테스트해야 합니다.
cargo check
cargo check
실행 파일을 생성하지 않고 컴파일 에러만 확인합니다. cargo build보다 훨씬 빠르므로, TDD나 빠른 피드백 루프에서 유용합니다.
IDE(rust-analyzer)는 내부적으로 cargo check를 지속적으로 실행하여 실시간 에러를 표시합니다.
워크스페이스
여러 관련 크레이트를 하나의 저장소에서 관리할 때 사용합니다. Node.js의 monorepo(npm workspaces, Turborepo)와 유사합니다.
blockchain-workspace/
├── Cargo.toml # 워크스페이스 루트
├── core/ # 핵심 블록체인 로직 (라이브러리)
│ ├── Cargo.toml
│ └── src/lib.rs
├── node/ # 노드 실행 프로그램
│ ├── Cargo.toml
│ └── src/main.rs
└── cli/ # CLI 도구
├── Cargo.toml
└── src/main.rs
루트 Cargo.toml:
[workspace]
members = [
"core",
"node",
"cli",
]
resolver = "2" # 의존성 해석 버전 (2021 edition에서 권장)
멤버 크레이트에서 다른 멤버 참조:
# node/Cargo.toml
[dependencies]
core = { path = "../core" }
모듈 시스템 기초
Rust의 모듈 시스템은 Node.js의 import/export와 다릅니다. 파일 이름이 모듈 이름이 됩니다.
처음에는 다음 대응만 기억하세요.
| Node.js/TypeScript | Rust |
|---|---|
import { sha256 } from "./crypto" | mod crypto; use crypto::sha256; |
export function sha256(...) | pub fn sha256(...) |
export class Block | pub struct Block + impl Block |
models/index.ts에서 re-export | models/mod.rs에서 pub use |
Rust는 파일을 만들었다고 자동으로 import하지 않습니다. mod crypto;처럼 “이 파일을 모듈 트리에 포함한다”고 선언해야 합니다.
기본 모듈 선언
// src/main.rs
mod crypto; // src/crypto.rs 또는 src/crypto/mod.rs 를 모듈로 선언
mod models; // src/models.rs 또는 src/models/mod.rs
use crypto::sha256;
use models::Block;
fn main() {
let hash = sha256("hello");
let block = Block::new();
println!("hash = {}", hash);
println!("block #{} = {}", block.index, block.hash);
}
// src/crypto.rs
use sha2::{Digest, Sha256};
pub fn sha256(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
hex::encode(hasher.finalize())
}
#![allow(unused)]
fn main() {
// src/models.rs
pub struct Block {
pub index: u64,
pub hash: String,
}
impl Block {
pub fn new() -> Self {
Block {
index: 0,
hash: String::new(),
}
}
}
}
이 예제에서 pub은 “다른 모듈에서도 접근 가능”이라는 뜻입니다. pub이 없으면 같은 파일 또는 같은 모듈 안에서만 접근할 수 있습니다. TypeScript에서 export를 붙이지 않으면 다른 파일에서 import할 수 없는 것과 비슷합니다.
하위 디렉토리 모듈
src/
├── main.rs
└── models/
├── mod.rs # models 모듈의 루트
├── block.rs # models::block 서브모듈
└── tx.rs # models::tx 서브모듈
// src/models/mod.rs
pub mod block;
pub mod tx;
// re-export (외부에서 models::Block으로 접근 가능)
pub use block::Block;
pub use tx::Transaction;
// src/main.rs
mod models;
use models::Block; // models/mod.rs에서 re-export 했으므로 가능
Node.js의 index.ts에서 re-export하는 패턴과 동일합니다:
// models/index.ts (Node.js)
export { Block } from './block';
export { Transaction } from './tx';
자주 쓰이는 크레이트 목록
블록체인 개발에서 자주 보게 될 크레이트들:
| 크레이트 | 용도 | npm 유사물 |
|---|---|---|
serde | 직렬화/역직렬화 | class-transformer |
serde_json | JSON 처리 | JSON 내장 |
tokio | 비동기 런타임 | Node.js 자체 |
reqwest | HTTP 클라이언트 | axios |
axum | HTTP 서버 | express / fastify |
sha2 | SHA 해시 | crypto (내장) |
hex | 16진수 인코딩 | Buffer.toString('hex') |
thiserror | 에러 타입 정의 | - |
anyhow | 에러 처리 편의 | - |
log | 로깅 인터페이스 | winston (인터페이스) |
env_logger | 로깅 구현체 | winston |
tracing | 구조화된 로깅 | pino |
clap | CLI 인자 파싱 | commander / yargs |
dotenv | .env 파일 로드 | dotenv |
chrono | 날짜/시간 | dayjs / date-fns |
요약
Cargo.toml은package.json과 유사하지만 빌드 프로파일, features 등 더 많은 설정 가능cargo add <크레이트>로 의존성 추가Cargo.lock은 바이너리 프로젝트에서 반드시 커밋- 개발 빌드(
cargo build)와 릴리스 빌드(cargo build --release)의 성능 차이가 큼 - 모듈 시스템은 파일 이름 기반,
mod키워드로 선언
다음으로는 블록체인의 기본 개념을 먼저 살펴본 후, Rust의 핵심인 소유권 시스템을 배웁니다.
블록체인이란 무엇인가?
한 문장 요약: 블록체인은 “모든 참여자가 동일한 장부를 갖는 분산 데이터베이스“다.
Node.js 백엔드 개발자로서 여러분은 PostgreSQL, MongoDB, Redis 같은 데이터베이스를 다뤄봤을 것이다. 데이터베이스는 데이터를 저장하고 조회하는 시스템이다. 그렇다면 블록체인은 왜 등장했을까? 기존 데이터베이스로 충분하지 않았을까?
이 챕터에서는 블록체인이 탄생한 배경과 핵심 개념을 Node.js 개발자의 관점에서 설명한다.
먼저 잡는 네 단어
블록체인 설명은 보통 익숙하지 않은 단어가 연쇄적으로 나옵니다. 본문으로 들어가기 전에 네 단어를 먼저 고정하세요.
| 단어 | 뜻 | 백엔드 비유 |
|---|---|---|
| 트랜잭션(Transaction) | 체인 상태를 바꾸기 위해 제출하는 서명된 요청 | 인증된 POST 요청 |
| 블록(Block) | 여러 트랜잭션을 묶은 기록 단위 | append-only 변경 로그 배치 |
| 체인(Chain) | 이전 블록 해시로 연결된 블록 목록 | 삭제/수정이 막힌 이벤트 로그 |
| 노드(Node) | 트랜잭션과 블록을 검증하고 전파하는 프로그램 | DB 복제본과 API 서버 역할을 함께 하는 참여자 |
이 네 단어의 관계는 다음과 같습니다.
사용자가 트랜잭션을 만든다
↓
노드들이 트랜잭션을 검증하고 전파한다
↓
검증자/채굴자가 여러 트랜잭션을 블록으로 묶는다
↓
새 블록이 이전 블록 뒤에 붙어 체인이 길어진다
이 구조를 기억하면 뒤에서 나오는 해시, 합의, 가스, 스마트 컨트랙트도 “체인에 안전하게 기록하기 위한 장치”로 연결해서 이해할 수 있습니다.
1. 문제의 출발점: 이중 지불(Double Spending)
인터넷 상에서 돈을 보내는 것을 생각해보자. 내가 1만 원짜리 디지털 파일을 Alice에게 보냈다면, 그 파일을 복사해서 Bob에게도 보낼 수 있다. 디지털 데이터는 복사가 자유롭기 때문이다.
현실 세계에서 이 문제를 해결하는 방법은 은행이라는 신뢰할 수 있는 중개자를 두는 것이다.
Alice의 계좌 잔액: 10,000원
│
├─ 은행이 Alice의 잔액 확인
├─ Alice → Bob 5,000원 이체 기록
├─ Alice의 잔액을 5,000원으로 업데이트
└─ Bob의 잔액을 5,000원 증가
은행은 중앙 원장(Central Ledger)을 관리하며, 모든 거래를 기록하고 이중 지불을 방지한다. 그런데 문제가 있다:
- 은행이 해킹당하면? → 모든 데이터가 조작될 수 있다
- 은행이 파산하면? → 돈을 잃을 수 있다
- 은행이 거래를 거부하면? → 막을 방법이 없다
- 은행이 거래 내역을 조작하면? → 알 수가 없다
2008년 금융 위기 당시, 중앙화된 금융 시스템의 한계가 드러났다. 바로 그 해, **사토시 나카모토(Satoshi Nakamoto)**라는 정체불명의 인물이 비트코인 백서를 발표했다.
“A purely peer-to-peer version of electronic cash would allow online payments to be sent directly from one party to another without going through a financial institution.” — 비트코인 백서, 2008
2. 해결책: 분산된 장부
블록체인의 핵심 아이디어는 단순하다. 중앙 기관 없이 거래를 검증하고 기록하는 것이다.
전통적인 시스템:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Alice │────▶│ 은행 │────▶│ Bob │
└──────────┘ │(중앙 DB) │ └──────────┘
└──────────┘
블록체인 시스템:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Node 1 │────▶│ Node 2 │────▶│ Node 3 │
│ (장부 사본)│◀───│ (장부 사본)│◀───│ (장부 사본)│
└──────────┘ └──────────┘ └──────────┘
│ │ │
└────────────────┴────────────────┘
모두가 동일한 장부를 보유
네트워크에 참여하는 모든 노드(컴퓨터)가 전체 거래 내역의 사본을 갖는다. 누군가 데이터를 조작하려면 네트워크의 과반수 이상을 동시에 공격해야 한다. 이것이 사실상 불가능하게 만드는 것이 블록체인의 핵심이다.
3. 블록체인의 핵심 속성
3.1 불변성 (Immutability)
한번 블록에 기록된 데이터는 수정이 사실상 불가능하다. 왜냐하면:
- 각 블록은 이전 블록의 해시값을 포함한다
- 특정 블록을 수정하면 그 블록의 해시값이 바뀐다
- 해시값이 바뀌면 이후 모든 블록이 무효화된다
- 네트워크의 모든 노드가 이 변조를 감지한다
[블록 #1] ──해시 포함──▶ [블록 #2] ──해시 포함──▶ [블록 #3]
│ │ │
│ 블록 #1 수정 시도 │ │
▼ │ │
[블록 #1'] │ │
(해시 변경됨) │ │
│ │ │
└──▶ 블록 #2와 연결 끊김! ──▶ 블록 #3도 무효!
3.2 투명성 (Transparency)
퍼블릭 블록체인(비트코인, 이더리움)에서는 모든 거래 내역이 공개된다. 누구나 블록 익스플로러(예: etherscan.io)에서 모든 트랜잭션을 조회할 수 있다.
- 개인정보는 보호된다 (주소는 공개되지만, 그 주소의 실제 소유자는 모름)
- 거래 자체는 투명하게 공개된다
- 감사(Audit)가 용이하다
3.3 탈중앙화 (Decentralization)
단일 실패 지점(Single Point of Failure)이 없다:
중앙화 시스템:
┌───────────┐
│ 서버 │◀── 서버 다운 = 서비스 전체 중단
└───────────┘
분산 시스템:
●───●───●
│ │ │
●───●───●
│ │ │
●───●───●
일부 노드가 다운되어도 네트워크는 계속 동작
4. 전통 DB vs 블록체인: 개발자 관점 비교
Node.js 개발자에게 친숙한 PostgreSQL과 블록체인을 직접 비교해보자.
| 항목 | PostgreSQL | 블록체인 |
|---|---|---|
| 데이터 저장 | 중앙 서버의 단일 DB | 수천 개 노드에 분산 복제 |
| 쓰기 속도 | 매우 빠름 (ms 단위) | 느림 (초~분 단위) |
| 읽기 속도 | 매우 빠름 | 빠름 (로컬 노드 조회 시) |
| 데이터 수정 | UPDATE/DELETE 자유 | 사실상 불가능 |
| 접근 제어 | Role 기반 (DBA가 관리) | 암호학 기반 (개인 키) |
| 합의 과정 | 필요 없음 (단일 권위) | 필수 (과반수 동의) |
| 신뢰 모델 | DB 관리자 신뢰 | 코드(알고리즘) 신뢰 |
| 장애 허용 | 레플리케이션으로 일부 해결 | 노드 상당수 장애에도 동작 |
| 비용 | 서버 비용 | 트랜잭션 수수료 (Gas) |
| 감사 | 로그 (관리자 조작 가능) | 완전 공개 (조작 불가) |
| 사용 사례 | 대부분의 앱 | 신뢰가 필요한 분산 시스템 |
언제 블록체인이 필요한가?
블록체인이 적합한 경우:
- 여러 기관이 데이터를 공유해야 하는데, 서로 신뢰하지 못할 때
- 데이터의 불변성과 감사 추적이 핵심일 때
- 중개자 없이 가치를 이전해야 할 때
- 자동으로 실행되는 계약(스마트 컨트랙트)이 필요할 때
블록체인이 불필요한 경우:
- 단일 조직이 관리하는 일반 앱
- 빠른 읽기/쓰기가 필요한 서비스
- 데이터 수정이 자주 필요한 서비스
5. Node.js 개발자를 위한 비유
여러분이 여러 회사가 함께 사용하는 공유 시스템을 만든다고 상상해보자.
일반적인 접근 (중앙화):
- 회사 A가 서버를 운영
- 회사 B, C, D는 회사 A의 API를 통해 데이터 접근
- 회사 A가 데이터를 마음대로 수정할 수 있음
- 회사 A 서버가 다운되면 모두가 피해
블록체인 접근 (탈중앙화):
- 모든 회사가 동일한 DB 사본을 보유
- 새 데이터를 추가하려면 과반수의 동의 필요
- 어느 회사도 단독으로 데이터를 수정할 수 없음
- 일부 서버가 다운되어도 나머지로 운영 가능
더 구체적으로, Node.js 코드로 비유하면:
// 전통적인 데이터베이스 (중앙화)
class CentralDatabase {
constructor() {
this.data = []; // 단일 서버에만 존재
}
async write(record) {
// 관리자가 마음대로 수정 가능
this.data.push(record);
}
async delete(id) {
// 기록 삭제도 가능
this.data = this.data.filter(r => r.id !== id);
}
}
// 블록체인의 개념 (탈중앙화)
class Blockchain {
constructor() {
this.chain = []; // 모든 노드가 동일한 사본 보유
}
async addBlock(data) {
// 새 블록 추가는 네트워크 합의 필요
const newBlock = {
index: this.chain.length,
data: data,
previousHash: this.getLastBlock().hash,
hash: this.calculateHash(data),
timestamp: Date.now()
};
// 합의 과정을 통해서만 추가 가능
if (await this.getNetworkConsensus(newBlock)) {
this.chain.push(newBlock);
}
}
// DELETE는 없다! 수정도 없다!
// 오직 새 블록 추가만 가능
}
6. 블록체인의 종류
6.1 퍼블릭 블록체인 (Public Blockchain)
- 누구나 참여 가능
- 완전히 탈중앙화
- 예: 비트코인, 이더리움
- 특징: 느리지만 가장 안전하고 투명
6.2 프라이빗 블록체인 (Private Blockchain)
- 허가된 참여자만 접근
- 중앙 기관이 존재
- 예: Hyperledger Fabric
- 특징: 빠르지만 탈중앙화 정도가 낮음
6.3 컨소시엄 블록체인 (Consortium Blockchain)
- 특정 그룹(예: 여러 은행)이 공동 운영
- 반탈중앙화
- 예: R3 Corda, Besu 기반 기업 네트워크
- 특징: 프라이빗과 퍼블릭의 중간
탈중앙화 정도:
낮음 ◀─────────────────────────▶ 높음
프라이빗 컨소시엄 퍼블릭
│ │ │
Hyperledger Besu/IBFT 이더리움/비트코인
7. 블록체인 기술 스택 전체 그림
이 가이드에서 우리가 배울 내용을 미리 살펴보자:
┌─────────────────────────────────────────┐
│ 애플리케이션 레이어 │
│ (DApp, Web3 프론트엔드, Node.js 백엔드) │
├─────────────────────────────────────────┤
│ 스마트 컨트랙트 레이어 │
│ (Solidity, ERC-20, ERC-721) │
├─────────────────────────────────────────┤
│ 이더리움 프로토콜 레이어 │
│ (EVM, Gas, 트랜잭션, 계정 모델) │
├─────────────────────────────────────────┤
│ 합의 레이어 │
│ (PoW, PoS, IBFT 2.0) │
├─────────────────────────────────────────┤
│ 네트워크/P2P 레이어 │
│ (노드 통신, 블록 전파) │
├─────────────────────────────────────────┤
│ 암호학 기초 │
│ (해시, 공개키/비밀키, 디지털 서명) │
└─────────────────────────────────────────┘
8. 핵심 정리
- 블록체인 = 분산 불변 장부: 모든 참여자가 동일한 데이터를 갖고, 아무도 단독으로 수정할 수 없다
- 탄생 배경: 신뢰할 수 있는 중개자 없이 이중 지불을 방지하기 위해
- 핵심 속성: 불변성, 투명성, 탈중앙화
- 적합한 사용 사례: 서로 신뢰하지 못하는 여러 당사자가 데이터를 공유해야 할 때
- PostgreSQL과의 차이: 중앙 권위자가 없고, 데이터 삭제/수정이 불가능하며, 합의 과정이 필요
다음 챕터에서는 블록체인의 기반이 되는 암호학 — 해시 함수, 머클 트리, 공개키 암호화 — 을 Rust 코드와 함께 깊이 파고들 것이다.
암호학 기초: 해시, 머클 트리, 공개키 암호화
블록체인은 암호학 위에 세워진 시스템이다. 암호학을 이해하지 않으면 블록체인이 왜 안전한지, 어떻게 신뢰를 만들어내는지 알 수 없다. 이 챕터에서는 블록체인에 사용되는 핵심 암호학 개념을 Rust 코드와 함께 깊이 이해한다.
1. 해시 함수 (Hash Function)
1.1 해시 함수란?
해시 함수는 임의 길이의 입력을 받아 고정 길이의 출력(다이제스트, digest)을 만드는 함수다.
입력 (임의 길이) 출력 (고정 길이)
"hello" ──▶ 2cf24dba5fb0a3... (256비트)
"hello world" ──▶ b94d27b9934d3e... (256비트)
10MB 파일 ──▶ a3f5c8d21b09e4... (256비트)
Node.js에서 이미 해시를 써봤을 것이다:
// Node.js에서의 해시
const crypto = require('crypto');
const hash = crypto.createHash('sha256')
.update('hello')
.digest('hex');
console.log(hash);
// 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
1.2 암호학적 해시 함수의 5가지 특성
1. 결정론적 (Deterministic) 같은 입력은 항상 같은 출력을 낸다.
SHA256("hello") = 2cf24dba... (언제나 동일)
2. 단방향성 (One-way / Preimage Resistance) 출력에서 입력을 역산하는 것이 계산상 불가능하다.
2cf24dba... ──▶ "hello" ← 이것이 불가능해야 함
모든 가능한 입력을 시도(무차별 대입)하는 것 외에 방법이 없다.
3. 눈사태 효과 (Avalanche Effect) 입력이 아주 조금 바뀌어도 출력이 완전히 달라진다.
SHA256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e...
SHA256("hellO") = 185f8db32921bd46d35cc3c8c85b...
한 글자만 바꿔도 출력이 50% 이상 바뀐다.
4. 충돌 저항성 (Collision Resistance) 서로 다른 두 입력이 같은 출력을 내는 경우(충돌)를 찾는 것이 계산상 불가능하다.
find x, y such that SHA256(x) == SHA256(y) ← 사실상 불가능
5. 빠른 연산 해시 계산 자체는 매우 빠르다 (역산이 어려운 것이지, 정방향은 빠름).
1.3 SHA-256 동작 원리 (개념적)
SHA-256은 다음과 같은 과정을 거친다:
입력 메시지
│
▼
┌─────────────────┐
│ 패딩 (Padding) │ ← 메시지를 512비트 블록의 배수로 만듦
└────────┬────────┘
│
┌────▼────┐
│ 블록 1 │ ──▶ 압축 함수 (64라운드) ──▶ 중간 해시값
└─────────┘ │
┌─────────┐ ▼
│ 블록 2 │ ──▶ 압축 함수 (64라운드) ──▶ 중간 해시값
└─────────┘ │
... ▼
┌─────────┐ │
│ 블록 N │ ──▶ 압축 함수 (64라운드) ──▶ 최종 256비트 해시
└─────────┘
각 압축 함수는 비트 연산(AND, OR, XOR, 시프트, 로테이션)과 모듈러 덧셈을 64번 반복한다. 이 과정이 눈사태 효과를 만들어낸다.
2. Rust로 SHA-256 해싱 구현하기
Cargo.toml에 의존성을 추가한다:
[dependencies]
sha2 = "0.10"
hex = "0.4"
코드를 보기 전에 두 크레이트의 역할을 분리해보자.
| 크레이트 | 하는 일 | Node.js 비유 |
|---|---|---|
sha2 | SHA-256 해시 계산 | crypto.createHash("sha256") |
hex | 바이트 배열을 16진수 문자열로 변환 | Buffer.toString("hex") |
아래 Rust 코드는 “문자열 입력 → 바이트로 변환 → SHA-256 계산 → 16진수 문자열로 출력” 순서로 읽으면 된다.
use sha2::{Sha256, Digest};
fn hash_data(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let result = hasher.finalize();
hex::encode(result)
}
fn main() {
let inputs = vec!["hello", "hello world", "blockchain"];
for input in inputs {
let hash = hash_data(input);
println!("SHA256({:?}) = {}", input, hash);
}
// 눈사태 효과 확인
println!("\n--- 눈사태 효과 ---");
println!("SHA256(\"hello\") = {}", hash_data("hello"));
println!("SHA256(\"hellO\") = {}", hash_data("hellO"));
// 한 글자 차이지만 완전히 다른 해시!
}
한 줄씩 해석하면 다음과 같다.
| 코드 | 의미 |
|---|---|
use sha2::{Sha256, Digest}; | sha2 크레이트에서 사용할 타입과 트레이트를 가져온다 |
fn hash_data(input: &str) -> String | 문자열을 빌려 받아(&str) 새 String 해시를 반환한다 |
let mut hasher = Sha256::new(); | 해시 계산기를 만든다. update로 내부 상태를 바꿔야 하므로 mut가 필요하다 |
input.as_bytes() | 문자열을 바이트 배열처럼 읽는다 |
hasher.finalize() | 해시 계산을 끝내고 32바이트 결과를 얻는다 |
hex::encode(result) | 사람이 읽을 수 있는 16진수 문자열로 바꾼다 |
Digest는 Sha256::new, update, finalize 같은 메서드를 사용할 수 있게 해주는 트레이트다. TypeScript에서는 import한 객체에 메서드가 바로 있는 것처럼 보이지만, Rust에서는 트레이트가 스코프에 있어야 메서드 호출이 가능한 경우가 있다. 트레이트는 5장에서 자세히 다룬다.
실행 결과:
SHA256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
SHA256("hello world") = b94d27b9934d3e08a52e52d7da7dabfac484efe04294e576fbe1ea5cc1f7f26b
SHA256("blockchain") = ef7797e13d3a75526946a3bcf00daec9fc9d1baea5a3d79434669b9eb6b4b250
--- 눈사태 효과 ---
SHA256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
SHA256("hellO") = 185f8db32921bd46d35cc3c8c85b0068eb62859e80a82f2e1e13d3e5a19f7bc9
3. 머클 트리 (Merkle Tree)
3.1 왜 머클 트리가 필요한가?
블록 하나에 수천 개의 트랜잭션이 있다고 하자. 특정 트랜잭션이 블록에 포함되어 있는지 검증하려면 어떻게 해야 할까?
단순한 방법: 모든 트랜잭션을 다운로드해서 확인 → 수백 MB 다운로드 필요
머클 트리 방법: 몇 개의 해시값만으로 O(log n) 검증 가능
3.2 머클 트리 구조
┌──────────────┐
│ 루트 해시 │ ← 머클 루트 (블록 헤더에 저장)
│ H(H12+H34) │
└──────┬───────┘
┌──────┴───────┐
┌──────┴──────┐ ┌────┴──────────┐
│ H12 │ │ H34 │
│ H(H1+H2) │ │ H(H3+H4) │
└──────┬──────┘ └──────┬────────┘
┌────────┴──────┐ ┌─────┴─────────┐
┌──┴───┐ ┌───┴──┐ ┌──┴───┐ ┌──┴────┐
│ H1 │ │ H2 │ │ H3 │ │ H4 │
│Hash │ │Hash │ │Hash │ │Hash │
│(TX1) │ │(TX2) │ │(TX3) │ │(TX4) │
└──────┘ └──────┘ └──────┘ └───────┘
▲ ▲ ▲ ▲
TX1 TX2 TX3 TX4
(트랜잭션 1) (트랜잭션 4)
3.3 머클 증명 (Merkle Proof)
TX3가 블록에 포함되어 있다는 것을 증명하려면:
- TX3의 해시 (H3)
- H4 (형제 노드)
- H12 (삼촌 노드)
- 머클 루트
이 4개의 값만으로 TX3의 포함 여부를 검증할 수 있다. 수천 개의 트랜잭션 전체를 다운로드할 필요가 없다!
검증 과정:
1. H3 = Hash(TX3) 계산
2. H34 = Hash(H3 + H4) 계산
3. Root = Hash(H12 + H34) 계산
4. 계산된 Root == 블록 헤더의 머클 루트? → 검증 성공!
3.4 Rust로 머클 트리 구현
use sha2::{Sha256, Digest};
/// 두 해시를 합쳐서 새 해시 생성
fn hash_pair(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(left);
hasher.update(right);
hasher.finalize().into()
}
/// 단일 데이터의 해시
fn hash_leaf(data: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(data.as_bytes());
hasher.finalize().into()
}
/// 간단한 머클 트리 구현
struct MerkleTree {
leaves: Vec<[u8; 32]>,
}
impl MerkleTree {
fn new(transactions: &[&str]) -> Self {
let leaves = transactions.iter()
.map(|tx| hash_leaf(tx))
.collect();
Self { leaves }
}
/// 머클 루트 계산
fn root(&self) -> Option<[u8; 32]> {
if self.leaves.is_empty() {
return None;
}
let mut current_level = self.leaves.clone();
while current_level.len() > 1 {
let mut next_level = Vec::new();
// 두 개씩 짝지어 해시
let mut i = 0;
while i < current_level.len() {
if i + 1 < current_level.len() {
// 두 노드를 합쳐 부모 노드 생성
next_level.push(hash_pair(¤t_level[i], ¤t_level[i + 1]));
} else {
// 홀수 개인 경우, 마지막 노드를 자기 자신과 합침
next_level.push(hash_pair(¤t_level[i], ¤t_level[i]));
}
i += 2;
}
current_level = next_level;
}
Some(current_level[0])
}
/// 특정 리프의 머클 증명 경로 생성
fn proof(&self, index: usize) -> Vec<([u8; 32], bool)> {
let mut proof = Vec::new();
let mut current_level = self.leaves.clone();
let mut current_index = index;
while current_level.len() > 1 {
let sibling_index = if current_index % 2 == 0 {
// 왼쪽 노드면 오른쪽 형제
(current_index + 1).min(current_level.len() - 1)
} else {
// 오른쪽 노드면 왼쪽 형제
current_index - 1
};
let is_right = current_index % 2 == 0; // 형제가 오른쪽에 있는지
proof.push((current_level[sibling_index], is_right));
// 다음 레벨로
let mut next_level = Vec::new();
let mut i = 0;
while i < current_level.len() {
if i + 1 < current_level.len() {
next_level.push(hash_pair(¤t_level[i], ¤t_level[i + 1]));
} else {
next_level.push(hash_pair(¤t_level[i], ¤t_level[i]));
}
i += 2;
}
current_index /= 2;
current_level = next_level;
}
proof
}
}
/// 머클 증명 검증
fn verify_proof(
leaf: &[u8; 32],
proof: &[([u8; 32], bool)],
root: &[u8; 32],
) -> bool {
let mut current = *leaf;
for (sibling, sibling_is_right) in proof {
current = if *sibling_is_right {
hash_pair(¤t, sibling)
} else {
hash_pair(sibling, ¤t)
};
}
¤t == root
}
fn main() {
let transactions = vec![
"Alice -> Bob: 1 ETH",
"Bob -> Carol: 0.5 ETH",
"Carol -> Dave: 0.2 ETH",
"Dave -> Eve: 0.1 ETH",
];
let tree = MerkleTree::new(&transactions);
let root = tree.root().unwrap();
println!("머클 루트: {}", hex::encode(root));
// TX2 (인덱스 1)에 대한 증명
let tx_index = 1;
let leaf_hash = hash_leaf(transactions[tx_index]);
let proof = tree.proof(tx_index);
println!("\n'{}' 검증:", transactions[tx_index]);
println!("리프 해시: {}", hex::encode(leaf_hash));
let is_valid = verify_proof(&leaf_hash, &proof, &root);
println!("검증 결과: {}", if is_valid { "성공!" } else { "실패!" });
// 조작된 트랜잭션은 검증 실패
let fake_leaf = hash_leaf("Alice -> Bob: 1000 ETH"); // 조작!
let is_fake_valid = verify_proof(&fake_leaf, &proof, &root);
println!("\n조작된 트랜잭션 검증: {}", if is_fake_valid { "성공" } else { "실패 (올바름!)" });
}
4. 공개키/비밀키 암호학
4.1 비대칭 암호화의 개념
Node.js에서 JWT를 써봤다면 이미 비대칭 암호화를 경험한 것이다. 블록체인에서는 이 개념이 훨씬 중요하다.
비대칭 키 쌍:
┌──────────────┐ ┌──────────────┐
│ 비밀키 │ │ 공개키 │
│ (Private Key)│ │ (Public Key) │
│ │ │ │
│ 절대 노출 X │ │ 모두에게 공개│
│ 지갑의 비밀번호│ │ 계좌 번호처럼│
└──────────────┘ └──────────────┘
│ │
│ 수학적으로 연결됨 │
└───────────────────────┘
비밀키 → 공개키: 비밀키에서 공개키를 계산할 수 있다 (단방향) 공개키 → 비밀키: 공개키에서 비밀키를 역산하는 것은 불가능 (이산 대수 문제)
4.2 타원 곡선 암호 (ECDSA)
이더리움과 비트코인은 secp256k1 타원 곡선을 사용한다.
타원 곡선: y² = x³ + 7 (mod p)
y
│ ╭────╮
│ ╭─╯ ╰─╮
│ ╱ ╲
│ │ │
├──┼─────────────┼──── x
│ │ │
│ ╲ ╱
│ ╰─╮ ╭─╯
│ ╰────╯
비밀키는 무작위 256비트 정수, 공개키는 이 값에 타원 곡선의 생성원 G를 곱한 점이다.
4.3 이더리움 지갑 주소 생성 과정
1. 비밀키 생성 (256비트 난수)
예: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
2. 타원 곡선 연산으로 공개키 생성 (512비트)
비밀키 × G(생성원) = 공개키 점(x, y)
3. 공개키를 Keccak-256으로 해싱
4. 해시의 마지막 20바이트 = 이더리움 주소
비밀키(32바이트) ──ECDSA──▶ 공개키(64바이트)
│
Keccak-256 해시
│
32바이트
│
마지막 20바이트 추출
│
▼
0x742d35Cc6634C0532925a3b8D4C9...
(이더리움 지갑 주소)
4.4 디지털 서명: 서명과 검증
서명 과정 (송신자):
┌─────────────────────────────────────────┐
│ 메시지: "Alice → Bob: 1 ETH" │
│ ↓ Keccak-256 │
│ 메시지 해시 │
│ ↓ + 비밀키 │
│ 서명 (r, s, v) — 64바이트 + 1바이트 │
└─────────────────────────────────────────┘
검증 과정 (수신자):
┌─────────────────────────────────────────┐
│ 서명 (r, s, v) + 메시지 해시 │
│ ↓ + 공개키 │
│ 서명이 유효한가? (Yes/No) │
│ │
│ ※ 비밀키 없이도 검증 가능! │
└─────────────────────────────────────────┘
중요한 점: 서명을 검증할 때 비밀키가 필요하지 않다. 공개키만 있으면 서명의 유효성을 확인할 수 있다. 이것이 블록체인에서 트랜잭션 인증이 작동하는 원리다.
4.5 Rust로 키쌍 생성, 서명, 검증
[dependencies]
secp256k1 = { version = "0.27", features = ["rand"] }
sha3 = "0.10"
rand = "0.8"
hex = "0.4"
use secp256k1::{Secp256k1, Message, SecretKey, PublicKey};
use sha3::{Keccak256, Digest};
use rand::rngs::OsRng;
/// 메시지를 Keccak-256으로 해싱
fn keccak256(data: &[u8]) -> [u8; 32] {
let mut hasher = Keccak256::new();
hasher.update(data);
hasher.finalize().into()
}
/// 공개키에서 이더리움 주소 계산
fn public_key_to_address(public_key: &PublicKey) -> String {
// 비압축 공개키 (65바이트: 04 + x + y)
let serialized = public_key.serialize_uncompressed();
// 앞의 '04' 바이트를 제외한 64바이트를 해싱
let hash = keccak256(&serialized[1..]);
// 마지막 20바이트가 주소
let address = &hash[12..];
format!("0x{}", hex::encode(address))
}
fn main() {
let secp = Secp256k1::new();
// 1. 키쌍 생성
let (secret_key, public_key) = secp.generate_keypair(&mut OsRng);
println!("=== 키쌍 생성 ===");
println!("비밀키: 0x{}", hex::encode(secret_key.secret_bytes()));
println!("공개키: 0x{}", hex::encode(public_key.serialize()));
println!("주소: {}", public_key_to_address(&public_key));
// 2. 메시지 서명
let message_text = "Alice -> Bob: 1 ETH";
let message_hash = keccak256(message_text.as_bytes());
let message = Message::from_digest(message_hash);
let signature = secp.sign_ecdsa(&message, &secret_key);
println!("\n=== 트랜잭션 서명 ===");
println!("메시지: {}", message_text);
println!("서명: {}", hex::encode(signature.serialize_compact()));
// 3. 서명 검증
let is_valid = secp.verify_ecdsa(&message, &signature, &public_key).is_ok();
println!("\n=== 서명 검증 ===");
println!("검증 결과: {}", if is_valid { "유효함!" } else { "무효!" });
// 4. 잘못된 공개키로 검증 시도 (실패해야 함)
let (_, wrong_public_key) = secp.generate_keypair(&mut OsRng);
let is_wrong_valid = secp.verify_ecdsa(&message, &signature, &wrong_public_key).is_ok();
println!("다른 공개키로 검증: {}", if is_wrong_valid { "통과 (문제!)" } else { "실패 (올바름!)" });
// 5. 서명에서 공개키 복구 (이더리움이 실제로 하는 방식)
println!("\n=== 발신자 복구 ===");
let recoverable_sig = secp.sign_ecdsa_recoverable(&message, &secret_key);
let recovered_key = secp.recover_ecdsa(&message, &recoverable_sig).unwrap();
println!("복구된 주소: {}", public_key_to_address(&recovered_key));
println!("원래 주소: {}", public_key_to_address(&public_key));
println!("일치: {}", recovered_key == public_key);
}
5. 실제 이더리움에서의 활용
이더리움에서 트랜잭션이 서명되는 전체 흐름:
사용자 행동: "1 ETH를 Bob에게 보낸다"
│
▼
트랜잭션 객체 생성:
{
nonce: 5,
gasPrice: 20 gwei,
gasLimit: 21000,
to: "0xBob...",
value: 1 ETH,
data: ""
}
│
▼
RLP 인코딩 → 바이트 배열
│
▼
Keccak-256 해시
│
▼
비밀키로 ECDSA 서명 → (r, s, v)
│
▼
서명된 트랜잭션을 네트워크에 브로드캐스트
│
▼
검증자(노드)가 서명 검증:
- 서명에서 공개키 복구
- 공개키에서 주소 계산
- 트랜잭션의 from 주소와 일치하면 유효!
6. 핵심 정리
| 개념 | 설명 | 블록체인에서의 역할 |
|---|---|---|
| SHA-256 | 256비트 해시 생성 | 블록 해시, PoW |
| Keccak-256 | 이더리움 표준 해시 | 트랜잭션 해시, 주소 생성 |
| 머클 트리 | 트랜잭션 요약 트리 | 블록 내 TX 검증, 경량 클라이언트 |
| ECDSA | 타원 곡선 서명 | 트랜잭션 인증, 소유권 증명 |
| 비밀키 | 256비트 랜덤 숫자 | 지갑 소유권, 서명 생성 |
| 공개키 | 비밀키 × G | 주소 생성, 서명 검증 |
| 지갑 주소 | Keccak(공개키)[-20바이트] | 계정 식별자 |
다음 챕터에서는 이 암호학 기초 위에 블록과 체인이 어떻게 구성되는지 알아본다.
2장: 소유권 시스템
Rust의 가장 독특한 개념
소유권(Ownership)은 Rust가 다른 모든 언어와 구별되는 핵심 개념입니다. 이것을 이해하지 못하면 Rust 코드를 작성할 수 없습니다. 반대로 이것을 제대로 이해하면, Rust 코드의 90%가 자연스럽게 풀립니다.
처음에는 컴파일러가 당신의 코드를 계속 거부하는 것처럼 느껴집니다. 이건 정상입니다. Rust를 만든 사람들도, 수십 년 경력의 시스템 프로그래머들도 처음에는 같은 경험을 합니다.
왜 소유권이 필요한가?
메모리 관리의 세 가지 방법
프로그램은 실행 중에 메모리를 사용하고, 더 이상 필요 없으면 해제해야 합니다. 역사적으로 세 가지 방법이 있었습니다:
1. 수동 메모리 관리 (C, C++)
// C 코드
char* create_message() {
char* msg = malloc(256); // 개발자가 직접 할당
strcpy(msg, "Hello");
return msg;
}
int main() {
char* m = create_message();
printf("%s\n", m);
free(m); // 개발자가 직접 해제 — 잊으면 메모리 누수!
// free 후에 m을 사용하면? use-after-free 버그!
// free를 두 번 하면? double-free 버그!
}
장점: 빠르다, GC 없음 단점: 매우 위험하다. 역사상 대부분의 보안 취약점이 여기서 나왔음
2. 가비지 컬렉터 (Java, Go, JavaScript, Python)
// JavaScript 코드
function createMessage() {
let msg = "Hello"; // 힙에 할당
return msg;
}
let m = createMessage();
console.log(m);
// m이 더 이상 참조되지 않으면 GC가 알아서 해제
// 개발자는 신경 쓸 필요 없음
장점: 안전하다, 편리하다 단점: GC 실행 시 프로그램이 멈춤(stop-the-world), 메모리 사용량이 많음, 실시간 시스템에 부적합
3. 소유권 시스템 (Rust)
// Rust 코드
fn create_message() -> String {
let msg = String::from("Hello"); // 힙에 할당
msg // 소유권이 호출자에게 이동
} // msg가 여기서 drop — 하지만 소유권이 이미 이동했으므로 해제 안 됨
fn main() {
let m = create_message(); // 소유권 획득
println!("{}", m);
} // m이 여기서 drop — 자동으로 메모리 해제
장점: 안전하다(컴파일 타임에 검증), GC 없다(예측 가능한 성능) 단점: 배우기 어렵다
블록체인에서 왜 중요한가?
스마트 컨트랙트는 한 번 배포하면 수정이 불가능합니다. 메모리 버그가 있는 컨트랙트는 해킹당해도 되돌릴 수 없습니다.
- C/C++로 만든 시스템: 메모리 버그 가능, 높은 성능
- Java/Go로 만든 시스템: 안전하지만 GC로 인한 예측 불가 레이턴시
- Rust로 만든 시스템: 메모리 안전 + GC 없는 예측 가능한 성능
이것이 Solana, Near, Polkadot이 Rust를 선택한 핵심 이유입니다.
소유권이 해결하는 문제들
소유권 시스템은 다음 문제들을 컴파일 타임에 방지합니다:
1. Use-After-Free (해제 후 사용)
// C에서 발생하는 버그
int* ptr = malloc(sizeof(int));
*ptr = 42;
free(ptr); // 메모리 해제
printf("%d\n", *ptr); // 버그! 해제된 메모리 접근
Rust에서는 이런 코드가 컴파일조차 되지 않습니다.
2. Double-Free (이중 해제)
// C에서 발생하는 버그
int* ptr = malloc(sizeof(int));
free(ptr); // 첫 번째 해제
free(ptr); // 두 번째 해제 — 정의되지 않은 동작!
Rust의 소유권은 각 값이 정확히 한 번 해제됨을 보장합니다.
3. Dangling Pointer (댕글링 포인터)
// C에서 발생하는 버그
int* get_value() {
int x = 42;
return &x; // 스택에 있는 지역 변수의 주소 반환
} // x는 여기서 사라짐!
int* ptr = get_value();
printf("%d\n", *ptr); // 버그! 이미 사라진 메모리 접근
Rust의 수명(lifetime) 시스템이 이를 방지합니다.
4. Memory Leak (메모리 누수)
// C에서 발생하는 버그
void process() {
int* data = malloc(1024);
if (!data) {
return;
}
if (should_stop_early()) {
return; // free(data)를 호출 못 함 → 누수!
}
free(data);
}
Rust에서는 값이 스코프를 벗어날 때 자동으로 drop이 호출됩니다.
소유권의 핵심 아이디어
소유권은 하나의 단순한 아이디어에서 출발합니다:
모든 값은 정확히 하나의 소유자(owner)가 있다. 소유자가 스코프를 벗어나면, 값은 자동으로 해제된다.
이게 전부입니다. 이 단순한 규칙에서 Rust의 모든 메모리 안전성이 나옵니다.
스코프와 자동 해제
fn main() {
// s는 이 시점에서 아직 존재하지 않음
{
let s = String::from("hello"); // s 생성, 힙에 메모리 할당
println!("{}", s); // s 사용
} // 이 중괄호에서 s의 스코프 끝 → drop(s) 자동 호출 → 메모리 해제
// println!("{}", s); // 에러! s는 여기서 존재하지 않음
}
TypeScript에서 변수는 스코프를 벗어나도 GC가 나중에 처리합니다:
function main() {
{
let s = "hello"; // 힙에 문자열 생성
console.log(s);
} // s는 여기서 참조 불가능하지만, GC가 나중에 해제
// 언제 해제될지 모름
}
Rust에서는 }를 만나는 순간 즉시, 결정론적으로 해제됩니다.
이 장의 구성
소유권 챕터는 세 부분으로 나뉩니다:
- 소유권 규칙 (2.1): Move vs Copy, String vs &str
- 참조와 빌림 (2.2): &T, &mut T, 빌림 규칙
- 슬라이스 (2.3): 문자열 슬라이스, 배열 슬라이스
이 세 개념을 이해하면 Rust의 메모리 모델이 완성됩니다.
요약
- 메모리 관리의 세 방법: 수동(C), GC(Java/JS), 소유권(Rust)
- 소유권은 GC 없이 메모리 안전성을 컴파일 타임에 보장
- 블록체인에서 중요: 배포 후 수정 불가, 예측 가능한 성능 필요
- 핵심 아이디어: 모든 값에 정확히 하나의 소유자, 스코프 종료 시 자동 해제
다음 챕터에서 세 가지 소유권 규칙을 구체적인 코드로 배웁니다.
2.1 소유권 규칙
세 가지 소유권 규칙
Rust의 소유권 시스템은 세 가지 규칙으로 요약됩니다:
- Rust의 각 값(value)은 **소유자(owner)**라고 불리는 변수를 가진다.
- 한 번에 소유자는 하나만 존재할 수 있다.
- 소유자가 스코프(scope)를 벗어나면 값은 드롭(drop)된다.
이 세 규칙이 모든 것의 기초입니다. 하나씩 살펴봅시다.
스택 vs 힙 메모리
소유권을 이해하려면 스택과 힙의 차이를 알아야 합니다.
스택 (Stack)
- 크기가 컴파일 타임에 알려진 값들을 저장
- LIFO(Last In, First Out) 구조
- 매우 빠름 (포인터를 밀고 빼는 것뿐)
- 함수 호출 시 자동 할당, 함수 종료 시 자동 해제
스택에 저장되는 타입들:
i32,u64,f64,bool,char등 기본 타입- 고정 크기 배열
[i32; 5] - 튜플
(i32, bool) - 포인터/참조 자체 (가리키는 데이터가 아닌 포인터)
힙 (Heap)
- 크기가 런타임에 결정되는 값들을 저장
- 운영체제에 메모리를 요청하고, 포인터를 받음
- 스택보다 느림 (할당/해제 비용 있음)
- 명시적으로 해제해야 함 (Rust는 소유권으로 자동화)
힙에 저장되는 타입들:
String(가변 길이 문자열)Vec<T>(가변 길이 벡터)Box<T>(힙에 할당된 값)HashMap<K, V>등
스택 힙
┌─────────┐ ┌──────────────────┐
│ ptr ───┼───────────►│ "hello, world" │
│ len: 5 │ └──────────────────┘
│ cap: 11│
└─────────┘
String s
String은 스택에 (포인터, 길이, 용량)을 저장하고, 실제 문자 데이터는 힙에 저장합니다.
이동(Move)
Copy 타입과 Move 타입
TypeScript에서는 모든 객체가 참조로 공유됩니다:
// TypeScript
let a = { value: 42 };
let b = a; // 참조 복사 (같은 객체를 가리킴)
b.value = 100;
console.log(a.value); // 100 (a도 변경됨!)
Rust는 다릅니다. 값을 변수에 할당하거나 함수에 전달하면 기본적으로 소유권이 이동합니다:
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1의 소유권이 s2로 이동 (move)
println!("{}", s2); // OK
println!("{}", s1); // 컴파일 에러!
// error[E0382]: borrow of moved value: `s1`
}
왜 이렇게 동작할까요?
String은 (포인터, 길이, 용량)으로 구성됩니다. s2 = s1을 하면:
이전: 이동 후:
s1: ptr → "hello" s1: (무효화됨)
s2: ptr → "hello"
만약 두 변수가 같은 포인터를 가지고 있다면, 스코프가 끝날 때 같은 메모리를 두 번 해제하는 double-free 버그가 생깁니다. Rust는 이를 막기 위해 이동 후 s1을 무효화합니다.
함수 인자로 전달 시 이동
fn takes_ownership(s: String) {
println!("Got: {}", s);
} // s의 스코프 끝 → drop 호출 → 메모리 해제
fn main() {
let s = String::from("hello");
takes_ownership(s); // s의 소유권이 함수로 이동
println!("{}", s); // 에러! s는 이미 이동됨
}
TypeScript에서는 이런 문제가 없습니다:
function takesString(s: string): void {
console.log(`Got: ${s}`);
}
const s = "hello";
takesString(s);
console.log(s); // 정상 동작 — JS는 문자열을 복사
함수에서 소유권 반환
소유권을 함수에서 돌려받을 수 있습니다:
fn gives_ownership() -> String {
let s = String::from("hello");
s // 소유권이 호출자에게 이동 (return 키워드 생략 가능)
}
fn takes_and_gives_back(s: String) -> String {
s // 받은 소유권을 그대로 반환
}
fn main() {
let s1 = gives_ownership(); // 소유권 획득
let s2 = String::from("world");
let s3 = takes_and_gives_back(s2); // s2 이동 → s3로 돌아옴
// s2는 더 이상 사용 불가
println!("{} {}", s1, s3);
}
이 패턴은 번거롭습니다. 이 문제를 해결하는 것이 다음 챕터의 **참조(Reference)**입니다.
Copy 타입
일부 타입은 이동 대신 **복사(copy)**됩니다:
fn main() {
let x = 5;
let y = x; // Copy! x는 여전히 유효
println!("{}", x); // OK — x가 복사됨
println!("{}", y); // OK
}
i32는 스택에만 저장되고 크기가 고정되어 있습니다. 복사 비용이 매우 낮으므로 Rust는 자동으로 복사합니다.
Copy 트레이트를 구현하는 타입들:
#![allow(unused)]
fn main() {
// 모든 정수 타입
let a: i8 = 1;
let b: i16 = 2;
let c: i32 = 3;
let d: i64 = 4;
let e: i128 = 5;
let f: u8 = 6;
// u16, u32, u64, u128, usize, isize 등
// 부동소수점
let g: f32 = 1.0;
let h: f64 = 2.0;
// bool
let i: bool = true;
// char
let j: char = 'A';
// 튜플 (모든 요소가 Copy인 경우)
let k: (i32, bool) = (1, true);
// 고정 크기 배열 (요소가 Copy인 경우)
let l: [i32; 3] = [1, 2, 3];
}
Copy가 아닌 타입들 (이동됨):
#![allow(unused)]
fn main() {
// String — 힙에 데이터가 있음
let s = String::from("hello");
// Vec<T> — 힙에 데이터가 있음
let v = vec![1, 2, 3];
// Box<T> — 힙 할당
let b = Box::new(42);
// 힙 데이터를 포함하는 모든 타입
}
Copy vs Clone
명시적으로 깊은 복사(deep copy)를 원하면 clone()을 사용합니다:
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // 힙 데이터까지 복사 (비용이 있음)
println!("{}", s1); // OK
println!("{}", s2); // OK — 완전히 독립된 복사본
}
TypeScript와 비교:
// TypeScript에서 깊은 복사
const obj1 = { name: "hello" };
const obj2 = { ...obj1 }; // 얕은 복사 (spread)
const obj3 = JSON.parse(JSON.stringify(obj1)); // 깊은 복사
String vs &str
Rust에서 가장 혼란스러운 부분 중 하나가 문자열 타입이 두 가지라는 점입니다.
String: 소유된 문자열
#![allow(unused)]
fn main() {
let s: String = String::from("hello");
let s2: String = "hello".to_string();
let s3: String = String::new(); // 빈 String
}
- 힙에 할당됨
- 가변(내용 변경 가능)
- 소유권이 있음
- 크기를 런타임에 알 수 있음
&str: 문자열 슬라이스 (참조)
#![allow(unused)]
fn main() {
let s: &str = "hello"; // 문자열 리터럴 — 프로그램 바이너리에 저장
}
- 어딘가의 문자열 데이터를 가리키는 참조
- 불변
- 소유권 없음 (빌려온 것)
- 크기가 고정됨
언제 무엇을 쓰나?
// 함수 인자: &str 를 선호 (더 유연함)
fn greet(name: &str) {
println!("Hello, {}!", name);
}
fn main() {
let owned = String::from("Alice");
greet(&owned); // String → &str 자동 변환 (deref coercion)
greet("Bob"); // &str 리터럴 직접 전달
}
// 반환값이나 구조체 필드: 소유권이 필요하면 String
struct User {
name: String, // 소유된 데이터
email: String,
}
// 임시 참조만 필요하면 &str (수명 어노테이션 필요할 수 있음)
struct Config<'a> {
name: &'a str, // 수명이 있는 참조 (나중에 자세히 배움)
}
TypeScript와 비교
TypeScript의 string은 Rust의 두 타입을 모두 커버합니다. Rust가 둘로 나눈 이유는 소유권과 성능 때문입니다:
// TypeScript: string은 항상 불변, 새 문자열 생성 시 새 할당
let s = "hello";
let s2 = s + " world"; // 새 문자열 할당
// JavaScript 엔진이 내부적으로 최적화해줌
#![allow(unused)]
fn main() {
// Rust: 명시적으로 선택
let s1 = "hello"; // &str: 복사 비용 없음, 불변
let s2 = String::from("hello"); // String: 힙 할당, 가변 가능
let s3 = s2 + " world"; // s2를 소비하고 새 String 반환
}
문자열 조작
fn main() {
// 문자열 생성
let mut s = String::from("Hello");
// 이어붙이기
s.push_str(", world"); // 문자열 이어붙이기
s.push('!'); // 문자 하나 이어붙이기
println!("{}", s); // "Hello, world!"
// + 연산자
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1의 소유권이 이동됨! s1은 더 이상 사용 불가
// s1을 소비하고 s2의 참조를 받아 새 String 반환
// format! 매크로 (소유권 이동 없음, 더 명확)
let s1 = String::from("Hello");
let s2 = String::from("world");
let s3 = format!("{}, {}!", s1, s2); // s1, s2 모두 여전히 유효
println!("{}", s1); // OK
println!("{}", s3); // "Hello, world!"
// 길이
let len = s3.len(); // 바이트 수
println!("Length: {}", len);
// 포함 여부
println!("{}", s3.contains("world")); // true
// 분리
let parts: Vec<&str> = "a,b,c".split(',').collect();
println!("{:?}", parts); // ["a", "b", "c"]
// 변환
let upper = "hello".to_uppercase(); // "HELLO"
let lower = "HELLO".to_lowercase(); // "hello"
let trimmed = " hello ".trim(); // "hello"
}
실제 블록체인 코드에서의 소유권
소유권이 실제 코드에서 어떻게 적용되는지 블록체인 예시로 봅시다:
#[derive(Debug)]
struct Block {
index: u64,
data: String, // 소유된 데이터
previous_hash: String,
hash: String,
}
impl Block {
fn new(index: u64, data: String, previous_hash: String) -> Block {
// data와 previous_hash의 소유권이 이 함수로 이동됨
let hash = calculate_hash(index, &data, &previous_hash);
// hash 계산 시 참조(&)를 사용 → 소유권 이동 없이 읽기만
Block {
index,
data,
previous_hash,
hash,
}
}
fn get_data(&self) -> &str {
// self.data의 참조를 반환 (&str)
// 소유권을 넘기지 않음
&self.data
}
}
fn calculate_hash(index: u64, data: &str, previous_hash: &str) -> String {
// &str로 받으므로 소유권 이동 없음
format!("{}:{}{}", index, data, previous_hash)
// 새 String을 생성해서 반환
}
fn main() {
let data = String::from("Genesis Block");
let prev_hash = String::from("0000000000000000");
// data와 prev_hash의 소유권이 Block::new로 이동
let block = Block::new(0, data, prev_hash);
// data와 prev_hash는 이제 Block이 소유
// println!("{}", data); // 에러! 이미 이동됨
// Block의 데이터는 참조로 읽기
let block_data: &str = block.get_data();
println!("Block data: {}", block_data);
println!("{:#?}", block);
}
소유권 규칙 정리
| 상황 | 동작 |
|---|---|
let y = x (Copy 타입) | 복사 — x, y 모두 유효 |
let y = x (Move 타입) | 이동 — y만 유효, x는 무효 |
func(x) (Copy 타입) | 복사 — x는 여전히 유효 |
func(x) (Move 타입) | 이동 — x는 무효 |
let y = x.clone() | 깊은 복사 — x, y 모두 유효 |
let y = &x | 참조 — 소유권 이동 없음 |
요약
- Rust는 세 가지 소유권 규칙으로 메모리 안전성을 보장
- 스택 타입(i32, bool 등)은 Copy, 힙 타입(String, Vec 등)은 Move
- Move 후에는 원래 변수를 사용할 수 없음
clone()으로 깊은 복사 가능 (비용 있음)String은 소유된 가변 문자열,&str은 불변 참조- 함수 인자로
&str을 선호, 소유권이 필요하면String
다음 챕터에서는 이 번거로움을 해결하는 **참조(Reference)**를 배웁니다.
2.2 참조와 빌림
참조가 왜 필요한가?
앞 챕터에서 소유권을 함수에 전달하면 원래 변수를 사용할 수 없게 된다는 걸 봤습니다:
fn calculate_length(s: String) -> usize {
s.len()
}
fn main() {
let s = String::from("hello");
let len = calculate_length(s); // s의 소유권이 이동!
println!("The length of '???' is {}.", len);
// println!("{}", s); // 에러! s는 이미 이동됨
}
이 문제를 해결하는 방법이 **참조(reference)**입니다. 참조를 사용하면 소유권을 넘기지 않고 값을 빌려 사용할 수 있습니다.
불변 참조 &T
fn calculate_length(s: &String) -> usize { // &String: String의 참조를 받음
s.len()
}
fn main() {
let s = String::from("hello");
let len = calculate_length(&s); // &s: s의 참조를 전달
println!("The length of '{}' is {}.", s, len); // s는 여전히 유효!
}
&s는 “s를 참조하지만 소유하지는 않는다“는 의미입니다. 소유권이 이동하지 않으므로, 참조가 스코프를 벗어나도 s는 해제되지 않습니다.
메모리 구조
스택 힙
┌─────────┐ ┌─────────────┐
│ s │ │ │
│ ptr ───┼───────►│ "hello" │
│ len: 5 │ │ │
│ cap: 5 │ └─────────────┘
└─────────┘ ▲
▲ │
┌─────────┐ │
│ r (&s)│ │
│ ptr ───┼───────────────┘
└─────────┘
(s를 가리키는 참조)
참조 자체는 스택에 있고, s의 스택 데이터(포인터, 길이, 용량)를 가리킵니다.
참조는 불변이 기본
#![allow(unused)]
fn main() {
fn try_to_change(s: &String) {
s.push_str(", world"); // 컴파일 에러!
// error[E0596]: cannot borrow `*s` as mutable, as it is behind a `&` reference
}
}
참조를 통해서는 기본적으로 값을 변경할 수 없습니다. 변경하려면 가변 참조를 써야 합니다.
가변 참조 &mut T
fn change(s: &mut String) {
s.push_str(", world"); // OK! 가변 참조로 변경 가능
}
fn main() {
let mut s = String::from("hello"); // mut 키워드 필요!
change(&mut s); // &mut s: 가변 참조 전달
println!("{}", s); // "hello, world"
}
가변 참조를 사용하려면:
- 변수 자체가
mut으로 선언되어야 함 - 참조를
&mut으로 만들어야 함 - 함수 인자 타입이
&mut T여야 함
TypeScript와 비교:
// TypeScript: 객체는 기본적으로 가변
function change(s: string[]): void {
s.push("world"); // 그냥 변경 가능
}
const arr = ["hello"];
change(arr);
console.log(arr); // ["hello", "world"]
// readonly로 불변 강제
function readOnly(s: readonly string[]): void {
s.push("world"); // 타입 에러! (컴파일 타임)
}
빌림 규칙 (Borrowing Rules)
Rust의 빌림 규칙은 참조가 항상 유효함을 보장합니다:
규칙 1: 가변 참조는 한 번에 하나만
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // 컴파일 에러!
// error[E0499]: cannot borrow `s` as mutable more than once at a time
println!("{}, {}", r1, r2);
}
왜? 두 개의 가변 참조가 동시에 존재하면 데이터 레이스(data race)가 발생할 수 있습니다.
데이터 레이스: 두 포인터가 같은 데이터를 동시에 접근하고, 적어도 하나가 쓰기 작업을 하며, 접근을 동기화하는 메커니즘이 없는 상황.
Node.js는 싱글 스레드여서 이 문제를 신경 쓸 필요가 없었습니다. Rust는 멀티스레드 환경을 기본으로 고려합니다.
// 해결법 1: 스코프 분리
fn main() {
let mut s = String::from("hello");
{
let r1 = &mut s;
println!("{}", r1);
} // r1의 스코프 끝
let r2 = &mut s; // OK! r1은 이미 스코프를 벗어남
println!("{}", r2);
}
// 해결법 2: NLL (Non-Lexical Lifetimes) — 마지막 사용 후 참조 종료
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
println!("{}", r1); // r1의 마지막 사용
// r1은 여기서 더 이상 사용되지 않으므로 유효 범위 종료
let r2 = &mut s; // OK!
println!("{}", r2);
}
규칙 2: 불변 참조와 가변 참조의 공존 불가
fn main() {
let mut s = String::from("hello");
let r1 = &s; // 불변 참조
let r2 = &s; // 불변 참조 (여러 개 OK)
let r3 = &mut s; // 컴파일 에러!
// error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
println!("{}, {}, {}", r1, r2, r3);
}
왜? 불변 참조를 사용하는 코드는 값이 변경되지 않을 거라고 기대합니다. 가변 참조가 동시에 존재하면 이 기대가 깨집니다.
// NLL 덕분에 이건 OK
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2); // r1, r2의 마지막 사용
// r1, r2가 더 이상 사용되지 않으므로
let r3 = &mut s; // OK!
println!("{}", r3);
}
규칙 정리
| 허용 여부 | 상황 |
|---|---|
| ✅ 허용 | 불변 참조 여러 개 동시에 |
| ✅ 허용 | 가변 참조 하나만 (불변 참조 없을 때) |
| ❌ 불허 | 가변 참조 여러 개 동시에 |
| ❌ 불허 | 불변 참조 + 가변 참조 동시에 |
댕글링 참조 방지
Rust의 컴파일러는 댕글링 참조(dangling reference)를 방지합니다:
#![allow(unused)]
fn main() {
fn dangle() -> &String { // 컴파일 에러!
let s = String::from("hello");
&s // s의 참조를 반환하려고 시도
} // s는 여기서 drop됨! — 반환된 참조가 가리키는 메모리가 해제됨
// error[E0106]: missing lifetime specifier
// help: this function's return type contains a borrowed value,
// but there is no value for it to be borrowed from
}
해결책: 소유권을 반환
#![allow(unused)]
fn main() {
fn no_dangle() -> String {
let s = String::from("hello");
s // 소유권을 반환 — 메모리가 해제되지 않음
}
}
실용적인 참조 패턴
패턴 1: 읽기만 할 때 &T
struct Block {
index: u64,
hash: String,
data: String,
}
impl Block {
// self를 참조로 받음 — 소유권 이동 없음
fn get_hash(&self) -> &str {
&self.hash
}
fn get_data(&self) -> &str {
&self.data
}
fn is_valid(&self) -> bool {
!self.hash.is_empty()
}
}
fn print_block(block: &Block) { // 참조로 받음
println!("Block {}: {}", block.index, block.hash);
}
fn main() {
let block = Block {
index: 0,
hash: String::from("abc123"),
data: String::from("genesis"),
};
print_block(&block); // 소유권 이동 없음
println!("Hash: {}", block.get_hash());
println!("Valid: {}", block.is_valid());
// block은 여전히 유효
}
패턴 2: 수정이 필요할 때 &mut T
struct Blockchain {
blocks: Vec<Block>,
}
impl Blockchain {
// &mut self: 자신을 가변으로 빌림
fn add_block(&mut self, block: Block) {
self.blocks.push(block);
}
fn get_last_block(&self) -> Option<&Block> {
self.blocks.last()
}
fn len(&self) -> usize {
self.blocks.len()
}
}
fn main() {
let mut chain = Blockchain { blocks: vec![] };
chain.add_block(Block {
index: 0,
hash: String::from("000abc"),
data: String::from("genesis"),
});
if let Some(last) = chain.get_last_block() {
println!("Last block: {}", last.index);
}
println!("Chain length: {}", chain.len());
}
패턴 3: 여러 필드를 동시에 가변 참조
struct Point {
x: f64,
y: f64,
}
fn main() {
let mut p = Point { x: 1.0, y: 2.0 };
// 구조체의 서로 다른 필드를 동시에 가변 참조 — OK
let rx = &mut p.x;
let ry = &mut p.y;
*rx += 1.0;
*ry += 1.0;
println!("({}, {})", p.x, p.y);
}
역참조 연산자 *
참조를 통해 실제 값에 접근하려면 *(역참조)를 씁니다:
fn main() {
let x = 5;
let r = &x; // r은 x의 참조
println!("{}", r); // 자동 역참조 — 5 출력
println!("{}", *r); // 명시적 역참조 — 5 출력
let mut y = 10;
let ry = &mut y;
*ry += 1; // 역참조 후 수정
println!("{}", y); // 11
}
대부분의 경우 Rust가 자동으로 역참조합니다(deref coercion). . 연산자는 자동으로 역참조합니다:
fn main() {
let s = String::from("hello");
let r = &s;
// 다음 두 줄은 동일
println!("{}", r.len()); // 자동 역참조
println!("{}", (*r).len()); // 명시적 역참조
}
함수에서 참조 반환 시 주의사항
// OK: 입력 참조의 수명을 그대로 반환
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &byte) in bytes.iter().enumerate() {
if byte == b' ' {
return &s[..i];
}
}
s
}
fn main() {
let sentence = String::from("hello world");
let word = first_word(&sentence);
// sentence.clear(); // 에러! word가 sentence를 참조 중
println!("First word: {}", word);
}
반환되는 참조의 수명은 입력 참조의 수명과 연결됩니다. sentence가 살아있는 동안만 word를 사용할 수 있습니다.
TypeScript/Node.js 개발자를 위한 핵심 차이점
// TypeScript: 참조는 항상 공유 가능
class Block {
data: string;
constructor(data: string) { this.data = data; }
}
const block = new Block("genesis");
const ref1 = block; // 같은 객체를 가리킴
const ref2 = block; // 같은 객체를 가리킴
ref1.data = "modified";
console.log(ref2.data); // "modified" — 같은 객체!
// Rust: 불변 참조는 여러 개, 가변 참조는 하나만
let mut block = Block { data: String::from("genesis") };
let ref1 = █ // 불변 참조
let ref2 = █ // 불변 참조 — OK
// let ref3 = &mut block; // 에러! 불변 참조가 살아있는 동안 불가
// 불변 참조를 다 쓴 후
let ref3 = &mut block; // OK
ref3.data = String::from("modified");
이 규칙이 멀티스레드 데이터 레이스를 컴파일 타임에 방지합니다.
요약
&T: 불변 참조 — 소유권 이동 없이 읽기만&mut T: 가변 참조 — 소유권 이동 없이 읽기/쓰기- 빌림 규칙: 불변 참조 여러 개 OR 가변 참조 하나 (동시에 둘 다 안 됨)
- 댕글링 참조: 컴파일러가 방지
*: 역참조 연산자 (.연산자는 자동 역참조)- 함수 인자는 가능하면
&T나&str로 받아서 소유권 이동 방지
다음 챕터에서는 슬라이스(slice)를 배웁니다.
2.3 슬라이스
슬라이스란?
슬라이스(slice)는 컬렉션의 일부를 소유권 없이 참조하는 타입입니다. 연속된 메모리의 특정 구간을 가리키는 “창문“이라고 생각하면 됩니다.
슬라이스는 두 가지 정보를 가집니다:
- 시작 위치를 가리키는 포인터
- 슬라이스의 길이
문자열 슬라이스 &str
앞서 &str이 “문자열 슬라이스“라고 했습니다. 이제 그 의미를 명확히 봅시다.
fn main() {
let s = String::from("hello world");
// 슬라이스: [시작 인덱스..끝 인덱스(미포함)]
let hello = &s[0..5]; // "hello"
let world = &s[6..11]; // "world"
println!("{}", hello); // hello
println!("{}", world); // world
// 처음부터: [..5] == [0..5]
let hello2 = &s[..5];
// 끝까지: [6..] == [6..s.len()]
let world2 = &s[6..];
// 전체: [..] == [0..s.len()]
let whole = &s[..];
println!("{} {}", hello2, world2);
println!("{}", whole);
}
메모리 구조
String s:
스택 힙
┌─────────┐ ┌─────────────────────────┐
│ ptr ───┼────────►│ h e l l o w o r l d │
│ len:11 │ │ 0 1 2 3 4 5 6 7 8 9 10 │
│ cap:11 │ └─────────────────────────┘
└─────────┘ ▲ ▲
│ │
&s[0..5] "hello": │ │
┌─────────┐ │ │
│ ptr ───┼─────────────────┘ │
│ len: 5 │ │
└─────────┘ │
│
&s[6..11] "world": │
┌─────────┐ │
│ ptr ───┼───────────────────────────┘
│ len: 5 │
└─────────┘
슬라이스는 새 메모리를 할당하지 않습니다. 원본 String의 특정 구간을 참조할 뿐입니다.
슬라이스와 소유권
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // &s의 슬라이스를 반환
// word는 s의 일부를 참조
s.clear(); // 컴파일 에러!
// error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
// word가 s를 불변으로 빌리고 있으므로 s를 수정할 수 없음
println!("The first word is: {}", word);
}
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &byte) in bytes.iter().enumerate() {
if byte == b' ' {
return &s[..i];
}
}
s
}
슬라이스가 원본 데이터의 참조를 유지하므로, 원본이 수정되면 슬라이스가 무효화될 수 있습니다. Rust는 이를 컴파일 타임에 방지합니다.
문자열 리터럴은 슬라이스
#![allow(unused)]
fn main() {
let s = "hello world"; // 타입: &str
}
문자열 리터럴은 프로그램 바이너리에 저장된 문자열 데이터를 가리키는 슬라이스입니다. 그래서 타입이 &str이고 불변입니다.
함수에서 &String 대신 &str 선호
// 덜 유연한 버전
fn first_word_v1(s: &String) -> &str {
// String 참조만 받을 수 있음
&s[..5]
}
// 더 유연한 버전 (권장)
fn first_word_v2(s: &str) -> &str {
// String 참조(&String)도 받을 수 있고,
// 문자열 리터럴(&str)도 받을 수 있음
&s[..5]
}
fn main() {
let owned = String::from("hello world");
let literal = "hello world";
// &String → &str 자동 변환 (deref coercion)
first_word_v2(&owned); // OK
first_word_v2(literal); // OK (이미 &str)
// &String만 받는 함수
first_word_v1(&owned); // OK
// first_word_v1(literal); // 에러! 타입 불일치
}
TypeScript에서 string과 String 객체를 구분하지 않는 것과 달리, Rust에서는 &str이 더 범용적인 타입입니다.
배열 슬라이스 &[T]
문자열 슬라이스와 동일한 개념이 배열에도 적용됩니다:
fn main() {
let a = [1, 2, 3, 4, 5];
// 배열의 슬라이스
let slice = &a[1..3]; // [2, 3]
println!("{:?}", slice);
// 슬라이스의 타입은 &[i32]
let first_three: &[i32] = &a[..3];
println!("{:?}", first_three); // [1, 2, 3]
// 슬라이스의 길이
println!("Length: {}", slice.len());
// 슬라이스 반복
for item in slice {
println!("{}", item);
}
}
Vec에서 슬라이스
fn sum(numbers: &[i32]) -> i32 { // &[i32]: i32 배열의 슬라이스
numbers.iter().sum()
}
fn main() {
// Vec에서 슬라이스
let v = vec![1, 2, 3, 4, 5];
let total = sum(&v); // Vec → &[i32] 자동 변환
let partial = sum(&v[1..4]); // 일부만
// 배열에서 슬라이스
let a = [1, 2, 3, 4, 5];
let total2 = sum(&a); // [i32; 5] → &[i32] 자동 변환
println!("{}, {}, {}", total, partial, total2);
}
&[T]를 인자로 받으면 Vec<T>와 [T; N] 모두 받을 수 있습니다. &str이 String과 &str 리터럴 모두를 받을 수 있는 것과 동일한 패턴입니다.
슬라이스 관련 주요 메서드
fn main() {
let v = vec![3, 1, 4, 1, 5, 9, 2, 6];
let s = v.as_slice(); // Vec → &[i32]
// 길이
println!("len: {}", s.len());
// 비어있는지
println!("empty: {}", s.is_empty());
// 첫/마지막 원소
println!("first: {:?}", s.first()); // Some(3)
println!("last: {:?}", s.last()); // Some(6)
// 인덱스 접근 (안전한 방법)
println!("get(2): {:?}", s.get(2)); // Some(4)
println!("get(100): {:?}", s.get(100)); // None (panic 없음!)
// 인덱스 접근 (위험한 방법 — 범위 초과시 panic)
println!("s[2]: {}", s[2]); // 4
// 포함 여부
println!("contains 9: {}", s.contains(&9));
// 정렬 (슬라이스에서 직접 정렬하면 원본이 바뀜)
let mut v2 = vec![3, 1, 4, 1, 5];
v2.sort();
println!("{:?}", v2); // [1, 1, 3, 4, 5]
// 분할
let (left, right) = s.split_at(4);
println!("left: {:?}", left); // [3, 1, 4, 1]
println!("right: {:?}", right); // [5, 9, 2, 6]
// 청크로 나누기
for chunk in s.chunks(3) {
println!("{:?}", chunk);
}
// [3, 1, 4]
// [1, 5, 9]
// [2, 6]
// 윈도우 슬라이딩
for window in s.windows(3) {
println!("{:?}", window);
}
// [3, 1, 4]
// [1, 4, 1]
// [4, 1, 5]
// [1, 5, 9]
// [5, 9, 2]
// [9, 2, 6]
}
블록체인에서 슬라이스 활용
fn verify_chain(blocks: &[Block]) -> bool {
// &[Block]: Block 슬라이스 (소유권 없이 검증)
if blocks.is_empty() {
return true;
}
// 연속한 두 블록 쌍을 윈도우로 검사
for window in blocks.windows(2) {
let prev = &window[0];
let curr = &window[1];
if curr.previous_hash != prev.hash {
return false;
}
if curr.index != prev.index + 1 {
return false;
}
}
true
}
struct Block {
index: u64,
hash: String,
previous_hash: String,
}
fn main() {
let chain = vec![
Block { index: 0, hash: "abc".to_string(), previous_hash: "000".to_string() },
Block { index: 1, hash: "def".to_string(), previous_hash: "abc".to_string() },
Block { index: 2, hash: "ghi".to_string(), previous_hash: "def".to_string() },
];
println!("Chain valid: {}", verify_chain(&chain));
// verify_chain이 chain의 소유권을 가져가지 않음
println!("Chain length: {}", chain.len());
}
슬라이스 인덱싱 주의사항
Rust의 문자열은 UTF-8로 인코딩됩니다. 멀티바이트 문자를 바이트 인덱스로 자르면 패닉이 발생합니다:
fn main() {
let s = String::from("안녕하세요");
// 한글은 UTF-8에서 3바이트
// "안"은 바이트 0..3, "녕"은 3..6, ...
// 이건 OK (바이트 경계에 맞게)
let an = &s[0..3]; // "안"
println!("{}", an);
// 이건 패닉! (바이트 경계 중간을 자름)
// let wrong = &s[0..1]; // panic!
// thread 'main' panicked at 'byte index 1 is not a char boundary'
// 안전한 방법: chars()로 문자 단위 접근
let first_char: Option<char> = s.chars().next();
println!("{:?}", first_char); // Some('안')
// 문자 단위 슬라이싱
let first_two: String = s.chars().take(2).collect();
println!("{}", first_two); // "안녕"
}
TypeScript와 비교:
const s = "안녕하세요";
const first = s[0]; // "안" — JS는 문자 단위로 인덱싱
const slice = s.slice(0, 2); // "안녕" — 문자 단위
JavaScript/TypeScript는 내부적으로 UTF-16을 사용하고 인덱싱이 코드 유닛 기준이라 이모지 같은 서로게이트 페어에서도 문제가 생길 수 있습니다. Rust는 더 명시적입니다.
요약
| 개념 | 타입 | 설명 |
|---|---|---|
| 문자열 소유 | String | 힙에 할당된 가변 문자열 |
| 문자열 슬라이스 | &str | 문자열 데이터의 참조 |
| 문자열 리터럴 | &str | 바이너리에 저장된 데이터의 참조 |
| 배열 슬라이스 | &[T] | 배열/Vec의 일부 참조 |
- 슬라이스는 새 메모리를 할당하지 않음 (참조)
&str을 인자로 받으면String과&str리터럴 모두 받을 수 있음&[T]를 인자로 받으면Vec<T>와[T; N]모두 받을 수 있음- UTF-8 문자열의 바이트 인덱싱은 주의 필요
다음으로는 블록과 체인 구조를 알아본 후, 구조체와 열거형으로 데이터를 모델링하는 방법을 배웁니다.
3장: 구조체와 열거형
데이터를 모델링하는 방법
모든 프로그램은 데이터를 표현해야 합니다. TypeScript에서는 class와 interface로 데이터를 모델링했습니다. Rust에서는 **구조체(struct)**와 **열거형(enum)**을 사용합니다.
TypeScript vs Rust 데이터 모델링
// TypeScript: class로 데이터 + 메서드 묶기
class Block {
index: number;
timestamp: number;
data: string;
previousHash: string;
hash: string;
constructor(index: number, data: string, previousHash: string) {
this.index = index;
this.timestamp = Date.now();
this.data = data;
this.previousHash = previousHash;
this.hash = this.calculateHash();
}
calculateHash(): string {
return sha256(this.index + this.timestamp + this.data + this.previousHash);
}
}
// Rust: struct로 데이터, impl 블록으로 메서드
struct Block {
index: u64,
timestamp: u64,
data: String,
previous_hash: String,
hash: String,
}
impl Block {
// 연관 함수 (TypeScript의 static 메서드)
fn new(index: u64, data: String, previous_hash: String) -> Block {
let timestamp = current_timestamp();
let hash = calculate_hash(index, timestamp, &data, &previous_hash);
Block { index, timestamp, data, previous_hash, hash }
}
// 메서드 (TypeScript의 인스턴스 메서드)
fn calculate_hash(&self) -> String {
calculate_hash(self.index, self.timestamp, &self.data, &self.previous_hash)
}
}
이 코드를 처음 읽을 때는 문법보다 역할을 먼저 보세요.
| Rust 코드 | 역할 |
|---|---|
struct Block { ... } | Block이라는 데이터 모양을 정의 |
index: u64 | 필드 이름은 index, 타입은 u64 |
impl Block { ... } | Block에 붙는 함수와 메서드를 정의 |
fn new(...) -> Block | 새 Block을 만들어 반환하는 생성 함수 |
fn calculate_hash(&self) -> String | 이미 만들어진 Block을 읽어 해시 문자열을 반환하는 메서드 |
&self | 이 메서드가 Block을 소유하지 않고 읽기만 빌린다는 뜻 |
Block { index, timestamp, data, previous_hash, hash } | 필드 값을 채워 새 구조체 인스턴스를 만드는 문법 |
TypeScript class는 데이터와 메서드가 한 덩어리지만, Rust는 데이터(struct)와 동작(impl)을 분리해서 읽습니다. 앞으로 블록체인 예제의 대부분은 Transaction, Block, Blockchain 같은 구조체를 먼저 정의하고, 그 아래 impl에서 생성, 해싱, 검증 동작을 붙이는 패턴으로 작성됩니다.
핵심 차이점:
- Rust의
struct는 데이터만 정의 (상속 없음) - 메서드는
impl블록에 별도로 정의 - 상속 대신 **트레이트(trait)**로 공통 동작 추상화
이 장의 구성
- 구조체 (3.1): 데이터 정의, 메서드, 연관 함수
- 열거형 (3.2): 대수적 데이터 타입, Option, Result
- 패턴 매칭 (3.3): match, if let, while let
왜 이게 블록체인에서 중요한가?
블록체인 스마트 컨트랙트는 상태(state)를 정의하고 트랜잭션을 처리합니다. 상태를 올바르게 모델링하는 것이 보안의 핵심입니다.
예를 들어 Solana의 온체인 프로그램에서:
// Solana 프로그램의 계정 상태 (실제 패턴)
#[account]
pub struct TokenAccount {
pub mint: Pubkey,
pub owner: Pubkey,
pub amount: u64,
pub delegate: Option<Pubkey>, // Option으로 null 안전하게 처리
pub state: AccountState, // 열거형으로 상태 표현
pub is_native: Option<u64>,
pub delegated_amount: u64,
pub close_authority: Option<Pubkey>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum AccountState {
Uninitialized,
Initialized,
Frozen,
}
구조체와 열거형을 제대로 이해해야 이런 코드를 읽고 작성할 수 있습니다.
다음 챕터에서 구조체를 자세히 배웁니다.
3.1 구조체 (Structs)
구조체 정의
구조체는 관련된 데이터를 하나로 묶는 타입입니다.
#![allow(unused)]
fn main() {
// 기본 구조체 정의
struct Block {
index: u64,
timestamp: u64,
data: String,
previous_hash: String,
hash: String,
nonce: u64,
}
}
TypeScript의 interface/class와 비교:
// TypeScript interface
interface Block {
index: number;
timestamp: number;
data: string;
previousHash: string;
hash: string;
nonce: number;
}
// TypeScript class
class Block {
index: number;
timestamp: number;
data: string;
previousHash: string;
hash: string;
nonce: number;
constructor(index: number, data: string, previousHash: string) {
this.index = index;
this.timestamp = Date.now();
this.data = data;
this.previousHash = previousHash;
this.hash = "";
this.nonce = 0;
}
}
구조체 인스턴스 생성
fn main() {
// 모든 필드를 지정해서 생성
let block = Block {
index: 0,
timestamp: 1700000000,
data: String::from("Genesis Block"),
previous_hash: String::from("0000000000000000"),
hash: String::from("abc123"),
nonce: 0,
};
// 필드 접근
println!("Block #{}: {}", block.index, block.hash);
println!("Data: {}", block.data);
}
주의: 구조체 인스턴스를 수정하려면 변수 자체가 mut이어야 합니다:
fn main() {
let mut block = Block {
index: 0,
timestamp: 1700000000,
data: String::from("Genesis Block"),
previous_hash: String::from("0000000000000000"),
hash: String::from(""),
nonce: 0,
};
// 필드 수정 (mut이므로 가능)
block.hash = String::from("computed_hash");
block.nonce = 42;
println!("Hash: {}, Nonce: {}", block.hash, block.nonce);
}
TypeScript는 const 객체도 내부 필드를 수정할 수 있지만, Rust는 변수가 mut이어야 합니다.
필드 초기화 단축 문법
함수 매개변수 이름과 구조체 필드 이름이 같으면 단축 문법을 쓸 수 있습니다:
fn create_block(index: u64, data: String, previous_hash: String) -> Block {
let timestamp = get_current_timestamp();
let nonce = 0;
let hash = compute_hash(index, timestamp, &data, &previous_hash, nonce);
Block {
index, // index: index 대신
timestamp, // timestamp: timestamp 대신
data, // data: data 대신
previous_hash, // previous_hash: previous_hash 대신
hash,
nonce,
}
}
TypeScript의 shorthand property와 동일합니다:
// TypeScript
function createBlock(index: number, data: string): Block {
const timestamp = Date.now();
return { index, timestamp, data }; // shorthand
}
구조체 업데이트 문법
기존 인스턴스의 일부 필드만 변경한 새 인스턴스를 만들 때:
fn main() {
let block1 = Block {
index: 0,
timestamp: 1700000000,
data: String::from("Genesis"),
previous_hash: String::from("0000"),
hash: String::from("abc"),
nonce: 0,
};
// ..block1: 나머지 필드는 block1에서 가져옴
let block2 = Block {
index: 1,
data: String::from("Second Block"),
hash: String::from("def"),
..block1 // 나머지 필드 (timestamp, previous_hash, nonce)는 block1에서
};
// 주의: Copy가 아닌 필드(String)는 이동됨!
// block1.previous_hash는 이제 block2가 소유
// println!("{}", block1.previous_hash); // 에러!
println!("Block {}: {}", block2.index, block2.data);
}
TypeScript의 spread 연산자와 유사:
const block2 = { ...block1, index: 1, data: "Second Block" };
튜플 구조체
이름 없는 필드를 가진 구조체입니다:
// 튜플 구조체 정의
struct Color(u8, u8, u8); // RGB
struct Point(f64, f64, f64); // 3D 좌표
struct Hash(String); // newtype 패턴
fn main() {
let red = Color(255, 0, 0);
let origin = Point(0.0, 0.0, 0.0);
let h = Hash(String::from("abc123"));
// 인덱스로 접근
println!("R: {}, G: {}, B: {}", red.0, red.1, red.2);
println!("x: {}", origin.0);
println!("Hash: {}", h.0);
}
Newtype 패턴: 타입 안전성을 위해 기본 타입을 감싸는 관용구입니다:
struct BlockHeight(u64);
struct TransactionId(String);
struct Wei(u128); // Ethereum 최소 단위
fn process_block(height: BlockHeight) {
println!("Processing block at height {}", height.0);
}
fn main() {
let height = BlockHeight(12345);
process_block(height);
// process_block(12345u64); // 에러! 타입이 다름
// 실수로 잘못된 숫자를 넣는 것을 컴파일 타임에 방지
}
유닛 구조체
필드가 없는 구조체입니다. 트레이트를 구현하기 위한 타입으로 자주 사용됩니다:
#![allow(unused)]
fn main() {
struct AlwaysEqual; // 필드 없음
// 트레이트 구현 시 유용
struct Block {
index: u64,
data: String,
}
struct GenesisBlock;
impl GenesisBlock {
fn create() -> Block {
Block {
index: 0,
data: String::from("Genesis Block"),
}
}
}
}
impl 블록: 메서드와 연관 함수
struct Block {
index: u64,
timestamp: u64,
data: String,
previous_hash: String,
hash: String,
nonce: u64,
}
impl Block {
// === 연관 함수 (Associated Functions) ===
// self 매개변수 없음 → TypeScript의 static 메서드
/// 제네시스 블록 생성
fn genesis() -> Block {
Block {
index: 0,
timestamp: 0,
data: String::from("Genesis Block"),
previous_hash: String::from("0000000000000000"),
hash: String::from(""),
nonce: 0,
}
}
/// 새 블록 생성
fn new(index: u64, data: String, previous_hash: String) -> Block {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let nonce = 0;
let hash = Block::compute_hash(index, timestamp, &data, &previous_hash, nonce);
Block { index, timestamp, data, previous_hash, hash, nonce }
}
/// 해시 계산 (내부용 연관 함수)
fn compute_hash(
index: u64,
timestamp: u64,
data: &str,
previous_hash: &str,
nonce: u64,
) -> String {
format!("{}{}{}{}{}", index, timestamp, data, previous_hash, nonce)
// 실제로는 SHA-256 해시를 써야 함
}
// === 메서드 (Methods) ===
// &self: 불변 참조 — 읽기 전용
// &mut self: 가변 참조 — 수정 가능
// self: 소유권 이동 — 인스턴스 소비
/// 블록 해시 반환 (&self: 읽기 전용)
fn get_hash(&self) -> &str {
&self.hash
}
/// 블록 인덱스 반환
fn index(&self) -> u64 {
self.index
}
/// 블록이 유효한지 검증
fn is_valid(&self) -> bool {
let expected = Block::compute_hash(
self.index,
self.timestamp,
&self.data,
&self.previous_hash,
self.nonce,
);
self.hash == expected
}
/// 해시 재계산 (&mut self: 수정)
fn recalculate_hash(&mut self) {
self.hash = Block::compute_hash(
self.index,
self.timestamp,
&self.data,
&self.previous_hash,
self.nonce,
);
}
/// nonce 증가 (&mut self)
fn increment_nonce(&mut self) {
self.nonce += 1;
self.recalculate_hash();
}
/// 블록 요약 문자열 (self를 소비: 재사용 불가)
fn into_summary(self) -> String {
format!("Block #{} [{}]: {}", self.index, &self.hash[..8], self.data)
// self가 소비됨
}
}
TypeScript class와 비교
// TypeScript
class Block {
index: number;
timestamp: number;
data: string;
previousHash: string;
hash: string;
nonce: number;
// static 메서드 (Rust의 연관 함수)
static genesis(): Block {
return new Block(0, "Genesis", "0000000000000000");
}
constructor(index: number, data: string, previousHash: string) {
this.index = index;
this.timestamp = Date.now();
this.data = data;
this.previousHash = previousHash;
this.nonce = 0;
this.hash = this.computeHash();
}
// 인스턴스 메서드 (Rust의 &self 메서드)
computeHash(): string {
return sha256(this.index + this.timestamp + this.data + this.previousHash + this.nonce);
}
isValid(): boolean {
return this.hash === this.computeHash();
}
}
핵심 차이:
| TypeScript | Rust |
|---|---|
constructor | 연관 함수 fn new() (관례) |
static method | 연관 함수 (self 없음) |
this.field | self.field |
메서드는 항상 this 변경 가능 | &self (읽기), &mut self (쓰기) 명시 |
| 상속 가능 | 상속 없음, 트레이트로 대체 |
여러 impl 블록
하나의 구조체에 여러 impl 블록을 가질 수 있습니다. 트레이트 구현이나 코드 구성에 유용합니다:
struct Block {
index: u64,
data: String,
previous_hash: String,
hash: String,
}
impl Block {
// 생성자들
fn genesis() -> Block {
Block {
index: 0,
data: String::from("Genesis Block"),
previous_hash: String::from("0000"),
hash: String::from("genesis-hash"),
}
}
fn new(index: u64, data: String, previous_hash: String) -> Block {
let hash = format!("hash-{index}");
Block { index, data, previous_hash, hash }
}
}
impl Block {
// 검증 메서드들
fn is_valid(&self) -> bool {
!self.hash.is_empty() && !self.previous_hash.is_empty()
}
fn verify_hash(&self) -> bool {
self.hash == format!("hash-{}", self.index) || self.index == 0
}
}
impl Block {
// 변환 메서드들
fn to_json(&self) -> String {
format!(
r#"{{"index":{},"data":"{}","hash":"{}"}}"#,
self.index, self.data, self.hash
)
}
fn into_summary(self) -> String {
format!("Block #{}: {}", self.index, self.hash)
}
}
구조체 출력: Debug와 Display
구조체를 println!으로 출력하려면 트레이트를 구현해야 합니다:
// derive 매크로로 자동 구현 (간단하지만 포맷이 고정)
#[derive(Debug)]
struct Block {
index: u64,
hash: String,
data: String,
}
fn main() {
let block = Block {
index: 0,
hash: String::from("abc123"),
data: String::from("Genesis"),
};
// Debug 출력 ({:?})
println!("{:?}", block);
// Block { index: 0, hash: "abc123", data: "Genesis" }
// 예쁜 Debug 출력 ({:#?})
println!("{:#?}", block);
// Block {
// index: 0,
// hash: "abc123",
// data: "Genesis",
// }
}
커스텀 출력 형식을 원하면 Display 트레이트를 직접 구현합니다:
use std::fmt;
struct Block {
index: u64,
hash: String,
data: String,
}
impl fmt::Display for Block {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Block #{} [{}...]: {}", self.index, &self.hash[..6], self.data)
}
}
fn main() {
let block = Block {
index: 0,
hash: String::from("abc123def456"),
data: String::from("Genesis"),
};
println!("{}", block); // Block #0 [abc123...]: Genesis
}
블록체인 전체 구조체 예시
use std::fmt;
#[derive(Debug, Clone)]
struct Block {
index: u64,
timestamp: u64,
data: String,
previous_hash: String,
hash: String,
nonce: u64,
}
impl Block {
fn new(index: u64, data: String, previous_hash: String) -> Self {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("Time went backwards")
.as_secs();
let mut block = Block {
index,
timestamp,
data,
previous_hash,
hash: String::new(),
nonce: 0,
};
block.hash = block.calculate_hash();
block
}
fn calculate_hash(&self) -> String {
// 실제 구현에서는 SHA-256 사용
format!("{:x}", self.index + self.timestamp + self.nonce)
}
fn mine(&mut self, difficulty: usize) {
let target = "0".repeat(difficulty);
while !self.hash.starts_with(&target) {
self.nonce += 1;
self.hash = self.calculate_hash();
}
println!("Block mined! Nonce: {}, Hash: {}", self.nonce, self.hash);
}
}
impl fmt::Display for Block {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Block #{}\n Timestamp: {}\n Data: {}\n Hash: {}\n Nonce: {}",
self.index, self.timestamp, self.data, self.hash, self.nonce
)
}
}
#[derive(Debug)]
struct Blockchain {
blocks: Vec<Block>,
difficulty: usize,
}
impl Blockchain {
fn new() -> Self {
let genesis = Block::new(0, String::from("Genesis Block"), String::from("0"));
Blockchain {
blocks: vec![genesis],
difficulty: 2,
}
}
fn add_block(&mut self, data: String) {
let previous_hash = self.blocks.last()
.map(|b| b.hash.clone())
.unwrap_or_default();
let index = self.blocks.len() as u64;
let mut block = Block::new(index, data, previous_hash);
block.mine(self.difficulty);
self.blocks.push(block);
}
fn is_valid(&self) -> bool {
for i in 1..self.blocks.len() {
let current = &self.blocks[i];
let previous = &self.blocks[i - 1];
if current.hash != current.calculate_hash() {
return false;
}
if current.previous_hash != previous.hash {
return false;
}
}
true
}
}
fn main() {
let mut blockchain = Blockchain::new();
blockchain.add_block(String::from("Alice sends 1 BTC to Bob"));
blockchain.add_block(String::from("Bob sends 0.5 BTC to Carol"));
for block in &blockchain.blocks {
println!("{}\n", block);
}
println!("Blockchain valid: {}", blockchain.is_valid());
}
요약
struct로 관련 데이터를 묶음 (TypeScript의 interface/class의 데이터 부분)impl블록에 메서드와 연관 함수를 정의- 연관 함수:
self없음 → TypeScriptstatic에 해당,Type::function()으로 호출 - 메서드:
&self(읽기),&mut self(쓰기),self(소비) 세 가지 #[derive(Debug)]로 자동 디버그 출력- 커스텀 출력은
fmt::Display트레이트 구현 - 상속 없음 — 대신 트레이트(5장)로 공통 동작 추상화
다음 챕터에서는 더 강력한 타입인 열거형(enum)을 배웁니다.
3.2 열거형 (Enums)
Rust의 열거형은 강력하다
TypeScript의 enum은 단순한 상수 집합입니다. Rust의 enum은 **대수적 데이터 타입(Algebraic Data Type)**으로, 각 배리언트(variant)가 서로 다른 타입과 양의 데이터를 가질 수 있습니다.
// TypeScript enum — 단순한 상수
enum Direction {
North,
South,
East,
West,
}
// TypeScript discriminated union — 데이터를 가진 유니온
type Message =
| { type: "Text"; content: string }
| { type: "Move"; x: number; y: number }
| { type: "ChangeColor"; r: number; g: number; b: number };
#![allow(unused)]
fn main() {
// Rust enum — 단순 배리언트
enum Direction {
North,
South,
East,
West,
}
// Rust enum — 데이터를 가진 배리언트 (훨씬 자연스럽게)
enum Message {
Text(String), // 튜플 배리언트
Move { x: i32, y: i32 }, // 구조체 배리언트
ChangeColor(u8, u8, u8), // 여러 값을 가진 튜플 배리언트
Quit, // 데이터 없는 배리언트
}
}
기본 열거형 정의와 사용
#[derive(Debug)]
enum TransactionStatus {
Pending,
Confirmed,
Failed,
Cancelled,
}
fn describe_status(status: &TransactionStatus) -> &str {
match status {
TransactionStatus::Pending => "대기 중",
TransactionStatus::Confirmed => "확정됨",
TransactionStatus::Failed => "실패",
TransactionStatus::Cancelled => "취소됨",
}
}
fn main() {
let status = TransactionStatus::Pending;
println!("Status: {}", describe_status(&status));
// 비교
let s2 = TransactionStatus::Confirmed;
// status == s2 // 에러! PartialEq를 derive해야 함
// 이렇게 사용
if let TransactionStatus::Pending = status {
println!("Transaction is pending");
}
}
데이터를 가진 열거형
#[derive(Debug)]
enum Transaction {
// 데이터 없음
CoinbaseReward,
// 단일 값
Transfer(u64), // 금액 (satoshi)
// 여러 값 (튜플 배리언트)
TransferWithFee(u64, u64), // (금액, 수수료)
// 이름 있는 필드 (구조체 배리언트)
SmartContract {
contract_address: String,
value: u64,
data: Vec<u8>,
},
}
fn process_transaction(tx: &Transaction) {
match tx {
Transaction::CoinbaseReward => {
println!("Coinbase reward transaction");
}
Transaction::Transfer(amount) => {
println!("Transfer: {} satoshi", amount);
}
Transaction::TransferWithFee(amount, fee) => {
println!("Transfer: {} satoshi, Fee: {} satoshi", amount, fee);
}
Transaction::SmartContract { contract_address, value, data } => {
println!(
"Smart contract call to {} with {} wei, data: {} bytes",
contract_address,
value,
data.len()
);
}
}
}
fn main() {
let txs = vec![
Transaction::CoinbaseReward,
Transaction::Transfer(100_000),
Transaction::TransferWithFee(50_000, 1_000),
Transaction::SmartContract {
contract_address: String::from("0xabcd..."),
value: 0,
data: vec![0x60, 0x80, 0x60, 0x40],
},
];
for tx in &txs {
process_transaction(tx);
}
}
impl 블록을 가진 열거형
열거형에도 메서드를 구현할 수 있습니다:
#[derive(Debug)]
enum NetworkType {
Mainnet,
Testnet,
Devnet,
Localnet,
}
impl NetworkType {
fn rpc_url(&self) -> &str {
match self {
NetworkType::Mainnet => "https://api.mainnet-beta.solana.com",
NetworkType::Testnet => "https://api.testnet.solana.com",
NetworkType::Devnet => "https://api.devnet.solana.com",
NetworkType::Localnet => "http://127.0.0.1:8899",
}
}
fn is_production(&self) -> bool {
matches!(self, NetworkType::Mainnet)
}
fn from_str(s: &str) -> Option<NetworkType> {
match s {
"mainnet" | "mainnet-beta" => Some(NetworkType::Mainnet),
"testnet" => Some(NetworkType::Testnet),
"devnet" => Some(NetworkType::Devnet),
"localnet" | "localhost" => Some(NetworkType::Localnet),
_ => None,
}
}
}
fn main() {
let network = NetworkType::Devnet;
println!("RPC URL: {}", network.rpc_url());
println!("Production: {}", network.is_production());
if let Some(net) = NetworkType::from_str("mainnet") {
println!("Parsed: {:?}", net);
}
}
Option<T>: null을 대체하는 타입
Rust에는 null이 없습니다. 대신 Option<T> 열거형을 사용합니다.
#![allow(unused)]
fn main() {
// 표준 라이브러리에 이렇게 정의되어 있음
enum Option<T> {
Some(T), // 값이 있음
None, // 값이 없음
}
}
TypeScript의 null/undefined와 비교:
// TypeScript
function findBlock(index: number): Block | null {
const blocks = [
{ index: 0, hash: "genesis" },
{ index: 1, hash: "abc123" },
];
const found = blocks.find((block) => block.index === index);
if (!found) {
return null;
}
return found;
}
const block = findBlock(5);
// 개발자가 null 체크를 잊어도 TypeScript는 타입 에러를 냄 (strict mode)
if (block !== null) {
console.log(block.hash);
}
// 하지만 런타임에 null이 올 수 있는 상황이 많음
// Rust
struct Block {
index: u64,
hash: String,
}
fn find_block(blocks: &[Block], index: u64) -> Option<&Block> {
blocks.iter().find(|b| b.index == index)
}
fn main() {
let blocks = vec![
Block { index: 0, hash: String::from("genesis") },
Block { index: 1, hash: String::from("abc123") },
Block { index: 5, hash: String::from("def456") },
];
let result = find_block(&blocks, 5);
// Option을 처리하지 않으면 컴파일 에러!
match result {
Some(block) => println!("Found: {}", block.hash),
None => println!("Block not found"),
}
// 또는 if let
if let Some(block) = find_block(&blocks, 3) {
println!("Block 3 hash: {}", block.hash);
}
}
Option 관련 주요 메서드
fn main() {
let some_val: Option<i32> = Some(42);
let no_val: Option<i32> = None;
// unwrap(): Some이면 값 반환, None이면 panic!
// 프로덕션 코드에서 주의해서 사용
let val = some_val.unwrap(); // 42
// unwrap_or(): None일 때 기본값
let val2 = no_val.unwrap_or(0); // 0
let val3 = no_val.unwrap_or_default(); // 타입의 기본값 (i32 → 0)
// unwrap_or_else(): None일 때 클로저 실행
let val4 = no_val.unwrap_or_else(|| compute_default());
// expect(): None이면 커스텀 메시지로 panic
// let val5 = no_val.expect("값이 있어야 합니다");
// is_some(), is_none()
println!("some_val has value: {}", some_val.is_some()); // true
println!("no_val is none: {}", no_val.is_none()); // true
// map(): Some이면 변환, None이면 None 유지
let doubled: Option<i32> = some_val.map(|v| v * 2); // Some(84)
let nothing: Option<i32> = no_val.map(|v| v * 2); // None
// and_then(): Some이면 Option 반환하는 함수 실행 (flatMap)
let result: Option<String> = some_val.and_then(|v| {
if v > 0 { Some(v.to_string()) } else { None }
});
// filter(): 조건이 참이면 Some 유지, 아니면 None
let filtered: Option<i32> = some_val.filter(|&v| v > 100); // None (42 <= 100)
// or(): None이면 다른 Option으로 대체
let result2 = no_val.or(Some(99)); // Some(99)
// as_ref(): Option<T> → Option<&T> (소유권 유지)
let s: Option<String> = Some(String::from("hello"));
let r: Option<&String> = s.as_ref(); // s의 소유권을 유지하면서 참조
println!("{:?}", r);
println!("{:?}", s); // s 여전히 유효
}
fn compute_default() -> i32 { 42 }
블록체인에서 Option 활용
#[derive(Debug)]
struct Block {
index: u64,
hash: String,
previous_hash: Option<String>, // 제네시스 블록은 이전 해시 없음
data: String,
}
impl Block {
fn genesis() -> Self {
Block {
index: 0,
hash: String::from("0000abc"),
previous_hash: None, // 제네시스는 이전 블록 없음
data: String::from("Genesis"),
}
}
fn new(index: u64, data: String, previous_hash: String) -> Self {
Block {
index,
hash: String::from("computed"),
previous_hash: Some(previous_hash),
data,
}
}
fn is_genesis(&self) -> bool {
self.previous_hash.is_none()
}
fn get_previous_hash(&self) -> &str {
self.previous_hash.as_deref().unwrap_or("N/A")
}
}
fn main() {
let genesis = Block::genesis();
let block1 = Block::new(1, String::from("tx1"), genesis.hash.clone());
println!("Genesis? {}", genesis.is_genesis()); // true
println!("Prev hash: {}", genesis.get_previous_hash()); // N/A
println!("Block1 genesis? {}", block1.is_genesis()); // false
println!("Block1 prev: {}", block1.get_previous_hash()); // 0000abc
}
TypeScript discriminated union과 비교
// TypeScript discriminated union
type WalletEvent =
| { kind: "deposit"; amount: number; from: string }
| { kind: "withdraw"; amount: number; to: string }
| { kind: "swap"; fromToken: string; toToken: string; amount: number }
| { kind: "error"; message: string };
function handleEvent(event: WalletEvent): void {
switch (event.kind) {
case "deposit":
console.log(`Deposit ${event.amount} from ${event.from}`);
break;
case "withdraw":
console.log(`Withdraw ${event.amount} to ${event.to}`);
break;
case "swap":
console.log(`Swap ${event.amount} ${event.fromToken} → ${event.toToken}`);
break;
case "error":
console.log(`Error: ${event.message}`);
break;
// TypeScript는 모든 케이스를 처리했는지 확인 (exhaustive check)
}
}
// Rust enum — 훨씬 간결하고 타입 안전
#[derive(Debug)]
enum WalletEvent {
Deposit { amount: u64, from: String },
Withdraw { amount: u64, to: String },
Swap { from_token: String, to_token: String, amount: u64 },
Error (String),
}
fn handle_event(event: &WalletEvent) {
match event {
WalletEvent::Deposit { amount, from } => {
println!("Deposit {} from {}", amount, from);
}
WalletEvent::Withdraw { amount, to } => {
println!("Withdraw {} to {}", amount, to);
}
WalletEvent::Swap { from_token, to_token, amount } => {
println!("Swap {} {} → {}", amount, from_token, to_token);
}
WalletEvent::Error(msg) => {
println!("Error: {}", msg);
}
// 모든 배리언트를 처리하지 않으면 컴파일 에러!
// non-exhaustive patterns
}
}
fn main() {
let events = vec![
WalletEvent::Deposit { amount: 1_000_000, from: String::from("Alice") },
WalletEvent::Withdraw { amount: 500_000, to: String::from("Bob") },
WalletEvent::Error(String::from("Insufficient balance")),
];
for event in &events {
handle_event(event);
}
}
Rust enum의 장점:
kind필드 없이 배리언트 자체가 구분자- 각 배리언트마다 다른 타입과 양의 데이터
match에서 모든 케이스 처리 강제 (exhaustive)- 패턴 매칭으로 데이터를 바로 꺼낼 수 있음
요약
- Rust
enum은 TypeScriptenum보다 훨씬 강력 — 각 배리언트가 데이터를 가질 수 있음 - TypeScript의 discriminated union과 유사하지만 더 간결하고 타입 안전
Option<T>: null/undefined 대체 —Some(T)또는NoneOption메서드:unwrap(),unwrap_or(),map(),and_then(),filter()match로 모든 배리언트를 처리해야 함 (exhaustive check — 컴파일 타임)- 열거형에도
impl블록으로 메서드 추가 가능
다음 챕터에서는 열거형과 가장 잘 어울리는 패턴 매칭을 자세히 배웁니다.
3.3 패턴 매칭
match 표현식
match는 Rust에서 가장 강력한 제어 흐름 구조입니다. TypeScript의 switch와 유사하지만 훨씬 강력합니다.
#![allow(unused)]
fn main() {
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: &Coin) -> u32 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
}
TypeScript switch와 비교:
function valueInCents(coin: Coin): number {
switch (coin) {
case Coin.Penny: return 1;
case Coin.Nickel: return 5;
case Coin.Dime: return 10;
case Coin.Quarter: return 25;
// default 없어도 TypeScript가 exhaustive check (enum의 경우)
}
}
match의 핵심 규칙: 모든 경우를 처리해야 한다 (exhaustive)
fn value_in_cents(coin: &Coin) -> u32 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
// Dime, Quarter 누락!
}
// error[E0004]: non-exhaustive patterns: `&Coin::Dime` and `&Coin::Quarter` not covered
}
match 팔(arm)의 구조
각 match 팔은 패턴 => 표현식 형태입니다:
fn describe_number(n: i32) -> &'static str {
match n {
// 단일 값
0 => "zero",
// 여러 값 (OR 패턴)
1 | 2 | 3 => "small positive",
// 범위
4..=10 => "medium positive",
// 조건 (match guard)
n if n > 0 => "large positive",
// 나머지 (와일드카드)
_ => "negative",
}
}
fn main() {
println!("{}", describe_number(0)); // zero
println!("{}", describe_number(2)); // small positive
println!("{}", describe_number(7)); // medium positive
println!("{}", describe_number(100)); // large positive
println!("{}", describe_number(-5)); // negative
}
여러 줄 팔
fn process_block(block: &Block) -> String {
match block.status {
BlockStatus::Pending => {
println!("Block is being processed...");
let hash = compute_hash(block);
format!("Pending block hash: {}", hash)
}
BlockStatus::Confirmed => {
format!("Confirmed at height {}", block.height)
}
BlockStatus::Invalid(ref reason) => {
eprintln!("Invalid block: {}", reason);
String::from("invalid")
}
}
}
데이터를 가진 열거형 패턴 매칭
#[derive(Debug)]
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(u8, u8, u8),
}
fn process(msg: Message) {
match msg {
Message::Quit => {
println!("Quit!");
}
Message::Move { x, y } => {
// 구조 분해로 필드를 직접 꺼냄
println!("Move to ({}, {})", x, y);
}
Message::Write(text) => {
// 튜플 배리언트의 값을 꺼냄
println!("Write: {}", text);
}
Message::ChangeColor(r, g, b) => {
println!("Color: rgb({}, {}, {})", r, g, b);
}
}
}
fn main() {
process(Message::Move { x: 10, y: 20 });
process(Message::Write(String::from("hello")));
process(Message::ChangeColor(255, 128, 0));
process(Message::Quit);
}
구조 분해 (Destructuring)
패턴 매칭의 핵심 기능 중 하나는 구조 분해입니다.
구조체 구조 분해
struct Point {
x: f64,
y: f64,
}
fn main() {
let p = Point { x: 3.0, y: 4.0 };
// 구조 분해로 필드를 변수로 꺼냄
let Point { x, y } = p;
println!("x: {}, y: {}", x, y);
// 다른 이름으로 꺼냄
let Point { x: px, y: py } = Point { x: 1.0, y: 2.0 };
println!("px: {}, py: {}", px, py);
// 일부 필드만 (나머지는 무시)
let Point { x, .. } = Point { x: 5.0, y: 6.0 };
println!("x only: {}", x);
// match에서 구조 분해
let points = vec![
Point { x: 0.0, y: 0.0 },
Point { x: 1.0, y: 5.0 },
];
for point in &points {
match point {
Point { x: 0.0, y: 0.0 } => println!("Origin"),
Point { x, y: 0.0 } => println!("On x-axis at {}", x),
Point { x: 0.0, y } => println!("On y-axis at {}", y),
Point { x, y } => println!("At ({}, {})", x, y),
}
}
}
튜플 구조 분해
fn main() {
let (a, b, c) = (1, 2, 3);
println!("{} {} {}", a, b, c);
// 일부 무시
let (first, _, last) = (1, 2, 3);
println!("{} {}", first, last);
// 중첩 구조 분해
let ((x1, y1), (x2, y2)) = ((1, 2), (3, 4));
println!("({},{}) to ({},{})", x1, y1, x2, y2);
}
열거형 구조 분해
#![allow(unused)]
fn main() {
#[derive(Debug)]
enum TransactionResult {
Success { txid: String, block_height: u64 },
Failure { code: u32, reason: String },
Pending(String), // tx hash
}
fn handle_result(result: TransactionResult) {
match result {
TransactionResult::Success { txid, block_height } => {
println!("TX {} confirmed at block {}", txid, block_height);
}
TransactionResult::Failure { code, reason } => {
println!("TX failed ({}): {}", code, reason);
}
TransactionResult::Pending(hash) => {
println!("TX {} is pending...", hash);
}
}
}
}
와일드카드와 변수 바인딩
fn main() {
let num = 7u32;
match num {
// 값 무시
_ => println!("anything"),
}
// 변수 바인딩과 와일드카드
match num {
n @ 1..=10 => println!("Got {} (1-10)", n), // @ 바인딩
n @ 11..=20 => println!("Got {} (11-20)", n),
_ => println!("Out of range"),
}
// 참조 패턴
let reference = &4;
match reference {
&val => println!("Got a value via destructuring: {}", val),
}
// 또는 ref 키워드로
let value = 5;
match value {
ref r => println!("Got a reference to {}", r),
}
}
@ 바인딩 활용
fn categorize_block_height(height: u64) -> String {
match height {
// 값을 n에 바인딩하면서 범위 검사
n @ 0 => format!("Genesis block"),
n @ 1..=99 => format!("Early block #{}", n),
n @ 100..=999 => format!("Block #{} (hundreds)", n),
n => format!("Block #{} (large)", n),
}
}
match 가드 (Match Guards)
패턴에 추가 조건을 붙일 수 있습니다:
fn classify_transaction(amount: u64, is_confirmed: bool) -> &'static str {
match (amount, is_confirmed) {
(0, _) => "zero-value transaction",
(_, false) => "unconfirmed",
(amt, true) if amt > 1_000_000 => "large confirmed",
(amt, true) if amt > 10_000 => "medium confirmed",
(_, true) => "small confirmed",
}
}
fn main() {
println!("{}", classify_transaction(0, true)); // zero-value
println!("{}", classify_transaction(5_000, false)); // unconfirmed
println!("{}", classify_transaction(2_000_000, true)); // large confirmed
println!("{}", classify_transaction(50_000, true)); // medium confirmed
println!("{}", classify_transaction(100, true)); // small confirmed
}
if let: 단일 패턴 매칭
match가 한 패턴만 처리할 때, if let이 더 간결합니다:
fn main() {
let some_value: Option<u32> = Some(42);
// match로 쓰면
match some_value {
Some(v) => println!("Got: {}", v),
None => {} // 아무것도 안 함
}
// if let으로 더 간결하게
if let Some(v) = some_value {
println!("Got: {}", v);
}
// else 추가 가능
if let Some(v) = some_value {
println!("Got: {}", v);
} else {
println!("Nothing");
}
// 열거형과 함께
let event = WalletEvent::Deposit { amount: 1000, from: String::from("Alice") };
if let WalletEvent::Deposit { amount, from } = event {
println!("Deposit {} from {}", amount, from);
}
}
블록체인 코드에서 if let 활용
fn get_block_data(blockchain: &Blockchain, index: u64) -> Option<String> {
blockchain.blocks.get(index as usize).map(|b| b.data.clone())
}
fn main() {
let blockchain = Blockchain::new();
// if let으로 깔끔하게 처리
if let Some(data) = get_block_data(&blockchain, 0) {
println!("Genesis data: {}", data);
} else {
println!("Block not found");
}
// 체이닝
if let Some(block) = blockchain.blocks.first() {
if let Some(hash) = block.hash.get(..6) {
println!("Short hash: {}...", hash);
}
}
}
while let: 조건부 반복
fn main() {
let mut stack = vec![1, 2, 3, 4, 5];
// stack.pop()이 Some을 반환하는 동안 반복
while let Some(top) = stack.pop() {
println!("Popped: {}", top);
}
// 5, 4, 3, 2, 1 순서로 출력
// 채널에서 메시지 받기 (tokio/std 채널 패턴)
// while let Ok(msg) = receiver.recv() {
// handle_message(msg);
// }
}
let else (Rust 1.65+)
패턴이 매칭되지 않으면 early return하는 패턴:
fn process_transaction(tx_data: &str) -> Result<(), String> {
// tx_data를 파싱
let parts: Vec<&str> = tx_data.split(':').collect();
// 패턴 매칭 실패시 else 블록 실행 (return/break/continue/panic 필요)
let [from, to, amount_str] = parts.as_slice() else {
return Err(String::from("Invalid transaction format"));
};
let Ok(amount) = amount_str.parse::<u64>() else {
return Err(format!("Invalid amount: {}", amount_str));
};
println!("Transfer {} from {} to {}", amount, from, to);
Ok(())
}
fn main() {
match process_transaction("Alice:Bob:1000") {
Ok(()) => println!("Success"),
Err(e) => println!("Error: {}", e),
}
match process_transaction("invalid") {
Ok(()) => println!("Success"),
Err(e) => println!("Error: {}", e),
}
}
matches! 매크로
bool을 반환하는 패턴 매칭 단축형:
#[derive(PartialEq)]
enum Status { Active, Inactive, Suspended }
fn main() {
let status = Status::Active;
// match로
let is_active = match status {
Status::Active => true,
_ => false,
};
// matches! 매크로로 (더 간결)
let is_active2 = matches!(status, Status::Active);
// 여러 패턴
let is_problematic = matches!(status, Status::Inactive | Status::Suspended);
// 조건 포함
let num = 42i32;
let in_range = matches!(num, 1..=100);
println!("{} {} {} {}", is_active, is_active2, is_problematic, in_range);
}
전체 패턴 종류 요약
fn all_patterns(x: i32) {
match x {
// 1. 리터럴
0 => println!("zero"),
// 2. 변수 (모든 값을 n에 바인딩)
n => println!("n = {}", n),
}
let pair = (1, -1);
match pair {
// 3. 튜플 패턴
(0, y) => println!("First is zero, y={}", y),
(x, 0) => println!("x={}, Second is zero", x),
(x, y) => println!("({}, {})", x, y),
}
// 4. 열거형 패턴 (앞서 설명)
// 5. 구조체 패턴 (앞서 설명)
// 6. 범위 패턴 (앞서 설명)
// 7. @ 바인딩 (앞서 설명)
// 8. 와일드카드 _ (앞서 설명)
// 9. OR 패턴 |
// 10. 가드 if
// 11. ref/ref mut (참조 바인딩)
}
요약
match: 강력한 패턴 매칭 — exhaustive (모든 케이스 강제 처리)if let: 단일 패턴에 간결하게 사용while let: 패턴이 매칭되는 동안 반복let else: 매칭 실패 시 early returnmatches!: bool 반환하는 패턴 매칭 단축형- 구조 분해: 튜플, 구조체, 열거형을 분해해서 내부 값 꺼내기
@바인딩: 패턴 매칭하면서 값을 변수에 바인딩- match 가드:
if조건으로 패턴에 추가 조건 부여
다음으로는 합의 알고리즘을 배운 뒤, 1주차 미니프로젝트로 블록체인을 직접 구현합니다. 에러 처리는 2주차에서 본격적으로 다룹니다.
블록과 체인: 데이터 구조 완전 분석
9.1장에서 해시 함수와 암호학을 배웠다. 이제 그 도구들이 어떻게 블록과 체인을 만드는지 알아보자. 블록체인의 이름 그 자체 — “블록(Block)의 체인(Chain)” — 을 코드 레벨에서 이해한다.
참고: 이 챕터는 3장에서 배운
struct,enum,Vec를 블록체인 데이터 구조에 적용합니다. 문법 설명을 다시 반복하기보다,Transaction,Block,Blockchain이 어떤 책임을 나눠 갖는지에 집중하세요.
1. 블록의 구조
하나의 블록은 크게 **헤더(Header)**와 **바디(Body)**로 나뉜다.
먼저 블록을 “데이터베이스 row 하나”로 생각하면 안 된다. 블록은 여러 트랜잭션을 한 번에 담는 append-only 기록 묶음이다. 헤더는 이 묶음을 검증하기 위한 요약 정보이고, 바디는 실제 트랜잭션 목록이다.
┌─────────────────────────────────────────────────────┐
│ 블록 #N │
├─────────────────────────────────────────────────────┤
│ HEADER (헤더) │
│ ┌─────────────────────────────────────────────┐ │
│ │ previousHash: "0x3a7f..." (이전 블록 해시) │ │
│ │ timestamp: 1700000000 (생성 시각) │ │
│ │ nonce: 293847 (PoW용 임의값) │ │
│ │ merkleRoot: "0xb94d..." (트랜잭션 요약) │ │
│ │ difficulty: 0x1d00ffff (채굴 난이도) │ │
│ │ blockHash: "0xf2a1..." (이 블록의 해시) │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────┤
│ BODY (바디) │
│ ┌──────────────────────────────────────────────┐ │
│ │ transactions: [ │ │
│ │ { from: "0xAlice", to: "0xBob", ... }, │ │
│ │ { from: "0xBob", to: "0xCarol", ... }, │ │
│ │ ... │ │
│ │ ] │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
1.1 헤더 필드 상세 설명
previousHash (이전 블록 해시)
- 이 블록이 어떤 블록 다음에 오는지를 명시한다
- 체인의 연결 고리 역할
- 최초 블록(제네시스 블록)은 이 값이
0x000...000
timestamp (타임스탬프)
- 블록이 생성된 Unix 시각 (초 단위)
- 네트워크 시간 조작을 방지하기 위해 규칙이 있음
- 이전 블록보다 늦어야 함
- 네트워크 시간에서 너무 멀면 거부됨
nonce (논스)
- “Number used ONCE“의 약자
- Proof of Work에서 채굴자가 조정하는 값
- 블록 해시가 난이도 조건을 만족할 때까지 nonce를 바꿔가며 시도
merkleRoot (머클 루트)
- 바디의 모든 트랜잭션을 머클 트리로 요약한 해시
- 헤더만 봐도 트랜잭션 변조 여부를 알 수 있음
difficulty (난이도)
- 블록 해시가 만족해야 하는 조건
- 예: “해시가 0이 18개로 시작해야 한다”
- 약 2주마다 자동 조절됨
1.2 블록 해시 계산
블록 해시는 헤더 전체를 해싱해서 만든다:
blockHash = SHA256(SHA256(
previousHash + merkleRoot + timestamp + nonce + difficulty
))
비트코인은 SHA256을 두 번 적용한다(이중 해싱). 이더리움은 Keccak-256을 사용한다.
2. 체인이 되는 원리
각 블록이 이전 블록의 해시를 포함함으로써 블록들이 연결된다.
제네시스 블록 블록 #1 블록 #2
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ prevHash: │ │ prevHash: │ │ prevHash: │
│ 0x00000000 │ │ 0xABC123... │◀─────────│ 0xDEF456... │
│ │ │ │ │ │
│ merkleRoot: │ │ merkleRoot: │ │ merkleRoot: │
│ 0x111... │ │ 0x222... │ │ 0x333... │
│ │ │ │ │ │
│ nonce: 0 │ │ nonce: 4829 │ │ nonce: 9103 │
│ │ │ │ │ │
│ blockHash: │──────────│ blockHash: │──────────│ blockHash: │
│ 0xABC123... │ │ 0xDEF456... │ │ 0xGHI789... │
└─────────────┘ └─────────────┘ └─────────────┘
TX: 코인베이스 TX: Alice→Bob TX: Bob→Carol
TX: Carol→Dave
왜 과거 데이터를 수정하기 어려운가?
블록 #1의 트랜잭션을 수정하면:
- 블록 #1의 머클 루트가 바뀐다
- 블록 #1의 해시가 바뀐다
- 블록 #2의
previousHash가 맞지 않게 된다 - 블록 #2를 다시 채굴해야 한다
- 블록 #3도 바뀐다 → 블록 #3도 재채굴
- … 현재 최신 블록까지 모두 재채굴 필요
게다가, 정직한 노드들은 계속 새 블록을 채굴하고 있다. 공격자는 전체 네트워크 해시파워의 51% 이상을 확보해야 따라잡을 수 있다. 이것이 51% 공격이다.
3. 트랜잭션 구조
트랜잭션은 사용자가 체인 상태를 바꿔 달라고 제출하는 서명된 요청이다. 이더리움에서 ETH 전송, ERC-20 토큰 전송, 스마트 컨트랙트 함수 호출은 모두 트랜잭션으로 표현된다.
백엔드 API 요청과 비교하면 다음과 같다.
| 백엔드 API 요청 | 이더리움 트랜잭션 |
|---|---|
POST /transfer | to, value, data를 담은 트랜잭션 |
| JWT나 세션 쿠키로 인증 | 개인 키 서명(v, r, s)으로 인증 |
| 서버가 DB에 쓰기 | 검증자가 블록에 포함해야 상태 변경 |
| 실패 시 서버가 에러 응답 | 실패 시 revert되고 가스 일부 또는 전부 소비 |
따라서 트랜잭션은 단순한 “송금 기록”이 아니라, 블록체인 상태 전이를 일으키는 입력값이다.
3.1 트랜잭션 필드
이더리움 트랜잭션의 기본 구조:
트랜잭션:
{
nonce: 5, // 이 계정이 보낸 TX 수 (재전송 공격 방지)
gasPrice: 20_000_000_000, // wei per gas (20 Gwei)
gasLimit: 21_000, // 최대 사용 가능한 gas
to: "0xBob...", // 수신자 주소 (컨트랙트 생성 시 null)
value: 1_000_000_000_000_000_000, // 1 ETH (wei 단위)
data: "0x", // 컨트랙트 호출 시 입력 데이터
v: 27, // 서명 복구 값
r: "0x...", // 서명 r 값
s: "0x..." // 서명 s 값
}
nonce의 두 가지 역할:
- 재전송 공격(Replay Attack) 방지: 같은 서명된 TX를 여러 번 보내는 공격 차단
- 순서 보장: 같은 계정에서 발송한 TX가 nonce 순서대로 처리됨
3.2 트랜잭션 수명 주기
사용자가 TX 생성 및 서명
│
▼
P2P 네트워크로 브로드캐스트
│
▼
각 노드의 Mempool(메모리 풀)에 대기
┌──────────────────────────────┐
│ Mempool (미확인 TX 대기소) │
│ [TX_A, TX_B, TX_C, TX_D,…] │
│ 가스비 높은 순으로 정렬 │
└──────────────────────────────┘
│
▼ (채굴자/검증자가 선택)
블록에 포함
│
▼
블록이 네트워크에 전파
│
▼
다른 노드들이 블록 검증 및 수락
│
▼
TX 최종 확인 (Confirmation)
(일반적으로 6 블록 후 = ~1분)
**Mempool(멤풀)**은 Node.js의 Redis 큐(Bull/BullMQ)와 비슷하다. 처리 대기 중인 작업들의 목록이다. 차이는 누구나 Mempool에 TX를 제출할 수 있고, 가스비가 높을수록 먼저 처리된다는 점이다.
4. UTXO 모델 vs Account 모델
블록체인은 잔액을 추적하는 방식이 두 가지로 나뉜다. 비트코인과 이더리움이 서로 다른 방식을 사용한다.
4.1 UTXO 모델 (비트코인)
UTXO = Unspent Transaction Output (미사용 트랜잭션 출력)
현금을 생각해보자. 10만원짜리 지폐를 가지고 있다가 7만원짜리 물건을 사면:
- 10만원 지폐를 낸다 (소비됨)
- 7만원을 지불하고
- 3만원 거스름돈을 받는다
UTXO 모델 예시:
Alice가 받은 UTXO들:
UTXO_1: 0.5 BTC (TX_A의 출력)
UTXO_2: 0.3 BTC (TX_B의 출력)
UTXO_3: 1.2 BTC (TX_C의 출력)
총 잔액: 2.0 BTC
Alice가 Bob에게 0.7 BTC를 보낼 때:
입력: UTXO_1(0.5) + UTXO_2(0.3) = 0.8 BTC ← 소비됨
출력1: Bob에게 0.7 BTC ← Bob의 새 UTXO
출력2: Alice에게 0.1 BTC (거스름돈) ← Alice의 새 UTXO
결과:
Alice: UTXO_3(1.2 BTC) + UTXO_new(0.1 BTC) = 1.3 BTC
Bob: UTXO_new(0.7 BTC) = 0.7 BTC
UTXO 모델의 특징:
- 잔액이란 개념이 없음. “내가 소유한 미사용 출력들의 합“이 잔액
- 병렬 처리에 유리 (각 UTXO가 독립적)
- 프라이버시에 유리 (주소를 자주 바꿀 수 있음)
- 스마트 컨트랙트 구현이 복잡
4.2 Account 모델 (이더리움)
은행 계좌와 동일하다. 각 주소에 잔액이 직접 저장된다.
Account 모델:
상태(State):
Alice: { balance: 2.0 ETH, nonce: 5 }
Bob: { balance: 0.5 ETH, nonce: 2 }
Alice가 Bob에게 0.7 ETH를 보낼 때:
Alice.balance -= 0.7 ETH → 1.3 ETH
Bob.balance += 0.7 ETH → 1.2 ETH
Alice.nonce += 1 → 6
결과:
Alice: { balance: 1.3 ETH, nonce: 6 }
Bob: { balance: 1.2 ETH, nonce: 3 }
Account 모델의 특징:
- 직관적. 일반 데이터베이스처럼 잔액을 저장
- 스마트 컨트랙트 구현에 적합
- 상태 크기가 작음 (UTXO처럼 과거 TX 모두 추적 불필요)
- 재전송 공격 방지를 위해 nonce 필수
| 비교 | UTXO (비트코인) | Account (이더리움) |
|---|---|---|
| 잔액 표현 | 미사용 출력들의 합 | 계정에 직접 저장 |
| 프라이버시 | 더 높음 | 낮음 |
| 스마트 컨트랙트 | 어려움 | 쉬움 |
| 병렬 처리 | 유리 | 불리 |
| 상태 크기 | 크게 증가 가능 | 상대적으로 작음 |
5. Rust로 Block, Transaction 구조체 구현
use sha2::{Sha256, Digest};
use std::time::{SystemTime, UNIX_EPOCH};
/// 트랜잭션 구조체
#[derive(Debug, Clone)]
struct Transaction {
from: String, // 발신자 주소
to: String, // 수신자 주소
value: u64, // 이체 금액 (wei 단위)
nonce: u64, // 재전송 방지용 순번
data: String, // 컨트랙트 호출 데이터 (일반 전송 시 빈 문자열)
signature: String, // ECDSA 서명
}
impl Transaction {
fn new(from: &str, to: &str, value: u64, nonce: u64) -> Self {
Transaction {
from: from.to_string(),
to: to.to_string(),
value,
nonce,
data: String::new(),
signature: String::new(), // 실제로는 비밀키로 서명
}
}
/// 트랜잭션 해시 계산
fn hash(&self) -> String {
let content = format!(
"{}{}{}{}{}",
self.from, self.to, self.value, self.nonce, self.data
);
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
hex::encode(hasher.finalize())
}
}
/// 블록 헤더
#[derive(Debug, Clone)]
struct BlockHeader {
index: u64, // 블록 번호
previous_hash: String, // 이전 블록의 해시
timestamp: u64, // Unix timestamp
merkle_root: String, // 트랜잭션들의 머클 루트
nonce: u64, // PoW를 위한 논스
difficulty: usize, // 해시 앞에 0이 몇 개 있어야 하는가
}
/// 블록 구조체
#[derive(Debug, Clone)]
struct Block {
header: BlockHeader,
transactions: Vec<Transaction>,
hash: String,
}
impl Block {
/// 제네시스 블록 생성
fn genesis() -> Self {
let header = BlockHeader {
index: 0,
previous_hash: "0".repeat(64),
timestamp: current_timestamp(),
merkle_root: "0".repeat(64),
nonce: 0,
difficulty: 2, // 앞에 0이 2개 ('00'으로 시작)
};
let mut block = Block {
hash: String::new(),
header,
transactions: vec![],
};
block.hash = block.calculate_hash();
block
}
/// 새 블록 생성 (채굴 포함)
fn new(
index: u64,
previous_hash: String,
transactions: Vec<Transaction>,
difficulty: usize,
) -> Self {
let merkle_root = Self::calculate_merkle_root(&transactions);
let mut header = BlockHeader {
index,
previous_hash,
timestamp: current_timestamp(),
merkle_root,
nonce: 0,
difficulty,
};
let mut block = Block {
hash: String::new(),
header,
transactions,
};
// PoW: 난이도를 만족하는 nonce 탐색
block.mine();
block
}
/// 블록 해시 계산
fn calculate_hash(&self) -> String {
let h = &self.header;
let content = format!(
"{}{}{}{}{}{}",
h.index,
h.previous_hash,
h.timestamp,
h.merkle_root,
h.nonce,
h.difficulty
);
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
hex::encode(hasher.finalize())
}
/// 트랜잭션들의 머클 루트 계산 (간략 버전)
fn calculate_merkle_root(transactions: &[Transaction]) -> String {
if transactions.is_empty() {
return "0".repeat(64);
}
let mut hashes: Vec<String> = transactions.iter()
.map(|tx| tx.hash())
.collect();
while hashes.len() > 1 {
let mut next = Vec::new();
let mut i = 0;
while i < hashes.len() {
let left = &hashes[i];
let right = if i + 1 < hashes.len() {
&hashes[i + 1]
} else {
&hashes[i] // 홀수일 때 마지막 반복
};
let combined = format!("{}{}", left, right);
let mut hasher = Sha256::new();
hasher.update(combined.as_bytes());
next.push(hex::encode(hasher.finalize()));
i += 2;
}
hashes = next;
}
hashes[0].clone()
}
/// Proof of Work: 조건을 만족하는 nonce 탐색
fn mine(&mut self) {
let target = "0".repeat(self.header.difficulty);
println!("블록 #{} 채굴 시작 (난이도: {})...", self.header.index, self.header.difficulty);
loop {
let hash = self.calculate_hash();
if hash.starts_with(&target) {
self.hash = hash;
println!(
"채굴 완료! nonce={}, hash={}",
self.header.nonce,
&self.hash[..16]
);
return;
}
self.header.nonce += 1;
}
}
/// 블록 유효성 검사
fn is_valid(&self) -> bool {
// 1. 해시가 올바른가?
if self.hash != self.calculate_hash() {
return false;
}
// 2. 난이도 조건을 만족하는가?
let target = "0".repeat(self.header.difficulty);
self.hash.starts_with(&target)
}
}
/// 블록체인
struct Blockchain {
chain: Vec<Block>,
difficulty: usize,
}
impl Blockchain {
fn new() -> Self {
let genesis = Block::genesis();
Blockchain {
chain: vec![genesis],
difficulty: 2,
}
}
fn last_block(&self) -> &Block {
self.chain.last().unwrap()
}
fn add_block(&mut self, transactions: Vec<Transaction>) {
let index = self.chain.len() as u64;
let previous_hash = self.last_block().hash.clone();
let block = Block::new(index, previous_hash, transactions, self.difficulty);
self.chain.push(block);
}
/// 체인 전체 유효성 검사
fn is_valid(&self) -> bool {
for i in 1..self.chain.len() {
let current = &self.chain[i];
let previous = &self.chain[i - 1];
// 현재 블록 해시가 올바른가?
if !current.is_valid() {
return false;
}
// 이전 블록과 연결이 올바른가?
if current.header.previous_hash != previous.hash {
return false;
}
}
true
}
}
fn current_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn main() {
let mut blockchain = Blockchain::new();
// 트랜잭션 생성
let tx1 = Transaction::new("0xAlice", "0xBob", 1_000_000_000_000_000_000, 0);
let tx2 = Transaction::new("0xBob", "0xCarol", 500_000_000_000_000_000, 1);
blockchain.add_block(vec![tx1, tx2]);
let tx3 = Transaction::new("0xCarol", "0xDave", 200_000_000_000_000_000, 0);
blockchain.add_block(vec![tx3]);
// 체인 상태 출력
println!("\n=== 블록체인 상태 ===");
for block in &blockchain.chain {
println!(
"블록 #{}: hash={}... (tx 수: {})",
block.header.index,
&block.hash[..16],
block.transactions.len()
);
}
println!("\n체인 유효성: {}", blockchain.is_valid());
// 데이터 조작 시도
println!("\n=== 데이터 조작 시도 ===");
blockchain.chain[1].transactions[0].value = 999_999_999_999_999_999_999;
println!("블록 #1의 TX 금액을 변조함");
println!("체인 유효성: {}", blockchain.is_valid()); // false가 되어야 함
}
이 예제는 길지만, 읽는 순서는 단순하다.
| 코드 덩어리 | 먼저 볼 것 |
|---|---|
struct Transaction | 트랜잭션이 어떤 필드로 구성되는지 |
impl Transaction | 트랜잭션을 만드는 함수와 해시 계산 함수 |
struct BlockHeader | 블록 검증에 필요한 요약 정보 |
struct Block | 헤더와 트랜잭션 목록이 어떻게 묶이는지 |
impl Block | 제네시스 생성, 새 블록 생성, 머클 루트 계산, 채굴 |
struct Blockchain | 블록 목록과 난이도를 어떤 상태로 보관하는지 |
impl Blockchain | 마지막 블록 조회, 블록 추가, 체인 검증 |
처음 읽을 때 &self, Vec<Transaction>, Self, clone()을 모두 완벽히 이해할 필요는 없다. 지금은 “블록체인 프로그램은 데이터를 구조체로 모델링하고, 검증/해싱/추가 동작을 impl에 붙인다”는 큰 흐름을 잡으면 된다.
6. 핵심 정리
- 블록 = 헤더 + 바디: 헤더에는 이전 블록 해시, 머클 루트, 논스 등이 있고 바디에는 트랜잭션 목록이 있다
- 체인 연결: 각 블록이 이전 블록의 해시를 포함해 역방향 변조가 불가능해진다
- 트랜잭션 nonce: 재전송 공격을 방지하고 처리 순서를 보장한다
- UTXO(비트코인): 미사용 출력들의 합이 잔액. 프라이버시 유리, 스마트 컨트랙트 불리
- Account(이더리움): 계정에 잔액 직접 저장. 스마트 컨트랙트 구현에 최적화
다음 챕터에서는 블록들이 서로 경쟁하며 추가될 때 네트워크가 어떻게 합의에 도달하는지 — 합의 알고리즘 — 을 다룬다.
합의 알고리즘: 분산 네트워크가 동의에 이르는 방법
수천 개의 노드가 각자 다른 트랜잭션을 받고, 각자 다른 블록을 만들려고 할 때, 어떻게 모두가 동일한 블록체인에 동의할 수 있을까? 이것이 합의(Consensus) 문제다.
1. 비잔틴 장군 문제
합의 알고리즘을 이해하려면 먼저 **비잔틴 장군 문제(Byzantine Generals Problem)**를 알아야 한다.
시나리오: 비잔틴 제국의 군대가 적의 도시를 포위하고 있다.
여러 장군이 각기 다른 위치에 있으며 전령을 통해 소통한다.
장군 A
/ \
전령 전령
/ \
장군 B ─────전령──── 장군 C
(배신자)
- 장군들은 "공격" 또는 "후퇴"를 투표해야 함
- 과반수가 같은 행동을 해야 성공
- 문제: 일부 장군(또는 전령)이 배신자일 수 있음
- 배신자는 거짓 메시지를 보내거나,
어떤 장군에게는 "공격", 다른 장군에게는 "후퇴"를 전달할 수 있음
블록체인에서의 대응:
- 장군 = 네트워크 노드
- 메시지 = 블록, 트랜잭션
- 배신자 = 악의적인 노드 (해커, 버그가 있는 노드)
- 합의 = 모든 정직한 노드가 같은 블록체인 상태에 동의
이론적으로 비잔틴 내결함성(BFT)을 달성하려면 전체 노드 중 최대 1/3만이 악의적이어야 한다 (3f+1 규칙).
2. Proof of Work (PoW) — 작업 증명
2.1 핵심 아이디어
“계산 작업을 많이 한 사람이 블록을 추가할 권리를 얻는다.”
CPU/GPU 연산 능력을 소모해야 블록을 생성할 수 있으므로, 공격하려면 엄청난 실제 자원(전기, 하드웨어)이 필요하다.
2.2 채굴 과정 상세
채굴자의 목표:
SHA256(블록 헤더) 값이 특정 목표값보다 작아야 한다
목표값 (난이도에 따라 달라짐):
00000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
(앞에 0이 8개)
채굴 루프:
nonce = 0
반복:
hash = SHA256(SHA256(헤더 + nonce))
if hash < target:
블록 발견! 네트워크에 전파
else:
nonce += 1
왜 어려운가? SHA-256의 출력은 완전히 예측 불가능하다. 올바른 nonce를 찾는 유일한 방법은 일일이 시도해보는 것(무차별 대입)뿐이다.
확률 예시:
- 해시 앞에 0이 10개 필요: 1/16^10 = 1/1,099,511,627,776 확률
- 즉 평균 1조 번 이상의 계산이 필요
2.3 난이도 조절
비트코인은 2,016블록(약 2주)마다 난이도를 자동 조절한다:
실제 소요 시간 < 2주: 난이도 상승 (더 어렵게)
실제 소요 시간 > 2주: 난이도 하락 (더 쉽게)
목표: 항상 평균 10분당 1블록
예시:
이전 2주간 블록 생성: 1,500개 (목표의 74.4%)
→ 10분이 걸릴 것들이 평균 8.1분에 생성됨
→ 난이도를 10/8.1 = 1.23배 상승
2.4 Rust로 PoW 시뮬레이션
use sha2::{Sha256, Digest};
use std::time::Instant;
fn mine_block(data: &str, difficulty: usize) -> (u64, String) {
let target = "0".repeat(difficulty);
let start = Instant::now();
let mut nonce: u64 = 0;
loop {
let input = format!("{}{}", data, nonce);
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let hash = hex::encode(hasher.finalize());
if hash.starts_with(&target) {
let elapsed = start.elapsed();
println!("채굴 완료!");
println!(" nonce: {}", nonce);
println!(" hash: {}", hash);
println!(" 시간: {:.3}초", elapsed.as_secs_f64());
println!(" 시도 수: {}", nonce + 1);
return (nonce, hash);
}
nonce += 1;
// 진행 상황 표시 (100만 번마다)
if nonce % 1_000_000 == 0 {
println!("{}백만 번 시도 중...", nonce / 1_000_000);
}
}
}
fn main() {
let block_data = "블록 #1: Alice→Bob 1ETH, Bob→Carol 0.5ETH";
for difficulty in [2, 3, 4, 5] {
println!("\n=== 난이도 {} ===", difficulty);
mine_block(block_data, difficulty);
}
}
실행 결과 예시:
=== 난이도 2 ===
채굴 완료!
nonce: 138
hash: 003a7f1c...
시간: 0.001초
시도 수: 139
=== 난이도 4 ===
채굴 완료!
nonce: 52,847
hash: 0000d3c1...
시간: 0.045초
시도 수: 52,848
=== 난이도 5 ===
채굴 완료!
nonce: 1,245,832
hash: 00000b7a...
시간: 1.073초
시도 수: 1,245,833
난이도가 1 증가할 때마다 평균 시도 횟수가 16배 증가한다 (16진수 한 자리 = 4비트).
2.5 PoW의 문제점
에너지 소비:
비트코인 네트워크 연간 전력 소비
≈ 아르헨티나 전체 전력 소비량 (약 130 TWh)
이는 모든 채굴자가 의도적으로 비효율적인
작업(nonce 탐색)에 자원을 낭비하기 때문
3. Proof of Stake (PoS) — 지분 증명
3.1 핵심 아이디어
“코인을 많이 담보로 맡긴 사람이 블록 생성 권한을 얻는다.”
계산 능력 대신 **경제적 담보(Stake)**를 사용한다.
PoW:
블록 생성권 = f(해시파워)
공격 비용 = 하드웨어 + 전기세
PoS:
블록 생성권 = f(스테이킹한 ETH 양)
공격 비용 = 코인 자체 (공격하면 내 코인 가치가 떨어짐)
3.2 이더리움 PoS 구성 요소
검증자 (Validator)
- 최소 32 ETH를 스테이킹(잠금)한 노드
- 블록 제안 및 투표 권한 획득
- 현재 이더리움에 약 900,000명 이상의 검증자
슬래싱 (Slashing)
- 악의적인 행동(이중 투표, 연속 오프라인 등)에 대한 처벌
- 스테이킹한 ETH의 일부 또는 전부가 소각됨
- 경제적 패널티가 정직한 행동을 유도
검증자의 보상과 처벌:
정직하게 행동 시:
+ 블록 제안 보상 (~0.1-0.2 ETH)
+ 증명(Attestation) 보상
+ MEV(최대 추출 가능 가치) 보상
악의적 행동 시:
- 슬래싱: 최소 1/32 스테이크 즉시 삭감
- 강제 퇴출 (검증자 자격 박탈)
- 최대 전체 스테이크 손실 가능
3.3 블록 생성 과정
1. 에포크(Epoch, 32슬롯 = ~6.4분) 시작 시
검증자들이 무작위로 위원회에 배정됨
2. 각 슬롯(12초)마다:
- RANDAO 알고리즘으로 무작위 블록 제안자 선택
- 제안자가 새 블록 생성 및 전파
3. 위원회 검증자들이 블록에 투표(Attestation)
4. 2/3 이상 투표 획득 시 블록 확정
5. 체크포인트(매 64슬롯)가 최종 확정(Finality) 됨
→ 이후 절대 변경 불가
3.4 The Merge — 이더리움의 PoW → PoS 전환
타임라인:
2015년 - 이더리움 메인넷 출시 (PoW)
2020년 - 비콘 체인(Beacon Chain) 출시 (PoS 테스트)
2022년 9월 15일 - The Merge 완료!
결과:
- 에너지 소비 99.95% 감소
- 발행량 ~90% 감소 (채굴 보상 없어짐)
- 블록 시간: 평균 13초 → 정확히 12초
4. Proof of History (PoH) — 역사 증명 (Solana)
4.1 문제: PoS의 시간 동기화 한계
분산 네트워크에서 “지금이 몇 시인가“는 합의가 필요한 문제다. 각 노드의 시계가 조금씩 다를 수 있기 때문이다. 이 때문에 PoS 시스템은 슬롯 시간을 길게 잡아야 했다.
4.2 PoH: 시간을 해시로 증명
Solana는 시간 자체를 블록체인에 기록한다.
PoH 순서:
hash_0 = SHA256("초기값")
hash_1 = SHA256(hash_0) ← 1회 반복
hash_2 = SHA256(hash_1) ← 2회 반복
hash_3 = SHA256(hash_2)
hash_4 = SHA256(hash_3)
hash_5 = SHA256(hash_4)
hash_N = SHA256(hash_{N-1}) ← N회 반복
각 hash_N이 "N번의 계산이 일어났다"는 증거가 됨
= 시간이 흘렀다는 증거
VDF (Verifiable Delay Function, 검증 가능한 지연 함수)
- 순차적으로만 계산 가능 (병렬화 불가)
- 계산은 오래 걸리지만, 검증은 빠름
- “이 해시를 만들려면 최소 X시간이 걸렸을 것“을 수학적으로 증명
4.3 PoH의 효과
기존 PoS:
각 블록마다 "이게 맞는 순서야?" 투표 → 느림
Solana PoH:
해시 체인 자체가 시간순서를 증명
→ 투표 없이도 순서가 명백함
→ 초당 65,000 트랜잭션 처리 가능 (이론값)
5. PBFT와 IBFT 2.0
5.1 PBFT (Practical Byzantine Fault Tolerance)
1999년에 제안된 고전적인 BFT 알고리즘.
PBFT 라운드:
1. Pre-prepare: 리더가 요청 브로드캐스트
2. Prepare: 각 노드가 "나도 봤어" 응답
3. Commit: 2f+1 응답 수집 후 "확정" 메시지
4. Reply: 클라이언트에게 결과 전달
f = 결함 허용 노드 수
총 노드 수 = 3f + 1 필요
예: f=1이면 최소 4개 노드 필요
특징:
- 최종성(Finality)이 즉각적: 블록이 한 번 확정되면 절대 변경 안 됨
- 노드 수가 많아지면 메시지가 O(n²)로 급증 → 확장성 한계
- 허가된(Permissioned) 네트워크에 적합
5.2 IBFT 2.0 (Istanbul Byzantine Fault Tolerance 2.0)
이 가이드의 실습 플랫폼인 Hyperledger Besu가 사용하는 합의 알고리즘이다.
PBFT를 블록체인에 맞게 개선한 버전:
IBFT 2.0 블록 생성 과정:
라운드 시작
│
▼
ProposerSelection: 라운드 로빈으로 제안자 선택
│
▼
Propose: 제안자가 블록 전파
│
▼
Prepare: 검증자들이 블록 검증 후 PREPARE 메시지 전송
│ (2f+1 PREPARE 수신 대기)
▼
Commit: COMMIT 메시지 전송
│ (2f+1 COMMIT 수신 시 블록 확정)
▼
블록 추가 완료 (즉각 최종성!)
│
└─ 만약 제한 시간 내 합의 실패 시:
라운드 변경 (Round Change) → 다음 제안자 선택
IBFT 2.0의 특징:
- 즉각적 최종성: 블록 확정 후 재조직(Reorg) 없음
- 에너지 효율적: PoW 불필요
- 허가형 네트워크: 검증자 집합이 알려져 있음
- 결함 허용: 전체 검증자의 1/3 미만이 악의적이어도 동작
- Besu 지원: Hyperledger Besu의 기본 엔터프라이즈 합의
Besu 네트워크 설정 예시 (genesis.json):
{
"config": {
"chainId": 1337,
"ibft2": {
"blockperiodseconds": 2, // 2초마다 블록
"epochlength": 30000, // 검증자 투표 주기
"requesttimeoutseconds": 4 // 타임아웃
}
}
}
6. 합의 알고리즘 비교
| 항목 | PoW | PoS | PoH (Solana) | IBFT 2.0 |
|---|---|---|---|---|
| 블록 시간 | ~10분 (BTC) | ~12초 (ETH) | ~400ms | ~2초 |
| 최종성 | 확률적 (6블록) | ~15분 | ~1초 | 즉각적 |
| 에너지 | 매우 높음 | 낮음 | 낮음 | 낮음 |
| 탈중앙화 | 높음 | 높음 | 중간 | 낮음 |
| 확장성 | 낮음 | 중간 | 높음 | 중간 |
| 결함 허용 | 51% 해시파워 | 33% 지분 | - | 33% 검증자 |
| 허가 필요 | 불필요 | 불필요 | 불필요 | 필요 |
| 주요 사용 | 비트코인 | 이더리움 | Solana | Besu 엔터프라이즈 |
| 공격 비용 | 하드웨어+전기 | 코인 자체 | - | 기관 신뢰 |
7. 핵심 정리
- 비잔틴 장군 문제: 악의적 참여자가 있는 분산 환경에서 합의를 달성하는 고전적 문제
- PoW: 계산 작업으로 권한 증명. 안전하지만 에너지 소비가 큼
- PoS: 경제적 담보로 권한 증명. 에너지 효율적이며 이더리움의 현재 방식
- PoH: 해시 체인으로 시간을 증명. Solana의 고속 처리를 가능하게 함
- IBFT 2.0: PBFT 기반의 즉각 최종성. 우리가 사용할 Besu의 합의 알고리즘
다음 파트(Chapter 10)에서는 이 모든 기초 위에 세워진 이더리움 플랫폼을 깊이 파고든다.
8장: 미니 프로젝트 — Rust로 블록체인 구현
프로젝트 개요
이 장에서는 1주차에 배운 내용을 중심으로 실제로 동작하는 블록체인을 Rust로 구현합니다.
읽는 방법: 이 프로젝트에서는
Result<T, E>,?연산자,thiserror,Vec의 이터레이터 등 아직 다루지 않은 개념이 일부 등장합니다. 지금은 문법을 모두 외우려고 하지 말고 블록체인의 데이터 흐름을 먼저 잡으세요. 문법은 2주차(에러 처리, 트레이트)와 3주차(컬렉션, 이터레이터)에서 본격적으로 배웁니다.
이 장에서 만들 프로그램은 실제 비트코인이나 이더리움처럼 네트워크 합의까지 구현하지 않습니다. 목표는 더 작습니다.
데이터 문자열을 받는다
↓
Block 구조체에 담는다
↓
이전 블록 해시와 연결한다
↓
Proof of Work 조건을 만족할 때까지 nonce를 바꾼다
↓
체인 전체가 변조되지 않았는지 검증한다
즉, 이 장의 핵심은 “블록체인이 왜 변조를 감지할 수 있는가”를 코드로 확인하는 것입니다.
구현 내용:
- SHA-256 해싱
- Block 구조체와 Blockchain 구조체
- Proof of Work (PoW) 마이닝
- 체인 검증
- JSON 직렬화/역직렬화
- 커맨드라인 인터페이스
프로젝트 초기화
cargo new mini-blockchain
cd mini-blockchain
Cargo.toml
[package]
name = "mini-blockchain"
version = "0.1.0"
edition = "2021"
[dependencies]
# SHA-256 해싱
sha2 = "0.10"
# 바이트 배열 ↔ 16진수 문자열
hex = "0.4"
# 직렬화/역직렬화
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# 시간 처리
chrono = { version = "0.4", features = ["serde"] }
# 에러 처리
thiserror = "1.0"
# 로깅
log = "0.4"
env_logger = "0.10"
전체 프로젝트 구조
mini-blockchain/
├── Cargo.toml
└── src/
├── main.rs # 진입점, CLI
├── block.rs # Block 구조체
├── blockchain.rs # Blockchain 구조체
├── error.rs # 에러 타입
└── crypto.rs # 해싱 유틸리티
파일별 책임을 먼저 잡고 들어가면 긴 코드가 덜 부담스럽습니다.
| 파일 | 책임 | 먼저 볼 질문 |
|---|---|---|
crypto.rs | SHA-256 해시 계산 | 같은 입력이 항상 같은 해시가 되는가? |
block.rs | 블록 하나의 데이터와 동작 | 블록 해시가 어떤 필드로 계산되는가? |
blockchain.rs | 블록 목록과 검증 규칙 | 새 블록이 이전 블록과 어떻게 연결되는가? |
error.rs | 실패 상황을 타입으로 표현 | 어떤 상황을 에러로 볼 것인가? |
main.rs | CLI 실행 흐름 | 사용자가 어떤 명령으로 동작을 실행하는가? |
이 순서대로 읽으면 됩니다: crypto.rs → block.rs → blockchain.rs → main.rs.
src/error.rs: 에러 타입
Rust는 예외를 던지는 대신 Result<T, E>로 성공과 실패를 값처럼 반환합니다. 아래 파일은 이 프로젝트에서 발생할 수 있는 실패를 BlockchainError라는 열거형으로 모아둡니다.
처음 보는 문법은 이렇게 읽으세요.
| 문법 | 뜻 |
|---|---|
enum BlockchainError | 가능한 에러 종류를 하나의 타입으로 묶음 |
#[derive(Error, Debug)] | thiserror가 에러 출력 코드를 자동 생성 |
#[error("...")] | 사람이 읽을 에러 메시지 형식 |
pub type Result<T> | 이 프로젝트 안에서 쓸 짧은 Result 별칭 |
use thiserror::Error;
#[derive(Error, Debug)]
pub enum BlockchainError {
#[error("Invalid block at index {index}: {reason}")]
InvalidBlock { index: u64, reason: String },
#[error("Chain validation failed at block {0}")]
ValidationFailed(u64),
#[error("Block not found at height {0}")]
BlockNotFound(u64),
#[error("Mining failed: {0}")]
MiningError(String),
#[error("Serialization error: {0}")]
SerializationError(#[from] serde_json::Error),
#[error("Empty blockchain")]
EmptyChain,
}
pub type Result<T> = std::result::Result<T, BlockchainError>;
src/crypto.rs: SHA-256 해싱
블록체인의 변조 감지는 해시에서 시작합니다. 이 파일은 “바이트 또는 문자열을 넣으면 SHA-256 해시 문자열을 돌려주는 작은 유틸리티”입니다.
use sha2::{Sha256, Digest};
/// 입력 데이터의 SHA-256 해시를 16진수 문자열로 반환
pub fn sha256(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
let result = hasher.finalize();
hex::encode(result)
}
/// 문자열의 SHA-256 해시
pub fn sha256_str(data: &str) -> String {
sha256(data.as_bytes())
}
/// 여러 데이터를 연결한 SHA-256 해시
pub fn sha256_concat(parts: &[&str]) -> String {
let combined = parts.join("");
sha256_str(&combined)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sha256_known_value() {
// SHA-256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
let hash = sha256_str("hello");
assert_eq!(
hash,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn test_sha256_deterministic() {
let h1 = sha256_str("blockchain");
let h2 = sha256_str("blockchain");
assert_eq!(h1, h2, "Same input must produce same hash");
}
#[test]
fn test_sha256_different_inputs() {
let h1 = sha256_str("block1");
let h2 = sha256_str("block2");
assert_ne!(h1, h2, "Different inputs must produce different hashes");
}
}
src/block.rs: Block 구조체
이 파일이 프로젝트의 중심입니다. Block은 하나의 블록을 표현합니다.
블록 필드는 다음 뜻입니다.
| 필드 | 뜻 |
|---|---|
index | 체인에서 몇 번째 블록인지 나타내는 높이 |
timestamp | 블록 생성 시각 |
data | 이 미니 프로젝트에서 트랜잭션 대신 저장하는 문자열 |
previous_hash | 바로 앞 블록의 해시 |
hash | 이 블록 자체의 해시 |
nonce | Proof of Work 조건을 맞추기 위해 바꾸는 숫자 |
실제 블록체인에서는 data 자리에 트랜잭션 목록과 머클 루트가 들어갑니다. 여기서는 처음 배우는 독자가 구조를 볼 수 있도록 문자열 하나로 단순화했습니다.
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fmt;
use crate::crypto::sha256_concat;
/// 블록체인의 단일 블록
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Block {
/// 블록 높이 (체인에서의 위치)
pub index: u64,
/// 블록 생성 시각 (Unix timestamp)
pub timestamp: i64,
/// 블록에 담긴 데이터 (실제 블록체인에서는 트랜잭션 목록)
pub data: String,
/// 이전 블록의 해시 (체인 연결)
pub previous_hash: String,
/// 이 블록의 해시
pub hash: String,
/// Proof of Work에서 사용한 논스값
pub nonce: u64,
}
impl Block {
/// 제네시스(첫 번째) 블록 생성
pub fn genesis() -> Self {
let mut block = Block {
index: 0,
timestamp: Utc::now().timestamp(),
data: String::from("Genesis Block"),
previous_hash: String::from("0000000000000000000000000000000000000000000000000000000000000000"),
hash: String::new(),
nonce: 0,
};
block.hash = block.calculate_hash();
block
}
/// 새 블록 생성 (아직 마이닝 전)
pub fn new(index: u64, data: String, previous_hash: String) -> Self {
let timestamp = Utc::now().timestamp();
let mut block = Block {
index,
timestamp,
data,
previous_hash,
hash: String::new(),
nonce: 0,
};
block.hash = block.calculate_hash();
block
}
/// 블록의 SHA-256 해시 계산
/// 해시 = SHA256(index + timestamp + data + previous_hash + nonce)
pub fn calculate_hash(&self) -> String {
sha256_concat(&[
&self.index.to_string(),
&self.timestamp.to_string(),
&self.data,
&self.previous_hash,
&self.nonce.to_string(),
])
}
/// 해시가 올바른지 검증
pub fn has_valid_hash(&self) -> bool {
self.hash == self.calculate_hash()
}
/// 해시가 요구 난이도를 만족하는지 확인
/// 난이도 N = 해시가 N개의 '0'으로 시작해야 함
pub fn meets_difficulty(&self, difficulty: usize) -> bool {
let target = "0".repeat(difficulty);
self.hash.starts_with(&target)
}
/// Proof of Work 마이닝
/// 요구 난이도를 만족하는 해시를 찾을 때까지 nonce 증가
pub fn mine(&mut self, difficulty: usize) {
let target = "0".repeat(difficulty);
let started_at = std::time::Instant::now();
println!(
"Mining block #{} (difficulty: {}, target prefix: {})...",
self.index, difficulty, target
);
// nonce를 0부터 증가시키며 목표 해시 탐색
loop {
self.hash = self.calculate_hash();
if self.hash.starts_with(&target) {
break;
}
self.nonce += 1;
}
let elapsed = started_at.elapsed();
println!(
"Block #{} mined in {:.2}s! Nonce: {}, Hash: {}...",
self.index,
elapsed.as_secs_f64(),
self.nonce,
&self.hash[..10]
);
}
}
impl fmt::Display for Block {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Block #{}
Timestamp: {}
Data: {}
Previous Hash: {}...
Hash: {}...
Nonce: {}",
self.index,
self.timestamp,
self.data,
&self.previous_hash[..10],
&self.hash[..10],
self.nonce
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_genesis_block() {
let genesis = Block::genesis();
assert_eq!(genesis.index, 0);
assert!(!genesis.hash.is_empty());
assert!(genesis.has_valid_hash());
}
#[test]
fn test_block_hash_changes_with_nonce() {
let mut block = Block::new(1, "test".to_string(), "prev".to_string());
let hash1 = block.calculate_hash();
block.nonce += 1;
let hash2 = block.calculate_hash();
assert_ne!(hash1, hash2);
}
#[test]
fn test_mining_meets_difficulty() {
let mut block = Block::new(1, "test".to_string(), "0000".to_string());
block.mine(2); // difficulty 2 (해시가 "00"으로 시작)
assert!(block.hash.starts_with("00"));
assert!(block.has_valid_hash());
}
#[test]
fn test_invalid_hash_detection() {
let mut block = Block::genesis();
block.data = String::from("tampered data"); // 데이터 변조
// 해시를 재계산하지 않았으므로 has_valid_hash()는 false
assert!(!block.has_valid_hash());
}
}
src/blockchain.rs: Blockchain 구조체
Blockchain은 Block 여러 개를 순서대로 보관하고, 새 블록을 추가하기 전에 연결 규칙을 검증합니다.
이 파일에서 확인할 핵심 규칙은 네 가지입니다.
- 새 블록의
index는 마지막 블록보다 정확히 1 커야 한다. - 새 블록의
previous_hash는 마지막 블록의hash와 같아야 한다. - 새 블록의
hash는 실제 필드값으로 다시 계산한 해시와 같아야 한다. - 새 블록의
hash는 현재 난이도 조건을 만족해야 한다.
이 네 가지가 지켜지면 “체인에 새 블록을 붙여도 된다”고 판단합니다.
use serde::{Deserialize, Serialize};
use std::fmt;
use crate::block::Block;
use crate::error::{BlockchainError, Result};
/// 블록들의 체인
#[derive(Debug, Serialize, Deserialize)]
pub struct Blockchain {
/// 블록 목록 (인덱스 0이 제네시스)
pub blocks: Vec<Block>,
/// 채굴 난이도 (해시 앞의 0 개수)
pub difficulty: usize,
}
impl Blockchain {
/// 새 블록체인 생성 (제네시스 블록 포함)
pub fn new(difficulty: usize) -> Self {
println!("Creating new blockchain with difficulty {}...", difficulty);
let genesis = Block::genesis();
println!("Genesis block created: {}", &genesis.hash[..10]);
Blockchain {
blocks: vec![genesis],
difficulty,
}
}
/// 가장 마지막(최신) 블록 반환
pub fn last_block(&self) -> Option<&Block> {
self.blocks.last()
}
/// 블록 높이 반환
pub fn height(&self) -> u64 {
self.blocks.len() as u64
}
/// 새 블록 추가 (자동으로 마이닝)
pub fn add_block(&mut self, data: String) -> Result<&Block> {
let last = self.last_block()
.ok_or(BlockchainError::EmptyChain)?;
let index = last.index + 1;
let previous_hash = last.hash.clone();
let mut new_block = Block::new(index, data, previous_hash);
new_block.mine(self.difficulty);
// 추가 전 검증
self.validate_new_block(&new_block)?;
self.blocks.push(new_block);
Ok(self.blocks.last().unwrap())
}
/// 새로 추가될 블록의 유효성 검증
fn validate_new_block(&self, block: &Block) -> Result<()> {
let last = self.last_block()
.ok_or(BlockchainError::EmptyChain)?;
// 1. 인덱스 확인
if block.index != last.index + 1 {
return Err(BlockchainError::InvalidBlock {
index: block.index,
reason: format!(
"Expected index {}, got {}",
last.index + 1,
block.index
),
});
}
// 2. 이전 해시 확인
if block.previous_hash != last.hash {
return Err(BlockchainError::InvalidBlock {
index: block.index,
reason: format!(
"Invalid previous hash: expected {}, got {}",
&last.hash[..10],
&block.previous_hash[..10]
),
});
}
// 3. 해시 유효성 확인
if !block.has_valid_hash() {
return Err(BlockchainError::InvalidBlock {
index: block.index,
reason: String::from("Hash does not match block data"),
});
}
// 4. 난이도 충족 확인
if !block.meets_difficulty(self.difficulty) {
return Err(BlockchainError::InvalidBlock {
index: block.index,
reason: format!(
"Hash does not meet difficulty {}",
self.difficulty
),
});
}
Ok(())
}
/// 전체 체인 유효성 검증
pub fn validate(&self) -> Result<()> {
// 제네시스 블록 검증
if self.blocks.is_empty() {
return Err(BlockchainError::EmptyChain);
}
let genesis = &self.blocks[0];
if !genesis.has_valid_hash() {
return Err(BlockchainError::ValidationFailed(0));
}
// 나머지 블록들 검증
for i in 1..self.blocks.len() {
let current = &self.blocks[i];
let previous = &self.blocks[i - 1];
// 해시 유효성
if !current.has_valid_hash() {
return Err(BlockchainError::ValidationFailed(current.index));
}
// 이전 해시 연결성
if current.previous_hash != previous.hash {
return Err(BlockchainError::ValidationFailed(current.index));
}
// 인덱스 순서
if current.index != previous.index + 1 {
return Err(BlockchainError::ValidationFailed(current.index));
}
}
Ok(())
}
/// 특정 높이의 블록 조회
pub fn get_block(&self, height: u64) -> Result<&Block> {
self.blocks.get(height as usize)
.ok_or(BlockchainError::BlockNotFound(height))
}
/// JSON으로 직렬화
pub fn to_json(&self) -> Result<String> {
serde_json::to_string_pretty(self).map_err(BlockchainError::from)
}
/// JSON에서 역직렬화
pub fn from_json(json: &str) -> Result<Self> {
serde_json::from_str(json).map_err(BlockchainError::from)
}
/// 체인 요약 출력
pub fn print_summary(&self) {
println!("\n=== Blockchain Summary ===");
println!("Height: {} blocks", self.height());
println!("Difficulty: {}", self.difficulty);
println!("Valid: {}", self.validate().is_ok());
println!("==========================\n");
}
}
impl fmt::Display for Blockchain {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "=== Blockchain (difficulty: {}) ===", self.difficulty)?;
for block in &self.blocks {
writeln!(f, "{}", block)?;
writeln!(f, " {}", "-".repeat(50))?;
}
write!(f, "Total blocks: {}", self.blocks.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_chain() -> Blockchain {
let mut chain = Blockchain::new(1); // 낮은 난이도로 빠른 테스트
chain.add_block("Alice → Bob: 1 BTC".to_string()).unwrap();
chain.add_block("Bob → Carol: 0.5 BTC".to_string()).unwrap();
chain
}
#[test]
fn test_new_blockchain_has_genesis() {
let chain = Blockchain::new(1);
assert_eq!(chain.height(), 1);
assert_eq!(chain.blocks[0].index, 0);
}
#[test]
fn test_add_block_increases_height() {
let mut chain = Blockchain::new(1);
chain.add_block("tx1".to_string()).unwrap();
chain.add_block("tx2".to_string()).unwrap();
assert_eq!(chain.height(), 3);
}
#[test]
fn test_chain_validation_passes_for_valid_chain() {
let chain = create_test_chain();
assert!(chain.validate().is_ok());
}
#[test]
fn test_chain_validation_fails_after_tampering() {
let mut chain = create_test_chain();
// 블록 데이터 변조
chain.blocks[1].data = String::from("TAMPERED: Alice → Attacker: 1 BTC");
// 해시는 재계산하지 않음 → 검증 실패
assert!(chain.validate().is_err());
}
#[test]
fn test_blocks_are_linked() {
let chain = create_test_chain();
for i in 1..chain.blocks.len() {
assert_eq!(
chain.blocks[i].previous_hash,
chain.blocks[i - 1].hash,
"Block {} should reference block {}'s hash",
i, i - 1
);
}
}
#[test]
fn test_json_serialization_roundtrip() {
let chain = create_test_chain();
let json = chain.to_json().unwrap();
let restored = Blockchain::from_json(&json).unwrap();
assert_eq!(chain.blocks.len(), restored.blocks.len());
assert_eq!(chain.blocks[0].hash, restored.blocks[0].hash);
}
#[test]
fn test_get_block_by_height() {
let chain = create_test_chain();
assert!(chain.get_block(0).is_ok());
assert!(chain.get_block(1).is_ok());
assert!(chain.get_block(99).is_err());
}
}
src/main.rs: 진입점과 CLI
mod block;
mod blockchain;
mod crypto;
mod error;
use blockchain::Blockchain;
use std::env;
fn print_usage() {
println!("Usage: mini-blockchain <command>");
println!();
println!("Commands:");
println!(" demo — Run a demonstration");
println!(" mine <data> — Mine a new block with the given data");
println!(" verify — Create and verify a chain");
println!(" bench — Benchmark different difficulties");
}
fn run_demo() {
println!("╔═══════════════════════════════════╗");
println!("║ Mini Blockchain in Rust 🦀 ║");
println!("╚═══════════════════════════════════╝\n");
// 난이도 2로 블록체인 생성
let mut chain = Blockchain::new(2);
// 트랜잭션 데이터 추가
let transactions = vec![
"Alice → Bob: 1.5 BTC",
"Bob → Carol: 0.5 BTC",
"Carol → Dave: 0.1 BTC",
];
for tx in transactions {
println!("Adding transaction: {}", tx);
match chain.add_block(tx.to_string()) {
Ok(block) => println!(" ✓ Block #{} added (hash: {}...)\n", block.index, &block.hash[..10]),
Err(e) => eprintln!(" ✗ Failed: {}\n", e),
}
}
// 체인 출력
println!("{}", chain);
chain.print_summary();
// 체인 검증
println!("Validating chain...");
match chain.validate() {
Ok(()) => println!("✓ Chain is valid!\n"),
Err(e) => println!("✗ Chain is invalid: {}\n", e),
}
// 변조 시도
println!("Attempting to tamper with block #1...");
chain.blocks[1].data = String::from("ATTACKER → Attacker: 1000 BTC");
// 해시를 재계산하지 않음
println!("Validating tampered chain...");
match chain.validate() {
Ok(()) => println!("✗ Chain accepted tampered data! (This should not happen)"),
Err(e) => println!("✓ Tampering detected: {}\n", e),
}
// JSON 직렬화 데모
println!("Serializing blockchain to JSON...");
// 원본 체인으로 복원
let mut clean_chain = Blockchain::new(2);
clean_chain.add_block("Alice → Bob: 1 BTC".to_string()).unwrap();
match clean_chain.to_json() {
Ok(json) => {
println!("JSON (first 200 chars): {}...\n", &json[..200.min(json.len())]);
// 역직렬화
match Blockchain::from_json(&json) {
Ok(restored) => println!("✓ Deserialized: {} blocks\n", restored.blocks.len()),
Err(e) => println!("✗ Deserialization failed: {}\n", e),
}
}
Err(e) => println!("✗ Serialization failed: {}\n", e),
}
}
fn run_verify() {
println!("Creating and verifying a 3-block chain...\n");
let mut chain = Blockchain::new(2);
chain.add_block("Block 1 data".to_string()).unwrap();
chain.add_block("Block 2 data".to_string()).unwrap();
println!("{}", chain);
match chain.validate() {
Ok(()) => println!("✓ Chain valid"),
Err(e) => println!("✗ Invalid: {}", e),
}
}
fn run_bench() {
println!("Benchmarking mining at different difficulties...\n");
for difficulty in 1..=4 {
let start = std::time::Instant::now();
let mut chain = Blockchain::new(difficulty);
chain.add_block(format!("Benchmark block at difficulty {}", difficulty)).unwrap();
let elapsed = start.elapsed();
let block = chain.last_block().unwrap();
println!(
"Difficulty {}: {:.3}s, nonce={}, hash={}...",
difficulty,
elapsed.as_secs_f64(),
block.nonce,
&block.hash[..10]
);
}
}
fn main() {
// 로거 초기화 (RUST_LOG 환경변수로 제어)
env_logger::init();
let args: Vec<String> = env::args().collect();
let command = args.get(1).map(|s| s.as_str()).unwrap_or("demo");
match command {
"demo" => run_demo(),
"mine" => {
let data = args.get(2).cloned().unwrap_or_else(|| "Default block data".to_string());
let mut chain = Blockchain::new(2);
match chain.add_block(data) {
Ok(block) => println!("Mined: {}", block),
Err(e) => eprintln!("Error: {}", e),
}
}
"verify" => run_verify(),
"bench" => run_bench(),
_ => print_usage(),
}
}
실행 방법
# 데모 실행
cargo run -- demo
# 특정 데이터로 마이닝
cargo run -- mine "Alice → Bob: 2.5 BTC"
# 체인 검증
cargo run -- verify
# 성능 벤치마크
cargo run -- bench
# 릴리스 빌드 (훨씬 빠름, PoW 마이닝은 꼭 릴리스로)
cargo build --release
./target/release/mini-blockchain bench
# 테스트 실행
cargo test
# 특정 테스트만
cargo test blockchain::tests::test_chain_validation
cargo test block::tests
# 로그 출력 포함
RUST_LOG=debug cargo run -- demo
예상 출력
╔═══════════════════════════════════╗
║ Mini Blockchain in Rust 🦀 ║
╚═══════════════════════════════════╝
Creating new blockchain with difficulty 2...
Genesis block created: 4b227777d...
Adding transaction: Alice → Bob: 1.5 BTC
Mining block #1 (difficulty: 2, target prefix: 00)...
Block #1 mined in 0.001s! Nonce: 127, Hash: 003f7a2b1...
✓ Block #1 added (hash: 003f7a2b1...)
Adding transaction: Bob → Carol: 0.5 BTC
Mining block #2 (difficulty: 2, target prefix: 00)...
Block #2 mined in 0.003s! Nonce: 432, Hash: 00ab12c3d...
✓ Block #2 added (hash: 00ab12c3d...)
Adding transaction: Carol → Dave: 0.1 BTC
Mining block #3 (difficulty: 2, target prefix: 00)...
Block #3 mined in 0.002s! Nonce: 89, Hash: 006ef4a11...
✓ Block #3 added (hash: 006ef4a11...)
=== Blockchain (difficulty: 2) ===
Block #0
Timestamp: 1700000000
Data: Genesis Block
Previous Hash: 0000000000...
Hash: 4b227777d4...
Nonce: 0
--------------------------------------------------
...
=== Blockchain Summary ===
Height: 4 blocks
Difficulty: 2
Valid: true
==========================
Validating chain...
✓ Chain is valid!
Attempting to tamper with block #1...
Validating tampered chain...
✓ Tampering detected: Chain validation failed at block 1
핵심 개념 설명
Proof of Work (작업 증명)
PoW는 “이 정도의 계산 작업을 했음“을 증명하는 메커니즘입니다:
목표: 해시가 "00..."으로 시작하는 nonce 찾기
→ 평균적으로 256번 시도 (difficulty=2)
→ 계산 비용이 있어서 악의적인 체인 재작성을 어렵게 함
→ 검증은 한 번의 해시 계산으로 O(1)
// 마이닝: O(2^(4*difficulty)) 평균 시도
fn mine(&mut self, difficulty: usize) {
let target = "0".repeat(difficulty);
while !self.hash.starts_with(&target) {
self.nonce += 1;
self.hash = self.calculate_hash();
}
}
// 검증: O(1)
fn has_valid_hash(&self) -> bool {
self.hash == self.calculate_hash()
}
해시 체인의 불변성
블록 N의 해시는 블록 N-1의 해시를 포함합니다:
Block 0 (Genesis)
hash = SHA256("0" + timestamp + "Genesis Block" + "000...0" + "0")
hash = "4b22..."
Block 1
hash = SHA256("1" + timestamp + "Alice → Bob" + "4b22..." + nonce)
hash = "003f..."
Block 2
hash = SHA256("2" + timestamp + "Bob → Carol" + "003f..." + nonce)
hash = "00ab..."
블록 1의 데이터를 바꾸면:
- 블록 1의 해시가 바뀜
- 블록 2의
previous_hash가 틀려짐 - 블록 2 이후 모든 블록을 다시 마이닝해야 함 → 실용적으로 불가능
확장 아이디어
이 미니 블록체인을 확장해볼 수 있는 방향들:
1. 트랜잭션 구조체 추가
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Transaction {
pub from: String,
pub to: String,
pub amount: u64,
pub fee: u64,
pub signature: String,
}
pub struct Block {
pub index: u64,
pub previous_hash: String,
pub hash: String,
pub nonce: u64,
pub transactions: Vec<Transaction>, // data 대신
}
2. Merkle Tree 루트 해시
impl Block {
pub fn merkle_root(&self) -> String {
let tx_hashes: Vec<String> = self.transactions.iter()
.map(|tx| sha256_str(&serde_json::to_string(tx).unwrap()))
.collect();
compute_merkle_root(tx_hashes)
}
}
3. 비동기 마이닝 (Tokio)
use tokio::task;
impl Block {
pub async fn mine_async(&mut self, difficulty: usize) {
// CPU 집약적 작업은 spawn_blocking으로 별도 스레드에서
let block = self.clone();
let mined = task::spawn_blocking(move || {
let mut b = block;
b.mine(difficulty);
b
}).await.unwrap();
*self = mined;
}
}
4. P2P 네트워크 (Tokio TCP)
async fn handle_peer(
mut stream: TcpStream,
state: Arc<RwLock<Blockchain>>,
) {
// 피어로부터 새 블록 수신 및 검증
let mut buf = vec![0u8; 65536];
let n = stream.read(&mut buf).await.unwrap();
let block: Block = serde_json::from_slice(&buf[..n]).unwrap();
let mut chain = state.write().await;
// 블록 검증 및 추가
}
5. 지갑과 서명 (ed25519)
[dependencies]
ed25519-dalek = "2.0"
rand = "0.8"
use ed25519_dalek::{Keypair, Signer, Verifier};
fn create_wallet() -> Keypair {
let mut rng = rand::thread_rng();
Keypair::generate(&mut rng)
}
fn sign_transaction(keypair: &Keypair, tx_data: &str) -> String {
let signature = keypair.sign(tx_data.as_bytes());
hex::encode(signature.to_bytes())
}
요약
이 장에서 구현한 것:
- SHA-256 해싱 (
sha2크레이트) — 암호학적 해시 함수 - Block 구조체 — 인덱스, 타임스탬프, 데이터, 이전 해시, 현재 해시, 논스
- Proof of Work — 목표 난이도를 충족하는 해시 탐색
- Blockchain 구조체 — 블록 목록, 검증 로직
- 체인 불변성 — 해시 체인으로 변조 감지
- JSON 직렬화 —
serde/serde_json으로 영속화 - 테스트 — 단위 테스트로 핵심 로직 검증
- CLI —
env::args()로 커맨드라인 인터페이스
사용된 Rust 개념들:
- 구조체와
impl블록 - 열거형과 패턴 매칭 (에러 처리)
Result<T, E>와?연산자thiserror로 커스텀 에러 타입#[derive(Debug, Clone, Serialize, Deserialize)]- 이터레이터와 클로저
- 소유권과 참조 (
&self,&mut self,.clone()) - 모듈 시스템 (
mod,pub,use)
1주차를 완료했습니다! Rust 기초와 블록체인 핵심 구조를 직접 구현해봤습니다. 2주차에서는 에러 처리, 트레이트 등 Rust를 더 깊이 배우면서 이더리움과 Solidity를 시작합니다.
4장: 에러 처리
Rust의 에러 처리 철학
Rust에는 예외(exception)가 없습니다. try/catch가 없습니다. throw가 없습니다.
이것은 버그가 아니라 의도적인 설계입니다.
왜 예외를 없앴는가?
TypeScript/Node.js에서 예외 기반 에러 처리의 문제점:
// 이 함수가 에러를 던질 수 있다는 걸 타입에서 알 수 없음
async function fetchBlock(height: number): Promise<Block> {
const response = await fetch(`/api/blocks/${height}`);
const block = await response.json();
return block;
}
// 호출하는 쪽에서 try/catch를 반드시 써야 하는지 모름
const block = await fetchBlock(100); // 예외가 날 수 있는지 알 수 없음
// try/catch를 써도 에러 타입이 unknown
try {
const block = await fetchBlock(100);
} catch (error) {
// error는 any/unknown 타입
// 어떤 에러인지 타입 체크해야 함
if (error instanceof NetworkError) {
logger.warn({ height: 100, error }, "network failed");
} else if (error instanceof ParseError) {
logger.error({ height: 100, error }, "invalid block payload");
} else {
throw error;
}
}
문제점:
- 함수 시그니처만으로 에러 가능성을 알 수 없음
- 예외를 처리하지 않아도 컴파일 에러 없음 (런타임에서야 발견)
- 에러 타입이 타입 시스템에서 보장되지 않음
Rust의 해결책: 에러는 값이다
// 반환 타입에 에러 가능성이 명시됨
fn fetch_block(height: u64) -> Result<Block, NetworkError> {
if height == 0 {
return Err(NetworkError::InvalidHeight);
}
Ok(Block {
height,
hash: String::from("0xabc123"),
})
}
// 호출하는 쪽에서 에러를 반드시 처리해야 함
let block = fetch_block(100); // Result<Block, NetworkError>
// block을 바로 사용하려면 에러 처리 필요
match block {
Ok(b) => println!("Got block: {}", b.hash),
Err(e) => println!("Failed: {}", e),
}
장점:
- 함수 시그니처에서 에러 가능성이 보임
- 에러를 처리하지 않으면 컴파일러 경고/에러
- 에러 타입이 명확히 지정됨
Rust의 두 가지 에러 종류
1. 복구 불가능한 에러: panic!
프로그래밍 버그, 불변식 위반 등 계속 실행이 의미 없는 상황:
fn get_block(index: usize) -> &Block {
// 인덱스가 범위를 벗어나면 panic (버그)
&blocks[index] // 범위 초과 시 panic!
}
2. 복구 가능한 에러: Result<T, E>
파일 없음, 네트워크 에러, 파싱 실패 등 정상적인 에러 상황:
fn parse_block(json: &str) -> Result<Block, ParseError> {
// 파싱 실패는 예상된 상황 — Result로 처리
serde_json::from_str(json).map_err(|e| ParseError::Json(e))
}
이 장의 구성
- panic! (4.1): 언제 쓰고, 블록체인에서 왜 위험한가
- Result<T, E> (4.2): Ok/Err, unwrap, 커스텀 에러 타입
- 에러 전파 (4.3):
?연산자, From 트레이트
NestJS와 비교
// NestJS 에러 처리
@Get('/block/:height')
async getBlock(@Param('height') height: string): Promise<BlockDto> {
const h = parseInt(height);
if (isNaN(h)) {
throw new BadRequestException('Invalid block height');
}
const block = await this.blockService.findByHeight(h);
if (!block) {
throw new NotFoundException(`Block at height ${h} not found`);
}
return block;
}
// HttpException을 던지면 NestJS가 자동으로 적절한 HTTP 응답으로 변환
// Rust 에러 처리 (axum 사용)
async fn get_block(Path(height): Path<u64>) -> Result<Json<Block>, AppError> {
let block = block_service::find_by_height(height).await
.map_err(AppError::Database)?;
match block {
Some(b) => Ok(Json(b)),
None => Err(AppError::NotFound(format!("Block {} not found", height))),
}
}
// Result<Json<Block>, AppError>가 자동으로 HTTP 응답으로 변환
핵심 차이: Rust는 에러가 반환 타입에 명시되고, 처리를 강제합니다.
다음 챕터에서 panic!부터 시작합니다.
4.1 panic!
panic!이란?
panic!은 프로그램이 계속 실행될 수 없는 상황에서 즉시 종료하는 메커니즘입니다. 스택을 풀어내며(unwinding) 정리 코드를 실행하거나, 즉시 중단(abort)합니다.
fn main() {
panic!("Something went terribly wrong!");
// thread 'main' panicked at 'Something went terribly wrong!', src/main.rs:2:5
}
panic!이 발생하는 상황들
1. 명시적 panic! 호출
#![allow(unused)]
fn main() {
fn divide(a: f64, b: f64) -> f64 {
if b == 0.0 {
panic!("Division by zero!");
}
a / b
}
}
2. 배열/Vec 범위 초과
fn main() {
let v = vec![1, 2, 3];
println!("{}", v[10]);
// thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 10'
}
3. unwrap()이 None에 호출될 때
fn main() {
let value: Option<i32> = None;
let x = value.unwrap();
// thread 'main' panicked at 'called `Option::unwrap()` on a `None` value'
}
4. expect()
fn main() {
let value: Option<i32> = None;
let x = value.expect("value must exist here");
// thread 'main' panicked at 'value must exist here'
}
5. 정수 오버플로 (debug 모드)
fn main() {
let x: u8 = 255;
let y = x + 1; // debug 모드에서 panic! (overflow)
// release 모드에서는 wrapping (0이 됨)
}
6. assert! 매크로
fn main() {
let x = 5;
assert!(x > 10, "x must be greater than 10, got {}", x);
// thread 'main' panicked at 'x must be greater than 10, got 5'
assert_eq!(x, 5); // x == 5이면 통과
assert_ne!(x, 10); // x != 10이면 통과
}
panic!을 쓸 때와 쓰지 말 때
써야 하는 상황
1. 불변식(invariant) 위반
fn add_block(&mut self, block: Block) {
// 이 조건이 깨지면 버그 — 프로그램 자체가 잘못된 것
assert!(
block.index == self.blocks.len() as u64,
"Block index mismatch: expected {}, got {}",
self.blocks.len(),
block.index
);
self.blocks.push(block);
}
2. 테스트 코드
#[cfg(test)]
mod tests {
#[test]
fn test_block_hash() {
let block = Block::genesis();
assert!(!block.hash.is_empty(), "Genesis block must have a hash");
assert_eq!(block.index, 0, "Genesis block index must be 0");
}
}
3. 학습 중 임시 중단 코드
todo!, unimplemented!, unreachable!는 실행 가능한 완성 코드가 아니라, 개발 중 일부러 프로그램을 중단시키는 매크로입니다. 최종 예제나 실습 코드에는 남기지 않습니다.
#[derive(Debug)]
struct Block {
nonce: u64,
}
fn mine_block(difficulty: usize) -> Result<Block, String> {
if difficulty > 6 {
return Err(String::from("difficulty is too high for this demo"));
}
Ok(Block { nonce: 42 })
}
fn verify_signature(signature: &[u8]) -> Result<bool, String> {
if signature.is_empty() {
return Err(String::from("signature is empty"));
}
Ok(true)
}
fn main() {
let block = mine_block(2).expect("mining should succeed in the demo");
let verified = verify_signature(&[1, 2, 3]).expect("signature should be present");
println!("mined block: {:?}, signature verified: {}", block, verified);
}
완성 코드에서는 위처럼 실제 구현이나 명시적 에러 반환을 사용합니다.
4. 외부 입력이 아닌, 프로그래머의 실수로만 발생할 수 있는 상황
// 컴파일러가 None이 불가능하다고 판단하지 못하지만,
// 로직상 절대 None이 될 수 없는 경우
let last = self.blocks.last().expect("Blockchain must have at least one block");
쓰지 말아야 하는 상황
외부 입력, 네트워크, 파일 등 예상 가능한 에러
// 나쁜 코드 — 외부 입력을 panic으로 처리
fn parse_block_height(s: &str) -> u64 {
s.parse::<u64>().unwrap() // 잘못된 입력이면 panic!
}
// 좋은 코드 — Result로 처리
fn parse_block_height(s: &str) -> Result<u64, std::num::ParseIntError> {
s.parse::<u64>()
}
블록체인에서 panic!의 위험성
스마트 컨트랙트에서의 panic
Solana 온체인 프로그램에서 panic!이 발생하면:
- 트랜잭션이 즉시 실패
- 해당 트랜잭션의 상태 변경이 롤백됨
- 수수료는 차감됨 (가스는 소모됨)
- 온체인 로그에 에러 메시지가 남음
// Solana 프로그램에서 나쁜 패턴
pub fn process_transfer(ctx: Context<Transfer>, amount: u64) -> Result<()> {
let balance = ctx.accounts.source.amount;
// 잔액 부족 시 panic! — 잘못된 접근
assert!(balance >= amount, "Insufficient balance");
ctx.accounts.source.amount -= amount;
ctx.accounts.destination.amount += amount;
}
// 좋은 패턴 — 에러를 반환
pub fn process_transfer(ctx: Context<Transfer>, amount: u64) -> Result<()> {
let balance = ctx.accounts.source.amount;
if balance < amount {
return Err(ErrorCode::InsufficientFunds.into());
}
ctx.accounts.source.amount -= amount;
ctx.accounts.destination.amount += amount;
Ok(())
}
서버 프로그램에서의 panic
Tokio 비동기 런타임에서 태스크 내부의 panic!은:
- 해당 태스크만 종료 (프로세스 전체가 죽지 않음)
JoinHandle에서 에러로 처리 가능- 하지만 예기치 않은 상태 불일치를 만들 수 있음
// 프로덕션 서버에서 — panic을 잡아서 처리
use std::panic;
let result = panic::catch_unwind(|| {
// panic이 날 수 있는 코드
risky_operation()
});
match result {
Ok(val) => println!("Success: {:?}", val),
Err(_) => eprintln!("Caught a panic!"),
}
Cargo.toml에서 panic 동작 설정
[profile.release]
# 릴리스 빌드에서 panic 시 즉시 abort (스택 unwinding 없음)
# 바이너리 크기 감소, 더 빠름
# 스마트 컨트랙트에서 선호
panic = "abort"
[profile.dev]
# 개발 빌드에서는 unwind (기본값) — 에러 메시지 풍부
panic = "unwind"
RUST_BACKTRACE 환경변수
panic 발생 시 스택 트레이스를 보려면:
RUST_BACKTRACE=1 cargo run
# 또는 전체 트레이스
RUST_BACKTRACE=full cargo run
출력 예시:
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 10', src/main.rs:3:20
stack backtrace:
0: rust_begin_unwind
1: core::panicking::panic_fmt
2: core::slice::index_failed
3: my_project::main
at ./src/main.rs:3:20
todo!, unimplemented!, unreachable! 비교
| 매크로 | 의미 | 사용 시점 |
|---|---|---|
todo!() | 아직 구현하지 않은 코드 | 개발 중, 나중에 구현 예정 |
unimplemented!() | 의도적으로 구현하지 않음 | 트레이트 메서드 중 일부만 구현 |
unreachable!() | 도달할 수 없는 코드 | 로직상 불가능한 분기 |
#![allow(unused)]
fn main() {
enum Direction { North, South, East, West }
fn turn_left(dir: Direction) -> Direction {
match dir {
Direction::North => Direction::West,
Direction::West => Direction::South,
Direction::South => Direction::East,
Direction::East => Direction::North,
}
}
fn handle_special_only(dir: Direction) {
match dir {
Direction::North => println!("Special north handling"),
_ => unreachable!("Only North should reach here"),
}
}
}
요약
panic!: 복구 불가능한 에러 — 프로그램 즉시 종료- 써야 할 때: 버그, 불변식 위반, 테스트, todo/unimplemented
- 쓰지 말아야 할 때: 외부 입력, 네트워크, 파일 등 예상 가능한 에러
- 블록체인 스마트 컨트랙트에서
panic!은 트랜잭션 실패 + 가스 소모 - 프로덕션 코드에서는
Result<T, E>를 사용
다음 챕터에서 Result<T, E>로 에러를 우아하게 처리하는 방법을 배웁니다.
4.2 Result<T, E>
Result 타입이란?
Result<T, E>는 성공(Ok(T)) 또는 실패(Err(E))를 나타내는 열거형입니다:
#![allow(unused)]
fn main() {
// 표준 라이브러리에 이렇게 정의되어 있음
enum Result<T, E> {
Ok(T), // 성공: 값 T를 담고 있음
Err(E), // 실패: 에러 E를 담고 있음
}
}
TypeScript의 Promise<T>와 비슷하지만, 비동기가 아닙니다. 단순히 “성공 또는 실패“를 타입으로 표현합니다.
// TypeScript: 에러는 예외로 처리 (타입에 드러나지 않음)
async function parseBlockHeight(s: string): Promise<number> {
const n = parseInt(s);
if (isNaN(n)) throw new Error(`Invalid height: ${s}`);
return n;
}
// 또는 명시적으로 Result 패턴을 흉내내기도 함
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
#![allow(unused)]
fn main() {
// Rust: 에러가 반환 타입에 명시됨
fn parse_block_height(s: &str) -> Result<u64, String> {
s.parse::<u64>().map_err(|e| format!("Invalid height: {}", e))
}
}
Result 반환하기
use std::num::ParseIntError;
fn parse_height(s: &str) -> Result<u64, ParseIntError> {
let n = s.parse::<u64>()?; // ? 연산자: 에러면 즉시 반환
Ok(n)
}
// 또는 직접 Ok/Err 반환
fn validate_block_index(index: u64, chain_len: usize) -> Result<(), String> {
if index as usize != chain_len {
return Err(format!(
"Expected index {}, got {}",
chain_len, index
));
}
Ok(()) // 성공, 반환할 값 없음
}
Result 처리하기
1. match로 처리
fn main() {
let result = parse_height("42");
match result {
Ok(height) => println!("Height: {}", height),
Err(e) => println!("Error: {}", e),
}
// 에러에 따른 다른 처리
match "abc".parse::<u64>() {
Ok(n) => println!("Parsed: {}", n),
Err(e) => {
eprintln!("Parse error: {}", e);
// 기본값 사용, 재시도, 로그 등
}
}
}
2. unwrap() — 빠르게 쓰되 주의
fn main() {
// Ok이면 값 반환, Err이면 panic!
let height = "42".parse::<u64>().unwrap();
println!("{}", height); // 42
// Err이면 panic
// let bad = "abc".parse::<u64>().unwrap();
// thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: ...'
}
unwrap()은 프로토타입, 테스트, 또는 절대 실패하지 않는다고 확신할 때만 씁니다.
3. expect() — 더 나은 에러 메시지
fn main() {
let config_path = std::env::var("CONFIG_PATH")
.expect("CONFIG_PATH environment variable must be set");
let port: u16 = std::env::var("PORT")
.unwrap_or(String::from("8080"))
.parse()
.expect("PORT must be a valid port number (1-65535)");
}
4. unwrap_or() — 기본값
fn main() {
let height: u64 = "abc".parse().unwrap_or(0);
println!("{}", height); // 0 (파싱 실패 시 기본값)
let height2: u64 = "abc".parse().unwrap_or_default();
println!("{}", height2); // 0 (u64의 기본값)
let height3: u64 = "abc".parse().unwrap_or_else(|e| {
eprintln!("Parse failed: {}, using default", e);
0
});
}
5. map() — Ok 값 변환
fn main() {
// Ok(42) → Ok("42 blocks")
let result: Result<String, _> = "42".parse::<u64>()
.map(|n| format!("{} blocks", n));
println!("{:?}", result); // Ok("42 blocks")
// Err는 그대로 통과
let result2: Result<String, _> = "abc".parse::<u64>()
.map(|n| format!("{} blocks", n));
println!("{:?}", result2); // Err(...)
}
6. map_err() — Err 값 변환
#[derive(Debug)]
enum AppError {
ParseError(String),
NetworkError(String),
}
fn parse_height(s: &str) -> Result<u64, AppError> {
s.parse::<u64>()
.map_err(|e| AppError::ParseError(format!("Cannot parse '{}': {}", s, e)))
}
7. and_then() — Ok일 때 다음 연산 (flatMap)
fn get_block_hash(height_str: &str, blockchain: &Blockchain) -> Result<String, AppError> {
"42".parse::<u64>()
.map_err(|e| AppError::ParseError(e.to_string()))
.and_then(|height| {
blockchain.get_block(height)
.map(|block| block.hash.clone())
.ok_or(AppError::NotFound(format!("Block {} not found", height)))
})
}
8. is_ok(), is_err()
fn main() {
let ok: Result<i32, &str> = Ok(42);
let err: Result<i32, &str> = Err("oops");
println!("{}", ok.is_ok()); // true
println!("{}", ok.is_err()); // false
println!("{}", err.is_ok()); // false
println!("{}", err.is_err()); // true
}
커스텀 에러 타입 만들기
방법 1: 단순 String 에러 (간단하지만 제한적)
#![allow(unused)]
fn main() {
fn parse(s: &str) -> Result<u64, String> {
s.parse::<u64>().map_err(|e| e.to_string())
}
}
방법 2: 열거형 에러 타입 (권장)
use std::fmt;
#[derive(Debug)]
enum BlockchainError {
InvalidBlockIndex { expected: u64, got: u64 },
HashMismatch { expected: String, actual: String },
InvalidProofOfWork { hash: String, difficulty: usize },
EmptyChain,
SerializationError(String),
}
impl fmt::Display for BlockchainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BlockchainError::InvalidBlockIndex { expected, got } => {
write!(f, "Invalid block index: expected {}, got {}", expected, got)
}
BlockchainError::HashMismatch { expected, actual } => {
write!(f, "Hash mismatch: expected {}, got {}", expected, actual)
}
BlockchainError::InvalidProofOfWork { hash, difficulty } => {
write!(f, "Hash '{}' doesn't meet difficulty {}", hash, difficulty)
}
BlockchainError::EmptyChain => {
write!(f, "Blockchain is empty")
}
BlockchainError::SerializationError(msg) => {
write!(f, "Serialization error: {}", msg)
}
}
}
}
// std::error::Error 트레이트 구현 (선택사항이지만 관례)
impl std::error::Error for BlockchainError {}
방법 3: thiserror 크레이트 (가장 편리, 권장)
thiserror는 에러 타입 정의를 매크로로 단순화합니다:
# Cargo.toml
[dependencies]
thiserror = "1.0"
use thiserror::Error;
#[derive(Error, Debug)]
enum BlockchainError {
#[error("Invalid block index: expected {expected}, got {got}")]
InvalidBlockIndex { expected: u64, got: u64 },
#[error("Hash mismatch: expected {expected}, got {actual}")]
HashMismatch { expected: String, actual: String },
#[error("Hash '{hash}' doesn't meet difficulty {difficulty}")]
InvalidProofOfWork { hash: String, difficulty: usize },
#[error("Blockchain is empty")]
EmptyChain,
#[error("Serialization error: {0}")]
SerializationError(String),
// 다른 에러를 감싸기 (#[from] — From 트레이트 자동 구현)
#[error("JSON error: {0}")]
JsonError(#[from] serde_json::Error),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
thiserror를 쓰면 Display와 Error 트레이트가 자동으로 구현됩니다.
실제 블록체인 코드에서의 에러 처리
use thiserror::Error;
#[derive(Error, Debug)]
pub enum BlockchainError {
#[error("Invalid block: {0}")]
InvalidBlock(String),
#[error("Block not found at height {0}")]
BlockNotFound(u64),
#[error("Chain validation failed at block {0}")]
ValidationFailed(u64),
#[error("Serialization failed: {0}")]
Serialization(#[from] serde_json::Error),
}
pub struct Blockchain {
blocks: Vec<Block>,
difficulty: usize,
}
impl Blockchain {
pub fn add_block(&mut self, data: String) -> Result<&Block, BlockchainError> {
let last_block = self.blocks.last()
.ok_or(BlockchainError::InvalidBlock("Chain is empty".to_string()))?;
let new_index = last_block.index + 1;
let previous_hash = last_block.hash.clone();
let mut new_block = Block::new(new_index, data, previous_hash);
new_block.mine(self.difficulty);
// 검증
if !new_block.is_valid() {
return Err(BlockchainError::InvalidBlock(
format!("Block {} failed validation", new_index)
));
}
self.blocks.push(new_block);
Ok(self.blocks.last().unwrap()) // unwrap OK: 방금 push했으므로 항상 Some
}
pub fn get_block(&self, height: u64) -> Result<&Block, BlockchainError> {
self.blocks.get(height as usize)
.ok_or(BlockchainError::BlockNotFound(height))
}
pub fn validate(&self) -> Result<(), BlockchainError> {
for i in 1..self.blocks.len() {
let current = &self.blocks[i];
let previous = &self.blocks[i - 1];
if current.previous_hash != previous.hash {
return Err(BlockchainError::ValidationFailed(current.index));
}
}
Ok(())
}
pub fn to_json(&self) -> Result<String, BlockchainError> {
// serde_json::Error가 #[from]으로 BlockchainError::Serialization으로 자동 변환
Ok(serde_json::to_string(self)?)
}
}
fn main() {
let mut chain = Blockchain::new();
match chain.add_block(String::from("Alice sends 1 BTC to Bob")) {
Ok(block) => println!("Added block #{}", block.index),
Err(e) => eprintln!("Failed to add block: {}", e),
}
match chain.validate() {
Ok(()) => println!("Chain is valid"),
Err(BlockchainError::ValidationFailed(height)) => {
eprintln!("Chain invalid at block {}", height);
}
Err(e) => eprintln!("Validation error: {}", e),
}
}
anyhow: 애플리케이션 코드용 에러 처리
thiserror가 라이브러리 에러 타입 정의에 쓰인다면, anyhow는 애플리케이션의 main/bin 코드에서 편리하게 씁니다:
[dependencies]
anyhow = "1.0"
use anyhow::{Context, Result, anyhow, bail};
fn main() -> Result<()> { // anyhow::Result = Result<T, anyhow::Error>
let config_path = std::env::args().nth(1)
.ok_or_else(|| anyhow!("Usage: program <config-path>"))?;
let content = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config from '{}'", config_path))?;
let config: serde_json::Value = serde_json::from_str(&content)
.context("Config file must be valid JSON")?;
let port = config["port"].as_u64()
.ok_or_else(|| anyhow!("Config must have 'port' field"))?;
if port > 65535 {
bail!("Port {} is out of range", port); // bail! = return Err(anyhow!(...))
}
println!("Starting server on port {}", port);
Ok(())
}
| 크레이트 | 사용 케이스 |
|---|---|
thiserror | 라이브러리, 구체적인 에러 타입이 필요한 경우 |
anyhow | 바이너리/애플리케이션, 에러 타입이 다양하게 섞이는 경우 |
요약
Result<T, E>: 성공(Ok(T)) 또는 실패(Err(E))를 타입으로 표현- 처리 방법:
match,unwrap(),expect(),unwrap_or(),map(),and_then() - 커스텀 에러:
thiserror크레이트 권장 (Display, Error 자동 구현) - 애플리케이션 코드:
anyhow크레이트 편리 unwrap()은 테스트/프로토타입에서만, 프로덕션에서는?연산자 사용
다음 챕터에서 ? 연산자로 에러를 간결하게 전파하는 방법을 배웁니다.
4.3 에러 전파와 ? 연산자
? 연산자
? 연산자는 Result와 Option에서 에러를 상위 함수로 전파하는 단축 문법입니다.
기본 동작
// ? 없이 — 장황한 코드
fn read_block_from_file(path: &str) -> Result<Block, std::io::Error> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return Err(e), // 에러면 즉시 반환
};
let block = match serde_json::from_str(&content) {
Ok(b) => b,
Err(e) => return Err(e), // 에러면 즉시 반환 (타입이 다르므로 실제로는 변환 필요)
};
Ok(block)
}
// ? 사용 — 간결한 코드
fn read_block_from_file(path: &str) -> Result<Block, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?; // 에러면 즉시 return Err(e)
let block = serde_json::from_str(&content)?; // 에러면 즉시 return Err(e)
Ok(block)
}
?는 다음과 동등합니다:
// 이 두 코드는 동일
let value = some_result?;
let value = match some_result {
Ok(v) => v,
Err(e) => return Err(e.into()), // .into()로 에러 타입 변환
};
? 연산자의 에러 타입 변환
?는 내부적으로 From 트레이트를 이용해 에러 타입을 변환합니다.
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error), // std::io::Error → AppError 자동 변환
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error), // serde_json::Error → AppError 자동 변환
#[error("Parse error: {0}")]
Parse(#[from] std::num::ParseIntError),
}
fn load_blockchain(path: &str) -> Result<Blockchain, AppError> {
// std::io::Error → AppError::Io 자동 변환 (?가 From::from 호출)
let content = std::fs::read_to_string(path)?;
// serde_json::Error → AppError::Json 자동 변환
let chain: Blockchain = serde_json::from_str(&content)?;
Ok(chain)
}
From 트레이트 직접 구현
#![allow(unused)]
fn main() {
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
enum MyError {
Io(io::Error),
Parse(ParseIntError),
}
// io::Error → MyError 변환 구현
impl From<io::Error> for MyError {
fn from(e: io::Error) -> Self {
MyError::Io(e)
}
}
// ParseIntError → MyError 변환 구현
impl From<ParseIntError> for MyError {
fn from(e: ParseIntError) -> Self {
MyError::Parse(e)
}
}
fn load_height_from_file(path: &str) -> Result<u64, MyError> {
let content = std::fs::read_to_string(path)?; // io::Error → MyError::Io
let height = content.trim().parse::<u64>()?; // ParseIntError → MyError::Parse
Ok(height)
}
}
Option에서 ?
?는 Option에서도 동작합니다:
fn get_first_block_hash(chain: &Blockchain) -> Option<&str> {
let first = chain.blocks.first()?; // None이면 즉시 return None
let hash = first.hash.get(..8)?; // None이면 즉시 return None
Some(hash)
}
단, Option을 반환하는 함수에서만 ?를 쓸 수 있습니다. Result를 반환하는 함수에서 Option에 ?를 쓰려면 변환이 필요합니다:
fn process(chain: &Blockchain) -> Result<String, AppError> {
let first = chain.blocks.first()
.ok_or(AppError::EmptyChain)?; // Option → Result 변환 후 ?
Ok(first.hash.clone())
}
main 함수에서 Result 반환
main 함수도 Result를 반환할 수 있습니다:
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let content = std::fs::read_to_string("blockchain.json")?;
let chain: Blockchain = serde_json::from_str(&content)?;
println!("Loaded {} blocks", chain.blocks.len());
Ok(())
}
에러가 발생하면 프로그램이 에러 메시지를 출력하고 종료합니다:
Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }
anyhow를 사용하면 더 나은 에러 출력:
fn main() -> anyhow::Result<()> {
let content = std::fs::read_to_string("blockchain.json")
.context("Failed to open blockchain.json")?;
let chain: Blockchain = serde_json::from_str(&content)
.context("Invalid JSON in blockchain.json")?;
println!("Loaded {} blocks", chain.blocks.len());
Ok(())
}
NestJS의 HttpException과 비교
NestJS에서 에러 처리 패턴:
// NestJS — 예외를 던지고 글로벌 필터가 처리
@Injectable()
export class BlockService {
async findByHeight(height: number): Promise<Block> {
const block = await this.repo.findOne({ where: { height } });
if (!block) {
throw new NotFoundException(`Block at height ${height} not found`);
}
return block;
}
async addBlock(data: CreateBlockDto): Promise<Block> {
try {
const block = this.repo.create(data);
return await this.repo.save(block);
} catch (error) {
if (error.code === '23505') { // unique violation
throw new ConflictException('Block already exists');
}
throw new InternalServerErrorException('Database error');
}
}
}
// Rust + axum — Result를 반환하고 IntoResponse 구현으로 HTTP 변환
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("Block at height {0} not found")]
NotFound(u64),
#[error("Block already exists")]
Conflict,
#[error("Database error: {0}")]
Database(String),
}
// AppError → HTTP 응답 자동 변환
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(h) => (StatusCode::NOT_FOUND, format!("Block {} not found", h)),
AppError::Conflict => (StatusCode::CONFLICT, self.to_string()),
AppError::Database(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.clone()),
};
(status, Json(json!({ "error": message }))).into_response()
}
}
async fn get_block(Path(height): Path<u64>) -> Result<Json<Block>, AppError> {
let block = db::find_block(height).await
.map_err(|e| AppError::Database(e.to_string()))?;
block.ok_or(AppError::NotFound(height)).map(Json)
}
async fn add_block(Json(data): Json<CreateBlockRequest>) -> Result<Json<Block>, AppError> {
let block = db::insert_block(data).await
.map_err(|e| match e.kind() {
db::ErrorKind::UniqueViolation => AppError::Conflict,
_ => AppError::Database(e.to_string()),
})?;
Ok(Json(block))
}
비교 정리:
| NestJS | Rust (axum) |
|---|---|
throw new NotFoundException(...) | return Err(AppError::NotFound(...)) |
@UseFilters(HttpExceptionFilter) | impl IntoResponse for AppError |
| 에러 타입이 런타임에 결정 | 에러 타입이 컴파일 타임에 결정 |
| try/catch로 처리 | ?로 전파, match로 처리 |
에러 처리 체이닝 패턴
실제 코드에서 여러 에러 처리를 체이닝하는 패턴:
use anyhow::{Context, Result};
async fn process_new_block(
blockchain: &mut Blockchain,
tx_data: &str,
) -> Result<String> {
// 1. 트랜잭션 데이터 검증
let tx = parse_transaction(tx_data)
.context("Failed to parse transaction data")?;
// 2. 잔액 확인
let balance = get_balance(&tx.from).await
.with_context(|| format!("Failed to get balance for {}", tx.from))?;
if balance < tx.amount {
anyhow::bail!(
"Insufficient balance: {} < {}",
balance,
tx.amount
);
}
// 3. 블록 추가
let block = blockchain.add_block(tx_data.to_string())
.context("Failed to add block to chain")?;
// 4. 영속화
save_blockchain(blockchain).await
.context("Failed to save blockchain")?;
Ok(block.hash.clone())
}
에러 로깅 패턴
use tracing::{error, warn, info};
async fn handle_transaction(tx: Transaction) {
match process_transaction(&tx).await {
Ok(result) => {
info!("Transaction {} processed: {:?}", tx.id, result);
}
Err(e) => {
// 에러 체인 전체 출력
error!("Transaction {} failed: {:#}", tx.id, e);
// 에러 종류에 따른 다른 처리
if let Some(db_err) = e.downcast_ref::<DatabaseError>() {
// DB 에러면 재시도
warn!("DB error, will retry: {}", db_err);
}
}
}
}
요약
?연산자:Result/Option의 에러를 상위 함수로 전파하는 단축 문법?는 내부적으로From::from()을 호출해 에러 타입을 변환#[from](thiserror): 에러 타입 변환 자동 구현main함수도Result<(), E>를 반환할 수 있음Option에서도?사용 가능 (Option을 반환하는 함수에서)- NestJS의 예외 기반 에러처리 vs Rust의 반환값 기반 에러 처리
다음으로는 이더리움 아키텍처를 살펴본 후, 제네릭과 트레이트를 배웁니다.
이더리움: 월드 컴퓨터
이더리움을 한 문장으로: “튜링 완전한 스마트 컨트랙트를 실행할 수 있는 분산 세계 컴퓨터”
비트코인이 “분산된 디지털 화폐“라면, 이더리움은 “분산된 컴퓨터“다. 비트코인이 송금이라는 단일 목적으로 설계된 반면, 이더리움은 그 위에 어떤 프로그램이든 실행할 수 있도록 설계되었다.
1. 이더리움의 탄생
1.1 비탈릭 부테린 (Vitalik Buterin)
2013년, 당시 19세의 러시아계 캐나다인 프로그래머 비탈릭 부테린은 비트코인의 한계를 깨달았다.
비탈릭의 관찰:
비트코인 스크립트 언어는 의도적으로 제한적임
- 반복문(loop) 없음
- 복잡한 조건 처리 불가
- 상태(state) 저장 어려움
→ "왜 비트코인 위에 더 복잡한 앱을 못 만드는가?"
→ "일반 목적 블록체인을 만들면 어떨까?"
2013년 말 이더리움 백서(Yellow Paper) 발표, 2014년 ICO로 자금 조달, 2015년 7월 30일 메인넷 출시.
1.2 이더리움의 역사 주요 사건
2015.07 Frontier 출시 — 최초 메인넷 (개발자 전용)
2016.03 Homestead — 첫 안정화 버전
2016.06 The DAO 해킹 — 360만 ETH 도난
2016.07 하드포크 → ETH / ETC(클래식) 분리
2017.10 Metropolis 시작 — EIP-1559 기반 작업 시작
2019.12 비콘 체인 스테이킹 시작
2020.12 비콘 체인(Beacon Chain) 출시
2021.08 EIP-1559 활성화 — 기본 수수료 소각 시작
2022.09 The Merge — PoW → PoS 완전 전환 (에너지 99.95% 절감)
2023.03 Shanghai/Capella — 스테이킹 인출 활성화
2024.03 Dencun — EIP-4844 Blob 트랜잭션 (L2 수수료 90% 절감)
2025.05 Pectra — 계정 추상화(EIP-7702), 검증자 한도 상향
1.3 The DAO 해킹과 하드포크 — 불변성의 역설
2016년 The DAO(탈중앙화 자율 조직) 스마트 컨트랙트가 재진입(Reentrancy) 공격으로 360만 ETH가 도난당했다. 커뮤니티는 갈렸다:
찬성 진영: "코드를 롤백해서 피해자를 구해야 한다"
반대 진영: "코드가 곧 법(Code is Law). 불변성을 지켜야 한다"
결과: 하드포크 실행 → 두 개의 체인으로 분리
ETH (이더리움) - 롤백을 선택한 다수
ETC (이더리움 클래식) - 불변성을 고수한 소수
이 사건은 “블록체인은 불변이지만 커뮤니티 합의로 프로토콜을 바꿀 수 있다“는 현실을 보여줬다.
2. 이더리움 vs 비트코인
Node.js 개발자 관점에서 두 시스템의 핵심 차이:
비트코인:
목적: 디지털 화폐, 가치 저장
스크립트: 제한적인 스택 기반 언어 (튜링 불완전)
상태: UTXO 집합
블록 시간: ~10분
처리량: ~7 TPS
언어: Script
이더리움:
목적: 범용 스마트 컨트랙트 플랫폼
VM: EVM (튜링 완전)
상태: 계정 기반 글로벌 상태 트리
블록 시간: 12초
처리량: ~15-30 TPS (L2 포함 수천 TPS)
언어: Solidity, Vyper, Yul
스마트 컨트랙트의 차이
비트코인의 “스크립트“로 할 수 있는 것:
- 특정 주소로만 전송 가능
- 다중 서명(MultiSig) 조건
- 시간 잠금(Timelock)
이더리움의 스마트 컨트랙트로 할 수 있는 것:
- 조건부 자산 이전 (DeFi)
- 토큰 발행 (ERC-20)
- 디지털 소유권 증명 (NFT, ERC-721)
- 탈중앙화 거래소 (DEX)
- 대출/차입 프로토콜
- DAO (조직 거버넌스)
- 게임, 예측 시장, 보험…
3. 이더리움 생태계 개요
3.1 레이어 구조
┌──────────────────────────────────────────────────────────┐
│ 레이어 3 (L3) │
│ 앱별 특화 롤업 (App-specific) │
├──────────────────────────────────────────────────────────┤
│ 레이어 2 (L2) │
│ Arbitrum │ Optimism │ Base │ zkSync │ Starknet │ Scroll │
│ (이더리움의 확장 솔루션 — 빠르고 저렴) │
├──────────────────────────────────────────────────────────┤
│ 이더리움 메인넷 (L1) │
│ (보안과 탈중앙화의 최종 기준점) │
└──────────────────────────────────────────────────────────┘
3.2 DeFi (탈중앙화 금융)
전통 금융: DeFi:
은행 (중개자) 스마트 컨트랙트 (코드)
영업 시간 24/7 운영
계좌 개설 필요 지갑만 있으면 됨
국경 제한 글로벌 접근
주요 DeFi 프로토콜:
Uniswap — 탈중앙화 거래소 (DEX)
Aave — 대출/차입
MakerDAO — 스테이블코인(DAI) 발행
Compound — 이자 농사(Yield Farming)
Curve — 스테이블코인 간 교환
TVL(총 잠긴 가치): 수백억 달러 규모
3.3 NFT (Non-Fungible Token)
대체 가능 (Fungible):
1 ETH == 1 ETH (동등한 가치)
대체 불가능 (Non-Fungible):
토큰 #1 ≠ 토큰 #2 (각각 고유한 속성)
ERC-721 표준으로 구현:
- 디지털 아트 소유권
- 게임 아이템
- 도메인 이름 (ENS)
- 실물 자산 토큰화
3.4 L2 생태계 (2025 현재)
이더리움 확장성 문제를 해결하기 위한 레이어 2 솔루션이 폭발적으로 성장했다:
Optimistic Rollup (낙관적 롤업):
원리: 트랜잭션을 오프체인 처리, 결과만 L1에 기록
사기 증명(Fraud Proof)으로 검증
예시: Arbitrum, Optimism, Base (Coinbase 운영)
인출 대기: 7일 (챌린지 기간)
ZK Rollup (영지식 롤업):
원리: 영지식 증명(ZK-SNARK/STARK)으로 유효성 수학적 증명
즉각적 최종성
예시: zkSync Era, Starknet, Scroll, Polygon zkEVM
인출: 즉시 가능
2025 현재 L2 일일 트랜잭션:
이더리움 L1: ~100만 TX/일
전체 L2 합산: ~1000만+ TX/일 (이미 L1의 10배 이상)
4. 현재 상황 (2025-2026)
4.1 Pectra 업그레이드 (2025년 활성화)
Pectra(Prague + Electra)는 이더리움의 최신 주요 업그레이드다:
주요 EIP (개선 제안):
EIP-7702: 계정 추상화
- EOA(일반 지갑)가 임시로 스마트 계정처럼 동작
- 가스비를 다른 토큰으로 지불 가능
- 트랜잭션 일괄 처리
- 소셜 복구 등 고급 기능
EIP-7251: 검증자 최대 잔액 상향
- 기존: 검증자당 최대 32 ETH (고정)
- 변경: 최대 2,048 ETH까지 누적 가능
- 효과: 검증자 수 감소, 네트워크 효율 향상
EIP-7549, EIP-7685: 검증자 통신 최적화
4.2 이더리움의 로드맵 (The Surge, The Scourge…)
비탈릭 부테린은 이더리움 발전 단계를 색으로 표현했다:
The Merge ✅ 완료 — PoS 전환
The Surge 🔄 진행중 — L2 확장 (목표: 100,000 TPS)
The Scourge 🔜 예정 — MEV 문제 해결, 검열 저항성
The Verge 🔜 예정 — Verkle 트리 도입 (상태 경량화)
The Purge 🔜 예정 — 히스토리 데이터 정리
The Splurge 🔜 예정 — 기타 개선사항
5. Node.js 개발자를 위한 이더리움 생태계 지도
여러분이 접하게 될 주요 도구들:
개발 언어:
Solidity — 스마트 컨트랙트 (주력)
Vyper — 더 간결한 대안
JavaScript — DApp 프론트엔드, 테스트
TypeScript — Hardhat, ethers.js 생태계
Rust — 성능 크리티컬 도구 (이 가이드!)
개발 프레임워크:
Hardhat — Node.js 기반, 가장 인기 있음
Foundry — Rust 기반, 빠른 테스트
Truffle — 레거시, 점차 사용 줄어드는 중
라이브러리:
ethers.js — JS/TS에서 이더리움 상호작용 (현대적)
web3.js — 구버전 대안 (점차 교체 중)
viem — 타입 안전한 최신 라이브러리
alloy — Rust 생태계의 ethers.js
노드 클라이언트:
Geth — Go 언어, 가장 널리 사용
Besu — Java, 엔터프라이즈 (이 가이드의 실습 환경!)
Nethermind — C#
Reth — Rust, 고성능 신예
(Rust 선택 이유: Go 기반 Geth와 성능 경쟁하면서 합의 임계 코드의
메모리 안전성 확보. tokio 비동기 런타임으로 수천 개 P2P 피어를
효율적으로 처리. 병렬 블록 실행을 데이터 레이스 없이 구현)
블록 익스플로러:
Etherscan — 메인넷 트랜잭션/계정 조회
Beaconcha.in — 스테이킹/검증자 조회
6. 왜 이더리움인가? (Node.js 개발자 관점)
// Node.js 백엔드 개발자가 이미 아는 것:
// - REST API 서버 구축
// - 데이터베이스 CRUD
// - 비즈니스 로직 구현
// - 서드파티 API 연동
// 이더리움으로 확장하면:
// - 스마트 컨트랙트 = 비즈니스 로직 (백엔드)
// - 이더리움 상태 = 데이터베이스
// - ABI = REST API 명세 (OpenAPI)
// - ethers.js = axios/fetch (클라이언트)
// - 가스비 = 서버 비용 (하지만 사용자가 냄)
// - 이벤트(Event) = WebSocket 알림
// Node.js 서버 vs 스마트 컨트랙트:
const expressServer = {
코드변경: '언제든지 재배포 가능',
데이터수정: 'UPDATE/DELETE 자유',
접근제어: 'JWT, 세션',
비용: '서버 호스팅 비용',
신뢰: '서버 운영자를 신뢰',
};
const smartContract = {
코드변경: '배포 후 수정 불가 (업그레이드 패턴 별도 존재)',
데이터수정: '이벤트로만 기록, 과거 변경 불가',
접근제어: '개인키(서명) 기반',
비용: '트랜잭션 가스비 (사용자 부담)',
신뢰: '코드 자체를 신뢰 (오픈소스, 검증 가능)',
};
7. 핵심 정리
- 이더리움 = 월드 컴퓨터: 튜링 완전한 스마트 컨트랙트를 실행하는 탈중앙화 플랫폼
- 비트코인과의 차이: 단순 화폐 기능을 넘어 임의의 프로그램 실행 가능
- 생태계: DeFi(수백조 원 규모), NFT, L2(이미 L1 트랜잭션의 10배)
- The Merge: 2022년 PoW → PoS 전환으로 에너지 99.95% 절감
- Pectra (2025): 계정 추상화(EIP-7702)로 사용자 경험 혁신 진행 중
- 실습 환경: 우리는 Hyperledger Besu(IBFT 2.0)를 사용해 프라이빗 이더리움 네트워크를 구축
다음 챕터에서는 이더리움의 핵심 구성요소인 계정과 트랜잭션을 ethers.js 코드와 함께 깊이 분석한다.
계정과 트랜잭션: 이더리움의 기본 단위
이더리움의 모든 활동은 **계정(Account)**과 **트랜잭션(Transaction)**으로 이루어진다. Node.js 개발자라면 계정을 “데이터베이스의 레코드”, 트랜잭션을 “상태를 변경하는 API 요청“으로 이해하면 쉽다.
1. 두 종류의 계정
이더리움에는 근본적으로 다른 두 종류의 계정이 있다.
이더리움 계정 종류:
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ EOA │ │ CA (Contract Account) │
│ (Externally Owned Account) │ │ │
│ 외부 소유 계정 │ │ 컨트랙트 계정 │
├─────────────────────────────┤ ├─────────────────────────────┤
│ ✅ 개인 키(Private Key) 있음 │ │ ❌ 개인 키 없음 │
│ ✅ 트랜잭션 시작 가능 │ │ ❌ 스스로 TX 시작 불가 │
│ ❌ 코드 없음 │ │ ✅ 코드(바이트코드) 있음 │
│ ❌ 자동 실행 없음 │ │ ✅ 호출 시 코드 실행 │
│ │ │ ✅ 스토리지(상태) 있음 │
│ 예: 개인 지갑, MetaMask │ │ 예: Uniswap, ERC-20 토큰 │
└─────────────────────────────┘ └─────────────────────────────┘
1.1 EOA (외부 소유 계정)
- 사람(또는 프로그램)이 개인 키로 제어
- 트랜잭션을 시작할 수 있는 유일한 계정 유형
- 주소는 공개 키에서 파생:
address = keccak256(pubkey)[12:] - 예:
0x742d35Cc6634C0532925a3b8D4C9b845b3A09b92
// ethers.js로 EOA 생성
const { ethers } = require("ethers");
// 새 지갑(EOA) 생성
const wallet = ethers.Wallet.createRandom();
console.log("주소:", wallet.address);
console.log("비밀키:", wallet.privateKey);
console.log("니모닉:", wallet.mnemonic.phrase);
// 기존 비밀키로 지갑 복원
const restored = new ethers.Wallet("0xac0974bec...");
console.log("복원된 주소:", restored.address);
1.2 CA (컨트랙트 계정)
- 개인 키가 없다 → 아무도 “소유“하지 않음
- 코드가 규칙. 코드에 정의된 대로만 동작
- EOA 또는 다른 CA에 의해 호출될 때만 실행됨
- 주소는 배포자 주소와 nonce로 결정:
address = keccak256(deployer, nonce)
CA 호출 흐름:
사용자(EOA) ──TX──▶ Uniswap 컨트랙트(CA) ──내부 호출──▶ 토큰 컨트랙트(CA)
│
코드 실행
(Swap 로직)
2. 계정 상태
이더리움 글로벌 상태는 모든 계정의 상태를 담은 거대한 key-value 저장소다. 각 계정은 4개의 필드를 가진다.
계정 상태 구조:
address → {
nonce: u64, // EOA: 발송한 TX 수 / CA: 생성한 컨트랙트 수
balance: u256, // 보유 ETH (wei 단위)
storageRoot: bytes32, // 스토리지 내용의 머클 루트 (CA만 의미 있음)
codeHash: bytes32, // 컨트랙트 코드의 Keccak 해시 (EOA는 빈 해시)
}
실제 예시:
EOA (Alice의 지갑):
{
nonce: 42, // 42번 트랜잭션을 보냄
balance: 5_000_000_000_000_000_000, // 5 ETH (wei)
storageRoot: 0x56e81f...0000, // 빈 스토리지 (EOA는 스토리지 없음)
codeHash: 0xc5d2460...0000, // 빈 코드 해시
}
CA (ERC-20 토큰 컨트랙트):
{
nonce: 1, // 컨트랙트 배포 시 1
balance: 0, // 토큰 컨트랙트 자체는 ETH 없음
storageRoot: 0x3a7f8c..., // 잔액 맵 등 스토리지
codeHash: 0xbf1a9c..., // ERC-20 바이트코드 해시
}
이더리움의 글로벌 상태 전체는 **패트리샤 머클 트라이(Patricia Merkle Trie)**라는 자료구조에 저장된다. 블록 헤더의 stateRoot가 이 트라이의 루트 해시다.
3. 트랜잭션 타입
이더리움은 역사적으로 여러 트랜잭션 타입이 추가되었다.
3.1 타입 0 — 레거시 트랜잭션
EIP-2718 이전의 원래 형식:
{
nonce: 42,
gasPrice: 20_000_000_000, // 20 Gwei (고정 가격)
gasLimit: 21_000,
to: "0xBob...",
value: ethers.parseEther("1.0"),
data: "0x",
v: 27, // 체인 ID 반영 (EIP-155)
r: "0x...",
s: "0x...",
}
문제: gasPrice가 고정이라 가스비 예측이 어렵고, 채굴자가 모든 수수료를 가져감.
3.2 타입 1 — EIP-2930 (Access List)
2021년 도입. 사전에 접근할 스토리지 슬롯을 선언해 가스비 절감:
{
type: 1,
chainId: 1,
nonce: 42,
gasPrice: 20_000_000_000,
gasLimit: 50_000,
to: "0xContract...",
value: 0,
data: "0x...",
accessList: [ // 미리 선언하면 가스비 절감
{
address: "0xToken...",
storageKeys: ["0x0000...0001"],
}
],
}
3.3 타입 2 — EIP-1559 (현재 표준)
2021년 London 업그레이드에서 도입. 수수료 시장 개혁:
{
type: 2,
chainId: 1,
nonce: 42,
maxFeePerGas: 30_000_000_000, // 최대 30 Gwei (지불 상한)
maxPriorityFeePerGas: 2_000_000_000, // 최대 2 Gwei (채굴자 팁)
gasLimit: 21_000,
to: "0xBob...",
value: ethers.parseEther("1.0"),
data: "0x",
accessList: [],
}
EIP-1559 수수료 구조:
실제 지불 가스비 = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
baseFee: 프로토콜이 결정 (소각됨! 채굴자에게 가지 않음)
tip: maxPriorityFeePerGas (검증자에게 인센티브)
환불: maxFeePerGas - (baseFee + tip) 는 돌려받음
예:
baseFee = 15 Gwei
maxPriorityFeePerGas = 2 Gwei
maxFeePerGas = 30 Gwei
실제 지불 = 15 + 2 = 17 Gwei/gas
환불 = 30 - 17 = 13 Gwei/gas
소각 = 15 Gwei/gas (baseFee)
검증자 수익 = 2 Gwei/gas (tip)
4. nonce의 역할 (매우 중요!)
nonce는 단순한 카운터처럼 보이지만 두 가지 중요한 역할을 한다.
4.1 재전송 공격(Replay Attack) 방지
공격 시나리오 (nonce 없이):
1. Alice가 "Bob에게 1 ETH 전송" TX에 서명
2. 공격자가 이 서명된 TX를 가로챔
3. 공격자가 같은 TX를 100번 재전송
4. Alice의 계좌에서 100 ETH가 빠져나감!
nonce가 있으면:
1. Alice의 첫 TX: nonce=5
2. 이더리움이 TX 처리 후 Alice.nonce = 6으로 업데이트
3. 공격자가 nonce=5 TX를 재전송
4. 이더리움이 "Alice의 nonce는 이미 6이야, nonce=5 TX는 거부"
4.2 트랜잭션 순서 보장
Alice가 빠르게 3개의 TX를 전송:
TX_A: nonce=5, "Bob에게 1 ETH"
TX_B: nonce=6, "컨트랙트 배포"
TX_C: nonce=7, "배포된 컨트랙트 호출"
이더리움은 반드시 nonce 순서대로 처리:
nonce=5 처리 → nonce=6 처리 → nonce=7 처리
TX_B(컨트랙트 배포)보다 TX_C(컨트랙트 호출)가 먼저
처리되는 일은 절대 일어나지 않음!
주의: nonce=6이 멤풀에 없으면 nonce=7은 대기 상태로 막힘
5. ethers.js로 트랜잭션 전체 흐름 구현
const { ethers } = require("ethers");
async function demonstrateTransactions() {
// 1. 프로바이더 연결 (로컬 Besu 노드)
const provider = new ethers.JsonRpcProvider("http://localhost:8545");
// 2. 지갑 생성 (Besu 개발 계정 사용)
const privateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
const wallet = new ethers.Wallet(privateKey, provider);
console.log("=== 계정 정보 ===");
console.log("주소:", wallet.address);
// 3. 계정 상태 조회
const balance = await provider.getBalance(wallet.address);
const nonce = await provider.getTransactionCount(wallet.address);
console.log("잔액:", ethers.formatEther(balance), "ETH");
console.log("nonce:", nonce);
// 4. 트랜잭션 수동 구성 (타입 2, EIP-1559)
const recipient = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const txRequest = {
to: recipient,
value: ethers.parseEther("0.1"),
gasLimit: 21_000n,
maxFeePerGas: ethers.parseUnits("30", "gwei"),
maxPriorityFeePerGas: ethers.parseUnits("2", "gwei"),
nonce: nonce,
chainId: 31337n, // Hardhat/Besu 로컬
type: 2,
};
// 5. 트랜잭션 서명 (아직 전송 안 함)
const signedTx = await wallet.signTransaction(txRequest);
console.log("\n=== 서명된 트랜잭션 ===");
console.log("서명된 TX (RLP encoded):", signedTx.slice(0, 40) + "...");
// 6. 트랜잭션 전송 (더 간단한 방법)
console.log("\n=== 트랜잭션 전송 ===");
const tx = await wallet.sendTransaction({
to: recipient,
value: ethers.parseEther("0.1"),
});
console.log("TX 해시:", tx.hash);
console.log("nonce:", tx.nonce);
console.log("gasLimit:", tx.gasLimit.toString());
console.log("maxFeePerGas:", ethers.formatUnits(tx.maxFeePerGas ?? 0n, "gwei"), "Gwei");
// 7. 확인 대기
console.log("\n블록 포함 대기 중...");
const receipt = await tx.wait(1); // 1 컨펌 대기
// 8. 트랜잭션 영수증 분석
console.log("\n=== 트랜잭션 영수증 ===");
console.log("상태:", receipt.status === 1 ? "성공" : "실패");
console.log("블록 번호:", receipt.blockNumber);
console.log("가스 사용:", receipt.gasUsed.toString());
const gasCost = receipt.gasUsed * (receipt.gasPrice ?? 0n);
console.log("실제 가스비:", ethers.formatEther(gasCost), "ETH");
console.log("이벤트 로그 수:", receipt.logs.length);
// 9. 잔액 변화 확인
const newBalance = await provider.getBalance(wallet.address);
console.log("\n=== 잔액 변화 ===");
console.log("이전 잔액:", ethers.formatEther(balance), "ETH");
console.log("이후 잔액:", ethers.formatEther(newBalance), "ETH");
console.log("차이:", ethers.formatEther(balance - newBalance), "ETH (전송량 + 가스비)");
}
demonstrateTransactions().catch(console.error);
5.1 트랜잭션 영수증 (Receipt) 상세
// 트랜잭션 영수증 구조
const receipt = {
// 상태
status: 1, // 1=성공, 0=실패(revert)
// 블록 정보
blockHash: "0x...", // 포함된 블록의 해시
blockNumber: 18500000, // 포함된 블록 번호
transactionIndex: 42, // 블록 내 TX 순서
// 가스 정보
gasUsed: 21000n, // 실제 사용된 가스
cumulativeGasUsed: 500000n, // 블록 내 이 TX까지 누적 가스
effectiveGasPrice: 17000000000n, // 실제 지불된 가스 가격 (wei)
// 컨트랙트 관련
contractAddress: null, // 컨트랙트 배포 시 새 주소, 일반 TX는 null
// 이벤트 로그 (스마트 컨트랙트가 발행한 이벤트)
logs: [
{
address: "0xToken...", // 이벤트 발행 컨트랙트
topics: ["0xddf252..."], // Transfer 이벤트 시그니처
data: "0x00...0001", // 이벤트 데이터
}
],
};
6. 트랜잭션 타입별 사용 가이드
// 단순 ETH 전송 (ethers.js가 자동으로 EIP-1559 사용)
const simpleTx = await wallet.sendTransaction({
to: recipient,
value: ethers.parseEther("1.0"),
});
// 가스비 수동 설정이 필요한 경우
const urgentTx = await wallet.sendTransaction({
to: recipient,
value: ethers.parseEther("1.0"),
maxFeePerGas: ethers.parseUnits("100", "gwei"), // 긴급, 높은 상한
maxPriorityFeePerGas: ethers.parseUnits("5", "gwei"), // 높은 팁
});
// 현재 가스 가격 조회 (EIP-1559)
const feeData = await provider.getFeeData();
console.log("baseFee:", ethers.formatUnits(feeData.gasPrice ?? 0n, "gwei"), "Gwei");
console.log("maxFeePerGas:", ethers.formatUnits(feeData.maxFeePerGas ?? 0n, "gwei"), "Gwei");
console.log("maxPriorityFeePerGas:", ethers.formatUnits(feeData.maxPriorityFeePerGas ?? 0n, "gwei"), "Gwei");
// 트랜잭션 조회 (이미 전송된 것)
const txDetails = await provider.getTransaction("0xabcd...");
console.log("from:", txDetails?.from);
console.log("to:", txDetails?.to);
console.log("value:", ethers.formatEther(txDetails?.value ?? 0n));
// 컨트랙트 배포 트랜잭션 (to가 null)
const deployTx = await wallet.sendTransaction({
to: null, // 또는 undefined
data: "0x6080604052...", // 컨트랙트 바이트코드
value: 0n,
});
7. 실용적인 패턴: 트랜잭션 모니터링
// 특정 주소의 트랜잭션을 실시간 모니터링
async function watchAddress(provider, address) {
console.log(`${address} 모니터링 시작...`);
// 새 블록마다 확인
provider.on("block", async (blockNumber) => {
const block = await provider.getBlock(blockNumber, true); // TX 포함
for (const tx of block.prefetchedTransactions) {
if (tx.from === address || tx.to === address) {
console.log(`\n새 TX 감지! 블록 #${blockNumber}`);
console.log(" 해시:", tx.hash);
console.log(" from:", tx.from);
console.log(" to:", tx.to);
console.log(" 값:", ethers.formatEther(tx.value), "ETH");
}
}
});
}
// pending TX 모니터링 (Mempool 관찰)
provider.on("pending", (txHash) => {
console.log("새 Pending TX:", txHash);
});
8. 핵심 정리
| 개념 | 설명 | Node.js 비유 |
|---|---|---|
| EOA | 개인 키로 제어하는 계정 | API 클라이언트 (요청 시작자) |
| CA | 코드로 동작하는 계정 | 서버 엔드포인트 (로직 실행자) |
| nonce | TX 순서 번호 | API 요청의 시퀀스 ID |
| gasLimit | 최대 허용 계산량 | API 타임아웃 설정 |
| baseFee | 네트워크 기본 수수료 | 서버 기본 처리 비용 |
| tip | 검증자 인센티브 | 빠른 처리를 위한 프리미엄 |
| receipt | TX 처리 결과 | HTTP 응답 (status, body) |
| logs | 컨트랙트 이벤트 | 서버 측 이벤트 (SSE, WebSocket) |
다음 챕터에서는 이더리움의 심장부인 **EVM(이더리움 가상 머신)**과 가스 시스템을 깊이 파고든다.
EVM과 가스: 이더리움의 실행 엔진
이더리움이 “월드 컴퓨터“라면, **EVM(Ethereum Virtual Machine)**은 그 컴퓨터의 CPU다. 스마트 컨트랙트가 실행되는 환경이자, 모든 이더리움 노드가 동일하게 구현해야 하는 명세다.
1. EVM이란?
1.1 가상 머신의 개념
Node.js 개발자에게 가상 머신은 익숙한 개념이다.
Java의 JVM:
Java 코드 → 컴파일 → .class(바이트코드) → JVM이 실행
어떤 OS에서도 동일하게 동작 ("Write once, run anywhere")
Node.js의 V8:
JavaScript → V8 엔진이 JIT 컴파일 → 네이티브 코드로 실행
EVM:
Solidity → 컴파일 → EVM 바이트코드 → EVM이 실행
어떤 이더리움 노드에서도 동일한 결과 보장
핵심 차이: JVM이나 V8은 파일 시스템, 네트워크 등에 접근할 수 있다. EVM은 철저히 격리(Sandboxed)되어 있다. 외부 세계와의 유일한 접점은 블록체인 상태(스토리지, 잔액)뿐이다.
1.2 EVM의 종류
구현체별 EVM:
Geth (Go) → go-ethereum의 EVM
Besu (Java) → Hyperledger Besu의 EVM
Reth (Rust) → Reth의 EVM (revm 라이브러리)
Nethermind (C#) → Nethermind의 EVM
모두 동일한 이더리움 명세를 따름
→ 같은 컨트랙트를 실행하면 항상 같은 결과
2. EVM 아키텍처: 스택 기반 머신
2.1 세 가지 저장 영역
EVM 실행 컨텍스트:
┌─────────────────────────────────────────────────────────┐
│ EVM │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ 스택 │ │ 메모리 │ │ 스토리지 │ │
│ │ (Stack) │ │ (Memory) │ │ (Storage) │ │
│ │ │ │ │ │ │ │
│ │ 최대 1024개 │ │ 바이트 배열 │ │ key-value │ │
│ │ 각 256비트 │ │ 실행 중만 │ │ 영구 저장 │ │
│ │ LIFO 구조 │ │ 존재 │ │ 블록체인에 │ │
│ │ │ │ 동적 확장 │ │ 기록됨 │ │
│ └──────────────┘ └──────────────┘ └───────────────┘ │
│ 빠름 중간 느리고 비쌈 │
└─────────────────────────────────────────────────────────┘
스택 (Stack)
- EVM의 주 작업 공간
- 모든 연산은 스택에서 이루어짐
- LIFO (Last In First Out) 구조
- 최대 깊이: 1024
- 각 슬롯: 256비트 (32바이트)
메모리 (Memory)
- 바이트 단위로 주소 지정 가능한 선형 배열
- 함수 호출 동안만 존재 (함수 종료 시 소멸)
- 동적으로 확장 가능 (확장할수록 가스 비용 증가)
- 함수 인자, 반환값, 임시 데이터 저장
스토리지 (Storage)
- 컨트랙트별 영구적인 key-value 저장소
- 키: 32바이트, 값: 32바이트
- 블록체인에 영구 기록 → 가장 비쌈
- Solidity의
state variable이 여기에 저장됨
2.2 Node.js와의 비교
Node.js V8 엔진:
힙(Heap): 객체, 배열 (GC가 관리)
스택(Stack): 함수 호출, 지역 변수
외부 접근: 파일, 네트워크, OS API
EVM:
스토리지: 영구 상태 (블록체인)
메모리: 임시 바이트 배열 (실행 중)
스택: 연산 작업 공간 (256비트 슬롯)
외부 접근: 불가능! (완전 격리)
V8은 JIT 컴파일로 최적화된 네이티브 코드 실행
EVM은 바이트코드를 인터프리팅 (훨씬 느림, 하지만 결정론적)
2.3 바이트코드와 옵코드
Solidity 코드는 EVM이 이해하는 바이트코드로 컴파일된다. 각 바이트는 **옵코드(Opcode)**라는 명령어를 나타낸다.
예: 두 숫자를 더하는 간단한 연산
Solidity:
uint256 result = a + b;
EVM 바이트코드: (16진수)
60 05 ← PUSH1 5 (숫자 5를 스택에 푸시)
60 03 ← PUSH1 3 (숫자 3을 스택에 푸시)
01 ← ADD (스택 상위 2개를 꺼내 더한 후 결과를 푸시)
스택 변화:
초기: []
PUSH1 5: [5]
PUSH1 3: [5, 3]
ADD: [8] ← 결과
주요 옵코드 목록:
| 옵코드 | 값 | 설명 | 가스 |
|---|---|---|---|
| STOP | 0x00 | 실행 중단 | 0 |
| ADD | 0x01 | 스택 상위 2개 더하기 | 3 |
| MUL | 0x02 | 곱하기 | 5 |
| SUB | 0x03 | 빼기 | 3 |
| DIV | 0x04 | 나누기 | 5 |
| SLOAD | 0x54 | 스토리지에서 읽기 | 2,100 |
| SSTORE | 0x55 | 스토리지에 쓰기 | 20,000 |
| MLOAD | 0x51 | 메모리에서 읽기 | 3 |
| MSTORE | 0x52 | 메모리에 쓰기 | 3 |
| CALL | 0xf1 | 다른 컨트랙트 호출 | 가변 |
| CREATE | 0xf0 | 새 컨트랙트 배포 | 32,000 |
3. 가스 시스템
3.1 왜 가스가 필요한가?
가스 없는 이더리움의 문제:
// 이런 컨트랙트를 배포한다면?
contract Malicious {
function attack() public {
while(true) {
// 무한 루프!
}
}
}
→ 모든 이더리움 노드가 영원히 이 루프를 실행
→ 네트워크 전체 마비 (DoS 공격)
가스는 두 가지 문제를 동시에 해결한다:
- 중단 문제(Halting Problem) 완화: 가스가 소진되면 실행 중단
- 자원 비용 반영: 더 많은 계산 = 더 많은 가스 = 더 많은 비용
3.2 가스 기본 개념
가스 = 계산 작업량의 단위
gasLimit: 이 TX에 최대 얼마나 쓸 것인가 (사용자 설정)
gasUsed: 실제로 얼마나 썼는가 (실행 후 결정됨)
gasPrice: 가스 1단위당 얼마를 낼 것인가 (wei)
총 수수료 = gasUsed × effectiveGasPrice
예:
단순 ETH 전송: 21,000 gas (고정)
ERC-20 transfer: ~65,000 gas
Uniswap 스왑: ~150,000 gas
컨트랙트 배포: 수십만 ~ 수백만 gas
가스 한도 (gasLimit)가 충분하지 않으면?
gasLimit = 10,000 gas
실제 필요 = 21,000 gas
→ 10,000 gas 소진 시점에 'out of gas' 에러
→ 트랜잭션 revert (모든 상태 변경 롤백)
→ 이미 소비한 10,000 gas 수수료는 환불 안 됨!
→ 나머지 11,000 gas에 대한 수수료는 환불
3.3 EIP-1559: 기본 수수료 + 팁
2021년 London 업그레이드에서 도입된 수수료 개혁:
EIP-1559 이전 (경매 방식):
채굴자가 gasPrice 높은 TX부터 처리
→ 가스비 예측 불가
→ 급한 상황에서 경쟁적 입찰로 수수료 폭등
EIP-1559 이후 (프로토콜 결정):
baseFee: 프로토콜이 자동 결정 (전 블록 가스 사용량 기반)
- 블록이 50% 이상 찼으면 baseFee 상승 (최대 12.5%)
- 블록이 50% 미만이면 baseFee 하락 (최대 12.5%)
tip: 사용자가 설정하는 검증자 인센티브
baseFee는 소각(burn)됨! ETH 공급량 감소 효과
baseFee 조절 메커니즘:
목표 블록 가스: 15,000,000 gas
최대 블록 가스: 30,000,000 gas
이전 블록 가스 사용: 20,000,000 (목표의 133%)
→ baseFee 상승: 현재 baseFee × (1 + 0.125 × (20M-15M)/15M)
= 현재 baseFee × 1.0417 (약 4% 상승)
이전 블록 가스 사용: 10,000,000 (목표의 67%)
→ baseFee 하락: 현재 baseFee × (1 - 0.125 × (15M-10M)/15M)
= 현재 baseFee × 0.9583 (약 4% 하락)
3.4 주요 가스 비용 예시
스토리지 관련 (가장 비쌈):
SSTORE (새 값, 0→비영): 20,000 gas
SSTORE (기존 값 변경): 2,900 gas
SSTORE (값 삭제 0으로): -15,000 gas (환불!)
SLOAD (스토리지 읽기): 2,100 gas (cold) / 100 gas (warm)
메모리 관련:
MLOAD (메모리 읽기): 3 gas
MSTORE (메모리 쓰기): 3 gas
메모리 확장: 확장할수록 quadratic 증가
기본 연산:
ADD, SUB: 3 gas
MUL, DIV: 5 gas
SHA3 (Keccak256): 30 gas + 6 gas/word
트랜잭션 기본:
기본 비용: 21,000 gas
calldata 0 바이트: 4 gas/byte
calldata 비0 바이트: 16 gas/byte
컨트랙트:
CREATE (배포): 32,000 gas + 코드 크기 비용
CALL: 2,600 gas (cold address)
DELEGATECALL: 2,600 gas
4. Solidity에서 EVM까지: 실행 흐름
4.1 컴파일 과정
Solidity 소스코드 (.sol)
│
▼ solc 컴파일러
│
┌─────┴──────────────────────┐
│ EVM 바이트코드 │ ← 블록체인에 저장되는 것
│ ABI (Application Binary │ ← 외부 인터페이스 명세
│ Interface) │
└────────────────────────────┘
│ (배포 트랜잭션)
▼
이더리움 네트워크
│
▼ (함수 호출 트랜잭션)
EVM이 바이트코드 실행
│
▼
상태 변경 / 반환값 / 이벤트
4.2 간단한 Solidity 코드와 EVM 실행 추적
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleCounter {
uint256 public count; // 스토리지 슬롯 0에 저장
function increment() public {
count += 1; // SLOAD(슬롯0) → ADD → SSTORE(슬롯0)
}
function getCount() public view returns (uint256) {
return count; // SLOAD(슬롯0) → RETURN
}
}
increment() 함수 호출 시 EVM 실행:
1. PUSH1 0x00 스택: [0] → 스토리지 슬롯 0 주소
2. SLOAD 스택: [현재count] → 슬롯 0에서 값 읽기 (2,100 gas)
3. PUSH1 0x01 스택: [현재count, 1]
4. ADD 스택: [count+1] → 더하기 (3 gas)
5. PUSH1 0x00 스택: [count+1, 0]
6. SSTORE 스택: [] → 슬롯 0에 저장 (20,000 gas)
7. STOP → 종료
가스 계산:
기본 TX 비용: 21,000
calldata (함수 시그니처): 64 (4바이트 × 16)
SLOAD (cold): 2,100
ADD: 3
SSTORE (새 값): 20,000
기타 옵코드: ~200
────────────────────────
총 약: 43,367 gas
5. calldata, memory, storage 차이
Node.js 개발자에게 친숙한 방식으로 비교:
calldata:
역할: 함수 호출 시 전달되는 입력 데이터 (읽기 전용)
Node.js 비유: req.body (HTTP 요청 본문)
가스: 저렴 (0 바이트=4gas, 비0 바이트=16gas)
예:
function transfer(address to, uint256 amount) external {
// 'to'와 'amount'는 calldata에 있음 (수정 불가)
}
memory:
역할: 함수 실행 중 임시 데이터 저장
Node.js 비유: 함수 내 지역 변수
가스: 중간 (확장할수록 비쌈)
수명: 함수 실행 중에만 존재
예:
function processData(bytes calldata input) external {
bytes memory temp = new bytes(input.length); // memory 할당
// temp는 이 함수가 끝나면 사라짐
}
storage:
역할: 컨트랙트의 영구 상태
Node.js 비유: 데이터베이스 레코드
가스: 매우 비쌈 (읽기 2,100 / 쓰기 20,000)
수명: 영구적 (블록체인에 기록)
예:
contract MyContract {
uint256 public totalSupply; // storage 변수
mapping(address => uint256) public balances; // storage 맵
}
실용적인 가스 최적화 패턴:
// 비효율적: storage를 루프에서 반복 접근
function badSum(uint256[] storage arr) internal view returns (uint256) {
uint256 sum = 0;
for (uint256 i = 0; i < arr.length; i++) {
sum += arr[i]; // 매번 SLOAD (2,100 gas × n번)
}
return sum;
}
// 효율적: storage를 memory로 캐싱
function goodSum(uint256[] storage arr) internal view returns (uint256) {
uint256[] memory localArr = arr; // 한 번만 SLOAD
uint256 sum = 0;
for (uint256 i = 0; i < localArr.length; i++) {
sum += localArr[i]; // MLOAD (3 gas × n번)
}
return sum;
}
6. EVM 실행의 결정론적 특성
이더리움의 가장 중요한 속성 중 하나:
결정론적 실행:
동일한 상태 + 동일한 TX → 항상 동일한 결과
이것이 왜 중요한가:
- 전 세계 수천 개 노드가 같은 TX를 실행
- 모두가 같은 결과를 얻어야 함
- 그래야 블록체인의 상태 합의가 가능
EVM의 격리 이유:
- 타임스탬프: 블록 헤더의 값만 사용 (시스템 시계 X)
- 난수: 없음 (or PREVRANDAO 사용, 블록에서 가져옴)
- 외부 API: 접근 불가
- 파일 시스템: 접근 불가
7. ethers.js로 EVM 상태 조회
const { ethers } = require("ethers");
const provider = new ethers.JsonRpcProvider("http://localhost:8545");
async function inspectEVMState() {
const contractAddress = "0x0000000000000000000000000000000000001000";
// 1. 스토리지 슬롯 직접 읽기 (저수준)
const slot0 = await provider.getStorage(contractAddress, 0);
console.log("스토리지 슬롯 0:", slot0);
// → 0x0000000000000000000000000000000000000000000000000000000000000042
// = 66 (uint256)
// 2. 코드 읽기 (바이트코드)
const code = await provider.getCode(contractAddress);
console.log("바이트코드 크기:", (code.length - 2) / 2, "bytes");
console.log("바이트코드 (앞 20바이트):", code.slice(0, 42));
// 3. 가스 추정
const gasEstimate = await provider.estimateGas({
to: contractAddress,
data: "0xd09de08a", // increment() 함수 시그니처
});
console.log("예상 가스:", gasEstimate.toString());
// 4. eth_call로 상태 변경 없이 실행 (view 함수)
const result = await provider.call({
to: contractAddress,
data: "0x06661abd", // count() getter 시그니처
});
const count = BigInt(result);
console.log("현재 count:", count.toString());
// 5. 트랜잭션 추적 (디버깅용, 일부 노드에서 지원)
// const trace = await provider.send("debug_traceTransaction", [txHash, {}]);
}
inspectEVMState().catch(console.error);
8. 핵심 정리
EVM 아키텍처:
┌────────────────────────────────────────┐
│ 스택: 연산 작업 (빠름, 싸다) │
│ 메모리: 임시 저장 (중간) │
│ 스토리지: 영구 저장 (느림, 비싸다) │
└────────────────────────────────────────┘
가스 시스템:
- 모든 연산에 가스 비용 부과 (무한 루프 방지)
- SSTORE이 가장 비쌈 (20,000 gas)
- EIP-1559: baseFee(소각) + tip(검증자)
최적화 팁:
- storage 읽기를 최소화 → memory에 캐싱
- calldata는 storage보다 훨씬 싸다
- 불필요한 스토리지 변수 삭제 시 가스 환불
다음 챕터에서는 이 EVM 위에서 실행되는 스마트 컨트랙트의 전체 개요와 ABI, 배포 과정을 살펴본다.
스마트 컨트랙트 개요: 블록체인 위의 자동 실행 프로그램
스마트 컨트랙트를 한 문장으로: “조건이 충족되면 자동으로 실행되는, 블록체인 위의 변경 불가능한 프로그램”
Node.js 개발자로서 여러분은 이미 “코드가 비즈니스 로직을 처리한다“는 개념에 익숙하다. 스마트 컨트랙트는 그 코드가 중앙 서버 대신 블록체인 위에서 실행된다는 점이 다르다. 한번 배포하면 누구도 — 심지어 개발자 본인도 — 멈추거나 수정할 수 없다.
1. 스마트 컨트랙트란?
1.1 Nick Szabo의 자판기 비유
1994년, Nick Szabo는 스마트 컨트랙트 개념을 처음 제안하며 자판기에 비유했다:
자판기:
1. 돈을 넣는다 (조건 충족)
2. 버튼을 누른다 (함수 호출)
3. 음료가 나온다 (자동 실행)
4. 거스름돈이 나온다 (결과 반환)
→ 점원(중개자) 없이 규칙대로 자동 동작
→ 조건과 결과가 기계에 내장되어 있음
스마트 컨트랙트:
1. ETH를 전송한다 (조건 충족)
2. 함수를 호출한다 (트랜잭션)
3. 코드가 실행된다 (자동 실행)
4. 토큰 등이 발행/전송된다 (결과 반환)
→ 은행, 변호사, 중개인 없이 코드대로 동작
→ 조건과 결과가 블록체인에 내장되어 있음
1.2 Node.js 백엔드와의 비교
// 전통적인 Node.js 백엔드
app.post('/transfer', authenticate, async (req, res) => {
const { to, amount } = req.body;
// 이 로직을 서버 운영자가 언제든 바꿀 수 있음
// 서버가 다운되면 서비스 중단
// DB 관리자가 잔액을 임의로 수정 가능
await db.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, req.user.id]
);
await db.query(
'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
[amount, to]
);
res.json({ success: true });
});
// 스마트 컨트랙트 (Solidity)
// 배포 후 이 코드는 영원히 고정됨
// 이더리움이 살아있는 한 항상 동작
// 누구도 임의로 잔액 변경 불가
function transfer(address to, uint256 amount) public {
require(balances[msg.sender] >= amount, "잔액 부족");
balances[msg.sender] -= amount;
balances[to] += amount;
emit Transfer(msg.sender, to, amount);
}
2. 불변성: 한번 배포하면 수정 불가
2.1 불변성의 의미
컨트랙트 주소: 0x6B175474E89094C44Da98b954EedeAC495271d0F
(DAI 스테이블코인, 2019년 배포)
이 주소에 있는 코드:
- DAI 팀도 수정 불가
- 이더리움 재단도 수정 불가
- 어떤 정부도 중단 불가
- 코드에 버그가 있으면 영원히 유지됨 (!)
- 코드가 올바르면 영원히 신뢰 가능 (!)
불변성이 강점인 이유:
- 사용자가 코드를 신뢰할 수 있음 (나중에 바뀌지 않음)
- 감사(audit)된 코드가 변경되지 않음
- “코드가 곧 계약(Law)“이 가능
불변성의 위험:
- 버그를 수정할 수 없음
- 보안 취약점이 발견되어도 즉시 패치 불가
- 2016년 The DAO 해킹이 대표적 사례
2.2 업그레이드 가능한 컨트랙트 패턴
불변성의 단점을 보완하기 위해 **프록시 패턴(Proxy Pattern)**이 개발되었다:
프록시 패턴:
사용자 ──▶ Proxy Contract ──▶ Implementation V1
(주소 변하지 않음) (실제 로직, 교체 가능)
(스토리지 유지)
──업그레이드──▶ Implementation V2
(버그 수정된 버전)
핵심:
- 사용자는 항상 Proxy 주소를 사용
- 로직(Implementation)만 교체
- 데이터(Storage)는 Proxy에 유지됨
- 투명 프록시, UUPS, 다이아몬드 패턴 등 다양한 변형
그러나 주의: 업그레이드 가능한 컨트랙트는 “신뢰의 중앙화“를 일부 포기한다. 업그레이드 권한을 가진 주소(보통 멀티시그)가 생기기 때문이다.
3. 컨트랙트의 수명 주기
1. 작성 (Write)
개발자가 Solidity로 비즈니스 로직 작성
┌─────────────────────────┐
│ MyToken.sol │
│ pragma solidity ^0.8; │
│ contract MyToken { │
│ ... │
│ } │
└─────────────────────────┘
2. 컴파일 (Compile)
solc 컴파일러가 바이트코드 + ABI 생성
┌──────────────────┐ ┌──────────────┐
│ 바이트코드 │ │ ABI │
│ 6080604052... │ │ [{ │
│ (EVM 기계어) │ │ "name": │
│ │ │ "transfer"│
│ │ │ ... │
└──────────────────┘ └──────────────┘
3. 테스트 (Test)
로컬 환경에서 단위 테스트 / 통합 테스트
Hardhat, Foundry 등 사용
4. 감사 (Audit) — 선택적이지만 중요
보안 전문가가 코드 검토
Slither, MythX 등 자동화 도구 사용
5. 배포 (Deploy)
바이트코드를 담은 트랜잭션을 전송
새 계약 주소가 생성됨
TX { to: null, data: 바이트코드 }
→ 컨트랙트 주소: 0xAbCd...
6. 상호작용 (Interact)
사용자 또는 다른 컨트랙트가 함수 호출
TX { to: 컨트랙트주소, data: 함수+인자 }
7. (선택) 자기소멸 (Self-Destruct)
SELFDESTRUCT 옵코드로 컨트랙트 파기
잔여 ETH는 지정 주소로 전송
→ Cancun 업그레이드 이후 기능 제한됨
4. ABI: 컨트랙트의 API 명세
4.1 ABI란?
**ABI (Application Binary Interface)**는 컨트랙트의 함수와 이벤트 목록이다. Node.js 개발자에게는 REST API의 OpenAPI/Swagger 명세와 동일한 역할을 한다.
REST API 세계:
Swagger/OpenAPI → API 명세 (엔드포인트, 파라미터, 응답 형식)
클라이언트가 이 명세를 보고 API 호출 방법을 앎
블록체인 세계:
ABI → 컨트랙트 명세 (함수, 이벤트, 파라미터 타입)
클라이언트가 ABI를 보고 컨트랙트 호출 방법을 앎
4.2 ABI 구조
[
{
"type": "function",
"name": "transfer",
"inputs": [
{ "name": "to", "type": "address" },
{ "name": "amount", "type": "uint256" }
],
"outputs": [
{ "name": "", "type": "bool" }
],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "balanceOf",
"inputs": [
{ "name": "account", "type": "address" }
],
"outputs": [
{ "name": "", "type": "uint256" }
],
"stateMutability": "view"
},
{
"type": "event",
"name": "Transfer",
"inputs": [
{ "name": "from", "type": "address", "indexed": true },
{ "name": "to", "type": "address", "indexed": true },
{ "name": "value", "type": "uint256", "indexed": false }
]
},
{
"type": "constructor",
"inputs": [
{ "name": "initialSupply", "type": "uint256" }
],
"stateMutability": "nonpayable"
}
]
4.3 함수 시그니처와 셀렉터
컨트랙트 함수 호출 시 어떤 함수를 호출하는지를 4바이트로 인코딩한다:
함수 시그니처: transfer(address,uint256)
Keccak-256: a9059cbb2ab09eb219583f4a59a5d0623ade346d962bcd4e46b11da047c9049b
앞 4바이트: 0xa9059cbb ← 이것이 함수 셀렉터
TX의 data 필드:
0xa9059cbb ← 함수 셀렉터 (4바이트)
000000000000000000000000742d35cc... ← to 주소 (32바이트 패딩)
0000000000000000000000000000000000000000000000000de0b6b3a7640000 ← amount (1 ETH in wei)
4.4 ethers.js로 ABI 활용
const { ethers } = require("ethers");
const provider = new ethers.JsonRpcProvider("http://localhost:8545");
const wallet = new ethers.Wallet(privateKey, provider);
// ERC-20 ABI (최소 버전)
const ERC20_ABI = [
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address account) view returns (uint256)",
"function transfer(address to, uint256 amount) returns (bool)",
"function approve(address spender, uint256 amount) returns (bool)",
"function allowance(address owner, address spender) view returns (uint256)",
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Approval(address indexed owner, address indexed spender, uint256 value)",
];
// 컨트랙트 인스턴스 생성 (ABI + 주소)
const tokenAddress = "0x0000000000000000000000000000000000001000";
const token = new ethers.Contract(tokenAddress, ERC20_ABI, wallet);
async function interactWithToken() {
// 1. view 함수 호출 (읽기, 가스 없음)
const name = await token.name();
const symbol = await token.symbol();
const decimals = await token.decimals();
const totalSupply = await token.totalSupply();
console.log(`토큰: ${name} (${symbol})`);
console.log(`소수점: ${decimals}`);
console.log(`총 발행량: ${ethers.formatUnits(totalSupply, decimals)}`);
// 2. 잔액 조회
const myBalance = await token.balanceOf(wallet.address);
console.log(`내 잔액: ${ethers.formatUnits(myBalance, decimals)} ${symbol}`);
// 3. 토큰 전송 (쓰기 함수, 가스 필요)
const recipient = "0x0000000000000000000000000000000000002000";
const amount = ethers.parseUnits("100", decimals); // 100 토큰
const tx = await token.transfer(recipient, amount);
console.log("TX 해시:", tx.hash);
const receipt = await tx.wait();
console.log("블록에 포함됨:", receipt.blockNumber);
// 4. 이벤트 로그 파싱
for (const log of receipt.logs) {
try {
const parsed = token.interface.parseLog(log);
if (parsed?.name === "Transfer") {
console.log(`Transfer: ${parsed.args.from} → ${parsed.args.to}`);
console.log(` 금액: ${ethers.formatUnits(parsed.args.value, decimals)}`);
}
} catch {}
}
// 5. 과거 이벤트 조회
const filter = token.filters.Transfer(wallet.address, null);
const events = await token.queryFilter(filter, -1000); // 최근 1000블록
console.log(`내가 보낸 Transfer 이벤트: ${events.length}개`);
}
5. 대표적인 스마트 컨트랙트 유형
5.1 토큰 컨트랙트 (ERC-20, ERC-721)
ERC-20 (대체 가능 토큰):
- 모든 토큰이 동등 (1 USDC == 1 USDC)
- 화폐, 거버넌스 토큰, 유틸리티 토큰
- 예: USDC, DAI, UNI, LINK
- 핵심 함수: transfer, approve, transferFrom, balanceOf
ERC-721 (대체 불가능 토큰, NFT):
- 각 토큰이 고유 (ID가 있음)
- 디지털 아트, 게임 아이템, 도메인
- 예: CryptoPunks, BAYC, ENS
- 핵심 함수: transferFrom, ownerOf, tokenURI
ERC-1155 (멀티 토큰):
- ERC-20 + ERC-721 혼합
- 게임 아이템 (여러 종류, 각 종류마다 여러 개)
- 예: Enjin, OpenSea 컨트랙트
5.2 DEX (탈중앙화 거래소)
AMM (Automated Market Maker) 방식:
전통 거래소: AMM (Uniswap 등):
매수 주문 ──▶ 오더북 유동성 풀 ──▶ 자동 가격 결정
매도 주문 ──▶ 매칭 x × y = k (불변 곱 공식)
예: ETH/USDC 풀
ETH 100개, USDC 200,000개 (1 ETH = 2,000 USDC)
사용자가 1 ETH를 USDC로 교환:
새 ETH = 100 + 1 = 101
새 USDC = 200,000 × 100 / 101 ≈ 198,020
받는 USDC = 200,000 - 198,020 ≈ 1,980 USDC
(슬리피지 때문에 정확히 2,000이 아님)
5.3 대출/차입 프로토콜
Aave / Compound 방식:
공급자: ETH 예치 → aETH 토큰 수령 → 이자 수익
차입자: 담보 예치 → 다른 자산 차입 → 이자 지불
과담보(Over-collateralized):
100 ETH 예치 (담보)
→ 최대 75 ETH 가치의 USDC 차입 가능
청산(Liquidation):
ETH 가격 하락 → 담보 비율 위험 수준
→ 청산인이 담보를 싸게 구매해 부채 상환
→ 자동으로 스마트 컨트랙트가 실행
5.4 DAO (탈중앙화 자율 조직)
전통 조직: DAO:
이사회 의결 토큰 보유자 투표
CEO 결정 거버넌스 컨트랙트가 자동 실행
법적 구조 코드가 규칙
예: Uniswap DAO
UNI 토큰 보유자가 프로토콜 수수료, 업그레이드 등 투표
투표 결과가 스마트 컨트랙트로 자동 실행
(인간의 개입 최소화)
6. 스마트 컨트랙트의 한계와 주의사항
주의사항 1: 오라클 문제 (Oracle Problem)
스마트 컨트랙트는 블록체인 밖의 데이터를 모름
"ETH 현재 가격은?" → 알 수 없음!
해결: Chainlink 같은 오라클 서비스가 외부 데이터 공급
주의사항 2: 가스 비용
모든 계산에 비용 발생
복잡한 로직 = 비싼 가스비 = 사용자 부담
주의사항 3: 보안 취약점
재진입 공격 (Reentrancy): The DAO 해킹의 원인
정수 오버플로우: Solidity 0.8 이후 자동 방어
플래시 론 공격: 순식간에 대량 자본 조달 후 조작
주의사항 4: 업그레이드 불가 (기본)
버그 수정을 위해 새 컨트랙트 배포 필요
사용자가 새 주소로 마이그레이션해야 함
주의사항 5: 공개 코드
모든 코드가 블록체인에 공개됨
비즈니스 로직 비밀 유지 불가
(영지식 증명으로 일부 해결 가능)
7. 다음 파트 미리보기: 직접 Solidity 작성하기
다음 파트(Chapter 11)에서 직접 작성할 내용:
Ch11. Solidity 기초
- 변수 타입, 함수, 제어문
- storage vs memory vs calldata
- 접근 제어 (public, private, internal, external)
- modifier로 권한 제어
Ch12. 토큰 구현
- ERC-20 표준 직접 구현
- OpenZeppelin 라이브러리 활용
- 민팅, 소각, 전송 로직
Ch13. 고급 패턴
- 재진입 공격 방어
- 업그레이드 가능한 컨트랙트 (프록시 패턴)
- 이벤트와 로그 활용
Ch14. 테스트와 배포
- Hardhat으로 단위 테스트
- 로컬 Besu 네트워크에 배포
- 스크립트 자동화
Node.js 개발자로서 여러분이 이미 가진 기술들:
알고 있는 것 → 스마트 컨트랙트에서 대응
REST API 설계 → ABI 설계
DB 스키마 설계 → storage 변수 설계
비즈니스 로직 → Solidity 함수
미들웨어 (auth) → modifier
이벤트 에밋 → emit Event
API 테스트 → Hardhat 테스트
환경 변수 관리 → .env + hardhat.config
배포 스크립트 → Hardhat deploy scripts
8. 핵심 정리
- 스마트 컨트랙트 = 블록체인 위의 자동 실행 프로그램: 중개자 없이 코드가 계약을 집행
- 불변성: 배포 후 수정 불가 → 보안과 신뢰의 근거이자 동시에 위험 요소
- 수명 주기: 작성 → 컴파일 → 테스트 → 감사 → 배포 → 상호작용
- ABI: 컨트랙트의 OpenAPI 명세 — 클라이언트가 함수를 어떻게 호출하는지 정의
- 주요 유형: ERC-20 토큰, DEX(AMM), 대출 프로토콜, DAO
- 한계: 오라클 문제, 가스 비용, 보안 취약점, 공개 코드
이제 블록체인의 개념적 기초(Chapter 9)와 이더리움 플랫폼(Chapter 10)을 완주했다. 다음 파트에서는 실제 Solidity 코드를 한 줄씩 작성하며 스마트 컨트랙트 개발자로 발전한다.
5장: 제네릭과 트레이트
추상화의 두 축
Rust에서 코드 재사용과 추상화는 두 개념을 중심으로 이루어집니다:
- 제네릭(Generics): “어떤 타입이든 동작하는 코드“를 작성
- 트레이트(Traits): “이 동작을 할 수 있는 타입“을 정의
TypeScript에서는 제네릭은 비슷하게 존재하지만, 트레이트는 interface와 유사하면서도 중요한 차이가 있습니다.
제네릭 — TypeScript와 유사
// TypeScript 제네릭
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
first([1, 2, 3]); // number
first(["a", "b"]); // string
// Rust 제네릭
fn first<T>(slice: &[T]) -> Option<&T> {
slice.first()
}
first(&[1, 2, 3]); // Option<&i32>
first(&["a", "b"]); // Option<&&str>
트레이트 — interface보다 강력
// TypeScript interface
interface Hashable {
computeHash(): string;
}
class Block implements Hashable {
constructor(private readonly index: number, private readonly data: string) {}
computeHash(): string {
return `${this.index}:${this.data}`;
}
}
#![allow(unused)]
fn main() {
// Rust trait
trait Hashable {
fn compute_hash(&self) -> String;
}
struct Block {
index: u64,
data: String,
}
impl Hashable for Block {
fn compute_hash(&self) -> String {
format!("{}:{}", self.index, self.data)
}
}
}
핵심 차이: Rust 트레이트는 기존 타입에도 구현할 수 있습니다 (TypeScript interface는 불가). 예를 들어 String에 내가 만든 트레이트를 구현할 수 있습니다 (단, 고아 규칙 제한 있음).
이 장의 구성
- 제네릭 (5.1): 함수, 구조체, 열거형의 제네릭, 모노모피제이션
- 트레이트 (5.2): 정의, 구현, 바운드, 표준 트레이트, derive
- 수명 (5.3): 수명 어노테이션, 생략 규칙
블록체인에서의 활용
// 제네릭 + 트레이트로 범용적인 블록체인 저장소 구현
trait BlockStore {
type Block;
type Error;
fn get(&self, height: u64) -> Result<Option<Self::Block>, Self::Error>;
fn insert(&mut self, block: Self::Block) -> Result<(), Self::Error>;
fn height(&self) -> u64;
}
// 인메모리 구현
struct InMemoryStore {
blocks: Vec<Block>,
}
// 데이터베이스 구현
struct RocksDbStore {
db: rocksdb::DB,
}
// 동일한 BlockStore 인터페이스로 두 구현체 모두 사용 가능
fn process_new_block<S: BlockStore<Block = Block>>(
store: &mut S,
block: Block,
) -> Result<(), S::Error> {
store.insert(block)
}
다음 챕터에서 제네릭부터 자세히 배웁니다.
5.1 제네릭
제네릭이 필요한 이유
같은 로직인데 타입만 다른 코드를 반복 작성하는 것은 나쁜 설계입니다:
#![allow(unused)]
fn main() {
// 반복 코드 — 나쁜 패턴
fn largest_i32(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn largest_f64(list: &[f64]) -> &f64 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
}
제네릭으로 하나로 합칩니다:
// 제네릭 함수 — 좋은 패턴
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let numbers = vec![34, 50, 25, 100, 65];
println!("Largest number: {}", largest(&numbers));
let chars = vec!['y', 'm', 'a', 'q'];
println!("Largest char: {}", largest(&chars));
}
T: PartialOrd는 “T는 비교 가능해야 한다“는 트레이트 바운드입니다. 5.2에서 자세히 다룹니다.
함수의 제네릭
// 단일 타입 매개변수
fn identity<T>(value: T) -> T {
value
}
// 여러 타입 매개변수
fn zip_first<T, U>(a: Vec<T>, b: Vec<U>) -> Vec<(T, U)> {
a.into_iter().zip(b.into_iter()).collect()
}
// 참조와 제네릭
fn print_value<T: std::fmt::Display>(value: &T) {
println!("{}", value);
}
fn main() {
let x = identity(42); // T = i32
let s = identity("hello"); // T = &str
let pairs = zip_first(vec![1, 2, 3], vec!["a", "b", "c"]);
println!("{:?}", pairs); // [(1, "a"), (2, "b"), (3, "c")]
print_value(&42);
print_value(&"hello");
}
TypeScript와 비교:
// TypeScript 제네릭 함수
function identity<T>(value: T): T {
return value;
}
function zipFirst<T, U>(a: T[], b: U[]): [T, U][] {
return a.map((item, i) => [item, b[i]]);
}
문법이 매우 유사합니다. 핵심 차이는 Rust에서 제네릭을 사용할 때 타입이 어떤 동작을 지원하는지 트레이트 바운드로 명시해야 한다는 점입니다.
구조체의 제네릭
// 단일 타입 매개변수
#[derive(Debug)]
struct Wrapper<T> {
value: T,
}
impl<T> Wrapper<T> {
fn new(value: T) -> Self {
Wrapper { value }
}
fn get(&self) -> &T {
&self.value
}
fn into_inner(self) -> T {
self.value
}
}
// 특정 타입에 대한 추가 메서드
impl Wrapper<String> {
fn uppercase(&self) -> String {
self.value.to_uppercase()
}
}
fn main() {
let w1 = Wrapper::new(42);
let w2 = Wrapper::new("hello");
let w3 = Wrapper::new(String::from("world"));
println!("{}", w1.get()); // 42
println!("{}", w2.get()); // hello
println!("{}", w3.uppercase()); // WORLD (String 전용 메서드)
}
여러 타입 매개변수를 가진 구조체
#[derive(Debug)]
struct KeyValue<K, V> {
key: K,
value: V,
}
impl<K: std::fmt::Display, V: std::fmt::Display> KeyValue<K, V> {
fn print(&self) {
println!("{}: {}", self.key, self.value);
}
}
fn main() {
let kv1 = KeyValue { key: "height", value: 12345u64 };
let kv2 = KeyValue { key: 1u32, value: "genesis" };
kv1.print(); // height: 12345
kv2.print(); // 1: genesis
}
블록체인에서의 제네릭 구조체
/// 제네릭 트랜잭션 — 다양한 페이로드를 담을 수 있음
#[derive(Debug, Clone)]
struct Transaction<T> {
id: String,
from: String,
to: String,
payload: T, // 트랜잭션 데이터 타입이 유연
timestamp: u64,
}
impl<T: Clone> Transaction<T> {
fn new(from: String, to: String, payload: T) -> Self {
Transaction {
id: generate_id(),
from,
to,
payload,
timestamp: current_timestamp(),
}
}
}
// 구체적인 페이로드 타입들
#[derive(Debug, Clone)]
struct TokenTransfer {
token: String,
amount: u64,
}
#[derive(Debug, Clone)]
struct ContractCall {
contract: String,
method: String,
args: Vec<String>,
}
fn main() {
let transfer_tx: Transaction<TokenTransfer> = Transaction::new(
"Alice".to_string(),
"Bob".to_string(),
TokenTransfer { token: "USDC".to_string(), amount: 1000 },
);
let contract_tx: Transaction<ContractCall> = Transaction::new(
"Alice".to_string(),
"0xContract".to_string(),
ContractCall {
contract: "0xabcd".to_string(),
method: "transfer".to_string(),
args: vec!["Bob".to_string(), "1000".to_string()],
},
);
println!("{:?}", transfer_tx);
println!("{:?}", contract_tx);
}
fn generate_id() -> String { uuid::Uuid::new_v4().to_string() }
fn current_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
열거형의 제네릭
이미 Option<T>와 Result<T, E>에서 봤습니다. 직접 만들어봅시다:
// 이진 트리 (블록체인 Merkle Tree의 기초)
#[derive(Debug)]
enum Tree<T> {
Leaf(T),
Node {
value: T,
left: Box<Tree<T>>,
right: Box<Tree<T>>,
},
}
impl<T: std::fmt::Display> Tree<T> {
fn depth(&self) -> usize {
match self {
Tree::Leaf(_) => 0,
Tree::Node { left, right, .. } => {
1 + left.depth().max(right.depth())
}
}
}
fn print_inorder(&self) {
match self {
Tree::Leaf(v) => print!("{} ", v),
Tree::Node { value, left, right } => {
left.print_inorder();
print!("{} ", value);
right.print_inorder();
}
}
}
}
fn main() {
let tree: Tree<i32> = Tree::Node {
value: 4,
left: Box::new(Tree::Node {
value: 2,
left: Box::new(Tree::Leaf(1)),
right: Box::new(Tree::Leaf(3)),
}),
right: Box::new(Tree::Leaf(5)),
};
println!("Depth: {}", tree.depth()); // 2
tree.print_inorder(); // 1 2 3 4 5
println!();
}
모노모피제이션: 런타임 비용 없음
Rust의 제네릭은 **모노모피제이션(monomorphization)**으로 구현됩니다. 컴파일 시 각 구체 타입에 대한 특화 버전이 생성됩니다.
fn identity<T>(x: T) -> T { x }
// 컴파일러가 이 두 호출을 감지하고:
identity(42); // T = i32
identity(3.14); // T = f64
// 이렇게 두 개의 함수를 생성:
// fn identity_i32(x: i32) -> i32 { x }
// fn identity_f64(x: f64) -> f64 { x }
결과: 런타임에 타입 체크가 없습니다. C++의 템플릿과 동일한 방식으로, 제네릭 코드가 구체 타입과 동일한 성능을 냅니다.
TypeScript와 비교:
// TypeScript/JavaScript: 런타임에 타입이 동적으로 처리됨
// 제네릭은 컴파일 타임만의 개념, 런타임에는 지워짐 (type erasure)
function identity<T>(x: T): T { return x; }
// 컴파일 후: function identity(x) { return x; }
트레이드오프:
- 장점: 런타임 오버헤드 없음, C 수준 성능
- 단점: 컴파일 시간이 길어짐, 바이너리 크기 증가
타입 매개변수 기본값 (Rust 1.0+)
// HashMap은 기본 해셔를 가짐
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::BuildHasherDefault;
fn main() {
let map: HashMap<String, u64> = HashMap::new();
println!("기본 해셔 HashMap 길이: {}", map.len());
// 커스텀 해셔 사용
let custom_map: HashMap<String, u64, BuildHasherDefault<DefaultHasher>> =
HashMap::with_hasher(BuildHasherDefault::default());
println!("커스텀 해셔 HashMap 길이: {}", custom_map.len());
}
제네릭 타입 추론
Rust의 타입 추론이 강력하기 때문에 대부분의 경우 타입을 명시하지 않아도 됩니다:
fn main() {
// 타입 추론으로 명시 불필요
let v = vec![1, 2, 3]; // Vec<i32>
let first = v.first(); // Option<&i32>
let doubled: Vec<_> = v.iter().map(|x| x * 2).collect(); // Vec<i32>
// 명확하지 않을 때는 타입 힌트
let parsed = "42".parse::<u64>().unwrap(); // turbofish 문법
let parsed: u64 = "42".parse().unwrap(); // 또는 타입 어노테이션
// 컬렉션 타입이 모호할 때
let chars: Vec<char> = "hello".chars().collect();
let set: std::collections::HashSet<_> = v.iter().collect();
}
TypeScript와 Rust 제네릭 비교 정리
| 기능 | TypeScript | Rust |
|---|---|---|
| 기본 문법 | <T> | <T> |
| 여러 타입 | <T, U> | <T, U> |
| 타입 제약 | <T extends Hashable> | <T: Hashable> (트레이트 바운드) |
| 여러 제약 | <T extends A & B> | <T: A + B> |
| 기본값 | <T = string> | <T = DefaultType> (일부 지원) |
| 런타임 동작 | 타입 소거 (erasure) | 모노모피제이션 |
| 성능 | 단일 함수로 동작 | 타입별 특화 함수 생성 |
요약
- 제네릭으로 타입에 무관한 범용 코드 작성
- 함수, 구조체, 열거형,
impl블록 모두에 제네릭 사용 가능 - 모노모피제이션: 컴파일 시 구체 타입별 특화 버전 생성 → 런타임 비용 없음
- 트레이트 바운드(
T: Trait)로 제네릭 타입에 요구 동작 명시 - 타입 추론으로 대부분 타입 명시 불필요
다음 챕터에서 트레이트(Traits)를 자세히 배웁니다.
5.2 트레이트 (Traits)
트레이트란?
트레이트는 타입이 구현해야 하는 동작(메서드)의 집합을 정의합니다. TypeScript의 interface와 유사하지만 더 강력합니다.
#![allow(unused)]
fn main() {
// 트레이트 정의
trait Hashable {
// 메서드 시그니처 (구현 필수)
fn compute_hash(&self) -> String;
// 기본 구현 (선택적으로 오버라이드)
fn short_hash(&self) -> String {
let full = self.compute_hash();
full[..8].to_string() // 앞 8자리
}
}
}
TypeScript 인터페이스와 비교:
// TypeScript interface
interface Hashable {
computeHash(): string;
// 기본 구현은 interface에 없음 (abstract class에서만)
// 트레이트의 기본 구현은 Rust만의 기능
}
트레이트 구현
struct Block {
index: u64,
data: String,
previous_hash: String,
nonce: u64,
}
// Block에 Hashable 트레이트 구현
impl Hashable for Block {
fn compute_hash(&self) -> String {
// 실제로는 SHA-256 사용
format!("{:x}",
self.index as u64 * 1000
+ self.data.len() as u64
+ self.nonce
)
}
// short_hash()는 기본 구현 사용
}
struct Transaction {
from: String,
to: String,
amount: u64,
}
impl Hashable for Transaction {
fn compute_hash(&self) -> String {
format!("{}{}{}",
self.from.len() + self.to.len(),
self.amount,
"tx"
)
}
// 트랜잭션은 short_hash를 다르게 구현
fn short_hash(&self) -> String {
format!("TX:{}", &self.compute_hash()[..4])
}
}
fn main() {
let block = Block {
index: 1,
data: String::from("genesis"),
previous_hash: String::from("0000"),
nonce: 42,
};
let tx = Transaction {
from: String::from("Alice"),
to: String::from("Bob"),
amount: 1000,
};
println!("Block hash: {}", block.compute_hash());
println!("Block short: {}", block.short_hash()); // 기본 구현
println!("TX hash: {}", tx.compute_hash());
println!("TX short: {}", tx.short_hash()); // 오버라이드된 구현
}
트레이트 바운드
제네릭 함수에서 타입이 특정 트레이트를 구현해야 한다고 요구할 때:
// 방법 1: 인라인 트레이트 바운드
fn print_hash<T: Hashable>(item: &T) {
println!("Hash: {}", item.compute_hash());
}
// 방법 2: where 절 (더 읽기 좋음, 복잡할 때 권장)
fn compare_hashes<T>(a: &T, b: &T) -> bool
where
T: Hashable + std::fmt::Debug,
{
println!("Comparing {:?} and {:?}", a, b);
a.compute_hash() == b.compute_hash()
}
// 여러 트레이트 바운드
fn process<T: Hashable + Clone + std::fmt::Display>(item: T) {
let cloned = item.clone();
println!("Processing: {}", item);
println!("Hash: {}", cloned.compute_hash());
}
트레이트 객체 (dyn Trait)
컴파일 타임에 타입을 모를 때, 런타임에 트레이트를 통해 동적으로 디스패치:
// 정적 디스패치 (제네릭) — 컴파일 타임에 타입 결정, 더 빠름
fn hash_static<T: Hashable>(item: &T) -> String {
item.compute_hash()
}
// 동적 디스패치 (트레이트 객체) — 런타임에 타입 결정, 유연함
fn hash_dynamic(item: &dyn Hashable) -> String {
item.compute_hash()
}
fn main() {
let block = Block {
index: 1,
data: String::from("block data"),
previous_hash: String::from("0000"),
nonce: 7,
};
let tx = Transaction {
from: String::from("Alice"),
to: String::from("Bob"),
amount: 1000,
};
// 정적 디스패치 — 각 호출에서 구체 타입을 앎
hash_static(&block);
hash_static(&tx);
// 동적 디스패치 — 런타임에 결정
let items: Vec<Box<dyn Hashable>> = vec![
Box::new(Block { index: 0, data: String::from("g"), previous_hash: String::from("0"), nonce: 0 }),
Box::new(Transaction { from: String::from("A"), to: String::from("B"), amount: 100 }),
];
for item in &items {
println!("{}", item.compute_hash());
}
}
TypeScript에서는 항상 동적 디스패치(런타임 다형성):
// TypeScript: 항상 런타임 타입 기반
function hashItem(item: Hashable): string {
return item.computeHash(); // 런타임에 어떤 메서드인지 결정
}
표준 라이브러리의 주요 트레이트
Display와 Debug
use std::fmt;
struct Block {
index: u64,
hash: String,
}
// Debug: {:?} 포맷 — 개발자용, derive로 자동 구현 가능
impl fmt::Debug for Block {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Block")
.field("index", &self.index)
.field("hash", &self.hash)
.finish()
}
}
// Display: {} 포맷 — 사용자용, 직접 구현 필요
impl fmt::Display for Block {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Block #{} [{}]", self.index, &self.hash[..6])
}
}
fn main() {
let block = Block { index: 0, hash: String::from("abcdef1234") };
println!("{:?}", block); // Debug
println!("{}", block); // Display
}
Clone과 Copy
// Clone: 명시적 깊은 복사 (.clone() 호출)
#[derive(Clone)]
struct Block {
index: u64,
hash: String, // String은 Clone이지만 Copy가 아님
}
// Copy: 암묵적 복사 (스택에만 있는 간단한 타입)
// Copy는 Clone을 내포함
#[derive(Clone, Copy)]
struct Point {
x: f64, // f64는 Copy
y: f64,
}
fn main() {
let b1 = Block { index: 0, hash: String::from("abc") };
let b2 = b1.clone(); // 명시적 복사
println!("{}", b1.index); // OK, b1도 유효
let p1 = Point { x: 1.0, y: 2.0 };
let p2 = p1; // 암묵적 Copy
println!("{}", p1.x); // OK, p1도 유효 (Copy 타입이므로)
}
PartialEq, Eq, PartialOrd, Ord
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
struct BlockHeight(u64);
fn main() {
let h1 = BlockHeight(100);
let h2 = BlockHeight(200);
let h3 = BlockHeight(100);
// PartialEq: == 연산자
println!("{}", h1 == h3); // true
println!("{}", h1 != h2); // true
// PartialOrd: <, >, <=, >= 연산자
println!("{}", h1 < h2); // true
// Ord: 전순서 비교 (sort 등에 필요)
let mut heights = vec![BlockHeight(300), BlockHeight(100), BlockHeight(200)];
heights.sort();
println!("{:?}", heights); // [BlockHeight(100), BlockHeight(200), BlockHeight(300)]
let max = heights.iter().max().unwrap();
println!("Max: {:?}", max);
}
Default
#[derive(Debug, Default)]
struct BlockConfig {
difficulty: usize, // 기본값: 0
max_transactions: u32, // 기본값: 0
mining_reward: u64, // 기본값: 0
network: String, // 기본값: ""
}
// 커스텀 기본값
impl Default for BlockConfig {
fn default() -> Self {
BlockConfig {
difficulty: 4,
max_transactions: 1000,
mining_reward: 625_000_000, // 6.25 BTC in satoshi
network: String::from("mainnet"),
}
}
}
fn main() {
let config = BlockConfig::default();
println!("{:?}", config);
// 일부만 변경
let custom = BlockConfig {
difficulty: 6,
..BlockConfig::default() // 나머지는 기본값
};
println!("{:?}", custom);
// unwrap_or_default()와 함께
let maybe_config: Option<BlockConfig> = None;
let config2 = maybe_config.unwrap_or_default();
}
Iterator 트레이트 (6.3장에서 자세히)
struct CountingIterator {
current: u64,
max: u64,
}
impl CountingIterator {
fn new(max: u64) -> Self {
CountingIterator { current: 0, max }
}
}
impl Iterator for CountingIterator {
type Item = u64; // 연관 타입
fn next(&mut self) -> Option<Self::Item> {
if self.current < self.max {
self.current += 1;
Some(self.current)
} else {
None
}
}
}
fn main() {
let counter = CountingIterator::new(5);
let sum: u64 = counter.sum();
println!("Sum 1..5 = {}", sum); // 15
// Iterator를 구현하면 map, filter, collect 등 모두 자동으로 사용 가능
let evens: Vec<u64> = CountingIterator::new(10)
.filter(|n| n % 2 == 0)
.collect();
println!("{:?}", evens); // [2, 4, 6, 8, 10]
}
derive 매크로
#[derive(...)]는 표준 트레이트의 기계적인 구현을 자동으로 생성합니다:
#[derive(
Debug, // {:?} 출력
Clone, // .clone() 메서드
PartialEq, // == 연산자
Eq, // 완전 동등 (Hash 구현에 필요)
Hash, // HashMap의 키로 사용
Default, // ::default() 생성자
serde::Serialize, // serde 크레이트
serde::Deserialize, // serde 크레이트
)]
struct TransactionId {
value: String,
}
derive 가능한 표준 트레이트:
| 트레이트 | 기능 |
|---|---|
Debug | {:?} 포맷 |
Clone | .clone() 메서드 |
Copy | 암묵적 복사 (Clone도 필요) |
PartialEq | ==, != 연산자 |
Eq | 완전 동등 (PartialEq 필요) |
PartialOrd | <, >, <=, >= (PartialEq 필요) |
Ord | 전순서 (Eq, PartialOrd 필요) |
Hash | HashMap/HashSet 키 (Eq 필요) |
Default | ::default() |
트레이트의 고아 규칙 (Orphan Rule)
트레이트를 구현할 수 있는 조건:
- 내가 만든 타입에 외부 트레이트 구현 가능
- 외부 타입에 내가 만든 트레이트 구현 가능
- 외부 타입에 외부 트레이트 구현 불가능 (고아 규칙 위반)
use std::fmt;
// OK: 내 타입(Block)에 외부 트레이트(Display) 구현
impl fmt::Display for Block {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Block #{}", self.index)
}
}
// OK: 외부 타입(Vec<Block>)에 내 트레이트(Hashable) 구현
impl Hashable for Vec<Block> {
fn compute_hash(&self) -> String {
self.iter()
.map(|block| block.compute_hash())
.collect::<Vec<_>>()
.join("")
}
}
// 에러: 외부 타입(String)에 외부 트레이트(Display) 구현 불가
// impl fmt::Display for String {
// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// write!(f, "{self}")
// }
// }
// error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
TypeScript interface와 Rust trait 상세 비교
// TypeScript
interface Serializable {
serialize(): string;
deserialize(data: string): void; // 메서드 반환 타입에 void
}
// TypeScript는 클래스가 여러 인터페이스를 구현할 수 있음
class Block implements Serializable, Hashable {
serialize(): string { return JSON.stringify(this); }
deserialize(data: string): void { Object.assign(this, JSON.parse(data)); }
computeHash(): string { return this.serialize(); }
}
// TypeScript는 인터페이스 확장 가능
interface ExtendedHashable extends Hashable {
verifyHash(): boolean;
}
// Rust
trait Serializable {
fn serialize(&self) -> String;
// Rust trait의 기본 구현
fn serialize_pretty(&self) -> String {
format!("```\n{}\n```", self.serialize())
}
}
// 트레이트 확장 (supertrait)
trait ExtendedHashable: Hashable {
fn verify_hash(&self) -> bool;
}
// Block에 여러 트레이트 구현
impl Serializable for Block {
fn serialize(&self) -> String {
serde_json::to_string(self).unwrap_or_default()
}
}
impl ExtendedHashable for Block {
fn verify_hash(&self) -> bool {
self.hash == self.compute_hash()
}
}
// Hashable도 별도로 구현해야 함 (ExtendedHashable의 supertrait이므로)
impl Hashable for Block {
fn compute_hash(&self) -> String {
format!("{}:{}:{}", self.index, self.previous_hash, self.nonce)
}
}
핵심 차이:
| TypeScript interface | Rust trait |
|---|---|
| 기본 구현 없음 (abstract class에서는 가능) | 기본 구현 가능 |
| 기존 클래스에 나중에 인터페이스 추가 불가 | 기존 타입에 트레이트 구현 가능 |
| 런타임 타입 체크 | 컴파일 타임 체크 |
| 항상 동적 디스패치 | 정적(제네릭) 또는 동적(dyn) 선택 |
연관 타입 (Associated Types)
트레이트에서 출력 타입을 정의하는 또 다른 방법:
trait BlockStore {
type Block; // 연관 타입
type Error;
fn get(&self, height: u64) -> Result<Option<Self::Block>, Self::Error>;
fn height(&self) -> u64;
}
struct InMemoryStore {
blocks: Vec<Block>,
}
impl BlockStore for InMemoryStore {
type Block = Block;
type Error = String;
fn get(&self, height: u64) -> Result<Option<Block>, String> {
Ok(self.blocks.get(height as usize).cloned())
}
fn height(&self) -> u64 {
self.blocks.len() as u64
}
}
요약
- 트레이트: 타입이 구현해야 하는 동작의 집합 정의
impl Trait for Type: 특정 타입에 트레이트 구현- 기본 구현: 트레이트에서 제공, 오버라이드 가능
- 트레이트 바운드:
<T: Trait>또는where T: Trait - 동적 디스패치:
dyn Trait(런타임 결정, 유연함) - 표준 트레이트:
Debug,Clone,Copy,Display,PartialEq,Default등 #[derive(...)]: 표준 트레이트 자동 구현- 고아 규칙: 내 타입 또는 내 트레이트 중 하나는 현재 크레이트에 있어야 함
다음 챕터에서 수명(lifetime) 어노테이션을 배웁니다.
5.3 수명 (Lifetimes)
수명 어노테이션이 필요한 이유
수명(lifetime)은 Rust에서 가장 어렵고 생소한 개념입니다. 하지만 실제로 하는 일은 단순합니다: 참조가 얼마나 오래 유효한지 컴파일러에게 알려주는 것입니다.
문제: 댕글링 참조
fn main() {
let r;
{
let x = 5;
r = &x; // x의 참조를 r에 저장
} // x가 여기서 drop됨
println!("{}", r); // 에러! r이 가리키는 x는 이미 사라짐
}
컴파일러 에러:
error[E0597]: `x` does not live long enough
--> src/main.rs:5:13
|
5 | r = &x;
| ^^ borrowed value does not live long enough
6 | }
| - `x` dropped here while still borrowed
7 | println!("{}", r);
| - borrow later used here
컴파일러가 각 변수의 수명을 추적해서 참조가 원본보다 오래 살 수 없도록 합니다.
함수에서 수명 어노테이션
두 참조를 받아 하나를 반환하는 함수에서 수명이 필요합니다:
#![allow(unused)]
fn main() {
// 컴파일 에러 — 반환하는 참조의 수명을 알 수 없음
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
// error[E0106]: missing lifetime specifier
// help: this function's return type contains a borrowed value, but the
// signature does not say whether it is borrowed from `x` or `y`
}
컴파일러는 반환되는 &str이 x의 수명인지 y의 수명인지 알 수 없습니다. 수명 어노테이션으로 알려줍니다:
#![allow(unused)]
fn main() {
// 수명 어노테이션 추가
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
}
'a는 수명 매개변수입니다. 이 시그니처는 이렇게 읽습니다:
“x와 y가 모두 수명 ’a를 가질 때, 반환값도 수명 ’a를 가진다.”
실제로는 “x와 y 중 더 짧은 수명“을 의미합니다.
fn main() {
let string1 = String::from("long string");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
println!("Longest: {}", result); // OK
}
// println!("{}", result); // 에러! string2가 이미 drop됨
// result의 수명이 string2에 제한됨
}
수명 어노테이션 문법
// 참조 타입에 수명 어노테이션
&i32 // 수명 없는 참조 (컴파일러가 추론)
&'a i32 // 수명 'a를 가진 참조
&'a mut i32 // 수명 'a를 가진 가변 참조
// 함수
fn function<'a>(x: &'a str) -> &'a str { x }
// 여러 수명
fn function2<'a, 'b>(x: &'a str, y: &'b str) -> &'a str { x }
// 'static: 프로그램 전체 기간
let s: &'static str = "I live forever"; // 문자열 리터럴
구조체의 수명
구조체가 참조를 필드로 가질 때 수명 어노테이션이 필요합니다:
// 구조체가 참조를 소유하지 않고 빌림
struct BlockRef<'a> {
// &str이 아닌 &'a str — 원본 데이터의 수명에 묶임
hash: &'a str,
data: &'a str,
}
impl<'a> BlockRef<'a> {
fn new(hash: &'a str, data: &'a str) -> Self {
BlockRef { hash, data }
}
fn display(&self) -> String {
format!("Block[{}]: {}", &self.hash[..6], self.data)
}
}
fn main() {
let hash = String::from("abcdef1234567890");
let data = String::from("Genesis Block");
let block_ref = BlockRef::new(&hash, &data);
println!("{}", block_ref.display());
// block_ref는 hash, data보다 오래 살 수 없음
// hash나 data가 여기서 drop되면 block_ref도 무효
}
실용적인 조언: 구조체 필드로 참조 대신 String, Vec<T> 등 소유된 타입을 사용하면 수명 어노테이션이 필요 없습니다. 성능이 매우 중요한 경우가 아니면 소유된 타입을 선호합니다:
#![allow(unused)]
fn main() {
// 수명 어노테이션 없음 — 소유된 데이터
struct Block {
hash: String, // String 소유 (힙에 할당)
data: String,
}
}
수명 생략 규칙 (Lifetime Elision Rules)
자주 쓰이는 패턴에서 컴파일러가 수명을 자동으로 추론합니다. 수명을 명시하지 않아도 되는 경우:
규칙 1: 각 참조 매개변수는 고유한 수명을 가짐
fn foo(x: &str) -> &str { x }
// 컴파일러가 이렇게 처리:
fn foo<'a>(x: &'a str) -> &'a str { x }
규칙 2: 하나의 참조 입력만 있으면, 반환 참조는 그 수명을 가짐
fn first_word(s: &str) -> &str {
// 수명 명시 불필요
let bytes = s.as_bytes();
for (i, &byte) in bytes.iter().enumerate() {
if byte == b' ' {
return &s[..i];
}
}
s
}
// 컴파일러가 이렇게 처리:
fn first_word<'a>(s: &'a str) -> &'a str {
for (i, byte) in s.as_bytes().iter().enumerate() {
if *byte == b' ' {
return &s[..i];
}
}
s
}
규칙 3: 메서드에서 &self 또는 &mut self가 있으면, 반환 참조는 self의 수명
impl Block {
fn get_data(&self) -> &str {
// 수명 명시 불필요 — self의 수명으로 자동 처리
&self.data
}
}
// 컴파일러가 이렇게 처리:
// fn get_data<'a>(&'a self) -> &'a str { &self.data }
수명이 필요한 실제 상황
상황 1: 두 참조 중 하나를 반환
#![allow(unused)]
fn main() {
// 수명 어노테이션 필요
fn longest_prefix<'a>(s: &'a str, prefix: &'a str) -> &'a str {
if s.starts_with(prefix) { prefix } else { s }
}
}
상황 2: 구조체가 참조를 포함할 때
struct Parser<'a> {
input: &'a str,
position: usize,
}
impl<'a> Parser<'a> {
fn new(input: &'a str) -> Self {
Parser { input, position: 0 }
}
fn peek(&self) -> Option<char> {
self.input[self.position..].chars().next()
}
fn current_slice(&self) -> &'a str {
&self.input[self.position..]
}
}
fn main() {
let tx_data = String::from("FROM:Alice TO:Bob AMOUNT:1000");
let mut parser = Parser::new(&tx_data);
println!("{:?}", parser.peek()); // Some('F')
println!("{}", parser.current_slice());
}
상황 3: 입력과 출력 수명이 다를 때
#![allow(unused)]
fn main() {
fn get_prefix<'a, 'b>(s: &'a str, _separator: &'b str) -> &'a str {
// _separator의 수명은 반환값과 무관
// 반환값의 수명은 s에만 묶임
s.split_once(':').map(|(prefix, _)| prefix).unwrap_or(s)
}
}
’static 수명
'static은 프로그램 전체 기간 동안 유효한 수명입니다:
// 문자열 리터럴은 'static
let s: &'static str = "Hello, World!";
// static 상수
static MAX_BLOCK_SIZE: usize = 1_000_000;
// 'static 바운드 — 소유된 타입이거나 'static 참조여야 함
fn spawn_thread<F: Fn() + Send + 'static>(f: F) {
std::thread::spawn(f);
}
// 오류 타입에서 흔히 보임
fn may_fail() -> Result<(), Box<dyn std::error::Error + 'static>> {
Ok(())
}
Node.js 개발자를 위한 실용 조언
수명을 처음 배울 때 느끼는 답답함의 대부분은:
- 구조체 필드에 참조를 쓰려다 발생
- 여러 함수 걸쳐 참조를 전달하려다 발생
가장 실용적인 해결책: 소유된 데이터를 사용하세요.
#![allow(unused)]
fn main() {
// 수명 문제를 만날 때의 선택지:
// 1. 소유된 타입 사용 (가장 간단)
struct Config {
network: String, // &str 대신 String
rpc_url: String,
}
// 2. Arc<str> 사용 (공유 소유권)
use std::sync::Arc;
struct Config2 {
network: Arc<str>,
rpc_url: Arc<str>,
}
// 3. 수명 어노테이션 (성능이 중요할 때)
struct Config3<'a> {
network: &'a str,
rpc_url: &'a str,
}
}
Node.js에서는 객체를 참조로 자유롭게 공유합니다. Rust에서는 소유권이 있으므로 데이터를 복제(clone())하거나, 참조 카운팅(Rc<T>, Arc<T>)을 사용하거나, 수명을 관리하는 세 가지 선택지가 있습니다.
수명 관련 자주 보는 에러와 해결법
error[E0106]: missing lifetime specifier
→ 함수가 참조를 반환하는데 수명이 불명확. 입력 참조 중 어느 것에서 나오는지 명시하거나, 소유된 타입(String) 반환 고려.
error[E0597]: `x` does not live long enough
→ 참조가 원본 데이터보다 오래 살려고 함. 데이터의 스코프를 늘리거나, 소유권을 이동(clone).
error[E0502]: cannot borrow `x` as mutable because it is also borrowed as immutable
→ 불변 참조가 살아있는 동안 가변 참조 생성 시도. 불변 참조를 먼저 끝내고 가변 참조 사용.
요약
- 수명: 참조가 유효한 기간을 컴파일러에게 알려주는 어노테이션
'a,'b등으로 표기 (알파벳 소문자, 관례상 짧게)- 구조체가 참조 필드를 가질 때 수명 어노테이션 필요
- 수명 생략 규칙: 흔한 패턴에서 컴파일러가 자동 추론
'static: 프로그램 전체 기간 동안 유효 (문자열 리터럴, 상수)- 실용 팁: 수명이 어려우면 소유된 타입(
String,Vec)으로 해결
다음으로는 Solidity 기초를 배웁니다. 컬렉션(Vec, HashMap 등)은 3주차에서 다룹니다.
Chapter 11: Solidity 기초
Solidity란 무엇인가
Solidity는 이더리움 스마트 컨트랙트를 작성하기 위한 정적 타입(statically typed) 프로그래밍 언어다. 2014년 Gavin Wood가 제안하고 이더리움 팀이 개발했으며, 현재는 이더리움 생태계의 표준 스마트 컨트랙트 언어로 자리잡았다.
Node.js 백엔드 개발자 관점에서 보면 Solidity는 JavaScript/TypeScript와 문법적으로 유사한 부분이 많다. 중괄호로 블록을 구분하고, if/else, for, while 같은 제어 흐름도 동일하다. 하지만 실행 환경이 근본적으로 다르다.
TypeScript 코드가 실행되는 환경:
- Node.js V8 엔진 위에서 실행
- 서버의 CPU와 메모리 사용
- 실행 비용: 서버 운영비
Solidity 코드가 실행되는 환경:
- EVM(Ethereum Virtual Machine) 위에서 실행
- 수천 개의 노드가 동일한 코드를 동시에 실행
- 실행 비용: 가스(Gas) — 실제 ETH로 지불
이 차이가 Solidity의 모든 설계 결정에 영향을 미친다. 반복문을 쓸 때도, 데이터를 저장할 때도, 함수를 호출할 때도 항상 “이게 얼마나 비싼가?“를 생각해야 한다.
컨트랙트 구조
모든 Solidity 파일은 세 가지 핵심 요소로 구성된다.
1. pragma — 컴파일러 버전 지정
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
pragma solidity ^0.8.20은 “Solidity 0.8.20 이상, 0.9.0 미만의 컴파일러를 사용하라“는 의미다. ^ 기호는 npm의 semver와 동일한 의미다.
SPDX-License-Identifier는 소스 코드의 라이선스를 명시한다. 블록체인에 배포된 코드는 누구나 볼 수 있으므로 라이선스 표시가 중요하다. MIT, Apache-2.0, GPL-3.0 등을 사용할 수 있으며, 라이선스가 없다면 UNLICENSED를 쓴다.
2. import — 다른 파일 불러오기
// 상대 경로 import
import "./Token.sol";
// 특정 심볼만 import
import { ERC20 } from "./ERC20.sol";
// npm 패키지 스타일 (OpenZeppelin 등)
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
// 별칭(alias) 사용
import { Ownable as OwnableContract } from "@openzeppelin/contracts/access/Ownable.sol";
TypeScript의 ES module import와 거의 동일하다. @openzeppelin/... 같은 패키지 경로는 remappings.txt 또는 foundry.toml에서 실제 경로로 매핑된다.
3. contract — 컨트랙트 정의
contract MyContract {
// 상태 변수 (블록체인에 영구 저장)
uint256 public count;
// 함수
function increment() public {
count += 1;
}
}
contract 키워드는 TypeScript의 class와 유사하다. 상태 변수는 클래스의 인스턴스 변수처럼 컨트랙트의 데이터를 저장하며, 이 데이터는 블록체인에 영구적으로 기록된다.
TypeScript class vs Solidity contract 비교
| 개념 | TypeScript | Solidity |
|---|---|---|
| 타입 정의 | class MyClass {} | contract MyContract {} |
| 인스턴스 변수 | private count: number | uint256 private count |
| 생성자 | constructor() {} | constructor() {} |
| 메서드 | increment(): void {} | function increment() public {} |
| 상속 | extends BaseClass | is BaseContract |
| 인터페이스 | implements IFoo | is IFoo (인터페이스도 동일 구문) |
| 읽기 전용 | readonly | view 또는 pure 함수 |
| 접근 제어 | public/private/protected | public/private/internal/external |
핵심 차이점:
- TypeScript 클래스는 메모리에 인스턴스가 생성되고 GC가 관리한다
- Solidity 컨트랙트는 블록체인 주소에 배포되고 영원히 존재한다
- TypeScript는 생성자를 여러 번 호출할 수 있다
- Solidity 생성자는 배포 시 딱 한 번만 실행된다
Remix IDE로 첫 컨트랙트 작성하기
Remix IDE는 브라우저에서 바로 사용할 수 있는 Solidity 개발 환경이다. 설치 없이 바로 시작할 수 있어서 학습하기에 최적이다.
Remix 시작하기
https://remix.ethereum.org접속- 좌측 파일 탐색기에서
contracts/폴더 우클릭 →New File - 파일명:
Counter.sol입력
첫 컨트랙트: Counter
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title Counter - 간단한 카운터 컨트랙트
/// @notice 숫자를 증가/감소시키는 기본 컨트랙트
contract Counter {
// 상태 변수: 블록체인에 저장되는 카운터 값
uint256 private _count;
// 소유자 주소
address public owner;
// 이벤트: 카운터가 변경될 때 로그를 남긴다
event CountChanged(uint256 newCount, address changedBy);
// 생성자: 배포 시 딱 한 번 실행
constructor() {
owner = msg.sender; // 배포한 사람의 주소
_count = 0;
}
/// @notice 카운터를 1 증가시킨다
function increment() public {
_count += 1;
emit CountChanged(_count, msg.sender);
}
/// @notice 카운터를 1 감소시킨다 (0 미만으로는 내려가지 않음)
function decrement() public {
require(_count > 0, "Counter: cannot decrement below zero");
_count -= 1;
emit CountChanged(_count, msg.sender);
}
/// @notice 특정 값으로 리셋 (소유자만 가능)
function reset(uint256 newValue) public {
require(msg.sender == owner, "Counter: only owner can reset");
_count = newValue;
emit CountChanged(_count, msg.sender);
}
/// @notice 현재 카운터 값 조회 (읽기 전용)
function getCount() public view returns (uint256) {
return _count;
}
}
Remix에서 컴파일하기
- 좌측 메뉴에서 Solidity Compiler (두 번째 아이콘) 클릭
Compiler버전을0.8.20으로 선택- Compile Counter.sol 버튼 클릭
- 컴파일 성공 시 아이콘에 초록 체크 표시
Remix에서 배포하기
- 좌측 메뉴에서 Deploy & Run Transactions (세 번째 아이콘) 클릭
Environment를Remix VM (Shanghai)선택 (로컬 테스트 환경)Contract드롭다운에서Counter선택- Deploy 버튼 클릭
- 하단에 배포된 컨트랙트 주소가 생성됨
Remix에서 함수 호출하기
배포 후 하단 Deployed Contracts 섹션에서:
-
주황색 버튼: 상태를 변경하는 함수 (트랜잭션 발생, 가스 소비)
increment: 카운터 +1decrement: 카운터 -1reset: 입력값으로 리셋
-
파란색 버튼: 읽기 전용 함수 (트랜잭션 없음, 가스 소비 없음)
getCount: 현재 값 조회owner: 소유자 주소 조회
increment를 3번 클릭한 후 getCount를 클릭하면 3이 반환된다.
카운터 컨트랙트 상세 설명
msg 전역 객체
msg.sender // 현재 함수를 호출한 주소 (EOA 또는 컨트랙트)
msg.value // 함수에 함께 전송된 ETH 양 (wei 단위)
msg.data // 함수 호출 시 전달된 전체 calldata
msg.sender는 NestJS의 @Req() req에서 꺼내는 req.user와 비슷한 개념이다. 현재 호출자의 신원을 알 수 있다. 단, 블록체인에서는 서명으로 신원을 증명하므로 위조가 불가능하다.
require — 조건 검증
require(조건, "실패 메시지");
조건이 false면 트랜잭션을 되돌리고(revert) 메시지를 반환한다. 가스는 이미 사용된 만큼만 소비된다.
Node.js에서의 가드 클로즈(guard clause)와 유사하다:
// TypeScript
if (!user) throw new UnauthorizedException('User not found');
// Solidity 동등한 코드
require(msg.sender != address(0), "Invalid sender");
event와 emit
event CountChanged(uint256 newCount, address changedBy);
emit CountChanged(_count, msg.sender);
이벤트는 블록체인의 로그에 기록된다. 컨트랙트 외부(프론트엔드, 백엔드)에서 이 로그를 구독할 수 있다. 상태 변수보다 훨씬 저렴하게 데이터를 기록하는 방법이다.
view 함수
function getCount() public view returns (uint256) {
return _count;
}
view 키워드는 “이 함수는 상태를 읽기만 하고 변경하지 않는다“는 의미다. TypeScript의 getter와 동일하다. view 함수는 트랜잭션 없이 무료로 호출할 수 있다.
정리
Solidity는 TypeScript와 문법이 유사하지만, 블록체인이라는 특수한 실행 환경 때문에 다른 사고방식이 필요하다:
- 모든 코드는 공개된다 — 배포된 컨트랙트 코드는 누구나 볼 수 있다
- 상태 변경은 비용이 든다 — 가스를 소비하므로 효율적으로 작성해야 한다
- 한번 배포하면 수정 불가 — 업그레이드 패턴을 미리 설계해야 한다
- 신뢰 없는 환경 — 외부 입력은 항상 검증해야 한다
다음 챕터에서는 Solidity의 타입 시스템을 TypeScript와 비교하며 자세히 살펴본다.
Chapter 11-1: 타입과 변수
Solidity 타입 시스템 개요
Solidity는 정적 타입 언어다. TypeScript처럼 컴파일 시점에 타입을 확인하며, 모든 변수는 선언 시 타입을 명시해야 한다. 단, TypeScript와 달리 타입 추론이 제한적이므로 거의 항상 명시적으로 타입을 작성한다.
값 타입 (Value Types)
값 타입은 변수에 값 자체가 저장되며, 다른 변수에 할당할 때 복사된다.
bool
bool public isActive = true;
bool public isPaused = false;
function toggle() public {
isActive = !isActive;
}
TypeScript의 boolean과 동일하다. &&, ||, ! 연산자 사용 가능.
uint와 int — 정수형
Solidity의 정수형은 크기를 직접 지정한다. 8비트 단위로 8~256까지 지정 가능하다.
uint8 smallNumber = 255; // 0 ~ 255
uint16 mediumNumber = 65535; // 0 ~ 65,535
uint32 tokenId = 4294967295; // 0 ~ 4,294,967,295
uint128 halfMax = type(uint128).max;
uint256 bigNumber = 1e18; // 10^18 (1 ETH in wei)
// uint는 uint256의 별칭
uint sameAsUint256 = 100;
// 부호 있는 정수
int8 temp = -10; // -128 ~ 127
int256 debt = -1000; // int는 int256의 별칭
TypeScript와의 비교:
| TypeScript | Solidity | 범위 |
|---|---|---|
number | uint8 | 0~255 |
number | uint16 | 0~65535 |
number | uint256 | 0~2^256-1 |
number | int256 | -2^255 ~ 2^255-1 |
bigint | uint256 | 유사하지만 Solidity는 언어 기본형 |
TypeScript의 number는 IEEE 754 64비트 부동소수점이라 정밀도 문제가 있다. Solidity는 정수만 지원하므로 이런 문제가 없다. Solidity에는 소수점이 없다. 이더(ETH)도 wei 단위(10^18)의 정수로 표현한다.
// ETH 금액 계산 예시
uint256 oneEther = 1 ether; // 1000000000000000000 (10^18 wei)
uint256 halfEther = 0.5 ether; // 500000000000000000
uint256 oneGwei = 1 gwei; // 1000000000 (10^9 wei)
// 퍼센트 계산: 소수점 대신 basis point (1/10000) 사용
uint256 feeRate = 250; // 2.50%
uint256 amount = 10000;
uint256 fee = amount * feeRate / 10000; // 250 (2.5%)
address
Ethereum 주소(20바이트, 40자리 16진수)를 저장하는 타입이다.
address public owner;
address payable public treasury; // ETH를 받을 수 있는 주소
// address 리터럴 (체크섬 형식)
address constant ZERO_ADDRESS = address(0);
address constant BURN_ADDRESS = 0x000000000000000000000000000000000000dEaD;
function example() public {
// address 비교
require(msg.sender != address(0), "Invalid address");
// 잔액 조회
uint256 balance = address(this).balance; // 이 컨트랙트의 ETH 잔액
uint256 ownerBalance = owner.balance; // owner 주소의 ETH 잔액
// ETH 전송 (payable 주소에만 가능)
address payable recipient = payable(msg.sender);
recipient.transfer(1 ether); // 실패 시 revert
recipient.send(1 ether); // 실패 시 false 반환 (권장하지 않음)
// call 방식 (권장)
(bool success, ) = recipient.call{value: 1 ether}("");
require(success, "Transfer failed");
}
address와 address payable의 차이: payable 주소만 transfer, send를 직접 호출할 수 있다. 일반 address는 call을 사용해야 한다.
bytes — 고정 크기 바이트 배열
bytes1 singleByte = 0xFF;
bytes4 selector = 0x12345678; // 함수 선택자 (4바이트)
bytes32 hash = keccak256("hello"); // 해시값 저장에 자주 사용
// 비트 연산
bytes1 a = 0x0F;
bytes1 b = 0xF0;
bytes1 result = a | b; // 0xFF
bytes32는 특히 자주 쓰인다. 해시 저장, role 정의 등에 활용된다.
참조 타입 (Reference Types)
참조 타입은 데이터의 위치(storage, memory, calldata)를 함께 지정해야 한다.
string
string public name = "MyToken";
string private description;
function setDescription(string memory newDesc) public {
description = newDesc;
}
function getDescription() public view returns (string memory) {
return description;
}
Solidity의 string은 UTF-8로 인코딩된 동적 길이 바이트 배열이다. TypeScript의 string과 달리 인덱스 접근(s[0])이나 길이 확인(s.length)이 직접 되지 않는다. 문자열 연산이 필요하면 bytes로 변환하거나 라이브러리를 사용한다.
// 문자열 길이 확인 (bytes로 변환 필요)
string memory s = "hello";
uint256 len = bytes(s).length; // 5
// 문자열 연결 (abi.encodePacked 사용)
string memory greeting = string(abi.encodePacked("Hello, ", name, "!"));
배열 (Array)
// 고정 크기 배열
uint256[3] public fixedArray = [1, 2, 3];
// 동적 배열 (상태 변수)
uint256[] public dynamicArray;
address[] public voters;
function addVoter(address voter) public {
voters.push(voter); // 요소 추가
}
function removeLastVoter() public {
voters.pop(); // 마지막 요소 제거
}
function getVoterCount() public view returns (uint256) {
return voters.length;
}
// 메모리 배열 (함수 내부)
function createTempArray(uint256 size) public pure returns (uint256[] memory) {
uint256[] memory temp = new uint256[](size); // 동적 크기
for (uint256 i = 0; i < size; i++) {
temp[i] = i * 2;
}
return temp;
}
TypeScript Array와의 차이:
- Storage 배열은
push,pop만 지원 (중간 삽입 없음) - 메모리 배열은 크기를 미리 지정해야 함
- 배열 삭제(
delete arr[i])는 해당 인덱스를 기본값으로 초기화할 뿐 크기는 줄지 않음
struct — 구조체
struct User {
address wallet;
string username;
uint256 balance;
bool isActive;
uint256 createdAt;
}
// 상태 변수로 저장
User public admin;
User[] public allUsers;
mapping(address => User) public users;
function createUser(string memory username) public {
// 구조체 초기화 방법 1: 필드명 지정
users[msg.sender] = User({
wallet: msg.sender,
username: username,
balance: 0,
isActive: true,
createdAt: block.timestamp
});
// 구조체 초기화 방법 2: 순서대로
allUsers.push(User(msg.sender, username, 0, true, block.timestamp));
}
function deactivateUser() public {
// storage 참조로 직접 수정
User storage user = users[msg.sender];
user.isActive = false;
}
TypeScript의 interface나 type과 유사하다. 단, User storage user로 참조를 가져오면 실제 storage를 직접 수정하고, User memory user로 가져오면 복사본을 수정하므로 원본이 바뀌지 않는다.
mapping — 키-값 저장소
// mapping(키 타입 => 값 타입)
mapping(address => uint256) public balances;
mapping(address => bool) public whitelist;
mapping(uint256 => string) public tokenURIs;
// 중첩 매핑 (ERC-20의 allowance 구조)
mapping(address => mapping(address => uint256)) public allowances;
function getBalance(address account) public view returns (uint256) {
return balances[account]; // 없는 키는 기본값(0) 반환
}
function allow(address spender, uint256 amount) public {
allowances[msg.sender][spender] = amount;
}
mapping은 별도의 챕터(11-03)에서 더 자세히 다룬다.
상태 변수 vs 로컬 변수 vs 전역 변수
상태 변수 (State Variable)
컨트랙트 최상위 레벨에 선언되며 블록체인의 storage에 영구 저장된다.
contract Example {
uint256 public totalSupply; // 상태 변수 (storage)
address private _owner; // 상태 변수 (storage)
string public name; // 상태 변수 (storage)
}
쓰기(write) 비용이 비싸다. SSTORE opcode는 약 20,000 가스 (새 슬롯 쓰기) 또는 5,000 가스 (기존 슬롯 업데이트).
로컬 변수 (Local Variable)
함수 내부에 선언되며 함수 실행 중에만 존재한다. EVM 스택이나 메모리를 사용하므로 훨씬 저렴하다.
function calculate(uint256 a, uint256 b) public pure returns (uint256) {
uint256 sum = a + b; // 로컬 변수 (stack)
uint256 product = a * b; // 로컬 변수 (stack)
return sum + product;
}
전역 변수 (Global Variable)
EVM이 제공하는 특수 변수들이다. 선언 없이 어디서든 사용할 수 있다.
// 블록 관련
block.timestamp // 현재 블록의 Unix 타임스탬프 (uint256)
block.number // 현재 블록 번호 (uint256)
block.coinbase // 현재 블록을 채굴한 주소 (address payable)
block.gaslimit // 현재 블록의 가스 한도 (uint256)
block.basefee // 현재 블록의 기본 가스비 (uint256, EIP-1559)
// 트랜잭션 관련
msg.sender // 현재 함수 호출자 주소
msg.value // 전송된 ETH (wei)
msg.data // 전체 calldata (bytes)
msg.sig // 함수 선택자 (bytes4, msg.data의 처음 4바이트)
tx.origin // 트랜잭션 최초 발신자 (EOA만 가능)
tx.gasprice // 트랜잭션의 가스 가격
// 가스
gasleft() // 남은 가스량 (함수)
가시성 (Visibility)
상태 변수 가시성
contract Visibility {
uint256 public pubVar; // 외부 읽기 가능, 자동 getter 생성
uint256 private privVar; // 이 컨트랙트만 접근 가능
uint256 internal intVar; // 이 컨트랙트 + 상속 컨트랙트
// external은 상태 변수에 사용 불가
}
public 상태 변수는 Solidity가 자동으로 getter 함수를 생성한다:
uint256 public totalSupply = 1000;
// 위 선언이 아래 함수를 자동 생성:
// function totalSupply() external view returns (uint256) { return totalSupply; }
함수 가시성
contract FunctionVisibility {
// public: 외부, 내부 모두 호출 가능
function publicFunc() public {}
// external: 외부에서만 호출 가능 (내부에서 this.externalFunc()로는 가능)
function externalFunc() external {}
// internal: 이 컨트랙트 + 상속 컨트랙트에서 호출 가능
function internalFunc() internal {}
// private: 이 컨트랙트에서만 호출 가능
function privateFunc() private {}
}
성능 팁: 외부에서만 호출할 함수는 external로 선언하는 게 public보다 약간 가스를 절약한다. external 함수의 파라미터는 calldata에서 직접 읽으므로 복사 비용이 없다.
상수: constant와 immutable
contract Constants {
// constant: 컴파일 타임 상수 (리터럴만 가능)
uint256 public constant MAX_SUPPLY = 1_000_000 * 10**18;
string public constant NAME = "MyToken";
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
// immutable: 배포 시 한 번만 설정 가능
address public immutable deployer;
uint256 public immutable deployedAt;
constructor() {
deployer = msg.sender; // 생성자에서만 설정 가능
deployedAt = block.timestamp; // 런타임 값 사용 가능
}
}
constant vs immutable 비교:
| constant | immutable | |
|---|---|---|
| 값 설정 시점 | 컴파일 시 | 배포 시 (생성자) |
| 런타임 값 사용 | 불가 | 가능 |
| 가스 비용 | 가장 저렴 | 저렴 |
| 사용 사례 | 고정 한도, 역할 해시 | 배포자 주소, 배포 시각 |
constant와 immutable 모두 storage를 사용하지 않아서 일반 상태 변수보다 훨씬 저렴하다.
데이터 위치 (Data Location)
Solidity의 참조 타입(string, bytes, array, struct, mapping)은 반드시 데이터 위치를 지정해야 한다.
storage
블록체인에 영구 저장. 상태 변수는 기본적으로 storage.
mapping(address => uint256) public balances; // storage (상태 변수)
function updateUser(address addr) internal {
// storage 참조 - 실제 데이터를 직접 수정
User storage user = users[addr];
user.balance += 100; // storage의 값이 직접 변경됨
}
memory
함수 실행 중에만 존재하는 임시 메모리. 함수 호출이 끝나면 사라진다.
function processName(string memory name) public pure returns (string memory) {
// memory - 복사본, 함수 끝나면 사라짐
bytes memory nameBytes = bytes(name);
return string(nameBytes);
}
calldata
함수의 입력 파라미터 영역. 읽기 전용이고 external 함수에서 사용 가능. memory보다 저렴하다.
// calldata - 복사 없이 직접 읽기 (gas 절약)
function processData(bytes calldata data) external pure returns (uint256) {
return data.length;
}
언제 무엇을 쓸까?
contract DataLocationExample {
struct Item {
uint256 id;
string name;
}
Item[] public items;
// calldata: external 함수의 읽기 전용 파라미터 (가장 저렴)
function addItem(string calldata name) external {
items.push(Item(items.length, name));
}
// memory: 함수 내부에서 수정하거나 반환할 때
function getItemCopy(uint256 id) external view returns (Item memory) {
return items[id]; // storage에서 memory로 복사
}
// storage: storage 데이터를 직접 수정할 때
function renameItem(uint256 id, string memory newName) external {
Item storage item = items[id]; // 참조
item.name = newName; // storage 직접 수정
}
}
TypeScript 타입과 Solidity 타입 대응표
| TypeScript | Solidity | 비고 |
|---|---|---|
boolean | bool | 동일 |
number | uint256 | TS는 부동소수점, SOL은 정수 |
bigint | uint256 | 범위 다름 (SOL은 2^256-1까지) |
string | string | SOL은 인덱스 접근 불가 |
Buffer/Uint8Array | bytes | 동적 크기 바이트 |
string (hex) | address | 20바이트 특수 타입 |
T[] | T[] / T[N] | SOL은 고정/동적 구분 |
Record<K,V> / Map<K,V> | mapping(K => V) | SOL은 순회 불가 |
interface/type | struct | SOL struct는 메서드 없음 |
enum | enum | SOL enum은 uint8 기반 |
null/undefined | 없음 | SOL은 기본값으로 초기화 |
기본값 (Default Values)
Solidity에서 선언만 하고 초기화하지 않으면 타입의 기본값이 자동으로 설정된다:
bool public b; // false
uint256 public n; // 0
int256 public i; // 0
address public a; // address(0) = 0x0000...0000
bytes32 public hash; // bytes32(0)
string public s; // "" (빈 문자열)
uint256[] public arr; // [] (빈 배열)
TypeScript에서 undefined나 null이 없는 것처럼, Solidity에도 null이 없다. 대신 기본값으로 초기화된다. 이 특성 때문에 mapping에서 없는 키를 조회하면 0이 반환된다.
정리
Solidity의 타입 시스템은 TypeScript보다 더 세밀하고 제약이 많다. 특히:
- 정수 크기를 명시해야 한다 —
uint256,uint8등으로 메모리 크기 최적화 - 소수점이 없다 — 금액 계산 시 wei 단위 또는 basis point 활용
- 데이터 위치를 지정해야 한다 — storage/memory/calldata는 가스 비용에 직결
- 기본값이 있다 — null/undefined 없이 항상 타입의 기본값으로 초기화
다음 챕터에서는 함수와 제어자(modifier)를 자세히 살펴본다.
Chapter 11-2: 함수와 제어자 (Functions & Modifiers)
함수 선언 문법
Solidity 함수의 전체 문법 구조:
function transfer(address to, uint256 amount) public returns (bool) {
balances[msg.sender] -= amount;
balances[to] += amount;
return true;
}
실제 예시로 각 요소를 확인해보자:
// 기본 함수
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
// 여러 값 반환
function getMinMax(uint256[] memory arr) public pure returns (uint256 min, uint256 max) {
min = arr[0];
max = arr[0];
for (uint256 i = 1; i < arr.length; i++) {
if (arr[i] < min) min = arr[i];
if (arr[i] > max) max = arr[i];
}
// named return이면 return문 생략 가능
}
// 반환값 명시적으로
function divide(uint256 a, uint256 b) public pure returns (uint256 quotient, uint256 remainder) {
return (a / b, a % b);
}
TypeScript와의 문법 비교:
// TypeScript
function add(a: number, b: number): number {
return a + b;
}
// 구조분해 반환
function getMinMax(arr: number[]): { min: number; max: number } {
return { min: Math.min(...arr), max: Math.max(...arr) };
}
// Solidity - 타입이 앞에, 이름이 뒤에
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
가시성 (Visibility)
public
외부 계정(EOA), 다른 컨트랙트, 그리고 이 컨트랙트 내부 어디서든 호출 가능.
function transfer(address to, uint256 amount) public {
balances[msg.sender] -= amount;
balances[to] += amount;
}
external
오직 외부에서만 호출 가능. 컨트랙트 내부에서는 this.funcName()으로 호출해야 한다. 파라미터를 calldata에서 직접 읽어 public보다 가스 효율이 좋다.
// 인터페이스 구현 함수, 외부에서만 호출될 함수에 적합
function deposit(uint256 amount) external payable {
require(msg.value == amount, "Value mismatch");
balances[msg.sender] += amount;
}
internal
이 컨트랙트와 이를 상속한 자식 컨트랙트에서만 호출 가능. TypeScript의 protected와 유사.
function _mint(address to, uint256 amount) internal {
totalSupply += amount;
balances[to] += amount;
}
// 자식 컨트랙트에서 호출 가능
contract MyToken is BaseToken {
function publicMint() external {
_mint(msg.sender, 100 * 10**18);
}
}
private
오직 이 컨트랙트에서만 호출 가능. 상속 컨트랙트에서도 접근 불가.
function _validateAmount(uint256 amount) private pure returns (bool) {
return amount > 0 && amount <= type(uint256).max;
}
관례: internal/private 함수는 이름 앞에 _ 언더스코어를 붙이는 것이 관례다 (OpenZeppelin 스타일).
상태 변경성 (State Mutability)
view
상태를 읽기만 하고 변경하지 않는다. 트랜잭션 없이 무료로 호출 가능 (로컬 노드에서 실행).
uint256 private _totalSupply;
mapping(address => uint256) private _balances;
function totalSupply() public view returns (uint256) {
return _totalSupply;
}
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
// 계산 로직도 view로 가능 (상태 읽기는 허용)
function getPercentage(address account) public view returns (uint256) {
if (_totalSupply == 0) return 0;
return (_balances[account] * 100) / _totalSupply;
}
pure
상태를 읽지도, 변경하지도 않는다. 순수하게 입력값만으로 계산.
function multiply(uint256 a, uint256 b) public pure returns (uint256) {
return a * b;
}
function hashData(address addr, uint256 amount) public pure returns (bytes32) {
return keccak256(abi.encodePacked(addr, amount));
}
// 수학 유틸리티 함수들은 보통 pure
function min(uint256 a, uint256 b) internal pure returns (uint256) {
return a < b ? a : b;
}
payable
ETH를 받을 수 있는 함수. msg.value가 0이 아닌 트랜잭션을 수락한다.
function deposit() external payable {
require(msg.value > 0, "Must send ETH");
balances[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value);
}
// payable이 없으면 ETH와 함께 호출 시 자동 revert
function regularFunction() external {
// msg.value는 항상 0
}
상태 변경성 규칙 요약:
| 키워드 | 상태 읽기 | 상태 쓰기 | ETH 수신 | 비용 |
|---|---|---|---|---|
| (없음) | O | O | X | 가스 필요 |
view | O | X | X | 무료 (로컬) |
pure | X | X | X | 무료 (로컬) |
payable | O | O | O | 가스 필요 |
생성자 (Constructor)
컨트랙트 배포 시 딱 한 번만 실행되는 특수 함수.
contract Token {
string public name;
string public symbol;
uint256 public totalSupply;
address public owner;
mapping(address => uint256) private _balances;
constructor(
string memory _name,
string memory _symbol,
uint256 initialSupply
) {
name = _name;
symbol = _symbol;
owner = msg.sender;
// 초기 공급량을 배포자에게 지급
totalSupply = initialSupply;
_balances[msg.sender] = initialSupply;
}
}
배포 시 생성자 인수를 전달한다:
# Foundry로 배포 시
forge create Token --constructor-args "MyToken" "MTK" 1000000000000000000000000
상속과 생성자:
contract Ownable {
address public owner;
constructor() {
owner = msg.sender;
}
}
contract Token is Ownable {
string public name;
// 부모 생성자는 자동 호출 (인수 없는 경우)
constructor(string memory _name) {
name = _name;
}
}
// 부모 생성자에 인수가 필요한 경우
contract ChildToken is BaseToken {
constructor(string memory _name) BaseToken(_name, "CHILD") {
// BaseToken의 생성자에 인수 전달
}
}
receive()와 fallback() 함수
컨트랙트가 ETH를 받거나 알 수 없는 함수가 호출될 때 실행되는 특수 함수들.
receive()
순수하게 ETH만 전송될 때 (calldata가 비어있을 때) 호출된다.
contract ETHReceiver {
event Received(address sender, uint256 amount);
// ETH를 받기 위한 함수
receive() external payable {
emit Received(msg.sender, msg.value);
// 추가 로직 가능
}
}
// 외부에서 단순 ETH 전송 (TypeScript/ethers.js)
await signer.sendTransaction({
to: contractAddress,
value: ethers.parseEther("1.0")
// data 없음 -> receive() 호출
});
fallback()
- 매칭되는 함수가 없을 때
- calldata가 있는데 receive()가 없을 때
contract Proxy {
address public implementation;
constructor(address _impl) {
implementation = _impl;
}
// 모든 호출을 구현체로 전달 (프록시 패턴)
fallback() external payable {
address impl = implementation;
assembly {
// calldata를 그대로 구현체에 전달
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
receive() external payable {}
}
receive vs fallback 호출 흐름:
ETH 전송 또는 함수 호출
|
calldata 있음?
/ \
없음 있음
| |
receive() 함수 선택자 매칭?
존재? / \
/ \ 있음 없음
있음 없음 | |
| | 해당 함수 fallback()
receive() fallback() 존재?
/ \
있음 없음
| |
fallback() revert
함수 제어자 (Modifier)
제어자는 함수 실행 전후에 공통 로직을 삽입하는 메커니즘이다. NestJS의 Guard, Middleware와 개념적으로 유사하다.
기본 제어자 패턴
contract Ownable {
address public owner;
constructor() {
owner = msg.sender;
}
// 제어자 정의
modifier onlyOwner() {
require(msg.sender == owner, "Ownable: caller is not the owner");
_; // 이 위치에 실제 함수 코드가 삽입됨
}
// 제어자 적용
function transferOwnership(address newOwner) public onlyOwner {
require(newOwner != address(0), "Ownable: new owner is the zero address");
owner = newOwner;
}
function renounceOwnership() public onlyOwner {
owner = address(0);
}
}
_; 는 “여기에 함수 본문을 실행하라“는 플레이스홀더다. 제어자 코드가 _; 전에 오면 함수 실행 전 체크, 후에 오면 함수 실행 후 체크다.
NestJS Guards와의 비교
// NestJS - Guard
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
return request.user?.role === 'admin';
}
}
// Controller에 적용
@UseGuards(AuthGuard)
@Post('/admin/action')
async adminAction() {
// Guard 통과 후 실행
}
// Solidity - Modifier
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
// 함수에 적용
function adminAction() public onlyOwner {
// require 통과 후 실행
}
핵심 차이: NestJS Guard는 HTTP 레이어에서 작동하고 DI(의존성 주입)을 활용한다. Solidity Modifier는 컴파일 시점에 인라인으로 삽입된다 (매크로와 유사).
파라미터가 있는 제어자
modifier minimumAmount(uint256 minimum) {
require(msg.value >= minimum, "Insufficient ETH sent");
_;
}
function premiumDeposit() external payable minimumAmount(0.1 ether) {
// 0.1 ETH 이상만 가능
premiumBalances[msg.sender] += msg.value;
}
여러 제어자 조합
contract AccessControl {
address public owner;
bool public paused;
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
modifier whenNotPaused() {
require(!paused, "Contract is paused");
_;
}
modifier validAddress(address addr) {
require(addr != address(0), "Zero address");
_;
}
// 여러 제어자를 순서대로 적용
function transfer(
address to,
uint256 amount
) public whenNotPaused validAddress(to) {
// whenNotPaused 체크 -> validAddress 체크 -> 함수 실행
balances[msg.sender] -= amount;
balances[to] += amount;
}
function pause() public onlyOwner {
paused = true;
}
function unpause() public onlyOwner {
paused = false;
}
}
실행 전후 로직
// ReentrancyGuard 패턴 - 재진입 공격 방지
modifier nonReentrant() {
require(!locked, "ReentrancyGuard: reentrant call");
locked = true;
_; // 함수 실행
locked = false; // 함수 실행 후
}
접근 제어가 있는 완전한 컨트랙트 예제
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title SimpleVault - 접근 제어가 있는 금고 컨트랙트
contract SimpleVault {
// ============ 상태 변수 ============
address public owner;
address public pendingOwner;
bool public paused;
bool private _locked;
mapping(address => uint256) private _balances;
mapping(address => bool) private _operators;
// ============ 이벤트 ============
event Deposited(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
event OwnershipTransferProposed(address indexed newOwner);
event OwnershipTransferred(address indexed oldOwner, address indexed newOwner);
event OperatorSet(address indexed operator, bool status);
event Paused(address indexed by);
event Unpaused(address indexed by);
// ============ 제어자 ============
modifier onlyOwner() {
require(msg.sender == owner, "Vault: not owner");
_;
}
modifier onlyOperatorOrOwner() {
require(
msg.sender == owner || _operators[msg.sender],
"Vault: not authorized"
);
_;
}
modifier whenNotPaused() {
require(!paused, "Vault: paused");
_;
}
modifier nonReentrant() {
require(!_locked, "Vault: reentrant call");
_locked = true;
_;
_locked = false;
}
modifier validAmount(uint256 amount) {
require(amount > 0, "Vault: amount must be positive");
_;
}
// ============ 생성자 ============
constructor() {
owner = msg.sender;
emit OwnershipTransferred(address(0), msg.sender);
}
// ============ ETH 수신 ============
receive() external payable {
_deposit(msg.sender, msg.value);
}
// ============ 외부 함수 ============
/// @notice ETH를 금고에 예치
function deposit() external payable whenNotPaused validAmount(msg.value) {
_deposit(msg.sender, msg.value);
}
/// @notice ETH를 출금
function withdraw(uint256 amount)
external
whenNotPaused
nonReentrant
validAmount(amount)
{
require(_balances[msg.sender] >= amount, "Vault: insufficient balance");
// Checks-Effects-Interactions 패턴
_balances[msg.sender] -= amount; // 상태 먼저 변경
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Vault: transfer failed");
emit Withdrawn(msg.sender, amount);
}
/// @notice 특정 사용자의 잔액 조회
function balanceOf(address user) external view returns (uint256) {
return _balances[user];
}
/// @notice 이 컨트랙트의 총 ETH 잔액
function totalAssets() external view returns (uint256) {
return address(this).balance;
}
// ============ 소유자 전용 함수 ============
/// @notice 오퍼레이터 권한 설정
function setOperator(address operator, bool status) external onlyOwner {
require(operator != address(0), "Vault: zero address");
_operators[operator] = status;
emit OperatorSet(operator, status);
}
/// @notice 소유권 이전 제안 (2단계 이전)
function proposeOwnership(address newOwner) external onlyOwner {
require(newOwner != address(0), "Vault: zero address");
pendingOwner = newOwner;
emit OwnershipTransferProposed(newOwner);
}
/// @notice 소유권 이전 수락
function acceptOwnership() external {
require(msg.sender == pendingOwner, "Vault: not pending owner");
address oldOwner = owner;
owner = pendingOwner;
pendingOwner = address(0);
emit OwnershipTransferred(oldOwner, owner);
}
// ============ 오퍼레이터/소유자 함수 ============
function pause() external onlyOperatorOrOwner {
paused = true;
emit Paused(msg.sender);
}
function unpause() external onlyOwner {
paused = false;
emit Unpaused(msg.sender);
}
// ============ 내부 함수 ============
function _deposit(address user, uint256 amount) internal {
_balances[user] += amount;
emit Deposited(user, amount);
}
// ============ 조회 함수 ============
function isOperator(address account) external view returns (bool) {
return _operators[account];
}
}
이 컨트랙트는 실무에서 자주 보이는 패턴들을 담고 있다:
- 2단계 소유권 이전:
proposeOwnership→acceptOwnership— 실수로 잘못된 주소로 이전하는 사고를 방지 - Checks-Effects-Interactions 패턴: 재진입 공격 방지
- nonReentrant 제어자: 이중 인출 방지
- 이벤트 발행: 모든 중요한 상태 변경을 로그에 기록
정리
Solidity 함수는 TypeScript 함수와 문법은 유사하지만 독특한 개념들이 있다:
- 가시성은 외부 호환성과 가스 비용에 영향을 준다 —
external이public보다 저렴 - 상태 변경성은 함수가 블록체인과 어떻게 상호작용하는지 정의한다
- receive/fallback은 컨트랙트가 ETH를 받거나 알 수 없는 호출을 처리하는 방법
- modifier는 반복되는 검증 로직을 선언적으로 재사용하는 강력한 도구
다음 챕터에서는 mapping, 이벤트, 에러 처리를 자세히 다룬다.
Chapter 11-3: Mapping, 이벤트, 에러 처리
mapping — 키-값 저장소
기본 선언과 사용
mapping은 Solidity에서 가장 중요한 자료구조다. 해시 테이블 기반으로 구현되어 있어 O(1) 조회가 가능하다.
// mapping(키 타입 => 값 타입) 가시성 변수명;
mapping(address => uint256) public balances;
mapping(uint256 => address) public tokenOwners;
mapping(address => bool) public whitelist;
mapping(bytes32 => uint256) public roleTimestamps;
기본 사용법:
contract MappingBasics {
mapping(address => uint256) private _balances;
function set(address account, uint256 amount) public {
_balances[account] = amount; // 쓰기
}
function get(address account) public view returns (uint256) {
return _balances[account]; // 읽기
}
function addAmount(address account, uint256 amount) public {
_balances[account] += amount; // 기존 값에 더하기
}
function deleteEntry(address account) public {
delete _balances[account]; // 기본값(0)으로 초기화
}
}
JavaScript Map과의 결정적 차이
// JavaScript Map
const balances = new Map();
balances.set('alice', 100);
console.log(balances.get('bob')); // undefined
console.log(balances.has('bob')); // false
// 순회 가능
for (const [addr, amount] of balances) {
console.log(addr, amount);
}
balances.size; // 크기 확인 가능
// Solidity mapping
mapping(address => uint256) balances;
balances[alice] = 100;
balances[bob]; // 0 (undefined가 아니라 기본값!)
// balances.has(bob) 같은 메서드 없음
// for...of 순회 불가능
// .size/.length 없음
// 저장된 키 목록 조회 불가능
Solidity mapping의 3가지 핵심 제약:
- 순회 불가능 — 어떤 키가 저장되어 있는지 알 수 없다
- 기본값 존재 — 없는 키를 읽으면 0/false/address(0) 등 기본값 반환
- 삭제해도 완전 제거 아님 —
delete는 기본값으로 초기화만 함
“키가 존재하는지” 확인하는 패턴:
// 방법 1: 별도 bool mapping으로 추적
mapping(address => uint256) public balances;
mapping(address => bool) public exists;
function register(address user) public {
exists[user] = true;
balances[user] = 0;
}
// 방법 2: struct의 sentinel 값으로 구분
struct User {
uint256 balance;
bool registered; // 등록 여부 추적
}
mapping(address => User) public users;
function isRegistered(address user) public view returns (bool) {
return users[user].registered;
}
순회가 필요한 경우 — 별도 배열로 키 추적:
contract IterableMapping {
mapping(address => uint256) public balances;
address[] public holders; // 키(주소) 목록을 별도 배열로 유지
mapping(address => bool) private _isHolder;
function deposit(address user, uint256 amount) public {
if (!_isHolder[user]) {
holders.push(user);
_isHolder[user] = true;
}
balances[user] += amount;
}
// 이제 순회 가능
function getTotalBalance() public view returns (uint256 total) {
for (uint256 i = 0; i < holders.length; i++) {
total += balances[holders[i]];
}
}
function getHolderCount() public view returns (uint256) {
return holders.length;
}
}
주의: 배열을 유지하는 비용이 추가된다. 배열이 커지면 getTotalBalance 같은 루프 함수는 가스 한도를 초과할 수 있다.
중첩 mapping (Nested Mapping)
// ERC-20의 allowance 구조
// owner => spender => 허용량
mapping(address => mapping(address => uint256)) private _allowances;
function approve(address spender, uint256 amount) public returns (bool) {
_allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function allowance(address owner, address spender) public view returns (uint256) {
return _allowances[owner][spender];
}
더 복잡한 중첩:
// NFT 마켓플레이스: 판매자 => 토큰ID => 가격
mapping(address => mapping(uint256 => uint256)) public listings;
// 역할 관리: 역할 해시 => 계정 => 권한 여부
mapping(bytes32 => mapping(address => bool)) private _roles;
function grantRole(bytes32 role, address account) public {
_roles[role][account] = true;
}
function hasRole(bytes32 role, address account) public view returns (bool) {
return _roles[role][account];
}
이벤트 (Event)
이벤트의 역할
이벤트는 블록체인의 로그(log)에 데이터를 기록하는 메커니즘이다. 상태 변수보다 훨씬 저렴하게 데이터를 기록할 수 있으며, 외부(프론트엔드, 백엔드 서버)에서 구독할 수 있다.
이벤트 vs 상태 변수 비교:
- 상태 변수 저장: ~20,000 가스 (새 슬롯)
- 이벤트 로그: ~375 가스 (기본) + 8 가스/byte
단, 이벤트 로그는 컨트랙트 내부에서 읽을 수 없다. 오직 외부에서만 조회 가능하다.
이벤트 선언과 발행
contract EventExample {
// 이벤트 선언
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
event Mint(address indexed to, uint256 amount, uint256 timestamp);
// 이벤트 발행 (emit)
function transfer(address to, uint256 amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
emit Transfer(msg.sender, to, amount); // emit 키워드로 발행
}
}
indexed 파라미터
이벤트 파라미터에 indexed를 붙이면 해당 값으로 이벤트를 필터링할 수 있다. 이벤트당 최대 3개의 indexed 파라미터를 가질 수 있다.
event Transfer(
address indexed from, // 인덱싱됨 - 필터링 가능
address indexed to, // 인덱싱됨 - 필터링 가능
uint256 value // 인덱싱 안됨 - 데이터로만 저장
);
// indexed가 있으면 특정 주소의 Transfer만 필터링 가능:
// 예: alice가 보낸 모든 Transfer 이벤트 조회 가능
indexed vs non-indexed 비교:
indexed: Bloom 필터에 저장 → 빠른 검색 가능, 최대 32바이트 (32바이트 초과 시 keccak256 해시)- non-indexed: ABI 인코딩되어 data 필드에 저장 → 검색 불가하지만 임의 크기 가능
Node.js EventEmitter와의 비교
// Node.js EventEmitter
const EventEmitter = require('events');
const emitter = new EventEmitter();
// 이벤트 리스너 등록
emitter.on('Transfer', ({ from, to, value }) => {
console.log(`${from} -> ${to}: ${value}`);
});
// 이벤트 발생
emitter.emit('Transfer', { from: 'alice', to: 'bob', value: 100 });
// Solidity - 이벤트 발행
event Transfer(address indexed from, address indexed to, uint256 value);
function transfer(address to, uint256 value) public {
emit Transfer(msg.sender, to, value);
}
// ethers.js - Solidity 이벤트 구독 (프론트엔드/백엔드)
const contract = new ethers.Contract(address, abi, provider);
// 실시간 구독
contract.on('Transfer', (from, to, value, event) => {
console.log(`Transfer: ${from} -> ${to}: ${value}`);
console.log('Block:', event.blockNumber);
console.log('TxHash:', event.transactionHash);
});
// 과거 이벤트 조회
const filter = contract.filters.Transfer(aliceAddress); // alice가 보낸 것만
const events = await contract.queryFilter(filter, fromBlock, toBlock);
events.forEach(e => {
console.log(e.args.from, e.args.to, e.args.value);
});
이벤트 설계 패턴
contract GoodEventDesign {
// 규칙 1: 중요한 상태 변경은 항상 이벤트 발행
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
// 규칙 2: indexed는 "누가/무엇이" 필터링이 필요한 파라미터에
event TokenMinted(
address indexed to, // "누가 받았나" 필터링 가능
uint256 indexed tokenId, // "어떤 토큰" 필터링 가능
string metadataURI // 메타데이터 (indexed 불필요, 32byte 초과 가능)
);
// 규칙 3: 이전 값과 새 값 모두 기록
event PriceUpdated(
uint256 indexed tokenId,
uint256 oldPrice,
uint256 newPrice
);
// 규칙 4: 타임스탬프는 이벤트에 포함할 필요 없음 (블록에 이미 있음)
// block.timestamp는 이벤트 구독자가 event.blockNumber로 조회 가능
}
에러 처리
require()
가장 일반적인 조건 검증. 조건이 false면 트랜잭션을 revert하고 메시지를 반환한다.
function transfer(address to, uint256 amount) public {
// 입력값 검증
require(to != address(0), "ERC20: transfer to zero address");
require(amount > 0, "ERC20: amount must be positive");
// 상태 검증
require(balances[msg.sender] >= amount, "ERC20: insufficient balance");
// 실행
balances[msg.sender] -= amount;
balances[to] += amount;
emit Transfer(msg.sender, to, amount);
}
가스 효율: require가 실패하면 남은 가스를 전부 반환한다 (이미 소비된 가스는 제외).
revert()
require와 동일하게 트랜잭션을 되돌리지만, 더 복잡한 조건 처리에 유용하다.
function withdraw(uint256 amount) public {
if (amount == 0) {
revert("Cannot withdraw zero");
}
if (balances[msg.sender] < amount) {
revert("Insufficient balance");
}
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
assert()
내부 불변식(invariant) 검증에 사용. 절대 false가 되어서는 안 되는 조건에 사용한다. assert가 실패하면 남은 가스를 전부 소비한다 (버그를 나타내므로 페널티).
function _update(address from, address to, uint256 amount) internal {
if (from != address(0)) {
balances[from] -= amount;
}
if (to != address(0)) {
balances[to] += amount;
}
// 불변식: 총 공급량은 항상 모든 잔액의 합과 같아야 함
// 이 조건이 false라면 코드에 심각한 버그가 있는 것
assert(totalSupply == calculateTotalBalance());
}
언제 무엇을 써야 하나:
| 상황 | 사용 |
|---|---|
| 외부 입력값 검증 | require |
| 비즈니스 규칙 검증 | require |
| 내부 불변식 검증 | assert |
| 복잡한 분기 처리 | revert |
| 커스텀 에러 타입 | revert CustomError() |
커스텀 에러 (Custom Errors, Solidity 0.8.4+)
Solidity 0.8.4부터 타입이 있는 커스텀 에러를 정의할 수 있다. 문자열 에러 메시지보다 가스 효율이 훨씬 좋다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract CustomErrors {
// 에러 선언 (컨트랙트 외부 또는 내부에 선언 가능)
error InsufficientBalance(address user, uint256 available, uint256 required);
error Unauthorized(address caller, address required);
error InvalidAmount(uint256 amount);
error TransferToZeroAddress();
mapping(address => uint256) public balances;
address public owner;
constructor() {
owner = msg.sender;
}
function transfer(address to, uint256 amount) public {
// 커스텀 에러로 revert (파라미터 포함)
if (to == address(0)) revert TransferToZeroAddress();
if (amount == 0) revert InvalidAmount(amount);
if (balances[msg.sender] < amount) {
revert InsufficientBalance(msg.sender, balances[msg.sender], amount);
}
balances[msg.sender] -= amount;
balances[to] += amount;
}
function adminAction() public {
if (msg.sender != owner) revert Unauthorized(msg.sender, owner);
paused = !paused;
}
}
커스텀 에러의 장점:
- 가스 절약: 문자열 저장 불필요 (4바이트 선택자 + ABI 인코딩 파라미터만)
- 타입 안전성: 파라미터가 명확히 정의됨
- 프론트엔드에서 파싱 용이
// ethers.js에서 커스텀 에러 처리
try {
await contract.transfer(toAddress, amount);
} catch (error) {
if (error.code === 'CALL_EXCEPTION') {
// 커스텀 에러 파싱
const decoded = contract.interface.parseError(error.data);
if (decoded?.name === 'InsufficientBalance') {
const { user, available, required } = decoded.args;
console.log(`잔액 부족: ${available} < ${required}`);
}
}
}
가스 비용 비교:
// 문자열 에러: ~50+ bytes of data
require(amount > 0, "ERC20: transfer amount must be greater than zero");
// 커스텀 에러: 4 bytes selector + encoded params
if (amount == 0) revert InvalidAmount(amount);
try/catch
외부 컨트랙트 호출 시 에러를 처리하는 구문이다.
interface IOracle {
function getPrice(address token) external view returns (uint256);
}
contract PriceConsumer {
IOracle public oracle;
constructor(address _oracle) {
oracle = IOracle(_oracle);
}
function getSafePrice(address token) public view returns (uint256 price, bool success) {
try oracle.getPrice(token) returns (uint256 _price) {
// 성공 케이스
return (_price, true);
} catch Error(string memory reason) {
// require/revert("message") 실패
emit PriceFetchFailed(token, reason);
return (0, false);
} catch Panic(uint256 errorCode) {
// assert 실패, 오버플로 등 (errorCode로 구분)
// errorCode: 0x01=assert, 0x11=overflow, 0x12=div by zero
return (0, false);
} catch (bytes memory lowLevelData) {
// 커스텀 에러 또는 기타
return (0, false);
}
}
}
TypeScript try/catch와의 비교:
// TypeScript
async function getSafePrice(token: string): Promise<number | null> {
try {
const price = await oracle.getPrice(token);
return price;
} catch (error) {
if (error instanceof InsufficientDataError) {
console.log('데이터 부족:', error.message);
}
return null;
}
}
// Solidity
function getSafePrice(address token) public returns (uint256) {
try oracle.getPrice(token) returns (uint256 price) {
return price;
} catch {
return 0;
}
}
주요 차이: Solidity의 try/catch는 외부 함수 호출에만 사용 가능하다. 내부 함수 호출이나 산술 연산에는 사용할 수 없다.
완전한 예제: 이벤트와 에러가 있는 NFT 마켓플레이스
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC721 {
function ownerOf(uint256 tokenId) external view returns (address);
function transferFrom(address from, address to, uint256 tokenId) external;
function getApproved(uint256 tokenId) external view returns (address);
function isApprovedForAll(address owner, address operator) external view returns (bool);
}
/// @title NFT 마켓플레이스 컨트랙트
contract NFTMarketplace {
// ============ 커스텀 에러 ============
error NotTokenOwner(address caller, address owner, uint256 tokenId);
error NotApproved(uint256 tokenId);
error ListingNotFound(address nft, uint256 tokenId);
error ListingAlreadyExists(address nft, uint256 tokenId);
error PriceTooLow(uint256 provided, uint256 minimum);
error InsufficientPayment(uint256 sent, uint256 required);
error WithdrawFailed(address recipient, uint256 amount);
error InvalidAddress();
// ============ 구조체 ============
struct Listing {
address seller;
uint256 price;
bool active;
}
// ============ 상태 변수 ============
uint256 public constant MINIMUM_PRICE = 0.001 ether;
uint256 public constant FEE_BPS = 250; // 2.5% (basis points)
address public feeRecipient;
// nft 주소 => tokenId => 판매 정보
mapping(address => mapping(uint256 => Listing)) public listings;
// 판매자별 미정산 수익
mapping(address => uint256) public proceeds;
// ============ 이벤트 ============
event Listed(
address indexed nft,
uint256 indexed tokenId,
address indexed seller,
uint256 price
);
event Sold(
address indexed nft,
uint256 indexed tokenId,
address indexed buyer,
address seller,
uint256 price
);
event Cancelled(
address indexed nft,
uint256 indexed tokenId,
address indexed seller
);
event PriceUpdated(
address indexed nft,
uint256 indexed tokenId,
uint256 oldPrice,
uint256 newPrice
);
event ProceedsWithdrawn(address indexed seller, uint256 amount);
// ============ 생성자 ============
constructor(address _feeRecipient) {
if (_feeRecipient == address(0)) revert InvalidAddress();
feeRecipient = _feeRecipient;
}
// ============ 판매 등록 ============
function listItem(
address nft,
uint256 tokenId,
uint256 price
) external {
if (nft == address(0)) revert InvalidAddress();
if (price < MINIMUM_PRICE) revert PriceTooLow(price, MINIMUM_PRICE);
IERC721 token = IERC721(nft);
// 소유자 확인
address tokenOwner = token.ownerOf(tokenId);
if (msg.sender != tokenOwner) {
revert NotTokenOwner(msg.sender, tokenOwner, tokenId);
}
// 승인 확인 (마켓플레이스가 전송 권한 필요)
bool approved = token.getApproved(tokenId) == address(this)
|| token.isApprovedForAll(msg.sender, address(this));
if (!approved) revert NotApproved(tokenId);
// 중복 등록 확인
if (listings[nft][tokenId].active) {
revert ListingAlreadyExists(nft, tokenId);
}
listings[nft][tokenId] = Listing({
seller: msg.sender,
price: price,
active: true
});
emit Listed(nft, tokenId, msg.sender, price);
}
// ============ 구매 ============
function buyItem(address nft, uint256 tokenId) external payable {
Listing storage listing = listings[nft][tokenId];
if (!listing.active) revert ListingNotFound(nft, tokenId);
if (msg.value < listing.price) {
revert InsufficientPayment(msg.value, listing.price);
}
address seller = listing.seller;
uint256 price = listing.price;
// 상태 먼저 변경 (재진입 공격 방지)
listing.active = false;
// 수수료 계산
uint256 fee = (price * FEE_BPS) / 10000;
uint256 sellerProceeds = price - fee;
// 수익 기록
proceeds[seller] += sellerProceeds;
proceeds[feeRecipient] += fee;
// NFT 전송 (외부 호출 - 상태 변경 후)
try IERC721(nft).transferFrom(seller, msg.sender, tokenId) {
emit Sold(nft, tokenId, msg.sender, seller, price);
} catch {
// 전송 실패 시 상태 복구
listing.active = true;
proceeds[seller] -= sellerProceeds;
proceeds[feeRecipient] -= fee;
revert("NFT transfer failed");
}
// 초과 지불금 환불
uint256 excess = msg.value - price;
if (excess > 0) {
(bool refundSuccess, ) = payable(msg.sender).call{value: excess}("");
require(refundSuccess, "Refund failed");
}
}
// ============ 등록 취소 ============
function cancelListing(address nft, uint256 tokenId) external {
Listing storage listing = listings[nft][tokenId];
if (!listing.active) revert ListingNotFound(nft, tokenId);
if (listing.seller != msg.sender) {
revert NotTokenOwner(msg.sender, listing.seller, tokenId);
}
listing.active = false;
emit Cancelled(nft, tokenId, msg.sender);
}
// ============ 가격 수정 ============
function updatePrice(address nft, uint256 tokenId, uint256 newPrice) external {
Listing storage listing = listings[nft][tokenId];
if (!listing.active) revert ListingNotFound(nft, tokenId);
if (listing.seller != msg.sender) {
revert NotTokenOwner(msg.sender, listing.seller, tokenId);
}
if (newPrice < MINIMUM_PRICE) revert PriceTooLow(newPrice, MINIMUM_PRICE);
uint256 oldPrice = listing.price;
listing.price = newPrice;
emit PriceUpdated(nft, tokenId, oldPrice, newPrice);
}
// ============ 수익 출금 ============
function withdrawProceeds() external {
uint256 amount = proceeds[msg.sender];
require(amount > 0, "No proceeds to withdraw");
// 재진입 방지: 상태 먼저 변경
proceeds[msg.sender] = 0;
(bool success, ) = payable(msg.sender).call{value: amount}("");
if (!success) {
proceeds[msg.sender] = amount; // 실패 시 복구
revert WithdrawFailed(msg.sender, amount);
}
emit ProceedsWithdrawn(msg.sender, amount);
}
// ============ 조회 함수 ============
function getListing(
address nft,
uint256 tokenId
) external view returns (Listing memory) {
return listings[nft][tokenId];
}
function getProceeds(address seller) external view returns (uint256) {
return proceeds[seller];
}
}
이 컨트랙트는 다음을 보여준다:
- 커스텀 에러: 파라미터가 있는 타입 안전한 에러
- 이벤트 설계: indexed로 필터링 가능한 구조
- try/catch: 외부 컨트랙트 호출 실패 처리
- Checks-Effects-Interactions: 외부 호출 전 상태 변경
정리
- mapping은 O(1) 해시 테이블이지만 순회 불가, 기본값 존재
- 이벤트는 블록체인 로그에 저렴하게 기록하고 외부에서 구독 가능
- require는 입력 검증, assert는 내부 불변식, revert는 복잡한 분기
- 커스텀 에러는 문자열 에러보다 가스 효율적이고 타입 안전함
- try/catch는 외부 컨트랙트 호출에만 사용 가능
Chapter 12: Foundry — Solidity 개발 도구
Foundry란 무엇인가
Foundry는 Rust로 작성된 고성능 Ethereum 스마트 컨트랙트 개발 프레임워크다. 2021년 Paradigm이 개발했으며, 현재 전문 Solidity 개발자들이 가장 많이 선택하는 도구다.
Foundry의 가장 큰 특징은 테스트를 Solidity로 작성한다는 점이다. JavaScript/TypeScript로 테스트를 작성하는 Hardhat과 달리, Foundry는 Solidity 컨트랙트로 테스트를 작성한다. 처음에는 낯설게 느껴지지만, 타입 안전성과 실행 속도에서 큰 이점이 있다.
Hardhat vs Foundry 비교
| 항목 | Hardhat | Foundry |
|---|---|---|
| 언어 | JavaScript/TypeScript | Rust |
| 테스트 언어 | JavaScript/TypeScript | Solidity |
| 속도 | 보통 | 매우 빠름 |
| 플러그인 생태계 | 풍부 | 성장 중 |
| 퍼즈 테스트 | 별도 도구 필요 | 기본 내장 |
| 가스 리포트 | 플러그인 필요 | 기본 내장 |
| 학습 곡선 | 낮음 (JS 친숙) | 중간 (Solidity 테스트) |
| 설정 | hardhat.config.ts | foundry.toml |
| 패키지 관리 | npm | git 서브모듈 |
Node.js 개발자 관점:
Hardhat은 NestJS 프로젝트처럼 JavaScript 생태계에 완전히 녹아있다. npm으로 의존성을 관리하고, Jest/Mocha 스타일로 테스트를 작성한다. 이미 TypeScript를 잘 안다면 빠르게 시작할 수 있다.
Foundry는 Go나 Rust 도구체인에 더 가깝다. 빠르고, 자기완결적이며, Solidity 세계에 집중한다. 실무에서는 두 도구를 함께 사용하는 경우도 많다(Hardhat으로 스크립트, Foundry로 테스트).
Foundry의 4가지 도구
Foundry는 단일 바이너리가 아니라 4개의 전문 도구로 구성된다. Node.js 생태계의 도구들과 비교해보자.
forge — 컴파일러 + 테스트 러너 + 배포 도구
forge build # 컴파일
forge test # 테스트 실행
forge script # 배포 스크립트 실행
forge create # 컨트랙트 직접 배포
forge fmt # 코드 포맷
forge coverage # 커버리지 리포트
forge snapshot # 가스 스냅샷
forge inspect # 컨트랙트 ABI, 바이트코드 등 조회
Node.js 비유: tsc (컴파일) + jest (테스트) + 배포 스크립트가 하나로 합쳐진 것.
cast — EVM 상호작용 CLI
배포된 컨트랙트와 블록체인 데이터를 조회하고 트랜잭션을 전송하는 커맨드라인 도구.
cast call <주소> "balanceOf(address)(uint256)" <지갑주소>
cast send <주소> "transfer(address,uint256)" <to> <amount> --private-key <key>
cast balance <주소>
cast block latest
cast tx <txhash>
cast abi-encode "transfer(address,uint256)" <to> <amount>
cast to-wei 1.5 ether
cast from-wei 1500000000000000000
cast keccak "hello"
Node.js 비유: curl + ethers.js 커맨드라인 버전. Hardhat의 npx hardhat console과 유사하지만 더 강력.
anvil — 로컬 테스트 노드
개발용 로컬 Ethereum 노드. 무한 ETH를 가진 테스트 계정들을 제공한다.
anvil # 기본 실행 (포트 8545)
anvil --port 8546 # 포트 변경
anvil --fork-url <rpc-url> # 메인넷 포크 (실제 상태 복제)
anvil --chain-id 1337 # 체인 ID 설정
anvil --block-time 12 # 12초마다 블록 생성
Node.js 비유: jest의 --testEnvironment 또는 로컬 개발용 SQLite 같은 개념. Hardhat의 Hardhat Network와 동일한 역할이지만 독립 프로세스로 실행.
anvil 실행 시 출력:
Available Accounts
==================
(0) 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000 ETH)
(1) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 (10000 ETH)
(2) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC (10000 ETH)
Private Keys
==================
(0) 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
(1) 0x59c6995e998f97a5a0044966f094538cb70b0d2f517b7d49da0d4d5d18258f6d
Listening on 127.0.0.1:8545
chisel — Solidity REPL
Solidity 코드를 대화형으로 실행하는 셸. 빠른 프로토타이핑과 학습에 유용.
chisel
# 프롬프트가 나오면 Solidity 코드를 바로 입력
➜ uint256 x = 100;
➜ uint256 y = 200;
➜ x + y
Type: uint256
Hex: 0x12c
Decimal: 300
➜ address(0x1234).balance
Type: uint256
Decimal: 0
➜ keccak256(abi.encodePacked("hello"))
Type: bytes32
Hex: 0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8
Node.js 비유: node REPL 또는 TypeScript의 ts-node 인터랙티브 모드.
설치 확인
설치 방법
# foundryup 설치 (Foundry 버전 관리자)
curl -L https://foundry.paradigm.xyz | bash
# 셸 재시작 후
foundryup
설치 확인
forge --version
# forge 0.2.0 (xxxxxxx 20xx-xx-xx T00:00:00.000000000Z)
cast --version
# cast 0.2.0 (xxxxxxx 20xx-xx-xx T00:00:00.000000000Z)
anvil --version
# anvil 0.2.0 (xxxxxxx 20xx-xx-xx T00:00:00.000000000Z)
chisel --version
# chisel 0.2.0 (xxxxxxx 20xx-xx-xx T00:00:00.000000000Z)
업데이트
foundryup # 최신 안정 버전으로 업데이트
foundryup --pr 1234 # 특정 PR 버전 설치
foundryup --branch master # 개발 브랜치 버전
Foundry가 빠른 이유
Rust로 작성된 Foundry는 Node.js 기반 Hardhat보다 테스트 실행 속도가 10~100배 빠른 경우가 많다. 이는 다음 이유들 때문이다:
- EVM을 Rust로 구현 — revm(Rust EVM)을 사용해 네이티브 속도
- 병렬 테스트 실행 — 기본적으로 멀티코어 활용
- JIT 컴파일 없음 — Node.js의 초기화 비용 없음
- Solidity 테스트 — ABI 인코딩/디코딩 오버헤드 없음
실제로 100개의 테스트를 Hardhat은 10초가 걸릴 때, Foundry는 0.5초 이내에 처리하는 경우가 흔하다.
Foundry 도구 체계 정리
Foundry 생태계
├── forge - 빌드/테스트/배포 (메인 도구)
├── cast - 블록체인 상호작용 CLI
├── anvil - 로컬 테스트 노드
└── chisel - Solidity REPL
Node.js 생태계 대응
├── forge build ↔ tsc
├── forge test ↔ jest / mocha
├── forge script ↔ ts-node deploy.ts
├── cast ↔ curl + ethers.js CLI
├── anvil ↔ hardhat node / ganache
└── chisel ↔ node / ts-node (REPL)
다음 챕터에서는 Foundry 프로젝트를 직접 생성하고 구조를 파악한다.
Chapter 12-1: Foundry 프로젝트 구조
forge init으로 프로젝트 생성
# 새 디렉토리에 프로젝트 생성
forge init my-project
cd my-project
# 현재 디렉토리에 초기화
forge init .
# 기존 git 저장소에 초기화 (--force로 덮어쓰기)
forge init --force .
생성 직후 출력:
Initializing /path/to/my-project...
Installing forge-std in /path/to/my-project/lib/forge-std (url: Some("https://github.com/foundry-rs/forge-std"), tag: None)
Initialized forge project
디렉토리 구조
my-project/
├── foundry.toml # 프로젝트 설정 파일
├── .gitignore
├── .gitmodules # git 서브모듈 목록
│
├── src/ # 컨트랙트 소스 (= NestJS의 src/)
│ └── Counter.sol
│
├── test/ # 테스트 파일 (= NestJS의 test/ 또는 *.spec.ts)
│ └── Counter.t.sol # .t.sol 확장자가 관례
│
├── script/ # 배포/운영 스크립트 (= NestJS의 scripts/)
│ └── Counter.s.sol # .s.sol 확장자가 관례
│
└── lib/ # 외부 라이브러리 (= NestJS의 node_modules/)
└── forge-std/ # Foundry 표준 라이브러리 (테스트 헬퍼)
├── src/
│ ├── Test.sol
│ ├── Vm.sol
│ └── ...
└── ...
NestJS 프로젝트와 비교:
| Foundry | NestJS | 역할 |
|---|---|---|
src/ | src/ | 메인 소스 코드 |
test/ | test/ 또는 *.spec.ts | 테스트 |
script/ | scripts/ | 유틸리티 스크립트 |
lib/ | node_modules/ | 외부 의존성 |
foundry.toml | package.json + tsconfig.json | 프로젝트 설정 |
forge-std | @nestjs/testing | 테스트 프레임워크 |
.gitmodules | package-lock.json | 의존성 잠금 |
foundry.toml 설정 파일
foundry.toml은 Foundry 프로젝트의 핵심 설정 파일이다. package.json과 tsconfig.json을 합친 것으로 생각하면 된다.
기본 구조
# foundry.toml
[profile.default]
src = "src" # 컨트랙트 소스 디렉토리
test = "test" # 테스트 디렉토리
script = "script" # 스크립트 디렉토리
out = "out" # 컴파일 출력 디렉토리
libs = ["lib"] # 라이브러리 경로
컴파일러 설정
[profile.default]
solc_version = "0.8.20" # 특정 solc 버전 고정
optimizer = true # 옵티마이저 활성화
optimizer_runs = 200 # 옵티마이저 실행 횟수
# 낮을수록 배포 비용↓, 높을수록 호출 비용↓
via_ir = false # IR 기반 컴파일 (더 강력한 최적화)
evm_version = "paris" # 대상 EVM 버전
optimizer_runs 가이드:
200(기본): 배포와 호출 비용의 균형1: 배포 비용 최소화 (한 번만 배포, 자주 호출 안 하는 컨트랙트)1000000: 호출 비용 최소화 (자주 호출되는 컨트랙트)
테스트 설정
[profile.default]
fuzz_runs = 256 # 퍼즈 테스트 반복 횟수 (기본 256)
verbosity = 0 # 기본 출력 레벨 (CLI에서 -vvv로 오버라이드 가능)
match_test = "" # 특정 테스트만 실행 (정규식)
no_match_test = "" # 특정 테스트 제외
[fuzz]
runs = 1000 # 퍼즈 테스트 실행 횟수 더 높게 설정
max_test_rejects = 65536 # 퍼즈 입력 거부 최대 횟수
seed = "0x1234" # 재현 가능한 테스트를 위한 시드
[invariant]
runs = 256 # 불변식 테스트 실행 횟수
depth = 15 # 각 실행당 함수 호출 깊이
포맷터 설정
[fmt]
line_length = 120 # 한 줄 최대 길이
tab_width = 4 # 들여쓰기 공백 수
bracket_spacing = false # 괄호 내부 공백 여부
int_types = "long" # "long" = uint256, "short" = uint
multiline_func_header = "all" # 함수 헤더 멀티라인 기준
sort_imports = true # import 정렬
RPC 설정
[rpc_endpoints]
mainnet = "https://eth-mainnet.alchemyapi.io/v2/${ALCHEMY_API_KEY}"
sepolia = "https://eth-sepolia.alchemyapi.io/v2/${ALCHEMY_API_KEY}"
polygon = "https://polygon-mainnet.alchemyapi.io/v2/${ALCHEMY_API_KEY}"
localhost = "http://127.0.0.1:8545"
[etherscan]
mainnet = { key = "${ETHERSCAN_API_KEY}" }
sepolia = { key = "${ETHERSCAN_API_KEY}", url = "https://api-sepolia.etherscan.io/api" }
프로필별 설정
NestJS의 NODE_ENV처럼, Foundry도 환경별로 다른 설정을 사용할 수 있다:
# 기본 프로필
[profile.default]
solc_version = "0.8.20"
optimizer = true
optimizer_runs = 200
# CI 환경 (더 엄격한 테스트)
[profile.ci]
fuzz_runs = 10000
verbosity = 4
# 프로덕션 배포 (최적화 극대화)
[profile.production]
optimizer_runs = 1000000
via_ir = true
# 프로필 선택
FOUNDRY_PROFILE=ci forge test
FOUNDRY_PROFILE=production forge build
완성된 foundry.toml 예시
[profile.default]
src = "src"
test = "test"
script = "script"
out = "out"
libs = ["lib"]
solc_version = "0.8.20"
optimizer = true
optimizer_runs = 200
evm_version = "paris"
[profile.default.fuzz]
runs = 256
[profile.ci]
fuzz_runs = 10000
[fmt]
line_length = 120
tab_width = 4
sort_imports = true
[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"
sepolia = "${SEPOLIA_RPC_URL}"
localhost = "http://127.0.0.1:8545"
[etherscan]
mainnet = { key = "${ETHERSCAN_API_KEY}" }
sepolia = { key = "${ETHERSCAN_API_KEY}", url = "https://api-sepolia.etherscan.io/api" }
remappings — 의존성 경로 매핑
remappings는 import 경로의 별칭을 정의한다. npm의 paths (tsconfig.json)와 동일한 개념이다.
remappings.txt
# remappings.txt (루트 디렉토리)
@openzeppelin/=lib/openzeppelin-contracts/
@uniswap/v3-core/=lib/v3-core/
forge-std/=lib/forge-std/src/
foundry.toml에 직접 설정
[profile.default]
remappings = [
"@openzeppelin/=lib/openzeppelin-contracts/",
"forge-std/=lib/forge-std/src/",
"solmate/=lib/solmate/src/",
]
remappings 적용 전후:
// remappings 없을 때 (번거로운 상대 경로)
import "../../lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
// remappings 적용 후 (깔끔한 절대 경로)
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
// TypeScript tsconfig.json paths와 비교
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@common/*": ["./src/common/*"]
}
}
}
// import { UserService } from '@common/user.service';
remappings 자동 감지
# 설치된 라이브러리의 remappings 자동 감지
forge remappings
# 출력 예시:
# ds-test/=lib/forge-std/lib/ds-test/src/
# forge-std/=lib/forge-std/src/
# @openzeppelin/=lib/openzeppelin-contracts/
forge install로 라이브러리 설치
Foundry는 npm 대신 git 서브모듈로 의존성을 관리한다.
기본 설치
# GitHub 저장소 설치
forge install OpenZeppelin/openzeppelin-contracts
# 특정 버전(태그) 설치
forge install OpenZeppelin/openzeppelin-contracts@v5.0.0
# 여러 패키지 동시 설치
forge install OpenZeppelin/openzeppelin-contracts Uniswap/v3-core
# 커밋 해시로 고정
forge install OpenZeppelin/openzeppelin-contracts@a1948c5
자주 사용하는 라이브러리
# OpenZeppelin - 표준 컨트랙트 라이브러리
forge install OpenZeppelin/openzeppelin-contracts
# OpenZeppelin Upgradeable - 업그레이드 가능한 컨트랙트
forge install OpenZeppelin/openzeppelin-contracts-upgradeable
# solmate - 가스 최적화된 컨트랙트 모음
forge install transmissions11/solmate
# Uniswap V3 Core
forge install Uniswap/v3-core
# Chainlink - 오라클
forge install smartcontractkit/chainlink
설치 후 remappings 추가
# OpenZeppelin 설치 후
forge install OpenZeppelin/openzeppelin-contracts
# remappings.txt에 추가
echo "@openzeppelin/=lib/openzeppelin-contracts/" >> remappings.txt
설치 후 디렉토리 구조:
lib/
├── forge-std/ # 기본 설치됨
│ └── src/
│ ├── Test.sol
│ └── ...
└── openzeppelin-contracts/ # 새로 설치
└── contracts/
├── token/
│ ├── ERC20/
│ │ ├── ERC20.sol
│ │ └── extensions/
│ └── ERC721/
├── access/
│ ├── Ownable.sol
│ └── AccessControl.sol
└── ...
의존성 업데이트와 제거
# 특정 라이브러리 업데이트
forge update lib/openzeppelin-contracts
# 모든 라이브러리 업데이트
forge update
# 라이브러리 제거
forge remove openzeppelin-contracts
# 또는
forge remove lib/openzeppelin-contracts
.gitmodules 파일
git 서브모듈로 관리되므로 .gitmodules에 의존성이 기록된다:
[submodule "lib/forge-std"]
path = lib/forge-std
url = https://github.com/foundry-rs/forge-std
branch = v1
[submodule "lib/openzeppelin-contracts"]
path = lib/openzeppelin-contracts
url = https://github.com/OpenZeppelin/openzeppelin-contracts
branch = v5.0.0
새 팀원이 프로젝트를 클론할 때:
git clone --recursive <repo-url>
# 또는
git clone <repo-url>
git submodule update --init --recursive
npm의 npm install에 해당하는 것이 git submodule update --init --recursive다.
초기 파일 내용
forge init 후 생성되는 기본 파일들:
src/Counter.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Counter {
uint256 public number;
function setNumber(uint256 newNumber) public {
number = newNumber;
}
function increment() public {
number++;
}
}
test/Counter.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";
contract CounterTest is Test {
Counter public counter;
function setUp() public {
counter = new Counter();
counter.setNumber(0);
}
function test_Increment() public {
counter.increment();
assertEq(counter.number(), 1);
}
function testFuzz_SetNumber(uint256 x) public {
counter.setNumber(x);
assertEq(counter.number(), x);
}
}
script/Counter.s.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Script, console} from "forge-std/Script.sol";
import {Counter} from "../src/Counter.sol";
contract CounterScript is Script {
Counter public counter;
function setUp() public {}
function run() public {
vm.startBroadcast();
counter = new Counter();
vm.stopBroadcast();
}
}
첫 빌드와 테스트
# 컴파일
forge build
# 출력:
# [⠒] Compiling...
# [⠢] Compiling 24 files with 0.8.20
# [⠆] Solc 0.8.20 finished in 2.34s
# Compiler run successful!
# 테스트 실행
forge test
# 출력:
# [⠒] Compiling...
# No files changed, compilation skipped
#
# Running 2 tests for test/Counter.t.sol:CounterTest
# [PASS] testFuzz_SetNumber(uint256) (runs: 256, μ: 27553, ~: 27553)
# [PASS] test_Increment() (μ: 28334, ~: 28334)
# Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 8.25ms
프로젝트 구조 최종 정리
실무 프로젝트의 완성된 구조:
my-defi-project/
├── foundry.toml
├── remappings.txt
├── .env # 환경 변수 (절대 커밋하지 말 것!)
├── .env.example # 환경 변수 예시 (커밋 OK)
├── .gitignore
├── .gitmodules
│
├── src/
│ ├── interfaces/ # 인터페이스 정의
│ │ ├── IToken.sol
│ │ └── IVault.sol
│ ├── libraries/ # 내부 라이브러리
│ │ └── Math.sol
│ ├── Token.sol # 메인 컨트랙트들
│ └── Vault.sol
│
├── test/
│ ├── unit/ # 단위 테스트
│ │ ├── Token.t.sol
│ │ └── Vault.t.sol
│ ├── integration/ # 통합 테스트
│ │ └── VaultIntegration.t.sol
│ └── mocks/ # 목 컨트랙트
│ └── MockERC20.sol
│
├── script/
│ ├── Deploy.s.sol # 배포 스크립트
│ └── Interactions.s.sol # 상호작용 스크립트
│
├── out/ # 컴파일 산출물 (gitignore)
│ ├── Token.sol/
│ │ └── Token.json # ABI + 바이트코드
│ └── ...
│
└── lib/
├── forge-std/
└── openzeppelin-contracts/
다음 챕터에서는 Foundry로 테스트를 작성하는 방법을 자세히 다룬다.
Chapter 12-2: Foundry 테스트 작성
Solidity로 테스트를 작성한다
Foundry의 가장 독특한 점은 테스트를 Solidity로 작성한다는 것이다. JavaScript 테스트 프레임워크(Jest, Mocha)에 익숙한 Node.js 개발자에게는 처음에 어색하지만, 익숙해지면 타입 안전성과 속도 면에서 큰 이점이 있다.
// Hardhat / Jest 스타일 (TypeScript)
describe("Counter", function () {
let counter: Counter;
beforeEach(async function () {
const Counter = await ethers.getContractFactory("Counter");
counter = await Counter.deploy();
});
it("should increment", async function () {
await counter.increment();
expect(await counter.number()).to.equal(1);
});
});
// Foundry 스타일 (Solidity)
contract CounterTest is Test {
Counter counter;
function setUp() public {
counter = new Counter();
}
function test_Increment() public {
counter.increment();
assertEq(counter.number(), 1);
}
}
Test 컨트랙트 구조
기본 구조
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// forge-std의 Test를 상속 (어서션, 치트코드 포함)
import {Test, console} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";
contract CounterTest is Test {
// 테스트에 사용할 컨트랙트 및 변수
Counter public counter;
address public alice = makeAddr("alice"); // 테스트용 주소 생성
address public bob = makeAddr("bob");
// setUp: 각 테스트 함수 실행 전에 호출 (beforeEach)
function setUp() public {
counter = new Counter();
}
// test_로 시작하는 함수 = 일반 테스트
function test_InitialValue() public view {
assertEq(counter.number(), 0);
}
function test_Increment() public {
counter.increment();
assertEq(counter.number(), 1);
}
function test_SetNumber() public {
counter.setNumber(42);
assertEq(counter.number(), 42);
}
// testFuzz_로 시작하는 함수 = 퍼즈 테스트
function testFuzz_SetNumber(uint256 x) public {
counter.setNumber(x);
assertEq(counter.number(), x);
}
}
NestJS 테스트와 대응:
| Foundry | Jest/NestJS | 역할 |
|---|---|---|
setUp() | beforeEach() | 각 테스트 전 초기화 |
test_XXX() | it('should ...') | 단위 테스트 |
testFuzz_XXX() | 없음 (별도 도구) | 퍼즈 테스트 |
assertEq(a, b) | expect(a).toBe(b) | 동등 어서션 |
console.log() | console.log() | 디버그 출력 |
forge-std/Test.sol | @nestjs/testing | 테스트 프레임워크 |
테스트 파일 명명 규칙
test/
├── Counter.t.sol # 단위 테스트 (.t.sol 확장자)
├── Vault.t.sol
└── integration/
└── VaultFlow.t.sol # 통합 테스트
어서션 (Assertions)
forge-std의 Test 컨트랙트가 제공하는 주요 어서션들:
동등 비교
// assertEq(actual, expected) - 두 값이 같은지
assertEq(counter.number(), 42);
assertEq(token.balanceOf(alice), 1000 * 1e18);
assertEq(contract.owner(), alice);
// 지원 타입: uint256, int256, address, bytes32, string, bytes, bool
assertEq(token.name(), "MyToken");
assertEq(token.symbol(), "MTK");
// 오류 메시지 포함
assertEq(counter.number(), 42, "Counter should be 42");
부등 비교
assertNotEq(a, b); // a != b
assertGt(a, b); // a > b (greater than)
assertGe(a, b); // a >= b (greater or equal)
assertLt(a, b); // a < b (less than)
assertLe(a, b); // a <= b (less or equal)
// 예시
assertGt(token.balanceOf(alice), 0, "Alice should have tokens");
assertLe(fee, maxFee, "Fee should not exceed maximum");
불리언 어서션
assertTrue(condition);
assertFalse(condition);
// 예시
assertTrue(vault.isActive(), "Vault should be active");
assertFalse(token.paused(), "Token should not be paused");
근사값 비교 (부동소수점 대신)
// assertApproxEqAbs(actual, expected, maxDelta) - 절대 오차
assertApproxEqAbs(actual, expected, 1e15); // 0.001 ETH 오차 허용
// assertApproxEqRel(actual, expected, maxPercentDelta) - 상대 오차 (1e18 = 100%)
assertApproxEqRel(actual, expected, 1e16); // 1% 오차 허용
배열 어서션
uint256[] memory expected = new uint256[](3);
expected[0] = 1;
expected[1] = 2;
expected[2] = 3;
assertEq(actual, expected); // 배열 전체 비교
치트코드 (Cheatcodes)
치트코드는 테스트 환경에서만 사용할 수 있는 특수 함수들이다. vm 객체를 통해 접근한다. 블록체인 상태를 자유롭게 조작해 다양한 시나리오를 테스트할 수 있다.
vm.prank() — 다른 주소로 위장
// 다음 트랜잭션 한 번만 다른 주소로 실행
vm.prank(alice);
token.transfer(bob, 100);
// 이 시점부터는 다시 원래 주소
// 여러 트랜잭션을 같은 주소로 실행
vm.startPrank(alice);
token.approve(vault, 1000);
vault.deposit(500);
vm.stopPrank();
NestJS 비유: JWT 토큰을 변경해서 다른 사용자로 API를 호출하는 것. 실제 개인키 없이도 어떤 주소로든 트랜잭션을 보낼 수 있다.
// NestJS 테스트에서 다른 사용자로 요청
const response = await request(app.getHttpServer())
.get('/profile')
.set('Authorization', `Bearer ${aliceToken}`);
// Foundry에서 다른 주소로 호출
vm.prank(alice);
contract.doSomething();
vm.expectRevert() — 에러 검증
// 다음 호출이 revert될 것을 예상
vm.expectRevert("ERC20: insufficient balance");
token.transfer(bob, 999999 ether);
// 커스텀 에러 검증
vm.expectRevert(
abi.encodeWithSelector(InsufficientBalance.selector, alice, 0, 100)
);
token.transfer(bob, 100);
// 에러 타입만 검증 (파라미터 무시)
vm.expectRevert(InsufficientBalance.selector);
token.transfer(bob, 100);
// 빈 revert
vm.expectRevert();
maliciousCall();
Jest 비유:
// Jest
expect(() => service.transfer(bob, 999999)).toThrow('insufficient balance');
vm.deal() — ETH 잔액 설정
// alice에게 10 ETH 부여
vm.deal(alice, 10 ether);
// 컨트랙트에 ETH 부여
vm.deal(address(vault), 100 ether);
// 잔액 확인
assertEq(alice.balance, 10 ether);
vm.warp() — 시간 조작
// 현재 블록 타임스탬프 변경
vm.warp(block.timestamp + 1 days);
vm.warp(block.timestamp + 365 days);
// 특정 시점으로 이동
vm.warp(1700000000); // Unix 타임스탬프
// 블록 번호 변경
vm.roll(block.number + 100);
사용 예시 - 타임락 테스트:
function test_TimelockExpiry() public {
// 출금 요청
vault.requestWithdrawal(1 ether);
// 타임락 전에는 불가
vm.expectRevert("Timelock not expired");
vault.executeWithdrawal();
// 24시간 후
vm.warp(block.timestamp + 1 days);
// 이제 가능
vault.executeWithdrawal();
assertEq(alice.balance, 1 ether);
}
vm.mockCall() — 외부 호출 모킹
// 특정 주소의 특정 함수 호출을 모킹
address mockOracle = address(0x1234);
vm.mockCall(
mockOracle,
abi.encodeWithSelector(IOracle.getPrice.selector, address(token)),
abi.encode(2000 * 1e8) // $2000 반환
);
// 이후 mockOracle.getPrice(token) 호출은 2000 * 1e8 반환
uint256 price = IOracle(mockOracle).getPrice(address(token));
assertEq(price, 2000 * 1e8);
vm.expectEmit() — 이벤트 검증
// 이벤트가 발생할 것을 예상
// expectEmit(checkTopic1, checkTopic2, checkTopic3, checkData)
vm.expectEmit(true, true, false, true);
emit Transfer(alice, bob, 100); // 예상하는 이벤트
// 실제 호출 (이 호출에서 위 이벤트가 발생해야 함)
token.transfer(bob, 100);
vm.label() — 주소에 이름 붙이기
vm.label(alice, "Alice");
vm.label(address(token), "MyToken");
// 테스트 실패 시 주소 대신 이름으로 표시
기타 유용한 치트코드
// 환경 변수 읽기
string memory key = vm.envString("PRIVATE_KEY");
// storage 직접 읽기/쓰기 (private 변수도 가능!)
bytes32 value = vm.load(address(contract), bytes32(0)); // slot 0 읽기
vm.store(address(contract), bytes32(0), bytes32(uint256(42))); // 값 쓰기
// 특정 블록에서 포크 (메인넷 상태 복제)
vm.createFork("mainnet", 18000000);
// 가스 측정
uint256 gasBefore = gasleft();
contract.expensiveFunction();
uint256 gasUsed = gasBefore - gasleft();
퍼즈 테스트 (Fuzz Testing)
퍼즈 테스트는 Foundry가 자동으로 다양한 입력값을 생성해 테스트하는 기능이다. 개발자가 미처 생각하지 못한 엣지 케이스를 자동으로 찾아준다.
기본 퍼즈 테스트
// testFuzz_로 시작하는 함수가 퍼즈 테스트
function testFuzz_Transfer(
address to,
uint256 amount
) public {
// Foundry가 to와 amount에 다양한 값을 자동으로 넣어서 실행
vm.assume(to != address(0)); // 제약 조건
vm.assume(amount <= 1000 ether);
deal(address(token), alice, amount);
vm.prank(alice);
token.transfer(to, amount);
assertEq(token.balanceOf(to), amount);
}
foundry.toml의 fuzz.runs = 256에 따라 256가지 다른 입력 조합으로 테스트한다.
vm.assume() — 입력값 제약
function testFuzz_Deposit(uint256 amount) public {
// 조건이 false인 입력은 건너뜀 (해당 실행을 카운트하지 않음)
vm.assume(amount > 0);
vm.assume(amount <= type(uint128).max); // 오버플로 방지
vm.deal(alice, amount);
vm.prank(alice);
vault.deposit{value: amount}();
assertEq(vault.balanceOf(alice), amount);
}
bound() — 범위로 입력값 제한
vm.assume()은 조건을 만족하지 못하면 해당 실행을 건너뛴다. bound()는 입력값을 범위 내로 조정해 거부율을 줄인다:
function testFuzz_PartialWithdraw(uint256 depositAmount, uint256 withdrawAmount) public {
// 범위 내로 조정 (건너뛰지 않고 값을 변환)
depositAmount = bound(depositAmount, 1, 100 ether);
withdrawAmount = bound(withdrawAmount, 1, depositAmount);
vm.deal(alice, depositAmount);
vm.startPrank(alice);
vault.deposit{value: depositAmount}();
vault.withdraw(withdrawAmount);
vm.stopPrank();
assertEq(vault.balanceOf(alice), depositAmount - withdrawAmount);
}
forge test 출력 읽는 법
기본 출력
forge test
Running 5 tests for test/Token.t.sol:TokenTest
[PASS] test_InitialSupply() (gas: 12345)
[PASS] test_Transfer() (gas: 45678)
[FAIL. Counterexample: calldata=0x... args=[0x0000...0000]] testFuzz_Transfer(address,uint256)
[PASS] test_Approval() (gas: 23456)
[PASS] testFuzz_Mint(uint256) (runs: 256, μ: 34567, ~: 34567)
Test result: FAILED. 4 passed; 1 failed; finished in 123.45ms
gas: 12345— 해당 테스트의 가스 사용량runs: 256— 퍼즈 테스트 실행 횟수μ: 34567— 가스 사용량 평균~: 34567— 가스 사용량 중앙값
-v 플래그로 상세 출력
forge test -v # 실패한 테스트의 로그
forge test -vv # 모든 테스트의 로그
forge test -vvv # 스택 트레이스 포함
forge test -vvvv # 전체 콜 트레이스
forge test -vvv
[FAIL. Counterexample: calldata=... args=[0]]
testFuzz_Transfer(uint256)
Traces:
[45678] TokenTest::testFuzz_Transfer(0)
├─ [0] VM::assume(false) <-- vm.assume이 실패하면 아닌데...
├─ [12345] Token::transfer(0xalice, 0)
│ └─ ← revert: "Amount must be positive"
└─ ← [Revert]
Error: Amount must be positive
스택 트레이스를 통해 어떤 순서로 함수가 호출되고 어디서 실패했는지 파악할 수 있다.
특정 테스트만 실행
# 테스트 함수명으로 필터링
forge test --match-test test_Transfer
# 파일명으로 필터링
forge test --match-path test/Token.t.sol
# 컨트랙트명으로 필터링
forge test --match-contract TokenTest
# 가스 리포트 출력
forge test --gas-report
전체 테스트 예제
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
// 테스트 대상 컨트랙트
contract SimpleToken {
error InsufficientBalance(address from, uint256 available, uint256 required);
error InvalidRecipient();
event Transfer(address indexed from, address indexed to, uint256 amount);
event Mint(address indexed to, uint256 amount);
string public name;
string public symbol;
uint256 public totalSupply;
address public owner;
mapping(address => uint256) public balanceOf;
constructor(string memory _name, string memory _symbol, uint256 initialSupply) {
name = _name;
symbol = _symbol;
owner = msg.sender;
_mint(msg.sender, initialSupply);
}
function transfer(address to, uint256 amount) external returns (bool) {
if (to == address(0)) revert InvalidRecipient();
if (balanceOf[msg.sender] < amount) {
revert InsufficientBalance(msg.sender, balanceOf[msg.sender], amount);
}
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function mint(address to, uint256 amount) external {
require(msg.sender == owner, "Not owner");
_mint(to, amount);
}
function _mint(address to, uint256 amount) internal {
totalSupply += amount;
balanceOf[to] += amount;
emit Mint(to, amount);
}
}
// 테스트 컨트랙트
contract SimpleTokenTest is Test {
SimpleToken public token;
address public owner = makeAddr("owner");
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
uint256 public constant INITIAL_SUPPLY = 1_000_000 * 1e18;
// 각 테스트 전 실행
function setUp() public {
vm.prank(owner);
token = new SimpleToken("SimpleToken", "STK", INITIAL_SUPPLY);
}
// ============ 초기 상태 테스트 ============
function test_InitialState() public view {
assertEq(token.name(), "SimpleToken");
assertEq(token.symbol(), "STK");
assertEq(token.totalSupply(), INITIAL_SUPPLY);
assertEq(token.owner(), owner);
assertEq(token.balanceOf(owner), INITIAL_SUPPLY);
}
// ============ 전송 테스트 ============
function test_Transfer() public {
uint256 amount = 100 * 1e18;
// owner -> alice 전송
vm.prank(owner);
token.transfer(alice, amount);
assertEq(token.balanceOf(owner), INITIAL_SUPPLY - amount);
assertEq(token.balanceOf(alice), amount);
assertEq(token.totalSupply(), INITIAL_SUPPLY); // 총 공급량 불변
}
function test_Transfer_EmitsEvent() public {
uint256 amount = 100 * 1e18;
// 이벤트 검증
vm.expectEmit(true, true, false, true);
emit SimpleToken.Transfer(owner, alice, amount);
vm.prank(owner);
token.transfer(alice, amount);
}
function test_Transfer_RevertOnInsufficientBalance() public {
uint256 amount = INITIAL_SUPPLY + 1;
vm.expectRevert(
abi.encodeWithSelector(
SimpleToken.InsufficientBalance.selector,
owner,
INITIAL_SUPPLY,
amount
)
);
vm.prank(owner);
token.transfer(alice, amount);
}
function test_Transfer_RevertOnZeroAddress() public {
vm.expectRevert(SimpleToken.InvalidRecipient.selector);
vm.prank(owner);
token.transfer(address(0), 100);
}
// ============ 민팅 테스트 ============
function test_Mint_OnlyOwner() public {
uint256 mintAmount = 500 * 1e18;
vm.prank(owner);
token.mint(alice, mintAmount);
assertEq(token.balanceOf(alice), mintAmount);
assertEq(token.totalSupply(), INITIAL_SUPPLY + mintAmount);
}
function test_Mint_RevertIfNotOwner() public {
vm.expectRevert("Not owner");
vm.prank(alice);
token.mint(alice, 100);
}
// ============ 퍼즈 테스트 ============
function testFuzz_Transfer(address to, uint256 amount) public {
vm.assume(to != address(0));
vm.assume(to != owner); // 같은 주소면 잔액 계산이 복잡해짐
amount = bound(amount, 1, INITIAL_SUPPLY);
vm.prank(owner);
token.transfer(to, amount);
assertEq(token.balanceOf(to), amount);
assertEq(token.balanceOf(owner), INITIAL_SUPPLY - amount);
// 불변식: 총 공급량은 절대 변하지 않음
assertEq(token.totalSupply(), INITIAL_SUPPLY);
}
function testFuzz_Mint(address to, uint256 amount) public {
vm.assume(to != address(0));
amount = bound(amount, 1, type(uint128).max); // 오버플로 방지
uint256 supplyBefore = token.totalSupply();
vm.prank(owner);
token.mint(to, amount);
assertEq(token.totalSupply(), supplyBefore + amount);
assertEq(token.balanceOf(to), amount);
}
// ============ 디버깅 ============
function test_Debug() public view {
// console.log는 forge test -vv 이상에서 출력
console.log("Owner:", owner);
console.log("Initial supply:", INITIAL_SUPPLY);
console.log("Balance:", token.balanceOf(owner));
// console.logBytes32, console.logAddress 등도 사용 가능
}
}
테스트 실행
# 전체 테스트
forge test
# 상세 출력
forge test -vvv
# 가스 리포트
forge test --gas-report
# 커버리지 확인
forge coverage
# 특정 테스트만
forge test --match-test test_Transfer -vvv
커버리지 출력 예시:
| File | % Lines | % Statements | % Branches | % Funcs |
|---------------------|----------|--------------|------------|----------|
| src/SimpleToken.sol | 100.00% | 100.00% | 87.50% | 100.00% |
다음 챕터에서는 배포 스크립트와 실제 배포 과정을 다룬다.
Chapter 12-3: Foundry 배포
배포 스크립트 (Script 컨트랙트)
Foundry의 배포 스크립트는 Solidity로 작성한다. Hardhat의 deploy.ts처럼 JavaScript로 작성하지 않고, Script 컨트랙트를 상속해서 Solidity로 배포 로직을 작성한다.
// script/Deploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console} from "forge-std/Script.sol";
import {SimpleToken} from "../src/SimpleToken.sol";
contract DeploySimpleToken is Script {
// setUp은 선택사항
function setUp() public {}
// run()이 실제 실행되는 진입점
function run() external returns (SimpleToken token) {
// 환경 변수에서 배포자 개인키 읽기
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
address deployer = vm.addr(deployerPrivateKey);
console.log("Deploying with address:", deployer);
console.log("Deployer balance:", deployer.balance);
// startBroadcast ~ stopBroadcast 사이의 트랜잭션만 실제 전송
vm.startBroadcast(deployerPrivateKey);
token = new SimpleToken(
"SimpleToken",
"STK",
1_000_000 * 1e18
);
vm.stopBroadcast();
console.log("SimpleToken deployed at:", address(token));
console.log("Total supply:", token.totalSupply());
return token;
}
}
vm.startBroadcast / vm.stopBroadcast
이 두 함수 사이에 있는 트랜잭션만 실제 블록체인에 전송된다. 그 외 코드는 로컬에서 시뮬레이션만 된다.
function run() external {
// 이 부분은 로컬 시뮬레이션만 (트랜잭션 없음)
address deployer = vm.addr(privateKey);
console.log("Simulating deployment...");
vm.startBroadcast(privateKey);
// 이 부분만 실제 트랜잭션으로 전송
Token token = new Token();
token.mint(deployer, 1000 * 1e18);
vm.stopBroadcast();
// 다시 로컬 시뮬레이션
console.log("Deployed at:", address(token));
}
복잡한 배포 스크립트
// script/DeploySystem.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console} from "forge-std/Script.sol";
import {MyToken} from "../src/MyToken.sol";
import {Vault} from "../src/Vault.sol";
contract DeploySystem is Script {
struct DeployedContracts {
address token;
address vault;
}
function run() external returns (DeployedContracts memory deployed) {
uint256 deployerKey = vm.envUint("PRIVATE_KEY");
address deployer = vm.addr(deployerKey);
address treasury = vm.envAddress("TREASURY_ADDRESS");
console.log("=== Deployment Start ===");
console.log("Deployer:", deployer);
console.log("Treasury:", treasury);
vm.startBroadcast(deployerKey);
// 1. 토큰 배포
MyToken token = new MyToken("MyToken", "MTK");
console.log("Token:", address(token));
// 2. Vault 배포 (토큰 주소 필요)
Vault vault = new Vault(address(token), treasury);
console.log("Vault:", address(vault));
// 3. 초기 설정
token.grantRole(token.MINTER_ROLE(), address(vault));
vault.setDepositLimit(10_000 * 1e18);
vm.stopBroadcast();
deployed = DeployedContracts({
token: address(token),
vault: address(vault)
});
console.log("=== Deployment Complete ===");
}
}
.env 파일 설정
# .env (절대 git에 커밋하지 말 것!)
PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
TREASURY_ADDRESS=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
ETHERSCAN_API_KEY=YOUR_ETHERSCAN_KEY
SEPOLIA_RPC_URL=https://eth-sepolia.alchemyapi.io/v2/YOUR_KEY
MAINNET_RPC_URL=https://eth-mainnet.alchemyapi.io/v2/YOUR_KEY
# .env.example (커밋 OK, 값은 빈칸)
PRIVATE_KEY=
TREASURY_ADDRESS=
ETHERSCAN_API_KEY=
SEPOLIA_RPC_URL=
MAINNET_RPC_URL=
Anvil로 로컬 노드 실행
배포 전에 로컬 환경에서 먼저 테스트한다.
# 기본 로컬 노드 실행
anvil
# 출력:
# _ _
# (_) | |
# __ _ _ __ __ __ _ | |
# / _` | | '_ \ \ \ / /| | | |
# | (_| | | | | | \ V / | | | |
# \__,_| |_| |_| \_/ |_| |_|
#
# Available Accounts
# ==================
# (0) 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000 ETH)
# (1) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 (10000 ETH)
# (2) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC (10000 ETH)
#
# Private Keys
# ==================
# (0) 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
# (1) 0x59c6995e998f97a5a0044966f094538cb70b0d2f517b7d49da0d4d5d18258f6d
#
# Listening on 127.0.0.1:8545
Anvil 옵션
# 포트 변경
anvil --port 8546
# 계정 수와 잔액 설정
anvil --accounts 20 --balance 1000
# 블록 시간 설정 (기본: 즉시 채굴)
anvil --block-time 12
# 체인 ID 설정
anvil --chain-id 31337
# 메인넷 포크 (실제 상태 복제)
anvil --fork-url $MAINNET_RPC_URL --fork-block-number 18000000
# 상태 저장 (재시작 시 복원)
anvil --state ./anvil-state.json
메인넷 포크의 강점: 실제 메인넷의 토큰, DEX, 프로토콜 상태를 그대로 복제해서 로컬에서 테스트할 수 있다. 예를 들어 Uniswap V3, Aave, Compound 등과의 상호작용을 실제 배포 없이 테스트 가능하다.
forge script로 배포
로컬(Anvil)에 배포
# 터미널 1: Anvil 실행
anvil
# 터미널 2: 배포
forge script script/Deploy.s.sol:DeploySimpleToken \
--rpc-url http://127.0.0.1:8545 \
--broadcast \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
-vvvv
테스트넷(Sepolia)에 배포
# .env 로드
source .env
# 시뮬레이션만 (--broadcast 없음)
forge script script/Deploy.s.sol:DeploySimpleToken \
--rpc-url $SEPOLIA_RPC_URL \
-vvv
# 실제 배포 (--broadcast 추가)
forge script script/Deploy.s.sol:DeploySimpleToken \
--rpc-url $SEPOLIA_RPC_URL \
--broadcast \
--verify \ # Etherscan 검증
--etherscan-api-key $ETHERSCAN_API_KEY \
-vvvv
배포 결과 파일
배포 후 broadcast/ 디렉토리에 트랜잭션 정보가 저장된다:
broadcast/
└── Deploy.s.sol/
├── 31337/ # chain ID
│ └── run-latest.json # 가장 최근 실행 결과
└── 11155111/ # Sepolia chain ID
└── run-latest.json
// broadcast/Deploy.s.sol/31337/run-latest.json
{
"transactions": [
{
"hash": "0x...",
"contractName": "SimpleToken",
"contractAddress": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
"transactionType": "CREATE",
"arguments": ["SimpleToken", "STK", "1000000000000000000000000"]
}
],
"receipts": [...],
"timestamp": 1700000000
}
forge create로 직접 배포
스크립트 없이 CLI로 바로 배포할 수 있다. 간단한 컨트랙트에 유용하다.
# 기본 배포
forge create src/SimpleToken.sol:SimpleToken \
--constructor-args "SimpleToken" "STK" 1000000000000000000000000 \
--rpc-url http://127.0.0.1:8545 \
--private-key $PRIVATE_KEY
# 출력:
# Deployer: 0xf39Fd6...
# Deployed to: 0x5FbDB2...
# Transaction hash: 0xabc123...
# Etherscan 검증 포함
forge create src/SimpleToken.sol:SimpleToken \
--constructor-args "SimpleToken" "STK" 1000000000000000000000000 \
--rpc-url $SEPOLIA_RPC_URL \
--private-key $PRIVATE_KEY \
--verify \
--etherscan-api-key $ETHERSCAN_API_KEY
cast로 컨트랙트 상호작용
배포 후 cast로 컨트랙트와 상호작용할 수 있다.
읽기 (call — 트랜잭션 없음)
# 함수 호출: "함수명(파라미터타입)(반환타입)"
cast call $TOKEN_ADDRESS "name()(string)" \
--rpc-url http://127.0.0.1:8545
# 출력: SimpleToken
cast call $TOKEN_ADDRESS "balanceOf(address)(uint256)" \
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 \
--rpc-url http://127.0.0.1:8545
# 출력: 1000000000000000000000000
cast call $TOKEN_ADDRESS "totalSupply()(uint256)" \
--rpc-url http://127.0.0.1:8545
쓰기 (send — 트랜잭션 발생)
# 전송 트랜잭션
cast send $TOKEN_ADDRESS \
"transfer(address,uint256)" \
0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
1000000000000000000 \
--rpc-url http://127.0.0.1:8545 \
--private-key $PRIVATE_KEY
# ETH와 함께 전송 (payable 함수)
cast send $VAULT_ADDRESS \
"deposit()" \
--value 1ether \
--rpc-url http://127.0.0.1:8545 \
--private-key $PRIVATE_KEY
유틸리티 명령어
# ETH 잔액 조회
cast balance 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 \
--rpc-url http://127.0.0.1:8545
# 단위 변환
cast to-wei 1.5 ether # 1500000000000000000
cast from-wei 1500000000000000000 # 1.5 (ETH)
cast to-wei 100 gwei # 100000000000
# 해시 계산
cast keccak "transfer(address,uint256)"
# 0xa9059cbb2ab09eb219583f4a59a5d0623ade346d962bcd4e46b11da047c9049b
# 함수 선택자 (처음 4바이트)
cast sig "transfer(address,uint256)"
# 0xa9059cbb
# ABI 인코딩
cast abi-encode "transfer(address,uint256)" \
0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
1000000000000000000
# 블록 정보
cast block latest --rpc-url http://127.0.0.1:8545
cast block 100 --rpc-url http://127.0.0.1:8545
# 트랜잭션 정보
cast tx 0xabc123... --rpc-url http://127.0.0.1:8545
# 트랜잭션 영수증
cast receipt 0xabc123... --rpc-url http://127.0.0.1:8545
전체 배포 워크플로
1단계: 로컬 테스트
# 테스트 통과 확인
forge test -vvv
# 가스 리포트
forge test --gas-report
2단계: 로컬 배포 테스트
# 터미널 1
anvil
# 터미널 2
forge script script/Deploy.s.sol \
--rpc-url http://127.0.0.1:8545 \
--broadcast \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
# 배포된 주소 확인 후 상호작용 테스트
export TOKEN=0x5FbDB2315678afecb367f032d93F642f64180aa3
cast call $TOKEN "totalSupply()(uint256)" --rpc-url http://127.0.0.1:8545
3단계: 테스트넷 배포
source .env
# 먼저 시뮬레이션
forge script script/Deploy.s.sol \
--rpc-url $SEPOLIA_RPC_URL \
-vvv
# 실제 배포
forge script script/Deploy.s.sol \
--rpc-url $SEPOLIA_RPC_URL \
--broadcast \
--verify \
--etherscan-api-key $ETHERSCAN_API_KEY \
-vvvv
4단계: 검증 확인
# Etherscan에서 컨트랙트 검증 상태 확인
# https://sepolia.etherscan.io/address/<CONTRACT_ADDRESS>
# 또는 cast로 확인
cast etherscan-source $CONTRACT_ADDRESS \
--chain sepolia \
--etherscan-api-key $ETHERSCAN_API_KEY
Makefile로 워크플로 자동화
# Makefile
.PHONY: build test deploy-local deploy-sepolia
include .env
build:
forge build
test:
forge test -vvv
test-gas:
forge test --gas-report
deploy-local:
forge script script/Deploy.s.sol \
--rpc-url http://127.0.0.1:8545 \
--broadcast \
--private-key $(PRIVATE_KEY)
deploy-sepolia:
forge script script/Deploy.s.sol \
--rpc-url $(SEPOLIA_RPC_URL) \
--broadcast \
--verify \
--etherscan-api-key $(ETHERSCAN_API_KEY) \
-vvvv
verify:
forge verify-contract \
$(CONTRACT_ADDRESS) \
src/SimpleToken.sol:SimpleToken \
--chain sepolia \
--etherscan-api-key $(ETHERSCAN_API_KEY)
anvil:
anvil --chain-id 31337
# 사용법
make build
make test
make deploy-local
make deploy-sepolia
정리
Foundry 배포 워크플로:
forge test— 테스트 통과 확인anvil실행 — 로컬 노드 시작forge script --broadcast(로컬) — 로컬 배포 및 검증forge script --broadcast(테스트넷) — 테스트넷 배포cast call/send— 배포된 컨트랙트 상호작용- Etherscan 검증 — 소스코드 공개
다음 챕터에서는 ERC-20, ERC-721 토큰 표준을 살펴본다.
Chapter 13: 토큰 표준
토큰이란 무엇인가
블록체인에서 “토큰(token)“은 스마트 컨트랙트가 관리하는 디지털 자산이다. 실물 화폐, 주식, 쿠폰, 게임 아이템, 예술 작품 등 어떤 가치도 토큰으로 표현할 수 있다.
토큰과 코인의 차이:
| 코인 (Coin) | 토큰 (Token) | |
|---|---|---|
| 예시 | ETH, BTC | USDC, UNI, BAYC |
| 존재 방식 | 블록체인 자체에 내장 | 스마트 컨트랙트로 구현 |
| 전송 방식 | 네트워크 프로토콜 | 컨트랙트 함수 호출 |
| 발행 주체 | 프로토콜 | 개발자/팀 |
| 저장 위치 | 체인 네이티브 상태 | 컨트랙트 mapping |
ETH는 이더리움 블록체인의 네이티브 코인이다. 반면 USDC(달러 스테이블코인)는 이더리움 위에서 스마트 컨트랙트로 구현된 토큰이다. USDC를 전송할 때 실제로는 USDC 컨트랙트의 transfer() 함수를 호출하는 것이다.
// ETH 전송: 프로토콜 레벨 (네이티브)
payable(recipient).transfer(1 ether);
// USDC 전송: 컨트랙트 함수 호출
IERC20(USDC_ADDRESS).transfer(recipient, 1_000_000); // USDC는 decimals=6
Node.js 비유: 코인은 데이터베이스 자체의 기본 기능(예: PostgreSQL의 기본 데이터 타입)이고, 토큰은 그 위에서 애플리케이션 레이어로 구현한 기능(예: 애플리케이션의 포인트 시스템)이다.
// Node.js 포인트 시스템 (토큰의 중앙화 버전)
class PointSystem {
private balances = new Map<string, number>();
transfer(from: string, to: string, amount: number) {
const fromBalance = this.balances.get(from) ?? 0;
if (fromBalance < amount) throw new Error('Insufficient balance');
this.balances.set(from, fromBalance - amount);
this.balances.set(to, (this.balances.get(to) ?? 0) + amount);
}
}
// Solidity ERC-20 토큰 (탈중앙화 버전)
contract MyToken {
mapping(address => uint256) private _balances;
function transfer(address to, uint256 amount) public returns (bool) {
require(_balances[msg.sender] >= amount, "Insufficient balance");
_balances[msg.sender] -= amount;
_balances[to] += amount;
return true;
}
}
핵심 차이: Node.js 포인트 시스템은 서버 운영자가 임의로 잔액을 수정할 수 있다. 스마트 컨트랙트 토큰은 코드에 정의된 규칙 외에는 아무도 수정할 수 없다.
왜 표준이 필요한가
초기 이더리움에서는 개발자마다 토큰을 다르게 구현했다. 어떤 토큰은 send(), 어떤 토큰은 transfer(), 어떤 토큰은 pay()로 전송하는 식이었다. 이로 인해:
- 거래소가 새 토큰을 상장할 때마다 커스텀 통합 코드가 필요했다
- 지갑 앱이 모든 토큰을 개별적으로 지원해야 했다
- DeFi 프로토콜이 임의의 토큰과 상호작용할 수 없었다
- 감사(audit) 비용이 토큰마다 달랐다
표준의 필요성을 Node.js로 비유하면:
Node.js의 Stream 인터페이스가 없다면, 모든 라이브러리가 데이터 읽기/쓰기 API를 제각각 구현할 것이다. fs.createReadStream()의 출력을 zlib.createGzip()에 파이프하고, 다시 net.Socket에 파이프할 수 있는 건 모두가 같은 Stream 인터페이스를 구현하기 때문이다.
토큰 표준도 마찬가지다. 모든 ERC-20 토큰이 transfer(), balanceOf(), approve() 같은 동일한 인터페이스를 구현하기 때문에, Uniswap 같은 DEX는 어떤 ERC-20 토큰과도 상호작용할 수 있다.
// 표준 덕분에 가능한 범용 코드 (ethers.js)
const ERC20_ABI = ['function balanceOf(address) view returns (uint256)'];
async function getBalance(tokenAddress: string, walletAddress: string) {
// USDC든 UNI든 WETH든 동일한 코드로 조회 가능
const token = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
return token.balanceOf(walletAddress);
}
// Solidity에서 범용 토큰 처리
function swapAnyERC20(address token, uint256 amount) external {
// 어떤 ERC-20이든 동일한 인터페이스로 처리
IERC20(token).transferFrom(msg.sender, address(this), amount);
uint256 outputAmount = quoteOutput(token, amount);
require(outputAmount > 0, "ZERO_OUTPUT");
IERC20(token).transfer(msg.sender, outputAmount);
}
ERC 표준 체계
**ERC(Ethereum Request for Comments)**는 이더리움 커뮤니티가 토큰과 컨트랙트 표준을 제안하고 논의하는 과정이다. Node.js의 RFC, Python의 PEP와 유사한 개념이다.
**EIP(Ethereum Improvement Proposal)**로 제안되고, 커뮤니티 검토를 거쳐 ERC로 확정된다.
표준 제안 과정
1. 개발자가 EIP 제안 (GitHub PR)
2. 커뮤니티 토론 및 피드백
3. 레퍼런스 구현 작성
4. 광범위한 검토 및 수정
5. "Final" 상태로 확정 → ERC가 됨
주요 토큰 표준
| 표준 | 이름 | 특징 | 대표 예시 |
|---|---|---|---|
| ERC-20 | 대체 가능 토큰 | 모든 단위가 동일 | USDC, UNI, WETH |
| ERC-721 | 대체 불가능 토큰(NFT) | 각 토큰이 고유 | CryptoPunks, BAYC |
| ERC-1155 | 멀티 토큰 | ERC-20 + ERC-721 통합 | 게임 아이템 |
| ERC-4626 | 토큰화된 금고 | yield-bearing vault | Yearn, Aave |
| ERC-2612 | Permit | 서명으로 approve | USDC v2, DAI |
| ERC-4337 | 계정 추상화 | 스마트 지갑 | Safe, Biconomy |
ERC-20 — 대체 가능 토큰 (Fungible Token)
1 USDC = 다른 1 USDC. 완전히 동일하고 상호 교환 가능하다. 지폐처럼 개별 구분이 없다.
사용 사례:
- 스테이블코인 (USDC, USDT, DAI)
- 거버넌스 토큰 (UNI, COMP, AAVE)
- 래핑된 자산 (WETH, WBTC)
- 유동성 풀 토큰 (LP tokens)
- 프로젝트 유틸리티 토큰
ERC-20이 만들어진 2015년 이후 이더리움 생태계의 근간이 됐다. 현재 수천 개의 ERC-20 토큰이 존재하며, 총 시가총액은 수조 달러에 달한다.
ERC-721 — 대체 불가능 토큰 (Non-Fungible Token)
각 토큰이 고유한 ID를 가지며 서로 다르다. 토큰 #1과 토큰 #2는 같은 컨트랙트에서 발행됐어도 다른 자산이다. 실물 미술품처럼 각각이 독립적인 가치를 가진다.
사용 사례:
- 디지털 아트 (CryptoPunks, BAYC)
- 게임 아이템 (특정 캐릭터, 무기)
- 도메인 이름 (ENS)
- 부동산 권리증
- 이벤트 티켓
- 신원 증명서
ERC-1155 — 멀티 토큰
하나의 컨트랙트에서 대체 가능 토큰과 대체 불가능 토큰을 모두 관리할 수 있다. 게임에서 금화(대체 가능)와 전설 검(대체 불가능)을 하나의 컨트랙트로 관리하는 식이다.
사용 사례:
- 블록체인 게임 아이템
- 이벤트 티켓 (같은 좌석 등급 = 대체 가능, 특정 좌석 = 대체 불가능)
- 한정판 에디션 (100개 중 하나)
ERC-1155의 장점: 여러 토큰을 하나의 트랜잭션으로 배치 전송 가능 → 가스 비용 절약.
ERC-4626 — 토큰화된 금고 (Tokenized Vault)
수익률(yield)을 발생시키는 금고의 표준 인터페이스다. 예치(deposit), 출금(withdraw), 수익 계산 등의 표준화된 API를 제공한다.
interface IERC4626 is IERC20 {
function asset() external view returns (address);
function totalAssets() external view returns (uint256);
function deposit(uint256 assets, address receiver) external returns (uint256 shares);
function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);
function convertToShares(uint256 assets) external view returns (uint256 shares);
function convertToAssets(uint256 shares) external view returns (uint256 assets);
}
표준의 실제 작동 방식
표준은 코드가 아니라 인터페이스 명세다. 다음 함수들을 반드시 구현해야 한다고 정의한다.
// ERC-20 표준 인터페이스
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
이 인터페이스를 구현하면 그게 ERC-20 토큰이다. 내부 구현은 자유롭게 할 수 있다.
// TypeScript 인터페이스와 정확히 같은 개념
interface IERC20 {
totalSupply(): Promise<bigint>;
balanceOf(account: string): Promise<bigint>;
transfer(to: string, amount: bigint): Promise<boolean>;
allowance(owner: string, spender: string): Promise<bigint>;
approve(spender: string, amount: bigint): Promise<boolean>;
transferFrom(from: string, to: string, amount: bigint): Promise<boolean>;
}
토큰 생태계 현황
시가총액 기준 주요 ERC-20 (2024년 기준)
스테이블코인:
- USDT (Tether) - 약 1000억 달러
- USDC (Circle) - 약 400억 달러
- DAI (MakerDAO) - 약 50억 달러
DeFi 거버넌스 토큰:
- UNI (Uniswap) - 탈중앙화 거래소
- AAVE (Aave) - 대출 프로토콜
- COMP (Compound) - 대출 프로토콜
- MKR (MakerDAO) - 스테이블코인 거버넌스
래핑된 자산:
- WETH - ETH의 ERC-20 버전
- WBTC - Bitcoin의 이더리움 버전
토큰이 가능하게 한 혁신
토큰 표준이 생긴 이후 이더리움 생태계에서 폭발적인 혁신이 일어났다:
ICO (2017-2018):
- 누구나 토큰을 발행하고 자금 조달 가능
- 기존 VC 투자 없이 글로벌 크라우드펀딩
DeFi (2020~):
- 탈중앙화 거래소 (Uniswap): 토큰끼리 자동 교환
- 대출 프로토콜 (Aave): 토큰을 담보로 대출
- 수익 최적화 (Yearn): 토큰으로 자동 투자
NFT (2021~):
- 디지털 소유권의 새로운 형태
- 크리에이터 이코노미
이 챕터에서 다룰 내용
13-1: ERC-20 직접 구현
- 6개 필수 함수 전체 구현
- approve + transferFrom 2단계 패턴
- Foundry 테스트
13-2: ERC-721 구현
- NFT의 개념과 구조
- tokenURI와 메타데이터
- safeTransfer의 중요성
13-3: OpenZeppelin 활용
- 검증된 구현 라이브러리 활용
- 접근 제어 패턴
- 보안 유틸리티
다음 챕터에서는 ERC-20을 처음부터 직접 구현한다.
Chapter 13-1: ERC-20 토큰 구현
ERC-20 인터페이스 전체 설명
ERC-20은 이더리움에서 가장 많이 사용되는 토큰 표준이다. 6개의 필수 함수와 2개의 이벤트로 구성된다.
interface IERC20 {
// ============ 조회 함수 ============
/// @notice 토큰 총 발행량
function totalSupply() external view returns (uint256);
/// @notice 특정 주소의 잔액
function balanceOf(address account) external view returns (uint256);
/// @notice spender가 owner 대신 사용할 수 있는 허용량
function allowance(address owner, address spender) external view returns (uint256);
// ============ 전송 함수 ============
/// @notice 호출자에서 to로 amount만큼 전송
function transfer(address to, uint256 amount) external returns (bool);
/// @notice spender가 from에서 to로 amount만큼 전송 (사전 승인 필요)
function transferFrom(address from, address to, uint256 amount) external returns (bool);
// ============ 승인 함수 ============
/// @notice spender가 호출자 대신 amount만큼 사용하도록 승인
function approve(address spender, uint256 amount) external returns (bool);
// ============ 이벤트 ============
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
approve + transferFrom 2단계 패턴
ERC-20에서 가장 중요하고 자주 오해받는 패턴이다. 왜 transfer 하나로 충분하지 않을까?
문제: 스마트 컨트랙트와의 상호작용
Alice가 DEX(Uniswap 같은 탈중앙화 거래소)에서 토큰을 스왑하려 한다고 가정하자.
Alice -> DEX.swap(token, 1000) 호출
DEX가 Alice의 토큰을 가져가야 함
문제는 DEX가 Alice의 서명 없이 Alice의 토큰을 가져갈 수 없다는 점이다. EVM에서 모든 트랜잭션은 발신자가 서명해야 하고, msg.sender는 현재 호출자다.
// DEX 내부에서 이런 코드를 쓸 수 없음
function swap(address token, uint256 amount) external {
// 이 코드에서 msg.sender는 DEX 컨트랙트
// Alice의 토큰을 가져오려면 Alice가 서명한 트랜잭션이 필요
IERC20(token).transfer(dex, amount); // 오류! msg.sender가 DEX니까 DEX 잔액을 씀
}
해결: 2단계 approve + transferFrom
1단계: Alice가 DEX에게 허용량 승인
Alice -> token.approve(dex, 1000) 트랜잭션
2단계: DEX가 허용량 내에서 토큰 가져오기
Alice -> dex.swap() 트랜잭션
└-> 내부에서 token.transferFrom(alice, dex, 1000) 호출
(alice가 승인했으므로 가능)
Node.js 비유: OAuth 2.0의 토큰 위임과 비슷하다.
1단계: 사용자가 앱에 Google 계정 접근 권한 승인 (authorize)
2단계: 앱이 허용된 범위 내에서 사용자 대신 Google API 호출 (access)
실제 사용 예시
// 1단계: Alice가 approve 실행 (Alice의 지갑에서)
token.approve(uniswapRouter, 1000 * 1e18);
// 2단계: Alice가 swap 실행 (같은 트랜잭션 또는 나중에)
// Uniswap Router 내부:
// token.transferFrom(alice, uniswapPool, 1000 * 1e18);
// ethers.js 코드
// 1단계: approve
const approveTx = await tokenContract.approve(
uniswapRouterAddress,
ethers.parseUnits("1000", 18)
);
await approveTx.wait();
// 2단계: swap (Router가 내부적으로 transferFrom 호출)
const swapTx = await routerContract.swapExactTokensForETH(
ethers.parseUnits("1000", 18),
minEthOutput,
[tokenAddress, wethAddress],
aliceAddress,
deadline
);
await swapTx.wait();
ERC-20 직접 구현 (OpenZeppelin 없이)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title ERC-20 토큰 완전 구현 (교육용)
contract ERC20 {
// ============ 에러 ============
error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed);
error ERC20InvalidReceiver(address receiver);
error ERC20InvalidSender(address sender);
error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed);
error ERC20InvalidApprover(address approver);
error ERC20InvalidSpender(address spender);
// ============ 이벤트 ============
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
// ============ 상태 변수 ============
string private _name;
string private _symbol;
uint8 private _decimals;
uint256 private _totalSupply;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
// ============ 생성자 ============
constructor(string memory name_, string memory symbol_) {
_name = name_;
_symbol = symbol_;
_decimals = 18; // 이더리움 표준: 18자리 소수점
}
// ============ 메타데이터 (ERC-20 선택 사항이지만 사실상 필수) ============
function name() public view returns (string memory) {
return _name;
}
function symbol() public view returns (string memory) {
return _symbol;
}
/// @notice 소수점 자릿수. 거의 항상 18
/// 1 토큰 = 1 * 10^18 최소 단위
function decimals() public view returns (uint8) {
return _decimals;
}
// ============ ERC-20 필수 함수 ============
function totalSupply() public view returns (uint256) {
return _totalSupply;
}
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
function allowance(address owner, address spender) public view returns (uint256) {
return _allowances[owner][spender];
}
/// @notice 호출자에서 to로 amount 전송
function transfer(address to, uint256 amount) public returns (bool) {
_transfer(msg.sender, to, amount);
return true;
}
/// @notice 호출자가 spender에게 amount 사용 권한 부여
function approve(address spender, uint256 amount) public returns (bool) {
_approve(msg.sender, spender, amount);
return true;
}
/// @notice from에서 to로 amount 전송 (사전 승인된 호출자만 가능)
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
_spendAllowance(from, msg.sender, amount);
_transfer(from, to, amount);
return true;
}
// ============ 편의 함수 ============
/// @notice 현재 허용량에 amount를 추가
function increaseAllowance(address spender, uint256 addedValue) public returns (bool) {
_approve(msg.sender, spender, _allowances[msg.sender][spender] + addedValue);
return true;
}
/// @notice 현재 허용량에서 amount를 차감
function decreaseAllowance(address spender, uint256 subtractedValue) public returns (bool) {
uint256 currentAllowance = _allowances[msg.sender][spender];
if (currentAllowance < subtractedValue) {
revert ERC20InsufficientAllowance(spender, currentAllowance, subtractedValue);
}
unchecked {
_approve(msg.sender, spender, currentAllowance - subtractedValue);
}
return true;
}
// ============ 내부 함수 ============
/// @dev 실제 전송 로직
function _transfer(address from, address to, uint256 amount) internal {
if (from == address(0)) revert ERC20InvalidSender(address(0));
if (to == address(0)) revert ERC20InvalidReceiver(address(0));
uint256 fromBalance = _balances[from];
if (fromBalance < amount) {
revert ERC20InsufficientBalance(from, fromBalance, amount);
}
// unchecked: 위에서 fromBalance >= amount를 확인했으므로 언더플로 불가
unchecked {
_balances[from] = fromBalance - amount;
_balances[to] += amount;
}
emit Transfer(from, to, amount);
}
/// @dev 토큰 발행 (supply 증가)
function _mint(address to, uint256 amount) internal {
if (to == address(0)) revert ERC20InvalidReceiver(address(0));
_totalSupply += amount;
unchecked {
_balances[to] += amount;
}
emit Transfer(address(0), to, amount);
}
/// @dev 토큰 소각 (supply 감소)
function _burn(address from, uint256 amount) internal {
if (from == address(0)) revert ERC20InvalidSender(address(0));
uint256 fromBalance = _balances[from];
if (fromBalance < amount) {
revert ERC20InsufficientBalance(from, fromBalance, amount);
}
unchecked {
_balances[from] = fromBalance - amount;
_totalSupply -= amount;
}
emit Transfer(from, address(0), amount);
}
/// @dev 허용량 설정
function _approve(address owner, address spender, uint256 amount) internal {
if (owner == address(0)) revert ERC20InvalidApprover(address(0));
if (spender == address(0)) revert ERC20InvalidSpender(address(0));
_allowances[owner][spender] = amount;
emit Approval(owner, spender, amount);
}
/// @dev 허용량 소비 (transferFrom에서 사용)
function _spendAllowance(address owner, address spender, uint256 amount) internal {
uint256 currentAllowance = allowance(owner, spender);
// type(uint256).max는 "무제한 허용"으로 취급 (차감하지 않음)
if (currentAllowance != type(uint256).max) {
if (currentAllowance < amount) {
revert ERC20InsufficientAllowance(spender, currentAllowance, amount);
}
unchecked {
_approve(owner, spender, currentAllowance - amount);
}
}
}
}
decimals의 의미
1 ETH = 10^18 wei
1 USDC = 10^6 (USDC는 decimals=6)
1 대부분의 ERC-20 = 10^18 (decimals=18)
코드에서:
uint256 oneToken = 1 * 10**18; // 또는 1e18
uint256 halfToken = 5 * 10**17; // 또는 0.5e18
사용자에게 보이는 값 = 실제 저장값 / 10^decimals
실제 토큰 컨트랙트 구현
위의 ERC-20을 상속해서 실제 사용할 토큰을 만든다:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./ERC20.sol";
/// @title MyToken - 실제 사용 가능한 ERC-20 토큰
contract MyToken is ERC20 {
address public owner;
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
modifier onlyOwner() {
require(msg.sender == owner, "MyToken: not owner");
_;
}
constructor(
string memory name,
string memory symbol,
uint256 initialSupply
) ERC20(name, symbol) {
owner = msg.sender;
// 초기 공급량을 배포자에게 지급
_mint(msg.sender, initialSupply);
}
/// @notice 새 토큰 발행 (소유자만)
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
/// @notice 내 토큰 소각
function burn(uint256 amount) external {
_burn(msg.sender, amount);
}
function transferOwnership(address newOwner) external onlyOwner {
require(newOwner != address(0), "MyToken: zero address");
emit OwnershipTransferred(owner, newOwner);
owner = newOwner;
}
}
테스트 코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";
contract MyTokenTest is Test {
MyToken public token;
address public owner = makeAddr("owner");
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
address public spender = makeAddr("spender");
uint256 public constant INITIAL_SUPPLY = 1_000_000 * 1e18;
function setUp() public {
vm.prank(owner);
token = new MyToken("MyToken", "MTK", INITIAL_SUPPLY);
}
// ============ 기본 정보 테스트 ============
function test_Metadata() public view {
assertEq(token.name(), "MyToken");
assertEq(token.symbol(), "MTK");
assertEq(token.decimals(), 18);
assertEq(token.totalSupply(), INITIAL_SUPPLY);
assertEq(token.owner(), owner);
}
function test_InitialBalance() public view {
assertEq(token.balanceOf(owner), INITIAL_SUPPLY);
assertEq(token.balanceOf(alice), 0);
}
// ============ transfer 테스트 ============
function test_Transfer() public {
uint256 amount = 100 * 1e18;
vm.prank(owner);
bool success = token.transfer(alice, amount);
assertTrue(success);
assertEq(token.balanceOf(owner), INITIAL_SUPPLY - amount);
assertEq(token.balanceOf(alice), amount);
assertEq(token.totalSupply(), INITIAL_SUPPLY); // 총량 불변
}
function test_Transfer_EmitsEvent() public {
uint256 amount = 100 * 1e18;
vm.expectEmit(true, true, false, true);
emit MyToken.Transfer(owner, alice, amount);
vm.prank(owner);
token.transfer(alice, amount);
}
function test_Transfer_RevertInsufficientBalance() public {
vm.expectRevert(
abi.encodeWithSelector(
MyToken.ERC20InsufficientBalance.selector,
alice, 0, 1
)
);
vm.prank(alice);
token.transfer(bob, 1);
}
function test_Transfer_RevertZeroAddress() public {
vm.expectRevert(
abi.encodeWithSelector(
MyToken.ERC20InvalidReceiver.selector,
address(0)
)
);
vm.prank(owner);
token.transfer(address(0), 1);
}
// ============ approve + transferFrom 테스트 ============
function test_Approve() public {
uint256 allowanceAmount = 500 * 1e18;
vm.prank(owner);
bool success = token.approve(spender, allowanceAmount);
assertTrue(success);
assertEq(token.allowance(owner, spender), allowanceAmount);
}
function test_TransferFrom() public {
uint256 allowanceAmount = 500 * 1e18;
uint256 transferAmount = 200 * 1e18;
// 1단계: owner가 spender에게 허용
vm.prank(owner);
token.approve(spender, allowanceAmount);
// 2단계: spender가 owner 대신 alice에게 전송
vm.prank(spender);
bool success = token.transferFrom(owner, alice, transferAmount);
assertTrue(success);
assertEq(token.balanceOf(owner), INITIAL_SUPPLY - transferAmount);
assertEq(token.balanceOf(alice), transferAmount);
// 허용량이 차감됨
assertEq(token.allowance(owner, spender), allowanceAmount - transferAmount);
}
function test_TransferFrom_RevertInsufficientAllowance() public {
uint256 allowanceAmount = 100 * 1e18;
uint256 transferAmount = 200 * 1e18;
vm.prank(owner);
token.approve(spender, allowanceAmount);
vm.expectRevert(
abi.encodeWithSelector(
MyToken.ERC20InsufficientAllowance.selector,
spender, allowanceAmount, transferAmount
)
);
vm.prank(spender);
token.transferFrom(owner, alice, transferAmount);
}
function test_MaxAllowance_NotDecremented() public {
// type(uint256).max 허용량은 차감하지 않음 (무제한)
vm.prank(owner);
token.approve(spender, type(uint256).max);
vm.prank(spender);
token.transferFrom(owner, alice, 100 * 1e18);
// 여전히 max
assertEq(token.allowance(owner, spender), type(uint256).max);
}
// ============ mint / burn 테스트 ============
function test_Mint() public {
uint256 mintAmount = 500 * 1e18;
vm.prank(owner);
token.mint(alice, mintAmount);
assertEq(token.balanceOf(alice), mintAmount);
assertEq(token.totalSupply(), INITIAL_SUPPLY + mintAmount);
}
function test_Mint_OnlyOwner() public {
vm.expectRevert("MyToken: not owner");
vm.prank(alice);
token.mint(alice, 1);
}
function test_Burn() public {
uint256 burnAmount = 100 * 1e18;
vm.prank(owner);
token.burn(burnAmount);
assertEq(token.balanceOf(owner), INITIAL_SUPPLY - burnAmount);
assertEq(token.totalSupply(), INITIAL_SUPPLY - burnAmount);
}
// ============ 퍼즈 테스트 ============
function testFuzz_Transfer(address to, uint256 amount) public {
vm.assume(to != address(0) && to != owner);
amount = bound(amount, 1, INITIAL_SUPPLY);
vm.prank(owner);
token.transfer(to, amount);
assertEq(token.balanceOf(to), amount);
assertEq(token.totalSupply(), INITIAL_SUPPLY); // 불변식
}
function testFuzz_ApproveAndTransferFrom(
uint256 approveAmount,
uint256 transferAmount
) public {
approveAmount = bound(approveAmount, 1, INITIAL_SUPPLY);
transferAmount = bound(transferAmount, 1, approveAmount);
vm.prank(owner);
token.approve(spender, approveAmount);
vm.prank(spender);
token.transferFrom(owner, alice, transferAmount);
assertEq(token.balanceOf(alice), transferAmount);
assertEq(token.allowance(owner, spender), approveAmount - transferAmount);
}
}
정리
ERC-20의 핵심 포인트:
- 6개의 표준 함수 — 모든 ERC-20 토큰이 동일한 인터페이스를 구현
- approve + transferFrom — 스마트 컨트랙트가 사용자 대신 토큰을 이동시키는 2단계 패턴
- decimals = 18 — 1 토큰은 10^18 최소 단위로 저장
- Transfer 이벤트 — address(0)에서 오면 mint, address(0)으로 가면 burn
- unchecked 블록 — 안전이 검증된 산술 연산에서 가스 절약
다음 챕터에서는 ERC-721(NFT)을 살펴본다.
Chapter 13-2: ERC-721 (NFT)
NFT란 무엇인가
NFT(Non-Fungible Token, 대체 불가능 토큰)는 각각이 고유한 정체성을 가지는 토큰이다. ERC-20 토큰과 달리 모든 NFT는 서로 다르다.
대체 가능(Fungible) vs 대체 불가능(Non-Fungible):
대체 가능:
- 1만원권 지폐 A = 1만원권 지폐 B (동일한 가치, 교환 가능)
- 1 ETH = 다른 1 ETH
- 1 USDC = 다른 1 USDC
대체 불가능:
- 모나리자 원본 ≠ 다른 그림 (각각 고유)
- CryptoPunk #1 ≠ CryptoPunk #2 (같은 컬렉션이지만 다른 자산)
- 이벤트 티켓 A석 3열 5번 ≠ A석 3열 6번
ERC-20 vs ERC-721 비교:
| ERC-20 | ERC-721 | |
|---|---|---|
| 단위 | amount (수량) | tokenId (고유 번호) |
| 교환성 | 완전히 동일 | 각각 고유 |
| 잔액 | balanceOf(address) → 수량 | balanceOf(address) → 보유 개수 |
| 소유권 | 계정마다 잔액 | tokenId마다 소유자 |
| 대표 예 | USDC, UNI | CryptoPunks, BAYC |
ERC-721 인터페이스
interface IERC721 {
// ============ 이벤트 ============
/// @dev 토큰 전송 (mint: from=0, burn: to=0)
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
/// @dev 단일 토큰 승인
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
/// @dev 전체 컬렉션 승인/취소
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
// ============ 조회 함수 ============
/// @notice 주소가 보유한 NFT 개수
function balanceOf(address owner) external view returns (uint256 balance);
/// @notice 특정 tokenId의 소유자
function ownerOf(uint256 tokenId) external view returns (address owner);
/// @notice 특정 tokenId를 이동시킬 수 있도록 승인된 주소
function getApproved(uint256 tokenId) external view returns (address operator);
/// @notice operator가 owner의 모든 NFT를 관리할 수 있는지 여부
function isApprovedForAll(address owner, address operator) external view returns (bool);
// ============ 전송 함수 ============
/// @notice 안전한 전송 (수신자가 컨트랙트면 onERC721Received 확인)
function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
function safeTransferFrom(address from, address to, uint256 tokenId) external;
/// @notice 단순 전송 (수신자 확인 없음)
function transferFrom(address from, address to, uint256 tokenId) external;
// ============ 승인 함수 ============
/// @notice 특정 tokenId의 이동 권한을 approve에게 부여
function approve(address to, uint256 tokenId) external;
/// @notice operator에게 모든 NFT 관리 권한 부여/취소
function setApprovalForAll(address operator, bool approved) external;
}
ERC-20과 ERC-721 승인의 차이:
ERC-20: approve(spender, amount) — 금액 기준 승인
ERC-721: approve(spender, tokenId) — 특정 토큰 ID 기준 승인
ERC-721: setApprovalForAll(op, bool) — 전체 컬렉션 승인 (NFT 마켓플레이스용)
메타데이터와 tokenURI
NFT의 핵심 가치는 메타데이터다. 이미지, 속성, 설명 등의 정보가 담긴 JSON을 가리키는 URI다.
interface IERC721Metadata {
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function tokenURI(uint256 tokenId) external view returns (string memory);
}
메타데이터 JSON 형식
{
"name": "CryptoPunk #1",
"description": "A unique CryptoPunk character",
"image": "ipfs://QmXxx.../1.png",
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Type", "value": "Human" },
{ "trait_type": "Accessory", "value": "Sunglasses" }
]
}
tokenURI 패턴
// 패턴 1: IPFS 기반 (분산 스토리지 - 탈중앙화)
function tokenURI(uint256 tokenId) public view returns (string memory) {
require(_exists(tokenId), "Token does not exist");
return string(abi.encodePacked(
"ipfs://QmBaseHash/",
Strings.toString(tokenId),
".json"
));
}
// 결과: "ipfs://QmBaseHash/42.json"
// 패턴 2: 중앙화 서버 (업데이트 가능하지만 탈중앙화 아님)
string private _baseURI = "https://api.myproject.com/metadata/";
function tokenURI(uint256 tokenId) public view returns (string memory) {
return string(abi.encodePacked(_baseURI, Strings.toString(tokenId)));
}
// 결과: "https://api.myproject.com/metadata/42"
// 패턴 3: 온체인 메타데이터 (Base64 인코딩)
function tokenURI(uint256 tokenId) public view returns (string memory) {
string memory json = Base64.encode(bytes(string(abi.encodePacked(
'{"name":"Token #', Strings.toString(tokenId),
'","description":"On-chain NFT","image":"data:image/svg+xml;base64,',
_generateSVG(tokenId),
'"}'
))));
return string(abi.encodePacked("data:application/json;base64,", json));
}
간단한 NFT 컨트랙트 구현
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
/// @title SimpleNFT - 기본 ERC-721 구현 (교육용)
contract SimpleNFT {
using Strings for uint256;
// ============ 에러 ============
error NotOwnerOrApproved();
error InvalidTokenId(uint256 tokenId);
error NotERC721Receiver(address to);
error AlreadyMinted(uint256 tokenId);
error MaxSupplyReached();
error NotOwner();
error ZeroAddress();
// ============ 이벤트 ============
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
// ============ 상태 변수 ============
string public name;
string public symbol;
string private _baseTokenURI;
address public owner;
uint256 public nextTokenId;
uint256 public maxSupply;
// tokenId => 소유자
mapping(uint256 => address) private _owners;
// 소유자 => 보유 개수
mapping(address => uint256) private _balances;
// tokenId => 승인된 주소
mapping(uint256 => address) private _tokenApprovals;
// 소유자 => operator => 전체 승인 여부
mapping(address => mapping(address => bool)) private _operatorApprovals;
// ============ 생성자 ============
constructor(
string memory _name,
string memory _symbol,
string memory baseURI,
uint256 _maxSupply
) {
name = _name;
symbol = _symbol;
_baseTokenURI = baseURI;
maxSupply = _maxSupply;
owner = msg.sender;
}
// ============ 조회 함수 ============
function balanceOf(address account) public view returns (uint256) {
if (account == address(0)) revert ZeroAddress();
return _balances[account];
}
function ownerOf(uint256 tokenId) public view returns (address) {
address tokenOwner = _owners[tokenId];
if (tokenOwner == address(0)) revert InvalidTokenId(tokenId);
return tokenOwner;
}
function tokenURI(uint256 tokenId) public view returns (string memory) {
if (_owners[tokenId] == address(0)) revert InvalidTokenId(tokenId);
return string(abi.encodePacked(_baseTokenURI, tokenId.toString(), ".json"));
}
function getApproved(uint256 tokenId) public view returns (address) {
if (_owners[tokenId] == address(0)) revert InvalidTokenId(tokenId);
return _tokenApprovals[tokenId];
}
function isApprovedForAll(address tokenOwner, address operator) public view returns (bool) {
return _operatorApprovals[tokenOwner][operator];
}
// ============ 승인 함수 ============
function approve(address to, uint256 tokenId) public {
address tokenOwner = ownerOf(tokenId);
if (msg.sender != tokenOwner && !isApprovedForAll(tokenOwner, msg.sender)) {
revert NotOwnerOrApproved();
}
_tokenApprovals[tokenId] = to;
emit Approval(tokenOwner, to, tokenId);
}
function setApprovalForAll(address operator, bool approved) public {
if (operator == address(0)) revert ZeroAddress();
_operatorApprovals[msg.sender][operator] = approved;
emit ApprovalForAll(msg.sender, operator, approved);
}
// ============ 전송 함수 ============
function transferFrom(address from, address to, uint256 tokenId) public {
if (!_isApprovedOrOwner(msg.sender, tokenId)) revert NotOwnerOrApproved();
_transfer(from, to, tokenId);
}
function safeTransferFrom(address from, address to, uint256 tokenId) public {
safeTransferFrom(from, to, tokenId, "");
}
function safeTransferFrom(
address from,
address to,
uint256 tokenId,
bytes memory data
) public {
if (!_isApprovedOrOwner(msg.sender, tokenId)) revert NotOwnerOrApproved();
_safeTransfer(from, to, tokenId, data);
}
// ============ 민팅 (소유자만) ============
function mint(address to) external returns (uint256 tokenId) {
if (msg.sender != owner) revert NotOwner();
if (nextTokenId >= maxSupply) revert MaxSupplyReached();
if (to == address(0)) revert ZeroAddress();
tokenId = nextTokenId;
nextTokenId++;
_mint(to, tokenId);
}
/// @notice 여러 NFT를 한 번에 민팅
function batchMint(address to, uint256 quantity) external {
if (msg.sender != owner) revert NotOwner();
if (nextTokenId + quantity > maxSupply) revert MaxSupplyReached();
if (to == address(0)) revert ZeroAddress();
for (uint256 i = 0; i < quantity; i++) {
_mint(to, nextTokenId);
nextTokenId++;
}
}
// ============ 내부 함수 ============
function _mint(address to, uint256 tokenId) internal {
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(address(0), to, tokenId);
}
function _burn(uint256 tokenId) internal {
address tokenOwner = ownerOf(tokenId);
delete _tokenApprovals[tokenId];
_balances[tokenOwner] -= 1;
delete _owners[tokenId];
emit Transfer(tokenOwner, address(0), tokenId);
}
function _transfer(address from, address to, uint256 tokenId) internal {
if (ownerOf(tokenId) != from) revert NotOwnerOrApproved();
if (to == address(0)) revert ZeroAddress();
delete _tokenApprovals[tokenId];
_balances[from] -= 1;
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(from, to, tokenId);
}
function _safeTransfer(
address from,
address to,
uint256 tokenId,
bytes memory data
) internal {
_transfer(from, to, tokenId);
// 수신자가 컨트랙트라면 ERC721Receiver 인터페이스 확인
if (to.code.length > 0) {
bytes4 retval = IERC721Receiver(to).onERC721Received(
msg.sender, from, tokenId, data
);
if (retval != IERC721Receiver.onERC721Received.selector) {
revert NotERC721Receiver(to);
}
}
}
function _isApprovedOrOwner(address spender, uint256 tokenId) internal view returns (bool) {
address tokenOwner = ownerOf(tokenId);
return (
spender == tokenOwner ||
isApprovedForAll(tokenOwner, spender) ||
getApproved(tokenId) == spender
);
}
function _exists(uint256 tokenId) internal view returns (bool) {
return _owners[tokenId] != address(0);
}
}
/// @notice 컨트랙트가 NFT를 안전하게 받을 수 있음을 나타내는 인터페이스
interface IERC721Receiver {
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4);
}
safeTransferFrom의 중요성
// 일반 transfer: 컨트랙트 주소로 보내면 NFT가 영원히 잠길 수 있음
token.transferFrom(alice, contractAddress, tokenId);
// contractAddress가 NFT를 처리할 코드가 없다면 영원히 locked!
// safeTransfer: 컨트랙트가 NFT를 받을 수 있는지 확인 후 전송
token.safeTransferFrom(alice, contractAddress, tokenId);
// 컨트랙트가 onERC721Received를 구현하지 않으면 revert
NFT를 받는 컨트랙트 구현 예:
contract NFTVault is IERC721Receiver {
mapping(uint256 => address) public depositor;
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external override returns (bytes4) {
depositor[tokenId] = from;
// 반드시 이 selector를 반환해야 함
return IERC721Receiver.onERC721Received.selector;
}
}
Foundry 테스트
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {SimpleNFT} from "../src/SimpleNFT.sol";
contract SimpleNFTTest is Test {
SimpleNFT public nft;
address public owner = makeAddr("owner");
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
function setUp() public {
vm.prank(owner);
nft = new SimpleNFT(
"SimpleNFT", "SNFT",
"ipfs://QmBase/",
100 // maxSupply
);
}
function test_Mint() public {
vm.prank(owner);
uint256 tokenId = nft.mint(alice);
assertEq(tokenId, 0);
assertEq(nft.ownerOf(0), alice);
assertEq(nft.balanceOf(alice), 1);
assertEq(nft.nextTokenId(), 1);
}
function test_TokenURI() public {
vm.prank(owner);
nft.mint(alice);
assertEq(nft.tokenURI(0), "ipfs://QmBase/0.json");
}
function test_Transfer() public {
vm.prank(owner);
nft.mint(alice);
vm.prank(alice);
nft.transferFrom(alice, bob, 0);
assertEq(nft.ownerOf(0), bob);
assertEq(nft.balanceOf(alice), 0);
assertEq(nft.balanceOf(bob), 1);
}
function test_ApproveAndTransfer() public {
vm.prank(owner);
nft.mint(alice);
// alice가 bob에게 토큰 0 이동 권한 부여
vm.prank(alice);
nft.approve(bob, 0);
assertEq(nft.getApproved(0), bob);
// bob이 alice 대신 전송
vm.prank(bob);
nft.transferFrom(alice, bob, 0);
assertEq(nft.ownerOf(0), bob);
}
function test_SetApprovalForAll() public {
vm.prank(owner);
nft.batchMint(alice, 3);
// alice가 bob에게 모든 NFT 관리 권한 부여
vm.prank(alice);
nft.setApprovalForAll(bob, true);
assertTrue(nft.isApprovedForAll(alice, bob));
// bob이 alice의 NFT #1을 자신에게 전송
vm.prank(bob);
nft.transferFrom(alice, bob, 1);
assertEq(nft.ownerOf(1), bob);
}
function test_RevertTransferFromNonOwner() public {
vm.prank(owner);
nft.mint(alice);
vm.expectRevert(SimpleNFT.NotOwnerOrApproved.selector);
vm.prank(bob);
nft.transferFrom(alice, bob, 0);
}
function test_MaxSupply() public {
vm.startPrank(owner);
nft.batchMint(alice, 100); // maxSupply 채우기
vm.expectRevert(SimpleNFT.MaxSupplyReached.selector);
nft.mint(alice);
vm.stopPrank();
}
}
정리
ERC-721의 핵심 개념:
- tokenId — 각 NFT의 고유 식별자. ERC-20의 amount와 달리 개별 자산을 가리킴
- ownerOf(tokenId) — 특정 NFT의 현재 소유자
- approve vs setApprovalForAll — 단일 토큰 승인 vs 전체 컬렉션 승인
- safeTransfer — 컨트랙트 수신자가 NFT를 처리할 수 있는지 확인
- tokenURI — NFT의 메타데이터(이미지, 속성)를 가리키는 URI
다음 챕터에서는 OpenZeppelin 라이브러리를 활용해 이를 더 쉽게 구현하는 방법을 배운다.
Chapter 13-3: OpenZeppelin
OpenZeppelin이란
OpenZeppelin은 스마트 컨트랙트 보안 회사이자, 이더리움 생태계에서 가장 널리 사용되는 오픈소스 컨트랙트 라이브러리를 제공하는 조직이다. 2016년부터 수많은 보안 감사를 거친 검증된 구현체를 제공한다.
Node.js 생태계 비유:
| Node.js | OpenZeppelin |
|---|---|
express | ERC-20, ERC-721 기본 구현 |
passport | AccessControl, Ownable |
helmet | ReentrancyGuard, Pausable |
lodash | SafeMath (0.8+ 이후 내장), Strings |
sequelize | - |
직접 구현보다 OpenZeppelin을 사용하는 이유:
- 수백만 달러 규모의 컨트랙트에서 검증됨
- 보안 취약점 발견 시 빠른 패치
- 커뮤니티 표준으로 자리잡아 감사(audit) 비용 절감
- 최신 EIP 구현 반영
Foundry에서 설치
# OpenZeppelin 컨트랙트 설치
forge install OpenZeppelin/openzeppelin-contracts
# 업그레이드 가능 버전 (프록시 패턴용)
forge install OpenZeppelin/openzeppelin-contracts-upgradeable
# remappings.txt에 경로 매핑 추가
echo "@openzeppelin/=lib/openzeppelin-contracts/" >> remappings.txt
설치 확인:
lib/
└── openzeppelin-contracts/
└── contracts/
├── access/
│ ├── Ownable.sol
│ └── AccessControl.sol
├── token/
│ ├── ERC20/
│ └── ERC721/
├── security/
│ ├── ReentrancyGuard.sol
│ └── Pausable.sol
└── utils/
├── Strings.sol
└── ...
ERC20 상속으로 토큰 만들기
앞 챕터에서 직접 구현한 ERC-20 코드가 수백 줄이었다. OpenZeppelin을 사용하면:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
/// @title MyToken - OpenZeppelin 기반 ERC-20 토큰
contract MyToken is ERC20, ERC20Burnable, ERC20Permit, Ownable {
uint256 public constant MAX_SUPPLY = 100_000_000 * 1e18; // 1억 토큰
constructor(
address initialOwner,
uint256 initialSupply
)
ERC20("MyToken", "MTK")
ERC20Permit("MyToken") // EIP-2612: 서명으로 approve
Ownable(initialOwner)
{
require(initialSupply <= MAX_SUPPLY, "Exceeds max supply");
_mint(initialOwner, initialSupply);
}
/// @notice 소유자가 새 토큰 발행 (최대 공급량 제한)
function mint(address to, uint256 amount) external onlyOwner {
require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");
_mint(to, amount);
}
}
ERC20Burnable은 burn(), burnFrom() 함수를 자동으로 추가한다.
ERC20Permit은 EIP-2612 gasless approve를 지원한다 (서명으로 approve, 트랜잭션 없이).
ERC721 상속 예시
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {ERC721URIStorage} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import {ERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
contract MyNFT is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable {
using Strings for uint256;
uint256 private _nextTokenId;
uint256 public maxSupply;
string private _baseURIValue;
constructor(
address initialOwner,
uint256 _maxSupply,
string memory baseURI
) ERC721("MyNFT", "MNFT") Ownable(initialOwner) {
maxSupply = _maxSupply;
_baseURIValue = baseURI;
}
function mint(address to) external onlyOwner returns (uint256) {
require(_nextTokenId < maxSupply, "Max supply reached");
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
return tokenId;
}
function _baseURI() internal view override returns (string memory) {
return _baseURIValue;
}
// ERC721Enumerable + ERC721URIStorage 충돌 해결
function _update(address to, uint256 tokenId, address auth)
internal
override(ERC721, ERC721Enumerable)
returns (address)
{
return super._update(to, tokenId, auth);
}
function _increaseBalance(address account, uint128 value)
internal
override(ERC721, ERC721Enumerable)
{
super._increaseBalance(account, value);
}
function tokenURI(uint256 tokenId)
public view
override(ERC721, ERC721URIStorage)
returns (string memory)
{
return super.tokenURI(tokenId);
}
function supportsInterface(bytes4 interfaceId)
public view
override(ERC721, ERC721Enumerable, ERC721URIStorage)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
Ownable — 소유권 관리
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract MyContract is Ownable {
uint256 public value;
constructor(address initialOwner) Ownable(initialOwner) {}
// onlyOwner 제어자 자동 제공
function setValue(uint256 newValue) external onlyOwner {
value = newValue;
}
// 소유권 포기 (address(0)으로 이전)
// renounceOwnership() 자동 제공
// 소유권 이전
// transferOwnership(address newOwner) 자동 제공
}
OpenZeppelin v5부터 생성자에서 초기 소유자를 명시적으로 전달해야 한다(Ownable(initialOwner)).
Ownable2Step — 2단계 소유권 이전
실수로 잘못된 주소로 소유권을 이전하는 사고를 방지한다:
import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol";
contract SafeOwnable is Ownable2Step {
constructor(address initialOwner) Ownable(initialOwner) {}
function adminAction() external onlyOwner {
// 소유자만 실행 가능
}
}
// 사용:
// 1. contract.transferOwnership(newOwner) — 제안
// 2. newOwner가 contract.acceptOwnership() 호출 — 수락
// → 새 소유자가 수락하지 않으면 소유권 이전 안 됨
AccessControl — 역할 기반 접근 제어
Ownable은 소유자 1명만 관리할 수 있다. 여러 역할이 필요할 때는 AccessControl을 사용한다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract RoleBasedToken is ERC20, AccessControl {
// 역할 정의: keccak256 해시로 식별
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
bool public paused;
constructor(address admin) ERC20("RoleToken", "RTK") {
// admin에게 DEFAULT_ADMIN_ROLE 부여 (역할 관리자)
_grantRole(DEFAULT_ADMIN_ROLE, admin);
// admin에게 MINTER_ROLE도 부여
_grantRole(MINTER_ROLE, admin);
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
_mint(to, amount);
}
function burn(address from, uint256 amount) external onlyRole(BURNER_ROLE) {
_burn(from, amount);
}
function pause() external onlyRole(PAUSER_ROLE) {
paused = true;
}
function unpause() external onlyRole(PAUSER_ROLE) {
paused = false;
}
// 역할 관리 (DEFAULT_ADMIN_ROLE만 가능)
// grantRole(role, account) — 자동 제공
// revokeRole(role, account) — 자동 제공
// renounceRole(role, account) — 자동 제공
}
// NestJS의 @Roles() 데코레이터 + RolesGuard와 개념적으로 동일
@Roles('admin', 'minter')
@UseGuards(RolesGuard)
@Post('/mint')
async mint(@Body() dto: MintDto) {
return this.tokenService.mint(dto.to, dto.amount);
}
// Solidity - onlyRole 제어자로 동일한 패턴
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
_mint(to, amount);
}
AccessControl 사용 예:
# 역할 부여 (DEFAULT_ADMIN_ROLE 보유자만)
cast send $TOKEN "grantRole(bytes32,address)" \
$(cast keccak "MINTER_ROLE") $MINTER_ADDRESS \
--private-key $ADMIN_KEY
# 역할 확인
cast call $TOKEN "hasRole(bytes32,address)" \
$(cast keccak "MINTER_ROLE") $MINTER_ADDRESS
ReentrancyGuard — 재진입 공격 방지
재진입 공격(Reentrancy Attack)은 외부 컨트랙트 호출 중에 다시 같은 함수를 호출하는 공격이다. The DAO 해킹(2016, 6천만 달러)의 원인이 된 공격 유형이다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeVault is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// nonReentrant: 재진입 시 revert
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "Insufficient balance");
// 상태 먼저 변경 (Checks-Effects-Interactions)
balances[msg.sender] -= amount;
// 외부 호출 (이 호출 중 재진입 시도하면 nonReentrant가 막음)
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Transfer failed");
}
}
nonReentrant는 내부적으로 _status 변수로 잠금을 구현한다:
// OpenZeppelin ReentrancyGuard 내부 (단순화)
modifier nonReentrant() {
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
_status = _ENTERED;
_;
_status = _NOT_ENTERED;
}
Pausable — 긴급 정지
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Pausable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract PausableToken is ERC20, ERC20Pausable, Ownable {
constructor(address initialOwner)
ERC20("PausableToken", "PTK")
Ownable(initialOwner)
{}
// 긴급 정지 (소유자만)
function pause() external onlyOwner {
_pause(); // whenNotPaused 상태로 잠금
}
function unpause() external onlyOwner {
_unpause();
}
// ERC20Pausable이 transfer 시 _requireNotPaused() 자동 호출
function _update(address from, address to, uint256 value)
internal
override(ERC20, ERC20Pausable)
{
super._update(from, to, value);
}
}
Upgradeable 컨트랙트 기초
스마트 컨트랙트는 한 번 배포하면 수정이 불가능하다. 업그레이드 가능한 컨트랙트는 프록시 패턴으로 이 문제를 해결한다.
자세한 내용은 14-02 챕터에서 다루고, 여기서는 OpenZeppelin의 업그레이드 가능 컨트랙트 사용법만 간략히 소개한다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// 업그레이드 가능 버전은 -upgradeable 패키지에서
import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
/// @custom:oz-upgrades-unsafe-allow constructor
contract UpgradeableToken is
Initializable,
ERC20Upgradeable,
OwnableUpgradeable,
UUPSUpgradeable
{
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // 구현체 직접 초기화 방지
}
// constructor 대신 initialize 사용
function initialize(
string memory name,
string memory symbol,
address initialOwner
) public initializer {
__ERC20_init(name, symbol);
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
_mint(initialOwner, 1_000_000 * 1e18);
}
// 업그레이드 권한 제어 (소유자만 업그레이드 가능)
function _authorizeUpgrade(address newImplementation)
internal
override
onlyOwner
{}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
}
업그레이드 가능 컨트랙트의 핵심 규칙:
constructor대신initialize함수 사용 (initializer제어자로 한 번만 실행)- 상태 변수 순서를 업그레이드 시 변경하면 안 됨 (스토리지 충돌)
immutable변수 사용 금지
OpenZeppelin 주요 확장 목록
// ERC-20 확장
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
// burn(), burnFrom() 추가
import {ERC20Capped} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Capped.sol";
// 최대 공급량 제한
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
// EIP-2612: 서명으로 approve (가스리스)
import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
// 거버넌스 투표권 (체크포인트 기반)
import {ERC20FlashMint} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20FlashMint.sol";
// 플래시론 지원
// ERC-721 확장
import {ERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
// 토큰 목록 순회 가능
import {ERC721URIStorage} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
// 토큰별 URI 저장
import {ERC721Royalty} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Royalty.sol";
// EIP-2981: 로열티 정보
// 유틸리티
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
완성된 프로덕션급 토큰 예시
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol";
contract GovernanceToken is
ERC20,
ERC20Burnable,
ERC20Permit,
ERC20Votes,
AccessControl,
ReentrancyGuard
{
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
uint256 public constant MAX_SUPPLY = 1_000_000_000 * 1e18; // 10억
constructor(address admin)
ERC20("GovernanceToken", "GOV")
ERC20Permit("GovernanceToken")
{
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(MINTER_ROLE, admin);
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");
_mint(to, amount);
}
// ERC20Votes + ERC20Permit 충돌 해결
function _update(address from, address to, uint256 value)
internal
override(ERC20, ERC20Votes)
{
super._update(from, to, value);
}
function nonces(address owner)
public view
override(ERC20Permit, Nonces)
returns (uint256)
{
return super.nonces(owner);
}
}
정리
OpenZeppelin 사용의 핵심:
- 검증된 구현 재사용 — 직접 구현 대신 검증된 코드를 상속
- 역할 기반 접근 제어 —
Ownable(단순) vsAccessControl(복잡) - 보안 패턴 —
ReentrancyGuard,Pausable은 거의 항상 포함 - 확장성 — 필요한 기능을 믹스인(Mixin)처럼 추가
- 업그레이드 —
Upgradeable버전으로 업그레이드 가능한 컨트랙트 구현
다음 챕터에서는 상속, 프록시 패턴, 보안 취약점 등 고급 주제를 다룬다.
Chapter 15: 미니프로젝트 — ERC-20 토큰 + Vault
프로젝트 개요
지금까지 배운 내용을 종합해 실제로 동작하는 미니 프로젝트를 완성한다. 이 프로젝트는 두 개의 컨트랙트로 구성된다:
- MyToken.sol — ERC-20 토큰 (OpenZeppelin 기반)
- Vault.sol — 토큰을 예치하고 출금하는 금고 컨트랙트
전체 요구사항
MyToken:
- ERC-20 표준 준수 (OpenZeppelin ERC20 상속)
- 최대 공급량 제한 (100만 토큰)
- 소유자만 민팅 가능
- 토큰 소각(burn) 기능
Vault:
- MyToken만 예치 가능
- 사용자별 예치 잔액 관리
- 예치(deposit)와 출금(withdraw) 기능
- 재진입 공격 방지 (ReentrancyGuard)
- 긴급 정지 기능 (Pausable)
- 모든 주요 동작 이벤트 발행
프로젝트 구조
token-vault/
├── foundry.toml
├── remappings.txt
├── .env.example
│
├── src/
│ ├── MyToken.sol
│ └── Vault.sol
│
├── test/
│ ├── MyToken.t.sol
│ └── Vault.t.sol
│
└── script/
└── Deploy.s.sol
MyToken.sol 전체 코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
/// @title MyToken - ERC-20 토큰
/// @notice 최대 공급량이 제한된 ERC-20 토큰
contract MyToken is ERC20, ERC20Burnable, Ownable2Step {
// ============ 에러 ============
error ExceedsMaxSupply(uint256 requested, uint256 available);
error ZeroAddress();
error ZeroAmount();
// ============ 상수 ============
/// @notice 최대 발행 가능 토큰 수 (1,000,000 MTK)
uint256 public constant MAX_SUPPLY = 1_000_000 * 10 ** 18;
// ============ 이벤트 ============
event TokensMinted(address indexed to, uint256 amount, uint256 newTotalSupply);
// ============ 생성자 ============
/// @param initialOwner 초기 소유자 주소
/// @param initialSupply 초기 발행량 (MTK 단위, 18 decimals 적용)
constructor(
address initialOwner,
uint256 initialSupply
) ERC20("MyToken", "MTK") Ownable(initialOwner) {
if (initialOwner == address(0)) revert ZeroAddress();
if (initialSupply > MAX_SUPPLY) {
revert ExceedsMaxSupply(initialSupply, MAX_SUPPLY);
}
if (initialSupply > 0) {
_mint(initialOwner, initialSupply);
emit TokensMinted(initialOwner, initialSupply, totalSupply());
}
}
// ============ 소유자 전용 함수 ============
/// @notice 새 토큰 발행 (소유자만)
/// @param to 수령 주소
/// @param amount 발행 수량 (wei 단위)
function mint(address to, uint256 amount) external onlyOwner {
if (to == address(0)) revert ZeroAddress();
if (amount == 0) revert ZeroAmount();
uint256 available = MAX_SUPPLY - totalSupply();
if (amount > available) {
revert ExceedsMaxSupply(amount, available);
}
_mint(to, amount);
emit TokensMinted(to, amount, totalSupply());
}
// ============ 조회 함수 ============
/// @notice 추가로 발행 가능한 토큰 수
function remainingMintable() external view returns (uint256) {
return MAX_SUPPLY - totalSupply();
}
}
MyToken 설명
- ERC20Burnable 상속:
burn(amount),burnFrom(account, amount)자동 제공 - Ownable2Step: 소유권 이전 시 새 소유자가
acceptOwnership()호출해야 확정됨 (실수 방지) - MAX_SUPPLY: 컴파일 타임 상수 — 가스 비용 없이 storage에서 읽지 않음
- 커스텀 에러: 파라미터 포함으로 클라이언트에서 상세한 에러 처리 가능
Vault.sol 전체 코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
/// @title Vault - MyToken 금고 컨트랙트
/// @notice 사용자가 MyToken을 예치하고 출금할 수 있는 금고
contract Vault is ReentrancyGuard, Pausable, Ownable2Step {
using SafeERC20 for IERC20;
// ============ 에러 ============
error ZeroAmount();
error ZeroAddress();
error InsufficientBalance(address user, uint256 available, uint256 requested);
error ExceedsDepositLimit(uint256 amount, uint256 limit);
error TotalDepositLimitReached(uint256 totalDeposits, uint256 limit);
// ============ 상태 변수 ============
/// @notice 예치할 수 있는 토큰 컨트랙트
IERC20 public immutable token;
/// @notice 사용자별 예치 잔액
mapping(address => uint256) private _deposits;
/// @notice 총 예치량
uint256 public totalDeposits;
/// @notice 사용자 1인당 최대 예치 한도
uint256 public depositLimit;
/// @notice 금고 전체 최대 예치 한도
uint256 public totalDepositLimit;
// ============ 이벤트 ============
event Deposited(
address indexed user,
uint256 amount,
uint256 newUserBalance,
uint256 newTotalDeposits
);
event Withdrawn(
address indexed user,
uint256 amount,
uint256 newUserBalance,
uint256 newTotalDeposits
);
event DepositLimitUpdated(uint256 oldLimit, uint256 newLimit);
event TotalDepositLimitUpdated(uint256 oldLimit, uint256 newLimit);
event EmergencyWithdrawn(address indexed to, uint256 amount);
// ============ 생성자 ============
/// @param _token 예치 토큰 주소 (MyToken)
/// @param initialOwner 초기 소유자
/// @param _depositLimit 사용자 1인당 최대 예치량
/// @param _totalDepositLimit 금고 전체 최대 예치량
constructor(
address _token,
address initialOwner,
uint256 _depositLimit,
uint256 _totalDepositLimit
) Ownable(initialOwner) {
if (_token == address(0)) revert ZeroAddress();
if (initialOwner == address(0)) revert ZeroAddress();
token = IERC20(_token);
depositLimit = _depositLimit;
totalDepositLimit = _totalDepositLimit;
}
// ============ 핵심 함수 ============
/// @notice 토큰을 금고에 예치
/// @param amount 예치할 토큰 수량 (wei 단위)
/// @dev 사전에 Vault에 대한 approve 필요
function deposit(uint256 amount)
external
nonReentrant
whenNotPaused
{
// Checks
if (amount == 0) revert ZeroAmount();
uint256 newUserBalance = _deposits[msg.sender] + amount;
if (newUserBalance > depositLimit) {
revert ExceedsDepositLimit(amount, depositLimit);
}
uint256 newTotalDeposits = totalDeposits + amount;
if (newTotalDeposits > totalDepositLimit) {
revert TotalDepositLimitReached(totalDeposits, totalDepositLimit);
}
// Effects
_deposits[msg.sender] = newUserBalance;
totalDeposits = newTotalDeposits;
// Interactions
token.safeTransferFrom(msg.sender, address(this), amount);
emit Deposited(msg.sender, amount, newUserBalance, newTotalDeposits);
}
/// @notice 예치한 토큰을 출금
/// @param amount 출금할 토큰 수량 (wei 단위)
function withdraw(uint256 amount)
external
nonReentrant
whenNotPaused
{
// Checks
if (amount == 0) revert ZeroAmount();
uint256 currentBalance = _deposits[msg.sender];
if (currentBalance < amount) {
revert InsufficientBalance(msg.sender, currentBalance, amount);
}
// Effects
uint256 newUserBalance = currentBalance - amount;
_deposits[msg.sender] = newUserBalance;
totalDeposits -= amount;
// Interactions
token.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount, newUserBalance, totalDeposits);
}
// ============ 조회 함수 ============
/// @notice 특정 사용자의 예치 잔액
function balanceOf(address user) external view returns (uint256) {
return _deposits[user];
}
/// @notice 금고에 실제로 있는 토큰 수
function totalAssets() external view returns (uint256) {
return token.balanceOf(address(this));
}
/// @notice 금고의 남은 예치 가능량 (전체 한도 기준)
function remainingCapacity() external view returns (uint256) {
return totalDepositLimit - totalDeposits;
}
/// @notice 특정 사용자의 남은 예치 가능량 (개인 한도 기준)
function remainingUserCapacity(address user) external view returns (uint256) {
uint256 used = _deposits[user];
if (used >= depositLimit) return 0;
return depositLimit - used;
}
// ============ 소유자 전용 함수 ============
/// @notice 사용자 1인당 예치 한도 변경
function setDepositLimit(uint256 newLimit) external onlyOwner {
emit DepositLimitUpdated(depositLimit, newLimit);
depositLimit = newLimit;
}
/// @notice 전체 예치 한도 변경
function setTotalDepositLimit(uint256 newLimit) external onlyOwner {
emit TotalDepositLimitUpdated(totalDepositLimit, newLimit);
totalDepositLimit = newLimit;
}
/// @notice 금고 일시 정지
function pause() external onlyOwner {
_pause();
}
/// @notice 금고 재개
function unpause() external onlyOwner {
_unpause();
}
/// @notice 긴급 자금 회수 (일시 정지 상태에서만 가능)
/// @param to 자금을 받을 주소
function emergencyWithdraw(address to) external onlyOwner whenPaused {
if (to == address(0)) revert ZeroAddress();
uint256 balance = token.balanceOf(address(this));
token.safeTransfer(to, balance);
emit EmergencyWithdrawn(to, balance);
}
}
Vault 설계 포인트
- SafeERC20 사용:
transfer반환값 false를 무시하지 않고 자동으로 revert - CEI 패턴: Checks → Effects(상태 변경) → Interactions(외부 호출)
- 이중 한도 관리: 개인 한도 + 전체 한도로 리스크 제어
- emergencyWithdraw: 일시 정지 상태에서만 실행 가능 (정상 운영 중 오용 방지)
- immutable token: 배포 후 변경 불가 — storage 접근보다 저렴
테스트 코드
test/MyToken.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";
contract MyTokenTest is Test {
MyToken public token;
address public owner = makeAddr("owner");
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
uint256 public constant INITIAL_SUPPLY = 500_000 * 10 ** 18;
function setUp() public {
vm.prank(owner);
token = new MyToken(owner, INITIAL_SUPPLY);
}
// ============ 초기 상태 ============
function test_InitialState() public view {
assertEq(token.name(), "MyToken");
assertEq(token.symbol(), "MTK");
assertEq(token.decimals(), 18);
assertEq(token.totalSupply(), INITIAL_SUPPLY);
assertEq(token.balanceOf(owner), INITIAL_SUPPLY);
assertEq(token.owner(), owner);
assertEq(token.MAX_SUPPLY(), 1_000_000 * 10 ** 18);
}
function test_RemainingMintable() public view {
assertEq(token.remainingMintable(), 500_000 * 10 ** 18);
}
// ============ 민팅 ============
function test_Mint() public {
uint256 mintAmount = 100_000 * 10 ** 18;
vm.prank(owner);
token.mint(alice, mintAmount);
assertEq(token.balanceOf(alice), mintAmount);
assertEq(token.totalSupply(), INITIAL_SUPPLY + mintAmount);
}
function test_Mint_RevertIfNotOwner() public {
vm.expectRevert(
abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", alice)
);
vm.prank(alice);
token.mint(alice, 100);
}
function test_Mint_RevertIfExceedsMaxSupply() public {
uint256 available = token.remainingMintable();
vm.expectRevert(
abi.encodeWithSelector(
MyToken.ExceedsMaxSupply.selector,
available + 1,
available
)
);
vm.prank(owner);
token.mint(alice, available + 1);
}
function test_Mint_RevertZeroAddress() public {
vm.expectRevert(MyToken.ZeroAddress.selector);
vm.prank(owner);
token.mint(address(0), 100);
}
function test_Mint_RevertZeroAmount() public {
vm.expectRevert(MyToken.ZeroAmount.selector);
vm.prank(owner);
token.mint(alice, 0);
}
// ============ 소각 ============
function test_Burn() public {
uint256 burnAmount = 100 * 10 ** 18;
vm.prank(owner);
token.burn(burnAmount);
assertEq(token.totalSupply(), INITIAL_SUPPLY - burnAmount);
assertEq(token.balanceOf(owner), INITIAL_SUPPLY - burnAmount);
}
function test_BurnFrom() public {
uint256 burnAmount = 100 * 10 ** 18;
// alice가 owner에게 소각 권한 부여
vm.prank(owner);
token.transfer(alice, burnAmount);
vm.prank(alice);
token.approve(owner, burnAmount);
vm.prank(owner);
token.burnFrom(alice, burnAmount);
assertEq(token.balanceOf(alice), 0);
assertEq(token.totalSupply(), INITIAL_SUPPLY - burnAmount);
}
// ============ 소유권 이전 (2단계) ============
function test_TransferOwnership() public {
vm.prank(owner);
token.transferOwnership(alice);
// 아직 이전 완료 안 됨
assertEq(token.owner(), owner);
assertEq(token.pendingOwner(), alice);
// alice가 수락
vm.prank(alice);
token.acceptOwnership();
assertEq(token.owner(), alice);
}
// ============ 퍼즈 테스트 ============
function testFuzz_MintAndBurn(uint256 mintAmount) public {
mintAmount = bound(mintAmount, 1, token.remainingMintable());
vm.prank(owner);
token.mint(alice, mintAmount);
assertEq(token.balanceOf(alice), mintAmount);
assertLe(token.totalSupply(), token.MAX_SUPPLY());
vm.prank(alice);
token.burn(mintAmount);
assertEq(token.balanceOf(alice), 0);
}
}
test/Vault.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";
import {Vault} from "../src/Vault.sol";
contract VaultTest is Test {
MyToken public token;
Vault public vault;
address public owner = makeAddr("owner");
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
uint256 public constant DEPOSIT_LIMIT = 10_000 * 10 ** 18;
uint256 public constant TOTAL_LIMIT = 100_000 * 10 ** 18;
uint256 public constant ALICE_BALANCE = 20_000 * 10 ** 18;
function setUp() public {
vm.startPrank(owner);
// 토큰 배포
token = new MyToken(owner, 500_000 * 10 ** 18);
// Vault 배포
vault = new Vault(
address(token),
owner,
DEPOSIT_LIMIT,
TOTAL_LIMIT
);
// Alice에게 토큰 지급
token.mint(alice, ALICE_BALANCE);
token.mint(bob, ALICE_BALANCE);
vm.stopPrank();
// Alice가 Vault에 approve
vm.prank(alice);
token.approve(address(vault), type(uint256).max);
vm.prank(bob);
token.approve(address(vault), type(uint256).max);
}
// ============ 예치 테스트 ============
function test_Deposit() public {
uint256 amount = 1_000 * 10 ** 18;
vm.prank(alice);
vault.deposit(amount);
assertEq(vault.balanceOf(alice), amount);
assertEq(vault.totalDeposits(), amount);
assertEq(token.balanceOf(address(vault)), amount);
assertEq(token.balanceOf(alice), ALICE_BALANCE - amount);
}
function test_Deposit_EmitsEvent() public {
uint256 amount = 1_000 * 10 ** 18;
vm.expectEmit(true, false, false, true);
emit Vault.Deposited(alice, amount, amount, amount);
vm.prank(alice);
vault.deposit(amount);
}
function test_Deposit_RevertZeroAmount() public {
vm.expectRevert(Vault.ZeroAmount.selector);
vm.prank(alice);
vault.deposit(0);
}
function test_Deposit_RevertExceedsUserLimit() public {
vm.expectRevert(
abi.encodeWithSelector(
Vault.ExceedsDepositLimit.selector,
DEPOSIT_LIMIT + 1,
DEPOSIT_LIMIT
)
);
vm.prank(alice);
vault.deposit(DEPOSIT_LIMIT + 1);
}
function test_Deposit_MultipleUsers() public {
uint256 amount = 5_000 * 10 ** 18;
vm.prank(alice);
vault.deposit(amount);
vm.prank(bob);
vault.deposit(amount);
assertEq(vault.totalDeposits(), amount * 2);
assertEq(vault.balanceOf(alice), amount);
assertEq(vault.balanceOf(bob), amount);
}
// ============ 출금 테스트 ============
function test_Withdraw() public {
uint256 depositAmount = 5_000 * 10 ** 18;
uint256 withdrawAmount = 2_000 * 10 ** 18;
vm.startPrank(alice);
vault.deposit(depositAmount);
vault.withdraw(withdrawAmount);
vm.stopPrank();
assertEq(vault.balanceOf(alice), depositAmount - withdrawAmount);
assertEq(vault.totalDeposits(), depositAmount - withdrawAmount);
assertEq(
token.balanceOf(alice),
ALICE_BALANCE - depositAmount + withdrawAmount
);
}
function test_Withdraw_Full() public {
uint256 amount = 5_000 * 10 ** 18;
vm.startPrank(alice);
vault.deposit(amount);
vault.withdraw(amount);
vm.stopPrank();
assertEq(vault.balanceOf(alice), 0);
assertEq(vault.totalDeposits(), 0);
assertEq(token.balanceOf(alice), ALICE_BALANCE);
}
function test_Withdraw_RevertInsufficientBalance() public {
uint256 depositAmount = 1_000 * 10 ** 18;
vm.prank(alice);
vault.deposit(depositAmount);
vm.expectRevert(
abi.encodeWithSelector(
Vault.InsufficientBalance.selector,
alice,
depositAmount,
depositAmount + 1
)
);
vm.prank(alice);
vault.withdraw(depositAmount + 1);
}
// ============ 일시 정지 ============
function test_Pause() public {
vm.prank(owner);
vault.pause();
assertTrue(vault.paused());
vm.expectRevert(abi.encodeWithSignature("EnforcedPause()"));
vm.prank(alice);
vault.deposit(1000);
}
function test_Unpause() public {
vm.prank(owner);
vault.pause();
vm.prank(owner);
vault.unpause();
assertFalse(vault.paused());
// 재개 후 정상 동작
vm.prank(alice);
vault.deposit(1_000 * 10 ** 18);
}
function test_EmergencyWithdraw() public {
uint256 amount = 5_000 * 10 ** 18;
vm.prank(alice);
vault.deposit(amount);
vm.prank(owner);
vault.pause();
uint256 ownerBalanceBefore = token.balanceOf(owner);
vm.prank(owner);
vault.emergencyWithdraw(owner);
assertEq(token.balanceOf(owner), ownerBalanceBefore + amount);
assertEq(token.balanceOf(address(vault)), 0);
}
function test_EmergencyWithdraw_RevertWhenNotPaused() public {
vm.expectRevert(abi.encodeWithSignature("ExpectedPause()"));
vm.prank(owner);
vault.emergencyWithdraw(owner);
}
// ============ 한도 관리 ============
function test_SetDepositLimit() public {
uint256 newLimit = 50_000 * 10 ** 18;
vm.prank(owner);
vault.setDepositLimit(newLimit);
assertEq(vault.depositLimit(), newLimit);
}
function test_RemainingCapacity() public {
uint256 amount = 30_000 * 10 ** 18;
vm.prank(owner);
token.mint(alice, amount);
vm.prank(alice);
token.approve(address(vault), amount);
vm.prank(owner);
vault.setDepositLimit(amount); // 개인 한도 늘리기
vm.prank(alice);
vault.deposit(amount);
assertEq(vault.remainingCapacity(), TOTAL_LIMIT - amount);
assertEq(vault.remainingUserCapacity(alice), 0);
}
// ============ 통합 시나리오 ============
function test_FullScenario() public {
// 1. Alice 예치
uint256 aliceDeposit = 5_000 * 10 ** 18;
vm.prank(alice);
vault.deposit(aliceDeposit);
// 2. Bob 예치
uint256 bobDeposit = 3_000 * 10 ** 18;
vm.prank(bob);
vault.deposit(bobDeposit);
assertEq(vault.totalDeposits(), aliceDeposit + bobDeposit);
// 3. Alice 일부 출금
uint256 aliceWithdraw = 2_000 * 10 ** 18;
vm.prank(alice);
vault.withdraw(aliceWithdraw);
assertEq(vault.balanceOf(alice), aliceDeposit - aliceWithdraw);
// 4. 비상 상황: 일시 정지 후 긴급 출금
vm.prank(owner);
vault.pause();
vm.prank(owner);
vault.emergencyWithdraw(owner);
assertEq(token.balanceOf(address(vault)), 0);
}
// ============ 퍼즈 테스트 ============
function testFuzz_DepositAndWithdraw(uint256 depositAmount, uint256 withdrawAmount) public {
depositAmount = bound(depositAmount, 1, DEPOSIT_LIMIT);
withdrawAmount = bound(withdrawAmount, 1, depositAmount);
// Alice가 충분한 토큰이 있는지 확인
if (token.balanceOf(alice) < depositAmount) {
vm.prank(owner);
token.mint(alice, depositAmount - token.balanceOf(alice));
}
vm.startPrank(alice);
vault.deposit(depositAmount);
vault.withdraw(withdrawAmount);
vm.stopPrank();
assertEq(vault.balanceOf(alice), depositAmount - withdrawAmount);
// 불변식: vault의 실제 잔액 = 총 예치량
assertEq(token.balanceOf(address(vault)), vault.totalDeposits());
}
}
배포 스크립트
// script/Deploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console} from "forge-std/Script.sol";
import {MyToken} from "../src/MyToken.sol";
import {Vault} from "../src/Vault.sol";
contract Deploy is Script {
// 배포 설정
uint256 constant INITIAL_SUPPLY = 500_000 * 10 ** 18;
uint256 constant DEPOSIT_LIMIT = 10_000 * 10 ** 18;
uint256 constant TOTAL_LIMIT = 100_000 * 10 ** 18;
function run() external returns (MyToken token, Vault vault) {
uint256 deployerKey = vm.envUint("PRIVATE_KEY");
address deployer = vm.addr(deployerKey);
console.log("=== Token Vault Deployment ===");
console.log("Deployer:", deployer);
console.log("Network Chain ID:", block.chainid);
vm.startBroadcast(deployerKey);
// 1. MyToken 배포
token = new MyToken(deployer, INITIAL_SUPPLY);
console.log("MyToken deployed:", address(token));
console.log(" Initial supply:", INITIAL_SUPPLY / 10 ** 18, "MTK");
// 2. Vault 배포
vault = new Vault(
address(token),
deployer,
DEPOSIT_LIMIT,
TOTAL_LIMIT
);
console.log("Vault deployed:", address(vault));
console.log(" Deposit limit:", DEPOSIT_LIMIT / 10 ** 18, "MTK per user");
console.log(" Total limit:", TOTAL_LIMIT / 10 ** 18, "MTK");
// 3. 초기 설정: Vault에 토큰 민팅 권한은 부여하지 않음
// (Vault는 예치된 토큰만 관리, 새 토큰 발행 없음)
vm.stopBroadcast();
console.log("=== Deployment Complete ===");
console.log("Next steps:");
console.log(" 1. Users approve Vault to spend their tokens");
console.log(" 2. Users call vault.deposit(amount)");
console.log(" 3. Users call vault.withdraw(amount)");
}
}
단계별 실행 가이드
1단계: 프로젝트 설정
# 프로젝트 생성
forge init token-vault
cd token-vault
# OpenZeppelin 설치
forge install OpenZeppelin/openzeppelin-contracts
# remappings.txt 설정
echo "@openzeppelin/=lib/openzeppelin-contracts/" > remappings.txt
# src/ 파일 작성 (MyToken.sol, Vault.sol)
# test/ 파일 작성
# script/ 파일 작성
2단계: 컴파일 및 테스트
# 컴파일
forge build
# 전체 테스트 실행
forge test -vvv
# 가스 리포트
forge test --gas-report
# 커버리지
forge coverage
예상 출력:
Running 20 tests for test/Vault.t.sol:VaultTest
[PASS] test_Deposit() (gas: 98234)
[PASS] test_Withdraw() (gas: 75123)
[PASS] test_Pause() (gas: 45678)
[PASS] test_RevertWhen_DepositZero() (gas: 31890)
[PASS] test_RevertWhen_WithdrawTooMuch() (gas: 40211)
Test result: ok. 20 passed; 0 failed; finished in 45.23ms
3단계: 로컬 배포
# 터미널 1: Anvil 실행
anvil
# 터미널 2: 배포
export PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
forge script script/Deploy.s.sol \
--rpc-url http://127.0.0.1:8545 \
--broadcast \
-vvv
4단계: cast로 상호작용
# 배포된 주소 확인
export TOKEN=<MyToken 주소>
export VAULT=<Vault 주소>
export USER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
export PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
# 잔액 확인
cast call $TOKEN "balanceOf(address)(uint256)" $USER \
--rpc-url http://127.0.0.1:8545
# Vault에 approve (10,000 MTK)
cast send $TOKEN \
"approve(address,uint256)" \
$VAULT $(cast to-wei 10000 ether) \
--private-key $PRIVATE_KEY \
--rpc-url http://127.0.0.1:8545
# 1,000 MTK 예치
cast send $VAULT \
"deposit(uint256)" \
$(cast to-wei 1000 ether) \
--private-key $PRIVATE_KEY \
--rpc-url http://127.0.0.1:8545
# Vault 잔액 확인
cast call $VAULT "balanceOf(address)(uint256)" $USER \
--rpc-url http://127.0.0.1:8545
# 500 MTK 출금
cast send $VAULT \
"withdraw(uint256)" \
$(cast to-wei 500 ether) \
--private-key $PRIVATE_KEY \
--rpc-url http://127.0.0.1:8545
5단계: 테스트넷 배포 (Sepolia)
# .env 파일 설정
cat > .env << EOF
PRIVATE_KEY=<your-private-key>
SEPOLIA_RPC_URL=https://eth-sepolia.alchemyapi.io/v2/<your-key>
ETHERSCAN_API_KEY=<your-etherscan-key>
EOF
source .env
# Sepolia 배포
forge script script/Deploy.s.sol \
--rpc-url $SEPOLIA_RPC_URL \
--broadcast \
--verify \
--etherscan-api-key $ETHERSCAN_API_KEY \
-vvvv
프로젝트 확장 아이디어
이 프로젝트를 기반으로 다음 기능을 추가해볼 수 있다:
1. 이자 기능
- 예치 기간에 비례한 이자 계산
- 블록 번호나 타임스탬프 기반 이자율
2. 유동성 토큰 (Vault Share Token)
- 예치 시 vMTK 토큰 발행
- 출금 시 vMTK 소각
- ERC-4626 표준 구현
3. 프록시 업그레이드
- UUPS 프록시로 업그레이드 가능하게
- V2에 수수료 기능 추가
4. 거버넌스
- 토큰 보유자 투표로 파라미터 변경
- OpenZeppelin Governor 활용
정리
이 미니프로젝트에서 사용한 핵심 패턴들:
| 패턴 | 적용 | 이유 |
|---|---|---|
| OpenZeppelin ERC20 상속 | MyToken | 검증된 구현 재사용 |
| Ownable2Step | 두 컨트랙트 모두 | 소유권 이전 실수 방지 |
| ReentrancyGuard | Vault | 재진입 공격 방지 |
| Pausable | Vault | 긴급 정지 |
| SafeERC20 | Vault | 안전한 토큰 전송 |
| CEI 패턴 | Vault | 재진입 방지 이중 보호 |
| 커스텀 에러 | 두 컨트랙트 모두 | 가스 효율 + 타입 안전 |
| immutable | Vault.token | 가스 절약 |
| 퍼즈 테스트 | 테스트 | 엣지 케이스 자동 탐지 |
6장: 컬렉션, 클로저, 이터레이터
Rust 컬렉션 개요
Rust의 표준 라이브러리는 여러 컬렉션 타입을 제공합니다. Node.js의 Array, Map, Set과 유사하지만 메모리 소유권과 함께 동작합니다.
주요 컬렉션 타입
| Rust | JavaScript/TypeScript | 설명 |
|---|---|---|
Vec<T> | Array | 동적 크기 배열 |
String | string | UTF-8 문자열 |
HashMap<K, V> | Map | 키-값 저장소 |
HashSet<T> | Set | 중복 없는 집합 |
BTreeMap<K, V> | Map (정렬됨) | 정렬된 키-값 저장소 |
VecDeque<T> | - | 앞뒤로 삽입/삭제 가능한 큐 |
LinkedList<T> | - | 이중 연결 리스트 |
BinaryHeap<T> | - | 우선순위 큐 |
모든 컬렉션은 힙에 할당됩니다. 따라서 소유권 이동, 참조, 클론 개념이 모두 적용됩니다.
컬렉션과 소유권
fn main() {
let v = vec![1, 2, 3];
// 소유권 이동 — v는 더 이상 사용 불가
let v2 = v;
// 참조로 접근 — 소유권 이동 없음
for item in &v2 {
println!("{}", item);
}
println!("Length: {}", v2.len()); // v2는 여전히 유효
}
이 장의 구성
- 공통 컬렉션 (6.1): Vec, String, HashMap 심화
- 클로저 (6.2): Fn, FnMut, FnOnce
- 이터레이터 (6.3): map, filter, collect 체인
블록체인에서의 컬렉션
struct Blockchain {
// Vec: 순서가 있는 블록 목록
blocks: Vec<Block>,
// HashMap: 트랜잭션 ID → 트랜잭션 빠른 조회
tx_index: HashMap<String, Transaction>,
// HashSet: 사용된 UTXO 추적 (이중 지출 방지)
spent_outputs: HashSet<String>,
}
다음 챕터에서 각 컬렉션을 자세히 배웁니다.
6.1 공통 컬렉션: Vec, String, HashMap
Vec<T>: 동적 배열
Vec<T>는 JavaScript의 Array에 해당하는 동적 크기 배열입니다. 힙에 할당되며, 크기가 런타임에 변경됩니다.
생성
fn main() {
// 빈 Vec 생성
let v1: Vec<i32> = Vec::new();
// 타입 추론 가능하면 생략
let mut v2 = Vec::new();
v2.push(1); // 컴파일러가 Vec<i32>로 추론
// vec! 매크로로 초기값 지정
let v3 = vec![1, 2, 3, 4, 5];
// 특정 크기와 초기값으로
let v4: Vec<u8> = vec![0; 32]; // [0, 0, 0, ..., 0] (32개)
// 범위에서 생성
let v5: Vec<i32> = (0..10).collect(); // [0, 1, 2, ..., 9]
// 다른 컬렉션에서 변환
let v6: Vec<String> = vec!["a", "b", "c"]
.iter()
.map(|s| s.to_string())
.collect();
}
TypeScript와 비교:
const v1: number[] = [];
const v2 = new Array<number>();
const v3 = [1, 2, 3, 4, 5];
const v4 = new Array(32).fill(0);
const v5 = Array.from({ length: 10 }, (_, i) => i);
읽기와 수정
fn main() {
let mut v = vec![10, 20, 30, 40, 50];
// 인덱스 접근 — 범위 초과 시 panic!
println!("{}", v[0]); // 10
println!("{}", v[4]); // 50
// 안전한 접근 — Option 반환
println!("{:?}", v.get(2)); // Some(30)
println!("{:?}", v.get(100)); // None (panic 없음)
// 수정
v[0] = 100;
println!("{:?}", v); // [100, 20, 30, 40, 50]
// 추가
v.push(60);
println!("{:?}", v); // [100, 20, 30, 40, 50, 60]
// 마지막 원소 제거
let last = v.pop(); // Some(60)
println!("{:?}", last);
// 특정 위치에 삽입 (O(n) 비용)
v.insert(1, 15); // 인덱스 1에 15 삽입
println!("{:?}", v); // [100, 15, 20, 30, 40, 50]
// 특정 위치 제거 (O(n) 비용)
let removed = v.remove(1); // 인덱스 1 제거
println!("Removed: {}", removed); // 15
// 마지막과 교환 후 제거 (O(1), 순서 바뀜)
let swapped = v.swap_remove(0);
println!("Swap-removed: {}", swapped); // 100
}
반복
fn main() {
let v = vec![1, 2, 3, 4, 5];
// 불변 참조로 반복
for item in &v {
println!("{}", item);
}
// v는 여전히 유효
// 인덱스와 함께
for (i, item) in v.iter().enumerate() {
println!("[{}] = {}", i, item);
}
// 소유권 이동으로 반복 (v는 이후 사용 불가)
for item in v {
println!("{}", item);
}
// println!("{:?}", v); // 에러! v는 소비됨
// 가변 참조로 수정하며 반복
let mut v2 = vec![1, 2, 3];
for item in &mut v2 {
*item *= 2; // 역참조로 값 수정
}
println!("{:?}", v2); // [2, 4, 6]
}
유용한 메서드
fn main() {
let mut v = vec![3, 1, 4, 1, 5, 9, 2, 6, 5, 3];
// 길이와 비어있는지
println!("len: {}", v.len()); // 10
println!("empty: {}", v.is_empty()); // false
// 정렬
v.sort();
println!("{:?}", v); // [1, 1, 2, 3, 3, 4, 5, 5, 6, 9]
// 역순 정렬
v.sort_by(|a, b| b.cmp(a));
println!("{:?}", v); // [9, 6, 5, 5, 4, 3, 3, 2, 1, 1]
// 커스텀 정렬 (key 기반)
let mut words = vec!["banana", "apple", "cherry"];
words.sort_by_key(|s| s.len());
println!("{:?}", words); // ["apple", "banana", "cherry"]
// 중복 제거 (정렬 후)
v.sort();
v.dedup();
println!("{:?}", v); // [1, 2, 3, 4, 5, 6, 9]
// 검색
println!("{:?}", v.contains(&5)); // true
println!("{:?}", v.iter().position(|&x| x == 5)); // Some(4)
// 분할
let (left, right) = v.split_at(3);
println!("{:?} | {:?}", left, right);
// 연결
let a = vec![1, 2, 3];
let b = vec![4, 5, 6];
let combined: Vec<i32> = a.into_iter().chain(b.into_iter()).collect();
println!("{:?}", combined);
// 확장
let mut c = vec![1, 2, 3];
c.extend([4, 5, 6]);
println!("{:?}", c);
// 잘라내기 (길이 제한)
c.truncate(4);
println!("{:?}", c); // [1, 2, 3, 4]
// 전체 지우기
c.clear();
println!("{:?}", c); // []
}
블록체인에서의 Vec 활용
struct MerkleTree {
leaves: Vec<String>, // 트랜잭션 해시들
}
impl MerkleTree {
fn new(transactions: Vec<String>) -> Self {
MerkleTree { leaves: transactions }
}
fn root(&self) -> Option<String> {
if self.leaves.is_empty() {
return None;
}
let mut current = self.leaves.clone();
while current.len() > 1 {
let mut next = Vec::new();
// 두 개씩 묶어서 해시
for chunk in current.chunks(2) {
let combined = match chunk {
[left, right] => format!("{}{}", left, right),
[left] => left.clone(), // 홀수 개면 마지막은 그대로
_ => unreachable!(),
};
next.push(hash(&combined));
}
current = next;
}
current.into_iter().next()
}
}
fn hash(s: &str) -> String {
format!("{:x}", s.len()) // 실제로는 SHA-256
}
String: UTF-8 문자열
String은 힙에 할당된 가변 UTF-8 문자열입니다.
생성과 변환
fn main() {
// 생성 방법들
let s1 = String::new();
let s2 = String::from("hello");
let s3 = "hello".to_string();
let s4 = "hello".to_owned(); // to_string()과 동일
// 숫자 → 문자열
let n = 42;
let s5 = n.to_string();
let s6 = format!("{}", n);
// 문자열 → 숫자
let parsed: Result<i32, _> = "42".parse();
let num: i32 = "42".parse().unwrap();
}
이어붙이기
fn main() {
// push_str: 문자열 추가
let mut s = String::from("Hello");
s.push_str(", World");
s.push('!');
println!("{}", s); // "Hello, World!"
// + 연산자 (s1의 소유권이 이동됨!)
let s1 = String::from("Hello");
let s2 = String::from(", World!");
let s3 = s1 + &s2; // s1은 더 이상 유효하지 않음
println!("{}", s3);
// format! (소유권 이동 없음, 권장)
let s1 = String::from("Hello");
let s2 = String::from(", World!");
let s3 = format!("{}{}", s1, s2);
println!("{} {}", s1, s2); // s1, s2 모두 유효
println!("{}", s3);
}
인덱싱과 슬라이싱
fn main() {
let s = String::from("hello");
// 인덱스로 접근 불가! (UTF-8 때문)
// let c = s[0]; // 에러!
// 바이트 슬라이스 (ASCII는 OK, 멀티바이트 문자는 위험)
let slice = &s[0..3]; // "hel" (바이트 단위)
// 문자 단위 반복
for c in s.chars() {
print!("{} ", c); // h e l l o
}
// 바이트 단위 반복
for b in s.bytes() {
print!("{} ", b); // 104 101 108 108 111
}
// 한글 처리
let korean = String::from("안녕하세요");
println!("len (bytes): {}", korean.len()); // 15 (한글 = 3바이트)
println!("chars: {}", korean.chars().count()); // 5 (문자 수)
// 첫 번째 문자
let first: Option<char> = korean.chars().next();
println!("{:?}", first); // Some('안')
// n번째 문자 (O(n))
let third: Option<char> = korean.chars().nth(2);
println!("{:?}", third); // Some('하')
}
주요 String 메서드
fn main() {
let s = String::from(" Hello, World! ");
// 공백 제거
println!("{}", s.trim()); // "Hello, World!"
println!("{}", s.trim_start()); // "Hello, World! "
println!("{}", s.trim_end()); // " Hello, World!"
// 대소문자
println!("{}", s.trim().to_uppercase()); // "HELLO, WORLD!"
println!("{}", s.trim().to_lowercase()); // "hello, world!"
// 검색
println!("{}", s.contains("World")); // true
println!("{}", s.starts_with(" Hello")); // true
println!("{}", s.ends_with("! ")); // true
println!("{:?}", s.find("World")); // Some(9)
// 분리
let csv = "Alice,Bob,Carol";
let names: Vec<&str> = csv.split(',').collect();
println!("{:?}", names); // ["Alice", "Bob", "Carol"]
// 줄 분리
let text = "line1\nline2\nline3";
for line in text.lines() {
println!("{}", line);
}
// 교체
let replaced = "hello world".replace("world", "Rust");
println!("{}", replaced); // "hello Rust"
// 반복
let repeated = "abc".repeat(3);
println!("{}", repeated); // "abcabcabc"
// 문자 확인
println!("{}", "123".chars().all(|c| c.is_ascii_digit())); // true
println!("{}", "abc".chars().all(|c| c.is_alphabetic())); // true
// 분할 후 수집
let words: Vec<&str> = "one two three".split_whitespace().collect();
println!("{:?}", words); // ["one", "two", "three"]
}
HashMap<K, V>: 키-값 저장소
HashMap<K, V>는 JavaScript의 Map에 해당합니다.
생성과 삽입
use std::collections::HashMap;
fn main() {
// 빈 HashMap 생성
let mut scores: HashMap<String, u64> = HashMap::new();
// 삽입
scores.insert(String::from("Alice"), 100);
scores.insert(String::from("Bob"), 200);
scores.insert(String::from("Carol"), 150);
// 리터럴로 생성 (collect 이용)
let map: HashMap<&str, i32> = [
("one", 1),
("two", 2),
("three", 3),
].iter().cloned().collect();
// Rust 1.56+ 방법
let map2 = HashMap::from([
("Alice", 100),
("Bob", 200),
]);
println!("{:?}", scores);
}
읽기
use std::collections::HashMap;
fn main() {
let mut map = HashMap::from([
(String::from("Alice"), 100u64),
(String::from("Bob"), 200u64),
]);
// 인덱스 접근 — 키가 없으면 panic!
// let score = map["Charlie"]; // panic!
// 안전한 접근 — Option 반환
let alice_score = map.get("Alice"); // Some(&100)
let charlie_score = map.get("Charlie"); // None
println!("{:?}", alice_score); // Some(100)
println!("{:?}", charlie_score); // None
// 참조 없이 값만 얻기
let score = map.get("Alice").copied(); // Option<u64> (Copy 타입이므로)
let score2 = map.get("Alice").cloned(); // Option<u64> (Clone으로)
// 키 존재 확인
println!("{}", map.contains_key("Alice")); // true
// 기본값으로 가져오기
let score = map.get("Charlie").copied().unwrap_or(0);
println!("{}", score); // 0
}
업데이트
use std::collections::HashMap;
fn main() {
let mut map: HashMap<String, u64> = HashMap::new();
// 1. 덮어쓰기
map.insert(String::from("Alice"), 100);
map.insert(String::from("Alice"), 200); // 기존 값 교체
println!("{:?}", map.get("Alice")); // Some(200)
// 2. 없을 때만 삽입 (entry API)
map.entry(String::from("Bob")).or_insert(150);
map.entry(String::from("Bob")).or_insert(999); // 이미 있으므로 무시
println!("{:?}", map.get("Bob")); // Some(150)
// 3. 기존 값 기반 업데이트
let text = "hello world hello rust world hello";
let mut word_count: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
let count = word_count.entry(word).or_insert(0);
*count += 1; // 역참조로 값 수정
}
println!("{:?}", word_count);
// {"hello": 3, "world": 2, "rust": 1}
// 4. 조건부 업데이트
map.entry(String::from("Carol"))
.or_insert_with(|| 300); // 없을 때 클로저 실행
// 5. 삭제
map.remove("Alice");
println!("{}", map.contains_key("Alice")); // false
}
반복
use std::collections::HashMap;
fn main() {
let map = HashMap::from([
("Alice", 100),
("Bob", 200),
("Carol", 150),
]);
// 키-값 쌍 반복 (순서 불보장!)
for (name, score) in &map {
println!("{}: {}", name, score);
}
// 키만
for name in map.keys() {
println!("{}", name);
}
// 값만
for score in map.values() {
println!("{}", score);
}
// 정렬된 순서로 (BTreeMap 사용하거나 Vec으로 변환)
let mut sorted: Vec<(&str, &i32)> = map.iter().collect();
sorted.sort_by_key(|(k, _)| *k);
for (name, score) in sorted {
println!("{}: {}", name, score);
}
}
블록체인에서의 HashMap 활용
use std::collections::HashMap;
struct UTXO {
txid: String,
vout: u32,
amount: u64,
}
struct UTXOSet {
// txid:vout → UTXO
utxos: HashMap<String, UTXO>,
}
impl UTXOSet {
fn new() -> Self {
UTXOSet { utxos: HashMap::new() }
}
fn add(&mut self, utxo: UTXO) {
let key = format!("{}:{}", utxo.txid, utxo.vout);
self.utxos.insert(key, utxo);
}
fn spend(&mut self, txid: &str, vout: u32) -> Option<UTXO> {
let key = format!("{}:{}", txid, vout);
self.utxos.remove(&key)
}
fn get_balance(&self, address: &str) -> u64 {
self.utxos.values()
.filter(|u| u.txid.starts_with(address)) // 실제로는 주소 비교
.map(|u| u.amount)
.sum()
}
}
JavaScript Array/Map vs Rust Vec/HashMap 총 비교
// JavaScript
const arr = [1, 2, 3];
arr.push(4);
arr.pop();
arr[0];
arr.includes(2);
arr.indexOf(2);
arr.slice(0, 2);
arr.splice(1, 1);
arr.sort();
arr.reverse();
arr.find(x => x > 2);
arr.filter(x => x > 1);
arr.map(x => x * 2);
arr.reduce((acc, x) => acc + x, 0);
arr.forEach(x => console.log(x));
arr.some(x => x > 2);
arr.every(x => x > 0);
arr.flat();
arr.flatMap(x => [x, x * 2]);
const map = new Map();
map.set("key", "value");
map.get("key");
map.has("key");
map.delete("key");
map.size;
// Rust
let mut v = vec![1, 2, 3];
v.push(4);
v.pop();
v[0];
v.contains(&2);
v.iter().position(|&x| x == 2);
v[0..2].to_vec(); // 슬라이스 후 Vec으로
v.remove(1);
v.sort();
v.reverse();
v.iter().find(|&&x| x > 2);
v.iter().filter(|&&x| x > 1);
v.iter().map(|&x| x * 2);
v.iter().fold(0, |acc, &x| acc + x);
v.iter().for_each(|x| println!("{}", x));
v.iter().any(|&x| x > 2);
v.iter().all(|&x| x > 0);
v.iter().flatten();
v.iter().flat_map(|&x| vec![x, x * 2]);
let mut map = HashMap::new();
map.insert("key", "value");
map.get("key");
map.contains_key("key");
map.remove("key");
map.len();
요약
Vec<T>: 동적 배열,push/pop/insert/removeString: UTF-8 문자열, 인덱스 접근 불가 (chars()로 문자 단위 접근)HashMap<K, V>: 키-값 저장,entry()API로 조건부 삽입/업데이트- 컬렉션 반복:
&v(불변 참조),&mut v(가변 참조),v(소유권 이동) get()으로 안전하게 접근 (Option 반환), 인덱스는 panic 위험
다음 챕터에서 클로저를 배웁니다.
6.2 클로저 (Closures)
클로저란?
클로저는 환경에서 변수를 캡처할 수 있는 익명 함수입니다. JavaScript의 화살표 함수와 매우 유사합니다.
// JavaScript/TypeScript 화살표 함수
const add = (x: number, y: number): number => x + y;
const double = (x: number) => x * 2;
const greet = () => "Hello!";
// 환경 캡처
const multiplier = 3;
const multiplyBy = (x: number) => x * multiplier; // 외부 변수 캡처
// Rust 클로저
let add = |x, y| x + y;
let double = |x| x * 2;
let greet = || "Hello!";
// 환경 캡처
let multiplier = 3;
let multiply_by = |x| x * multiplier; // 외부 변수 캡처
println!("{}", multiply_by(5)); // 15
클로저 문법
fn main() {
// 기본 형태: |매개변수| 표현식
let add = |x, y| x + y;
// 타입 명시 (보통 생략)
let add2 = |x: i32, y: i32| -> i32 { x + y };
// 여러 줄 블록
let complex = |x: i32| {
let doubled = x * 2;
let added = doubled + 10;
added // 반환값 (return 키워드 없이)
};
// 매개변수 없음
let greet = || String::from("Hello!");
println!("{}", add(1, 2)); // 3
println!("{}", complex(5)); // 20
println!("{}", greet()); // Hello!
}
타입 추론
Rust는 사용 방법에서 클로저의 타입을 추론합니다:
fn main() {
let add = |x, y| x + y; // 타입 아직 미결정
let result = add(1i32, 2i32); // 이 줄에서 T = i32로 확정
// add(1.0f64, 2.0f64); // 에러! 이미 i32로 확정됨
// 각 클로저 인스턴스는 고유한 타입 — 두 개의 클로저는 같은 타입이 아님
let c1 = |x: i32| x + 1;
let c2 = |x: i32| x + 1; // c1과 동일해 보이지만 다른 타입
}
환경 캡처 방법
클로저는 외부 변수를 세 가지 방법으로 캡처합니다:
1. 불변 참조로 캡처 (기본)
fn main() {
let x = 5;
let print_x = || println!("x = {}", x); // &x 캡처
print_x(); // x = 5
print_x(); // 여러 번 호출 가능
println!("{}", x); // x는 여전히 유효
}
2. 가변 참조로 캡처
fn main() {
let mut count = 0;
let mut increment = || {
count += 1; // &mut count 캡처
println!("Count: {}", count);
};
increment(); // Count: 1
increment(); // Count: 2
// println!("{}", count); // 에러! increment가 가변 참조를 보유 중
}
// count는 여기서 다시 접근 가능
3. 소유권 이동 (move)
fn main() {
let data = vec![1, 2, 3];
// move: data의 소유권을 클로저로 이동
let owns_data = move || {
println!("{:?}", data);
};
owns_data();
// println!("{:?}", data); // 에러! data의 소유권이 클로저로 이동됨
// 스레드에 클로저를 보낼 때 특히 중요
let text = String::from("hello");
let thread = std::thread::spawn(move || {
println!("In thread: {}", text);
// text의 소유권이 스레드로 이동
});
thread.join().unwrap();
}
Fn, FnMut, FnOnce 트레이트
클로저는 캡처 방식에 따라 세 가지 트레이트 중 하나(또는 여러 개)를 구현합니다:
FnOnce: 소유권을 이동하는 클로저
fn call_once<F: FnOnce()>(f: F) {
f(); // 한 번만 호출 가능
// f(); // 에러! f는 소비됨
}
fn main() {
let text = String::from("hello");
let consume = move || {
println!("{}", text);
drop(text); // text를 소비
};
call_once(consume); // OK
// call_once(consume); // 에러! consume은 이미 소비됨
}
FnMut: 가변 참조를 캡처하는 클로저
fn call_multiple_times<F: FnMut()>(mut f: F) {
f();
f();
f();
}
fn main() {
let mut count = 0;
let mut counter = || {
count += 1;
println!("Count: {}", count);
};
call_multiple_times(&mut counter);
// 또는
// call_multiple_times(counter);
}
Fn: 불변 참조만 캡처하는 클로저
fn apply_twice<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
f(f(x))
}
fn main() {
let offset = 5;
let add_offset = |x| x + offset; // &offset 캡처 (불변)
println!("{}", apply_twice(add_offset, 10)); // 20
println!("{}", apply_twice(add_offset, 10)); // 20 — 여러 번 사용 가능
}
트레이트 관계
FnOnce ← FnMut ← Fn
(슈퍼트레이트) (서브트레이트)
- 모든
Fn은FnMut이기도 함 - 모든
FnMut은FnOnce이기도 함 - 함수 인자에
FnOnce를 요구하면 가장 유연함 (모든 클로저 받을 수 있음) Fn을 요구하면 가장 제한적 (불변 캡처만 가능)
#![allow(unused)]
fn main() {
// 가장 유연: FnOnce (어떤 클로저든 받음)
fn run_once<F: FnOnce() -> String>(f: F) -> String { f() }
// 중간: FnMut (반복 호출, 내부 상태 변경 가능)
fn run_many<F: FnMut() -> String>(mut f: F) { let _ = f(); let _ = f(); }
// 가장 제한적: Fn (반복 호출, 내부 상태 변경 불가)
fn run_shared<F: Fn() -> String>(f: F) { println!("{}", f()); println!("{}", f()); }
}
일반 함수도 클로저 트레이트를 구현함
fn double(x: i32) -> i32 { x * 2 }
fn apply<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
f(x)
}
fn main() {
// 일반 함수도 Fn 트레이트를 구현
println!("{}", apply(double, 5)); // 10
println!("{}", apply(|x| x + 1, 5)); // 6
}
클로저를 반환하는 함수
// 클로저 반환 — impl Fn 사용
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
move |y| x + y // x를 캡처해서 반환
}
fn make_multiplier(factor: f64) -> impl Fn(f64) -> f64 {
move |x| x * factor
}
fn main() {
let add5 = make_adder(5);
let add10 = make_adder(10);
println!("{}", add5(3)); // 8
println!("{}", add10(3)); // 13
let double = make_multiplier(2.0);
let triple = make_multiplier(3.0);
println!("{}", double(5.0)); // 10.0
println!("{}", triple(5.0)); // 15.0
}
여러 다른 클로저 타입을 반환해야 할 때는 Box<dyn Fn>:
fn make_validator(min: u64, max: u64) -> Box<dyn Fn(u64) -> bool> {
Box::new(move |value| value >= min && value <= max)
}
fn main() {
let valid_amount = make_validator(1, 1_000_000);
println!("{}", valid_amount(500)); // true
println!("{}", valid_amount(2_000_000)); // false
}
블록체인에서 클로저 활용
struct Transaction {
from: String,
to: String,
amount: u64,
fee: u64,
}
struct Mempool {
pending: Vec<Transaction>,
}
impl Mempool {
fn new() -> Self {
Mempool { pending: Vec::new() }
}
fn add(&mut self, tx: Transaction) {
self.pending.push(tx);
}
// 클로저로 필터링 기준 주입 (전략 패턴)
fn select_transactions<F>(&self, predicate: F) -> Vec<&Transaction>
where
F: Fn(&Transaction) -> bool,
{
self.pending.iter().filter(|tx| predicate(tx)).collect()
}
// 클로저로 정렬 기준 주입
fn get_sorted<F>(&self, key_fn: F) -> Vec<&Transaction>
where
F: Fn(&Transaction) -> u64,
{
let mut txs: Vec<&Transaction> = self.pending.iter().collect();
txs.sort_by_key(|tx| key_fn(tx));
txs
}
}
fn main() {
let mut mempool = Mempool::new();
mempool.add(Transaction { from: "A".into(), to: "B".into(), amount: 100, fee: 10 });
mempool.add(Transaction { from: "C".into(), to: "D".into(), amount: 5000, fee: 50 });
mempool.add(Transaction { from: "E".into(), to: "F".into(), amount: 50, fee: 5 });
// 최소 금액 필터 (클로저로 기준 주입)
let min_amount = 100u64;
let large_txs = mempool.select_transactions(|tx| tx.amount >= min_amount);
println!("Large transactions: {}", large_txs.len());
// 수수료 기준 정렬 (클로저로 기준 주입)
let by_fee = mempool.get_sorted(|tx| tx.fee);
for tx in by_fee {
println!("Fee: {}, Amount: {}", tx.fee, tx.amount);
}
// 인라인 클로저로 복잡한 필터
let profitable = mempool.select_transactions(|tx| {
let fee_rate = tx.fee * 100 / tx.amount.max(1);
fee_rate >= 10 && tx.amount >= 100
});
println!("Profitable: {}", profitable.len());
}
JavaScript 화살표 함수와 비교 정리
| JavaScript/TypeScript | Rust |
|---|---|
const f = (x) => x + 1 | let f = |x| x + 1; |
const f = (x, y) => x + y | let f = |x, y| x + y; |
const f = () => {} | let f = || {}; |
| 외부 변수 자동 캡처 | 불변 참조 우선, move로 소유권 이동 |
| 항상 여러 번 호출 가능 | Fn (여러 번), FnMut (가변), FnOnce (한 번) |
| 반환 시 클로저 타입 명시 불필요 | impl Fn(i32) -> i32 또는 Box<dyn Fn> |
요약
- 클로저:
|매개변수| 표현식문법의 익명 함수 - 환경 캡처: 불변 참조(기본), 가변 참조(
mut필요), 소유권 이동(move) Fn: 불변 참조, 여러 번 호출 가능FnMut: 가변 참조, 여러 번 호출 가능FnOnce: 소유권 이동, 한 번만 호출 가능- 클로저 반환:
impl Fn(...), 다형적이면Box<dyn Fn(...)> - 일반 함수도 Fn 트레이트를 구현함
다음 챕터에서 이터레이터를 배웁니다.
6.3 이터레이터 (Iterators)
Iterator 트레이트
Rust의 이터레이터는 Iterator 트레이트를 구현합니다:
#![allow(unused)]
fn main() {
pub trait Iterator {
type Item; // 순회할 원소 타입
fn next(&mut self) -> Option<Self::Item>; // 유일하게 구현 필수인 메서드
// 나머지 수백 개의 메서드는 기본 구현이 있음:
// map, filter, collect, fold, take, skip 등
}
}
next()를 구현하면 map, filter, collect 등 모든 어댑터를 무료로 얻습니다.
이터레이터 생성
fn main() {
let v = vec![1, 2, 3, 4, 5];
// iter(): 불변 참조 이터레이터 → &T
let mut iter1 = v.iter();
println!("{:?}", iter1.next()); // Some(1)
println!("{:?}", iter1.next()); // Some(2)
// into_iter(): 소유권을 가져가는 이터레이터 → T
let v2 = vec![1, 2, 3];
let mut iter2 = v2.into_iter();
println!("{:?}", iter2.next()); // Some(1)
// v2는 이제 사용 불가
// iter_mut(): 가변 참조 이터레이터 → &mut T
let mut v3 = vec![1, 2, 3];
for x in v3.iter_mut() {
*x *= 2;
}
println!("{:?}", v3); // [2, 4, 6]
// for 루프는 into_iter()를 자동 호출
let v4 = vec![1, 2, 3];
for x in &v4 { // = v4.iter()
print!("{} ", x);
}
for x in &mut v3 { // = v3.iter_mut()
*x += 1;
}
for x in v4 { // = v4.into_iter()
print!("{} ", x);
}
}
이터레이터 어댑터
어댑터는 이터레이터를 받아 새 이터레이터를 반환합니다. **지연 평가(lazy evaluation)**입니다 — 최종 소비 메서드(collect, sum, for_each 등)가 호출될 때까지 실행되지 않습니다.
map: 변환
fn main() {
let v = vec![1, 2, 3, 4, 5];
// 각 원소에 함수 적용
let doubled: Vec<i32> = v.iter().map(|&x| x * 2).collect();
println!("{:?}", doubled); // [2, 4, 6, 8, 10]
// 타입 변환
let strings: Vec<String> = v.iter().map(|x| x.to_string()).collect();
println!("{:?}", strings); // ["1", "2", "3", "4", "5"]
// 블록체인: 트랜잭션 해시 계산
let transactions = vec!["tx1_data", "tx2_data", "tx3_data"];
let hashes: Vec<String> = transactions.iter()
.map(|data| compute_hash(data))
.collect();
}
fn compute_hash(data: &str) -> String {
format!("{:x}", data.len()) // 실제로는 SHA-256
}
filter: 조건 필터링
fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let evens: Vec<&i32> = numbers.iter().filter(|&&x| x % 2 == 0).collect();
println!("{:?}", evens); // [2, 4, 6, 8, 10]
// 소유된 값으로
let evens_owned: Vec<i32> = numbers.iter().filter(|&&x| x % 2 == 0).copied().collect();
// 블록체인: 특정 조건의 트랜잭션 필터링
struct Transaction { amount: u64, confirmed: bool }
let txs = vec![
Transaction { amount: 100, confirmed: true },
Transaction { amount: 500, confirmed: false },
Transaction { amount: 1000, confirmed: true },
];
let confirmed_large: Vec<&Transaction> = txs.iter()
.filter(|tx| tx.confirmed && tx.amount >= 500)
.collect();
println!("Confirmed large txs: {}", confirmed_large.len()); // 1
}
map + filter 체이닝
fn main() {
let data = vec!["42", "abc", "100", "def", "7"];
// 파싱 성공한 것만 필터링
let valid_numbers: Vec<u32> = data.iter()
.filter_map(|s| s.parse::<u32>().ok()) // filter + map 한번에
.collect();
println!("{:?}", valid_numbers); // [42, 100, 7]
// filter_map = filter(Some) + map(unwrap)
let doubled_valid: Vec<u32> = data.iter()
.filter_map(|s| s.parse::<u32>().ok())
.map(|n| n * 2)
.collect();
println!("{:?}", doubled_valid); // [84, 200, 14]
}
collect: 이터레이터를 컬렉션으로
use std::collections::{HashMap, HashSet};
fn main() {
let numbers = vec![1, 2, 3, 2, 1];
// Vec로 수집
let v: Vec<i32> = numbers.iter().copied().collect();
// HashSet으로 수집 (중복 제거)
let set: HashSet<i32> = numbers.iter().copied().collect();
println!("{:?}", set); // {1, 2, 3}
// HashMap으로 수집
let words = vec!["hello", "world", "rust"];
let word_lengths: HashMap<&str, usize> = words.iter()
.map(|&w| (w, w.len()))
.collect();
println!("{:?}", word_lengths);
// String으로 수집
let chars = vec!['h', 'e', 'l', 'l', 'o'];
let s: String = chars.into_iter().collect();
println!("{}", s); // "hello"
// Result<Vec, E>로 수집 (하나라도 Err이면 전체 Err)
let strings = vec!["1", "2", "3"];
let numbers: Result<Vec<u32>, _> = strings.iter().map(|s| s.parse::<u32>()).collect();
println!("{:?}", numbers); // Ok([1, 2, 3])
let strings2 = vec!["1", "abc", "3"];
let numbers2: Result<Vec<u32>, _> = strings2.iter().map(|s| s.parse::<u32>()).collect();
println!("파싱 실패 여부: {}", numbers2.is_err());
}
fold와 reduce: 누적
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
// fold: 초기값 + 누적 함수
let sum = numbers.iter().fold(0i32, |acc, &x| acc + x);
let product = numbers.iter().fold(1i32, |acc, &x| acc * x);
println!("Sum: {}, Product: {}", sum, product); // 15, 120
// sum(), product(): 특화된 fold
let sum2: i32 = numbers.iter().sum();
let product2: i32 = numbers.iter().product();
println!("{}, {}", sum2, product2);
// reduce: 초기값 없음 (첫 원소가 초기값)
let max = numbers.iter().copied().reduce(|a, b| if a > b { a } else { b });
println!("{:?}", max); // Some(5)
// max(), min()
println!("{:?}", numbers.iter().max()); // Some(5)
println!("{:?}", numbers.iter().min()); // Some(1)
// 블록체인: 총 트랜잭션 금액
struct Tx { amount: u64 }
let txs = vec![Tx { amount: 100 }, Tx { amount: 200 }, Tx { amount: 150 }];
let total: u64 = txs.iter().map(|tx| tx.amount).sum();
println!("Total: {}", total); // 450
}
take, skip, enumerate, zip
fn main() {
let numbers: Vec<i32> = (1..=10).collect();
// take: 처음 n개
let first_three: Vec<i32> = numbers.iter().copied().take(3).collect();
println!("{:?}", first_three); // [1, 2, 3]
// skip: 처음 n개 건너뛰기
let after_three: Vec<i32> = numbers.iter().copied().skip(3).collect();
println!("{:?}", after_three); // [4, 5, 6, 7, 8, 9, 10]
// enumerate: 인덱스와 함께
for (i, &n) in numbers.iter().enumerate().take(3) {
println!("[{}] = {}", i, n); // [0] = 1, [1] = 2, [2] = 3
}
// zip: 두 이터레이터를 쌍으로 묶기
let names = vec!["Alice", "Bob", "Carol"];
let scores = vec![100, 200, 150];
let pairs: Vec<(&&str, &i32)> = names.iter().zip(scores.iter()).collect();
println!("{:?}", pairs);
// 더 실용적으로
let combined: Vec<String> = names.iter().zip(scores.iter())
.map(|(name, score)| format!("{}: {}", name, score))
.collect();
println!("{:?}", combined);
// chain: 두 이터레이터를 이어 붙이기
let a = vec![1, 2, 3];
let b = vec![4, 5, 6];
let chained: Vec<i32> = a.iter().chain(b.iter()).copied().collect();
println!("{:?}", chained); // [1, 2, 3, 4, 5, 6]
// flat_map: map 후 평탄화
let words = vec!["hello world", "foo bar"];
let all_words: Vec<&str> = words.iter()
.flat_map(|s| s.split_whitespace())
.collect();
println!("{:?}", all_words); // ["hello", "world", "foo", "bar"]
}
any, all, find, position
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
// any: 하나라도 조건 만족하면 true (단락 평가)
println!("{}", numbers.iter().any(|&x| x > 4)); // true
println!("{}", numbers.iter().any(|&x| x > 10)); // false
// all: 모두 조건 만족하면 true (단락 평가)
println!("{}", numbers.iter().all(|&x| x > 0)); // true
println!("{}", numbers.iter().all(|&x| x > 2)); // false
// find: 조건 만족하는 첫 번째 원소
let found = numbers.iter().find(|&&x| x > 3);
println!("{:?}", found); // Some(4)
// position: 조건 만족하는 첫 번째 인덱스
let pos = numbers.iter().position(|&x| x > 3);
println!("{:?}", pos); // Some(3)
// count
let count = numbers.iter().filter(|&&x| x % 2 == 0).count();
println!("{}", count); // 2 (2, 4)
}
지연 평가 (Lazy Evaluation)
이터레이터 어댑터는 지연 평가됩니다. collect() 같은 소비 메서드가 호출될 때까지 실행되지 않습니다:
fn main() {
let v = vec![1, 2, 3, 4, 5];
// 이 코드는 아직 아무것도 실행하지 않음
let iter = v.iter()
.map(|x| {
println!("mapping {}", x); // 아직 실행 안 됨!
x * 2
})
.filter(|x| x > &4);
println!("Iterator created, nothing executed yet");
// collect()를 호출할 때 실제 실행
let result: Vec<i32> = iter.collect();
println!("{:?}", result);
}
// 출력:
// Iterator created, nothing executed yet
// mapping 1
// mapping 2
// mapping 3 (filter에서 거름)
// mapping 4
// mapping 5
// [6, 8, 10]
장점: 불필요한 중간 컬렉션을 만들지 않아 메모리 효율적입니다.
#![allow(unused)]
fn main() {
// take(3)이 있으면 세 개 찾은 후 중단 (나머지는 실행 안 됨)
let first_three_even: Vec<i32> = (0..) // 무한 이터레이터!
.filter(|x| x % 2 == 0)
.take(3)
.collect();
println!("{:?}", first_three_even); // [0, 2, 4]
}
커스텀 이터레이터 구현
struct BlockHeightRange {
current: u64,
end: u64,
}
impl BlockHeightRange {
fn new(start: u64, end: u64) -> Self {
BlockHeightRange { current: start, end }
}
}
impl Iterator for BlockHeightRange {
type Item = u64;
fn next(&mut self) -> Option<u64> {
if self.current < self.end {
let height = self.current;
self.current += 1;
Some(height)
} else {
None
}
}
}
fn main() {
let range = BlockHeightRange::new(100, 105);
// Iterator 트레이트를 구현했으므로 모든 어댑터 사용 가능
let hashes: Vec<String> = range
.map(|height| format!("block_{}", height))
.collect();
println!("{:?}", hashes);
// ["block_100", "block_101", "block_102", "block_103", "block_104"]
// for 루프에서도 사용 가능
for height in BlockHeightRange::new(200, 203) {
println!("Processing block {}", height);
}
}
JavaScript Array 메서드와 비교
// JavaScript
const arr = [1, 2, 3, 4, 5];
arr.map(x => x * 2); // [2, 4, 6, 8, 10]
arr.filter(x => x % 2 === 0); // [2, 4]
arr.reduce((acc, x) => acc + x, 0); // 15
arr.find(x => x > 3); // 4
arr.findIndex(x => x > 3); // 3
arr.some(x => x > 4); // true
arr.every(x => x > 0); // true
arr.includes(3); // true
arr.slice(1, 3); // [2, 3]
arr.flat();
arr.flatMap(x => [x, x * 2]);
arr.forEach(x => console.log(x));
// Rust
let v = vec![1, 2, 3, 4, 5];
v.iter().map(|&x| x * 2).collect::<Vec<_>>();
v.iter().filter(|&&x| x % 2 == 0).collect::<Vec<_>>();
v.iter().fold(0i32, |acc, &x| acc + x);
v.iter().find(|&&x| x > 3);
v.iter().position(|&x| x > 3);
v.iter().any(|&x| x > 4);
v.iter().all(|&x| x > 0);
v.contains(&3);
v[1..3].to_vec(); // 또는 v.iter().skip(1).take(2).collect()
v.iter().flatten();
v.iter().flat_map(|&x| vec![x, x * 2]).collect::<Vec<_>>();
v.iter().for_each(|x| println!("{}", x));
핵심 차이:
- Rust는
iter(),iter_mut(),into_iter()구분 - Rust는 지연 평가 —
collect()등이 없으면 실행 안 됨 - Rust는 타입을 명시해야 하는 경우 많음 (
collect::<Vec<_>>()) - 참조(
&x,&&x)를 역참조하는 패턴이 자주 필요
실용 패턴: 블록체인 이터레이터 체이닝
#[derive(Debug)]
struct Transaction {
id: String,
from: String,
to: String,
amount: u64,
fee: u64,
confirmed: bool,
}
fn analyze_transactions(transactions: &[Transaction]) {
// 1. 확정된 트랜잭션의 총 금액
let confirmed_total: u64 = transactions.iter()
.filter(|tx| tx.confirmed)
.map(|tx| tx.amount)
.sum();
// 2. 수수료 순으로 상위 3개 선택
let mut sorted_by_fee: Vec<&Transaction> = transactions.iter().collect();
sorted_by_fee.sort_by_key(|tx| std::cmp::Reverse(tx.fee));
let top_3: Vec<&Transaction> = sorted_by_fee.into_iter().take(3).collect();
// 3. 주소별 거래량 집계
use std::collections::HashMap;
let volume_by_address: HashMap<&str, u64> = transactions.iter()
.flat_map(|tx| {
// 송신자와 수신자 모두 집계
vec![
(tx.from.as_str(), tx.amount),
(tx.to.as_str(), tx.amount),
]
})
.fold(HashMap::new(), |mut map, (addr, amount)| {
*map.entry(addr).or_insert(0) += amount;
map
});
// 4. 대용량 미확정 트랜잭션 ID 목록
let pending_large: Vec<&str> = transactions.iter()
.filter(|tx| !tx.confirmed && tx.amount > 10_000)
.map(|tx| tx.id.as_str())
.collect();
println!("Confirmed total: {}", confirmed_total);
println!("Pending large count: {}", pending_large.len());
println!("Unique addresses: {}", volume_by_address.len());
}
요약
Iterator트레이트:next()하나만 구현하면 수백 개의 메서드 무료iter()(불변 참조),iter_mut()(가변 참조),into_iter()(소유권) 구분- 어댑터:
map,filter,filter_map,flat_map,take,skip,zip,chain,enumerate - 소비 메서드:
collect,sum,product,fold,reduce,any,all,find,count,for_each - 지연 평가: 소비 메서드 호출 전까지 실행되지 않음
collect::<Vec<_>>()— 타입 힌트가 필요할 때 turbofish 문법 사용
다음으로는 스마트 컨트랙트 심화 주제를 다룬 후, 비동기 프로그래밍을 배웁니다.
Chapter 14: 스마트 컨트랙트 심화
개요
앞선 챕터에서 Solidity의 기본 문법과 ERC-20/721 토큰 표준을 살펴봤다. 이 챕터에서는 실무에서 마주치는 더 복잡한 주제들을 다룬다.
이 챕터에서 다루는 내용
14-1: 상속과 인터페이스
Solidity의 상속 시스템은 TypeScript보다 훨씬 복잡하다. 다중 상속이 허용되고, C3 선형화 알고리즘으로 충돌을 해결한다. 추상 컨트랙트와 인터페이스의 차이, virtual/override 키워드를 TypeScript의 extends/implements와 비교해 이해한다.
14-2: 프록시 패턴
스마트 컨트랙트는 한 번 배포하면 코드를 바꿀 수 없다. 프록시 패턴은 이 제약을 우회해 로직을 업그레이드할 수 있게 한다. delegatecall이 어떻게 작동하는지, Transparent Proxy와 UUPS Proxy의 차이, 스토리지 충돌을 어떻게 피하는지 배운다.
14-3: 보안
스마트 컨트랙트는 버그가 곧 자금 손실로 이어진다. 역사상 실제 발생한 해킹 사례(The DAO, 2016)를 분석하고, 재진입 공격·정수 오버플로·tx.origin 오용·프론트러닝 등 주요 취약점과 방어 패턴을 익힌다.
왜 심화 내용이 중요한가
Node.js 백엔드에서 버그가 발생하면 서버를 재시작하고 패치를 배포하면 된다. 최악의 경우 데이터베이스를 롤백할 수 있다.
스마트 컨트랙트에서 버그가 발생하면:
- 코드를 수정할 수 없다 — 배포된 컨트랙트는 불변
- 자금이 즉시 탈취될 수 있다 — The DAO 해킹: 하루 만에 6천만 달러 손실
- 트랜잭션은 되돌릴 수 없다 — 블록체인의 불변성은 공격자에게도 유리
이런 이유로 프로 Solidity 개발자는 코드를 작성하는 것만큼 보안과 업그레이드 전략에 많은 시간을 투자한다.
실무 스마트 컨트랙트 개발 사이클:
1. 요구사항 정의
2. 설계 (업그레이드 전략 포함)
3. 구현 (OpenZeppelin 활용)
4. 단위 테스트 + 퍼즈 테스트
5. 내부 보안 리뷰 (체크리스트 기반)
6. 외부 감사 (audit)
7. 테스트넷 배포 + 버그바운티
8. 메인넷 배포
9. 모니터링 (Tenderly, OpenZeppelin Defender)
Node.js 서비스 배포보다 훨씬 신중한 프로세스가 필요하다. 이 챕터는 그 기반을 다진다.
실제 해킹 사례 타임라인
심화 내용을 배우기 전에 실제 피해 사례를 먼저 살펴보자. 이것이 왜 이 내용이 중요한지 동기를 부여한다.
| 연도 | 프로젝트 | 취약점 | 피해액 |
|---|---|---|---|
| 2016 | The DAO | 재진입 공격 | $60M |
| 2020 | bZx | 플래시론 + 오라클 조작 | $1M |
| 2021 | Poly Network | 접근 제어 오류 | $611M |
| 2021 | Compound | 거버넌스 버그 | $80M |
| 2022 | Ronin Bridge | 개인키 탈취 + 접근 제어 | $625M |
| 2022 | Wormhole | 서명 검증 오류 | $320M |
| 2023 | Euler Finance | 플래시론 + 로직 오류 | $197M |
이 사례들은 모두 방지 가능했다. 올바른 패턴을 알고, 충분히 테스트하고, 외부 감사를 받았다면 피할 수 있었던 버그들이다.
Node.js 개발자를 위한 사고방식 전환
Node.js 백엔드 개발자는 다음 사고방식을 스마트 컨트랙트 개발에 맞게 바꿔야 한다.
기존: “버그는 고칠 수 있다”
// Node.js - 배포 후 수정 가능
app.get('/transfer', async (req, res) => {
// 버그 발견 → 코드 수정 → 재배포
await transferService.transfer(req.body);
});
새로운: “버그는 영구적이다”
// Solidity - 배포 후 수정 불가
function transfer(address to, uint256 amount) public {
// 이 코드에 버그가 있다면 영원히 존재
// 유일한 해결책: 처음부터 올바르게 작성
_transfer(msg.sender, to, amount);
}
기존: “실패하면 로그 보고 디버깅”
// Node.js - 스택 트레이스, 로그, 디버거 사용 가능
try {
await complexOperation();
} catch (err) {
logger.error(err); // 상세 로그
// 재시도, 수동 수정 가능
}
새로운: “실패하면 트랜잭션 revert, 가스만 소비”
// Solidity - revert되면 상태 변화 없이 가스만 소비
function complexOperation() external {
// 이 중간에 revert되면 모든 상태 변화가 롤백됨
// 하지만 소비한 가스는 돌려받지 못함
step1();
step2(); // 여기서 실패하면 step1도 롤백
step3();
}
기존: “사용자 입력은 미들웨어에서 검증”
// NestJS - DTO 검증
@Post('/deposit')
async deposit(@Body() dto: DepositDto) {
// 클래스-벨리데이터가 이미 검증함
}
새로운: “모든 입력은 컨트랙트에서 직접 검증”
// Solidity - 컨트랙트 자체가 최후의 방어선
function deposit(uint256 amount) external {
// 외부 검증에 의존하지 말고 직접 검증
require(amount > 0, "Amount must be positive");
require(amount <= MAX_DEPOSIT, "Exceeds limit");
require(!paused, "Contract is paused");
balances[msg.sender] += amount;
totalDeposits += amount;
emit Deposited(msg.sender, amount);
}
이 챕터 학습 순서
1단계: 상속 이해 (14-1)
기본 상속부터 다중 상속, C3 선형화까지 차근차근 이해한다. OpenZeppelin 라이브러리가 이 패턴을 어떻게 활용하는지 보면 자연스럽게 이해된다.
단일 상속 → 다중 상속 → 추상 컨트랙트 → 인터페이스
2단계: 프록시 패턴 이해 (14-2)
delegatecall의 동작을 완전히 이해하는 것이 핵심이다. 처음에는 헷갈리지만, storage 슬롯이 어떻게 공유되는지 이해하면 모든 게 명확해진다.
delegatecall 원리 → Transparent Proxy → UUPS Proxy → 스토리지 레이아웃
3단계: 보안 패턴 습득 (14-3)
실제 해킹 코드를 보고 분석한 다음 방어 코드를 작성한다. 공격을 이해해야 방어할 수 있다.
재진입 공격 → CEI 패턴 → 오버플로 → tx.origin → 프론트러닝 → 체크리스트
필수 선행 지식 확인
이 챕터를 시작하기 전에 다음을 확인하자:
[ ] Solidity 기본 문법 (ch11)
[ ] Foundry 테스트 작성 (ch12)
[ ] ERC-20 구조 이해 (ch13-01)
[ ] OpenZeppelin 기본 사용법 (ch13-03)
[ ] modifier 작성 경험
[ ] mapping과 이벤트 이해
이 지식이 없다면 앞 챕터를 먼저 학습하고 오자. 특히 프록시 패턴은 storage 슬롯에 대한 깊은 이해가 필요하다.
다음 챕터 미리보기
이 챕터를 마치면:
- 복잡한 상속 구조를 읽고 이해할 수 있다
- OpenZeppelin의 업그레이드 가능 컨트랙트를 직접 사용할 수 있다
- 보안 취약점을 코드 리뷰에서 발견할 수 있다
- **미니프로젝트(ch15)**를 자신감 있게 완성할 수 있다
스마트 컨트랙트 개발의 핵심은 처음부터 올바르게 작성하는 것이다. 시작해보자.
Chapter 14-1: 상속과 인터페이스
컨트랙트 상속: is 키워드
Solidity에서 상속은 is 키워드를 사용한다. TypeScript의 extends와 동일한 역할이다.
// TypeScript
class Animal {
name: string;
constructor(name: string) { this.name = name; }
speak(): string { return `${this.name} makes a sound`; }
}
class Dog extends Animal {
speak(): string { return "Woof!"; }
}
// Solidity
contract Animal {
string public name;
constructor(string memory _name) { name = _name; }
function speak() public virtual returns (string memory) { return "Animal sound"; }
}
contract Dog is Animal {
constructor() Animal("Dog") {}
function speak() public virtual override returns (string memory) { return "Woof!"; }
}
부모 함수 호출
contract Base {
function greet() public virtual returns (string memory) {
return "Hello from Base";
}
}
contract Child is Base {
function greet() public virtual override returns (string memory) {
// super로 부모 함수 호출
string memory parentGreet = super.greet();
return string(abi.encodePacked(parentGreet, " and Child"));
}
}
부모 생성자 호출
contract Ownable {
address public owner;
constructor(address _owner) {
owner = _owner;
}
}
contract Pausable {
bool public paused;
constructor() {
paused = false;
}
}
// 여러 부모의 생성자를 모두 호출
contract MyContract is Ownable, Pausable {
// 방법 1: 선언부에서
constructor() Ownable(msg.sender) Pausable() {
// 추가 초기화
}
}
다중 상속과 C3 선형화
Solidity는 다중 상속을 지원한다. 즉 하나의 컨트랙트가 여러 부모를 가질 수 있다.
contract A {
function foo() public virtual returns (string memory) { return "A"; }
}
contract B is A {
function foo() public virtual override returns (string memory) { return "B"; }
}
contract C is A {
function foo() public virtual override returns (string memory) { return "C"; }
}
// B와 C 모두 상속 — 다이아몬드 문제
contract D is B, C {
// D에서 foo()를 override하지 않으면 컴파일 에러
// 어느 부모의 foo()를 써야 할지 모호하기 때문
function foo() public override(B, C) returns (string memory) {
return super.foo(); // C3 선형화 순서에 따라 C의 foo() 호출
}
}
C3 선형화 규칙
Solidity는 Python의 C3 선형화 알고리즘을 사용한다. is 뒤에 나열하는 순서가 가장 기본(base)에서 가장 파생(derived) 순서여야 한다.
// 상속 순서: 가장 기본(base) → 가장 파생(derived) 순으로
contract D is A, B, C {
function foo() public override(A, B, C) returns (string memory) {
return super.foo();
}
}
// ↑ 가장 기본 ↑ 가장 파생
// super.foo() 호출 시 MRO(Method Resolution Order):
// D → C → B → A 순으로 탐색
실용적 규칙: super.foo()는 상속 목록의 오른쪽→왼쪽 순서로 호출된다.
contract A { function foo() public virtual returns (string memory) { return "A"; } }
contract B is A { function foo() public virtual override returns (string memory) {
return string(abi.encodePacked(super.foo(), "B")); // "AB"
}}
contract C is A { function foo() public virtual override returns (string memory) {
return string(abi.encodePacked(super.foo(), "C")); // "AC"
}}
contract D is B, C { function foo() public override(B, C) returns (string memory) {
return super.foo(); // "ACB" — C먼저, 그다음 B
}}
D is B, C에서 MRO: D → C → B → A
따라서 super.foo()는 C.foo() → B.foo() → A.foo() 순으로 체인된다.
추상 컨트랙트 (Abstract Contract)
구현이 없는 함수가 하나 이상 있는 컨트랙트는 abstract로 선언해야 한다. 직접 배포할 수 없고 상속해서만 사용한다.
// TypeScript abstract class와 동일
abstract class Shape {
abstract area(): number; // 구현 없음
perimeter(): number { return 0; } // 구현 있음
}
// Solidity abstract contract
abstract contract Shape {
// 구현 없는 함수 (자식이 반드시 구현해야)
function area() public virtual returns (uint256);
// 구현 있는 함수 (자식이 override 선택 가능)
function describe() public virtual returns (string memory) {
return "I am a shape";
}
}
contract Circle is Shape {
uint256 public radius;
constructor(uint256 _radius) { radius = _radius; }
// 반드시 구현해야 함
function area() public view override returns (uint256) {
// π * r^2 (정수 근사: 3141592 * r^2 / 1000000)
return (3141592 * radius * radius) / 1000000;
}
}
contract Square is Shape {
uint256 public side;
constructor(uint256 _side) { side = _side; }
function area() public view override returns (uint256) {
return side * side;
}
}
인터페이스 (Interface)
인터페이스는 함수 선언만 있고 구현이 전혀 없다. 상태 변수, 생성자, 구현된 함수를 가질 수 없다.
// TypeScript interface와 거의 동일
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
// 이벤트도 포함 가능
event Transfer(address indexed from, address indexed to, uint256 value);
}
// TypeScript
interface IERC20 {
transfer(to: string, amount: bigint): Promise<boolean>;
balanceOf(account: string): Promise<bigint>;
}
인터페이스의 제약:
- 모든 함수는
external - 상태 변수 없음
- 생성자 없음
- 구현된 함수 없음 (Solidity 0.6+ 이전에는 허용됨)
- 다른 인터페이스를 상속할 수 있음
인터페이스를 통한 외부 컨트랙트 호출
인터페이스의 핵심 사용법은 주소를 특정 타입으로 캐스팅하는 것이다.
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
}
contract TokenSwapper {
// 임의의 ERC-20 토큰과 상호작용
function getBalance(address tokenAddress, address account)
external view returns (uint256)
{
// 주소를 IERC20으로 캐스팅
IERC20 token = IERC20(tokenAddress);
return token.balanceOf(account);
}
function swapTokens(
address fromToken,
address toToken,
uint256 amount
) external {
IERC20(fromToken).transferFrom(msg.sender, address(this), amount);
uint256 outputAmount = quote(fromToken, toToken, amount);
require(outputAmount > 0, "ZERO_OUTPUT");
IERC20(toToken).transfer(msg.sender, outputAmount);
}
}
TypeScript 비유:
// TypeScript - interface로 타입 캐스팅
interface IERC20 {
transfer(to: string, amount: bigint): Promise<boolean>;
balanceOf(account: string): Promise<bigint>;
}
function getBalance(tokenAddress: string, account: string): Promise<bigint> {
const token = new ethers.Contract(tokenAddress, ERC20_ABI, provider) as unknown as IERC20;
return token.balanceOf(account);
}
virtual과 override 키워드
virtual = "자식이 이 함수를 재정의할 수 있다"
override = "이 함수는 부모의 virtual 함수를 재정의한다"
contract Base {
// virtual: 자식이 override 가능
function canOverride() public virtual returns (string memory) {
return "Base";
}
// virtual 없음: 자식이 override 불가
function cannotOverride() public returns (string memory) {
return "Fixed";
}
}
contract Child is Base {
// override: 부모의 virtual 함수를 재정의
function canOverride() public virtual override returns (string memory) {
return "Child";
}
// 이건 컴파일 에러:
// function cannotOverride() public override returns (string memory) {
// return "Child";
// }
}
contract GrandChild is Child {
// Child도 virtual로 선언했으므로 또 override 가능
function canOverride() public override returns (string memory) {
return "GrandChild";
}
}
다중 상속 시 override
contract A {
function foo() public virtual returns (uint256) { return 1; }
}
contract B is A {
function foo() public virtual override returns (uint256) { return 2; }
}
contract C is A {
function foo() public virtual override returns (uint256) { return 3; }
}
contract D is B, C {
// 여러 부모의 override 목록 명시
function foo() public override(B, C) returns (uint256) {
return super.foo(); // C의 foo() (MRO 순서)
}
}
추상 컨트랙트 vs 인터페이스 비교
| 추상 컨트랙트 | 인터페이스 | |
|---|---|---|
| 구현된 함수 | 가능 | 불가 |
| 상태 변수 | 가능 | 불가 |
| 생성자 | 가능 | 불가 |
| 이벤트 | 가능 | 가능 |
| 다중 상속 | 가능 | 가능 |
| 사용 목적 | 공통 로직 공유 | 타입 정의/계약 |
언제 인터페이스를 쓰고 언제 추상 컨트랙트를 쓰나:
// 인터페이스: 외부 컨트랙트와의 상호작용 표준 정의
interface IUniswapV2Router {
function swapExactTokensForETH(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external returns (uint[] memory amounts);
}
// 추상 컨트랙트: 공통 로직을 가진 베이스 컨트랙트
abstract contract BaseVault {
// 공통 상태 변수
address public asset;
mapping(address => uint256) public balances;
// 공통 구현
function totalAssets() public view returns (uint256) {
return IERC20(asset).balanceOf(address(this));
}
// 자식이 구현해야 할 함수
function _deposit(address user, uint256 amount) internal virtual;
function _withdraw(address user, uint256 amount) internal virtual;
}
contract ETHVault is BaseVault {
function _deposit(address user, uint256 amount) internal override {
// ETH 전용 예치 로직
balances[user] += amount;
}
function _withdraw(address user, uint256 amount) internal override {
// ETH 전용 출금 로직
balances[user] -= amount;
payable(user).transfer(amount);
}
}
TypeScript extends/implements와의 비교
// TypeScript
interface IShape {
area(): number;
}
abstract class BaseShape implements IShape {
abstract area(): number;
describe(): string { return "I am a shape"; }
}
class Circle extends BaseShape {
constructor(private radius: number) { super(); }
area(): number { return Math.PI * this.radius ** 2; }
}
// Solidity
interface IShape {
function area() external returns (uint256);
}
abstract contract BaseShape is IShape {
// area()는 IShape에서 선언됐으므로 자동으로 virtual처럼 동작
function describe() public virtual returns (string memory) {
return "I am a shape";
}
}
contract Circle is BaseShape {
uint256 public radius;
constructor(uint256 _radius) { radius = _radius; }
function area() public view override returns (uint256) {
return (3141592 * radius * radius) / 1000000;
}
}
주요 차이점:
| TypeScript | Solidity |
|---|---|
class ... extends ... implements ... | contract ... is ..., ... |
extends(상속)와 implements(인터페이스) 구분 | is 키워드로 통일 |
abstract 메서드 = 구현 없음 | virtual + 구현 없음 = 추상 함수 |
super.method() | super.method() 또는 ContractName.method() |
| 다중 인터페이스 구현 가능 | 다중 상속 가능 |
| MRO 없음 (단일 상속) | C3 선형화 MRO |
실전 상속 패턴
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// 1. 인터페이스: 외부 계약
interface IVault {
function deposit(uint256 amount) external;
function withdraw(uint256 amount) external;
function balanceOf(address user) external view returns (uint256);
event Deposited(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
}
// 2. 추상 베이스: 공통 로직
abstract contract BaseVault is IVault {
mapping(address => uint256) internal _balances;
uint256 internal _totalDeposited;
// 공통 구현 (모든 Vault에서 동일)
function balanceOf(address user) public view override returns (uint256) {
return _balances[user];
}
function totalDeposited() public view returns (uint256) {
return _totalDeposited;
}
// 자식이 구현해야 할 훅
function _beforeDeposit(address user, uint256 amount) internal virtual {}
function _afterDeposit(address user, uint256 amount) internal virtual {}
function _beforeWithdraw(address user, uint256 amount) internal virtual {}
function _afterWithdraw(address user, uint256 amount) internal virtual {}
// 공통 deposit 로직 (훅 포함)
function deposit(uint256 amount) public virtual override {
_beforeDeposit(msg.sender, amount);
_balances[msg.sender] += amount;
_totalDeposited += amount;
_afterDeposit(msg.sender, amount);
emit IVault.Deposited(msg.sender, amount);
}
function withdraw(uint256 amount) public virtual override {
require(_balances[msg.sender] >= amount, "Insufficient");
_beforeWithdraw(msg.sender, amount);
_balances[msg.sender] -= amount;
_totalDeposited -= amount;
_afterWithdraw(msg.sender, amount);
emit IVault.Withdrawn(msg.sender, amount);
}
}
// 3. 구체 구현: 특정 기능 추가
contract FeeVault is BaseVault {
uint256 public feeRate = 100; // 1% (basis points)
address public feeRecipient;
uint256 public totalFees;
constructor(address _feeRecipient) {
feeRecipient = _feeRecipient;
}
// 출금 시 수수료 차감
function _beforeWithdraw(address user, uint256 amount) internal override {
uint256 fee = (amount * feeRate) / 10000;
_balances[feeRecipient] += fee;
totalFees += fee;
}
}
정리
Solidity 상속의 핵심:
is키워드 — 상속과 인터페이스 구현 모두 동일한 키워드virtual/override— 재정의 가능 여부를 명시적으로 선언- C3 선형화 — 다중 상속 시 메서드 해석 순서,
is목록의 오른쪽 → 왼쪽 - 추상 컨트랙트 — 공통 로직 + 미구현 함수를 가진 베이스
- 인터페이스 — 순수 타입 정의, 외부 컨트랙트와의 상호작용에 필수
다음 챕터에서는 스마트 컨트랙트 업그레이드를 위한 프록시 패턴을 배운다.
Chapter 14-2: 프록시 패턴 (Proxy Patterns)
왜 업그레이드가 필요한가
스마트 컨트랙트는 한 번 배포하면 코드를 변경할 수 없다. 이것이 블록체인의 불변성(immutability) 원칙이다. 그런데 실무에서는:
- 배포 후 버그가 발견된다
- 비즈니스 요구사항이 변경된다
- 새로운 기능이 필요하다
- 보안 취약점이 발견된다
Node.js 서비스라면 코드를 고치고 재배포하면 끝이다. 스마트 컨트랙트는?
문제: 컨트랙트 A에 버그 발견
↓
새 컨트랙트 B를 배포해도...
- 기존 사용자는 A를 가리키고 있음
- A에 있는 데이터(잔액, 상태)는 B로 이동 불가
- 에코시스템(거래소, 프론트엔드)이 A 주소를 사용 중
delegatecall 동작 원리
프록시 패턴의 핵심은 delegatecall이다. 일반 call과의 차이를 이해해야 한다.
call vs delegatecall
contract Logic {
uint256 public value;
function setValue(uint256 _value) public {
value = _value; // Logic의 storage에 저장
}
}
contract Caller {
uint256 public value;
Logic logic = Logic(0x0000000000000000000000000000000000001000);
function useCall() public {
// call: Logic 컨트랙트의 context에서 실행
// Logic.value가 변경됨, Caller.value는 그대로
logic.setValue(42);
}
function useDelegatecall() public {
// delegatecall: Caller 컨트랙트의 context에서 실행
// Caller.value가 변경됨, Logic.value는 그대로
(bool success, ) = address(logic).delegatecall(
abi.encodeWithSignature("setValue(uint256)", 42)
);
require(success);
}
}
delegatecall의 핵심: 코드는 Logic에서 빌려오지만, 실행 환경(storage, msg.sender, msg.value)은 호출자(Caller)의 것을 사용한다.
일반 call:
Caller → [call] → Logic
├─ msg.sender = Caller
├─ storage = Logic's storage
└─ code = Logic's code
delegatecall:
Caller → [delegatecall] → Logic
├─ msg.sender = 원래 호출자 (EOA)
├─ storage = Caller's storage ← 핵심!
└─ code = Logic's code ← 빌린 코드
Node.js 비유: JavaScript의 Function.prototype.call(thisArg)와 유사하다.
const logic = {
setValue(value) {
this.value = value; // 'this'는 호출 시 결정
}
};
const proxy = { value: 0 };
// call: proxy의 context에서 logic의 함수 실행
logic.setValue.call(proxy, 42);
// proxy.value = 42, logic.value는 그대로
프록시 패턴 아키텍처
사용자 → Proxy 컨트랙트 → Logic(Implementation) 컨트랙트
(데이터 저장) (코드만 있음, 데이터 없음)
(주소 불변) (업그레이드 시 교체)
업그레이드 시:
사용자 → Proxy 컨트랙트 → Logic V2 컨트랙트 (새 주소)
(주소 그대로) (새 코드)
(데이터 그대로)
Transparent Proxy vs UUPS Proxy
Transparent Proxy (투명 프록시)
OpenZeppelin이 초기에 제안한 패턴. 관리자(admin)와 일반 사용자가 다른 함수를 호출한다.
contract TransparentProxy {
address public implementation;
address public admin;
modifier ifAdmin() {
if (msg.sender == admin) {
_; // 관리자: 프록시 자체 함수 (upgrade 등)
} else {
_delegate(implementation); // 사용자: 구현체로 위임
}
}
function upgrade(address newImplementation) external ifAdmin {
implementation = newImplementation;
}
fallback() external payable {
_delegate(implementation);
}
function _delegate(address impl) internal {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
Transparent Proxy의 문제:
- 모든 호출에서 admin 체크 → 가스 낭비
- ProxyAdmin 컨트랙트가 별도로 필요해 복잡성 증가
UUPS Proxy (Universal Upgradeable Proxy Standard, EIP-1822)
업그레이드 로직을 구현체(implementation)에 두는 방식. 프록시는 단순히 위임만 한다.
// 프록시 컨트랙트 (매우 단순)
contract ERC1967Proxy {
// EIP-1967 표준 슬롯: keccak256("eip1967.proxy.implementation") - 1
bytes32 private constant IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
constructor(address implementation, bytes memory data) {
_setImplementation(implementation);
if (data.length > 0) {
(bool success,) = implementation.delegatecall(data);
require(success, "Initialization failed");
}
}
fallback() external payable {
_delegate(_getImplementation());
}
receive() external payable {
_delegate(_getImplementation());
}
function _getImplementation() internal view returns (address impl) {
assembly {
impl := sload(IMPLEMENTATION_SLOT)
}
}
function _setImplementation(address newImpl) internal {
assembly {
sstore(IMPLEMENTATION_SLOT, newImpl)
}
}
function _delegate(address impl) internal {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
// 구현체 컨트랙트 (업그레이드 로직 포함)
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract MyContractV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public value;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address initialOwner) public initializer {
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
value = 0;
}
function setValue(uint256 _value) external {
value = _value;
}
// 업그레이드 권한 제어 — 소유자만 업그레이드 가능
function _authorizeUpgrade(address newImplementation)
internal
override
onlyOwner
{}
}
UUPS vs Transparent 비교:
| Transparent Proxy | UUPS | |
|---|---|---|
| 업그레이드 로직 위치 | Proxy | Implementation |
| 가스 효율 | 낮음 (admin 체크) | 높음 |
| 업그레이드 실수 위험 | 낮음 | 높음 (구현체에 _authorizeUpgrade 없으면 영구 잠금) |
| 복잡성 | ProxyAdmin 필요 | 단순 |
| 현재 권장 | 레거시 | 권장 |
스토리지 충돌 주의사항
delegatecall을 사용할 때 가장 위험한 문제가 스토리지 슬롯 충돌이다.
// 프록시
contract Proxy {
address public implementation; // slot 0
address public admin; // slot 1
}
// 구현체
contract Logic {
uint256 public value; // slot 0 ← 충돌!
address public owner; // slot 1 ← 충돌!
}
delegatecall로 Logic.setValue(42)를 호출하면 Logic의 slot 0에 쓰는 게 아니라 Proxy의 slot 0, 즉 implementation 주소가 42로 덮어써진다!
EIP-1967 표준 슬롯으로 해결
// EIP-1967: 충돌 가능성이 없는 특수 슬롯 사용
// implementation 주소를 일반 slot 0이 아닌 특수 슬롯에 저장
bytes32 constant IMPLEMENTATION_SLOT =
keccak256("eip1967.proxy.implementation") - 1;
// = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
bytes32 constant ADMIN_SLOT =
keccak256("eip1967.proxy.admin") - 1;
// = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
이렇게 하면 구현체의 상태 변수(slot 0, 1, 2…)와 절대 충돌하지 않는다.
업그레이드 시 스토리지 레이아웃 유지
// V1 구현체
contract MyContractV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public value; // slot 0 (OZ 내부 슬롯 제외)
address public treasury; // slot 1
function setValue(uint256 newValue) external onlyOwner {
value = newValue;
}
}
// V2 구현체 (올바른 업그레이드)
contract MyContractV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public value; // slot 0 — 유지!
address public treasury; // slot 1 — 유지!
uint256 public newFeature; // slot 2 — 새로 추가 (뒤에만 가능)
function setNewFeature(uint256 newValue) external onlyOwner {
newFeature = newValue;
}
}
// V2 구현체 (위험한 업그레이드 — 하지 말 것)
contract MyContractV2_BAD is Initializable, OwnableUpgradeable, UUPSUpgradeable {
address public treasury; // slot 0 — 변경! value가 treasury로 해석됨
uint256 public value; // slot 1 — 변경! treasury가 value로 해석됨
}
OpenZeppelin UUPSUpgradeable 사용 예제
전체 구현 예시
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol";
/// @title UpgradeableVault V1
contract UpgradeableVaultV1 is
Initializable,
OwnableUpgradeable,
ReentrancyGuardUpgradeable,
UUPSUpgradeable
{
// ============ 상태 변수 (순서 절대 변경 금지) ============
ERC20Upgradeable public token;
mapping(address => uint256) public deposits;
uint256 public totalDeposits;
// ============ 이벤트 ============
event Deposited(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
// ============ 생성자 비활성화 ============
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
// ============ 초기화 (constructor 대체) ============
function initialize(
address _token,
address initialOwner
) public initializer {
__Ownable_init(initialOwner);
__ReentrancyGuard_init();
__UUPSUpgradeable_init();
token = ERC20Upgradeable(_token);
}
// ============ 핵심 기능 ============
function deposit(uint256 amount) external nonReentrant {
require(amount > 0, "Amount must be positive");
token.transferFrom(msg.sender, address(this), amount);
deposits[msg.sender] += amount;
totalDeposits += amount;
emit Deposited(msg.sender, amount);
}
function withdraw(uint256 amount) external nonReentrant {
require(deposits[msg.sender] >= amount, "Insufficient deposit");
deposits[msg.sender] -= amount;
totalDeposits -= amount;
token.transfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}
// ============ 업그레이드 권한 ============
function _authorizeUpgrade(address newImplementation)
internal
override
onlyOwner
{}
// ============ 버전 조회 ============
function version() external pure returns (string memory) {
return "V1";
}
}
// V2: 수수료 기능 추가
contract UpgradeableVaultV2 is UpgradeableVaultV1 {
// ============ 새 상태 변수 (기존 변수 뒤에 추가) ============
uint256 public feeRate; // 추가 (slot N)
uint256 public totalFees; // 추가 (slot N+1)
address public feeRecipient; // 추가 (slot N+2)
// ============ 새 초기화 (reinitializer) ============
function initializeV2(
uint256 _feeRate,
address _feeRecipient
) public reinitializer(2) {
feeRate = _feeRate;
feeRecipient = _feeRecipient;
}
// ============ 기존 함수 override ============
function withdraw(uint256 amount) external override nonReentrant {
require(deposits[msg.sender] >= amount, "Insufficient deposit");
uint256 fee = (amount * feeRate) / 10000;
uint256 netAmount = amount - fee;
deposits[msg.sender] -= amount;
totalDeposits -= amount;
totalFees += fee;
if (fee > 0) token.transfer(feeRecipient, fee);
token.transfer(msg.sender, netAmount);
emit Withdrawn(msg.sender, netAmount);
}
function version() external pure override returns (string memory) {
return "V2";
}
}
배포 스크립트
// script/DeployUpgradeable.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console} from "forge-std/Script.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {UpgradeableVaultV1} from "../src/UpgradeableVaultV1.sol";
contract DeployUpgradeable is Script {
function run() external {
uint256 deployerKey = vm.envUint("PRIVATE_KEY");
address deployer = vm.addr(deployerKey);
address tokenAddress = vm.envAddress("TOKEN_ADDRESS");
vm.startBroadcast(deployerKey);
// 1. 구현체 배포
UpgradeableVaultV1 implementation = new UpgradeableVaultV1();
// 2. 초기화 데이터 인코딩
bytes memory initData = abi.encodeCall(
UpgradeableVaultV1.initialize,
(tokenAddress, deployer)
);
// 3. 프록시 배포 (구현체 + 초기화 데이터)
ERC1967Proxy proxy = new ERC1967Proxy(
address(implementation),
initData
);
vm.stopBroadcast();
console.log("Implementation:", address(implementation));
console.log("Proxy:", address(proxy));
console.log("Version:", UpgradeableVaultV1(address(proxy)).version());
}
}
// script/UpgradeVault.s.sol
contract UpgradeVault is Script {
function run() external {
address proxyAddress = vm.envAddress("PROXY_ADDRESS");
uint256 deployerKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerKey);
// 1. V2 구현체 배포
UpgradeableVaultV2 implementationV2 = new UpgradeableVaultV2();
// 2. 프록시를 통해 업그레이드 + V2 초기화
UpgradeableVaultV1 proxy = UpgradeableVaultV1(proxyAddress);
proxy.upgradeToAndCall(
address(implementationV2),
abi.encodeCall(
UpgradeableVaultV2.initializeV2,
(100, feeRecipient) // 1% fee
)
);
vm.stopBroadcast();
console.log("Upgraded to V2:", address(implementationV2));
console.log("Version:", UpgradeableVaultV2(proxyAddress).version());
}
}
NestJS DI와의 비유
// NestJS: 의존성 주입으로 구현체 교체
// module에서 토큰으로 구현체를 바인딩
@Module({
providers: [
{
provide: 'PAYMENT_SERVICE',
useClass: StripePaymentService, // V1
},
],
})
export class AppModule {}
// 교체 시: 코드 변경 + 재배포
// useClass: PaypalPaymentService // V2
// Solidity: 프록시 패턴으로 구현체 교체
// 프록시가 구현체 주소를 가리킴
// 업그레이드 = 새 구현체 주소로 변경
proxy.upgradeToAndCall(address(implementationV2), "");
// 사용자는 같은 프록시 주소를 사용 — 투명하게 교체됨
핵심 차이: NestJS DI는 코드 레벨에서 교체하고 서버를 재시작한다. 스마트 컨트랙트 프록시는 체인 위에서 교체하고 데이터가 유지된다.
정리
- delegatecall — 코드는 빌리고 storage와 context는 호출자 것을 사용
- Transparent Proxy — admin/user 분리, 가스 비효율, 레거시
- UUPS Proxy — 업그레이드 로직이 구현체에, 가스 효율적, 현재 표준
- 스토리지 충돌 — EIP-1967 표준 슬롯으로 해결
- 업그레이드 규칙 — 기존 변수 순서 유지, 새 변수는 뒤에만 추가
- _disableInitializers() — 구현체 직접 초기화 방지 필수
다음 챕터에서는 스마트 컨트랙트 보안 취약점과 방어 패턴을 다룬다.
Chapter 14-3: 스마트 컨트랙트 보안
왜 보안이 중요한가
스마트 컨트랙트 보안은 일반 웹 보안과 다른 차원의 위험이 있다.
| 웹 서버 (Node.js) | 스마트 컨트랙트 | |
|---|---|---|
| 버그 발견 시 | 패치 배포 | 패치 불가 (불변성) |
| 피해 범위 | 데이터 유출, 서비스 중단 | 즉각적인 자금 탈취 |
| 복구 가능성 | DB 롤백, 백업 복원 | 불가 (트랜잭션 불변) |
| 코드 공개 | 선택적 | 강제 공개 (블록체인) |
| 공격자 | 내부 시스템 접근 필요 | 누구나 공개된 인터페이스로 호출 |
2023년 기준 스마트 컨트랙트 해킹으로 수십억 달러가 탈취됐다. 코드를 배포하기 전 철저한 보안 검토가 필수다.
1. 재진입 공격 (Reentrancy Attack)
The DAO 해킹 사례 (2016)
2016년 “The DAO” 프로젝트에서 재진입 공격으로 3.6백만 ETH(당시 약 6천만 달러)가 탈취됐다. 이 사건으로 이더리움이 ETH/ETC로 하드포크됐다.
공격 원리
정상 출금 흐름:
1. 사용자가 withdraw() 호출
2. 잔액 확인 (충분한 잔액)
3. ETH 전송
4. 잔액 업데이트 (0으로)
재진입 공격 흐름:
1. 공격자가 withdraw() 호출
2. 잔액 확인 (100 ETH)
3. ETH 전송 → 공격자 컨트랙트의 receive() 트리거
→ receive()에서 withdraw()를 다시 호출 ← 재진입!
→ 잔액이 아직 업데이트 안 됨 → 또 100 ETH 전송
→ 또 재진입...
4. 모든 ETH 소진 후 잔액 업데이트 (이미 늦음)
취약한 코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// 취약한 뱅크 컨트랙트
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// 취약점: 상태 업데이트 전에 외부 호출
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// 1. ETH 전송 (외부 호출)
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// 2. 상태 업데이트 (너무 늦음!)
balances[msg.sender] = 0;
}
}
공격자 컨트랙트
// 재진입 공격 컨트랙트
contract Attacker {
VulnerableBank public bank;
uint256 public attackCount;
constructor(address _bank) {
bank = VulnerableBank(_bank);
}
function attack() external payable {
require(msg.value >= 1 ether, "Need 1 ETH");
bank.deposit{value: 1 ether}();
bank.withdraw();
}
// ETH를 받을 때마다 다시 withdraw() 호출 (재진입!)
receive() external payable {
attackCount++;
if (address(bank).balance >= 1 ether && attackCount < 10) {
bank.withdraw(); // 재진입!
}
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
방어 방법 1: Checks-Effects-Interactions 패턴
contract SecureBank_CEI {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
// Checks (검증)
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// Effects (상태 변경) — 외부 호출 전에!
balances[msg.sender] = 0;
// Interactions (외부 호출) — 마지막에
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
상태를 먼저 변경하면 재진입해도 잔액이 이미 0이므로 추가 인출 불가.
방어 방법 2: ReentrancyGuard
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureBank_Guard is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// nonReentrant: 실행 중 재진입 시 revert
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
권장: CEI 패턴 + nonReentrant를 함께 사용한다. CEI는 로직적 보호, nonReentrant는 이중 안전망.
2. 정수 오버플로/언더플로
Solidity 0.8 이전 (위험)
// Solidity 0.7 이하 — 오버플로 체크 없음!
contract OldToken {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
// uint256 최대값 + 1 = 0 (오버플로!)
balances[msg.sender] -= amount; // 잔액 0인데 빼면 엄청 큰 값이 됨
balances[to] += amount;
}
}
// 공격: 잔액이 0인 계정에서 1을 빼면
// 0 - 1 = type(uint256).max (약 1.15 * 10^77)
// 사실상 무한한 잔액이 생김
이를 방지하기 위해 OpenZeppelin의 SafeMath 라이브러리를 사용했다:
// 0.7 이하 시절
using SafeMath for uint256;
balances[msg.sender] = balances[msg.sender].sub(amount); // underflow 방지
Solidity 0.8 이후 (자동 보호)
// Solidity 0.8+ — 오버플로/언더플로 자동 감지 후 revert
contract NewToken {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
// 잔액 부족 시 자동으로 revert (언더플로 방지)
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
0.8 이후에는 SafeMath가 불필요하다. 단, 가스 최적화를 위해 unchecked 블록을 사용할 때는 직접 체크해야 한다:
// unchecked: 오버플로 체크 비활성화 (가스 절약)
// 안전이 보장된 경우에만 사용!
function _transfer(address from, address to, uint256 amount) internal {
uint256 fromBalance = _balances[from];
require(fromBalance >= amount, "Insufficient balance"); // 수동 체크 필수!
unchecked {
_balances[from] = fromBalance - amount; // 위에서 체크했으므로 안전
_balances[to] += amount; // uint256 최대값 초과 시 문제될 수 있음
}
}
3. tx.origin vs msg.sender
tx.origin은 트랜잭션을 최초 시작한 EOA(외부 계정)다. msg.sender는 현재 함수의 직접 호출자다.
EOA(Alice) → ContractA → ContractB.foo()
ContractB.foo() 내부:
msg.sender = ContractA (직접 호출자)
tx.origin = Alice (최초 발신자)
tx.origin 사용의 위험
// 취약한 지갑 컨트랙트
contract VulnerableWallet {
address public owner;
constructor() { owner = msg.sender; }
// tx.origin 사용 — 위험!
function transfer(address payable to, uint256 amount) external {
require(tx.origin == owner, "Not owner");
to.transfer(amount);
}
}
// 피싱 공격 컨트랙트
contract PhishingContract {
VulnerableWallet public wallet;
constructor(address _wallet) {
wallet = VulnerableWallet(_wallet);
}
// 사용자가 이 함수를 호출하도록 속임
receive() external payable {
// tx.origin = Alice (속은 사용자)
// msg.sender = PhishingContract
// wallet은 tx.origin(Alice)을 owner로 확인 → 통과!
wallet.transfer(payable(msg.sender), address(wallet).balance);
}
}
공격 흐름:
- 공격자가 피싱 사이트를 만들어 Alice에게 “무료 NFT를 받으려면 이 컨트랙트에 ETH를 보내세요” 유도
- Alice가 PhishingContract에 ETH 전송 → receive() 실행
- tx.origin = Alice이므로 VulnerableWallet의 보안 통과
- Alice의 지갑에 있는 모든 ETH 탈취
올바른 접근법
contract SecureWallet {
address public owner;
constructor() { owner = msg.sender; }
// msg.sender 사용 — 안전
function transfer(address payable to, uint256 amount) external {
require(msg.sender == owner, "Not owner");
// msg.sender가 컨트랙트라면 컨트랙트가 owner여야 통과
// 피싱 공격 시 msg.sender = PhishingContract ≠ owner → revert
to.transfer(amount);
}
}
규칙: 접근 제어에 tx.origin을 절대 사용하지 말고 항상 msg.sender를 사용하라. tx.origin의 유일한 합법적 사용은 “EOA만 허용” (컨트랙트 호출 방지)이지만, 이 역시 권장되지 않는다.
4. 프론트러닝 (Front-Running)
블록체인의 트랜잭션은 확정되기 전에 멤풀(mempool)에 공개된다. 채굴자나 다른 참여자가 이 정보를 보고 자신의 트랜잭션을 먼저 끼워넣을 수 있다.
Alice의 트랜잭션: DEX에서 토큰 A를 100 ETH로 구매 (멤풀에 공개)
↓
공격자(Bot)가 발견: Alice의 구매로 가격이 오를 것을 예측
↓
공격자: 더 높은 가스비로 같은 토큰 먼저 구매 (Alice보다 먼저 채굴됨)
↓
Alice의 구매 실행: 이미 가격이 올라서 더 비싸게 삼 (슬리피지 손실)
↓
공격자: 비싸진 가격에 팔아서 차익 획득
이를 **샌드위치 공격(Sandwich Attack)**이라고 한다.
방어 방법
// 1. 슬리피지 제한 (사용자가 허용할 최소 수량 지정)
function swap(
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 minAmountOut // 이 이상을 못 받으면 revert
) external {
uint256 amountOut = _calculateOutput(tokenIn, tokenOut, amountIn);
require(amountOut >= minAmountOut, "Slippage too high");
IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
IERC20(tokenOut).transfer(msg.sender, amountOut);
}
// 2. Commit-Reveal 패턴 (게임, 경매에서)
mapping(address => bytes32) public commitments;
// 1단계: 의도를 해시로 숨겨서 제출
function commit(bytes32 commitment) external {
commitments[msg.sender] = commitment;
}
// 2단계: 나중에 공개
function reveal(uint256 value, bytes32 salt) external {
require(
commitments[msg.sender] == keccak256(abi.encodePacked(value, salt)),
"Invalid reveal"
);
// value 사용
delete commitments[msg.sender];
}
5. 플래시론 공격 개요
플래시론(Flash Loan)은 동일 트랜잭션 안에서 담보 없이 거액을 빌리고 갚는 DeFi 기능이다. 정당한 용도(차익거래, 담보 교환)도 있지만 공격에 악용될 수 있다.
공격 예시 (가격 조작):
1. 플래시론으로 100만 ETH 대출
2. 소형 DEX에서 특정 토큰 대량 매입 → 가격 조작
3. 조작된 가격을 사용하는 프로토콜에서 이익 취득
4. 플래시론 상환
방어: 외부 가격 피드로 오라클을 사용하거나 (Chainlink), TWAP(시간 가중 평균 가격)을 사용해 순간적인 가격 조작을 무력화한다.
6. 기타 주요 취약점
access control 실수
// 취약: initialize를 누구나 호출 가능
function initialize(address owner) external {
_owner = owner; // 공격자가 먼저 호출해서 소유권 탈취!
}
// 안전: initializer 제어자 사용
function initialize(address owner) external initializer {
_owner = owner;
}
block.timestamp 조작
// 취약: 채굴자가 타임스탬프를 약간 조작 가능 (±15초)
function isLotteryOpen() public view returns (bool) {
return block.timestamp % 7 == 0; // 예측/조작 가능
}
// 안전: 타임스탬프는 큰 범위에서만 신뢰 (초 단위 정밀도는 금물)
function hasExpired() public view returns (bool) {
return block.timestamp > deadline; // 수 분 이상 차이는 괜찮음
}
정수 나눗셈 버림
// 버림(truncation) 주의
uint256 fee = (amount * 3) / 100;
// amount = 10이면: (10 * 3) / 100 = 30 / 100 = 0 (수수료 없음!)
// 충분한 정밀도 유지
uint256 fee = (amount * 300) / 10000; // basis points 사용
// amount = 10이면: (10 * 300) / 10000 = 3000 / 10000 = 0 (여전히 0)
// 최소 금액 제한을 두거나 더 큰 단위로 계산해야 함
외부 컨트랙트 신뢰
// 취약: 토큰 컨트랙트가 악의적일 수 있음
function deposit(address token, uint256 amount) external {
IERC20(token).transferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount; // 실제로 받은 양 ≠ amount일 수 있음
// 전송 수수료가 있는 토큰(fee-on-transfer)이면 실제 수령량이 적음
}
// 안전: 실제 받은 양 확인
function deposit(address token, uint256 amount) external {
uint256 before = IERC20(token).balanceOf(address(this));
IERC20(token).transferFrom(msg.sender, address(this), amount);
uint256 actual = IERC20(token).balanceOf(address(this)) - before;
balances[msg.sender] += actual; // 실제 수령량 사용
}
안전한 컨트랙트 작성 체크리스트
[ ] CEI 패턴 준수 (Checks → Effects → Interactions)
[ ] nonReentrant 적용 (ETH/토큰 전송이 있는 모든 함수)
[ ] Solidity 0.8+ 사용 (자동 오버플로 방지)
[ ] tx.origin 미사용 (접근 제어에)
[ ] msg.sender 기반 접근 제어
[ ] 슬리피지 보호 (DEX 관련)
[ ] 입력값 전체 검증 (address(0), 0 값, 범위 초과)
[ ] 외부 컨트랙트 호출 최소화
[ ] 실제 수령량 확인 (fee-on-transfer 토큰 대비)
[ ] 초기화 함수 보호 (initializer 제어자)
[ ] 긴급 정지(Pause) 메커니즘
[ ] 다단계 소유권 이전 (Ownable2Step)
[ ] 업그레이드 가능 여부 설계
[ ] 이벤트 발행 (모든 중요 상태 변경)
[ ] 퍼즈 테스트 실행 (다양한 입력값)
[ ] 코드 커버리지 100% 목표
[ ] 외부 감사(audit) 진행
[ ] 버그 바운티 프로그램 운영
완전한 보안 컨트랙트 예시
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
/// @title SecureVault - 모든 보안 패턴이 적용된 금고
contract SecureVault is ReentrancyGuard, Ownable2Step, Pausable {
using SafeERC20 for IERC20;
// ============ 에러 ============
error ZeroAmount();
error ZeroAddress();
error InsufficientBalance(uint256 available, uint256 required);
error ExceedsDepositLimit(uint256 amount, uint256 limit);
// ============ 상태 변수 ============
IERC20 public immutable token;
mapping(address => uint256) private _balances;
uint256 public totalDeposits;
uint256 public depositLimit;
// ============ 이벤트 ============
event Deposited(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
event DepositLimitUpdated(uint256 oldLimit, uint256 newLimit);
// ============ 생성자 ============
constructor(address _token, address initialOwner, uint256 _depositLimit)
Ownable(initialOwner)
{
if (_token == address(0)) revert ZeroAddress();
if (initialOwner == address(0)) revert ZeroAddress();
token = IERC20(_token);
depositLimit = _depositLimit;
}
// ============ 핵심 함수 (CEI + nonReentrant + whenNotPaused) ============
function deposit(uint256 amount)
external
nonReentrant
whenNotPaused
{
// Checks
if (amount == 0) revert ZeroAmount();
if (totalDeposits + amount > depositLimit) {
revert ExceedsDepositLimit(amount, depositLimit);
}
// Effects (상태 변경 먼저)
_balances[msg.sender] += amount;
totalDeposits += amount;
// Interactions (외부 호출 마지막)
// SafeERC20: transfer 실패 시 revert (반환값 false 처리)
// fee-on-transfer 토큰 대비: 실제 수령량 확인 가능
token.safeTransferFrom(msg.sender, address(this), amount);
emit Deposited(msg.sender, amount);
}
function withdraw(uint256 amount)
external
nonReentrant
whenNotPaused
{
// Checks
if (amount == 0) revert ZeroAmount();
uint256 balance = _balances[msg.sender];
if (balance < amount) revert InsufficientBalance(balance, amount);
// Effects
_balances[msg.sender] = balance - amount;
totalDeposits -= amount;
// Interactions
token.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}
// ============ 조회 ============
function balanceOf(address user) external view returns (uint256) {
return _balances[user];
}
// ============ 소유자 전용 ============
function setDepositLimit(uint256 newLimit) external onlyOwner {
emit DepositLimitUpdated(depositLimit, newLimit);
depositLimit = newLimit;
}
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
// 긴급 자금 회수 (일시 정지 상태에서만)
function emergencyWithdraw(address to) external onlyOwner whenPaused {
if (to == address(0)) revert ZeroAddress();
uint256 balance = token.balanceOf(address(this));
token.safeTransfer(to, balance);
}
}
이 컨트랙트는 다음 보안 패턴을 모두 적용했다:
- ReentrancyGuard — 재진입 공격 방지
- CEI 패턴 — Effects가 Interactions 전에 완료
- Ownable2Step — 2단계 소유권 이전
- Pausable — 긴급 정지
- SafeERC20 — 안전한 토큰 전송
- 커스텀 에러 — 가스 효율적 에러
- 이벤트 — 모든 상태 변경 기록
- 입력 검증 — 모든 파라미터 검증
정리
스마트 컨트랙트 보안의 핵심 원칙:
- CEI 패턴 — Checks → Effects → Interactions 순서를 절대 지킨다
- nonReentrant — ETH/토큰 전송이 포함된 함수에 항상 적용
- msg.sender — 접근 제어는 항상 msg.sender, tx.origin은 사용 금지
- 입력 검증 — 모든 외부 입력값을 철저히 검증
- 최소 권한 — 각 역할에 필요한 최소한의 권한만 부여
- 외부 감사 — 큰 금액이 걸린 컨트랙트는 반드시 전문가 감사
7장: 비동기 프로그래밍
왜 비동기가 필요한가?
블록체인 노드는 동시에 많은 일을 처리합니다:
- 여러 피어와 TCP 연결 유지
- 새 트랜잭션 수신 및 검증
- 블록 동기화
- RPC 요청 처리
- 마이닝 (CPU 집약적)
이런 I/O 집약적 작업을 동기적으로 처리하면 하나의 작업이 끝날 때까지 나머지가 모두 대기합니다.
Node.js의 비동기 모델
Node.js는 싱글 스레드 + 이벤트 루프로 동시성을 처리합니다:
// Node.js — 이벤트 루프가 내장
const response1 = await fetch('https://api.example.com/blocks/1');
const response2 = await fetch('https://api.example.com/blocks/2');
// 순차적 — 1이 끝나야 2 시작
// 병렬 처리
const [r1, r2] = await Promise.all([
fetch('https://api.example.com/blocks/1'),
fetch('https://api.example.com/blocks/2'),
]);
Node.js는 처음부터 비동기를 전제로 설계되었습니다. 런타임(V8 + libuv)이 이벤트 루프를 제공합니다.
Rust의 비동기 모델
Rust는 async/await 문법을 제공하지만, 런타임은 포함되어 있지 않습니다. 런타임을 직접 선택해야 합니다.
// Rust — 런타임을 선택해야 함
use tokio; // 가장 널리 쓰이는 비동기 런타임
#[tokio::main]
async fn main() {
let response1 = fetch_block(1).await;
let response2 = fetch_block(2).await;
// 병렬 처리
let (r1, r2) = tokio::join!(
fetch_block(1),
fetch_block(2),
);
}
왜 런타임이 별도인가?
이것은 Rust의 “제로 비용 추상화” 철학의 일부입니다:
- 임베디드 시스템에서는 Tokio 런타임이 너무 무거울 수 있음
- 스마트 컨트랙트(Solana on-chain)에서는 비동기가 필요 없음
- 시스템 프로그래밍에서는 직접 스레드 관리가 필요할 수 있음
Rust는 async/await 문법만 언어에 포함하고, 실행 방법은 선택하게 합니다.
주요 비동기 런타임
| 런타임 | 특징 | 사용처 |
|---|---|---|
| Tokio | 가장 널리 사용, 고성능, 풍부한 생태계 | 웹 서버, 블록체인 노드 |
| async-std | 표준 라이브러리와 유사한 API | 범용 |
| smol | 경량 | 임베디드, 리소스 제한 환경 |
이 책에서는 Tokio를 사용합니다. Ethereum의 ethers-rs, Solana의 tokio 기반 클라이언트 모두 Tokio를 사용합니다.
이 장의 구성
- async/await (7.1): Future 트레이트, Node.js와 차이점
- Tokio (7.2): 설치, spawn, channel, HTTP
- 공유 상태 (7.3): Arc, Mutex, RwLock
다음 챕터에서 async/await를 자세히 배웁니다.
7.1 async/await와 Future
async fn: 비동기 함수
async fn으로 정의한 함수는 Future를 반환합니다:
#![allow(unused)]
fn main() {
// 동기 함수
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
// 비동기 함수
async fn greet_async(name: &str) -> String {
// 네트워크 요청, 파일 읽기 등 I/O 작업을 여기서
format!("Hello, {}!", name)
}
}
async fn foo() -> T는 실제로 fn foo() -> impl Future<Output = T>로 변환됩니다.
TypeScript와 비교:
// TypeScript: async fn은 Promise를 반환
async function greetAsync(name: string): Promise<string> {
return `Hello, ${name}!`;
}
#![allow(unused)]
fn main() {
// Rust: async fn은 Future를 반환 (Future ≈ Promise)
async fn greet_async(name: &str) -> String {
format!("Hello, {}!", name)
}
}
Future 트레이트
Future는 아직 완료되지 않은 비동기 계산을 나타냅니다:
// 표준 라이브러리에 이렇게 정의
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T), // 완료됨
Pending, // 아직 진행 중
}
Future는 poll()이 호출될 때만 진행됩니다. 생성만으로는 실행되지 않습니다. 이것이 Node.js의 Promise와 가장 큰 차이점입니다.
Promise vs Future
// TypeScript Promise: 생성 즉시 실행 시작
const promise = fetchData(); // 이미 실행 중!
// 나중에 await하든 안 하든 실행됨
// Rust Future: await하기 전까지 실행 안 됨
let future = fetch_data(); // 아직 실행 안 됨!
let result = future.await; // 여기서 실행 시작
.await 사용법
async fn fetch_block(height: u64) -> Result<Block, String> {
// 네트워크 요청 (비동기)
let url = format!("https://api.blockchain.com/blocks/{}", height);
// 실제 HTTP 요청은 reqwest 등 사용
Ok(Block { index: height, hash: String::from("abc") })
}
async fn get_latest_blocks() -> Vec<Block> {
let mut blocks = Vec::new();
// 순차 실행 — 하나씩
let block1 = fetch_block(1).await; // 완료 후
let block2 = fetch_block(2).await; // 실행
if let Ok(b) = block1 { blocks.push(b); }
if let Ok(b) = block2 { blocks.push(b); }
blocks
}
struct Block { index: u64, hash: String }
.await는 async fn 안에서만
// 에러! async 아닌 함수에서 .await 사용 불가
fn not_async() {
let result = fetch_block(1).await; // 컴파일 에러
}
// 해결책 1: async fn으로 만들기
async fn is_async() {
let result = fetch_block(1).await; // OK
}
// 해결책 2: 블로킹 실행 (런타임 필요)
fn blocking_main() {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(fetch_block(1));
}
실행자(Executor)가 필요한 이유
Future는 혼자 실행될 수 없습니다. **실행자(executor)**가 필요합니다. 실행자는 Future를 poll()로 구동시키는 스케줄러입니다.
Future ──────────────────► Executor
(비동기 계산의 명세) (실제 실행하는 주체)
poll() → Pending ──────► 다른 Future 실행 (I/O 대기 중)
poll() → Ready(T) ──────► 결과 반환
Node.js에서는 V8 엔진과 libuv가 이벤트 루프를 기본으로 제공합니다. Rust에서는 Tokio 등 외부 런타임이 이 역할을 합니다.
// Tokio 런타임이 Future를 실행
#[tokio::main]
async fn main() {
// tokio::main 매크로가 Tokio 런타임을 설정하고
// main() Future를 실행자에 제출
let result = fetch_block(1).await;
}
// 매크로 없이 직접 런타임 생성
fn main() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
let result = fetch_block(1).await;
println!("{:?}", result);
});
}
async 블록
함수 전체가 아닌 일부만 비동기로 만들 때:
fn main() {
let future = async {
// 이 블록은 async 컨텍스트
let block = fetch_block(1).await;
block
};
// future는 아직 실행 안 됨
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(future);
}
병렬 실행
tokio::join! — 여러 Future 동시 실행
use tokio;
async fn fetch_block(height: u64) -> String {
// 네트워크 요청 시뮬레이션
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
format!("block_{}", height)
}
#[tokio::main]
async fn main() {
// 순차 실행 — 300ms 소요
let b1 = fetch_block(1).await;
let b2 = fetch_block(2).await;
let b3 = fetch_block(3).await;
// 병렬 실행 — 100ms 소요 (가장 느린 것만큼)
let (b1, b2, b3) = tokio::join!(
fetch_block(1),
fetch_block(2),
fetch_block(3),
);
println!("{}, {}, {}", b1, b2, b3);
}
TypeScript와 비교:
// TypeScript
const [b1, b2, b3] = await Promise.all([
fetchBlock(1),
fetchBlock(2),
fetchBlock(3),
]);
tokio::select! — 가장 먼저 완료되는 것 선택
use tokio;
#[tokio::main]
async fn main() {
// 두 작업 중 먼저 끝나는 것 선택 (나머지는 취소)
tokio::select! {
result = fetch_block(1) => {
println!("Block 1 finished first: {}", result);
}
result = fetch_from_backup(1) => {
println!("Backup finished first: {}", result);
}
}
}
async fn fetch_from_backup(height: u64) -> String {
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
format!("backup_block_{}", height)
}
TypeScript와 비교:
// TypeScript
const result = await Promise.race([
fetchBlock(1),
fetchFromBackup(1),
]);
에러 처리와 async
use reqwest;
use serde_json::Value;
async fn fetch_block_data(height: u64) -> Result<Value, reqwest::Error> {
let url = format!("https://api.blockcypher.com/v1/btc/main/blocks/{}", height);
let response = reqwest::get(&url).await?; // ?로 에러 전파
let json = response.json::<Value>().await?;
Ok(json)
}
#[tokio::main]
async fn main() {
match fetch_block_data(100).await {
Ok(data) => println!("Block: {:?}", data),
Err(e) => eprintln!("Error: {}", e),
}
// ?와 함께 사용
// main이 Result를 반환하면 ?를 main에서도 사용 가능
}
Node.js와 Rust 비동기 비교 정리
| 개념 | Node.js/TypeScript | Rust |
|---|---|---|
| 비동기 함수 | async function f(): Promise<T> | async fn f() -> T (impl Future<Output=T>) |
| 값 꺼내기 | await promise | future.await |
| 병렬 실행 | Promise.all([...]) | tokio::join!(...) |
| 경쟁 실행 | Promise.race([...]) | tokio::select! { ... } |
| 에러 처리 | try/catch 또는 .catch() | ? 연산자, match |
| 런타임 | V8 + libuv (내장) | Tokio 등 (선택) |
| 실행 모델 | 싱글 스레드 이벤트 루프 | 멀티 스레드 (기본) |
| 즉시 실행 | Promise 생성 즉시 | .await 호출 시 |
요약
async fn:Future를 반환하는 함수 —.await로 완료 대기Future: 비동기 계산의 명세 —poll()이 호출될 때만 진행- 실행자(Executor):
Future를 구동하는 스케줄러 — Tokio 등이 담당 async fn안에서만.await사용 가능tokio::join!: 여러 Future 병렬 실행 (Promise.all)tokio::select!: 가장 먼저 완료되는 것 선택 (Promise.race)- Promise와 달리 Future는
.await전까지 실행 안 됨 (lazy)
다음 챕터에서 Tokio 런타임을 자세히 배웁니다.
7.2 Tokio 런타임
Tokio 설치
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
features = ["full"]은 모든 Tokio 기능을 활성화합니다. 프로덕션에서는 필요한 기능만 선택합니다:
# 세밀한 기능 선택
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "io-util"] }
#[tokio::main]
use tokio;
#[tokio::main]
async fn main() {
println!("Hello from async main!");
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
println!("One second later");
}
#[tokio::main] 매크로는 다음으로 확장됩니다:
fn main() {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
println!("Hello from async main!");
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
println!("One second later");
})
}
tokio::spawn: 비동기 태스크 생성
tokio::spawn은 새 태스크를 생성합니다. Node.js의 Promise 즉시 실행과 유사합니다:
use tokio;
async fn process_transaction(id: u64) -> String {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
format!("tx_{} processed", id)
}
#[tokio::main]
async fn main() {
// 태스크 생성 — 즉시 실행 시작 (백그라운드)
let handle1 = tokio::spawn(process_transaction(1));
let handle2 = tokio::spawn(process_transaction(2));
let handle3 = tokio::spawn(process_transaction(3));
// 각 태스크의 결과 기다리기
let r1 = handle1.await.unwrap(); // JoinHandle.await → Result<T, JoinError>
let r2 = handle2.await.unwrap();
let r3 = handle3.await.unwrap();
println!("{}, {}, {}", r1, r2, r3);
}
TypeScript와 비교:
// TypeScript
async function processTransaction(id: number): Promise<string> {
await sleep(100);
return `tx_${id} processed`;
}
const p1 = processTransaction(1); // 즉시 시작
const p2 = processTransaction(2);
const p3 = processTransaction(3);
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
spawn 주의사항
#[tokio::main]
async fn main() {
let data = String::from("hello");
// data를 스폰된 태스크로 이동 (move 필요)
let handle = tokio::spawn(async move {
println!("In task: {}", data);
// data의 소유권이 이 태스크로 이동
});
// println!("{}", data); // 에러! 이동됨
handle.await.unwrap();
}
스폰된 태스크는 'static 수명을 요구합니다. 즉, 캡처하는 모든 변수는 소유되거나 'static이어야 합니다.
tokio::time: 타이머
use tokio::time::{sleep, Duration, timeout, interval};
#[tokio::main]
async fn main() {
// sleep: N초 대기
sleep(Duration::from_secs(1)).await;
sleep(Duration::from_millis(500)).await;
// timeout: N초 안에 완료되지 않으면 에러
let result = timeout(
Duration::from_secs(5),
fetch_block(100), // 이 future가 5초 안에 완료되어야 함
).await;
match result {
Ok(block) => println!("Got block"),
Err(_) => println!("Timed out!"),
}
// interval: 주기적 실행
let mut ticker = interval(Duration::from_secs(1));
for _ in 0..5 {
ticker.tick().await; // 1초마다 실행
println!("Tick!");
}
}
async fn fetch_block(height: u64) -> String {
sleep(Duration::from_millis(100)).await;
format!("block_{}", height)
}
채널 (Channels)
채널은 태스크 간 메시지 전달에 사용합니다. Node.js에는 직접적인 대응이 없지만, EventEmitter나 Queue와 유사합니다.
mpsc: 다수 송신자, 단일 수신자
Multi-Producer Single-Consumer — 가장 흔한 패턴입니다.
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
// 버퍼 크기 32인 채널 생성
let (tx, mut rx) = mpsc::channel::<String>(32);
// 여러 송신자 (tx.clone()으로 복제)
let tx1 = tx.clone();
let tx2 = tx.clone();
drop(tx); // 원본 tx 드롭 (남은 sender가 없으면 rx는 None을 받음)
// 송신자 태스크 1
tokio::spawn(async move {
for i in 0..3 {
tx1.send(format!("task1: tx_{}", i)).await.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
});
// 송신자 태스크 2
tokio::spawn(async move {
for i in 0..3 {
tx2.send(format!("task2: tx_{}", i)).await.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(75)).await;
}
});
// 수신자: 모든 메시지 처리
while let Some(msg) = rx.recv().await {
println!("Received: {}", msg);
}
println!("All senders dropped, channel closed");
}
oneshot: 단일 응답
use tokio::sync::oneshot;
async fn compute_hash(data: String, responder: oneshot::Sender<String>) {
let hash = format!("{:x}", data.len()); // 실제로는 SHA-256
responder.send(hash).unwrap();
}
#[tokio::main]
async fn main() {
let (tx, rx) = oneshot::channel::<String>();
tokio::spawn(compute_hash(String::from("block_data"), tx));
let hash = rx.await.unwrap();
println!("Hash: {}", hash);
}
블록체인에서의 채널 패턴
use tokio::sync::mpsc;
#[derive(Debug)]
enum MinerCommand {
StartMining { data: String, difficulty: usize },
StopMining,
}
#[derive(Debug)]
struct MinedBlock {
data: String,
hash: String,
nonce: u64,
}
async fn miner_task(mut cmd_rx: mpsc::Receiver<MinerCommand>, result_tx: mpsc::Sender<MinedBlock>) {
while let Some(cmd) = cmd_rx.recv().await {
match cmd {
MinerCommand::StartMining { data, difficulty } => {
println!("Mining with difficulty {}...", difficulty);
let target = "0".repeat(difficulty);
let mut nonce = 0u64;
loop {
let hash = format!("{:x}", data.len() + nonce as usize);
if hash.starts_with(&target) {
let block = MinedBlock { data: data.clone(), hash, nonce };
result_tx.send(block).await.unwrap();
break;
}
nonce += 1;
// CPU 독점 방지 — 주기적으로 다른 태스크에 양보
if nonce % 1000 == 0 {
tokio::task::yield_now().await;
}
}
}
MinerCommand::StopMining => {
println!("Mining stopped");
break;
}
}
}
}
#[tokio::main]
async fn main() {
let (cmd_tx, cmd_rx) = mpsc::channel(10);
let (result_tx, mut result_rx) = mpsc::channel(10);
// 마이너 태스크 시작
tokio::spawn(miner_task(cmd_rx, result_tx));
// 마이닝 명령 전송
cmd_tx.send(MinerCommand::StartMining {
data: String::from("Block 1 data"),
difficulty: 1,
}).await.unwrap();
// 결과 수신
if let Some(block) = result_rx.recv().await {
println!("Mined! Hash: {}, Nonce: {}", block.hash, block.nonce);
}
}
HTTP 요청: reqwest
use reqwest;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
struct BlockInfo {
height: u64,
hash: String,
time: u64,
n_tx: u32,
}
#[derive(Debug, Deserialize)]
struct BitcoinBlockResponse {
height: u64,
hash: String,
}
async fn get_latest_bitcoin_block() -> Result<BitcoinBlockResponse, reqwest::Error> {
let client = reqwest::Client::new();
let response = client
.get("https://blockchain.info/latestblock")
.header("User-Agent", "rust-blockchain-learner/1.0")
.send()
.await?
.json::<BitcoinBlockResponse>()
.await?;
Ok(response)
}
async fn post_transaction(tx_data: &str) -> Result<String, reqwest::Error> {
let client = reqwest::Client::new();
let response = client
.post("https://api.example.com/transactions")
.header("Content-Type", "application/json")
.body(tx_data.to_string())
.send()
.await?;
let status = response.status();
let body = response.text().await?;
if status.is_success() {
Ok(body)
} else {
Err(reqwest::Error::from(
// 실제로는 커스텀 에러 타입 사용
reqwest::StatusCode::INTERNAL_SERVER_ERROR
))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
match get_latest_bitcoin_block().await {
Ok(block) => println!("Latest block: height={}, hash={}", block.height, block.hash),
Err(e) => eprintln!("Failed: {}", e),
}
Ok(())
}
reqwest 클라이언트 재사용
use reqwest::Client;
use std::sync::Arc;
// 클라이언트를 Arc로 공유 (커넥션 풀 재사용)
#[derive(Clone)]
struct BlockchainClient {
http: Arc<Client>,
base_url: String,
}
impl BlockchainClient {
fn new(base_url: String) -> Self {
BlockchainClient {
http: Arc::new(Client::new()),
base_url,
}
}
async fn get_block(&self, height: u64) -> Result<serde_json::Value, reqwest::Error> {
let url = format!("{}/blocks/{}", self.base_url, height);
self.http.get(&url).send().await?.json().await
}
}
#[tokio::main]
async fn main() {
let client = BlockchainClient::new("https://api.blockchain.com/v3/btc".to_string());
// 여러 태스크에서 공유
let handles: Vec<_> = (0..5).map(|i| {
let c = client.clone(); // Arc 클론 — 저렴함
tokio::spawn(async move {
match c.get_block(i).await {
Ok(block) => println!("Block {}: {:?}", i, block),
Err(e) => eprintln!("Error block {}: {}", i, e),
}
})
}).collect();
for h in handles {
h.await.unwrap();
}
}
Express/NestJS와 Axum 비교
// NestJS
@Controller('blocks')
export class BlockController {
constructor(private blockService: BlockService) {}
@Get(':height')
async getBlock(@Param('height') height: string): Promise<BlockDto> {
return this.blockService.findByHeight(parseInt(height));
}
@Post()
async addBlock(@Body() dto: CreateBlockDto): Promise<BlockDto> {
return this.blockService.create(dto);
}
}
// Axum (Rust의 웹 프레임워크)
use axum::{
routing::{get, post},
Router, Json, Path,
extract::State,
};
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
blockchain: Arc<tokio::sync::RwLock<Blockchain>>,
}
async fn get_block(
Path(height): Path<u64>,
State(state): State<AppState>,
) -> Result<Json<Block>, String> {
let chain = state.blockchain.read().await;
chain.get_block(height)
.map(|b| Json(b.clone()))
.ok_or_else(|| format!("Block {} not found", height))
}
async fn add_block(
State(state): State<AppState>,
Json(data): Json<CreateBlockRequest>,
) -> Result<Json<Block>, String> {
let mut chain = state.blockchain.write().await;
chain.add_block(data.data)
.map(|b| Json(b.clone()))
.map_err(|e| e.to_string())
}
#[tokio::main]
async fn main() {
let state = AppState {
blockchain: Arc::new(tokio::sync::RwLock::new(Blockchain::new())),
};
let app = Router::new()
.route("/blocks/:height", get(get_block))
.route("/blocks", post(add_block))
.with_state(state);
println!("Server running on http://0.0.0.0:3000");
axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}
struct Blockchain { blocks: Vec<Block> }
impl Blockchain {
fn new() -> Self { Blockchain { blocks: vec![] } }
fn get_block(&self, height: u64) -> Option<&Block> { self.blocks.get(height as usize) }
fn add_block(&mut self, data: String) -> Result<&Block, String> {
let block = Block { index: self.blocks.len() as u64, data, hash: String::from("abc") };
self.blocks.push(block);
Ok(self.blocks.last().unwrap())
}
}
#[derive(Clone, serde::Serialize)]
struct Block { index: u64, data: String, hash: String }
#[derive(serde::Deserialize)]
struct CreateBlockRequest { data: String }
요약
#[tokio::main]: 비동기 main 함수를 위한 매크로tokio::spawn: 새 태스크 생성 (백그라운드 실행)tokio::time::sleep: 비동기 대기tokio::time::timeout: 시간 제한 설정mpsc채널: 다수 송신자, 단일 수신자oneshot채널: 단일 요청-응답 패턴reqwest: 비동기 HTTP 클라이언트- 웹 서버: Axum (NestJS/Express 대응)
다음 챕터에서 스레드 간 안전한 상태 공유를 배웁니다.
7.3 공유 상태: Arc, Mutex, RwLock
왜 공유 상태가 필요한가?
여러 비동기 태스크나 스레드가 같은 데이터에 접근해야 하는 경우가 있습니다:
- 블록체인 상태를 여러 RPC 핸들러가 읽고 쓰기
- 메모리 풀(mempool)에 여러 태스크가 동시에 트랜잭션 추가
- 캐시를 여러 요청 핸들러가 공유
Node.js는 싱글 스레드이기 때문에 이런 문제가 없었습니다. Rust의 비동기 런타임(Tokio)은 멀티 스레드로 동작하므로 데이터 레이스를 방지해야 합니다.
소유권과 스레드 안전성
// 이건 불가능 — 소유권은 하나
fn main() {
let data = String::from("blockchain data");
let t1 = std::thread::spawn(|| {
println!("{}", data); // data 캡처 시도
});
let t2 = std::thread::spawn(|| {
println!("{}", data); // 에러! data는 이미 t1이 가져감
});
}
해결책: 여러 소유자를 허용하는 Arc<T>를 사용합니다.
Arc<T>: 스레드 간 공유 소유권
Arc는 Atomically Reference Counted — 스레드 안전한 참조 카운팅 스마트 포인터입니다.
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(vec![1, 2, 3, 4, 5]);
let mut handles = vec![];
for i in 0..3 {
let data_clone = Arc::clone(&data); // 참조 카운트 증가 (데이터 복사 없음)
let handle = thread::spawn(move || {
println!("Thread {}: {:?}", i, data_clone);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
// 여기서도 data 사용 가능
println!("Main: {:?}", data);
}
Arc::clone()은 데이터를 복사하지 않습니다. 내부 참조 카운터만 증가합니다. 마지막 Arc가 드롭될 때 데이터가 해제됩니다.
Rc<T>(단일 스레드 참조 카운팅) vs Arc<T>(멀티 스레드):
Rc<T>: 싱글 스레드 전용, 더 빠름Arc<T>: 스레드 간 공유 가능, 원자적 연산으로 약간 느림
Mutex<T>: 상호 배제
Arc는 불변 데이터를 공유합니다. 데이터를 수정하려면 Mutex가 필요합니다.
Mutex(Mutual Exclusion)는 한 번에 하나의 스레드만 접근할 수 있도록 잠금을 제공합니다.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0u64));
let mut handles = vec![];
for _ in 0..10 {
let counter_clone = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter_clone.lock().unwrap(); // 잠금 획득
*num += 1;
// num이 스코프를 벗어나면 잠금 자동 해제 (MutexGuard의 Drop)
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Counter: {}", *counter.lock().unwrap()); // 10
}
Mutex의 잠금 획득과 해제
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut val = m.lock().unwrap(); // 잠금 획득 → MutexGuard
*val = 6;
println!("val = {}", *val);
} // MutexGuard 드롭 → 잠금 자동 해제
println!("m = {:?}", m); // Mutex { data: 6 }
// 잠금 상태 확인
if let Ok(guard) = m.try_lock() {
println!("Got lock: {}", *guard);
} else {
println!("Lock is held by another thread");
};
}
데드락 주의
use std::sync::{Arc, Mutex};
// 데드락 예시 — 주의!
fn deadlock_example() {
let lock1 = Arc::new(Mutex::new(1));
let lock2 = Arc::new(Mutex::new(2));
let l1 = Arc::clone(&lock1);
let l2 = Arc::clone(&lock2);
// 스레드 A: lock1 → lock2 순서
let t1 = std::thread::spawn(move || {
let _g1 = l1.lock().unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let _g2 = l2.lock().unwrap(); // lock2 대기 중
});
// 스레드 B: lock2 → lock1 순서 — 데드락!
let t2 = std::thread::spawn(move || {
let _g2 = lock2.lock().unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let _g1 = lock1.lock().unwrap(); // lock1 대기 중
});
// t1은 lock2를 기다리고, t2는 lock1을 기다림 — 영원히 대기
t1.join().unwrap();
t2.join().unwrap();
}
// 해결책: 항상 같은 순서로 잠금 획득
fn safe_locking() {
let lock1 = Arc::new(Mutex::new(1));
let lock2 = Arc::new(Mutex::new(2));
// 두 스레드 모두 lock1 → lock2 순서로 잠금
// 데드락 없음
}
RwLock<T>: 읽기/쓰기 락
Mutex는 읽기도 독점합니다. 읽기 작업이 많고 쓰기가 드문 경우 RwLock이 효율적입니다.
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let blockchain = Arc::new(RwLock::new(vec!["genesis".to_string()]));
let mut handles = vec![];
// 여러 읽기 스레드 — 동시에 접근 가능
for i in 0..5 {
let chain = Arc::clone(&blockchain);
let handle = thread::spawn(move || {
let chain_ref = chain.read().unwrap(); // 읽기 잠금 (여러 동시 가능)
println!("Reader {}: {} blocks", i, chain_ref.len());
// 잠금 자동 해제
});
handles.push(handle);
}
// 하나의 쓰기 스레드 — 독점 접근
{
let chain = Arc::clone(&blockchain);
let handle = thread::spawn(move || {
let mut chain_ref = chain.write().unwrap(); // 쓰기 잠금 (독점)
chain_ref.push("block_1".to_string());
println!("Writer: added block");
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final: {:?}", *blockchain.read().unwrap());
}
읽기/쓰기 비율에 따른 선택
| 상황 | 선택 |
|---|---|
| 읽기 전용 | Arc<T> |
| 읽기 >> 쓰기 | Arc<RwLock<T>> |
| 읽기 ≈ 쓰기 또는 쓰기 >> 읽기 | Arc<Mutex<T>> |
Tokio에서의 공유 상태
Tokio 비동기 환경에서는 tokio::sync::Mutex와 tokio::sync::RwLock을 사용합니다:
use tokio::sync::{Mutex, RwLock};
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
blockchain: Arc<RwLock<Blockchain>>,
mempool: Arc<Mutex<Vec<Transaction>>>,
}
impl AppState {
fn new() -> Self {
AppState {
blockchain: Arc::new(RwLock::new(Blockchain::new())),
mempool: Arc::new(Mutex::new(Vec::new())),
}
}
}
async fn handle_new_transaction(
state: AppState,
tx: Transaction,
) -> Result<(), String> {
// mempool에 트랜잭션 추가
let mut pool = state.mempool.lock().await;
pool.push(tx);
println!("Mempool size: {}", pool.len());
Ok(())
// pool 드롭 → 잠금 해제
}
async fn get_block_count(state: &AppState) -> u64 {
// 읽기 잠금 — 여러 태스크 동시 접근 가능
let chain = state.blockchain.read().await;
chain.blocks.len() as u64
}
async fn mine_new_block(state: AppState, data: String) -> Result<(), String> {
// 쓰기 잠금 — 독점 접근
let mut chain = state.blockchain.write().await;
chain.add_block(data)?;
Ok(())
}
struct Blockchain { blocks: Vec<String> }
impl Blockchain {
fn new() -> Self { Blockchain { blocks: vec!["genesis".to_string()] } }
fn add_block(&mut self, data: String) -> Result<(), String> {
self.blocks.push(data);
Ok(())
}
}
struct Transaction { id: String }
#[tokio::main]
async fn main() {
let state = AppState::new();
let state_clone = state.clone();
tokio::spawn(async move {
handle_new_transaction(
state_clone,
Transaction { id: "tx1".to_string() },
).await.unwrap();
});
println!("Block count: {}", get_block_count(&state).await);
}
.await 중 락 보유의 위험성
use tokio::sync::Mutex;
use std::sync::Arc;
// 위험한 패턴!
async fn dangerous(state: Arc<Mutex<Vec<String>>>) {
let mut data = state.lock().await; // 잠금 획득
// .await 동안 잠금을 보유!
// 다른 태스크가 같은 잠금을 얻으려 하면 교착 상태!
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
data.push("hello".to_string());
} // 잠금 해제
// 안전한 패턴
async fn safe(state: Arc<Mutex<Vec<String>>>) {
// 방법 1: 잠금 범위를 최소화
let result = {
let mut data = state.lock().await;
data.push("hello".to_string());
data.len() // 잠금 해제 전에 필요한 값 추출
}; // 잠금 해제
// .await은 잠금 해제 후에
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
println!("Data size: {}", result);
// 방법 2: 명시적으로 drop
let mut data = state.lock().await;
data.push("world".to_string());
drop(data); // 명시적 잠금 해제
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
규칙: .await 포인트를 걸치는 Mutex 가드는 Rust 컴파일러가 경고합니다. std::sync::Mutex를 async 코드에서 쓰면 컴파일 에러가 나는 경우도 있습니다.
Node.js에서 공유 상태가 필요 없는 이유
// Node.js — 싱글 스레드 이벤트 루프
const blockchain = []; // 전역 변수로 안전하게 공유
app.post('/blocks', async (req, res) => {
// 이벤트 루프의 한 틱에서 실행
// await 전까지는 다른 요청이 끼어들 수 없음
blockchain.push(newBlock); // 안전
const result = await saveToDb(newBlock); // 여기서만 다른 요청 실행 가능
res.json(result);
});
Node.js는 await 포인트 사이에서는 완전히 단독으로 실행됩니다. 여러 요청이 동시에 blockchain 배열을 수정할 수 없습니다.
Rust/Tokio는 멀티 스레드이므로 여러 태스크가 정말로 동시에 실행됩니다. 따라서 Arc<Mutex<T>>가 필요합니다.
실용 패턴: 블록체인 노드 상태 관리
use tokio::sync::RwLock;
use std::sync::Arc;
use std::collections::HashMap;
#[derive(Clone)]
pub struct NodeState {
// 블록체인 — 읽기 많음
pub blockchain: Arc<RwLock<Blockchain>>,
// 피어 목록 — 읽기/쓰기 혼합
pub peers: Arc<RwLock<Vec<String>>>,
// 트랜잭션 캐시 — 쓰기 많음
pub tx_cache: Arc<RwLock<HashMap<String, Transaction>>>,
}
impl NodeState {
pub fn new() -> Self {
NodeState {
blockchain: Arc::new(RwLock::new(Blockchain::new())),
peers: Arc::new(RwLock::new(Vec::new())),
tx_cache: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn add_peer(&self, addr: String) {
let mut peers = self.peers.write().await;
if !peers.contains(&addr) {
peers.push(addr);
}
}
pub async fn get_peer_count(&self) -> usize {
self.peers.read().await.len()
}
pub async fn cache_transaction(&self, tx: Transaction) {
let mut cache = self.tx_cache.write().await;
cache.insert(tx.id.clone(), tx);
}
pub async fn get_cached_tx(&self, id: &str) -> Option<Transaction> {
self.tx_cache.read().await.get(id).cloned()
}
}
#[derive(Clone)]
struct Transaction { id: String }
struct Blockchain { blocks: Vec<String> }
impl Blockchain { fn new() -> Self { Blockchain { blocks: vec![] } } }
요약
Arc<T>: 스레드 간 공유 소유권 (참조 카운팅)Mutex<T>: 상호 배제 잠금 — 읽기/쓰기 모두 독점RwLock<T>: 읽기 잠금(동시 다중) / 쓰기 잠금(독점) 분리Arc<Mutex<T>>또는Arc<RwLock<T>>: 가장 흔한 패턴- Tokio에서는
tokio::sync::Mutex,tokio::sync::RwLock사용 .await중에 락을 보유하지 않도록 주의 (교착 상태 위험)- Node.js는 싱글 스레드 → 이벤트 루프로 안전, Rust는 멀티 스레드 → 명시적 동기화 필요
다음으로는 Solana 아키텍처를 배웁니다.
Solana 아키텍처: 고성능 단일 레이어 블록체인
Solana란 무엇인가
Solana는 2020년 Anatoly Yakovenko가 창시한 고성능 레이어 1 블록체인입니다. “세상에서 가장 빠른 블록체인“을 목표로 설계된 Solana는 이더리움이 레이어 2나 샤딩으로 해결하려는 확장성 문제를 단일 레이어에서 해결하는 독자적인 접근법을 취합니다.
Node.js 백엔드 개발자 관점에서 비유하자면:
- 이더리움 = 수직 확장에 한계가 있어 마이크로서비스(L2)로 분산시키는 모놀리식 서버
- Solana = 처음부터 고성능 싱글 프로세스로 설계된 서버 (Node.js의 이벤트 루프처럼 단일 스레드이지만 극도로 최적화된)
이더리움과 Solana 핵심 차이점 비교
| 항목 | 이더리움 | Solana |
|---|---|---|
| TPS | ~15 (L1 기준) | ~4,000 (실측), 이론 65,000+ |
| 블록 확정 시간 | ~12초 | ~400ms |
| 평균 수수료 | $1 ~ $50+ (가스 경매) | $0.00025 (고정에 가까움) |
| 합의 메커니즘 | PoS (Casper) | PoS + PoH (Proof of History) |
| 상태 모델 | 컨트랙트 내부 저장소 | 분리된 Account 모델 |
| 스마트 컨트랙트 언어 | Solidity, Vyper | Rust, C, C++ |
| 개발 프레임워크 | Hardhat, Foundry | Anchor |
| 병렬 처리 | 순차 처리 | Sealevel 병렬 실행 |
| 레이어 구조 | L1 + L2 생태계 | 단일 L1 |
상태 모델 차이: 가장 중요한 개념
이더리움 모델:
┌─────────────────────────────┐
│ ERC20 컨트랙트 │
│ ┌───────────────────────┐ │
│ │ balances mapping │ │ ← 데이터가 컨트랙트 안에 있음
│ │ totalSupply │ │
│ │ allowances mapping │ │
│ └───────────────────────┘ │
│ 함수: transfer, approve │
└─────────────────────────────┘
Solana 모델:
┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Token │ │ User A의 │ │ User B의 │
│ Program │───▶│ Token Account │ │ Token Account │
│ (코드만) │ │ balance: 100 │ │ balance: 50 │
└─────────────┘ └──────────────────┘ └──────────────────┘
프로그램은 상태를 갖지 않음. 데이터는 별도 Account에 저장
NestJS에 비유하면:
- 이더리움:
UserService가 자체 인메모리 Map으로 데이터를 관리 (서비스 안에 DB가 있는 구조) - Solana:
UserService는 로직만 갖고, 실제 데이터는 외부 PostgreSQL Account에 저장
수수료 모델 차이
// 이더리움: 가스 경매 방식 (런타임에 결정)
// 트랜잭션 수수료 = gasUsed × gasPrice
// 네트워크 혼잡 시 gasPrice가 폭등 → 예측 불가
// Solana: 기본 수수료 고정
// 수수료 = 서명 수 × 5,000 lamports
// 1 SOL = 1,000,000,000 lamports
// 서명 1개 = 0.000005 SOL ≈ $0.00025 (SOL이 $50일 때)
Solana의 8가지 핵심 혁신
1. Proof of History (PoH) - Solana의 심장
PoH는 Solana의 가장 혁신적인 기술입니다. 시간 자체를 블록체인에 기록하는 방식입니다.
일반 블록체인의 문제:
- 검증자들이 "이 트랜잭션이 언제 발생했는가"를 합의로 결정해야 함
- 매 블록마다 네트워크 통신이 필요 → 느림
PoH의 해결책:
SHA256(이전 해시) → SHA256(SHA256(이전 해시)) → ...
이 체인이 곧 시계 역할을 함
[Hash_0] → [Hash_1, Tx_A] → [Hash_2] → [Hash_3, Tx_B] → [Hash_4]
↑ Tx_A가 Hash_0 이후, ↑ Tx_B가 Tx_A 이후에
Hash_2 이전에 발생했음이 Hash_4 이전에 발생했음
수학적으로 증명됨 이 수학적으로 증명됨
SHA256은 순차적으로만 계산 가능하므로, 해시 체인의 길이 자체가 경과 시간을 증명합니다. 검증자들이 매번 합의할 필요 없이 리더 혼자 시간을 기록하고 나머지가 병렬로 검증합니다.
2. Tower BFT - PoH 기반 합의
전통적인 PBFT(Practical Byzantine Fault Tolerance)를 PoH 시계 위에서 실행합니다.
- 검증자들이 투표할 때 “lockout” 타임아웃을 증가시킴
- 이전 투표를 번복할수록 더 큰 페널티(슬래싱)
- PoH 덕분에 시간 동기화 문제가 해결되어 합의 속도 향상
3. Turbine - 블록 전파 프로토콜
BitTorrent에서 영감을 받은 블록 데이터 전파 방식입니다.
전통적 방식: Turbine 방식:
리더
리더 → 모든 검증자 ↙ ↓ ↘
(O(n) 대역폭 필요) 검증자1 검증자2 검증자3
↙↘ ↙↘ ↙↘
... ... ...
(데이터를 청크로 분산 전파)
블록을 작은 패킷(shred)으로 분할하여 트리 구조로 전파함으로써 리더의 대역폭 부담을 O(log n)으로 줄입니다.
4. Gulf Stream - 멤풀 없는 트랜잭션 전달
이더리움은 트랜잭션을 멤풀(mempool)에 쌓아두고 채굴자/검증자가 선택합니다. Solana는 멤풀을 없애고 다음 리더에게 직접 트랜잭션을 전달합니다.
이더리움:
사용자 → 멤풀(대기열) → 검증자가 선택 → 블록 포함
Solana:
사용자 → 현재 리더, 다음 리더, 다다음 리더에게 미리 전달
→ 리더가 즉시 처리 가능, 멤풀 혼잡 없음
5. Sealevel - 병렬 스마트 컨트랙트 실행
이더리움의 EVM은 트랜잭션을 순차적으로 처리합니다. Solana의 Sealevel은 서로 다른 계정에 접근하는 트랜잭션을 병렬 처리합니다.
// Solana 트랜잭션은 사용할 계정 목록을 미리 선언
Transaction {
accounts: [user_a, token_account_a], // 이 트랜잭션이 건드리는 계정
}
// 런타임이 계정 충돌 분석:
// Tx1: [A, B] 사용 → 병렬 실행 가능!
// Tx2: [C, D] 사용 → A, B와 겹치지 않으므로
// Tx3: [A, E] 사용 → A가 겹치므로 Tx1과 순차 실행
6. Pipelining - CPU 파이프라인 최적화
트랜잭션 처리를 4단계 파이프라인으로 분리합니다:
- 데이터 수신 (네트워크 수신 유닛)
- 서명 검증 (GPU 활용)
- 뱅킹 처리 (CPU 코어들)
- 블록 기록 (디스크)
각 단계가 동시에 다른 트랜잭션 배치를 처리합니다.
7. Cloudbreak - 수평 확장 계정 DB
Solana의 계정 데이터베이스는 SSD에 최적화된 방식으로 동시 읽기/쓰기를 지원합니다.
8. Archivers (현재 미구현) - 분산 원장 저장
계획상 전체 원장을 모든 노드가 저장하지 않고, Archivers(replicators)가 분산 저장하는 구조.
Solana 네트워크 구조
클러스터 (Cluster)
Solana 네트워크는 여러 클러스터로 구성됩니다:
Mainnet-beta: 실제 운영 네트워크 (https://api.mainnet-beta.solana.com)
Testnet: 스트레스 테스트용 (https://api.testnet.solana.com)
Devnet: 개발/테스트용, 무료 SOL 에어드롭 가능 (https://api.devnet.solana.com)
Localnet: 로컬 개발 환경 (solana-test-validator)
Node.js 개발자에게 익숙한 개념으로: NODE_ENV=development|staging|production과 유사합니다.
검증자 (Validator)
검증자는 Solana 네트워크의 노드입니다. 각 검증자는:
- 전체 블록체인 상태를 유지
- 트랜잭션을 검증하고 블록에 투표
- 스테이킹된 SOL 양에 비례하여 리더로 선택
현재 ~2,000개 이상의 검증자가 운영 중입니다.
리더 (Leader) / 슬롯 (Slot)
Epoch (약 2-3일)
├── Slot 0: 리더 A (400ms)
├── Slot 1: 리더 A (400ms)
├── Slot 2: 리더 A (400ms)
├── Slot 3: 리더 A (400ms) ← 연속 4 슬롯
├── Slot 4: 리더 B (400ms)
├── Slot 5: 리더 B (400ms)
├── ...
└── Slot N: ...
- 하나의 슬롯 = ~400ms, 하나의 블록에 해당
- 각 슬롯마다 한 명의 리더(슬롯 리더)가 트랜잭션을 처리하고 블록을 생성
- 리더 스케줄은 에폭 시작 시 스테이크 가중치에 따라 미리 결정됨
- Gulf Stream 덕분에 다음 리더가 누구인지 알고 트랜잭션을 미리 전달 가능
현재 Solana의 발전 현황
Firedancer
Jump Crypto가 개발 중인 Solana의 두 번째 검증자 클라이언트입니다 (2024년 mainnet 출시).
현재: Solana Labs 클라이언트 (Rust) ← 모든 검증자가 사용
목표: Firedancer (C/C++) ← Jump Crypto 개발, 100만 TPS 목표
이더리움과의 비교:
이더리움은 클라이언트 다양성(Geth, Prysm, Lighthouse 등)이 잘 갖춰져 있음
Solana는 Firedancer로 이 약점을 보완 중
Solana Labs 클라이언트가 Rust를 선택한 이유:
- GC 없는 예측 가능한 레이턴시: PoH 해시 체인은 SHA256을 연속으로 계산하는 단일 경로로, GC 일시정지가 끼어들면 슬롯 타이밍(400ms)이 어긋남
- Sealevel 병렬 처리: 서로 다른 계정에 접근하는 트랜잭션을 동시에 실행할 때 데이터 레이스를 컴파일 타임에 차단
- 두려움 없는 동시성: 검증자 내부의 네트워크 수신·서명 검증·뱅킹·디스크 기록 4단계 파이프라인을 스레드 안전하게 구현
- WASM 컴파일 지원: 스마트 컨트랙트(Program)를 Rust로 작성해 BPF 바이트코드로 컴파일, 단일 언어로 인프라와 컨트랙트를 모두 개발 가능
Firedancer의 특징:
- C/C++로 재작성하여 극한의 성능 최적화
- 독립적인 코드베이스로 클라이언트 다양성 확보
- 목표 TPS: 100만+ (이론적)
Alpenglow
2025년 발표된 Solana의 새로운 합의 프로토콜입니다. Tower BFT를 대체하는 것이 목표입니다:
- Rotor: Turbine의 개선 버전, 더 효율적인 블록 전파
- Votor: 새로운 투표 메커니즘, 단일 슬롯 내 확정(single-slot finality) 목표
현재 Tower BFT는 완전한 확정(finality)에 32개 슬롯 (~12.8초)이 필요합니다. Alpenglow는 이를 단일 슬롯(~400ms) 내에 달성하는 것을 목표로 합니다.
개발 환경 설정
# Solana CLI 설치
sh -c "$(curl -sSfL https://release.solana.com/stable/install)"
# 설치 확인
solana --version
# Devnet으로 설정
solana config set --url devnet
# 현재 설정 확인
solana config get
# Config File: ~/.config/solana/cli/config.yml
# RPC URL: https://api.devnet.solana.com
# WebSocket URL: wss://api.devnet.solana.com/
# Keypair Path: ~/.config/solana/id.json
# 새 지갑 생성
solana-keygen new
# Devnet에서 무료 SOL 받기 (에어드롭)
solana airdrop 2
# 잔액 확인
solana balance
# 2 SOL
# 로컬 테스트 검증자 실행
solana-test-validator
다음 장 미리보기
이제 Solana의 전체적인 구조를 이해했습니다. 다음 장에서는 Solana의 가장 핵심적인 개념인 Account 모델을 깊이 파고들겠습니다. “Solana에서는 모든 것이 Account다“라는 말의 의미를 코드와 함께 살펴봅니다.
Account 모델: Solana에서는 모든 것이 Account다
핵심 개념 이해
Solana를 처음 배울 때 가장 먼저, 가장 깊이 이해해야 할 개념이 바로 Account 모델입니다. 이더리움에서 넘어온 개발자들이 가장 많이 혼란스러워하는 부분이기도 합니다.
“Solana에서는 모든 것이 Account다”
SOL을 보유한 지갑? Account입니다. 스마트 컨트랙트(프로그램)? Account입니다. 프로그램이 저장하는 데이터? Account입니다. 시스템 프로그램(SOL 전송 기능)? Account입니다.
이더리움은 EOA(Externally Owned Account)와 Contract Account 두 종류만 있었다면, Solana는 훨씬 더 유연하고 일관된 단일 Account 구조를 사용합니다.
Account의 구조
모든 Solana Account는 다음 필드를 가집니다:
┌─────────────────────────────────────────────────────┐
│ Account │
├─────────────────┬───────────────────────────────────┤
│ lamports │ u64 - SOL 잔액 (1 SOL = 10^9 lamports) │
├─────────────────┼───────────────────────────────────┤
│ data │ Vec<u8> - 임의의 바이트 배열 │
├─────────────────┼───────────────────────────────────┤
│ owner │ Pubkey - 이 계정을 "소유"하는 프로그램 │
├─────────────────┼───────────────────────────────────┤
│ executable │ bool - 프로그램인지 여부 │
├─────────────────┼───────────────────────────────────┤
│ rent_epoch │ u64 - 렌트 관련 (현재는 거의 무시) │
└─────────────────┴───────────────────────────────────┘
lamports (잔액)
// 1 SOL = 1,000,000,000 lamports (10억)
// lamport는 Solana의 최소 단위
// Leslie Lamport (분산 시스템 대가)의 이름에서 유래
let balance_in_sol = account.lamports as f64 / 1_000_000_000.0;
println!("잔액: {} SOL", balance_in_sol);
// JavaScript에서:
const lamports = await connection.getBalance(publicKey);
const sol = lamports / LAMPORTS_PER_SOL; // LAMPORTS_PER_SOL = 1e9
data (바이트 배열)
Account의 data 필드는 그냥 바이트 배열(Vec<u8>)입니다. 이 바이트 배열에 무엇을 어떻게 저장할지는 owner 프로그램이 결정합니다.
지갑 Account: data = [] (비어있음)
Token Account: data = [mint(32), owner(32), amount(8), ...] (165 bytes)
Mint Account: data = [mint_authority(36), supply(8), decimals(1), ...] (82 bytes)
커스텀 프로그램 데이터: data = 프로그램이 원하는 구조로 직렬화
owner (소유 프로그램)
owner는 이 계정의 data를 읽고 쓸 수 있는 프로그램의 주소입니다.
중요 규칙:
1. 프로그램은 자신이 owner인 계정의 data만 수정 가능
2. 어떤 프로그램도 다른 프로그램 소유 계정의 data를 직접 수정 불가
3. 단, lamports 감소는 서명자가 가능 (전송)
예시:
- 일반 지갑: owner = System Program (11111111...)
- Token Account: owner = Token Program (TokenkegQfe...)
- 내가 만든 프로그램 데이터: owner = 내 프로그램 주소
executable (실행 가능 여부)
#![allow(unused)]
fn main() {
// executable = true → 프로그램 Account (스마트 컨트랙트)
// executable = false → 데이터 Account (일반 계정)
// 프로그램 Account의 data에는 BPF 바이트코드가 저장됨
// executable Account는 수정 불가 (업그레이드 가능 프로그램은 별도 메커니즘 사용)
}
Account의 세 가지 유형
1. 데이터 계정 (Data Account)
프로그램이 상태를 저장하는 계정입니다. executable = false.
┌─────────────────────────────────────┐
│ 데이터 계정 예시 │
├────────────────┬────────────────────┤
│ lamports │ 2,000,000 │ ← 렌트 면제 보증금
│ data │ { score: 100, │ ← 프로그램이 정의한 구조
│ │ player: "Alice" }│
│ owner │ 내 게임 프로그램 │ ← 이 프로그램만 data 수정 가능
│ executable │ false │
│ rent_epoch │ 0 │
└────────────────┴────────────────────┘
데이터 계정의 예:
- 사용자의 토큰 잔액을 저장하는 Token Account
- NFT 메타데이터를 저장하는 Account
- 게임 플레이어의 점수를 저장하는 Account
- DEX의 유동성 풀 정보를 저장하는 Account
2. 프로그램 계정 (Program Account)
스마트 컨트랙트 코드가 저장된 계정입니다. executable = true.
┌─────────────────────────────────────┐
│ 프로그램 계정 │
├────────────────┬────────────────────┤
│ lamports │ 1,141,440 │ ← 렌트 면제 보증금
│ data │ [BPF 바이트코드] │ ← 컴파일된 프로그램
│ owner │ BPF Loader │ ← BPF Loader가 소유
│ executable │ true │ ← 실행 가능!
│ rent_epoch │ 0 │
└────────────────┴────────────────────┘
주소 예시:
- Token Program: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
- System Program: 11111111111111111111111111111111
3. 네이티브 계정 (Native Program Account)
Solana 런타임에 내장된 특별 프로그램들입니다.
System Program (11111111111111111111111111111111)
→ 새 계정 생성, SOL 전송, 프로그램 배포
Token Program (TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA)
→ SPL 토큰 발행, 전송, 소각
Token-2022 Program (TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb)
→ 확장 기능이 추가된 새 토큰 프로그램
BPF Loader (BPFLoaderUpgradeab1e11111111111111111111111)
→ 프로그램 배포 및 업그레이드 관리
Sysvar 계정들 (읽기 전용 시스템 정보)
→ Clock: 현재 시간/슬롯 정보
→ Rent: 렌트 정책 정보
→ EpochSchedule: 에폭 스케줄 정보
이더리움 vs Solana 상태 모델 심층 비교
NestJS 관점으로 이해하기
// 이더리움 방식: 컨트랙트가 자체 저장소를 가짐
// NestJS 비유: 서비스가 자체 인메모리 Map으로 상태 관리
@Injectable()
class EthereumStyleUserService {
// 컨트랙트 storage 변수처럼, 서비스 안에 데이터가 있음
private balances: Map<string, number> = new Map();
private totalSupply: number = 0;
transfer(from: string, to: string, amount: number) {
// 같은 서비스 안의 데이터를 직접 수정
this.balances.set(from, this.balances.get(from)! - amount);
this.balances.set(to, (this.balances.get(to) || 0) + amount);
}
}
// Solana 방식: 프로그램은 로직만, 데이터는 외부 Account에
// NestJS 비유: 서비스는 로직만 갖고, 외부 DB(Account)를 읽고 씀
@Injectable()
class SolanaStyleTokenProgram {
// 프로그램 자체에 상태 없음!
transfer(
fromAccount: TokenAccount, // 외부에서 전달받은 Account
toAccount: TokenAccount, // 외부에서 전달받은 Account
amount: number
) {
// 외부 Account의 데이터를 수정
fromAccount.balance -= amount;
toAccount.balance += amount;
// 변경사항은 각 Account에 저장됨
}
}
// 실제 Solana에서:
// 트랜잭션을 보낼 때 어떤 Account를 사용할지 미리 명시해야 함
// → Sealevel 병렬 처리의 기반!
이더리움 ERC20 vs Solana SPL Token 비교
이더리움 ERC20:
┌─────────────────────────────────────────┐
│ ERC20 컨트랙트 │
│ address: 0xA0b86991c6218b36c1d19D4... │
│ │
│ mapping balances: │
│ 0xUser1 → 100 USDC │
│ 0xUser2 → 50 USDC │
│ 0xUser3 → 200 USDC │
│ │
│ uint256 totalSupply: 350 │
│ string name: "USD Coin" │
│ uint8 decimals: 6 │
└─────────────────────────────────────────┘
Solana SPL Token:
┌──────────────┐
│ Mint 계정 │ ← 토큰 자체 정보 (ERC20 컨트랙트의 메타데이터)
│ supply: 350 │
│ decimals: 6 │
│ authority │
└──────┬───────┘
│ "이 Mint에 속하는 Token Account들"
├──────────────────────────────┐
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ User1의 │ │ User2의 │
│ Token 계정 │ │ Token 계정 │
│ owner: User1│ │ owner: User2│
│ amount: 100 │ │ amount: 50 │
│ mint: USDC │ │ mint: USDC │
└─────────────┘ └─────────────┘
핵심 차이: Solana에서 각 사용자는 토큰마다 별도의 Token Account를 생성해야 합니다. 이것이 Solana에서 새 토큰을 받으려면 먼저 “Associated Token Account“를 만들어야 하는 이유입니다.
렌트 (Rent): 계정 유지 비용
Solana에서 Account를 유지하려면 렌트를 지불해야 합니다. 이는 검증자들의 RAM 사용에 대한 비용입니다.
렌트 작동 방식
과거 방식 (현재는 deprecated):
- 매 에폭마다 account.lamports에서 렌트 차감
- lamports가 0이 되면 계정 삭제
현재 방식: Rent-Exempt (렌트 면제)
- 충분한 lamports를 보증금으로 예치하면 렌트 영구 면제
- 최소 lamports = 데이터 크기에 따라 계산
- 계정을 닫을 때 보증금을 돌려받음
Rent-Exempt 계산
// 렌트 면제 최소 lamports 계산
// 기준: ~0.00000348 SOL per byte per year × 2년 = 면제
// 대략적인 계산:
// 128 bytes 계정 = 약 890,880 lamports = 0.00089 SOL
// 0 bytes 계정 = 약 890,880 lamports (기본 오버헤드)
// JavaScript로 계산:
const { Connection, LAMPORTS_PER_SOL } = require('@solana/web3.js');
const connection = new Connection('https://api.devnet.solana.com');
async function calculateRentExempt(dataSize: number): Promise<number> {
const rentExemptBalance = await connection.getMinimumBalanceForRentExemption(dataSize);
console.log(`${dataSize} bytes 계정의 렌트 면제 최소: ${rentExemptBalance} lamports`);
console.log(`= ${rentExemptBalance / LAMPORTS_PER_SOL} SOL`);
return rentExemptBalance;
}
// 실제 값 예시:
// 0 bytes → 890,880 lamports (0.00089 SOL)
// 165 bytes (Token Account) → 2,039,280 lamports (0.00204 SOL)
// 200 bytes → 2,277,120 lamports (0.00228 SOL)
Rent-Exempt 조건
계정 lamports ≥ 2년치 렌트 → 렌트 면제 (영구 유지)
계정 lamports < 2년치 렌트 → 매 에폭마다 차감 (결국 삭제)
실제로 Solana 생태계에서는 모든 계정을 rent-exempt 상태로 생성하는 것이 표준입니다.
ASCII 아트: Account 전체 구조 시각화
Solana 네트워크의 전체 Account 구조:
┌─────────────────────────────────────────────────────────────────┐
│ Solana 상태 (State) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ System Program │ │ Token Program │ Native Programs │
│ │ executable=true│ │ executable=true│ │
│ │ data=[bytecode]│ │ data=[bytecode]│ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ owns │ owns │
│ ▼ ▼ │
│ ┌────────────────┐ ┌──────────────────┐ ┌──────────────┐ │
│ │ Alice 지갑 │ │ Alice USDC │ │ Mint 계정 │ │
│ │ lamports:5SOL │ │ Token Account │ │ USDC 정보 │ │
│ │ data:[] │ │ amount: 100 │ │ supply:1000 │ │
│ │ exec: false │ │ exec: false │ │ exec: false │ │
│ └────────────────┘ └──────────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 내 게임 프로그램 │ │
│ │ address: GameProg111... executable=true │ │
│ │ data: [BPF 바이트코드] │ │
│ └───────────────────────────┬──────────────────────────────┘ │
│ │ owns │
│ ▼ │
│ ┌────────────────────────────────────┐ │
│ │ Alice의 게임 데이터 계정 │ │
│ │ owner: GameProg111... │ │
│ │ data: {score: 9500, level: 42} │ │
│ │ executable: false │ │
│ └────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
코드 예제: JavaScript/TypeScript로 Account 데이터 읽기
import {
Connection,
PublicKey,
LAMPORTS_PER_SOL,
AccountInfo,
} from '@solana/web3.js';
const connection = new Connection('https://api.devnet.solana.com', 'confirmed');
async function readAccountInfo(address: string) {
const publicKey = new PublicKey(address);
// 기본 계정 정보 읽기
const accountInfo: AccountInfo<Buffer> | null =
await connection.getAccountInfo(publicKey);
if (!accountInfo) {
console.log('계정이 존재하지 않습니다.');
return;
}
console.log('=== Account 정보 ===');
console.log(`lamports: ${accountInfo.lamports} (${accountInfo.lamports / LAMPORTS_PER_SOL} SOL)`);
console.log(`data 크기: ${accountInfo.data.length} bytes`);
console.log(`owner: ${accountInfo.owner.toBase58()}`);
console.log(`executable: ${accountInfo.executable}`);
console.log(`rentEpoch: ${accountInfo.rentEpoch}`);
}
// SOL 잔액 조회
async function getBalance(address: string) {
const publicKey = new PublicKey(address);
const lamports = await connection.getBalance(publicKey);
console.log(`잔액: ${lamports / LAMPORTS_PER_SOL} SOL`);
}
// 여러 계정 한 번에 조회 (배치 최적화)
async function getMultipleAccounts(addresses: string[]) {
const publicKeys = addresses.map(addr => new PublicKey(addr));
const accounts = await connection.getMultipleAccountsInfo(publicKeys);
accounts.forEach((account, index) => {
if (account) {
console.log(`계정 ${index}: ${account.lamports} lamports, owner: ${account.owner.toBase58()}`);
} else {
console.log(`계정 ${index}: 존재하지 않음`);
}
});
}
// 렌트 면제 최소 잔액 계산
async function checkRentExempt(dataSize: number) {
const minBalance = await connection.getMinimumBalanceForRentExemption(dataSize);
console.log(`${dataSize}bytes 계정의 렌트 면제 최소: ${minBalance / LAMPORTS_PER_SOL} SOL`);
}
// 실행
(async () => {
// System Program 주소 (잘 알려진 주소)
const SYSTEM_PROGRAM = '11111111111111111111111111111111';
const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
await readAccountInfo(SYSTEM_PROGRAM);
await readAccountInfo(TOKEN_PROGRAM);
await checkRentExempt(165); // Token Account 크기
await checkRentExempt(200); // 커스텀 데이터 계정
})();
실행 결과 예시:
=== Account 정보 (System Program) ===
lamports: 1000000000000 (1000 SOL)
data 크기: 14 bytes
owner: NativeLoader1111111111111111111111111111111
executable: true
rentEpoch: 0
=== Account 정보 (Token Program) ===
lamports: 1141440 (0.00114144 SOL)
data 크기: 36 bytes (업그레이드 가능한 프로그램 포인터)
owner: BPFLoaderUpgradeab1e11111111111111111111111
executable: true
rentEpoch: 0
165bytes 계정의 렌트 면제 최소: 0.00203928 SOL
200bytes 계정의 렌트 면제 최소: 0.00228288 SOL
Rust에서 Account 데이터 역직렬화
use solana_program::{
account_info::AccountInfo,
pubkey::Pubkey,
};
use borsh::{BorshDeserialize, BorshSerialize};
// 커스텀 데이터 구조 정의
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct PlayerData {
pub score: u64,
pub level: u32,
pub player_name: String,
pub is_initialized: bool,
}
// Account에서 데이터 읽기
pub fn read_player_data(account: &AccountInfo) -> Result<PlayerData, Box<dyn std::error::Error>> {
// account.data는 RefCell<&mut [u8]>
let data = account.data.borrow();
// Borsh로 역직렬화
let player_data = PlayerData::try_from_slice(&data)?;
println!("플레이어: {}, 점수: {}, 레벨: {}",
player_data.player_name,
player_data.score,
player_data.level
);
Ok(player_data)
}
// Account에 데이터 쓰기
pub fn write_player_data(
account: &AccountInfo,
data: &PlayerData,
) -> Result<(), Box<dyn std::error::Error>> {
let mut account_data = account.data.borrow_mut();
data.serialize(&mut *account_data)?;
Ok(())
}
핵심 정리
| 개념 | 이더리움 | Solana |
|---|---|---|
| 기본 단위 | Wei (10^-18 ETH) | Lamport (10^-9 SOL) |
| 계정 유형 | EOA, Contract | 모두 Account |
| 데이터 위치 | 컨트랙트 내부 storage | 별도 Account의 data 필드 |
| 프로그램 상태 | stateful | stateless |
| 계정 유지 비용 | 없음 | Rent (rent-exempt 가능) |
| 계정 생성 | 자동 (첫 트랜잭션) | 명시적 생성 필요 |
다음 장에서는 프로그램(스마트 컨트랙트)이 어떻게 작동하고, Instruction과 Transaction이 어떻게 구성되는지 살펴봅니다.
프로그램과 Instruction: Solana 스마트 컨트랙트
프로그램 = 스마트 컨트랙트
Solana에서 **프로그램(Program)**은 이더리움의 스마트 컨트랙트와 동일한 개념입니다. 용어만 다를 뿐, 블록체인 위에서 실행되는 코드입니다.
그러나 결정적인 차이가 있습니다:
이더리움 스마트 컨트랙트: Solana 프로그램:
┌────────────────────┐ ┌────────────────────┐
│ Counter Contract │ │ Counter Program │
│ │ │ │
│ uint count = 0; │ │ (상태 없음!) │
│ │ │ │
│ function inc() { │ │ fn increment( │
│ count++; │ │ accounts, │
│ } │ │ data │
│ │ │ ) { │
│ function get() { │ │ // 외부 account │
│ return count; │ │ // 의 data 수정 │
│ } │ │ } │
└────────────────────┘ └────────────────────┘
컨트랙트 자체가 상태 보유 프로그램은 순수 로직만
Solana 프로그램은 완전히 stateless입니다. 상태는 항상 별도의 Account에 저장됩니다. 이는 마치 Rust의 순수 함수처럼, 같은 입력(accounts + data)이 주어지면 항상 같은 결과를 냅니다.
Instruction 구조
Solana에서 프로그램을 호출하는 단위는 Instruction입니다. 이더리움에서 컨트랙트 함수를 호출하는 것과 유사하지만, 구조가 더 명시적입니다.
// Instruction의 세 가지 구성요소
pub struct Instruction {
/// 호출할 프로그램의 주소
pub program_id: Pubkey,
/// 이 Instruction이 읽거나 쓸 Account 목록
pub accounts: Vec<AccountMeta>,
/// 프로그램에 전달할 직렬화된 데이터
pub data: Vec<u8>,
}
pub struct AccountMeta {
/// Account의 주소
pub pubkey: Pubkey,
/// 이 트랜잭션에서 서명자인가?
pub is_signer: bool,
/// 이 Instruction에서 데이터를 수정할 수 있는가?
pub is_writable: bool,
}
왜 accounts를 미리 선언해야 하는가?
// 이더리움: 어떤 storage에 접근할지 런타임에 결정됨
contract.transfer(to, amount); // 내부적으로 어떤 storage 건드리는지 미리 알 수 없음
// Solana: 어떤 Account를 사용할지 미리 명시
const instruction = new TransactionInstruction({
programId: COUNTER_PROGRAM_ID,
accounts: [
{ pubkey: counterAccount, isSigner: false, isWritable: true },
{ pubkey: user.publicKey, isSigner: true, isWritable: false },
],
data: Buffer.from([0]), // increment 명령
});
미리 선언하는 이유: Sealevel이 어떤 트랜잭션들이 병렬 실행 가능한지 분석하기 위해서입니다. 서로 다른 Account를 건드리는 트랜잭션은 동시에 실행될 수 있습니다.
Transaction 구조
Transaction은 하나 이상의 Instruction을 묶은 것입니다. 이더리움과 달리 여러 Instruction이 하나의 Transaction에 포함될 수 있으며, 이는 원자적(atomic)으로 실행됩니다.
Transaction
├── 헤더 (서명자 수, 읽기 전용 계정 수 등)
├── 계정 주소 목록 (중복 제거된 모든 Account)
├── recent_blockhash (재생 공격 방지)
├── Instruction 목록
│ ├── Instruction 1: System Program → 새 Account 생성
│ ├── Instruction 2: Token Program → 토큰 초기화
│ └── Instruction 3: 내 프로그램 → 사용자 등록
└── 서명 목록
Atomic 실행의 중요성
// 예시: NFT 민팅 트랜잭션
// 세 Instruction이 모두 성공하거나, 모두 실패해야 함
const mintTx = new Transaction()
.add(
// 1. NFT Mint Account 생성 (System Program)
SystemProgram.createAccount({
fromPubkey: payer.publicKey,
newAccountPubkey: mintAccount.publicKey,
lamports: mintRent,
space: MINT_SIZE,
programId: TOKEN_PROGRAM_ID,
})
)
.add(
// 2. Mint 초기화 (Token Program)
createInitializeMintInstruction(
mintAccount.publicKey,
0, // decimals
payer.publicKey, // mintAuthority
payer.publicKey // freezeAuthority
)
)
.add(
// 3. NFT 메타데이터 생성 (Metaplex Program)
createCreateMetadataAccountV3Instruction(
{ metadata, mint, mintAuthority, payer, updateAuthority },
{ createMetadataAccountArgsV3: metadataArgs }
)
);
// 만약 3번이 실패하면, 1번과 2번도 롤백됨!
await sendAndConfirmTransaction(connection, mintTx, [payer, mintAccount]);
이더리움에서 여러 컨트랙트 호출을 원자적으로 처리하려면 별도의 Multicall 컨트랙트가 필요했지만, Solana는 이것이 기본 기능입니다.
직렬화: Borsh
Solana의 Instruction data 필드와 Account data 필드는 모두 바이트 배열입니다. 이 바이트 배열을 만들고 해석하는 직렬화 방식으로 Borsh를 사용합니다.
Borsh vs JSON 비교
JSON 직렬화:
{"action": "increment", "amount": 5}
→ 33 bytes, 파싱 오버헤드 있음
Borsh 직렬화:
[0, 5, 0, 0, 0, 0, 0, 0, 0] (action=0, amount=5 as u64 little-endian)
→ 9 bytes, 파싱이 매우 빠름
이름의 유래: Binary Object Representation Serializer for Hashing
특징:
- 결정론적 (같은 데이터 → 항상 같은 바이트)
- 스키마 없이 역직렬화 가능
- 매우 빠르고 간결
- Rust, JavaScript, Python 등 다양한 언어 지원
// Rust에서 Borsh 사용
use borsh::{BorshDeserialize, BorshSerialize};
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct CounterInstruction {
pub action: u8, // 0 = increment, 1 = decrement, 2 = reset
pub amount: u64,
}
// 직렬화
let ix = CounterInstruction { action: 0, amount: 5 };
let bytes = ix.try_to_vec().unwrap();
// bytes = [0, 5, 0, 0, 0, 0, 0, 0, 0]
// 역직렬화
let ix_parsed = CounterInstruction::try_from_slice(&bytes).unwrap();
assert_eq!(ix_parsed.action, 0);
assert_eq!(ix_parsed.amount, 5);
// TypeScript에서 Borsh 사용
import * as borsh from 'borsh';
class CounterInstruction {
action: number;
amount: bigint;
constructor(fields: { action: number; amount: bigint }) {
this.action = fields.action;
this.amount = fields.amount;
}
}
const schema = new Map([
[CounterInstruction, {
kind: 'struct',
fields: [
['action', 'u8'],
['amount', 'u64'],
],
}],
]);
// 직렬화
const instruction = new CounterInstruction({ action: 0, amount: BigInt(5) });
const bytes = borsh.serialize(schema, instruction);
// 역직렬화
const parsed = borsh.deserialize(schema, CounterInstruction, Buffer.from(bytes));
Native 프로그램 작성 (Anchor 없이)
Anchor 프레임워크 없이 Raw Rust로 카운터 프로그램을 작성해보겠습니다. 이를 통해 Solana 프로그램의 기본 구조를 이해할 수 있습니다.
프로젝트 구조
counter/
├── Cargo.toml
└── src/
└── lib.rs
Cargo.toml
[package]
name = "counter"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
[dependencies]
solana-program = "1.18"
borsh = { version = "0.10", features = ["derive"] }
borsh-derive = "0.10"
src/lib.rs - 완전한 카운터 프로그램
use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::{
account_info::{next_account_info, AccountInfo},
entrypoint,
entrypoint::ProgramResult,
msg,
program_error::ProgramError,
pubkey::Pubkey,
rent::Rent,
system_instruction,
program::invoke,
sysvar::Sysvar,
};
// ============================================================
// 1. 데이터 구조 정의
// ============================================================
/// 카운터 계정에 저장될 데이터
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct CounterAccount {
pub count: u64,
pub authority: Pubkey, // 카운터를 초기화한 사람
pub is_initialized: bool,
}
impl CounterAccount {
pub const LEN: usize =
8 + // count: u64
32 + // authority: Pubkey
1; // is_initialized: bool
// = 41 bytes
}
/// Instruction 타입 정의
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub enum CounterInstruction {
/// 카운터 초기화
/// accounts: [counter_account(writable), authority(signer), system_program]
Initialize,
/// 카운터 증가
/// accounts: [counter_account(writable), authority(signer)]
Increment { amount: u64 },
/// 카운터 감소
/// accounts: [counter_account(writable), authority(signer)]
Decrement { amount: u64 },
/// 카운터 리셋
/// accounts: [counter_account(writable), authority(signer)]
Reset,
}
// ============================================================
// 2. 엔트리포인트 등록
// ============================================================
// 이 매크로가 프로그램의 진입점을 등록합니다
// 이더리움의 fallback 함수와 유사한 역할
entrypoint!(process_instruction);
// ============================================================
// 3. 메인 처리 함수
// ============================================================
pub fn process_instruction(
program_id: &Pubkey, // 이 프로그램의 주소
accounts: &[AccountInfo], // 트랜잭션에서 전달된 계정들
instruction_data: &[u8], // 직렬화된 명령 데이터
) -> ProgramResult {
// Instruction 역직렬화
let instruction = CounterInstruction::try_from_slice(instruction_data)
.map_err(|_| ProgramError::InvalidInstructionData)?;
// Instruction 종류에 따라 라우팅
match instruction {
CounterInstruction::Initialize => {
process_initialize(program_id, accounts)
}
CounterInstruction::Increment { amount } => {
process_increment(accounts, amount)
}
CounterInstruction::Decrement { amount } => {
process_decrement(accounts, amount)
}
CounterInstruction::Reset => {
process_reset(accounts)
}
}
}
// ============================================================
// 4. 각 Instruction 처리 함수
// ============================================================
fn process_initialize(
program_id: &Pubkey,
accounts: &[AccountInfo],
) -> ProgramResult {
// 계정 목록 파싱 (순서가 중요!)
let accounts_iter = &mut accounts.iter();
let counter_account = next_account_info(accounts_iter)?;
let authority = next_account_info(accounts_iter)?;
let system_program = next_account_info(accounts_iter)?;
// 검증: authority가 서명했는가?
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// 검증: counter_account가 이미 초기화되었는가?
if counter_account.data_len() > 0 {
return Err(ProgramError::AccountAlreadyInitialized);
}
// 렌트 면제를 위한 최소 lamports 계산
let rent = Rent::get()?;
let required_lamports = rent.minimum_balance(CounterAccount::LEN);
// System Program에 CPI로 계정 생성
// (CPI = Cross-Program Invocation, 프로그램이 다른 프로그램 호출)
invoke(
&system_instruction::create_account(
authority.key, // 비용 지불자
counter_account.key, // 새로 만들 계정
required_lamports, // 렌트 면제 보증금
CounterAccount::LEN as u64, // 데이터 크기
program_id, // 소유자 = 이 프로그램
),
&[
authority.clone(),
counter_account.clone(),
system_program.clone(),
],
)?;
// 초기 데이터 저장
let counter_data = CounterAccount {
count: 0,
authority: *authority.key,
is_initialized: true,
};
let mut account_data = counter_account.data.borrow_mut();
counter_data.serialize(&mut *account_data)?;
msg!("카운터 초기화 완료! authority: {}", authority.key);
Ok(())
}
fn process_increment(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let accounts_iter = &mut accounts.iter();
let counter_account = next_account_info(accounts_iter)?;
let authority = next_account_info(accounts_iter)?;
// 서명 검증
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// 현재 데이터 읽기
let mut counter_data =
CounterAccount::try_from_slice(&counter_account.data.borrow())?;
// 권한 검증: 초기화한 사람만 수정 가능
if counter_data.authority != *authority.key {
return Err(ProgramError::IllegalOwner);
}
// 초기화 여부 확인
if !counter_data.is_initialized {
return Err(ProgramError::UninitializedAccount);
}
// 오버플로 방지하며 증가
counter_data.count = counter_data.count
.checked_add(amount)
.ok_or(ProgramError::InvalidArgument)?;
// 수정된 데이터 저장
counter_data.serialize(&mut *counter_account.data.borrow_mut())?;
msg!("카운터 증가: {} → {}", counter_data.count - amount, counter_data.count);
Ok(())
}
fn process_decrement(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let accounts_iter = &mut accounts.iter();
let counter_account = next_account_info(accounts_iter)?;
let authority = next_account_info(accounts_iter)?;
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
let mut counter_data =
CounterAccount::try_from_slice(&counter_account.data.borrow())?;
if counter_data.authority != *authority.key {
return Err(ProgramError::IllegalOwner);
}
// 언더플로 방지
counter_data.count = counter_data.count
.checked_sub(amount)
.ok_or(ProgramError::InvalidArgument)?;
counter_data.serialize(&mut *counter_account.data.borrow_mut())?;
msg!("카운터 감소: {}", counter_data.count);
Ok(())
}
fn process_reset(accounts: &[AccountInfo]) -> ProgramResult {
let accounts_iter = &mut accounts.iter();
let counter_account = next_account_info(accounts_iter)?;
let authority = next_account_info(accounts_iter)?;
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
let mut counter_data =
CounterAccount::try_from_slice(&counter_account.data.borrow())?;
if counter_data.authority != *authority.key {
return Err(ProgramError::IllegalOwner);
}
counter_data.count = 0;
counter_data.serialize(&mut *counter_account.data.borrow_mut())?;
msg!("카운터 리셋!");
Ok(())
}
이더리움 컨트랙트 호출과의 비교
이더리움 (Solidity):
─────────────────────────────────────────────────────
// 컨트랙트 배포 후 영구 주소
address contractAddress = 0xA0b86991c6218b36c1d19D4...;
// ABI로 함수 호출
await contract.increment(5);
// → tx에 함수 선택자 + 인코딩된 인자가 data로 들어감
// → 컨트랙트 내부의 count storage를 직접 수정
Solana (Native Rust):
─────────────────────────────────────────────────────
// 프로그램 ID (배포된 주소)
const PROGRAM_ID = new PublicKey("Counter111...");
// Instruction 생성: 어떤 계정을 사용할지 명시
const ix = new TransactionInstruction({
programId: PROGRAM_ID,
accounts: [
{ pubkey: counterPubkey, isSigner: false, isWritable: true },
{ pubkey: user.publicKey, isSigner: true, isWritable: false },
],
data: borsh.serialize(schema, new IncrementInstruction({ amount: 5n })),
});
// 트랜잭션 실행
await sendAndConfirmTransaction(connection, new Transaction().add(ix), [user]);
// → 프로그램이 counterPubkey Account의 data를 수정
클라이언트 코드: TypeScript로 카운터 호출
import {
Connection,
PublicKey,
Transaction,
TransactionInstruction,
SystemProgram,
Keypair,
sendAndConfirmTransaction,
AccountMeta,
} from '@solana/web3.js';
import * as borsh from 'borsh';
const PROGRAM_ID = new PublicKey('여기에_배포된_프로그램_ID');
const connection = new Connection('https://api.devnet.solana.com', 'confirmed');
// Instruction 스키마 (Borsh)
class InitializeInstruction {
instruction: number = 0;
constructor() {}
}
class IncrementInstruction {
instruction: number = 1;
amount: bigint;
constructor(amount: bigint) { this.amount = amount; }
}
const schema = new Map([
[InitializeInstruction, { kind: 'struct', fields: [['instruction', 'u8']] }],
[IncrementInstruction, { kind: 'struct', fields: [['instruction', 'u8'], ['amount', 'u64']] }],
]);
async function initializeCounter(
payer: Keypair,
counterKeypair: Keypair
): Promise<void> {
const ix = new TransactionInstruction({
programId: PROGRAM_ID,
accounts: [
{ pubkey: counterKeypair.publicKey, isSigner: true, isWritable: true },
{ pubkey: payer.publicKey, isSigner: true, isWritable: true },
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
],
data: Buffer.from(borsh.serialize(schema, new InitializeInstruction())),
});
const tx = new Transaction().add(ix);
const sig = await sendAndConfirmTransaction(connection, tx, [payer, counterKeypair]);
console.log(`초기화 완료! 서명: ${sig}`);
}
async function incrementCounter(
payer: Keypair,
counterPubkey: PublicKey,
amount: bigint
): Promise<void> {
const ix = new TransactionInstruction({
programId: PROGRAM_ID,
accounts: [
{ pubkey: counterPubkey, isSigner: false, isWritable: true },
{ pubkey: payer.publicKey, isSigner: true, isWritable: false },
],
data: Buffer.from(borsh.serialize(schema, new IncrementInstruction(amount))),
});
const tx = new Transaction().add(ix);
await sendAndConfirmTransaction(connection, tx, [payer]);
console.log(`카운터 ${amount} 증가!`);
}
핵심 정리
Solana 프로그램 실행 흐름:
사용자
│
▼
Transaction
├── Instruction 1
│ ├── program_id: 어떤 프로그램?
│ ├── accounts: 어떤 계정들?
│ └── data: 무슨 명령? (Borsh 직렬화)
└── Instruction 2
└── ...
│
▼
Solana Runtime (Sealevel)
├── 병렬 실행 가능한 트랜잭션 분석
├── 프로그램 로드
└── process_instruction(program_id, accounts, data) 호출
│
▼
프로그램 실행
├── Instruction 역직렬화
├── 계정 검증 (owner, signer, writable)
├── 비즈니스 로직 실행
└── Account data 수정
다음 장에서는 PDA(Program Derived Address)와 CPI(Cross-Program Invocation)를 통해 프로그램들이 어떻게 서로 상호작용하는지 배웁니다.
PDA와 CPI: 프로그램 간 상호작용
PDA (Program Derived Address)
왜 PDA가 필요한가?
Solana에서 일반 Account는 개인키(private key)로 서명해야 트랜잭션을 보낼 수 있습니다. 그런데 프로그램이 “자신을 대신해서” Account를 제어하고 싶을 때 문제가 생깁니다.
시나리오: 에스크로(Escrow) 프로그램
─────────────────────────────────────
Alice가 100 SOL을 에스크로에 예치
→ Bob이 조건 이행 시 자동으로 SOL 받음
문제: 에스크로 Account의 SOL을 누가 전송하는가?
- Alice도 아니고 (이미 에스크로에 넣었으므로)
- Bob도 아니고 (조건 확인 전에 가져가면 안 되므로)
- 에스크로 프로그램이 직접 전송해야 함!
하지만 프로그램은 private key가 없으므로 서명 불가!
→ PDA가 해결책
PDA는 프로그램이 “소유“하고 “서명“할 수 있는 특수 주소입니다.
PDA 생성 원리
일반 Keypair는 ed25519 타원 곡선 위에 존재하는 점(point)입니다. PDA는 의도적으로 곡선 밖에 있는 주소를 사용합니다.
ed25519 타원 곡선:
y
│ · · ·
│ · ·
│ · · ← 곡선 위 점 = 일반 Keypair (private key 존재)
│· ·
──────────────────────── x
│· ·
│ · ·
│ · ·
│ · · ·
│
│ × ← 곡선 밖 점 = PDA (private key 없음!)
find_program_address(seeds, program_id):
1. SHA256(seeds + program_id + bump) 계산
2. 결과가 곡선 위에 있으면 bump 감소 후 재시도
3. 결과가 곡선 밖이면 → 이것이 PDA!
bump는 255부터 시작해서 감소 (canonical bump = 최초 성공 값)
// PDA 생성 코드
use solana_program::pubkey::Pubkey;
// find_program_address: canonical bump를 자동으로 찾아줌
let seeds = &[
b"user-data", // 문자열 시드
user_pubkey.as_ref(), // 사용자 주소 시드
];
let (pda, bump) = Pubkey::find_program_address(seeds, &program_id);
// 반환값:
// pda: 생성된 PDA 주소 (Pubkey)
// bump: 곡선 밖으로 밀어낸 값 (0~255)
// create_program_address: bump를 직접 지정 (검증용)
let pda_verify = Pubkey::create_program_address(
&[b"user-data", user_pubkey.as_ref(), &[bump]],
&program_id,
)?;
assert_eq!(pda, pda_verify);
// TypeScript에서 PDA 찾기
import { PublicKey } from '@solana/web3.js';
const PROGRAM_ID = new PublicKey("11111111111111111111111111111111");
const userPubkey = new PublicKey("So11111111111111111111111111111111111111112");
// seeds는 Buffer 또는 Uint8Array 배열
const [pda, bump] = PublicKey.findProgramAddressSync(
[
Buffer.from("user-data"), // 문자열 시드
userPubkey.toBuffer(), // 주소 시드
],
PROGRAM_ID
);
console.log("PDA:", pda.toBase58());
console.log("Bump:", bump);
// 같은 seeds + program_id → 항상 같은 PDA (결정론적!)
// 즉, 클라이언트와 프로그램이 독립적으로 같은 주소를 계산할 수 있음
PDA의 실제 사용 패턴
패턴 1: 사용자별 데이터 계정
// 각 사용자마다 고유한 데이터 계정을 결정론적으로 생성
let seeds = &[
b"player",
user_pubkey.as_ref(),
&[bump],
];
// → 같은 user는 항상 같은 PDA 주소
// → 클라이언트가 사전에 주소 계산 가능 (DB 조회 불필요!)
패턴 2: 글로벌 상태 계정
// 프로그램 전체의 설정/상태를 저장
let seeds = &[b"global-config", &[bump]];
// → 프로그램당 하나의 고정된 설정 계정
패턴 3: 에스크로 금고
// 특정 거래의 SOL/토큰을 보관하는 금고
let seeds = &[
b"escrow",
trade_id.as_ref(),
&[bump],
];
// → 거래 ID마다 고유한 에스크로 계정
PDA Rust 코드 예제: 사용자 프로필 시스템
use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::{
account_info::{next_account_info, AccountInfo},
entrypoint,
entrypoint::ProgramResult,
msg,
program::invoke_signed,
program_error::ProgramError,
pubkey::Pubkey,
rent::Rent,
system_instruction,
sysvar::Sysvar,
};
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct UserProfile {
pub owner: Pubkey,
pub username: String,
pub score: u64,
pub bump: u8, // PDA bump 저장 (나중에 invoke_signed에 사용)
}
impl UserProfile {
pub const MAX_USERNAME_LEN: usize = 32;
pub const LEN: usize = 32 + 4 + Self::MAX_USERNAME_LEN + 8 + 1;
// owner(32) + string_len(4) + username(32) + score(8) + bump(1)
}
entrypoint!(process_instruction);
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
data: &[u8],
) -> ProgramResult {
// username 파싱 (첫 바이트가 길이, 나머지가 UTF-8)
let username_len = data[0] as usize;
let username = std::str::from_utf8(&data[1..1 + username_len])
.map_err(|_| ProgramError::InvalidInstructionData)?
.to_string();
let accounts_iter = &mut accounts.iter();
let profile_account = next_account_info(accounts_iter)?;
let user = next_account_info(accounts_iter)?;
let system_program = next_account_info(accounts_iter)?;
if !user.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// PDA 검증: 클라이언트가 올바른 PDA를 전달했는가?
let (expected_pda, bump) = Pubkey::find_program_address(
&[b"profile", user.key.as_ref()],
program_id,
);
if expected_pda != *profile_account.key {
msg!("잘못된 PDA 주소");
return Err(ProgramError::InvalidArgument);
}
// 렌트 계산
let rent = Rent::get()?;
let required_lamports = rent.minimum_balance(UserProfile::LEN);
// PDA Account 생성 (invoke_signed 사용!)
// 일반 invoke와 달리, PDA seeds를 제공하여 프로그램이 서명
invoke_signed(
&system_instruction::create_account(
user.key,
profile_account.key,
required_lamports,
UserProfile::LEN as u64,
program_id,
),
&[user.clone(), profile_account.clone(), system_program.clone()],
// signer_seeds: PDA를 "서명"하는 seeds
&[&[b"profile", user.key.as_ref(), &[bump]]],
)?;
// 프로필 데이터 저장
let profile = UserProfile {
owner: *user.key,
username,
score: 0,
bump,
};
profile.serialize(&mut *profile_account.data.borrow_mut())?;
msg!("프로필 생성 완료!");
Ok(())
}
CPI (Cross-Program Invocation)
CPI란?
CPI는 하나의 프로그램이 다른 프로그램을 호출하는 기능입니다. 이더리움의 external call 또는 NestJS에서 서비스가 다른 서비스를 호출하는 것과 유사합니다.
이더리움 external call:
─────────────────────────────
MyContract.foo() {
// 다른 컨트랙트 호출
IERC20(tokenAddress).transferFrom(from, to, amount);
}
NestJS 서비스 간 호출:
─────────────────────────────
@Injectable()
class OrderService {
constructor(private paymentService: PaymentService) {}
async createOrder() {
await this.paymentService.charge(amount); // 다른 서비스 호출
}
}
Solana CPI:
─────────────────────────────
// 내 프로그램에서 Token Program의 transfer 호출
invoke(
&token_instruction::transfer(...),
&[from_account, to_account, authority],
)?;
invoke() vs invoke_signed()
// invoke(): 일반 CPI (PDA 서명 불필요)
// 사용 케이스: user가 서명자인 경우
invoke(
&system_instruction::transfer(from_pubkey, to_pubkey, lamports),
&[from_account.clone(), to_account.clone(), system_program.clone()],
)?;
// invoke_signed(): PDA가 서명자인 CPI
// 사용 케이스: 프로그램 자신의 PDA에서 자산 이동
invoke_signed(
&system_instruction::transfer(pda_pubkey, to_pubkey, lamports),
&[pda_account.clone(), to_account.clone(), system_program.clone()],
&[&[b"vault", &[bump]]], // PDA seeds로 서명
)?;
CPI 예제 1: System Program에 CPI로 SOL 전송
// 시나리오: 내 프로그램의 PDA(금고)에서 SOL을 사용자에게 전송
use solana_program::{
account_info::{next_account_info, AccountInfo},
entrypoint::ProgramResult,
program::invoke_signed,
pubkey::Pubkey,
system_instruction,
};
pub fn withdraw_from_vault(
program_id: &Pubkey,
accounts: &[AccountInfo],
amount: u64,
) -> ProgramResult {
let accounts_iter = &mut accounts.iter();
let vault_account = next_account_info(accounts_iter)?; // PDA 금고
let recipient = next_account_info(accounts_iter)?; // 받는 사람
let system_program = next_account_info(accounts_iter)?; // System Program
// PDA 검증
let (vault_pda, bump) = Pubkey::find_program_address(
&[b"vault"],
program_id,
);
assert_eq!(vault_pda, *vault_account.key);
// PDA(금고)에서 recipient에게 SOL 전송
// vault_account는 private key가 없으므로 invoke_signed 필요
invoke_signed(
&system_instruction::transfer(
vault_account.key, // from (PDA)
recipient.key, // to
amount,
),
&[
vault_account.clone(),
recipient.clone(),
system_program.clone(),
],
// PDA의 seeds로 서명 (private key 없이도 서명 가능!)
&[&[b"vault", &[bump]]],
)?;
Ok(())
}
CPI 예제 2: Token Program에 CPI로 토큰 전송
use anchor_spl::token::{self, Token, TokenAccount, Transfer};
use anchor_lang::prelude::*;
// Anchor를 사용한 Token Program CPI
pub fn transfer_tokens<'info>(
from: &Account<'info, TokenAccount>,
to: &Account<'info, TokenAccount>,
authority: &Signer<'info>,
token_program: &Program<'info, Token>,
amount: u64,
) -> Result<()> {
let cpi_accounts = Transfer {
from: from.to_account_info(),
to: to.to_account_info(),
authority: authority.to_account_info(),
};
let cpi_ctx = CpiContext::new(
token_program.to_account_info(),
cpi_accounts,
);
token::transfer(cpi_ctx, amount)?;
Ok(())
}
// PDA가 authority인 경우 (invoke_signed 케이스)
pub fn transfer_tokens_with_pda<'info>(
from: &Account<'info, TokenAccount>,
to: &Account<'info, TokenAccount>,
pda_authority: &AccountInfo<'info>,
token_program: &Program<'info, Token>,
amount: u64,
seeds: &[&[u8]], // PDA seeds
) -> Result<()> {
let cpi_accounts = Transfer {
from: from.to_account_info(),
to: to.to_account_info(),
authority: pda_authority.clone(),
};
let cpi_ctx = CpiContext::new_with_signer(
token_program.to_account_info(),
cpi_accounts,
&[seeds], // signer_seeds
);
token::transfer(cpi_ctx, amount)?;
Ok(())
}
이더리움 external call과의 비교
이더리움 external call:
─────────────────────────────────────────────────────────────
// Re-entrancy 공격 가능!
// 악의적인 컨트랙트가 callback으로 재진입할 수 있음
contract Vulnerable {
mapping(address => uint) balances;
function withdraw() external {
uint amount = balances[msg.sender];
(bool success,) = msg.sender.call{value: amount}(""); // ← 위험!
balances[msg.sender] = 0; // 이미 늦음
}
}
Solana CPI:
─────────────────────────────────────────────────────────────
// Re-entrancy가 구조적으로 불가능!
// 이유 1: 프로그램은 자신이 owner인 계정만 수정 가능
// 이유 2: 호출 스택에서 같은 프로그램의 재진입 금지
// 이유 3: 계정 잠금(borrow) 메커니즘
invoke(
&token::transfer(...),
accounts,
)?;
// CPI 완료 후에만 다음 코드 실행
// 중간에 재진입 불가
PDA + CPI 종합 예제: 에스크로 프로그램
// 완전한 에스크로 흐름:
// 1. Alice가 SOL을 에스크로 PDA에 예치
// 2. 조건 확인 후 프로그램이 Bob에게 자동 전송
#[derive(BorshSerialize, BorshDeserialize)]
pub struct EscrowData {
pub depositor: Pubkey, // Alice
pub recipient: Pubkey, // Bob
pub amount: u64,
pub condition_met: bool,
pub bump: u8,
}
// Step 1: Alice가 에스크로 생성 및 SOL 예치
pub fn create_escrow(
program_id: &Pubkey,
accounts: &[AccountInfo],
amount: u64,
recipient: Pubkey,
) -> ProgramResult {
let accounts_iter = &mut accounts.iter();
let escrow_account = next_account_info(accounts_iter)?;
let alice = next_account_info(accounts_iter)?;
let system_program = next_account_info(accounts_iter)?;
let (escrow_pda, bump) = Pubkey::find_program_address(
&[b"escrow", alice.key.as_ref()],
program_id,
);
// PDA 생성 (System Program에 CPI)
invoke_signed(
&system_instruction::create_account(
alice.key,
escrow_account.key,
amount + rent_minimum, // 예치금 + 렌트
EscrowData::LEN as u64,
program_id,
),
&[alice.clone(), escrow_account.clone(), system_program.clone()],
&[&[b"escrow", alice.key.as_ref(), &[bump]]],
)?;
// Alice의 SOL을 에스크로로 전송 (Alice가 서명자이므로 invoke 사용)
invoke(
&system_instruction::transfer(alice.key, escrow_account.key, amount),
&[alice.clone(), escrow_account.clone(), system_program.clone()],
)?;
let data = EscrowData {
depositor: *alice.key,
recipient,
amount,
condition_met: false,
bump,
};
data.serialize(&mut *escrow_account.data.borrow_mut())?;
Ok(())
}
// Step 2: 조건 이행 후 Bob에게 SOL 전송
pub fn release_escrow(
program_id: &Pubkey,
accounts: &[AccountInfo],
) -> ProgramResult {
let accounts_iter = &mut accounts.iter();
let escrow_account = next_account_info(accounts_iter)?;
let bob = next_account_info(accounts_iter)?;
let system_program = next_account_info(accounts_iter)?;
let mut data = EscrowData::try_from_slice(&escrow_account.data.borrow())?;
// 수취인 검증
if data.recipient != *bob.key {
return Err(ProgramError::InvalidArgument);
}
// 에스크로 PDA에서 Bob에게 SOL 전송 (PDA가 서명자이므로 invoke_signed)
invoke_signed(
&system_instruction::transfer(
escrow_account.key,
bob.key,
data.amount,
),
&[escrow_account.clone(), bob.clone(), system_program.clone()],
// PDA seeds로 서명! private key 없이도 가능
&[&[b"escrow", data.depositor.as_ref(), &[data.bump]]],
)?;
Ok(())
}
PDA와 CPI 핵심 정리
PDA 요약:
┌────────────────────────────────────────────────────┐
│ PDA = seeds + program_id → 곡선 밖의 결정론적 주소 │
│ │
│ 특징: │
│ • private key 없음 → 탈취 불가 │
│ • 프로그램만 서명 가능 (invoke_signed) │
│ • 결정론적 → 클라이언트가 미리 계산 가능 │
│ • 사용자별 데이터 계정, 금고, 에스크로 등에 활용 │
└────────────────────────────────────────────────────┘
CPI 요약:
┌────────────────────────────────────────────────────┐
│ invoke() = 일반 호출 (user가 서명자) │
│ invoke_signed() = PDA가 서명자인 호출 │
│ │
│ 이더리움 대비 장점: │
│ • Re-entrancy 공격 구조적 불가 │
│ • 계정 접근 권한이 명확 │
│ • Atomic 실행 보장 │
└────────────────────────────────────────────────────┘
다음 장부터는 Anchor 프레임워크를 배워 이 모든 보일러플레이트를 대폭 줄이겠습니다.
Anchor: Solana의 개발 프레임워크
Anchor란 무엇인가?
Anchor는 Solana 프로그램 개발을 위한 프레임워크입니다. Solana 생태계에서 Anchor의 위치를 다른 도구들과 비교하면 이렇습니다:
이더리움 개발 스택:
Solidity 언어 + Foundry/Hardhat 프레임워크
Solana 개발 스택:
Rust 언어 + Anchor 프레임워크
Node.js 백엔드 스택:
TypeScript 언어 + NestJS 프레임워크
Anchor를 “Solana의 NestJS“라고 부르는 이유는, NestJS가 Express.js의 보일러플레이트를 데코레이터와 의존성 주입으로 줄여주듯이, Anchor가 Native Solana 프로그램의 반복적이고 위험한 코드를 매크로(macro)로 대폭 줄여주기 때문입니다.
왜 Anchor를 써야 하는가?
앞 장에서 Native Rust로 카운터 프로그램을 작성했을 때를 기억하시나요? 다음과 같은 코드를 직접 작성해야 했습니다:
// Native Rust: 개발자가 직접 해야 할 것들
// 1. 계정 파싱 (보일러플레이트)
let accounts_iter = &mut accounts.iter();
let counter_account = next_account_info(accounts_iter)?;
let authority = next_account_info(accounts_iter)?;
let system_program = next_account_info(accounts_iter)?;
// 2. 서명자 검증 (보안 취약점 가능성)
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// 3. 계정 owner 검증
if counter_account.owner != program_id {
return Err(ProgramError::IllegalOwner);
}
// 4. 데이터 역직렬화
let mut counter_data =
CounterAccount::try_from_slice(&counter_account.data.borrow())?;
// 5. 비즈니스 로직
counter_data.count += amount;
// 6. 데이터 재직렬화
counter_data.serialize(&mut *counter_account.data.borrow_mut())?;
// 7. Instruction 디스패치 (열거형 매칭)
match instruction {
CounterInstruction::Initialize => initialize(counter_account),
CounterInstruction::Increment { amount } => increment(counter_account, amount),
CounterInstruction::Reset => reset(counter_account),
}
이 코드에는 두 가지 문제가 있습니다:
- 반복 작업: 모든 함수마다 동일한 검증 코드를 작성해야 함
- 보안 취약점: 검증 코드를 실수로 빠뜨리면 치명적인 버그 발생
Anchor는 이를 매크로로 해결합니다:
// Anchor: 같은 기능, 훨씬 적은 코드
#[program]
pub mod counter {
use super::*;
pub fn increment(ctx: Context<Increment>, amount: u64) -> Result<()> {
// 비즈니스 로직만!
ctx.accounts.counter.count += amount;
Ok(())
}
}
#[derive(Accounts)]
pub struct Increment<'info> {
#[account(mut, has_one = authority)] // owner, writable, authority 검증 자동!
pub counter: Account<'info, CounterAccount>,
pub authority: Signer<'info>, // 서명자 검증 자동!
}
Native vs Anchor 코드 비교
같은 “카운터 초기화” 기능을 두 방식으로 구현한 코드를 비교합니다:
Native Rust (약 80줄)
fn process_initialize(
program_id: &Pubkey,
accounts: &[AccountInfo],
) -> ProgramResult {
let accounts_iter = &mut accounts.iter();
let counter_account = next_account_info(accounts_iter)?;
let authority = next_account_info(accounts_iter)?;
let system_program = next_account_info(accounts_iter)?;
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
if counter_account.data_len() > 0 {
return Err(ProgramError::AccountAlreadyInitialized);
}
let rent = Rent::get()?;
let required_lamports = rent.minimum_balance(CounterAccount::LEN);
invoke(
&system_instruction::create_account(
authority.key,
counter_account.key,
required_lamports,
CounterAccount::LEN as u64,
program_id,
),
&[authority.clone(), counter_account.clone(), system_program.clone()],
)?;
let counter_data = CounterAccount {
count: 0,
authority: *authority.key,
is_initialized: true,
};
counter_data.serialize(&mut *counter_account.data.borrow_mut())?;
Ok(())
}
Anchor (약 20줄)
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.count = 0;
counter.authority = ctx.accounts.authority.key();
Ok(())
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init, // 계정 생성 자동
payer = authority, // 비용 지불자
space = 8 + CounterAccount::LEN // 공간 할당
)]
pub counter: Account<'info, CounterAccount>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
}
코드 양이 75% 감소하고, 보안 검증은 오히려 더 철저합니다.
Anchor가 자동으로 처리하는 것들
Anchor 매크로가 생성하는 코드:
┌─────────────────────────────────────────────────────────┐
│ #[derive(Accounts)] 가 자동 생성하는 것: │
│ ✓ 계정 파싱 (next_account_info 반복 제거) │
│ ✓ Account discriminator 검증 (8바이트 타입 식별자) │
│ ✓ owner 프로그램 검증 │
│ ✓ 서명자 검증 (Signer<'info>) │
│ ✓ writable 검증 (#[account(mut)]) │
│ ✓ 초기화 여부 검증 │
│ ✓ PDA 검증 (#[account(seeds, bump)]) │
│ ✓ 관계 검증 (#[account(has_one)]) │
│ │
│ #[program] 이 자동 생성하는 것: │
│ ✓ entrypoint 등록 │
│ ✓ Instruction 디스패치 (8바이트 discriminator 기반) │
│ ✓ Context 구조체 주입 │
│ ✓ 에러 처리 및 변환 │
│ │
│ #[account] 가 자동 생성하는 것: │
│ ✓ Borsh 직렬화/역직렬화 │
│ ✓ Account discriminator 추가 (앞 8바이트) │
│ ✓ space 계산 헬퍼 │
└─────────────────────────────────────────────────────────┘
Anchor 설치 방법
AVM (Anchor Version Manager) 사용
Node.js 개발자에게 친숙한 nvm(Node Version Manager)과 동일한 개념입니다:
# AVM 설치
cargo install --git https://github.com/coral-xyz/anchor avm --locked
# 설치 확인
avm --version
# 최신 Anchor 버전 설치
avm install latest
avm use latest
# 특정 버전 설치 및 사용
avm install 0.30.1
avm use 0.30.1
# 현재 버전 확인
anchor --version
# anchor-cli 0.30.1
# 설치된 버전 목록
avm list
전제 조건 확인
# Rust 설치 (없으면)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup update
# Solana CLI 설치 (없으면)
sh -c "$(curl -sSfL https://release.solana.com/stable/install)"
# Node.js 18+ (이미 설치되어 있을 것)
node --version
# Yarn (Anchor 프로젝트 기본 패키지 매니저)
npm install -g yarn
# 모든 도구 버전 확인
rustc --version # rustc 1.75+
solana --version # solana-cli 1.18+
anchor --version # anchor-cli 0.30+
node --version # v18+
Anchor IDL (Interface Definition Language)
Anchor의 숨은 강력한 기능 중 하나는 IDL 자동 생성입니다. 이더리움의 ABI와 동일한 개념입니다:
// Anchor가 자동 생성하는 IDL (target/idl/counter.json)
{
"version": "0.1.0",
"name": "counter",
"instructions": [
{
"name": "initialize",
"accounts": [
{ "name": "counter", "isMut": true, "isSigner": false },
{ "name": "authority", "isMut": true, "isSigner": true },
{ "name": "systemProgram", "isMut": false, "isSigner": false }
],
"args": []
},
{
"name": "increment",
"accounts": [
{ "name": "counter", "isMut": true, "isSigner": false },
{ "name": "authority", "isMut": false, "isSigner": true }
],
"args": [
{ "name": "amount", "type": "u64" }
]
}
],
"accounts": [
{
"name": "CounterAccount",
"type": {
"kind": "struct",
"fields": [
{ "name": "count", "type": "u64" },
{ "name": "authority", "type": "publicKey" }
]
}
}
]
}
이 IDL을 기반으로 TypeScript 클라이언트가 자동으로 타입 안전한 함수를 제공합니다:
// IDL 기반 자동 생성 타입 (타입 안전!)
await program.methods
.increment(new BN(5)) // amount: u64
.accounts({
counter: counterPubkey,
authority: wallet.publicKey,
})
.rpc();
// 컴파일 타임에 타입 검사:
// - 인자 타입 검사 (u64여야 함)
// - 계정 이름 검사 (counter, authority여야 함)
Anchor 생태계
Anchor 생태계:
┌──────────────────────────────────────────────────────┐
│ anchor-lang → Rust 매크로 및 트레이트 │
│ anchor-spl → SPL Token, Token-2022 통합 │
│ anchor-client → Rust 클라이언트 │
│ @coral-xyz/anchor → TypeScript 클라이언트 (핵심!) │
└──────────────────────────────────────────────────────┘
주요 버전:
0.29.x → 현재 많은 프로젝트에서 사용
0.30.x → 최신 안정 버전 (2024)
(버전 간 API 변경이 있으므로 Anchor.toml 버전 확인 중요)
다음 장에서는 anchor init으로 프로젝트를 생성하고 전체 디렉토리 구조를 살펴봅니다.
Anchor 프로젝트 구조
anchor init으로 프로젝트 생성
# 새 Anchor 프로젝트 생성
anchor init my-project
cd my-project
# 또는 JavaScript 테스트 대신 TypeScript로 (기본값)
anchor init my-project --javascript # JS 사용 시 (비권장)
생성 직후 출력:
yarn install v1.22.19
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
Done in 12.34s.
my-project initialized
디렉토리 구조 전체 해설
my-project/
├── Anchor.toml ← 프로젝트 설정 파일 (NestJS의 nest-cli.json)
├── Cargo.toml ← Rust 워크스페이스 설정
├── package.json ← Node.js 의존성 (테스트용)
├── tsconfig.json ← TypeScript 설정
├── .gitignore
│
├── programs/ ← Solana 프로그램 (스마트 컨트랙트) 디렉토리
│ └── my-project/
│ ├── Cargo.toml ← 프로그램별 Rust 의존성
│ └── src/
│ └── lib.rs ← 프로그램 소스코드 (핵심!)
│
├── tests/ ← TypeScript 테스트 (Mocha 기반)
│ └── my-project.ts
│
├── migrations/ ← 배포 스크립트
│ └── deploy.ts
│
├── app/ ← 프론트엔드 앱 (선택사항, 기본 비어있음)
│
└── target/ ← 빌드 결과물 (git ignore)
├── deploy/
│ └── my_project.so ← 컴파일된 프로그램 (BPF 바이트코드)
├── idl/
│ └── my_project.json ← 자동 생성 IDL
└── types/
└── my_project.ts ← 자동 생성 TypeScript 타입
Anchor.toml 상세 설명
# Anchor.toml - NestJS의 nest-cli.json + .env 역할
[features]
resolution = true # IDL 계정 해석 활성화
skip-lint = false # Anchor 린트 규칙 적용
[programs.localnet]
# 프로그램 이름 = 프로그램 ID (배포 주소)
my_project = "Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS"
[programs.devnet]
my_project = "실제_배포된_프로그램_ID"
[registry]
url = "https://api.apr.dev"
[provider]
# 사용할 클러스터
cluster = "Localnet" # Localnet | Devnet | Mainnet
# 서명에 사용할 키페어 파일 경로
wallet = "~/.config/solana/id.json"
[scripts]
# anchor test 실행 시 호출되는 스크립트
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
클러스터별 설정 전환
# 현재 Solana CLI 클러스터 확인
solana config get
# Localnet으로 전환 (개발 중)
solana config set --url localhost
# Anchor.toml의 cluster = "Localnet"과 일치해야 함
# Devnet으로 전환 (테스트 배포)
solana config set --url devnet
# Anchor.toml의 cluster = "Devnet"으로 변경
NestJS vs Anchor 구조 상세 비교
Node.js 백엔드 개발자에게 친숙한 NestJS 패턴과 Anchor를 1:1 대응으로 설명합니다.
1. 모듈 선언: @Module vs declare_id!
// NestJS: 앱 모듈 선언
@Module({
imports: [UsersModule, AuthModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
// Anchor: 프로그램 ID 선언 (프로그램의 "신원증명")
use anchor_lang::prelude::*;
// 이 프로그램이 배포된 주소 (네트워크의 고유 식별자)
// anchor build 후 target/deploy/my_project-keypair.json에서 자동 생성
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
declare_id!는 프로그램이 자신의 주소를 알고 있게 하여, 내부에서 crate::ID로 참조할 수 있게 합니다. PDA 생성 시 필수입니다.
2. 컨트롤러: @Controller vs #[program]
// NestJS: HTTP 요청을 받는 컨트롤러
@Controller('users')
export class UserController {
@Post()
create(@Body() dto: CreateUserDto) {
return this.userService.create(dto);
}
@Put(':id')
update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
return this.userService.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.userService.remove(id);
}
}
// Anchor: Solana Instruction을 받는 프로그램 모듈
#[program]
pub mod my_project {
use super::*;
// @Post() create() 에 해당
pub fn create_user(ctx: Context<CreateUser>, name: String) -> Result<()> {
// ctx.accounts.* 로 계정 접근
let user = &mut ctx.accounts.user;
user.name = name;
user.owner = ctx.accounts.signer.key();
Ok(())
}
// @Put(':id') update() 에 해당
pub fn update_user(ctx: Context<UpdateUser>, new_name: String) -> Result<()> {
ctx.accounts.user.name = new_name;
Ok(())
}
// @Delete(':id') remove() 에 해당 (계정 닫기)
pub fn delete_user(ctx: Context<DeleteUser>) -> Result<()> {
// #[account(close = signer)] 제약조건이 자동으로 처리
Ok(())
}
}
3. DTO 검증: class-validator vs #[derive(Accounts)]
// NestJS: 요청 데이터 검증
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
@MaxLength(32)
name: string;
}
// 파이프라인이 자동으로 검증 실행
// ValidationPipe가 요청 전에 DTO 검증
// Anchor: 트랜잭션 계정 검증
#[derive(Accounts)]
pub struct CreateUser<'info> {
#[account(
init, // 새 계정 생성
payer = signer, // 비용 지불자
space = 8 + User::LEN, // 할당 공간
)]
pub user: Account<'info, User>, // 역직렬화 + owner 검증 자동
#[account(mut)] // 잔액 변경 허용
pub signer: Signer<'info>, // 서명자 검증 자동
pub system_program: Program<'info, System>, // System Program 검증 자동
}
// Anchor가 트랜잭션 실행 전에 모든 검증 자동 실행
4. 엔티티: TypeORM Entity vs #[account]
// NestJS + TypeORM: 데이터베이스 엔티티
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 32 })
name: string;
@Column({ type: 'bigint' })
score: number;
@ManyToOne(() => Organization)
org: Organization;
}
// Anchor: Solana Account에 저장될 데이터 구조
#[account] // ← @Entity()에 해당
pub struct User {
pub owner: Pubkey, // 32 bytes - 소유자 주소 (FK처럼 참조)
pub name: String, // 4 + len bytes
pub score: u64, // 8 bytes
pub bump: u8, // 1 byte - PDA bump
}
impl User {
pub const LEN: usize =
32 + // owner: Pubkey
4 + 32 + // name: String (4 = length prefix, 32 = max chars)
8 + // score: u64
1; // bump: u8
// 총 77 bytes
// + 8 bytes (Anchor discriminator) = 85 bytes 실제 할당
}
5. 서비스/비즈니스 로직: @Injectable Service vs 프로그램 함수
// NestJS: 비즈니스 로직을 담당하는 서비스
@Injectable()
export class UserService {
async addScore(userId: string, points: number): Promise<void> {
const user = await this.userRepo.findOne(userId);
if (!user) throw new NotFoundException();
if (user.score + points > MAX_SCORE) throw new BadRequestException();
user.score += points;
await this.userRepo.save(user);
}
}
// Anchor: 같은 역할의 프로그램 함수
pub fn add_score(ctx: Context<AddScore>, points: u64) -> Result<()> {
let user = &mut ctx.accounts.user;
// 검증 로직
require!(
user.score.checked_add(points).is_some(),
MyError::ScoreOverflow
);
// 비즈니스 로직
user.score = user.score.checked_add(points).unwrap();
// 이벤트 발행 (이더리움의 emit과 동일)
emit!(ScoreAdded {
user: user.owner,
points,
new_score: user.score,
});
Ok(())
}
핵심 매크로 상세 설명
declare_id!
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
// 이 매크로가 하는 일:
// 1. ID 상수 생성: pub const ID: Pubkey = Pubkey::new_from_array([...])
// 2. check_id() 함수 생성: 프로그램 ID 검증용
// 3. 런타임이 올바른 프로그램을 호출했는지 검증
// PDA 생성 시 활용:
let (pda, bump) = Pubkey::find_program_address(
&[b"seed"],
&crate::ID, // declare_id!로 선언된 프로그램 ID 참조
);
#[program]
#[program]
pub mod my_program {
use super::*;
// 이 매크로가 하는 일:
// 1. entrypoint 자동 등록
// 2. 함수 이름 기반 8바이트 discriminator 생성
// sha256("global:function_name")[..8]
// 3. Instruction 디스패치 자동화
// 4. Context<T> 자동 주입
pub fn my_instruction(ctx: Context<MyAccounts>, arg: u64) -> Result<()> {
// Result<()>는 Anchor의 에러 타입
// Ok(()) 반환 시 성공, Err(e) 반환 시 트랜잭션 롤백
Ok(())
}
}
// 생성되는 discriminator 예시:
// "global:initialize" → sha256 → 앞 8바이트
// [175, 175, 109, 31, 13, 152, 155, 237]
#[derive(Accounts)]
#[derive(Accounts)]
#[instruction(amount: u64)] // Instruction 인자에 접근 필요할 때
pub struct Transfer<'info> {
// 이 매크로가 하는 일:
// 1. 각 필드를 순서대로 AccountInfo에서 파싱
// 2. 타입에 맞는 검증 실행 (Account<T>, Signer, Program 등)
// 3. 제약조건(constraint) 검증
#[account(
mut,
has_one = owner, // from.owner == owner.key()
constraint = from.amount >= amount @ MyError::InsufficientFunds
)]
pub from: Account<'info, Wallet>,
#[account(mut)]
pub to: Account<'info, Wallet>,
pub owner: Signer<'info>,
}
#[account]
#[account]
pub struct GameState {
pub admin: Pubkey,
pub total_players: u32,
pub prize_pool: u64,
pub is_active: bool,
pub name: String,
}
// #[account] 매크로가 하는 일:
// 1. BorshSerialize, BorshDeserialize 자동 구현
// 2. AccountSerialize, AccountDeserialize 구현
// → 앞 8바이트 discriminator 자동 추가/검증
// 3. Owner 트레이트 구현
// → 이 구조체는 현재 프로그램만 소유할 수 있음
// discriminator = sha256("account:GameState")[..8]
// 이를 통해 타입 안전성 보장: 잘못된 타입의 계정 전달 시 에러
프로젝트 초기 파일 내용
programs/my-project/src/lib.rs (초기 템플릿)
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod my_project {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
msg!("Greetings from: {:?}", ctx.program_id);
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize {}
programs/my-project/Cargo.toml
[package]
name = "my-project"
version = "0.1.0"
description = "Created with Anchor"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
name = "my_project"
[features]
default = []
cpi = [] # 다른 프로그램에서 이 프로그램을 CPI로 호출할 때 활성화
no-entrypoint = [] # 라이브러리로만 사용할 때
no-idl = []
no-log-ix-name = []
is-upgrade-authority-signer = []
[dependencies]
anchor-lang = "0.30.1"
# SPL Token 사용 시:
# anchor-spl = "0.30.1"
tests/my-project.ts (초기 템플릿)
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { MyProject } from "../target/types/my_project";
describe("my-project", () => {
// 테스트 환경 설정
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.MyProject as Program<MyProject>;
it("Is initialized!", async () => {
const tx = await program.methods.initialize().rpc();
console.log("Transaction signature", tx);
});
});
빌드 및 배포 명령어
# 빌드 (Rust → BPF 바이트코드)
anchor build
# 결과: target/deploy/my_project.so
# 결과: target/idl/my_project.json
# 결과: target/types/my_project.ts
# 프로그램 ID 확인 및 업데이트
anchor keys list
# my_project: Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS
# Anchor.toml과 lib.rs의 ID가 다르면 동기화
anchor keys sync
# 로컬 테스트 검증자 실행 (별도 터미널)
solana-test-validator
# 로컬에 배포
anchor deploy
# 테스트 실행 (로컬 검증자 자동 시작 + 배포 + 테스트)
anchor test
# Devnet에 배포
anchor deploy --provider.cluster devnet
전체 흐름 한눈에 보기
개발 워크플로:
[lib.rs 작성]
│
▼
[anchor build]
│
├─ target/deploy/my_project.so (BPF 바이트코드)
├─ target/idl/my_project.json (ABI)
└─ target/types/my_project.ts (TypeScript 타입)
│
▼
[anchor test]
│
├─ 로컬 검증자 시작
├─ 프로그램 배포
└─ tests/*.ts 실행 (Mocha + Chai)
│
└─ program.methods.xxx().accounts({}).rpc()
→ 트랜잭션 전송 → 프로그램 실행 → 결과 확인
다음 장에서는 #[derive(Accounts)]의 계정 타입과 제약조건을 상세히 알아봅니다.
Account 검증: #[derive(Accounts)] 완전 가이드
왜 Account 검증이 중요한가?
Solana 프로그램 보안의 핵심은 계정 검증입니다. 악의적인 사용자는 다음과 같은 공격을 시도할 수 있습니다:
공격 시나리오:
1. 타입 혼동 공격: 다른 구조체로 초기화된 계정을 전달
2. 권한 우회: 자신이 서명하지 않은 트랜잭션으로 타인 계정 수정
3. 가짜 프로그램: Token Program 대신 악의적인 프로그램 주소 전달
4. 재사용 공격: 이미 닫힌 계정을 다시 사용
Anchor의 #[derive(Accounts)]는 이런 공격들을 컴파일 타임과 런타임에 차단합니다.
Account 타입 종류
1. Account<’info, T> - 역직렬화된 계정
가장 많이 사용하는 타입입니다. Anchor가 자동으로:
- Account의
data를T타입으로 역직렬화 owner가 현재 프로그램인지 검증- discriminator(앞 8바이트)가
T타입과 일치하는지 검증
#[derive(Accounts)]
pub struct UpdateProfile<'info> {
// UserProfile 타입으로 자동 역직렬화
// owner = 현재 프로그램인지 자동 검증
// discriminator 타입 검증 자동
pub profile: Account<'info, UserProfile>,
}
// 사용:
pub fn update_profile(ctx: Context<UpdateProfile>, new_name: String) -> Result<()> {
// .profile로 직접 접근 (이미 역직렬화됨)
ctx.accounts.profile.name = new_name;
// mut가 없으면 컴파일 에러! → #[account(mut)] 필요
Ok(())
}
// 타입 파라미터 T의 조건:
// T는 #[account] 매크로가 적용된 구조체여야 함
#[account]
pub struct UserProfile {
pub owner: Pubkey,
pub name: String,
pub score: u64,
}
2. Signer<’info> - 서명자 검증
트랜잭션에 서명한 계정을 나타냅니다.
#[derive(Accounts)]
pub struct MintTokens<'info> {
// 이 계정이 트랜잭션에 서명했는지 자동 검증
// 서명 없으면 → MissingRequiredSignature 에러
pub authority: Signer<'info>,
}
// Signer vs &Signer:
// Signer<'info> → AccountInfo 데이터 접근 가능
// &Signer<'info> → 주소만 필요할 때 (더 효율적)
// 클라이언트에서 서명자 지정
await program.methods
.mintTokens(new BN(100))
.accounts({
authority: wallet.publicKey, // ← 이 키페어로 서명해야 함
})
.signers([wallet]) // ← 실제 서명 키페어
.rpc();
3. Program<’info, T> - 프로그램 계정 검증
다른 프로그램의 ID를 검증합니다. CPI에서 필수입니다.
use anchor_spl::token::Token;
use anchor_lang::system_program::System;
#[derive(Accounts)]
pub struct CreateTokenAccount<'info> {
// System Program 검증
// 주소가 11111111...인지 자동 확인
pub system_program: Program<'info, System>,
// Token Program 검증
// 주소가 TokenkegQfe...인지 자동 확인
pub token_program: Program<'info, Token>,
}
// 악의적인 사용자가 가짜 프로그램 주소를 전달해도 차단!
4. SystemAccount<’info> - System Program 소유 계정
#[derive(Accounts)]
pub struct Initialize<'info> {
// owner가 System Program인지 검증
// 일반 SOL 지갑 계정에 사용
pub payer: SystemAccount<'info>,
}
5. UncheckedAccount<’info> - 검증 없는 계정 (주의!)
#[derive(Accounts)]
pub struct Dangerous<'info> {
/// CHECK: 이 계정은 오직 주소만 사용하고 데이터는 읽지 않음
/// 주소가 my_config_account와 일치하는지만 확인
#[account(address = MY_CONFIG_PUBKEY)]
pub config: UncheckedAccount<'info>,
}
// UncheckedAccount를 사용하면 Anchor가 주석을 강제함:
// /// CHECK: 이유를 설명해야 컴파일됨
// 주석 없으면 → 컴파일 에러: "doc comment required"
사용 시나리오:
- 외부 프로그램의 계정을 주소로만 참조할 때
- 계정 데이터 구조를 직접 파싱해야 할 때
- 성능 최적화가 필요한 특수 케이스
계정 제약조건 (Account Constraints)
#[account(init, payer, space)] - 새 계정 생성
#[derive(Accounts)]
pub struct CreatePost<'info> {
#[account(
init, // 새 Account 생성 (없으면 에러)
payer = author, // 렌트 비용을 author가 지불
space = 8 + Post::LEN, // 할당할 바이트 수 (8 = discriminator)
)]
pub post: Account<'info, Post>,
#[account(mut)] // author 잔액 감소하므로 mut 필요
pub author: Signer<'info>,
pub system_program: Program<'info, System>,
}
// init이 자동으로 처리:
// 1. System Program에 CPI → 새 Account 생성
// 2. owner를 현재 프로그램으로 설정
// 3. discriminator 작성
// 4. rent 면제 lamports 이체
#[account(init_if_needed)] - 없으면 생성, 있으면 사용
#[derive(Accounts)]
pub struct GetOrCreateProfile<'info> {
#[account(
init_if_needed, // 없으면 생성, 있으면 그냥 로드
payer = user,
space = 8 + UserProfile::LEN,
seeds = [b"profile", user.key().as_ref()],
bump,
)]
pub profile: Account<'info, UserProfile>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
// 주의: init_if_needed는 Cargo.toml에 feature 추가 필요
// anchor-lang = { version = "0.30.1", features = ["init-if-needed"] }
#[account(mut)] - 수정 가능 계정
#[derive(Accounts)]
pub struct UpdateScore<'info> {
#[account(mut)] // data 또는 lamports를 수정할 때 필수
pub user_data: Account<'info, UserData>,
// mut 없이 수정하면?
// → 런타임에서 "writable" 플래그 없음 에러
// → 또는 Anchor가 컴파일 타임에 경고
}
#[account(has_one)] - 관계 검증
#[account]
pub struct Post {
pub author: Pubkey, // 작성자 주소 저장
pub content: String,
}
#[derive(Accounts)]
pub struct DeletePost<'info> {
#[account(
mut,
has_one = author, // post.author == author.key() 자동 검증!
close = author, // 계정 닫고 lamports를 author에게 반환
)]
pub post: Account<'info, Post>,
pub author: Signer<'info>, // post.author와 같아야 함
}
// has_one 없이 구현하면:
// if post.author != ctx.accounts.author.key() {
// return Err(MyError::Unauthorized);
// }
// → has_one이 이 코드를 자동으로 생성
#[account(constraint)] - 커스텀 제약조건
#[derive(Accounts)]
#[instruction(amount: u64)] // Instruction 인자 접근 필요 시
pub struct Transfer<'info> {
#[account(
mut,
has_one = owner,
// 커스텀 제약조건: 잔액 충분한지 확인
constraint = from.balance >= amount @ MyError::InsufficientBalance,
// @ MyError::... 로 에러 타입 지정 가능
)]
pub from: Account<'info, Wallet>,
#[account(mut)]
pub to: Account<'info, Wallet>,
pub owner: Signer<'info>,
}
#[account(seeds, bump)] - PDA 검증
#[derive(Accounts)]
pub struct UpdateUserData<'info> {
#[account(
mut,
seeds = [b"user-data", user.key().as_ref()], // PDA seeds
bump, // bump를 자동으로 찾아서 검증
// bump = user_data.bump 처럼 저장된 bump 사용 가능
)]
pub user_data: Account<'info, UserData>,
pub user: Signer<'info>,
}
// seeds + bump가 하는 일:
// 1. find_program_address(seeds, program_id) 실행
// 2. 계산된 PDA == user_data.key() 검증
// 3. 불일치 시 → ConstraintSeeds 에러
// PDA 생성 시 (init + seeds):
#[derive(Accounts)]
pub struct CreateUserData<'info> {
#[account(
init,
payer = user,
space = 8 + UserData::LEN,
seeds = [b"user-data", user.key().as_ref()],
bump,
)]
pub user_data: Account<'info, UserData>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[account(close)] - 계정 닫기 (렌트 회수)
#[derive(Accounts)]
pub struct CloseAccount<'info> {
#[account(
mut,
has_one = owner,
close = owner, // 계정을 닫고 lamports를 owner에게 반환
)]
pub data_account: Account<'info, MyData>,
#[account(mut)] // lamports를 받으므로 mut 필요
pub owner: Signer<'info>,
}
// close가 자동으로 처리:
// 1. data_account.lamports → owner.lamports로 이전
// 2. data_account.data → 0으로 초기화
// 3. data_account.owner → System Program으로 변경
// → 계정이 완전히 삭제됨
// 주의: "closing attack" 방지를 위해
// Anchor가 자동으로 discriminator를 CLOSED_ACCOUNT_DISCRIMINATOR로 설정
#[account(address)] - 특정 주소 검증
use anchor_lang::solana_program::sysvar::clock::ID as CLOCK_ID;
#[derive(Accounts)]
pub struct CheckTime<'info> {
// Clock sysvar의 정확한 주소인지 검증
#[account(address = CLOCK_ID)]
pub clock: AccountInfo<'info>,
// 또는 상수 주소 검증
#[account(address = MY_TREASURY_PUBKEY)]
pub treasury: SystemAccount<'info>,
}
Space 계산 방법
Anchor 계정의 공간을 계산하는 것은 중요한 기술입니다:
#[account]
pub struct MemoAccount {
pub author: Pubkey, // 32 bytes
pub content: String, // 4 + max_len bytes
pub timestamp: i64, // 8 bytes
pub likes: u32, // 4 bytes
pub is_public: bool, // 1 byte
pub tags: Vec<String>, // 4 + (4 + max_tag_len) * max_tags bytes
pub bump: u8, // 1 byte
}
impl MemoAccount {
pub const MAX_CONTENT_LEN: usize = 280; // 트위터처럼 280자
pub const MAX_TAG_LEN: usize = 20;
pub const MAX_TAGS: usize = 5;
pub const LEN: usize =
32 + // author: Pubkey
4 + Self::MAX_CONTENT_LEN + // content: String
8 + // timestamp: i64
4 + // likes: u32
1 + // is_public: bool
4 + (4 + Self::MAX_TAG_LEN) * Self::MAX_TAGS + // tags: Vec<String>
1; // bump: u8
// = 32 + 284 + 8 + 4 + 1 + 124 + 1 = 454 bytes
}
// 계정 생성 시:
// space = 8 + MemoAccount::LEN (8 = Anchor discriminator)
// space = 8 + 454 = 462 bytes
Rust 타입별 Borsh 크기 참조표
타입 크기 (bytes)
─────────────────────────────────────────────
bool 1
u8 / i8 1
u16 / i16 2
u32 / i32 4
u64 / i64 8
u128 / i128 16
f32 4
f64 8
Pubkey 32
String 4 (length) + 문자열 바이트 수
Vec<T> 4 (length) + T의 크기 × 요소 수
Option<T> 1 (Some/None) + T의 크기 (Some일 때)
[T; N] T의 크기 × N
전체 예제: 메모 프로그램
생성, 업데이트, 삭제 기능을 갖춘 완전한 메모 프로그램입니다.
use anchor_lang::prelude::*;
declare_id!("Memo1111111111111111111111111111111111111111");
// ============================================================
// 에러 정의
// ============================================================
#[error_code]
pub enum MemoError {
#[msg("메모 내용이 너무 깁니다 (최대 280자)")]
ContentTooLong,
#[msg("빈 메모는 생성할 수 없습니다")]
EmptyContent,
#[msg("권한이 없습니다")]
Unauthorized,
}
// ============================================================
// 이벤트 정의
// ============================================================
#[event]
pub struct MemoCreated {
pub author: Pubkey,
pub memo_id: Pubkey,
pub timestamp: i64,
}
#[event]
pub struct MemoUpdated {
pub memo_id: Pubkey,
pub timestamp: i64,
}
// ============================================================
// 계정 구조체
// ============================================================
#[account]
pub struct Memo {
pub author: Pubkey, // 32
pub content: String, // 4 + 280
pub created_at: i64, // 8
pub updated_at: i64, // 8
pub bump: u8, // 1
}
impl Memo {
pub const MAX_CONTENT_LEN: usize = 280;
pub const LEN: usize = 32 + 4 + Self::MAX_CONTENT_LEN + 8 + 8 + 1;
}
// ============================================================
// 프로그램
// ============================================================
#[program]
pub mod memo_program {
use super::*;
/// 새 메모 생성
pub fn create_memo(ctx: Context<CreateMemo>, content: String) -> Result<()> {
// 입력 검증
require!(!content.is_empty(), MemoError::EmptyContent);
require!(
content.len() <= Memo::MAX_CONTENT_LEN,
MemoError::ContentTooLong
);
let clock = Clock::get()?;
let memo = &mut ctx.accounts.memo;
memo.author = ctx.accounts.author.key();
memo.content = content;
memo.created_at = clock.unix_timestamp;
memo.updated_at = clock.unix_timestamp;
memo.bump = ctx.bumps.memo; // Anchor가 자동으로 bump 제공
emit!(MemoCreated {
author: memo.author,
memo_id: ctx.accounts.memo.key(),
timestamp: clock.unix_timestamp,
});
msg!("메모 생성: {}", ctx.accounts.memo.key());
Ok(())
}
/// 메모 내용 업데이트
pub fn update_memo(ctx: Context<UpdateMemo>, new_content: String) -> Result<()> {
require!(!new_content.is_empty(), MemoError::EmptyContent);
require!(
new_content.len() <= Memo::MAX_CONTENT_LEN,
MemoError::ContentTooLong
);
let clock = Clock::get()?;
let memo = &mut ctx.accounts.memo;
memo.content = new_content;
memo.updated_at = clock.unix_timestamp;
emit!(MemoUpdated {
memo_id: ctx.accounts.memo.key(),
timestamp: clock.unix_timestamp,
});
Ok(())
}
/// 메모 삭제 (렌트 반환)
pub fn delete_memo(_ctx: Context<DeleteMemo>) -> Result<()> {
// close = author 제약조건이 자동으로 처리
msg!("메모 삭제 완료");
Ok(())
}
}
// ============================================================
// 계정 검증 구조체
// ============================================================
#[derive(Accounts)]
#[instruction(content: String)] // content로 seeds 접근 가능 (여기선 미사용)
pub struct CreateMemo<'info> {
#[account(
init,
payer = author,
space = 8 + Memo::LEN,
// 사용자당 하나의 메모: author 주소로 PDA 생성
// 실제로는 메모 ID(랜덤 키페어)를 사용할 수도 있음
seeds = [b"memo", author.key().as_ref()],
bump,
)]
pub memo: Account<'info, Memo>,
#[account(mut)]
pub author: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct UpdateMemo<'info> {
#[account(
mut,
seeds = [b"memo", author.key().as_ref()],
bump = memo.bump, // 저장된 bump 사용 (더 효율적)
has_one = author @ MemoError::Unauthorized, // 커스텀 에러 지정
)]
pub memo: Account<'info, Memo>,
pub author: Signer<'info>,
}
#[derive(Accounts)]
pub struct DeleteMemo<'info> {
#[account(
mut,
seeds = [b"memo", author.key().as_ref()],
bump = memo.bump,
has_one = author @ MemoError::Unauthorized,
close = author, // 계정 닫고 lamports를 author에게 반환
)]
pub memo: Account<'info, Memo>,
#[account(mut)] // lamports를 받으므로 mut
pub author: Signer<'info>,
}
제약조건 빠른 참조
#[account(init)] 새 계정 생성
#[account(init_if_needed)] 없으면 생성
#[account(mut)] 수정 가능
#[account(has_one = field)] account.field == field.key()
#[account(constraint = expr)] 커스텀 불리언 표현식
#[account(address = pubkey)] 특정 주소 강제
#[account(owner = program)] 특정 프로그램 소유 강제
#[account(seeds = [...], bump)] PDA 검증
#[account(close = target)] 계정 닫기, lamports → target
#[account(signer)] 서명자 강제 (드물게 사용)
#[account(zero)] 데이터가 0으로 초기화된 계정
#[account(rent_exempt = enforce)] rent-exempt 강제
다음 장에서는 TypeScript로 이 프로그램을 테스트하는 방법을 배웁니다.
Anchor 테스트: TypeScript로 Solana 프로그램 테스트하기
Solana 테스트는 TypeScript로 작성한다
Anchor 프로그램의 테스트는 TypeScript로 작성합니다. Node.js 백엔드 개발자에게 매우 친숙한 환경입니다. 테스트 프레임워크는 Mocha + Chai를 사용하며, Jest와 유사한 패턴으로 작성합니다.
이더리움 테스트 스택: Solana(Anchor) 테스트 스택:
Solidity (프로그램) Rust + Anchor (프로그램)
Foundry (Forge) 또는 @coral-xyz/anchor (클라이언트)
Hardhat (테스트) Mocha + Chai (테스트 러너)
JavaScript/TypeScript TypeScript
패키지 설정
// package.json (anchor init이 자동 생성)
{
"scripts": {
"lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w",
"lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check"
},
"dependencies": {
"@coral-xyz/anchor": "^0.30.1"
},
"devDependencies": {
"chai": "^4.3.4",
"mocha": "^9.0.3",
"ts-mocha": "^10.0.0",
"@types/bn.js": "^5.1.0",
"@types/chai": "^4.3.0",
"@types/mocha": "^9.0.0",
"typescript": "^4.3.5"
}
}
// tsconfig.json
{
"compilerOptions": {
"types": ["mocha", "chai"],
"typeRoots": ["./node_modules/@types"],
"lib": ["es2015"],
"module": "commonjs",
"target": "es6",
"esModuleInterop": true
}
}
AnchorProvider 설정
AnchorProvider는 Solana RPC 연결과 지갑(서명자)을 묶어주는 객체입니다. NestJS의 ConfigService나 DataSource 초기화와 유사한 역할입니다.
import * as anchor from "@coral-xyz/anchor";
import { Program, AnchorProvider, web3, BN } from "@coral-xyz/anchor";
import { MyProject } from "../target/types/my_project"; // 자동 생성 타입
import { assert, expect } from "chai";
describe("my-project", () => {
// ============================================================
// 1. Provider 설정
// ============================================================
// anchor test 실행 시 환경변수에서 자동 설정:
// - ANCHOR_PROVIDER_URL: 클러스터 URL
// - ANCHOR_WALLET: 키페어 파일 경로
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
// 접근 가능한 객체들:
const connection = provider.connection; // RPC 연결
const wallet = provider.wallet; // 기본 지갑 (payer)
const payer = provider.wallet as anchor.Wallet;
// ============================================================
// 2. Program 인스턴스 생성
// ============================================================
// workspace: Anchor.toml에서 읽어온 프로그램 ID + IDL을 자동 로드
const program = anchor.workspace.MyProject as Program<MyProject>;
// 또는 수동으로:
// const program = new Program<MyProject>(
// idl as MyProject,
// PROGRAM_ID,
// provider,
// );
console.log("프로그램 ID:", program.programId.toBase58());
console.log("지갑 주소:", wallet.publicKey.toBase58());
});
핵심 패턴: program.methods 체인
Anchor TypeScript 클라이언트의 핵심 API입니다:
// 기본 패턴:
const txSignature = await program.methods
.functionName(arg1, arg2) // Instruction 이름 + 인자
.accounts({ // 계정 목록
accountName: publicKey,
})
.signers([keypair]) // 추가 서명자 (payer 외)
.rpc(); // 트랜잭션 전송 + 확정 대기
// 변형 패턴들:
// .rpc() → 전송 후 서명 반환
// .transaction() → Transaction 객체 반환 (나중에 전송)
// .instruction() → Instruction 객체 반환 (직접 조합)
// .simulate() → 시뮬레이션만 (전송 안 함)
// .prepare() → 서명 전 Transaction 준비
BN (BigNumber) 사용
Solana의 u64는 JavaScript의 number 범위를 초과하므로 BN을 사용합니다:
import { BN } from "@coral-xyz/anchor";
// u64 타입 인자
await program.methods
.mintPoints(new BN(1000)) // u64 → BN
.accounts({
pointSystem: pointSystemPda,
userPoint: userPointPda,
authority: provider.wallet.publicKey,
})
.rpc();
// BN 연산:
const a = new BN(100);
const b = new BN(50);
console.log(a.add(b).toString()); // "150"
console.log(a.sub(b).toString()); // "50"
console.log(a.mul(b).toString()); // "5000"
console.log(a.gt(b)); // true
console.log(a.toNumber()); // 100 (안전한 범위일 때만)
계정 데이터 읽기: program.account
// 단일 계정 읽기
const accountData = await program.account.userProfile.fetch(accountPubkey);
console.log("score:", accountData.score.toString());
console.log("owner:", accountData.owner.toBase58());
// 여러 계정 읽기 (배치)
const accounts = await program.account.userProfile.fetchMultiple([
pubkey1,
pubkey2,
pubkey3,
]);
// 조건으로 계정 검색 (주의: 전체 계정 스캔, 비쌈)
const allProfiles = await program.account.userProfile.all();
console.log("전체 프로필 수:", allProfiles.length);
// 필터로 검색 (memcmp: 메모리 비교)
const authorProfiles = await program.account.userProfile.all([
{
memcmp: {
offset: 8, // discriminator(8) 건너뜀
bytes: wallet.publicKey.toBase58(), // owner 필드 값
},
},
]);
PDA 주소 계산
import { PublicKey } from "@solana/web3.js";
// Rust의 find_program_address와 동일한 결과
const [profilePda, bump] = PublicKey.findProgramAddressSync(
[
Buffer.from("profile"),
wallet.publicKey.toBuffer(),
],
program.programId
);
console.log("PDA:", profilePda.toBase58());
console.log("Bump:", bump);
// 여러 seeds 조합:
const [escrowPda] = PublicKey.findProgramAddressSync(
[
Buffer.from("escrow"),
Buffer.from(tradeId.toString()),
buyer.toBuffer(),
seller.toBuffer(),
],
program.programId
);
에러 테스트: AnchorError
import { AnchorError } from "@coral-xyz/anchor";
it("권한 없는 사용자는 메모를 수정할 수 없어야 함", async () => {
const attacker = web3.Keypair.generate();
try {
await program.methods
.updateMemo("해킹 시도")
.accounts({
memo: memoPda,
author: attacker.publicKey, // 잘못된 author
})
.signers([attacker])
.rpc();
// 여기까지 오면 테스트 실패
assert.fail("에러가 발생해야 합니다");
} catch (err) {
// AnchorError 타입 검사
expect(err).to.be.instanceOf(AnchorError);
const anchorErr = err as AnchorError;
// 에러 코드 확인 (Rust의 #[error_code] enum)
expect(anchorErr.error.errorCode.code).to.equal("Unauthorized");
expect(anchorErr.error.errorCode.number).to.equal(6002);
// 에러 메시지 확인
expect(anchorErr.error.errorMessage).to.include("권한이 없습니다");
// 프로그램 확인
expect(anchorErr.program.equals(program.programId)).to.be.true;
}
});
// 또는 chai의 rejectedWith 패턴:
it("빈 메모는 생성 불가", async () => {
await expect(
program.methods
.createMemo("")
.accounts({ memo: memoPda, author: wallet.publicKey, systemProgram: web3.SystemProgram.programId })
.rpc()
).to.be.rejectedWith(AnchorError, "EmptyContent");
});
anchor test 명령어와 동작 방식
# 전체 테스트 실행 (가장 많이 사용)
anchor test
# 내부 동작:
# 1. anchor build → 프로그램 컴파일
# 2. solana-test-validator 시작 (백그라운드)
# 3. anchor deploy → 로컬 검증자에 배포
# 4. yarn run ts-mocha ... → 테스트 실행
# 5. 검증자 종료
# 이미 실행 중인 검증자 사용 (빌드 캐시 활용)
anchor test --skip-local-validator
# 빌드 건너뛰기 (코드 변경 없을 때)
anchor test --skip-build
# 특정 테스트 파일만
anchor test tests/memo.ts
# 로컬 검증자 직접 실행 (개발 중 유지)
solana-test-validator --reset # 초기화 후 시작
# 별도 터미널에서:
anchor deploy
anchor test --skip-local-validator --skip-build
전체 테스트 예제: 메모 프로그램
import * as anchor from "@coral-xyz/anchor";
import { Program, AnchorError, BN, web3 } from "@coral-xyz/anchor";
import { MemoProgram } from "../target/types/memo_program";
import { assert, expect } from "chai";
describe("memo-program", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.MemoProgram as Program<MemoProgram>;
const author = provider.wallet as anchor.Wallet;
// 테스트 전체에서 공유할 PDA
let memoPda: anchor.web3.PublicKey;
let memoBump: number;
// ============================================================
// before: 각 테스트 전 공통 설정
// ============================================================
before(async () => {
// PDA 주소 계산
[memoPda, memoBump] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("memo"), author.publicKey.toBuffer()],
program.programId
);
console.log("메모 PDA:", memoPda.toBase58());
console.log("작성자:", author.publicKey.toBase58());
});
// ============================================================
// 테스트 1: 메모 생성
// ============================================================
it("새 메모를 생성할 수 있다", async () => {
const content = "안녕하세요, Solana!";
const txSig = await program.methods
.createMemo(content)
.accounts({
memo: memoPda,
author: author.publicKey,
systemProgram: web3.SystemProgram.programId,
})
.rpc();
console.log("트랜잭션:", txSig);
// 계정 데이터 확인
const memoAccount = await program.account.memo.fetch(memoPda);
assert.equal(memoAccount.content, content);
assert.isTrue(memoAccount.author.equals(author.publicKey));
assert.isAbove(memoAccount.createdAt.toNumber(), 0);
assert.equal(memoAccount.bump, memoBump);
});
// ============================================================
// 테스트 2: 메모 조회
// ============================================================
it("메모를 조회할 수 있다", async () => {
const memoAccount = await program.account.memo.fetch(memoPda);
expect(memoAccount.content).to.equal("안녕하세요, Solana!");
expect(memoAccount.author.toBase58()).to.equal(author.publicKey.toBase58());
});
// ============================================================
// 테스트 3: 메모 업데이트
// ============================================================
it("작성자가 메모를 수정할 수 있다", async () => {
const newContent = "수정된 메모 내용";
await program.methods
.updateMemo(newContent)
.accounts({
memo: memoPda,
author: author.publicKey,
})
.rpc();
const memoAccount = await program.account.memo.fetch(memoPda);
expect(memoAccount.content).to.equal(newContent);
expect(memoAccount.updatedAt.toNumber()).to.be.gte(
memoAccount.createdAt.toNumber()
);
});
// ============================================================
// 테스트 4: 에러 케이스 - 빈 내용
// ============================================================
it("빈 내용으로 메모를 생성하면 에러가 발생한다", async () => {
// 다른 사용자로 새 PDA 계산
const newAuthor = web3.Keypair.generate();
// Devnet에서 SOL 에어드롭 (로컬에서는 자동)
const airdropSig = await provider.connection.requestAirdrop(
newAuthor.publicKey,
2 * web3.LAMPORTS_PER_SOL
);
await provider.connection.confirmTransaction(airdropSig);
const [newMemoPda] = web3.PublicKey.findProgramAddressSync(
[Buffer.from("memo"), newAuthor.publicKey.toBuffer()],
program.programId
);
try {
await program.methods
.createMemo("")
.accounts({
memo: newMemoPda,
author: newAuthor.publicKey,
systemProgram: web3.SystemProgram.programId,
})
.signers([newAuthor])
.rpc();
assert.fail("에러가 발생해야 합니다");
} catch (err) {
expect(err).to.be.instanceOf(AnchorError);
const anchorErr = err as AnchorError;
expect(anchorErr.error.errorCode.code).to.equal("EmptyContent");
}
});
// ============================================================
// 테스트 5: 에러 케이스 - 권한 없는 수정
// ============================================================
it("다른 사용자는 메모를 수정할 수 없다", async () => {
const attacker = web3.Keypair.generate();
// 공격자에게 SOL 지급
const sig = await provider.connection.requestAirdrop(
attacker.publicKey,
web3.LAMPORTS_PER_SOL
);
await provider.connection.confirmTransaction(sig);
try {
await program.methods
.updateMemo("해킹!")
.accounts({
memo: memoPda,
author: attacker.publicKey, // 잘못된 author
})
.signers([attacker])
.rpc();
assert.fail("에러가 발생해야 합니다");
} catch (err) {
// has_one = author 제약조건 위반
// → ConstraintHasOne 에러 또는 커스텀 Unauthorized 에러
expect(err).to.be.instanceOf(AnchorError);
}
});
// ============================================================
// 테스트 6: 이벤트 리스닝
// ============================================================
it("메모 생성 시 이벤트가 발행된다", async () => {
return new Promise<void>(async (resolve, reject) => {
// 이벤트 리스너 등록
const listener = program.addEventListener(
"MemoCreated",
(event, slot) => {
try {
expect(event.author.toBase58()).to.equal(
author.publicKey.toBase58()
);
expect(event.timestamp.toNumber()).to.be.above(0);
console.log("이벤트 수신! slot:", slot);
program.removeEventListener(listener);
resolve();
} catch (e) {
reject(e);
}
}
);
// 트랜잭션 전송 (이벤트 트리거)
// 새 author로 테스트 (기존 PDA는 이미 초기화됨)
const newAuthor = web3.Keypair.generate();
await provider.connection.confirmTransaction(
await provider.connection.requestAirdrop(
newAuthor.publicKey,
web3.LAMPORTS_PER_SOL
)
);
const [newPda] = web3.PublicKey.findProgramAddressSync(
[Buffer.from("memo"), newAuthor.publicKey.toBuffer()],
program.programId
);
await program.methods
.createMemo("이벤트 테스트")
.accounts({
memo: newPda,
author: newAuthor.publicKey,
systemProgram: web3.SystemProgram.programId,
})
.signers([newAuthor])
.rpc();
});
});
// ============================================================
// 테스트 7: 계정 삭제 및 렌트 반환
// ============================================================
it("메모를 삭제하면 렌트가 반환된다", async () => {
// 삭제 전 잔액
const balanceBefore = await provider.connection.getBalance(
author.publicKey
);
await program.methods
.deleteMemo()
.accounts({
memo: memoPda,
author: author.publicKey,
})
.rpc();
// 삭제 후 잔액 (렌트 반환으로 증가)
const balanceAfter = await provider.connection.getBalance(author.publicKey);
expect(balanceAfter).to.be.greaterThan(balanceBefore);
// 계정이 삭제되었는지 확인
const deletedAccount = await provider.connection.getAccountInfo(memoPda);
expect(deletedAccount).to.be.null;
});
// ============================================================
// 테스트 8: 전체 계정 목록 조회
// ============================================================
it("모든 메모 계정을 조회할 수 있다", async () => {
const allMemos = await program.account.memo.all();
console.log(`현재 메모 수: ${allMemos.length}`);
// 특정 작성자의 메모만 필터링
const myMemos = await program.account.memo.all([
{
memcmp: {
offset: 8, // discriminator 건너뜀
bytes: author.publicKey.toBase58(),
},
},
]);
console.log(`내 메모 수: ${myMemos.length}`);
});
});
유용한 테스트 유틸리티
// 트랜잭션 상세 정보 확인
async function getTransactionDetails(sig: string) {
const tx = await provider.connection.getTransaction(sig, {
commitment: "confirmed",
maxSupportedTransactionVersion: 0,
});
if (tx?.meta?.logMessages) {
console.log("프로그램 로그:");
tx.meta.logMessages.forEach(log => console.log(" ", log));
}
if (tx?.meta?.fee) {
console.log(`수수료: ${tx.meta.fee} lamports`);
}
}
// 계정 존재 여부 확인
async function accountExists(pubkey: web3.PublicKey): Promise<boolean> {
const info = await provider.connection.getAccountInfo(pubkey);
return info !== null;
}
// SOL 잔액 확인 (SOL 단위)
async function getBalanceSOL(pubkey: web3.PublicKey): Promise<number> {
const lamports = await provider.connection.getBalance(pubkey);
return lamports / web3.LAMPORTS_PER_SOL;
}
// 여러 트랜잭션 동시 전송 (성능 테스트용)
async function sendParallelTransactions(count: number) {
const promises = Array.from({ length: count }, (_, i) =>
program.methods
.someInstruction(i)
.accounts({
user: provider.wallet.publicKey,
systemProgram: web3.SystemProgram.programId,
})
.rpc()
);
const results = await Promise.allSettled(promises);
const succeeded = results.filter(r => r.status === "fulfilled").length;
const failed = results.filter(r => r.status === "rejected").length;
console.log(`성공: ${succeeded}, 실패: ${failed}`);
}
테스트 실행 및 결과 해석
$ anchor test
# 출력 예시:
memo-program
새 메모를 생성할 수 있다
트랜잭션: 5KBNvBW2w...
✓ 새 메모를 생성할 수 있다 (1234ms)
메모를 조회할 수 있다
✓ 메모를 조회할 수 있다 (456ms)
작성자가 메모를 수정할 수 있다
✓ 작성자가 메모를 수정할 수 있다 (789ms)
빈 내용으로 메모를 생성하면 에러가 발생한다
✓ 빈 내용으로 메모를 생성하면 에러가 발생한다 (321ms)
다른 사용자는 메모를 수정할 수 없다
✓ 다른 사용자는 메모를 수정할 수 없다 (654ms)
메모 생성 시 이벤트가 발행된다
이벤트 수신! slot: 42
✓ 메모 생성 시 이벤트가 발행된다 (987ms)
메모를 삭제하면 렌트가 반환된다
✓ 메모를 삭제하면 렌트가 반환된다 (543ms)
모든 메모 계정을 조회할 수 있다
현재 메모 수: 3
내 메모 수: 1
✓ 모든 메모 계정을 조회할 수 있다 (234ms)
8 passing (5s)
다음 장에서는 배운 모든 것을 종합하여 포인트 시스템 미니 프로젝트를 구현합니다.
미니프로젝트: Anchor로 포인트 시스템 구현
프로젝트 개요
지금까지 배운 Solana와 Anchor의 모든 개념을 종합하여 **포인트 시스템(Point System)**을 구현합니다. 실제 서비스에서 자주 쓰이는 충성도 포인트, 게임 점수, 리워드 토큰 등의 패턴을 Solana 위에서 구현합니다.
요구사항
기능:
1. initialize - 포인트 시스템 초기화 (관리자 설정)
2. register_user - 사용자 등록 (포인트 계정 생성)
3. mint_points - 관리자가 사용자에게 포인트 발행
4. transfer_points - 사용자 간 포인트 전송
5. burn_points - 포인트 소각
규칙:
- 관리자만 mint 가능
- 사용자는 자신의 포인트만 전송/소각 가능
- 전송 시 잔액 부족 에러 처리
- 각 작업마다 이벤트 발행
계정 구조:
- PointSystem: 전체 시스템 상태 (글로벌 PDA)
- UserPoint: 사용자별 포인트 잔액 (사용자별 PDA)
프로젝트 생성
anchor init sol-point-system
cd sol-point-system
# 빌드 의존성 확인
anchor --version # 0.30.1
solana --version # 1.18+
계정 구조 설계
┌────────────────────────────────────────────────────────┐
│ 포인트 시스템 계정 구조 │
│ │
│ PointSystem Account (PDA: ["point-system"]) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ admin: Pubkey ← 관리자 주소 │ │
│ │ total_supply: u64 ← 총 발행량 │ │
│ │ total_burned: u64 ← 총 소각량 │ │
│ │ user_count: u64 ← 등록 사용자 수 │ │
│ │ bump: u8 ← PDA bump │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ UserPoint Account (PDA: ["user-point", user_pubkey]) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ owner: Pubkey ← 사용자 주소 │ │
│ │ balance: u64 ← 포인트 잔액 │ │
│ │ total_earned: u64 ← 누적 획득량 │ │
│ │ total_spent: u64 ← 누적 사용량 │ │
│ │ bump: u8 ← PDA bump │ │
│ └─────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
프로그램 코드: programs/sol-point-system/src/lib.rs
use anchor_lang::prelude::*;
declare_id!("PoiNt1111111111111111111111111111111111111111");
// ============================================================
// 에러 코드
// ============================================================
#[error_code]
pub enum PointError {
#[msg("관리자 권한이 필요합니다")]
AdminRequired,
#[msg("포인트 잔액이 부족합니다")]
InsufficientBalance,
#[msg("발행량은 0보다 커야 합니다")]
ZeroAmount,
#[msg("이미 등록된 사용자입니다")]
AlreadyRegistered,
#[msg("산술 오버플로가 발생했습니다")]
ArithmeticOverflow,
#[msg("자기 자신에게 전송할 수 없습니다")]
SelfTransfer,
}
// ============================================================
// 이벤트
// ============================================================
#[event]
pub struct SystemInitialized {
pub admin: Pubkey,
pub timestamp: i64,
}
#[event]
pub struct UserRegistered {
pub user: Pubkey,
pub timestamp: i64,
}
#[event]
pub struct PointsMinted {
pub recipient: Pubkey,
pub amount: u64,
pub new_balance: u64,
pub total_supply: u64,
pub timestamp: i64,
}
#[event]
pub struct PointsTransferred {
pub from: Pubkey,
pub to: Pubkey,
pub amount: u64,
pub timestamp: i64,
}
#[event]
pub struct PointsBurned {
pub user: Pubkey,
pub amount: u64,
pub remaining: u64,
pub timestamp: i64,
}
// ============================================================
// 계정 구조체
// ============================================================
/// 포인트 시스템 전역 상태
#[account]
pub struct PointSystem {
pub admin: Pubkey, // 32
pub total_supply: u64, // 8
pub total_burned: u64, // 8
pub user_count: u64, // 8
pub bump: u8, // 1
}
impl PointSystem {
pub const LEN: usize = 32 + 8 + 8 + 8 + 1; // 57 bytes
}
/// 사용자별 포인트 계정
#[account]
pub struct UserPoint {
pub owner: Pubkey, // 32
pub balance: u64, // 8
pub total_earned: u64, // 8
pub total_spent: u64, // 8
pub bump: u8, // 1
}
impl UserPoint {
pub const LEN: usize = 32 + 8 + 8 + 8 + 1; // 57 bytes
}
// ============================================================
// 프로그램 로직
// ============================================================
#[program]
pub mod sol_point_system {
use super::*;
// ----------------------------------------------------------
// 1. initialize: 포인트 시스템 초기화
// ----------------------------------------------------------
/// 포인트 시스템을 배포 후 한 번만 실행
/// admin 계정을 설정하고 글로벌 상태 계정 생성
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let system = &mut ctx.accounts.point_system;
let clock = Clock::get()?;
system.admin = ctx.accounts.admin.key();
system.total_supply = 0;
system.total_burned = 0;
system.user_count = 0;
system.bump = ctx.bumps.point_system;
emit!(SystemInitialized {
admin: system.admin,
timestamp: clock.unix_timestamp,
});
msg!("포인트 시스템 초기화 완료. 관리자: {}", system.admin);
Ok(())
}
// ----------------------------------------------------------
// 2. register_user: 사용자 등록
// ----------------------------------------------------------
/// 새 사용자를 등록하고 UserPoint PDA 계정 생성
/// 누구나 자신의 계정을 생성할 수 있음
pub fn register_user(ctx: Context<RegisterUser>) -> Result<()> {
let user_point = &mut ctx.accounts.user_point;
let system = &mut ctx.accounts.point_system;
let clock = Clock::get()?;
user_point.owner = ctx.accounts.user.key();
user_point.balance = 0;
user_point.total_earned = 0;
user_point.total_spent = 0;
user_point.bump = ctx.bumps.user_point;
// 사용자 수 증가
system.user_count = system.user_count
.checked_add(1)
.ok_or(PointError::ArithmeticOverflow)?;
emit!(UserRegistered {
user: user_point.owner,
timestamp: clock.unix_timestamp,
});
msg!("사용자 등록: {}", user_point.owner);
Ok(())
}
// ----------------------------------------------------------
// 3. mint_points: 포인트 발행 (관리자 전용)
// ----------------------------------------------------------
/// 관리자가 사용자에게 포인트를 발행
/// has_one = admin 제약조건으로 관리자 검증
pub fn mint_points(ctx: Context<MintPoints>, amount: u64) -> Result<()> {
require!(amount > 0, PointError::ZeroAmount);
let system = &mut ctx.accounts.point_system;
let user_point = &mut ctx.accounts.user_point;
let clock = Clock::get()?;
// 잔액 증가 (오버플로 방지)
user_point.balance = user_point.balance
.checked_add(amount)
.ok_or(PointError::ArithmeticOverflow)?;
user_point.total_earned = user_point.total_earned
.checked_add(amount)
.ok_or(PointError::ArithmeticOverflow)?;
// 총 공급량 증가
system.total_supply = system.total_supply
.checked_add(amount)
.ok_or(PointError::ArithmeticOverflow)?;
emit!(PointsMinted {
recipient: user_point.owner,
amount,
new_balance: user_point.balance,
total_supply: system.total_supply,
timestamp: clock.unix_timestamp,
});
msg!(
"포인트 발행: {} → {} (잔액: {})",
amount,
user_point.owner,
user_point.balance
);
Ok(())
}
// ----------------------------------------------------------
// 4. transfer_points: 포인트 전송
// ----------------------------------------------------------
/// 사용자가 다른 사용자에게 포인트 전송
pub fn transfer_points(ctx: Context<TransferPoints>, amount: u64) -> Result<()> {
require!(amount > 0, PointError::ZeroAmount);
let from_point = &mut ctx.accounts.from_point;
let to_point = &mut ctx.accounts.to_point;
// 자기 자신에게 전송 방지
require!(
from_point.owner != to_point.owner,
PointError::SelfTransfer
);
// 잔액 확인
require!(
from_point.balance >= amount,
PointError::InsufficientBalance
);
let clock = Clock::get()?;
// 송신자 잔액 감소
from_point.balance = from_point.balance
.checked_sub(amount)
.ok_or(PointError::ArithmeticOverflow)?;
from_point.total_spent = from_point.total_spent
.checked_add(amount)
.ok_or(PointError::ArithmeticOverflow)?;
// 수신자 잔액 증가
to_point.balance = to_point.balance
.checked_add(amount)
.ok_or(PointError::ArithmeticOverflow)?;
to_point.total_earned = to_point.total_earned
.checked_add(amount)
.ok_or(PointError::ArithmeticOverflow)?;
emit!(PointsTransferred {
from: from_point.owner,
to: to_point.owner,
amount,
timestamp: clock.unix_timestamp,
});
msg!(
"포인트 전송: {} → {} ({}pt)",
from_point.owner,
to_point.owner,
amount
);
Ok(())
}
// ----------------------------------------------------------
// 5. burn_points: 포인트 소각
// ----------------------------------------------------------
/// 사용자가 자신의 포인트를 소각
pub fn burn_points(ctx: Context<BurnPoints>, amount: u64) -> Result<()> {
require!(amount > 0, PointError::ZeroAmount);
let system = &mut ctx.accounts.point_system;
let user_point = &mut ctx.accounts.user_point;
let clock = Clock::get()?;
require!(
user_point.balance >= amount,
PointError::InsufficientBalance
);
user_point.balance = user_point.balance
.checked_sub(amount)
.ok_or(PointError::ArithmeticOverflow)?;
user_point.total_spent = user_point.total_spent
.checked_add(amount)
.ok_or(PointError::ArithmeticOverflow)?;
system.total_burned = system.total_burned
.checked_add(amount)
.ok_or(PointError::ArithmeticOverflow)?;
emit!(PointsBurned {
user: user_point.owner,
amount,
remaining: user_point.balance,
timestamp: clock.unix_timestamp,
});
msg!(
"포인트 소각: {} ({}pt, 잔액: {})",
user_point.owner,
amount,
user_point.balance
);
Ok(())
}
}
// ============================================================
// 계정 검증 구조체
// ============================================================
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = admin,
space = 8 + PointSystem::LEN,
seeds = [b"point-system"],
bump,
)]
pub point_system: Account<'info, PointSystem>,
#[account(mut)]
pub admin: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct RegisterUser<'info> {
#[account(
mut,
seeds = [b"point-system"],
bump = point_system.bump,
)]
pub point_system: Account<'info, PointSystem>,
#[account(
init,
payer = user,
space = 8 + UserPoint::LEN,
seeds = [b"user-point", user.key().as_ref()],
bump,
)]
pub user_point: Account<'info, UserPoint>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct MintPoints<'info> {
#[account(
mut,
seeds = [b"point-system"],
bump = point_system.bump,
has_one = admin @ PointError::AdminRequired,
)]
pub point_system: Account<'info, PointSystem>,
#[account(
mut,
seeds = [b"user-point", user_point.owner.as_ref()],
bump = user_point.bump,
)]
pub user_point: Account<'info, UserPoint>,
pub admin: Signer<'info>,
}
#[derive(Accounts)]
pub struct TransferPoints<'info> {
#[account(
mut,
seeds = [b"user-point", sender.key().as_ref()],
bump = from_point.bump,
has_one = sender @ PointError::AdminRequired,
)]
pub from_point: Account<'info, UserPoint>,
#[account(
mut,
seeds = [b"user-point", to_point.owner.as_ref()],
bump = to_point.bump,
)]
pub to_point: Account<'info, UserPoint>,
pub sender: Signer<'info>,
}
#[derive(Accounts)]
pub struct BurnPoints<'info> {
#[account(
mut,
seeds = [b"point-system"],
bump = point_system.bump,
)]
pub point_system: Account<'info, PointSystem>,
#[account(
mut,
seeds = [b"user-point", user.key().as_ref()],
bump = user_point.bump,
has_one = user @ PointError::AdminRequired,
)]
pub user_point: Account<'info, UserPoint>,
pub user: Signer<'info>,
}
TypeScript 테스트 코드: tests/sol-point-system.ts
import * as anchor from "@coral-xyz/anchor";
import { Program, BN, AnchorError, web3 } from "@coral-xyz/anchor";
import { SolPointSystem } from "../target/types/sol_point_system";
import { assert, expect } from "chai";
describe("sol-point-system", () => {
// ============================================================
// 설정
// ============================================================
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.SolPointSystem as Program<SolPointSystem>;
// 역할별 키페어
const admin = provider.wallet as anchor.Wallet;
const alice = web3.Keypair.generate();
const bob = web3.Keypair.generate();
const attacker = web3.Keypair.generate();
// PDA 주소들
let systemPda: web3.PublicKey;
let alicePointPda: web3.PublicKey;
let bobPointPda: web3.PublicKey;
// ============================================================
// before: 테스트 전 환경 준비
// ============================================================
before(async () => {
// PDA 계산
[systemPda] = web3.PublicKey.findProgramAddressSync(
[Buffer.from("point-system")],
program.programId
);
[alicePointPda] = web3.PublicKey.findProgramAddressSync(
[Buffer.from("user-point"), alice.publicKey.toBuffer()],
program.programId
);
[bobPointPda] = web3.PublicKey.findProgramAddressSync(
[Buffer.from("user-point"), bob.publicKey.toBuffer()],
program.programId
);
// Alice, Bob, Attacker에게 SOL 에어드롭
const airdropTargets = [alice.publicKey, bob.publicKey, attacker.publicKey];
for (const target of airdropTargets) {
const sig = await provider.connection.requestAirdrop(
target,
2 * web3.LAMPORTS_PER_SOL
);
await provider.connection.confirmTransaction(sig);
}
console.log("Admin:", admin.publicKey.toBase58());
console.log("Alice:", alice.publicKey.toBase58());
console.log("Bob:", bob.publicKey.toBase58());
console.log("System PDA:", systemPda.toBase58());
console.log("Alice Point PDA:", alicePointPda.toBase58());
console.log("Bob Point PDA:", bobPointPda.toBase58());
});
// ============================================================
// 테스트 1: 시스템 초기화
// ============================================================
it("포인트 시스템을 초기화할 수 있다", async () => {
const txSig = await program.methods
.initialize()
.accounts({
pointSystem: systemPda,
admin: admin.publicKey,
systemProgram: web3.SystemProgram.programId,
})
.rpc();
console.log("초기화 tx:", txSig);
const systemAccount = await program.account.pointSystem.fetch(systemPda);
assert.isTrue(systemAccount.admin.equals(admin.publicKey));
assert.equal(systemAccount.totalSupply.toNumber(), 0);
assert.equal(systemAccount.totalBurned.toNumber(), 0);
assert.equal(systemAccount.userCount.toNumber(), 0);
});
// ============================================================
// 테스트 2: 사용자 등록
// ============================================================
it("Alice가 사용자 등록을 할 수 있다", async () => {
await program.methods
.registerUser()
.accounts({
pointSystem: systemPda,
userPoint: alicePointPda,
user: alice.publicKey,
systemProgram: web3.SystemProgram.programId,
})
.signers([alice])
.rpc();
const aliceAccount = await program.account.userPoint.fetch(alicePointPda);
assert.isTrue(aliceAccount.owner.equals(alice.publicKey));
assert.equal(aliceAccount.balance.toNumber(), 0);
const systemAccount = await program.account.pointSystem.fetch(systemPda);
assert.equal(systemAccount.userCount.toNumber(), 1);
});
it("Bob도 사용자 등록을 할 수 있다", async () => {
await program.methods
.registerUser()
.accounts({
pointSystem: systemPda,
userPoint: bobPointPda,
user: bob.publicKey,
systemProgram: web3.SystemProgram.programId,
})
.signers([bob])
.rpc();
const systemAccount = await program.account.pointSystem.fetch(systemPda);
assert.equal(systemAccount.userCount.toNumber(), 2);
});
// ============================================================
// 테스트 3: 포인트 발행 (mint)
// ============================================================
it("관리자가 Alice에게 포인트를 발행할 수 있다", async () => {
const mintAmount = new BN(1000);
await program.methods
.mintPoints(mintAmount)
.accounts({
pointSystem: systemPda,
userPoint: alicePointPda,
admin: admin.publicKey,
})
.rpc();
const aliceAccount = await program.account.userPoint.fetch(alicePointPda);
assert.equal(aliceAccount.balance.toNumber(), 1000);
assert.equal(aliceAccount.totalEarned.toNumber(), 1000);
const systemAccount = await program.account.pointSystem.fetch(systemPda);
assert.equal(systemAccount.totalSupply.toNumber(), 1000);
});
it("관리자가 Bob에게도 포인트를 발행할 수 있다", async () => {
await program.methods
.mintPoints(new BN(500))
.accounts({
pointSystem: systemPda,
userPoint: bobPointPda,
admin: admin.publicKey,
})
.rpc();
const bobAccount = await program.account.userPoint.fetch(bobPointPda);
assert.equal(bobAccount.balance.toNumber(), 500);
const systemAccount = await program.account.pointSystem.fetch(systemPda);
assert.equal(systemAccount.totalSupply.toNumber(), 1500);
});
it("관리자가 아닌 사용자는 포인트를 발행할 수 없다", async () => {
try {
await program.methods
.mintPoints(new BN(9999))
.accounts({
pointSystem: systemPda,
userPoint: alicePointPda,
admin: attacker.publicKey, // 공격자가 admin 사칭
})
.signers([attacker])
.rpc();
assert.fail("에러가 발생해야 합니다");
} catch (err) {
expect(err).to.be.instanceOf(AnchorError);
const anchorErr = err as AnchorError;
expect(anchorErr.error.errorCode.code).to.equal("AdminRequired");
console.log("관리자 권한 검증 성공!");
}
});
// ============================================================
// 테스트 4: 포인트 전송
// ============================================================
it("Alice가 Bob에게 포인트를 전송할 수 있다", async () => {
const transferAmount = new BN(300);
await program.methods
.transferPoints(transferAmount)
.accounts({
fromPoint: alicePointPda,
toPoint: bobPointPda,
sender: alice.publicKey,
})
.signers([alice])
.rpc();
const aliceAccount = await program.account.userPoint.fetch(alicePointPda);
const bobAccount = await program.account.userPoint.fetch(bobPointPda);
assert.equal(aliceAccount.balance.toNumber(), 700); // 1000 - 300
assert.equal(aliceAccount.totalSpent.toNumber(), 300);
assert.equal(bobAccount.balance.toNumber(), 800); // 500 + 300
assert.equal(bobAccount.totalEarned.toNumber(), 800); // 500 + 300
console.log("전송 후 Alice 잔액:", aliceAccount.balance.toNumber());
console.log("전송 후 Bob 잔액:", bobAccount.balance.toNumber());
});
it("잔액 부족 시 전송이 실패한다", async () => {
try {
await program.methods
.transferPoints(new BN(9999)) // Alice 잔액(700)보다 많음
.accounts({
fromPoint: alicePointPda,
toPoint: bobPointPda,
sender: alice.publicKey,
})
.signers([alice])
.rpc();
assert.fail("에러가 발생해야 합니다");
} catch (err) {
expect(err).to.be.instanceOf(AnchorError);
const anchorErr = err as AnchorError;
expect(anchorErr.error.errorCode.code).to.equal("InsufficientBalance");
console.log("잔액 부족 검증 성공!");
}
});
it("자기 자신에게 전송하면 에러가 발생한다", async () => {
try {
await program.methods
.transferPoints(new BN(100))
.accounts({
fromPoint: alicePointPda,
toPoint: alicePointPda, // 자기 자신
sender: alice.publicKey,
})
.signers([alice])
.rpc();
assert.fail("에러가 발생해야 합니다");
} catch (err) {
expect(err).to.be.instanceOf(AnchorError);
const anchorErr = err as AnchorError;
expect(anchorErr.error.errorCode.code).to.equal("SelfTransfer");
}
});
// ============================================================
// 테스트 5: 포인트 소각
// ============================================================
it("Bob이 자신의 포인트를 소각할 수 있다", async () => {
const burnAmount = new BN(200);
const bobBefore = await program.account.userPoint.fetch(bobPointPda);
const systemBefore = await program.account.pointSystem.fetch(systemPda);
await program.methods
.burnPoints(burnAmount)
.accounts({
pointSystem: systemPda,
userPoint: bobPointPda,
user: bob.publicKey,
})
.signers([bob])
.rpc();
const bobAfter = await program.account.userPoint.fetch(bobPointPda);
const systemAfter = await program.account.pointSystem.fetch(systemPda);
assert.equal(
bobAfter.balance.toNumber(),
bobBefore.balance.toNumber() - 200
);
assert.equal(
systemAfter.totalBurned.toNumber(),
systemBefore.totalBurned.toNumber() + 200
);
console.log("소각 후 Bob 잔액:", bobAfter.balance.toNumber());
console.log("총 소각량:", systemAfter.totalBurned.toNumber());
});
// ============================================================
// 테스트 6: 전체 상태 확인
// ============================================================
it("최종 시스템 상태를 확인한다", async () => {
const system = await program.account.pointSystem.fetch(systemPda);
const alice = await program.account.userPoint.fetch(alicePointPda);
const bob = await program.account.userPoint.fetch(bobPointPda);
console.log("\n========== 최종 포인트 현황 ==========");
console.log(`총 발행량: ${system.totalSupply.toNumber()} pt`);
console.log(`총 소각량: ${system.totalBurned.toNumber()} pt`);
console.log(`유통량: ${system.totalSupply.sub(system.totalBurned).toNumber()} pt`);
console.log(`사용자 수: ${system.userCount.toNumber()} 명`);
console.log(`Alice 잔액: ${alice.balance.toNumber()} pt (획득: ${alice.totalEarned}, 사용: ${alice.totalSpent})`);
console.log(`Bob 잔액: ${bob.balance.toNumber()} pt (획득: ${bob.totalEarned}, 사용: ${bob.totalSpent})`);
console.log("======================================\n");
// 불변식 검증: 유통량 = 모든 사용자 잔액 합계
const totalBalance = alice.balance.add(bob.balance);
const circulation = system.totalSupply.sub(system.totalBurned);
assert.equal(
totalBalance.toString(),
circulation.toString(),
"유통량이 잔액 합계와 일치해야 함"
);
});
// ============================================================
// 테스트 7: 이벤트 수신
// ============================================================
it("포인트 발행 시 이벤트가 발행된다", async () => {
return new Promise<void>(async (resolve, reject) => {
const listener = program.addEventListener(
"PointsMinted",
(event, slot) => {
try {
expect(event.amount.toNumber()).to.equal(50);
console.log(
`이벤트 수신: ${event.amount} pt → ${event.recipient.toBase58().slice(0, 8)}...`
);
program.removeEventListener(listener);
resolve();
} catch (e) {
reject(e);
}
}
);
setTimeout(() => {
program.removeEventListener(listener);
reject(new Error("이벤트 타임아웃"));
}, 10000);
await program.methods
.mintPoints(new BN(50))
.accounts({
pointSystem: systemPda,
userPoint: alicePointPda,
admin: admin.publicKey,
})
.rpc();
});
});
});
단계별 실행 가이드
Step 1: 프로젝트 설정
# 프로젝트 생성 및 이동
anchor init sol-point-system
cd sol-point-system
# lib.rs에 위의 프로그램 코드 붙여넣기
# programs/sol-point-system/src/lib.rs
# 테스트 파일 작성
# tests/sol-point-system.ts
Step 2: 빌드
anchor build
# 빌드 성공 확인:
# target/deploy/sol_point_system.so
# target/idl/sol_point_system.json
# target/types/sol_point_system.ts
# 프로그램 ID 확인
anchor keys list
# sol_point_system: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# lib.rs의 declare_id!와 Anchor.toml의 ID를 빌드된 ID로 업데이트
anchor keys sync
Step 3: 로컬 테스트 실행
# 전체 테스트 실행 (검증자 자동 시작/종료)
anchor test
# 개발 중 빠른 반복:
# 터미널 1: 검증자 유지
solana-test-validator --reset
# 터미널 2: 배포 후 테스트
anchor build && anchor deploy && anchor test --skip-local-validator --skip-build
Step 4: Devnet 배포 및 테스트
# Devnet으로 전환
solana config set --url devnet
# SOL 에어드롭 (Devnet 무료)
solana airdrop 2
# Anchor.toml 수정
# [provider]
# cluster = "Devnet"
# Devnet에 배포
anchor deploy --provider.cluster devnet
# Devnet에서 테스트
anchor test --provider.cluster devnet
Step 5: 배포된 프로그램 탐색
# Solana Explorer에서 확인
# https://explorer.solana.com/address/<PROGRAM_ID>?cluster=devnet
# CLI로 프로그램 계정 확인
solana program show <PROGRAM_ID>
# 생성된 계정 확인 (Python 코드 예시)
# 또는 TypeScript로:
npx ts-node -e "
const anchor = require('@coral-xyz/anchor');
const { PublicKey } = require('@solana/web3.js');
const connection = new anchor.web3.Connection('https://api.devnet.solana.com');
const programId = new PublicKey(process.env.PROGRAM_ID);
connection.getAccountInfo(programId).then((account) => {
console.log(account ? '프로그램 계정 존재' : '프로그램 계정 없음');
});
"
확장 아이디어
이 기본 포인트 시스템에 추가할 수 있는 기능들:
// 1. 만료 기능: expires_at: i64 필드 추가
pub fn is_expired(&self, current_time: i64) -> bool {
self.expires_at > 0 && current_time > self.expires_at
}
// 2. 등급 시스템
pub fn get_tier(&self) -> &str {
match self.total_earned.try_into().unwrap_or(0u64) {
0..=999 => "Bronze",
1000..=4999 => "Silver",
5000..=9999 => "Gold",
_ => "Diamond",
}
}
// 3. 전송 수수료 (일부를 treasury로)
// 4. 일일 발행 한도
// 5. 화이트리스트 발행자 (admin 외 추가 발행자)
// 6. 잠금(lock) 기능 - 일정 기간 전송 불가
배운 내용 정리
이 미니프로젝트를 통해 다음을 실습했습니다:
Solana/Anchor 핵심 개념:
✓ declare_id! - 프로그램 ID 관리
✓ #[program] - Instruction 라우팅
✓ #[account] - 계정 데이터 구조
✓ #[derive(Accounts)] - 계정 검증
✓ PDA (Program Derived Address) - 결정론적 계정 주소
✓ seeds + bump - PDA 생성 및 검증
✓ has_one - 계정 관계 검증
✓ #[error_code] - 커스텀 에러
✓ #[event] - 이벤트 발행
✓ checked_add/sub - 안전한 산술 연산
TypeScript 테스트:
✓ AnchorProvider 설정
✓ program.methods 체인
✓ PDA 주소 계산
✓ 계정 데이터 조회
✓ AnchorError 처리
✓ 이벤트 리스닝
✓ 잔액 검증
이제 여러분은 Solana 위에서 실제 동작하는 dApp 백엔드를 구축할 수 있습니다. 다음 단계로는 SPL Token 통합, Metaplex를 이용한 NFT, 또는 실제 DEX 프로토콜 구현에 도전해보세요.
19장: Alloy - Rust에서 Ethereum과 상호작용하기
Alloy란 무엇인가
Alloy는 Rust에서 Ethereum 블록체인과 상호작용하기 위한 최신 라이브러리 모음이다. ethers-rs의 후속 프로젝트로, Foundry 팀(Paradigm)이 주도하여 개발하고 있다. 2023년 말부터 본격적으로 개발되었으며, 2024년에 안정 버전이 출시되었다.
Alloy를 한 문장으로 정의하면: Rust 생태계에서 Ethereum JSON-RPC를 다루는 표준 방법이다.
Node.js 개발자에게 익숙한 ethers.js나 viem과 유사한 역할을 한다. 다만 Rust의 타입 시스템과 비동기 런타임을 완전히 활용한다는 점이 다르다.
ethers.js (Node.js) ↔ Alloy (Rust)
viem (TypeScript) ↔ Alloy (Rust)
web3.py (Python) ↔ Alloy (Rust)
ethers-rs에서 Alloy로의 전환 배경
ethers-rs의 한계
ethers-rs는 2020년부터 개발된 Rust용 Ethereum 라이브러리였다. 하지만 몇 가지 구조적 문제가 있었다:
- Provider 구조의 복잡성: 미들웨어 패턴이 중첩되어 타입이 복잡해졌다
- ABI 처리 방식:
ethabi크레이트에 의존하여 타입 안전성이 부족했다 - 유지보수 중단: 2023년부터 ethers-rs는 더 이상 활발히 유지되지 않는다
공식 저장소에는 이런 메시지가 있다:
“ethers-rs is in maintenance mode. New projects should use Alloy.”
Alloy의 설계 철학
Alloy는 처음부터 다시 설계되었다:
- 모듈성: 필요한 크레이트만 선택적으로 사용
- 타입 안전성:
sol!매크로로 컴파일 타임에 ABI 검증 - 성능: 불필요한 직렬화/역직렬화 최소화
- 인체공학: Provider 빌더 패턴으로 간결한 설정
ethers-rs vs Alloy 코드 비교
// ethers-rs 방식 (구식)
use ethers::providers::{Http, Provider, Middleware};
use ethers::types::Address;
let provider = Provider::<Http>::try_from("http://localhost:8545")?;
let balance = provider.get_balance(address, None).await?;
// Alloy 방식 (현대적)
use alloy::providers::{Provider, ProviderBuilder};
use alloy::primitives::Address;
let provider = ProviderBuilder::new().on_http("http://localhost:8545".parse()?);
let balance = provider.get_balance(address).await?;
Alloy는 코드가 더 간결하고 타입이 명확하다.
Alloy의 구성 요소
Alloy는 단일 거대 라이브러리가 아니라 목적별로 분리된 크레이트들의 모음이다. 각 크레이트를 이해하는 것이 중요하다.
alloy-primitives
Ethereum에서 사용하는 기본 타입들을 정의한다.
use alloy::primitives::{
Address, // 20바이트 Ethereum 주소
U256, // 256비트 부호 없는 정수 (토큰 잔액 등)
B256, // 32바이트 해시값 (트랜잭션 해시 등)
Bytes, // 동적 크기 바이트 배열
keccak256, // Keccak-256 해시 함수
FixedBytes, // 고정 크기 바이트 배열
};
U256은 Ethereum에서 모든 숫자를 표현하는 데 사용된다. Solidity의 uint256에 대응한다.
// U256 사용 예시
let one_ether = U256::from(1_000_000_000_000_000_000u128); // 1 ETH = 10^18 wei
let amount: U256 = "1000000000000000000".parse().unwrap();
// Address 사용 예시
let addr: Address = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".parse().unwrap();
alloy-provider
블록체인 노드와의 연결을 관리한다. HTTP, WebSocket, IPC 등 다양한 전송 방식을 지원한다.
use alloy::providers::{Provider, ProviderBuilder};
use alloy::network::Ethereum;
use alloy::transports::http::Http;
// HTTP Provider
let provider = ProviderBuilder::new()
.on_http("http://localhost:8545".parse()?);
// WebSocket Provider
let provider = ProviderBuilder::new()
.on_ws(alloy::transports::ws::WsConnect::new("ws://localhost:8546"))
.await?;
Provider는 블록체인 상태 조회(읽기)에 사용된다.
alloy-signer
개인키를 관리하고 트랜잭션에 서명한다.
use alloy::signers::local::PrivateKeySigner;
use alloy::signers::Signer;
// 개인키로 서명자 생성
let signer: PrivateKeySigner =
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80".parse()?;
// 서명자의 주소 확인
println!("Address: {}", signer.address());
alloy-contract
스마트 컨트랙트와의 상호작용을 추상화한다. sol! 매크로로 생성된 타입과 함께 사용한다.
use alloy::contract::ContractInstance;
use alloy::dyn_abi::DynSolValue;
// 동적 ABI로 컨트랙트 호출
let contract = ContractInstance::new(address, provider, interface);
let result = contract.function("transfer", &[to_value, amount_value])?.call().await?;
alloy-sol-types와 sol! 매크로
가장 강력한 기능이다. Solidity 코드나 ABI를 Rust 타입으로 컴파일 타임에 변환한다.
use alloy::sol;
// Solidity 코드를 직접 인라인으로 작성
sol! {
interface IERC20 {
function balanceOf(address owner) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
event Transfer(address indexed from, address indexed to, uint256 value);
}
}
// 이제 IERC20::balanceOfCall, IERC20::transferCall 등의 타입을 사용 가능
alloy-network
여러 Ethereum 호환 네트워크를 추상화한다. Ethereum 메인넷, Besu 프라이빗 체인, Optimism 등을 동일한 인터페이스로 다룰 수 있다.
Cargo.toml 설정 방법
Alloy는 feature flag로 필요한 기능만 선택할 수 있다. 이는 컴파일 시간과 바이너리 크기를 줄이는 데 도움이 된다.
기본 설정
[dependencies]
# Alloy 전체 패키지 (개발/학습용으로 편리)
alloy = { version = "0.9", features = ["full"] }
# 비동기 런타임
tokio = { version = "1", features = ["full"] }
# 에러 처리
eyre = "0.6"
anyhow = "1"
프로덕션 설정 (필요한 기능만)
[dependencies]
alloy = { version = "0.9", features = [
# 코어 기능
"providers", # Provider, ProviderBuilder
"provider-http", # HTTP 전송
"provider-ws", # WebSocket 전송 (이벤트 구독 필요 시)
# 컨트랙트 상호작용
"contract", # ContractInstance
"sol-types", # sol! 매크로, ABI 인코딩
"json-abi", # JSON ABI 파일 파싱
# 서명
"signers", # Signer 트레이트
"signer-local", # PrivateKeySigner
# 네트워크
"network", # Ethereum 네트워크 추상화
"rpc-types", # RPC 요청/응답 타입
# 추가 유틸리티
"consensus", # 블록, 트랜잭션 타입
] }
tokio = { version = "1", features = ["full"] }
eyre = "0.6"
platform 프로젝트의 실제 Cargo.toml 패턴
platform의 iksan-api 서비스는 다음과 같이 설정한다:
[package]
name = "iksan-api"
version = "0.1.0"
edition = "2021"
[dependencies]
# Alloy - 블록체인 상호작용
alloy = { version = "0.9", features = [
"providers",
"provider-http",
"contract",
"sol-types",
"json-abi",
"signers",
"signer-local",
"network",
"rpc-types",
"consensus",
"node-bindings", # 테스트용 Anvil 실행
] }
# 웹 프레임워크
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }
# 데이터베이스
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono"] }
# 직렬화
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# 에러 처리
anyhow = "1"
thiserror = "2"
# 로깅
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# 유틸리티
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
hex = "0.4"
sha2 = "0.10"
Alloy 버전과 호환성
Alloy는 빠르게 발전하고 있다. 버전 간 API 변경이 있을 수 있으므로 주의가 필요하다.
| 버전 | 상태 | 주요 변경사항 |
|---|---|---|
| 0.1 | EOL | 최초 안정 버전 |
| 0.3 | EOL | Provider API 개선 |
| 0.6 | 유지보수 | sol! 매크로 안정화 |
| 0.9 | 현재 권장 | 네트워크 추상화 개선 |
항상 Cargo.lock을 커밋하여 팀 간 버전 일관성을 유지하라.
요약
Alloy는 Rust에서 Ethereum과 상호작용하는 현대적인 표준이다:
- alloy-primitives: 기본 타입 (Address, U256, B256)
- alloy-provider: 노드 연결과 RPC 호출
- alloy-signer: 개인키 관리와 트랜잭션 서명
- alloy-contract: 스마트 컨트랙트 상호작용
- alloy-sol-types: Solidity ABI를 Rust 타입으로 변환
다음 장에서는 Provider를 사용하여 실제로 블록체인 데이터를 읽어보겠다.
19-1: Provider와 읽기 호출
Provider란 무엇인가
Provider는 Ethereum 노드와의 연결을 추상화한 인터페이스다. 블록체인에서 데이터를 읽거나 트랜잭션을 제출할 때 사용한다. Node.js의 ethers.provider나 viem의 publicClient에 해당한다.
Rust 코드 → Provider → JSON-RPC → Ethereum 노드 (Besu/Geth/etc.)
Provider는 두 가지 역할을 한다:
- 읽기 (Read): 잔액 조회, 블록 정보, 트랜잭션 조회, view 함수 호출
- 쓰기 (Write): 트랜잭션 전송 (서명이 필요하므로 다음 장에서 다룸)
Provider 생성 방법
HTTP Provider
가장 기본적인 방법이다. 대부분의 RPC 엔드포인트는 HTTP를 지원한다.
use alloy::providers::{Provider, ProviderBuilder};
#[tokio::main]
async fn main() -> eyre::Result<()> {
// 로컬 개발 환경 (Anvil 또는 Hardhat)
let provider = ProviderBuilder::new()
.on_http("http://localhost:8545".parse()?);
// Besu 프라이빗 체인
let provider = ProviderBuilder::new()
.on_http("http://besu-node:8545".parse()?);
// Infura (이더리움 메인넷)
let provider = ProviderBuilder::new()
.on_http("https://mainnet.infura.io/v3/YOUR_KEY".parse()?);
Ok(())
}
HTTP Provider는 단방향 요청/응답이다. 이벤트 구독이 필요하면 WebSocket을 사용해야 한다.
WebSocket Provider
실시간 이벤트 구독(블록 생성, 로그 등)에 필요하다.
use alloy::providers::{Provider, ProviderBuilder};
use alloy::transports::ws::WsConnect;
#[tokio::main]
async fn main() -> eyre::Result<()> {
let ws = WsConnect::new("ws://localhost:8546");
let provider = ProviderBuilder::new()
.on_ws(ws)
.await?;
// 새 블록 구독
let subscription = provider.subscribe_blocks().await?;
let mut stream = subscription.into_stream();
while let Some(block) = stream.next().await {
println!("새 블록: {}", block.number);
}
Ok(())
}
ProviderBuilder 패턴
ProviderBuilder는 Provider를 단계적으로 구성하는 빌더 패턴을 제공한다.
use alloy::providers::ProviderBuilder;
use alloy::signers::local::PrivateKeySigner;
// 읽기 전용 Provider (서명자 없음)
let read_provider = ProviderBuilder::new()
.on_http("http://localhost:8545".parse()?);
// 서명 가능한 Provider (트랜잭션 전송용)
let signer: PrivateKeySigner = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80".parse()?;
let wallet_provider = ProviderBuilder::new()
.with_recommended_fillers() // nonce, gas price 자동 관리
.wallet(EthereumWallet::from(signer))
.on_http("http://localhost:8545".parse()?);
with_recommended_fillers()는 다음을 자동으로 처리한다:
- Nonce 관리: 트랜잭션 nonce 자동 증가
- Gas 추정: 가스 한도 자동 추정
- Gas 가격: 현재 네트워크 가스 가격 자동 설정
읽기 호출 예제
잔액 조회
use alloy::providers::{Provider, ProviderBuilder};
use alloy::primitives::Address;
async fn get_balance_example() -> eyre::Result<()> {
let provider = ProviderBuilder::new()
.on_http("http://localhost:8545".parse()?);
let address: Address = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".parse()?;
// wei 단위로 반환됨
let balance = provider.get_balance(address).await?;
// ETH로 변환 (1 ETH = 10^18 wei)
let balance_eth = balance.to_string();
println!("잔액 (wei): {}", balance);
// 더 읽기 좋게 표시
let divisor = alloy::primitives::U256::from(1_000_000_000_000_000_000u128);
let eth_part = balance / divisor;
let wei_part = balance % divisor;
println!("잔액: {}.{:018} ETH", eth_part, wei_part);
Ok(())
}
블록 정보 조회
use alloy::providers::{Provider, ProviderBuilder};
use alloy::rpc::types::BlockNumberOrTag;
async fn get_block_example() -> eyre::Result<()> {
let provider = ProviderBuilder::new()
.on_http("http://localhost:8545".parse()?);
// 최신 블록 번호 조회
let block_number = provider.get_block_number().await?;
println!("현재 블록: {}", block_number);
// 특정 블록 정보 조회 (트랜잭션 해시만 포함)
let block = provider
.get_block_by_number(BlockNumberOrTag::Latest, false)
.await?
.expect("블록을 찾을 수 없음");
println!("블록 해시: {:?}", block.header.hash);
println!("타임스탬프: {}", block.header.timestamp);
println!("가스 사용량: {}", block.header.gas_used);
println!("트랜잭션 수: {}", block.transactions.len());
// 특정 번호의 블록 (트랜잭션 상세 포함)
let block_with_txs = provider
.get_block_by_number(BlockNumberOrTag::Number(12345), true)
.await?;
if let Some(block) = block_with_txs {
println!("블록 {} 트랜잭션:", block.header.number);
for tx in block.transactions.into_transactions() {
println!(" TX: {:?}", tx.hash);
}
}
Ok(())
}
트랜잭션 조회
use alloy::providers::{Provider, ProviderBuilder};
use alloy::primitives::B256;
async fn get_transaction_example() -> eyre::Result<()> {
let provider = ProviderBuilder::new()
.on_http("http://localhost:8545".parse()?);
let tx_hash: B256 = "0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060"
.parse()?;
// 트랜잭션 정보 조회
let tx = provider
.get_transaction_by_hash(tx_hash)
.await?
.expect("트랜잭션 없음");
println!("From: {:?}", tx.from);
println!("To: {:?}", tx.to);
println!("Value: {}", tx.value);
println!("Gas: {}", tx.gas);
println!("Nonce: {}", tx.nonce);
// 트랜잭션 영수증 (트랜잭션이 채굴된 후에만 존재)
let receipt = provider
.get_transaction_receipt(tx_hash)
.await?;
match receipt {
Some(r) => {
println!("상태: {}", if r.status() { "성공" } else { "실패" });
println!("가스 사용량: {}", r.gas_used);
println!("블록 번호: {:?}", r.block_number);
}
None => println!("아직 채굴되지 않음"),
}
Ok(())
}
컨트랙트 읽기 호출 (view 함수)
view 함수는 블록체인 상태를 변경하지 않는 읽기 전용 함수다. 가스가 들지 않고, 트랜잭션이 아닌 eth_call RPC로 처리된다.
use alloy::sol;
use alloy::providers::{Provider, ProviderBuilder};
use alloy::primitives::Address;
// ERC-20 인터페이스 정의
sol! {
interface IERC20 {
function name() external view returns (string);
function symbol() external view returns (string);
function decimals() external view returns (uint8);
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
}
}
async fn read_contract_example() -> eyre::Result<()> {
let provider = ProviderBuilder::new()
.on_http("http://localhost:8545".parse()?);
// USDC 컨트랙트 주소 (이더리움 메인넷)
let contract_address: Address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".parse()?;
// 컨트랙트 인스턴스 생성
let contract = IERC20::new(contract_address, &provider);
// view 함수 호출 - .call()을 사용
let name = contract.name().call().await?;
let symbol = contract.symbol().call().await?;
let decimals = contract.decimals().call().await?;
let total_supply = contract.totalSupply().call().await?;
println!("이름: {}", name._0);
println!("심볼: {}", symbol._0);
println!("소수점: {}", decimals._0);
println!("총 공급량: {}", total_supply._0);
// 특정 주소의 잔액 조회
let holder: Address = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".parse()?;
let balance = contract.balanceOf(holder).call().await?;
println!("잔액: {} (최소 단위)", balance._0);
Ok(())
}
sol! 매크로가 자동으로 IERC20::balanceOfCall 같은 타입을 생성한다. 반환값은 구조체이며, ._0, ._1 등으로 접근한다.
전체 코드 예제 (async main + 에러 처리)
실제 프로젝트에서 사용할 수 있는 완전한 예제다:
use alloy::{
primitives::{address, Address, U256},
providers::{Provider, ProviderBuilder},
rpc::types::BlockNumberOrTag,
sol,
};
use eyre::Result;
// ERC-20 ABI 정의
sol! {
#[sol(rpc)]
interface IERC20 {
function name() external view returns (string);
function symbol() external view returns (string);
function decimals() external view returns (uint8);
function balanceOf(address account) external view returns (uint256);
event Transfer(address indexed from, address indexed to, uint256 value);
}
}
struct BlockchainClient {
provider: alloy::providers::RootProvider<alloy::transports::http::Http<reqwest::Client>>,
}
impl BlockchainClient {
fn new(rpc_url: &str) -> Result<Self> {
let provider = ProviderBuilder::new()
.on_http(rpc_url.parse()?);
Ok(Self { provider })
}
async fn get_chain_info(&self) -> Result<()> {
let chain_id = self.provider.get_chain_id().await?;
let block_number = self.provider.get_block_number().await?;
println!("체인 ID: {}", chain_id);
println!("현재 블록: {}", block_number);
Ok(())
}
async fn get_eth_balance(&self, address: Address) -> Result<U256> {
let balance = self.provider.get_balance(address).await?;
Ok(balance)
}
async fn get_token_info(&self, token_address: Address) -> Result<()> {
let token = IERC20::new(token_address, &self.provider);
let name = token.name().call().await
.map_err(|e| eyre::eyre!("name() 호출 실패: {}", e))?;
let symbol = token.symbol().call().await
.map_err(|e| eyre::eyre!("symbol() 호출 실패: {}", e))?;
let decimals = token.decimals().call().await
.map_err(|e| eyre::eyre!("decimals() 호출 실패: {}", e))?;
println!("토큰: {} ({})", name._0, symbol._0);
println!("소수점: {}", decimals._0);
Ok(())
}
}
#[tokio::main]
async fn main() -> Result<()> {
// tracing으로 로그 초기화
tracing_subscriber::fmt::init();
let client = BlockchainClient::new("http://localhost:8545")?;
// 체인 정보
client.get_chain_info().await?;
// ETH 잔액 조회
let address: Address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".parse()?;
let balance = client.get_eth_balance(address).await?;
println!("ETH 잔액: {} wei", balance);
// 에러 처리 예시 - 잘못된 주소
match client.get_eth_balance(Address::ZERO).await {
Ok(balance) => println!("Zero 주소 잔액: {}", balance),
Err(e) => eprintln!("에러: {}", e),
}
Ok(())
}
ethers.js (Node.js)와의 코드 비교
Node.js 배경의 개발자를 위한 대응 코드 비교표다.
잔액 조회
// ethers.js (Node.js)
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("http://localhost:8545");
const balance = await provider.getBalance("0x742d35Cc6634C0532925a3b844Bc454e4438f44e");
console.log(ethers.formatEther(balance)); // "1.5"
// Alloy (Rust)
use alloy::providers::{Provider, ProviderBuilder};
use alloy::primitives::Address;
let provider = ProviderBuilder::new().on_http("http://localhost:8545".parse()?);
let address: Address = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".parse()?;
let balance = provider.get_balance(address).await?;
// U256로 반환됨, 직접 포맷팅 필요
블록 조회
// ethers.js
const block = await provider.getBlock("latest");
console.log(block.number, block.hash, block.timestamp);
// Alloy
use alloy::rpc::types::BlockNumberOrTag;
let block = provider
.get_block_by_number(BlockNumberOrTag::Latest, false)
.await?
.unwrap();
println!("{} {:?} {}", block.header.number, block.header.hash, block.header.timestamp);
컨트랙트 읽기
// ethers.js
const abi = ["function balanceOf(address) view returns (uint256)"];
const contract = new ethers.Contract(tokenAddress, abi, provider);
const balance = await contract.balanceOf(userAddress);
// Alloy - sol! 매크로로 타입 안전한 호출
sol! {
interface IERC20 {
function balanceOf(address) external view returns (uint256);
}
}
let contract = IERC20::new(token_address, &provider);
let result = contract.balanceOf(user_address).call().await?;
let balance = result._0; // U256 타입
주요 차이점 정리
| 항목 | ethers.js | Alloy |
|---|---|---|
| 비동기 | Promise / async-await | Future / async-await |
| 에러 처리 | try/catch | Result<T, E> |
| 타입 안전성 | 런타임 ABI 디코딩 | 컴파일 타임 타입 검증 |
| 숫자 타입 | BigInt | U256 |
| 주소 타입 | string | Address (20바이트) |
| 컨트랙트 정의 | ABI 배열 | sol! 매크로 |
| 에러 메시지 | JS 예외 | Rust Result |
실습: 체인 상태 모니터링
use alloy::providers::{Provider, ProviderBuilder};
use std::time::Duration;
use tokio::time::sleep;
#[tokio::main]
async fn main() -> eyre::Result<()> {
let provider = ProviderBuilder::new()
.on_http("http://localhost:8545".parse()?);
println!("블록 모니터링 시작...");
let mut last_block = provider.get_block_number().await?;
loop {
sleep(Duration::from_secs(2)).await;
let current_block = provider.get_block_number().await?;
if current_block > last_block {
for block_num in (last_block + 1)..=current_block {
let block = provider
.get_block_by_number(block_num.into(), false)
.await?;
if let Some(b) = block {
println!(
"블록 {}: {} TX, 가스 {}",
block_num,
b.transactions.len(),
b.header.gas_used
);
}
}
last_block = current_block;
}
}
}
요약
- HTTP Provider: 단순 읽기 호출에 적합,
ProviderBuilder::new().on_http(url) - WebSocket Provider: 실시간 이벤트 구독에 필요,
.on_ws(ws).await? get_balance(): ETH 잔액을 wei 단위로 반환get_block_by_number(): 블록 정보 조회get_transaction_by_hash(): 트랜잭션 정보 조회contract.view_fn().call().await?: 컨트랙트 view 함수 호출with_recommended_fillers()로 nonce, gas를 자동 관리
다음 장에서는 서명자를 추가하여 실제 트랜잭션을 전송하는 방법을 배운다.
19-2: 트랜잭션 서명과 전송
왜 서명이 필요한가
블록체인에서 상태를 변경하는 모든 행위는 트랜잭션이다. 트랜잭션은 반드시 개인키로 서명되어야 한다. 서명이 없으면 누구나 다른 사람의 이름으로 트랜잭션을 보낼 수 있기 때문이다.
서명 과정:
1. 트랜잭션 데이터 구성 (to, value, data, gas, nonce 등)
2. 데이터를 해시 (keccak256)
3. 개인키로 해시에 ECDSA 서명
4. 서명된 트랜잭션을 노드에 전송
5. 노드가 서명 검증 후 블록에 포함
Node.js에서는 보통 ethers.Wallet으로 서명을 처리한다. Alloy에서는 PrivateKeySigner와 EthereumWallet을 사용한다.
로컬 서명자 (PrivateKeySigner)
PrivateKeySigner는 개인키를 메모리에 보관하는 가장 간단한 서명 방식이다. 프로덕션에서는 HSM이나 KMS를 사용해야 하지만, 개발과 학습에는 이것으로 충분하다.
use alloy::signers::local::PrivateKeySigner;
use alloy::signers::Signer;
use alloy::primitives::Address;
fn create_signer_examples() -> eyre::Result<()> {
// 방법 1: 16진수 개인키 문자열로 생성
let signer: PrivateKeySigner =
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
.parse()?;
// 방법 2: 랜덤 키 생성 (테스트용)
let random_signer = PrivateKeySigner::random();
// 방법 3: 환경변수에서 읽기 (권장)
let private_key = std::env::var("PRIVATE_KEY")
.expect("PRIVATE_KEY 환경변수가 없습니다");
let signer_from_env: PrivateKeySigner = private_key.parse()?;
// 서명자의 주소 확인
println!("서명자 주소: {}", signer.address());
// 체인 ID 설정 (EIP-155 replay protection)
let signer_with_chain = signer.with_chain_id(Some(1337)); // 로컬 개발용 체인 ID
Ok(())
}
보안 주의: 절대로 개인키를 소스코드에 하드코딩하지 말 것. 환경변수나 시크릿 관리 서비스를 사용하라.
서명자가 있는 Provider 생성
트랜잭션을 전송하려면 서명자를 Provider에 연결해야 한다.
use alloy::{
network::EthereumWallet,
providers::ProviderBuilder,
signers::local::PrivateKeySigner,
};
async fn create_wallet_provider() -> eyre::Result<()> {
let private_key = std::env::var("PRIVATE_KEY")?;
let signer: PrivateKeySigner = private_key.parse()?;
// EthereumWallet은 여러 서명자를 관리할 수 있음
let wallet = EthereumWallet::from(signer);
let provider = ProviderBuilder::new()
.with_recommended_fillers() // nonce, gas 자동 처리
.wallet(wallet)
.on_http("http://localhost:8545".parse()?);
Ok(())
}
with_recommended_fillers()는 다음 3가지 Filler를 포함한다:
- NonceFiller: 트랜잭션 nonce를 자동으로 관리
- GasFiller:
eth_estimateGas로 가스 한도 자동 추정 - ChainIdFiller: 체인 ID를 자동으로 설정
트랜잭션 구성
TransactionRequest
use alloy::{
network::TransactionBuilder,
primitives::{Address, U256},
rpc::types::TransactionRequest,
};
fn build_transaction() -> eyre::Result<TransactionRequest> {
let to: Address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".parse()?;
let tx = TransactionRequest::default()
.with_to(to)
.with_value(U256::from(1_000_000_000_000_000u128)) // 0.001 ETH
.with_gas_limit(21_000) // 단순 ETH 전송의 기본 가스
.with_max_fee_per_gas(20_000_000_000u128) // 20 Gwei
.with_max_priority_fee_per_gas(1_000_000_000u128); // 1 Gwei
Ok(tx)
}
트랜잭션 필드:
to: 수신 주소 (None이면 컨트랙트 배포)value: 전송할 ETH 양 (wei 단위)gas_limit: 허용할 최대 가스max_fee_per_gas: EIP-1559 최대 가스 가격 (wei/gas)max_priority_fee_per_gas: 검증자에게 주는 팁input/data: 컨트랙트 호출 데이터
가스 추정
with_recommended_fillers()를 사용하면 자동이지만, 직접 추정할 수도 있다:
use alloy::providers::Provider;
async fn estimate_gas_example(
provider: &impl Provider,
tx: &TransactionRequest,
) -> eyre::Result<u64> {
let gas_estimate = provider.estimate_gas(tx).await?;
// 안전 마진 20% 추가
let gas_with_buffer = gas_estimate * 120 / 100;
println!("추정 가스: {}", gas_estimate);
println!("버퍼 포함: {}", gas_with_buffer);
Ok(gas_with_buffer)
}
Nonce 관리
Nonce는 계정에서 발송한 트랜잭션의 순번이다. 0부터 시작하여 트랜잭션마다 1씩 증가한다. 같은 nonce로 두 트랜잭션을 보내면 하나만 처리된다.
use alloy::providers::Provider;
use alloy::primitives::Address;
async fn nonce_management(
provider: &impl Provider,
sender: Address,
) -> eyre::Result<u64> {
// 현재 nonce 조회 (확정된 트랜잭션 기준)
let nonce = provider.get_transaction_count(sender).await?;
println!("다음 nonce: {}", nonce);
// with_recommended_fillers()를 사용하면 자동으로 관리됨
// 수동으로 설정할 경우:
// tx.with_nonce(nonce)
Ok(nonce)
}
with_recommended_fillers()를 쓰면 nonce 관리가 자동이다. 하지만 빠른 연속 트랜잭션이나 특수한 경우에는 직접 관리가 필요할 수 있다.
트랜잭션 전송과 영수증 대기
use alloy::{
network::EthereumWallet,
primitives::{Address, U256},
providers::{Provider, ProviderBuilder},
signers::local::PrivateKeySigner,
};
async fn send_eth_transfer() -> eyre::Result<()> {
// 서명자 설정
let signer: PrivateKeySigner = std::env::var("PRIVATE_KEY")?.parse()?;
let wallet = EthereumWallet::from(signer);
let provider = ProviderBuilder::new()
.with_recommended_fillers()
.wallet(wallet)
.on_http("http://localhost:8545".parse()?);
let to: Address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".parse()?;
let value = U256::from(100_000_000_000_000_000u128); // 0.1 ETH
// 트랜잭션 전송
println!("트랜잭션 전송 중...");
let tx_hash = provider
.send_transaction(
alloy::rpc::types::TransactionRequest::default()
.with_to(to)
.with_value(value),
)
.await?;
println!("TX 해시: {:?}", tx_hash.tx_hash());
// 영수증 대기 (트랜잭션이 블록에 포함될 때까지)
println!("영수증 대기 중...");
let receipt = tx_hash.get_receipt().await?;
println!("블록 번호: {:?}", receipt.block_number);
println!("가스 사용량: {}", receipt.gas_used);
println!("상태: {}", if receipt.status() { "성공" } else { "실패" });
Ok(())
}
send_transaction()은 트랜잭션을 전송하고 PendingTransactionBuilder를 반환한다. .get_receipt()는 영수증이 올 때까지 폴링한다.
영수증 대기 옵션
use alloy::providers::PendingTransactionConfig;
// 기본: 1번 확인으로 완료 처리
let receipt = pending_tx.get_receipt().await?;
// 커스텀: 3번 블록 확인 후 완료
let receipt = pending_tx
.with_required_confirmations(3)
.with_timeout(Some(std::time::Duration::from_secs(60)))
.get_receipt()
.await?;
컨트랙트 쓰기 호출 (상태 변경 함수)
sol! 매크로로 정의된 컨트랙트의 상태 변경 함수를 호출하는 방법이다.
use alloy::{
network::EthereumWallet,
primitives::{Address, U256},
providers::ProviderBuilder,
signers::local::PrivateKeySigner,
sol,
};
// 상태 변경 함수가 있는 컨트랙트 정의
sol! {
#[sol(rpc)]
contract SimpleStorage {
uint256 public value;
function setValue(uint256 newValue) external;
function increment() external;
event ValueChanged(uint256 indexed oldValue, uint256 indexed newValue);
}
}
async fn write_contract_example() -> eyre::Result<()> {
let signer: PrivateKeySigner = std::env::var("PRIVATE_KEY")?.parse()?;
let wallet = EthereumWallet::from(signer);
let provider = ProviderBuilder::new()
.with_recommended_fillers()
.wallet(wallet)
.on_http("http://localhost:8545".parse()?);
let contract_address: Address = "0x5FbDB2315678afecb367f032d93F642f64180aa3".parse()?;
let contract = SimpleStorage::new(contract_address, &provider);
// 읽기 호출 (view 함수) - .call()
let current_value = contract.value().call().await?;
println!("현재 값: {}", current_value._0);
// 쓰기 호출 (상태 변경) - .send()
let new_value = U256::from(42u64);
println!("값 설정 중: {}", new_value);
let pending_tx = contract.setValue(new_value).send().await?;
println!("TX 해시: {:?}", pending_tx.tx_hash());
// 영수증 대기
let receipt = pending_tx.get_receipt().await?;
println!("트랜잭션 완료. 블록: {:?}", receipt.block_number);
// 이벤트 로그 파싱
for log in &receipt.inner.logs() {
if let Ok(event) = SimpleStorage::ValueChanged::decode_log(log.inner.as_ref(), true) {
println!("이벤트: {} → {}", event.oldValue, event.newValue);
}
}
// 변경된 값 확인
let updated_value = contract.value().call().await?;
println!("업데이트된 값: {}", updated_value._0);
Ok(())
}
핵심 차이:
.call(): view/pure 함수, 가스 없음, 즉시 결과 반환.send(): 상태 변경 함수, 가스 필요, PendingTransaction 반환
전체 코드 예제: ERC-20 전송
실제 ERC-20 토큰 전송을 포함한 완전한 예제다:
use alloy::{
network::EthereumWallet,
primitives::{Address, U256},
providers::{Provider, ProviderBuilder},
signers::local::PrivateKeySigner,
sol,
};
use eyre::Result;
sol! {
#[sol(rpc)]
contract ERC20 {
string public name;
string public symbol;
uint8 public decimals;
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
}
async fn transfer_erc20(
token_address: Address,
recipient: Address,
amount: U256,
) -> Result<()> {
// 서명자 설정
let private_key = std::env::var("PRIVATE_KEY")
.map_err(|_| eyre::eyre!("PRIVATE_KEY 환경변수 없음"))?;
let signer: PrivateKeySigner = private_key
.parse()
.map_err(|e| eyre::eyre!("개인키 파싱 실패: {}", e))?;
let sender_address = signer.address();
let wallet = EthereumWallet::from(signer);
let provider = ProviderBuilder::new()
.with_recommended_fillers()
.wallet(wallet)
.on_http("http://localhost:8545".parse()?);
let token = ERC20::new(token_address, &provider);
// 전송 전 잔액 확인
let sender_balance = token.balanceOf(sender_address).call().await?;
println!("내 잔액: {}", sender_balance._0);
if sender_balance._0 < amount {
return Err(eyre::eyre!("잔액 부족: {} < {}", sender_balance._0, amount));
}
// 전송
println!("{} 토큰을 {} 에게 전송 중...", amount, recipient);
let pending = token
.transfer(recipient, amount)
.send()
.await
.map_err(|e| eyre::eyre!("전송 실패: {}", e))?;
let tx_hash = *pending.tx_hash();
println!("TX 해시: {:?}", tx_hash);
// 영수증 대기
let receipt = pending
.get_receipt()
.await
.map_err(|e| eyre::eyre!("영수증 대기 실패: {}", e))?;
if !receipt.status() {
return Err(eyre::eyre!("트랜잭션 실패 (reverted)"));
}
println!("전송 성공!");
println!("블록: {:?}", receipt.block_number);
println!("가스 사용: {}", receipt.gas_used);
// Transfer 이벤트 파싱
for log in receipt.inner.logs() {
if let Ok(transfer) = ERC20::Transfer::decode_log(log.inner.as_ref(), true) {
println!(
"이벤트 Transfer: {} → {} : {}",
transfer.from, transfer.to, transfer.value
);
}
}
// 전송 후 잔액 확인
let new_balance = token.balanceOf(sender_address).call().await?;
println!("전송 후 내 잔액: {}", new_balance._0);
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let token_address: Address = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512".parse()?;
let recipient: Address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".parse()?;
let amount = U256::from(1_000_000u64); // 1 토큰 (decimals=6 가정)
transfer_erc20(token_address, recipient, amount).await?;
Ok(())
}
트랜잭션 에러 처리 패턴
컨트랙트 호출이 revert되는 경우를 처리하는 방법:
use alloy::contract::Error as ContractError;
use alloy::transports::RpcError;
async fn robust_contract_call(contract: &ERC20, to: Address, amount: U256) -> Result<()> {
match contract.transfer(to, amount).send().await {
Ok(pending) => {
let receipt = pending.get_receipt().await?;
if receipt.status() {
println!("성공");
} else {
// 트랜잭션이 실행되었지만 revert됨
eprintln!("트랜잭션 revert됨. TX: {:?}", receipt.transaction_hash);
return Err(eyre::eyre!("트랜잭션 revert"));
}
}
Err(e) => {
// RPC 레벨 에러 (예: 가스 부족, 인코딩 에러)
eprintln!("트랜잭션 전송 실패: {}", e);
return Err(eyre::eyre!("전송 실패: {}", e));
}
}
Ok(())
}
platform 프로젝트의 트랜잭션 패턴
platform의 iksan-api 서비스에서 블록체인에 해시를 기록하는 실제 패턴:
// apps/iksan-api/src/services/blockchain.rs 패턴
pub struct BlockchainService {
provider: Arc<dyn Provider>,
contract_address: Address,
wallet: EthereumWallet,
}
impl BlockchainService {
pub async fn record_trace_hash(
&self,
record_id: &str,
data_hash: [u8; 32],
) -> Result<B256> {
// TraceRecord 컨트랙트 호출
let contract = TraceRecord::new(self.contract_address, &self.provider);
let pending = contract
.recordHash(record_id.to_string(), FixedBytes::from(data_hash))
.send()
.await
.map_err(|e| AppError::BlockchainError(e.to_string()))?;
let tx_hash = *pending.tx_hash();
// 영수증 대기 (비즈니스 로직에 따라 필요 여부 결정)
let receipt = pending.get_receipt().await
.map_err(|e| AppError::BlockchainError(e.to_string()))?;
if !receipt.status() {
return Err(AppError::BlockchainError("컨트랙트 call revert됨".to_string()));
}
Ok(tx_hash)
}
}
요약
트랜잭션 서명과 전송의 핵심:
- PrivateKeySigner: 개인키로 서명자 생성
- EthereumWallet: 서명자를 감싸는 지갑
with_recommended_fillers(): nonce, gas 자동 관리.send(): 상태 변경 트랜잭션 전송 →PendingTransaction.get_receipt(): 영수증 대기 (트랜잭션 확정 확인).call(): view 함수, 트랜잭션 없음
다음 장에서는 sol! 매크로를 더 깊이 이해한다.
19-3: sol! 매크로 - Solidity ABI를 Rust 타입으로
sol! 매크로란
sol!은 Alloy가 제공하는 절차적 매크로(procedural macro)다. Solidity 코드나 ABI JSON을 파싱하여 컴파일 타임에 대응하는 Rust 타입을 자동 생성한다.
이것이 왜 강력한가? 런타임에 ABI를 해석하는 대신, 컴파일 타임에 타입을 확인한다. 잘못된 함수 인자를 전달하면 컴파일 에러가 발생한다. Node.js의 ethers.js는 런타임까지 이런 오류를 발견하지 못한다.
ethers.js 방식:
contract.transfer(address, amount) ← 런타임에 ABI 인코딩, 타입 체크 없음
sol! 매크로 방식:
contract.transfer(address, amount) ← 컴파일 타임에 타입 검증
// address 자리에 U256을 넣으면 컴파일 에러!
인라인 Solidity 코드에서 타입 생성
가장 일반적인 방법이다. Solidity 인터페이스나 컨트랙트 코드를 그대로 Rust 파일에 작성한다.
기본 사용법
use alloy::sol;
// 단순 인터페이스
sol! {
interface ICounter {
function count() external view returns (uint256);
function increment() external;
function decrement() external;
function reset(uint256 value) external;
}
}
// 컨트랙트 (상태 변수 포함)
sol! {
contract Counter {
uint256 public count;
address public owner;
constructor(address _owner);
function increment() external;
function getCount() external view returns (uint256);
event Incremented(address indexed by, uint256 newCount);
error NotOwner(address caller);
}
}
생성되는 타입들
sol! 매크로는 다음 Rust 타입들을 자동 생성한다:
sol! {
contract Counter {
function increment() external;
function getCount() external view returns (uint256);
function reset(uint256 value) external;
event Incremented(address indexed by, uint256 newCount);
}
}
// 위 sol! 매크로가 생성하는 것들:
// 1. 함수 호출 타입 (각 함수마다)
Counter::incrementCall // 인자 없음
Counter::getCountCall // 인자 없음
Counter::resetCall { value: U256 } // 인자 있음
// 2. 함수 반환 타입
Counter::getCountReturn { _0: U256 }
// 3. 이벤트 타입
Counter::Incremented { by: Address, newCount: U256 }
// 4. 에러 타입
Counter::NotOwner { caller: Address }
// 5. 컨트랙트 인스턴스 (with #[sol(rpc)] 속성 시)
Counter::new(address, provider) // ContractInstance 반환
#[sol(rpc)] 속성
RPC 호출을 위한 메서드를 생성하려면 #[sol(rpc)] 속성이 필요하다:
use alloy::sol;
sol! {
#[sol(rpc)] // 이 속성이 있어야 .call(), .send() 메서드가 생성됨
contract Counter {
uint256 public count;
function increment() external;
function getCount() external view returns (uint256);
event Incremented(address indexed by, uint256 newCount);
}
}
// #[sol(rpc)]가 있으면:
let contract = Counter::new(address, &provider);
let count = contract.getCount().call().await?; // 가능
let tx = contract.increment().send().await?; // 가능
// #[sol(rpc)]가 없으면 ABI 인코딩만 가능:
let call_data = Counter::incrementCall {}.abi_encode(); // 가능
// contract.increment().call() // 불가능
복잡한 타입 처리
Solidity의 구조체, 배열, 매핑을 처리하는 방법:
use alloy::sol;
sol! {
#[sol(rpc)]
contract TraceRecord {
struct Record {
bytes32 dataHash;
uint256 timestamp;
address recorder;
bool verified;
}
mapping(string => Record) public records;
string[] public recordIds;
function addRecord(string calldata id, bytes32 hash) external;
function getRecord(string calldata id) external view returns (Record memory);
function verifyRecord(string calldata id, bytes32 hash) external view returns (bool);
function getAllIds() external view returns (string[] memory);
event RecordAdded(
string indexed id,
bytes32 hash,
address indexed recorder,
uint256 timestamp
);
error RecordNotFound(string id);
error DuplicateRecord(string id);
}
}
// 생성된 타입 사용
async fn use_trace_record(
contract: &TraceRecord::TraceRecordInstance<_, _>,
) -> eyre::Result<()> {
// 구조체 반환값 처리
let record = contract.getRecord("batch-001".to_string()).call().await?;
// Record 구조체 필드 접근
println!("해시: {:?}", record._0.dataHash);
println!("타임스탬프: {}", record._0.timestamp);
println!("기록자: {}", record._0.recorder);
println!("검증됨: {}", record._0.verified);
Ok(())
}
JSON ABI 파일에서 타입 생성
컨트랙트 ABI를 JSON 파일로 가지고 있을 때 사용한다. Foundry로 컴파일하면 out/ 디렉토리에 JSON이 생성된다.
ABI JSON 파일 구조
Foundry가 생성하는 ABI JSON 파일 (out/TraceRecord.sol/TraceRecord.json):
{
"abi": [
{
"type": "function",
"name": "addRecord",
"inputs": [
{"name": "id", "type": "string"},
{"name": "hash", "type": "bytes32"}
],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "event",
"name": "RecordAdded",
"inputs": [
{"name": "id", "type": "string", "indexed": true},
{"name": "hash", "type": "bytes32", "indexed": false}
]
}
],
"bytecode": {
"object": "0x608060..."
}
}
JSON ABI 파일에서 로드
use alloy::sol;
// 방법 1: ABI만 있는 JSON
sol!(
#[sol(rpc)]
TraceRecord,
"abi/TraceRecord.json" // ABI 배열만 있는 파일
);
// 방법 2: Foundry 출력 파일 (ABI + bytecode 포함)
sol!(
#[sol(rpc)]
TraceRecord,
"out/TraceRecord.sol/TraceRecord.json"
);
// 방법 3: 환경에 따라 경로 지정
sol!(
#[sol(rpc)]
MyContract,
concat!(env!("CARGO_MANIFEST_DIR"), "/abi/MyContract.json")
);
Cargo.toml에 ABI 파일 경로 설정
# build.rs가 ABI 파일 변경 시 재컴파일하도록
[package.metadata]
abi-dir = "abi/"
# 또는 build.rs 작성
// build.rs
fn main() {
// ABI 파일이 바뀌면 재컴파일
println!("cargo:rerun-if-changed=abi/");
}
생성된 타입으로 컨트랙트 호출
실제로 생성된 타입을 어떻게 활용하는지 보여주는 상세 예제:
use alloy::{
primitives::{Address, FixedBytes, U256},
sol,
sol_types::SolEvent,
};
sol! {
#[sol(rpc)]
contract TraceRecord {
struct Record {
bytes32 dataHash;
uint256 timestamp;
address recorder;
}
function addRecord(string calldata id, bytes32 hash) external returns (uint256 recordIndex);
function getRecord(string calldata id) external view returns (Record memory);
function recordExists(string calldata id) external view returns (bool);
event RecordAdded(string indexed id, bytes32 hash, uint256 timestamp);
error RecordAlreadyExists(string id);
}
}
// ABI 인코딩 직접 사용 (Provider 없이)
fn encode_call_data() {
// 함수 호출 데이터 인코딩
let call = TraceRecord::addRecordCall {
id: "batch-001".to_string(),
hash: FixedBytes::from([0x42u8; 32]),
};
use alloy::sol_types::SolCall;
let encoded: Vec<u8> = call.abi_encode();
println!("인코딩된 calldata: 0x{}", hex::encode(&encoded));
// 반환값 디코딩
let return_data: Vec<u8> = vec![/* raw bytes from node */];
// let decoded = TraceRecord::addRecordReturn::abi_decode(&return_data, true)?;
}
// 이벤트 로그 파싱
fn parse_event_log(log: &alloy::rpc::types::Log) -> Option<TraceRecord::RecordAdded> {
use alloy::sol_types::SolEvent;
TraceRecord::RecordAdded::decode_log(log.inner.as_ref(), true).ok()
}
이벤트 필터링과 로그 파싱
이벤트(Event)는 Solidity에서 emit으로 발생시키는 로그다. 블록체인에 영구적으로 저장되며 Rust에서 필터링하여 읽을 수 있다.
과거 이벤트 조회
use alloy::{
primitives::Address,
providers::{Provider, ProviderBuilder},
rpc::types::{Filter, FilterBlockOption},
sol,
sol_types::SolEvent,
};
sol! {
#[sol(rpc)]
contract ERC20Token {
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
}
async fn get_past_transfer_events(
provider: &impl Provider,
token_address: Address,
from_block: u64,
) -> eyre::Result<Vec<ERC20Token::Transfer>> {
// 이벤트 필터 구성
let filter = Filter::new()
.address(token_address)
.event_signature(ERC20Token::Transfer::SIGNATURE_HASH)
.from_block(from_block)
.to_block(alloy::rpc::types::BlockNumberOrTag::Latest);
// 로그 조회
let logs = provider.get_logs(&filter).await?;
println!("Transfer 이벤트 {}개 발견", logs.len());
// 로그 파싱
let mut events = Vec::new();
for log in logs {
match ERC20Token::Transfer::decode_log(log.inner.as_ref(), true) {
Ok(transfer) => {
println!(
"Transfer: {} → {} : {}",
transfer.from, transfer.to, transfer.value
);
events.push(transfer);
}
Err(e) => {
eprintln!("로그 파싱 실패: {}", e);
}
}
}
Ok(events)
}
특정 주소가 관련된 이벤트만 필터링
async fn get_transfers_for_address(
provider: &impl Provider,
token_address: Address,
user_address: Address,
) -> eyre::Result<()> {
use alloy::primitives::B256;
// indexed 파라미터로 필터링
// Transfer(address indexed from, address indexed to, uint256 value)
// from이 user_address인 이벤트만
let topic1: B256 = user_address.into_word(); // from 필드
let filter = Filter::new()
.address(token_address)
.event_signature(ERC20Token::Transfer::SIGNATURE_HASH)
.topic1(topic1); // indexed 첫 번째 파라미터 (from)
let outgoing_logs = provider.get_logs(&filter).await?;
println!("발신 Transfer: {}개", outgoing_logs.len());
// to가 user_address인 이벤트만
let topic2: B256 = user_address.into_word(); // to 필드
let filter_incoming = Filter::new()
.address(token_address)
.event_signature(ERC20Token::Transfer::SIGNATURE_HASH)
.topic2(topic2); // indexed 두 번째 파라미터 (to)
let incoming_logs = provider.get_logs(&filter_incoming).await?;
println!("수신 Transfer: {}개", incoming_logs.len());
Ok(())
}
WebSocket으로 실시간 이벤트 구독
use alloy::providers::{Provider, ProviderBuilder};
use alloy::transports::ws::WsConnect;
use futures_util::StreamExt;
async fn subscribe_to_events(token_address: Address) -> eyre::Result<()> {
let ws = WsConnect::new("ws://localhost:8546");
let provider = ProviderBuilder::new().on_ws(ws).await?;
let filter = Filter::new()
.address(token_address)
.event_signature(ERC20Token::Transfer::SIGNATURE_HASH);
// 실시간 로그 구독
let subscription = provider.subscribe_logs(&filter).await?;
let mut stream = subscription.into_stream();
println!("Transfer 이벤트 구독 시작...");
while let Some(log) = stream.next().await {
if let Ok(transfer) = ERC20Token::Transfer::decode_log(log.inner.as_ref(), true) {
println!(
"새 Transfer: {} → {} : {}",
transfer.from, transfer.to, transfer.value
);
}
}
Ok(())
}
전체 예제: ERC-20 컨트랙트와 상호작용하는 Rust 클라이언트
use alloy::{
network::EthereumWallet,
primitives::{address, Address, U256},
providers::{Provider, ProviderBuilder},
rpc::types::Filter,
signers::local::PrivateKeySigner,
sol,
sol_types::SolEvent,
};
use eyre::Result;
// 전체 ERC-20 ABI 정의
sol! {
#[sol(rpc)]
contract ERC20 {
// 상태 변수 (자동으로 getter 생성됨)
string public name;
string public symbol;
uint8 public decimals;
uint256 public totalSupply;
// 조회 함수
function balanceOf(address account) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
// 상태 변경 함수
function transfer(address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
// 이벤트
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
// 에러
error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed);
error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed);
}
}
pub struct Erc20Client {
contract: ERC20::ERC20Instance<
alloy::transports::http::Http<reqwest::Client>,
alloy::providers::fillers::FillProvider<
alloy::providers::fillers::JoinFill<
alloy::providers::Identity,
alloy::providers::fillers::JoinFill<
alloy::providers::fillers::GasFiller,
alloy::providers::fillers::JoinFill<
alloy::providers::fillers::BlobGasFiller,
alloy::providers::fillers::JoinFill<
alloy::providers::fillers::NonceFiller,
alloy::providers::fillers::ChainIdFiller,
>,
>,
>,
>,
alloy::providers::fillers::WalletFiller<EthereumWallet>,
alloy::network::Ethereum,
>,
alloy::network::Ethereum,
>,
token_address: Address,
}
// 타입이 너무 복잡할 때는 Box<dyn Provider>나 Arc<dyn Provider>를 사용하는 것도 방법
// 실제 코드에서는 제네릭으로 처리하는 경우가 많음
/// 더 실용적인 구조 - 제네릭 사용
pub struct SimpleErc20Client<P: Provider> {
provider: P,
token_address: Address,
}
impl<P: Provider + Clone> SimpleErc20Client<P> {
pub fn new(provider: P, token_address: Address) -> Self {
Self { provider, token_address }
}
fn contract(&self) -> ERC20::ERC20Instance<P::Transport, &P, alloy::network::Ethereum>
where
P::Transport: Clone,
{
ERC20::new(self.token_address, &self.provider)
}
/// 토큰 기본 정보 조회
pub async fn get_info(&self) -> Result<TokenInfo> where P::Transport: Clone {
let contract = self.contract();
let name = contract.name().call().await?.name;
let symbol = contract.symbol().call().await?.symbol;
let decimals = contract.decimals().call().await?.decimals;
let total_supply = contract.totalSupply().call().await?.totalSupply;
Ok(TokenInfo { name, symbol, decimals, total_supply })
}
/// 잔액 조회
pub async fn balance_of(&self, account: Address) -> Result<U256>
where P::Transport: Clone {
let result = self.contract().balanceOf(account).call().await?;
Ok(result._0)
}
/// 전송
pub async fn transfer(&self, to: Address, amount: U256) -> Result<alloy::primitives::B256>
where P::Transport: Clone {
let pending = self.contract()
.transfer(to, amount)
.send()
.await?;
let tx_hash = *pending.tx_hash();
let receipt = pending.get_receipt().await?;
if !receipt.status() {
return Err(eyre::eyre!("전송 실패 (revert)"));
}
Ok(tx_hash)
}
/// 과거 Transfer 이벤트 조회
pub async fn get_transfer_history(
&self,
from_block: u64,
) -> Result<Vec<TransferEvent>> where P::Transport: Clone {
let filter = Filter::new()
.address(self.token_address)
.event_signature(ERC20::Transfer::SIGNATURE_HASH)
.from_block(from_block);
let logs = self.provider.get_logs(&filter).await?;
let mut transfers = Vec::new();
for log in logs {
if let Ok(event) = ERC20::Transfer::decode_log(log.inner.as_ref(), true) {
transfers.push(TransferEvent {
from: event.from,
to: event.to,
value: event.value,
block_number: log.block_number.unwrap_or(0),
tx_hash: log.transaction_hash.unwrap_or_default(),
});
}
}
Ok(transfers)
}
}
#[derive(Debug)]
pub struct TokenInfo {
pub name: String,
pub symbol: String,
pub decimals: u8,
pub total_supply: U256,
}
#[derive(Debug)]
pub struct TransferEvent {
pub from: Address,
pub to: Address,
pub value: U256,
pub block_number: u64,
pub tx_hash: alloy::primitives::B256,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter("info")
.init();
// 서명자 설정 (쓰기 작업용)
let private_key = std::env::var("PRIVATE_KEY")
.unwrap_or_else(|_| {
// 개발용 Anvil 기본 키
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80".to_string()
});
let signer: PrivateKeySigner = private_key.parse()?;
let my_address = signer.address();
let wallet = EthereumWallet::from(signer);
let provider = ProviderBuilder::new()
.with_recommended_fillers()
.wallet(wallet)
.on_http("http://localhost:8545".parse()?);
// 토큰 주소 (로컬 Anvil에 배포된 테스트 토큰 가정)
let token_address: Address = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512".parse()?;
let client = SimpleErc20Client::new(provider, token_address);
// 1. 토큰 정보 조회
match client.get_info().await {
Ok(info) => {
println!("=== 토큰 정보 ===");
println!("이름: {}", info.name);
println!("심볼: {}", info.symbol);
println!("소수점: {}", info.decimals);
println!("총 공급량: {}", info.total_supply);
}
Err(e) => eprintln!("토큰 정보 조회 실패: {}", e),
}
// 2. 잔액 조회
match client.balance_of(my_address).await {
Ok(balance) => println!("\n내 잔액: {}", balance),
Err(e) => eprintln!("잔액 조회 실패: {}", e),
}
// 3. 전송
let recipient: Address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".parse()?;
let amount = U256::from(1_000u64);
println!("\n{} 토큰 전송 중...", amount);
match client.transfer(recipient, amount).await {
Ok(tx_hash) => println!("전송 성공: {:?}", tx_hash),
Err(e) => eprintln!("전송 실패: {}", e),
}
// 4. 이벤트 이력 조회
println!("\n=== Transfer 이벤트 이력 ===");
match client.get_transfer_history(0).await {
Ok(events) => {
for event in events {
println!(
"블록 {}: {} → {} : {}",
event.block_number, event.from, event.to, event.value
);
}
}
Err(e) => eprintln!("이벤트 조회 실패: {}", e),
}
Ok(())
}
자주 발생하는 문제
1. 반환 타입 접근
// sol! 생성 타입의 반환값은 구조체임
let result = contract.balanceOf(addr).call().await?;
// result는 ERC20::balanceOfReturn { _0: U256 }
// 단일 반환값: ._0으로 접근
let balance: U256 = result._0;
// 여러 반환값:
// function getInfo() returns (string name, uint256 value)
// → result.name, result.value
2. bytes32 처리
use alloy::primitives::FixedBytes;
// bytes32는 FixedBytes<32>로 매핑됨
let hash: FixedBytes<32> = FixedBytes::from([0u8; 32]);
// &[u8; 32] 변환
let raw: [u8; 32] = *hash;
// Vec<u8>에서 변환
let data: Vec<u8> = vec![1, 2, 3]; // 반드시 32바이트여야 함
// let hash = FixedBytes::<32>::try_from(data.as_slice())?;
3. string 처리
// Solidity string → Rust String
let result = contract.getName().call().await?;
let name: String = result._0; // 이미 String
// Rust String → Solidity string (함수 인자)
let id = "batch-001".to_string();
contract.getRecord(id).call().await?;
// &str도 자동 변환됨
contract.getRecord("batch-001".to_string()).call().await?;
요약
sol! 매크로의 핵심:
- 인라인 Solidity: 소스에 직접 ABI 작성, 간단하고 명확
- JSON ABI 파일: Foundry 출력과 연동, 빌드 파이프라인에 통합 가능
#[sol(rpc)]: RPC 호출 메서드 생성 (없으면 ABI 인코딩만).call(): view 함수 → 즉시 결과.send(): 상태 변경 함수 → PendingTransaction- 이벤트:
Filter+decode_log()또는 WebSocket 구독
다음 장(20장)부터는 Hyperledger Besu와 프라이빗 체인을 다룬다.
20장: 프라이빗 체인과 엔터프라이즈 블록체인
프라이빗 체인이란
프라이빗 체인(Private Chain)은 허가된 참여자만 접근할 수 있는 블록체인이다. 이더리움 메인넷처럼 누구나 참여할 수 있는 퍼블릭 체인과 달리, 프라이빗 체인은 운영자가 누가 노드를 운영하고, 누가 트랜잭션을 제출할 수 있는지를 통제한다.
“블록체인“이라는 단어에서 많은 개발자들이 비트코인이나 이더리움 같은 퍼블릭 체인만 떠올리지만, 실제 기업 환경에서는 프라이빗 체인이 훨씬 실용적인 경우가 많다.
퍼블릭 체인: 누구나 참여 → 탈중앙화 → 검열 저항
프라이빗 체인: 허가된 참여 → 효율성 → 기업 제어
platform 프로젝트가 바로 이 프라이빗 체인을 사용한다. 식품 공급망 데이터의 무결성을 보증하면서도, 비즈니스 기밀을 보호하고 규제 요구사항을 충족하기 위해서다.
왜 프라이빗 체인이 존재하는가
기업 데이터 프라이버시
퍼블릭 체인에 올라간 데이터는 전 세계 누구나 볼 수 있다. 기업 입장에서 공급업체 계약 조건, 가격 정보, 생산량 데이터를 공개하는 것은 경쟁력 손실로 직결된다.
프라이빗 체인에서는:
- 데이터 가시성을 세밀하게 제어 가능
- Hyperledger Besu의 프라이빗 트랜잭션 기능으로 특정 참여자만 데이터 열람 가능
- 허가된 노드만 블록체인 네트워크에 참여
규제 준수
금융, 의료, 식품 안전 분야는 엄격한 규제를 받는다:
- GDPR (유럽 개인정보보호법): 데이터 삭제권 → 퍼블릭 체인의 불변성과 충돌
- HACCP (식품 안전): 이력 추적 의무화 → 블록체인으로 자연스럽게 충족
- 금융 규제: KYC/AML → 허가된 참여자만 접근 필요
프라이빗 체인은 규제 기관이 요구하는 감사 능력을 제공하면서도, 일반 대중의 접근을 제한할 수 있다.
성능 요구사항
퍼블릭 이더리움의 처리량은 약 15-30 TPS(초당 트랜잭션)이다. 기업 애플리케이션은 이보다 훨씬 높은 처리량이 필요할 수 있다.
| 방식 | TPS | 확정 시간 |
|---|---|---|
| 이더리움 메인넷 | 15-30 | 12초 (1블록) |
| Besu IBFT 2.0 | 100-1000+ | 1-2초 |
| Hyperledger Fabric | 수천 | 초 미만 |
프라이빗 체인은 신뢰할 수 있는 검증자들로만 구성되므로, 복잡한 작업증명(PoW) 없이 빠른 합의가 가능하다.
비용 절감
이더리움 메인넷에서 트랜잭션을 보내면 가스비를 ETH로 지불한다. 2021년 NFT 붐 시기에는 단순한 트랜잭션 하나에 수만 원이 들기도 했다.
프라이빗 체인에서는:
- 가스 가격을 0으로 설정 가능 (platform이 이렇게 함)
- 트랜잭션 비용 없음
- 블록체인의 무결성 보증은 그대로 유지
퍼블릭 vs 프라이빗 vs 컨소시엄 체인 비교표
| 특성 | 퍼블릭 체인 | 프라이빗 체인 | 컨소시엄 체인 |
|---|---|---|---|
| 참여자 | 누구나 | 운영사 단독 | 허가된 조직 그룹 |
| 예시 | 이더리움, 비트코인 | 사내 Besu 체인 | R3 Corda, Quorum |
| 합의 | PoW, PoS | IBFT, QBFT, Raft | PBFT, IBFT |
| TPS | 낮음 (15-30) | 높음 (100-1000+) | 높음 |
| 데이터 공개성 | 완전 공개 | 비공개 | 참여자 간 공유 |
| 탈중앙화 | 높음 | 낮음 | 중간 |
| 검열 저항성 | 높음 | 없음 | 낮음 |
| 가스비 | 실제 비용 발생 | 0으로 설정 가능 | 거의 없음 |
| EVM 호환 | 예 (이더리움) | 예 (Besu) | 가능 (Quorum) |
| 기업 채택 | 낮음 | 높음 | 높음 |
| 스마트 컨트랙트 | Solidity | Solidity (Besu) | 다양 |
| 노드 수 | 수천~수만 | 수~수십 | 수십~수백 |
퍼블릭 체인의 장점
퍼블릭 체인은 진정한 탈중앙화와 검열 저항성을 제공한다. 특정 기관이 데이터를 조작하거나 삭제할 수 없다. DeFi(탈중앙화 금융), NFT, DAO 같은 새로운 가능성을 열어준다.
하지만 기업용으로 사용하기에는:
- 높은 트랜잭션 비용
- 낮은 처리량
- 데이터 프라이버시 없음
- 규제 불확실성
프라이빗 체인의 장점
프라이빗 체인은 “블록체인의 기술적 특성(불변성, 감사 추적)“을 기업 환경에 맞게 적용한다:
- 빠른 트랜잭션
- 비용 없음
- 데이터 제어 가능
- EVM 호환 시 기존 Solidity 코드 재사용
약점은 중앙화다. 운영사가 이론적으로 체인을 조작할 수 있다. 하지만 컨소시엄 구성이나 외부 감사로 이를 보완할 수 있다.
컨소시엄 체인: 중간 지점
컨소시엄 체인은 여러 독립적인 조직이 함께 블록체인을 운영한다. 식품 공급망의 경우 생산자, 유통업자, 소매업자, 정부기관이 각각 노드를 운영하면, 어느 한 참여자가 단독으로 데이터를 조작할 수 없다.
platform이 미래에 확장된다면 컨소시엄 모델이 될 수 있다:
현재: platform 회사 단독 운영 프라이빗 체인
미래: 농업인 조합 + 유통업자 + 정부기관이 각각 노드 운영
언제 무엇을 선택할까
의사결정 트리:
데이터를 누구나 볼 수 있어야 하는가?
YES → 퍼블릭 체인 (이더리움, Solana)
NO ↓
여러 독립 조직이 관리해야 하는가?
YES → 컨소시엄 체인 (Hyperledger Fabric, Quorum)
NO ↓
단일 조직이지만 감사 추적이 필요한가?
YES → 프라이빗 체인 (Besu, Quorum)
NO → 그냥 데이터베이스 사용
platform의 선택
platform은 프라이빗 Besu 체인을 선택했다:
- 식품 데이터 기밀성: 농업인의 수확량, 유통 경로 등은 경쟁에 민감한 정보
- EVM 호환: 팀이 알고 있는 Solidity를 그대로 사용
- 가스비 0: 고객에게 트랜잭션 비용 부과 불필요
- 빠른 확정: IBFT 2.0으로 1-2초 내 트랜잭션 확정
- 감사 추적: 식품 안전 규제 충족을 위한 불변 이력
하지만 모든 데이터를 체인에 올리지 않는다. 큰 데이터는 PostgreSQL에, 그 해시만 체인에 기록한다. 이 패턴을 다음 장(20-2)에서 자세히 다룬다.
요약
- 프라이빗 체인: 허가된 참여자만 접근, 기업 데이터 프라이버시 + 빠른 성능
- 존재 이유: 기업 데이터 보호, 규제 준수, 높은 처리량, 비용 절감
- vs 퍼블릭: 탈중앙화 낮지만 실용성 높음
- vs 컨소시엄: 단일 조직 통제, 컨소시엄은 여러 조직 공동 운영
- platform의 선택: Besu 프라이빗 체인 = EVM 호환 + 가스비 0 + 데이터 프라이버시
다음 장에서는 Hyperledger Besu를 구체적으로 알아본다.
20-1: Hyperledger Besu - 엔터프라이즈 이더리움 클라이언트
Hyperledger Besu란
Hyperledger Besu는 Linux Foundation의 Hyperledger 프로젝트 중 하나로, Java로 작성된 엔터프라이즈급 Ethereum 클라이언트다. 2019년에 PegaSys(ConsenSys의 자회사)가 개발하여 Hyperledger에 기증했다.
“엔터프라이즈급“이라는 말의 의미:
- 이더리움 메인넷과 완전히 호환 (같은 EVM, 같은 JSON-RPC API)
- 기업용 추가 기능: 프라이빗 트랜잭션, 권한 관리, 모니터링
- 안정성과 지원 체계 (엔터프라이즈 버전 존재)
Besu는 Geth(Go Ethereum)와 동일한 이더리움 프로토콜을 구현하지만, 기업 환경에 필요한 기능을 추가로 제공한다.
주요 특징
1. 퍼블릭/프라이빗 네트워크 모두 지원
Besu 노드 하나로 이더리움 메인넷에 연결하거나, 완전히 독립된 프라이빗 네트워크를 구성할 수 있다.
# 이더리움 메인넷 클라이언트로 실행
besu --network=mainnet --sync-mode=SNAP
# 완전히 새로운 프라이빗 네트워크 시작
besu --genesis-file=genesis.json --network-id=1337
같은 소프트웨어, 같은 Solidity 코드를 두 환경 모두에서 사용할 수 있다는 것이 핵심이다.
2. 합의 알고리즘
Besu는 여러 합의 알고리즘을 지원한다:
IBFT 2.0 (Istanbul Byzantine Fault Tolerant)
- 프라이빗/컨소시엄 체인에서 가장 많이 사용
- 검증자(validator) 집합이 블록을 생성하고 합의
- 즉시 확정성(finality): 한 번 확정된 블록은 번복 불가
- BFT(Byzantine Fault Tolerant): 검증자 1/3 미만이 악의적으로 행동해도 안전
QBFT (Quorum Byzantine Fault Tolerant)
- IBFT 2.0의 개선 버전
- EIP 표준 준수, 더 나은 성능
- 새 프로젝트에 권장됨
Clique (Proof of Authority)
- 간단한 PoA 구현
- 개발/테스트 환경에 적합
- 블록 생성자가 순번대로 돌아가며 서명
Ethash (Proof of Work)
- 이더리움 원래 합의 방식 (이제 Merge로 deprecated)
- 테스트 목적으로만 사용
platform은 IBFT 2.0을 사용한다.
3. 프라이빗 트랜잭션 (Tessera 연동)
Besu는 Tessera라는 별도 프라이버시 관리자와 연동하여 프라이빗 트랜잭션을 지원한다.
일반 트랜잭션: 모든 노드가 데이터를 볼 수 있음
프라이빗 트랜잭션: 지정된 참여자만 payload를 복호화 가능
예시: A 농장의 생산 원가 데이터를 B 유통업자와만 공유하고, C 소매업자는 볼 수 없게 하는 것이 가능하다.
platform은 현재 Tessera를 사용하지 않지만, 확장 시 도입 가능한 옵션이다.
4. 권한 관리
Besu는 온체인/오프체인 권한 관리를 지원한다:
{
"permissioning": {
"nodes-allowlist": [
"enode://abc123@192.168.1.1:30303",
"enode://def456@192.168.1.2:30303"
],
"accounts-allowlist": [
"0x627306090abaB3A6e1400e9345bC60c78a8BEf57"
]
}
}
- 노드 권한: 특정 노드만 네트워크 참여 가능
- 계정 권한: 특정 주소만 트랜잭션 제출 가능
platform에서는 계정 권한 관리로 승인된 서비스 계정만 컨트랙트에 쓸 수 있도록 제한한다.
platform이 Besu를 사용하는 이유
식품 공급망 데이터의 기밀성
농업인의 재배 방법, 수확량, 약품 사용 이력 등은 비즈니스 기밀이다. 이더리움 메인넷에 올리면 경쟁사도 볼 수 있다.
Besu 프라이빗 체인에서는:
- 네트워크 참여자를 통제
- 필요시 Tessera로 특정 참여자만 데이터 열람 가능
- 규제 기관에게는 감사 접근권 부여 가능
EVM 호환 - 같은 Solidity 컨트랙트 사용
가장 큰 장점 중 하나다. Besu는 완전한 EVM 구현을 포함하므로, 이더리움 메인넷용으로 작성된 Solidity 코드를 그대로 배포할 수 있다.
이더리움 메인넷: TraceRecord.sol 배포 → 동작
Besu 프라이빗: TraceRecord.sol 배포 → 동일하게 동작
Foundry로 컴파일하고, Alloy로 상호작용하는 코드도 변경이 거의 없다. RPC 엔드포인트 URL만 바꾸면 된다.
가스 비용 0으로 설정
genesis.json에서 가스 가격을 0으로 설정할 수 있다:
{
"config": {
"chainId": 1337,
"berlinBlock": 0,
"ibft2": {
"blockperiodseconds": 2,
"epochlength": 30000,
"requesttimeoutseconds": 4
}
},
"gasLimit": "0x1fffffffffffff",
"difficulty": "0x1",
"alloc": {
"0xfe3b557e8fb62b89f4916b721be55ceb828dbd73": {
"privateKey": "...",
"balance": "0xad78ebc5ac6200000"
}
}
}
그리고 min-gas-price=0 옵션으로 가스 가격 0인 트랜잭션을 허용한다:
besu \
--genesis-file=genesis.json \
--min-gas-price=0 \
--rpc-http-enabled \
--rpc-http-cors-origins="all"
이렇게 하면 플랫폼 운영사가 가스 비용을 부담하지 않아도 되고, 사용자에게도 추가 비용이 없다.
Docker로 로컬 Besu 네트워크 실행하기
개발 환경에서 Besu IBFT 2.0 네트워크를 Docker로 구성하는 방법 개요:
디렉토리 구조
besu-network/
├── docker-compose.yml
├── genesis.json
├── node1/
│ ├── data/
│ └── key # 노드 개인키
├── node2/
│ ├── data/
│ └── key
├── node3/
│ ├── data/
│ └── key
└── node4/
├── data/
└── key
genesis.json (IBFT 2.0)
{
"config": {
"chainId": 1337,
"berlinBlock": 0,
"londonBlock": 0,
"ibft2": {
"blockperiodseconds": 2,
"epochlength": 30000,
"requesttimeoutseconds": 4
}
},
"nonce": "0x0",
"timestamp": "0x58ee40ba",
"extraData": "0xf87aa00000000000000000000000000000000000000000000000000000000000000000f854940000000000000000000000000000000000000001940000000000000000000000000000000000000002940000000000000000000000000000000000000003940000000000000000000000000000000000000004c080a00000000000000000000000000000000000000000000000000000000000000000880000000000000000",
"gasLimit": "0x1fffffffffffff",
"difficulty": "0x1",
"mixHash": "0x63746963616c2062797a616e74696e65206661756c7420746f6c6572616e6365",
"coinbase": "0x0000000000000000000000000000000000000000",
"alloc": {
"0xfe3b557e8fb62b89f4916b721be55ceb828dbd73": {
"balance": "0xad78ebc5ac6200000"
},
"0x627306090abaB3A6e1400e9345bC60c78a8BEf57": {
"balance": "0xad78ebc5ac6200000"
}
},
"number": "0x0",
"gasUsed": "0x0",
"parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000"
}
docker-compose.yml
version: '3.8'
services:
bootnode:
image: hyperledger/besu:latest
container_name: besu-bootnode
command: >
--genesis-file=/config/genesis.json
--node-private-key-file=/config/node1/key
--rpc-http-enabled
--rpc-http-api=ETH,NET,IBFT,WEB3
--host-allowlist="*"
--rpc-http-cors-origins="all"
--min-gas-price=0
--p2p-port=30303
--rpc-http-port=8545
volumes:
- ./genesis.json:/config/genesis.json
- ./node1:/config/node1
- ./node1/data:/var/lib/besu
ports:
- "8545:8545"
- "30303:30303"
networks:
- besu-network
validator1:
image: hyperledger/besu:latest
container_name: besu-validator1
depends_on:
- bootnode
command: >
--genesis-file=/config/genesis.json
--node-private-key-file=/config/node2/key
--bootnodes=enode://<bootnode-enode>@bootnode:30303
--min-gas-price=0
--p2p-port=30303
--rpc-http-enabled
--rpc-http-port=8546
volumes:
- ./genesis.json:/config/genesis.json
- ./node2:/config/node2
- ./node2/data:/var/lib/besu
ports:
- "8546:8546"
networks:
- besu-network
validator2:
image: hyperledger/besu:latest
container_name: besu-validator2
depends_on:
- bootnode
command: >
--genesis-file=/config/genesis.json
--node-private-key-file=/config/node3/key
--bootnodes=enode://<bootnode-enode>@bootnode:30303
--min-gas-price=0
--p2p-port=30303
volumes:
- ./genesis.json:/config/genesis.json
- ./node3:/config/node3
- ./node3/data:/var/lib/besu
networks:
- besu-network
validator3:
image: hyperledger/besu:latest
container_name: besu-validator3
depends_on:
- bootnode
command: >
--genesis-file=/config/genesis.json
--node-private-key-file=/config/node4/key
--bootnodes=enode://<bootnode-enode>@bootnode:30303
--min-gas-price=0
--p2p-port=30303
volumes:
- ./genesis.json:/config/genesis.json
- ./node4:/config/node4
- ./node4/data:/var/lib/besu
networks:
- besu-network
networks:
besu-network:
driver: bridge
네트워크 시작
# 검증자 키 생성
besu operator generate-blockchain-config \
--config-file=ibftConfigFile.json \
--to=networkFiles \
--private-key-file-name=key
# 네트워크 시작
docker-compose up -d
# 상태 확인
curl -X POST \
http://localhost:8545 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"ibft_getValidatorsByBlockNumber","params":["latest"],"id":1}'
IBFT 2.0 합의 상세
IBFT 2.0(Istanbul Byzantine Fault Tolerant 2.0)은 프라이빗 Ethereum 네트워크에서 가장 많이 사용되는 합의 알고리즘이다.
검증자(Validator)란
검증자는 블록을 제안하고 서명할 권한을 가진 노드다. IBFT 2.0에서는 검증자 집합이 미리 정의되어 있으며, 동적으로 추가/제거가 가능하다.
검증자 집합 = {V1, V2, V3, V4}
일반 노드 = 블록체인을 동기화하지만 블록 생성에 참여 안 함
블록 생성 라운드
각 블록 생성은 “라운드“를 통해 이루어진다:
라운드 1:
1. 제안자(Proposer) 선택: 블록 높이에 따라 순번 결정
(높이 % 검증자 수 = 제안자 인덱스)
2. 블록 제안: 제안자가 새 블록을 네트워크에 브로드캐스트
3. PREPARE 단계: 다른 검증자들이 제안 받고 PREPARE 메시지 전송
4. COMMIT 단계: 2/3 초과 PREPARE 수신 시 COMMIT 메시지 전송
5. 블록 확정: 2/3 초과 COMMIT 수신 시 블록이 체인에 추가됨
2/3 이상 동의 필요
BFT 알고리즘의 핵심이다:
검증자 수: N
허용 가능한 결함 노드: f < N/3
필요한 동의: > 2N/3
예시 (검증자 4개):
4 * 2/3 = 2.67 → 3개 이상 동의 필요
즉, 1개 노드가 고장나거나 악의적이어도 합의 가능
예시 (검증자 7개):
7 * 2/3 = 4.67 → 5개 이상 동의 필요
즉, 2개 노드까지 허용
즉시 확정성(Immediate Finality)
IBFT 2.0의 중요한 특성이다. 블록이 한 번 체인에 추가되면 절대로 번복되지 않는다.
이더리움 PoS에서는 “확정(finality)“에 2-3 에포크(~13분)가 필요하다. 그 전까지는 이론적으로 체인 재구성이 가능하다.
IBFT 2.0에서는:
블록 추가 = 즉시 확정
영수증을 받으면 = 영원히 확정
platform 같은 비즈니스 애플리케이션에서 이것은 매우 중요하다. “이 트랜잭션이 정말로 확정됐나요?“를 걱정할 필요가 없다.
타임아웃과 라운드 변경
제안자가 응답하지 않거나 잘못된 블록을 제안하면, 타임아웃 후 다음 라운드로 넘어간다:
"ibft2": {
"blockperiodseconds": 2, // 블록 생성 주기 (초)
"epochlength": 30000, // 검증자 재선출 주기 (블록 수)
"requesttimeoutseconds": 4 // 라운드 타임아웃 (초)
}
blockperiodseconds: 2: 2초마다 새 블록 시도requesttimeoutseconds: 4: 4초 안에 합의 실패 시 다음 라운드
검증자 추가/제거
IBFT 2.0은 온체인 투표로 검증자를 동적으로 관리한다:
# 새 검증자 추가 제안 (기존 검증자가 투표)
curl -X POST http://localhost:8545 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "ibft_proposeValidatorVote",
"params": ["0xNew_Validator_Address", true],
"id": 1
}'
# 현재 검증자 목록 조회
curl -X POST http://localhost:8545 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "ibft_getValidatorsByBlockNumber",
"params": ["latest"],
"id": 1
}'
과반수(>50%) 검증자가 동의하면 해당 주소가 검증자로 추가된다.
Besu 모니터링
Besu는 Prometheus 메트릭을 기본 지원한다:
besu \
--metrics-enabled \
--metrics-host=0.0.0.0 \
--metrics-port=9545
Grafana + Prometheus로 대시보드를 구성할 수 있다. 주요 메트릭:
- 블록 생성 시간
- 피어 연결 수
- 트랜잭션 풀 크기
- 가스 사용량
Alloy에서 Besu 연결
Besu 프라이빗 체인에 연결하는 것은 이더리움 메인넷 연결과 코드상 거의 동일하다:
use alloy::providers::{Provider, ProviderBuilder};
// 이더리움 메인넷 (코드)
let mainnet = ProviderBuilder::new()
.on_http("https://mainnet.infura.io/v3/KEY".parse()?);
// Besu 프라이빗 체인 (코드) - URL만 다름
let besu = ProviderBuilder::new()
.on_http("http://besu-node:8545".parse()?);
// 체인 ID 확인 (Besu는 genesis.json에서 설정한 ID)
let chain_id = besu.get_chain_id().await?;
println!("Besu 체인 ID: {}", chain_id); // 1337
// 검증자 목록 조회 (Besu 전용 API)
// raw JSON-RPC 호출
let validators: serde_json::Value = besu
.raw_request(
"ibft_getValidatorsByBlockNumber".into(),
serde_json::json!(["latest"]),
)
.await?;
요약
Hyperledger Besu:
- Java 기반 엔터프라이즈 Ethereum 클라이언트
- EVM 호환: 같은 Solidity, 같은 Alloy 코드 사용
- IBFT 2.0: 빠른 BFT 합의, 즉시 확정성
- 가스비 0:
--min-gas-price=0으로 무료 트랜잭션 - 권한 관리: 노드/계정 화이트리스트
- 프라이빗 트랜잭션: Tessera 연동으로 선택적 공개
platform이 Besu를 선택한 이유: EVM 호환성 + 빠른 확정 + 가스비 0 + 데이터 프라이버시
다음 장에서는 퍼블릭 vs 프라이빗 체인을 더 깊이 비교하고, “왜 DB 대신 블록체인?“이라는 질문에 답한다.
20-2: 퍼블릭 vs 프라이빗 - 심층 비교와 설계 결정
10가지 기준으로 상세 비교
| # | 기준 | 퍼블릭 체인 (이더리움) | 프라이빗 체인 (Besu IBFT) | 비고 |
|---|---|---|---|---|
| 1 | 접근 권한 | 누구나 읽기/쓰기 가능 | 허가된 주소/노드만 | Besu 계정 allowlist |
| 2 | 합의 방식 | PoS (검증자 ~100만 명) | IBFT 2.0 (검증자 4-21개) | BFT vs 경제적 보안 |
| 3 | 처리 속도 | 15-30 TPS, 12초/블록 | 수백~수천 TPS, 1-2초/블록 | 검증자가 적어 빠름 |
| 4 | 트랜잭션 비용 | ETH 가스비 (수천~수만 원) | 0 (min-gas-price=0) | 기업 운영 비용 절감 |
| 5 | 데이터 프라이버시 | 완전 공개 (누구나 조회) | 제한적 (허가된 노드만) | Tessera로 더 강화 가능 |
| 6 | 탈중앙화 정도 | 매우 높음 | 낮음 (운영사 통제) | 신뢰 모델이 다름 |
| 7 | 검열 저항성 | 매우 높음 | 없음 (운영사가 제어) | 트레이드오프 |
| 8 | 확정성 | ~13분 (2 에포크) | 즉시 (IBFT 특성) | 비즈니스 UX에 중요 |
| 9 | 스마트 컨트랙트 | Solidity, Vyper | Solidity (EVM 호환) | 코드 재사용 가능 |
| 10 | 운영 복잡도 | 노드 직접 운영 불필요 | 자체 노드 운영 필요 | DevOps 부담 증가 |
각 기준의 의미
접근 권한: 퍼블릭 체인에서는 개인키만 있으면 누구나 트랜잭션을 보낼 수 있다. 프라이빗 체인에서는 운영사가 승인한 주소만 가능하다. platform에서는 iksan-api 서비스 계정만 TraceRecord 컨트랙트에 데이터를 쓸 수 있다.
합의 방식: 이더리움 PoS는 경제적 인센티브(슬래싱)로 검증자를 정직하게 만든다. IBFT는 신원이 알려진 소수의 검증자가 BFT 프로토콜로 합의한다. 후자는 법적 책임이 있는 기업 환경에서 충분히 안전하다.
확정성: 이더리움 메인넷에서 트랜잭션이 “안전“하려면 12번 이상 블록이 쌓이기를 기다리는 게 일반적이다 (약 2.5분). IBFT에서는 영수증이 오면 즉시 확정이다. 사용자 경험 측면에서 큰 차이다.
“PostgreSQL에 저장하면 되는데 왜 블록체인?”
이 질문은 블록체인 프로젝트에서 가장 자주 듣는 비판이다. 솔직하게 답해보자.
데이터베이스의 한계
일반 PostgreSQL:
- 데이터 저장: O
- 빠른 조회: O
- ACID 트랜잭션: O
- 관리자가 데이터 수정 가능: O ← 이것이 문제
- 감사 로그 위변조 가능: O ← 이것이 문제
- 독립적 제3자 검증: X
데이터베이스 관리자(DBA)는 데이터를 수정할 수 있다. 백업을 교체할 수 있다. 로그를 삭제할 수 있다. 이것이 의도적인 설계이지만, 신뢰 문제를 만든다.
실제 사례: 식품 안전 사고가 발생했을 때, 회사 측이 내부 데이터를 수정했다는 의혹이 제기되었다. PostgreSQL 기반이라면 이 의혹을 완전히 반박하기 어렵다.
블록체인의 추가 가치
블록체인 (Besu):
- 데이터 저장: O (하지만 느리고 비쌈 → 해시만 저장)
- 빠른 조회: X (느림 → DB 병행)
- 불변성: O (블록 추가 후 변경 불가)
- 독립 검증: O (감사자가 자체 노드로 확인 가능)
- 시간 증명: O (타임스탬프 조작 불가)
- 참여자 합의 감사: O (누가, 언제 기록했는지 추적 가능)
블록체인은 “누가 이 데이터를 언제 기록했는가“를 독립적으로 검증 가능하게 만든다.
platform의 선택: 데이터는 DB에, 무결성 증명은 체인에
platform은 두 가지를 조합한다:
PostgreSQL (빠른 읽기/쓰기):
- 실제 이벤트 데이터 (농산물 수확 정보, 유통 경로 등)
- 전체 이력 조회
- 복잡한 쿼리 (JOIN, 필터, 정렬)
Besu 블록체인 (불변 증명):
- 각 이벤트의 keccak256 해시
- 타임스탬프
- 기록자 주소
- 트랜잭션 해시
나중에 누군가가 “이 데이터가 조작되지 않았음을 증명하라“고 하면:
- DB에서 원본 데이터를 가져옴
- 같은 방법으로 해시를 계산
- 체인에서 해당 해시를 조회
- 일치하면 → 데이터가 기록 시점 이후 변조되지 않았음을 증명
이것이 핵심 가치다. 데이터 자체를 체인에 올리는 것이 아니라, 데이터의 지문(해시)을 올리는 것이다.
하이브리드 아키텍처 패턴
패턴 1: 오프체인 데이터 + 온체인 해시
platform이 사용하는 패턴이다.
┌─────────────────────────────────────────────────────────┐
│ iksan-api 서비스 │
│ │
│ 1. 이벤트 생성 │
│ event = { id, type, data, timestamp } │
│ │
│ 2. 해시 계산 │
│ hash = keccak256(json(event)) │
│ │
│ 3. DB 저장 │
│ INSERT INTO trace_events (id, data, hash, ...) │
│ │
│ 4. 블록체인 기록 │
│ TraceRecord.recordHash(event_id, hash) │
│ │
│ 5. TX 해시 DB에 저장 │
│ UPDATE trace_events SET tx_hash = ... │
└─────────────────┬───────────────────────────────────────┘
│
┌─────────┴─────────┐
│ │
┌────▼────┐ ┌────▼────┐
│PostgreSQL│ │ Besu │
│ │ │ │
│이벤트 데이터│ │ 해시값 │
│(수백 KB) │ │(32바이트)│
└─────────┘ └─────────┘
왜 모든 데이터를 체인에 올리지 않는가?
- 체인 저장 비용: 1바이트 = 680 gas (이더리움 메인넷 기준 매우 비쌈)
- 프라이빗 체인이라도 블록 크기 제한 존재
- 조회 속도: 체인 조회보다 DB 조회가 100배 이상 빠름
- 프라이버시: 민감 데이터는 DB 접근 제어로 보호
32바이트 해시는 사실상 비용이 없으면서, 원본 데이터의 무결성을 보증한다.
패턴 2: IPFS + 블록체인
대용량 파일(문서, 이미지)의 경우:
파일 → IPFS 업로드 → CID(해시) 획득 → CID를 체인에 기록
IPFS CID 자체가 내용의 해시이므로, CID만 체인에 기록해도 파일 변조를 탐지할 수 있다.
platform은 현재 이 패턴을 사용하지 않지만, 인증서 파일이나 검사 보고서를 저장할 때 확장 가능하다.
패턴 3: 이벤트 소싱 + 블록체인
모든 상태 변경을 이벤트로 기록 → 이벤트를 블록체인에 저장
현재 상태 = 이벤트들의 순차 적용
이 패턴은 구현이 복잡하지만, 완전한 감사 추적이 가능하다. Hyperledger Fabric 같은 플랫폼에서 주로 사용한다.
platform의 정확한 패턴
// services/trace.rs의 핵심 로직 (의사코드)
pub async fn create_trace_event(
db: &PgPool,
blockchain: &BlockchainService,
input: CreateEventInput,
) -> Result<TraceEvent> {
// 1. 이벤트 데이터 구성
let event = TraceEvent {
id: Uuid::new_v4(),
event_type: input.event_type,
payload: input.payload,
created_at: Utc::now(),
..Default::default()
};
// 2. 해시 계산 (keccak256)
let event_json = serde_json::to_string(&event)?;
let hash = keccak256(event_json.as_bytes());
// 3. DB에 저장 (빠른 쓰기)
let saved = sqlx::query_as!(
TraceEvent,
"INSERT INTO trace_events (id, event_type, payload, data_hash, created_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING *",
event.id,
event.event_type,
event.payload,
hash.as_slice(),
event.created_at,
)
.fetch_one(db)
.await?;
// 4. 블록체인에 해시 기록 (비동기 - 실패해도 재시도)
match blockchain.record_hash(&event.id.to_string(), hash).await {
Ok(tx_hash) => {
// 5. TX 해시 DB에 저장
sqlx::query!(
"UPDATE trace_events SET tx_hash = $1 WHERE id = $2",
tx_hash.as_slice(),
event.id
)
.execute(db)
.await?;
}
Err(e) => {
// 블록체인 기록 실패는 별도 큐에서 재시도
tracing::error!("블록체인 기록 실패: {}", e);
// 이벤트 자체는 이미 DB에 저장됨 (가용성 우선)
}
}
Ok(saved)
}
중요한 설계 결정: 블록체인 실패가 이벤트 생성을 막지 않는다. 가용성 우선이다. 실패한 블록체인 기록은 나중에 재시도한다.
언제 블록체인을 쓰는가 / 쓰지 않는가
의사결정 프레임워크
블록체인이 적합한 경우:
✅ 블록체인 사용 권장:
1. 여러 신뢰하지 않는 당사자 간 데이터 공유
예: 경쟁 관계에 있는 공급업체들이 재고 데이터 공유
2. 독립적 감사가 필요한 감사 추적
예: 식품 안전 이력 추적 (platform의 케이스)
3. 중개자 제거로 비용 절감
예: 무역 금융 (LC 처리)
4. 토큰화가 핵심인 경우
예: RWA(실물자산 토큰화), NFT
5. 탈중앙화 자율 조직(DAO) 거버넌스
❌ 블록체인 불필요:
1. 단일 조직 내부 데이터 관리
→ 그냥 PostgreSQL + 감사 로그 테이블
2. 빠른 읽기/쓰기가 필요한 트랜잭션 데이터
→ Redis, PostgreSQL
3. 대용량 파일 저장
→ S3, GCS (블록체인은 저장 비용이 극도로 높음)
4. 개인정보를 포함한 데이터 (GDPR 삭제권)
→ 체인에 올리면 삭제 불가
5. 빠른 프로토타이핑/MVP
→ 블록체인 통합은 복잡도를 크게 높임
3가지 핵심 질문
1. "제3자가 이 데이터를 독립적으로 검증해야 하는가?"
NO → 블록체인 불필요
YES → 다음 질문으로
2. "여러 신뢰하지 않는 당사자가 같은 데이터를 공유하는가?"
NO → 감사 로그 DB로 충분
YES → 다음 질문으로
3. "데이터의 불변성이 비즈니스 핵심 가치인가?"
NO → 블록체인 과잉설계
YES → 블록체인 사용 검토
platform은 이 세 질문 모두에 YES다:
- 식품 안전 감사 기관이 독립적으로 이력을 검증해야 함
- 농업인, 유통업자, 소매업자가 같은 이력을 공유
- “이 데이터가 변조되지 않았음“이 핵심 서비스 가치
흔한 오용 사례
오용 1: "내부 ERP를 블록체인으로 만들자"
→ 단일 회사 데이터, 수정 가능해야 함, 빠른 응답 필요
→ 그냥 ERP 사용
오용 2: "공급망 데이터를 모두 이더리움에 올리자"
→ 가스비 감당 불가, 민감 데이터 공개, 느림
→ 해시만 체인에, 데이터는 DB에
오용 3: "투표 시스템을 퍼블릭 체인으로"
→ 개인 투표 내용 공개 (익명성 훼손)
→ ZK-proof나 프라이빗 체인 고려
오용 4: "실시간 게임 상태를 블록체인에"
→ 블록체인은 초당 수십~수백 트랜잭션만 처리
→ 결과물만 체인에, 게임 로직은 서버에
실제 비교: 같은 기능을 두 방식으로 구현
방식 A: PostgreSQL만 사용
-- 감사 테이블
CREATE TABLE trace_events (
id UUID PRIMARY KEY,
data JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(42) NOT NULL
);
-- 변경 이력
CREATE TABLE audit_log (
id SERIAL PRIMARY KEY,
table_name VARCHAR(50),
row_id UUID,
operation VARCHAR(10),
old_data JSONB,
new_data JSONB,
changed_at TIMESTAMPTZ DEFAULT NOW(),
changed_by VARCHAR(100)
);
문제: DBA가 audit_log를 삭제할 수 있다. 회사 내부자가 데이터와 로그를 모두 조작할 수 있다.
방식 B: DB + 블록체인 해시 (platform 방식)
CREATE TABLE trace_events (
id UUID PRIMARY KEY,
data JSONB NOT NULL,
data_hash BYTEA NOT NULL, -- keccak256(data)
tx_hash BYTEA, -- 블록체인 TX 해시
block_number BIGINT, -- 기록된 블록 번호
created_at TIMESTAMPTZ DEFAULT NOW()
);
// 블록체인에는 해시만 저장
mapping(string => bytes32) public recordHashes;
function recordHash(string calldata id, bytes32 hash) external {
recordHashes[id] = hash;
emit HashRecorded(id, hash, block.timestamp);
}
이제 DBA가 DB의 data를 수정해도:
data_hash도 수정해야 하고- 블록체인의 해시도 바꿔야 하는데
- 블록체인 데이터는 수정 불가
→ 조작이 즉시 탐지된다.
요약
핵심 인사이트:
- 블록체인 = 신뢰 기계: 신뢰가 필요한 곳에만 사용
- 하이브리드가 정답: 데이터는 DB, 증명은 체인
- 해시 패턴: 32바이트 해시로 무한 크기 데이터를 증명
- 즉시 확정성: IBFT로 사용자 경험 개선
- 비용 0: 프라이빗 체인에서 가스 제거
“블록체인이 필요한가?“보다 “누가 누구를 신뢰하는가?“를 먼저 물어보라. 신뢰 문제가 없으면 데이터베이스로 충분하다.
다음 장(21장)에서는 이 모든 개념을 합쳐 미니 트레이서빌리티 서비스를 직접 구축한다.
21장: 미니프로젝트 - Platform 스타일 트레이서빌리티 서비스
프로젝트 개요
이 장에서는 platform 프로젝트의 핵심 패턴을 축소하여 직접 구현한다. 지금까지 배운 모든 것 - Rust, Axum, SQLx, Alloy, Solidity - 을 하나의 동작하는 서비스로 통합한다.
서비스 이름: mini-trace
핵심 기능:
- 이벤트(trace event)를 생성하면 PostgreSQL에 저장하고, 해시를 블록체인에 기록
- 이벤트를 조회하면 DB 데이터와 함께 온체인 검증 결과를 반환
- 별도 검증 엔드포인트로 DB와 체인 간 데이터 무결성을 확인
전체 요구사항
기능 요구사항
- TraceRecord.sol: 데이터 해시를 기록하고 검증하는 Solidity 컨트랙트
- Rust 백엔드 (Axum):
POST /events: 이벤트 생성 → keccak256 해시 계산 → DB 저장 → 체인 기록GET /events/:id: 이벤트 조회 + 온체인 해시 비교 결과 포함GET /events/:id/verify: DB 해시 vs 온체인 해시 비교, 무결성 검증
- SQLite: 개발 편의를 위해 SQLite 사용 (platform은 PostgreSQL)
- Alloy: TraceRecord 컨트랙트와 상호작용
비기능 요구사항
- 에러 처리: 블록체인 실패가 API 실패로 이어지지 않도록 (가용성 우선)
- 로깅:
tracing크레이트로 구조화된 로그 - 환경변수: 설정을 코드에서 분리
프로젝트 구조
mini-trace/
├── Cargo.toml
├── .env
├── contracts/
│ └── src/
│ └── TraceRecord.sol
├── abi/
│ └── TraceRecord.json ← Foundry로 컴파일한 ABI
├── migrations/
│ └── 001_create_events.sql
└── src/
├── main.rs ← Axum 서버 설정
├── config.rs ← 환경변수 설정
├── errors.rs ← 에러 타입
├── models.rs ← 데이터 모델
├── routes.rs ← 라우트 정의
└── services/
├── mod.rs
├── trace.rs ← 비즈니스 로직
└── blockchain.rs ← Alloy 연동
Solidity 컨트랙트
contracts/src/TraceRecord.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title TraceRecord
/// @notice 식품 공급망 이벤트의 데이터 해시를 불변 기록으로 저장
/// @dev platform의 TraceRecord.sol을 단순화한 버전
contract TraceRecord {
// 레코드 구조체
struct Record {
bytes32 dataHash; // 이벤트 데이터의 keccak256 해시
uint256 timestamp; // 기록된 블록 타임스탬프
address recorder; // 기록한 주소
bool exists; // 레코드 존재 여부 (초기값 false)
}
// 컨트랙트 소유자
address public owner;
// 이벤트 ID → 레코드 매핑
mapping(string => Record) private records;
// 모든 이벤트 ID 목록
string[] public eventIds;
// 이벤트
event HashRecorded(
string indexed eventId,
bytes32 dataHash,
address indexed recorder,
uint256 timestamp
);
event HashUpdated(
string indexed eventId,
bytes32 oldHash,
bytes32 newHash,
uint256 timestamp
);
// 에러
error NotOwner(address caller);
error RecordNotFound(string eventId);
error EmptyEventId();
error EmptyHash();
// 수정자
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner(msg.sender);
_;
}
constructor() {
owner = msg.sender;
}
/// @notice 이벤트 해시를 블록체인에 기록
/// @param eventId 이벤트 고유 ID (UUID 문자열)
/// @param dataHash 이벤트 데이터의 keccak256 해시
function recordHash(
string calldata eventId,
bytes32 dataHash
) external onlyOwner {
if (bytes(eventId).length == 0) revert EmptyEventId();
if (dataHash == bytes32(0)) revert EmptyHash();
bool isNew = !records[eventId].exists;
bytes32 oldHash = records[eventId].dataHash;
records[eventId] = Record({
dataHash: dataHash,
timestamp: block.timestamp,
recorder: msg.sender,
exists: true
});
if (isNew) {
eventIds.push(eventId);
emit HashRecorded(eventId, dataHash, msg.sender, block.timestamp);
} else {
emit HashUpdated(eventId, oldHash, dataHash, block.timestamp);
}
}
/// @notice 이벤트 해시 조회
/// @param eventId 이벤트 고유 ID
/// @return 레코드 구조체 (exists=false면 기록 없음)
function getRecord(string calldata eventId)
external
view
returns (Record memory)
{
return records[eventId];
}
/// @notice 제공된 해시가 기록된 해시와 일치하는지 검증
/// @param eventId 이벤트 고유 ID
/// @param dataHash 검증할 해시
/// @return true면 일치 (무결성 확인), false면 불일치 또는 미기록
function verifyHash(
string calldata eventId,
bytes32 dataHash
) external view returns (bool) {
Record memory record = records[eventId];
if (!record.exists) return false;
return record.dataHash == dataHash;
}
/// @notice 전체 이벤트 수 반환
function getEventCount() external view returns (uint256) {
return eventIds.length;
}
/// @notice 소유권 이전
function transferOwnership(address newOwner) external onlyOwner {
owner = newOwner;
}
}
컨트랙트 컴파일 (Foundry)
# Foundry 설치 (이미 설치되어 있다면 건너뜀)
curl -L https://foundry.paradigm.xyz | bash
foundryup
# 컨트랙트 컴파일
cd mini-trace/contracts
forge build
# ABI 파일 추출
cp out/TraceRecord.sol/TraceRecord.json ../abi/
# 로컬 Anvil에 배포
anvil & # 별도 터미널에서
forge create \
--rpc-url http://localhost:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
src/TraceRecord.sol:TraceRecord
Cargo.toml
[package]
name = "mini-trace"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "mini-trace"
path = "src/main.rs"
[dependencies]
# 웹 프레임워크
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }
# 블록체인
alloy = { version = "0.9", features = [
"providers",
"provider-http",
"contract",
"sol-types",
"json-abi",
"signers",
"signer-local",
"network",
"rpc-types",
"consensus",
] }
# 데이터베이스
sqlx = { version = "0.8", features = [
"sqlite",
"runtime-tokio-native-tls",
"uuid",
"chrono",
] }
# 직렬화
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# 에러 처리
anyhow = "1"
thiserror = "2"
# 로깅
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# 유틸리티
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
hex = "0.4"
sha3 = "0.10" # keccak256 for data hashing
dotenvy = "0.15"
데이터베이스 마이그레이션
migrations/001_create_events.sql
CREATE TABLE IF NOT EXISTS trace_events (
id TEXT PRIMARY KEY, -- UUID
event_type TEXT NOT NULL, -- "harvest", "transport", "inspection" 등
payload TEXT NOT NULL, -- JSON 데이터
data_hash TEXT NOT NULL, -- keccak256 해시 (hex 문자열)
tx_hash TEXT, -- 블록체인 TX 해시 (기록 후 채움)
block_number INTEGER, -- 기록된 블록 번호
created_at TEXT NOT NULL DEFAULT (datetime('now')),
recorded_at TEXT -- 체인에 기록된 시각
);
CREATE INDEX IF NOT EXISTS idx_trace_events_event_type ON trace_events(event_type);
CREATE INDEX IF NOT EXISTS idx_trace_events_created_at ON trace_events(created_at);
Rust 코드
src/config.rs
use anyhow::Result;
#[derive(Debug, Clone)]
pub struct Config {
pub database_url: String,
pub rpc_url: String,
pub private_key: String,
pub contract_address: String,
pub port: u16,
}
impl Config {
pub fn from_env() -> Result<Self> {
dotenvy::dotenv().ok();
Ok(Config {
database_url: std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite:mini-trace.db".to_string()),
rpc_url: std::env::var("RPC_URL")
.unwrap_or_else(|_| "http://localhost:8545".to_string()),
private_key: std::env::var("PRIVATE_KEY")
.unwrap_or_else(|_| {
// Anvil 기본 개인키 (절대 프로덕션에 사용 금지)
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
.to_string()
}),
contract_address: std::env::var("CONTRACT_ADDRESS")
.unwrap_or_else(|_| "0x5FbDB2315678afecb367f032d93F642f64180aa3".to_string()),
port: std::env::var("PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.unwrap_or(3000),
})
}
}
src/errors.rs
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("이벤트를 찾을 수 없음: {0}")]
NotFound(String),
#[error("데이터베이스 오류: {0}")]
Database(#[from] sqlx::Error),
#[error("블록체인 오류: {0}")]
Blockchain(String),
#[error("입력 오류: {0}")]
BadRequest(String),
#[error("내부 서버 오류: {0}")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::Database(e) => {
tracing::error!("DB 오류: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "데이터베이스 오류".to_string())
}
AppError::Blockchain(msg) => {
tracing::warn!("블록체인 오류: {}", msg);
(StatusCode::SERVICE_UNAVAILABLE, msg.clone())
}
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Internal(e) => {
tracing::error!("내부 오류: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "내부 서버 오류".to_string())
}
};
let body = Json(json!({
"error": message,
"status": status.as_u16()
}));
(status, body).into_response()
}
}
src/models.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// DB에 저장되는 이벤트
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct TraceEvent {
pub id: String,
pub event_type: String,
pub payload: String, // JSON 문자열
pub data_hash: String, // keccak256 hex
pub tx_hash: Option<String>, // 블록체인 TX 해시
pub block_number: Option<i64>,
pub created_at: String,
pub recorded_at: Option<String>,
}
/// 이벤트 생성 요청
#[derive(Debug, Deserialize)]
pub struct CreateEventRequest {
pub event_type: String,
pub payload: serde_json::Value,
}
/// 이벤트 응답 (온체인 검증 결과 포함)
#[derive(Debug, Serialize)]
pub struct TraceEventResponse {
pub id: String,
pub event_type: String,
pub payload: serde_json::Value,
pub data_hash: String,
pub tx_hash: Option<String>,
pub block_number: Option<i64>,
pub created_at: String,
pub blockchain_status: BlockchainStatus,
}
/// 온체인 검증 상태
#[derive(Debug, Serialize)]
pub struct BlockchainStatus {
pub recorded: bool, // 체인에 기록됐는가
pub hash_matches: Option<bool>, // 해시가 일치하는가
pub on_chain_timestamp: Option<u64>,
pub recorder_address: Option<String>,
}
/// 검증 응답
#[derive(Debug, Serialize)]
pub struct VerifyResponse {
pub event_id: String,
pub db_hash: String,
pub on_chain_hash: Option<String>,
pub is_valid: bool,
pub message: String,
}
src/services/blockchain.rs
use alloy::{
network::EthereumWallet,
primitives::{Address, FixedBytes},
providers::{Provider, ProviderBuilder},
signers::local::PrivateKeySigner,
sol,
};
use anyhow::Result;
use std::sync::Arc;
// TraceRecord 컨트랙트 ABI 정의
sol! {
#[sol(rpc)]
contract TraceRecord {
struct Record {
bytes32 dataHash;
uint256 timestamp;
address recorder;
bool exists;
}
function recordHash(string calldata eventId, bytes32 dataHash) external;
function getRecord(string calldata eventId) external view returns (Record memory);
function verifyHash(string calldata eventId, bytes32 dataHash) external view returns (bool);
function getEventCount() external view returns (uint256);
event HashRecorded(
string indexed eventId,
bytes32 dataHash,
address indexed recorder,
uint256 timestamp
);
error NotOwner(address caller);
error RecordNotFound(string eventId);
}
}
pub struct OnChainRecord {
pub data_hash: [u8; 32],
pub timestamp: u64,
pub recorder: String,
pub exists: bool,
}
pub struct BlockchainService {
contract_address: Address,
rpc_url: String,
private_key: String,
}
impl BlockchainService {
pub fn new(rpc_url: String, private_key: String, contract_address: String) -> Result<Self> {
let address: Address = contract_address.parse()
.map_err(|e| anyhow::anyhow!("컨트랙트 주소 파싱 실패: {}", e))?;
Ok(Self {
contract_address: address,
rpc_url,
private_key,
})
}
// 읽기 전용 Provider 생성
fn read_provider(&self) -> Result<impl Provider> {
let provider = ProviderBuilder::new()
.on_http(self.rpc_url.parse()
.map_err(|e| anyhow::anyhow!("RPC URL 파싱 실패: {}", e))?);
Ok(provider)
}
/// 해시를 블록체인에 기록
pub async fn record_hash(
&self,
event_id: &str,
data_hash: [u8; 32],
) -> Result<String> {
let signer: PrivateKeySigner = self.private_key.parse()
.map_err(|e| anyhow::anyhow!("개인키 파싱 실패: {}", e))?;
let wallet = EthereumWallet::from(signer);
let provider = ProviderBuilder::new()
.with_recommended_fillers()
.wallet(wallet)
.on_http(self.rpc_url.parse()
.map_err(|e| anyhow::anyhow!("RPC URL 파싱 실패: {}", e))?);
let contract = TraceRecord::new(self.contract_address, &provider);
let hash_bytes: FixedBytes<32> = FixedBytes::from(data_hash);
tracing::info!("블록체인에 해시 기록: event_id={}", event_id);
let pending = contract
.recordHash(event_id.to_string(), hash_bytes)
.send()
.await
.map_err(|e| anyhow::anyhow!("트랜잭션 전송 실패: {}", e))?;
let tx_hash = format!("{:?}", pending.tx_hash());
let receipt = pending.get_receipt().await
.map_err(|e| anyhow::anyhow!("영수증 대기 실패: {}", e))?;
if !receipt.status() {
return Err(anyhow::anyhow!("컨트랙트 호출이 revert됨"));
}
tracing::info!("해시 기록 완료: tx_hash={}", tx_hash);
Ok(tx_hash)
}
/// 온체인 레코드 조회
pub async fn get_record(&self, event_id: &str) -> Result<OnChainRecord> {
let provider = self.read_provider()?;
let contract = TraceRecord::new(self.contract_address, &provider);
let result = contract.getRecord(event_id.to_string()).call().await
.map_err(|e| anyhow::anyhow!("getRecord 호출 실패: {}", e))?;
let record = result._0;
Ok(OnChainRecord {
data_hash: *record.dataHash,
timestamp: record.timestamp.to::<u64>(),
recorder: format!("{}", record.recorder),
exists: record.exists,
})
}
/// 해시 검증
pub async fn verify_hash(
&self,
event_id: &str,
data_hash: [u8; 32],
) -> Result<bool> {
let provider = self.read_provider()?;
let contract = TraceRecord::new(self.contract_address, &provider);
let hash_bytes: FixedBytes<32> = FixedBytes::from(data_hash);
let result = contract
.verifyHash(event_id.to_string(), hash_bytes)
.call()
.await
.map_err(|e| anyhow::anyhow!("verifyHash 호출 실패: {}", e))?;
Ok(result._0)
}
}
src/services/trace.rs
use crate::{
errors::AppError,
models::{
BlockchainStatus, CreateEventRequest, TraceEvent, TraceEventResponse, VerifyResponse,
},
services::blockchain::BlockchainService,
};
use anyhow::Result;
use sha3::{Digest, Keccak256};
use sqlx::SqlitePool;
use std::sync::Arc;
use uuid::Uuid;
pub struct TraceService {
pub db: SqlitePool,
pub blockchain: Arc<BlockchainService>,
}
impl TraceService {
pub fn new(db: SqlitePool, blockchain: Arc<BlockchainService>) -> Self {
Self { db, blockchain }
}
/// 이벤트 생성: DB 저장 → 해시 계산 → 체인 기록
pub async fn create_event(
&self,
req: CreateEventRequest,
) -> Result<TraceEventResponse, AppError> {
if req.event_type.is_empty() {
return Err(AppError::BadRequest("event_type이 비어있습니다".to_string()));
}
let event_id = Uuid::new_v4().to_string();
let payload_str = serde_json::to_string(&req.payload)
.map_err(|e| AppError::BadRequest(format!("payload 직렬화 실패: {}", e)))?;
// 해시 계산: keccak256(event_id + event_type + payload)
let hash_input = format!("{}{}{}", event_id, req.event_type, payload_str);
let mut hasher = Keccak256::new();
hasher.update(hash_input.as_bytes());
let hash_bytes: [u8; 32] = hasher.finalize().into();
let data_hash_hex = hex::encode(hash_bytes);
// DB에 저장
sqlx::query!(
r#"
INSERT INTO trace_events (id, event_type, payload, data_hash, created_at)
VALUES (?1, ?2, ?3, ?4, datetime('now'))
"#,
event_id,
req.event_type,
payload_str,
data_hash_hex,
)
.execute(&self.db)
.await
.map_err(AppError::Database)?;
tracing::info!("이벤트 DB 저장 완료: id={}", event_id);
// 블록체인에 해시 기록 (실패해도 API는 성공)
let mut tx_hash = None;
let mut block_number = None;
match self.blockchain.record_hash(&event_id, hash_bytes).await {
Ok(hash) => {
tracing::info!("블록체인 기록 성공: tx_hash={}", hash);
tx_hash = Some(hash.clone());
// TX 해시 DB에 업데이트
sqlx::query!(
"UPDATE trace_events SET tx_hash = ?1, recorded_at = datetime('now') WHERE id = ?2",
hash,
event_id,
)
.execute(&self.db)
.await
.ok(); // 실패해도 무시
}
Err(e) => {
tracing::warn!("블록체인 기록 실패 (나중에 재시도): {}", e);
// 운영 버전에서는 outbox 테이블이나 메시지 큐에
// event_id와 data_hash를 저장해 백그라운드 워커가 재시도한다.
}
}
Ok(TraceEventResponse {
id: event_id,
event_type: req.event_type,
payload: req.payload,
data_hash: data_hash_hex,
tx_hash,
block_number,
created_at: chrono::Utc::now().to_rfc3339(),
blockchain_status: BlockchainStatus {
recorded: tx_hash.is_some(),
hash_matches: None,
on_chain_timestamp: None,
recorder_address: None,
},
})
}
/// 이벤트 조회 + 온체인 검증
pub async fn get_event(
&self,
event_id: &str,
) -> Result<TraceEventResponse, AppError> {
// DB에서 이벤트 조회
let event = sqlx::query_as!(
TraceEvent,
"SELECT * FROM trace_events WHERE id = ?1",
event_id,
)
.fetch_optional(&self.db)
.await
.map_err(AppError::Database)?
.ok_or_else(|| AppError::NotFound(format!("이벤트 없음: {}", event_id)))?;
// 온체인 상태 확인
let blockchain_status = match self.blockchain.get_record(event_id).await {
Ok(record) if record.exists => {
let db_hash_bytes = hex::decode(&event.data_hash)
.unwrap_or_default();
let hash_matches = db_hash_bytes == record.data_hash;
BlockchainStatus {
recorded: true,
hash_matches: Some(hash_matches),
on_chain_timestamp: Some(record.timestamp),
recorder_address: Some(record.recorder),
}
}
Ok(_) => BlockchainStatus {
recorded: false,
hash_matches: None,
on_chain_timestamp: None,
recorder_address: None,
},
Err(e) => {
tracing::warn!("온체인 조회 실패: {}", e);
BlockchainStatus {
recorded: event.tx_hash.is_some(),
hash_matches: None,
on_chain_timestamp: None,
recorder_address: None,
}
}
};
let payload: serde_json::Value = serde_json::from_str(&event.payload)
.unwrap_or(serde_json::Value::String(event.payload.clone()));
Ok(TraceEventResponse {
id: event.id,
event_type: event.event_type,
payload,
data_hash: event.data_hash,
tx_hash: event.tx_hash,
block_number: event.block_number,
created_at: event.created_at,
blockchain_status,
})
}
/// 무결성 검증: DB 해시 vs 온체인 해시
pub async fn verify_event(
&self,
event_id: &str,
) -> Result<VerifyResponse, AppError> {
// DB에서 이벤트 조회
let event = sqlx::query_as!(
TraceEvent,
"SELECT * FROM trace_events WHERE id = ?1",
event_id,
)
.fetch_optional(&self.db)
.await
.map_err(AppError::Database)?
.ok_or_else(|| AppError::NotFound(format!("이벤트 없음: {}", event_id)))?;
let db_hash = event.data_hash.clone();
// 온체인 해시 조회
match self.blockchain.get_record(event_id).await {
Ok(record) if record.exists => {
let on_chain_hash = hex::encode(record.data_hash);
let is_valid = db_hash == on_chain_hash;
let message = if is_valid {
"데이터 무결성 확인: DB와 체인의 해시가 일치합니다".to_string()
} else {
"경고: DB와 체인의 해시가 불일치합니다. 데이터가 변조되었을 수 있습니다!".to_string()
};
tracing::info!(
event_id = event_id,
is_valid = is_valid,
"무결성 검증 완료"
);
Ok(VerifyResponse {
event_id: event_id.to_string(),
db_hash,
on_chain_hash: Some(on_chain_hash),
is_valid,
message,
})
}
Ok(_) => {
Ok(VerifyResponse {
event_id: event_id.to_string(),
db_hash,
on_chain_hash: None,
is_valid: false,
message: "블록체인에 아직 기록되지 않았습니다".to_string(),
})
}
Err(e) => {
Err(AppError::Blockchain(format!("온체인 조회 실패: {}", e)))
}
}
}
}
src/routes.rs
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use std::sync::Arc;
use crate::{
errors::AppError,
models::CreateEventRequest,
services::trace::TraceService,
};
pub type AppState = Arc<TraceService>;
pub fn create_router(state: AppState) -> Router {
Router::new()
.route("/health", get(health_check))
.route("/events", post(create_event))
.route("/events/:id", get(get_event))
.route("/events/:id/verify", get(verify_event))
.with_state(state)
}
/// GET /health
async fn health_check() -> Json<serde_json::Value> {
Json(serde_json::json!({
"status": "ok",
"service": "mini-trace"
}))
}
/// POST /events
/// Body: { "event_type": "harvest", "payload": { "lot_id": "LOT-2026-001" } }
async fn create_event(
State(service): State<AppState>,
Json(req): Json<CreateEventRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>), AppError> {
let event = service.create_event(req).await?;
Ok((
StatusCode::CREATED,
Json(serde_json::json!({
"success": true,
"data": event
})),
))
}
/// GET /events/:id
async fn get_event(
State(service): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let event = service.get_event(&id).await?;
Ok(Json(serde_json::json!({
"success": true,
"data": event
})))
}
/// GET /events/:id/verify
async fn verify_event(
State(service): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let result = service.verify_event(&id).await?;
let status = if result.is_valid {
StatusCode::OK
} else {
StatusCode::OK // 검증 실패도 200, is_valid로 구분
};
Ok(Json(serde_json::json!({
"success": true,
"data": result
})))
}
src/main.rs
mod config;
mod errors;
mod models;
mod routes;
mod services;
use std::sync::Arc;
use anyhow::Result;
use sqlx::sqlite::SqlitePoolOptions;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::{
config::Config,
routes::create_router,
services::{blockchain::BlockchainService, trace::TraceService},
};
#[tokio::main]
async fn main() -> Result<()> {
// 로깅 초기화
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "mini_trace=info,tower_http=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
tracing::info!("mini-trace 서버 시작 중...");
// 설정 로드
let config = Config::from_env()?;
// 데이터베이스 연결
let db = SqlitePoolOptions::new()
.max_connections(5)
.connect(&config.database_url)
.await?;
// 마이그레이션 실행
sqlx::migrate!("./migrations")
.run(&db)
.await?;
tracing::info!("데이터베이스 연결 완료");
// 블록체인 서비스 초기화
let blockchain = Arc::new(
BlockchainService::new(
config.rpc_url.clone(),
config.private_key.clone(),
config.contract_address.clone(),
)?
);
tracing::info!("블록체인 서비스 초기화 완료: contract={}", config.contract_address);
// 트레이스 서비스
let trace_service = Arc::new(TraceService::new(db, blockchain));
// Axum 라우터
let app = create_router(trace_service)
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive());
let addr = format!("0.0.0.0:{}", config.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("서버 시작: http://{}", addr);
axum::serve(listener, app).await?;
Ok(())
}
src/services/mod.rs
pub mod blockchain;
pub mod trace;
.env 파일
DATABASE_URL=sqlite:mini-trace.db
RPC_URL=http://localhost:8545
PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
CONTRACT_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3
PORT=3000
RUST_LOG=mini_trace=info,tower_http=debug
단계별 실행 가이드
1단계: 환경 준비
# Foundry 설치
curl -L https://foundry.paradigm.xyz | bash
foundryup
# Rust 설정 확인
rustc --version # 1.75+
cargo --version
# SQLx CLI 설치 (마이그레이션용)
cargo install sqlx-cli --features sqlite
2단계: 프로젝트 생성
cargo new mini-trace
cd mini-trace
mkdir -p contracts/src abi migrations src/services
위의 모든 파일을 해당 위치에 작성한다.
3단계: 블록체인 환경 시작
# 터미널 1: Anvil 시작 (로컬 이더리움 노드)
anvil
# 터미널 2: 컨트랙트 배포
cd mini-trace/contracts
forge init --no-git # contracts 폴더 초기화
# TraceRecord.sol을 src/에 작성 후
forge build
forge create \
--rpc-url http://localhost:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
src/TraceRecord.sol:TraceRecord
# 배포된 컨트랙트 주소를 .env의 CONTRACT_ADDRESS에 설정
4단계: 데이터베이스 초기화
cd mini-trace
sqlx database create
sqlx migrate run
5단계: 서버 실행
cargo run
# 또는 개발 중 자동 재시작
cargo install cargo-watch
cargo watch -x run
6단계: API 테스트
# 1. 헬스체크
curl http://localhost:3000/health
# 2. 이벤트 생성
curl -X POST http://localhost:3000/events \
-H "Content-Type: application/json" \
-d '{
"event_type": "harvest",
"payload": {
"crop": "사과",
"quantity_kg": 500,
"farm_id": "farm-001",
"location": "경북 안동",
"quality_grade": "A"
}
}'
# 응답 예시:
# {
# "success": true,
# "data": {
# "id": "550e8400-e29b-41d4-a716-446655440000",
# "event_type": "harvest",
# "payload": { ... },
# "data_hash": "0x3f4a...",
# "tx_hash": "0x8b2c...",
# "blockchain_status": {
# "recorded": true,
# "hash_matches": null,
# ...
# }
# }
# }
# 3. 이벤트 조회 (EVENT_ID는 위 응답의 id)
EVENT_ID="550e8400-e29b-41d4-a716-446655440000"
curl http://localhost:3000/events/$EVENT_ID
# 4. 무결성 검증
curl http://localhost:3000/events/$EVENT_ID/verify
# 응답 예시:
# {
# "success": true,
# "data": {
# "event_id": "550e8400...",
# "db_hash": "3f4a...",
# "on_chain_hash": "3f4a...",
# "is_valid": true,
# "message": "데이터 무결성 확인: DB와 체인의 해시가 일치합니다"
# }
# }
7단계: 데이터 변조 시뮬레이션
# DB에서 직접 데이터 수정 (변조 시뮬레이션)
sqlite3 mini-trace.db \
"UPDATE trace_events SET payload = '{\"crop\":\"배추\",\"quantity_kg\":999}' WHERE id='$EVENT_ID'"
# 검증 - 해시 불일치 탐지
curl http://localhost:3000/events/$EVENT_ID/verify
# {
# "is_valid": false,
# "message": "경고: DB와 체인의 해시가 불일치합니다. 데이터가 변조되었을 수 있습니다!"
# }
이것이 블록체인의 가치다. DB 데이터가 변조되었지만 체인의 해시가 이를 탐지한다.
확장 아이디어
이 미니프로젝트를 완성하면 다음 기능을 추가해볼 수 있다:
- 재시도 큐: 블록체인 기록 실패 시 Redis/DB 큐로 재시도
- 이벤트 목록:
GET /events?type=harvest&page=1페이지네이션 - 배치 기록: 여러 이벤트를 한 트랜잭션에 기록
- WebSocket: 새 이벤트를 실시간으로 클라이언트에 푸시
- 인증: JWT 미들웨어로 API 보호
- PostgreSQL 전환: SQLite에서 PostgreSQL로 (platform 수준)
요약
이 장에서 구현한 것:
- Solidity 컨트랙트: 해시 기록/조회/검증
- Axum + SQLx: REST API와 SQLite 연동
- Alloy: 컨트랙트 쓰기/읽기
- 하이브리드 패턴: 데이터는 DB, 해시는 체인
- 무결성 검증: DB vs 체인 해시 비교
- 에러 처리: 블록체인 실패가 API를 막지 않음
platform의 핵심 패턴을 이해했다면 다음 장(22장)에서 실제 platform 코드를 읽어본다.
22장: Platform 프로젝트 분석
Platform이란
Platform은 식품 공급망 이력 추적(traceability) SaaS 서비스다. 농업인이 작물을 재배하는 순간부터 소비자에게 도달하기까지의 모든 이력을 기록하고, 그 무결성을 블록체인으로 보증한다.
사용자 관점에서는 QR 코드를 스캔하면 “이 사과가 어느 농장에서, 언제, 어떤 방법으로 재배되었고, 어떤 유통 경로를 거쳤는지“를 신뢰할 수 있는 방식으로 확인할 수 있다.
기술 관점에서는 Rust + Axum + SQLx + Alloy + Solidity + Hyperledger Besu로 구성된 마이크로서비스 아키텍처다.
3개 마이크로서비스 구조
┌─────────────────────────────────────────────────────────────────┐
│ Platform 시스템 │
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────┐ │
│ │ account │ │ traceability │ │ iksan-api │ │
│ │ │ │ │ │ │ │
│ │ - 인증/인가 │ │ - 이력 추적 │ │ - 농업인/작물 │ │
│ │ - 계정 관리 │ │ - 제품 관리 │ │ - 블록체인 연동 │ │
│ │ - DID 신원 │ │ - 규정 준수 │ │ - 이벤트 처리 │ │
│ │ - 요금제 │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ :3001 │ │ :3002 │ │ :3003 │ │
│ └───────┬────────┘ └───────┬────────┘ └────────┬───────────┘ │
│ │ │ │ │
│ ┌───────▼───────────────────▼────────────────────▼───────────┐ │
│ │ PostgreSQL (각 서비스별 DB 스키마) │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Hyperledger Besu (IBFT 2.0) │ │
│ │ - TraceRecord 컨트랙트 │ │
│ │ - DID 컨트랙트 │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
서비스 간 통신
서비스들은 HTTP RPC로 통신한다. 예를 들어:
traceability가 이력 조회 시account에서 사용자 정보를 확인iksan-api가 이벤트를 처리할 때account에서 토큰을 검증
마이크로서비스 아키텍처지만 서비스 메시(Service Mesh)나 gRPC 없이 단순한 HTTP 통신을 사용한다. 규모가 커지면 이 부분을 개선할 수 있다.
기술 스택 전체 정리
언어 및 런타임
| 계층 | 기술 | 버전 |
|---|---|---|
| 언어 | Rust | 1.75+ |
| 비동기 런타임 | Tokio | 1.x |
| 웹 프레임워크 | Axum | 0.7 |
데이터베이스
| 서비스 | DB | ORM/쿼리 |
|---|---|---|
| account | PostgreSQL | SQLx |
| traceability | PostgreSQL | SQLx |
| iksan-api | PostgreSQL | SQLx |
모든 서비스가 PostgreSQL을 사용하지만 스키마는 분리되어 있다. 서비스 간 직접 DB 쿼리는 없고, 반드시 HTTP API를 통해 통신한다.
블록체인
| 항목 | 기술 |
|---|---|
| 클라이언트 | Hyperledger Besu |
| 합의 | IBFT 2.0 |
| Rust 라이브러리 | Alloy 0.9 |
| 컨트랙트 언어 | Solidity ^0.8.20 |
| 컨트랙트 개발 | Foundry (Forge, Cast) |
| 패턴 | UUPS 프록시 (업그레이드 가능) |
인프라
| 항목 | 기술 |
|---|---|
| 컨테이너 | Docker, Docker Compose |
| 리버스 프록시 | Nginx |
| 로깅 | tracing + tracing-subscriber |
| 설정 | 환경변수 + dotenvy |
보안
| 항목 | 구현 |
|---|---|
| 인증 | JWT (account 서비스 발급) |
| 신원 | DID (Decentralized Identifier) |
| API 보안 | Tower 미들웨어 |
| 개인키 관리 | 환경변수 (프로덕션: KMS) |
각 서비스의 역할 요약
account 서비스 (:3001)
사용자 인증과 신원 관리를 담당한다. Node.js 배경이라면 NestJS의 Auth 모듈 + Users 모듈이 합쳐진 것으로 이해하면 된다.
주요 기능:
- 회원가입/로그인 (JWT 발급)
- 계정 프로필 관리
- DID(탈중앙 신원) 등록/조회
- 요금제(subscription) 관리
- 블록체인 컨트랙트 배포 (Foundry 연동)
traceability 서비스 (:3002)
이력 추적의 핵심 비즈니스 로직을 담당한다.
주요 기능:
- 제품(product) 등록과 관리
- 이력 이벤트(trace event) 기록
- 규정 준수(compliance) 확인
- 공급망 경로 추적
- QR 코드 생성과 조회
iksan-api 서비스 (:3003)
농업인과 작물 데이터, 그리고 블록체인 연동의 핵심을 담당한다. “익산 API“라는 이름은 초기 파일럿 지역이 전북 익산이었기 때문이다.
주요 기능:
- 농업인 프로필 관리
- 작물/재배 정보 관리
- 이벤트를 블록체인에 기록 (가장 핵심)
- 해시 검증
- 블록체인 이벤트 인덱싱
코드 규모와 구조
platform의 각 서비스는 다음 구조를 따른다:
apps/{service-name}/
├── Cargo.toml
├── src/
│ ├── main.rs ← 서버 진입점
│ ├── core/
│ │ ├── app.rs ← AppState, 의존성 주입
│ │ └── config.rs ← 설정
│ ├── routes/
│ │ ├── mod.rs
│ │ └── {domain}.rs ← 라우트 핸들러
│ ├── services/
│ │ ├── mod.rs
│ │ ├── {domain}.rs ← 비즈니스 로직
│ │ └── blockchain.rs ← Alloy 연동
│ ├── repositories/
│ │ └── {domain}.rs ← DB 쿼리
│ ├── models/
│ │ └── {domain}.rs ← 데이터 타입
│ └── middleware/
│ └── auth.rs ← Tower 미들웨어
├── contracts/
│ └── src/
│ └── {Contract}.sol
└── migrations/
└── *.sql
이 구조는 다음 장에서 더 자세히 분석한다.
Platform이 가르쳐주는 것
platform 코드를 읽으면서 얻을 수 있는 것들:
- 실전 Rust 패턴:
Arc<AppState>,thiserror,anyhow, 트레이트 객체 활용 - Axum 실전: 미들웨어, 에러 처리, 상태 공유
- SQLx 실전: 마이그레이션, 복잡한 조인 쿼리, 트랜잭션
- Alloy 실전:
sol!매크로, 서명자 관리, 이벤트 인덱싱 - 마이크로서비스: 서비스 간 HTTP 통신, 에러 전파
이 장(22장) 전체에서 platform의 실제 코드 패턴을 분석한다.
다음 장(22-1)에서는 각 서비스의 구조를 Node.js/NestJS 패턴과 대응시켜 이해한다.
22-1: 서비스 아키텍처 - NestJS 개발자를 위한 대응 분석
개요: NestJS vs Axum
4년간 Node.js 백엔드를 개발했다면 NestJS의 구조에 익숙할 것이다. Axum은 NestJS와 철학이 다르지만, 역할은 대응된다.
NestJS Axum (platform 방식)
───────────────────────── ─────────────────────────
@Module() ↔ AppState (구조체)
@Injectable() Service ↔ Service 구조체
@Controller() ↔ Router + Handler 함수
@Middleware() ↔ Tower Layer/middleware
@Guard() ↔ Tower middleware 또는 Extractor
DI Container ↔ Arc<AppState> 수동 주입
Pipes (validation) ↔ serde + validator 크레이트
Interceptors ↔ Tower Layer
Exception Filters ↔ IntoResponse 구현
NestJS는 데코레이터와 DI 컨테이너로 “마법처럼” 의존성을 연결한다. Axum은 모든 것이 명시적이다. Arc<AppState>를 직접 만들어서 라우터에 붙인다. 더 verbose하지만 무슨 일이 일어나는지 완전히 이해할 수 있다.
AppState - NestJS의 @Module providers
NestJS에서 @Module({ providers: [UserService, AuthService, TypeOrmModule] })으로 의존성을 등록하듯, Axum에서는 AppState 구조체에 모든 의존성을 담는다.
platform의 AppState 패턴
// apps/iksan-api/src/core/app.rs
use std::sync::Arc;
use sqlx::PgPool;
use crate::services::{
blockchain::BlockchainService,
event::EventService,
farmer::FarmerService,
crop::CropService,
};
/// 앱 전체에서 공유되는 상태
/// NestJS의 AppModule providers에 해당
#[derive(Clone)]
pub struct AppState {
// 데이터베이스 커넥션 풀
pub db: PgPool,
// 서비스들 - Arc로 감싸 참조 카운팅
pub blockchain: Arc<BlockchainService>,
pub event_service: Arc<EventService>,
pub farmer_service: Arc<FarmerService>,
pub crop_service: Arc<CropService>,
// 설정
pub config: Arc<AppConfig>,
}
impl AppState {
pub async fn new(config: AppConfig) -> anyhow::Result<Self> {
// 1. DB 연결
let db = sqlx::postgres::PgPoolOptions::new()
.max_connections(config.db_max_connections)
.connect(&config.database_url)
.await?;
// 마이그레이션
sqlx::migrate!("./migrations").run(&db).await?;
// 2. 블록체인 서비스 초기화
let blockchain = Arc::new(
BlockchainService::new(
config.rpc_url.clone(),
config.private_key.clone(),
config.contract_address.clone(),
).await?
);
// 3. 비즈니스 서비스 초기화 (블록체인 의존성 주입)
let event_service = Arc::new(
EventService::new(db.clone(), Arc::clone(&blockchain))
);
let farmer_service = Arc::new(FarmerService::new(db.clone()));
let crop_service = Arc::new(CropService::new(db.clone()));
Ok(AppState {
db,
blockchain,
event_service,
farmer_service,
crop_service,
config: Arc::new(config),
})
}
}
AppState는 Clone을 구현하는데, 실제로 데이터를 복사하지 않는다. Arc 참조 카운터만 증가시킨다. Axum이 각 요청 핸들러에 AppState를 전달할 때 이 방식으로 작동한다.
NestJS와 비교
// NestJS
@Module({
imports: [
TypeOrmModule.forFeature([Farmer, Crop, TraceEvent]),
],
providers: [
FarmerService,
CropService,
EventService,
BlockchainService,
],
controllers: [FarmerController, CropController, EventController],
})
export class AppModule {}
// Axum (platform)
let state = AppState::new(config).await?;
let app = Router::new()
.nest("/farmers", farmer_routes())
.nest("/crops", crop_routes())
.nest("/events", event_routes())
.with_state(state); // AppState를 라우터에 주입
NestJS는 프레임워크가 DI를 관리하고, Axum은 개발자가 직접 주입한다.
Router - Express/NestJS Controller와 대응
platform의 라우트 구조
// apps/iksan-api/src/main.rs
use axum::{Router, middleware};
use crate::{
core::app::AppState,
routes::{farmer, crop, event, health},
middleware::auth::auth_middleware,
};
pub fn create_app(state: AppState) -> Router {
// 공개 라우트 (인증 불필요)
let public = Router::new()
.route("/health", get(health::check))
.route("/events/:id/public", get(event::get_public));
// 보호된 라우트 (JWT 필요)
let protected = Router::new()
.nest("/farmers", farmer_routes())
.nest("/crops", crop_routes())
.nest("/events", event_routes())
.layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware, // JWT 검증 미들웨어
));
Router::new()
.merge(public)
.merge(protected)
.with_state(state)
}
fn farmer_routes() -> Router<AppState> {
Router::new()
.route("/", get(farmer::list).post(farmer::create))
.route("/:id", get(farmer::get).put(farmer::update).delete(farmer::delete))
.route("/:id/crops", get(farmer::get_crops))
}
fn event_routes() -> Router<AppState> {
Router::new()
.route("/", get(event::list).post(event::create))
.route("/:id", get(event::get))
.route("/:id/verify", get(event::verify))
.route("/:id/blockchain", get(event::get_blockchain_status))
}
NestJS Controller와 비교
// NestJS
@Controller('farmers')
@UseGuards(JwtAuthGuard)
export class FarmerController {
constructor(private readonly farmerService: FarmerService) {}
@Get()
list(@Query() query: ListFarmerDto) {
return this.farmerService.findAll(query);
}
@Post()
create(@Body() dto: CreateFarmerDto) {
return this.farmerService.create(dto);
}
@Get(':id')
get(@Param('id') id: string) {
return this.farmerService.findOne(id);
}
}
// Axum (platform)
// routes/farmer.rs
pub async fn list(
State(state): State<AppState>,
Query(params): Query<ListFarmerParams>,
Extension(user): Extension<AuthUser>, // JWT에서 추출된 사용자
) -> Result<Json<Vec<FarmerResponse>>, AppError> {
let farmers = state.farmer_service.find_all(¶ms).await?;
Ok(Json(farmers))
}
pub async fn create(
State(state): State<AppState>,
Extension(user): Extension<AuthUser>,
Json(body): Json<CreateFarmerRequest>,
) -> Result<(StatusCode, Json<FarmerResponse>), AppError> {
let farmer = state.farmer_service.create(user.id, body).await?;
Ok((StatusCode::CREATED, Json(farmer)))
}
차이점:
- NestJS: 메서드에 데코레이터로 HTTP 메서드 지정
- Axum:
Router::new().route("/", get(fn).post(fn))으로 함수와 메서드 분리 - NestJS: DI로 서비스 주입
- Axum:
State(state)extractor로 AppState 접근
Tower 미들웨어 - NestJS middleware와 대응
Tower는 Axum이 사용하는 미들웨어 추상화 라이브러리다. Service 트레이트를 기반으로 한다.
인증 미들웨어
// apps/iksan-api/src/middleware/auth.rs
use axum::{
extract::{Request, State},
middleware::Next,
response::Response,
http::StatusCode,
};
use crate::core::app::AppState;
#[derive(Clone, Debug)]
pub struct AuthUser {
pub id: String,
pub email: String,
pub role: String,
}
/// JWT 인증 미들웨어
/// NestJS의 @UseGuards(JwtAuthGuard)에 해당
pub async fn auth_middleware(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
// Authorization 헤더에서 토큰 추출
let token = req
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
// JWT 검증 (account 서비스에 요청하거나 로컬 검증)
let claims = verify_jwt(token, &state.config.jwt_secret)
.map_err(|_| StatusCode::UNAUTHORIZED)?;
// 검증된 사용자 정보를 요청 확장(Extension)에 추가
req.extensions_mut().insert(AuthUser {
id: claims.sub,
email: claims.email,
role: claims.role,
});
// 다음 핸들러로 진행
Ok(next.run(req).await)
}
fn verify_jwt(token: &str, secret: &str) -> anyhow::Result<Claims> {
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
let key = DecodingKey::from_secret(secret.as_bytes());
let validation = Validation::new(Algorithm::HS256);
let data = decode::<Claims>(token, &key, &validation)?;
Ok(data.claims)
}
#[derive(serde::Deserialize)]
struct Claims {
sub: String,
email: String,
role: String,
exp: u64,
}
NestJS 가드와 비교
// NestJS
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext): boolean {
// passport-jwt가 자동으로 토큰 검증
return super.canActivate(context) as boolean;
}
}
// 사용
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Request() req) {
return req.user; // passport가 주입한 사용자
}
// Axum
// 미들웨어가 Extension에 AuthUser를 추가
// 핸들러에서 Extension extractor로 접근
pub async fn get_profile(
Extension(user): Extension<AuthUser>,
) -> Json<serde_json::Value> {
Json(json!({ "id": user.id, "email": user.email }))
}
CORS 미들웨어
// main.rs에서 tower-http의 CORS 레이어 사용
use tower_http::cors::{CorsLayer, Any};
use axum::http::{HeaderName, Method};
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers(Any)
.allow_origin(Any); // 프로덕션에서는 특정 도메인만
let app = create_app(state)
.layer(cors)
.layer(TraceLayer::new_for_http());
요청 로깅 미들웨어
use tower_http::trace::{TraceLayer, DefaultMakeSpan, DefaultOnResponse};
use tracing::Level;
let trace_layer = TraceLayer::new_for_http()
.make_span_with(
DefaultMakeSpan::new()
.level(Level::INFO)
.include_headers(false)
)
.on_response(
DefaultOnResponse::new()
.level(Level::INFO)
.latency_unit(tower_http::LatencyUnit::Millis)
);
TraceLayer는 NestJS의 LoggingInterceptor와 유사하다. 모든 HTTP 요청/응답에 대해 자동으로 tracing 스팬을 생성한다.
각 서비스 상세 분석
account 서비스
역할: 인증, 계정, DID, 요금제
account/
├── src/
│ ├── main.rs
│ ├── core/
│ │ ├── app.rs ← AppState (DB + blockchain + services)
│ │ └── config.rs
│ ├── routes/
│ │ ├── auth.rs ← POST /auth/login, /auth/register, /auth/refresh
│ │ ├── account.rs ← GET/PUT /accounts/me
│ │ ├── did.rs ← POST /did/register, GET /did/:did
│ │ └── subscription.rs ← GET/POST /subscriptions
│ ├── services/
│ │ ├── auth.rs ← JWT 발급, 검증, 리프레시
│ │ ├── account.rs ← 계정 CRUD
│ │ ├── did.rs ← DID 생성, 블록체인 등록
│ │ └── subscription.rs ← 요금제 관리
│ ├── foundry/
│ │ └── contract.rs ← 컨트랙트 배포 로직
│ └── models/
│ ├── account.rs
│ └── did.rs
account 서비스의 특이점은 Foundry를 Rust에서 직접 실행한다는 것이다. 새 고객이 가입하면, 해당 고객 전용 스마트 컨트랙트를 자동으로 배포한다:
// apps/account/src/foundry/contract.rs
pub async fn deploy_trace_contract(
rpc_url: &str,
deployer_key: &str,
) -> anyhow::Result<Address> {
// forge create 명령을 서브프로세스로 실행
let output = tokio::process::Command::new("forge")
.args([
"create",
"--rpc-url", rpc_url,
"--private-key", deployer_key,
"contracts/src/TraceRecord.sol:TraceRecord",
])
.output()
.await?;
// 출력에서 배포된 주소 파싱
let stdout = String::from_utf8(output.stdout)?;
let address = parse_deployed_address(&stdout)?;
Ok(address)
}
traceability 서비스
역할: 이력 추적 비즈니스 로직, 제품 관리, 규정 준수
traceability/
├── src/
│ ├── routes/
│ │ ├── product.rs ← 제품 등록/조회/QR 생성
│ │ ├── trace.rs ← 이력 조회, 타임라인
│ │ └── compliance.rs ← 규정 준수 체크
│ ├── services/
│ │ ├── product.rs ← 제품 비즈니스 로직
│ │ ├── trace.rs ← 이력 집계 및 검증
│ │ └── qr.rs ← QR 코드 생성
│ └── repositories/
│ ├── product.rs ← 제품 DB 쿼리
│ └── trace.rs ← 이력 DB 쿼리
traceability 서비스는 iksan-api에서 기록한 데이터를 읽어서 소비자에게 보여주는 역할을 한다. 블록체인에 직접 쓰지 않고, iksan-api의 API를 통해 데이터를 가져온다.
iksan-api 서비스
역할: 농업인/작물 관리 + 블록체인 연동 핵심
iksan-api/
├── src/
│ ├── routes/
│ │ ├── farmer.rs ← 농업인 CRUD
│ │ ├── crop.rs ← 작물 CRUD
│ │ └── event.rs ← 이벤트 생성/조회/검증
│ ├── services/
│ │ ├── farmer.rs
│ │ ├── crop.rs
│ │ ├── event.rs ← 이벤트 비즈니스 로직
│ │ └── blockchain.rs ← Alloy 연동 핵심
│ └── contracts/
│ └── src/
│ └── TraceRecord.sol
핵심 데이터베이스 스키마
account 서비스 테이블
-- 계정
CREATE TABLE accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100),
role VARCHAR(50) DEFAULT 'user',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- DID (Decentralized Identifier)
CREATE TABLE dids (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID REFERENCES accounts(id) ON DELETE CASCADE,
did VARCHAR(255) UNIQUE NOT NULL, -- did:ethr:0x...
public_key TEXT NOT NULL,
contract_address VARCHAR(42), -- DID 컨트랙트 주소
blockchain_tx VARCHAR(66), -- 등록 TX 해시
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 요금제
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID REFERENCES accounts(id) ON DELETE CASCADE,
plan VARCHAR(50) NOT NULL, -- 'free', 'basic', 'enterprise'
status VARCHAR(50) DEFAULT 'active',
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
iksan-api 서비스 테이블
-- 농업인
CREATE TABLE farmers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL, -- account 서비스의 ID (외래키 아님, 서비스 분리)
name VARCHAR(100) NOT NULL,
phone VARCHAR(20),
address TEXT,
farm_name VARCHAR(200),
certification_number VARCHAR(100), -- 농업인 확인증
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 작물
CREATE TABLE crops (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
farmer_id UUID REFERENCES farmers(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
variety VARCHAR(100), -- 품종
planting_date DATE,
expected_harvest DATE,
location TEXT, -- 재배지
area_sqm DECIMAL(10, 2), -- 재배 면적 (㎡)
status VARCHAR(50) DEFAULT 'growing',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 이벤트 (핵심 테이블)
CREATE TABLE trace_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
crop_id UUID REFERENCES crops(id),
farmer_id UUID REFERENCES farmers(id),
event_type VARCHAR(100) NOT NULL, -- 'planting', 'harvest', 'inspection', 'transport'
payload JSONB NOT NULL, -- 이벤트 상세 데이터
data_hash VARCHAR(66) NOT NULL, -- keccak256 (0x + 64자)
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 블록체인 기록 (별도 테이블로 분리)
CREATE TABLE blockchain_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID REFERENCES trace_events(id) ON DELETE CASCADE,
tx_hash VARCHAR(66) NOT NULL, -- 0x + 64자
block_number BIGINT,
recorded_at TIMESTAMPTZ DEFAULT NOW(),
status VARCHAR(50) DEFAULT 'pending' -- 'pending', 'confirmed', 'failed'
);
-- 인덱스
CREATE INDEX idx_trace_events_crop_id ON trace_events(crop_id);
CREATE INDEX idx_trace_events_farmer_id ON trace_events(farmer_id);
CREATE INDEX idx_trace_events_event_type ON trace_events(event_type);
CREATE INDEX idx_trace_events_created_at ON trace_events(created_at DESC);
CREATE INDEX idx_blockchain_records_event_id ON blockchain_records(event_id);
trace_events와 blockchain_records를 분리한 이유:
- 이벤트 생성(빠른 DB 쓰기)과 블록체인 기록(느린 TX)을 비동기로 처리
- 블록체인 기록 실패 시 재시도 가능
- 블록체인 기록 상태를 독립적으로 추적
서비스 간 HTTP 통신
platform에서 서비스 간 통신은 단순한 HTTP 요청이다. NestJS의 HttpModule이나 gRPC 없이 reqwest 크레이트를 사용한다.
// traceability 서비스가 iksan-api에서 이벤트를 조회하는 예
// apps/traceability/src/services/trace.rs
use reqwest::Client;
pub struct TraceService {
http_client: Client,
iksan_api_url: String,
}
impl TraceService {
pub async fn get_crop_trace_history(
&self,
crop_id: &str,
jwt_token: &str,
) -> anyhow::Result<Vec<TraceEvent>> {
let url = format!("{}/crops/{}/events", self.iksan_api_url, crop_id);
let response = self.http_client
.get(&url)
.header("Authorization", format!("Bearer {}", jwt_token))
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"iksan-api 요청 실패: {}", response.status()
));
}
let events: Vec<TraceEvent> = response.json().await?;
Ok(events)
}
}
이것은 단순하지만, 프로덕션에서는 서킷 브레이커(tower의 timeout, retry)를 추가해야 한다.
에러 처리 패턴
platform 전체에서 일관된 에러 처리 패턴을 사용한다:
// 각 서비스의 errors.rs
use axum::response::{IntoResponse, Response};
use axum::http::StatusCode;
use axum::Json;
#[derive(thiserror::Error, Debug)]
pub enum AppError {
#[error("찾을 수 없음: {0}")]
NotFound(String),
#[error("권한 없음")]
Unauthorized,
#[error("요청 오류: {0}")]
BadRequest(String),
#[error("데이터베이스 오류")]
Database(#[from] sqlx::Error),
#[error("블록체인 오류: {0}")]
Blockchain(String),
#[error("외부 서비스 오류: {0}")]
ExternalService(String),
#[error("내부 오류")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code, message) = match &self {
AppError::NotFound(msg) => (
StatusCode::NOT_FOUND,
"NOT_FOUND",
msg.clone()
),
AppError::Unauthorized => (
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
"인증이 필요합니다".to_string()
),
AppError::BadRequest(msg) => (
StatusCode::BAD_REQUEST,
"BAD_REQUEST",
msg.clone()
),
AppError::Database(e) => {
tracing::error!(error = %e, "DB 오류");
(
StatusCode::INTERNAL_SERVER_ERROR,
"DATABASE_ERROR",
"데이터베이스 오류가 발생했습니다".to_string()
)
},
AppError::Blockchain(msg) => {
tracing::warn!(error = %msg, "블록체인 오류");
(
StatusCode::SERVICE_UNAVAILABLE,
"BLOCKCHAIN_ERROR",
msg.clone()
)
},
AppError::ExternalService(msg) => (
StatusCode::BAD_GATEWAY,
"EXTERNAL_SERVICE_ERROR",
msg.clone()
),
AppError::Internal(e) => {
tracing::error!(error = %e, "내부 오류");
(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"내부 서버 오류가 발생했습니다".to_string()
)
},
};
(
status,
Json(serde_json::json!({
"success": false,
"error": {
"code": code,
"message": message
}
}))
).into_response()
}
}
NestJS의 ExceptionFilter와 비교:
// NestJS
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
response.status(status).json({
success: false,
error: { message: 'error' }
});
}
}
Rust의 접근이 더 타입 안전하다. 가능한 에러 타입이 모두 명시되어 있고, 처리되지 않은 케이스는 컴파일 에러가 난다.
요약
| NestJS 개념 | Axum/platform 구현 |
|---|---|
@Module() | AppState 구조체 |
@Injectable() | 일반 구조체 + Arc<T> |
@Controller() | Router::new().route() |
@Get(), @Post() | get(fn), post(fn) |
@UseGuards() | middleware::from_fn() |
@UseInterceptors() | Tower Layer |
@Param(), @Body(), @Query() | Path, Json, Query Extractor |
DI Container | Arc<AppState> 수동 주입 |
ExceptionFilter | IntoResponse 구현 |
HttpException | AppError enum |
다음 장에서는 블록체인 연동 흐름을 더 자세히 분석한다.
22-2: 블록체인 연동 흐름 상세
전체 흐름 개요
platform에서 블록체인 연동은 iksan-api 서비스가 담당한다. 이벤트 하나가 생성되면 다음 흐름이 실행된다:
클라이언트
│
│ POST /events { crop_id, event_type, payload }
▼
[iksan-api: routes/event.rs]
│
│ create_event(req)
▼
[iksan-api: services/event.rs]
│
├─ 1. payload → keccak256 해시 계산
├─ 2. DB 저장 (trace_events 테이블)
│
│ record_hash(event_id, hash)
▼
[iksan-api: services/blockchain.rs]
│
├─ 3. TraceRecord.recordHash() 트랜잭션 전송
├─ 4. 영수증 대기
├─ 5. TX 해시 DB 업데이트 (blockchain_records 테이블)
│
▼
클라이언트 응답 (event + tx_hash)
단계별 상세 코드
1단계: 이벤트 생성 → 해시 계산
// apps/iksan-api/src/services/event.rs
use sha3::{Digest, Keccak256};
use alloy::primitives::keccak256;
pub async fn create_event(
db: &PgPool,
blockchain: &BlockchainService,
req: CreateEventRequest,
) -> Result<TraceEventResponse, AppError> {
// 이벤트 ID 생성
let event_id = Uuid::new_v4();
// payload를 정규화된 JSON으로 직렬화
// 키 정렬로 동일 데이터는 항상 같은 해시 보장
let payload_normalized = {
let mut map: std::collections::BTreeMap<String, serde_json::Value> =
serde_json::from_value(req.payload.clone())
.map_err(|_| AppError::BadRequest("payload는 객체여야 합니다".into()))?;
serde_json::to_string(&map)?
};
// 해시 입력: event_id + event_type + normalized_payload
let hash_input = format!(
"{}{}{}",
event_id,
req.event_type,
payload_normalized
);
// keccak256 계산 (Alloy primitives 사용)
use alloy::primitives::keccak256 as alloy_keccak256;
let hash_bytes = alloy_keccak256(hash_input.as_bytes());
let data_hash = format!("0x{}", hex::encode(hash_bytes.as_slice()));
tracing::debug!(
event_id = %event_id,
event_type = %req.event_type,
data_hash = %data_hash,
"해시 계산 완료"
);
sqlx::query!(
"INSERT INTO trace_events (id, payload, data_hash) VALUES ($1, $2, $3)",
event_id,
payload,
data_hash
)
.execute(&self.db)
.await?;
해시 입력에 event_id를 포함하는 이유: 동일한 payload라도 다른 이벤트라면 다른 해시를 가져야 한다. event_id(UUID v4)는 항상 유니크하므로 해시 충돌을 방지한다.
2단계: DB 저장
// 트랜잭션으로 원자적 저장
let mut tx = db.begin().await.map_err(AppError::Database)?;
// trace_events 삽입
let event = sqlx::query_as!(
TraceEvent,
r#"
INSERT INTO trace_events
(id, crop_id, farmer_id, event_type, payload, data_hash, created_at)
VALUES
($1, $2, $3, $4, $5::jsonb, $6, NOW())
RETURNING *
"#,
event_id,
req.crop_id,
req.farmer_id,
req.event_type,
serde_json::to_string(&req.payload)?,
data_hash,
)
.fetch_one(&mut *tx)
.await
.map_err(AppError::Database)?;
tx.commit().await.map_err(AppError::Database)?;
tracing::info!(event_id = %event_id, "이벤트 DB 저장 완료");
3단계: 블록체인에 해시 기록
// 블록체인 기록은 DB 커밋 이후 별도로 실행
// 실패해도 DB 데이터는 보존됨 (가용성 우선 설계)
let hash_bytes: [u8; 32] = {
let decoded = hex::decode(data_hash.trim_start_matches("0x"))
.map_err(|e| AppError::Internal(e.into()))?;
decoded.try_into()
.map_err(|_| AppError::Internal(anyhow::anyhow!("해시 크기 오류")))?
};
match blockchain.record_hash(&event_id.to_string(), hash_bytes).await {
Ok(BlockchainReceipt { tx_hash, block_number }) => {
// blockchain_records 테이블에 기록
sqlx::query!(
r#"
INSERT INTO blockchain_records
(event_id, tx_hash, block_number, status, recorded_at)
VALUES
($1, $2, $3, 'confirmed', NOW())
"#,
event_id,
tx_hash,
block_number as i64,
)
.execute(db)
.await
.map_err(AppError::Database)?;
tracing::info!(
event_id = %event_id,
tx_hash = %tx_hash,
block_number = block_number,
"블록체인 기록 완료"
);
Ok(build_response(event, Some(tx_hash), Some(block_number)))
}
Err(e) => {
// 블록체인 실패: 재시도 큐에 추가
tracing::warn!(
event_id = %event_id,
error = %e,
"블록체인 기록 실패, 재시도 예정"
);
// pending 상태로 blockchain_records 삽입
sqlx::query!(
r#"
INSERT INTO blockchain_records
(event_id, tx_hash, status)
VALUES
($1, '', 'pending')
"#,
event_id,
)
.execute(db)
.await
.ok(); // 이것도 실패하면 무시
// API는 성공 응답 반환 (블록체인 없이도 사용 가능)
Ok(build_response(event, None, None))
}
}
4단계: Alloy로 컨트랙트 호출 (blockchain.rs)
// apps/iksan-api/src/services/blockchain.rs
use alloy::{
network::EthereumWallet,
primitives::{Address, FixedBytes},
providers::{Provider, ProviderBuilder},
signers::local::PrivateKeySigner,
sol,
};
sol! {
#[sol(rpc)]
contract TraceRecord {
struct Record {
bytes32 dataHash;
uint256 timestamp;
address recorder;
bool exists;
}
function recordHash(string calldata eventId, bytes32 dataHash) external;
function getRecord(string calldata eventId) external view returns (Record memory);
function verifyHash(string calldata eventId, bytes32 dataHash) external view returns (bool);
event HashRecorded(
string indexed eventId,
bytes32 dataHash,
address indexed recorder,
uint256 timestamp
);
error NotOwner(address caller);
}
}
pub struct BlockchainReceipt {
pub tx_hash: String,
pub block_number: u64,
}
pub struct BlockchainService {
rpc_url: String,
private_key: String,
contract_address: Address,
}
impl BlockchainService {
pub async fn new(
rpc_url: String,
private_key: String,
contract_address_str: String,
) -> anyhow::Result<Self> {
let contract_address: Address = contract_address_str.parse()
.map_err(|e| anyhow::anyhow!("컨트랙트 주소 파싱 실패: {}", e))?;
// 연결 테스트
let provider = ProviderBuilder::new()
.on_http(rpc_url.parse()
.map_err(|e| anyhow::anyhow!("RPC URL 오류: {}", e))?);
let chain_id = provider.get_chain_id().await
.map_err(|e| anyhow::anyhow!("블록체인 연결 실패: {}", e))?;
tracing::info!(chain_id = chain_id, "블록체인 연결 확인");
Ok(Self { rpc_url, private_key, contract_address })
}
/// 트랜잭션 서명 Provider 생성 (매 호출마다 새로 생성 - 단순성 우선)
async fn signed_provider(
&self,
) -> anyhow::Result<impl Provider> {
let signer: PrivateKeySigner = self.private_key.parse()
.map_err(|e| anyhow::anyhow!("개인키 파싱 실패: {}", e))?;
let wallet = EthereumWallet::from(signer);
let provider = ProviderBuilder::new()
.with_recommended_fillers()
.wallet(wallet)
.on_http(self.rpc_url.parse()
.map_err(|e| anyhow::anyhow!("RPC URL 오류: {}", e))?);
Ok(provider)
}
/// 해시를 블록체인에 기록
pub async fn record_hash(
&self,
event_id: &str,
data_hash: [u8; 32],
) -> anyhow::Result<BlockchainReceipt> {
let provider = self.signed_provider().await?;
let contract = TraceRecord::new(self.contract_address, &provider);
let hash_bytes: FixedBytes<32> = FixedBytes::from(data_hash);
tracing::debug!(
event_id = event_id,
hash = %hex::encode(&data_hash),
"recordHash 트랜잭션 전송"
);
let pending = contract
.recordHash(event_id.to_string(), hash_bytes)
.send()
.await
.map_err(|e| anyhow::anyhow!("트랜잭션 전송 실패: {}", e))?;
let tx_hash = format!("{:?}", pending.tx_hash());
// 영수증 대기 (최대 60초)
let receipt = tokio::time::timeout(
std::time::Duration::from_secs(60),
pending.get_receipt(),
)
.await
.map_err(|_| anyhow::anyhow!("트랜잭션 타임아웃 (60초)"))?
.map_err(|e| anyhow::anyhow!("영수증 대기 실패: {}", e))?;
if !receipt.status() {
return Err(anyhow::anyhow!(
"트랜잭션 revert됨: {}",
tx_hash
));
}
let block_number = receipt.block_number.unwrap_or(0);
tracing::info!(
event_id = event_id,
tx_hash = %tx_hash,
block_number = block_number,
gas_used = receipt.gas_used,
"recordHash 완료"
);
Ok(BlockchainReceipt {
tx_hash,
block_number,
})
}
/// 온체인 레코드 조회
pub async fn get_record(
&self,
event_id: &str,
) -> anyhow::Result<Option<OnChainRecord>> {
let provider = ProviderBuilder::new()
.on_http(self.rpc_url.parse()?);
let contract = TraceRecord::new(self.contract_address, &provider);
let result = contract
.getRecord(event_id.to_string())
.call()
.await
.map_err(|e| anyhow::anyhow!("getRecord 실패: {}", e))?;
let record = result._0;
if !record.exists {
return Ok(None);
}
Ok(Some(OnChainRecord {
data_hash: *record.dataHash,
timestamp: record.timestamp.to::<u64>(),
recorder: format!("{}", record.recorder),
}))
}
/// 해시 검증
pub async fn verify_hash(
&self,
event_id: &str,
expected_hash: [u8; 32],
) -> anyhow::Result<bool> {
let provider = ProviderBuilder::new()
.on_http(self.rpc_url.parse()?);
let contract = TraceRecord::new(self.contract_address, &provider);
let hash_bytes: FixedBytes<32> = FixedBytes::from(expected_hash);
let result = contract
.verifyHash(event_id.to_string(), hash_bytes)
.call()
.await
.map_err(|e| anyhow::anyhow!("verifyHash 실패: {}", e))?;
Ok(result._0)
}
}
DID 컨트랙트 - 분산 신원 관리
platform은 DID(Decentralized Identifier)를 사용하여 농업인의 신원을 블록체인으로 관리한다. DID는 W3C 표준으로, 중앙 기관 없이 신원을 검증할 수 있다.
DID 형식
did:ethr:0x742d35Cc6634C0532925a3b844Bc454e4438f44e
│ │ └─ Ethereum 주소
│ └─ DID 메서드 (ethr = Ethereum 기반)
└─ DID 스킴
DID 컨트랙트 (단순화)
// apps/account/contracts/src/DID.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract DIDRegistry {
struct DIDDocument {
string did;
address owner;
bytes publicKey;
string[] serviceEndpoints;
uint256 createdAt;
bool active;
}
mapping(string => DIDDocument) public documents;
mapping(address => string) public addressToDid;
event DIDRegistered(string indexed did, address indexed owner);
event DIDDeactivated(string indexed did);
function register(
string calldata did,
bytes calldata publicKey,
string[] calldata serviceEndpoints
) external {
require(bytes(documents[did].did).length == 0, "DID already exists");
require(bytes(addressToDid[msg.sender]).length == 0, "Address already has DID");
documents[did] = DIDDocument({
did: did,
owner: msg.sender,
publicKey: publicKey,
serviceEndpoints: serviceEndpoints,
createdAt: block.timestamp,
active: true
});
addressToDid[msg.sender] = did;
emit DIDRegistered(did, msg.sender);
}
function resolve(string calldata did)
external
view
returns (DIDDocument memory)
{
require(documents[did].active, "DID not found or deactivated");
return documents[did];
}
function deactivate(string calldata did) external {
require(documents[did].owner == msg.sender, "Not DID owner");
documents[did].active = false;
emit DIDDeactivated(did);
}
}
Rust에서 DID 등록
// apps/account/src/services/did.rs
sol! {
#[sol(rpc)]
contract DIDRegistry {
struct DIDDocument {
string did;
address owner;
bytes publicKey;
string[] serviceEndpoints;
uint256 createdAt;
bool active;
}
function register(
string calldata did,
bytes calldata publicKey,
string[] calldata serviceEndpoints
) external;
function resolve(string calldata did)
external view returns (DIDDocument memory);
event DIDRegistered(string indexed did, address indexed owner);
}
}
pub async fn register_did(
blockchain: &BlockchainService,
account_address: Address,
public_key: Vec<u8>,
) -> anyhow::Result<String> {
// DID 생성: did:ethr:{address}
let did = format!("did:ethr:{}", account_address);
// 서비스 엔드포인트 (플랫폼 API)
let endpoints = vec![
"https://platform.example.com/api/v1".to_string()
];
// 블록체인에 등록
let tx_hash = blockchain
.register_did(&did, public_key, endpoints)
.await?;
tracing::info!(did = %did, tx_hash = %tx_hash, "DID 등록 완료");
Ok(did)
}
UUPS 프록시로 컨트랙트 업그레이드
platform의 TraceRecord와 DID 컨트랙트는 UUPS(Universal Upgradeable Proxy Standard) 패턴을 사용한다. 이를 통해 컨트랙트 로직을 업그레이드해도 기존 데이터와 주소가 유지된다.
UUPS 패턴의 구조
사용자/Rust 코드 → 프록시 컨트랙트 (주소 불변)
│
│ delegatecall
▼
구현 컨트랙트 (로직, 교체 가능)
(데이터는 프록시에 저장됨)
OpenZeppelin UUPS 사용
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract TraceRecordV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
mapping(string => bytes32) private _hashes;
mapping(string => uint256) private _timestamps;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address initialOwner) public initializer {
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
}
function recordHash(string calldata eventId, bytes32 hash) external onlyOwner {
_hashes[eventId] = hash;
_timestamps[eventId] = block.timestamp;
}
function getHash(string calldata eventId) external view returns (bytes32) {
return _hashes[eventId];
}
// 업그레이드 권한 - 오너만 가능
function _authorizeUpgrade(address newImplementation)
internal
override
onlyOwner
{}
}
// V2 - 새 기능 추가
contract TraceRecordV2 is TraceRecordV1 {
// 기존 storage layout 유지 필수!
mapping(string => address) private _recorders; // 새 storage 추가
function recordHashWithRecorder(
string calldata eventId,
bytes32 hash
) external onlyOwner {
_hashes[eventId] = hash;
_timestamps[eventId] = block.timestamp;
_recorders[eventId] = msg.sender;
}
}
Rust에서 프록시 배포
// apps/account/src/foundry/contract.rs
pub async fn deploy_upgradeable_trace_record(
rpc_url: &str,
deployer_key: &str,
owner: Address,
) -> anyhow::Result<Address> {
// 1. 구현 컨트랙트 배포
let impl_address = deploy_implementation(rpc_url, deployer_key).await?;
// 2. 초기화 데이터 인코딩
// initialize(owner) 함수 호출 데이터
use alloy::sol_types::SolCall;
let init_data = TraceRecordV1::initializeCall { initialOwner: owner }
.abi_encode();
// 3. ERC1967Proxy 배포 (구현 주소 + 초기화 데이터)
let proxy_address = deploy_proxy(
rpc_url,
deployer_key,
impl_address,
init_data,
).await?;
tracing::info!(
impl_address = %impl_address,
proxy_address = %proxy_address,
"UUPS 프록시 배포 완료"
);
// 4. 실제로 사용하는 것은 proxy_address
Ok(proxy_address)
}
업그레이드 시:
pub async fn upgrade_to_v2(
rpc_url: &str,
owner_key: &str,
proxy_address: Address,
new_impl_address: Address,
) -> anyhow::Result<()> {
// upgradeToAndCall() 호출
// 프록시가 새 구현으로 교체됨
// 저장된 데이터(해시들)는 그대로 유지
Ok(())
}
재시도 패턴
블록체인 기록 실패는 흔히 발생한다 (네트워크 일시 장애, 가스 부족 등). platform은 실패한 기록을 나중에 재시도하는 패턴을 사용한다:
// apps/iksan-api/src/services/retry.rs
pub struct RetryService {
db: PgPool,
blockchain: Arc<BlockchainService>,
}
impl RetryService {
/// 주기적으로 실행: pending 상태 레코드 재시도
pub async fn retry_pending_records(&self) -> anyhow::Result<()> {
// pending 상태인 blockchain_records 조회
let pending = sqlx::query!(
r#"
SELECT br.id, br.event_id, te.data_hash
FROM blockchain_records br
JOIN trace_events te ON te.id = br.event_id
WHERE br.status = 'pending'
AND br.recorded_at < NOW() - INTERVAL '5 minutes'
ORDER BY br.recorded_at ASC
LIMIT 10
"#,
)
.fetch_all(&self.db)
.await?;
for record in pending {
let hash_bytes: [u8; 32] = hex::decode(
record.data_hash.trim_start_matches("0x")
)
.unwrap_or_default()
.try_into()
.unwrap_or([0u8; 32]);
match self.blockchain.record_hash(&record.event_id.to_string(), hash_bytes).await {
Ok(receipt) => {
sqlx::query!(
"UPDATE blockchain_records
SET tx_hash = $1, block_number = $2, status = 'confirmed', recorded_at = NOW()
WHERE id = $3",
receipt.tx_hash,
receipt.block_number as i64,
record.id,
)
.execute(&self.db)
.await?;
tracing::info!(event_id = %record.event_id, "재시도 성공");
}
Err(e) => {
tracing::warn!(event_id = %record.event_id, error = %e, "재시도 실패");
}
}
}
Ok(())
}
}
// main.rs에서 백그라운드 태스크로 실행
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(300)); // 5분마다
loop {
interval.tick().await;
if let Err(e) = retry_service.retry_pending_records().await {
tracing::error!("재시도 실패: {}", e);
}
}
});
검증 흐름
GET /events/:id/verify 엔드포인트의 처리 흐름:
// apps/iksan-api/src/services/event.rs
pub async fn verify_event(
db: &PgPool,
blockchain: &BlockchainService,
event_id: &str,
) -> Result<VerifyResult, AppError> {
// 1. DB에서 이벤트 조회
let event = sqlx::query!(
"SELECT id, payload, event_type, data_hash FROM trace_events WHERE id = $1",
Uuid::parse_str(event_id)?,
)
.fetch_optional(db)
.await?
.ok_or_else(|| AppError::NotFound(format!("이벤트 없음: {}", event_id)))?;
let db_hash = event.data_hash.clone();
// 2. DB 데이터로 해시 재계산 (데이터 자체의 무결성 검증)
let recalculated_hash = {
let hash_input = format!(
"{}{}{}",
event.id,
event.event_type,
event.payload
);
let bytes = alloy::primitives::keccak256(hash_input.as_bytes());
format!("0x{}", hex::encode(bytes.as_slice()))
};
// 3. DB에 저장된 해시와 재계산 해시 비교
let db_integrity = recalculated_hash == db_hash;
if !db_integrity {
// 매우 심각한 상황: DB 데이터가 변조됨
tracing::error!(
event_id = event_id,
stored_hash = %db_hash,
recalculated = %recalculated_hash,
"DB 데이터 변조 의심!"
);
}
// 4. 온체인 해시와 DB 해시 비교
let hash_bytes: [u8; 32] = hex::decode(db_hash.trim_start_matches("0x"))
.map_err(|e| AppError::Internal(e.into()))?
.try_into()
.map_err(|_| AppError::Internal(anyhow::anyhow!("해시 크기 오류")))?;
match blockchain.verify_hash(event_id, hash_bytes).await {
Ok(on_chain_matches) => {
Ok(VerifyResult {
event_id: event_id.to_string(),
db_hash,
db_integrity, // DB 내부 일관성
blockchain_verified: on_chain_matches, // DB-체인 일치
is_fully_valid: db_integrity && on_chain_matches,
message: match (db_integrity, on_chain_matches) {
(true, true) => "완전 무결성 확인".to_string(),
(true, false) => "DB는 일관적이나 체인과 불일치 (미기록 또는 변조)".to_string(),
(false, _) => "DB 데이터 변조 의심!".to_string(),
},
})
}
Err(e) => {
Err(AppError::Blockchain(format!("온체인 검증 실패: {}", e)))
}
}
}
이벤트 인덱싱 (선택적)
이벤트 수가 많아지면 체인에서 과거 이벤트를 효율적으로 조회하기 어렵다. platform에서는 HashRecorded 이벤트를 인덱싱하는 백그라운드 서비스를 운영한다:
// apps/iksan-api/src/services/indexer.rs
pub async fn index_blockchain_events(
provider: &impl Provider,
db: &PgPool,
contract_address: Address,
from_block: u64,
) -> anyhow::Result<()> {
use alloy::rpc::types::Filter;
use alloy::sol_types::SolEvent;
let filter = Filter::new()
.address(contract_address)
.event_signature(TraceRecord::HashRecorded::SIGNATURE_HASH)
.from_block(from_block);
let logs = provider.get_logs(&filter).await?;
for log in logs {
if let Ok(event) = TraceRecord::HashRecorded::decode_log(
log.inner.as_ref(),
true,
) {
let event_id = &event.eventId;
let tx_hash = format!("{:?}", log.transaction_hash.unwrap_or_default());
let block_number = log.block_number.unwrap_or(0);
// blockchain_records 업데이트
sqlx::query!(
r#"
INSERT INTO blockchain_records (event_id, tx_hash, block_number, status)
VALUES ($1::uuid, $2, $3, 'confirmed')
ON CONFLICT (event_id) DO UPDATE
SET tx_hash = EXCLUDED.tx_hash,
block_number = EXCLUDED.block_number,
status = 'confirmed'
"#,
Uuid::parse_str(event_id).ok(),
tx_hash,
block_number as i64,
)
.execute(db)
.await?;
}
}
Ok(())
}
요약
platform의 블록체인 연동 핵심:
- 해시 계산:
keccak256(event_id + type + normalized_payload)- 결정론적 - DB 우선: 이벤트는 항상 DB에 먼저 저장, 블록체인은 이후
- 비동기 처리: 블록체인 실패가 API를 막지 않음
- 재시도:
pending상태 레코드를 주기적으로 재처리 - DID: 농업인 신원을 탈중앙 방식으로 관리
- UUPS 프록시: 컨트랙트 로직 업그레이드 가능, 데이터 유지
- 검증: DB 해시 재계산 + 온체인 비교로 이중 검증
다음 장에서는 실제 platform 코드를 읽는 순서와 방법을 안내한다.
22-3: Platform 코드 읽는 법과 인수인계 체크리스트
코드를 읽는 순서
처음 프로젝트를 받았을 때 막막하다면 다음 순서로 읽어라. 무작위로 파일을 열지 말고 계층을 따라 내려가는 것이 핵심이다.
1단계: 진입점 파악 (main.rs)
모든 Rust 서비스는 main.rs에서 시작한다. main.rs를 읽으면 서비스의 전체 구조가 보인다.
apps/iksan-api/src/main.rs
읽을 때 찾아야 할 것들:
- 어떤 환경변수를 읽는가? → 배포 설정 이해
- 어떤 외부 의존성을 초기화하는가? (DB, 블록체인, HTTP 클라이언트)
- 어떤 라우터를 마운트하는가? → API 엔드포인트 구조
// main.rs에서 확인할 패턴
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 로깅: 어떤 레벨과 필터를 사용하는가
tracing_subscriber::fmt()
.with_env_filter("info")
.init();
// 설정: 어떤 환경변수가 필요한가
let config = Config::from_env()?;
// AppState: 어떤 의존성을 갖는가
let state = AppState::new(config).await?;
// 라우터: 어떤 경로가 있는가
let app = create_app(state);
// 서버: 포트와 리슨 설정
axum::serve(listener, app).await?;
}
2단계: AppState 이해 (core/app.rs)
apps/iksan-api/src/core/app.rs
AppState는 서비스의 “신경계“다. 여기서 다음을 파악한다:
- 어떤 서비스 객체들이 있는가
- 의존성 주입 구조는 어떻게 되는가
- 초기화 순서는 어떻게 되는가
NestJS 개발자라면 AppModule의 providers 목록을 읽는 것과 같다.
3단계: 블록체인 서비스 (services/blockchain.rs)
apps/iksan-api/src/services/blockchain.rs
이 파일이 이 교재의 핵심이다. Alloy 사용 패턴, sol! 매크로, 트랜잭션 전송, 영수증 처리가 모두 여기 있다.
읽을 때 집중해야 할 것:
sol!매크로로 어떤 컨트랙트를 정의했는가- Provider를 어떻게 생성하는가 (with_recommended_fillers 등)
- 에러 처리를 어떻게 하는가
- 비동기 처리 패턴
4단계: 이벤트 서비스 (services/event.rs)
apps/iksan-api/src/services/event.rs
비즈니스 로직과 블록체인 연동이 결합되는 곳이다.
집중 포인트:
- 해시 계산 로직 (keccak256 입력 구성)
- DB 저장과 블록체인 기록의 순서
- 실패 처리 (블록체인 실패 시 어떻게 하는가)
- Result/에러 전파 패턴
5단계: 컨트랙트 (contracts/src/TraceRecord.sol)
apps/iksan-api/contracts/src/TraceRecord.sol
Rust 코드에서 호출하는 컨트랙트의 실제 구현이다.
집중 포인트:
- 어떤 storage 변수가 있는가 (데이터 구조)
- 어떤 함수가 있는가 (public/external)
- 어떤 이벤트를 emit하는가
- 접근 제어는 어떻게 되는가 (onlyOwner 등)
6단계: account 서비스의 컨트랙트 배포 (foundry/contract.rs)
apps/account/src/foundry/contract.rs
Rust에서 forge를 어떻게 실행하는지, 새 고객의 컨트랙트를 어떻게 자동 배포하는지 볼 수 있다.
7단계: 인증 서비스 (services/auth.rs)
apps/account/src/services/auth.rs
JWT 발급과 검증 로직이다. 다른 서비스의 auth 미들웨어와 연결 지점을 이해할 수 있다.
핵심 파일별 주목할 패턴
apps/iksan-api/src/main.rs
// 주목 1: tokio::spawn으로 백그라운드 태스크
tokio::spawn(async move {
retry_service.run_forever().await;
});
// 주목 2: graceful shutdown
tokio::signal::ctrl_c().await?;
tracing::info!("종료 신호 수신, 서버 종료 중...");
// 주목 3: 레이어 순서 (아래서 위로 실행됨)
let app = router
.layer(TraceLayer::new_for_http()) // 3번째 실행
.layer(CorsLayer::permissive()) // 2번째 실행
.layer(CompressionLayer::new()); // 1번째 실행
apps/iksan-api/src/core/app.rs
use std::sync::Arc;
pub struct BlockchainService;
pub struct EventService;
pub struct DbPool;
impl BlockchainService {
pub async fn new() -> anyhow::Result<Self> {
Ok(Self)
}
}
impl EventService {
pub fn new(db: Arc<DbPool>, blockchain: Arc<BlockchainService>) -> Self {
let _ = (db, blockchain);
Self
}
}
// 주목 1: 무거운 객체(DB 풀, HTTP 클라이언트)는 Arc로 감싸 참조 공유
// 주목 2: Clone은 Arc 참조만 복사
#[derive(Clone)]
pub struct AppState {
pub blockchain: Arc<BlockchainService>,
pub event_service: Arc<EventService>,
}
// 주목 3: 의존성 순서
// blockchain → event_service (blockchain을 주입받음)
async fn build_state(db: Arc<DbPool>) -> anyhow::Result<AppState> {
let blockchain = Arc::new(BlockchainService::new().await?);
let event_service = Arc::new(EventService::new(db, Arc::clone(&blockchain)));
Ok(AppState { blockchain, event_service })
}
apps/iksan-api/src/services/blockchain.rs
// 주목 1: sol! 매크로의 #[sol(rpc)] 속성
sol! {
#[sol(rpc)] // 이게 있어야 .call(), .send() 가능
contract TraceRecord {
function recordHash(bytes32 dataHash) external;
function getRecord(bytes32 dataHash) external view returns (address recorder, uint256 timestamp);
}
}
// 주목 2: 타입 변환
// Rust [u8; 32] → Alloy FixedBytes<32>
let hash: FixedBytes<32> = FixedBytes::from(raw_bytes);
// 주목 3: 타임아웃 처리
let receipt = tokio::time::timeout(
Duration::from_secs(60),
pending.get_receipt(),
).await??; // ? 두 번: timeout 에러, 영수증 에러
// 주목 4: tracing structured logging
tracing::info!(
event_id = event_id, // key=value 형식
tx_hash = %tx_hash, // % = Display trait 사용
block_number = block_number,
"recordHash 완료"
);
apps/iksan-api/src/services/event.rs
// 주목 1: BTreeMap으로 JSON 정규화 (키 정렬)
let sorted: BTreeMap<_, _> = payload.as_object()
.unwrap()
.iter()
.collect();
// 주목 2: 에러 전파 패턴
let event = sqlx::query_as!(
TraceEvent,
"SELECT id, payload, data_hash FROM trace_events WHERE id = $1",
event_id
)
.fetch_one(&mut *tx) // 트랜잭션 내에서 실행
.await
.map_err(AppError::Database)?; // sqlx::Error → AppError
// 주목 3: match로 블록체인 실패 처리
match blockchain.record_hash(event.data_hash).await {
Ok(receipt) => {
tracing::info!(tx_hash = %receipt.tx_hash, "온체인 기록 성공");
}
Err(e) => {
tracing::warn!(error = %e, "온체인 기록 실패");
// API는 성공, 블록체인만 나중에 재시도
}
}
apps/iksan-api/contracts/src/TraceRecord.sol
// 주목 1: UUPS 업그레이드 패턴
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
// 주목 2: storage layout 주의 (업그레이드 시)
// 새 변수는 반드시 기존 변수 뒤에 추가
mapping(string => Record) private records; // slot 0
string[] public eventIds; // slot 1
// 업그레이드 후 추가:
// uint256 public newFeature; // slot 2 (반드시)
// 주목 3: custom error (gas 효율적)
error NotOwner(address caller); // string error보다 저렴
revert NotOwner(msg.sender);
// 주목 4: indexed event parameter
event HashRecorded(
string indexed eventId, // indexed: 필터링 가능
bytes32 dataHash, // not indexed: 값만 기록
address indexed recorder
);
apps/account/src/foundry/contract.rs
// 주목 1: 외부 프로세스 실행
let output = tokio::process::Command::new("forge")
.args([
"create",
"--rpc-url",
rpc_url,
"--private-key",
deployer_private_key,
"contracts/TraceRecord.sol:TraceRecord",
])
.output()
.await?;
// 주목 2: stdout 파싱으로 배포 주소 추출
// forge create 출력 형식:
// "Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3"
let address = output.stdout
.lines()
.find(|l| l.contains("Deployed to:"))
.and_then(|l| l.split_whitespace().last())
.ok_or(anyhow::anyhow!("배포 주소 파싱 실패"))?;
apps/account/src/services/auth.rs
// 주목 1: Argon2 패스워드 해싱 (bcrypt보다 권장)
use argon2::{Argon2, PasswordHash, PasswordVerifier};
// 주목 2: JWT 클레임 구조
#[derive(Serialize, Deserialize)]
struct Claims {
sub: String, // subject (user id)
email: String,
role: String,
exp: u64, // expiration timestamp
iat: u64, // issued at
}
// 주목 3: refresh token rotation
// 리프레시 토큰 사용 시 새 토큰 발급 + 구 토큰 무효화
pub async fn refresh_token(&self, old_refresh: &str) -> Result<TokenPair> {
let token = self.validate_refresh_token(old_refresh).await?;
self.revoke_token(old_refresh).await?; // 구 토큰 무효화
self.issue_new_tokens(token.user_id).await // 새 토큰 쌍 발급
}
Rust 역량 인수인계 체크리스트
platform 코드를 유지보수하려면 다음 역량이 필요하다. 각 항목을 확인해보자.
필수 Rust 역량 (10항목)
-
1. 소유권과 빌림:
&T,&mut T,T의 차이를 코드에서 즉시 파악- 확인:
blockchain.rs의&selfvs&mut self메서드 구분
- 확인:
-
2. 라이프타임: 기본 라이프타임 어노테이션 읽기 (복잡한 것은 나중에)
- 확인: 컴파일러 에러 메시지에서 라이프타임 힌트 이해
-
3. Result와 ? 연산자: 에러 전파 체인 추적
- 확인:
event.rs의?체인을 따라가며 에러 흐름 파악
- 확인:
-
4. async/await와 Tokio: Future, 비동기 함수,
.await이해- 확인:
tokio::spawn,tokio::time::timeout사용 패턴
- 확인:
-
5. Arc와 Mutex: 멀티스레드 공유 상태 처리
- 확인:
Arc<BlockchainService>가 여러 요청 핸들러에서 공유되는 방식
- 확인:
-
6. 트레이트 객체:
dyn Trait,impl Trait구분- 확인:
Provider트레이트를 반환하는 함수들
- 확인:
-
7. Serde:
#[derive(Serialize, Deserialize)],#[serde(rename_all)]등- 확인:
models.rs의 JSON 직렬화 설정
- 확인:
-
8. 매크로 읽기:
sol!,sqlx::query!,tracing::info!등- 확인: 매크로 출력이 무엇인지 대략 추측 가능
-
9. 에러 타입 정의:
thiserror로 에러 enum 작성- 확인:
errors.rs를 수정하여 새 에러 variant 추가
- 확인:
-
10. cargo 명령어:
build,test,clippy,fmt- 확인: CI에서 실행되는 명령어 모두 로컬에서 실행 가능
필수 블록체인 역량 (10항목)
-
1. Solidity 기초: 컨트랙트, 함수 가시성, modifier, event, error
- 확인:
TraceRecord.sol전체를 읽고 동작 설명 가능
- 확인:
-
2. ABI 이해: 함수 시그니처, 인자 인코딩, 반환값 디코딩
- 확인:
sol!매크로가 생성하는 타입 이름 예측 가능
- 확인:
-
3. 트랜잭션 라이프사이클: mempool → 채굴 → 확정
- 확인:
pending.get_receipt()가 왜 필요한지 설명 가능
- 확인:
-
4. 가스와 비용: 가스 추정,
with_recommended_fillers의 역할- 확인: Besu에서 가스 비용이 0인 이유 설명 가능
-
5. Alloy Provider: HTTP Provider, 서명자 연결,
with_recommended_fillers- 확인: 새 Provider를 직접 구성하여 테스트 가능
-
6. sol! 매크로: 인라인 ABI, JSON ABI, #[sol(rpc)] 속성
- 확인: 새 컨트랙트 함수를 sol! 에 추가하고 Rust에서 호출 가능
-
7. 이벤트 필터링: Filter 구성, decode_log, indexed 파라미터
- 확인: 특정 event를 과거 블록에서 조회하는 코드 작성 가능
-
8. UUPS 프록시: 구현/프록시 분리, 업그레이드 절차, storage layout
- 확인: 새 storage 변수를 추가하면 왜 위험한지 설명 가능
-
9. keccak256 해시: 해시 계산, 입력 정규화, 32바이트 처리
- 확인: 동일한 이벤트가 항상 같은 해시를 생성하는지 확인 가능
-
10. Besu 설정: genesis.json, IBFT 2.0, min-gas-price=0
- 확인: 로컬 Besu 네트워크를 Docker로 시작하고 컨트랙트 배포 가능
코드 수정 시 주의사항
TraceRecord.sol 수정 시
위험: storage layout 변경
안전: 새 함수 추가, 이벤트 추가, 새 storage 변수 끝에 추가
절대 금지:
- 기존 storage 변수 순서 변경
- 기존 storage 변수 타입 변경
- 기존 storage 변수 삭제
UUPS 업그레이드 절차:
1. V2 컨트랙트 작성 (V1 storage 그대로 유지)
2. 로컬에서 테스트
3. 테스트넷에 새 구현 배포
4. upgradeToAndCall() 호출
5. 검증 후 프로덕션 적용
blockchain.rs의 sol! 매크로 수정 시
컨트랙트와 sol! 정의가 불일치하면 런타임 에러가 발생한다. 컨트랙트를 수정할 때마다 sol!도 동기화해야 한다.
// 컨트랙트에 새 함수를 추가했다면 sol!에도 추가
sol! {
#[sol(rpc)]
contract TraceRecord {
// 기존
function recordHash(bytes32 dataHash) external;
function getRecord(bytes32 dataHash) external view returns (Record memory);
// 새로 추가
function batchRecordHashes(
string[] calldata eventIds,
bytes32[] calldata hashes
) external;
}
}
DB 마이그레이션 주의사항
-- 안전: 새 컬럼 추가 (nullable 또는 default 있어야 함)
ALTER TABLE trace_events ADD COLUMN metadata JSONB;
-- 안전: 새 인덱스 추가 (CONCURRENTLY로 무중단)
CREATE INDEX CONCURRENTLY idx_new ON trace_events(new_column);
-- 위험: 컬럼 삭제 (먼저 코드에서 참조 제거 후 삭제)
-- ALTER TABLE trace_events DROP COLUMN old_column;
-- 위험: 컬럼 타입 변경 (데이터 손실 가능)
-- ALTER TABLE trace_events ALTER COLUMN id TYPE BIGINT;
1달 후 다음 단계 학습 추천
platform 코드에 익숙해진 후 성장하기 위한 다음 학습 경로:
Rust 심화
1순위: 비동기 Rust 심화
- 교재: Tokio 공식 튜토리얼
- 내용: select!, join!, mpsc 채널, 백프레셔 처리
- 왜: platform의 백그라운드 태스크와 재시도 로직 개선에 직접 필요
2순위: 에러 처리 고급
- 교재: Error Handling in Rust - A Deep Dive (corrode.dev)
- 내용: error context, anyhow vs thiserror 선택, 에러 체인
- 왜: 프로덕션 장애 대응 시 에러 메시지 품질이 핵심
3순위: Rust 성능 최적화
- 교재: The Rust Performance Book
- 내용: 불필요한 clone 제거, 제로 카피 파싱, flamegraph 프로파일링
- 왜: 트랜잭션 수가 증가하면 성능 최적화가 필요
블록체인 심화
1순위: Foundry 고급 사용
- 교재: Foundry Book
- 내용: Forge 테스트 작성, fuzz 테스트, invariant 테스트, cast 도구
- 왜: 컨트랙트 업그레이드 전 철저한 테스트가 필수
2순위: 스마트 컨트랙트 보안
- 교재: Cyfrin Updraft - Smart Contract Security
- 내용: reentrancy, integer overflow, access control 취약점
- 왜: TraceRecord에 잘못된 access control이 있으면 누구나 해시를 덮어쓸 수 있음
3순위: EVM 내부 구조
- 교재: evm.codes
- 내용: 옵코드, storage slot 계산, DELEGATECALL 동작
- 왜: UUPS 프록시의 storage collision 문제를 이해하는 데 필수
인프라/운영
서비스 모니터링
- Prometheus + Grafana로 Axum 메트릭 수집
- Besu 노드 상태 모니터링
- 알람 설정 (블록 생성 중단, pending 트랜잭션 급증)
컨트랙트 업그레이드 자동화
- Foundry의
forge script로 업그레이드 스크립트 작성 - 멀티시그(Gnosis Safe)로 업그레이드 권한 분산
자주 발생하는 문제와 해결법
“트랜잭션이 pending 상태에서 멈췄다”
# Besu 노드 상태 확인
curl -X POST http://besu:8545 \
-d '{"jsonrpc":"2.0","method":"txpool_content","id":1}'
# 검증자 목록 확인 (IBFT)
curl -X POST http://besu:8545 \
-d '{"jsonrpc":"2.0","method":"ibft_getValidatorsByBlockNumber","params":["latest"],"id":1}'
# nonce 확인 (낮은 nonce 트랜잭션이 막고 있을 수 있음)
curl -X POST http://besu:8545 \
-d '{"jsonrpc":"2.0","method":"eth_getTransactionCount","params":["0xYOUR_ADDRESS","latest"],"id":1}'
“컨트랙트 호출이 revert됨”
# cast로 revert 이유 확인
cast call \
--rpc-url http://besu:8545 \
0xCONTRACT_ADDRESS \
"recordHash(string,bytes32)" \
"event-001" \
0x1234...
# 또는 Rust에서
let err = contract.recordHash(id, hash).call().await.unwrap_err();
println!("{:#?}", err); // AlloyError 상세 출력
“해시 불일치”
데이터가 변조되거나 해시 계산 로직이 다를 때 발생한다.
// 디버깅: 해시 입력을 출력해서 확인
let hash_input = format!("{}{}{}", event_id, event_type, payload);
println!("해시 입력: {}", hash_input);
println!("해시: {}", hex::encode(keccak256(hash_input.as_bytes())));
// Solidity에서 동일 검증
// bytes32 expected = keccak256(abi.encodePacked(eventId, eventType, payload));
// 주의: Rust의 keccak256(bytes)와 Solidity의 keccak256(abi.encodePacked(...))은 다를 수 있음
요약
platform 코드 읽기 순서:
main.rs→ 서버 구조 파악core/app.rs→ 의존성 구조 파악services/blockchain.rs→ Alloy 패턴 학습services/event.rs→ 비즈니스 로직과 블록체인 연동contracts/TraceRecord.sol→ 온체인 데이터 구조foundry/contract.rs→ 자동 배포 패턴services/auth.rs→ 인증 구조
체크리스트 10+10항목을 완료하면 platform을 안전하게 유지보수할 수 있다. 이후 Foundry 고급, 컨트랙트 보안, Tokio 심화 순으로 성장하면 된다.
이것으로 22장(Platform 분석)을 마친다. 부록에서는 블록체인 생태계 현황과 Node.js → Rust 전환 가이드를 다룬다.
부록 A: 블록체인 생태계 현황 (2026)
주요 블록체인 체인 비교
TVL(Total Value Locked) 기준 순위
TVL은 해당 블록체인의 DeFi 프로토콜에 잠긴 자산 총액이다. 생태계 규모와 활성도를 나타내는 핵심 지표다.
| 순위 | 체인 | TVL (2026년 초) | 특징 |
|---|---|---|---|
| 1 | Ethereum | ~$65B | DeFi/NFT 원조, EVM 표준 |
| 2 | Solana | ~$12B | 고속 저비용, Firedancer |
| 3 | Base (L2) | ~$8B | Coinbase 운영, OP Stack |
| 4 | Arbitrum (L2) | ~$7B | 이더리움 L2, Optimistic Rollup |
| 5 | BNB Chain | ~$6B | Binance 생태계 |
| 6 | Tron | ~$5.5B | 스테이블코인 중심 |
| 7 | Avalanche | ~$1.5B | 서브넷, 기업 체인 |
| 8 | Polygon | ~$1B | L2/사이드체인 |
출처: MEXC 분석 리포트 (https://www.mexc.com/learn/article/solana-vs-ethereum-l2s-2026-fundamental-analysis-tvl-revenue-stablecoin-metrics/1)
L2가 메인스트림: 2026년 기준 새로 배포되는 스마트 컨트랙트의 65% 이상이 L2(Arbitrum, Base, Optimism, zkSync 등)에 배포된다. 이더리움 메인넷은 고가치 자산 결제와 L2 결제 레이어로 자리 잡았다.
출처: CoinLaw L2 통계 (https://coinlaw.io/layer-2-networks-adoption-statistics/)
개발자 수
| 체인 | 월간 활성 개발자 (2025) | 전년 대비 성장 |
|---|---|---|
| Ethereum (+ L2) | ~7,800 | +12% |
| Solana | ~2,100 | +38% |
| BNB Chain | ~1,200 | -5% |
| Polkadot | ~900 | +8% |
| Near | ~650 | +15% |
| Cosmos | ~600 | +5% |
| Avalanche | ~550 | -3% |
출처: CoinLaw 블록체인 개발자 통계 (https://coinlaw.io/blockchain-developer-activity-statistics/)
Solana의 개발자 수 급증은 Firedancer 출시와 meme coin 붐, 그리고 모바일 친화적 개발 환경이 기여했다.
트랜잭션 처리량 비교
| 체인 | 최대 TPS | 실제 평균 TPS | 평균 수수료 | 확정 시간 |
|---|---|---|---|---|
| 이더리움 메인넷 | 30 | 15 | $0.5~5 | 12초 |
| Arbitrum | 40,000 | 10~50 | $0.01~0.1 | <1초 |
| Base | 20,000 | 5~30 | $0.01 미만 | <1초 |
| Solana | 65,000 (이론) | 2,000~5,000 | $0.00025 | 0.4초 |
| Solana (Firedancer) | 1,000,000 (목표) | 진행 중 | - | 0.4초 |
| Besu IBFT 2.0 | 1,000+ | 50~200 | 0 (프라이빗) | 1~2초 |
스마트 컨트랙트 언어 점유율
전체 스마트 컨트랙트 코드베이스에서의 언어 사용 비율 (2025년 기준):
| 언어 | 점유율 | 주요 플랫폼 | 특징 |
|---|---|---|---|
| Solidity | 87% | Ethereum, EVM 호환 체인 전체 | 가장 성숙한 생태계 |
| Vyper | 4.2% | Ethereum | Python 유사 문법, 단순성 |
| Rust | 2.3% | Solana, NEAR, Polkadot | 성능과 안전성 |
| Move | 2.1% | Aptos, Sui | 자원 중심 타입 시스템 |
| Go (Chaincode) | 1.8% | Hyperledger Fabric | 기업 블록체인 |
| Cairo | 1.4% | StarkNet | ZK 증명용 |
| 기타 | 1.2% | 다양 | Ink! (Polkadot), Leo (Aleo) 등 |
출처: Yield App Labs 분석 (https://yieldapplabs.medium.com/solidity-vs-rust-move-e6fec78f77df)
Solidity의 압도적 지위: EVM이 블록체인의 표준 VM으로 자리 잡았기 때문이다. Ethereum, Polygon, Arbitrum, Base, Optimism, Avalanche C-Chain, BNB Chain, Besu 모두 EVM 호환이다. Solidity 한 번 배우면 이 모든 환경에서 사용 가능하다.
2025-2026 주요 동향
1. Ethereum Pectra 업그레이드 (2025년 5월)
Pectra는 Prague + Electra의 합성어로, 2025년 5월에 활성화된 이더리움의 주요 업그레이드다.
핵심 EIP:
EIP-7702 (계정 추상화) 가장 중요한 변경. 일반 EOA(외부 소유 계정)가 스마트 컨트랙트처럼 동작할 수 있게 된다.
기존: 지갑은 서명만 가능, 가스는 ETH로만 지불
EIP-7702 이후:
- 가스를 다른 토큰으로 지불 가능 (USDC 등)
- 배치 트랜잭션: 여러 tx를 하나로
- 소셜 복구: 개인키 분실 시 지정 계정이 복구
- 세션 키: 게임/DeFi에서 매번 서명 불필요
이것이 왜 중요한가? 일반 사용자가 “가스비“와 “지갑“을 의식하지 않아도 블록체인 앱을 쓸 수 있게 된다. 웹2 수준의 UX가 가능해진다.
EIP-7251 (검증자 최대 잔액 증가) 검증자 최대 잔액을 32 ETH에서 2,048 ETH로 증가. 검증자 수가 줄어들어 네트워크 부담 감소.
EIP-6110 (검증자 예치 온체인화) 검증자 예치 프로세스를 완전히 온체인으로 이동. 검증자 활성화 시간이 며칠에서 몇 시간으로 단축.
출처: ethereum.org Pectra 로드맵 (https://ethereum.org/roadmap/pectra/)
2. Solana Firedancer - 성능 혁명
Firedancer는 Jump Trading의 자회사 Jump Crypto가 개발한 Solana의 새 검증자 클라이언트다. C/C++로 완전히 새로 구현되어 극단적인 성능을 목표로 한다.
핵심 성과:
- 이론적 최대 TPS: 1,000,000 (백만)
- 테스트넷에서 실제 달성: ~1,000,000 TPS (2025년)
- 2025년 10월 Solana 메인넷 배포 시작
왜 중요한가:
- 현재 Solana의 병목은 네트워크/합의, Firedancer는 처리 계층을 완전히 재설계
- 기존 Agave(구 Solana Labs) 클라이언트와 다른 구현체 → 클라이언트 다양성 향상
- 검증자 중단 시 네트워크 안정성 개선
출처: The Block (https://www.theblock.co/post/382411/jump-cryptos-firedancer-hits-solana-mainnet)
3. RWA (Real World Assets) 토큰화 - $17B TVL
실물자산 토큰화(RWA)는 부동산, 채권, 금, 탄소 크레딧 등 현실 자산을 블록체인 토큰으로 표현하는 것이다.
2025-2026 주요 수치:
- 온체인 RWA TVL: $17B (2025년 기준)
- 전년 대비 성장: +300%
- RWA TVL이 DEX(탈중앙화 거래소) TVL을 초과
주요 사례:
- BlackRock BUIDL: 국채 펀드 토큰화 ($500M+)
- Franklin Templeton: 국채 펀드 온체인 (Stellar, Polygon)
- Centrifuge: 기업 채권 DeFi 담보
platform과의 연관성: 식품 공급망 데이터 무결성 증명은 RWA 토큰화의 전제 조건이다. 농산물 이력이 검증된 데이터라면, 그 농산물을 담보로 한 금융 상품(수확 전 대출 등)을 블록체인에서 발행할 수 있다.
출처: UNLOCK Blockchain (https://www.unlock-bc.com/153930/real-world-assets-step-into-defis-core-surpassing-dexs-by-tvl)
4. EigenLayer 리스테이킹 - $19.7B TVL
EigenLayer는 이더리움 ETH 스테이킹의 경제적 보안을 다른 프로토콜이 재사용(리스테이킹)할 수 있게 하는 프로토콜이다.
작동 원리:
기존 스테이킹:
ETH 스테이커 → 이더리움 보안 담보
EigenLayer 리스테이킹:
ETH 스테이커 → 이더리움 보안 담보
→ EigenDA(데이터 가용성) 보안 담보 (추가 수익)
→ AVS1, AVS2 보안 담보 (추가 수익)
규모:
- TVL: $19.7B (2025년 피크)
- ETH 스테이킹의 ~15%가 EigenLayer에 리스테이킹
왜 중요한가: L2, 오라클, 브리지 등 새로운 프로토콜이 자체 토큰 없이 ETH의 경제적 보안을 빌려 쓸 수 있다. 블록체인 인프라의 “보안 임대” 시장이 열렸다.
출처: QuickNode (https://blog.quicknode.com/restaking-revolution-eigenlayer-defi-yields-2025/)
Rust가 블록체인에서 중요한 이유
블록체인의 요구사항이 Rust의 특성과 완벽하게 일치한다.
블록체인이 Rust를 선택하는 이유
1. 메모리 안전성 (보안) 스마트 컨트랙트 버그는 수백억 원 손실로 이어질 수 있다. Rust는 컴파일 타임에 메모리 취약점(버퍼 오버플로우, use-after-free, null 포인터)을 근본적으로 차단한다.
2. 성능 블록체인 검증자는 트랜잭션을 밀리초 단위로 처리해야 한다. Rust는 GC(가비지 컬렉션) 없이 C/C++ 수준의 성능을 제공한다.
3. 결정론적 실행 같은 입력에 항상 같은 출력. GC 일시정지가 없으므로 실행 시간이 예측 가능하다. 블록체인 합의에서 필수적이다.
4. 크로스 컴파일과 WebAssembly Rust는 WASM(WebAssembly) 컴파일을 공식 지원한다. Polkadot, NEAR의 스마트 컨트랙트는 Rust → WASM으로 컴파일된다.
프로젝트별 Rust 활용 현황 및 선택 이유
| 프로젝트 | Rust 사용 범위 | 왜 Rust인가 |
|---|---|---|
| Solana | 검증자 클라이언트 전체, 스마트 컨트랙트(Program) | Sealevel 병렬 처리 엔진과 PoH 해시 체인에서 GC 일시정지가 블록 생산 지연으로 직결되므로 GC 없는 예측 가능한 레이턴시가 필수. 스마트 컨트랙트를 네이티브 BPF 바이트코드로 컴파일해 최대 처리량 확보 |
| Polkadot/Substrate | 노드 구현, 팔렛(모듈), 스마트 컨트랙트 | 런타임을 Rust → WASM으로 컴파일하여 하드포크 없는 온체인 업그레이드(forkless upgrade) 구현. 블록 생산 중 GC 일시정지 없는 결정론적 실행 보장. 강력한 타입 시스템으로 크로스체인 메시지(XCM) 포맷 오류를 컴파일 타임에 차단 |
| NEAR Protocol | 노드 구현, 스마트 컨트랙트 | 스마트 컨트랙트를 Rust → WASM으로 컴파일해 결정론적 샌드박스 실행. 금융 코드에서 정수 오버플로우·메모리 오류를 컴파일 타임에 방지. 모든 노드가 동일한 WASM 실행 결과를 보장하는 결정론적 실행 |
| StarkNet | Cairo 컴파일러, 프로버(prover) | ZK 증명 생성(prover)은 CPU 집약적 연산으로 C++ 수준 성능 필요. 컴파일러 버그가 증명 위조로 이어질 수 있어 메모리 안전성이 보안상 필수 |
| Ethereum (Besu) | Java (Rust 아님) | 엔터프라이즈 환경의 Java 생태계 활용. Alloy 라이브러리로 Rust에서 Besu와 연동 가능 |
| Ethereum (Reth) | 실행 클라이언트 전체 | Go 기반 Geth와 성능 경쟁을 위해 채택. tokio 비동기 런타임으로 수천 개 P2P 피어 연결 처리. 병렬 블록 실행을 데이터 레이스 없이 구현. 합의 임계 코드에서 메모리 오류로 인한 슬래싱 위험 차단 |
| Foundry | forge, cast, anvil 전체 | JavaScript 기반 Hardhat 대비 테스트 실행 속도 10~100배 향상. 네이티브 바이너리로 배포되어 Node.js 런타임 의존성 없음 |
| Alloy | Ethereum 클라이언트 라이브러리 | ethers-rs의 후계자. 제로 비용 추상화로 ABI 인코딩/디코딩 성능 극대화. 강력한 타입 시스템으로 컨트랙트 인터페이스 오류를 컴파일 타임에 방지 |
| Lighthouse | Ethereum 합의 클라이언트 | PoS 검증자 서명·슬래싱 방지 로직에서 이중 서명 버그는 스테이킹 자산 손실로 직결. 빌림 검사기가 공유 가변 상태를 통한 이중 서명 시나리오를 원천 차단 |
| platform (이 교재) | 마이크로서비스, Alloy 연동 | Rust 백엔드 + Solidity 컨트랙트 |
출처: DasRoot Rust 블록체인 분석 (https://dasroot.net/posts/2026/02/rust-blockchain-decentralized-systems-performance-security/)
Rust가 블록체인 개발자에게 주는 경쟁 우위
2026년 기준 Rust를 사용할 수 있는 블록체인 개발자는 여전히 희귀하다. JavaScript/TypeScript 스마트 컨트랙트 개발자는 많지만, 다음을 모두 할 수 있는 개발자는 드물다:
✓ Solidity 스마트 컨트랙트 작성
✓ Rust로 백엔드 서비스 구현
✓ Alloy로 컨트랙트 연동
✓ Solana Program 작성 (선택)
✓ 블록체인 인프라 운영
platform 프로젝트를 이해하고 유지보수할 수 있다면, 위 5가지 중 최소 4가지는 이미 갖춘 것이다.
한국 블록체인 기술 생태계
한국은 블록체인 채택률과 개발자 활성도 측면에서 아시아 최상위권이다.
주요 특징:
- 카카오(클레이튼 → 카이아), 라인(LINK), 넷마블(MBX) 등 대형 기업 참여
- 게임파이(GameFi)와 P2E(Play-to-Earn) 생태계 활발
- 정부 주도 공공 블록체인 프로젝트 (행정, 물류)
- RWA와 CBDC 파일럿 프로젝트 진행 중
platform 같은 B2B SaaS가 특히 유망한 이유:
- 식품 안전법 강화로 이력 추적 의무 확대
- HACCP 인증과 블록체인 연동 수요 증가
- 수출 농산물의 원산지 증명 요구 증가
요약
2026년 블록체인 생태계의 핵심 트렌드:
- L2가 주류: 이더리움 L2에서 새 컨트랙트 65%+ 배포
- Pectra로 UX 혁신: 계정 추상화로 일반인도 쓸 수 있는 앱 가능
- Solana의 급성장: Firedancer로 1M TPS 목표, 개발자 38% 성장
- RWA 폭발적 성장: 실물자산 토큰화 $17B TVL, DEX 추월
- EigenLayer: 이더리움 보안을 다른 프로토콜이 임대
- Rust의 지배: 고성능 블록체인 인프라는 대부분 Rust
- Solidity 독주: 스마트 컨트랙트 언어 점유율 87%
이 교재를 완료한 당신은 이 생태계에서 가장 수요가 높은 기술 조합을 보유했다: Rust + Solidity + 블록체인 인프라.
부록 B: Node.js 개발자를 위한 Rust 전환 주의사항
개요
4년간 Node.js를 사용했다면 Rust로 전환할 때 특정 패턴에서 반복적으로 막히게 된다. 이 부록은 가장 흔한 함정들을 TypeScript/JavaScript 코드와 Rust 코드를 직접 비교하며 설명한다.
출처: corrode.dev TypeScript to Rust Migration Guide (https://corrode.dev/blog/typescript-to-rust/)
1. 빌림 검사기와 싸우지 말 것
빌림 검사기(Borrow Checker)는 컴파일러의 일부로, 메모리 안전성을 보장한다. Node.js에는 이런 개념이 없다. GC가 대신 처리하기 때문이다.
흔한 실수: 이동 후 사용
// TypeScript - 동작함
const data = { name: "apple", count: 5 };
processData(data);
console.log(data.name); // 문제없음
// Rust - 컴파일 에러
let data = EventData { name: "apple".to_string(), count: 5 };
process_data(data); // data의 소유권이 이동됨
println!("{}", data.name); // 에러! data는 이미 이동됨
해결: 참조(&) 또는 Clone 사용
// 해결 1: 참조 전달 (권장)
let data = EventData { name: "apple".to_string(), count: 5 };
process_data(&data); // 참조만 전달
println!("{}", data.name); // OK - 소유권 유지됨
// 해결 2: Clone (비용이 있음)
let data = EventData { name: "apple".to_string(), count: 5 };
process_data(data.clone()); // 복사본 전달
println!("{}", data.name); // OK - 원본 소유권 유지됨
// 함수 시그니처도 맞춰야 함
fn process_data(data: &EventData) {
println!("{}: {}", data.name, data.count);
} // 참조 받기
Arc로 여러 곳에서 공유
// TypeScript - 여러 곳에서 참조 가능
const service = new BlockchainService(config);
const handler1 = new EventHandler(service);
const handler2 = new CropHandler(service);
// 모두 같은 service 객체를 참조
// Rust - Arc로 공유 참조
use std::sync::Arc;
let service = Arc::new(BlockchainService::new(config).await?);
let handler1 = EventHandler::new(Arc::clone(&service));
let handler2 = CropHandler::new(Arc::clone(&service));
// Arc 참조 카운팅으로 공유, 마지막 참조 소멸 시 해제
핵심 규칙: 빌림 검사기 에러를 무시하지 마라. 억지로 컴파일을 통과시키려 하면 더 복잡해진다. 에러 메시지를 읽고 소유권 모델을 이해하는 것이 빠른 길이다.
2. .unwrap() 남발 금지
Node.js에서는 에러 처리를 안 해도 런타임까지 잘 굴러가는 경우가 많다. Rust에서 .unwrap()은 panic!을 숨기는 시한폭탄이다.
// TypeScript (나쁜 패턴이지만 동작함)
const value = JSON.parse(userInput); // 실패해도 일단 try-catch로
const result = await db.query(sql); // undefined 체크 없이 사용
// Rust - 나쁜 패턴 (절대 금지)
let value: serde_json::Value = serde_json::from_str(user_input).unwrap(); // panic 가능
let result = db.fetch_one(query).await.unwrap(); // panic 가능
// Rust - 올바른 패턴
// 방법 1: ? 연산자 (가장 간결)
async fn handle_request(input: &str) -> Result<Response, AppError> {
let value: serde_json::Value = serde_json::from_str(input)
.map_err(|e| AppError::BadRequest(format!("JSON 파싱 실패: {}", e)))?;
let result = db.fetch_one(query).await
.map_err(AppError::Database)?;
Ok(Response { data: result })
}
// 방법 2: match
match serde_json::from_str::<serde_json::Value>(input) {
Ok(value) => { /* 사용 */ }
Err(e) => return Err(AppError::BadRequest(e.to_string())),
}
// 방법 3: if let (Option에서 자주 사용)
if let Some(record) = db.fetch_optional(query).await? {
// record 사용
} else {
return Err(AppError::NotFound("레코드 없음".to_string()));
}
언제 unwrap/expect를 써도 되는가:
// 테스트 코드에서
#[test]
fn test_hash_calculation() {
let hash = calculate_hash("test").unwrap(); // 테스트에서는 OK
assert_eq!(hash.len(), 64);
}
// 프로그램 시작 시 반드시 있어야 하는 설정
let db_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL 환경변수가 반드시 있어야 합니다");
// expect는 panic 메시지를 더 명확하게 만듦
// 논리적으로 절대 실패할 수 없는 경우
let arr = [1, 2, 3];
let first = arr.first().unwrap(); // 배열이 비어있을 수 없음
3. String vs &str 혼동
Node.js에는 문자열이 하나다. Rust에는 두 가지가 있다.
// TypeScript
const name: string = "apple";
const greeting: string = `Hello, ${name}`;
function greet(name: string): string {
return `Hello, ${name}`;
}
// Rust
let name: &str = "apple"; // 정적 문자열 슬라이스 (불변, 스택)
let owned: String = "apple".to_string(); // 힙 할당 문자열 (가변, 소유)
let owned2: String = String::from("apple");
// 함수 파라미터
fn greet(name: &str) -> String { // &str 받고 String 반환 (권장)
format!("Hello, {}", name)
}
// 호출 시
greet("apple"); // &str 직접
greet(&owned); // String → &str 자동 변환 (Deref)
언제 무엇을 쓸까:
// &str을 써야 하는 경우:
// - 함수 파라미터 (호출자가 String이든 &str이든 모두 받을 수 있음)
// - 문자열을 수정하지 않을 때
fn process(name: &str) {
println!("processing {name}");
}
// String을 써야 하는 경우:
// - 구조체 필드 (소유권 필요)
// - 반환값으로 새 문자열 생성
// - 문자열을 수정해야 할 때
struct Event {
name: String, // &str이면 라이프타임 문제
}
// 변환
let s: String = "hello".to_string();
let s: String = format!("{}", some_value);
let slice: &str = &s; // String → &str
let owned: String = slice.to_owned(); // &str → String
4. 금융 계산에 float 사용 금지
Node.js에서도 금융 계산에 number 타입을 쓰면 안 된다는 것을 알고 있을 것이다. Rust에서도 마찬가지다.
// TypeScript - 잘못된 예시
const price = 0.1 + 0.2; // 0.30000000000000004
const total = 1.5 * 100; // 실제로는 149.99999... 일 수 있음
#![allow(unused)]
fn main() {
// Rust - 잘못된 예시
let price: f64 = 0.1 + 0.2; // 0.30000000000000004
let token_amount: f64 = 1_500_000.0 * 0.001; // 부동소수점 오차
}
// Rust - 올바른 방법: 정수 사용 (최소 단위)
// ETH는 wei 단위 (10^18)
let balance: u128 = 1_000_000_000_000_000_000u128; // 1 ETH = 10^18 wei
let gas_price: u128 = 20_000_000_000u128; // 20 Gwei = 20 * 10^9
// Alloy의 U256 (256비트 정수)
use alloy::primitives::U256;
let amount: U256 = U256::from(1_000_000_000_000_000_000u128);
// rust_decimal 크레이트 사용 (가격, 환율 등)
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
let price = dec!(1.5); // 정확한 소수 표현
let fee_rate = dec!(0.003); // 0.3%
let fee = price * fee_rate; // 정확히 0.0045
// 표시용으로만 f64 변환
let display: f64 = fee.to_f64().unwrap_or(0.0);
블록체인에서의 규칙:
- 토큰 잔액: 항상 최소 단위 정수 (wei, lamports, satoshi)
- 가격/환율:
rust_decimal::Decimal - 퍼센트:
u32(예: 300 = 3.00%) - 표시용 포맷팅 시에만 부동소수점으로 변환
5. .await 중 MutexGuard 보유 위험
Node.js는 싱글스레드이므로 이런 문제가 없다. Rust의 async에서는 .await 지점에서 다른 태스크로 전환될 수 있어 데드락이 발생한다.
// TypeScript - 문제 없음 (싱글스레드)
async function processEvent(mutex: Mutex, event: Event) {
const lock = mutex.lock();
const result = await someAsyncOp(); // 다른 태스크 전환 없음
lock.release();
}
// Rust - 컴파일 에러 또는 데드락!
async fn process_event(mutex: Arc<Mutex<State>>, event: Event) -> Result<()> {
let guard = mutex.lock().unwrap(); // 락 획득
let result = some_async_op().await; // 여기서 다른 태스크로 전환 가능
// guard가 .await를 넘어 살아있으면 Future가 Send를 구현하지 않아 컴파일 에러
Ok(())
}
// 올바른 패턴 1: 락 범위를 .await 밖으로
async fn process_event(mutex: Arc<Mutex<State>>, event: Event) -> Result<()> {
// 락을 최소 범위로 사용
let value = {
let guard = mutex.lock().unwrap();
guard.some_value.clone() // 값을 복사하고
}; // 여기서 guard 해제
// .await는 락 없이
let result = some_async_op(value).await?;
// 다시 락이 필요하면
{
let mut guard = mutex.lock().unwrap();
guard.some_value = result;
}
Ok(())
}
// 올바른 패턴 2: tokio::sync::Mutex 사용 (async-aware)
use tokio::sync::Mutex;
async fn process_event(mutex: Arc<Mutex<State>>, event: Event) -> Result<()> {
let mut guard = mutex.lock().await; // .await로 락 획득 (데드락 없음)
let result = some_async_op(&guard.data).await?;
guard.result = result;
Ok(())
}
규칙:
std::sync::Mutex: sync 코드에서만 사용,.await전에 반드시 해제tokio::sync::Mutex: async 코드에서.await를 넘어야 할 때- 가능하면 락 범위를 최소화
6. try/catch → Result 전환
// TypeScript
async function fetchUser(id: string): Promise<User> {
try {
const user = await db.findUser(id);
if (!user) throw new NotFoundError(`User ${id} not found`);
return user;
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('DB error:', error);
throw new InternalError('Database error');
}
}
// Rust
async fn fetch_user(db: &PgPool, id: &str) -> Result<User, AppError> {
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_optional(db)
.await
.map_err(|e| {
tracing::error!(error = %e, "DB 오류");
AppError::Database(e)
})?;
user.ok_or_else(|| AppError::NotFound(format!("사용자 없음: {}", id)))
}
패턴 대응표:
| TypeScript | Rust |
|---|---|
try { } catch (e) { } | match result { Ok(v) => ..., Err(e) => ... } |
throw new Error("msg") | return Err(AppError::SomeError("msg".into())) |
Promise<T> | Result<T, E> (async에서) |
async function | async fn |
await promise | future.await |
Promise.all([...]) | tokio::join!(...) 또는 futures::join_all(...) |
catch (e) { if (e instanceof X) } | match e { AppError::X(_) => ..., _ => ... } |
7. 컴파일 시간 관리
Rust는 컴파일이 느리다. Node.js에서 ts-node로 즉시 실행하던 것과 달리, Rust는 전체 빌드에 분 단위가 걸릴 수 있다.
# 느린 방법 (전체 빌드)
cargo build # 처음: 2-10분
cargo build --release # 최적화 빌드: 더 오래 걸림
# 빠른 방법들
# 1. cargo check - 실행 파일 없이 타입만 검사 (가장 빠름)
cargo check # 컴파일의 ~30% 시간
# 2. cargo clippy - 타입 검사 + 린트 (check보다 약간 느림)
cargo clippy
# 3. mold/lld 링커 사용 (링킹 시간 단축)
# .cargo/config.toml
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
# 4. cargo-watch - 파일 변경 시 자동 cargo check
cargo install cargo-watch
cargo watch -x check # 저장 시 check
cargo watch -x "run --bin api" # 저장 시 재시작
# 5. sccache - 빌드 캐시
cargo install sccache
RUSTC_WRAPPER=sccache cargo build
# 6. 작업공간 분리 - 자주 바뀌는 코드를 별도 크레이트로
[workspace]
members = ["core", "api", "blockchain"]
# core가 안 바뀌면 재컴파일 불필요
개발 워크플로우 추천:
# 코딩 중: check만
cargo check
# PR 전: 전체 검증
cargo clippy -- -D warnings
cargo test
cargo fmt --check
8. npm → cargo 명령어 대응표
| npm / Node.js | cargo / Rust | 설명 |
|---|---|---|
npm init | cargo new my-project | 새 프로젝트 |
npm install | cargo build | 의존성 다운로드 + 빌드 |
npm install pkg | cargo add pkg | 의존성 추가 |
npm run start | cargo run | 실행 |
npm run build | cargo build --release | 릴리즈 빌드 |
npm test | cargo test | 테스트 실행 |
npm run lint | cargo clippy | 린트 |
npx prettier --write | cargo fmt | 코드 포맷 |
package.json | Cargo.toml | 프로젝트 설정 |
package-lock.json | Cargo.lock | 잠금 파일 |
node_modules/ | ~/.cargo/registry/ | 의존성 캐시 |
npx ts-node src/index.ts | cargo run --bin name | 특정 바이너리 실행 |
npm publish | cargo publish | 패키지 배포 |
npm outdated | cargo outdated | 오래된 의존성 확인 |
npx tsc --noEmit | cargo check | 타입 검사만 |
.npmrc | .cargo/config.toml | 설정 파일 |
npm workspaces | cargo workspace | 모노레포 |
jest --watch | cargo watch -x test | 테스트 감시 모드 |
Cargo.toml vs package.json 비교:
// package.json
{
"name": "my-app",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.0",
"prisma": "^5.0.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"jest": "^29.0.0"
},
"scripts": {
"start": "node dist/index.js",
"dev": "ts-node src/index.ts",
"test": "jest"
}
}
# Cargo.toml
[package]
name = "my-app"
version = "1.0.0"
edition = "2021"
[dependencies]
axum = "0.7" # express에 해당
sqlx = "0.8" # prisma에 해당
[dev-dependencies]
tokio-test = "0.4" # jest에 해당 (테스트용만)
# scripts에 해당하는 것은 Makefile이나 cargo-make 사용
# [[bin]] 섹션으로 여러 실행 파일 정의
[[bin]]
name = "server"
path = "src/main.rs"
[[bin]]
name = "migrate"
path = "src/bin/migrate.rs"
9. NestJS → Axum 패턴 대응표 (상세)
모듈 구조
// NestJS
@Module({
imports: [DatabaseModule, AuthModule],
providers: [EventService, BlockchainService],
controllers: [EventController],
exports: [EventService],
})
export class EventModule {}
// Axum - 별도 모듈 시스템 없음, 수동으로 구성
// src/services/event.rs
pub struct EventService {
db: PgPool,
blockchain: Arc<BlockchainService>,
}
// src/core/app.rs
pub struct AppState {
pub event_service: Arc<EventService>,
pub blockchain: Arc<BlockchainService>,
}
컨트롤러와 라우터
// NestJS
@Controller('events')
@UseGuards(JwtAuthGuard)
export class EventController {
constructor(private readonly eventService: EventService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() dto: CreateEventDto, @Req() req: AuthRequest) {
return this.eventService.create(req.user.id, dto);
}
@Get(':id')
async findOne(@Param('id') id: string) {
return this.eventService.findOne(id);
}
}
// Axum
pub fn event_routes() -> Router<AppState> {
Router::new()
.route("/", post(create_event))
.route("/:id", get(get_event))
// 미들웨어는 라우터 레벨에서 적용
.route_layer(middleware::from_fn_with_state(
AppState::placeholder(),
require_auth,
))
}
async fn create_event(
State(state): State<AppState>,
Extension(user): Extension<AuthUser>, // JWT에서 추출
Json(body): Json<CreateEventRequest>,
) -> Result<(StatusCode, Json<EventResponse>), AppError> {
let event = state.event_service.create(user.id, body).await?;
Ok((StatusCode::CREATED, Json(event)))
}
async fn get_event(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<EventResponse>, AppError> {
let event = state.event_service.find_one(&id).await?;
Ok(Json(event))
}
DTO와 검증
// NestJS - class-validator
import { IsString, IsNotEmpty, IsObject, MinLength } from 'class-validator';
export class CreateEventDto {
@IsString()
@IsNotEmpty()
@MinLength(1)
eventType: string;
@IsObject()
payload: Record<string, unknown>;
}
// Axum - serde + validator 크레이트
use serde::Deserialize;
use validator::Validate;
#[derive(Deserialize, Validate)]
pub struct CreateEventRequest {
#[validate(length(min = 1, message = "event_type은 비어있을 수 없습니다"))]
pub event_type: String,
pub payload: serde_json::Value,
}
// 핸들러에서 수동 검증
async fn create_event(
Json(body): Json<CreateEventRequest>,
) -> Result<Json<TraceEventResponse>, AppError> {
body.validate()
.map_err(|e| AppError::BadRequest(e.to_string()))?;
let response = TraceEventResponse::from_request(body);
Ok(Json(response))
}
// 또는 커스텀 Extractor로 자동 검증
struct ValidatedJson<T>(T);
#[async_trait]
impl<T, S> FromRequest<S> for ValidatedJson<T>
where
T: DeserializeOwned + Validate,
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let Json(value) = Json::<T>::from_request(req, state)
.await
.map_err(|e| AppError::BadRequest(e.to_string()))?;
value.validate()
.map_err(|e| AppError::BadRequest(e.to_string()))?;
Ok(ValidatedJson(value))
}
}
환경변수 설정
// NestJS - @nestjs/config
@Injectable()
export class AppConfigService {
constructor(private configService: ConfigService) {}
get databaseUrl(): string {
return this.configService.get<string>('DATABASE_URL');
}
get privateKey(): string {
return this.configService.getOrThrow('PRIVATE_KEY');
}
}
// Axum - dotenvy + std::env
use dotenvy::dotenv;
pub struct Config {
pub database_url: String,
pub private_key: String,
pub rpc_url: String,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
dotenv().ok(); // .env 파일 로드 (없어도 OK)
Ok(Config {
database_url: std::env::var("DATABASE_URL")
.map_err(|_| anyhow::anyhow!("DATABASE_URL 환경변수 없음"))?,
private_key: std::env::var("PRIVATE_KEY")
.map_err(|_| anyhow::anyhow!("PRIVATE_KEY 환경변수 없음"))?,
rpc_url: std::env::var("RPC_URL")
.unwrap_or_else(|_| "http://localhost:8545".to_string()),
})
}
}
인터셉터와 Tower 레이어
// NestJS Interceptor
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const start = Date.now();
return next.handle().pipe(
tap(() => {
const duration = Date.now() - start;
console.log(`요청 처리 시간: ${duration}ms`);
})
);
}
}
// Axum - Tower Layer (tower-http 사용)
use tower_http::trace::TraceLayer;
let app = Router::new()
.route("/health", get(health_check))
.route("/events", post(create_event))
.layer(TraceLayer::new_for_http()); // 자동으로 요청/응답 로깅
// 커스텀 레이어가 필요하면
use tower::{Layer, Service};
#[derive(Clone)]
struct TimingLayer;
impl<S> Layer<S> for TimingLayer {
type Service = TimingService<S>;
fn layer(&self, inner: S) -> Self::Service {
TimingService { inner }
}
}
실수별 빠른 참조 카드
에러: "use of moved value"
해결: & 참조 사용 또는 .clone()
에러: "cannot borrow as mutable"
해결: &mut 참조 또는 RefCell<T>
에러: "future is not Send"
해결: .await 전에 MutexGuard 해제
에러: "the trait bound is not satisfied"
해결: where T: 트레이트 경계 추가 또는 Arc<dyn Trait>
에러: "expected String, found &str"
해결: .to_string() 또는 .to_owned() 추가
경고: "unused Result"
해결: let _ = result; 또는 .ok() 또는 ? 연산자
경고: "unnecessary clone"
해결: & 참조로 전달 가능한지 확인
요약
Node.js에서 Rust로 전환 시 가장 중요한 10가지:
- 소유권: 한 번에 한 소유자, 이동 후 사용 불가 →
&참조 사용 - unwrap 금지:
?연산자와match로 에러 처리 - String vs &str: 함수 파라미터는
&str, 구조체 필드는String - 정수 연산: 금융/토큰 계산은 반드시
U256,u128,Decimal - async Mutex:
tokio::sync::Mutex사용,.await전에std::sync::Mutex해제 - Result 체인:
?,map_err,and_then으로 에러 전파 - cargo check: 빠른 타입 검사로 개발 속도 향상
- cargo 명령어: npm 대신 cargo, 패키지는 crates.io
- Axum 패턴: State extractor, Extension extractor, Router::new()
- 컴파일러 믿기: 에러가 나면 억지로 고치지 말고 메시지를 읽어라
Rust 컴파일러는 엄격하지만 친절하다. 에러 메시지에 해결책이 힌트로 들어있는 경우가 많다.
부록 C: 전체 참고 자료
이 교재에서 다룬 모든 기술의 공식 문서와 학습 자료를 카테고리별로 정리했다. 각 자료에 URL, 설명, 난이도를 표시한다.
난이도 기준:
- 🟢 입문: 프로그래밍 경험만 있으면 시작 가능
- 🟡 중급: 해당 분야 기초 지식 필요
- 🔴 고급: 깊은 이해와 경험 필요
1. Rust 학습
공식 자료
The Rust Programming Language (The Book)
- URL: https://doc.rust-lang.org/book/
- 한국어판: https://rinthel.github.io/rust-lang-book-ko/
- 설명: Rust 공식 교재. 소유권, 빌림, 트레이트, 에러 처리 등 핵심 개념을 순서대로 다룬다. 어떤 Rust 학습 자료보다 이것을 먼저 읽어야 한다.
- 난이도: 🟢 입문
- 분량: 20장 + 부록, 약 30-50시간
Rustlings
- URL: https://github.com/rust-lang/rustlings
- 설명: 터미널에서 실행하는 인터랙티브 Rust 연습 문제 모음. 컴파일 에러를 고치면서 Rust를 익힌다. The Book과 병행하면 효과적이다.
- 난이도: 🟢 입문
- 분량: 약 100개 문제, 10-20시간
Rust by Example
- URL: https://doc.rust-lang.org/rust-by-example/
- 설명: 실행 가능한 예제 코드로 Rust를 배운다. The Book이 설명 중심이라면, 이것은 코드 중심. 특정 문법이나 기능을 빠르게 확인할 때 유용하다.
- 난이도: 🟢 입문
- 분량: 필요한 부분만 참고
Tokio 공식 튜토리얼
- URL: https://tokio.rs/tokio/tutorial
- 설명: Rust 비동기 런타임 Tokio의 공식 튜토리얼. async/await, task, channel, select!, 네트워킹을 직접 구현하며 배운다. platform 코드를 이해하려면 반드시 이수해야 한다.
- 난이도: 🟡 중급
- 분량: 8개 챕터, 10-15시간
심화 자료
corrode.dev - Rust for TypeScript Developers
- URL: https://corrode.dev/blog/
- 설명: TypeScript/JavaScript 개발자를 위한 Rust 전환 가이드. 이 교재의 부록 B 내용이 여기서 영감을 받았다. 실무적인 조언이 많다.
- 난이도: 🟡 중급
Rust Async Book
- URL: https://rust-lang.github.io/async-book/
- 설명: Rust 비동기 프로그래밍의 공식 가이드. Future, executor, waker 등 내부 동작 원리까지 설명한다.
- 난이도: 🔴 고급
The Rust Performance Book
- URL: https://nnethercote.github.io/perf-book/
- 설명: Rust 코드 성능 최적화 가이드. 프로파일링, 불필요한 할당 제거, SIMD 등을 다룬다.
- 난이도: 🔴 고급
Zero To Production In Rust
- URL: https://www.zero2prod.com/
- 설명: Rust로 실제 프로덕션 웹 서비스를 처음부터 만드는 책. 테스트, CI/CD, 로깅, 보안까지 다룬다. 유료이지만 가장 실용적인 Rust 웹 서비스 교재.
- 난이도: 🟡 중급
2. 블록체인 기초
ethereum.org 개발자 문서
- URL: https://ethereum.org/developers
- 설명: 이더리움 재단이 관리하는 공식 개발자 포털. 블록체인 기초 개념, 이더리움 아키텍처, 스마트 컨트랙트 소개가 잘 정리되어 있다.
- 난이도: 🟢 입문
Cyfrin Updraft
- URL: https://updraft.cyfrin.io/
- 설명: Patrick Collins가 만든 무료 블록체인 개발 교육 플랫폼. Solidity 기초부터 스마트 컨트랙트 보안, 고급 DeFi까지 체계적인 커리큘럼을 무료로 제공한다. 이 분야 최고의 무료 학습 자료 중 하나.
- 난이도: 🟢 입문 ~ 🟡 중급
- 특이사항: 영어, 무료, 수료증 발급
CryptoZombies
- URL: https://cryptozombies.io/
- 설명: 게임 스토리로 Solidity를 배우는 인터랙티브 튜토리얼. 좀비 게임을 만들면서 ERC-20, ERC-721을 구현한다. 재미있게 시작하기 좋다.
- 난이도: 🟢 입문
- 분량: 6개 레슨, 10-15시간
Alchemy University
- URL: https://university.alchemy.com/
- 설명: Alchemy(블록체인 인프라 회사)가 운영하는 무료 교육 과정. Ethereum Developer Bootcamp, JavaScript 블록체인 개발 등 실용적인 과정을 무료로 제공한다.
- 난이도: 🟢 입문 ~ 🟡 중급
Mastering Ethereum (책)
- URL: https://github.com/ethereumbook/ethereumbook (무료 온라인)
- 설명: Andreas Antonopoulos와 Gavin Wood가 쓴 이더리움 기술서. EVM 내부 구조, 암호학 기초, 보안까지 깊이 있게 다룬다. 이더리움을 진지하게 공부하고 싶다면 필독.
- 난이도: 🟡 중급 ~ 🔴 고급
3. Solidity / EVM
Solidity by Example
- URL: https://solidity-by-example.org/
- 설명: 실제 작동하는 Solidity 예제 코드 모음. Hello World부터 DeFi 프로토콜까지 다양한 패턴을 코드로 보여준다. 특정 기능을 빠르게 확인할 때 최고의 참고자료.
- 난이도: 🟢 입문 ~ 🟡 중급
Foundry Book
- URL: https://book.getfoundry.sh/
- 설명: Rust로 만든 Solidity 개발 도구 Foundry의 공식 문서. forge 테스트 작성, fuzz 테스트, invariant 테스트, cast 도구 사용법이 상세히 나와 있다. platform 개발에 직접 필요한 자료.
- 난이도: 🟡 중급
OpenZeppelin Contracts
- URL: https://docs.openzeppelin.com/contracts/
- GitHub: https://github.com/OpenZeppelin/openzeppelin-contracts
- 설명: 감사받은 스마트 컨트랙트 라이브러리. ERC-20, ERC-721, Access Control, UUPS Proxy, Ownable 등 platform에서 사용하는 패턴의 원본 구현이다.
- 난이도: 🟡 중급
evm.codes
- URL: https://www.evm.codes/
- 설명: EVM 옵코드 레퍼런스. PUSH, ADD, SLOAD 등 모든 EVM 명령어의 gas 비용, 스택 효과, 설명이 있다. UUPS 프록시의 DELEGATECALL이나 storage slot 계산을 이해하려면 참고해야 한다.
- 난이도: 🔴 고급
EVM from Scratch
- URL: https://evm.codes/playground
- 설명: EVM 바이트코드를 직접 실행해볼 수 있는 플레이그라운드. 스마트 컨트랙트가 어떻게 컴파일되고 실행되는지 실제로 보여준다.
- 난이도: 🔴 고급
Solidity 공식 문서
- URL: https://docs.soliditylang.org/
- 설명: Solidity 언어의 완전한 레퍼런스. 문법, 내장 함수, 타입 시스템 등 의문이 생길 때 최종 참고자료.
- 난이도: 🟡 중급
4. Alloy / Ethereum Rust
Alloy 공식 문서
- URL: https://alloy.rs/
- GitHub: https://github.com/alloy-rs/alloy
- 설명: Alloy 라이브러리 공식 문서. Provider, Signer, sol! 매크로, Network 추상화 등 모든 API가 문서화되어 있다.
- 난이도: 🟡 중급
Alloy 예제 코드
- URL: https://github.com/alloy-rs/examples
- 설명: Alloy 공식 예제 저장소. Provider 사용, 트랜잭션 전송, 컨트랙트 배포, 이벤트 구독 등 다양한 실용 예제가 있다. 새 기능을 사용할 때 여기서 먼저 찾아보라.
- 난이도: 🟡 중급
Reth (Rust Ethereum)
- URL: https://reth.rs/
- GitHub: https://github.com/paradigmxyz/reth
- 설명: Paradigm이 개발한 Rust 기반 이더리움 실행 클라이언트. Alloy와 같은 팀이 만들어 긴밀하게 통합된다. 이더리움 내부 구조를 Rust 코드로 이해하는 데 좋다.
- 난이도: 🔴 고급
5. Solana
Solana 개발자 포털
- URL: https://solana.com/developers
- 설명: Solana 공식 개발자 시작 지점. 문서, 튜토리얼, 예제 코드, SDK 링크가 모여 있다.
- 난이도: 🟢 입문
Solana 개발자 문서
- URL: https://docs.solanalabs.com/
- 설명: Solana 프로토콜과 클라이언트의 공식 기술 문서. Proof of History, 계정 모델, 프로그램 구조 등.
- 난이도: 🟡 중급
Solana Cookbook
- URL: https://solanacookbook.com/
- 설명: 실용적인 Solana 개발 패턴 모음. “이걸 어떻게 하지?“에 대한 답을 코드 예제로 보여준다. TypeScript(web3.js), Rust(Anchor) 예제 모두 있다.
- 난이도: 🟡 중급
Anchor 프레임워크 문서
- URL: https://www.anchor-lang.com/
- GitHub: https://github.com/coral-xyz/anchor
- 설명: Solana 스마트 컨트랙트(Program) 개발을 쉽게 만드는 프레임워크. Rust로 Solana Program을 작성할 때 사실상 표준. 매크로로 보일러플레이트를 크게 줄여준다.
- 난이도: 🟡 중급
Helius Solana 가이드
- URL: https://www.helius.dev/blog
- 설명: Helius(Solana 인프라 회사) 블로그. Solana의 고급 개념을 깊이 있게 설명하는 기술 글이 많다. 특히 Firedancer, 합의, 성능 최적화 관련 글이 훌륭하다.
- 난이도: 🟡 중급 ~ 🔴 고급
6. Hyperledger Besu
Hyperledger Besu 공식 문서
- URL: https://besu.hyperledger.org/
- 설명: Besu의 완전한 공식 문서. 설치, 설정, IBFT 2.0, QBFT, 프라이빗 트랜잭션, 권한 관리 등 모든 내용이 있다.
- 난이도: 🟡 중급
Besu GitHub
- URL: https://github.com/hyperledger/besu
- 설명: Besu 소스 코드와 이슈 트래커. 버그 보고나 특정 동작의 원인을 이해하려면 여기를 확인한다.
- 난이도: 🔴 고급
Besu 네트워크 빠른 시작
- URL: https://besu.hyperledger.org/private-networks/tutorials/ibft
- 설명: IBFT 2.0 프라이빗 네트워크를 처음 구성하는 단계별 가이드.
- 난이도: 🟡 중급
7. 심화 / 전문가 과정
Blockchain from Scratch
- URL: https://github.com/anders94/blockchain-A-to-Z
- 설명: 블록체인을 처음부터 직접 구현한다. 해싱, 체인 구조, P2P 네트워크, PoW를 코드로 구현하며 블록체인의 본질을 이해한다.
- 난이도: 🔴 고급
EVM from Scratch
- URL: https://github.com/w1nt3r-eth/evm-from-scratch
- 설명: 순수 코드로 미니 EVM을 구현하는 과제. PUSH, ADD, SSTORE, CALL 등 옵코드를 직접 구현한다. EVM 내부 동작을 가장 확실하게 이해하는 방법.
- 난이도: 🔴 고급
Polkadot Blockchain Academy (PBA)
- URL: https://polkadot.com/blockchain-academy
- 설명: Polkadot 생태계가 운영하는 블록체인 심화 교육. 암호학 기초, 합의 알고리즘, 경제학, Substrate 개발을 전문가 수준으로 가르친다. 선발 과정이 있으며, 수료 후 Polkadot 생태계 취업 연계.
- 난이도: 🔴 고급
- 형태: 집중 부트캠프 (현장)
DeFi 보안 - Secureum
- URL: https://secureum.xyz/
- 설명: 스마트 컨트랙트 보안 전문가 양성 교육. Epoch0부터 CARE4 시리즈까지 무료로 제공한다. 보안 감사(audit) 분야로 진출하고 싶다면 필수.
- 난이도: 🔴 고급
8. 도구 및 인프라
SQLx 문서
- URL: https://docs.rs/sqlx/latest/sqlx/
- GitHub: https://github.com/launchbadge/sqlx
- 설명: Rust용 비동기 SQL 라이브러리. platform이 사용하는 ORM/쿼리 빌더. 마이그레이션, 컴파일 타임 쿼리 검증, PostgreSQL/MySQL/SQLite 지원.
- 난이도: 🟡 중급
Axum 문서
- URL: https://docs.rs/axum/latest/axum/
- GitHub: https://github.com/tokio-rs/axum
- 설명: Axum 공식 API 문서. Extractor, Router, 미들웨어 등 모든 API가 코드 예제와 함께 설명된다.
- 난이도: 🟡 중급
Tower 미들웨어
- URL: https://docs.rs/tower/latest/tower/
- 설명: Axum이 내부적으로 사용하는 미들웨어 프레임워크. 커스텀 Layer, Service를 만들 때 참고.
- 난이도: 🔴 고급
tracing 크레이트
- URL: https://docs.rs/tracing/latest/tracing/
- 설명: Rust용 구조화된 로깅 프레임워크. platform 전체에서 사용하는
tracing::info!,tracing::error!등의 공식 문서. - 난이도: 🟡 중급
Docker 공식 문서
- URL: https://docs.docker.com/
- 설명: platform과 Besu 네트워크를 컨테이너로 운영하는 데 필요한 Docker/Compose 레퍼런스.
- 난이도: 🟢 입문 ~ 🟡 중급
9. 커뮤니티와 뉴스레터
Rust 공식 포럼
- URL: https://users.rust-lang.org/
- 설명: Rust 사용자 포럼. 질문, 토론, 프로젝트 공유. 막히는 부분이 있을 때 검색하거나 질문하면 커뮤니티가 친절하게 답변한다.
Ethereum Research
- URL: https://ethresear.ch/
- 설명: 이더리움 연구자들의 기술 논의 포럼. EIP 초안, 프로토콜 개선 제안, 암호학 연구가 올라온다.
Week in Ethereum News
- URL: https://weekinethereumnews.com/
- 설명: 매주 이더리움 생태계의 중요 뉴스와 개발 소식을 정리하는 뉴스레터. 생태계 동향을 파악하는 가장 효율적인 방법.
Solana Dev Newsletter
- URL: https://solana.com/news (또는 Helius 블로그)
- 설명: Solana 개발 소식과 생태계 업데이트.
학습 경로 추천
이 교재를 끝낸 독자를 위한 다음 단계
Rust 백엔드 개발자 (3개월)
월 1: Zero To Production In Rust (웹 서비스 전체 스택)
월 2: Tokio 심화 + 비동기 패턴 고급
월 3: platform에 새 기능 추가 (실전)
블록체인 개발자 (3개월)
월 1: Cyfrin Updraft 전체 과정 + Foundry 테스트 작성
월 2: OpenZeppelin 컨트랙트 분석 + DeFi 프로토콜 이해
월 3: 스마트 컨트랙트 보안 (Secureum)
Solana 전문가 (3개월)
월 1: Anchor 튜토리얼 전체 + 기본 Program 구현
월 2: Token Program, Metaplex NFT 이해
월 3: Firedancer 아키텍처 이해 + 고성능 Program 최적화
풀스택 블록체인 엔지니어 (6개월)
모두 포함 + Polkadot Blockchain Academy 지원
참고 자료 요약표
| 카테고리 | 자료명 | URL | 난이도 | 무료 |
|---|---|---|---|---|
| Rust | The Book | doc.rust-lang.org/book | 🟢 | ✅ |
| Rust | Rustlings | github.com/rust-lang/rustlings | 🟢 | ✅ |
| Rust | Tokio Tutorial | tokio.rs/tokio/tutorial | 🟡 | ✅ |
| Rust | Zero To Production | zero2prod.com | 🟡 | ❌ |
| 블록체인 | ethereum.org | ethereum.org/developers | 🟢 | ✅ |
| 블록체인 | Cyfrin Updraft | updraft.cyfrin.io | 🟢 | ✅ |
| 블록체인 | Mastering Ethereum | github.com/ethereumbook | 🟡 | ✅ |
| Solidity | Solidity by Example | solidity-by-example.org | 🟢 | ✅ |
| Solidity | Foundry Book | book.getfoundry.sh | 🟡 | ✅ |
| Solidity | OpenZeppelin | docs.openzeppelin.com | 🟡 | ✅ |
| EVM | evm.codes | evm.codes | 🔴 | ✅ |
| Alloy | Alloy Docs | alloy.rs | 🟡 | ✅ |
| Solana | Anchor Docs | anchor-lang.com | 🟡 | ✅ |
| Solana | Solana Cookbook | solanacookbook.com | 🟡 | ✅ |
| Besu | Besu Docs | besu.hyperledger.org | 🟡 | ✅ |
| 심화 | Secureum | secureum.xyz | 🔴 | ✅ |
| 심화 | PBA | polkadot.com/blockchain-academy | 🔴 | 선발 |
이 부록의 모든 URL은 2026년 4월 기준으로 유효하다. 빠르게 변하는 블록체인 생태계 특성상 일부 링크는 변경될 수 있다. 검색 엔진에서 자료명으로 검색하면 최신 버전을 찾을 수 있다.