← Back

게임 서버 엔티티 시스템: 객체 관리와 ECS 패턴

게임 오브젝트 관리의 어려움

게임 서버에는 플레이어, 몬스터, NPC, 아이템, 투사체, 트리거 등 다양한 종류의 오브젝트가 존재합니다. 이들은 일부 속성을 공유하면서도 각각 고유한 동작을 가집니다.

이 오브젝트들을 어떻게 설계하느냐에 따라 코드의 유지보수성과 성능이 크게 달라집니다.

상속 기반 설계의 한계

전통적인 접근은 상속 계층을 만드는 것입니다.

class Entity { /* 위치, ID */ };
class Character : public Entity { /* HP, 이동 */ };
class Player : public Character { /* 인벤토리, 스킬 */ };
class Monster : public Character { /* AI, 드롭 테이블 */ };
class NPC : public Character { /* 대화, 퀘스트 */ };
class Projectile : public Entity { /* 속도, 충돌 */ };

처음에는 깔끔하지만, 게임이 복잡해지면서 문제가 드러납니다.

다이아몬드 문제

"날 수 있는 몬스터"를 만들고 싶으면 어디에 넣어야 하는가. FlyingCreatureMonster를 모두 상속하면 다중 상속 문제가 생깁니다.

조합의 폭발

탈것에 탄 플레이어, AI가 조종하는 탈것, 소환수를 조종하는 플레이어... 조합이 늘어날수록 상속 계층은 감당할 수 없이 복잡해집니다.

비효율적인 메모리

상속 기반에서 엔티티는 보통 포인터로 관리합니다. 포인터를 따라가는 간접 참조(indirection)는 캐시 미스를 유발하고, 수천 개의 엔티티를 매 틱마다 순회할 때 성능 병목이 됩니다.

컴포넌트 기반 설계

상속 대신 조합으로 전환합니다. 엔티티는 빈 껍데기이고, 기능은 컴포넌트로 부착합니다.

// 컴포넌트 정의
struct Position
{
    float x;

    float y;

    float z;
};

struct Health
{
    int current;

    int max;
};

struct Movement
{
    float speed;

    float direction;
};

struct AIController
{
    AIState state;

    float aggroRange;
};

struct PlayerInput
{
    uint64_t sessionId;
};

struct Inventory
{
    std::vector<Item> items;

    int maxSlots;
};

엔티티는 필요한 컴포넌트만 조합합니다.

플레이어: Position + Health + Movement + PlayerInput + Inventory
몬스터:  Position + Health + Movement + AIController
NPC:     Position + Health
투사체:  Position + Movement
아이템:  Position

"날 수 있는 몬스터"는 Flying 컴포넌트를 추가하면 끝입니다. 상속 계층을 수정할 필요가 없습니다.

ECS (Entity Component System)

컴포넌트 기반 설계를 체계화한 패턴입니다.

간단한 ECS 구현

using Entity = uint64_t;

class World
{
public:
    Entity createEntity()
    {
        return nextId_++;
    }

    void destroyEntity(Entity entity)
    {
        // 모든 컴포넌트 저장소에서 해당 엔티티 제거
        positions_.erase(entity);
        healths_.erase(entity);
        movements_.erase(entity);
        aiControllers_.erase(entity);
    }

    // 컴포넌트 추가
    void addPosition(Entity entity, const Position& pos)
    {
        positions_[entity] = pos;
    }

    void addHealth(Entity entity, const Health& hp)
    {
        healths_[entity] = hp;
    }

    void addMovement(Entity entity, const Movement& mov)
    {
        movements_[entity] = mov;
    }

    void addAIController(Entity entity, const AIController& ai)
    {
        aiControllers_[entity] = ai;
    }

    // 컴포넌트 조회
    Position* getPosition(Entity entity)
    {
        auto it = positions_.find(entity);
        return it != positions_.end() ? &it->second : nullptr;
    }

    Health* getHealth(Entity entity)
    {
        auto it = healths_.find(entity);
        return it != healths_.end() ? &it->second : nullptr;
    }

private:
    Entity nextId_ = 1;

    std::unordered_map<Entity, Position> positions_;

    std::unordered_map<Entity, Health> healths_;

    std::unordered_map<Entity, Movement> movements_;

    std::unordered_map<Entity, AIController> aiControllers_;
};

시스템 구현

class MovementSystem
{
public:
    void update(World& world, float deltaTime)
    {
        // Position과 Movement를 모두 가진 엔티티만 처리
        for (auto& [entity, movement] : world.movements())
        {
            auto* pos = world.getPosition(entity);

            if (!pos)
                continue;

            pos->x += std::cos(movement.direction) * movement.speed * deltaTime;
            pos->z += std::sin(movement.direction) * movement.speed * deltaTime;
        }
    }
};

