블록체인 개발의 첫 걸음을 떼려는 분들을 위해 이더리움 스마트컨트랙트의 기초를 다뤄볼게요. 단순한 Hello World가 아닌, 실제로 작동하고 프론트엔드와 연동 가능한 완전한 dApp을 만들어봐요.
개발 환경 5분 세팅
먼저 필요한 도구들을 설치해요. Node.js 18+ 버전이 설치되어 있다는 가정하에 진행할게요.
# Hardhat 개발 환경 설치
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init
# OpenZeppelin 컨트랙트 라이브러리
npm install @openzeppelin/contracts
MetaMask 지갑을 브라우저에 설치하고, Sepolia 테스트넷을 추가해요. Alchemy에서 무료 계정을 만들어 RPC URL을 받아오세요.
핵심 스마트컨트랙트 구현
기본 Hello World를 넘어서, 실제로 유용한 기능을 가진 메시지 보드 컨트랙트를 만들어볼게요.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MessageBoard is ReentrancyGuard, Ownable {
struct Message {
address author;
string content;
uint256 timestamp;
uint256 tips;
}
Message[] public messages;
mapping(address => uint256) public authorEarnings;
uint256 public constant MIN_TIP = 0.0001 ether;
uint256 public messageCount;
event MessagePosted(uint256 indexed id, address indexed author, string content);
event TipSent(uint256 indexed messageId, address indexed tipper, uint256 amount);
메시지 구조체와 기본 변수들을 정의했어요. ReentrancyGuard로 재진입 공격을 방지하고, Ownable로 권한 관리를 구현해요.
function postMessage(string memory _content) external {
require(bytes(_content).length > 0, "Empty message");
require(bytes(_content).length <= 280, "Message too long");
messages.push(Message({
author: msg.sender,
content: _content,
timestamp: block.timestamp,
tips: 0
}));
emit MessagePosted(messageCount, msg.sender, _content);
messageCount++;
}
메시지 게시 함수에요. 트위터처럼 280자 제한을 두어 가스비를 절약해요.
function tipMessage(uint256 _messageId) external payable nonReentrant {
require(_messageId < messages.length, "Invalid message ID");
require(msg.value >= MIN_TIP, "Tip too small");
Message storage message = messages[_messageId];
require(message.author != msg.sender, "Cannot tip own message");
message.tips += msg.value;
authorEarnings[message.author] += msg.value;
emit TipSent(_messageId, msg.sender, msg.value);
}
function withdrawEarnings() external nonReentrant {
uint256 earnings = authorEarnings[msg.sender];
require(earnings > 0, "No earnings");
authorEarnings[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: earnings}("");
require(success, "Transfer failed");
}
}
팁 기능과 출금 함수를 추가했어요. 재진입 공격 방지와 Checks-Effects-Interactions 패턴을 적용해 보안을 강화했어요.
가스 최적화 버전
위 컨트랙트의 가스 효율적인 개선 버전이에요:
contract OptimizedMessageBoard is ReentrancyGuard, Ownable {
struct Message {
address author;
string content;
uint128 timestamp; // 128비트로 충분
uint128 tips; // 패킹으로 스토리지 슬롯 절약
}
Message[] public messages;
mapping(address => uint256) public authorEarnings;
// 상수는 bytecode에 직접 삽입되어 가스 절약
uint256 private constant MIN_TIP = 0.0001 ether;
uint256 private constant MAX_MESSAGE_LENGTH = 280;
// 메시지 일괄 조회로 RPC 호출 최소화
function getMessages(uint256 _start, uint256 _count)
external view returns (Message[] memory) {
uint256 end = _start + _count;
if (end > messages.length) {
end = messages.length;
}
Message[] memory result = new Message[](end - _start);
for (uint256 i = _start; i < end; i++) {
result[i - _start] = messages[i];
}
return result;
}
}
프론트엔드 React 연동
Next.js와 ethers.js를 사용해 프론트엔드를 구현해요.
npx create-next-app@latest message-board-dapp
cd message-board-dapp
npm install ethers
컨트랙트와 상호작용하는 React 컴포넌트를 만들어요:
// components/MessageBoard.js
import { useState, useEffect } from 'react';
import { ethers } from 'ethers';
import contractABI from '../abi/MessageBoard.json';
const CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_CONTRACT_ADDRESS;
export default function MessageBoard() {
const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState('');
const [contract, setContract] = useState(null);
const [account, setAccount] = useState('');
useEffect(() => {
initializeContract();
}, []);
async function initializeContract() {
if (typeof window.ethereum === 'undefined') {
alert('MetaMask를 설치해주세요');
return;
}
const provider = new ethers.BrowserProvider(window.ethereum);
const accounts = await provider.send("eth_requestAccounts", []);
setAccount(accounts[0]);
const signer = await provider.getSigner();
const contractInstance = new ethers.Contract(
CONTRACT_ADDRESS,
contractABI,
signer
);
setContract(contractInstance);
loadMessages(contractInstance);
}
메시지 로딩과 게시 함수를 구현해요:
async function loadMessages(contractInstance) {
try {
const messageCount = await contractInstance.messageCount();
const messagesArray = [];
// 배치 로딩으로 RPC 호출 최적화
const batchSize = 10;
for (let i = 0; i < messageCount; i += batchSize) {
const batch = await contractInstance.getMessages(i, batchSize);
messagesArray.push(...batch);
}
setMessages(messagesArray);
} catch (error) {
console.error('메시지 로딩 실패:', error);
}
}
async function postMessage() {
if (!contract || !newMessage) return;
try {
const tx = await contract.postMessage(newMessage);
await tx.wait();
setNewMessage('');
await loadMessages(contract);
} catch (error) {
if (error.code === 'ACTION_REJECTED') {
alert('트랜잭션이 거부되었어요');
} else {
console.error('메시지 게시 실패:', error);
}
}
}
Hardhat 배포 스크립트
테스트넷 배포를 위한 스크립트를 작성해요:
// scripts/deploy.js
const hre = require("hardhat");
async function main() {
console.log("MessageBoard 배포 시작...");
const MessageBoard = await hre.ethers.getContractFactory("MessageBoard");
const messageBoard = await MessageBoard.deploy();
await messageBoard.waitForDeployment();
const address = await messageBoard.getAddress();
console.log(`MessageBoard 배포 완료: ${address}`);
// 컨트랙트 검증을 위한 대기
console.log("Etherscan 검증을 위해 30초 대기중...");
await new Promise(resolve => setTimeout(resolve, 30000));
// 자동 검증
await hre.run("verify:verify", {
address: address,
constructorArguments: [],
});
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
테스트넷 배포 실전
Hardhat 설정 파일을 구성해요:
// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
module.exports = {
solidity: {
version: "0.8.24",
settings: {
optimizer: {
enabled: true,
runs: 200
}
}
},
networks: {
sepolia: {
url: process.env.ALCHEMY_RPC_URL,
accounts: [process.env.PRIVATE_KEY],
chainId: 11155111
}
},
etherscan: {
apiKey: process.env.ETHERSCAN_API_KEY
}
};
환경 변수 파일을 생성해요:
# .env
ALCHEMY_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY
PRIVATE_KEY=your_wallet_private_key
ETHERSCAN_API_KEY=your_etherscan_api_key
배포 명령어를 실행해요:
npx hardhat run scripts/deploy.js --network sepolia
보안 체크리스트
메인넷 배포 전 필수 확인 사항이에요:
스마트컨트랙트 보안:
- Reentrancy Guard 적용
- Integer Overflow 방지 (Solidity 0.8+)
- Access Control 구현
- DoS 공격 방지 (가스 제한)
- Front-running 대응
테스트 커버리지:
// test/MessageBoard.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("MessageBoard", function () {
let messageBoard;
let owner, addr1, addr2;
beforeEach(async function () {
[owner, addr1, addr2] = await ethers.getSigners();
const MessageBoard = await ethers.getContractFactory("MessageBoard");
messageBoard = await MessageBoard.deploy();
});
it("메시지 게시가 정상 작동해야 해요", async function () {
await messageBoard.connect(addr1).postMessage("Hello Ethereum!");
const message = await messageBoard.messages(0);
expect(message.content).to.equal("Hello Ethereum!");
expect(message.author).to.equal(addr1.address);
});
it("빈 메시지는 거부되어야 해요", async function () {
await expect(
messageBoard.connect(addr1).postMessage("")
).to.be.revertedWith("Empty message");
});
});
흔한 에러와 해결법
- "Nonce too high" 에러: MetaMask 설정 → 고급 → 계정 초기화를 실행하세요.
- "Insufficient funds" 에러: Sepolia 테스트 ETH를 Alchemy Faucet에서 받으세요.
- "Gas estimation failed" 에러: 컨트랙트 함수의 require 조건을 확인하세요. 대부분 조건 불충족이 원인이에요.
- 프론트엔드 연결 실패: 네트워크가 올바른지, CONTRACT_ADDRESS가 정확한지 확인하세요.
메인넷 배포 체크리스트
실제 메인넷 배포 전 최종 점검 사항이에요:
- 감사 필수: 메인넷 배포 전 반드시 전문 감사를 받으세요
- 멀티시그 지갑: Owner 권한은 멀티시그로 관리하세요
- 업그레이드 전략: 프록시 패턴 고려 (UUPS/Transparent)
- 모니터링: Tenderly나 OpenZeppelin Defender 설정
- 비상 정지: Circuit Breaker 패턴 구현 검토
경고: 테스트넷에서 충분히 테스트하세요. 실제 자금 사용 시 본인 책임이에요.
블록체인 개발은 계속 진화하는 분야예요. 최신 보안 패턴과 가스 최적화 기법을 꾸준히 학습하면서 안전한 스마트컨트랙트를 개발해보세요.