C++로 ECS 구현하기: Entity·Component 스토리지·System·쿼리 설계

들어가며: “플레이어가 적이 되고, 적이 아이템이 되면 상속이 폭발한다”

전통적인 상속 기반 게임 오브젝트 설계에서는 GameObject → Character → Player / Enemy 같은 계층이 쌓입니다. 그런데 “적을 처치하면 아이템으로 변한다”, “플레이어가 몬스터에 빙의한다” 같은 요구가 생기면 다중 상속, 다이아몬드 상속, dynamic_cast 지옥에 빠집니다. ECS(Entity-Component-System) 는 조합(Composition) 방식으로, 엔티티에 필요한 컴포넌트만 붙여 유연하게 확장합니다. Overwatch의 게임플레이 코드, Unity의 DOTS(Entities 패키지), Bevy 엔진 등이 이 구조를 씁니다(Unity의 기존 GameObject나 Unreal의 Actor 모델은 컴포넌트를 쓰지만 데이터와 로직이 함께 있어 엄밀한 ECS는 아닙니다).

관련 글: 캐시·데이터 지향 설계, 게임 엔진 기초, 디자인 패턴 종합·Factory·Command와 조합·행동 분리를 비교해 보면 설계 선택이 정리됩니다.


상속 구조가 무너지는 게임 개발 상황

Enemy가 Character를 상속하고 Item은 별도 계층인 상태에서 “적이 죽으면 아이템으로 변한다”는 요구가 들어왔다고 해 봅시다. 상속 구조에서는 Enemy 객체를 지우고 Item 객체를 새로 만들어야 하므로, 그 적을 가리키던 참조를 모두 찾아 갱신해야 하고 Character*를 Item*으로 바꾸는 dynamic_cast가 곳곳에 생깁니다. “적이면서 아이템”인 상태는 아예 표현할 수 없습니다. ECS에서는 같은 엔티티에서 EnemyTag를 떼고 ItemComponent를 붙이면 끝이고, 엔티티 ID가 그대로이므로 참조를 갱신할 필요도 없습니다.

플레이어가 몬스터 몸을 조종하는 “빙의” 기능도 비슷합니다. Player의 입력 처리와 Monster의 렌더·물리를 한 객체에 합치려면 다중 상속과 다이아몬드 상속을 피하기 어렵습니다. ECS에서는 몬스터 엔티티에 InputControlled 컴포넌트를 붙이기만 하면, InputControlled와 Transform을 가진 엔티티를 처리하는 PlayerControllerSystem이 그 몬스터를 움직입니다. 빙의를 풀 때는 컴포넌트를 떼면 됩니다.

성능 측면에서도 차이가 있습니다. 파티클 1만 개를 position, velocity, color를 멤버로 가진 객체 배열(AoS)로 관리하면, 위치만 갱신하는 루프에서도 색상 데이터가 함께 캐시로 올라옵니다. ECS는 컴포넌트를 타입별 배열로 저장하므로, 이동 시스템은 위치와 속도 배열만 순차로 읽습니다. 시스템 실행 순서, 순회 중 삭제, 직렬화, 시스템 간 의존 같은 나머지 문제는 뒤의 실수 절에서 다룹니다.

상황별 문제 다이어그램

flowchart TB
    subgraph Problems[상속 기반 설계 문제]
        P1[다중 상속 지옥]
        P2[다이아몬드 상속]
        P3[dynamic_cast 남발]
        P4[캐시 비효율 AoS]
        P5[엔티티 변환 시 참조 갱신]
    end
    subgraph Solutions[ECS 해결]
        S1[조합으로 유연한 확장]
        S2[컴포넌트 추가/제거만]
        S3[타입 기반 쿼리]
        S4[SoA 캐시 친화]
        S5[삭제 예약]
    end
    P1 --> S1
    P2 --> S1
    P3 --> S3
    P4 --> S4
    P5 --> S2

Entity·Component·System의 역할 분담

Entity, Component, System

개념설명비유
Entity고유 ID만 가진 “빈 껍데기”. 게임 내 오브젝트를 식별하는 핸들사람의 주민등록번호
Component순수 데이터. 행동 없음. 위치, 속도, 체력 등나이, 키, 혈액형
System특정 컴포넌트 조합을 가진 엔티티만 처리하는 로직”나이 20세 이상”에게만 적용되는 정책

