---
id: 26
title: "단일 스레드 전역 상태: EVM 성능 병목의 설계 원점"
slug: 0-12-single-thread-global-state
date: 2026/09/29
summary: 이더리움 옐로 페이퍼는 블록 수준 상태 전이를 중첩 호출로 적었고, 그 결과 평가 순서는 명세의 일부가 됩니다. 트랜잭션을 하나씩 직렬로 처리하는 의미의 출처, 읽기/쓰기 집합 충돌을 미리 판정할 수 없는 이유, 결정성과 검증 단순성이 무엇을 얻어 냈는지, 그리고 이 설계 원점에 어떤 방식이 정면으로 응답하고 있는지가 이어지는 글의 주된 흐름입니다.
keywords: 단일 스레드EVM,전역 상태,읽기/쓰기 집합 충돌,결정성,블록 수준 액세스 리스트
heroImage: /images/articles/photos/0-12-single-thread-global-state.jpg
---

옐로 페이퍼 2절은 이더리움을 트랜잭션이 구동하는 상태 기계로 설명합니다. 그 형식화에서 쓰는 기호 약속은 이렇습니다. σ는 월드 스테이트, T는 트랜잭션 하나, Υ는 트랜잭션 수준 상태 전이 함수로 트랜잭션 하나를 상태에 적용합니다. B는 블록, Π는 블록 수준 상태 전이 함수이며, 블록 안의 트랜잭션은 순서대로 T₀, T₁……로 적습니다. 이 표기를 바탕으로 옐로 페이퍼는 두 개의 식을 제시합니다. 트랜잭션 하나의 상태 전이는 `σ_{t+1} ≡ Υ(σ_t, T)`이고, 블록 수준 전이는 `Π(σ, B) ≡ Υ(Υ(σ, T_0), T_1)…`입니다. 두 번째 식은 잠시 더 들여다볼 만합니다. 블록 전체의 실행을 함수의 중첩 호출로 적었고, 두 번째 트랜잭션의 입력은 첫 번째를 실행한 뒤의 상태입니다.

따라서 평가 순서는 명세의 일부이고, 클라이언트가 스스로 고를 여지는 없습니다. 물어야 할 것은 이렇습니다. 왜 이렇게 정의할 수밖에 없는지, 두 트랜잭션을 동시에 평가하도록 허용하면 무엇을 잃는지, 그리고 이 선택이 어떻게 EVM 처리량을 단일 스레드에 묶어 두는지입니다. 여기서는 이 층, 즉 설계 원점만 다룹니다.

## 순서는 명세에 적혀 있고, 클라이언트의 스케줄링 자유로 남겨져 있지 않다

옐로 페이퍼의 서술 방식은 실행 계층이 할 일을 한정합니다. 부모 상태와 트랜잭션 목록이 주어지면 상태 전이 함수를 반복 적용해 하나의 상태를 얻습니다. 블록 헤더의 유효성 조건 가운데 하나는 이 상태를 트라이로 접어 얻은 루트가 블록 헤더의 stateRoot와 같아야 한다는 것입니다. 트랜잭션 하나가 실행을 마치고 상태가 완전히 기록된 뒤에야 다음 트랜잭션이 상태를 읽기 시작한다는 이 선후 관계는 정의 자체에 속하며, 최적화할 여지를 남기지 않습니다.

실행 계층에는 정렬 권한도 없습니다. 트랜잭션 순서는 합의 계층이 만들어 낸 트랜잭션 목록이 정하고, 실행 계층은 주어진 순서대로 평가할 뿐입니다. 이 역할 분담 덕분에 합의 계층은 "인터리빙 실행의 결과"에 대해 실행 계층과 합의할 필요가 없고, "어떤 트랜잭션이 담겼고 순서가 어떤지"만 합의하면 됩니다. 어떤 노드든 순서를 바꿔 같은 블록을 재생하면 얻는 루트가 어긋나고, 그 결과는 곧바로 오류로 판정됩니다.

```python
# 블록 수준 상태 전이: 두 번째 트랜잭션의 입력 상태가 곧 첫 번째 트랜잭션의 출력 상태
state = parent_state
for tx in block.transactions:
    state = apply_transaction(state, tx)      # Υ(σ, T)
state = apply_withdrawals(state, block.withdrawals)  # 출금은 모든 트랜잭션 이후에 실행
assert trie_root(state) == block.header.state_root
```

## 순서의 강한 제약: nonce, 잔액과 누적 Gas

