background
aurora10분

SFU 연결

2026년 1월 31일

거진 2달 가량의 대기시간이 끝나고 백엔드 쪽에서 어느정도 SFU 서버 로직이 테스트가 가능하다고 해서 해당 테스트 코드를 요청하여 받아봤다.

typescript
async function connect() {
        // 사용자로부터 채널 PK와 Auth Token을 가져오기.
        channelPk = parseInt(document.getElementById('channelPk').value, 10);
        const authToken = document.getElementById('authToken').value;

        // 필수 입력 값 확인
        if (!channelPk || !authToken) {
          alert('채널 PK와 Auth Token을 모두 입력해주세요.');
          return;
        }

        log(`Connecting to ${WS_URL}...`);
        // WebSocket 연결 인스턴스 생성
        ws = new WebSocket(WS_URL);

        /**
         * WebSocket 연결이 성공적으로 열렸을 때 호출됨
         */
        ws.onopen = () => {
          log('WebSocket connection established', 'success');
          // UI 상태 업데이트
          statusDiv.textContent = '연결됨';
          statusDiv.className = 'connected';
          connectBtn.disabled = true;
          disconnectBtn.disabled = false;

          // mediasoup 관련 초기화 로직 시작
          initMediasoup();
        };

        /**
         * WebSocket 서버로부터 메시지를 수신했을 때 호출.
         * 수신된 JSON 메시지를 파싱하고 handleMessage 함수로 전달함
         * @param {MessageEvent} event - WebSocket 메시지 이벤트
         */
        ws.onmessage = async (event) => {
          try {
            const message = JSON.parse(event.data);
            log(`RECV: ${message.event}`, 'info'); // 수신된 이벤트 로깅 (RECV: Receive 약자)
            await handleMessage(message); // 메시지 처리 함수 호출
          } catch (error) {
            log(`메세지 처리 실패: ${error.message}`, 'error');
          }
        };

        /**
         * WebSocket 연결 중 오류가 발생했을 때 호출됨
         * @param {Event} error - WebSocket 오류 이벤트
         */
        ws.onerror = (error) => {
          log(
            `WebSocket 연결 실패: ${error.message || 'Unknown error'}`,
            'error',
          );
        };

        /**
         * WebSocket 연결이 닫혔을 때 호출됨
         */
        ws.onclose = () => {
          log('WebSocket 연결 해제', 'warn');
          cleanUp(); // 연결 종료 시 자원 정리
        };
      }

      /**
       * WebSocket 연결을 명시적닫기
       */
      function disconnect() {
        log('연결 해제중...');
        if (ws) {
          ws.close(); // WebSocket 연결 종료
        }
      }

      /**
       * mediasoup 관련 모든 자원(스트림, 컨슈머, 트랜스포트 등)을 정리하고 UI 초기화
       * 연결 해제 또는 오류 발생 시 호출됨
       */
      function cleanUp() {
        // 로컬 미디어 스트림 트랙 중지
        if (localStream) {
          localStream.getTracks().forEach((track) => track.stop());
          localStream = null;
        }

        // 모든 컨슈머 종료 및 맵 초기화
        consumers.forEach((consumer) => consumer.close());
        consumers.clear();
        consumerIdToUserData.clear();

        // 프로듀서 트랜스포트 종료
        if (producerTransport) {
          producerTransport.close();
          producerTransport = null;
        }
        // 컨슈머 트랜스포트 종료
        if (consumerTransport) {
          consumerTransport.close();
          consumerTransport = null;
        }
        // 오디오 프로듀서 종료
        if (audioProducer) {
          audioProducer.close();
          audioProducer = null;
        }
        // 비디오 프로듀서 종료
        if (videoProducer) {
          videoProducer.close();
          videoProducer = null;
        }

        device = null; // mediasoup Device 객체 초기화

        // 원격 비디오 요소 제거
        document.getElementById('remoteVideos').innerHTML = '';
        // 로컬 비디오 요소 초기화 및 숨기기
        document.getElementById('localVideo').srcObject = null;
        document.getElementById('localVideo').style.display = 'none';

        // UI 상태 초기화
        statusDiv.textContent = '연결되지 않음';
        statusDiv.className = 'disconnected';
        connectBtn.disabled = false;
        disconnectBtn.disabled = true;
        startAudioBtn.disabled = true;
        stopAudioBtn.disabled = true;
        startVideoBtn.disabled = true;
        stopVideoBtn.disabled = true;

        log('초기화 완료', 'success');
      }

      /**
       * WebSocket을 통해 서버로 메시지를 전송
       * @param {string} event - 서버에 보낼 이벤트 이름
       * @param {object} data - 이벤트와 함께 보낼 데이터 페이로드
       */
      function send(event, data) {
        // WebSocket 연결이 열려있는지 확인
        if (ws && ws.readyState === WebSocket.OPEN) {
          const message = JSON.stringify({ event, ...data }); // JSON 문자열로 변환
          log(`SENT: ${event}`, 'info'); // 전송 이벤트 로깅
          ws.send(message); // 메시지 전송
        }
      }

      /**
       * mediasoup 클라이언트 디바이스 초기화 및 룸 참여 요청을 시작
       * WebSocket 연결이 성공적으로 수립된 후 호출됨
       */
      async function initMediasoup() {
        try {
          log('mediasoup 장치 시작중...');
          const authToken = document.getElementById('authToken').value;
          // 서버에 룸 참여(join-room) 이벤트를 전송
          // 인증 토큰과 현재 채널 PK를 포함하며, RTP Capabilities는 나중에 얻음
          // RTP Capability란? 단말기가 어떤 형태의 미디어(코덱, 해상도 등)를 처리할 수 있는지에 대한 능력과 설정이 담긴 정보
          // 즉, "나는 이 코덱으로 이런 미디어 스트림을 이 포트로 보낼 수 있다" 라고 상대방에게 알리는 통신 환경 설정 및 지원 능력임
          send('join-room', {
            channelPk,
            authToken,
            rtpCapabilities: {},
          });
          // rtpCapabilities를 빈 채로 보내는 이유는 초기화 시점에서는 아직 mediasoupClient.Device가 로드되지 않았기 때문.
          // rtpc 정보는 device가 로드되어야 확인이 가능함
        } catch (error) {
          log(`mediasoup 시작 실패: ${error.message}`, 'error');
        }
      }

      /**
       * 서버로부터 수신된 WebSocket 메시지를 처리햠
       * 각 이벤트 유형에 따라 적절한 클라이언트 측 로직을 실행함
       * @param {object} message - 서버로부터 수신된 JSON 메시지 객체
       */
      async function handleMessage(message) {
        const { event, ...data } = message; // 이벤트 이름과 데이터를 분리 (서버로부터 묶여서 나오기 때문)
        switch (event) {
          // 룸 참여 성공 이벤트 (서버로부터 채널에 성공적으로 입장했음을 알림)
          // 서버에 send(join-room) 을 해서 성공하면 서버의 Rtp Capabilities 데이터를 포함하는 router-rtp-capabilities를 요청
          case 'joinRoomSuccess':
            log('방 참여 성공', 'success');
            // UI 버튼 활성화
            startAudioBtn.disabled = false;
            startVideoBtn.disabled = false;

            // 서버로부터 받은 기존 프로듀서 정보를 임시 변수에 저장
            // 이 정보는 `router-rtp-capabilities` 이벤트 처리 시에 사용됨
            // `joinRoomSuccess` 데이터에 `existingProducers`가 없을 경우 빈 배열로 초기화
            receivedExistingProducers = data.existingProducers || [];

            // 룸 참여 성공 후, 라우터의 RTP Capabilities를 서버에 요청하여 mediasoup Device 로드할 준비
            send('get-router-rtp-capabilities', { channelPk });
            break;

          // 라우터의 RTP Capabilities 수신 이벤트 (mediasoup Device 로드에 필요)
          case 'router-rtp-capabilities':
            device = new mediasoupClient.Device(); // mediasoup Device 인스턴스 생성
            // 라우터의 RTP Capabilities를 사용하여 Device 로드
            // 이 과정을 통해 클라이언트의 WebRTC 스택과 mediasoup 라우터 간의 호환성을 설정함
            // * 호환성을 설정하기 위해 서버의 RTP Capabilities 정보를 요청받는 것임(사용자와 서버의 RTPC 교집합을 찾는 것)
            await device.load({ routerRtpCapabilities: data.rtpCapabilities });
            log('Device 로드됨', 'success');

            // 미디어 송수신을 위한 WebRTC Transport를 생성
            await createTransports();

            // 방 입장 시 자동으로 오디오 전송 시작
            // `startAudio` 함수는 내부적으로 `producerTransport.produce`를 호출함
            await startAudio(); // 자동 오디오 전송 시작

            // `joinRoomSuccess` 이벤트에서 저장해둔 기존 프로듀서 정보를 사용하여 Consume을 시작
            // 이전에 `data.existingProducers`를 직접 `router-rtp-capabilities` 이벤트에서 사용하려 했으나,
            // `router-rtp-capabilities` 이벤트의 `data`에는 해당 정보가 없으므로 전역 변수 활용
            if (receivedExistingProducers.length > 0) {
              log(
                `존재하는 producer ${receivedExistingProducers.length}개 있음`,
                'info',
              );
              for (const producerInfo of receivedExistingProducers) {
                await consumeMedia(producerInfo);
              }
            }
            // 사용 후 임시 저장 변수를 초기화하여 다음 연결 시 영향을 주지 않도록 함
            receivedExistingProducers = [];
            break;

          // WebRTC Transport 생성 완료 이벤트 (createTransports 함수 내부에서 처리되므로 여기서는 주석 처리)
          case 'webrtc-transport-created':
            // 이 이벤트는 `createTransports()` 함수 내부에 `ws.addEventListener('message', onMessage)` 리스너에서 직접 처리됨
            // 따라서 여기에 추가적인 로직은 필요 X
            break;

          // Transport 연결 완료 이벤트
          case 'transport-connected':
            log(`Transport 연결됨: ${data.transportId}`, 'success');
            break;

          // 미디어 Producer 생성 완료 이벤트
          case 'produced':
            log(`Media produced 생성 완료: ${data.producerId}`, 'success');
            // Producer 객체에 서버에서 할당된 ID를 저장 (현재는 `producerTransport.produce`에서 직접 처리)
            // if (producers.has(data.producerId)) {
            //     producers.get(data.producerId).server_id = data.producerId;
            // }
            break;

          // 새로운 Producer가 룸에 생성되었음을 알리는 이벤트 (다른 피어가 미디어 전송 시작 시)
          case 'new-producer':
            log(
              `New producer 생성됨: ${data.producerId} (userId: ${data.userId})`,
              'info',
            );
            await consumeMedia(data); // 해당 Producer의 미디어를 소비 시작
            break;

          // 미디어 Consumer 생성 완료 이벤트 (서버로부터 컨슈머 정보 수신 시)
          case 'consumed':
            await handleNewConsumer(data); // 새로운 Consumer를 처리하고 UI에 미디어 표시
            break;

          // 피어가 룸을 떠났음을 알리는 이벤트
          case 'peer-left':
            log(`Peer 나감: userId ${data.userId}`, 'warn');
            handlePeerLeft(data.userId); // 해당 피어의 미디어 정리
            break;

          // Producer가 종료되었음을 알리는 이벤트 (피어가 미디어 전송 중지 시)
          case 'producer-closed':
            handleProducerClosed(data.producerId); // 해당 Producer의 Consumer 정리
            break;

          // 서버 측에서 발생한 오류 이벤트
          case 'error':
            log(`Server error: ${data.message}`, 'error');
            // 토큰 관련 오류의 경우, 연결 해제를 시도함
            if (data.message.includes('token')) {
              disconnect();
            }
            break;
        }
      }

      ...

