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

Spring Boot Web Chatting : 스프링 부트로 실시간 화상 채팅 만들기 (11) Datachannel && 화면 제어 기능 추가

TerianP 2023. 11. 11.
728x90

1. 시작하면서

오늘도 엄청나게 오랜만에 글을 쓰는 듯 합니다. 마지막 글을 보니 벌써 1달이 넘어갔네요...와 빠르다ㅠㅠ

물론 그래도 이번에는 그냥 놀기만 하지는 않았습니다!! 10월은 한달 내내 chatForYou 프로젝트의 기능을 추가하고 개선하고 하는 한 달이었습니다.

 

사실 기능 추가하고 개선하고 할 때마다 글을 쓸까했는데...세상에 너무 많아서 이거 다 쓰다가는 오히려 개선하는 작업에 시간 투자가 안될 듯하여 미루고 미루다가 이제야 글을 쓰게 되었습니다ㅠㅠㅠ

 

이번 글에서는 가장 중요하게 생각되는 두 가지! 바로 datachannel 을 사용한 채팅 기능과 각종 화면 제어 기능에 대해서만 설명하고 가겠습니다. 더 자세하게 뭐가 바뀌었고 뭐가 추가되었는가? 에 대해서는 아래 git 이슈 부분을 참고해주시기 바랍니다!

https://github.com/SeJonJ/Spring-WebSocket-WebRTC-Chatting/issues?q=is%3Aopen+is%3Aissue

 

GitHub - SeJonJ/Spring-WebSocket-WebRTC-Chatting: SpringBoot WebSocket & WebRTC Chatting

SpringBoot WebSocket & WebRTC Chatting. Contribute to SeJonJ/Spring-WebSocket-WebRTC-Chatting development by creating an account on GitHub.

github.com

 

2. Datachannel : 내가 이겼다!!

하...정말 이 친구에 대해서는 할 이야기가 많습니다. 지난 몇개월간 저를 엄청나게 괴롭혔던 친구였거든요. 정말정말 구현하고 싶었는데...저번 글에도 이야기했지만 정말 어이없게 성공해버렸습니다. 

물론 단순히 '채팅' 에 한해서는 웹소켓으로 통신해서 채팅하는 방법도 있습니다. 그러나 이왕에 webrtc 에 발을 들여놓은 이상 이와 관련된 다른 여러 기능들도 함께 사용하고 싶었거든요. 뭣보다 제 최종 목표를 위해서는 datachannel 이 꼭! 필요했기도 하고 말이죠

 

1) Datachannel 그래서 너는 뭔데?

아마 Web Socket 방식의 채팅은 많은 사람들에게 익숙할 것이다. 어쩌면 가장 널리 알려져있고, 가장 단순?하기 때문이다.

반면 Datachannel 에 대해서는 '그게 뭐임?' 이라고 던지는 사람이 많을 것이라고 생각한다. 나 역시 그랬다. webrtc 로 화상채팅을 구현하고 있을 무렵 블로그에 방문해주신 'andonekwon' 선생님께서 '이제 Datachannel 도 한번 구현해보시죠' 라고 하기에 그런것도 있나? 하고 들어갔던 것이기 때문이다.

 

datachannel 은 WebRTC 의 기능 중 일부이다. 정확히는 영상을 전송할 수 있는 채널이 아닌 말 그대로 data 를 전달하기 위한 WebRTC 의 기능이다. WebRTC 를 베이스로 하기 때문에 WebRTC 의 성질을 그대로 이용 가능하다. 또한 SCTP(Stream Control Transmission Protocol)을 이용하기 때문에 일반적인 UDP와는 달리 비순차 전송, 재전송을 설정 가능하다.

WebRTC 프로토콜 스택(출처 : https://hpbn.co/webrtc/)

 

2) SCTP : TCP + UDP

STCP 란 UDP의 메세지 스트리밍 특성과 TCP의 연결형 및 신뢰성 제공을 조합한 차세대 프로토콜이다.

이게 뭔 말인지 알려면 기존에 우리에게 익숙한 TCP 와 UDP 를 한번 살펴봐야한다.

 

