rudu_std
WebRTC [화상 랜덤 채팅 구현 시 맞닥뜨린 문제] 본문
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>랜덤 화상채팅</title>
<link rel="stylesheet" href="/css/chat-style.css">
<script src="https://cdn.jsdelivr.net/npm/jwt-decode/build/jwt-decode.min.js"></script>
</head>
<body>
<h1>랜덤 화상채팅</h1>
<button id="startRandomChat">랜덤 채팅 시작</button>
<div id="loadingMessage" style="display: none;">
<p>상대를 검색중입니다...</p>
<div class="loader"></div> <!-- 로딩 아이콘 -->
</div>
<div id="chatControls" style="display: none;">
<button id="disconnectChat">연결 끊기</button>
<button id="findAnother">다른 상대 찾기</button>
</div>
<div class="container">
<video id="localVideo" autoplay playsinline></video>
<video id="remoteVideo" autoplay playsinline></video>
<div id="chatBox"></div>
</div>
<input type="text" id="messageInput" placeholder="메시지를 입력하세요">
<button id="sendMessage">전송</button>
<script>
console.log(typeof jwt_decode);
console.log("Access Token: " + localStorage.getItem('access_token'));
const accessToken = localStorage.getItem('access_token');
const decoded = jwt_decode(accessToken);
const email = decoded.email;
const id = decoded.id;
console.log("이메일:", email);
console.log("유저 ID:", id);
let signalingServer;
let localStream;
let remoteStream;
let peerConnection;
let pendingCandidates = [];
let offerQueue = []; // Offer를 대기시키는 큐
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
document.addEventListener('DOMContentLoaded', function() {
const chatControlBox = document.getElementById('chatControls');
const disconnectButton = document.getElementById('disconnectChat');
const findAnotherButton = document.getElementById('findAnother');
const startRandomChatButton = document.getElementById('startRandomChat');
const loadingMessage = document.getElementById('loadingMessage');
startRandomChatButton.onclick = function() {
startRandomChatButton.style.display = 'none';
loadingMessage.style.display = 'block';
startVideoChat();
};
disconnectButton.onclick = function() {
disconnectChat();
};
findAnotherButton.onclick = function() {
disconnectChat();
resetForNewChat();
};
function resetForNewChat() {
chatControlBox.style.display = 'none';
startRandomChatButton.style.display = 'block';
}
function disconnectChat() {
if (signalingServer) {
signalingServer.close();
signalingServer = null;
}
cleanupVideoChat();
resetForNewChat();
}
function startVideoChat() {
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
initializePeerConnection(localVideo, remoteVideo);
const signalingServerUrl = 'wss://1.232.89.187:8443/ws/random-video-chat';
if (signalingServer) {
signalingServer.close(); // 기존 연결이 있으면 종료
signalingServer = null;
}
signalingServer = new WebSocket(signalingServerUrl);
const chatBox = document.getElementById('chatBox');
const messageInput = document.getElementById('messageInput');
const sendMessageButton = document.getElementById('sendMessage');
signalingServer.onopen = () => {
console.log("랜덤채팅 대기열에 추가되었습니다.");
signalingServer.send(JSON.stringify({ type: 'random' })); // 대기열에 추가 요청
};
signalingServer.onmessage = message => {
const data = JSON.parse(message.data);
switch (data.type) {
case 'match':
setTimeout(() => {
// initializePeerConnection(localVideo, remoteVideo);
alert(data.message);
loadingMessage.style.display = 'none';
chatControlBox.style.display = 'block';
}, 2000);
break;
case 'offer':
handleOffer(data.offer);
break;
case 'answer':
handleAnswer(data.answer);
break;
case 'candidate':
handleCandidate(data.candidate);
break;
case 'chat':
chatBox.innerHTML += <div>상대방: ${data.message}</div>;
chatBox.scrollTop = chatBox.scrollHeight;
break;
case 'system': // 추가: 퇴장 메시지 처리
chatBox.innerHTML += <div class="system-message">${data.message}</div>;
chatBox.scrollTop = chatBox.scrollHeight;
break;
default:
console.log("알 수 없는 메시지 타입: " + data.type);
}
};
sendMessageButton.onclick = () => {
const message = messageInput.value;
if (message) {
signalingServer.send(JSON.stringify({ type: 'chat', message: message }));
chatBox.innerHTML += <div>나: ${message}</div>;
chatBox.scrollTop = chatBox.scrollHeight;
messageInput.value = '';
}
};
messageInput.addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
sendMessageButton.click();
}
});
}
function initializePeerConnection(localVideo, remoteVideo) {
// localVideo와 remoteVideo가 정상적으로 전달되었는지 확인
if (!localVideo || !remoteVideo) {
console.error("비디오 요소가 정의되지 않았습니다");
return;
}
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => {
localVideo.srcObject = stream;
localStream = stream;
peerConnection = new RTCPeerConnection(config);
console.log("RTC 연결 생성됨");
// 로컬 비디오 및 오디오 트랙을 추가하여 원격 피어에게 전송
addTracksToPeerConnection(localStream);
// ICE connection state change 이벤트 핸들러 설정
peerConnection.oniceconnectionstatechange = function() {
console.log("ICE 연결 상태:", peerConnection.iceConnectionState);
};
peerConnection.onicecandidate = event => {
if (event.candidate) {
console.log("ICE 후보 발견:", event.candidate);
signalingServer.send(JSON.stringify({ type: 'candidate', candidate: event.candidate }));
}
};
// 상대방의 트랙이 추가되었을 때 (상대방 화면이 들어옴)
peerConnection.ontrack = event => {
console.log("상대방 트랙 수신:", event);
if (!remoteStream) {
remoteStream = new MediaStream();
remoteVideo.srcObject = remoteStream;
}
remoteStream.addTrack(event.track);
console.log("상대방 화면 표시 시작");
};
return peerConnection.createOffer();
})
.then(offer => {
return peerConnection.setLocalDescription(offer);
})
.then(() => {
signalingServer.send(JSON.stringify({ type: 'offer', offer: peerConnection.localDescription }));
})
.catch(error => {
console.error("startVideoChat 중 오류:", error);
});
}
function addTracksToPeerConnection(stream) {
stream.getTracks().forEach(track => peerConnection.addTrack(track, stream));
}
function handleOffer(offer) {
console.log("Offer 수신: ", offer);
if (!peerConnection || peerConnection.signalingState !== "stable") {
console.warn("Offer를 수신할 수 없는 잘못된 상태입니다. 시그널링 상태:", peerConnection.signalingState);
return;
}
peerConnection.setRemoteDescription(new RTCSessionDescription(offer))
.then(() => {
console.log("Remote Description 설정 완료");
return peerConnection.createAnswer();
})
.then(answer => {
console.log("Answer 생성 완료");
return peerConnection.setLocalDescription(answer);
})
.then(() => {
console.log("Local Description 설정 완료");
signalingServer.send(JSON.stringify({ type: 'answer', answer: peerConnection.localDescription }));
})
.catch(error => console.error("Offer 처리 중 오류: ", error));
}
function handleAnswer(answer) {
if (peerConnection.signalingState !== "have-local-offer") {
console.warn("잘못된 상태에서 answer가 수신되었습니다.");
return;
}
peerConnection.setRemoteDescription(new RTCSessionDescription(answer))
.catch(error => console.error("Answer 처리 중 오류:", error));
}
function handleCandidate(candidate) {
if (!peerConnection || !peerConnection.remoteDescription) {
console.warn("원격 설명이 설정되지 않아 ICE 후보를 대기합니다.");
pendingCandidates.push(candidate);
return;
}
peerConnection.addIceCandidate(new RTCIceCandidate(candidate))
.catch(error => console.error("ICE 후보 처리 중 오류:", error));
}
function cleanupVideoChat() {
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
localVideo.srcObject = null;
remoteVideo.srcObject = null;
}
});
</script>
</body>
</html>
상대방의 화면이 안나와
이 아래는 상대 1의 로그야
random-video-chat:33 function
random-video-chat:34 Access Token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJhanVmcmVzaEBnbWFpbC5jb20iLCJpYXQiOjE3MjgzNjg2MjIsImV4cCI6MTcyODQ1NTAyMiwic3ViIjoiY2tjYzgxMzBAZ21haWwuY29tIiwiZW1haWwiOiJja2NjODEzMEBnbWFpbC5jb20iLCJpZCI6MX0.MymgaLrjVEvC-6gWlw2RhHeIUJFbFHVvoMtJptd4o6Q
random-video-chat:39 이메일: ckcc8130@gmail.com
random-video-chat:40 유저 ID: 1
random-video-chat:109 랜덤채팅 대기열에 추가되었습니다.
random-video-chat:175 RTC 연결 생성됨
random-video-chat:186 ICE 후보 발견: RTCIceCandidate {candidate: 'candidate:4225351067 1 udp 2122260223 1.232.89.187…619 typ host generation 0 ufrag 0jFP network-id 1', sdpMid: '0', sdpMLineIndex: 0, foundation: '4225351067', component: 'rtp', …}
random-video-chat:186 ICE 후보 발견: RTCIceCandidate {candidate: 'candidate:4225351067 1 udp 2122260223 1.232.89.187…620 typ host generation 0 ufrag 0jFP network-id 1', sdpMid: '1', sdpMLineIndex: 1, foundation: '4225351067', component: 'rtp', …}
4random-video-chat:142 알 수 없는 메시지 타입: waiting
random-video-chat:186 ICE 후보 발견: RTCIceCandidate {candidate: 'candidate:91449615 1 tcp 1518280447 1.232.89.187 9…ptype active generation 0 ufrag 0jFP network-id 1', sdpMid: '0', sdpMLineIndex: 0, foundation: '91449615', component: 'rtp', …}
random-video-chat:186 ICE 후보 발견: RTCIceCandidate {candidate: 'candidate:91449615 1 tcp 1518280447 1.232.89.187 9…ptype active generation 0 ufrag 0jFP network-id 1', sdpMid: '1', sdpMLineIndex: 1, foundation: '91449615', component: 'rtp', …}
2random-video-chat:142 알 수 없는 메시지 타입: waiting
random-video-chat:220 Offer 수신: {type: 'offer', sdp: 'v=0\r\no=- 5436863803603850875 2 IN IP4 127.0.0.1\r\ns…a587c19beb 72441c33-bda0-4924-a6b3-01b77e244bfd\r\n'}
random-video-chat:222 Offer를 수신할 수 없는 잘못된 상태입니다. 시그널링 상태: have-local-offer
handleOffer @ random-video-chat:222
signalingServer.onmessage @ random-video-chat:125Understand this warning
4random-video-chat:253 원격 설명이 설정되지 않아 ICE 후보를 대기합니다.
handleCandidate @ random-video-chat:253
signalingServer.onmessage @ random-video-chat:131Understand this warning
이 아래부터는 상대 2의 로그야
random-video-chat:33 function
random-video-chat:34 Access Token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJhanVmcmVzaEBnbWFpbC5jb20iLCJpYXQiOjE3MjgzNjg2MjIsImV4cCI6MTcyODQ1NTAyMiwic3ViIjoiY2tjYzgxMzBAZ21haWwuY29tIiwiZW1haWwiOiJja2NjODEzMEBnbWFpbC5jb20iLCJpZCI6MX0.MymgaLrjVEvC-6gWlw2RhHeIUJFbFHVvoMtJptd4o6Q
random-video-chat:39 이메일: ckcc8130@gmail.com
random-video-chat:40 유저 ID: 1
random-video-chat:109 랜덤채팅 대기열에 추가되었습니다.
random-video-chat:175 RTC 연결 생성됨
random-video-chat:186 ICE 후보 발견: RTCIceCandidate {candidate: 'candidate:2798419141 1 udp 2122260223 1.232.89.187…292 typ host generation 0 ufrag H24M network-id 1', sdpMid: '0', sdpMLineIndex: 0, foundation: '2798419141', component: 'rtp', …}
random-video-chat:186 ICE 후보 발견: RTCIceCandidate {candidate: 'candidate:2798419141 1 udp 2122260223 1.232.89.187…293 typ host generation 0 ufrag H24M network-id 1', sdpMid: '1', sdpMLineIndex: 1, foundation: '2798419141', component: 'rtp', …}
random-video-chat:186 ICE 후보 발견: RTCIceCandidate {candidate: 'candidate:3624115805 1 tcp 1518280447 1.232.89.187…ptype active generation 0 ufrag H24M network-id 1', sdpMid: '0', sdpMLineIndex: 0, foundation: '3624115805', component: 'rtp', …}
random-video-chat:186 ICE 후보 발견: RTCIceCandidate {candidate: 'candidate:3624115805 1 tcp 1518280447 1.232.89.187…ptype active generation 0 ufrag H24M network-id 1', sdpMid: '1', sdpMLineIndex: 1, foundation: '3624115805', component: 'rtp', …}
화상 채팅 애플리케이션에서 상대방의 화면이 표시되지 않는 문제는 주로 WebRTC 시그널링 과정에서 발생하는 문제와 관련이 있습니다. 제공해주신 로그와 코드를 분석한 결과, 두 사용자가 동시에 offer를 생성하여 서로의 offer를 처리하지 못하는 상황이 발생하고 있는 것으로 보입니다. 이를 해결하기 위해 다음과 같은 조치를 취할 수 있습니다.
문제 분석
- 동시 Offer 생성:
- 두 사용자가 랜덤 채팅에 매칭되었을 때, 각각 startVideoChat 함수를 호출하여 RTCPeerConnection을 초기화하고 offer를 생성합니다.
- 이로 인해 두 사용자가 동시에 offer를 생성하여 서로의 offer를 처리하려 할 때, 시그널링 상태가 have-local-offer로 설정되어 추가적인 offer를 받을 수 없는 상태가 됩니다.
- 시그널링 상태 충돌:
- 사용자가 offer를 생성한 후, 상대방의 offer를 수신하면 현재의 시그널링 상태가 have-local-offer로 설정되어 있기 때문에 새로운 offer를 수신할 수 없습니다.
- 이로 인해 handleOffer 함수에서 Offer를 수신할 수 없는 잘못된 상태입니다. 시그널링 상태: have-local-offer 경고가 발생하고, 결과적으로 원격 스트림을 설정하지 못하게 됩니다.
해결 방안
1. 역할(Role) 할당
랜덤 채팅에서 두 사용자 중 한 명을 제안자(Offerer) 로, 다른 한 명을 응답자(Answerer) 로 역할을 할당하여 한 명만 offer를 생성하도록 합니다. 이를 통해 시그널링 상태 충돌을 방지할 수 있습니다.
구현 방법:
- 서버 측에서 역할 할당:
- 서버가 두 사용자가 매칭되었을 때, 한 명에게 역할을 할당하도록 합니다.
- 역할 정보(offerer 또는 answerer)를 클라이언트로 전송합니다.
- 클라이언트 측에서 역할에 따른 동작 분기:
- offerer 역할을 가진 사용자는 RTCPeerConnection을 초기화하고 offer를 생성하여 시그널링 서버로 전송합니다.
- answerer 역할을 가진 사용자는 시그널링 서버로부터 offer를 수신하면 answer를 생성하여 시그널링 서버로 전송합니다.
요약
두 사용자가 동시에 offer를 생성하여 시그널링 상태 충돌이 발생하는 문제를 해결하기 위해, 역할을 할당하여 한 사용자만 offer를 생성하고 다른 사용자는 answer를 생성하도록 하는 것이 가장 효과적입니다. 이를 통해 원격 스트림이 정상적으로 설정되고, 상대방의 화면이 표시되지 않는 문제를 해결할 수 있습니다.
필요한 경우, 서버 측에서 역할 할당을 지원하도록 수정하거나, 클라이언트 측에서 시그널링 상태를 더 유연하게 처리하도록 코드를 개선하세요.
알겠습니다. 사용자 ID는 현재 문제와 관련이 없으며, 주된 문제는 두 사용자가 동시에 offer를 생성하려고 시도하여 시그널링 상태 충돌이 발생하고 있다는 점입니다. 이를 해결하기 위해 역할(Role) 할당을 도입하여 한 사용자만 offer를 생성하고 다른 사용자는 answer를 생성하도록 해야 합니다. 이를 통해 시그널링 상태 충돌을 방지할 수 있습니다.
1. 백엔드에서 역할(Role) 할당
백엔드에서 두 사용자가 매칭될 때 역할을 할당하고, 각 사용자에게 해당 역할을 전달하도록 수정해야 합니다. 예를 들어, 첫 번째 사용자에게 offerer 역할을, 두 번째 사용자에게 answerer 역할을 할당합니다.
// 매칭된 사용자들에게 매칭 메시지 전송 (역할 정보 포함)
private void notifyUsersOfMatch(WebSocketSession user1, WebSocketSession user2, String roomId) {
// user1은 offerer, user2는 answerer 역할 할당
sendMessage(user1, new TextMessage("{\"type\": \"match\", \"roomId\": \"" + roomId + "\", \"role\": \"offerer\", \"message\": \"상대방과 연결되었습니다.\"}"));
sendMessage(user2, new TextMessage("{\"type\": \"match\", \"roomId\": \"" + roomId + "\", \"role\": \"answerer\", \"message\": \"상대방과 연결되었습니다.\"}"));
// 역할 정보를 세션에 저장
user1.getAttributes().put("role", "offerer");
user2.getAttributes().put("role", "answerer");
}
3. 역할(Role) 할당의 필요성
두 클라이언트가 동시에 offer를 생성하면 시그널링 상태가 have-local-offer로 설정되어 추가적인 offer를 처리할 수 없게 됩니다. 이를 방지하기 위해 역할 할당을 통해 한 클라이언트만 offer를 생성하고, 다른 클라이언트는 answer를 생성하도록 해야 합니다.
4. 전체적인 흐름 요약
- 사용자 매칭:
- 두 사용자가 매칭되면 백엔드는 각 사용자에게 역할(offerer, answerer)을 할당하고 match 메시지를 전송합니다.
- 클라이언트 역할 처리:
- offerer 역할을 받은 클라이언트는 offer를 생성하여 시그널링 서버로 전송합니다.
- answerer 역할을 받은 클라이언트는 offer를 기다리고, 수신한 offer에 대해 answer를 생성하여 시그널링 서버로 전송합니다.
- ICE 후보 처리:
- 각 클라이언트는 ICE 후보를 시그널링 서버를 통해 상대방에게 전달합니다.
- answerer는 offer를 수신한 후 answer를 생성하고, offerer는 answer를 수신하여 연결을 완료합니다.
- 비디오 스트림 표시:
- 연결이 완료되면 양쪽 클라이언트 모두 상대방의 비디오 스트림을 표시할 수 있습니다.
5. 추가적인 고려 사항
- 백엔드의 시그널링 메시지 처리:
- 백엔드에서 시그널링 메시지를 처리할 때, 각 사용자의 역할에 따라 올바르게 메시지를 전달하도록 해야 합니다.
- 예를 들어, offerer가 보낸 offer는 answerer에게 전달되고, answerer가 보낸 answer는 offerer에게 전달됩니다.
- 디버깅 및 로그 강화:
- 백엔드와 클라이언트 모두에서 역할 할당과 시그널링 메시지 흐름을 명확하게 로그로 기록하여 디버깅에 도움을 줄 수 있습니다.
- 에러 처리 개선:
- 예기치 않은 시그널링 상태나 메시지 타입에 대해 추가적인 에러 처리를 구현하여 안정성을 높일 수 있습니다.
결론
현재 발생하고 있는 문제는 두 클라이언트가 동시에 offer를 생성하려고 시도하여 시그널링 상태 충돌이 발생하는 것입니다. 이를 해결하기 위해 백엔드에서 역할을 할당하고, 클라이언트에서는 해당 역할에 따라 offer를 생성하거나 answer를 생성하도록 로직을 수정해야 합니다. 위의 가이드에 따라 백엔드와 클라이언트 코드를 수정하면 상대방의 화면이 정상적으로 표시되는 문제를 해결할 수 있을 것입니다.
1. WebRTC의 기본 개
1.1. Peer-to-Peer 통신
WebRTC는 브라우저 간의 실시간 통신을 가능하게 하는 기술로, Peer-to-Peer (P2P) 방식을 사용합니다. 이를 통해 별도의 중간 서버 없이도 오디오, 비디오, 데이터 스트림을 직접 주고받을 수 있습니다.
1.2. Signaling
P2P 연결을 설정하기 위해서는 두 브라우저 간에 **신호 교환(Signaling)**이 필요합니다.
이는 Offer/Answer 과정과 ICE Candidate 교환을 통해 이루어집니다.
Signaling은 WebRTC 표준에 포함되지 않으며, 보통 WebSocket, HTTP, SIP 등을 통해 구현됩니다
2. Offerer와 Answerer의 역할
WebRTC 연결을 설정하는 과정에서 offerer와 answerer는 각각 특정 역할을 담당합니다.
이 두 역할은 연결을 시작하고 응답하는 주체를 나타냅니다.
일반적으로 1대1 화상 채팅에서는 한 쪽이 Offerer로, 다른 쪽이 Answerer로 역할이 고정됩니다. 이는 연결을 설정하는 과정에서 역할이 일관되게 유지되어 PeerConnection이 안정적으로 설정되도록 하기 위함입니다.
2.1. Offerer
- Offerer는 연결을 시작하는 쪽입니다.
- createOffer() 메서드를 사용하여 연결 제안( Offer SDP(Session Description Protocol ))을 생성합니다.(RTC 내장)
- 생성된 Offer 는 상대방( Answerer )에게 전송됩니다.
2.2. Answerer
- Answerer는 offer를 받은 쪽 으로, Offerer의 제안에 응답합니다.
- createAnswer() 메서드를 사용하여 offer에 대한 응답( Answer SDP )를 생성합니다.
- 생성된 Answer 는 Signaling Server를 통해 Offerer에게 전송됩니다
2.3. 역할 분담
// 대기 중인 사용자 매칭
private void matchUsers() {
// 대기열에 2명 이상이 있는 경우 매칭
System.out.println("현재 대기 중인 사용자 수: " + waitingUsers.size());
if (waitingUsers.size() >= 2) {
Collections.shuffle(waitingUsers); // 대기열 랜덤 셔플
WebSocketSession user1 = waitingUsers.remove(0);
WebSocketSession user2 = waitingUsers.remove(0);
// ...코드 생략...
// 매칭된 사용자에게 방 ID와 연결 메시지 전송
notifyUsersOfMatch(user1, user2, roomId);
// ...코드 생략...
}
// 매칭된 사용자들에게 매칭 메시지 전송 및 역할 분배
private void notifyUsersOfMatch(WebSocketSession user1, WebSocketSession user2, String roomId) {
// ...코드 생략...
// 역할 정보를 세션에 저장
user1.getAttributes().put("role", "offerer");
user2.getAttributes().put("role", "answerer");
}
2.3. 과정 요약
- Offerer가 createOffer()를 호출하여 Offer 를 생성합니다.
- Offerer는 생성된 offer를 *Signaling Server를 통해 Answerer에게 전송합니다.
- Answerer는 받은 Offer 를 setRemoteDescription()으로 설정한 후, createAnswer()를 호출하여 Answer 를 생성합니다.
- Answerer는 생성된 Answer 를 *Signaling Server를 통해 Offerer에게 전송합니다.
- Offerer는 받은 Answer 를 setRemoteDescription()으로 설정하여 P2P 연결을 완료합니다.
이 과정을 통해 두 브라우저 간의 P2P 연결이 설정됩니다.
*Signaling Server ( 내가 사용한 서버는 WebSocket )
let signalingServer;
const signalingServerUrl = 'wss://1.232.89.187:8443/ws/random-video-chat';
signalingServer = new WebSocket(signalingServerUrl);
나의 문제
3. 현재 발생하는 문제 분석
오류 메시지 : PeerConnection이 초기화되지 않았습니다.
이 오류는 createOffer() 또는 createAnswer() 메서드가 호출될 때 peerConnection 객체가 제대로 초기화되지 않았음을 의미합니다. 주로 다음과 같은 원인으로 발생할 수 있습니다.
- PeerConnection 객체 초기화 지연: initializePeerConnection 함수가 비동기적으로 실행되기 때문에, peerConnection 객체가 초기화되기 전에 createOffer()가 호출됩니다.
- 동시 Offer 생성 시도: 두 사용자가 동시에 Offer를 생성하려고 하면, peerConnection 객체가 아직 초기화되지 않은 상태에서 createOffer()가 호출되어 오류가 발생할 수 있습니다.
- initializePeerConnection 함수는 navigator.mediaDevices.getUserMedia를 호출하여 미디어 스트림을 가져오고, RTCPeerConnection 객체를 초기화합니다. 이 과정은 비동기적으로 처리되며 Promise를 반환합니다. 따라서 createOffer()는 peerConnection 객체가 완전히 초기화된 후에만 호출되도록 보장해야 합니다. 이를 위해 initializePeerConnection 함수를 Promise를 반환하도록 수정하고, 비동기 처리를 명확히 하여 peerConnection 객체가 준비된 상태에서만 createOffer() 또는 createAnswer()를 호출하도록 합니다. Role 기반 Offer/Answer 처리:
- Answerer는 Signaling Server로부터 Offer를 수신하면 handleOffer()를 호출하여 Answer를 생성하고, 이를 Offerer에게 전송합니다.
- Offerer는 Signaling Server로부터 Answer를 수신하면 handleAnswer()를 호출하여 PeerConnection을 설정합니다.
- Signaling 메시지 처리 오류: Signaling Server로부터 오는 메시지를 잘못 처리하여 peerConnection이 올바르게 설정되지 않음.
4. Offerer와 Answerer 관계 상세 설명
4.1. Offerer와 Answerer의 상호 작용
- Offerer 초기화:
- peerConnection 객체를 생성하고, 로컬 미디어 스트림(비디오, 오디오)을 추가합니다.
- createOffer()를 호출하여 offer를 생성하고, 이를 Signaling Server를 통해 Answerer에게 전송합니다.
- Answerer 초기화:
- Signaling Server로부터 offer를 수신합니다.
- peerConnection 객체를 생성하고, 로컬 미디어 스트림을 추가합니다.
- setRemoteDescription(offer)를 호출하여 offer를 설정합니다.
- createAnswer()를 호출하여 answer를 생성하고, 이를 Signaling Server를 통해 Offerer에게 전송합니다.
- Offerer 응답 처리:
- Signaling Server로부터 answer를 수신합니다.
- setRemoteDescription(answer)를 호출하여 answer를 설정합니다.
4.2. ICE Candidate 교환
- Offerer와 Answerer는 서로의 네트워크 정보를 교환하기 위해 ICE Candidates를 주고받습니다.
- onicecandidate 이벤트 핸들러를 통해 발견된 ICE Candidates를 Signaling Server를 통해 상대방에게 전송합니다.
- 수신한 ICE Candidates는 addIceCandidate() 메서드를 통해 peerConnection에 추가됩니다.
4.3. 연결 상태 모니터링
- oniceconnectionstatechange 이벤트를 통해 ICE 연결 상태를 모니터링합니다.
- 연결 상태에 따라 적절한 처리를 수행합니다 (예: 연결 종료, 재연결 시도 등).
5. 코드 수정 및 개선 방안
현재 코드에서 PeerConnection이 초기화되지 않았습니다. 오류가 발생하는 원인은 peerConnection 객체가 비동기적으로 초기화되기 전에 createOffer()가 호출되었기 때문입니다. 이를 해결하기 위해 다음과 같은 수정이 필요합니다.
5.1. initializePeerConnection 함수 수정
initializePeerConnection 함수가 Promise를 반환하도록 수정하고, peerConnection 객체가 초기화된 후에 createOffer()가 호출되도록 보장합니다.
function initializePeerConnection(localVideo, remoteVideo) {
if (!localVideo || !remoteVideo) {
console.error("비디오 요소가 정의되지 않았습니다");
return Promise.reject("비디오 요소가 정의되지 않았습니다");
}
return navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => {
localVideo.srcObject = stream;
localStream = stream;
peerConnection = new RTCPeerConnection(config);
console.log("RTC 연결 생성됨");
addTracksToPeerConnection(localStream);
peerConnection.oniceconnectionstatechange = function() {
console.log("ICE 연결 상태:", peerConnection.iceConnectionState);
};
peerConnection.onicecandidate = event => {
if (event.candidate) {
console.log("ICE 후보 발견:", event.candidate);
signalingServer.send(JSON.stringify({ type: 'candidate', candidate: event.candidate }));
}
};
peerConnection.ontrack = event => {
console.log("상대방 트랙 수신:", event);
if (!remoteStream) {
remoteStream = new MediaStream();
remoteVideo.srcObject = remoteStream;
}
remoteStream.addTrack(event.track);
console.log("상대방 화면 표시 시작");
};
})
.catch(error => {
console.error("getUserMedia 중 오류:", error);
throw error;
});
}
5.2. signalingServer.onmessage 핸들러 수정
Offerer와 Answerer의 역할이 올바르게 할당된 후에 initializePeerConnection을 호출하고, peerConnection 객체가 초기화된 후에 createOffer()를 호출하도록 합니다.
signalingServer.onmessage = message => {
const data = JSON.parse(message.data);
switch (data.type) {
case 'match':
setTimeout(() => {
alert(data.message);
loadingMessage.style.display = 'none';
chatControlBox.style.display = 'block';
role = data.role;
initializePeerConnection(localVideo, remoteVideo)
.then(() => {
if (role === 'offerer') {
createOffer();
} else if (role === 'answerer') {
console.log("Answerer 역할: Offer를 기다립니다.");
}
})
.catch(error => {
console.error("PeerConnection 초기화 실패:", error);
// 필요한 경우 사용자에게 오류 메시지 표시
});
}, 2000);
break;
case 'offer':
if (role === 'answerer') {
handleOffer(data.offer);
}
break;
case 'answer':
if (role === 'offerer') {
handleAnswer(data.answer);
}
break;
case 'candidate':
handleCandidate(data.candidate);
break;
case 'chat':
chatBox.innerHTML += `<div>상대방: ${data.message}</div>`;
chatBox.scrollTop = chatBox.scrollHeight;
break;
case 'system':
chatBox.innerHTML += `<div class="system-message">${data.message}</div>`;
chatBox.scrollTop = chatBox.scrollHeight;
break;
case 'waiting':
loadingMessage.style.display = 'block';
chatControlBox.style.display = 'none';
break;
default:
console.log("알 수 없는 메시지 타입: " + data.type);
}
};
5.3. Offerer와 Answerer 역할 관리
Offerer와 Answerer가 올바르게 역할이 할당되고, 그에 따라 메서드가 호출되도록 관리합니다. 이는 Signaling Server의 구현과 밀접하게 연관됩니다.
5.3.1. Signaling Server의 역할 할당
Signaling Server는 랜덤으로 클라이언트를 매칭할 때, 각 클라이언트에게 offerer 또는 answerer 역할을 할당해야 합니다. 예를 들어, 두 클라이언트를 매칭할 때 첫 번째 클라이언트를 offerer, 두 번째 클라이언트를 answerer로 지정할 수 있습니다.
{
"type": "match",
"message": "상대방을 찾았습니다!",
"role": "offerer" // 또는 "answerer"
}
5.3.2. Offerer와 Answerer의 메시지 처리
- Offerer:
- **createOffer()**를 호출하여 offer를 생성하고 Signaling Server로 전송합니다.
- Signaling Server로부터 answer를 수신하면 handleAnswer()를 호출하여 answer를 설정합니다.
- Answerer:
- Signaling Server로부터 offer를 수신하면 handleOffer()를 호출하여 offer를 설정하고, answer를 생성하여 Signaling Server로 전송합니다.
주요 변경 사항 요약
- initializePeerConnection 함수 수정:
- 함수가 Promise를 반환하도록 수정하여, peerConnection 객체가 초기화된 후에 createOffer()가 호출되도록 했습니다.
- 초기화 과정에서 비동기 처리를 명확히 하여 peerConnection 객체가 준비된 상태에서만 다음 단계가 진행되도록 했습니다.
- signalingServer.onmessage 핸들러 수정:
- match 메시지 처리 시, peerConnection 초기화가 완료된 후에 Offerer인 경우 createOffer()가 호출되도록 했습니다.
- Error Handling을 추가하여 peerConnection 초기화 실패 시 오류를 콘솔에 출력하고, 필요한 경우 사용자에게 오류 메시지를 표시할 수 있도록 했습니다.
- addTracksToPeerConnection 함수 수정:
- peerConnection 객체가 존재하는지 확인하고, 존재하지 않을 경우 오류 메시지를 출력하도록 했습니다.
- 기타 수정 사항:
- handleAnswer 함수에서 peerConnection 객체의 존재 여부를 확인하도록 했습니다.
- cleanupVideoChat 함수에서 remoteStream을 정리하도록 추가했습니다.
6. 추가적인 고려 사항
6.1. Signaling Server의 역할
Signaling Server는 클라이언트 간의 신호 교환을 관리합니다. 클라이언트가 서로를 매칭하고, Offer/Answer, ICE Candidates를 주고받을 수 있도록 도와줍니다. Signaling Server의 올바른 구현이 필수적입니다.
- 매칭 로직: 두 클라이언트를 랜덤하게 매칭하고, 각 클라이언트에게 offerer 또는 answerer 역할을 할당합니다.
- 메시지 전달: 클라이언트 간의 메시지를 중계하여 Offer/Answer, ICE Candidates를 전달합니다.
- 에러 처리: 연결 오류나 메시지 전달 오류를 적절히 처리합니다.
6.2. STUN/TURN 서버 설정
- STUN 서버는 NAT 방화벽을 통과하기 위한 네트워크 정보를 제공합니다.
- TURN 서버는 P2P 연결이 불가능할 때 미디어 스트림을 중계합니다.
- 현재 stun.l.google.com:19302 STUN 서버를 사용하고 있지만, NAT 환경에 따라 TURN 서버를 추가로 설정하는 것이 좋습니다.
6.3. HTTPS 사용
WebRTC는 보안 연결(HTTPS)을 요구합니다. 로컬 개발 시에는 localhost가 예외로 허용되지만, 외부 IP나 도메인을 사용할 경우 HTTPS가 필요합니다.
- 로컬 개발: HTTPS가 필요하지 않습니다.
- 배포 시: Let's Encrypt와 같은 무료 인증서를 사용하여 HTTPS를 설정합니다.
6.4. 브라우저 호환성
- 최신 브라우저(Chrome, Firefox, Edge 등)를 사용하여 테스트합니다.
- WebRTC 지원 상태를 확인하고, 필요한 경우 브라우저 업데이트를 진행합니다.
6.5. 사용자 경험 개선
- 로딩 애니메이션: 사용자가 상대방을 찾는 동안 로딩 애니메이션을 표시하여 대기 상태를 명확히 합니다.
- 오류 메시지: 연결 실패나 오류 발생 시 사용자에게 명확한 메시지를 표시하여 문제를 인지하고 대처할 수 있도록 합니다.
- 연결 상태 표시: ICE 연결 상태를 실시간으로 표시하여 사용자가 현재 연결 상태를 확인할 수 있도록 합니다.
7. 디버깅 및 테스트
7.1. 브라우저 개발자 도구 활용
- Console 탭: JavaScript 오류 및 로그 메시지를 확인합니다.
- Network 탭: WebSocket 연결 상태와 메시지 교환을 모니터링합니다.
- Elements 탭: HTML 요소와 CSS 적용 상태를 확인합니다.
7.2. Signaling Server 로그 확인
- Signaling Server의 로그를 확인하여 클라이언트 간의 메시지 전달이 정상적으로 이루어지는지 확인합니다.
- 연결 실패나 메시지 전달 오류가 있는지 점검합니다.
7.3. Peer Connection 상태 모니터링
- oniceconnectionstatechange 이벤트 핸들러를 통해 ICE 연결 상태를 지속적으로 모니터링합니다.
- 연결 상태 변화에 따라 적절한 처리를 수행합니다.
7.4. 다중 연결 테스트
- 여러 클라이언트를 동시에 연결하여 Signaling Server의 매칭 로직과 WebRTC 연결이 올바르게 작동하는지 테스트합니다.
- 연결 종료 후 재연결 시에도 정상적으로 동작하는지 확인합니다.
8. 결론
이전 코드에서 발생했던 주요 문제는 Offerer와 Answerer 역할의 중복 시도와 PeerConnection 초기화 타이밍의 불일치로 인해 PeerConnection 객체가 제대로 초기화되지 않았던 것입니다. 이를 해결하기 위해 다음과 같은 조치를 취했습니다.
핵심은 peerConnection 객체가 완전히 초기화된 후에 createOffer() 또는 createAnswer()를 호출하도록 보장하는 것입니다. 이를 위해 initializePeerConnection 함수를 Promise를 반환하도록 수정하고, 비동기 처리를 명확히 하여 peerConnection 객체가 준비된 상태에서만 다음 단계가 진행되도록 했습니다.
- Offerer와 Answerer 역할을 명확히 할당하여 두 사용자가 동시에 Offerer 역할을 시도하지 않도록 했습니다.
- PeerConnection 초기화가 완료된 후에만 Offer를 생성하도록 비동기 로직을 수정했습니다.
- Signaling Server와 클라이언트 측 코드에서 역할 기반 메시지 처리를 강화했습니다.
이러한 수정 사항을 통해 두 사용자가 정상적으로 Offerer와 Answerer 역할을 수행하며, PeerConnection 객체가 올바르게 초기화되어 화상 채팅이 원활하게 이루어질 수 있을 것입니다.
6. WebRTC 작동 방식 및 흐름
WebRTC를 통해 두 피어 간의 P2P 연결을 설정하는 전반적인 과정을 단계별로 설명하겠습니다.
6.1. Signaling 서버 설정
WebRTC는 Signaling 메커니즘을 제공하지 않으므로, 개발자는 별도의 Signaling 서버를 구현해야 합니다. 일반적으로 WebSocket을 사용하여 실시간 메시지 교환을 수행합니다. Signaling 서버의 주요 역할은 Offer, Answer, ICE 후보 등을 중계하는 것입니다.
6.2. P2P 연결 설정 단계
- Signaling 서버 연결:
- 두 피어는 Signaling 서버에 WebSocket을 통해 연결합니다.
- 매칭 또는 세션 설정:
- 두 피어가 서로 매칭되거나 세션을 설정하도록 Signaling 서버에 요청합니다.
- Offer 생성 및 전송 (Offerer):
- Offerer는 createOffer()를 호출하여 Offer SDP를 생성합니다.
- Offer를 setLocalDescription()으로 설정합니다.
- Offer를 Signaling 서버를 통해 Answerer에게 전송합니다.
- Offer 수신 및 Answer 생성 (Answerer):
- Answerer는 Signaling 서버를 통해 Offer를 수신합니다.
- Offer를 setRemoteDescription()으로 설정합니다.
- createAnswer()를 호출하여 Answer SDP를 생성합니다.
- Answer를 setLocalDescription()으로 설정합니다.
- Answer를 Signaling 서버를 통해 Offerer에게 전송합니다.
- Answer 수신 및 세션 설정 (Offerer):
- Offerer는 Signaling 서버를 통해 Answer를 수신합니다.
- Answer를 setRemoteDescription()으로 설정하여 세션을 완료합니다.
- ICE 후보 교환:
- 양 피어는 ICE 후보를 교환하여 최적의 네트워크 경로를 설정합니다.
- onicecandidate 이벤트를 통해 후보를 발견하고, 상대 피어에게 전송합니다.
- 상대 피어는 addIceCandidate()를 통해 후보를 추가합니다.
- 미디어 스트림 교환:
- P2P 연결이 완료되면, 양 피어는 오디오 및 비디오 스트림을 주고받습니다.
- ontrack 이벤트를 통해 상대 피어의 스트림을 수신하고 표시합니다.
6.3. ICE, STUN, TURN의 역할
- STUN (Session Traversal Utilities for NAT):
- 피어의 공용 IP 주소와 포트를 알아내는 데 사용됩니다.
- NAT(Network Address Translation) 뒤에 있는 피어도 P2P 연결을 설정할 수 있도록 도와줍니다.
- TURN (Traversal Using Relays around NAT):
- P2P 연결이 직접 설정되지 않을 때, 중계 서버를 통해 미디어 스트림을 전달합니다.
- 보통 STUN으로 직접 연결이 불가능할 때 사용됩니다.
9. WebRTC 연결 프로세스의 흐름 예시
아래는 WebRTC P2P 연결의 전체 흐름을 요약한 예시입니다.
- 초기화:
- 사용자가 페이지에 접속하면 getUserMedia()를 호출하여 미디어 스트림을 가져옵니다.
- 미디어 스트림을 로컬 비디오 요소에 할당합니다.
- RTCPeerConnection 객체를 생성하고, 로컬 스트림의 트랙을 추가합니다.
- Signaling 서버 연결 및 매칭:
- Signaling 서버에 WebSocket을 통해 연결합니다.
- 매칭 요청을 보내고, 매칭된 피어에게 역할을 할당받습니다.
- Offer/Answer 프로세스:
- Offerer:
- createOffer()를 호출하여 Offer SDP를 생성합니다.
- Offer를 setLocalDescription()으로 설정하고, Signaling 서버를 통해 전송합니다.
- Answerer:
- Offer를 수신하고, setRemoteDescription()으로 설정합니다.
- createAnswer()를 호출하여 Answer SDP를 생성합니다.
- Answer를 setLocalDescription()으로 설정하고, Signaling 서버를 통해 전송합니다.
- Offerer:
- Answer를 수신하고, setRemoteDescription()으로 설정하여 세션을 완성합니다.
- Offerer:
- ICE 후보 교환:
- 양 피어는 ICE 후보를 발견할 때마다 onicecandidate 이벤트를 통해 상대 피어에게 전송합니다.
- 상대 피어는 수신한 ICE 후보를 addIceCandidate()를 통해 추가합니다.
- 미디어 스트림 교환:
- P2P 연결이 설정되면, 양 피어는 오디오 및 비디오 스트림을 주고받습니다.
- ontrack 이벤트를 통해 상대 피어의 스트림을 수신하여 원격 비디오 요소에 표시합니다.
- 연결 유지 및 종료:
- ICE 연결 상태를 모니터링하여 연결 상태를 관리합니다.
- 연결이 종료되면, cleanupVideoChat() 등을 호출하여 자원을 해제합니다.
'Spring & Spring Boot' 카테고리의 다른 글
Spring - Pageable (0) | 2024.09.02 |
---|---|
ResponseEntity (0) | 2024.08.25 |
@RestController, @Controller (0) | 2024.08.20 |
REST API란 무엇인가? (0) | 2024.08.20 |
@RequestParam과 @PathVariable (0) | 2024.08.20 |