Node.js와 Socket.io를 활용한 실시간 멀티플레이어 웹 게임 백엔드 구축

실시간 멀티플레이어 웹 게임의 백엔드 아키텍처

본 가이드에서는 Node.js 환경을 기반으로 한 실시간 대전 게임의 서버 측 로직을 설계하고 구현하는 과정을 다룹니다. 클라이언트와의 양방향 통신을 위한 WebSocket 설정부터 게임 상태 관리, 엔티티 업데이트 및 충돌 감지 알고리즘까지 핵심적인 백엔드 구성 요소를 살펴봅니다.

1. 서버 엔트리포인트 및 네트워크 설정

게임 서버의 진입점은 HTTP 요청을 처리할 웹 프레임워크와 실시간 통신을 위한 WebSocket 서버를 초기화하는 역할을 합니다. 여기서는 Express와 Socket.io를 결합하여 네트워크 계층을 구성합니다.

const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const path = require('path');

const app = express();
const server = http.createServer(app);
const io = new Server(server);

// 정적 파일 및 환경별 미들웨어 설정
const isDev = process.env.NODE_ENV !== 'production';
if (isDev) {
  const webpack = require('webpack');
  const webpackDevMiddleware = require('webpack-dev-middleware');
  const config = require('../../webpack.dev.js');
  app.use(webpackDevMiddleware(webpack(config)));
} else {
  app.use(express.static(path.join(__dirname, '../../dist')));
}

const PORT = process.env.PORT || 8080;
server.listen(PORT, () => console.log(`Backend running on port ${PORT}`));

// 게임 세션 초기화
const GameSession = require('./GameSession');
const session = new GameSession();

io.on('connection', (socket) => {
  console.log(`New connection: ${socket.id}`);
  
  socket.on('JOIN', (nickname) => session.registerPlayer(socket, nickname));
  socket.on('MOVE', (direction) => session.processInput(socket, direction));
  socket.on('disconnect', () => session.unregisterPlayer(socket));
});

개발 환경에서는 Webpack 미들웨어를 통해 핫 리로딩을 지원하고, 프로덕션 환경에서는 미리 빌드된 정적 파일을 서빙합니다. Socket.io 서버는 Express HTTP 서버에 바인딩되며, 클라이언트의 연결 및 해제 이벤트에 따라 게임 세션의 플레이어 등록 로직을 트리거합니다.

2. 게임 세션 및 상태 관리

GameSession 클래스는 서버의 두뇌 역할을 합니다. 주요 책무는 연결된 클라이언트 관리, 게임 오브젝트 상태 업데이트, 그리고 주기적인 상태 브로드캐스트입니다. 객체 참조의 효율성을 위해 기본 오브젝트 대신 Map 자료구조를 사용하여 소켓과 플레이어 데이터를 관리합니다.

const { MAP_SIZE, TICK_RATE } = require('../shared/constants');
const Combatant = require('./Combatant');
const resolveCollisions = require('./collisions');

class GameSession {
  constructor() {
    this.connections = new Map();
    this.combatants = new Map();
    this.projectiles = [];
    this.lastTick = Date.now();
    this.tickCounter = 0;
    
    // 게임 루프 시작
    setInterval(() => this.tick(), 1000 / TICK_RATE);
  }

  registerPlayer(socket, nickname) {
    this.connections.set(socket.id, socket);
    const spawnX = MAP_SIZE * (0.2 + Math.random() * 0.6);
    const spawnY = MAP_SIZE * (0.2 + Math.random() * 0.6);
    this.combatants.set(socket.id, new Combatant(socket.id, nickname, spawnX, spawnY));
  }

  unregisterPlayer(socket) {
    this.connections.delete(socket.id);
    this.combatants.delete(socket.id);
  }

  processInput(socket, dir) {
    const player = this.combatants.get(socket.id);
    if (player) player.changeDirection(dir);
  }

