Develop/Blockchain

[블록체인] 크립토좀비 - 3. 고급 솔리디티 개념

안다희 2019. 4. 3. 17:45
728x90

https://cryptozombies.io/ko/lesson/3/chapter/1

챕터 1: 컨트랙트의 불변성

지금까지 본 것만으로는, 솔리디티는 자바스크립트 같은 다른 언어와 꽤 비슷해보였을 것이네. 하지만 이더리움 DApp에는 일반적인 애플리케이션과는 다른 여러가지 특징이 있지.

첫째로, 자네가 이더리움에 컨트랙트를 배포하고 나면, 컨트랙트는 변하지 않는다네(Immutable). 다시 말하자면 컨트랙트를 수정하거나 업데이트할 수 없다는 것이지.

자네가 컨트랙트로 배포한 최초의 코드는 항상, 블록체인에 영구적으로 존재한다네. 이것이 바로 솔리디티에 있어서 보안이 굉장히 큰 이슈인 이유라네. 만약 자네의 컨트랙트 코드에 결점이 있다면, 그것을 이후에 고칠 수 있는 방법이 전혀 없다네. 자네는 사용자들에게 결점을 보완한 다른 스마트 컨트랙트 주소를 쓰라고 말하고 다녀야 할 것이네.

그러나 이것 또한 스마트 컨트랙트의 한 특징이네. 코드는 곧 법인 것이지. 자네가 어떤 스마트 컨트랙트의 코드를 읽고 검증을 했다면, 자네는 자네가 함수를 호출할 때마다, 코드에 쓰여진 그대로 함수가 실행될 것이라고 확신할 수 있네. 그 누구도 배포 이후에 함수를 수정하거나 예상치 못한 결과를 발생시키지 못한다네.

외부 의존성

레슨 2에서, 우리는 크립토키티 컨트랙트의 주소를 우리 DApp에 직접 써넣었네. 그런데 만약 크립토키티 컨트랙트에 버그가 있었고, 누군가 모든 고양이들을 파괴해버렸다면 어떻게 될 것 같은가?

그럴 일은 잘 없겠지만, 만약 그런 일이 발생한다면 우리의 DApp은 완전히 쓸모가 없어질 것이네. 우리 DApp은 주소를 코드에 직접 써넣기 때문에 어떤 고양이들도 받아올 수 없겠지. 우리 좀비들은 고양이를 먹을 수 없을 것이고, 우리는 그걸 고치기 위해 우리의 컨트랙트를 수정할 수도 없을 것이네.

이런 이유로, 대개의 경우 자네가 자네 DApp의 중요한 일부를 수정할 수 있도록 하는 함수를 만들어놓는 것이 합리적이겠지.

예를 들자면 우리 DApp에 크립토키티 컨트랙트 주소를 직접 써넣는 것 대신, 언젠가 크립토키티 컨트랙트에 문제가 생기면 해당 주소를 바꿀 수 있도록 해주는 setKittyContractAddress 함수를 만들 수 있을 것이네.

직접 해보기

레슨 2에서 우리가 만든 코드를 크립토키티 컨트랙트 주소의 업데이트가 가능하도록 바꿔보세.

  1. 우리가 직접 주소를 써넣었던 ckAddress가 있는 줄을 지우게.

  2. 우리가 kittyContract를 생성했던 줄을 변수 선언만 하도록 변경하게 - 어떤 것도 대입을 하지 않도록 하게.

  3. setKittyContractAddress라는 이름의 함수를 생성하게. 이 함수는 address 타입의 변수 _address를 하나의 인자로 받고, external 함수여야 하네.

  4. 함수 내에서, kittyContract에 KittyInterface(_address)를 대입하는 한 줄의 코드를 작성하게.

참고: 자네가 이 함수에서 보안 취약점을 발견했더라도, 걱정하지 말게 - 우린 다음 챕터에서 그걸 고칠 것이네 ;)

 

챕터 2: 소유 가능한 컨트랙트

자네, 이전 챕터에서 보안 취약점을 발견했는가?

setKittyContractAddress 함수는 external이라, 누구든 이 함수를 호출할 수 있네! 이는 아무나 이 함수를 호출해서 크립트키티 컨트랙트의 주소를 바꿀 수 있고, 모든 사용자를 대상으로 우리 앱을 무용지물로 만들 수 있다는 것이지.

우리는 우리 컨트랙트에서 이 주소를 바꿀 수 있게끔 하고 싶지만, 그렇다고 모든 사람이 주소를 업데이트할 수 있기를 원하지는 않네.

이런 경우에 대처하기 위해서, 최근에 주로 쓰는 하나의 방법은 컨트랙트를 소유 가능하게 만드는 것이네. 컨트랙트를 대상으로 특별한 권리를 가지는 소유자가 있음을 의미하는 것이지.

OpenZeppelin의 Ownable 컨트랙트

아래에 나와있는 것은 OpenZeppelin 솔리디티 라이브러리에서 가져온 Ownable 컨트랙트이네. OpenZeppelin은 자네의 DApp에서 사용할 수 있는, 안전하고 커뮤니티에서 검증받은 스마트 컨트랙트의 라이브러리라네. 이 레슨 이후에, 자네가 레슨 4의 출시를 고대하며 기다리는 동안, 우린 자네가 저들의 사이트를 확인하고 더 학습하기를 추천하네!

아래 컨트랙트를 한번 훑어보게. 우리가 아직 배우지 않은 것들이 몇몇 보이겠지만, 걱정하지 말게. 앞으로 그것들에 대해 차차 살펴볼 것이네.

 

 

/**
 * @title Ownable
 * @dev The Ownable contract has an owner address, and provides basic authorization control
 * functions, this simplifies the implementation of "user permissions".
 */
contract Ownable {
  address public owner;
  event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

  /**
   * @dev The Ownable constructor sets the original `owner` of the contract to the sender
   * account.
   */
  function Ownable() public {
    owner = msg.sender;
  }

  /**
   * @dev Throws if called by any account other than the owner.
   */
  modifier onlyOwner() {
    require(msg.sender == owner);
    _;
  }

  /**
   * @dev Allows the current owner to transfer control of the contract to a newOwner.
   * @param newOwner The address to transfer ownership to.
   */
  function transferOwnership(address newOwner) public onlyOwner {
    require(newOwner != address(0));
    OwnershipTransferred(owner, newOwner);
    owner = newOwner;
  }
}

