전역 상태 도입과 커스텀 훅 개선하기

드뮴·2025년 2월 26일
2

🪴 개발일지

목록 보기
3/8
post-thumbnail

기존에 진행한 프로젝트인 화상회의 스터디 플랫폼의 핵심 기능은 화상회의였다. 따라서 화상회의를 관리하는 세션 페이지 컴포넌트에 모든 로직이 집중되어 있었다.

세션 페이지라는 컴포넌트는 엄청나게 많은 일을 처리하게 되었고 코드가 길어 테스트 코드를 도입한 후 분리하는 리팩토링을 진행하였지만 아쉬운 점이 많았다.

문제를 정리하기 전 props drilling 문제가 주가 되기 때문에 props drilling에 대해서 간단히 정리해보았다.

props drilling이란?

props drilling은 상위 컴포넌트에서 데이터를 여러 계층의 하위 컴포넌트로 전달하기 위해 중간에 있는 컴포넌트를 통해 props를 계속 drilling 하듯이 전달하는 과정을 말한다.

이렇게 전달하는 구조의 문제점은 무엇일까?

  • 코드의 복잡성이 증가한다. 여러 계층을 통과하는 props는 코드를 복잡하게 만든다.
  • 중간 컴포넌트들이 실제로 사용하지 않는 props가 변경되면 그 컴포넌트들도 리렌더링될 수 있다.
  • 컴포넌트 계층이 깊어질수록 데이터 흐름을 추적하기 어렵다.
  • props를 많이 받는 컴포넌트는 재사용하기 어려워진다.

props drilling을 해결하기 위해 Context API를 사용해 중간 컴포넌트를 거치지 않고 데이터를 공유할 수 있다. 아니면 전역 상태 관리 라이브러리를 사용해 전역 상태로 관리할 수도 있다.


💥 화상회의 페이지의 문제점

1️⃣ props drilling

스터디를 화상회의로 진행하는게 서비스의 핵심이었고, 화상회의를 담당하는 페이지가 세션 페이지였다. (최근 리팩토링을 통해 이름을 세션이 아닌 좀 더 직관적인 (스터디)채널로 변경했다.)

해당 세션(채널) 페이지의 구조는 위 그림과 같았다.

📌 문제라고 생각했던 부분

  • 세션 페이지를 이루는 컴포넌트에게 props로 전달하는 값이 굉장히 많았고, 상태 뿐만 아니라 상태 변경 함수도 전달하기 때문에 더 복잡했다.
  • 실제로 전달되는 props는 바로 하위 컴포넌트가 아닌 계속해서 하위 컴포넌트로 전달하며 중간 컴포넌트에서는 해당 props를 사용하지 않기도 했다.
  • 결과적으로 useSession이라는 훅에서 반환받은 값을 props로 전달하는데, 이 구조도 문제가 있는데, 훅에 대해서는 아래에서 더 자세히 다룰 예정이다.

최종적으로 UI 부분만 보면 코드 자체는 위와 같았다. 이렇게 작성된 코드에서 내가 문제라고 느낀 부분은 다음과 같았다.

  • 많은 상태를 props로 전달하고 있어 구조 자체를 파악하기가 어렵고, props 전달이 많아서 구조 변경을 하게 된다면 복잡해진다.
  • 바로 하위 컴포넌트가 아닌 여러 단계를 타고 props를 전달하는 경우도 많았고, 이때 중간 컴포넌트는 해당 props를 사용하지 않는 경우도 있었고 컴포넌트가 길어서 분리하려면 또 상태를 전달해줘야하는 복잡성이 있었다.
  • 모든 상태를 페이지 컴포넌트에서 관리해주고 있기 때문에, 하위 컴포넌트 중 해당 상태와 연관이 없음에도 리렌더링 되는 컴포넌트가 많았다.

반면 이렇게 구현했을 때의 장점도 있었다.

  • 상태 자체를 컴포넌트 상위에서 관리하고 useState로 관리해주었기 때문에 컴포넌트에서 상태를 관리하는 것이 생각보다 간단했다.
    • 간단하다는 것의 의미는 해당 컴포넌트가 언마운트 되거나 하는 일이 있을 때 상태가 자동으로 정리되기 때문에 특별히 관리해줄 부분이 없었다.

전반적으로 장점보다는 수정이 쉽지 않은 구조와 수정을 하지 않더라도 흐름 파악이 어려운 코드이기 때문에 리팩토링이 필요하다 느꼈다.


2️⃣ 훅의 역할에 대한 고민

