WebAssembly 브라우저 외부: WASI와 server-side Wasm
1. 개요
WebAssembly(Wasm)는 브라우저에서 고성능 코드를 실행하기 위해 만들어졌습니다. 하지만 그 특성(컴팩트함, 엄격한 sandbox, CPU 아키텍처 독립성)은 서버 측에서도 매우 매력적입니다.
이 글에서는 WASI가 무엇이고 왜 server-side Wasm이 주목받고 있는지 이해할 것입니다. Wasm 모듈을 빌드하고, 명령줄 runtime으로 실행하고, Java 21 애플리케이션에 포함하며 테스트를 작성할 것입니다. 글의 마지막에는 Wasm을 사용하면 안 되는 경우들을 다룹니다.
2. WebAssembly가 WASI를 필요로 하는 이유
2.1. 순수한 Wasm은 외부 세계와 상호작용할 수 없다
순수한 Wasm 모듈은 자신의 선형 메모리(linear memory)에서만 계산할 수 있습니다. 파일을 열거나, 환경 변수를 읽거나, 시스템 시간을 가져오거나, socket을 열 수 없습니다. 외부와의 모든 상호작용은 host(모듈을 실행하는 환경)가 제공하는 import 형태의 함수를 통해야 합니다.
브라우저에서 host는 JavaScript입니다. 브라우저 외부에서는 아직 통일된 host가 없습니다. 각 runtime이 자신의 API를 정의하면, 한 runtime을 위해 빌드한 모듈은 다른 runtime에서 실행되지 않습니다.
2.2. WASI: Wasm을 위한 표준 운영 체제 인터페이스
WASI(WebAssembly System Interface)는 파일, clock, random, 환경 변수, 명령줄 매개변수, 그리고 더 최신 버전에서는 network 등 운영 체제 스타일의 작업을 위한 표준 API 집합입니다. WASI를 목표로 컴파일된 모듈은 Wasmtime, WasmEdge, Wasmer 또는 애플리케이션에 포함된 runtime 같이 WASI를 지원하는 어느 runtime에서든 실행될 수 있습니다.
가장 중요한 차이점은 capability 모델입니다. 모듈은 기본적으로 어떤 권한도 갖지 않습니다. Host는 명시적인 권한을 부여해야 합니다. 예를 들어 ’./data 디렉터리 읽기 권한’처럼요. 권한이 없으면 호출이 실패합니다. 이는 실행 중인 사용자의 모든 권한을 상속하는 일반적인 process와 반대입니다.
2.3. WASI Preview 1, Preview 2 및 Component Model
WASI는 여러 단계를 거쳤습니다. 두 가지 일반적인 이름을 마주치게 됩니다:
- Preview 1(wasip1): C 스타일 함수 API이며, 가장 광범위하게 지원됩니다.
- Preview 2(wasip2): Component Model 위에 구축되었습니다. 인터페이스는 WIT라는 IDL 언어로 기술됩니다. 다른 언어로 작성된 component들은 정수만이 아닌 string, record, list 같은 풍부한 데이터 타입을 통해 서로 호출할 수 있습니다.
지원 수준은 runtime과 toolchain에 따라 다릅니다. 선택 전에 사용하려는 runtime의 문서를 확인하세요.
3. Server-side Wasm이 주목받는 이유
네 가지 주요 이유가 있습니다:
- 기본 sandbox: 모듈은 자신의 메모리 영역 밖에 접근할 수 없으며, 부여받지 않은 시스템 권한이 없습니다. 이는 고객의 plugin이나 사용자가 업로드한 script처럼 신뢰할 수 없는 코드를 실행하기에 매우 적합합니다.
- 빠른 시작, 작은 footprint: 모듈은 일반적으로 가볍고 새로운 container나 JVM보다 훨씬 빠르게 초기화됩니다. 따라서 Wasm은 각 요청이 자체 instance를 필요로 할 수 있는 serverless와 edge에 적합합니다.
- 이식성:
.wasm파일은 x86 또는 ARM, Linux 또는 macOS에서 runtime만 있으면 실행됩니다. - 다중 언어: Rust, C/C++, Go, Zig, AssemblyScript… 모두 Wasm으로 컴파일할 수 있습니다. Java 애플리케이션은 JNI 없이 다른 언어로 작성된 로직을 로드할 수 있습니다.
Container와의 빠른 비교:
| 기준 | Container | Wasm + WASI |
|---|---|---|
| 패키징 단위 | Userland OS를 포함한 Image | .wasm 파일 하나 |
| 격리 | Kernel의 namespace/cgroup | Runtime의 sandbox + capability |
| 아키텍처 의존성 | Architecture별 image 필요 | 아니오 |
| 생태계 | 매우 성숙함 | 개발 중 |
Wasm은 container를 대체하지 않습니다. 둘은 보통 서로 보완합니다: container 내부에서 Wasm runtime을 실행할 수 있습니다.
4. WASI 프로그램 빌드 및 실행
Rust의 Wasm toolchain이 꽤 편리하기 때문에 Rust를 사용하는 예제를 보여드립니다. 같은 아이디어가 다른 언어에도 적용됩니다.
4.1. 주문 파일을 읽는 프로그램
// src/main.rs
use std::fs;
fn main() {
match fs::read_to_string("orders.csv") {
Ok(content) => {
let total: f64 = content
.lines()
.filter_map(|line| line.split(',').nth(1))
.filter_map(|amount| amount.trim().parse::<f64>().ok())
.sum();
println!("주문 총액: {total}");
}
Err(e) => eprintln!("파일을 읽을 수 없습니다: {e}"),
}
}
프로그램은 orders.csv를 읽고 두 번째 열(금액)을 가져와서 합계를 계산합니다. Wasm을 위한 특별한 코드는 없습니다: std::fs는 컴파일 시 WASI 호출로 매핑됩니다.
Target을 추가하고 빌드합니다:
rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1
결과는 target/wasm32-wasip1/release/order-report.wasm 파일입니다(패키지 이름에 따라 다름).
4.2. Wasmtime으로 실행하고 capability 작동 확인
데이터 파일을 만듭니다:
ORD-1,120.5
ORD-2,79.5
ORD-3,300
디렉터리 권한 없이 실행:
wasmtime run target/wasm32-wasip1/release/order-report.wasm
파일을 읽을 수 없습니다: failed to find a pre-opened file descriptor through which "orders.csv" could be opened
모듈이 실행되지만 파일을 볼 수 없습니다. Filesystem에 대한 capability가 없습니다. 이제 현재 디렉터리에 권한을 부여합니다:
wasmtime run --dir=. target/wasm32-wasip1/release/order-report.wasm
주문 총액: 500
--dir 플래그는 모듈에 대해 디렉터리를 “pre-open”합니다. 모듈은 해당 디렉터리만 볼 수 있으며 /etc나 home 디렉터리는 볼 수 없습니다. 오류 메시지의 내용은 runtime 버전에 따라 다를 수 있지만 동작은 동일합니다.
참고: 플래그 구문(
--dir,--env, host에서 guest로의 디렉터리 매핑)은 Wasmtime 버전 간에 변경될 수 있습니다. 시스템에서wasmtime run --help를 확인하세요.
5. Java 애플리케이션에 Wasm 포함하기
Server-side Wasm은 CLI에서 .wasm 파일을 실행하는 것만이 아닙니다. 매우 실용적인 사용 방법은 기존 애플리케이션에 runtime을 포함하여 제3자가 제공하는 plugin이나 로직을 실행하는 것입니다.
Java의 경우 두 가지 방향이 있습니다:
- JNI 또는 FFM을 통해 native runtime(Wasmtime, WasmEdge)에 바인딩
- Chicory같이 완전히 Java로 작성된 runtime 사용. Native 라이브러리가 필요 없으므로 패키징이 쉽고 JVM이 있는 곳이면 어디서나 실행됩니다.
아래 예제에서 Chicory를 사용합니다.
5.1. 배송료를 계산하는 Module
파트너가 Wasm 형태로 배송료 계산 로직을 제공한다고 가정합시다:
// src/lib.rs, Cargo.toml에: [lib] crate-type = ["cdylib"]
#[no_mangle]
pub extern "C" fn shipping_fee(weight_grams: i32, distance_km: i32) -> i32 {
let base = 15_000;
let by_weight = (weight_grams / 500) * 5_000;
let by_distance = distance_km * 1_000;
base + by_weight + by_distance
}
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/release/shipping.wasm src/main/resources/
이 모듈은 WASI를 사용하지 않습니다. 순수 계산만 하므로 wasm32-unknown-unknown target으로 충분하고 module은 어떤 import도 필요하지 않습니다.
5.2. Maven Dependency
<properties>
<java.version>21</java.version>
<!-- Maven Central에서 최신 버전 확인 -->
<chicory.version>REPLACE_WITH_LATEST</chicory.version>
</properties>
<dependencies>
<dependency>
<groupId>com.dylibso.chicory</groupId>
<artifactId>runtime</artifactId>
<version>${chicory.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Spring Boot 3.x를 사용하는 경우 JUnit과 AssertJ 버전은 spring-boot-starter-test를 통해 이미 관리됩니다.
5.3. Java에서 Wasm 함수 호출
package dev.nonla.shipping;
import com.dylibso.chicory.runtime.ExportFunction;
import com.dylibso.chicory.runtime.Instance;
import com.dylibso.chicory.wasm.Parser;
import java.io.InputStream;
public class WasmShippingCalculator {
private final ExportFunction shippingFee;
public WasmShippingCalculator(String resource) {
try (InputStream wasm = getClass().getResourceAsStream(resource)) {
var module = Parser.parse(wasm);
Instance instance = Instance.builder(module).build();
this.shippingFee = instance.export("shipping_fee");
} catch (Exception e) {
throw new IllegalStateException("Module을 로드할 수 없습니다: " + resource, e);
}
}
public int fee(int weightGrams, int distanceKm) {
long[] result = shippingFee.apply(weightGrams, distanceKm);
return (int) result[0];
}
}
설명:
Parser.parse(...)는 Wasm 바이트코드를 module 모델로 읽습니다.Instance.builder(module).build()는 자체 메모리를 가진 instance를 만듭니다. Module이 import하는 것이 없으므로 host function을 제공할 필요가 없습니다.instance.export("shipping_fee")는 export된 함수를 가져옵니다. 이름은 Rust의#[no_mangle]과 일치해야 합니다.apply(...)는long[]을 받고 반환합니다. Wasm의 숫자 타입(i32,i64등)이 모두long으로 표현되기 때문입니다. 직접int로 타입 캐스트합니다.
시도해봅시다:
var calculator = new WasmShippingCalculator("/shipping.wasm");
System.out.println(calculator.fee(1_200, 10));
35000
결과는 15,000의 기본 요금에 무게 기준 2 × 5,000(1,200 / 500 = 2), 거리 기준 10 × 1,000을 더합니다.
5.4. 동작 테스트
package dev.nonla.shipping;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class WasmShippingCalculatorTest {
static WasmShippingCalculator calculator;
@BeforeAll
static void setUp() {
calculator = new WasmShippingCalculator("/shipping.wasm");
}
@Test
void givenLightParcelNearby_whenFee_thenOnlyBaseAndDistance() {
assertThat(calculator.fee(300, 2)).isEqualTo(17_000);
}
@Test
void givenHeavyParcelFarAway_whenFee_thenAllComponentsAdded() {
assertThat(calculator.fee(2_000, 50)).isEqualTo(15_000 + 20_000 + 50_000);
}
@Test
void givenMissingModule_whenCreate_thenFailFast() {
assertThatThrownBy(() -> new WasmShippingCalculator("/not-found.wasm"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("not-found.wasm");
}
}
세 번째 테스트는 module이 없을 때 첫 번째 request까지 기다리지 않고 시작 시 즉시 오류를 보장합니다.
참고:
Instance는 자체 메모리와 상태를 가집니다. 기본적으로 여러 thread 간에 안전하게 공유되지 않습니다. 순수 계산 module의 경우 가장 간단한 방법은 요청별로 instance를 만들거나 instance pool을 사용하는 것입니다. 파싱된 module은 여러 instance를 만드는 데 재사용할 수 있습니다.
5.5. Module이 WASI가 필요한 경우
plugin이 파일을 읽거나 stdout에 인쇄해야 하면 WASI 함수를 import합니다. Instance를 만들 때 이러한 함수를 제공해야 합니다. Chicory는 이를 위한 전용 wasi module을 가지고 있습니다.
원칙은 여전히 capability입니다: plugin이 실제로 필요한 정확한 디렉터리, 환경 변수, stdout/stderr만 제공하세요. 편의를 위해 host의 전체 환경을 “상속”하지 마세요.
6. 실제 적용 모델
- Plugin 시스템: 고객이나 파트너가 JVM에서 코드를 직접 실행할 필요 없이 로직(가격 계산, 규칙 검사, 데이터 변환)을 커스터마이즈할 수 있습니다.
- Policy engine: 권한 부여 규칙을 Wasm module로 패키징하여 여러 다국어 service 간에 공유합니다.
- Serverless 및 edge function: 각 요청이 격리된 instance에서 실행되어 빠르게 시작됩니다.
- Native 라이브러리 재사용: C/Rust 라이브러리(parser, codec, 알고리즘)를 Wasm으로 컴파일하고 Java에서 호출하여 JNI와 JVM 전체 crash 위험을 피합니다.
- 인프라 확장: 일부 proxy, database, message broker는 Wasm으로 확장을 허용합니다.
7. 언제 사용하면 안 되는가
Server-side Wasm은 매력적이지만 항상 올바른 선택은 아닙니다:
- Java로 잘 작성되고 격리가 필요 없는 로직: Wasm 계층을 추가하면 복잡성, 데이터 변환 비용만 증가하고 디버깅이 어려워집니다.
- 무거운 멀티스레드, GPU 또는 전문 SIMD가 필요한 workload: Wasm의 thread 지원 및 hardware 기능은 여전히 제한적이며 runtime 간에 균일하지 않습니다.
- network와 라이브러리 생태계에 크게 의존하는 애플리케이션: WASI를 통한 socket, TLS, database driver가 실제 OS만큼 완전하지 않습니다. 많은 라이브러리가 아직 Wasm으로 컴파일되지 않습니다.
- 많은 데이터와의 왕복 호출: 호스트와 게스트 간의 string 또는 배열 전송은 항상 linear memory로 복사됩니다. 큰 payload로 Wasm 함수를 수백만 번 호출하면 이 비용이 상당합니다. 작업을 큰 호출로 묶고 적게 호출하세요.
- Sandbox가 절대적인 안전을 의미한다고 생각: Wasm은 메모리를 격리하지만 무한 루프나 CPU 소비를 자동으로 차단하지 않습니다. 여전히 timeout, 메모리 제한 및 runtime이 지원하는 경우 실행 중단 메커니즘(fuel, epoch interruption 등)이 필요합니다.
- 팀이 준비되지 않음: Wasm의 toolchain, debugger 및 observability는 아직 개발 중입니다. “핫”하다고 해서 중요한 시스템에만 넣지 마세요.
일반적인 오류 몇 가지:
- 잘못된 target으로 빌드.
std::fs또는println!을 사용하는 module은 WASI target이 필요합니다. 순수 계산 module은wasm32-unknown-unknown을 사용합니다. #[no_mangle]또는extern "C"를 잊어버려 함수가 export 목록에 나타나지 않습니다.- 타입 혼동: Wasm은 기본 숫자 타입만 가집니다. string을 전달하려면 linear memory에서 직접 할당을 관리하거나 WIT를 사용한 Component Model을 사용해야 합니다.
- 과도하게 넓은 권한 부여(
--dir=/, 전체 env 상속)로 WASI의 가장 큰 이점을 잃음.
8. 결론
WASI는 WebAssembly를 브라우저 기술에서 서버를 위한 portable하고 가벼운 강한 sandbox 실행 형식으로 변환합니다. Capability 모델(부여받지 않은 권한이 없음)은 기존 process 및 container와의 핵심 차이점입니다.
이 기사에서는 WASI 프로그램을 빌드하고 filesystem 권한이 어떻게 제어되는지 확인했습니다. 또한 순수 Java runtime을 사용하여 Wasm module을 Java 21 애플리케이션에 포함하고 테스트를 작성했습니다.
신뢰할 수 없는 코드를 실행하거나, 다국어 plugin이 필요하거나, 매우 빠른 시작이 필요할 때 Wasm을 고려하세요. 하지만 Java service의 일반적인 비즈니스 로직이라면 가장 간단한 솔루션이 여전히 최고입니다.