토이 프로젝트/Spring&Java 갖고놀기

Spring Boot Web Chatting : 스프링 부트로 실시간 채팅 만들기 (9) Kurento 를 이용한 그룹 화상 채팅 코드 분석

TerianP 2023. 1. 27.
728x90

0. 시작하면서

1) MediaServer 의 구현

- SFU 의 모습을 기억해두자. 특히 이 모습과 코드를 비교하면서 보면 더욱 이해가 쉽다.

 

2) 동작은 어떻게??

대략 이런 느낌이구나하고 넘어가고, 아래의 코드를 보면서 더 자세한 내용을 확인한다.

1. roomMap 을 통해 전체 room 을 조회, 추가, 수정, 삭제 한다
2. KurentoRoom 을 통해 화상 채팅 방을 만든다.
3. KurentoRoomManager 를 통해 KurentoRoom 에 대한 자세한 관리 - 조회, 추가, 수정, 삭제 -를 한다.
4. KurentoRegistry 는 이런 room 에 접속한 유저 - kurentoUserSession - 를 관리한다.  
5. KurentoUserSession webrtc 를 위해 각 유저에게 필요한 부분을 다룬다 - sdp, ice, 각 유저 endpoint 관리, incomingMedia, outgoingMedia 관리

 

대략 요런 느낌...난중에 그림으로 바꿀게요ㅋㅋ


1. KurentoConfig

  • 쿠렌토 서버와 연결하기 위한 설정에 대한 정보를 담는 class
  • Kurento 라이브러리를 임포트 후 KurentoHandler 와 KurentoClient() 를 Bean 으로 등록한다.
    • 이후 addHandler 에 kurentoHander() 를 등록한다. 이 다음 “/signal” 부분에는 websocket 을 통해 연결할 요청 주소 를 적어준다.
    • createWebSocketContainer() 부분은 메세지 버퍼 크기, 유휴 시간 초과 등 런타임 특성을 제어하는 설정값을 적어준다.
public class WebRtcConfig implements WebSocketConfigurer {
    /* TODO WebRTC 관련 */
    // signalHandler 대신 KurentoHandler 사용
//    private final SignalHandler signalHandler;

    // kurento 를 다루기 위한 핸들러
    @Bean
    public KurentoHandler kurentoHandler(){
        return new KurentoHandler();
    }

    // Kurento Media Server 를 사용하기 위한 Bean 설정
    // Bean 으로 등록 후 반드시!! VM 옵션에서 kurento 관련 설정을 해주어야한다.
    // 아니면 에러남
    @Bean
    public KurentoClient kurentoClient() {
        return KurentoClient.create();
    }

    // signal 로 요청이 왔을 때 아래의 WebSockerHandler 가 동작하도록 registry 에 설정
    // 요청은 클라이언트 접속, close, 메시지 발송 등에 대해 특정 메서드를 호출한다
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(kurentoHandler(), "/signal")
                .setAllowedOrigins("*");
    }

    // 웹 소켓에서 rtc 통신을 위한 최대 텍스트 버퍼와 바이너리 버퍼 사이즈를 설정한다?
    @Bean
    public ServletServerContainerFactoryBean createWebSocketContainer() {
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
        container.setMaxTextMessageBufferSize(32768);
        container.setMaxBinaryMessageBufferSize(32768);
        return container;
    }

}

 

2. KurentoRoomDto

  • 기존에는 화상채팅을 사용하기 위해서 따로 화상채팅에 대한 room class 를 만들지 않았고, type 으로 구분해서 하나의 클래스를 사용하였다.
    • 이렇게 하나로 사용하는 첫번째 이유는 하나의 room class 로 만들어야지만 나중에 추후에 프론트에서 roomlist 를 보다 쉽게 뿌려줄 수 있기 때문이었다.
    • 두번째 이유는 추후 DB 를 사용해서 room 정보를 저장하거나 두 개로 나누었던 채팅을 최종적으로는 하나의 서비스(화상+문자 동시에 되도록) 하는 것을 목표로했기 때문에였다. 하나로 해야지만 나중에 더 편할것 같아서…
  • 그러나 이렇게 하나로 가려는 고집이 결국 문제를 만들었다. 왜냐하면 kurento 를 사용하기 위한 여러 클래스들이 많았고, 이 때문에 기존 코드를 거의 다 뜯어고쳐야 할 상황에 처했다.
  • “차라리 내 코드 전체를 없애고, kurento 에 맞춰서 다시 설계를 해야할까?” 부터 시작해서 정말 많이 고민했다. 그러나 이렇게하면 “내 프로젝트에 kurento 를 적용한다” 가 아닌 “kurento 를 좀 더 좋게 개발한다” 가 되어버릴것이라고 생각했기에 조금 더 고민해서 kurento 를 뜯어보기로 했다.
    • 그렇게 찾은 방법은 기존의 ChatRoomDto 를 상속받은 KurentoRoomDto 를 사용하는 것이었다.
    • 이를 통해서 roomList map 에 chatRoomDto 와 함께 담을 수 있고, 동시에 userList 역시 사용 가능하게 되었다.
    • participants 변수는 한 채팅방 안에 존재하는 유저들을 저장하기 위한 변수로, 기존의 kurento 에서 사용하던 변수로 chatRoomDto의 userList 가 완전히 동일한 역할을 한다.
