본문 바로가기

블록체인_9기/⛓ BlockChain

56강_230904_Blockchain(SHA256, merkle Tree/Root, Block 생성, TypeScript로 Block 생성)

728x90

 

 

 

 


Hash 함수 SHA256

crypto-js 모듈을 사용하여 SHA256 해시로 암호화하는 방법은 현재 블록체인에서 가장 많이 채택해서 사용하고 있는 암호방식이다.

SHA256의 특징은 다음과 같다.

  • 출력속도가 빠르고 단방향성 암호화 방법을 제공한다.
  • 복호화가 불가능하다. 아직까지 큰 단점은 발견되지 않았다.
  • 속도가 빨라서 인증서나 블록체인에 많이 사용중이다.
  • sha256 알고리즘은 256bit 구성된 64자리 문자열 암호화 해준다.

 

다음의 코드는 SHA256 암호화 방법을 간단히 구현한 예시이다.

const { SHA256 } = require("crypto-js");
// 해당방법으로도 불러올 수 있다.
// const SHA256 = reqeire("crypto-js/sha256");

const str = "안녕하세요";

// 문자열 "Hello"를 SHA-256 해시로 변환하고, 그 결과를 문자열로 변환하여 출력한다.
console.log("해시 결과: ", SHA256(str).toString());
// 해시 결과:  2c68318e352971113645cbc72861e1ec23f48d5baa5f9b405fed9dddca893eb4
console.log("길이는?: ", SHA256(str).toString().length);
// 길이는?:  64

 

 

 

 


Mercle Tree

 머클트리(Merkle tree)란, 모든 자식 노드들이 암호학적 해시로 이뤄진 데이터 블록을 갖는 트리 형태의 자료 구조로 해시 트리(hash tree)라고도 부른다.

그래서 머클트리는 어디서, 어떻게 사용되는 구조일까? 이 개념을 알기 위해서는 먼저 블록의 구조를 짚고 넘어가야 한다.

위의 그림과 같이 구성되어 있는 블록의 블록헤더는 또 다음과 같은 구성으로 이루어져있다.

  1. 현재 블록이 이전(previous) 블록들과 연결되어 있음을 나타내는, 이전 블록의 해시 값을 갖는 데이터
  2. 난이도, 타임스탬프, 난스: 채굴 경쟁과 직접적 연관이 되는 부분.
  3. 머클루트 (merkle Root)

1번은 해당 블록과 이전 블록을 연결하게끔 도와주는 것이고, 2번은 채굴의 연산과 직접적으로 연관되는 것이다. 그렇다면 3번인 머클루트는 어떤 것일까?

위의 사진이 머클 트리(Merkle Tree)의 예시이다. 해당 예시에서는 1 ~ 8번째 거래가 존재하고 각 거래들을 2개씩 묶어 해시 암호화한다.  암호화된 해시 값을 다시 두 개씩 묶어 암호화를 진행하면 결국에는 하나의 뿌리(root)가 만들어지고, 이 뿌리를 머클루트(Merkle Root)라고 한다. 

해당 예시는 8개의 거래를 예시로 들었지만, 몇 개의 거래 데이터가 있든 상관없이 머클트리를 생성할 수 있다. 2개씩 짝을 지어 암호화를 진행했는데 만약 홀수개인 거래를 암호화하려면 어떻게 진행해야 할까? 마지막 거래 데이터를 한 번 더 해시해서 원래의 마지막 값과 한 번 더 해시된 값을 암호화한다. 아래의 코드가 홀수개인 거래의 머클트리 생성 예시이다.

const merkle = require("merkle");

// 초기의 거래 내용
const data = ["A", "B", "C", "D", "E"];

// 머클루트를 처리할 때 홀수일 경우 마지막 데이터를 한번 더 해시해서 사용한다.
// 홀 수일 경우 마지막 데이터를 복사해서 두 값을 해시화한다.
// ex) ["A", "B", "C", "D", "E"] => ["A", "B", "C", "D", "E", "E"]

// 머클루트로 암호화
const merkleTree = merkle("sha256").sync(data);

// 머클루트의 마지막 값. 즉 가장 최상단의 루트의 값을 받음
const Root = merkleTree.root();
console.log(Root);
// 결과(머클루트): AE4F3A195A3CBD6A3057C205DEF94520930F03F51F73C5A540D8FDAB05163FEF


// A 해시화, B 해시화 => 둘 다 더해서 AB
// C 해시화, D 해시화 => 둘 다 더해서 CD
// E 해시화, E 해시화 => 둘 다 더해서 EE

// AB 해시화, CD 해시화 => 더해서 ABCD
// EE 해시화, EE 해시화 => 더해서 EEEE

// ABCD 해시화, EEEE 해시화 => ABCDEEEE 결과 나옴

 

머클 트리는 데이터의 무결성을 검증에 사용되는 트리구조이다. 다음은 머클 트리의 특징이다.

  • 블록의 필수 요소이고, 데이터들을 해시화해서 더한 해시화 반복
  • 트리처럼 뻗어서 마지막 루트 해시 구해서 사용한다.
  • 중간 데이터가 변경되면 루트 해시 값도 변경되기 때문에 데이터의 변경됨을   있다.
    • 데이터 두 개씩 묶어서 올라가게 되면, 거래량이 기하급수적으로 늘어나도 특정 거래를 찾기 쉽다는 이점이 있다.
    • 거래내역을 위조하려는 시도가 있어도 머클트리의 경로를 따라가면 해시 값이 다른 것을 찾을 수 있어, 거래의 위변조를 빠르게 알 수 있고 이를 방지할 수 있다.
  • 블록체인의 일부 데이터만 다운 받아도 특정 거래를 찾을 수 있도록 도와준다.
    • 모든 블록체인을 '풀 노드(full node)'라고 하는데, 블록체인의 용량은 시간이 지날 수록 지속적으로 늘어나기 때문에 성능이 좋은 컴퓨터만이 다운받을 수 있다.
    • 일부 정보(머클 트리의 루트 포함한 헤더)만을 저장하는 것을 '라이트 노드(light node)라고 한다.
    • 모바일로도 쉽고 빠르게 특정 거래를 찾을 수 있도록 도와준다.

 

 

 