아키텍처 다이어그램

flowchart TB
    subgraph Entities[엔티티]
        E1["Entity 1 (ID 1)"]
        E2["Entity 2 (ID 2)"]
        E3["Entity 3 (ID 3)"]
    end
    subgraph Components[컴포넌트]
        C1[Transform]
        C2[Velocity]
        C3[Sprite]
        C4[Health]
    end
    subgraph Systems[시스템]
        S1[MovementSystem]
        S2[RenderSystem]
        S3[DamageSystem]
    end
    E1 --> C1
    E1 --> C2
    E1 --> C3
    E2 --> C1
    E2 --> C2
    E2 --> C4
    E3 --> C1
    E3 --> C3
    C1 --> S1
    C2 --> S1
    C1 --> S2
    C3 --> S2
    C4 --> S3

ECS vs 상속 비교

flowchart LR
    subgraph Inheritance[상속 기반]
        A[GameObject] --> B[Character]
        B --> C[Player]
        B --> D[Enemy]
        A --> E[Item]
        direction TB
    end
    subgraph ECS_[ECS 기반]
        F[Entity 1: Transform+PlayerTag+Health]
        G[Entity 2: Transform+EnemyTag+Health]
        H[Entity 3: Transform+ItemTag]
        I[Entity 4: Transform+EnemyTag+ItemTag]
    end

ID 핸들로 Entity 구현하기

최소 Entity: ID만 가진 핸들

엔티티는 ID만 가지는 것이 이상적입니다. 컴포넌트는 별도 스토리지에 저장하며, 엔티티 ID로 인덱싱합니다.

// entity.hpp
#pragma once
#include <cstdint>
using EntityID = uint32_t;
// 삭제된 엔티티 구분용
constexpr EntityID NULL_ENTITY = 0;
inline bool is_valid_entity(EntityID id) {
    return id != NULL_ENTITY;
}

EntityID는 불변 식별자입니다. 삭제된 엔티티의 ID를 다시 쓰지 않으면 오래된 ID가 새 엔티티를 가리키는 일이 없지만, ID가 계속 커져 ID로 인덱싱하는 배열이 희소해집니다. 그래서 보통은 ID를 재사용하되 세대(generation) 번호를 붙여 (id, generation) 쌍으로 관리합니다.

// entity_ref.hpp - ID 재사용 시 안전한 참조
struct EntityRef {
    uint32_t id = 0;
    uint32_t generation = 0;
};
// 삭제 시 generation 증가, 조회 시 is_valid(generation) 검증

Entity + 컴포넌트를 엔티티에 보관하는 방식 (간단한 구현)

작은 프로젝트에서는 엔티티가 컴포넌트를 직접 갖는 방식도 사용합니다. type_index로 타입별 저장합니다.

// entity_with_components.hpp
#pragma once
#include <memory>
#include <typeindex>
#include <unordered_map>
using EntityID = uint32_t;
struct Component {
    virtual ~Component() = default;
};
class Entity {
    EntityID id_;
    std::unordered_map<std::type_index, std::unique_ptr<Component>> components_;
public:
    explicit Entity(EntityID id) : id_(id) {}
    template <typename T, typename... Args>
    T& add_component(Args&&... args) {
        auto component = std::make_unique<T>(std::forward<Args>(args)...);
        T* ptr = component.get();
        components_[typeid(T)] = std::move(component);
        return *ptr;
    }
    template <typename T>
    T* get_component() {
        auto it = components_.find(typeid(T));
        return it != components_.end()
                   ? static_cast<T*>(it->second.get())
                   : nullptr;
    }
    template <typename T>
    bool has_component() const {
        return components_.find(typeid(T)) != components_.end();
    }
    template <typename T>
    void remove_component() {
        components_.erase(typeid(T));
    }
    EntityID get_id() const { return id_; }
};

주의: 이 방식은 엔티티당 unordered_map을 갖고, 컴포넌트가 메모리에 흩어져 있어 캐시 효율이 떨어집니다. 프로토타입이나 소규모 프로젝트에 적합합니다.


Component 정의와 AoS·SoA 스토리지

컴포넌트: 순수 데이터 (POD에 가깝게)

컴포넌트는 행동(메서드) 없이 데이터만 갖습니다. 가상 함수, 상속을 피하면 캐시에 유리합니다.

