[Blockchain] 크립토좀비 lesson3 (Solidity)

공효은·2022년 3월 1일
0

Blockchain

목록 보기
8/10
post-thumbnail

😋이제 solidity 기본적인 문법 공부는 끝났다!
고급 솔리디티 개념을 공부해보려고한다.

컨트랙트의 불변성

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

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

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

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

외부 의존성

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

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

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

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

  KittyInterface kittyContract;

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

소유 가능한 컨트랙트

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

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

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

OpenZeppelin의 Ownable 컨트랙트

아래에 나와있는 것은 OpenZeppelin 솔리디티 라이브러리에서 가져온 Ownable 컨트랙트이다. OpenZeppelin은 DApp에서 사용할 수 있는, 안전하고 커뮤니티에서 검증받은 스마트 컨트랙트의 라이브러리다.

contract Ownable {
 address public owner;
  
 event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
  
function Ownable() public {
  owner = msg.sender;
}
  
modifier onlyOwner() {
  require(msg.sender == owner);
  _;
}
  
function transferOwndership(address newOwnder) public onlyOwner {
  require(newOwner != address(0));
  OwnershipTransferred(owner, newOwner);
  owner = newOwner;
}
  • 생성자(Constructor): function Ownable() 는 생성자이다. 컨트랙트와 동일한 이름을 가진, 생략할 수 있는 특별한 함수이다. 이 함수는 컨트랙트가 생성될 때 딱 한 번만 실행된다.
  • 함수 제어자(Function Modifier): modifier onlyOwner() 제어자는 다른 함수들에 대한 접근을 제어하기 위해 사용되는 일종의 유사함수이다.
    보통 함수 실행 전의 요구사항 충족 여부를 확인하는 데에 사용한다.
    onlyOwner의 경우에는 접근을 제한해서 오직 컨트랙트의 소유자만 해당 함수를 실행할 수 있도록 하기 위해 사용될 수 있다. _; <- 이게 뭔지도 알아 볼 것이다.

즉 Ownable 컨트랙트는 기본적으로 다음과 같은 것들한다.

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

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

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

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

onlyOwner 함수 제어자

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

이건 컨트랙트가 상속 되는 구조 때문이다.

ZombieFeeding is AombieFactory
ZombieFactory is Ownable

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

함수 제어자

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

modifier onlyOwner() {
  require(msg.sender == owner);
  _;
}

다음과 같이 사용한다.

contract MyContract is Ownable {
  event LaughManiacally(string laughter);
  
  function likeAboss() external only onlyOwner {
    LaughManiacally("Muahahahaha")
  }
}

likeABoss 함수를 호출하면, onlyOwner의 코드가 먼저 실행된다. 그리고 onlyOwner의 _; 부분을 likeABoss 함수로 되돌아가 해당 코드를 실행한다.

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

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

가스(Gas)

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

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

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

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

함수를 실행하는 것은 사용자들에게 실제 돈을 쓰게 하기 때문에, 이더리움에서 코드 최적화는 다른 프로그래밍 언어들에 비해 훨씬 중요하다.

가스는 왜 필요한가?

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

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

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

lesson 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 필드가 묶여있기 때문이다.

시간단위

level : 전투에서 많이 이긴 좀비는 레벨업을 하고 더 많은 기능이 생길 것이다!

readyTime 속성은 좀비가 먹이를 먹거나 공격을 하고 나서 다시 먹거나 공격할 수 있을 떄까지 기다려야하는 "재사용 대기 시간"을 추가하는 것이다. 이 속성 없이는, 좀비는 하루에 천 번이상 공격하거나 증식 할 수 있다. 이러면 게임이 너무 쉬워진다.

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

시간단위(Time uints)

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

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

유닉스 타입은 전통적으로 32비트 숫자로 저장된다. 이는 유닉스 타임스탬프 값이 32비트로 표시가 되지 않을 만큼 커졌을 때 많은 구형 시스템에 문제가 발생한다. 그러니 만약 우리 DApp이 지금부터 20년 이상 운영되길 원한다면 우리는 64비트 숫자를 써야한다. 하지만 우리 유저들은 더 많은 가스를 소모해야한다...

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

예시

uint lastUpdated;

function updateTimestamp() public {
  lastUpdated = now;
}

//마지막으로 updateTimestamp 가 호출된 뒤 5분이 지났으면 true를 5분이 아직 지나지 않았으면 false를 반환

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

이런 시간 단위들은 좀비의 cooldown 기능을 추가할 때 사용한다.

구조체를 인수로 전달하기

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

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

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

함수 제어자의 또 다른 특징

인수를 가지는 함수 제어자

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

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

//사용자가 특정 나이 이상인지 아닌지 확인하는 제어자
modifier olderThan(uint _age, uint _userId){
  require(age[_userId] >= _age)'
  _;
  
//차를 운전하기 위해서는 16살 이상이어야 한다.(미국에서)
//olderThan 제어자를 인수화 함꼐 호출하려면 이렇게 하면 된다.

 function driverCar(uint _userId) public olderThan(16, _userId){
   //필요한 함수 내용들
 }

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

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

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

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

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

가스를 최적화 하는 비결은 가능한 모든 곳에 읽기 전용의 external view 함수를 쓰는 것이다.

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

Storage는 비싸다

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

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

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

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

메모리에 배열 선언하기

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

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

메모리 배열은 반드시 길이 인수와 함께 생성되어야 한다.(이 예시에서는 3) 메모리 배열은 현재로서는 storage 배열 처럼 array.push()로 크키가 조절 되지 않는다.

For 반복문

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

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

mapping (address => unit[]) 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() pur external returns(uint[]){
  uint[] memory evens = new uint[](5);
  //새로운 배열의 인덱스를 추적하는 변수
  uint count = 0;
  //for 반복문에서 1부터 10까지 반복함
  for(uint i =1; i <= 10; i++){
    //i가 짝수라면
    if(i % 2 == 0) {
      //배열에 i를 추가함
      evens[count] = i;
      //events의 다음 빈 인덱스 값으로 counter를 증가시킴
      counter++;
    }
  }
  return evens;
}

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

profile
잼나게 코딩하면서 살고 싶어요 ^O^/

0개의 댓글