WebAssembly가 새로운 JVM이 되어가는 과정: 예외 처리를 이용한 기괴한 명목상 타입 구현기


15년 넘게 시스템 프로그래밍과 아키텍처를 다루면서 수많은 기술의 흥망성쇠를 지켜봤습니다. 그중에서도 WebAssembly(이하 Wasm)의 진화 과정은 꽤나 흥미롭습니다. 초창기 Wasm은 C나 C++ 코드를 브라우저에서 빠르게 실행하기 위한 단순하고 우아한 ‘Portable Assembly’였습니다. 하지만 최근 Wasm GC와 Exception Handling 제안이 표준에 편입되면서, Wasm은 우리가 알던 단순한 타겟 아키텍처가 아니라 거대한 가상 머신으로 변모하고 있습니다.

최근 Andy Wingo의 블로그 글을 읽다가 헛웃음이 터졌습니다. Wasm에는 명목상 타입(Nominal Type)이 없으니, 새로 추가된 예외 처리(Exception Handling) 기능을 해킹해서 명목상 타입처럼 쓰는 기괴한 방법을 소개했기 때문입니다. 이 글은 농담(Troll post)으로 작성되었지만, 현재 Wasm 설계의 본질과 한계를 너무나도 정확하게 찌르고 있습니다. 오늘 이 포스트에서는 이 ‘저주받은’ 해킹 기법이 어떻게 동작하는지 파헤쳐보고, Wasm이 도대체 어디로 가고 있는지 엔지니어의 시각에서 논의해 보겠습니다.

구조적 타입의 한계와 Rec Group 트릭

기본적으로 Wasm은 구조적 타입(Structural Type) 시스템을 사용합니다. 즉, 구조가 같으면 완전히 동일한 타입으로 취급합니다. 아래의 코드를 보시죠.

(type $t (struct i32))
(type $u (struct i32))

이 두 타입은 Wasm 엔진 내에서 같은 Equivalence class로 묶입니다. 구조가 같기 때문이죠. 하지만 보안이나 캡슐화를 위해 두 타입을 엄격히 구분해야 한다면 어떨까요? 모듈 시스템에서 외부 모듈이 내가 정의한 권한(Capability) 객체를 임의로 생성하는 것을 막고 싶을 때 말입니다.

이때 Wasm에서 제공하는 우회책이 바로 rec 그룹입니다. 본래 재귀적 타입(Recursive types)을 정의하기 위해 만들어진 기능이지만, 흥미롭게도 Wasm 스펙상 rec 그룹은 구조적 타입 동등성의 기준 단위가 됩니다.

(rec 
  (type $t (struct i32)) 
  (type $u (struct i32))
)

이렇게 하나의 rec 그룹 안에 넣으면, 구조가 같더라도 $t와 $u는 서로 다른 타입으로 취급됩니다. Hacker News의 한 댓글에 따르면, 실제로 Binaryen 같은 옵티마이저가 이 트릭을 적극적으로 사용합니다. 컴파일러는 타입 충돌을 막기 위해 모든 타입을 하나의 거대한 rec 그룹에 밀어 넣거나, 아무 의미 없는 ‘Brand types’를 추가하여 구조를 강제로 다르게 만들어 버립니다.

솔직히 컴파일러 최적화 단계를 직접 작성해 본 엔지니어라면 이 방식이 얼마나 지저분한 워크어라운드인지 공감하실 겁니다.

예외(Exception)를 남용한 기괴한 명목상 타입

단일 모듈 내에서는 rec 그룹으로 버틸 수 있지만, 모듈 간에 타입을 Import/Export 할 때는 이마저도 무용지물이 됩니다. 외부 모듈이 똑같은 구조의 rec 그룹을 정의해 버리면 그만이니까요.

여기서 Andy Wingo의 천재적이고 기괴한 발상이 등장합니다. 지난 7월 표준에 편입된 Exception Handling 제안의 tag 키워드를 사용하는 것입니다.

(tag $v (param $secret i32))

tag는 Wasm 내에서 완벽하게 고유한 명목상 타입으로 동작합니다. 문제는 이 타입의 인스턴스를 생성하고 접근하는 방식입니다. 일반적인 struct.new를 쓸 수 없으니, 데이터를 생성하려면 말 그대로 예외를 던져야(throw) 합니다.

