C++ 게임 엔진 아키텍처 완벽 가이드 | 게임 루프·ECS·씬 그래프·리소스 매니저·물리 통합

C++ 게임 엔진 아키텍처 완벽 가이드 | 게임 루프·ECS·씬 그래프·리소스 매니저·물리 통합

이 글의 핵심

C++ 게임 엔진 아키텍처 완벽 가이드에 대한 실전 가이드입니다. 게임 루프·ECS·씬 그래프·리소스 매니저·물리 통합 등을 예제와 함께 상세히 설명합니다.

들어가며: “프레임이 떨어지고, 엔티티 관리가 혼란스러워요”

왜 게임 엔진 아키텍처인가

직접 게임을 만들다 보면 프레임 드랍, 엔티티 추가/삭제 시 크래시, 리소스 로딩 병목, 씬 전환 시 메모리 폭발 같은 문제를 겪습니다. 이는 게임 루프, ECS, 씬 그래프, 리소스 매니저아키텍처 설계가 체계적이지 않아서 발생합니다. 이 글에서는 프로덕션급 게임 엔진의 핵심 아키텍처를 완전한 C++ 예제로 구현합니다.

이 글에서 다루는 것:

  • 문제 시나리오: 실제 개발에서 겪는 6가지 상황
  • 게임 루프: 고정 timestep, 가변 timestep, 보간(interpolation)
  • ECS: 엔티티·컴포넌트·시스템 완전 구현
  • 씬 그래프: 계층 구조, 월드/로컬 변환, 부모-자식 관계
  • 리소스 매니저: 비동기 로딩, 캐싱, 참조 카운팅
  • 물리 통합: Box2D 연동, 동기화 패턴
  • 자주 하는 실수: 10가지와 해결법
  • 베스트 프랙티스: 성능, 확장성, 유지보수
  • 프로덕션 패턴: 씬 전환, 직렬화, 디버깅

관련 글: 게임 엔진 기초 #50-3, 메모리 풀 #48-3.

개념을 잡는 비유

이 글의 주제는 여러 부품이 맞물리는 시스템으로 보시면 이해가 빠릅니다. 한 레이어(저장·네트워크·관측)의 선택이 옆 레이어에도 영향을 주므로, 본문에서는 트레이드오프를 숫자와 패턴으로 정리합니다.


목차

  1. 문제 시나리오
  2. 게임 루프 아키텍처
  3. ECS 완전 구현
  4. 씬 그래프
  5. 리소스 매니저
  6. 물리 엔진 통합
  7. 완전한 게임 엔진 예제
  8. 자주 하는 실수와 해결법
  9. 베스트 프랙티스
  10. 프로덕션 패턴
  11. 체크리스트
  12. 정리

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_;
};

비동기 로딩 큐 (메인 스레드 통합)

AsyncLoadQueueenqueue(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 |