---
id: 22
title: "스택 기계 해부: 256비트 워드, 1024 스택 깊이와 실행 루프"
slug: 0-4-stack-machine-anatomy
date: 2026/09/20
summary: "EVM은 레지스터가 없는 스택 기계입니다: 256비트 워드 크기, 1024개 스택 깊이, JUMPDEST로만 점프할 수 있는 프로그램 카운터를 갖습니다. 인출, 해독, 실행이라는 이 루프는 스택 언더플로와 오버플로가 왜 gas를 전부 태우는지, 그리고 스택 기계가 레지스터 기계에 비해 무엇을 얻고 무엇을 치렀는지 설명합니다."
keywords: EVM 스택 기계,256비트 워드,스택 오버플로,프로그램 카운터,DUP와 SWAP
heroImage: /images/articles/photos/0-4-stack-machine-anatomy.jpg
---

`0x6001600201`이라는 다섯 바이트는 EVM에서 한 가지 일을 합니다. 1과 2를 더해 스택 꼭대기에 3을 남깁니다. 어떤 레지스터도 참조하지 않고 결과를 어디에 둘지도 쓰지 않았으며, 두 피연산자는 스택 위에서 암묵적으로 자리를 잡고 결과도 스택으로 돌아갑니다. EVM의 모든 산술과 제어 흐름이 이 모델 위에 세워져 있습니다.

앞선 글들은 상태 기계와 계정을 계속 다뤘습니다. 이번 글은 한 겹 안쪽을 봅니다. 이 기계가 무엇으로 이루어져 있는지, 한 번의 실행 루프가 어떻게 돌아가는지, 256비트 워드 크기와 1024개 스택 깊이는 어디서 왔는지, 그리고 이 스택 기계 설계가 무엇을 얻고 무엇을 치렀는지입니다.

## 256비트 워드: 산술이 아니라 암호학에 맞춘 선택

옐로 페이퍼의 워드 크기에 대한 설명은 한 문장뿐입니다. 기계 워드 크기, 즉 스택 요소의 폭은 256비트이며, 이 선택은 Keccak-256 해시와 타원 곡선 연산을 편하게 하기 위한 것입니다. 풀어 보면 구체적인 이유가 세 가지입니다.

Keccak-256의 출력은 256비트라서 해시 값과 스토리지 키를 자르거나 늘릴 필요가 없습니다. 이더리움은 secp256k1 위의 ECDSA로 서명하며, 개인키와 서명 구성 요소가 256비트 공간에 들어갑니다. EVM 쪽의 대응 기능은 ecrecover가 제공하는데, 0x01 주소에 있는 프리컴파일 컨트랙트이고 가격은 3000 gas입니다. 주소는 160비트여서 256비트 워드에 넣으면 자연스럽게 정렬됩니다.

흔히 간과되는 정밀도 문제가 하나 있습니다. 이더리움이 쓰는 Keccak-256은 NIST가 표준화한 이후의 SHA3-256이 아닙니다. 둘은 출력 비트 폭이 같고 치환 함수도 같지만, 패딩의 도메인 구분 바이트가 다릅니다. 원래 Keccak은 0x01을, SHA3-256은 0x06을 씁니다. 해시 검증을 할 때는 Keccak-256이라고 명확히 표기한 라이브러리를 골라야 하며, SHA3-256을 쓰면 완전히 다른 결과가 나옵니다.

워드 폭의 대가도 분명합니다. 모든 산술은 모듈로 2^256에서 이루어지고, 오버플로는 조용히 감싸 돌며 예외를 내지 않습니다. 불리언 값 하나도 32바이트를 온전히 차지합니다. calldata와 스토리지 슬롯은 32바이트로 정렬되므로 짧은 타입은 인코딩 계층에서 패킹해야 하며, Solidity의 storage packing이 하는 일이 바로 이것입니다(같은 시리즈의 0.19 "Storage Layout: Solidity 상태 변수가 slot에 놓이는 방식" 참고). 감싸 도는 의미론은 역사적으로 수많은 정수 오버플로 취약점의 근원이기도 합니다. Solidity 0.8부터는 기본으로 검사를 삽입하고 Panic(0x11)로 리버트하는데, 이는 언어 차원의 보완이며 EVM 자체는 바뀌지 않았습니다.

