개요
CRDT 라이브러리(Yjs, Automerge 등)를 사용할 때 CRDT 형식의 operation을 받게 됩니다. 이러한 operation을 에디터의 operation 형식으로 변환한 후 모델에 적용하고 렌더링해야 합니다.
핵심 포인트:
- CRDT operation은 path/offset이 아닌 position ID 사용
- CRDT position을 에디터 path로 매핑 필요
- CRDT operation을 에디터 operation으로 변환
- 에디터의 transaction 시스템을 통해 operation 적용
- 원격 operation 도착 시 선택 영역 업데이트 처리
CRDT Operation 형식
CRDT 라이브러리는 일반적으로 다음과 같은 형식의 operation을 제공합니다:
// Yjs, Automerge 등에서 받는 CRDT operation 예제
interface CRDTOperation {
type: 'insert' | 'delete' | 'update' | 'format';
position: PositionId; // 전역 고유 위치 식별자
after?: PositionId; // 이 위치 뒤에 삽입
positions?: PositionId[]; // 범위에 대한 여러 위치
content?: string | Node;
attributes?: Record<string, any>;
siteId: string;
clock: number;
}
// 위치 식별자 (CRDT 라이브러리마다 다름)
interface PositionId {
siteId: string;
clock: number;
offset?: number; // 분수 위치용
}
// 예제: Yjs operation
const yjsOperation = {
type: 'insert',
after: { siteId: 'user1', clock: 42 },
positions: [
{ siteId: 'user2', clock: 100, offset: 0.1 },
{ siteId: 'user2', clock: 100, offset: 0.2 }
],
content: 'hi',
siteId: 'user2',
clock: 100
};위치 매핑
핵심 과제는 CRDT position ID를 에디터의 path/offset 시스템으로 매핑하는 것입니다. 양방향 매핑을 유지해야 합니다.
위치 매핑 전략
class CRDTPositionMapper {
// CRDT position을 에디터 path/offset으로 매핑
private crdtToEditor = new Map<string, { path: number[], offset: number }>();
// 에디터 path/offset을 CRDT position으로 매핑
private editorToCrdt = new Map<string, PositionId>();
// CRDT position을 에디터 path로 변환
toEditorPath(crdtPosition: PositionId, model: DocumentModel): { path: number[], offset: number } | null {
const key = this.positionKey(crdtPosition);
// 캐시 확인
if (this.crdtToEditor.has(key)) {
return this.crdtToEditor.get(key)!;
}
// 모델을 순회하며 위치 찾기
const result = this.findPositionInModel(crdtPosition, model);
if (result) {
this.crdtToEditor.set(key, result);
const editorKey = this.editorPathKey(result.path, result.offset);
this.editorToCrdt.set(editorKey, crdtPosition);
}
return result;
}
// 모델에 CRDT position 저장
private findPositionInModel(crdtPosition: PositionId, model: DocumentModel): { path: number[], offset: number } | null {
// 모델을 순회하며 일치하는 CRDT position을 가진 노드 찾기
// 모델 노드에 CRDT position ID를 저장해야 함
return this.traverseModel(model, crdtPosition);
}
}모델에 CRDT Position 저장
// 모델을 확장하여 CRDT position 저장
interface TextNode {
type: 'text';
text: string;
charPositions?: PositionId[]; // 각 문자에 대한 CRDT position
formats?: Record<string, any>;
}
interface BlockNode {
type: string;
crdtPosition?: PositionId; // 노드의 CRDT position
children: Node[];
attributes?: Record<string, any>;
}
// 로컬 operation 적용 시 CRDT position 저장
class EditorWithCRDT {
applyLocalOperation(operation: EditorOperation) {
const tx = this.beginTransaction();
// Operation 적용
tx.add(operation);
// CRDT position 생성 및 저장
if (operation.type === 'insertText') {
const crdtPositions = this.generateCRDTPositions(operation.path, operation.text.length);
this.storeCRDTPositions(operation.path, crdtPositions);
}
tx.commit();
// CRDT 라이브러리로 전송
this.sendToCRDT(operation, crdtPositions);
}
}Operation 변환
CRDT operation을 에디터의 operation 형식으로 변환합니다:
Insert Operations
class CRDTOperationConverter {
constructor(
private positionMapper: CRDTPositionMapper,
private editor: Editor
) {}
convertInsert(crdtOp: CRDTOperation): EditorOperation | null {
// CRDT position을 에디터 path로 매핑
const editorPath = this.positionMapper.toEditorPath(crdtOp.after || crdtOp.position, this.editor.getModel());
if (!editorPath) {
console.warn('CRDT position을 에디터 path로 매핑할 수 없음', crdtOp);
return null;
}
if (typeof crdtOp.content === 'string') {
// 텍스트 삽입
return {
type: 'insertText',
path: editorPath.path,
offset: editorPath.offset,
text: crdtOp.content
};
} else {
// 노드 삽입
return {
type: 'insertNode',
path: editorPath.path,
node: this.convertCRDTNodeToEditorNode(crdtOp.content)
};
}
}
}Delete Operations
convertDelete(crdtOp: CRDTOperation): EditorOperation | null {
if (crdtOp.positions && crdtOp.positions.length > 0) {
// 범위 삭제
const startPath = this.positionMapper.toEditorPath(crdtOp.positions[0], this.editor.getModel());
const endPath = this.positionMapper.toEditorPath(
crdtOp.positions[crdtOp.positions.length - 1],
this.editor.getModel()
);
if (!startPath || !endPath) return null;
// 길이 계산
const length = this.calculateLength(startPath, endPath);
return {
type: 'deleteContent',
path: startPath.path,
offset: startPath.offset,
length: length
};
}
return null;
}Format Operations
convertFormat(crdtOp: CRDTOperation): EditorOperation | null {
if (!crdtOp.positions || crdtOp.positions.length < 2) return null;
const startPath = this.positionMapper.toEditorPath(crdtOp.positions[0], this.editor.getModel());
const endPath = this.positionMapper.toEditorPath(
crdtOp.positions[crdtOp.positions.length - 1],
this.editor.getModel()
);
if (!startPath || !endPath) return null;
const length = this.calculateLength(startPath, endPath);
return {
type: 'applyFormat',
path: startPath.path,
offset: startPath.offset,
length: length,
format: Object.keys(crdtOp.attributes || {})[0],
value: Object.values(crdtOp.attributes || {})[0]
};
}
// 메인 변환 메서드
convert(crdtOp: CRDTOperation): EditorOperation | EditorOperation[] | null {
switch (crdtOp.type) {
case 'insert':
return this.convertInsert(crdtOp);
case 'delete':
return this.convertDelete(crdtOp);
case 'format':
return this.convertFormat(crdtOp);
case 'update':
return this.convertUpdateAttributes(crdtOp);
default:
console.warn('알 수 없는 CRDT operation 타입', crdtOp.type);
return null;
}
}Operation 적용
변환 후 에디터의 transaction 시스템을 통해 operation을 적용합니다:
class CRDTEditorAdapter {
constructor(
private editor: Editor,
private converter: CRDTOperationConverter,
private positionMapper: CRDTPositionMapper
) {}
// 들어오는 CRDT operation 처리
handleCRDTOperation(crdtOp: CRDTOperation) {
// 우리 자신의 operation이면 건너뛰기
if (crdtOp.siteId === this.editor.getSiteId()) {
return;
}
// 에디터 operation으로 변환
const editorOps = this.converter.convert(crdtOp);
if (!editorOps) {
console.warn('CRDT operation 변환 실패', crdtOp);
return;
}
// 배열로 정규화
const operations = Array.isArray(editorOps) ? editorOps : [editorOps];
// Transaction을 통해 적용
const tx = this.editor.beginTransaction();
for (const op of operations) {
tx.add(op);
// 향후 매핑을 위해 모델에 CRDT position 저장
this.storeCRDTPositions(op, crdtOp);
}
// Transaction 커밋 (렌더링 트리거)
tx.commit();
// 모델 변경 후 위치 캐시 무효화
this.positionMapper.invalidate();
}
// CRDT operation 배치 처리
handleCRDTOperations(crdtOps: CRDTOperation[]) {
// Transaction별로 그룹화 (같은 clock)
const transactions = this.groupByTransaction(crdtOps);
for (const transactionOps of transactions) {
const tx = this.editor.beginTransaction();
for (const crdtOp of transactionOps) {
const editorOps = this.converter.convert(crdtOp);
if (editorOps) {
const ops = Array.isArray(editorOps) ? editorOps : [editorOps];
for (const op of ops) {
tx.add(op);
this.storeCRDTPositions(op, crdtOp);
}
}
}
tx.commit();
}
this.positionMapper.invalidate();
}
}렌더링 업데이트
에디터의 렌더링 시스템은 transaction을 통해 operation이 적용될 때 자동으로 업데이트됩니다:
// 에디터는 이미 렌더링을 처리해야 함
class Editor {
beginTransaction(): Transaction {
return new Transaction(this);
}
// Transaction 커밋이 렌더링 트리거
commitTransaction(tx: Transaction) {
// 모델에 operation 적용
for (const op of tx.operations) {
this.applyOperationToModel(op);
}
// 렌더 업데이트 트리거
this.renderer.update(this.model);
// 필요 시 선택 영역 업데이트
this.updateSelection(tx.afterSelection);
}
}
// 렌더러는 모델 변경에 따라 DOM 업데이트
class Renderer {
update(model: DocumentModel) {
// 모델과 DOM 차이 계산
const changes = this.diffModelAndDOM(model, this.currentDOM);
// 변경 사항을 증분적으로 적용
for (const change of changes) {
this.applyChange(change);
}
this.currentDOM = this.serializeModel(model);
}
}선택 영역 처리
원격 operation이 도착하면 선택 영역을 조정해야 합니다:
class CRDTSelectionManager {
constructor(private editor: Editor, private positionMapper: CRDTPositionMapper) {}
// 원격 operation 도착 시 선택 영역 조정
adjustSelectionForRemoteOperation(
currentSelection: Selection,
crdtOp: CRDTOperation
): Selection {
const editorOp = this.editor.getConverter().convert(crdtOp);
if (!editorOp) return currentSelection;
// Operation이 선택 영역에 영향을 주는지 확인
if (this.operationAffectsSelection(editorOp, currentSelection)) {
return this.adjustSelection(editorOp, currentSelection);
}
return currentSelection;
}
private adjustSelection(op: EditorOperation, selection: Selection): Selection {
if (op.type === 'insertText') {
// 삽입이 선택 시작 전이면 선택 영역 이동
if (this.isBefore(op.path, op.offset, selection.start.path, selection.start.offset)) {
return {
start: {
path: selection.start.path,
offset: selection.start.offset + op.text.length
},
end: {
path: selection.end.path,
offset: selection.end.offset + op.text.length
}
};
}
} else if (op.type === 'deleteContent') {
// 삭제가 선택 전이면 선택 영역을 뒤로 이동
if (this.isBefore(op.path, op.offset, selection.start.path, selection.start.offset)) {
const deletedLength = op.length;
return {
start: {
path: selection.start.path,
offset: Math.max(0, selection.start.offset - deletedLength)
},
end: {
path: selection.end.path,
offset: Math.max(0, selection.end.offset - deletedLength)
}
};
}
}
return selection;
}
}