Hits

🌟 배경

Socket을 이용한 통신과, WebRTC를 이용한 P2P 통신이 메인 기능인 프로젝트를 진행중이었습니다.
어떤식으로 소켓을 관리해야 여러 컴포넌트에서 소켓을 쉽게 사용 할 수 있을지 고민을 해보았습니다.
그 과정에서 생각했던 여러 가지 고민들과 결론에 대한 포스트를 작성해보았습니다.


🌟 state와 Props를 통한 관리

가장 먼저, 생각 할 수 있는 부분은 최상단 컴포넌트(ex, 각 페이지 컴포넌트)에서 소켓을 연결하고, 해당 소켓을 props를 통해, 필요한 컴포넌트들에게 전달하는 방식입니다.

const FanUP = () => {
  const { fanUpId } = useParams();
  const [socket, setSocket] = useState<Socket | null>(null);
 
  useEffect(() => {
    if (socket) {
      return;
    }
    if (!fanUpId) {
      return;
    }
 
    setSocket(getSocket(fanUpId));
  }, [fanUpId]);
 
  return (
    <>
      <Header />
      <FanUpWrapper>
        <FanUpRightSection>
          <VideoList />
          <BottomOptionBar socket={socket} />
        </FanUpRightSection>
        <FeatureBox socket={socket} />
      </FanUpWrapper>
    </>
  );
};
const FanUP = () => {
  const { fanUpId } = useParams();
  const [socket, setSocket] = useState<Socket | null>(null);
 
  useEffect(() => {
    if (socket) {
      return;
    }
    if (!fanUpId) {
      return;
    }
 
    setSocket(getSocket(fanUpId));
  }, [fanUpId]);
 
  return (
    <>
      <Header />
      <FanUpWrapper>
        <FanUpRightSection>
          <VideoList />
          <BottomOptionBar socket={socket} />
        </FanUpRightSection>
        <FeatureBox socket={socket} />
      </FanUpWrapper>
    </>
  );
};

useEffect 훅을 통해, 컴포넌트가 Mount 되는 시점에 소켓을 만들고, 해당 소켓을 state로 관리합니다. 그리고 하위 컴포넌트에는 Props를 통해 전달합니다.

이렇게 구현 할 경우, 발생하게 될 문제점과 의문점은 다음과 같습니다.

  • Props Drilling 문제가 심각해진다. → 소켓을 사용하는 컴포넌트가 루트 페이지 컴포넌트로 부터 한참 떨어진 자식 컴포넌트라면?...
  • 소켓은 과연 상태인가? → 리액트에서 상태란, UI에 반영하기 위해, 유지해야할 값 묶음이다. 상태가 변화하면, UI가 변화한다 => 소켓이 생기면 화면이 변화하는가? X

이제 위 2가지 문제와 의문을 해결 할 수 있는 방법을 찾아보았습니다.


🌟Context

먼저, Props Drilling 문제를 해결하기 위해, Context 또는 전역 Store 의 도입을 고민해 볼 수 있습니다.
Context 또는 전역 Store를 이용해, 소켓을 관리한다면, Props Drilling 문제를 해결 할 수 있습니다.

interface SocketContextProps {
  socket: Socket | null;
}
 
export const SocketContext = createContext<SocketContextProps>({
  socket: null,
});
 
export const SocketProvider = ({ children }: PropsWithChildren) => {
  const { fanUpId } = useParams();
  const [socket, setSocket] = useState<Socket | null>(null);
 
  useEffect(() => {
    if (socket) {
      return;
    }
    if (!fanUpId) {
      return;
    }
 
    setSocket(getSocket(fanUpId));
  }, [fanUpId]);
 
  return (
    <SocketContext.Provider value={{ socket }}>
      {children}
    </SocketContext.Provider>
  );
};
 
const FanUP = () => {
  return (
    <SocketProvider>
      <Header />
      <FanUpWrapper>
        <FanUpRightSection>
          <VideoList />
          <BottomOptionBar />
        </FanUpRightSection>
        <FeatureBox />
      </FanUpWrapper>
    </SocketProvider>
  );
};
interface SocketContextProps {
  socket: Socket | null;
}
 
export const SocketContext = createContext<SocketContextProps>({
  socket: null,
});
 
export const SocketProvider = ({ children }: PropsWithChildren) => {
  const { fanUpId } = useParams();
  const [socket, setSocket] = useState<Socket | null>(null);
 
  useEffect(() => {
    if (socket) {
      return;
    }
    if (!fanUpId) {
      return;
    }
 
    setSocket(getSocket(fanUpId));
  }, [fanUpId]);
 
  return (
    <SocketContext.Provider value={{ socket }}>
      {children}
    </SocketContext.Provider>
  );
};
 
const FanUP = () => {
  return (
    <SocketProvider>
      <Header />
      <FanUpWrapper>
        <FanUpRightSection>
          <VideoList />
          <BottomOptionBar />
        </FanUpRightSection>
        <FeatureBox />
      </FanUpWrapper>
    </SocketProvider>
  );
};

이렇게, 최상단 컴포넌트를 SocketContext로 감싸주고, 내부에 포함된 하위 컴포넌트에서는 소켓을 useContext 훅을 통해, 가져오는 형태가 될 것입니다.

물론, 상태를 사용하지 않고 값의 형태로 구현 할 수도 있습니다.
React에서 제공하는 useMemo 훅을 이용해, 소켓을 저장하고, memoization을 적용하고 값의 형태로 관리할 수 있습니다.

interface SocketContextProps {
  socket: Socket | null;
}
 
export const SocketContext = createContext<SocketContextProps>({
  socket: null,
});
 
