- Published on
CRDT 완전 가이드 2025: 충돌 없는 복제 데이터 타입, 로컬 우선 협업, Yjs/Automerge
- Authors

- Name
- Youngju Kim
- @fjvbn20031
TL;DR
- CRDT의 마법: 여러 사용자가 동시에 다른 복제본을 수정해도 자동으로 같은 상태로 수렴. 락 없이, 중앙 서버 없이
- 두 가지 접근: State-based(전체 상태 전송) vs Operation-based(연산 전송). 각각 장단점
- 로컬 우선 운동: Ink & Switch 연구소가 시작. Figma, Linear, Notion이 채택
- Yjs vs Automerge: Yjs는 성능과 텍스트 협업, Automerge는 JSON 데이터 모델에 강함
- 언제 사용: 협업 에디터, 오프라인 우선 앱, P2P 동기화, 분산 메모
1. CRDT란 무엇인가?
1.1 문제 — 분산 환경에서의 충돌
시나리오: 두 사용자가 동시에 같은 문서를 편집합니다.
초기: "Hello"
User A 추가: "Hello World"
User B 동시 추가: "Hello Friend"
병합 시 어떻게? 어떤 게 이기나?
전통적 해결책:
- 마지막 쓰기 승리(Last Write Wins) — 한 사람의 변경이 사라짐
- 수동 병합 — 사용자가 직접 해결
- 락(Locking) — 한 명만 편집 가능, 협업 불가능
CRDT의 답: 두 변경을 자동으로 의미 있게 병합. 둘 다 보존.
1.2 CRDT의 수학적 정의
CRDT는 두 가지 속성을 가진 데이터 타입입니다:
- 결합법칙(Associativity):
(a + b) + c = a + (b + c) - 교환법칙(Commutativity):
a + b = b + a - 멱등성(Idempotence):
a + a = a
이 세 가지를 만족하면, 어떤 순서로 병합해도 결과가 동일합니다. 이를 ACI 속성 또는 semilattice라고 합니다.
1.3 두 가지 접근
State-based CRDT (CvRDT)
- 전체 상태를 전송
- 병합 함수:
merge(a, b) → c - 장점: 단순, 메시지 순서 무관
- 단점: 큰 상태 = 큰 네트워크 비용
Operation-based CRDT (CmRDT)
- 연산만 전송
- 모든 노드가 같은 연산을 같은 횟수 적용해야 함
- 장점: 작은 메시지
- 단점: 안정적인 메시지 전달 필요 (at-least-once)
실용적 선택: 대부분의 라이브러리(Yjs, Automerge)는 하이브리드 접근을 사용합니다.
2. 기본 CRDT 타입
2.1 G-Counter (Grow-only Counter)
증가만 가능한 카운터.
class GCounter:
def __init__(self, node_id):
self.node_id = node_id
self.counts = {} # {node_id: count}
def increment(self):
self.counts[self.node_id] = self.counts.get(self.node_id, 0) + 1
def value(self):
return sum(self.counts.values())
def merge(self, other):
# 각 노드의 카운트는 max로 병합
for node, count in other.counts.items():
self.counts[node] = max(self.counts.get(node, 0), count)
왜 max?: 같은 노드의 카운트는 단조 증가(monotonic). max를 취하면 어떤 노드가 더 최신인지 자동 결정.
사용 예: 페이지 뷰 카운트, 좋아요 수.
2.2 PN-Counter (Positive-Negative Counter)
증가/감소 모두 가능.
class PNCounter:
def __init__(self, node_id):
self.positive = GCounter(node_id) # 증가용
self.negative = GCounter(node_id) # 감소용
def increment(self):
self.positive.increment()
def decrement(self):
self.negative.increment()
def value(self):
return self.positive.value() - self.negative.value()
def merge(self, other):
self.positive.merge(other.positive)
self.negative.merge(other.negative)
핵심 트릭: 두 개의 G-Counter로 분리. 감소는 "음수의 증가"로 표현.
2.3 G-Set (Grow-only Set)
요소 추가만 가능한 Set.
class GSet:
def __init__(self):
self.elements = set()
def add(self, e):
self.elements.add(e)
def merge(self, other):
self.elements |= other.elements # 합집합
한계: 삭제 불가능. 영원히 자라는 Set.
2.4 2P-Set (Two-Phase Set)
추가와 삭제 모두 가능. 단, 한 번 삭제된 요소는 다시 추가 불가.
class TwoPhaseSet:
def __init__(self):
self.added = set()
self.removed = set()
def add(self, e):
if e not in self.removed:
self.added.add(e)
def remove(self, e):
self.removed.add(e)
def contains(self, e):
return e in self.added and e not in self.removed
def merge(self, other):
self.added |= other.added
self.removed |= other.removed
한계: tombstone(삭제 표시)이 영원히 누적.
2.5 LWW-Register (Last-Write-Wins Register)
타임스탬프 기반의 단일 값.
class LWWRegister:
def __init__(self):
self.value = None
self.timestamp = 0
def write(self, value, timestamp):
if timestamp > self.timestamp:
self.value = value
self.timestamp = timestamp
def merge(self, other):
if other.timestamp > self.timestamp:
self.value = other.value
self.timestamp = other.timestamp
문제: 시계 동기화. **Hybrid Logical Clock(HLC)**로 해결.
2.6 OR-Set (Observed-Remove Set)
가장 실용적인 Set CRDT. 추가/삭제 자유.
class ORSet:
def __init__(self):
# {element: set of unique tags}
self.elements = defaultdict(set)
self.tombstones = defaultdict(set)
def add(self, e):
tag = uuid.uuid4()
self.elements[e].add(tag)
def remove(self, e):
# 현재 보이는 모든 태그를 tombstone에
self.tombstones[e] |= self.elements[e]
def contains(self, e):
return bool(self.elements[e] - self.tombstones[e])
def merge(self, other):
for e, tags in other.elements.items():
self.elements[e] |= tags
for e, tags in other.tombstones.items():
self.tombstones[e] |= tags
핵심 아이디어: 각 추가에 고유 태그. 삭제는 "현재 본 태그들"을 tombstone에 추가. 새 추가는 새 태그 → 살아남음.
3. 텍스트 협업 — 가장 어려운 CRDT
3.1 왜 텍스트가 어려운가?
시나리오: 두 사용자가 같은 위치에 동시에 삽입.
초기: "ABCDE"
User A: 위치 2에 "X" 삽입 → "ABXCDE"
User B: 위치 2에 "Y" 삽입 → "ABYCDE"
병합: "ABXYCDE" 또는 "ABYXCDE"?
문제: 위치는 상대적입니다. 한 사용자의 삽입이 다른 사용자의 위치를 무효화.
3.2 RGA (Replicated Growable Array)
각 문자에 고유 ID를 부여. 위치 대신 ID를 참조.
초기 문서:
[start] - A(id1) - B(id2) - C(id3) - [end]
User A: B 다음에 X 삽입 (id4)
[start] - A(id1) - B(id2) - X(id4) - C(id3) - [end]
User B: B 다음에 Y 삽입 (id5)
[start] - A(id1) - B(id2) - Y(id5) - C(id3) - [end]
병합 (id로 비교, 작은 id가 먼저):
[start] - A(id1) - B(id2) - X(id4) - Y(id5) - C(id3) - [end]
핵심: 고유 ID + 정렬 규칙으로 결정적 병합.
3.3 Yjs의 YATA 알고리즘
Yjs는 **YATA(Yet Another Transformation Approach)**라는 변형 알고리즘 사용. RGA보다 효율적입니다.
각 문자가 다음을 가집니다:
- 고유 ID
(client_id, clock) origin_left: 삽입 시 왼쪽 이웃의 IDorigin_right: 삽입 시 오른쪽 이웃의 ID
병합 규칙:
- 같은 origin을 가진 삽입들은 client_id 순으로 정렬
- origin이 다르면 위치 정보로 정렬
효율성: Yjs는 같은 문자열의 연속 입력을 단일 객체로 압축 → 메모리 100배+ 효율.
4. Yjs — JavaScript CRDT의 표준
4.1 기본 사용법
import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
// 공유 문서 생성
const doc = new Y.Doc()
// 공유 텍스트
const ytext = doc.getText('shared-text')
ytext.insert(0, 'Hello, ')
ytext.insert(7, 'World!')
console.log(ytext.toString()) // "Hello, World!"
// P2P 동기화 (WebRTC)
const provider = new WebrtcProvider('my-room', doc)
4.2 다양한 데이터 타입
// 텍스트
const ytext = doc.getText('text')
ytext.insert(0, 'Hello')
// 배열
const yarray = doc.getArray('list')
yarray.push(['item1', 'item2'])
// 맵 (객체)
const ymap = doc.getMap('config')
ymap.set('theme', 'dark')
// XML/JSON
const yxml = doc.getXmlFragment('content')
4.3 변경 감지
ytext.observe((event) => {
console.log('Changes:', event.changes.delta)
// [{ retain: 7 }, { insert: 'World!' }]
})
4.4 영속화 — IndexedDB
import { IndexeddbPersistence } from 'y-indexeddb'
const persistence = new IndexeddbPersistence('my-doc', doc)
persistence.on('synced', () => {
console.log('Loaded from IndexedDB')
})
브라우저 새로고침 후에도 상태 유지 — 진정한 로컬 우선.
4.5 다중 프로바이더
Yjs는 transport-agnostic — 여러 동기화 방식 지원:
y-websocket— 중앙 서버 동기화y-webrtc— P2P 동기화y-indexeddb— 로컬 영속화y-leveldb— Node.js 서버- 커스텀 프로바이더 작성 가능
5. Automerge — JSON 데이터 모델
5.1 JSON 친화적 인터페이스
import * as Automerge from '@automerge/automerge'
let doc = Automerge.init()
doc = Automerge.change(doc, 'Initial', d => {
d.todos = []
d.todos.push({ text: 'Buy milk', done: false })
})
// 다른 디바이스
let doc2 = Automerge.merge(Automerge.init(), doc)
doc2 = Automerge.change(doc2, 'Add task', d => {
d.todos.push({ text: 'Walk dog', done: false })
})
// 병합
const merged = Automerge.merge(doc, doc2)
console.log(merged.todos) // [Buy milk, Walk dog]
5.2 Yjs vs Automerge
| Yjs | Automerge | |
|---|---|---|
| 작성 언어 | JavaScript | TypeScript + WASM |
| 데이터 모델 | CRDT 타입 (Y.Text, Y.Map) | JSON-like |
| 텍스트 성능 | 매우 빠름 | 좋음 |
| 메모리 효율 | 우수 | 보통 |
| 학습 곡선 | 중간 | 낮음 |
| 사용 사례 | 협업 에디터 | 일반 데이터 동기화 |
| 문서 크기 | 작음 | 보통 |
| 사용처 | Notion, Linear, Affine | Local-first 앱 |
선택 가이드:
- 텍스트 협업 (에디터, 노트): Yjs
- JSON 데이터 (앱 상태, 폼): Automerge
- 둘 다 고려: 프로토타입은 Automerge가 빠름
6. Local-First Software 운동
6.1 7가지 이상
Ink & Switch 연구소의 Local-First Software 매니페스토:
- 빠르다 — 네트워크 왕복 없음
- 다중 디바이스 작동 — 동기화는 백그라운드
- 네트워크 선택적 — 오프라인에서 완전 작동
- 다른 사람과 협업 — CRDT로 충돌 해결
- 장기적 보존 — 클라우드가 사라져도 데이터 보존
- 기본적 보안과 프라이버시 — 데이터는 사용자 디바이스에
- 궁극적 사용자 통제 — 데이터 소유권
6.2 클라우드 우선 vs 로컬 우선
| 클라우드 우선 | 로컬 우선 | |
|---|---|---|
| 데이터 위치 | 서버 | 디바이스 |
| 오프라인 | 작동 X | 완전 작동 |
| 응답 시간 | 네트워크 의존 | 즉시 |
| 협업 | 서버 중계 | P2P 또는 서버 |
| 회사 폐업 | 데이터 손실 | 데이터 유지 |
| 예시 | Google Docs | Obsidian, Logseq, Linear |
6.3 로컬 우선 채택 사례
Linear — 프로젝트 관리:
- 전체 데이터를 로컬 IndexedDB에
- 즉시 응답 (latency 0)
- WebSocket으로 백그라운드 동기화
- 오프라인 변경은 큐에 저장 후 재연결 시 전송
Figma — 디자인 협업:
- 자체 CRDT 구현 (RGA 기반)
- 실시간 멀티 커서
- 오프라인 편집 후 동기화
Affine — 노트:
- Yjs 사용
- 완전 로컬 우선
- 클라우드는 선택적 동기화
Notion — 노트/위키:
- 부분적 CRDT (블록 단위)
- 텍스트 편집은 OT에서 CRDT로 마이그레이션 중
7. CRDT vs OT (Operational Transformation)
7.1 OT란?
Google Docs가 사용한 전통적 협업 방식. 연산 변환 — 동시 연산을 변환하여 일관성 유지.
초기: "ABC"
User A: insert(1, "X") → "AXBC"
User B: delete(2) → "AB"
User B의 연산을 User A의 변경 후로 변환:
delete(2) → delete(3) (위치 조정)
결과: "AXB"
7.2 비교
| OT | CRDT | |
|---|---|---|
| 중앙 서버 | 필수 | 선택 |
| 오프라인 지원 | 어려움 | 자연스러움 |
| 알고리즘 복잡도 | 매우 복잡 | 복잡 (하지만 검증 가능) |
| P2P | 어려움 | 자연스러움 |
| Google Docs | ✅ (현재) | ❌ |
| Figma, Linear | ❌ | ✅ |
| 학계 연구 | 1990년대~ | 2000년대~ |
트렌드: OT → CRDT 마이그레이션 (Notion, Confluent 등). CRDT가 분산 환경에 더 적합.
8. CRDT의 한계와 함정
8.1 메타데이터 폭증
문제: tombstone, 태그, 클럭 등이 누적되어 문서 크기가 실제 콘텐츠보다 큼.
해결:
- 압축(Compaction): 더 이상 필요 없는 메타데이터 정리
- 델타 압축: Yjs가 사용. 연속 작업을 단일 객체로
- 체크포인트: 주기적으로 베이스라인 생성
8.2 의미적 충돌
CRDT는 구문적 충돌(같은 위치 동시 편집)을 해결합니다. 의미적 충돌은 해결 못 합니다.
예시: 캘린더 앱. 두 사용자가 같은 회의실을 다른 회의로 동시 예약. CRDT는 둘 다 예약 성공으로 처리 — 비즈니스 규칙 위반.
해결: CRDT 위에 비즈니스 로직 레이어 추가. 또는 강한 일관성이 필요한 부분은 다른 메커니즘.
8.3 부분적 CRDT가 더 실용적
Notion의 접근: 블록 수준에서 CRDT, 블록 내부는 일반 텍스트. 모든 것을 CRDT로 만들 필요 없음.
Linear의 접근: 일부 필드만 CRDT, 나머지는 LWW. 단순함과 성능의 균형.
9. 실전 — 협업 텍스트 에디터 만들기
9.1 기본 구조
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { EditorView, basicSetup } from 'codemirror'
import { yCollab } from 'y-codemirror.next'
// 1. Yjs 문서 생성
const ydoc = new Y.Doc()
const ytext = ydoc.getText('codemirror')
// 2. 동기화 프로바이더
const provider = new WebsocketProvider(
'wss://my-server.com',
'document-id-123',
ydoc
)
// 3. CodeMirror 에디터 + Yjs 통합
const view = new EditorView({
doc: ytext.toString(),
extensions: [
basicSetup,
yCollab(ytext, provider.awareness) // 협업 확장
],
parent: document.body
})
코드 100줄 미만으로 Google Docs 같은 협업 에디터 구현 가능.
9.2 사용자 인식 (Awareness)
provider.awareness.setLocalStateField('user', {
name: 'Alice',
color: '#ff0000'
})
provider.awareness.on('change', () => {
const users = Array.from(provider.awareness.getStates().values())
console.log('Online users:', users)
})
다른 사용자의 커서, 선택 영역이 자동으로 표시됩니다.
퀴즈
1. CRDT의 수학적 속성 3가지는?
답: (1) 결합법칙 (Associativity): (a + b) + c = a + (b + c), (2) 교환법칙 (Commutativity): a + b = b + a, (3) 멱등성 (Idempotence): a + a = a. 이 세 가지를 만족하면 어떤 순서로 병합해도 결과가 동일합니다. 이를 ACI 속성 또는 semilattice라고 합니다. 이것이 CRDT의 자동 충돌 해결의 수학적 기반입니다.
2. State-based와 Operation-based CRDT의 차이는?
답: **State-based (CvRDT)**는 전체 상태를 전송, 병합 함수로 결합. 메시지 순서 무관, 단순. 단점은 큰 상태 = 큰 네트워크 비용. **Operation-based (CmRDT)**는 연산만 전송. 작은 메시지지만 안정적인 메시지 전달 필요 (at-least-once delivery + 멱등성). 실용적 라이브러리(Yjs, Automerge)는 하이브리드 접근을 사용합니다.
3. Yjs와 Automerge 중 무엇을 선택해야 하나요?
답: 텍스트 협업 (에디터, 노트, IDE) → Yjs (성능과 메모리 효율 우수, 사용처: Notion, Linear, Affine). JSON 데이터 동기화 (앱 상태, 폼, 설정) → Automerge (JSON-like API, 학습 곡선 낮음). 둘 다 고려된다면 프로토타입은 Automerge로 빠르게, 프로덕션은 Yjs로 최적화하는 패턴이 일반적입니다.
4. Local-First Software의 핵심 가치는?
답: Ink & Switch의 7가지 이상: (1) 빠르다(네트워크 의존 X), (2) 다중 디바이스, (3) 네트워크 선택적(오프라인 완전 작동), (4) 협업, (5) 장기적 보존(클라우드 사라져도 데이터 유지), (6) 보안/프라이버시(데이터는 디바이스에), (7) 사용자 데이터 통제. 클라우드 우선은 회사가 망하면 데이터도 사라지지만, 로컬 우선은 데이터가 사용자 손에 있습니다. Linear, Figma, Notion이 채택했습니다.
5. CRDT가 해결할 수 없는 충돌은?
답: **의미적 충돌(semantic conflict)**입니다. CRDT는 같은 데이터 구조의 동시 변경을 자동 병합하지만, 비즈니스 규칙 위반은 못 막습니다. 예시: 두 사용자가 같은 회의실을 다른 회의로 동시 예약 → CRDT는 둘 다 성공으로 처리. 해결: CRDT 위에 비즈니스 로직 레이어 추가, 또는 강한 일관성이 필요한 부분은 별도 메커니즘(중앙 검증, 분산 락 등) 사용.
참고 자료
- CRDT.tech — CRDT 종합 자료
- Local-First Software — Ink & Switch 매니페스토
- Yjs — 공식 문서
- Automerge — 공식 문서
- Conflict-Free Replicated Data Types — 원논문 (Shapiro 2011)
- A Conflict-Free Replicated JSON Datatype — Automerge 논문
- YATA: Yet Another Transformation Approach — Yjs 알고리즘
- Designing Data-Intensive Applications — Martin Kleppmann (CRDT 챕터)
- Linear's offline mode — Linear 동기화 엔진
- Figma's multiplayer technology — Figma 협업
- Diamond Types — Rust로 작성된 고성능 CRDT