SSTORE가 쓴 값이 디스크에 도착하기까지: 메모리에 쌓이고, 트라이를 거쳐 루트 32바이트로 요약되고, 마침내 저장되는 경로를 코드로 따라갑니다. core/statetrie, 이더리움이 "지금 상태"를 들고 있는 자리입니다.
기준 코드: go-ethereum ca1f2e4d3

지도

오늘 다루는 구간: 결과의 저장

지갑 서명: 1주

개인키로 서명

RPC / p2p: 2주

접수와 전파

txpool: 2주

대기실, 검증

EVM 실행: 3주

옵코드 하나씩

상태 / 트라이: 오늘

StateDB→디스크

블록 · 합의: 5주

조립과 전파

3주차는 SSTORE에서 멈췄다. 옵코드는 값을 넘기기만 하고 끝났고, 그 값이 어디로 가는지는 남의 일이었다. 오늘은 그 "남의 일" 쪽을 연다.

오늘 답할 질문 세 개: ① 상태와 StateDB와 트라이는 각각 무엇인가 ② SSTORE가 쓴 값은 언제 메모리를 떠나 디스크에 닿는가 ③ 슬롯 하나가 바뀌면 왜 상태 루트가 바뀌는가

core/types/state_account.go

상태는 모든 주소의 현재 잔고와 데이터다

type StateAccount struct {
    Nonce    uint64
    Balance  *uint256.Int
    Root     common.Hash   // merkle root of the storage trie
    CodeHash []byte
}
하는 일

상태는 주소에서 계정으로 가는 하나의 큰 매핑이다. 내 지갑의 잔고도, 컨트랙트의 변수 값도 전부 여기 있다.

여기서 드러나는 것

계정 하나는 이 네 필드가 전부다. 컨트랙트의 스토리지는 이 구조체 안에 없고, Root라는 해시 하나가 대신 들어 있다.

  • 용어를 먼저 못박는 이유: 상태라는 말을 오늘 계속 쓴다. 잔고 이체도, 컨트랙트 변수 변경도 결국 이 매핑의 항목을 바꾸는 일이다.
  • Root가 뭔지는: 트라이를 본 다음에 돌아온다. 지금은 표시만 해둔다.
core/types/state_account.go

계정은 두 종류, 구조체는 하나다

EOA (지갑)

Nonce: 보낸 tx 수
Balance: 보유 ETH
Root: 빈 트라이 해시 (고정 상수)
CodeHash: 빈 문자열 해시 (고정 상수)

컨트랙트

Nonce: 배포한 컨트랙트 수
Balance: 보유 ETH
Root: 자기 스토리지 트라이의 루트
CodeHash: 배포된 코드의 해시

  • 타입 플래그 같은 건 없다: CodeHash가 빈 해시가 아니면 컨트랙트다.
  • EOA의 스토리지가 영원히 비어 있는 이유: 실행할 코드가 없어서 SSTORE를 할 방법이 없다.
  • Nonce는 계정 종류에 따라 의미가 다르다: 컨트랙트에서는 CREATE 카운터다.
  • 코드 본체는 트라이 안에 없다: 해시만 계정에 있고, 실제 바이트코드는 따로 저장된다.
core/state/statedb.go

StateDB는 상태를 들고 있는 메모리 객체다

EVM

실행

StateDB

메모리 · 작업 사본

저장소

트라이 + 디스크

하는 일

EVM이 상태를 읽고 쓰는 유일한 창구다. GetState, SetState, GetBalance, AddBalance 전부 이 객체의 메서드다.

이렇게 만든 이유

변경은 바로 저장소로 가지 않고 메모리에 쌓인다. 디스크의 상태가 진실이고, StateDB는 그 위에서 작업하는 사본이다.

  • 이름에 DB가 들어가지만 반대다: 디스크를 떠올리기 쉬운데, StateDB는 메모리 객체다.
  • 일회용이다: 블록 하나를 처리하는 동안 살아 있고, 끝나면 커밋되거나 통째로 버려진다. 이 성질이 오늘 마지막에 복선으로 돌아온다.
core/blockchain.go · state_processor.go

상태는 블록 하나가 처리되는 동안 다뤄진다