노션에 올라가는 코드의 양에 한계가 있어서 함수 별로 설명하면

connect

typescript
/**
       * SFU 서버에 WebSocket 연결, mediasoup 초기화
       */
      async function connect() {
        // 사용자로부터 채널 PK와 Auth Token을 가져오기.
        channelPk = parseInt(document.getElementById('channelPk').value, 10);
        const authToken = document.getElementById('authToken').value;

        // 필수 입력 값 확인
        if (!channelPk || !authToken) {
          alert('채널 PK와 Auth Token을 모두 입력해주세요.');
          return;
        }

        log(`Connecting to ${WS_URL}...`);
        // WebSocket 연결 인스턴스 생성
        ws = new WebSocket(WS_URL);

        /**
         * WebSocket 연결이 성공적으로 열렸을 때 호출됨
         */
        ws.onopen = () => {
          log('WebSocket connection established', 'success');
          // UI 상태 업데이트
          statusDiv.textContent = '연결됨';
          statusDiv.className = 'connected';
          connectBtn.disabled = true;
          disconnectBtn.disabled = false;

          // mediasoup 관련 초기화 로직 시작
          initMediasoup();
        };

        /**
         * WebSocket 서버로부터 메시지를 수신했을 때 호출.
         * 수신된 JSON 메시지를 파싱하고 handleMessage 함수로 전달함
         * @param {MessageEvent} event - WebSocket 메시지 이벤트
         */
        ws.onmessage = async (event) => {
          try {
            const message = JSON.parse(event.data);
            log(`RECV: ${message.event}`, 'info'); // 수신된 이벤트 로깅 (RECV: Receive 약자)
            await handleMessage(message); // 메시지 처리 함수 호출
          } catch (error) {
            log(`메세지 처리 실패: ${error.message}`, 'error');
          }
        };

        /**
         * WebSocket 연결 중 오류가 발생했을 때 호출됨
         * @param {Event} error - WebSocket 오류 이벤트
         */
        ws.onerror = (error) => {
          log(
            `WebSocket 연결 실패: ${error.message || 'Unknown error'}`,
            'error',
          );
        };

        /**
         * WebSocket 연결이 닫혔을 때 호출됨
         */
        ws.onclose = () => {
          log('WebSocket 연결 해제', 'warn');
          cleanUp(); // 연결 종료 시 자원 정리
        };
      }

