C++ 게임 엔진 기초: 게임 루프·ECS·씬 그래프·입력 처리 구현

들어가며: 게임 엔진의 내부 구조

게임 엔진은 게임 루프, 엔티티·컴포넌트, 씬 그래프, 입력 처리가 결합된 실시간 시스템입니다. Unity나 Unreal을 쓰더라도 내부 동작을 이해하지 못하면 성능 병목, 프레임 드랍, 입력 지연을 해결하기 어렵습니다. 이 글은 게임 루프, 최소 ECS, 씬 그래프, 입력 처리를 각각 작은 C++ 코드로 만들어 보고, 그 코드가 실제 게임에서 어떤 식으로 깨지는지(프레임률에 따라 달라지는 속도, 씹히는 입력, 씬 전환 크래시)를 함께 짚습니다. 예제는 원리를 보여 주기 위한 최소 구현이라, 각 절에서 실제 엔진이 추가로 처리하는 부분도 설명합니다.

관련 글: 메모리 풀, 캐시 친화적 코드.


게임 루프·입력·씬 전환에서 겪는 상황

게임 엔진을 이해하지 못하면 아래와 같은 문제가 발생합니다.

flowchart TB
    subgraph Problems[실무 문제 시나리오]
        P1[프레임 드랍·불규칙한 업데이트]
        P2[입력 지연·더블 입력]
        P3[메모리 파편화·할당 병목]
        P4[씬 전환 시 크래시]
        P5[물리·렌더링 불일치]
    end
    subgraph Solutions[해결 방향]
        S1[고정 타임스텝 게임 루프]
        S2[입력 버퍼링·이벤트 큐]
        S3[ECS·객체 풀]
        S4[씬 그래프·안전한 전환]
        S5[업데이트 순서 명확화]
    end
    P1 --> S1
    P2 --> S2
    P3 --> S3
    P4 --> S4
    P5 --> S5

원인: 이동량에 델타타임을 곱하지 않았기 때문입니다. 프레임마다 고정값을 더하면 프레임을 많이 그리는 고사양 PC에서는 빠르게, 프레임이 적게 나오는 저사양에서는 느리게 움직입니다. 60fps 기준으로 속도를 맞춰 둔 게임을 144Hz 모니터에서 켜면 캐릭터가 두 배 이상 빨라지는 것이 전형적인 증상입니다. 델타타임을 곱하더라도 가변 델타타임만 쓰면 물리 계산(충돌, 점프 높이)이 프레임률에 따라 달라지는 문제가 남으므로, 아래의 고정 타임스텝이 필요합니다.

원인: 입력을 매 프레임 폴링만 하고, 키 다운/업 구분 없이 “현재 눌림”만 체크하기 때문입니다. 프레임마다 true가 되어 연속 점프가 발생합니다.

원인: 씬 A의 엔티티가 씬 B로 전환되는 중에 삭제된 객체를 참조하기 때문입니다. 씬 그래프나 ECS에서 부모-자식·참조 관계를 안전하게 정리하지 않은 경우입니다.

원인: 매 프레임 new/delete를 하고, 상속 기반 다형성으로 캐시 미스가 나기 때문입니다. ECS와 객체 풀로 데이터 지향 설계를 해야 합니다.

원인: 업데이트 순서가 불명확하기 때문입니다. 물리 → 게임 로직 → 렌더링 순서를 고정하고, 각 단계 간 데이터 전달을 명확히 해야 합니다.


고정 타임스텝 게임 루프

업데이트와 렌더로 나뉜 한 프레임

게임 루프는 입력 → 업데이트 → 렌더링을 반복하는 핵심 구조입니다. 프레임 간 시간(델타타임)을 측정하고 업데이트에 반영해야 기기 성능과 무관하게 일정한 게임 속도를 유지할 수 있습니다.

flowchart LR
    subgraph Loop[게임 루프]
        I[입력 처리]
        U[업데이트]
        R[렌더링]
    end
    I --> U --> R --> I

프레임별 시퀀스

sequenceDiagram
    participant GL as 게임 루프
    participant Input as 입력
    participant Update as 업데이트
    participant Render as 렌더링
    GL->>Input: process_events()
    Input->>Input: 키/마우스 상태 갱신
    GL->>Update: update(fixed_dt)
    Update->>Update: 물리, 로직, 애니메이션
    GL->>Render: render(alpha)
    Render->>Render: 씬 그래프, 드로우 콜
    GL->>Input: end_frame()

고정 타임스텝 vs 가변 델타타임

방식장점단점
고정 타임스텝물리·로직 일관성, 재현 가능저사양에서 누적 지연
가변 델타타임유연함, 단순물리 불안정, 기기별 속도 차이

권장: 물리·게임 로직은 고정 타임스텝(예: 1/60초)으로, 렌더링은 가변으로 돌리고 인터폴레이션합니다.

완전한 게임 루프 예제

