본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 10장: 실전 프로젝트 — WebAssembly 애플리케이션 구축
2026년 4월 5일·프로그래밍·

10장: 실전 프로젝트 — WebAssembly 애플리케이션 구축

Rust+Spin 서버리스 API 구축, 브라우저 Wasm 모듈 통합, 엣지 배포, 성능 벤치마킹, Wasm 도입 의사결정 가이드, 그리고 WebAssembly의 미래 전망을 다룹니다.

19분1,431자10개 섹션
webassemblyrust
공유
webassembly10 / 10
12345678910
이전9장: 엣지 컴퓨팅과 Wasm 배포

학습 목표

  • Rust+Spin으로 서버리스 API를 처음부터 끝까지 구축합니다
  • 브라우저 Wasm 모듈과 서버 API를 통합하는 풀스택 아키텍처를 설계합니다
  • 성능 벤치마킹으로 Wasm의 실제 효과를 측정합니다
  • 조직에서 Wasm 도입 여부를 판단하는 의사결정 프레임워크를 수립합니다

프로젝트 개요 — 이미지 처리 서비스

이번 장에서 구축할 프로젝트는 이미지 처리 서비스입니다. 시리즈에서 다룬 핵심 개념들을 하나의 실전 프로젝트로 통합합니다.

아키텍처 구성:

  • 브라우저 Wasm 모듈: 클라이언트 측 이미지 프리뷰 및 간단한 필터
  • Spin 서버 API: 고해상도 이미지 처리, 리사이즈, 포맷 변환
  • 엣지 배포: 글로벌 저지연 서비스 제공

1단계: 프로젝트 설정

프로젝트 구조

project-structure.sh
bash
mkdir -p image-service/{server,browser,shared}
cd image-service
 
# Spin 서버 프로젝트 생성
cd server
spin new -t http-rust image-api
 
# 브라우저 Wasm 라이브러리 생성
cd ../browser
cargo init --lib image-preview
 
# 공유 로직 라이브러리
cd ../shared
cargo init --lib image-core

공유 이미지 처리 로직

서버와 브라우저에서 공통으로 사용하는 핵심 로직을 별도 크레이트로 분리합니다.

shared/image-core/src/lib.rs
rust
/// RGBA 픽셀 배열에 세피아 필터를 적용합니다.
pub fn apply_sepia(pixels: &mut [u8]) {
    for chunk in pixels.chunks_exact_mut(4) {
        let r = chunk[0] as f32;
        let g = chunk[1] as f32;
        let b = chunk[2] as f32;
 
        chunk[0] = (0.393 * r + 0.769 * g + 0.189 * b).min(255.0) as u8;
        chunk[1] = (0.349 * r + 0.686 * g + 0.168 * b).min(255.0) as u8;
        chunk[2] = (0.272 * r + 0.534 * g + 0.131 * b).min(255.0) as u8;
    }
}
 
/// RGBA 픽셀 배열에 그레이스케일 필터를 적용합니다.
pub fn apply_grayscale(pixels: &mut [u8]) {
    for chunk in pixels.chunks_exact_mut(4) {
        let gray = (0.299 * chunk[0] as f32
            + 0.587 * chunk[1] as f32
            + 0.114 * chunk[2] as f32) as u8;
        chunk[0] = gray;
        chunk[1] = gray;
        chunk[2] = gray;
    }
}
 
/// 가우시안 블러를 적용합니다.
pub fn apply_blur(pixels: &mut [u8], width: u32, height: u32, radius: u32) {
    let len = (width * height * 4) as usize;
    let mut output = vec![0u8; len];
 
    let r = radius as i32;
 
    for y in 0..height as i32 {
        for x in 0..width as i32 {
            let mut sum_r = 0u32;
            let mut sum_g = 0u32;
            let mut sum_b = 0u32;
            let mut count = 0u32;
 
            for dy in -r..=r {
                for dx in -r..=r {
                    let nx = x + dx;
                    let ny = y + dy;
 
                    if nx >= 0 && nx < width as i32 && ny >= 0 && ny < height as i32 {
                        let idx = ((ny as u32 * width + nx as u32) * 4) as usize;
                        sum_r += pixels[idx] as u32;
                        sum_g += pixels[idx + 1] as u32;
                        sum_b += pixels[idx + 2] as u32;
                        count += 1;
                    }
                }
            }
 
            let idx = ((y as u32 * width + x as u32) * 4) as usize;
            output[idx] = (sum_r / count) as u8;
            output[idx + 1] = (sum_g / count) as u8;
            output[idx + 2] = (sum_b / count) as u8;
            output[idx + 3] = pixels[idx + 3]; // alpha 유지
        }
    }
 
    pixels.copy_from_slice(&output);
}
 