초기에 웹소켓을 연결하는 로직으로 현재 채널의 pk와 accessToken 을 가져와 웹 소켓 연결을 준비한다. 성공적응로 웹 소켓이 연결되면 initMeidasoup 를 호출하여 Mediasoup 연결을 시작한다.

cleanup

typescript
/**
       * mediasoup 관련 모든 자원(스트림, 컨슈머, 트랜스포트 등)을 정리하고 UI 초기화
       * 연결 해제 또는 오류 발생 시 호출됨
       */
      function cleanUp() {
        // 로컬 미디어 스트림 트랙 중지
        if (localStream) {
          localStream.getTracks().forEach((track) => track.stop());
          localStream = null;
        }

        // 모든 컨슈머 종료 및 맵 초기화
        consumers.forEach((consumer) => consumer.close());
        consumers.clear();
        consumerIdToUserData.clear();

        // 프로듀서 트랜스포트 종료
        if (producerTransport) {
          producerTransport.close();
          producerTransport = null;
        }
        // 컨슈머 트랜스포트 종료
        if (consumerTransport) {
          consumerTransport.close();
          consumerTransport = null;
        }
        // 오디오 프로듀서 종료
        if (audioProducer) {
          audioProducer.close();
          audioProducer = null;
        }
        // 비디오 프로듀서 종료
        if (videoProducer) {
          videoProducer.close();
          videoProducer = null;
        }

        device = null; // mediasoup Device 객체 초기화

        // 원격 비디오 요소 제거
        document.getElementById('remoteVideos').innerHTML = '';
        // 로컬 비디오 요소 초기화 및 숨기기
        document.getElementById('localVideo').srcObject = null;
        document.getElementById('localVideo').style.display = 'none';

        // UI 상태 초기화
        statusDiv.textContent = '연결되지 않음';
        statusDiv.className = 'disconnected';
        connectBtn.disabled = false;
        disconnectBtn.disabled = true;
        startAudioBtn.disabled = true;
        stopAudioBtn.disabled = true;
        startVideoBtn.disabled = true;
        stopVideoBtn.disabled = true;

        log('초기화 완료', 'success');
      }

