dev-miri

Solidity Path: Beginner to Intermediate Smart Contracts [2] 본문

BlockChain/CryptoZombies

Solidity Path: Beginner to Intermediate Smart Contracts [2]

miri-dev 2022. 9. 4. 19:59

1. 매핑과 주소

-주소

이더리움 블록체인은 은행 계좌와 같은 계정들로 이루어져 있다. 

계정은 이더리움 블록체인상의 통화인 _이더_의 잔액을 가진다.

은행 계좌에서 다른 계좌로 돈을 송금할 수 있듯이, 계정을 통해 다른 계정과 이더를 주고 받을 수 있다.

 

각 계정은 은행 계좌번호와 같은 주소를 가지고 있다. 

예를 들면 , 0x0cE446255506E92DF41614C46F1d6df9Cc969183 와 같다.

"주소는 특정 유저(혹은 스마트 컨트랙트)가 소유한다" 라고 이해하면 된다.

 

-매핑

매핑은 솔리디티에서 구조화된 데이터를 저장하는 또 다른 방법이다.

 

다음과 같이 매핑을 정의한다 : 

//금융 앱용으로, 유저의 계좌 잔액을 보유하는 unit을 저장한다.
mapping(address=> unit) public accountBanlance;
//혹은 userID로 유저 이름을 저장/검색하는 데 매핑을 쓸 수도 있다.
mappint(unit => string) userIdToName;

매핑은 기본적으로 키-값(key-value) 저장소로, 데이터를 저장하고 검색하는 데 이용된다.

첫 번째 예시에서 키는 address이고, 값은 uint이다. 

두 번째 예시에서 키는 uint이고, 값은 string이다. 

 

2. Msg.sender

솔리디티에는 모든 함수에서 이용 가능한 특정 전역 변수들이 있다. 

그 중 하나가 현재 함수를 호출한 사람(혹은 스마트 컨트랙트)의 주소를 가리키는 msg.sender이다.

 

**솔리디티에서 함수 실행은 항상 외부 호출자가 시작한다.

컨트랙트는 누군가가 컨트랙트의 함수를 호출할 때까지 블록체인 상에서 아무것도 하지않고 있는다.

따라서 항상 msg.sender가 있어야 한다. 

 

msg.sender를 이용하고 mapping을 업데이트하는 예시는 아래와 같다. 

mapping (address => uint) favoriteNumber;

function setMyNumber(uint _myNumber) public {
	//`msg.sender`에 대해 `_myNumber`가 저장되도록 `favoriteNumber` 매핑을 업데이트한다
	favoriteNumber[msg.sender] = _myNumber;
    // ^ 데이터를 저장하는 구문은 배열로 데이터를 저장할 때와 동일하다.
}

function whatIsMyNumber() public view returns (uint) {
	//sender의 주소에 저장된 값을 불러온다
    //sender가 `setMyNumber`을 아직 호출하지 않았다면 반환값은 `0`이 될 것이다
    return favoriteNumber[msg.sender];
}

이 예시에서 누구나 setMyNumber을 호출하여 본인의 주소와 연결된 우리 컨트랙트 내에 uint를 저장할 수 있다.

 

msg.sender를 활용하면 이더리움 블록체인의 보안성을 이용할 수 있다.

즉, 누군가 다른 사람의 데이터를 변경하려면 해당 이더리움 주소와 관련된 개인키를 훔치는 것 밖에는 다른 방법이 없다.

 

3. Require

위 게임에서는 유저들이 게임을 시작할 때 첫 좀비 호출 함수를 한번만 호출하게 구현하기를 원한다.

함수가 각 플레이어마다 한 번씩만 호출되게 하려면 require을 활용하면 된다.

require를 활용하면 특정 조건이 참이 아닐 때 함수가 에러 메시지를 발생하고 실행을 멈추게 된다. 

function sayHiToVitalik(string _name) public returns (string) {
//_name이 "Vitalik"인지 비교한다. 참이 아닐 경우 에러 메시지를 발생하고 함수를 벗어난다.
//(참고 : 솔리디티는 고유의 스트링 비교 기능을 가지고 있지 않기 때문에
//스트링의 keccak256 해시값을 비교하여 스트링의 값이 같은지 판단한다)
require(keccak256(_name) == keccak256("Vitalik"));
//참이면 함수 실행을 진행한다:
return "Hi!";
}

 sayHiToVitalik("Vitalik")로 이 함수를 실행하면 "HI!"가 반환된다.

"Vitalik"이 아닌 다른 값으로 이 함수를 호출할 경우, 에러 메시지가 뜨고 함수가 실행되지 않을 것이다. 

