이미지 로딩 중...
AI Generated
2025. 11. 11. · 4 Views
타입스크립트로 비트코인 클론하기 25편 최장 체인 합의 알고리즘
블록체인의 핵심 메커니즘인 최장 체인 합의 알고리즘을 타입스크립트로 직접 구현해봅니다. 네트워크 분기 상황에서 어떻게 올바른 체인을 선택하는지, 실제 비트코인이 사용하는 방식을 코드로 이해합니다.
목차
- 최장 체인 규칙 - 블록체인의 진실을 결정하는 방법
- 체인 유효성 검증 - 받은 체인을 믿을 수 있는지 확인하기
- 누적 난이도 계산 - 체인의 진짜 무게 측정하기
- 체인 재구성 - 더 나은 체인으로 전환하기
- 포크 해결 - 동시 채굴 경쟁 상황 처리하기
- 고아 블록 처리 - 경쟁에서 진 블록 관리하기
1. 최장 체인 규칙 - 블록체인의 진실을 결정하는 방법
시작하며
여러분이 블록체인 네트워크를 운영하고 있는데, 두 명의 채굴자가 거의 동시에 블록을 찾았다고 상상해보세요. 네트워크의 절반은 A의 블록을 받았고, 나머지 절반은 B의 블록을 받았습니다.
이제 어느 블록이 "진짜"일까요? 이런 문제는 실제 블록체인 네트워크에서 자주 발생합니다.
분산 시스템에서는 네트워크 지연, 동시 채굴, 악의적인 공격 등으로 인해 체인이 여러 갈래로 나뉠 수 있습니다. 이를 "포크(fork)"라고 하며, 이 문제를 해결하지 못하면 네트워크는 혼란에 빠지고 이중 지불이 가능해집니다.
바로 이럴 때 필요한 것이 최장 체인 규칙입니다. 이 규칙은 간단하지만 강력합니다.
"가장 많은 작업 증명이 축적된 체인을 진실로 받아들인다"는 원칙으로, 비트코인이 탈중앙화된 상태에서도 합의에 도달할 수 있게 해주는 핵심 메커니즘입니다.
개요
간단히 말해서, 최장 체인 규칙은 여러 개의 유효한 체인이 존재할 때 가장 긴 체인을 정당한 체인으로 선택하는 알고리즘입니다. 여기서 "길이"는 단순히 블록의 개수가 아니라, 축적된 작업 증명(Proof of Work)의 양을 의미합니다.
이 규칙이 필요한 이유는 블록체인이 완전히 탈중앙화되어 있기 때문입니다. 중앙 관리자가 없는 상황에서, 모든 노드가 독립적으로 어느 체인이 올바른지 판단할 수 있어야 합니다.
예를 들어, 네트워크 분할이 발생하여 두 그룹이 각각 다른 블록을 추가하다가 다시 연결되었을 때, 어느 체인을 따를지 자동으로 결정할 수 있어야 합니다. 기존에는 중앙 서버가 "이 트랜잭션이 유효합니다"라고 판단했다면, 이제는 네트워크의 계산 능력 대부분이 "이 체인이 올바릅니다"라고 증명하는 방식으로 바뀌었습니다.
이것이 바로 작업 증명 기반 합의의 본질입니다. 최장 체인 규칙의 핵심 특징은 다음과 같습니다.
첫째, 객관적입니다. 모든 노드가 같은 기준으로 체인을 평가할 수 있습니다.
둘째, 경제적 인센티브와 연결됩니다. 공격자가 악의적인 체인을 만들려면 네트워크 전체보다 더 많은 계산 능력을 투입해야 하므로 경제적으로 불가능합니다.
셋째, 자동으로 수렴합니다. 시간이 지나면서 네트워크는 자연스럽게 하나의 체인으로 합의하게 됩니다.
코드 예제
// Block 클래스 정의 - 각 블록은 이전 블록의 해시를 참조
class Block {
constructor(
public index: number,
public previousHash: string,
public timestamp: number,
public data: string,
public hash: string,
public difficulty: number,
public nonce: number
) {}
}
// 최장 체인 규칙 구현 - 누적 난이도가 높은 체인 선택
function replaceChain(newChain: Block[], currentChain: Block[]): Block[] {
// 새 체인의 유효성 검증
if (!isValidChain(newChain)) {
console.log('받은 체인이 유효하지 않습니다');
return currentChain;
}
// 누적 난이도 계산 및 비교
const newChainDifficulty = calculateCumulativeDifficulty(newChain);
const currentChainDifficulty = calculateCumulativeDifficulty(currentChain);
// 새 체인의 누적 난이도가 더 높으면 교체
if (newChainDifficulty > currentChainDifficulty) {
console.log('받은 체인이 현재 체인보다 누적 난이도가 높습니다. 체인을 교체합니다.');
return newChain;
} else {
console.log('현재 체인을 유지합니다.');
return currentChain;
}
}
// 누적 난이도 계산 - 2^difficulty의 합
function calculateCumulativeDifficulty(chain: Block[]): number {
return chain.reduce((total, block) => total + Math.pow(2, block.difficulty), 0);
}
설명
이것이 하는 일: 위 코드는 블록체인 네트워크에서 다른 노드로부터 새로운 체인을 받았을 때, 현재 체인과 비교하여 어느 것을 채택할지 결정하는 핵심 로직입니다. 단순히 블록 개수를 세는 것이 아니라, 각 블록에 투입된 작업 증명의 양을 합산하여 비교합니다.
첫 번째 단계에서는 받은 체인의 유효성을 검증합니다. isValidChain 함수는 체인의 모든 블록이 올바른 해시를 가지고 있는지, 이전 블록과 제대로 연결되어 있는지, 작업 증명이 유효한지 등을 확인합니다.
이 검증 없이 체인을 교체하면 악의적인 노드가 조작된 체인을 주입할 수 있기 때문에, 이 단계는 보안상 필수적입니다. 그 다음으로, calculateCumulativeDifficulty 함수가 실행되면서 각 체인의 누적 난이도를 계산합니다.
여기서 중요한 점은 단순히 난이도 값을 더하는 것이 아니라, 2의 난이도 제곱을 합산한다는 것입니다. 난이도 4인 블록 하나는 난이도 3인 블록 두 개보다 더 많은 작업 증명을 나타내기 때문입니다.
실제로 난이도 4는 평균 16번의 해시 시도가 필요하고, 난이도 3은 8번이 필요하므로, 2^4 = 16과 2^3 × 2 = 16으로 정확히 비교할 수 있습니다. 마지막으로, 두 체인의 누적 난이도를 비교하여 더 큰 값을 가진 체인을 선택합니다.
새로운 체인의 누적 난이도가 더 높다면, 그 체인에 더 많은 계산 능력이 투입되었다는 의미이므로, 그것이 네트워크 대다수가 합의한 체인이라고 판단할 수 있습니다. 현재 체인을 새 체인으로 교체하면, 보류 중이던 트랜잭션 풀도 재구성해야 할 수 있습니다.
여러분이 이 코드를 사용하면 네트워크 분할, 동시 채굴, 악의적 공격 등 다양한 상황에서도 모든 정직한 노드가 자동으로 같은 체인에 합의하게 됩니다. 이를 통해 이중 지불을 방지하고, 트랜잭션의 최종성을 보장하며, 완전히 탈중앙화된 상태에서도 일관성 있는 원장을 유지할 수 있습니다.
실제 비트코인 네트워크는 이 메커니즘으로 15년 이상 안정적으로 운영되고 있습니다.
실전 팁
💡 체인 교체는 비용이 큰 작업이므로, 받은 체인의 유효성을 완전히 검증한 후에만 교체하세요. 모든 블록의 해시, 작업 증명, 트랜잭션 서명을 확인하지 않으면 보안 취약점이 됩니다.
💡 누적 난이도 계산 시 정수 오버플로우에 주의하세요. 비트코인처럼 긴 체인의 경우 BigInt를 사용하거나, 로그 스케일로 비교하는 방법을 고려해야 합니다.
💡 체인을 교체할 때는 현재 체인의 블록에 포함되었던 트랜잭션 중 새 체인에는 없는 것들을 트랜잭션 풀에 다시 추가하세요. 그렇지 않으면 사용자의 트랜잭션이 사라질 수 있습니다.
💡 6개 이상의 확인(confirmation)을 받은 트랜잭션은 되돌리기 매우 어렵습니다. 이것이 비트코인에서 중요한 트랜잭션은 6 확인을 기다리는 이유입니다.
💡 테스트 시에는 짧은 체인으로 시작하되, 프로덕션에서는 체인 재구성(reorg)이 발생했을 때 애플리케이션 상태를 올바르게 롤백할 수 있는지 반드시 확인하세요.
2. 체인 유효성 검증 - 받은 체인을 믿을 수 있는지 확인하기
시작하며
여러분의 노드가 다른 노드로부터 "더 긴 체인을 발견했어요!"라는 메시지와 함께 새로운 블록 목록을 받았다고 가정해보세요. 그냥 믿고 교체해도 될까요?
만약 그 노드가 악의적이라면 어떻게 될까요? 이 문제는 블록체인 보안의 핵심입니다.
탈중앙화된 네트워크에서는 누구도 믿을 수 없다는 "제로 트러스트" 원칙이 적용됩니다. 악의적인 노드가 가짜 블록을 주입하거나, 난이도를 조작하거나, 이중 지불을 시도할 수 있습니다.
검증 없이 체인을 교체하면 네트워크 전체가 공격에 노출됩니다. 바로 이럴 때 필요한 것이 체인 유효성 검증입니다.
받은 체인의 모든 블록을 처음부터 끝까지 검증하여, 블록체인의 모든 규칙이 지켜졌는지 확인합니다. 이를 통해 신뢰할 수 없는 네트워크에서도 안전하게 합의에 도달할 수 있습니다.
개요
간단히 말해서, 체인 유효성 검증은 받은 블록체인이 모든 합의 규칙을 준수하는지 확인하는 과정입니다. 제네시스 블록부터 시작하여 각 블록의 해시, 이전 블록 연결, 작업 증명, 타임스탬프 등을 순차적으로 검증합니다.
이 검증이 필요한 이유는 블록체인이 적대적 환경에서 작동하기 때문입니다. 공격자는 가짜 체인을 만들어 보내거나, 과거 트랜잭션을 수정하거나, 난이도 조정 규칙을 무시할 수 있습니다.
예를 들어, 공격자가 자신에게 유리한 트랜잭션만 포함한 체인을 만들어 전파할 때, 검증 없이 받아들이면 네트워크가 분열되거나 자산이 도난당할 수 있습니다. 기존의 중앙화된 시스템에서는 신뢰할 수 있는 서버가 "이 데이터는 유효합니다"라고 보증했다면, 블록체인에서는 각 노드가 직접 모든 것을 검증합니다.
"검증하지 말고 신뢰하라(Trust, don't verify)"가 아니라 "신뢰하지 말고 검증하라(Don't trust, verify)"가 원칙입니다. 체인 검증의 핵심 특징은 다음과 같습니다.
첫째, 완전성입니다. 제네시스 블록부터 최신 블록까지 모든 블록을 검증합니다.
둘째, 독립성입니다. 각 노드가 독립적으로 검증하므로 중앙 권한이 필요 없습니다.
셋째, 결정론적입니다. 같은 체인에 대해 모든 노드가 같은 검증 결과를 얻습니다.
이러한 특징들이 블록체인의 무신뢰성(trustlessness)을 가능하게 합니다.
코드 예제
// 체인 전체의 유효성 검증
function isValidChain(chain: Block[]): boolean {
// 제네시스 블록 검증
if (JSON.stringify(chain[0]) !== JSON.stringify(getGenesisBlock())) {
console.log('제네시스 블록이 일치하지 않습니다');
return false;
}
// 각 블록을 순차적으로 검증
for (let i = 1; i < chain.length; i++) {
const currentBlock = chain[i];
const previousBlock = chain[i - 1];
// 1. 이전 블록 해시 연결 확인
if (currentBlock.previousHash !== previousBlock.hash) {
console.log(`블록 ${i}의 이전 해시가 일치하지 않습니다`);
return false;
}
// 2. 블록 해시 재계산 및 검증
const calculatedHash = calculateHash(
currentBlock.index,
currentBlock.previousHash,
currentBlock.timestamp,
currentBlock.data,
currentBlock.difficulty,
currentBlock.nonce
);
if (currentBlock.hash !== calculatedHash) {
console.log(`블록 ${i}의 해시가 유효하지 않습니다`);
return false;
}
// 3. 작업 증명(PoW) 검증
if (!hashMatchesDifficulty(currentBlock.hash, currentBlock.difficulty)) {
console.log(`블록 ${i}의 작업 증명이 유효하지 않습니다`);
return false;
}
// 4. 타임스탬프 검증 (이전 블록보다 미래여야 함)
if (currentBlock.timestamp <= previousBlock.timestamp) {
console.log(`블록 ${i}의 타임스탬프가 유효하지 않습니다`);
return false;
}
// 5. 블록 인덱스가 순차적인지 확인
if (currentBlock.index !== previousBlock.index + 1) {
console.log(`블록 ${i}의 인덱스가 순차적이지 않습니다`);
return false;
}
}
return true;
}
// 해시가 난이도 요구사항을 만족하는지 확인
function hashMatchesDifficulty(hash: string, difficulty: number): boolean {
const requiredPrefix = '0'.repeat(difficulty);
return hash.startsWith(requiredPrefix);
}
설명
이것이 하는 일: 위 코드는 다른 노드로부터 받은 블록체인 전체를 검증하는 종합적인 검증 시스템입니다. 제네시스 블록부터 시작하여 각 블록이 블록체인 프로토콜의 모든 규칙을 준수하는지 단계적으로 확인합니다.
첫 번째 단계에서는 제네시스 블록을 검증합니다. 제네시스 블록은 블록체인의 시작점으로, 모든 노드가 정확히 같은 제네시스 블록을 가져야 합니다.
JSON.stringify를 사용하여 받은 체인의 첫 블록과 로컬 제네시스 블록을 비교합니다. 만약 다르다면, 이는 완전히 다른 블록체인이거나 공격 시도이므로 즉시 거부합니다.
이 검증이 없으면 공격자가 자신만의 체인을 만들어 전파할 수 있습니다. 그 다음으로, 반복문을 통해 두 번째 블록부터 마지막 블록까지 순차적으로 검증합니다.
각 블록에 대해 다섯 가지 핵심 검증을 수행합니다. 첫째, 이전 블록 해시 연결을 확인하여 체인이 끊기지 않았는지 검증합니다.
둘째, 블록의 모든 데이터로부터 해시를 재계산하여 블록이 변조되지 않았는지 확인합니다. 셋째, 해시가 난이도 요구사항을 만족하는지 검증하여 작업 증명이 올바르게 수행되었는지 확인합니다.
넷째, 타임스탬프가 논리적으로 올바른지 검증하여 시간 조작을 방지합니다. 다섯째, 블록 인덱스가 순차적인지 확인하여 블록이 빠지거나 중복되지 않았는지 검증합니다.
hashMatchesDifficulty 함수는 작업 증명을 검증하는 핵심 로직입니다. 블록의 해시가 요구된 난이도만큼의 선행 제로를 가지는지 확인합니다.
예를 들어, 난이도가 4라면 해시는 "0000..."으로 시작해야 합니다. 이는 채굴자가 실제로 많은 nonce 값을 시도하여 유효한 해시를 찾았다는 증거입니다.
이 검증 없이는 공격자가 작업 증명 없이 블록을 추가할 수 있습니다. 여러분이 이 검증 시스템을 구현하면 악의적인 노드로부터 네트워크를 보호할 수 있습니다.
변조된 블록, 이중 지불, 난이도 조작, 타임스탬프 공격 등 다양한 공격 벡터를 차단할 수 있습니다. 검증 과정에서 하나라도 실패하면 전체 체인을 거부하므로, 공격자는 모든 규칙을 완벽하게 준수하는 체인을 만들어야 하는데, 이는 정직하게 채굴하는 것보다 더 어렵고 비용이 많이 듭니다.
실제 비트코인 노드는 이보다 훨씬 더 많은 검증을 수행합니다. 트랜잭션 서명 검증, UTXO 유효성 확인, 스크립트 실행, 블록 크기 제한, 코인베이스 보상 검증 등이 추가로 필요합니다.
하지만 위 코드는 블록체인 검증의 핵심 원리를 보여주며, 이를 기반으로 더 복잡한 검증 로직을 추가할 수 있습니다.
실전 팁
💡 제네시스 블록 비교 시 JSON.stringify 대신 각 필드를 개별적으로 비교하는 것이 더 안전합니다. 객체 직렬화 순서가 다를 수 있기 때문입니다.
💡 체인 검증은 CPU 집약적 작업이므로, 긴 체인을 받았을 때는 Worker Thread나 Web Worker를 사용하여 메인 스레드를 차단하지 않도록 하세요.
💡 타임스탬프 검증 시 일정 범위의 오차를 허용하세요. 네트워크 지연과 노드 간 시계 차이로 인해 완벽한 순서를 기대할 수 없습니다. 비트코인은 2시간의 미래 시간까지 허용합니다.
💡 검증 실패 시 구체적인 오류 메시지를 로깅하세요. 어느 블록의 어떤 검증이 실패했는지 기록하면 디버깅과 보안 모니터링에 큰 도움이 됩니다.
💡 프로덕션 환경에서는 검증된 체인을 캐싱하여 같은 체인을 반복적으로 검증하지 않도록 최적화하세요. 체인의 해시를 키로 사용하면 효과적입니다.
3. 누적 난이도 계산 - 체인의 진짜 무게 측정하기
시작하며
여러분 앞에 두 개의 블록체인이 있습니다. A 체인은 100개의 블록을 가지고 있고 각 블록의 난이도는 3입니다.
B 체인은 80개의 블록을 가지고 있지만 각 블록의 난이도는 4입니다. 어느 체인에 더 많은 작업이 투입되었을까요?
이 질문의 답은 직관과 다를 수 있습니다. 단순히 블록 개수만 세면 A 체인이 더 길어 보이지만, 실제로 투입된 계산 능력은 B 체인이 훨씬 클 수 있습니다.
난이도가 1 증가하면 필요한 계산량은 2배가 되기 때문입니다. 블록 개수만으로 체인을 비교하면, 공격자가 낮은 난이도로 많은 블록을 빠르게 생성하여 네트워크를 속일 수 있습니다.
바로 이럴 때 필요한 것이 누적 난이도 계산입니다. 각 블록에 투입된 작업 증명의 양을 정확히 계산하고 합산하여, 어느 체인이 진짜로 더 많은 계산 능력을 투입받았는지 객관적으로 판단할 수 있게 해줍니다.
개요
간단히 말해서, 누적 난이도는 체인의 모든 블록에 투입된 작업 증명의 총량을 나타내는 지표입니다. 각 블록의 난이도를 단순히 더하는 것이 아니라, 2의 난이도 제곱으로 변환한 후 합산하여 실제 계산 비용을 정확히 반영합니다.
이 계산이 필요한 이유는 작업 증명이 지수적으로 증가하는 특성을 가지기 때문입니다. 난이도 4는 난이도 3보다 2배 더 많은 해시 시도가 필요하고, 난이도 5는 4배가 필요합니다.
예를 들어, 공격자가 네트워크와 분리된 상태에서 낮은 난이도로 블록을 대량 생성하려는 51% 공격을 시도할 때, 블록 개수가 아닌 누적 난이도로 비교해야만 정직한 체인을 보호할 수 있습니다. 기존의 단순한 체인 길이 비교 방식은 "가장 많은 블록을 가진 체인"을 선택했다면, 누적 난이도 방식은 "가장 많은 계산 능력이 투입된 체인"을 선택합니다.
이는 "네트워크의 계산 능력 대다수가 동의한 체인"을 의미하므로, 진정한 합의를 나타냅니다. 누적 난이도 계산의 핵심 특징은 다음과 같습니다.
첫째, 지수적 가중치를 적용하여 높은 난이도 블록이 더 큰 영향을 미칩니다. 둘째, 난이도 조정이 반영되므로 네트워크 해시레이트 변화에도 공정한 비교가 가능합니다.
셋째, 공격 비용을 정확히 모델링하여 51% 공격에 필요한 실제 자원을 계산할 수 있습니다. 이러한 특징들이 블록체인을 경제적으로 공격하기 어렵게 만듭니다.
코드 예제
// 누적 난이도 계산 - 체인의 총 작업 증명량
function calculateCumulativeDifficulty(chain: Block[]): number {
return chain.reduce((total, block) => {
// 각 블록의 난이도를 2^difficulty로 변환하여 합산
// 난이도 4 = 2^4 = 16번의 평균 해시 시도
const blockWork = Math.pow(2, block.difficulty);
return total + blockWork;
}, 0);
}
// 두 체인 비교 - 누적 난이도 기반
function compareChains(chain1: Block[], chain2: Block[]): number {
const difficulty1 = calculateCumulativeDifficulty(chain1);
const difficulty2 = calculateCumulativeDifficulty(chain2);
console.log(`체인 1 누적 난이도: ${difficulty1}`);
console.log(`체인 2 누적 난이도: ${difficulty2}`);
// 양수: chain1이 더 큼, 0: 동일, 음수: chain2가 더 큼
return difficulty1 - difficulty2;
}
// 실제 사용 예시
function selectBestChain(chains: Block[][]): Block[] {
if (chains.length === 0) {
throw new Error('체인이 없습니다');
}
// 모든 체인 중 누적 난이도가 가장 높은 것 선택
let bestChain = chains[0];
let maxDifficulty = calculateCumulativeDifficulty(bestChain);
for (let i = 1; i < chains.length; i++) {
const currentDifficulty = calculateCumulativeDifficulty(chains[i]);
if (currentDifficulty > maxDifficulty) {
bestChain = chains[i];
maxDifficulty = currentDifficulty;
}
}
console.log(`선택된 체인의 누적 난이도: ${maxDifficulty}`);
return bestChain;
}
// 블록이 체인에 기여하는 작업량 계산
function calculateBlockWork(difficulty: number): number {
return Math.pow(2, difficulty);
}
설명
이것이 하는 일: 위 코드는 블록체인의 "무게"를 정확히 측정하는 시스템입니다. 각 블록에 투입된 계산 능력을 지수 함수로 변환하여, 어느 체인이 네트워크의 계산 능력 대다수로부터 지지받고 있는지 객관적으로 판단할 수 있게 합니다.
첫 번째 단계에서 calculateCumulativeDifficulty 함수는 체인의 모든 블록을 순회하면서 각 블록의 난이도를 2의 거듭제곱으로 변환합니다. 왜 단순히 난이도 값을 더하지 않을까요?
작업 증명의 특성상, 난이도가 1 증가하면 필요한 해시 시도 횟수가 2배가 됩니다. 난이도 3인 블록은 평균 8번(2^3)의 해시 계산이 필요하고, 난이도 4는 16번(2^4)이 필요합니다.
따라서 난이도 4인 블록 하나는 난이도 3인 블록 두 개와 동일한 작업량을 나타냅니다. 이 관계를 정확히 반영하기 위해 지수 변환을 사용합니다.
그 다음으로, compareChains 함수가 두 체인의 누적 난이도를 계산하고 비교합니다. 이 함수는 단순히 숫자를 반환하는 것 이상의 의미를 가집니다.
반환 값이 양수라면 첫 번째 체인에 더 많은 계산 능력이 투입되었다는 뜻이고, 이는 곧 네트워크의 대다수 채굴자가 그 체인을 선택했다는 의미입니다. 음수라면 두 번째 체인이, 0이라면 두 체인이 정확히 같은 양의 작업 증명을 가진다는 뜻입니다.
selectBestChain 함수는 여러 개의 경쟁하는 체인 중에서 최적의 체인을 선택하는 실전 예시입니다. 네트워크 분할이 발생하여 여러 개의 유효한 체인이 존재할 때, 이 함수는 모든 체인의 누적 난이도를 계산하고 가장 높은 값을 가진 체인을 선택합니다.
이 과정에서 블록 개수, 최신성, 특정 노드의 선호도 같은 주관적인 요소는 전혀 고려되지 않습니다. 오직 투입된 계산 능력만이 기준이 됩니다.
calculateBlockWork 함수는 개별 블록이 체인에 기여하는 작업량을 계산합니다. 이 값은 채굴자가 그 블록을 찾기 위해 평균적으로 수행해야 하는 해시 계산 횟수를 나타냅니다.
난이도가 높을수록 이 값이 지수적으로 증가하므로, 높은 난이도로 채굴된 블록 하나가 낮은 난이도 블록 여러 개보다 더 "무겁다"는 것을 정량적으로 보여줍니다. 여러분이 이 누적 난이도 시스템을 구현하면 다양한 공격 시나리오를 방어할 수 있습니다.
예를 들어, 공격자가 네트워크에서 분리되어 난이도 조정이 일어나지 않은 상태로 블록을 대량 생성하더라도, 낮은 난이도로 인해 누적 난이도가 정직한 체인보다 낮아 자동으로 거부됩니다. 또한 Selfish Mining 같은 고급 공격 기법에서도, 공격자의 은닉된 체인이 공개 체인을 이기려면 실제로 더 많은 계산 능력을 투입해야 하므로 경제적 인센티브가 공격을 억제합니다.
실제 비트코인 코드에서는 난이도 대신 "chainwork"라는 개념을 사용하며, 더 정밀한 계산을 위해 256비트 정수를 사용합니다. 또한 난이도 조정 기간마다 목표 난이도가 변경되므로, 각 블록의 실제 목표값(target)을 기반으로 작업량을 계산합니다.
위 코드는 개념을 명확히 보여주기 위해 단순화했지만, 핵심 원리는 동일합니다.
실전 팁
💡 긴 체인의 경우 누적 난이도가 JavaScript의 Number 안전 범위를 초과할 수 있으므로, BigInt를 사용하거나 라이브러리(bn.js 등)를 활용하세요.
💡 성능 최적화를 위해 각 블록의 누적 난이도를 블록 객체에 캐싱하세요. 새 블록이 추가될 때만 이전 블록의 누적 난이도에 현재 블록의 작업량을 더하면 O(1) 시간에 계산할 수 있습니다.
💡 난이도 조정이 구현된 실제 블록체인에서는 각 블록의 "target" 값을 저장하고, 이를 기반으로 작업량을 계산하는 것이 더 정확합니다. 실제 비트코인은 work = 2^256 / (target + 1) 공식을 사용합니다.
💡 여러 체인을 비교할 때는 누적 난이도만이 아니라 체인의 유효성도 함께 검증하세요. 가장 높은 누적 난이도를 가진 체인이라도 잘못된 블록을 포함하고 있다면 거부해야 합니다.
💡 디버깅 시에는 각 블록의 개별 작업량과 누적 난이도를 로그로 출력하여 체인의 "무게 분포"를 시각적으로 파악하세요. 이를 통해 난이도 조정이 제대로 작동하는지 확인할 수 있습니다.
4. 체인 재구성 - 더 나은 체인으로 전환하기
시작하며
여러분의 노드가 5개의 블록을 가진 체인에서 작업하고 있었는데, 갑자기 다른 노드로부터 7개의 블록을 가진 더 긴 체인을 받았다고 상상해보세요. 그냥 새 체인으로 바꾸면 될까요?
기존 체인의 트랜잭션들은 어떻게 되나요? 이 상황은 블록체인 네트워크에서 정기적으로 발생합니다.
네트워크 지연으로 인해 다른 노드가 먼저 블록을 찾았거나, 일시적인 네트워크 분할이 해소되었거나, 심지어 악의적인 공격이 진행 중일 수도 있습니다. 체인을 잘못 재구성하면 확정되었다고 생각한 트랜잭션이 사라지거나, 이중 지불이 발생할 수 있습니다.
바로 이럴 때 필요한 것이 체인 재구성(chain reorganization) 알고리즘입니다. 안전하게 새로운 체인으로 전환하면서, 롤백된 블록의 트랜잭션을 보존하고, 애플리케이션 상태를 올바르게 업데이트하는 복잡한 과정을 처리합니다.
개요
간단히 말해서, 체인 재구성은 현재 체인에서 더 높은 누적 난이도를 가진 다른 체인으로 전환하는 과정입니다. 단순히 체인을 교체하는 것이 아니라, 롤백되는 블록의 데이터를 처리하고, 새 체인의 블록을 적용하며, 트랜잭션 풀을 재구성하는 일련의 작업을 포함합니다.
이 과정이 필요한 이유는 블록체인의 합의 메커니즘이 "최종적 일관성(eventual consistency)"을 제공하기 때문입니다. 최근에 추가된 블록은 언제든지 롤백될 수 있으며, 시간이 지날수록 확정성이 높아집니다.
예를 들어, 거래소가 사용자의 입금을 확인할 때 6개의 컨펌(confirmation)을 기다리는 이유는, 6블록 이전의 트랜잭션을 되돌리려면 공격자가 엄청난 계산 능력을 투입해야 하기 때문입니다. 기존의 중앙화된 데이터베이스에서는 "커밋된 트랜잭션은 영구적"이라는 강한 보장을 제공했다면, 블록체인에서는 "충분히 깊이 묻힌 트랜잭션은 실질적으로 불가역적"이라는 확률적 보장을 제공합니다.
이 차이를 이해하고 올바르게 처리하는 것이 안전한 블록체인 애플리케이션의 핵심입니다. 체인 재구성의 핵심 특징은 다음과 같습니다.
첫째, 원자성입니다. 재구성은 완전히 성공하거나 완전히 실패해야 하며, 중간 상태로 남아서는 안 됩니다.
둘째, 트랜잭션 보존입니다. 롤백된 블록의 트랜잭션 중 새 체인에 포함되지 않은 것들은 트랜잭션 풀에 다시 추가되어 재처리 기회를 얻습니다.
셋째, 이벤트 알림입니다. 애플리케이션 계층에 재구성 이벤트를 알려 필요한 조치를 취할 수 있게 합니다.
이러한 특징들이 네트워크의 동적인 특성 속에서도 일관성을 유지하게 해줍니다.
코드 예제
// 체인 재구성 구현
function reorganizeChain(
currentChain: Block[],
newChain: Block[],
transactionPool: Transaction[]
): {
chain: Block[];
pool: Transaction[];
reorged: boolean;
rollbackDepth: number;
} {
// 1. 새 체인 검증
if (!isValidChain(newChain)) {
console.log('새 체인이 유효하지 않습니다');
return {
chain: currentChain,
pool: transactionPool,
reorged: false,
rollbackDepth: 0
};
}
// 2. 누적 난이도 비교
const currentDifficulty = calculateCumulativeDifficulty(currentChain);
const newDifficulty = calculateCumulativeDifficulty(newChain);
if (newDifficulty <= currentDifficulty) {
console.log('새 체인의 누적 난이도가 낮습니다');
return {
chain: currentChain,
pool: transactionPool,
reorged: false,
rollbackDepth: 0
};
}
// 3. 공통 조상 블록 찾기
const forkPoint = findCommonAncestor(currentChain, newChain);
const rollbackDepth = currentChain.length - forkPoint - 1;
console.log(`체인 재구성: ${rollbackDepth}개 블록 롤백`);
// 4. 롤백되는 블록의 트랜잭션 수집
const rolledBackTransactions: Transaction[] = [];
for (let i = forkPoint + 1; i < currentChain.length; i++) {
const block = currentChain[i];
// 블록의 트랜잭션을 추출 (코인베이스 제외)
const transactions = extractTransactions(block).filter(
tx => !tx.isCoinbase
);
rolledBackTransactions.push(...transactions);
}
// 5. 새 체인의 트랜잭션 수집
const newChainTransactions: Set<string> = new Set();
for (let i = forkPoint + 1; i < newChain.length; i++) {
const transactions = extractTransactions(newChain[i]);
transactions.forEach(tx => newChainTransactions.add(tx.id));
}
// 6. 트랜잭션 풀 재구성
const updatedPool = [...transactionPool];
rolledBackTransactions.forEach(tx => {
// 새 체인에 포함되지 않은 트랜잭션만 풀에 다시 추가
if (!newChainTransactions.has(tx.id)) {
updatedPool.push(tx);
console.log(`트랜잭션 ${tx.id}을 풀에 다시 추가`);
}
});
// 7. 재구성 이벤트 발생
emitReorgEvent({
oldChain: currentChain,
newChain: newChain,
rollbackDepth: rollbackDepth,
affectedTransactions: rolledBackTransactions.length
});
return {
chain: newChain,
pool: updatedPool,
reorged: true,
rollbackDepth: rollbackDepth
};
}
// 공통 조상 블록 찾기
function findCommonAncestor(chain1: Block[], chain2: Block[]): number {
const minLength = Math.min(chain1.length, chain2.length);
for (let i = 0; i < minLength; i++) {
if (chain1[i].hash !== chain2[i].hash) {
return i - 1; // 마지막으로 일치한 블록 인덱스
}
}
return minLength - 1;
}
설명
이것이 하는 일: 위 코드는 블록체인 네트워크에서 가장 중요하고 복잡한 작업 중 하나인 체인 재구성을 처리합니다. 단순히 체인 포인터를 바꾸는 것이 아니라, 데이터 일관성을 유지하면서 안전하게 상태를 전환하는 전체 프로세스를 구현합니다.
첫 번째 단계에서는 받은 새 체인의 유효성을 검증하고 누적 난이도를 비교합니다. 이 단계가 없다면 악의적인 노드가 잘못된 체인으로 네트워크를 혼란시킬 수 있습니다.
새 체인이 유효하지 않거나 누적 난이도가 낮다면 즉시 거부하고 현재 체인을 유지합니다. 이는 "이미 가지고 있는 것보다 나은 증거가 없다면 변경하지 않는다"는 보수적 원칙을 따릅니다.
그 다음으로, findCommonAncestor 함수를 사용하여 두 체인이 분기된 지점을 찾습니다. 이것이 중요한 이유는 공통 조상까지는 두 체인이 동일하므로 롤백할 필요가 없기 때문입니다.
예를 들어, 현재 체인이 [A, B, C, D, E]이고 새 체인이 [A, B, C, F, G, H]라면, 공통 조상은 C입니다. 따라서 D와 E만 롤백하면 됩니다.
롤백 깊이(rollbackDepth)는 사용자에게 얼마나 많은 블록이 "확정 취소"되었는지 알려주는 중요한 정보입니다. 세 번째 단계에서는 롤백되는 블록의 트랜잭션을 수집합니다.
위 예시에서 D와 E 블록의 트랜잭션들이 여기 해당합니다. 중요한 점은 코인베이스 트랜잭션(채굴 보상)은 제외한다는 것입니다.
코인베이스는 블록에 종속적이므로, 블록이 롤백되면 해당 보상도 사라지는 것이 맞습니다. 일반 트랜잭션들은 새 체인의 블록에 포함될 수도 있고, 아직 처리되지 않았을 수도 있으므로 다음 단계에서 확인합니다.
네 번째 단계에서는 롤백된 트랜잭션 중 새 체인에 포함되지 않은 것들만 트랜잭션 풀에 다시 추가합니다. 예를 들어, D 블록의 트랜잭션 Tx1이 새 체인의 F 블록에 이미 포함되어 있다면, 이는 두 채굴자가 같은 트랜잭션을 처리한 것이므로 풀에 추가하지 않습니다.
하지만 E 블록의 Tx2가 새 체인 어디에도 없다면, 이는 아직 처리되지 않은 트랜잭션이므로 풀에 다시 넣어 다음 블록에 포함될 기회를 줍니다. 마지막으로, emitReorgEvent를 통해 애플리케이션 계층에 재구성이 발생했음을 알립니다.
이 이벤트를 받은 애플리케이션은 필요한 조치를 취할 수 있습니다. 예를 들어, 거래소는 롤백 깊이가 6 이상이면 입출금을 일시 중지하거나, 지갑 애플리케이션은 사용자에게 "최근 트랜잭션이 재처리 중입니다"라는 알림을 표시할 수 있습니다.
여러분이 이 체인 재구성 시스템을 구현하면 블록체인의 동적인 특성을 안전하게 처리할 수 있습니다. 일시적인 네트워크 분할, 동시 채굴, 심지어 소규모 51% 공격 시도까지도 자동으로 감지하고 복구할 수 있습니다.
가장 중요한 것은 사용자의 트랜잭션이 합리적인 시간 내에 체인에 포함된다는 보장을 제공하면서도, 충분히 깊이 묻힌 트랜잭션은 실질적으로 불가역적이라는 확정성을 제공한다는 점입니다. 실제 비트코인 클라이언트는 이보다 훨씬 복잡한 재구성 로직을 가지고 있습니다.
UTXO 집합의 롤백 및 재적용, 스크립트 재실행, 고아 블록(orphan blocks) 관리, 재구성 깊이 제한 등의 추가 메커니즘이 구현되어 있습니다. 하지만 위 코드는 체인 재구성의 핵심 개념과 흐름을 명확히 보여주며, 이를 기반으로 더 견고한 시스템을 구축할 수 있습니다.
실전 팁
💡 재구성이 발생했을 때는 롤백 깊이를 모니터링하세요. 깊이가 6 이상이면 네트워크에 이상이 있거나 51% 공격이 진행 중일 수 있으므로 즉시 조사해야 합니다.
💡 트랜잭션 풀에 다시 추가된 트랜잭션의 유효성을 재검증하세요. 새 체인에서는 UTXO 상태가 달라져 이전에 유효했던 트랜잭션이 무효가 될 수 있습니다.
💡 프로덕션 환경에서는 재구성 전에 현재 체인의 스냅샷을 저장하여, 재구성이 실패하면 롤백할 수 있도록 하세요. 데이터베이스 트랜잭션을 사용하면 원자성을 보장할 수 있습니다.
💡 사용자 인터페이스에서는 트랜잭션의 컨펌 수를 표시하고, 충분한 컨펌(보통 6개)이 쌓이기 전에는 "보류 중" 상태로 표시하여 사용자가 위험을 인지하도록 하세요.
💡 성능 최적화를 위해 재구성이 자주 발생하는 체인의 끝 부분(최근 1-2 블록)은 메모리에 캐싱하고, 깊이 묻힌 블록은 디스크에 저장하는 계층적 저장 구조를 사용하세요.
5. 포크 해결 - 동시 채굴 경쟁 상황 처리하기
시작하며
여러분의 네트워크에서 두 채굴자가 거의 동시에 블록을 찾았습니다. 네트워크의 절반은 Alice의 블록 #100을 받았고, 나머지 절반은 Bob의 블록 #100을 받았습니다.
지금 네트워크는 두 개의 유효한 체인으로 분열되어 있습니다. 어떻게 다시 합쳐질까요?
이 상황은 블록체인에서 피할 수 없는 현상입니다. 블록 생성 간격이 10분인 비트코인에서도, 전 세계에 분산된 노드들의 네트워크 지연 때문에 가끔씩 이런 "경합(race condition)"이 발생합니다.
이더리움처럼 블록 시간이 짧은 블록체인에서는 더 자주 일어납니다. 이를 제대로 처리하지 못하면 네트워크가 영구적으로 분열되거나, 이중 지불이 가능해집니다.
바로 이럴 때 필요한 것이 포크 해결 메커니즘입니다. 최장 체인 규칙을 활용하여, 경쟁하는 포크 중 하나가 자연스럽게 다른 것보다 길어지면 모든 노드가 자동으로 같은 체인으로 수렴하게 만듭니다.
이것이 바로 "Nakamoto Consensus"의 아름다움입니다.
개요
간단히 말해서, 포크 해결은 동시에 여러 개의 유효한 블록이 같은 높이에 생성되었을 때, 시간이 지나면서 하나의 체인으로 자연스럽게 수렴하는 과정입니다. 각 노드는 받은 순서에 따라 임시로 다른 포크를 따르지만, 다음 블록이 찾아지면서 자동으로 가장 긴 체인으로 이동합니다.
이 메커니즘이 필요한 이유는 분산 시스템에서는 완벽한 동기화가 불가능하기 때문입니다. 네트워크 지연, 지리적 거리, 대역폭 제한 등으로 인해 서로 다른 노드가 서로 다른 시간에 같은 정보를 받습니다.
예를 들어, 중국의 채굴자가 찾은 블록은 중국 내 노드에 먼저 도착하고, 미국의 채굴자가 찾은 블록은 미국 내 노드에 먼저 도착합니다. 이 상황에서 중앙 조정자 없이 어떻게 합의할 수 있을까요?
기존의 분산 합의 알고리즘(Paxos, Raft 등)은 명시적인 투표와 리더 선출을 사용했다면, Nakamoto Consensus는 암묵적 투표를 사용합니다. 채굴자들은 다음 블록을 어느 포크 위에 쌓을지 선택함으로써 "투표"합니다.
대다수의 해시파워가 한 포크를 선택하면, 그 포크가 더 빨리 길어지고, 나머지 노드들이 자동으로 따라옵니다. 포크 해결의 핵심 특징은 다음과 같습니다.
첫째, 비동기성입니다. 노드 간 동기화나 통신 없이 각자 독립적으로 판단합니다.
둘째, 확률적 최종성입니다. 시간이 지날수록 포크가 유지될 확률이 지수적으로 감소합니다.
셋째, 인센티브 정렬입니다. 채굴자가 고아 블록(orphan block)을 만들면 보상을 잃으므로, 대다수가 따르는 체인에 블록을 쌓으려는 경제적 동기가 있습니다.
이러한 특징들이 중앙 조정 없이도 네트워크를 하나로 수렴시킵니다.
코드 예제
// 포크 관리 시스템
class ForkManager {
private forks: Map<string, Block[]> = new Map(); // 포크 ID -> 체인
private activeFork: string; // 현재 활성 포크
constructor(genesisBlock: Block) {
const mainForkId = 'main';
this.forks.set(mainForkId, [genesisBlock]);
this.activeFork = mainForkId;
}
// 새 블록 수신 시 처리
receiveBlock(block: Block): void {
console.log(`블록 수신: #${block.index} (${block.hash.substring(0, 8)}...)`);
// 블록이 어느 포크에 연결되는지 확인
const parentFork = this.findForkForBlock(block);
if (parentFork) {
// 기존 포크에 블록 추가
this.addBlockToFork(parentFork, block);
console.log(`블록을 포크 '${parentFork}'에 추가`);
} else {
// 새 포크 생성 (동시 채굴 감지)
const newForkId = `fork-${block.hash.substring(0, 8)}`;
const baseChain = this.getChainUpTo(block.previousHash);
if (baseChain) {
this.forks.set(newForkId, [...baseChain, block]);
console.log(`새 포크 '${newForkId}' 생성 (동시 채굴 감지)`);
}
}
// 가장 긴 포크 선택
this.selectBestFork();
}
// 블록이 연결될 포크 찾기
private findForkForBlock(block: Block): string | null {
for (const [forkId, chain] of this.forks.entries()) {
const lastBlock = chain[chain.length - 1];
if (lastBlock.hash === block.previousHash) {
return forkId;
}
}
return null;
}
// 포크에 블록 추가
private addBlockToFork(forkId: string, block: Block): void {
const chain = this.forks.get(forkId);
if (chain) {
chain.push(block);
}
}
// 최적의 포크 선택 (누적 난이도 기준)
private selectBestFork(): void {
let bestForkId = this.activeFork;
let maxDifficulty = calculateCumulativeDifficulty(
this.forks.get(this.activeFork)!
);
for (const [forkId, chain] of this.forks.entries()) {
const difficulty = calculateCumulativeDifficulty(chain);
if (difficulty > maxDifficulty) {
bestForkId = forkId;
maxDifficulty = difficulty;
}
}
// 활성 포크 변경 감지
if (bestForkId !== this.activeFork) {
console.log(`포크 전환: ${this.activeFork} -> ${bestForkId}`);
const oldDepth = this.forks.get(this.activeFork)!.length;
const newDepth = this.forks.get(bestForkId)!.length;
console.log(` 이전 깊이: ${oldDepth}, 새 깊이: ${newDepth}`);
this.activeFork = bestForkId;
this.cleanupStaleF orks();
}
}
// 오래된 포크 정리
private cleanupStaleForks(): void {
const activeChain = this.forks.get(this.activeFork)!;
const activeDifficulty = calculateCumulativeDifficulty(activeChain);
for (const [forkId, chain] of this.forks.entries()) {
if (forkId === this.activeFork) continue;
const forkDifficulty = calculateCumulativeDifficulty(chain);
// 활성 체인보다 너무 뒤처진 포크는 삭제
if (activeDifficulty - forkDifficulty > Math.pow(2, 10)) {
console.log(`오래된 포크 '${forkId}' 삭제`);
this.forks.delete(forkId);
}
}
}
// 특정 해시까지의 체인 가져오기
private getChainUpTo(blockHash: string): Block[] | null {
for (const chain of this.forks.values()) {
const index = chain.findIndex(b => b.hash === blockHash);
if (index !== -1) {
return chain.slice(0, index + 1);
}
}
return null;
}
// 현재 활성 체인 반환
getActiveChain(): Block[] {
return this.forks.get(this.activeFork)!;
}
// 모든 포크 정보 반환
getForkInfo(): { forkId: string; length: number; difficulty: number }[] {
return Array.from(this.forks.entries()).map(([forkId, chain]) => ({
forkId,
length: chain.length,
difficulty: calculateCumulativeDifficulty(chain)
}));
}
}
설명
이것이 하는 일: 위 코드는 블록체인 노드가 여러 개의 경쟁하는 포크를 동시에 추적하고 관리하는 시스템입니다. 실시간으로 들어오는 블록들을 올바른 포크에 연결하고, 누적 난이도를 기반으로 최적의 포크를 선택하며, 더 이상 필요 없는 포크는 정리하는 전체 생명주기를 관리합니다.
첫 번째 단계에서 receiveBlock 함수는 네트워크로부터 새 블록을 받았을 때 호출됩니다. 이 함수는 먼저 받은 블록이 어느 포크에 연결되어야 하는지 판단합니다.
블록의 previousHash가 특정 포크의 마지막 블록 해시와 일치하면, 그 포크에 추가합니다. 만약 여러 포크의 마지막 블록 해시가 같다면(동시 채굴 상황), 새로운 포크를 생성합니다.
예를 들어, Alice와 Bob이 동시에 블록 #100을 찾으면, 네트워크에는 두 개의 유효한 포크가 생깁니다. 그 다음으로, selectBestFork 함수가 모든 포크의 누적 난이도를 계산하고 비교합니다.
이 시점이 매우 중요합니다. 만약 Alice의 포크에서 Charlie가 블록 #101을 찾았다면, Alice의 포크의 누적 난이도가 Bob의 포크보다 높아집니다.
이 함수는 이를 감지하고 활성 포크를 Alice의 포크로 전환합니다. Bob의 포크를 따르던 노드들도 Alice의 더 긴 체인을 받으면 자동으로 전환하게 됩니다.
세 번째 단계에서는 포크 전환이 발생했을 때 이를 로깅하고, 필요한 재구성 작업을 트리거합니다. 활성 포크가 변경되면, 이는 체인 재구성을 의미하므로 이전 섹션에서 다룬 reorganizeChain 함수가 호출되어야 합니다.
여기서는 어느 포크가 선택되었는지만 결정하고, 실제 상태 전환은 분리된 로직에서 처리하는 것이 좋은 설계입니다. cleanupStaleForks 함수는 메모리 관리를 위해 더 이상 경쟁력이 없는 포크를 삭제합니다.
활성 체인보다 누적 난이도가 2^10 이상 낮은 포크는 사실상 따라잡을 수 없으므로 안전하게 삭제할 수 있습니다. 이는 오래된 고아 블록들로 인해 메모리가 무한정 증가하는 것을 방지합니다.
실제 비트코인 노드는 이보다 더 공격적으로 고아 블록을 정리합니다. getForkInfo 함수는 디버깅과 모니터링을 위해 현재 추적 중인 모든 포크의 상태를 반환합니다.
이 정보를 웹 대시보드나 로그에 출력하면, 네트워크에서 포크가 얼마나 자주 발생하는지, 포크가 해결되는 데 얼마나 걸리는지 등을 관찰할 수 있습니다. 비정상적으로 많은 포크가 동시에 존재한다면, 네트워크 공격이나 버그를 의심해볼 수 있습니다.
여러분이 이 포크 관리 시스템을 구현하면 블록체인의 가장 어려운 문제 중 하나를 해결할 수 있습니다. 동시 채굴로 인한 일시적인 분열은 자동으로 처리되고, 네트워크는 항상 하나의 합의된 체인으로 수렴합니다.
더 나아가 이 시스템은 Selfish Mining 같은 공격에도 대응할 수 있습니다. 공격자가 은닉한 체인을 나중에 공개하더라도, 그 체인의 누적 난이도가 공개 체인보다 높을 때만 채택되므로, 공격자는 네트워크의 51% 이상 해시파워를 가져야만 성공할 수 있습니다.
실제 구현에서는 이 외에도 고려할 사항이 많습니다. 블록 전파 최적화(Compact Block Relay, Fibre 등), 고아 블록 해결을 위한 블록 요청 메커니즘, 포크 깊이 제한, 네트워크 분할 감지 등이 추가로 필요합니다.
하지만 위 코드는 포크 해결의 핵심 로직을 명확히 보여주며, 이를 기반으로 더 견고한 프로덕션 시스템을 구축할 수 있습니다.
실전 팁
💡 포크가 발생했을 때는 즉시 전환하지 말고, 다음 블록이 추가될 때까지 두 포크를 모두 유지하세요. 이렇게 하면 네트워크가 자연스럽게 수렴하는 것을 기다릴 수 있습니다.
💡 채굴자라면 포크가 감지되었을 때 네트워크에서 더 많이 보이는 포크를 선택하세요. 이를 위해 다른 노드들로부터 같은 높이의 블록을 여러 개 받으면 카운트하여, 가장 많이 받은 블록을 따르도록 구현할 수 있습니다.
💡 포크 발생 빈도를 모니터링하여 블록 시간 조정이 필요한지 판단하세요. 포크가 너무 자주 발생하면 블록 시간이 네트워크 지연에 비해 너무 짧다는 신호입니다.
💡 테스트 환경에서는 의도적으로 동시 블록을 생성하여 포크 해결 로직을 검증하세요. 두 채굴자가 정확히 같은 시간에 블록을 찾는 시나리오를 시뮬레이션하는 것이 중요합니다.
💡 사용자 인터페이스에서는 포크가 진행 중일 때 "네트워크 동기화 중" 같은 경고를 표시하여, 사용자가 중요한 트랜잭션을 보내지 않도록 안내하세요. 포크가 해결된 후 트랜잭션을 보내는 것이 안전합니다.
6. 고아 블록 처리 - 경쟁에서 진 블록 관리하기
시작하며
여러분이 채굴자로서 10분간 열심히 계산하여 드디어 블록을 찾았습니다. 기뻐하며 네트워크에 전파했는데, 알고 보니 다른 채굴자가 1초 먼저 블록을 찾아서 이미 네트워크 대부분이 그 블록을 받아들였습니다.
여러분의 블록은 어떻게 되나요? 이 상황에서 여러분의 블록은 "고아 블록(orphan block)" 또는 "stale block"이 됩니다.
이것은 완벽히 유효한 블록이지만, 메인 체인에 포함되지 못한 블록입니다. 채굴 보상도 받지 못하고, 포함된 트랜잭션들도 다시 처리되어야 합니다.
이는 작업 증명의 본질적인 특성이며, 피할 수 없는 현상입니다. 바로 이럴 때 필요한 것이 고아 블록 처리 메커니즘입니다.
고아 블록의 트랜잭션을 복구하고, 채굴자가 손해를 감수하도록 인센티브를 설계하며, 네트워크 통계를 업데이트하여 시스템의 건강성을 모니터링하는 일련의 과정을 포함합니다.
개요
간단히 말해서, 고아 블록은 네트워크에서 거부되지는 않았지만 메인 체인에 포함되지 못한 유효한 블록입니다. 포크 경쟁에서 진 블록이라고 할 수 있으며, 이러한 블록을 올바르게 처리하는 것이 블록체인의 일관성과 공정성을 유지하는 데 중요합니다.
고아 블록 처리가 필요한 이유는 분산 네트워크의 비동기적 특성 때문입니다. 블록 전파에는 시간이 걸리며, 두 채굴자가 거의 동시에 블록을 찾으면 일부 노드는 A의 블록을 먼저 받고, 다른 노드는 B의 블록을 먼저 받습니다.
시간이 지나면서 하나의 체인이 길어지면, 짧은 체인의 블록은 고아가 됩니다. 예를 들어, 비트코인에서는 평균적으로 1~2%의 블록이 고아가 되는데, 이는 정상적인 현상입니다.
기존의 중앙화된 시스템에서는 "먼저 도착한 것이 승자"라는 명확한 순서가 있었다면, 블록체인에서는 "더 긴 체인이 승자"라는 규칙을 따릅니다. 이는 공정성을 희생하는 것처럼 보이지만, 실제로는 탈중앙화를 가능하게 하는 핵심 트레이드오프입니다.
중앙 조정자 없이 모든 노드가 합의하려면, 객관적이고 결정론적인 규칙이 필요하기 때문입니다. 고아 블록 처리의 핵심 특징은 다음과 같습니다.
첫째, 트랜잭션 보존입니다. 고아 블록의 트랜잭션은 버려지지 않고 트랜잭션 풀로 돌아가 다시 처리됩니다.
둘째, 보상 상실입니다. 고아 블록의 채굴자는 보상을 받지 못하므로, 네트워크 지연을 최소화하고 메인 체인을 빠르게 따르려는 인센티브가 생깁니다.
셋째, 통계 추적입니다. 고아 블록 비율을 모니터링하여 네트워크 건강성과 중앙화 위험을 평가할 수 있습니다.
이러한 특징들이 블록체인을 경제적으로 안정적으로 만듭니다.
코드 예제
// 고아 블록 관리 시스템
class OrphanBlockManager {
private orphanBlocks: Map<string, Block> = new Map();
private orphanStats = {
totalOrphans: 0,
totalBlocks: 0,
orphanRate: 0
};
// 블록이 고아가 되었을 때 처리
handleOrphanBlock(
orphanBlock: Block,
transactionPool: Transaction[]
): Transaction[] {
console.log(`블록 #${orphanBlock.index} (${orphanBlock.hash.substring(0, 8)}...)이 고아가 되었습니다`);
// 1. 고아 블록 저장 (나중에 재구성이 필요할 수 있음)
this.orphanBlocks.set(orphanBlock.hash, orphanBlock);
// 2. 통계 업데이트
this.orphanStats.totalOrphans++;
this.orphanStats.totalBlocks++;
this.orphanStats.orphanRate =
(this.orphanStats.totalOrphans / this.orphanStats.totalBlocks) * 100;
console.log(`고아 블록 비율: ${this.orphanStats.orphanRate.toFixed(2)}%`);
// 3. 고아 블록의 트랜잭션 추출
const transactions = extractTransactions(orphanBlock);
const nonCoinbaseTransactions = transactions.filter(tx => !tx.isCoinbase);
console.log(`${nonCoinbaseTransactions.length}개의 트랜잭션을 풀로 복원합니다`);
// 4. 트랜잭션 풀에 다시 추가 (중복 제거)
const updatedPool = [...transactionPool];
const poolTxIds = new Set(transactionPool.map(tx => tx.id));
nonCoinbaseTransactions.forEach(tx => {
if (!poolTxIds.has(tx.id)) {
updatedPool.push(tx);
console.log(` 트랜잭션 ${tx.id.substring(0, 8)}... 복원됨`);
} else {
console.log(` 트랜잭션 ${tx.id.substring(0, 8)}... 이미 풀에 존재함`);
}
});
// 5. 채굴 보상 손실 로깅 (인센티브 메커니즘)
const coinbaseTransaction = transactions.find(tx => tx.isCoinbase);
if (coinbaseTransaction) {
console.log(`채굴 보상 ${coinbaseTransaction.amount} 손실 (채굴자: ${coinbaseTransaction.to})`);
}
// 6. 고아 블록 이벤트 발생 (모니터링용)
this.emitOrphanEvent({
block: orphanBlock,
transactionsRecovered: nonCoinbaseTransactions.length,
rewardLost: coinbaseTransaction?.amount || 0
});
return updatedPool;
}
// 고아 블록이 필요할 때 검색 (체인 재구성 시)
getOrphanBlock(hash: string): Block | undefined {
return this.orphanBlocks.get(hash);
}
// 오래된 고아 블록 정리
cleanupOldOrphans(currentHeight: number, maxAge: number = 100): void {
let cleanedCount = 0;
for (const [hash, block] of this.orphanBlocks.entries()) {
// 현재 높이보다 maxAge 이상 오래된 고아 블록 삭제
if (currentHeight - block.index > maxAge) {
this.orphanBlocks.delete(hash);
cleanedCount++;
}
}
if (cleanedCount > 0) {
console.log(`${cleanedCount}개의 오래된 고아 블록을 정리했습니다`);
}
}
// 고아 블록 통계 반환
getOrphanStats() {
return {
...this.orphanStats,
currentOrphansCount: this.orphanBlocks.size
};
}
// 고아 블록 비율이 비정상적으로 높은지 확인
isOrphanRateAbnormal(threshold: number = 5): boolean {
// 5% 이상이면 네트워크 문제나 공격 가능성
return this.orphanStats.orphanRate > threshold;
}
// 고아 블록 이벤트 발생
private emitOrphanEvent(data: {
block: Block;
transactionsRecovered: number;
rewardLost: number;
}): void {
// 실제 구현에서는 EventEmitter나 옵저버 패턴 사용
console.log('OrphanBlockEvent:', {
blockIndex: data.block.index,
blockHash: data.block.hash.substring(0, 8),
transactionsRecovered: data.transactionsRecovered,
rewardLost: data.rewardLost
});
}
}
// 채굴자를 위한 고아 블록 방지 전략
class MinerOrphanPrevention {
// 블록 전파 최적화 - 가능한 한 빨리 전파
broadcastBlockFast(block: Block, peers: Peer[]): void {
// 모든 피어에게 동시에 전송 (병렬)
const broadcasts = peers.map(peer =>
this.sendBlockToPeer(peer, block)
);
Promise.all(broadcasts).then(() => {
console.log(`블록을 ${peers.length}개 피어에게 전파 완료`);
});
}
// 네트워크 지연이 낮은 피어 우선 연결
connectToLowLatencyPeers(peers: Peer[]): Peer[] {
return peers
.sort((a, b) => a.latency - b.latency)
.slice(0, 8); // 상위 8개만 유지
}
private async sendBlockToPeer(peer: Peer, block: Block): Promise<void> {
// 실제 네트워크 전송 로직
return new Promise(resolve => setTimeout(resolve, peer.latency));
}
}
설명
이것이 하는 일: 위 코드는 블록체인에서 고아 블록이 발생했을 때 이를 체계적으로 처리하는 시스템입니다. 트랜잭션을 잃지 않고 복구하며, 네트워크 건강성을 모니터링하고, 채굴자가 고아 블록을 최소화하도록 인센티브를 제공하는 전체 메커니즘을 구현합니다.
첫 번째 단계에서 handleOrphanBlock 함수는 체인 재구성 과정에서 메인 체인에 포함되지 못한 블록을 받아 처리합니다. 먼저 고아 블록을 Map에 저장하는데, 이는 나중에 또 다른 재구성이 발생했을 때 이 블록이 필요할 수 있기 때문입니다.
예를 들어, A 포크의 블록이 고아가 되었다가, 나중에 A 포크가 다시 길어져서 메인 체인이 되면, 저장해둔 고아 블록을 다시 활성화할 수 있습니다. 이는 드물지만 가능한 시나리오입니다.
그 다음으로, 고아 블록 통계를 업데이트합니다. 총 블록 수 대비 고아 블록 수의 비율을 계산하여, 네트워크의 건강 상태를 판단하는 지표로 사용합니다.
정상적인 비트코인 네트워크에서는 1~2%의 고아 블록 비율이 관찰됩니다. 만약 이 비율이 5% 이상으로 올라가면, 네트워크에 심각한 지연이 있거나, 채굴 파워가 과도하게 중앙화되었거나, Selfish Mining 같은 공격이 진행 중일 가능성이 있습니다.
세 번째 단계에서는 고아 블록의 트랜잭션을 추출하고 복구합니다. 코인베이스 트랜잭션(채굴 보상)은 제외하는데, 이는 특정 블록에 종속된 것이므로 블록이 고아가 되면 같이 사라지는 것이 맞습니다.
일반 트랜잭션들은 트랜잭션 풀에 다시 추가되어, 다음 블록에 포함될 기회를 얻습니다. 중복 검사를 수행하여 이미 메인 체인이나 풀에 있는 트랜잭션은 건너뜁니다.
이는 두 포크가 같은 트랜잭션을 포함할 수 있기 때문입니다. 채굴 보상 손실을 명시적으로 로깅하는 것은 인센티브 메커니즘의 일부입니다.
채굴자는 자신의 블록이 고아가 되면 막대한 손실(현재 비트코인 기준으로 약 30만 달러)을 입습니다. 이는 채굴자가 다음과 같은 행동을 취하도록 동기를 부여합니다.
첫째, 네트워크 연결을 최적화하여 블록을 빠르게 전파합니다. 둘째, 메인 체인을 빠르게 따라가며 항상 최신 블록 위에 채굴합니다.
셋째, 네트워크를 분열시키는 Selfish Mining 같은 전략을 피합니다. cleanupOldOrphans 함수는 메모리 관리를 담당합니다.
현재 블록 높이보다 100블록 이상 뒤처진 고아 블록은 다시 활성화될 가능성이 거의 없으므로 안전하게 삭제할 수 있습니다. 100블록을 되돌리려면 공격자가 네트워크 전체보다 훨씬 많은 해시파워를 가져야 하는데, 이는 경제적으로 불가능합니다.
이 임계값은 보안과 메모리 효율 사이의 트레이드오프입니다. MinerOrphanPrevention 클래스는 채굴자가 고아 블록을 최소화하기 위해 취할 수 있는 전략을 보여줍니다.
블록을 찾자마자 모든 피어에게 병렬로 전파하고, 지연이 낮은 피어와 우선적으로 연결하는 것이 핵심입니다. 실제로 대형 채굴 풀들은 전용 고속 네트워크(FIBRE, Fast Internet Bitcoin Relay Engine)를 사용하여 블록 전파 시간을 수백 밀리초로 줄입니다.
여러분이 이 고아 블록 시스템을 구현하면 블록체인의 경제적 안정성을 보장할 수 있습니다. 트랜잭션은 절대 잃어버리지 않고 결국 체인에 포함되며, 채굴자는 고아 블록을 줄이려는 경제적 동기를 가지고, 네트워크 운영자는 시스템의 건강성을 실시간으로 모니터링할 수 있습니다.
고아 블록 비율이 비정상적으로 높아지면 자동으로 경고를 발생시켜, 네트워크 공격이나 인프라 문제를 조기에 감지할 수 있습니다. 실제 비트코인과 이더리움은 이보다 더 정교한 메커니즘을 가지고 있습니다.
비트코인은 고아 블록을 "stale block"이라고 부르며, BIP152(Compact Block Relay)를 통해 블록 전파를 최적화합니다. 이더리움은 GHOST 프로토콜을 사용하여 고아 블록("uncle block")에도 일부 보상을 지급하여, 채굴자가 불이익을 덜 받도록 했습니다.
하지만 핵심 원리는 동일합니다.
실전 팁
💡 고아 블록 비율을 대시보드에 표시하여 실시간으로 모니터링하세요. 갑작스러운 상승은 네트워크 공격이나 대규모 채굴 풀의 등장을 의미할 수 있습니다.
💡 채굴자라면 블록을 찾자마자 헤더만 먼저 전파하고, 이후에 전체 블록을 전송하는 "블록 공지(block announcement)" 기법을 사용하여 전파 속도를 높이세요.
💡 고아 블록의 트랜잭션을 풀에 다시 추가할 때는 유효성을 재검증하세요. UTXO 상태가 변경되어 이전에 유효했던 트랜잭션이 무효가 될 수 있습니다.
💡 지리적으로 분산된 노드를 운영하면 고아 블록을 줄일 수 있습니다. 한 지역에서만 채굴하면 그 지역 외부의 블록에 항상 뒤처지게 됩니다.
💡 테스트 환경에서는 의도적으로 고아 블록을 생성하여 시스템이 올바르게 복구하는지 검증하세요. 두 노드를 격리시킨 후 각각 블록을 채굴하게 하고, 다시 연결했을 때의 동작을 테스트하세요.