이더리움 스마트컨트랙트 101: Solidity로 Hello World 구현하는 방법



블록체인 개발의 첫 걸음을 떼려는 분들을 위해 이더리움 스마트컨트랙트의 기초를 다뤄볼게요. 단순한 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가 정확한지 확인하세요.


메인넷 배포 체크리스트


실제 메인넷 배포 전 최종 점검 사항이에요:

  1. 감사 필수: 메인넷 배포 전 반드시 전문 감사를 받으세요
  2. 멀티시그 지갑: Owner 권한은 멀티시그로 관리하세요
  3. 업그레이드 전략: 프록시 패턴 고려 (UUPS/Transparent)
  4. 모니터링: Tenderly나 OpenZeppelin Defender 설정
  5. 비상 정지: Circuit Breaker 패턴 구현 검토

경고: 테스트넷에서 충분히 테스트하세요. 실제 자금 사용 시 본인 책임이에요.


블록체인 개발은 계속 진화하는 분야예요. 최신 보안 패턴과 가스 최적화 기법을 꾸준히 학습하면서 안전한 스마트컨트랙트를 개발해보세요.


Web3 DApp 개발 가이드: 블록체인과 프론트엔드 연결하기