WebSocket으로 구현하는 실시간 AI 멀티플레이어 로비 세션과 상태 동기화

WebSocket으로 구현하는 실시간 AI 멀티플레이어 로비 세션과 상태 동기화

WebSocket 기반 게임 로비에서 세션 생성·입장·퇴장, AI 플레이어 관리, 상태 버전 관리와 재접속 동기화를 구현하는 서버 설계와 실전 예제를 정리합니다.

실시간 AI 멀티플레이어 로비의 핵심은 서버를 유일한 상태 권위자(Authoritative Server) 로 두고 모든 클라이언트가 상태 변경을 요청만 하게 만드는 것이다. WebSocket은 로비 이벤트를 즉시 전달하고 stateVersion과 재접속용 스냅샷을 함께 사용하면 중복 이벤트·순서 역전·연결 끊김에도 일관된 상태를 유지할 수 있다.

WebSocket 멀티플레이어 로비는 어떻게 동기화할까?

로비는 전투 장면보다 데이터가 단순해 보이지만 실제로는 동시성 문제가 자주 발생한다. 두 사용자가 마지막 빈 자리를 동시에 차지하거나 방장이 나간 직후 시작 요청이 도착하거나 재접속한 클라이언트가 오래된 화면을 표시할 수 있다.

이 문제를 줄이는 기본 원칙은 다음과 같다.

  1. 클라이언트는 joinLobby, setReady, startMatch 같은 명령(command) 만 전송한다.
  2. 서버는 인증, 권한, 정원, 현재 상태를 검증한 뒤 로비 상태를 변경한다.
  3. 서버는 변경된 결과를 lobby:state 이벤트로 브로드캐스트한다.
  4. 클라이언트는 수신한 stateVersion이 더 최신일 때만 화면 상태를 교체한다.
구분클라이언트서버
역할입력 전달, 화면 렌더링, 재접속 요청상태 검증, 상태 변경, 브로드캐스트
신뢰 수준신뢰하지 않음권위자
준비 상태 변경요청만 전송본인 상태만 변경 허용
게임 시작시작 요청방장 권한과 인원 조건 검증
AI 플레이어표시만 수행추가·제거·행동 결정

WebSocket 서버를 중심으로 플레이어와 AI 플레이어가 로비 상태를 동기화하는 구조

로비 세션 상태는 무엇을 저장해야 할까?

메모리 객체만으로 시작할 수 있지만 재시작과 재접속을 고려하면 상태 구조를 먼저 명확히 정의하는 편이 좋다. 아래 예시는 Node.js와 Socket.IO 서버에서 다루기 좋은 형태다.

export type LobbyStatus = "waiting" | "starting" | "in_game";

export interface LobbyMember {
  playerId: string;
  nickname: string;
  isReady: boolean;
  kind: "human" | "ai";
  connected: boolean;
}

export interface LobbyState {
  lobbyId: string;
  hostPlayerId: string;
  maxPlayers: number;
  status: LobbyStatus;
  stateVersion: number;
  members: LobbyMember[];
  updatedAt: string;
}

특히 stateVersion은 상태 변경마다 서버에서 1씩 증가시키는 정수다. 클라이언트가 이전 버전의 패킷을 늦게 받더라도 최신 상태를 덮어쓰지 않게 해 준다.

function publishLobby(io: Server, lobby: LobbyState) {
  lobby.stateVersion += 1;
  lobby.updatedAt = new Date().toISOString();
  io.to(`lobby:${lobby.lobbyId}`).emit("lobby:state", lobby);
}

서버 메모리의 로비 상태는 Redis에 저장할 수 있다. 서버 인스턴스가 여러 대라면 Socket.IO Redis Adapter 또는 동등한 Pub/Sub 계층으로 인스턴스 간 이벤트 전달도 구성해야 한다. 단일 프로세스의 Map<string, LobbyState>는 개발 환경에는 충분하지만 수평 확장 환경의 공유 상태 저장소가 될 수 없다.

AI 플레이어를 로비 멤버로 다룰 때의 설계 원칙