여기에 우리가 아직 본 적 없는 몇몇 새로운 요소가 있네:

  • 생성자(Constructor): function Ownable()는 생성자이네. 컨트랙트와 동일한 이름을 가진,생략할 수 있는 특별한 함수이지. 이 함수는 컨트랙트가 생성될 때 딱 한 번만 실행된다네.
  • 함수 제어자(Function Modifier): modifier onlyOwner(). 제어자는 다른 함수들에 대한 접근을 제어하기 위해 사용되는 일종의 유사 함수라네. 보통 함수 실행 전의 요구사항 충족 여부를 확인하는 데에 사용하지. onlyOwner의 경우에는 접근을 제한해서 오직 컨트랙트의 소유자만 해당 함수를 실행할 수 있도록 하기 위해 사용될 수 있지. 우리는 다음 챕터에서 함수 제어자에 대해 더 살펴보고, _;라는 이상한 것이 뭘 하는 것인지 알아볼 것이네.
  • indexed 키워드: 이건 걱정하지 말게. 우린 아직 이게 필요하지 않아.

즉, Ownable 컨트랙트는 기본적으로 다음과 같은 것들을 하네:

  1. 컨트랙트가 생성되면 컨트랙트의 생성자가 owner에 msg.sender(컨트랙트를 배포한 사람)를 대입한다.

  2. 특정한 함수들에 대해서 오직 소유자만 접근할 수 있도록 제한 가능한 onlyOwner 제어자를 추가한다.

  3. 새로운 소유자에게 해당 컨트랙트의 소유권을 옮길 수 있도록 한다.

onlyOwner는 컨트랙트에서 흔히 쓰는 것 중 하나라, 대부분의 솔리디티 DApp들은 Ownable 컨트랙트를 복사/붙여넣기 하면서 시작한다네. 그리고 첫 컨트랙트는 이 컨트랙트를 상속해서 만들지.

우리는 setKittyContractAddress 함수를 onlyOwner로 제한하고 싶으니까, 우리 컨트랙트에도 똑같이 적용해보겠네.

직접 해보기

우리가 먼저 Ownable 컨트랙트의 코드를 ownable.sol이라는 새로운 파일로 복사해놨다네. 어서 ZombieFactory가 이걸 상속받도록 만들어보게.

  1. 우리 코드가 ownable.sol의 내용을 import하도록 수정하게. 어떻게 하는지 기억이 나지 않는다면 zombiefeeding.sol을 살펴보게.

  2. ZombieFactory 컨트랙트가 Ownable을 상속하도록 수정하게. 다시 말하지만, 이걸 어떻게 하는지 잘 기억나지 않는다면 zombiefeeding.sol을 살펴보게.

 

챕터 3: onlyOwner 함수 제어자

자, 이제 우리의 기본 컨트랙트인 ZombieFactory가 Ownable을 상속하고 있으니, 우리는 onlyOwner함수 제어자를 ZombieFeeding에서도 사용할 수 있네.

이건 컨트랙트가 상속되는 구조 때문이지. 아래 내용을 기억하게:

ZombieFeeding is ZombieFactory ZombieFactory is Ownable

그렇기 때문에 ZombieFeeding 또한 Ownable이고, Ownable 컨트랙트의 함수/이벤트/제어자에 접근할 수 있다네. 이건 향후에 ZombieFeeding을 상속하는 다른 컨트랙트들에도 마찬가지로 적용되네.

함수 제어자

함수 제어자는 함수처럼 보이지만, function 키워드 대신 modifier 키워드를 사용한다네. 그리고 자네가 함수를 호출하듯이 직접 호출할 수는 없지. 대신에 함수 정의부 끝에 해당 함수의 작동 방식을 바꾸도록 제어자의 이름을 붙일 수 있네.

onlyOwner를 살펴보면서 더 자세히 알아보도록 하지.

/**
 * @dev Throws if called by any account other than the owner.
 */
modifier onlyOwner() {
  require(msg.sender == owner);
  _;
}

우리는 이 제어자를 다음과 같이 사용할 것이네:

contract MyContract is Ownable {
  event LaughManiacally(string laughter);

  // 아래 `onlyOwner`의 사용 방법을 잘 보게:
  function likeABoss() external onlyOwner {
    LaughManiacally("Muahahahaha");
  }
}

likeABoss 함수의 onlyOwner 제어자 부분을 잘 보게. 자네가 likeABoss 함수를 호출하면, onlyOwner의 코드가 먼저 실행되네. 그리고 onlyOwner의 _; 부분을 likeABoss 함수로 되돌아가 해당 코드를 실행하게 되지.

자네가 제어자를 사용할 수 있는 다양한 방법이 있지만, 가장 일반적으로 쓰는 예시 중 하나는 함수 실행 전에 require 체크를 넣는 것이네.

onlyOwner의 경우에는, 함수에 이 제어자를 추가하면 오직 컨트랙트의 소유자(자네가 배포했다면 자네겠지)만이 해당 함수를 호출할 수 있네.

참고: 이렇게 소유자가 컨트랙트에 특별한 권한을 갖도록 하는 것은 자주 필요하지만, 이게 악용될 수도 있다네. 예를 들어, 소유자가 다른 사람의 좀비를 뺏어올 수 있도록 하는 백도어 함수를 추가할 수도 있지!

그러니 잘 기억하게. 이더리움에서 돌아가는 DApp이라고 해서 그것만으로 분산화되어 있다고 할 수는 없네. 반드시 전체 소스 코드를 읽어보고, 자네가 잠재적으로 걱정할 만한, 소유자에 의한 특별한 제어가 불가능한 상태인지 확인하게. 개발자로서는 자네가 잠재적인 버그를 수정하고 DApp을 안정적으로 유지하도록 하는 것과, 사용자들이 그들의 데이터를 믿고 저장할 수 있는 소유자가 없는 플랫폼을 만드는 것 사이에서 균형을 잘 잡는 것이 중요하네.

직접 해보기

이제 우리는 미래에 setKittyContractAddress를 우리만 수정할 수 있도록 제한할 수 있네.

  1. onlyOwner 제어자를 setKittyContractAddress에 추가하게.

 