/**
 * @modifyBy SeJon Jang (wkdtpwhs@gmail.com)
 * @desc 화상채팅을 위한 클래스 ChatRoomDto 를 상속받음
 */
@Getter
@Setter
@NoArgsConstructor
@RequiredArgsConstructor
public class KurentoRoomDto extends ChatRoomDto implements Closeable {

  // 로깅 객체 생성
  private final Logger log = LoggerFactory.getLogger(KurentoRoomDto.class);

  private KurentoClient kurento;

  // 미디어 파이프라인
  private MediaPipeline pipeline;

  @NotNull
  private String roomId; // 채팅방 아이디
  private String roomName; // 채팅방 이름
  private int userCount; // 채팅방 인원수
  private int maxUserCnt; // 채팅방 최대 인원 제한

  private String roomPwd; // 채팅방 삭제시 필요한 pwd
  private boolean secretChk; // 채팅방 잠금 여부
  private ChatType chatType; //  채팅 타입 여부

  /**
   * @desc 참여자를 저장하기 위한 Map
   * TODO ConcurrentHashMap 에 대해서도 공부해둘 것!
   * */
  private ConcurrentMap<String, KurentoUserSession> participants;


//  // 채팅룸 이름?
//  private final String roomId;

  // 룸 정보 set
  public void setRoomInfo(String roomId, String roomName, String roomPwd, boolean secure, int userCount, int maxUserCnt, ChatType chatType, KurentoClient kurento){
    this.roomId = roomId;
    this.roomName = roomName;
    this.roomPwd = roomPwd;
    this.secretChk = secure;
    this.userCount = userCount;
    this.maxUserCnt = maxUserCnt;
    this.chatType = chatType;
    this.kurento = kurento;
    this.participants = (ConcurrentMap<String, KurentoUserSession>) this.userList;
  }

 

3. ChatRoomMap

- 채팅방을 담기위한 클래스. 이전과 달라진 것은 하나도 없다.
- 화상채팅 방에 접속한 유저는 ChatRoomMap 안에 있는 KurentoRoom 클래스 안에 있는 participants 에 닉네임과 KurentoSession 과 함께 저장된다.

/**
 * @desc Room 을 담기위한 클래스 => 싱글톤
 * */
// 싱글톤으로 생성
// 모든 ChatService 에서 ChatRooms가 공통된 필요함으로
@Getter
@Setter
public class ChatRoomMap {
    private static ChatRoomMap chatRoomMap = new ChatRoomMap();
    private ConcurrentMap<String, ChatRoomDto> chatRooms = new ConcurrentHashMap<>();

    private ChatRoomMap(){}

    public static ChatRoomMap getInstance(){
        return chatRoomMap;
    }

}

 

4. KurentoHandler : 중요 클래스 1

- 이름부터 중요하다. 무려 kurento 에서 시그널링을 담당하는 클래스 되시겠다.
- 사실 코드를 보면...어디선가 많이 봤던 모습을 보인다. 이전의 나의 코드를 봤던 사람들이나 아니면 시그널링 서버를 구현햇던 사람들이라면 알겠지만 정말 딱!! 그 모양이다. 더도 덜도 없이 정말 그 모양 그대로이다.
- 중요 코드라고 했지만 오히려 가장 고치지 않은못한 클래스이다. 사실 고칠게 거의 없었다. 특히 중간에 handleTextMessage 부분은 전혀 건들지 않았고, close 부분과 joinRoom, leaveRoom 부분만 고쳐서 사용했다.
- 여기서 중요한 부분은 handleTextMessage 부분인데, 이곳에서 kurentoRegistry 를 통해 KurentoUserSession 를 사용한다는 것을 유의하자. 역시 아주 중요한 클래스이다.
- 이전에 시그널링 서버를 구축 해봤던게 이렇게 도움이 되다니 굉장히 행복했다ㅋㅋ

package webChat.rtc;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import lombok.RequiredArgsConstructor;
import org.kurento.client.IceCandidate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import webChat.dto.KurentoRoomDto;
import webChat.service.chatService.KurentoManager;
import webChat.service.chatService.KurentoUserRegistry;

import java.io.IOException;

@RequiredArgsConstructor
public class KurentoHandler extends TextWebSocketHandler {

    // 로깅을 위한 객체 생성
    private static final Logger log = LoggerFactory.getLogger(KurentoHandler.class);

    // 데이터를 json 으로 넘겨 받고, 넘기기 때문에 관련 라이브러리로 GSON 을 사용함
    // gson은 json구조를 띄는 직렬화된 데이터를 JAVA의 객체로 역직렬화, 직렬화 해주는 자바 라이브러리 입니다.
    // 즉, JSON Object -> JAVA Object 또는 그 반대의 행위를 돕는 라이브러리 입니다.
    private static final Gson gson = new GsonBuilder().create();

