30 September 2020

TL;DR

이 시리즈에서는 직접 구현해나가면서 경험한 문제와 해결 방법들을 순서대로 기록하기 때문에 좀 돌아갈 수도 있다.
빠르게 완성된 코드를 먼저 보고 직접 분석하고 싶다면, 맨 마지막에 있는 깃헙 주소를 참조하기 바란다.

WebRTC는 간단한 API를 통해 브라우저와 모바일 애플리케이션에 실시간 통신 (RTC) 기능을 제공하는 무료 개방형 프로젝트입니다. WebRTC 구성 요소는 이러한 목적에 가장 적합하도록 최적화되었습니다.
- webrtc.org

1:1 비디오 중계 서비스의 문제

철수와 영희가 전형적인 http나 웹 소켓 통을 이용한 서비스를 이용해서 메시지를 주고받는 상황을 모델링해보자. 일반적으로 사용자간의 데이터 전송을 하기 위해서는 고정된 접근경로(ip / domain)을 가진 웹서버에 여러 사용자가 접속하여 서로 통신을 한다.

sequenceDiagram Participant 철수 Participant server Participant 영희 철수->>server: 영희야(18byte) server->>영희: 영희야(18byte) 영희-->>server: 꺼져(6byte) server-->>철수: 꺼져(6byte) Note over 철수: send: 18byte, recieve: 6byte Note over 영희: send: 6byte, recieve: 18byte Note over server: send: 24byte, recieve: 24byte

실제 주고 받는 정보의 양에 비해 중계서버의 부담이 상당하다. 만약 실시간 비디오와 같은 대량의 데이터가 이러한 방식으로 오간다면 상당한 비용이 필요할 뿐 아니라 서버 컨디션에 따라 서비스 품질에도 상당한 영향이 있을것이다. 반면 데이터를 사용자간에 직접 주고받을 수 있다면 (peer-to-peer) 서버가 부담하는 트래픽은 거의 없어질 수 있고 많은 비용 절감과 함께 사용자 입장에서도 품질 향상을 이룰 수 있다.

sequenceDiagram Participant 철수 Participant server Participant 영희 철수->>영희: 영희야(18byte) 영희-->>철수: 꺼져(6byte) Note over 철수: send: 18byte, recieve: 6byte Note over 영희: send: 6byte, recieve: 18byte

하지만 이를 실제 구현하기 위한 여러가지 기술적 과제들은 간단한 문제가 아니며, 브라우저와 서버간의 동기/비동기적 통신으로 대표되는 전통적인 웹브라우징 방식으로는 해결하기 쉽지 않다. 다행히 이를 해결하기 위한 공통적인 구현이 WebRTC라는 이름의 오픈소스 웹 표준으로 정의되어 있고, 현재 거의 모든 모던 브라우저에 구현되어 있어, 간단한 javascript API 사용만으로 접근할 수 있다.

다만 이러한 API 를 사용한다 해도 실제 구현시 발생하는 여러가지 문제들을 best practice로 해결하기란 쉽지 않은 일이며, 잘 구현된 라이브러리나 웹서비스 를 사용하는 것이 현명할 수도 있다. 그러나 관련 라이브러리를 사용한다 하더라도 그 기반을 이루고 있는 기술과 기본적인 프로토콜에 대한 이해는 필수적이다. 그러므로 WebRTC API를 직접 이용해 가장 단순한 구현부터 시작해보기로 한다.

이 스터디의 목표는 웹캠 화면을 브라우저에 띄우는 것부터 시작해서 AWS를 통해 모바일 크로스 플랫폼 간에 1:1 p2p통신을 이용하여 영상 및 채팅 메시지를 전송할수 있도록 구현하는 것 까지이다.

클라이언트 어플리케이션 구성

1:1 화상채팅을 연결하는 단계의 로직은 요청하는 쪽과 받는쪽의 로직으로 나눌 수 있다. 완성단계에서는 하나의 화면으로 합쳐지겠지만 과정의 이해를 쉽게 하기위해 처음에는 caller와 callee 두 화면으로 나누어서 구성한다. Boiler Plates 코드를 최소화 하기 위해 별도의 번들러를 이용하지 않고 단순한 스태틱 파일로 작성할 것이다.

/
└─ client/
   ├ caller.html
   ├ caller.js
   ├ callee.html
   └ callee.js

이 스태틱 페이지들은 나중에 구현할 Node.js 서버를 통해서 서빙될 예정이다.

화면에 웹캠 스트리밍 표시하기

복잡한 기술적 사항을 다루느라 흥미를 잃기 전에 바로 화면부터 띄워보자.

caller.html, callee.html 동일하게

<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <title>WebRTC</title>
</head>
<body>
<!--내 얼굴이 나올 video 요소-->
<video id="localVideo" autoplay width="480px"></video>
<!--상대방의 얼굴이 나올 video 요소-->
<video id="remoteVideo" autoplay width="480px"></video>
<script src="caller.js(또는 callee.js)"></script>
</body>
</html>

caller.js, callee.js 동일하게

(async () => {
  const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
  document.getElementById('localVideo').srcObject = mediaStream;

  // 이후 코드는 모두 이 async 함수 안에 작성한다.
})();

위와 같이 작성하고 caller.html 또는 callee.html 을 브라우저로 띄우면 사용자에게 비디오 전송 허용 여부를 묻고 이후 웹캠 화면이 브라우저에 나타나기 시작한다.

RTC 연결 생성하기 (1)

WebRTC 통신은 RTCPeerConnection 객체를 통해서 이루어진다. caller, callee 양쪽에서 동일하게 다음과 같이 생성하고 addTrack 메쏘드를 통해 상대편에게 전송할 미디어 스트림을 연결해줄 수 있다.

caller.js, callee.js 동일하게

/*앞의 async 함수 내부에*/
const rtcPeerConnection = new RTCPeerConnection();
mediaStream.getTracks().forEach(track => rtcPeerConnection.addTrack(track));

(간혹 addTrack 대신 addStream을 이용한 예제가 있는데 deprecate되었다. api가 최근에 구현된 일부 브라우저에서 작동하지 않는다.)

이제 양쪽 클라이언트의 RTCPeerConnection는 서로 어떤 형식의 데이터를 주고받을 지를 협의(negotiation)해야 한다. 이러한 negotiation이 필요하다고 판단될 시, 즉 연결 초기 셋업시(e.g. addTrack), 또는 네트워크 환경 변화(e.g. 모바일 기기 ip변경)등으로 재협상이 필요할때 브라우저는 negotiationneeded 이벤트를 발생시킨다.

SDP offer/answer 교환 단계

negotiationneeded 이벤트는 윗 문단에서 설명한 것 처럼 어떤 형식의 데이터를 주고받을 지를 협의해야 한다는 소리니까, 이 이벤트의 핸들러에서 이 협의 절차를 수행해주어야 하겠다.

이 데이터를 교환하기 위한 절차는 SDP - Session Description Protocol 라는 프로토콜로 정의되어 있다. SDP는 양 peer가 SDP offer와 SDP answer라 불리는 특정 형식의 데이터를 주고받음으로써 수행된다.

sequenceDiagram caller->>caller: createOffer() Note left of caller: LocalDescription = offer caller->>callee: offerSDP Note right of callee: RemoteDescription = offer callee->>callee: createAnswer() Note right of callee: LocalDescription = answer callee-->>caller: answerSDP Note left of caller: RemoteDescription = answer

다이어그램을 보면 caller측과 callee측이 서로 통신을 하는데 이 통신 자체에 대한 구현은 나중으로 미룰 것이다. 일단 아래 예제는 클라이언트 간 메시지 교환을 위한 onMessage, sendMessage 메소드가 구현되어 있다고 가정하고 작성한다.

메시지 교환 순서의 이해를 돕기 위해 caller, callee 사이에 메시지가 오가는 순서대로 작성한다.

⚠️ 각각 어느 파일에 작성해야 하는지에 주의

caller.js

/*앞의 코드에 이어서*/ 
rtcPeerConnection.addEventListener('negotiationneeded', async () => {
  // 이쪽편에 접속하기 위한 정보를 생성해서 local session description에 설정
  const sdpOffer = await rtcPeerConnection.createOffer(); // { type: 'offer', sdp: '...' }
  rtcPeerConnection.setLocalDescription(sdpOffer);
  
  // 상대편에게 전송 -> callee.js, onMessage('SDP')
  sendMessage('SDP', sdpOffer) 
});

callee.js

/*앞의 코드에 이어서*/
onMessage('SDP', async sdpOffer => {  
  // caller 에서 넘어온 sdp 데이터를 이용해서 remote session description을 설정  
  await rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(sdpOffer)); 

  // answer 타입의 session description을 생성해서 local에 설정
  const sdpAnswer = await rtcPeerConnection.createAnswer();
  await rtcPeerConnection.setLocalDescription(sdpAnswer);

  // caller에게 응답을 되돌려줌
  sendMessage('SDP', sdpAnswer);
}); 

다시 caller.js

onMessage('SDP', sdpAnswer => { 
  // callee 에서 넘어온 sdp 데이터를 이용해서 remote session description을 설정  
  rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(sdpAnswer)); 
});

rtcPeerConnection.setRemoteDescription를 호출하여 상대편 브라우저의 SDP 정보를 지정해주면 원격 peer의 데이터 형식을 알게 되므로 remote트랙이 추가되어 track 이벤트가 발생한다.

caller.js, callee.js 동일하게

/*앞의 코드에 이어서*/
rtcPeerConnection.addEventListener('track', e => {
  document.getElementById('remoteVideo').srcObject = new MediaStream([e.track])
});

이 이벤트 객체에 담긴 track을 MediaStream 객체에 담아서 <video /> 요소의 srcObject로 지정하면 이후 connection이 성립 한 후에 stream을 타고 전송되어 온 remote video data를 화면에 송출할 수 있다.

하지만 아직 몇가지 남은 숙제가 있다. 먼저 앞서 언급했듯이 onMessage, sendMessage 두 메소드가 아직 구현되지 않았다. 이 두 메소드의 역할과 구현 방식에 대해서 다음 챕터에서 알아보자.

Signaling Server 구현

위 코드에서 보이는바와 같이 WebRTC로 p2p 통신을 시작하기 전, 서로 어떻게 정보를 주고받아야 할지 협상을 거치는 단계에서도 각 클라이언트간에 통신이 필요하다. 현재 구현되지 않은 onMessage, sendMessage 메소드가 이 부분이다.

이 메소들이 호출되는 상황에서는 아직 양 클라이언트들은 상대방에게 어떻게 접속해야할지 알지 못하므로 직접 통신을 할 수 없다. p2p통신이 불가능한 상황에서 이 메시지들은 어떻게 주고 받아야 할까? signaling server라 불리는 서버가 그 역할을 한다. 결국 본격적인 비디오 데이터가 오가기 전까지는 본 포스팅 맨 위에 있는 다이어그램과 같이 중계서버를 통한 통신을 해야만 한다.

이 signaling server는 서비스 성격에 따라서 매우 다양한 역할을 수행해야 하기 때문에 표준으로 정해진 구현이 없고, 서비스의 요구사항과 상황에 따라 구현하게 된다. 일반적으로 서버와 브라우저간 양방향 메시지 전달이 필요하기 때문에 WebSocket등을 이용해서 구현한다. (단순한 http REST API에 polling을 하거나 웹 푸시 방식으로도 가능은 할 것 같다.)

이 단계에서는 Socket.io를 사용하여 클라이언트끼리 메시지를 상호 전송해주는 단순한 Node.js 서버와, 그에 상응하는 onMessage, sendMessage 메소드를 브라우저단에 구현할 것이다.

프로젝트 root directory에서 다음 커맨드를 실행하여 npm socket.io package를 설치하고 server/index.js 파일을 작성한다.

> npm init
> npm install socket.io node-static

directory

/  
├ package.json
│
├ server/
│└index.js   ## 추가됨
│
└ client/
  ├message.js ## 추가됨
  ├ ...

server/index.js

const static = require('node-static'); 
const http = require('http');
const socketIO = require('socket.io');

// client 파일들을 같은 서버에서 서빙한다.
const file = new static.Server('../client');
const app = http
  .createServer(file.serve.bind(file))
  .listen(3000);

// 모든 클라이언트에서 발송된 메시지를 다른 모든 클라이언트에게 무차별적으로 전송한다.
socketIO
  .listen(app).sockets
  .on('connection', socket => (
    socket.on('message', message => socket.broadcast.emit('message', message))
  ));

caller.html, callee.html

<!--body 맨 하단-->
<script src="/socket.io/socket.io.js"></script> <!--서버측 socket.io에서 서빙되는 socket.io.js를 추가-->
<script src="caller(또는 callee).js" type="module"></script> <!--type="module" 추가-->

이제부터 위에서 작성한 웹서버를 통해서 접근할 것이기 때문에 es6 module import를 사용할 수 있다.
html파일들에 socket.io.js 의존성을 추가해주고, module import를 사용하기 위해서 type="module"를 추가해준다.

client/message.js 를 추가하고 sendMessage, onMessage 구현

const socket = window.io();

export const sendMessage = (type, payload) => socket.emit('message', {type, payload});
export const onMessage = (type, callback) => socket.on('message', message => (
  message.type === type && callback(message.payload)
));

client/caller.js 에 import 추가 (전체코드)

import {sendMessage, onMessage} from "./message.js";  // 추가

(async () => {
  const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
  document.getElementById('localVideo').srcObject = mediaStream;

  const rtcPeerConnection = new RTCPeerConnection();
  mediaStream.getTracks().forEach(track => rtcPeerConnection.addTrack(track));

  rtcPeerConnection.addEventListener('negotiationneeded', async () => {
    const sdpOffer = await rtcPeerConnection.createOffer();
    rtcPeerConnection.setLocalDescription(sdpOffer);
    sendMessage('SDP', sdpOffer)
  }); 
  
  onMessage('SDP', sdpAnswer => rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(sdpAnswer)));
 
  rtcPeerConnection.addEventListener('track', e => {
    document.getElementById('remoteVideo').srcObject = new MediaStream([e.track])
  });
})();

client/callee.js 에 import 추가 (전체코드)

import {sendMessage, onMessage} from "./message.js";  // 추가

(async () => {
  const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
  document.getElementById('localVideo').srcObject = mediaStream;

  const rtcPeerConnection = new RTCPeerConnection();
  mediaStream.getTracks().forEach(track => rtcPeerConnection.addTrack(track));

  onMessage('SDP', async sdpOffer => {    
    await rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(sdpOffer)); 
    const sdpAnswer = await rtcPeerConnection.createAnswer();
    await rtcPeerConnection.setLocalDescription(sdpAnswer);
    sendMessage('SDP', sdpAnswer);
  });
  
  rtcPeerConnection.addEventListener('track', e => {
    document.getElementById('remoteVideo').srcObject = new MediaStream([e.track])
  });
})();

여기까지 구현을 마치고 터미널에 > node server 를 입력하여 웹서버를 구동시키면 http://localhost:3000/caller.html, http://localhost:3000/callee.html 로 각각 접속 가능하다.

각 탭의 개발자 도구의 네트워크 탭을 보면 Signaling Server의 WebSocket을 통해서 서로 SDP를 교환하는 내용을 확인할 수 있다.

브라우저가 크롬인 경우, chrome://webrtc-internals/ 에 접속해보면 webrtc 관련한 네트워크 상태와 이벤트 발생을 모니터 할 수 있는데, 마지막으로 발생한 signalingstatechange 이벤트를 살펴보면 양측에서 상태가 stable로 변경된 것을 확인할 수 있다. (다른 상태들에 대해서는 developer.mozilla.org의 관련 문서 를 참조)

⚠️주의사항

지금 구현하는 signaling server는 접속자 식별기능이 없기 때문에 모든 메시지를 broadcasting한다. 로컬에서 테스트시 caller, callee 각각 한개씩 이상의 브라우저를 띄울 경우, 각각의 연결 상태와 메시지가 일치하지 않아 정상적으로 작동이 불가능하다.

RTC 연결 생성하기 (2)

이제 Signaling Server까지 구현이 완료되었다! 그러면 이제 두개의 브라우저에 각각 caller Appcallee App 을 띄우고 서로 전송되는 두개의 화면을 볼수 있는 것일까?!

실제 실행해보면 아직 local video만 나오고 remote video는 나오지 않는다. 왜냐하면 여태까지 두 peer사이에서 오간 정보는 미디어 데이터 형식에 대한 것 뿐이고, 서로를 네트워크 상에서 어떻게 식별해야 하는지를 알지 못하기 때문에 데이터를 어디로 보내야 할지 알 수가 없다. 이 문제를 해결하기 위한 프레임워크가 바로 ICE - Interactive Connectivity Establishment 이다.
두개의 peer가 처해있는 네트워크 상황(라우터, 방화벽 등) 에 따라 여러가지 방식으로 서로 통신이 가능하기때문에 ICE는 ICE candidates라고 불리는 여러개의 후보군을 주고 받고, 이중에서 서로 가능한 방식이 있을때 최적이라고 판단되는 candidate를 골라서 이를 통해 connection을 수립하게 된다.

이 선택과정은 표준에 의해 구현된 브라우저가 실행하게 되므로 우리는 위에서 구축한 Signaling Server를 통해서 ICE candidates를 서로 전달해주기만 하면 된다.

ICE candidates 교환 단계

rtcPeerConnection객체는 SDP교환 단계에서 rtcPeerConnection.setLocalDescription를 호출한 시점 이후로부터 ICE candidate 를 생성할 수 있고 그때마다 icecandidate이벤트를 발생시킨다. 이 이벤트 객체에 ICE candidate 정보가 들어있으므로 이를 상대편에게 메시지로 전달하고, 또 메시지가 들어오면 rtcPeerConnection.addIceCandidate 메소드를 호출하여 상대편에서 보내온 ICE candidate를 로컬 rtcPeerConnection에 등록하면 된다.

caller.js, callee.js 동일하게

/*...앞의 코드 내용에 이어서...*/

// 실제 로그를 찍어보면 이벤트는 총 세번 발생하는데, 
// 각각 TCP, UDP 프로토콜 연결에 대한 ICE가 생성되고
// 마지막으로 ICE candidate의 끝을 나타내는 null값이 전달된다.
rtcPeerConnection.addEventListener('icecandidate', e => {
    if (e.candidate === null) return;
    sendMessage('ICE', e.candidate)  
});

onMessage('ICE', iceCandidate => {
    rtcPeerConnection.addIceCandidate(new RTCIceCandidate(iceCandidate));
})

여기까지 수행하면 두개의 브라우저에 각각 local, remote 화면이 출력되는 것을 확인할 수 있으며, 서로 다른 프로세스 사이에서 시그널 서버를 비롯한 별개의 서버를 통하지 않고 직접 네트워크를 통해 통신하여 비디오 스트림을 전송하는 것을 확인할 수 있다. 실제 전송되는 데이터에 대한 실시간 모니터링을 chrome://webrtc-internals/에서 확인할 수 있다. 다만 현재까지는 하나의 컴퓨터에서 실행하고 있기 때문에 local 비디오나 remote 비디오나 같은 화면이 보일텐데 네트워크를 통하기 때문에 자세히 보면 미세한 레이턴시가 발생하는것을 알 수 있다.

여기까지의 통신과정을 시퀀스 다이어그램으로 정리하면 아래와 같다.

sequenceDiagram Participant caller Participant signal server Participant callee par caller->>caller: createOffer() Note left of caller: LocalDescription = offer caller->>signal server: offerSDP signal server->>callee: offerSDP Note right of callee: RemoteDescription = offer callee->>callee: createAnswer() Note right of callee: LocalDescription = answer callee-->>signal server: answerSDP signal server-->>caller: answerSDP Note left of caller: RemoteDescription = answer and callee->>signal server: IceCandidate(callee) x N signal server->>caller: IceCandidate(callee) x N Note left of caller: IceCandidates = iceCandidate(callee) x N and caller->>signal server: IceCandidate(caller) x N signal server->>callee: IceCandidate(caller) x N Note right of callee: IceCandidates = iceCandidate(caller) x N end Note over caller, callee: negotiation end caller->>callee: p2p Media streaming(Audio, Video, Data) callee->>caller: p2p Media streaming(Audio, Video, Data)

Caller, Callee 코드 통합 및 정리

여태까지 로직 구분을 간단히 하기 위해 Caller, Callee를 구분해서 작성했지만, 실제 사용시에는 두 역할에 따라 엔드포인트가 구분되어 있지 않는 경우가 많고, 로직도 중복된 부분이 많아서 적당한 UI와 함께 하나의 App으로 통합해도 무리가 없다.

두 부분을 하나로 통합하고 중복된 부분을 정리한 결과는 다음과 같다.

index.html

<!doctype html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>WebRTC</title>
</head>
<body>
    <h1>화상통화</h1>
    <h2>내 화면</h2>
    <video id="localVideo" autoplay width="480px"></video>
    <h2>상대방 화면</h2>
    <video id="remoteVideo" autoplay width="480px"></video>
    <button id="call">📞CALL</button>
    <script src="/socket.io/socket.io.js"></script>
    <script src="script.js" type="module"></script>
</body>
</html>

두 사용자가 대기상태에서 어느쪽이 caller인지를 결정하기 위해서 CALL 버튼을 추가했다. 아래 코드에서 callButton에 설정된 'click' 이벤트리스너를 참조하기 바란다.

script.js

import {onMessage, sendMessage} from "./message.js";

const callButton = document.getElementById('call');
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
const rtcPeerConnection = new RTCPeerConnection();

const sendSdpOffer = async () => {
  const rtcSessionDescriptionInit = await rtcPeerConnection.createOffer();
  await rtcPeerConnection.setLocalDescription(rtcSessionDescriptionInit);
  sendMessage('SDP', rtcSessionDescriptionInit)
};

const sendSdpAnswer = async () => {
  const rtcSessionDescriptionInit = await rtcPeerConnection.createAnswer();
  await rtcPeerConnection.setLocalDescription(rtcSessionDescriptionInit);
  sendMessage('SDP', rtcSessionDescriptionInit);
};

navigator.mediaDevices
  .getUserMedia({video: true, audio: false})
  .then(mediaStream => {
    localVideo.srcObject = mediaStream;
    mediaStream.getTracks().forEach(track => rtcPeerConnection.addTrack(track));
  });

// exchange SDP
rtcPeerConnection.addEventListener('negotiationneeded', () => callButton.disabled = false)
callButton.addEventListener('click', sendSdpOffer)
onMessage('SDP', async descriptionInit => {
  const rtcSessionDescription = new RTCSessionDescription(descriptionInit);
  await rtcPeerConnection.setRemoteDescription(rtcSessionDescription);
  if (descriptionInit.type === 'offer') {
    callButton.disabled = true
    await sendSdpAnswer();
  }
})

// exchange ICE
rtcPeerConnection.addEventListener('icecandidate', e => e.candidate == null || sendMessage('ICE', e.candidate));
onMessage('ICE', candidateInit => rtcPeerConnection.addIceCandidate(new RTCIceCandidate(candidateInit)))

// handle remote stream
rtcPeerConnection.addEventListener('track', e => remoteVideo.srcObject = new MediaStream([e.track]));

브라우저의 서로 다른 탭에 두개의 화면을 띄우고 한쪽에서 CALL 버튼을 클릭하면 서로 통신하는 것을 확인할수 있다.

서로 다른 기기에서 접속해보기

이제 똑같은 화면이 두개씩 나오는 것은 실컷 봤으니 실제 다른 디바이스에서 화면을 보며 확인해보고 싶다. 서버가 아닌 다른 기기에서 접속하기 위해서는 localhost가 아닌 ip로 접근할 필요가 있다. 다음 커맨드를 터미널에 입력하여 로컬 ip를 알아낸 다음 같은 와이파이 망에 접속된 모바일 기기로 접속해보자.(mac 기준) 아래 나와있는 ip(192.168.0.6)는 환경에 따라 달라질 수 있다.

> ifconfig | grep 192 ## 192.168.0.6 -> http://192.168.0.6:3000로 접속

아마 제대로 작동이 안될 것이다. userMedia를 가져올때 localhost 이외의 주소에서는 TLS(https) 통신이 요구된다. 그렇지 않으면 관련 API가 작동하지 않는다. 간단한 SSL proxy 서버를 통해서 로컬에서 https 서버를 띄워보자.

> npm install -g local-ssl-proxy
> local-ssl-proxy --source 3001 --target 3000

두개의 모바일 기기 또는 pc와 모바일 기기로 https://192.168.0.6:3001로 접속하자. 다시한번 주의할 점은 세개이상의 브라우저 탭에서 동시접속하면 제대로 작동이 안된다는 것이다. 각종 보안경고를 다 뚫고서 접속해주면 (최근 크롬에선 이걸 뚫기가 매우 번거로와졌다. Firefox나 Safari를 활용하자)

드디어 제대로 작동하는 화상통화 화면을 볼 수 있…

마지막 관문

을 줄 알았는데 사용한 모바일 디바이스가 iOS 기기라면 까만 화면만 나올 것이다. (안드로이드에선 확인을 못해봤음) mobile safari에선 <video /> 태그는 기본적으로 전체화면 모드에서만 재생 가능하다. 이걸 문서내에서 재생하기 위해서는 playsinline 속성을 추가해주어야 한다.

index.html

<video id="localVideo" autoplay playsinline width="480px"></video>
...
<video id="remoteVideo" autoplay playsinline width="480px"></video>

여태 삽질까지 모두 따라 하느라 수고 많았다.
이제 드디어 두개의 화면중 하나에서 CALL 버튼을 누르면 내 얼굴을 서로 다른 각도로 확인해 볼 수 있다! 본인의 정수리 탈모 진행상태를 체크하는 데 매우 유용하다. 이제 자기 얼굴을 두개나 보는데 질렸다면 가족 등 주변인에게도 접속해 보라고 해보자. 다만 같은 와이파이를 잡아야 하고 각종 보안 경고를 왜 무시해야 하는지 불안해하고 귀찮아 하는 상대방을 잘 설득해야 한다. 그리고 “안돼는데??” 같은 소리를 듣지 않으려면, 한번에 딱 두명씩만 접속해야 하고, PC에 따로 접속된 브라우저 탭이 있다면 미리미리 꺼두고 시도하자.

남은 과제

만약 가족에게도 테스트를 시도해봤다면 절실히 느꼈겠지만, 아직 제대로된 기능을 구현했다고 보기는 어렵다. 현재 시그널링 서버 구현으로는 두개 이상의 기기가 접속하면 제대로 연결을 맺어줄 수 없으므로, 여러 사용자가 접속해서 원하는 상대방을 찾을 수 있는 방법을 구현해야 한다.

또한 제대로 하나의 서비스로써 완성하기 위해서는 임의의 사용자가 접속해서 이용할 수 있는 public ip와 SSL로 인증된 도메인을 갖춘 환경을 구축해야 한다. 이러한 문제를 해결하기 위한 여러 방법이 있지만, 개인 프로젝트의 경우 AWS와 같은 클라우드 서비스에 배포하는 방법이 현실적일 것이다.

게다가 서비스가 로컬 네트워크가 아닌 인터넷으로 나아갔을때는 더 골치아픈 문제들이 기다리고 있다.

다음 포스팅에서 이러한 내용들을 다룰 예정이다.

여기까지 진행된 소스 코드는 아래 깃헙 리파지토리 브랜치에 공유되어 있다.

https://github.com/neocjmix/WebRTC/tree/p2p-화상채팅-구현-(1)



blog comments powered by Disqus
처음으로