/// 이미지를 리사이즈합니다 (최근접 이웃 보간).
pub fn resize(
    src: &[u8],
    src_w: u32,
    src_h: u32,
    dst_w: u32,
    dst_h: u32,
) -> Vec<u8> {
    let mut dst = vec![0u8; (dst_w * dst_h * 4) as usize];
    let x_ratio = src_w as f64 / dst_w as f64;
    let y_ratio = src_h as f64 / dst_h as f64;
 
    for y in 0..dst_h {
        for x in 0..dst_w {
            let sx = (x as f64 * x_ratio) as u32;
            let sy = (y as f64 * y_ratio) as u32;
 
            let src_idx = ((sy * src_w + sx) * 4) as usize;
            let dst_idx = ((y * dst_w + x) * 4) as usize;
 
            if src_idx + 3 < src.len() && dst_idx + 3 < dst.len() {
                dst[dst_idx..dst_idx + 4].copy_from_slice(&src[src_idx..src_idx + 4]);
            }
        }
    }
 
    dst
}
 
/// 지원하는 필터 유형
pub enum Filter {
    Sepia,
    Grayscale,
    Blur { radius: u32 },
}
 
/// 필터를 적용합니다.
pub fn apply_filter(pixels: &mut [u8], width: u32, height: u32, filter: Filter) {
    match filter {
        Filter::Sepia => apply_sepia(pixels),
        Filter::Grayscale => apply_grayscale(pixels),
        Filter::Blur { radius } => apply_blur(pixels, width, height, radius),
    }
}
Tip

핵심 로직을 별도 크레이트로 분리하면 서버와 브라우저에서 동일한 코드를 재사용할 수 있습니다. 이것이 WebAssembly의 진정한 이식성입니다. 한 번 작성한 이미지 처리 알고리즘이 브라우저에서도, 서버에서도, 엣지에서도 동일하게 동작합니다.

2단계: 브라우저 Wasm 모듈

browser/image-preview/src/lib.rs
rust
use wasm_bindgen::prelude::*;
use image_core::{apply_filter, Filter};
 
#[wasm_bindgen]
pub struct ImagePreview {
    width: u32,
    height: u32,
    original: Vec<u8>,
    current: Vec<u8>,
}
 
#[wasm_bindgen]
impl ImagePreview {
    #[wasm_bindgen(constructor)]
    pub fn new(data: &[u8], width: u32, height: u32) -> Self {
        let original = data.to_vec();
        let current = data.to_vec();
        Self { width, height, original, current }
    }
 
    pub fn apply_sepia(&mut self) {
        self.current = self.original.clone();
        apply_filter(&mut self.current, self.width, self.height, Filter::Sepia);
    }
 
    pub fn apply_grayscale(&mut self) {
        self.current = self.original.clone();
        apply_filter(&mut self.current, self.width, self.height, Filter::Grayscale);
    }
 
    pub fn apply_blur(&mut self, radius: u32) {
        self.current = self.original.clone();
        apply_filter(
            &mut self.current,
            self.width,
            self.height,
            Filter::Blur { radius },
        );
    }
 
    pub fn reset(&mut self) {
        self.current = self.original.clone();
    }
 
    pub fn pixels_ptr(&self) -> *const u8 {
        self.current.as_ptr()
    }
 
    pub fn pixels_len(&self) -> usize {
        self.current.len()
    }
}
browser/app.js
javascript
import init, { ImagePreview } from './pkg/image_preview.js';
 
async function main() {
    await init();
 
    const canvas = document.getElementById('preview-canvas');
    const ctx = canvas.getContext('2d');
 
    // 이미지 로드
    const img = new Image();
    img.onload = () => {
        canvas.width = img.width;
        canvas.height = img.height;
        ctx.drawImage(img, 0, 0);
 
        const imageData = ctx.getImageData(0, 0, img.width, img.height);
        const preview = new ImagePreview(imageData.data, img.width, img.height);
 
        // 필터 버튼 이벤트
        document.getElementById('btn-sepia').addEventListener('click', () => {
            const start = performance.now();
            preview.apply_sepia();
            const elapsed = performance.now() - start;
            console.log(`Sepia filter: ${elapsed.toFixed(2)}ms`);
            updateCanvas(ctx, preview, img.width, img.height);
        });
 
        document.getElementById('btn-grayscale').addEventListener('click', () => {
            const start = performance.now();
            preview.apply_grayscale();
            const elapsed = performance.now() - start;
            console.log(`Grayscale filter: ${elapsed.toFixed(2)}ms`);
            updateCanvas(ctx, preview, img.width, img.height);
        });
 
        document.getElementById('btn-blur').addEventListener('click', () => {
            const start = performance.now();
            preview.apply_blur(3);
            const elapsed = performance.now() - start;
            console.log(`Blur filter: ${elapsed.toFixed(2)}ms`);
            updateCanvas(ctx, preview, img.width, img.height);
        });
 
        document.getElementById('btn-reset').addEventListener('click', () => {
            preview.reset();
            updateCanvas(ctx, preview, img.width, img.height);
        });
    };
    img.src = '/sample.jpg';
}
 