Block 생성

블록체인의 기본 개념을 코드로 구현하여 살펴보고자 한다.

작성되는 코드는 다음의 구성을 가진다.

  1. 블록과 블록 헤더를 만든다.
  2. 블록체인의 첫 번째 블록인 제네시스 블록을 생성한다.
  3. 그 다음 블록을 생성하는 방법을 작성한다.

 


💡 제네시스 블록(Genesis Block)? 

  • 제네시스 블록은 블록체인의 첫 번째 블록이다.
  • 이전 블록이 없기 때문에 이전 블록을 참조하지 않는다.
  • 이는 일반적으로 블록 헤더의 "이전 해시" 필드를 특수 값(종종 모두 0으로 표시됨)으로 설정하여 표시된다.

 

 

1. 필요한 모듈을 가져온다.

👉 vsc 터미널에서 필요한 모듈을 install 한다.

# package.json 생성
npm init -y

# crypto-js install
npm i -D crypto-js

# merkle install
npm i -D merkle

 

👉 필요한 모듈을 require 한다.

const { SHA256 } = require("crypto-js");
const merkle = require("merkle");

 

2. Block의 Header를 설정한다.

블록의 헤더 정보를 저장하는 클래스 Header를 생성한다. 생성자(constructor)에 매개변수로 블록의 높이(_height)와 이전 블록의 해시 값(_previousHash)을 받는다. 

블록의 버전, 높이, 생성시간, 이전 블록의 해시 값을 초기화 한다.

getVersion과 getTimeStamp 메서드를 정적(static)타입으로 생성하여 블록의 버전과, 생성시간을 반환한다.

class Header {
  constructor(_height, _previousHash) {
    // 블록의 버전
    this.version = Header.getVersion();
    // 블록의 높이
    this.height = _height;
    // 블록의 생성시간
    this.timestamp = Header.getTimeStamp();
    // 이전 블록의 해시 값
    // 최초 블록은 이전 블록이 없으니까 0으로 대체
    this.previousHash = _previousHash || "0".repeat(64);
  }
  // static으로 메서드 선언 하면 전역으로 사용할 수 있고
  // 이 클래스로 객체를 생성.
  // 즉 동적할당 했을 때 이 메서드가 그 객체에 생성되지 않는다.
  static getVersion() {
    return "1.0.0";
  }
  static getTimeStamp() {
    return new Date().getTime();
  }
}

 

3. Block의 Block을 설정한다.

블록의 정보를 저장하는 클래스 Block을 생성한다.

 

생성자(constructor)에 매개변수로 블록의 헤더 값(_header)과 데이터(_data)를 받는다.

블록 헤더(_header)로부터 받은 값으로 블록의 버전, 높이, 생성시간, 이전 블록의 해시 값을 초기화 한다. 

getMerkleRoot 메서드에서 매개변수로 받은 데이터(_data)로 Merkle root를 반환한다.

class Block {
  // block _header, _data 헤더 객체와 내용을 받아서 생성
  constructor(_header, _data) {
    this.version = _header.version;
    this.height = _header.height;
    this.timestamp = _header.timestamp;
    this.previousHash = _header.previousHash;
    this.data = _data;
    this.merkleRoot = Block.getMerkleRoot(_data);
    // 블록의 해시
    this.hash = Block.createBlockHash(_header, Block.getMerkleRoot(_data));
  }
  // _data를 기반으로 Merkle root를 반환한다.
  static getMerkleRoot(_data) {
    const merkleTree = merkle("sha256").sync(_data);
    return merkleTree.root();
  }
  // 블록 헤더와 Merkle root를 사용하여 블록의 해시를 계산
  static createBlockHash(_header, _merkleRoot) {
    // 값을 모두 배열로 가져와서
    const values = Object.values(_header);
    // join으로 배열을 문자열로 합치고 구분점은 빈 문자열 ""
    const data = values.join("") + _merkleRoot;
    return SHA256(data).toString();
  }
}

 createBlockHash 메서드의 매개변수로 블록의 헤더정보(_header)를 받아오고, getMerkleRoot에서 반환한 머클루트 값(_merkleRoot)을 받아온다. 

 

Object.values 메서드로 블록 헤더 객체(_header)의 값을 뽑아 배열화시킨다.

const values = Object.values(_header);
console.log("values : ", values);

콘솔에 찍힌 values 값이다. 

values :  [
  '1.0.0',
  1,
  1693836621473,
  'f54ca106d1bda482a69da73f9ae2a7728be4f7b59508113ebdb3931548c6fb5f'
]

 

values 값을 join시켜 배열의 모든 요소를 연결하고 _merkleRoot 값을 연결한다.

_merkleRoot 의 값이 ' 49463C857DA98C9D7BC28531CBA08A7357D74C341FB7F249A658F182048FD7D3 '라고 했을 때

 const data = values.join("") + _merkleRoot;
 console.log("data : ", data);

콘솔에 찍힌 data값이다. 

1.0.011693836621473f54ca106d1bda482a69da73f9ae2a7728be4f7b59508113ebdb3931548c6fb5f49463C857DA98C9D7BC28531CBA08A7357D74C341FB7F249A658F182048FD7D3

이 값을 뜯어보면 아래와 같다.

1.0.0(헤더의 버전 값)

1(헤더의 높이 값)

1693836621473(헤더의 타임스탬프)

f54ca106d1bda482a69da73f9ae2a7728be4f7b59508113ebdb3931548c6fb5f(헤더의 해시 값)

49463C857DA98C9D7BC28531CBA08A7357D74C341FB7F249A658F182048FD7D3(_merkleRoot 값)

결과적으로 SHA256 함수를 사용하여 문자열 data의 해시 값을 계산하고, 그 결과를 문자열로 반환한다.

 

4. 제네시스 블록 생성

