온라인 투표 시스템 설계하기

KimCookieYa·2022년 7월 30일
0

블록체인 인 액션

목록 보기
2/5
post-thumbnail

* Blockchain in Action(블록체인 인 액션) 책의 실습을 바탕으로 작성하였음.

설계 단계

투표 문제를 해결하기 위해 다음과 같은 단계를 진행한다.

  1. 유스 케이스 다이어그램을 설계하기 위해, 설계 원칙 1, 2, 3을 적용한다. 이 다이어그램을 이용해 사용자, 데이터 애셋, 트랜잭션을 식별하자.
  2. 설계 원칙 4를 사용해 데이터, 수정자, 확인과 검증을 위한 규칙, 함수를 정의하는 컨트랙트 다이어그램을 설계한다.
  3. 컨트랙트 다이어그램을 사용해 솔리디티로 스마트 컨트랙트를 개발하자.
  4. 리믹스 IDE에서 스마트 컨트랙트를 컴파일하고 배포, 테스트하자.

투표 문제는 유한 상태 머신(FSM, Finite State Machine) 모델이라는 UML 설계 다이어그램 한 가지를 더 추가시키는데, 이것은 투표 과정의 각 단계를 표현한다.


설계

문제 설정

온라인 투표 시스템을 기획한다. 사람들은 다수의 제안 가운데 하나에만 투표한다. 의장은 투표할 수 있는 사람을 등록하고, 오직 등록된 사람만이 제안 선택지 중 하나에 투표(오직 한 번만)한다. 의장의 표는 가중치를 주어서 두 표로 계산한다. 투표 과정은 네 개의 상태(Init, Regs, Vote, Done)를 거치며, 이 상태에 따라 각각 다른 오퍼레이션(initialize, register, vote, count votes)을 수행한다.

UML 유스 케이스 다이어그램

투표 문제를 UML 유스 케이스 다이어그램을 이용해 분석해보자. 이 다이어그램은 사용자, 애셋, 트랜잭션을 식별하는 설계 원칙을 달성하기 위해 첫 번째로 할 일이다.

주요 행위자와 역할은 다음과 같다.

  • 의장(chairperson)은 투표자를 등록하며, 자신도 스스로 등록하고 투표할 수 있다.
  • 투표자(voters)는 투표를 한다.
  • 불특정 다수(anybody)는 누구나 투표 과정의 승자나 결과를 요청할 수 있다.

이 다이어그램은 의장 역시 투표자의 한 사람이라는 규칙을 보여준다. 유스 케이스에는 register, vote 그리고 reqWinner가 있고, count votes 함수는 다이어그램 count votes 유스 케이스가 보여주는 것처럼 내부함수다.

코드의 점진적 개발

스마트 컨트랙트의 개발 과정을 이해하기 위해, 투표 문제를 해결하기 위한 코드를 네 개의 점진적 단계를 거쳐 개발할 것이다.

  1. BallotV1 - 스마트 컨트랙트의 데이터 구조를 정의하고 테스트
  2. BallotV2 - constructor와 투표 상태를 변화시키기 위한 함수를 추가
  3. BallotV3 - 스마트 컨트랙트의 다른 함수와 신회 구축을 위한 솔리디티 기능을 보여주기 위한 수정자 추가
  4. BallotV4 - 신뢰 요소인 require(), revert(), assert()와 함수 접근 수정자를 추가

사용자, 애셋, 트랜잭션

이제 설계 원칙 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의 구성 요소는 다음과 같다.

  • states - 시작 상태와 하나 이상의 종료 상태를 가지고 있는데, 이 두 상태는 관례적으로 이중 원으로 표시한다.
  • transitions - 하나의 상태에서 다른 상태로 변화하는 것을 말한다.
  • inputs - 상태 변화를 일으키는 입력값을 의미한다(T=0, T+10일, T+11일)
  • outputs - 상태 변화 동안 출력되는 것. 없을 수도 있고 하나 이상일수도 있다. 예를 들면 표시된 상태에 따라 등록(Regs), 투표(Vote), 집계(Done) 등이 일어난다.

