실시간 게임 서버 설계 Room, Matchmaking, 서버 권한, 상태 동기화와 동시성 제어

실시간 멀티플레이어 게임 서버는 단순히 클라이언트의 요청을 받아 데이터베이스에 저장하는 서버와 다르다. 여러 플레이어가 동시에 행동하고, 각자의 네트워크 지연이 다르며, 동일한 게임 상태를 서로 다른 시점에 관찰하기 때문이다.

이러한 환경에서 서버는 플레이어를 적절한 게임 세션에 배치하고, 게임의 정합성을 유지하며, 여러 요청이 동시에 발생하더라도 일관된 결과를 보장해야 한다.

이 글에서는 실시간 게임 서버를 설계할 때 필요한 핵심 개념을 살펴본다.

  • Room 구조와 라이프사이클
  • Matchmaking과 대기열
  • 서버 권한 기반 게임 로직
  • 게임 상태 동기화
  • 메시지 순서 및 중복 처리
  • 동시 요청과 상태 변경 제어

Room 구조와 라이프사이클

Room이란?

Room은 여러 플레이어가 하나의 게임 세션에 참여하기 위한 논리적인 공간이다.

예를 들어 4인 협동 공포 게임을 개발한다고 가정하자. 네 명의 플레이어가 같은 맵에 입장하고, 몬스터와 상호작용하며, 미션을 수행한다.

이때 각 플레이어의 상태와 게임 진행 상황을 독립적으로 관리하는 단위가 Room이다.

Room은 일반적으로 다음과 같은 데이터를 관리한다.

  • 참여 플레이어 목록
  • 플레이어별 연결 상태
  • 게임 진행 상태
  • 맵과 게임 모드
  • 게임 오브젝트 상태
  • 게임 시작 및 종료 조건
  • Room 생성 시각과 종료 시각

Room은 단순한 채팅방이 아니다. 하나의 독립적인 게임 세션을 관리하는 실행 단위다.

Room의 기본 구조

type RoomPhase =
  | "WAITING"
  | "STARTING"
  | "IN_PROGRESS"
  | "ENDING"
  | "CLOSED";

interface Player {
  id: string;
  connected: boolean;
  ready: boolean;
}

interface RoomState {
  roomId: string;
  phase: RoomPhase;
  players: Map<string, Player>;
  maxPlayers: number;
  startedAt?: number;
}

class GameRoom {
  readonly id: string;
  readonly maxPlayers: number;

  phase: RoomPhase = "WAITING";
  players = new Map<string, Player>();

  constructor(id: string, maxPlayers: number) {
    this.id = id;
    this.maxPlayers = maxPlayers;
  }

  addPlayer(player: Player): void {
    if (this.phase !== "WAITING") {
      throw new Error("플레이어를 추가할 수 없는 상태입니다.");
    }

    if (this.players.has(player.id)) {
      return;
    }

    if (this.players.size >= this.maxPlayers) {
      throw new Error("Room이 가득 찼습니다.");
    }

    this.players.set(player.id, player);
  }

  start(): void {
    if (this.phase !== "WAITING") {
      throw new Error("게임을 시작할 수 없는 상태입니다.");
    }

    if (this.players.size < 2) {
      throw new Error("최소 인원이 부족합니다.");
    }

    this.phase = "STARTING";

    // 맵 로딩, 게임 오브젝트 생성 등의 초기화 작업

    this.phase = "IN_PROGRESS";
  }

  end(): void {
    if (
      this.phase !== "IN_PROGRESS" &&
      this.phase !== "STARTING"
    ) {
      return;
    }

    this.phase = "ENDING";

    // 보상 계산, 결과 기록 등의 처리

    this.phase = "CLOSED";
  }
}

위 코드는 Room의 기본적인 상태 관리 구조를 보여준다. 실제 서버에서는 초기화 실패, 연결 해제, 재접속, 결과 저장 실패 등을 추가로 처리해야 한다.