  tick() {
    const now = Date.now();
    const deltaTime = (now - this.lastTick) / 1000;
    this.lastTick = now;

    // 투사체 업데이트 및 수명 관리
    this.projectiles = this.projectiles.filter(proj => !proj.advance(deltaTime));

    // 플레이어 업데이트 및 신규 투사체 생성
    for (const player of this.combatants.values()) {
      const newProj = player.advance(deltaTime);
      if (newProj) this.projectiles.push(newProj);
    }

    // 충돌 처리 및 점수 정산
    const hits = resolveCollisions([...this.combatants.values()], this.projectiles);
    hits.forEach(proj => {
      const shooter = this.combatants.get(proj.ownerId);
      if (shooter) shooter.addScore();
    });
    this.projectiles = this.projectiles.filter(p => !hits.includes(p));

    // 사망자 처리
    for (const [id, player] of this.combatants.entries()) {
      if (player.isDead()) {
        this.connections.get(id)?.emit('GAME_OVER');
        this.unregisterPlayer(this.connections.get(id));
      }
    }

    // 상태 브로드캐스트 (틱 카운터를 이용해 대역폭 최적화)
    this.tickCounter++;
    if (this.tickCounter % 2 === 0) {
      this.broadcastState();
    }
  }

  broadcastState() {
    const rankings = this.getRankings();
    for (const [id, socket] of this.connections.entries()) {
      const me = this.combatants.get(id);
      socket.emit('STATE_UPDATE', this.buildPayload(me, rankings));
    }
  }

  getRankings() {
    return [...this.combatants.values()]
      .sort((a, b) => b.score - a.score)
      .slice(0, 5)
      .map(p => ({ name: p.nickname, points: Math.round(p.score) }));
  }

  buildPayload(me, rankings) {
    const viewRadius = MAP_SIZE / 2;
    return {
      timestamp: Date.now(),
      self: me.toNetworkObject(),
      others: [...this.combatants.values()]
        .filter(p => p !== me && p.getDistanceTo(me) <= viewRadius)
        .map(p => p.toNetworkObject()),
      projectiles: this.projectiles
        .filter(p => p.getDistanceTo(me) <= viewRadius)
        .map(p => p.toNetworkObject()),
      rankings
    };
  }
}

module.exports = GameSession;

게임 루프(tick)는 초당 60회 실행되도록 설정되어 물리 연산의 정밀도를 높입니다. 반면, 네트워크 패킷 전송은 tickCounter를 활용해 초당 30회로 제한함으로써 서버의 연산 능력은 유지하면서 네트워크 대역폭 소모는 절반으로 줄이는 최적화를 적용했습니다. 또한, buildPayload 메서드에서는 시야 반경 내의 오브젝트만 필터링하여 불필요한 데이터 전송을 방지합니다.

3. 엔티티 설계 및 물리 엔진

플레이어와 투사체는 모두 좌표, 속도, 방향을 가지는 움직이는 원형 오브젝트라는 공통점이 있습니다. 코드 중복을 피하고 유지보수성을 높이기 위해 Entity라는 추상 베이스 클래스를 설계합니다.

class Entity {
  constructor(id, x, y, angle, velocity) {
    this.id = id;
    this.x = x;
    this.y = y;
    this.angle = angle;
    this.velocity = velocity;
  }

  advance(dt) {
    this.x += dt * this.velocity * Math.sin(this.angle);
    this.y -= dt * this.velocity * Math.cos(this.angle);
  }

  getDistanceTo(target) {
    const dx = this.x - target.x;
    const dy = this.y - target.y;
    return Math.hypot(dx, dy);
  }

  changeDirection(angle) {
    this.angle = angle;
  }

  toNetworkObject() {
    return { id: this.id, x: this.x, y: this.y };
  }
}

module.exports = Entity;

Math.hypot을 활용하면 제곱근과 제곱 연산을 더 깔끔하게 처리할 수 있습니다. 이 베이스 클래스를 상속받아 투사체(Projectile)와 플레이어(Combatant)를 구현합니다.

