WebRTC를 활용한 화상채팅 개발 회고 (시그널링 구현)

NARARIA03·2025년 1월 6일
0

WebRTC

목록 보기
2/3
post-thumbnail

개요

지난 포스트에서 WebRTC와 Signaling의 개념에 대해서 다뤄보았다.

이번에는 ReactExpress를 활용해 최대 3인 화상 채팅, 비디오/오디오 장치 변경 기능을 구현하며 실습해보려고 한다.

추가로 예전부터 사용해보고 싶었던 Headless UI인 shadcn/ui와, 패키지 매니저 pnpm을 활용해볼 예정이지만, 이 내용은 글에서 다루지 않을 예정이다. (개인적인 호기심 충족 목적이므로)

간단하게만 두 기술에 대해 정리하면 아래와 같다.

  • shadcn/ui: UI 라이브러리(MUI, Bootstrap 등)는 편리하지만, "커스터마이징"이 어렵다는 단점이 있다. 반면 Headless UI 라이브러리는 스타일이 적용되어 있지 않아 커스터마이징에 용이하다.

    • UI 라이브러리로 드롭다운 컴포넌트를 사용할 경우 스타일을 변경하기 어렵고, 처음부터 직접 구현하기에는 번거롭다. 이럴 때 shadcn/ui를 활용하면 드롭다운 컴포넌트를 가져와 스타일만 입혀 간편하게 사용할 수 있다.
  • pnpm: Performant NPM의 약자로, 패키지를 Content-addressable 스토어에 설치한 후 각 프로젝트에서 심볼릭 링크를 활용해 관리한다. 이로 인해 pnpm은 속도가 빠르고 디스크 용량을 절약할 수 있으며, 플랫하지 않은 node_modules 구조로 유령 의존성 문제를 방지한다.

    • 유령 의존성이란, 패키지 A가 의존하는 패키지 B가 Hoisting 되어 프로젝트에서 B에 직접 접근할 수 있는 상황을 말한다. 만약 프로젝트가 B를 참조하고 있는데 패키지 A가 B의 의존성을 제거하면, 예기치 못한 에러가 발생할 수 있다. 이는 패키지 B가 package.json의 종속성 목록에 명시되지 않아 관리에서 누락되기 때문이다.

그럼 먼저 pnpm을 활용해 React, Express 개발 환경을 구축해보자.


개발 환경 구성

BE

backend 폴더를 생성하고 이동한 뒤 pnpm을 활용해 초기화를 수행했다.

mkdir backend
cd backend
pnpm init

다음으로 필요한 라이브러리를 설치해줬다.

pnpm install express socket.io
pnpm install -D @types/express @types/node ts-node-dev typescript

이후 package.json의 scripts에 "dev": "ts-node-dev --respawn --transpile-only app.ts"를 추가해서 코드 변경 시 자동으로 재시작하도록 하고, TS 설정을 위해 pnpm dlx tsc --init을 해줬다.

pnpm dlx === npx

마지막으로 app.ts를 생성하고 보일러 플레이트를 작성해 환경 구성이 잘 되었는지 확인했다.

// backend/app.ts

import express, { Express, Request, Response } from "express";

const app: Express = express();
const port = 8080;

app.get("/", (req: Request, res: Response) => {
  res.send("hello");
});

app.listen(port, () => {
  console.log(`Server on ${port}`);
});

FE

frontend 폴더를 생성하고 이동한 뒤 Vite를 활용해 React, TS, SWC 템플릿으로 구성했다.

cd ..
mkdir frontend
cd frontend
pnpm create vite@latest .
pnpm install

이후 필요한 라이브러리를 추가로 설치해줬다.

pnpm install react-router socket.io-client
pnpm install -D tailwindcss postcss autoprefixer

shadcn/ui 공식문서를 참고하며 TailwindCSS와 shadcn/ui 초기 설정도 해줬다.


정리

최종적인 프로젝트 구조다. WebRTC 쪽만 구현하는 것이 목표이므로 폴더 구조는 더 깊게 가져가지 않을 예정이다.

📦backend
┣ 📜app.ts
┣ 📜package.json
┣ 📜pnpm-lock.yaml
┗ 📜tsconfig.json

📦frontend
 ┣ 📂src
 ┃ ┣ 📂lib
 ┃ ┃ ┗ 📜utils.ts
 ┃ ┣ 📜App.tsx
 ┃ ┣ 📜index.css
 ┃ ┣ 📜main.tsx
 ┃ ┗ 📜vite-env.d.ts
 ┣ 📜.gitignore
 ┣ 📜components.json
 ┣ 📜eslint.config.js
 ┣ 📜index.html
 ┣ 📜package.json
 ┣ 📜pnpm-lock.yaml
 ┣ 📜postcss.config.js
 ┣ 📜tailwind.config.js
 ┣ 📜tsconfig.app.json
 ┣ 📜tsconfig.json
 ┣ 📜tsconfig.node.json
 ┗ 📜vite.config.ts

구현

간단한 라우팅 구성하기

FE에서 React-Router를 활용해 두 개의 페이지를 구축할 것이다.

  • /: 닉네임을 입력받는 페이지로 input에 입력하고 submit하면 /room으로 이동

  • /room: 닉네임 없이 접근하면 /로 리다이렉트, 닉네임이 있으면 WebSocket을 활용해 RTC 연결 수행 후 화상 채팅 & 장치 변경 기능 제공

App.tsx에 라우터를 구성하고, 비어있는 페이지 LandingPage.tsx, RoomPage.tsx를 가져와 연결해주자.

// frontend/src/App.tsx

import { BrowserRouter, Routes, Route, Navigate } from "react-router";
import LandingPage from "./LandingPage";
import RoomPage from "./RoomPage";

function App(): JSX.Element {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<LandingPage />} />
        <Route path="/room" element={<RoomPage />} />
        <Route path="*" element={<Navigate replace to="/" />} />
      </Routes>
    </BrowserRouter>
  );
}

export default App;

랜딩페이지 구현

shadcn/ui에서 Input과 Button, Toast를 가져와 구현할 것이다.

