블로그로 돌아가기

Rust 엣지 네이티브 게임 서버: WASM 위 Tokio가 멀티플레이어 아키텍처에 주는 의미

게시일 2026년 9월 29일
Rust 엣지 네이티브 게임 서버: WASM 위 Tokio가 멀티플레이어 아키텍처에 주는 의미 AI의 도움으로 생성됨

핵심 요약

Rust 엣지 네이티브 게임 서버 핵심 아키텍처를 실제 사례와 함께 분석합니다. Tokio on WASM, Emscripten 타깃, 엣지-리전 하이브리드 설계, 비용 비교, 5가지 모범 사례까지 멀티플레이어 게임 백엔드 설계에 지금 바로 적용하세요.

멀티플레이어 백엔드는 한 곳에 존재한다. 아마 us-east-1, 운이 좋으면 eu-west-1일 것이다. 그 리전 밖의 모든 플레이어는 레이턴시 비용을 치르며, 그 비용은 패킷 하나하나에 누적된다.

상파울루의 플레이어가 US-East 서버에 연결하면 단 하나의 게임 패킷이 처리되기 전에 이미 120150ms의 왕복 시간(RTT)이 발생한다. 뭄바이의 플레이어가 EU-West에 접속한다면? 180220ms. 실시간 멀티플레이어에게 이는 '반응이 빠름'과 '플레이 불가' 사이의 차이다.

기존의 해결책은 비용이 많이 든다. 여러 리전에 전용 서버를 배포하고, 지오 라우팅을 설정하고, 별도의 데이터베이스 복제본을 유지하면서 리전당 월 3,000~8,000달러를 책정해야 한다. 대부분의 인디 스튜디오는 출시 후 수익이 실제 사용자층을 증명하기 전까지는 이 비용을 정당화하기 어렵다.

하지만 새로운 아키텍처 가능성이 떠오르고 있다. 이제 Rust 게임 서버 로직을 WebAssembly로 컴파일해 전 세계 300개 이상의 도시에 있는 엣지 노드에서 실행할 수 있다. Cloudflare는 새로운 Emscripten 컴파일 타깃을 통해 Tokio 기반 Rust 애플리케이션을 자사 Workers 플랫폼에서 실행하는 실험적 지원을 막 출시했다. 멀티플레이어 게임 백엔드에 미치는 영향은 상당하다. 이 글은 Rust WASM 엣지 게임 서버가 실제로 어떻게 생겼는지, 어디에서 빛을 발하고 어디에서 실패하는지, 그리고 그 제약을 어떻게 아키텍처로 해결하는지를 구체적으로 설명한다.

무엇이 바뀌었나: Emscripten 타깃이 라이브러리 장벽을 허문다

이전에 Rust를 엣지 워커에서 실행하려면 두 가지 나쁜 선택지 중 하나를 골라야 했다.

  1. wasm32-unknown-unknown — 계산 전용 함수에는 동작하지만 네이티브 플랫폼 기능을 제거한다. 소켓도, 파일시스템도 없고, 타이머도 제한적이다. 실제 게임 서버에는 쓸모없다.
  2. 네이티브 바이너리 — 전통적인 VM 배포로 제한되며, 이는 리전별 데이터센터 비용을 의미한다.

wasm-bindgen의 새로운 wasm32-unknown-emscripten 타깃이 이를 바꾼다. Emscripten은 Web Platform API 위에 타이머, 파일시스템 연산, 소켓 같은 네이티브 플랫폼 기능을 가상화한다. wasm-bindgen의 Rust-to-JavaScript 인터롭 레이어와 결합하면 다음을 얻을 수 있다.

  • 가상화된 I/O를 통한 완전한 TCP 소켓 지원 (Cloudflare 팀은 실제 TCP 소켓을 사용하는 Minecraft 서버를 포팅했다)
  • 게임 루프와 틱 레이트를 위한 타이머 및 스케줄링 지원
  • 논블로킹 연산을 위한 Tokio async 런타임 통합
  • V8의 최적화된 WebAssembly 실행을 통한 네이티브에 가까운 성능