// components.hpp
#pragma once
struct TransformComponent {
    float x = 0.0f, y = 0.0f, z = 0.0f;
    float rotation = 0.0f;
    float scale_x = 1.0f, scale_y = 1.0f;
};
struct VelocityComponent {
    float vx = 0.0f, vy = 0.0f, vz = 0.0f;
};
struct HealthComponent {
    int current = 100;
    int max = 100;
};
struct SpriteComponent {
    int texture_id = 0;
    int width = 32, height = 32;
    int z_order = 0;
};
// 태그 컴포넌트: 데이터 없이 "이 엔티티는 플레이어"만 표시
struct PlayerTag {};
struct EnemyTag {};

AoS 스토리지: 엔티티별로 컴포넌트 저장

// storage_aos.hpp
#pragma once
#include <unordered_map>
#include <memory>
#include "entity.hpp"
#include "components.hpp"
template <typename T>
class ComponentStorageAoS {
    std::unordered_map<EntityID, T> storage_;
public:
    T* add(EntityID entity, T component = T{}) {
        auto [it, inserted] = storage_.emplace(entity, std::move(component));
        return &it->second;
    }
    T* get(EntityID entity) {
        auto it = storage_.find(entity);
        return it != storage_.end() ? &it->second : nullptr;
    }
    bool has(EntityID entity) const {
        return storage_.find(entity) != storage_.end();
    }
    void remove(EntityID entity) {
        storage_.erase(entity);
    }
    auto begin() { return storage_.begin(); }
    auto end() { return storage_.end(); }
    size_t size() const { return storage_.size(); }
};

구현이 단순하지만, unordered_map 순회 시 캐시 locality가 낮습니다. 컴포넌트가 메모리에 흩어져 있습니다.

SoA 스토리지: 컴포넌트별 배열 (캐시 친화)

// storage_soa.hpp
#pragma once
#include <vector>
#include <unordered_map>
#include "entity.hpp"
#include "components.hpp"
template <typename T>
class ComponentStorageSoA {
    // EntityID -> 배열 인덱스
    std::unordered_map<EntityID, size_t> entity_to_index_;
    std::vector<EntityID> index_to_entity_;
    std::vector<T> components_;
public:
    // 반환 포인터는 다음 add/remove 전까지만 유효 (vector 재할당·swap-and-pop)
    T* add(EntityID entity, T component = T{}) {
        if (auto it = entity_to_index_.find(entity); it != entity_to_index_.end()) {
            components_[it->second] = std::move(component);  // 이미 있으면 덮어씀
            return &components_[it->second];
        }
        size_t idx = components_.size();
        entity_to_index_[entity] = idx;
        index_to_entity_.push_back(entity);
        components_.push_back(std::move(component));
        return &components_.back();
    }
    T* get(EntityID entity) {
        auto it = entity_to_index_.find(entity);
        return it != entity_to_index_.end()
                   ? &components_[it->second]
                   : nullptr;
    }
    bool has(EntityID entity) const {
        return entity_to_index_.find(entity) != entity_to_index_.end();
    }
    void remove(EntityID entity) {
        auto it = entity_to_index_.find(entity);
        if (it == entity_to_index_.end()) return;
        size_t idx = it->second;
        size_t last = components_.size() - 1;
        if (idx != last) {
            // swap-and-pop: 마지막 요소를 삭제 위치로
            std::swap(components_[idx], components_[last]);
            std::swap(index_to_entity_[idx], index_to_entity_[last]);
            entity_to_index_[index_to_entity_[idx]] = idx;
        }
        components_.pop_back();
        index_to_entity_.pop_back();
        entity_to_index_.erase(it);
    }
    // 순차 순회 (캐시 친화)
    const std::vector<EntityID>& entities() const { return index_to_entity_; }
    std::vector<T>& data() { return components_; }
    const std::vector<T>& data() const { return components_; }
};

컴포넌트가 vector에 연속 저장되어 순회 시 캐시 효율이 좋습니다. remove는 swap-and-pop으로 O(1)에 처리합니다. TransformComponent처럼 여러 필드가 있으면, 필드별 vector를 두는 완전한 SoA도 가능합니다.

완전한 SoA 예: Position만 분리

