시그널링 서버 구축하기

JSM·2023년 12월 7일
0

프로젝트

목록 보기
7/10
post-thumbnail

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);
}
profile
내 기술적 고민들을 모은 곳...

0개의 댓글