export const SocketProvider = ({ children }: PropsWithChildren) => {
  const { fanUpId } = useParams();
  const socket = useMemo(() => getSocket(fanUpId), [fanUpId]);
 
  return (
    <SocketContext.Provider value={{ socket }}>
      {children}
    </SocketContext.Provider>
  );
};
interface SocketContextProps {
  socket: Socket | null;
}
 
export const SocketContext = createContext<SocketContextProps>({
  socket: null,
});
 
export const SocketProvider = ({ children }: PropsWithChildren) => {
  const { fanUpId } = useParams();
  const socket = useMemo(() => getSocket(fanUpId), [fanUpId]);
 
  return (
    <SocketContext.Provider value={{ socket }}>
      {children}
    </SocketContext.Provider>
  );
};

Context를 이용한 HOC 패턴과 useMemo를 이용해서, 위에서 언급한 Props Drilling 문제와, 과연 상태인가? 에 대해서 모두 해결 할 수 있었습니다.

하지만, 소켓 통신과 P2P 연결이 메인 기능인 우리 서비스에서는 다음과 같은 문제점이 있었습니다.
만약, 하나의 컴포넌트에서 여러 개의 소켓을 필요로 한다면?...
서비스의 특성상, 하나의 컴포넌트에서 여러 개의 소켓 연결을 필요로 할 수도 있었습니다.

예를 들면, 다음과 같은 상황입니다.

  • A ➡ B ➡ C ➡ D 라는 컴포넌트 상속 관계가 있을 때, ContextA Component를 감싸고 있는 상태입니다.

A Component에서는 해당 방에 대한 소켓 하나만 필요한데, D Component에서는 해당 방에 대한 소켓과, 기타 다른 소켓이 필요한 상황입니다.
이런 상황에서 D Component가 소켓을 여러개 사용하기 위해서는, Context에 여러 개의 매게변수를 주입하고, 사용 해야 합니다.
이렇게 할 경우, Context에 의존성을 주입하는 방식을 통해, 해결 할 수도 있지만, 복잡해지고 관리가 힘들어지는 상황이 발생 할 수 있습니다.


🌟 Custom Hook

React의 함수형 컴포넌트를 사용하게 되면서, HOC 패턴보다는 Custom Hook 패턴으로 로직을 관리하고 재사용 하는 것이 편리하고 직관이었습니다.

따라서, HOC 패턴이 아닌 Custom Hook 패턴으로 소켓을 관리하는 로직을 만들었습니다.

고민해야 하는 부분은 아래와 같았습니다.

  • Props Drilling 없이, 소켓을 원하는 컴포넌트에서 가져올 수 있어야 한다.
  • 같은 매게변수에 대해서 같은 소켓을 반환받아야 한다.
  • 상태가 아니다.

같은 매게변수에 대해서 같은 소켓을 반환 받아야 한다는 생각을 하니, 싱글톤 패턴이 생각났습니다.
이에 따라, 소켓을 여러개 관리 할 수 있는 객체를 만들고, 이를 이용해 소켓을 반환하는 Custom Hook을 만들기로 했다.

const sockets: Record<string, Socket> = {};
 
export function useSocket(chatRoom: string): [Socket, () => void] {
  const disconnect = () => {
    if (chatRoom && sockets[chatRoom]) {
      sockets[chatRoom].disconnect();
      delete sockets[chatRoom];
    }
  };
 
  if (!sockets[chatRoom]) {
    sockets[chatRoom] = io(`${backUrl}`, {
      transports: ["websocket"],
    });
  }
 
  return [sockets[chatRoom], disconnect];
}
const sockets: Record<string, Socket> = {};
 
export function useSocket(chatRoom: string): [Socket, () => void] {
  const disconnect = () => {
    if (chatRoom && sockets[chatRoom]) {
      sockets[chatRoom].disconnect();
      delete sockets[chatRoom];
    }
  };
 
  if (!sockets[chatRoom]) {
    sockets[chatRoom] = io(`${backUrl}`, {
      transports: ["websocket"],
    });
  }
 
  return [sockets[chatRoom], disconnect];
}

채팅방을 매게변수로 받아서, 해당 채팅방에 대한 소켓이 존재한다면, if(sockets[chatRoom]) 소켓을 반환하고, 없다면, if(!sockets[chatRoom]) 소켓을 연결한 뒤 반환하게 됩니다.
이제 소켓을 사용하는 컴포넌트에서는 다음과 같이 useSocket 훅을 이용해 소켓을 사용 할 수 있습니다.

const FanUP = () => {
  const { fanUpId } = useParams();
  const chatSocket = useSocket(fanUpId);
  const eventSocket = useSocket("event");
 
  return (
    <SocketProvider>
      <Header />
      <FanUpWrapper>
        <FanUpRightSection>
          <VideoList />
          <BottomOptionBar />
        </FanUpRightSection>
        <FeatureBox />
      </FanUpWrapper>
    </SocketProvider>
  );
};
const FanUP = () => {
  const { fanUpId } = useParams();
  const chatSocket = useSocket(fanUpId);
  const eventSocket = useSocket("event");
 
  return (
    <SocketProvider>
      <Header />
      <FanUpWrapper>
        <FanUpRightSection>
          <VideoList />
          <BottomOptionBar />
        </FanUpRightSection>
        <FeatureBox />
      </FanUpWrapper>
    </SocketProvider>
  );
};

Props Drilling을 하지 않고도, 여러 개의 컴포넌트가 같은 매게변수를 넣으면, 같은 소켓을 반환받게 됩니다.
추가로, 여러 개의 소켓을 사용하는 컴포넌트에서도, 직관적으로 다른 매게변수를 집어 넣어, 여러 개의 소켓을 사용 할 수 있다.