WebRTC를 활용한 화상채팅 개발 회고 (트랙 온오프/변경 구현)

NARARIA03·2025년 1월 12일
1

WebRTC

목록 보기
3/3
post-thumbnail

개요

지난 포스트에서 WebRTC 시그널링 과정을 Mesh 형태로 구현했었다.

하지만 소스코드 모듈화 없이 useEffect 내의 단일 함수로 구성되어 있어 가독성이 매우 떨어지는 단점이 있다. (작성자인 나조차 읽기 어려울 정도..)

때문에 이번 포스트에서는 먼저 useSocket 커스텀 훅을 리팩토링한 뒤, 비디오/오디오 장치 온오프 기능을 구현하고, 마지막으로 비디오/오디오 장치 변경 드롭다운을 구현해볼 예정이다.


useSocket 리팩토링

코드 리팩토링을 위해 먼저 useSocket 훅의 handleRTC 함수가 하는 역할에 대해 생각해보자.

현재 handleRTC 함수는 네 가지 역할을 수행한다.

  1. 기존 유저가 있는 상황에서 내가 접속하면 실행되는 로직 (recv-users > send-offer > recv-answer)

  2. 내가 접속해있던 상황에서 새 유저가 들어오면 실행되는 로직 (recv-offer > send-answer)

  3. 다른 유저의 ICE candidate를 수신하는 로직 (recv-ice-candidate)

  4. 다른 유저가 나가면 실행되는 로직 (disconnected)

해당 로직들은 모두 Socket 객체에 이벤트 리스너(on)를 연결하고, 그 안에 콜백 함수를 작성하는 형태다. 따라서 socketRef.current를 매개변수로 받는 함수들로 분리가 가능하다.

그 외의 값(state, ref)들은 스코프 체인을 통해 접근할 수 있으므로, 매개변수로 넘겨주지 않아도 된다.

useEffect 내에서 사용되는 함수를 밖에서 선언하는 경우, dependency 관리를 위해 useCallback으로 감싸는 것이 필요할 수 있다. 그러지 않으면 React가 컴포넌트를 리렌더링할 때 함수들이 재선언 되면서 Reference가 달라지므로 useEffect가 다시 실행된다.

즉, useEffect의 특징인 "컴포넌트 마운트 시 실행", "dependency가 변할 때만 실행", "컴포넌트 언마운트 시 cleanup 실행"이 정상적으로 동작하지 않게 된다.

ESLint를 사용하면, 이러한 문제를 경고로 확인할 수 있다.


0. getUserMedia 함수를 분리

이후 "장치 변경"을 구현할 때, 시그널링 로직과 최대한 분리시키기 위해 getUserMedia라는 함수를 만들어 stream을 반환하도록 해보자.

// useSocket.ts의 getUserMedia 함수

const getUserMedia = async () => {
  return navigator.mediaDevices.getUserMedia({
    video: true,
    audio: true,
  });
};

1. 기존 유저가 있는 상황에서 내가 접속하면 실행되는 로직

먼저 recv-users > send-offer > recv-answer 로직부터 함수로 분리해보자.

connectToExistingUsers 라는 함수명을 사용하겠다. (존재하는 유저들에게 연결한다는 의미)

// useSocket.ts의 connectToExistingUsers 함수

const connectToExistingUsers = useCallback(
  (socket: Socket) => {
    socket.on("recv-users", async (users: RecvUser[]) => {
      const stream = await getUserMedia();

      myStreamRef.current = stream;
      if (myVideoRef.current) myVideoRef.current.srcObject = stream;

      users.forEach(async ({ socketId, nick }) => {
        const peer = new RTCPeerConnection();

        stream.getTracks().forEach((track) => peer.addTrack(track, stream));

        const offer = await peer.createOffer();
        peer.setLocalDescription(offer);
        socket.emit("send-offer", socketId, socket.id, offer);

        peer.onicecandidate = (e) => {
          if (e.candidate) {
            socket.emit(
              "send-ice-candidate",
              socketId,
              socket.id,
              e.candidate
            );
          }
        };

        peer.ontrack = (e) => {
          const streamObj: RemoteStream = {
            socketId,
            nick,
            stream: e.streams[0],
          };
          setRemoteStreams((prev) =>
            prev.some((remoteStream) => remoteStream.socketId === socketId)
              ? prev
              : [...prev, streamObj]
          );
        };

        peersRef.current.push({ socketId, nick, peer });
      });
    });
    
    socket.on(
      "recv-answer",
      (hostId: string, answer: { type: RTCSdpType; sdp: string }) => {
        const peer = peersRef.current.find((p) => p.socketId === hostId);
        if (peer) peer.peer.setRemoteDescription(answer);
      }
    );
  },
  [myVideoRef]
);

