C++ 게임 엔진 기초 | 게임 루프·ECS·씬 그래프·입력 처리 완전 가이드

C++ 게임 엔진 기초 | 게임 루프·ECS·씬 그래프·입력 처리 완전 가이드

이 글의 핵심

C++ 게임 엔진 기초에 대한 실전 가이드입니다. 게임 루프·ECS·씬 그래프·입력 처리 완전 가이드 등을 예제와 함께 상세히 설명합니다.

들어가며: “게임이 프레임마다 어떻게 돌아가는지 모르겠어요”

왜 게임 엔진 기초인가

게임 엔진은 게임 루프, 엔티티·컴포넌트, 씬 그래프, 입력 처리가 결합된 실시간 시스템입니다. Unity나 Unreal을 쓰더라도 내부 동작을 이해하지 못하면 성능 병목, 프레임 드랍, 입력 지연을 해결하기 어렵습니다. 이 글은 실제 겪는 문제 시나리오, 완전한 C++ 예제(게임 루프·ECS·씬 그래프·입력), 자주 하는 실수, 프로덕션 패턴까지 포함해 게임 엔진 핵심을 실전 관점에서 다룹니다.

이 글에서 다루는 것:

  • 문제 시나리오: 프레임 드랍, 입력 지연, 메모리 파편화 등 실제 상황
  • 게임 루프: 고정 타임스텝 vs 가변 델타타임
  • Entity Component System (ECS): 데이터 지향 설계
  • 씬 그래프: 계층적 변환, 부모-자식 관계
  • 입력 처리: 이벤트 기반 vs 폴링, 입력 버퍼링
  • 자주 하는 실수: 델타타임 오류, 컴포넌트 순환 참조
  • 프로덕션 패턴: 멀티스레드 업데이트, 객체 풀

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

개념을 잡는 비유

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


목차

  1. 문제 시나리오: 게임 개발 시 겪는 상황
  2. 게임 루프
  3. Entity Component System (ECS)
  4. 씬 그래프
  5. 입력 처리
  6. 완전한 게임 엔진 예제
  7. 자주 하는 실수와 해결법
  8. 모범 사례·베스트 프랙티스
  9. 프로덕션 패턴
  10. 정리 및 체크리스트

1. 문제 시나리오: 게임 개발 시 겪는 상황

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

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

시나리오 1: “60fps인데 캐릭터가 빠르게 움직여요”

원인: 델타타임을 곱하지 않거나, 고정 타임스텝 없이 가변 델타타임만 사용. 고사양 PC에서는 델타타임이 작아서 느리게, 저사양에서는 크게 나와서 빠르게 움직입니다.

시나리오 2: “키를 한 번 눌렀는데 점프가 두 번 돼요”

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

시나리오 3: “씬 전환할 때 크래시해요”

원인: 씬 A의 엔티티가 씬 B로 전환 중에 삭제된 객체를 참조. 씬 그래프나 ECS에서 부모-자식·참조 관계를 안전하게 정리하지 않음.

시나리오 4: “수천 개 오브젝트에서 프레임이 떨어져요”

원인: 매 프레임 new/delete, 상속 기반 다형성으로 캐시 미스. ECS와 객체 풀로 데이터 지향 설계가 필요합니다.

시나리오 5: “물리와 렌더링이 어긋나요”

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


2. 게임 루프

기본 개념

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

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

프레임별 시퀀스

sequenceDiagram
    participant Loop as 게임 루프
    participant Input as 입력
    participant Update as 업데이트
    participant Render as 렌더링

    Loop->>Input: process_events()
    Input->>Input: 키/마우스 상태 갱신
    Loop->>Update: update(fixed_dt)
    Update->>Update: 물리, 로직, 애니메이션
    Loop->>Render: render(alpha)
    Render->>Render: 씬 그래프, 드로우 콜
    Loop->>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 클램프: 디버깅 중 멈춤 등으로 인한 스파이크 방지

3. 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로 “이 컴포넌트들을 가진 엔티티”만 필터링
  • 배열 기반 저장으로 캐시 친화적 순회 가능

4. 씬 그래프

기본 개념

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

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

완전한 씬 그래프 예제

// scene_graph.cpp - 계층적 변환
#include <vector>
#include <memory>
#include <glm/glm.hpp>  // 또는 직접 4x4 행렬 구현

struct Transform {
    float x, y, z;
    float rot_x, rot_y, rot_z;
    float scale_x, scale_y, scale_z;