세션 페이지에서 사용되는 훅은 위와 같았다.

  • useAudioDetector: 로컬 사용자와 연결된 피어들의 오디오 상태를 감지하는 훅
    • 각 사용자들의 말하는 상태를 반환
  • useBlockNavigate: 화상회의 페이지에서 새로고침 시 바로 나가지지 않고 알림창을 띄워주는 훅
  • useMediaDevices: 사용자의 미디어 디바이스를 가져오거나 그 외의 미디어 관련 처리를 하는 훅
    • 훅이 반환하는 값: userAudioDevices, userVideoDevices, selectedAudioDeviceId, selectedVideoDeviceId, stream, isVideoOn, isMicOn, setIsVideoOn, videoLoading, handleMicToggle, handleVideoToggle, setSelectedAudioDeviceId, setSelectedVideoDeviceId, getMedia, getMediaStream
    • 미디어 관련 처리 뿐만 아니라 관련된 상태 관리도 내부에서 이루어진다.
  • useMediaStreamCleanUp: 미디어 스트림 클린 업 훅
  • usePeerConnection: 피어 간 연결을 처리하는 훅
    • 훅이 반환하는 값: peers, setPeers, peerConnections, createPeerConnection, closePeerConnection, dataChannels, peerMediaStatus
  • usePeerConnectionCleanUp: 피어 커넥션을 정리하는 훅
  • useReaction: 리액션 관련 처리를 해주는 훅
  • useStudy: 스터디 채널(화상회의)에서 스터디를 시작하고 종료하는 등과 관련된 로직을 처리하는 훅
  • useSocketEvents: 모든 소켓 이벤트를 등록하고 해제하는 훅
    • 피어 커넥션 시 발생하는 이벤트, 스터디 시 발생하는 이벤트, 피어들이 발생시키는 이벤트들에 대한 관리
  • useSession: 위의 대부분 훅을 호출하며 최종적으로 세션 페이지 컴포넌트에서 호출하게 되는 훅

useSession 훅 코드의 일부를 보면 다음과 같다.

export const useSession = (sessionId: string) => {
  const { socket } = useSocket();
  const toast = useToast();

  const {
    createPeerConnection,
    closePeerConnection,
    peers,
    setPeers,
    peerConnections,
    dataChannels,
    peerMediaStatus,
  } = usePeerConnection(socket!);
  const { nickname: username } = useAuth();
  const [nickname, setNickname] = useState<string>("");
  const [roomMetadata, setRoomMetadata] = useState<RoomMetadata | null>(null);
  const [isHost, setIsHost] = useState<boolean>(false);
  const mediaPreviewModal = useModal();
  const [ready, setReady] = useState(false);
  useEffect(() => {
    mediaPreviewModal.openModal();
  }, []);

  const {
    userVideoDevices,
    userAudioDevices,
    selectedAudioDeviceId,
    selectedVideoDeviceId,
    stream,
    isVideoOn,
    isMicOn,
    setIsVideoOn,
    handleMicToggle,
    handleVideoToggle,
    setSelectedAudioDeviceId,
    setSelectedVideoDeviceId,
    getMedia,
    getMediaStream,
    videoLoading,
  } = useMediaDevices(dataChannels);

  const { setShouldBlock } = useBlockNavigate();

  useEffect(() => {
    if (username) {
      setNickname(username);
    }
  }, [setNickname, username]);

  useEffect(() => {
    if (selectedAudioDeviceId || selectedVideoDeviceId) {
      getMedia();
    }
  }, [selectedAudioDeviceId, selectedVideoDeviceId, getMedia]);

  usePeerConnectionCleanup(peerConnections);
  useMediaStreamCleanup(stream);

  const { reaction, emitReaction, handleReaction } = useReaction(
    socket,
    sessionId,
    setPeers
  );

  const { requestChangeIndex, stopStudySession, startStudySession } = useStudy(
    socket,
    isHost,
    sessionId,
    roomMetadata
  );

  useSocketEvents({
    socket,
    stream,
    nickname,
    sessionId,
    createPeerConnection,
    closePeerConnection,
    peerConnections,
    setPeers,
    setIsHost,
    setRoomMetadata,
    handleReaction,
    setShouldBlock,
  });

  const joinRoom = async () => {
    ...

    socket.emit(SESSION_EMIT_EVENT.JOIN, { roomId: sessionId, nickname });
  };

  const participants: Participant[] = useMemo(
    () => [
      { nickname, isHost },
      ...peers.map((peer) => ({
        nickname: peer.peerNickname,
        isHost: peer.isHost || false,
      })),
    ],
    [nickname, isHost, peers]
  );

  return {
    nickname,
    setNickname,
    reaction,
    peers,
    peerConnections,
    userVideoDevices,
    userAudioDevices,
    isVideoOn,
    isMicOn,
    setIsVideoOn,
    stream,
    roomMetadata,
    isHost,
    participants,
    handleMicToggle: () => handleMicToggle(),
    handleVideoToggle: () => handleVideoToggle(peerConnections.current),
    setSelectedAudioDeviceId,
    setSelectedVideoDeviceId,
    joinRoom,
    emitReaction,
    videoLoading,
    peerMediaStatus,
    requestChangeIndex,
    startStudySession,
    stopStudySession,
    getMedia,
    getMediaStream,
    mediaPreviewModal,
    ready,
    setReady,
    setShouldBlock,
  };
};