Q: 무한 재귀로 인해 remoteStreams가 계속 증식한다면?

setRemoteStreams((prev) =>
  prev.some((remoteStream) => remoteStream.socketId === socketId)
    ? prev
    : [...prev, streamObj]
);

위 코드는 setState의 콜백 함수에서 state에 접근한다. 이렇게 하면 전체 함수의 dependency에 state가 포함되지 않아 한 번 업데이트 후 멈춘다. (원하는 동작)

if (!remoteStreams.some((stream) => stream.socketId === socketId))
  setRemoteStreams((prev) => [...prev, streamObj]);

반면, 위와 같이 접근하면 함수의 dependency에 state가 들어가 setState 로직의 dependency에 state가 포함되면서 무한 재귀에 빠진다. (원하지 않는 동작)

이를 통해 setState 로직에서 외부 state를 직접 참조하면 무한 재귀가 발생할 수 있음을 배울 수 있다.

만약 state의 조건에 따라 setState를 실행해야 한다면, 콜백 함수를 통해 이전 state(prev)를 안전하게 참조하는 방식으로 구현하자!


2. 내가 접속해있던 상황에서 새 유저가 들어오면 실행되는 로직

다음으로 recv-offer > send-answer 로직을 함수로 분리하자.

connectToNewUser 라는 함수명을 사용하겠다. (새로 들어온 유저와 연결한다는 의미)

connectToExistingUsers는 복수형이고, connectToNewUser는 단수형인 이유는 로직을 생각해보면 된다. 전자는 모든 기존 유저들에게 Offer를 전송하고, 후자는 Offer를 전송한 새 유저에게 Answer를 전송한다는 의미를 함수명에 최대한 담아보았다.

// useSocket.ts의 connectToNewUser 함수

const connectToNewUser = useCallback(
  (socket: Socket) => {
    socket.on(
      "recv-offer",
      async (
        socketId: string,
        nick: string,
        offer: { type: RTCSdpType; sdp: string }
      ) => {
        const peer = new RTCPeerConnection();

        const stream = await getUserMedia();
        myStreamRef.current = stream;
        if (myVideoRef.current) myVideoRef.current.srcObject = stream;
        stream.getTracks().forEach((track) => peer.addTrack(track, stream));

        peer.setRemoteDescription(offer);

        peer.ontrack = (e) => {
          const streamObj: RemoteStream = {
            socketId,
            nick,
            stream: e.streams[0],
          };
          setRemoteStreams((prev) =>
            prev.some((remoteStream) => remoteStream.socketId === socketId)
              ? prev
              : [...prev, streamObj]
          );
        };

        const answer = await peer.createAnswer();
        peer.setLocalDescription(answer);
        socket.emit("send-answer", socketId, socket.id, answer);

        peer.onicecandidate = (e) => {
          if (e.candidate) {
            socket.emit(
              "send-ice-candidate",
              socketId,
              socket.id,
              e.candidate
            );
          }
        };

        peersRef.current.push({ socketId, nick, peer });
      }
    );
  },
  [myVideoRef]
);

3. 다른 유저의 ICE candidate를 수신하는 로직

이제 recv-ice-candidate 이벤트 리스너를 함수로 분리하자.

handleRecvIceCandidate 라는 함수명을 사용하겠다. 이 함수는 dependency가 없어 useCallback으로 감싸주지 않아도 된다.

// useSocket.ts의 handleRecvIceCandidate 함수