AI 플레이어는 사람이 아닌 kind: "ai" 멤버일 뿐이며 별도 소켓 연결을 만들 필요는 없다. 서버가 AI의 준비 상태와 게임 행동을 결정하고 일반 멤버와 동일한 상태 배열에 넣어 클라이언트에 전달하면 UI와 프로토콜이 단순해진다.

function addAiMember(lobby: LobbyState) {
  if (lobby.members.length >= lobby.maxPlayers) {
    throw new Error("LOBBY_FULL");
  }

  const aiNumber = lobby.members.filter((m) => m.kind === "ai").length + 1;

  lobby.members.push({
    playerId: `ai:${crypto.randomUUID()}`,
    nickname: `AI ${aiNumber}`,
    isReady: true,
    kind: "ai",
    connected: true,
  });
}

AI가 외부 LLM API를 사용한다면 로비 요청 처리 루프에서 직접 모델 응답을 기다리지 않는 것이 중요하다. 로비에는 즉시 AI 슬롯을 추가하고 실제 게임 턴의 AI 판단은 작업 큐나 별도 워커에서 처리한다. 외부 API 지연이 WebSocket 이벤트 처리와 다른 사용자의 로비 입장을 막아서는 안 된다.

WebSocket 로비 입장과 준비 상태를 어떻게 구현할까?

1. 연결 직후 인증하고 사용자 ID를 소켓에 연결한다

WebSocket 핸드셰이크에서 JWT를 검증하고 검증된 사용자 ID만 socket.data.playerId에 저장한다. 클라이언트가 보낸 playerId는 권한 판단에 사용하지 않는다.

io.use((socket, next) => {
  try {
    const token = socket.handshake.auth.token;
    const payload = verifyAccessToken(token);
    socket.data.playerId = payload.sub;
    next();
  } catch {
    next(new Error("UNAUTHORIZED"));
  }
});

2. 서버에서 입장 조건을 검증하고 Socket.IO room에 참가시킨다

데이터베이스 트랜잭션 또는 Redis 원자 연산으로 정원 검사를 처리해야 한다. 단순히 members.length를 읽은 뒤 배열에 넣으면 동시에 들어온 두 요청이 모두 빈 자리라고 판단할 수 있다.

socket.on("lobby:join", async ({ lobbyId }, reply) => {
  const playerId = socket.data.playerId;
  const result = await lobbyService.joinAtomically(lobbyId, playerId);

  if (!result.ok) {
    return reply({ ok: false, code: result.code });
  }

  await socket.join(`lobby:${lobbyId}`);
  socket.emit("lobby:state", result.lobby);
  socket.to(`lobby:${lobbyId}`).emit("lobby:state", result.lobby);
  reply({ ok: true });
});

3. 상태 변경 이벤트는 멱등성과 권한을 함께 검증한다

준비 버튼을 연속으로 눌러도 결과가 예측 가능해야 한다. 토글 이벤트보다 ready: true | false처럼 목표 상태를 담은 명령이 멱등성 구현에 유리하다.

socket.on("lobby:set-ready", async ({ lobbyId, ready }, reply) => {
  const lobby = await lobbyService.setReady({
    lobbyId,
    playerId: socket.data.playerId,
    ready: Boolean(ready),
  });

  if (!lobby) return reply({ ok: false, code: "NOT_IN_LOBBY" });

  publishLobby(io, lobby);
  reply({ ok: true, stateVersion: lobby.stateVersion });
});

상태 버전과 스냅샷이 필요한 이유는 무엇인가?

WebSocket은 연결 지향적이지만 애플리케이션 관점에서 모든 메시지의 처리 결과를 자동 보장하지 않는다. 모바일 네트워크 전환, 브라우저 백그라운드 제한, 서버 재배포로 연결이 끊길 수 있다.

따라서 클라이언트는 이벤트를 누적 적용하기보다 서버가 보낸 완전한 로비 스냅샷을 적용하는 방식이 안전하다.

let appliedVersion = -1;

socket.on("lobby:state", (nextState) => {
  if (nextState.stateVersion <= appliedVersion) return;

  appliedVersion = nextState.stateVersion;
  renderLobby(nextState);
});

socket.on("connect", () => {
  socket.emit("lobby:resync", { lobbyId: currentLobbyId });
});