    // 유저 등록? 을 위한 객체 생성
    @Autowired
   private KurentoUserRegistry registry;

   // room 매니저
    @Autowired
    private KurentoManager roomManager;

    // 이전에 사용하던 그 메서드
    @Override
    public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        final JsonObject jsonMessage = gson.fromJson(message.getPayload(), JsonObject.class);

        final KurentoUserSession user = registry.getBySession(session);

        if (user != null) {
            log.debug("Incoming message from user '{}': {}", user.getName(), jsonMessage);
        } else {
            log.debug("Incoming message from new user: {}", jsonMessage);
        }

        // 일전에 내가 만들었던 시그널링 서버와 동일하게 handleTextMessage 파라미터 message 로 값이 들어오면
        // swtich 문으로 해당 message 를 잘라서 사용한다.
        // 이때 message 는 json 형태로 들어온다
        // key : id 에 대하여
        switch (jsonMessage.get("id").getAsString()) {
            case "joinRoom": // value : joinRoom 인 경우
                joinRoom(jsonMessage, session); // joinRoom 메서드를 실행
                break;

            case "receiveVideoFrom": // receiveVideoFrom 인 경우
                // sender 명 - 사용자명 - 과
                final String senderName = jsonMessage.get("sender").getAsString();
                // 유저명을 통해 session 값을 가져온다
                final KurentoUserSession sender = registry.getByName(senderName);
                // jsonMessage 에서 sdpOffer 값을 가져온다
                final String sdpOffer = jsonMessage.get("sdpOffer").getAsString();
                // 이후 receiveVideoFrom 실행 => 아마도 특정 유저로부터 받은 비디오를 다른 유저에게 넘겨주는게 아닌가...?
                user.receiveVideoFrom(sender, sdpOffer);
                break;

            case "leaveRoom": // 유저가 나간 경우
                leaveRoom(user);
                break;

            case "onIceCandidate": // 유저에 대해 IceCandidate 프로토콜을 실행할 때
                JsonObject candidate = jsonMessage.get("candidate").getAsJsonObject();

                if (user != null) {
                    IceCandidate cand = new IceCandidate(candidate.get("candidate").getAsString(),
                            candidate.get("sdpMid").getAsString(), candidate.get("sdpMLineIndex").getAsInt());
                    user.addCandidate(cand, jsonMessage.get("name").getAsString());
                }
                break;

            default:
                break;
        }
    }

    // 유저의 연결이 끊어진 경우
    // 참여자 목록에서 유저 제거
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        KurentoUserSession user = registry.removeBySession(session);
//        roomManager.getRoom(user.getRoomName()).leave(user);
    }

    // 유저가 Room 에 입장했을 때
    private void joinRoom(JsonObject params, WebSocketSession session) throws IOException {
        // json 형태의 params 에서 room 과 name 을 분리해온다
        final String roomName = params.get("room").getAsString();
        final String name = params.get("name").getAsString();
        log.info("PARTICIPANT {}: trying to join room {}", name, roomName);

        // roomName 를 기준으로 room 으 ㄹ가져온다
        KurentoRoomDto room = roomManager.getRoom(roomName);

        // 유저명과 session 을 room 에 넘겨서 room 에 유저 저장
        final KurentoUserSession user = room.join(name, session);

        // 단순히 room 에 저장하는 것 외에도 user 를 저장하기 위한 메서드?
        registry.register(user);
    }

    // 유저가 room 에서 떠났을 때
    private void leaveRoom(KurentoUserSession user) throws IOException {
        // 유저명을 기준으로 room 을 가져온다
        final KurentoRoomDto room = roomManager.getRoom(user.getRoomName());
        // room 에서 유저를 제거하고
        room.leave(user);

        // room 에서 userCount -1
        room.setUserCount(room.getUserCount()-1);

    }

}

 

5. KurentoUserSession : 중요 클래스 2

- kurentoUserSession 은 쿠렌토 미디어 서버를 통해서 관리되는 유저들의 세션을 다루기 위한 클래스이다.
- 딱 보면 알겠지만 얘와 앞에서 설명한 KurentoHandler 은 아주 중요하고 특히 Handler 쪽에서 이 클래스를 많이 사용하는 모습을 보인다.
- SFU 형태로 구현한 코드이다. 위의 그림과 함께 생각하면 알겠지만 보내는 것은 하나, 받는 것은 여러개이다
=> outgoing 은 하나 incoming 은 여러개(map)이다.
- 사실 설명할 내용은 굉장히 많다. 나름 열심히 주석을 달고 이해했지만, 사실 완전히 이해를 한 것이 아닌 일부만 이해를 했고, 코드 역시 기존 코드에 일부만 뜯어서 사용하는 상황이라...진짜 중요한 부분만 설명하고 넘어간다.
- 차라리 원본 코드와 주석을 봐주세요ㅠ.ㅠ

만약 이해가 힘들다면 이렇게 생각해보자.
결국 kurento 를 사용하더라도 ICE 와 SDP 를 통해 각 유저들이 서로를 알고 서로에게 미디어 정보를 보내게 된다. 이때 유저가 늘어나면 늘어날 수록 서버는 각 유저를 알아야하고 구분해야한다. 그래야 각 유저들에게 필요한 미디어 정보를 줄 수 있을 것이다.