Header 클래스의 매개변수로 height 값을 0으로 주고, previousHash 값을 주지 않아 "0".repeat(64)가 동작해 0의 값이 대체된다. Block 클래스의 매개변수로 생성된 헤더 객체와 블록체인의 첫 기사 제목의 값을 data로 넣어 제네시스 블록을 생성한다.

// 블록 헤더 객체 생성
// 첫 번째 블록 0을 주고 객체 생성
const header = new Header(0);
// version: '1.0.0',
// height: 0,
// timestamp: 1693790990464,
// previousHash: '0000000000000000000000000000000000000000000000000000000000000000',

const block = new Block(header, data);
console.log(block);

다음은 콘솔의 내용이다.

Block {
  version: '1.0.0',
  height: 0,
  timestamp: 1693836621469,
  previousHash: '0000000000000000000000000000000000000000000000000000000000000000',
  data: [
    'The Times 03/Jan/2009 Chancellor on brink of second bailout for banks'
  ],
  merkleRoot: 'A6D72BAA3DB900B03E70DF880E503E9164013B4D9A470853EDC115776323A098',
  hash: 'f54ca106d1bda482a69da73f9ae2a7728be4f7b59508113ebdb3931548c6fb5f'
}

 

5. 두 번째 블록 생성

Header 클레스의 매개변수로 height 값을 1로 전달하고, previousHash 값을 이전 블록(예시의 경우 제네시스 블록)의 해시 값을 전달한다. Block 클래스의 매개변수로 블록의 헤더 객체와 "두 번째 블록 데이터" 문자열이 전달된다.

const header2 = new Header(1, block.hash);
const block2 = new Block(header2, ["두 번째 블록 데이터"]);
console.log(block2);

다음은 콘솔의 내용이다.

Block {
  version: '1.0.0',
  height: 1,
  timestamp: 1693836621473,
  previousHash: 'f54ca106d1bda482a69da73f9ae2a7728be4f7b59508113ebdb3931548c6fb5f',
  data: [ '두 번째 블록 데이터' ],
  merkleRoot: '49463C857DA98C9D7BC28531CBA08A7357D74C341FB7F249A658F182048FD7D3',
  hash: 'fa25aab3bbb57d7bc8a6db92df41ddfd9b4d495fbbfdffe3835a83b8490f41e5'
}

해당 결과에서 previousHash 값을 확인하면 이전 블록의 해시 값이 들어가야 하는데 지금 블록의 previousHash 값과 이전 블록인 제네시스 블록의 hash 값과 동일한 것을 확인할 수 있다.

 

 

 

 


TypeScript로 Block 생성

 

#. 폴더의 구조는 다음과 같다.

 

1. 사용할 모듈을 install 한다.

npm init -y

npm i -D typescript ts-node
npm i -D @types/merkle merkle
npm i -D @types/crypto-js crypto-js

# tsc-alias tsconfig-paths : node 환경에서 실행을 할 때 우리가 정해준 별칭을 경로로 변환해서 실행기키기 위해 사용
npm i -D tsc-alias tsconfig-paths

npx tsc --init

npm i -D @types/jest jest
npm i -D ts-jest
npm i -D crypto-js

 

2. src/core/interface/block.interface.ts_블록과 블록 헤더의 타입을 지정한다.

// 블록의 헤더 타입을 지정
export interface IBlockHeader {
  version: string;
  height: number;
  timestamp: number;
  previousHash: string;
}

// 블록의 타입을 지정
export interface IBlock extends IBlockHeader {
  //  IBlockHeader 속성을 상속 받았으므로, 해당 인터페이스 속성이 들어가있는 것과 같다
  merkleRoot: string;
  hash: string;
  nonce: number;
  difficulty: number;
  data: string[];
}

 

3. src/core/interface/failable.interface.ts_블록의 유효성의 결과를 나타내는 타입을 생성한다.

Result객체는 isError속성의 값이 false이므로, 블록이 유효할 경우 사용되는 객체이다. Faillure 객체는 isError속성의 값이 true이므로, 블록이 유효하지 않을 경우 사용되는 객체이다.

Failable의 타입은 Result나 Faillure일 수 있으며, 매개변수로 Result과 Faillure 인터페이스의 매개변수 값을 전달해 줄 수 있다.

export interface Result<R> {
  // 에러가 false이므로 Result 객체가 성공적인 결과임을 나타낸다.
  isError: false;
  value: R;
}

export interface Faillure<E> {
  // 에러가 true이므로 Faillure 객체는 실패한 결과를 나타낸다.
  isError: true;
  value: E;
}

// Failable의 타입은 Result 또는 Faillure일 수 있다.
export type Failable<R, E> = Result<R> | Faillure<E>;

/* Failable<string,number>로 넘겨줄 경우
Result<R>에는 string이 넘어가 
export interface Result<R> {
  isError: false;
  value: string;
}
이 되고,

Faillure<E>에는 number가 넘어가
export interface Faillure<E> {
  isError: true;
  value: number;
}
가 된다.
*/

 

4. tsconfig.json_타입 스크립트의 컴파일을 설정한다.

{
  // compilerOptions: typescript 컴파일 진행시 어떤 형태로 컴파일할지 속성을 정의 
  "compilerOptions": {
    // module: 모듈 시스템을 지정 
    "module": "CommonJS",
    // outDir: 내보낼 경로를 지정
    "outDir": "./dist",
    // target: 번들링 문법을 지정
    "target": "ES6",
    // esModuleInterop: 문법을 자연스럽게 변경. 주로 true로 설정 
    "esModuleInterop": true,
    // baseUrl: 모듈의 상대 경로 지정
    "baseUrl": ".",
    // paths: 'baseUrl' 경로를 기준으로 상대 위치를 가져오는 매핑 값.
    "paths": {
      // baseUrl 경로 부터 별칭 사용
      "@core/*": ["src/core/*"]
    }
  },
  "ts-node": {
    // tsconfig-paths/register: 타입스크립트 모듈 경로를 해석하고 관리하는 라이브러리인 
    // 'tsconfig-paths'의 등록(register)명령을 실행한다.
    // 따라서, baseUrl과 paths 설정을 기반으로 모듈 경로를 해석하고 실제 파일의 경로로 변환하는데 사용된다.
    "require": ["tsconfig-paths/register"]
  }
}

 

