실시간 멀티플레이어 편집기를 위한 Y.js CRDT 심층 분석
출처: Dev.to
실시간 협업: OT에서 CRDT로
이 발췌는 실시간 YATA 이중 연결 리스트 시뮬레이터를 포함한 긴 글의 일부입니다 – 두 피어 편집기에 동시에 입력하고, 네트워크 파티션을 토글해 오프라인‑퍼스트 편집을 시뮬레이션한 뒤, Y.js가 실시간으로 병합 충돌을 자동 해결하는 모습을 확인해 보세요. 전체 인터랙티브 버전 읽기 →
OT 병목 현상
수십 년 동안 실시간 협업 소프트웨어를 만들려면 중앙 집중식 서버 오케스트레이터가 필요했습니다.
업계 표준은 운영 변환(Operational Transformation, OT) – 구글 문서를 구동하는 아키텍처였습니다.
| OT 워크플로 |
|---|
| 1️⃣ 모든 로컬 편집은 연산(operation)으로 래핑됩니다. |
| 2️⃣ 연산은 중앙 서버로 전송됩니다. |
| 3️⃣ 서버는 단일 진실 원본(single source of truth)으로서 연산 좌표를 재계산해 충돌을 해결합니다. |
| 4️⃣ 조정된 명령이 모든 클라이언트에 다시 브로드캐스트됩니다. |
문제점
- 서버는 100 % 가동되어야 하며 복잡한 시퀀스‑히스토리 버퍼를 유지해야 합니다.
- 클라이언트가 오프라인이 되면 피어‑투‑피어 동기화가 거의 불가능해져 문서가 조용히 갈라지는 상황이 발생합니다.
CRDTs – 충돌 없는 복제 데이터 타입
CRDT는 협업 방식을 완전히 재구상합니다. 서버가 충돌 우선순위를 결정하는 대신, CRDT 구조는 조정 없이 어떤 순서로든 연산을 병합하도록 수학적으로 설계되어 모든 클라이언트가 동일한 상태로 수렴하도록 보장합니다.
분산된 피어 간에 절대적인 수렴을 달성하려면 데이터 구조가 조인‑세미라티스(join‑semilattice) 를 형성해야 합니다. 실제로 병합 함수는 다음 세 가지 대수적 불변식을 만족해야 합니다:
| 속성 | 수식 | 의미 |
|---|---|---|
| 교환법칙 (Commutativity) | A ⊕ B = B ⊕ A | 연산은 어떤 순서로든 병합될 수 있다 |
| 결합법칙 (Associativity) | (A ⊕ B) ⊕ C = A ⊕ (B ⊕ C) | 그룹화 방식이 결과에 영향을 주지 않는다 |
| 멱등법칙 (Idempotency) | A ⊕ A = A | 동일한 연산을 두 번 받아도 상태가 변하지 않는다 |
핵심 요약: 연산의 어떤 부분집합이라도, 어떤 순서로든, 몇 번이든 병합할 수 있으며 항상 동일한 최종 문서에 도달합니다 – 조정 서버가 전혀 필요 없습니다.
Y.js와 YATA
Y.js는 YATA(Yet Another Transformation Algorithm)를 구현합니다 – 텍스트 편집을 위해 특별히 설계된 고성능 CRDT입니다.
내부 표현
Y.js는 문서를 원시 문자열 배열로 저장하지 않습니다.
대신 Item 블록들의 이중 연결 리스트 를 사용합니다. 각 Item 은 다음을 포함합니다:
| 필드 | 설명 |
|---|---|
id | (clientId, lamportClock) 형태의 고유 튜플 – 모든 피어에서 전역적으로 유일 |
content | 문자 혹은 문자열 청크 |
originLeft | 생성 시 바로 왼쪽에 있던 아이템의 ID |
originRight | 생성 시 바로 오른쪽에 있던 아이템의 ID |
deleted | 삭제 여부를 나타내는 불리언 토ombstone 플래그 |
이 origin 포인터들은 영구적인 공간 앵커 역할을 합니다. 다른 사용자가 동시에 원래 경계 사이에 문자를 삽입하더라도, YATA는 클라이언트‑ID 우선순위를 사용해 동시 삽입을 결정적으로 정렬하므로 모든 복제본이 동일한 순서를 유지합니다.
예시: 동시 삽입
Peer A inserts "X" with originLeft = "SYS:4"
Peer B inserts "Y" with originLeft = "SYS:4"
두 경우 모두 originLeft 가 동일합니다. YATA는 클라이언트 ID를 알파벳 순으로 비교하여 충돌을 해결합니다.
{
// y-websocket handles CRDT sync protocol
setupWSConnection(ws, req, {
// Persistence callbacks – hook into Redis or PostgreSQL here
persistence: {
bindState: async (docName, ydoc) => {
// Load existing document state from Redis
const state = await redisPublisher.get(`ydoc:${docName}`);
if (state) Y.applyUpdate(ydoc, Buffer.from(state, "base64"));
},
writeState: async (docName, ydoc) => {
// Persist document state on every update
const state = Y.encodeStateAsUpdate(ydoc);
await redisPublisher.set(
`ydoc:${docName}`,
Buffer.from(state).toString("base64")
);
}
}
});
});
Redis Pub/Sub을 사용하면, 동일한 문서 방에 여러 WebSocket 서버 인스턴스가 참여할 수 있습니다. Server A에 도착한 업데이트는 Redis를 통해 전파되고, Server B와 Server C에 퍼져 모든 연결된 피어—서버가 어디에 있든—가 동기화된 상태를 유지합니다.
인식(Awareness) 및 상태 벡터 동기화
-
Awareness Protocol – Y.js는 일시적인 상태(커서 위치, 사용자 이름, 색상 등)를 CRDT 문서에 영구 저장하지 않고 공유할 수 있는 내장 인식 시스템을 제공합니다. 존재감을 표시할 때 사용하고, 메인
Y.Doc에는 저장하지 마세요. -
State Vector Sync – 재연결 시 클라이언트는 상태 벡터를 교환해 누락된 업데이트만 효율적으로 받아올 수 있어 대역폭 사용량을 크게 줄입니다.
위의 개념과 코드 조각은 Y.js와 같은 최신 CRDT 기반 라이브러리가 오프라인‑퍼스트, 피어‑투‑피어 협업 편집을 단일 장애 지점 없이 어떻게 가능하게 하는지 보여줍니다.
# YATA – Efficient CRDT Collaboration (Excerpt)
**State Vectors** – Use `Y.encodeStateVector` to exchange state vectors and compute only the missing updates, avoiding full‑