방학동안 WebRTC를 사용해서 프로젝트를 진행했는데, 그때 공부한 WebRTC 관련 내용을 정리해보려고 한다.
사실 처음에는 그냥 실시간 통신을 위해서 많이 사용하는 기술이라서 무지성으로 적용을 했다. 개념만 간단히 이해하고 코드를 작성하는데, 이게 뭔가 작동하기는 하는데 이게 맞는지 의문점이 많이 들어서 공부를 깊게 해보았다. 공부하면 할수록 꽤 깊게 파보면 좋은 내용들이 있어서 정리해보려고 한다.
1. WebRTC란?
1-1. 기본 개념
“웹 어플리케이션, 사이트 등이 중간자 없이 서로 영상, 오디오 등의 미디어와 데이터를 교환할 수 있도록 하는 기술”
여기서 가장 중요한 것은, 중간자 없이 라는 것이다. 기존의 인터넷 통신은 대부분 사용자 사이에 서버를 두고, 이 서버가 데이터를 중개하는 역할을 한다. 이는 기존의 웹 통신 구조 상 가장 기본적이고 편리한 방법이지만, 실시간으로 빠르게 통신해야하는 상황에서, 이 서버를 거치는것은 불필요한 오버헤드가 된다.
물론 기존의 웹 통신 구조에서, 이와같은 P2P 채널을 여는것은 그리 간단하지만은 않다. 아래에서 살펴보겠지만, P2P 채널 셋업을 위해 NAT Traversal을 위한 별도의 서버(STUN or TURN)이 있어야하고, Signaling을 위한 서버(Signal Server)가 있어야한다. 아무튼 이러한 P2P 통신을 위한 준비 절차와 통신 규약을 규정한 것이 WebRTC라는 기술이고, 결국 중간자를 없애기 위해, Connection Setup과정에서 약간의 Overhead를 감수하는 그런 기술이 된다.
따라서 주로 WebRTC는 한번 연결하면 비교적 오랜 기간 동안 계속 연결을 유지하는, 그런 application에서 적합하다.
1-2. WebRTC 통신 Flow
Web RTC 통신을 위해 필요한 절차들이다. 위에서 말했듯, 여러개의 서버가 필요하며 약간 복잡하다. 그렇지만, 이 준비과정만 끝나면, Peer간의 데이터 전송 방법은 아주 간단하다.
위의 과정은, 설정과 환경에 따라 약간의 차이는 있지만, 아주 기본적인 순서는 아래와 같다.
- A와 B는 STUN Server, 또는 TURN Server를 사용하여, 자신의 ICE Candidate를 수집
- A가 B에게 Offer(SDP)를 전송
- B가 A에게 Answer(SDP)를 전송
- ICE Candidate를 교환
- ICE Connectivity Check, Establish
- WebRTC 통신 시작
(2→3, 그리고 4→5만 서로 순서가 상관이 있고, 나머지는 서로간에 순서가 바뀌거나 병렬적으로 수행될 수 있다.)
1-3. NAT Traversal
WebRTC에서 P2P 통신을 준비하는 과정에서, 가장 처음 부딪히는 문제는, 각 Peer가 자신의 IP 주소(공인 IP)를 모른다는 점이다.
일반적인 환경에서, 인터넷에 접속할때 NAT를 통해 공인 IP를 획득하고, 서버는 이 공인 IP로 답장을 보낸다. NAT는 호스트 외부의 라우터에서 일어나는 일임으로, 호스트가 관여할 수 없고 일반적인 방법으로 자신의 공인 IP를 알기는 어렵다. 문제는 P2P를 위해서는 상대가 나의 공인 IP를 알아야한다는 것인데, 나도 모른다는것이다…
그래서 이렇게 외부 서버를 사용해서, 자신이 어떤 공인 IP를 할당받았는지 확인하는 과정이 필요해진다. 이렇게 외부에 서버를 두고, 요청을 날려서 IP를 물어보면 될것 같은 간단한 문제처럼 보이지만, 이것은 NAT 설정과 방화벽 등에 의해서 꽤 어려운 문제가 된다.
이 부분에 제대로 된 설명은 NAT
아무튼 외부 서버를 사용하여 자신의 공인 IP를 획득하는 것을 NAT Traversal이라고 하고, 이를 위해 사용되는 서버가 STUN 또는 TURN이다. STUN은 아주 기본적으로 구글의 what’s my ip처럼 자신의 공인 IP를 알려주는 역할을 하고, TURN은 Symmetric NAT등으로 P2P 통신 자체가 불가능한 상황에서, 데이터를 중개하는 서버 역할을 한다. 따라서 TURN을 사용하면 WebRTC이지만, 결과적으로는 P2P 통신이 아니게 된다.
이런 과정을 통해, 각 peer는 ICE(Interactive Connectivity Establishment) Candidate라는 것을 얻게 된다. 이는
- Local Address : 클라이언트의 사설 주소(Host Candidate), 랜과 무선랜 등 다수 인터페이스가 있으면 모든 주소가 후보가 됨
- Server Reflexive Address : NAT 장비가 매핑한 클라이언트의 공인망 주소로 STUN에 의해 판단한다.(Server Reflexive Candidate)
- Relayed Address : TURN서버가 패킷 릴레이를 위해 할당하는 주소(Relayed Candidate)
이 세가지 종류로 구성된다.
그리고 이렇게 자신의 ICE Candidate 얻은 후, 자신의 정보를 상대방에게 전송해야하는데, 문제는 여전히 상대방의 IP는 모른다는 문제가 있다…
이를 위해서 Signal Server라는 중간 서버(서로 기존에 협의된)를 사용하여 ICE Candidate를 교환하게 된다. 그리고 상대방의 ICE Candidate로, 최적의 통신 설정을 결정해서 ICE Candidate pair를 결정한다.
1-3. SDP
각 peer가 셋업 과정에서 공유하는 정보는 ICE Candidate 뿐만이 아니다. 결국 최종적인 목표는 실시간으로 원하는 데이터를 주고받는 것인데, 이를 위해서는 사전에 어떤 형식의 데이터를 주고받을 것인지, 그리고 데이터 통신 채널을 몇개 만들 것인지, 등등의 협의가 필요하다.
이를 위해서, SDP(Session Description Protocol) 라는 패킷을 서로 교환하는 과정을 거친다. SDP란?
그리고 SDP는 서로에게 한번씩 보내는데, 보내는 순서는 사전에 협의가 되어있어야 한다. 위의 도식에서, A가 Offer SDP를 보내면, 이를 B가 확인하고 Answer SDP를 보낸다.
그리고 여기서 약간 헷갈릴 수 있는데, SDP에는 데이터 형식과 개수 뿐만 아니라, 위의 NAT Traversal로 얻은 ICE Candidate도 포함될 수 있다.
가장 기본적인 상황은 ICE Candidate를 전부 수집한 후, 자신의 SDP(Candiate를 포함시켜)를 보내는 것이다. 다만 ICE Candidate는 경우에 따라 여러개의 서버를 사용하여 delay가 꽤 생길 수 있으므로, 자신의 SDP를 보내기 전, 또는 후에 ICE Candidate를 SDP와 별개로 주고받을 수도 있다. 이를 Trickle ICE라고 한다.
아무튼, B가 Answer SDP를 보내면, 기본적으로 WebRTC를 위한 셋업은 거의 끝났다고 생각하면 된다.
1-4. Signal Server
위에서 언급했듯이 셋업 과정에서 SDP와 ICE Candidate를 보내기 위해 사전에 협의된 별도의 서버가 있어야한다고 했는데, 이를 Signal Server라고 부른다. 그리고 이렇게 Signal Server를 통해 데이터를 주고받으며 WebRTC 통신 셋업을 하는 과정을 Signaling이라고 한다.
WebRTC의 다른 부분들과 달리, Signal Server에 대한 명시적인 스펙은 존재하지 않는다.
그래서 STUN이나 TURN 서버는 구글에서도 제공해주는 것과 달리, Signal Server는 보통 직접 구현하고, 각 peer에서 그 Signal Server의 스펙에 맞게 통신을 하게 된다. 물론 Signal Server를 만드는 것은 그리 어렵지 않고, 그냥 peer와 어떻게 통신할 지 정해만 두고 그 스펙을 구현하기만 되기에 어떻게 구현하는 상관이 없다.
정말 naive하게는 그냥 http 통신을 하도록 구현해도 되고, mqtt, websocket, 등등 아무거나 편한 프로토콜을 사용하면 된다. 그치만 편의상 websocket을 많이 사용하기는 하는 듯 하다.
- Signal Server에게 A가 Offer를 보내면, Server는 B에게 Offer를 전달한다.
- Signal Server에게 B가 Answer를 보내면, Server는 A에게 Answer를 전달한다.
- Trickle ICE를 사용한다면, A와 B의 ICE Candidates 도 전달
이렇게가 전부이고, 각 peer가 필요한 메시지를 전부 전송/수신 했다고 판단하면, Signal Server로 부터 연결을 해제하면 된다.
2. WebRTC 구현
프로젝트 내에서 WebRTC를 어떻게 구현했는지, 간략하게 적어보겠다.
2-1. Signal Server
Signal Server Github 이 레포를 참고하여, socket io 기반으로 Signal Server를 만들었다. socket io가 처음이라면 Socket io Doc을 읽어보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// MESSAGING LOGIC
io.on("connection", (socket) => {
console.log("User connected with id", socket.id);
...
socket.on("message", (message) => {
// Send message to all peers expect the sender
socket.broadcast.emit("message", message);
});
socket.on("messageOne", (message) => {
// Send message to a specific targeted peer
const { target } = message;
const targetPeer = connections[target];
if (targetPeer) {
io.to(targetPeer.socketId).emit("message", { ...message });
} else {
console.log(`Target ${target} not found`);
}
});
...
서버 코드는 아주 단순하다. 이렇게 message 이벤트가 발생하면, target Peer를 찾아서 그 peer에게 message를 전달하는 역할만 하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const io = require("socket.io-client");
// This is a bare minimum example of how one might setup a signaling channel as a class
class SignalingChannel {
constructor(peerId, signalingServerUrl, token) {
this.peerId = peerId;
this.socket = new io(signalingServerUrl, {
auth: { token },
autoConnect: false,
reconnection: false,
});
this.onMessage = () => {};
}
connect() {
this.socket.on("connect", () => {
console.log("Connected with id", this.socket.id);
this.socket.emit("ready", this.peerId);
});
this.socket.on("disconnect", () => {
console.log("Disconnected");
});
this.socket.on("connect_error", (error) => {
console.log("Connection error", error.message);
});
this.socket.on("message", this.onMessage);
this.socket.on("uniquenessError", (message) => {
console.error(`Error: ${message.error}`);
// process.exit(1);
});
this.socket.connect();
}
send(message) {
this.socket.emit("message", { from: this.peerId, target: "all", message });
}
sendTo(targetPeerId, message) {
this.socket.emit("messageOne", { from: this.peerId, target: targetPeerId, message });
}
disconnect() {
if (this.socket) {
this.socket.disconnect();
}
}
}
module.exports = SignalingChannel;
그리고 각 peer는 이 Signaling Channel 클래스를 사용하여,
connect → sendTo → .on(“message”) → disconnect
등의 흐름을 거쳐 Signaling을 끝내게 된다.
실제 프로젝트에서는 Server 코드와 Signaling Channel 코드를 수정해서 사용하였다. 이 코드만 잘 이해해도 Signal Server 만드는 것은 문제가 없을 것이다.
2-2. Signaling
Signaling을 하기 위해서는, 우선 먼저 위에서 만든 Signaling Channel을 만들고, Signal Server에게 connect 이벤트를 보내야 한다. 그리고 webRTC 연결을 관리하는 클래스를 만들어야하는데, 이는 RTCPeerConnection 이란 js에 들어있는 클래스를 사용하면 된다. 정확한 스펙은 PeerConnection Doc WebRTC를 사용하고자 한다면, 꼭 읽어보자…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const signal = SignalingChannel(SIGNAL_SERVER_URL,...);
signal.connect();
const config = {
iceServers : {
[{
urls: STUN_SERVER_URL,
username: STUN_SERVER_USER,
credential: STUN_SERVER_PASSWORD,
},]
}
}
const pc = RTCPeerConnection(config);
pc.addTrack(...); //Media Channel
pc.createDataChannel(...); //Data Channel
const offer = await pc.createOffer();
const localDescription = await pc.setLocalDescription(offer);
signal.sendTo("B", localDescription);
const answerHandler = (message) => {
const remoteDesctiption = await pc.setRemoteDescription(message);
}
Offer 쪽에서 정말 필요한 부분만 추출한 코드이다. 아무튼 대략적인 순서는,
- Signal Server 연결
- RTCPeerConnection 생성
- Media Channel, Data Channel 생성
- Offer 생성, 전송
- Answer 수신
- WebRTC 통신 시작
그리고 프로젝트에서 한쪽은 js, 다른쪽은 python으로 작성했는데, python에서 RTCPeerConnection같은 모듈은, aiortc를 사용하면 된다.
2-3. Media Channel과 Data Channel
이 채널들을 생성하는 것이 WebRTC의 최종적인 목표이다.
위의 코드에서 처럼,
1
2
pc.addTrack(...); //Media Channel
const dataChannel = pc.createDataChannel(...); //Data Channel
이렇게 해주면 생성이 되고,
1
dataChannel.send(data);
이런식으로 데이터를 보낼 수 있다.
그리고 채널을 생성할 때, 괸련된 이벤트 핸들러를 만들 수 있다(만들어야 한다).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dataChannel.onopen = () => {
console.log('Data channel is open');
// 연결이 열리면 메시지를 보낼 수 있습니다.
dataChannel.send('Hello from client!');
};
dataChannel.onclose = () => {
console.log('Data channel is closed');
};
dataChannel.onerror = (error) => {
console.log('Data channel error:', error);
};
dataChannel.onmessage = (event) => {
console.log('Received message:', event.data);
// 데이터 수신 처리
};
이런 식으로, 핸들러를 만들어서 채널이 열리고, 닫히고, 메시지가 오는 등의 이벤트에 대해서 처리를 해줄 수 있다.
사실 WebRTC의 목표상 가장 중요한 코드이고, 비즈니스 로직은 이 핸들러에 연계되어 나타날 것이다.
3. 기타 사항
WebRTC 셋업과정에서 에러가 발생할 수 있는데, 디버깅하는 방법은 크게 두 가지가 있다.
- RTCPeerConnection의 이벤트 핸들러 사용
- Chrome의 WebRTC Internal 사용
1
2
3
4
5
6
7
attribute EventHandler onnegotiationneeded;
attribute EventHandler onicecandidate;
attribute EventHandler onicecandidateerror;
attribute EventHandler onsignalingstatechange;
attribute EventHandler oniceconnectionstatechange;
attribute EventHandler onicegatheringstatechange;
attribute EventHandler onconnectionstatechange;
RTCPeerConnction에서는 위와 같은 이벤트 핸들러들을 제공해주는데, 핸들러를 설치해서 상태 변화를 실시간으로 확인할 수 있다.
Chrome Web Internals Web Internals 설명서 이게 사실 핸들러 설치하는 것보다 직관적이면서 쉽긴하지만, application이 크롬을 사용하지 않는 경우는 사용할 수가 없고, 이벤트 핸들러의 결과랑 약간 다른 부분들이 있어서 문제가 생겼을 경우 둘 다 사용하는 것을 추천한다.