pnpm dlx shadcn@latest add input button toast

shadcn/ui 공식문서 코드를 참고해 간단하게 닉네임을 입력받고 입장 버튼을 누르면 닉네임이 존재하는 경우에만 /room으로 이동하며 state를 전달하고, 닉네임이 없다면 토스트 메시지를 보여주도록 구현해보자.

// frontend/src/LandingPage.tsx

import { useState, ChangeEvent } from "react";
import { useNavigate } from "react-router";
import { Button } from "./components/ui/button";
import { Input } from "./components/ui/input";
import { useToast } from "./hooks/use-toast";
import { Toaster } from "./components/ui/toaster";

function LandingPage(): JSX.Element {
  const [nick, setNick] = useState<string>("");
  const navigate = useNavigate();
  const { toast } = useToast();

  const handleNickChange = (e: ChangeEvent<HTMLInputElement>) => {
    setNick(e.target.value);
  };

  const handleClickBtn = () => {
    if (!nick) {
      toast({
        variant: "destructive",
        title: "닉네임이 입력되지 않았어요!",
        description: "닉네임을 확인 후 다시 시도해주세요",
      });
      return;
    }
    navigate("/room", { state: { nick } });
  };

  return (
    <>
      <main className="w-screen h-screen flex justify-center items-center">
        <div className="flex flex-wrap gap-3 w-full max-w-sm items-center">
          <Input
            type="text"
            placeholder="닉네임을 입력해주세요"
            value={nick}
            onChange={handleNickChange}
          />
          <Button className="w-full" onClick={handleClickBtn}>
            입장하기
          </Button>
        </div>
      </main>
      <Toaster />
    </>
  );
}

export default LandingPage;

나쁘지 않다!


소켓 연결 구현

랜딩페이지에서 전달한 닉네임 가져오기

useLocation 훅을 사용해 가져오고, 닉네임이 undefined면 /페이지로 리다이렉트 시켜줬다.

// frontend/src/RoomPage.tsx

import { useLocation } from "react-router";

function RoomPage(): JSX.Element {
  const location = useLocation();
  const navigate = useNavigate();
  const nick = location.state?.nick as string | undefined;
  
  useEffect(() => {
    if (!nick) navigate("/");
  }, [navigate, nick]);

  console.log(nick);

  return <></>;
}

export default RoomPage;

브라우저 콘솔에 닉네임이 잘 나오는 것을 확인할 수 있다.


BE 소켓 구성

먼저 BE에서 소켓 서버를 열어보자.

HTTP 통신은 수행하지 않을 예정이므로 / 엔드포인트는 제거했다.

// backend/app.ts

import express, { Express, Request, Response } from "express";
import { createServer } from "http";
import { Server } from "socket.io";

const app: Express = express();
const port = 8080;

// 추가 ------------------------------------------
const server = createServer(app);
const io = new Server(server, { 
  cors: {
    origin: "http://localhost:5173",
    methods: ["GET", "POST"],
  },
});
app.set("io", io);
// ------------------------------------------ 추가

// app.listen -> server.listen로 변경
server.listen(port, () => {
  console.log(`Server on ${port}`);
});

모듈화를 위해 socketHandler.ts를 생성해 io 객체를 받아 소켓 이벤트를 처리하도록 하고, app.ts에서 호출하자.

// backend/socketHandler.ts

import { Server, Socket } from "socket.io";

export const socketHandler = (io: Server) => {
  io.on("connection", (socket: Socket) => {
    // 클라이언트와 소켓 연결 시 nick을 받아와 socket객체의 data에 저장해둠
    socket.data = { nick: socket.handshake.query.nick };
    console.log(socket.data.nick); // 닉네임 받아지는지 테스트 용도!
  });
};
// backend/app.ts

import express, { Express, Request, Response } from "express";
import { createServer } from "http";
import { Server } from "socket.io";
import { socketHandler } from "./socketHandler";

const app: Express = express();
const port = 8080;

const server = createServer(app);
const io = new Server(server, {
  cors: {
    origin: "http://localhost:5173",
    methods: ["GET", "POST"],
  },
});
app.set("io", io);
socketHandler(io); // 추가

server.listen(port, () => {
  console.log(`Server on ${port}`);
});

FE 소켓 구성

커스텀 훅을 통해 로직을 컴포넌트에서 분리해 작성해보자.

소켓 객체는 리액트 라이프사이클의 탈출구에서 관리되어야 하므로 useRef에 저장해야 한다.

처음 구현할 때 앞으로 나올 다양한 객체들을 State로 관리해야 할지 Ref로 관리해야 할지 고민을 많이 했었다.

GPT의 할루시네이션에 당해 리렌더링 시 Ref가 초기화된다는 착각을 했었는데, Ref는 컴포넌트 마운트부터 언마운트까지 유지되는 변수로 활용할 수 있으며 리렌더링 트리거 역할을 수행하지 않는다는 특징을 꼭 기억해두자.

// frontend/src/hooks/useSocket.ts

import { useEffect, useRef } from "react";
import { Socket, io } from "socket.io-client";

