SSTORE가 쓴 값이 디스크에 도착하기까지: 메모리에 쌓이고, 트라이를 거쳐 루트 32바이트로 요약되고, 마침내 저장되는 경로를 코드로 따라갑니다. core/state와 trie, 이더리움이 "지금 상태"를 들고 있는 자리입니다.
기준 코드: go-ethereum ca1f2e4d3
오늘 다루는 구간: 결과의 저장
개인키로 서명
접수와 전파
대기실, 검증
옵코드 하나씩
StateDB→디스크
조립과 전파
3주차는 SSTORE에서 멈췄다. 옵코드는 값을 넘기기만 하고 끝났고, 그 값이 어디로 가는지는 남의 일이었다. 오늘은 그 "남의 일" 쪽을 연다.
오늘 답할 질문 세 개: ① 상태와 StateDB와 트라이는 각각 무엇인가 ② SSTORE가 쓴 값은 언제 메모리를 떠나 디스크에 닿는가 ③ 슬롯 하나가 바뀌면 왜 상태 루트가 바뀌는가
상태는 모든 주소의 현재 잔고와 데이터다
type StateAccount struct {
Nonce uint64
Balance *uint256.Int
Root common.Hash // merkle root of the storage trie
CodeHash []byte
}상태는 주소에서 계정으로 가는 하나의 큰 매핑이다. 내 지갑의 잔고도, 컨트랙트의 변수 값도 전부 여기 있다.
계정 하나는 이 네 필드가 전부다. 컨트랙트의 스토리지는 이 구조체 안에 없고, Root라는 해시 하나가 대신 들어 있다.
- 용어를 먼저 못박는 이유: 상태라는 말을 오늘 계속 쓴다. 잔고 이체도, 컨트랙트 변수 변경도 결국 이 매핑의 항목을 바꾸는 일이다.
- Root가 뭔지는: 트라이를 본 다음에 돌아온다. 지금은 표시만 해둔다.
계정은 두 종류, 구조체는 하나다
Nonce: 보낸 tx 수
Balance: 보유 ETH
Root: 빈 트라이 해시 (고정 상수)
CodeHash: 빈 문자열 해시 (고정 상수)
Nonce: 배포한 컨트랙트 수
Balance: 보유 ETH
Root: 자기 스토리지 트라이의 루트
CodeHash: 배포된 코드의 해시
- 타입 플래그 같은 건 없다: CodeHash가 빈 해시가 아니면 컨트랙트다.
- EOA의 스토리지가 영원히 비어 있는 이유: 실행할 코드가 없어서 SSTORE를 할 방법이 없다.
- Nonce는 계정 종류에 따라 의미가 다르다: 컨트랙트에서는 CREATE 카운터다.
- 코드 본체는 트라이 안에 없다: 해시만 계정에 있고, 실제 바이트코드는 따로 저장된다.
StateDB는 상태를 들고 있는 메모리 객체다
실행
메모리 · 작업 사본
트라이 + 디스크
EVM이 상태를 읽고 쓰는 유일한 창구다. GetState, SetState, GetBalance, AddBalance 전부 이 객체의 메서드다.
변경은 바로 저장소로 가지 않고 메모리에 쌓인다. 디스크의 상태가 진실이고, StateDB는 그 위에서 작업하는 사본이다.
- 이름에 DB가 들어가지만 반대다: 디스크를 떠올리기 쉬운데, StateDB는 메모리 객체다.
- 일회용이다: 블록 하나를 처리하는 동안 살아 있고, 끝나면 커밋되거나 통째로 버려진다. 이 성질이 오늘 마지막에 복선으로 돌아온다.
상태는 블록 하나가 처리되는 동안 다뤄진다
// ProcessBlock (프리페처 끈 경로)
statedb, _ := state.New(parentRoot, bc.statedb)
p.Process(block, statedb, cfg)- 1StateDB 생성
RAM에 상태 0개
- 2tx 실행
지난주 그 루프. 접근할 때마다 하나씩 로딩
- 3블록 끝
트라이 갱신
- 4커밋
디스크로
루트 해시 하나만 들고 빈 StateDB를 연 뒤, 블록 안의 tx를 실행한다.
StateDB는 처음에 비어 있다. 수억 개 계정을 미리 올리는 단계는 없다: 애초에 불가능하다.
- 필요한 것만 하나씩 올라온다: 지난주 본 인터프리터 루프가 돌면서, 그리고 그 앞뒤로 nonce 검사나 잔고 이체를 하면서, 계정이나 슬롯에 접근할 때마다 디스크에서 올라와 메모리에 남는다.
- 규모 감각: 단순 송금 하나도 보내는 쪽, 받는 쪽, 수수료 받는 쪽 세 계정을 읽고 쓴다. 블록 전체로도 수백 개 수준이다.
- 3주차와 이어지는 지점: 그 인터프리터 루프가 도는 동안, 그 아래에서 벌어지는 일이 오늘 내용이다.
트라이도 트리다, 차이는 키가 어디 있느냐다
상자 안에 단어가 통째로 들어 있다
열어보고 비교하며 내려간다
넣는 순서에 따라 모양이 달라진다
갈림길마다 글자. c → a → t 길 끝이 cat의 자리다
cat과 car는 c, a까지 길을 공유한다
넣는 순서와 무관하게 모양이 항상 같다
트리는 키를 들고 비교하며 내려간다. 트라이는 경로가 곧 키라서, 비교 없이 키를 따라 내려간다.
- 트라이도 트리의 한 종류다: 트리가 큰 범주고, 트라이는 경로가 곧 키인 특별한 트리다.
- 순서 무관 성질을 기억해 둘 것: 왜 중요한지는 다음 절 끝에서 회수한다.
- 이름의 유래: trie는 retrieval에서 왔다. 원래 tree와 발음이 같다.
이더리움의 트라이는 루트 32바이트로 전체를 요약한다
주소 → StateAccount
둘을 잇는 필드
slot → value · 컨트랙트마다 하나
- 글자가 여기서는 니블이다: 16진수 한 자리. 해시된 주소나 슬롯을 한 자리씩 소비하며 내려간다.
- 각 노드가 자식들의 해시를 들고 있다: 리프가 바뀌면 부모, 그 부모, 루트까지 해시가 연쇄로 바뀐다. 그래서 바뀐 경로만 다시 계산하면 된다.
- 트라이는 두 층이다: 계정 트라이는 체인 전체에 하나, 리프가 StateAccount다. 컨트랙트마다 스토리지 트라이가 따로 있고, 둘을 잇는 게 앞에서 본 Root 필드다. EOA는 빈 트라이라 가리킬 게 없다.
내용이 하나라도 다르면 루트가 달라진다. 순서와 무관하게 모양이 결정되니, 같은 상태면 누가 계산해도 같은 32바이트가 나온다. 오늘의 주제문이다.
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마다 메모리, 블록마다 트라이, 그리고 디스크.
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
}이번 tx가 쓴 값
이번 블록이 쓴 값
블록 끝에 반영
dirty → pending → 트라이. 쓰기는 이 두 칸에 쌓이고 단계적으로 승격된다.
originStorage는 이 경로에 속하지 않는다. 커밋된 원본을 읽어와 담아두는 별도의 칸이다.
- 세 칸을 한 줄로 세우면 안 된다: 주석이 성격을 말해준다. 앞의 둘은 modified, origin만 accessed다.
- 왜 쓰기를 둘로 나눴나: 되돌릴 단위가 다르기 때문이다. tx가 실패하면 dirty만 버리고, pending은 앞선 tx들이 성공시킨 값이라 건드리지 않는다.
- 셋이 한 줄로 정렬되는 건 읽을 때뿐이다: dirty → pending → origin 순으로 찾는다. 다음 절에서 코드로 확인한다.
읽기는 커밋된 값을 구하고, 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에 상태 전체가 들어 있는 게 아니라, 접근한 계정만 지연 로딩된다.
쓰기는 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 순이다.
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
루트 변경
Root 필드 변경
변경
- 슬롯이 바뀌면: 그 컨트랙트의 스토리지 트라이 루트가 바뀐다.
- 그 루트는 계정 데이터의 필드라서: 계정 트라이의 리프가 바뀐 셈이 된다.
- 리프가 바뀌면: 계정 트라이의 루트, 즉 상태 루트가 바뀐다.
- 잔고나 nonce만 바뀌는 tx는: 두 번째 칸을 건너뛰고 StateAccount부터 시작한다. EOA 간 송금이 그렇다.
오늘의 중심 그림이다. 이 네 단계를 자기 말로 설명할 수 있으면 이번 주차는 성공이다.
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이 실전에 등장하는 지점이다.
루트 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바이트 한 번이면 끝난다.
- 이게 트라이를 쓰는 이유다: 맵이면 요약 해시를 만들 때마다 전체를 다시 해싱해야 하지만, 트라이는 바뀐 경로만 다시 계산한다. 검증에 필요한 건 상태 전체가 아니라 커밋먼트 하나다.
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가 일회용 작업 사본이라는 성질이 여기서 힘을 발휘한다.
오늘 정리와 다음 주 주제
메모리(StateDB) → 트라이(MPT) → 디스크. tx마다 메모리, 블록마다 트라이, 그리고 저장
쓰기는 dirty(tx) → pending(블록) → 트라이로 승격되고, origin은 따로 노는 읽기 캐시다
슬롯 하나가 바뀌면 상태 루트까지 연쇄로 바뀌고, 검증은 32바이트 비교 한 번으로 끝난다
5주차 · 블록 조립: 빌더는 이 일회용 StateDB 위에서 어떻게 tx를 골라 블록을 채울까. 3주차에서 "여기서 꺼내진다"까지만 보고 넘긴 miner 패키지를 연다.