Skip to content

YJS와 Websocket 그리고 React‐Flow

김동준 edited this page Mar 16, 2025 · 1 revision

YJS란

참고 자료

https://www.tag1consulting.com/blog/deep-dive-real-time-collaborative-editing-solutions-tagteamtalk-001-0

YJS의 목적

  • 실시간 협업 솔루션에 대한 필요성.
  • 인트라넷, 이미 존재하는 서비스 등에 실시간 협업 기능을 쉽게 추가할 수 있는 방법 필요.

동시 편집, CRDT의 핵심

  • CRDT의 기본은 position-based가 아니라 모든 캐릭터에 대해 identifier를 두는 것.

    “this is some text"와 같은 텍스트가 있다고 하자. A, B가 이를 동시에 편집 중이다.

    A의 커서는 맨 앞에, position 0에 있다.

    B의 커서는 “some” 뒤, position 12에 있다.

    이 때, A는 “hello”를, B는 “nice”를 추가하고 싶다. 결국 동시 편집 상 원하는 결과는 “hello this is some nice text”이다.

    이 때, 각자의 position을 기준으로 동시편집이 이루어지면 A의 “hello”에 의해 기존 텍스트가 밀려나면서 “hello this is nice some text”와 같이 원하는 것과 다른 결과가 나올 수 있다.

    이를 해결하기 위해 마치 사람이 이 문제를 해결하듯이, B의 “nice”를 position 12가 아닌, “some” 뒤에 추가하는 것이 CRDT의 기본적인 해결 방법이다.

Decentralized Approach

  • Operational Transformation을 사용하는 기존의 솔루션들은 서버에서 동시성을 해결해주는 중앙화된 방식으로 동작한다. 단점은 제한된 동시성이다. 서버와 클라이언트 사이에서만 동시성이 존재하기 때문에. 또 이 서버가 single point of failure가 되기도 한다.
  • YJS, 혹은 CRDT는 이와 달리 좀 더 분산화된 방식을 채택하고 있다. CRDT는 변화가 어떤 순서로 들어오든지 같은 결과를 갖게 되기 때문에 중앙된 서버에 의존하지 않아도 된다. 이에 대한 장점은 중앙에 모이지 않으니 scalable하다는 것도 있고, 서버 연결이 되지 않는 비행기와 같은 공간에서도 한 팀이 같이 작업할 수 있다는 점도 있다.

Edit History와 Undo/Redo

  • Operational Transformation에서는 해당 문서의 history가 존재하기 때문에. Undo/Redo가 어렵지 않다.
  • 반면 CRDT에서는 그런 linear한 history가 없다. 트리 구조(정확히는 DAG인듯)를 띄기 때문에 Undo/Redo 시에는 이에 대한 경로와 state를 통해 해결하는데, 결론적으로는 잘 해결되어 있는 문제인 것 같다. 이를 위해 Undo Manager가 구현되어 있는데 문서의 scope와 권한의 scope를 지정해줄 수 있다.
  • 결국 고려할 사항은 유저가 Undo/Redo를 했을 때, 어떤 어떤 문서까지 수정할 것인지(현재 편집 중인 문서, 존재하는 모든 문서), 다른 유저가 수정한 것을 Undo/Redo할 것인지가 된다.

YJS와 Shared Types

Array, Map, Set 같은, 근데 자동적으로 state가 sync되는 Shared Types를 제공하는게 YJS의 핵심이다. 이 점에서 Rich Text Editor에 사용하기 적합하다.

팟캐스트 1시간 동안 들었는데 뒷부분에서는 크게 뭐 얻어갈 건 없었네요ㅠㅁㅠ


Websocket과 LevelDB

https://github.com/ivan-topp/y-socket.io

https://github.com/yjs/y-websocket

https://github.com/yjs/y-leveldb

  • Socket IO로 사용을 할 수는 있는데 일반적인 방법은 아닌 것 같다. 일반적으로는 websocket을 사용하는 듯.
  • persistance를 위해서는 levelDB를 주로 사용하는 것 같다, y-leveldb 있어서 그냥 yjs의 일부라고 생각하고 그냥 써도 될듯..
import * as Y from 'yjs'
import { LeveldbPersistence } from 'y-leveldb'

const persistence = new LeveldbPersistence('./storage-location')

const ydoc = new Y.Doc()
ydoc.getArray('arr').insert(0, [1, 2, 3])
ydoc.getArray('arr').toArray() // => [1, 2, 3]

// store document updates retrieved from other clients
persistence.storeUpdate('my-doc', Y.encodeStateAsUpdate(ydoc))

// when you want to sync, or store data to a database,
// retrieve the temporary Y.Doc to consume data
const ydocPersisted = await persistence.getYDoc('my-doc')
ydocPersisted.getArray('arr') // [1, 2, 3]
  • 메인 DB로는 현재 사용 중인 postgresql를 그대로 써도 될듯.. 하다. 무언가 검색할 일 생길 때도 좋을 것 같고..
  • 변화가 생기면 메인 DB에도 저장하는 건 그냥 이렇게 해주면 될 것 같다.
doc.on('update', async () => {
    await this.flowService.handleDocumentUpdate(flowId, doc);
});
  • 아니면 Y.Map 자체를 클라이언트랑 똑같이 obeserve할 수도 있는 듯
