티스토리 뷰

Plate.js, Yjs, WebRTC를 사용하여 구글 독스 같은 실시간 협업 에디터를 만들어봅니다.

프로젝트 소개

구글 독스처럼 여러 사용자가 동시에 문서를 편집할 수 있는 실시간 협업 에디터를 만들어봤습니다. 특별한 점은 서버에 부하를 주지 않는 P2P 방식으로 구현했다는 것입니다.

주요 특징

  • 실시간 동기화: 타이핑하는 순간 다른 사용자에게 반영
  • 다중 커서: 누가 어디를 편집하고 있는지 실시간으로 표시
  • P2P 통신: WebRTC를 사용해 서버를 거치지 않고 직접 통신
  • 충돌 해결: CRDT(Yjs)로 동시 편집 시 자동 병합
  • 리치 에디터: 볼드, 이탤릭, 코드 블록 등 다양한 포맷 지원

기술 스택

프론트엔드

  • Next.js 16 - React 프레임워크
  • Plate.js - 강력한 리치 텍스트 에디터 프레임워크
  • Yjs - CRDT 기반 분산 데이터 구조 (충돌 없는 동기화)
  • y-webrtc - Yjs를 WebRTC로 동기화하는 라이브러리
  • TypeScript - 타입 안정성

백엔드 (최소한의 서버)

  • WebSocket - 시그널링 서버 (연결 중개만)
  • Coturn - TURN 서버 (방화벽 환경에서 P2P 중계)

아키텍처

전체 구조도

┌─────────────┐                    ┌─────────────┐
│  사용자 A   │                    │  사용자 B   │
│             │                    │             │
│ ┌─────────┐ │                    │ ┌─────────┐ │
│ │ Browser │ │                    │ │ Browser │ │
│ │         │ │                    │ │         │ │
│ │ Plate.js│ │                    │ │ Plate.js│ │
│ │   +     │ │                    │ │   +     │ │
│ │  Yjs    │ │                    │ │  Yjs    │ │
│ └─────────┘ │                    │ └─────────┘ │
└──────┬──────┘                    └──────┬──────┘
       │                                  │
       │ ①WebSocket (시그널링만)          │
       │                                  │
       └────────►┌──────────────┐◄────────┘
                 │ Signaling    │
                 │ Server       │
                 │ (WebSocket)  │
                 └──────────────┘

       ② WebRTC P2P (실제 데이터 전송)
       ◄─────────────────────────────────►

데이터 흐름

  1. 초기 연결 단계 (WebSocket 사용)
    • 사용자들이 같은 roomName으로 시그널링 서버에 접속
    • WebRTC 연결에 필요한 메타데이터 교환 (SDP, ICE candidates)
  2. P2P 연결 수립 (WebRTC)
    • STUN 서버를 통해 각 클라이언트의 공인 IP 확인
    • 브라우저끼리 직접 연결 시도
    • 연결 실패 시 TURN 서버를 통해 중계
  3. 실시간 동기화 (Yjs over WebRTC)
    • 에디터 변경사항을 Yjs로 CRDT 업데이트 생성
    • WebRTC P2P 채널로 다른 사용자에게 직접 전송
    • 각 클라이언트가 독립적으로 충돌 해결

왜 이 구조를 선택했나?

기존 방식 (중앙 서버)

[사용자] → [서버] → [사용자]
        ↑ 모든 데이터가 서버 경유
        ↑ 서버 부하 증가
        ↑ 지연 시간 증가

P2P 방식 (WebRTC)

[사용자] ←─────────→ [사용자]
         직접 통신
         서버 부하 ↓
         지연 시간 ↓

장점:

  • 서버 대역폭 절약 (데이터가 서버를 거치지 않음)
  • 낮은 지연시간 (직접 통신)
  • 확장성 (사용자가 늘어도 서버 부하 동일)

단점:

  • 방화벽/NAT 환경에서 연결 어려움 → TURN 서버로 해결
  • 동시 접속자 수 제한 (일반적으로 10명 이하 권장)