또한 Map을 사용하는 것만으로 동시성 문제가 해결되는 것은 아니다. 여러 요청이 같은 실행 환경에서 처리되는지, 여러 서버 인스턴스가 같은 Room을 수정할 수 있는지에 따라 별도의 제어가 필요하다.

Room 라이프사이클

Room은 일반적으로 다음과 같은 과정을 거친다.

생성 → 대기 → 게임 준비 → 진행 → 종료 → 정리

  • 생성: Room ID를 발급하고 기본 설정을 초기화한다.
  • 대기: 플레이어 입장과 준비 상태 변경을 처리한다.
  • 게임 준비: 맵, 플레이어 캐릭터, 게임 오브젝트를 생성한다.
  • 진행: 이동, 전투, 상호작용 등 게임 로직을 실행한다.
  • 종료: 승패 판정, 보상 계산, 결과 저장을 수행한다.
  • 정리: 연결과 타이머를 해제하고 메모리를 반환한다.

특히 종료 처리와 정리 작업은 구분하는 것이 좋다. 게임이 끝났다고 해서 즉시 모든 데이터를 삭제하면 결과 저장이나 재접속 처리가 실패할 수 있기 때문이다.

Room의 책임과 서버의 책임

Room은 게임 세션 내부의 상태를 관리한다. 반면 Matchmaking은 플레이어를 적절한 Room에 배정하고, 데이터베이스는 영구적으로 보존해야 하는 데이터를 저장한다.

이러한 책임을 분리하면 게임 모드가 늘어나더라도 전체 서버 구조를 유지하기 쉬워진다.


Matchmaking과 대기열

Matchmaking이란?

Matchmaking은 플레이어를 적절한 게임 세션에 배정하는 과정이다.

단순한 협동 게임에서는 먼저 대기열에 들어온 플레이어부터 인원을 채워 게임을 시작할 수 있다. 경쟁 게임에서는 실력, 지역, 핑, 대기 시간 등을 고려해야 한다.

Matchmaking 시스템은 일반적으로 다음과 같이 구성된다.

  1. 클라이언트가 매칭을 요청한다.
  2. 서버가 인증과 매칭 조건을 검증한다.
  3. 플레이어를 대기열에 등록한다.
  4. 매칭 조건을 만족하는 플레이어를 선택한다.
  5. Room을 생성하거나 기존 Room을 선택한다.
  6. 플레이어를 Room에 배정한다.
  7. 클라이언트에 접속 정보를 전달한다.

대기열 자료구조

가장 단순한 대기열은 FIFO(First In, First Out) 방식으로 구현할 수 있다.

interface MatchTicket {
  playerId: string;
  gameMode: string;
  region: string;
  enqueuedAt: number;
}

class MatchQueue {
  private tickets: MatchTicket[] = [];

  enqueue(ticket: MatchTicket): void {
    this.tickets.push(ticket);
  }

  dequeue(): MatchTicket | undefined {
    return this.tickets.shift();
  }

  remove(playerId: string): void {
    this.tickets = this.tickets.filter(
      ticket => ticket.playerId !== playerId
    );
  }
}

이 예제는 개념을 설명하기 위한 구현이다. JavaScript 배열의 shift()는 요소가 많아지면 비용이 커질 수 있으며, 실제 서비스에서는 큐 자료구조나 Redis 기반 대기열 등을 고려할 수 있다.

또한 실제 Matchmaking에서는 플레이어 한 명이 여러 번 매칭을 요청하거나, 매칭 도중 취소하거나, 연결이 끊어지는 상황을 처리해야 한다.

따라서 대기열에 등록된 플레이어를 중복으로 추가하지 않도록 해야 한다.

매칭 상태 관리

플레이어의 매칭 상태를 명시적으로 관리하면 중복 요청과 잘못된 상태 변경을 방지하기 쉽다.

type MatchStatus =
  | "IDLE"
  | "QUEUED"
  | "MATCHED"
  | "CANCELLED";