챕터 4: 가스(Gas)

훌륭해! 이제 우리는 사용자들이 우리 컨트랙트를 마구 휘젓지 못하게 하면서도 DApp의 핵심적인 부분을 업데이트할 수 있는 방법을 터득했네.

지금부터는 또 다른 솔리디티와 다른 프로그래밍 언어들의 차이점을 살펴볼 것이네.

가스 - 이더리움 DApp이 사용하는 연료

솔리디티에서는 사용자들이 자네가 만든 DApp의 함수를 실행할 때마다 _가스_라고 불리는 화폐를 지불해야 하네. 사용자는 이더(ETH, 이더리움의 화폐)를 이용해서 가스를 사기 때문에, 자네의 DApp 함수를 실행하려면 사용자들은 ETH를 소모해야만 하네.

함수를 실행하는 데에 얼마나 많은 가스가 필요한지는 그 함수의 로직(논리 구조)이 얼마나 복잡한지에 따라 달라지네. 각각의 연산은 소모되는 가스 비용(gas cost)이 있고, 그 연산을 수행하는 데에 소모되는 컴퓨팅 자원의 양이 이 비용을 결정하네. 예를 들어, storage에 값을 쓰는 것은 두 개의 정수를 더하는 것보다 훨씬 비용이 높네. 자네 함수의 전체 가스 비용은 그 함수를 구성하는 개별 연산들의 가스 비용을 모두 합친 것과 같네.

함수를 실행하는 것은 자네의 사용자들에게 실제 돈을 쓰게 하기 때문에, 이더리움에서 코드 최적화는 다른 프로그래밍 언어들에 비해 훨씬 더 중요하네. 만약 자네의 코드가 엉망이라면, 사용자들은 자네의 함수를 실행하기 위해 일종의 할증료를 더 내야 할 걸세. 그리고 수천 명의 사용자가 이런 불필요한 비용을 낸다면 할증료가 수십 억 원까지 쌓일 수 있지.

가스는 왜 필요한가?

이더리움은 크고 느린, 하지만 굉장히 안전한 컴퓨터와 같다고 할 수 있네. 자네가 어떤 함수를 실행할 때, 네트워크상의 모든 개별 노드가 함수의 출력값을 검증하기 위해 그 함수를 실행해야 하지. 모든 함수의 실행을 검증하는 수천 개의 노드가 바로 이더리움을 분산화하고, 데이터를 보존하며 누군가 검열할 수 없도록 하는 요소이지.

이더리움을 만든 사람들은 누군가가 무한 반복문을 써서 네트워크를 방해하거나, 자원 소모가 큰 연산을 써서 네트워크 자원을 모두 사용하지 못하도록 만들길 원했다네. 그래서 그들은 연산 처리에 비용이 들도록 만들었고, 사용자들은 저장 공간 뿐만 아니라 연산 사용 시간에 따라서도 비용을 지불해야 한다네.

참고: 사이드체인에서는 반드시 이렇지는 않다네. 크립토좀비를 만든 사람들이 Loom Network에서 만들고 있는 것들이 좋은 예시가 되겠군. 이더리움 메인넷에서 월드 오브 워크래프트 같은 게임을 직접적으로 돌리는 것은 절대 말이 되지 않을 걸세. 가스 비용이 엄청나게 높을 것이기 때문이지. 하지만 다른 합의 알고리즘을 가진 사이드체인에서는 가능할 수 있지. 우린 다음에 나올 레슨에서 DApp을 사이드체인에 올릴지, 이더리움 메인넷에 올릴지 판단하는 방법들에 대해 더 얘기할 걸세.

가스를 아끼기 위한 구조체 압축

레슨 1에서, 우리는 uint에 다른 타입들이 있다는 것을 배웠지. uint8, uint16, uint32, 기타 등등..

기본적으로는 이런 하위 타입들을 쓰는 것은 아무런 이득이 없네. 왜냐하면 솔리디티에서는 uint의 크기에 상관없이 256비트의 저장 공간을 미리 잡아놓기 때문이지. 예를 들자면, uint(uint256) 대신에 uint8을 쓰는 것은 가스 소모를 줄이는 데에 아무 영향이 없네.

하지만 여기에 예외가 하나 있지. 바로 struct의 안에서라네.

만약 자네가 구조체 안에 여러 개의 uint를 만든다면, 가능한 더 작은 크기의 uint를 쓰도록 하게. 솔리디티에서 그 변수들을 더 적은 공간을 차지하도록 압축할 것이네. 예를 들면 다음과 같지:

 

struct NormalStruct {
  uint a;
  uint b;
  uint c;
}

struct MiniMe {
  uint32 a;
  uint32 b;
  uint c;
}

// `mini`는 구조체 압축을 했기 때문에 `normal`보다 가스를 조금 사용할 것이네.
NormalStruct normal = NormalStruct(10, 20, 30);
MiniMe mini = MiniMe(10, 20, 30); 

이런 이유로, 구조체 안에서는 자네는 가능한 한 작은 크기의 정수 타입을 쓰는 것이 좋네.

또한 동일한 데이터 타입은 하나로 묶어놓는 것이 좋네. 즉, 구조체에서 서로 옆에 있도록 선언하면 솔리디티에서 사용하는 저장 공간을 최소화한다네. 예를 들면, uint c; uint32 a; uint32 b;라는 필드로 구성된 구조체가 uint32 a; uint c; uint32 b; 필드로 구성된 구조체보다 가스를 덜 소모하네. uint32 필드가 묶여있기 때문이지.

직접 해보기

이번 레슨에서는, 우리는 우리의 좀비에게 2개의 새로운 특징을 추가할 것이네. level과 readyTime이지. readyTime은 좀비가 먹이를 먹는 빈도를 제한할 재사용 대기 시간을 구현하기 위해 사용하네.

자, 그럼 다시 zombiefactory.sol으로 돌아가지.

  1. 우리의 Zombie 구조체에 2개의 속성을 더 추가하게: level(uint32)과 readyTime(마찬가지로 uint32)를 말이지. 우리는 이 데이터 타입들을 압축하길 원하니, 이 둘을 구조체의 마지막 부분에 쓰게.

