3학년 2학기 수업에서 WebSocket과 WebRTC와 관련된 개념과, 간단한 실습 및 과제를 수행했다.
그중, WebRTC를 활용한 최대 3인 화상 채팅 + WebSocket을 활용한 채팅을 구현하는 과제를 진행할 때 애를 좀 많이 먹었었다. (당시 유튜브에서 해외 튜토리얼도 보고, 여러 레퍼런스도 참고했으나 과제 구현 목표와 미묘하게 달라 문제가 많이 발생했었다)
useSocket.ts 코드를 보면 알 수 있듯 주석 붙여가며 당시에 최대한 이해하려고 노력했었는데, 다시 한 번 핵심적인 부분만 구현하면서 확실하게 이해하고 넘어가려고 한다.
대부분의 레퍼런스는 1대1 화상채팅이고, 비디오/오디오 장치를 변경하는 기능은 고려되어있지 않아 두 가지 부분에서 막혔다.
최대 3명이서 화상 채팅을 하려면 시그널링을 어떻게 수행해야 할까?
비디오/오디오 장치를 변경하면, 뭘 건드려야 화상채팅에 반영될까?
게다가 수업에서는 Express만으로 FE와 BE를 모두 처리하는 전통적인 방식을 다뤘다면, 과제는 React + Express로 구현하고 Restful API로 소통하는 방식을 스스로 채택했던 상황이었다.
며칠간 모든 로직에서 콘솔을 찍어가며 실행되지 않는 시그널링 로직을 찾고, GPT와 함께 이유를 찾아보며 약 7일 가까이 고민해서 최종적으로 구현해낼 수는 있었다..
당시 상황에 대한 이야기는 이쯤 하고, 이제 WebRTC에 대해서부터 간단하게 이해해보자.
WebRTC(Web Real-Time Communication)는 이름 그대로 웹, 모바일에서 활용 가능한 실시간 음성, 영상 통신 기술이다.
WebRTC는 Peer-to-Peer 방식으로, Client-Server 모델과 다르게 서버를 거치지 않고 Client끼리 데이터를 주고받는다.
하지만 처음부터 Client끼리 데이터를 주고받을 수는 없어, 준비 과정인 Signaling을 돕는 Signaling server가 필요하다.
클라이언트 A와 B는 Peer-to-Peer로 데이터를 주고받기 전, Signaling server와 요청을 주고받으며 통신할 음성/영상 스트림을 결정하고, 어떻게 통신할지 결정하는 과정을 거친 뒤 Peer-to-Peer로 전환한다.
비유하면.. Signaling server는 주선자, 클라이언트는 참여자랄까..?
WebRTC는 Peer-to-Peer 통신 기술이고, Signaling이 선행되어야 한다는 것까지 알아봤다. 그러면 이제 Signaling에 대해 알면 될 것 같다.
근데, 이 Signaling이 만만치 않다. 천천히 살펴보자..!