투표 과정을 나타내는 Init, Regs, Vote, Done의 네 개의 단계 또는 상태를 정의했다. 시스템은 우선 초기화된 Init 단계에서 시작하여 Regs 단계로 전이되는데, 여기서 등록이 일어난다. 10일간의 등록 기간 이후, 시스템은 Vote 단계로 이동하는데, 여기서 하루 동안 투표가 일어난다. 마지막으로 Done 단계로 들어가서 어느 제안이 선정되었는지 판별한다. 이 케이스에서 상태 변화는 시간 변화으로 일어난다.

BallotV2.sol 코딩

앞의 설계 내용을 코드로 전환해본다. 이 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;
    }
}

이것이 어떻게 작동하는지 살펴보자.

  1. BallotV2.sol 스마트 컨트랙트 코드를 리믹스 IDE에 입력하고, 상태 변화 기능을 체크해보자.
  2. 컨트랙트를 컴파일하고, 파라미터값으로 3을 설정한 후 Deploy 버튼을 클릭하자. Deploy 버튼을 클릭할 때마다 오른쪽 박스에 있는 파라미터 값은 투표할 제안(proposals)의 숫자로 설정된다.
  3. UI에 있는 state 버튼을 클릭하면 state의 현재 값으로 0을 보여준다.
  4. 파라미터를 1로 설정한 상태에서 changeState를 클릭하고, state의 값이 Regs를 나타내는 1로 바뀐 것을 확인하자.
  5. 이 과정을 다른 파라미터값을 가지고 반복하자.

파라미터값으로 유효하지 않은 값을 입력하면 함수가 실행이 안 된다. 만일 changeState() 함수에 음수값을 주면, 이더리움 VM은 에러로 처리할 것이다.

BallotV2.sol의 코드는 상태 변화를 일으키는 스마트 컨트랙트의 일반적 패턴을 보여준다. 상태 변화를 위한 규칙(검증을 위한)은 다른 일반적인 코드와 마찬가지로 if문으로 표련할 수 있기는 하다. 다만, 규칙을 정의하는 코드를 함수의 내용과 분리함으로써 신뢰 중개자로써의 스마트 컨트래그의 역할을 강조할 수 있다.

신뢰 중개자

통상적으로 어떤 문제에서 처리해야 할 확인, 검증, 예외는 강제해야 할 규칙과 체크해야 할 조건들에 따라 명시한다. 또한, 블록체인 기반 애플리케이션에서는 신뢰(규칙으로 표현되는)를 위반하는 트랜잭션을 되돌리거나 중단시켜서 허가받지 않은 트랜잭션이 블록체인의 변조 불가능 장부에 포함되는 것을 방지해야 한다.

솔리디티는 이러한 신뢰 요구 조건을 다룰 여러 가지 언어적 기능과 함수를 제공한다. 언어적 기능에는 다음과 같은 것이 있다.

  • modifier
  • require(condition)
  • revert()
  • assert(condition)

수정자 정의는 다음과 같다. 파라미터 reqPhase에서 설정한 대로, 투표 과정의 상태가 올바른 단계에 있는지를 확인한다.

modifier validPhase(Phase reqPhase) {
	require(state == reqPhase);
	_;
}

수정자를 포함한 컨트랙트 다이어그램

지금까지 분석하고 설계한 것을 이용해 투표 스마트 컨트랙트를 코딩하기 위해 필요한 데이터 구조와 함수를 정의하는 컨트랙트 다이어그램을 만들어 보았다. 투표 컨트랙트 다이어그램에는 데이터 정의 아래에 있는 수정자 박스에 validPhase라는 한 개의 수정자 정의가 있다. 컨트랙트의 함수 박스에서 세 개의 함수 헤더에 validPhase 수정자를 반복적으로 사용하고 있고, 다른 함수에서 validPhase 수정자가 서로 다른 파라미터를 가지고 호출되었다는 것에 주목하자. 이것은 수정자의 유연성과 재활용성을 보여준다.