// position_soa.hpp
struct PositionSoA {
    std::vector<float> x, y, z;
    std::vector<EntityID> entities;
    size_t add(EntityID entity, float px, float py, float pz) {
        size_t idx = x.size();
        entities.push_back(entity);
        x.push_back(px);
        y.push_back(py);
        z.push_back(pz);
        return idx;
    }
};

AoS vs SoA 성능 비교

항목AoSSoA
메모리struct { x,y,z,vx,vy,vz } 연속x[], y[], vx[] 등 분리
캐시한 필드만 써도 전체 로드필요한 배열만 순차 로드
적합프로토타입, 소규모파티클 1만+, 물리, AI
SIMD어려움x[i:i+4] += vx[i:i+4] 용이

SoA가 얼마나 빨라지는지는 루프가 실제로 쓰는 필드의 비율에 달려 있습니다. 위치 갱신처럼 구조체의 일부 필드만 읽는 루프라면 AoS는 안 쓰는 필드까지 캐시로 끌어오지만 SoA는 쓰는 배열만 읽으므로, 이득은 대략 “구조체 크기 ÷ 실제로 쓰는 바이트”를 넘기 어렵습니다. 모든 필드를 함께 쓰는 루프에서는 차이가 거의 없고, SIMD로 벡터화되면 그보다 더 벌어질 수 있습니다.


컴포넌트 조합을 처리하는 System 구현

시스템: 특정 컴포넌트 조합을 쿼리해 처리

시스템은 컴포넌트 스토리지에 접근해, 필요한 컴포넌트를 가진 엔티티만 처리합니다.

// movement_system.hpp
#pragma once
#include "system.hpp"
#include "world.hpp"
class MovementSystem : public System {
public:
    void update(World& world, float dt) override {
        auto& transforms = world.transforms();
        auto& velocities = world.velocities();
        auto& transform_entities = transforms.entities();
        auto& transform_data = transforms.data();
        for (size_t i = 0; i < transform_entities.size(); ++i) {
            EntityID eid = transform_entities[i];
            VelocityComponent* vel = velocities.get(eid);
            if (!vel) continue;  // Velocity 없으면 스킵
            auto& t = transform_data[i];
            t.x += vel->vx * dt;
            t.y += vel->vy * dt;
            t.z += vel->vz * dt;
        }
    }
};

RenderSystem: Transform + Sprite 쿼리

Transform과 Sprite를 모두 가진 엔티티를 쿼리해 화면에 그립니다.

// render_system.hpp
class RenderSystem : public System {
public:
    void update(World& world, float /*dt*/) override {
        auto& transforms = world.transforms();
        auto& sprites = world.sprites();
        for (EntityID eid : transforms.entities()) {
            if (auto* s = sprites.get(eid)) {
                auto* t = transforms.get(eid);
                // draw_sprite(s->texture_id, t->x, t->y, ...);
            }
        }
    }
};

DamageSystem: Health + 충돌 처리

데미지 시스템은 체력을 가진 엔티티 간 충돌을 감지하며, 체력을 감소시킵니다. 체력이 0 이하가 되면 world.destroy_entity()로 삭제 예약합니다.

// damage_system.hpp
#pragma once
#include "storage_soa.hpp"
#include "components.hpp"
#include "world.hpp"
class DamageSystem : public System {
public:
    // 모든 쌍을 비교하는 O(n²) 예제: 엔티티가 많으면 공간 분할(그리드 등)이 필요
    void update(World& world, float /*dt*/) override {
        auto& health = world.health();
        auto& transforms = world.transforms();
        for (EntityID eid1 : health.entities()) {
            auto* t1 = transforms.get(eid1);
            auto* h1 = health.get(eid1);
            if (!t1 || !h1) continue;
            for (EntityID eid2 : health.entities()) {
                if (eid1 >= eid2) continue;
                auto* t2 = transforms.get(eid2);
                auto* h2 = health.get(eid2);
                if (!t2 || !h2) continue;
                float dx = t1->x - t2->x, dy = t1->y - t2->y;
                if (dx * dx + dy * dy < 32 * 32) {
                    h1->current -= 10;
                    h2->current -= 10;
                    if (h1->current <= 0) world.destroy_entity(eid1);
                    if (h2->current <= 0) world.destroy_entity(eid2);
                }
            }
        }
    }
};

시스템 인터페이스 (의존성 주입)

// system.hpp
#pragma once
class World;  // 전방 선언
struct System {
    virtual ~System() = default;
    virtual void update(World& world, float dt) = 0;
};

