Overview.
가상 컨테이너 환경에서 블록체인 노드를 운영해야 할 때, 이를 사용하는 주변 애플리케이션이 맞물려 있을 경우 배포된 스마트 컨트랙트와 상호작용하는 테스트를 작성 하기는 어떻게 보면 까다롭기도 하다.
그냥 스마트 컨트랙트를 배포하는 테스트 코드를 실행하고, 그 후에 배포된 스마트 컨트랙트와 상호작용 하는 테스트 코드를 실행하면 되는 것 아니냐고 생각될 수도 있지만 현실은 테스트 코드가 순차적으로 실행되리라 보장할 수도 없고 빠른 피드백과 더 나은 테스트 가능성을 위해 그렇게 해서도 안된다고 생각된다.
만약에 그렇게 되야 한다면 비즈니스 로직을 테스트 하는 환경 자체가 스마트 컨트랙트와 강결합 되어 있다고 보여진다. 이는 새 요구사항에 대한 개발 속도 저하 및 테스트 깨짐 가능성만 높일 뿐이다.
때문에 우리는 통합 테스트 환경에서 블록체인 노드 및 스마트 컨트랙트와 상호작용 테스트를 위해서는 반드시 테스트 노드가 구동될 때 자동으로 스마트 컨트랙트를 자동 배포하는 방안이 필요하다.
테스트를 위한 스마트 컨트랙트 자동 배포
통합 테스트를 위해 스마트 컨트랙트를 자동으로 배포해야 하는 이유는 앞서 언급한 내용말고도 블록 관련 API를 테스트하기 위해 필요하다. 블록체인은 트랜잭션이 발생해야 블록이 쌓인다. 때문에 편의를 위해서라도 컨트랙트를 배포하고 값에 대한 쓰기 행위가 자동으로 수반되야 한다.
가상 컨테이너 환경에서 실행되기 때문에, 방법은 상당히 간단하다. 미리 작성된 도커 컴포즈에 블록체인 노드가 Up status가 되면 스마트 컨트랙트 배포용 서비스를 띄워 스크립트를 통해 배포하게 하고, 배포가 성공하면 해당 서비스를 종료하면 된다. (Testcontainers
에서 블록체인도 지원해 주면 좋았겠지만 유감 스럽게도 지원을 안함)
Try #1.
처음에는 회사 프로젝트의 통합 테스트를 위해 적용하려고 했는데 프로젝트 폐기로 인해 관련 패키지들이 사설 서버에서 삭제되어 빌드 조차할 수 없게 되었다. 따라서 샘플 프로젝트를 생성하고 그 안에서 간단히 환경을 구성해 보았다.
먼저, 테스트용 블록체인을 선정해야 하는데 회사에서 사용하는 컨센시스 고쿼럼으로 했다. 노드를 구성하는 방법도 간단하고 시스템 사양이 높지 않은 이유도 있다.
Node.js가 설치되어 있다면 아래 명령어를 통해 쿼럼 노드를 실행하는데 필요한 설정 파일들을 얻을 수 있다. (컨센시스는 이처럼 편리한 툴들이 오픈소스로 공개되어 있음)
npx quorum-genesis-tool --consensus qbft --chainID 1337 --blockperiod 5 --requestTimeout 10 --epochLength 30000 --difficulty 1 --gasLimit '0xFFFF' --coinbase '0x0000000000000000000000000000000000000000' --validators 4 --members 0 --bootnodes 0 --outputPath 'artifacts'
다음으로 artifacts/{생성날짜}/goQuorum
디렉토리에서 static-nodes.json
의 내용을 컨테이너 환경에서 사용할 수 있게 미리 수정한다. 여기서는 도커 컴포즈에서 서비스명을 goquorum0
~goquourm3
으로 사용할 것이기 때문에 아래와 같이 수정했다. (포트 번호도 함께 변경해야 함)
["enode://55b63a7509c5a444674d605aa5cbbd271f2a49602f925e4ac52840388c921d0bb2da82a679ea1dbcba3c728535fe29c94fc25a84273c6dce84a3271e55767ac8@goquorum0:30310?discport=0&raftport=53000",
"enode://75e789fb239a69637f93b7d9af7c281cd03ae371a245f855ad67829818c7635a5e20298a694f10fd07225c28e363953e3f26f03a6cde1300e4bd7f8a4af24133@goquorum1:30311?discport=0&raftport=53001",
"enode://b061ef807f40ca18bc9c01ff542b25483259c5f70f4ee2ef1cb6398e552729356b8805badc19a1e92dcf187efa2b5a401f76749579f871e290d5fb06ac61a72f@goquorum2:30312?discport=0&raftport=53002",
"enode://c663b71ca32bbcef204772908c077ac8478e2b8bfe9156c3f4f93e0516ada27e7ba4646a8abb609e08fec6295e0e0d88a959168b2d596a773edefbe41f2cea03@goquorum3:30313?discport=0&raftport=53003"]
또한, 도커 컴포즈에서 불러와 사용할 .env
파일에 필요한 환경변수를 입력했다. ADDRESS_VALIDATOR
에 대한 값은 artifacts/{생성날짜}/
디렉토리 내에서 각각의 validator
디렉토리 안에 accountAddress
파일을 확인하면 된다.
NETWORK_ID=1337
ADDRESS_VALIDATOR_0=e9d79a12c1290f9aba63409aa286a54ed0f06538
ADDRESS_VALIDATOR_1=deaa297b07da4f3528d9847243195fa33c8a7a38
ADDRESS_VALIDATOR_2=f30d2adac9adac3623df0d4f3e1162e05260e947
ADDRESS_VALIDATOR_3=a99a58e574b5316ecfb4d3c03a8e1879b96d5f19
GAS_PRICE=0
설정 파일을 컨테이너 내에 위치시키고 노드 애플리케이션을 실행하기 위한 Dockerfile
도 작성했다. 파일명은 goquorum.Dockerfile
이다.
FROM quorumengineering/quorum:latest
ARG VALIDATOR
WORKDIR /node
RUN mkdir -p /data/keystore
COPY local-quorum/goQuorum/genesis.json ./data/genesis.json
COPY local-quorum/goQuorum/static-nodes.json ./data/static-nodes.json
COPY local-quorum/${VALIDATOR}/account* ./data/keystore/
COPY local-quorum/${VALIDATOR}/address ./data/
COPY local-quorum/${VALIDATOR}/nodekey* ./data/
RUN geth --datadir ./data init ./data/genesis.json
ENTRYPOINT ["geth"]
마지막으로 도커 컴포즈 파일을 아래와 같이 작성했다. (healthcheck
옵션은 필수는 아님)
services:
goquorum0:
networks:
- quorum-network
build:
context: .
dockerfile: goquorum.Dockerfile
args:
- VALIDATOR=validator0
restart: on-failure
hostname: goquorum0
command: >
--datadir ./data
--identity goquorum0
--networkid ${NETWORK_ID} --nodiscover
--syncmode full
--mine --miner.threads 1 --miner.gasprice 0 --emitcheckpoints
--unlock ${ADDRESS_VALIDATOR_0} --allow-insecure-unlock --password ./data/keystore/accountPassword
--http --http.addr 0.0.0.0 --http.port 22000 --http.corsdomain "*" --http.vhosts "*"
--ws --ws.addr 0.0.0.0 --ws.port 32000 --ws.origins "*"
--http.api admin,eth,debug,miner,net,txpool,personal,web3
--ws.api admin,eth,debug,miner,net,txpool,personal,web3
--port 30310
ports:
- '30310:30310'
- '22000:22000'
- '32000:32000'
- '53000:53000'
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:22000" ]
interval: 5s
timeout: 3s
retries: 10
goquorum1:
networks:
- quorum-network
build:
context: .
dockerfile: goquorum.Dockerfile
args:
- VALIDATOR=validator1
restart: on-failure
hostname: goquorum1
command: >
--datadir ./data
--identity goquorum1
--networkid ${NETWORK_ID} --nodiscover
--syncmode full
--mine --miner.threads 1 --miner.gasprice 0 --emitcheckpoints
--unlock ${ADDRESS_VALIDATOR_1} --allow-insecure-unlock --password ./data/keystore/accountPassword
--http --http.addr 0.0.0.0 --http.port 22001 --http.corsdomain "*" --http.vhosts "*"
--ws --ws.addr 0.0.0.0 --ws.port 32001 --ws.origins "*"
--http.api admin,eth,debug,miner,net,txpool,personal,web3
--ws.api admin,eth,debug,miner,net,txpool,personal,web3
--port 30311
ports:
- '30311:30311'
- '22001:22001'
- '32001:32001'
- '53001:53001'
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:22001" ]
interval: 5s
timeout: 3s
retries: 10
goquorum2:
networks:
- quorum-network
build:
context: .
dockerfile: goquorum.Dockerfile
args:
- VALIDATOR=validator2
restart: on-failure
hostname: goquorum2
command: >
--datadir ./data
--identity goquorum2
--networkid ${NETWORK_ID} --nodiscover
--syncmode full
--mine --miner.threads 1 --miner.gasprice 0 --emitcheckpoints
--unlock ${ADDRESS_VALIDATOR_2} --allow-insecure-unlock --password ./data/keystore/accountPassword
--http --http.addr 0.0.0.0 --http.port 22002 --http.corsdomain "*" --http.vhosts "*"
--ws --ws.addr 0.0.0.0 --ws.port 32002 --ws.origins "*"
--http.api admin,eth,debug,miner,net,txpool,personal,web3
--ws.api admin,eth,debug,miner,net,txpool,personal,web3
--port 30312
ports:
- '30312:30312'
- '22002:22002'
- '32002:32002'
- '53002:53002'
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:22002" ]
interval: 5s
timeout: 3s
retries: 10
goquorum3:
networks:
- quorum-network
build:
context: .
dockerfile: goquorum.Dockerfile
args:
- VALIDATOR=validator3
restart: on-failure
hostname: goquorum3
command: >
--datadir ./data
--identity goquorum3
--networkid ${NETWORK_ID} --nodiscover
--syncmode full
--mine --miner.threads 1 --miner.gasprice 0 --emitcheckpoints
--unlock ${ADDRESS_VALIDATOR_3} --allow-insecure-unlock --password ./data/keystore/accountPassword
--http --http.addr 0.0.0.0 --http.port 22003 --http.corsdomain "*" --http.vhosts "*"
--ws --ws.addr 0.0.0.0 --ws.port 32003 --ws.origins "*"
--http.api admin,eth,debug,miner,net,txpool,personal,web3
--ws.api admin,eth,debug,miner,net,txpool,personal,web3
--port 30313
ports:
- '30313:30313'
- '22003:22003'
- '32003:32003'
- '53003:53003'
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:22003" ]
interval: 5s
timeout: 3s
retries: 10
networks:
quorum-network:
driver: bridge
Try #2.
기본적인 환경 설정을 마쳤으니, 본격적으로 스마트 컨트랙트 자동 배포를 위한 환경을 구축해야 한다. 이를 위해 다음 요소들을 준비한다.
간단한 스마트 컨트랙트 샘플 소스 코드
스마트 컨트랙트 배포 스크립트(JavaScript) 및 이를 위한 별도 패키지 구성
스마트 컨트랙트 배포 서비스에 대한 Dockerfile 및 도커 컴포즈 파일 작성
스마트 컨트랙트 샘플 소스 코드
샘플 소스 코드는 아래와 같이 작성했다. 기본적인 문자열 업데이트 컨트랙트지만 블록 이벤트 구독 테스트를 위해 솔리디티 코드 내에 이벤트를 포함했다. (그러나 현재 목표는 배포를 성공하는 것에만 있음)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SetString {
string private storedString;
// 이벤트: 문자열이 변경될 때마다 발생
event StringUpdated(string newString);
// 문자열을 설정하는 함수
function setString(string calldata newString) external {
storedString = newString;
emit StringUpdated(newString);
}
// 저장된 문자열을 가져오는 함수
function getString() external view returns (string memory) {
return storedString;
}
}
배포 스크립트 및 패키지 구성
import path from 'path'
import fs from 'fs'
import solc from 'solc';
import Web3 from "web3";
class Web3Client {
#web3
static #instance
constructor() {
if (Web3Client.#instance) {
return Web3Client.#instance
}
this.#web3 = new Web3(new Web3.providers.HttpProvider("http://goquorum0:22000"))
Web3Client.#instance = this
}
static getInstance() {
if (!Web3Client.#instance) {
Web3Client.#instance = new Web3Client()
}
return Web3Client.#instance
}
async web3() {
return this.#web3
}
}
class ContractDeployer {
static async deploy(web3Client, privateKey, types, values, bytecode) {
try {
const encodedParams = web3Client.eth.abi.encodeParameters(types, values).slice(2)
const encodedData = '0x' + bytecode + encodedParams
const account = web3Client.eth.accounts.privateKeyToAccount(privateKey)
const nonce = await web3Client.eth.getTransactionCount(account.address, 'pending')
const tx = {
chainId: 1337,
nonce,
from: account.address,
value: '0x00',
data: encodedData,
gasPrice: '0x0',
gas: '0xFFFFF'
}
const rawTx = (await account.signTransaction(tx)).rawTransaction
const receipt = await web3Client.eth.sendSignedTransaction(rawTx);
console.log('Transaction successful:', receipt);
return receipt
} catch (error) {
console.error('Deployment failed with details:', error);
throw error
}
}
}
class SolidityCompiler {
async sourcesOf(files) {
const sources = {}
for (let file of files) {
const sourceCode = await fs.promises.readFile(file.path, 'utf-8')
sources[path.basename(file.path)] = {content: sourceCode}
}
return sources
}
async artifactsOf(output) {
const resultFilePath = './compiled'
await fs.promises.mkdir(resultFilePath, {recursive: true})
const parsedContractsOutput = JSON.parse(output).contracts
const compiledResults = []
for (let contract in parsedContractsOutput) {
// contract is equal file name
for (let contractName in parsedContractsOutput[contract]) {
const artifact = JSON.stringify(parsedContractsOutput[contract][contractName], null, 2)
await fs.promises.writeFile(path.join(resultFilePath, contractName + '.json'), artifact, 'utf-8')
const contractData = parsedContractsOutput[contract][contractName]
compiledResults.push({
fileName: contract,
contractName: contractName,
abi: contractData.abi,
bytecode: contractData.evm.bytecode.object,
})
}
}
return compiledResults
}
async compileOf(sources) {
const input = {
language: 'Solidity',
sources: sources,
settings: {
outputSelection: {
'*': {
'*': ['*', 'evm.bytecode'],
},
},
},
}
return solc.compile(JSON.stringify(input))
}
async compile(files) {
// 1. 소스 코드 읽기
const sources = await this.sourcesOf(files)
// 2. 컴파일 실행
const output = await this.compileOf(sources)
// 3. 컴파일 에러 체크
const parsedOutput = JSON.parse(output)
if (parsedOutput.errors) {
const errorMessages = parsedOutput.errors.map(error => error.message).join('\n')
console.error('Compilation errors:', errorMessages)
if (parsedOutput.errors.some(error => error.severity === 'error')) {
throw new Error('Compilation failed: ' + errorMessages)
}
}
// 4. 아티팩트 생성
return await this.artifactsOf(output)
}
}
async function main() {
try {
const files = [
{
path: path.join('.', 'StringStore.sol'),
name: 'StringStore.sol'
}
];
const compiler = new SolidityCompiler()
const compiledContracts = await compiler.compile(files)
console.log('\nCompilation completed successfully!')
console.log('Compiled contracts:', compiledContracts.map(c => ({
name: c.contractName,
})))
const privateKey = "0xb44cc1a1a213938d3de58d0e4aa2f80a4138d70288f35f527a9576568d9c6337"
const types = ["string"]
const value = ["Hello World"]
const bytecode = compiledContracts[0].bytecode
const web3Client = await (new Web3Client().web3())
await ContractDeployer.deploy(web3Client, privateKey, types, value, bytecode)
} catch (error) {
console.error('Error: ', error)
process.exit(1)
}
}
main().then(() => process.exit(0));
해당 배포 스크립트는 아래 패키지 구조처럼 솔리디티의 컴파일 결과 파일과 함께 둔다. 이 때, 필요한 라이브러리를 불러 올 수 있도록 기존 백엔드 서비스와 다른 별도의 패키지 구성을 위해 npm init
해 라이브러리들을 설치할 수 있도록 해야한다. (위에서 필요한 라이브러리는 web3@1.10.2
)
Dockerfile 및 도커 컴포즈 파일 작성
FROM node:18
WORKDIR /app
COPY . .
COPY ./package.json .
RUN npm install
CMD ["npm", "run", "deploy"]
필요한 파일들을 준비하고 컨테이너의 /app
디렉토리 내에 위치시켰다. 또한 관련 라이브러리들이 설치되고 실행될 수 있도록 했다.
contract-deploy:
networks:
- quorum-network
build:
context: .
dockerfile: ./smart-contract/contract-deploy.Dockerfile
depends_on:
goquorum0:
condition: service_healthy
goquorum1:
condition: service_healthy
goquorum2:
condition: service_healthy
goquorum3:
condition: service_healthy
hostname: contract-deploy
restart: "no"
volumes:
- ./smart-contract/:/app/
마지막으로 contract-deploy
서비스 내용을 기존 도커 컴포즈 파일 하단에 추가한다.
Test.
docker compose up -d build
후에 컨테이너가 게시되고 docker ps -a
로 모든 인스턴스가 Up status
상태가 되면 docker compose logs contract-deploy
명령으로 스마트 컨트랙트가 블록체인 노드에 배포가 성공했는 지 확인할 수 있다. 성공시 트랜잭션 리시트를 콘솔에 출력하기 때문에 이를 확인하면 된다.
Conclusion.
지금까지 도커 컨테이너 환경에서 통합 테스트를 위해 블록체인 노드 구동시 스마트 컨트랙트를 자동 배포하는 방법에 대해 알아보았다. 이런 도구가 필요한 이유는 우리가 작성한 블록체인 관련 테스트 코드가 순차적으로 실행되지 않고 병렬적으로 실행되기 때문에 반드시 블록체인 네트워크에 미리 테스트용 스마트 컨트랙트가 필요하기 때문이다. 이는 테스트 코드 자체가 스마트 컨트랙트에 강결합되는 상황을 예방할 수 있다.
이를 위해 도커 컴포즈 구성으로 스마트 컨트랙트 배포 서비스를 작성해 넣었고, 실행했을 때 컨트랙트가 배포 성공됨을 확인할 수 있었다.
Future Work.
- 스마트 컨트랙트 배포 서비스가 블록체인으로 컨트랙트 배포를 성공하면 도커 인스턴스를 제거하는 과정을 자동화할 필요가 있다.