WebRTC의 ICE Candidate 처리 순서 문제

 

간헐적으로 WebRTC 연결이 안되는 경우가 있었습니다.

시그널링 서버를 통해 offer, answer, candidate 메세지를 잘 주고 받은 이력도 확인하였습니다. 
특이한 점은 발견하지 못했습니다.
서버에는 문제가 없었기에 프론트엔드를 확인해 보았습니다. 


아래와 같은 오류가 확인되네요. 

Failed to execute 'addIceCandidate' on 'RTCPeerConnection': The remote description is null

간헐적으로 발생하니 해당 에러를 발견하기 까지 꽤 긴 시간을 할애하였습니다. (프론트엔드에서 발생하는 예외도 서버에서 로그 관리를 해야 하는 이유이기도 합니다.)

오류 메세지를 읽어보면 remote description이 null이라서 addIceCandidate 을 실행하지 못하였다고 합니다.

어떤 의미일까요?

ICE 후보가 먼저 도착하고 setRemoteDescription()이 아직 호출되지 않은 경우가 발생할 수 있습니다.

 

시그널링 연동 흐름은 다음과 같을 거라 예상하지만...

 

실상은 다음과 같은 흐름으로 될 수도 있습니다.

 

The remote description is null 오류가 발생되는 원인은 다음과 같이 해석할 수 있겠네요.

 

caller : 통화를 시작하는 피어
callee : 통화를 수락하는 피어
caller : answer 메세지를 받기 전에 candidate 메세지를 받아서 발생
callee : offer 메세지를 받기 전에 candidate 메세지를 받아서 발생

 

시그널링 과정에서 offer, answer, candiate 메세지가 순서대로 전달되지 않는 이유가 무엇일까요?
아래 코드는 offer를 생성하는 javascript 입니다.

let createOffer = async function (pc, receiverSessionId) {
    let mediaConstraints = {
        offerToReceiveAudio: true,
        offerToReceiveVideo: true,
        iceRestart: true,
        voiceActivityDetection: true
    };

    const offer = await pc.createOffer(mediaConstraints);
    await pc.setLocalDescription(offer);
    APIHandler.offer(offer.sdp, pc.senderSessionId, receiverSessionId);
};

let offer = function (sdp, senderSessionId, receiverSessionId) {
    callAjax({
        url: '/tt/offer',
        type: "POST",
        contentType: 'application/json',
        async: true,
        data: JSON.stringify({
            senderSessionId: senderSessionId,
            receiverSessionId: receiverSessionId,
            message: {
                type: "offer",
                sdp: sdp
            }
        })
    });
};

pc.addEventListener('icecandidate', event => handleLocalCandidate(pc, event, receiverSessionId));

let handleLocalCandidate = function (pc, event, receiverSessionId) {
	console.log(`created candidate message : ${JSON.stringify(event.candidate)}`);
	APIHandler.candidate(event.candidate, pc.senderSessionId, receiverSessionId);
};


createOffer 함수를 보시면 setLocalDescription을 호출하는 구간이 있습니다.
setLocalDescription 함수가 호출되는 순간부터 ICE 프로세스가 시작되어 네트워크 경로를 탐색하여 후보 데이터를 찾아내기 시작합니다. 후보를 찾아내면 RTCPeerConnection의 icecandidate 이벤트가 발생하게 되어 시그널링 서버로 전송하게 됩니다.

문제를 찾은 것 같네요.

candidate 메세지보다 offer 메세지를 먼저 서버로 전송하면 되니 아래처럼 바꿔볼까요?

let createOffer = async function (pc, receiverSessionId) {
    let mediaConstraints = {
        offerToReceiveAudio: true,
        offerToReceiveVideo: true,
        iceRestart: true,
        voiceActivityDetection: true
    };

    const offer = await pc.createOffer(mediaConstraints);
    APIHandler.offer(offer.sdp, pc.senderSessionId, receiverSessionId); // setLocalDescription 위에 구현
    await pc.setLocalDescription(offer);   
};

 