환경 설정 (Mac 기준)

1. 프로젝트 생성

# Next.js 프로젝트 생성
npx create-next-app@latest rtc-editor
cd rtc-editor

# pnpm 사용 (선호하는 패키지 매니저 사용 가능)
pnpm install

2. 의존성 패키지 설치

필수 패키지 (협업 기능)

# 에디터 프레임워크
pnpm add platejs

# Yjs 및 WebRTC (실시간 협업)
pnpm add @platejs/yjs yjs y-webrtc @slate-yjs/react

# 시그널링 서버용
pnpm add -D ws @types/ws

선택 패키지 (에디터 기능 확장)

# 기본 노드 (제목, 문단, 리스트 등)
pnpm add @platejs/basic-nodes @platejs/basic-styles

# 추가 기능 (필요한 것만 선택)
pnpm add @platejs/code-block  # 코드 블록
pnpm add @platejs/list         # 순서/비순서 리스트
pnpm add @platejs/table        # 테이블
pnpm add @platejs/link         # 링크
pnpm add @platejs/autoformat   # 마크다운 자동 변환

# UI 컴포넌트 (툴바, 드롭다운 등)
pnpm add @radix-ui/react-toolbar @radix-ui/react-tooltip
pnpm add lucide-react clsx tailwind-merge

3. Coturn 설치 (TURN 서버)

외부에서 ip 로 접근해서 하기 위한 서버, STUN 으로 외부 서버로 가능하면 STUN 을 쓰셔도 좋습니다.(내부망이라던지, 방화벽 이슈가 있을 수 있어서 TURN 을 사용하여 테스트 진행)

# Homebrew로 설치
brew install coturn

# 설치 확인
turnserver --help

4. 환경 변수 설정

.env.local 파일 생성:

# WebSocket 시그널링 서버 주소
NEXT_PUBLIC_SIGNALING_SERVER=ws://localhost:4444

# TURN 서버 설정 (선택사항)
NEXT_PUBLIC_TURN_SERVER_URL=
NEXT_PUBLIC_TURN_USERNAME=
NEXT_PUBLIC_TURN_PASSWORD=

Tip: 로컬 테스트만 한다면 TURN 서버는 생략 가능합니다.

핵심 코드 구현

1. Plate Editor 설정

// app/editor/components/plate-editor.tsx
'use client'

import { useEffect, useState } from 'react'
import { YjsPlugin } from '@platejs/yjs/react'
import { Plate, usePlateEditor } from 'platejs/react'

const PlateEditor = ({ roomName = 'default-room' }) => {
  const [isReady, setIsReady] = useState(false)

  const editor = usePlateEditor({
    plugins: [
      // 기본 노드 플러그인들
      ...BasicNodesKit,

      // Yjs + WebRTC 플러그인
      YjsPlugin.configure({
        options: {
          // 사용자 커서 정보
          cursors: {
            data: {
              name: '사용자 이름',
              color: '#FF6B6B'
            }
          },

          // WebRTC 프로바이더 설정
          providers: [{
            type: 'webrtc',
            options: {
              roomName,

              // 시그널링 서버
              signaling: [
                process.env.NEXT_PUBLIC_SIGNALING_SERVER ||
                'ws://localhost:4444'
              ],

              // 최대 동시 연결 수 (10명까지)
              maxConns: 9,

              // WebRTC 설정
              peerOpts: {
                config: {
                  iceServers: [
                    // STUN 서버 (공인 IP 확인)
                    { urls: 'stun:stun.l.google.com:19302' },
                    { urls: 'stun:stun1.l.google.com:19302' },

                    // TURN 서버 (중계)
                    {
                      urls: process.env.NEXT_PUBLIC_TURN_SERVER_URL,
                      username: process.env.NEXT_PUBLIC_TURN_USERNAME,
                      credential: process.env.NEXT_PUBLIC_TURN_PASSWORD
                    }
                  ]
                }
              }
            }
          }],

          // 이벤트 핸들러
          onConnect: ({ type }) => {
            console.log(`[${type}] 연결됨: ${roomName}`)
          },
          onSyncChange: ({ isSynced }) => {
            if (isSynced) setIsReady(true)
          }
        }
      })
    ]
  }, [roomName])

  // Yjs 초기화
  useEffect(() => {
    const yjsApi = editor.getApi(YjsPlugin)
    yjsApi.yjs.init({
      id: roomName,
      value: [{ type: 'p', children: [{ text: '시작!' }] }]
    })

    return () => {
      yjsApi.yjs.destroy()
    }
  }, [editor, roomName])

  return (
    <Plate editor={editor}>
      <Editor placeholder="여기에 입력하세요..." />
    </Plate>
  )
}