doc.getMap('nodes').observe(event => {
  event.changes.added.forEach((item) => {
    console.log('Node added:', item);
  });

  event.changes.deleted.forEach((item) => {
    console.log('Node deleted:', item);
  });

  event.changes.updated.forEach((item) => {
    console.log('Node updated:', item);
  });
});

아니 YJS 엄청 좋네


YJS를 사용한 노드 뷰 프로토타입

실행 예시

liveflow-screencast.1.mp4

Github 링크

https://github.com/djk01281/live-flow/blob/main/README.md

Y.Doc

Shared Types를 모두 담는 root 레벨의 일종의 컨터이너, 혹은 content라고 생각할 수 있다.

const doc = new Y.Doc();

상태를 저장할 수 있는 방법은 크게 AwarenessY.Map이 있다.

Awareness

일시적으로만 필요한 상태를 위해서 사용한다.

프로토타입에서는 커서 정보를 담기 위해 사용하고 있고, 마우스를 움직였을 때 이 값을 변경시켜준다. setLocalState()로 변경시켜준다.

interface AwarenessState {
  cursor: { x: number; y: number } | null;
  color: string;
  clientId: number;
}

// ...

const handleMouseMove = useCallback((e: React.MouseEvent) => {
  if (!provider.current?.awareness || !flowRef.current) return;

  const bounds = flowRef.current.getBoundingClientRect();
  const cursor = {
    x: e.clientX - bounds.left,
    y: e.clientY - bounds.top,
  };

  provider.current.awareness.setLocalState({
    cursor,
    color: userColor.current,
    clientId: provider.current.awareness.clientID,
  });
}, []);

다른 유저들은 이 정보에 접근하기 위해 awreness.on()의 콜백 함수를 사용할 수 있다.

const [cursors, setCursors] = useState<Map<number, AwarenessState>>(
  new Map()
);

// ...

wsProvider.awareness.on("change", () => {
  const states = new Map(
    wsProvider.awareness.getStates() as Map<number, AwarenessState>
  );
  setCursors(states);
});

// ...

return(
{Array.from(cursors.entries()).map(([clientId, state]) => 
    <Cursor
      key={clientId}
      x={state.cursor.x}
      y={state.cursor.y}
      color={state.color}
    />
  )
}

Y.Map

유지되어야 하는 정보를 위해 사용한다. 또, Undo/Redo를 위한 history 또한 보관한다.

프로토타입에서는 노드, 엣지 정보를 담기 위해 사용하고 있다. 프로토타입에는 아직 없지만 이 Y.Map을 위해 levelDB, y-leveldb를 사용하면 될 것 같다.

client에서 노드, 엣지의 상태는 다음과 같이 React Flow가 제공하는 hook을 통해 관리된다.

const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);

소켓 측에는 다음과 같이 저장한다.

const nodesMap = doc.getMap("nodes");
const edgesMap = doc.getMap("edges");

edge가 변경되면 이 client의 edges 상태와 함께 Y.Map을 변경시킨다.

  const onConnect = useCallback(
    (connection: Connection) => {
      if (!connection.source || !connection.target) return;

      const newEdge: Edge = {
        id: `e${connection.source}-${connection.target}`,
        source: connection.source,
        target: connection.target,
        sourceHandle: connection.sourceHandle || undefined,
        targetHandle: connection.targetHandle || undefined,
      };

      if (ydoc.current) {
        ydoc.current.getMap("edges").set(newEdge.id, newEdge);
      }

      setEdges((eds) => addEdge(connection, eds));
    },
    [setEdges]
  );

또, 다음과 같이 Y.Map에 변경이 있으면 그로부터 값을 가져와 client 상태를 변경시켜준다.

edgesMap.observe(() => {
  const yEdges = Array.from(edgesMap.values()) as Edge[];
  setEdges(yEdges);
});

후기

기술적인 검증

무언가 실시간으로 상태를 공유할 때, YJS를 통해 어떻게든 할 수 있겠다는 생각이 듬. 예를 들어 현재 편집 중인 edge도 공유할 수는 있을 것 같다. 다만, 이를 어떻게 svg와 bezier curve로 표현할 수 있는지는 모르겠다. (양 끝점은 알아도 control point 정보가 없는데 어떻게 해야할지..) 아니면 투명한 노드를 만들어야할까?

발견한 과제

라이브 커서를 구현한다고 했을 때, 문제가 될 만한 걸 발견했다. Canvas의 zoom, panning 상태다. 이를 통해 canvas 상의 x, y 위치를 계산해야 한다..! 악몽이 다시 떠오른다..

levelDB 자체는 그냥 설정만 해주고 persistance를 위해서만 쓰면 될 것 같음. 문제는 그것보다는 서비스의 어떤 부분에서 같은 ws를 쓸 것인지를 결정해야할 것 같음.

개발 문서

⚓️ 사용자 피드백과 버그 기록
👷🏻 기술적 도전
📖 위키와 학습정리
🚧 트러블슈팅

팀 문화

🧸 팀원 소개
⛺️ 그라운드 룰
🍞 커밋 컨벤션
🧈 이슈, PR 컨벤션
🥞 브랜치 전략

그룹 기록

📢 발표 자료
🌤️ 데일리 스크럼
📑 회의록
🏖️ 그룹 회고
🚸 멘토링 일지

Clone this wiki locally