그러므로  require는 함수를 실행하기 전에 참이어야 하는 특정 조건을 확인하는데 있어서 꽤 유용하다. 

 

4.  상속

아주 긴 컨트랙트를 만드는 것 보다 코드를 잘 정리해서 여러 컨트랙트에 코드 로직을 나누는 것이 더 합리적일 수 있다.

이를 보다 관리하게 쉽도록 하는 솔리디티 기능이 바로 컨트랙트 상속이다!

contract Doge {
	function catchphrase() public returns (string) {
    	return "So Wow CryptoDoge";
    }
}

contract BabyDoge is Doge {
	function anotherCatchphrase() public return (string) {
    	return "Such Monn BabyDoge";
    }
}

BabyDoge 컨트랙트는  Doge 컨트랙트를 상속한다.

즉, BabyDoge 컨트랙트를 컴파일해서 구축할 때,  BabyDoge 컨트랙트가 catchphrase() 함수와 anotherCatchphrase() 함수에 모두 접근할 수 있다는 뜻이다. (Doge 컨트랙트에 정의되는 다른 어떤 public 함수가 정의되어도 접근할 수 있다)

 

5.  Import

코드가 길어질 땐, 여러 파일로 나누어 정리하면 관리하기 편하다.

보통 이런 방식으로 솔리디티 프로젝트의 긴 코드를 처리한다.

다수의 파일이 있고, 어떤 파일을 다른 파일로 불러오고 싶을 때, 솔리디티는 import라는 키워드를 이용한다.

import "./someothercontract.sol";

contract newContract is SomeOtherContract {

}

이 컨트랙트와 동일한 폴더에(이게 ./ 가 의미하는 바이다) someothercontract.sol이라는 파일이 있을 때, 이 파일을 컴파일러가 불러오게 된다. 

 

6. Storage vs Memory

솔리디티에는 변수를 저장할 수 있는 공간으로 storage와 memory 두 가지가 있다.

 

Storage는 블록체인 상에 영구적으로 저장되는 변수를 의미한다. (=하드디스크)

Memory는 임시적으로 저장되는 변수로, 컨트랙트 함수에 대한 외부 호출들이 일어나는 사이에 지워진다. (=RAM)

 

대부분의 경우에는 솔리디티가 알아서 처리해주기 때문에 이런 키워드를 이용할 필요가 없다.

상태 변수(함수 외부에 선언된 변수)는 초기 설정상 storage로 선언되어 블록체인에 영구적으로 저장되는 반면, 

함수 내에 선언된 변수는 memory로 자동 선언되어서 함수 호출이 종료되면 사라진다. 

 

하지만 함수 내의 구조체와 배열을 처리할 때에는 이 키워드를 사용해야 한다. 

contract SandwichFactory {
  struct Sandwich {
    string name;
    string status;
  }

  Sandwich[] sandwiches;

  function eatSandwich(uint _index) public {
    // Sandwich mySandwich = sandwiches[_index];

    // ^ 꽤 간단해 보이나, 솔리디티는 여기서 
    // `storage`나 `memory`를 명시적으로 선언해야 한다는 경고 메시지를 발생한다. 
    // 그러므로 `storage` 키워드를 활용하여 다음과 같이 선언해야 한다:
    Sandwich storage mySandwich = sandwiches[_index];
    // ...이 경우, `mySandwich`는 저장된 `sandwiches[_index]`를 가리키는 포인터이다.
    // 그리고 
    mySandwich.status = "Eaten!";
    // ...이 코드는 블록체인 상에서 `sandwiches[_index]`을 영구적으로 변경한다. 

    // 단순히 복사를 하고자 한다면 `memory`를 이용하면 된다: 
    Sandwich memory anotherSandwich = sandwiches[_index + 1];
    // ...이 경우, `anotherSandwich`는 단순히 메모리에 데이터를 복사하는 것이 된다. 
    // 그리고 
    anotherSandwich.status = "Eaten!";
    // ...이 코드는 임시 변수인 `anotherSandwich`를 변경하는 것으로 
    // `sandwiches[_index + 1]`에는 아무런 영향을 끼치지 않는다. 그러나 다음과 같이 코드를 작성할 수 있다: 
    sandwiches[_index + 1] = anotherSandwich;
    // ...이는 임시 변경한 내용을 블록체인 저장소에 저장하고자 하는 경우이다.
  }
}

 

7. 함수 접근 제어자

-Internal과 External

public과 private 이외에도 솔리디티에는 internal과 external이라는 함수 접근 제어자가 있다.

internal은 함수가 정의된 컨트랙트를 상속하는 컨트랙트에서도 접근이 가능하나는 점을 제외하면 private과 동일하다.