// game_loop.cpp - 고정 타임스텝 게임 루프
#include <chrono>
#include <functional>
class GameLoop {
public:
    using UpdateFunc = std::function<void(double)>;
    using RenderFunc = std::function<void(double)>;
    GameLoop(double fixed_dt = 1.0 / 60.0)
        : fixed_dt_(fixed_dt)
        , accumulator_(0.0)
        , running_(false) {}
    void set_update(UpdateFunc f) { update_ = std::move(f); }
    void set_render(RenderFunc f) { render_ = std::move(f); }
    void run() {
        running_ = true;
        auto last_time = std::chrono::high_resolution_clock::now();
        while (running_) {
            auto now = std::chrono::high_resolution_clock::now();
            double frame_time = std::chrono::duration<double>(now - last_time).count();
            last_time = now;
            // 스파이크 방지: 최대 0.25초로 클램프
            if (frame_time > 0.25) frame_time = 0.25;
            accumulator_ += frame_time;
            // 고정 타임스텝 업데이트 (물리·로직)
            while (accumulator_ >= fixed_dt_) {
                if (update_) update_(fixed_dt_);
                accumulator_ -= fixed_dt_;
            }
            // 렌더링 (인터폴레이션용 alpha)
            double alpha = accumulator_ / fixed_dt_;
            if (render_) render_(alpha);
            process_events();
        }
    }
    void stop() { running_ = false; }
private:
    double fixed_dt_;
    double accumulator_;
    bool running_;
    UpdateFunc update_;
    RenderFunc render_;
    void process_events() {
        // 윈도우 이벤트, 입력 등 처리
    }
};

핵심 포인트:

  • accumulator_: 누적된 시간을 저장해 고정 타임스텝만큼 여러 번 업데이트
  • alpha: 다음 프레임까지의 보간 비율 (0~1), 렌더링 시 스무딩에 사용
  • frame_time > 0.25 클램프: 디버깅 중 멈춤 등으로 인한 스파이크 방지

이 구조는 Glenn Fiedler의 “Fix Your Timestep!”으로 널리 알려진 방식입니다. 핵심은 시뮬레이션 시간과 실제 시간을 분리하는 것입니다. 실제 시간이 얼마나 흘렀든 시뮬레이션은 항상 fixed_dt 단위로만 전진하므로, 같은 입력을 주면 어떤 PC에서든 같은 결과가 나옵니다. 이 결정성 덕분에 리플레이 저장, 네트워크 동기화(록스텝), 물리 버그 재현이 가능해집니다.

alpha는 흔히 오해받는 값입니다. 렌더링 시점은 대개 두 업데이트 사이 어딘가에 있는데, 마지막 업데이트 결과를 그대로 그리면 렌더 프레임과 업데이트 주기가 어긋나는 만큼 움직임이 미세하게 떨립니다(temporal aliasing). 그래서 이전 상태와 현재 상태를 alpha 비율로 보간해서 그립니다. 뒤의 “렌더 인터폴레이션” 절처럼 엔티티마다 이전 위치를 따로 저장해야 하므로 메모리와 코드가 늘어나며, 그 대가로 최대 한 틱만큼의 표시 지연이 생긴다는 트레이드오프도 있습니다.

시간 측정에는 high_resolution_clock보다 steady_clock이 안전합니다. 표준은 high_resolution_clock이 단조 증가한다고 보장하지 않으며, 일부 구현에서는 system_clock의 별칭이라 시스템 시각이 조정되면 frame_time이 음수가 되거나 크게 튈 수 있습니다.


Entity Component System (ECS)

엔티티·컴포넌트·시스템의 역할

ECS는 엔티티(ID), 컴포넌트(순수 데이터), 시스템(로직)으로 분리하는 데이터 지향 설계입니다. 상속 대신 조합으로 유연성을 확보하며, 캐시 친화적인 배열 기반 저장으로 성능을 높입니다.

flowchart TB
    subgraph ECS[ECS 구조]
        E[Entity ID]
        C1[Transform]
        C2[Velocity]
        C3[Sprite]
        S1[MovementSystem]
        S2[RenderSystem]
    end
    E --> C1
    E --> C2
    E --> C3
    S1 --> C1
    S1 --> C2
    S2 --> C1
    S2 --> C3

완전한 ECS 예제