// ProcessBlock (프리페처 끈 경로)
statedb, _ := state.New(parentRoot, bc.statedb)
p.Process(block, statedb, cfg)
  1. 1
    StateDB 생성

    RAM에 상태 0개

  2. 2
    tx 실행

    지난주 그 루프. 접근할 때마다 하나씩 로딩

  3. 3
    블록 끝

    트라이 갱신

  4. 4
    커밋

    디스크로

하는 일

루트 해시 하나만 들고 빈 StateDB를 연 뒤, 블록 안의 tx를 실행한다.

여기서 드러나는 것

StateDB는 처음에 비어 있다. 수억 개 계정을 미리 올리는 단계는 없다: 애초에 불가능하다.

  • 필요한 것만 하나씩 올라온다: 지난주 본 인터프리터 루프가 돌면서, 그리고 그 앞뒤로 nonce 검사나 잔고 이체를 하면서, 계정이나 슬롯에 접근할 때마다 디스크에서 올라와 메모리에 남는다.
  • 규모 감각: 단순 송금 하나도 보내는 쪽, 받는 쪽, 수수료 받는 쪽 세 계정을 읽고 쓴다. 블록 전체로도 수백 개 수준이다.
  • 3주차와 이어지는 지점: 그 인터프리터 루프가 도는 동안, 그 아래에서 벌어지는 일이 오늘 내용이다.
자료구조

트라이도 트리다, 차이는 키가 어디 있느냐다

보통 트리 · 이진 탐색 트리

상자 안에 단어가 통째로 들어 있다
열어보고 비교하며 내려간다
넣는 순서에 따라 모양이 달라진다

트라이

갈림길마다 글자. c → a → t 길 끝이 cat의 자리다
cat과 car는 c, a까지 길을 공유한다
넣는 순서와 무관하게 모양이 항상 같다

트리는 키를 들고 비교하며 내려간다. 트라이는 경로가 곧 키라서, 비교 없이 키를 따라 내려간다.

  • 트라이도 트리의 한 종류다: 트리가 큰 범주고, 트라이는 경로가 곧 키인 특별한 트리다.
  • 순서 무관 성질을 기억해 둘 것: 왜 중요한지는 다음 절 끝에서 회수한다.
  • 이름의 유래: trie는 retrieval에서 왔다. 원래 tree와 발음이 같다.
trie

이더리움의 트라이는 루트 32바이트로 전체를 요약한다

계정 트라이

주소 → StateAccount

StateAccount.Root

둘을 잇는 필드

스토리지 트라이

slot → value · 컨트랙트마다 하나

  • 글자가 여기서는 니블이다: 16진수 한 자리. 해시된 주소나 슬롯을 한 자리씩 소비하며 내려간다.
  • 각 노드가 자식들의 해시를 들고 있다: 리프가 바뀌면 부모, 그 부모, 루트까지 해시가 연쇄로 바뀐다. 그래서 바뀐 경로만 다시 계산하면 된다.
  • 트라이는 두 층이다: 계정 트라이는 체인 전체에 하나, 리프가 StateAccount다. 컨트랙트마다 스토리지 트라이가 따로 있고, 둘을 잇는 게 앞에서 본 Root 필드다. EOA는 빈 트라이라 가리킬 게 없다.

내용이 하나라도 다르면 루트가 달라진다. 순서와 무관하게 모양이 결정되니, 같은 상태면 누가 계산해도 같은 32바이트가 나온다. 오늘의 주제문이다.

core/vm/interface.go

EVM이 아는 상태의 전부는 인터페이스 하나다

// opSstore: 스택에서 꺼내 넘긴다
evm.StateDB.SetState(addr, loc, val)

// opSload: 물어보고 스택에 놓는다
val := evm.StateDB.GetState(addr, loc)

// EVM이 아는 상태의 전부
type StateDB interface {
    GetBalance(...)  AddBalance(...)
    GetNonce(...)    SetNonce(...)
    GetCode(...)     SetCode(...)
    GetState(...)
    SetState(...)
    AddRefund(...)   Snapshot()
    RevertToSnapshot(...)
    ...
}
하는 일