각 유저(KurentoUserSession)는 하나의 미디어 정보가 도달하는 EndPoint 즉 끝 점이라고 생각하고, 쿠렌토에서는 어떤 끝 점(EndPoint) 에 도달하면 되는지를 관리하기 위해 해당 클래스를 사용한다고 생각하면 편하다.

이 클래스에서 가장 중요한 부분은 incomingMedia 부분과 onEvent 부분이다.  
먼저 incomingMedia 부분은 String 을 key 로 하고 WebRtcEndpint 를 value 로 하는 map 형태로 되어있다. 이는 하나의 유저(kurentoSession) 이 어떤 endPoint 들에 대해서 미디어 정보를 받는지 저장하기 위한 변수이다. 이렇게 저장해두고 handler 에서 사람이 추가되거나 없어졌을 때 incomingMedia 에서 해당 유저를 추가하거나 삭제하면서 endpoint 정보를 조절한다.
다음으로 onEvent 부분이다. 이 부분은 솔직히 본인도 이해를 잘 못하고 넘어갔다ㅠㅠ 대략 어떤 이벤트 - 추가, 삭제 - 가 발생했을 때 이를 해당 룸(kurentoRoom) 에 있는 다른 유저와 ice 정보나 incomingMedia 정보를 동기화 시키는 부분인거 같은데...
아시는 분은 댓글로 설명 부탁드립니다!!
package webChat.rtc;

import com.google.gson.JsonObject;
import lombok.RequiredArgsConstructor;
import org.kurento.client.*;
import org.kurento.jsonrpc.JsonUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;

import java.io.Closeable;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

/**
 *
 * @modifyBy SeJon Jang (wkdtpwhs@gmail.com)
 * @desc 결국엔 여기서 중요한 것은 현재의 '나' 의 webRtcEndPoint 객체와 다른 사람들의 webRtcEndPoint 객체를 저장한 map 을 확인하고
 * 새로운 유저가 들어왔을 때 이를 나의 map 에 저장하고, 다른 사람들과 이를 동기화 해서 일치 시키는 것?
 */
@RequiredArgsConstructor
public class KurentoUserSession implements Closeable {

  private static final Logger log = LoggerFactory.getLogger(KurentoUserSession.class);

  private final String name;
  private final WebSocketSession session;

  private final MediaPipeline pipeline;

  private final String roomName;

  /**
   * @desc 현재 '나' 의 webRtcEndPoint 객체
   * 나의 것이니까 밖으로 내보낸다는 의미의 outgoingMedia
   * */
  private final WebRtcEndpoint outgoingMedia;

  /**
   * @desc '나'와 연결된 다른 사람의 webRtcEndPoint 객체 => map 형태로 유저명 : webRtcEndPoint 로 저장됨
   * 다른 사람꺼니까 받는다는 의미의 incomingMedia
   * */
  private final ConcurrentMap<String, WebRtcEndpoint> incomingMedia = new ConcurrentHashMap<>();

  /**
   * @Param String 유저명, String 방이름, WebSocketSession 세션객체, MediaPipline (kurento)mediaPipeline 객체
   */
  public KurentoUserSession(String name, String roomName, WebSocketSession session,
                            MediaPipeline pipeline) {

    this.pipeline = pipeline;
    this.name = name;
    this.session = session;
    this.roomName = roomName;

    // 외부로 송신하는 미디어?
    this.outgoingMedia = new WebRtcEndpoint.Builder(pipeline).build();

    // iceCandidateFounder 이벤트 리스너 등록
    // 이벤트가 발생했을 때 다른 유저들에게 새로운 iceCnadidate 후보를 알림
    this.outgoingMedia.addIceCandidateFoundListener(new EventListener<IceCandidateFoundEvent>() {


      @Override
      public void onEvent(IceCandidateFoundEvent event) {
        // JsonObject 생성
        JsonObject response = new JsonObject();
        // id : iceCnadidate, id 는 ice후보자 선정
        response.addProperty("id", "iceCandidate");
        // name : 유저명
        response.addProperty("name", name);

        // add 랑 addProperty 랑 차이점?
        // candidate 를 key 로 하고, IceCandidateFoundEvent 객체를 JsonUtils 를 이용해
        // json 형태로 변환시킨다 => toJsonObject 는 넘겨받은 Object 객체를 JsonObject 로 변환
        response.add("candidate", JsonUtils.toJsonObject(event.getCandidate()));

        try {
          /** synchronized 안에는 동기화 필요한 부분 지정*/
          // 먼저 동기화는 프로세스(스레드)가 수행되는 시점을 조절하여 서로가 알고 있는 정보가 일치하는 것
          // 여기서는 쉽게 말해 onEvent 를 통해서 넘어오는 모든 session 객체에게 앞에서 생성한 response json 을
          // 넘겨주게되고 이를 통해서 iceCandidate 상태를 '일치' 시킨다? ==> 여긴 잘 모르겟어요...
          synchronized (session) {
            session.sendMessage(new TextMessage(response.toString()));
          }
        } catch (IOException e) {
          log.debug(e.getMessage());
        }
      }
    });
  }