Signaling 과정에서 Peer들이 Offer, Answer, ICE candidate를 주고받게 되며, 사용되는 주요 함수들은 다음과 같다.
getUserMedia: 사용자의 카메라/마이크에서 비디오/오디오 스트림을 가져온다.
addTrack: 비디오/오디오 스트림을 RTCPeerConnection 객체에 추가해 Peer에게 전송을 준비한다. (addStream은 deprecated 되었다.)
ontrack: 상대방의 비디오/오디오 스트림이 전달되면 발생하는 이벤트 리스너다.
createOffer: Offer를 생성한다.
createAnswer: Answer를 생성한다.
setLocalDescription: 본인이 생성한 Offer/Answer를 로컬 세션 설명으로 저장한다.
setRemoteDescription: 상대방이 전달한 Offer/Answer를 원격 세션 설명으로 저장한다.
onicecandidate: ICE candidate가 발견되면 발생하는 이벤트 리스너다.
addIceCandidate: 상대방의 ICE candidate를 자신의 연결에 추가한다.
여기서 Offer와 Answer는 SDP(Session Description Protocol) 형식으로 작성된 메시지로 미디어 정보, 네트워크 정보, 세션 정보 등이 담기게 된다.
또한, onicecandidate와 addIceCandidate는 Offer/Answer 교환과 별도로 처리된다. 왜 그런지 이해하기 위해서는 ICE candidate가 무엇인지 이해해야 한다.
ICE candidate는 NAT 뒤에 있는 클라이언트 간 Peer-to-Peer 연결을 위한 네트워크 경로 후보다. NAT은 외부IP와 내부IP를 변환하는 네트워크 기술로 "공유기"를 떠올리면 된다.
거의 모든 클라이언트들은 NAT 뒤쪽의 내부 IP를 사용하고 있기 때문에 외부에서 내부로 접속이 불가능하다. 이를 해결하기 위해 STUN 또는 TURN 서버를 활용해서 네트워크 경로를 확보하고, 이 경로 후보들을 주고받아 최적의 Peer-to-Peer 통신 경로를 결정하게 된다.
STUN: P2P 연결을 위한 본인의 외부 IP와 포트를 알려주는 서버
TURN: P2P 연결이 불가능한 경우 데이터를 중계하는 릴레이 서버
네트워크 경로를 찾는 과정의 순서는 다음과 같다.
Host candidate: 동일 네트워크나 동일 기기에서 내부IP를 사용해 직접 연결을 시도할 때 사용
STUN candidate: NAT 뒤에 있는 장치가 STUN 서버를 통해 공인 IP 주소와 포트를 확인하고, 이를 사용해 연결을 시도할 때 사용
TURN candidate: 위 두 candidate가 실패하면, 최후의 수단으로 사용
즉, onicecandidate 이벤트는 Peer-to-Peer 연결을 위한 네트워크 경로를 찾으면 발생하는 이벤트이고, 찾은 네트워크 경로를 서로 공유하고 저장해두기 위해 addIceCandidate 함수가 사용된다.
이 부분이 매우 중요하다. 코드도 이 흐름과 동일하게 작성해야만 하기 때문이다.
B가 방에 들어와 있었고, 여기에 A가 들어왔다고 가정하겠다.
A와 B 모두 비디오/오디오 스트림을 가져온다.
A가 server로부터 B가 존재한다는 정보를 받는다.
A가 RTCPeerConnection 객체를 생성하고 A의 스트림을 addTrack 함수로 연결한다.
A가 Offer를 생성해 객체의 localDescription에 저장하고, server에 Offer를 전송한다.
A가 객체에 ontrack, onicecandidate 이벤트 리스너를 연결한다.
B가 server로부터 Offer를 전달받으면, RTCPeerConnection 객체를 생성하고 B의 스트림을 addTrack 함수로 연결한다.
B가 전달받은 Offer를 객체의 remoteDescription에 저장하고, Answer를 생성한다.
B가 Answer를 객체의 localDescription에 저장하고, server에 Answer를 전송한다.
B가 객체에 ontrack, onicecandidate 이벤트 리스너를 연결한다.
A가 server로부터 Answer를 전달받으면, 객체의 remoteDescription에 저장한다.
A의 onicecandidate 이벤트가 발생하면, server에 ICE candidate를 전송한다.
B가 server로부터 ICE candidate를 전달받으면, 객체의 addIceCandidate 함수로 저장한다.
B의 onicecandidate 이벤트가 발생하면, server에 ICE candidate를 전송한다.
A가 server로부터 ICE candidate를 전달받으면, 객체의 addIceCandidate 함수로 저장한다.
ontrack 이벤트가 발생하면, stream을 받아와 상대방의 비디오/오디오를 렌더링할 수 있게 된다.
지금까지 WebRTC가 무엇인지, Signaling은 어떻게 수행되는지, 핵심 함수로는 어떤 것들이 있고, 무엇을 주고받는지 살펴봤다.
Peer의 역할을 잘 살펴보면, 크게 두 가지로 나뉜다는 사실을 알 수 있다.
Offer를 생성하는 Peer = Answer를 수신하는 Peer
Offer를 수신하는 Peer = Answer를 생성하는 Peer
그리고, 두 역할 모두 공통적으로 수신하는 이벤트가 있다.
ontrack (상대방의 스트림을 받아오기 위해 필수로 수신)
onicecandidate (네트워크 경로를 찾기 위해 필수로 수신)
즉, Peer의 의사 코드를 간단하게 작성해보면 아래와 같을 것이다.
const stream = navigator.mediaDevices.getUserMedia(); // 내 스트림 가져오기
// Offer를 생성하는 Peer 입장에서 실행됨
socket.on("user-info", (userId) => {
const peer = new RTCPeerConnection(); // RTC 연결 관리를 위한 객체 생성
내_스트림을_객체에_연결();
offer_생성();
offer를_객체의_localDescription에_저장();
서버로_offer_전송();
peer.ontrack((stream) => {
다른_peer의_스트림_저장(stream);
});
peer.onicecandidate((iceCandidate) => {
서버로_ice_candidate_전송(iceCandidate);
});
});
socket.on("recv-answer", (answer) => {
answer를_객체의_remoteDescription에_저장();
});
// ------------------------------------------------ //
// Offer를 수신하는 Peer 입장에서 실행됨
socket.on("recv-offer", (offer) => {
const peer = new RTCPeerConnection(); // RTC 연결 관리를 위한 객체 생성
내_스트림을_객체에_연결();
offer를_객체의_remoteDescription에_저장();
answer_생성();
answer를_객체의_localDescription에_저장();
서버로_answer_전송();
peer.ontrack((stream) => {
다른_peer의_스트림_저장(stream);
});
peer.onicecandidate((iceCandidate) => {
서버로_ice_candidate_전송(iceCandidate);
});
});
// ------------------------------------------------ //
// 서버로부터 ICE candidate를 수신하면, 이를 add해줌
socket.on("recv-candidate", (iceCandidate) => {
객체에_addIceCandidate를_수행하는_로직(iceCandidate)
});
여기서 peer는 A와 B를 연결하기 위해 필요한 것들이 저장될 객체로 자신의 스트림, remoteDescription, localDescription, ontrack(), onicecandidate(), addIceCandidate()가 저장되는 객체다.
그래서 소켓 이벤트 콜백함수 내에서 만드는 것이다. peer는 단순히 A나 B를 의미하는 객체가 아니라 A <-> B를 의미하기 때문이다.
위 로직과 peer의 의미를 잘 생각해보면, peer를 배열로 확장하고 중복을 방지하며 관리하면 2명 이상의 WebRTC 연결이 가능해진다.
하지만 실제 Product에서는 이런 방법을 잘 사용하지는 않는다고 한다. 사용자 수에 따라 연결 수가 기하급수적으로 늘어나 확장성이 떨어지기 때문이다.

예를 들어 4명의 연결을 가정해보면, A는 B C D와 signaling을 수행하고, B는 A C D와 signaling을 수행하며 Mesh 구조가 된다.
이런 형태는 확장성이 매우 떨어지므로, 실제 Product에서는 MCU나 SFU 방식을 활용한다고 한다.
MCU: 모든 스트림을 중앙 서버에서 받아 인코딩한 뒤 각 Peer에게 전달
(A에게 B C D의 스트림을 인코딩해 전달)
SFU: 모든 스트림을 중앙 서버에서 받아 그대로 전달하되, 각 Peer가 원하는 스트림만 전달
(A가 B C의 스트림을 원하면, B C의 스트림만 그대로 전달)
하지만, 본 회고의 목적은 Signaling 방식의 WebRTC를 확실하게 이해하는 것이 목표이므로, MCU/SFU는 더 다루지 않을 예정이다.
지금까지 WebRTC와 Signaling의 개념 및 순서, 1:1일 경우의 의사 코드 등에 대해 자세하게 살펴보았다.
다음 포스트에서 React + Express를 활용해 본격적으로 "최대 3인 화상 채팅, 비디오/오디오 장치를 변경 기능"을 구현해보겠다.