// ecs_basic.cpp - 최소 ECS 구현
#include <cstdint>
#include <vector>
#include <unordered_map>
#include <bitset>
using EntityId = uint32_t;
constexpr EntityId NULL_ENTITY = 0;
// 컴포넌트: 순수 데이터만
struct TransformComponent {
    float x, y, z;
    float rotation;
};
struct VelocityComponent {
    float vx, vy, vz;
};
struct ComponentMask {
    std::bitset<64> components;
    ComponentMask& set(size_t idx) { components.set(idx); return *this; }
    bool matches(const ComponentMask& other) const {
        return (components & other.components) == other.components;
    }
};
class ECS {
public:
    EntityId create_entity() {
        EntityId id = next_id_++;
        entities_.push_back(id);
        masks_[id] = ComponentMask();
        return id;
    }
    void destroy_entity(EntityId id) {
        // 마스크 초기화, 컴포넌트 제거
        masks_[id].components.reset();
        // 실제로는 삭제 대기 큐에 넣으며, 시스템 업데이트 후 정리
    }
    template<typename T>
    T* add_component(EntityId id, T comp) {
        auto& vec = get_component_vec<T>();
        size_t idx = comp_index<T>();
        if (vec.size() <= id) vec.resize(id + 1);
        vec[id] = comp;
        masks_[id].set(idx);
        return &vec[id];
    }
    template<typename T>
    T* get_component(EntityId id) {
        auto& vec = get_component_vec<T>();
        if (id >= vec.size() || !masks_[id].components.test(comp_index<T>()))
            return nullptr;
        return &vec[id];
    }
    template<typename T>
    std::vector<EntityId> entities_with() {
        std::vector<EntityId> result;
        ComponentMask required;
        required.set(comp_index<T>());
        for (EntityId id : entities_) {
            if (masks_[id].matches(required))
                result.push_back(id);
        }
        return result;
    }
private:
    EntityId next_id_ = 1;
    std::vector<EntityId> entities_;
    std::unordered_map<EntityId, ComponentMask> masks_;
    std::vector<TransformComponent> transforms_;
    std::vector<VelocityComponent> velocities_;
    template<typename T> size_t comp_index();
    template<typename T> std::vector<T>& get_component_vec();
};
template<> size_t ECS::comp_index<TransformComponent>() { return 0; }
template<> size_t ECS::comp_index<VelocityComponent>() { return 1; }
template<> std::vector<TransformComponent>& ECS::get_component_vec<TransformComponent>() { return transforms_; }
template<> std::vector<VelocityComponent>& ECS::get_component_vec<VelocityComponent>() { return velocities_; }
// 시스템: 특정 컴포넌트 조합을 가진 엔티티만 처리
class MovementSystem {
public:
    void update(ECS& ecs, double dt) {
        for (EntityId id : ecs.entities_with<VelocityComponent>()) {
            auto* vel = ecs.get_component<VelocityComponent>(id);
            auto* trans = ecs.get_component<TransformComponent>(id);
            if (vel && trans) {
                trans->x += vel->vx * static_cast<float>(dt);
                trans->y += vel->vy * static_cast<float>(dt);
                trans->z += vel->vz * static_cast<float>(dt);
            }
        }
    }
};

핵심 포인트:

  • 컴포넌트는 순수 데이터, 시스템은 로직만
  • ComponentMask로 “이 컴포넌트들을 가진 엔티티”만 필터링
  • 배열 기반 저장으로 캐시 친화적 순회 가능

이 최소 구현은 원리를 보여 주는 데 충분하지만, 그대로 쓰면 문제가 되는 부분이 있습니다. add_component는 std::vector의 원소 주소를 반환하는데, 나중에 더 큰 ID의 엔티티에 컴포넌트를 추가하면 resize가 재할당을 일으켜 이전에 받아 둔 포인터가 모두 댕글링이 됩니다. 시스템 안에서 포인터를 받아 두고 루프 도중 새 엔티티를 만드는 코드가 가장 흔한 크래시 원인입니다. 또 컴포넌트를 엔티티 ID로 직접 인덱싱하므로 ID가 드문드문하면 메모리 낭비가 크고, entities_with가 매번 모든 엔티티를 훑어 새 벡터를 만드는 것도 엔티티가 많아지면 부담이 됩니다.

실제 ECS 라이브러리는 이 문제를 희소 집합(sparse set)이나 아키타입(archetype) 저장소로 풉니다. 희소 집합은 엔티티 ID → 밀집 배열 인덱스 매핑을 따로 두어 컴포넌트를 빈틈없이 저장하고(EnTT 방식), 아키타입은 같은 컴포넌트 조합을 가진 엔티티끼리 표 하나에 모아 여러 컴포넌트를 함께 순회할 때 캐시 효율을 극대화합니다(flecs, Unity DOTS 방식). 대신 아키타입은 컴포넌트를 추가·제거할 때마다 엔티티를 다른 표로 옮겨야 하므로, 구성이 자주 바뀌는 게임이라면 희소 집합 쪽이 유리할 수 있습니다.


부모-자식 변환을 다루는 씬 그래프

계층 구조와 월드 행렬

씬 그래프는 부모-자식 계층으로 오브젝트를 구성합니다. 부모의 변환(위치·회전·스케일)이 자식에게 상속되므로, “캐릭터의 손에 든 검”처럼 계층적 움직임을 표현할 수 있습니다.

flowchart TB
    subgraph Scene[씬 그래프]
        Root[Root]
        Player[Player]
        Weapon[Weapon]
        Arm[Arm]
    end
    Root --> Player
    Player --> Arm
    Arm --> Weapon

완전한 씬 그래프 예제

