(6) 블록체인 실전 – 블록(Block)

이전 포스팅들을 통해서 블록체인을 이해하기 위한 모든 준비를 마쳤어요.

위 포스팅에 정리된 내용들을 바탕으로 본격적으로 블록체인 네트워크를 다뤄볼거에요. 이전 포스팅들에서는 이해를 돕고자 비유를 많이 사용했지만, 이제부터는 비유를 가급적 자제할 예정이에요.

왜냐하면 비유를 사용하게 되면 의도와는 달리 사실 전달에 왜곡이 발생하기 때문이에요. 이제 충분한 배경지식이 있으니 비유 없이도 블록체인의 기술을 이해할 수 있는 수준이라고 생각해요.

들어가며

블록체인 기술을 누군가의 해석이 아닌, 있는 그대로 이해해요

블록체인은 소프트웨어 기술이에요. 소프트웨어라는 의미는 곧 사람이 읽을 수 있는 프로그램 코드로 작성이 되어 있다는 의미에요. 그래서 블록체인을 이해하는 과정에서 블록체인의 프로그램 코드를 같이 이해하는 것이 블록체인을 가장 직접적으로 정확하게 이해하는 것과 같다고 할 수 있어요.

하지만 프로그래머가 아닌 사람들이 코드를 이해하기란 무척 어려운 일이에요. 그래서 제가 코드를 바탕으로 프로그래머가 아닌 사람들도 코드를 이해할 수 있는 형태로 풀어서 설명할 예정이에요. 이 과정에서 최대한 코드의 내용을 축약하거나 변경하지 않고 있는 그대로 전달할 예정이에요. 마치 외국어를 한국어로 통역하듯이 의미는 그대로 살리되 전달이 가능한 형태로 변형할 뿐이지, 재해석은 하지 않을 거에요.

저의 포스팅을 읽으신 분들이 블록체인에 대해서 이해하는 과정에서 누군가의 해석을 이해한 것이 아니라 정말 그 기술 자체를 이해하시길 원하기 때문이에요.

블록체인 기술은 오픈소스로 누구든지 열람할 수 있어요

본격적으로 블록체인 기술을 다루기 전에, ‘오픈소스’ 라는 개념을 이해할 필요가 있어요. 왜냐하면 포스팅 전반에서 블록체인의 코드를 설명드릴 예정인데 이 코드가 어디서 온 것인지? 믿을 수 있는 것인지? 에 대한 근본적인 의문이 들 수 있기 때문이에요.

오픈소스는 말 그대로 ‘공개된 소스’ 라는 거에요. 음식 레시피로 예를 들어 볼게요. 어떤 음식이든 만드는 방법은 정말 다양해요. 이 중에 ‘백종원님의 ㅇㅇ 만드는 방법’ 은 이미 유튜브로 공개가 된 상태에요. ㅇㅇ 만드는 방법은 오픈소스인 것이죠.

오픈은 알겠는데 그럼 ‘소스’는 무엇인지 궁금하실 수 있어요. 잠깐 컴퓨터 프로그램에 대해서 짚고 넘어갈게요. 컴퓨터 프로그램은 프로그래머가 특정한 컴퓨터 언어(C, C++, JAVA 등)로 만들고 이 컴퓨터 언어가 여러 과정을 거쳐서 우리가 사용하는 프로그램이 되어요. 이 때 프로그래머가 작성한 컴퓨터 언어를 보통 ‘source’ 라고 표현해요.

그런 의미에서 어떠한 프로그램이 ‘오픈소스’ 라는 의미는 이 프로그램을 만드는 방법이 대중에게 공개가 되어 있다는 의미에요. 그러면’해킹에 취약한 것이 아닌지’, ‘왜 굳이 이런걸 공개하는지’ 등 여러 의문이 자연스럽게 드실 수 있을 것 같아요. 이건 여기서 다루기 보다는 이미 나와있는 여러 레퍼런스를 참고해보시거나 검색해보시면 좋을 것 같아요.

여기서 제가 오픈소스가 무엇인지 설명 드리는 이유는 제가 설명할 블록체인 기술들의 레퍼런스가 블록체인 코드들이기 때문이에요. 제가 어떻게 코드를 알고 있는지, 이게 진짜 블록체인이 맞는 것인지 등에 대해서 드실 수 있는 의문을 미리 정리해드리고 싶어서 오픈소스라는 개념에 대해서 다뤘어요.

