[Workspace 구현 여정기 -1] 게스트, 유저 Workspace 분리화

hyonun·2024년 12월 29일
0

Nocta - Workspace 구현

목록 보기
1/3

우리 글래스모팀의 목표는 실제 서비스 같은 완성도였습니다.

이를위해서는 CRDT구현 말고도 workspace라는 개념이 필요했습니다.

기획단계에서 여러 유저가 접속해서 동시에 실시간 편집을 하려면, 어떤 곳에서 실시간 편집을 해야할까? 라는 의문이 생겼습니다.

그래서 유저별 1개의 워크스페이스를 가지게하고 이 워크스페이스에 초대할 수있는 권한을 부여하는 것으로 기획이 정해졌습니다.

아참 하나 더 기획단계에서 나온 이야기가 있는데요,

바로 게스트유저 입니다.

대부분의 포트폴리오를 이용하는 유저분들은 하나하나 로그인/비밀번호를 입력하며 실제 사용자처럼 본인만의 아이디를 사용하는 것이 아니라, 동작기능이나 유저 경험이 좋냐를 먼저 판단하기 때문에

게스트 유저를 반드시 도입하자는 의견이 있었습니다.

결론적으로, 유저시나리오에서

  1. 처음 서비스를 접속하면 게스트 워크스페이스를 이용하게 한다.
  2. 로그인을 하면 본인만의 워크스페이스를 이용하게 한다.

로 정하게 되었습니다.

✅ 목표

  • 로그인을 하지 않은 유저의 guest Workspace
  • 로그인 유저의 Workspace (초대기능)

위 두개를 판단하기 위해서는 client 단에서 현재 socket의 userId를 받아와야 했습니다.

그 후 userId에 해당하는 유저를 찾습니다.

  1. 있다
    • 해당하는 워크스페이스 정보를 client에 전송합니다.
  2. 없다
    • guest 워크스페이스 정보를 client에 전송합니다.