포함·제외 조건을 가진 쿼리 설계

쿼리: “Transform + Velocity를 가진 엔티티”

여러 컴포넌트를 모두 가진 엔티티만 필터링하는 것이 쿼리입니다.

// query.hpp
#pragma once
#include <vector>
#include "entity.hpp"
template <typename... Components>
class Query {
public:
    template <typename... Storages>
    static std::vector<EntityID> execute(Storages&... storages) {
        std::vector<EntityID> result;
        // 첫 번째 스토리지 기준으로 순회
        execute_impl(result, storages...);
        return result;
    }
private:
    template <typename First, typename... Rest>
    static void execute_impl(std::vector<EntityID>& result,
                            First& first, Rest&... rest) {
        for (EntityID eid : first.entities()) {
            if ((rest.has(eid) && ...)) {
                result.push_back(eid);
            }
        }
    }
};

반복자 기반 쿼리 (메모리 할당 최소화)

매 프레임 vector를 할당하지 않으려면, 반복자를 반환하는 방식이 좋습니다.

// query_iterator.hpp
#pragma once
#include <tuple>
#include "entity.hpp"
template <typename... Components>
class QueryIterator {
public:
    template <typename Func, typename... Storages>
    static void each(Func&& func, Storages&... storages) {
        auto& first = std::get<0>(std::tie(storages...));
        for (EntityID eid : first.entities()) {
            if ((storages.has(eid) && ...)) {
                func(eid, storages.get(eid)...);
            }
        }
    }
};

사용 예:

void MovementSystem::update(World& world, float dt) {
    QueryIterator<TransformComponent, VelocityComponent>::each(
        [&](EntityID eid, TransformComponent* t, VelocityComponent* v) {
            t->x += v->vx * dt;
            t->y += v->vy * dt;
            t->z += v->vz * dt;
        },
        world.transforms(), world.velocities());
}

제외 쿼리: “Velocity 있지만 Static 없음”

포함할 스토리지와 제외할 스토리지를 각각 std::tie로 묶어 넘깁니다. 클래스 템플릿에는 매개변수 팩을 두 개 둘 수 없고, 함수 인자에 팩 두 개를 나란히 두면 어디까지가 첫 번째 팩인지 추론할 수 없으므로 튜플로 경계를 나눕니다.

// query_exclude.hpp
#include <tuple>
template <typename... Inc, typename... Exc>
std::vector<EntityID> query_exclude(std::tuple<Inc&...> inc, std::tuple<Exc&...> exc) {
    std::vector<EntityID> result;
    for (EntityID eid : std::get<0>(inc).entities()) {
        bool has_all  = std::apply([eid](auto&... s) { return (s.has(eid) && ...); }, inc);
        bool has_none = std::apply([eid](auto&... s) { return !(s.has(eid) || ...); }, exc);
        if (has_all && has_none) result.push_back(eid);
    }
    return result;
}
// 사용: 속도는 있고 Static 태그는 없는 엔티티
// auto ids = query_exclude(std::tie(world.transforms(), world.velocities()),
//                          std::tie(world.static_tags()));

World와 게임 루프로 묶은 ECS 예제

World: 엔티티 + 컴포넌트 스토리지 + 시스템

// world.hpp
#pragma once
#include <memory>
#include <vector>
#include "entity.hpp"
#include "components.hpp"
#include "storage_soa.hpp"
#include "system.hpp"
class World {
    EntityID next_id_ = 1;
    std::vector<EntityID> to_destroy_;
    ComponentStorageSoA<TransformComponent> transforms_;
    ComponentStorageSoA<VelocityComponent> velocities_;
    ComponentStorageSoA<HealthComponent> health_;
    ComponentStorageSoA<SpriteComponent> sprites_;
    ComponentStorageSoA<PlayerTag> player_tags_;
    ComponentStorageSoA<EnemyTag> enemy_tags_;
    std::vector<std::unique_ptr<System>> systems_;
public:
    EntityID spawn_entity() {
        EntityID id = next_id_++;
        transforms_.add(id);
        return id;
    }
    void destroy_entity(EntityID id) {
        to_destroy_.push_back(id);
    }
    void flush_destroyed() {
        for (EntityID id : to_destroy_) {
            transforms_.remove(id);
            velocities_.remove(id);
            health_.remove(id);
            sprites_.remove(id);
            player_tags_.remove(id);
            enemy_tags_.remove(id);
        }
        to_destroy_.clear();
    }
    auto& transforms() { return transforms_; }
    auto& velocities() { return velocities_; }
    auto& health() { return health_; }
    auto& sprites() { return sprites_; }
    auto& player_tags() { return player_tags_; }
    auto& enemy_tags() { return enemy_tags_; }
    void add_system(std::unique_ptr<System> sys) {
        systems_.push_back(std::move(sys));
    }
    void update(float dt) {
        for (auto& sys : systems_) {
            sys->update(*this, dt);
        }
        flush_destroyed();
    }
};