블록체인은 기술에 대한 개념적 정의일 뿐이에요

블록체인은 기술 자체에 대한 개념적 정의에요. 화약이 점화되면서 폭발이 일어나고, 이 폭발이 만드는 운동에너지가 특정 물체를 멀리 보낼 수 있어요. 이 모든걸 ‘화약 기술’ 이라고 했을때 이 화약 기술을 이용해서 대포, 총, 폭죽, 로켓 등을 만들었듯이 블록체인도 기술이고 이걸 이용한 여러가지 구현체들이 존재해요. 블록체인을 이용한 구현체들이 여러 블록체인 네트워크이고 이 네트워크들을 움직이게 하는 보상들이 우리가 흔히 알고 있는 OO코인 들이에요.

블록체인은 이전에 없던 새로운 형태의 자료구조이자 개념이에요. 가장 친숙한 단어로 표현하자면 저는 ‘기술’ 이라는 단어가 제일 잘 맞는 것 같아요. 그리고 블록체인을 이용해서 만들어진 네트워크들이 여러가지가 존재하고 각각의 네트워크들 내부에서 보상으로서 주어지는 것들이 OO코인이에요.

알아보는 블록체인의 구현체는 비트코인 블록체인이에요

여러 블록체인 네트워크 중 가장 전통적이고 시장 지배적인 네트워크인 비트코인 블록체인을 가지고 블록체인을 깊이 이해해볼거에요. 비트코인 네트워크는 오픈소스이고 소스 전체는 여기에서 확인할 수 있어요. 아래 내용들은 모두 해당 소스를 기준으로 설명되어요.

블록(Block) 이란?

블록의 구조

블록은 거래 데이터를 저장하는 공간이에요. 블록은 크게 두 부분으로 구성되어요.

  • 블록 헤더
    • 블록의 메타데이터를 포함
  • 블록 바디
    • 실제 거래 내역들을 포함

블록의 헤더(header)

위 그림은 블록을 시각화 한 것이에요. 사실 Body 라는 개념이 실제로 있는 것은 아닌데, 헤더와 그 이외의 것을 구분하기 위해서 이해하기 쉽게 Body 라는 개념을 보통 사용해요. 아래는 비트코인의 헤더 소스에요.

class CBlockHeader
{
public:
    // header
    int32_t nVersion;
    uint256 hashPrevBlock;
    uint256 hashMerkleRoot;
    uint32_t nTime;
    uint32_t nBits;
    uint32_t nNonce;

    CBlockHeader()
    {
        SetNull();
    }

    SERIALIZE_METHODS(CBlockHeader, obj) { READWRITE(obj.nVersion, obj.hashPrevBlock, obj.hashMerkleRoot, obj.nTime, obj.nBits, obj.nNonce); }

    void SetNull()
    {
        nVersion = 0;
        hashPrevBlock.SetNull();
        hashMerkleRoot.SetNull();
        nTime = 0;
        nBits = 0;
        nNonce = 0;
    }

    bool IsNull() const
    {
        return (nBits == 0);
    }

    uint256 GetHash() const;

    NodeSeconds Time() const
    {
        return NodeSeconds{std::chrono::seconds{nTime}};
    }

    int64_t GetBlockTime() const
    {
        return (int64_t)nTime;
    }
};

블록의 헤더는 어떤 것들로 구성되어 있고, 각각은 무엇을 의미하는지를 이해하는 것이 중요해요. 각각의 필드를 구체적으로 다뤄볼게요. 헤더를 구성하는 전체 필드를 먼저 관조하고 각각을 다뤄볼게요.

Block 의 Header

  • nVersion
  • hashPrevBlock
  • hashMerkleRoot
  • nTime
  • nBits
  • nNonce

nVersion(버전)

블록체인 네트워크의 버전을 의미해요. 일반적으로 우리가 알고 있는 그 ‘버전’ 이에요. 블록체인 네트워크도 계속해서 발전해요. 미비한 부분들을 수정, 보완하면서 발전하거나 아예 새로운 무엇인가가 더해질 수 있어요. 그러면 버전이 올라가요.

