Advanced Solidity Concepts (2)

nn·2022년 2월 16일

크립토좀비

목록 보기
5/8

솔리디티와 다른 프로그래밍 언어와의 차이점에 대해 더 알아보자.

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

솔리디티는 DApp의 함수를 실행할 때마다 가스라는 화폐를 지불해야한다.
가스는 이더를 이용해서 구매하므로 즉 이더가 있어야 DApp 함수를 실행 할 수 있다.

가스비는 함수의 로직에 따라 달라진다.
연산이 복잡하고 수행하는데 소모되는 자원이 많을 수록 가스비가 높아진다.

함수를 실행하는 것이 곧 사용자에게 돈을 쓰게 하는 것으로 이루어지기 때문에 코드 최적화는 다른 프로그래밍에 비해 더욱 중요할 수 밖에 없다.

가스는 왜 필요한가

이더리움은 느리지만 안전한 큰 컴퓨터와 같다고 할 수 있다.
어떤 함수를 실행하려고 하면 네트워크상의 모든 개별 노드가 함수를 실행해야한다.
이 개별 노드가 바로 이더리움을 분산화하고, 데이터를 보존하며 누구도 검열할 수 없게 해준다.

이더리움은 이 네트워크를 누군가가 방해하지 못하도록 연산처리에도 비용이 들게 만들었다.
(일부러 무한 반복문을 사용하거나, 자원 소모가 큰 연산을 써서 네트워크를 방해 할 수 있기 때문이다.)

그래서 이더리움은 저장 공간 뿐만 아니라 연산 사용 시간에 따라서도 비용이 들게 구성한 것이다.

가스를 아끼기 위해

가스를 아끼기 위해 unit256 보다 uint8 타입을 사용하는 것이 가스 소모를 줄이는데 영향이 있을까?

정답은 아니다.

솔리디티에서는 uint의 크기에 상관없이 256비트의 저장 공간을 미리 잡아놓기 때문이다.

다만 struct 구조체 안에서는 이야기가 다르다.

만약 구조체 안에 여러 uint 를 만든다면 가장 작은 uint 를 사용하는 것이 도움이 된다.
솔리디티는 구조체 안의 변수들은 가장 작은 공간을 차지하도록 압축하기 때문이다.

다음 예시를 보자.

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

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

NormalStruct normal = NormalStruct(10, 20, 30);
MiniMe mini = MiniMe(10, 20, 30); 

mini는 구조체 압축을 했기 때문에 normal보다 가스를 조금 사용할 것이다.

또한 동일한 데이터 타입은 하나로 묶는 것이 좋다.
구조체에서 서로 가까이 있으면 솔리디티가 사용하는 저장공간을 최소화하기때문이다.

그렇기 때문에 uint c; uint32 a; uint32 b; 라는 필드로 구성된 구조체가 uint32 a; uint c; uint32 b; 필드로 구성된 구조체보다 가스를 덜 소모한다.


위 내용을 기억하고 코드로 돌아가보자.

좀비에게 readyTimelevel 이라는 두가지 특징을 더 부여해보았다.

여기서 readyTime 은 좀비가 먹이를 먹는 빈도를 제한할 재사용 대기 시간을 구현하기 위해 사용하며
level 은 단어 그대로 좀비의 능력치를 판단하는 수치로 사용할 것이다.

그러므로 Zombie 구조체는 다음과 같이 수정하면 된다.
zombiefactory.sol

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

시간 단위(Time units)

readyTime 을 사용하기 위해 솔리디티에서는 시간을 다루는 법을 살펴보겠다.

솔리디티는 now,seconds, minutes, hours, days, weeks, years 같은 시간 단위 또한 포함하고 있다.
이들은 해당하는 길이 만큼의 초 단위 uint 숫자로 변환된다.

즉, 1 minutes는 60, 1 hours는 3600(60초 x 60 분), 1 days는 86400(24시간 x 60분 x 60초) 같이 변환된다.

또한 now 변수를 쓰면 현재의 유닉스 타임스탬프(1970년 1월 1일부터 지금까지의 초 단위 합) 값을 얻을 수 있다.

다음 예시를 보자.

uint lastUpdated;

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

function fiveMinutesHavePassed() public view returns (bool) {
  return (now >= (lastUpdated + 5 minutes));
}

마지막으로 updateTimestamp가 호출된 뒤 5분이 지났으면 true를, 5분이 아직 지나지 않았으면 false를 반환하는 것을 알 수 있다.


위 내용을 참고해서 좀비 대기시간을 추가하고, 공격하거나 먹이를 먹은지 하루가 지나야 다시 공격할 수 있도록 해보자.

zombiefactory.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 {
    
    //Zombie 구조체에 level인자와 readyTime 인자가 추가되었으므로 push 할때도 인자를 모두 채워줘야한다. 
    // 1은 level이며, readyTime은 시간을 계산해서 반환된 uint32으로 전달
        uint id = zombies.push(Zombie(_name, _dna, 1, uint32(now + cooldownTime))) - 1;
        zombieToOwner[id] = msg.sender;
        ownerZombieCount[msg.sender]++;
        NewZombie(id, _name, _dna);
    }
    ...

