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

Spring Boot Web Chatting : 스프링 부트로 실시간 채팅 만들기 (10) 오디오만을 사용한 화상채팅 개발 & kubernetes 배포

TerianP 2023. 9. 17.
728x90

1. 개발일지 : 오디오만을 사용한 화상채팅

정말 정말 오랜만에 화상채팅 프로젝트에 관해서 글을 쓰는 것 같습니다. 굉장히 오랜만에 프로젝트와 관련된 개발을 할 수 있어서 즐거웠던 것 같네요. 역시 내꺼 만드는게 진짜 제일 재미있어요ㅋㅋㅋ

 

오늘은 이전에 개발 목표로 잡았던 오디오만을 사용한 화상채팅 기능! 을 드디어 개발 완료했고, 동시에 외부 접속까지 가능하도록 kubernetes 를 사용하여 배포까지 완료했습니다. 

 

이번 개발하면서 어려웠던 것은 역시나 화상채팅 js  와 관련된 부분이었습니다. 이번 개발에서는 특히 chatgpt 선생님의 도움을 많이 받았던 것 같습니다. 동시에 chatgpt 를 결코 맹신해서는 안되겠구나...하고 느꼈습니다. 특히 개발에 있어서는 양날의 검이라는 수식어가 딱 들어 맞는 도구가 아닐까 생각되네요

 

이렇게 개발을 완료햇는데 문제는 곳곳에서 버그가 터지는게 눈에 보이더라구요ㅠㅠㅠ 오디오만을 사용해서 화상채팅에 들어갔을 때 화면공유가 안되는 부분이나 화면공유를 누르고 바로 취소를 누르면 기존의 video stream 을 못가져와서 터지는 부분이나...아직 갈길이 멀다는 것을 느꼈습니다ㅠㅠ

2. 배포일지 : kubernetes 배포

드디어 우리 kubernetes 가 본인 일을 하기 시작했습니다!! 사실 배포하는 부분도 정말...정말 문제가 많았습니다.

단순히 yaml 을 작성해서 배포하고 끝!! 이 아니라 deployment 와 ReplicaSet 에 대한 부분들 그리고 dockerhub 에서 chatforyou docker image 를 pull 받아서 사용하기 위한 시크릿 키 등록 등등 정말 배워야 할 것들, 알아야할 것들이 정말 많더라구요

특히 오래걸렸던 부분은 역시 pod 배포와 관련된 부분이었습니다.

현재 chatforyou 는 라즈베리파이에 배포가 되어있습니다. 사실 처음에는 openstack 로 생성한 인스턴스에 배포하고 싶었어요. 문제는 openstack 으로 생성한 인스턴스는 cpu 가 2개라는 점...이 었습니다.

 

참 웃긴게 인스턴스에 배포를 하면 pod 가 시작하다가 죽어버리더군요. 특히 java bean 을 생성하면서 바로 터져서 springboot 가 죽어버리더라구요.

원인 파악을 위해 로컬 환경에서(맥 에어) 구동하였으나 인텔리j 로 구동하는 경우와 docker image 로 구동하는 경우 모두 정상 동작하는 것을 확인했습니다. 혹시나 해서 beelink 미니 PC 에 docker image 로 구동했을 때도 역시나 정상 동작ㅠㅠ

정말 딱 인스턴스 2대에서 구동했을 때만 에러가 나더라구요. 사실 처음에는 kubernetes 가 문제이거나 혹은 kubernetes 에서 springboot 로 된 docker image 를 구동하기 위해서는 뭔가 다른 설정이 필요한가...? 싶어서 찾아보았습니다. docker build 옵션도 바꿔보고, ram 제한 cpu 제한 등 정말 많이 해봤으나 역시나 정확한 원인 파악은 불가능했습니다.

마지막으로 혹시나 하는 마음에서 라즈베리파이 컨트롤 플레인(마스터 서버) 에 옵션을 설정한 후 kubernetes 를 이용해서 배포하였는데...세상에 정말 지금까지 노력이 우습게도 바로 성공하더랍니다ㅠㅠ

 

여기서부터는 오직 제 추측입니다.