컨트랙트 코드가 해석되기 전에 이미 프로토콜 계층이 순서를 요구합니다. 옐로 페이퍼 6절이 나열한 트랜잭션 초기 유효성 검사 가운데 하나는 트랜잭션 nonce가 발신자 계정의 현재 nonce와 같아야 한다는 것입니다. 따라서 같은 발신자가 보낸 두 트랜잭션에는 프로토콜이 강제하는 전순서가 있고, 뒤의 것을 앞당기면 트랜잭션이 다른 결과를 내는 것이 아니라 곧바로 무효로 판정됩니다. 또 다른 검사는 발신자 계정에 배포된 코드가 없을 것을 요구하는데(EIP-3607), 이것 역시 트랜잭션 하나가 실행된 뒤의 상태를 읽는 일입니다.

잔액과 Gas도 같은 층에서 순서를 고정합니다. 모든 트랜잭션은 실행 전에 발신자 잔액이 선지급액을 감당할 만큼 충분한지 검사받는데, 그 잔액은 앞선 트랜잭션이 실행된 뒤의 결과입니다. 블록의 Gas 상한은 블록 수준 제약이고, 영수증의 누적 Gas 사용량은 앞선 트랜잭션들이 더해진 값입니다(옐로 페이퍼는 블록 영수증 부분에서 n번째 트랜잭션의 누적값을 이전 누적값과 이번 사용량의 합으로 적습니다). 뒤 트랜잭션이 쓸 수 있는 Gas는 앞에서 이미 얼마를 썼는지에 달려 있습니다.

즉 컨트랙트 스토리지를 전혀 고려하지 않더라도 "앞선 상태와 뒤의 상태"라는 전제는 이미 프로토콜 규칙 안에서 성립합니다. 컨트랙트 실행은 이렇게 이미 꿰어 놓은 순서 위에 의존성을 더 얹을 뿐입니다.

## 충돌은 어디서 오는가: 읽기/쓰기 집합이 교차하고, 그것도 사후에야 알 수 있다

두 트랜잭션을 동시에 실행할 수 있는지 판단하는 표준적인 방법은 두 트랜잭션의 읽기/쓰기 집합(Read/Write Set)을 비교하는 것입니다. 읽기/쓰기 집합은 한 트랜잭션이 읽거나 쓰는 상태 위치의 집합입니다. 한쪽이 쓴 위치를 다른 쪽이 읽거나 쓰기만 해도 실행 순서가 결과를 좌우하며, 이런 트랜잭션을 충돌이라고 부릅니다.

충돌은 실제 워크로드에서 매우 흔합니다. 자동 시장 조성자(Automated Market Maker, AMM) 풀의 스왑 트랜잭션은 준비금과 가격 누적값을 다시 쓰고, 바로 뒤따르는 대출 청산은 같은 풀의 가격을 읽어야 하므로, 두 트랜잭션의 순서가 청산이 발동하는지, 손실을 누가 지는지를 곧바로 결정합니다. 이런 의존성은 누가 일부러 만들 필요가 없습니다. 컨트랙트끼리 상태를 공유하기만 하면 나타납니다.

더 골치 아픈 것은 실행기가 읽기/쓰기 집합을 볼 수 없다는 점입니다. EVM은 트랜잭션이 임의 주소를 호출하고 임의 스토리지 슬롯을 읽고 쓰도록 허용하는데, 호출 대상과 슬롯 번호 모두 런타임에야 계산될 수 있습니다.

```solidity
// 호출 대상과 매개변수는 런타임에야 정해지고, 정적 분석으로는 읽기/쓰기 집합을 낼 수 없음
function dispatch(bytes32 poolId, bytes calldata payload) external {
    address pool = pools[poolId];        // 대상은 스토리지에서 오며, 임의의 등록된 컨트랙트일 수 있음
    (bool ok, ) = pool.call(payload);    // 어떤 슬롯을 건드리는지는 pool과 payload에 달려 있음
    require(ok, "call failed");
}
```

매핑 타입의 슬롯 번호는 키와 슬롯 위치를 이어 붙인 뒤 keccak256을 취해 얻으며, 키는 런타임 입력일 수 있고 슬롯 위치도 컴파일 시점에 고정된다고 볼 수 없습니다. 그래서 "이 트랜잭션이 어떤 슬롯을 건드릴지"는 트랜잭션 실행 전에 바이트코드에서 읽어낼 수 없고, 실제 실행을 관측해야만 알 수 있습니다. EIP-7928은 동기 부분에서 이 점을 아주 노골적으로 적습니다. 어떤 주소와 스토리지 슬롯에 접근할지 미리 알지 못한다면 트랜잭션 실행은 병렬화될 수 없습니다.