function updateCanvas(ctx, preview, width, height) {
    // Wasm 메모리에서 직접 픽셀 데이터 읽기
    const ptr = preview.pixels_ptr();
    const len = preview.pixels_len();
    const memory = new Uint8Array(preview.__wbg_ptr);
 
    const imageData = new ImageData(
        new Uint8ClampedArray(memory.buffer, ptr, len),
        width,
        height
    );
    ctx.putImageData(imageData, 0, 0);
}
 
main();

3단계: Spin 서버 API

server/image-api/src/lib.rs
rust
use spin_sdk::http::{IntoResponse, Request, Response, Method};
use spin_sdk::http_component;
use image_core::{apply_filter, resize, Filter};
use serde::{Deserialize, Serialize};
 
#[derive(Deserialize)]
struct ProcessRequest {
    filter: String,
    width: Option<u32>,
    height: Option<u32>,
    blur_radius: Option<u32>,
}
 
#[derive(Serialize)]
struct ProcessResponse {
    status: String,
    original_size: usize,
    processed_size: usize,
    processing_time_ms: f64,
}
 
#[http_component]
fn handle(req: Request) -> anyhow::Result<impl IntoResponse> {
    match (req.method(), req.path()) {
        (Method::Post, "/api/process") => process_image(req),
        (Method::Get, "/api/health") => health_check(),
        _ => Ok(Response::builder()
            .status(404)
            .header("content-type", "application/json")
            .body(r#"{"error":"Not Found"}"#)
            .build()),
    }
}
 
fn process_image(req: Request) -> anyhow::Result<Response> {
    let start = std::time::Instant::now();
 
    // 요청 파싱 (멀티파트 대신 간소화된 형태)
    let content_type = req.header("content-type")
        .and_then(|v| v.as_str().ok())
        .unwrap_or("");
 
    // 쿼리 파라미터에서 처리 옵션 추출
    let query = req.query();
    let filter_name = query.get("filter").map(|s| s.as_str()).unwrap_or("none");
    let target_width: Option<u32> = query.get("width").and_then(|s| s.parse().ok());
    let target_height: Option<u32> = query.get("height").and_then(|s| s.parse().ok());
    let blur_radius: u32 = query.get("blur_radius")
        .and_then(|s| s.parse().ok())
        .unwrap_or(3);
 
    let body = req.body().to_vec();
    let original_size = body.len();
 
    // 이미지 디코딩 (간소화 - 실제로는 image 크레이트 사용)
    let mut pixels = body.clone();
    let img_width: u32 = 800;  // 실제로는 헤더에서 추출
    let img_height: u32 = 600;
 
    // 필터 적용
    match filter_name {
        "sepia" => apply_filter(&mut pixels, img_width, img_height, Filter::Sepia),
        "grayscale" => apply_filter(&mut pixels, img_width, img_height, Filter::Grayscale),
        "blur" => apply_filter(
            &mut pixels,
            img_width,
            img_height,
            Filter::Blur { radius: blur_radius },
        ),
        _ => {}
    }
 
    // 리사이즈
    let result = if let (Some(w), Some(h)) = (target_width, target_height) {
        resize(&pixels, img_width, img_height, w, h)
    } else {
        pixels
    };
 
    let elapsed = start.elapsed().as_secs_f64() * 1000.0;
 
    let response = ProcessResponse {
        status: "ok".to_string(),
        original_size,
        processed_size: result.len(),
        processing_time_ms: elapsed,
    };
 
    Ok(Response::builder()
        .status(200)
        .header("content-type", "application/json")
        .body(serde_json::to_string(&response)?)
        .build())
}
 
fn health_check() -> anyhow::Result<Response> {
    Ok(Response::builder()
        .status(200)
        .header("content-type", "application/json")
        .body(r#"{"status":"ok","runtime":"spin-wasm"}"#)
        .build())
}

4단계: 엣지 배포

server/spin.toml
toml
spin_manifest_version = 2
 
[application]
name = "image-service"
version = "1.0.0"
description = "WebAssembly-powered image processing service"
 
[[trigger.http]]
route = "/api/..."
component = "image-api"
 
[component.image-api]
source = "target/wasm32-wasip2/release/image_api.wasm"
allowed_outbound_hosts = []
 
[component.image-api.build]
command = "cargo component build --release"
deploy.sh
bash
# 빌드
cd server/image-api
spin build
 
# 로컬 테스트
spin up &
 
# 헬스 체크
curl http://localhost:3000/api/health
 
# 이미지 처리 테스트
curl -X POST http://localhost:3000/api/process?filter=sepia \
  --data-binary @test-image.raw \
  -H "Content-Type: application/octet-stream"
 
# Fermyon Cloud / Akamai Edge에 배포
spin deploy

5단계: 성능 벤치마킹

프로젝트를 완성했으니, 실제 성능을 측정해 보겠습니다.

브라우저 필터 성능 (1920x1080 이미지)

필터JavaScriptWasm (Rust)개선
세피아45ms12ms3.8배
그레이스케일38ms9ms4.2배
블러 (r=3)890ms210ms4.2배
리사이즈 (50%)120ms35ms3.4배

서버 API 성능

항목Node.jsSpin (Wasm)개선
콜드 스타트150ms0.5ms300배
세피아 필터52ms14ms3.7배
메모리 사용85MB8MB10.6배
동시 인스턴스 (1GB)약 12개약 120개10배
Info

위 벤치마크는 특정 환경에서의 측정값이며, 실제 성능은 하드웨어, 이미지 크기, 알고리즘 복잡도에 따라 달라질 수 있습니다. 중요한 것은 절대적인 수치가 아니라, Wasm이 연산 집약적 작업에서 일관되게 높은 성능을 보인다는 패턴입니다.

Wasm 도입 의사결정 가이드

조직에서 WebAssembly 도입을 검토할 때 고려해야 할 핵심 질문들입니다.

도입이 적합한 경우

다음 조건 중 여러 개에 해당한다면 Wasm 도입을 적극 고려할 만합니다.

  • 콜드 스타트가 중요합니다: 서버리스, 엣지 환경에서 밀리초 단위의 응답 시간이 요구됩니다
  • 연산 집약적 작업이 있습니다: 이미지/비디오 처리, 암호화, 데이터 변환 등
  • 멀티테넌트 격리가 필요합니다: 서드파티 코드를 안전하게 실행해야 합니다
  • 크로스 플랫폼 배포가 필요합니다: 동일 코드를 브라우저, 서버, 엣지에서 실행해야 합니다
  • 다국어 팀입니다: Rust, Go, Python 등 다양한 언어의 모듈을 조합해야 합니다

도입이 부적합한 경우

반면 다음 상황에서는 기존 기술이 더 나은 선택일 수 있습니다.

  • 팀에 Rust/C++ 경험이 없고, 학습 여력이 부족합니다: Wasm 생태계의 핵심 도구는 Rust 기반입니다
  • 기존 인프라가 잘 동작하고, 성능 문제가 없습니다: 작동하는 것을 바꿀 이유가 없습니다
  • 데이터베이스 집약적 CRUD 앱입니다: Wasm의 이점이 크지 않습니다
  • 디버깅 도구의 성숙도가 중요합니다: 아직 개선 중인 영역입니다

WebAssembly의 미래

2026년은 Fermyon의 Spin 2.x CEO인 Matt Butcher가 "일반 개발자가 Wasm을 진지하게 인식하는 해"라고 예측한 시점입니다. 실제로 그 예측은 현실이 되고 있습니다.

단기 전망 (2026-2027)

  • WASI 1.0 안정화: 2026년 말~2027년 초 예상. 비동기 지원, 소켓, HTTP가 안정화됩니다
  • 컴포넌트 모델 성숙: 언어별 도구 체인이 안정화되어 실제 프로덕션 사용이 보편화됩니다
  • SpinKube GA: CNCF 프로젝트로서 Kubernetes 통합이 완성됩니다
  • 더 많은 클라우드 제공자의 Wasm 지원: AWS, GCP, Azure의 네이티브 Wasm 지원 확대

중기 전망 (2027-2029)

  • GC 통합: Wasm GC 제안이 안정화되어 Java, Kotlin, Dart 등의 Wasm 지원이 본격화됩니다
  • 디버깅 도구 성숙: 소스 레벨 디버깅, 프로파일링이 네이티브 수준에 근접합니다
  • AI 워크로드 표준화: 엣지 AI 추론을 위한 WASI 인터페이스가 표준화됩니다

장기 전망

WebAssembly의 궁극적 비전은 Solomon Hykes의 "write once, run anywhere" 약속의 실현입니다. 브라우저, 서버, 엣지, IoT, 데스크톱 등 어디서든 동일한 바이너리가 안전하고 빠르게 실행되는 세계입니다.

Info

WebAssembly는 완성된 기술이 아니라 진화 중인 기술입니다. 2026년 현재도 WASI 비동기 지원, 디버깅 도구, GC 통합 등 많은 영역이 발전하고 있습니다. 하지만 서버리스, 엣지, 플러그인 시스템 등 특정 영역에서는 이미 프로덕션 준비가 완료되었습니다. 기술의 성숙도를 정확히 파악하고, 적합한 영역부터 점진적으로 도입하는 것이 현명한 접근입니다.

시리즈 정리

10장에 걸쳐 WebAssembly의 전체 그림을 살펴보았습니다.

장핵심 내용
1장WebAssembly의 등장 배경, 바이너리/텍스트 포맷, 보안 모델
2장스택 머신 실행 모델, 주요 런타임 비교, AOT vs JIT
3장WASI의 Capability-based 보안, Worlds, 0.3/1.0 로드맵
4장컴포넌트 모델, WIT 타입 시스템, 컴포넌트 구성
5장Rust에서 Wasm 빌드, 크기 최적화, Spin HTTP 핸들러
6장Go, Python, C/C++, AssemblyScript의 Wasm 지원
7장브라우저 고성능 앱, wasm-bindgen, AI 추론
8장Spin, Fermyon/Akamai, SpinKube, Docker vs Wasm
9장Cloudflare Workers, Fastly Compute, Akamai Edge
10장실전 프로젝트, 성능 벤치마킹, 도입 가이드, 미래 전망

WebAssembly는 "한 번 컴파일하면 어디서든 실행된다"는 오래된 약속을 가장 현실적으로 이행하고 있는 기술입니다. 브라우저에서 시작하여 서버, 엣지, IoT로 확장되는 여정은 아직 끝나지 않았습니다. 이 시리즈가 그 여정에 함께하는 출발점이 되기를 바랍니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#webassembly#rust

관련 글

프로그래밍

8장: 서버사이드 Wasm — Spin, Fermyon, SpinKube

Spin 프레임워크의 아키텍처, Fermyon Cloud와 Akamai 통합, SpinKube를 활용한 Kubernetes 배포, Docker와 Wasm의 비교를 통해 서버사이드 WebAssembly의 현재를 분석합니다.

2026년 4월 5일·16분
프로그래밍

9장: 엣지 컴퓨팅과 Wasm 배포

Cloudflare Workers, Fastly Compute, Akamai Edge의 Wasm 실행 환경을 비교하고, 엣지에서의 AI 추론, 콜드 스타트 성능 분석, 엣지 배포 전략을 다룹니다.

2026년 4월 5일·16분
프로그래밍

7장: 브라우저 고성능 앱 — Wasm의 원래 영역

JavaScript와 Wasm의 상호 호출, Web API 연동, 이미지/비디오 처리, AI 추론, 게임 엔진 등 브라우저에서 WebAssembly로 고성능 애플리케이션을 구축하는 방법을 다룹니다.

2026년 4월 3일·14분
이전 글9장: 엣지 컴퓨팅과 Wasm 배포

댓글

목차

약 19분 남음
  • 학습 목표
  • 프로젝트 개요 — 이미지 처리 서비스
  • 1단계: 프로젝트 설정
    • 프로젝트 구조
    • 공유 이미지 처리 로직
  • 2단계: 브라우저 Wasm 모듈
  • 3단계: Spin 서버 API
  • 4단계: 엣지 배포
  • 5단계: 성능 벤치마킹
    • 브라우저 필터 성능 (1920x1080 이미지)
    • 서버 API 성능
  • Wasm 도입 의사결정 가이드
    • 도입이 적합한 경우
    • 도입이 부적합한 경우
  • WebAssembly의 미래
    • 단기 전망 (2026-2027)
    • 중기 전망 (2027-2029)
    • 장기 전망
  • 시리즈 정리