우선적으로 WebRTC 시그널링은 WebSocket으로 이루어진다. WebSocket으로 Json 형태의 메세지를 실시간으로 보내고 Json에 messageType이라는 Key 값을 통해 메세지를 구별해 messageType에 맞는 로직들을 처리하면 된다.
따라서 방 참가와 동시에 프론트엔드에서 WebSocket 연결을 하고 시그널링에 필요한 데이터들을 주고 받으면 된다.
그리고 Kurento Media Server는 공식 홈페이지에는 실행 시키는 법이 자세히 나와있다. 필자는 Docker를 이용해 띄웠다. 그리고 Spring에서 다음과 같이 설정해주면 된다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
//Kurento 설정
@Bean
public KurentoClient kurentoClient() {
return KurentoClient.create("ws://쿠렌토 띄운 주소:PORT/kurento");
}
}
이렇게하면 Kurento 서버와 연결은 쉽게 된다. 내 기억상 한 컴퓨터에 Kurento와 Spring을 동시에 띄운 경우에는 따로 주소를 안 적어줘도 되었던 걸로 기억한다.
방 생성은 처음에는 WebSocket으로 하다가 지금은 따로 API 형식으로 한다.
교수자만 방을 생성할 수 있고, 교수자가 방을 생성하면 UUID를 반환한다.
public Rooms(User user) {
this.roomName = user.getUserId() + "/" + UUID.randomUUID();
this.createdTime = LocalDateTime.now();
this.creatorId = user.getUserId();
user.setRoom(this);
}
방 이름에 UUID를 부여하되, 이후에 생성자 userId를 편하게 알기 위해 userId + / + UUID 형식으로 만들었다.
그리고 Kurento에서 제공하는 MediaPipeLine 객체를 만들어준다. 후에 이 MediapipeLine 객체를 이용하여 미디어 송수신 객체를 만든다.
이후 DB에 저장하고 meetingRoomMap에 등록하는 등 로직들을 처리하면 된다.
방 생성은 따로 Signaling이나 WebSocket이 필요하지 않기 때문에 이 정도만 하고 넘어가겠다.
방 참가부터 WebSocket 연결과 동시에 시그널링이 시작된다.
아래 코드는 messageType으로 동작을 다르게 하는 메서드 일부이다.
@Transactional
public synchronized void handleSignalingActions(WebSocketSession session, Message socketMessage) throws IOException {
String roomName = socketMessage.getRoomName();
String senderId = socketMessage.getUserId();
String receiverId = socketMessage.getReceiverId();
String creatorId = roomName.split("/")[0];
Rooms room = RoomList.get(roomName);
switch (socketMessage.getMessageType()) {
case JOIN:
String messageType = joinUser(socketMessage, session);
String joinMessage = messageService.makeRoomEventMessage(messageType, roomName);
messageService.sendSynchronizedMessage(joinMessage, session);
break;
이렇게 messageType:JOIN으로 올 시 유저를 참가시키는데, 방에 처음 들어 온 사람이랑 방에 사람이 있을때 들어간거랑 로직이 나뉜다.
방에 처음 들어간 경우, Kurento에서 제공하는 WebRtcEndpoint 객체를 만든다(송신용). 그 외에도 UserSession을 만들면서 필요한 객체들을 만들고 설정해준다.
WebRtcEndpoint는 미디어를 송신, 수신할 수 있는 객체라고 생각하면 편하다.
만든 WebRtcEndpoint 객체에 시그널링을 한다.
첫번째로 Sdp 교환을 한다. 클라이언트에서 Sdp offer가 오면 processOffer 메서드를 통해 sdp를 등록하고 Sdp answer를 만들어 클라이언트에게 전송한다.
두번째로 IceCandidateFoundListener를 통해 Ice 후보자가 발견되면 메세지를 전송하게 등록한다.
이후에 gatherCandidates() 메서드를 통해 Ice 후보자들을 수집한다.
이렇게 완료되면 객체 하나에 대한 시그널링이 완료된다. 코드는 방 참가 이후에 적어 놓겠다.
SFU 방식은 송신 객체(WebRtcEndpoint) 한 개, 수신 객체가 방에 있는 사람들 만큼 있다. 따라서 방에 있는 기존 사람들도 새로 들어온 사람의 미디어를 수신하기 위한 객체를 만들어야하고, 새로 들어온 사람도 방에 있던 사람들의 미디어를 수신하기 위해 객체를 추가로 만들어야 한다.
예제를 통해 설명하면, 방에 사람이 2명 있고, 새로운 사람이 들어오면, 방에 있던 두명의 사람은 새로 들어온 사람의 미디어를 받기 위한 객체를 각각 만들어야하고, 새로 들어온 사람도 방에 기존에 있던 2명의 미디어를 수신하기 위해 객체를 2개 만들어야 한다. 따라서 방에 있는 사람들은 각각 3개의 객체(1개 송신 + 2개 수신)를 가지고 있다.
송신 객체는 messageType을 SDP_OFFER로 해서 Signaling하게 만들었고, 수신 객체는 RECEIVER_SDP_OFFER로 만들어서 Signaling을 하게 만들었다. 왜냐하면 송신 객체, 수신 객체들도 동일하게 Sdp 교환을 해야하고 Ice 교환을 해야하기 때문이다.
이를 순서로 나타내면 다음과 같다.
방에 사람이 참가한다.
참가한 사람은 송신 WebRtcEndpoint 객체를 만들고 Sdp 교환, Ice 이벤트 리스너 등록, gatherCandidate를 한다.
그리고 방에 있는 사람들 만큼의 WebRtcEndpoint 객체(수신용)를 만들고 Sdp 교환, Ice 이벤트 리스너 등록, gatherCandidate를 한다.
이와 동시에 방에 있는 사람들도 새로 들어온 사람에 대한 수신 WebRtcEndpoint 객체를 만들고 Sdp 교환, Ice 이벤트 리스너 등록, gatherCandidate를 한다.
그리고 송신 객체와 수신 객체를 Kurento에서 제공하는 connect() 함수를 통해 연결한다.
이렇게하면 시그널링부터 미디어 연결까지 끝난다.
@Transactional
public synchronized void handleSignalingActions(WebSocketSession session, Message socketMessage) throws IOException {
String roomName = socketMessage.getRoomName();
String senderId = socketMessage.getUserId();
String receiverId = socketMessage.getReceiverId();
String creatorId = roomName.split("/")[0];
Rooms room = RoomList.get(roomName);
switch (socketMessage.getMessageType()) {
case JOIN:
String messageType = joinUser(socketMessage, session);
String joinMessage = messageService.makeRoomEventMessage(messageType, roomName);
messageService.sendSynchronizedMessage(joinMessage, session);
break;
case SDP_OFFER:
String userInRoomMessage = messageService.makeUserInRoomMessage(room);
messageService.sendSynchronizedMessage(userInRoomMessage, session);
WebRtcEndpoint webRtcEndpoint = webRTCService.discriminateSenderEP(senderId, creatorId, room);
webRTCService.processSdpOffer(session, webRtcEndpoint, socketMessage);
break;
case RECEIVER_SDP_OFFER:
UserSession senderUserSession = room.getUserInRoomList().get(senderId);
WebRtcEndpoint receiverEndpoint = senderUserSession.getDownStreams().get(receiverId);
webRTCService.processReceiverSdpOffer(senderUserSession, receiverEndpoint, socketMessage);
break;
case ICE_CANDIDATE:
WebRtcEndpoint senderWebRtcEndpoint = webRTCService.discriminateSenderEP(senderId, creatorId, room);
WebRtcEndpoint receiverWebRtcEndpoint = webRTCService.discriminateReceiverEP(senderId, receiverId, room);
webRTCService.processIceCandidate(senderWebRtcEndpoint, receiverWebRtcEndpoint, socketMessage);
break;
case LEAVE:
session.close(); //afterConnectionClosed 자동 호출되어 방 나가기 로직 수행
break;
case PING:
break;
}
}
우선적으로 이 메서드가 Signaling을 처리하는 메서드이다. messageType으로 구별해서 각기 다른 로직들을 수행한다.
위에서 설명한대로 joinUser 메서드를 통해 UserSession 객체, WebRtcEndpoint 객체 등을 만들고 방에 참가시킨다. 객체 생성 후, Ice candidate 리스너를 등록한다. (실제 교환은 gatherCandidates() 이후에 동작하는 것 같다)
클라이언트로부터 sdp 교환 요청이 오면, 클라이언트에게 방에 누구누구가 있는지(프론트엔드에서도 방에 있는 사람들 만큼의 수신 객체를 만들어야하기 때문에) 알려주는 메세지를 보내고, processSdpOffer 메서드를 통해 Sdp 등록, Sdp Answer를 생성한다.
또한 gatherCandidates를 통해 Ice 후보자들을 수집하고 등록된 리스너로 메세지를 보낸다.
processSdpOffer
public void processSdpOffer(WebSocketSession webSocketSession, WebRtcEndpoint webRtcEndpoint, Message message) throws IOException {
String sdpOffer = message.getSdpOffer();
String senderId = message.getUserId();
String receiverId = message.getReceiverId();
String messageType = (senderId.equals("SCREEN_SHARING")) ? "SCREEN_SHARING_SDP_ANSWER" : "SDP_ANSWER";
//sdp answer 생성 후 다시 클라이언트에게 보내기
String sdpAnswer = webRtcEndpoint.processOffer(sdpOffer);
String jsonSdpAnswer = messageService.makeSdpAnswerMessage(sdpAnswer, messageType, senderId, receiverId);
messageService.sendSynchronizedMessage(jsonSdpAnswer, webSocketSession);
webRtcEndpoint.gatherCandidates();
}
쿠렌토에서 제공하는 메서드를 활용하면 난이도가 꽤나 쉬워진다. SCREEN_SHARING이란 것은 나중에 화면 공유 포스팅할때 설명하겠다.
이 messageType은 프론트엔드에서 수신 객체를 만들고, 그 객체에 대한 시그널링을 진행하기 위해 만들었다. 백엔드쪽에선 송신 객체와 차이가 없지만 프론트엔드의 요청에 의해서 만들었다. 로직은 SDP_OFFER와 90% 비슷하다.
프론트엔드에서도 IceCandidateFoundListener를 등록하면 자동적으로 Ice 교환 메세지들이 오는데, 이 메세지를 받아서 처리하는 messageType이다. 여기는 송신 객체, 수신 객체를 구별하기 위해 discriminateSenderEp, discriminateReceiverEP를 사용했다.
나는 송신, 수신 객체를 구별하기 위해 senderId, receiverId 등을 이용하여 구별하였는데, 이는 그냥 내가 생각한 로직이니까 구별하기 위해 사용했구나 정도로 생각하면 될 것 같다.
예를 들어, senderId=1, receiverId=null이면 1의 송신 객체, senderId=1, receiverId=2이면 1이 2의 미디어를 수신하기 위한 수신 객체... 이런식으로 구별했다.
public void processIceCandidate(WebRtcEndpoint senderWebRtcEndpoint, WebRtcEndpoint receiverWebRtcEndpoint, Message message) {
IceCandidatePayload payload = message.getIceCandidate();
IceCandidate iceCandidate =
new IceCandidate(payload.getCandidate(), payload.getSdpMid(), payload.getSdpMLineIndex());
//사용자 본인을 등록하는 경우
if (receiverWebRtcEndpoint == null) {
senderWebRtcEndpoint.addIceCandidate(iceCandidate);
}
//새로 들어오거나 기존에 있던 사람이 방에 있는 다른 사람을 등록하는 경우
else {
receiverWebRtcEndpoint.addIceCandidate(iceCandidate);
}
}
여기서도 addIceCandidate를 사용하여 등록하면 된다.
이 정도면 시그널링 순서랑 로직들을 어느정도 설명한 것 같다. 모든 코드를 보여주고 설명하기엔 너무 방대하기도 하고, 짜잘짜잘한 것들도 많아서 큰틀은 이 정도고 궁금한게 있으면 댓글을 통해 물어보면 성실히 답변하겠다.
Kurento 공식 github 등을 참고하면 자세한 코드들도 정석대로 볼 수 있다.