interface PlayerMatchState {
  playerId: string;
  status: MatchStatus;
  ticketId?: string;
  roomId?: string;
}

예를 들어 QUEUED 상태인 플레이어가 다시 매칭을 요청하면 기존 요청을 반환하거나 오류를 응답할 수 있다.

매칭이 완료되면 MATCHED로 전환하고, 취소 요청이 들어오면 해당 요청이 아직 대기열에 있는지 확인한 후 CANCELLED로 변경한다.

중요한 것은 상태 확인과 상태 변경을 하나의 논리적 작업으로 처리하는 것이다. 두 요청이 동시에 들어오면 둘 다 플레이어가 IDLE 상태라고 판단할 수 있기 때문이다.

실전에서 고려할 매칭 조건

조건목적
게임 모드서로 다른 게임 규칙의 플레이어 분리
최대 인원Room의 정원 유지
MMR 또는 ELO실력이 비슷한 플레이어 매칭
지역 및 핑네트워크 지연 최소화
대기 시간오래 기다린 플레이어의 매칭 범위 확대
파티 단위함께 신청한 플레이어의 동시 입장
재접속 여부기존 게임에 복귀해야 하는 플레이어 처리

모든 게임에 복잡한 매칭 알고리즘이 필요한 것은 아니다. 소규모 협동 게임이라면 인원과 게임 모드만으로도 충분할 수 있다.


서버 권한 기반 게임 로직

서버 권한 모델이란?

서버 권한(Server Authoritative)은 게임의 중요한 판정과 상태 변경을 서버가 최종적으로 결정하는 구조다.

클라이언트는 플레이어의 입력을 전달하고, 서버는 입력이 유효한지 검증한 후 게임 상태를 변경한다.

예를 들어 플레이어가 몬스터를 공격했다고 가정하자.

클라이언트가 다음과 같이 요청한다고 해서 서버가 그대로 처리해서는 안 된다.

{
  "type": "ATTACK",
  "targetId": "monster-001",
  "damage": 9999
}

클라이언트가 전달한 damage를 그대로 신뢰하면 공격력을 조작할 수 있다.

대신 서버가 플레이어의 무기, 공격력, 공격 속도, 거리, 대상의 상태를 확인하고 피해량을 계산해야 한다.

interface AttackCommand {
  playerId: string;
  targetId: string;
}

interface Weapon {
  baseDamage: number;
  attackCooldownMs: number;
  range: number;
}

interface Character {
  id: string;
  x: number;
  y: number;
  hp: number;
  weapon: Weapon;
  lastAttackAt: number;
}

function attack(
  attacker: Character,
  target: Character,
  now: number
): void {
  if (attacker.hp <= 0 || target.hp <= 0) {
    throw new Error("공격할 수 없는 상태입니다.");
  }

  const dx = attacker.x - target.x;
  const dy = attacker.y - target.y;
  const distance = Math.sqrt(dx * dx + dy * dy);

  if (distance > attacker.weapon.range) {
    throw new Error("공격 범위를 벗어났습니다.");
  }

  if (
    now - attacker.lastAttackAt <
    attacker.weapon.attackCooldownMs
  ) {
    throw new Error("공격 대기 시간이 지나지 않았습니다.");
  }

  target.hp = Math.max(
    0,
    target.hp - attacker.weapon.baseDamage
  );

  attacker.lastAttackAt = now;
}

이 코드는 서버 권한 로직의 핵심을 보여준다. 실제 구현에서는 입력 검증, 이동 상태의 신뢰성, 무적 시간, 버프와 디버프, 지연 시간 보정, 동시 공격 처리 등을 추가해야 한다.

서버 권한이 중요한 이유

서버가 판정을 담당하면 다음과 같은 문제를 통제할 수 있다.

  • 공격력과 체력 조작
  • 이동 속도 및 순간이동 치트
  • 공격 쿨다운 무시
  • 보상과 재화의 위조
  • 게임 규칙을 벗어난 상태 변경