5. src/core/crypto/crypto.module.ts_16진수의 해시 값을 2진수로 변환한다.

' crypto-js '모듈의 SHA256으로 데이터를 해시화하면 16진수의 데이터로 해시값이 생성되는데 이 데이터로 작업증명(PoW) 마이닝 작업을 하기 위해서는 16진수의 값을 2진수로 변환해 주는 작업이 필요하다. 변환해야하는 이유는 아래와 같다.

  • POW 알고리즘은 채굴자가 찾은 해시 값에서 특정한 개수(난이도)의 선행 0의 값을 충족하도록 요구한다. 이 조건은 이진 형식으로 정의되기 때문에 16진수의 해시 값을 바이너리로 변환하여 채굴자가 찾은 해시가 기준에 일치하는지 확인한다.
  • 일부 암호화 라이브러리 및 기능은 바이너리 데이터를 처리할 때 더 효율적이므로 마이닝 프로세스가 더 빠르고 간소화된다.

아래의 코드는 16진수의 해시 값을 이진 형식으로 변환시켜주는 코드이다. 코드 진행순서는 다음과 같다.

  1. 해시 문자열을 2글자씩 가지고 와서 반복
  2. for 반복문에서 i를 2씩 증가한다.
  3. 16진수의 바이트를 10진수로 변환한다.
  4. 10진수를 2진 문자열로 반환하고 8자리로 패딩한다.
  5. 현재의 2진 바이트를 최종 이진 문자열(변수 binary)에 추가한다.

for문은 해시 값의 인덱스 값(hash.lenght -1)만큼 반복하고 2씩 증가한다.

변수 hexByte로 해시 값에서 인덱스 i부터 +2 인덱스의 값을 가져온다. 예를 들어, 해시 값이 ' 0b3dbbbad3 ~ ' 에서 i가 0이라면, 인덱스 0부터 인덱스 1의 값을 가져오기 때문에 ' 0b '의 값을 가져오는 것이다. hexByte에서 해시 값을 2개씩 가져오는 이유는 16진수의 값은 일반적으로 문자 쌍을 사용하여 이진 형식으로 변환하기 때문이다. (' string.substr(start, length) ' : 문자열의 특정위치(start)부터 특정길이만(length)큼의 값을 추출한다.)

변수 dec은 parseInt 메서드를 사용해 hexByte의 값을 10진수로 변환한다. ( ' parseInt(string, radix) ' : 문자열(string)의 값을 10진수로 변환한다. 문자열의 진수(radix)를 작성해주어야 한다. )

변수 binaryByte는 dec을 2진수로 변환한다. toString(2)를 사용해 10진 데이터를 2진수로 변환하고, padStart(8, "0")을 사용해 8자리로 패딩한다. 따라서 binaryByte의 값은 8자리의 2진수로 표현된다. ( ' obj.toString(radix) ' : 문자열을 반환한다. 매개변수로 number를 넣으면 해당 기수로 문자열을 표현한다. , ' str.padStart(targetLength, padString) ' : 현재 문자열의 시작을 다른 문자열로 채워, 주어진 길이(targetLength)를 만족하는 새로운 문자열을 반환한다. 채워넣기(padString)는 대상 문자열의 시작(좌측)부터 적용된다.)

padStart ex.

"abc".padStart(8, "0"); // "00000abc"

 

반복문을 돌면서 생성된 binaryByte의 값을 binary에 추가하며 최종적인 이진 문자열을 빌드한 binary 변수를 반환한다.

class CryptoModule {
  static hashToBinary(hash: string): string {
    let binary: string = "";

    // 1. 해시 문자열을 2글자씩 가지고 와서 반복
    for (let i = 0; i < hash.length; i += 2) {
      // 2. 반복문에서 i를 2씩 증가
      const hexByte = hash.substr(i, 2);

      // 3. 16진수의 바이트를 10진수로 변환
      const dec = parseInt(hexByte, 16);

      // 4. 10진수를 2진 문자열로 변환. 8자리로 패딩
      const binaryByte = dec.toString(2).padStart(8, "0");

      // 5. 현재의 2진 바이트를 최종 이진 문자열에 추가
      binary += binaryByte;
    }

    return binary;
  }
}

export default CryptoModule;

 

6. src/core/block/blockHeader.ts_블록 헤더의 클래스를 정의한다.

BlockHeader 클래스는 2번에서 설정한 IBlockHeader 인스턴스의 설정을 받아 구현한다.

constructor 이전 블록(_previousBlock)의 정보를 받아와서 현재 블록 헤더를 초기화한다. 즉, 새로운 블록 헤더를 생성할 때 이전 블록의 정보가 필요한 것이다.

getVersion 메서드는 정적(static)메서드로 블록의 버전 값을 반환하고, getTimestamp 메서드는 정적(static)메서드로 블록이 생성될 때 현재시간의 값을 반환한다.

import { IBlock, IBlockHeader } from "@core/interface/block.interface";

class BlockHeader implements IBlockHeader {
  version: string;
  height: number;
  timestamp: number;
  previousHash: string;
  constructor(_previousBlock: IBlock) {
    // 블록을 생성할 때 이전 블록의 정보가 필요하다.
    // 이전 블록의 해시나 높이 등
    this.version = BlockHeader.getVersion();
    this.timestamp = BlockHeader.getTimestamp();
    this.height = _previousBlock.height + 1;
    this.previousHash = _previousBlock.hash;
  }
  static getVersion() {
    return "1.0.0";
  }
  static getTimestamp() {
    return new Date().getTime();
  }
}

export default BlockHeader

 

7. src/core/block/block.ts_블록체인 블록의 클래스를 정의한다.