2. 원격 커서 표시

// app/editor/components/ui/remote-cursor-overlay.tsx
import { useRemoteCursorOverlayPositions } from '@platejs/yjs'

export const RemoteCursorOverlay = () => {
  const cursors = useRemoteCursorOverlayPositions()

  return (
    <>
      {cursors.map((cursor) => (
        <div
          key={cursor.clientId}
          style={{
            position: 'absolute',
            left: cursor.left,
            top: cursor.top,
            width: cursor.width,
            height: cursor.height,
            borderLeft: `2px solid ${cursor.data.color}`,
            pointerEvents: 'none'
          }}
        >
          <div
            style={{
              position: 'absolute',
              top: -20,
              left: -1,
              backgroundColor: cursor.data.color,
              color: 'white',
              padding: '2px 6px',
              borderRadius: '4px',
              fontSize: '12px',
              whiteSpace: 'nowrap'
            }}
          >
            {cursor.data.name}
          </div>
        </div>
      ))}
    </>
  )
}

3. 연결 상태 표시

// app/editor/components/ui/connection-status.tsx
import { YjsPlugin } from '@platejs/yjs/react'
import { useEditorPlugin } from 'platejs/react'

export const ConnectionStatus = ({ userName, userColor }) => {
  const { useOption } = useEditorPlugin(YjsPlugin)
  const status = useOption('status')
  const peers = useOption('peers')

  return (
    <div className="flex items-center gap-2">
      {/* 현재 사용자 */}
      <div className="flex items-center gap-2">
        <div
          className="w-3 h-3 rounded-full"
          style={{ backgroundColor: userColor }}
        />
        <span>{userName} (나)</span>
      </div>

      {/* 온라인 사용자들 */}
      {peers.map((peer) => (
        <div key={peer.id} className="flex items-center gap-2">
          <div
            className="w-3 h-3 rounded-full"
            style={{ backgroundColor: peer.data.color }}
          />
          <span>{peer.data.name}</span>
        </div>
      ))}

      {/* 연결 상태 */}
      <div className={`
        px-2 py-1 rounded text-xs
        ${status === 'connected' ? 'bg-green-500' : 'bg-gray-500'}
      `}>
        {status === 'connected' ? '연결됨' : '연결 중...'}
      </div>
    </div>
  )
}

시그널링 서버 구현

WebRTC P2P 연결을 위해서는 초기 연결 정보를 교환할 시그널링 서버가 필요합니다.

서버 코드

// server/signaling.cjs
const WebSocket = require('ws')

const PORT = process.env.PORT || 4444
const HOST = '0.0.0.0'

const wss = new WebSocket.Server({ host: HOST, port: PORT })

// room별로 연결된 클라이언트 관리
const topics = new Map()

console.log(`Signaling server running on ${HOST}:${PORT}`)

