Skip to content

YJS 가이드 근데 이제 Socket.io를 곁들인

Hyunjun KIM edited this page Nov 16, 2024 · 3 revisions

데모

example.mp4

🐙 데모 구현 코드

  1. Socket.io로 통신, Y.doc에 일어난 변경 값을 서버에서 확인
  2. 클라이언트들과 서버 간 같은 Y.doc을 공유

YJS

image

YJS상태를 충돌없이 공유할 수 있게 해주는 라이브러리라고 할 수 있습니다. 이 상태를 담아둔 공간을 Y.Doc이라고 합니다.

동시성 해결을 위해서는 CRDT를 사용합니다.

동시 사용이 가능한 토글 버튼 만들기

가장 간단한 예시를 생각해보면, 다음과 같을 것 같습니다.

const toggleMap = {
    toggle: true
}

이와 같은 object에 여러 사람이 동시에 접근해야하는 상황입니다. 각 클라이언트에는 버튼이 있고, 이를 눌러서 버튼의 상태를 토글해줄 수 있습니다.

실시간으로 동작해야 하기 때문에, 다른 클라이언트에서 클릭했을 때의 변화도 바로 알 수 있고 동시에 클릭하더라도 이에 대한 충돌을 해결해줘야 합니다.

[클라이언트]

이를 위한 클라이언트 코드는 다음과 같습니다.

import { useEffect, useState, useRef } from "react";
import * as Y from "yjs";
import { SocketIOProvider } from "y-socket.io";

export default function App() {
  const [toggle, setToggle] = useState(false);

  const ydocRef = useRef<Y.Doc>();
  const providerRef = useRef<SocketIOProvider>();

  useEffect(() => {
    ydocRef.current = new Y.Doc();

    const provider = new SocketIOProvider(
      "http://localhost:3000",
      "room-name",
      ydocRef.current,
      {
        autoConnect: true,
        disableBc: false,
      },
      {
        reconnectionDelayMax: 10000,
        timeout: 5000,
        transports: ["websocket", "polling"],
      }
    );

    providerRef.current = provider;

    provider.awareness.setLocalState({
      user: {
        name: `User-${Math.floor(Math.random() * 100)}`,
      },
    });

    const toggleMap = ydocRef.current.getMap("toggleMap");

    const updateToggleState = () => {
      const newValue = toggleMap.get("toggle");
      setToggle(Boolean(newValue));
    };
    if (!toggleMap.has("toggle")) {
      toggleMap.set("toggle", false);
    } else {
      updateToggleState();
    }

    toggleMap.observe(() => {
      updateToggleState();
    });

    return () => {
      provider.destroy();
      ydocRef.current?.destroy();
    };
  }, []);

  const handleToggle = () => {
    if (!ydocRef.current) return;

    const toggleMap = ydocRef.current.getMap("toggleMap");
    const newValue = !toggle;

    toggleMap.set("toggle", newValue);
  };

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        gap: "20px",
        justifyContent: "center",
        alignItems: "center",
        width: "100vw",
        height: "100vh",
        backgroundColor: "#231f20",
      }}
    >
      <button
        onClick={handleToggle}
        style={{
          width: "96px",
          height: "96px",
          borderRadius: "50%",
          transition: "background-color 0.3s",
          backgroundColor: toggle ? "#f7b528" : "#e9203d",
          border: "none",
          cursor: "pointer",
        }}
      />
    </div>
  );
}

처음 한번만 useEffect 내부의 함수가 실행됩니다.

ydocRef.current = new Y.Doc();

const provider = new SocketIOProvider(
  "http://localhost:3000",
  "room-name",
  ydocRef.current,
  {
    autoConnect: true,
    disableBc: false,
  },
  {
    reconnectionDelayMax: 10000,
    timeout: 5000,
    transports: ["websocket", "polling"],
  }
);

providerRef.current = provider;

Y.Doc을 만들고 이를 provider에 연결해줍니다.

이제 toggle 상태를 담을 map이 필요합니다.

// 1
const toggleMap = ydocRef.current.getMap("toggleMap");

// 2
const updateToggleState = () => {
  const newValue = toggleMap.get("toggle");
  setToggle(Boolean(newValue));
};
if (!toggleMap.has("toggle")) {
  toggleMap.set("toggle", false);
} else {
  updateToggleState();
}
  1. “state”라는 임의의 이름의 map을 받아옵니다.
  2. sharedMaptoggle이란 key가 없으면 false로 초기화해줍니다. 있다면 다른 사용자가 이미 만든 것(접속한 것)입니다.

공유하고 있는 sharedMap에 일어난 변화를 감지할 수 있습니다. 이는 뒤에 나오겠지만 서버에서도 마찬가지로 감지할 수 있습니다.

const updateToggleState = () => {
  const newValue = toggleMap.get("toggle");
  setToggle(Boolean(newValue));
};

toggleMap.observe(() => {
  updateToggleState();
});

변화가 감지되면 이 값을 로컬의 상태에 반영해줍니다. (React - useState)

이제 toggle 상태에 따라 다른 색상의 버튼을 보여주고, 버튼을 클릭하면 공유되고 있는 toggle 상태를 변경해주어야 합니다.