완전한 게임 루프 예제

// main.cpp
#include "world.hpp"
#include "movement_system.hpp"
#include "render_system.hpp"
#include "damage_system.hpp"
int main() {
    World world;
    // 플레이어 생성
    EntityID player = world.spawn_entity();
    world.transforms().add(player, TransformComponent{100, 200, 0});
    world.velocities().add(player, VelocityComponent{0, 0, 0});
    world.health().add(player, HealthComponent{100, 100});
    world.sprites().add(player, SpriteComponent{0, 32, 32, 1});
    world.player_tags().add(player, PlayerTag{});
    // 적 생성
    EntityID enemy = world.spawn_entity();
    world.transforms().add(enemy, TransformComponent{300, 200, 0});
    world.velocities().add(enemy, VelocityComponent{-50, 0, 0});
    world.health().add(enemy, HealthComponent{50, 50});
    world.sprites().add(enemy, SpriteComponent{1, 32, 32, 0});
    world.enemy_tags().add(enemy, EnemyTag{});
    // 시스템 등록 (순서 중요!)
    world.add_system(std::make_unique<MovementSystem>());
    world.add_system(std::make_unique<DamageSystem>());
    world.add_system(std::make_unique<RenderSystem>());
    // 게임 루프
    while (running) {
        float dt = get_delta_time();
        world.update(dt);
    }
    return 0;
}

시퀀스 다이어그램: 한 프레임 처리

sequenceDiagram
    participant Main
    participant World
    participant Movement
    participant Damage
    participant Render
    Main->>World: update(dt)
    World->>Movement: update(world, dt)
    Movement->>Movement: Transform + Velocity 쿼리
    Movement->>Movement: 위치 갱신
    World->>Damage: update(world, dt)
    Damage->>Damage: Health 충돌 처리
    World->>Render: update(world, dt)
    Render->>Render: Transform + Sprite 쿼리
    Render->>Render: 그리기
    World->>World: flush_destroyed()

순회 중 삭제, 포인터 캐싱, generation 누락: ECS 실수

시스템 순회 중 엔티티 삭제

for (auto* e : entities) { if (e->dead) destroy(e); }처럼 순회 중에 삭제하면 반복자가 무효화되어 원소를 건너뛰거나 크래시가 납니다. 삭제는 예약해 두었다가 프레임 끝에 한꺼번에 처리합니다.

// ❌ 잘못된 예
for (auto& [id, entity] : entities_) {
    if (entity->health().current <= 0) {
        destroy_entity(id);  // 순회 중 삭제!
    }
}
// ✅ 올바른 예
for (auto& [id, entity] : entities_) {
    if (entity->health().current <= 0) {
        to_destroy_.push_back(id);
    }
}
// 이후 flush_destroyed()에서 일괄 삭제

컴포넌트 포인터 캐싱 후 삭제

get_component로 받은 포인터를 보관해 두면, 나중에 컴포넌트가 제거되거나 SoA 스토리지가 재할당·swap-and-pop으로 원소를 옮길 때 dangling pointer가 됩니다. 포인터는 그 자리에서만 쓰고, 다음에는 ID로 다시 조회합니다.

// ❌ 위험
auto* t = entity.get_component<TransformComponent>();
// ... 다른 시스템에서 entity.remove_component<Transform>() ...
t->x = 100;  // UB: t가 이미 삭제됨
// ✅ 안전
if (auto* t = entity.get_component<TransformComponent>()) {
    t->x = 100;
}

시스템 순서 오류

물리 시스템이 입력 시스템보다 먼저 돌면 이번 프레임 입력이 한 프레임 늦게 반영됩니다. 시스템 등록 순서를 명확히 하고, 규모가 커지면 의존성 그래프로 순서를 정합니다.