Block 클래스는 IBlock의 형태를 가지며, 블록 헤더의 값을 갖는 BlockHeader 클래스의 속성을 참조한다. 

constructor는 생성자는 이전 블록(_previousBlock)과 데이터(_data)를 매개변수로 받아와서 새로운 블록을 초기화한다. 이전 블록의 정보를 기반으로 머클 루트, 해시 값, 데이터 등을 설정한다.

Block 클래스에는 여러 정적 메소드가 선언되어있다. 먼저 Block 클래스에 대한 전체 구조를 확인하고 아래에서 메소드를 자세히 확인하고자한다.

import { SHA256 } from "crypto-js";
import merkle from "merkle";
import BlockHeader from "./blockHeader";
// @core : tsconfig.json에서 해당 경로를 별칭으로 지정했기 때문.
import { IBlock } from "@core/interface/block.interface";
import { Failable } from "@core/interface/failable.interface";
import CryptoModule from "@core/crypto/crypto.module";

// block 형태를 클래스로 정의
// IBlock 형태를 가지고 BloxkHeader의 속성을 참조한 Block 클래스
class Block extends BlockHeader implements IBlock {
  hash: string;
  merkleRoot: string;
  nonce: number;
  difficulty: number;
  data: string[];
  constructor(_previousBlock: Block, _data: string[]) {
    //  부모클래스 생성자 호출 super
    super(_previousBlock);
    this.merkleRoot = Block.getMarkleRoot(_data);
    // 블록 본인의 데이터를 해시화한게 블록의 해시값이기 때문에 매개변수로 this를 담는다.
    this.hash = Block.createBlockHash(this);
    // 블록 채굴은 뒤에 추가. 지금은 0으로.
    this.nonce = 0;
    // 지금은 난이도 3. 채굴에 관한 내용
    this.difficulty = 3;
    this.data = _data;
  }

  // 블록 추가
  static generateBlock(_previousBlock: Block, _data: string[]): Block {
	....
  }

  // 마이닝 작업 코드
  static findBlock(generateBlock: Block) {
	....
  }

  // 블록의 해시를 구하는 함수
  static createBlockHash(_block: Block): string {
	....
  }

  // 머클루트 구하는 함수
  static getMarkleRoot<T>(_data: T[]): string {
  	....
  }

  // 블록이 유효한지 정상적인 블록인지 검사한다.
  static isValidNewBlock(
	....
  }
}
export default Block;

 

generateBlock 메서드는 마이닝을 통해서 블록의 생성 권한을 받은 블록을 생성한다. 매개변수로 이전 블록(_previousBlock)과 데이터(_data)를 매개변수로 받아온다. Block 인터페이스 타입으로 반환한다.

  // 블록 추가
  static generateBlock(_previousBlock: Block, _data: string[]): Block {
    const generateBlock = new Block(_previousBlock, _data);
    // 마이닝을 통해서 블록의 생성 권한을 받은 블록을 만든다.
    const newBlock = Block.findBlock(generateBlock);
    return newBlock;
  }

 

findBlock 메서드는 마이닝(채굴) 작업을 하는 코드이다. 연산을 통해 난이도에 따른 정답을 찾는다. 정답에 충족하는 값을 구하면 보상으로 블록의 생성 권한을 얻는다.난이도 조건을 만족하는 블록을 찾을 때까지 반복한다.

POW(proof of work) 작업 증명 알고리즘으로 작업이 진행된다. 블록의 난이도에 충족하는 값을 구하기 위해서 연산 작업을 계속 진행한다. 조건에 충족하는 값을 구하면 블록을 생성할 수 있는 권한을 부여한다.

변수 nonce는 마이닝 작업한 횟수를 나타낸다. 마이닝 작업을 수행할 무한루프 while문 내에서 1씩 증가한다. nonce는 블록의 해시 값을 만들 때 적용하는 속성 중 하나로, findBlock 메서드에서 해당 블록의 해시 값이 난이도에 부합하지 않으면 nonce 값을 변경해서 새로운 해시 값을 만들어낸다. nonce가 변경됨으로 새롭게 생성된 해시 값을 다시 메소드에서 검사를 진행해 난이도에 부합하는 해시값을 찾을 때까지 반복한다.

블록의 해시 값을 구하는 메서드인 createBlockHash를 사용해 매개변수로 받은 블록의 해시 값을 구해 변수 hash에 넣는다.

변수 binary는 해시 값을 CryptoModule 클래스의 hashToBinary 메서드를 이용하여 16진수의 해시 데이터를 2진수로 변환해 반환받은 값을 binary 변수에 넣는다. 이 작업으로 연산의 값이 난이도에 충족했는지 비교, 확인할 수 있다.

변수 result는 이진 데이터를 갖고있는 binary의 값이 난이도에 충족했는지 체크하는 변수이다. startWith 메서드를 사용해 binary의 값의 시작이 난이도에 부합하는지 체크한다. 난이도의 값은 generateBlock.difficulty로 이전 블록의 난이도 값을 받아와 확인한다. ( ' str.startWith(searchString, position) ' : 문자열이 특정 문자로 시작하는지 확인해서 boolean 값으로 반환한다. )

if문으로 난이도에 충족하는 binary 값을 찾았을 경우(result의 결과 값이 true일 경우) 연산을 통해 완성된 hash값과 완성된 블록의 데이터를 반환한다.

static findBlock(generateBlock: Block) {
    let hash: string;
    let nonce: number = 0;

    while (true) {
      generateBlock.nonce = nonce;
      nonce++;

      // 블록 해시 구하는 구문 추가
      hash = Block.createBlockHash(generateBlock);

      const binary: string = CryptoModule.hashToBinary(hash);
      console.log("binary : ", binary);

      // 연산의 값이 난이도에 충족했는지 체크할 변수
      // startsWith: 문자열의 시작이 매개변수로 전달된 문자열로 시작하는지 체크.
      // ex) binary.startsWith의 값 "000" = 이 문자열로 시작하는지 결과가 true, false로 반환된다.
      const result: boolean = binary.startsWith(
        "0".repeat(generateBlock.difficulty)
      );
      console.log("result : ", result);

      // 조건 충족 했으면 블록 채굴할 수 있는 권한을 얻었고 조건에 충족해서 나온 값을 반환한다.
      if (result) {
        // 연산을 통해 완성된 hash 값과
        generateBlock.hash = hash;
        // 완성된 블록을 내보내 준다.
        return generateBlock;
      }
    }
  }

 

