[Lesson3] Advanced Solidity Concepts

Seokhun Yoon·2022년 2월 19일
0

[Solidity] CryptoZombie

목록 보기
3/5
post-thumbnail

Advanced Solidity Concepts

Crypto Zombie lesson3 링크

Lesson1과 Lesson2에서는 기본적인 문법에 대해서 좀 배웠다.
이제는 컨트랙트 소유권이나 가스 비용, 코드 최적화, 보안 등 실제로 디앱을 개발할 때 고려해야하는 개념들에 대해서 알아본다.

1. Immutability of Contracts

이더리움에 컨트랙트를 배포하면 블록체인에 영구적으로 저장되기 때문에, 해당 컨트랙트에 대한 수정이나 업데이트가 불가능하다. 만약 컨트랙트 코드에 결함이 있다해도 패치를 할 수 있는 방법이 없으며, 결함을 수정한 새로운 스마트 컨트랙트 주소를 사용자에게 알리는 수 밖에 없다.
하지만 이런 특징 덕분에 매번 함수를 할 때마다 어떤 결과가 나올지 정확하게 예측이 가능하다.

External dependencies

Lesson2에서 크립토키티 컨트랙트를 사용한 적이 있다. 이때, 우리는 크립토키티 컨트랙트 주소를 직접 변수에 할당했었다. 그러나 만약에 크립토키티 컨트랙트에 버그가 있거나 새로운 업데이트로 컨트랙트 주소가 바뀐다면 어떻게 될까?
위에서 말한 것처럼 디앱은 수정할 수 없기 때문에 우리의 디앱은 쓸모가 없어질 것이다.
이런 문제를 방지하기 위해 개발자가 디앱의 중요한 부분들을 수정을 할 수 있도록 함수를 만들어야 한다.

이제 이전 코드를 수정해보자.

contract ZombieFeeding is ZombieFactory {
  // address ckAddress = 0x06012c8cf97BEaD5deAe237070F9587f8E7A266d; 	// 직접 대입하는 코드는 삭제
  
  // KittyInterface kittyContract = KittyInterface(ckAddress);			// 우변 지우기
  KittyInterface kittyContract; 										//  kittyContract 변수만 선언
  
  // cryptoKitty 컨트랙트 주소가 바뀔 수도 있기 때문에 이를 임의로 바꿀 수 있는 함수를 만듦
  function setKittyContractAddress(address _address) external onlyOwner {
    kittyContract = KittyInterface(_address);
  }
  
  ...
}

2. Ownable Contracts

아무나 크립토키티 주소를 변경하게 되면 앱이 정상 작동하지 않을 수 있다.
따라서 누군가에게 그 권한을 주려고 한다.

OpenZepplin's Ownable contract

솔리디티 라이브러리인 OpenZepplin에서 Ownable 컨트랙트를 사용해서 특정 Address에 수정 권한을 주는 코드를 짜보자.

Ownable 컨트랙트

  • 생성자 함수가 ownermsg.sender를 대입한다.
  • onlyOwner : 특정 함수에 오직 owner만 접근할 수 있도록 하는 modifier이다.

코드 설명
1. ownable.sol 파일을 만들고 Ownable 컨트랙트 코드를 입력
2. zombiefactory.sol에서import "./ownable.sol"을 입력
3. ZombieFactory 컨트랙트가 Ownable 컨트랙트를 상속

pragma solidity >=0.5.0 <0.6.0; 	// compiler version: 0.5.x

import "./ownable.sol";				// ownable 컨트랙트를 import함

contract ZombieFactory is Ownable { // ZombieFactory가 Ownable를 상속함 
	...
}  

Function Modifiers

함수 제어자는 modifier로 선언하며 우리가 직접 호출할 수 없다.
함수 내부 코드를 실행하기 전에 modifier를 먼저 실행한다.

/* ownable.sol */ 

modifier onlyOwner() {
  require(isOwner());
  _;					// 기존 함수로 되돌아감
}
/* zombiefeeding.sol */
...

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

...

3. Gas

솔리디티에서는 사용자가 디앱의 함수를 실행할 때마다 가스를 소모하는데, 가스는 이더를 통해 구매해야 한다.
따라서 사용자가 디앱의 함수를 실행하려면 이더를 소모해야한다.

