Node.js로 30fps 게임 서버 만들기
Escape from Dogkov의 서버는 Node.js + Express + Socket.io 스택으로 구성되어 있습니다. 하나의 서버가 웹 페이지 서빙과 게임 로직 처리를 동시에 담당합니다. 이 글에서는 게임 서버의 핵심 아키텍처를 설명합니다.
서버 구조 개요
서버는 크게 세 가지 역할을 수행합니다:
- 정적 파일 서빙 — HTML, CSS, JS, 이미지 등 게임 클라이언트 파일
- WebSocket 통신 — Socket.io를 통한 실시간 양방향 통신
- 게임 틱 루프 — 30fps로 게임 상태를 업데이트하고 브로드캐스트
const express = require("express");
const app = express();
const http = require("http").createServer(app);
const io = require("socket.io")(http, { cors: { origin: "*" } });
// 정적 파일 서빙
app.use(express.static(path.join(__dirname, "public")));
// 라우트 정의
app.get("/", (req, res) => res.sendFile("public/index.html"));
app.get("/play", (req, res) => res.sendFile("public/play.html"));
// ... 기타 라우트
// WebSocket 연결 처리
io.on("connection", (socket) => {
// 이벤트 핸들러 등록
});
// 게임 틱 루프
setInterval(gameLoop, 1000 / 30);
http.listen(5000);
30fps 틱 루프
게임 서버의 심장은 setInterval로 구현된 30fps 틱 루프입니다. 왜 30fps인가?
- 60fps — 이상적이지만 서버 부하가 2배. 클라이언트가 60fps로 렌더링해도 서버 상태는 30fps 업데이트로 충분
- 30fps — 약 33ms 간격. 사람이 체감하기 어려운 수준의 지연. 서버 리소스와 네트워크 대역폭의 적정선
- 20fps — 50ms 간격. 빠른 전투에서 눈에 띄는 끊김 발생
틱 루프에서 처리하는 내용:
setInterval(() => {
const now = Date.now();
const dt = 1 / 30;
for (let rid in rooms) {
const room = rooms[rid];
if (!room.sessionActive) continue;
// 1. 매치 타이머 감소
room.matchTime -= dt;
if (room.matchTime <= 0) {
endRoomSession(room, "timeout");
continue;
}
// 2. 모든 플레이어 이동 처리
for (let id in room.players) {
processPlayerMovement(room, id, dt);
}
// 3. 스캐브 AI 업데이트
updateScavAI(room, dt, now);
// 4. 상태 브로드캐스트
io.to(room.id).emit("state", {
players: room.players,
enemies: room.enemies,
matchTime: room.matchTime
});
}
}, 1000 / 30);
룸 시스템 설계
여러 게임이 동시에 진행될 수 있도록 룸 시스템을 구현했습니다. 각 룸은 독립적인 게임 인스턴스입니다:
const rooms = {};
// 룸 생명 주기:
// 1. 생성: 호스트가 방 생성
// 2. 대기: 다른 플레이어 입장 대기
// 3. 게임 시작: 호스트가 시작 버튼 (3초 카운트다운)
// 4. 진행 중: 10분 매치
// 5. 종료: 탈출/타임아웃/마지막 생존자
// 6. 재시작: 20초 후 자동 재시작
// 7. 삭제: 모든 플레이어 퇴장 시
Socket.io의 Room 기능(socket.join(), io.to())을 활용하면 네트워크 격리가 자연스럽게 이루어집니다. A방의 상태가 B방에 전송되는 일이 없습니다.
호스트 마이그레이션
호스트(방장)가 나가면 게임이 종료되면 안 됩니다. 자동으로 다음 플레이어에게 호스트 권한을 넘기는 마이그레이션을 구현했습니다:
if (room.hostId === socket.id) {
const remaining = Object.keys(room.players);
if (remaining.length > 0) {
room.hostId = remaining[0];
room.hostNick = room.players[remaining[0]].nickname;
broadcastRoomPlayerList(room);
} else {
deleteRoom(roomId);
}
}
맵 생성: 절차적 생성
Dogkov의 맵은 매 게임마다 절차적으로 생성됩니다. 17개의 기본 방 위치가 정해져 있지만, 각 방의 정확한 좌표와 문 위치는 랜덤입니다:
function generateMap() {
let walls = [
// 맵 외벽 (고정)
{x:0, y:0, w:MAP_W, h:50},
{x:0, y:MAP_H-50, w:MAP_W, h:50},
{x:0, y:0, w:50, h:MAP_H},
{x:MAP_W-50, y:0, w:50, h:MAP_H},
];
// 각 방: 기본 위치 ± 30px 랜덤 오프셋
rooms.forEach(r => {
const rx = r.bx + Math.floor(Math.random()*60 - 30);
const ry = r.by + Math.floor(Math.random()*60 - 30);
// 4면 중 랜덤 1면에 문 생성
const doorSide = Math.floor(Math.random() * 4);
// 문 위치에는 벽을 두 조각으로 나눠서 빈 공간 생성
});
// 차량 16대, 바위 12개 추가
// ...
return walls;
}
같은 맵 구조를 반복하면 최적 루트가 고정되어 재미가 줄어듭니다. 방 위치의 미세 변동과 문 방향 랜덤화만으로도 매 판 다른 전략을 요구하는 맵이 만들어집니다.
메모리 관리
Node.js 단일 프로세스에서 여러 게임 룸을 돌리면 메모리 누수에 주의해야 합니다:
- 룸 정리 — 모든 플레이어가 나가면 즉시
delete rooms[id]로 제거 - 타이머 정리 — 재접속 대기 타이머(
setTimeout)를 룸 삭제 시 모두clearTimeout - 이벤트 리스너 — Socket disconnect 시 해당 소켓의 모든 참조 정리
function deleteRoom(roomId) {
const room = rooms[roomId];
if (!room) return;
// 모든 재접속 대기 타이머 정리
for (let token in room.disconnectedPlayers) {
clearTimeout(room.disconnectedPlayers[token].timeout);
}
delete rooms[roomId];
}
이벤트 기반 아키텍처의 장점
Node.js의 이벤트 루프는 게임 서버에 의외로 잘 맞습니다:
- 비동기 I/O — 정적 파일 서빙이 게임 루프를 블로킹하지 않음
- 단일 스레드 — 공유 상태(rooms 객체)에 대한 동시성 문제가 없음. 락이 필요 없음
- Socket.io 통합 — Express 서버와 같은 포트에서 HTTP와 WebSocket을 동시 처리
물론 단일 스레드의 한계도 있습니다. CPU 집약적인 작업(AI 경로 탐색, 물리 시뮬레이션)이 무거우면 틱 루프가 밀릴 수 있습니다. 현재 Dogkov의 AI는 가벼운 상태 머신이므로 문제없지만, 향후 AI를 복잡하게 만든다면 Worker Thread 분리를 고려해야 합니다.
마치며
Node.js + Express + Socket.io 조합은 소규모 실시간 게임 서버에 최적입니다. 하나의 파일(server.js)에서 웹 서빙, WebSocket 통신, 게임 로직을 모두 처리할 수 있어 아키텍처가 단순합니다. 대규모 트래픽에는 한계가 있지만, "점심시간에 동료들끼리 즐기는 게임"이라는 목표에는 완벽히 부합합니다.