Hocuspocus + Redis로 Yjs 협업 서버 수평 확장하기: sticky session과 문서 영속성 전략
단일 서버에서는 잘 되던 실시간 협업 편집이 인스턴스를 두 대로 늘리는 순간 이상해진 경험, 혹시 있으신가요? 사용자 A가 입력한 텍스트가 사용자 B 화면에서 사라졌다가 다시 나타나거나, 같은 문서를 두 사람이 열었는데 서버마다 내용이 달라 보이는 상황 말입니다. 저도 그랬습니다. Hocuspocus는 Yjs CRDT 덕분에 충돌 해결 자체는 알아서 해주는데, 문제는 서버 간에 상태를 어떻게 공유하느냐였습니다.
이 글은 단일 Hocuspocus 서버로 시작했다가 스케일아웃을 고민하고 있는 분, 또는 Tiptap 기반 SaaS를 만들면서 인프라 설계를 처음 잡아보는 분을 위해 썼습니다. Redis Pub/Sub으로 인스턴스 간 업데이트를 동기화하고, 별도 Database 익스텐션으로 영속성을 확보하는 구조로 정착하게 된 과정을 풀어볼 텐데, Redis는 인스턴스 간 동기화만 담당하고 문서 영속성은 반드시 Database 익스텐션으로 분리해야 안전한 수평 확장이 가능합니다. sticky session의 현실적인 한계와 PostgreSQL, S3를 활용한 계층형 지속성 전략까지, 각 선택의 트레이드오프를 솔직하게 이야기해보겠습니다.
핵심 개념
Yjs CRDT와 Hocuspocus의 역할 분리
Yjs는 CRDT(Conflict-free Replicated Data Type) 알고리즘을 구현한 라이브러리입니다. 두 클라이언트가 동시에 같은 문서를 다르게 편집해도 서버에서 자동으로 병합해주는 수학적 구조로, 네트워크가 끊겼다가 다시 연결되어도 충돌 없이 합쳐집니다.
CRDT(Conflict-free Replicated Data Type): 분산 시스템에서 여러 노드가 독립적으로 데이터를 수정해도 항상 동일한 결과로 수렴되는 데이터 구조. 서버 없이도, 네트워크가 끊겨도 나중에 자동 병합됩니다.
Hocuspocus는 이 Yjs를 위한 WebSocket 백엔드입니다. Node.js 18 이상, Bun, Deno, Cloudflare Workers에서 동작하며, 익스텐션 방식으로 기능을 조합할 수 있습니다.
| 컴포넌트 | 역할 |
|---|---|
@hocuspocus/server |
WebSocket 연결 관리, Yjs 문서 조정 |
@hocuspocus/extension-redis |
Redis Pub/Sub으로 인스턴스 간 업데이트 전파 |
@hocuspocus/extension-database |
PostgreSQL, MySQL, MongoDB 등 외부 DB에 문서 저장 |
| Redis Pub/Sub | 브로드캐스트 채널 역할, 저장은 하지 않음 |
Redis 익스텐션의 동작 원리
Redis 익스텐션이 하는 일은 생각보다 단순합니다. 클라이언트가 인스턴스 A에 연결되어 문서를 수정하면, A는 그 업데이트를 Redis 채널에 publish합니다. 인스턴스 B와 C는 같은 채널을 subscribe하고 있다가 업데이트를 받아서 자신에게 연결된 클라이언트들에게 전달합니다.
중요한 점은 Redis에는 데이터가 저장되지 않는다는 겁니다. Redis가 재시작되면 그 사이의 메시지는 그냥 사라집니다. 실무에서 Redis를 영속성 저장소로 착각하는 사례를 직접 봤는데, 이 부분은 아래에서 다시 짚겠습니다.
Redlock과 분산 잠금
인스턴스가 여러 개라면 이런 상황이 발생할 수 있습니다. 클라이언트 A가 인스턴스 1에, 클라이언트 B가 인스턴스 2에 붙어있고, 둘 다 거의 동시에 같은 문서를 수정합니다. 두 인스턴스가 동시에 같은 문서를 데이터베이스에 저장하려 하면 하나의 쓰기가 다른 쓰기를 덮어쓸 위험이 생깁니다. Redlock 때문에 한 번 호되게 당했는데, 그때 서버 간 시계가 30초 가까이 차이 났더군요.
Hocuspocus Redis 익스텐션은 내부적으로 Redlock 알고리즘을 사용해 이 문제를 해결합니다. 여러 Redis 노드에 동시에 잠금을 요청하고 과반수 이상에서 성공했을 때만 잠금을 획득했다고 판단하므로, 단일 Redis 노드가 장애 나도 잠금이 유지될 수 있습니다.
주의: Redlock은 각 서버의 시스템 시계가 크게 차이 나지 않아야 정확히 동작합니다. 분산 환경에서 NTP 동기화 설정은 선택이 아닌 필수입니다.
실전 적용
예시 1: 표준 수평 확장 구성 (Hocuspocus + Redis + PostgreSQL)
대부분의 팀이 처음 시작하는 구성입니다. 동시 접속 문서 수가 수백 개 이하이고 DB 쓰기 부하가 특별히 높지 않은 환경이라면 이 패턴으로 충분합니다.
[Client A] ──WebSocket──▶ [Load Balancer (Sticky Session)]
[Client B] ──WebSocket──▶ │ │
[Hocuspocus #1] [Hocuspocus #2]
│ │
[Redis Pub/Sub (동기화)]
│ │
[Database Extension] [Database Extension]
│ │
└──────┬───────┘
[PostgreSQL]두 인스턴스 모두 독립적으로 PostgreSQL에 접근한다는 점이 중요합니다.
// hocuspocus-server.ts (각 인스턴스에서 동일하게 실행)
import { Server } from '@hocuspocus/server'
import { Redis } from '@hocuspocus/extension-redis'
import { Database } from '@hocuspocus/extension-database'
import { Pool } from 'pg'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const server = Server.configure({
port: Number(process.env.PORT) || 1234,
// onLoadDocument와 Database 익스텐션의 fetch를 동시에 사용하면
// 같은 문서에 Y.applyUpdate가 두 번 적용되어 문서 상태가 오염됩니다.
// 반드시 둘 중 하나만 사용하세요. 여기서는 Database 익스텐션만 사용합니다.
extensions: [
new Redis({
host: process.env.REDIS_HOST ?? 'localhost',
port: 6379,
}),
new Database({
fetch: async ({ documentName }) => {
// 프로덕션에서는 try/catch를 추가하세요.
// fetch 실패 시 문서가 빈 상태로 열릴 수 있습니다.
const result = await pool.query(
'SELECT data FROM documents WHERE name = $1',
[documentName]
)
return result.rows[0]?.data ?? null
},
store: async ({ documentName, state }) => {
// 프로덕션에서는 try/catch를 추가하세요.
await pool.query(
`INSERT INTO documents (name, data, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (name) DO UPDATE
SET data = EXCLUDED.data, updated_at = NOW()`,
[documentName, Buffer.from(state)]
)
},
}),
],
})
server.listen()
console.log(`Hocuspocus running on port ${server.configuration.port}`)HAProxy를 사용한 sticky session 설정입니다. WebSocket 연결은 세션이 길기 때문에 timeout tunnel 값을 충분히 크게 잡아야 합니다.
# haproxy.cfg
frontend ws_frontend
bind *:80
default_backend ws_backend
# option http-server-close는 HTTP keep-alive를 비활성화합니다.
# WebSocket 전용 프론트엔드라면 문제없지만,
# 일반 HTTP 트래픽도 같이 처리한다면 성능 저하가 발생할 수 있습니다.
backend ws_backend
balance leastconn
cookie SERVERID insert indirect nocache
timeout connect 5s
timeout client 1h
timeout tunnel 24h
server hocuspocus1 hocuspocus-1:1234 check cookie s1
server hocuspocus2 hocuspocus-2:1234 check cookie s2Nginx를 선호하신다면 아래 방식을 사용할 수 있습니다.
upstream hocuspocus_backend {
hash $remote_addr consistent;
server hocuspocus-1:1234;
server hocuspocus-2:1234;
}
server {
listen 80;
location / {
proxy_pass http://hocuspocus_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
}
}핵심 인사이트: Redis 익스텐션과 Database 익스텐션은 함께 사용해야 합니다. Redis만 있으면 동기화는 되지만 서버 재시작 시 데이터가 날아갑니다.
예시 2: 계층형 문서 지속성 전략 (고부하 환경)
이 예시부터는 고부하 환경 또는 감사 로그·버전 히스토리가 필요한 상황을 위한 내용입니다. 동시 접속 문서 수가 수천 개를 넘거나, 모니터링에서 DB 쓰기 지연이 올라오기 시작할 때 고려하면 좋습니다. 예시 1로 먼저 시작하고 실제 병목이 확인된 후 전환하는 것을 권장합니다.
한 가지 짚고 넘어갈 점이 있습니다. Database 익스텐션의 store 콜백이 받는 state는 마지막 변경분(delta)이 아니라 Y.Doc의 전체 상태입니다. 매 호출마다 전체 문서가 전달됩니다. 이 점을 이해하면 fetch 로직도 단순해집니다. 가장 최근 레코드 하나만 가져오면 상태를 완전히 복원할 수 있습니다.
이를 활용한 계층형 전략은 다음과 같습니다. 항상 최신 상태를 documents 테이블에 upsert하면서, 주기적으로 복구 포인트용 스냅샷을 별도 테이블에 기록해두는 방식입니다.
import { Server } from '@hocuspocus/server'
import { Redis } from '@hocuspocus/extension-redis'
import { Database } from '@hocuspocus/extension-database'
import { Pool } from 'pg'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
// 업데이트 카운터 (인메모리, 인스턴스별로 독립적으로 동작합니다)
// 주의: 인스턴스가 여러 개면 각자 카운터가 따로 돌아
// 스냅샷 타이밍이 제각각이 됩니다.
// 고부하 환경에서는 별도 스케줄러 프로세스로 분리하는 것이 안전합니다.
const updateCounters = new Map<string, number>()
const SNAPSHOT_INTERVAL = 500
const server = Server.configure({
extensions: [
new Redis({ host: process.env.REDIS_HOST, port: 6379 }),
new Database({
fetch: async ({ documentName }) => {
// state가 항상 전체 상태이므로 최신 레코드 하나만 가져오면 됩니다.
const result = await pool.query(
'SELECT data FROM documents WHERE name = $1',
[documentName]
)
return result.rows[0]?.data ?? null
},
store: async ({ documentName, state }) => {
// state는 Y.Doc 전체 상태입니다 (변경분이 아닙니다).
const count = (updateCounters.get(documentName) ?? 0) + 1
updateCounters.set(documentName, count)
// 항상 최신 상태로 upsert
await pool.query(
`INSERT INTO documents (name, data, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (name) DO UPDATE SET data = EXCLUDED.data, updated_at = NOW()`,
[documentName, Buffer.from(state)]
)
// 주기적으로 복구 포인트 스냅샷 기록
if (count % SNAPSHOT_INTERVAL === 0) {
await pool.query(
`INSERT INTO document_snapshots (name, data, created_at) VALUES ($1, $2, NOW())`,
[documentName, Buffer.from(state)]
)
// 이전 스냅샷보다 오래된 항목은 별도 아카이빙 프로세스에서 S3로 이동 가능
}
// 프로덕션에서는 try/catch를 추가하세요.
},
}),
],
})| 저장 계층 | 내용 | 접근 패턴 |
|---|---|---|
PostgreSQL documents |
문서별 최신 전체 상태 (upsert) | 문서 열기 시 |
PostgreSQL document_snapshots |
주기적 복구 포인트 | 장애 복구, 버전 히스토리 |
| S3 | 오래된 스냅샷 콜드 아카이브 | 감사·장기 보관 시에만 접근 |
예시 3: y-redis Worker 분리 패턴 (대안 아키텍처)
이 예시도 중급 이상의 환경을 위한 내용입니다. 아키텍처 선택지를 넓게 파악하고 싶을 때 참고하면 좋습니다.
y-redis는 Hocuspocus의 Redis 익스텐션과 접근 방식이 다릅니다. Redis Streams를 처음부터 메시지 큐로 쓰고, 별도 Worker 프로세스가 비동기로 S3나 PostgreSQL에 플러시하는 구조입니다.
[WebSocket Server] ──업데이트──▶ [Redis Streams]
│
[y-redis Worker]
│
[S3 또는 PostgreSQL]WebSocket 서버가 인메모리에 Y.Doc을 유지하지 않아 메모리 효율이 높고, 서버를 언제든 재시작해도 Redis Streams에서 상태를 복구할 수 있다는 점이 장점입니다.
한 가지 언급할 게 있는데, y-redis를 상업 서비스에 도입하기 전에 라이선스를 확인해보시길 권장합니다. AGPL 또는 상용 라이선스 이중 구조라서 Hocuspocus(MIT)와 달리 별도 비용이 발생할 수 있습니다. 이 점 때문에 아키텍처적으로 y-redis가 더 우아해 보여도 Hocuspocus + Redis 조합을 선택하는 팀이 많습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| CRDT 자동 병합 | 네트워크 파티션 후 재연결 시 충돌 없이 자동 병합, 별도 충돌 해결 로직 불필요 |
| 점진적 확장 | 단일 인스턴스로 시작 후 Redis 익스텐션만 추가하면 수평 확장 가능 |
| 다양한 런타임 지원 | Node.js, Bun, Deno, Cloudflare Workers에서 동일하게 동작 |
| 풍부한 에코시스템 | Tiptap 공식 지원, PostgreSQL/MySQL/MongoDB 등 다양한 Database 드라이버 |
| 오프라인 편집 | 클라이언트가 오프라인 상태에서 편집하다 재연결 시 자동 동기화 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Sticky Session 취약성 | 특정 서버 장애 시 해당 서버의 모든 연결 단절 | graceful drain 설정, Stateless 설계로 전환 |
| Redis 재시작 시 데이터 손실 | Redis 익스텐션은 동기화만 담당, 저장 기능 없음 | Database 익스텐션 병행 필수 |
| CPU 부하 분산 불가 | 모든 인스턴스가 모든 업데이트를 처리하므로 CPU가 분산되지 않음 | 문서 ID 기반 샤딩으로 인스턴스 분리 (이 글 범위 외) |
| Redlock 시계 의존성 | 서버 간 시계 드리프트가 크면 잠금 오작동 | NTP 동기화 필수 |
Sticky Session 취약성은 소규모 팀에서는 크게 문제가 안 됩니다만, Kubernetes에서 HPA를 쓰는 순간 스케일 인 시 연결이 강제 종료되는 첫 번째 장벽이 됩니다. CPU 부하 분산을 Redis로 해결하려는 시도도 자주 보는데, Redis 익스텐션은 메시지 전파가 목적이지 부하 분산 용도가 아닙니다. 이 목적이라면 문서 ID 기준 샤딩이나 y-redis 같은 별도 아키텍처를 검토하는 것이 맞습니다.
Sticky Session(스티키 세션): 로드 밸런서가 특정 클라이언트의 요청을 항상 같은 서버로 라우팅하는 방식. WebSocket처럼 상태를 서버 메모리에 유지하는 경우에 필요하지만, 서버 장애나 동적 스케일링 시 연결이 끊길 수 있습니다.
Redis Pub/Sub vs Streams: 즉시성이 중요하고 메시지 유실을 허용할 수 있다면 Pub/Sub이 적합합니다. 메시지 재처리나 소비자 그룹 관리가 필요하다면 Redis Streams를 선택하는 것을 권장합니다.
실무에서 가장 흔한 실수
-
Redis 익스텐션을 영속성 저장소로 착각하는 것: 이건 제가 직접 봤던 사례입니다. Redis 익스텐션은 인스턴스 간 메시지를 중계할 뿐 데이터를 저장하지 않습니다. Redis가 재시작되면 그 사이의 변경 사항은 복구할 방법이 없습니다. Database 익스텐션은 선택이 아닙니다.
-
onLoadDocument와 Database 익스텐션의fetch를 동시에 사용하는 것: 두 경로 모두 DB에서 문서를 읽어Y.applyUpdate를 적용하게 됩니다. 같은 문서에 업데이트가 두 번 적용되면 상태가 오염되고, 원인을 추적하기 매우 어려운 버그로 이어집니다. 반드시 둘 중 하나만 사용해야 합니다. -
Sticky Session 없이 여러 인스턴스를 운영하는 것: Redis로 문서 업데이트는 동기화되지만, 같은 문서의 클라이언트들이 서로 다른 인스턴스에 붙으면 awareness 상태(커서 위치, 사용자 색상 등)가 제대로 전달되지 않을 수 있습니다. 완전한 Stateless 전환 전까지는 Sticky Session이 필요합니다.
-
업데이트 카운터를 인스턴스별 인메모리로만 관리하는 것: 인스턴스마다 카운터가 따로 돌기 때문에, 어떤 인스턴스는 300번째에, 다른 인스턴스는 700번째에 스냅샷을 찍게 됩니다. 최악의 경우 스냅샷 없이 상태가 무한히 쌓이는 시나리오가 발생할 수 있습니다. 고부하 환경에서는 스냅샷 주기 관리를 별도 스케줄러 프로세스로 분리하는 것이 안전합니다.
마치며
핵심은 단순합니다. Redis를 동기화 채널로, Database 익스텐션을 영속성 레이어로 명확히 분리하는 설계 원칙을 지키는 것. 이 원칙을 지키면 인스턴스를 몇 개로 늘려도 구조는 그대로 유지됩니다.
지금 바로 시작해볼 수 있는 3단계입니다.
-
pnpm add @hocuspocus/server @hocuspocus/extension-database로 설치하고, PostgreSQL과 연결해 문서를 저장해봅니다. 두 브라우저에서 같은 문서를 열고 타이핑했을 때 양쪽에 반영되면 1단계 완료입니다. 이 단계에서documents테이블 스키마를 탄탄하게 잡아두면 이후 확장이 편합니다. -
pnpm add @hocuspocus/extension-redis를 추가하고docker-compose로hocuspocus-1,hocuspocus-2,redis컨테이너를 함께 구성해봅니다. 터미널에서 한 컨테이너의 로그를 보면서 다른 인스턴스로 연결된 클라이언트의 메시지가 전달되는 것이 확인되면 2단계 완료입니다. -
HAProxy 또는 Nginx로 Sticky Session을 설정하고 장애 시나리오를 테스트해봅니다. 인스턴스 하나를
docker stop으로 강제 종료한 뒤, 클라이언트가 재연결되고 문서 내용이 유지되면 3단계 완료입니다. 이 과정에서 Database 익스텐션이 실제로 어떤 역할을 하는지 체감이 됩니다.
참고 자료
- Hocuspocus 공식 문서 - Redis Extension | Tiptap
- Hocuspocus 공식 문서 - 확장성 가이드 | Tiptap
- Hocuspocus 공식 문서 - Database Extension | Tiptap
- GitHub - ueberdosis/hocuspocus
- GitHub - hocuspocus Redis.ts 소스
- y-redis README - Yjs 공식
- y-redis 공식 Yjs 문서
- Yjs Community - y-redis 대안 백엔드 논의
- Redis 공식 문서 - 분산 잠금
- HAProxy - WebSocket 스티키 세션 구성
- WebSocket 스티키 세션 vs 분산 상태 아키텍처 비교 | Scale with Chintan
- Redis Pub/Sub vs Streams 비교 - Redis 공식 블로그
- PostgreSQL + Yjs CRDT 협업 편집 - PowerSync
- GitHub - hackmdio/y-socketio-redis
- @hocuspocus/extension-redis - npm