// scene_graph.cpp - 계층적 변환
#include <algorithm>
#include <vector>
#include <memory>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>  // translate, rotate, scale
struct Transform {
    float x = 0, y = 0, z = 0;
    float rot_x = 0, rot_y = 0, rot_z = 0;              // 라디안
    float scale_x = 1, scale_y = 1, scale_z = 1;
    glm::mat4 local_matrix() const {
        const glm::mat4 I(1.0f);
        glm::mat4 T = glm::translate(I, glm::vec3(x, y, z));
        glm::mat4 R = glm::rotate(I, rot_z, glm::vec3(0, 0, 1))
                    * glm::rotate(I, rot_y, glm::vec3(0, 1, 0))
                    * glm::rotate(I, rot_x, glm::vec3(1, 0, 0));
        glm::mat4 S = glm::scale(I, glm::vec3(scale_x, scale_y, scale_z));
        return T * R * S;  // 스케일 → 회전 → 이동 순으로 적용
    }
};
class SceneNode {
public:
    SceneNode() : parent_(nullptr), dirty_(true) {}
    void set_parent(SceneNode* parent) {
        if (parent_) {
            auto it = std::find(parent_->children_.begin(),
                               parent_->children_.end(), this);
            if (it != parent_->children_.end())
                parent_->children_.erase(it);
        }
        parent_ = parent;
        if (parent_)
            parent_->children_.push_back(this);
        set_dirty();
    }
    void add_child(SceneNode* child) {
        child->set_parent(this);
    }
    Transform& transform() { set_dirty(); return transform_; }
    const Transform& transform() const { return transform_; }
    // 월드 행렬: 부모 연쇄 곱
    glm::mat4 world_matrix() {
        update_world_if_dirty();
        return world_matrix_;
    }
private:
    Transform transform_;
    SceneNode* parent_;
    std::vector<SceneNode*> children_;
    glm::mat4 world_matrix_;
    bool dirty_;
    void set_dirty() {
        dirty_ = true;
        for (auto* c : children_) c->set_dirty();
    }
    void update_world_if_dirty() {
        if (!dirty_) return;
        if (parent_)
            world_matrix_ = parent_->world_matrix() * transform_.local_matrix();
        else
            world_matrix_ = transform_.local_matrix();
        dirty_ = false;
    }
};

핵심 포인트:

  • dirty_: 부모나 자신이 바뀌면 자식까지 dirty 전파
  • world_matrix(): 부모 월드 × 로컬 변환
  • 부모 변경 시 기존 부모의 children에서 제거 후 새 부모에 추가

원래 local_matrix()는 세 번의 glm::rotate에 모두 이동 행렬 T를 넘겨서, 결과적으로 이동이 세 번 곱해지는 버그가 있었습니다. glm::rotate(m, ...)는 “m에 회전을 오른쪽으로 곱한 행렬”을 돌려주기 때문입니다. 위처럼 단위 행렬에서 각각 만든 뒤 T * R * S 순서로 곱해야 정점에 스케일, 회전, 이동이 차례로 적용됩니다. 곱하는 순서를 바꾸면(예: R * T) 오브젝트가 제자리에서 도는 대신 원점을 중심으로 공전하므로, 씬 그래프 버그를 볼 때 가장 먼저 확인할 곳입니다. Euler 각을 Z·Y·X 순으로 곱하는 방식은 짐벌 락이 생길 수 있어 실제 엔진은 회전을 쿼터니언으로 저장하는 경우가 많습니다.

비상수 transform() 접근자는 호출될 때마다 서브트리 전체를 dirty로 표시하므로, 값을 읽기만 할 때도 비용이 듭니다. 부모가 dirty면 자식도 반드시 dirty라는 불변식이 성립하므로, set_dirty() 첫 줄에 if (dirty_) return;을 두면 이미 표시된 서브트리를 다시 순회하지 않게 됩니다. 또 이 구현은 world_matrix()를 부를 때마다 부모 쪽으로 재귀하므로, 깊은 계층에서 자식 여러 개가 같은 부모를 공유하면 계산은 캐시되지만 호출은 반복됩니다. 규모가 커지면 매 프레임 한 번 루트부터 dirty 노드만 내려가며 갱신하는 배치 방식이 더 예측 가능합니다.


이벤트 큐 기반 입력 처리

이벤트 기반 vs 폴링

방식장점단점
이벤트키 다운/업 구분, 한 번만 처리플랫폼별 API
폴링단순, 플랫폼 독립적매 프레임 체크, 지연 가능

권장: 이벤트로 상태 변경을 기록하고, 게임 로직에서는 현재 상태를 폴링합니다. 점프는 “키 업→다운” 시점에서 한 번만 처리합니다.

완전한 입력 처리 예제

