실시간 협업 앱의 동시 편집 충돌, CRDT와 LWW로 자동 병합하는 법
처음 실시간 협업 기능을 만들어봤을 때 밤새 이 질문을 붙잡고 있었습니다. "두 사람이 동시에 같은 필드를 수정하면 어떻게 될까?" 락(lock)을 걸자니 사용성이 망가지고, 그냥 나중 것으로 덮어쓰자니 한 명의 작업이 조용히 증발하고. 그냥 덮어쓰는 방식으로 일단 배포했다가 실제로 유저 데이터가 날아가는 걸 보고 나서야 제대로 파고들게 됐습니다. 이 결정을 잘못 내리면 버그 리포트도 없이 데이터가 사라집니다. 유저는 자기가 입력한 내용이 없어진 줄도 모르고요.
CRDT(Conflict-free Replicated Data Type)는 Notion, Figma, Linear 같은 서비스들이 여러 사람의 동시 편집을 가능하게 하는 핵심 기술입니다. LWW(Last-Write-Wins)는 Cassandra, DynamoDB 같은 분산 데이터베이스가 수십 년째 쓰고 있는, 그보다 훨씬 단순한 전략입니다. 둘 다 "충돌을 어떻게 해결할 것인가"라는 질문에 대한 답이지만 철학이 완전히 다릅니다. CRDT는 충돌 자체가 생기지 않도록 구조를 설계하는 쪽이고, LWW는 충돌을 인정하되 패배한 쪽을 깔끔하게 버리는 쪽입니다.
이 글을 읽고 나면 CRDT와 LWW가 각각 어떤 원리로 동작하는지 이해하고, 지금 진행 중인 프로젝트의 어떤 필드에 어느 전략이 적합한지 스스로 판단할 수 있습니다.
핵심 개념
CRDT와 LWW, 어디서 갈리는가
먼저 두 개념의 핵심 차이부터 한눈에 보겠습니다. 세부 내용은 읽다 보면 자연스럽게 따라오는데, 전체 그림을 먼저 잡고 가는 편이 훨씬 수월합니다.
| 기준 | CRDT | LWW |
|---|---|---|
| 충돌 처리 방식 | 모든 업데이트를 병합 — 데이터 손실 없음 | 타임스탬프가 큰 쪽 승리 — 패배 측 폐기 |
| 수렴 보장 | 수학적으로 보장 (Strong Eventual Consistency) | 보장하지만 클락 스큐에 취약 |
| 구현 복잡도 | 높음 — 올바른 설계에 수학적 이해 필요 | 낮음 — 타임스탬프 비교가 전부 |
| 메타데이터 오버헤드 | 높음 (벡터 클락, 고유 ID, 묘비 등) | 거의 없음 |
| 적합한 케이스 | 동시에 같은 필드를 편집할 수 있는 협업 기능 | 마지막 값만 의미 있는 필드 (설정값, 로그인 시각 등) |
분산 시스템의 근본적인 문제
네트워크는 언제든 끊길 수 있습니다. 두 노드가 오프라인 상태에서 동시에 같은 데이터를 수정할 수 있습니다. CAP 정리는 이 상황, 즉 파티션이 발생한 순간에 "일관성(Consistency)과 가용성(Availability) 중 하나를 포기해야 한다"고 말합니다. 정상 상태에서는 둘 다 가질 수 있지만, 네트워크가 갈라졌을 때는 선택이 강요됩니다. 대부분의 현대 분산 시스템은 가용성을 선택합니다 — 파티션 중에도 읽기/쓰기를 허용하고, 재연결 후 충돌을 해결하는 방식으로요.
CRDT와 LWW는 바로 그 "재연결 후 충돌을 어떻게 해결할 것인가"에 대한 서로 다른 답입니다.
CRDT — 병합 연산이 수학적으로 보장되는 데이터 구조
CRDT(Conflict-free Replicated Data Type): 분산된 여러 노드가 중앙 서버 없이 독립적으로 데이터를 수정해도, 최종적으로 모든 복제본이 동일한 상태로 수렴(convergence)함을 수학적으로 보장하는 데이터 구조.
CRDT가 보장하는 건 단순한 Eventual Consistency(EC)가 아닙니다. **Strong Eventual Consistency(SEC)**입니다. 일반 EC는 "언젠가 같아진다"는 느슨한 약속인 반면, SEC는 "같은 업데이트 집합을 받은 두 노드는 즉시 동일한 상태가 된다"는 더 강한 보장입니다. 이 차이가 CRDT를 협업 편집기에 쓸 수 있게 만드는 핵심입니다.
이 보장의 비결은 merge() 연산이 세 가지 수학 법칙을 만족한다는 점입니다.
| 법칙 | 의미 | 실용적 효과 |
|---|---|---|
| 교환법칙 (commutativity) | merge(A, B) = merge(B, A) |
도착 순서가 달라도 결과가 같음 |
| 결합법칙 (associativity) | merge(merge(A, B), C) = merge(A, merge(B, C)) |
어떤 순서로 병합해도 결과가 같음 |
| 멱등법칙 (idempotency) | merge(A, A) = A |
같은 메시지가 중복 도착해도 괜찮음 |
세 가지가 모두 보장되면 네트워크 파티션 후 재연결 시 어떤 순서로 업데이트가 전파되든 결국 같은 상태가 됩니다. 중앙 조정자가 필요 없는 이유입니다.
CRDT는 구현 방식에 따라 두 종류로 나뉩니다. **State-based(CvRDT)**는 전체 상태를 전송하고 수신 측에서 merge()로 합치는 방식입니다. 구현이 단순한 대신 페이로드가 큽니다. **Operation-based(CmRDT)**는 변경 연산만 전파하여 네트워크 부하를 줄이는 방식인데, 메시지가 정확히 한 번 전달된다는 인프라 보장이 필요합니다. Yjs는 실제로 Delta CRDT에 가까운 방식을 씁니다 — 변경된 부분만 전파하므로 대역폭 효율이 좋습니다. 아래 GCounter 예시는 개념 이해를 위해 State-based 방식으로 설명합니다.
// State-based CRDT (CvRDT) — 전체 상태를 전송하고 merge()로 합침
class GCounter {
private counts: Map<string, number> = new Map();
increment(nodeId: string): void {
this.counts.set(nodeId, (this.counts.get(nodeId) ?? 0) + 1);
}
value(): number {
return [...this.counts.values()].reduce((sum, v) => sum + v, 0);
}
merge(other: GCounter): GCounter {
const merged = new GCounter();
const allKeys = new Set([...this.counts.keys(), ...other.counts.keys()]);
for (const key of allKeys) {
merged.counts.set(key, Math.max(
this.counts.get(key) ?? 0,
other.counts.get(key) ?? 0
));
}
return merged;
}
}
// 노드 A와 B가 각자 카운터를 올리고 나중에 병합
const nodeA = new GCounter();
nodeA.increment('nodeA');
nodeA.increment('nodeA'); // nodeA: 2
const nodeB = new GCounter();
nodeB.increment('nodeB'); // nodeB: 1
const merged = nodeA.merge(nodeB);
console.log(merged.value()); // 3 — 도착 순서와 무관하게 항상 올바른 결과G-Counter처럼 증가만 가능한 단순한 것부터, 텍스트 편집에 쓰이는 복잡한 Sequence CRDT까지 다양한 종류가 있습니다.
LWW — 타임스탬프로 승자를 정하는 단순하고 실용적인 전략
LWW(Last-Write-Wins): 충돌한 쓰기 중 타임스탬프가 가장 큰 값이 "승리"하는 전략. 패배한 쪽의 쓰기는 폐기됩니다.
LWW는 수학적으로도 CRDT의 일종으로 구현할 수 있습니다. merge() 함수가 단순히 "더 큰 타임스탬프를 가진 값을 선택"하는 것이니까요. 저도 처음에 이 구현을 짤 때 write()와 merge()의 tie-breaking 로직을 따로따로 짜다가 일관성이 깨지는 경험을 했습니다. 같은 밀리초에 두 쓰기가 들어오면 write()는 두 번 다 실패하고, merge()는 nodeId로 결정하는 불일치가 생기거든요. 아래 구현은 그 문제를 고친 버전입니다.
interface LWWValue<T> {
value: T;
timestamp: number;
nodeId: string; // 동일 타임스탬프 tie-breaking용
}
class LWWRegister<T> {
private nodeId: string;
private state: LWWValue<T>;
constructor(initialValue: T, nodeId: string) {
this.nodeId = nodeId;
this.state = { value: initialValue, timestamp: 0, nodeId };
}
write(value: T): void {
const timestamp = Date.now();
// write()와 merge() 모두 동일한 tie-breaking 기준을 써야 일관성이 생긴다
if (
timestamp > this.state.timestamp ||
(timestamp === this.state.timestamp && this.nodeId > this.state.nodeId)
) {
this.state = { value, timestamp, nodeId: this.nodeId };
}
}
read(): T {
return this.state.value;
}
merge(other: LWWRegister<T>): LWWRegister<T> {
const winner =
other.state.timestamp > this.state.timestamp
? other.state
: other.state.timestamp === this.state.timestamp && other.state.nodeId > this.state.nodeId
? other.state
: this.state;
const result = new LWWRegister<T>(winner.value, winner.nodeId);
result.state = winner;
return result;
}
}LWW-Register가 단일 값을 다룬다면, LWW-Element-Set은 집합의 각 원소에 타임스탬프를 붙여 "추가된 시각"과 "삭제된 시각"을 비교하는 방식으로 동작합니다.
class LWWElementSet<T> {
private addSet: Map<T, number> = new Map();
private removeSet: Map<T, number> = new Map();
add(element: T, timestamp: number): void {
const existing = this.addSet.get(element) ?? -Infinity;
if (timestamp > existing) {
this.addSet.set(element, timestamp);
}
}
remove(element: T, timestamp: number): void {
const existing = this.removeSet.get(element) ?? -Infinity;
if (timestamp > existing) {
this.removeSet.set(element, timestamp);
}
}
has(element: T): boolean {
const addTime = this.addSet.get(element) ?? -Infinity;
const removeTime = this.removeSet.get(element) ?? -Infinity;
return addTime >= removeTime;
}
merge(other: LWWElementSet<T>): void {
for (const [elem, ts] of other.addSet) this.add(elem, ts);
for (const [elem, ts] of other.removeSet) this.remove(elem, ts);
}
}실전 적용
예시 1: 실시간 협업 편집기에서 Yjs 활용
직접 CRDT를 처음부터 구현하는 건 솔직히 쉬운 일이 아닙니다. 텍스트 Sequence CRDT 하나를 제대로 만들려면 논문 몇 편을 읽어야 할 수준이고요. 실무에서는 Yjs처럼 검증된 라이브러리를 활용하는 것이 훨씬 현실적입니다. Yjs는 주당 90만 이상 다운로드되는 가장 널리 쓰이는 JavaScript CRDT 라이브러리로, Notion과 Linear가 실제로 사용하는 방식과 유사합니다.
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
// 공유 문서 생성
const ydoc = new Y.Doc()
// WebSocket으로 다른 클라이언트와 동기화
const provider = new WebsocketProvider('wss://your-server.com', 'room-name', ydoc)
// 공유 텍스트 타입 — 여러 사람이 동시에 수정해도 CRDT가 병합 처리
const ytext = ydoc.getText('content')
// 사용자 A가 "Hello"를 입력
ytext.insert(0, 'Hello')
// 사용자 B가 오프라인 상태에서 동시에 " World"를 뒤에 추가한 후 재연결
// → Yjs가 YATA 알고리즘으로 자동 병합: "Hello World"
// 협업 카운터 (좋아요 수 등) — Y.Map은 LWW 방식으로 충돌 해결
const ymap = ydoc.getMap('metadata')
ymap.set('likes', (ymap.get('likes') as number ?? 0) + 1)
// 변경 감지
ytext.observe(() => {
console.log('텍스트 변경됨:', ytext.toString())
})| 구성 요소 | 역할 |
|---|---|
Y.Doc |
CRDT 문서 루트, 모든 공유 타입 관리 |
WebsocketProvider |
네트워크 동기화 담당, 오프라인 시 로컬 저장 |
Y.Text |
Sequence CRDT, 텍스트 삽입/삭제 충돌 자동 해결 |
Y.Map |
키-값 CRDT, LWW 방식으로 충돌 해결 |
시작은 Yjs 공식 레포의 examples 폴더에서 원하는 환경에 맞는 예시를 가져다 쓰는 것을 권장합니다.
예시 2: 오프라인 지원 쇼핑 카트
모바일 앱에서 인터넷이 끊겨도 카트에 담기/빼기가 동작하고, 재연결 시 서버와 병합되는 패턴입니다. 쇼핑 카트는 여러 탭이나 기기에서 동시에 수정하는 경우가 많아 충돌이 꽤 자주 발생하는 케이스입니다.
import * as Y from 'yjs'
import { IndexeddbPersistence } from 'y-indexeddb'
class OfflineShoppingCart {
private ydoc: Y.Doc
private items: Y.Map<number> // itemId -> quantity
constructor(userId: string) {
this.ydoc = new Y.Doc()
this.items = this.ydoc.getMap('cart')
// 로컬 IndexedDB에 자동 저장 — 앱을 닫아도 데이터 유지
new IndexeddbPersistence(`cart-${userId}`, this.ydoc)
}
addItem(itemId: string, quantity: number): void {
const current = this.items.get(itemId) ?? 0
this.items.set(itemId, current + quantity)
}
removeItem(itemId: string): void {
this.items.delete(itemId)
}
getItems(): Record<string, number> {
return Object.fromEntries(this.items.entries())
}
// 서버에서 받은 상태와 병합 (재연결 시)
mergeWithServer(serverUpdate: Uint8Array): void {
Y.applyUpdate(this.ydoc, serverUpdate)
// CRDT 보장: 어떤 순서로 병합해도 최종 결과는 동일
}
}예시 3: Cassandra 스타일 LWW — 마지막 로그인 시간 저장
LWW가 정말 딱 맞는 케이스입니다. 유저의 "마지막 로그인 시간"은 정의상 가장 최근 값만 의미가 있으니까요. 이전 값을 살릴 이유가 전혀 없는 상황에 CRDT를 억지로 끼워 맞추는 건 복잡도만 올리는 일입니다.
interface UserSession {
userId: string;
lastLoginAt: number; // Unix timestamp (ms)
deviceInfo: string;
}
class UserSessionStore {
private sessions: Map<string, UserSession> = new Map();
upsert(session: UserSession): void {
const existing = this.sessions.get(session.userId);
// LWW: 더 최근 타임스탬프가 승리
if (!existing || session.lastLoginAt > existing.lastLoginAt) {
this.sessions.set(session.userId, session);
}
}
// 두 노드의 상태를 병합 (예: 리전 간 복제)
merge(other: UserSessionStore): void {
for (const [, session] of other.sessions) {
this.upsert(session);
}
}
}
// 이 TypeScript 클래스가 수동으로 하는 일을, Cassandra는 스키마 수준에서 자동으로 처리합니다.
// CREATE TABLE user_sessions (
// user_id uuid PRIMARY KEY,
// last_login_at timestamp,
// device_info text
// );
// -- 동일 파티션 키에 대한 쓰기는 자동으로 LWW 적용 (쓰기 시각 기준)Cassandra가 수십 년째 기본 충돌 해결 전략으로 LWW를 쓰는 이유가 이 단순함입니다. 로그인 시각, 사용자 설정, 읽음 표시처럼 "마지막이 진리"인 데이터에 잘 맞습니다.
장단점 분석
CRDT 장점
| 항목 | 내용 |
|---|---|
| 무조정 수렴 | 중앙 서버 없이도 모든 복제본이 동일 상태로 수렴됩니다 |
| 고가용성 | 네트워크 파티션 중에도 읽기/쓰기가 계속 동작합니다 |
| 오프라인 지원 | 연결이 끊겨도 로컬에서 수정하고 재연결 시 자동 병합됩니다 |
| 데이터 손실 없음 | 충돌 시 패배 측을 버리지 않고 모든 변경을 반영합니다 |
| 수평 확장 | P2P 구조로 중앙 병목 없이 노드를 늘릴 수 있습니다 |
CRDT 단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 메모리 오버헤드 | 벡터 클락, 고유 ID 등 병합용 메타데이터가 누적됩니다 | 가비지 컬렉션 전략 설계, 스냅샷 압축 활용 |
| 삭제의 어려움 | 삭제 연산은 묘비(tombstone)를 남겨야 해 데이터가 계속 늘어납니다 | 주기적인 tombstone GC, 또는 OR-Set 사용 |
| 강한 일관성 미보장 | 순간적인 불일치가 존재합니다 (Strong Eventual Consistency) | 강한 일관성이 필요한 로직은 분리 설계 |
| 구현 복잡성 | 올바른 CRDT 설계는 수학적 이해가 필요합니다 | Yjs, Automerge 같은 검증된 라이브러리 활용 |
LWW 단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 클락 스큐 | 분산 노드 간 시계 불일치로 논리적으로 나중 쓰기가 덮어써질 수 있습니다 | HLC(Hybrid Logical Clock) 또는 벡터 클락으로 대체 |
| 데이터 손실 | 패배한 쓰기는 완전히 폐기됩니다 | 손실이 허용되는 필드에만 LWW 적용 |
| 단순한 tie-breaking | 동일 타임스탬프 처리가 구현마다 달라 불일관성이 생길 수 있습니다 | write()와 merge() 모두 동일한 tie-breaking 로직 사용 |
용어 보충 — 클락 스큐(Clock Skew): 분산된 여러 컴퓨터의 시계가 완벽히 동기화되지 않아 생기는 오차입니다. NTP를 사용해도 수백 밀리초의 오차가 발생할 수 있어, 물리적 시계 기반 LWW는 "나중에 일어난 일이 더 이른 타임스탬프를 갖는" 역전 현상이 생길 수 있습니다.
용어 보충 — 묘비(Tombstone): CRDT에서 원소를 삭제할 때 실제로 지우는 대신 "삭제됐음"을 표시하는 마커를 남기는 방식입니다. 다른 노드가 삭제 사실을 알기 전에 해당 원소를 참조할 수 있기 때문에 필요합니다.
용어 보충 — 벡터 클락(Vector Clock): 각 노드가 자신과 알고 있는 모든 노드의 논리적 시계를 벡터로 관리하는 방식입니다. 물리적 시계와 달리 인과 관계(어떤 이벤트가 다른 이벤트보다 먼저 일어났는가)를 추적할 수 있어 클락 스큐 문제를 피할 수 있습니다. HLC(Hybrid Logical Clock)는 물리적 시계의 직관성과 벡터 클락의 인과 추적 능력을 결합한 실용적인 대안입니다.
실무에서 가장 흔한 실수
-
물리적 시계를 LWW 타임스탬프로 그대로 쓰는 것. 저도 처음에
Date.now()를 그대로 LWW 타임스탬프로 쓰다가 스테이징 환경에서 데이터가 사라지는 걸 목격했습니다. 두 서버의 시계가 200ms 어긋나 있었고, 논리적으로 나중에 발생한 쓰기가 타임스탬프 경쟁에서 지고 있었습니다. HLC(Hybrid Logical Clock) 사용을 권장합니다. -
묘비 GC를 설계하지 않는 것. OR-Set 기반 CRDT를 쓰면서 삭제된 원소의 묘비를 주기적으로 정리하지 않으면, 시간이 지날수록 메모리와 디스크가 묘비로 가득 찹니다. 처음 설계할 때부터 "언제 묘비를 안전하게 삭제할 수 있는가"를 생각해두는 것이 좋습니다.
-
모든 충돌에 CRDT를 적용하려는 것. "마지막 로그인 시각"처럼 의미상 나중 값이 항상 옳은 필드에는 LWW가 훨씬 단순하고 적합합니다. CRDT는 복잡도 비용이 있으므로, 실제로 모든 변경을 살려야 하는 케이스에만 적용하는 것이 좋습니다.
마치며
사용자가 같은 필드를 동시에 편집할 수 있다면 CRDT, 마지막 상태만 의미 있다면 LWW가 적합합니다. 이 한 줄이 대부분의 선택 상황을 커버합니다. 텍스트 편집기와 공유 화이트보드는 CRDT 쪽이고, 로그인 시각·읽음 표시·마지막 설정값은 LWW 쪽입니다.
지금 바로 시작해볼 수 있는 3단계가 있습니다.
- jakelazaroff.com의 인터랙티브 CRDT 소개를 열어서 브라우저 안에서 CRDT 병합이 실제로 어떻게 동작하는지 직접 조작해보시는 것을 권장합니다. 백문이 불여일견이고, 이 데모 하나가 긴 설명보다 훨씬 빠르게 감을 줍니다.
- 지금 진행 중인 프로젝트에서 LWW가 적합한 필드를 찾아보시면 좋습니다. "마지막 수정 시간", "최근 설정값", "읽음 표시" 같은 필드들은 이미 암묵적으로 LWW를 기대하고 있을 가능성이 높습니다. 명시적으로 설계에 반영해두면 나중에 멀티 리전 또는 오프라인 지원이 필요해졌을 때 훨씬 수월합니다.
pnpm add yjs y-websocket으로 공유 카운터나 공유 체크리스트 같은 단순한 기능 하나를 붙여보시면 좋습니다. Yjs 공식 레포의 examples 폴더에 다양한 환경별 시작점이 준비되어 있습니다. 작은 것부터 실제로 동작시켜보는 것이 CRDT를 체감하는 가장 빠른 방법입니다.
참고 자료
- About CRDTs | crdt.tech
- Conflict-free replicated data type | Wikipedia
- Approaches to Conflict-free Replicated Data Types | ACM Computing Surveys (2024)
- An Interactive Intro to CRDTs | jakelazaroff.com
- Conflict Resolution: Using Last-Write-Wins vs. CRDTs | DZone
- Operation-based CRDTs: registers and sets | Bartosz Sypytkowski
- CRDTs solve distributed data consistency challenges | Ably
- Diving into CRDTs | Redis Blog
- What are CRDTs | Loro Docs
- Building real-time collaboration: OT vs CRDT | TinyMCE
- CRDT Survey, Part 2: Semantic Techniques | Matthew Weidner