CRDT 라이브러리 리팩토링 -1

hyonun·2025년 6월 2일

Nocta - CRDT구현

목록 보기
7/9

서론

noctaCRDT 리팩토링

기존에 만든 noctaCRDT라이브러리는 localInsert나 사용해야하는 메서드에 따라 그 함수 내부가 아닌 외부에서 설정을 바꿔야하는 경우가 많았습니다.

이를 방지하기 위해 noctaCRDT자체적으로 캐럿을 자동으로 관리할 수 있도록 라이브러리화를 좀더 체계적으로 진행하고 자 했습니다.

문제

현재는 Editor내에 CRDT를 생성하고 중첩적인 구조로 각 블록별, 문자별로 클래스 덩어리를 만들며 해당하는 연산이 일어날때마다 직접 그 블럭과 텍스트에 입력 처리를 해주는 연산이 일어나고 있습니다.

nocta-doc/
├─ package.json
├─ tsconfig.json
└─ src/
   ├─ core/                # CRDT 핵심 로직
   │   ├─ NoctaDoc.ts      # public 진입점
   │   ├─ OperationLog.ts  # encode/update, stateVector
   │   ├─ LinkedList.ts    # 공용 연결리스트
   │   └─ Id.ts            # Lamport·Client ID
   │
   ├─ model/               # 도메인 모델
   │   ├─ Block.ts
   │   ├─ Char.ts
   │   └─ Types.ts
   │
   ├─ ops/                 # 직렬화 대상 Operation 정의
   │   ├─ InsertOp.ts
   │   ├─ DeleteOp.ts
   │   └─ index.ts
   │
   ├─ codec/               # VarInt·CBOR 등 인코딩 헬퍼
   │   ├─ Encoder.ts
   │   └─ Decoder.ts
   │
   ├─ util/                # assert, helper, iterator
   │   └─ invariant.ts
   │
   └─ index.ts             # `export *` 모음

→ 위 구조는 외부 라이브러리 사용성을 극대화한 구조임.

→ 좀더 React나 실제 라이브러리에서 쓰기 쉬운 형태로 구조화와 분리가 필요함


폴더 구조

nocta-doc/
├── core/                   # CRDT 핵심 클래스들
│   ├── NoctaDoc.ts        # 중앙 API, Editor는 이것만 알면 됨
│   ├── Adapter.ts         # Editor와 NoctaDoc을 연결하는 중간계층
│   ├── CaretManager.ts    # 캐럿 위치 상태 추적 및 변경
│   └── StateVector.ts     # 버전벡터 관리 (CRDT 충돌 해결)

├── crdt/                  # CRDT 내부 자료구조
│   ├── BlockCRDT.ts       # 블록 수준 CRDT (e.g. reorder, checkbox toggle)
│   ├── CharCRDT.ts        # 문자 수준 CRDT (e.g. insert, delete)
│   ├── LinkedList.ts      # Char/Block용 공통 연결 리스트
│   ├── Node.ts            # CharNode, BlockNode 등
│   └── Id.ts              # Lamport clock + clientId 구조

├── model/                 # 도메인 모델
│   ├── Block.ts           # Block 모델 정의
│   ├── Char.ts            # Char 모델 정의
│   └── Types.ts           # 공통 인터페이스, enum 등

├── sync/                  # 네트워크 동기화 관련
│   ├── Encoder.ts         # encodeUpdate
│   ├── Decoder.ts         # applyUpdate
│   └── OperationLog.ts    # insert, delete 등의 로그 저장

├── store/                 # 내부 상태 저장소
│   ├── EditorState.ts     # 전체 문서 상태
│   ├── BlockState.ts      # Block들의 Map
│   └── CharState.ts       # Char들의 Map

├── utils/                 # 유틸 함수들
│   ├── PositionUtils.ts   # caret offset 계산 등
│   └── MergeUtils.ts      # CRDT 병합 함수

└── index.ts               # NoctaDoc의 진입점 (Adapter + Doc)

목표 사용성

클라이언트 기준

import { NoctaDoc } from "noctaDoc";

const doc = new NoctaDoc(clientId);

doc.insertChar(blockId, index, "안녕");
doc.deleteBlock(blockId);
// uuid + clock 

기본 구조

Nocta

  • 어뎁터 역할로 nocta라이브러리의 추상화 계층
  • 기타 소켓연결 관리

NoctaDoc(녹타독)

  • CRDT및 문서 기본 구조화와 CRDT관련

NoctaRealm(녹타 렐름)

ClientNoctaRealm, ServerNoctaRealm 으로 분기된다.

  • y.js로 치면, awarness 시스템
  • 동기화와 캐럿상태를 관리한다

  • 현재 client를 서버에서 부여한 정수를 사용하지 않고 uuid 를 부여받는다 고려하여 string을 쓰고있다.
  • blockCRDT → charCRDT → node → nodeId의 중첩 구조를 탈피하려고 했다.
  • blockCRDT와 charCRDT만두고, char에는 (blockId,prevId,nextId,value,style..) 로 관리해서 Map 저장공간에서 관리하는 형태였다. 그러면 불필요한 class생성도 줄일 수 있지 않을까 했다.
  • 리액트 렌더링 최적화 (React.memo)에서 이를 감지할 수 있는 방법이 있나 싶었다.
  • 만약 연결관계가 바뀌면, 클래스 내부에는 연결되어있는 부분이 쉽게 바뀐다. (prev, next만 연결)
  • 하지만 다시 렌더링 한다고하면? 모~든 char가 연결된 부분을 다 찾아서 다시 렌더링을 쏴줘야한다.
  • 이 결과 값들을 react.memo로 각 블럭별로 분기 처리를해야한다. (block1은 “asd”, block2는 “sdg” ..)