class AISystem
{
public:
    void update(World& world, float deltaTime)
    {
        for (auto& [entity, ai] : world.aiControllers())
        {
            auto* pos = world.getPosition(entity);

            if (!pos)
                continue;

            switch (ai.state)
            {
            case AIState::Idle:
                updateIdle(world, entity, ai, *pos);
                break;

            case AIState::Chase:
                updateChase(world, entity, ai, *pos, deltaTime);
                break;

            case AIState::Attack:
                updateAttack(world, entity, ai);
                break;
            }
        }
    }

private:
    void updateIdle(World& world, Entity entity, AIController& ai, const Position& pos)
    {
        // 어그로 범위 내 플레이어 탐색
        auto target = findNearestPlayer(world, pos, ai.aggroRange);

        if (target.has_value())
        {
            ai.state = AIState::Chase;
            ai.targetEntity = target.value();
        }
    }

    void updateChase(World& world, Entity entity, AIController& ai, Position& pos, float dt)
    {
        auto* targetPos = world.getPosition(ai.targetEntity);

        if (!targetPos)
        {
            ai.state = AIState::Idle;
            return;
        }

        // 타겟 방향으로 이동
        float dx = targetPos->x - pos.x;
        float dz = targetPos->z - pos.z;
        float dist = std::sqrt(dx * dx + dz * dz);

        if (dist < ATTACK_RANGE)
        {
            ai.state = AIState::Attack;
            return;
        }

        auto* mov = world.getMovement(entity);

        if (mov)
        {
            pos.x += (dx / dist) * mov->speed * dt;
            pos.z += (dz / dist) * mov->speed * dt;
        }
    }

    void updateAttack(World& world, Entity entity, AIController& ai)
    {
        // 공격 로직
    }

    static constexpr float ATTACK_RANGE = 2.0f;
};

게임 루프에서의 실행

class GameServer
{
public:
    void tick(float deltaTime)
    {
        aiSystem_.update(world_, deltaTime);
        movementSystem_.update(world_, deltaTime);
        combatSystem_.update(world_, deltaTime);
        respawnSystem_.update(world_, deltaTime);
    }

private:
    World world_;

    AISystem aiSystem_;

    MovementSystem movementSystem_;

    CombatSystem combatSystem_;

    RespawnSystem respawnSystem_;
};

시스템의 실행 순서가 명확하고, 각 시스템은 독립적이므로 추가/제거가 쉽습니다.

ECS의 장점

유연한 조합

런타임에 컴포넌트를 동적으로 추가/제거할 수 있습니다.

// 몬스터가 죽으면 Movement와 AI를 제거하고 Loot 컴포넌트 추가
void onMonsterDeath(World& world, Entity monster)
{
    world.removeMovement(monster);
    world.removeAIController(monster);
    world.addLoot(monster, generateLoot(monster));
}

테스트 용이성

시스템은 순수하게 데이터를 변환하는 함수이므로, 테스트가 쉽습니다.

void testMovementSystem()
{
    World world;
    auto entity = world.createEntity();
    world.addPosition(entity, {0, 0, 0});
    world.addMovement(entity, {10.0f, 0.0f}); // 속도 10, 방향 0

    MovementSystem system;
    system.update(world, 1.0f); // 1초 경과

    auto* pos = world.getPosition(entity);
    assert(std::abs(pos->x - 10.0f) < 0.001f); // x방향으로 10 이동
}

캐시 친화적 설계

같은 종류의 컴포넌트를 연속된 메모리에 배치하면, 순회 시 캐시 적중률이 높아집니다. std::unordered_map 대신 밀집 배열(dense array)을 사용하면 성능이 크게 향상됩니다.

// 캐시 친화적 — 같은 타입의 데이터가 연속 메모리에 배치
struct PositionArray
{
    std::vector<Entity> entities;

    std::vector<Position> data;
    // entities[i]에 대응하는 Position이 data[i]에 위치
};

마무리

엔티티 시스템의 설계는 게임 서버의 확장성과 유지보수성에 직접적인 영향을 미칩니다. 상속 기반 설계는 초기에는 직관적이지만, 게임이 복잡해질수록 한계가 드러납니다.

ECS 패턴은 처음에는 학습 곡선이 있지만, 익숙해지면 새로운 기능을 추가하는 것이 컴포넌트와 시스템을 만드는 것만큼 간단해집니다. 특히 대규모 게임 서버에서 성능과 유연성을 동시에 얻을 수 있는 검증된 아키텍처입니다.