각각의 연산은 소모되는 가스 비용이 존재하고, 그 연산을 수행하는데 소모되는 컴퓨팅 자원의 양이 가스 비용을 결정한다.
따라서 함수의 논리구조가 얼마나 복잡한지에 따라 가스 비용이 달라진다.

  • 함수의 가스 비용 = 함수의 개별 연산들의 가스 비용의 합

이런 이유로 솔리디티는 코드 최적화가 매우 중요하다.

다음은 Etereum Yellow Paper의 27페이지에 기재된 가스비이다.

3-1. Why is gas necessary?

이더리움은 마치 크고 느리지만 보안이 뛰어난 컴퓨터와 비슷하다.
만약 내가 어떤 함수를 실행한다면, 네트워크 상의 모든 노드들이 이 함수의 아웃풋을 검증하기 위해서 동일한 함수를 실행하게 된다. 이런 특징을 악용해서 누군가 악의적인 의도로 무한 반복문을 통해 네트워크를 마비시키거나, 혹은 네트워크 자원을 모두 써버릴 정도의 연산을 돌릴 수도 있다.
이더리움에서는 이를 해결하기 위해 트랜잭션 시 비용을 지불하고, 사용자가 저장 공간 뿐만 아니라 연산 사용 시간에 따라서도 비용을 지불하도록 했다.

NOTE_
다른 블록체인에서도 이더리움처럼 가스를 적용하는 것이 필수는 아니다.
만약 고사양의 게임(e.g. 워크래프트)을 이더리움 메인넷에서 돌린다면 엄청난 큰 가스비가 필요할 것이다.
하지만 Loom network와 같이 다른 합의 알고리즘을 가진 블록체인에서는 가능하다.

3-2. Struct packing to save gas

정수의 타입을 지정할 때 비트 수에 따라 uint8, uint16, ..., uint256으로 선언한다.
마치 메모리 영역을 나타낸 것 같지만 사실 솔리디티에서는 이 비트 수와는 상관없이 모두 256 bits (32 bytes)의 공간을 차지한다.
따라서 uint8 쓰는 것과 uint256 쓰는 것 모두 동일한 가스비를 소모한다.

하지만 struct 안에서는 예외로, 타입의 사이즈에 따라 가스비가 다르게 소모된다.
그렇기 때문에 구조체에서는 가능한 작은 사이즈의 타입을 적어주는 것이 좋다.

NOTE_
구조체 내 필드를 선언할 때, 선언한 타입의 순서에 따라 소모되는 가스비가 달라진다.
EVM은 32 bytes 단위로 실행을 하기 때문에, 32 bytes 단위로 타입을 선언해주면 좋다.

// 112465 gas
struct UserUnpacked {
  bool    isMarried;
  bytes32 description;	// 32 bytes
  bytes23 name;			// 23 bytes
  bytes10 picture;		// 10 bytes
  bytes20 location;		// 20 bytes
  uint64  id;			// 8 bytes
  uint8   age;			// 1 byte
}
// 82565 gas
struct UserPacked {
  uint64  id;			// 8 bytes
  uint8   age;			// 1 byte
  bytes23 name;			// 23 bytes
  bytes32 description;	// 32 bytes
  bytes20 location;		// 20 bytes
  bytes10 picture;		// 10 bytes
  bool    isMarried;	// 1 byte
}

가스를 생각해서 Zombiestruct에 필드를 추가해보자.

/* zombiefactory.sol */ 

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

4. Time Units

대부분의 컴퓨터 게임을 보면 스킬을 사용했을 때 혹은 공격 후 일정 시간 스킬을 사용하지 못하게 쿨타임이 존재한다.
우리가 만든 좀비도 한 번 공격하면 일정시간의 쿨타임을 갖도록 해보자.

4-1. now

now 는 가장 최근 블록의 Unix 타임스탬프를 반환한다.

  • Unix 타임스탬프 : 1970년 1월 1일부터 지난 시간을 초 단위로 환산한 것

4-2. time units

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

  • seconds, minutes, hours, days, weeks, years
  • (e.g. 1 minutes = 60, 1 hours = 3600)
/* zombiefactory.sol */ 

uint cooldownTime = 1 days;

5. Zombie Cooldowns

