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)
}
}관련 글
- 빠르게 로드되는 웹 게임 출시하기
- 게임 입력 처리
- 게임 로직을 위한 Web Workers
- COOP/COEP와 SharedArrayBuffer — 멀티플레이어 환경에서 SharedArrayBuffer에 필요한 헤더
- 2026년 협동 게임 디자인 — "친구와 즐기는" 협동 게임이 왜 폭발적으로 늘고 있는지, 그리고 브라우저 멀티플레이어가 어떻게 어울리는지
- 게임 잼 & 해커톤 — 멀티플레이어 잼 게임이 더 많은 관심을 받습니다
외부 자료
- 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 — 실제 상용 게임에서 쓰이는 권위 기반 네트워킹 패턴