// ✅ 명시적 순서
world.add_system(std::make_unique<InputSystem>());   // 1
world.add_system(std::make_unique<MovementSystem>()); // 2
world.add_system(std::make_unique<PhysicsSystem>());  // 3
world.add_system(std::make_unique<CollisionSystem>()); // 4
world.add_system(std::make_unique<RenderSystem>());   // 5

AoS로 대량 엔티티 처리

수천 개 엔티티를 vector<Entity>로 두고 각 Entity가 unordered_map<type_index, Component>를 가지면, 컴포넌트가 힙 곳곳에 흩어져 캐시 미스가 심합니다. 컴포넌트 타입별로 연속 저장하는 스토리지로 바꿉니다.

// ❌ 캐시 비효율
std::vector<Entity> entities;  // Entity마다 map, 흩어진 메모리
// ✅ 캐시 친화
ComponentStorageSoA<TransformComponent> transforms;
// transforms.data()는 연속 메모리

쿼리 결과 매 프레임 vector 할당

쿼리가 매 프레임 결과 vector를 새로 할당하면 시스템 수 × 프레임 수만큼 할당이 쌓입니다. 콜백 기반 each()를 쓰거나 결과 벡터를 재사용합니다.

// ❌ 매 프레임 할당
auto entities = query.execute<Transform, Velocity>(transforms, velocities);
for (auto eid : entities) { ... }
// ✅ 할당 없이 순회
QueryIterator<Transform, Velocity>::each(
    [&](EntityID eid, Transform* t, Velocity* v) { ... },
    transforms, velocities);

컴포넌트에 로직 넣기

HealthComponent에 take_damage() 같은 메서드를 넣으면 로직이 컴포넌트마다 흩어집니다. 컴포넌트는 데이터만 두고 로직은 시스템에 둡니다.

// ❌ 컴포넌트에 로직
struct HealthComponent {
    int current, max;
    void take_damage(int d) { current -= d; }  // X
};
// ✅ 시스템에 로직
class DamageSystem {
    void update(World& w, float dt) {
        for (EntityID eid : w.health().entities()) {  // damage_to_apply: 이번 프레임에 쌓인 피해량 맵
            auto* health = w.health().get(eid);
            if (damage_to_apply.count(eid))
                health->current -= damage_to_apply[eid];
        }
    }
};

EntityID 재사용 시 generation 누락

엔티티 삭제 후 ID를 재사용하는데 이전 ID를 들고 있는 코드가 남아 있으면 엉뚱한 새 엔티티를 참조합니다. (EntityID, Generation) 쌍을 쓰거나 ID를 재사용하지 않습니다.

// ✅ Generation 포함
struct EntityRef {
    EntityID id;
    uint32_t generation;
};
// 삭제 시 generation 증가, 조회 시 generation 검증

add 시 기존 엔티티 덮어쓰기 누락

add가 이미 있는 엔티티에 대해 아무것도 하지 않으면, spawn_entity()가 기본 Transform을 넣은 뒤 add(player, TransformComponent{100,200,0})을 호출해도 (0,0,0)이 유지됩니다. 위 SoA 스토리지처럼 이미 있으면 값을 덮어쓰도록 구현합니다.

시스템 간 순환 의존

SystemA와 SystemB가 서로를 참조하면 헤더 순환 참조와 초기화 순서 문제가 생깁니다. 시스템은 World에만 의존합니다. 시스템끼리 직접 참조하지 않으며, 컴포넌트 스토리지를 통해 통신합니다.


작은 컴포넌트·지연 삭제 같은 ECS 설계 원칙

컴포넌트는 작고, 시스템은 단일 책임

  • 컴포넌트: 한 가지 관심사만 (위치, 속도, 체력 등).
  • 시스템: 한 가지 일만 (이동, 렌더, 충돌 등).

태그 컴포넌트 활용

데이터 없이 “플레이어”, “적” 구분만 필요할 때 태그 컴포넌트를 씁니다. struct PlayerTag {};는 크기가 1바이트지만, 스토리지의 ID 매핑 비용은 그대로 들므로 EnTT 같은 라이브러리는 빈 타입을 위해 값 배열을 아예 두지 않는 최적화를 합니다.