const handleRecvIceCandidate = (socket: Socket) => {
  socket.on(
    "recv-ice-candidate",
    (hostId: string, candidate: RTCIceCandidate) => {
      const peer = peersRef.current.find((p) => p.socketId === hostId);
      if (peer) {
        peer.peer.addIceCandidate(candidate);
      }
    }
  );
};

4. 다른 유저가 나가면 실행되는 로직

마지막으로 유저가 나가면, peersRef와 remoteStreams를 정리하는 로직을 함수로 빼내자.

handleUserDisconnection 이라는 함수명을 사용하겠다. 이 함수 역시 dependency가 존재하지 않는다.

// useSocket.ts의 handleUserDisconnection 함수

const handleUserDisconnection = (socket: Socket) => {
  socket.on("disconnected", (socketId: string) => {
    peersRef.current.filter((peer) => peer.socketId !== socketId);
    setRemoteStreams((prev) =>
      prev.filter((stream) => stream.socketId !== socketId)
    );
  });
};

useEffect에서 함수 호출

handleRTC 함수를 제거하고, 위 4개의 함수를 호출해주면 끝난다.

// useSocket.ts의 useEffect
useEffect(() => {
  if (nick) {
    socketRef.current = io("http://localhost:8080", {
      query: {
        nick,
      },
    });
    connectToExistingUsers(socketRef.current);
    connectToNewUser(socketRef.current);
    handleRecvIceCandidate(socketRef.current);
    handleUserDisconnection(socketRef.current);
  }

  return () => {
    if (socketRef.current) socketRef.current.close();
  };
}, [connectToExistingUsers, connectToNewUser, nick]);

함수를 쪼갠 후에도 문제 없이 작동하는 것을 확인했다!

이제 다음으로 넘어가 본격적으로 스트림 온오프/변경 기능을 구현해보자.


스트림 온오프 기능 구현

MediaStream 객체에는 getVideoTracksgetAudioTracks 메서드가 존재한다.

이 두 메서드를 통해 MediaStreamTrack 객체에 접근해 enabled 값을 토글시켜 온오프 기능을 구현할 수 있다.

우리는 이미 myStreamRef를 활용해 MediaStream 객체를 관리 중이므로, 사용자가 UI를 통해 조작하는 정보를 useSocket 훅에서 파라미터로 받아와 useEffect 내에서 getVideoTracks와 getAudioTracks를 활용해 토글 시키면 된다.

먼저 video/audio 온오프 상태를 관리하기 위한 간단한 타입을 선언한다.

// frontend/src/frontTypes.ts

export type TrackOn = {
  video: boolean;
  audio: boolean;
};

그리고 RoomPage 컴포넌트에서 TrackOn 타입의 state를 만든 뒤, 토글 UI를 shadcn/ui를 활용해 구현하고, useSocket 훅의 파라미터로 전달할 것이다.


토글 UI 구현

shadcn/ui 공식 문서를 참고해 토글 UI를 가져와 사용해보자.

pnpm dlx shadcn@latest add toggle

그리고 VideoToggle, AudioToggle 컴포넌트를 만들자. 아이콘은 Lucide를 참고했다. 코드는 간단하다!

// frontend/src/VideoToggle.tsx

import { Dispatch, SetStateAction } from "react";
import { TrackOn } from "./frontTypes";
import { Toggle } from "@/components/ui/toggle";
import { Video, VideoOff } from "lucide-react";

interface Props {
  trackOn: TrackOn;
  setTrackOn: Dispatch<SetStateAction<TrackOn>>;
}

function VideoToggle({ trackOn, setTrackOn }: Props): JSX.Element {
  const handleClick = () => {
    setTrackOn((prev) => ({ ...prev, video: !prev.video }));
  };

  return (
    <Toggle variant="outline" aria-label="Toggle italic" onClick={handleClick}>
      {trackOn.video ? <Video /> : <VideoOff />}
    </Toggle>
  );
}

export default VideoToggle;
// frontend/src/AudioToggle.tsx

import { Dispatch, SetStateAction } from "react";
import { TrackOn } from "./frontTypes";
import { Toggle } from "@/components/ui/toggle";
import { Mic, MicOff } from "lucide-react";