자원 정리 함수로써, 모든 컨슈머와 프로듀서를 종료하고 device 객체를 null 로 할당하여 누수를 방지한다.

disconnect

typescript
/**
       * WebSocket 연결을 명시적닫기
       */
      function disconnect() {
        log('연결 해제중...');
        if (ws) {
          ws.close(); // WebSocket 연결 종료
        }
      }

웹 소켓 연결을 해제 한다

send

typescript
/**
       * WebSocket을 통해 서버로 메시지를 전송
       * @param {string} event - 서버에 보낼 이벤트 이름
       * @param {object} data - 이벤트와 함께 보낼 데이터 페이로드
       */
      function send(event, data) {
        // WebSocket 연결이 열려있는지 확인
        if (ws && ws.readyState === WebSocket.OPEN) {
          const message = JSON.stringify({ event, ...data }); // JSON 문자열로 변환
          log(`SENT: ${event}`, 'info'); // 전송 이벤트 로깅
          ws.send(message); // 메시지 전송
        }
      }

메시지를 전송하기 위한 함수로 웹소켓이 열려 있는 경우에만 메시지를 보낼 수 있도록 제한했다.

여기까지는 WebSocket 연결과 송신 등의 함수고 다음부터 mediasoup 관련 로직이다.


initMediaSoup