createBlockHash 메서드는 블록의 해시 값을 구하는 메소드이다. 매개변수로 해시 값을 구할 블록을 받아온다. 블록의 여러 속성값을 구조분해 할당으로 받아와 value 값으로 넣어준다. 이어 붙인 속성값들을 SHA256 암호화 방식으로 해시된 데이터를 문자열 형태로 반환한다.

  static createBlockHash(_block: Block): string {
    const {
      version,
      timestamp,
      height,
      merkleRoot,
      previousHash,
      difficulty,
      nonce,
    } = _block;
    const value: string = `${version}${timestamp}${height}${merkleRoot}${previousHash}${difficulty}${nonce}`;
    return SHA256(value).toString();
  }

 

getMerkleRoot 메소드는 머클루트를 구하는 함수이다. 제네릭 코드를 사용하여 메소드의 매개변수로 받은 T로 받은 매개변수의 타입을 사용한다. 변수 merkleTree는 sha256 방식으로 _data의 값을 해시화한다. 메소드는 해시화한 데이터의 머클루트를 반환한다.

  // 머클루트 구하는 함수
  static getMerkleRoot<T>(_data: T[]): string {
    const merkleTree = merkle("sha256").sync(_data);
    return merkleTree.root();
  }

 

isValidNewBlock 메소드는 블록이 유효한 블록인지 검사하는 메소드이다. 매개변수로 새로 생성된 블록과 이전에 생성된 블록을 받아와 두 블록을 비교하면서 새로 생성된 블록이 유효한 블록인지 검사하는 코드이다. 반환되는 값의 타입은 Failable 타입을 따른다. 이전에 확인한 Failable 타입은 다음과 같았는데

  export interface Result<R> {
  isError: false;
  value: R;
  }

  export interface Failure<E> {
  isError: true;
  value: E;
  }

  export type Failable<R, E> = Result<R> | Failure<E>;                                                                         - failable.interface.ts - 

Failable< R, E > 타입을 Failable<Block, string> 로 정의를 했으므로, 인터페이스 Reasult의 value는 Block 타입으로 지정되고, 인터페이스 Failure의 value 타입은 string으로 지정된다. 

if문으로 블록의 정상여부를 걸러낸다. 첫 번째 if문은 '이전 블록의 height 값 + 1' 이 '새로운 블록의 height' 값과 동일한가 확인한다. 동일하지 않으면 isError의 값을 true로 전달해 Failure의 형식으로 반환하도록 한다. 

두 번째 if문은 '이전 블록의 hash 값' 이 '새로운 블록의 previousHash(이전 해시 값)'과 동일한지 확인한다. 동일하지 않으면 isError의 값을 true로 전달해 Failure의 형식으로 반환하도록 한다. 

세 번째 if문은 새로운 블록의 데이터가 변조되었는지 확인하는 구문이다. '새로운 블록의 해시 값을 createBlockHash 메서드를 사용해 블록의 현재 데이터로 다시 hash 값을 생성'하고 그 값이 기존에 블록이 가지고 있던 hash 값과 일치하는지 확인한다. 블록의 값이 바뀌었다면 hash 값도 변경되었을테니 말이다. 동일하지 않으면 isError의 값을 true로 전달해 Failure의 형식으로 반환하도록 한다. 

모든 if문을 통과하여 블록이 정상인지 판단되었다면 isError의 값을 false로 전달하고 value 값을 검사를 진행했던 블록으로 전달한 객체를 반환한다.

static isValidNewBlock(
    _newBlock: Block,
    _previousBlock: Block
  ): Failable<Block, string> {
    // 블록의 유효성 검사를 하는데

    // 블록의 높이가 정상적인지 검사.
    if (_previousBlock.height + 1 !== _newBlock.height)
      return { isError: true, value: "이전 높이 오류" };

    // 이전 블록의 해시 값이 새로운 블록의 이전 해시 값과 동일한지 검사
    if (_previousBlock.hash !== _newBlock.previousHash)
      return { isError: true, value: "이전 블록 hash 오류" };

    // 생성된 블록의 정보를 가지고 다시 해시해서 블록의 값이 변조되었는지, 정상적인 블록인지 검사
    if (Block.createBlockHash(_newBlock) !== _newBlock.hash)
      return { isError: true, value: "블록 해시 오류" };

    // 블록이 유효성 검사를 통과. 정상적인 블록이다.
    return { isError: false, value: _newBlock };
  }
}

 

8. config.js_제네시스 블록을 하드코딩으로 생성한다.

Block 이나 BlockHeader 클래스에서 새로운 블록을 생성할 때 이전 블록의 데이터가 필요하므로, 최초의 블록인 제네시스 블록을 하드코딩으로 생성해준다.

// 제네시스 블록
// 최초 블록은 하드 코딩으로 생성
import { IBlock } from "./interface/block.interface";
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: [
    "The Times 03/Jan/2009 Chancellor on brink of second bailout for banks",
  ],
};

 

9. jest.config.ts_테스트 코드를 실행할 jest 옵션을 설정한다.

루트 디렉토리에 ' jest.config.ts ' 파일을 생성한다.

jest에 대한 자세한 설명은 다음 포스트를 참고하도록 하자.

 

[JEST] 📚 JEST 소개 & 기본 사용법 정리

JEST 란? Jest는 페이스북에서 만들어서 React와 더불어 많은 자바스크립트 개발자들로 부터 좋은 반응을 얻고 있는 테스팅 라이브러리다. 출시 초기에는 프론트앤드에서 주로 쓰였지만 최근에는