## 단일 스레드가 사 온 것: 결정성, 그리고 검증이 곧 재생

이 설계가 주는 이득은 구체적입니다. 모든 노드가 같은 순서로 평가해 같은 상태 루트를 얻으므로, 검증자는 스케줄러를 신뢰할 필요도 없고 인터리빙 실행의 여러 가능성을 따질 필요도 없이, 같은 규칙으로 같은 블록을 재생해 블록 헤더의 stateRoot와 비교하기만 하면 됩니다. 이 설계가 서로 다른 구현, 다른 언어, 다른 하드웨어의 클라이언트들을 같은 값에 합의하게 만드는 힘은 불확실성을 "입력 더하기 순서"로 압축한 데서 나옵니다.

실패 의미론도 순서에 의존합니다. REVERT와 예외 롤백은 실행 중 기록한 상태 스냅샷을 이용해 층층이 되돌리며, 롤백의 단위는 호출 스택과 트랜잭션입니다. Gas 환불, `cumulativeGasUsed`, 영수증의 상태 비트는 모두 트랜잭션 순서가 정해져 있을 때만 유일한 의미를 가집니다. 여러 트랜잭션이 인터리빙으로 진행되도록 허용하면 "먼저 실행된 부분은 롤백하고 나중에 실행된 부분은 남긴다"를 정의할 완전히 새로운 의미론이 필요합니다.

그래서 트랜잭션을 하나씩 직렬로 처리하는 것은 분명한 트레이드오프입니다. 하지만 그것이 얻어 오는 것(네트워크 전체가 검증할 수 있는 결정성, 별도의 정확성 증명이 필요 없는 검증 방식)은 퍼블릭 체인의 존재 근거이며, 이를 "구현이 게을러서"라고 설명하는 것은 성립하지 않습니다.

## 클라이언트는 실제로 멀티코어를 다 쓰지만, 모두 의미론 밖이다

실제 엔지니어링 현장을 보면 주류 클라이언트가 기계를 놀리는 것은 아닙니다. Reth를 예로 들면, 트랜잭션 서명 복구는 스레드 풀에 맡겨 병렬로 실행합니다. 상태 갱신의 해싱과 상태 루트 구성은 병렬 희소 트라이 작업에 맡기고, 여러 worker가 증명 생성과 트라이 노드 읽기를 나눠 맡으며, 작업이 시간 초과되거나 실패하면 직렬 계산으로 되돌아갑니다. 블록 처리 경로에서는 별도의 스레드 풀에서 트랜잭션을 미리 실행해 이후의 정식 실행을 위해 캐시를 데웁니다.

정식 실행 자체는 여전히 순차적입니다. Reth의 블록 처리 흐름에서 블록 수준 액세스 리스트를 싣지 않은 기본 경로는 블록 순서대로 순차 EVM 실행을 하고, 상태 갱신을 데이터 스트림 형태로 상태 루트 작업에 넘깁니다. BAL을 실은 블록에는 병렬 실행 분기가 하나 더 있지만, 이는 Amsterdam 포크와 BAL의 존재에 의존합니다(뒤에서 다룰 블록 수준 액세스 리스트 경로입니다). 사전 실행은 캐시만 채우고 그 결과가 그대로 커밋되지는 않습니다. 이 경계는 설계 원점이 남긴 것입니다. 멀티코어는 서명 검증과 해싱, 증명 생성, 캐시 채우기를 가속할 수 있지만 "의미상 유일한 그 평가 사슬"을 줄여 주지는 못합니다.

곁들여 바로잡아 둘 표현이 하나 있습니다. 단일 스레드는 기계에서 코어 하나만 일한다는 뜻이 아니라, 의미상 평가 순서가 하나뿐이라는 뜻입니다. 확장의 핵심 질문이 코어 수가 아니라 "이 평가 사슬을 넓힐 수 있는가"에 있는 이유도 여기에 있습니다.

## 비용: 병렬성이 프로토콜 밖으로 넘어갔다

명세가 순서를 고정했으므로, 실행 계층이 멀티코어로 트랜잭션을 밀어붙이려면 명세 순서와 등가인 어떤 인터리빙을 찾아 그 결과를 같은 상태로 수렴시키는 것이 유일하게 합법적인 방향입니다. 이를 위해서는 읽기/쓰기 집합을 미리 알거나 먼저 발견해야 합니다. 트랜잭션을 보내는 쪽이나 블록 빌더가 미리 선언하거나, 런타임에 동적으로 추적해 충돌을 처리하는 것입니다. 전자는 부담을 프로토콜과 툴체인에 넘기고, 후자는 부담을 런타임과 롤백에 넘깁니다.