    glm::mat4 local_matrix() const {
        glm::mat4 T = glm::translate(glm::mat4(1), glm::vec3(x, y, z));
        glm::mat4 R = glm::rotate(T, rot_z, glm::vec3(0, 0, 1))
                    * glm::rotate(T, rot_y, glm::vec3(0, 1, 0))
                    * glm::rotate(T, rot_x, glm::vec3(1, 0, 0));
        return glm::scale(R, glm::vec3(scale_x, scale_y, scale_z));
    }
};

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에서 제거 후 새 부모에 추가

5. 입력 처리

이벤트 기반 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:
                key_states_[ev.key_or_button] = KeyState::Pressed;
                just_pressed_.insert(ev.key_or_button);  // 이번 프레임에 눌림
                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_ 초기화
  • 이벤트 큐로 플랫폼 이벤트를 게임 로직과 분리

6. 완전한 게임 엔진 예제

전체 아키텍처

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

7. 자주 하는 실수와 해결법

자주 발생하는 문제

문제 1: 델타타임을 곱하지 않음

증상: 고사양 PC에서는 느리게, 저사양에서는 빠르게 움직임.

원인: position += velocity처럼 델타타임 없이 매 프레임 고정값 덧셈.

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

// ✅ 올바른 코드
void update(float dt) {
    x += speed * dt;  // 초당 speed만큼 이동
}

문제 2: 점프가 연속으로 발생

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

// ❌ 잘못된 코드
if (input.is_key_down(KEY_SPACE)) {
    jump();  // 매 프레임 jump() 호출
}

// ✅ 올바른 코드
if (input.is_key_just_pressed(KEY_SPACE)) {
    jump();  // 키를 막 누른 그 프레임에만
}

문제 3: 씬 전환 시 삭제된 객체 참조

원인: 씬 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();
}

문제 4: 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

문제 5: 게임 루프에서 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;

문제 6: 씬 그래프에서 부모 삭제 시 자식 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;
}

문제 7: 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);

문제 8: 입력 지연 (입력→화면 반영까지 시간)

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

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

8. 모범 사례·베스트 프랙티스

업데이트 순서 고정

// 권장 순서
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>로 충분합니다. 오버엔지니어링을 피하세요.


9. 프로덕션 패턴

패턴 1: 객체 풀 (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(); }
};

패턴 2: 멀티스레드 업데이트 (작업 분리)

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

패턴 3: 씬 스택 (일시정지·오버레이)

// 씬 스택: 게임 씬 위에 메뉴 씬
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();
}

패턴 4: 고정 타임스텝 + 렌더 인터폴레이션

// 업데이트는 고정, 렌더는 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);
    }
}

패턴 5: 시스템 실행 순서 의존성 관리

// 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});
        std::sort(systems_.begin(), systems_.end(),
             { return a.order < b.order; });
    }
    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면 자식도 연쇄 갱신하되, 한 번에 모아서 처리하면 좋습니다.


10. 정리 및 체크리스트

핵심 요약

  • 게임 루프: 고정 타임스텝 업데이트 + 가변 렌더링, 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와 함께 활용

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 커스텀 메모리 할당자(Memory Pool) 제작기 [#48-3]
  • C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
  • C++ 캐시 최적화 | 메모리 접근 패턴 바꿔서 성능 10배 향상시키기
  • C++ 게임 엔진 기초 | 렌더링·물리·입력·스크립팅 시스템 구현 [#50-3]
  • C++ 디버깅 완벽 가이드 | GDB·Sanitizer·메모리 누수·멀티스레드 디버깅 실전
  • C++ 현대적인 C++ GUI: Dear ImGui로 디버깅 툴·대시보드 만들기 [#36-1]

이 글에서 다루는 키워드 (관련 검색어)

C++, 게임엔진, 게임루프, ECS, Entity Component System, 씬그래프, 입력처리 등으로 검색하시면 이 글이 도움이 됩니다.

실전 체크리스트

실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 케이스가 충분한가?
  • 문서화가 되어 있는가?

이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++로 게임 엔진 핵심을 구현합니다. 게임 루프, Entity Component System, 씬 그래프, 입력 처리의 완전한 예제와 문제 시나리오, 자주 하는 실수, 프로덕션 패턴까지. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


관련 글

  • C++ 시리즈 전체 보기
  • C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
  • C++ ADL |
  • C++ Aggregate Initialization |