## 기계 상태: 스택, 메모리, 프로그램 카운터가 각각 맡는 것

옐로 페이퍼는 기계 상태를 6-튜플 `μ = (g, pc, m, i, s, o)`로 씁니다. 사용 가능한 gas, 프로그램 카운터, 메모리 내용, 현재 활성 메모리 워드 수, 스택 내용, 그리고 반환 데이터 버퍼입니다. 네 가지 구성 요소의 역할 분담은 나란히 볼 수 있습니다:

| 구성 요소 | 주소 지정과 단위 | 존속 범위 | 관련 gas 비용 |
|---|---|---|---|
| 스택 stack | 꼭대기만 보이며, 항목당 256비트, 상한 1024개 | 단일 호출 프레임 | 명령어 자체의 2~3 gas |
| 메모리 memory | 바이트 단위 주소 지정, 확장은 32바이트 워드를 단위로 함 | 단일 호출 프레임, 새 프레임은 전부 0에서 시작 | `3a + ⌊a²/512⌋`, a는 활성 워드 수 |
| 프로그램 카운터 PC | 코드의 바이트 오프셋 | 단일 호출 프레임 | JUMP 8 gas, JUMPI 10 gas, JUMPDEST 1 gas |
| gas 카운터 | 사용 가능한 gas, 음이 아닌 정수 | 트랜잭션 전체, 호출 프레임 사이에서 호출 인자에 따라 남은 gas를 넘김 | 명령어마다 수수료표에 따라 차감 |

스택(stack)은 유일한 암묵적 피연산자 영역입니다. 꼭대기만 노출합니다. 대부분의 명령은 꼭대기에서 인자를 꺼내고 결과를 다시 꼭대기에 밀어 넣으며, 중간 요소는 명령에서 보이지 않습니다. 상한은 1024개이고 항목당 256비트입니다.

메모리(memory)는 바이트 단위로 주소를 지정하고 모든 위치가 처음에는 0이며, MLOAD/MSTORE(32바이트 워드 단위로 읽고 씀)와 MSTORE8(1바이트를 씀)로 접근할 수 있습니다. 비용은 동적입니다. 확장은 활성 워드 수로 과금되고, 옐로 페이퍼가 제시한 공식에 따르면 a개 워드를 차지할 때 총 메모리 비용은 `3a + ⌊a²/512⌋` gas이며, 이차항 때문에 매우 큰 오프셋에 접근하면 곧바로 gas가 바닥납니다. 그래서 EVM 메모리에는 공짜 대형 배열이 없고 "초기화되지 않은 데이터를 읽는" 문제도 없습니다. 쓰지 않은 위치는 항상 0입니다.

프로그램 카운터(program counter, PC)는 코드에서 다음 명령의 바이트 오프셋입니다. 제어 흐름은 JUMP/JUMPI로만 바꿀 수 있고, 옐로 페이퍼는 합법적 점프 대상 집합을 코드에서 JUMPDEST 명령이 나타나는 위치로 정의합니다. 따라서 대상 집합은 정적으로 열거할 수 있고, 실행 중에 임의 주소를 계산해 건너뛰는 것은 불가능합니다. 이 제약이 오프라인 제어 흐름 분석이 성립하는 토대입니다.

