이번 포스팅에서는 저번 포스팅에 이어서 블록의 hash 값을 만들 때 "난이도"에 의해 설정된 목표값 보다 작은 hash값을 만들어주는 과정에 대해 다뤄보고자 한다.
이전 글)
2022.06.12 - [BlockChain] - BlockChain - TypeScript로 블록체인 만들기 (3)
< 목차 >
- PoW (작업증명방식) 구현하기
- 테스트 코드 작성하기
1. PoW (작업증명방식) 구현하기
PoW (Proof of Work, 작업 증명 방식) 는 분산 합의 알고리즘의 한 종류이다. 분산 합의 알고리즘은 분산 시스템 상에서 모두가 동일한 상태를 가지고 있을 수 있도록 하는 방식으로 블록체인 상의 모든 참여자들이 동일한 순서로 블록을 연결하기 위해서는 합의 알고리즘이 필요하다. 대표적인 합의 알고리즘으로 PoW, PoS, DPoS, PoA 등이 있다.
"작업 증명 방식" 이라고 일컫는 PoW는 목표값 이하의 hash 값을 찾는 과정을 무수히 반복함으로써 해당 작업에 참여했음을 증명하는 방식의 알고리즘이다. 작업 증명 알고리즘의 필요성은 네트워크 상의 모든 노드가 동시에 블록을 만들 수 없도록 하는 것에 있다. 즉, 작업 증명을 통과해야만 비로소 블록을 생성할 수 있게 된다는 점에 의의가 있다. 그리고 이러한 작업 증명 알고리즘은 Difficulty 조절 알고리즘을 이용하여 약 10분당 1개의 블록이 생성되는 것을 보장하게 된다.
우선, Difficulty 조절 알고리즘은 다음과 같이 설계하고자 한다. 블록 한 개가 생성되는 예상 시간(BLOCK_GENERATION_INTERVAL)을 10분으로 설정 한 후, 10개의 블록을 한 묶음(DIFFICULTY_ADJUSTMENT_INTERVAL)으로 해서 블록 한 묶음이 생성되는 예상 시간(timeExpected)에 6000(초) 이라는 값을 할당해준다. 이후 10개의 블록이 생성되는데 걸리는 시간(timeTaken)이 "timeExpected / 2" 보다 작을 경우 난이도를 +1 증가시키고 "timeExpected * 2" 보다 클 경우 난이도를 -1 감소시킨다.
- DIFFICULTY_ADJUSTMENT_INTERVAL : 난이도 조정 블록 범위 => 10
- DIFFICULTY_GENERATION_INTERVAL : 블록 생성 시간 (단위 : 분) => 10
- BLOCK_GENERATION_TIME_UNIT : 생성 시간의 단위 (초) => 60
이제 GENESIS 블록을 만들어줬던 config.ts 파일 안에 위의 변수들을 선언해주도록 하자.
// config.ts 파일
/**
* 난이도 조정 블록 범위
*/
export const DIFFICULTY_ADJUSTMENT_INTERVAL: number = 10;
/**
* 블록 생성 시간 (단위 : 분) // 10*60 = 600
*/
export const BLOCK_GENERATION_INTERVAL: number = 10;
/**
* 생성 시간의 단위 (초)
*/
export const BLOCK_GENERATION_TIME_UNIT: number = 60;
export const GENESIS: IBlock = {
version: '1.0.0',
height: 0,
timestamp: new Date().getTime(),
hash: '0'.repeat(64),
previousHash: '0'.repeat(64),
merkleRoot: '0'.repeat(64),
difficulty: 0,
nonce: 0,
data: ['Hello Block'],
};
다음으로 config.ts 파일 안에서 선언된 변수들을 바탕으로 Chain 클래스와 Block 클래스 코드 안에서 PoW를 구현해보도록 하자. Chain 클래스가 작성된 chain.ts 파일 안의 코드를 다음과 같이 수정하였다.
// chain.ts 파일
import { Block } from "@core/blockchain/block";
import { DIFFICULTY_ADJUSTMENT_INTERVAL } from "@core/config";
export class Chain {
public blockchain: Block[];
constructor() {
this.blockchain = [Block.getGENESIS()];
}
public getChain(): Block[] {
return this.blockchain;
}
public getLength(): number {
return this.blockchain.length;
}
public getLatestBlock(): Block {
return this.blockchain[this.blockchain.length - 1];
}
public addBlock(data: string[]): Failable<Block, string> {
const previousBlock = this.getLatestBlock();
// -10 번째 블록 구하기
const adjustmentBlock: Block = this.getAdjustmentBlock();
// generateBlock() 함수에 adjunstmentBlock 인자값 추가
const newBlock = Block.generateBlock(previousBlock, data, adjustmentBlock);
const isValid = Block.isValidNewBlock(newBlock, previousBlock);
if (isValid.isError) return { isError: true, error: isValid.error };
this.blockchain.push(newBlock);
return { isError: false, value: newBlock };
}
/**
* getAdjustmentBlock()
* 생성 시점 기준으로 블록 높이가 -10 인 블록 구하기
* 1. 현재 높이값 < DIFFICULTY_ADJUSTMENT_INTERVAL : 제네시스 블록 반환
* 2. 현재 높이값 > DIFFICULTY_ADJUSTMENT_INTERVAL : -10번째 블록 반환
*/
public getAdjustmentBlock() {
const currentLength = this.getLength();
const adjustmentBlock: Block =
this.getLength() < DIFFICULTY_ADJUSTMENT_INTERVAL
? Block.getGENESIS()
: this.blockchain[currentLength - DIFFICULTY_ADJUSTMENT_INTERVAL];
return adjustmentBlock; // 제네시스 블록 or -10번째 블록 반환
}
}
수정된 부분을 살펴보면 블록 체인 상에 연결하고자 하는 블록을 생성할 때, 즉, addBlock( ) 메소드에 의해 generateBlock( ) 함수가 호출될 때 해당 함수의 인자값으로 adjustmentBlock 이라는 블록을 추가로 넣어준 것을 확인할 수 있다. getAdjustmentBlock( ) 이라는 static 메소드에 의해 반환되는 adjustmentBlock은 블록 생성 시점을 기준으로 블록 높이가 -10인 블록이다. 만약 블록 체인의 길이가 10보다 작을 경우에는 제네시스 블록이 adjustmentBlock이 되도록 하였다. 이제 generateBlock( ) 메소드가 정의된 Block 클래스 안에서 adjustmentBlock을 이용해 목표값을 구하는 코드를 작성해주면 된다.
// block.ts 파일
import { SHA256 } from "crypto-js";
import merkle from "merkle";
import { BlockHeader } from "./blockHeader";
// 수정
import {
DIFFICULTY_ADJUSTMENT_INTERVAL,
BLOCK_GENERATION_INTERVAL,
BLOCK_GENERATION_TIME_UNIT,
GENESIS,
} from "@core/config";
// hex-to-binary 모듈
import hexToBinary from "hex-to-binary";
export class Block extends BlockHeader implements IBlock {
public hash: string;
public merkleRoot: string;
public nonce: number;
public difficulty: number;
public data: string[];
// constructor() 함수의 매개변수로 adjustmentBlock 추가
constructor(_previousBlock: Block, _data: string[], _adjustmentBlock: Block) {
// 부모 속성 가져오기
super(_previousBlock);
const merkleRoot = Block.getMerkleRoot(_data);
this.merkleRoot = merkleRoot;
this.hash = Block.createBlockHash(this);
this.nonce = 0;
// getDifficulty() 함수로 난이도 생성
this.difficulty = Block.getDifficulty(
this,
_adjustmentBlock,
_previousBlock
);
this.data = _data;
}
public static getGENESIS(): Block {
return GENESIS;
}
public static getMerkleRoot<T>(_data: T[]): string {
const merkleTree = merkle("sha256").sync(_data);
return merkleTree.root();
}
public static createBlockHash(_block: Block): string {
// hash값 생성시 difficulty , nonce 값 추가
const {
version,
timestamp,
height,
merkleRoot,
previousHash,
difficulty, // 추가
nonce, // 추가
} = _block;
const values: string = `${version}${timestamp}${height}${merkleRoot}${previousHash}${difficulty}${nonce}`;
return SHA256(values).toString();
}
// generateBlock() 함수의 매개변수로 adjustmentBlock 추가
public static generateBlock(
_previousBlock: Block,
_data: string[],
_adjustmentBlock: Block
): Block {
const generateBlock = new Block(_previousBlock, _data, _adjustmentBlock);
// newBlock은 마이닝이 완료된 블록
const newBlock = Block.findBlock(generateBlock);
return newBlock;
}
/**
* findBlock()
* 마이닝 작업 코드
*/
public static findBlock(_generateBlock: Block) {
let hash: string;
let nonce: number = 0;
while (true) {
nonce++;
_generateBlock.nonce = nonce;
hash = Block.createBlockHash(_generateBlock);
// hexToBinary(hash) : 16진수 -> 2진수
const binary: string = hexToBinary(hash);
// difficulty는 0의 개수를 의미.
const result: boolean = binary.startsWith(
"0".repeat(_generateBlock.difficulty)
);
if (result) {
_generateBlock.hash = hash;
return _generateBlock;
}
}
}
public static getDifficulty(
_newBlock: Block,
_adjustmentBlock: Block,
_previousBlock: Block
): number {
if (_newBlock.height <= 9) return 0;
if (_newBlock.height <= 19) return 1;
// 10번째 배수의 블록에 한해서만 난이도 구현
// 10개의 묶음씩 같은 난이도를 갖게 된다.
if (_newBlock.height % DIFFICULTY_ADJUSTMENT_INTERVAL !== 0)
return _previousBlock.difficulty;
// 블록 1개당 생성시간 : 10분, 10개 생성되는데 걸리는 시간 : 6000초
/**
* DIFFICULTY_ADJUSTMENT_INTERVAL = 10
* BLOCK_GENERATION_INTERVAL = 10
* BLOCK_GENERATION_TIME_UNIT = 60
*/
const timeTaken: number = _newBlock.timestamp - _adjustmentBlock.timestamp;
const timeExpected: number =
BLOCK_GENERATION_TIME_UNIT *
BLOCK_GENERATION_INTERVAL *
DIFFICULTY_ADJUSTMENT_INTERVAL; // 6000
if (timeTaken < timeExpected / 2) return _adjustmentBlock.difficulty + 1;
else if (timeTaken > timeExpected * 2)
return _adjustmentBlock.difficulty - 1;
return _adjustmentBlock.difficulty;
}
public static isValidNewBlock(
_newBlock: Block,
_previousBlock: Block
): Failable<Block, string> {
if (_previousBlock.height + 1 !== _newBlock.height)
return { isError: true, error: "height error" };
if (_previousBlock.hash !== _newBlock.previousHash)
return { isError: true, error: "previousHash error" };
if (Block.createBlockHash(_newBlock) !== _newBlock.hash)
return { isError: true, error: "block hash error" };
return { isError: false, value: _newBlock };
}
}
우선 adjustmentBlock을 인자값으로 받고 있는 Block.generateBlock( ) 메소드를 살펴 보자. generateBlock( ) 메소드는 previousBlock, data, adjustmentBlock을 인자값으로 받고 있으며 Block 클래스로부터 생성된 블록 인스턴스를 const generateBlock 변수에 할당하였다. 그리고 Block 클래스 역시 constructor( ) 함수의 매개변수로 adjustmentBlock이 추가된 것을 알 수 있는데 이는 adjustmentBlock을 이용해 difficulty (난이도) 속성을 구하기 위함이다.
// constructor() 함수의 매개변수로 adjustmentBlock 추가
constructor(_previousBlock: Block, _data: string[], _adjustmentBlock: Block) {
// 부모 속성 가져오기
super(_previousBlock);
const merkleRoot = Block.getMerkleRoot(_data);
this.merkleRoot = merkleRoot;
this.hash = Block.createBlockHash(this);
this.nonce = 0;
// getDifficulty() 함수로 난이도 생성
this.difficulty = Block.getDifficulty(
this,
_adjustmentBlock,
_previousBlock
);
this.data = _data;
}
Block 클래스의 constructor( ) 함수를 살펴보면, difficulty 속성의 값이 this, adjustmentBlock, previousBlock을 인자값으로 하는 Block.getDifficulty( ) 메소드에 의해 구해지는 것을 확인할 수 있다. Block 클래스 안에서 static 메소드로 정의되어 있는 getDifficulty( ) 함수는 다음과 같다.
public static getDifficulty(
_newBlock: Block,
_adjustmentBlock: Block,
_previousBlock: Block
): number {
if (_newBlock.height <= 9) return 0;
if (_newBlock.height <= 19) return 1;
// 10번째 배수의 블록에 한해서만 난이도 구현
// 한 묶음(10개)씩 같은 난이도를 갖게 된다.
if (_newBlock.height % DIFFICULTY_ADJUSTMENT_INTERVAL !== 0)
return _previousBlock.difficulty;
// 블록 1개당 생성시간 : 10분, 10개 생성되는데 걸리는 시간 : 6000초
/**
* DIFFICULTY_ADJUSTMENT_INTERVAL = 10
* BLOCK_GENERATION_INTERVAL = 10
* BLOCK_GENERATION_TIME_UNIT = 60
*/
const timeTaken: number = _newBlock.timestamp - _adjustmentBlock.timestamp;
const timeExpected: number =
BLOCK_GENERATION_TIME_UNIT *
BLOCK_GENERATION_INTERVAL *
DIFFICULTY_ADJUSTMENT_INTERVAL; // 6000
if (timeTaken < timeExpected / 2) return _adjustmentBlock.difficulty + 1;
else if (timeTaken > timeExpected * 2)
return _adjustmentBlock.difficulty - 1;
return _adjustmentBlock.difficulty;
}
getDifficulty( ) 함수 안에서 if문을 이용해 새로 생성되는 블록의 height 이 9 이하일 경우에는 0 을 return 하고 19 이하일 경우에는 1을 return 하도록 하였다. 다시말해, 10개의 블록씩 묶음을 형성했을 때 첫번째 묶음(height : 0 ~ 9)의 난이도는 0이 되고 두번째 묶음(height : 10 ~19)의 난이도는 1이 되는 것이다. 이후 height 이 20 이상인 블록부터는 height이 10의 배수(DIFFICULTY_ADJUSTMENT_INTERVAL = 10) 가 아닌 경우, previousBlock.difficulty 를 return 하여 한 묶음(10개)씩 같은 난이도를 가지게끔 하였다. 만약 height이 10의 배수라면 난이도 조절 과정을 거쳐 난이도를 +1 하거나 -1 하도록 코드를 구현하였다.
Difficulty 조절 알고리즘에서는 새로 생성된 블록의 timestamp 에서 adjustmentBlock의 timestamp를 차감한 값을 10개의 블록이 생성되는데 걸리는 시간(timeTaken) 으로 하여 기준값으로 설정한 예상시간(timeExpected) 과의 비교를 진행하였다. "예상시간(timeExpected) / 2"의 값보다 걸린시간(timeTaken)이 작다면 difficulty를 +1 증가시키고 "예상시간(timeExpected) * 2"의 값보다 걸린시간(timeTaken)이 크다면 difficulty를 -1 감소시켰다.
이렇게 계산된 난이도는 새롭게 생성되는 블록의 difficulty 값으로 들어가게 된다. 하지만 Chain 클래스의 addBlock( ) 메소드에 의해 호출된 Block.generateBlock( ) 함수가 완전히 종료된 것은 아니다.
// generateBlock() 함수의 매개변수로 adjustmentBlock 추가
public static generateBlock(
_previousBlock: Block,
_data: string[],
_adjustmentBlock: Block
): Block {
const generateBlock = new Block(_previousBlock, _data, _adjustmentBlock);
// newBlock은 마이닝이 완료된 블록
const newBlock = Block.findBlock(generateBlock);
return newBlock;
}
새롭게 생성된 블록은 generateBlock 변수에 할당되고 Block 클래스 안에서 정의된 findBlock( ) 메소드에 의해 마이닝(Mining) 부분이 구현된다. 블록체인에서 "마이닝" 이라고 하면 익히 들어왔던 것처럼 코인이 채굴되는 것만을 생각할 수 있을 것이다. 하지만 "코인의 채굴"이라는 관점은 채굴자의 입장에서 바라본 마이닝인 것이고 개발 입장에서의 마이닝은 단순히 코인을 채굴한다는 개념이 아닌, PoW 방식의 합의 알고리즘을 거쳐 블록이 생성되는 것 자체를 의미한다. 블록 생성의 부산물로써 보상의 개념으로 얻어지는 것이 코인일 뿐, 코인을 얻게 되는 근본적인 과정인 블록 생성 자체가 바로 마이닝(Mining) 인 것이다.
findBlock( ) 함수는 인자값으로 generateBlock 이라는 새롭게 생성된 블록을 받게 되는데 작업 증명 방식의 합의 알고리즘으로 구현되는 마이닝은 다음과 같다.
/**
* findBlock()
* 마이닝 작업 코드
*/
public static findBlock(_generateBlock: Block) {
let hash: string;
let nonce: number = 0;
while (true) {
nonce++;
_generateBlock.nonce = nonce;
hash = Block.createBlockHash(_generateBlock);
// import hexToBinary from 'hex-to-binary'
// hexToBinary(hash) : 16진수 -> 2진수
const binary: string = hexToBinary(hash);
// difficulty는 0의 개수를 의미.
const result: boolean = binary.startsWith(
"0".repeat(_generateBlock.difficulty)
);
if (result) {
_generateBlock.hash = hash;
return _generateBlock;
}
}
}
앞서 설명한 것처럼 난이도(difficulty)에 의해 형성된 목표값보다 작은 hash 값을 만들어 내는 것이 findBlock( ) 함수, 즉, 마이닝 함수의 목표이다. 현재 생성된 블록의 hash 값은 16진수로 표현된 string 이다. 그리고 string으로 표현된 hash 값을 숫자로 표현하고자 "hex-to-binary" 모듈을 사용해 16진수를 2진수로 변환하였다.
난이도(difficulty)가 의미하는 것은 2진수로 변환된 hash 값에서 앞 자리에 위치한 0의 개수라고 볼 수 있다.**
실제 비트코인에서 difficulty는 다음과 같은 산식을 갖는다.
difficulty = (maximum_target) / (current_target)
maximum_target은 비트코인 네트워크에서 가능한 최대 target 값으로 블록 해시가 가질 수 있는 최대값을 나타내며 이 값은 고정되어 있다. current_target은 블록 해시가 충족해야 하는 현재의 목표값으로 블록 생성 난이도를 결정한다. 즉, 낮은 current_target 값은 더 높은 난이도(difficulty)를 의미하는 것이다. 블록 생성자는 블록 해시가 현재 목표값인 current_target 보다 작은 값을 찾아야만 하기 때문에 블록 해시의 연속된 0의 개수와 difficulty 사이에는 간접적인 관계가 생기게 된다. 블록 생성의 난이도가 높아질수록 블록 해시의 앞부분에는 더 많은 0이 나타날 확률이 높아지는 것이다. (블록 생성 난이도가 높아질수록 블록 해시의 앞부분에 더 많은 0이 나타날 확률이 높아지지만, difficulty 값 자체가 연속된 0의 개수를 직접적으로 나타내는 것은 아니다.) 우리는 간단한 토이 비트코인을 만들고 있으므로 difficulty가 곧 2진수로 변환된 블록 해시 값에서 앞 자리에 위치한 0의 개수라고 생각하고 진행하고자 한다.
예를 들어 difficulty 속성값이 3인 경우를 생각해보자. createBlockHash( _generateBlock ) 함수에 의해 만들어진 hash 값은 hexToBinary( hash ) 함수에 의해 2진수로 변환된다. 그리고 2진수로 표현된 binary의 앞 자리에 위치한 0의 개수가 difficulty 속성값과 일치할 때 ( difficulty = 3 → binary의 앞 자리에 0이 3개 존재할 때 ) 해당 hash 값이 블록의 hash 값이 된다. 이러한 과정은 while 문을 사용하여 처리했으며 hash 값을 생성할 때 nonce 값을 사용한다는 사실을 이용해 binary의 앞 자리에 위치한 0의 개수가 difficulty 속성값과 일치할 때까지 계속해서 새로운 hash 값을 만들게 된다. 즉, while 문이 한번 돌아갈 때마다 nonce 값을 1씩 증가시킴으로써 그 때마다 새로운 hash 값이 만들어지고 새롭게 만들어진 hash 값으로 다시 difficulty 를 사용해 비교하는 과정을 거치게 되는 것이다. 결국, binary.startsWith( "0".repeat(_generateBlock.difficulty) ) 의 return 값이 true 가 되어 if문 안의 코드블록이 실행되기 전까지 findBlock( ) 함수는 종료되지 않는다. difficulty에 의해 형성된 목표값보다 작은 크기의 hash 값이 만들어졌다면 if 문 안의 코드 블록이 실행되면서 해당 hash 값은 생성된 블록의 hash 값이 되고 비로소 findBlock( ) 함수가 종료된다.
findBlock( ) 함수의 종료는 작업 증명이 완료되었다는 것을 의미하고 findBlock( ) 함수에 의해 return 된 generateBlock은 Block.generateBlock( ) 메소드 안에서 newBlock 변수에 할당된다. Block.generateBlock( )은 이제 작업 증명 방식으로 마이닝이 완료된 newBlock을 return 하게 되고 반환된 newBlock은 Chain 클래스의 addBlock( ) 메소드 안에서 블록 검증 과정 ( Block.isValidNewBlock( ) 메소드 )을 거쳐 블록 체인 상에 연결된다.
참고)
hex-to-binary 모듈 설치
npm install hex-to-binary
hex-to-binary라는 외장 라이브러리를 사용하기 위해 @types/ 디렉토리 안에 hex-to-binary 디렉토리를 생성하고 해당 디렉토리 안에 index.d.ts (타입 정의 파일) 를 생성해준다.
index.d.ts 파일에서는 다음과 같이 hex-to-binary 모듈에 대한 선언만 해주면 된다.
// hex-to-binary/index.d.ts 파일
declare module "hex-to-binary";
2. 테스트 코드 작성하기
지금까지의 모든 코드들을 chain.test.ts 파일 안에서 Chain 클래스의 addBlock( ) 함수 테스트를 통해 검증해볼 수 있다.
import { Chain } from '@core/blockchain/chain';
describe('Chain 함수 체크', () => {
let node: Chain = new Chain(); // [GENESIS]
it('getChain() 함수 체크', () => {
console.log(node.getChain());
});
it('getLength() 함수 체크', () => {
console.log(node.getLength());
});
it('getLatestBlock() 함수 체크', () => {
console.log(node.getLatestBlock());
});
it('addBlock 함수 체크', () => {
for (let i = 1; i <= 300; i++) {
node.addBlock([`Block #${i}`]);
}
console.log(node.getChain());
});
});
npx jest ./src/core/blockchain/chain.test.ts
보이는 바와 같이 생성된 블록들의 hash 값과 difficulty 값의 비교를 통해 작업 증명 방식 (PoW) 의 마이닝이 제대로 구현된 것을 확인할 수 있다.
지금까지 TypeScript를 사용해 작업 증명 방식 (PoW)의 마이닝을 통해 블록들을 생성하고 연결하는 블록 체인을 만들어 보았다. 다음 시리즈에서는 블록체인 네트워크를 구축해보는 작업을 진행해보고자 한다.
'BlockChain' 카테고리의 다른 글
BlockChain - 블록체인 P2P 네트워크 만들기 (2) (0) | 2022.06.15 |
---|---|
BlockChain - 블록체인 P2P 네트워크 만들기 (1) (1) | 2022.06.14 |
BlockChain - TypeScript로 블록체인 만들기 (3) (0) | 2022.06.12 |
BlockChain - TypeScript로 블록체인 만들기 (2) (feat. Jest) (1) | 2022.06.11 |
BlockChain - TypeScript로 블록체인 만들기 (1) (0) | 2022.06.11 |