wss.on('connection', (ws, req) => {
  const clientIp = req.socket.remoteAddress
  console.log(`New connection from ${clientIp}`)

  const subscribedTopics = new Set()

  ws.on('message', (message) => {
    try {
      const msg = JSON.parse(message.toString())

      if (msg.type === 'subscribe') {
        // room 구독
        ;(msg.topics || []).forEach((topic) => {
          if (!topics.has(topic)) {
            topics.set(topic, new Set())
          }
          topics.get(topic).add(ws)
          subscribedTopics.add(topic)
          console.log(
            `Subscribed to "${topic}" (${topics.get(topic).size} peers)`
          )
        })
      } else if (msg.type === 'unsubscribe') {
        // room 구독 해제
        ;(msg.topics || []).forEach((topic) => {
          if (topics.has(topic)) {
            topics.get(topic).delete(ws)
            subscribedTopics.delete(topic)
          }
        })
      } else if (msg.type === 'publish') {
        // 메시지 브로드캐스트 (시그널링 정보 전달)
        const topic = msg.topic
        if (topics.has(topic)) {
          topics.get(topic).forEach((peer) => {
            if (peer !== ws && peer.readyState === WebSocket.OPEN) {
              peer.send(JSON.stringify(msg))
            }
          })
        }
      } else if (msg.type === 'ping') {
        ws.send(JSON.stringify({ type: 'pong' }))
      }
    } catch (err) {
      console.error('Error:', err.message)
    }
  })

  ws.on('close', () => {
    // 연결 종료 시 모든 room에서 제거
    subscribedTopics.forEach((topic) => {
      if (topics.has(topic)) {
        topics.get(topic).delete(ws)
        if (topics.get(topic).size === 0) {
          topics.delete(topic)
        }
      }
    })
    console.log(`Disconnected from ${clientIp}`)
  })
})

process.on('SIGINT', () => {
  console.log('\nShutting down...')
  wss.close(() => process.exit(0))
})

package.json 스크립트 추가

{
  "scripts": {
    "dev": "next dev -p 4000",
    "signaling": "node server/signaling.cjs"
  }
}

시그널링 서버의 역할

  1. Room 관리: 같은 roomName을 사용하는 클라이언트들을 그룹화
  2. 메시지 중계: WebRTC 연결에 필요한 SDP, ICE candidates 교환
  3. 경량 통신: 실제 에디터 데이터는 전달하지 않음 (오직 연결 정보만)

중요: 시그널링 서버는 WebRTC 연결이 수립된 후에는 거의 사용되지 않습니다. 모든 데이터는 P2P로 전송됩니다.

TURN 서버 구축

TURN 서버가 필요한 이유

WebRTC는 기본적으로 P2P 직접 연결을 시도하지만, 다음과 같은 환경에서는 실패할 수 있습니다:

  • 방화벽이 UDP를 차단하는 경우
  • 기업 네트워크에서 제한적인 NAT 사용
  • Symmetric NAT 환경

이때 TURN 서버가 중계 서버 역할을 합니다.

Coturn 설정

1. 설정 파일 생성

sudo nano /usr/local/etc/turnserver.conf

2. 설정 내용

# /usr/local/etc/turnserver.conf

# 기본 설정
listening-port=3478
fingerprint
lt-cred-mech

# 사용자 인증 (username:password)
user=test:test123
realm=localhost

# 서버 IP (실제 공인 IP로 변경!)
external-ip=YOUR_SERVER_IP
relay-ip=YOUR_SERVER_IP

# 로그
verbose
log-file=/tmp/turn.log

# 네트워크 설정
listening-ip=0.0.0.0

# 릴레이 포트 범위
min-port=49152
max-port=65535

# 보안
no-cli
no-tlsv1
no-tlsv1_1

중요: external-ip를 실제 서버의 공인 IP로 변경하세요!

3. IP 주소 확인

# 내부 IP (로컬 네트워크)
ifconfig | grep "inet "

# 공인 IP (외부 네트워크)
curl ifconfig.me

4. TURN 서버 실행

# 테스트용 (포그라운드 실행)
turnserver -c /usr/local/etc/turnserver.conf

# 프로덕션용 (백그라운드 실행)
sudo turnserver -c /usr/local/etc/turnserver.conf -o