좀비의 레벨과 시간 데이터(Timestamp)를 저장하는 데에는 충분하고도 남는 크기이니, 이렇게 하면 보통의 uint(256비트)를 쓰는 것보다 데이터를 더 압축해서 가스 비용을 줄이도록 해줄 것이네.

 

 

챕터 5: 시간 단위

level 속성은 뭔지 말 안 해도 알겠지? 나중에 우리가 전투 시스템을 만들게 되면, 전투에서 더 많이 이긴 좀비는 시간이 지나며 레벨업을 하게 될 것이고 더 많은 기능이 생길 것이네.

readyTime 속성은 조금 설명이 필요할 듯하군. 이것의 목표는 좀비가 먹이를 먹거나 공격을 하고 나서 다시 먹거나 공격할 수 있을 때까지 기다려야 하는 "재사용 대기 시간"을 추가하는 것이네. 이 속성 없이는, 좀비는 하루에 천 번 이상 공격하거나 증식할 수 있지. 이러면 게임이 너무 쉬워져 버릴 것이네.

좀비가 다시 공격할 때까지 기다려야 하는 시간을 측정하기 위해, 우리는 솔리디티의 시간 단위(Time units)를 사용할 것이네.

시간 단위(Time units)

솔리디티는 시간을 다룰 수 있는 단위계를 기본적으로 제공하네.

now 변수를 쓰면 현재의 유닉스 타임스탬프(1970년 1월 1일부터 지금까지의 초 단위 합) 값을 얻을 수 있네. 내가 이 글을 쓸 때 유닉스 타임의 값은 1515527488이군.

참고: 유닉스 타임은 전통적으로 32비트 숫자로 저장되네. 이는 유닉스 타임스탬프 값이 32비트로 표시가 되지 않을 만큼 커졌을 때 많은 구형 시스템에 문제가 발생할 "Year 2038" 문제를 일으킬 것이네. 그러니 만약 우리 DApp이 지금부터 20년 이상 운영되길 원한다면, 우리는 64비트 숫자를 써야 할 것이네. 하지만 우리 유저들은 그동안 더 많은 가스를 소모해야 하겠지. 설계를 보고 결정을 해야 하네!

솔리디티는 또한 seconds, minutes, hours, days, weeks, years 같은 시간 단위 또한 포함하고 있다네. 이들은 그에 해당하는 길이 만큼의 초 단위 uint 숫자로 변환되네. 즉 1 minutes는 60, 1 hours는 3600(60초 x 60 분), 1 days는 86400(24시간 x 60분 x 60초) 같이 변환되네.

이 시간 단위들이 유용하게 사용될 수 있는 예시는 다음과 같네:

 

uint lastUpdated;

// `lastUpdated`를 `now`로 설정
function updateTimestamp() public {
  lastUpdated = now;
}

// 마지막으로 `updateTimestamp`가 호출된 뒤 5분이 지났으면 `true`를, 5분이 아직 지나지 않았으면 `false`를 반환
function fiveMinutesHavePassed() public view returns (bool) {
  return (now >= (lastUpdated + 5 minutes));
}

 

우리는 이런 시간 단위들은 좀비의 cooldown 기능을 추가할 때 사용할 것이네.

직접 해보기

우리 DApp에 재사용 대기 시간을 추가하고, 좀비들이 공격하거나 먹이를 먹은 후 1일이 지나야만 다시 공격할 수 있도록 할 것이네.

  1. cooldownTime이라는 uint 변수를 선언하고, 여기에 1 days를 대입하게.(문법적으로 이상하게 보여도 넘어가게. 자네가 "1 day"를 대입한다면, 컴파일이 되지 않을 것일세!)

  2. 우리가 이전 챕터에서 우리의 Zombie 구조체에 level과 readyTime을 추가했으니, 우린 Zombie구조체를 생성할 때 함수의 인수 개수가 정확히 맞도록 _createZombie() 함수를 업데이트해야 하네.

    코드의 zombies.push 줄에 2개의 인수를 더 사용하도록 업데이트하게: 1(level에 사용), uint32(now + cooldownTime)(readyTime에 사용).

참고: now가 기본적으로 uint256을 반환하기 때문에, uint32(...) 부분이 필수적이네. 이렇게 함으로써 해당 데이터를 uint32로 명시적으로 변환하는 것이지.

now + cooldownTime은 현재 유닉스 타임스탬프(초 단위)에 1일을 초 단위로 바꾼 것의 합과 같을 것이네. 바꿔 말해 지금부터 하루 뒤의 유닉스 타임스탬프 값과 같은 것이지. 이후에 우리는 좀비를 다시 사용하기 위해 충분한 시간이 지났는지 확인할 수 있도록 좀비의 readyTime이 now보다 큰지 비교할 것이네.

 

챕터 6: 좀비 재사용 대기 시간

이제 Zombie 구조체에 readyTime 속성을 가지고 있으니, zombiefeeding.sol로 들어가서 재사용 대기 시간 타이머를 구현해보도록 하지.

우린 feedAndMultiply를 다음과 같이 수정할 것이네:

  1. 먹이를 먹으면 좀비가 재사용 대기에 들어가고,

  2. 좀비는 재사용 대기 시간이 지날 때까지 고양이들을 먹을 수 없네.

이렇게 하면 좀비들이 끊임없이 고양이들을 먹고 온종일 증식하는 것을 막을 수 있지. 나중에 우리가 전투 기능을 추가하면, 다른 좀비들을 공격하는 것도 재사용 대기 시간에 걸리도록 할 것이네.

먼저, 우리가 좀비의 readyTime을 설정하고 확인할 수 있도록 해주는 헬퍼 함수를 정의할 것이네.

구조체를 인수로 전달하기

자네는 private 또는 internal 함수에 인수로서 구조체의 storage 포인터를 전달할 수 있네. 이건 예를 들어 함수들 간에 우리의 Zombie 구조체를 주고받을 때 유용하네.

문법은 이와 같이 생겼네:

function _doStuff(Zombie storage _zombie) internal {
  // _zombie로 할 수 있는 것들을 처리
}

이런 방식으로 우리는 함수에 좀비 ID를 전달하고 좀비를 찾는 대신, 우리의 좀비에 대한 참조를 전달할 수 있네.

