🔄 Realtime Collaboration Stack

21 min read
nodejssocketioredispostgresqlyjswebrtcreacttypescriptdockernginxcloudflarerealtimecollaborationcrdtwebsocket

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에서 편집

Found this helpful? Share it with others!
Tweet

🔗 Related Content

You might also be interested in these articles

🏢 company

🏢 Notion 기술 스택 분석

노션이 수천만 사용자에게 All-in-One 워크스페이스를 제공하는 기술 스택 심층 분석 - 블록 기반 에디터, 실시간 협업, 그리고 확장 가능한 데이터베이스

24 min read
notion, typescript +11
Read more
🏢 company

🏢 Figma 기술 스택 분석

피그마가 브라우저에서 실시간 협업 디자인 툴을 구현한 기술 스택 심층 분석 - C++, WebAssembly, WebGL로 만든 차세대 디자인 플랫폼

22 min read
figma, c++ +11
Read more
🏢 company

🏢 Canva 기술 스택 분석

캔바가 1억 명 이상의 사용자에게 쉽고 강력한 디자인 도구를 제공하는 기술 스택 심층 분석 - Java, TypeScript, AWS로 구축한 대규모 디자인 플랫폼

32 min read
canva, java +10
Read more
🏗️ stack

🤖 AI-Powered App Stack

프로덕션 레벨 AI 애플리케이션 구축을 위한 검증된 기술 스택 - LangChain, FastAPI, Vector DB로 RAG 시스템 구현

13 min read
python, fastapi +13
Read more
🏢 company

🏢 Discord 기술 스택 분석

디스코드가 전 세계 1.5억 명에게 실시간 음성/비디오 통화와 채팅을 제공하는 기술 스택 심층 분석 - Elixir, Rust, WebRTC로 구축한 차세대 커뮤니케이션 플랫폼

24 min read
discord, elixir +12
Read more

Found this helpful?

Help us improve this content by contributing on GitHub or sharing your feedback with the community.