Blockchain

#9 체인 최신화

Sila 2022. 6. 17. 21:56

블록체인 네트워크 내의 모든 사람들이 동일한 체인을 가진다.

 

이를 위해서는 실시간 통신을 활용해 블록체인에 업데이트할 내용이 있는지를 확인한 후

 

블록체인을 최신화하는 것이 필요하다.

 

이제부터는 http, webSocket을 이용해 서로 가지고 있는 블럭체인에 대한 정보를 교환하고

 

그 정보에 따라 맞는 코드를  실행해 적절하게 블럭의 정보를 최신화하는 것을 구현할 것이다.

 

우선 서버를 실행하기 위해 express, ws 라이브러리를 설치한다.

 

npm i express
npm i --save-dev @types/express

npm i ws
npm i --save-dev @types/ws

 

루트 디렉토리에 index.ts 파일을 만들고 서버에 관련된 코드를 작성한다.

 

1. 서버 구축, 구동

/*  index.ts  */

import express from 'express'
const app = express()

app.use(express.json())

app.get('/', (req, res) => {
    res.send('hello server')
})

app.listen(3000, () => {
    console.log('server run 3000')
}

 

다음으로 여기서 import할 blockchain을 만들어준다.

 

/*  src/core/index.ts  */

import { Chain } from './blockchain/chain'

export class BlockChain {
    public chain : Chain
    
    constructor() {
        this.chain = new Chain()
    }
}

 

이 새로 만든 BlockChain class 객체는 Chain 만을 속성을 가진다. 이를 루트 디렉토리의 index.ts에서 import 해온다.

 

/*  index.ts  */

import { BlockChain } from './src/core/index'

const bc = new BlockChain()
// 전역 변수로 Blockchain class 객체를 하나 만든다

app.get('/getChain', (req, res) => {
    const result = bc.chain.getChain()
    console.log(result)
    res.json(bc.chain.getChain())
}

 

해당 uri로 접속하면 현재 블럭체인 (아마 제네시스 블럭만 있을) 을 볼 수 있을 것이다.

 

블럭을 생성하는 것도 다음과 같이 할 post 요청을 활용해 구현할 수 있다.

 

/*  index.ts  */

app.post('/mineBlock', (req, res) => {
    const { data } = req.body
    const newBlock = bc.chain.addBlock(data)
    if(newBlock.isError == true) {
        return res.status(500).send(newBlock.error)
    }
    res.send('successfully added')
}

 

2. WebSocket 통신

이제 이런식으로 업데이트된 데이터를 실시간을 주고 받을 수 있도록 webSocket을 추가할 것이다.

 

src 폴더 안에 serve 폴더를 생성하고 p2p.ts파일을 생성한다.

 

/*  src/serve/p2p.ts  */

import { WebSocket } from 'ws'

export class P2PServer {

    public sockets : WebSockets[]
    // 연결된 socket들을 배열로 담을 함수
    
    constructor() {
        this.sockets = []
    }
    
    listen() {
        const server = new WebSocket.server( { port : 7545 } )
        server.on('connection', (socket) => {
            console.log('webSocket connection done')
        }
        // connection이 발생하면 콘솔에 메시지를 출력한다
        // 이 코드는 상대방이 내게 'connection'할 때 실행된다
    }
    
    connectToPeer (newPeer: string) {
        const socket = new WebSocket(newPeer)
    }
    // 내가 상대방에게 연결할 때 이 코드가 실행
}

 

여기서 잠깐 WebSocket class 객체의 'on' method에 대해 복습을 하고 넘어가자면

 

'on' method는 JS의 addEventListener 함수와 비슷한 역할을 한다고 보면 된다.

 

인자 값으로 이벤트 종류와 그 이벤트 발생시 실행할 콜백함수를 넣어주는데,

 

위에서 server 변수에 on method를 걸고 넣은 두 개의 인자값을 예로 들어 설명하자면

 

( 다른 WebSocket과) connection이 발생할 경우 콜백 함수가 실행되어 'webSocket connection done' 메시지가 출력된다.

 

 

다시 루트 디렉토리의 index.ts로 돌아와 이를 P2PServer class객체를 import 해온다.

 

/*  index.ts  */

import { P2PServer } from './src/serve/p2p'

//...중략

app.post('/addToPeer', (req, res) => {
    const { peer } = req.body
    // ip를 포함한 정보를 담아 요청을 보내면
    ws.connectToPeer(peer)
    // connectToPeer 함수가 실행된다.
}

 

서버와 클라이언트가 계속해서 바뀔 것이므로 대신 node1과 node2로 대체하는 것이 좋을 것 같다.

 

동일한 블록체인 네트워크 참여자 node1과 node2는 똑같은 코드를 있다고 가정한다.

 

node2가 node1에게 post method로 자신의 정보 (보통 ip주소)를 담아 요청을 보내면 node1은 이 정보를 담아

 

WebSocket class 객체를 새로 생성한다.

 

이제 node2와 node1 간에 서로가 가진 블록체인에 대한 정보를 교환해야한다.

 

처음 서로에게 연결된 후 서로의 블록체인을 확인하는 것은 node1, node2 모두에게 발생할 일이므로

 

동일한 함수를 콜백함수에 추가해준다.

 

/*  src/serve/p2p.ts  */

import { WebSocket } from 'ws'

export class P2PServer {

    public sockets : WebSockets[]
    // 연결된 socket들을 배열로 담을 함수
    
    constructor() {
        this.sockets = []
    }
    
    listen() {
        const server = new WebSocket.server( { port : 7545 } )
        server.on('connection', (socket) => {
            console.log('webSocket connection done')
            this.connectSocket(socket)
            // 연결된 상대방의 정보를 매개변수로 갖는 conenctSocket 함수 실행
        }
    }
    
    connectToPeer (newPeer: string) {
        const socket = new WebSocket(newPeer)
        socket.on('open', () => {
            this.sonnectSocket(socket)
        }
        // 내가 상대방과 연결되는 것을 인식해 connectSocket 함수 실행
    }
    
    connectSocket( socket : WebSocket ) {
        this.socket.push(socket)
                
        socket.send('hello network peer')
        // 연결된 node에게 메시지를 보낸다
        
        socket.on('message', (data : string) => {
            console.log(Buffer.from(data).toString())
        }
        // 메시지 수신을 인식해 그 메시지를 출력해주는 함수
    }
}

 

connectSocket 함수가 node1, node2 모두에게서 실행이 되지만 그 실행까지의 과정이 다르다는 것에 유의해야한다.

 

node2에게서 연결 요청을 받은 node1은 'connection' 이벤트에 반응해 connectSocket을 실행한다.

 

node2는 node1과 연결이 되었다는 이벤트 'open' 에 반응한 콜백함수가 실행되어 connectSocket을 실행하는 것이다.

 

(요청을 '받아서' 연결이 된 node는 connection, 요청을 '보내서' 연결이 된 node는 open 이벤트가 일어난다.)

 

그렇게 connectSocket 함수가 실행되면 새로 연결된 node에게 서로 메시지를 보내고 (socket.send 메소드)

 

서로의 메시지를 받으면 'message' 이벤트에 의한 콜백 함수가 실행되어 받은 메시지를 해독해 출력한다.

 

이제 send 메소드를 통해 주고 받는 것을 스트링에서 블럭체인으로 바꾸기만 하면 된다.

 

3. 다른 node와의 블럭체인 비교, 최신화

이제 node간에 연결이 되면 서로의 블록체인을 받아서 이를 자신의 블록체인과 비교해보고

 

상황에 따라 다른 코드를 실행해주도록 connectSocket 함수를 보강하면 된다.

 

Chain을 주고 받기 위해서는 우선 메시지에 Chain class를 추가해야한다.

 

다음과 같이 P2PServer class 객체에 Chain class를 가져온다.

 

/*  src/serve/p2p.ts  */

import { Chain } from '@core/blockchain/chain'

export class P2PServer extends Chain {
    public sockets : WebSocket[]
    
    constructor() {
        super()
        // Chain의 속성을 전부 가져온다
        this.sockets = []
    }
    
    // ... 이하 생략
}

 

node 간 블럭체인의 정보를 교환했을 때 일어날 수 있는 경우를 몇 가지로 나눠보자.

 

a) 체인의 길이차이가 1일 경우

> 체인 길이가 짧은 쪽이 긴 쪽에게 최근 블럭에 대한 정보를 요청 후 검증을 거쳐 자신의 체인에 추가한다.

 

b) 체인의 길이 차이가 1보다 클 경우

> 체인 길이가 짧은 쪽이 긴 쪽의 모든 블럭 정보를 요청해 받은 후, 검증을 거쳐 원래 체인을 버리고 받은 체인을 자신의 체인으로 복사해 가진다.

 

c) 내 체인길이가 더 긴 경우

> 아무 일도 일어나지 않는다. (상대방 입장에선 자신의 체인 길이가 더 짧으니 어떤 식으로든 최신화가 일어날 것이다.)

 

이 각각의 경우에 대해 다른 메시지를 보내야 하는데, 이는 다음과 같은 방법으로 구현해보자.

 

/*  src/serve/p2p.ts  */

export enum MessageType {
    latest_block = 0
}
// enum은 가질 수 있는 속성과 그 값을 유한하게 제한하겠다는 것을 의미한다.
// 이렇게 쓸 경우, 앞으로 우리가 MessageType class 객체를 설정해도
// latest_block 이외에는 쓸 수 없다.
// 다른 값은 차차 추가해나가도록 한다.

export interface Message {
    type : MessageType
    payload : any
}
// 다른 node에겐 다음과 같은 형태로 메시지를 전송하고 받을 것이다.

 

a, b, c에 해당하는 경우를 각각 switch - case문으로 분류하고, 그 때마다 다른 MessageType을 Message class 객체에

 

넣어 상대방 node에게 전송할 것이다.

 

그 전에, 우선 다른 node로부터의 message 수신에 반응해 이를 해독해 출력해주는 코드를 작성해준다.

 

/*  src/serve/p2p.ts  */

public connectSocket( socket : WebSocket ) {
    this.sockets.push(socket)
    
    // 이 부분은 내가 연결되는 즉시 상대방에게 상대방의 최신 블럭을 요청하는 코드
    const data : Message = {
        type : MessageType.latest_block,
        payload : [this.getLatestBlock()]
    }
    
    socket.send(JSON.stringify(data))
    // 이 send는 WebSocket class의 내장 함수 send
    // data를 string 형태로 바꿔 상대방 node에게 전송
    
    const send = this.send(socket)
    send(data)
    // 이 send는 P2PServer class 객체에서 선언한 send 함수 
    // 상대방에게 data를 전송
    // send 함수는 아래에 후술
    
    // 여기부터는 상대방으로부터 메시지를 수신하는 이벤트가 발생 시,
    // 그를 인식해 실행하는 함수
    socket.on('message', (data : string) => {
        console.log(Buffer.from(data).toString()
        // 받은 메시지를 버퍼 형태에서 문자열로 해독해 출력
        
        const message : Message = P2PServer.dataParse<Message>(data)
        
        switch (message.type) {
        // 분석한 Message class 객체의 type을 읽어 switch문을 실행한다.
            case messageType.latest_block :
            console.log( message )
            break
        }
        const Block : IBlock = message.payload
        console.log(Block)
        // 받은 메시지의 MessageType이 latest_block일 경우, message와 message의
        // 한 속성인 Block 
    })
}

public static dataParse<T> (_data : string) : T {
    return JSON.parse(Buffer.from(_data).toString())
    // 받은 데이터를 Message class 객체 형태로 바꿔준다.
}

public send(_socket : WebSocket ) {
    return ( _data : Message ) => {
        _socket.send(JSON.stringify(_data))
    }
}

 

전술했듯, 두 node간 connection이 발생하면 과정의 차이가 있지만 결국 둘 모두 conenctSocket 함수가 실행된다.

 

이 때 가장 처음 할 일은, 서로에게 최신 블럭을 요청해 정보를 확인하는 일이며, 

 

이 결과를 기반으로 앞으로 어떤 상호작용을 할지 결정이 되므로 이는 반드시 실행되어야 한다.

 

메시지 수신시 이를 해독해 대응하는 응답을 주는 함수를 따로 분리 작성한다.

 

/*  src/serve/p2p.ts  */

connectSocket(socket : WebSocket) {
    this.sockets.push(socket)

    const data : Message = {
        type : MessageType.latest_block,
        payload : {}
    }
    this.send(socket)(data)
    // 연결된 즉시 서로 상대방에게 가진 블럭체인의 마지막 블럭을 요청한다.
        
    this.messageHandler(socket)
    // 메시지 수신에 대한 함수를 따로 분리
}

messageHandler (socket : WebSocket ) {
    const messageResponse = (data : string) => {
        const result : Message = P2PServer.dataParse<Message>(data)
        const send = this.send(socket)
        
        switch (result.type) {
            case MessageType.latest_block : {
                const message : Message = {
                    type : 1,
                    payload : [this.getLatestBlock()]
                }
                send(message)
                break
            }
            // 상대방에게 내 최신 블럭을 달라는 요청을 받으면
            // 내 최신 블럭과 type을 넣어 이를 전송한다.
            // type : 1 에 관한 함수는 잠시후 추가
        }
    }
    socket.on('message', messageResponse)
}

 

코드의 실행 순서에 유의해야 한다.

 

node 간에 연결이 성공해 connectSocket 함수가 실행되면

 

> 그 함수 안에서 messageHandler 함수가 실행된 후,

 

> 상대방 node에 메시지를 보낸다. ( 동시에 상대 node도 내 node 쪽으로 메시지를 보낸다.)

 

> 이 때 messageHandler가 먼저 실행되고 있는 상태이므로 메시지 수신 이벤트를 인식해

 

messageResponse 함수가 발동한다.

 

> 수신된 data를 dataParse 함수를 이용해 Message class 객체로 해독한 후,

 

MessageType을 확인한다.

 

connection 직후 받은 메시지는 반드시 최신 블럭을 요청하는 메시지이므로 특별한 일이 없다면

 

이 때의 MessageType은 latest_block일 것이다.

 

> 내 최신 블럭을 getLatestBlock 함수를 이용해 가져온 후, send 함수를 통해 전송한다. 

 

( send 함수는 WebSocket의 함수 send가 아닌 내가 P2PServer class 객체 안에서 선언한 send 함수)

 

3-1 상황에 따른 메시지 작성

이제 서로의 최신 블럭을 비교한 후, 이를 바탕으로 어떤 데이터를 요청할지 결정하는 코드를 만들어보자.

 

가장 먼저 생각해볼 case는 상대방의 체인길이가 나보다 1만큼 긴 경우이다.

 

/*  src/serve/p2p.ts  */

export enum MessageType {
    latest_block : 0,
    received_latest_block = 1,
    receivedChain = 2
}

// ... 중략

messageHandler (socket : WebSocket) {
    const messageResponse = (data : string ) => {
        // ...중략
        
        switch(result.type) {
            case MessageType.latest_block : {
                // ...중략
            }
            
            case MessageType.received_latest_block : {
                const [ receivedBlock ] = result.payload
                // 상대방이 보낸 최신 블럭을 확인
                const isValid = this.addToChain(receivedBlock)
                // 이게 내 최신 블럭 다음 블럭이라고 가정하고 addToChain함수 실행
            
                if( isValid.isError === false ) break
                // 정말 내 최신 블럭 다음 블럭이어서
                // addToChain 함수에서 에러가 나지 않았다면 여기서 상호작용 종료
            
                const message : Message = {
                    type : MessageType.fullChainReq,
                    payload : this.getChain()
                }
                // 만약 에러가 났다면
                // 이번엔 상대방의 전체 체인을 요청하는 메시지 작성
            
            send(message)
            break
            // 작성한 메시지를 상대방 노드에 전송
        }
    }
}

 

논리 구조가 조금 이질적인데, 받은 블럭을 무조건 내가 가진 체인의 최신 블럭의 다음 블럭이라고 가정을 하고

 

일단 블럭을 체인에 추가하는 함수를 실행해버린다.

 

정말 내 체인의 최신 블럭의 다음 블럭이 맞았다면 그 블럭을 내 체인에 추가하고 상호작용이 종료된다.

 

그렇지 않을 경우 ( 상대방의 체인에 다른 에러가 없다고 가정하면 )

 

이 때는 상대방의 전체 체인을 요청하는 메시지를 다시 만들어 보낸다. 

 

그래서 이제 할 일은 상대방이 내게 전체 체인을 요청했을 때, 이를 인식해 응답을 주는 코드를 작성하는 것이다.

 

/*  src/serve/p2p.ts  */

export enum MessageType {
    latest_block = 0,
    received_latest_block = 1,
    fullChainReq = 2
}

messageHandler ( socket : WebSocket ) {
    const messageResponse = (data : string) => {
    // ...중략
    
        switch(result.type) {
            // ...중략
            
            case MessageType.fullChainReq : {
                const receivedChain : IBlock [] = result.payload
                // Block 내부 함수는 필요없이 블럭체인 내용만 알고 싶으므로
                // IBlock class 객체를 주고 받는다.
                this.handleChainResponse(receivedChain)
                // 받은 체인을 확인후 대응할 함수
                break
            }
        }
    }
    socket.on('message', messageResponse)
}

handleChainResponse (_receivedChain:IBlock[]) : Failable < Message | undefined, string > {
    const isValidChain = this.isValidChain(_receivedChain)
    // 전달받은 체인에 에러가 없는지 검증하는 함수 (후술)
    
    if( isValidChain.isError == true ) {
        return { isError : true, error : isValidChain.error }
    }
    // 상대 체인에 에러가 있다면 에러를 리턴
    
    const isValid = this.replaceChain(_receivedChain)
    // 받은 체인에 에러가 없다면 내 원래 가진 체인을 받은 상대방 체인으로 교체
    
    if( isValid.isError == true) {
        return { isError : true, error : isValid.error }
    }
    // 에러가 있다면 에러를 리턴
    
    return { isError : false, value : undefined }
}

handleChainResponse 에는 블럭 체인이 에러가 없는지 검증하는 함수와

 

내가 가진 블럭체인을 대체하는 함수 두 가지가 들어있는데, 이는 각각 다음과 같이 chain class 객체에 넣어주면 된다.

 

/*  src/core/blockchain/chain.ts  */

public isValidChain(_chain : Block[]) : Failable <undefined, string> {
    const genesis = _chain[0]
    // 받은 체인의 제네시스 블럭
    
    for ( let i = 1; i < _chain.length; i++) {
        const newBlock = _chain[i]
        const previousBlock = _chain[i-1]
        const isValid = Block.isValidNewBlock(newBlock, previousBlock)
        
        if(isValid.isError) {
            return { isError : true, error : isValid.error }
        }
        // 각 블럭간 연결에 문제가 없는지 확인, 하나라도 에러가 발견되면 즉시 에러 리턴
    }
    return { isError : false, value : undefined }
    // 문제가 없을시 이 객체 리턴
}

// isValidChain의 검증을 문제 없이 통과했다면 replaceChain 함수 실행
// 내가 가진 블럭 체인을 상대방의 블럭체인으로 교체할지 여부 결정

public replaceChain( _receivedChain : Block[] ) : Failable <undefined, string> {
    const latestReceivedBlock : Block = _receivedChain[_receivedChain.length - 1]
    // 받은 체인의 최신 블럭
    const latestBlock : Block = this.getLatestBlock()
    // 내 체인의 최신 블럭
    
    if( latestReceivedBlock.height === 0 ) {
        return { isError : true, error : 'genesis block입니다.' }
    }
    
    if( latestReceivedBlock.previousBlock === latestBlock.hash ) {
        return { isError : true, error : '블럭 차이가 1입니다.' }
    }
    
    if ( latestReceivedBlock.height <= latestBlock.height ) {
        return { isError : true, error : '현재 체인이 더 최신 체인입니다.' }
    }
    // 이상의 조건을 다 통과한다면 체인을 대체한다.
    
    this.blockchain = receivedChain
    return { isError : false, value : undefined } 
}

 

이렇게 블럭체인이 업데이트 되었다면 내 최신화된 블럭체인을 연결된 모든 노드에 전달해 다른 노드들의 블럭체인을

 

최신화하는데 기여할 수 있다.

 

/*  src/serve/p2p.ts  */

public handleChainResponse (_receivedChain:IBlock[]) : Failable <Message | undefined, string> {

// ...중략

    const message : Message = {
        type : MessageType.fullChainReq,
        payload : _receivedChain
    }
    // 최신화한 체인에 대한 정보를 연결된 모든 네트워크 참여자에게 전달한다.
    
    this.broadCast(message)
    return { isError : false, value : undefined }
}

public broadCast ( message : Message ) : void {
    this.sockets.forEach((socket) => this.send(socket)(message))
    // 모든 연결된 node에게 최신화된 내 체인을 전송
}

 

마지막으로 연결된 네트워크의 누군가가 통신을 종료할 경우, 

 

이를 sockets 배열에서 제거하는 에러 대응 함수를 추가하면 된다.

 

/*  src/serve/p2p.ts  */

public connetSocket ( socket : WebSocket) {
    this.socket.push(socket)
    this.errorHandler(socket)
    // ...중략
}

public errorHandler (socket : WebSocket) {
    const close = () => {
        this.sockets.splice(this.sockets.indexOf(socket), 1)
    }

    socket.on('close', close)
    socket.on('error', close)
}

 

여기까지 했으면 이제 index.ts에 P2PServer와 Message, MessageType을 가져와 사용하면 된다.

 

/*  index.ts  */

import { P2PServer, Message, MessageType } from './src/serve/p2p'

const ws = new P2PServer()
// bc에 사용한 메소드들을 전부 ws로 교체해줘야 네트워크의 체인에 변화를 줄 수 있다.

app.get('/getChain', (req, res) => {
    res.json(ws.getChain())
})

app.post('/mineBlock', (req, res) => {
    const { data } = req.body
    const newBlock = ws.addBlock(data)
    
    if(newBlock.isError == true) {
        return res.status(500).send(newBlock.error)
    }
    
    const msg : Message = {
        type : MessageType.latest_block,
        payload : {}
    }
    
    ws.broadcast(msg)
    res.json(newBlock.value)
})

'Blockchain' 카테고리의 다른 글

#11 Transaction ch1.개념  (0) 2022.06.22
#10 지갑, 개인키  (0) 2022.06.20
#8 mining ch.2 - 활용  (0) 2022.06.17
#7 mining ch.1 - 개념  (0) 2022.06.17
#6 chain  (0) 2022.06.12