직접 해보기

  1. _triggerCooldown을 정의하면서 시작하지. 이 함수는 1개의 인수로 Zombie storage 포인터 타입인 _zombie를 받네. 이 함수는 internal이어야 하네.

  2. 함수의 내용에서는 _zombie.readyTime을 uint32(now + cooldownTime)으로 설정해야 하네.

  3. 다음으로, _isReady라고 불리는 함수를 만들게. 이 함수 역시 _zombie라는 이름의 Zombie storage 타입 인수를 받네. internal view여야 하고, bool을 리턴해야 하네.

  4. 함수의 내용에서는 (_zombie.readyTime <= now)를 리턴해야 하고, 이는 true 아니면 false로 계산될 것이네. 이 함수는 우리에게 좀비가 먹이를 먹은 후 충분한 시간이 지났는지 알려줄 것이네.

 

챕터 7: Public 함수 & 보안

이제 feedAndMultiply를 우리의 재사용 대기 시간 타이머를 고려하도록 수정해보게.

이 함수를 다시 살펴보면, 우리가 이전 레슨에서 이 함수를 public으로 만들었던 것을 볼 수 있을 것이네. 보안을 점검하는 좋은 방법은 자네의 모든 public과 external 함수를 검사하고, 사용자들이 그 함수들을 남용할 수 있는 방법을 생각해보는 것이네. 이걸 기억하시게 - 이 함수들이 onlyOwner 같은 제어자를 갖지 않는 이상, 어떤 사용자든 이 함수들을 호출하고 자신들이 원하는 모든 데이터를 함수에 전달할 수 있네.

위의 함수를 다시 살펴보면, 사용자들은 이 함수를 직접적으로 호출할 수 있고 그들이 원하는 아무 _targetDna나 _species를 전달할 수 있네. 이건 정말 게임 같지는 않군 - 우리는 그들이 우리의 규칙을 따르길 바라네!

좀 더 자세히 들여보면, 이 함수는 오직 feedOnKitty()에 의해서만 호출이 될 필요가 있네. 그러니 이런 남용을 막을 가장 쉬운 방법은 이 함수를 internal로 만드는 것이지.

직접 해보기

  1. 현재 feedAndMultiply는 public 함수이네. 이걸 internal로 만들어서 컨트랙트가 더 안전해지도록 하세. 우리는 사용자들이 그들이 원하는 아무 DNA나 넣어서 이 함수를 실행하는 것을 원하지 않네.

  2. feedAndMultiply 함수가 cooldownTime을 고려하도록 만들어보세. 먼저, myZombie를 찾은 후에, _isReady()를 확인하는 require 문장을 추가하고 거기에 myZombie를 전달하게. 이렇게 하면 사용자들은 좀비의 재사용 대기 시간이 끝난 다음에만 이 함수를 실행할 수 있네.

  3. 함수의 끝에서 _triggerCooldown(myZombie) 함수를 호출하여 먹이를 먹는 것이 좀비의 재사용 대기 시간을 만들도록 하게.

챕터 8: 함수 제어자의 또 다른 특징

훌륭하네! 우리 좀비가 이제 재사용 대기 시간 타이머를 가지게 되었군.

다음으로, 우리는 추가적인 헬퍼 메소드를 좀 더 추가할 것이네. 자네를 위해 zombiehelper.sol이라는, zombiefeeding.sol을 import하는 새로운 파일을 추가해뒀네. 이렇게 하면 우리의 코드가 잘 정리된 상태를 유지할 수 있을 것이네.

이제 좀비들이 특정 레벨에 도달하면 특별한 능력들을 얻을 수 있도록 만들 것이네. 하지만 그렇게 하기 위해선, 먼저 함수 제어자에 대해 조금 더 배울 필요가 있네.

인수를 가지는 함수 제어자

이전에는 onlyOwner라는 간단한 예시를 살펴보았네. 하지만 함수 제어자는 사실 인수 또한 받을 수 있네. 예를 들면:

 

// 사용자의 나이를 저장하기 위한 매핑
mapping (uint => uint) public age;

// 사용자가 특정 나이 이상인지 확인하는 제어자
modifier olderThan(uint _age, uint _userId) {
  require (age[_userId] >= _age);
  _;
}

// 차를 운전하기 위햐서는 16살 이상이어야 하네(적어도 미국에서는).
// `olderThan` 제어자를 인수와 함께 호출하려면 이렇게 하면 되네:
function driveCar(uint _userId) public olderThan(16, _userId) {
  // 필요한 함수 내용들
}

여기서 자네는 olderthan 제어자가 함수와 비슷하게 인수를 받는 것을 볼 수 있을 것이네. 그리고 driveCar 함수는 받은 인수를 제어자로 전달하고 있지.

이제 특별한 능력에 제한을 걸 수 있도록 좀비의 level 속성을 사용하는 우리만의 modifier를 만들어보세.

직접 해보기

  1. ZombieHelper에서, aboveLevel이라는 이름의 modifier를 만들게. 이 제어자는 _level(uint), _zombieId(uint) 두 개의 인수를 받을 것이네.

  2. 함수 내용에서는 zombies[_zombieId].level이 _level 이상인지 확실하게 확인해야 하네.

  3. 함수의 나머지 내용을 실행할 수 있도록 제어자의 마지막 줄에 _;를 넣는 것을 잊지 말게.

챕터 9: 좀비 제어자

이제 몇몇 함수를 만들 때 우리의 aboveLevel 제어자를 사용해보세.

우리 게임에서는 사용자들이 그들의 좀비를 레벨업할 때 인센티브를 줄 것이네.

  • 레벨 2 이상인 좀비인 경우, 사용자들은 그 좀비의 이름을 바꿀 수 있네.
  • 레벨 20 이상인 좀비인 경우, 사용자들은 그 좀비에게 임의의 DNA를 줄 수 있네.

이 함수들을 아래에 구현할 것이네. 참고로 하기 위해 이전 레슨에서 본 예제 코드를 주겠네.

 

// 사용자의 나이를 저장하기 위한 매핑
mapping (uint => uint) public age;

// 사용자가 특정 나이 이상인지 확인하는 제어자
modifier olderThan(uint _age, uint _userId) {
  require (age[_userId] >= _age);
  _;
}