  /**
   * @desc 나의 webRtcEndpoint 객체를 return 함
   * @return webRtcEndpoint 객체
   * */
  public WebRtcEndpoint getOutgoingWebRtcPeer() {
    return outgoingMedia;
  }

  /**
   * @desc IncomingMedia return
   * @return ConcurrentMap<String, WebRtcEndpoint>
   */
  public ConcurrentMap<String, WebRtcEndpoint> getIncomingMedia() {
    return incomingMedia;
  }

  /**
   * @desc 이름 return
   * */
  public String getName() {
    return name;
  }

  /**
   * @desc webSocketSession 객체 return
   * */
  public WebSocketSession getSession() {
    return session;
  }

  /**
   * The room to which the user is currently attending.
   *
   * @return The room
   */
  public String getRoomName() {
    return this.roomName;
  }

  /**
   * @desc
   * @Param userSession, String
   * */
  public void receiveVideoFrom(KurentoUserSession sender, String sdpOffer) throws IOException {
    // 유저가 room 에 들어왓음을 알림
    log.info("USER {}: connecting with {} in room {}", this.name, sender.getName(), this.roomName);

    // 들어온 유저가 Sdp 제안
    log.trace("USER {}: SdpOffer for {} is {}", this.name, sender.getName(), sdpOffer);

    /**
     *
     *  @Desc sdpOffer 에 대한 결과 String
     */
    final String ipSdpAnswer = this.getEndpointForUser(sender).processOffer(sdpOffer);

    final JsonObject scParams = new JsonObject();
    scParams.addProperty("id", "receiveVideoAnswer");
    scParams.addProperty("name", sender.getName());
    scParams.addProperty("sdpAnswer", ipSdpAnswer);

    log.trace("USER {}: SdpAnswer for {} is {}", this.name, sender.getName(), ipSdpAnswer);
    this.sendMessage(scParams);
    log.debug("gather candidates");
    this.getEndpointForUser(sender).gatherCandidates();
  }

  /**
   * @Desc userSession 을 통해서 해당 유저의 WebRtcEndPoint 객체를 가져옴
   * @Param UserSession : 보내는 유저의 userSession 객체
   * @return WebRtcEndPoint
   * */
  private WebRtcEndpoint getEndpointForUser(final KurentoUserSession sender) {
    // 만약 sender 명이 현재 user명과 일치한다면, 즉 sdpOffer 제안을 보내는 쪽과 받는 쪽이 동일하다면?
    // loopback 임을 찍고, 그대로 outgoinMedia 를 return
    if (sender.getName().equals(name)) {
      log.debug("PARTICIPANT {}: configuring loopback", this.name);
      return outgoingMedia;
    }

    // 참여자 name 이 sender 로부터 비디오를 받음을 확인
    log.debug("PARTICIPANT {}: receiving video from {}", this.name, sender.getName());

    // sender 의 이름으로 나의 incomingMedia 에서 sender 의 webrtcEndpoint 객체를 가져옴
    WebRtcEndpoint incoming = incomingMedia.get(sender.getName());

    // 만약 가져온 incoming 이 null 이라면
    // 즉 현재 내가 갖고 있는 incomingMedia 에 sender 의 webrtcEndPoint 객체가 없다면
    if (incoming == null) {
      // 새로운 endpoint 가 만들어졌음을 확인
      log.debug("PARTICIPANT {}: creating new endpoint for {}", this.name, sender.getName());

      // 새로 incoming , 즉 webRtcEndpoint 를 만들고
      incoming = new WebRtcEndpoint.Builder(pipeline).build();

      // incoming 객체의 addIceCandidateFoundListener 메서드 실행
      incoming.addIceCandidateFoundListener(new EventListener<IceCandidateFoundEvent>() {

        @Override
        public void onEvent(IceCandidateFoundEvent event) {
          // json 오브젝트 생성
          JsonObject response = new JsonObject();

          // { id : "iceCandidate"}
          response.addProperty("id", "iceCandidate");
          // { name : sender 의 유저명}
          response.addProperty("name", sender.getName());
          // {candidate : { event.getCandidate 를 json 으로 만든 형태 }
          response.add("candidate", JsonUtils.toJsonObject(event.getCandidate()));
          try {
            // 새로 webRtcEndpoint 가 만들어 졌기 때문에 모든 UserSession 이 이것을 동일하게 공유해야 할 필요가 있다.
            // 즉 모든 UserSession 의 정보를 일치시키기 위해 동기화 - synchronized - 실행
            // 이를 통해서 모든 user 의 incomingMedia 가 동일하게 일치 - 동기화 - 됨
            synchronized (session) {
              session.sendMessage(new TextMessage(response.toString()));
            }
          } catch (IOException e) {
            log.debug(e.getMessage());
          }
        }
      });

      // incomingMedia 에 유저명과 새로 생성된 incoming - webrtcEndPoint 객체 - 을 넣어준다
      incomingMedia.put(sender.getName(), incoming);
    }

    log.debug("PARTICIPANT {}: obtained endpoint for {}", this.name, sender.getName());

    /** 여기가 이해가 안갔었음 */
    // sender 기존에 갖고 있던 webRtcEndPoint 와 새로 생성된 incoming 을 연결한다
    sender.getOutgoingWebRtcPeer().connect(incoming);

    return incoming;
  }

