Blockchain

#22 Truffle을 이용한 컨트랙트 배포

Sila 2022. 7. 12. 18:53

오늘은 Truffle 프로그램을 이용해 내가 만든 컨트랙트를 네트워크에 배포하는 방법에 대해 알아보자.

 

우선 js 파일을 작성하고 이를 solc를 이용해 컴파일하고, 이를 web3 라이브러리를 이용해

 

블록체인 네트워크 상에 배포하는 것을 하드코딩으로 진행해봄으로써

 

Truffle 프로그램의 존재 이유와 장점을 알아보고,

 

이후 Truffle을 사용하는 것을 연습해보는 식으로 진행한다.

 

1.  하드 코딩으로 컨트랙트 배포 ( w/o Truffle )

web3와 node 내장 라이브러리를 이용해 컨트랙트를 배포해보자.

 

npm init -y
npm i web3 solc

 

컨트랙트는 저번에 사용했던 기본적인 기능만을 갖춘 컨트랙트를 사용한다.

 

/*  contracts/HelloWorld.sol  */

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

contract Hello2 {
    string text;

    constructor() {
        text = "hello world";
    }

    function getText() public view returns (string memory) {
        return text;
    }

    function setText(string memory value) public {
        text = value;
    }
}

// 파일명과 컨트랙트명이 다르다는 것을 기억해둔다.

 

저번 글에서는 솔리디티 언어로 코드를 작성한 다음

 

> 이를 solc 라이브러리로 abi, bytecode(.bin) 를 생성한 후

 

> tx를 이용해 블록체인에 전송했다.

 

그리고 이렇게 할 경우, 만약 컨트랙트를 수정할 경우 solc 라이브러리가 생성해준 abi, bin 파일을 삭제 후

 

다시 반복적인 작업을 수행해야 하는 단점이 있었는데,

 

이제부터는 이런 번거로운 작업을 조금 줄일 수 있도록

 

js파일을 solc와 web3 라이브러리를 이용해 컴파일 해

 

바로 bytecode와 abi를 가진 json 파일을 만들어 이를 배포하는 방식을 사용할 것이다.

 

1.1 sol > json 파일 생성 set up

상술한 방식을 사용해 배포를 진행하려면

 

우선 연결할 블록체인 네트워크와 어떤 파일을 어떻게 컴파일해 어디에 저장할지 셋업하는 작업이 필요하다.

 

controllers 폴더를 만들고 우선 블록체인 네트워크를 지정하는 class를 만들어 export 한다.

 

/*  Controllers/clients.js  */

const Web3 = require('web3')

let instance

class Client {
    constructor(_url) {
        if (instance) return instance
        // 이미 instance 값이 있다면 그 값을 사용
        
        this.web3 = new Web3(_url)
        instance = this
        // 없다면 우리가 준 매개변수 (url) 를 가진 네트워크에 연결한다.
    }
}

module.exports ={ Client }

 

.sol 파일을 컴파일해 json 파일을 만들기 위한 셋업은 다음과 같이 할 수 있다.

 

/*  Controllers/compile.js  */

const solc = require('solc')
const fs = require('fs-extra')
const path = require('path')

class Contract {
    static compile(_filename) {
        const contractPath = path.join(__dirname, '../contracts', _filename)
        // 어떤 파일을 컴파일 할지 그 경로와 파일이름을 알려준다
        
        const data = JSON.stringify({
            language : "Solidity",
            // 어떤 언어로 작성한 파일을 컴파일 할건지 지정
            sources : {
                [_filename] : {
                    content : fs.readFileSync( contractPath, 'utf8')
                    // 어디의 어떤 파일을 어떤 형식으로 읽어올 것인지 지정
                }
                // 여러 개가 있다면 여러 개를 쓰면 된다
            },
            settings : {
                outputSelection : {
                    "*" : {
                        "*" : ["*"]
                    }
                }
            }
            // settings 항목은 그냥 이렇게 써주면 된다
        })
        const compiled = JSON.parse(solc.compile(data))
        // 위에 작성한 data 변수를 solc 라이브러리의 compile 메소드로 변환해
        // compiled 변수에 담는다.
        
        return Contract.writeOutput(compiled)
        // 필요한 정보를 담은 compiled 변수를 가지고 writeOutput 함수를 거쳐 
        // abi, bytecode를 만들고, 이들을 포함한 JSON을 만들어 파일로 저장한다.
    }
    
    static writeOutput(_compiled) {
        // 위에서 solc.compile 메소드를 거치면 필요한 데이터를 가진 객체를 돌려주는데,
        // 이 중 필요한 것만 가져다 JSON에 넣으면 된다.
        
        console.log(_compiled)
    }
}

Contract.compile('HelloWorld.sol')

 