external은 함수가 컨트랙트 바깥에서만 호출될 수 있고 컨트랙트 내의 다른 함수에 의해 호출될 수 없다는 점을 제외하면 public과 동일하다. 

internal이나 external 함수를 선언하는 건 private과 public 함수를 선언하는 구문과 동일하다.

contract Sandwich {
  uint private sandwichesEaten = 0;

  function eat() internal {
    sandwichesEaten++;
  }
}

contract BLT is Sandwich {
  uint private baconSandwichesEaten = 0;

  function eatWithBacon() public returns (string) {
    baconSandwichesEaten++;
    // eat 함수가 internal로 선언되었기 때문에 여기서 호출이 가능하다 
    eat();
  }
}

 

8. 다른 컨트랙트와 상호작용하기(인터페이스)

블록체인 상에 있으면서 우리가 소유하지 않은 컨트랙트와 우리 컨트랙트가 상호작용을 하려면 우선 인터페이스를 정의해야 한다.

contract LuckyNumber {
  mapping(address => uint) numbers;

  function setNum(uint _num) public {
    numbers[msg.sender] = _num;
  }

  function getNum(address _myAddress) public view returns (uint) {
    return numbers[_myAddress];
  }
}

이 컨트랙트는 아무나 자신의 행운의 수를 저장할 수 있는 간단한 컨트랙트이고, 각자의 이더리움 주소와 연관이 있을 것이다.

이 주소를 이용해서 누구나 그 사람의 행운의 수를 찾아볼 수 있다.

 

getNum 함수를 이용하여 이 컨트랙트에 있는 데이터를 읽고자 하는 external 함수가 있다고 해보자.

먼저 LuckyNumber 컨트랙트의 인터페이스를 정의할 필요가 있다.

contract NumberInterface {
  function getNum(address _myAddress) public view returns (uint);
}

약간 다르지만, 인터페이스를 정의하는 것은 컨트랙트를 정의하는 것과 유사하다.

먼저, 다른 컨트랙트와 상호작용하고자 하는 함수만을 선언할 뿐(이 예시에서는 getNum) 다른 함수나 상태 변수를 언급하지 않는다.

또, 함수 몸체를 정의하지 않는다. 중괄호{} 를 쓰지 않고 함수 선언을 세미콜론(;)으로 간단하게 끝낸다.

인터페이스는 컨트랙트 뼈대처럼 보인다고 할 수 있다. 컴파일러도 그렇게 인터페이스를 인식한다.

우리의 dapp 코드에 이런 인터페이스를 포함하면 컨트랙트는 다른 컨트랙트에 정의된 함수의 특성, 호출 방법, 예상되는 응답 내용에 대해 알 수 있게 된다. 

 

9. 인터페이스 활용하기

contract NumberInterface {
  function getNum(address _myAddress) public view returns (uint);
}

이와 같이 인터페이스가 정의되면 다음과 같이 컨트랙트에서 인터페이스를 이용할 수 있다.

contract MyContract {
  address NumberInterfaceAddress = 0xab38...
  // ^ 이더리움상의 FavoriteNumber 컨트랙트 주소이다
  NumberInterface numberContract = NumberInterface(NumberInterfaceAddress)
  // 이제 `numberContract`는 다른 컨트랙트를 가리키고 있다.

  function someFunction() public {
    // 이제 `numberContract`가 가리키고 있는 컨트랙트에서 `getNum` 함수를 호출할 수 있다:
    uint num = numberContract.getNum(msg.sender);
    // ...그리고 여기서 `num`으로 무언가를 할 수 있다
  }
}

10. 다수의 반환값 처리하기

예시를 통해 다수의 반환값을 처리하는 방법을 알아보자

function multipleReturns() internal returns(uint a, uint b, uint c) {
  return (1, 2, 3);
}

function processMultipleReturns() external {
  uint a;
  uint b;
  uint c;
  // 다음과 같이 다수 값을 할당한다:
  (a, b, c) = multipleReturns();
}

// 혹은 단 하나의 값에만 관심이 있을 경우: 
function getLastReturnValue() external {
  uint c;
  // 다른 필드는 빈칸으로 놓기만 하면 된다: 
  (,,c) = multipleReturns();
}

11. 기타

솔리디티에서 if문은 자바스크립트의 if문과 동일하다

function eatBLT(string sandwich) public {
  // 스트링 간의 동일 여부를 판단하기 위해 keccak256 해시 함수를 이용해야 한다는 것을 기억하자 
  if (keccak256(sandwich) == keccak256("BLT")) {
    eat();
  }
}

 

Comments