// 차를 운전하기 위햐서는 16살 이상이어야 하네(적어도 미국에서는).
function driveCar(uint _userId) public olderThan(16, _userId) {
  // 필요한 함수 내용들
}

 

직접 해보기

  1. changeName이라는 함수를 만들게. 이 함수는 2개의 인수를 받을 것이네: _zombieId(uint), _newName(string). 그리고 함수를 external로 만들게. 이 함수는 aboveLevel 제어자를 가져야 하고, _level에 2라는 값을 전달해야 하네. _zombieId 또한 전달하는 것을 잊지 말게나.

  2. 함수의 내용에서는, 먼저 우리는 msg.sender가 zombieToOwner[_zombieId]와 같은지 검증해야 하네. require 문장을 사용하게.

  3. 그리고 나서 이 함수에서는 zombies[_zombieId].name에 _newName을 대입해야 하네.

  4. changeName 아래에 changeDna라는 또다른 함수를 만들게. 그리고 함수를 external로 만들게. 이 함수의 정의와 내용은 changeName과 거의 똑같지만, 두 번째 인수가 _newDna(uint)이고, aboveLevel의 _level 매개 변수에 20을 전달해야 할 것이네. 물론, 이 함수는 좀비의 이름을 설정하는 것 대신에 좀비의 dna를 _newDna로 설정해야 하겠지.

챕터 10: 'View' 함수를 사용해 가스 절약하기

엄청나군! 우리는 이제 더 높은 레벨의 좀비들에게 특별한 능력을 주었고, 이건 좀비 주인들이 그들의 좀비를 열심히 키우도록 장치가 될 것이네. 우리가 원한다면 이런 것들을 나중에 더 많이 추가할 수도 있지.

함수 하나를 더 만들어보세: 우리의 DApp은 사용자의 전체 좀비 군대를 볼 수 있는 메소드가 필요하네 - 이 메소드는 getZombiesByOwner라고 할 것이네.

이 함수는 블록체인에서 데이터를 읽기만 하면 되네. 그러니 우리는 이걸 view 함수로 만들 수 있지. 이 부분은 가스 최적화를 말할 때 가장 중요한 내용이기도 하네.

View 함수는 가스를 소모하지 않네

view 함수는 사용자에 의해 외부에서 호출되었을 때 가스를 전혀 소모하지 않네.

이건 view 함수가 블록체인 상에서 실제로 어떤 것도 수정하지 않기 떄문이네 - 데이터를 읽기만 하지. 그러니 함수에 view 표시를 하는 것은 web3.js에 이렇게 말하는 것과 같네. "이 함수는 실행할 때 자네 로컬 이더리움 노드에 질의만 날리면 되고, 블록체인에 어떤 트랜잭션도 만들지 않아"(트랜잭션은 모든 개별 노드에서 실행되어야 하고, 가스를 소모하네).

자네의 고유 노드로 web3.js를 설정하는 것은 나중에 다룰 것이네. 지금은 자네가 사용자들을 위해 DApp의 가스 사용을 최적화하는 비결은 가능한 모든 곳에 읽기 전용의 external view 함수를 쓰는 것이라는 것만 명심해두게.

참고: 만약 view 함수가 동일 컨트랙트 내에 있는, view 함수가 아닌 다른 함수에서 내부적으로 호출될 경우, 여전히 가스를 소모할 것이네. 이것은 다른 함수가 이더리움에 트랜잭션을 생성하고, 이는 모든 개별 노드에서 검증되어야 하기 때문이네. 그러니 view 함수는 외부에서 호출됐을 때에만 무료라네.

직접 해보기

우리는 사용자의 전체 좀비 군대를 반환하는 함수를 구현할 것이네. 우리가 만약 사용자들의 프로필 페이지에 그들의 전체 군대를 표시하고 싶다면, 나중에 이 함수를 web3.js에서 호출하면 된다네.

이 함수의 내용은 조금 복잡해서, 구현하는 데에 챕터 몇 개를 써야 할 것이네.

  1. getZombiesByOwner라는 이름의 함수를 만들게. 이 함수는 _owner라는 이름의 address를 하나의 인수로 받을 것이네.

  2. 이걸 external view 함수로 만들게. 우리는 이 함수를 web3.js에서 가스를 쓸 필요 없이 호출할 수 있을 것이네.

  3. 이 함수는 uint[]를 반환해야 하네(uint의 배열).

지금은 함수의 내용을 비워두게. 다음 챕터에서 채워나갈 것이네.

 

 

챕터 11: Storage는 비싸다

솔리디티에서 더 비싼 연산 중 하나는 바로 storage를 쓰는 것이네 - 그중에서도 쓰기 연산이지.

이건 자네가 데이터의 일부를 쓰거나 바꿀 때마다, 블록체인에 영구적으로 기록되기 때문이네. 영원히! 지구상의 수천 개의 노드들이 그들의 하드 드라이브에 그 데이터를 저장해야 하고, 블록체인이 커져가면서 이 데이터의 양 또한 같이 커져가네. 그러니 이 연산에는 비용이 들지.

비용을 최소화하기 위해서, 진짜 필요한 경우가 아니면 storage에 데이터를 쓰지 않는 것이 좋네. 이를 위해 때때로는 겉보기에 비효율적으로 보이는 프로그래밍 구성을 할 필요가 있네 - 어떤 배열에서 내용을 빠르게 찾기 위해, 단순히 변수에 저장하는 것 대신 함수가 호출될 때마다 배열을 memory에 다시 만드는 것처럼 말이지.

대부분의 프로그래밍 언어에서는, 큰 데이터 집합의 개별 데이터에 모두 접근하는 것은 비용이 비싸네. 하지만 솔리디티에서는 그 접근이 external view 함수라면 storage를 사용하는 것보다 더 저렴한 방법이네. view 함수는 사용자들의 가스를 소모하지 않기 때문이지(가스는 사용자들이 진짜 돈을 쓰는 것이네!).

우리는 다음 챕터에서 for 반복문을 알아볼 것이지만, 먼저 메모리에 배열을 선언하는 방법을 알아보도록 하지.

메모리에 배열 선언하기

