본문 바로가기

React

React에서 실시간 협업 에디터 구현하기

실시간 협업 에디터는 동시에 여러 사용자가 같은 문서를 편집할 수 있도록 하는 프런트엔드·백엔드 조합입니다. 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)만으로 충분하며, 이후 권한, 오프라인, 확장성, 스냅샷을 단계적으로 추가하면 안정적인 협업 경험을 제공할 수 있습니다.