만약 함수가 호출된 시점의 상태와 일치하지 않는다면, 그 함수의 호출을 중단하고 블록체인상에 실행하거나 기록하지 않는다. 이러한 검증이 수정자의 역할이다.

BallotV3.sol 코딩

정의한 수정자를 포함하여 스마트 컨트랙트를 완성해본다. 이 코드에 포함된 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;
            }
        }
    }
}
  • constructor()
  • changeState()
  • register()
  • vote()
  • reqWinner()

테스팅

Dapp 설계에 있어서 스마트 컨트랙트를 테스팅하는 것은 매우 중요한 단계다.

강력한 테스트 프로세스는 두 가지 유형의 테스팅을 포함한다.

  • 긍정 테스트 - 유효한 입력값이 주어졌을 때 스마트 컨트랙트는 기대한 바대로 올바르게 작동한다.
  • 부정 테스트 - 유효하지 않은 입력값이 주어졌을 때 스마트 컨트랙트는 확인과 검증을 통해 오류를 잡아내고 함수를 중단시킨다.

수정자 require(), revert() 사용하기

함수 호출에 다수의 규칙(액세스 수정자)을 적용할 수 있다. 만일 하나의 함수 내에서 구문의 실행 과정 중이나 후에 어떤 조건을 검사해야 한다면? 이런 경우 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() 선언

assert() 함수는 어떤 함수 내에서의 연산 과정에서 특정한 조건을 충족했는지 여부를 확인해준다.

우리가 지금까지 다룬 투표 시스템에서 이기기 위해서는 최소한 세 표(또는 과반수)가 필요하다고 가정하자. reqWinner() 함수에 assert() 문을 추가해서 이 규칙을 강제할 수 있다. 스마트 컨트랙트에 입력되는 파라미터뿐만 아니라, 함수 내에서 연산이 일어나는 여러 단계에서도 검증할 수 있다. assert(winningVoteCount >= 3)을 사용해 만일 가장 많은 투표를 받은 수가 1 또는 2이거나 전체 투표자 수가 3 미만일 경우, 함수를 중단시킬 수 있다.

BallotV4.sol 코딩

새로운 수정자인 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()는 데이터, 연산, 파라미터값 등을 검증하는 데 사용하자.


베스트 프랙티스

지금까지 블록체인 애플리케이션 개발에 특화된 중요한 추가 기능에 대해 알아보았다.

  • 스마트 컨트랙트 코드는 간결하고 일관성 있고 감사하기 쉽도록 작성하자. 스마트 컨트랙트에서 정의한 각 상태 변수와 함수는 한 가지 문제만을 다루도록 하자. 중복적인 데이터 또는 관계없는 함수를 포함하지 말아야 한다. 함수 실행 시 사전 또는 사후 조건을 확인해야 할 필요가 있을 경우, 인라인(if/else) 코드 대신, 커스텀 함수 수정자를 사용함으로써 감사하기 쉽도록 한다.
  • 함수 접근 수정자는 다음과 같은 경우에 사용하자.
    • 모든 참여자의 데이터 접근을 위한 규칙, 정책, 규정을 구현할 때
    • 함수에 접근할 수 있는 모든 참여자를 위한 공통 규칙을 구현할 때
    • 애플리케이션에 특정한 조건을 선언적으로 검증할 때
    • 스마트 컨트랙트의 정확성을 검증하기 위해 감사 가능한 요소를 제공하고자 할 때
  • 블록체인에 저장할 필요가 없는 로컬 변수는 memory 타입을 사용하자. 메모리 변수는 임시적이며 저장되지 않는다.
  • 스마트 컨트랙트를 점진적인 단계로 개발하고 매 단계마다 디버깅을 하자.
  • 솔리디티 언어는 성능과 보안 향상을 위해 자주 업데이트된다는 점을 기억하자. 업데이트가 발생할 경우, 코드를 최신 버전의 요구 조건에 맞게 변경해야 한다.
profile
무엇이 나를 살아있게 만드는가

0개의 댓글