PartyKit으로 실시간 멀티플레이어 만들기: Room·Connection 모델, 상태 동기화, Hibernation, Yjs
이 글의 핵심
PartyKit은 Cloudflare 기반의 실시간 협업·멀티플레이어용 백엔드 플랫폼입니다. Party.Server와 Room·Connection 모델, 브로드캐스트·Storage 영속화, Hibernation 스케일링, PartyKit AI와 y-partykit(Yjs)까지 한 번에 정리합니다.
들어가며
PartyKit은 실시간 멀티플레이어와 협업 애플리케이션을 위한 백엔드 플랫폼입니다. WebSocket 연결, 방(Room) 단위 격리, 사용자와 가까운 엣지에서의 실행을 Cloudflare Durable Objects 위에 구현하고, 이를 Party.Server라는 클래스 API로 노출합니다. PartyKit은 2024년 Cloudflare에 인수되었고, 같은 모델을 Cloudflare Workers 프로젝트에서 직접 쓰는 partyserver·partysocket 라이브러리도 함께 제공되고 있습니다. 이 글은 PartyKit 플랫폼의 API를 기준으로 설명하지만 개념은 양쪽에 공통입니다.
WebSocket을 다뤄 본 경험이 있고, Cloudflare Workers나 Durable Object의 기본 개념(요청 단위 격리, 객체 단위 상태)을 접해 본 백엔드·풀스택 개발자를 대상으로 합니다.
PartyKit이 해결하는 문제
멀티플레이어 게임, 공동 편집, 라이브 대시보드, 실시간 채팅은 모두 동시 접속자 사이의 상태를 맞춰야 합니다. 직접 구현하면 연결 수와 지역별 지연, 재연결을 처리해야 하고, 방이나 매치 단위로 상태를 어느 프로세스에 둘지 설계해야 하며, 배포나 스케일 아웃 때 세션이 끊기지 않게 해야 합니다. 특히 여러 서버 인스턴스가 있을 때 같은 방의 사용자를 같은 인스턴스로 보내는 일(sticky routing)이 까다롭습니다.
PartyKit은 각 방을 URL로 식별되는 하나의 상태ful 객체로 추상화합니다. 방은 Party 이름과 방 ID로 식별되며(/parties/:name/:id), 같은 방 ID로 연결한 모든 클라이언트는 전 세계 어디서 접속하든 같은 Party.Server 인스턴스와 통신합니다. 라우팅은 플랫폼이 처리하므로, 같은 방의 사용자를 한곳에 모으는 문제가 사라집니다.
현재 권장 API는 생성자로 Party.Room을 받는 클래스 기반 Party.Server 모델입니다. 객체 리터럴 형태의 레거시 PartyKitServer 스타일도 문서에 남아 있지만, 새 프로젝트에서는 클래스에 implements Party.Server를 붙이는 방식을 씁니다.
아키텍처: 엣지 Worker와 Room
요청은 먼저 사용자와 가까운 엣지 Worker에서 처리될 수 있습니다. PartyKit은 이 단계에 onBeforeRequest, onBeforeConnect 같은 정적 훅을 제공해, 요청이 Room에 도달하기 전에 인증, 레이트 리밋, 차단을 수행하게 합니다. 실제 상태를 가진 로직은 해당 URL에 매핑된 Room에서 실행됩니다.
이 구조 덕분에 전역 HTTP API와 방 안의 실시간 로직을 나눌 수 있습니다. 예를 들어 JWT 검증은 엣지에서 하고, 통과한 연결만 Room의 onConnect로 넘깁니다. 거부할 연결이 Room까지 가지 않으므로 Room의 처리 부담도 줄어듭니다.
핵심 개념
Party.Server
Party.Server는 생성자에서 Party.Room을 주입받는 TypeScript 클래스입니다. 주요 라이프사이클 메서드는 다음과 같습니다.
| 메서드 | 역할 |
|---|---|
onStart | 서버가 시작되거나 히버네이션에서 깨어난 직후, 첫 onConnect/onRequest 전에 비동기 초기화 |
onConnect | 새 WebSocket 연결 시 |
onMessage | 특정 Connection에서 메시지 수신 시 |
onClose / onError | 연결 종료·오류 시 |
onRequest | 해당 Room URL로 들어온 HTTP 요청 처리 |
onAlarm | 예약한 알람 실행 |
히버네이션 여부는 options.hibernate로 설정하며 기본값은 false입니다.
Party.Room
Room은 이 방의 식별자, 연결 목록, Storage, 환경 변수, 다른 Party로의 참조(room.context.parties)를 제공합니다.
room.id: URL의 방 ID(/parties/:name/:id의:id)room.broadcast(msg, without?): 연결된 모든 클라이언트에 전송. 두 번째 인자로 제외할 연결 ID 배열을 넘깁니다.room.getConnections(tag?): 현재 연결 목록. 태그로 거를 수 있습니다.room.storage: 방 단위 영속 키-값 저장소(아래 Persistence 절 참고)
정적 메서드(Worker 레벨)
클래스의 static 메서드로 프로젝트 전역 동작을 정의합니다. onFetch는 Party URL이 아닌 HTTP 요청에, onSocket은 Party URL이 아닌 WebSocket에 응답하고, onCron은 partykit.json의 crons 스케줄에 따라 실행됩니다. 이들은 특정 Room 밖에서 실행되므로 Room Storage에 접근할 수 없고, 헬스 체크나 공개 메타데이터 API 같은 용도에 맞습니다.
Party와 Connection
문서와 커뮤니티에서 말하는 “Party”는 대개 방 하나에 대응하는 서버 인스턴스와 그 상태 전체를 가리킵니다. 타입 이름은 Party.Room이지만, 개념적으로는 멀티플레이어 세션 하나로 이해하면 됩니다.
Connection
Party.Connection은 클라이언트 하나의 WebSocket을 감싼 객체입니다. connection.id는 연결 고유 ID로, 클라이언트가 PartySocket에서 직접 지정할 수도 있습니다. connection.send(data)는 해당 클라이언트에게만 보냅니다. connection.setState()와 connection.state는 닉네임 같은 소량의 메타데이터를 연결에 붙이는 용도로, 연결이 끊기면 사라지지만 히버네이션 동안에는 직렬화되어 유지됩니다(크기 제한이 작으므로 큰 데이터는 넣지 않습니다).
연결 태그
getConnectionTags(connection, ctx)를 구현하면 연결마다 태그 배열을 붙일 수 있습니다. 예를 들어 요청의 국가 코드로 GB 태그를 붙여 두고 room.getConnections("GB")로 그 연결들만 골라 메시지를 보낼 수 있습니다. 전체 브로드캐스트 대신 역할, 팀, 지역별로 대상을 나눌 때 유용합니다.
브로드캐스트 패턴
가장 흔한 동기화는 한 클라이언트의 입력을 서버가 검증한 뒤 나머지에 전파하는 것입니다.
import type * as Party from "partykit/server";
export default class Server implements Party.Server {
constructor(readonly room: Party.Room) {}
async onMessage(message: string | ArrayBuffer, sender: Party.Connection) {
if (typeof message !== "string") return;
// JSON 프로토콜 { type, payload }
let parsed: { type: string; payload: unknown };
try {
parsed = JSON.parse(message);
} catch {
return;
}
if (parsed.type === "move") {
// 실제로는 payload의 형태와 값 범위를 여기서 검증
this.room.broadcast(JSON.stringify({ type: "move", payload: parsed.payload }), [
sender.id, // 발신자 제외
]);
}
}
}
Room은 Durable Object 위에서 한 번에 한 이벤트씩 처리되는 단일 스레드 모델로 동작하므로, 서버가 방 상태의 기준(source of truth)이 되기 쉽습니다. 클라이언트가 다른 플레이어의 상태를 임의로 덮어쓰지 못하게 하려면 메시지 타입별 검증과, 필요하다면 서버 측 시뮬레이션을 넣습니다.
상태 동기화 전략
PartyKit은 특정 동기화 알고리즘을 강제하지 않고, 메시지 전달과 방마다 단일 작성자가 보장되는 실행 환경만 제공합니다. 선택지는 크게 세 가지입니다.
커스텀 프로토콜과 브로드캐스트
위 예처럼 JSON 메시지로 이벤트를 주고받습니다. 구현은 단순하지만 충돌 해결, 오프라인 편집, 되감기는 직접 설계해야 합니다. 게임의 입력 전달이나 단순한 화이트보드 스트로크 공유에 적합합니다.
CRDT(Yjs)로 자동 병합
공동 문서 편집이나 디자인 캔버스처럼 여러 사람이 같은 데이터를 동시에 고치는 경우에는 Yjs 같은 CRDT가 널리 쓰입니다. PartyKit 생태계에서는 y-partykit이 Yjs 문서를 Room에 연결하는 서버 헬퍼와 클라이언트 프로바이더를 제공합니다.
스냅샷과 델타
게임 서버에서는 주기적인 전체 스냅샷과 그 사이의 변경분(델타)을 섞어 대역폭을 줄입니다. PartyKit에서도 스냅샷을 Storage에 저장해 두고 재접속한 클라이언트에 보내는 방식을 쓸 수 있습니다. 이때 아래의 히버네이션과의 상호작용을 함께 고려해야 합니다.
Persistence: room.storage
room.storage는 방마다 붙는 비동기 키-값 저장소로, Durable Object Storage를 그대로 노출합니다. 키는 최대 2,048바이트 문자열이고, 값은 structured clone이 가능한 타입으로 항목당 최대 128KiB입니다.
Room은 재배포, 크래시, 플랫폼의 재시작 등으로 언제든 메모리 상태를 잃을 수 있습니다. 그래서 복구해야 하는 상태는 Storage에 두고, 매 틱 바뀌는 순간적인 값만 메모리에 두면서 주기적으로 체크포인트를 저장하는 전략이 일반적입니다.
async onStart() {
const checkpoint = await this.room.storage.get<{ tick: number }>("checkpoint");
// … 메모리 상태 복원
}
async saveCheckpoint(state: { tick: number }) {
await this.room.storage.put("checkpoint", state);
}
큰 문서는 한 키에 몰지 말고 여러 키로 나눠 저장해야 128KiB 제한에 걸리지 않습니다. 키 개수 자체에는 실질적인 제한이 거의 없다고 공식 가이드는 설명합니다.
Hibernation
options.hibernate = true로 설정하면, 활동이 없을 때 Room 인스턴스를 메모리에서 내릴 수 있습니다. 공식 가이드는 히버네이션을 끈 상태에서 방당 약 100개 연결, 켠 상태에서 수만 개(문서 기준 32,000개) 연결까지 다룰 수 있다고 설명합니다. 제품 한도는 바뀔 수 있으므로 최신 문서를 확인합니다.
동작 방식
메시지가 없는 동안 서버 객체는 메모리에서 내려가지만, WebSocket 연결은 플랫폼이 계속 유지합니다. 다시 메시지가 오면 새 Server 인스턴스가 만들어지고 onStart가 다시 호출된 뒤 onMessage가 실행됩니다. 따라서 생성자나 onConnect에서 인스턴스 필드에 담아 둔 값은 사라질 수 있습니다. 반면 room.getConnections()와 각 연결의 connection.state는 플랫폼이 보관하므로 깨어난 뒤에도 그대로 쓸 수 있습니다.
설계 시 유의사항
영속이 필요한 값은 Storage에 둡니다. 연결 목록을 인스턴스 필드의 Map으로 직접 관리하지 말고 room.getConnections()를 쓰며, 연결별 메타데이터는 connection.setState()에 둡니다. Yjs 같은 라이브러리는 히버네이션과 호환되는 최신 버전과 권장 설정을 따릅니다.
히버네이션은 메모리 비용과 연결 수 한도를 맞바꾸는 기능입니다. 프로토타입 단계에서는 끄고 시작해 상태 관리가 단순한 상태로 개발한 뒤, 트래픽 패턴을 보고 켜는 것도 합리적입니다. 히버네이션을 켠 뒤 상태가 가끔 사라진다면 대부분 인스턴스 필드에만 있던 값이 원인입니다.
PartyKit AI와 Vectorize
PartyKit AI는 Cloudflare Workers AI와 Vectorize를 PartyKit 프로젝트에서 쓰게 해 주는 레이어입니다. partykit-ai 패키지의 Ai 클래스는 Workers AI 클라이언트 라이브러리와 같은 사용법을 목표로 합니다.
Room에서 모델 호출
import { Ai } from "partykit-ai";
import type * as Party from "partykit/server";
export default class Server implements Party.Server {
ai: Ai;
constructor(public room: Party.Room) {
this.ai = new Ai(room.context.ai);
}
// onMessage 등에서 this.ai.run(...)으로 텍스트 생성·분류 등을 수행
}
onFetch, onSocket, onCron 같은 정적 메서드에서도 lobby.ai로 같은 패턴을 쓸 수 있어, 비실시간 배치 작업과 실시간 Room을 한 저장소에 둘 수 있습니다. 공식 문서의 onFetch 예시는 Llama 계열 모델에 stream: true를 주고 결과를 SSE로 반환하므로, 채팅 UI처럼 토큰을 순차적으로 보여 주는 화면에 바로 쓸 수 있습니다.
Vectorize로 RAG
partykit.json에 vectorize 설정을 추가하면 room.context.vectorize.<이름>으로 인덱스에 접근할 수 있고, insert와 query로 시맨틱 검색을 구현합니다. 메타데이터 필터로 테넌트나 카테고리를 좁힐 수 있어, 협업 문서 안에서 근거를 찾아 답하는 기능 같은 응용이 가능합니다.
외부 모델 API와의 결합
PartyKit AI는 Cloudflare가 호스팅하는 모델을 대상으로 합니다. OpenAI 같은 외부 모델은 환경 변수에 둔 API 키로 Room에서 fetch 호출하고, 결과를 Room 메시지 흐름에 합치는 방식이 흔합니다. API 키는 서버(Room) 쪽에만 두고 클라이언트에는 절대 노출하지 않습니다.
Yjs 협업 에디터 구성
실제 패키지 버전과 import 경로는 y-partykit 문서를 따릅니다. 여기서는 구성 요소와 흐름을 정리합니다.
클라이언트는 Y.Doc과 에디터 바인딩(Monaco, ProseMirror, TipTap 등)을 가지고, YPartyKitProvider로 같은 방 ID의 Yjs 문서를 동기화합니다. 서버는 y-partykit이 제공하는 onConnect 헬퍼로 들어온 연결을 Yjs 동기화 프로토콜에 연결합니다.
공동 편집에서 문자 단위로 락을 거는 방식은 지연이 크고 오프라인 편집을 지원하지 못합니다. Yjs는 CRDT로 로컬 편집을 즉시 반영하고, 다른 사용자의 변경과는 중앙 조정 없이도 같은 결과로 수렴하도록 병합합니다. PartyKit은 그 변경을 주고받는 전송 계층과 방 단위 상태를 제공합니다.
y-partykit은 문서 스냅샷이나 변경 이력을 Room Storage에 저장하는 영속 옵션을 지원하므로, Room이 재시작되어도 문서를 복구할 수 있습니다. 커서 위치나 접속자 정보 같은 존재 정보(awareness)는 문서와 별도 채널로 오가므로, 협업 UX 문제를 디버깅할 때는 문서 동기화와 awareness를 나눠서 확인합니다.
보안: onBeforeConnect와 인증
연결이 Room에 들어오기 전에 막으려면 static onBeforeConnect를 씁니다. 이 훅은 엣지에서 실행되며 Room Storage에 접근할 수 없습니다. 대신 JWT 검증, 쿼리 문자열 토큰 확인, IP 제한 같은 상태 없는 검사에 적합하고, Response를 반환하면 연결이 거부됩니다.
방 안에서만 알 수 있는 정보(예: 이 사용자가 이 게임 세션의 참가자인지)는 Room의 onConnect에서 다시 확인해야 합니다. 엣지에서 신원을 확인하고 Room에서 권한을 확인하는 이중 검증이 일반적인 구성입니다.
로컬 개발과 배포
partykit CLI로 프로젝트를 만들거나 기존 앱에 추가한 뒤, partykit.json에 Party 이름, 진입 파일, 크론 등을 정의합니다. npx partykit dev로 로컬 서버를 띄우고, 프런트엔드에서는 PartySocket이나 Yjs 프로바이더로 그 호스트에 연결합니다. npx partykit deploy로 배포한 뒤에는 클라이언트의 연결 호스트를 환경별로 나눠 지정합니다. Next.js 같은 프레임워크와의 통합은 공식 문서의 “Add PartyKit to …” 튜토리얼을 따르는 것이 가장 안전합니다.
트러블슈팅
| 증상 | 확인할 것 |
|---|---|
| 상태가 가끔 사라짐 | 히버네이션을 켠 상태에서 인스턴스 필드에만 상태를 두고 있지 않은지 |
| Storage 저장 실패 | 값 128KiB, 키 2,048바이트 제한. 큰 값은 여러 키로 분할 |
| 보낸 사람에게 자기 메시지가 되돌아옴 | broadcast의 두 번째 인자에 발신자 id를 넣었는지 |
| Yjs 문서가 어긋남 | 프로바이더와 서버 패키지 버전 불일치, 잘못된 방 ID |
참고
- Party.Server (Server API)
- Persisting state into storage
- Scaling PartyKit servers with Hibernation
- PartyKit AI
- Y-PartyKit (Yjs API)