CRDT(Yjs) 문서는 평평한 키로, 큰 숫자 열은 바이너리 하나로 둔다
동시에 생길 수 있는 구조는 합쳐지는 단위로 나누고, 통째로 쓰고 잘 안 바뀌는 데이터는 압축해 한 값으로 둔다.
Yjs로 공동 편집을 만들면 두 가지 문제가 자주 나온다. 두 사람이 동시에 같은 도형을 만들면 한쪽 속성이 통째로 사라지고, 그림 몇 개 그렸을 뿐인데 문서 하나가 수 MB가 된다.
사라지는 하위 Map. 같은 키에 두 클라이언트가 각자 new Y.Map()을 넣으면, 두 Map은 합쳐지지 않고 하나만 남는다. 다른 쪽이 그 안에 쓴 내용은 함께 사라진다. 「없으면 만든다」 식의 초기화도 같은 충돌과 undo 문제를 만든다.
해법은 "<id>/<필드>"처럼 평평한 키다. 필드 하나하나가 따로 합쳐지니 동시 생성에도 잃는 것이 없다. 기본값은 문서에 쓰지 말고 읽을 때 채운다.
부푸는 숫자 열. 좌표를 Y.Array에 원소 하나씩 넣으면 값마다 메타데이터가 붙어 수십 바이트가 든다. 좌표처럼 한 번에 쓰고 잘 안 바뀌는 데이터는 정밀도를 정해 정수로 바꾸고, 앞 값과의 차이(delta)를 varint로 압축해 바이너리 하나로 저장한다. 이렇게 하면 크기가 약 1/4~1/5로 준다.
import * as Y from 'yjs';
const doc = new Y.Doc();
const shapes = doc.getMap<unknown>('shapes');
// 나쁨: shapes.set(id, new Y.Map()) — 동시에 만들면 한쪽이 사라진다
shapes.set(`${id}/color`, '#e5484d');
shapes.set(`${id}/points`, encodePoints(points));
function encodePoints(points: number[], precision = 100): Uint8Array {
const out: number[] = [];
let prev = 0;
for (const p of points) {
const n = Math.round(p * precision);
const d = n - prev;
prev = n;
let z = d >= 0 ? d * 2 : -d * 2 - 1; // zigzag: 음수도 작은 양수로
while (z >= 0x80) {
out.push((z % 0x80) | 0x80);
z = Math.floor(z / 0x80);
}
out.push(z);
}
return Uint8Array.from(out);
}
동시에 여럿이 고칠 수 있는 데이터는 잘게 나누고, 한 사람이 통째로 쓰는 데이터는 하나로 뭉친다. 이 기준 하나로 문서 구조 대부분이 정해진다.