  public void cancelVideoFrom(final KurentoUserSession sender) {
    this.cancelVideoFrom(sender.getName());
  }

  public void cancelVideoFrom(final String senderName) {
    log.debug("PARTICIPANT {}: canceling video reception from {}", this.name, senderName);
    final WebRtcEndpoint incoming = incomingMedia.remove(senderName);

    log.debug("PARTICIPANT {}: removing endpoint for {}", this.name, senderName);
    incoming.release(new Continuation<Void>() {
      @Override
      public void onSuccess(Void result) throws Exception {
        log.trace("PARTICIPANT {}: Released successfully incoming EP for {}",
                KurentoUserSession.this.name, senderName);
      }

      @Override
      public void onError(Throwable cause) throws Exception {
        log.warn("PARTICIPANT {}: Could not release incoming EP for {}", KurentoUserSession.this.name,
                senderName);
      }
    });
  }

  @Override
  public void close() throws IOException {
    log.debug("PARTICIPANT {}: Releasing resources", this.name);
    for (final String remoteParticipantName : incomingMedia.keySet()) {

      log.trace("PARTICIPANT {}: Released incoming EP for {}", this.name, remoteParticipantName);

      final WebRtcEndpoint ep = this.incomingMedia.get(remoteParticipantName);

      ep.release(new Continuation<Void>() {

        @Override
        public void onSuccess(Void result) throws Exception {
          log.trace("PARTICIPANT {}: Released successfully incoming EP for {}",
                  KurentoUserSession.this.name, remoteParticipantName);
        }

        @Override
        public void onError(Throwable cause) throws Exception {
          log.warn("PARTICIPANT {}: Could not release incoming EP for {}", KurentoUserSession.this.name,
                  remoteParticipantName);
        }
      });
    }

    outgoingMedia.release(new Continuation<Void>() {

      @Override
      public void onSuccess(Void result) throws Exception {
        log.trace("PARTICIPANT {}: Released outgoing EP", KurentoUserSession.this.name);
      }

      @Override
      public void onError(Throwable cause) throws Exception {
        log.warn("USER {}: Could not release outgoing EP", KurentoUserSession.this.name);
      }
    });
  }

  public void sendMessage(JsonObject message) throws IOException {
    log.debug("USER {}: Sending message {}", name, message);
    synchronized (session) {
      session.sendMessage(new TextMessage(message.toString()));
    }
  }

  public void addCandidate(IceCandidate candidate, String name) {
    if (this.name.compareTo(name) == 0) {
      outgoingMedia.addIceCandidate(candidate);
    } else {
      WebRtcEndpoint webRtc = incomingMedia.get(name);
      if (webRtc != null) {
        webRtc.addIceCandidate(candidate);
      }
    }
  }

  /*
   * (non-Javadoc)
   *
   * @see java.lang.Object#equals(java.lang.Object)
   */
  @Override
  public boolean equals(Object obj) {

    if (this == obj) {
      return true;
    }
    if (obj == null || !(obj instanceof KurentoUserSession)) {
      return false;
    }
    KurentoUserSession other = (KurentoUserSession) obj;
    boolean eq = name.equals(other.name);
    eq &= roomName.equals(other.roomName);
    return eq;
  }

  /*
   * (non-Javadoc)
   *
   * @see java.lang.Object#hashCode()
   */
  @Override
  public int hashCode() {
    int result = 1;
    result = 31 * result + name.hashCode();
    result = 31 * result + roomName.hashCode();
    return result;
  }
}

 

6. KurentoManager

- KurentoRoom 을 관리하기 위한 클래스.
- 이전에 rtcService 에서 했던 기능들 - 조회, 추가, 수정, 삭제 - 들이 여기서 이뤄진다.

 
package webChat.service.chatService;

import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import webChat.dto.ChatRoomDto;
import webChat.dto.ChatRoomMap;
import webChat.dto.KurentoRoomDto;

import java.util.concurrent.ConcurrentMap;

/**
 * @modifyBy SeJon Jang (wkdtpwhs@gmail.com)
 * @Desc KuirentoRoom 을 관리하기 위한 클래스
 */
@Service
@RequiredArgsConstructor
public class KurentoManager {

  // 로깅을 위한 객체 생성
  private final Logger log = LoggerFactory.getLogger(KurentoManager.class);

  // kurento 미디어 서버 연결을 위한 객체 생성?
//  private final KurentoClient kurento;

  /**
   * @desc room 정보를 담은 map
   * */
//  private final ConcurrentMap<String, KurentoRoom> rooms = new ConcurrentHashMap<>();
  private final ConcurrentMap<String, ChatRoomDto> rooms = ChatRoomMap.getInstance().getChatRooms();