‘블록체인 네트워크도 버전이 있구나’ 로만 이해해도 무방한데, 조금 더 깊이 이해해보면 좋을 것 같아요. 더 깊이 이해한다는 것의 의미는 ‘버전이 다르면 뭐가 달라지는데?’라는 의문에 대해서 스스로 답할 수 있다는 의미에요.

기본적으로 블록이 제공하는 기능들이 있을거에요. 위 그림에서 볼 수 있듯이 거래를 저장하는 것이 하나의 기능일 수 있겠죠. 버전이 올라간다는 것은 기존의 기능은 그대로 유지하되(=하위 호환) 추가로 개선 반영된 것들은 특정 버전 이상으로만 동작한다는 거죠.

예를 들어서 원래의 버전이 1이었고, 블록의 보안 규칙이 강화되면서 버전 2를 출시했다고 가정해볼게요. 그러면 버전이 1인 시절에 만들어진 블록 A가 있고, 버전이 2인 시기에 만들어진 블록 B가 있을 거에요. A 와 B 는 서로 블록으로서 역할을 수행하지만 B는 조금더 발전된 기능을 수행하는 거에요.

네트워크는 ‘블록이 따르는 규칙’ 이라고 표현 할 수 있어요. 그리고 하나의 네트워크 내에 다른 버전의 블록이 공존할 수 있어요.

hashPrevBlock(이전 블록 해시)

이전 블록의 해시값을 나타내요. 이걸 설명하기 위해서는 블록의 해시가 어떻게 만들어지는지? 블록의 해시란 무엇인지? 를 알아야해요.

블록의 해시는 아래의 과정으로 만들어져요.

  1. 헤더 필드들을 순서대로 연결
    • [nVersion][hashPrevBlock][hashMerkleRoot][nTime][nBits][nNonce]
  2. 연결된 데이터에 SHA256 두 번 적용
    • SHA256(SHA256(Header Data))

이전 포스팅에서 해시(hash) 는 마법의 터널을 통과하는 것과 같다고 설명 드렸었어요. 헤더의 필드들을 연속해서 이은 값을SHA256 마법의 터널에 두 번 통과시킨 값이 블록의 헤더인 셈이에요.

헤더에 있는 hashPrevBlock 는 이전 블록의 블록 해시값이에요. 이 값은 블록체인 네트워크에서 유일하게 존재하기에 이 블록의 앞에 어떤 블록이 있는지는 이 값을 보면 알 수 있는 거죠.

이렇게 처리되고 있는 부분을 코드에서 찾아보면 아래 부분에서 찾을 수 있어요.

uint256 CBlockHeader::GetHash() const
{
    return (HashWriter{} << *this).GetHash();
}

SERIALIZE_METHODS(CBlockHeader, obj) { 
   READWRITE(
        obj.nVersion,
        obj.hashPrevBlock,
        obj.hashMerkleRoot,
        obj.nTime,
        obj.nBits,
        obj.nNonce);
}

hashPrevBlock 필드는 블록’체인’ 인 이유라고 할 수 있어요. hashPrevBlock 필드를 이용해서 A블록 <- B블록 <- C블록 처럼 연결 되어 있는 거죠.

더블 해싱(SHA256 을 두 번 처리)하는 이유는 그만큼 보안을 더 강화하고 충돌 가능성을 줄이기 위함이에요.

hashMerkleRoot(머클루트)

이전 포스팅에서 머클트리와 머클루트의 개념에 대해서 다뤘었어요. 모든 트랜잭션은 트랜잭션 해시값을 가져요. 트랜잭션에 대해서는 블록에 이어서 더 자세히 알아볼텐데 해시값이 어떤 원리로 생기는지는 그 때 더 알아보도록 하고 여기서는 트랜잭션도 결국 블록처럼 해시 값을 가진다고만 이해하면 충분해요.

머클루트는 트랜잭션의 해시값들을 이용해서 최종적으로 계산되는 값이에요. 그림으로 간략히 표현하면 아래와 같아요.

위에 블록의 도식을 보면 하나의 블록에는 여러 트랜잭션이 있는 것을 알 수 있는데, 그 블록이 가진 모든 트랜잭션에 대해서 트랜잭션의 해시를 계산하고 그 트랜잭션의 해시들을 이용해서 최종적으로 머클루트를 계산하고 그 값을 헤더에 저장해요.

