본문 바로가기
Ethereum/Solidity

CrytoZombies_Lesson3_고급 솔리디티 개념

by rooney-l3 2019. 8. 16.

1. 컨트랙트의 불변성 및 외부 의존성

 (1)불변성

 이더리움 컨트랙트를 배포한 이후에는 컨트랙트는 변경하거나 업데이트 할 수 없습니다. 최초 배포한 코드는 이더리움 블록체인에 영구적으로 존재하게 됩니다. 만약 컨트랙트 코드에 결함이 있는채로 배포했다면 이를 고칠수 있는 방법이 전혀 없게 됩니다. 이것이 바로 솔리디티에 있어서 보안이 굉장히 큰 이슈인 이유입니다. 그러나 이것 또한 스마트 컨트랙트의 한 특징입니다. 누군가가 스마트 컨트랙트 함수를 호출할 때마다, 코드에 쓰여진 그대로 함수가 실행될것이라고 확실 할 수 있습니다. 그 누구도 배포 이후에 함수를 수정하거나 예상치 못한 결과를 발생시키지 못하기 때문입니다.

 

 (2)외부 의존성

 우리가 이전에 크립토키디 컨트랙트의 주소를 Dapp에 직접써넣었습니다. 만약 크립토키티 컨트랙트에 결함이 있었고 누군가가 이를 이용하여 모든 고양이들을 파괴해버렸다면 어떻게 될까요? 만약 그런일이 발생한다면 우리가 만든 Dapp은 쓸모가 없어질 것입니다. 어떠한 고양이도 받아올 수 없게되기 때문입니다. 앞서 설명한 불변성때문에 우리는 우리의 컨트랙트를 수정할 수도 없을 것입니다. 그렇기 때문에 우리는 크립토키티 컨트랙트의 주소를 직접 써넣는 대신 언젠가 크립토 키티 컨트랙트에 문제가 생기면 해당 주소를 바꿀 수 있도록 해주는 setKittyContractAddress 함수 만드는 것이 더 나은 방법이 될 것 입니다. 

 

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 {
    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");
  }

}

 

2. 소유 가능한 컨트랙트

 

위에서 수행한 setKittyContractAddress 함수는 external이라, 누구든 이 함수를 호출할 수 있습니다. 따라서 누구든 이 함수를 호출해서 크립트키티 컨트랙트의 주소를 바꿀 수 있습니다. 우리는 우리 컨트랙트에서 이 주소를 바꾸고 싶지만 , 그렇다고 누구나 이 주소를 바꾸게 하고 싶지는 않습니다. 이런 경우에 최근 주로 쓰이는 하나의 방법은 컨트랙트를 소유가능하게 만드는것입니다. 

 

OpenZeppelin의 Ownable 컨트랙트

OpenZeppelin은 자네의 DApp에서 사용할 수 있는, 안전하고 커뮤니티에서 검증받은 스마트 컨트랙트입니다. 

  • 생성자(Constructor): function Ownable()는 생성자입니다. 컨트랙트와 동일한 이름을 가진,생략할 수 있는 특별한 함수입니다. 이 함수는 컨트랙트가 생성될 때 딱 한 번만 실행되게 됩니다.
  • 함수 제어자(Function Modifier): modifier onlyOwner(). 제어자는 다른 함수들에 대한 접근을 제어하기 위해 사용되는 일종의 유사 함수입니다. 보통 함수 실행 전의 요구사항 충족 여부를 확인하는 데에 사용하게 됩니다. onlyOwner의 경우에는 접근을 제한해서 오직 컨트랙트의 소유자만 해당 함수를 실행할 수 있도록 하기 위해 사용될 수 있습니다.
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;

    struct Zombie {
        string name;
        uint dna;
    }

    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;
        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);
    }

}

 OpenZeppelin 솔리디티 라이브러리에서 가져온 Ownable 컨트랙트를 사용하기 위해 import를 해주었습니다. 그리고 ZombieFactory가 이걸 상속받도록 하였습니다. 이제 우리는 Ownable 컨트랙트사용 할 수 있게 되었습니다.

 

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");
  }

}

setKittyContractAddress에 onlyOwner 제어자를 추가하였습니다. 이제 msg.sender == owner 인경우에만 해당 함수를 호출하여 주소를 바꿀 수 있게됩니다.

 