결과적으로 Rust 게임 서버는 일반적인 Unix 시스템에서 실행된다고 생각하지만, 실제로는 수백 개 도시에 분산된 엣지 인프라에서 실행된다. Emscripten 타깃은 target_family = unix를 보고하므로 대부분의 저수준 시스템 라이브러리는 수정 없이 동작한다.

게임 개발자에게 이는 가벼운 Rust WASM 엣지 게임 서버가 단 하나의 데이터센터도 배포하지 않고도 플레이어 기반의 90% 이상에게 세션 관리, 플레이어 상태, 입력 검증을 10ms 미만의 레이턴시로 처리할 수 있음을 의미한다.

최소한의 엣지 게임 상태 핸들러는 다음과 같다.

use wasm_bindgen::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Serialize, Deserialize, Clone)]
struct PlayerState {
    player_id: String,
    position: [f32; 3],
    health: u16,
    last_update: u64,
}

#[derive(Serialize, Deserialize)]
struct GameState {
    players: HashMap<String, PlayerState>,
    tick: u64,
}

#[wasm_bindgen]
pub struct EdgeGameServer {
    state: GameState,
}

#[wasm_bindgen]
impl EdgeGameServer {
    #[wasm_bindgen(constructor)]
    pub fn new() -> EdgeGameServer {
        EdgeGameServer {
            state: GameState {
                players: HashMap::new(),
                tick: 0,
            },
        }
    }

    /// Update a player's position — called per input frame
    pub fn update_player(
        &mut self,
        player_id: &str,
        x: f32,
        y: f32,
        z: f32,
    ) -> String {
        self.state.tick += 1;
        let entry = self.state.players
            .entry(player_id.to_string())
            .or_insert(PlayerState {
                player_id: player_id.to_string(),
                position: [0.0, 0.0, 0.0],
                health: 100,
                last_update: 0,
            });
        entry.position = [x, y, z];
        entry.last_update = self.state.tick;

        // Return authoritative state snapshot to the client
        serde_json::to_string(&self.state).unwrap_or_default()
    }

    /// Full state snapshot for a new player joining
    pub fn get_snapshot(&self) -> String {
        serde_json::to_string(&self.state).unwrap_or_default()
    }
}

이 코드는 WebAssembly로 컴파일되어 Cloudflare Worker 내부에 로드되며, 인근 플레이어의 입력을 한 자릿수 밀리초 레이턴시로 처리한다. wasm-bindgen 레이어가 Rust와 JavaScript를 연결한다. Worker의 요청 핸들러가 WASM 모듈을 호출해 권위 있는 상태를 갱신하고 결과를 반환한다.

Tokio 문제: 동기식 엣지에서의 비동기 런타임

게임 서버는 단순한 상태 머신이 아니다. 동시 네트워크 I/O, 타이머, 그리고 백엔드 통신을 위한 TCP 연결까지 처리해야 한다. Rust에서는 이것이 Tokio의 몫이다.

하지만 Cloudflare Workers — 그리고 일반적으로 엣지 런타임 — 는 단일 스레드이며 JavaScript 이벤트 루프 안에서 호스팅된다. Tokio의 async 모델은 스레드 parking 의미론을 사용한 블로킹 연산을 기반으로 구축된다. 이 두 모델은 근본적으로 호환되지 않는다. 블로킹 epoll_wait 하나가 공유 이벤트 루프 전체를 멈춰 세워 해당 워커의 다른 모든 요청을 지연시키기 때문이다.

Cloudflare 엔지니어들은 두 가지 상호 보완적인 접근 방식으로 이 문제를 해결했으며, 둘 다 업스트림 Tokio에 대한 실험적 패치셋으로 구현되었다.

접근법 1: WebAssembly JavaScript Promise Integration (JSPI)

JSPI는 블로킹 WebAssembly 호출이 자신의 스택을 일시 중단하고 제어권을 JavaScript 이벤트 루프로 돌려줄 수 있게 한다. 이전 호출이 일시 중단된 상태에서 새 Wasm 호출이 들어오면 JSPI는 별도의 WebAssembly 스택을 생성한다. 둘은 충돌 없이 공존한다.