전역 상태라는 전제가 비용을 더 뚜렷하게 만듭니다. 상태는 모든 노드가 공유하는 하나의 트리이고, 트랜잭션마다 갱신이 리프에서 루트까지의 경로를 따라 노드를 다시 쓰므로, 상태 접근 자체가 임계 경로의 한 고리입니다. 디스크 I/O와 네트워크 대역폭은 수평으로 확장할 수 있지만, "상태를 가져오고, 결과를 계산하고, 상태를 다시 쓰고, 다음 트랜잭션의 상태를 다시 가져오는" 이 사슬은 그럴 수 없습니다.

## 정면 응답: 읽기/쓰기 집합을 미리 선언한다

이 원점에 대한 직접적인 응답은 빠진 정보를 채워 넣는 것입니다. EIP-7928이 제안한 블록 수준 액세스 리스트(Block-Level Access List, BAL)는 블록 헤더에 `block_access_list_hash` 필드를 새로 두고, 블록 실행 동안 접근한 모든 계정과 스토리지 위치, 그리고 그 실행 후 값을 기록합니다. 이 선언이 있으면 클라이언트는 디스크를 병렬로 읽고, 트랜잭션을 병렬로 검증하고, 상태 루트를 병렬로 계산할 수 있으며, 실행을 하지 않고 곧바로 상태를 갱신할 수도 있습니다.

비용도 제안서에 함께 적혀 있습니다. 액세스 리스트는 생성되어야 하며, 보통 블록 빌더가 실행 후에 만들어 내고 다른 노드가 그 정확성을 검증합니다. 트랜잭션 순서와 유일성, 결정성은 다시 규정되어야 하는데, 병렬화의 전제가 각 트랜잭션이 의존하는 위치가 이미 선언되어 있다는 것이기 때문입니다. EIP-7928 본문에 표시된 상태는 동료 검토 중이며 아직 확정되지 않았습니다. Glamsterdam 업그레이드는 BAL을 개발망 테스트 범위에 넣었고, 메인넷 활성화 시점은 정해지지 않았으며, 관련 진행 상황은 ethereum.org의 Glamsterdam 로드맵 페이지에서 볼 수 있고 개발망 세부 사항은 서드파티 추적 페이지가 유지합니다. 이것은 설계 원점을 없애지 않으며, 병렬 실행을 위해 검증이 필요한 사전 선언을 하나 덧붙였을 뿐입니다.

덧붙이자면 EIP-2930이 도입한 트랜잭션 수준 액세스 리스트는 선택 사항이고, 명세는 트랜잭션이 어떤 계정과 슬롯에 접근하는지 선언하도록 강제하지 않으므로, 의존할 수 있는 병렬화 전제를 만들지 못했습니다. EIP-7928이 이를 블록 수준으로 끌어올려 강제로 기록하려는 이유도 여기에 있습니다.

## 반례와 경계: 순서는 존재해도 충돌이 반드시 나타나지는 않는다

설계 원점을 분명히 설명하려면 그 경계도 짚어야 합니다. 순서는 강제되지만 충돌은 워크로드에 따라 달라집니다. 일괄 지급, 정산, 에어드롭 분배 같은 트랜잭션은 대부분 각자의 수신자 잔액만 건드려 읽기/쓰기 집합이 거의 겹치지 않으므로 이론상 높은 병렬성을 낼 수 있습니다. 반면 인기 있는 AMM 풀, 대출 시장의 청산, NFT 민팅 경쟁 같은 워크로드는 읽기/쓰기 집합이 크게 겹쳐 병렬화 여지가 충돌 자체에 잠식됩니다. 같은 실행 모델도 워크로드에 따라 성능 차이가 크므로, 병렬화 이득을 논할 때는 워크로드 프로필과 충돌률을 반드시 밝혀야 합니다.

또 하나의 경계는 결정성이 자리 잡는 지점에 있습니다. 병렬 실행이 결정성 요구를 없애지는 않습니다. 다만 반드시 유지해야 하는 대상을 하나의 실제 평가 순서에서, 그 순서와 직렬화 가능하게 동등한 실행 부류로 완화합니다. 충돌 감지와 검증, 선택적 재실행의 비용은 다시 처리량으로 되돌아오므로, 병렬화는 직렬 구간을 충돌 경로로 줄이는 것에 가깝지, 직렬을 완전히 제거하는 것이 아닙니다.