useSession에서 다양한 훅을 모두 호출하게 되고 최종적으로 모든 상태를 return하면 세션 페이지 컴포넌트가 이 훅의 반환 값을 하위 컴포넌트로 전달해주는 형태이다.

처음 코드가 길어 기능 별로 훅을 분리해서 코드 길이를 줄이고 기능 별로 파악하기 좋다고 느꼈지만, 수정하고 상태 흐름을 추적하려고 코드를 다시 살펴보니 많은 문제가 있었다. 내가 생각한 문제는 다음과 같다.

  • 기능을 추가하게 된다면 새로운 훅을 만들고 이는 useSession에 추가되며, useSession이 반환하는 값은 더 늘어나게 된다. 이는 세션 페이지 컴포넌트에서 다시 하위 컴포넌트로 전달할 props가 늘어나게 된다.
    • 훅 자체들이 각각 다른 기능을 하도록 분리한 것 같지만, 훅을 기능 별로 분리한 의미가 없이 세션 페이지에서 한 번에 다 불러오고 있다.
  • useSocketEvents 훅에서도 모든 이벤트를 등록하는데, 이때 이벤트 핸들러는 피어커넥션을 생성, 종료하는 로직도 필요하고 그 외에도 다른 훅에서 사용하는 상태나 로직이 필요하다.
    • 이렇게 한꺼번에 모든 로직에 대한 소켓 이벤트를 처리하면 소켓 이벤트 처리를 한 번만 해서 간편하고 일관성 있게 처리할 수 있었지만, 관심사 분리가 되지 않는 단점이 있었다.
    • 각 기능이 명확하게 하는 일을 분리해주고 싶었기 때문에 이를 없애고 각 기능별 훅에서 소켓 이벤트도 등록하도록 수정할 필요가 있었다.

☑️ 리팩토링의 목표

위에서 작성했듯이 현재 화상회의를 진행하는 페이지에서의 문제는 정리하면 다음과 같다.

  • 모든 상태를 여러 훅에서 정의하고 최종적으로 useSession 훅이 이들을 모아 반환하면, 페이지 컴포넌트가 이 훅의 반환 값을 받아서 페이지 컴포넌트의 하위 컴포넌트에게 props로 전달해준다.
    • 너무 많은 props를 전달하고 있으며 하위로 전달할 때 해당 props를 사용하지 않는 컴포넌트도 많다. 따라서 불필요한 리렌더링이 발생한다.
    • 전달되는 상태가 많기 때문에 상태 추적 또한 복잡하다.
    • 컴포넌트를 리팩토링을 시도하려면 지나치게 props로 전달 받는 상태에 의존하는 형태라 분리나 수정하는 것은 복잡한 문제이다.
  • 기능 별로 나누어서 훅만 대충 만들어 놓았기 때문에, 분리하지 않아도 되는 것까지 분리되거나 혹은 기능 별 분리를 목표로 해두고 소켓 이벤트는 한 번에 처리하는 등 관심사 분리가 제대로 이루어지지 않았다.
    • 여러 훅에서 각각 상태를 관리하고 있어서 상태의 출처나 변경 흐름 추적이 어렵다.
    • useSession이란 훅이 여러 훅을 한 번에 호출하는 역할을 해주면서 많은 책임을 지고 있어서 이를 분리해줄 필요가 있다.
    • useScoketEvents 훅에서는 모든 소켓 이벤트를 한 번에 처리해서 간편하지만, 한 곳에 로직이 집중되어 의존성이 복잡해지는 문제가 있다.

어떻게 개선할까?

가장 먼저 props drilling을 해결해서 props를 사용하지 않는 컴포넌트가 이를 전달 받을 필요없이 구성하고, 해당 상태를 사용하는 컴포넌트만이 리렌더링될 수 있도록 할 필요가 있었다.

이를 위해 zustand 전역 상태 라이브러리로 대부분의 상태를 전역 상태로 관리할 수 있게 하기로 했다. zustand가 간단한 라이브러리라 사용하기가 편하다는 장점은 있었지만, 정확히 원리를 알고 사용하는게 좋을거 같아 zustand 동작 원리를 간단하게 공부하고 사용했다.