inpa.tistory.com

앞서, 1번 작업에서 jest 라이브러리를 install 했으므로 새로 생성한 jest.config.ts 파일에서 jest를 설정하기 위한 함수를 import 한다.

import type { Config } from "@jest/types";

'Config.InitialOptions'는 Jest 구성을 정의할 때 Jest가 기대하는 초기 구성 옵션을 설정하는 코드이다. 설정하는 세부 속성들은 다음과 같다.

  • moduleFileExtensions : jest가 테스트 파일로 인식하는 파일의 확장자를 설정한다.
    • 현재 설정의 경우, TypescriptJavascript를 테스트 파일로 인식하도록 지정한다.
  • testMatch : 어떤 파일을 테스트 파일로 실행할지 설정한다.
    • 현재 설정의 경우, ' ["<rootDir>/**/*.test.(js|ts)"] ' 이므로 프로젝트의 루트 경로(<rootDir>)에서 모든 폴더의 모든 파일 중, 파일의 이름이 '.test.ts' 이거나 '.test.js'인 파일을 테스트 파일로 실행한다.
  • moduleNameMapper : 모듈 이름의 별칭을 설정한다.
    • 현재 설정의 경우, { "^@core/(.*)$" : "<rootDir>/src/core/$1" } 이므로, '@core'로 시작하는 별칭은 루트 경로에서 'src/core'로 연결된다.
  • testEnvironment : 테스트 환경을 설정한다.
    • 현재 설정의 경우, node 환경에서 실행하도록 설정한다.
  • verbose : 테스트를 실행할 때 더 자세한 로그를 출력할지 여부를 설정한다. true로 설정 시 자세한 로그 출력을 허용하는 것이다.
    • 현재 설정의 경우, true이므로 자세한 로그를 출력한다.
  • preset : typescript에서 사용할 jest 및 ts-node 설정을 지정한다.
    • 현재 설정의 경우, ts-jest를 사용해서 typescript 프로젝트의 테스트를 실행한다.
import type { Config } from "@jest/types";

const config: Config.InitialOptions = {
  // 1. 모듈 파일 확장자 설정 : typescript와 javascript 둘 다 테스트 파일로 지정
  moduleFileExtensions: ["ts", "js"],

  // 2. 테스트 파일 매치 설정 : 파일 이름의 패턴을 설정
  // 루트 경로에서 모든 폴더에, 모든 파일 이름의 패턴이 'test.js' or 'test.ts'
  testMatch: ["<rootDir>/**/*.test.(js|ts)"],

  // 3. 모듈의 이름에 대한 별칭 설정 : @core
  // 별칭으로 지정된 @core를 어떻게 경로를 바꿔줄거냐
  // ^@core == @core/**/* 시작하는 별칭은 루트 경로에 src/core의 경로까지
  moduleNameMapper: {
    "^@core/(.*)$": "<rootDir>/src/core/$1",
  },

  // 4. 테스트 환경 설정 : node환경에서 실행 시킬거임
  testEnvironment: "node",

  // 5. 자세한 로그 설정 출력: 터미널에 로그들을 더 자세히 출력할지 여부
  verbose: true,

  // 6. 프리셋 설정: typescript에서 사용할 jest / ts-node 설정
  preset: "ts-jest",
};

export default config;

 

10. package.json_jest로 스크립트를 실행하기 위해 예약어를 설정한다.

package.json 파일에서 script 부분에 ' "test": "jest" '를 추가한다.

VSC 터미널에서 npm run test 대신, npm test를 입력하면 테스트 스크립트 실행이 가능하다.

// ------ package.json ------
....
"scripts": {
    "test": "jest"
  },
....

 

11. src/core/__test/block.test.ts_블록체인 블록에 대한 단위 테스트 코드를 생성 및 실행한다.

작성한 테스트 코드를 설명하기에 앞서 테스트 코드를 작성의 장단점을 짚어보자면 아래와 같다.

  • 단점. 별도의 테스트 코드를 작성해야하기 때문에 시간이 오래 걸린다는 단점이 있다.
  • 장점. 단위별로 테스트를 진행해서 디버깅을 진행할 수 있기 때문에 절차적인 테스트가 가능하다. 따라서, 코드의 품질을 올릴 수 있다.

 

먼저, 테스트에 필요한 모듈을 가져온다.

import Block from "@core/block/block";
import { GENESIS } from "@core/config";

 

describe 함수를 실행하면 테스트들을 그룹화 할 수 있다. it 함수를 사용하면 각각의 테스트를 생성할 수 있으며, 테스트 코드의 최소 단위이다.  다음은 테스트 그룹화에 대한 간단한 예시이다.

// describe: 테스트들의 그룹화. 그룹을 지정할 수 있다.
  // 첫 번째 매개변수: 그룹의 명. 어떤 테스트 그룹인지 설정한다.
  // 두 번째 매개변수: 테스트들을 실행시키는 콜백 함수
describe("block 테스트 코드 그룹", () => {
  // 테스트들의 단위를 어떻게 작성하냐
  // 하나의 테스트 단위
    // 첫 번재 매개변수: 테스트 이름.
    // 두 번째 매개변수: 테스트의 동작을 가지고 있는 콜백 함수
  it("제네시스 블록 테스트", () => {
    console.log(GENESIS);
  });

});

 

현재 작성한 block 검증 테스트 그룹은 '블록 추가' 그룹과 '블록 유효성 검증' 테스트가 있다. 전체적인 구성은 다음과 같다.

describe("block 검증", () => {
  // 테스트 시 사용할 블록을 담을 변수
  let newBlock: Block;
  let newBlock2: Block;

  it("블록 추가", () => {
	....
  });

  it("블록 유효성 검증", () => {
	....
  });
});

 

각각의 테스트 코드들을 자세히 살펴보고, 연산 순서를 확인, 그에 대한 결과값을 확인하며 정리를 마치려고 한다.