클라이언트가 보내는 데이터는 기본적으로 신뢰할 수 없는 입력으로 취급해야 한다.

다만 서버 권한 모델이 모든 치트를 자동으로 막아 주는 것은 아니다. 서버가 검증하는 규칙이 부실하거나 클라이언트의 입력을 과도하게 신뢰하면 여전히 악용될 수 있다.

서버 권한과 클라이언트 예측

서버가 모든 입력을 처리한 뒤 결과를 전달하면 네트워크 지연 때문에 조작감이 나빠질 수 있다.

이를 개선하기 위해 클라이언트는 입력 결과를 먼저 예측하고, 서버의 확정 상태가 도착하면 차이를 보정할 수 있다.

  • Client Prediction: 클라이언트가 입력 결과를 미리 계산한다.
  • Server Reconciliation: 서버의 확정 결과와 비교해 클라이언트 상태를 보정한다.
  • Lag Compensation: 지연 시간 때문에 발생하는 판정 불일치를 제한적으로 보정한다.

여기서 핵심은 반응 속도는 클라이언트에서 개선하되, 최종적인 게임 규칙과 결과는 서버가 결정한다는 것이다.


게임 상태 동기화

상태 동기화란?

상태 동기화(State Synchronization)는 여러 클라이언트가 동일한 게임 세계의 상태를 관찰할 수 있도록 데이터를 전달하는 과정이다.

서버가 플레이어의 위치를 변경했다고 가정하자. 다른 플레이어의 화면에서도 해당 캐릭터가 적절한 위치에 표시되어야 한다.

서버는 위치, 회전, 체력, 애니메이션 상태 등 필요한 정보를 클라이언트에 전송한다.

interface PlayerSnapshot {
  playerId: string;
  x: number;
  y: number;
  rotation: number;
  hp: number;
}

interface GameSnapshot {
  roomId: string;
  tick: number;
  serverTime: number;
  players: PlayerSnapshot[];
}

tick은 서버가 게임 상태를 갱신하는 논리적인 순서를 나타낸다. serverTime은 스냅샷이 생성된 시각을 나타낸다.

두 값은 목적이 다르다. Tick은 순서와 시뮬레이션 단계를 구분하는 데 유용하고, 서버 시각은 시간 기반 보정에 활용할 수 있다.

상태 동기화 방식

상태 스냅샷(State Snapshot)

서버가 특정 시점의 게임 상태를 전송한다. 클라이언트는 전달받은 상태를 바탕으로 게임 화면을 갱신한다.

구현이 비교적 단순하지만, 매번 전체 상태를 전송하면 네트워크 사용량이 커질 수 있다.

델타 동기화(Delta Synchronization)

이전 상태와 비교해 변경된 데이터만 전송한다.

예를 들어 플레이어 20명의 상태 중 3명의 위치만 변경됐다면, 변경된 3명의 정보만 전송할 수 있다.

네트워크 사용량을 줄일 수 있지만, 클라이언트와 서버가 기준으로 삼는 상태를 관리해야 하며, 패킷 손실이나 기준 상태 불일치에 대비해야 한다.

이벤트 기반 동기화(Event-Based Synchronization)

게임 상태 자체보다 상태를 변경하는 사건을 전달한다.

예를 들어 PLAYER_DAMAGED, ITEM_PICKED_UP, DOOR_OPENED와 같은 이벤트를 전송한다.

이벤트는 게임의 의미를 표현하기 좋지만, 모든 이벤트가 정확히 전달되고 올바른 순서로 처리되는지 관리해야 한다.

실제 게임에서는 세 방식을 혼합해 사용하기도 한다. 위치는 주기적인 스냅샷으로 동기화하고, 아이템 획득은 이벤트로 전달하며, 변경량이 적은 데이터는 델타 방식으로 전송할 수 있다.

보간과 예측

서버에서 전달하는 스냅샷은 네트워크 지연 때문에 일정한 간격으로 도착하지 않을 수 있다.