- TCP : TCP는 OSI7계층중 4계층인 전송계층에서 사용하는 신뢰성이 중요한 어떤 응용에 의해 사용될 수 있는 신뢰성 있는 연결-지형 프로토콜이다. 연결지향, 신뢰성 두가지를 보면 알겠지만 TCP 는 흐름제어와 혼잡제어, 오류 제어라는 특징을 갖는다. 다만 딱 보면 알겠지만 UDP 에 비하면 데이터 전송 속도가 느리다는 단점이 있다.

 

- UDP : UDP는 오류제어가 응용층 프로세스에 의해서 제공되는 으용에서 단순성과 효율성으로 사용되는 신뢰성 없는 비연결 전송층 프로토콜이다. UDP 는 일단 흐름제어와 오류제어가 없는 비연결 프로토콜이기에 속도가 빠르다는 장점이 있다. 즉, 도착하기 까지의 경로를 지정하지 않아서 목적지는 부산이라고 하더라도 데이터가 비행기를 타고 가는지, 기차를 타고 가는지 ,걸어가는지 우리는 알지 못하게 알아가서 가게 되고 이로 인해 데이터 도착 순서도 보장되지 않으며, 중간에 데이터가 손상되거나 중복되어도 해결 방법이 없다.

 

- SCTP : TCP 와 UDP 의 특성을 결합한 새로운 전송층 프로토콜이다. 프로세스-프로세스간 통신을 제공하며 TCP 와 마찬가지로 연결 지향의 신뢰성 있는 연결을 제공한다. 따라서 오류제어와, 혼잡제어, 흐름제어도 가능하다. 동시에 UDP 의 데이터 지향 및 빠른 전송의 특성도 함께 갖는다. 이는 즉 UDP 의 멀티캐스트처럼 SCTP는 멀티스트리밍(multi-streaming) 및 멀티호밍(multi-homing) 특성을 제공한다. 멀티 스트리밍 특성을 통해 TCP의 HOL(Head-of-Line) 블로킹 문제를 해결하고 있으며, 멀티호밍 특성을 통해, IP 경로 장애에 대한 복구(fail-over) 기능을 제공한다.

 

3. DataCahnnel 을 사용한 채팅 구현

설명은 여기까지하고 이제 바로 코드로 넘어가보자

 

1) KurentoUserSession

- outgoingMedia 와 incomingMedia 에서 useDatachannels() 을 선언해서 WebRtcEndPoint 를 생성한다.

- 이는 kurento 서버에 datachannel 을 사용할 것임을 알려줘서 datachannel 과 관련된 내용이 들어왔을 때 이를 처리할 수 있도록 해준다.

- 당연하지만 이거 안해주면 에러난다ㅠㅠ 이전에 datachannel 구현이 안되었던 원인 1번

// 보내는 media
this.outgoingMedia = new WebRtcEndpoint.Builder(pipeline)
        .useDataChannels()
        .build();
        
        ----
       
// 들어오는 media
incomingMedia = new WebRtcEndpoint.Builder(pipeline)
        .useDataChannels()
        .build();

 

2) kurento-service.js

- new kurentoUtils.WebRtcPeer.WebRtcPeerSendrecv 의 파라미터 중 option 객체에 datachannel 관련된 속성을 추가한다.

여기서 onExistingParticipants 와 receiveVideo 가 option 이 서로 다른 이유는 모두 설정하게 되면 onopen event 와 onclose 이벤트가 중복으로 발생하는 문제가 있어서 이를 해결하기 위해 한곳만 onopen 과 onclose 이벤트를 달아두었다. onopen evnet 는 참여자가 datachannel 에 입장했을 때 이벤트를 의미하며, onclose 는 참여자가 퇴장했을 때(datachannel 통신이 끊어질때)를 의미한다

- 여기서 특히!! 주요한 부분은 turnserver 와 설정을 담당하는 configuration 부분이다. 이 부분에서 꼭! turnserver 에 대한 설정을 해주어야한다. 이걸 안 했을 때는 로컬환경에서 문제가 생기는 경우가 있다. 동시에 datachannel 에서도 문제가 발생한다.

