이미지 로딩 중...
AI Generated
2025. 11. 11. · 2 Views
타입스크립트로 비트코인 클론하기 32편 - 보안 강화 및 최적화
블록체인 애플리케이션의 보안을 한 단계 더 강화하고 성능을 최적화하는 방법을 배웁니다. 실제 공격 시나리오를 방어하는 구체적인 구현 방법과 네트워크 효율성을 높이는 최적화 기법을 다룹니다.
목차
- Rate Limiting으로 DDoS 공격 방어하기
- 트랜잭션 입력 검증으로 악의적 데이터 차단하기
- 암호화 해시를 이용한 데이터 무결성 검증
- 메모리 풀 최적화로 트랜잭션 처리 성능 향상
- P2P 네트워크 연결 풀 관리로 안정성 향상
- 블록 동기화 최적화로 초기 다운로드 속도 향상
- 서명 검증 캐싱으로 CPU 사용량 줄이기
- 트랜잭션 우선순위 정책으로 네트워크 효율 극대화
1. Rate Limiting으로 DDoS 공격 방어하기
시작하며
여러분이 블록체인 노드를 운영하는데 갑자기 수천 개의 요청이 한꺼번에 들어와서 서버가 다운된 경험이 있나요? 악의적인 공격자가 무한정 트랜잭션을 생성하거나 블록을 요청해서 여러분의 노드를 마비시킬 수 있습니다.
이런 DDoS(Distributed Denial of Service) 공격은 실제 블록체인 네트워크에서 자주 발생하는 보안 위협입니다. 공격자는 자동화된 스크립트로 초당 수백 번의 요청을 보내 정상적인 사용자들의 접근을 차단하고, 네트워크 전체를 마비시킬 수 있습니다.
바로 이럴 때 필요한 것이 Rate Limiting입니다. 특정 IP나 사용자가 일정 시간 동안 보낼 수 있는 요청의 수를 제한함으로써, 과도한 트래픽으로부터 시스템을 보호할 수 있습니다.
개요
간단히 말해서, Rate Limiting은 특정 사용자가 정해진 시간 내에 보낼 수 있는 요청 횟수를 제한하는 보안 메커니즘입니다. 블록체인 노드는 24시간 운영되며 누구나 접근할 수 있기 때문에 공격에 매우 취약합니다.
예를 들어, 악의적인 사용자가 1초에 1000개의 트랜잭션을 생성하려고 시도할 때, Rate Limiting이 없다면 서버의 CPU와 메모리가 순식간에 고갈되어 정상적인 서비스 제공이 불가능해집니다. 기존에는 모든 요청을 무조건 처리했다면, 이제는 각 클라이언트의 요청 빈도를 추적하고 임계값을 초과하면 일정 시간 동안 차단할 수 있습니다.
Rate Limiting의 핵심 특징은 첫째, 시간 윈도우(예: 1분)를 설정하고, 둘째, 최대 요청 횟수를 정의하며, 셋째, 초과 시 자동으로 차단한다는 것입니다. 이러한 특징들이 시스템의 안정성과 가용성을 보장하는 핵심 방어 메커니즘이 됩니다.
코드 예제
// src/security/rateLimiter.ts
interface RateLimitConfig {
windowMs: number; // 시간 윈도우 (밀리초)
maxRequests: number; // 최대 요청 수
}
class RateLimiter {
private requests: Map<string, number[]> = new Map();
constructor(private config: RateLimitConfig) {}
// 요청 허용 여부 확인
isAllowed(clientId: string): boolean {
const now = Date.now();
const clientRequests = this.requests.get(clientId) || [];
// 시간 윈도우 밖의 요청 제거
const validRequests = clientRequests.filter(
timestamp => now - timestamp < this.config.windowMs
);
// 최대 요청 수 초과 여부 확인
if (validRequests.length >= this.config.maxRequests) {
return false; // 요청 거부
}
// 새 요청 기록
validRequests.push(now);
this.requests.set(clientId, validRequests);
return true; // 요청 허용
}
// 주기적으로 오래된 데이터 정리
cleanup(): void {
const now = Date.now();
for (const [clientId, timestamps] of this.requests.entries()) {
const valid = timestamps.filter(
ts => now - ts < this.config.windowMs
);
if (valid.length === 0) {
this.requests.delete(clientId);
} else {
this.requests.set(clientId, valid);
}
}
}
}
// 사용 예시: 1분에 최대 10개 요청 허용
const limiter = new RateLimiter({
windowMs: 60000, // 1분
maxRequests: 10
});
설명
이것이 하는 일: RateLimiter 클래스는 각 클라이언트의 요청 기록을 추적하고, 설정된 시간 윈도우 내에서 최대 요청 수를 초과하는지 검사하여 공격을 차단합니다. 첫 번째로, requests Map은 클라이언트 ID를 키로, 타임스탬프 배열을 값으로 저장합니다.
새 요청이 들어오면 isAllowed 메서드가 호출되어 현재 시간(Date.now())을 기준으로 시간 윈도우 내의 유효한 요청들만 필터링합니다. 이렇게 하는 이유는 1분 전의 요청은 더 이상 카운트하지 않아야 하기 때문입니다.
그 다음으로, validRequests 배열의 길이를 확인하여 maxRequests와 비교합니다. 만약 이미 최대 요청 수에 도달했다면 false를 반환하여 요청을 거부하고, 아직 여유가 있다면 현재 타임스탬프를 배열에 추가한 후 true를 반환합니다.
이 메커니즘이 실제로 공격을 차단하는 핵심 로직입니다. 마지막으로, cleanup 메서드는 주기적으로(예: 10분마다) 호출되어 메모리 누수를 방지합니다.
오래된 타임스탬프 데이터를 삭제하지 않으면 Map이 계속 커져서 결국 메모리 부족 문제가 발생할 수 있기 때문입니다. 여러분이 이 코드를 사용하면 악의적인 대량 요청으로부터 노드를 보호할 수 있습니다.
예를 들어, 공격자가 1초에 100개의 트랜잭션을 보내더라도, 1분에 10개 제한을 설정했다면 10개 이후의 모든 요청이 자동으로 차단되어 서버 리소스를 보호합니다. 실무에서는 IP 주소를 clientId로 사용하거나, 더 정교하게는 사용자 계정별로 다른 제한을 적용할 수 있습니다.
또한 Redis 같은 분산 캐시를 사용하면 여러 서버에서도 일관된 Rate Limiting을 구현할 수 있습니다.
실전 팁
💡 windowMs와 maxRequests는 서비스 특성에 맞게 조정하세요. 공개 API는 엄격하게(1분 10회), 인증된 사용자는 관대하게(1분 100회) 설정할 수 있습니다.
💡 IP 기반 제한 시 NAT 환경을 고려하세요. 같은 회사의 여러 사용자가 하나의 IP를 공유할 수 있으므로, 너무 낮은 제한은 정상 사용자를 차단할 수 있습니다.
💡 429 Too Many Requests 응답과 함께 Retry-After 헤더를 보내 클라이언트가 언제 다시 시도해야 하는지 알려주세요.
💡 중요한 엔드포인트(트랜잭션 생성, 블록 마이닝)에는 더 엄격한 제한을, 조회 API에는 상대적으로 완화된 제한을 적용하세요.
💡 공격 패턴을 로깅하여 분석하면, 특정 IP나 패턴을 완전히 차단하는 블랙리스트를 구축할 수 있습니다.
2. 트랜잭션 입력 검증으로 악의적 데이터 차단하기
시작하며
여러분이 만든 블록체인에 누군가 음수 금액을 보내거나, 비정상적으로 긴 메시지를 포함한 트랜잭션을 생성하려고 한다면 어떻게 될까요? 검증 없이 이런 데이터를 받아들이면 블록체인이 손상되거나 노드가 크래시할 수 있습니다.
이런 문제는 실제 블록체인 개발에서 매우 심각한 보안 취약점입니다. 공격자는 의도적으로 잘못된 형식의 데이터를 보내 블록체인의 무결성을 해치거나, 메모리 오버플로우를 일으켜 시스템을 공격할 수 있습니다.
심지어 SQL Injection처럼 악성 스크립트를 삽입하려는 시도도 있을 수 있습니다. 바로 이럴 때 필요한 것이 입력 검증(Input Validation)입니다.
모든 외부 데이터를 신뢰하지 않고, 엄격한 규칙에 따라 검증한 후에만 처리함으로써 시스템의 안전성을 보장할 수 있습니다.
개요
간단히 말해서, 입력 검증은 외부로부터 받은 모든 데이터가 예상된 형식과 범위에 부합하는지 확인하는 보안 필수 과정입니다. 블록체인은 한 번 기록되면 수정이 불가능하기 때문에 잘못된 데이터가 들어가면 영구적인 문제가 됩니다.
예를 들어, 음수 금액의 트랜잭션이 블록에 포함되면 그 블록을 참조하는 모든 후속 계산이 틀어지고, 전체 체인의 무결성이 손상됩니다. 기존에는 클라이언트가 보낸 데이터를 그대로 믿고 처리했다면, 이제는 타입 검사, 범위 검사, 길이 제한, 포맷 검증 등 다층적인 검증 단계를 거쳐야 합니다.
입력 검증의 핵심 원칙은 첫째, 화이트리스트 방식(허용된 것만 통과), 둘째, 실패 시 명확한 에러 메시지, 셋째, 검증 통과 전에는 어떤 처리도 하지 않기입니다. 이러한 원칙들이 블록체인의 데이터 무결성과 시스템 안정성을 지키는 첫 번째 방어선이 됩니다.
코드 예제
// src/validation/transactionValidator.ts
interface TransactionInput {
sender: string;
recipient: string;
amount: number;
timestamp: number;
data?: string;
}
class TransactionValidator {
private readonly MAX_DATA_LENGTH = 1000; // 최대 데이터 길이
private readonly MIN_AMOUNT = 0.00000001; // 최소 금액 (1 satoshi)
private readonly MAX_AMOUNT = 21000000; // 최대 금액
// 트랜잭션 전체 검증
validate(tx: TransactionInput): { valid: boolean; error?: string } {
// 1. 필수 필드 존재 확인
if (!tx.sender || !tx.recipient || tx.amount === undefined) {
return { valid: false, error: '필수 필드 누락' };
}
// 2. 주소 형식 검증 (16진수 64자)
const addressRegex = /^[0-9a-f]{64}$/;
if (!addressRegex.test(tx.sender) || !addressRegex.test(tx.recipient)) {
return { valid: false, error: '잘못된 주소 형식' };
}
// 3. 금액 범위 검증
if (tx.amount < this.MIN_AMOUNT || tx.amount > this.MAX_AMOUNT) {
return { valid: false, error: `금액은 ${this.MIN_AMOUNT}~${this.MAX_AMOUNT} 사이여야 함` };
}
// 4. 타임스탬프 검증 (미래 시간 방지)
const now = Date.now();
if (tx.timestamp > now + 3600000) { // 1시간 이상 미래
return { valid: false, error: '타임스탬프가 너무 미래임' };
}
// 5. 선택적 데이터 필드 검증
if (tx.data && tx.data.length > this.MAX_DATA_LENGTH) {
return { valid: false, error: '데이터 길이 초과' };
}
// 6. XSS 공격 방지 (HTML 태그 차단)
if (tx.data && /<[^>]*>/g.test(tx.data)) {
return { valid: false, error: 'HTML 태그는 허용되지 않음' };
}
return { valid: true };
}
}
설명
이것이 하는 일: TransactionValidator 클래스는 트랜잭션 객체를 받아 6단계의 체계적인 검증 과정을 거쳐 데이터의 안전성을 확인합니다. 첫 번째로, 필수 필드(sender, recipient, amount) 존재 여부를 확인합니다.
JavaScript/TypeScript에서는 undefined나 null 값이 쉽게 전달될 수 있으므로, 명시적으로 이들을 체크하는 것이 중요합니다. 이 단계를 건너뛰면 후속 검증에서 런타임 에러가 발생할 수 있습니다.
두 번째로, 정규표현식을 사용해 주소 형식을 검증합니다. 비트코인 주소는 64자의 16진수 문자열이어야 하므로, addressRegex.test()로 이를 확인합니다.
만약 공격자가 'DROP TABLE' 같은 SQL 명령어를 주소에 삽입하려고 해도, 이 단계에서 차단됩니다. 세 번째와 네 번째로, 금액과 타임스탬프의 합리적인 범위를 검사합니다.
음수 금액이나 비트코인 총량(2100만 개)을 초과하는 금액은 불가능하며, 미래의 타임스탬프는 시간 조작 공격을 의미할 수 있습니다. 이러한 논리적 검증이 블록체인의 경제적 무결성을 보호합니다.
다섯 번째와 여섯 번째로, 선택적 data 필드의 길이와 내용을 검증합니다. 무제한 길이를 허용하면 공격자가 거대한 데이터를 보내 메모리를 고갈시킬 수 있고, HTML 태그를 허용하면 나중에 이 데이터를 표시할 때 XSS(Cross-Site Scripting) 공격이 가능합니다.
여러분이 이 검증 로직을 모든 입력 지점에 적용하면, 대부분의 악의적 시도를 초기에 차단할 수 있습니다. 실무에서는 이런 검증을 미들웨어로 구현하여 API 레벨에서 자동으로 적용하거나, class-validator 같은 라이브러리를 사용해 더 선언적으로 작성할 수도 있습니다.
실전 팁
💡 화이트리스트 방식을 사용하세요. "이것은 금지"보다 "이것만 허용"이 훨씬 안전합니다. 예상된 모든 가능한 값을 명시하고 나머지는 거부하세요.
💡 에러 메시지에 너무 많은 정보를 담지 마세요. "데이터베이스 연결 실패" 같은 내부 정보는 공격자에게 힌트가 됩니다. "잘못된 요청"처럼 일반적으로 응답하세요.
💡 검증은 클라이언트와 서버 모두에서 해야 합니다. 클라이언트 검증은 사용자 경험 향상용이고, 서버 검증이 실제 보안을 담당합니다. 클라이언트 검증만 믿으면 안 됩니다.
💡 타입 안정성을 위해 TypeScript의 엄격 모드를 활성화하고, Zod나 Joi 같은 런타임 스키마 검증 라이브러리를 함께 사용하세요.
💡 정규표현식은 ReDoS(Regular expression Denial of Service) 공격에 취약할 수 있습니다. 복잡한 정규표현식은 타임아웃을 설정하거나 더 단순한 로직으로 대체하세요.
3. 암호화 해시를 이용한 데이터 무결성 검증
시작하며
여러분이 블록체인 네트워크에서 블록을 받았을 때, 그 블록이 전송 중에 변조되지 않았다는 것을 어떻게 확신할 수 있나요? 악의적인 중간자가 트랜잭션 금액을 바꾸거나 수신자 주소를 변경했다면 큰 문제가 됩니다.
이런 데이터 무결성 문제는 분산 네트워크에서 가장 중요한 보안 이슈입니다. 블록체인은 P2P 네트워크로 작동하므로 데이터가 여러 노드를 거쳐 전달되는 동안 누구든 중간에 개입할 수 있습니다.
단 하나의 비트만 바뀌어도 전체 블록이 무효화되어야 합니다. 바로 이럴 때 필요한 것이 암호화 해시 함수입니다.
SHA-256 같은 해시 함수로 데이터의 "지문"을 만들어, 데이터가 조금이라도 변경되면 완전히 다른 해시 값이 나오게 함으로써 변조를 즉시 탐지할 수 있습니다.
개요
간단히 말해서, 암호화 해시는 임의 길이의 데이터를 고정 길이의 고유한 값으로 변환하며, 원본 데이터가 단 1비트만 바뀌어도 완전히 다른 해시가 생성되는 일방향 함수입니다. 블록체인에서 해시는 두 가지 핵심 역할을 합니다.
첫째, 각 블록의 무결성을 보장하고, 둘째, 이전 블록의 해시를 포함시켜 블록들을 체인으로 연결합니다. 예를 들어, 공격자가 과거 블록의 트랜잭션을 수정하면, 그 블록의 해시가 바뀌고, 이는 다음 블록에 저장된 이전 해시와 맞지 않아 즉시 발각됩니다.
기존에는 데이터를 그대로 비교하거나 체크섬을 사용했다면, 이제는 SHA-256 같은 암호학적으로 안전한 해시 함수를 사용해 역산이 불가능하고 충돌 확률이 극히 낮은 검증 메커니즘을 구현합니다. 해시의 핵심 특성은 첫째, 결정론적(같은 입력은 항상 같은 출력), 둘째, 일방향(해시에서 원본 복구 불가), 셋째, 눈사태 효과(작은 변화가 큰 변화를 유발)입니다.
이러한 특성들이 블록체인의 변조 불가능성을 만드는 수학적 기반이 됩니다.
코드 예제
// src/crypto/hashValidator.ts
import * as crypto from 'crypto';
interface Block {
index: number;
timestamp: number;
transactions: Transaction[];
previousHash: string;
nonce: number;
hash: string;
}
class HashValidator {
// 블록의 해시 계산
calculateBlockHash(block: Omit<Block, 'hash'>): string {
// 블록의 모든 필드를 결합하여 하나의 문자열로 만듦
const blockData =
block.index.toString() +
block.timestamp.toString() +
JSON.stringify(block.transactions) +
block.previousHash +
block.nonce.toString();
// SHA-256 해시 생성
return crypto
.createHash('sha256')
.update(blockData)
.digest('hex');
}
// 블록의 무결성 검증
isBlockValid(block: Block): boolean {
// 1. 블록의 해시가 올바르게 계산되었는지 확인
const calculatedHash = this.calculateBlockHash(block);
if (block.hash !== calculatedHash) {
console.log('해시 불일치: 블록이 변조되었습니다');
return false;
}
return true;
}
// 체인의 무결성 검증
isChainValid(chain: Block[]): boolean {
// 제네시스 블록부터 시작 (인덱스 1부터 검증)
for (let i = 1; i < chain.length; i++) {
const currentBlock = chain[i];
const previousBlock = chain[i - 1];
// 현재 블록의 해시 검증
if (!this.isBlockValid(currentBlock)) {
return false;
}
// 이전 블록과의 연결 검증
if (currentBlock.previousHash !== previousBlock.hash) {
console.log('체인 불일치: 이전 블록 해시가 맞지 않습니다');
return false;
}
}
return true;
}
// 트랜잭션 해시 계산 (머클 트리 루트)
calculateMerkleRoot(transactions: Transaction[]): string {
if (transactions.length === 0) return '';
// 각 트랜잭션의 해시 계산
let hashes = transactions.map(tx =>
crypto.createHash('sha256').update(JSON.stringify(tx)).digest('hex')
);
// 머클 트리 구성: 해시들을 쌍으로 묶어 다시 해시
while (hashes.length > 1) {
const newLevel: string[] = [];
for (let i = 0; i < hashes.length; i += 2) {
const left = hashes[i];
const right = hashes[i + 1] || left; // 홀수 개면 마지막을 복제
const combined = crypto
.createHash('sha256')
.update(left + right)
.digest('hex');
newLevel.push(combined);
}
hashes = newLevel;
}
return hashes[0];
}
}
설명
이것이 하는 일: HashValidator 클래스는 블록의 모든 데이터를 결합해 SHA-256 해시를 계산하고, 이를 블록에 저장된 해시와 비교하여 변조 여부를 검증합니다. 첫 번째로, calculateBlockHash 메서드는 블록의 모든 필드(index, timestamp, transactions 등)를 하나의 문자열로 연결합니다.
이때 순서가 매우 중요한데, 같은 데이터라도 순서가 다르면 다른 해시가 나오기 때문입니다. crypto.createHash('sha256')을 사용해 Node.js의 내장 암호화 라이브러리로 해시를 생성하며, digest('hex')는 16진수 문자열 형태로 결과를 반환합니다.
두 번째로, isBlockValid 메서드는 블록을 받아 그 자리에서 해시를 다시 계산합니다. 만약 계산된 해시가 블록에 저장된 해시와 다르다면, 누군가 블록의 데이터를 변조한 것입니다.
이것이 가능한 이유는 해시의 눈사태 효과 때문입니다. 예를 들어, 금액을 10에서 11로 바꾸면 해시가 완전히 달라집니다.
세 번째로, isChainValid 메서드는 전체 블록체인을 순회하며 두 가지를 검증합니다. 첫째, 각 블록의 해시가 올바른지, 둘째, 각 블록의 previousHash가 실제 이전 블록의 hash와 일치하는지 확인합니다.
이 체인 구조 덕분에 과거의 어떤 블록도 수정할 수 없습니다. 하나를 바꾸면 그 이후의 모든 블록을 다시 계산해야 하는데, 이는 작업 증명(Proof of Work) 때문에 계산적으로 불가능합니다.
네 번째로, calculateMerkleRoot 메서드는 블록 내 모든 트랜잭션을 효율적으로 검증하는 머클 트리를 구현합니다. 트랜잭션이 1000개 있어도, 루트 해시 하나만 비교하면 전체 무결성을 확인할 수 있습니다.
트랜잭션 해시들을 쌍으로 묶어 다시 해시하는 과정을 트리 구조로 반복하여, 최종적으로 하나의 루트 해시를 얻습니다. 여러분이 이 검증 시스템을 사용하면, 네트워크에서 받은 블록이 신뢰할 수 있는지 수학적으로 증명할 수 있습니다.
실무에서는 체인 동기화 시 매 블록마다 이 검증을 수행하며, 검증에 실패한 블록은 즉시 거부하고 해당 피어를 블랙리스트에 추가합니다.
실전 팁
💡 항상 crypto 모듈의 암호학적 해시(sha256, sha512)를 사용하세요. 일반 해시(md5, sha1)는 충돌 공격에 취약하여 보안용으로 부적합합니다.
💡 해시 계산 시 데이터 직렬화 방식을 명확히 정의하세요. JSON.stringify는 키 순서가 보장되지 않으므로, 직접 필드를 나열하거나 정렬된 직렬화를 사용하세요.
💡 대용량 데이터를 해시할 때는 스트림을 사용하세요. crypto.createHash().update()를 여러 번 호출해 메모리 사용을 줄일 수 있습니다.
💡 머클 트리를 사용하면 SPV(Simplified Payment Verification)가 가능합니다. 전체 블록을 다운로드하지 않고도 특정 트랜잭션의 포함 여부를 로그 시간에 검증할 수 있습니다.
💡 블록 해시에 타임스탬프를 포함시켜 시간 순서를 보장하고, nonce를 포함시켜 작업 증명을 구현하세요. 이는 채굴자가 해시를 조작할 수 없게 만듭니다.
4. 메모리 풀 최적화로 트랜잭션 처리 성능 향상
시작하며
여러분의 노드에 초당 수백 개의 트랜잭션이 몰려들 때, 이들을 어떻게 효율적으로 관리하고 블록에 포함시킬까요? 단순한 배열에 저장하면 검색과 우선순위 처리가 느려져 전체 시스템 성능이 저하됩니다.
이런 성능 문제는 실제 블록체인 네트워크에서 매우 중요합니다. 비트코인은 초당 약 7개, 이더리움은 약 30개의 트랜잭션을 처리하는데, 실제로는 이보다 훨씬 많은 트랜잭션이 네트워크에 제출됩니다.
메모리 풀(mempool)이 제대로 최적화되지 않으면 높은 수수료를 지불한 트랜잭션도 처리가 지연되고, 사용자 경험이 나빠집니다. 바로 이럴 때 필요한 것이 최적화된 메모리 풀 자료구조입니다.
우선순위 큐와 해시맵을 결합하여 빠른 삽입, 검색, 우선순위 기반 추출을 모두 효율적으로 수행할 수 있습니다.
개요
간단히 말해서, 메모리 풀은 아직 블록에 포함되지 않은 유효한 트랜잭션들을 임시로 저장하고, 수수료나 우선순위에 따라 효율적으로 관리하는 대기 큐입니다. 채굴자는 새 블록을 생성할 때 메모리 풀에서 가장 수익성 높은(수수료가 높은) 트랜잭션들을 선택합니다.
예를 들어, 블록 크기 제한이 1MB라면, 그 안에 들어갈 수 있는 최대 수수료 합계를 만드는 트랜잭션 조합을 찾아야 합니다. 이는 배낭 문제(Knapsack Problem)와 유사한 최적화 문제입니다.
기존에는 단순 배열이나 리스트로 트랜잭션을 저장했다면, 이제는 이중 자료구조(해시맵 + 우선순위 큐)를 사용해 O(1) 검색과 O(log n) 우선순위 추출을 동시에 달성합니다. 메모리 풀의 핵심 기능은 첫째, 중복 트랜잭션 방지, 둘째, 수수료 기반 우선순위 정렬, 셋째, 메모리 제한 초과 시 낮은 수수료 트랜잭션 제거입니다.
이러한 기능들이 제한된 블록 공간을 최적으로 활용하고 네트워크 처리량을 극대화합니다.
코드 예제
// src/mempool/optimizedMempool.ts
interface Transaction {
id: string;
sender: string;
recipient: string;
amount: number;
fee: number;
timestamp: number;
}
class OptimizedMempool {
private txMap: Map<string, Transaction> = new Map(); // O(1) 검색용
private txQueue: Transaction[] = []; // 우선순위 정렬용
private readonly MAX_MEMPOOL_SIZE = 5000; // 최대 트랜잭션 수
private readonly MIN_FEE = 0.0001; // 최소 수수료
// 트랜잭션 추가
addTransaction(tx: Transaction): boolean {
// 1. 중복 확인 (O(1))
if (this.txMap.has(tx.id)) {
console.log('중복 트랜잭션 거부');
return false;
}
// 2. 최소 수수료 확인
if (tx.fee < this.MIN_FEE) {
console.log('수수료가 너무 낮음');
return false;
}
// 3. 메모리 풀이 가득 찬 경우
if (this.txQueue.length >= this.MAX_MEMPOOL_SIZE) {
// 가장 낮은 수수료 트랜잭션과 비교
const lowestFeeTx = this.txQueue[this.txQueue.length - 1];
if (tx.fee <= lowestFeeTx.fee) {
console.log('메모리 풀 가득 참, 수수료가 더 낮아 거부');
return false;
}
// 가장 낮은 수수료 트랜잭션 제거
this.removeTransaction(lowestFeeTx.id);
}
// 4. 트랜잭션 추가 및 정렬
this.txMap.set(tx.id, tx);
this.txQueue.push(tx);
this.sortByFee(); // 수수료 내림차순 정렬
return true;
}
// 수수료 기준 정렬
private sortByFee(): void {
this.txQueue.sort((a, b) => b.fee - a.fee); // 높은 수수료 우선
}
// 상위 N개 트랜잭션 추출 (블록에 포함할 트랜잭션)
getTopTransactions(count: number): Transaction[] {
return this.txQueue.slice(0, Math.min(count, this.txQueue.length));
}
// 트랜잭션 제거 (블록에 포함된 후)
removeTransaction(txId: string): boolean {
const tx = this.txMap.get(txId);
if (!tx) return false;
this.txMap.delete(txId);
this.txQueue = this.txQueue.filter(t => t.id !== txId);
return true;
}
// 여러 트랜잭션 일괄 제거
removeTransactions(txIds: string[]): void {
txIds.forEach(id => this.removeTransaction(id));
}
// 메모리 풀 통계
getStats(): { size: number; totalFees: number; avgFee: number } {
const totalFees = this.txQueue.reduce((sum, tx) => sum + tx.fee, 0);
return {
size: this.txQueue.length,
totalFees,
avgFee: this.txQueue.length > 0 ? totalFees / this.txQueue.length : 0
};
}
}
설명
이것이 하는 일: OptimizedMempool 클래스는 이중 자료구조를 사용해 트랜잭션 ID로 O(1) 시간에 중복을 확인하면서도, 수수료 우선순위에 따라 효율적으로 정렬된 상태를 유지합니다. 첫 번째로, txMap은 트랜잭션 ID를 키로 하는 해시맵으로, 새 트랜잭션이 들어올 때 has() 메서드로 즉시 중복 여부를 확인합니다.
만약 배열만 사용했다면 find()로 전체를 순회해야 하므로 O(n) 시간이 걸렸겠지만, Map을 사용하면 O(1)에 처리됩니다. 이는 초당 수백 개의 트랜잭션이 들어오는 환경에서 큰 성능 차이를 만듭니다.
두 번째로, txQueue는 실제 트랜잭션 객체들을 수수료 기준 내림차순으로 저장하는 배열입니다. sortByFee() 메서드가 호출될 때마다 sort()로 정렬되는데, 이는 O(n log n) 연산입니다.
최적화를 위해 실무에서는 힙(Heap) 자료구조를 사용해 O(log n)에 정렬을 유지할 수 있습니다. 세 번째로, 메모리 풀이 MAX_MEMPOOL_SIZE에 도달하면, 가장 낮은 수수료 트랜잭션(txQueue의 마지막 요소)과 새 트랜잭션의 수수료를 비교합니다.
새 트랜잭션의 수수료가 더 높으면 기존 트랜잭션을 제거하고 새 것을 추가합니다. 이는 자연스럽게 수수료 시장을 형성하여, 네트워크가 혼잡할 때 사용자들이 더 높은 수수료를 지불하도록 유도합니다.
네 번째로, getTopTransactions() 메서드는 채굴자가 새 블록을 만들 때 호출되어, 가장 높은 수수료의 트랜잭션들을 블록 크기 제한 내에서 선택합니다. slice()를 사용해 원본 배열을 변경하지 않고 복사본을 반환하므로, 블록 생성이 실패해도 메모리 풀은 영향을 받지 않습니다.
여러분이 이 최적화된 메모리 풀을 사용하면, 트랜잭션 처리 속도가 크게 향상되고, 채굴 수익도 최대화할 수 있습니다. 실무에서는 여기에 더해 Replace-By-Fee(RBF) 정책을 구현하여, 같은 사용자가 더 높은 수수료로 기존 트랜잭션을 교체할 수 있게 하거나, Child-Pays-For-Parent(CPFP)를 지원하여 관련 트랜잭션들을 묶어서 처리할 수도 있습니다.
실전 팁
💡 정렬을 매번 하지 말고 삽입 정렬이나 힙을 사용하세요. 새 트랜잭션이 들어올 때마다 전체 정렬(O(n log n))하는 것은 비효율적입니다. Min-Heap이나 Max-Heap을 사용하면 O(log n)에 유지할 수 있습니다.
💡 오래된 트랜잭션을 주기적으로 제거하세요. 타임스탬프를 확인해 24시간 이상 된 트랜잭션은 자동으로 삭제하여 메모리 누수를 방지하세요.
💡 수수료율(fee per byte)을 기준으로 정렬하세요. 단순 수수료 금액이 아니라, 트랜잭션 크기 대비 수수료를 계산하면 더 공정하고 효율적입니다.
💡 메모리 풀 상태를 모니터링하고 로깅하세요. getStats() 같은 메서드로 평균 수수료, 대기 트랜잭션 수 등을 추적하면 네트워크 상태를 파악할 수 있습니다.
💡 트랜잭션 검증을 비동기로 처리하세요. 서명 검증은 CPU 집약적이므로, Worker Threads나 클러스터링을 사용해 메인 스레드를 블로킹하지 않도록 하세요.
5. P2P 네트워크 연결 풀 관리로 안정성 향상
시작하며
여러분의 노드가 블록체인 네트워크에 연결되어 있는데, 일부 피어가 갑자기 오프라인이 되거나 악의적으로 동작한다면 어떻게 해야 할까요? 모든 피어를 무조건 신뢰하면 공격에 취약하고, 너무 많은 연결을 유지하면 네트워크 리소스가 고갈됩니다.
이런 네트워크 안정성 문제는 분산 시스템의 핵심 과제입니다. P2P 네트워크는 중앙 서버가 없으므로 각 노드가 스스로 안정적인 연결을 유지해야 합니다.
연결된 피어가 너무 적으면 네트워크 분할(partition)이 발생할 수 있고, 너무 많으면 대역폭과 메모리가 낭비됩니다. 바로 이럴 때 필요한 것이 지능형 연결 풀 관리입니다.
피어의 신뢰도를 평가하고, 적절한 수의 연결을 유지하며, 문제가 있는 피어는 자동으로 교체하는 메커니즘을 구현할 수 있습니다.
개요
간단히 말해서, 연결 풀은 현재 연결된 피어들을 관리하고, 각 피어의 품질을 평가하여 최적의 네트워크 토폴로지를 자동으로 유지하는 시스템입니다. 건강한 블록체인 네트워크를 위해서는 지리적으로 분산된 다양한 피어들과 연결해야 합니다.
예를 들어, 모든 피어가 같은 데이터 센터에 있으면 그곳에 네트워크 장애가 발생할 때 전체 노드가 고립될 수 있습니다. 또한 악의적인 피어가 잘못된 블록을 전파하면 이를 빠르게 탐지하고 차단해야 합니다.
기존에는 초기에 연결된 피어들을 계속 유지했다면, 이제는 동적으로 피어를 평가하고, 성능이나 신뢰도가 떨어지는 피어는 새로운 피어로 교체합니다. 연결 풀의 핵심 기능은 첫째, 피어 점수 시스템(성공적인 블록 전파 +점수, 잘못된 데이터 -점수), 둘째, 최소/최대 연결 수 유지, 셋째, 자동 재연결 및 피어 발견입니다.
이러한 기능들이 네트워크의 복원력과 탈중앙화를 보장합니다.
코드 예제
// src/network/connectionPool.ts
interface Peer {
id: string;
address: string;
port: number;
score: number; // 신뢰도 점수
lastSeen: number;
failureCount: number;
}
class ConnectionPool {
private peers: Map<string, Peer> = new Map();
private readonly MIN_PEERS = 8; // 최소 연결 수
private readonly MAX_PEERS = 125; // 최대 연결 수
private readonly PEER_TIMEOUT = 300000; // 5분
private readonly MAX_FAILURES = 3;
// 새 피어 추가
addPeer(address: string, port: number): boolean {
const peerId = `${address}:${port}`;
// 이미 연결된 피어인지 확인
if (this.peers.has(peerId)) {
return false;
}
// 최대 연결 수 확인
if (this.peers.size >= this.MAX_PEERS) {
// 가장 낮은 점수의 피어 제거
this.removeLowestScoredPeer();
}
// 새 피어 추가
const peer: Peer = {
id: peerId,
address,
port,
score: 50, // 초기 중립 점수
lastSeen: Date.now(),
failureCount: 0
};
this.peers.set(peerId, peer);
console.log(`피어 추가: ${peerId}, 총 ${this.peers.size}개 연결`);
return true;
}
// 피어 점수 업데이트
updatePeerScore(peerId: string, delta: number): void {
const peer = this.peers.get(peerId);
if (!peer) return;
peer.score = Math.max(0, Math.min(100, peer.score + delta));
peer.lastSeen = Date.now();
// 점수가 너무 낮으면 제거
if (peer.score < 20) {
this.removePeer(peerId, '점수가 너무 낮음');
}
}
// 피어 실패 기록
recordFailure(peerId: string): void {
const peer = this.peers.get(peerId);
if (!peer) return;
peer.failureCount++;
peer.score -= 10; // 실패 시 점수 감소
if (peer.failureCount >= this.MAX_FAILURES) {
this.removePeer(peerId, '반복적인 실패');
}
}
// 피어 성공 기록
recordSuccess(peerId: string): void {
const peer = this.peers.get(peerId);
if (!peer) return;
peer.failureCount = 0;
peer.score += 5; // 성공 시 점수 증가
peer.lastSeen = Date.now();
}
// 비활성 피어 정리
cleanupInactivePeers(): void {
const now = Date.now();
for (const [peerId, peer] of this.peers.entries()) {
if (now - peer.lastSeen > this.PEER_TIMEOUT) {
this.removePeer(peerId, '타임아웃');
}
}
// 최소 연결 수 미달 시 경고
if (this.peers.size < this.MIN_PEERS) {
console.warn(`피어 부족: ${this.peers.size}/${this.MIN_PEERS}`);
// 피어 발견 프로세스 시작
this.discoverNewPeers();
}
}
// 최고 점수 피어들 가져오기
getTopPeers(count: number): Peer[] {
return Array.from(this.peers.values())
.sort((a, b) => b.score - a.score)
.slice(0, count);
}
// 피어 제거
private removePeer(peerId: string, reason: string): void {
this.peers.delete(peerId);
console.log(`피어 제거: ${peerId}, 이유: ${reason}`);
}
// 가장 낮은 점수 피어 제거
private removeLowestScoredPeer(): void {
let lowestPeer: Peer | null = null;
for (const peer of this.peers.values()) {
if (!lowestPeer || peer.score < lowestPeer.score) {
lowestPeer = peer;
}
}
if (lowestPeer) {
this.removePeer(lowestPeer.id, '새 피어를 위한 공간 확보');
}
}
// 새 피어 발견 (DNS seeds, 알려진 노드 등)
private discoverNewPeers(): void {
// 실제 구현에서는 DNS seeds나 하드코딩된 노드 목록 사용
console.log('새 피어 발견 프로세스 시작...');
}
}
설명
이것이 하는 일: ConnectionPool 클래스는 연결된 각 피어의 성능과 신뢰도를 점수로 추적하며, 최소/최대 연결 수 사이를 유지하고 문제가 있는 피어를 자동으로 교체합니다. 첫 번째로, 각 피어는 0-100 사이의 점수를 가지며, 초기에는 중립적인 50점에서 시작합니다.
피어가 유효한 블록을 전파하면 recordSuccess()가 호출되어 +5점, 잘못된 데이터를 보내거나 응답하지 않으면 recordFailure()가 호출되어 -10점이 됩니다. 이 점수 시스템은 피어의 장기적인 행동 패턴을 반영하여, 일시적인 네트워크 문제와 악의적인 행동을 구분할 수 있게 합니다.
두 번째로, addPeer() 메서드는 새 연결을 추가하기 전에 현재 연결 수가 MAX_PEERS를 초과하는지 확인합니다. 초과한다면 removeLowestScoredPeer()를 호출해 가장 성적이 나쁜 피어를 제거하고 새 피어를 위한 공간을 만듭니다.
이렇게 하면 네트워크 리소스를 효율적으로 사용하면서도 항상 가장 좋은 품질의 연결을 유지할 수 있습니다. 세 번째로, cleanupInactivePeers() 메서드는 주기적으로(예: 1분마다) 호출되어 PEER_TIMEOUT(5분) 동안 활동이 없는 피어들을 제거합니다.
노드가 오프라인이 되거나 네트워크 문제로 응답하지 않는 피어를 계속 유지하면 블록 전파 지연이 발생하기 때문입니다. 또한 연결 수가 MIN_PEERS 아래로 떨어지면 경고를 출력하고 discoverNewPeers()를 호출하여 새로운 피어를 능동적으로 찾습니다.
네 번째로, getTopPeers() 메서드는 블록이나 트랜잭션을 전파할 때 사용됩니다. 모든 피어에게 데이터를 보내는 것이 아니라, 점수가 높은 상위 N개 피어에게만 전송하여 네트워크 효율성을 높입니다.
이는 "좋은 이웃 전략"으로, 신뢰할 수 있는 피어들과의 관계를 우선시하여 전체 네트워크 품질을 향상시킵니다. 여러분이 이 연결 풀을 사용하면, 네트워크 장애나 공격에도 노드가 안정적으로 작동합니다.
실무에서는 여기에 더해 지리적 다양성(같은 리전에 너무 많은 피어를 두지 않기), Eclipse 공격 방어(공격자가 모든 연결을 장악하는 것 방지), 그리고 피어 교환 프로토콜(다른 피어로부터 새로운 피어 정보 획득)을 구현합니다.
실전 팁
💡 피어 점수 시스템은 여러 지표를 고려하세요. 응답 속도, 블록 전파 시간, 업타임, 프로토콜 버전 호환성 등을 종합적으로 평가하세요.
💡 Inbound와 Outbound 연결을 구분하여 관리하세요. Outbound(여러분이 시작한 연결)는 더 신뢰할 수 있고, Inbound(다른 노드가 연결)는 공격 벡터가 될 수 있습니다.
💡 피어 목록을 디스크에 저장하세요. 노드가 재시작될 때 이전에 좋은 성적을 보인 피어들을 우선적으로 재연결하면 부트스트랩 시간을 단축할 수 있습니다.
💡 연결 다양성을 위해 일부 연결 슬롯은 무작위 피어에게 할당하세요. 항상 높은 점수 피어만 유지하면 네트워크가 중앙화될 수 있습니다.
💡 WebSocket이나 HTTP/2 같은 지속 연결을 사용하세요. 매번 TCP 핸드셰이크를 하는 것보다 훨씬 효율적이며, 푸시 알림도 가능합니다.
6. 블록 동기화 최적화로 초기 다운로드 속도 향상
시작하며
여러분이 새로운 노드를 시작할 때, 수천 개 또는 수백만 개의 블록을 다운로드해야 한다면 얼마나 오래 걸릴까요? 순차적으로 블록을 하나씩 다운로드하면 비트코인의 경우 며칠이 걸릴 수 있습니다.
이런 초기 블록 다운로드(IBD, Initial Block Download) 문제는 새로운 노드의 진입 장벽을 높입니다. 사용자들은 빠르게 네트워크에 참여하고 싶어 하는데, 너무 오래 기다리면 포기하고 중앙화된 서비스를 사용하게 됩니다.
또한 네트워크가 성장할수록 체인이 길어져 문제가 더 심각해집니다. 바로 이럴 때 필요한 것이 병렬 블록 동기화입니다.
여러 피어로부터 동시에 다른 블록들을 다운로드하고, 순서에 맞게 조립하여 동기화 시간을 극적으로 단축할 수 있습니다.
개요
간단히 말해서, 병렬 블록 동기화는 여러 피어에게 서로 다른 블록 범위를 요청하여 동시에 다운로드하고, 받은 블록들을 검증한 후 순서대로 체인에 추가하는 최적화 기법입니다. 블록체인은 선형 자료구조이지만, 다운로드는 병렬로 할 수 있습니다.
예를 들어, 현재 블록 높이가 100이고 최신이 1000이라면, 피어 A에게는 100-300, 피어 B에게는 300-500, 피어 C에게는 500-700을 요청할 수 있습니다. 각 블록은 이전 블록의 해시를 포함하므로, 나중에 순서대로 연결하면서 무결성을 검증합니다.
기존에는 블록 N을 받고, 검증하고, 추가한 다음 블록 N+1을 요청하는 순차적 방식이었다면, 이제는 한 번에 수백 개의 블록을 동시에 요청하여 네트워크와 CPU를 최대한 활용합니다. 병렬 동기화의 핵심 요소는 첫째, 블록 범위를 청크로 분할, 둘째, 여러 피어에게 분산 요청, 셋째, 순서 무관하게 받되 순서대로 검증 및 추가입니다.
이러한 전략이 동기화 시간을 몇 시간 또는 며칠에서 몇 분 또는 몇 시간으로 단축시킵니다.
코드 예제
// src/sync/parallelBlockSync.ts
interface BlockHeader {
index: number;
hash: string;
previousHash: string;
}
interface DownloadTask {
startIndex: number;
endIndex: number;
peerId: string;
status: 'pending' | 'downloading' | 'completed' | 'failed';
}
class ParallelBlockSync {
private tasks: DownloadTask[] = [];
private downloadedBlocks: Map<number, Block> = new Map();
private currentIndex: number;
private readonly CHUNK_SIZE = 100; // 한 번에 요청할 블록 수
private readonly MAX_PARALLEL = 5; // 동시 다운로드 수
constructor(
private targetIndex: number,
currentIndex: number,
private peers: Peer[]
) {
this.currentIndex = currentIndex;
this.createDownloadTasks();
}
// 다운로드 작업 생성
private createDownloadTasks(): void {
const totalBlocks = this.targetIndex - this.currentIndex;
const chunks = Math.ceil(totalBlocks / this.CHUNK_SIZE);
for (let i = 0; i < chunks; i++) {
const startIndex = this.currentIndex + i * this.CHUNK_SIZE;
const endIndex = Math.min(
startIndex + this.CHUNK_SIZE - 1,
this.targetIndex
);
this.tasks.push({
startIndex,
endIndex,
peerId: '',
status: 'pending'
});
}
console.log(`${chunks}개의 다운로드 작업 생성 (총 ${totalBlocks}블록)`);
}
// 동기화 시작
async startSync(): Promise<void> {
console.log('병렬 블록 동기화 시작...');
// 병렬 다운로드 실행
const downloadPromises: Promise<void>[] = [];
for (let i = 0; i < this.MAX_PARALLEL; i++) {
downloadPromises.push(this.downloadWorker());
}
await Promise.all(downloadPromises);
// 모든 블록이 다운로드되면 순서대로 추가
await this.assembleChain();
console.log('블록 동기화 완료');
}
// 다운로드 워커 (병렬 실행)
private async downloadWorker(): Promise<void> {
while (true) {
// 대기 중인 작업 찾기
const task = this.tasks.find(t => t.status === 'pending');
if (!task) break;
task.status = 'downloading';
task.peerId = this.selectBestPeer();
try {
// 블록 범위 다운로드 (실제로는 네트워크 요청)
const blocks = await this.downloadBlockRange(
task.startIndex,
task.endIndex,
task.peerId
);
// 다운로드된 블록 저장
blocks.forEach(block => {
this.downloadedBlocks.set(block.index, block);
});
task.status = 'completed';
console.log(
`블록 ${task.startIndex}-${task.endIndex} 다운로드 완료 ` +
`(${task.peerId})`
);
} catch (error) {
task.status = 'failed';
console.error(
`블록 ${task.startIndex}-${task.endIndex} 다운로드 실패: ` +
`${error.message}`
);
// 재시도를 위해 상태를 pending으로 되돌림
setTimeout(() => {
task.status = 'pending';
task.peerId = '';
}, 5000);
}
}
}
// 블록 범위 다운로드 (실제 네트워크 요청)
private async downloadBlockRange(
start: number,
end: number,
peerId: string
): Promise<Block[]> {
// 실제 구현에서는 WebSocket/HTTP 요청
return new Promise((resolve) => {
setTimeout(() => {
const blocks: Block[] = [];
for (let i = start; i <= end; i++) {
blocks.push({
index: i,
timestamp: Date.now(),
transactions: [],
previousHash: '...',
hash: '...',
nonce: 0
});
}
resolve(blocks);
}, Math.random() * 1000 + 500); // 시뮬레이션: 0.5-1.5초
});
}
// 최적의 피어 선택 (점수 기반)
private selectBestPeer(): string {
// 실제로는 ConnectionPool의 getTopPeers() 사용
return this.peers[Math.floor(Math.random() * this.peers.length)].id;
}
// 체인 조립 (순서대로 블록 추가)
private async assembleChain(): Promise<void> {
console.log('블록 체인 조립 중...');
for (let i = this.currentIndex + 1; i <= this.targetIndex; i++) {
const block = this.downloadedBlocks.get(i);
if (!block) {
throw new Error(`블록 ${i}이 누락됨`);
}
// 블록 검증 및 추가
if (!this.validateAndAddBlock(block)) {
throw new Error(`블록 ${i} 검증 실패`);
}
this.currentIndex = i;
// 진행률 출력 (매 100블록마다)
if (i % 100 === 0) {
const progress = ((i - this.currentIndex) /
(this.targetIndex - this.currentIndex)) * 100;
console.log(`동기화 진행률: ${progress.toFixed(1)}%`);
}
}
}
// 블록 검증 및 추가
private validateAndAddBlock(block: Block): boolean {
// 실제로는 HashValidator와 트랜잭션 검증 수행
return true;
}
}
설명
이것이 하는 일: ParallelBlockSync 클래스는 전체 블록 범위를 작은 청크로 나누고, 여러 워커가 병렬로 각 청크를 다운로드한 후, 순서대로 검증하며 체인에 추가합니다. 첫 번째로, createDownloadTasks() 메서드는 전체 블록 범위(currentIndex부터 targetIndex까지)를 CHUNK_SIZE(예: 100블록)씩 나누어 작업 목록을 만듭니다.
예를 들어, 블록 0부터 1000까지 동기화해야 한다면 10개의 작업(0-99, 100-199, ..., 900-999)이 생성됩니다. 이렇게 작업을 분할하는 이유는 한 피어가 응답하지 않거나 느릴 때 다른 피어에게 재할당할 수 있기 때문입니다.
두 번째로, startSync() 메서드는 MAX_PARALLEL(예: 5개)만큼의 downloadWorker()를 동시에 실행합니다. 각 워커는 독립적으로 pending 상태의 작업을 찾아 downloading으로 변경하고, 해당 블록 범위를 다운로드합니다.
Promise.all()을 사용해 모든 워커가 완료될 때까지 기다리므로, 한 워커가 실패해도 다른 워커는 계속 작동합니다. 세 번째로, downloadBlockRange() 메서드는 실제 네트워크 통신을 담당합니다.
실무에서는 WebSocket이나 HTTP/2를 사용해 피어에게 "getBlocks(start, end)" 메시지를 보내고 응답을 기다립니다. 다운로드된 블록들은 downloadedBlocks Map에 블록 인덱스를 키로 저장되므로, 순서 무관하게 받을 수 있습니다.
이것이 병렬 처리의 핵심입니다. 네 번째로, 다운로드가 실패하면 catch 블록에서 task.status를 'failed'로 설정하고, 5초 후 다시 'pending'으로 되돌립니다.
이렇게 하면 다른 워커가 이 작업을 재시도하거나, 다른 피어를 선택하여 다운로드를 계속할 수 있습니다. 이런 재시도 메커니즘이 네트워크 불안정성에 대한 복원력을 제공합니다.
다섯 번째로, assembleChain() 메서드는 모든 블록이 다운로드된 후 호출되어, currentIndex+1부터 순서대로 블록을 가져와 검증하고 체인에 추가합니다. 여기서 중요한 점은 다운로드는 병렬이지만, 검증과 추가는 순차적이어야 한다는 것입니다.
각 블록은 이전 블록의 해시를 포함하므로, 순서대로 처리해야 previousHash 검증이 가능합니다. 여러분이 이 병렬 동기화를 사용하면, 10만 블록을 다운로드하는 시간을 몇 시간에서 몇 분으로 단축할 수 있습니다.
실무에서는 여기에 더해 헤더 우선 동기화(Headers-First Sync)를 구현하여, 먼저 가벼운 헤더만 다운로드해 체인 구조를 파악한 후 트랜잭션 데이터를 받거나, 체크포인트를 사용해 특정 블록 이전은 검증을 간소화할 수도 있습니다.
실전 팁
💡 CHUNK_SIZE와 MAX_PARALLEL을 네트워크 상황에 맞게 조정하세요. 빠른 네트워크에서는 청크를 크게, 동시성을 높게 설정하여 처리량을 최대화하세요.
💡 다운로드 속도를 추적하여 느린 피어는 자동으로 교체하세요. 특정 피어가 평균 속도의 50% 이하라면 연결을 끊고 다른 피어를 선택하세요.
💡 검증과 디스크 쓰기를 분리하세요. 블록을 메모리에서 검증한 후 배치로 디스크에 쓰면 I/O 오버헤드를 줄일 수 있습니다.
💡 진행률을 사용자에게 명확히 보여주세요. "X블록 다운로드 중 (Y/Z, 예상 시간: N분)" 같은 정보는 사용자 경험을 크게 향상시킵니다.
💡 웜 스타트(Warm Start)를 구현하세요. 동기화가 중단되면 이미 다운로드한 블록을 디스크에 캐시해두어, 재시작 시 처음부터 다시 다운로드하지 않도록 하세요.
7. 서명 검증 캐싱으로 CPU 사용량 줄이기
시작하며
여러분의 노드가 같은 블록을 여러 피어로부터 받을 때, 매번 모든 트랜잭션의 디지털 서명을 다시 검증한다면 CPU가 과부하되지 않을까요? 서명 검증은 암호학적 연산이라 매우 무겁고, 수백 개의 트랜잭션을 가진 블록이라면 몇 초가 걸릴 수 있습니다.
이런 중복 연산 문제는 P2P 네트워크에서 특히 심각합니다. 같은 트랜잭션이 여러 경로로 전파되어 여러 번 받게 되는데, 매번 서명을 검증하면 CPU 사이클을 낭비합니다.
특히 초기 블록 동기화 시에는 수만 개의 트랜잭션을 처리해야 하므로 성능 병목이 됩니다. 바로 이럴 때 필요한 것이 서명 검증 캐시입니다.
한 번 검증된 서명은 결과를 캐시에 저장하여, 같은 트랜잭션을 다시 만나면 즉시 캐시에서 결과를 가져와 CPU 사용량을 극적으로 줄일 수 있습니다.
개요
간단히 말해서, 서명 검증 캐시는 트랜잭션 ID를 키로, 검증 결과(유효/무효)를 값으로 저장하는 메모리 기반 캐시로, 중복 암호화 연산을 제거하는 성능 최적화 기법입니다. 디지털 서명 검증은 ECDSA(Elliptic Curve Digital Signature Algorithm) 같은 공개키 암호화를 사용하는데, 이는 곱셈이나 덧셈보다 수천 배 느린 연산입니다.
예를 들어, 하나의 서명 검증에 1ms가 걸린다면, 100개 트랜잭션 블록은 100ms, 1000개는 1초가 됩니다. 하지만 캐시를 사용하면 이미 검증한 트랜잭션은 해시 테이블 조회(O(1), 약 1μs)로 끝납니다.
기존에는 모든 트랜잭션을 매번 검증했다면, 이제는 LRU(Least Recently Used) 캐시에 최근 검증 결과를 저장하여, 동일 트랜잭션의 재검증을 99% 이상 제거합니다. 서명 캐시의 핵심 원칙은 첫째, 트랜잭션 ID로 유일성 보장, 둘째, 제한된 메모리 사용(LRU 정책), 셋째, 검증 실패도 캐시하여 공격 방어입니다.
이러한 원칙들이 노드의 처리 능력을 몇 배로 향상시키고 동기화 속도를 가속화합니다.
코드 예제
// src/crypto/signatureCache.ts
import * as crypto from 'crypto';
interface CacheEntry {
txId: string;
isValid: boolean;
timestamp: number;
}
class SignatureVerificationCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly MAX_CACHE_SIZE = 10000; // 최대 캐시 항목 수
private readonly CACHE_TTL = 3600000; // 1시간 (밀리초)
private hits = 0; // 캐시 히트 횟수
private misses = 0; // 캐시 미스 횟수
// 서명 검증 (캐시 사용)
verifySignature(
transaction: Transaction,
publicKey: string,
signature: string
): boolean {
const txId = transaction.id;
// 1. 캐시 확인
const cached = this.cache.get(txId);
if (cached) {
// 캐시된 결과가 만료되지 않았는지 확인
if (Date.now() - cached.timestamp < this.CACHE_TTL) {
this.hits++;
console.log(`캐시 히트: ${txId.substring(0, 8)}...`);
return cached.isValid;
} else {
// 만료된 엔트리 제거
this.cache.delete(txId);
}
}
// 2. 캐시 미스 - 실제 서명 검증 수행
this.misses++;
console.log(`캐시 미스: ${txId.substring(0, 8)}... (검증 중)`);
const isValid = this.performSignatureVerification(
transaction,
publicKey,
signature
);
// 3. 결과를 캐시에 저장
this.addToCache(txId, isValid);
return isValid;
}
// 실제 서명 검증 (무거운 연산)
private performSignatureVerification(
transaction: Transaction,
publicKey: string,
signature: string
): boolean {
try {
// 트랜잭션 데이터 직렬화
const txData = JSON.stringify({
sender: transaction.sender,
recipient: transaction.recipient,
amount: transaction.amount,
timestamp: transaction.timestamp
});
// ECDSA 서명 검증 (Node.js crypto 사용)
const verifier = crypto.createVerify('SHA256');
verifier.update(txData);
verifier.end();
const isValid = verifier.verify(
{
key: publicKey,
format: 'pem',
type: 'spki'
},
signature,
'hex'
);
return isValid;
} catch (error) {
console.error('서명 검증 오류:', error.message);
return false;
}
}
// 캐시에 추가 (LRU 정책)
private addToCache(txId: string, isValid: boolean): void {
// 캐시가 가득 찬 경우 가장 오래된 항목 제거
if (this.cache.size >= this.MAX_CACHE_SIZE) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
// 새 항목 추가
this.cache.set(txId, {
txId,
isValid,
timestamp: Date.now()
});
}
// 캐시 통계
getStats(): { hits: number; misses: number; hitRate: number; size: number } {
const total = this.hits + this.misses;
return {
hits: this.hits,
misses: this.misses,
hitRate: total > 0 ? (this.hits / total) * 100 : 0,
size: this.cache.size
};
}
// 캐시 무효화 (특정 트랜잭션)
invalidate(txId: string): void {
this.cache.delete(txId);
}
// 전체 캐시 클리어
clear(): void {
this.cache.clear();
this.hits = 0;
this.misses = 0;
console.log('서명 검증 캐시 초기화됨');
}
// 만료된 항목 정리 (주기적으로 호출)
cleanup(): void {
const now = Date.now();
let removed = 0;
for (const [txId, entry] of this.cache.entries()) {
if (now - entry.timestamp > this.CACHE_TTL) {
this.cache.delete(txId);
removed++;
}
}
if (removed > 0) {
console.log(`${removed}개의 만료된 캐시 항목 제거됨`);
}
}
// 배치 검증 (여러 트랜잭션을 효율적으로)
verifyBatch(transactions: Array<{
tx: Transaction;
publicKey: string;
signature: string;
}>): boolean[] {
return transactions.map(({ tx, publicKey, signature }) =>
this.verifySignature(tx, publicKey, signature)
);
}
}
// 싱글톤 인스턴스 (전역 캐시)
export const signatureCache = new SignatureVerificationCache();
// 주기적 정리 (10분마다)
setInterval(() => {
signatureCache.cleanup();
}, 600000);
설명
이것이 하는 일: SignatureVerificationCache 클래스는 트랜잭션 ID를 키로 검증 결과를 캐시하며, 동일 트랜잭션을 다시 만나면 무거운 ECDSA 연산 없이 즉시 결과를 반환합니다. 첫 번째로, verifySignature() 메서드가 호출되면 먼저 cache.get(txId)로 캐시를 확인합니다.
캐시에 있고 TTL(Time To Live) 내라면 cached.isValid를 즉시 반환하여 암호화 연산을 완전히 건너뜁니다. 이 단순한 해시 테이블 조회는 마이크로초 단위로 완료되지만, 실제 서명 검증은 밀리초 단위이므로 약 1000배의 성능 향상입니다.
두 번째로, 캐시 미스가 발생하면 performSignatureVerification()이 호출되어 실제 ECDSA 검증을 수행합니다. crypto.createVerify()로 SHA-256 해시와 타원곡선 암호화를 사용해 서명이 해당 공개키로 만들어졌는지 수학적으로 검증합니다.
이 과정은 큰 정수 연산과 점 곱셈을 포함하므로 CPU 집약적입니다. 세 번째로, 검증 결과(성공이든 실패든)를 addToCache()로 캐시에 저장합니다.
흥미롭게도 실패한 검증도 캐시합니다. 그 이유는 공격자가 같은 무효 트랜잭션을 반복해서 보내 CPU를 소진시키는 공격(Signature Grinding Attack)을 방어하기 위해서입니다.
한 번 무효로 판명되면 다시는 검증하지 않습니다. 네 번째로, LRU(Least Recently Used) 정책을 구현합니다.
MAX_CACHE_SIZE에 도달하면 가장 오래전에 추가된 항목(cache.keys().next().value)을 제거합니다. JavaScript Map은 삽입 순서를 유지하므로, 첫 번째 키가 가장 오래된 것입니다.
더 정교한 구현은 LRU 전용 라이브러리를 사용하거나, 접근 시간도 추적하여 진정한 LRU를 구현할 수 있습니다. 다섯 번째로, getStats() 메서드로 캐시 효율성을 모니터링할 수 있습니다.
히트율이 90% 이상이면 캐시가 효과적으로 작동하는 것입니다. 만약 히트율이 낮다면 MAX_CACHE_SIZE를 늘리거나 TTL을 조정해야 합니다.
또한 cleanup() 메서드가 10분마다 자동으로 호출되어 만료된 항목을 제거하므로 메모리 누수를 방지합니다. 여러분이 이 캐시를 사용하면, 블록 동기화 시간이 몇 배 빠라지고 실시간 트랜잭션 처리량이 크게 향상됩니다.
실무에서는 싱글톤 패턴으로 전역 캐시를 유지하고, 모든 검증 로직에서 이를 사용합니다. 또한 멀티코어 환경에서는 Worker Threads를 사용해 여러 서명을 병렬로 검증하면서도 캐시를 공유할 수 있습니다.
실전 팁
💡 캐시 크기는 메모리와 히트율의 균형을 고려하세요. 10,000개 항목은 약 1-2MB 메모리를 사용하며, 대부분의 사용 사례에 충분합니다.
💡 멀티스레드 환경에서는 SharedArrayBuffer나 Redis를 사용해 워커 간 캐시를 공유하세요. 각 워커가 독립 캐시를 가지면 효율이 떨어집니다.
💡 캐시 워밍(Cache Warming)을 구현하세요. 노드 시작 시 최근 블록의 트랜잭션들을 미리 캐시에 로드하면 초기 성능이 향상됩니다.
💡 검증 실패 결과에는 더 짧은 TTL을 사용하세요. 무효 트랜잭션은 재전파될 가능성이 낮으므로, 메모리를 절약하기 위해 5분 정도만 캐시하세요.
💡 프로덕션에서는 lru-cache 라이브러리를 사용하세요. 직접 구현보다 더 최적화되어 있고, maxAge, updateAgeOnGet 등 고급 기능을 제공합니다.
8. 트랜잭션 우선순위 정책으로 네트워크 효율 극대화
시작하며
여러분이 블록 크기 제한이 있는 상황에서 수천 개의 대기 중인 트랜잭션 중 어떤 것을 먼저 처리해야 할까요? 단순히 도착 순서대로 처리하면 높은 수수료를 지불한 사용자가 오래 기다리게 되어 불공평합니다.
이런 자원 할당 문제는 블록체인 경제학의 핵심입니다. 블록 공간은 제한된 자원이므로, 누가 그 공간을 사용할지 결정하는 메커니즘이 필요합니다.
잘못된 우선순위 정책은 네트워크 혼잡을 악화시키고, 사용자 경험을 저하시키며, 채굴자의 수익도 감소시킵니다. 바로 이럴 때 필요한 것이 다층적 우선순위 정책입니다.
수수료뿐만 아니라 트랜잭션 크기, 대기 시간, 사용자 계정 나이 등 여러 요소를 종합적으로 고려하여 최적의 트랜잭션 선택을 할 수 있습니다.
개요
간단히 말해서, 트랜잭션 우선순위 정책은 제한된 블록 공간에 포함될 트랜잭션을 선택하는 알고리즘으로, 수수료 효율성, 네트워크 공정성, 사용자 만족도를 균형있게 고려합니다. 채굴자는 자신의 수익을 최대화하고 싶어 하므로 가장 높은 수수료 트랜잭션을 선호합니다.
하지만 단순히 절대 수수료만 보면 큰 트랜잭션(많은 바이트)이 항상 유리해지므로, 수수료율(fee per byte)을 사용해 공간 효율성을 고려해야 합니다. 예를 들어, 250바이트에 0.001 BTC 수수료인 트랜잭션이 500바이트에 0.0015 BTC인 것보다 효율적입니다.
기존에는 FIFO(First-In-First-Out)나 단순 수수료 정렬을 사용했다면, 이제는 가중치 기반 점수 시스템으로 여러 요소를 결합하여 최적 선택을 합니다. 우선순위 정책의 핵심 요소는 첫째, 수수료율(바이트당 수수료), 둘째, 대기 시간(오래 기다린 트랜잭션에 보너스), 셋째, 트랜잭션 체인 처리(관련 트랜잭션 함께 포함)입니다.
이러한 요소들이 공정하고 효율적인 블록 생성을 가능하게 합니다.
코드 예제
// src/mempool/priorityPolicy.ts
interface PrioritizedTransaction {
tx: Transaction;
priority: number; // 계산된 우선순위 점수
feeRate: number; // 바이트당 수수료
age: number; // 대기 시간 (초)
}
class TransactionPriorityPolicy {
private readonly WEIGHT_FEE_RATE = 0.7; // 수수료율 가중치 70%
private readonly WEIGHT_AGE = 0.2; // 대기 시간 가중치 20%
private readonly WEIGHT_ACCOUNT_AGE = 0.1; // 계정 나이 가중치 10%
private readonly MIN_AGE_BONUS = 60; // 1분 이상 대기 시 보너스
// 트랜잭션 크기 계산 (바이트)
calculateTxSize(tx: Transaction): number {
// 실제로는 직렬화된 트랜잭션의 바이트 크기
const serialized = JSON.stringify(tx);
return Buffer.byteLength(serialized, 'utf8');
}
// 수수료율 계산 (바이트당 수수료)
calculateFeeRate(tx: Transaction): number {
const size = this.calculateTxSize(tx);
return tx.fee / size;
}
// 우선순위 점수 계산
calculatePriority(
tx: Transaction,
accountAge?: number // 선택적: 계정 생성 이후 일수
): number {
const now = Date.now();
const age = (now - tx.timestamp) / 1000; // 대기 시간 (초)
const feeRate = this.calculateFeeRate(tx);
// 1. 수수료율 점수 (0-100으로 정규화)
// 가정: 최대 수수료율은 0.01 BTC/byte
const feeScore = Math.min(100, (feeRate / 0.01) * 100);
// 2. 대기 시간 점수 (0-100으로 정규화)
// 1시간 이상 대기하면 최대 점수
let ageScore = Math.min(100, (age / 3600) * 100);
// 최소 대기 시간 이상이면 보너스
if (age >= this.MIN_AGE_BONUS) {
ageScore += 10;
}
// 3. 계정 나이 점수 (신규 계정 스팸 방지)
let accountScore = 50; // 기본 점수
if (accountAge !== undefined) {
// 30일 이상 된 계정은 보너스
accountScore = Math.min(100, (accountAge / 30) * 100);
}
// 4. 가중 평균으로 최종 점수 계산
const priority =
feeScore * this.WEIGHT_FEE_RATE +
ageScore * this.WEIGHT_AGE +
accountScore * this.WEIGHT_ACCOUNT_AGE;
return priority;
}
// 메모리 풀의 트랜잭션들을 우선순위 정렬
prioritizeTransactions(
transactions: Transaction[],
accountAges?: Map<string, number>
): PrioritizedTransaction[] {
const prioritized = transactions.map(tx => {
const accountAge = accountAges?.get(tx.sender);
const priority = this.calculatePriority(tx, accountAge);
const feeRate = this.calculateFeeRate(tx);
const age = (Date.now() - tx.timestamp) / 1000;
return {
tx,
priority,
feeRate,
age
};
});
// 우선순위 내림차순 정렬
return prioritized.sort((a, b) => b.priority - a.priority);
}
// 블록에 포함할 최적의 트랜잭션 선택
selectTransactionsForBlock(
transactions: Transaction[],
maxBlockSize: number, // 바이트
accountAges?: Map<string, number>
): Transaction[] {
const prioritized = this.prioritizeTransactions(transactions, accountAges);
const selected: Transaction[] = [];
let currentSize = 0;
for (const { tx } of prioritized) {
const txSize = this.calculateTxSize(tx);
// 블록 크기 제한 확인
if (currentSize + txSize <= maxBlockSize) {
selected.push(tx);
currentSize += txSize;
}
// 블록이 가득 찼으면 중단
if (currentSize >= maxBlockSize * 0.95) { // 95% 채우기
break;
}
}
console.log(
`블록 생성: ${selected.length}개 트랜잭션, ` +
`${currentSize}/${maxBlockSize} 바이트 (${(currentSize/maxBlockSize*100).toFixed(1)}%)`
);
return selected;
}
// 트랜잭션 체인 분석 (UTXO 의존성)
analyzeTransactionChains(
transactions: Transaction[]
): Map<string, Transaction[]> {
// 실제로는 UTXO를 추적하여 의존 관계 파악
// 예: Tx B가 Tx A의 출력을 입력으로 사용
const chains = new Map<string, Transaction[]>();
// 간단한 예시: sender가 같은 트랜잭션을 체인으로 간주
transactions.forEach(tx => {
const existing = chains.get(tx.sender) || [];
existing.push(tx);
chains.set(tx.sender, existing);
});
return chains;
}
// Child-Pays-For-Parent (CPFP) 점수 조정
adjustPriorityForCPFP(
parentTx: Transaction,
childTx: Transaction
): number {
// 자식 트랜잭션이 높은 수수료를 지불하면
// 부모 트랜잭션의 우선순위도 함께 올림
const combinedFee = parentTx.fee + childTx.fee;
const combinedSize = this.calculateTxSize(parentTx) +
this.calculateTxSize(childTx);
const combinedFeeRate = combinedFee / combinedSize;
return this.calculatePriority({
...parentTx,
fee: combinedFee
} as Transaction);
}
}
설명
이것이 하는 일: TransactionPriorityPolicy 클래스는 여러 요소를 가중 평균하여 각 트랜잭션에 우선순위 점수를 부여하고, 이를 기반으로 블록에 포함할 최적의 트랜잭션 조합을 선택합니다. 첫 번째로, calculatePriority() 메서드는 세 가지 점수를 계산합니다.
feeScore는 바이트당 수수료를 0-100으로 정규화한 것으로, 높은 수수료율이 높은 점수를 받습니다. ageScore는 대기 시간을 반영하여, 1시간 이상 기다린 트랜잭션은 최대 점수를 받고, 1분 이상이면 추가 보너스까지 받습니다.
accountScore는 계정의 나이를 반영하여, 새로 만들어진 계정의 스팸을 억제합니다. 두 번째로, 이 세 점수를 WEIGHT_FEE_RATE(70%), WEIGHT_AGE(20%), WEIGHT_ACCOUNT_AGE(10%)의 가중치로 결합합니다.
수수료율이 가장 중요하지만, 대기 시간과 계정 나이도 고려하여 공정성을 보장합니다. 이 가중치는 네트워크 정책에 따라 조정할 수 있습니다.
예를 들어, 혼잡하지 않을 때는 수수료 가중치를 낮추고 대기 시간 가중치를 높일 수 있습니다. 세 번째로, selectTransactionsForBlock() 메서드는 배낭 문제의 탐욕 알고리즘(Greedy Algorithm)을 사용합니다.
우선순위가 높은 순서대로 트랜잭션을 선택하며, 각 트랜잭션의 크기를 확인해 maxBlockSize를 초과하지 않도록 합니다. 블록의 95%를 채우면 중단하는데, 이는 블록 헤더와 기타 메타데이터를 위한 공간을 남겨두기 위해서입니다.
네 번째로, analyzeTransactionChains()와 adjustPriorityForCPFP() 메서드는 트랜잭션 간 의존 관계를 처리합니다. UTXO 모델에서는 한 트랜잭션의 출력이 다른 트랜잭션의 입력이 될 수 있으므로, 부모 트랜잭션이 블록에 포함되지 않으면 자식도 무효가 됩니다.
CPFP는 자식이 높은 수수료를 지불하면 부모의 우선순위도 함께 올려 두 트랜잭션을 함께 포함시키는 전략입니다. 다섯 번째로, 이 알고리즘은 O(n log n) 시간 복잡도를 가집니다(정렬 때문).
메모리 풀에 10,000개 트랜잭션이 있어도 밀리초 내에 처리할 수 있습니다. 실제 블록 생성 시간(마이닝)이 몇 초에서 몇 분이므로, 트랜잭션 선택 오버헤드는 무시할 수 있는 수준입니다.
여러분이 이 우선순위 정책을 사용하면, 채굴 수익을 최대화하면서도 네트워크 사용자들에게 공정한 서비스를 제공할 수 있습니다. 실무에서는 동적으로 가중치를 조정하거나, 머신러닝으로 최적의 정책을 학습시킬 수도 있습니다.
또한 Replace-By-Fee(RBF)를 구현하여 사용자가 더 높은 수수료로 대기 중인 트랜잭션을 교체할 수 있게 하면 사용자 경험이 크게 향상됩니다.
실전 팁
💡 가중치를 네트워크 상태에 따라 동적으로 조정하세요. 혼잡할 때는 수수료 가중치를 높이고, 한가할 때는 대기 시간 가중치를 높여 공정성을 개선하세요.
💡 최소 수수료를 설정하여 무료 또는 극히 낮은 수수료 트랜잭션을 자동 필터링하세요. 이는 스팸 공격을 방어하는 첫 번째 방어선입니다.
💡 트랜잭션 크기 제한을 두세요. 비정상적으로 큰 트랜잭션(예: 100KB 이상)은 수수료가 아무리 높아도 거부하여 블록 공간 독점을 방지하세요.
💡 시뮬레이션으로 정책을 테스트하세요. 과거 블록의 트랜잭션 데이터로 여러 정책을 비교하여 수익과 공정성을 측정하세요.
💡 사용자에게 수수료 추천을 제공하세요. 현재 메모리 풀 상태를 분석해 "빠름: 0.001 BTC", "보통: 0.0005 BTC", "느림: 0.0001 BTC" 같은 옵션을 제시하세요. 이상으로 "타입스크립트로 비트코인 클론하기 32편 - 보안 강화 및 최적화"에 대한 8개의 코드 카드를 작성했습니다! 각 카드는 실무에서 바로 활용할 수 있는 구체적인 구현 방법과 상세한 설명을 담고 있습니다.