Skip to content

WebSocket 멀티플레이어 기초

WebSocket으로 멀티플레이어 게임 만들기: 서버 아키텍처, 게임 룸, 상태 동기화

WebSocket은 멀티플레이어 게임의 실시간 통신을 가능하게 합니다. 이 튜토리얼은 클라이언트 쪽 기초를 다룹니다.

1) 서버에 연결하기

js
const ws = new WebSocket('wss://game-server.example.com')

ws.onopen = () => {
  console.log('Connected')
  ws.send(JSON.stringify({ type: 'join', name: 'Player1' }))
}

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data)
  handleMessage(msg)
}

ws.onclose = () => {
  console.log('Disconnected')
}

ws.onerror = (err) => {
  console.error('WebSocket error:', err)
}

2) 메시지 프로토콜 설계

간단한 프로토콜을 정의합니다.

js
// Client -> Server
{ type: 'join', name: 'Player1' }
{ type: 'input', keys: { left: true, jump: true } }
{ type: 'chat', message: 'Hello!' }

// Server -> Client
{ type: 'state', players: [...], entities: [...] }
{ type: 'playerJoined', id: 'abc', name: 'Player2' }
{ type: 'playerLeft', id: 'abc' }

3) 연결 매니저 클래스

js
class GameConnection {
  constructor(url) {
    this.url = url
    this.ws = null
    this.handlers = new Map()
    this.reconnectDelay = 1000
  }
  
  connect() {
    this.ws = new WebSocket(this.url)
    
    this.ws.onopen = () => {
      this.reconnectDelay = 1000
      this.emit('connected')
    }
    
    this.ws.onmessage = (e) => {
      const msg = JSON.parse(e.data)
      this.emit(msg.type, msg)
    }
    
    this.ws.onclose = () => {
      this.emit('disconnected')
      this.scheduleReconnect()
    }
    
    this.ws.onerror = () => {
      this.ws.close()
    }
  }
  
  scheduleReconnect() {
    setTimeout(() => {
      this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000)
      this.connect()
    }, this.reconnectDelay)
  }
  
  send(type, data = {}) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ type, ...data }))
    }
  }
  
  on(type, handler) {
    if (!this.handlers.has(type)) {
      this.handlers.set(type, [])
    }
    this.handlers.get(type).push(handler)
  }
  
  emit(type, data) {
    const handlers = this.handlers.get(type) || []
    handlers.forEach(h => h(data))
  }
  
  disconnect() {
    this.ws?.close()
  }
}

4) 플레이어 입력 보내기

위치가 아니라 입력을 보내세요. 서버가 권위를 갖게 합니다.

js
const connection = new GameConnection('wss://server.example.com')

function sendInput(input) {
  connection.send('input', {
    left: input.left,
    right: input.right,
    jump: input.jump,
    shoot: input.shoot,
    seq: inputSequence++,
  })
}

// 고정된 빈도로 입력 전송 (예: 초당 20회)
setInterval(() => {
  sendInput(currentInput)
}, 50)

5) 게임 상태 받기

js
let gameState = { players: [], entities: [] }

connection.on('state', (msg) => {
  gameState = msg
})

connection.on('playerJoined', (msg) => {
  gameState.players.push({ id: msg.id, name: msg.name })
})

connection.on('playerLeft', (msg) => {
  gameState.players = gameState.players.filter(p => p.id !== msg.id)
})

6) 부드러운 움직임을 위한 보간

서버는 스냅샷을 보냅니다. 스냅샷 사이를 보간하세요.

js
class EntityInterpolator {
  constructor() {
    this.buffer = [] // { timestamp, state }
    this.renderDelay = 100 // 서버보다 ms 뒤
  }
  
  addSnapshot(timestamp, state) {
    this.buffer.push({ timestamp, state })
    
    // 최근 스냅샷만 유지
    while (this.buffer.length > 10) {
      this.buffer.shift()
    }
  }
  
  getState(currentTime) {
    const renderTime = currentTime - this.renderDelay
    
    // 둘러싸는 스냅샷 찾기
    let before = null
    let after = null
    
    for (let i = 0; i < this.buffer.length - 1; i++) {
      if (this.buffer[i].timestamp <= renderTime && 
          this.buffer[i + 1].timestamp >= renderTime) {
        before = this.buffer[i]
        after = this.buffer[i + 1]
        break
      }
    }
    
    if (!before || !after) {
      return this.buffer[this.buffer.length - 1]?.state
    }
    
    // 보간
    const t = (renderTime - before.timestamp) / (after.timestamp - before.timestamp)
    return this.lerp(before.state, after.state, t)
  }
  
  lerp(a, b, t) {
    return {
      x: a.x + (b.x - a.x) * t,
      y: a.y + (b.y - a.y) * t,
    }
  }
}