// input_handler.cpp - 입력 버퍼링·상태 관리
#include <unordered_map>
#include <unordered_set>
#include <queue>
#include <cstdint>
enum class KeyState { Released, Pressed };
enum class InputEventType { KeyDown, KeyUp, MouseMove, MouseButton };
struct InputEvent {
    InputEventType type;
    int key_or_button;
    int x, y;  // 마우스용
};
class InputHandler {
public:
    void push_event(InputEvent ev) {
        events_.push(ev);
    }
    void process_events() {
        while (!events_.empty()) {
            auto ev = events_.front();
            events_.pop();
            switch (ev.type) {
            case InputEventType::KeyDown:
                // OS 키 반복(auto-repeat)으로 KeyDown이 연달아 와도 처음 한 번만 just_pressed
                if (!is_key_down(ev.key_or_button))
                    just_pressed_.insert(ev.key_or_button);
                key_states_[ev.key_or_button] = KeyState::Pressed;
                break;
            case InputEventType::KeyUp:
                key_states_[ev.key_or_button] = KeyState::Released;
                break;
            case InputEventType::MouseMove:
                mouse_x_ = ev.x;
                mouse_y_ = ev.y;
                break;
            default:
                break;
            }
        }
    }
    // 매 프레임 끝에 호출: "이번 프레임에 눌림" 초기화
    void end_frame() {
        just_pressed_.clear();
    }
    bool is_key_down(int key) const {
        auto it = key_states_.find(key);
        return it != key_states_.end() && it->second == KeyState::Pressed;
    }
    // "이번 프레임에 방금 눌렀는가" → 점프 등에 사용
    bool is_key_just_pressed(int key) const {
        return just_pressed_.count(key) > 0;
    }
    void get_mouse_position(int& x, int& y) const {
        x = mouse_x_;
        y = mouse_y_;
    }
private:
    std::queue<InputEvent> events_;
    std::unordered_map<int, KeyState> key_states_;
    std::unordered_set<int> just_pressed_;
    int mouse_x_ = 0, mouse_y_ = 0;
};

핵심 포인트:

  • just_pressed_: “이번 프레임에 방금 눌림” → 점프·공격 등 한 번만 반응
  • end_frame(): 매 프레임 끝에 just_pressed_ 초기화
  • 이벤트 큐로 플랫폼 이벤트를 게임 로직과 분리

키를 누르고 있으면 대부분의 OS는 잠시 뒤부터 KeyDown 이벤트를 반복해서 보냅니다(텍스트 입력의 자동 반복). 원래 코드처럼 KeyDown마다 just_pressed_에 넣으면, 점프 키를 꾹 누르고 있는 동안 반복 이벤트가 올 때마다 점프가 다시 발동합니다. 위처럼 이미 눌린 상태면 무시하거나, SDL의 event.key.repeat처럼 플랫폼이 알려 주는 반복 플래그를 확인해야 합니다.

더 미묘한 문제는 고정 타임스텝과의 조합입니다. 렌더 프레임이 업데이트보다 자주 돌면(144fps 렌더, 60Hz 업데이트) 어떤 프레임에는 고정 업데이트가 한 번도 실행되지 않습니다. 그 프레임에 들어온 점프 입력은 just_pressed_에 들어갔다가, 업데이트가 한 번도 보지 못한 채 end_frame()에서 지워집니다. 반대로 프레임이 느려 한 프레임에 업데이트가 여러 번 돌면 같은 just_pressed를 여러 번 보고 점프가 중복될 수 있습니다. 제가 이 구조를 쓸 때는 just_pressed_를 업데이트 틱이 소비한 뒤에만 지우도록(첫 번째 틱에서 읽고 비우기) 바꾸는데, 고주사율 모니터 사용자에게서만 “점프가 가끔 씹힌다”는 신고가 들어오는 버그가 대개 이것입니다.


루프·ECS·씬·입력을 묶은 미니 엔진

전체 아키텍처

flowchart TB
    subgraph Core[게임 엔진 코어]
        GL[Game Loop]
        ECS[ECS]
        SG[Scene Graph]
        IH[Input Handler]
    end
    subgraph Systems[시스템]
        MS[MovementSystem]
        PS[PhysicsSystem]
        RS[RenderSystem]
    end
    GL --> IH
    GL --> ECS
    GL --> SG
    IH --> MS
    ECS --> MS
    ECS --> PS
    SG --> RS
    ECS --> RS

아래는 게임 루프·ECS·씬 그래프·입력을 통합한 최소 실행 가능 예제입니다.