  /**
   *
   * @Desc room 정보 가져오기
   * @param roomId room 이름
   * @return 만약에 room 이 있다면 해당 room 객체 return 아니라면 새로운 room 생성 후 return
   */
  public KurentoRoomDto getRoom(String roomId) {
    log.debug("Searching for room {}", roomId);

    // roomName 기준으로 room 가져오기
    KurentoRoomDto room = (KurentoRoomDto) rooms.get(roomId);

    // room return
    return room;
  }

  /**
   *
   * @param room
   * @Desc room 삭제
   */
  public void removeRoom(KurentoRoomDto room) {
    // rooms 에서 room 객체 삭제 => 이때 room 의 Name 을 가져와서 조회 후 삭제
    this.rooms.remove(room.getRoomId());

    // room 을 종료?
    room.close();

    log.info("Room {} removed and closed", room.getRoomId());
  }

}

 

7. KurentoRegistry

- KurentoUserSession 을 다루기 위한 - 조회, 추가, 수정, 삭제 - 클래스. 일종의 userService 클래스라고 생각하면 편하다.

package webChat.service.chatService;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.web.socket.WebSocketSession;
import webChat.rtc.KurentoUserSession;

import java.util.concurrent.ConcurrentHashMap;

/**
 * Map of users registered in the system. This class has a concurrent hash map to store users, using
 * its name as key in the map.
 *
 * 유저를 관리하는 클래스로 concurrent hash map 을 쓰는데 유저명을 key 로 사용함
 *
 * @author Boni Garcia (bgarcia@gsyc.es)
 * @author Micael Gallego (micael.gallego@gmail.com)
 * @author Ivan Gracia (izanmail@gmail.com)
 * @modifyBy SeJon Jang (wkdtpwhs@gmail.com)
 */
@Service
@RequiredArgsConstructor
public class KurentoUserRegistry {

  /**
   * @Desc 유저명 - userSession 객체 저장 map
   * */
  private final ConcurrentHashMap<String, KurentoUserSession> usersByName = new ConcurrentHashMap<>();

  /**
   * @Desc 세션아이디 - userSession 객체 저장 map
   * */
  private final ConcurrentHashMap<String, KurentoUserSession> usersBySessionId = new ConcurrentHashMap<>();

  /**
   * @Desc userSession 을 파라미터로 받은 후 해당 객체에서 userName 과 sessionId 를 key 로해서 userSession 저장
   * @Param userSession
   * */
  public void register(KurentoUserSession user) {
    usersByName.put(user.getName(), user);
    usersBySessionId.put(user.getSession().getId(), user);
  }

  /**
   * @Desc 유저명으로 userSession 을 가져옴
   * @Param userName
   * @Return userSession
   * */
  public KurentoUserSession getByName(String name) {
    return usersByName.get(name);
  }

  /**
   * @Desc 파라미터로 받은 webSocketSession 로 userSession 을 가져옴
   * @Param WebSocketSession
   * @Return userSession
   * */
  public KurentoUserSession getBySession(WebSocketSession session) {
    return usersBySessionId.get(session.getId());
  }

  /**
   * @Desc 파라미터로 받은 userName 이 usersByName map 에 있는지 검색
   * @Param String userName
   * @Return Boolean
   * */
  public boolean exists(String name) {
    return usersByName.keySet().contains(name);
  }

  /**
   * @Desc 파라미터로 WebSocketSession 을 받은 후 이를 기준으로 해당 유저의 userSession 객체를 가져옴,
   *        이후 userByName 과 userBySessionId 에서 해당 유저를 삭제함
   * @Param WebSocketSession session
   * @return userSession 객체
   * */
  public KurentoUserSession removeBySession(WebSocketSession session) {
    final KurentoUserSession user = getBySession(session);
    usersByName.remove(user.getName());
    usersBySessionId.remove(session.getId());
    return user;
  }

}

 

8. Html, JS

1) html

- 아주 단순한 html 코드. kurento-utils 와 adapter-latest, getscreenId js 가 꼭 필요하다.

<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">

    <script src="https://code.jquery.com/jquery-3.6.1.min.js"></script>
    <script src="https://cdn.WebRTC-Experiment.com/getScreenId.js"></script>
    <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
    <script src="/js/rtc/participant.js"></script>
    <script src="/js/rtc/kurento-utils.js"></script>
    <script src="/js/rtc/conferenceroom.js"></script>

    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css">
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.0/js/bootstrap.min.js"></script>
    <link rel="styleSheet" href="/css/rtc/kurentostyle.css" type="text/css" media="screen">


</head>
<body>
<div id="container">
    <div id="wrapper">
        <div id="room">
            <input type="hidden" id="roomId" th:value="${roomId}">
            <input type="hidden" id="uuid" th:value="${uuid}">

            <div class="col-lg-12 mb-3">
                <div class="d-flex justify-content-around mb-3">
                    <div class="mr-2" data-toggle="buttons">
                        <input type="button" class="btn btn-outline-success" id="screenShareBtn" value="share Off"
                               data-flag="false" onclick="screenShare()">
                    </div>
                </div>
            </div>

            <h2 id="room-header"></h2>
            <div id="participants"></div>
            <input type="button" id="button-leave" onmouseup="leaveRoom();"
                   value="Leave room">
        </div>
    </div>