'블록추가' 테스트 코드는, 이전 블록의 데이터를 기반으로 새로운 블록을 생성하는 테스트 코드이다. Block 클래스의 generateBlock 메서드를 사용해 생성한 블록을 이전에 선언한 변수에 담는다. 코드와 함께 자세히 살펴보면,

const data = ["Block 1"];
newBlock = Block.generateBlock(GENESIS, data);

data에 블록 생성 시, data 속성 값에 넣을 값을 string 타입으로 변수 data에 저장한다. Block.generateBlock 메서드를 사용하여 추가적으로 블록을 생성하는데, 첫 생성이므로 이전 블록의 값을 담을 첫 번째 매개변수 자리에는 초기의 블록인 GENESIS 블록을 전달하고, 두 번째 매개변수에는 블록의 data속성에 넣을 data 값을 전달한다. 이렇게 생성된 블록의 데이터를 변수 newBlock에 담는다.

마찬가지로 변수 newBlock2에 담을 블록도 같은 방식으로 생성된다. 다만, newBlock2는 newBlock 생성 뒤 만들어지는 블록이므로, Block.generateBlock 메서드의 첫 번째 매개변수로 전달할 값이 이전 블록의 값을 넘겨주어야 하므로 이때는 newBlock이 전달된다.

const data2 = ["Block 2"];
newBlock2 = Block.generateBlock(newBlock, data2);
console.log(newBlock2);

 

newBlock과 newBlock2의 결과값은 다음과 같다.

  # console.log(newBlock)
    Block {
      version: '1.0.0',
      timestamp: 1693997355603,
      height: 1,
      previousHash: '0000000000000000000000000000000000000000000000000000000000000000',
      merkleRoot: '8EB412D817C7762CBD93DD64982B163E9B75AB1E4B584052B2C675247A7A9C22',
      hash: '1da5112d313da3731d5fab5d302ae894170b8ccca7ff5ab7762a0bfbe76258f2',
      nonce: 15,
      difficulty: 3,
      data: [ 'Block 1' ]
    }
    
    
  # console.log(newBlock2)
    Block {
      version: '1.0.0',
      timestamp: 1693997355617,
      height: 2,
      previousHash: '1da5112d313da3731d5fab5d302ae894170b8ccca7ff5ab7762a0bfbe76258f2',
      merkleRoot: '3098EA9817BCA09FAD1817836ACACE069F4A63FAFDF7E981B6D2330EF1295A10',
      hash: '07e95baae003fffd2e031f374912c635f3d5716c99c765631a3261efa09e5481',
      nonce: 0,
      difficulty: 3,
      data: [ 'Block 2' ]
    }

 

'블록 유효성 검증' 테스트 코드는, Block.isValidNewBlock 메소드를 사용하여 블록의 유효성을 검사하고 검사한 결과 값을 변수 isValidNewBlock에 반환받는다. 반환받는 값은 Failable 타입에서 설정한 Result나 Faillure 인터페이스의 타입을 갖는다.

if문으로 반환받은 isValidNewBlock.isError 값이 true(오류가 발생했을 경우)라면 ' expect(true).toBe(false) '를 반환한다. (사실 이 코드가 이해하기 정말 어려웠는데 우선 나는 이런 방식으로 이해했다.)

expect(true).toBe(false)는 에러의 결과 값이 true이므로 오류가 발생한 것으로 인지하고 의도적으로 테스트 실패라는 값을 반환한 것이다. expect(true)의 true 값은 toBe(false)의 false 값과 동일(===)하지 않으므로 이 코드를 반환한다면 반드시 테스트 실패의 결과를 갖게된다.

반대로, ' expect(isValidNewBlock.isError).toBe(false) ' 코드는 expect()에 isValidNewBlock.isError의 값을 넣었지만 이미 위의 if문에서 한번 걸러 false 값만 받을 수 있는 조건이므로, 해당 코드는 ' expect(false).toBe(false) '로 진행될 것이므로, expect(false)의 true 값은 toBe(false)의 false 값과 동일(===)하므로 이 코드를 반환한다면 반드시 테스트 성공의 결과를 갖게된다.

  it("블록 유효성 검증", () => {
    const isValidNewBlock = Block.isValidNewBlock(newBlock, GENESIS);
    if (isValidNewBlock.isError) {
      // expect(true).toBe(false) : 값이 맞는지 확인할 때 사용하는 코드
      // 성공한 결과가 맞는지 확인하는 코드이다.
      // true false 비교해서 맞는지 확인
      return expect(true).toBe(false);
    }
    expect(isValidNewBlock.isError).toBe(false);
  });

 

🙆‍♂️ 테스트 성공 결과 예시

newBlock의 블록의 유효성 검사를 진행하고자 한다. 유효한지 값을 비교하기 위해서 사용될 블록은 GENESIS 블록이다. 이전 실행에서 newBlock은 GENESIS 블록 이후에 생성된 블록이므로, 이 비교의 결과 값은 테스트 성공일 것이다. 아래는 터미널에서 확인한 결과값이다.

 

🙅‍♂️ 테스트 실패 결과 예시

newBlock2의 블록의 유효성 검사를 진행하고자 한다. 유효한지 값을 비교하기 위해서 사용될 블록은 GENESIS 블록이다. 이전 실행에서 newBlock2는 newBlock 블록 이후에 생성된 블록이다. 따라서 이 비교의 결과 값은 테스트 실패이다. 아래는 터미널에서 확인한 결과값이다.

 

 

아직 블록체인의 개념을 배우는 중인데도 새로 배우는 내용이 많다보니 내용이 길어졌다... 정말 중요한 내용이니, 개념을 확실히 짚고 넘어야가될 것 같아 정말 코드 하나하나를 분석하고, 이해한 내용을 문서화 하다보니 상당한 시간이 걸렸다. 혹시 기억이 잘 안나더라도 열심히 정리를 했기 때문에 걱정은 없다! 🤗 코딩 정말 너무 어려운데 이해하고 동작이 되면 그만큼 또 짜릿한게 없는 것 같다 크크크

 

 

728x90