머클트리, 머클루트가 무엇인지 이해했다면 여기서는 단지 블록의 헤더에는 머클루트가 존재하고 이 머클루트의 원천 데이터는 트랜잭션 각각의 해시값이라는 것만 기억하면 충분해요.

nTime(채굴완료시각)

블록체인에서 ‘채굴’이 되었다는 의미는 ‘블록이 생성되고 이어졌다’라는 의미와 같아요. 채굴이라는 것은 nBits(난이도 목표) 보다 낮은 블록해시 값을 찾았다는 의미에요.(채굴 자체에 대해서는 보다 상세하게 다른 포스팅에서 다룰 예정이에요)

채굴은 채굴자(마이너)에 따라서 약간씩 방식이 다를 순 있는데 기본적인 매커니즘은 아래와 같아요.

nTime = 현재시각
nNonce = 0

while (true) {
    // 먼저 nNonce 값을 변경하며 시도
    if (nNonce < MAX_NONCE) {
        nNonce++;
    } 
    // nNonce를 모두 시도했다면
    else {
        nTime++;    // 시간 업데이트
        nNonce = 0; // nNonce 리셋
    }
    
    // 목표 난이도보다 작은 해시값을 찾으면 성공
    if (CalculateHash() < target) break;
}

설명해보자면 기본적으로 현재 시각을 nTime 으로 설정하고 nonce 값을 순차적으로 증가시키며 블록 해시값을 찾아요. 블록 해시값을 찾는다는 의미는 계산해서 나온 블록의 해시값이 난이도 목표보다 낮은 경우를 찾는다는 것을 의미해요.

그래서 실제로 채굴이 완료된 시각과 nTime 에 기록되는 시간은 약간의 차이가 있을 수 있어요.

그리고 nTime 에 대해서는 아래의 룰이 반드시 지켜져야해요.

  1. nTime은 반드시 이전 블록보다 커야 함
  2. 현재 네트워크 시간 + 2시간을 초과할 수 없음

그리고 비트코인 네트워크는 블록이 채굴되는 이상적인 시간을 10분으로 정의하고 있어요. 그래서 ‘2016개의 블록이 평균적으로 10분 간격으로 생기는가’ 의 기준으로 블록의 nTime 을 측정하고 간격이 10분보다 높으면 난이도 목표를 더 쉽게 만들고, 반대로 10분 미만이면 난이도 목표를 어렵게 해서 이상적인 시간 차이인 10분을 유지되도록 해요.

여기서 우리가 주목해야할 점은 nTime 이 난이도 목표 설정에 영향을 미치며, 결과적으로 블록의 생성 주기에도 영향을 준다는 것이에요.

nBits(난이도 목표)

바로 위에서 채굴에 대해서 간략하게 짚어 봤어요. 채굴이란 난이도 목표 미만의 값을 도출하는 블록 해시를 찾는 행위를 의미해요.

쉽게 생각해보자면 1~1000이 나오는 주사위를 던지는데 특정한 기준 값을 제시하고 그 값보다 낮은 숫자가 나오면 성공인 게임을 하는 것과 같아요. 극단적으로 보면 기준값이 1000 이면 무조건 성공일테고, 기준 값이 500이면 성공 확률이 50% 일 것이고 기준값이 10 이라면 10 이하로 나와야 하니까 주사위를 엄청 많이 던져야 할 거에요. 기준 값이 1이라면 1이 나올 때까지 계속 던져야하니까 이론상 1000번을 던져야 할 거에요. 핵심은 난이도 목표가 낮을 수록 어렵다는 것이에요.

비트코인 네트워크에서는 블록간 nTime 의 간격이 10분이기를 기대해요. 2016개 블록마다 난이도를 조정하며, 이전 2016개 블록의 총 생성 시간을 확인해요. 바로 인접한 두 블록의 차이를 보지 않고 2016개를 보는 이유는 갑자기 네트워크의 컴퓨팅 파워가 높거나 낮아질 수 있기에 더 많은 블록을 판단의 기준으로 삼는 것이에요. 이러한 맥락에서 난이도 목표의 역할은 이상적인 채굴 간격을 조정하기 위한 장치라고 할 수 있어요.

