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명과 함께 찍었습니다ㅎㅎ 친구놈들한테 자랑도 하고 디코 대신 저걸로 잘 놀고있어요ㅋㅋㅋ
댓글