3. gas(가스) - 이더리움 DApp이 사용하는 연료

  솔리디티에서는 사용자들이 우리가 만든 DApp의 함수를 실행 할 때마다 '가스'라고 불리는 화폐를 지불해야 합니다. 사용자는 이더(ETH)를 이용해서 가스를 사야하기 때문에, 결과적으로 우리가 만든 DApp을 사용하기 위해서는 사용자들은 ETH를 소모해야합니다. 

 

  함수를 실행하는 데에 얼마나 많은 가스가 필요한지는 그 함수의 로직(논리구조)이 얼마나 복잡한지에 따라 달라지게 됩니다. 각각의 연산은 소모되는 가스 비용 (gas cost)이 있고, 그 연산을 수행할 때 소모되는 컴퓨팅 자원의 양이 이 비용을 결정하게 됩니다. 우리의 함수 전체 가스 비용은 그 함수를 구성하는 개별 연산들의 가스 비용을 모두 합친 것과 같습니다. 함수를 실행하는 것은 사용자들에게 실제 돈을 쓰게 하기 때문에, 이더리움에서 코드 최적화는 다른 프로그래밍 언어들에 비해 훨씬 더 중요합니다. 

 

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

 

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

 

  가스를 아끼기 위해서는 구조체를 압출하는 방법을 사용 할 수 있습니다. 우리는 Lesson1에서 uint에 다른 타입들이 있따는 것을 배웠습니다. 기본적으로  uint256대신 unit32를 사용한다고 하여 가스가 덜 소모되는것은 아닙니다. 그 이유는 솔리디티에서는 uint의 타입에 상관없이 256의 저장공간을 미리 확보해놓기 때문입니다. 하지만 구조체 안에서라면 그 얘기가 달라지게 됩니다. 

struct NormalStruct {

uint a;

uint b;

uint c;

}

 

struct MiniMe {

uint32 a;

uint32 b;

uint c;

}

 

MiniMe는 NormalStruct보다 더 적은 공간을 차지하게 될것입니다. 이러한 이유로 구조체 안에서는 가능한 작은 크기의 정수 타입을 쓰는것이 가스의 소모량을 줄일 수 있습니다. 

 

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;

    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;
        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);
    }

}

 

zombiefactory.sol내의 Zombie 구조체에 level(uint32)과 readyTime(uint32)를 추가하였습니다. 좀비의 레벨과 시간 데이터를 저장하는데에는 32bit로도 충분하므로 256bit를 쓰는것보다 가스 비용을 줄일 수 있을 것입니다.  

 

6. 시간단위

솔리디티는 시간을 다룰 수 있는 단위계를 기본적으로 제공합니다. now 변수를 쓰면 현재 유닉스 타임스탬프(1970년 1월 1일부터 지금까지의 초 단위 합) 값을 얻을 수 있습니다. 또한 솔리디티는seconds, minutes, hours, days, weeks, years 같은 시간 단위를 포함하고 있습니다. 이들은 그에 해당하는 길이만큼 초 단위 uint 숫자로 변환되게 됩니다. 

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);
    }

}
  

cooldownTime이라는 uint 변수를 선언하고, 여기에 1 days를 대입했습니다. 또한 우리가 이전 챕터에서 우리의 Zombie구조체에 level readyTime을 추가했으니, 우린 Zombie 구조체를 생성할 때 함수의 인수 개수가 정확해지도록 1(level에 사용), uint32(now + cooldownTime)(readyTime에 사용)을 추가했습니다. 

 

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 _triggerCooldown(Zombie storage _zombie) internal {
    _zombie.readyTime = uint32(now + cooldownTime);
  }

  function _isReady(Zombie storage _zombie) internal view returns(bool){
    return (_zombie.readyTime <= now);
  }

  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");
  }

}

Zombie 구조체에 readyTime 속성을 가지고 zombiefeeding.sol로 들어가서 재사용 대기 시간 타이머를 구현했습니다.

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

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 _triggerCooldown(Zombie storage _zombie) internal {
    _zombie.readyTime = uint32(now + cooldownTime);
  }

  function _isReady(Zombie storage _zombie) internal view returns (bool) {
      return (_zombie.readyTime <= now);
  }

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

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

}

myZombie구조체를 이용하여 _isReady함수를 실행하여 return 값이 ture 경우에만 해당 함수가 실행되도록 하였습니다.

함수의 끝에서 triggerCooldown함수를 실행하여 myZombie 구조체의 readyTime 값이 현재시간으로부터 1일이 더해지도록 했습니다. 

 