interface Props {
  trackOn: TrackOn;
  setTrackOn: Dispatch<SetStateAction<TrackOn>>;
}

function AudioToggle({ trackOn, setTrackOn }: Props): JSX.Element {
  const handleClick = () => {
    setTrackOn((prev) => ({ ...prev, audio: !prev.audio }));
  };

  return (
    <Toggle variant="outline" aria-label="Toggle italic" onClick={handleClick}>
      {trackOn.audio ? <Mic /> : <MicOff />}
    </Toggle>
  );
}

export default AudioToggle;

두 컴포넌트 모두 trackOn과 setTrackOn을 props로 받아와 state를 토글 시키도록 구현했다.


RoomPage 코드 작성

위에서 만든 두 토글 컴포넌트를 RoomPage로 가져와 연결해주자.

// frontend/src/RoomPage.tsx
...
function RoomPage(): JSX.Element {
  ...
  const [trackOn, setTrackOn] = useState<TrackOn>({
    video: true,
    audio: true,
  }); // 추가
  ...
  const remoteStreams = useSocket(nick, myVideoRef, trackOn); // 파라미터 추가

  return (
    <div className="w-screen h-screen">
      <div className="w-full flex justify-center items-center">
        <div>
          <video className="w-64 h-64" ref={myVideoRef} autoPlay playsInline />
          <p className="text-center">{nick}</p>
        </div>

        {remoteStreams.map((stream) => (
          <div key={stream.socketId}>
            <Video remoteStream={stream} />
            <p className="text-center">{stream.nick}</p>
          </div>
        ))}
      </div>

      <div className="w-full flex justify-center items-center gap-4 mt-4">
        <VideoToggle trackOn={trackOn} setTrackOn={setTrackOn} />
        <AudioToggle trackOn={trackOn} setTrackOn={setTrackOn} />
      </div>
    </div>
  );
}

export default RoomPage;

이제 VideoToggleAudioToggle 컴포넌트를 활용해 사용자가 trackOn을 변경할 수 있게 되었다.


useSocket 코드 작성

useSocket 훅에서 trackOn 파라미터를 받고, enabled를 변경하도록 useEffect를 구현하자.

// frontend/src/hooks/useSocket.ts
...
export const useSocket = (
  nick: string | undefined,
  myVideoRef: RefObject<HTMLVideoElement>,
  trackOn: TrackOn // 추가
) => {
  ...
  // 비디오/오디오 스트림 토글 구현
  useEffect(() => {
    if (myStreamRef.current) {
      myStreamRef.current
        .getVideoTracks()
        .forEach((videoTrack) => (videoTrack.enabled = trackOn.video));
      myStreamRef.current
        .getAudioTracks()
        .forEach((audioTrack) => (audioTrack.enabled = trackOn.audio));
    }
  }, [trackOn]);

  return remoteStreams;
};

스트림 온오프 기능이 문제없이 잘 작동하는 것을 확인할 수 있다!


스트림 변경 기능 구현

MDN 공식 문서를 살펴보면, getUserMedia 함수의 매개변수로 constraint를 전달해 특정 디바이스의 스트림을 가져오도록 강제하는 법이 나와있다.

그러면 선택된 deviceId를 state로 관리하면서 Select 요소를 통해 사용자가 디바이스를 변경 시 getUserMedia 부분을 다시 실행시키면, 장치 변경 기능을 구현할 수 있지 않을까?

하지만 이 방법은 비효율적이다. "시그널링"을 처음부터 다시 수행하기 때문이다.

deviceId가 변경되면 getUserMedia부터 다시 시작한다는 말은, 새로운 스트림을 활용해 다시 시그널링 로직을 실행해야 한다는 것이고, 이로 인해 깜빡임이나 딜레이가 생길 수 있다. 이는 사용자 경험을 크게 저하시킬 것이다.

스트림 변경을 위해 MDN 공식 문서에서는 replaceTrack 메서드를 사용하라고 한다. replaceTrack 메서드를 사용하면 기존 연결을 유지하며 스트림만 변경할 수 있다.

먼저, 선택된 디바이스를 관리하기 위한 타입을 선언한다.

// frontend/src/frontTypes.ts

