본문으로 건너뛰기
Previous
Next
C++ 게임 엔진 아키텍처 완벽 가이드 | 게임 루프·ECS·씬 그래프·리소스 매니저·물리 통합

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

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

이 글의 핵심

C++ 게임 엔진 아키텍처 : 게임 루프·ECS·씬 그래프·리소스 매니저·물리 통합. 실무에서 겪은 문제·문제 시나리오.

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

왜 게임 엔진 아키텍처인가

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

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

개념을 잡는 비유

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

실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.

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++, 게임엔진, 아키텍처, ECS, 씬그래프, 게임루프, 리소스매니저, 물리엔진 등으로 검색하시면 이 글이 도움이 됩니다.

관련 글

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「C++ 게임 엔진 아키텍처 완벽 가이드 | 게임 루프·ECS·씬 그래프·리소스 매니저·물리 통합」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「C++ 게임 엔진 아키텍처 완벽 가이드 | 게임 루프·ECS·씬 그래프·리소스 매니저·물리 통합」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.