읽기든 쓰기든 상태에 대한 모든 접근이 이 인터페이스를 지난다.

여기서 드러나는 것

트라이나 디스크라는 단어가 아예 없다. 3주차에 SSTORE에서 멈춘 이유가 이것이다: 옵코드는 세 줄이고, 저장은 남의 일이다.

  • SLOAD도 대칭이다: GetState 한 번.
  • Snapshot과 RevertToSnapshot이 여기 있다: 잠시 뒤 journal 절의 복선이다.
지도

값이 가는 경로는 세 단계다

메모리

StateDB · 실행 중

트라이

MPT · 블록 처리 끝

디스크

KV DB · 블록 저장

EVM 실행 중에는 어떤 변경도 트라이에 닿지 않는다. 세 단계의 경계가 언제 넘어가는지가 오늘의 뼈대다.

  • 리듬을 기억할 것: 왼쪽 단계일수록 자주 일어나고 싸다. 오른쪽으로 갈수록 드물고 비싸다. tx마다 메모리, 블록마다 트라이, 그리고 디스크.
core/state/state_object.go

stateObject는 쓰기가 두 단계, 읽기 캐시가 하나다

type stateObject struct {
    data types.StateAccount   // 변경 반영된 계정 값

    // 읽어온 원본 캐시
    originStorage  Storage // accessed within the current block
    // 쓰기가 쌓이고 승격되는 두 칸
    dirtyStorage   Storage // modified within the current transaction
    pendingStorage Storage // modified within the current block
}
dirtyStorage

이번 tx가 쓴 값

pendingStorage

이번 블록이 쓴 값

트라이

블록 끝에 반영

쓰기가 승격되는 경로

dirty → pending → 트라이. 쓰기는 이 두 칸에 쌓이고 단계적으로 승격된다.

따로 노는 읽기 캐시

originStorage는 이 경로에 속하지 않는다. 커밋된 원본을 읽어와 담아두는 별도의 칸이다.

  • 세 칸을 한 줄로 세우면 안 된다: 주석이 성격을 말해준다. 앞의 둘은 modified, origin만 accessed다.
  • 왜 쓰기를 둘로 나눴나: 되돌릴 단위가 다르기 때문이다. tx가 실패하면 dirty만 버리고, pending은 앞선 tx들이 성공시킨 값이라 건드리지 않는다.
  • 셋이 한 줄로 정렬되는 건 읽을 때뿐이다: dirty → pending → origin 순으로 찾는다. 다음 절에서 코드로 확인한다.
core/state/state_object.go

읽기는 커밋된 값을 구하고, dirty가 있으면 그게 답이다

func (s *stateObject) getState(key) (common.Hash, common.Hash) {
    origin := s.GetCommittedState(key)   // 커밋된 값 먼저
    value, dirty := s.dirtyStorage[key]
    if dirty {
        return value, origin             // 이번 tx가 쓴 값이 답
    }
    return origin, origin                // 안 바꿨으면 원본이 곧 현재값
}

func (s *stateObject) GetCommittedState(key) common.Hash {
    if v, ok := s.pendingStorage[key]; ok { return v }   // 1. 이번 블록
    if v, ok := s.originStorage[key]; ok { return v }    // 2. 캐시
    value, err := s.db.reader.Storage(s.address, key)    // 3. 저장소
    s.originStorage[key] = value        // 4. 다음을 위해 남긴다
    return value
}
하는 일

커밋된 값을 먼저 구한 뒤, 이번 tx가 쓴 값이 있으면 그걸 답으로 돌려준다. 반환값이 (현재값, 원본값) 둘이다.

이렇게 만든 이유

