Google Docs는 OT를, Figma는 CRDT를 선택했다 — 충돌 해결 방식이 실시간 협업 아키텍처 전반을 결정하는 이유
실시간 협업 기능을 직접 만들어보려 할 때, "두 사람이 동시에 같은 곳을 고치면 어떻게 되지?"라는 질문이 아키텍처 설계 전반을 좌우합니다. 저도 처음엔 "마지막 쓴 사람이 이기면 되는 거 아냐?"라고 생각했는데, 막상 구현해보니 인덱스 하나 차이로 내용이 꼬이는 버그를 잡느라 며칠을 날렸습니다.
Google Docs와 Figma는 둘 다 수백만 명이 동시에 쓰는 협업 도구지만, 충돌을 해결하는 방식은 완전히 다릅니다. 하나는 OT(Operational Transformation), 다른 하나는 CRDT(Conflict-free Replicated Data Type) 아이디어를 채택했죠. 두 접근은 "더 좋은 것이 무엇인가"가 아니라, 제품의 데이터 구조와 인프라 특성에 따라 자연스럽게 갈리는 서로 다른 질문에 대한 답입니다. 오프라인 지원이 필요한가, 비텍스트 구조를 다루는가, 중앙 서버가 이미 모든 연산을 보고 있는가 — 이 판단이 알고리즘 선택을 결정하고, 그 선택이 이후 설계 결정을 줄줄이 끌고 옵니다.
직접 실시간 협업 기능을 구현할 계획이거나, 이미 구현 중인데 왜 예상치 못한 상태 불일치가 생기는지 이해하고 싶다면 도움이 될 내용을 정리해봤습니다.
핵심 개념
충돌이 왜 생기는가 — 인덱스가 달라지는 문제
텍스트 에디터에서 두 사람이 동시에 편집하는 상황을 생각해봅니다.
초기 문서: "hello"
A: 인덱스 5에 "!" 삽입 → "hello!"
B: 인덱스 5에 "?" 삽입 → "hello?"두 연산을 그냥 순서대로 적용하면 어떻게 될까요?
A 먼저 적용: "hello!" (길이 6)
그 다음 B 적용 (인덱스 5 그대로): "hello?!" ← 의도와 다름
기대 결과: "hello!?" 또는 "hello?!"B가 원한 건 "hello 뒤에 ?"를 붙이는 것이었는데, A의 삽입으로 인덱스 5가 이미 "!"로 채워진 상태라 위치가 어긋납니다. 이 어긋남을 어떻게 처리하느냐가 OT와 CRDT가 갈리는 지점입니다.
수렴(convergence)이란
본격적인 설명 전에 자주 등장하는 용어를 하나 짚겠습니다. 수렴(convergence)이란 모든 클라이언트가 결국 동일한 상태에 도달하는 성질입니다. 어떤 순서로 연산이 도착하든, 어떤 클라이언트가 먼저 반영했든, 최종 문서는 모두 같아야 합니다. OT와 CRDT는 이 수렴을 보장하는 방법이 다릅니다.
OT — 서버가 연산을 변환해서 수렴을 만든다
OT의 아이디어는 "A의 연산이 이미 적용된 상태를 감안해서 B의 연산을 변환하자"입니다. 위치를 그대로 쓰되, 다른 연산의 영향을 반영해 조정하는 것이죠.
// 단순화된 OT 변환 함수 예시
function transformInsert(op1: InsertOp, op2: InsertOp): InsertOp {
if (op2.position > op1.position) {
return { ...op2, position: op2.position + op1.text.length };
} else if (op2.position === op1.position) {
// 같은 위치 충돌: 실제 OT 구현에서는 서버 수신 순서나
// 논리 시계(logical clock)로 우선순위를 결정하며,
// 아래는 설명을 위해 clientId로 단순화한 예시입니다
return op1.clientId < op2.clientId
? { ...op2, position: op2.position + op1.text.length }
: op2;
}
return op2;
}서버에서의 흐름은 이렇습니다.
// Node.js + ShareDB 스타일 (단순화)
server.on('submit', (agent, op) => {
const pendingOps = db.getOpsSince(op.v); // 클라이언트가 모르는 이후 연산들
const transformed = pendingOps.reduce(
(acc, serverOp) => transform(acc, serverOp),
op
);
db.commit(transformed); // 변환된 연산 저장
broadcast(transformed); // 모든 클라이언트에 배포
});핵심: OT에서 서버는 연산의 이력을 보관하고, 새 연산이 들어오면 그 사이에 적용된 연산들을 기준으로 변환한 뒤 배포합니다. 순서의 최종 결정권은 항상 서버에 있습니다.
OT 변환 함수가 왜 구현하기 까다로운지 이해하려면 TP1·TP2 조건을 알아야 합니다. TP1은 두 연산을 어떤 순서로 변환해도 같은 결과가 나와야 한다는 조건, TP2는 여러 연산이 조합될 때도 일관성이 유지돼야 한다는 조건입니다. 텍스트 삽입/삭제 두 가지만으로도 이 조건을 완전히 충족하는 변환 함수 구현이 어렵고, ACM CSCW 2020 연구에서도 기존 OT 알고리즘 구현에서 이 조건을 위반하는 결함이 발견됐을 정도입니다.
CRDT — 자료구조가 병합 규칙을 내장한다
CRDT는 접근 자체가 다릅니다. "위치"라는 개념을 버리고, 각 문자에 고유한 ID를 부여합니다.
// CRDT (RGA, Replicated Growable Array 스타일) 문자 삽입
interface CRDTChar {
id: string; // 고유 식별자 (예: "clientA:3")
value: string;
afterId: string | null; // "어떤 문자 뒤에 온다"는 관계
}
// "hello"를 CRDT로 표현
const doc: CRDTChar[] = [
{ id: "s:1", value: "h", afterId: null },
{ id: "s:2", value: "e", afterId: "s:1" },
{ id: "s:3", value: "l", afterId: "s:2" },
{ id: "s:4", value: "l", afterId: "s:3" },
{ id: "s:5", value: "o", afterId: "s:4" },
];
// A가 "!" 삽입: "s:5 뒤에 삽입" (id: "A:6")
// B가 "?" 삽입: "s:5 뒤에 삽입" (id: "B:6")
// 두 문자가 동일한 afterId를 가질 때, id 사전순(lexicographic)으로 정렬
// "A:6" < "B:6" → 순서: o → ! → ? → "hello!?"
function merge(chars: CRDTChar[]): string {
// afterId 기반으로 정렬하되, 같은 위치 충돌은 id 사전순으로 비교
return topoSort(chars).map(c => c.value).join('');
}핵심: CRDT에서 "3번 위치에 삽입"은 존재하지 않습니다. "ID
s:5뒤에 삽입"처럼 관계로 위치를 표현하기 때문에, 어느 클라이언트에서 어떤 순서로 병합해도 수학적으로 동일한 결과(수렴)가 보장됩니다.
두 패러다임의 핵심 차이
| 구분 | OT | CRDT |
|---|---|---|
| 충돌 해결 주체 | 서버 (중앙 조정) | 자료구조 자체 |
| 위치 표현 방식 | 인덱스 (0, 1, 2...) | 관계 (ID 기반) |
| 서버 의존성 | 필수 | 불필요 (없어도 수렴) |
| 오프라인 지원 | 불가 | 재연결 시 자동 병합 |
| 메모리 오버헤드 | 낮음 | 높음 (문자당 메타데이터) |
| 구현 복잡도 핵심 | 변환 함수 TP1·TP2 조건 맞추기 | 병합 알고리즘 내장 (라이브러리 활용 가능) |
실전 적용
예시 1: Google Docs가 OT를 선택한 이유 — 서버가 어차피 모든 걸 본다
솔직히 말하면, Google이 OT를 선택한 건 "OT가 더 뛰어나서"가 아닙니다. Google 인프라 특성상 OT가 구조적으로 자연스러운 선택이었기 때문입니다.
Google 서버는 어차피 모든 연산을 받아야 합니다. 접근 제어(ACL), 버전 관리, 렌더링, 저장 — 이 모든 것이 서버를 통합니다. 여기서 연산을 변환하는 추가 비용은 5ms 미만이고, 대신 얻는 건 문서를 메타데이터 없이 컴팩트하게 유지하는 것입니다.
사용자 A (인덱스 3에 "x" 삽입)
↓
Google 서버 (A 연산 처리: rev 15)
↓
사용자 B의 연산 도착 (인덱스 4에 "y", rev 14 기준)
→ transform("insert y at 4", rev 14→15 diff)
→ "insert y at 5" 로 변환
↓
모든 클라이언트에 브로드캐스트CRDT를 썼다면? 문자 하나하나에 ID와 인과 메타데이터가 붙어야 합니다. 비압축 RGA 구현 기준으로 10만 자 문서는 원본 100KB에서 메타데이터만으로 수 MB 수준으로 팽창할 수 있습니다. Yjs 같은 라이브러리는 내부 압축으로 실용 범위 내로 유지하지만, 그게 없으면 부담이 상당합니다. 게다가 삭제된 문자도 tombstone으로 남아 문서가 커질수록 메모리 부담이 쌓입니다.
텍스트 편집이라는 선형적 구조, 중앙 서버 보유, 오프라인 지원 불필요 — 이 세 조건이 겹치는 상황에서 OT는 단점이 거의 없습니다.
예시 2: Figma가 CRDT 아이디어를 택한 이유 — "텍스트 에디터가 아니다"
Figma는 초기에 OT를 검토했다가 포기했습니다.
Figma 문서 구조 (단순화)
└── Frame A
├── Rectangle (x: 100, y: 200, width: 300)
├── Text "Hello" (font-size: 16, color: #333)
└── Group
├── Circle (r: 50)
└── Image (src: "...")OT 변환 함수는 "텍스트의 인덱스 기반 삽입/삭제"를 위해 설계됐습니다. 그런데 Figma 문서는 중첩된 트리 구조고, 연산 종류도 훨씬 다양합니다. "Rectangle의 x를 100으로", "Rectangle의 width를 200으로"가 동시에 들어올 때 OT 변환 함수를 작성하면 TP1·TP2 조건을 맞추기 위한 경우의 수가 폭발합니다. 스타트업 속도로는 감당이 안 되는 복잡도입니다.
Figma가 택한 방식은 CRDT의 아이디어를 빌린 하이브리드입니다.
// Figma 방식 단순화 — 속성별 LWW(Last Write Wins)
interface FigmaOperation {
nodeId: string;
property: string;
value: unknown;
timestamp: number; // 논리 시각
clientId: string;
}
function mergeProperties(
ops: FigmaOperation[]
): Record<string, FigmaOperation> {
return ops.reduce((acc, op) => {
const key = `${op.nodeId}.${op.property}`;
if (!acc[key] || acc[key].timestamp < op.timestamp) {
acc[key] = op;
}
return acc;
}, {} as Record<string, FigmaOperation>);
}LWW (Last Write Wins): 같은 속성에 충돌이 발생하면 가장 최근 타임스탬프의 값이 채택되는 방식입니다. 두 사람이 동시에 같은 레이어 색상을 바꾸면 어차피 한 명의 선택이 덮어쓰여지는데, 그게 "마지막에 바꾼 사람"이면 디자인 도구에서는 꽤 자연스럽습니다.
완전한 P2P CRDT는 아닙니다. 서버는 여전히 존재하고 권위적 순서 결정도 서버가 합니다. 다만 충돌 해결 로직을 CRDT 병합 규칙처럼 속성 단위로 단순화해서, 스타트업이 복잡한 OT 변환 함수 없이도 빠르게 멀티플레이어 기능을 출시할 수 있었습니다.
직접 구현하기: Yjs
오늘날 가장 현실적인 CRDT 선택지는 Yjs입니다. 주간 다운로드 900만 건, ProseMirror·Quill·Monaco 공식 바인딩 지원 — 웬만한 에디터에 바로 붙일 수 있습니다.
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { QuillBinding } from 'y-quill'
// CRDT 문서 생성
const ydoc = new Y.Doc()
// WebSocket으로 피어 연결 (서버는 단순 relay 역할)
const provider = new WebsocketProvider(
'wss://your-server.com',
'room-name',
ydoc
)
// Quill 에디터에 바인딩
const ytext = ydoc.getText('quill')
const binding = new QuillBinding(ytext, quill, provider.awareness)
// 동시 편집이 자동으로 처리되고,
// 오프라인 편집 후 재연결 시 자동 병합됩니다OT를 직접 구현했다면 변환 함수, 서버 이력 관리, 버전 벡터 처리를 모두 작성해야 했을 겁니다. 빠른 프로토타이핑이나 소규모 팀이라면 Yjs가 압도적으로 빠른 시작점입니다.
장단점 분석
OT와 CRDT를 나란히 놓고 비교해보면 이렇습니다.
| 항목 | OT | CRDT |
|---|---|---|
| 메모리 효율 | 문서를 원본 크기 그대로 유지 | 메타데이터로 인해 수배 팽창 가능 (라이브러리 압축으로 완화) |
| 오프라인 지원 | 불가 | 재연결 시 자동 병합 |
| P2P 적합성 | 서버 없이 불가 | 서버 없이 피어 간 직접 동기화 가능 |
| 서버 의존성 | 필수, 단일 장애점 | 선택적 (relay 서버로 단순화 가능) |
| 구현 복잡도 | 변환 함수 TP1·TP2 조건 맞추기 어려움 | 병합 알고리즘 자체 내장, 라이브러리 활용 용이 |
| 충돌 예측 가능성 | 서버 결정 → 결정론적 | 병합 규칙 기반 → 간접적 예측 |
| 비텍스트 구조 | 트리·그래프 구조에서 변환 함수 폭발 | LWW 등으로 자연스럽게 확장 |
특히 메모리 오버헤드 항목은 CRDT를 처음 검토할 때 자주 과소평가되는 부분입니다. Yjs는 내부 압축 덕분에 실용 범위 내로 유지하지만, 삭제된 요소가 tombstone으로 남는 문제는 장기 운영 시 누적됩니다.
tombstone: CRDT에서 삭제된 요소를 실제로 제거하지 않고 "삭제됨" 표시만 남겨두는 것입니다. 다른 피어가 이미 삭제된 요소를 참조할 수 있기 때문에 필요한 메커니즘이지만, 장기 운영 시 문서 크기가 계속 불어나는 원인이 됩니다. 주기적 스냅샷과 가비지 컬렉션 설계가 함께 필요합니다.
실무에서 가장 흔한 실수
-
CRDT라서 서버가 필요 없다고 가정하는 것 — Figma, Notion 모두 서버를 유지합니다. "서버 없이도 동작한다"는 가능성이지, 실제 서비스에서 서버를 없애도 된다는 뜻이 아닙니다. 접근 제어, 백업, 인증은 여전히 서버가 필요합니다.
-
텍스트 구조가 아닌데 OT를 선택하는 것 — 중첩 트리, 그래프, 객체 속성 동기화를 OT로 구현하려 하면 변환 함수 조합이 폭발합니다. 비텍스트 데이터에는 CRDT나 LWW가 훨씬 자연스럽습니다.
-
Yjs를 쓰면서 awareness를 무시하는 것 — Yjs의
awarenessAPI는 "현재 누가 어디를 편집 중인지" 커서 위치와 사용자 상태를 공유하는 기능입니다. 이걸 빠뜨리면 협업 도구가 혼자 쓰는 도구처럼 느껴집니다. 설정은 코드 다섯 줄이면 됩니다.
마치며
어떤 알고리즘이 더 뛰어난지보다, 제품의 데이터 구조와 인프라 특성에 어떤 것이 자연스럽게 맞는지가 훨씬 중요한 질문입니다.
저라면 새로운 협업 기능을 만들 때 이렇게 접근하겠습니다.
-
오프라인 지원이 필요하다면 Yjs로 시작 —
pnpm add yjs y-websocket으로 시작하는 것이 가장 빠릅니다. 에디터 통합은y-prosemirror,y-quill,y-codemirror중 현재 스택에 맞는 것을 선택하면 됩니다. -
서버 중심 아키텍처라면 ShareDB를 검토 — Node.js 환경에서 OT 기반 실시간 편집을 비교적 빠르게 구현할 수 있고, MongoDB 어댑터도 공식 지원됩니다 (
pnpm add sharedb). -
복잡한 객체 트리나 비텍스트 데이터라면 LWW부터 시도 — Figma 방식처럼 속성별 타임스탬프 비교로 시작하고, 실제로 어떤 충돌 케이스가 발생하는지 관찰한 뒤 점진적으로 정교화하는 것을 권장합니다.
참고 자료
- How Figma's multiplayer technology works | Figma Blog
- Building real-time collaboration applications: OT vs CRDT | TinyMCE
- CRDTs vs Operational Transformation: A Practical Guide | HackerNoon
- Real Differences between OT and CRDT | ACM CSCW 2020
- Collaborative Text Editing without CRDTs or OT | Matthew Weidner (2025)
- Peritext: A CRDT for Rich-Text Collaboration | Ink & Switch
- Yjs Documentation
- About CRDTs | crdt.tech
- Operational Transformation | Wikipedia
- Conflict-free Replicated Data Type | Wikipedia