결국 원인은 서버 리소스의 문제가 아니었을까 생각이 됩니다. 이유는 인스턴스 cpu 2개 라즈베리파이는 4개 라는 점과 램이 인스턴스는 5GB 이고, 라즈베리파이는 8GB 라는 점 이기 때문입니다.

사실 제 프로젝트가 cpu 2 개 이상 램을 5GB 이상 잡아먹을정도인가...? 하면 아무리 코드가 이상하더라도 아니라고 보는데 말이죠ㅠㅠ

 

이 부분은 단순히 제 프로젝트에서만 그런건 아니고, sonarqube 나 jenkins 를 인스턴스 워커 노드에 올렸을 때도 동일한 증상이었다는 점입니다. 웃긴건 라즈베리파이에 올리면 돌아가요!!! 진짜 신기하기도 하고 화가 나기도 하고...ㅋㅋㅋ 이건 나중에 kubernetes 에 각종 서비스들 배포한 내용들에 대해서 포스팅 하면서 다시 작성하도록 하겠습니다

문제는 많았지만
어쨌든 성공ㅇㅇ!!

 

3. ChatController : turn 서버 설정

- 이번 개선은 대부분 front 특히 js 코드만 수정했는데 딱 한부분 백엔드에서 바뀌는 것이 있다면 바로 여기 chatController 쪽이다.

물론 여기도 크게 바뀌는 것은 아니고 application.properties 에서 turn 서버와 관련된 값을 가져와서 프론트로 보내주기 위해서 값을 넣어주는 것 정도...?

- 각각 turn server url, 계정ID, 계정 비밀번호를 의미한다. 꼭!! 필요하다

@Slf4j
@RequiredArgsConstructor
@RestController
public class ChatController {

    // 아래에서 사용되는 convertAndSend 를 사용하기 위해서 서언
    // convertAndSend 는 객체를 인자로 넘겨주면 자동으로 Message 객체로 변환 후 도착지로 전송한다.
    private final SimpMessageSendingOperations template;

    private final MsgChatService msgChatService;
    private final ChatServiceMain chatServiceMain;

    @Value("${turn.server.urls}")
    private String turnServerUrl;

    @Value("${turn.server.username}")
    private String turnServerUserName;

    @Value("${turn.server.credential}")
    private String turnServerCredential;

 

4. 개발에서!!

4-1) init 과 constrants

- js 코드는 항상 많이 바뀌는 듯 하다ㅠㅠ 이번에도 특히 중요하다고 생각되는 부분만 간단하게 설명한다.

- 먼저 init function 쪽에서 fetch 요청을 통해서 turn Url 과 user, pwd 를 받아와서 각각 변수에 세팅해준다.

var init = function(){
    fetch("https://"+locationHost+"/turnconfig", {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        }
    })
        .then(response => response.json()) // JSON 데이터로 변환
        .then(data => {
            turnUrl = data.url;
            turnUser = data.username;
            turnPwd = data.credential;
        })
        .catch(error => {
            console.error('Error:', error);
        });

}

let constraints = {
    audio: {
        autoGainControl: true,
        channelCount: 2,
        echoCancellation: true,
        latency: 0,
        noiseSuppression: true,
        sampleRate: 48000,
        sampleSize: 16,
        volume: 0.5
    }
};

navigator.mediaDevices.getUserMedia(constraints)
    .then(stream => {
        // Add your logic after successfully getting the media here.
        constraints.video = {
            width: 1200,
            height: 1000,
            maxFrameRate: 50,
            minFrameRate: 40
        };
    });

 

4-2) onExistingParticipants

- 두번째로 constraints 를 세팅하는 부분이 바뀌었다. 이 부분은 더 아래의 handleMediaError 부분과 함께 확인해야한다.

먼저  navigator.mediaDevices.getUserMedia(constraints) 부분에서 video 를 세팅하게 된다. 여기서에 2가지 케이스가 발생한다.

1) video 장비가 있고 && video 권한도 있는 경우 : 아무일 없이 handleSuccess 가 실행된다. 동시에 카메라 video stream 을 포함한 데이터가 상대방에게 넘어가고 보여지게 된다.

