‘이더리움 댑 개발’ 세미나 3. 4장. Our First Smart Contract

이번 세미나에서는 4장.Our First Smart Contract을 다룹니다.

솔리디티의 세부적인 내용을 자세하게 학습한다기 보다는 개발 환경에 익숙해지는 것을 목표로 삼고 진행하시면 됩니다.

실습 위주로 진행해야 하며, 분량도 조금 많습니다. 매일 조금씩이라도 진행하겠다는 마음가짐을 가지고 시간 계획을 잘 세우셔서 진행하셔야 합니다.

 

이번 장의 전체 소스코드는 아래 링크에서 확인할 수 있습니다.

https://github.com/RedSquirrelTech/hoscdev/tree/master/chapter-4/greeter

Setup

프로젝트 디렉토리를 새로 만들고, init 명령으로 프로젝트를 초기화 합니다.

truffle init으로 프로젝트를 초기화하면 다음과 같은 디렉토리와 파일들이 생성됩니다.

  • contracts 디렉토리
    • Migrations.sol 파일
  • migrations 디렉토리
    • 1_initial_migration.js
  • test 디렉토리
  • truffle-config.js 파일

 

contracts 디렉토리에 솔리디티 컨트랙트 파일들을 작성합니다. test 디렉토리에 테스트 파일들을 작성합니다. Mocha와 Chai를 사용해 자바 스크립트로 테스트 케이스를 작성할 수 있습니다.

Migrations.sol은 트러플이 컨트랙트의 배포 상태를 추적하기 위해 사용하는 컨트랙트입니다. 다른 컨트랙트들의 배포 상태를 관리해야 하니, 당연히 가장 먼저 배포되는 컨트랙트입니다.

migrations 디렉토리에는 컨트랙트를 배포하기 위해 사용되는 자바 스크립트 파일들이 작성됩니다. 1_initial_migration.js는 Migrations.sol 컨트랙트 배포에 사용됩니다. 추가적인 컨트랙트를 작성한 경우 배포를 위한 자바 스크립트를 작성해야 컨트랙트를 배포할 수 있습니다. 하나의 배포 스크립트에서 하나 이상의 컨트랙트를 배포할 수 있습니다.

truffle-config.js는 환경설정 파일입니다.

Our First Test

TDD(Test-Driven Development) 방식으로 개발을 진행할 것입니다. 자바 스크립트로 테스트를 작성하는 것에 대해서는 트러플 테스트 문서를 참조합니다.

여기서는 이 문서의 내용을 간단히 요약하도록 하겠습니다.

  • 트러플은 Mocha 테스팅 프레임워크를 사용하고 assertion을 위해서 Chai를 사용한다.
  • 트러플 테스트는 Mocha와 달리 contract라는 함수를 제공한다. 이 함수는 describe와 정확히 같은 일을 하는데, 트러플의 클린룸 특징을 가능하게 한다.
    • contract 함수가 실행되기 전에, 컨트랙트가 이더리움 클라이언트에 재배포되기 때문에 컨트랙트는 clean 상태로 테스트된다. 
    • contract 함수는 테스트에서 사용할 수 있도록 계정 리스트를 제공할 수 있다.
  • 트러플은 자바스크립트에서 컨트랙트와 상호작용하기 위해 contract abstraction을 제공한다.
    • artifacts.require()를 사용한다.
  • 각각의 테스트 파일에서 web3 인스턴스를 사용할 수 있다.

 

아래 인용한 본문 내용을 다시 한 번 주의 깊게 읽어 봅니다.

  • Truffle tests use Mocha, but with a twist. The contract function will act similar to the built-in describe but with the added benefit of using Truffle’s clean room feature. This feature means that fresh contracts will be deployed before the tests nested within are executed. This helps prevent state from being shared between different test groups.

테스트 작성하기