# 로그 확인
tail -f /tmp/turn.log

5. 방화벽 설정

# UDP 포트 열기
sudo ufw allow 3478/udp
sudo ufw allow 3478/tcp
sudo ufw allow 49152:65535/udp

6. TURN 서버 종료

# 프로세스 종료
sudo pkill turnserver

# 확인
ps aux | grep turnserver

TURN 서버 테스트

온라인 도구로 테스트할 수 있습니다:

설정 입력:

TURN URI: turn:YOUR_SERVER_IP:3478
Username: test
Password: test123

"Gather candidates" 클릭 후 relay 타입의 candidate가 나타나면 성공!

실행 및 테스트

1. 시그널링 서버 실행

# 터미널 1
pnpm signaling

출력:

Signaling server running on 0.0.0.0:4444
Access from other devices: ws://YOUR_SERVER_IP:4444

2. (선택) TURN 서버 실행

# 터미널 2
sudo turnserver -c /usr/local/etc/turnserver.conf

3. Next.js 개발 서버 실행

# 터미널 3
pnpm dev

브라우저에서 http://localhost:4000 접속

4. 협업 테스트

로컬 테스트 (같은 컴퓨터)

  1. Chrome에서 http://localhost:4000 접속
  2. Safari에서 http://localhost:4000 접속 (또는 시크릿 모드)
  3. 한쪽 에디터에서 타이핑하면 다른 쪽에서 실시간 반영 확인

네트워크 테스트 (다른 디바이스)

  1. Mac의 IP 주소 확인: ifconfig | grep "inet "
  2. 스마트폰/다른 PC에서 http://YOUR_SERVER_IP:4000 접속
  3. 실시간 협업 확인

5. 디버깅

브라우저 개발자 도구 콘솔에서 다음 로그 확인:

[webrtc] Connected to room: my-room
[webrtc] Synced: true (room: my-room)

연결 실패 시:

[webrtc] Error in room my-room: ...

주요 이슈 해결

P2P 연결이 안 될 때

chrome://webrtc-internals -> 접속해서 디버그

  1. 시그널링 서버가 실행 중인지 확인
  2. 방화벽 설정 확인 (4444 포트)
  3. TURN 서버 로그 확인
  4. 브라우저 콘솔에서 WebRTC 에러 확인

동기화가 느릴 때

  1. WebRTC 연결 상태 확인 (relay 타입이면 느림)
  2. 네트워크 상태 확인
  3. 동시 접속자 수 확인 (10명 이하 권장)

환경 변수가 적용되지 않을 때

# Next.js 서버 재시작 필수
# Ctrl+C로 종료 후
pnpm dev

프로젝트 구조

rtc-editor/
├── app/
│   ├── editor/
│   │   ├── components/
│   │   │   ├── plate-editor.tsx       # 메인 에디터
│   │   │   ├── ui/
│   │   │   │   ├── remote-cursor-overlay.tsx  # 원격 커서
│   │   │   │   └── connection-status.tsx      # 연결 상태
│   │   │   └── plugins/
│   │   │       └── basic-nodes-kit.tsx        # 에디터 플러그인
│   │   └── hooks/
│   │       ├── use-user-data.ts       # 사용자 정보 관리
│   │       └── use-mounted.ts         # SSR 처리
│   ├── lib/
│   │   └── crypto-polyfill.ts         # crypto.subtle 폴리필
│   └── layout.tsx
├── server/
│   └── signaling.cjs                  # WebSocket 시그널링 서버
├── .env.local                         # 환경 변수
├── package.json
└── tsconfig.json

 

핵심 포인트

  1. CRDT (Yjs): 충돌 없는 동시 편집의 핵심
  2. WebRTC: 서버 부하를 줄이는 P2P 통신
  3. 시그널링: 최소한의 서버로 초기 연결만 중개
  4. TURN: 방화벽 환경에서도 안정적인 연결 보장

학습 자료

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/01   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함