2) video 장비가 없거나 || video 권한이 없는 경우 : 에러가 바로 뻥! 하고 터진다. 에러는 handleMediaError 를 실행시킨다. handleMediaError 에서는 콘솔에 에러 로그를 출력하고 - Error accessing video, trying audio only - constranints 에서 video 데이터가 존재하는지 확인한 후 만약 데이터가 있다면 해당 데이터를 삭제 - delete constraints.video - 한 후 다시 handleSuccess 를 실행한다.

- handleSuccess function 에서는 hasVideo 를 통해 비디오 데이터가 잇는지 확인한 후 있다면 localVideo 에 데이터를 넣어주고 아니면 null 값을 담아서 넘긴다.

function onExistingParticipants(msg) {
    //console.log(name + " registered in room " + roomId);
    var participant = new Participant(name);
    participants[name] = 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),
            configuration: {
                iceServers: [
                    {
                        urls: turnUrl,
                        username: turnUser,
                        credential: turnPwd
                    }
                ]
            }
        };

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

        msg.data.forEach(receiveVideo);
    }

    function handleMediaError(error) {
        console.error("Error accessing video, trying audio only.", error);
        if (constraints.video) {
            delete constraints.video;  // Remove video constraints
            navigator.mediaDevices.getUserMedia(constraints)
                .then(handleSuccess)
                .catch(function(error) {
                    console.error("Error accessing media devices.", error);
                });
        } else {
            console.error("Error accessing media devices.", error);
        }
    }

    navigator.mediaDevices.getUserMedia(constraints)
        .then(handleSuccess)
        .catch(handleMediaError);
}

 

4-3) receiveVideo

- onExistingParticipant 에서 넘겨받은 steam 을 조작하는 participant.rtcPeer.peerConnection.onaddstream 부분이 수정되었다. 넘겨받게 되면 video.srcObject 와 audio.srcObject 에 stream 에서 각각의 데이터를 파싱해서 넣어준다.

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),
        configuration: { // 이 부분에서 TURN 서버 연결 설정
            iceServers: [
                {
                    urls: turnUrl,
                    username: turnUser,
                    credential: turnPwd
                }
            ]
        }
    }

    participant.rtcPeer = new kurentoUtils.WebRtcPeer.WebRtcPeerRecvonly(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;
    };
}

5. 배포까지!!

- 배포는 사실 크게...별거없었다라고 이야기하고 싶지만 이쪽도 많은 변화가 있었다. 물론 기존의 dockerfile 의 변화도 살짝 있었지만, 무엇보다 쿠버네티스에 배포하기 위해 추가 yaml 을 작성하였다.

- 아래는 쿠버네티스에 배포할때 사용되는 deployment.yaml 의 내용만 적어두었다. 실제로는 네임 스페이스, 레플리카셋, 도커 허브와 연결하기 위한 secret, service 등등 다양한 컴포넌트들이 함께 배포된다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: chatforyou-v03
  namespace: chatforyou
spec:
  replicas: 1
  selector:
    matchLabels:
      app: chatforyou-v03
  template:
    metadata:
      labels:
        app: chatforyou-v03
    spec:
      tolerations: ## 마스터 노드에 서비스를 배포하기 위한 설정
      - key: "node-role.kubernetes.io/control-plane"
        operator: "Exists"
        effect: "NoSchedule"
      nodeSelector:
        node-role.kubernetes.io/control-plane: ""
      containers:
      - name: chatforyou-container
        image: {{도커 허브에 존재하는 이미지 명}}
        ports:
        - containerPort: 8443
        env:
        - name: KMS_URL
          value: "{{KMS URL 주소}}"
      imagePullSecrets:
      - name: dockerhub-credentials ## 도커 허브에서 이미지를 가져오기 위한 secret 연결

 

6. 배포 및 시연 영상!!

- 시연 연상!! 저랑 제 친구들 2명과 함께 찍었습니다ㅎㅎ 친구놈들한테 자랑도 하고 디코 대신 저걸로 잘 놀고있어요ㅋㅋㅋ

 

댓글