typescript
/**
       * mediasoup 클라이언트 디바이스 초기화 및 룸 참여 요청을 시작
       * WebSocket 연결이 성공적으로 수립된 후 호출됨
       */
      async function initMediasoup() {
        try {
          log('mediasoup 장치 시작중...');
          const authToken = document.getElementById('authToken').value;
          // 서버에 룸 참여(join-room) 이벤트를 전송
          // 인증 토큰과 현재 채널 PK를 포함하며, RTP Capabilities는 나중에 얻음
          // RTP Capability란? 단말기가 어떤 형태의 미디어(코덱, 해상도 등)를 처리할 수 있는지에 대한 능력과 설정이 담긴 정보
          // 즉, "나는 이 코덱으로 이런 미디어 스트림을 이 포트로 보낼 수 있다" 라고 상대방에게 알리는 통신 환경 설정 및 지원 능력임
          send('join-room', {
            channelPk,
            authToken,
            rtpCapabilities: {},
          });
          // rtpCapabilities를 빈 채로 보내는 이유는 초기화 시점에서는 아직 mediasoupClient.Device가 로드되지 않았기 때문.
          // rtpc 정보는 device가 로드되어야 확인이 가능함
        } catch (error) {
          log(`mediasoup 시작 실패: ${error.message}`, 'error');
        }
      }

Mediasoup를 시작하는 함수로 join-room 메시지에 채널 pk와 accessToken 을 보내준다. rtpCapabilities 의 경우 초기에 연결할 때, 빈 객체를 보내줘야 rtpc 정보 확인이 가능하기 때문이다

handleMessage(message)