zombieFeeding.sol

pragma solidity ^0.4.19;

import "./zombiefactory.sol";

contract KittyInterface {
  function getKitty(uint256 _id) external view returns (
    bool isGestating,
  	...
    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);
  }

// internal로 변경
  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");
  }

}

구조체를 인수로 전달할때는 function _triggerCooldown(Zombie storage _zombie) 와 같이 사용하면 된다.

위와 같은 방식을 사용하지 않으면 함수에 좀비 Id를 전달하고, 이 키 값에 맞는 좀비를 매번 찾아야한다.

구조체를 직접 전달함으로서 좀비를 바로 참조 할 수 있다.

또한 public 으로 만들어져 있던 feedAndMultiplyinternal로 변경했다.

앞서 말했듯이 보안을 위해서는 public과 external를 남용해서는 안된다.
이 함수는 오직 feedOnKitty() 함수에 의해서만 호출되어야 하므로 internal 로 변경하는 것이 적당하다.


인수를 가지는 함수제어자

이전 포스팅에선 onlyOwner 라는 제어자를 통해 함수 실행 권한을 체크했다.

좀비들이 특정 레벨에 도달한 경우에 다음과 같은 역할을 부여해주기 위해 level 인자로 함수를 제어해보겠다.

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

zombieHelper라는 새로운 컨트랙트 파일을 만들고 ZombieFeeding을 상속했다.

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

// 좀비레벨이 2이상인 경우에만 좀비 이름을 바꿀수 있도록 함수제어자 추가
  function changeName(uint _zombieId, string _newName) external  aboveLevel(2, _zombieId) {
   
    require(msg.sender == zombieToOwner[_zombieId]);

    zombies[_zombieId].name = _newName;
  }
  
  // 좀비레벨이 20이상인 경우만 dna를 바꿀수 있도록 함수제어자 추가
  function changeDna(uint _zombieId, uint _newDna) external aboveLevel(20, _zombieId) {
    require(msg.sender == zombieToOwner[_zombieId]);

        zombies[_zombieId].dna = _newDna;

  }

}

View

전체 좀비를 보여주는 함수를 생성하겠다.

이 함수는 연산이 필요하지 않다. 단순히 블록체인에서 데이터를 읽기만 하므로 view 함수로 생성하는 것이 좋다.

view 는 가스를 소모하지 않는다.

view 를 사용하는 것은 web3.js가 실행할 때 로컬 이더리움에만 질의를 날리고, 블록체인에는 트랜잭션을 일으키지 않겠다는 뜻이다.

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


Storage

storage 는 솔리디티에서 비싼 연산에 속한다.

그 중 쓰기 연산이 제일 비싸다.
데이터를 쓰거나 바꿀때마다 모든 기록이 블록체인에 영구적으로 남기 때문에 블록체인이 커지고 테이터의 양도 늘어나기 때문이다.

그러므로 비용을 최소화하기 위해서는 storage 를 꼭 필요한 경우에만 사용하고 memory 를 잘 활용해야한다.

예를 들어 어떤 배열에서 내용을 찾으려면 내용을 storage 에 저장하는 것 대신 함수가 호출 될때마다 배열을 memory 에 새로 만들어 찾아내는 것이 효율적일 것이다.

또한 모든 프로그래밍 언어가 그렇듯이 큰 데이터의 집합에서 개별 데이터에 접근하는 것은 비용이 비싸므로 external view 함수로 접근하는 것이 storage 를 사용하는 것보다 효율적이다.

메모리에 배열 선언하기

storage 에 아무것도 쓰지 않고도 함수 안에 새로운 배열을 만들려면 배열에 memory 키워드를 쓰면 된다.


아래의 코드를 보자.
계정 소유자는 []을 통해 여러 좀비 군단에 대해 매핑이 되어있다.

mapping (address => uint[]) public ownerToZombies

그리고 좀비가 추가 될 때마다 배열에 좀비를 추가했다.

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

위 함수는 이미 전에 작성한 내용들이다.

하지만 배열안에 있는 하나의 좀비를 다른 소유자에게 전달하려면 어떻게 해야할까?

다음과 같은 로직으로 구현해야 한다.

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

위 단계 중 3번 단계는 가스소모가 극단적으로 많을 것이다.
배열에 있는 좀비들이 모두 연산 되어야하기 때문이다. 즉 시간복잡도가 O(n)O(n)으로 비효율적이다.

그러므로 가스소모를 하지 않는 view 함수인 getZombiesByOwner 함수에서 연산을 하는 것이 효율적이다.

위의 조건을 확인하고 아래와 같이 코드를 작성했다.

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

}

getZombiesByOwner 함수를 보자.
이 함수에서는 소유자가 가진 전체 좀비군단을 볼 수 있다.
그러므로 리턴 타입은 배열이다.

view 함수 이므로 이 함수에서는 연산을 해도 가스비용이 들지 않는다.

좀비군단의 배열 크기만큼 반복하면서 좀비의 소유자를 확인하며 새로운 배열에 추가하면
소유자의 좀비 군단을 얻을 수 있다.

profile
내가 될 거라고 했잖아

0개의 댓글