=> 이 부분이 datachannel 구현 원인 2번

function onExistingParticipants(msg) {

    var participant = new Participant(name);
    participants[name] = participant;
    dataChannel.initDataChannelUser(participant);
    var video = participant.getVideoElement();
    var audio = participant.getAudioElement();

    function handleSuccess(stream) {
        var hasVideo = constraints.video && stream.getVideoTracks().length > 0

        var options = {
            localVideo: hasVideo ? video : null,
            localAudio: audio,
            mediaStream: stream,
            mediaConstraints: constraints,
            onicecandidate: participant.onIceCandidate.bind(participant),
            dataChannels : true, // dataChannel 사용 여부
            dataChannelConfig: { // dataChannel event 설정
                id : dataChannel.getChannelName,
                // onopen : dataChannel.handleDataChannelOpen,
                // onclose : dataChannel.handleDataChannelClose,
                onmessage : dataChannel.handleDataChannelMessageReceived,
                onerror : dataChannel.handleDataChannelError
            },
            configuration: {
                iceServers: [
                    {
                        urls: turnUrl,
                        username: turnUser,
                        credential: turnPwd
                    }
                ]
            }
        };

        participant.rtcPeer = new kurentoUtils.WebRtcPeer.WebRtcPeerSendrecv(options,
            function(error) {
                if (error) {
                    return console.error(error);
                }

                this.generateOffer(participant.offerToReceiveVideo.bind(participant));
            });

        msg.data.forEach(receiveVideo);
    }

    navigator.mediaDevices.getUserMedia(constraints)
        .then(handleSuccess)
}

function receiveVideo(sender) {
    var participant = new Participant(sender);
    participants[sender] = participant;
    var video = participant.getVideoElement();
    var audio = participant.getAudioElement();

    var options = {
        remoteVideo: video,
        remoteAudio : audio,
        onicecandidate: participant.onIceCandidate.bind(participant),
        dataChannels : true, // dataChannel 사용 여부
        dataChannelConfig: { // dataChannel event 설정
            id : dataChannel.getChannelName,
            onopen : dataChannel.handleDataChannelOpen,
            onclose : dataChannel.handleDataChannelClose,
            onmessage : dataChannel.handleDataChannelMessageReceived,
            onerror : dataChannel.handleDataChannelError
        },
        configuration: { // 이 부분에서 TURN 서버 연결 설정
            iceServers: [
                {
                    urls: turnUrl,
                    username: turnUser,
                    credential: turnPwd
                }
            ]
        }
    }

    participant.rtcPeer = new kurentoUtils.WebRtcPeer.WebRtcPeerSendrecv(options,
        function (error) {
            if (error) {
                return console.error(error);
            }
            this.generateOffer(participant.offerToReceiveVideo.bind(participant));
        });

    participant.rtcPeer.peerConnection.onaddstream = function(event) {
        audio.srcObject = event.stream;
        video.srcObject = event.stream;
    };
}

 

3) datachannel.js & datachannelChatting.js

- 이 두가지 js  는 말 그대로 datachannel 의 기능을 담당하는 js 이다. datachannel 의 이벤트도 워낙 방대하고 앞으로 더욱 관련된 기능을 넣을 것이기에 완전히 따로 빼서 만들었다.

- 두 가지 js 는 사실 크게 어려운 내용은 없다. 솔직히 이벤트 선언하고, 불러와서 사용하고 하는 정도라....절대 설명하기 귀찮아서가 아니다

 

3-1) datachannel.js

- 그나마 중요하다고 생각되는 받은 메시지를 show 하는 부분. html 에 선언된 chatting 부분에 recvMessage 를 보여주는 부분으로 type 은 '내'가 보냈는지 '다른사람' 이 보냇는지에 따라서 채팅을 보여주는 위치 왼쪽 - 오른쪽을 조절한다.