typescript
/**
       * 서버로부터 수신된 WebSocket 메시지를 처리햠
       * 각 이벤트 유형에 따라 적절한 클라이언트 측 로직을 실행함
       * @param {object} message - 서버로부터 수신된 JSON 메시지 객체
       */
      async function handleMessage(message) {
        const { event, ...data } = message; // 이벤트 이름과 데이터를 분리 (서버로부터 묶여서 나오기 때문)
        switch (event) {
          // 룸 참여 성공 이벤트 (서버로부터 채널에 성공적으로 입장했음을 알림)
          // 서버에 send(join-room) 을 해서 성공하면 서버의 Rtp Capabilities 데이터를 포함하는 router-rtp-capabilities를 요청
          case 'joinRoomSuccess':
            log('방 참여 성공', 'success');
            // UI 버튼 활성화
            startAudioBtn.disabled = false;
            startVideoBtn.disabled = false;

            // 서버로부터 받은 기존 프로듀서 정보를 임시 변수에 저장
            // 이 정보는 `router-rtp-capabilities` 이벤트 처리 시에 사용됨
            // `joinRoomSuccess` 데이터에 `existingProducers`가 없을 경우 빈 배열로 초기화
            receivedExistingProducers = data.existingProducers || [];

            // 룸 참여 성공 후, 라우터의 RTP Capabilities를 서버에 요청하여 mediasoup Device 로드할 준비
            send('get-router-rtp-capabilities', { channelPk });
            break;

          // 라우터의 RTP Capabilities 수신 이벤트 (mediasoup Device 로드에 필요)
          case 'router-rtp-capabilities':
            device = new mediasoupClient.Device(); // mediasoup Device 인스턴스 생성
            // 라우터의 RTP Capabilities를 사용하여 Device 로드
            // 이 과정을 통해 클라이언트의 WebRTC 스택과 mediasoup 라우터 간의 호환성을 설정함
            // * 호환성을 설정하기 위해 서버의 RTP Capabilities 정보를 요청받는 것임(사용자와 서버의 RTPC 교집합을 찾는 것)
            await device.load({ routerRtpCapabilities: data.rtpCapabilities });
            log('Device 로드됨', 'success');

            // 미디어 송수신을 위한 WebRTC Transport를 생성
            await createTransports();

            // 방 입장 시 자동으로 오디오 전송 시작
            // `startAudio` 함수는 내부적으로 `producerTransport.produce`를 호출함
            await startAudio(); // 자동 오디오 전송 시작

            // `joinRoomSuccess` 이벤트에서 저장해둔 기존 프로듀서 정보를 사용하여 Consume을 시작
            // 이전에 `data.existingProducers`를 직접 `router-rtp-capabilities` 이벤트에서 사용하려 했으나,
            // `router-rtp-capabilities` 이벤트의 `data`에는 해당 정보가 없으므로 전역 변수 활용
            if (receivedExistingProducers.length > 0) {
              log(
                `존재하는 producer ${receivedExistingProducers.length}개 있음`,
                'info',
              );
              for (const producerInfo of receivedExistingProducers) {
                await consumeMedia(producerInfo);
              }
            }
            // 사용 후 임시 저장 변수를 초기화하여 다음 연결 시 영향을 주지 않도록 함
            receivedExistingProducers = [];
            break;

          // WebRTC Transport 생성 완료 이벤트 (createTransports 함수 내부에서 처리되므로 여기서는 주석 처리)
          case 'webrtc-transport-created':
            // 이 이벤트는 `createTransports()` 함수 내부에 `ws.addEventListener('message', onMessage)` 리스너에서 직접 처리됨
            // 따라서 여기에 추가적인 로직은 필요 X
            break;

          // Transport 연결 완료 이벤트
          case 'transport-connected':
            log(`Transport 연결됨: ${data.transportId}`, 'success');
            break;

          // 미디어 Producer 생성 완료 이벤트
          case 'produced':
            log(`Media produced 생성 완료: ${data.producerId}`, 'success');
            // Producer 객체에 서버에서 할당된 ID를 저장 (현재는 `producerTransport.produce`에서 직접 처리)
            // if (producers.has(data.producerId)) {
            //     producers.get(data.producerId).server_id = data.producerId;
            // }
            break;

          // 새로운 Producer가 룸에 생성되었음을 알리는 이벤트 (다른 피어가 미디어 전송 시작 시)
          case 'new-producer':
            log(
              `New producer 생성됨: ${data.producerId} (userId: ${data.userId})`,
              'info',
            );
            await consumeMedia(data); // 해당 Producer의 미디어를 소비 시작
            break;

          // 미디어 Consumer 생성 완료 이벤트 (서버로부터 컨슈머 정보 수신 시)
          case 'consumed':
            await handleNewConsumer(data); // 새로운 Consumer를 처리하고 UI에 미디어 표시
            break;

          // 피어가 룸을 떠났음을 알리는 이벤트
          case 'peer-left':
            log(`Peer 나감: userId ${data.userId}`, 'warn');
            handlePeerLeft(data.userId); // 해당 피어의 미디어 정리
            break;

          // Producer가 종료되었음을 알리는 이벤트 (피어가 미디어 전송 중지 시)
          case 'producer-closed':
            handleProducerClosed(data.producerId); // 해당 Producer의 Consumer 정리
            break;

          // 서버 측에서 발생한 오류 이벤트
          case 'error':
            log(`Server error: ${data.message}`, 'error');
            // 토큰 관련 오류의 경우, 연결 해제를 시도함
            if (data.message.includes('token')) {
              disconnect();
            }
            break;
        }
      }

