C++ 게임 엔진 아키텍처 완벽 가이드 | 게임 루프·ECS·씬 그래프·리소스 매니저·물리 통합
이 글의 핵심
C++ 게임 엔진 아키텍처 완벽 가이드에 대한 실전 가이드입니다. 게임 루프·ECS·씬 그래프·리소스 매니저·물리 통합 등을 예제와 함께 상세히 설명합니다.
들어가며: “프레임이 떨어지고, 엔티티 관리가 혼란스러워요”
왜 게임 엔진 아키텍처인가
직접 게임을 만들다 보면 프레임 드랍, 엔티티 추가/삭제 시 크래시, 리소스 로딩 병목, 씬 전환 시 메모리 폭발 같은 문제를 겪습니다. 이는 게임 루프, ECS, 씬 그래프, 리소스 매니저 등 아키텍처 설계가 체계적이지 않아서 발생합니다. 이 글에서는 프로덕션급 게임 엔진의 핵심 아키텍처를 완전한 C++ 예제로 구현합니다.
이 글에서 다루는 것:
- 문제 시나리오: 실제 개발에서 겪는 6가지 상황
- 게임 루프: 고정 timestep, 가변 timestep, 보간(interpolation)
- ECS: 엔티티·컴포넌트·시스템 완전 구현
- 씬 그래프: 계층 구조, 월드/로컬 변환, 부모-자식 관계
- 리소스 매니저: 비동기 로딩, 캐싱, 참조 카운팅
- 물리 통합: Box2D 연동, 동기화 패턴
- 자주 하는 실수: 10가지와 해결법
- 베스트 프랙티스: 성능, 확장성, 유지보수
- 프로덕션 패턴: 씬 전환, 직렬화, 디버깅
관련 글: 게임 엔진 기초 #50-3, 메모리 풀 #48-3.
개념을 잡는 비유
이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.
목차
- 문제 시나리오
- 게임 루프 아키텍처
- ECS 완전 구현
- 씬 그래프
- 리소스 매니저
- 물리 엔진 통합
- 완전한 게임 엔진 예제
- 자주 하는 실수와 해결법
- 베스트 프랙티스
- 프로덕션 패턴
- 체크리스트
- 정리
1. 문제 시나리오
시나리오 1: “60 FPS 목표인데 30 FPS에서도 프레임 드랍이 발생해요”
상황: 게임 루프에서 dt(델타 타임)를 그대로 물리·업데이트에 사용합니다. 저사양 PC에서 한 프레임이 50ms 걸리면 dt=0.05가 되어, 한 번에 3프레임 분량의 물리가 적용됩니다. 결과적으로 터널링(빠른 오브젝트가 벽 통과), 물리 불안정이 발생합니다.
원인: 가변 timestep을 물리에 직접 사용. dt가 커지면 한 스텝에 이동 거리가 과도해짐.
해결: 고정 timestep으로 물리를 업데이트하고, 렌더링은 보간(interpolation) 으로 부드럽게 표시.
시나리오 2: “엔티티를 삭제했는데 다른 시스템이 크래시해요”
상황: PhysicsSystem이 충돌을 감지하고 destroy_entity(id)를 호출합니다. 같은 프레임에 RenderSystem이 아직 해당 엔티티를 렌더링하려다 use-after-free로 크래시합니다.
원인: 시스템 실행 순서 중간에 엔티티 삭제. 다른 시스템이 이미 획득한 포인터/참조가 무효화됨.
해결: 지연 삭제(Deferred Destruction) — 삭제할 ID를 큐에 넣고, 프레임 종료 시점에 일괄 삭제.
시나리오 3: “씬 로딩 시 3초 동안 화면이 멈춰요”
상황: load_scene("level1") 호출 시 텍스처·모델·사운드를 동기적으로 모두 로드합니다. 500개 에셋 × 6ms = 3초. 그동안 게임 루프가 블로킹됩니다.
원인: 메인 스레드에서 동기 I/O. 리소스 매니저가 비동기 로딩을 지원하지 않음.
해결: 비동기 리소스 로더 — 백그라운드 스레드에서 로드하고, 메인 루프에서 완료된 것만 통합. 로딩 화면 표시.
시나리오 4: “캐릭터가 부모 오브젝트를 따라가지 않아요”
상황: 플레이어가 이동하는 플랫폼 위에 서 있습니다. 플랫폼이 오른쪽으로 이동해도 캐릭터는 제자리에 있습니다.
원인: 씬 그래프가 없어 부모-자식 변환을 적용하지 않음. 각 엔티티가 독립적인 월드 좌표만 가짐.
해결: 씬 그래프 — 자식의 최종 위치 = 부모 월드 변환 × 로컬 변환. 부모 이동 시 자식도 자동으로 따라감.
시나리오 5: “같은 텍스처를 10번 로드해요”
상황: 10개의 적이 같은 enemy.png를 사용하는데, 각 스프라이트가 load_texture("enemy.png")를 호출합니다. 메모리 10배, 로딩 시간 10배.
원인: 리소스 매니저가 캐싱을 하지 않음. 경로만으로 중복 체크 없이 매번 새로 로드.
해결: 리소스 매니저 — 경로를 키로 하는 unordered_map 캐시. 참조 카운팅으로 사용 중인 리소스만 유지.
시나리오 6: “물리 엔진과 렌더 좌표가 어긋나요”
상황: Box2D는 미터 단위, 게임은 픽셀 단위를 사용합니다. position = body->GetPosition()을 그대로 쓰면 캐릭터가 화면 밖으로 나갑니다.
원인: 스케일 변환 없이 물리 좌표를 렌더 좌표에 직접 대입.
해결: PPM(Pixels Per Meter) 상수로 변환. render_x = physics_x * PPM, physics_x = render_x / PPM.
문제 시나리오 다이어그램
flowchart TB
subgraph Problems["게임 엔진 문제"]
P1[프레임 드랍/물리 불안정]
P2[엔티티 삭제 크래시]
P3[씬 로딩 블로킹]
P4[부모-자식 변환 누락]
P5[리소스 중복 로드]
P6[물리-렌더 좌표 불일치]
end
subgraph Solutions["해결책"]
S1[고정 timestep + 보간]
S2[지연 삭제]
S3[비동기 리소스 로더]
S4[씬 그래프]
S5[리소스 매니저 캐싱]
S6[PPM 스케일 변환]
end
P1 --> S1
P2 --> S2
P3 --> S3
P4 --> S4
P5 --> S5
P6 --> S6
2. 게임 루프 아키텍처
게임 루프의 핵심
게임 루프는 입력 → 업데이트 → 렌더링을 반복합니다. 문제는 업데이트를 얼마나 자주, 어떤 dt로 할지입니다.
flowchart LR
subgraph Frame["한 프레임"]
A[입력 처리] --> B[업데이트]
B --> C[물리 스텝]
C --> D[렌더링]
D --> E[프레임 제한]
end
E --> A
고정 timestep vs 가변 timestep
| 방식 | 장점 | 단점 |
|---|---|---|
| 가변 timestep | 구현 단순, 프레임마다 정확한 시간 반영 | 물리 불안정, 터널링, 디버깅 어려움 |
| 고정 timestep | 물리 결정론적, 재현 가능 | 저 FPS 시 업데이트 누적, 스파이크 가능 |
권장: 물리는 고정 timestep(예: 1/60초), 게임 로직은 가변 또는 고정, 렌더링은 보간.
고정 timestep 게임 루프 (완전한 예제)
#include <chrono>
#include <cmath>
class GameLoop {
public:
static constexpr float FIXED_DT = 1.0f / 60.0f; // 60Hz 물리
static constexpr float MAX_ACCUMULATED = 0.25f; // 4프레임 이상 누적 방지
void run() {
auto last_time = std::chrono::high_resolution_clock::now();
float accumulated = 0.0f;
while (running_) {
auto now = std::chrono::high_resolution_clock::now();
float frame_time = std::chrono::duration<float>(now - last_time).count();
last_time = now;
// 스파이크 방지: 누적 시간 상한
accumulated += std::min(frame_time, MAX_ACCUMULATED);
// 입력은 매 프레임 (가변)
input_system_.update();
// 물리: 고정 timestep으로 여러 번
while (accumulated >= FIXED_DT) {
physics_system_.update(FIXED_DT);
game_logic_system_.update(FIXED_DT);
accumulated -= FIXED_DT;
}
// 보간 계수: 다음 물리 스텝까지의 진행률 (0~1)
float alpha = accumulated / FIXED_DT;
// 렌더링: 보간된 위치로 부드럽게
render_system_.render(alpha);
}
}
private:
bool running_ = true;
InputSystem input_system_;
PhysicsSystem physics_system_;
GameLogicSystem game_logic_system_;
RenderSystem render_system_;
};
핵심: alpha는 “다음 물리 스텝까지 얼마나 왔는지”를 나타냅니다. 렌더 시 pos = prev_pos + (curr_pos - prev_pos) * alpha로 보간합니다.
보간(Interpolation) 적용
// TransformComponent에 이전 프레임 위치 저장
struct TransformComponent {
glm::vec3 position{0, 0, 0};
glm::vec3 prev_position{0, 0, 0}; // 이전 물리 스텝 위치
void sync_prev() {
prev_position = position;
}
};
// 물리 스텝 후
void PhysicsSystem::update(float dt) {
// ... 물리 연산으로 position 업데이트 ...
for (auto* e : entities_) {
e->get_component<TransformComponent>()->sync_prev();
}
}
// 렌더 시 alpha로 보간
glm::vec3 get_interpolated_position(const TransformComponent& t, float alpha) {
return t.prev_position + (t.position - t.prev_position) * alpha;
}
3. ECS 완전 구현
ECS 아키텍처 개요
Entity: ID만 가진 빈 껍데기.
Component: 데이터만 담는 구조체 (Transform, Sprite, RigidBody 등).
System: 특정 컴포넌트 조합을 가진 엔티티들을 처리하는 로직.
flowchart TB
subgraph ECS["ECS"]
E1[Entity 1] --> C1[Transform]
E1 --> C2[Sprite]
E2[Entity 2] --> C3[Transform]
E2 --> C4[RigidBody]
E2 --> C5[Collider]
end
subgraph Systems["Systems"]
S1[RenderSystem] --> C1
S1 --> C2
S2[PhysicsSystem] --> C3
S2 --> C4
S2 --> C5
end
엔티티·컴포넌트 기본 구현
#include <cstdint>
#include <memory>
#include <unordered_map>
#include <typeindex>
#include <vector>
using EntityID = uint32_t;
// 컴포넌트 베이스 (RTTI용)
struct Component {
virtual ~Component() = default;
};
// 위치·회전·스케일
struct TransformComponent : Component {
glm::vec3 position{0, 0, 0};
glm::vec3 prev_position{0, 0, 0};
glm::vec3 rotation{0, 0, 0};
glm::vec3 scale{1, 1, 1};
};
// 스프라이트
struct SpriteComponent : Component {
std::string texture_id;
SDL_Rect src_rect{0, 0, 32, 32};
int z_order = 0;
};
// 물리
struct RigidBodyComponent : Component {
glm::vec3 velocity{0, 0, 0};
float mass = 1.0f;
bool is_static = false;
};
// 충돌체
struct ColliderComponent : Component {
float width = 32.0f;
float height = 32.0f;
bool is_trigger = false;
};
class Entity {
public:
explicit Entity(EntityID id) : id_(id) {}
template <typename T, typename... Args>
T& add_component(Args&&... args) {
auto comp = std::make_unique<T>(std::forward<Args>(args)...);
T* ptr = comp.get();
components_[typeid(T)] = std::move(comp);
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();
}
EntityID get_id() const { return id_; }
private:
EntityID id_;
std::unordered_map<std::type_index, std::unique_ptr<Component>> components_;
};
엔티티 매니저 + 지연 삭제
class EntityManager {
public:
Entity& create_entity() {
EntityID id = next_id_++;
auto entity = std::make_unique<Entity>(id);
Entity* ptr = entity.get();
entities_[id] = std::move(entity);
return *ptr;
}
void destroy_entity(EntityID id) {
entities_.erase(id);
}
// 지연 삭제: 프레임 종료 시 일괄 실행
void mark_for_destruction(EntityID id) {
to_destroy_.push_back(id);
}
void process_destruction() {
for (EntityID id : to_destroy_) {
destroy_entity(id);
}
to_destroy_.clear();
}
template <typename... Components>
std::vector<Entity*> get_entities_with() {
std::vector<Entity*> result;
for (auto& [id, entity] : entities_) {
if ((entity->has_component<Components>() && ...)) {
result.push_back(entity.get());
}
}
return result;
}
Entity* get_entity(EntityID id) {
auto it = entities_.find(id);
return it != entities_.end() ? it->second.get() : nullptr;
}
private:
std::unordered_map<EntityID, std::unique_ptr<Entity>> entities_;
std::vector<EntityID> to_destroy_;
EntityID next_id_ = 1;
};
4. 씬 그래프
씬 그래프 개념
씬 그래프는 엔티티를 트리 구조로 계층화합니다. 부모가 이동·회전하면 자식도 함께 변환됩니다.
flowchart TB
Root[Root] --> Player[Player]
Root --> World[World]
Player --> Sword[Sword]
Player --> Shield[Shield]
World --> Platform1[Platform1]
World --> Platform2[Platform2]
씬 노드 구현
#include <glm/gtc/matrix_transform.hpp>
class SceneNode {
public:
SceneNode(EntityID entity_id) : entity_id_(entity_id) {}
void set_parent(SceneNode* parent) {
if (parent_) {
auto& siblings = parent_->children_;
siblings.erase(std::remove(siblings.begin(), siblings.end(), this), siblings.end());
}
parent_ = parent;
if (parent_) {
parent_->children_.push_back(this);
}
}
void set_local_transform(const glm::vec3& pos, const glm::vec3& rot, const glm::vec3& scl) {
local_position_ = pos;
local_rotation_ = rot;
local_scale_ = scl;
dirty_ = true;
}
// 부모 변환 × 로컬 변환 = 월드 변환
glm::mat4 get_world_matrix() {
if (dirty_) {
local_matrix_ = glm::mat4(1.0f);
local_matrix_ = glm::translate(local_matrix_, local_position_);
local_matrix_ = glm::rotate(local_matrix_, local_rotation_.z, glm::vec3(0, 0, 1));
local_matrix_ = glm::scale(local_matrix_, local_scale_);
if (parent_) {
world_matrix_ = parent_->get_world_matrix() * local_matrix_;
} else {
world_matrix_ = local_matrix_;
}
dirty_ = false;
for (auto* child : children_) {
child->dirty_ = true; // 자식도 갱신 필요
}
}
return world_matrix_;
}
glm::vec3 get_world_position() {
glm::vec4 pos = get_world_matrix() * glm::vec4(0, 0, 0, 1);
return glm::vec3(pos);
}
EntityID get_entity_id() const { return entity_id_; }
SceneNode* get_parent() const { return parent_; }
const std::vector<SceneNode*>& get_children() const { return children_; }
private:
EntityID entity_id_;
SceneNode* parent_ = nullptr;
std::vector<SceneNode*> children_;
glm::vec3 local_position_{0, 0, 0};
glm::vec3 local_rotation_{0, 0, 0};
glm::vec3 local_scale_{1, 1, 1};
glm::mat4 local_matrix_{1.0f};
glm::mat4 world_matrix_{1.0f};
bool dirty_ = true;
};
class SceneGraph {
public:
SceneNode* create_node(EntityID entity_id) {
nodes_.push_back(std::make_unique<SceneNode>(entity_id));
return nodes_.back().get();
}
void update_world_transforms(EntityManager& entities) {
for (auto& node : nodes_) {
glm::mat4 world = node->get_world_matrix();
Entity* e = entities.get_entity(node->get_entity_id());
if (e) {
auto* t = e->get_component<TransformComponent>();
if (t) {
glm::vec4 pos = world * glm::vec4(0, 0, 0, 1);
t->position = glm::vec3(pos);
}
}
}
}
private:
std::vector<std::unique_ptr<SceneNode>> nodes_;
};
5. 리소스 매니저
리소스 매니저 요구사항
- 캐싱: 같은 경로 재로드 방지
- 참조 카운팅: 사용 중인 리소스만 유지
- 비동기 로딩: 메인 루프 블로킹 방지 (선택)
텍스처 리소스 매니저 (완전한 예제)
#include <string>
#include <unordered_map>
#include <memory>
#include <mutex>
#include <future>
class TextureResource {
public:
SDL_Texture* get() const { return texture_; }
int ref_count() const { return ref_count_; }
void add_ref() { ++ref_count_; }
void release() { --ref_count_; }
private:
SDL_Texture* texture_ = nullptr;
int ref_count_ = 1;
friend class ResourceManager;
};
class ResourceManager {
public:
explicit ResourceManager(SDL_Renderer* renderer) : renderer_(renderer) {}
// 동기 로드 (캐싱 포함)
TextureResource* load_texture(const std::string& path) {
std::lock_guard lock(mutex_);
auto it = textures_.find(path);
if (it != textures_.end()) {
it->second->add_ref();
return it->second.get();
}
SDL_Surface* surface = IMG_Load(path.c_str());
if (!surface) {
SDL_Log("Failed to load texture: %s", path.c_str());
return nullptr;
}
SDL_Texture* tex = SDL_CreateTextureFromSurface(renderer_, surface);
SDL_FreeSurface(surface);
if (!tex) return nullptr;
auto resource = std::make_unique<TextureResource>();
resource->texture_ = tex;
TextureResource* ptr = resource.get();
textures_[path] = std::move(resource);
return ptr;
}
void release_texture(TextureResource* resource) {
if (!resource) return;
resource->release();
if (resource->ref_count() <= 0) {
std::lock_guard lock(mutex_);
for (auto it = textures_.begin(); it != textures_.end(); ++it) {
if (it->second.get() == resource) {
SDL_DestroyTexture(it->second->get());
textures_.erase(it);
break;
}
}
}
}
// 비동기 로드: 백그라운드에서 로드, 완료 시 콜백
void load_texture_async(const std::string& path,
std::function<void(TextureResource*)> on_complete) {
std::async(std::launch::async, [this, path, on_complete]() {
TextureResource* res = load_texture(path);
// 메인 스레드에서 콜백 호출 필요 시 큐에 넣고, 메인 루프에서 처리
on_complete(res);
});
}
private:
SDL_Renderer* renderer_;
std::unordered_map<std::string, std::unique_ptr<TextureResource>> textures_;
std::mutex mutex_;
};
비동기 로딩 큐 (메인 스레드 통합)
AsyncLoadQueue는 enqueue(path, callback)으로 로드 요청을 넣고, 메인 루프에서 process_completed(rm)를 호출해 완료된 로드의 콜백을 실행합니다. 워커 스레드에서 SDL/OpenGL 컨텍스트를 건드리지 않도록 주의합니다.
6. 물리 엔진 통합
Box2D와 ECS 연동
Box2D는 미터 단위를 사용합니다. 게임이 픽셀이면 PPM(Pixels Per Meter) 로 변환합니다.
#include <box2d/box2d.h>
constexpr float PPM = 100.0f; // 100 pixels = 1 meter
class Box2DPhysicsSystem {
public:
Box2DPhysicsSystem() : world_({0.0f, 9.8f}) {}
void sync_to_physics(EntityManager& entities) {
for (auto* entity : entities.get_entities_with<TransformComponent, RigidBodyComponent, ColliderComponent>()) {
auto* t = entity->get_component<TransformComponent>();
auto* rb = entity->get_component<RigidBodyComponent>();
auto* col = entity->get_component<ColliderComponent>();
b2BodyDef def;
def.position.Set(t->position.x / PPM, t->position.y / PPM);
def.type = rb->is_static ? b2_staticBody : b2_dynamicBody;
b2Body* body = world_.CreateBody(&def);
b2PolygonShape box;
box.SetAsBox(col->width / (2 * PPM), col->height / (2 * PPM));
b2FixtureDef fix;
fix.shape = &box;
fix.density = 1.0f;
body->CreateFixture(&fix);
body->SetUserData(reinterpret_cast<void*>(static_cast<uintptr_t>(entity->get_id())));
entity_to_body_[entity->get_id()] = body;
}
}
void step(float dt) {
world_.Step(dt, 6, 2); // velocityIterations, positionIterations
}
void sync_from_physics(EntityManager& entities) {
for (auto* entity : entities.get_entities_with<TransformComponent, RigidBodyComponent, ColliderComponent>()) {
auto it = entity_to_body_.find(entity->get_id());
if (it == entity_to_body_.end()) continue;
b2Body* body = it->second;
auto* t = entity->get_component<TransformComponent>();
auto* rb = entity->get_component<RigidBodyComponent>();
t->prev_position = t->position;
b2Vec2 pos = body->GetPosition();
t->position.x = pos.x * PPM;
t->position.y = pos.y * PPM;
b2Vec2 vel = body->GetLinearVelocity();
rb->velocity.x = vel.x * PPM;
rb->velocity.y = vel.y * PPM;
}
}
private:
b2World world_;
std::unordered_map<EntityID, b2Body*> entity_to_body_;
};
7. 완전한 게임 엔진 예제
통합 아키텍처
flowchart TB
subgraph Engine["Game Engine"]
GL[GameLoop]
EM[EntityManager]
SG[SceneGraph]
RM[ResourceManager]
PS[PhysicsSystem]
RS[RenderSystem]
IS[InputSystem]
end
GL --> EM
GL --> SG
GL --> RM
GL --> PS
GL --> RS
GL --> IS
PS --> EM
RS --> EM
RS --> RM
SG --> EM
GameEngine 통합 클래스
class GameEngine {
public:
bool init() {
if (SDL_Init(SDL_INIT_VIDEO) != 0) return false;
window_ = SDL_CreateWindow("Engine", SDL_WINDOWPOS_CENTERED,
SDL_WINDOWPOS_CENTERED, 800, 600, 0);
if (!window_) return false;
renderer_ = SDL_CreateRenderer(window_, -1, SDL_RENDERER_ACCELERATED);
if (!renderer_) return false;
resource_mgr_ = std::make_unique<ResourceManager>(renderer_);
render_system_ = std::make_unique<RenderSystem>(renderer_, resource_mgr_.get());
physics_system_ = std::make_unique<Box2DPhysicsSystem>();
input_system_ = std::make_unique<InputSystem>();
create_sample_scene();
return true;
}
void run() {
auto last_time = std::chrono::high_resolution_clock::now();
float accumulated = 0.0f;
while (running_) {
auto now = std::chrono::high_resolution_clock::now();
float frame_time = std::chrono::duration<float>(now - last_time).count();
last_time = now;
accumulated += std::min(frame_time, 0.25f);
input_system_->update();
if (input_system_->is_quit_requested()) break;
while (accumulated >= GameLoop::FIXED_DT) {
physics_system_->step(GameLoop::FIXED_DT);
physics_system_->sync_from_physics(entities_);
scene_graph_.update_world_transforms(entities_);
accumulated -= GameLoop::FIXED_DT;
}
entities_.process_destruction(); // 지연 삭제 처리
float alpha = accumulated / GameLoop::FIXED_DT;
render_system_->render(entities_, alpha);
}
}
void shutdown() {
if (renderer_) SDL_DestroyRenderer(renderer_);
if (window_) SDL_DestroyWindow(window_);
SDL_Quit();
}
private:
void create_sample_scene() {
auto& player = entities_.create_entity();
player.add_component<TransformComponent>().position = {100, 200, 0};
player.add_component<SpriteComponent>().texture_id = "player";
player.add_component<RigidBodyComponent>();
player.add_component<ColliderComponent>();
auto& floor = entities_.create_entity();
floor.add_component<TransformComponent>().position = {0, 500, 0};
floor.add_component<SpriteComponent>().texture_id = "floor";
auto& rb = floor.add_component<RigidBodyComponent>();
rb.is_static = true;
floor.add_component<ColliderComponent>().width = 800;
floor.get_component<ColliderComponent>()->height = 100;
}
SDL_Window* window_ = nullptr;
SDL_Renderer* renderer_ = nullptr;
bool running_ = true;
EntityManager entities_;
SceneGraph scene_graph_;
std::unique_ptr<ResourceManager> resource_mgr_;
std::unique_ptr<RenderSystem> render_system_;
std::unique_ptr<Box2DPhysicsSystem> physics_system_;
std::unique_ptr<InputSystem> input_system_;
};
8. 자주 하는 실수와 해결법
에러 1: 엔티티 삭제 후 use-after-free
증상: destroy_entity 호출 직후 다른 시스템이 해당 엔티티 포인터 접근 시 크래시.
// ❌ 잘못된 예
void PhysicsSystem::on_collision(Entity* a, Entity* b) {
if (is_coin(b)) {
entities_.destroy_entity(b->get_id()); // 즉시 삭제
}
// RenderSystem이 아직 b를 참조 중!
}
// ✅ 올바른 예: 지연 삭제
void PhysicsSystem::on_collision(Entity* a, Entity* b) {
if (is_coin(b)) {
entities_.mark_for_destruction(b->get_id());
}
}
// 프레임 종료 시 entities_.process_destruction() 호출
에러 2: 물리 터널링 (빠른 오브젝트가 벽 통과)
증상: 총알이나 빠른 캐릭터가 벽을 뚫고 지나감.
// ❌ dt가 클 때 한 스텝에 이동 거리가 collider보다 큼
physics_system_.update(dt); // dt=0.05면 50ms 분량 한 번에
// ✅ 서브스텝 또는 고정 timestep
const int substeps = 4;
float sub_dt = dt / substeps;
for (int i = 0; i < substeps; ++i) {
physics_system_.update(sub_dt);
}
에러 3: PPM 스케일 누락
증상: Box2D 연동 시 캐릭터가 화면 밖으로 나가거나 너무 작게 보임.
// ❌ 픽셀 좌표를 그대로 Box2D에 전달
def.position.Set(transform->position.x, transform->position.y);
// ✅ PPM로 변환
def.position.Set(transform->position.x / PPM, transform->position.y / PPM);
에러 4: 씬 그래프 dirty 플래그 누락
증상: 부모 이동 후 자식 위치가 갱신되지 않음.
// ✅ set_local_transform 시 dirty = true
void set_local_transform(const glm::vec3& pos, ...) {
local_position_ = pos;
dirty_ = true;
}
// ✅ 부모 갱신 시 자식도 dirty
for (auto* child : children_) {
child->dirty_ = true;
}
에러 5: 리소스 해제 시점 오류
증상: 텍스처를 사용 중인데 해제해 검은 화면 또는 크래시.
// ❌ 참조 카운팅 없이 즉시 해제
textures_.erase(path);
// ✅ 참조 카운팅
void release_texture(TextureResource* res) {
res->release();
if (res->ref_count() <= 0) {
// 실제 해제
}
}
에러 6: 렌더 순서 불안정 (z_order 동일 시)
증상: 같은 z_order인 엔티티가 프레임마다 순서가 바뀜.
// ✅ 안정 정렬 + 2차 키
std::stable_sort(entities.begin(), entities.end(),
{
int za = a->get_component<SpriteComponent>()->z_order;
int zb = b->get_component<SpriteComponent>()->z_order;
if (za != zb) return za < zb;
return a->get_id() < b->get_id();
});
에러 7: 고정 timestep 누적 시 스파이크
증상: 1초 동안 게임이 멈추면 accumulated가 1.0이 되어 60번 물리 스텝. 복구 시 프레임 스파이크.
// ✅ 누적 시간 상한
accumulated += std::min(frame_time, 0.25f); // 최대 4프레임 분량
에러 8: Lua/C++ 경계에서 엔티티 ID 오류
증상: Lua에서 destroy_entity(999) 호출 시 999가 이미 삭제된 ID.
// ✅ 삭제 전 유효성 검사
void destroy_entity(EntityID id) {
if (entities_.find(id) == entities_.end()) return;
entities_.erase(id);
}
에러 9: 메인 루프에서 비동기 콜백 직접 호출
증상: 워커 스레드에서 on_complete(texture) 호출 시 OpenGL/SDL 컨텍스트가 메인 스레드에만 있어 크래시.
해결: 완료 콜백을 큐에 넣고, 메인 루프에서 main_thread_queue_.process()로 처리합니다.
에러 10: Box2D Body 생성 후 Fixture 설정 순서
증상: CreateFixture 호출 전에 b2PolygonShape 지역 변수가 스코프를 벗어나 dangling reference.
해결: CreateFixture는 shape을 복사하므로, 호출 직후까지 box가 유효하면 됩니다. body 생성과 fixture 생성 사이에 다른 작업을 넣지 마세요.
9. 베스트 프랙티스
1. 시스템 실행 순서
- 입력 → 물리 → 게임 로직 → 씬 그래프 갱신 → 렌더링 → 지연 삭제
- 물리 전에 입력을 처리해 플레이어 조작이 즉시 반영되도록 합니다.
2. 컴포넌트 설계
- 컴포넌트는 데이터만 담고, 로직은 시스템에 둡니다.
- 컴포넌트 크기를 작게 유지해 캐시 효율을 높입니다.
3. 엔티티 쿼리 최적화
get_entities_with매번 전체 순회 대신, 컴포넌트 인덱스를 유지해 O(1)에 가깝게 조회합니다.
4. 리소스 로딩
- 씬 로딩 시 필수 리소스만 동기 로드, 나머지는 비동기로 로딩 화면과 함께 처리합니다.
5. 디버깅 지원
#ifdef DEBUG블록에서 엔티티 수, 물리 스텝 시간, FPS를 오버레이로 표시합니다.
6. 설정 외부화
- PPM, FIXED_DT, MAX_ACCUMULATED 등을 config.json으로 로드해 빌드 없이 조정 가능하게 합니다.
10. 프로덕션 패턴
패턴 1: 씬 전환 시스템
class SceneManager {
public:
void load_scene(const std::string& name) {
entities_.clear();
to_destroy_.clear();
scene_graph_.clear();
if (scene_loaders_.count(name)) {
scene_loaders_[name](entities_, scene_graph_, *resource_mgr_);
}
}
void register_scene(const std::string& name,
std::function<void(EntityManager&, SceneGraph&, ResourceManager&)> loader) {
scene_loaders_[name] = std::move(loader);
}
private:
std::unordered_map<std::string, std::function<void(EntityManager&, SceneGraph&, ResourceManager&)>> scene_loaders_;
};
패턴 2: 게임 상태 직렬화
void save_game(const std::string& path, EntityManager& entities) {
nlohmann::json j;
for (auto& [id, entity] : entities.get_all()) {
j["entities"].push_back(serialize_entity(*entity));
}
std::ofstream f(path);
f << j.dump();
}
패턴 3: 디버그 오버레이
void render_debug_overlay(float dt, size_t entity_count) {
ImGui::Text("FPS: %.1f", 1.0f / dt);
ImGui::Text("Entities: %zu", entity_count);
ImGui::Text("Physics steps: %d", physics_step_count_);
}
패턴 4: 객체 풀링 (엔티티 재사용)
엔티티를 destroy 대신 풀에 반환하고, acquire 시 풀에서 꺼내 재사용합니다. reset_components()로 컴포넌트를 초기화합니다.
패턴 5: 이벤트 버스 (시스템 간 통신)
EventBus::publish(Event)로 이벤트를 발행하고, subscribe로 핸들러를 등록합니다. 시스템 간 결합도를 낮춥니다.
11. 체크리스트
- 게임 루프: 고정 timestep(1/60), accumulated 상한, 렌더 보간, 지연 삭제는 프레임 종료 시
- ECS: 컴포넌트는 데이터만,
mark_for_destruction+process_destruction, 시스템 실행 순서 고정 - 씬 그래프:
dirty플래그, 부모 변경 시 자식 전파 - 리소스: 경로 캐싱, 참조 카운팅, 비동기 시 메인 스레드 콜백
- 물리: PPM 변환, sync 타이밍
- 프로덕션: 설정 외부화, 디버그 오버레이, 씬 전환 시 리소스 정리
12. 정리
| 항목 | 핵심 |
|---|---|
| 게임 루프 | 고정 timestep 물리, 보간 렌더링, accumulated 상한 |
| ECS | 엔티티-컴포넌트-시스템, 지연 삭제 |
| 씬 그래프 | 부모-자식 변환, dirty 전파 |
| 리소스 매니저 | 캐싱, 참조 카운팅, 비동기 로딩 |
| 물리 통합 | PPM 변환, sync 타이밍 |
| 프로덕션 | 씬 전환, 직렬화, 이벤트 버스, 객체 풀 |
한 줄 요약: 게임 루프·ECS·씬 그래프·리소스 매니저·물리 통합을 체계적으로 설계하면 프레임 드랍, 크래시, 로딩 병목을 해결할 수 있습니다.
다음 글: 데이터베이스 엔진 #50-4
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 프로덕션급 게임 엔진 설계: 프레임 드랍, 엔티티 관리 혼란, 리소스 로딩 병목 등 문제 시나리오부터 게임 루프, ECS, 씬 그래프, 리소스 매니저, 물리 통합까지 완전한 예제로 구현합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
이전 글: REST API 서버 #50-2
참고 자료
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 게임 엔진 기초 | 렌더링·물리·입력·스크립팅 시스템 구현 [#50-3]
- C++ 커스텀 메모리 할당자(Memory Pool) 제작기 [#48-3]
- C++ 현대적인 C++ GUI: Dear ImGui로 디버깅 툴·대시보드 만들기 [#36-1]
실전 체크리스트
실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.
코드 작성 전
- 이 기법이 현재 문제를 해결하는 최선의 방법인가?
- 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
- 성능 요구사항을 만족하는가?
코드 작성 중
- 컴파일러 경고를 모두 해결했는가?
- 엣지 케이스를 고려했는가?
- 에러 처리가 적절한가?
코드 리뷰 시
- 코드의 의도가 명확한가?
- 테스트 케이스가 충분한가?
- 문서화가 되어 있는가?
이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.
이 글에서 다루는 키워드 (관련 검색어)
C++, 게임엔진, 아키텍처, ECS, 씬그래프, 게임루프, 리소스매니저, 물리엔진 등으로 검색하시면 이 글이 도움이 됩니다.
관련 글
- C++ 시리즈 전체 보기
- C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
- C++ ADL |
- C++ Aggregate Initialization |