스마트 컨트랙트와 상호작용하기

수호 첫 TF팀의 카페 NFT 프로젝트 여정기
수호 첫 TF팀의 카페 NFT 프로젝트 여정기 ep.4

카페 NFT 프로젝트의 막바지입니다. 지난 포스팅에서 컨트랙트 코드 작성은 모두 끝났으니, 이제 프론트엔드(React)에서 고객에게 재료를 발급해주고, 재료를 모아 커피 NFT로 교환하는 것을 구현해 보는 것으로 WEB3 프론트엔드 개발 포스트를 마무리하겠습니다.


먼저, React와 Caver.js를 활용하여 지금까지 작성한 컨트랙트를 프론트엔드와 통신하기 위한 기본적인 세팅을 진행해보겠습니다.

Create-React-App!

npx create-react-app sooho-cafe —template typescript

먼저 create-react-app으로 새 리액트 프로젝트를 만들어 주겠습니다. 컴포넌트 스타일링은 Material UIstyled-components를 사용하였는데요, 여기서는 중요한 내용이 아니니 자세한 설명은 생략하겠습니다.

npm install caver-js

caver-js는 클레이튼의 노드와 상호작용할 수 있게 도와주는 API 라이브러리입니다. 클레이튼은 이더리움 동일성을 지원하는데요, 이더리움 생태계에서 동작하는 도구는 클레이튼 생태계에서도 문제없이 작동한다고 생각하시면 됩니다.

그런데 caver-js를 설치하고 난 후 리액트 프로젝트를 실행하려고 하면, 에러들이 무지막지하게 나타납니다... 실제로 리액트에서 caver-js API를 사용하기 전에 해줘야 할 작업이 조금 있습니다.

먼저 아래 라이브러리들을 모두 설치해 주세요.

npm install --save-dev react-app-rewired crypto-browserify stream-browserify assert stream-http https-browserify os-browserify url buffer process

그 다음, 루트 폴더에 config-overrides.js 파일을 생성한 후 아래 코드를 붙여넣어 주세요.

const webpack = require('webpack');

module.exports = function override(config) {
    const fallback = config.resolve.fallback || {};
    Object.assign(fallback, {
        "crypto": require.resolve("crypto-browserify"),
        "stream": require.resolve("stream-browserify"),
        "assert": require.resolve("assert"),
        "http": require.resolve("stream-http"),
        "https": require.resolve("https-browserify"),
        "os": require.resolve("os-browserify"),
        "url": require.resolve("url"),
        "fs": false
    })
    config.resolve.fallback = fallback;
    config.plugins = (config.plugins || []).concat([
        new webpack.ProvidePlugin({
            process: 'process/browser',
            Buffer: ['buffer', 'Buffer']
        })
    ])
    return config;
}

마지막으로 package.jsonscripts 부분을 다음과 같이 수정해 주세요.

...
"scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "test": "react-app-rewired test",
		...
},

이제 caver-js를 사용하기 위한 모든 준비가 끝났습니다! 참고로 web3.js 라이브러리를 사용하고자 하시는 분들도 비슷한 방식으로 적용할 수 있습니다.

Caver 세팅하기

이전 포스트에서 배포했던 수호 카페 Contract와 통신하기 위해 Caver를 세팅해 보도록 하겠습니다.

klaytn api service(kas)에서 제공하는 노드를 사용할 수도 있지만, 테스트넷에서 빠르게 테스트해보기 위해 퍼블릭 노드를 활용하며 진행합니다. 퍼블릭 노드 엔드포인트 목록 참고

const BAOBAB_URL = '<https://api.baobab.klaytn.net:8651/>'
const caver = new Caver(new Caver.providers.HttpProvider(BAOBAB_URL))

위와 같이 바오밥 테스트넷의 퍼블릭 엔드포인트 노드에 접근할 수 있습니다. 이제 본격적으로 지난 포스트에서 배포했던 CafeMenu, ProductFactory 컨트랙트와 상호작용하기 위한 Contract 인스턴스를 생성해 보겠습니다.

import { CafeMenuAbi } from 'caver/abi/CafeMenu.abi'
import { ProductFactoryAbi } from 'caver/abi/ProductFactory.abi'