const { v4: uuidv4 } = require('uuid');
const Entity = require('./Entity');
const { PROJECTILE_SPEED, MAP_SIZE } = require('../shared/constants');

class Projectile extends Entity {
  constructor(ownerId, x, y, angle) {
    super(uuidv4(), x, y, angle, PROJECTILE_SPEED);
    this.ownerId = ownerId;
  }

  advance(dt) {
    super.advance(dt);
    // 맵 경계를 벗어나면 true 반환하여 제거 대상이 됨
    return this.x < 0 || this.x > MAP_SIZE || this.y < 0 || this.y > MAP_SIZE;
  }
}

module.exports = Projectile;

투사체는 고유 ID 생성을 위해 uuid를 사용하며, 맵의 경계를 초과할 경우 소멸하도록 advance 메서드를 오버라이딩합니다.

const Entity = require('./Entity');
const Projectile = require('./Projectile');
const { PLAYER_SPEED, MAP_SIZE, MAX_HP, FIRE_RATE } = require('../shared/constants');

class Combatant extends Entity {
  constructor(id, nickname, x, y) {
    super(id, x, y, Math.random() * Math.PI * 2, PLAYER_SPEED);
    this.nickname = nickname;
    this.hp = MAX_HP;
    this.cooldown = 0;
    this.score = 0;
  }

  advance(dt) {
    super.advance(dt);
    this.score += dt * 10; 

    // 맵 내부로 위치 제한 (Clamping)
    this.x = Math.max(0, Math.min(MAP_SIZE, this.x));
    this.y = Math.max(0, Math.min(MAP_SIZE, this.y));

    // 무기 쿨다운 및 발사
    this.cooldown -= dt;
    if (this.cooldown <= 0) {
      this.cooldown = FIRE_RATE;
      return new Projectile(this.id, this.x, this.y, this.angle);
    }
    return null;
  }

  takeDamage() {
    this.hp -= 20;
  }

  addScore() {
    this.score += 50;
  }

  isDead() {
    return this.hp <= 0;
  }

  toNetworkObject() {
    return {
      ...super.toNetworkObject(),
      angle: this.angle,
      hp: this.hp
    };
  }
}

module.exports = Combatant;

플레이어 엔티티는 위치 보정(Clamping)을 통해 맵 밖으로 나가는 것을 방지하며, 쿨다운 로직을 통해 자동으로 투사체를 생성하고 반환합니다.

4. 충돌 감지 알고리즘

마지막으로 투사체가 플레이어에게 명중했는지 확인하는 충돌 감지 로직을 구현합니다. 모든 게임 오브젝트가 원형(Circle)이므로, 두 중심점 사이의 거리가 반지름의 합보다 작거나 같을 때 충돌로 간주하는 간단한 수학적 모델을 사용합니다.

const { PLAYER_RADIUS, PROJECTILE_RADIUS } = require('../shared/constants');

function resolveCollisions(players, projectiles) {
  const impacted = [];
  
  for (const proj of projectiles) {
    // Array.find를 사용하여 첫 번째 충돌 대상만 탐색 (다중 충돌 방지)
    const hitTarget = players.find(p => 
      p.id !== proj.ownerId && 
      p.getDistanceTo(proj) <= (PLAYER_RADIUS + PROJECTILE_RADIUS)
    );

    if (hitTarget) {
      impacted.push(proj);
      hitTarget.takeDamage();
    }
  }
  
  return impacted;
}

module.exports = resolveCollisions;

이 로직에서 중요한 세부 사항은 두 가지입니다. 첫째, p.id !== proj.ownerId 조건을 통해 본인이 발사한 총알에 자신이 맞는 현상을 방지합니다. 둘째, Array.find를 사용함으로써 하나의 투사체가 여러 플레이어와 동시에 겹치는 희귀한 상황에서도 첫 번째로 감지된 플레이어에게만 데미지를 적용하고 탐색을 중단하여 점수 버그를 예방합니다.

태그: Node.js Socket.io Express websocket GameLoop

6월 4일 01:00에 게시됨