코드 자체는 스택, 메모리, 스토리지에 들어 있지 않습니다. 옐로 페이퍼는 이 기계가 폰 노이만 구조를 따르지 않는다고 분명히 밝히며, 코드는 전용 명령으로만 접근할 수 있는 별도의 가상 ROM에 보관됩니다. 따라서 배포된 컨트랙트 코드는 스스로에 의해 다시 쓰이지 않고, 실행 도중 명령 흐름이 교체될 가능성도 없습니다. 스토리지와 메모리는 가변적이지만 코드는 그렇지 않습니다.

## 인출, 해독, 실행: 한 번의 루프

옐로 페이퍼는 "현재 실행할 명령"을 조각별 함수로 정의합니다. 프로그램 카운터가 코드 길이보다 작으면 명령은 그 위치의 바이트이고, 그렇지 않으면 명령은 STOP과 동등합니다. 즉 코드 끝을 넘겨 읽는 것은 오류가 아니며, 명세는 이런 방식으로 자연스러운 종료를 정의합니다.

명령 하나가 실행되려면 먼저 세 가지를 알아야 합니다. 얼마나 많이 팝하는지(δ), 얼마나 많이 푸시하는지(α), 그리고 gas를 얼마나 쓰는지(비용 함수 C)입니다. 이 세 값은 명령이 스스로 정하며 오프코드 표의 행에 적혀 있습니다. 그래서 루프는 아래처럼 쓸 수 있습니다. substate, 액세스 리스트, gas 환불은 생략했습니다:

```python
# 단순화한 실행 루프, 명세의 판정 순서를 유지
pc, gas, stack, memory = 0, gas_limit, [], bytearray()

while True:
    # 인출: 코드 끝을 넘어가면 STOP과 동등
    op = code[pc] if pc < len(code) else STOP
    # 해독: 표를 조회해 팝 수, 푸시 수, 비용을 얻음
    delta, alpha, cost = OPCODE_TABLE[op]
    # 검증은 실행보다 먼저: gas 부족, 스택 부족, 스택 오버플로는 모두 예외 정지
    if gas < cost or len(stack) < delta or len(stack) - delta + alpha > 1024:
        raise ExceptionalHalt()
    gas -= cost
    # 실행: 스택 꼭대기에서 피연산자를 꺼내 결과를 다시 꼭대기에 푸시
    args = [stack.pop() for _ in range(delta)]
    stack.extend(dispatch(op, args, memory, pc))
    # PC 전진; PUSH 계열 명령은 바로 뒤의 즉시값도 건너뛰어야 함
    pc += 1 + immediates(op)
```

주석은 틀리기 쉬운 세 곳에 대응합니다. 경계를 넘는 인출 의미론, 검증이 실행보다 먼저 와야 한다는 점, PUSH의 즉시값이 코드 공간을 차지한다는 점입니다.

마지막 항목은 풀어서 설명할 필요가 있습니다. PUSH1부터 PUSH32까지는 상수를 오프코드 바로 뒤에 인코딩합니다. 예를 들어 `PUSH1 0x2a`는 두 바이트를 차지합니다. 여기서 직관에 어긋나는 결과가 나옵니다. 바이트코드의 모든 바이트가 명령은 아니며, 점프 대상 스캔은 즉시 데이터를 식별해 건너뛰어야 합니다. 그렇지 않으면 상수 안의 어떤 바이트를 JUMPDEST로 잘못 판정할 수 있습니다.

`60 2a 60 5b 56`이라는 다섯 바이트를 펼쳐 보면 더 분명합니다. `60 2a`는 즉시값을 가진 명령입니다. `60 5b`의 두 번째 바이트는 상수 0x5b이고, 그 값은 JUMPDEST의 오프코드와 완전히 같지만 명령은 아닙니다. `56`이야말로 JUMP입니다. 합법적 점프 대상을 스캔할 때는 먼저 0x60을 식별한 뒤 바로 뒤의 한 바이트를 건너뛰어야 합니다. 규칙 자체는 복잡하지 않습니다. 다만 제어 흐름 분석을 하는 모든 구현이 PUSH의 길이를 정확히 계산해야 하며, 그렇지 않으면 대상 집합이 상수 바이트로 오염됩니다.

