WebAssembly에서 구조적 타입 동등성과 rec 그룹, 그리고 예외 처리 제안을 이용해 모듈 간 명목 타입에 가까운 동작을 만드는 방법을 살펴본다.
2026년 3월 10일 오전 8:19
WebAssembly에 관리형 데이터 타입 확장이 표준에 편입되기 전에는 타입 동등성에 대해 엄청난 논쟁이 있었다. 최종 결과는 이렇다. Wasm 모듈 안에 다음처럼 모양이 같은 타입 둘이 있으면:
(type $t (struct i32)) (type $u (struct i32))
그러면 사실상 모든 목적에서 동등하다. Wasm 구현체가 모듈을 로드할 때, 모듈의 타입들을 동등성 클래스들로 분할해야 한다. Wasm 프로그램이 (struct.get $t 0)처럼 이름으로 어떤 타입을 참조하면(이는 타입 $t의 첫 번째 필드를 가져온다), 구현체는 $t를 $t와 $u를 포함하는 동등성 클래스로 매핑한다. 자세한 내용은 spec을 보라.
이는 _구조적 타입 동등성_의 한 형태다. 때로는 이것이 원하는 바다. 하지만 언제나 그런 것은 아니다! 때로는 어떤 타입 선언도 다른 어떤 것과도 동등하지 않은 _명목 타입_을 원한다. WebAssembly에는 그게 없지만, 비슷한 것은 있다: _재귀 타입 그룹_이다. 사실 위의 타입 선언은 다음과 동등하다:
(rec (type $t (struct i32))) (rec (type $u (struct i32)))
즉 각 타입이 자기 자신만을 포함하는 그룹에 들어 있다는 뜻이다. 이것이 가능하게 하는 것 중 하나는 자기 재귀이며, 예를 들면:
(type $succ (struct (ref null $succ)))
여기서 struct의 필드는 $succ struct에 대한 참조이거나 null이다(그냥 ref가 아니라 ref null이기 때문에).
타입들 사이의 상호 재귀를 허용하려면, 각자 자기 rec 그룹을 갖게 하는 대신 같은 rec 그룹에 넣으면 된다:
(rec (type $t (struct i32)) (type $u (struct i32)))
하지만 $t와 $u 사이에는 상호 재귀가 없는데, 왜 굳이 그럴까? rec 그룹에는 또 다른 역할이 있는데, 그것은 구조적 타입 동등성의 단위라는 점이다. 이 경우 타입 $t와 $u는 같은 동등성 클래스에 있지 않다. 같은 rec 그룹의 일부이기 때문이다. 다시 말해 자세한 내용은 the spec을 보라.
Wasm 모듈 내부에서는 rec가 명목 타이핑의 근사치를 제공한다. 하지만 모듈 사이에서는 어떨까? $t가 중요한 권한(capability)을 담고 있고, 다른 모듈이 그 권한을 위조하지 못하게 하고 싶다고 해 보자. 이 경우 rec는 충분하지 않다. 다른 모듈이 동등한 rec 그룹을 정의하고 $t를 구성한 다음 우리 모듈로 넘길 수 있기 때문이다. isorecursive 타입 동등성 때문에 이것은 문제없이 동작한다. 어떻게 해야 할까?
나는 앞에서 Wasm에는 명목 타입이 없다고 말했다. 과거에는 그랬지만 이제는 아니다! nominal typing proposal이 지난 7월 표준에 편입되었다. 다만 용어가 좀 이상하다. 데이터 타입을 tag 키워드로 정의해야 한다:
(tag $v (param $secret i32))
문법적으로도 이 데이터 타입들은 좀 이상하다. field 대신 param으로 필드를 선언해야 하고, 필드들을 struct로 감쌀 필요도 없다.
또한 isorecursive struct에 비해 몇몇 기능이 빠져 있는데, 구체적으로는 서브타이핑과 가변성이다. 하지만 때로는 서브타이핑이 필요 없기도 하고, 가변 필드는 대입 변환으로 처리할 수 있으며 필요하다면 가변 struct로 감싸면 된다.
명목적으로 타이핑된 값을 구성하는 메커니즘은 다소 복잡하다. (struct.new $t (i32.const 42)) 대신 throw를 사용한다:
(block $b (result (ref exn)) (try_table (catch_all_ref $b) (throw $v (i32.const 42))) (unreachable))
물론 이것은 새로운 제안이므로, 아직 Wasm 쪽에서 정확한 타입 정보를 갖고 있지는 않다. 그래서 새 인스턴스는 대신 명목적으로 타이핑된 값에 대한 최상위 타입인 exn으로 반환된다.
어떤 값이 $v인지 확인하려면, 약간의 코드를 작성해야 한다:
(func $is-v? (param $x (ref exn)) (result i32) (block $yep (result (ref exn)) (block $nope (try_table (catch_ref $v $yep) (catch_all $nope) (throw_ref (local.get $x)))) (return (i32.const 0))) (return (i32.const 1)))
마지막으로, 필드 접근도 좀 이상하다. struct는 struct.get이 있지만, 명목 타입은 catch 핸들러를 통해서만 모든 값을 받는다.
(func $v-fields (param $x (ref exn)) (result i32) (try_table (catch $v 0) (throw_ref (local.get $x))) (unreachable))
여기서 (catch $v 0)의 0은 함수 호출 자체를 가리킨다. $v의 모든 필드가 함수 호출의 반환값으로 나온다. 이 경우에는 하나뿐이지만, 그렇지 않다면 get-fields 함수는 여러 값을 반환할 것이다. 다행히 이 접근자는 타입 안전성을 보존한다. $x가 실제로 $v가 아니면 예외가 던져진다.
이제 때로는 명목 타입의 정체성에 대해 아주 엄격하고 싶을 수 있다. 그런 경우에는 모듈 안에서 tag를 정의하고 내보내지 않으면 된다. 하지만 다른 모듈이 우연히 당신의 타입과 구조적으로 같은 타입을 구현했느냐의 랜덤성에 좌우되는 것이 아니라, 원칙적인 방식으로 조합(composition)을 가능하게 하고 싶다면, 명목 타이핑 제안은 type imports의 미리보기도 제공한다. 기능은 직관적이다. 모듈에서 tag를 export하고, 다른 모듈이 그것을 import하도록 허용하기만 하면 된다. 그러면 모든 것이 기대대로 동작한다!
여러분, 내가 말하지 않아도 아주 명백하겠지만, 이 글은 트롤 글이다 :) 하지만 틀린 말은 아니다! 서브타이핑도 없고 필드 가변성도 없는 명목 타입 struct를 위한 모든 장치는 예외 처리 제안에 들어 있다.
이 작업의 맥락은, 내가 Hoot을 업데이트해서 표준화 이전 버전 대신 최신 Wasm 예외 처리를 사용하도록 했다는 점이다. 좋은 변화였지만, exnref 타입을 도입하면서 재미있는 장난을 칠 수 있는 문이 열리긴 한다. 그리고 위원회가 7년 동안 타입 import에 대해 망설이고 질질 끌다가, 이런 역방향 같은 방식으로 그걸 실어 보내는 것이 나는 정말 웃기다.
다음은 Wastrel에 예외 지원을 넣는 일인데, 이 새로운 명목 타이핑 기능을 위해 타입 태그를 어디에 할당해야 하는지부터 알아내야 한다. 계속 전진!
이름
메일 (공개되지 않습니다)
웹사이트
34와 42 사이의 숫자는?
powered by tekuti