놓치기 쉬운 전제가 하나 더 있습니다. 실행 단계가 어떻게 병렬화되든 최종적으로는 같은 전역 상태 트리와 같은 루트로 수렴해야 합니다. 충돌 경로에 있는 트랜잭션은 명세 순서대로 재생해야 하고, 샤드 간 상태 접근에는 추가 조정이 필요합니다. 이는 뒤에서 상태 샤딩을 논할 때 피할 수 없는 제약이기도 합니다.

## 역할 분담: 원점은 이 글, 천장과 역사적 혼잡은 다른 글

여기서 답할 것은 "왜 트랜잭션을 하나씩 직렬로 처리해야 하고, 그 비용은 무엇인가"뿐입니다. 이 경계가 만드는 성능 천장이 얼마나 높은지, 2017년 이후의 혼잡이 그것을 어떻게 거듭 드러냈는지, Gas 상한 조정과 EIP-1559가 왜 실행 모델을 바꾸지 못하는지는 이미 발행된 "단일 스레드 EVM이 TPS를 제한하는 이유: 혼잡의 역사와 실행 모델"에서 다루는 범위이며, 여기서 그 데이터와 개혁 과정을 반복하지 않습니다. 두 글을 합치면 하나의 온전한 사슬이 됩니다. 먼저 원점을 밝히고, 다음으로 천장을 재는 것입니다.

## 출처

- [Ethereum Yellow Paper](https://ethereum.github.io/yellowpaper/paper.pdf)(2절의 상태 전이 함수(식 1)와 블록 수준 중첩 정의(식 2), 4장의 전체 유효성, 6절의 트랜잭션 초기 유효성(nonce, 잔액과 EIP-3607), 블록 영수증의 누적 Gas 정의(식 186), 부록 D.1의 증명 공간 설명)
- [EIP-7928: Block-Level Access Lists](https://eips.ethereum.org/EIPS/eip-7928)(Review, 2025-03-31 생성, 읽기/쓰기 집합을 모르면 병렬화할 수 없다는 동기, `block_access_list_hash` 필드, 순서와 결정성 요구, EIP-2930 액세스 리스트가 강제되지 않는 문제)
- [EIP-3607](https://eips.ethereum.org/EIPS/eip-3607)(배포된 코드를 가진 계정이 보낸 트랜잭션 거부)
- [ethereum.org: Glamsterdam](https://ethereum.org/roadmap/glamsterdam/)(BAL이 Glamsterdam 개발망 테스트 범위에 포함되었다는 진행 기준, 메인넷 시점 미정)
- [reth: stages 문서](https://github.com/paradigmxyz/reth/blob/main/docs/crates/stages.md)(SenderRecoveryStage와 ExecutionStage의 역할 구분)
- [reth sender_recovery.rs](https://github.com/paradigmxyz/reth/blob/main/crates/stages/stages/src/stages/sender_recovery.rs)(서명 복구가 rayon 스레드 풀에서 병렬 실행)
- [reth_trie_parallel::state_root_task 소스](https://github.com/paradigmxyz/reth/blob/main/crates/trie/parallel/src/state_root_task.rs)(상태 루트 작업이 실행 훅(직렬 실행에 해당) 또는 해시 처리된 갱신 스트림을 받음)
- [reth_engine_tree::tree::state_root_strategy](https://github.com/paradigmxyz/reth/blob/main/crates/engine/tree/src/tree/state_root_strategy/mod.rs)(희소 트라이 작업이 시간 초과되거나 실패하면 직렬 상태 루트 계산으로 폴백)
- [reth payload_validator 소스](https://github.com/paradigmxyz/reth/blob/main/crates/engine/tree/src/tree/payload_validator.rs)와 [payload_processor::prewarm 소스](https://github.com/paradigmxyz/reth/blob/main/crates/engine/tree/src/tree/payload_processor/prewarm.rs)(순차 EVM 실행에 병렬 사전 실행 워밍과 병렬 상태 루트 작업을 결합, BAL을 실은 경우의 병렬 실행 분기)

## 더 읽기

- 다음 글: ["단일 스레드 EVM이 TPS를 제한하는 이유: 혼잡의 역사와 실행 모델"](/ko/blog/evm-single-thread-bottleneck)
- 관련 글: ["병렬 실행의 세 가지 경로: 결정론적 스케줄링, 낙관적 OCC, 객체 모델"](/ko/blog/parallel-execution-approaches)
- 관련 글: ["낙관적 동시성 제어(OCC) 입문: 데이터베이스에서 온체인 실행까지"](/ko/blog/optimistic-concurrency-control-intro)