이는 왜 임의 오프셋으로의 점프를 허용하는 대신 전용 0x5b를 점프 표식으로 두었는지도 설명합니다.

## 스택 언더플로와 오버플로: 두 가지 예외, 같은 결말

옐로 페이퍼의 예외 정지 판정 함수 Z는 실행을 즉시 중단시키는 모든 조건을 나열합니다. 그중 스택과 관련된 두 가지는 다음과 같습니다. 스택 안의 요소가 그 명령이 팝할 개수보다 적은 경우, 즉 언더플로(stack underflow)입니다. 실행 후 스택 높이가 1024를 넘는 경우, 즉 오버플로(stack overflow)입니다.

둘은 같은 길을 갑니다. 예외 정지, 남은 gas 전부 소모, 이번 호출 스택 프레임 안의 상태 변경 전부 폐기입니다. EIP-3855의 명세 테스트 벡터는 깔끔한 대조를 제공합니다. 연속된 PUSH0 1024개는 성공하고, 연속된 1025개는 스택 오버플로로 중단됩니다.

다른 종류의 "실패"와 구분해야 합니다. REVERT 명령(0xfd, Byzantium부터, EIP-140)도 상태를 되돌리지만 남은 gas를 소모하지 않고, 메모리의 바이트 일부를 오류 데이터로 호출자에게 반환할 수도 있습니다. Solidity의 require 실패는 대개 REVERT로 컴파일되어 오류 메시지를 호출자에게 되돌려 주고 남은 gas도 소모하지 않습니다. 반면 스택 언더플로는 강성 예외이고, 디버깅할 때 gas 고갈과 반환 데이터 없음으로 나타납니다. 디버깅 경험상 트랜잭션이 gas를 다 태우고 반환 데이터도 얻지 못하는 흔한 원인은 바이트코드가 강성 예외를 밟는 것이며, 계산량 자체가 큰 것은 아닐 수 있습니다.

경계도 분명히 써 둡니다. REVERT 자체의 gas가 부족하거나 실행 중 스택 언더플로를 만나면, 그것은 보통 예외로 퇴화해 마찬가지로 gas를 전부 소모합니다.

## 스택 기계의 이득: 구현 복잡도를 최소로 줄인다

ethereum.org의 설명에 따르면 스택 구조는 구현하기 쉬워 오류와 보안 취약점이 생길 가능성이 더 낮기 때문에 가상 머신의 우선 선택 아키텍처입니다. 이 이득은 몇 가지로 나눌 수 있습니다.

디코더가 극도로 단순합니다. 오프코드가 1바이트이고 피연산자의 위치는 스택 순서가 암묵적으로 결정하므로, 바이트코드에 명령마다 레지스터 번호를 인코딩할 필요가 없습니다. PUSH 계열 명령이 즉시값을 가진다는 점을 빼면 명령 길이가 거의 고정이라 해독은 표를 한 번 조회하는 일입니다.

명세에는 레지스터 할당이라는 계층이 없습니다. 할당 전략은 원래 컴파일러의 자유지만, 일단 가상 머신 명세에 들어오면 모든 구현이 비트 단위로 일치해야 하는 동작이 됩니다. 스택 기계는 "값을 어디에 두는가"를 명세에서 지워 버리므로, 합의가 고정해야 하는 상태 기술이 작아집니다.

스택 위 연산의 의미론은 거의 항목별로 독립적으로 정의되고, gas 비용을 오프코드에 바로 붙일 수 있으며, 정적 분석과 퍼징, 형식 검증의 탐색 공간도 더 작습니다. EVM이 여러 독립 구현(geth, revm, evmone 등)을 가지면서 바이트 수준 일치를 유지할 수 있는 것도 그 이유 중 하나입니다.

## 스택 기계의 대가: DUP, SWAP과 임의 접근 불가