const CafeMenuAddress = process.env.REACT_APP_CAFEMENU_CONTRACT_ADDRESS
const ProductFactoryAddress = process.env.REACT_APP_PRODUCT_FACTORY_CONTRACT_ADDRESS

const CafeMenuContract = caver.contract.create(CafeMenuAbi, CafeMenuAddress)
const ProductFactoryContract = caver.contract.create(ProductFactoryAbi, ProductFactoryAddress)

스마트 컨트랙트와 상호작용하기 위해서는 두 가지가 필요한데요, 컨트랙트 주소abi 파일 입니다.

abi (Application Binary Interface)는 스마트 컨트랙트와 상호작용하기 위한 인터페이스 파일로서, 컨트랙트에 어떤 함수들이 있는지, 그 함수들을 호출하기 위해서 어떤 인자가 필요하며 어떤 값이 리턴되는지 등 스마트 컨트랙트의 전반적인 내용이 기술되어 있습니다.

컨트랙트의 주소abi 파일hardhat 프로젝트 폴더의 deployments/baobab 폴더의 {컨트랙트명}.json 파일에서 확인할 수 있습니다.

이제 스마트 컨트랙트와 상호작용할 준비는 끝났습니다! 이제 caver.js를 통해 스마트 컨트랙트에 저장된 정보를 조회하고, 트랜잭션을 전송해 보도록 하겠습니다🔥


가지고 있는 재료 & 커피 NFT 확인하기

먼저, 유저가 커피의 재료(물, 샷, 우유 등..)를 얼마나 가지고 있는지 확인해 보겠습니다. 유저가 재료를 얼마 나 가지고 있는지 일괄적으로 보여주는 materialTokensOf 함수는 ProductFactory 컨트랙트에 구현해 두었습니다.

function materialTokensOf(address _owner) public view returns (MaterialToken[] memory){
    return ownerToTokens[_owner];
}

유저와 관리자의 지갑 주소는 미리 얻어두었다고 가정하고, 두 번째 포스트에서 만들어 두었던 ProductFactoryContract 인스턴스를 활용하여 컨트랙트와 본격적으로 상호작용해 보겠습니다. userAddress 를 가져오는 방법은 다음번 번외  포스팅에서 자세히 말씀드리겠습니다. (feat. useKlip)

// userAddress = "0x123123abcabc..."
const materialTokens = await ProductFactoryContract
		.methods
		.materialTokensOf(userAddress)
		.call();

컨트랙트의 view function을 실행하고자 할 때는 .call() 로, view가 아닌 다른 함수들을 실행하고자 할 때는 .send() 로 함수를 실행해 주어야 합니다. 재료 목록을 조회하는 함수는 view function이므로 .call() 로 함수를 호출하였습니다.

유저가 가지고 있는 커피 NFT 목록을 불러오는 함수 또한 동일한 방법으로 호출할 수 있습니다.

// userAddress = "0x123123abcabc..."
const productTokens = await ProductFactoryContract
		.methods
		.productTokensOf(userAddress)
		.call();

하지만 아직까지는 재료나 커피 NFT를 고객에게 발급해 주지 않았으니, 두 함수 모두 호출 결과가 빈 배열 뿐일 것입니다. 이제는 실제로 고객에게 재료와 커피 NFT를 발급해 보도록 해보겠습니다.

고객에게 재료 발급해주기

위에서 컨트랙트의 view function을 실행할 때는 .call() 로, view가 아닌 함수를 실행하고자 할 때는 .send() 로 함수를 실행해 주어야 한다고 했었죠?

고객에게 재료를 발급해주거나 재료를 커피 NFT로 교환해 주는 등의 과정은 모두 컨트랙트에 쓰기 작업이 필요한 함수이므로 view function이 아닙니다. 따라서 .send()issueMaterialToken 함수를 호출해 보겠습니다.

// ownerAddress = "0xfffeeedddccc..."
// userAddress = "0x123123abcabc..."
const receipt = await ProductFactoryContract
		.methods
		.issueMaterialToken(userAddress)
		.send({
				from: ownerAddress,
				// gas: 250E9,
				// gasLimit: ???
		});