또한 훅들을 다시 정리하고, 훅 자체 내에서도 복잡한 로직을 분리할 계층을 도입하기로 했다. 특히나 미디어 관련 로직과 피어 커넥션 로직은 꽤나 복잡했기 때문에 복잡한 로직을 따로 뺄 수 있는 서비스 로직을 만들기로 결정했다.

해당 리팩토링 과정에서 수정된 코드가 굉장히 많았기 때문에 가장 많이 변경되고 복잡했던 미디어 관련 로직과 피어 커넥션 관련 로직을 리팩토링한 내용을 중심으로 작성했다.


1️⃣ 전역 상태로 관리하기

전역 상태로 대부분 상태를 관리하도록 수정하기로 했다. 전역 상태로 변경하게 되면 얻는 이점은 다음과 같았다.

  • props drilling이 제거되기 때문에 상태를 전달해주는 복잡한 로직이 사라지고, 필요한 상태만을 각 컴포넌트에서 구독하고 상태 흐름 추적이 더 편해진다.
  • 각 상태별로 스토어를 만들어서 모아볼 수 있고, 상태 업데이트를 하기 위한 API를 한 곳에서 관리할 수 있다.

미디어 관련된 상태와 피어와 관련된 상태, 그리고 채널 정보와 관련된 상태를 나누어서 관리하기로 했다.

useMediaStore: 미디어 관련 상태 관리

const useMediaStore = create<MediaState>((set) => ({
  stream: null,
  isVideoOn: true,
  isMicOn: true,
  videoLoading: false,

  userVideoDevices: [],
  userAudioDevices: [],
  selectedVideoDeviceId: null,
  selectedAudioDeviceId: null,

  setStream: ...,
  setIsVideoOn: ...,
  setIsMicOn: ...,
  setVideoLoading: ...,
  
  setUserVideoDevices: ...,
  setUserAudioDevices: ...,
  setSelectedVideoDeviceId: ...,
  setSelectedAudioDeviceId: ...,
}));

비디오/오디오 on/off 상태나 미디어 장치 관련 정보부터 이런 상태를 변경하는 API가 정의되어있다.

usePeerStore: 피어 관련 상태 관리

const usePeerStore = create<PeerState>((set) => ({
  peers: [],
  peerMediaStatus: {},
  setPeers: ...,
  setPeerMediaStatus: ...,
}));

피어 목록과 피어들의 비디오/오디오 상태를 저장하는 상태와 이를 변경하는 API가 정의되어있다.

useSessionStore: 채널 관련 상태 관리

const useSessionStore = create<SessionState>((set) => ({
  nickname: null,
  isHost: false,
  roomId: "",
  roomMetadata: {
    title: "",
    status: "PUBLIC",
    participants: 0,
    maxParticipants: 0,
    createdAt: 0,
    inProgress: false,
    host: { socketId: "", createdAt: 0, nickname: "" },
    category: "",
    questionListId: 0,
    questionListContents: [],
    currentIndex: -1
  },
  participants: [],
  ready: false,

  setNickname: ...,
  setIsHost: ...,
  setRoomId: ...,
  setRoomMetadata: ...,
  setParticipants: ...,
  setReady: ...,
}));

채널 자체가 가지는 정보인 채널 이름부터 호스트 정보, 스터디 채널에서 사용하는 질문 리스트 정보 상태와 해당 채널에서의 사용자의 닉네임, 호스트 여부 등의 상태와 이를 변경하는 API가 정의되어있다.

이렇게 전역 상태로 변경하고 나서 문제를 발견할 수 있었다.

📌 기존 훅 자체의 문제점

훅에서 데이터 채널과 피어커넥션을 useRef로 관리해주는 부분만 빼고 전역 상태로 관리하게 했다. 그래서 해당 상태를 usePeerConnection 훅에서 반환받아 사용했는데, 이를 다른 훅에서도 사용해야해서 전달해줘야해서 호출을 했고 하위 컴포넌트에서도 해당 값으로 UI를 보여주는 로직이 있어 2번을 호출하고 있었다.

그런데 props drilling을 없애고 각 컴포넌트가 필요한 상태만을 사용할 수 있게 설계하는게 목표였고, 위 문제는 훅 자체가 너무 많은 일을 하게 되어 생긴 일이었다. 따라서 데이터 채널, 피어커넥션 관리를 useRef로 훅에서 관리하는게 아닌 서비스 클래스를 만들어 이 인스턴스 안에서 상태 관리를 해주고 훅은 세션 관리(피어 연결과 관련된 로직)만 처리하고, 상태 관리 부분은 분리해서 해당 상태를 사용하는 곳에서는 해당 인스턴스를 불러오도록 설계를 바꾸기로 했다.

해당 리팩토링 부분은 아래에 더 자세히 작성하였다.