showNewMessage: function(recvMessage, type) { // 이거는 datachannelChatting 으로 넘어가야하는거...?
    // 기본은 '나'가 보낸것
    type = type === undefined ? 'self' : type;

    if (type === 'self') {
        if (!recvMessage) return;

        dataChannelChatting.messagesContainer.append([
            '<li class="self">',
            recvMessage,
            '</li>'
        ].join(''));

        this.sendMessage(recvMessage);

        // clean out old message
        dataChannelChatting.userTextInput.html('');

        // focus on input
        dataChannelChatting.userTextInput.focus();

        dataChannelChatting.messagesContainer.finish().animate({
            scrollTop: dataChannelChatting.messagesContainer.prop("scrollHeight")
        }, 250);

    } else {
        dataChannelChatting.messagesContainer.append([
            '<li class="other">',
            recvMessage,
            '</li>'
        ].join(''));
    }
}

 

4. 채팅방 화면 제어 기능 : 볼륨 조절, 음소거, 화상 화면 제어

사실 지금까지 꼭 필요했던 기능 중 하나였다. 그리고 친구들이 가장 많이 요구한 기능이기도 하다. '소리 조절하게 해줘', '음소거 기능 넣어줘' 등등등 디스코드에 있는데 내 쪽에는 없던 기능들 때문에 내 chatForYou 가 점점 밀려나서 한동안은 디스코드를 사용할 수 밖에 없었다ㅠㅠ

 

정말로 다행이었던 점은 기능을 구현하는데 크게 어려움이 없었다는 점이다. 의외로 스무스하게 수정할 수 있었다. 아마 이 부분은 화면 공유를 구현하면서 remoteStream() 과 localStream() 을 열심히 만졌기 때문이 아닐까...? 라는 생각이 든다.

 

1) paticipant.js

- 기본적으로 각 유저마다 볼륨을 조절하는 옵션이 화상 화면 밑에 추가된다. 동시에 자신의 비디오와 오디오를 on, off 할 수 있는 버튼과 다른 유저의 비디오, 오디오를 on, off 할 수 있는 버튼이 추가된다.

- userSetting 을 누르면 모달창이 뜨고 거기서 나와 다른 유저에 대한 설정을 조절 할 수 있다

- '나' 즉, local user 에 대한 설정은 rtcPeer 의 localStream() 의 audio 와 video 의 enabled 속성을 true, false 로 조절한다. 마찬가지로 '다른 유저'는 remoteStream() 을 조절한다.

- local 에 대한 설정과 remote 에 대한 설정을 구분하고 조절하기 위해서 각 버튼 id 에는 각 유저의 name 값이 들어간다. 즉 video_[name] 로 이때 로컬 유저는 'participant main' 태그의 id 값을 로컬 유저의 name 으로 설정했다. 이를 통해 participant main 클래스의 id 만 확인하면 현재 로컬 유저가 누구인지 파악 가능하다.