스택이 꼭대기만 노출한다는 대가는 바이트코드의 명령 수로 드러납니다.

꼭대기에 가까운 요소를 쓰려면 DUP1부터 DUP16까지로 그것을 꼭대기로 복사하거나, SWAP1부터 SWAP16까지로 꼭대기로 바꿔 올릴 수 있습니다. 열여섯이 강한 상한입니다. 복사하려는 요소가 17번째 층에 있으면 먼저 SWAP16 같은 것으로 복사 가능한 범위로 끌어올린 뒤 계속 조작해야 하고, 스택이 깊을수록 옮기는 사슬이 길어집니다. 같은 계산이 레지스터 기계에서는 흔히 한 명령으로 끝나지만, 여기서는 푸시, 복사, 교환, 팝의 한 줄로 펼쳐집니다.

구체적인 장면을 하나 들어 봅니다. 컨트랙트가 `f(a, b, c, d)`를 계산해야 하는데 d가 스택의 4번째 층에 있습니다. 레지스터 기계는 d가 있는 레지스터를 곧바로 참조할 수 있습니다. 스택 기계에서는 컴파일러가 미리 d를 복사해 꼭대기에 더 가까운 곳에 두거나, 호출 전에 SWAP 계열로 끌어올리고 사용한 뒤 다시 되돌려 놓아야 합니다. Solidity 컴파일러가 함수 인자가 많을 때 이런 재배치 명령을 대량으로 생성하는 이유가 여기에 있습니다.

컴파일러는 또 스택 균형을 유지해야 합니다. 각 기본 블록이 끝날 때 스택 높이가 예측 가능해야 하며, 그렇지 않으면 점프 이후의 코드가 피연산자를 찾을 수 없습니다. 그래서 Solidity 컴파일 시점에는 스택 스케줄링을 따로 해야 하고, 인자와 지역 변수가 많을 때 DUP, SWAP, POP을 삽입해 피연산자를 옮깁니다. 이런 명령 자체는 비싸지 않고 대부분 3 gas 등급이지만, 인터프리터의 실행 경로를 길게 만듭니다.

스택 기계가 레지스터 기계보다 무엇을 더 치르는지는 JVM에서 간접적인 참조를 찾을 수 있습니다. Davis 등은 JVM 바이트코드를 가상 레지스터 기계로 번역해 실행 명령 수가 줄고 바이트코드 인출 횟수가 늘어나는 맞바꿈을 보고했습니다(Davis 등, 2003). 이 대조는 JVM에서 온 것이라 EVM에 그대로 옮길 수는 없습니다. 그것이 측정한 것은 인터프리터 실행의 디스패치 비용인데, EVM의 gas 가격 책정은 인터프리터 비용 대부분을 이미 외부화했기 때문입니다. 지지할 수 있는 방향성 판단은 하나뿐입니다. 같은 계산이라면 스택 기계는 보통 더 많은 실행 명령을 필요로 하고, 레지스터 기계는 더 많은 인출로 더 적은 실행 명령을 삽니다.

EVM 설계자들은 이 대가를 받아들이고 구현 단순성과 명세의 확정성을 얻었습니다. 최근에는 작은 증분 개선도 있었습니다. 예를 들어 PUSH0(EIP-3855, Shanghai)는 1바이트, 2 gas 명령 하나로 2바이트, 3 gas인 `PUSH1 0x00`을 대체했습니다.

## 이 설계가 어떤 경우에 부담이 되는가

표현식 중첩이 깊고 함수 인자가 많을 때는 스택 재배치 명령의 비중이 올라가고, 컨트랙트 실행의 gas 소모도 함께 높아집니다. 비트 폭 처리에서 EVM에는 타고난 8비트, 32비트, 64비트 타입이 없고, 모든 것을 명시적으로 자르거나 마스킹해야 하므로 다른 언어에서 이식할 때 각별히 주의해야 합니다.

