본문 바로가기
websocket & webRTC

2. 시그널링 서버 연결 및 미디어 장치 연결

by spare8433 2025. 4. 18.

1. MediaStream 관련 API

※ 관련 내용 정리한 포스팅 : MediaDevices와 MediaStream을 통해 알아보는 웹 실시간 미디어 처리 방식





2. 구현

※ 예시로 작성한 코드로 일부 내용을 생략한 관계로 참고만 하시길 추천드립니다. ※



2.1 소켓 서버 연결 코드

// socket.ts
import { io } from "socket.io-client";
export const videoChatSocket = io(URL, { path: "/video-server-chat" }); // "/chat" 서버에 연결





2.2 클라이언트 코드

 

📔 생략한 내용 및 추가할만한 부분

  • 미디어 장치 mute 여부를 미디어 장치 변경 시 적용
  • 시그널링 서버 연결 및 이벤트 핸들러 등등 주요 로직 context api 로 처리
  • 현재 화상 채팅방 나가기 기능 및 기존 스트림 정리




// videoCharRoom.tsx
import { Mic, MicOff, Video, VideoOff } from "lucide-react"; // img 소스
import { useEffect, useRef, useState } from "react";
import { videoChatSocket } from "@/lib/socket";

type MediaType = "audio" | "video";