아래는 난이도 목표 조정 관련 소스에요.

// pow.cpp
// 다음 난이도(nBits)를 계산하는 함수
// pindexLast: 현재 블록, nFirstBlockTime: 2016블록 전의 시각
unsigned int CalculateNextWorkRequired(const CBlockIndex* pindexLast, int64_t nFirstBlockTime, const Consensus::Params& params)
{
    // 난이도 조정이 비활성화되어 있으면 이전 난이도 그대로 사용
    if (params.fPowNoRetargeting)
        return pindexLast->nBits;

    // 실제 걸린 시간 계산 (마지막 블록 시각 - 첫 블록 시각)
    int64_t nActualTimespan = pindexLast->GetBlockTime() - nFirstBlockTime;
    
    // 너무 빠르거나 느리면 조정 (급격한 변화 방지)
    // 목표 시간(2주)의 1/4보다 작으면 1/4로 조정
    if (nActualTimespan < params.nPowTargetTimespan/4)
        nActualTimespan = params.nPowTargetTimespan/4;
    // 목표 시간의 4배보다 크면 4배로 조정
    if (nActualTimespan > params.nPowTargetTimespan*4)
        nActualTimespan = params.nPowTargetTimespan*4;

    // 최대 난이도 설정 (너무 쉬워지는 것 방지)
    const arith_uint256 bnPowLimit = UintToArith256(params.powLimit);
    arith_uint256 bnNew;

    // 테스트넷4를 위한 특별 규칙
    if (params.enforce_BIP94) {
        // 난이도 조정 주기의 첫 블록 난이도 사용
        int nHeightFirst = pindexLast->nHeight - (params.DifficultyAdjustmentInterval()-1);
        const CBlockIndex* pindexFirst = pindexLast->GetAncestor(nHeightFirst);
        bnNew.SetCompact(pindexFirst->nBits);
    } else {
        // 일반적인 경우: 마지막 블록의 난이도 사용
        bnNew.SetCompact(pindexLast->nBits);
    }

    // 새로운 난이도 계산
    // 실제 시간이 목표보다 길면 난이도 감소 (더 쉽게)
    // 실제 시간이 목표보다 짧으면 난이도 증가 (더 어렵게)
    bnNew *= nActualTimespan;
    bnNew /= params.nPowTargetTimespan;

    // 계산된 난이도가 최대 난이도를 넘지 않도록 체크
    if (bnNew > bnPowLimit)
        bnNew = bnPowLimit;

    // 압축된 형태로 변환하여 반환
    return bnNew.GetCompact();
}

nNonce(논스)

모든 블록은 네트워크의 규칙을 따르고 있어야해요. 그러한 맥락에서 현재 이미 완성되어 연결되어 있는 블록들은 네트워크의 규칙을 만족하는 블록들이란 의미에요. 당연히 새로운 블록 역시 네트워크의 규칙을 따르고 있어야해요.

여기서 말하는 ‘네트워크 규칙’이란 이미 위에서 우리가 알아본 블록의 특징을 의미해요. 난이도 목표에 대해서 알아볼때 블록해시는 난이도 목표보다 낮아야 한다는 것을 알아봤어요. 잠깐 블록의 해시에 대해서 복기해볼게요.

  1. 헤더 필드들을 순서대로 연결
    • [nVersion][hashPrevBlock][hashMerkleRoot][nTime][nBits][nNonce]
  2. 연결된 데이터에 SHA256 두 번 적용
    • SHA256(SHA256(Header Data))

채굴은 결국 네트워크 규칙에 맞는 블록을 찾는 과정이고 이는 헤더 필드들의 조합을 찾는 과정이라고 볼 수 있어요. 주로 nonce 값을 변경해가면서 블록의 해시를 계산해보고 이것이 난이도 목표보다 낮은지 검증해보는 과정을 반복해요. nonce 는 0부터 시작해서 최대값은 4,294,967,295 이에요.

채굴시 변경하는 값은 꼭 nonce 만 있는 것은 아니에요. 주로 변경을 하는 값인 것이고 경우에 따라 머클루트를 변경하기 위해서 트랜잭션의 순서나 구성을 바꾸기도 해요.