테스트 파일은 다음과 같이 구조화하도록 하겠습니다.

  • 컨트랙트 하나에 하나의 테스트 파일을 작성합니다.
  • 하나의 테스트 파일에는 다수의 contract 함수를 가질 수 있습니다. contract 함수 마다 clear한 컨트랙트 상태를 갖게되므로 컨트랙트 상태 변경에 따라 서로 영향을 받을 수 있는 테스트 케이스들은 분리해야 합니다.
  • contract 함수는 다수의 describe를 가질 수 있습니다. describe 함수가 테스트 스위트가 됩니다.
    • contract 함수가 최상위 테스트 스위트가 됩니다.
  • describe 함수는 다수의 it 함수를 가질 수 있습니다. it 함수가 테스트 케이스가 됩니다.
  • contract 함수 하위에 it 함수를 바로 작성할 수도 있습니다.
    • 컨트랙트 배포와 같이 테스트 대상 함수가 없는 경우

 

greeter 디렉토리에서 비주얼 스튜디오 코드를 실행하고, test 디렉토리에 greeter_test.js 파일을 추가합니다.

본문 내용을 참고하고, 위에서 언급한 테스트 파일 구조화에 따라 테스트 케이스를 작성합니다.

  • artifacts.require 함수를 사용해서 컨트랙트를 로드합니다.
  • contract 함수를 사용해서 테스트 스위트를 작성하고, it 함수를 사용해 테스트 케이스를 작성합니다.
      • 배포된 컨트랙트를 구합니다.
        • GreeterContract.deployed()
      • 컨트랙트 함수 호출은 모두 비동기적으로 이루어집니다. async와 await 사용합니다.

 

테스트 명령으로 테스트를 실행합니다. Greeter 컨트랙트를 아직 작성하지 않았으니 에러가 납니다.

 

contracts 디렉토리에 Greeter .sol 파일을 추가합니다.

  • Greeter 컨트랙트를 작성합니다.
  • 솔리디티 컴파일 버전을 0.4.0과 같거나 크고 0.7.0보다는 작게 설정합니다. 솔리디티는 실험적 언어로 버전 마다 큰 차이를 보이기도 합니다. 특히 0.5.0 버전에서 좀 많은 변화가 있었습니다. 이 책은 0.5.0 이후 변경 내용을 반영하고 있기 때문에 “pragma solidity >= 0.5.0;”라고 작성해도 됩니다. 버전을 명시할 때 “^0.5.0″와 같이 작성할 수도 있습니다. 이 경우는 0.5.0이후 버전을 지원하지만 0.6으로 시작하는 버전은 지원하지 않게 됩니다.

 

테스트를 다시 실행합니다. Greeter 컨트랙트가 아직 배포되지 않았으니 테스트는 여전히 실패합니다.

테스트를 실행하면 트러플은 컨트랙트를 컴파일하고 배포합니다. 컨트랙트를 배포하려면 배포 파일을 자바스크립트로 작성해야 합니다. migrations 디렉토리에 2_deploy_greeter.js를 추가합니다. 트러플은 배포 순서에 따라 컨트랙트를 배포합니다. 배포 파일의 접두어를 순서를 사용합니다. 첫 번째 배포되어야 할 컨트랙트는 Migrations.sol이고, 이에 대한 배포 파일은 1_initial_migration.js입니다. 이 파일은 프로젝트 초기화를 통해 트러플이 자동 생성했습니다. 이름의 일관성을 위해서 2_deploy_greeter.js보다는 2_greeter_migration.js가 더 적당해 보입니다.

  • artifacts.require 함수로 컨트랙트를 로드합니다.
  • deployer.deploy 함수를 사용해서 컨트랙트를 배포합니다.

 

테스트를 다시 실행합니다. 테스트 케이스가 성공적으로 통과합니다.

Saying Hello

Greeter 컨트랙트는 “Hello, World!”라고 인사할 수 있어야 합니다.

  • Greeter.greet 함수는 “Hello, World!”를 리턴해야 합니다.

 

