크립토좀비 레슨3

707·2022년 7월 25일
2

솔리디티

목록 보기
5/5
post-thumbnail

여기서부터는 정리를 안하면 까먹을 듯 해서..

Lesson2 정리

  • internal vs external
  • storage vs memory

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

불변성

디앱이 다른 어플리케이션과 다른 점은 한번 배포를 하고나면 더이상 수정이나 업데이트를 할 수 없다는 점이다.
이 특징은 컨트랙트에 신뢰성을 주기도 하지만 또한 이로인해 솔리디티에서는 보안성이 아주아주 중요해지는 이유이기도 하다. 배포를 한 코드에 결함이 있다면 이를 고칠 수 있는 방법이 전혀 없기 때문이다.

외부의존성

외부 컨트랙트를 참조해서 나의 DApp을 만든 경우,
혹시나 해당 외부컨트랙트에 문제가 있는 경우에 우리는 외부컨트랙트의 주소를 우리의 DApp에서 바꿀 수 있어야만 한다.

단순히 외부컨트랙트의 주소값을 직접 써넣어 가지고 오는 방법 대신
함수를 이용하여 참조할 주소를 지정하도록 해주는 방법을 사용하면 된다

코드

❌ 기존의 컨트랙트 주소 직접 대입 방식

contract ZombieFeeding is ZombieFactory {

  address ckAddress = 0x06012c8cf97BEaD5deAe237070F9587f8E7A266d;
  KittyInterface kittyContract = KittyInterface(ckAddress);
  
  // 이하 메소드 생략
}

⭕️ setAddress 함수 활용

contract ZombieFeeding is ZombieFactory {

  KittyInterface kittyContract;
  
  function setKittyContractAddress(address _address) external {
    kittyContract = KittyInterface(_address);
  }
  
  // 이하 메소드 생략
}


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

위의 코드를 보면 setKittyContractAddress 함수는 external로 선언되어 누구든지 해당 함수를 실행시킬 수 있는 상태이다. 하지만 이렇게 컨트랙트가 참조하고 있는 외부컨트랙트의 주소를 아무나 변경할 수 있도록 두는 것은 보안상 매우 좋지않다.

이를 해결하기 위한 방법으로 컨트랙트를 소유가능하도록 만드는 것이 있다.
컨트랙트를 소유가능하게 한 뒤, 해당 컨트랙트의 소유자만 함수를 실행할 수 있게끔 하는 것이다.

[참조] openZepplin 라이브러리의 Ownable컨트랙트

ownable.sol

contract Ownable {
  address public owner;
  event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

  // 생성자함수 : 컨트랙트 배포시에만 실행
  function Ownable() public {
    owner = msg.sender;
  }

  // 함수제어자 modifier : 다른 함수에 대한 접근 제어 목적
  modifier onlyOwner() {
    require(msg.sender == owner);
    _;
  }

  // setter : onlyOwner를 가지고 있는 세터함수이므로 msg.sender를 먼저 확인하게 됨. 맞을때만 owner를 변경하는 내부 코드가 실행된다.
  function transferOwnership(address newOwner) public onlyOwner {
    require(newOwner != address(0));
    OwnershipTransferred(owner, newOwner);
    owner = newOwner;
  }
}

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

코드

우리는 위에 작성한 setKittyContractAddress 함수를 컨트랙트의 소유자만 제어할 수 있도록 만들고 싶다. 방금 본 onlyOwner를 이용하여 코드를 수정해보자.

위의 ownable.sol을 import한 뒤 우리의 ZombieFactory 컨트랙트가 ownable.sol의 Ownable 컨트랙트를 상속받도록 해준다.

import "./ownable.sol";

contract ZombieFactory is Ownable {
  // 내부코드 생략
}

이렇게 하면 ZombieFactory컨트랙트를 상속받는 다른 컨트랙트(ZombieFeeding)에도 자동적으로 Ownable이 상속되어 onlyOwner를 사용할 수 있게된다.

우리는 ZombieFeeding 컨트랙트에서 만든 setKittyContractAddress 함수가 소유자만 접근할 수 있도록 만들고 싶다. 해당 함수에 onlyOwner를 추가해준다.

contract ZombieFeeding is ZombieFactory {

  KittyInterface kittyContract;
  
  function setKittyContractAddress(address _address) external onlyOwner {
    kittyContract = KittyInterface(_address);
  }
  
  // 이하 메소드 생략
}


챕터4 : Gas

솔리디티로 작성한 DApp의 함수를 실행할 때마다 실행한 사용자는 이더리움 네트워크의 EVM을 사용한 수수료 개념으로 가스를 지불해야 한다.

함수를 실행할 때 필요한 가스는 해당 함수의 로직이 얼마나 복잡한지에 따라 달라진다. 연산을 수행할 때 필요한 컴퓨터 자원의 양이 많을수록 더 많은 가스비를 지불해야한다.

이런 이유로 코드최적화가 다른 언어들보다 더 필요하다. 줄일 수도 있었을 필요없는 수수료를 사용자들이 매번 써야한다면 굉장한 낭비일 것이다.

