Colyseus는 Node.js 환경에서 실시간 멀티플레이어 게임 서버를 개발하기 위한 오픈소스 프레임워크다. Room 기반 게임 세션 관리, Schema 기반 상태 동기화, 클라이언트 메시지 처리, 세션 복구 등의 기능을 제공한다.
일반적인 REST API 서버는 클라이언트의 요청을 처리하고 응답을 반환하는 방식으로 동작한다. 반면 실시간 멀티플레이어 게임 서버는 여러 클라이언트가 하나의 게임 세션에 참여하며, 서버에서 변경된 상태를 참여자들에게 지속적으로 전달해야 한다.
Colyseus는 이러한 구조를 Room이라는 단위로 추상화한다.
이번 포스팅에서는 다음 항목을 중심으로 살펴본다.
- Room 생성 및 관리
- Schema 기반 상태 동기화
- 클라이언트 메시지 검증
- 재접속 및 Room 복구
- 다중 서버 운영 구조
Room 생성 및 관리
Room이란?
Room은 독립적인 게임 세션을 관리하는 단위다.
예를 들어 4인 협동 공포 게임을 개발한다고 가정하자. 네 명의 플레이어가 하나의 게임에 참가하면 해당 플레이어들은 같은 Room에 속한다. 다른 네 명이 새로운 게임을 시작하면 별도의 Room이 생성된다.
Room은 다음과 같은 역할을 담당한다.
- 플레이어의 입장과 퇴장 처리
- 게임 세션의 상태 관리
- 클라이언트 메시지 수신 및 처리
- 게임 로직 실행
- 상태 변경 및 동기화
- 세션 종료와 자원 정리
Room은 단순한 네트워크 연결 그룹이 아니다. 하나의 게임 세션을 구성하는 상태와 로직의 경계다.
Room의 생명주기
일반적인 Room의 생명주기는 다음과 같다.
onCreate()— Room 초기화 및 메시지 핸들러 등록onJoin()— 플레이어 입장 처리onLeave()— 플레이어 연결 종료 처리onDispose()— Room 종료 및 자원 정리
onLeave()와 onDispose()는 서로 다른 의미를 갖는다. 플레이어가 연결을 종료했다고 해서 Room 자체가 즉시 종료되는 것은 아니다. 다른 플레이어가 남아 있거나, 재접속을 허용해야 할 수도 있기 때문이다.
Room 구현 예제
아래는 TypeScript 기반의 기본적인 Room 구현 예제다.
import { Room, Client } from "colyseus";
interface Player {
sessionId: string;
x: number;
y: number;
}
export class GameRoom extends Room {
maxClients = 4;
private players = new Map<string, Player>();
onCreate() {
this.onMessage("move", (client, message) => {
this.handleMove(client, message);
});
console.log("GameRoom created:", this.roomId);
}
onJoin(client: Client) {
this.players.set(client.sessionId, {
sessionId: client.sessionId,
x: 0,
y: 0,
});
console.log("Player joined:", client.sessionId);
}
onLeave(client: Client) {
this.players.delete(client.sessionId);
console.log("Player left:", client.sessionId);
}
onDispose() {
this.players.clear();
console.log("GameRoom disposed:", this.roomId);
}
private handleMove(
client: Client,
message: unknown
) {
// 실제 검증은 별도 검증 함수에서 수행한다.
if (
typeof message !== "object" ||
message === null ||
!("x" in message) ||
!("y" in message)
) {
return;
}
const { x, y } = message as {
x: unknown;
y: unknown;
};
if (
typeof x !== "number" ||
typeof y !== "number" ||
!Number.isFinite(x) ||
!Number.isFinite(y)
) {
return;
}
const player = this.players.get(client.sessionId);
if (!player) {
return;
}
// 예제에서는 서버가 좌표를 변경한다.
player.x = x;
player.y = y;
}
}
이 코드는 Room의 기본적인 구조를 설명하기 위한 예제다. 실제 서비스에서는 클라이언트가 전달한 좌표를 그대로 적용하지 않고, 이동 속도와 충돌 여부, 이동 가능 영역 등을 서버에서 검증해야 한다.
또한 위 코드는 일반적인 JavaScript Map을 사용하므로 Colyseus의 Schema 자동 동기화를 수행하지 않는다. 상태 동기화를 구현하려면 다음 절에서 설명하는 Schema와 MapSchema 등의 자료구조를 사용해야 한다.
Room 생성과 매칭
Room은 직접 생성할 수도 있고, 매치메이킹을 통해 참가할 수도 있다.
클라이언트에서 사용할 수 있는 대표적인 메서드는 다음과 같다.
// 새로운 Room 생성
const room = await client.create("game_room");
// 조건에 맞는 기존 Room 참가
const joined = await client.join("game_room");
// 조건에 맞는 Room이 없으면 생성하고 참가
const createdOrJoined =
await client.joinOrCreate("game_room");
// 필터 조건에 맞는 Room 참가 또는 생성
const filtered =
await client.joinOrCreate("game_room", {
region: "asia",
difficulty: "normal",
});
joinOrCreate()는 매치메이킹 과정에서 자주 사용한다. 다만 전달한 옵션이 실제로 Room을 필터링하거나 참가 조건을 강제하는지는 서버의 filterBy() 설정과 Room 구현에 따라 달라진다.
실제 게임에서는 다음과 같은 조건을 고려할 수 있다.
- 현재 플레이어 수
- 게임 모드와 난이도
- 지역 및 네트워크 환경
- 파티 ID
- 랭크와 매칭 점수
- Room의 현재 진행 상태
모든 Room을 무조건 참가 가능한 상태로 유지해서는 안 된다. 게임이 시작된 Room에 신규 플레이어가 참가하지 못하도록 하는 등의 정책이 필요하다.
Schema 기반 상태 동기화
상태 동기화가 필요한 이유
멀티플레이어 게임에서는 여러 클라이언트가 동일한 게임 세계를 관찰해야 한다.
예를 들어 플레이어 A가 이동하면 플레이어 B의 화면에서도 A의 위치가 변경되어야 한다. 일반적인 REST API 방식이라면 클라이언트가 주기적으로 서버에 상태를 요청하거나, 별도의 실시간 통신 계층을 구현해야 한다.
Colyseus는 Schema 기반 상태 동기화를 통해 이러한 과정을 단순화한다.
서버는 동기화할 상태를 정의하고 해당 상태를 변경한다. Colyseus는 Schema의 변경 사항을 추적해 연결된 클라이언트에 필요한 상태 업데이트를 전달한다.
핵심은 클라이언트가 직접 상태를 동기화하는 것이 아니라, 서버가 관리하는 상태를 기준으로 변경 사항을 전달한다는 점이다.
Schema 정의
Colyseus에서는 @colyseus/schema 패키지의 Schema, MapSchema, ArraySchema, type 등을 사용해 동기화할 상태를 정의할 수 있다.
import {
Schema,
MapSchema,
type,
} from "@colyseus/schema";
export class PlayerState extends Schema {
@type("number")
x = 0;
@type("number")
y = 0;
@type("number")
hp = 100;
@type("string")
nickname = "";
}
export class GameState extends Schema {
@type({
map: PlayerState,
})
players = new MapSchema<PlayerState>();
}
PlayerState는 플레이어 한 명의 상태를 표현한다. GameState는 전체 게임 상태를 관리하며, players를 통해 여러 플레이어의 상태를 보관한다.
여기서 @type()은 단순한 TypeScript 타입 선언이 아니다. Schema 직렬화와 네트워크 상태 동기화에 필요한 타입 정보를 제공한다.
Room에 Schema 적용하기
Room의 상태를 setState()로 설정하면 Colyseus가 해당 Schema를 기준으로 상태를 관리한다.
import { Room, Client } from "colyseus";
import { PlayerState, GameState } from "./GameState";
export class GameRoom extends Room<GameState> {
maxClients = 4;
onCreate() {
this.setState(new GameState());
}
onJoin(client: Client, options: {
nickname?: string;
}) {
const player = new PlayerState();
player.nickname =
options.nickname ?? "Unknown";
this.state.players.set(
client.sessionId,
player
);
}
onLeave(client: Client) {
this.state.players.delete(client.sessionId);
}
}
클라이언트가 입장하면 서버는 플레이어 상태를 생성해 players에 추가한다. 퇴장하면 해당 상태를 제거한다.
이후 서버에서 player.x, player.y, player.hp 등을 변경하면 Schema가 추적하는 변경 사항이 클라이언트에 동기화된다.
클라이언트가 수신하는 상태 변경 이벤트와 구체적인 콜백 API는 사용 중인 Colyseus 클라이언트 SDK 버전에 맞춰 구현해야 한다.
전체 상태 전송과 변경 사항 전송
상태 동기화는 크게 두 가지 관점으로 구분할 수 있다.
초기 상태
플레이어가 Room에 입장할 때 현재 게임 상태를 파악할 수 있도록 초기 상태를 전달한다.
상태 변경 사항
이미 입장한 플레이어에게는 이후 변경된 상태를 전달한다. Schema 기반 동기화는 변경 사항을 추적해 전체 상태를 매번 다시 보내는 방식보다 불필요한 전송을 줄일 수 있다.
예를 들어 플레이어 100명의 좌표를 매번 전체 전송하는 대신 변경된 필드 중심으로 동기화할 수 있다.
다만 실제 네트워크 사용량은 상태 구조, 변경 빈도, 클라이언트 수, 메시지 크기 등에 따라 달라진다. Schema를 사용한다고 해서 모든 게임에서 자동으로 최적의 네트워크 성능이 보장되는 것은 아니다.
Schema와 서버 권한
Schema는 상태 동기화 수단이지, 보안 기능 자체가 아니다.
다음과 같은 구조는 위험하다.
// 위험한 설계의 개념적 예시
this.state.players.get(client.sessionId)!.hp =
message.hp;
클라이언트가 전달한 체력을 그대로 반영하면 공격을 받지 않은 플레이어가 임의로 체력을 변경할 수 있다.
올바른 구조는 클라이언트가 행동 의도를 전달하고, 서버가 규칙을 검증한 뒤 상태를 변경하는 것이다.
this.onMessage("attack", (client, message) => {
const attacker =
this.state.players.get(client.sessionId);
if (!attacker) {
return;
}
// 대상, 사거리, 공격 속도,
// 쿨다운, 생존 여부 등을 검증한다.
// 검증에 성공한 경우에만 서버가 체력을 변경한다.
});
실시간 게임에서 Schema는 서버가 결정한 결과를 전달하는 역할을 한다. 게임 규칙의 최종 권한은 서버에 있어야 한다.
메시지 검증
클라이언트 메시지를 신뢰하면 안 되는 이유
클라이언트는 사용자가 제어하는 환경에서 실행된다. 따라서 클라이언트가 전달하는 메시지는 언제든지 변조될 수 있다.
예를 들어 다음과 같은 메시지를 수신할 수 있다.
{
"x": 999999,
"y": -999999
}
JSON 형식이 올바르다는 사실은 해당 이동이 유효하다는 의미가 아니다.
서버는 최소한 다음 항목을 검증해야 한다.
- 메시지 구조와 필수 필드
- 데이터 타입
- 숫자의 유효성 및 허용 범위
- 플레이어의 현재 상태
- 요청 권한
- 게임 규칙 위반 여부
- 요청 빈도와 중복 요청
- 메시지 크기
검증 로직 구현
TypeScript의 타입 선언만으로는 런타임 검증이 완성되지 않는다. 네트워크 메시지는 실행 시점에 외부 입력으로 들어오기 때문에 실제 값에 대한 검증이 필요하다.
다음은 Zod를 사용하는 예제다.
import { z } from "zod";
const MoveMessageSchema = z.object({
x: z.number().finite()
.min(-1000)
.max(1000),
y: z.number().finite()
.min(-1000)
.max(1000),
}).strict();
this.onMessage("move", (client, message) => {
const result =
MoveMessageSchema.safeParse(message);
if (!result.success) {
console.warn(
"Invalid move message:",
client.sessionId
);
return;
}
const { x, y } = result.data;
const player =
this.state.players.get(client.sessionId);
if (!player) {
return;
}
// 좌표 범위 검증은 통과했다.
// 실제 게임에서는 이동 속도와 충돌 등도 검증한다.
player.x = x;
player.y = y;
});
safeParse()는 검증 실패 시 예외를 던지는 대신 성공 여부와 검증 결과를 반환한다.
.strict()는 정의되지 않은 추가 필드를 거부하도록 한다. 프로토콜에서 확장 필드를 허용해야 한다면 이 정책을 조정해야 한다.
단, 위 예제의 좌표 범위 검증은 이동 가능 여부를 완전히 검증하지 않는다. 클라이언트가 한 번의 요청으로 좌표를 크게 변경할 수 있기 때문이다.
서버 권한 이동 방식이라면 입력 방향과 이동 시간 등을 바탕으로 서버가 좌표를 계산해야 한다. 서버가 허용하는 이동량을 검증하는 방식도 가능하지만, 클라이언트 시간값을 무조건 신뢰해서는 안 된다.
요청 빈도 제한
유효한 메시지라도 지나치게 많이 보내면 서버 자원을 소모시킬 수 있다.
예를 들어 공격 요청을 초당 수천 번 전송하면 게임 로직 처리와 로그 기록에 부담을 줄 수 있다.
따라서 다음과 같은 정책을 적용할 수 있다.
- 플레이어별 메시지 처리 빈도 제한
- 공격 쿨다운 검증
- 메시지 크기 제한
- 비정상 요청에 대한 로그 기록
- 반복적인 위반에 대한 연결 종료 또는 제재
특히 클라이언트가 전달하는 공격 쿨다운이나 타임스탬프만으로 요청의 유효성을 판단하면 안 된다. 서버가 자체적으로 시간을 측정하고 게임 규칙을 적용해야 한다.
재접속 및 Room 복구
연결 종료와 게임 종료는 다르다
실시간 게임에서는 네트워크 단절이 반드시 플레이어의 게임 종료를 의미하지 않는다.
모바일 환경에서 앱이 백그라운드로 이동하거나, 와이파이가 끊기거나, 일시적인 네트워크 지연이 발생할 수 있다.
이때 연결이 끊어졌다는 이유만으로 플레이어 데이터를 즉시 제거하면 정상적인 재접속이 불가능해질 수 있다.
따라서 다음 상태를 구분하는 것이 중요하다.
- 연결 중: 클라이언트와 정상적으로 통신하는 상태
- 일시 연결 해제: 재접속을 기다리는 상태
- 게임 이탈: 플레이어가 명시적으로 게임을 종료한 상태
- 세션 만료: 재접속 허용 시간이 지난 상태
재접속 허용
Colyseus는 버전에 따라 연결 끊김 후 재접속을 지원하는 API를 제공한다. 일반적으로 Room의 allowReconnection()을 활용해 재접속을 허용할 수 있다.
import { Room, Client } from "colyseus";
export class GameRoom extends Room {
async onLeave(client: Client, consented: boolean) {
if (consented) {
// 정상적인 게임 이탈이라면
// 재접속 대기 없이 퇴장 처리한다.
this.removePlayer(client.sessionId);
return;
}
try {
// 일시적인 연결 종료라면
// 일정 시간 동안 재접속을 허용한다.
await this.allowReconnection(client, 20);
console.log(
"Player reconnected:",
client.sessionId
);
} catch {
// 허용 시간이 지나면 최종 퇴장 처리한다.
this.removePlayer(client.sessionId);
}
}
private removePlayer(sessionId: string) {
// 플레이어 상태 제거와 게임 로직 정리
}
}
이 예제는 재접속 흐름을 설명하기 위한 코드다. 실제로는 설치된 Colyseus 버전의 onLeave() 반환 타입과 allowReconnection() 사용법을 확인해야 하며, removePlayer()도 게임 상태에 맞게 구현해야 한다.
consented는 클라이언트가 명시적으로 연결을 종료했는지 판단하는 데 활용할 수 있다. 단, 이를 게임 내 모든 이탈 의도의 완벽한 판별 수단으로 간주해서는 안 된다.
재접속 시 고려할 사항
재접속을 허용하려면 기존 플레이어 상태를 유지해야 한다.
예를 들어 플레이어가 연결을 잃었다고 해서 즉시 players에서 제거하면 재접속 후 기존 상태를 복구하기 어렵다.
따라서 재접속 대기 시간 동안에는 플레이어 상태를 유지하고, 재접속에 성공하면 기존 세션에 연결을 복구한다. 시간이 초과되면 최종 퇴장 처리를 수행한다.
게임의 성격에 따라 연결이 끊긴 플레이어를 어떻게 처리할지도 결정해야 한다.
- 협동 게임: 일정 시간 동안 플레이어를 유지하고 AI가 대신 조작
- PvP 게임: 일정 시간 동안 무적 또는 이탈 페널티 적용
- 턴제 게임: 제한 시간 동안 턴을 보류하거나 자동 진행
- 경쟁 게임: 재접속 기회를 제공하되 경기 결과 처리 규칙 적용
재접속은 단순히 소켓 연결을 복구하는 문제가 아니다. 게임 규칙과 플레이어 상태의 일관성을 유지하는 문제다.
Room 복구와 서버 장애 복구의 차이
여기서 중요한 구분이 있다.
재접속은 기존 Room이 살아 있는 상태에서 클라이언트 연결을 복구하는 것이다.
Room 복구는 Room을 실행하던 서버 프로세스가 종료되거나 장애가 발생했을 때 게임 상태를 다시 구성하는 것이다.
allowReconnection()만으로 서버 프로세스가 죽은 뒤의 Room 상태까지 복구할 수는 없다.
서버 장애 이후에도 진행 중인 게임을 복구하려면 별도의 설계가 필요하다.
- 게임 상태의 주기적인 스냅샷 저장
- 중요한 이벤트의 영속 로그 기록
- Room의 식별자와 게임 진행 상태 저장
- 복구 가능한 서버에 Room 재생성
- 클라이언트 재인증 및 재입장
- 중복 보상이나 중복 요청 방지
모든 프레임의 상태를 데이터베이스에 저장하는 것은 일반적으로 비효율적이다. 게임의 요구사항에 따라 복구 지점과 저장 주기를 결정해야 한다.
예를 들어 협동 게임에서는 체크포인트와 주요 진행 상태만 저장할 수 있다. 반면 경쟁 게임의 결과와 재화 지급은 별도의 영속 저장과 중복 처리 방지 로직이 필요하다.
다중 서버 운영 구조
단일 서버의 한계
초기에는 하나의 Node.js 프로세스에서 여러 Room을 운영할 수 있다.
Client A ─┐
Client B ─┼──> Game Server
Client C ─┘ ├── Room 1
├── Room 2
└── Room 3
하지만 사용자가 증가하면 하나의 프로세스가 처리해야 하는 Room과 네트워크 이벤트가 많아진다.
CPU 사용량, 메모리, 네트워크 처리량 등의 한계에 도달하면 서버를 확장해야 한다.
여기서 중요한 점은 Room을 여러 개 생성하는 것과 서버 프로세스를 여러 개 운영하는 것은 서로 다른 개념이라는 사실이다.
여러 프로세스와 여러 서버
Colyseus를 다중 서버로 운영하면 다음과 같은 구조를 구성할 수 있다.
Clients
|
Load Balancer
|
┌────────┴────────┐
| |
Server A Server B
Process A Process B
| |
┌───┴───┐ ┌───┴───┐
| | | |
Room 1 Room 2 Room 3 Room 4
\ /
\ /
Shared Presence
/ Matchmaker
|
Shared Infrastructure
├── Database
├── Redis
└── Monitoring
이 구조에서 각 서버 프로세스는 자신이 관리하는 Room의 상태를 메모리에서 처리한다.
예를 들어 Room 1이 서버 A에서 실행되고 있다면, 해당 Room의 게임 상태는 일반적으로 서버 A가 권한을 갖고 관리한다. 서버 B가 Room 1의 상태를 자동으로 공유받아 동시에 같은 게임 로직을 실행하는 것은 아니다.
Presence와 MatchMaker
다중 서버 환경에서는 Room의 존재 여부와 참가 가능한 Room을 파악하는 기능이 필요하다.
Colyseus의 MatchMaker는 Room을 생성하고 참가 가능한 Room을 탐색하는 과정을 관리한다. Presence는 여러 프로세스 간의 조정과 공유 상태를 위한 추상화 계층이다.
분산 환경에서는 적절한 Presence 및 드라이버 구성을 통해 여러 프로세스가 Room 정보를 공유하고 작업을 조정할 수 있다.
Redis는 이러한 분산 구성에 활용할 수 있는 대표적인 인프라다. 다만 Redis를 도입했다고 해서 모든 게임 상태가 자동으로 공유되거나 서버 장애 시 Room이 자동 복구되는 것은 아니다.
각 구성 요소의 역할을 구분해야 한다.
- MatchMaker: Room 생성과 참가를 조정
- Presence: 프로세스 간 공유 상태 및 조정 기능 제공
- Room: 개별 게임 세션의 상태와 로직 관리
- Load Balancer: 외부 연결을 적절한 진입점으로 전달
- Database: 영속적으로 보존해야 하는 게임 데이터 저장
- Redis: 캐시, 분산 조정, 공유 데이터 등 용도에 맞게 사용
Colyseus 버전과 배포 환경에 따라 구체적인 드라이버 구성과 분산 처리 방식은 달라질 수 있다.
로드 밸런서만으로 해결되지 않는 문제
로드 밸런서는 클라이언트의 최초 연결을 여러 서버로 분산할 수 있다. 그러나 이미 특정 Room에 참가한 클라이언트의 연결은 해당 Room을 관리하는 서버와 연결되어야 한다.
따라서 다음 요소를 고려해야 한다.
- WebSocket 연결 유지
- 연결이 종료된 뒤 재접속할 서버 탐색
- Room을 소유한 프로세스 식별
- 서버별 Room 및 플레이어 수
- 특정 서버의 과부하 방지
- 서버 장애 시 대체 경로와 상태 복구 정책
단순히 HTTP 요청을 여러 서버에 분배하는 것만으로는 실시간 게임 세션을 안정적으로 운영할 수 없다.
수평 확장과 상태의 소유권
서버를 여러 대로 확장할 때 가장 중요한 설계 원칙은 게임 상태의 소유권을 명확하게 유지하는 것이다.
예를 들어 서버 A와 서버 B가 같은 플레이어의 체력을 동시에 수정한다면 상태 충돌이 발생할 수 있다.
이를 방지하려면 일반적으로 하나의 Room에 대해 권한을 갖는 실행 주체를 정하고, 해당 Room의 게임 로직은 그 주체가 처리하도록 설계한다.
다른 서버는 Room 참가 정보나 영속 데이터 등 필요한 정보를 공유할 수 있지만, 게임 상태를 무조건 양쪽에서 동시에 수정해서는 안 된다.
서버 간 통신이 필요한 경우에는 메시지 전달, 이벤트 처리, 상태 저장 및 복구 전략을 명시적으로 설계해야 한다.
Graceful Shutdown
배포나 서버 교체 과정에서 실행 중인 프로세스를 즉시 종료하면 연결된 플레이어가 강제로 끊길 수 있다.
Graceful Shutdown은 서버가 종료되기 전에 새로운 작업을 제한하고, 기존 작업과 연결을 안전하게 정리하는 절차다.
일반적으로 다음 순서로 설계한다.
- 서버를 신규 매칭 대상에서 제외한다.
- 신규 Room 생성이나 참가를 제한한다.
- 진행 중인 게임을 완료하도록 기다리거나 플레이어를 다른 세션으로 유도한다.
- 필요한 상태와 결과를 저장한다.
- Room과 네트워크 연결을 정리한다.
- 제한 시간 안에 종료되지 않은 작업은 명시적인 정책에 따라 처리한다.
- 프로세스를 종료한다.
실제 구현에서는 Colyseus의 서버 종료 API와 사용 중인 버전의 라이프사이클을 확인해야 한다. Graceful Shutdown만으로 Room이 다른 서버로 자동 이전되는 것은 아니다. 진행 중인 게임을 이전하거나 복구해야 한다면 별도의 상태 전달 및 재입장 설계가 필요하다.
실무에서 고려해야 할 설계 원칙
Colyseus 기반 게임 서버를 개발할 때는 다음 원칙을 지키는 것이 중요하다.
Room은 게임 세션의 경계다.
Room마다 독립적인 게임 상태와 규칙을 유지하고, 서로 다른 게임 세션이 불필요하게 상태를 공유하지 않도록 한다.
Schema는 상태 동기화 도구다.
Schema를 통해 상태 변경을 효율적으로 전달하되, 게임 규칙의 검증과 권한 관리는 서버에서 수행한다.
모든 클라이언트 메시지는 외부 입력이다.
타입, 범위, 권한, 빈도, 게임 규칙을 검증하고 비정상적인 요청에 대응한다.
재접속과 서버 복구는 별개의 문제다.
클라이언트의 연결 복구는 기존 Room이 살아 있다는 전제를 갖는다. 프로세스 장애까지 복구하려면 상태 저장과 재구성 전략이 필요하다.
수평 확장은 상태 소유권과 함께 설계한다.
서버를 여러 대로 늘리는 것뿐 아니라 Room 탐색, 연결 유지, 서버 간 조정, 장애 대응까지 고려해야 한다.