// 1
const handleToggle = () => {
  if (!ydocRef.current) return;

  const toggleMap = ydocRef.current.getMap("toggleMap");
  const newValue = !toggle;

  toggleMap.set("toggle", newValue);
};

// 2 
return (
  <button
    {...}
  />
);
  1. 버튼 클릭 시, sharedMap의 토글 값을 바꿔줍니다. Y.Mapset()해주는 것은 일반적인 map에 해주는 방식과 동일합니다.
  2. toggle의 상태에 따라 다른 색상의 버튼을 보여줍니다 (생략).

현재의 Socket 서버

[서버]

우선 소켓 서버를 만들어야 클라이언트가 이에 접속할 수 있습니다. 현재는 이를 위해 y-websocket 라이브러리를 사용합니다.

import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import * as WebSocket from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils';

@Injectable()
export class YjsService implements OnModuleInit {
  private wss: WebSocket.Server;
  private logger = new Logger('YjsService');

  onModuleInit() {
    this.wss = new WebSocket.Server({ port: 1234 });

    this.wss.on('connection', (ws: WebSocket, req: any) => {
      this.logger.log('Client connected');
      setupWSConnection(ws, req);
    });

    this.logger.log('WebSocket server initialized on port 1234');
  }
}

여기까지만 했을 때도 클라이언트끼리는 소켓 서버를 통해 상태를 공유할 수 있습니다.

문제는 공유된 상태을 저장해야된다는 점입니다. 클라이언트가 접속하지 않아 있고 (즉 상태를 들고 있는 사람이 없고), 서버가 재시작된다면 공유된 상태는 사라지게 됩니다.

빠른 저장을 위해 leveldb 또는 redis를 사용할 수 있고, 이를 위한 어댑터들도 존재합니다 (y-leveldb, y-redis).

또, 지금처럼 y-websocket 사용시, 공유된 상태에 접근 하기 위해서는 (결국 접근해야 처리해줄 수 있으니까) 이런 leveldb 또는 redis를 추가로 사용해야해서 현재 develop 브랜치의 yjs 모듈로는 불가능합니다.

따라서 우선 Socket.io를 사용해서 현재 공유된 상태에 접근하는 예시를 만들어봤습니다.

Socket.io 를 사용한 YJS 소켓 서버

목적은 서버에서 공유된 상태에 접근하는 것입니다. 결국 이 상태에 변경이 일어나면 이를 저장해주는 로직이 서버로 옮겨가야 하기 때문에.

import {
  OnGatewayConnection,
  OnGatewayDisconnect,
  OnGatewayInit,
  WebSocketGateway,
  WebSocketServer,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { Server } from 'socket.io';
import { YSocketIO } from 'y-socket.io/dist/server';
import * as Y from 'yjs';

@WebSocketGateway({
  cors: true,
  transports: ['websocket', 'polling'],
})
export class YjsGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  private logger = new Logger('YjsGateway');
  private ysocketio: YSocketIO;

  @WebSocketServer()
  server: Server;

  afterInit() {
    this.ysocketio = new YSocketIO(this.server, {
      gcEnabled: true,
    });

    this.ysocketio.initialize();

    this.ysocketio.on('document-loaded', (doc: Y.Doc) => {
      this.logger.log(`Document loaded: ${doc.guid}`);

      const toggleMap = doc.getMap('toggleMap');
      toggleMap.observe(() => {
        const toggleState = toggleMap.get('toggle') || false;
        this.logger.log('🐰 토글 상태 변경', {
          toggleState,
        });
      });
    });
  }

  handleConnection() {
    this.logger.log('접속');
  }

  handleDisconnect() {
    this.logger.log('접속 해제');
  }
}

또, 클라이언트에서와 마찬가지로 공유된 toggle 상태를 변경할 수도 있습니다.

// 클라이언트에서와 같은 방식으로 toggle 상태 변경.

setInterval(() => {
  const toggleMap = doc.getMap('toggleMap');
  const currentToggle = toggleMap.get('toggle');
  sharedMap.set('toggle', !currentToggle);
}, 3000);

🐙

중요한 변경사항

값이 변경되면 감지할 수 있기 때문에, 클라이언트에서 서버로 보내던 저장 요청을 이제는 서버에서 바로 처리할 수 있게 됩니다.

클라이언트에서 저장 요청을 보내면 안되는 이유는 CRDT를 사용하는 이유와 같습니다. 요청의 충돌이 나기 때문에, YJS를 통해 충돌이 해결된 값을 서버에서 observe해서 변화가 생기면 이를 저장해주는 식으로 되어야할 것 같습니다. 이를 위해 YJS-DB 혹은 YJS-REDIS를 연결해주는 어댑터 라이브러리를 사용할 수도 있을 것 같구요..!


마치며

간단한 가이드다보니 더 넣지 못한 내용들도 있습니다.. 예를 들어 상태를 저장하기 위해 Y.Map을 사용했지만 그 외의 대부분의 데이터 구조도 지원합니다 (Array, Text). 또 유지될 필요가 없는 상태를 위해서는 Awareness를 사용합니다.

YJS, Sockets, React Flow에 간단하게 적어두긴 했었는데.. 우선은 이정도면 더 학습해보시는데에는 큰 문제가 없을 것 같습니다. 👏🏻👏🏻

개발 문서

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

팀 문화

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

그룹 기록

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