클라이언트가 매번 전달받은 위치로 즉시 이동하면 캐릭터가 순간적으로 튀는 현상이 발생한다.

이를 완화하는 대표적인 기법은 다음과 같다.

  • Interpolation(보간): 과거와 현재 스냅샷 사이의 값을 계산해 부드럽게 표시한다.
  • Extrapolation(외삽): 최근 이동 정보를 바탕으로 다음 위치를 예측한다.
  • Prediction(예측): 입력 결과를 미리 계산해 지연을 줄인다.
  • Reconciliation(재조정): 서버의 확정 상태를 기준으로 예측 결과를 보정한다.

보간은 안정적인 화면 표현에 유리하지만 표시되는 상태가 실제 서버 상태보다 조금 늦을 수 있다. 외삽은 반응성이 좋지만 예측이 빗나가면 보정이 필요하다.

따라서 모든 게임 오브젝트에 동일한 동기화 전략을 적용하기보다는 오브젝트의 특성과 게임 장르에 따라 선택하는 것이 좋다.


메시지 순서 및 중복 처리

메시지 순서가 중요한 이유

실시간 게임에서는 메시지가 서버에 도착하는 순서와 클라이언트가 입력을 생성한 순서가 다를 수 있다.

예를 들어 다음과 같은 입력이 발생했다고 가정하자.

  1. 플레이어가 문을 연다.
  2. 플레이어가 문을 통과한다.
  3. 플레이어가 문을 닫는다.

이 메시지들이 의도한 순서와 다르게 처리되면 잘못된 게임 상태가 만들어질 수 있다.

TCP는 하나의 연결에서 전달되는 바이트 스트림의 순서를 보장한다. 하지만 여러 연결이나 서로 다른 메시지 처리 경로를 거치는 작업까지 전체적으로 순서가 보장되는 것은 아니다.

UDP는 메시지의 전달이나 순서를 보장하지 않으므로, 필요한 경우 애플리케이션 계층에서 별도의 처리가 필요하다.

Sequence Number를 활용한 순서 관리

각 입력에 순번을 부여하면 메시지의 순서를 확인할 수 있다.

interface PlayerInput {
  playerId: string;
  sequence: number;
  moveX: number;
  moveY: number;
}

class InputProcessor {
  private lastProcessedSequence = new Map<string, number>();

  process(input: PlayerInput): boolean {
    const last =
      this.lastProcessedSequence.get(input.playerId) ?? 0;

    if (input.sequence <= last) {
      return false;
    }

    // 실제 게임 상태 변경 로직 실행

    this.lastProcessedSequence.set(
      input.playerId,
      input.sequence
    );

    return true;
  }
}

이 예제는 이미 처리한 입력을 무시하는 기본 구조다.

단, 모든 입력을 단순히 순번으로만 비교하면 안 된다. 입력이 10, 12, 11 순서로 도착했을 때 12를 먼저 처리하고 11을 버리는 것이 올바른지는 게임 규칙에 따라 다르다.

  • 이동 입력처럼 최신 상태가 중요하다면 오래된 입력을 버릴 수 있다.
  • 공격이나 아이템 사용처럼 모든 유효한 입력이 중요하다면 순서를 보존하거나 누락된 입력을 처리해야 할 수 있다.
  • 입력 순서에 따라 게임 결과가 달라진다면 연속된 순번을 확인하고, 필요한 경우 대기하거나 재전송을 요청해야 한다.

중복 요청 처리

중복 요청은 클라이언트의 재전송, 네트워크 재연결, 타임아웃 이후의 재시도 등으로 발생할 수 있다.

예를 들어 아이템 구매 요청을 보낸 클라이언트가 응답을 받지 못해 같은 요청을 다시 보낼 수 있다.

첫 번째 요청이 이미 처리됐다면 두 번째 요청은 재화 차감이나 아이템 지급을 반복해서는 안 된다.