위에서 만든 좀비의 readyTime 요소를 zombiefeeding에서 활용해보자.

/* zombiefeeding.sol */ 
...

  // 좀비 공격 이후 쿨타임 다시 설정
  function _triggerCooldown(Zombie storage _zombie) internal {
    _zombie.readyTime = uint32(now + cooldownTime);
  }

  // 쿨타임이 다 지났으면 true, 아니면 false 반환
  function _isReady(Zombie storage _zombie) internal view returns(bool){
    return (_zombie.readyTime <= now);
  }
  
...

6. Public Functions & Security

지금까지 만든 publicexternal 함수들은 onlyOwner가 없는 한 아무 사용자가 원하는 데이터를 넣을 수 있게 된다.
이런 남용을 방지하기 위해서는 internal 함수로 선언하는 것이다.
feedAndMultiply 함수는 모든 사용자가 접근할 필요가 없으므로 internal로 바꾸자.
그리고 위에서 생성한 쿨타임 관련 함수들을 적용해보자.

/* zombiefeeding.sol */ 

...

  function feedAndMultiply(uint _zombieId, uint _targetDna, string memory _species) internal {  // 남용을 막기 위해 public => internal로 바꿈
    ...
    
    uint newDna = (myZombie.dna + _targetDna) / 2;    // 물려서 변한 좀비의 DNA 계산
    require(_isReady(myZombie));					  // 쿨타임으로 좀비가 공격 가능한 상태인지 확인
    
    ...
    
    _createZombie("NoName", newDna);                  // 임시로 "NoName"이라는 이름을 가진 좀비 생성
    _triggerCooldown(myZombie);						  // 쿨타임 초기화
  }
  
...

7. More on Function Modifiers

이제는 좀비가 특정 레벨에서 특수 능력을 얻는 코드를 짜보자.
먼저 새로운 파일 zombiehelper.sol에 아래처럼 ZombieFeeding을 상속하는 ZombieHelper 컨트랙트를 만든다.

/* zombiehelper.sol */ 

pragma solidity >=0.5.0 <0.6.0;

import "./zombiefeeding.sol";

contract ZombieHelper is ZombieFeeding {
	// 코드 적을 곳
}

7-1. Function modifiers with arguments

이전에onlyOwner라는 함수 제어자를 만들었을 때 매개변수를 받지 않았지만, 함수 제어자에도 매개변수를 넣을 수 있다.

/* zombiehelper.sol */ 

...
  // 좀비가 지정된 레벨보다 높거나 같은지 확인
  modifier aboveLevel (uint _level, uint _zombieId) {
    require(zombies[_zombieId].level >= _level);
    _;
  }
...

7-2. Zombie modifiers

aboveLevel을 이용해서 레벨 2부터는 좀비 이름을 바꿀 수 있고, 레벨 20부터는 DNA를 바꾸는 함수를 만들어보자.

  • level >= 2 : 좀비 이름 변경
  • level >= 20 : 좀비 DNA 변경
...

  // 레벨 2이상이면 좀비 이름 변경 가능
  function changeName(uint _zombieId, string calldata _newName) external aboveLevel(2, _zombieId) {
    require(zombieToOwner[_zombieId] == msg.sender);  // 현재 사용자가 좀비 소유자인지 확인
    zombies[_zombieId].name = _newName;
  }

  // 레벨 20이상이면 좀비 DNA 변경 가능
  function changeDna(uint _zombieId, uint _newDna) external aboveLevel(20, _zombieId) {
    require(zombieToOwner[_zombieId] == msg.sender);  // 현재 사용자가 좀비 소유자인지 확인
    zombies[_zombieId].dna = _newDna;
  }
  
 ...

8. Saving Gas With 'View' Functions

View functions don't cost gas
view 함수를 외부에서 호출되면 로컬 이더리움 노드에서 데이터를 읽어오기 때문에 블록체인으로 트랜잭션을 발생시키지 않는다. 따라서 사용자가 이 함수를 사용할 때 가스가 소모되지 않는다.

NOTE_
만약 같은 컨트랙트의 다른 함수에서 view 함수를 호출한다면 가스가 소모될 수 있다.
호출한 함수가 트랜잭션을 생성하게 되면 모든 노드에서 이 함수를 검증해야하기 떄문이다.
따라서 view 함수는 외부에서 호출됐을 때만 가스가 소모되지 않는다.

