LabHub

블로그

Bevy 게임 엔진 — Rust로 다시 쓰는 ECS 패러다임의 게임 개발 (심층 핸즈온, 2026)

한국어English日本語

프롤로그 — 왜 Rust와 ECS가 게임 개발에 어울리는가

게임 엔진의 역사는 두 줄로 압축할 수 있다.

  1. 상속 시대(1995~2015) — Unity의 MonoBehaviour, Unreal의 UObject, Godot의 Node. 모든 게임 오브젝트는 클래스를 상속받고, "GameObject is a Component" 식의 깊은 계층을 만들었다. C++ 또는 C# 같은 OOP 언어가 자연스러웠다.
  2. 데이터 지향 시대(2015~) — Naughty Dog, Insomniac, Epic의 Mass(Unreal 5)가 "캐시 미스가 곧 프레임 드랍"이라는 걸 인정했다. Data-Oriented Design(DOD)이 부상했고, Entity-Component-System(ECS)이 모범 답안으로 등장했다.

ECS는 한 줄로 이렇다.

상속 트리가 없다. Player extends Character extends Pawn 같은 것 자체가 사라진다. "플레이어"는 그냥 Player, Position, Velocity, Health component를 같이 가진 entity일 뿐이다.

여기에 Rust를 얹으면 ECS의 약점이었던 두 가지가 즉시 해결된다. 첫째, 동시성. 빌림 검사기(borrow checker)가 "두 system이 같은 component를 동시에 mutate하지 못한다"를 컴파일 타임에 보장한다. Bevy의 스케줄러는 이걸 이용해 system을 자동 병렬화한다. 둘째, 안정성. 게임 코드는 늘 포인터·null·범위 외 접근 버그로 멍든다. Rust는 그 카테고리 자체를 없앤다.

문제는 "현실"이다. 2026년 5월, Unity는 여전히 모바일·인디·VR 1위, Unreal은 AAA·시네마틱 1위, Godot은 인디 2D 진영에서 큰 점유, Bevy는 오픈소스 진영의 별이지만 출시된 상업 게임은 손에 꼽는다. 그래서 이 글은 "Year of Bevy"를 외치지 않는다. 대신 정직하게 답한다 — Bevy는 무엇을 잘하고, 어디까지 와 있고, 어디서 막혀 있는가.

이 글의 구성.

  1. ECS 패러다임 이해 — MonoBehaviour와 무엇이 다른가
  2. Bevy의 구조 — App, Schedule, Plugin
  3. 플러그인 아키텍처 — 모든 것이 플러그인이다
  4. wgpu로 렌더링 — 브라우저·데스크톱·모바일을 한 번에
  5. 첫 Bevy 게임 30분 — 키 입력으로 움직이는 원
  6. Godot·Unity·Unreal·Macroquad와의 비교
  7. 실제로 출시된 Bevy 게임들 — Foresight, Roll It Up, 그리고 Tiny Glade의 진실
  8. Bevy가 아직 준비 안 된 곳

기억할 한 줄: "엔진은 도구지 신앙이 아니다. 하지만 ECS는 도구 이전에 패러다임이고, Bevy는 그 패러다임을 Rust로 가장 정직하게 구현한 답이다."


1장 · ECS 패러다임 — Unity·Unreal과 무엇이 다른가

1.1 상속 모델의 작동 방식

Unity에서 적 캐릭터를 만들면 보통 이렇다.

// Unity / MonoBehaviour 스타일
public class Enemy : MonoBehaviour {
    public int health = 100;
    public float speed = 2.0f;
    private Transform target;

    void Start() { target = GameObject.FindWithTag("Player").transform; }
    void Update() {
        transform.position = Vector3.MoveTowards(
            transform.position, target.position, speed * Time.deltaTime);
        if (health <= 0) Destroy(gameObject);
    }
}

EnemyMonoBehaviour를 상속받고, 상태(체력·속도)와 동작(Update)을 같은 클래스에 둔다. 객체 100개면 Update가 100번 호출된다. 가상 함수 호출 + 캐시 미스 + 분기 예측 실패가 누적된다.

Unreal의 AActor + UCharacterMovementComponent 조합도 본질은 같다. AEnemy : public ACharacter : public APawn : public AActor라는 깊은 상속 체인, 그리고 각 컴포넌트는 부모 actor에 포인터를 들고 다닌다.

1.2 ECS의 작동 방식

같은 적 캐릭터를 Bevy로 쓰면.

// Bevy / ECS 스타일
use bevy::prelude::*;

#[derive(Component)]
struct Enemy;

#[derive(Component)]
struct Health(i32);

#[derive(Component)]
struct Speed(f32);

#[derive(Component)]
struct Target(Entity);

fn move_enemies(
    time: Res<Time>,
    mut enemies: Query<(&mut Transform, &Speed, &Target), With<Enemy>>,
    targets: Query<&Transform, Without<Enemy>>,
) {
    for (mut tf, speed, target) in &mut enemies {
        if let Ok(target_tf) = targets.get(target.0) {
            let dir = (target_tf.translation - tf.translation).normalize_or_zero();
            tf.translation += dir * speed.0 * time.delta_secs();
        }
    }
}

fn kill_dead_enemies(mut commands: Commands, q: Query<(Entity, &Health), With<Enemy>>) {
    for (e, hp) in &q {
        if hp.0 <= 0 { commands.entity(e).despawn(); }
    }
}

차이는 명확하다.

1.3 Archetype과 데이터 배치

Bevy(그리고 Flecs, Unity DOTS, Unreal Mass)는 archetype 기반 ECS다. "어떤 component 집합을 가진 entity"별로 메모리 청크를 만든다.

archetype A: [Position, Velocity, Sprite] → entity 1000개의 Position이 연속, Velocity가 연속, Sprite가 연속. archetype B: [Position, Velocity, Sprite, Health, Enemy] → 또 다른 청크.

Query<(&Position, &Velocity)>는 A와 B 양쪽의 청크를 순회하면서, 각 청크 안에선 단순 배열 순회로 끝난다. 가상 함수 호출도 없고, 캐시 미스도 거의 없다. 이게 ECS가 빠른 이유다.

1.4 ECS의 단점도 정직하게

ECS는 만병통치약이 아니다.

기억할 한 줄: "OOP는 동사를 명사 안에 가둔다. ECS는 명사(데이터)와 동사(system)를 끝까지 분리한다."


2장 · Bevy의 구조 — App, Schedule, System

2.1 App

Bevy 프로그램의 진입점은 App이다. main 함수는 보통 이렇게 생겼다.

use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, spawn_camera)
        .add_systems(Update, (move_player, count_fps).chain())
        .run();
}

코드 전체에서 class GameManager : MonoBehaviour { void Awake() } 같은 매니저 클래스가 없다는 점이 핵심이다. App 자체가 매니저고, 게임 로직은 자유 함수다.

2.2 Schedule — 언제 실행되나

Bevy 0.10에서 "스테이지"가 사라지고 스케줄이 들어왔다. 0.15 기준 주요 스케줄은 이렇다.

system 사이 순서는 .before(), .after(), .chain(), SystemSet으로 제어한다.

app.add_systems(
    Update,
    (
        input_system,
        movement_system.after(input_system),
        collision_system.after(movement_system),
    ),
);

2.3 Component·Resource·Event

데이터를 ECS 월드에 넣는 세 가지 방법.

#[derive(Resource)]
struct Score(u32);

#[derive(Event)]
struct EnemyKilled { entity: Entity, by: Entity }

fn award_score(mut score: ResMut<Score>, mut events: EventReader<EnemyKilled>) {
    for _ in events.read() {
        score.0 += 100;
    }
}

Res<T>는 읽기 전용, ResMut<T>는 쓰기 가능. 두 system이 같은 ResMut를 잡으면 자동으로 직렬 실행되고, 한쪽이 Res 한쪽이 Res면 병렬이다.

2.4 Query — system이 데이터를 가져오는 법

fn move_player(
    time: Res<Time>,
    mut q: Query<(&mut Transform, &Speed), With<Player>>,
) {
    for (mut tf, speed) in &mut q {
        tf.translation.x += speed.0 * time.delta_secs();
    }
}

Query 시그니처가 곧 system의 "데이터 요구사항"이다. With<Player>는 필터(component를 받지는 않지만 entity가 가지고 있어야 함). 두 system이 충돌하지 않는 query를 가지면 Bevy는 자동으로 두 개를 병렬 실행한다.

기억할 한 줄: "Bevy 코드를 읽는 법 = system 시그니처만 봐도 그 system이 무엇을 읽고 무엇을 쓰는지 다 보인다."


3장 · 플러그인 아키텍처 — 모든 것이 플러그인이다

Bevy의 가장 큰 미적 결정은 "엔진 자체가 플러그인 컬렉션"이라는 점이다.

DefaultPlugins를 풀어보면 약 20개의 플러그인이 들어 있다.

원하지 않으면 통째로 뺄 수 있다.

// 헤드리스 서버 — 렌더·윈도우 없이 ECS만
App::new()
    .add_plugins(MinimalPlugins)
    .add_plugins(LogPlugin::default())
    .add_systems(Update, game_logic)
    .run();

3.1 자기 플러그인 만들기

내 게임 코드도 플러그인으로 묶으면 모듈화가 깔끔하다.

use bevy::prelude::*;

pub struct PlayerPlugin;

impl Plugin for PlayerPlugin {
    fn build(&self, app: &mut App) {
        app
            .add_event::<PlayerHurt>()
            .add_systems(Startup, spawn_player)
            .add_systems(Update, (
                player_input,
                player_movement.after(player_input),
                player_animation,
            ));
    }
}

#[derive(Event)]
pub struct PlayerHurt { pub amount: i32 }

#[derive(Component)]
pub struct Player;

fn spawn_player(mut commands: Commands) {
    commands.spawn((
        Player,
        Transform::default(),
        // ... sprite, collider, etc.
    ));
}

fn player_input(/* ... */) {}
fn player_movement(/* ... */) {}
fn player_animation(/* ... */) {}

main은 이렇게 짧아진다.

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins((PlayerPlugin, EnemyPlugin, AudioPlugin, UiPlugin))
        .run();
}

3.2 생태계의 플러그인들 (2026년 5월 기준)

bevy.org 또는 bevy_assets 깃허브 레포에 큐레이션된 목록이 있다.

기억할 한 줄: "플러그인은 단순한 모듈이 아니라 Bevy의 사상이다 — 엔진과 게임 코드 사이의 경계가 없다."


4장 · wgpu 렌더링 — 브라우저·데스크톱·모바일을 한 번에

Bevy의 렌더러는 wgpu 위에 얹혀 있다. wgpu는 Rust 진영의 WebGPU 구현으로, 한 코드로 Vulkan·Metal·DirectX 12·WebGPU·OpenGL ES를 모두 타겟한다.

4.1 무엇이 좋은가

4.2 무엇이 약한가

4.3 커스텀 셰이더

WGSL로 셰이더를 짠 다음 Material trait을 구현해 Bevy material로 등록한다.

use bevy::{
    prelude::*,
    render::render_resource::{AsBindGroup, ShaderRef},
    sprite::Material2d,
};

#[derive(Asset, TypePath, AsBindGroup, Clone, Debug)]
struct WaveMaterial {
    #[uniform(0)] time: f32,
    #[uniform(0)] color: LinearRgba,
}

impl Material2d for WaveMaterial {
    fn fragment_shader() -> ShaderRef { "shaders/wave.wgsl".into() }
}

그리고 셰이더 파일.

struct Uniforms {
    time: f32,
    color: vec4<f32>,
};

@group(2) @binding(0) var<uniform> u: Uniforms;

@fragment
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
    let wave = sin(uv.x * 10.0 + u.time) * 0.5 + 0.5;
    return vec4<f32>(u.color.rgb * wave, 1.0);
}

기억할 한 줄: "wgpu 덕에 Bevy는 '한 코드로 어디든 돌아가는 렌더러'를 거의 공짜로 가졌다 — 단, '어디든'이 '최적'은 아니다."


5장 · 첫 Bevy 게임 30분 — 키 입력으로 움직이는 원

이제 코드를 쓴다. "원이 화면에 떠 있고, 화살표 키로 움직인다." 30분 안에 끝낸다.

5.1 프로젝트 시작

# Rust가 없다면 rustup으로 설치
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# 새 프로젝트
cargo new moving_circle && cd moving_circle

Cargo.toml을 연다.

[package]
name = "moving_circle"
version = "0.1.0"
edition = "2021"

[dependencies]
bevy = "0.15"

# 빠른 컴파일을 위한 dev 프로파일 — 권장
[profile.dev]
opt-level = 1

[profile.dev.package."*"]
opt-level = 3

5.2 첫 코드 — 빈 창 띄우기

src/main.rs.

use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .run();
}

fn setup(mut commands: Commands) {
    commands.spawn(Camera2d);
}
cargo run

검은 빈 창이 뜬다. 처음 빌드는 2~3분 걸린다(의존성 컴파일). 이후 increment 빌드는 수 초.

5.3 원 그리기

use bevy::prelude::*;

#[derive(Component)]
struct Player;

#[derive(Component)]
struct Speed(f32);

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
) {
    commands.spawn(Camera2d);

    commands.spawn((
        Player,
        Speed(300.0),
        Mesh2d(meshes.add(Circle::new(40.0))),
        MeshMaterial2d(materials.add(Color::srgb(0.2, 0.7, 1.0))),
        Transform::from_xyz(0.0, 0.0, 0.0),
    ));
}

다시 cargo run. 파란 원 하나가 가운데 떠 있다.

코드의 핵심.

5.4 입력으로 움직이기

update 스케줄에 system을 추가한다.

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .add_systems(Update, move_player)
        .run();
}

fn move_player(
    time: Res<Time>,
    keyboard: Res<ButtonInput<KeyCode>>,
    mut q: Query<(&mut Transform, &Speed), With<Player>>,
) {
    let dt = time.delta_secs();
    for (mut tf, speed) in &mut q {
        let mut dir = Vec2::ZERO;
        if keyboard.pressed(KeyCode::ArrowLeft)  { dir.x -= 1.0; }
        if keyboard.pressed(KeyCode::ArrowRight) { dir.x += 1.0; }
        if keyboard.pressed(KeyCode::ArrowUp)    { dir.y += 1.0; }
        if keyboard.pressed(KeyCode::ArrowDown)  { dir.y -= 1.0; }
        if dir != Vec2::ZERO {
            tf.translation += (dir.normalize() * speed.0 * dt).extend(0.0);
        }
    }
}

cargo run. 화살표 키를 누르면 원이 움직인다. 끝.

핵심 패턴.

5.5 적 한 마리 추가

#[derive(Component)]
struct Enemy;

fn setup(/* ... */) {
    // ... (위와 동일)

    commands.spawn((
        Enemy,
        Speed(150.0),
        Mesh2d(meshes.add(Circle::new(30.0))),
        MeshMaterial2d(materials.add(Color::srgb(1.0, 0.3, 0.3))),
        Transform::from_xyz(200.0, 200.0, 0.0),
    ));
}

fn chase_player(
    time: Res<Time>,
    player_q: Query<&Transform, With<Player>>,
    mut enemy_q: Query<(&mut Transform, &Speed), (With<Enemy>, Without<Player>)>,
) {
    let Ok(player_tf) = player_q.single() else { return };
    let dt = time.delta_secs();
    for (mut tf, speed) in &mut enemy_q {
        let dir = (player_tf.translation - tf.translation).normalize_or_zero();
        tf.translation += dir * speed.0 * dt;
    }
}

// main에 .add_systems(Update, (move_player, chase_player)) 추가

빨간 적이 플레이어를 따라온다. ECS의 진가 — 별도 클래스 정의 없이, 새 component와 system만 추가하면 새 행동이 생긴다.

기억할 한 줄: "30분 안에 '키로 움직이는 원'까지 가는 게 진짜 '엔진의 첫 인상' 테스트다. Bevy는 그 테스트를 통과한다."


6장 · Godot·Unity·Unreal·Macroquad와의 정직한 비교

엔진은 종교가 아니다. 표를 보자.

6.1 한 줄 요약

6.2 항목별 비교

언어와 패러다임

에디터

컴파일 속도

런타임 성능

모바일/콘솔 빌드

오픈소스 정책

6.3 언제 무엇을 쓰나

기억할 한 줄: "Bevy는 'Unity 대체재'가 아니다. 다른 도구다 — Rust·ECS·오픈소스·모듈성을 동시에 원할 때만 옳다."


7장 · 실제로 출시된 Bevy 게임들 — 무엇이 사실인가

여기는 정직해야 한다. 인터넷에 도는 "Bevy로 만든 게임" 리스트에는 가끔 거짓이 섞인다.