7. 인수를 갖는 함수제어자

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;
  }

}

 

  ZombieFeeding을 상속하는 ZombieHelper 컨트랙트를 새로 만들었습니다.  이전에 배운 함수 제어자로는 onlyOwner가 있습니다. 이 함수 제어자를 사용할때는 우리는 함수 정의부분 맨끝에 onlyOwner()라고만 써주면 함수를 제어 할 수 있었습니다. 

  또다른 방법으로 함수 제어자에 인수를 넣어 함수를 제어하는 방법이 있습니다. 위의 코드에서 aboveLevel이라는 이름의  modifier를 만들었습니다. 이는 일정 레벨 이상인경우에만 함수를 실행 할 수 있는 함수 제어자의 역할을 정한것이라고 보면 됩니다. 아래의 changeName 함수의 정의부분 끝에 abobLevel(2,_zombieId)라고 설정해 줬습니다. 해당 함수를 실행 하려면 zombies 구조체 배열의 level이 우리가 설정한 '2'이상인 경우여야 합니다. 즉 사용자는 좀비의 레벨이 2 이상인 경우에만 좀비의 이름을 변경할 수 있습니다. 또한 함수안에 require을 설정하여 함수를 호출한 사람이 좀비의 주인이여야하는 조건도 생성하였습니다. 

  정리하자면 좀비의 이름을 바꾸려면 2가지 조건을 충족해야합니다. 첫번째 조건은 좀비의 레벨이 2이상일것 두 번째 조건은 함수호출자가 좀비의 주인인것입니다. 

 

8. 'View'함수를 사용해 가스 절약하기 

  view 함수는 사용자에 의해 외부에서 호출되더라도 가스를 소모하지 않습니다. 이는 view함수는 블록체인 상에서 어떤 것도 수정하지 않기 때문입니다. 즉 해당 함수는 어떠한 트랜잭션도 만들지 않습니다. 

 

 이와는 반대로 storage는 가스를 많이 소모하게 됩니다. 이는 storage 함수는 데이터의 일부를 쓰거나 바꿀 때마다, 블록체인에 영구적으로 기록되기 때문입니다. 수천 개의 노드들이 그들의 하드 드라이브에 그 데이터를 저장해야하고, 블록체인이 커져가면서 이 데이터의 양 또한 같이 커져야하으모 이러한 연산에는 비용이 드는것입니다. 

 

  어떤 배열에서 내용을 찾는 경우, 단순히 변수에 값을 저장하여 찾는것보다는 배열을 memory에 다시 만들어서 내용을 찾는것이 비용이 덜 들게 됩니다. 이는 겉보기에는 비효율적으로 보이는 프로그래밍 구성이지만 비용을 최소화 하기 위한 하나의 방법입니다.  memory 배열은 함수가 끝날 때 까지만 존재하고 컨트랙트 함수에 대한 외부 호출들이 일어나는 사이에 지워지게 됩니다. 

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;
  }

}

 

result라는 이름을 갖은 uint[] memory 변수를 선언했습니다. 해당 변수에 새로운 uint 배열을 대입했습니다. 배열의 길이는 소유한 좀비의 개수랑 같습니다. 따라서 zombiefasctory.sol에서 만들어준 mapping인 ownerZombieCount 을 사용하여 소유한 좀비의 개수를 찾았습니다. ownerZombieCount는 좀비를 생성할때마다 1씩 값이 올라가기 때문입니다.

 

counter라는 이름의 uint변수를 하나 선언하고 그 값으로 0을 넣어줬습니다. 이는 반복문에서 result배열의 인덱스 값으로 사용되게 될것입니다. 반복문은 i=0부터 i=배열의 길이 바로 전 까지 반복하도록 설정했습니다.

만약 zombieToOwner[0]의 반환값인 주소와 _onwer의 주소가 같다면 result[0]에 0을 저장 할것입니다. 이는 0이라는 id를 갖은 좀비가 내 소유라는 의미입니다. 이를 계속 반복하여 내 소유인 좀비의 id값을 result배열에 차곡차곡 저장하게 됩니다. 

 

마지막으로 함수가 return으로 result을 반환할것이므로 내가 보는 결과는 내가 소유한 좀비들 id의 집합이 될것입니다. 

 

'Ethereum > Solidity' 카테고리의 다른 글

CrytoZombies_Lesson2_심화문법  (0) 2019.08.16
CrytoZombies_Lesson1_기본문법  (0) 2019.08.16

댓글