이미지 로딩 중...
AI Generated
2025. 11. 11. · 2 Views
타입스크립트로 비트코인 클론하기 20편 - WebSocket 기반 P2P 통신 설정
블록체인의 핵심인 P2P 네트워크를 WebSocket으로 구현하는 방법을 배웁니다. 노드 간 실시간 통신, 메시지 브로드캐스팅, 연결 관리까지 실전 예제와 함께 알아봅니다.
목차
- WebSocket 서버 설정 - P2P 노드의 시작점
- P2P 클라이언트 연결 - 다른 노드와 연결하기
- 메시지 타입 정의 - 구조화된 통신 프로토콜
- 메시지 브로드캐스팅 - 모든 피어에게 전파하기
- 메시지 핸들러 구현 - 받은 메시지 처리하기
- 피어 초기화 및 동기화 - 네트워크 진입 시 체인 동기화
- WebSocket 서버와 클라이언트 통합 - 완전한 P2P 노드
- Ping-Pong 하트비트 - 죽은 연결 탐지 및 정리
- 피어 디스커버리 - 네트워크 확장하기
- 보안 고려사항 - DoS 방어와 악의적 피어 차단
1. WebSocket 서버 설정 - P2P 노드의 시작점
시작하며
블록체인 프로젝트를 만들다가 "어떻게 노드끼리 실시간으로 블록 데이터를 주고받을 수 있을까?"라는 고민을 하신 적 있나요? HTTP로는 계속 폴링을 해야 하고, 실시간성도 떨어지죠.
블록체인의 핵심은 분산 네트워크입니다. 중앙 서버 없이 각 노드가 서로 연결되어 블록과 트랜잭션 정보를 실시간으로 공유해야 합니다.
하지만 전통적인 HTTP 요청-응답 방식으로는 이런 양방향 실시간 통신을 효율적으로 구현하기 어렵습니다. 바로 이럴 때 필요한 것이 WebSocket 서버입니다.
WebSocket을 사용하면 한 번 연결을 맺은 후 계속해서 양방향으로 데이터를 주고받을 수 있어 블록체인의 P2P 통신에 완벽하게 맞습니다.
개요
간단히 말해서, WebSocket 서버는 P2P 네트워크에서 다른 노드들이 접속할 수 있는 진입점입니다. 실제 비트코인이나 이더리움 같은 블록체인 네트워크에서는 각 노드가 서버이자 클라이언트 역할을 동시에 합니다.
여러분의 노드도 다른 노드들을 연결하면서(클라이언트), 동시에 다른 노드들의 연결을 받아들여야(서버) 합니다. 예를 들어, 새로운 블록이 채굴되었을 때 모든 연결된 노드에게 즉시 알려야 하는 상황에서 매우 유용합니다.
전통적인 방법과의 비교를 해볼까요? 기존 HTTP REST API를 사용했다면 클라이언트가 주기적으로 서버에 "새로운 블록 있어요?"라고 물어봐야 했습니다.
하지만 WebSocket을 사용하면 새로운 블록이 생기는 순간 서버가 모든 클라이언트에게 바로 푸시할 수 있습니다. WebSocket 서버의 핵심 특징은 지속적인 연결 유지, 양방향 통신, 낮은 오버헤드입니다.
HTTP처럼 매번 헤더를 주고받지 않아도 되기 때문에 네트워크 효율성이 높고, 실시간으로 데이터를 푸시할 수 있어 블록체인 같은 분산 시스템에 최적화되어 있습니다.
코드 예제
import WebSocket from "ws";
class P2PServer {
private server: WebSocket.Server;
private sockets: WebSocket[] = [];
constructor(port: number) {
// WebSocket 서버를 특정 포트에 생성
this.server = new WebSocket.Server({ port });
this.server.on("connection", (socket: WebSocket) => {
console.log("새로운 피어가 연결되었습니다");
this.initConnection(socket);
});
console.log(`P2P 서버가 포트 ${port}에서 실행중입니다`);
}
private initConnection(socket: WebSocket): void {
// 연결된 소켓을 배열에 저장
this.sockets.push(socket);
this.initMessageHandler(socket);
this.initErrorHandler(socket);
}
private initMessageHandler(socket: WebSocket): void {
socket.on("message", (data: string) => {
console.log("받은 메시지:", data.toString());
});
}
private initErrorHandler(socket: WebSocket): void {
socket.on("close", () => this.removeSocket(socket));
socket.on("error", () => this.removeSocket(socket));
}
private removeSocket(socket: WebSocket): void {
this.sockets = this.sockets.filter(s => s !== socket);
}
}
// 서버 시작
const p2pServer = new P2PServer(6001);
설명
이것이 하는 일: WebSocket 서버는 특정 포트에서 다른 피어(노드)들의 연결을 기다리고, 연결이 성공하면 그 소켓을 관리하면서 메시지를 주고받을 준비를 합니다. 코드의 핵심을 단계별로 살펴보겠습니다.
첫 번째로, constructor에서 new WebSocket.Server({ port })를 통해 WebSocket 서버 인스턴스를 생성합니다. 이 서버는 지정된 포트(예: 6001)에서 들어오는 WebSocket 연결 요청을 계속 기다립니다.
connection 이벤트 리스너를 등록하여 새로운 피어가 접속할 때마다 자동으로 initConnection 메서드가 실행되도록 합니다. 그 다음으로, initConnection 메서드가 실행되면서 연결된 소켓을 sockets 배열에 저장합니다.
이 배열은 현재 연결된 모든 피어들을 추적하는 용도로 사용됩니다. 동시에 메시지 핸들러와 에러 핸들러를 초기화하여 이 소켓에서 발생하는 모든 이벤트를 처리할 준비를 합니다.
initMessageHandler는 피어로부터 메시지가 도착했을 때 처리하고, initErrorHandler는 연결이 끊어지거나 에러가 발생했을 때 해당 소켓을 배열에서 제거합니다. 마지막으로, 에러 핸들링 부분에서는 close와 error 이벤트를 모두 처리합니다.
네트워크 문제나 피어가 의도적으로 연결을 끊었을 때, 해당 소켓을 sockets 배열에서 제거하여 메모리 누수를 방지하고 유효하지 않은 소켓으로 메시지를 보내는 것을 막습니다. 여러분이 이 코드를 사용하면 여러 노드가 동시에 접속할 수 있는 P2P 서버를 운영할 수 있습니다.
실무에서는 이 서버가 24시간 돌아가면서 전 세계의 블록체인 노드들과 연결을 유지하게 됩니다. 연결된 각 소켓은 독립적으로 메시지를 주고받을 수 있으며, 하나의 소켓에서 문제가 생겨도 다른 연결에는 영향을 주지 않습니다.
실전 팁
💡 포트 번호는 방화벽에서 열어줘야 외부 노드들이 접속할 수 있습니다. AWS나 클라우드 환경에서는 보안 그룹 설정도 확인하세요.
💡 sockets 배열을 Set 자료구조로 바꾸면 중복 연결 방지와 삭제 성능이 더 좋아집니다.
💡 프로덕션 환경에서는 연결 수 제한을 두는 것이 좋습니다. 너무 많은 연결은 서버 리소스를 고갈시킬 수 있습니다.
💡 WebSocket 서버는 기본적으로 heartbeat 체크를 하지 않으므로, 주기적으로 ping/pong을 보내서 죽은 연결을 정리해야 합니다.
💡 에러 로그를 파일이나 모니터링 시스템에 저장하면 네트워크 문제를 디버깅하기 훨씬 쉬워집니다.
2. P2P 클라이언트 연결 - 다른 노드와 연결하기
시작하며
블록체인 네트워크에 참여하려면 어떻게 해야 할까요? 단순히 서버만 띄워놓는다고 네트워크가 형성되지 않습니다.
여러분의 노드가 능동적으로 다른 노드들을 찾아서 연결을 시도해야 합니다. 이것이 실제 블록체인 네트워크의 핵심입니다.
비트코인이나 이더리움 노드를 실행하면 설정 파일에 있는 몇 개의 "시드 노드"에 먼저 연결하고, 그 노드들로부터 다른 노드들의 주소를 받아서 네트워크를 확장해 나갑니다. 이렇게 해야 중앙화된 서버 없이도 분산 네트워크가 유지될 수 있습니다.
바로 이럴 때 필요한 것이 P2P 클라이언트 연결 기능입니다. 여러분의 노드가 다른 노드의 WebSocket 서버에 클라이언트로 접속하여 피어 관계를 맺는 것입니다.
개요
간단히 말해서, P2P 클라이언트 연결은 여러분의 노드가 다른 노드에게 "나도 네트워크에 참여하고 싶어요!"라고 말하는 과정입니다. 서버 기능만으로는 수동적으로 연결을 기다릴 수밖에 없습니다.
하지만 클라이언트 기능을 추가하면 능동적으로 알려진 노드들에게 접속을 시도할 수 있습니다. 예를 들어, 여러분이 새로운 블록체인 노드를 시작할 때 "ws://localhost:6001", "ws://peer1.example.com:6001" 같은 주소 목록을 가지고 있다면, 이 주소들에 차례대로 연결을 시도하여 네트워크에 진입할 수 있습니다.
전통적인 클라이언트-서버 모델과의 차이점은 무엇일까요? 일반적인 웹 애플리케이션에서는 클라이언트가 서버에 요청하고 응답을 받으면 끝입니다.
하지만 P2P에서는 한 번 연결을 맺으면 계속 유지하면서 서로가 서로에게 데이터를 보낼 수 있습니다. 즉, 연결을 시작한 쪽이 클라이언트지만, 연결 후에는 완전히 동등한 관계가 됩니다.
P2P 클라이언트의 핵심 특징은 능동적 연결 시도, 연결 재시도 로직, 연결 후 피어로서의 동등한 권한입니다. 네트워크가 일시적으로 불안정해도 자동으로 재연결을 시도하고, 일단 연결되면 서버에서 받은 연결과 똑같이 처리됩니다.
코드 예제
import WebSocket from "ws";
class P2PClient {
private sockets: WebSocket[] = [];
// 피어 주소로 연결 시도
connectToPeer(peerAddress: string): void {
try {
const socket = new WebSocket(peerAddress);
socket.on("open", () => {
console.log(`피어 연결 성공: ${peerAddress}`);
this.initConnection(socket);
});
socket.on("error", (error) => {
console.log(`피어 연결 실패: ${peerAddress}`, error.message);
});
socket.on("close", () => {
console.log(`피어 연결 종료: ${peerAddress}`);
this.removeSocket(socket);
});
} catch (error) {
console.error("연결 중 오류:", error);
}
}
private initConnection(socket: WebSocket): void {
this.sockets.push(socket);
socket.on("message", (data: string) => {
console.log("피어로부터 메시지 수신:", data.toString());
});
}
private removeSocket(socket: WebSocket): void {
this.sockets = this.sockets.filter(s => s !== socket);
}
// 여러 피어에 동시 연결
connectToPeers(peerAddresses: string[]): void {
peerAddresses.forEach(peer => this.connectToPeer(peer));
}
}
// 사용 예시
const client = new P2PClient();
client.connectToPeers(["ws://localhost:6001", "ws://localhost:6002"]);
설명
이것이 하는 일: P2P 클라이언트는 알려진 피어들의 주소 목록을 받아서 각 주소에 WebSocket 연결을 시도하고, 성공하면 그 소켓을 관리 배열에 추가하여 지속적인 통신을 가능하게 합니다. 코드를 단계별로 분석해보겠습니다.
첫 번째로, connectToPeer 메서드에서 new WebSocket(peerAddress)를 호출하여 연결을 시작합니다. 이때 peerAddress는 "ws://hostname:port" 형식의 문자열입니다.
WebSocket 생성자는 즉시 서버에 연결을 시도하며, 이 과정은 비동기로 진행됩니다. 연결이 진행되는 동안 여러 이벤트가 발생할 수 있으므로 이벤트 리스너를 등록해야 합니다.
그 다음으로, open 이벤트가 발생하면 연결이 성공적으로 완료된 것입니다. 이때 initConnection을 호출하여 해당 소켓을 sockets 배열에 추가하고 메시지 핸들러를 설정합니다.
이제부터 이 소켓을 통해 피어와 양방향으로 데이터를 주고받을 수 있습니다. 반대로 error 이벤트가 발생하면 피어 서버가 응답하지 않거나, 네트워크 문제가 있거나, 주소가 잘못된 경우입니다.
이 경우 연결 시도를 중단하고 에러를 로깅합니다. 마지막으로, connectToPeers 메서드는 여러 피어 주소를 받아서 각각에 대해 connectToPeer를 호출합니다.
이렇게 하면 한 번에 여러 노드와 연결을 맺을 수 있습니다. 실제 블록체인에서는 시작할 때 설정 파일에 있는 5~10개의 시드 노드에 동시에 연결을 시도합니다.
일부는 실패할 수 있지만, 몇 개만 성공해도 네트워크에 참여할 수 있습니다. 여러분이 이 코드를 사용하면 고립된 노드가 아닌 네트워크에 연결된 활성 노드가 됩니다.
연결된 피어들로부터 최신 블록 정보를 받을 수 있고, 여러분이 채굴한 블록을 다른 노드들에게 전파할 수도 있습니다. P2P 네트워크의 핵심은 이렇게 각 노드가 여러 피어와 연결을 유지하면서 정보를 공유하는 것입니다.
실전 팁
💡 연결 실패 시 자동 재시도 로직을 추가하세요. setTimeout으로 5초 후 재시도하되, 최대 3~5번까지만 시도하는 것이 좋습니다.
💡 같은 피어에 중복 연결되지 않도록 이미 연결된 주소를 Set으로 관리하세요. IP:포트 조합을 키로 사용하면 됩니다.
💡 연결 타임아웃을 설정하세요. WebSocket 생성 시 옵션으로 handshakeTimeout을 지정하면 응답 없는 서버에 무한정 기다리지 않습니다.
💡 프로덕션에서는 TLS를 사용한 wss:// 프로토콜을 써야 중간자 공격을 방지할 수 있습니다.
💡 DNS 조회 실패나 잘못된 URL 형식 등의 예외를 try-catch로 잡아서 프로그램이 중단되지 않도록 하세요.
3. 메시지 타입 정의 - 구조화된 통신 프로토콜
시작하며
WebSocket 연결은 성공했는데, 이제 무엇을 주고받아야 할까요? 그냥 문자열을 보내면 받는 쪽에서 "이게 블록 데이터인가?
트랜잭션인가? 아니면 단순한 인사 메시지인가?"를 구분할 수 없습니다.
실제 블록체인 네트워크에서 노드들은 수십 가지 종류의 메시지를 주고받습니다. 새로운 블록 알림, 트랜잭션 전파, 블록체인 동기화 요청, 피어 목록 공유 등 각각의 메시지는 다른 방식으로 처리되어야 합니다.
만약 이런 메시지들을 구분할 방법이 없다면 완전히 혼란에 빠지게 됩니다. 바로 이럴 때 필요한 것이 메시지 타입 정의입니다.
메시지에 타입 필드를 추가하여 받는 쪽이 즉시 "아, 이건 블록 데이터네. 블록 처리 함수를 실행해야겠다"라고 판단할 수 있게 만드는 것입니다.
개요
간단히 말해서, 메시지 타입은 P2P 통신의 "언어 규칙"입니다. 서로 약속된 형식으로 데이터를 주고받아야 올바르게 소통할 수 있습니다.
TypeScript의 강력한 타입 시스템을 활용하면 메시지 구조를 컴파일 타임에 검증할 수 있습니다. 잘못된 형식의 메시지를 보내려고 하면 코드를 실행하기도 전에 에러가 발생하여 버그를 미리 잡을 수 있습니다.
예를 들어, 블록 데이터를 보낼 때 필수 필드를 빠뜨리면 TypeScript 컴파일러가 즉시 알려줍니다. 전통적인 방법에서는 메시지를 그냥 JSON 문자열로 보내고, 받는 쪽에서 파싱한 후 "type이라는 필드가 있나?
그 값이 뭐지?"라고 런타임에 확인했습니다. 하지만 이제는 enum으로 가능한 모든 메시지 타입을 미리 정의하고, interface로 각 타입의 구조를 명확히 하여 타입 안정성을 확보합니다.
메시지 타입 정의의 핵심 특징은 명확한 구조, 타입 안정성, 확장 가능성입니다. 새로운 메시지 타입이 필요하면 enum에 추가하고 인터페이스를 정의하면 되므로, 시스템이 커져도 체계적으로 관리할 수 있습니다.
코드 예제
// 메시지 타입 열거형
enum MessageType {
QUERY_LATEST = 0, // 최신 블록 요청
QUERY_ALL = 1, // 전체 블록체인 요청
RESPONSE_BLOCKCHAIN = 2, // 블록체인 응답
QUERY_TRANSACTION_POOL = 3, // 트랜잭션 풀 요청
RESPONSE_TRANSACTION_POOL = 4 // 트랜잭션 풀 응답
}
// 메시지 인터페이스
interface Message {
type: MessageType;
data: any;
}
// 메시지 생성 함수들
const createQueryLatestMessage = (): Message => ({
type: MessageType.QUERY_LATEST,
data: null
});
const createQueryAllMessage = (): Message => ({
type: MessageType.QUERY_ALL,
data: null
});
const createResponseBlockchainMessage = (blockchain: Block[]): Message => ({
type: MessageType.RESPONSE_BLOCKCHAIN,
data: JSON.stringify(blockchain)
});
// 메시지 파싱
const parseMessage = (data: string): Message => {
return JSON.parse(data);
};
설명
이것이 하는 일: 메시지 타입 시스템은 P2P 네트워크에서 주고받는 모든 메시지에 일관된 구조를 부여하고, TypeScript의 타입 체크를 통해 잘못된 메시지 생성을 방지합니다. 코드의 핵심을 살펴보겠습니다.
첫 번째로, MessageType enum은 가능한 모든 메시지 종류를 숫자 상수로 정의합니다. 0~4까지의 숫자가 각각 다른 의미를 갖습니다.
이렇게 숫자로 하는 이유는 네트워크 전송 시 문자열보다 바이트 크기가 작아 효율적이기 때문입니다. 실제 비트코인 프로토콜도 비슷하게 숫자 코드를 사용합니다.
예를 들어 QUERY_LATEST는 "나에게 최신 블록 하나만 보내줘"라는 의미이고, QUERY_ALL은 "전체 블록체인을 보내줘"라는 의미입니다. 그 다음으로, Message 인터페이스는 모든 메시지가 따라야 할 기본 구조를 정의합니다.
type 필드로 메시지 종류를 식별하고, data 필드에 실제 페이로드를 담습니다. data를 any로 한 것은 메시지 타입마다 다른 형태의 데이터를 담을 수 있기 때문입니다.
더 엄격하게 하려면 제네릭을 사용하거나 타입별로 인터페이스를 세분화할 수도 있습니다. 세 번째로, 메시지 생성 함수들은 일관된 방식으로 메시지 객체를 만듭니다.
createQueryLatestMessage 같은 함수를 사용하면 매번 객체를 수동으로 만들 필요 없이, 그냥 함수를 호출하면 올바른 형식의 메시지가 반환됩니다. createResponseBlockchainMessage는 Block 배열을 받아서 JSON 문자열로 변환하여 data 필드에 넣습니다.
이렇게 하면 블록체인 데이터를 네트워크로 전송할 수 있는 형태가 됩니다. 마지막으로, parseMessage 함수는 받은 문자열 데이터를 다시 Message 객체로 변환합니다.
WebSocket은 문자열이나 바이너리 데이터만 전송할 수 있기 때문에, 보낼 때는 JSON.stringify로 직렬화하고, 받을 때는 JSON.parse로 역직렬화해야 합니다. 여러분이 이 타입 시스템을 사용하면 코드의 안정성과 가독성이 크게 향상됩니다.
다른 개발자가 코드를 봐도 "아, QUERY_LATEST 메시지를 보내면 상대방이 최신 블록을 응답하겠구나"라고 즉시 이해할 수 있습니다. 또한 TypeScript 자동완성 덕분에 메시지 타입을 잘못 쓸 일도 없어집니다.
실전 팁
💡 메시지 타입을 추가할 때마다 대응하는 생성 함수와 핸들러도 함께 만들어야 일관성이 유지됩니다.
💡 data 필드를 제네릭으로 만들면 더 타입 안전합니다. Message<T>로 정의하고 각 메시지 타입마다 구체적인 데이터 타입을 지정하세요.
💡 JSON.parse는 예외를 던질 수 있으므로 try-catch로 감싸고, 파싱 실패 시 적절히 처리해야 합니다.
💡 메시지 버전 필드를 추가하면 프로토콜이 업데이트되어도 하위 호환성을 유지할 수 있습니다.
💡 큰 데이터는 압축해서 보내면 네트워크 대역폭을 절약할 수 있습니다. zlib 같은 라이브러리를 사용하세요.
4. 메시지 브로드캐스팅 - 모든 피어에게 전파하기
시작하며
여러분이 새로운 블록을 채굴했다면 어떻게 해야 할까요? 한 명한 명 일일이 메시지를 보내야 할까요?
연결된 노드가 100개, 1000개라면 코드가 얼마나 복잡해질까요? 블록체인에서 가장 중요한 것 중 하나가 정보의 신속한 전파입니다.
새로운 블록이나 트랜잭션이 생성되면 네트워크 전체에 빠르게 퍼져야 모든 노드가 동일한 상태를 유지할 수 있습니다. 만약 전파가 느리면 체인 분기가 자주 발생하고, 이중 지불 같은 문제가 생길 수 있습니다.
바로 이럴 때 필요한 것이 브로드캐스팅입니다. 연결된 모든 소켓에 동시에 같은 메시지를 보내는 기능으로, 한 줄의 코드로 모든 피어에게 정보를 전달할 수 있습니다.
개요
간단히 말해서, 브로드캐스팅은 여러분이 관리하는 모든 WebSocket 연결에 동일한 메시지를 한 번에 전송하는 것입니다. P2P 네트워크에서 브로드캐스팅은 필수적입니다.
여러분이 10개의 피어와 연결되어 있고 새로운 블록을 채굴했다면, 이 10개 노드 모두에게 즉시 알려야 합니다. 그러면 그 노드들도 각자 연결된 다른 노드들에게 전파하고, 이런 식으로 네트워크 전체에 몇 초 안에 퍼집니다.
예를 들어, 비트코인에서는 평균 10초 안에 새로운 블록이 전 세계 대부분의 노드에 도달합니다. 기존 방식과 비교하면 어떨까요?
중앙화된 시스템에서는 서버가 클라이언트들에게 푸시 알림을 보내지만, P2P에서는 각 노드가 직접 연결된 피어들에게 전파합니다. 이렇게 하면 단일 실패 지점이 없고, 일부 노드가 다운되어도 네트워크는 계속 작동합니다.
브로드캐스팅의 핵심 특징은 동시 전송, 효율성, 신뢰성입니다. 배열에 저장된 모든 소켓을 순회하면서 각각에 메시지를 보내되, 한 소켓에서 에러가 나도 다른 소켓들에는 계속 전송합니다.
또한 WebSocket의 비동기 특성 덕분에 여러 전송이 동시에 진행됩니다.
코드 예제
class P2PBroadcaster {
private sockets: WebSocket[] = [];
// 소켓 추가
addSocket(socket: WebSocket): void {
this.sockets.push(socket);
}
// 모든 피어에게 메시지 전송
broadcast(message: Message): void {
const messageStr = JSON.stringify(message);
this.sockets.forEach(socket => {
// OPEN 상태인 소켓에만 전송
if (socket.readyState === WebSocket.OPEN) {
socket.send(messageStr);
}
});
}
// 특정 타입의 메시지 브로드캐스트
broadcastLatestBlock(latestBlock: Block): void {
const message = createResponseBlockchainMessage([latestBlock]);
this.broadcast(message);
}
// 전체 블록체인 브로드캐스트
broadcastBlockchain(blockchain: Block[]): void {
const message = createResponseBlockchainMessage(blockchain);
this.broadcast(message);
}
// 연결된 피어 수 확인
getPeerCount(): number {
return this.sockets.filter(s => s.readyState === WebSocket.OPEN).length;
}
}
// 사용 예시
const broadcaster = new P2PBroadcaster();
// 새 블록 채굴 시
const newBlock = mineBlock(blockchainData, difficulty);
broadcaster.broadcastLatestBlock(newBlock);
설명
이것이 하는 일: 브로드캐스터는 관리하는 모든 WebSocket 연결을 순회하면서 각각에 동일한 메시지를 전송하여, 네트워크 전체에 정보가 빠르게 퍼지도록 합니다. 코드 구조를 자세히 살펴보겠습니다.
첫 번째로, broadcast 메서드가 핵심입니다. Message 객체를 받아서 JSON 문자열로 변환한 후, sockets 배열의 모든 소켓을 forEach로 순회합니다.
이때 중요한 것은 socket.readyState === WebSocket.OPEN 체크입니다. WebSocket은 CONNECTING, OPEN, CLOSING, CLOSED 네 가지 상태를 가질 수 있는데, OPEN 상태일 때만 메시지를 보낼 수 있습니다.
다른 상태에서 send를 호출하면 에러가 발생하므로 반드시 확인해야 합니다. 그 다음으로, broadcastLatestBlock과 broadcastBlockchain 같은 특화된 브로드캐스트 함수들이 있습니다.
이들은 각각의 용도에 맞는 메시지를 생성한 후 broadcast를 호출합니다. broadcastLatestBlock은 방금 채굴한 블록 하나만 배열에 담아서 보내는 것이고, broadcastBlockchain은 전체 블록체인을 보냅니다.
이렇게 함수를 분리하면 코드가 명확해지고, 나중에 각 브로드캐스트 전후에 로깅이나 검증 로직을 추가하기도 쉽습니다. 세 번째로, getPeerCount 메서드는 현재 활성화된 연결 수를 반환합니다.
이것은 디버깅이나 모니터링에 유용합니다. "지금 몇 개의 피어와 연결되어 있나?"를 알 수 있으면 네트워크 상태를 파악하기 쉽습니다.
예를 들어 연결된 피어가 0이라면 여러분의 노드가 네트워크에서 고립된 것이므로, 다른 피어들에게 재연결을 시도해야 합니다. 실제 사용 시나리오를 보면, 새 블록을 채굴하면 즉시 broadcastLatestBlock을 호출합니다.
그러면 연결된 모든 피어가 이 블록을 받아서 자신의 블록체인에 추가하려고 시도합니다. 만약 블록이 유효하면 체인에 추가하고, 그 피어들도 각자의 피어들에게 다시 브로드캐스트합니다.
이렇게 해서 몇 초 안에 전체 네트워크에 전파됩니다. 여러분이 이 브로드캐스팅 기능을 사용하면 중앙 서버 없이도 모든 노드가 동기화된 상태를 유지할 수 있습니다.
이것이 바로 블록체인이 분산화되어 있으면서도 일관성을 유지할 수 있는 비결입니다.
실전 팁
💡 브로드캐스트 전에 메시지 크기를 체크하세요. 너무 큰 메시지는 청크로 나누거나 압축해서 보내야 합니다.
💡 send가 실패할 수 있으므로 try-catch로 감싸고, 실패한 소켓은 배열에서 제거하는 것이 좋습니다.
💡 같은 데이터를 여러 번 JSON.stringify하지 말고, 한 번만 하고 결과를 재사용하세요. 성능 차이가 큽니다.
💡 중복 브로드캐스트를 방지하려면 메시지마다 고유 ID를 부여하고, 이미 받은 메시지는 다시 전파하지 않도록 하세요.
💡 피어 수가 많다면 Promise.all로 병렬 전송하되, 실패한 것들만 따로 처리하는 로직을 추가하세요.
5. 메시지 핸들러 구현 - 받은 메시지 처리하기
시작하며
메시지를 보낼 수는 있는데, 받은 메시지는 어떻게 처리해야 할까요? 단순히 console.log로 출력만 하면 아무 의미가 없습니다.
메시지 타입에 따라 적절한 동작을 수행해야 진짜 블록체인 노드가 됩니다. 실제로 피어로부터 "최신 블록 좀 보내줘"라는 메시지를 받았을 때, 여러분의 블록체인에서 최신 블록을 가져와서 응답해야 합니다.
또는 "이게 새로운 블록이야"라는 메시지를 받으면 검증한 후 자신의 체인에 추가해야 합니다. 이런 로직이 없으면 노드들은 서로 연결만 되어 있을 뿐 실제로 협력하지 못합니다.
바로 이럴 때 필요한 것이 메시지 핸들러입니다. 각 메시지 타입에 대해 어떤 함수를 실행할지 정의하여, 받은 메시지에 자동으로 반응하도록 만드는 것입니다.
개요
간단히 말해서, 메시지 핸들러는 메시지 타입과 처리 함수를 매핑하는 라우터입니다. switch 문이나 객체 맵으로 구현할 수 있습니다.
P2P 통신에서 메시지 핸들러는 노드의 두뇌 역할을 합니다. 피어가 요청하면 응답하고, 새로운 데이터를 받으면 검증하고 저장합니다.
예를 들어, QUERY_LATEST 메시지를 받으면 자신의 블록체인에서 마지막 블록을 가져와 RESPONSE_BLOCKCHAIN 메시지로 응답합니다. 반대로 RESPONSE_BLOCKCHAIN을 받으면 받은 블록들을 검증하고, 자신의 체인보다 길면 교체합니다.
전통적인 REST API와 비교하면, REST에서는 GET /blocks/latest 같은 엔드포인트를 만들지만, WebSocket에서는 메시지 타입으로 구분합니다. 차이점은 WebSocket은 양방향이라는 것입니다.
서버와 클라이언트 구분 없이 누구나 요청을 보내고 응답할 수 있습니다. 메시지 핸들러의 핵심 특징은 타입 기반 라우팅, 비동기 처리, 에러 처리입니다.
각 메시지 타입마다 다른 로직을 실행하고, 블록체인 조회나 검증 같은 무거운 작업은 비동기로 처리하며, 잘못된 메시지가 와도 프로그램이 중단되지 않도록 해야 합니다.
코드 예제
class MessageHandler {
constructor(
private blockchain: Blockchain,
private broadcaster: P2PBroadcaster
) {}
// 메시지 처리 메인 함수
handleMessage(socket: WebSocket, messageData: string): void {
try {
const message: Message = JSON.parse(messageData);
switch (message.type) {
case MessageType.QUERY_LATEST:
this.handleQueryLatest(socket);
break;
case MessageType.QUERY_ALL:
this.handleQueryAll(socket);
break;
case MessageType.RESPONSE_BLOCKCHAIN:
this.handleResponseBlockchain(message.data);
break;
default:
console.log("알 수 없는 메시지 타입:", message.type);
}
} catch (error) {
console.error("메시지 처리 오류:", error);
}
}
// 최신 블록 요청 처리
private handleQueryLatest(socket: WebSocket): void {
const latestBlock = this.blockchain.getLatestBlock();
const message = createResponseBlockchainMessage([latestBlock]);
socket.send(JSON.stringify(message));
}
// 전체 블록체인 요청 처리
private handleQueryAll(socket: WebSocket): void {
const blocks = this.blockchain.getAllBlocks();
const message = createResponseBlockchainMessage(blocks);
socket.send(JSON.stringify(message));
}
// 블록체인 응답 처리
private handleResponseBlockchain(data: string): void {
const receivedBlocks: Block[] = JSON.parse(data);
if (receivedBlocks.length === 0) return;
// 받은 체인이 더 길면 교체
if (receivedBlocks.length > this.blockchain.getLength()) {
if (this.blockchain.replaceChain(receivedBlocks)) {
console.log("블록체인이 업데이트되었습니다");
this.broadcaster.broadcastBlockchain(receivedBlocks);
}
}
}
}
설명
이것이 하는 일: 메시지 핸들러는 WebSocket으로 받은 원시 데이터를 파싱하고, 메시지 타입에 따라 블록체인 조회, 블록 검증, 체인 업데이트 등 다양한 작업을 수행합니다. 코드의 흐름을 단계별로 살펴보겠습니다.
첫 번째로, handleMessage 메서드가 모든 메시지의 진입점입니다. 문자열 데이터를 받아서 JSON.parse로 Message 객체로 변환합니다.
이 과정은 try-catch로 감싸져 있어서, 잘못된 JSON이 오거나 예상치 못한 에러가 발생해도 프로그램이 죽지 않습니다. 파싱에 성공하면 switch 문으로 메시지 타입을 판별하여 해당하는 핸들러 함수를 호출합니다.
그 다음으로, 각각의 핸들러 함수들을 보겠습니다. handleQueryLatest는 상대방이 "최신 블록 보내줘"라고 요청했을 때 실행됩니다.
자신의 블록체인에서 getLatestBlock()으로 마지막 블록을 가져오고, 이것을 배열에 담아서 RESPONSE_BLOCKCHAIN 메시지로 만든 후 요청한 소켓에만 send합니다. 브로드캐스트가 아니라 1:1 응답입니다.
handleQueryAll도 비슷하지만 전체 블록 배열을 보냅니다. 세 번째로, handleResponseBlockchain이 가장 중요합니다.
피어로부터 블록체인 데이터를 받았을 때 실행됩니다. 먼저 data 문자열을 Block 배열로 파싱합니다.
그 다음 받은 체인의 길이를 자신의 체인과 비교합니다. 블록체인에서는 가장 긴 체인이 정당한 체인으로 간주되므로(작업 증명이 가장 많이 투입된 체인), 받은 체인이 더 길면 replaceChain을 시도합니다.
이 메서드 내부에서는 받은 체인의 모든 블록이 유효한지 검증하고, 유효하면 자신의 체인을 교체합니다. 마지막으로, 체인이 성공적으로 교체되면 이 새로운 체인을 다시 브로드캐스트합니다.
왜냐하면 다른 피어들도 업데이트가 필요할 수 있기 때문입니다. 이렇게 해서 네트워크 전체가 점점 동일한 상태로 수렴해 갑니다.
여러분이 이 핸들러를 사용하면 노드가 자율적으로 동작합니다. 사람이 일일이 명령을 내리지 않아도, 피어들끼리 서로 묻고 답하면서 블록체인을 동기화하고 최신 상태를 유지합니다.
이것이 바로 분산 시스템의 핵심입니다.
실전 팁
💡 메시지 검증 로직을 추가하세요. 메시지 구조가 올바른지, 필수 필드가 있는지 확인해야 악의적인 메시지를 거를 수 있습니다.
💡 핸들러 함수들을 async/await로 만들면 DB 조회나 무거운 검증 작업을 논블로킹으로 처리할 수 있습니다.
💡 핸들러마다 실행 시간을 로깅하면 어떤 메시지 처리가 느린지 파악하여 최적화할 수 있습니다.
💡 받은 블록체인이 유효하지 않으면 해당 피어를 차단하는 로직을 추가하여 악의적인 노드를 걸러내세요.
💡 메시지 처리 중 에러가 발생하면 에러 메시지를 보낸 피어에게 응답하여 문제를 알려주는 것도 좋은 방법입니다.
6. 피어 초기화 및 동기화 - 네트워크 진입 시 체인 동기화
시작하며
새로운 노드가 네트워크에 처음 참여하면 어떻게 될까요? 블록체인이 비어있는 상태에서 시작하면 다른 노드들과 완전히 다른 상태가 됩니다.
이미 수천 개의 블록이 존재하는 네트워크에서 어떻게 따라잡을 수 있을까요? 비트코인 노드를 처음 실행하면 며칠 동안 "동기화 중..."이라는 메시지를 본 적 있을 겁니다.
이것은 2009년부터 지금까지의 모든 블록을 다운로드하고 검증하는 과정입니다. 작은 규모의 프라이빗 블록체인이라도 기존 노드들과 상태를 맞추는 것은 필수입니다.
바로 이럴 때 필요한 것이 피어 초기화 및 동기화 로직입니다. 연결이 성공하면 즉시 상대방의 블록체인을 요청하고, 받은 후에는 검증하여 자신의 체인을 업데이트하는 과정입니다.
개요
간단히 말해서, 피어 초기화는 새로운 연결이 맺어졌을 때 자동으로 상대방에게 블록체인 정보를 요청하여 동기화하는 과정입니다. 이 과정이 없으면 각 노드가 제각각의 블록체인을 가지게 되어 합의가 불가능합니다.
초기화 로직은 보통 두 단계로 이루어집니다. 첫째, 상대방의 최신 블록만 요청하여 자신의 체인과 비교합니다.
만약 상대방의 블록 높이가 더 높으면, 전체 블록체인을 요청하여 업데이트합니다. 예를 들어, 여러분의 체인에 100개 블록이 있는데 상대방이 150개를 가지고 있다면, 전체를 요청하여 따라잡아야 합니다.
전통적인 데이터베이스 동기화와 비교하면 어떨까요? 중앙화된 시스템에서는 마스터 DB에서 슬레이브로 일방향 복제가 일어나지만, P2P에서는 누가 마스터인지 모릅니다.
각 피어가 자신의 체인과 다른 피어의 체인을 비교하여, 더 긴(더 많은 작업 증명이 들어간) 체인을 채택합니다. 피어 초기화의 핵심 특징은 자동 동기화, 점진적 업데이트, 검증 기반 수용입니다.
연결되면 자동으로 동기화를 시작하고, 필요한 부분만 요청하며, 받은 데이터가 유효한지 반드시 검증한 후 수용합니다.
코드 예제
class PeerInitializer {
constructor(
private blockchain: Blockchain,
private messageHandler: MessageHandler
) {}
// 새 피어 연결 시 초기화
initConnectionWithPeer(socket: WebSocket): void {
// 메시지 핸들러 등록
socket.on("message", (data: string) => {
this.messageHandler.handleMessage(socket, data);
});
// 에러 및 종료 처리
socket.on("error", () => this.handleConnectionError(socket));
socket.on("close", () => this.handleConnectionClose(socket));
// 최신 블록 요청하여 동기화 시작
this.queryLatestBlock(socket);
}
// 최신 블록 요청
private queryLatestBlock(socket: WebSocket): void {
const message = createQueryLatestMessage();
socket.send(JSON.stringify(message));
console.log("피어에게 최신 블록 요청");
}
// 전체 블록체인 요청
private queryAllBlocks(socket: WebSocket): void {
const message = createQueryAllMessage();
socket.send(JSON.stringify(message));
console.log("피어에게 전체 블록체인 요청");
}
// 블록체인 비교 및 동기화 결정
compareAndSync(receivedBlocks: Block[]): void {
if (receivedBlocks.length === 0) return;
const latestReceivedBlock = receivedBlocks[receivedBlocks.length - 1];
const myLatestBlock = this.blockchain.getLatestBlock();
// 받은 블록이 더 최신이면
if (latestReceivedBlock.index > myLatestBlock.index) {
console.log("피어의 체인이 더 길어 동기화 필요");
// 전체 체인 교체 시도
if (receivedBlocks.length === 1) {
// 한 블록만 받았으면 전체 요청
console.log("전체 블록체인 재요청");
} else {
// 여러 블록 받았으면 검증 후 교체
this.blockchain.replaceChain(receivedBlocks);
}
} else {
console.log("내 체인이 최신 상태입니다");
}
}
private handleConnectionError(socket: WebSocket): void {
console.log("피어 연결 오류 발생");
}
private handleConnectionClose(socket: WebSocket): void {
console.log("피어 연결 종료");
}
}
설명
이것이 하는 일: 피어 초기화 로직은 WebSocket 연결이 성공하면 즉시 이벤트 핸들러를 등록하고, 상대방에게 최신 블록을 요청하여 자신의 블록체인과 비교한 후 필요시 전체 동기화를 수행합니다. 코드의 동작을 자세히 살펴보겠습니다.
첫 번째로, initConnectionWithPeer 메서드가 모든 새로운 연결에서 실행됩니다. 서버로서 연결을 받았든, 클라이언트로서 연결을 시작했든 상관없이 동일한 초기화 과정을 거칩니다.
먼저 message 이벤트 리스너를 등록하여 이 소켓에서 오는 모든 메시지가 messageHandler.handleMessage로 전달되도록 합니다. 그 다음 에러와 종료 이벤트도 처리합니다.
마지막으로, queryLatestBlock을 호출하여 상대방에게 최신 블록을 보내달라고 요청합니다. 그 다음으로, 상대방이 RESPONSE_BLOCKCHAIN 메시지로 응답하면 메시지 핸들러에서 compareAndSync가 호출됩니다.
이 메서드는 받은 블록들의 마지막 블록(최신 블록)과 자신의 최신 블록을 비교합니다. index 필드는 블록의 높이를 나타내므로, 숫자가 클수록 더 최신입니다.
만약 받은 블록의 인덱스가 더 크면, 상대방이 더 긴 체인을 가지고 있다는 뜻입니다. 세 번째로, 동기화 전략을 결정합니다.
만약 상대방이 최신 블록 하나만 보냈다면(receivedBlocks.length === 1), 그것만으로는 전체 체인을 알 수 없으므로 QUERY_ALL 메시지를 보내서 전체 블록체인을 요청해야 합니다. 하지만 여러 블록을 받았다면, 이것이 상대방의 전체 체인이므로 즉시 replaceChain을 호출하여 검증하고 교체합니다.
replaceChain 메서드 내부에서는 받은 체인의 모든 블록을 처음부터 끝까지 검증합니다. 제네시스 블록이 올바른지, 각 블록의 해시가 맞는지, 이전 블록 해시가 연결되는지, 작업 증명이 유효한지 등을 확인합니다.
하나라도 문제가 있으면 체인 전체를 거부합니다. 모든 검증을 통과해야만 자신의 체인을 교체하고 true를 반환합니다.
여러분이 이 초기화 로직을 사용하면 노드를 새로 시작해도 몇 초 안에 네트워크와 동기화됩니다. 작은 프라이빗 블록체인이라면 거의 즉시, 큰 블록체인이라도 점진적으로 블록을 다운로드하면서 빠르게 따라잡을 수 있습니다.
실전 팁
💡 동기화가 오래 걸리면 진행 상태를 표시하세요. "150개 블록 중 50개 동기화 완료" 같은 정보가 있으면 사용자가 안심합니다.
💡 한 피어에서 동기화 실패하면 다른 피어에게 시도하세요. 여러 피어와 연결된 이유가 바로 이것입니다.
💡 동기화 중에도 새로운 블록이 생성될 수 있으므로, 동기화 완료 후 다시 한 번 최신 블록을 확인하세요.
💡 대용량 블록체인은 한 번에 모두 요청하지 말고, 1000블록씩 나눠서 요청하는 페이지네이션을 구현하세요.
💡 체인 검증에 실패하면 해당 피어를 "불신 목록"에 추가하여 다시 연결하지 않도록 하세요.
7. WebSocket 서버와 클라이언트 통합 - 완전한 P2P 노드
시작하며
지금까지 서버 기능, 클라이언트 기능, 메시지 핸들링, 브로드캐스팅을 모두 따로 만들었습니다. 하지만 실제 블록체인 노드는 이 모든 것을 동시에 해야 합니다.
어떻게 하나로 통합할까요? 실제 P2P 네트워크의 각 노드는 하이브리드입니다.
다른 노드의 연결을 받아들이는 서버이자, 동시에 다른 노드에 접속하는 클라이언트입니다. 비트코인 노드를 실행하면 8333 포트로 서버를 열면서, 동시에 다른 노드들에게 아웃바운드 연결을 시도합니다.
이렇게 해야 진정한 분산 네트워크가 형성됩니다. 바로 이럴 때 필요한 것이 서버와 클라이언트의 통합입니다.
하나의 클래스에서 모든 P2P 기능을 관리하여, 외부에서는 간단한 API로 노드를 시작하고 피어를 추가할 수 있도록 만드는 것입니다.
개요
간단히 말해서, P2P 노드 클래스는 WebSocket 서버, 클라이언트, 메시지 핸들러, 브로드캐스터를 모두 포함하여 완전한 블록체인 노드 기능을 제공합니다. 통합의 핵심은 모든 소켓을 하나의 배열로 관리하는 것입니다.
서버로 받은 인바운드 연결이든, 클라이언트로 시작한 아웃바운드 연결이든, 일단 연결이 성공하면 똑같이 취급합니다. 모두 같은 메시지 핸들러를 사용하고, 브로드캐스트 시 모두에게 전송됩니다.
예를 들어, 10개 연결 중 3개는 여러분이 받은 것이고 7개는 여러분이 시작한 것이라도, 새 블록을 브로드캐스트하면 10개 모두에게 갑니다. 전통적인 서버 아키텍처와의 차이를 보면, 일반적으로는 서버 코드와 클라이언트 코드가 완전히 분리되어 있습니다.
하지만 P2P에서는 둘이 하나의 프로그램 안에 공존하며, 심지어 같은 로직을 공유합니다. 이것이 분산 시스템의 아름다움입니다.
통합 노드의 핵심 특징은 양방향 역할, 통합된 소켓 관리, 일관된 메시지 처리입니다. 하나의 인터페이스로 모든 P2P 기능을 제어할 수 있고, 내부적으로는 서버든 클라이언트든 상관없이 동일하게 처리됩니다.
코드 예제
class P2PNode {
private sockets: WebSocket[] = [];
private server: WebSocket.Server;
constructor(
private p2pPort: number,
private blockchain: Blockchain
) {
this.server = new WebSocket.Server({ port: p2pPort });
this.initServer();
}
// 서버 초기화
private initServer(): void {
this.server.on("connection", (socket: WebSocket) => {
console.log("새 피어 연결됨 (인바운드)");
this.initConnection(socket);
});
console.log(`P2P 서버 시작: 포트 ${this.p2pPort}`);
}
// 피어에 연결 (클라이언트)
connectToPeer(peerUrl: string): void {
const socket = new WebSocket(peerUrl);
socket.on("open", () => {
console.log("피어 연결 성공 (아웃바운드):", peerUrl);
this.initConnection(socket);
});
socket.on("error", () => {
console.log("피어 연결 실패:", peerUrl);
});
}
// 연결 초기화 (인바운드/아웃바운드 공통)
private initConnection(socket: WebSocket): void {
this.sockets.push(socket);
this.initMessageHandler(socket);
this.initErrorHandler(socket);
this.queryLatestBlock(socket);
}
// 메시지 핸들러 등록
private initMessageHandler(socket: WebSocket): void {
socket.on("message", (data: string) => {
const message: Message = JSON.parse(data);
this.handleMessage(socket, message);
});
}
// 에러 핸들러 등록
private initErrorHandler(socket: WebSocket): void {
const closeConnection = () => {
console.log("연결 종료");
this.sockets = this.sockets.filter(s => s !== socket);
};
socket.on("close", closeConnection);
socket.on("error", closeConnection);
}
// 메시지 처리
private handleMessage(socket: WebSocket, message: Message): void {
switch (message.type) {
case MessageType.QUERY_LATEST:
const latest = this.blockchain.getLatestBlock();
socket.send(JSON.stringify(
createResponseBlockchainMessage([latest])
));
break;
case MessageType.RESPONSE_BLOCKCHAIN:
this.handleBlockchainResponse(JSON.parse(message.data));
break;
}
}
// 블록체인 응답 처리
private handleBlockchainResponse(blocks: Block[]): void {
if (blocks.length > this.blockchain.getLength()) {
if (this.blockchain.replaceChain(blocks)) {
this.broadcast(createResponseBlockchainMessage(blocks));
}
}
}
// 브로드캐스트
broadcast(message: Message): void {
this.sockets.forEach(socket => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
}
});
}
// 최신 블록 요청
private queryLatestBlock(socket: WebSocket): void {
socket.send(JSON.stringify(createQueryLatestMessage()));
}
// 연결된 피어 수
getPeers(): number {
return this.sockets.length;
}
}
// 사용 예시
const blockchain = new Blockchain();
const node = new P2PNode(6001, blockchain);
node.connectToPeer("ws://localhost:6002");
설명
이것이 하는 일: P2P 노드는 지정된 포트에서 WebSocket 서버를 실행하면서 동시에 다른 노드들에게 연결할 수 있으며, 모든 연결을 동일하게 관리하여 블록체인 동기화와 데이터 전파를 수행합니다. 코드 구조를 깊이 있게 분석해보겠습니다.
첫 번째로, constructor에서 WebSocket 서버를 생성하고 initServer를 호출합니다. 서버는 백그라운드에서 계속 실행되면서 들어오는 연결을 기다립니다.
p2pPort는 보통 6001, 6002 같은 값을 사용하며, 각 노드마다 다른 포트를 써야 동일한 머신에서 여러 노드를 테스트할 수 있습니다. blockchain 인스턴스를 받아서 저장하는 이유는 메시지 처리 시 블록체인을 조회하거나 업데이트해야 하기 때문입니다.
그 다음으로, connectToPeer 메서드로 다른 노드에 클라이언트로 접속할 수 있습니다. 이것은 외부에서 호출하는 public 메서드입니다.
WebSocket 객체를 생성하고 open 이벤트가 발생하면 initConnection을 호출합니다. 여기서 중요한 점은 서버로 받은 연결(initServer의 connection 이벤트)이든 클라이언트로 시작한 연결이든, 모두 똑같이 initConnection으로 전달된다는 것입니다.
이렇게 하면 인바운드와 아웃바운드를 구분할 필요 없이 통일된 방식으로 처리됩니다. 세 번째로, initConnection이 모든 연결의 핵심 초기화 로직입니다.
소켓을 sockets 배열에 추가하고, 메시지와 에러 핸들러를 등록합니다. 마지막으로 queryLatestBlock을 호출하여 즉시 상대방에게 최신 블록을 요청합니다.
이렇게 하면 연결되자마자 자동으로 동기화가 시작됩니다. 양쪽 노드가 서로에게 최신 블록을 요청하므로, 누가 먼저 연결을 시작했든 상관없이 동기화됩니다.
네 번째로, handleMessage에서 받은 메시지를 처리합니다. QUERY_LATEST를 받으면 자신의 최신 블록을 응답하고, RESPONSE_BLOCKCHAIN을 받으면 받은 블록들과 자신의 체인을 비교합니다.
만약 받은 체인이 더 길고 유효하다면, replaceChain으로 교체하고 그 체인을 다시 모든 피어에게 브로드캐스트합니다. 이렇게 해서 네트워크 전체가 가장 긴 유효한 체인으로 수렴하게 됩니다.
여러분이 이 통합 노드 클래스를 사용하면 간단한 API로 완전한 P2P 블록체인을 운영할 수 있습니다. new P2PNode(6001, blockchain)로 노드를 생성하고, connectToPeer로 다른 노드들과 연결하면 끝입니다.
나머지는 자동으로 처리됩니다.
실전 팁
💡 여러 노드를 로컬에서 테스트하려면 각각 다른 포트를 사용하세요. 6001, 6002, 6003 식으로 증가시키면 됩니다.
💡 실제 환경에서는 환경 변수로 포트와 시드 노드 목록을 설정하면 배포가 편리합니다.
💡 연결된 피어 목록을 저장했다가 재시작 시 자동으로 재연결하는 기능을 추가하면 유용합니다.
💡 최대 연결 수를 제한하세요. 무제한으로 받으면 DoS 공격에 취약할 수 있습니다. 보통 50~100개 정도가 적당합니다.
💡 연결 품질을 모니터링하여 응답이 느린 피어는 자동으로 끊고 새로운 피어를 찾는 로직을 추가하세요.
8. Ping-Pong 하트비트 - 죽은 연결 탐지 및 정리
시작하며
여러분의 노드가 10개의 피어와 연결되어 있다고 생각하지만, 실제로는 그 중 3개가 이미 오프라인 상태라면 어떻게 될까요? WebSocket은 연결이 끊어져도 즉시 알려주지 않는 경우가 많습니다.
네트워크 문제나 서버 다운 시, TCP 연결이 정상적으로 종료되지 않으면 "좀비 연결"이 남습니다. 여러분의 소켓 배열에는 존재하지만 실제로는 통신할 수 없는 연결입니다.
이런 상태로 브로드캐스트를 시도하면 메시지가 전달되지 않고, 리소스만 낭비됩니다. 바로 이럴 때 필요한 것이 Ping-Pong 하트비트입니다.
주기적으로 모든 피어에게 ping을 보내고 pong 응답을 확인하여, 응답하지 않는 연결은 자동으로 정리하는 메커니즘입니다.
개요
간단히 말해서, 하트비트는 일정 간격으로 "너 살아있니?" 신호를 보내서 연결 상태를 확인하고, 응답 없는 연결을 제거하는 기능입니다. WebSocket 프로토콜은 기본적으로 ping/pong 프레임을 지원합니다.
이것은 애플리케이션 레벨 메시지가 아니라 프로토콜 레벨의 제어 프레임입니다. ws 라이브러리는 ping() 메서드를 제공하며, 상대방은 자동으로 pong을 응답합니다.
우리는 이 응답을 감지하여 연결이 살아있음을 확인할 수 있습니다. 예를 들어, 30초마다 ping을 보내고 40초 동안 pong이 없으면 연결을 끊는 식입니다.
전통적인 방법에서는 애플리케이션 레벨에서 "heartbeat" 메시지를 직접 구현했지만, WebSocket의 내장 ping/pong을 사용하면 더 효율적입니다. 프로토콜 레벨이라 오버헤드가 적고, 네트워크 스택에서 직접 처리되기 때문입니다.
하트비트의 핵심 특징은 자동 탐지, 리소스 정리, 네트워크 건강성 유지입니다. 사람이 수동으로 죽은 연결을 찾을 필요 없이, 시스템이 자동으로 관리하여 항상 유효한 피어들과만 통신합니다.
코드 예제
class HeartbeatManager {
private readonly HEARTBEAT_INTERVAL = 30000; // 30초
private readonly HEARTBEAT_TIMEOUT = 40000; // 40초
private heartbeatIntervals: Map<WebSocket, NodeJS.Timeout> = new Map();
// 하트비트 시작
startHeartbeat(socket: WebSocket): void {
// 소켓에 alive 플래그 추가
(socket as any).isAlive = true;
// pong 이벤트 리스너
socket.on("pong", () => {
(socket as any).isAlive = true;
});
// 주기적으로 ping 전송
const interval = setInterval(() => {
if ((socket as any).isAlive === false) {
console.log("피어 응답 없음, 연결 종료");
socket.terminate(); // 강제 종료
this.stopHeartbeat(socket);
return;
}
// alive를 false로 설정하고 ping 전송
(socket as any).isAlive = false;
socket.ping();
}, this.HEARTBEAT_INTERVAL);
this.heartbeatIntervals.set(socket, interval);
}
// 하트비트 중지
stopHeartbeat(socket: WebSocket): void {
const interval = this.heartbeatIntervals.get(socket);
if (interval) {
clearInterval(interval);
this.heartbeatIntervals.delete(socket);
}
}
// 모든 연결에 하트비트 적용
startAllHeartbeats(sockets: WebSocket[]): void {
sockets.forEach(socket => this.startHeartbeat(socket));
}
// 모든 하트비트 정리
stopAllHeartbeats(): void {
this.heartbeatIntervals.forEach((interval, socket) => {
clearInterval(interval);
});
this.heartbeatIntervals.clear();
}
}
// P2P 노드에 통합
class P2PNodeWithHeartbeat extends P2PNode {
private heartbeatManager = new HeartbeatManager();
protected initConnection(socket: WebSocket): void {
super.initConnection(socket);
this.heartbeatManager.startHeartbeat(socket);
// 연결 종료 시 하트비트도 정리
socket.on("close", () => {
this.heartbeatManager.stopHeartbeat(socket);
});
}
}
설명
이것이 하는 일: 하트비트 매니저는 각 WebSocket 연결에 대해 타이머를 설정하여 주기적으로 ping을 보내고, pong 응답을 받으면 연결이 살아있다고 표시하며, 응답이 없으면 연결을 강제 종료합니다. 코드의 메커니즘을 자세히 살펴보겠습니다.
첫 번째로, startHeartbeat에서 소켓 객체에 isAlive라는 커스텀 속성을 추가합니다. TypeScript는 이것을 허용하지 않지만 (socket as any)로 우회할 수 있습니다.
이 플래그는 "마지막 ping에 대한 pong을 받았는가?"를 추적합니다. 초기값은 true입니다.
그 다음 pong 이벤트 리스너를 등록하여, pong을 받을 때마다 isAlive를 true로 설정합니다. 이것은 ws 라이브러리가 자동으로 처리해주는 이벤트입니다.
그 다음으로, setInterval로 30초마다 실행되는 타이머를 만듭니다. 타이머가 실행될 때마다 먼저 isAlive를 체크합니다.
만약 false라면, 이전 ping에 대한 pong을 받지 못했다는 뜻이므로 연결이 죽은 것으로 판단합니다. socket.terminate()로 즉시 연결을 종료하고, stopHeartbeat로 타이머를 정리합니다.
만약 isAlive가 true라면, 이것을 false로 바꾸고 socket.ping()을 호출합니다. 이제 다음 30초 안에 pong이 와서 isAlive를 true로 바꿔야 합니다.
세 번째로, heartbeatIntervals Map에 소켓과 타이머를 매핑하여 저장합니다. 이렇게 하면 나중에 특정 소켓의 하트비트를 중지해야 할 때 해당 타이머를 찾을 수 있습니다.
stopHeartbeat는 Map에서 타이머를 찾아서 clearInterval로 중지하고 Map에서 제거합니다. 메모리 누수를 방지하기 위해 연결이 정상적으로 종료될 때도 반드시 호출해야 합니다.
P2PNodeWithHeartbeat 클래스는 기존 P2P 노드를 확장하여 하트비트 기능을 추가합니다. initConnection을 오버라이드하여, 부모 클래스의 초기화를 먼저 수행한 후 startHeartbeat를 호출합니다.
또한 close 이벤트에 리스너를 추가하여 연결 종료 시 하트비트 타이머도 함께 정리합니다. 여러분이 이 하트비트 시스템을 사용하면 네트워크 문제로 끊어진 연결이 계속 메모리에 남아있는 문제를 방지할 수 있습니다.
피어 수가 정확하게 유지되고, 브로드캐스트 시 실제로 응답할 수 있는 피어들에게만 메시지가 전송됩니다.
실전 팁
💡 하트비트 간격과 타임아웃은 네트워크 환경에 맞게 조정하세요. 로컬 네트워크는 짧게, 인터넷 환경은 길게 설정합니다.
💡 ping 전송 실패도 에러 이벤트를 발생시킬 수 있으므로, try-catch로 감싸면 더 안전합니다.
💡 하트비트 실패 시 로그를 남기면 네트워크 품질 문제를 파악하는 데 도움됩니다.
💡 프로덕션에서는 연속으로 2~3번 실패해야 끊도록 하는 것도 좋은 방법입니다. 일시적인 네트워크 지연을 허용할 수 있습니다.
💡 연결이 많으면 모든 ping을 동시에 보내지 말고 시간을 분산시켜서 부하를 줄이세요.
9. 피어 디스커버리 - 네트워크 확장하기
시작하며
처음에는 설정 파일에 있는 몇 개의 시드 노드에만 연결합니다. 하지만 그 노드들이 모두 오프라인이면 어떻게 될까요?
또는 네트워크에 수천 개의 노드가 있는데 어떻게 더 많은 피어를 찾을 수 있을까요? 실제 비트코인이나 이더리움 네트워크에서는 노드들이 서로 알고 있는 피어 목록을 공유합니다.
여러분이 A 노드에 연결하면, A가 "내가 아는 다른 노드들은 B, C, D야"라고 알려줍니다. 그러면 여러분은 B, C, D에도 연결을 시도하여 네트워크를 확장합니다.
이렇게 해서 중앙 서버 없이도 네트워크가 유기적으로 성장합니다. 바로 이럴 때 필요한 것이 피어 디스커버리입니다.
연결된 피어로부터 새로운 피어 정보를 받고, 그들에게도 연결을 시도하여 네트워크 연결성을 높이는 메커니즘입니다.
개요
간단히 말해서, 피어 디스커버리는 알려진 피어들로부터 새로운 피어의 주소를 받아서 자동으로 연결을 확장하는 기능입니다. 이 기능이 없으면 네트워크가 정적입니다.
처음 설정한 피어들하고만 연결되고, 그 중 일부가 다운되면 고립될 수 있습니다. 하지만 디스커버리 기능이 있으면, 네트워크는 동적으로 변화합니다.
새로운 노드가 추가되면 자연스럽게 퍼져나가고, 오래된 노드가 사라지면 다른 노드들로 대체됩니다. 예를 들어, 여러분이 3개 피어와 연결되어 있고 각 피어가 5개씩의 다른 피어를 알려주면, 총 15개의 잠재적 연결 대상이 생깁니다.
전통적인 중앙화 시스템에서는 "마스터 서버"가 모든 노드 목록을 관리하지만, P2P에서는 각 노드가 부분적인 정보만 갖고 있으며, 이것을 공유하여 전체 네트워크를 구성합니다. 이것이 진정한 탈중앙화입니다.
피어 디스커버리의 핵심 특징은 점진적 확장, 중복 방지, 연결 제한입니다. 한 번에 모든 노드에 연결하지 않고 필요에 따라 늘려가며, 같은 피어에 중복 연결하지 않고, 최대 연결 수를 넘지 않도록 관리합니다.
코드 예제
enum MessageType {
// 기존 타입들...
QUERY_PEERS = 5, // 피어 목록 요청
RESPONSE_PEERS = 6 // 피어 목록 응답
}
interface PeerInfo {
url: string;
lastSeen: number;
}
class PeerDiscovery {
private knownPeers: Map<string, PeerInfo> = new Map();
private maxPeers: number = 50;
constructor(private node: P2PNode) {}
// 피어 목록 요청 메시지 생성
createQueryPeersMessage(): Message {
return {
type: MessageType.QUERY_PEERS,
data: null
};
}
// 피어 목록 응답 메시지 생성
createResponsePeersMessage(peers: string[]): Message {
return {
type: MessageType.RESPONSE_PEERS,
data: JSON.stringify(peers)
};
}
// 받은 피어 목록 처리
handlePeersResponse(peersData: string): void {
const receivedPeers: string[] = JSON.parse(peersData);
receivedPeers.forEach(peerUrl => {
// 자기 자신이 아니고, 아직 모르는 피어라면
if (!this.isOwnAddress(peerUrl) && !this.knownPeers.has(peerUrl)) {
this.knownPeers.set(peerUrl, {
url: peerUrl,
lastSeen: Date.now()
});
// 현재 연결 수가 최대치 미만이면 연결 시도
if (this.node.getPeers() < this.maxPeers) {
console.log("새 피어 발견, 연결 시도:", peerUrl);
this.node.connectToPeer(peerUrl);
}
}
});
}
// 알고 있는 피어 목록 반환
getKnownPeers(): string[] {
return Array.from(this.knownPeers.values())
.map(peer => peer.url)
.slice(0, 20); // 최대 20개만 공유
}
// 자기 자신의 주소인지 확인
private isOwnAddress(peerUrl: string): boolean {
// 자신의 포트와 비교
const ownPort = this.node.getPort();
return peerUrl.includes(`:${ownPort}`);
}
// 오래된 피어 정보 정리
cleanupOldPeers(): void {
const now = Date.now();
const maxAge = 24 * 60 * 60 * 1000; // 24시간
this.knownPeers.forEach((peer, url) => {
if (now - peer.lastSeen > maxAge) {
this.knownPeers.delete(url);
}
});
}
// 피어 정보 업데이트
updatePeerSeen(peerUrl: string): void {
const peer = this.knownPeers.get(peerUrl);
if (peer) {
peer.lastSeen = Date.now();
}
}
}
// 사용 예시
const discovery = new PeerDiscovery(node);
// 피어 목록 요청
socket.send(JSON.stringify(discovery.createQueryPeersMessage()));
// 피어 목록 응답 처리
discovery.handlePeersResponse(message.data);
설명
이것이 하는 일: 피어 디스커버리 시스템은 연결된 피어들에게 그들이 아는 다른 피어 목록을 요청하고, 받은 목록에서 새로운 피어를 발견하면 연결을 시도하여 네트워크 연결성을 향상시킵니다. 코드의 동작 원리를 분석해보겠습니다.
첫 번째로, 새로운 메시지 타입 QUERY_PEERS와 RESPONSE_PEERS를 추가합니다. 이것은 기존 블록체인 메시지와 별개로 피어 정보를 교환하기 위한 것입니다.
PeerInfo 인터페이스는 피어의 URL과 마지막으로 본 시간을 저장합니다. lastSeen은 나중에 오래된 피어를 정리할 때 사용됩니다.
예를 들어 한 달 전에 추가된 피어는 이미 오프라인일 가능성이 높으므로 목록에서 제거합니다. 그 다음으로, knownPeers Map은 발견한 모든 피어를 저장합니다.
URL을 키로 사용하여 중복을 자동으로 방지합니다. handlePeersResponse에서 받은 피어 목록을 순회하면서 각 피어를 처리합니다.
먼저 isOwnAddress로 자기 자신인지 확인합니다. 만약 자신의 주소가 포함되어 있으면 스킵해야 합니다.
그렇지 않으면 자기 자신에게 연결을 시도하는 웃긴 상황이 발생합니다. 세 번째로, 새로운 피어를 발견하면 knownPeers에 추가하고, 현재 연결 수를 확인합니다.
maxPeers 제한보다 적으면 즉시 connectToPeer를 호출하여 연결을 시도합니다. 이렇게 하면 네트워크가 자동으로 확장됩니다.
하지만 maxPeers 제한이 있어서 무한정 늘어나지는 않습니다. 보통 50~100개 정도가 적당하며, 너무 많으면 리소스 낭비이고 너무 적으면 네트워크가 분할될 위험이 있습니다.
네 번째로, getKnownPeers는 자신이 아는 피어 목록을 반환합니다. 하지만 모든 피어를 다 주지 않고 최대 20개로 제한합니다.
왜냐하면 한 번에 수백 개의 피어를 받으면 처리하기 부담스럽고, 네트워크 대역폭도 낭비되기 때문입니다. 각 노드가 일부씩만 공유해도 여러 노드로부터 받으면 충분한 정보가 모입니다.
마지막으로, cleanupOldPeers는 24시간 이상 보지 못한 피어를 삭제합니다. 이것을 주기적으로 실행하면(예: 1시간마다) 피어 목록이 항상 최신 상태로 유지됩니다.
오프라인된 노드에 계속 연결을 시도하는 낭비를 줄일 수 있습니다. 여러분이 이 디스커버리 시스템을 사용하면 네트워크가 유기체처럼 성장합니다.
새로운 노드가 추가되면 자연스럽게 퍼져나가고, 각 노드는 안정적인 연결을 유지하면서도 새로운 피어를 계속 탐색합니다. 이것이 진정한 P2P 네트워크의 모습입니다.
실전 팁
💡 피어 목록을 파일에 저장했다가 재시작 시 로드하면, 처음부터 다시 탐색할 필요가 없습니다.
💡 연결 성공률을 추적하여 자주 실패하는 피어는 우선순위를 낮추세요.
💡 지리적으로 가까운 피어를 우선 연결하면 레이턴시가 낮아집니다. IP 기반 위치 추정을 활용하세요.
💡 피어 목록에 평판 점수를 추가하면, 신뢰할 수 있는 노드를 우선적으로 연결할 수 있습니다.
💡 DNS 씨드를 구현하면 완전히 새로운 노드도 쉽게 네트워크에 진입할 수 있습니다. 도메인 조회 시 현재 활성 노드 IP를 반환하는 방식입니다.
10. 보안 고려사항 - DoS 방어와 악의적 피어 차단
시작하며
P2P 네트워크는 누구나 참여할 수 있습니다. 하지만 그것은 동시에 악의적인 노드도 참여할 수 있다는 뜻입니다.
초당 수천 개의 메시지를 보내거나, 잘못된 블록을 계속 전파하는 노드가 있다면 어떻게 막을 수 있을까요? 실제 블록체인 네트워크는 다양한 공격에 노출됩니다.
DoS 공격(서비스 거부), 스팸 메시지, 잘못된 블록 전파, 시빌 공격(한 사람이 여러 노드를 운영) 등 여러 위협이 있습니다. 방어 메커니즘이 없으면 악의적인 노드 몇 개가 전체 네트워크를 마비시킬 수 있습니다.
바로 이럴 때 필요한 것이 보안 조치입니다. 메시지 크기 제한, 전송 속도 제한, 잘못된 데이터 차단, 악의적 피어 블랙리스트 등을 구현하여 네트워크를 보호해야 합니다.
개요
간단히 말해서, P2P 보안은 악의적이거나 버그가 있는 노드로부터 시스템을 보호하기 위한 다층 방어 체계입니다. 보안의 첫 번째 원칙은 "신뢰하지 말고 검증하라"입니다.
피어로부터 받은 모든 데이터는 검증해야 합니다. 블록의 해시가 맞는지, 작업 증명이 유효한지, 트랜잭션 서명이 올바른지 등을 확인합니다.
하나라도 잘못되면 전체를 거부합니다. 예를 들어, 누군가 채굴 없이 블록을 만들어 보내도, 작업 증명이 없으면 즉시 거부됩니다.
두 번째 원칙은 "리소스를 보호하라"입니다. 메모리, CPU, 네트워크 대역폭은 유한합니다.
무제한으로 데이터를 받거나 처리하면 시스템이 다운됩니다. 따라서 메시지 크기를 제한하고, 초당 처리할 수 있는 메시지 수를 제한하며, 연결 수도 제한해야 합니다.
세 번째 원칙은 "나쁜 행위자를 격리하라"입니다. 반복적으로 잘못된 데이터를 보내거나 공격 패턴을 보이는 피어는 블랙리스트에 추가하여 더 이상 연결을 받지 않습니다.
IP 주소나 피어 ID 기반으로 차단할 수 있습니다. P2P 보안의 핵심 특징은 입력 검증, 속도 제한, 피어 평판 관리입니다.
모든 입력을 의심하고, 자원 소모를 제어하며, 피어의 행동을 추적하여 신뢰도를 평가합니다.
코드 예제
class P2PSecurity {
private messageCount: Map<string, number[]> = new Map();
private blacklist: Set<string> = new Set();
private readonly MAX_MESSAGE_SIZE = 10 * 1024 * 1024; // 10MB
private readonly MAX_MESSAGES_PER_MINUTE = 60;
private readonly BLACKLIST_DURATION = 60 * 60 * 1000; // 1시간
// 메시지 크기 검증
validateMessageSize(data: string): boolean {
const size = Buffer.byteLength(data, 'utf8');
if (size > this.MAX_MESSAGE_SIZE) {
console.log(`메시지 크기 초과: ${size} bytes`);
return false;
}
return true;
}
// 속도 제한 (Rate Limiting)
checkRateLimit(peerId: string): boolean {
const now = Date.now();
const timestamps = this.messageCount.get(peerId) || [];
// 1분 이내의 메시지만 필터링
const recentMessages = timestamps.filter(
time => now - time < 60000
);
if (recentMessages.length >= this.MAX_MESSAGES_PER_MINUTE) {
console.log(`속도 제한 초과: ${peerId}`);
this.addToBlacklist(peerId);
return false;
}
// 현재 메시지 추가
recentMessages.push(now);
this.messageCount.set(peerId, recentMessages);
return true;
}
// 블랙리스트에 추가
addToBlacklist(peerId: string): void {
this.blacklist.add(peerId);
console.log(`피어 블랙리스트 추가: ${peerId}`);
// 일정 시간 후 자동 제거
setTimeout(() => {
this.blacklist.delete(peerId);
console.log(`피어 블랙리스트 해제: ${peerId}`);
}, this.BLACKLIST_DURATION);
}
// 블랙리스트 확인
isBlacklisted(peerId: string): boolean {
return this.blacklist.has(peerId);
}
// 블록 검증
validateBlock(block: Block, previousBlock: Block): boolean {
// 인덱스 연속성
if (block.index !== previousBlock.index + 1) {
console.log("블록 인덱스 불일치");
return false;
}
// 이전 해시 연결
if (block.previousHash !== previousBlock.hash) {
console.log("이전 블록 해시 불일치");
return false;
}
// 블록 해시 검증
const calculatedHash = calculateHash(block);
if (block.hash !== calculatedHash) {
console.log("블록 해시 검증 실패");
return false;
}
// 작업 증명 검증
if (!this.validateProofOfWork(block.hash, block.difficulty)) {
console.log("작업 증명 검증 실패");
return false;
}
return true;
}
// 작업 증명 검증
private validateProofOfWork(hash: string, difficulty: number): boolean {
const requiredPrefix = '0'.repeat(difficulty);
return hash.startsWith(requiredPrefix);
}
// 메시지 처리 (보안 체크 포함)
secureHandleMessage(socket: WebSocket, peerId: string, data: string): boolean {
// 블랙리스트 확인
if (this.isBlacklisted(peerId)) {
console.log("블랙리스트 피어로부터 메시지 무시");
socket.terminate();
return false;
}
// 메시지 크기 확인
if (!this.validateMessageSize(data)) {
this.addToBlacklist(peerId);
socket.terminate();
return false;
}
// 속도 제한 확인
if (!this.checkRateLimit(peerId)) {
socket.terminate();
return false;
}
return true; // 검증 통과
}
}
설명
이것이 하는 일: 보안 시스템은 받는 모든 데이터와 연결을 다층적으로 검증하여, 악의적이거나 버그가 있는 노드가 네트워크에 피해를 주지 못하도록 차단하고 격리합니다. 코드의 보안 메커니즘을 상세히 살펴보겠습니다.
첫 번째로, validateMessageSize는 받은 메시지의 바이트 크기를 확인합니다. Buffer.byteLength를 사용하여 UTF-8 인코딩 기준으로 정확한 크기를 측정합니다.
10MB를 초과하면 false를 반환합니다. 왜냐하면 일반적인 블록체인 메시지는 몇 KB~몇 MB 수준이므로, 10MB를 넘는 메시지는 의심스럽기 때문입니다.
공격자가 거대한 메시지를 보내서 메모리를 고갈시키려는 시도를 차단합니다. 그 다음으로, checkRateLimit은 특정 피어가 보내는 메시지 빈도를 추적합니다.
messageCount Map에 피어 ID와 타임스탬프 배열을 저장합니다. 새 메시지가 올 때마다 최근 1분 이내의 메시지만 필터링하여 개수를 셉니다.
만약 60개를 초과하면(초당 1개 이상), 속도 제한 위반으로 간주하고 블랙리스트에 추가합니다. 정상적인 노드는 초당 1개도 보내지 않는 경우가 많으므로, 이 제한은 합리적입니다.
세 번째로, addToBlacklist는 악의적 피어를 Set에 추가합니다. Set은 중복을 자동으로 제거하므로 같은 피어를 여러 번 추가해도 문제없습니다.
setTimeout으로 1시간 후 자동 제거되도록 설정합니다. 영구 블랙리스트는 문제가 있습니다.
왜냐하면 그 피어의 IP가 나중에 다른 정상적인 노드에 할당될 수 있고, 또는 버그가 수정되어 정상화될 수도 있기 때문입니다. 시간 제한 블랙리스트는 공격을 차단하면서도 유연성을 유지합니다.
네 번째로, validateBlock은 블록의 무결성을 다각도로 검증합니다. 먼저 인덱스가 이전 블록보다 정확히 1 큰지 확인하여 블록이 순서대로 연결되는지 확인합니다.
그 다음 previousHash가 실제 이전 블록의 해시와 일치하는지 확인합니다. 이것이 블록체인을 "체인"으로 만드는 핵심입니다.
하나라도 변조되면 해시가 맞지 않아 즉시 탐지됩니다. 다섯 번째로, 블록 자체의 해시를 재계산하여 블록에 저장된 해시와 비교합니다.
calculateHash 함수는 블록의 모든 필드(인덱스, 타임스탬프, 데이터, 이전 해시, 논스)를 SHA-256으로 해싱합니다. 만약 블록 내용이 조금이라도 변경되었다면 해시가 완전히 달라지므로 검증 실패합니다.
마지막으로 validateProofOfWork에서 해시가 요구된 난이도를 만족하는지 확인합니다. 난이도 5라면 해시가 "00000"으로 시작해야 합니다.
이것이 없으면 누구나 채굴 없이 블록을 만들 수 있습니다. secureHandleMessage는 모든 보안 체크를 통합한 게이트키퍼입니다.
메시지를 처리하기 전에 이 함수를 호출하여, 블랙리스트 확인 → 크기 확인 → 속도 제한 확인을 순서대로 수행합니다. 하나라도 실패하면 즉시 연결을 종료(socket.terminate())하고 메시지를 무시합니다.
모든 검증을 통과한 메시지만 실제 핸들러로 전달됩니다. 여러분이 이 보안 시스템을 사용하면 악의적인 노드가 네트워크에 참여해도 피해를 최소화할 수 있습니다.
스팸 공격은 속도 제한으로, DoS 공격은 크기 제한으로, 잘못된 블록은 검증 로직으로 차단됩니다. 완벽한 보안은 없지만, 다층 방어로 공격을 매우 어렵게 만들 수 있습니다.
실전 팁
💡 피어 ID는 IP 주소 대신 공개키 해시를 사용하면 더 안전합니다. IP는 쉽게 바꿀 수 있지만 키는 바꾸기 어렵습니다.
💡 블랙리스트를 파일에 저장하면 재시작 후에도 공격자를 계속 차단할 수 있습니다.
💡 화이트리스트 기능을 추가하여 신뢰할 수 있는 피어는 속도 제한을 완화할 수 있습니다.
💡 메시지 검증 실패 시 상세한 로그를 남기면, 공격 패턴을 분석하여 방어를 개선할 수 있습니다.
💡 프로덕션에서는 TLS(wss://)를 사용하여 중간자 공격을 방지하고, 데이터 암호화로 프라이버시를 보호하세요.