← 블로그 목록 2025.01.30

Node.js로 30fps 게임 서버 만들기

Escape from Dogkov의 서버는 Node.js + Express + Socket.io 스택으로 구성되어 있습니다. 하나의 서버가 웹 페이지 서빙과 게임 로직 처리를 동시에 담당합니다. 이 글에서는 게임 서버의 핵심 아키텍처를 설명합니다.

서버 구조 개요

서버는 크게 세 가지 역할을 수행합니다:

  1. 정적 파일 서빙 — HTML, CSS, JS, 이미지 등 게임 클라이언트 파일
  2. WebSocket 통신 — Socket.io를 통한 실시간 양방향 통신
  3. 게임 틱 루프 — 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인가?

틱 루프에서 처리하는 내용:

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 단일 프로세스에서 여러 게임 룸을 돌리면 메모리 누수에 주의해야 합니다:

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의 이벤트 루프는 게임 서버에 의외로 잘 맞습니다:

물론 단일 스레드의 한계도 있습니다. CPU 집약적인 작업(AI 경로 탐색, 물리 시뮬레이션)이 무거우면 틱 루프가 밀릴 수 있습니다. 현재 Dogkov의 AI는 가벼운 상태 머신이므로 문제없지만, 향후 AI를 복잡하게 만든다면 Worker Thread 분리를 고려해야 합니다.

마치며

Node.js + Express + Socket.io 조합은 소규모 실시간 게임 서버에 최적입니다. 하나의 파일(server.js)에서 웹 서빙, WebSocket 통신, 게임 로직을 모두 처리할 수 있어 아키텍처가 단순합니다. 대규모 트래픽에는 한계가 있지만, "점심시간에 동료들끼리 즐기는 게임"이라는 목표에는 완벽히 부합합니다.

← 이전 글: 총기 반동 시스템 다음 글: Fly.io 배포 →