lobby:resync 요청에는 인증된 사용자에게 해당 로비의 최신 스냅샷을 다시 보내면 된다. 중요한 것은 클라이언트가 이전 로컬 상태를 신뢰하지 않고 재연결 성공 직후 서버 상태로 교체하는 것이다.

flowchart LR
  A[클라이언트 명령] --> B[WebSocket 서버]
  B --> C{인증·권한·정원 검증}
  C -- 실패 --> D[에러 코드 응답]
  C -- 성공 --> E[원자적 상태 저장]
  E --> F[stateVersion 증가]
  F --> G[lobby:state 스냅샷 브로드캐스트]
  G --> H[클라이언트 최신 버전만 렌더링]

분산 서버 환경에서 세션 상태를 안전하게 저장하는 방법

로비 상태는 빠른 변경과 짧은 수명이 특징이므로 Redis가 적합하다. 영속 데이터는 PostgreSQL 같은 관계형 데이터베이스에 분리해 저장한다.

데이터권장 저장소이유
현재 로비 멤버와 준비 상태Redis낮은 지연 시간, TTL, 원자 연산
게임 결과와 매치 기록PostgreSQL영속성, 조회와 분석
접속 토큰과 세션 식별자Redis 또는 인증 서비스만료 관리와 빠른 검증
AI 작업 요청Redis Streams, RabbitMQ, SQS 등 큐비동기 처리와 재시도

Redis에서는 Lua 스크립트나 WATCH/MULTI/EXEC, 혹은 서버 측 원자 연산을 이용해 입장 처리를 하나의 경쟁 구간으로 묶는다. 여러 API 서버가 동시에 같은 로비를 처리한다면 애플리케이션 메모리 락은 인스턴스 밖에서 효력이 없다는 점도 주의해야 한다.

Redis 기반 로비 상태 저장소와 WebSocket 서버 확장 구조

보안과 운영에서 반드시 확인할 항목

  • JWT의 서명, 만료 시간, 발급자와 대상자를 검증한다.
  • 로비 ID는 UUID처럼 추측하기 어려운 값으로 만들고 입장 권한을 서버에서 검사한다.
  • 이벤트별 페이로드 크기 제한과 사용자별 rate limit을 둔다.
  • startMatch는 방장 여부, 최소 인원, 모든 인간 플레이어의 준비 상태를 서버에서 확인한다.
  • 연결 종료 시 즉시 탈퇴시키기보다 유예 시간과 재접속 토큰을 두면 모바일 환경의 이탈을 줄일 수 있다.
  • 로그에는 토큰, 이메일, 원문 채팅 등 민감 정보를 남기지 않는다.
  • Prometheus나 OpenTelemetry로 활성 소켓 수, 로비 입장 실패율, 재동기화 횟수, 이벤트 지연 시간을 관측한다.

자주 묻는 질문 (FAQ)

WebSocket만 쓰면 상태 동기화가 보장되는가?

아니다. 연결이 끊기거나 메시지가 지연될 수 있으므로 서버 권위 상태, 버전 번호, 재접속 시 스냅샷 재동기화가 필요하다.

AI 플레이어도 Socket.IO 연결을 만들어야 하는가?

대부분의 경우 필요 없다. AI는 서버가 관리하는 로비 멤버로 표현하고 실제 AI 판단은 서버 워커나 작업 큐에서 수행하면 된다.

로비 상태를 매번 전체 전송해도 되는가?

멤버 수가 작은 일반 게임 로비라면 전체 스냅샷이 구현과 복구 측면에서 유리하다. 대규모 방이나 빈번한 상태 변경이 발생할 때만 JSON Patch 같은 델타 전송을 검토한다.

정리

실시간 로비의 안정성은 WebSocket 자체보다 상태를 누가 결정하고 어떻게 복구하는지에 달려 있다. 서버에서 명령을 검증하고 원자적으로 상태를 갱신한 뒤 버전이 포함된 스냅샷을 배포하자. 이 구조를 먼저 갖추면 AI 플레이어, 재접속, 다중 서버 확장, 게임 시작 전환까지 같은 규칙으로 관리할 수 있다.

#WebSocket#멀티플레이어 로비#게임 서버#상태 동기화#Socket.IO#AI 플레이어#세션 관리

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs