이미지 로딩 중...
AI Generated
2025. 11. 11. · 2 Views
타입스크립트로 비트코인 클론하기 22편 - 피어 간 블록 동기화 구현
블록체인 네트워크에서 피어들이 서로 블록을 동기화하는 메커니즘을 타입스크립트로 구현합니다. P2P 통신, 블록 요청/응답, 체인 검증 등 실제 블록체인의 핵심 동기화 로직을 다룹니다.
목차
- Block 인터페이스 정의
- BlockMessage 타입 설계
- WebSocket 연결 관리
- 블록 요청 메커니즘
- 블록 응답 처리
- 체인 검증 로직
- 블록 브로드캐스팅
- 피어 동기화 상태 관리
1. Block 인터페이스 정의
시작하며
여러분이 블록체인을 처음 구현할 때, 가장 먼저 고민하게 되는 것이 "블록을 어떻게 표현할까?"입니다. 블록은 단순한 데이터 덩어리가 아니라, 암호학적으로 연결된 불변의 기록이기 때문에 그 구조가 매우 중요합니다.
실제로 많은 초보 개발자들이 블록 구조를 너무 단순하게 만들거나, 반대로 필요 없는 필드를 너무 많이 넣어서 네트워크 부하를 증가시키는 실수를 합니다. 비트코인과 이더리움 같은 실제 블록체인은 수년간의 검증을 거쳐 최적화된 구조를 사용합니다.
바로 이럴 때 필요한 것이 명확한 Block 인터페이스 정의입니다. 타입스크립트의 인터페이스를 활용하면 컴파일 타임에 타입 안정성을 보장받으면서, 블록의 필수 구조를 강제할 수 있습니다.
개요
간단히 말해서, Block 인터페이스는 블록체인의 각 블록이 반드시 가져야 하는 필드와 타입을 정의하는 청사진입니다. 블록은 index(높이), hash(현재 블록의 해시), previousHash(이전 블록의 해시), timestamp(생성 시간), data(트랜잭션 데이터), nonce(작업 증명 값) 등의 필드를 포함해야 합니다.
예를 들어, 금융 거래를 기록하는 블록체인이라면 data 필드에 거래 정보를 JSON 형태로 저장할 수 있습니다. 기존에는 JavaScript에서 객체 리터럴로만 블록을 표현했다면, 이제는 타입스크립트의 인터페이스로 명확한 계약을 정의할 수 있습니다.
이렇게 하면 잘못된 타입의 데이터가 블록에 들어가는 것을 방지할 수 있습니다. 이 인터페이스의 핵심 특징은 첫째, 모든 필드가 명시적 타입을 가진다는 점, 둘째, 불변성을 위해 readonly를 활용할 수 있다는 점, 셋째, 제네릭을 사용해 data 필드를 유연하게 처리할 수 있다는 점입니다.
이러한 특징들은 대규모 블록체인 프로젝트에서 유지보수성과 안정성을 크게 향상시킵니다.
코드 예제
// 블록체인의 기본 블록 구조 정의
interface Block<T = string> {
// 블록의 높이 (0부터 시작)
index: number;
// 현재 블록의 SHA-256 해시값
hash: string;
// 이전 블록의 해시값 (첫 블록은 "0")
previousHash: string;
// 블록 생성 시간 (Unix timestamp)
timestamp: number;
// 블록에 포함된 데이터 (트랜잭션 등)
data: T;
// 작업 증명을 위한 nonce 값
nonce: number;
// 난이도 (해시의 앞에 나와야 하는 0의 개수)
difficulty: number;
}
설명
이것이 하는 일: Block 인터페이스는 블록체인의 개별 블록이 어떤 정보를 담아야 하는지 타입 수준에서 명시합니다. 제네릭 타입 T를 사용해 data 필드의 타입을 유연하게 지정할 수 있습니다.
첫 번째로, index와 timestamp는 블록의 순서와 시간 정보를 제공합니다. index는 0부터 시작하는 블록 높이로, 체인에서 블록의 위치를 나타냅니다.
timestamp는 밀리초 단위의 Unix 시간으로, 블록이 생성된 정확한 시점을 기록합니다. 이 두 필드는 블록의 순서를 보장하고 이중 지불 공격을 방지하는 데 핵심적인 역할을 합니다.
그 다음으로, hash와 previousHash는 블록들을 암호학적으로 연결합니다. hash는 현재 블록의 모든 정보를 SHA-256으로 해싱한 값이고, previousHash는 이전 블록의 hash 값을 저장합니다.
이렇게 하면 체인의 중간 블록을 변조할 경우 이후 모든 블록의 해시가 무효화되어 변조를 쉽게 감지할 수 있습니다. 세 번째로, data 필드는 실제 비즈니스 로직 데이터를 담습니다.
제네릭 타입 T를 사용했기 때문에 string뿐만 아니라 Transaction[], { from: string, to: string, amount: number }[] 같은 복잡한 타입도 사용할 수 있습니다. 이는 타입 안정성을 유지하면서도 유연성을 제공합니다.
마지막으로, nonce와 difficulty는 작업 증명(Proof of Work)을 위한 필드입니다. 채굴자는 hash가 difficulty만큼의 앞자리 0을 가질 때까지 nonce 값을 증가시키며 해싱을 반복합니다.
예를 들어 difficulty가 4라면 hash가 "0000abcd..."처럼 시작해야 합니다. 여러분이 이 인터페이스를 사용하면 블록 생성 시 필수 필드 누락을 방지하고, 타입 불일치로 인한 런타임 에러를 컴파일 타임에 잡을 수 있습니다.
또한 IDE의 자동완성과 타입 체킹을 통해 개발 생산성이 크게 향상됩니다.
실전 팁
💡 제네릭 타입 T에 기본값을 string으로 설정하면, 단순한 케이스에서는 타입 파라미터를 생략할 수 있어 편리합니다
💡 readonly 수정자를 필드에 추가하면 블록 생성 후 변조를 방지할 수 있습니다 (예: readonly hash: string)
💡 timestamp는 Date.now()로 생성하되, 테스트 시에는 고정된 값을 주입할 수 있도록 팩토리 패턴을 사용하세요
💡 hash와 previousHash는 정확히 64자리 16진수 문자열이어야 하므로, type HashString = string & { __brand: 'hash' } 같은 브랜드 타입을 사용해 더 강한 타입 안정성을 확보할 수 있습니다
💡 difficulty는 동적으로 조절되어야 하므로, 블록 생성 시간이 목표(예: 10초)보다 빠르면 증가, 느리면 감소시키는 로직을 구현하세요
2. BlockMessage 타입 설계
시작하며
여러분이 P2P 네트워크를 구현하면서 가장 어려운 부분 중 하나가 "피어들이 어떻게 소통할까?"입니다. 각 노드는 블록을 요청하기도 하고, 응답하기도 하고, 새로운 블록을 브로드캐스트하기도 합니다.
이런 다양한 메시지를 어떻게 타입 안전하게 처리할까요? 실제로 많은 블록체인 프로젝트에서 메시지 타입을 문자열로만 구분하다가, 오타나 타입 불일치로 인해 동기화 실패가 발생하는 경우를 자주 봅니다.
"request_block"인지 "requestBlock"인지 "REQUESTBLOCK"인지 일관성이 없으면 디버깅이 매우 어려워집니다. 바로 이럴 때 필요한 것이 명확한 BlockMessage 타입 설계입니다.
타입스크립트의 유니온 타입과 판별 유니온(Discriminated Union)을 활용하면, 각 메시지 타입에 따라 다른 페이로드 구조를 강제하면서도 타입 안전성을 보장할 수 있습니다.
개요
간단히 말해서, BlockMessage는 피어 간 주고받는 모든 메시지의 형태를 정의하는 타입 시스템입니다. 블록체인 P2P 네트워크에서는 크게 세 가지 메시지가 필요합니다: QUERY_LATEST (최신 블록 요청), QUERY_ALL (전체 체인 요청), RESPONSE_BLOCKCHAIN (블록 응답).
예를 들어, 새로운 피어가 네트워크에 참여하면 QUERY_ALL을 보내서 전체 블록체인을 동기화하고, 이후에는 QUERY_LATEST만 주기적으로 보내서 최신 블록만 확인합니다. 기존에는 { type: 'query', data: null } 같은 느슨한 객체를 사용했다면, 이제는 판별 유니온으로 type에 따라 data의 타입이 자동으로 추론되도록 할 수 있습니다.
이렇게 하면 잘못된 페이로드를 보내는 실수를 컴파일 타임에 방지할 수 있습니다. 이 타입 시스템의 핵심 특징은 첫째, type 필드를 통한 메시지 구분, 둘째, 각 type에 맞는 data 페이로드 타입 강제, 셋째, 타입 가드를 통한 런타임 검증 가능성입니다.
이러한 특징들은 대규모 P2P 네트워크에서 메시지 처리의 안정성과 명확성을 크게 향상시킵니다.
코드 예제
// P2P 메시지 타입 정의 (판별 유니온 활용)
enum MessageType {
QUERY_LATEST = 'QUERY_LATEST',
QUERY_ALL = 'QUERY_ALL',
RESPONSE_BLOCKCHAIN = 'RESPONSE_BLOCKCHAIN'
}
// 최신 블록 요청 메시지
interface QueryLatestMessage {
type: MessageType.QUERY_LATEST;
data: null;
}
// 전체 체인 요청 메시지
interface QueryAllMessage {
type: MessageType.QUERY_ALL;
data: null;
}
// 블록체인 응답 메시지
interface ResponseBlockchainMessage {
type: MessageType.RESPONSE_BLOCKCHAIN;
data: Block[];
}
// 모든 메시지의 유니온 타입
type BlockMessage = QueryLatestMessage | QueryAllMessage | ResponseBlockchainMessage;
설명
이것이 하는 일: BlockMessage 타입은 P2P 네트워크에서 교환되는 모든 메시지의 구조를 명시하고, type 필드에 따라 data의 타입을 자동으로 추론하도록 합니다. 첫 번째로, MessageType enum을 정의해 메시지 타입을 상수화합니다.
문자열 리터럴 대신 enum을 사용하면 오타를 방지하고, IDE의 자동완성을 활용할 수 있습니다. 또한 리팩토링 시 모든 사용처가 자동으로 업데이트되므로 유지보수가 편리합니다.
그 다음으로, 각 메시지 타입마다 별도의 인터페이스를 정의합니다. QueryLatestMessage와 QueryAllMessage는 data가 null인데, 이는 단순히 "최신 블록 주세요" 또는 "전체 체인 주세요"라는 신호만 보내기 때문입니다.
반면 ResponseBlockchainMessage는 data가 Block[] 타입으로, 실제 블록 배열을 담아 응답합니다. 세 번째로, 이 세 인터페이스를 유니온 타입으로 결합합니다.
이렇게 하면 타입스크립트 컴파일러가 type 필드를 보고 자동으로 타입을 좁혀(narrow) 줍니다. 예를 들어 message.type === MessageType.RESPONSE_BLOCKCHAIN이면, 컴파일러는 message.data가 Block[] 타입임을 자동으로 인식합니다.
실제 사용 시에는 switch 문이나 if 문으로 type을 체크하면, 각 분기에서 올바른 data 타입에 안전하게 접근할 수 있습니다. 예를 들어 response 브랜치에서는 message.data.forEach(block => ...)처럼 배열 메서드를 사용할 수 있습니다.
여러분이 이 타입 시스템을 사용하면 메시지 처리 로직에서 타입 에러를 사전에 방지하고, 코드 리뷰 시 메시지 구조를 명확히 이해할 수 있으며, 새로운 메시지 타입 추가 시 기존 코드에 미치는 영향을 컴파일 타임에 파악할 수 있습니다.
실전 팁
💡 enum 대신 const 객체와 as const를 사용하면 번들 크기를 줄일 수 있습니다: const MessageType = { QUERY_LATEST: 'QUERY_LATEST' } as const
💡 메시지에 timestamp와 senderId 필드를 추가하면 메시지 순서 보장과 발신자 식별에 유용합니다
💡 타입 가드 함수를 만들어 런타임에 메시지 검증을 강화하세요: function isResponseMessage(msg: BlockMessage): msg is ResponseBlockchainMessage { return msg.type === MessageType.RESPONSE_BLOCKCHAIN; }
💡 메시지 직렬화 시 JSON.stringify를 사용하되, BigInt나 Date 같은 특수 타입은 커스텀 replacer를 작성해야 합니다
💡 메시지 크기 제한을 설정하세요. 악의적인 피어가 거대한 블록체인을 보내 메모리를 고갈시키는 공격을 방지할 수 있습니다
3. WebSocket 연결 관리
시작하며
여러분이 블록체인 노드를 실제로 구동할 때, "어떻게 다른 노드들과 연결할까?"가 첫 번째 과제입니다. HTTP는 요청-응답 패턴이라 실시간 통신에 부적합하고, 새로운 블록이 생성될 때마다 즉시 알릴 수 없습니다.
실제로 비트코인, 이더리움 같은 주요 블록체인들은 TCP 소켓 기반의 양방향 통신을 사용합니다. 하지만 브라우저 환경에서도 동작해야 한다면 WebSocket이 최선의 선택입니다.
WebSocket은 한 번 연결하면 서버와 클라이언트가 자유롭게 메시지를 주고받을 수 있습니다. 바로 이럴 때 필요한 것이 체계적인 WebSocket 연결 관리입니다.
연결 생성, 재연결 처리, 에러 핸들링, 연결 풀 관리 등을 제대로 구현해야 안정적인 P2P 네트워크를 구축할 수 있습니다.
개요
간단히 말해서, WebSocket 연결 관리는 여러 피어와의 지속적인 양방향 통신 채널을 생성하고 유지하는 시스템입니다. 각 노드는 WebSocket 서버와 클라이언트 역할을 동시에 수행합니다.
서버로서는 다른 노드의 연결을 받아들이고, 클라이언트로서는 다른 노드에 연결을 시도합니다. 예를 들어, 노드 A가 6001번 포트로 서버를 열고, 노드 B의 6002번 포트에 클라이언트로 연결하는 식입니다.
기존의 단순한 HTTP API로는 서버에서 클라이언트로 푸시 알림을 보낼 수 없었다면, 이제는 WebSocket으로 새로운 블록이 생성되는 즉시 모든 연결된 피어에게 브로드캐스트할 수 있습니다. 이렇게 하면 블록 전파 속도가 수백 배 빨라집니다.
이 시스템의 핵심 특징은 첫째, ws 라이브러리를 사용한 양방향 통신, 둘째, 연결된 모든 피어를 배열로 관리, 셋째, 각 연결마다 이벤트 리스너 등록입니다. 이러한 특징들은 탈중앙화 네트워크의 핵심 인프라를 제공합니다.
코드 예제
import WebSocket from 'ws';
// 연결된 모든 피어 소켓을 저장
const sockets: WebSocket[] = [];
// WebSocket 서버 시작 (다른 노드의 연결을 받음)
function initP2PServer(port: number): void {
const server = new WebSocket.Server({ port });
server.on('connection', (ws: WebSocket) => {
console.log(`새 피어 연결됨`);
initConnection(ws);
});
console.log(`P2P 서버 시작: ws://localhost:${port}`);
}
// 다른 노드에 클라이언트로 연결
function connectToPeers(peers: string[]): void {
peers.forEach(peer => {
const ws = new WebSocket(peer);
ws.on('open', () => {
console.log(`피어 연결 성공: ${peer}`);
initConnection(ws);
});
ws.on('error', (error) => {
console.log(`연결 실패: ${peer}`, error.message);
});
});
}
// 연결 초기화 및 이벤트 리스너 등록
function initConnection(ws: WebSocket): void {
sockets.push(ws);
initMessageHandler(ws);
initErrorHandler(ws);
// 연결 즉시 최신 블록 요청
write(ws, { type: MessageType.QUERY_LATEST, data: null });
}
설명
이것이 하는 일: WebSocket 연결 관리 시스템은 블록체인 노드가 다른 노드들과 실시간 양방향 통신을 할 수 있도록 네트워크 레이어를 제공합니다. 첫 번째로, initP2PServer 함수가 WebSocket 서버를 시작합니다.
new WebSocket.Server({ port })로 서버를 생성하면, 해당 포트로 들어오는 모든 WebSocket 연결 요청을 받을 수 있습니다. connection 이벤트가 발생하면 새로운 피어가 연결된 것이므로 initConnection을 호출해 초기화합니다.
이렇게 하면 여러분의 노드가 네트워크의 허브 역할을 할 수 있습니다. 그 다음으로, connectToPeers 함수가 다른 노드에 클라이언트로 연결합니다.
peers 배열에는 "ws://localhost:6002", "ws://192.168.1.100:6003" 같은 WebSocket URL이 들어있습니다. 각 URL마다 new WebSocket(peer)로 연결을 시도하고, open 이벤트가 발생하면 연결 성공입니다.
error 이벤트를 처리해 연결 실패를 로그로 남기면 디버깅이 쉬워집니다. 세 번째로, initConnection 함수가 연결을 초기화합니다.
먼저 sockets 배열에 연결을 추가해서 나중에 모든 피어에게 브로드캐스트할 수 있게 합니다. 그 다음 initMessageHandler로 메시지 수신 리스너를 등록하고, initErrorHandler로 에러와 연결 종료를 처리합니다.
마지막으로 즉시 QUERY_LATEST 메시지를 보내 최신 블록을 요청합니다. 실제 운영에서는 연결이 끊어지면 자동으로 재연결을 시도해야 합니다.
ws.on('close')에서 setTimeout으로 5-10초 후 재연결을 시도하되, 최대 재시도 횟수를 제한하면 무한 루프를 방지할 수 있습니다. 여러분이 이 시스템을 사용하면 노드를 실행하자마자 자동으로 네트워크에 참여하고, 새로운 피어가 추가될 때마다 연결을 확장하며, 연결이 끊어져도 안정적으로 재연결할 수 있습니다.
이는 탈중앙화 네트워크의 핵심 기능입니다.
실전 팁
💡 sockets 배열은 Set으로 변경하면 중복 연결을 자동으로 방지하고 삭제 성능이 향상됩니다
💡 연결 시 핸드셰이크로 노드 버전, 체인 ID를 교환하면 호환되지 않는 노드와의 연결을 조기에 차단할 수 있습니다
💡 heartbeat(ping/pong)를 30초마다 보내 유휴 연결이 끊어지는 것을 방지하세요: setInterval(() => sockets.forEach(ws => ws.ping()), 30000)
💡 최대 연결 수를 제한하세요 (예: 50개). DoS 공격으로 수천 개의 연결이 생성되면 서버가 다운될 수 있습니다
💡 연결된 피어의 정보를 Map<WebSocket, PeerInfo>로 관리하면 각 피어의 상태(마지막 블록 높이, 레이턴시 등)를 추적할 수 있습니다
4. 블록 요청 메커니즘
시작하며
여러분이 새로운 노드를 네트워크에 추가하거나, 일시적으로 오프라인이었다가 복귀했을 때, 가장 먼저 해야 할 일은 "내가 놓친 블록이 있나?"를 확인하는 것입니다. 다른 노드들은 이미 1000번 블록까지 가지고 있는데, 여러분의 노드는 950번까지만 있다면 동기화가 필요합니다.
실제로 블록체인 네트워크에서 노드 간 블록 높이 차이는 매우 흔한 일입니다. 네트워크 지연, 일시적 연결 끊김, 노드 재시작 등 다양한 이유로 블록이 누락될 수 있습니다.
비트코인 노드를 처음 실행하면 2009년부터 현재까지의 모든 블록을 다운로드하는데 수 시간이 걸리는 것도 이 때문입니다. 바로 이럴 때 필요한 것이 효율적인 블록 요청 메커니즘입니다.
최신 블록만 필요한지, 전체 체인이 필요한지, 특정 범위의 블록만 필요한지를 판단하고, 적절한 요청을 보내야 합니다.
개요
간단히 말해서, 블록 요청 메커니즘은 로컬 체인과 피어의 체인을 비교해서 필요한 블록만 효율적으로 가져오는 시스템입니다. 두 가지 주요 요청 방식이 있습니다: queryLatestBlock()은 피어의 최신 블록 하나만 요청하고, queryAllBlocks()는 피어의 전체 블록체인을 요청합니다.
예를 들어, 정상 운영 중에는 주기적으로 queryLatestBlock()을 호출해 새로운 블록이 있는지 확인하고, 노드를 처음 시작할 때나 체인이 크게 뒤처졌을 때만 queryAllBlocks()를 호출합니다. 기존에는 모든 경우에 전체 체인을 다운로드했다면, 이제는 상황에 맞는 요청 방식을 선택해 네트워크 대역폭과 동기화 시간을 크게 절약할 수 있습니다.
이렇게 하면 수백 MB의 데이터 전송을 수 KB로 줄일 수 있습니다. 이 메커니즘의 핵심 특징은 첫째, 브로드캐스트를 통한 모든 피어 동시 요청, 둘째, 메시지 타입에 따른 요청 구분, 셋째, JSON 직렬화를 통한 메시지 전송입니다.
이러한 특징들은 동기화 효율성과 네트워크 부하 최적화를 가능하게 합니다.
코드 예제
// 모든 피어에게 최신 블록 요청
function queryLatestBlock(): void {
const message: QueryLatestMessage = {
type: MessageType.QUERY_LATEST,
data: null
};
broadcast(message);
console.log('최신 블록 요청 전송');
}
// 모든 피어에게 전체 블록체인 요청
function queryAllBlocks(): void {
const message: QueryAllMessage = {
type: MessageType.QUERY_ALL,
data: null
};
broadcast(message);
console.log('전체 블록체인 요청 전송');
}
// 모든 연결된 피어에게 메시지 브로드캐스트
function broadcast(message: BlockMessage): void {
sockets.forEach(socket => {
write(socket, message);
});
}
// 개별 소켓에 메시지 전송
function write(ws: WebSocket, message: BlockMessage): void {
ws.send(JSON.stringify(message));
}
설명
이것이 하는 일: 블록 요청 메커니즘은 로컬 노드가 네트워크의 다른 노드들로부터 필요한 블록 데이터를 가져오기 위한 통신 인터페이스를 제공합니다. 첫 번째로, queryLatestBlock 함수는 가장 자주 사용되는 요청 방식입니다.
MessageType.QUERY_LATEST 타입의 메시지를 생성하고 broadcast()로 모든 피어에게 보냅니다. 피어들은 이 메시지를 받으면 자신의 체인에서 가장 최근 블록을 응답합니다.
이렇게 하면 여러분의 노드는 단 한 번의 요청으로 모든 피어의 최신 상태를 알 수 있습니다. 만약 피어 A는 블록 100을, 피어 B는 블록 101을 응답한다면, 피어 B가 더 긴 체인을 가지고 있음을 알 수 있습니다.
그 다음으로, queryAllBlocks 함수는 초기 동기화나 큰 격차가 발견되었을 때 사용합니다. 예를 들어 여러분의 노드가 블록 50까지만 있는데, 피어가 블록 100을 가지고 있다면, QUERY_LATEST로는 블록 100 하나만 받습니다.
하지만 블록 51~99가 없으면 블록 100을 검증할 수 없으므로(previousHash가 맞지 않음), 이때 queryAllBlocks()를 호출해 전체 체인을 다시 받아야 합니다. 세 번째로, broadcast 함수가 실제 메시지 전송을 담당합니다.
sockets 배열의 모든 WebSocket 연결을 순회하며 write()를 호출합니다. 이는 fan-out 패턴으로, 하나의 메시지를 여러 수신자에게 동시에 전달합니다.
만약 10개의 피어와 연결되어 있다면, 10개의 응답을 받게 되고, 그 중 가장 긴 유효한 체인을 선택할 수 있습니다. 마지막으로, write 함수가 메시지를 JSON으로 직렬화해 전송합니다.
WebSocket은 바이너리와 텍스트 모두 지원하지만, JSON은 디버깅이 쉽고 언어 간 호환성이 좋아 널리 사용됩니다. 다만 대용량 블록체인을 전송할 때는 Protocol Buffers나 MessagePack 같은 바이너리 포맷이 더 효율적입니다.
여러분이 이 메커니즘을 사용하면 노드 시작 시 자동으로 최신 블록을 동기화하고, 주기적으로 체인을 업데이트하며, 여러 피어의 응답을 비교해 가장 신뢰할 수 있는 체인을 선택할 수 있습니다.
실전 팁
💡 queryLatestBlock()을 setInterval로 10초마다 자동 호출하면 항상 최신 상태를 유지할 수 있습니다
💡 요청에 타임아웃을 설정하세요. 피어가 5초 안에 응답하지 않으면 해당 연결을 제거하고 다른 피어에게 재요청합니다
💡 한 번에 너무 많은 블록을 요청하지 마세요. 1000개 이상의 블록이 필요하면 100개씩 청크로 나눠서 요청하는 것이 안전합니다
💡 요청 ID를 추가해 응답 매칭을 정확히 하세요: { type: 'QUERY_LATEST', requestId: uuid(), data: null }
💡 피어가 다른 블록 높이를 응답하면, 그 정보를 메타데이터로 저장해 나중에 가장 앞선 피어를 우선 선택할 수 있습니다
5. 블록 응답 처리
시작하며
여러분이 피어에게 블록을 요청한 후, 응답이 돌아왔을 때 가장 중요한 질문은 "이 블록을 믿을 수 있나?"입니다. 악의적인 노드는 가짜 블록을 보낼 수 있고, 네트워크 오류로 데이터가 손상될 수도 있으며, 단순히 피어의 체인이 내 체인보다 오래되었을 수도 있습니다.
실제로 블록체인 보안의 핵심은 "신뢰하지 않고 검증한다(Don't trust, verify)"입니다. 아무리 신뢰할 만한 피어라도, 받은 모든 블록을 철저히 검증해야 합니다.
previousHash 검증, 타임스탬프 검증, 작업 증명 검증 등 여러 단계를 거쳐야 합니다. 바로 이럴 때 필요한 것이 체계적인 블록 응답 처리 메커니즘입니다.
메시지 파싱, 블록 검증, 체인 교체 판단, 에러 핸들링을 모두 포함해야 합니다.
개요
간단히 말해서, 블록 응답 처리는 피어로부터 받은 블록 데이터를 파싱하고, 검증하고, 로컬 체인에 추가하거나 교체하는 전체 프로세스입니다. 메시지 핸들러는 WebSocket의 message 이벤트에 등록되어, 피어가 보낸 모든 메시지를 수신합니다.
JSON.parse()로 메시지를 객체로 변환하고, type 필드를 보고 어떤 종류의 메시지인지 판단합니다. 예를 들어, type이 RESPONSE_BLOCKCHAIN이면 data 필드에 Block[] 배열이 들어있으므로, 이를 처리하는 로직을 실행합니다.
기존에는 모든 블록을 무조건 받아들였다면, 이제는 받은 블록체인의 길이, 유효성, 난이도 누적값 등을 비교해서 더 나은 체인일 때만 교체합니다. 이렇게 하면 짧거나 무효한 체인으로 롤백되는 것을 방지할 수 있습니다.
이 메커니즘의 핵심 특징은 첫째, 메시지 타입에 따른 분기 처리, 둘째, 받은 블록의 엄격한 검증, 셋째, 체인 교체 시 원자적 업데이트입니다. 이러한 특징들은 네트워크의 일관성과 보안을 보장합니다.
코드 예제
// WebSocket 메시지 핸들러 등록
function initMessageHandler(ws: WebSocket): void {
ws.on('message', (data: string) => {
try {
const message: BlockMessage = JSON.parse(data);
handleBlockchainResponse(message, ws);
} catch (error) {
console.error('메시지 파싱 실패:', error);
}
});
}
// 블록체인 응답 처리
function handleBlockchainResponse(message: BlockMessage, ws: WebSocket): void {
switch (message.type) {
case MessageType.QUERY_LATEST:
// 최신 블록 요청받음 -> 내 최신 블록 전송
write(ws, {
type: MessageType.RESPONSE_BLOCKCHAIN,
data: [getLatestBlock()]
});
break;
case MessageType.QUERY_ALL:
// 전체 체인 요청받음 -> 내 전체 체인 전송
write(ws, {
type: MessageType.RESPONSE_BLOCKCHAIN,
data: getBlockchain()
});
break;
case MessageType.RESPONSE_BLOCKCHAIN:
// 블록 응답받음 -> 검증 후 체인 업데이트
const receivedBlocks = message.data;
handleReceivedBlocks(receivedBlocks);
break;
}
}
설명
이것이 하는 일: 블록 응답 처리 시스템은 P2P 네트워크에서 주고받는 모든 메시지를 해석하고, 적절한 액션을 수행하는 핵심 로직입니다. 첫 번째로, initMessageHandler가 각 WebSocket 연결에 message 이벤트 리스너를 등록합니다.
WebSocket으로 데이터가 도착하면 이 리스너가 호출됩니다. data는 문자열 형태이므로 JSON.parse()로 객체로 변환합니다.
try-catch로 감싸서 잘못된 JSON이 오더라도 노드가 다운되지 않도록 합니다. 예를 들어 악의적인 피어가 "{invalid json"을 보내도 에러 로그만 남기고 계속 동작합니다.
그 다음으로, handleBlockchainResponse가 메시지 타입에 따라 분기 처리합니다. switch 문을 사용하면 타입스크립트가 각 case에서 message의 타입을 자동으로 좁혀줍니다.
QUERY_LATEST 케이스에서는 피어가 "네 최신 블록 좀 보여줘"라고 요청한 것이므로, getLatestBlock()으로 가장 최근 블록을 가져와서 RESPONSE_BLOCKCHAIN 메시지로 응답합니다. 세 번째로, QUERY_ALL 케이스에서는 전체 체인 요청이므로 getBlockchain()으로 모든 블록을 응답합니다.
이는 비용이 큰 작업이므로, 실제로는 rate limiting을 적용해 한 피어가 1분에 1번만 요청할 수 있도록 제한해야 합니다. 마지막으로, RESPONSE_BLOCKCHAIN 케이스가 가장 중요합니다.
피어가 블록 데이터를 보낸 것이므로, message.data에서 Block[] 배열을 추출하고 handleReceivedBlocks()로 처리합니다. 이 함수는 받은 블록들이 유효한지 검증하고, 현재 체인보다 길고 유효하면 체인을 교체합니다.
예를 들어 현재 체인이 길이 10이고, 받은 체인이 길이 12이며 모든 블록이 유효하면 교체합니다. 여러분이 이 시스템을 사용하면 피어의 모든 메시지에 자동으로 반응하고, 받은 블록을 안전하게 검증하며, 네트워크 전체가 일관된 상태를 유지할 수 있습니다.
실전 팁
💡 메시지 크기 제한을 설정하세요. ws.on('message')에서 data.length > 10MB면 거부해서 메모리 고갈 공격을 방지합니다
💡 같은 피어에게서 같은 블록을 중복으로 받는 경우를 처리하세요. 블록 해시를 Set에 저장해 이미 처리한 블록은 스킵합니다
💡 비동기 검증을 고려하세요. 블록 검증(특히 작업 증명)은 CPU 집약적이므로 Worker Thread로 처리하면 메인 스레드가 블록되지 않습니다
💡 부분 체인 업데이트를 지원하세요. 1000개 블록 중 100개만 새롭다면, 새로운 100개만 추가하는 것이 전체 교체보다 효율적입니다
💡 메시지 처리 순서를 보장하세요. 빠르게 연속으로 블록이 도착하면 경합 조건이 발생할 수 있으므로, 큐를 사용해 순차 처리합니다
6. 체인 검증 로직
시작하며
여러분이 피어로부터 새로운 블록체인을 받았을 때, 가장 중요한 질문은 "이 체인이 정말 유효한가?"입니다. 단순히 길이만 비교하면 안 됩니다.
악의적인 노드가 긴 체인을 보냈지만 작업 증명이 없거나, 블록 간 해시 연결이 깨져있거나, 타임스탬프가 미래 시간일 수도 있습니다. 실제로 블록체인의 불변성은 암호학적 해시 체인과 작업 증명의 결합으로 보장됩니다.
한 블록의 데이터를 변조하면 그 블록의 해시가 바뀌고, 다음 블록의 previousHash와 맞지 않게 되어 체인이 깨집니다. 또한 작업 증명이 없으면 아무리 긴 체인을 만들어도 네트워크가 거부합니다.
바로 이럴 때 필요한 것이 엄격한 체인 검증 로직입니다. 블록 구조 검증, 해시 검증, previousHash 연결 검증, 작업 증명 검증 등 여러 레벨의 검증을 거쳐야 합니다.
개요
간단히 말해서, 체인 검증 로직은 블록체인의 모든 블록이 암호학적으로 연결되어 있고, 각 블록이 작업 증명을 만족하는지 확인하는 시스템입니다. 검증은 여러 단계로 이루어집니다: isValidNewBlock()은 개별 블록의 유효성을 검사하고, isValidChain()은 전체 체인의 연결성을 검사합니다.
예를 들어, 블록 N+1의 previousHash가 블록 N의 hash와 일치하는지, 블록 N+1의 index가 N+1인지, hash가 실제로 블록 내용의 SHA-256 해시인지 등을 확인합니다. 기존에는 단순히 JSON 구조만 확인했다면, 이제는 암호학적 해시를 직접 계산해서 비교하고, 작업 증명의 난이도를 확인합니다.
이렇게 하면 위조된 블록을 99.99% 이상의 정확도로 탐지할 수 있습니다. 이 로직의 핵심 특징은 첫째, 개별 블록과 전체 체인의 이중 검증, 둘째, SHA-256을 사용한 암호학적 무결성 확인, 셋째, 작업 증명 난이도 검증입니다.
이러한 특징들은 블록체인의 보안과 신뢰성을 근본적으로 보장합니다.
코드 예제
import crypto from 'crypto';
// 블록의 해시 계산
function calculateHash(block: Block): string {
const data = block.index + block.previousHash + block.timestamp +
JSON.stringify(block.data) + block.nonce + block.difficulty;
return crypto.createHash('sha256').update(data).digest('hex');
}
// 개별 블록 유효성 검증
function isValidNewBlock(newBlock: Block, previousBlock: Block): boolean {
// index 연속성 검증
if (previousBlock.index + 1 !== newBlock.index) {
console.log('invalid index');
return false;
}
// previousHash 연결 검증
if (previousBlock.hash !== newBlock.previousHash) {
console.log('invalid previousHash');
return false;
}
// hash 무결성 검증
if (calculateHash(newBlock) !== newBlock.hash) {
console.log('invalid hash');
return false;
}
// 작업 증명 검증 (해시가 난이도만큼 0으로 시작하는지)
if (!hashMatchesDifficulty(newBlock.hash, newBlock.difficulty)) {
console.log('invalid proof of work');
return false;
}
return true;
}
// 전체 체인 유효성 검증
function isValidChain(chain: Block[]): boolean {
// 제네시스 블록 검증
if (JSON.stringify(chain[0]) !== JSON.stringify(getGenesisBlock())) {
return false;
}
// 모든 블록 순차 검증
for (let i = 1; i < chain.length; i++) {
if (!isValidNewBlock(chain[i], chain[i - 1])) {
return false;
}
}
return true;
}
// 해시가 난이도를 만족하는지 검증
function hashMatchesDifficulty(hash: string, difficulty: number): boolean {
const prefix = '0'.repeat(difficulty);
return hash.startsWith(prefix);
}
설명
이것이 하는 일: 체인 검증 로직은 블록체인의 암호학적 무결성과 작업 증명을 확인해서, 악의적인 조작이나 데이터 손상을 탐지합니다. 첫 번째로, calculateHash 함수가 블록의 모든 필드를 연결해서 SHA-256 해시를 계산합니다.
block.index + block.previousHash + ... 순서로 문자열을 만들고, crypto.createHash('sha256')로 해시를 생성합니다.
중요한 점은 data 필드를 JSON.stringify()로 정규화해야 객체 순서가 달라도 같은 해시가 나온다는 것입니다. 예를 들어 { a: 1, b: 2 }와 { b: 2, a: 1 }은 같은 객체지만 문자열로 변환하면 다르므로 주의해야 합니다.
그 다음으로, isValidNewBlock 함수가 네 가지 검증을 수행합니다. 첫째, index가 연속적인지 확인합니다(N 다음에 N+1).
둘째, previousHash가 실제로 이전 블록의 hash와 일치하는지 확인합니다. 이 검증이 핵심으로, 만약 블록 N의 데이터를 변조하면 블록 N의 hash가 바뀌고, 블록 N+1의 previousHash와 맞지 않아 즉시 탐지됩니다.
셋째, calculateHash를 다시 수행해서 저장된 hash가 정말 맞는지 확인합니다. 넷째, 작업 증명을 검증합니다.
세 번째로, hashMatchesDifficulty 함수가 작업 증명을 확인합니다. difficulty가 4라면, hash가 "0000"으로 시작해야 합니다.
이는 채굴자가 수천~수백만 번의 해싱을 시도해야 얻을 수 있는 값입니다. 예를 들어 difficulty 4는 평균 2^16 = 65,536번의 시도가 필요하고, difficulty 5는 2^20 = 1,048,576번의 시도가 필요합니다.
이렇게 계산 비용을 강제함으로써 공격자가 긴 가짜 체인을 쉽게 만들 수 없게 합니다. 마지막으로, isValidChain 함수가 전체 체인을 검증합니다.
먼저 제네시스 블록(첫 블록)이 하드코딩된 값과 정확히 일치하는지 확인합니다. 그 다음 모든 블록을 순회하며 isValidNewBlock을 호출합니다.
하나라도 실패하면 전체 체인이 무효입니다. 여러분이 이 검증 로직을 사용하면 피어가 보낸 체인을 안전하게 검증하고, 51% 공격 같은 악의적 시도를 탐지하며, 네트워크 오류로 손상된 데이터를 거부할 수 있습니다.
실전 팁
💡 calculateHash를 캐싱하세요. 같은 블록의 해시를 여러 번 계산하는 것은 낭비입니다. WeakMap<Block, string>을 사용하면 메모리 누수 없이 캐싱할 수 있습니다
💡 타임스탬프 검증을 추가하세요. 블록의 timestamp가 이전 블록보다 미래이고, 현재 시간보다 10분 이상 미래가 아닌지 확인합니다
💡 난이도 조절 로직을 검증하세요. difficulty가 급격히 변하지 않았는지(예: 한 번에 2 이상 변하지 않음) 확인해서 난이도 조작을 방지합니다
💡 병렬 검증을 고려하세요. 1000개 블록을 검증할 때, Worker Thread 풀을 사용해 100개씩 병렬로 처리하면 속도가 크게 향상됩니다
💡 체크포인트를 설정하세요. 블록 10000 이전은 이미 검증되었다고 가정하고, 그 이후만 검증하면 노드 시작 시간을 단축할 수 있습니다
7. 블록 브로드캐스팅
시작하며
여러분이 새로운 블록을 채굴하거나 받았을 때, 가장 중요한 다음 단계는 "이 블록을 네트워크에 전파하자"입니다. 블록체인은 탈중앙화 시스템이므로, 중앙 서버 없이 피어들끼리 정보를 공유해야 합니다.
한 노드가 새 블록을 만들면, 그 노드와 연결된 모든 피어에게 전파하고, 받은 피어는 다시 자신의 피어들에게 전파합니다. 실제로 비트코인 네트워크에서는 새로운 블록이 전 세계의 수만 개 노드에 평균 10초 이내에 전파됩니다.
이는 효율적인 브로드캐스팅 메커니즘 덕분입니다. Gossip 프로토콜이라고 불리는 이 방식은, 각 노드가 받은 정보를 이웃에게 전달하면 기하급수적으로 확산됩니다.
바로 이럴 때 필요한 것이 체계적인 블록 브로드캐스팅 시스템입니다. 새 블록을 모든 피어에게 전송하되, 중복 전송을 피하고, 전송 실패를 처리하며, 네트워크 대역폭을 효율적으로 사용해야 합니다.
개요
간단히 말해서, 블록 브로드캐스팅은 새로운 블록을 네트워크의 모든 노드에게 빠르고 안정적으로 전파하는 메커니즘입니다. 브로드캐스팅은 크게 두 가지 상황에서 발생합니다: 첫째, 자신이 직접 블록을 채굴했을 때, 둘째, 다른 피어에게서 새로운 블록을 받아서 체인에 추가했을 때입니다.
예를 들어, 노드 A가 블록 101을 채굴하면 연결된 노드 B, C, D에게 브로드캐스트합니다. 노드 B는 블록 101을 받아 검증하고 체인에 추가한 후, 자신의 피어들(E, F)에게 다시 브로드캐스트합니다.
기존에는 폴링 방식으로 주기적으로 블록을 요청했다면, 이제는 푸시 방식으로 즉시 전파해서 블록 전파 시간을 수십 배 단축할 수 있습니다. 이렇게 하면 블록 생성부터 네트워크 합의까지 걸리는 시간이 분 단위에서 초 단위로 줄어듭니다.
이 시스템의 핵심 특징은 첫째, 모든 연결된 피어에 대한 동시 전송, 둘째, RESPONSE_BLOCKCHAIN 메시지 타입 사용, 셋째, 최신 블록만 전송해 대역폭 절약입니다. 이러한 특징들은 탈중앙화 네트워크의 빠른 합의를 가능하게 합니다.
코드 예제
// 받은 블록을 처리하고 필요시 체인 교체 및 브로드캐스트
function handleReceivedBlocks(receivedBlocks: Block[]): void {
if (receivedBlocks.length === 0) {
console.log('받은 블록이 없음');
return;
}
// 받은 체인 정렬 (index 순)
const sortedBlocks = receivedBlocks.sort((a, b) => a.index - b.index);
const latestReceived = sortedBlocks[sortedBlocks.length - 1];
const latestLocal = getLatestBlock();
// 경우 1: 받은 블록이 내 체인보다 1칸 앞선 경우
if (latestReceived.index > latestLocal.index) {
if (latestLocal.hash === latestReceived.previousHash) {
// 검증 후 추가
if (isValidNewBlock(latestReceived, latestLocal)) {
addBlockToChain(latestReceived);
// 새 블록을 모든 피어에게 브로드캐스트
broadcastLatest();
}
} else if (receivedBlocks.length === 1) {
// 블록 하나만 받았는데 연결이 안 됨 -> 전체 체인 요청
queryAllBlocks();
} else {
// 여러 블록 받았으면 전체 체인 교체 시도
replaceChain(receivedBlocks);
}
}
}
// 최신 블록을 모든 피어에게 브로드캐스트
function broadcastLatest(): void {
const message: ResponseBlockchainMessage = {
type: MessageType.RESPONSE_BLOCKCHAIN,
data: [getLatestBlock()]
};
broadcast(message);
console.log('최신 블록 브로드캐스트 완료');
}
// 체인 교체 (받은 체인이 더 길고 유효하면)
function replaceChain(newBlocks: Block[]): void {
if (isValidChain(newBlocks) && newBlocks.length > getBlockchain().length) {
console.log('받은 체인이 유효하고 더 김. 체인 교체');
setBlockchain(newBlocks);
broadcastLatest();
} else {
console.log('받은 체인 거부');
}
}
설명
이것이 하는 일: 블록 브로드캐스팅 시스템은 새로운 블록이 체인에 추가될 때마다 모든 연결된 피어에게 자동으로 전파해서 네트워크 전체가 동기화된 상태를 유지하도록 합니다. 첫 번째로, handleReceivedBlocks 함수가 피어에게서 받은 블록들을 처리합니다.
먼저 받은 블록이 없으면 조기 반환하고, 있으면 index 순으로 정렬합니다. 그 다음 받은 체인의 최신 블록(latestReceived)과 로컬 체인의 최신 블록(latestLocal)을 비교합니다.
예를 들어 로컬이 블록 100까지, 받은 것이 블록 101이라면 단순 추가만 하면 됩니다. 그 다음으로, 세 가지 시나리오를 처리합니다.
첫 번째 시나리오는 받은 블록이 정확히 다음 블록인 경우입니다(latestLocal.hash === latestReceived.previousHash). 이 경우 isValidNewBlock으로 검증하고, 통과하면 addBlockToChain으로 추가한 후 broadcastLatest()를 호출합니다.
이렇게 하면 여러분의 노드가 받은 블록을 다시 자신의 피어들에게 전파하는 릴레이 역할을 합니다. 세 번째로, 두 번째 시나리오는 블록이 하나만 왔는데 연결이 안 되는 경우입니다.
예를 들어 로컬이 블록 100까지, 받은 것이 블록 105라면 101~104가 누락된 것입니다. 이때는 queryAllBlocks()를 호출해 전체 체인을 다시 요청합니다.
네 번째로, 세 번째 시나리오는 여러 블록을 받은 경우입니다. replaceChain 함수로 전체 체인 교체를 시도합니다.
isValidChain으로 받은 체인 전체를 검증하고, 길이도 비교해서 더 길고 유효하면 setBlockchain()으로 교체합니다. 교체 후에도 broadcastLatest()를 호출해 업데이트된 체인을 알립니다.
마지막으로, broadcastLatest 함수가 실제 브로드캐스팅을 수행합니다. getLatestBlock()으로 최신 블록 하나만 가져와서 RESPONSE_BLOCKCHAIN 메시지로 감싸고, broadcast()로 모든 피어에게 전송합니다.
전체 체인이 아닌 최신 블록만 보내므로 네트워크 부하가 최소화됩니다. 여러분이 이 시스템을 사용하면 새로운 블록이 네트워크에 기하급수적으로 전파되어, 수천 개의 노드가 있어도 수 초 안에 모두 동기화됩니다.
실전 팁
💡 중복 브로드캐스트 방지를 위해 최근 전송한 블록 해시를 Set에 저장하고, 이미 전송한 블록은 다시 보내지 않습니다
💡 브로드캐스트에 TTL(Time To Live)을 추가하세요. 메시지가 무한히 전파되지 않도록 홉 수를 제한합니다(예: 최대 10홉)
💡 선택적 브로드캐스트를 고려하세요. 모든 피어가 아닌, 레이턴시가 낮거나 업타임이 높은 상위 N개 피어에게만 전송해 효율을 높입니다
💡 브로드캐스트 실패를 추적하세요. 특정 피어가 계속 응답하지 않으면 연결을 끊고 새 피어를 찾습니다
💡 압축을 사용하세요. 블록 데이터를 gzip으로 압축하면 대역폭을 50% 이상 절약할 수 있습니다
8. 피어 동기화 상태 관리
시작하며
여러분이 블록체인 노드를 운영하다 보면, "내 노드가 네트워크와 제대로 동기화되어 있나?"를 항상 확인해야 합니다. 네트워크가 블록 1000까지 진행했는데, 여러분의 노드는 블록 950에 멈춰있다면 문제가 있는 것입니다.
반대로 항상 최신 상태를 유지한다면 노드가 건강하게 작동하는 것입니다. 실제로 블록체인 노드 운영자들은 동기화 상태를 모니터링하는 대시보드를 만듭니다.
"로컬 블록 높이", "피어들의 최고 블록 높이", "마지막 동기화 시간", "연결된 피어 수" 같은 메트릭을 실시간으로 추적합니다. 이더리움 클라이언트인 Geth도 eth.syncing API로 동기화 진행 상황을 제공합니다.
바로 이럴 때 필요한 것이 체계적인 피어 동기화 상태 관리입니다. 각 피어의 블록 높이를 추적하고, 정기적으로 동기화를 시도하며, 뒤처진 경우 자동으로 따라잡는 로직을 구현해야 합니다.
개요
간단히 말해서, 피어 동기화 상태 관리는 로컬 노드와 네트워크의 동기화 수준을 모니터링하고, 자동으로 최신 상태를 유지하는 시스템입니다. 동기화 상태는 여러 요소로 구성됩니다: 로컬 체인 길이, 각 피어의 체인 길이, 마지막 블록 수신 시간, 동기화 시도 횟수 등입니다.
예를 들어, 5개 피어 중 4개가 블록 100을 가지고 있고, 1개만 블록 98을 가지고 있다면, 블록 100이 네트워크 합의라고 판단할 수 있습니다. 기존에는 수동으로 동기화 상태를 확인했다면, 이제는 주기적으로 자동 체크하고, 뒤처진 경우 즉시 블록을 요청하며, 메트릭을 로그로 남겨 분석할 수 있습니다.
이렇게 하면 노드가 24/7 무인으로 운영되어도 항상 최신 상태를 유지합니다. 이 시스템의 핵심 특징은 첫째, 주기적인 동기화 체크(예: 10초마다), 둘째, 피어별 메타데이터 추적, 셋째, 자동 복구 메커니즘입니다.
이러한 특징들은 노드의 안정성과 신뢰성을 크게 향상시킵니다.
코드 예제
// 피어별 메타데이터 저장
interface PeerMetadata {
ws: WebSocket;
lastBlockHeight: number;
lastSeen: number;
failures: number;
}
const peers: Map<WebSocket, PeerMetadata> = new Map();
// 피어 메타데이터 업데이트
function updatePeerMetadata(ws: WebSocket, blockHeight: number): void {
const existing = peers.get(ws);
peers.set(ws, {
ws,
lastBlockHeight: blockHeight,
lastSeen: Date.now(),
failures: existing?.failures || 0
});
}
// 주기적 동기화 체크 (10초마다)
function startSyncLoop(): void {
setInterval(() => {
const localHeight = getBlockchain().length - 1;
const maxPeerHeight = Math.max(...Array.from(peers.values()).map(p => p.lastBlockHeight));
console.log(`동기화 상태: 로컬=${localHeight}, 피어최고=${maxPeerHeight}`);
// 뒤처진 경우 자동 동기화
if (maxPeerHeight > localHeight + 5) {
console.log('5블록 이상 뒤처짐. 전체 체인 요청');
queryAllBlocks();
} else if (maxPeerHeight > localHeight) {
console.log('약간 뒤처짐. 최신 블록 요청');
queryLatestBlock();
}
// 오래된 피어 정리 (60초 이상 응답 없음)
const now = Date.now();
peers.forEach((metadata, ws) => {
if (now - metadata.lastSeen > 60000) {
console.log('피어 타임아웃. 연결 종료');
ws.close();
peers.delete(ws);
}
});
}, 10000);
}
// 블록 수신 시 피어 메타데이터 업데이트
function onBlockReceived(ws: WebSocket, blocks: Block[]): void {
if (blocks.length > 0) {
const maxIndex = Math.max(...blocks.map(b => b.index));
updatePeerMetadata(ws, maxIndex);
}
}
설명
이것이 하는 일: 피어 동기화 상태 관리 시스템은 노드가 항상 네트워크의 최신 상태를 유지하도록 모니터링하고, 문제 발생 시 자동으로 복구합니다. 첫 번째로, PeerMetadata 인터페이스가 각 피어의 상태 정보를 정의합니다.
lastBlockHeight는 해당 피어가 마지막으로 알려준 블록 높이, lastSeen은 마지막 통신 시간, failures는 연속 실패 횟수입니다. Map<WebSocket, PeerMetadata>로 관리하면 WebSocket 객체를 키로 사용해 O(1) 시간에 메타데이터에 접근할 수 있습니다.
그 다음으로, updatePeerMetadata 함수가 피어 정보를 갱신합니다. 피어에게서 블록을 받을 때마다 호출해서, 해당 피어의 최신 블록 높이와 통신 시간을 기록합니다.
existing?.failures || 0 패턴으로 기존 실패 횟수를 보존하면서 다른 필드만 업데이트합니다. 세 번째로, startSyncLoop 함수가 핵심 동기화 로직입니다.
setInterval로 10초마다 실행되며, 먼저 로컬 블록 높이와 모든 피어의 최고 블록 높이를 비교합니다. Math.max(...Array.from(peers.values()).map(p => p.lastBlockHeight))로 피어들 중 가장 높은 블록을 찾습니다.
만약 피어 A가 블록 100, 피어 B가 블록 102, 피어 C가 블록 101을 가지고 있다면, maxPeerHeight는 102가 됩니다. 네 번째로, 격차에 따라 다른 동기화 전략을 사용합니다.
5블록 이상 뒤처졌다면(예: 로컬 95, 피어 최고 102) 중간 블록들이 많이 누락된 것이므로 queryAllBlocks()로 전체 체인을 요청합니다. 1~4블록만 뒤처졌다면 queryLatestBlock()으로 최신 블록만 요청해도 점진적으로 따라잡을 수 있습니다.
이렇게 상황별로 최적화하면 네트워크 부하를 줄이면서도 빠르게 동기화됩니다. 마지막으로, 피어 정리 로직이 좀비 연결을 제거합니다.
lastSeen이 60초 이상 지난 피어는 네트워크 문제나 노드 다운으로 응답하지 않는 것이므로, ws.close()로 연결을 끊고 peers Map에서 삭제합니다. 이렇게 하면 메모리 누수를 방지하고, 활성 피어 수를 정확히 파악할 수 있습니다.
여러분이 이 시스템을 사용하면 노드가 자동으로 최신 상태를 유지하고, 일시적 네트워크 문제에서 자동 복구하며, 피어 품질을 모니터링해 불량 피어를 제거할 수 있습니다.
실전 팁
💡 exponential backoff를 구현하세요. 동기화 실패 시 재시도 간격을 10초 -> 20초 -> 40초로 늘려서 네트워크 부하를 줄입니다
💡 피어 점수 시스템을 추가하세요. 빠르고 신뢰성 있는 피어에게 높은 점수를 주고, 우선적으로 요청하면 동기화 속도가 향상됩니다
💡 동기화 진행률을 퍼센트로 표시하세요: (localHeight / maxPeerHeight * 100).toFixed(2) + '%'
💡 피어 다양성을 유지하세요. 같은 IP의 피어를 너무 많이 연결하면 한 데이터센터 장애로 모든 연결이 끊길 수 있습니다
💡 체크포인트 기반 동기화를 구현하세요. 1000블록마다 스냅샷을 저장해두면, 재시작 시 전체 체인을 다시 다운로드하지 않아도 됩니다