이제 사용자가 소유하고 있는 모든 좀비를 불러오는 함수 getZombiesByOwner()를 만들어 보자.
이 함수의 경우에는 블록체인에 있는 데이터를 불러오기만 하므로 View 함수로 정의해야 한다.

  function getZombiesByOwner(address _owner) external view returns(uint[] memory) {
  	// 코드는 여기에 
  }

9. Storage is Expensive

솔리디티에서 storage를 사용하는 것, 특히 storage에 쓰는 동작은 가스비가 매우 많이 소모된다.
storage에 데이터를 수정하거나 저장하게 되면 블록체인에 영원히 저장된다.
이로 인해 네트워크에 연결된 모든 노드들의 하드디스크에 해당 데이터를 쓰는 작업을 하게 된다.
이런 이유로 가스비가 많이 소모될 수 밖에 없다.

Declaring arrays in memory
따라서 블록체인에 저장하지 않는 불필요한 데이터들은 storage 대신memory를 사용한다.
memory를 사용하면 로컬 노드에서 불러온 데이터를 임시 배열에 담아서 사용하게 되므로, 블록체인의 데이터를 사용하지 않아 가스비를 크게 절감할 수 있다.
(매번 함수에서 배열을 만드는 동작은 프로그래밍 로직으로는 비효율적으로 보이지만, 가스비를 생각하면 이 방법이 훨씬 효율적이다.)

이를 이용해서 getZombiesByOwner() 함수 내부에 검색 결과를 담을 배열을 만들어보자.

...

  // 소유자가 가진 모든 좀비들 불러오기 함수
  function getZombiesByOwner(address _owner) external view returns(uint[] memory) {
    uint[] memory result = new uint[](ownerZombieCount[_owner]);  // memory를 이용해서 검색한 결과를 담을 배열 생성 
    return result;
  }
  
...

10. For Loops

마지막으로 for 반복문을 통해 result배열에 소유자가 가진 배열들을 넣어주는 코드를 작성해보자.

...

  // 소유자가 가진 모든 좀비들 불러오기 함수
  function getZombiesByOwner(address _owner) external view returns(uint[] memory) {
    uint[] memory result = new uint[](ownerZombieCount[_owner]);  // memory를 이용해서 검색한 결과를 담을 배열 생성 
    uint counter = 0;   // for문에서 사용할 result 배열의 index
    for (uint i = 0; i < zombies.length; i++) {
      if(zombieToOwner[i] == _owner) {  // zombie의 소유자가 _owner와 같다면
        result[counter] = i;            // result에 해당 좀비 id를 추가
        counter++;                      // result 배열의 인덱스 증가
      }
    }
    return result;
  }
  
 ...

NOTE_
그런데 문득 이런 의문이 생길 수도 있다.
아래 코드처럼 각 소유자에 따라 좀비 배열을 매칭시키면 빠른 검색이 가능하지 않을까?

mapping (address => uint[]) public ownerToZombies;

소유하고 있는 좀비를 다른 사용자에게 전달하는 경우를 살펴보자.
위의 매핑을 사용하게 된다면, 매번 좀비가 전달될 때마다 아래의 프로세스를 거치게 된다.
1. 전달하려는 좀비를 새로운 소유자의 ownerToZombies 배열에 푸시한다.
2. 전달된 좀비를 이전 소유자의 ownerToZombies 배열에서 제거한다.
3. 좀비를 제거하고 생긴 배열 내부의 빈공간을 메꾸기 위해 다른 요소들을 시프트한다.
4. 이전 소유자의 배열의 길이를 1 줄인다.

만약 20개의 좀비를 가진 소유자가 첫번째 좀비를 전송한다면, 나머지 19개의 좀비를 시프트하게 된다.
이러면 한번의 전송으로 19번의 storage 쓰기 동작을 하게 되는 것으로 심각하게 많은 가스비를 지불하게 될 것이다.
따라서 storage의 재정렬을 하는 것보다 위처럼 view함수에서 for반복문을 활용하는 것이 가스비 관점에서 훨씬 효율적이다!


전체 코드

전체 코드는 Github 링크에서 확인 가능합니다.

profile
블록체인 개발자를 꿈꾸다

0개의 댓글