// mini_game_engine.cpp - 통합 예제
#include <iostream>
#include <chrono>
#include <memory>
// 1. 게임 루프
class Engine {
public:
    Engine() : fixed_dt_(1.0 / 60.0), accumulator_(0.0)
    {
        ecs_ = std::make_unique<ECS>();
        input_ = std::make_unique<InputHandler>();
        movement_system_ = std::make_unique<MovementSystem>();
    }
    void run() {
        auto last = std::chrono::high_resolution_clock::now();
        while (running_) {
            auto now = std::chrono::high_resolution_clock::now();
            double frame_time = std::chrono::duration<double>(now - last).count();
            last = now;
            if (frame_time > 0.25) frame_time = 0.25;
            accumulator_ += frame_time;
            // 입력 처리
            input_->process_events();
            // 고정 타임스텝 업데이트
            while (accumulator_ >= fixed_dt_) {
                movement_system_->update(*ecs_, fixed_dt_);
                accumulator_ -= fixed_dt_;
            }
            // 렌더링 (여기서는 로그만)
            render(accumulator_ / fixed_dt_);
            input_->end_frame();
            process_events();
        }
    }
    ECS& ecs() { return *ecs_; }
    InputHandler& input() { return *input_; }
    void stop() { running_ = false; }
private:
    double fixed_dt_;
    double accumulator_;
    bool running_ = true;
    std::unique_ptr<ECS> ecs_;
    std::unique_ptr<InputHandler> input_;
    std::unique_ptr<MovementSystem> movement_system_;
    void render(double alpha) {
        (void)alpha;
        // 실제로는 씬 그래프, 렌더러 호출
    }
    void process_events() {
        // 윈도우 이벤트, 종료 조건 등
    }
};
// 2. 게임 초기화 및 플레이어 생성
int main() {
    Engine engine;
    // 플레이어 엔티티 생성
    auto player = engine.ecs().create_entity();
    engine.ecs().add_component(player, TransformComponent{0, 0, 0, 0});
    engine.ecs().add_component(player, VelocityComponent{0, 0, 0});
    // 입력에 따라 속도 조정 (실제로는 별도 PlayerInputSystem에서)
    // engine.run() 내부에서 input().is_key_down() 체크
    engine.run();
    return 0;
}

델타타임 누락, 연속 점프, 씬 전환 크래시: 자주 하는 실수

델타타임을 곱하지 않음

증상: 프레임률이 높은 고사양 PC에서는 빠르게, 저사양에서는 느리게 움직입니다. 원인: position += velocity처럼 델타타임 없이 매 프레임 고정값을 더하기 때문입니다.

// ❌ 잘못된 코드
void update(float dt) {
    (void)dt;
    x += speed;  // 프레임마다 speed만큼 → fps에 따라 속도 변동
}
// ✅ 올바른 코드
void update(float dt) {
    x += speed * dt;  // 초당 speed만큼 이동
}

점프가 연속으로 발생

원인: is_key_down()만 사용해 매 프레임 true.

// ❌ 잘못된 코드
if (input.is_key_down(KEY_SPACE)) {
    jump();  // 매 프레임 jump() 호출
}
// ✅ 올바른 코드
if (input.is_key_just_pressed(KEY_SPACE)) {
    jump();  // 키를 막 누른 그 프레임에만
}

씬 전환 시 삭제된 객체 참조

원인: 씬 A 엔티티가 씬 B의 포인터를 들고 있는데 씬 B를 먼저 파괴하기 때문입니다.

// ❌ 잘못된 코드
void switch_scene(Scene* new_scene) {
    delete current_scene_;  // 여기서 current_scene_ 참조자들이 dangling
    current_scene_ = new_scene;
}
// ✅ 올바른 코드
void switch_scene(Scene* new_scene) {
    current_scene_->on_exit();  // 모든 참조 해제, 리스너 제거
    delete current_scene_;
    current_scene_ = new_scene;
    current_scene_->on_enter();
}

ECS에서 컴포넌트 순환 참조

원인: 컴포넌트 A가 B를, B가 A를 참조하기 때문입니다. 삭제 순서에 따라 크래시가 납니다.

// ❌ 잘못된 코드
struct AComponent { BComponent* b; };
struct BComponent { AComponent* a; };
// ✅ 올바른 코드: EntityId로 참조
struct AComponent { EntityId target_b; };
struct BComponent { EntityId target_a; };
// 시스템에서 id로 lookup

게임 루프에서 accumulator 폭주

원인: 디버깅 중 멈췄다가 재개하면 frame_time이 수 초가 됩니다. accumulator가 커져서 수백 번 업데이트합니다.

// ❌ 잘못된 코드
accumulator += frame_time;  // frame_time이 5초면 300번 업데이트
// ✅ 올바른 코드
if (frame_time > 0.25) frame_time = 0.25;  // 최대 0.25초로 클램프
accumulator += frame_time;

씬 그래프에서 부모 삭제 시 자식 dangling

원인: 부모를 먼저 삭제하면 자식들이 부모 포인터를 들고 있어 크래시가 납니다.

// ❌ 잘못된 코드
void remove_node(SceneNode* node) {
    delete node;  // 자식들이 아직 parent_로 이 node를 가리킴
}
// ✅ 올바른 코드: 자식부터 제거하거나, 삭제 시 자식들의 parent_를 null로
void remove_node(SceneNode* node) {
    for (auto* child : node->children_)
        child->parent_ = nullptr;
    node->children_.clear();
    delete node;
}

ECS 엔티티 삭제를 시스템 중간에 수행

원인: MovementSystem 순회 중 destroy_entity를 호출하면 반복자가 무효화됩니다.