7) 클라이언트 측 예측

반응이 빠른 조작을 위해 로컬에서 예측하고 서버와 맞춰 보정합니다.

js
class PredictedPlayer {
  constructor() {
    this.position = { x: 0, y: 0 }
    this.pendingInputs = []
  }
  
  applyInput(input) {
    // 로컬에서 입력 적용
    if (input.left) this.position.x -= 5
    if (input.right) this.position.x += 5
    
    // 보정을 위해 저장
    this.pendingInputs.push({ seq: input.seq, input })
  }
  
  reconcile(serverState, lastProcessedSeq) {
    // 서버가 확정한 위치
    this.position = { ...serverState.position }
    
    // 확정된 입력 제거
    this.pendingInputs = this.pendingInputs.filter(i => i.seq > lastProcessedSeq)
    
    // 아직 확정되지 않은 입력 다시 적용
    for (const pending of this.pendingInputs) {
      if (pending.input.left) this.position.x -= 5
      if (pending.input.right) this.position.x += 5
    }
  }
}

8) 지연 시간 표시 처리

js
class LatencyMonitor {
  constructor(connection) {
    this.connection = connection
    this.pingStart = 0
    this.latency = 0
    
    connection.on('pong', () => {
      this.latency = Date.now() - this.pingStart
    })
    
    setInterval(() => this.ping(), 1000)
  }
  
  ping() {
    this.pingStart = Date.now()
    this.connection.send('ping')
  }
  
  getLatency() {
    return this.latency
  }
}

9) 효율을 위한 바이너리 메시지

고빈도 업데이트에는 바이너리를 사용하세요.

js
// 바이너리 전송
const buffer = new ArrayBuffer(12)
const view = new DataView(buffer)
view.setFloat32(0, player.x)
view.setFloat32(4, player.y)
view.setUint32(8, inputFlags)
ws.send(buffer)

// 바이너리 수신
ws.binaryType = 'arraybuffer'
ws.onmessage = (e) => {
  if (e.data instanceof ArrayBuffer) {
    const view = new DataView(e.data)
    const x = view.getFloat32(0)
    const y = view.getFloat32(4)
    // ...
  }
}

10) 기본 서버 예제 (Node.js)

js
import { WebSocketServer } from 'ws'

const wss = new WebSocketServer({ port: 8080 })
const players = new Map()

wss.on('connection', (ws) => {
  const id = crypto.randomUUID()
  players.set(id, { id, x: 0, y: 0, ws })
  
  ws.on('message', (data) => {
    const msg = JSON.parse(data)
    
    if (msg.type === 'input') {
      const player = players.get(id)
      if (msg.left) player.x -= 5
      if (msg.right) player.x += 5
    }
  })
  
  ws.on('close', () => {
    players.delete(id)
    broadcast({ type: 'playerLeft', id })
  })
})

// 초당 20회 상태 브로드캐스트
setInterval(() => {
  const state = {
    type: 'state',
    players: Array.from(players.values()).map(p => ({
      id: p.id, x: p.x, y: p.y
    }))
  }
  broadcast(state)
}, 50)

function broadcast(msg) {
  const data = JSON.stringify(msg)
  for (const player of players.values()) {
    player.ws.send(data)
  }
}

관련 글

외부 자료

  • MDN: WebSocket API — 브라우저 WebSocket 레퍼런스
  • MDN: WebRTC API — 더 낮은 지연 시간을 위한 P2P 연결
  • MDN: WebTransport API — 2026년 3월 Safari 26.4가 지원을 출시하면서 크로스 브라우저 Baseline에 도달한 더 새로운 HTTP/3 전송 방식입니다 (Chrome 97+, Edge, Firefox 114+에는 이미 들어가 있었습니다). 단일 TCP 기반 WebSocket과 달리 WebTransport는 QUIC 위에서 동작하며, 신뢰성이 없는 데이터그램과 여러 개의 독립 스트림을 제공합니다. 그래서 패킷 하나가 손실되어도 나머지 전부가 head-of-line 블로킹에 걸리지 않습니다. 흔한 패턴은 고빈도 위치 업데이트는 데이터그램으로 보내고, 채팅이나 스코어보드는 신뢰성 있는 스트림에 두는 것입니다. WebSocket은 여전히 가장 호환성이 높은 선택지이고 위의 내용은 모두 그대로 적용되지만, 지연 시간에 민감한 게임이라면 이제 WebTransport도 고려해 볼 만합니다.
  • Socket.IO documentation — 널리 쓰이는 WebSocket 래퍼 라이브러리
  • Valve Source Multiplayer Networking — 실제 상용 게임에서 쓰이는 권위 기반 네트워킹 패턴