WebAssembly가 왜 전형적인 스택 머신과는 다르며, 그 차이가 코드 작성과 최적화에 어떤 의미를 가지는지 살펴본다.
2026년 4월 27일Lobsters
모두가 Wasm이 스택 머신이라는 것을 알고 있다. 위키백과도 그렇게 말하고, 공식 Wasm 설계 명세도 그렇게 말한다. 나도 그렇게 생각했다.
직접 Wasm 코드를 쓰기 시작하기 전까지는 말이다. Wasm용으로 컴파일하는 것이 아니라, 명령어를 손으로 직접 작성하기 시작했을 때 나는 Wasm과 다른 모든 스택 기반 언어 사이에 이 주장을 오해의 소지가 있게 만드는 큰 차이가 존재한다는 사실을 알게 되었다.
조금 뒤로 물러나 보자. 도대체 스택 머신 이란 무엇일까?
고급 언어로 프로그램을 작성하고 있고, 어느 시점에 2 * 3 + 5 * 7을 계산하고 싶다고 하자. 저수준 언어에는 복합 표현식이라는 개념이 없다. 한 번에 한 연산만 수행할 수 있다. 따라서 두 번의 곱셈을 수행하고, 그 결과를 저장한 다음, 덧셈을 수행해야 한다.
x86 어셈블리 같은 많은 저수준 언어는 이런 단계를 다음과 같이 표현한다:
a = 2b = 3c = a * bd = 5e = 7f = d * eg = c + f이것을 레지스터 머신 이라고 부른다. 값과 임시 결과를 저장하는 데 모두 사용할 수 있는 변수들, 즉 레지스터 가 있고, 각 명령어는 var1 = var2 op var3 형태를 가진다.
반면 Forth나 Hex Casting 같은 다른 언어들은 이를 위해 스택 을 사용한다. 스택은 값의 순서를 유지한 채로 연속해서 저장할 수 있으므로, 이미 계산된 부분 표현식들을 다른 부분을 작업하는 동안 그대로 두어도 된다. 스택 기반 언어에서는 같은 계산이 다음과 같이 보인다:
push(2)push(3)mul() – 스택에서 마지막 두 값을 꺼내 그 곱을 다시 넣는다push(5)push(7)mul()add()두 프로그램 사이에 유사성이 있다는 점에 주목하자. 단계 수는 같고, 대응되는 단계는 같은 연산을 수행한다. 큰 차이는 스택 머신에서는 연산 대상 값이 프로그램의 순서 속에 암묵적으로 인코딩되는 반면, 레지스터 머신은 항상 인덱스를 명시적으로 인코딩한다는 점이다.
하지만 항상 길이가 줄어드는 무손실 압축이 존재하지 않는다는 것은 알고 있다. 그렇다면 인덱스를 암묵적으로 만드는 대가로 어떤 표현력이 사라질까? 단순한 표현식에서는 별로 크지 않다. 그러나 값이 재사용 될 때 차이가 분명해진다.
당신이 컴파일러이고, 다음 프로그램을 컴파일하라는 요청을 받았다고 하자:
x = 1 + 2 + 3 + 4
y = x * x * x
레지스터 머신에서는 다음과 같이 할 수 있다:
x를 평소처럼 계산한다)tmp = x * xy = tmp * x그러나 위에서 설명한 스택 머신은 같은 값을 두 번 가리키는 방법을 제공하지 않는다. mul은 항상 스택의 서로 다른 위치에 있는 두 값을 곱한다. 이 계산을 가능하게 하기 위해, 실제 스택 머신은 순수 계산 외에도 스택 조작 연산을 도입한다. 여기서 필요한 것은 dup인데, 이것은 스택 맨 위의 값을 복제한다:
x를 평소처럼 계산한다)dup() – 이제 스택에는 x, x가 있다dup() – 이제 스택에는 x, x, x가 있다mul() – 이제 스택에는 x, x*x가 있다mul() – 이제 스택에는 x*(x*x)가 있다레지스터 머신은 (x*x)*x를 계산했지만, 스택 머신은 x*(x*x)를 계산했다는 점을 눈치챘을지도 모른다. 곱셈에서는 두 결과가 같지만, 다른 연산에서는 달라질 수 있다. 이를 바로잡으려면 swap도 도입해야 한다. 이름 그대로, 스택 맨 위의 두 값을 서로 바꾼다:
x를 평소처럼 계산한다)dup() – 이제 스택에는 x, x가 있다dup() – 이제 스택에는 x, x, x가 있다mul() – 이제 스택에는 x, x*x가 있다swap() – 이제 스택에는 x*x, x가 있다mul() – 이제 스택에는 (x*x)*x가 있다실제로는 계산을 돕기 위해 더 많은 연산이 흔히 사용된다. over(뒤에서 두 번째 값을 맨 위로 복사), 2dup(두 값을 복제), drop(마지막 값 꺼내기), rot(뒤에서 세 번째 값을 맨 위로 이동) 등이 있다.
이 관점에서 보면, 스택 머신은 연산 과 그 연산이 작동하는 인덱스 를 분리한 것으로 볼 수 있다. 레지스터 머신은 언제나 인덱스를 인코딩하고 그것이 중복될 때 더 큰 비용을 치르는 반면, 스택 머신은 필요할 때만 인덱스를 인코딩하지만 더 많은 명령어 수를 대가로 치른다. 좀 멋있게 말하고 싶다면, 스택 머신은 레지스터 머신에 대한 엔트로피 부호화 압축을 구현한다고 할 수 있겠다.
위키백과가 WebAssembly와 비교하는 잘 알려진 스택 머신인 JVM을 보면, 사실상 정확히 이런 바이트코드 명령어 목록을 찾을 수 있다:
iaload, iastore, iconst.d2f, iadd.dup, dup_x1(즉 over), pop(즉 drop), swap.JVM은 순수한 스택 머신은 아니다. iload, istore 같은 지역 변수 접근 명령어도 있다. 하지만 이들을 사용하지 않고도 강력한 JVM 프로그램을 작성하는 것은 가능하며, javac는 대체로 자바 프로그래머가 명시적으로 만든 변수에 대해서만 이들을 사용한다.
이제 Wasm 명령어 집합을 보자:
i32.load, i32.store, i32.const.f32.demote_f64, i32.add.drop, 음, ???.꽤 흥미롭지 않은가? Wasm에는 인자를 받아 스택에 반환값을 놓는 명령어가 아주 많지만, 그것을 재배열할 수 있는 명령어는 거의 없다. 그리고 내가 보기에 drop은 함수 출력을 무시할 방법이 없게 되는 일을 막기 위해서만 존재한다.
순수한 Wasm이 할 수 있는 일은 거의 정확히 소스 코드에 쓰인 그대로 단순한 표현식을 평가하는 것뿐이다. 최적화 컴파일러는 새 변수를 도입하지 않고는 공통 부분식 제거를 수행하거나 expr^2를 expr * expr로 최적화할 수 없다. 조금이라도 비자명한 것이 필요해지는 순간, 변수를 꺼내 써야 한다. 그러면 결국 레지스터 머신이 되어 버리고, “스택 머신”이라는 환상은 무너진다.
내 생각에 Wasm을 바라보는 올바른 방법은, 연산이 복합 표현식으로 일반화된 레지스터 머신으로 보는 것이다.
바이너리 Wasm에서 표현식은 역폴란드 표기법으로 인코딩된다. 이것은 스택으로 평가할 수는 있지만 그저 인코딩 방식일 뿐이다. 예를 들어 텍스트 형식의 Wasm에서는 대신 LISP 비슷한 표기법으로 표현된다. 이것이 더 효율적이지도, 덜 효율적이지도 않다. 바이너리 Wasm도 접두 표기법을 사용했을 세계를 상상할 수 있으며, 그 영향은 크지 않았을 것이다. 내 추측으로는, 최적화를 하지 않는 인터프리터를 단순하게 만들기 위해 후위 표기법이 선호되었거나, 아마도 스택 기반 VM에 대한 경험이 동률을 깨는 요인이었을 것이다.
이 관점은 Wasm이 multi-value 확장을 얻기 전까지 제어 흐름 블록이 스택과 거의 상호작용할 수 없었다는 사실로도 더욱 뒷받침된다. if 전에 스택에 쌓은 값은 if 본문 안에서 접근할 수 없었고, if 본문도 오직 하나의 값만 반환할 수 있었기 때문에, if는 사실상 삼항 연산자에 불과했다. 심지어 소비자가 하나뿐인 값조차 지역 변수를 거쳐야 했다.
정말로 중요한 문제일까? 거의 모든 머신은 SSA로 변환할 수 있으니, 그 시점에서는 입력 형식은 고려 대상이 아니다. 그리고 스택 기반 구현의 단순함은 Wasm 보급에 좋은 점이었으리라고 생각한다. 하지만 스택 기반 VM에 대한 경험이 Wasm에 잘 그대로 옮겨지지는 않는다는 점을 강조하는 것은 타당하다고 본다. Wasm은 완전히 스택 머신은 아니기 때문이다.
이 글을 쓴 직후, 같은 문제를 다른 최적화 중심 시각에서 다룬 아주 멋진 글을 찾았다. 그것도 꼭 읽어 보길 바란다!