실시간 협업 에디터는 동시에 여러 사용자가 같은 문서를 편집할 수 있도록 하는 프런트엔드·백엔드 조합입니다. React에서는 CRDT 기반의 Yjs와 TipTap(프로즈미러)을 결합하면 낮은 러닝커브로 생산성 높은 협업 에디터를 구현할 수 있습니다. 본 글은 빠르게 동작하는 MVP를 만들고, 권한/프레즌스/오프라인/성능까지 확장하는 실무 가이드를 제공합니다.
1. 아키텍처 선택: OT vs CRDT, 그리고 Yjs
협업 에디터의 핵심은 충돌 해결입니다. OT(Operational Transform)는 중앙 조정 서버가 필요하고 구현 복잡도가 높습니다. CRDT는 분산 환경과 오프라인/재동기화 시 강합니다. React 실무에서는 Yjs(문서 CRDT) + WebSocket(동기화) + TipTap(편집기) 조합이 구현 난이도 대비 안정성이 좋습니다.
2. 전체 구조
- 클라이언트: React + TipTap + Yjs + y-websocket(WebSocketProvider) + Awareness(프레즌스)
- 서버: y-websocket 서버(문서 동기화) + 영속화(LevelDB 등) + 인증(JWT)
- 저장: 서버 영속화(문서 복구), 선택적으로 클라이언트 IndexedDB(오프라인 캐시)
3. 서버 빠른 시작(y-websocket + 영속화 + JWT)
y-websocket 기본 서버는 메모리 기반입니다. 실무에서는 LevelDB로 영속화하고, 간단한 JWT 검증을 붙여 문서 접근을 제한합니다.
// server.js
// package.json에 "type": "module"를 설정하세요.
import http from 'http'
import express from 'express'
import ws from 'ws'
import { setupWSConnection } from 'y-websocket/bin/utils.js'
import { LeveldbPersistence } from 'y-leveldb'
import jwt from 'jsonwebtoken'
const app = express()
const server = http.createServer(app)
const wss = new ws.Server({ server })
// 문서 영속화 (./y-docs 폴더)
const persistence = new LeveldbPersistence('./y-docs')
wss.on('connection', (conn, req) => {
const url = new URL(req.url, 'http://localhost')
const docName = url.pathname.slice(1) || 'demo' // ws://host:1234/<docId>
const token = url.searchParams.get('token')
try {
if (!token) throw new Error('no token')
// 실제 서비스에서는 강한 시크릿/만료/권한 스코프를 사용하세요.
jwt.verify(token, process.env.JWT_SECRET || 'dev-secret')
} catch (e) {
conn.close(1008, 'unauthorized')
return
}
setupWSConnection(conn, req, { docName, persistence })
})
server.listen(1234, () => {
console.log('y-websocket server listening on :1234')
})포인트: room(docName)은 URL 경로로 분리합니다. JWT는 최소 검증만 예시로 넣었습니다. 실제로는 역할/문서 접근 권한을 확인하세요.
4. React + TipTap + Yjs 클라이언트
TipTap의 Collaboration 확장과 Yjs를 연결하면 최소 코드로 실시간 협업이 됩니다. history는 CRDT와 충돌하므로 TipTap StarterKit의 history는 끕니다.
// Editor.tsx
import React, { useMemo, useEffect } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
export default function DocEditor({ docId, token, name }) {
const { provider, ydoc } = useMemo(() => {
const ydoc = new Y.Doc()
const provider = new WebsocketProvider(
'ws://localhost:1234',
docId,
ydoc,
{ params: { token } } // 서버로 JWT 전달
)
return { provider, ydoc }
}, [docId, token])
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: ydoc }),
CollaborationCursor.configure({
provider,
user: {
name: name || 'Guest',
color: `hsl(${Math.floor(Math.random() * 360)} 70% 50%)`
}
})
],
autofocus: true,
editorProps: {
attributes: { class: 'prose max-w-none focus:outline-none' }
}
})
useEffect(() => {
return () => {
editor?.destroy()
provider.destroy()
ydoc.destroy()
}
}, [editor, provider, ydoc])
return (
<div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16 }}>
<Header provider={provider} />
<EditorContent editor={editor} />
</div>
)
}
function Header({ provider }) {
const [status, setStatus] = React.useState('connecting')
const [count, setCount] = React.useState(1)
useEffect(() => {
const onStatus = ({ status }) => setStatus(status)
const onAwareness = () => setCount(provider.awareness.getStates().size)
provider.on('status', onStatus)
provider.awareness.on('update', onAwareness)
return () => {
provider.off('status', onStatus)
provider.awareness.off('update', onAwareness)
}
}, [provider])
return (
<div style={{ marginBottom: 8, fontSize: 12, color: '#555' }}>
Status: {status} • Users: {count}
</div>
)
}포인트: CollaborationCursor로 사용자 커서/셀렉션을 색상과 이름으로 표시합니다. provider.awareness로 접속자 수 등 프레즌스를 관리합니다.
5. 오프라인·재접속: IndexedDB 캐시
네트워크 이슈가 잦다면 클라이언트에 IndexedDB 캐시를 붙이세요. 재접속 시 로컬 상태를 즉시 렌더링하고 이후 서버와 병합합니다.
// 오프라인 캐시 (옵션)
import { IndexeddbPersistence } from 'y-indexeddb'
const ydoc = new Y.Doc()
const idb = new IndexeddbPersistence(docId, ydoc)
idb.once('synced', () => console.log('Loaded from IndexedDB'))
const provider = new WebsocketProvider('ws://localhost:1234', docId, ydoc)6. 권한과 룸 전략
- 룸 분리: 문서 ID를 room으로 사용하고, 서버에서 JWT의 문서 접근 권한을 검증합니다.
- 읽기 전용: 서버에서 특정 토큰은 접속은 허용하되 awareness만 허용하고 문서 변경은 거부하는 정책을 둘 수 있습니다(문서 정책 훅 커스터마이징).
- 감사 로그: 중요한 문서는 변경 이벤트를 수집하여 감사 테이블에 적재합니다.
7. 성능 팁
- TipTap history 비활성화: CRDT와 이중으로 충돌합니다.
- 대용량 문서: 문단 단위 노멀라이즈(헤더/리스트 등 블록을 잘게 유지)로 렌더 비용을 줄입니다.
- 배포: WebSocket은 지역 리전에 가깝게 배치하고, sticky session이 필요 없는 y-websocket을 수평 확장 시 영속화를 공유하세요(LevelDB는 단일 인스턴스용, 멀티는 Redis 기반 어댑터 검토).
8. 저장·버전 관리
- 스냅샷: 특정 시점의 Yjs 상태를 서버에서 스냅샷으로 저장하고 복구 API를 제공합니다.
- 내보내기: 프로즈미러 JSON이나 HTML로 변환하여 백업/검색 인덱싱에 활용합니다.
- 변경 이력: UI에서는 문서 히스토리 타임라인만 제공하고, 실제 undo/redo는 CRDT 동기화에 맡기는 패턴이 안정적입니다.
9. 테스트·운영 체크
- 동시 입력 테스트: 2개 브라우저에서 같은 문장을 같은 위치에 입력해 충돌 없는지 확인합니다.
- 네트워크 토글: 오프라인 후 재연결 시 로컬 변경이 정상 병합되는지 확인합니다.
- 권한 우회 방지: 잘못된 토큰/없는 룸으로 접속 시 서버가 차단하는지 확인합니다.
- 메모리/파일 핸들: 문서가 많은 환경에서 서버 메모리와 파일 디스크립터 누수 여부를 모니터링합니다.
10. 마무리
React에서 실시간 협업 에디터는 Yjs + y-websocket + TipTap 조합으로 빠르게 구현할 수 있습니다. 최소 기능(MVP)은 서버(y-websocket + 영속화)와 클라이언트(Collaboration, Cursor, Awareness)만으로 충분하며, 이후 권한, 오프라인, 확장성, 스냅샷을 단계적으로 추가하면 안정적인 협업 경험을 제공할 수 있습니다.
'React' 카테고리의 다른 글
| React 앱에서 폴더 구조 자동화와 절대 경로(alias) 설정하기 (0) | 2026.07.27 |
|---|---|
| React 앱에서 브라우저 Wake Lock API 활용하기 (0) | 2026.07.26 |
| React에서 OpenAPI 기반 API 클라이언트 자동 생성하기 (1) | 2026.07.26 |
| React 앱에서 OTP(일회용 비밀번호) 입력 UI 구현하기 (0) | 2026.07.24 |
| React에서 Canvas 기반 이미지 크롭 편집기 구현하기 (0) | 2026.07.24 |