← Back

게임 루프와 틱 레이트: 서버 시간 관리의 모든 것

게임 루프란

게임 서버는 웹 서버와 달리 요청이 없어도 계속 동작합니다. 몬스터의 AI, 버프 지속시간, 리스폰 타이머 등 시간에 따라 처리해야 하는 로직이 있기 때문입니다.

게임 루프는 이 로직을 일정 간격으로 반복 실행하는 메인 루프입니다.

while (running)
{
    processInput();   // 클라이언트 패킷 처리
    updateLogic();    // 게임 로직 업데이트
    sendOutput();     // 변경 사항 클라이언트에 전송
}

틱 레이트 (Tick Rate)

틱 레이트는 게임 루프가 1초에 몇 번 실행되는지를 나타냅니다.

20 tick/s = 50ms 간격  → MMORPG, 전략 게임
30 tick/s = 33ms 간격  → 일반적인 액션 게임
64 tick/s = 15.6ms 간격 → FPS (CS2, Valorant)
128 tick/s = 7.8ms 간격 → 경쟁 FPS

틱 레이트가 높을수록 반응성이 좋지만, 서버 부하도 비례해서 증가합니다. 게임 장르에 따라 적절한 값을 선택해야 합니다.

고정 틱 게임 루프

가장 일반적인 방식입니다. 일정한 간격으로 게임 로직을 업데이트합니다.

class GameLoop
{
public:
    void run()
    {
        using Clock = std::chrono::steady_clock;
        using Duration = std::chrono::milliseconds;

        constexpr Duration TICK_INTERVAL{50}; // 20 tick/s
        auto nextTick = Clock::now();

        while (running_)
        {
            auto now = Clock::now();

            // 틱 시간이 되었으면 업데이트
            while (now >= nextTick)
            {
                processPackets();
                update(TICK_INTERVAL);
                nextTick += TICK_INTERVAL;
            }

            // 다음 틱까지 대기
            auto sleepTime = nextTick - Clock::now();

            if (sleepTime > Duration::zero())
                std::this_thread::sleep_for(sleepTime);
        }
    }

private:
    void update(std::chrono::milliseconds dt)
    {
        float deltaSeconds = dt.count() / 1000.0f;

        // 모든 엔티티 업데이트
        for (auto& entity : entities_)
            entity->update(deltaSeconds);

        // 충돌 처리
        collisionSystem_.process();

        // 타이머 처리
        timerManager_.tick(deltaSeconds);
    }

    bool running_ = true;

    std::vector<std::unique_ptr<Entity>> entities_;

    CollisionSystem collisionSystem_;

    TimerManager timerManager_;
};

밀린 틱 처리

서버에 부하가 걸려 한 틱이 50ms 이상 걸리면, 다음 틱까지의 시간이 음수가 됩니다. while 루프로 밀린 틱을 따라잡습니다.

while (now >= nextTick)
{
    update(TICK_INTERVAL); // 밀린 만큼 여러 번 실행
    nextTick += TICK_INTERVAL;
}

주의: 밀린 틱이 과도하게 쌓이면 한 프레임에 수십 번 업데이트가 실행되어 더 느려질 수 있습니다. 최대 따라잡기 횟수를 제한하는 것이 안전합니다.

int catchUpCount = 0;

while (now >= nextTick && catchUpCount < MAX_CATCHUP)
{
    update(TICK_INTERVAL);
    nextTick += TICK_INTERVAL;
    ++catchUpCount;
}

// 너무 많이 밀렸으면 포기하고 현재 시간으로 리셋
if (now >= nextTick)
    nextTick = now + TICK_INTERVAL;

델타 타임

게임 로직에서 시간에 의존하는 계산은 반드시 델타 타임을 곱해야 합니다.

// 나쁜 예 — 틱 레이트에 따라 이동 속도가 달라짐
void Monster::update()
{
    position_.x += speed_; // 20 tick이면 초당 20 이동, 60 tick이면 초당 60 이동
}

// 좋은 예 — 틱 레이트와 무관하게 일정한 속도
void Monster::update(float deltaTime)
{
    position_.x += speed_ * deltaTime; // 항상 초당 speed_ 만큼 이동
}

고정 틱이라면 델타 타임이 항상 같지만, 그래도 습관적으로 델타 타임을 사용하는 것이 좋습니다. 나중에 틱 레이트를 변경하더라도 게임 로직이 동일하게 동작합니다.

타이머 시스템

버프 지속시간, 쿨다운, 리스폰 대기 등 시간 기반 이벤트를 관리합니다.

class TimerManager
{
public:
    using Callback = std::function<void()>;

    uint64_t addTimer(float delaySeconds, Callback callback, bool repeat = false)
    {
        uint64_t id = nextId_++;

        timers_.push_back({
            id,
            delaySeconds,
            delaySeconds,
            std::move(callback),
            repeat
        });

        return id;
    }