원본은 dirty 여부와 무관하게 항상 필요하다. SSTORE 가스가 원래 값에 따라 달라지기 때문이다.

  • 우선순위와 실행 순서는 다르다: 값의 우선순위는 dirty가 최상위지만, 코드가 먼저 실행하는 건 커밋된 값 쪽이다.
  • GetCommittedState가 dirty를 안 보는 건 실수가 아니라 정의다: 이름 그대로 커밋된 값만 돌려준다. EVM이 가스를 계산할 때 이 함수를 직접 부르기도 한다.
  • 저장소까지 갔으면 캐시에 남긴다: 4번 줄. 같은 슬롯 두 번째 읽기는 디스크에 안 간다.
  • 계정도 같은 패턴이다: getStateObject가 맵을 확인하고, 없으면 reader로 읽어와서, 맵에 남긴다. 접근한 것만 올리고, 올린 건 남긴다.
  • 앞에서 예고한 로딩 시점의 답이 여기다: StateDB에 상태 전체가 들어 있는 게 아니라, 접근한 계정만 지연 로딩된다.
core/state/state_object.go · journal.go

쓰기는 journal에 남기고 dirty에 쌓는다

func (s *stateObject) SetState(key, value) common.Hash {
    prev, origin := s.getState(key)
    if prev == value { return prev }   // 같으면 아무것도 안 함
    s.db.journal.storageChange(s.address, key, prev, origin)
    s.setState(key, value, origin)     // dirtyStorage[key] = value
}
func (j *journal) revert(statedb *StateDB, snapshot int) {
    for i := len(j.entries) - 1; i >= snapshot; i-- {
        j.entries[i].revert(statedb)   // 예: setState(이전 값)
    }
}
하는 일

가드 하나를 빼면 본질은 두 줄이다. journal에 이전 값을 남기고, dirty에 새 값을 쌓는다.

이렇게 만든 이유

기록이 쓰기보다 먼저다. 이전 값을 잃기 전에 남겨야 하기 때문이다.

3주차 질문의 답: REVERT 옵코드는 상태를 직접 바꾸지 않는다. 에러가 올라가면 journal을 스냅샷까지 역순으로 재생해서 되돌린다.

  • 스냅샷이란: journal 길이를 적어두는 것이다.
  • 왜 역순인가: 나중 것부터 풀어야 처음 값이 남는다. A에서 B, B에서 C로 바꿨으면 C에서 B, B에서 A 순이다.
core/state_processor.go · statedb.go

tx가 끝나면 Finalise, 트라이는 아직이다

// ApplyTransactionWithEVM 마지막: tx 하나마다 불린다
evm.StateDB.Finalise(true)