2️⃣ usePeerConnection → useRTCSession

리팩토링 전의 usePeerConnection

const usePeerConnection = (socket: Socket) => {
  const [peers, setPeers] = useState<PeerConnection[]>([]); // 연결 관리
  const peerConnections = useRef<{ [key: string]: RTCPeerConnection }>({});
  const dataChannels = useRef<{ [peerId: string]: RTCDataChannel }>({});
  const [peerMediaStatus, setPeerMediaStatus] = useState<{
    [peerId: string]: {
      audio: boolean;
      video: boolean;
    };
  }>({});
  
  const pcConfig = { iceServers: [...] };

  const createPeerConnection = async (...) => { 피어 커넥션 생성 };

  const closePeerConnection = (peerSocketId: string) => { 피어커넥션 종료 };

  return {
    peers,
    setPeers,
    peerConnections,
    createPeerConnection,
    closePeerConnection,
    dataChannels,
    peerMediaStatus,
  };
};

export default usePeerConnection;

리팩토링 전의 훅을 보면 다음과 같다.

  • peer와 관련된 상태를 해당 훅에서 관리한다.
  • 피어커넥션과 데이터 채널도 해당 훅에서 useRef로 관리한다.
    • useRef로 관리하는 이유는 두 상태가 리액트 렌더링에 영향을 받지 않고 데이터를 유지하기 위해서였다. 즉, 변경사항이 생겨도 렌더링을 트리거하지 않도록 하기 위해서였다.
    • 두 객체는 렌더링에 직접 사용되지 않고, 피어 목록이나 미디어 상태 등은 따로 관리를 하므로 이들은 렌더링과 관련이 없기 때문이다.
  • 연결 처리 시 발생하는 이벤트를 등록하는 것 useSocketEvents에서 관리해주고 있는데 이 파일에서 피어 연결 시 발생하는 이벤트를 가져와서 함께 관리해주도록 변경할 예정이다.

리팩토링 후의 훅

const useWebRTCSession = (socket: Socket) => { 
  const setPeers = usePeerStore(state => state.setPeers);
  ...
  const setParticipants = useSessionStore(state => state.setParticipants);
  const webRTCManagerRef = useRef<WebRTCManager | null>(null);

  useEffect(() => {
    if (!socket || !nickname || !stream) return;

    webRTCManagerRef.current = WebRTCManager.getInstance( ... );

    const peerConnections = webRTCManagerRef.current.getPeerConnection();
    const dataChannels = webRTCManagerRef.current.getDataChannels();

    const handleGetOffer = async () => {
      const pc = await webRTCManagerRef.current?.createPeerConnection( ... );
      pc.setRemoteDescription(new RTCSessionDescription(data.sdp)),
      const answer = await pc.createAnswer();
      await pc.setLocalDescription(answer);

      socket?.emit(SIGNAL_EMIT_EVENT.ANSWER, { ... });
    };

    const handleGetAnswer = async () => { 
      await pc.setRemoteDescription(new RTCSessionDescription(data.sdp));
    };

    const handleGetCandidate = async () => {
      await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
    };

    const handleAllUsers = async (data: RoomJoinResponse) => {
      const response = { /* 만들어진 방에 대한 데이터 */ };

      setRoomMetadata(response);
      setIsHost(response.host.socketId === socket.id);
      setParticipants( ... );

      for (const [socketId, userInfo] of Object.entries(data.connectionMap)) {
        await webRTCManagerRef.current?.createPeerConnection( ... );
      }
    }

    const handleUserExit = ({ socketId }: { socketId: string }) => { 
      webRTCManagerRef.current?.closePeerConnection(socketId);
    };

    socket.on(SIGNAL_LISTEN_EVENT.OFFER, handleGetOffer);
    socket.on(SIGNAL_LISTEN_EVENT.ANSWER, handleGetAnswer);
    ...

    return () => {
      webRTCManagerRef.current?.cleanup();
      webRTCManagerRef.current = null;

      Object.values(dataChannels).forEach(channel => { channel.close(); });

      socket.off(SIGNAL_LISTEN_EVENT.OFFER, handleGetOffer);
      socket.off(SIGNAL_LISTEN_EVENT.ANSWER, handleGetAnswer);
      ...
    };
  }, [socket, nickname, stream]);
}; 
  • 변경된 훅에서는 상태를 관리하거나 반환하지 않는다. 필요한 상태는 전역 상태에 있는 것들을 가져온다.
    • 전역 상태로 관리하기로 한 이유는 여러 컴포넌트에서 사용되기 때문에 props 전달보다는 전역 상태로 관리하는게 더 효율적이라 판단했다.
  • 이전에 없던 관련 소켓 이벤트를 등록하고 해제한다. (훅 자체는 화상회의하는 채널 페이지에서 한 번 부르는 훅으로 만들고, 상태 관리는 하지 않는다.)
  • 소켓 이벤트에는 offer, answer, 유저가 나가거나 들어오는 이벤트 등이 있는데 이에 대한 핸들러 함수를 정의했다. 그리고 createPeerConnection처럼 연결 설정을 하는 부분이 꽤 복잡하다. 이 부분을 서비스 레이어를 만들어 그 부분에서 하도록 만들었다.

