게임 루프란
게임 서버는 웹 서버와 달리 요청이 없어도 계속 동작합니다. 몬스터의 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 쿼리가 메인 스레드를 블로킹하고 있을 수 있습니다.
마무리
게임 루프는 게임 서버의 심장입니다. 설계가 단순해 보이지만, 시간 관리를 정확하게 하지 않으면 게임 전체에 미묘한 버그가 발생합니다.
핵심 원칙을 정리하면 다음과 같습니다.
- 고정 틱 레이트 사용 — 물리와 게임 로직의 일관성 보장
- 모든 시간 의존 계산에 델타 타임 적용
- 밀린 틱 따라잡기 횟수 제한
- 타이머 시스템으로 시간 기반 이벤트 관리
- 틱 성능을 지속적으로 모니터링
틱 레이트는 게임 출시 후 변경하기 어려우므로, 프로토타입 단계에서 부하 테스트를 통해 적절한 값을 결정하는 것이 중요합니다.