WebRTC가 무엇일까?
- ZOOM을 생각하면 된다. 즉, 클라이언트가 서로 화상으로 연결될 수 있는 기술이다.
- 즉 각 클라이언트간 실시간으로 비디오와 음성을 전달해줄 수 있는 기술이다.
- 이때 시그널링 서버, STUN 서버, TURN 서버를 통해 피어간 비디오와 음성을 교환할 수 있는 환경을 만드는 것이다.
- 크게 MESH, SPU, MCU 방식이 있고 이 중 MESH 방식을 구현해볼 것이다.
Mesh
- Mesh 방식은 클라이언트간 P2P 통신을 의미한다.
- 이는 서버의 부하가 가장 적은 방식이지만 인원이 많아질수록 클라이언트의 부담이 커진다.
- 예를 들어 4명이 한 방에 있으면 각 클라이언트당 3명과 P2P 통신을 해야하므로 클라이언트 부담이 크다.
SPU
- SFU는 중앙 서버를 통해 트래픽을 중계하는 방법이다.
- 클라이언트가 연결이 아니라 클라이언트와 서버를 연결하는 방법이다.
- 서버에게만 자신의 데이터를 보내면 되고 MESH 보다는 느려도 실시간 성을 보장한다.
- 단 이 과정에서도 다른 클라이언트에 대한 SDP와 ICE Candidate의 정보 교환이 있어야 하므로 클라이언트 부담이 생긴다.
MCU
- 중앙 서버를 통해 각 클라이언트의 미디어를 가공하고 이를 배부하는 방식이다.
- 모든 피어에 대한 작업이 들어가므로 즉 10명이라명 1명에게 9명의 데이터를 가공해서 보내야 하는 것이다.
- 실시간 성이 떨어지고 서버의 부하가 급격히 증가한다.
SDP가 무엇일까?
- 먼저 시그널링 서버를 이해하기 전에 SDP와 ICE Candidate에 대한 이해가 필요하다.
- 서로 전혀 접점이 없는 두 클라이언트가 어떻게 비디오와 음성으로 통신할 수 있을까? 서로의 카메라나 마이크 네트워크 정보 어떤 정보도 가지고 있지 않은데 말이다.
- 바로 이때 필요한 것이 SDP이다.
- 클라이언트는 시그널링 서버를 통해 서로 SDP를 주고 받는다.
- A 클라이언트가 어떤 방에 입장해있다고 하고 B 클라이언트가 들어왔다고 하자.
- 그럼 B 클라이언트가 자신의 SDP를 A 클라이언트에게 보내고 이를 받은 A 클라이언트가 자신의 SDP를 B 클라이언트로 보낸다.
즉 SDP는 네트워크와 미디어 정보를 담은 RTCSessionDescription객체이고 클라이언트는 서로의 SDP를 시그널링 서버를 통해 보낸다.
SDP를 교환하는 과정을 좀 더 구체화해보자
- A가 있는 방에 B가 입장한다.
- B는 offer(SDP)를 생성하면서 이를 LocalDescription으로 설정한 후 시그널링 서버로 보낸다.
- 시그널링 서버는 이를 A에게 전달하고, A는 이를 RemoteDescription으로 설정한다.
- 이 후 A는 answer(SDP)를 생성하면서 이를 LocalDescription으로 설정한 후 시그널링 서버로 보낸다.
- 시그널링 서버는 이를 B에게 전달하고, B는 이를 RemoteDescription으로 설정한다.
- 즉 LocalDescription은 자신의 SDP이고 RemoteDescription은 상대방의 SDP이다.
ICE가 무엇일까?
- ICE는 가능한 IP 주소와 포트번호등에 대한 정보를 담은 후보를 교환하고 이 중 가장 좋은 후보를 선출한다.
- 이때 각 후보가 ICE Candidate이며 클라이언트끼리 ICE Candidate를 교환하는 것이다.
- 이 작업 역시 시그널링 서버를 통해 일어나고 Trickle ICE 즉, ICE 후보를 찾는 동시에 서버를 통해 교환 작업이 이루어진다.
STUN 서버가 필요하다.
- STUN 서버는 각 PC가 자신의 사설 IP를 통해 공인 IP를 확인할 수 있는 서버이다.
- 이 기술을 NAT라고 하는데 이를 통해 공인 IP를 발급해준다.
- 즉 이렇게 공인 IP를 받아서 ICE Candidate를 만들어 클라이언트간 교환이 일어난다.
TURN 서버가 왜 필요할까?
- NAT에는 Symmetric NAT라는 종류가 하나 있다. 이놈이 문제이다.
- NAT에는 사설 IP:PORT가 공인 IP:PORT로 매핑되는데 Symmetric NAT는 클라이언트간 서로 한번이라도 연결이 되지 않았더라면 연결이 불가능한 것이다.
- 따라서 이 연결을 위해 TURN 서버가 필요하다.
- 각 클라이언트 자신의 데이터를 TURN 서버로 보내고 클라이언트가 데이터를 중계해주는 역할을 한다.
- 특히 공용 네트워크 공간 EX) 지하철, 스타벅스 등은 보안을 위해 거의 Symmetric NAT를 사용한다고 한다.
Symmetric NAT의 문제점이 무엇일까?
- 서로의 공인IP:PORT 매핑정보를 알아야 통신할 수 있는데 이 정보를 알아낼 수 없는 문제입니다.
- A와 B가 연결하려 할때 A공인IP:A_PORT 를 NAT에 할당하고 B는 B공인IP:B_PORT 를 할당합니다.
- 이때 A는 B를 B_공인IP:임의_PORT로 인식하기 때문에 연결을 할 수 없습니다.
- 즉 주로 포트 번호 인식을 못해 발생하는 문제입니다.
정리하자면
- 처음에 P2P 연결을 위해 SDP를 교환합니다. 이때 자신의 SDP를 LocalDescription, 상대방의 SDP를 RemoteDescription으로 설정합니다.
- 그리고 이와 동시에 클라이언트는 서로 ICE Candidate를 교환하고 그중 가장 좋은 것으로 P2P 연결을 시도합니다.
시그널링 서버 구현해보기
1. 먼저 룸에 조인합니다
@SubscribeMessage(SOCKET_EVENT.JOIN_ROOM)
handleJoin(
@MessageBody() data: JoinRoomDto,
@ConnectedSocket() socket: Socket,
) {
this.logger.log(`on joinRoom called : ${socket.id}`);
this.webRtcService.validateJoinRoom(data);
const { room } = data;
const existingRoom = this.roomToUsers.get(room);
if (existingRoom) {
const count = existingRoom.length;
if (count === SOCKET.ROOM_FULL) {
const socketId = socket.id;
socket.to(socketId).emit(SOCKET_EVENT.ROOM_FULL);
}
this.webRtcService.isRoomFull(existingRoom.length);
existingRoom.push({ id: socket.id });
this.roomToUsers.set(room, existingRoom);
} else {
this.roomToUsers.set(room, [{ id: socket.id }]);
}
this.socketToRoom.set(socket.id, room);
socket.join(room);
const usersInRoom = this.roomToUsers
.get(room)
.filter((user) => user.id !== socket.id);
const roomUsersDto: RoomUsersDto = { users: usersInRoom };
socket.emit(SOCKET_EVENT.ALL_USERS, roomUsersDto);
}
2. 클라이언트에게 SDP OFFER를 받은 후 이를 적절한 클라이언트에게 전달합니다.
@SubscribeMessage(SOCKET_EVENT.OFFER)
handleOffer(
@MessageBody()
data: GetOfferDto,
@ConnectedSocket() socket: Socket,
) {
this.logger.log(`on offer called : ${socket.id}`);
this.webRtcService.validateOffer(data);
const { sdp, offerSendId, offerReceiveId } = data;
const postOfferDto: PostOfferDto = { sdp, offerSendId };
socket.to(offerReceiveId).emit(SOCKET_EVENT.GET_OFFER, postOfferDto);
}
3. 클라이언트에게 SDP Answer를 받은 후 이를 적절한 클라이언트에게 전달합니다.
@SubscribeMessage(SOCKET_EVENT.ANSWER)
handleAnswer(
@MessageBody()
data: GetAnswerDto,
@ConnectedSocket() socket: Socket,
) {
this.logger.log(`on answer called : ${socket.id}`);
this.webRtcService.validateAnswer(data);
const { sdp, answerSendId, answerReceiveId } = data;
const postAnswerDto: PostAnswerDto = { sdp, answerSendId };
socket.to(answerReceiveId).emit(SOCKET_EVENT.GET_ANSWER, postAnswerDto);
}
4. 클라이언트가 연결을 종료하면 이를 다른 클라이언트들에게 알립니다.
@SubscribeMessage(SOCKET_EVENT.DISCONNECT)
handleDisconnect(@ConnectedSocket() socket: Socket) {
this.logger.log(`on disconnected called : ${socket.id}`);
const roomID = this.socketToRoom.get(socket.id);
this.socketToRoom.delete(socket.id);
const roomUsers = this.roomToUsers.get(roomID);
if (roomUsers) {
const afterLeave = roomUsers.filter((user) => user.id !== socket.id);
if (afterLeave.length === SOCKET.ROOM_EMPTY) {
this.roomToUsers.delete(roomID);
} else {
this.roomToUsers.set(roomID, afterLeave);
}
}
const id = socket.id;
const userIdDto: UserIdDto = { id };
socket.to(roomID).emit(SOCKET_EVENT.USER_EXIT, userIdDto);
}