우선 이렇게 작성하고 node로 compile.js를 실행하면

 

{
  contracts: { 'HelloWorld.sol': { Hello: [Object] } },
  sources: { 'HelloWorld.sol': { id: 0 } }
}

 

다음과 같은 객체를 돌려준다. 

 

이중 contracts 객체 안에 우리에게 필요한 컨트랙트와 abi, bytecode등이 들어있다. 다음과 같이 꺼내서 사용하면 된다.

 

객체 자체의 구조가 매우 복잡하므로 그냥 필요한 데이터가 여기에 있구나 정도만 알고 넘어가면 된다.

 

/*  Controllers/compile.js  */

// ...중략

static writeOutput (_compiled) {
    for ( const contractFilename in _compiled.contracts) {
        const [ contractName ] = Object.keys(_compiled.contracts[contractFilename])
        
        const contract = _compiled.contracts[contractFilename][contractName]
        
        const abi = contract.abi
        const bytecode = contract.evm.bytecode.object
        
        const obj = {
            abi,
            bytecode
        }
        // 컴파일된 결과물에서 abi, bytecode만 가져온다
        
        const buildPath = path.join(__dirname, '../build', `${contractName}.json`)
        // 데이터를 모은 파일을 어디에 생성해줄지 그 경로와 파일명을 정한다.
        // 난 build 폴더 안에 생성해주고 싶으므로 루트디렉토리에 build 폴더를 생성해둔다.
        
        fs.outputJSONSync(buildPath, obj)
        // 설정에 맞춰 파일을 생성한다.
        
        return [ abi, bytecode ]
        // 컨트랙트의 abi, bytecode를 결과값으로서 반환한다.
    }
}

module.exports = { Contract }

// Contract class의 함수가 잘 되는지 테스트가 끝났다면 Contract.compile 함수 실행 코드는 지울 것

 

루트 디렉토리에 build 폴더를 생성해준 후, 다음으로 넘어간다.

 

1.2 파일 생성, 배포

이제 이렇게 설정한 상태에서 블록체인 네트워크를 실행한다. (geth, attach)

 

eth --datadir node --http --http.addr "0.0.0.0" --http.port 9000 --http.corsdomain "*" --http.api "admin,eth,debug,miner,net,txpool,personal,web3" --syncmode full --networkid 7019 --port 30300 --ws --ws.addr "0.0.0.0" --ws.port 9005 --ws.origins "*" --ws.api "miner,eth,net,web3" --allow-insecure-unlock --unlock "0,1" --password "./node/password" --ipcpath "~/.Ethereum/geth.ip"

 

geth attach http://localhost:9000

 

네트워크가 돌아가는 상태에서 HelloWorld.sol 파일을 컴파일해 json 파일을 생성하고

 

그 과정에서 생성된 abi, bytecode를 블록체인 네트워크로 전달해 컨트랙트를 배포해보자.

 

/*  index.js  */

const { Contract } = require('./controllers/compile.js')
const { Client } = require('./controllers/clients.js')

const [abi, bytecode] = Contract.compile('HelloWorld.sol')
// 컨트랙트의 abi, bytecode를 만든다

const client = new Client('ws://127.0.0.1:9005')
// tx를 보낼 네트워크

const txObject = {
    data : bytecode
}
// 바이트 코드만을 txObject에 포함시킨다

const contract = new client.web3.eth.Contract(abi)

// 배포 과정
contract
.deploy(txObject)
.send({ from : '0xa4fad52aad9d5548db57603a21bc7d6d457ddbcc' })
// 여기까지 실행하면 txpool에 컨트랙트와 tx가 들어간다.
// 채굴을 통해 컨트랙트를 포함한 tx가 블록에 담기면 다음 .then이 실행된다.
.then((instance) => {
    console.log(instance.options.address)
    // 블록이 담기면서 생성된 CA를 출력한다.
    // 0x235C577edc42863bDB8662915AD5b841F6e3cF80
})

 

채굴이 진행되지 않은 상태에서 index 파일을 실행하면 tx는 일어나지만 이 tx는 아직 블록에 담기지 않았으므로

 

컨트랙트가 배포가 된 상태가 아니다.

 

attach에서 txpool을 입력하면 아직 pending 상태인 tx가 있을 것이다.

 

채굴을 진행해 이를 블록에 담으면 이제 .then 메소드가 실행되어 CA를 출력해준다.

 

( CA : contract address 값을 복사해 컨트랙트 내용을  블록체인 네트워크로부터 호출할 수 있다. )

 

1.3 배포된 컨트랙트 불러오기

이제 index2.js를 새로 만들어 배포가 된 컨트랙트를 불러와보자.

 

/*  index2.js  */