왜 이런 가스를 소모하도록 한 것일까?

우리가 배포한 DApp은 결과적으로 이더리움 네트워크의 EVM에서 실행된다. 어떤 함수를 실행하고자 할 때 네트워크 상의 모든 개별 노드가 이 함수를 실행하여 결과값을 검증하는 작업을 하게된다.

그렇다면 누군가 악의적으로 무한반복문이나 자원 소모가 큰 연산을 써서 올리게되면 어떻게 될까? 네트워크가 마비될 것이다.
이런 경우를 방지하기 위하여 저장공간을 사용하거나, 연산을 처리할 때마다 가스가 발생하도록 한 것이다.

구조체 압축

솔리디티에서 사용되는 숫자 타입에는 uint 가 있다.
uint는 기본적으로 256비트의 공간을 미리 잡아두게 된다.
하지만 이 공간을 줄여서 잡을 수 있도록 하여 효율적인 메모리 사용을 할 수 있게도 할 수 있다.

기본적으로 변수의 타입을 uint8이나 uint32 등으로 지정하는 것은 사실 아무 영향을 미치지 못한다. 솔리디티는 uint 타입은 무조건 256비트의 공간을 잡아두기 때문이다.

하지만 한가지 예외가 있는데 바로 구조체안에서 압축된 uint 타입으로 선언된 변수의 경우이다. 이런 경우에는 더 작은 크기의 uint를 사용하여 적은 메모리를 차지하게 한다!

따라서 구조체 안에서는 가능한 한 작은 크기의 정수 타입을 사용하도록 하는 것이 효율적이다.

또한 동일한 데이터 타입의 필드를 연속해서 적어 묶어두는 것이 좋다. 이런 식으로 구조체를 선언해야 사용하는 저장공간을 최적화하여 적은 가스를 소모하기 때문이다.

코드

기존에 만들었던 Zombie 구조체의 속성을 변경해보자.

struct Zombie {
	string name;
    uint dna;
}

좀비에 levelreadyTime(다음 먹이 먹을 때까지 쿨타임)이라는 새로운 특징을 추가할 것이다.

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

uint32인 두 속성은 연속해서 적어 효율적으로 저장공간을 사용할 수 있도록 하였다.

챕터5-7 : 시간

시간단위

솔리디티는 기본적으로 시간에 대한 단위계를 제공한다.
now 변수로 현재의 유닉스타임스탬프를 얻을 수 있고,
seconds, minutes,hours, days,weeks, years 같은 시간 단위 역시 포함하고 있다. 각각은 해당 단위를 초단위로 환산할 수 있게 해준다.

ex)
minutes = 60
days = 60 60 24

이를 이용하여 변경된 zombie 구조체로 zombie를 생성하도록 _createZombie 함수를 고쳐보자

zombieFactory.sol

