여기서부터는 정리를 안하면 까먹을 듯 해서..
디앱이 다른 어플리케이션과 다른 점은 한번 배포를 하고나면 더이상 수정이나 업데이트를 할 수 없다
는 점이다.
이 특징은 컨트랙트에 신뢰성
을 주기도 하지만 또한 이로인해 솔리디티에서는 보안성
이 아주아주 중요해지는 이유이기도 하다. 배포를 한 코드에 결함이 있다면 이를 고칠 수 있는 방법이 전혀 없기 때문이다.
외부 컨트랙트를 참조해서 나의 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);
}
// 이하 메소드 생략
}
위의 코드를 보면 setKittyContractAddress
함수는 external로 선언되어 누구든지 해당 함수를 실행시킬 수 있는 상태이다. 하지만 이렇게 컨트랙트가 참조하고 있는 외부컨트랙트의 주소를 아무나 변경할 수 있도록 두는 것은 보안상 매우 좋지않다.
이를 해결하기 위한 방법으로 컨트랙트를 소유가능
하도록 만드는 것이 있다.
컨트랙트를 소유가능하게 한 뒤, 해당 컨트랙트의 소유자만 함수를 실행할 수 있게끔 하는 것이다.
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);
}
// 이하 메소드 생략
}
솔리디티로 작성한 DApp의 함수를 실행할 때마다 실행한 사용자는 이더리움 네트워크의 EVM을 사용한 수수료 개념으로 가스
를 지불해야 한다.
함수를 실행할 때 필요한 가스는 해당 함수의 로직이 얼마나 복잡한지에 따라 달라진다. 연산을 수행할 때 필요한 컴퓨터 자원의 양이 많을수록 더 많은 가스비를 지불해야한다.
이런 이유로 코드최적화가 다른 언어들보다 더 필요하다. 줄일 수도 있었을 필요없는 수수료를 사용자들이 매번 써야한다면 굉장한 낭비일 것이다.
왜 이런 가스를 소모하도록 한 것일까?
우리가 배포한 DApp은 결과적으로 이더리움 네트워크의 EVM에서 실행된다. 어떤 함수를 실행하고자 할 때 네트워크 상의 모든 개별 노드가 이 함수를 실행하여 결과값을 검증하는 작업을 하게된다.
그렇다면 누군가 악의적으로 무한반복문이나 자원 소모가 큰 연산을 써서 올리게되면 어떻게 될까? 네트워크가 마비될 것이다.
이런 경우를 방지하기 위하여 저장공간을 사용하거나, 연산을 처리할 때마다 가스가 발생하도록 한 것이다.
솔리디티에서 사용되는 숫자 타입에는 uint
가 있다.
uint는 기본적으로 256비트의 공간을 미리 잡아두게 된다.
하지만 이 공간을 줄여서 잡을 수 있도록 하여 효율적인 메모리 사용을 할 수 있게도 할 수 있다.
기본적으로 변수의 타입을 uint8이나 uint32 등으로 지정하는 것은 사실 아무 영향을 미치지 못한다. 솔리디티는 uint 타입은 무조건 256비트의 공간을 잡아두기 때문이다.
하지만 한가지 예외가 있는데 바로 구조체
안에서 압축된 uint 타입으로 선언된 변수의 경우이다. 이런 경우에는 더 작은 크기의 uint를 사용하여 적은 메모리를 차지하게 한다!
따라서 구조체 안에서는 가능한 한 작은 크기의 정수 타입을 사용하도록
하는 것이 효율적이다.
또한 동일한 데이터 타입의 필드를 연속해서 적어 묶어두는 것
이 좋다. 이런 식으로 구조체를 선언해야 사용하는 저장공간을 최적화하여 적은 가스를 소모하기 때문이다.
기존에 만들었던 Zombie 구조체의 속성을 변경해보자.
struct Zombie {
string name;
uint dna;
}
좀비에 level
과 readyTime(다음 먹이 먹을 때까지 쿨타임)
이라는 새로운 특징을 추가할 것이다.
struct Zombie {
string name;
uint dna;
uint32 level;
uint32 readyTime;
}
uint32인 두 속성은 연속해서 적어 효율적으로 저장공간을 사용할 수 있도록 하였다.
솔리디티는 기본적으로 시간에 대한 단위계를 제공한다.
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);
}
}
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 포인터
타입으로 인자를 지정해주자.
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로 변경해준다.
위에서 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 이상일 때에만 함수가 실행된다.
view 함수는 블록체인 상에서 어떤 상태도 변경하지 않는 (트랜잭션을 날리지 않는) 함수이다.
함수가 단순히 블록체인에 있는 컨트랙트의 값을 읽어오기만 할 때 이 함수가 읽기전용임을 명시해주는 external view
를 제어자로 붙여 명시해줌으로써 불필요한 gas의 지출을 막을 수 있다.
storage를 이용한 연산은 많은 가스를 소모한다.
데이터를 쓰거나 바꿀 때 이 데이터가 블록체인에 영구적으로 기록되기 때문이다. 정말 필요한 경우가 아니면 storage에 데이터를 쓰지 않는 것이 좋다.
이 때문에 겉보기엔 비효율적으로 코드를 짜야할 때도 있다.
(어떤 배열에서 내용을 빠르게 찾기 위해, 단순히 변수에 저장하는 것 대신 함수가 호출될 때마다 배열을 memory에 다시 만드는 방식 사용)
위의 챕터 10, 11과 함게 이번 챕터에서 account를 입력하면 해당 account가 가지고 있는 좀비의 배열을 리턴하는 함수를 만들어보자.
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배열에 담는 방식이다.
사실은 위의 함수를 이용하는 방식이 아닌 아래처럼 코드를 작성할 수도 있다.
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유저에게 전달해주려고 한다고 생각해보자
이 작업을 조금 더 자세히 나누면 아래와 같다.
모든 좀비를 한 칸씩 움직인다.
👈 가스의 소모가 아주 많을 것!배열에서 shift가 일어나면 전체 배열이 새로 쓰여야한다.
storage로 기록되어있는 ownerToZombies에서 좀비 하나가 빠지게 된다면 해당 좀비 이후의 인덱스들이 한칸씩 앞으로 당겨 새로 쓰여져야하며, 이 과정에서 많은 가스가 소모된다.
방법1을 사용한다면?
방법1을 사용한다면 배열은 사용되지 않는다. storage로 기록된 데이터들은 좀비-주인
으로만 매핑되어 있으므로 A의 소유였던 좀비를 B의 소유로 변경하고 싶다면 zombieToOwner
에서 해당 좀비의 ID를 찾아 account만 변경해주면 된다. 한번만 새로 쓰여지면 그 이상의 가스비 지출이 불필요하다!
💫 이상 레슨3 끝!