소켓 이벤트를 하나에서 관리하다 분리한 이유

소켓 이벤트는 스터디룸에서 스터디를 진행할 때 발생하는 이벤트도 있고 스터디룸 자체를 만들 때 커넥션을 생성하며 발생하는 이벤트 등 다양했다. 그러나 이걸 한 번에 등록하게 되면서 관련 핸들러 함수는 흩어져있었고, 소켓 이벤트와 관련 로직이 같은 위치에 있지 않다보니 파일을 왔다갔다 찾아다니는게 불편했다. 그래서 하나에서 관리하는게 아닌 관련 파일에서 관리하도록 변경했다.

WebRTC 매니저

class WebRTCManager {
  private static instance: WebRTCManager | null = null; 
  private constructor(
    socket: Socket,
    setPeers: (update: (prev: PeerConnection[]) => PeerConnection[]) => void,
    setPeerMediaStatus: (update: (prev: PeerMediaStatuses) => PeerMediaStatuses) => void,
    setParticipants: (
      participants: Participant[] | ((prev: Participant[]) => Participant[])
    ) => void,
  ) {
    this.socket = socket;
    this.pcConfig = { ... };
    this.setPeers = setPeers;
    this.setPeerMediaStatus = setPeerMediaStatus;
    this.setParticipants = setParticipants;
  }

  public static getInstance( ... ) {
    if (WebRTCManager.instance) return WebRTCManager.instance;
    WebRTCManager.instance = new WebRTCManager( ... );
    return WebRTCManager.instance;
  }

  public cleanup() {
    Object.keys(this.peerConnections).forEach(peerId => {
      this.closePeerConnection(peerId);
    });
    this.peerConnections = {};
    this.dataChannels = {};
    WebRTCManager.instance = null;
  }

  public getPeerConnection() return this.peerConnections;
  public getDataChannels() return this.dataChannels;

  private peerUpdated = ( ... ) => {
    this.setPeers((prev) => {
      const newPeers = prev.map(p =>
        p.peerId === peerId ? { ...p, stream } : p
      );

      const isNewPeer = !prev.find(p => p.peerId === peerId);
      if (isNewPeer) {
        newPeers.push( ... );
        this.setParticipants((prevParticipants) => { ... });
      }
      return newPeers;
    });
  }

  private peerRemoved = (peerId: string) => {
    this.setPeers(prev => { ... });
    this.setParticipants(prev => { ... });
  };

  private mediaStatusChanged = () => { ... };

  private handleConnectionFailure = () => {
    this.peerRemoved(peerSocketId);
    this.closePeerConnection(peerSocketId);

    setTimeout(() => {
      this.createPeerConnection( ... );
    }, RETRY_CONNECTION_MS);
  };

  async createPeerConnection( ... ) {
    if (this.peerConnections[peerSocketId]) {
      const existingPc = this.peerConnections[peerSocketId];

      if (existingPc.connectionState === "failed" ||
        existingPc.connectionState === "disconnected") {
        this.closePeerConnection(peerSocketId);
      } else {
        return existingPc;
      }
    }
    const pc = new RTCPeerConnection(this.pcConfig);
    this.peerConnections[peerSocketId] = pc;

    if (stream) {
      stream.getTracks().forEach((track) => {
        pc.addTrack(track, stream);
      });
    }

    pc.onicecandidate = (e: RTCPeerConnectionIceEvent) => {
      if (!e.candidate) return;

      this.socket.emit(SIGNAL_EMIT_EVENT.CANDIDATE, {
        candidateReceiveID: peerSocketId,
        candidate: e.candidate,
        candidateSendID: this.socket.id,
      });
    };

    pc.onsignalingstatechange = () => {
      const candidates = this.pendingIceCandidates.get(peerSocketId) || [];
      candidates.forEach(candidate => {
        this.socket.emit(SIGNAL_EMIT_EVENT.CANDIDATE, {
          candidateReceiveID: peerSocketId,
          candidate,
          candidateSendID: this.socket.id,
        });
      });
      this.pendingIceCandidates.delete(peerSocketId);
    };

    const mediaDataChannel = pc.createDataChannel("media-status", {
      ordered: true,
      negotiated: true,
      id: 0
    });

    mediaDataChannel.onopen = () => {
      this.dataChannels[peerSocketId] = mediaDataChannel;

      if (stream) {
        const audioTracks = stream.getAudioTracks();
        const audioEnabled = audioTracks.length > 0 && audioTracks[0].enabled;
        const videoTracks = stream.getVideoTracks();
        const videoEnabled = videoTracks.length > 0 && videoTracks[0].label !== "blackTrack";

        mediaDataChannel.send(JSON.stringify({ type: "audio", status: audioEnabled }));
        mediaDataChannel.send(JSON.stringify({ type: "video", status: videoEnabled }));
      }
    };

    mediaDataChannel.onmessage = (e) => {
      const data = JSON.parse(e.data);
      this.mediaStatusChanged(peerSocketId, data.type, data.status);
    };

    mediaDataChannel.onclose = () => {
      delete this.dataChannels[peerSocketId];
    };

    pc.ontrack = (e) => {
      this.peerUpdated(peerSocketId, peerNickname, e.streams[0], localUser.isHost);

      const audioTracks = e.streams[0].getAudioTracks();
      const videoTracks = e.streams[0].getVideoTracks();
      this.mediaStatusChanged(peerSocketId, 'audio', audioTracks.length > 0 && audioTracks[0].enabled);
      this.mediaStatusChanged(peerSocketId, 'video', videoTracks.length > 0 && videoTracks[0].label !== "blackTrack");
    };

    pc.onconnectionstatechange = () => {
      if (pc.connectionState === "disconnected" || pc.connectionState === "failed") {
        this.handleConnectionFailure(peerSocketId, peerNickname, stream, isOffer, localUser);
      }
    };

    if (isOffer) {
      try {
        const offer = await pc.createOffer();
        await pc.setLocalDescription(offer);

        if (this.socket && pc.localDescription) {
          this.socket.emit(SIGNAL_EMIT_EVENT.OFFER, {
            offerReceiveID: peerSocketId,
            sdp: pc.localDescription,
            offerSendID: this.socket.id,
            offerSendNickname: localUser.nickname,
          });
        }
      } catch (error) {
        console.error(error);
      }
    }

    return pc;
  };

  closePeerConnection(peerSocketId: string) {
    if (this.peerConnections[peerSocketId]) {
      const pc = this.peerConnections[peerSocketId];
	  ...

      delete this.peerConnections[peerSocketId];
      delete this.dataChannels[peerSocketId];
      pc.close();

      this.peerRemoved(peerSocketId);
    }
  };
}

코드가 길어서 일부분은 생략했다.

  • WebRTC 연결 관리를 해주는 서비스 매니저다.
  • 싱글톤으로 구현했는데, 현재 해당 매니저에서 연결 상태나 데이터 채널과 같은 상태를 관리해주고 있기 때문에 이를 사용해야하는 컴포넌트에서 언제든지 불러와 사용할 수 있게 구현하기 위해서 싱글톤으로 만들게 되었다.
    • 위 상태를 이용하기 위해 getter 함수를 만들었다.
  • peerConenction을 해주고 종료하는 메서드도 해당 매니저에서 호출해서 사용하면 된다. 이렇게 구현함으로써 실제 훅에서는 복잡한 로직을 다 적을 필요없이 필요한 인자만 전달해서 가독성을 높일 수 있었다.

이 훅이 대표적인 예시라 작성했지만, 미디어 관리를 해주는 훅에서도 서비스 레이어를 넣어서 복잡한 로직을 서비스 계층에서 처리하도록 했다.

결과적으로 훅에서 처리하던 피어 상태와 같은 상태들을 전역 상태로 관리하게 되었고, 피어 연결 생성 및 관리 로직은 매니저가 관리한다. 훅 자체는 소켓 이벤트 등록/해제와 WebRTC 매니저 인스턴스 생명주기 관리만 담당하면서 책임을 명확하게 할 수 있게 했다.

MediaManager

class MediaManager {
  async getUserDevices() {
    const devices = await navigator.mediaDevices.enumerateDevices();

    return {
      audioDevices: devices.filter(device => device.kind === "audioinput"),
      videoDevices: devices.filter(device => device.kind === "videoinput"),
      hasPermission: devices.some(device => device.deviceId !== "")
    };
  }

  stopTracks(stream: MediaStream | null) {
    stream?.getTracks().forEach((track) => track.stop());
  }

  combineTracks(videoStream: MediaStream | null, audioStream: MediaStream | null) {
    const tracks = [
      ...(videoStream?.getVideoTracks() || [createDummyStream()]),
      ...(audioStream?.getAudioTracks() || [])
    ]
    return new MediaStream(tracks);
  }