// ❌ 잘못된 코드
for (EntityId id : ecs.entities_with<Velocity>()) {
    if (should_die(id))
        ecs.destroy_entity(id);  // entities_ 변경 → 크래시
}
// ✅ 올바른 코드: 삭제 대기 큐
std::vector<EntityId> to_remove;
for (EntityId id : ecs.entities_with<Velocity>()) {
    if (should_die(id)) to_remove.push_back(id);
}
for (EntityId id : to_remove) ecs.destroy_entity(id);

입력 지연 (입력→화면 반영까지 시간)

원인: 입력 처리 시점이 렌더링 직후에만 있으면 다음 프레임까지 반영이 밀립니다.

// ✅ 개선: 입력을 프레임 시작 직후에 처리
void frame() {
    input.process_events();      // 1. 가장 먼저
    update(fixed_dt);
    render(alpha);
    input.end_frame();
}

업데이트 순서·순수 데이터 컴포넌트 같은 설계 원칙

업데이트 순서 고정

// 권장 순서
void frame() {
    input.process();
    physics_system.update(dt);   // 1. 물리
    game_logic_system.update(dt); // 2. 게임 로직
    animation_system.update(dt);  // 3. 애니메이션
    render_system.render(alpha);  // 4. 렌더링
}

컴포넌트는 순수 데이터

// ❌ 나쁜 예: 로직 포함
struct TransformComponent {
    float x, y;
    void move(float dx, float dy) { x += dx; y += dy; }  // 시스템 역할
};
// ✅ 좋은 예
struct TransformComponent {
    float x, y;
};
// MovementSystem에서 이동 로직 처리

씬 그래프 dirty 전파

부모 변환이 바뀌면 자식 월드 행렬을 다시 계산해야 합니다. set_dirty()로 부모→자식 전파를 구현합니다.

입력은 이벤트 큐로 버퍼링

플랫폼 콜백에서 바로 게임 로직을 호출하지 말고, 이벤트를 큐에 넣고 게임 루프의 한 지점에서 process_events()로 처리합니다.

ECS vs 전통적 OOP 비교

항목OOP (상속 기반)ECS
조합단일 상속 제약컴포넌트 자유 조합
캐시객체별 메모리 분산SoA로 연속 접근
시스템 추가기존 클래스 수정새 시스템만 추가
디버깅다형성 추적 어려움데이터·로직 분리로 명확

씬 그래프 vs 평면 리스트

계층적 변환이 필요하면(캐릭터-팔-무기) 씬 그래프를 쓰며, 단순 목록이면 std::vector<EntityId>로 충분합니다. 오버엔지니어링을 피하세요.


객체 풀·씬 스택·렌더 인터폴레이션 패턴

객체 풀 (Entity/Component 재사용)

// object_pool.cpp - 엔티티 풀
class EntityPool {
public:
    EntityId acquire() {
        if (!free_list_.empty()) {
            EntityId id = free_list_.back();
            free_list_.pop_back();
            return id;
        }
        return create_new();
    }
    void release(EntityId id) {
        ecs_.destroy_entity(id);  // 컴포넌트 제거
        free_list_.push_back(id);
    }
private:
    ECS ecs_;
    std::vector<EntityId> free_list_;
    EntityId create_new() { return ecs_.create_entity(); }
};

EntityPool처럼 ID를 재사용할 때는 옛 ID를 들고 있는 코드가 문제를 일으킵니다. 적 A(ID 7)가 죽어 ID 7이 새 아이템에 재사용되면, A를 추적하던 유도탄은 이제 아이템을 쫓아갑니다. 크래시는 나지 않아서 원인을 찾기 가장 어려운 종류의 버그입니다. 실무 ECS는 ID를 “인덱스 + 세대(generation)“로 구성해 재사용할 때마다 세대를 올리고, 조회 시 세대가 다르면 이미 파괴된 엔티티로 취급합니다.

멀티스레드 업데이트 (작업 분리)

// 물리·AI는 별도 스레드, 렌더링은 메인
std::thread physics_thread([&]() {
    while (running) {
        physics_system.update(fixed_dt);
        std::this_thread::sleep_for(std::chrono::duration<double>(fixed_dt));
    }
});
// 메인: 입력, 게임 로직, 렌더링

이 스레드 분리 스케치는 개념만 보여 줍니다. 물리 스레드가 위치를 쓰는 동안 메인 스레드가 같은 위치를 읽어 렌더링하면 데이터 레이스이므로, 실제로는 물리 결과를 이중 버퍼(쓰기용·읽기용 상태를 번갈아 교체)로 넘기거나 프레임 단위로 동기화해야 합니다. 또 sleep_for는 요청한 시간보다 길게 잘 수 있어(Windows에서는 타이머 해상도 때문에 수 ms 단위) 물리 주기가 흔들리므로, 이 역시 누적 시간 기반 고정 타임스텝으로 돌리는 편이 정확합니다. 최근 엔진은 시스템별 전용 스레드보다 작업(job) 시스템으로 한 프레임의 일을 잘게 나눠 여러 코어에 분배하는 방식을 더 많이 씁니다.

씬 스택 (일시정지·오버레이)

