WIT 인터페이스로 모놀리식 플러그인 구조를 분해하기 — WebAssembly Component Model의 호스트-게스트 바인딩 설계
플러그인 시스템을 설계해본 경험이 있다면, 언젠가 한 번은 이런 상황을 겪어봤을 겁니다. 외부 팀이 만든 shared library를 dlopen으로 로드했는데, 그 라이브러리 안에서 null 포인터 역참조가 발생하면서 메인 프로세스 전체가 죽는 경우요. 아니면 Rust로 짠 코어에 Python 플러그인을 끼우려다 FFI 글루 코드를 몇 주째 디버깅하고 있는 경우. WebAssembly Component Model은 이 두 가지 문제를 구조적으로 해소하는 아키텍처입니다. 언어 경계를 넘는 플러그인 인터페이스를 WIT(WebAssembly Interface Types)로 한 번 정의하면, 이후 언어별 바인딩은 도구가 자동으로 생성하고, 플러그인 크래시는 샌드박스 안에서 격리됩니다.
2024년 초 WASI 0.2가 Component Model을 정식으로 포함시키면서, 이 이야기는 실험적 스펙에서 프로덕션 후보 아키텍처로 넘어왔습니다. Shopify Functions처럼 상인이 체크아웃 로직을 Wasm 컴포넌트로 작성·배포하고, Zed 같은 에디터가 Wasm 기반 확장으로 언어 서버 확장을 격리 실행하는 사례가 2026년 현재의 실제 풍경입니다. 이 글은 그 아키텍처가 어떻게 작동하는지, 특히 WIT 인터페이스 계약과 호스트-게스트 바인딩 파이프라인을 중심으로 코드와 함께 풀어봅니다.
Core Wasm과 Component Model, 어디서부터가 다른가
Component Model이 해결하려는 문제
Core WebAssembly는 선형 메모리와 숫자 타입 몇 가지만 다룹니다. 두 Wasm 모듈이 통신하려면 메모리 포인터와 오프셋을 날 것으로 주고받아야 합니다. 이는 C ABI가 가진 문제와 본질적으로 같습니다. 한쪽의 메모리 레이아웃 가정이 상대방에게 암묵적으로 누출되고, 언어가 바뀌면 그 가정이 무너집니다.
Component Model은 Core Wasm 위에 올라가는 상위 계층으로, 모듈을 명시적 인터페이스와 함께 패키징합니다. 어떤 타입을 임포트하고, 어떤 함수를 익스포트하며, 값이 경계를 넘을 때 메모리 레이아웃이 누출되지 않도록 처리하는 방식까지 정의합니다. 핵심은 경계(boundary)를 명시적으로 설계한다는 점입니다.
WIT의 World: 계약의 단위
WIT는 Component Model의 IDL(Interface Definition Language)입니다. WIT의 핵심 개념은 World로, 컴포넌트가 임포트할 수 있는 것과 익스포트하는 것을 한 파일에 선언합니다. logging처럼 호스트가 게스트에게 제공하는 인터페이스는 별도 interface로 뽑아두면 바인딩 생성기가 만드는 트레이트 경로가 예측 가능해집니다.
// plugin.wit
package example:event-processor@0.1.0;
interface logging {
log: func(level: string, msg: string);
}
interface transform {
record event {
id: string,
timestamp: u64,
payload: list<u8>,
}
record transform-result {
data: list<u8>,
tags: list<string>,
}
process: func(event: event) -> result<transform-result, string>;
}
world plugin {
import logging;
export transform;
}이 파일 하나가 계약의 전부입니다. Rust로 만든 플러그인이든 Go로 만든 플러그인이든, 이 World를 준수하면 호스트는 동일하게 다룰 수 있습니다.
호스트-게스트 바인딩 파이프라인 걷기
1단계: 게스트 컴포넌트 작성 (Rust)
wit-bindgen과 cargo component를 사용해서 게스트 측 Rust 컴포넌트를 만듭니다. 아래 wit-bindgen 버전은 예시일 뿐이며, 사용 시점의 최신 릴리스와 API 호환성을 확인해야 합니다.
# Cargo.toml (게스트)
[package]
name = "event-transform-plugin"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
# 사용 시점의 실제 릴리스 버전에 맞춰 조정
wit-bindgen = "0.*"
[package.metadata.component]
package = "example:event-processor"// src/lib.rs
wit_bindgen::generate!({
path: "plugin.wit",
world: "plugin",
});
struct Plugin;
impl exports::example::event_processor::transform::Guest for Plugin {
fn process(
event: exports::example::event_processor::transform::Event,
) -> Result<exports::example::event_processor::transform::TransformResult, String> {
example::event_processor::logging::log(
"info",
&format!("processing event: {}", event.id),
);
let processed = event.payload.iter().map(|b| b.wrapping_add(1)).collect();
Ok(exports::example::event_processor::transform::TransformResult {
data: processed,
tags: vec!["transformed".to_string()],
})
}
}
export!(Plugin);빌드는 cargo component build --release로 실행하면 .wasm 컴포넌트 바이너리가 나옵니다.
2단계: 호스트 측 바인딩 (Rust + Wasmtime)
호스트에서는 bindgen! 매크로로 WIT를 읽어 트레이트를 생성한 뒤, logging 인터페이스의 Host 트레이트를 호스트 상태 타입에 구현합니다. 인터페이스를 별도로 선언했기 때문에 생성되는 경로가 example::event_processor::logging::Host로 안정적으로 잡힙니다.
// host/src/main.rs
use wasmtime::component::{bindgen, Component, Linker};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::WasiCtxBuilder;
bindgen!({
path: "plugin.wit",
world: "plugin",
async: false,
});
struct HostState {
wasi: wasmtime_wasi::WasiCtx,
table: wasmtime::component::ResourceTable,
}
impl wasmtime_wasi::WasiView for HostState {
fn ctx(&mut self) -> &mut wasmtime_wasi::WasiCtx { &mut self.wasi }
fn table(&mut self) -> &mut wasmtime::component::ResourceTable { &mut self.table }
}
impl example::event_processor::logging::Host for HostState {
fn log(&mut self, level: String, msg: String) {
eprintln!("[PLUGIN:{level}] {msg}");
}
}
fn main() -> anyhow::Result<()> {
let mut config = Config::new();
config.wasm_component_model(true);
let engine = Engine::new(&config)?;
let component = Component::from_file(&engine, "event-transform-plugin.wasm")?;
let mut linker: Linker<HostState> = Linker::new(&engine);
wasmtime_wasi::add_to_linker_sync(&mut linker)?;
Plugin::add_to_linker(&mut linker, |state| state)?;
let wasi = WasiCtxBuilder::new().inherit_stderr().build();
let mut store = Store::new(&engine, HostState {
wasi,
table: Default::default(),
});
let (plugin, _) = Plugin::instantiate(&mut store, &component, &linker)?;
let event = example::event_processor::transform::Event {
id: "evt-001".to_string(),
timestamp: 1722000000,
payload: vec![1, 2, 3, 4, 5],
};
match plugin
.example_event_processor_transform()
.call_process(&mut store, event)?
{
Ok(result) => println!("transformed: {:?}, tags: {:?}", result.data, result.tags),
Err(e) => eprintln!("plugin error: {e}"),
}
Ok(())
}여기서 중요한 포인트가 있습니다. Plugin::add_to_linker(&mut linker, |state| state)? 이 한 줄이 호스트가 구현한 logging::Host를 게스트 컴포넌트에게 연결해줍니다. 게스트는 이 함수를 호출할 수 있지만, 그게 실제로 어디서 오는지는 알 수 없습니다. 인터페이스 계약만 알 뿐입니다.
컴포넌트 합성(Composition): 여러 플러그인을 파이프라인으로 연결
컴포넌트 합성은 두 갈래로 접근할 수 있습니다. CLI 레벨에서는 wasm-tools compose가 컴포넌트를 정적으로 링크하고, 조합 관계를 더 명시적으로 기술하고 싶다면 WAC(WebAssembly Composition) 언어를 사용합니다. 아래는 WAC 문법의 개념적 예시로, validator의 출력을 transformer의 입력으로 연결하는 파이프라인을 표현합니다.
// pipeline.wac — 개념적 예시
package example:pipeline;
let v = new example:validator { ... };
let t = new example:transformer {
transform: v.transform,
...
};
export t...;실제 문법은 WAC 버전에 따라 달라지므로 공식 문서의 예제를 기준으로 잡는 것이 안전합니다.
각 컴포넌트는 자신의 선형 메모리 공간 안에서만 실행됩니다. Transformer가 크래시해도 Validator와 호스트는 영향받지 않습니다.
트레이드오프 — 플러그인 격리 방식 비교
실무에서 플러그인 격리 방식을 고를 때 가장 많이 비교하는 세 가지 접근을 나란히 놓으면 이렇습니다.
| 항목 | dlopen / shared library | 프로세스 격리 (IPC) | Wasm Component Model |
|---|---|---|---|
| 메모리 격리 | 없음. 동일 주소 공간 공유 | OS 프로세스 경계 | 샌드박스 선형 메모리 |
| 크래시 전파 | 호스트 프로세스까지 죽음 | 플러그인 프로세스만 종료 | 컴포넌트 인스턴스만 종료 |
| 언어 지원 | C ABI 호환 언어만 | 모든 언어 (IPC 프로토콜 구현 필요) | WIT 지원 언어 (Rust, C/C++, Go, Python, JS 등) |
| FFI 글루 코드 | 언어 쌍마다 수동 작성 | 직렬화 포맷 공유 | wit-bindgen이 자동 생성 |
| 기동 속도 | 매우 빠름 | 느림 (프로세스 fork/exec) | 빠른 편. 컨테이너 대비 낮은 메모리 풋프린트 |
| 인터페이스 계약 검증 | 런타임. ABI 불일치는 세그폴트 | 런타임. 스키마 버전 관리 필요 | 컴파일 타임. WIT 불일치는 빌드 실패 |
| 이식성 | OS/아키텍처 의존 | OS 의존 | .wasm 바이너리 하나로 다중 OS·엣지 |
| 멀티 테넌시 | 격리 없는 공유 주소 공간 | 프로세스 수만큼 메모리 | 같은 프로세스에서 다수 인스턴스 격리 실행 |
실무에서 자주 하는 실수들
WIT 타입을 너무 세밀하게 쪼개는 것. list<u8>를 함수 경계로 자주 넘기는 설계는 메모리 복사 비용을 쌓이게 합니다. 샌드박스 경계는 공유 메모리를 허용하지 않으므로, 대용량 버퍼를 빈번히 교환하는 인터페이스라면 배치 처리나 청크 단위 스트리밍으로 재설계할 필요가 있습니다.
비동기 I/O 기대치 조절. Component Model의 비동기 지원은 WIT에 새로운 first-class 제네릭 타입을 얹는 방식이 아니라, async lift/lower 메커니즘과 wasi:io/poll 계열 인터페이스를 통해 컴포넌트 경계를 넘는 논블로킹 I/O를 가능하게 하는 방향으로 진화하고 있습니다. 2026년 기준으로도 여전히 스펙과 런타임 지원이 활발히 움직이는 영역이므로, 비동기 플러그인 인터페이스를 프로덕션에 쓰려면 사용하려는 런타임의 지원 수준을 먼저 확인하는 것이 좋습니다.
런타임 선택 시 Component Model 지원 수준 확인. Wasmtime은 Component Model 지원이 가장 성숙합니다. WasmEdge는 클라우드 네이티브와 AI 추론 지원에 강점이 있고, WAMR은 IoT·임베디드 환경에 최적화돼 있습니다. 런타임마다 컴포넌트 모델 지원 수준이 다르므로, 배포 환경에 맞는 런타임을 먼저 고정하는 것이 파이프라인 설계의 시작점입니다.
인터페이스 버전 관리 전략 부재. WIT 패키지는 @0.1.0 같은 버전을 포함합니다. 게스트 플러그인이 @0.1.0으로 컴파일됐는데 호스트가 @0.2.0을 요구하면 링킹이 실패합니다. WIT 파일을 API로 대우하고, 하위 호환 변경과 브레이킹 체인지를 명시적으로 구분하는 정책이 필요합니다. 실제 WIT의 호환성 규칙은 대략 다음과 같이 정리할 수 있습니다.
record 필드 추가처럼 직관적으로는 하위 호환일 것 같은 변경이 실제로는 브레이킹인 경우가 있으므로, 규칙을 로컬 문서로 두고 CI에서 강제하는 편이 안전합니다.
지금 이 생태계 어디쯤 와 있나
American Express가 내부 FaaS 플랫폼을 wasmCloud 기반으로 구축해 멀티 언어 함수를 격리 실행하는 사례가 있고, Envoy Proxy는 요청 필터링·라우팅·인증 미들웨어를 Wasm 플러그인으로 동적 교체합니다. C++ 코어를 재컴파일하지 않고 기능을 확장하는 것입니다.
멀티 테넌트 SaaS에서 테넌트별 플러그인 격리를 컨테이너로 구현하면 프로세스 수만큼 메모리와 기동 비용이 붙습니다. Wasm 컴포넌트라면 같은 프로세스 안에서 인스턴스 단위 격리를 유지하면서 밀도를 올릴 여지가 있습니다. 정확한 개선치는 워크로드 특성(메모리 사용량, 호출 빈도, I/O 패턴)에 따라 크게 갈리므로, 결정 전에 실제 워크로드 프로파일로 벤치마킹을 잡는 것이 필요합니다.
레지스트리 생태계도 형성되고 있습니다. wa.dev(warg 프로토콜)는 Wasm 컴포넌트를 위한 서명된 패키지 레지스트리로, 런타임에 동적으로 컴포넌트를 링크하는 워크플로를 현실화하고 있습니다. 취약점이 발견된 플러그인 컴포넌트를 핫패칭하는 시나리오도 이 레지스트리 기반에서 가능해집니다.
ML 모델을 Wasm 샌드박스 안에서 실행하는 방식도 최근 몇 년 사이 주목받는 사용 사례입니다. Wasm의 이식성과 격리성이 AI 서빙 인프라에서도 매력적인 선택지가 되고 있는 것입니다.
대용량 바이너리 데이터를 고빈도로 교환하는 플러그인이라면 메모리 복사 오버헤드를 실제 워크로드로 벤치마킹해야 하고, CPU 집약적 병렬 작업이 필요하다면 컴포넌트 간 스레드 공유 모델이 아직 정립 중인 점을 감안해야 합니다. 다만 "언어가 다른 팀이 만든 플러그인을 안전하게 격리 실행하고, 인터페이스를 컴파일 타임에 검증하고 싶다"는 요구라면, WIT 기반 Component Model이 현재 가장 구조적으로 건강한 답입니다.
도입을 검토한다면 다음 순서를 권합니다. 첫째, 배포 환경에서 쓸 런타임(Wasmtime, WasmEdge, WAMR 중 하나)을 고정하고 해당 런타임의 Component Model 지원 수준·async 지원 로드맵을 문서로 확인합니다. 둘째, 실제 플러그인이 교환할 데이터 크기와 호출 빈도로 마이크로벤치를 잡아 메모리 복사 비용을 측정합니다. 셋째, WIT 파일에 대한 호환성 규칙과 버전 정책을 리포지토리 CI에 심어 브레이킹 체인지가 조용히 넘어가지 않도록 게이트를 만듭니다.
참고 자료
- The WebAssembly Component Model — 공식 문서
- Why the Component Model? — Bytecode Alliance
- wit-bindgen GitHub 공식 저장소 (bytecodealliance)
- WAC — WebAssembly Composition 언어
- Building Native Plugin Systems with WebAssembly Components — Sy Brand
- Building host implementations for WebAssembly interfaces — radu-matei
- WASI and the WebAssembly Component Model: Current Status (2025.02)
- Wasmtime bindgen 매크로 공식 문서
- wasmCloud Interfaces — WASI and Wasm Component Model
- The Promise and Pitfalls of WebAssembly — arXiv (2025)