WebSocket을 위한 React 구조 설계

김용현·2024년 4월 17일

LESSER 개발일지

목록 보기
6/7
post-thumbnail

클라이언트에서 Socket.io를 어떻게 사용할 것인가?

서버와 소켓 통신하기 위해, Socket.io를 통해 소켓을 어떻게 생성하고, 해당 소켓으로 어떻게 구조를 설계할 것인가?

개요

사용자가 참여한 프로젝트 페이지의 경우, 다른 멤버들에 의한 페이지의 변경 사항이 실시간으로 화면에 반영되어야 했다(이하, 화면 동시 편집 기술이라 통칭). 이러한 기능을 구현하기 위해, 우리는 프로젝트의 Socket.io를 사용한 웹소켓 통신을 활용하고자 했다.(Socket.io 및 웹소켓 통신 회의 목록 참고)

각 프로젝트의 상세 데이터를 전달하는 모든 페이지들은 화면 동시 편집 기술이 적용되어야 한다. 이 때, 더욱 효율적인 프로젝트 구조를 위해 각 페이지 별 소켓 연결을 어떻게 관리하면 좋을지를 고민해야 했다.

화면 동시 편집 기술 적용 페이지간 관계

프로젝트를 관리하는 페이지는 상위 MainPage 내에서 React Router의 Outlet으로서 하위 컴포넌트로 렌더링되고 있다.
Outlet으로 렌더링 되는 페이지는 LandingPage, BacklogPage, SprintPage, ProjectSettingPage (이하, Outlet Component로 위 4개의 페이지를 통칭)로 MainPage와 아래와 같은 관계를 가진다.

MainPage의 하위 Outlet Component에 속하는 모든 페이지에서 화면 동시 편집 기술을 적용해야 한다.

Project 페이지의 구조

WebSocket의 생성 위치

프로젝트 별로 WebSocket 통신을 생성하고 서버와 연결하기로 결정(회의안 참고)하였기 때문에, Outlet Component 마다 개별적인 소켓 통신을 생성하는 것보다, MainPage에서 생성한 WebSocket을 각 페이지에서 활용하는 것이 더 효율적일 것이라 판단

설계 간 고민 사항 : WebSocket에서 전달하는 데이터를 어떻게 관리할 것인가?

서버로부터 프로젝트 페이지의 데이터를 WebSocket을 통해 전달받게 된다. 이 때, WebSocket에서 전달하는 데이터를 어떻게 관리할 것인지가 설계 간 가장 큰 고민 사항이었다.

  1. MainPage에서 모든 데이터를 전달받아 관리하고, Outlet Component에 Props로 데이터를 전달
  2. MainPage에서는 WebSocket 객체를 생성하여 Props로 전달하고, 각 Outlet Component에서 직접 데이터를 관리

방법1. MainPage에서 모든 데이터를 전달받아 관리하고, Outlet Component에 Props로 데이터를 전달

MainPage에서 모든 데이터 관리하는 구조

  • WebSocket에서 Outlet Component에 사용될 전체 데이터를 웹소켓을 통해 전체 데이터를 MainPage 에서 상태값으로 관리
  • Server으로부터 웹소켓을 통해 전달받은 데이터를 MainPage에서 수신하여 데이터 상태값을 수정하고, 각각의 데이터를 페이지에 Props로 전달하는 방식으로 설계

방법2. MainPage에서는 WebSocket 객체를 생성하여 Props로 전달, Outlet Component에서 직접 데이터를 관리

각 페이지 별로 데이터를 관리하는 구조

  • MainPage는 Socket 객체를 생성한다.
  • 생성한 Socket 객체를 Outlet Component에 전달하고, 각 컴포넌트 내부에서 Props로 WebSocket를 사용하여 서버로부터 데이터를 전달 받고 상태값으로 관리한다.

결정 사항 : WebSocket을 props로 전달하자

1. 코드 유지 보수 및 관리 관점

방법 1의 경우 웹소켓과 관련된 로직을 MainPage에서 처리하기 때문에, WebSocket 관련 로직을 한 곳에서 모아 쓸 수 있다. 하지만 반대로 Main Page에서 4개의 페이지를 생성하기 위한 막대한 양의 데이터를 동시에 관리해야 한다는 문제점이 있다.

방법 2를 사용하게 된다면, 각 페이지 별로 웹소켓 이벤트를 등록하는 로직을 짜야한다. 하지만 각 페이지라는 작은 단위로 쪼개서 상태값과 웹소켓 이벤트들을 관리 할 수 있다. 덕분에 코드의 흐름을 페이지 단위로 이해할 수 있어 가독성이 증가하고 유지보수에 편의성이 더욱 증가할 것으로 판단했다.

2. 기능 확장 관점

MainPage 에 새로운 기능을 추가한다고 가정할 경우, 방법 1은 Props로 전달하는 데이터의 타입과 구조를 수정해야하는 개발 소요가 발생하게 된다. 또한 많은 페이지의 상태 및 웹소켓 이벤트를 관리하는 로직 속에 새로운 코드들을 추가해야 한다.

하지만 방법 2 의 경우, 새로운 Component를 생성하고 Props로 전달하는 WebSocket을 사용하여 서버와 직접 연결하면 되기 때문에 기능 확장적 관점에서 효율이 더 높다.

