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

Web3는 단순한 기술 트렌드가 아니에요. 인터넷의 새로운 패러다임이자 탈중앙화된 미래를 향한 첫걸음이에요. 이 가이드에서는 DApp(탈중앙 애플리케이션)이 블록체인과 어떻게 소통하는지, 실제 배포 가능한 코드와 함께 알아볼게요.


전통적인 Web2 애플리케이션과 달리 DApp은 중앙 서버 대신 블록체인 네트워크와 직접 통신해요. 이 과정에서 Web3.js나 Ethers.js 같은 라이브러리가 브릿지 역할을 담당하죠.




개발 환경 세팅 (5분 완성)


먼저 필요한 도구들을 설치해볼게요. Node.js 18 이상 버전이 필요해요.

# 프로젝트 생성 및 의존성 설치
mkdir web3-dapp-tutorial
cd web3-dapp-tutorial
npm init -y
npm install hardhat @nomicfoundation/hardhat-toolbox
npm install ethers@6 dotenv
npx hardhat init


환경 변수 설정을 위한 .env 파일을 생성하세요:

PRIVATE_KEY=your_wallet_private_key
ALCHEMY_API_KEY=your_alchemy_api_key
ETHERSCAN_API_KEY=your_etherscan_api_key


경고: 절대로 실제 자금이 있는 지갑의 프라이빗 키를 사용하지 마세요. 테스트용 지갑을 새로 만들어 사용해요.


핵심 스마트 컨트랙트 구현


Web3의 핵심은 스마트 컨트랙트에요. 간단한 메시지 저장 컨트랙트를 만들어볼게요. 이 컨트랙트는 가스 최적화와 보안을 모두 고려한 설계예요.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract MessageBoard {
    struct Message {
        address author;
        string content;
        uint256 timestamp;
        uint256 likes;
    }
    
    Message[] public messages;
    mapping(uint256 => mapping(address => bool)) public hasLiked;
    mapping(address => uint256) public userMessageCount;
    
    event MessagePosted(uint256 indexed id, address indexed author, string content);
    event MessageLiked(uint256 indexed id, address indexed liker);
    
    modifier validMessageId(uint256 _id) {
        require(_id < messages.length, "Invalid message ID");
        _;
    }
    
    function postMessage(string calldata _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,
            likes: 0
        }));
        
        userMessageCount[msg.sender]++;
        emit MessagePosted(messages.length - 1, msg.sender, _content);
    }
}


위 컨트랙트의 가스 최적화 포인트를 살펴볼게요:

  • calldata 사용으로 메모리 복사 비용 절감
  • 이벤트 인덱싱으로 효율적인 로그 검색
  • 구조체 패킹으로 스토리지 슬롯 최적화

추가로 좋아요 기능을 구현해볼게요:

    function likeMessage(uint256 _id) external validMessageId(_id) {
        require(!hasLiked[_id][msg.sender], "Already liked");
        
        messages[_id].likes++;
        hasLiked[_id][msg.sender] = true;
        
        emit MessageLiked(_id, msg.sender);
    }
    
    function getMessages(uint256 _from, uint256 _count) 
        external 
        view 
        returns (Message[] memory) 
    {
        require(_from < messages.length, "Invalid start index");
        
        uint256 end = _from + _count;
        if (end > messages.length) {
            end = messages.length;
        }
        
        uint256 length = end - _from;
        Message[] memory result = new Message[](length);
        
        for (uint256 i = 0; i < length; i++) {
            result[i] = messages[_from + i];
        }
        
        return result;
    }


프론트엔드와 블록체인 연결


이제 React를 사용해 DApp 프론트엔드를 만들어볼게요. Web3 연결의 핵심은 Provider와 Signer 개념을 이해하는 거예요.

// utils/web3Connection.js
import { ethers } from 'ethers';

class Web3Connection {
    constructor() {
        this.provider = null;
        this.signer = null;
        this.contract = null;
    }
    
    async connect() {
        if (!window.ethereum) {
            throw new Error("MetaMask를 설치해주세요");
        }
        
        try {
            // 지갑 연결 요청
            await window.ethereum.request({ 
                method: 'eth_requestAccounts' 
            });
            
            // Provider와 Signer 설정
            this.provider = new ethers.BrowserProvider(window.ethereum);
            this.signer = await this.provider.getSigner();
            
            // 네트워크 확인
            const network = await this.provider.getNetwork();
            console.log("Connected to network:", network.chainId);
            
            return await this.signer.getAddress();
        } catch (error) {
            console.error("Connection failed:", error);
            throw error;
        }
    }
}


React 컴포넌트에서 블록체인과 상호작용하는 방법을 보여드릴게요:

// components/MessageBoard.jsx
import { useState, useEffect } from 'react';
import { ethers } from 'ethers';
import MessageBoardABI from '../artifacts/contracts/MessageBoard.sol/MessageBoard.json';

const CONTRACT_ADDRESS = "0x..."; // 배포된 컨트랙트 주소

function MessageBoard() {
    const [messages, setMessages] = useState([]);
    const [contract, setContract] = useState(null);
    const [account, setAccount] = useState('');
    
    useEffect(() => {
        initializeContract();
    }, []);
    
    async function initializeContract() {
        if (!window.ethereum) return;
        
        const provider = new ethers.BrowserProvider(window.ethereum);
        const signer = await provider.getSigner();
        const address = await signer.getAddress();
        setAccount(address);
        
        const contractInstance = new ethers.Contract(
            CONTRACT_ADDRESS,
            MessageBoardABI.abi,
            signer
        );
        
        setContract(contractInstance);
        await loadMessages(contractInstance);
        
        // 이벤트 리스너 설정
        contractInstance.on("MessagePosted", handleNewMessage);
        
        return () => {
            contractInstance.removeAllListeners();
        };
    }
}


