* Blockchain in Action(블록체인 인 액션) 책의 실습을 바탕으로 작성하였음.
투표 문제를 해결하기 위해 다음과 같은 단계를 진행한다.
투표 문제는 유한 상태 머신(FSM, Finite State Machine) 모델이라는 UML 설계 다이어그램 한 가지를 더 추가시키는데, 이것은 투표 과정의 각 단계를 표현한다.
온라인 투표 시스템을 기획한다. 사람들은 다수의 제안 가운데 하나에만 투표한다. 의장은 투표할 수 있는 사람을 등록하고, 오직 등록된 사람만이 제안 선택지 중 하나에 투표(오직 한 번만)한다. 의장의 표는 가중치를 주어서 두 표로 계산한다. 투표 과정은 네 개의 상태(Init, Regs, Vote, Done)를 거치며, 이 상태에 따라 각각 다른 오퍼레이션(initialize, register, vote, count votes)을 수행한다.
투표 문제를 UML 유스 케이스 다이어그램을 이용해 분석해보자. 이 다이어그램은 사용자, 애셋, 트랜잭션을 식별하는 설계 원칙을 달성하기 위해 첫 번째로 할 일이다.
주요 행위자와 역할은 다음과 같다.
이 다이어그램은 의장 역시 투표자의 한 사람이라는 규칙을 보여준다. 유스 케이스에는 register, vote 그리고 reqWinner가 있고, count votes 함수는 다이어그램 count votes 유스 케이스가 보여주는 것처럼 내부함수다.
스마트 컨트랙트의 개발 과정을 이해하기 위해, 투표 문제를 해결하기 위한 코드를 네 개의 점진적 단계를 거쳐 개발할 것이다.
이제 설계 원칙 3을 적용해보자. 문제 설정에서 정의한 목표는 사용자가 많은 제안 중에 투표를 해서 하나를 선정하는 것이다. 투표 시스템의 유저는 의장, 투표자(의장을 포함), 그리고 투표 과정의 결과에 관심이 있는 불특정 다수다.
이 케이스에서 데이터 애셋은 투표자들이 투표할 제안들이다. 또한, 투표자들이 투표했는지 여부와 투표의 가중치를 관리하는 것도 필요하다. 의장은 투표자이기도 하고, 가중치를 갖는다는 점을 상기하자. 이 분석을 가이드라인으로 이용해 식별한 두 개의 데이터 아이템을 struct 구조체를 가지는 voters와 proposals로 코딩한다. 문제 설정에서 정의한 투표의 단계를 열거 데이터 타입인 enum으로 코딩한다.
pragma solidity ^0.8.1;
contract BallotV2 {
struct Voter {
uint weight;
bool voted;
uint vote;
}
struct Proposal {
uint voteCount;
}
address chairperson;
mapping(address=>Voter) voters;
Proposal[] proposals;
enum Phase {Init, Regs, Vote, Done}
Phase public state = Phase.Init;
}
튜표 유스 케이스 다이어그램은 오직 정적인 정보만을 보여준다. 투표 과정에서 일어나는 다이내믹한 타이밍과 상태 변화를 보여줄 방법이 없다. 더구나 이 다이어그램은 각 오퍼레이션, 즉 등록 시기, 투표 시기, 승자를 결정하는 단계가 일어나는 순서를 부여할 수가 없다. 시스템 역동성을 표현할 수 있는 또 다른 다이어그램이 필요하다.
시스템 다이내믹스를 보여주기 위해 UML 유한 상태 머신 또는 FSM 다이어그램을 사용한다. FSM은 정규 컴퓨터공학과 수학에서 창안한 것인데, 다양한 용도로 활용할 수 있는 UML 설계 다이어그램이기도 하다. 이 다이어그램은 시간과 여러 조건에 의해 변화해가는 스마트 컨트랙트의 상태 변화를 나타내므로 매우 중요하다.
투표 과정에서 투표자는 미리 등록을 해야 하는데, 대개 등록과 투표를 마쳐야 하는 종료 시간이 있다. 일부 미국의 주에서는 선거일 30일 전까지 등록해야 하고, 하루 동안 열리는 투표장에 직접 가서 투표해야만 한다. 우리는 다음과 같은 규칙을 가정해보자.
FSM의 구성 요소는 다음과 같다.
투표 과정을 나타내는 Init, Regs, Vote, Done의 네 개의 단계 또는 상태를 정의했다. 시스템은 우선 초기화된 Init 단계에서 시작하여 Regs 단계로 전이되는데, 여기서 등록이 일어난다. 10일간의 등록 기간 이후, 시스템은 Vote 단계로 이동하는데, 여기서 하루 동안 투표가 일어난다. 마지막으로 Done 단계로 들어가서 어느 제안이 선정되었는지 판별한다. 이 케이스에서 상태 변화는 시간 변화으로 일어난다.
앞의 설계 내용을 코드로 전환해본다. 이 BallotV2.sol 리스트는 BallotV2.sol의 모든 내용을 포함하고, 여기에 constructor와 함수 changeState()를 추가한다. 상태 변수 state를 세팅하기 위해 enumerated 타입인 Phase를 사용한다. 우리의 목표는 상태 변화를 일으키고 관찰해 보는 것이다. 투표 시스템의 의장이 언제 상태 변화를 일으킬지에 대한 통제를 changeState() 함수를 호출해서 행사한다고 가정하자. 이때 이 함수의 파라미터값은 네 개의 단계를 표현하는 0, 1, 2 또는 3이다.
pragma solidity ^0.8.1;
contract BallotV1 {
struct Voter {
uint weight;
bool voted;
uint vote;
}
struct Proposal {
uint voteCount;
}
address chairperson;
mapping(address=>Voter) voters;
Proposal[] proposals;
enum Phase {Init, Regs, Vote, Done}
Phase public state = Phase.Init;
constructor (uint numProposals) public { // constructor 추가
chairperson = msg.sender;
voters[chairperson].weight = 2;
for (uint prop = 0; prop < numProposals; prop ++) {
proposals.push(Proposal(0));
}
}
function changeState(Phase x) public { // 단계를 변화시키는 함수
if (msg.sender != chairperson) revert();
if (x < state) revert();
state = x;
}
}
이것이 어떻게 작동하는지 살펴보자.
파라미터값으로 유효하지 않은 값을 입력하면 함수가 실행이 안 된다. 만일 changeState() 함수에 음수값을 주면, 이더리움 VM은 에러로 처리할 것이다.
BallotV2.sol의 코드는 상태 변화를 일으키는 스마트 컨트랙트의 일반적 패턴을 보여준다. 상태 변화를 위한 규칙(검증을 위한)은 다른 일반적인 코드와 마찬가지로 if문으로 표련할 수 있기는 하다. 다만, 규칙을 정의하는 코드를 함수의 내용과 분리함으로써 신뢰 중개자로써의 스마트 컨트래그의 역할을 강조할 수 있다.
통상적으로 어떤 문제에서 처리해야 할 확인, 검증, 예외는 강제해야 할 규칙과 체크해야 할 조건들에 따라 명시한다. 또한, 블록체인 기반 애플리케이션에서는 신뢰(규칙으로 표현되는)를 위반하는 트랜잭션을 되돌리거나 중단시켜서 허가받지 않은 트랜잭션이 블록체인의 변조 불가능 장부에 포함되는 것을 방지해야 한다.
솔리디티는 이러한 신뢰 요구 조건을 다룰 여러 가지 언어적 기능과 함수를 제공한다. 언어적 기능에는 다음과 같은 것이 있다.
수정자 정의는 다음과 같다. 파라미터 reqPhase에서 설정한 대로, 투표 과정의 상태가 올바른 단계에 있는지를 확인한다.
modifier validPhase(Phase reqPhase) {
require(state == reqPhase);
_;
}
지금까지 분석하고 설계한 것을 이용해 투표 스마트 컨트랙트를 코딩하기 위해 필요한 데이터 구조와 함수를 정의하는 컨트랙트 다이어그램을 만들어 보았다. 투표 컨트랙트 다이어그램에는 데이터 정의 아래에 있는 수정자 박스에 validPhase라는 한 개의 수정자 정의가 있다. 컨트랙트의 함수 박스에서 세 개의 함수 헤더에 validPhase 수정자를 반복적으로 사용하고 있고, 다른 함수에서 validPhase 수정자가 서로 다른 파라미터를 가지고 호출되었다는 것에 주목하자. 이것은 수정자의 유연성과 재활용성을 보여준다.
만약 함수가 호출된 시점의 상태와 일치하지 않는다면, 그 함수의 호출을 중단하고 블록체인상에 실행하거나 기록하지 않는다. 이러한 검증이 수정자의 역할이다.
정의한 수정자를 포함하여 스마트 컨트랙트를 완성해본다. 이 코드에 포함된 Phase 컴포넌트는 상태 변화, 즉 FSM 기반의 다이내믹스 설계와 수정자를 사용한 검증 예시를 보여준다.
pragma solidity ^0.8.1;
contract BallotV3 {
struct Voter {
uint weight;
bool voted;
uint vote;
}
struct Proposal {
uint voteCount;
}
address chairperson;
mapping(address=>Voter) voters;
Proposal[] proposals;
enum Phase {Init, Regs, Vote, Done}
Phase public state = Phase.Init;
modifier validPhase(Phase reqPhase) {
require(state == reqPhase);
_;
}
constructor (uint numProposals) public {
chairperson = msg.sender;
voters[chairperson].weight = 2;
for (uint prop = 0; prop < numProposals; prop++) {
proposals.push(Proposal(0));
}
state = Phase.Regs;
}
function changeState(Phase x) public {
if (msg.sender != chairperson) revert();
if (x < state) revert();
state = x;
}
function register(address voter) public validPhase(Phase.Regs) {
if (msg.sender != chairperson || voters[voter].voted) revert();
voters[voter].weight = 1;
voters[voter].voted = false;
}
function vote(uint toProposal) public validPhase(Phase.Vote) {
Voter memory sender = voters[msg.sender];
if (sender.voted || toProposal >= proposals.length) revert();
sender.voted = true;
sender.vote = toProposal;
proposals[toProposal].voteCount += sender.weight;
}
function reqWinner() public validPhase(Phase.Done) view returns (uint winningProposal) {
uint winningVoteCount = 0;
for (uint prop = 0; prop < proposals.length; prop++) {
if (proposals[prop].voteCount > winningVoteCount) {
winningVoteCount = proposals[prop].voteCount;
winningProposal = prop;
}
}
}
}
Dapp 설계에 있어서 스마트 컨트랙트를 테스팅하는 것은 매우 중요한 단계다.
강력한 테스트 프로세스는 두 가지 유형의 테스팅을 포함한다.
함수 호출에 다수의 규칙(액세스 수정자)을 적용할 수 있다. 만일 하나의 함수 내에서 구문의 실행 과정 중이나 후에 어떤 조건을 검사해야 한다면? 이런 경우 require() 조항을 이용해 조건을 충족하지 못했을 때 함수를 중단시킬 수 있다. 투표 스마트 컨트랙트에서 정의한 수정자 validPhase의 경우에도 이 안에서 조건을 체크하고 이를 어기면 트랜잭션을 중단시키기 위한 require() 조항을 사용했다. 또한, vote() 함수에서도 한 투표자가 중복 투표를 하지 못하도록 검증하기 위해 revert()를 사용했다.
새 수정자를 정의하고, register() 함수에 이 검증을 추가해본다. 문제 설정에서 오직 의장만이 다른 투표자를 등록할 수 있음을 상기하자. 이 규칙을 onlyChair라는 수정자로 강제할 수 있다.
// if (msg.sender != chairperson..) : 교체할 구문
modifier onlyChair() {
require(msg.sender == chairperson);
_;
}
function register(address voter) public validPhase(Phase.Regs) onlyChair {...}
하나의 함수에 빈칸으로 분리된 리스트로 다수의 수정자를 적용할 수 있다. 수정자는 표시된 순서대로 실행하기 때문에 주의해야 한다. register() 함수의 경우, state가 잘못되었다면, 누가 register() 함수를 호출했는지 확인할 필요조차 없다.
assert() 함수는 어떤 함수 내에서의 연산 과정에서 특정한 조건을 충족했는지 여부를 확인해준다.
우리가 지금까지 다룬 투표 시스템에서 이기기 위해서는 최소한 세 표(또는 과반수)가 필요하다고 가정하자. reqWinner() 함수에 assert() 문을 추가해서 이 규칙을 강제할 수 있다. 스마트 컨트랙트에 입력되는 파라미터뿐만 아니라, 함수 내에서 연산이 일어나는 여러 단계에서도 검증할 수 있다. assert(winningVoteCount >= 3)을 사용해 만일 가장 많은 투표를 받은 수가 1 또는 2이거나 전체 투표자 수가 3 미만일 경우, 함수를 중단시킬 수 있다.
새로운 수정자인 onlyChair와 assert() 함수를 사용한 Ballot 스마트 컨트랙트 코드를 보여준다. revert(), require(), 그리고 assert()를 수정자와 결합해서 적절히 사용하면 확인과 검증을 통해 예외들 처리가 가능하고, 결국 스마트 컨트랙트에 의한 강력한 신뢰 중개를 확립할 수 있다.
if 구문 대신 require()를 사용하여 조건이 실패하면, 이 트랜잭션이 중단된다는 것을 더 확실히 알 수 있다. 만일 함수가 중단되면, 이 함수에 의해서는 어떠한 Tx도 블록체인에 기록되지 않는다.
pragma solidity ^0.8.1;
contract BallotV4 {
struct Voter {
uint weight;
bool voted;
uint vote;
}
struct Proposal {
uint voteCount;
}
address chairperson;
mapping(address=>Voter) voters;
Proposal[] proposals;
enum Phase {Init, Regs, Vote, Done}
Phase public state = Phase.Init;
modifier validPhase(Phase reqPhase) {
require(state == reqPhase);
_;
}
modifier onlyChair() {
require(msg.sender == chairperson);
_;
}
constructor (uint numProposals) public {
chairperson = msg.sender;
voters[chairperson].weight = 2;
for (uint prop = 0; prop < numProposals; prop++) {
proposals.push(Proposal(0));
}
state = Phase.Regs;
}
function changeState(Phase x) onlyChair public {
require(x > state);
state = x;
}
function register(address voter) public validPhase(Phase.Regs) onlyChair {
require(voters[voter].voted);
voters[voter].weight = 1;
// voters[voter].voted = false;
}
function vote(uint toProposal) public validPhase(Phase.Vote) {
Voter memory sender = voters[msg.sender];
require(!sender.voted);
require(toProposal < proposals.length);
sender.voted = true;
sender.vote = toProposal;
proposals[toProposal].voteCount += sender.weight;
}
function reqWinner() public validPhase(Phase.Done) view returns (uint winningProposal) {
uint winningVoteCount = 0;
for (uint prop = 0; prop < proposals.length; prop++) {
if (proposals[prop].voteCount > winningVoteCount) {
winningVoteCount = proposals[prop].voteCount;
winningProposal = prop;
}
}
assert(winningVoteCount >= 3);
}
}
assert()는 예외 처리를 위한 곳에 간헐적으로 사용하고, require()는 데이터, 연산, 파라미터값 등을 검증하는 데 사용하자.
지금까지 블록체인 애플리케이션 개발에 특화된 중요한 추가 기능에 대해 알아보았다.