    void cancelTimer(uint64_t id)
    {
        timers_.erase(
            std::remove_if(timers_.begin(), timers_.end(),
                [id](const Timer& t) { return t.id == id; }),
            timers_.end()
        );
    }

    void tick(float deltaTime)
    {
        for (auto it = timers_.begin(); it != timers_.end();)
        {
            it->remaining -= deltaTime;

            if (it->remaining <= 0.0f)
            {
                it->callback();

                if (it->repeat)
                {
                    it->remaining = it->interval;
                    ++it;
                }
                else
                {
                    it = timers_.erase(it);
                }
            }
            else
            {
                ++it;
            }
        }
    }

private:
    struct Timer
    {
        uint64_t id;

        float interval;

        float remaining;

        Callback callback;

        bool repeat;
    };

    std::vector<Timer> timers_;

    uint64_t nextId_ = 1;
};

사용 예시

TimerManager timers;

// 5초 후 몬스터 리스폰
timers.addTimer(5.0f, [this]()
{
    spawnMonster(monsterType_, spawnPosition_);
});

// 1초마다 독 데미지
auto poisonTimer = timers.addTimer(1.0f, [player]()
{
    player->takeDamage(10);
}, true); // repeat = true

// 10초 후 독 해제
timers.addTimer(10.0f, [&timers, poisonTimer]()
{
    timers.cancelTimer(poisonTimer);
});

서버 시간 동기화

클라이언트와 서버의 시간이 다르면 문제가 발생합니다. 서버 시간을 기준으로 동기화하는 방법입니다.

서버 타임스탬프 전송

struct ServerTimeSync
{
    uint64_t serverTime;    // 서버의 현재 시간 (ms)

    uint64_t clientSendTime; // 클라이언트가 요청한 시간
};

// 클라이언트에서 RTT와 시간 오프셋 계산
// RTT = 현재시간 - clientSendTime
// 서버시간 ≈ serverTime + RTT/2

서버 권위 (Server Authority)

게임에서 시간 관련 판정은 반드시 서버가 합니다. 클라이언트가 "5초 전에 스킬을 사용했다"고 주장해도, 서버 시간 기준으로 검증합니다.

void handleSkillUse(Session& session, const SkillPacket& pkt)
{
    auto& player = getPlayer(session);
    auto now = getServerTime();

    // 쿨다운 체크는 서버 시간 기준
    if (now - player.lastSkillTime() < skill.cooldown())
    {
        sendError(session, "Skill is on cooldown");
        return;
    }

    player.setLastSkillTime(now);
    executeSkill(player, pkt.skillId);
}

성능 모니터링

게임 루프의 성능을 모니터링하면 병목을 조기에 발견할 수 있습니다.

class TickProfiler
{
public:
    void beginTick()
    {
        tickStart_ = std::chrono::steady_clock::now();
    }

    void endTick()
    {
        auto elapsed = std::chrono::steady_clock::now() - tickStart_;
        auto ms = std::chrono::duration_cast<std::chrono::microseconds>(elapsed).count();

        totalTicks_++;
        totalTime_ += ms;

        if (ms > maxTickTime_)
            maxTickTime_ = ms;

        // 틱이 예산을 초과하면 경고
        if (ms > TICK_BUDGET_US)
            warnCount_++;
    }

    void printStats()
    {
        auto avgMs = static_cast<double>(totalTime_) / totalTicks_ / 1000.0;
        auto maxMs = maxTickTime_ / 1000.0;

        std::cout << "Avg tick: " << avgMs << "ms, "
                  << "Max tick: " << maxMs << "ms, "
                  << "Overruns: " << warnCount_ << "\n";
    }

private:
    static constexpr int64_t TICK_BUDGET_US = 50000; // 50ms

    std::chrono::steady_clock::time_point tickStart_;

    int64_t totalTicks_ = 0;

    int64_t totalTime_ = 0;

    int64_t maxTickTime_ = 0;

    int64_t warnCount_ = 0;
};

평균 틱 시간이 예산의 70%를 넘으면 최적화를 검토해야 합니다. 최대 틱 시간이 간헐적으로 높다면, GC(가비지 컬렉션이 있는 언어의 경우)나 DB 쿼리가 메인 스레드를 블로킹하고 있을 수 있습니다.

마무리

게임 루프는 게임 서버의 심장입니다. 설계가 단순해 보이지만, 시간 관리를 정확하게 하지 않으면 게임 전체에 미묘한 버그가 발생합니다.

핵심 원칙을 정리하면 다음과 같습니다.

  1. 고정 틱 레이트 사용 — 물리와 게임 로직의 일관성 보장
  2. 모든 시간 의존 계산에 델타 타임 적용
  3. 밀린 틱 따라잡기 횟수 제한
  4. 타이머 시스템으로 시간 기반 이벤트 관리
  5. 틱 성능을 지속적으로 모니터링

틱 레이트는 게임 출시 후 변경하기 어려우므로, 프로토타입 단계에서 부하 테스트를 통해 적절한 값을 결정하는 것이 중요합니다.