C++ ECS 패턴 완벽 가이드 | Entity·Component·System·쿼리·컴포넌트 스토리지 실전
이 글의 핵심
C++ ECS 패턴 완벽 가이드에 대한 실전 가이드입니다. Entity·Component·System·쿼리·컴포넌트 스토리지 실전 등을 예제와 함께 설명합니다.
들어가며: “플레이어가 적이 되고, 적이 아이템이 되면 상속이 폭발한다”
왜 ECS인가
전통적인 상속 기반 게임 오브젝트 설계에서는 GameObject → Character → Player / Enemy 같은 계층이 쌓입니다. 그런데 “적을 처치하면 아이템으로 변한다”, “플레이어가 몬스터에 빙의한다” 같은 요구가 생기면 다중 상속, 다이아몬드 상속, dynamic_cast 지옥에 빠집니다. ECS(Entity-Component-System) 는 조합(Composition) 방식으로, 엔티티에 필요한 컴포넌트만 붙여 유연하게 확장합니다. Unity, Unreal, Overwatch 등에서 채택한 패턴입니다.
이 글에서 다루는 것:
- 문제 시나리오: 상속 기반 설계에서 겪는 실제 상황
- 완전한 ECS 구현: Entity, Component, System, 컴포넌트 스토리지, 쿼리
- 자주 하는 실수: 순환 참조, 시스템 순서, 캐시 미스
- 모범 사례: SoA vs AoS, 시스템 의존성, 프로덕션 패턴
관련 글: 캐시·데이터 지향 설계, 게임 엔진 기초, 디자인 패턴 종합·Factory·Command와 조합·행동 분리를 비교해 보면 설계 선택이 정리됩니다.
개념을 잡는 비유
ECS는 레고 블록처럼 필요한 부품(컴포넌트)만 조합해 엔티티를 만드는 방식입니다. 상속 트리로 “플레이어이면서 동시에 아이템인” 타입을 억지로 끼워 맞추지 않아도, 위치·체력·입력 같은 블록을 붙였다 떼었다 할 수 있습니다.
목차
- 문제 시나리오
- ECS 핵심 개념
- Entity 구현
- Component 정의와 스토리지
- System 구현
- 쿼리(Query) 설계
- 완전한 ECS 예제
- 자주 하는 실수와 해결법
- 모범 사례·베스트 프랙티스
- 프로덕션 패턴
- 정리
1. 문제 시나리오
시나리오 1: “적을 처치하면 드롭 아이템으로 변한다”
상황: Enemy 클래스가 Character를 상속하고, Item은 별도 계층입니다. “적이 죽으면 아이템으로 변한다” 요구가 들어왔습니다.
상속 방식의 한계:
Enemy→Item변환 시 객체를 새로 만들고, 기존 참조를 모두 갱신해야 합니다.Character*를Item*로dynamic_cast하는 코드가 곳곳에 생깁니다.- “적이면서 아이템” 같은 조합이 불가능합니다.
ECS 방식: Enemy와 Item을 클래스가 아니라 컴포넌트 조합으로 표현합니다. 엔티티에서 EnemyTag를 제거하고 ItemComponent를 추가하면 됩니다. 참조 갱신 없이 컴포넌트만 바꿉니다.
시나리오 2: “플레이어가 몬스터에 빙의한다”
상황: 플레이어가 몬스터 몸을 조종하는 “빙의” 기능이 필요합니다.
상속 방식의 한계:
Player와Monster는 별도 계층입니다. 빙의 시Player의 입력 로직 +Monster의 렌더·물리를 조합해야 합니다.- 다중 상속을 쓰면 다이아몬드 상속, 가상 상속 복잡도가 폭발합니다.
ECS 방식: InputControlled 컴포넌트를 몬스터 엔티티에 붙이면 됩니다. PlayerControllerSystem이 InputControlled + Transform을 가진 엔티티를 처리합니다. 빙의 해제 시 컴포넌트만 제거합니다.
시나리오 3: “수천 개 파티클이 프레임 드랍을 유발한다”
상황: 파티클 1만 개를 Particle 클래스로 관리합니다. 각 객체가 position, velocity, color 등을 멤버로 갖는 AoS(Array of Structures)입니다.
상속/객체 방식의 한계:
- 캐시 미스가 심합니다.
position만 갱신해도velocity,color등 불필요한 데이터가 캐시 라인에 함께 로드됩니다. - 가상 함수 테이블, 상속 오버헤드가 쌓입니다.
ECS 방식: 컴포넌트를 SoA(Structure of Arrays) 로 저장합니다. PositionComponent는 x[], y[], z[] 배열, VelocityComponent는 vx[], vy[], vz[] 배열로 분리합니다. 시스템이 필요한 컴포넌트만 순차 접근해 캐시 효율이 극대화됩니다.
시나리오 4: “시스템 실행 순서가 꼬여 물리가 먼저 돌아간다”
상황: 입력 → 물리 → 렌더 순서가 필요한데, 시스템 등록 순서를 잘못해 물리가 입력보다 먼저 실행됩니다.
ECS 방식: 시스템에 의존성 그래프를 두고, 위상 정렬로 실행 순서를 보장합니다. 또는 SystemA가 SystemB 이후에 실행되도록 명시적으로 설정합니다.
시나리오 5: “엔티티 삭제 시 dangling pointer”
상황: 시스템이 엔티티 목록을 순회하는 도중 다른 시스템이 엔티티를 삭제합니다. 삭제된 엔티티 포인터를 참조해 크래시가 발생합니다.
ECS 방식: 즉시 삭제 대신 삭제 예약(deferred destruction) 을 사용합니다. 프레임 끝에 한꺼번에 삭제하거나, 삭제된 엔티티 ID를 무효화하고 시스템에서 스킵합니다.
시나리오 6: “네트워크 동기화에 타입이 늘어난다”
상황: 상속 기반에서는 Player, Enemy, Projectile 등 타입마다 별도 직렬화 코드가 필요합니다.
ECS 방식: 컴포넌트가 순수 데이터이므로 컴포넌트별 serialize/deserialize 한 번만 구현하면 됩니다.
시나리오 7: “시스템 간 순환 참조”
상황: PhysicsSystem과 RenderSystem이 서로를 참조하면 빌드·초기화 순서가 꼬입니다.
ECS 방식: 시스템은 World에만 의존하고, 컴포넌트 스토리지를 통해 통신합니다.
문제 시나리오 다이어그램
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
2. ECS 핵심 개념
Entity, Component, System
| 개념 | 설명 | 비유 |
|---|---|---|
| Entity | 고유 ID만 가진 “빈 껍데기”. 게임 내 오브젝트를 식별하는 핸들 | 사람의 주민등록번호 |
| Component | 순수 데이터. 행동 없음. 위치, 속도, 체력 등 | 나이, 키, 혈액형 |
| System | 특정 컴포넌트 조합을 가진 엔티티만 처리하는 로직 | ”나이 20세 이상”에게만 적용되는 정책 |
아키텍처 다이어그램
flowchart TB
subgraph Entities["엔티티"]
E1["Entity 1br/ID: 1"]
E2["Entity 2br/ID: 2"]
E3["Entity 3br/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
3. 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를 재사용하지 않으면(dense ID) dangling reference를 줄일 수 있습니다. 재사용 시에는 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을 갖고, 컴포넌트가 메모리에 흩어져 있어 캐시 효율이 떨어집니다. 프로토타입이나 소규모 프로젝트에 적합합니다.
4. Component 정의와 스토리지
컴포넌트: 순수 데이터 (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:
T* add(EntityID entity, T component = T{}) {
if (entity_to_index_.count(entity))
return &components_[entity_to_index_[entity]];
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 성능 비교
| 항목 | AoS | SoA |
|---|---|---|
| 메모리 | struct { x,y,z,vx,vy,vz } 연속 | x[], y[], vx[] 등 분리 |
| 캐시 | 한 필드만 써도 전체 로드 | 필요한 배열만 순차 로드 |
| 적합 | 프로토타입, 소규모 | 파티클 1만+, 물리, AI |
| SIMD | 어려움 | x[i:i+4] += vx[i:i+4] 용이 |
10만 엔티티: AoS 100%, SoA 약 35% (약 3배 빠름).
5. System 구현
시스템: 특정 컴포넌트 조합을 쿼리해 처리
시스템은 컴포넌트 스토리지에 접근해, 필요한 컴포넌트를 가진 엔티티만 처리합니다.
// movement_system.hpp
#pragma once
#include "storage_soa.hpp"
#include "components.hpp"
class MovementSystem {
public:
void update(ComponentStorageSoA<TransformComponent>& transforms,
ComponentStorageSoA<VelocityComponent>& velocities,
float dt) {
auto& transform_entities = transforms.entities();
auto& transform_data = transforms.data();
auto& velocity_data = velocities.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:
void update(ComponentStorageSoA<TransformComponent>& transforms,
ComponentStorageSoA<SpriteComponent>& 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:
void update(World& world, float /*dt*/) {
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;
};
6. 쿼리(Query) 설계
쿼리: “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 <functional>
#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)...);
}
}
}
};
사용 예:
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 없음”
Include 컴포넌트는 있고 Exclude 컴포넌트는 없는 엔티티만 반환합니다.
// query_exclude.hpp - Include 후보에서 Exclude 있는 것 제거
template <typename... Include, typename... Exclude>
class QueryWithExclude {
public:
template <typename... IncStorages, typename... ExcStorages>
static std::vector<EntityID> execute(IncStorages&... inc, ExcStorages&... exc) {
auto candidates = Query<Include...>::execute(inc...);
std::vector<EntityID> result;
for (EntityID eid : candidates)
if (!(exc.has(eid) || ...))
result.push_back(eid);
return result;
}
};
7. 완전한 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()
8. 자주 하는 실수와 해결법
실수 1: 시스템 순회 중 엔티티 삭제
문제: 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()에서 일괄 삭제
실수 2: 컴포넌트 포인터 캐싱 후 삭제
문제: TransformComponent* t = entity->get_component<Transform>(); — 나중에 컴포넌트가 제거되면 dangling pointer.
해결: 매 프레임/매 호출마다 get_component로 조회하거나, 삭제 시 캐시를 무효화합니다.
// ❌ 위험
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;
}
실수 3: 시스템 순서 오류
문제: 물리 시스템이 입력 시스템보다 먼저 돌면, 이번 프레임 입력이 반영되지 않습니다.
해결: 시스템 등록 순서를 명확히 하고, 의존성 그래프를 두거나 문서화합니다.
// ✅ 명시적 순서
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
실수 4: AoS로 대량 엔티티 처리
문제: 수천 개 엔티티를 vector<Entity>로 두고, 각 Entity가 unordered_map<type_index, Component>를 갖습니다. 캐시 미스가 심합니다.
해결: SoA 스토리지로 전환하거나, 최소한 컴포넌트를 vector로 연속 저장합니다.
// ❌ 캐시 비효율
std::vector<Entity> entities; // Entity마다 map, 흩어진 메모리
// ✅ 캐시 친화
ComponentStorageSoA<TransformComponent> transforms;
// transforms.data()는 연속 메모리
실수 5: 쿼리 결과 매 프레임 vector 할당
문제: get_entities_with<A, B, C>()가 매번 vector를 새로 할당합니다. 60fps에서 초당 60번 할당.
해결: 반복자 기반 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);
실수 6: 컴포넌트에 로직 넣기
문제: 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 (auto [eid, health] : query<HealthComponent>(w)) {
if (damage_to_apply.count(eid))
health->current -= damage_to_apply[eid];
}
}
};
실수 7: EntityID 재사용 시 generation 누락
문제: 엔티티 삭제 후 ID를 재사용합니다. 이전에 그 ID를 캐싱한 코드가 남아 있으면 잘못된 엔티티를 참조합니다.
해결: (EntityID, Generation) 쌍을 사용하거나, ID를 재사용하지 않습니다.
// ✅ Generation 포함
struct EntityRef {
EntityID id;
uint32_t generation;
};
// 삭제 시 generation 증가, 조회 시 generation 검증
실수 8: add 시 기존 엔티티 덮어쓰기 누락
문제: SoA에서 add(entity, component) 호출 시 이미 entity가 있으면 덮어쓰지 않는 구현이 있습니다. add(player, TransformComponent{100,200,0}) 후에도 (0,0,0)이 유지됩니다.
해결: add에서 entity가 이미 있으면 components_[idx] = std::move(component)로 갱신합니다.
실수 9: 시스템 간 순환 의존
문제: SystemA가 SystemB를, SystemB가 SystemA를 참조하면 헤더 순환 참조로 빌드 실패합니다.
해결: 시스템은 World에만 의존합니다. 시스템끼리 직접 참조하지 않고, 컴포넌트 스토리지를 통해 통신합니다.
9. 모범 사례·베스트 프랙티스
1. 컴포넌트는 작고, 시스템은 단일 책임
- 컴포넌트: 한 가지 관심사만 (위치, 속도, 체력 등).
- 시스템: 한 가지 일만 (이동, 렌더, 충돌 등).
2. 태그 컴포넌트 활용
데이터 없이 “플레이어”, “적” 구분만 필요할 때 태그 컴포넌트를 씁니다. struct PlayerTag {}; — 크기 1바이트로 최소 오버헤드.
3. SoA는 핫 경로에, AoS는 프로토타입에
- 대량 처리(파티클, AI, 물리): SoA.
- 소규모, 빠른 프로토타입: 엔티티별 컴포넌트 map.
4. 시스템 의존성 명시
시스템 실행 순서를 문서화하거나 의존성 그래프로 관리합니다.
InputSystem → MovementSystem → PhysicsSystem → CollisionSystem → RenderSystem
5. 삭제는 지연(deferred)
순회 중 삭제를 피하고, to_destroy_에 모아 한 번에 처리합니다.
6. 쿼리 결과 재사용 또는 반복자
vector 할당을 줄이기 위해 each() 콜백 패턴을 사용합니다.
7. 엔티티 생성/삭제는 시스템 밖에서
시스템은 보통 update에서만 읽기/쓰기하고, 생성/삭제는 게임 로직(스크립트, 이벤트 핸들러)에서 수행합니다.
10. 프로덕션 패턴
패턴 1: Archetype 기반 스토리지
엔티티를 컴포넌트 조합(Archetype) 별로 그룹화합니다. 같은 Archetype끼리 연속 저장해 쿼리 시 캐시 효율이 극대화됩니다. Unity DOTS, Bevy ECS가 이 방식을 사용합니다.
// Archetype: {Transform, Velocity} vs {Transform, Velocity, Sprite}
// 각 Archetype은 별도 Chunk 배열
struct Archetype {
std::vector<Chunk> chunks;
std::type_index component_types[];
};
패턴 2: 멀티스레드 시스템
독립적인 시스템은 병렬 실행합니다. Transform만 쓰는 시스템과 Health만 쓰는 시스템은 동시에 돌려도 됩니다.
// 독립 시스템 병렬 실행
std::thread t1([&] { movement_system.update(world, dt); });
std::thread t2([&] { ai_system.update(world, dt); });
t1.join();
t2.join();
// 의존 있는 시스템은 순차
collision_system.update(world, dt);
패턴 3: 이벤트 기반 컴포넌트 변경
“엔티티가 죽었다”는 이벤트를 발행하고, 다른 시스템이 구독해 반응합니다. 직접 참조를 넘기지 않아 결합도를 낮춥니다.
struct EntityDiedEvent { EntityID id; };
event_bus.publish(EntityDiedEvent{enemy_id});
// 구독: LootSystem이 아이템 드롭, ScoreSystem이 점수 추가
패턴 4: 직렬화/재생
컴포넌트가 순수 데이터이므로 직렬화가 쉽습니다. 스냅샷을 저장해 리플레이, 네트워크 동기화에 활용합니다.
void save_snapshot(World& w, std::ostream& out) {
for (auto& [eid, t] : w.transforms().data())
out << t.x << " " << t.y << " " << t.z << "\n";
}
패턴 5: 라이브러리 활용
직접 구현 대신 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(
{ t.x += v.vx; });
11. 정리
| 항목 | 요약 |
|---|---|
| Entity | ID만 가진 핸들. 컴포넌트는 별도 스토리지에 저장 |
| Component | 순수 데이터. 로직 없음. POD에 가깝게 |
| System | 특정 컴포넌트 조합을 쿼리해 처리. 단일 책임 |
| 스토리지 | SoA = 캐시 친화, 대량 처리. AoS = 단순, 프로토타입 |
| 쿼리 | 반복자/each()로 할당 최소화 |
| 삭제 | 지연 삭제. 순회 중 삭제 금지 |
| 프로덕션 | Archetype, 멀티스레드, 이벤트, EnTT/flecs |
ECS는 상속 지옥을 피하고 캐시 친화적인 데이터 배치를 가능하게 합니다. 작은 프로젝트에서는 엔티티가 컴포넌트를 직접 갖는 간단한 구현으로 시작하고, 규모가 커지면 SoA, Archetype, 라이브러리로 확장하는 것이 좋습니다.
구현 체크리스트
- Entity는 ID만, 컴포넌트는 별도 스토리지
- 컴포넌트는 순수 데이터 (메서드 없음)
- 시스템은 단일 책임, 실행 순서 명시
- 삭제는 지연(deferred), 순회 중 삭제 금지
- 쿼리는
each()또는 결과 재사용으로 할당 최소화 - 대량 엔티티는 SoA 스토리지
- EntityID 재사용 시 generation 검증
참고 자료:
- EnTT - Entity Component System
- flecs - Fast Entity Component System
- Overwatch Gameplay Architecture and Netcode
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 캐시 효율적인 코드: 데이터 지향 설계 가이드
- C++ 게임 엔진 기초 | 렌더링·물리·입력·스크립팅 시스템 구현 [#50-3]
- C++ 현대적 다형성 설계: 상속 대신 합성·variant
이 글에서 다루는 키워드 (관련 검색어)
C++, ECS, Entity, Component, System, 게임아키텍처, 데이터지향설계 등으로 검색하시면 이 글이 도움이 됩니다.
실전 체크리스트
실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.
코드 작성 전
- 이 기법이 현재 문제를 해결하는 최선의 방법인가?
- 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
- 성능 요구사항을 만족하는가?
코드 작성 중
- 컴파일러 경고를 모두 해결했는가?
- 엣지 케이스를 고려했는가?
- 에러 처리가 적절한가?
코드 리뷰 시
- 코드의 의도가 명확한가?
- 테스트 케이스가 충분한가?
- 문서화가 되어 있는가?
이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 게임·시뮬레이션에서 상속 지옥을 피하는 ECS 패턴. 문제 시나리오, 완전한 구현 예제, 컴포넌트 스토리지·쿼리, 자주 하는 실수, 프로덕션 패턴까지. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
관련 글
- C++ 게임 엔진 기초 | 게임 루프·ECS·씬 그래프·입력 처리 완전 가이드
- C++ Redis 클론 | Modern C++ 인메모리 KV 스토어 [#48-1]
- C++ 초경량 HTTP 웹 프레임워크 바닥부터 만들기 [#48-2]
- C++ 커스텀 메모리 할당자(Memory Pool) 제작기 [#48-3]
- C++ vs Go | 성능·동시성·선택 가이드 완전 비교 [#47-1]