  async getMediaStream(
    mediaType: MediaStreamType,
    deviceId: string | null,
  ) {
    try {
      const constraints = {
        video: mediaType === "video" ? (deviceId ? { deviceId } : true) : false,
        audio: mediaType === "audio" ? (deviceId ? { deviceId } : true) : false
      };

      return await navigator.mediaDevices.getUserMedia(constraints);
    } catch (error) {
      console.warn(`${mediaType} 스트림을 가져오는데 실패했습니다:`, error);
      return null;
    }
  };

  async replaceVideoTrack(peerConnection: RTCPeerConnection, newTrack: MediaStreamTrack) {
    const sender = peerConnection
      .getSenders()
      .find((s) => s.track?.kind === "video");

    if (sender) {
      return await sender.replaceTrack(newTrack);
    }
  }

  removeVideoTracks(stream: MediaStream) {
    const videoTracks = stream.getVideoTracks();
    videoTracks.forEach((track) => {
      track.stop();
      stream.removeTrack(track);
    });
  }

  toggleAudioTrack(audioTrack: MediaStreamTrack, enable: boolean) {
    audioTrack.enabled = enable;
  }
} 
  • WebRTC 매니저에 비하면 생각보다 복잡하지는 않다.
  • 비디오 트랙을 지우거나, 트랙을 합치는 등의 메서드를 해당 매니저가 담당하게 하고 훅에서는 이를 호출해서 무슨 일을 하는지 함수 이름을 통해 알 수 있기 때문에 전체적인 코드 수가 감소하고 가독성은 향상되었다.
  • 복잡한 훅에서 어떤 일을 하는지 보여주고 그 내부의 복잡한 로직을 서비스 매니저가 담당하게 한 것이다.

서비스 계층을 도입하며

훅 자체가 명확히 관련된 일을 하도록 모아보면서 훅 자체의 코드 라인 수가 많은 게 아쉬웠다. 물론 기능이 많고 복잡하다보면 코드 라인 수는 상관 없을지도 모른다. 그렇지만 명확히 훅에서 어떤 일을 하는지 잘 읽히지 않았기 때문에 걱정이 있었다.

서비스 계층을 도입해서 복잡한 로직을 캡슐화하며, 가독성이 어느정도 향상되었다는 점이 가장 만족스러운 점이다.(더 개선될 여지는 있다.) 사실 중간에 전역 상태로 바꾸면서 props drilling을 1차적으로 제거해서 상태 관리를 잘 하면 괜찮을거라 생각하고 신경썼는데 훅의 너무 많은 책임 및 복잡함으로 인해 소켓 이벤트가 여러번 등록되며 화상회의가 동작하지 않았던 문제를 겪으며 구조를 생각하지 않고 훅을 분리했던 것에 대해 반성했다.

현재는 복잡한 로직을 서비스에 위임한다는 느낌이라 진정한 계층 분리가 이루어졌는가?에 대한 물음에 있어서는 살짝 아쉬움이 있다. 더 명확히 계층을 더 분리할 수도 있을거 같다. 그러나 너무 많은 코드 양을 리팩토링하다보니 살짝 지쳐서 일단 여기까지 해두었다.


📝 앞으로의 계획

복잡한 로직을 분리하고 UI만 나타내기

위 리팩토링 과정에서 비즈니스 로직을 서비스 계층을 만들어 분리해서, 복잡한 로직을 서비스 계층에서 처리할 수 있게 구현했다. 하지만 이렇게 분리했음에도 기능 자체가 복잡해서 복잡한 부분을 서비스에서 처리하도록 임시로 해둔 느낌이 강했다.

상태 변경이 일어나는 부분이나 이벤트를 처리하거나 외부와 통신하는 등 각 역할 별로 계층을 명확히 나누는 것을 시도해보는게 좋을거 같다.

특히나 UI 부분과 비즈니스 로직을 제대로 분리해서 UI 코드는 UI에만 집중할 수 있게 구성해두는게 필요해보인다.

커스텀 훅의 역할 명확히 하기

각 커스텀 훅이 어떤 역할을 하는지 역할을 명확히 할 필요가 있다고 느꼈다. 특히나 커스텀 훅에서 어떤 값을 반환하는데 여러 군데서 호출해서 사용해서 useEffect가 여러번 실행되어 의도치 않은 코드 실행이 발생할 수 있다는 걸 이번 리팩토링을 통해 알게되었다.

또한 커스텀 훅 내에서도 여러가지 일을 하고 있다면 이것도 계층을 나누어 수정해봐야겠다.

위 2가지 일을 하기 전 상위 컴포넌트의 구조를 다시 정리한 뒤 해야할 거 같다. 합성 컴포넌트 개념을 도입해서 코드를 명확히 구조화를 시도해봐야겠다.

profile
안녕하세오

0개의 댓글