서버로부터 송신된 메시지에 따라 처리할 로직을 나눈 함수이다.

createTransports

typescript
/**
       * 미디어 송신(Producer) 및 수신(Consumer)을 위한 WebRTC Transport를 생성
       * 서버와 시그널링을 통해 Transport 설정을 교환하고 연결을 수립함
       */
      async function createTransports() {
        /**
         * Transport 생성 요청을 캡슐화하는 헬퍼 함수.
         * @param {boolean} isProducer - Producer Transport인지 Consumer Transport인지 여부
         * @returns {Promise<void>} Transport 생성이 완료되면 resolve
         */
        const createTransportPromise = (isProducer) => {
          return new Promise((resolve, reject) => {
            // 서버에 WebRTC Transport 생성을 요청
            send('create-webrtc-transport', { channelPk });

            // 서버로부터 'webrtc-transport-created' 이벤트를 기다림
            const onMessage = (event) => {
              const data = JSON.parse(event.data);
              // Transport 생성 응답이 오면
              if (data.event === 'webrtc-transport-created') {
                ws.removeEventListener('message', onMessage); // 이벤트 리스너 제거

                // mediasoup-client Device를 사용하여 Transport 객체를 생성함
                const transport = isProducer
                  ? device.createSendTransport(data) // 송신 Transport
                  : device.createRecvTransport(data); // 수신 Transport

                // Transport 'connect' 이벤트 핸들러: DTLS 파라미터를 서버에 전송하여 연결을 완료
                transport.on(
                  'connect',
                  ({ dtlsParameters }, callback, errback) => {
                    send('connect-transport', {
                      transportId: transport.id,
                      dtlsParameters,
                      channelPk,
                    });
                    callback(); // mediasoup-client에 연결 완료를 알림
                  },
                );

                // Producer Transport인 경우 'produce' 이벤트 핸들러를 설정함
                // 이 핸들러는 `producerTransport.produce()` 호출 시 트리거됨
                if (isProducer) {
                  transport.on(
                    'produce',
                    async (
                      { kind, rtpParameters, appData },
                      callback,
                      errback,
                    ) => {
                      // 서버에 미디어 Producer 생성을 요청함
                      send('produce', {
                        transportId: transport.id,
                        channelPk,
                        kind, // 'audio' 또는 'video'
                        rtpParameters, // 미디어 코덱 및 설정
                      });

                      // 서버로부터 'produced' 응답을 기다림
                      const onProduceResponse = (event) => {
                        const data = JSON.parse(event.data);
                        if (data.event === 'produced') {
                          ws.removeEventListener('message', onProduceResponse); // 리스너 제거
                          callback({ id: data.producerId }); // mediasoup-client에 Producer ID 알림
                        }
                      };
                      ws.addEventListener('message', onProduceResponse);
                    },
                  );
                }

                // 생성된 Transport를 전역 변수에 할당함
                isProducer
                  ? (producerTransport = transport)
                  : (consumerTransport = transport);
                log(
                  `${isProducer ? 'Send' : 'Recv'} transport 생성됨`,
                  'success',
                );
                resolve(); // Promise 완료
              } else if (data.event === 'error') {
                ws.removeEventListener('message', onMessage);
                reject(new Error(data.message)); // 오류 발생 시 Promise 거부
              }
            };
            ws.addEventListener('message', onMessage); // WebSocket 메시지 리스너 추가
          });
        };

        // Producer Transport와 Consumer Transport를 순차적으로 생성함
        await createTransportPromise(true); // Producer (송신) Transport 생성
        await createTransportPromise(false); // Consumer (수신) Transport 생성
      }

