이 포스트는 Cryptozombies의 레슨을 따라하며 정리한 글입니다.
이전 포스트에서는
web3.js로 프로젝트를 시작하고,
스마트 컨트랙트와 통신해 함수를 호출하고,
메타마스크에서 사용자 계정을 가져오는 것을 진행했다.
이번 포스트에서는 간편한 jQuery
를 이용해서
스마트 컨트랙트에서 전달받은 데이터를 어떻게 파싱하고 표현하는지 실습해볼 것이다.
React를 사용한 web3-react는 이 다음 포스트에서 시작한다.
var cryptoZombies;
var userAccount;
function startApp() {
var cryptoZombiesAddress = "YOUR_CONTRACT_ADDRESS";
cryptoZombies = new web3js.eth.Contract(cryptoZombiesABI, cryptoZombiesAddress);
var accountInterval = setInterval(function() {
if(web3.eth.accounts[0] !== userAccount) {
userAccount = web3.eth.accounts[0];
getZombiesByOwner(userAccount)
.then(displayZombies);
}
},100);
}
function getZombieDetails(id) {
return cryptoZombies.methods.zombies(id).call()
}
function zombieToOwner(id) {
return cryptoZombies.methods.zombieToOwner(id).call()
}
function getZombiesByOwner(owner) {
return cryptoZombies.methods.getZombiesByOwner(owner).call()
}
window.addEventListener('load', function() {
if (typeof web3 !== 'undefined') {
web3js = new Web3(web3.currentProvider);
} else {
}
startApp()
})
주기적으로 계정을 확인하는 setInterval 함수 안에서는
바뀐 계정이 현재 계정과 같지 않다면 계정을 바꿔주고,
유저의 좀비를 불러오는 함수 getZombiesByOwner에 해당 계정을 보내고
실행에 성공한다면 displayZombies 함수를 실행했다.
그럼 지난 포스트에서 만들지 않은 displayZombies 함수를 만들어보자.
<body>
<div id="zombies"></div>
<script>
var cryptoZombies;
var userAccount;
function startApp() {
var cryptoZombiesAddress = "YOUR_CONTRACT_ADDRESS";
cryptoZombies = new web3js.eth.Contract(cryptoZombiesABI, cryptoZombiesAddress);
var accountInterval = setInterval(function() {
if(web3.eth.accounts[0] !== userAccount) {
userAccount = web3.eth.accounts[0];
getZombiesByOwner(userAccount)
.then(displayZombies);
}
},100);
}
function displayZombies(ids) {
$("zombies").empty();
for(id of ids) {
getZombieDetails(id)
.then(function(zombie) {
$("#zombies").append(`<div class="zombie">
<ul>
<li>Name: ${zombie.name}</li>
<li>DNA: ${zombie.dna}</li>
<li>Level: ${zombie.level}</li>
<li>Wins: ${zombie.winCount}</li>
<li>Losses: ${zombie.lossCount}</li>
<li>Ready Time: ${zombie.readyTime}</li>
</ul>
</div>`);
});
}
}
function getZombieDetails(id) {
return cryptoZombies.methods.zombies(id).call()
}
function zombieToOwner(id) {
return cryptoZombies.methods.zombieToOwner(id).call()
}
function getZombiesByOwner(owner) {
return cryptoZombies.methods.getZombiesByOwner(owner).call()
}
window.addEventListener('load', function() {
if (typeof web3 !== 'undefined') {
web3js = new Web3(web3.currentProvider);
} else {
}
startApp()
})
</script>
</body>
이제 우리 UI는 사용자의 메타마스크 계정을 감지하고,
자동으로 좀비 군대를 홈페이지에 표현할 수 있다.
이제 send
메소드로 스마트 컨트랙트의 데이터를 변경해보자.
이 send
함수는 실제로 call
함수와 다른 부분이 있다.
트랜잭션을 전송(send)하려면 함수를 호출한 사람의 from 주소가 필요하다.
(솔리디티 코드에서는 msg.sender
)
함수를 호출한 사람은 내 DApp의 사용자니까, 메타마스크가 열려서 유저에게 서명을 하게 하면 된다.
트랜잭션 전송(send)는 가스를 소모한다.
사용자가 트랜잭션을 전송(send)하고 실제로 블록체인에 적용될 때까지는 상당한 지연이 있다.
이더리움의 평균 블록 시간이 15초이기 때문에, 트랜잭션이 블록에 포함될 때까지 기다려야한다.
만약 이더리움에 보류 중인 거래가 많거나, 사용자가 가스 가격을 지나치게 낮게 보낼 경우는 우리 트랜잭션이 블록에 포함되길 몇분씩 기다려야할 수도 있다.
그러니 이 코드의 비동기적 특성을 다루기 위한 로직이 필요하다.
Q. 🤷🏻♀️ : 아니 몇분씩이라니 너무 불편하잖아! 왜이렇게 느린거야?
A. 맞아. 그래서 이더리움은 변하려고 하고있어! (이더리움 2.0 포스트)
블록체인의 속도 포스트도 참고하면 좋을 거야
우선 사용자가 호출할 우리 컨트랙트 내의 createRandomZombie
함수를 살펴보자.
function createRandomZombie(string _name) public {
require(ownerZombieCount[msg.sender] == 0);
uint randDna = _generateRandomDna(_name);
randDna = randDna - randDna % 100;
_createZombie(_name, randDna);
}
트랜잭션을 보내서, 컨트랙트 안에 있는 이 함수를 호출해야한다.
컨트랙트 내부의 함수를 호출하기 위한 함수는
전송(send)
하기getZombiesByOwner
호출해서 다시 그리기 )function createRandomZombie(name) {
$("#txStatus").text("Creating new zombie on the blockchain. This may take a while...");
return CryptoZombies.methods.createRandomZombie(name)
.send({ from: userAccount })
.on("receipt", function(receipt) {
$("#txStatus").text("Successfully created " + name + "!");
getZombiesByOwner(userAccount).then(displayZombies);
})
.on("error", function(error) {
$("#txStatus").text(error);
})
}
그리고 이 함수는, 우리의 Web3 프로바이더에게 트랜잭션을 전송(send)
하고, 몇 가지 이벤트 리스너들을 연결한다.
receipt
는 트랜잭션이 이더리움의 블록에 포함될 때, 즉 좀비 생성되고 우리의 컨트랙트에 저장되었을 때 발생한다.error
는 트랜잭션이 블럭에 포함되지 못했을 때, 예를 들어 사용자가 충분한 가스를 전송하지 않았을 때 발생한다.
전송(send)
를 호출할 때 gas와 gasPrice를 선택적으로 지정할 수 있다..send({ from: userAccount, gas: 3000000 })
지정하지 않으면 사용자들이 가스비를 선택할 수 있다.
<body>
<div id="txStatus"></div>
<div id="zombies"></div>
<script>
var cryptoZombies;
var userAccount;
function startApp() {
var cryptoZombiesAddress = "YOUR_CONTRACT_ADDRESS";
cryptoZombies = new web3js.eth.Contract(cryptoZombiesABI, cryptoZombiesAddress);
var accountInterval = setInterval(function() {
// 계정이 바뀌었는지 확인
if (web3.eth.accounts[0] !== userAccount) {
userAccount = web3.eth.accounts[0];
// 새 계정에 대한 UI로 업데이트하기 위한 함수 호출
getZombiesByOwner(userAccount)
.then(displayZombies);
}
}, 100);
}
function displayZombies(ids) {
$("zombies").empty();
for(id of ids) {
getZombieDetails(id)
.then(function(zombie) {
$("#zombies").append(`<div class="zombie">
<ul>
<li>Name: ${zombie.name}</li>
<li>DNA: ${zombie.dna}</li>
<li>Level: ${zombie.level}</li>
<li>Wins: ${zombie.winCount}</li>
<li>Losses: ${zombie.lossCount}</li>
<li>Ready Time: ${zombie.readyTime}</li>
</ul>
</div>`);
});
}
}
function createRandomZombie(name) {
$("#txStatus").text("Creating new zombie on the blockchain. This may take a while...");
return CryptoZombies.methods.createRandomZombie(name)
.send({ from: userAccount })
.on("receipt", function(receipt) {
$("#txStatus").text("Successfully created " + name + "!");
getZombiesByOwner(userAccount).then(displayZombies);
})
.on("error", function(error) {
$("#txStatus").text(error);
})
}
function feedOnKitty(zombieId, kittyId) {
$("#txStatus").text("Eating a kitty. This may take a while...");
return CryptoZombies.methods.feedOnKitty(zombieId, kittyId)
.send({ from: userAccount })
.on("receipt", function(receipt) {
$("#txStatus").text("Ate a kitty and spawned a new Zombie!");
getZombiesByOwner(userAccount).then(displayZombies);
})
.on("error", function(error) {
$("#txStatus").text(error);
})
}
function getZombieDetails(id) {
return cryptoZombies.methods.zombies(id).call()
}
function zombieToOwner(id) {
return cryptoZombies.methods.zombieToOwner(id).call()
}
function getZombiesByOwner(owner) {
return cryptoZombies.methods.getZombiesByOwner(owner).call()
}
window.addEventListener('load', function() {
if (typeof web3 !== 'undefined') {
web3js = new Web3(web3.currentProvider);
} else {
}
startApp()
})
</script>
</body>
createRandomZombie
와 동일한 방식으로 feedOnKitty
도 추가해주었다.
이제 Web3.js에서 특별한 처리가 필요한 payable
함수인 levelup
함수를 만들어보자.
배포되어있는 스마트 컨트랙트 안에 있는 ZombieHelper
함수 내부에는,
사용자가 레벨업할 수 있는 곳에 payable
함수가 추가되어있다.
function levelUp(uint _zombieId) external payable {
require(msg.value == levelUpFee);
zombies[_zombieId].level++;
}
함수를 이용해 이더를 보내는 건 매우 간단하지만,
이더가 아니라 wei
로 얼마를 보낼지 정해야하는 제한이 있다.
Solidity는 가상화폐를 다루는 언어로 탄생했기 때문에 가상화폐로 접근하기 위한 키워드인 payable
이 있다.
"이더리움 플랫폼" 위에서
"이더(ether) 코인을 전송"하는
스마트 컨트랙트(smart contract) 를 작성하기 위해서는
payable
키워드를 반드시 사용해야한다.
Payble
을 작성한 함수에서만 이더(ether)를 보낼 수 있고,
payable
을 작성하지 않은 함수에서는 이더(ether)를 보낼 수 없는 것.
스마트 컨트랙트 코드를 이더리움넷(mainnet/testnet)에 배포(deploy)했다면,
그리고 스마트 컨트랙트 외부에서 스마트 컨트랙트 내부에 있는 코인이동(전송) 함수를 사용하려 한다면,
해당 함수는 반드시 payable
키워드가 함께 작성된 함수여야한다.
address 타입과 같이 사용되면 지불가능한 주소(address payable)
의 타입이 되고,
function과 같이 사용하면 지불가능한 함수(payable function)
가 된다.
Payable이 이 작업을 수행하고, 수정자(modifier) Payable이 있는 솔리디티의 모든 기능은 Ether를 보내고 받을 수 있도록 한다.
0이 아닌 Ether 값을 가진 트랜잭션은 처리하고, 0인 트랜잭션은 거부한다.
또한 지불가능한 키워드를 포함하지 않은 경우 트랜잭션은 자동으로 거부한다.
wei
는 이더의 가장 작은 하위 단위로, 하나의 이더는 10^18 개의 wei
이다.
Web3.js에는 wei를 변환해주는 변환 유틸리티가 있다.
web3js.utils.toWei("1");
컨트랙트에서 정의한 levelup 함수에서는 levelUpFee를 0.001 ether로 설정했다.
그래서 levelup 함수를 호출할 때, 아래처럼 사용자가 0.001 이더를 보내게 할 수 있다.
CryptoZombies.methods.levelUp(zombieId)
.send({ from: userAccount, value: web3js.utils.toWei("0.001") })
위에서 설명한 컨트랙트의 levelUp
함수를 호출하는 levelUp 함수를 만들어보자.
function levelUp(zombieId){
$("#txStatus").text("좀비를 레벨업하는 중...");
return CryptoZombies.methods.levelUp(zombieId)
.send({ from: userAccount, value: web3js.utils.toWei("0.001") })
.on("receipt", function(receipt) {
$("#txStatus").text("압도적인 힘! 좀비가 성공적으로 레벨업했습니다.")
.on("error", function(error) {
$("#txStatus").text(error);
});
// 좀비의 레벨만 변하기 때문에, 스마트컨트랙트에 요청하고 UI를 다시 그릴 필요 없다
})
}
보다시피 Web3.js를 통해 컨트랙트와 상호작용하는 것은 간단하다.
한번 컨트랙트를 작성하고 나면, 내부함수를 호출하거나 트랜잭션을 전송하는 것은 일반적인 웹 API와 다르지 않다.
그럼 이제, 컨트랙트에서 이벤트를 구독해보자.
배포되어있는 컨트랙트 중 zombiefactory.sol
파일에는, 새로운 좀비가 생성될 대마다 매번 호출되던 NewZombie
라는 이벤트가 있다.
event NewZombie(uint zombieId, string name, uint dna);
Web3.js에서는 이벤트를 구독하면,
해당 이벤트 발생시마다 Web3 프로바이더가 내 코드 내의 어떤 로직을 실행시킬 수 있다.
cryptoZombies.eventes.NewZombie()
.on("data", function(event) {
let zombie = event.returnValues;
// `event.returnValue` 객체에서 이 이벤트의 세가지 반환 값에 접근할 수 있다.
console.log("새로운 좀비가 태어났습니다!", zombie.zombieId, zombie.name, zombie.dna);
})
.on("error", console.error);
하지만 이 로직은, DApp에서 어떤 사용자의 좀비가 태어나든지 항상 알림을 보낸다.
현재 사용자의 좀비가 새로 태어났을 때만 알림을 보내게 하려면 어떻게 해야할까?
이벤트를 필터링하고 현재 사용자와 연관된 변경만을 수신하기 위해서는 indexed
를 사용해야한다!
배포되어있는 컨트랙트 안에 구현되어있는 Transfer
이벤트의 파라미터에는 indexed 키워드가 사용되어 있다.
event Transfer(address indexed _from, address indexed _to, uint256 _tokenId);
from
과 to
가 indexed
되어 있기 때문에, 우리 프론트엔드의 이벤트 리스너에서 이들을 필터링할 수 있다.
indexed 키워드
솔리디티 컨트랙트 이벤트의 키워드.
우리의 정보가 들어있는 특정 이벤트만 가져오기 위해서 사용한다.
블록들 안에 출력된 이벤트들을 필터링하여 내가 원하는 특정 이벤트만 가져올 수 있다.
필터링하려고 하는 파라미터 값에 indexed만 써주면 된다.
// `filter`를 사용해 `_to`가 `userAccount`와 같을 때만 코드를 실행
cryptoZombies.events.Transfer({ filter: { _to: userAccount } })
.on("data", function(event) {
// 이때의 event는 생성된 좀비의 정보 객체.
let data = event.returnValues;
// 현재 사용자가 방금 좀비를 받았다!
// 해당 좀비를 보여줄 수 있도록 UI 업데이트 처리
}).on("error", console.error);
getPastEvents를 이용해 지난 이벤트들에 대해 요청을 하고,
fromBlock과 toBlock 필터를 이용해 이벤트 로그에 대한 "시간범위"를 솔리디티에 전달할 수 있다.
getPastEvents 함수
: 토큰의 모든 트랜스퍼 이벤트를 조회할 수 있다.
MyEvent
: 특정 이벤트 유형 내에서 필터를 지정할 수 있다 (매개변수 값으로 필터링할 수 있음)
getPastEvents
: 이벤트 유형에 대한 모든 이벤트를 반환한다.
fromBlock과 toBlock
: 이때의 block은 이더리움 블록의 번호를 말한다.
시작블록과 끝블록을 보낼 수 있기 때문에 시간범위를 전달할 수 있는 것
cryptoZombies.getPastEvents("NewZombie", { fromBlock: 0, toBlock: "latest" })
.then(function(events) {
// 이때의 events는, 위에서 했던 것처럼 반복접근할 `event 객체`들의 배열이다.
// 생성된 좀비 정보 객체들의 배열.
// 따라서 이 코드로 우리는 생성된 모든 좀비의 목록을 받을 수 있다
})
getPastEvents 메소드를 사용하면 시작시간부터의 이벤트 로그들에 대해 요청을 할 수 있다.
따라서, 이벤트를 저렴한 storage로 사용할 수 있다 ! 📦📦📦
저렴한 storage?
말 그대로, local storage / Async storage처럼 사용할 수 있는 것.
"데이터를 블록체인에 기록하는 것" 은 솔리디티에서 가장 비싼 비용을 지불하는 작업 중 하나다.
데이터를 블록체인에 비싸게 기록하는 대신 이벤트를 이용하면 가스 측면에서 훨씬 저렴하다.
( 예시 ) 좀비 전투 기록용
좀비가 다른 좀비를 공격할 때마다, 한 좀비가 이길 때마다 이벤트를 생성할 수 있다.
스마트 컨트랙트가 결과를 계산할 때 필요하지 않지만, 사용자들은 앱의 프론트엔드에서 찾아볼 수 있게 된다.
해당 유저의 Transfer
이벤트를 감지하는 코드를 만들어보자!
그리고 현재 사용자가 새로운 좀비를 받았을 때 앱의 UI를 업데이트시켜야한다.
cryptoZombies.events.Transfer
를 수신하는 코드는 startApp()
함수의 끝부분에 추가되어야 한다.
이벤트 리스너를 추가하기 전에 cryptoZombies
컨트랙트가 확실히 초기화될 수 있도록 하기 위해서!
function startApp() {
var cryptoZombiesAddress = "YOUR_CONTRACT_ADDRESS";
cryptoZombies = new web3js.eth.Contract(cryptoZombiesABI, cryptoZombiesAddress);
var accountInterval = setInterval(function() {
if (web3.eth.accounts[0] !== userAccount) {
userAccount = web3.eth.accounts[0];
getZombiesByOwner(userAccount)
.then(displayZombies);
}
}, 100);
cryptoZombies.events.Transfer({ filter: { _to: userAccount } })
.on("data", function(event) {
let data = event.returnValues;
getZombiesByOwner(userAccount)
.then(displayZombies);
}).on("error", console.error);
}
이것으로 스마트 컨트랙트와 상호작용 하는 첫 번째 Web3.js 프론트엔드를 완료했다!
크립토좀비 프로젝트에서는, 스마트 컨트랙트와 상호작용할 때 필요한 핵심 로직만을 사용했다.
매우 기초적인 부분만 다뤄서 전혀 어렵지 않았고, 재밌게 따라했다.
간단하지만 전체적인 개념을 이해하기 정말 좋은 프로젝트라고 생각한다.
(물론 Solidity에 대한 이해 부족이나, 설명없이 지나가는 용어들이 있어 내가 따로 정리를 한 부분도 있지만)
다음에 진행할 web3-react는 조금 더 실무적인 부분에 집중해서 구현해봐야겠다.