문제는 런타임 내부에 있다. Rust의 스레드 로컬 저장소는 스택 전환을 알지 못한다. Tokio는 스레드 로컬을 통해 런타임 컨텍스트를 추적하는데, JSPI 스택 전환은 스레드 전환이 아니다. 일시 중단된 두 스택이 결국 동일한 스레드 로컬 상태를 공유하게 되고, 런타임이 이미 진입했다고 판단하면 패닉이 발생한다.

해결책은 각 JSPI enter, exit, suspend, resume 호출 시 협력적 컨텍스트 스위칭을 수행하는 것이다. 사실상 각 일시 중단 스택이 자신의 런타임 컨텍스트를 갖는 시분할 스레딩이다. 취약하지만 동작하며, Cloudflare 팀은 자체 Tokio 패치셋에서 이를 검증했다.

접근법 2: Tokio를 위한 LocalEventLoop 런타임

더 일반적인 해결책은 Tokio의 실행 루프를 두 개의 개별 연산으로 분리하는 것이다.

  1. drive() — 준비된 모든 태스크를 한 배치로 폴링한 후 즉시 반환한다.
  2. wake() — 호스트 이벤트 루프에 '처리할 작업이 있으니 준비되면 drive()를 호출하라'고 알린다.

런타임은 스레드를 parking하는 대신 호스트에 신호를 보낸다. 호스트 — 즉 Worker의 요청 핸들러 — 는 자신의 마이크로태스크 주기에서 drive()를 호출한다. Tokio는 절대 블로킹하지 않으며 호스트의 스케줄링에 협력한다.

use tokio::runtime::LocalHandle;

/// Edge worker entry point — no blocking, cooperates with JS event loop
#[wasm_bindgen]
pub async fn handle_game_request(
    handle: &LocalHandle,
    request_body: &str,
) -> String {
    // Parse incoming player input
    let input: PlayerInput = serde_json::from_str(request_body)
        .unwrap_or_default();

    // Spawn async work on Tokio's local event loop
    // The host JS event loop drives this via wake()/drive()
    let result = handle.spawn_local(async move {
        // Async operations: DB read, backend API call, timer
        let player_data = fetch_player_profile(&input.player_id).await;
        apply_game_logic(input, player_data).await
    }).await;

    result.unwrap_or_else(|_| String::from("{\"error\":\"tick_failed\"}"))
}

LocalEventLoop 설계는 엣지 워커뿐만 아니라 기존 이벤트 루프에 Tokio를 통합해야 하는 Windows와 macOS 네이티브 UI 애플리케이션을 위한 범용 Tokio 기능으로 제안되었다. 이러한 일반성 덕분에 업스트림 채택 가능성이 높아진다.

엣지 게임 서버가 실제로 효과적인 곳 (그리고 그렇지 않은 곳)

구체적으로 살펴보자. 엣지 네이티브 게임 서버는 전용 서버의 보편적인 대체재가 아니다. 특정 레이턴시 민감 워크로드를 위한 특수한 도구다.

엣지에서 빛을 발하는 워크로드

플레이어 세션 관리 (sub-10ms) 인증 상태, 연결 메타데이터, 세션 토큰을 가장 가까운 엣지 노드에 둔다. 세션 토큰 검증을 위해 대륙 간 왕복이 필요 없다. 동시 접속 10,000명의 게임이라면 초당 10,000회의 세션 검증을 중앙 집중식이 아닌 로컬에서 처리한다.

카드/턴제 게임을 위한 가벼운 권위 상태 카드 게임의 상태 — 패 내용, 보드 배치, 턴 순서 — 는 보통 플레이어당 10KB 미만이다. 엣지 노드는 5ms 미만의 읽기 레이턴시로 이를 유지한다. Hearthstone, Slay the Spire 멀티플레이어, 또는 모든 턴제 전략 게임은 이 모델에 깔끔하게 맞아떨어진다.

프레즌스 및 하트비트 집계 '누가 온라인인가?' 질의가 중앙 데이터베이스 호출 대신 로컬 읽기가 된다. 엣지 노드는 하트비트를 리전별로 추적하고 주기적으로(5~10초마다) 글로벌 뷰로 집계한다.

