티스토리 뷰
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 (실제 데이터 전송)
◄─────────────────────────────────►
데이터 흐름
- 초기 연결 단계 (WebSocket 사용)
- 사용자들이 같은
roomName으로 시그널링 서버에 접속 - WebRTC 연결에 필요한 메타데이터 교환 (SDP, ICE candidates)
- 사용자들이 같은
- P2P 연결 수립 (WebRTC)
- STUN 서버를 통해 각 클라이언트의 공인 IP 확인
- 브라우저끼리 직접 연결 시도
- 연결 실패 시 TURN 서버를 통해 중계
- 실시간 동기화 (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"
}
}
시그널링 서버의 역할
- Room 관리: 같은
roomName을 사용하는 클라이언트들을 그룹화 - 메시지 중계: WebRTC 연결에 필요한 SDP, ICE candidates 교환
- 경량 통신: 실제 에디터 데이터는 전달하지 않음 (오직 연결 정보만)
중요: 시그널링 서버는 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. 협업 테스트
로컬 테스트 (같은 컴퓨터)
- Chrome에서
http://localhost:4000접속 - Safari에서
http://localhost:4000접속 (또는 시크릿 모드) - 한쪽 에디터에서 타이핑하면 다른 쪽에서 실시간 반영 확인
네트워크 테스트 (다른 디바이스)
- Mac의 IP 주소 확인:
ifconfig | grep "inet " - 스마트폰/다른 PC에서
http://YOUR_SERVER_IP:4000접속 - 실시간 협업 확인
5. 디버깅
브라우저 개발자 도구 콘솔에서 다음 로그 확인:
[webrtc] Connected to room: my-room
[webrtc] Synced: true (room: my-room)
연결 실패 시:
[webrtc] Error in room my-room: ...
주요 이슈 해결
P2P 연결이 안 될 때
chrome://webrtc-internals -> 접속해서 디버그
- 시그널링 서버가 실행 중인지 확인
- 방화벽 설정 확인 (4444 포트)
- TURN 서버 로그 확인
- 브라우저 콘솔에서 WebRTC 에러 확인
동기화가 느릴 때
- WebRTC 연결 상태 확인 (relay 타입이면 느림)
- 네트워크 상태 확인
- 동시 접속자 수 확인 (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
핵심 포인트
- CRDT (Yjs): 충돌 없는 동시 편집의 핵심
- WebRTC: 서버 부하를 줄이는 P2P 통신
- 시그널링: 최소한의 서버로 초기 연결만 중개
- TURN: 방화벽 환경에서도 안정적인 연결 보장
학습 자료
'개발..' 카테고리의 다른 글
| shadcn create 출시로 나만의 shadcn 만들기 (0) | 2025.12.18 |
|---|---|
| 멀티 프로젝트를 모노레포로 전환 후기 (0) | 2025.09.03 |
| Nextjs 를 AWS ElasticBeanstalk CI/CD 구축하기 + Parameter Store 사용하기 (0) | 2025.09.02 |
| pnpm 기반으로 github action, Dockerfile 생성하기 (0) | 2024.08.29 |
- Total
- Today
- Yesterday
- 오블완
- 모노레포
- ChatGPT
- NextJS
- 깃허브
- Github Actions
- vue router
- nextjs13
- cors
- React
- 티스토리챌린지
- vue composition api
- Ai
- svelte
- openAI
- 타입스크립트
- 스벨트
- nextjs14
- Zustand
- 서버 to 서버
- Git
- nextjs15
- seo
- Vite
- nodejs
- AWS
- NUXT
- github
- nuxt2
- vscode
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |