본문 바로가기

연구/멀티미디어통신

[webRTC] 웹RTC 예제로 화상 채팅 구현하기.

1. webRTC

 

RTC는 Real-Time Communications의 약자이다. 웹을 통해 웹어플리케이션이 비디오, 오디오 스트림 뿐만아니라 데이터를 중간의 서버가 없

 

이 직접 주고 받게할 수 있는 것이 목적이다.

 

webRTC의 장점은 브라우저 사이에 P2P로 연결되어 어떤 플러그인이나 써드파티 소프트웨어가 필요 없다. --> 스카이프 같은 통화 기능을 웹

 

에서 바로 사용가능하다!

2. 튜토리얼 채팅 구현하기

 

우선 나는 websocket과 nodejs 서버를 이용해서 다른 브라우저에 있는 2명의 client가 서로 채팅하기로 요청,응답해서 화상채팅하는 시나리오를 이용해서 구현하였다. 각각의 서버와 클라이언트는 다음과 같은 순서의 구현이 필요하다,

 

클라이언트 사이드

 

1. 나의 비디오 스트림 가져오기

2. RTCPeerConnection 을 이용해서 peer connection 하기

 

서버 사이드

 

1. 특정 클라이언트의 채팅 요청및 응답 이벤트 구현

 

 

------- webRTC tutorial 내의 서버측 필요 기능 --------

다른 말로, WebRTC는 4가지 종류의 서버측 기능들이 필요합니다:

  • 사용자 탐색과 통신.
  • Signaling.
  • NAT/firewall 탐색.
  • P2P 실패시의 중계서버들.

---------------------------------------

 

여기서 우리는 nodejs의 socket.io를 통해 사용자 탐색과 통신(??), signalling만 구현하게 됩니다.

 

실제로 tutorial에서도 사용자 탐색과 통신, signalling은 webRTC에 구현되있지 않고, 개발자가 선택적으로 원하는 방식을 취하면 된다고 합니다. codelab에서도 nodejs를 사용했습니다.

 

 

** 더 구체적인 webRTC signalling 정보


https://www.html5rocks.com/ko/tutorials/webrtc/infrastructure/

 

클라이언트 사이드 코드

1. 나의 비디오 스트림 가져오기

 

비디오 스트림 가져오는 코드는 엄청 쉽다.

 

우선 비디오 스트림을 보여줄 공간을 html, js에 정의한다.

<video playsinline id="left_cam" controls preload="metadata" autoplay></video>

html 내부의 video 태그 아마.. playsinline과 autoplay 특성을 적어줘야 했던걸로 기억한다..!

var localVideo = document.getElementById('left_cam');

html video 태그를 js로 가져온다.

navigator.mediaDevices.getUserMedia({
    audio: false,
    video: true
})
    .then(gotStream)
    .catch(function (e) {
        alert('getUserMedia() error: ' + e.name);
    });

navigator.mediaDevices 에서 getUserMedia를 통해 local stream을 가져오게 된다. 

 

이떄, audio, video 값을 true, false로 원하는 스트림을 가져올 수 있다. 스트림을 요청을 한 후, 성공하면 gotStream 함수를 호출한다.

function gotStream(stream) {
    console.log('Adding local stream.');
    localStream = stream;
    localVideo.srcObject = stream;
    sendMessage('got user media');
    if (isInitiator) {
        maybeStart();
    }
}

 

gotStream 함수는 전달받은 stream을 localVideo에 붙이게 된다. 그리고, isInitiator가 true이면 (isInitiator는 방을 최초로 만든사람인 경우 true가 된다.)

 

maybeStart 함수를 호출한다.

 

2. RTCPeerConnection 을 이용해서 peer connection 하기

function maybeStart() {
    console.log('>>>>>>> maybeStart() ', isStarted, localStream, isChannelReady);
    if (!isStarted && typeof localStream !== 'undefined' && isChannelReady) {
        console.log('>>>>>> creating peer connection');
        createPeerConnection();
        pc.addStream(localStream);
        isStarted = true;
        console.log('isInitiator', isInitiator);
        if (isInitiator) {
            doCall();
        }
    }
}

isStart는 최초 false로 저장되있고, maybeStart 함수가 처음 실행되는 경우 true로 바뀐다.

 

maybeStart는 createPeerConnection 함수로 peerconnection을 만들어 주고, 나의 peerconnection에 localStream을 붙인다.

 

그리고 isInitiator인 경우, (방을 만들었으면) doCall 함수를 통해 같은 방에 있는 client에게 rtc 요청을 하게 됩니다.

 

function createPeerConnection() {
    try {
        pc = new RTCPeerConnection(pcConfig);
        pc.onicecandidate = handleIceCandidate;
        pc.onaddstream = handleRemoteStreamAdded;
        pc.onremovestream = handleRemoteStreamRemoved;
        console.log('Created RTCPeerConnnection');
    } catch (e) {
        console.log('Failed to create PeerConnection, exception: ' + e.message);
        alert('Cannot create RTCPeerConnection object.');
        return;
    }
}

createPeerConnection 함수에서는 pcConfig 값으로 pc(peerconnection)을 만들어 줍니다.

 

그리고, pc에 icecandidate, addstream, removestrea 이벤트를 추가해 줍니다.

 

icecandidate는 서로 통신 채널을 확립하기 위한 방법 입니다.

 

onaddstream은 remote 스트림이 들어오면 발생하는 이벤트입니다.

 

function handleRemoteStreamAdded(event) {
    console.log('Remote stream added.');
    remoteStream = event.stream;
    console.log(event);
    remoteVideo.srcObject = remoteStream;
}

remoteStream이 들어오면, localVideo와 마찬가지로 remoteVideo에 remoteStream을 붙여줍니다.

 

***pcConfig

var pcConfig = {
    'iceServers': [{
        urls: 'stun:stun.l.google.com:19302'
    },
    {urls: "turn:numb.viagenie.ca",
    credential: "muazkh",
    username: "webrtc@live.com"}
    ]};

pcConfig에는 stun, turn 서버를 적게 되는데, 간단히 설명하면

 

rtc 중계가 끊어질 것을 대비한 임시 서버라고 보면 간단합니다.

 

https://gist.github.com/yetithefoot/7592580

 

위의 사이트를 통해 public turn + stun 서버의 리스트를 확인할 수 있습니다.

*********************

 

function doCall() {
    console.log('Sending offer to peer');
    pc.createOffer(setLocalAndSendMessage, handleCreateOfferError);
}

doCall 함수에서는 pc.createOffer를 통해 통신 요청을 하게 됩니다.

 

socket.on('message', function (message) {
    console.log('Client received message:', message);
    if (message === 'got user media') {
        maybeStart();
    } else if (message.type === 'offer') {
        if (!isInitiator && !isStarted) {
            maybeStart();
        }
        pc.setRemoteDescription(new RTCSessionDescription(message));
        doAnswer();
    } else if (message.type === 'answer' && isStarted) {
        pc.setRemoteDescription(new RTCSessionDescription(message));
    } else if (message.type === 'candidate' && isStarted) {
        var candidate = new RTCIceCandidate({
            sdpMLineIndex: message.label,
            candidate: message.candidate
        });
        pc.addIceCandidate(candidate);
    } else if (message === 'bye' && isStarted) {
        handleRemoteHangup();
    }
});

다음과 같은 web socket을 통해 서로 메세지를 주고 받으면서 연결을 확립하게 됩니다.

 

전체 코드

var isChannelReady = false;
var isInitiator = false;
var isStarted = false;
var localStream;
var pc;
var remoteStream;

var remoteVideo = document.getElementById('right_cam');
var localVideo = document.getElementById('left_cam');

var pcConfig = {
    'iceServers': [{
        urls: 'stun:stun.l.google.com:19302'
    },
    {urls: "turn:numb.viagenie.ca",
    credential: "muazkh",
    username: "webrtc@live.com"}
    ]};

var sdpConstraints = {
    offerToReceiveAudio: true,
    offerToReceiveVideo: true
};

localVideo.addEventListener("loadedmetadata", function () {
    console.log('left: gotStream with width and height:', localVideo.videoWidth, localVideo.videoHeight);
});

remoteVideo.addEventListener("loadedmetadata", function () {
    console.log('right: gotStream with width and height:', remoteVideo.videoWidth, remoteVideo.videoHeight);
});

remoteVideo.addEventListener('resize', () => {
    console.log(`Remote video size changed to ${remoteVideo.videoWidth}x${remoteVideo.videoHeight}`);
});

socket.on('connect', function () {
    socket.emit("onCollabo", socket.id);
});

socket.on('collabo', function (room) {
    socket.emit('create or join', room);
    console.log('Attempted to create or  join room', room);
});


socket.on('created', function (room) {
    console.log('Created room ' + room);
    isInitiator = true;
});

socket.on('full', function (room) {
    console.log('Room ' + room + ' is full');
});

socket.on('join', function (room) {
    console.log('Another peer made a request to join room ' + room);
    console.log('This peer is the initiator of room ' + room + '!');
    isChannelReady = true;
});

socket.on('joined', function (room) {
    console.log('joined: ' + room);
    isChannelReady = true;
});

socket.on('log', function (array) {
    console.log.apply(console, array);
});

function sendMessage(message) {
    console.log('Client sending message: ', message);
    socket.emit('message', message);
}

// This client receives a message
socket.on('message', function (message) {
    console.log('Client received message:', message);
    if (message === 'got user media') {
        maybeStart();
    } else if (message.type === 'offer') {
        if (!isInitiator && !isStarted) {
            maybeStart();
        }
        pc.setRemoteDescription(new RTCSessionDescription(message));
        doAnswer();
    } else if (message.type === 'answer' && isStarted) {
        pc.setRemoteDescription(new RTCSessionDescription(message));
    } else if (message.type === 'candidate' && isStarted) {
        var candidate = new RTCIceCandidate({
            sdpMLineIndex: message.label,
            candidate: message.candidate
        });
        pc.addIceCandidate(candidate);
    } else if (message === 'bye' && isStarted) {
        handleRemoteHangup();
    }
});

navigator.mediaDevices.getUserMedia({
    audio: false,
    video: true
})
    .then(gotStream)
    .catch(function (e) {
        alert('getUserMedia() error: ' + e.name);
    });

function gotStream(stream) {
    console.log('Adding local stream.');
    localStream = stream;
    localVideo.srcObject = stream;
    sendMessage('got user media');
    if (isInitiator) {
        maybeStart();
    }
}

var constraints = {
    video: true
};

console.log('Getting user media with constraints', constraints);

if (location.hostname !== 'localhost') {
    requestTurn(
        "stun:stun.l.google.com:19302"
    );
}

function maybeStart() {
    console.log('>>>>>>> maybeStart() ', isStarted, localStream, isChannelReady);
    if (!isStarted && typeof localStream !== 'undefined' && isChannelReady) {
        console.log('>>>>>> creating peer connection');
        createPeerConnection();
        pc.addStream(localStream);
        isStarted = true;
        console.log('isInitiator', isInitiator);
        if (isInitiator) {
            doCall();
        }
    }
}

window.onbeforeunload = function () {
    sendMessage('bye');
};

/////////////////////////////////////////////////////////

function createPeerConnection() {
    try {
        pc = new RTCPeerConnection(pcConfig);
        pc.onicecandidate = handleIceCandidate;
        pc.onaddstream = handleRemoteStreamAdded;
        pc.onremovestream = handleRemoteStreamRemoved;
        console.log('Created RTCPeerConnnection');
    } catch (e) {
        console.log('Failed to create PeerConnection, exception: ' + e.message);
        alert('Cannot create RTCPeerConnection object.');
        return;
    }
}

function handleIceCandidate(event) {
    console.log('icecandidate event: ', event);
    if (event.candidate) {
        sendMessage({
            type: 'candidate',
            label: event.candidate.sdpMLineIndex,
            id: event.candidate.sdpMid,
            candidate: event.candidate.candidate
        });
    } else {
        console.log('End of candidates.');
    }
}

function handleCreateOfferError(event) {
    console.log('createOffer() error: ', event);
}

function doCall() {
    console.log('Sending offer to peer');
    pc.createOffer(setLocalAndSendMessage, handleCreateOfferError);
}

function doAnswer() {
    console.log('Sending answer to peer.');
    pc.createAnswer().then(
        setLocalAndSendMessage,
        onCreateSessionDescriptionError
    );
}

function setLocalAndSendMessage(sessionDescription) {
    pc.setLocalDescription(sessionDescription);
    console.log('setLocalAndSendMessage sending message', sessionDescription);
    sendMessage(sessionDescription);
}

function onCreateSessionDescriptionError(error) {
    trace('Failed to create session description: ' + error.toString());
}

/*turn 서버 요청 CORS 문제 발생*/
function requestTurn(turnURL) {
    var turnExists = true;
    // for (var i in pcConfig.iceServers) {
    //     if (pcConfig.iceServers[i].urls.substr(0, 5) === 'stun:') {
    //         turnExists = true;
    //         turnReady = true;
    //         console.log("Exist stun server");
    //         break;
    //     }
    // }
    if (!turnExists) {
        // console.log('Getting TURN server from ', turnURL);
        // // No TURN server. Get one from computeengineondemand.appspot.com:
        // var xhr = new XMLHttpRequest();
        // xhr.onreadystatechange = function() {
        //     if (xhr.readyState === 4 && xhr.status === 200) {
        //         var turnServer = JSON.parse(xhr.responseText);
        //         console.log('Got TURN server: ', turnServer);
        //         pcConfig.iceServers.push({
        //             'urls': 'turn:' + turnServer.username + '@' + turnServer.turn,
        //             'credential': turnServer.password
        //         });
        //         turnReady = true;
        //     }
        // };
        //
        // xhr.open('GET', turnURL, true);
        // xhr.send();
    }
}

function handleRemoteStreamAdded(event) {
    console.log('Remote stream added.');
    remoteStream = event.stream;
    console.log(event);
    remoteVideo.srcObject = remoteStream;
}

function handleRemoteStreamRemoved(event) {
    console.log('Remote stream removed. Event: ', event);
}

function hangup() {
    console.log('Hanging up.');
    stop();
    sendMessage('bye');
}

function handleRemoteHangup() {
    console.log('Session terminated.');
    stop();
    isInitiator = false;
}

function stop() {
    isStarted = false;
    pc.close();
    pc = null;
}

위의 적지 않은 코드는 로그 기능과 함수이름을 통해 쉽게 알 수 있는 부분, 혹은 제가 구현하면서 이해할 필요 없을 것 같은 부분은 적지 않았습니다

 

서버 사이드 코드

서버 측은 각자 구현방식에 따라 달라질 것 같습니다.

 

socket.on('collabo', function (room) {
    socket.emit('create or join', room);
    console.log('Attempted to create or  join room', room);
});

서버에서 간단하게 request, response 채널을 통해 한개의 브라우저가 임의의 room을 생성하여 request를 하면, 타겟 브라우저는 response를 하고, response가 참이라면(수락), 응답해서 설정한 임의의 room에 들어가도록 구현하였습니다.

 

이때 저는 response와 request를 받을 때, broadcast를 사용했지만, 실제 어플리케이션을 만들 때에는 타겟이 되는 브라우저의 socket id나 특정 값을 통해 response, request 요청을 해야할 것 같습니다.

 

socket.on('connect', function () {
    socket.emit("onCollabo", socket.id);
});

클라이언트측 onCollabo 소켓 채널 소켓이 열리면, 채널에 새로운 브라우저(화상채팅)가 열렸다고 알려주고, 서버는 이를 통해 create or join 채널을 통해 room에 들어가도록 합니다.

 

socket.on('create or join', function (room) {
    log('Received request to create or join room ' + room);

    var clientsInRoom = io.sockets.adapter.rooms[room];
    var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0;
    log('Room ' + room + ' now has ' + numClients + ' client(s)');

    if (numClients === 0) {
        socket.join(room);
        log('Client ID ' + socket.id + ' created room ' + room);
        socket.emit('created', room, socket.id);
    } else if (numClients === 1) {
        log('Client ID ' + socket.id + ' joined room ' + room);
        io.sockets.in(room).emit('join', room);
        socket.join(room);
        socket.emit('joined', room, socket.id);
        io.sockets.in(room).emit('ready', room);
        socket.broadcast.emit('ready', room);
    } else { // max two clients
        socket.emit('full', room);
    }
});

create or join 채널은 다음과 같이 방의 client 수(최대2명)을 조절해 주고, room 내의 client(peer)들 간의 통신이 이루어 질 수 있도록 이벤트 채널을 발생시켜 파라미터 설정을 해주게 됩니다. (isInitiator, isChannelReady 등등)

 

 

 

실행

nodejs 실행

 

node js를 실행 시켜 줍니다. 저는 webStorm을 사용 했지만 그렇지 않은경우 cmd 창에서 node app.js를 입력하면 됩니다.

 

 

카메라 권한 요청

 

그럼 다음과 같은 권한이 요청 됩니다. 이때 pc에 카메라(웹캠)가 없으면 try catch에 의해 오류가 발생합니다.. getMedia 어쩌구..

 

**** https 서버사용  ****

 

http 서버를 사용하게 되면 카메라 권한을 가져올 수 없어서(보안 때문에) 오류가 발생 합니다.

 

따라서 nodejs 서버를 만들 때 https로 만들어 주어야합니다.

 

그런데 https 를 만들기 위해서는 인증서가 필요합니다.

 

윈도우에서도 발급받을 수 있지만.. 리눅스에서 발급받는것이 훨씬 간단합니다..!

 

http://blog.saltfactory.net/implements-nodejs-based-https-server/

 

위의 링크를 통해 간단하게 nodejs 자체 https 서버를 만들 수 있습니다.

 

 

 

 

 

결과 1번 컴퓨터와 2번 노트북 webcam 간 화상이 가능한 것을 확인할 수 있었습니다.

 

 

 

후기

 

자바 스크립트, html을 거의 해보지 않아서 이것저것 하는데 많이 힘들었다.

 

특히 nodejs 만들때, 라우팅하고, 프로젝트 구조 살펴보고 npm 사용하고..  서버 코딩에 관해서 많은 부분을 공부했다.

 

또 자바 스크립트의 문법을 거의 모르다 보니까 직접 구현하는게 어려워 거의 코드를 가져다 썻다.

 

방학 내에 자바 스크립트와 html 공부좀 해야겠따 ㅠ.ㅠ 지난번에 쟝고도 사용해본 적이 있는데 확실히 백엔드 프론트엔드 둘다 자바 스크립

 

트를 사용하니 훨씬 일관성..?이 있어서 편했다. 또 html 만들 떄 쟝고에서는 어떤 템플릿 엔진을 사용했던것 같은데, 이번에는 쌩 html을  

 

이용하다보니 코드가 많이 못생겼다.. nodejs 프로젝트 path나 routing 부분에 관련에서도 좀 더 공부가 필요 했다.

 

다음에는 webRTC 데이터채널을 이용해서 데이터를 교환하는 웹어플리케이션을 구현해봐야겠다!

 

 

출처 및 도움

 

https://www.html5rocks.com/ko/tutorials/webrtc/basics/#ice

기본 튜토리얼

 

https://www.html5rocks.com/ko/tutorials/webrtc/infrastructure/

signalling 방법

 

https://github.com/muaz-khan/WebRTC-Experiment/blob/master/demos/remote-stream-recording.html

그냥 소스코드..

 

https://codelabs.developers.google.com/codelabs/webrtc-web/#0

webRTC codelab 정말 자세하게 설명되있다. 97퍼센트는 여기 코드를 사용했다...!

 

https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API

webRTC API

 

https://www.tutorialspoint.com/webrtc/webrtc_rtcpeerconnection_apis.htm

코드 예제

 

https://stackoverflow.com/questions/22233980/implementing-our-own-stun-turn-server-for-webrtc-application

turn, stun 서버 연결 stackoverflow 질문..