Yjs 협업 에디터 아키텍처: Awareness·오프라인 지속성·서버 Relay를 직접 구현하며 겪은 것들
구글 독스처럼 여러 명이 동시에 편집해도 내용이 자연스럽게 합쳐지는 에디터. 처음 이걸 만들어야 한다는 말을 들었을 때 솔직히 막막했습니다. WebSocket으로 실시간 동기화야 해봤지만, 충돌 처리는 어떻게 하고, 오프라인 상태에서 쓰다가 다시 연결되면 어떻게 되고, 커서는 어떻게 공유하지? 하나씩 파다 보니 Yjs라는 라이브러리가 이 모든 걸 정리된 레이어로 묶어주고 있었습니다. 그러다 Yjs가 이미 Jupyter, Tiptap, Excalidraw의 동기화를 책임지고 있다는 걸 알았을 때, "그냥 유명한 게 아니구나" 싶었습니다.
이 글은 WebSocket 기반 실시간 기능을 구현해본 경험이 있는 프론트엔드·풀스택 개발자를 대상으로 합니다. Yjs로 협업 에디터를 구현할 때 실제로 부딪히는 세 가지 핵심 레이어인 Awareness(인식 레이어), 오프라인 지속성, 서버 Relay 설계를 차례로 살펴봅니다. 이 글을 읽고 나면 각 레이어의 역할과 조합 방식을 이해하고, 소규모 PoC부터 수평 확장이 필요한 프로덕션까지 어떤 선택을 해야 하는지 판단할 수 있게 됩니다.
TL;DR: Yjs는 CRDT 기반으로 충돌 해소를 라이브러리 레벨에서 처리합니다. 개발 시작은
npx y-websocket과y-indexeddb두 줄이면 충분하고, 프로덕션으로 넘어갈 때 Hocuspocus + Redis 조합으로 전환하면 됩니다. Awareness는 커서·Presence 등 휘발성 상태 전용이며, 영구 저장 없이 피어 단위로 자동 관리됩니다.
핵심 개념
Yjs가 충돌을 해결하는 방법: CRDT와 YATA
Yjs는 CRDT(Conflict-free Replicated Data Type) 기반 라이브러리입니다. CRDT의 핵심 아이디어는 단순합니다. 어떤 순서로 업데이트가 도착하든 최종 상태가 항상 동일하게 수렴하도록 데이터 구조를 설계하는 것입니다. 서버에서 "이게 진짜 최신 버전이야"라고 판결을 내리지 않아도 됩니다.
CRDT란? 분산 환경에서 여러 노드가 동시에 데이터를 수정해도, 네트워크 지연이나 순서 역전 없이 최종 상태가 자동으로 일치하도록 설계된 데이터 구조. 충돌 해소 로직이 데이터 구조 자체에 내장되어 있습니다.
Yjs 내부적으로는 YATA(Yet Another Transformation Approach) 알고리즘을 씁니다. Kevin Jahns의 논문에서 RGA, WOOT 같은 선행 연구와 비교·대조되는 방식으로 제시된 독립적인 알고리즘으로, 텍스트 타이핑처럼 순차 삽입이 많은 시나리오에 최적화되어 있습니다. 텍스트 삽입 위주 시나리오 초기 벤치마크에서는 Automerge 대비 압도적인 성능을 보였고, 최근 Automerge v2가 WASM 기반으로 성능을 많이 끌어올렸지만 여전히 Yjs가 앞서는 경향이 있습니다.
처음 이 개념을 접했을 때 "그래서 코드로 어떻게 쓰는 건데?"라는 생각이 먼저 들었는데, 다행히 실제 API는 개념보다 훨씬 단순합니다. 먼저 세 레이어 전체를 연결하는 코드를 보고 나서 각 개념을 짚어보겠습니다.
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
const ydoc = new Y.Doc()
// 로컬 먼저, 서버 나중
const indexeddbProvider = new IndexeddbPersistence('doc-room-1', ydoc)
const wsProvider = new WebsocketProvider('wss://relay.example.com', 'doc-room-1', ydoc)
wsProvider.awareness.setLocalState({
user: { name: 'Alice', color: '#ff6b6b' },
})세 줄로 오프라인 지속성, 실시간 동기화, Awareness를 동시에 붙일 수 있습니다. 각 레이어가 Y.Doc 하나를 공유하면서 독립적으로 동작하는 구조입니다.
협업 에디터의 세 레이어
| 레이어 | 역할 | 저장 여부 |
|---|---|---|
| Awareness | 누가 접속 중인지, 커서가 어디 있는지 등 ephemeral 상태 공유 | ❌ 영구 저장 불필요 |
| 오프라인 지속성 | 로컬에 문서 스냅숏 저장, 재연결 시 diff만 동기화 | ✅ IndexedDB 등 |
| 서버 Relay | 업데이트를 연결된 모든 클라이언트에 브로드캐스트 | 선택적 |
이 세 레이어가 독립적으로 동작하면서 서로를 보완한다는 게 Yjs 아키텍처의 묘미입니다. 서버가 없어도 WebRTC로 P2P 동기화가 가능한데, 이는 소규모 팀 내부 도구처럼 중앙 인프라 없이 브라우저끼리 직접 통신하고 싶은 상황에서 유용합니다. 다만 P2P는 방화벽 환경이나 모바일에서 연결 안정성 문제가 생길 수 있어서, 대부분의 프로덕션 환경에서는 서버 Relay가 더 현실적입니다.
실전 적용
예시 1: 기본 세팅 — 세 레이어를 한 번에 연결하기
처음 Yjs를 세팅할 때 가장 헷갈렸던 부분이 provider를 여러 개 붙이는 방식이었습니다. 알고 보면 단순합니다. Y.Doc 하나에 원하는 프로바이더를 여러 개 붙이면 됩니다.
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
const ydoc = new Y.Doc()
// 1. 오프라인 지속성 — 로컬에서 먼저 로드
const indexeddbProvider = new IndexeddbPersistence('doc-room-1', ydoc)
indexeddbProvider.on('synced', () => {
console.log('로컬 데이터 로드 완료, 이제 서버와 연결')
})
// 2. 네트워크 동기화 — diff만 전송
const wsProvider = new WebsocketProvider(
'wss://relay.example.com',
'doc-room-1',
ydoc
)
// 3. Awareness — 커서 위치와 사용자 정보 공유
const awareness = wsProvider.awareness
awareness.setLocalState({
user: {
name: 'Alice',
color: '#ff6b6b',
},
})
// 다른 사람의 커서 변경 구독
awareness.on('change', ({ added, updated, removed }) => {
const states = Array.from(awareness.getStates().entries())
// UI 렌더링은 에디터 통합 방식에 따라 달라집니다 — 예시 2 참고
renderCursors(states)
})| 코드 부분 | 설명 |
|---|---|
IndexeddbPersistence |
브라우저 IndexedDB에 문서를 저장. 오프라인 편집 후 재연결 시 자동 병합 |
WebsocketProvider |
서버와 WebSocket 연결. 로컬 변경분을 서버로 보내고 서버에서 받은 업데이트를 문서에 적용 |
awareness.setLocalState |
자신의 슬롯에만 쓸 수 있는 ephemeral 상태. 서버에 영구 저장되지 않음 |
awareness.on('change') |
다른 피어의 상태 변경(입장, 퇴장, 커서 이동) 이벤트 구독 |
IndexeddbPersistence를 WebSocket보다 먼저 초기화하는 순서가 중요합니다. 로컬 데이터를 먼저 불러온 뒤 서버와 diff를 주고받아야 초기 로딩이 빠르고 자연스럽습니다. 반대로 하면 빈 문서 상태가 서버로 올라가는 아찔한 상황이 생길 수 있습니다. 실무에서 저도 이 순서를 한 번 뒤집었다가 문서가 날아갈 뻔했습니다.
예시 2: Awareness로 멀티커서 구현하기
Awareness CRDT는 각 피어에게 고유 슬롯을 할당합니다. 자신의 슬롯만 수정할 수 있고, 다른 피어의 슬롯은 읽기만 됩니다. 이 구조 덕분에 race condition 걱정 없이 여러 사람의 커서 위치를 안전하게 공유할 수 있습니다.
한 가지 주의할 점이 있는데, 커서 위치를 절대 인덱스(anchor: 10, head: 15)로 저장하면 텍스트 변경이 생겼을 때 인덱스가 밀리면서 커서가 엉뚱한 곳으로 튀는 버그가 납니다. Yjs의 relative position API를 쓰는 것이 맞습니다.
import { Editor } from '@tiptap/core'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
// Tiptap 통합 시 — 바인딩이 relative position을 대신 처리해줌
const editor = new Editor({
extensions: [
Collaboration.configure({ document: ydoc }),
CollaborationCursor.configure({
provider: wsProvider,
user: {
name: 'Alice',
color: '#ff6b6b',
},
}),
],
})
// 직접 Awareness 상태를 제어하고 싶다면 relative position 사용
import * as Y from 'yjs'
function updateCursor(editor) {
const { from, to } = editor.state.selection
const ytext = ydoc.getText('content')
// 절대 인덱스가 아닌 relative position으로 저장
const anchor = Y.createRelativePositionFromTypeIndex(ytext, from)
const head = Y.createRelativePositionFromTypeIndex(ytext, to)
wsProvider.awareness.setLocalState({
user: { name: 'Alice', color: '#ff6b6b' },
cursor: { anchor, head },
})
}
// 컴포넌트 언마운트 시 정리
function cleanup() {
wsProvider.awareness.setLocalState(null) // 퇴장 처리
wsProvider.destroy()
indexeddbProvider.destroy()
}Ephemeral 데이터의 의미: Awareness 상태는 피어가 연결을 끊으면 자동으로 제거됩니다. 별도로 "누가 나갔다"는 이벤트를 만들 필요가 없습니다.
awareness.on('change')의removed배열에 자동으로 담깁니다.
예시 3: 서버 Relay 설계 — y-websocket에서 Hocuspocus로 넘어가는 시점
y-websocket으로 시작하면 설정이 매우 간단합니다. 문제는 트래픽이 늘어날 때입니다. 서버를 재시작하면 인메모리 문서 상태가 모두 날아가는 구조라서, 작은 서비스라도 프로덕션에 그대로 올리는 건 위험합니다.
// y-websocket 서버 (개발·소규모용)
// npx y-websocket 으로 즉시 실행 가능
// 단일 노드, 인메모리 — 서버 재시작 시 문서 상태 소실
// Hocuspocus 서버 (인증·훅·Redis 확장 내장)
import { Server } from '@hocuspocus/server'
import { Redis } from '@hocuspocus/extension-redis'
import { Database } from '@hocuspocus/extension-database'
const server = Server.configure({
port: 1234,
extensions: [
new Redis({
host: 'redis-host',
port: 6379,
}),
new Database({
fetch: async ({ documentName }) => {
return await db.getDocument(documentName)
},
store: async ({ documentName, state }) => {
await db.saveDocument(documentName, state)
},
}),
],
async onAuthenticate(data) {
const { token } = data
const user = await verifyToken(token)
if (!user) throw new Error('Unauthorized')
return { user }
},
})
server.listen()수평 확장 시에는 sticky session 설정이 필요합니다. WebSocket은 연결 상태를 서버 메모리에 들고 있기 때문에, 로드밸런서가 같은 클라이언트의 요청을 매번 다른 서버로 보내면 Awareness 상태 동기화가 깨집니다. Redis를 붙이더라도 이 라우팅 설정은 별도로 신경 써야 합니다.
| 규모 | 권장 솔루션 | 특징 |
|---|---|---|
| 개발·PoC | y-websocket |
npx 한 줄, 인메모리 |
| 소·중규모 | Hocuspocus |
인증 훅, DB 연동, Redis 확장 내장 |
| 대규모 수평 확장 | y-redis 또는 Hocuspocus + Redis |
Redis Streams 기반, sticky session 필요 |
| 인프라 없이 시작 | Liveblocks, PartyKit, Y-Sweet | 관리형 서비스, 엣지 분산 |
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 충돌 해소 자동화 | 업데이트 적용 순서와 무관하게 최종 상태 수렴. 충돌 처리 코드를 직접 짤 필요 없음 |
| 오프라인 지원 | y-indexeddb 한 줄로 오프라인 편집 후 자동 병합 |
| 전송 레이어 독립 | WebSocket, WebRTC, HTTP 어디서든 동작. 프로바이더만 바꾸면 됨 |
| 성능 | 텍스트 삽입 위주 시나리오에서 가장 빠른 CRDT 구현 중 하나 |
| 생태계 | ProseMirror, CodeMirror 6, Monaco, Quill 등 주요 에디터 바인딩 제공 |
| 중앙 서버 불필요 | WebRTC 모드로 완전 P2P 구성 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 문서 크기 단조 증가 | 삭제된 텍스트도 tombstone으로 잔존 | 주기적 스냅숏 + 가비지 컬렉션(ydoc.gc = true) |
| 리치 텍스트 한계 | 복잡한 서식 변환 시 의미 손실 가능성 | 에디터 바인딩(Tiptap 등)이 대부분 처리. 커스텀 포맷은 주의 필요 |
| 오프라인 Presence 없음 | 오프라인에서는 커서·Awareness 전파 불가 | 오프라인 상태 UI로 명시적으로 알려주는 방식으로 보완 |
| y-websocket 단일 노드 한계 | 인메모리 상태라 수평 확장 불가 | Hocuspocus + Redis 또는 y-redis로 전환 |
| 학습 곡선 | CRDT 개념 없이 쓰면 예상치 못한 병합에 당혹 | 공식 문서의 CRDT 개요부터 읽는 것을 권장 |
Tombstone이란? CRDT에서 삭제된 요소를 실제로 제거하지 않고 "삭제됨" 표시만 남기는 방식. 다른 피어와의 충돌 없는 병합을 위해 필요하지만, 문서가 계속 커지는 부작용이 있습니다.
ydoc.gc = true(기본값)로 가비지 컬렉션을 켤 수 있는데, 이때 스냅숏과 함께 쓰는 것이 중요합니다. 스냅숏 없이 오래된 업데이트만 불러오는 상태에서 gc가 켜져 있으면 문서 데이터가 깨질 수 있습니다.
실무에서 가장 흔한 실수
-
IndexeddbPersistence를 WebSocket보다 나중에 초기화하는 것 — 로컬 데이터 로드 전에 서버 연결이 먼저 열리면 빈 문서 상태가 서버로 올라가는 사고가 날 수 있습니다.indexeddbProvider.on('synced')이후 또는 동시 초기화 후 로컬 우선 로드를 확인하는 것을 권장합니다. -
y-websocket을 프로덕션에 그대로 쓰는 것 — 개발용으로는 완벽하지만, 서버가 재시작되면 인메모리 문서 상태가 모두 날아갑니다. 작은 서비스라도 Hocuspocus + DB 연동은 필수입니다.
-
컴포넌트 언마운트 시
awareness.setLocalState(null)안 하는 것 — 이걸 빼먹으면 유령 커서가 화면에 계속 남아있게 됩니다. 다른 사용자 입장에서는 떠난 사람의 커서가 한동안 보이는 불쾌한 경험이 됩니다. 실무에서 자주 맞닥뜨리는 상황인데, 발견하기 전까지는 원인을 찾기도 꽤 애매합니다.
마치며
결국 Yjs를 쓰면서 가장 크게 느낀 건, 협업 에디터에서 제일 어려운 부분이 이미 라이브러리 안에 들어가 있다는 것이었습니다. 충돌 해소를 직접 구현하려고 했다면 수개월은 족히 걸렸을 텐데, Yjs 덕분에 그 시간을 실제 제품 로직에 쓸 수 있었습니다. Awareness, 오프라인 지속성, 서버 Relay 세 레이어가 각자 독립적으로 동작하면서도 Y.Doc 하나를 통해 자연스럽게 연결된다는 설계 방식이 핵심입니다.
지금 바로 시작해볼 수 있는 3단계:
- 로컬 데모 실행해보기 —
npx y-websocket으로 Relay 서버를 띄우고, Yjs 공식 데모의 Tiptap 예제를 탭 두 개에서 열어보시면 됩니다. 설치 없이 5분 안에 멀티커서 동작을 직접 확인할 수 있습니다. y-indexeddb추가해서 오프라인 동작 확인해보기 — 데모에new IndexeddbPersistence('my-doc', ydoc)한 줄을 추가한 뒤, 브라우저를 오프라인으로 바꾸고 편집해보시면 됩니다. 재연결 후 내용이 자동 병합되는 과정을 직접 보면 CRDT가 어떻게 동작하는지 직관적으로 이해됩니다.- Hocuspocus로 서버를 전환하고 인증 훅 달아보기 —
@hocuspocus/server패키지로 서버를 구성한 뒤onAuthenticate훅에 JWT 검증을 추가해보시면 됩니다. 여기까지 오면 프로덕션에 배포할 수 있는 기반이 갖춰집니다.
참고 자료
- Yjs 공식 문서 - Introduction | yjs.dev
- Yjs 공식 문서 - Awareness & Presence | yjs.dev
- Yjs 공식 문서 - Offline Support (y-indexeddb) | yjs.dev
- Yjs 공식 문서 - y-websocket | yjs.dev
- GitHub: yjs/y-indexeddb
- GitHub: ueberdosis/hocuspocus
- Hocuspocus 확장성 가이드 | tiptap.dev
- y-redis: y-websocket 대안 백엔드 논의 | discuss.yjs.dev
- Are CRDTs suitable for shared editing? | Kevin Jahns
- ElectricSQL: AI 에이전트를 CRDT 피어로 (2026) | electric.ax
- GitHub: electric-sql/collaborative-ai-editor
- Liveblocks Yjs 소개 | liveblocks.io
- PartyKit y-partykit API 문서 | partykit.io
- Tiptap Awareness 문서 | tiptap.dev
- Tutorial: Yjs + valtio + React 협업 에디터 | DEV Community