Ver3.5 블록체인으로 투명한 투표 시스템 만들기

블록체인으로 투명한 투표 시스템 만들기
스마트 컨트랙트부터 프론트엔드 연동까지, 처음부터 끝까지 함께 달려보자고 🚀
야, 솔직히 말해봐. 선거 결과 나올 때마다 "이거 진짜 맞아?" 하고 의심해본 적 없어? 😅
중앙화된 서버 하나에 모든 투표 데이터가 몰려있으면, 이론적으로 누군가 그 서버에 접근해서 데이터를 바꿀 수도 있잖아.
물론 현실에서 그런 일이 쉽게 일어나진 않지만, 가능성 자체가 존재한다는 게 문제야.
그런데 블록체인을 쓰면 얘기가 달라져. 한 번 기록된 데이터는 절대로 수정할 수 없고, 모든 참여자가 동일한 데이터를 공유하며, 누가 어떤 투표를 했는지 완전히 투명하게 검증할 수 있어.
오늘은 이 블록체인 기반 투표 시스템을 직접 만들어보면서, 왜 이게 혁신적인지 몸으로 느껴보자! 💪
처음 보는 개념도 있겠지만, 차근차근 같이 가면 돼. 걱정 말고 따라와! 😎
기존 전자투표 시스템의 문제를 먼저 짚어보자. 현재 대부분의 전자투표 시스템은 중앙화된 서버에 의존해.
이 말은 즉, 그 서버를 관리하는 기관이나 해커가 데이터에 접근할 수 있다는 뜻이야.
중앙 서버가 다운되거나 해킹당하면 전체 시스템이 마비돼. 2020년 미국 아이오와 코커스에서 앱 오류로 결과 집계가 수 시간 지연된 사례가 대표적이야.
"내 투표가 제대로 반영됐나?" 확인할 방법이 없어. 시스템 내부 로직을 일반 유권자가 볼 수 없거든.
중앙화된 DB는 권한을 가진 내부자가 데이터를 수정할 수 있어. 기술적으로 막기 어렵고, 감사 로그도 조작될 수 있어.
결국 "우리 기관을 믿어줘"라는 말에 의존해야 해. 민주주의의 근간인 선거에서 이건 너무 취약한 구조야.
블록체인은 분산 원장(Distributed Ledger) 기술이야. 데이터가 수천 개의 노드에 동시에 저장되고,
한 번 기록된 데이터는 암호학적으로 변경이 불가능해. 구체적으로 어떤 특성이 투표에 딱 맞는지 볼게.
| 블록체인 특성 | 투표에서의 의미 |
|---|---|
| 🔒 불변성 (Immutability) | 한 번 기록된 투표는 절대 수정/삭제 불가 |
| 🌐 탈중앙화 (Decentralization) | 단일 기관이 시스템을 통제할 수 없음 |
| 👁️ 투명성 (Transparency) | 모든 투표 기록을 누구나 검증 가능 |
| 🔑 익명성 (Anonymity) | 지갑 주소로만 식별, 개인정보 노출 없음 |
| ⚡ 스마트 컨트랙트 | 투표 규칙이 코드로 자동 실행, 인간 개입 불필요 |
자, 이제 본격적으로 만들어볼 건데, 먼저 어떤 도구들을 쓸지 알아야겠지? 🧰
겁먹지 마. 하나씩 설명해줄게.
JavaScript랑 비슷한 문법이라 배우기 어렵지 않아. 투표 로직을 여기에 작성해.
로컬에서 이더리움 네트워크를 시뮬레이션하고, 컨트랙트를 컴파일·배포·테스트할 수 있어.
프론트엔드에서 블록체인과 통신할 때 쓰는 라이브러리야. Web3.js보다 가볍고 현대적이야.
유권자가 자신의 이더리움 지갑으로 투표에 서명할 때 사용해. 신원 인증 역할을 해.
투표 UI를 만들 때 사용. 물론 Vanilla JS나 Vue.js로도 가능해.
이 흐름만 머릿속에 넣어두면 전체 그림이 보여!
자, 이제 진짜 코드 짜러 가보자! 먼저 환경 세팅부터. 이거 안 하면 아무것도 못 해 😄
Node.js가 설치되어 있다고 가정할게. 없으면 nodejs.org에서 LTS 버전 설치해줘.
# 프로젝트 폴더 생성
mkdir blockchain-voting
cd blockchain-voting
# npm 초기화
npm init -y
# Hardhat 설치
npm install --save-dev hardhat
# Hardhat 프로젝트 초기화
npx hardhat init
# → "Create a JavaScript project" 선택
# 추가 패키지 설치
npm install --save-dev @nomicfoundation/hardhat-toolbox
npm install ethers dotenv
blockchain-voting/
├── contracts/
│ └── Voting.sol ← 스마트 컨트랙트
├── scripts/
│ └── deploy.js ← 배포 스크립트
├── test/
│ └── Voting.test.js ← 테스트 파일
├── frontend/
│ ├── index.html
│ ├── app.js ← Ethers.js 연동
│ └── style.css
├── hardhat.config.js
└── .env ← 비밀키 (절대 깃에 올리지 마!)
.gitignore에 반드시 .env를 추가해줘. 실수로 올렸다가 지갑 털리는 사람들 진짜 많아 😱
// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
module.exports = {
solidity: "0.8.19",
networks: {
// 로컬 테스트 네트워크
localhost: {
url: "http://127.0.0.1:8545"
},
// Sepolia 테스트넷 (실제 배포 테스트용)
sepolia: {
url: process.env.SEPOLIA_RPC_URL,
accounts: [process.env.PRIVATE_KEY]
}
}
};
이제 핵심 중의 핵심! 투표 로직을 담은 스마트 컨트랙트를 작성할 거야. 🔥
Solidity 처음이어도 괜찮아. 주석 달면서 설명해줄게.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
/**
* @title Voting - 블록체인 기반 투명 투표 시스템
* @dev 탈중앙화된 투표를 위한 스마트 컨트랙트
*/
contract Voting {
// ===== 데이터 구조 정의 =====
// 후보자 구조체
struct Candidate {
uint256 id; // 후보자 ID
string name; // 후보자 이름
string description; // 후보자 설명
uint256 voteCount; // 득표 수
}
// 투표 정보 구조체
struct VoteInfo {
string title; // 투표 제목
uint256 startTime; // 시작 시간
uint256 endTime; // 종료 시간
bool isActive; // 활성화 여부
}
// ===== 상태 변수 =====
address public owner; // 컨트랙트 소유자 (선관위)
VoteInfo public voteInfo; // 투표 정보
// 후보자 목록 (ID → 후보자)
mapping(uint256 => Candidate) public candidates;
uint256 public candidateCount; // 총 후보자 수
// 투표 여부 추적 (주소 → 투표했는지)
mapping(address => bool) public hasVoted;
// 유권자 등록 (주소 → 등록됐는지)
mapping(address => bool) public registeredVoters;
uint256 public totalVotes; // 총 투표 수
// ===== 이벤트 =====
// 이벤트는 블록체인에 로그를 남겨서 프론트에서 감지 가능
event VoterRegistered(address indexed voter);
event VoteCast(address indexed voter, uint256 indexed candidateId);
event VotingStarted(string title, uint256 startTime, uint256 endTime);
event VotingEnded(uint256 timestamp);
// ===== 수정자 (Modifier) =====
modifier onlyOwner() {
require(msg.sender == owner, "소유자만 실행 가능합니다");
_;
}
modifier onlyRegisteredVoter() {
require(registeredVoters[msg.sender], "등록된 유권자가 아닙니다");
_;
}
modifier votingActive() {
require(voteInfo.isActive, "투표가 진행 중이 아닙니다");
require(block.timestamp >= voteInfo.startTime, "투표가 아직 시작되지 않았습니다");
require(block.timestamp <= voteInfo.endTime, "투표가 종료되었습니다");
_;
}
// ===== 생성자 =====
constructor() {
owner = msg.sender; // 배포자가 소유자
}
// ===== 함수들 =====
/**
* @dev 투표 초기화 (소유자만 가능)
* @param _title 투표 제목
* @param _durationInMinutes 투표 지속 시간 (분)
*/
function initializeVoting(
string memory _title,
uint256 _durationInMinutes
) public onlyOwner {
require(!voteInfo.isActive, "이미 투표가 진행 중입니다");
voteInfo = VoteInfo({
title: _title,
startTime: block.timestamp,
endTime: block.timestamp + (_durationInMinutes * 1 minutes),
isActive: true
});
emit VotingStarted(_title, voteInfo.startTime, voteInfo.endTime);
}
/**
* @dev 후보자 추가 (소유자만 가능)
* @param _name 후보자 이름
* @param _description 후보자 설명
*/
function addCandidate(
string memory _name,
string memory _description
) public onlyOwner {
candidateCount++;
candidates[candidateCount] = Candidate({
id: candidateCount,
name: _name,
description: _description,
voteCount: 0
});
}
/**
* @dev 유권자 등록 (소유자만 가능)
* @param _voter 등록할 유권자 주소
*/
function registerVoter(address _voter) public onlyOwner {
require(!registeredVoters[_voter], "이미 등록된 유권자입니다");
registeredVoters[_voter] = true;
emit VoterRegistered(_voter);
}
/**
* @dev 투표하기 (등록된 유권자만, 투표 진행 중에만)
* @param _candidateId 투표할 후보자 ID
*/
function castVote(uint256 _candidateId)
public
onlyRegisteredVoter
votingActive
{
require(!hasVoted[msg.sender], "이미 투표하셨습니다");
require(_candidateId > 0 && _candidateId <= candidateCount, "유효하지 않은 후보자입니다");
hasVoted[msg.sender] = true;
candidates[_candidateId].voteCount++;
totalVotes++;
emit VoteCast(msg.sender, _candidateId);
}
/**
* @dev 투표 종료 (소유자만 가능)
*/
function endVoting() public onlyOwner {
require(voteInfo.isActive, "투표가 이미 종료되었습니다");
voteInfo.isActive = false;
emit VotingEnded(block.timestamp);
}
/**
* @dev 특정 후보자 정보 조회
* @param _candidateId 후보자 ID
*/
function getCandidate(uint256 _candidateId)
public
view
returns (uint256, string memory, string memory, uint256)
{
Candidate memory c = candidates[_candidateId];
return (c.id, c.name, c.description, c.voteCount);
}
/**
* @dev 현재 선두 후보자 조회
*/
function getWinner() public view returns (string memory winnerName, uint256 winnerVotes) {
require(!voteInfo.isActive || block.timestamp > voteInfo.endTime,
"투표가 아직 진행 중입니다");
uint256 maxVotes = 0;
uint256 winnerId = 0;
for (uint256 i = 1; i <= candidateCount; i++) {
if (candidates[i].voteCount > maxVotes) {
maxVotes = candidates[i].voteCount;
winnerId = i;
}
}
return (candidates[winnerId].name, candidates[winnerId].voteCount);
}
/**
* @dev 투표 현황 조회
*/
function getVotingStatus() public view returns (
string memory title,
bool isActive,
uint256 startTime,
uint256 endTime,
uint256 _totalVotes,
uint256 _candidateCount
) {
return (
voteInfo.title,
voteInfo.isActive,
voteInfo.startTime,
voteInfo.endTime,
totalVotes,
candidateCount
);
}
}
mapping(address => bool) public hasVoted는 "이 지갑 주소가 투표했나?"를 O(1)로 조회할 수 있어. 블록체인에서 효율적인 데이터 조회의 핵심이야.
onlyOwner는 소유자만 실행 가능하게 하고, votingActive는 투표 진행 중에만 실행 가능하게 해. 코드 재사용성을 높여줘.
require(!hasVoted[msg.sender])가 실패해서 트랜잭션 자체가 취소돼. 이게 블록체인의 강력한 보안이야.
기본 컨트랙트는 만들었는데, 실제 서비스에 쓰려면 몇 가지 더 필요해.
보안을 강화하고, 더 현실적인 시나리오를 처리할 수 있게 업그레이드해보자! 💪
투표를 직접 못 하는 상황에서 다른 사람에게 위임할 수 있는 기능이야.
이더리움 창시자 비탈릭 부테린이 제안한 "액체 민주주의(Liquid Democracy)"의 핵심 개념이기도 해.
// 위임 기능 추가 버전
// 유권자 구조체 확장
struct Voter {
bool isRegistered;
bool hasVoted;
uint256 votedCandidateId;
address delegate; // 위임받은 주소
uint256 weight; // 투표 가중치 (위임받으면 증가)
}
mapping(address => Voter) public voters;
/**
* @dev 투표권 위임
* @param _to 위임할 주소
*/
function delegate(address _to) public onlyRegisteredVoter {
Voter storage sender = voters[msg.sender];
require(!sender.hasVoted, "이미 투표하셨습니다");
require(_to != msg.sender, "자기 자신에게 위임할 수 없습니다");
// 순환 위임 방지
address delegateTo = _to;
while (voters[delegateTo].delegate != address(0)) {
delegateTo = voters[delegateTo].delegate;
require(delegateTo != msg.sender, "순환 위임이 감지되었습니다");
}
sender.hasVoted = true;
sender.delegate = delegateTo;
Voter storage delegateVoter = voters[delegateTo];
if (delegateVoter.hasVoted) {
// 이미 투표한 경우 바로 가중치 추가
candidates[delegateVoter.votedCandidateId].voteCount += sender.weight;
} else {
// 아직 투표 안 한 경우 가중치만 증가
delegateVoter.weight += sender.weight;
}
}
// 여러 후보에게 투표할 수 있는 버전 (예: 상위 3명 선택)
uint256 public maxChoices; // 최대 선택 가능 수
mapping(address => uint256[]) public voterChoices; // 유권자별 선택 목록
function castMultiVote(uint256[] memory _candidateIds)
public
onlyRegisteredVoter
votingActive
{
require(!hasVoted[msg.sender], "이미 투표하셨습니다");
require(_candidateIds.length <= maxChoices, "최대 선택 수를 초과했습니다");
require(_candidateIds.length > 0, "최소 1명은 선택해야 합니다");
// 중복 선택 방지
for (uint256 i = 0; i < _candidateIds.length; i++) {
for (uint256 j = i + 1; j < _candidateIds.length; j++) {
require(_candidateIds[i] != _candidateIds[j], "중복 선택 불가");
}
require(_candidateIds[i] > 0 && _candidateIds[i] <= candidateCount,
"유효하지 않은 후보자");
candidates[_candidateIds[i]].voteCount++;
}
hasVoted[msg.sender] = true;
voterChoices[msg.sender] = _candidateIds;
totalVotes++;
emit VoteCast(msg.sender, _candidateIds[0]); // 첫 번째 선택 로그
}
여기서 잠깐! 블록체인 투표의 딜레마가 있어. 🤔
투명성과 익명성이 충돌해. 모든 게 공개되면 누가 누구에게 투표했는지 알 수 있잖아.
이걸 해결하는 게 바로 영지식 증명(Zero-Knowledge Proof, ZKP)이야.
투표에 적용하면: "나는 유효한 유권자야"를 내 신원을 공개하지 않고 증명할 수 있어.
실제로 MACI (Minimal Anti-Collusion Infrastructure)라는 프로젝트가 이더리움 생태계에서 ZKP 기반 투표를 구현하고 있어. 이더리움 재단도 이 프로젝트를 지원하고 있지!
// ZKP 기반 투표의 개념적 구현 (실제로는 snarkjs 라이브러리 필요)
// 투표 커밋먼트 (실제 투표 내용을 해시로 숨김)
mapping(bytes32 => bool) public voteCommitments;
/**
* @dev 투표 커밋 (1단계: 투표 내용을 해시로 제출)
* @param _commitment 투표 내용의 해시값
*/
function commitVote(bytes32 _commitment) public onlyRegisteredVoter {
require(!hasVoted[msg.sender], "이미 투표하셨습니다");
require(!voteCommitments[_commitment], "이미 사용된 커밋먼트");
voteCommitments[_commitment] = true;
hasVoted[msg.sender] = true;
}
/**
* @dev 투표 공개 (2단계: 투표 종료 후 내용 공개)
* @param _candidateId 실제 투표한 후보자
* @param _nonce 랜덤 값 (해시 생성에 사용)
*/
function revealVote(uint256 _candidateId, uint256 _nonce) public {
require(!voteInfo.isActive, "투표가 아직 진행 중입니다");
// 커밋먼트 검증
bytes32 commitment = keccak256(abi.encodePacked(_candidateId, _nonce, msg.sender));
require(voteCommitments[commitment], "유효하지 않은 커밋먼트");
candidates[_candidateId].voteCount++;
voteCommitments[commitment] = false; // 재사용 방지
}
투표 기간에는 해시만 제출하고, 종료 후에 실제 내용을 공개하는 2단계 방식이야.
단점은 공개 단계에서 참여 안 하면 투표가 무효가 될 수 있다는 거야.
스마트 컨트랙트는 한 번 배포하면 수정이 불가능해. (업그레이드 패턴 제외)
그래서 배포 전 철저한 테스트가 정말 중요해. 버그 하나가 수억 원의 손실로 이어질 수 있거든 😰
Hardhat + Chai로 테스트 코드를 작성해보자.
// test/Voting.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Voting Contract", function () {
let voting;
let owner, voter1, voter2, voter3;
// 각 테스트 전에 새로운 컨트랙트 배포
beforeEach(async function () {
[owner, voter1, voter2, voter3] = await ethers.getSigners();
const Voting = await ethers.getContractFactory("Voting");
voting = await Voting.deploy();
await voting.waitForDeployment();
// 기본 설정
await voting.addCandidate("김철수", "경제 전문가");
await voting.addCandidate("이영희", "환경 운동가");
await voting.addCandidate("박민준", "교육 개혁가");
// 유권자 등록
await voting.registerVoter(voter1.address);
await voting.registerVoter(voter2.address);
await voting.registerVoter(voter3.address);
// 투표 시작 (60분)
await voting.initializeVoting("2024 시장 선거", 60);
});
// ===== 기본 기능 테스트 =====
describe("후보자 관리", function () {
it("후보자가 올바르게 추가되어야 함", async function () {
const [id, name, desc, votes] = await voting.getCandidate(1);
expect(name).to.equal("김철수");
expect(votes).to.equal(0);
});
it("소유자가 아닌 사람은 후보자 추가 불가", async function () {
await expect(
voting.connect(voter1).addCandidate("무단후보", "설명")
).to.be.revertedWith("소유자만 실행 가능합니다");
});
});
describe("투표 기능", function () {
it("등록된 유권자는 투표 가능", async function () {
await voting.connect(voter1).castVote(1);
const [,,,voteCount] = await voting.getCandidate(1);
expect(voteCount).to.equal(1);
});
it("이중 투표 방지", async function () {
await voting.connect(voter1).castVote(1);
await expect(
voting.connect(voter1).castVote(2)
).to.be.revertedWith("이미 투표하셨습니다");
});
it("미등록 유권자는 투표 불가", async function () {
const [, , , unregistered] = await ethers.getSigners();
await expect(
voting.connect(unregistered).castVote(1)
).to.be.revertedWith("등록된 유권자가 아닙니다");
});
it("유효하지 않은 후보자 ID로 투표 불가", async function () {
await expect(
voting.connect(voter1).castVote(99)
).to.be.revertedWith("유효하지 않은 후보자입니다");
});
});
describe("투표 결과", function () {
it("가장 많은 표를 받은 후보자가 당선", async function () {
await voting.connect(voter1).castVote(2); // 이영희
await voting.connect(voter2).castVote(2); // 이영희
await voting.connect(voter3).castVote(1); // 김철수
await voting.endVoting();
const [winnerName, winnerVotes] = await voting.getWinner();
expect(winnerName).to.equal("이영희");
expect(winnerVotes).to.equal(2);
});
it("투표 진행 중에는 결과 조회 불가", async function () {
await expect(voting.getWinner()).to.be.revertedWith(
"투표가 아직 진행 중입니다"
);
});
});
describe("이벤트 발생", function () {
it("투표 시 VoteCast 이벤트 발생", async function () {
await expect(voting.connect(voter1).castVote(1))
.to.emit(voting, "VoteCast")
.withArgs(voter1.address, 1);
});
it("유권자 등록 시 VoterRegistered 이벤트 발생", async function () {
const [, , , , newVoter] = await ethers.getSigners();
await expect(voting.registerVoter(newVoter.address))
.to.emit(voting, "VoterRegistered")
.withArgs(newVoter.address);
});
});
describe("시간 기반 테스트", function () {
it("투표 종료 후에는 투표 불가", async function () {
// 시간을 61분 앞으로 이동
await ethers.provider.send("evm_increaseTime", [61 * 60]);
await ethers.provider.send("evm_mine");
await expect(
voting.connect(voter1).castVote(1)
).to.be.revertedWith("투표가 종료되었습니다");
});
});
});
# 테스트 실행
npx hardhat test
# 가스 사용량 리포트와 함께
REPORT_GAS=true npx hardhat test
# 커버리지 확인
npx hardhat coverage
후보자 관리
✓ 후보자가 올바르게 추가되어야 함 (45ms)
✓ 소유자가 아닌 사람은 후보자 추가 불가 (12ms)
투표 기능
✓ 등록된 유권자는 투표 가능 (38ms)
✓ 이중 투표 방지 (29ms)
... (모두 통과!)
8 passing (2.3s)
// scripts/deploy.js
const { ethers } = require("hardhat");
async function main() {
console.log("🚀 블록체인 투표 시스템 배포 시작...\n");
// 배포자 정보
const [deployer] = await ethers.getSigners();
console.log("배포자 주소:", deployer.address);
console.log("배포자 잔액:", ethers.formatEther(
await ethers.provider.getBalance(deployer.address)
), "ETH\n");
// 컨트랙트 배포
const Voting = await ethers.getContractFactory("Voting");
const voting = await Voting.deploy();
await voting.waitForDeployment();
const contractAddress = await voting.getAddress();
console.log("✅ Voting 컨트랙트 배포 완료!");
console.log("컨트랙트 주소:", contractAddress, "\n");
// 초기 설정
console.log("📋 초기 설정 중...");
// 후보자 추가
const candidates = [
{ name: "김철수", desc: "경제 전문가 - 일자리 창출 공약" },
{ name: "이영희", desc: "환경 운동가 - 탄소중립 공약" },
{ name: "박민준", desc: "교육 개혁가 - 교육비 절감 공약" }
];
for (const candidate of candidates) {
const tx = await voting.addCandidate(candidate.name, candidate.desc);
await tx.wait();
console.log(` ✓ 후보자 추가: ${candidate.name}`);
}
// 투표 시작 (24시간)
const tx = await voting.initializeVoting("2024 시장 선거", 24 * 60);
await tx.wait();
console.log("\n🗳️ 투표 시작! (24시간 진행)");
// 배포 정보 저장
const deployInfo = {
contractAddress,
deployer: deployer.address,
network: (await ethers.provider.getNetwork()).name,
timestamp: new Date().toISOString()
};
const fs = require("fs");
fs.writeFileSync(
"deployment.json",
JSON.stringify(deployInfo, null, 2)
);
console.log("\n📄 배포 정보가 deployment.json에 저장되었습니다.");
console.log("🎉 배포 완료!\n");
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
# 로컬 네트워크에 배포 (테스트용)
npx hardhat node # 터미널 1: 로컬 노드 실행
npx hardhat run scripts/deploy.js --network localhost # 터미널 2: 배포
# Sepolia 테스트넷에 배포 (실제 테스트)
npx hardhat run scripts/deploy.js --network sepolia
# 배포 후 Etherscan 검증 (소스코드 공개)
npx hardhat verify --network sepolia DEPLOYED_CONTRACT_ADDRESS
2. MetaMask에서 Sepolia 테스트 ETH 받기 (sepoliafaucet.com)
3. .env 파일에 SEPOLIA_RPC_URL과 PRIVATE_KEY 설정
4. 테스트넷이라도 실제 트랜잭션이 발생하니 신중하게!
이제 사용자가 실제로 투표할 수 있는 웹 인터페이스를 만들어보자! 🎨
Ethers.js를 사용해서 MetaMask와 스마트 컨트랙트를 연결할 거야.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🗳️ 블록체인 투표 시스템</title>
<script src="https://cdn.ethers.io/lib/ethers-5.7.2.umd.min.js"></script>
</head>
<body>
<div id="app">
<header>
<h1>🗳️ 블록체인 투명 투표</h1>
<div id="wallet-info">
<button id="connect-btn">지갑 연결하기</button>
<span id="wallet-address"></span>
</div>
</header>
<main>
<section id="vote-info">
<h2 id="vote-title">투표 정보 로딩 중...</h2>
<div id="vote-status"></div>
<div id="vote-timer"></div>
</section>
<section id="candidates">
<h2>후보자 목록</h2>
<div id="candidate-list"></div>
</section>
<section id="results">
<h2>실시간 현황</h2>
<div id="results-chart"></div>
</section>
</main>
</div>
<script src="app.js"></script>
</body>
</html>
// app.js - 블록체인 투표 시스템 프론트엔드
// 배포된 컨트랙트 주소와 ABI
const CONTRACT_ADDRESS = "0x..."; // deployment.json에서 가져오기
const CONTRACT_ABI = [
// Solidity 컴파일 후 artifacts/contracts/Voting.sol/Voting.json에서 복사
"function castVote(uint256 _candidateId) public",
"function getCandidate(uint256 _candidateId) public view returns (uint256, string, string, uint256)",
"function getVotingStatus() public view returns (string, bool, uint256, uint256, uint256, uint256)",
"function hasVoted(address) public view returns (bool)",
"function candidateCount() public view returns (uint256)",
"function totalVotes() public view returns (uint256)",
"event VoteCast(address indexed voter, uint256 indexed candidateId)"
];
let provider, signer, contract;
let userAddress = null;
// ===== 지갑 연결 =====
async function connectWallet() {
if (typeof window.ethereum === 'undefined') {
alert('MetaMask를 설치해주세요! metamask.io');
return;
}
try {
// MetaMask 연결 요청
provider = new ethers.providers.Web3Provider(window.ethereum);
await provider.send("eth_requestAccounts", []);
signer = provider.getSigner();
userAddress = await signer.getAddress();
// 컨트랙트 인스턴스 생성
contract = new ethers.Contract(CONTRACT_ADDRESS, CONTRACT_ABI, signer);
// UI 업데이트
document.getElementById('wallet-address').textContent =
`${userAddress.slice(0, 6)}...${userAddress.slice(-4)}`;
document.getElementById('connect-btn').textContent = '연결됨 ✅';
document.getElementById('connect-btn').disabled = true;
// 데이터 로드
await loadVotingData();
await checkVotingStatus();
// 이벤트 리스너 등록
listenToEvents();
console.log("지갑 연결 성공:", userAddress);
} catch (error) {
console.error("지갑 연결 실패:", error);
alert("지갑 연결에 실패했습니다: " + error.message);
}
}
// ===== 투표 데이터 로드 =====
async function loadVotingData() {
try {
// 투표 현황 조회
const [title, isActive, startTime, endTime, totalVotes, candidateCount] =
await contract.getVotingStatus();
document.getElementById('vote-title').textContent = title;
// 남은 시간 계산
const now = Math.floor(Date.now() / 1000);
const remaining = endTime.toNumber() - now;
if (isActive && remaining > 0) {
startTimer(remaining);
document.getElementById('vote-status').innerHTML =
'<span style="color:green">🟢 투표 진행 중</span>';
} else {
document.getElementById('vote-status').innerHTML =
'<span style="color:red">🔴 투표 종료</span>';
}
// 후보자 목록 로드
await loadCandidates(candidateCount.toNumber(), totalVotes.toNumber());
} catch (error) {
console.error("데이터 로드 실패:", error);
}
}
// ===== 후보자 목록 로드 =====
async function loadCandidates(count, totalVotes) {
const container = document.getElementById('candidate-list');
container.innerHTML = '';
// 이미 투표했는지 확인
const voted = userAddress ? await contract.hasVoted(userAddress) : false;
for (let i = 1; i <= count; i++) {
const [id, name, description, voteCount] = await contract.getCandidate(i);
const percentage = totalVotes > 0
? Math.round((voteCount.toNumber() / totalVotes) * 100)
: 0;
const card = document.createElement('div');
card.className = 'candidate-card';
card.innerHTML = `
<h3>${name}</h3>
<p>${description}</p>
<div class="vote-bar">
<div class="vote-fill" style="width:${percentage}%"></div>
</div>
<span>${voteCount}표 (${percentage}%)</span>
${!voted ? `<button onclick="castVote(${id})">투표하기</button>` : ''}
`;
container.appendChild(card);
}
}
// ===== 투표 실행 =====
async function castVote(candidateId) {
if (!userAddress) {
alert("먼저 지갑을 연결해주세요!");
return;
}
try {
// 투표 트랜잭션 전송
const tx = await contract.castVote(candidateId);
// 트랜잭션 대기 중 UI 업데이트
showLoading("트랜잭션 처리 중... ⏳");
// 트랜잭션 완료 대기
const receipt = await tx.wait();
console.log("투표 완료! 트랜잭션 해시:", receipt.transactionHash);
alert(`✅ 투표가 완료되었습니다!\n트랜잭션: ${receipt.transactionHash}`);
// 데이터 새로고침
await loadVotingData();
} catch (error) {
if (error.code === 4001) {
alert("사용자가 트랜잭션을 취소했습니다.");
} else {
console.error("투표 실패:", error);
alert("투표 실패: " + (error.reason || error.message));
}
}
}
// ===== 실시간 이벤트 리스닝 =====
function listenToEvents() {
// VoteCast 이벤트 구독
contract.on("VoteCast", async (voter, candidateId) => {
console.log(`새 투표: ${voter} → 후보자 ${candidateId}`);
// 실시간 업데이트
await loadVotingData();
// 내 투표인 경우 알림
if (voter.toLowerCase() === userAddress.toLowerCase()) {
showNotification("내 투표가 블록체인에 기록되었습니다! 🎉");
}
});
}
// ===== 카운트다운 타이머 =====
function startTimer(seconds) {
const timerEl = document.getElementById('vote-timer');
const interval = setInterval(() => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
timerEl.textContent = `⏱️ 남은 시간: ${h}시간 ${m}분 ${s}초`;
if (seconds <= 0) {
clearInterval(interval);
timerEl.textContent = "⏰ 투표가 종료되었습니다";
loadVotingData();
}
seconds--;
}, 1000);
}
// ===== 이벤트 바인딩 =====
document.getElementById('connect-btn').addEventListener('click', connectWallet);
// MetaMask 계정 변경 감지
if (window.ethereum) {
window.ethereum.on('accountsChanged', (accounts) => {
if (accounts.length === 0) {
userAddress = null;
document.getElementById('wallet-address').textContent = '';
} else {
location.reload();
}
});
window.ethereum.on('chainChanged', () => {
location.reload();
});
}
-
ethers.providers.Web3Provider → ethers.BrowserProvider-
BigNumber.toNumber() → 그냥 숫자로 자동 변환-
ethers.utils.formatEther() → ethers.formatEther()프로젝트 시작 전에 버전 확인하고 맞는 문법 써줘!
스마트 컨트랙트 보안은 정말 중요해. 역사상 가장 유명한 해킹 사례인 2016년 DAO 해킹은
재진입 공격(Reentrancy Attack) 하나로 360만 ETH (당시 약 6천만 달러)가 탈취됐어. 😱
투표 시스템에서 주의해야 할 보안 이슈들을 살펴보자.
// OpenZeppelin AccessControl 사용 예시
import "@openzeppelin/contracts/access/AccessControl.sol";
contract SecureVoting is AccessControl {
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant REGISTRAR_ROLE = keccak256("REGISTRAR_ROLE");
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(ADMIN_ROLE, msg.sender);
}
// 유권자 등록은 REGISTRAR_ROLE만 가능
function registerVoter(address _voter) public onlyRole(REGISTRAR_ROLE) {
// ...
}
}
block.timestamp는 마이너가 약 15초 정도 조작할 수 있어. 투표 마감 시간이 정확히 중요한 경우, 블록 번호(block.number)를 기준으로 하거나 Chainlink 같은 오라클을 사용하는 게 더 안전해.
// 가스 최적화: 후보자 수 제한
uint256 public constant MAX_CANDIDATES = 50;
function addCandidate(string memory _name, string memory _desc) public onlyOwner {
require(candidateCount < MAX_CANDIDATES, "최대 후보자 수 초과");
// ...
}
// 가스 최적화: storage 대신 memory 사용
function getAllCandidates() public view returns (Candidate[] memory) {
Candidate[] memory result = new Candidate[](candidateCount);
for (uint256 i = 1; i <= candidateCount; i++) {
result[i-1] = candidates[i]; // storage → memory 복사
}
return result;
}
보안 검증된 스마트 컨트랙트 라이브러리인 OpenZeppelin을 적극 활용하자!
이미 수천 번의 감사(Audit)를 거친 코드라 직접 짜는 것보다 훨씬 안전해.
# OpenZeppelin 설치
npm install @openzeppelin/contracts
// 사용 예시
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureVoting is Ownable, Pausable, ReentrancyGuard {
// 긴급 상황 시 투표 일시 중지
function pauseVoting() public onlyOwner {
_pause();
}
// 재진입 공격 방지
function castVote(uint256 _candidateId)
public
nonReentrant // 재진입 방지
whenNotPaused // 일시 중지 상태 체크
{
// ...
}
}
CertiK, Trail of Bits, ConsenSys Diligence 같은 회사들이 스마트 컨트랙트 감사를 전문으로 해.
비용이 들더라도 수억 원의 손실을 막을 수 있어. 투자 가치가 충분해!
| 국가/기관 | 시기 | 내용 | 결과 |
|---|---|---|---|
| 🇨🇭 스위스 추크 | 2018년 | 이더리움 기반 시민 투표 (72명 참여) | 기술적 성공, 소규모 파일럿 |
| 🇸🇱 시에라리온 | 2018년 | 대통령 선거 일부 지역 블록체인 집계 | 투명성 향상, 공식 채택은 아님 |
| 🇺🇸 웨스트버지니아 | 2018년 | 해외 군인 투표 모바일 블록체인 앱 | 144명 참여, 이후 보안 우려로 중단 |
| 🇰🇷 서울시 | 2019년 | 청년 정책 제안 블록체인 투표 | 시범 운영 성공 |
| 🌐 이더리움 거버넌스 | 현재 | Snapshot 플랫폼으로 DAO 투표 | 수백만 명 사용 중 |
전국 단위 공식 선거에 적용하려면 아직 해결해야 할 기술적·사회적 과제가 많아.
하지만 기술은 계속 발전하고 있고, 5-10년 내에 실용적인 솔루션이 나올 가능성이 높아! 🚀
이론은 충분히 했으니, 지금 당장 실제로 쓸 수 있는 도구를 알아보자! 🎯
Snapshot은 현재 가장 널리 사용되는 블록체인 기반 투표 플랫폼이야.
Uniswap, Aave, Compound 같은 메이저 DeFi 프로토콜들이 모두 Snapshot으로 거버넌스 투표를 해.
// Snapshot 스페이스 설정 파일 예시 (snapshot.json)
{
"name": "My DAO",
"about": "우리 DAO의 거버넌스 투표 공간입니다",
"avatar": "ipfs://QmXxx...",
"network": "1", // 1: 이더리움 메인넷
"symbol": "MYDAO",
"strategies": [
{
"name": "erc20-balance-of",
"params": {
"address": "0x토큰컨트랙트주소",
"symbol": "MYDAO",
"decimals": 18
}
}
],
"admins": ["0x관리자주소"],
"members": [],
"filters": {
"minScore": 100, // 최소 100 토큰 보유자만 투표 가능
"onlyMembers": false
},
"voting": {
"delay": 86400, // 제안 후 24시간 후 투표 시작
"period": 259200, // 투표 기간 72시간
"type": "single-choice", // 단순 선택
"quorum": 0
}
}
이차 투표는 비탈릭 부테린이 강력 지지하는 투표 방식이야. 🤓
핵심 아이디어: 투표 비용이 표의 수의 제곱에 비례해.
- 2표 = 4 토큰 (2²)
- 3표 = 9 토큰 (3²)
- 10표 = 100 토큰 (10²)
이렇게 하면 부자가 돈으로 투표를 독점하기 어려워져. 100 토큰으로 10표밖에 못 내거든.
반면 1 토큰만 있어도 1표는 낼 수 있어서 소수 의견도 반영돼. 민주주의의 이상에 더 가까워!
// 이차 투표 구현 예시
function quadraticVote(uint256 _candidateId, uint256 _votes) public {
uint256 cost = _votes * _votes; // 비용 = 표 수의 제곱
require(
governanceToken.balanceOf(msg.sender) >= cost,
"토큰이 부족합니다"
);
// 토큰 소각 (투표 비용 지불)
governanceToken.burnFrom(msg.sender, cost);
candidates[_candidateId].voteCount += _votes;
emit QuadraticVoteCast(msg.sender, _candidateId, _votes, cost);
}
자, 이제 거의 다 왔어! 배포 전에 최종 점검을 해보자. 🎯
이 체크리스트를 모두 통과하면 실제 서비스에 올릴 준비가 된 거야.
- ☐ 모든 함수에 적절한 접근 제어 modifier 적용
- ☐ 모든 입력값에 require 검증 추가
- ☐ 이중 투표 방지 로직 테스트 완료
- ☐ 투표 시간 제한 로직 테스트 완료
- ☐ 가스 최적화 (불필요한 storage 읽기 최소화)
- ☐ 이벤트 로그 모든 중요 동작에 추가
- ☐ 단위 테스트 커버리지 90% 이상
- ☐ Slither 또는 MythX로 자동 보안 스캔
- ☐ 전문 감사(Audit) 완료 (실제 서비스 시)
- ☐ MetaMask 미설치 시 안내 메시지
- ☐ 잘못된 네트워크 연결 시 경고
- ☐ 트랜잭션 처리 중 로딩 UI
- ☐ 트랜잭션 실패 시 에러 메시지
- ☐ 실시간 이벤트 업데이트
- ☐ 모바일 반응형 디자인
- ☐ 접근성 (스크린 리더 지원)
- ☐ 컨트랙트 주소 Etherscan 링크 제공
- ☐ .env 파일 .gitignore에 추가
- ☐ 테스트넷 배포 및 충분한 테스트
- ☐ 컨트랙트 소스코드 Etherscan 검증
- ☐ 배포 주소 문서화
- ☐ 비상 연락처 및 대응 계획 수립
- ☐ 모니터링 시스템 구축 (Tenderly 등)
| 도구 | 용도 | 링크 |
|---|---|---|
| Remix IDE | 브라우저 기반 Solidity 개발환경 | remix.ethereum.org |
| Slither | Solidity 정적 분석 보안 도구 | github.com/crytic/slither |
| Tenderly | 스마트 컨트랙트 모니터링 | tenderly.co |
| Alchemy | 이더리움 노드 API | alchemy.com |
| Snapshot | 오프체인 DAO 투표 플랫폼 | snapshot.org |
| OpenZeppelin | 보안 검증된 컨트랙트 라이브러리 | openzeppelin.com |
마지막으로 앞으로 이 기술이 어떻게 발전할지 살펴보자. 미래를 알면 지금 뭘 공부해야 할지 보이거든! 👀
재능넷(jaenung.net)에서 Solidity 개발자, Web3 전문가를 찾아서 협업해보는 것도 좋은 방법이야.
다양한 블록체인 개발 재능을 가진 분들이 활동하고 있거든! 🙌
와, 진짜 긴 여정이었어! 🏃 처음부터 끝까지 따라온 너 진짜 대단해.
오늘 우리가 함께 한 것들을 정리해보자.
🔹 블록체인 투표가 왜 필요한지, 기존 시스템의 한계
🔹 Solidity로 완전한 투표 스마트 컨트랙트 작성
🔹 위임 투표, 다중 선택, 커밋-공개 방식 등 고급 기능
🔹 Hardhat으로 철저한 테스트 코드 작성
🔹 Ethers.js로 프론트엔드와 블록체인 연동
🔹 보안 취약점과 OpenZeppelin 활용법
🔹 실제 사례와 현실적 한계
🔹 Snapshot, DAO 거버넌스, 이차 투표
🔹 미래 기술 트렌드 (ZKP, DID, Layer 2)
📚 Solidity 공식 문서: docs.soliditylang.org
📚 Hardhat 공식 문서: hardhat.org
📚 OpenZeppelin 문서: docs.openzeppelin.com
📚 Ethereum.org 개발자 가이드: ethereum.org/developers
📚 CryptoZombies: cryptozombies.io (게임으로 배우는 Solidity)
📚 MACI 프로젝트: github.com/privacy-scaling-explorations/maci
2. 동아리 임원 선출 DAO 구축
3. 오픈소스 프로젝트 기여자 투표 시스템
4. NFT 홀더 전용 커뮤니티 투표 플랫폼
5. 재능넷 같은 플랫폼의 정책 결정 거버넌스 시스템
작은 것부터 시작해서 점점 키워나가면 돼! 💪
블록체인 기반 투표 시스템은 단순한 기술 프로젝트가 아니야.
민주주의를 더 투명하고 공정하게 만드는 사회적 인프라야.
네가 오늘 배운 이 기술이 언젠가 세상을 바꾸는 데 쓰일 수도 있어. 진짜로! 🌍
코딩하다 막히는 거 있으면 언제든 커뮤니티에 질문하고,
재능넷(jaenung.net)에서 블록체인 전문가의 도움을 받는 것도 좋은 방법이야.
같이 Web3 세상을 만들어가자! 🚀✨
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.