export type SelectedDeviceId = {
  video: string | null;
  audio: string | null;
};

그리고 RoomPage 컴포넌트에서 SelectedDeviceId 타입의 state를 만든 뒤, 사용자가 비디오/오디오 디바이스 리스트에서 원하는 디바이스를 선택할 수 있도록 하는 Select UI를 shadcn/ui를 활용해 구현하고, useSocket 훅의 파라미터로 전달할 것이다.


Select UI 구현

shadcn/ui 공식 문서를 참고해 Select UI를 가져와 사용해보자.

pnpm dlx shadcn@latest add select

그리고 VideoSelect, AudioSelect 컴포넌트를 만들자. 이 역시 코드는 간단하다!

// frontend/src/VideoSelect.tsx

import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { SelectedDeviceId } from "./frontTypes";

interface Props {
  setSelectedDeviceId: Dispatch<SetStateAction<SelectedDeviceId>>;
}

function VideoSelect({ setSelectedDeviceId }: Props): JSX.Element {
  const [myVideoDevices, setMyVideoDevices] = useState<MediaDeviceInfo[]>([]);

  const handleChange = (deviceId: string) => {
    setSelectedDeviceId((prev) => ({ ...prev, video: deviceId }));
  };

  useEffect(() => {
    const getVideoDevices = async () => {
      // 장치를 가져오기 전, 먼저 getUserMedia로 퍼미션부터 받기
      await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
      // 모든 비디오 장치들을 가져와 state에 저장
      const allDevices = await navigator.mediaDevices.enumerateDevices();
      const videoDevices = allDevices.filter(
        (device) => device.kind === "videoinput"
      );
      setMyVideoDevices(videoDevices);
    };

    getVideoDevices();
  }, []);

  return (
    <Select onValueChange={handleChange}>
      <SelectTrigger className="w-[240px]">
        <SelectValue placeholder="Change video device..." />
      </SelectTrigger>
      <SelectContent>
        {myVideoDevices.map((device) => (
          <SelectItem key={device.deviceId} value={device.deviceId}>
            {device.label}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

export default VideoSelect;
// frontend/src/AudioSelect.tsx

import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { SelectedDeviceId } from "./frontTypes";

interface Props {
  setSelectedDeviceId: Dispatch<SetStateAction<SelectedDeviceId>>;
}

function AudioSelect({ setSelectedDeviceId }: Props): JSX.Element {
  const [myVideoDevices, setMyVideoDevices] = useState<MediaDeviceInfo[]>([]);

  const handleChange = (deviceId: string) => {
    setSelectedDeviceId((prev) => ({ ...prev, audio: deviceId }));
  };

  useEffect(() => {
    const getAudioDevices = async () => {
      // 장치를 가져오기 전, 먼저 getUserMedia로 퍼미션부터 받기
      await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
      // 모든 오디오 장치들을 가져와 state에 저장
      const allDevices = await navigator.mediaDevices.enumerateDevices();
      const videoDevices = allDevices.filter(
        (device) => device.kind === "audioinput"
      );
      setMyVideoDevices(videoDevices);
    };

    getAudioDevices();
  }, []);

  return (
    <Select onValueChange={handleChange}>
      <SelectTrigger className="w-[240px]">
        <SelectValue placeholder="Change audio device..." />
      </SelectTrigger>
      <SelectContent>
        {myVideoDevices.map((device) => (
          <SelectItem key={device.deviceId} value={device.deviceId}>
            {device.label}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

export default AudioSelect;

토글 컴포넌트와 유사하게 setSelectedDevice를 props로 받아와 state를 변경하도록 구현했다.

장치 리스트를 가져오기 위해 useEffect와 useState를 활용했고, 비디오/오디오 디바이스를 구분하기 위해 kind를 활용해 필터링 해줬다.

주의할 점은 enumerateDevices 메서드 호출 시점에 getUserMedia 메서드가 호출되기 이전이면, 권한 문제로 장치 리스트를 가져오지 못해 렌더링 쪽에서 에러가 발생한다.

이를 해결하기 위해 useEffect에서 enumerateDevices를 호출하기 전, getUserMedia를 먼저 호출하고 await으로 기다려줬다.

여기서 호출하는 getUserMedia는 권한을 얻기 전까지 기다리기 위한 목적 뿐이다.

다른 방법으로 useSocket 훅에서 myStreamRef를 함께 return하고, VideoSelectAudioSelect에게 props로 전달해 조건부 렌더링을 수행하는 것도 괜찮아 보인다.

그 다음, 가져온 장치 리스트를 SelectContent 안쪽에 SelectItem 형태로 렌더링 해줬다.

이벤트 리스너는 Select의 onValueChange에 연결해줘야 한다! 참고


RoomPage 코드 작성

위에서 만든 두 Select 컴포넌트를 RoomPage로 가져와 연결해주자.

// frontend/src/RoomPage.tsx
...
function RoomPage(): JSX.Element {
  ...
  const [selectedDeviceId, setSelectedDeviceId] = useState<SelectedDeviceId>({
    video: null,
    audio: null,
  }); // 추가
  ...
  // selectedDeviceId 파라미터 추가
  const remoteStreams = useSocket(nick, myVideoRef, trackOn, selectedDeviceId);

  return (
    <div className="w-screen h-screen">
      <div className="w-full flex justify-center items-center">
        <div>
          <video className="w-64 h-64" ref={myVideoRef} autoPlay playsInline />
          <p className="text-center">{nick}</p>
        </div>

        {remoteStreams.map((stream) => (
          <div key={stream.socketId}>
            <Video remoteStream={stream} />
            <p className="text-center">{stream.nick}</p>
          </div>
        ))}
      </div>

      <div className="w-full flex justify-center items-center gap-4 mt-4">
        <VideoSelect setSelectedDeviceId={setSelectedDeviceId} />
        <AudioSelect setSelectedDeviceId={setSelectedDeviceId} />
        <VideoToggle trackOn={trackOn} setTrackOn={setTrackOn} />
        <AudioToggle trackOn={trackOn} setTrackOn={setTrackOn} />
      </div>
    </div>
  );
}

export default RoomPage;

이제 VideoSelectAudioSelect 컴포넌트를 활용해 사용자가 selectedDeviceId를 변경할 수 있게 되었다.


useSocket 코드 작성

useSocket 훅에서 selectedDeviceId를 파라미터를 받고, replaceTrack 메서드를 활용해 스트림 변경을 구현하자.

// frontend/src/hooks/useSocket.ts
...
export const useSocket = (
  nick: string | undefined,
  myVideoRef: RefObject<HTMLVideoElement>,
  trackOn: TrackOn,
  selectedDeviceId: SelectedDeviceId // 추가
) => {
  ...
  // 매개변수를 옵션으로 받도록 함수 수정!
  // 매개변수가 없으면 video/audio 모두 기본 설정으로 가져옴
  // 매개변수가 있으면, video/audio 값이 null이 아닐 때만 deviceId에 해당하는 장치로 가져옴
  // video/audio 값이 null이라면 기본 설정으로 가져옴
  const getUserMedia = async (selectedDeviceId?: SelectedDeviceId) => {
    if (selectedDeviceId) {
      return navigator.mediaDevices.getUserMedia({
        video: selectedDeviceId.video
          ? { deviceId: { exact: selectedDeviceId.video } }
          : true,
        audio: selectedDeviceId.audio
          ? { deviceId: { exact: selectedDeviceId.audio } }
          : true,
      });
    } else {
      return navigator.mediaDevices.getUserMedia({
        video: true,
        audio: true,
      });
    }
  };
  ...
  useEffect(() => {
    const handleChangeDevice = async () => {
      const newStream = await getUserMedia(selectedDeviceId);

      myStreamRef.current = newStream;
      if (myVideoRef.current) {
        myVideoRef.current.srcObject = newStream;
      }

      // 기존 RTCPeerConnection들의 트랙을 새 스트림으로 교체
      peersRef.current.forEach((peer) => {
        newStream.getTracks().forEach((newTrack) => {
          const sender = peer.peer
            .getSenders()
            .find((s) => s.track?.kind === newTrack.kind);
          if (sender) {
            sender.replaceTrack(newTrack);
          }
        });
      });
    };

    handleChangeDevice();
  }, [myVideoRef, selectedDeviceId]);

  return remoteStreams;
};

먼저 getUserMedia 함수를 매개변수 유무에 따라 constraint가 다르게 적용되도록 수정했다.

그리고 매개변수로 받아온 selectedDeviceId를 dependency로 갖는 useEffect 내에서 replaceTrack 로직을 수행한다...만... 개인적으로 좀 복잡하고 어렵다고 생각한다.

한 번 정리해보자.


replaceTrack 로직 살펴보기

peersRef.current.forEach((peer) => { ... });

  • 연결된 RTCPeerConnection 객체들을 순회

newStream.getTracks().forEach((newTrack) => { ... });

  • 새로 선택된 장치(selectedDeviceId)로부터 가져온 MediaStream의 newTrack들을 순회

const sender = peer.peer.getSenders()

  • 각 RTCPeerConnection에서 현재 전송 중인 모든 트랙(비디오/오디오)을 관리하는 객체(RTCRtpSender)들을 가져옴

.find((s) => s.track?.kind === newTrack.kind);

  • RTCRtpSender 객체들 중, newTrack과 동일한 kind의 트랙을 관리하는 객체를 찾음

if (sender) sender.replaceTrack(newTrack);

  • find 결과, 동일한 kind의 트랙을 관리하는 RTCRtpSender 객체가 존재하면, 해당 객체의 기존 트랙을 newTrack으로 교체

즉, 연결된 모든 피어에 대해, 새로운 MediaStream의 각 트랙마다 해당하는 RTCRtpSender가 있는지 찾아서 replaceTrack을 호출해 교체하는 것이다.

장치 변경이 잘 되는 것을 확인할 수 있다!

우리는 STUN/TURN 서버를 적용하지 않았기 때문에, 이 상태로 배포해도 다른 네트워크의 유저나 같은 공유기 환경에서 연결이 되지 않을 수 있다.

다른 네트워크끼리 WebRTC 시그널링을 위해선 TURN 서버가 필요하고, 같은 공유기 환경에서도 NAT(Network Address Translation)에 의해 STUN 서버가 필요할 수 있다.

본 포스트에서는 이를 다루지 않았기 때문에 localhost에서만 시연이 가능하다!


정리

이렇게 3편에 걸쳐 WebRTC 과제 회고를 마치게 되었다.

이미 구현이 완료된 코드를 기반으로 설명하지 않고, step-by-step 형태로 포스트를 작성하다 보니 분량이 늘어나고 예상보다 많은 시간이 소요되어 아쉽다..

그럼에도 불구하고, 처음부터 다시 구현해보면서 얻은 것이 많았다.

  • 예전부터 관심만 가지고 있었던 pnpmshadcn/ui를 이번 기회에 부담 없이 활용해볼 수 있었다. 두 기술 모두 빠르게 적응할 수 있었고, 매우 편리했다. 앞으로도 적극적으로 사용할 예정이다.

  • 과제 용도로 작성했던 코드는 복잡하고 읽기 어려웠으며, useEffect의 dependency 일부를 누락해야만 제대로 작동하는 등의 설계 미스가 많았는데, 회고를 진행하며 구조를 고민하고 다시 구현하며 더 짜임새 있고 읽기 편한 코드를 작성할 수 있었다.

  • 과제 마감 기한 때문에 이해하지 못한 채 넘어갔던 코드들을 이번 기회에 공식 문서와 GPT를 활용해 학습하며 확실히 이해하고 넘어간 점도 좋았다.

앞으로도 어려웠던 구현이나 문제 해결 과정을 step-by-step 형태로 정리하며 복습하듯 정리해 나가야겠다.

긴 글 읽어주셔서 감사합니다. 혹시라도 WebRTC에서 트랙 온오프/교체를 구현하는데 애를 먹고 계시다면 도움이 되었기를 바랍니다.

이 글에 대한 가독성, 오탈자/오개념, 코드 오타 등 다양한 지적을 환영합니다!


profile
신입 프론트엔드 개발자입니다. React와 RN 생태계를 좋아합니다.

0개의 댓글