// Finalise, however, will not push any updates into the tries
// just yet. Only IntermediateRoot or Commit will do that.
func (s *StateDB) Finalise(deleteEmptyObjects bool) {
하는 일

각 stateObject의 dirtyStorage를 pendingStorage로 옮기고, journal을 비운다. tx 경계를 넘는 revert는 없다.

이렇게 만든 이유

스토리지 트라이와 계정 트라이를 갱신하려면 해시를 다시 계산해야 해서 비싸다. tx 100개가 같은 슬롯을 바꿔도 블록 끝에 마지막 값으로 한 번만 갱신하면 된다.

  • 왜 tx 경계를 넘는 revert가 없어도 되나: 앞 tx는 이미 영수증으로 확정됐기 때문이다.
  • 소스 주석이 설계 의도를 직접 말한다: 몰아서 하는 함수가 IntermediateRoot다. 여기서 메모리 단계가 끝난다.
핵심

슬롯 하나가 상태 루트를 바꾸는 연쇄

슬롯 값 변경

SSTORE

스토리지 트라이

루트 변경

StateAccount

Root 필드 변경

상태 루트

변경

  • 슬롯이 바뀌면: 그 컨트랙트의 스토리지 트라이 루트가 바뀐다.
  • 그 루트는 계정 데이터의 필드라서: 계정 트라이의 리프가 바뀐 셈이 된다.
  • 리프가 바뀌면: 계정 트라이의 루트, 즉 상태 루트가 바뀐다.
  • 잔고나 nonce만 바뀌는 tx는: 두 번째 칸을 건너뛰고 StateAccount부터 시작한다. EOA 간 송금이 그렇다.

오늘의 중심 그림이다. 이 네 단계를 자기 말로 설명할 수 있으면 이번 주차는 성공이다.

core/state/statedb.go

IntermediateRoot가 그 연쇄를 실행한다

func (s *StateDB) IntermediateRoot(...) common.Hash {
    s.Finalise(...)                    // 1. dirty를 pending으로
    for addr, op := range s.mutations {
        workers.Go(func() error {
            obj.updateRoot()           // 2. 스토리지 트라이 → data.Root
        })
    }
    for addr := range s.mutations {
        s.updateStateObject(...)       // 3. 계정 트라이에 반영
    }
    return s.trie.Hash()               // 4. 상태 루트
}
// 2번 단계 확대
func (s *stateObject) updateRoot() {
    tr, _ := s.updateTrie()
    s.data.Root = tr.Hash()
}
하는 일

앞 절의 연쇄를 코드로 실행한다. 원문 140여 줄에서 프리페처와 witness를 걷어내면 네 단계다.

여기서 드러나는 것

s.data.Root = tr.Hash()가 연쇄의 세 번째 화살표다. 그림의 화살표 하나가 코드 한 줄에 정확히 대응한다.

  • 계정별 updateRoot는 goroutine으로 병렬 실행된다: 1주차 goroutine이 실전에 등장하는 지점이다.
core/block_validator.go

루트 32바이트로 실행 전체를 검증한다

// ValidateState 마지막
if root := statedb.IntermediateRoot(...); header.Root != root {
    return fmt.Errorf("invalid merkle root (remote: %x local: %x)",
                      header.Root, root, ...)
}
하는 일

블록의 모든 tx를 실행한 뒤, 내가 계산한 루트와 헤더의 Root를 비교한다. 다르면 블록은 거부된다.

여기서 드러나는 것

변수명이 상황을 설명한다: remote는 제안자의 값, local은 내 실행 결과다.

  • 상태 항목이 수억 개여도: 비교는 32바이트 한 번이면 끝난다.
  • 이게 트라이를 쓰는 이유다: 맵이면 요약 해시를 만들 때마다 전체를 다시 해싱해야 하지만, 트라이는 바뀐 경로만 다시 계산한다. 검증에 필요한 건 상태 전체가 아니라 커밋먼트 하나다.
core/blockchain.go · statedb.go

Commit이 바뀐 노드를 모아 디스크로 보낸다

// writeBlockWithState → statedb.Commit → commit
    s.IntermediateRoot(...)             // 1. 루트 확정
    for addr := range s.mutations {
        workers.Go(func() error {
            update, set, _ := obj.commit()  // 2. 바뀐 노드 수집
            merge(set)                      // 3. 전역 집합으로
        })
    }
    newroot, set := s.trie.Commit(true)  // 4. 계정 트라이도 (병렬)
    // 5. 노드 꾸러미 → triedb, 디스크는 triedb가 맡는다
하는 일

바뀐 트라이 노드를 모아 triedb로 넘긴다. 블록과 영수증은 rawdb로 가고, 상태는 이 함수가 맡는다.

여기서 드러나는 것

IntermediateRoot와 같은 병렬 패턴인데 결과물만 다르다. 하나는 해시만 얻고, 하나는 바뀐 노드를 모아 넘긴다.

다음 주로 넘기는 한 줄: state, err := miner.chain.StateAt(parent.Root) 루트 해시 하나만 있으면 그 시점의 상태 위에 StateDB를 새로 열 수 있다. 그 위에서 tx를 실행해 보고 마음에 안 들면 버리면 된다. StateDB가 일회용 작업 사본이라는 성질이 여기서 힘을 발휘한다.

정리

오늘 정리와 다음 주 주제

1. 세 단계를 지난다

메모리(StateDB) → 트라이(MPT) → 디스크. tx마다 메모리, 블록마다 트라이, 그리고 저장

2. 쓰기 두 단계, 읽기 캐시 하나

쓰기는 dirty(tx) → pending(블록) → 트라이로 승격되고, origin은 따로 노는 읽기 캐시다

3. 루트 하나가 전체를 요약한다

슬롯 하나가 바뀌면 상태 루트까지 연쇄로 바뀌고, 검증은 32바이트 비교 한 번으로 끝난다

5주차 · 블록 조립: 빌더는 이 일회용 StateDB 위에서 어떻게 tx를 골라 블록을 채울까. 3주차에서 "여기서 꺼내진다"까지만 보고 넘긴 miner 패키지를 연다.