send 로 함수를 호출할 때는 from, gasLimit, gasPrice 등 몇 가지 property를 지정해 주어야 합니다.

  • from : 컨트랙트 함수를 호출하는 EOA의 주소
  • gasPrice : 가스의 단위 가격. 클레이튼에서는 250ston (=250 * 10^9 peb)으로 고정되어 있어 따로 명시해 주지 않아도 됩니다.
  • gasLimit : 트랜잭션을 처리하기 위해 사용할 최대 가스량을 지정합니다.

from 값은 관리자의 지갑 주소를 넣으면 되고, 클레이튼에서는 gasPrice를 넣지 않아도 됩니다. 그런데… gasLimit는 어떻게 지정해 주어야 할까요?

web3.jscaver.js에서는 모두 트랜잭션 처리에 소요되는 가스량을 계산해주는 estimateGas 함수를 제공합니다. 사용 방법은 .send()로 함수를 호출하는 방법과 거의 동일합니다!

const gas = await ProductFactoryContract
		.methods
		.issueMaterialToken(userAddress)
		.estimateGas({
				from: ownerAddress
		});

정말 send 함수와 거의 동일한 방식이죠? 이렇게 구한 gas값을 gasLimit값으로 설정해 주어도 되고, estimateGas 함수를 사용하지 않고자 한다면 특정 상수 값을 gasLimit으로 지정해 주어도 됩니다.

단, gasLimit을 트랜잭션에 필요한 가스 양보다 적게 잡으면 Out of Gas 에러로 인해 트랜잭션이 실행되지 않을 수 있고, 너무 크게 잡아 Block Gas Limit을 넘기게 되면 트랜잭션이 블록에 담기지 못할 수 있으니 적정한 값으로 설정해 주는 것이 중요합니다.

view function이 아닌 함수를 실행하면, 즉 send()로 함수를 실행하면 함수의 리턴값은 컨트랙트 함수의 리턴값이 아닌 ContractReceipt 객체가 리턴됩니다. ContractReceipt 객체는 대략 이렇게 생겼습니다.

{
   "transactionHash": "0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b",
   "transactionIndex": 0,
   "blockHash": "0xef95f2f1ed3ca60b048b4bf67cde2195961e0bba6f70bcbea9a2c4e133e34b46",
   "blockNumber": 3,
   "contractAddress": "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe",
   "gasUsed": 30234,
   "events": {
     "MyEvent": {
       returnValues: {
         myIndexedParam: 20,
         myOtherIndexedParam: '0x123456789...',
         myNonIndexParam: 'My String'
       },
       raw: {
         data: '0x7f9fade1c0d57a7af66ab4ead79fade1c0d57a7af66ab4ead7c2c2eb7b11a91385',
         topics: ['0xfd43ade1c09fade1c0d57a7af66ab4ead7c2c2eb7b11a91ffdd57a7af66ab4ead7', '0x7f9fade1c0d57a7af66ab4ead79fade1c0d57a7af66ab4ead7c2c2eb7b11a91385']
       },
       event: 'MyEvent',
       signature: '0xfd43ade1c09fade1c0d57a7af66ab4ead7c2c2eb7b11a91ffdd57a7af66ab4ead7',
       logIndex: 0,
       transactionIndex: 0,
       transactionHash: '0x7f9fade1c0d57a7af66ab4ead79fade1c0d57a7af66ab4ead7c2c2eb7b11a91385',
       blockHash: '0xfd43ade1c09fade1c0d57a7af66ab4ead7c2c2eb7b11a91ffdd57a7af66ab4ead7',
       blockNumber: 1234,
       address: '0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe'
    },
    "MyOtherEvent": {
      ...
    },
    "MyMultipleEvent":[{...}, {...}] // If there are a multiple of the same events, they will be in an array.
  }
}

만약 이 트랜잭션이 제대로 실행되었는지 검증하고자 하거나, 또는 자세한 트랜잭션 내용을 보고자 할 때에는 블록 탐색기를 이용할 수 있습니다. 각 메인넷 네트워크 별로 블록 탐색기가 있는데, 대표적인 블록 탐색기로는 이더리움의 Etherscan과 클레이튼의 KlaytnScope 가 있습니다.

블록 탐색기에 들어가면 트랜잭션 정보 뿐만 아니라, 컨트랙트 주소나 지갑 주소 등을 검색하여 각각에 대한 상세한 정보도 얻을 수 있습니다. 컨트랙트를 개발할 때 가장 필수적인 서비스죠!