contract ZombieFactory is Ownable {
  
  	// 👇 : 쿨타임 1일
  	uint cooldownTime = 1 days;
  
	struct Zombie {
        string name;
        uint dna;
        uint32 level;
        uint32 readyTime;
    }
  
  	function _createZombie(string _name, uint _dna) internal {
        // 👇 : level과 readyTime에 해당하는 값을 넣어준다.
        uint id = zombies.push(Zombie(_name, _dna, 1, uint32(now + cooldownTime)) - 1;
        zombieToOwner[id] = msg.sender;
        ownerZombieCount[msg.sender]++;
        NewZombie(id, _name, _dna);
    }
}

readyTime 변경/확인 함수

zombie의 속성으로 readyTime을 만들어주었다.
이제 이 값을 활용하여 좀비가 다음 먹이를 먹기까지 충분한 시간을 기다려야 하도록 만들어줄 것이다.

먼저 먹이를 먹은 뒤 readyTime을 변경하도록 하는 triggerCooldown 함수와,
좀비가 먹이를 먹기 전 readyTime이 지났는지 확인하는 isReady함수를 internal로 만든다.

contract ZombieFeeding is ZombieFactory {
  
  // readyTime 지정 함수 : 좀비가 먹이 먹은 뒤 실행해서 쿨타임 설정
  function _triggerCooldown(Zombie storage _zombie) internal {
    _zombie.readyTime = uint32(now + cooldownTime);
  }

  // readyTime 확인 함수 : 좀비가 먹이 먹기 전 실행해서 쿨타임 지났는지 확인
  function _isReady(Zombie storage _zombie) internal view returns(bool) {
    return (_zombie.readyTime <= now);
  }
  
  // 그 외 생략
}

해당 함수에서는 storage로 블록체인에 저장된 Zombie 구조체의 값을 단순히 복사해서 사용하는 것이 아닌 저장된 값을 가져와서 변경하는 것이므로 Zombie storage 포인터 타입으로 인자를 지정해주자.

feedAndMultiply 함수 수정

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

위에서 생성한 triggerCooldown 함수와 isReady함수를 좀비가 먹이를 먹는 feedAndMultiply 함수내에 추가해준다.
또한, 이 함수는 feedOnKitty라는 함수의 내부에서만 실행되는 함수이므로 public이었던 접근제한자를 internal로 변경해준다.

챕터8-9 : 함수제어자

위에서 ownable과 같은 함수제어자를 직접 만들 수 있다는 것을 배웠다.
이런 함수제어자는 함수와 동일하게 인자를 받을 수도 있다.
zombiehelper라는 컨트랙트에서 좀비가 필요로 하는 레벨을 확인하는 제어자를 만들어보자

pragma solidity ^0.4.19;

import "./zombiefeeding.sol";

contract ZombieHelper is ZombieFeeding {

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

좀비아이디와 레벨을 인자로 받아 해당 좀비가 지정한 레벨 이상인지를 확인하는 require문을 작성해주었다.

이 함수제어자는 아래와 같이 사용될 수 있다.

function zombieFunction (...인자들) aboveLever(zombieId, 3) {}
// 좀비의 레벨이 3 이상인 경우에만 함수가 실행될 것이다

이를 이용하여 레벨이 충족시에만 좀비의 이름과 dna를 직접 수정할 수 있는 함수를 만들려고 한다.

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

각각 레벨이 2, 20 이상일 때에만 함수가 실행된다.

챕터10 : view함수

view 함수는 블록체인 상에서 어떤 상태도 변경하지 않는 (트랜잭션을 날리지 않는) 함수이다.
함수가 단순히 블록체인에 있는 컨트랙트의 값을 읽어오기만 할 때 이 함수가 읽기전용임을 명시해주는 external view를 제어자로 붙여 명시해줌으로써 불필요한 gas의 지출을 막을 수 있다.

챕터11 : storage

storage를 이용한 연산은 많은 가스를 소모한다.
데이터를 쓰거나 바꿀 때 이 데이터가 블록체인에 영구적으로 기록되기 때문이다. 정말 필요한 경우가 아니면 storage에 데이터를 쓰지 않는 것이 좋다.
이 때문에 겉보기엔 비효율적으로 코드를 짜야할 때도 있다.
(어떤 배열에서 내용을 빠르게 찾기 위해, 단순히 변수에 저장하는 것 대신 함수가 호출될 때마다 배열을 memory에 다시 만드는 방식 사용)

챕터12 : for 반복문

위의 챕터 10, 11과 함게 이번 챕터에서 account를 입력하면 해당 account가 가지고 있는 좀비의 배열을 리턴하는 함수를 만들어보자.


방법1 : memory와 for문 사용

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

account가 가지고 있는 좀비의 수만큼의 길이를 가진 result배열을 선언해둔 뒤,
전체 zombies 배열을 for문을 돌면서 좀비의 owner가 입력한 account와 동일한지 확인하여 맞다면 result배열에 담는 방식이다.



방법2 : storage와 getter 사용

사실은 위의 함수를 이용하는 방식이 아닌 아래처럼 코드를 작성할 수도 있다.

mapping (address => uint[]) public ownerToZombies

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

기존에는 ownerToZombies라는 변수에 {좀비ID : account} 방식의 매핑으로 각각의 좀비들이 어떤 owner를 가지는지를 명시해주었다면
두번째 방식으로는 {account: [좀비아이디1, 좀비아이디2, ...]} 매핑방식으로 account로 조회를 하면 해당 account 소유의 좀비 아이디 배열이 바로 리턴되도록 하는 방법이다.

어느 방법이 더 효율적인가?

위에 작성한 for문을 이용한 함수보다 한눈에 보기에도 간단해 보이지만, 이 방식을 쓰는 것은 블록체인 네트워크와 컨트랙트의 특성상 매우 비효율적이다. 데이터 수정작업을 하게 될 경우 gas의 소모면에서 크게 차이가 난다.

방법2를 사용했을 때, A유저가 가지고 있는 좀비 한마리를 B유저에게 전달해주려고 한다고 생각해보자

이 작업을 조금 더 자세히 나누면 아래와 같다.

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

배열에서 shift가 일어나면 전체 배열이 새로 쓰여야한다.
storage로 기록되어있는 ownerToZombies에서 좀비 하나가 빠지게 된다면 해당 좀비 이후의 인덱스들이 한칸씩 앞으로 당겨 새로 쓰여져야하며, 이 과정에서 많은 가스가 소모된다.

방법1을 사용한다면?

방법1을 사용한다면 배열은 사용되지 않는다. storage로 기록된 데이터들은 좀비-주인으로만 매핑되어 있으므로 A의 소유였던 좀비를 B의 소유로 변경하고 싶다면 zombieToOwner에서 해당 좀비의 ID를 찾아 account만 변경해주면 된다. 한번만 새로 쓰여지면 그 이상의 가스비 지출이 불필요하다!



💫 이상 레슨3 끝!

정리

  • 구조체 안에서는 압축된 정수 타입을 사용하자. 그리고 같은 타입끼리 붙여놓자.
  • 상태변수를 만들 때 mapping에 배열을 사용하지 말자. 가스비 ㅎㄷㄷ

0개의 댓글