리더보드 파티셔닝 리전별 리더보드는 엣지 노드에서 집계되어 일정에 따라 글로벌 순위로 합쳐진다. 플레이어는 자신의 지역 순위를 즉시 확인하고, 글로벌 순위는 수초 내에 갱신된다. '내 리전에서의 내 순위'가 글로벌 순위만큼 중요한 경쟁 게임에서 특히 효과적이다.

매치메이킹 상태 머신 로비 흐름 — 플레이어 큐, 실력 구간, 방 생성 — 은 주기적으로 동기화되는 엣지 로컬 상태 머신으로 매핑된다. 매치메이킹 로직이 플레이어와 가장 가까운 노드에서 실행되므로 큐 대기 시간이 줄어든다.

엣지에서 실패하는 워크로드

권위 있는 물리 시뮬레이션 20개 이상 엔티티의 충돌 감지를 포함한 60Hz 물리 틱을 실행하는 것은 일반적인 엣지 워커 CPU 예산을 초과한다. Cloudflare Workers는 요청당 1050ms의 CPU 시간 제한이 있다(플랜에 따라 다름). 중간 정도 복잡도의 씬에서 단일 물리 프레임은 전용 하드웨어에서 28ms가 걸리는데, 이는 엣지 예산에 너무 근접해 신뢰할 수 없다.

대규모 월드 상태 동기화 게임 상태가 약 1MB를 초과하면 엣지 워커의 직렬화 및 전송 비용이 감당하기 어려워진다. MMO 월드 상태, 대형 지형, 엔티티가 많은 씬은 영구 메모리를 갖춘 전통적인 서버에 속한다.

무거운 프레임 루프를 가진 지속 TCP 연결 Emscripten의 Tokio가 TCP 소켓을 지원하지만, 프레임별 처리(60Hz)가 있는 장기 연결을 유지하는 것은 엣지 워커 수명 제한에 부딪힌다. 대부분의 엣지 플랫폼은 호출당 30초~5분의 실행 시간을 강제한다.

아키텍처 패턴: 엣지 + 리전 하이브리드

실용적인 패턴은 '올 엣지'도 '올 전통'도 아니다. 백엔드를 레이턴시 민감 계층과 계산 집약 계층으로 분리하는 것이다.

Player Device
      │
      ▼
┌─────────────────────────────────────────┐
│         EDGE NODE (nearest city)         │
│  • Session management                    │
│  • Player state cache (read-heavy)       │
│  • Input validation & rate limiting      │
│  • Presence / heartbeat tracking         │
│  • Regional leaderboard queries          │
└──────────────────┬──────────────────────┘
                   │ (periodic sync, 1-5s)
                   ▼
┌─────────────────────────────────────────┐
│       REGIONAL GAME SERVER               │
│  • Authoritative physics / simulation    │
│  • World state management                │
│  • Heavy AI processing                   │
│  • Database writes (strong consistency)  │
└──────────────────┬──────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────┐
│       PERSISTENCE LAYER                  │
│  • Player accounts & authentication      │
│  • Inventory / progression storage       │
│  • Leaderboard persistence               │
│  • Cloud save with conflict resolution   │
└─────────────────────────────────────────┘

엣지 계층은 근접성에서 이점을 얻는 모든 것을 처리한다. 세션 검증, 입력 정화, 캐시된 플레이어 데이터 읽기, 프레즌스 추적 등이다. 그리고 주기적으로 리전 서버와 동기화한다. 중요하지 않은 상태는 1~5초마다, 데미지나 인벤토리 변경 같은 권위 있는 액션은 즉시 동기화한다.

이 아키텍처는 대부분의 지리적 리전에 있는 플레이어가 느끼는 일반적인 작업의 레이턴시를 120200ms에서 515ms로 줄인다. 경쟁 멀티플레이어 게임에서 이는 '반응이 빠름'과 '느릿함'의 차이다.

Unreal Engine 멀티플레이어에서 상태 불일치를 디버깅해 본 적이 있다면 명확한 경계 없이 일관성 모델을 섞는 것이 가장 재현하기 어려운 종류의 디싱크를 만든다는 것을 알 것이다. 엣지-리전 분할은 그 경계를 명시적으로 만든다. 엣지는 최종적 일관성(eventually consistent)이고, 리전은 권위적이다.

비용 분석: 엣지 vs. 전통적 멀티 리전

다음은 동시 접속 10,000명의 멀티플레이어 게임에 대한 현실적인 월 비용 비교다.

인프라 전통적 멀티 리전 엣지 + 리전 하이브리드
US-East 서버 $400–800/mo $400–800/mo
EU-West 서버 $400–800/mo $400–800/mo
아시아-태평양 서버 $400–800/mo —
엣지 컴퓨팅 (300개 이상 도시) — $50–200/mo
Geo-routing / DNS $50–100/mo $50–100/mo
합계 $1,250–2,500/mo $900–1,900/mo

엣지 접근 방식은 해당 플레이어들을 엣지 노드로 커버함으로써 세 번째 리전 배포의 필요성을 없앤다. 100,000 CCU에서는 절감 효과가 더 커진다. 세션 및 가벼운 상태 워크로드의 경우 전통적 멀티 리전 비용이 엣지-하이브리드 접근 방식보다 3~4배 더 높다.

엣지 컴퓨팅 가격은 보통 요청당 또는 호출당으로 책정되므로 변동이 심한 트래픽 패턴 — 게임 서버가 피크 시간과 비수기에 경험하는 바로 그 패턴 — 에 비용 효율적이다.

리전별 전용 인프라에서 유휴 컴퓨팅 비용을 줄이는 전략에 대해서는 Fortnite의 서버 하이버네이션 제안 분석에서 저트래픽 시간대에 서버를 축소하는 기법을 다룬다.

엣지 네이티브 게임 백엔드를 위한 5가지 모범 사례

1. 레이턴시 민감도에 따라 상태를 분리하라 모든 게임 상태는 두 범주로 나뉜다. 레이턴시 중요 상태(플레이어 위치, 입력 상태, 세션 데이터)와 일관성 중요 상태(월드 상태, 진행 저장, 리더보드)다. 첫 번째 범주는 엣지 노드에 두고, 두 번째 범주는 리전 서버나 영속성 계층에 유지하라. 500ms의 오래된 상태(staleness)를 견딜 수 있는 상태라면 엣지에 속한다.

2. 최종적 일관성을 설계하고 테스트하라 서로 다른 도시의 엣지 노드는 게임 상태에 대해 잠시 불일치할 수 있다. 이를 설계 제약으로 받아들여라. 엣지 캐시 상태에는 last-write-wins 의미론이나 CRDT를 사용하라. 두 엣지 노드가 동시에 같은 플레이어의 입력을 처리하는 상황을 시뮬레이션하고 병합 결과가 유효한지 검증하는 명시적 테스트를 작성하라.

#[cfg(test)]
mod consistency_tests {
    use super::*;

    #[test]
    fn two_edge_nodes_concurrent_update_merges_cleanly() {
        let mut state_a = GameState::new();
        let mut state_b = GameState::new();

        // Simulate two edge nodes processing input for same player
        state_a.update_player("player_1", 10.0, 0.0, 5.0);
        state_b.update_player("player_1", 12.0, 0.0, 3.0);

        // Merge with last-write-wins (higher tick wins)
        let merged = merge_states(&state_a, &state_b);
        let player = merged.players.get("player_1").unwrap();

        // Later tick's position should win
        assert_eq!(player.position, [12.0, 0.0, 3.0]);
    }
}

3. 엣지 워커 실행 시간에 하드 상한을 설정하라 대부분의 엣지 플랫폼은 호출당 30초5분 제한을 적용한다. 틱 로직을 조기에 프로파일링하라. 전용 서버에서 45ms가 걸리는 단일 게임 틱은 WASM 시작 오버헤드와 가상화된 I/O 때문에 엣지 인프라에서 6080ms가 걸릴 수 있다. Rust의 web_sys performance API로 계측하라.

use web_sys::window;