// "유저 설정" 버튼을 클릭할 때 모달을 설정합니다.
$('#userSetting').on('click', function (e) {
   let participantsList = $('#participantsList');
   participantsList.empty(); // 기존 목록을 비웁니다.

   // participants 객체를 반복하여 각 참가자에 대한 정보를 목록에 추가합니다.
   $.each(participants, function (name, participant) {
      let listItem = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
      let localUser = participant.getLocalUser(); // 로컬 user 의 id 확인

      // 볼륨 조절 슬라이더의 ID
      let volumeSliderId = 'volumeControl_' + name;
      // 기존의 볼륨 컨트롤을 찾아서 복사합니다.
      let existingVolumeSlider = $('#' + volumeSliderId);
      // 기존의 볼륨 컨트롤이 있으면 복사하여 사용합니다.
      let volumeSlider = existingVolumeSlider.clone(true);

      // 비디오 및 오디오 컨트롤 버튼 복사 및 ID 수정
      let videoButtonId = 'videoBtn_' + name;
      let audioButtonId = 'audioBtn_' + name;

      if (localUser === name) { // 사용자 본인의 video, audio 설정
         listItem.text('You');

         let videoButton = $('#videoBtn').clone(true).attr('id', videoButtonId);
         let audioButton = $('#audioBtn').clone(true).attr('id', audioButtonId);

         listItem.append(videoButton, audioButton, volumeSlider);
      } else { // 다른 유저의 video, audio 설정
         listItem.text(name);

         // 비디오 컨트롤 버튼 clone 및 이벤트 할당
         let remoteVideoButton = $('#videoBtn').clone().attr('id', videoButtonId);
         // localVideoToggle class 삭제
         remoteVideoButton.removeClass('localVideoToggle');
         // 클릭 이벤트 할당
         remoteVideoButton.click(function(){
            let useRemoteVideo = remoteVideoButton.data("flag")
            // 비디오 트랙만 가져오기
            let videoTrack = participant.rtcPeer.getRemoteStream().getTracks().filter(track => track.kind === 'video')[0];

            if (useRemoteVideo) { // 비디오가 사용중이라면 비디오 off : enabled = false
               videoTrack.enabled = false;
               remoteVideoButton.val("Video On");
               remoteVideoButton.data("flag", false);

            } else {
               videoTrack.enabled = true;
               remoteVideoButton.val("Video Off");
               remoteVideoButton.data("flag", true);
            }
         })

         // 다른 참여자(유저)의 오디오 컨트롤 버튼 clone 및 이벤트 할당
         let remoteAudioButton = $('#audioBtn').clone().attr('id', audioButtonId);
         // localAudioToggle class 삭제
         remoteAudioButton.removeClass('localAudioToggle')
         // 클릭 이벤트 할당
         remoteAudioButton.click(function(){
            let useRemoteVideo = remoteAudioButton.data("flag")
            // 오디오 트랙만 가져오기
            let audioTrack = participant.rtcPeer.getRemoteStream().getTracks().filter(track => track.kind === 'audio')[0];

            if (useRemoteVideo) { // 오디오가 사용중이라면 오디오 off : enabled = false
               audioTrack.enabled = false;
               remoteAudioButton.val("Audio On");
               remoteAudioButton.data("flag", false);

            } else {
               audioTrack.enabled = true;
               remoteAudioButton.val("Audio Off");
               remoteAudioButton.data("flag", true);
            }
         })

         listItem.append(remoteVideoButton, remoteAudioButton, volumeSlider);
      }

      participantsList.append(listItem);
   });
});

 

5. 구현 영상

- 채팅 오디오 소리는 짤려서 반정도는 혼잣말 한 것처럼 나오네요ㅠㅠ

 

Reference

항상 도움을 받습니다 감사합니다

https://andonekwon.tistory.com/65

 

WebRTC Data Channel

WebRTC Data Channel이 뭔데? 처음 회사에서 프로젝트를 진행할때 만해도 WebRTC를 동영상 스트리밍 용도로 사용하고 채팅은 4 Layer단의 TCP 소켓을 이용하거나 Web Socket을 사용하여 구현할 예정이었다.

andonekwon.tistory.com

 

https://novice-programmer-story.tistory.com/30

 

Ch 14. 멀티캐스트 & 브로드캐스트

모든 내용은 [윤성우 저, "열혈강의 TCP/IP 소켓 프로그래밍", 오렌지미디어] 를 기반으로 제 나름대로 이해하여 정리한 것입니다. 다소 부정확한 내용이 있을수도 있으니 이를 유념하고 봐주세요!

novice-programmer-story.tistory.com

 

https://physicallaw.tistory.com/115

 

[컴퓨터 네트워크] UDP와 SCTP

UDP의 구조와 특성 TCP가 비효율적인 경우도 있다. 실시간성을 중요시하거나 응답성을 중요시하는 프로그램이 대표적인데 이런 경우를 위해 개발된 것이 사용자 데이터그램 프로토콜인 UDP(User Dat

physicallaw.tistory.com

 

https://ettrends.etri.re.kr/ettrends/81/0905000417/18-3_011_020.pdf

 

 

https://itsandtravels.blogspot.com/2018/11/tcp-udp-sctp.html

 

TCP, UDP, SCTP의 특징 및 연결과정

공돌이의 여행 방문기와 맛집 탐방기 네트워크, 보안 관련 정보와 자격증 정보도 함께 기록중

itsandtravels.blogspot.com

 

 

댓글