</div>
</body>
</html>

 

2) kurento-service.js

- 딱 포인트만 기억하자. 물론 다 기억하면 좋겠지만, 직접 쿠렌토의 클라이언트 js 를 고칠게 아니라면 굳이 손댈필요가 없다고 생각한다.
- 두가지만 기억하면 되는데 하는 WebSocket 부분에서 어떻게 연결하는지이다. 이 부분은 이전과 동일하게 new WebSocket(url) 을 사용한다.
- 230225 기준 화면 공유쪽 코드가 많이 변경된 관계로 기존의 코드는 지우도록 하겠습니다. 자세한 내용은 git 에서 해당 코드를 확인해주세요

사실 화면 공유 코드를 보면 살짝 의문이 들 수 도 있다. 나는 내가 짜면서도 의문을 느꼈다.
바로 kurento 를 사용해서 미디어를 전송하는데 그냥 이전처럼 바로 video track 를 바꿔서 전송하는 방법을 써도 될까? 에 대한 의문이다.
처음에는 이전처럼 바로 video track 을 바꾸는 방법을 선택하더라도 크게 이상이 없을 것이라고 생각했다.
사실 kurento 던 뭐던 결국 webrtc 를 사용하는 것은 동일하기 때문에 크게 문제는 없다고 봤던 것이다. 그런데 하다보니까 궁금해졌던 부분이 "이렇게 video track 를 바꿨을 때 kurento 를 통해서 전달되는 것인지 아니면 바로 kurento 를 통해 연결된 다른 사람들에게 video track 만 바꿔서 다이렉트로 보내는건지" 고민이 되었다.

고민이 가속화 되었던 계기는 아래에 적어둔 stackoverflow 에서 찾은 내용 때문이다. 저기에서는 다른 방법을 사용하는 듯 한데...결국 그 방법으로는 못하고, 이전에 내가 사용했던 그대로 구현하게 되었고, 결과적으로 기능에 한해서는 성공했다.

이 부분에 대해서도 잘 아시는 분은 댓글 부탁드립니다.
// var script = document.createElement('script');
// script.src = "https://code.jquery.com/jquery-3.6.1.min.js";
// document.head.appendChild(script);

// websocket 연결 확인 후 register() 실행
var ws = new WebSocket('wss://' + location.host + '/signal');
ws.onopen = () => {
    register();
}

// console.log("location.host : "+location.host)
var participants = {};

let name = null;
let roomId = null;
const constraints = {
    // 'volume', 'channelCount', 'echoCancellation', 'autoGainControl', 'noiseSuppression', 'latency', 'sampleSize', 'sampleRate'
    audio: {
        autoGainControl: false,
        channelCount: 2,
        echoCancellation: false,
        latency: 0,
        noiseSuppression: false,
        sampleRate: 48000,
        sampleSize: 16,
        volume: 1.0
    },
    video: {
        width: 1200,
        height: 1000,
        maxFrameRate: 50,
        minFrameRate: 40
    }
};


---------------------------- 굉장히_많은_코드.js ----------------------------


/*  화면 공유를 위한 변수 선언 */
const screenHandler = new ScreenHandler();
let shareView = null;

<--- 수정된 코드 git 에서 확인해주세요 --->

 


Reference

- 문제의 kurento screen sharing
https://stackoverflow.com/questions/43843576/kurento-screen-sharing-in-room

 

Kurento screen sharing in room

I am currently facing some issues sharing my screen with any Kurento room that I join. For now I am using the repo (https://github.com/TribeMedia/kurento-group-call-1) and making modifications on the

stackoverflow.com


- 공식 문서
https://doc-kurento.readthedocs.io/en/latest/index.html#

 

Welcome to Kurento — Kurento 6.18.0 documentation

Warning Kurento is a low-level platform to create WebRTC applications from scratch. You will be responsible of managing STUN/TURN servers, networking, scalability, etc. If you are new to WebRTC, we recommend using OpenVidu instead. OpenVidu is an easier to

doc-kurento.readthedocs.io

https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getUserMedia

 

Navigator.getUserMedia() - Web APIs | MDN

The deprecated Navigator.getUserMedia() method prompts the user for permission to use up to one video input device (such as a camera or shared screen) and up to one audio input device (such as a microphone) as the source for a MediaStream.

developer.mozilla.org


- special thanks to
https://github.com/AndoneKwon/morse

 

GitHub - AndoneKwon/morse

Contribute to AndoneKwon/morse development by creating an account on GitHub.

github.com

https://andonekwon.tistory.com/71

 

WebRTC란? (시그널링 과정 feat. Kurento Media Server) (3)(작성중)

이전 글 복습 NAT 환경 같은 경우에는 자신은 Private IP를 가지고 있어서 시그널링을 할 때 Peer to Peer로 통신을 할 수 있는 방법이 없다. 따라서 자신의 퍼블릭 IP를 알아내기 위해 STUN서버를 통해서

andonekwon.tistory.com

 

댓글