재료를 커피 NFT와 교환하기

고객에게 재료를 발급해주는 과정은, 카페 관리자의 지갑 주소로 트랜잭션에 서명해야 하기 때문에 관리자용 기기에서 컨트랙트 함수를 호출해 주어야 합니다. 반면 고객이 재료를 모은 뒤 커피 NFT로 교환하는 과정은 고객의 기기에서 직접 호출해 주어야 한다는 차이점이 있습니다.

재료를 커피 NFT로 교환하는 작업은 모두 컨트랙트 상에서 일어나고 있으니, 프론트에서는 재료를 발급해 줄 때와 비슷하게 적절한 함수 인자와 함께 컨트랙트 상의 함수만 잘 호출해 주면 됩니다. 과정은 이전에 고객에게 재료 발급해주는 과정과 거의 유사합니다.

*어떤 재료를 어느 NFT로 교환해 줄지 정하는 로직은 프론트 상에서 구현이 되었다고 가정하겠습니다.

프론트에서 컨트랙트 함수를 호출하기 전에 컨트랙트 함수 내용부터 다시 보겠습니다. 함수명은 exchangeMaterialsToProduct, 인자는 _productId 가 들어가는군요. 고객이 직접 자신이 가진 재료를 NFT로 교환하는 함수입니다.

function exchangeMaterialsToProduct(uint _productId) external {
    CafeMenu.Recipe[] memory recipes = cafeMenu.getRecipes(_productId);
    _payMaterials(recipes);
    _createProductToken(msg.sender, _productId);
}

이전에 issueMaterialToken 함수를 호출할 때와 동일한 과정으로 다시 함수를 호출하였습니다. gasLimit 도 동일하게 estimateGas 함수를 통해 얻을 수 있겠죠?

const targetProductId = 0;
const receipt = await ProductFactoryContract
		.methods
		.exchangeMaterialsToProduct(targetProductId)
		.send({
				from: userAddress,
				gasLimit: XXX,
				// gas: 250E9,
		});

드디어 이제 고객의 지갑 주소를 가져와서, 고객이 가진 재료나 NFT 양을 조회하는 함수, 그리고 재료를 직접 발급해주고 재료들을 NFT로 교환하는 과정까지 모두 구현이 완료되었습니다!


돌아보기

어느덧 수호에 입사하고, 스마트 컨트랙트 세상에 발을 담근 지 3개월 정도의 시간이 흘렀습니다. 수호 카페 NFT 프로젝트는 제가 처음으로 개발하고 배포한 컨트랙트라서 그런지 더 마음이 가기도 하고, 한편으로는 좀 더 폭넓게 공부했더라면, EIP를 더 참고했더라면, 더 깔끔하고 Solidity스러운 코드를 작성할 수 있었을 텐데 하는 아쉬움이 많이 남기도 합니다.

하지만 누구나 다 처음 배울 때 겪는 시행착오가 있고, 그런 경험을 지속적으로 쌓아 나가면서 더 단단해지는 것이 아닐까요? 실제로 업무하면서 카페 NFT 개발할 때 했던 삽질이 큰 도움이 되는 경우도 많았고요😎

어찌 됐든, 작은 토이 프로젝트로 시작했던 카페 NFT 프로젝트 포스트는 이 정도로 마무리하겠습니다. 곧 수호 카페 웹사이트도 공개되고, 실제 카페에서도 사용해 볼 예정이니 기대해주세요!

더불어 컨텐츠 작성에 여러모로 신경 써주신 seney, 수호 카페 웹사이트 디자인을 맡아주신 joojoo, 카페 NFT에 대한 좋은 인사이트를 주신 chacha, 부족한 컨트랙트 코드였지만 코드 리뷰를 도와주신 ethan, dg까지!

바쁘신 와중에도 제 작은 프로젝트를 위해 여러모로 도움 주신 많은 수호자 분들께 진심으로 감사드립니다! 🙏🙏

Cafe NFT Project 시리즈 모아보기
수호 카페에 NFT가 생긴다면?
컨트랙트 개발 시작 - ERC721Enumerable
컨트랙트 코드 리뷰 - 가스비 절약하기
스마트 컨트랙트와 상호작용하기