Storage에 아무것도 쓰지 않고도 함수 안에 새로운 배열을 만들려면 배열에 memory 키워드를 쓰면 되네. 이 배열은 함수가 끝날 때까지만 존재할 것이고, 이는 storage의 배열을 직접 업데이트하는 것보다 가스 소모 측면에서 훨씬 저렴하네 - 외부에서 호출되는 view 함수라면 무료이지.

메모리에 배열을 선언하는 방법은 다음과 같네:

 

function getArray() external pure returns(uint[]) {
  // 메모리에 길이 3의 새로운 배열을 생성한다.
  uint[] memory values = new uint[](3);
  // 여기에 특정한 값들을 넣는다.
  values.push(1);
  values.push(2);
  values.push(3);
  // 해당 배열을 반환한다.
  return values;
}

 

이건 자네에게 문법을 보여주기 위한 그저 간단한 예시에 불과하네만, 다음 챕터에서는 실제 사용에 쓸 수 있도록 for 반복문과 이것을 결합하는 방법을 알아볼 것이네.

참고: 메모리 배열은 반드시 길이 인수와 함께 생성되어야 하네(이 예시에서는, 3). 메모리 배열은 현재로서는 storage 배열처럼 array.push()로 크기가 조절되지는 않네. 이후 버전의 솔리디티에서는 변경될 수도 있겠지만 말이야.

직접 해보기

getZombiesByOwner 함수에서, 우리는 특정한 사용자가 소유한 모든 좀비를 uint[] 배열로 반환하기를 원하네.

  1. result라는 이름의 uint[] memory 변수를 선언하게.

  2. 해당 변수에 uint 배열을 대입하게. 배열의 길이는 이 _owner가 소유한 좀비의 개수여야 하고, 이는 우리의 mapping인 ownerZombieCount[_owner]를 통해서 찾을 수 있네.

  3. 함수의 끝에서 result를 반환하게. 지금 당장은 빈 배열이지만, 다음 챕터에서 이를 채울 것이네.

챕터 12: For 반복문

이전 챕터에서, 때때로 자네가 함수 내에서 배열을 다룰 때, 그냥 storage에 해당 배열을 저장하는 것이 아니라 for 반복문을 사용해서 구성해야 할 때가 있을 것이라 했었네.

왜 그런지 살펴보세.

getZombiesByOwner를 구현할 때, 기초적인 구현 방법은 ZombieFactory 컨트랙트에서 소유자의 좀비 군대에 대한 mapping을 만들어 저장하는 것일 걸세.

mapping (address => uint[]) public ownerToZombies

그리고나서 새로운 좀비를 만들 때마다, 해당 소유자의 좀비 배열에 ownerToZombies[owner].push(zombieId)를 사용해서 새 좀비를 추가하겠지. getZombiesByOwner 함수는 굉장히 이해하기 쉬운 함수가 될 게야:

 

function getZombiesByOwner(address _owner) external view returns (uint[]) {
  return ownerToZombies[_owner];
}

이 방식의 문제

이러한 접근 방법은 구현의 간단함 때문에 매력적으로 보이지. 하지만 만약 나중에 한 좀비를 원래 소유자에서 다른 사람에게 전달하는 함수를 구현하게 된다면 어떤 일이 일어날지 생각해보세(이후의 레슨에서 우린 분명 이 기능을 원하게 될 것일세).

좀비 전달 함수는 이런 내용이 필요할 것이네:

  1. 전달할 좀비를 새로운 소유자의 ownerToZombies 배열에 넣는다.
  2. 기존 소유자의 ownerToZombies 배열에서 해당 좀비를 지운다.
  3. 좀비가 지워진 구멍을 메우기 위해 기존 소유자의 배열에서 모든 좀비를 한 칸씩 움직인다.
  4. 배열의 길이를 1 줄인다.

3번째 단계는 극단적으로 가스 소모가 많을 것이네. 왜냐하면 위치를 바꾼 모든 좀비에 대해 쓰기 연산을 해야 하기 때문이지. 소유자가 20마리의 좀비를 가지고 있고 첫 번째 좀비를 거래한다면, 배열의 순서를 유지하기 위해 우린 19번의 쓰기를 해야 할 것이네.

솔리디티에서 storage에 쓰는 것은 가장 비용이 높은 연산 중 하나이기 때문에, 이 전달 함수에 대한 모든 호출은 가스 측면에서 굉장히 비싸게 될 것이네. 더 안 좋은 점은, 이 함수가 실행될 때마다 다른 양의 가스를 소모할 것이라는 점이네. 사용자가 자신의 군대에 얼마나 많은 좀비를 가지고 있는지, 또 거래되는 좀비의 인덱스에 따라 달라지겠지. 즉 사용자들은 거래에 가스를 얼마나 쓰게 될지 알 수 없게 되네.

참고: 물론, 빈 자리를 채우기 위해 마지막 좀비를 움직인 다음, 배열의 길이를 하나 줄여도 되겠지. 하지만 그렇게 하면 교환이 일어날 때마다 좀비 군대의 순서가 바뀌게 될 것이네.

view 함수는 외부에서 호출될 때 가스를 사용하지 않기 때문에, 우린 getZombiesByOwner 함수에서 for 반복문을 사용해서 좀비 배열의 모든 요소에 접근한 후 특정 사용자의 좀비들로 구성된 배열을 만들 수 있을 것이네. 그러고 나면 transfer 함수는 훨씬 비용을 적게 쓰게 되겠지. 왜냐하면 storage에서 어떤 배열도 재정렬할 필요가 없으니까 말이야. 일반적인 직관과는 반대로 이런 접근법이 전체적으로 비용 소모가 더 적네.

for 반복문 사용하기

솔리디티에서 for 반복문의 문법은 자바스크립트의 문법과 비슷하네.

짝수로 구성된 배열을 만드는 예시를 한번 보세:

 

function getEvens() pure external returns(uint[]) {
  uint[] memory evens = new uint[](5);
  // 새로운 배열의 인덱스를 추적하는 변수
  uint counter = 0;
  // for 반복문에서 1부터 10까지 반복함
  for (uint i = 1; i <= 10; i++) {
    // `i`가 짝수라면...
    if (i % 2 == 0) {
      // 배열에 i를 추가함
      evens[counter] = i;
      // `evens`의 다음 빈 인덱스 값으로 counter를 증가시킴
      counter++;
    }
  }
  return evens;
}

 

