SFU 연결
거진 2달 가량의 대기시간이 끝나고 백엔드 쪽에서 어느정도 SFU 서버 로직이 테스트가 가능하다고 해서 해당 테스트 코드를 요청하여 받아봤다.
typescriptasync 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)
typescriptconstcreateTransportPromise = (isProducer) => {
returnnewPromise((resolve, reject) => {
왜 Promise로 감싸냐?
- Transport 생성은 서버 응답을 기다려야 하는 비동기 작업
await으로 순서 제어하려고
서버에 Transport 생성 요청
typescriptsend('create-webrtc-transport', { channelPk });
“이 채널에서 사용할 WebRTC Transport 하나 만들어줘”
서버에서는:
- ICE
- DTLS
- IP/PORT
- role(client/server)
같은 네트워크 레벨 정보를 생성함
webrtc-transport-created 응답 대기
typescriptconstonMessage = (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 생성
typescriptconst transport = isProducer
? device.createSendTransport(data)
: device.createRecvTransport(data);
- 이 시점부터 mediasoup-client가 Transport를 관리
- 아직 연결(connect)은 안 됨
- 단지 “설계도 기반 객체 생성”
transport.on("connect") – DTLS 연결 단계
typescripttransport.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 이벤트 처리
typescriptif (isProducer) {
transport.on('produce', ...)
}
왜 SendTransport만 produce 이벤트가 있나?
- RecvTransport는 소비만 함
- Produce는 “내가 미디어를 보낸다”는 행위
produce 이벤트의 정확한 의미
typescripttransport.produce({ track, appData })
이걸 호출하면
mediasoup-client가 자동으로 produce 이벤트를 발생시킴
typescripttransport.on('produce',({ kind, rtpParameters, appData }, callback) => {
서버에 Producer 생성 요청
typescriptsend('produce', {
transportId: transport.id,
channelPk,
kind,
rtpParameters,
});
“이 Transport 위에 Producer 하나 만들어줘
이 Producer는 audio/video고
이런 RTP 설정을 쓸 거야”
produced 응답 대기
typescriptif (data.event ==='produced') {
callback({id: data.producerId });
}
- mediasoup-client에게:
“서버에서 Producer 생성 완료됐어
이게 네 Producer ID야”
이제야 Producer 객체가 완전히 살아남
Transport 저장
typescriptisProducer ? (producerTransport = transport) : (consumerTransport = transport);
👉 이후:
producerTransport.produce(...)consumerTransport.consume(...)
에서 사용됨
Send → Recv 순서로 생성
typescriptawaitcreateTransportPromise(true);
awaitcreateTransportPromise(false);
순차 생성 이유
- 디버깅 쉬움
- 시그널링 충돌 방지
- mediasoup 예제에서도 동일 패턴
(병렬도 가능하지만 복잡도 ↑)
SFU와 협상해서
“미디어가 오갈 고속도로(Send/Recv Transport)”를
브라우저에 깔아주는 함수