(block $b (result (ref exn))
  (try_table (catch_all_ref $b)
    (throw $v (i32.const 42))
  )
  (unreachable)
)

값을 할당하기 위해 throw를 던지고, 그걸 try_table로 잡아내서 exn (예외 참조) 타입으로 반환받습니다. 그렇다면 저장된 필드 값은 어떻게 읽어올까요? 구조체처럼 struct.get을 쓸 수 없으니, 이번에도 예외를 던지고 catch 핸들러 에서 값을 받아옵니다.

(func $v-fields (param $x (ref exn)) (result i32)
  (try_table (catch $v 0)
    (throw_ref (local.get $x))
  )
  (unreachable)
)

이 코드를 보고 경악을 금치 못했습니다. HN의 한 유저가 남긴 “구조체를 마구 흔들어서 내용물이 떨어지는지 확인하는 것 같다”는 평가는 이 패턴을 완벽하게 요약합니다. 타입 안전성을 지키기 위해 예외 처리 파이프라인을 데이터 접근자로 악용하다니, 해커의 관점에서는 예술적이지만 프로덕션 레벨에서는 절대 용납할 수 없는 안티 패턴입니다.

Wasm은 두 번째 JVM이 되어가는가?

이 글은 명백한 농담이지만, HN 커뮤니티에서는 꽤 진지한 논의가 촉발되었습니다. 가장 눈에 띄는 비판은 다음과 같습니다.

  • 설계의 변질: WebAssembly는 이름부터가 웹을 위한 어셈블리인데, 도대체 왜 가상 머신이 구조체의 내부 레이아웃이나 가비지 컬렉션(GC) 메커니즘까지 알아야 하는가?
  • 복잡성 증가: 단순한 타겟 아키텍처였던 Wasm이 점점 거대해지며 예전의 JVM과 똑같은 길을 걷고 있다.

저 역시 과거의 미니멀했던 Wasm을 그리워하는 사람 중 하나입니다. x86이나 ARM처럼 메모리를 단순한 바이트 배열(Linear Memory)로 취급하던 시절이 직관적이었죠.

하지만 현실적인 엔지니어링 관점에서 보면 Wasm GC와 복잡한 타입 시스템의 도입은 불가피한 선택이었습니다. Java, C#, Go 같은 Managed Language를 브라우저에서 효율적으로 실행하려면, 런타임이 힙 메모리의 객체 구조를 이해하고 참조(Reference)를 추적할 수 있어야 합니다. 메모리를 단순한 바이트 덩어리로 두면 GC가 포인터를 안전하게 추적할 수 없기 때문입니다.

성능에 대한 우려도 있지만, 여전히 Wasm의 Throughput 성능은 네이티브 코드 대비 5% 내외의 차이만 보일 정도로 훌륭합니다. Akamai 같은 기업들은 Serverless 함수의 Cold start 지연 시간을 마이크로초 단위로 줄이기 위해 Wasm을 적극 도입하고 있습니다.

결론 (Verdict)

Andy Wingo가 제시한 ‘예외를 이용한 명목상 타입’ 해킹은 절대 실무에서 사용해서는 안 되는 장난감입니다. 하지만 이 기괴한 코드가 문법적으로 완벽하게 동작한다는 사실 자체가, Wasm이 얼마나 고도로 추상화된 런타임 플랫폼으로 진화했는지를 역설적으로 증명합니다.

WebAssembly는 더 이상 단순한 어셈블리가 아닙니다. 타입 안전성, 가비지 컬렉션, 예외 처리를 모두 내장한 거대한 다국어 런타임 환경입니다. 우리가 이 복잡성을 ‘두 번째 JVM’이라며 비판하든, 아니면 웹 생태계를 통합할 궁극의 플랫폼으로 찬양하든, 이미 Wasm은 돌아올 수 없는 강을 건넜습니다. Senior 엔지니어라면 이 변화를 거부하기보다는, 거대해진 Wasm의 내부 동작을 정확히 이해하고 최적화 포인트를 찾는 데 집중해야 할 시점입니다.


References