APIHandler.offer 구간은 비동기로 처리됩니다. offer 요청에 대한 결과를 받을 때까지 메인 스레드는 대기하지 않기에  그 다음 코드를 실행합니다.
offer 메세지를 서버에 전송하기 전에 await pc.setLocalDescription(offer); 코드가 실행되어 candiate 메세지가 먼저 전송될 가능성이 있습니다.

그럼 jquery에서 async: true를 false로 바꿔서 비동기 방식이 아닌 동기 방식으로 바꾸면 어떨까요?
이렇게 처리를 하면 메인 스레드는 offer 요청이 완료될 때까지 대기하게 되므로 사용자 경험을 저하시킵니다. offer 요청의 응답이 오래 걸리면 브라우저가 멈춘 것처럼 사용자가 느낄 수 있습니다. 또는 화면상에서 버튼을 클릭했는데 먹통이 되는 것 처럼 보일수도 있습니다.

그럼 APIHandler.offer 함수를 호출할 때 콜백 함수를 전달하고, 콜백 함수 내에서 await pc.setLocalDescription(offer); 를 처리하면 어떨까요?
순서는 보장할 수 있지만 ICE 후보 탐색이 늦게 시작될 수 있고, 결국 WebRTC 연결 시간이 늘어날 수 있습니다.

해결 방법은 ICE 후보 큐 관리 방식을 도입해야 합니다.
저는 아래와 같이 코드를 작성하였습니다.

let createAnswer = async function (pc, offer, receiverSessionId) {
    await pc.setRemoteDescription(offer);
    await pc.setRemoteDescription(new RTCSessionDescription(offer));

    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    APIHandler.answer(answer.sdp, pc.senderSessionId, receiverSessionId);
    await processRemoteCandidates(pc);
};

let handleRemoteAnswer = async function (pc, answer) {
    await pc.setRemoteDescription(new RTCSessionDescription(answer));
    await processRemoteCandidates(pc);
};

async function processRemoteCandidates(pc) {
    while (pc.remoteCandidates && pc.remoteCandidates.length > 0) {
        const candidate = pc.remoteCandidates.shift(); // 큐에서 원자적으로 추출 (동시성 문제 방지)
        if (!candidate) {
            await pc.addIceCandidate(endOfCandidate);
        } else {
            await pc.addIceCandidate(candidate);
        }
    }
}

let handleRemoteCandidate = async function (pc, candidate) {
    console.log(`handle remote candidate message : ${JSON.stringify(candidate)}`);

    let remoteDesc = pc.remoteDescription;
    if (candidate && remoteDesc) {
        if (!candidate) {
            await pc.addIceCandidate(endOfCandidate);
        } else {
            await pc.addIceCandidate(candidate);
        }
    } else {
        if (!pc.remoteCandidates) {
            pc.remoteCandidates = [];
        }
        pc.remoteCandidates.push(candidate);
    }
};

 

서버로부터 candiate 메세지를 수신하면 handleRemoteCandidate 함수를 호출합니다.
handleRemoteCandidate 함수 내부에서는 remoteDescription 에 메세지가 추가되어 있는지를 확인합니다.
이미 메세지가 추가되어 있으면 addIceCandidate 에 전달 받은 candiate 메세지를 추가합니다.
remoteDescription에 메세지가 추가되어 있지 않으면 remoteCandidates 배열을 만들고 candiate 메세지를 추가합니다. (후보군 쌓아두기)

createAnswer 함수 구현부를 보시면 processRemoteCandidates 함수를 호출하고, 해당 함수는 remoteCandidates 에 candiate 메세지가 있다면 addIceCandidate 하는 처리를 진행합니다.
handleRemoteAnswer 함수도 createAnswer 함수와 동일하게 처리됩니다.

 

결론은 ICE 후보 큐 관리 방식을 도입해야 안정적인 WebRTC 스트리밍 연결이 가능해집니다.

'WebRTC' 카테고리의 다른 글

NAS에 TURN 서버 구축하기  (1) 2024.12.20
TURN 서버를 통한 Relay 통신 원리  (0) 2024.12.10
NAT 종류에 따른 홀펀칭  (3) 2024.11.12
NAT 종류  (0) 2024.11.03
WebRTC 테스트를 위한 Docker 환경 구성  (0) 2024.09.22