측면Block 내부 CharCRDTCharCRDT에서 blockId 분기
구성블록 독립적 상태 보유중앙 집중적 상태
확장성rich block 관리에 유리전체 구조가 단순
렌더링블록 단위 리렌더링 쉬움효율적이지만 분기 처리 필요
상태 동기화블록 단위 직렬화 가능전체 상태 직렬화 편함
중첩 깊이깊음얕음

만약에 block의 속성을 바꾼다면?

특정 글자를 드래그해서 리치텍스트를 적용한다면, CharNode?

blockCRDT - block(3번째)
charCRDT - char(3번쨰 블록의 2번째 글)

head: “a”-”b”-”c”(2번째블록), “e”-”f” (3번쨰블록)

실제 y.doc은 어떻게 쓰나?

  • 똑같이 중첩 구조로 쓰고 있다.
Y.Doc
 └── blocks (Y.Array)
       ├─ Block 0 (Y.Map)
       │     ├─ type: "paragraph"
       │     └─ chars: Y.Text ("Hello world")
       ├─ Block 1 (Y.Map)
       │     └─ ...
Y.Doc
 └── blocks (Y.Array)
       ├─ Block 0 (Y.Map)Y.Text ("Hello world","현훈","주호")

얘네들은 이름을 따로 나눠놨다.

Y.Array
문서의 block들을 저장하는 리스트
Y.Map
각 block을 표현. type, 속성 등 보관
Y.Text
실제 텍스트 데이터 (CRDT 문자 삽입/삭제 전용)

그래서 우리는?

  • 그대로 중첩 구조로 가지만, linkedlist등의 불필요한 클래스는 제거 한다.

  • NoctaDoc

    • BlockNode
      • CharNode
  • block을 제거하면 CharCRDT에서도 해당 blockId 다 날려야 해서 묶여 있음

    • 이부분은 tombstone처리로 인해 큰 문제가 안될 수도 있다.

물론 우리도 한줄로 쭉 작성하고, block을 그냥 구분자 느낌으로 줄 수 있지만, 기존 구조가 이미 block 단위 리치 편집기로 쓰고있기 때문에 중첩구조는 그대로 가져가기로 하였다.


Operation 구조 재설정

export interface InsertOperation {
  type: "insert";
  node: Block | Char;
}
  • 기존구조는 해당 타입 체킹을 받는 곳에서 해야한다
  • 명확하게 blockCRDT와 charCRDT를 분리하기로 했으므로, operation도 따로 분리하기로했다.

Realm의 역할

✅ 캐럿 관리 흐름 예시

1. 클라이언트에서 캐럿 변경 감지

editor.addEventListener("selectionchange", () => {
  const caret = getCaretPosition();
  nocta.realm.setCaret(blockId, charId); // ← 커서 위치 업데이트
});

2. ClientNoctaRealm에서 서버로 전송

setCaret(blockId: string, charId: string) {
  this.socket.emit("caret", { blockId, charId });
}

3. 서버(ServerNoctaRealm)에서 다른 유저들에게 전파

this.socket.on("caret", (payload) => {
  // 저장하거나, 그대로 브로드캐스트
  this.socket.broadcast.emit("caret", { clientId, ...payload });
});

4. 다른 클라이언트에서 수신

this.socket.on("caret", ({ clientId, blockId, charId }) => {
  // 해당 유저의 캐럿을 화면에 표시
  renderRemoteCaret(clientId, blockId, charId);
});
  • NoctaRealm은 소켓 통신 + 유저 상태를 담당
  • 커서 위치는 NoctaDoc이 아닌 NoctaRealm이 추적하고 broadcast
  • 필요 시 내부적으로 clientAwareness: Map<clientId, caretState> 같은 구조로 관리

동작 테스트 컴포넌트

import { Nocta } from "@noctaDoc";
import { useRef, useEffect } from "react";

const socket = {
  on: () => {},
  emit: () => {},
};

const client = Nocta.createClient({ socket, clientId: "client-123" });

export const TestEditor = () => {
  const editorRef = useRef<HTMLDivElement>(null);
  const prevText = useRef("");
  useEffect(() => {
    // 처음 렌더링 시 block-1 생성
    client.insertBlock(null, "block-1", "paragraph");
  }, []);

  const handleInput = () => {
    const blockId = "block-1";
    const newText = editorRef.current?.innerText || "";
    const oldText = prevText.current;

    if (newText.length > oldText.length) {
      // 입력 발생
      const addedChar = newText.slice(oldText.length); // 단일 문자만 가정
      client.insertChar(blockId, oldText.length, addedChar);
    } else if (newText.length < oldText.length) {
      // 삭제 발생
      client.deleteChar(blockId, oldText.length - 1);
    }
    console.log(client.getText("block-1"));
    prevText.current = newText;
  };

  return (
    <div>
      <h2>TestEditor</h2>
      <div
        ref={editorRef}
        contentEditable
        onInput={handleInput}
        style={{
          border: "1px solid black",
          padding: "8px",
          minHeight: "100px",
          fontSize: "16px",
        }}
      />
    </div>
  );
};
  • 삭제, 삽입연산은 일단 테스트 컴포넌트에서 판단해서 삽입 삭제 연산이 일어나게 했다.

간단한 동작 테스트 성공!

Github PR:
https://github.com/boostcampwm-2024/refactor-web33-Nocta/pull/48

0개의 댓글