export const useSocket = (nick: string | undefined) => {
  const socketRef = useRef<Socket | null>(null);

  useEffect(() => {
    if (nick) {
      socketRef.current = io("http://localhost:8080", {
        query: {
          nick,
        },
      });

      socketRef.current.emit("hello", "world");
    }

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

useEffect로 인해 닉네임이 있을 때만 소켓 연결을 시도하고, 닉네임이 변경되지 않는 한 리렌더링이 발생해도 소켓 연결은 유지되며, 닉네임이 변경되면 기존 소켓 연결을 닫고, 새로운 소켓 연결을 연다.

닉네임을 매개변수로 받아와 handshake 과정에서 BE에게 전송하도록 해줬고, 테스트를 위해 소켓 연결에 성공하면 hello 이벤트를 emit 하도록 구현했다.

이제 useSocket 훅을 RoomPage에서 호출해준 뒤, BE에서 hello 이벤트를 잘 수신하는지 확인해보자.

// frontend/src/RoomPage.tsx

import { useLocation, useNavigate } from "react-router";
import { useSocket } from "./hooks/useSocket";
import { useEffect } from "react";

function RoomPage(): JSX.Element {
  const location = useLocation();
  const navigate = useNavigate();
  const nick = location.state?.nick as string | undefined;

  useEffect(() => {
    if (!nick) navigate("/");
  }, [navigate, nick]);

  useSocket(nick); // 추가
  
  return <></>;
}

export default RoomPage;
// backend/socketHandler.ts

import { Server, Socket } from "socket.io";

export const socketHandler = (io: Server) => {
  io.on("connection", (socket: Socket) => {
    socket.data = { nick: socket.handshake.query.nick };
    console.log(socket.data.nick); // 닉네임 받아지는지 테스트 용도!

    // 테스트용 추가
    socket.on("hello", (v: string) => {
      console.log(v);
    });
  });
};

닉네임을 입력하고 입장하기 버튼을 누르면, /room으로 이동되며 BE와 FE가 정상적으로 소켓 연결을 맺고, BE는 FE의 닉네임을 알 수 있게 되었다!

이제 테스트용 코드들을 제거하고, 본격적으로 WebRTC 연결 부분을 구현해보자.


기존 유저들의 socketId를 새로 접속한 유저에게 보내도록 구현

WebRTC 연결을 위해 유저가 입장하면, 입장한 유저가 기존 유저들에게 Offer를 전달해야 한다.

새로 입장한 유저가 기존 유저들에게 Offer를 전달할 수 있도록 입장한 유저에게 기존 유저들의 정보(socketId, nick)를 전달하는 recv-users 이벤트를 BE에 구현해야 한다.

// backend/socketHandler.ts

import { Server, Socket } from"socket.io";
import { User } from "./backTypes";

// 소켓에 연결된 유저들의 socketId, nick을 관리할 배열
let users: User[] = [];

export const socketHandler = (io: Server) => {
  io.on("connection", (socket: Socket) => {
    socket.data = { nick: socket.handshake.query.nick };
    users.push({ socketId: socket.id, nick: socket.data.nick }); // 새로 들어온 유저 정보 저장

    socket.emit(
      "recv-users",
      users.filter((user) => user.socketId !== socket.id)
    ); // 새로 들어온 유저에게 기존 유저 정보 전달

    socket.on("disconnect", () => {
      users = users.filter((user) => user.socketId !== socket.id); // 소켓 연결이 끊기면, 삭제
    });
  });
};

먼저 users 배열을 활용해 현재 소켓에 연결된 유저의 socketIdnick을 관리하도록 해줬다.
입장(connection) 시 배열에 추가하고, 퇴장(disconnect) 시 배열에서 찾아 삭제해줬다.

socket.id 를 통해 해당 유저의 고유한 socketId를 확인 및 관리할 수 있다.

성공적으로 소켓 연결을 마친 유저에게 users 배열에서 본인을 제외한 데이터를 recv-users 이벤트로 emit하여 전달하도록 구현했다. 이제 FE에서 recv-users 이벤트를 수신하면 된다.

// frontend/src/hooks/useSocket.ts

import { useEffect, useRef } from "react";
import { Socket, io } from "socket.io-client";
import { RecvUser } from "../frontTypes";

export const useSocket = (nick: string | undefined) => {
  const socketRef = useRef<Socket | null>(null);

  useEffect(() => {
    const handleRTC = async (socket: Socket, nick: string) => {
      // 기존 유저들의 정보 수신
      socket.on("recv-users", (users: RecvUser[]) => {
        console.log(users);
      });
    };

    if (nick) {
      socketRef.current = io("http://localhost:8080", {
        query: {
          nick,
        },
      });

      // 연결을 성공한 이후, 비동기 함수로 토스
      handleRTC(socketRef.current, nick);
    }

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

useEffect에서 소켓 연결을 열고, Socket 객체를 handleRTC 함수로 전달하도록 구성했다.

굳이 함수로 뺀 이유는 앞으로 비동기 로직이 필요해 순서 보장을 위해 async-await이 필요하기 때문이다.

우선 당장은 recv-users 이벤트를 받아 콘솔에 출력하도록 구성했다.

여러 브라우저를 활용해 두 명의 유저를 입장시켜둔 뒤, 새로운 유저를 입장시키자 기존 유저들의 정보가 정상적으로 콘솔에 찍혔다!


비디오/오디오 스트림 가져오기

FE에서 사용자의 비디오/오디오 스트림을 가져오기 위해 navigator.mediaDevices.getUserMedia() 함수를 활용하자.

스트림은 값이 계속 변경되므로, State로 관리 시 리렌더링으로 인해 video 요소가 깜빡거리게 된다. 따라서 스트림은 Ref로 관리해야 한다.

스트림을 가져와 Ref로 관리하고, 이를 video에 연결하도록 useSocket 훅을 수정해보자.

// frontend/src/hooks/useSocket.ts

import { RefObject, useEffect, useRef } from "react";
import { Socket, io } from "socket.io-client";
import { RecvUser } from "../frontTypes";

export const useSocket = (
  nick: string | undefined,
  myVideoRef: RefObject<HTMLVideoElement>
) => {
  const socketRef = useRef<Socket | null>(null);
  const myStreamRef = useRef<MediaStream | null>(null);

  useEffect(() => {
    const handleRTC = async (socket: Socket, nick: string) => {
      const stream = await navigator.mediaDevices.getUserMedia({
        video: true,
        audio: true,
      }); // 추후 이 부분과 useEffect의 dependancy를 수정하면 장치 변경 구현이 가능하다!

      myStreamRef.current = stream;
      if (myVideoRef.current) myVideoRef.current.srcObject = stream; // video에 stream 연결

      socket.on("recv-users", (users: RecvUser[]) => {
        console.log(users);
      });
    };

    if (nick) {
      socketRef.current = io("http://localhost:8080", {
        query: {
          nick,
        },
      });

      handleRTC(socketRef.current, nick);
    }

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

getUserMedia 함수를 통해 스트림을 가져온 뒤 myStreamRef에 저장하고, myVideoRef에 연결하도록 구현했다.

  • myStreamRef: 스트림을 관리하기 위한 Ref로, useSocket 내부에서 선언된다.

  • myVideoRef: 스트림을 video 요소에 출력하기 위해 사용되는 Ref로, 매개변수로 전달받는다.

useEffect의 의존성 배열에 myVideoRef를 추가하지 않으면, myVideoRef와 video 요소가 연결되기 전에 useEffect가 실행되어 스트림 연결에 실패할 수 있으므로 의존성 배열에 myVideoRef를 추가하는 것을 빼먹지 말자.

의존성 배열에 myVideoRef를 추가하면, myVideoRef와 video 요소가 연결된 이후 useEffect가 다시 실행되어 스트림 연결을 보장할 수 있다.

이제 RoomPage를 수정해 스트림을 video 요소에 연결해보자.

// frontend/src/RoomPage.tsx

import { useLocation, useNavigate } from "react-router";
import { useSocket } from "./hooks/useSocket";
import { useEffect, useRef } from "react";

function RoomPage(): JSX.Element {
  const location = useLocation();
  const navigate = useNavigate();
  const nick = location.state?.nick as string | undefined;

  const myVideoRef = useRef<HTMLVideoElement>(null); // 추가

  useEffect(() => {
    if (!nick) navigate("/");
  }, [navigate, nick]);

  useSocket(nick, myVideoRef); // 추가

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

export default RoomPage;

먼저 myVideoRef를 선언한 후, useSocket의 매개변수로 넘겨줬다.

이후 video 요소에 myVideoRef를 연결하고, autoPlay와 playsInline 속성을 추가했다.

  • autoPlay: 자동 실행

  • playsInline: 모바일 기기에서 비디오가 자동으로 전체 화면으로 실행되지 않도록 설정

접속 시 장치 권한을 요청하고, 정상적으로 비디오/오디오 스트림을 받아와 video 요소에 출력된다!


WebRTC 시그널링 코드 구현

Offer를 생성하는 Peer 코드 구현

Offer를 생성하는 입장에서의 signaling 코드를 작성하기 전 흐름을 정리하고 가자. 만약 잘 이해가 되지 않는다면, 이전 시리즈를 참고하면 도움이 될 것이다.

  1. RTCPeerConnection 객체를 생성하고, 스트림을 addTrack 함수로 연결

  2. Offer를 생성해 객체의 localDescription에 저장하고, 서버로 Offer를 전송

  3. 객체에 ontrack, onicecandidate 이벤트 리스너를 연결

  4. Answer를 전달받으면, 객체의 remoteDescription에 저장

  5. onicecandidate 이벤트가 발생하면, server에 ICE candidate를 전송

  6. ICE candidate를 전달받으면, 객체의 addIceCandidate 함수로 저장

  7. ontrack 이벤트가 발생하면, stream을 받아와 State로 관리

왜 다른 사용자의 스트림은 State로 관리할까?

다른 사용자의 스트림은, ontrack 이벤트, 즉 추가/제거 시에만 업데이트 되기 때문에 깜빡임 현상이 발생하지 않는다.
그리고 State로 관리해야만 입장/퇴장 시 쉽게 리렌더링 할 수 있다.

위 과정을 모든 기존 유저들에 대해 수행해주면 된다. 즉, 반복문 내에서 로직을 실행하면 되겠다.

먼저 RTCPeerConnection 객체들을 저장해둘 peersRef의 타입인 Peer와, 상대방 스트림을 저장해둘 remoteStreams의 타입인 RemoteStream을 선언하자.

목적은 RTCPeerConnectionMediaStream을 socketId와 nick을 활용해 구분할 수 있게 만드는 것이다.

// frontend/src/frontTypes.ts

export type Peer = {
  socketId: string; // 피어를 구분하는 소켓id
  nick: string; // 피어 닉네임 (출력용)
  peer: RTCPeerConnection; // 피어의 객체
};

export type RemoteStream = {
  socketId: string; // 피어를 구분하는 소켓id
  nick: string; // 피어 닉네임 (출력용)
  stream: MediaStream; // 피어의 스트림
};

이제 useSocket에 시그널링 코드를 작성하자.

코드 양이 늘어났는데, 주석 달린 부분이 추가된 코드이므로 주석 근처 위주로 읽으면 쉽게 이해할 수 있다.

// frontend/src/hooks/useSocket.ts

import { RefObject, useEffect, useRef, useState } from "react";
import { Socket, io } from "socket.io-client";
import { Peer, RecvUser, RemoteStream } from "../frontTypes";

export const useSocket = (
  nick: string | undefined,
  myVideoRef: RefObject<HTMLVideoElement>
) => {
  const socketRef = useRef<Socket | null>(null);
  const myStreamRef = useRef<MediaStream | null>(null);
  const peersRef = useRef<Peer[]>([]); // RTCPeerConnection 객체들을 저장해둘 Ref
  const [remoteStreams, setRemoteStreams] = useState<RemoteStream[]>([]); // Peer들의 스트림을 저장해둘 state

  useEffect(() => {
    const handleRTC = async (socket: Socket, nick: string) => {
      const stream = await navigator.mediaDevices.getUserMedia({
        video: true,
        audio: true,
      });

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

      socket.on("recv-users", (users: RecvUser[]) => {
        users.forEach(async ({ socketId, nick }) => {
          const peer = new RTCPeerConnection(); // peer 객체 생성

          // peer 객체에 자신의 스트림 연결
          stream.getTracks().forEach((track) => peer.addTrack(track, stream));

          const offer = await peer.createOffer(); // Offer 생성
          peer.setLocalDescription(offer); // localDescription에 Offer 저장

          // offer 받을 대상(socketId), offer 보낸 대상(socket.id), offer 3개 전송
          socket.emit("send-offer", socketId, socket.id, offer);

          // ICE candidate를 발견하면 실행되는 이벤트 리스너 연결
          peer.onicecandidate = (e) => {
            if (e.candidate) {
              // candidate를 받을 대상(socketId), 보낸 대상(socket.id), candidate 3개 전송
              socket.emit(
                "send-ice-candidate",
                socketId,
                socket.id,
                e.candidate
              );
            }
          };

          // 연결이 마무리되면 원격 스트림을 받아오는 이벤트 리스너 연결
          peer.ontrack = (e) => {
            const streamObj: RemoteStream = {
              socketId,
              nick,
              stream: e.streams[0],
            };
            // 기존에 동일한 socketId가 있는지 확인하고, 없을 때만 remoteStreams에 추가
            setRemoteStreams((prev) =>
              prev.some((remoteStream) => remoteStream.socketId === socketId)
                ? prev
                : [...prev, streamObj]
            );
          };

          // peersRef에 해당 RTCPeerConnection 객체 저장
          peersRef.current.push({ socketId, nick, peer });
        });
      });

      // Offer를 생성한 경우 answer를 수신해 remoteDescription에 저장
      socket.on(
        "recv-answer",
        (hostId: string, answer: { type: RTCSdpType; sdp: string }) => {
          // answer를 보낸 peer를 peersRef에서 찾아서 remoteDescription에 answer 추가
          const peer = peersRef.current.find((p) => p.socketId === hostId);
          if (peer) peer.peer.setRemoteDescription(answer);
        }
      );

      // Offer를 생성했던, Offer를 받았건 ICE candidate를 수신해서 addIceCandidate로 저장
      socket.on(
        "recv-ice-candidate",
        (hostId: string, candidate: RTCIceCandidate) => {
          // socketId가 hostId인 객체를 찾아 RTCPeerConnection 객체에 addIceCandidate 수행
          const peer = peersRef.current.find((p) => p.socketId === hostId);
          if (peer) peer.peer.addIceCandidate(candidate);
        }
      );
    };

    if (nick) {
      socketRef.current = io("http://localhost:8080", {
        query: {
          nick,
        },
      });

      handleRTC(socketRef.current, nick);
    }

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

users를 순회하며 객체를 생성하고, 자신의 스트림을 연결하고, Offer를 생성하고, localDescription에 저장한 뒤, Offer를 시그널링 서버로 전달한다.

이후 객체에 onicecandidateontrack 이벤트 핸들러를 연결해 ICE candidate를 발견하면 시그널링 서버로 전달하고, 상대방의 스트림을 받으면 remoteStreams를 업데이트 하도록 구현했다.

recv-answer, recv-ice-candidate 이벤트 리스너를 recv-users 밖에서 등록한 이유:
같은 이벤트명을 가지는 리스너는 하나만 등록되야 하기 때문이다.

반복문 내에서 이벤트 리스너를 등록하면 동일한 이벤트가 여러 번 처리되는 문제가 발생할 수 있다.
또, 기존 리스너 내부에서 새로운 리스너를 추가하면 중복 등록으로 인해 문제가 발생할 수 있다.

이벤트 리스너는 하나로 통일하되, 어떤 Peer로부터 온 Offer/Answer인지 조건문으로 구분한다!

send-offer와 send-ice-candidate를 보면 목적지 socketId, 발신지 socketId, 총 3개를 시그널링 서버로 전달하는 것을 알 수 있다.

이는 수신자가 발신자에게 답장을 보내기 위해 필요하며, Mesh 구조에서 정확한 대상을 식별해 해당 객체에 반영할 수 있도록 하기 위함이다.


시그널링 서버 코드 구현

send-offer, send-answer, send-ice-candidate 이벤트를 받아, 적절한 목적지로 recv-offer, recv-answer, recv-ice-candidate 이벤트를 전달하도록 시그널링 서버를 구현해야 한다. 바로 코드로 봐보자!

// backend/socketHandler.ts

import { Server, Socket } from "socket.io";
import { User } from "./backTypes";

let users: User[] = [];

export const socketHandler = (io: Server) => {
  io.on("connection", (socket: Socket) => {
    socket.data = { nick: socket.handshake.query.nick };
    users.push({ socketId: socket.id, nick: socket.data.nick });

    socket.emit(
      "recv-users",
      users.filter((user) => user.socketId !== socket.id)
    );

    // destId에게만 recv-offer를 전송
    socket.on(
      "send-offer",
      (
        destId: string,
        hostId: string,
        offer: { type: RTCSdpType; sdp: string }
      ) => {
        io.to(destId).emit("recv-offer", hostId, offer);
      }
    );

    // destId에게만 recv-answer를 전송
    socket.on(
      "send-answer",
      (
        destId: string,
        hostId: string,
        answer: { type: RTCSdpType; sdp: string }
      ) => {
        io.to(destId).emit("recv-answer", hostId, answer);
      }
    );

    // destId에게만 recv-ice-candidate를 전송
    socket.on(
      "send-ice-candidate",
      (destId: string, hostId: string, candidate: RTCIceCandidate) => {
        io.to(destId).emit("recv-ice-candidate", hostId, candidate);
      }
    );

    socket.on("disconnect", () => {
      users = users.filter((user) => user.socketId !== socket.id);
    });
  });
};

io.to(destId).emit() 을 활용해 특정 socketId를 가진 클라이언트에게만 이벤트를 전송하도록 구현했다.

시그널링 서버는 목적지로 값을 전달만 하면 된다. 값 처리는 FE에서 진행하기 때문이다.


Offer를 받는 Peer 코드 구현

현재까지 구현한 FE 코드는 useSocket에서 socket.on("recv-users")의 콜백 함수 내부에 해당한다.

따라서 Offer를 받는 입장의 코드 역시 구현해야 한다.

  1. Offer를 전달받으면, RTCPeerConnection 객체를 생성하고, 스트림을 addTrack 함수로 연결

  2. Offer를 객체의 remoteDescription에 저장하고 Answer 생성

  3. Answer를 객체의 localDescription에 저장하고, 서버로 Answer 전송

  4. 객체에 ontrack, onicecandidate 이벤트 리스너를 연결

  5. onicecandidate 이벤트가 발생하면, server에 ICE candidate를 전송

  6. ICE candidate를 전달받으면, 객체의 addIceCandidate 함수로 저장

  7. ontrack 이벤트가 발생하면, stream을 받아와 State로 관리

useSocket 훅의 recv-answer 이벤트 리스너 아래에 recv-offer 이벤트 리스너를 추가하자.

// frontend/src/hooks/useSocket.ts

import { RefObject, useEffect, useRef, useState } from "react";
import { Socket, io } from "socket.io-client";
import { Peer, RecvUser, RemoteStream } from "../frontTypes";

export const useSocket = (
  nick: string | undefined,
  myVideoRef: RefObject<HTMLVideoElement>
) => {
  const socketRef = useRef<Socket | null>(null);
  const myStreamRef = useRef<MediaStream | null>(null);
  const peersRef = useRef<Peer[]>([]); 
  const [remoteStreams, setRemoteStreams] = useState<RemoteStream[]>([]); 

  useEffect(() => {
    const handleRTC = async (socket: Socket, nick: string) => {
      const stream = await navigator.mediaDevices.getUserMedia({
        video: true,
        audio: true,
      });

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

      socket.on("recv-users", (users: RecvUser[]) => {
        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);
        }
      );

      // ------------------- 추가 ----------------------------------
      // Offer를 받는 Peer의 입장에서 작성하는 코드
      socket.on(
        "recv-offer",
        async (socketId: string, offer: { type: RTCSdpType; sdp: string }) => {
          const peer = new RTCPeerConnection(); // peer 객체 생성

          // peer 객체에 자신의 스트림 연결
          stream.getTracks().forEach((track) => peer.addTrack(track, stream));

          // peer 객체에 받은 offer를 remoteDescription에 저장
          peer.setRemoteDescription(offer);

          const answer = await peer.createAnswer(); //  answer 생성
          peer.setLocalDescription(answer); // localDescription에 저장
          socket.emit("send-answer", socketId, socket.id, answer); // 서버로 answer 전송

          // ICE candidate를 발견하면 실행되는 이벤트 리스너 연결
          peer.onicecandidate = (e) => {
            if (e.candidate) {
              // candidate를 받을 대상(socketId), 보낸 대상(socket.id), candidate 3개 전송
              socket.emit(
                "send-ice-candidate",
                socketId,
                socket.id,
                e.candidate
              );
            }
          };

          // 연결이 마무리되면 원격 스트림을 받아오는 이벤트 리스너 연결
          peer.ontrack = (e) => {
            const streamObj: RemoteStream = {
              socketId,
              nick,
              stream: e.streams[0],
            };
            // 기존에 동일한 socketId가 있는지 확인하고, 없을 때만 remoteStreams에 추가
            setRemoteStreams((prev) =>
              prev.some((remoteStream) => remoteStream.socketId === socketId)
                ? prev
                : [...prev, streamObj]
            );
          };

          // peersRef에 해당 RTCPeerConnection 객체 저장
          peersRef.current.push({ socketId, nick, peer });
        }
      );

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

    if (nick) {
      socketRef.current = io("http://localhost:8080", {
        query: {
          nick,
        },
      });

      handleRTC(socketRef.current, nick);
    }

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

주석 달린 부분만 다시 읽으면 된다. 흐름이 복잡하다 보니 일부 코드만 작성하면 이해에 더 어려움이 있을 것 같아 전체 코드를 계속 첨부하고 있는 점 양해 부탁드립니다..

이제 한 번 연결이 잘 되나 테스트해보면... 잘 안 된다...

뭐 사실 이번 포스팅 목적도 다시 한 번 트러블 슈팅을 하며 제대로 이해하는게 목표였기 때문에 상관 없다.


트러블 슈팅: 시그널링이 안 되는 문제

원인을 파악하기 위해 BE 모든 이벤트마다 콘솔을 추가했다. 그 결과 소켓에 연결된 후 FE에서 recv-users 이벤트를 수신하지 못 하고 있다는 것을 알 수 있었다.

connection 이후 흐름이 멈췄고, recv-users 부분에 콘솔을 찍어보았으나, 실행 조차 되지 않았다.

즉 "실행 타이밍" 의 문제로 좁힐 수 있었다. Claude의 도움을 살짝 받아 설명해보면..

"getUserMedia()가 완료되기 전에 서버가 recv-users 이벤트를 보내버리는데,
이 시점에서는 아직 이벤트 리스너가 등록되지 않은 상태"

아래와 같이 테스트하자 recv-users 이벤트를 정상 수신했고, 문제를 확신할 수 있었다.

// frontend/src/hooks/useSocket.ts

..
export const useSocket = (
  nick: string | undefined,
  myVideoRef: RefObject<HTMLVideoElement>
) => {
  ..(생략)
  
  useEffect(() => {
    ..(생략)
    
    if (nick) {
      socketRef.current = io("http://localhost:8080", {
        query: {
          nick,
        },
      });

      handleRTC(socketRef.current, nick);

      // 소켓 연결 상태 확인을 위한 이벤트 리스너 추가
      socketRef.current.on("connect", () => {
        console.log("Socket connected:", socketRef.current?.id);
      });

      socketRef.current.on("connect_error", (error) => {
        console.error("Socket connection error:", error);
      });

      // recv-users 이벤트 리스너를 handleRTC 밖에서도 추가
      socketRef.current.on("recv-users", (users) => {
        console.log("Received users outside handleRTC:", users);
      });
    }

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

  return remoteStreams;
};

현재 useSocket 훅의 handleRTC 함수에서는 Offer를 만드는 쪽과 Offer를 받는 쪽 모두 getUserMedia()가 선행되고 있다. 이 코드를 이벤트 리스너 콜백 함수로 옮겨주면 된다.

// frontend/src/hooks/useSocket.ts

import { RefObject, useEffect, useRef, useState } from "react";
import { Socket, io } from "socket.io-client";
import { Peer, RecvUser, RemoteStream } from "../frontTypes";

export const useSocket = (
  nick: string | undefined,
  myVideoRef: RefObject<HTMLVideoElement>
) => {
  const socketRef = useRef<Socket | null>(null);
  const myStreamRef = useRef<MediaStream | null>(null);
  const peersRef = useRef<Peer[]>([]);
  const [remoteStreams, setRemoteStreams] = useState<RemoteStream[]>([]);

  useEffect(() => {
    const handleRTC = (socket: Socket, nick: string) => {
      socket.on("recv-users", async (users: RecvUser[]) => {
        // getUserMedia 함수를 recv-users 이벤트 리스너 내에서 호출하고 사용..!!
        const stream = await navigator.mediaDevices.getUserMedia({
          video: true,
          audio: true,
        });

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

          myStreamRef.current = stream;
          if (myVideoRef.current) myVideoRef.current.srcObject = stream;
          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);
        }
      );

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

          // getUserMedia 함수를 recv-offer 이벤트 리스너 내에서 호출하고 사용..!!
          const stream = await navigator.mediaDevices.getUserMedia({
            video: true,
            audio: true,
          });
          myStreamRef.current = stream;
          if (myVideoRef.current) myVideoRef.current.srcObject = stream;
          stream.getTracks().forEach((track) => peer.addTrack(track, stream));

          peer.setRemoteDescription(offer);

          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
              );
            }
          };

          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-ice-candidate",
        (hostId: string, candidate: RTCIceCandidate) => {
          const peer = peersRef.current.find((p) => p.socketId === hostId);
          if (peer) {
            peer.peer.addIceCandidate(candidate);
          }
        }
      );
    };

    if (nick) {
      socketRef.current = io("http://localhost:8080", {
        query: {
          nick,
        },
      });

      handleRTC(socketRef.current, nick);
    }

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

  return remoteStreams;
};

getUserMedia() 함수 호출을 이벤트 리스너 밖에서 안으로 이동시켜줬다. 물론 그 결과 코드 중복이 생기긴 했으나, 당연히 실행 순서 보장이 더 중요하다.

포스팅 과정에서 이전 코드를 참고하면서 진행했는데, 왜 이중으로 getUserMedia()를 호출하는지 궁금했었다. 당시 과제를 진행할 때는 이해하지 못 하고 그냥 작성했는데, 이제 확실히 이해할 수 있게 되었다.
WebRTC 시그널링 과정은 "순서"가 핵심인 것 같다.

이제 remoteStreams를 가져와서 렌더링 해주면 될 것이다.
ref를 사용해 srcObject에 연결해야만 하므로 Video 컴포넌트를 만들어서 사용해보자.

// frontend/src/Video.tsx
import { useEffect, useRef } from "react";
import { RemoteStream } from "./frontTypes";

interface Props {
  remoteStream: RemoteStream;
}

function Video({ remoteStream }: Props): JSX.Element {
  const ref = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    if (ref.current) {
      ref.current.srcObject = remoteStream.stream;
    }
  }, [remoteStream]);

  return <video className="w-64 h-64" ref={ref} autoPlay playsInline />;
}

export default Video;

그리고 Video 컴포넌트를 RoomPage에서 가져다 쓰자.

// frontend/src/RoomPage.tsx

import { useLocation, useNavigate } from "react-router";
import { useSocket } from "./hooks/useSocket";
import { useEffect, useRef } from "react";
import Video from "./Video";

function RoomPage() {
  const location = useLocation();
  const navigate = useNavigate();
  const nick = location.state?.nick as string | undefined;

  const myVideoRef = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    if (!nick) navigate("/");
  }, [navigate, nick]);

  const remoteStreams = useSocket(nick, myVideoRef);

  return (
    <div className="w-screen h-screen 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>
  );
}

export default RoomPage;

흐음.. 이번엔 뭐가 문제인지 나중에 접속하는 피어(recv-users)는 상대 스트림을 잘 가져오는데, 먼저 접속해있던 피어(recv-offer)는 상대 스트림을 못 받아온다.


트러블 슈팅: 기존 피어가 새 피어의 스트림을 가져오지 못하는 문제

기존 피어를 새로고침 하면 스트림을 받아오는데, 로그를 찍어보니 recv-usersontrack으로 가져온다. 다시 말해 recv-offerontrack이 작동하지 않는 것이다.

아까와 마찬가지로 "실행 타이밍"의 문제로 예상하고, recv-offerontrack의 실행 위치를 이동시키다가 해결할 수 있었다.

Answer를 만들고 시그널링 서버로 전송하는 것보다 먼저 ontrack을 등록해야만 하는 것 같다.

// frontend/src/hooks/useSocket.ts

import { RefObject, useEffect, useRef, useState } from "react";
import { Socket, io } from "socket.io-client";
import { Peer, RecvUser, RemoteStream } from "../frontTypes";

export const useSocket = (
  nick: string | undefined,
  myVideoRef: RefObject<HTMLVideoElement>
) => {
  const socketRef = useRef<Socket | null>(null);
  const myStreamRef = useRef<MediaStream | null>(null);
  const peersRef = useRef<Peer[]>([]);
  const [remoteStreams, setRemoteStreams] = useState<RemoteStream[]>([]);

  useEffect(() => {
    const handleRTC = (socket: Socket, nick: string) => {
      socket.on("recv-users", async (users: RecvUser[]) => {
        const stream = await navigator.mediaDevices.getUserMedia({
          video: true,
          audio: true,
        });

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

          myStreamRef.current = stream;
          if (myVideoRef.current) myVideoRef.current.srcObject = stream;
          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);
        }
      );

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

          const stream = await navigator.mediaDevices.getUserMedia({
            video: true,
            audio: true,
          });
          myStreamRef.current = stream;
          if (myVideoRef.current) myVideoRef.current.srcObject = stream;
          stream.getTracks().forEach((track) => peer.addTrack(track, stream));

          peer.setRemoteDescription(offer);

          // ontrack의 위치를 createAnswer()보다 위로 이동!
          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 });
        }
      );

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

    if (nick) {
      socketRef.current = io("http://localhost:8080", {
        query: {
          nick,
        },
      });

      handleRTC(socketRef.current, nick);
    }

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

  return remoteStreams;
};

아니...! 이번에도 먼저 들어간 측에서 문제가 발생하는데, 새로 들어온 유저의 닉네임을 정상적으로 받아오지 못하는 것으로 보인다...


트러블 슈팅: 새 피어의 닉네임이 자신의 닉네임으로 나오는 문제

이건 문제점을 쉽게 찾고 해결할 수 있었다.

생각해보면, recv-offer 이벤트 리스너는 host의 닉네임을 전달받지 않는데, nick 변수에 접근하고 있었다.

즉 스코프 체이닝에 의해 상위 스코프를 찾아가게 될거고, 결국 자기 이름(...)을 Offer를 전달한 host의 닉네임으로 착각하게 되는 문제가 발생하는 것이다.

해결은 간단하다. 어쨋든 host의 닉네임이 필요하므로, 시그널링 서버에서 닉네임을 찾아서 Offer와 함께 전달해주자!

recv-offer 근처 코드만 수정하면 되므로 생략하면서 작성하겠다. 여전히 주석 주위만 보면 된다!

// backend/socketHandler.ts

import { Server, Socket } from "socket.io";
import { User } from "./backTypes";

let users: User[] = [];

export const socketHandler = (io: Server) => {
  io.on("connection", (socket: Socket) => {
    socket.data = { nick: socket.handshake.query.nick };
    users.push({ socketId: socket.id, nick: socket.data.nick });

	..(생략)

    socket.on(
      "send-offer",
      (
        destId: string,
        hostId: string,
        offer: { type: RTCSdpType; sdp: string }
      ) => {
        console.log("send-offer");
        io.to(destId).emit(
          "recv-offer",
          hostId,
          users.filter((user) => user.socketId === hostId)[0].nick, // host의 닉네임 추가!
          offer
        );
      }
    );
    
    ..(생략)
  };
};
// frontend/src/hooks/useSocket.ts

..
export const useSocket = (
  nick: string | undefined,
  myVideoRef: RefObject<HTMLVideoElement>
) => {
  ..(생략)
  useEffect(() => {
    const handleRTC = (socket: Socket, nick: string) => {
      ..(생략)
      socket.on(
        "recv-offer",
        async (
          socketId: string,
          nick: string, // 닉네임도 받아오기!
          offer: { type: RTCSdpType; sdp: string }
        ) => {
          ..(생략)
          peer.ontrack = (e) => {
            const streamObj: RemoteStream = {
              socketId,
              nick, // nick에 이제는 host의 닉네임이 들어간다!
              stream: e.streams[0],
            };
            setRemoteStreams((prev) =>
              prev.some((remoteStream) => remoteStream.socketId === socketId)
                ? prev
                : [...prev, streamObj]
            );
          };
  ..(생략)
};

드디어 Mesh 구조로 N:M WebRTC 연결에 성공했다...!!!


사용자가 나가면, peersRef와 remoteStreams에서 제거하기

지금은 사용자가 나가도 peersRefremoteStreams에 남아있어서 사라지지 않는다.

이는 잘못된 동작이므로 사용자가 연결이 끊기면, disconnected 이벤트를 남아있는 사용자에게 발송해서 어떤 사용자가 나갔는지 알려주고 각 사용자들은 나간 사용자를 peersRef와 remoteStreams에서 삭제하도록 구현해야 한다.

// backend/socketHandler.ts

..(생략)

export const socketHandler = (io: Server) => {
  io.on("connection", (socket: Socket) => {

    ..(생략)

    socket.on("disconnect", () => {
      users = users.filter((user) => user.socketId !== socket.id);
      io.emit("disconnected", socket.id); // 나간 유저의 socketId를 브로드캐스트
    });
  });
};
// frontend/src/hooks/useSocket.ts

..(생략)

export const useSocket = (
  nick: string | undefined,
  myVideoRef: RefObject<HTMLVideoElement>
) => {

  ..(생략)
  
  useEffect(() => {
    const handleRTC = (socket: Socket, nick: string) => {

      ..(생략)

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

      // 특정 peer가 나가면, 해당 peer를 peersRef와 remoteStreams에서 제외
      socket.on("disconnected", (socketId: string) => {
        peersRef.current.filter((peer) => peer.socketId !== socketId);
        setRemoteStreams((prev) =>
          prev.filter((stream) => stream.socketId !== socketId)
        );
      });
    };

    if (nick) {
      socketRef.current = io("http://localhost:8080", {
        query: {
          nick,
        },
      });

      handleRTC(socketRef.current, nick);
    }

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

  return remoteStreams;
};

이제 사용자가 중간에 연결을 끊으면, 다른 사용자들에게도 더이상 보이지 않는다.

또 새로 접속 시 바로바로 업데이트 역시 잘 되는 걸 알 수 있다!


정리

원래 해당 포스트에 비디오/오디오 관련 기능도 작성하려고 했으나, 길이가 생각보다 너무너무 길어져서 한 번 끊고 가려고 한다.

글 작성 준비와, 코드블록 작성 과정에서 WebRTC에 대해 다시 한 번 깊게 고민해볼 수 있어서 좋았고, 시그널링 과정은 역시 복잡하긴 한 것 같다...(ㅠㅠ)

그래도 이번 회고 과정에서 작성한 코드는 나름의 근거를 가지고 문제를 해결해 나갔다보니 좀 더 뿌듯하다. 사실 이 회고를 진행하는 이유가 과제 마감기한에 쫒기느라 GPT와 씨름만 하고, 공부는 못 했던 점이 아쉬워서이기 때문이다.

이번 포스트를 작성하면서 정말 글을 못 쓴다는 생각도 들었고, 더욱 더 열심히 글로 생각과 코드를 표현하는 연습을 하려고 마음먹었다.

다음 글에서는 우선 useSocket 리팩토링을 먼저 수행하고, 비디오/오디오 온오프를 구현한 뒤, 마지막으로 장치 변경 기능을 구현하며 WebRTC 회고를 마무리하려고 한다.

긴 글 읽어주셔서 감사합니다. 혹시라도 WebRTC 관련 구현에서 애를 먹고 계시다면 도움이 되었기를 바랍니다.

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


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

0개의 댓글