function VideoCharRoom() {
  const [myStream, setMyStream] = useState<MediaStream>(new MediaStream()); // 본인 스트림
  const [cameraList, setCameraList] = useState<Map<string, string>>(new Map()); // 현재 카메라 목록
  const [micList, setMicList] = useState<Map<string, string>>(new Map()); // 현재 마이크 목록
  const [isMicOn, setIsMicOn] = useState(true); // mic on/off 여부
  const [isCameraOn, setIsCameraOn] = useState(true); // camera on/off 여부
  const [mode, setMode] = useState<"lobby" | "chat">("lobby");

  const videoRef = useRef<HTMLVideoElement>(null); // 본인 stream 연결한 video tag ref

  // 미디어 장치 초기 설정
  useEffect(() => {
    (async function init() {
      try {
        // 초기 미디어 장치 연결 및 목록 가져오기
        const initialStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
        const devices = await navigator.mediaDevices.enumerateDevices();

        const audioDevices = devices.filter(({ kind }) => kind === "audioinput");
        const videoDevices = devices.filter(({ kind }) => kind === "videoinput");

        const videoDeviceMap = new Map(videoDevices.map(({ deviceId, label }) => [deviceId, label]));
        const audioDeviceMap = new Map(audioDevices.map(({ deviceId, label }) => [deviceId, label]));

        setCameraList(videoDeviceMap);
        setMicList(audioDeviceMap);
        setMyStream(initialStream);
      } catch (e) {
        console.log("미디어 장치를 불러오는데 실패했습니다. :", e);
      }
    })();

    const onConnect = () => console.log("연결됐습니다.");
    const onDisconnect = () => console.log("연결이 끊겼습니다.");
    const onJoinUser = () => setMode("chat");

    // socket 이벤트 등록
    videoChatSocket.on("connect", onConnect);
    videoChatSocket.on("disconnect", onDisconnect);
    videoChatSocket.on("user_joined", onJoinUser);

    return () => {
      // process 종료시 socket 이벤트 반환
      videoChatSocket.off("connect", onConnect);
      videoChatSocket.off("disconnect", onDisconnect);
      videoChatSocket.off("user_joined", onJoinUser);
    };
  }, []);

  // 본인 stream 연결 video 에 연결
  useEffect(() => {
    if (!videoRef.current) return;
    videoRef.current.srcObject = myStream;
  }, [myStream]);

  // 현재 stream 의 media track 반환 함수
  function getCurrentTrack(stream: MediaStream, type: "audio" | "video") {
    const tracks = type === "video" ? stream.getVideoTracks() : stream.getAudioTracks();
    return tracks.length > 0 ? tracks[0] : undefined;
  }

  // 소켓 room 입장 함수
  const enterRoom = async () => {
    videoChatSocket.emit("enter_room", () => {
      setMode("chat");
    });
  };

  // 현재 미디어 장치 mute handle 함수
  const handleDeviceMute = (type: MediaType) => {
    try {
      const track = getCurrentTrack(myStream, type);
      if (!track) throw new Error(`No current ${type} track found`);

      const mediaState = { video: isCameraOn, audio: isMicOn };
      // device mute 여부 토글
      if (type === "audio") {
        setIsMicOn((prev) => !prev);
        mediaState.audio = !isMicOn;
        track.enabled = !isMicOn;
      } else {
        setIsCameraOn((prev) => !prev);
        mediaState.video = !isCameraOn;
        track.enabled = !isCameraOn;
      }
    } catch (e) {
      console.log(`mute ${type} error`, e);
    }
  };

  // 미디어 장치 변경
  async function changeMediaDevice(type: MediaType, deviceId: string) {
    try {
      myStream.getTracks().forEach((track) => track.stop()); // 기존 트랙 정지

      // 반대쪽 트랙 백업
      const backupType: MediaType = type === "audio" ? "video" : "audio";
      const backupTrack = getCurrentTrack(myStream, backupType);

      // MediaStreamConstraints 설정
      const constraints: MediaStreamConstraints = { video: false, audio: false };
      constraints[type] = { deviceId: { exact: deviceId } };
      constraints[backupType] = backupTrack ? backupTrack.getConstraints() : false;

      // 변경된 디바이스 정보로 새로운 stream 생성
      const selectedStream = await navigator.mediaDevices.getUserMedia(constraints);

      // 변경된 camera track 저장
      const changedTrack = getCurrentTrack(selectedStream, type);
      console.log(changedTrack?.label);

      if (!changedTrack) throw new Error(`No changed ${type} track found`);
      changedTrack.enabled = type === "audio" ? isMicOn : isCameraOn;
      setMyStream(selectedStream);
    } catch (e) {
      console.log(`change ${type} error`, e);
    }
  }

  return (
    <>
      {/* 로비 화면 */}
      {mode === "lobby" && (
        <div className="w-2xl rounded-2xl space-y-3 border p-4">
          <h1 className="mb-8">화상 채팅방 로비</h1>
          <button
            type="button"
            className="w-full bg-blue-600 rounded-2xl text-white p-3 cursor-pointer"
            onClick={enterRoom}
          >
            입장
          </button>
        </div>
      )}

      {/* 내 video/audio 연결 view */}
      {mode === "chat" && (
        <div className="size-full flex flex-col">
          {/* 내 비디오 스트림 */}
          <div className="aspect-video">
            <video ref={videoRef} autoPlay playsInline className="size-full object-cover rounded-xl" />
          </div>

          {/* 미디어 장치 목록 */}
          <div className="flex">
            {/* 카메라 select */}
            <select onChange={(e) => changeMediaDevice("video", e.currentTarget.value)}>
              {Array.from(cameraList).map(([deviceId, label]) => (
                <option key={deviceId} value={deviceId}>
                  {label}
                </option>
              ))}
            </select>

            {/* 마이크 select */}
            <select onChange={(e) => changeMediaDevice("audio", e.currentTarget.value)}>
              {Array.from(micList).map(([deviceId, label]) => (
                <option key={deviceId} value={deviceId}>
                  {label}
                </option>
              ))}
            </select>

            {/* 마이크 mute 버튼 */}
            <button className="rounded-full size-9 bg-muted" onClick={() => handleDeviceMute("audio")}>
              {isMicOn ? <Mic className="size-6" /> : <MicOff className="size-6" />}
            </button>

            {/* 카메라 mute 버튼 */}
            <button className="rounded-full size-9 bg-muted" onClick={() => handleDeviceMute("video")}>
              {isCameraOn ? <Video className="size-6" /> : <VideoOff className="size-6" />}
            </button>
          </div>
        </div>
      )}
    </>
  );
}

export default VideoCharRoom;