ð 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ìì ížì§