const { Contract } = require('./controllers/compile')
const { Client } = require('./controllers/clients')

const [abi, bytecode] = Contract.compile('HelloWorld.sol')
const client = new Client('ws://127.0.0.1:9005')

const txObject = {
    data : bytecode
}

const ca = '0x235C577edc42863bDB8662915AD5b841F6e3cF80'
// 방금 배포시 출력된 ca 값을 넣는다.

const contract = new client.web3.eth.Contract(abi,ca)
// 블록체인 네트워크 상에 존재하는 컨트랙트를 특정해 가져온다.

contract.methods.getText().call()
.then((data) => {
    console.log(data)
})
// 컨트랙트 내부의 getText를 호출해 그 값을 출력한다

 

배포를 할때는 ca값이 없으므로 매개 변수는 abi 하나만 있으면 되었지만,

 

이미 블록체인 네트워크 상에 존재하는 컨트랙트를 특정해 가져오려면 인자값이 두개가 필요하다.

 

abi와 그에 더해 특정성을 대폭 늘려줄 수 있는 ca를 인자값으로 주면 블록체인 상의 컨트랙트 중

 

내가 원하는 그 컨트랙트 하나만을 특정할 수 있다.

 

컨트랙트를 특정지었다면 ( 여기선 당연히 우리가 방금 배포한 그 컨트랙트)

 

그 컨트랙트의 method에서 컨트랙트에 내장된 함수를 호출 할 수 있다.

 

함수를 호출했을 때 우리가 처음에 정한 문자열이 잘 출력되었다면 setText를 통해

 

컨트랙트 내부의 text 변수의 값을 바꿀 수 있다.

 

이 때는 가스비로 약간의 이더리움을 소비하게 된다.

 

/*  index2.js  */

// ...중략
const ca = '0x235C577edc42863bDB8662915AD5b841F6e3cF80'
const contract = new client.web3.eth.Contract(abi,ca)

contract.methods.setText('lsj').send({
    from : '0xa4fad52aad9d5548db57603a21bc7d6d457ddbcc'
})
.then((data) => {
    console.log(data)
})

contract.methods.getText().call()
.then((data) => {
    console.log(data)
})

 

다음과 같이 index2.js 를 수정하고 채굴을 진행한 후, 다시 getText() 함수를 실행했을 때

 

text 변수값이 바뀌었다면 성공이다.

 

 

2.  Truffle을 이용한 컨트랙트 배포

지금까지 한 배포를 조금 더 편하게 할 수 있도록 도와주는 프레임워크가 Truffle이다.

 

루트 디렉토리에 truflle 폴더를 하나 만들고, 이제부터 여기서 작업을 이어간다.

 

2.1 truffle setup

npm i truffle

npx truffle version
// 문제 없이 설치되었다면 truffle 등 라이브러리의 버전을 알려준다

npx truffle init

마지막에 truffle을 init 시켜주면 3개 폴더와 js 파일이 하나 생성된다.

 

각각의 역할은 다음과 같다

 

- Contracts : 솔리디티 코드를 모아두는 공간

 

- migration : deploy - 배포 작업은 따로 우리가 다시 수행하지 않는 이상 한 번만 실행하므로, 배포 코드를 여기 저장하고,

 

필요에 따라 재활용할 수 있다.

 

- test : 테스트 기능을 여기서 수행한다.

 

- truffle.config.js : 사용 네트워크 등을 설정한다. 사용할 부분의 주석을 해제해주면 된다.

 

여기에 더해 나중에 build라는 폴더가 하나 더 생성될 것이다.

 

/*  truffle/truffle.config.js  */

// ...중략

module.exports = {
    // ...중략
    networks : {
        // ...중략
        development : {
            host : "127.0.0.1",
            port : 9000,
            network_id : "7019"
        }
        // 이 부분만 주석을 해제하고 값을 바꿔주면 된다.
    },
    
    // ...중략
    
    compilers : {
        solc : {
        version : "0.8.15"
        // 사용하는 solc 버전과 동일한지 확인한다
        }
    }
    
    // ...중략
}

 

처음에 migration 폴더에 이미 파일이 하나 있다.

 

예시로 만들어진 배포용 코드라고 보면 되는데, 이와 동일한 형식으로 파일명과 내용을 바꿔주면 어렵지 않게

 

배포 작업을 진행할 수 있을 것이다.

 

파일명은 

 

[번호]_내용_Contract이름

 

의 형식으로 만들어주면 된다.

 

이제 조금 전 만든 HelloWorld.sol을 truffle을 이용해 배포해보자.

 

2.2 배포할 파일 가져오기

truffle/contracts 폴더 안에 HelloWorld.sol 파일을 복사해 넣는다.

 

migration 폴더에도 이를 배포할 코드를 추가해줘야 하는데, 1_initial_migration.js를 복사해

 

제목과 매개변수 값만 좀 바꿔 사용하면 된다.

 

/*  truffle/migrations/2_deploy_HelloWorld.js  */
// 파일명에 주의할 것

const Hello2 = artifacts.require("Hello2");
// 파일의 이름이 아닌 컨트랙트의 이름을 가져와야 한다.

module.exports = function (deployer) {
  deployer.deploy(Hello2);
};

 

이렇게 작성을 완료했다면 터미널에서 truffle 폴더 안에서 다음과 같이 명령어를 입력하면 배포가 된다.

 

npx truffle migration

 

이렇게만 하면 알아서 배포를 다 해주는데, 이 때 블록체인 네트워크 상에서는 채굴을 진행해 tx를 블록에 넣어줘야 한다.

 

그렇게 블록에 tx가 들어간 후에 CA와 가스 사용량 등의 정보들을 터미널에 출력해준다.

 

(따로 정해주지 않았다면 네트워크의 코인베이스 계정이 tx를 발생시키는 것으로 간주한다.)

 

이 때, 따로 지정해주지 않는다면 이미 배포가 완료된 컨트랙트들은 알아서 생략하고 배포를 진행한다.

 

만약 다시 컨트랙트를 배포하고 싶다면 다음과 같이 --reset을 명령어 후미에 붙여주면 된다.

 

npx truffle migration --reset

 

이제 truffle의 터미널을 사용해보자.

 

npx truffle console

 

을 이용하면 geth attach처럼 truffle을 사용할 수 있다.

 

조금 전 배포한 Hello2 컨트랙트의 address를 가져와보자.

 

Hello2.address
// '0x577ce8Ba67e21674BA8e5687b394054CEd0F5D1f'

 

우리가 어떤 컨트랙트를 가져올 때, 아까도 본 것처럼 객체 구조의 깊이가 굉장히 길어 함수를 실행하려면

 

객체 안에 객체, 그 안의 객체, 또 그 안의 객체.. 를 일일히 찾아 넣어야 하는 문제가 있었는데,

 

이를 간단하게 해줄 수 있는 기능이 있다.

 

Hello2.deployed().then(instance => izone = instance)

 

배포된 컨트랙트의 이름을 가져와 내가 부를 호칭을 정한다.

 

예시처럼 설정하면 izone을 입력후, 원래 컨트랙트 (Hello2) 안의 함수를 입력하면

 

여러 단계 객체를 거칠 필요 없이 바로 함수를 호출할 수 있다.

 

나는 izone으로 명칭을 정했다.

 

mysql에서 as문을 이용해 명칭을 정하는 것과 비슷하다고 보면 된다.

 

izone.

 

내가 정한 인스턴스의 이름에 .을 하나 붙이고 tab키를 두 번 누르면 그 안에서 사용할 수 있는 함수들을 보여줄 것이다.

 

이제 이를 이용해 함수를 호출해보자.

 

izone.getText()

 

현재 text의 값을 볼 수 있다.

 

izone.setText('panorama')

 

setText를 이용해 text의 값을 변화시키려면 가스비와 채굴 과정을 통한 블록에의 포함이 필요하다.

 

채굴을 동시에 진행해주면서 setText 함수를 호출하면 결과값을 출력해준다.

 

다시 한 번 getText 함수를 호출해 text의 값이 잘 바뀌었는지 확인해보자.

 

2.3 테스트

마지막으로 테스트 기능을 활용해보자.

 

/*  truffle/test/Hello2.test.js  */

const Hello2 = artifacts.require('Hello2')
// 컨트랙트 이름으로 컨트랙트를 가져온다.

contract ( "Hello2", (account) => {
    let izone

    describe ("Hello Contract", () => {
        it('hello2 contract deploy', async () => {
            izone = await Hello2.deployed()
        })

        it ('getText', async () => {
            console.log( await izone.getText())
        })

        it("set Text", async () => {
            await izone.setText('izone')
            console.log(await izone.getText())
        })
    })
})

 

조금 전에 했던 것과 동일한 작업을 테스트 코드로 반복해 수행한다.

 

nothing to compile  메시지가 떠도 정상이니 조금 기다려주면 결과를 알려줄 것이다.

 

이 테스트 코드는 실행할 때마다 계속 다시 컨트랙트를 블록체인 상에 전달하고 그 컨트랙트를 실행하기 때문에

 

실행할 때마다 초기값이 같을 것이다.

 

컨트랙트를 올리고 그 컨트랙트를 실행하는 모든 과정은 늘 채굴을 통해 그 과정이 끝나므로

 

항상 채굴을 하면서 이를 실행해주어야 한다.