3. 웹소켓 이벤트 관리 관점

MainPage에서 모든 이벤트를 등록, 관리하게 된다면, 사용자가 현재 들어가 있는 페이지와 무관한 데이터가 웹소켓 이벤트로 변화하고 관리하게 된다. 즉, 화면에 띄울 필요가 없는 데이터 관리를 위해 불필요한 클라이언트 리소스 소요가 발생하게 된다. 페이지 별로 이벤트를 등록/제거 한다면, 웹소켓 이벤트로 인한 상태값 변화를 최소화할 수 있을 것이라 판단했다.

4. 데이터 저장 효율성 관점

MainPage로 한 곳에 상태를 관리할 경우, 각 페이지에 공유하는 데이터가 있을 때 하나의 상태값을 통해 여러 페이지의 데이터를 관리할 수 있다. 즉 데이터를 저장하는 효율이 좋아진다. 하지만 LESSER의 기획상 Outlet Component의 데이터 사이에 공유하는 데이터는 거의 존재하지 않아, 데이터 관리 효율성 관점의 장점을 얻기가 힘들다.

위 4가지 관점을 고려한 끝에 최종적으로 방법2를 적용해보기로 결정했다.

WebSocket 생성 방법 : 커스텀 훅을 통한 생성

  • WebSocket의 경우 useSocket 커스텀 훅을 통해 생성하여 관리
  • useSocket은 전달받은 projectId를 기반으로 서버와 웹소켓 통신을 생성하고, 연결되었는지의 상태(connected)를 관리
  • useEffect를 사용하여 웹소켓의 connect/disconnect 이벤트에 따라 connected의 상태를 바꾸는 코드를 등록
  • 최종적으로 WebScoket 객체와 connected 상태를 반환
const useSocket = (projectId: string) => {
  const WS_URL = `${BASE_URL}/project-${projectId}`;
  const socket = io(WS_URL, {
    path: `/api/socket.io`,
  });
  const [connected, setConnected] = useState<boolean>(false);

  useEffect(() => {
    const handleOnConnect = () => {
      setConnected(true);
    };
    const handleOnDisconnect = () => {
      setConnected(false);
    };

    socket.on("connect", handleOnConnect);
    socket.on("disconnect", handleOnDisconnect);

    return () => {
      socket.off("connect", handleOnConnect);
      socket.off("disconnect", handleOnDisconnect);
    };
  }, []);

  return { socket, connected };
};
  • MainPage는 useSocket 훅을 통해 소켓 객체를 생성하고, 이를 Outlet에게 전달한다.
const MainPage = () => {
  const { pathname } = useLocation();
  const { projectId } = useParams();
  if (!projectId) throw Error("잘못된 ProjectID 입니다.");
  const { socket } = useSocket(projectId);
  return (
    <div className="flex justify-center items-center h-screen min-w-[76rem] gap-9">
      <ProjectSidebar {...{ pathname, projectId }} />
      <div className="h-[40.5rem] min-w-[67.9375rem]">
        <Outlet context={{ socket }} />
      </div>
    </div>
  );
};

하위 페이지에서 전달 받은 WebSocket을 커스텀 훅을 통해 관리

MainPage 로부터 전달받은 Websocket 객체를 커스텀 훅에 전달하여, 전달 받는 데이터를 관리

  • 전달 받은 데이터를 관리할 상태값 생성
  • 해당 페이지의 데이터를 관리하는 웹소켓 이벤트 핸들러 작성
  • useEffect 내에서 작성한 웹소켓 이벤트 핸들러를 등록/해제하는 코드를 작성
  • 예시 코드 : LandingPage에서 웹소켓을 통해 받은 데이터를 관리하기 위한 커스텀 훅
const useLandingSocket = (socket: Socket) => {
  const [project, setProject] = useState<LandingProjectDTO>(
    DEFAULT_VALUE.PROJECT
  );
  const [myInfo, setMyInfo] = useState<LandingMemberDTO>(DEFAULT_VALUE.MY_INFO);
  const [member, setMember] = useState<LandingMemberDTO[]>([]);
  const [sprint, setSprint] = useState<LandingSprintDTO | null>(null);
  const [board, setBoard] = useState<LandingMemoDTO[]>([]);
  const [link, setLink] = useState<LandingLinkDTO[]>([]);

  const handleOnLanding = ({ action, content }: SocketData) => {
    switch (action) {
      case LandingSocketEvent.INIT:
        const { project, myInfo, member, sprint, board, link } =
          content as LandingDTO;
        setProject(project);
        setMyInfo(myInfo);
        setMember(member);
        setSprint(sprint);
        setBoard(board);
        setLink(link);
        break;
       //... 이하, action에 따른 로직
    }
  };

  useEffect(() => {
    socket.emit("joinLanding");
    socket.on("landing", handleOnLanding);

    return () => {
      socket.off("landing");
    };
  }, [socket]);

  return { project, myInfo, member, sprint, board, link };
};

export default useLandingSocket;
profile
함께 일하고 싶은 개발자가 되기위해 노력 중입니다.

0개의 댓글