async handleConnection(client: Socket) {
    try {
      let { userId } = client.handshake.auth;
      const { workspaceId } = client.handshake.auth;
      if (!userId) {
        userId = "guest";
      }
      client.data.userId = userId;
      client.join(`user:${userId}`); // 유저 전용 룸에 조인 추후 초대 수신용

      const workspaces = await this.workSpaceService.getUserWorkspaces(userId);
      const userInfo = await this.authService.getProfile(userId);
      let NewWorkspaceId = "";
      if (userId === "guest") {
        client.join("guest");
        NewWorkspaceId = "guest";
      }

처음에는 user의 token정보를 기준으로 판단하여 workspace를 불러왔습니다.

하지만

  1. server에 유저 token정보를 저장하는것은 보안적으로 위험하다는 의견
  2. workspace 정보를 mongoDB에 저장할때 id가 쓰이게 되는데 굳이 그것을 활용하지 않고 토큰으로 또 매핑할 이유에 대한 정당한 이유 없음

등의 문제로 인해 nanoid로 생성된 userId를 사용하게 되었습니다.

export class User {
  @Prop({ required: true, unique: true, default: () => nanoid() })
  id: string;

MongoDB내의 스키마에서 요소가 생성될때 자동으로 만들어지도록 설정했습니다.

socket의 room 기능 활용

이제 client로부터 받은 정보를 통해 workspace를 판별했으면, 그 워크스페이스 에서 일어나는 socket 통신들은 해당하는 workspace에서만 일어나야 합니다.

(정보) -> if(정보 == workspace) -> 맞다면 true -> 아니면 다른 workspace...

위와 같이 if문이나 분기문 처리로 socket통신을 검사할 필요없이, socket.io의 room기능을 이용하면 매우 쉽게 처리해 줄 수 있었습니다.

📄 socket.join 활용

async handleConnection(client: Socket) {
    try {
      let { userId } = client.handshake.auth;
      const { workspaceId } = client.handshake.auth;
      if (!userId) {
        userId = "guest";
      }
      client.data.userId = userId;
      client.join(`user:${userId}`); // 유저 전용 룸에 조인 추후 초대 수신용
  • 위 코드를 보면 client 소켓을 join 하여 userId에 종속시키고 있습니다.

  • Roomjoin하면 독립적인 데이터 송수신과 초대 관리가 가능해집니다.

이후 Room에 속한 상태에서 page가 만들어진다면, room안에 있는 client들에게 그 사항도 반영을 해야한다.

이 내용을 create/page 와 같은 형태로 API를 제작했습니다.

(동사)/(명사))join/workspace, delete/page ... 

🏇 socket.to(userId).emit 활용

  • 백엔드 NestJS부분
  @SubscribeMessage("create/page")
  async handlePageCreate(
    @MessageBody() data: RemotePageCreateOperation,
    @ConnectedSocket() client: Socket,
  ): Promise<void> {
    **const clientInfo = this.clientMap.get(client.id);**
    try {
      this.logger.debug(
        `Page create 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`,
        JSON.stringify(data),
      );
      const userId = client.data.userId;
      const workspace = this.workSpaceService.getWorkspace(userId);

      const newEditorCRDT = new EditorCRDT(data.clientId);
      const newPage = new Page(nanoid(), "새로운 페이지", "📄", newEditorCRDT);
      workspace.pageList.push(newPage);

      const operation = {
        workspaceId: data.workspaceId,
        clientId: data.clientId,
        page: newPage.serialize(),
      };
      **client.emit("create/page", operation);
      client.to(userId).emit("create/page", operation);**
  • 이후는 페이지를 만든 클라이언트에게만 client.emit으로 쏴주고, 그 room에 속해있는 클라이언트들에게는 client.to(userId).emit 으로 전송해줄 수가 있다.

이렇게 workspace별, page별로 소켓통신이 일어날 수 있도록 설정했습니다.

📂 page접속의 join/leave 처리

아래 프로토 타입을 제작하다 발생한 문제도 있고, 최적화의 방안으로 팀원들끼리 나온 이야기가 있습니다.

바로

페이지탭을 닫을때는 socket통신이 일어나지 않는다.

페이지 탭을 열면 페이지의 정보를 받아오고, socket통신이 새로 연결된다.

위와 같이 페이지가 열려있고 닫혀있을때 소켓처리와 불러오기 처리를 하는 것 이였습니다.

페이지가 닫혀있는데도 소켓통신이 일어나고있으면 불필요한 정보를 수신하며 리소스를 많이 차지한다고 생각했습니다.

그래서 join/page, leave/page 의 API를 만들어서

  @SubscribeMessage("join/page")
  async handlePageJoin(
    @MessageBody() data: { pageId: string },
    @ConnectedSocket() client: Socket,
  ): Promise<void> {
    const start = process.hrtime();
    const clientInfo = this.clientMap.get(client.id);
    if (!clientInfo) {
      throw new WsException("Client information not found");
    }
    client.join(pageId);
  @SubscribeMessage("leave/page")
  async handlePageLeave(
    @MessageBody() data: { pageId: string },
    @ConnectedSocket() client: Socket,
  ): Promise<void> {
    const clientInfo = this.clientMap.get(client.id);
    if (!clientInfo) {
      throw new WsException("Client information not found");
    }

    try {
      const { pageId } = data;
      client.leave(pageId);

해당 socket을 join/leave 처리를 해주었습니다.

특히 join의 경우 초기 상태를 반영해야 하기 때문에

try {
      const { pageId } = data;
      const { workspaceId } = client.data;
      // 워크스페이스에서 해당 페이지 찾기
      const currentPage = await this.workSpaceService.getPage(workspaceId, pageId);
      if (!currentPage) {
        throw new WsException(`Page with id ${pageId} not found`);
      }
      // pageId에 가입 시키기
      client.join(pageId);

      client.emit("join/page", {
        pageId,
        serializedPage: currentPage,
      });

위 같이 워크스페이스를 찾아서 직렬화된 Page 정보를 해당 클라이언트에 송신해주었습니다.

이후 굉장히 많은 트러블 슈팅을 거치며 기능을 완성했습니다.
그 중 트러블 슈팅 하나를 가져와보았습니다.


📄 연산이 pageId 별로 분리가 안된다..!

  • 현재 socket의 join기능을 써서 workspace 내에서 원하는 client에게만 소켓을 전송하는건 구현했습니다.
  • 이제 page별로도 분리하면 마찬가지로 하면 되지 않을까 생각했습니다.

하지만 클라이언트 수신에서 문제가 발생했습니다.

문제 : remoteInsert를 수신할때 page별로 필터링 되지 않는 현상

원인

  • socket연결은 page두개가 켜지면 둘다 client.join(pageId)로 두개에 가입됩니다.
  • 하지만 remoteInsertBlock이나 remoteInsertChar가 어떤 pageId에만 적용되어야 하는지는 예외처리가 되지 않았다.
  • 그래서 클라이언트에서 pageId를 수신할때, 본인의 pageId가 아니면 반영하지 않게 filter 처리를 해주었습니다.

하지만 이게 정말 socket.io를 잘 쓰는 방법일까?

→ 불필요한 네트워크 트래픽을 줄이려면 page별로 또 room처리해 줄 수 있을 것 같습니다!

 onRemoteCharInsert: (operation) => {
        **if (!editorCRDT.current || operation.pageId !== pageId) return; // 이부분이다.**
        const targetBlock =
          editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)];
        targetBlock.crdt.remoteInsert(operation);
        setEditorState((prev) => ({
          clock: editorCRDT.current.clock,
          linkedList: editorCRDT.current.LinkedList,
          currentBlock: prev.currentBlock,
        }));
      },

트러블 슈팅들..!

https://abrupt-feta-9a9.notion.site/Workspace-1-Workspace-8f713bc4efad4969842ceb4e99f9cdb8?pvs=4


어찌보면 socket room 기능을 추가하면서 설계에 누락 되어있던 부분들이 추가 됐습니다.
이에 따라 매개변수들이나 type, interface등의 지정의 수정이 많이 발생했는데요.!

이번작업을 거치며 초기에 설계를 잘하는 것 과 얼마나 확장성을 고려해 설계하는 것이 중요한지 뼈저리게 느끼게 되었습니다.
(노션에 적지못한 트러블 슈팅을 포함하면.. 5개 이상..!)

다음은 워크스페이스 완성도 올리기 + 선택 UI모달 구현에 대한 글입니다!

profile
비전공자 + 반도체 경력2년의 IT 개발자 도전기~

0개의 댓글

관련 채용 정보