이를 방지하는 방법 중 하나가 Idempotency Key다.

interface PurchaseRequest {
  playerId: string;
  requestId: string;
  itemId: string;
}

interface PurchaseResult {
  success: boolean;
  itemId: string;
}

class PurchaseService {
  private results =
    new Map<string, PurchaseResult>();

  purchase(request: PurchaseRequest): PurchaseResult {
    const key = `${request.playerId}:${request.requestId}`;

    const previous = this.results.get(key);

    if (previous) {
      return previous;
    }

    // 실제 구현에서는 여기서 재화 확인,
    // 재화 차감, 아이템 지급을 원자적으로 처리해야 한다.

    const result: PurchaseResult = {
      success: true,
      itemId: request.itemId
    };

    this.results.set(key, result);

    return result;
  }
}

이 코드는 개념을 설명하기 위한 예시다. 메모리의 Map은 서버 재시작 후 데이터가 사라지고, 여러 서버 인스턴스 간에 공유되지 않으며, 동시 요청 시 중복 실행을 완전히 방지하지 못한다.

실제 재화와 아이템을 처리할 때는 요청 식별자에 고유 제약 조건을 적용하고, 재화 차감·아이템 지급·처리 결과 기록을 데이터베이스 트랜잭션으로 묶는 방식 등을 고려해야 한다.

중복 요청을 막는 것과 메시지 순서를 보장하는 것은 서로 다른 문제다. Idempotency Key는 동일한 작업의 반복 실행을 막고, Sequence Number는 입력의 순서를 판단하는 데 사용된다.


동시 요청과 상태 변경 제어

동시성 문제가 발생하는 이유

실시간 게임 서버에는 여러 플레이어의 요청이 동시에 들어온다. 같은 플레이어가 여러 요청을 빠르게 보낼 수도 있고, 서로 다른 플레이어가 같은 자원을 변경할 수도 있다.

예를 들어 체력이 100인 몬스터를 두 플레이어가 동시에 공격한다고 가정하자.

두 공격이 각각 70의 피해를 입힌다면 최종 체력은 0이어야 한다.

하지만 두 요청이 동일한 이전 체력 100을 읽고 각각 결과를 계산한 뒤 덮어쓰면, 최종 체력이 30으로 남는 문제가 발생할 수 있다. 이를 Lost Update라고 한다.

JavaScript가 단일 스레드로 실행되더라도 동시성 문제가 사라지는 것은 아니다. 비동기 작업이 서로 교차할 수 있고, 여러 프로세스나 서버 인스턴스가 같은 데이터를 변경할 수도 있기 때문이다.

단일 Room 실행 흐름

Room의 게임 상태를 하나의 실행 흐름에서 순차적으로 변경하면 동시성 문제를 줄일 수 있다.

interface DamageCommand {
  targetId: string;
  damage: number;
}

class Monster {
  constructor(
    public readonly id: string,
    public hp: number
  ) {}

  applyDamage(damage: number): void {
    if (this.hp <= 0) {
      return;
    }

    this.hp = Math.max(0, this.hp - damage);
  }
}

class RoomSimulation {
  private monsters = new Map<string, Monster>();
  private commands: DamageCommand[] = [];

  enqueueDamage(command: DamageCommand): void {
    this.commands.push(command);
  }

  update(): void {
    const currentCommands = this.commands;
    this.commands = [];

    for (const command of currentCommands) {
      const monster = this.monsters.get(command.targetId);

      if (!monster) {
        continue;
      }

      monster.applyDamage(command.damage);
    }
  }
}

이 구조에서는 요청을 명령 큐에 넣고 게임 업데이트 단계에서 순서대로 처리한다.

게임 로직을 하나의 시뮬레이션 루프에서 실행하면 상태 변경 순서를 명확하게 정의할 수 있다. 다만 이 방식만으로 외부 데이터베이스의 동시 갱신이나 여러 서버 인스턴스 사이의 경쟁 상태까지 해결되는 것은 아니다.