fn timed_tick<F: FnOnce() -> R, R>(label: &str, f: F) -> R {
    let perf = window()
        .expect("no window")
        .performance()
        .expect("no performance API");
    let start = perf.now();
    let result = f();
    let elapsed = perf.now() - start;

    if elapsed > 50.0 {
        web_sys::console::warn_1(
            &format!("[{}] tick exceeded 50ms budget: {:.2}ms", label, elapsed).into(),
        );
    }
    result
}

4. 엣지 노드를 write-through 캐시가 아닌 read-through 캐시로 사용하라 읽기는 엣지에서, 쓰기는 리전에서 수행하라. 엣지 노드는 자주 읽히는 플레이어 데이터 — 프로필, 로드아웃, 최근 매치 기록 — 를 캐시하고, 다른 플레이어에게 영향을 주거나 영속성이 필요한 상태 변경만 권위 있는 저장소로 전달하라. 이렇게 하면 엣지 노드는 빠르게 유지되고 데이터는 일관되게 유지된다. 대부분의 게임 세션에서 읽기/쓰기 비율은 10:1 이상이다.

5. 엣지-리전 동기화 지연을 일급 메트릭으로 모니터링하라 권위 있는 상태에서 너무 멀리 벗어난 엣지 상태는 최악의 버그를 만든다. 간헐적이고, 지역에 따라 다르며, 로컬에서 재현이 거의 불가능한 버그다. 동기화 계층에 첫날부터 관측 가능성을 구축하라. 동기화 간격을 추적하고, 엣지 캐시 상태와 권위 있는 상태 사이의 차이를 측정하며, 지연이 게임의 허용 한계 — 일반적으로 중요하지 않은 상태는 1~3초, 전투 관련 데이터는 500ms 미만 — 를 초과하면 알림을 받아라.

다음 멀티플레이어 프로젝트에 주는 의미

엣지 워커에서 Rust를 위한 Emscripten 타깃은 전용 게임 서버의 대체재가 아니다. 전통적인 멀티 리전 배포가 비용을 들여 불완전하게 처리해온 '라스트 마일' 레이턴시 문제를 해결하는 새로운 아키텍처 계층이다.

멀티플레이어 게임을 만들고 있고 서버까지의 레이턴시가 플레이어 불만을 일으키고 있다면 — 특히 주요 서버 리전 밖의 플레이어들 — 백엔드를 분리하는 것을 고려하라. 세션 관리, 프레즌스, 가벼운 상태를 엣지 노드로 옮기고, 물리, AI, 월드 시뮬레이션은 리전 인프라에 유지하라. 그리고 주기적 동기화 계층으로 연결하라.

도구는 실험적이지만 오늘날 기능한다. Cloudflare의 Rust Workers 예제는 TCP 소켓, Tokio async, 상태 저장 Durable Objects가 함께 동작하는 모습을 보여준다. 더 많은 스튜디오가 채택할수록 패턴은 빠르게 성숙할 것이다.

작게 시작하라. 기존 백엔드에 엣지 캐시 세션 검증을 추가하고, 가장 멀리 있는 플레이어의 레이턴시 개선을 측정하라. 수치가 맞다면 더 많은 엣지 네이티브 상태 관리로 확장하라.

이 아키텍처의 영속성 측면 — 엣지 노드가 동기화 대상으로 삼는 플레이어 인증, 클라우드 세이브, 리더보드 — 은 horizOn이 처리한다. 계정에 바인딩된 클라우드 세이브, 리비전 인식 충돌 해결, 크로스 디바이스 리더보드, 멀티 프로바이더 인증을 제공한다. 엣지 서버는 실시간 상태에 집중하고, horizOn은 서버 재시작 후에도 유지되어야 하는 모든 것을 담당한다.

글로벌 도달을 위해 멀티플레이어 백엔드를 설계할 준비가 되었는가? 게임 상태 연산 중 어떤 것이 레이턴시 민감하고 어떤 것이 일관성 중요인지 매핑하는 것부터 시작하라. 그 단일 설계 결정이 나머지 모든 것을 결정한다.


출처: Workers에서 네이티브 Rust 지원 — wasm-bindgen용 새 Emscripten 타깃

이 대시보드는 다음에 의해 애정을 담아 만들어졌습니다 Projectmakers

© 2026 projectmakers.de

unknown-v1.103.4 / unknown-v--