이 함수는 [2, 4, 6, 8, 10]를 가지는 배열을 반환할 것이네.

직접 해보기

for 반복문을 써서 getZombiesByOwner 함수를 끝내보도록 하지. 반복문 안에서는 우리 DApp 안에 있는 모든 좀비들에 접근하고, 그들의 소유자가 우리가 찾는 자인지 비교하여 확인한 후, 조건에 맞는 좀비들을 result 배열에 추가한 후 반환할 것이네.

  1. counter라는 이름의 uint를 하나 선언하고 0을 대입하게. 우린 result 배열에서 인덱스를 추적하기 위해 이 변수를 사용할 것이네.

  2. uint i = 0에서 시작해서 i < zombies.length까지 증가하는 for 반복문을 선언하게. 이 반복문에서 우리 배열의 모든 좀비에 접근할 것이네.

  3. for 반복문 안에서, zombieToOwner[i]가 _owner와 같은지 확인하는 if 문장을 만들게. 이 문장은 두 개의 주소값이 같은지 비교하는 것이네.

  4. if 문장 안에서:

    1. result[counter]에 i를 대입해서 result 배열에 좀비의 ID를 추가하게.
    2. counter를 1 증가시키게(위의 for 반복문 예시를 참고하게).

이게 끝이라네 - 이 함수는 이제 _owner가 소유한 모든 좀비를 가스를 소모하지 않고 반환하게 될 것이네.

 

 

zombiehelper.sol

pragma solidity ^0.4.19;

import "./zombiefeeding.sol";

contract ZombieHelper is ZombieFeeding {

  modifier aboveLevel(uint _level, uint _zombieId) {
    require(zombies[_zombieId].level >= _level);
    _;
  }

  function changeName(uint _zombieId, string _newName) external aboveLevel(2, _zombieId) {
    require(msg.sender == zombieToOwner[_zombieId]);
    zombies[_zombieId].name = _newName;
  }

  function changeDna(uint _zombieId, uint _newDna) external aboveLevel(20, _zombieId) {
    require(msg.sender == zombieToOwner[_zombieId]);
    zombies[_zombieId].dna = _newDna;
  }

  function getZombiesByOwner(address _owner) external view returns(uint[]) {
    uint[] memory result = new uint[](ownerZombieCount[_owner]);
    uint counter = 0;
    for (uint i = 0; i < zombies.length; i++) {
      if (zombieToOwner[i] == _owner) {
        result[counter] = i;
        counter++;
      }
    }
    return result;
  }

}

 

zombiefeeding.sol

pragma solidity ^0.4.19;

import "./zombiefactory.sol";

contract KittyInterface {
  function getKitty(uint256 _id) external view returns (
    bool isGestating,
    bool isReady,
    uint256 cooldownIndex,
    uint256 nextActionAt,
    uint256 siringWithId,
    uint256 birthTime,
    uint256 matronId,
    uint256 sireId,
    uint256 generation,
    uint256 genes
  );
}

contract ZombieFeeding is ZombieFactory {

  KittyInterface kittyContract;

  function setKittyContractAddress(address _address) external onlyOwner {
    kittyContract = KittyInterface(_address);
  }

  function feedAndMultiply(uint _zombieId, uint _targetDna, string _species) public {
    require(msg.sender == zombieToOwner[_zombieId]);
    Zombie storage myZombie = zombies[_zombieId];
    _targetDna = _targetDna % dnaModulus;
    uint newDna = (myZombie.dna + _targetDna) / 2;
    if (keccak256(_species) == keccak256("kitty")) {
      newDna = newDna - newDna % 100 + 99;
    }
    _createZombie("NoName", newDna);
  }

  function feedOnKitty(uint _zombieId, uint _kittyId) public {
    uint kittyDna;
    (,,,,,,,,,kittyDna) = kittyContract.getKitty(_kittyId);
    feedAndMultiply(_zombieId, kittyDna, "kitty");
  }

}

 

zombiefactory.sol

pragma solidity ^0.4.19;

import "./ownable.sol";

contract ZombieFactory is Ownable {

    event NewZombie(uint zombieId, string name, uint dna);

    uint dnaDigits = 16;
    uint dnaModulus = 10 ** dnaDigits;
    uint cooldownTime = 1 days;

    struct Zombie {
      string name;
      uint dna;
      uint32 level;
      uint32 readyTime;
    }

    Zombie[] public zombies;

    mapping (uint => address) public zombieToOwner;
    mapping (address => uint) ownerZombieCount;

    function _createZombie(string _name, uint _dna) internal {
        uint id = zombies.push(Zombie(_name, _dna, 1, uint32(now + cooldownTime))) - 1;
        zombieToOwner[id] = msg.sender;
        ownerZombieCount[msg.sender]++;
        NewZombie(id, _name, _dna);
    }

    function _generateRandomDna(string _str) private view returns (uint) {
        uint rand = uint(keccak256(_str));
        return rand % dnaModulus;
    }

    function createRandomZombie(string _name) public {
        require(ownerZombieCount[msg.sender] == 0);
        uint randDna = _generateRandomDna(_name);
        randDna = randDna - randDna % 100;
        _createZombie(_name, randDna);
    }

}

 

 

ownable.sol

/**
 * @title Ownable
 * @dev The Ownable contract has an owner address, and provides basic authorization control
 * functions, this simplifies the implementation of "user permissions".
 */
contract Ownable {
  address public owner;

  event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

  /**
   * @dev The Ownable constructor sets the original `owner` of the contract to the sender
   * account.
   */
  function Ownable() public {
    owner = msg.sender;
  }


  /**
   * @dev Throws if called by any account other than the owner.
   */
  modifier onlyOwner() {
    require(msg.sender == owner);
    _;
  }


  /**
   * @dev Allows the current owner to transfer control of the contract to a newOwner.
   * @param newOwner The address to transfer ownership to.
   */
  function transferOwnership(address newOwner) public onlyOwner {
    require(newOwner != address(0));
    OwnershipTransferred(owner, newOwner);
    owner = newOwner;
  }

}
출처: https://mingos-habitat.tistory.com/34 [밍고의서식지:티스토리]