[#3 Crypto Zombies] 고급 솔리디티 개념

cat_dev·2021년 2월 18일
0

Solidity

목록 보기
3/9
post-thumbnail

실습 링크

솔리디티 특징

컨트랙트의 불변성

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

  • 코드를 이후에 고칠 수 있는 방법이 없기 때문에 보안이 굉장히 큰 이슈가 됨
  • 함수를 호출할 때마다, 코드에 쓰여진 그대로 함수가 실행될 것이라고 확신할 수 있다. 그 누구도 배포 이후에 함수를 수정하거나 예상치 못한 결과를 발생시키지 못한다.

외부 의존성

블록체인 외부에서 끌어온 데이터에 문제가 생겼을 경우, 나의 DApp이 작동하지 못할 수 있다. 따라서 외부에서 끌어오는 데이터는 가변 데이터로 저장한다.

//인터페이스 변수 선언만 한다.
KittyInterface kittyContract;

//함수 내부에서 address대입!
  function setKittyContractAddress(address _address) external {
    kittyContract = KittyInterface(_address);
  }

소유 가능한 콘트랙트

Ownable contract

위의 코드에서 함수를 external로 선언했기 때문에, 누구든 이 함수를 호출해 주소를 변경할 수 있다. 따라서 컨트랙트를 소유 가능하게 만들어 특별한 권리를 가진 소유자만 주소 변경이 가능하도록 설정한다.

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

/**
 * @title Ownable
 * @dev The Ownable contract has an owner address, and provides basic authorization control
 * functions, this simplifies the implementation of "user permissions".
 */
contract Ownable {
  address public owner;
  event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

  /**
   * @dev The Ownable constructor sets the original `owner` of the contract to the sender
   * account.
   */
  function Ownable() public {
    owner = msg.sender;
  }

  /**
   * @dev Throws if called by any account other than the owner.
   */
  modifier onlyOwner() {
    require(msg.sender == owner);
    _;
  }

  /**
   * @dev Allows the current owner to transfer control of the contract to a newOwner.
   * @param newOwner The address to transfer ownership to.
   */
  function transferOwnership(address newOwner) public onlyOwner {
    require(newOwner != address(0));
    OwnershipTransferred(owner, newOwner);
    owner = newOwner;
  }
}

해당 코드 내부 요소 설명

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

Ownable 컨트랙트의 기본 역할

  1. 컨트랙트가 생성되면 컨트랙트의 생성자가 ownermsg.sender(컨트랙트를 배포한 사람)를 대입한다.

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

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

대부분의 솔리디티 DApp들은 Ownable 컨트랙트를 복사/붙여넣기 하면서 시작한다.

함수 제어자

제어자란?

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

onlyOwner를 살펴보면서 더 자세히 알아보도록 하자.

/**
 * @dev Throws if called by any account other than the owner.
 */
modifier onlyOwner() {
  require(msg.sender == owner);
  //원래 함수로 되돌아가는 코드는 아래쪽!
  _;
}

우리는 이 제어자를 다음과 같이 사용할 것이다.


contract MyContract is Ownable {
  event LaughManiacally(string laughter);

  // 아래 `onlyOwner`의 사용 방법을 잘 보자.
  function likeABoss() external onlyOwner {
    LaughManiacally("Muahahahaha");
  }
}

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

제어자 사용하기

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

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

🔎 참고: 이렇게 소유자가 컨트랙트에 특별한 권한을 갖도록 하는 것은 자주 필요하지만, 이게 악용될 수도 있다. 예를 들어, 소유자가 다른 사람의 좀비를 뺏어올 수 있도록 하는 백도어 함수를 추가할 수도 있음!

개발 시 주의사항

  • 이더리움에서 돌아가는 DApp이라고 해서 그것만으로 분산화되어 있다고 할 수는 없다.
  • 소유자에 의한 특별한 제어가 불가능한 상태인지 확인이 반드시 필요하다.
  • 잠재적인 버그를 수정하고 DApp을 안정적으로 유지하도록 하는 것과 사용자들이 그들의 데이터를 믿고 저장할 수 있는 소유자가 없는 플랫폼을 만드는 것 사이에서 균형을 잘 잡는 것이 중요하다.

인수를 가지는 함수 제어자

함수 제어자는 인수를 가질 수 있음!

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

// 사용자가 특정 나이 이상인지 확인하는 제어자
modifier olderThan(uint _age, uint _userId) {
  require (age[_userId] >= _age);
  _;
}

// 차를 운전하기 위햐서는 16살 이상이어야 하네(적어도 미국에서는).
// `olderThan` 제어자를 인수와 함께 호출하려면 이렇게 하면 된다.
function driveCar(uint _userId) public olderThan(16, _userId) {
  // 필요한 함수 내용들
}

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

Gas

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

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

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

함수를 실행하는 것은 사용자들에게 실제 돈을 쓰게 하기 때문에, 이더리움에서 코드 최적화는 다른 프로그래밍 언어들에 비해 훨씬 더 중요하다. 만약 코드가 엉망이라면, 사용자들은 함수를 실행하기 위해 일종의 할증료를 더 내야 한다. 그리고 수천 명의 사용자가 이런 불필요한 비용을 낸다면 할증료가 수십 억 원까지 쌓일 수 있다.

가스는 왜 필요한가?

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

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

참고: 사이드체인에서는 반드시 이렇지는 않다. 크립토좀비를 만든 사람들이 Loom Network에서 만들고 있는 것들이 좋은 예시. 이더리움 메인넷에서 월드 오브 워크래프트 같은 게임을 직접적으로 돌리는 것은 절대 말이 되지 않는다. 가스 비용이 엄청나게 높을 것이기 때문이다. 하지만 다른 합의 알고리즘을 가진 사이드체인에서는 가능할 수 있다.

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

앞에서 우리는 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 필드가 묶여있기 때문이다.

정리하면 가스 비용을 줄이기 위해서

  1. 구조체 안에서 가능한 한 작은 크기의 정수 타입을 쓰도록 한다.
  2. 동일한 데이터 타입을 하나로 묶어서 쓴다.

시간 단위

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

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

참고: 유닉스 타임은 전통적으로 32비트 숫자로 저장된다. 이는 유닉스 타임스탬프 값이 32비트로 표시가 되지 않을 만큼 커졌을 때 많은 구형 시스템에 문제가 발생할 "Year 2038" 문제를 일으킬 것이다. 그러니 만약 DApp이 지금부터 20년 이상 운영되길 원한다면, 우리는 64비트 숫자를 써야 할 것이다. 하지만 우리 유저들은 그동안 더 많은 가스를 소모해야 하니까, 설계를 보고 결정을 해야 함!

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

uint lastUpdated;

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

// 마지막으로 `updateTimestamp`가 호출된 뒤 5분이 지났으면 `true`를, 5분이 아직 지나지 않았으면 `false`를 반환
function fiveMinutesHavePassed() public view returns (bool) {
  return (now >= (lastUpdated + 5 minutes));
}

View 함수 이용해 가스 절약하기

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

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

  • 이건 view 함수가 블록체인 상에서 데이터를 읽기만 하지 실제로 어떤 것도 수정하지 않기 때문이다.
  • 함수에 view 표시를 하는 것은 web3.js에 로컬 이더리움 노드에 질의만 날리면 되고, 블록체인에 어떤 트랜잭션도 만들지 않는다는걸 알려주는 것과 같다.
  • 가능한 모든 곳에 읽기 전용의 external view 함수를 쓰는 것은 사용자들을 위해 DApp의 가스 사용을 최적화하는 비결!

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

Storage는 비싸다

storage 쓰기는 비싼 연산!

  • 데이터의 일부를 쓰거나 바꿀 때마다, 블록체인에 영구적으로 기록되기 때문에 이 연산이 굉장히 비싸다.

  • 비용을 최소화하기 위해서, 진짜 필요한 경우가 아니면 storage에 데이터를 쓰지 않는 것이 좋다.

  • 이를 위해 때때로는 겉보기에 비효율적으로 보이는 프로그래밍 구성을 할 필요가 있다.

  • 어떤 배열에서 내용을 빠르게 찾기 위해, 단순히 변수에 저장하는 것 대신 함수가 호출될 때마다 배열을 memory에 다시 만드는 것처럼!

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

메모리에 배열 선언하기

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

메모리에 배열을 선언하는 방법은 다음과 같다.

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

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

for 반복문

배열이 수정될 때마다 storage를 갈아치우는건 너무 많은 가스 비용을 소진한다. 따라서 배열 안에 있는 원소를 for문을 이용해 가져와 Memory에 저장해서 아용하는 연산을 이용한다.

function getEvens() pure external returns(uint[]) {
  uint[] memory evens = new uint[](5);
  // 새로운 배열의 인덱스를 추적하는 변수
  uint counter = 0;
  // for 반복문에서 1부터 10까지 반복함
  for (uint i = 1; i <= 10; i++) {
    // `i`가 짝수라면...
    if (i % 2 == 0) {
      // 배열에 i를 추가함
      evens[counter] = i;
      // `evens`의 다음 빈 인덱스 값으로 counter를 증가시킴
      counter++;
    }
  }
  return evens;
}
profile
devlog

0개의 댓글