메시지 전송 기능 구현:

    async function postMessage(content) {
        if (!contract) return;
        
        try {
            const tx = await contract.postMessage(content);
            console.log("Transaction sent:", tx.hash);
            
            // 트랜잭션 대기
            const receipt = await tx.wait();
            console.log("Transaction confirmed:", receipt);
            
            // 가스 사용량 표시
            const gasUsed = receipt.gasUsed;
            const gasPrice = receipt.gasPrice;
            const cost = ethers.formatEther(gasUsed * gasPrice);
            console.log(`Gas used: ${gasUsed}, Cost: ${cost} ETH`);
            
        } catch (error) {
            if (error.code === 'ACTION_REJECTED') {
                console.log("User rejected transaction");
            } else {
                console.error("Transaction failed:", error);
            }
        }
    }


테스트넷 배포 실습


Sepolia 테스트넷에 배포하는 과정을 설명할게요. Hardhat 설정 파일을 수정해요:

// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();

module.exports = {
    solidity: {
        version: "0.8.19",
        settings: {
            optimizer: {
                enabled: true,
                runs: 200
            }
        }
    },
    networks: {
        sepolia: {
            url: `https://eth-sepolia.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}`,
            accounts: [process.env.PRIVATE_KEY]
        }
    },
    etherscan: {
        apiKey: process.env.ETHERSCAN_API_KEY
    }
};


배포 스크립트 작성:

// scripts/deploy.js
const hre = require("hardhat");

async function main() {
    console.log("Deploying MessageBoard...");
    
    const MessageBoard = await hre.ethers.getContractFactory("MessageBoard");
    const messageBoard = await MessageBoard.deploy();
    
    await messageBoard.waitForDeployment();
    const address = await messageBoard.getAddress();
    
    console.log(`MessageBoard deployed to: ${address}`);
    
    // Etherscan 검증
    if (hre.network.name === "sepolia") {
        console.log("Waiting for block confirmations...");
        await messageBoard.deploymentTransaction().wait(6);
        
        await hre.run("verify:verify", {
            address: address,
            constructorArguments: []
        });
    }
}

main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
});


배포 명령어:

npx hardhat run scripts/deploy.js --network sepolia


보안 고려사항


DApp 개발 시 반드시 체크해야 할 보안 포인트들이에요:


1. Reentrancy 방지

contract SecureContract {
    mapping(address => uint256) private balances;
    mapping(address => bool) private locked;
    
    modifier noReentrant() {
        require(!locked[msg.sender], "Reentrant call");
        locked[msg.sender] = true;
        _;
        locked[msg.sender] = false;
    }
    
    function withdraw() external noReentrant {
        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");
    }
}


2. 프론트엔드 보안

// 입력값 검증
function validateInput(input) {
    // XSS 방지
    const sanitized = input.replace(/[<>]/g, '');
    
    // 길이 제한
    if (sanitized.length > 280) {
        throw new Error("Input too long");
    }
    
    return sanitized;
}

// 네트워크 체크
async function checkNetwork() {
    const chainId = await window.ethereum.request({ 
        method: 'eth_chainId' 
    });
    
    if (chainId !== '0xaa36a7') { // Sepolia chainId
        await window.ethereum.request({
            method: 'wallet_switchEthereumChain',
            params: [{ chainId: '0xaa36a7' }]
        });
    }
}


메인넷 배포 체크리스트


메인넷 배포 전 반드시 확인해야 할 사항들이에요:

  1. 스마트 컨트랙트 감사: 전문 감사 업체의 검토 필수
  2. 가스 최적화 검증: 각 함수의 가스 소비량 측정
  3. 권한 관리: Owner 권한과 멀티시그 지갑 설정
  4. 업그레이드 전략: 프록시 패턴 사용 여부 결정
  5. 모니터링 설정: 이벤트 로그와 알림 시스템 구축

흔한 에러와 해결법


1. "Nonce too high" 에러

MetaMask 설정에서 계정 리셋이 필요해요: 설정 > 고급 > 계정 초기화


2. "Insufficient funds" 에러

테스트넷 Faucet에서 테스트 ETH를 받아야 해요:

  • Sepolia Faucet: https://sepoliafaucet.com

3. "Gas estimation failed" 에러

컨트랙트 함수가 revert되는 경우예요. require 조건을 확인하세요.


4. CORS 에러

로컬 개발 시 발생할 수 있어요. 프록시 설정이나 CORS 확장 프로그램을 사용하세요.


마무리

Web3 DApp 개발은 전통적인 웹 개발과는 다른 패러다임을 요구해요. 블록체인의 불변성, 가스비, 트랜잭션 시간 등을 고려한 설계가 필요하죠. 이 가이드의 코드를 기반으로 자신만의 DApp을 만들어보세요.


중요 경고:

  • 메인넷 배포 전 반드시 전문가의 감사를 받으세요
  • 테스트넷에서 충분히 테스트하세요
  • 실제 자금 사용 시 모든 책임은 본인에게 있어요


50줄 파이썬으로 만드는 미니 암호화폐: 블록체인 첫걸음