Skip to content

✍️ 필사 모드: CRDT 완전 가이드 2025: 충돌 없는 복제 데이터 타입, 로컬 우선 협업, Yjs/Automerge

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

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는 두 가지 속성을 가진 데이터 타입입니다:

  1. 결합법칙(Associativity): (a + b) + c = a + (b + c)
  2. 교환법칙(Commutativity): a + b = b + a
  3. 멱등성(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: 삽입 시 왼쪽 이웃의 ID
  • origin_right: 삽입 시 오른쪽 이웃의 ID

병합 규칙:

  1. 같은 origin을 가진 삽입들은 client_id 순으로 정렬
  2. 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

YjsAutomerge
작성 언어JavaScriptTypeScript + WASM
데이터 모델CRDT 타입 (Y.Text, Y.Map)JSON-like
텍스트 성능매우 빠름좋음
메모리 효율우수보통
학습 곡선중간낮음
사용 사례협업 에디터일반 데이터 동기화
문서 크기작음보통
사용처Notion, Linear, AffineLocal-first 앱

선택 가이드:

  • 텍스트 협업 (에디터, 노트): Yjs
  • JSON 데이터 (앱 상태, 폼): Automerge
  • 둘 다 고려: 프로토타입은 Automerge가 빠름

6. Local-First Software 운동

6.1 7가지 이상

Ink & Switch 연구소의 Local-First Software 매니페스토:

  1. 빠르다 — 네트워크 왕복 없음
  2. 다중 디바이스 작동 — 동기화는 백그라운드
  3. 네트워크 선택적 — 오프라인에서 완전 작동
  4. 다른 사람과 협업 — CRDT로 충돌 해결
  5. 장기적 보존 — 클라우드가 사라져도 데이터 보존
  6. 기본적 보안과 프라이버시 — 데이터는 사용자 디바이스에
  7. 궁극적 사용자 통제 — 데이터 소유권

6.2 클라우드 우선 vs 로컬 우선

클라우드 우선로컬 우선
데이터 위치서버디바이스
오프라인작동 X완전 작동
응답 시간네트워크 의존즉시
협업서버 중계P2P 또는 서버
회사 폐업데이터 손실데이터 유지
예시Google DocsObsidian, 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 비교

OTCRDT
중앙 서버필수선택
오프라인 지원어려움자연스러움
알고리즘 복잡도매우 복잡복잡 (하지만 검증 가능)
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 위에 비즈니스 로직 레이어 추가, 또는 강한 일관성이 필요한 부분은 별도 메커니즘(중앙 검증, 분산 락 등) 사용.


참고 자료

현재 단락 (1/320)

- **CRDT의 마법**: 여러 사용자가 동시에 다른 복제본을 수정해도 **자동으로 같은 상태로 수렴**. 락 없이, 중앙 서버 없이

작성 글자: 0원문 글자: 10,366작성 단락: 0/320