createTransportPromise(isProducer)

typescript
constcreateTransportPromise = (isProducer) => {
returnnewPromise((resolve, reject) => {

왜 Promise로 감싸냐?

  • Transport 생성은 서버 응답을 기다려야 하는 비동기 작업
  • await으로 순서 제어하려고

서버에 Transport 생성 요청

typescript
send('create-webrtc-transport', { channelPk });

“이 채널에서 사용할 WebRTC Transport 하나 만들어줘”

서버에서는:

  • ICE
  • DTLS
  • IP/PORT
  • role(client/server)

같은 네트워크 레벨 정보를 생성함


webrtc-transport-created 응답 대기

typescript
constonMessage = (event) => {
const data =JSON.parse(event.data);

if (data.event ==='webrtc-transport-created') {

서버 응답에는 보통 이런 정보가 있음:

json
{
"event":"webrtc-transport-created",
"id":"...",
"iceParameters":{...},
"iceCandidates":[...],
"dtlsParameters":{...}
}

이걸로 클라이언트 Transport 객체를 만들 수 있음


mediasoup-client Transport 생성

typescript
const transport = isProducer
  ? device.createSendTransport(data)
  : device.createRecvTransport(data);
  • 이 시점부터 mediasoup-client가 Transport를 관리
  • 아직 연결(connect)은 안 됨
  • 단지 “설계도 기반 객체 생성”

transport.on("connect") – DTLS 연결 단계

typescript
transport.on('connect',({ dtlsParameters }, callback) => {
send('connect-transport', {
transportId: transport.id,
    dtlsParameters,
    channelPk,
  });
callback();
});

WebRTC 보안 연결 수립 단계

  • DTLS = WebRTC의 TLS 같은 존재
  • mediasoup-client가:
    • “이 DTLS 파라미터 서버로 보내라” 요청
  • 서버가:
    • router/transport에 연결

이 단계가 성공해야 미디어 패킷이 흐를 수 있음


Producer Transport일 때만 produce 이벤트 처리

typescript
if (isProducer) {
  transport.on('produce', ...)
}

왜 SendTransport만 produce 이벤트가 있나?

  • RecvTransport는 소비만 함
  • Produce는 “내가 미디어를 보낸다”는 행위

produce 이벤트의 정확한 의미

typescript
transport.produce({ track, appData })

이걸 호출하면

mediasoup-client가 자동으로 produce 이벤트를 발생시킴

typescript
transport.on('produce',({ kind, rtpParameters, appData }, callback) => {

서버에 Producer 생성 요청

typescript
send('produce', {
transportId: transport.id,
  channelPk,
  kind,
  rtpParameters,
});

“이 Transport 위에 Producer 하나 만들어줘

이 Producer는 audio/video고

이런 RTP 설정을 쓸 거야”


produced 응답 대기

typescript
if (data.event ==='produced') {
callback({id: data.producerId });
}
  • mediasoup-client에게:

    “서버에서 Producer 생성 완료됐어

이게 네 Producer ID야”

이제야 Producer 객체가 완전히 살아남


Transport 저장

typescript
isProducer
  ? (producerTransport = transport)
  : (consumerTransport = transport);

👉 이후:

  • producerTransport.produce(...)
  • consumerTransport.consume(...)

에서 사용됨


Send → Recv 순서로 생성

typescript
awaitcreateTransportPromise(true);
awaitcreateTransportPromise(false);

순차 생성 이유

  • 디버깅 쉬움
  • 시그널링 충돌 방지
  • mediasoup 예제에서도 동일 패턴

(병렬도 가능하지만 복잡도 ↑)


SFU와 협상해서
“미디어가 오갈 고속도로(Send/Recv Transport)”를
브라우저에 깔아주는 함수

태그

#aurora#Next.js#WebRTC