스택 깊이는 1024이지만, 여기의 1024는 흔히 혼동되는 또 다른 1024와 같은 것이 아닙니다. 옐로 페이퍼는 CALL/CREATE의 정의에서 호출 깊이도 1024로 제한합니다. 전자는 단일 프레임 안의 피연산자 수를 제한하고, 후자는 호출 체인 길이를 제한합니다. 전자를 밟으면 프로그램 오류고, 후자를 밟으면 보통 재귀를 잘못 쓴 것입니다.

경계도 하나 그어 둡니다. 스택 기계는 병렬 실행의 장애물이 아닙니다. 병렬화가 다루는 것은 전역 가변 상태에서의 읽기/쓰기 충돌이고, 피연산자가 스택의 어느 위치에 있는지는 스택 순서가 암묵적으로 정하는 문제라 충돌 감지나 실행 결정성과 아무 관계가 없습니다. 진짜 제약은 상태 계층에 있으며, 뒤의 몇 편에 속합니다.

## 출처

- Ethereum Yellow Paper(기계 상태 6-튜플, 스택 상한 1024, 메모리 비용 공식, JUMPDEST 대상 집합, 예외 정지 함수 Z, gas 수수료표와 오프코드 표, ECREC 프리컴파일 가격): https://ethereum.github.io/yellowpaper/paper.pdf
- ethereum.org, Ethereum Virtual Machine (EVM)(스택 깊이 1024, 256비트 워드와 Keccak-256 / secp256k1의 관계): https://ethereum.org/developers/docs/evm/
- ethereum.org, Understanding the Yellow Paper's EVM Specifications(스택 기계는 구현이 쉬워 결함이 적음, 256비트 워드를 고른 이유): https://ethereum.org/developers/tutorials/yellow-paper-evm/
- Keccak Team, Keccak specifications summary(SHA3-256의 접미 비트 0x06과 패딩 과정): https://keccak.team/keccak_specs_summary.html
- Keccak Team, The Keccak reference, version 3.0(원래 Keccak의 다중 속도 패딩 pad10*1): https://keccak.team/files/Keccak-reference-3.0.pdf
- NIST, FIPS 202(SHA-3 패딩 0x06): https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.202.pdf
- EIP-3855, PUSH0 instruction(0x5f, 2 gas, 1024와 1025개 PUSH0의 테스트 벡터): https://eips.ethereum.org/EIPS/eip-3855
- EIP-140, REVERT instruction(되돌리지만 남은 gas를 전부 소모하지는 않음, 그리고 예외로 퇴화하는 경계): https://eips.ethereum.org/EIPS/eip-140
- Davis, Beatty, Casey, Gregg, Waldron, The Case for Virtual Register Machines, 2003(JVM 바이트코드를 가상 레지스터 기계로 번역, 실행 명령 수 감소와 바이트코드 인출 횟수 증가 보고): https://mural.maynoothuniversity.ie/id/eprint/10191/1/KC-Case-2003.pdf
- Solidity 0.8.0 Release Announcement(산술 연산 기본 검사, Panic(0x11)로 리버트): https://www.soliditylang.org/blog/2020/12/16/solidity-v0.8.0-release-announcement/
- ethereum/execution-spec-tests(PUSH0와 스택 오버플로의 명세 테스트): https://github.com/ethereum/execution-spec-tests

## 더 읽기

- 관련 글: ["EVM 호환성의 실제 의미: 바이트코드, 프리컴파일, JSON-RPC, 툴링"](/ko/blog/evm-compatibility-explained)
- 관련 글: ["Bitroot 낙관적 병렬화: 감지, 재실행, 결정성"](/ko/blog/bitrootevm-)
- 다음 글: ["단일 스레드 EVM이 TPS를 제한하는 이유: 혼잡의 역사와 실행 모델"](/ko/blog/evm-single-thread-bottleneck)