Optimistic Locking과 Pessimistic Locking

데이터베이스의 상태를 변경할 때는 잠금 전략을 활용할 수 있다.

Optimistic Locking(낙관적 잠금)

충돌이 자주 발생하지 않는다고 가정하고, 데이터의 버전을 확인해 변경 충돌을 감지한다.

UPDATE inventory
SET quantity = quantity - 1,
    version = version + 1
WHERE player_id = ?
  AND item_id = ?
  AND version = ?
  AND quantity >= 1;

변경된 행이 1개라면 해당 버전의 데이터를 기준으로 갱신에 성공한 것이다. 0개라면 데이터가 변경됐거나 수량 조건을 만족하지 못한 것이므로 결과를 확인하고 재시도하거나 요청을 거부해야 한다.

Pessimistic Locking(비관적 잠금)

다른 요청이 동시에 데이터를 변경하지 못하도록 잠금을 획득한 뒤 작업을 수행한다.

START TRANSACTION;

SELECT quantity
FROM inventory
WHERE player_id = ?
  AND item_id = ?
FOR UPDATE;

-- 수량 확인 후 차감 및 관련 데이터 변경

COMMIT;

실제 구현에서는 수량 검증, 차감, 결과 기록을 모두 트랜잭션 안에서 처리하고, 오류가 발생하면 롤백해야 한다.

낙관적 잠금은 충돌이 적은 환경에 적합하고, 비관적 잠금은 충돌이 잦거나 작업의 직렬화가 필요한 상황에 유용하다. 다만 잠금은 대기 시간과 교착 상태를 유발할 수 있으므로 범위를 최소화해야 한다.

게임 상태와 영구 데이터의 구분

모든 게임 상태를 매 프레임 데이터베이스에 저장할 필요는 없다.

예를 들어 플레이어의 실시간 위치, 몬스터의 이동, 투사체의 위치는 메모리에서 처리하는 것이 일반적이다. 반면 재화, 결제, 인벤토리, 영구 보상 등은 영속성과 정합성을 고려해 저장해야 한다.

게임 세션의 실시간 상태와 영구 데이터를 분리하면 불필요한 데이터베이스 부하를 줄이고, 중요한 데이터의 일관성을 더 엄격하게 관리할 수 있다.


전체 구조 정리

실시간 게임 서버의 주요 구성 요소는 다음과 같이 연결된다.

Client
  │
  ├── 매칭 요청
  ▼
Matchmaking
  │
  ├── 대기열 관리
  ├── 플레이어 그룹 구성
  ▼
Room Manager
  │
  ├── Room 생성 및 배정
  ▼
Game Room
  │
  ├── 입력 검증
  ├── 게임 시뮬레이션
  ├── 상태 변경
  ├── 스냅샷 및 이벤트 전송
  ▼
Client Synchronization
  │
  ├── 보간 및 예측
  └── 서버 상태에 맞춰 보정

Persistent Data
  ├── 재화 및 인벤토리
  ├── 매치 결과
  └── 영구 보상 및 기록

이 구조에서 각 구성 요소는 명확한 책임을 가져야 한다.

  • Matchmaking은 플레이어를 적절한 게임 세션에 배정한다.
  • Room Manager는 Room의 생성과 수명 주기를 관리한다.
  • Game Room은 세션 내부의 게임 상태를 관리한다.
  • 서버 권한 로직은 입력을 검증하고 게임 결과를 판정한다.
  • 동기화 계층은 클라이언트에 필요한 상태와 이벤트를 전달한다.
  • 영구 데이터 계층은 재화와 인벤토리 등 보존해야 하는 정보를 안전하게 처리한다.

실제 구현에서는 이 구성 요소를 반드시 별도 서버나 프로세스로 분리할 필요는 없다. 초기에는 하나의 애플리케이션 안에서 모듈로 분리하고, 부하와 운영 요구가 증가하면 필요한 부분부터 독립적으로 확장할 수 있다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다