SoA는 핫 경로에, AoS는 프로토타입에

  • 대량 처리(파티클, AI, 물리): SoA.
  • 소규모, 빠른 프로토타입: 엔티티별 컴포넌트 map.

시스템 의존성 명시

시스템 실행 순서를 문서화하거나 의존성 그래프로 관리합니다.

InputSystem → MovementSystem → PhysicsSystem → CollisionSystem → RenderSystem

삭제는 지연(deferred)

순회 중 삭제를 피하며, to_destroy_에 모아 한 번에 처리합니다.

쿼리 결과 재사용 또는 반복자

vector 할당을 줄이기 위해 each() 콜백 패턴을 사용합니다.

시스템은 생성·삭제를 요청만 한다

시스템이 순회 중에 직접 엔티티를 만들거나 지우면 스토리지가 바뀌어 순회가 깨집니다. 앞의 DamageSystem처럼 시스템은 삭제·생성을 큐에 요청만 하고, 실제 반영은 모든 시스템이 끝난 뒤 World가 처리합니다.


Archetype·멀티스레드·직렬화: 규모가 커질 때의 패턴

Archetype 기반 스토리지

엔티티를 컴포넌트 조합(Archetype) 별로 그룹화합니다. 같은 Archetype끼리 연속 저장해 쿼리 시 캐시 효율이 극대화됩니다. Unity DOTS, Bevy ECS가 이 방식을 사용합니다.

// Archetype: {Transform, Velocity} vs {Transform, Velocity, Sprite}
// 각 Archetype은 별도 Chunk 배열
struct Archetype {
    std::vector<std::type_index> component_types;  // 이 조합에 속한 컴포넌트 타입들
    std::vector<Chunk> chunks;                     // 고정 크기 메모리 블록에 컴포넌트를 SoA로 배치
};

멀티스레드 시스템

서로 다른 컴포넌트에 쓰는 시스템, 또는 같은 컴포넌트를 읽기만 하는 시스템은 병렬로 실행할 수 있습니다. 예를 들어 Transform에 쓰는 이동 시스템과 Health에만 쓰는 회복 시스템은 동시에 돌려도 됩니다. 실제 엔진은 프레임마다 스레드를 만들지 않고 작업 스케줄러(잡 시스템)에 넘기며, 시스템이 선언한 읽기·쓰기 컴포넌트 목록으로 충돌 여부를 자동으로 판단합니다.

// 쓰는 컴포넌트가 겹치지 않는 시스템만 병렬 실행 (설명용으로 std::thread 사용)
std::thread t1([&] { movement_system.update(world, dt); });  // Transform 쓰기
std::thread t2([&] { regen_system.update(world, dt); });     // Health 쓰기
t1.join();
t2.join();
// 의존 있는 시스템은 순차
collision_system.update(world, dt);

이벤트 기반 컴포넌트 변경

“엔티티가 죽었다”는 이벤트를 발행하며, 다른 시스템이 구독해 반응합니다. 직접 참조를 넘기지 않아 결합도를 낮춥니다.

struct EntityDiedEvent { EntityID id; };
event_bus.publish(EntityDiedEvent{enemy_id});
// 구독: LootSystem이 아이템 드롭, ScoreSystem이 점수 추가

직렬화/재생

컴포넌트가 순수 데이터이므로 직렬화가 쉽습니다. 스냅샷을 저장해 리플레이, 네트워크 동기화에 활용합니다.

void save_snapshot(World& w, std::ostream& out) {
    auto& ids  = w.transforms().entities();
    auto& data = w.transforms().data();
    for (size_t i = 0; i < ids.size(); ++i)
        out << ids[i] << " " << data[i].x << " " << data[i].y << " " << data[i].z << "\n";
}

라이브러리 활용

직접 구현 대신 EnTT, flecs 같은 ECS 라이브러리를 쓰면 Archetype, 쿼리, 멀티스레딩을 검증된 방식으로 사용할 수 있습니다.

// EnTT 예시
entt::registry registry;
auto entity = registry.create();
registry.emplace<TransformComponent>(entity, 0.f, 0.f, 0.f);
registry.emplace<VelocityComponent>(entity, 1.f, 0.f, 0.f);
registry.view<TransformComponent, VelocityComponent>().each(
    [](TransformComponent& t, VelocityComponent& v) { t.x += v.vx; });

참고 자료:


같이 보면 좋은 글