“greet()”로 테스트 스위트를 작성하고, “it returns Hello, World!”로 테스트 케이스를 작성합니다.

  • describe(“greet()”, … it(“returns ‘Hello, World!'”, …
  • 테스트 결과로 기대된 값은 “Hello, World!” 입니다. 실제 값은 greeter.greet()를 실행한 값입니다. 실제 값과 기대 값이 같아야 합니다.

테스트를 실행합니다. Greeter 컨트랙트에 greet 함수를 작성하지 않았으니 오류가 발생할 것입니다.

 

Greeter 컨트랙트에 greet 함수를 작성합니다. 함수는 function 키워드를 사용해서 작성합니다.

  • 컨트랙트 외부에서만 접근할 수 있도록 가시성(visibility)을 external로 작성합니다.
    • 함수는 외부에서 접근할 수 있느냐 내부에서만 접근할 수 있느냐에 따라 external과 internal로 구분됩니다.
      • 컨트랙트는 객체지향 프로그래밍 언어의 클래스와 유사하게 상속을 지원합니다. 상속을 지원하다보니 해당 클래스에서만 접근할 수 있는 것과 상속 받는 파생 클래스에서도 접근할 수 있는 것이 구분되어야 합니다. 솔리디티는 파생 클래스에서도 접근할 수 있는 것을 internal(객체지향 프로그래밍 언어에서는 protected)로, 해당 클래스 에서만 접근할 수 있는 것을 private으로 구분합니다.
      • 솔리디티는 외부에서 접근할 수 있는 것을 외부에서만 접근할 수 있느냐 외부 뿐만 아니라 내부에서도 접근할 수 있느냐에 따라 external과 public을 구분합니다.
    • 함수와 달리 상태 변수는 external을 지원하지 않습니다.  public인 경우 컴파일러가 getter 함수를 생성합니다.
  • 컨트랙트 변수 값을 변경하거나 읽을 필요가 없습니다. pure로 설정합니다.
    • view는 컨트랙트 변수 값을 변경하지는 않지만 읽어야 할 경우 사용합니다.
  • 문자열 값을 리턴해야 합니다.
    • returns(string memory)
      • string 다음에 작성된 memory에 대해서는 뒤에서 좀 더 자세히 설명하겠습니다.

 

테스트를 다시 실행하면, 두 개의 테스트 케이스가 성공적으로 통과함을 볼 수 있습니다.

storage, memory, calldata, stack

데이터 위치에 대한 설명은 책 본문 내용 만으로 이해하기가 쉽지 않기에 좀 더 자세히 설명하도록 하겠습니다.

먼저 메모리 구조 관점에서 설명해 보겠습니다.

  • 프로그래밍에서 메모리 구조를 이야기할 때는 스택과 힙을 이야기 합니다. 이더리움 가상 머신도 스택과 힙을 구분합니다.
  • 이더리움 가신 머신은 자원 사용을 제약하기 위해 힙을 좀 더 세분화합니다. memory, storage, calldata를 구분합니다. 솔리디티에서는 이것들을 데이터 위치라고 합니다.
    • memory와 storage를 구분하는 기준은 라이프타임입니다. memory에 위치하는 값은 일시적이고 storage에 위치하는 값은 영속적입니다.
    • 컨트랙트 상태 변수는 항상 storage에 위치합니다.

 

다음은 함수 실행 관점에서 살펴보겠습니다.

  • 일반적인 프로그래밍 언어에서는 효율성을 위해 타입을 값타입과 참조타입으로 구분합니다.
    • 값타입은 스택에, 참조타입은 참조는 스택에 참조되는 실제 값은 힙에 작성됩니다.
    • 함수 호출 시에 매개변수와 리턴타입이 값타입인 경우 값 자체가 복사되어 전달(pass by value)되고, 참조타입인 경우 참조되는 메모리 위치만 복사되어 전달(pass by reference)됩니다.
      • 지역 변수에 할당 시에도 같은 규칙이 적용됩니다.
  • 솔리디티에서
    • 참조타입은 구조체, 배열, 매핑입니다.
      • 문자열(string) 타입은 동적 바이트 배열이니 참조타입 입니다.
    • 매개변수나 리턴타입이 참조타입이면 데이터 위치를 명시해야 합니다.
      • 개발자가 참조타입을 사용할 때 좀 더 주의를 기울이도록 하는 장치라고 생각하면 됩니다.
    • external 함수는 컨트랙트 외부에서만 호출할 수 있다고 했습니다. 컨트랙트 외부에서 참조에 의해 전달된 경우 함수 내에서 참조 값을 변경한다면 문제가 될 수 있습니다. 이를 막기 위한 추가적인 장치가 calldata입니다.
      • calldata는 external 함수의 매개변수에만 명시할 수 있습니다.
      • calldata로 명시된 매개변수 값은 변경할 수 없습니다.
      • calldata로 명시된 경우 참조 위치가 복사되는 것이 아니라 참조 값 자체가 복사되어 새로운 변수에 할당되어 전달됩니다.
        • 효율성 대신 안정성을 추구한 것입니다.
        • calldata로 부터 또는  calldata로 전달은 모두 값에 의해 전달됩니다. 데이터 위치가 memory든 storage든 상관 없이.
    • memory에서 memory로의 전달은 참조에 의해 전달되지만, memory에서 storage나 calldata로의 전달과  storage와 calldata로 부터 memory로의 전달은 모두 참조 값 자체가 복사되어 새로운 변수에 할당되어 전달됩니다.
    • 지역변수에 할당 시에도 같은 규칙이 사용됩니다.
      • storage에 위치한 참조타입 값을 지역 변수에 할당할 경우에는 참조 위치만 복사할지 참조 값 자체를 복사해서 할당할지를 결정해야 합니다.
        • 지역 변수의 데이터 위치를 storage로 명시하면 참조에 의한 할당이 됩니다.
          • 효율성을 고려할 수 있는 여지가 있으니 주의를 기울이라는 것입니다.
      •  storage로 명시된 경우 memory나 calldata로 부터 또는 memory나 calldata로 할당될 때는 모두 참조 값 자체를 복사해서 할당합니다.
    • 매개변수나 리턴타입이 참조타입이고 external이 아닌 경우 storage를 사용할 수 있습니다.
      • 지역 변수의 데이터 위치에 storage를 사용하는 것과 같은 이유로 사용합니다.

Making Our Contract Dynamic

Greeter 컨트랙트는 인사말을 변경할 수 있어야 합니다.

  • Greeter.setGreeting 함수는 인사말을 설정할 수 있어야 합니다. greet 함수는 설정된 인사말을 리턴해야 합니다.

 

테스트 케이스를 작성합니다.

  • 테스트 스위트를 “setGreeting(string)”로, 테스트 케이스를 “it sets greeting to passed in string”으로 작성합니다.
  • 하나의 컨트랙트에 대해서 하나의 contract 함수를 작성하기로 했으니, 본문과는 달리 “Greeter” contract에 추가합니다.
  • 인사말을 “Hi there!”로 설정합니다. greeter.greet 함수를 호출한 값과 expected 값이 같은지 비교합니다.

 

TDD 방식으로 테스트를 실행하고 점진적으로 Greeter 코드를 리팩토링 합니다.

Greeter 컨트랙트에 setGreeting을 추가합니다.

  • 컨트랙트 외부에서만 호출할 수 있고, 문자열 인사말을 인자로 받아야 합니다.  string은 참조타입이므로 데이터 위치를 명시해야 하는데 external 함수는 calldata로 설정해야 합니다.
  • setGreeting의 인자로 넘겨진 인사말을 greet에서 리턴하려면 인사말을 컨트랙트 변수로 설정해야 합니다.
    • _greeting 변수를 추가합니다. setGreeting으로 인사말을 설정하지 않은 경우 greet에서 “Hello, World!”를 리턴해야 함으로 _greeting 변수의 초기 값을 “Hello, World!”로 설정합니다.
    • greet 함수에서 컨트랙트 변수 _greeting을 참조해야 함으로 pure를 view로 변경합니다.

      •  

Making the Greeter Ownable

인사말을 아무나 변경하지 않도록 해야 합니다. 컨트랙트 소유자(owner)만 인사말을 변경할 수 있어야 합니다.

  • 컨트랙트 소유자가 설정되어 있는지, 컨트랙트를 배포한 계정으로 컨트랙트 소유자가 설정되어 있는지 테스트합니다. 배포한 계정은 accounts[0]에 해당합니다. contract 함수로 accounts가 전달되어야 합니다.
    • constructor 함수를 테스트 하는 것이니 테스트 스위트를 “constructor”로 작성하고, 테스트 케이스를 “it returns the address of the owner”와 “it matches the address that originally deployed the contract”로 해서 다음과 같이 작성합니다.
  • 테스트를 통과할 수 있도록 Greeter 컨트랙트를 변경합니다.
    • 상태변수로 소유자 계정에 해당하는 _owner를 작성합니다.
      • 이더리움에서 계정은 주소로 나타내고, 솔리디티는 주소 타입을 지원합니다.
        • 솔리디티는 주소 타입으로 address와 address payable을 제공합니다.
          • address payable은 transfer와 send 함수를 추가적으로 제공할 수 있고 이더를 받을 수도 있다는 점이 address와 다릅니다.
    • 소유자 계정을 리턴하는 owner() 함수를 작성합니다. 상태를 변경하지는 않지만 _owner를 읽기는 해야 하므로 view로 작성합니다.
      • function owner() public view returns(address)
        • 이 함수를 작성하는 시점까지는 이 함수를 호출하는 컨트랙트 내부 함수가 없기 때문에 public 보다는 external로 작성하는 것이 적당합니다.
    • 컨트랙트 소유자는 컨트랙트를 배포한 계정으로 컨트랙트 생성 시에 전달됩니다.
      • 컨트랙트 생성 시 실행되는 함수가 생성자입니다. 컨트랙트 배포 계정 주소는 msg.sender로 전달됩니다. 생성자는 컨트랙트 배포 시에만 실행되는 것으로 외부에서 호출할 수없기 때문에 external이 될 수 없습니다.
  • 테스트를 실행하고, 모든 테스트가 통과 됨을 확인합니다.
  • owner만 인사말을 변경할 수 있도록 하는지를 테스트 합니다. 다른 계정으로 인사말을 설정하려는 시도가 실패해야 합니다.
    • “setGreeting()” 테스트 스위트에 “does not set the greeting when message is sent by another account” 테스트 케이스로 추가합니다.
    • owner가 아닌 경우 예외처리를 하고 있는지를 확인해야 합니다. 예외 메시지를 비교합니다.
  • 테스트를 통과할 수 있도록 Greeter 컨트랙트를 변경합니다.
    • setGreeting() 실행을 요청한 계정이 owner와 같는지 확인 하고 다르면 에러를 발생시켜야 합니다.
      • 솔리디티는 함수 실행의 선행 조건을 확인할 수 있는 require 함수를 제공합니다. setGreeting 함수의 첫 번째 부분에 다음을 추가합니다.
        • require(msg.sender == _owner, “Ownable: caller is not the owner”);
  • 테스트를 실행하고, 모든 테스트가 통과 됨을 확인합니다.

 

modifier 사용, 오픈 제플린 컨트랙트 임포트

modifier는 좀 더 정확하게는 function modifier라고 해야 합니다. modifier는 이름 그대로 함수의 행위를 변경할 수 있도록 합니다. 일반적으로 setGreeting 함수의 선행조건 확인과 같은 함수와 관련된 제약사항을 처리하기 위해 사용됩니다.

modifier 구문은 함수와 비슷합니다. 함수와는 달리 가시성이 없습니다. 다음과 같이 setGreeting의 선행조건으로 작성한 부분을 modifier로 분리합니다.

  • modifier 이름은 onlyOwner입니다. “owner만 할 수 있어야 한다”를 표현한 것입니다.
  • require 함수를 사용해서 msg.sender가 owner인지를 체크합니다. owner가 아니면 “Ownable: caller is not the owner” 메시지로 에러를 발생시킵니다.
  • modifier는 함수 실행 전에 실행됩니다. “_”는 modifier가 실행되고 난 후 함수 부분을 실행하라는 것입니다.

 

Greeter의 setGreeting을 onlyOwner로 선언합니다.

  • external 뒤에 onlyOwner를 추가합니다.

 

오픈제플린에는 Ownable이라는 컨트랙트가 있는데 Ownable 컨트랙트에는 onlyOwner modifier가 정의되어 있습니다. 앞에서와 같이 직접 작성하지 않고 이걸 재사용할 수 있습니다.

  • greeter 프로젝트 디렉토리에서 오픈제플린을 로컬로 설치합니다.
    • npm install @openzeppelin/contracts
  • Greeter가 오픈제플린 Ownable을 상속받도록 합니다.
    • Greeter is Ownable
      • Ownable을 사용하기 위해 Ownable을 임포트 합니다.
        •  import “@openzeppelin/contracts/access/Ownable.sol”;
About the Author
(주)뉴테크프라임 대표 김현남입니다. 저에 대해 좀 더 알기를 원하시는 분은 아래 링크를 참조하세요. http://www.umlcert.com/kimhn/

Leave a Reply

*