실시간 협업 앱 제작을 위한 Yjs, 그리고 CRDT
충돌 없는 동시 편집, Yjs 사용과 내부 CRDT 동작 정리
Yjs 라이브러리와, 이 라이브러리의 주요 개념인 CRDT에 대해서 정리한 글입니다. Figma나 Google Docs와 같이 동시 편집 앱을 만들 시, 꼭 필요한 지식입니다.
1. Yjs는 무엇일까요?
- 실시간 협업 앱을 만들기 위한 라이브러리입니다.
- 실시간 협업은 동시에 편집이 발생하여 충돌이 일어날 수 있습니다. Yjs는 CRDT 자료구조를 사용해서 일관성을 유지합니다.
2. Yjs의 핵심 개념, 특징
2-1. CRDT 사용
- CRDT: Conflict-free replicated data type
- 분산 시스템에서 원활한 협업, 데이터 동기화를 가능하게 하는 데이터 구조입니다.
- 여러 사용자가 중앙 서버의 조정 없이도 동시에 문서를 편집할 수 있습니다.
- 네트워크 단절이 발생해도 모든 복제몬이 동일한 상태로 수렴합니다.
- CRDT에 대한 자세한 설명은 하단에서 따로 다루도록 하겠습니다.
2-2. 네트워크 독립적인 설계
- 공식 문서에서 Yjs는 Network Agnostic 설계로 만들어짐을 말하고 있습니다. (이는 CRDT의 특징이기도 합니다.)
- 즉, 어떤 네트워크 기술을 사용하던 변경 사항이 도착하기만 한다면 문서는 동기화 된다는 것입니다.
- 중앙 서버가 필수가 아닌 점과 통신 기법이 자유로운 점은 백엔드 설계의 다양한 가능성을 열어줍니다.
- Livebloocks, Y-Sweet, Tiptap과 같은 서비스를 사용하면 백엔드를 따로 유지보수하지 않고 앱을 구현할 수도 있습니다.
2-3. 공유 데이터 타입
- Yjs는 CRDT 모델을 기반으로 구현한 자료 구조를 제공합니다. 이를 공유 데이터 타입(Shared Types)라고 합니다.
- Text, Array, Map, Xml 등의 Shared Types가 있습니다.
- JS 코드 상에서 일반적인 자료 구조 사용과 유사하게 사용할 수 있습니다.
- 공유 타입의 변경 사항은 자동으로 감지되고 동기화됩니다.
3. Yjs 기본 사용법
3-1. Y.Doc
Yjs의 중심 요소는 Y.Doc
입니다. Y.Doc
은 여러 공유 데이터 타입을 포함하고 동기화를 관리합니다.
1
2
3
4
5
6
7
// Y.Doc 인스턴스 생성
const ydoc = new Y.Doc();
// 데이터 타입 정의
const ymap = ydoc.getMap("myMap");
const yarray = ydoc.getArray("myArray");
const ytext = ydoc.getText("myText");
3-2. Provider
Yjs는 다양한 프로바이더를 통해서 데이터 동기화를 할 수 있습니다. y-websocket, y-webrtc, y-indexeddb, y-dat과 같은 종류들이 있고, 원하는 프로토콜 방식에 따라서 프로바이더를 설정합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
// Y.Doc 인스턴스 생성
const ydoc = new Y.Doc();
// 웹 소켓 서버 연결
const provider = new WebSocketProvider(
"wss://demos.yjs.dev",
"my-roomname",
ydoc
);
// 데이터 타입 정의
const ytext = ydoc.getText("my-text");
// 이벤트 리스너 사용 예시 (변경 감지)
ytext.observe((event) => {
console.log("텍스트 변경:", ytext.toString());
});
// 텍스트 변경
ytext.insert(0, "Hello, Yjs!");
3-3. React에서의 적용
많은 경우, React 프로젝트 기반 위에 Yjs를 사용하는 것을 고려할 것입니다. React의 상태 관리 시스템과 결합하기 위한 좋은 사용 방법을 react-yjs
내부 코드에서 확인할 수 있습니다. react-yjs/packages/react-yjs/src/useY.ts
3-4. Details
Yjs의 구체적인 사용 방법은 공식 문서인 Yjs Docs에 있습니다. 또한, 최근 Jamsocket에서 공개한 인터랙티브 튜토리얼을 통해 더 쉽게 이해할 수 있습니다.
4. CRDT는 무엇일까요?
4-1. 역사
실시간 협업 도구에서 동시 편집을 지원하기 위해서 많은 연구가 80년대부터 존재했는데, 그 중에서 각광받았던 것은 OT(Operational Transform)였습니다. Google Docs도 OT를 기반으로 제작되었습니다.
OT는 모든 변경에 대해 시간순으로 목록을 저장하는데, 충돌을 해결하기 위한 작업을 중앙 서버가 수행합니다. 이것이 OT의 가장 큰 단점인데, 중앙 서버에 의존하기 때문에 네트워크 상황에 따라서 동기화가 잘 이루어지지 않을 수 있고, 분산 시스템에서의 적용이 어렵다는 문제가 있습니다.
분산 시스템: 여러 개의 독립적인 기기(노드)가 네트워크를 통해 데이터를 공유하는 구조
여러 개의 서버를 운영하면서 부하를 분산하고, 지연을 줄여나가는 시도를 통해 OT를 개선해 나갔습니다. 그런데 2006년에 나타난 CRDT는 기본적으로 클라이언트 단에서 병합을 이루어지게 하여 중앙 서버가 존재하지 않아도 데이터 일관성을 보장할 수 있는 방법으로 문제를 해결합니다.
4-2. CRDT 기본 동작 원리
각 문자는 특정한 위치에 고유한 ID를 가지고 삽입됩니다. 노드 A와 노드 B가 동시에 같은 위치에 문자를 삽입한다고 해도, 두 문자는 다른 ID를 가집니다.
Node A, Node B 초기 상태
문자 | ID |
---|---|
a | 0a |
b | 1a |
c | 2a |
이제 노드 A와 노드 B가 동시에 다른 문자를 삽입한다고 가정하겠습니다.
Node A
- ‘x’를 0a 오른쪽에 삽입 → ID: 3a
- ‘y’를 3a 오른쪽에 삽입 → ID: 4a
문자 | ID |
---|---|
a | 0a |
x | 3a |
y | 4a |
b | 1a |
c | 2a |
Node B: p,q 삽입
- ‘p’를 0a 오른쪽에 삽입 → ID: 3b
- ‘q’를 3b 오른쪽에 삽입 → ID: 4b
문자 | ID |
---|---|
a | 0a |
p | 3b |
q | 4b |
b | 1a |
c | 2a |
그리고 발생한 이벤트대로 각 노드에 바로 처리해보겠습니다.
Node A
문자 | ID |
---|---|
a | 0a |
p | 3b |
q | 4b |
x | 3a |
y | 4a |
b | 1a |
c | 2a |
Node B
문자 | ID |
---|---|
a | 0a |
x | 3a |
y | 4a |
p | 3b |
q | 4b |
b | 1a |
c | 2a |
위와 같이 각 Node 끼리 다른 상태가 만들어지는 문제가 발생합니다. 따라서 CRDT는 병합 규칙을 적용하여 충돌을 해결합니다. 이 예시에서는 ID의 비교와 삽입 의도를 고려해서 다음과 같은 최종 수렴 상태가 나타납니다.
Node A, Node B
문자 | ID |
---|---|
a | 0a |
x | 3a |
y | 4a |
p | 3b |
q | 4b |
b | 1a |
c | 2a |
- 삽입 의도 고려: a 뒤에 삽입될 후보는 x와 p입니다.
- ID 비교: 3a와 3b를 비교해보면 3a가 3b보다 앞에 옵니다. x를 삽입합니다.
- 삽입 의도 고려: 3a(x) 뒤에 삽입될 후보는 4a(y)만 존재합니다. y를 삽입합니다.
- 이후에 p와 q가 삽입됩니다.
어떤 알고리즘, 비교 규칙을 사용하냐에 따라서 다르겠지만, 위와 같은 비교 과정을 거쳐서 어떤 클라이언트에서든 동일한 결과물이 보이게 된다는 것이 핵심입니다. 규칙이 존재하기 때문에 언제가 병합 시점이 되더라도 일관성을 유지합니다.
4-3. Yjs의 경우
4-2
에서 봤던 CRDT는 ‘연산 기반 CRDT’입니다. Yjs는 연산 기반 CRDT와 상태 기반 CRDT를 모두 사용하는데, 발생한 작업이 삽입이냐 삭제냐에 따라서 다릅니다.
- 삽입: 연산 기반 CRDT, 각 삽입에는 clientID와 clock을 포함한 고유 ID를 가지고 있고, 이것을 통해서 규칙을 따라 순서를 결정합니다.
- 삭제: 상태 기반 CRDT, 해당 item에 ‘삭제됨’이라는 플래그를 설정하여 ‘논리적으로 삭제된 상태’로 만듭니다. 삭제됨 플래그가 설정된 경우 더 이상 보이지 않게 되며, 이후 GC가 동작하면 메모리에서 해제시킵니다.
🏷️ References
[Yjs deep dive: How Yjs makes real-time collaboration easier and more efficient - part 2 | Tag1 Consulting](https://www.tag1consulting.com/blog/yjs-deep-dive-part-2) |
[Introduction | Yjs Docs](https://docs.yjs.dev/) |
[제가 틀렸었어요. CRDT가 미래입니다. | GeekNews](https://news.hada.io/topic?id=2962) |
[Internals | Yjs Docs](https://beta.yjs.dev/docs/api/internals/), yjs/INTERNALS.md at main · yjs/yjs |
CRDT의 기본 개념과 원리 그리고 구현체인 Yjs의 원리
[CRDT, 실시간으로 데이터 일관성을 유지하는 법 | 설명탕](https://redundant4u.com/post/crdt) |