7.1 진짜 Bevy로 만들어진 상업 게임

7.2 종종 오해되는 케이스 — Tiny Glade

Pounce Light의 Tiny Glade는 2024년 9월 출시 직후 Steam 인기 1위까지 올라간 인디 명작 시뮬레이터다. 이 게임은 Bevy를 사용하지 않는다 — 자체 Rust 엔진을 만들었고, 일부 컴포넌트로 Rust 생태계의 라이브러리(wgpu 등)는 활용했지만 "Bevy 게임"으로 묶으면 잘못이다. 개발사가 여러 인터뷰와 GDC 토크에서 자체 엔진임을 분명히 했다.

"Tiny Glade가 Bevy로 만들어졌다"라는 글은 무시하라. Bevy 커뮤니티는 Tiny Glade의 성공으로 "Rust 게임 가능성"을 보여줬다는 점을 기뻐했지만, 엔진 자체는 다르다.

7.3 비게임 사용 사례

Bevy는 ECS + wgpu 조합 덕에 게임이 아닌 곳에서도 쓰인다.

엔진을 라이브러리처럼 임포트할 수 있다는 게 Bevy의 큰 특징이다 — Unity·Unreal로는 불가능에 가깝다.

기억할 한 줄: "Bevy는 'Tiny Glade 같은 게임을 만든다'고 거짓말하지 않는다. 'Foresight·Jarl·Tunnet 같은 게임을 만들었고, 데이터 시각화·시뮬레이션에서도 쓴다'고 정직하게 말한다."


8장 · Bevy가 아직 준비 안 된 곳 — 정직한 한계

"Year of Bevy"는 2024년에도, 2025년에도, 2026년에도 매년 외쳐졌다. 그 사이 진짜로 성숙한 부분과 여전히 거친 부분을 가른다.

8.1 성숙해진 것

8.2 여전히 거친 것

8.3 0.15 → 1.0의 길

Bevy 공식 로드맵(2026년 5월 갱신 기준)이 1.0까지 미루는 큰 항목.

  1. BSN 정착과 공식 에디터의 최소 버전.
  2. 안정적 ABI(플러그인을 동적 로딩).
  3. 핫 리로드 시나리오 확립.
  4. 멀티 윈도우·고DPI·드래그 앤 드롭 같은 데스크톱 polish.
  5. 비주얼 스크립팅(필요하다면 외부 플러그인으로).

"1.0이 언제냐"고 묻지 마라. 답은 항상 "준비되면". 다만 0.15는 작은 인디 게임을 시작하기에 충분히 안정적이다.

기억할 한 줄: "Bevy 1.0을 기다리지 마라. 0.15로 만들 수 있는 게임을 지금 만들어라. 1.0 마이그레이션은 그때 한다."


에필로그 — 시작하는 사람을 위한 안내

30분 핸즈온 체크리스트

Bevy 안티 패턴 7가지

  1. System 안에서 Vec<Entity>를 들고 다니며 매 프레임 lookup — query를 잘 짜면 안 해도 된다.
  2. 모든 걸 component로 — 상태 머신·간단한 enum은 component 안에 두는 게 낫다.
  3. Commands를 모든 곳에서 mut — 가능하지만 결과 반영은 다음 프레임이다. 즉시 반영이 필요하면 World를 직접 받는 exclusive system을 쓴다.
  4. Query::single()을 panic으로 풀기let Ok(...) = q.single() else { return }; 같은 형태가 안전하다.
  5. 이벤트 폭주EventReader를 매 프레임 들고 있지 않으면 이벤트가 사라진다. MyEvent를 두 system이 동시에 읽으려면 add_event + 일관된 system 순서.
  6. 렌더 system을 Update에 두기 — 렌더 system은 보통 Bevy 내부가 알아서 한다. 사용자 코드는 데이터만 바꾸면 된다.
  7. 0.x 마이그레이션 무시 — Bevy 0.x → 0.(x+1)는 매번 4~12시간 작업이 든다. 1.0 전에 큰 프로젝트면 마이그레이션 buffer를 일정에 넣어라.

다음 글 예고

기억할 한 줄: "Rust + ECS는 게임 엔진 패러다임의 답 중 가장 정직한 답이다. Bevy는 그 답을 직접 손으로 짜볼 수 있는 가장 빠른 길이다."


참고 / References

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다