// 씬 스택: 게임 씬 위에 메뉴 씬
std::vector<std::unique_ptr<Scene>> scene_stack_;
void push_scene(std::unique_ptr<Scene> s) {
    if (!scene_stack_.empty())
        scene_stack_.back()->on_pause();
    scene_stack_.push_back(std::move(s));
    scene_stack_.back()->on_enter();
}
void pop_scene() {
    scene_stack_.back()->on_exit();
    scene_stack_.pop_back();
    if (!scene_stack_.empty())
        scene_stack_.back()->on_resume();
}

고정 타임스텝 + 렌더 인터폴레이션

// 업데이트는 고정, 렌더는 alpha로 보간
void render(double alpha) {
    for (auto& e : entities) {
        // prev_pos, curr_pos를 업데이트 시점에 저장
        float x = lerp(e.prev_x, e.curr_x, alpha);
        float y = lerp(e.prev_y, e.curr_y, alpha);
        draw_sprite(x, y);
    }
}

시스템 실행 순서 의존성 관리

// system_order.cpp - 시스템 간 의존성
// MovementSystem → CollisionSystem → RenderSystem 순서 보장
class SystemScheduler {
public:
    void add_system(std::string name, std::function<void(double)> fn, int order) {
        systems_.push_back({std::move(name), std::move(fn), order});
        // 같은 order끼리는 등록 순서를 유지하도록 stable_sort
        std::stable_sort(systems_.begin(), systems_.end(),
            [](const auto& a, const auto& b) { return std::get<2>(a) < std::get<2>(b); });
    }
    void update(double dt) {
        for (auto& [name, fn, _] : systems_)
            fn(dt);
    }
private:
    std::vector<std::tuple<std::string, std::function<void(double)>, int>> systems_;
};

캐시 친화 순회와 할당 줄이기

ECS 캐시 친화적 순회

컴포넌트를 SoA(Structure of Arrays)로 저장하면 같은 타입 컴포넌트가 연속 메모리에 모여 캐시 히트율이 올라갑니다.

// SoA: Transform x, y, z를 각각 배열로
struct TransformSoA {
    std::vector<float> x, y, z;
    std::vector<float> rot_x, rot_y, rot_z;
};
// MovementSystem에서 x[i], y[i], z[i] 순차 접근 → 캐시 효율

할당 최소화

매 프레임 entities_with<T>()가 std::vector를 반환하면 할당이 발생합니다. 시스템에 재사용 버퍼를 두고 entities_with_into(buffer) 형태로 채우면 할당을 줄일 수 있습니다.

// 재사용 버퍼로 할당 제거
std::vector<EntityId> entity_buffer_;
void MovementSystem::update(ECS& ecs, double dt) {
    ecs.entities_with_into<VelocityComponent>(entity_buffer_);
    for (EntityId id : entity_buffer_) { /* ... */ }
}

씬 그래프 갱신 배치

매 프레임 모든 노드의 world_matrix()를 호출하지 말고, dirty인 노드만 갱신합니다. 부모가 dirty면 자식도 연쇄 갱신하되, 한 번에 모아서 처리하면 좋습니다.


게임 엔진 기초 요약

네 가지 구성 요소 정리

  • 게임 루프: 고정 타임스텝 업데이트 + 가변 렌더링, accumulator로 프레임 독립
  • ECS: 엔티티(ID) + 컴포넌트(데이터) + 시스템(로직), 캐시 친화적
  • 씬 그래프: 부모-자식 계층, dirty 전파로 월드 행렬 갱신
  • 입력: 이벤트 큐 + just_pressed로 한 번만 반응

미니 엔진 구현 점검 항목

  • 고정 타임스텝 게임 루프 (accumulator, frame_time 클램프)
  • ECS: 컴포넌트 순수 데이터, 시스템이 로직
  • 씬 그래프: dirty 전파, 부모 변경 시 children 갱신
  • 입력: is_key_just_pressed로 점프·공격 등
  • 씬 전환: on_exit에서 참조 해제
  • 객체 풀로 엔티티/컴포넌트 재사용 (선택)

참고 자료

  • Game Programming Patterns (Robert Nystrom)
  • Unity/Unreal 엔진 문서
  • 메모리 풀 - ECS와 함께 활용

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 고정 타임스텝 루프에서 프레임이 한 번 밀리면 게임이 계속 느려지는 이유는 무엇인가요?

A. 고정 타임스텝은 누적된 경과 시간(accumulator)만큼 업데이트를 여러 번 돌리는데, 한 프레임이 오래 걸리면 다음 프레임에서 따라잡기 위해 더 많은 업데이트를 실행하게 됩니다. 그 업데이트들 때문에 프레임이 또 느려지면 누적 시간이 계속 커지는 악순환에 빠지고, 게임이 사실상 멈춥니다. 한 프레임에서 반영할 경과 시간에 상한을 두거나 프레임당 최대 업데이트 횟수를 제한해, 따라잡지 못하는 시간은 버리도록 만드는 것이 일반적인 해결책입니다.