🔄 Realtime Collaboration Stack
Figma, Notion 같은 실시간 협업 앱 구축을 위한 기술 스택 - WebSocket, CRDT, WebRTC로 만드는 동시 편집 시스템
🔄 Realtime Collaboration Stack
여러 사용자가 동시에 작업할 수 있는 실시간 협업 애플리케이션 구축
"Build the next Figma, Notion, or Google Docs" - 실시간 동기화의 모든 것
🎯 이 스택이 적합한 경우
✅ 추천하는 경우
- 동시 편집이 핵심 기능 (문서, 디자인, 코드)
- 실시간 커서와 presence 표시 필요
- 오프라인 지원과 충돌 해결 필수
- 낮은 지연시간 (< 100ms) 요구사항
- 협업이 제품의 핵심 가치
❌ 다른 스택을 고려해야 할 경우
- 단순 채팅만 필요 → Firebase Realtime DB
- 일방향 스트리밍 → WebRTC + Media Server
- 턴제 협업 → 일반 CRUD + 알림
🧩 핵심 구성요소
실시간 동기화 엔진
// Yjs + Socket.io 기반 협업 엔진
import * as Y from 'yjs';
import { Server } from 'socket.io';
import { bindSocketIO } from 'y-socket.io';
import Redis from 'ioredis';
class CollaborationServer {
private docs = new Map<string, Y.Doc>();
private redis = new Redis.Cluster([...]);
constructor(io: Server) {
io.on('connection', (socket) => {
socket.on('join-document', async (docId: string) => {
// 문서 로드 또는 생성
const doc = await this.loadOrCreateDocument(docId);
// 방 참가
socket.join(docId);
// 현재 상태 동기화
const state = Y.encodeStateAsUpdate(doc);
socket.emit('sync-initial', state);
// Presence 업데이트
this.updatePresence(docId, socket.id, 'joined');
// Yjs 바인딩
bindSocketIO(doc, socket, {
gcEnabled: true,
pingInterval: 30000
});
});
// 커서 위치 브로드캐스트
socket.on('cursor-position', (data) => {
socket.to(data.docId).emit('remote-cursor', {
userId: socket.userId,
position: data.position,
selection: data.selection
});
});
});
}
private async loadOrCreateDocument(docId: string): Promise<Y.Doc> {
if (this.docs.has(docId)) {
return this.docs.get(docId)!;
}
const doc = new Y.Doc();
// Redis에서 문서 상태 로드
const savedState = await this.redis.get(`doc:${docId}`);
if (savedState) {
Y.applyUpdate(doc, Buffer.from(savedState, 'base64'));
}
// 변경사항 자동 저장
doc.on('update', async (update) => {
const state = Y.encodeStateAsUpdate(doc);
await this.redis.set(
`doc:${docId}`,
Buffer.from(state).toString('base64'),
'EX', 86400 // 24시간 TTL
);
});
this.docs.set(docId, doc);
return doc;
}
}
CRDT 기반 데이터 구조
// 협업 가능한 데이터 타입 정의
interface CollaborativeDocument {
// Yjs 타입 매핑
content: Y.Text; // 텍스트 편집기
shapes: Y.Array<Shape>; // 도형 (Figma-like)
comments: Y.Map<Comment>; // 댓글
metadata: Y.Map<any>; // 메타데이터
}
// React 컴포넌트에서 사용
function CollaborativeEditor({ documentId }: Props) {
const [doc, setDoc] = useState<Y.Doc>();
const [provider, setProvider] = useState<WebsocketProvider>();
const [awareness, setAwareness] = useState<Awareness>();
useEffect(() => {
// Yjs 문서 초기화
const ydoc = new Y.Doc();
const wsProvider = new WebsocketProvider(
"ws://localhost:1234",
documentId,
ydoc
);
// Awareness (presence) 설정
const awareness = wsProvider.awareness;
awareness.setLocalState({
user: currentUser,
cursor: null,
selection: null,
});
// 원격 사용자 추적
awareness.on("change", (changes) => {
const users = Array.from(awareness.getStates().entries()).map(
([clientId, state]) => ({
clientId,
...state.user,
cursor: state.cursor,
})
);
setRemoteUsers(users);
});
setDoc(ydoc);
setProvider(wsProvider);
setAwareness(awareness);
return () => {
wsProvider.destroy();
};
}, [documentId]);
return (
<div className="editor-container">
<RemoteCursors users={remoteUsers} />
<Editor doc={doc} awareness={awareness} />
</div>
);
}
WebRTC P2P 레이어
// P2P 연결로 서버 부하 감소
class P2PNetwork {
private peers = new Map<string, RTCPeerConnection>();
private dataChannels = new Map<string, RTCDataChannel>();
async connectToPeer(peerId: string, offer?: RTCSessionDescriptionInit) {
const pc = new RTCPeerConnection({
iceServers: [
{
urls: ["stun:stun.l.google.com:19302"],
},
],
});
// 데이터 채널 생성
if (!offer) {
const channel = pc.createDataChannel("yjs", {
ordered: true,
maxRetransmits: 3,
});
this.setupDataChannel(peerId, channel);
}
pc.ondatachannel = (event) => {
this.setupDataChannel(peerId, event.channel);
};
// ICE 후보 교환
pc.onicecandidate = (event) => {
if (event.candidate) {
this.signaling.send({
type: "ice-candidate",
target: peerId,
candidate: event.candidate,
});
}
};
this.peers.set(peerId, pc);
// Offer/Answer 교환
if (offer) {
await pc.setRemoteDescription(offer);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
return answer;
} else {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
return offer;
}
}
private setupDataChannel(peerId: string, channel: RTCDataChannel) {
channel.onopen = () => {
console.log(`P2P connection established with ${peerId}`);
// Yjs 동기화 시작
this.syncWithPeer(peerId);
};
channel.onmessage = (event) => {
// Yjs 업데이트 적용
const update = new Uint8Array(event.data);
Y.applyUpdate(this.doc, update);
};
this.dataChannels.set(peerId, channel);
}
}
오프라인 지원 & 충돌 해결
// IndexedDB를 사용한 로컬 영속성
class OfflineSync {
private db: IDBDatabase;
private syncQueue: SyncOperation[] = [];
async saveLocal(docId: string, update: Uint8Array) {
const tx = this.db.transaction(['documents'], 'readwrite');
const store = tx.objectStore('documents');
// 현재 상태 가져오기
const existing = await store.get(docId);
const doc = new Y.Doc();
if (existing) {
Y.applyUpdate(doc, existing.state);
}
// 업데이트 적용
Y.applyUpdate(doc, update);
// 저장
await store.put({
id: docId,
state: Y.encodeStateAsUpdate(doc),
lastModified: Date.now(),
pendingSync: !navigator.onLine
});
}
async syncWhenOnline() {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-documents');
}
}
// Service Worker에서 실행
self.addEventListener('sync', async (event) => {
if (event.tag === 'sync-documents') {
event.waitUntil(this.syncPendingDocuments());
}
});
}
💰 인프라 비용 분석
| 컴포넌트 | 최소 구성 | 표준 구성 | 대규모 | | ------------------ | ----------------------- | ------------------------- | -------------------------- | | 서버 (Node.js) | $40/월(2 vCPU, 4GB) | $160/월(4 vCPU, 16GB) | $640/월(16 vCPU, 64GB) | | Redis Cluster | $50/월 | $200/월 | $800/월 | | PostgreSQL | $25/월 | $100/월 | $400/월 | | 로드밸런서 | $20/월 | $20/월 | $100/월 | | CDN (100GB) | $10/월 | $50/월 | $200/월 | | TURN 서버 | $20/월 | $80/월 | $320/월 | | 총계 | ~$165/월 | ~$610/월 | ~$2,460/월 |
동시 접속자 수용량
- 최소: ~1,000명
- 표준: ~10,000명
- 대규모: ~50,000명
🚀 구현 로드맵
Phase 1: 기본 실시간 동기화 (2주)
# 프로젝트 설정
mkdir realtime-collab && cd realtime-collab
npm init -y
npm install express socket.io yjs y-websocket y-indexeddb
npm install -D typescript @types/node
# 기본 서버 구조
src/
├── server/
│ ├── index.ts # Express + Socket.io
│ ├── collaboration.ts # Yjs 서버
│ └── persistence.ts # Redis 저장
├── client/
│ ├── hooks/ # React 훅
│ ├── components/ # UI 컴포넌트
│ └── lib/ # Yjs 클라이언트
└── shared/
└── types.ts # 공통 타입
Phase 2: Presence & Awareness (1주)
// 실시간 커서 구현
interface UserPresence {
id: string;
name: string;
color: string;
cursor: { x: number; y: number } | null;
selection: Range | null;
lastSeen: number;
}
function usePresence(awareness: Awareness) {
const [users, setUsers] = useState<Map<number, UserPresence>>();
useEffect(() => {
const updateUsers = () => {
const states = awareness.getStates();
const userMap = new Map();
states.forEach((state, clientId) => {
if (state.user) {
userMap.set(clientId, {
...state.user,
cursor: state.cursor,
selection: state.selection,
lastSeen: Date.now(),
});
}
});
setUsers(userMap);
};
awareness.on("change", updateUsers);
updateUsers();
// 커서 위치 업데이트
const updateCursor = (e: MouseEvent) => {
awareness.setLocalStateField("cursor", {
x: e.clientX,
y: e.clientY,
});
};
document.addEventListener("mousemove", updateCursor);
return () => {
document.removeEventListener("mousemove", updateCursor);
};
}, [awareness]);
return users;
}
Phase 3: 성능 최적화 (2주)
// 1. 디바운싱 & 스로틀링
const debouncedUpdate = useMemo(
() =>
debounce((update: Uint8Array) => {
provider.ws.send(update);
}, 100),
[provider],
);
// 2. 선택적 동기화
class SelectiveSync {
private visibleNodes = new Set<string>();
syncVisibleOnly(doc: Y.Doc, viewport: Viewport) {
const nodes = this.getNodesInViewport(doc, viewport);
// 뷰포트에 들어온 노드만 동기화
nodes.forEach((node) => {
if (!this.visibleNodes.has(node.id)) {
this.subscribeToNode(node);
this.visibleNodes.add(node.id);
}
});
// 뷰포트 밖 노드 구독 해제
this.visibleNodes.forEach((nodeId) => {
if (!nodes.find((n) => n.id === nodeId)) {
this.unsubscribeFromNode(nodeId);
this.visibleNodes.delete(nodeId);
}
});
}
}
// 3. 압축 & 배치 처리
const compressionMiddleware = (socket: Socket) => {
const updateQueue: Uint8Array[] = [];
setInterval(() => {
if (updateQueue.length > 0) {
const compressed = pako.deflate(Y.mergeUpdates(updateQueue));
socket.emit("batch-update", compressed);
updateQueue.length = 0;
}
}, 50); // 50ms 배치
};
Phase 4: P2P & 확장성 (2-3주)
// Hybrid 아키텍처: 서버 + P2P
class HybridSync {
private serverConnection: WebSocket;
private p2pNetwork: P2PNetwork;
private syncStrategy: "server" | "p2p" | "hybrid" = "hybrid";
async initialize() {
// 서버 연결
this.serverConnection = new WebSocket("wss://server.com");
// P2P 네트워크 구축
this.p2pNetwork = new P2PNetwork();
// 룸의 피어 목록 받기
const peers = await this.getRoomPeers();
// 가까운 피어와 P2P 연결
const nearbyPeers = await this.findNearbyPeers(peers);
for (const peer of nearbyPeers) {
await this.p2pNetwork.connectToPeer(peer.id);
}
}
// 동기화 전략 선택
sync(update: Uint8Array) {
switch (this.syncStrategy) {
case "p2p":
// P2P로만 동기화 (서버 부하 최소)
this.p2pNetwork.broadcast(update);
break;
case "server":
// 서버로만 동기화 (안정성 최대)
this.serverConnection.send(update);
break;
case "hybrid":
// P2P 우선, 서버는 백업
this.p2pNetwork.broadcast(update);
this.debouncedServerSync(update);
break;
}
}
}
🏗️ 아키텍처 패턴
이벤트 소싱 + CQRS
// 모든 변경사항을 이벤트로 저장
interface CollaborationEvent {
id: string;
type: "INSERT" | "DELETE" | "FORMAT" | "MOVE";
userId: string;
timestamp: number;
data: any;
position: number;
}
class EventStore {
async appendEvent(docId: string, event: CollaborationEvent) {
await this.db.query(
`INSERT INTO events (doc_id, event_id, type, user_id, data, timestamp)
VALUES ($1, $2, $3, $4, $5, $6)`,
[docId, event.id, event.type, event.userId, event.data, event.timestamp],
);
// 읽기 모델 업데이트 (비동기)
this.updateReadModel(docId, event);
}
async getHistory(docId: string, from?: Date, to?: Date) {
const events = await this.db.query(
`SELECT * FROM events
WHERE doc_id = $1 AND timestamp BETWEEN $2 AND $3
ORDER BY timestamp`,
[docId, from, to],
);
return events.rows;
}
}
확장 가능한 아키텍처
┌─────────────┐
│ Load Balancer│
└──────┬──────┘
│
┌──────────────┴──────────────┐
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ Node.js │ │ Node.js │
│ Socket.io │◄────────────►│ Socket.io │
└──────┬──────┘ Redis └──────┬──────┘
│ Pub/Sub │
└──────────┬──────────────────┘
│
┌──────▼──────┐
│Redis Cluster│
│ (Session) │
└──────┬──────┘
│
┌──────▼──────┐
│ PostgreSQL │
│(Persistence)│
└─────────────┘
🔄 기술 대안
실시간 동기화 라이브러리
| 라이브러리 | 장점 | 단점 | 적합한 경우 | | ------------- | -------------------- | ------------- | ------------- | | Yjs | CRDT 기반, 확장 가능 | 학습 곡선 | 복잡한 협업 | | ShareJS | 간단한 API | 유지보수 중단 | 레거시 | | OT.js | OT 알고리즘 | 복잡한 구현 | 텍스트 편집기 | | Automerge | 강력한 CRDT | 무거움 | 오프라인 우선 |
백엔드 스택 옵션
# Node.js 스택 (추천)
backend:
language: TypeScript
framework: Express + Socket.io
crdt: Yjs
persistence: Redis + PostgreSQL
# Elixir 스택 (고성능)
backend:
language: Elixir
framework: Phoenix
channels: Phoenix Channels
persistence: PostgreSQL + Mnesia
# Go 스택 (확장성)
backend:
language: Go
framework: Gin + Melody
crdt: Custom implementation
persistence: Redis + CockroachDB
🏆 성공 사례
Figma
- 기술: C++ (렌더링) + WebAssembly + CRDTs
- 특징: 브라우저에서 실행되는 디자인 도구
- 성과: 실시간 협업의 표준
Notion
- 기술: React + WebSocket + 자체 동기화 엔진
- 특징: 블록 기반 편집기
- 규모: 수백만 동시 사용자
Miro
- 기술: Canvas API + WebSocket + Redis
- 특징: 무한 캔버스 화이트보드
- 성능: 100명+ 동시 협업
📚 필수 학습 자료
CRDT 이론
실시간 시스템
사례 연구
💬 실무자 조언
"CRDT vs OT 선택이 중요해요. 텍스트는 OT가 간단하지만, 복잡한 데이터는 CRDT가 답입니다." - @realtime_expert
"처음부터 P2P 하지 마세요. 서버 기반으로 안정화시킨 다음 P2P로 최적화하는 게 맞습니다." - @collaboration_dev
"오프라인 지원은 처음부터 설계하세요. 나중에 추가하면 아키텍처 다시 짜야 해요." - @offline_first
"성능보다 중요한 건 일관성입니다. 데이터 손실이나 충돌은 사용자 신뢰를 잃는 지름길이에요." - @data_integrity_advocate
⚡ 성능 최적화 팁
렌더링 최적화
// React 18 Concurrent Features 활용
function CollaborativeCanvas() {
const [isPending, startTransition] = useTransition();
const handleRemoteUpdate = useCallback((update) => {
// 낮은 우선순위로 렌더링
startTransition(() => {
applyUpdate(update);
});
}, []);
// Virtual Scrolling for 대용량 문서
return (
<VirtualList
height={800}
itemCount={items.length}
itemSize={50}
overscan={5}
>
{({ index, style }) => (
<div style={style}>
<CollaborativeItem item={items[index]} />
</div>
)}
</VirtualList>
);
}
네트워크 최적화
// 적응형 동기화 주기
class AdaptiveSync {
private latency = 0;
private syncInterval = 100;
adjustSyncRate() {
if (this.latency < 50) {
this.syncInterval = 50; // 빠른 네트워크
} else if (this.latency < 150) {
this.syncInterval = 100; // 보통
} else {
this.syncInterval = 200; // 느린 네트워크
}
}
}
🚨 주의사항
보안
- WebSocket에 인증 미들웨어 필수
- XSS 방지를 위한 입력 검증
- Rate limiting으로 DoS 방어
- E2E 암호화 고려 (민감한 데이터)
확장성
- Sticky Session 또는 Redis Adapter
- 수평 확장을 위한 상태 분리
- 마이크로서비스로 기능 분리
- 지역별 엣지 서버 배포
마지막 업데이트: 2025-01-28
기여하기: GitHub에서 편집