From 59832d9177f372382fe98d26fbb4a6f76c32c037 Mon Sep 17 00:00:00 2001
From: PARK NA HYUN <116629752+studioOwol@users.noreply.github.com>
Date: Mon, 11 Nov 2024 17:03:44 +0900
Subject: [PATCH] =?UTF-8?q?[FE][hotfix]=20WebRTC=20=EB=A1=9C=EC=A7=81=20?=
=?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20(#79)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor: JS 접근 제어자로 변경
* refactor: WebRTC 코드 리팩터링 및 렌더링 문제 일부 해결
* docs: README.md 업데이트
---
fe/README.md | 9 +-
fe/src/hooks/useGameRoom.ts | 74 ++++++
fe/src/hooks/useRoomActions.ts | 83 ++++++
.../pages/GamePage/PlayerList/VolumeBar.tsx | 2 +-
fe/src/pages/GamePage/index.tsx | 21 +-
fe/src/services/gameSocket.ts | 125 +++++----
fe/src/services/signalingSocket.ts | 248 ++++++------------
7 files changed, 351 insertions(+), 211 deletions(-)
create mode 100644 fe/src/hooks/useGameRoom.ts
create mode 100644 fe/src/hooks/useRoomActions.ts
diff --git a/fe/README.md b/fe/README.md
index 26e73f8..f4dbb49 100644
--- a/fe/README.md
+++ b/fe/README.md
@@ -2,4 +2,11 @@
### 새로운 게임방이 추가될 때 순서대로 추가되지 않음
-### 게임 페이지에서 새로고침하면 렌더링이 되지 않음
+### 게임 페이지에서 새로고침하면 렌더링이 완전히 되지 않음
+
+- VolumeBar `defaultValue` state 업데이트가 연속적으로 발생할 수 있는 구조
+- Controlled Component? Uncontrolled Component? 혼용하면 무한 상태 업데이트 루프 발생?
+
+## 게임 페이지에서 새로고침하면 players 방장을 제외하고 사라짐, 마이크도 안 됨
+
+- 새로고침이 아주 문제다!
diff --git a/fe/src/hooks/useGameRoom.ts b/fe/src/hooks/useGameRoom.ts
new file mode 100644
index 0000000..d852990
--- /dev/null
+++ b/fe/src/hooks/useGameRoom.ts
@@ -0,0 +1,74 @@
+import { gameSocket } from '@/services/gameSocket';
+import { getRoomsQuery } from '@/stores/queries/getRoomsQuery';
+import useRoomStore from '@/stores/zustand/useRoomStore';
+import { useCallback, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+export const useGameRoom = (roomId: string | undefined) => {
+ const navigate = useNavigate();
+ const setCurrentRoom = useRoomStore((state) => state.setCurrentRoom);
+ const { data: rooms, isLoading } = getRoomsQuery();
+
+ // 초기 설정 및 소켓 연결 관리
+ useEffect(() => {
+ // 로딩 중이면 리턴
+ if (isLoading) return;
+
+ // roomId나 rooms가 없으면 홈으로
+ if (!roomId || !rooms) {
+ navigate('/', { replace: true });
+ return;
+ }
+
+ const room = rooms.find((r) => r.roomId === roomId);
+ if (!room) {
+ navigate('/', { replace: true });
+ return;
+ }
+
+ // 소켓 연결 및 초기 설정
+ const initializeRoom = async () => {
+ try {
+ // 1. 소켓 연결
+ if (!gameSocket.socket?.connected) {
+ gameSocket.connect();
+ }
+
+ // 2. 방 설정
+ setCurrentRoom(room);
+
+ // 3. 소켓 재연결 (새로고침 대응)
+ await gameSocket.joinRoom(
+ roomId,
+ room.players[room.players.length - 1]
+ );
+ } catch (error) {
+ console.error('Failed to initialize room:', error);
+ navigate('/', { replace: true });
+ }
+ };
+
+ initializeRoom();
+
+ // Cleanup
+ return () => {
+ if (gameSocket.socket?.connected) {
+ gameSocket.disconnect();
+ }
+ setCurrentRoom(null);
+ };
+ }, [roomId, rooms, isLoading]);
+
+ // 방 나가기
+ const leaveRoom = useCallback(() => {
+ if (gameSocket.socket?.connected) {
+ gameSocket.disconnect();
+ }
+ setCurrentRoom(null);
+ navigate('/', { replace: true });
+ }, [navigate, setCurrentRoom]);
+
+ return {
+ leaveRoom,
+ };
+};
diff --git a/fe/src/hooks/useRoomActions.ts b/fe/src/hooks/useRoomActions.ts
new file mode 100644
index 0000000..2e51de9
--- /dev/null
+++ b/fe/src/hooks/useRoomActions.ts
@@ -0,0 +1,83 @@
+import { useCallback } from 'react';
+import { gameSocket } from '@/services/gameSocket';
+import { signalingSocket } from '@/services/signalingSocket';
+
+export const useRoomActions = () => {
+ const createRoom = useCallback(
+ async (roomName: string, hostNickname: string) => {
+ try {
+ // 1. 게임 소켓 연결
+ gameSocket.connect();
+
+ // 2. 오디오 스트림 설정 및 방 생성
+ const stream = await gameSocket.createRoom(roomName, hostNickname);
+
+ // 3. 시그널링 소켓 설정
+ signalingSocket.connect();
+ signalingSocket.setLocalStream(stream);
+
+ // 4. 방 생성 성공 시 시그널링 룸 참여
+ const onRoomCreated = (room) => {
+ signalingSocket.joinSignalingRoom(room.roomId, hostNickname);
+ gameSocket.socket?.off('roomCreated', onRoomCreated);
+ };
+
+ gameSocket.socket?.on('roomCreated', onRoomCreated);
+ } catch (error) {
+ console.error('방 생성 실패:', error);
+ throw error;
+ }
+ },
+ []
+ );
+
+ const joinRoom = useCallback(
+ async (roomId: string, playerNickname: string) => {
+ try {
+ // 1. 게임 소켓 연결
+ gameSocket.connect();
+
+ // 2. 오디오 스트림 설정 및 방 참여
+ const stream = await gameSocket.joinRoom(roomId, playerNickname);
+
+ // 3. 시그널링 소켓 설정
+ signalingSocket.connect();
+ signalingSocket.setLocalStream(stream);
+
+ // 4. 방 참여 성공 시 시그널링 룸 참여
+ const onUpdateUsers = (players) => {
+ if (players.includes(playerNickname)) {
+ signalingSocket.joinSignalingRoom(roomId, playerNickname);
+ gameSocket.socket?.off('updateUsers', onUpdateUsers);
+ }
+ };
+
+ gameSocket.socket?.on('updateUsers', onUpdateUsers);
+ } catch (error) {
+ console.error('방 참여 실패:', error);
+ throw error;
+ }
+ },
+ []
+ );
+
+ const leaveRoom = useCallback(() => {
+ signalingSocket.disconnect();
+ gameSocket.disconnect();
+ }, []);
+
+ const toggleAudio = useCallback((enabled: boolean) => {
+ if (gameSocket['#audioStream']) {
+ gameSocket['#audioStream'].getAudioTracks().forEach((track) => {
+ track.enabled = enabled;
+ });
+ }
+ }, []);
+
+ return {
+ createRoom,
+ joinRoom,
+ leaveRoom,
+ toggleAudio,
+ };
+};
diff --git a/fe/src/pages/GamePage/PlayerList/VolumeBar.tsx b/fe/src/pages/GamePage/PlayerList/VolumeBar.tsx
index 11cad76..390ec5d 100644
--- a/fe/src/pages/GamePage/PlayerList/VolumeBar.tsx
+++ b/fe/src/pages/GamePage/PlayerList/VolumeBar.tsx
@@ -17,7 +17,7 @@ const VolumeBar = ({ isOn }: AudioControlProps) => {
)}
{
const [isAudioOn, setIsAudioOn] = useState(true);
const { roomId } = useParams();
const { data: rooms } = getRoomsQuery();
+ const { leaveRoom } = useRoomActions();
const { currentRoom, setCurrentRoom } = useRoomStore();
+ const navigate = useNavigate();
+ // 방 정보 업데이트 및 WebRTC 연결 설정
useEffect(() => {
- if (rooms && roomId) {
+ if (rooms && roomId && (!currentRoom || currentRoom.roomId !== roomId)) {
const room = rooms.find((r) => r.roomId === roomId);
if (room) {
setCurrentRoom(room);
+ } else {
+ // 방을 찾을 수 없는 경우
+ navigate('/');
}
}
- }, [rooms, roomId]);
+ }, [rooms, roomId, currentRoom]);
+
+ // 페이지 나가기 전에 연결 정리
+ useEffect(() => {
+ return () => {
+ console.log('Cleaning up room connections');
+ leaveRoom();
+ };
+ }, [leaveRoom]);
if (!currentRoom) return null;
diff --git a/fe/src/services/gameSocket.ts b/fe/src/services/gameSocket.ts
index 5431feb..9c2ae34 100644
--- a/fe/src/services/gameSocket.ts
+++ b/fe/src/services/gameSocket.ts
@@ -5,18 +5,73 @@ import {
} from '@/types/socketTypes';
import { Room } from '@/types/roomTypes';
import { SocketService } from './SocketService';
-import useRoomStore from '@/stores/zustand/useRoomStore';
import { cleanupAudioStream, requestAudioStream } from './audioRequest';
+import useRoomStore from '@/stores/zustand/useRoomStore';
const GAME_SOCKET_URL = 'wss://game.clovapatra.com/rooms';
+interface AudioSetup {
+ audioContext: AudioContext;
+ source: MediaStreamAudioSourceNode;
+ gainNode: GainNode;
+}
+
class GameSocket extends SocketService {
#audioStream: MediaStream | null = null;
+ #audioSetup: AudioSetup | null = null;
constructor() {
super();
}
+ async setupAudioStream(): Promise {
+ try {
+ if (this.#audioStream) {
+ return this.#audioStream;
+ }
+
+ const stream = await requestAudioStream();
+ this.#audioStream = stream;
+
+ // 오디오 설정
+ this.#audioSetup = await this.#setupAudio(stream);
+
+ return stream;
+ } catch (error) {
+ console.error('Failed to setup audio stream:', error);
+ throw error;
+ }
+ }
+
+ async #setupAudio(stream: MediaStream): Promise {
+ try {
+ const audioContext = new AudioContext();
+ const source = audioContext.createMediaStreamSource(stream);
+ const gainNode = audioContext.createGain();
+
+ source.connect(gainNode);
+ gainNode.connect(audioContext.destination);
+
+ // 기본 볼륨 설정
+ gainNode.gain.value = 0.5;
+
+ return {
+ audioContext,
+ source,
+ gainNode,
+ };
+ } catch (error) {
+ console.error('Error setting up audio:', error);
+ throw error;
+ }
+ }
+
+ setAudioVolume(volume: number) {
+ if (this.#audioSetup) {
+ this.#audioSetup.gainNode.gain.value = volume;
+ }
+ }
+
connect() {
if (this.socket?.connected) return;
@@ -26,20 +81,30 @@ class GameSocket extends SocketService {
}) as Socket;
this.setSocket(socket);
- this.setupEventListeners();
+ this.#setupEventListeners();
+ }
+
+ async createRoom(roomName: string, hostNickname: string) {
+ this.validateSocket();
+ const stream = await this.setupAudioStream();
+ this.socket?.emit('createRoom', { roomName, hostNickname });
+ return stream;
+ }
+
+ async joinRoom(roomId: string, playerNickname: string) {
+ this.validateSocket();
+ const stream = await this.setupAudioStream();
+ this.socket?.emit('joinRoom', { roomId, playerNickname });
+ return stream;
}
- private setupEventListeners() {
+ #setupEventListeners() {
if (!this.socket) return;
this.socket.on('connect', () => {
console.log('Game socket connected');
});
- this.socket.on('connect_error', (error) => {
- console.error('Game socket connection error:', error);
- });
-
this.socket.on('roomCreated', async (room: Room) => {
try {
const store = useRoomStore.getState();
@@ -50,12 +115,13 @@ class GameSocket extends SocketService {
}
});
- this.socket.on('updateUsers', async (players: string[]) => {
+ this.socket.on('updateUsers', (players: string[]) => {
try {
- const { currentRoom, setCurrentRoom } = useRoomStore.getState();
+ const store = useRoomStore.getState();
+ const { currentRoom } = store;
if (currentRoom) {
- setCurrentRoom({
+ store.setCurrentRoom({
...currentRoom,
players,
hostNickname: players[0],
@@ -65,50 +131,19 @@ class GameSocket extends SocketService {
console.error('Failed to update users:', error);
}
});
-
- this.socket.on('error', (error) => {
- console.error('Socket error:', error);
- window.location.href = '/';
- });
- }
-
- createRoom(roomName: string, hostNickname: string) {
- this.validateSocket();
-
- // 마이크 권한 요청 후 방 생성
- requestAudioStream()
- .then((stream) => {
- this.#audioStream = stream; // stream 저장
- this.socket?.emit('createRoom', { roomName, hostNickname });
- })
- .catch((error) => {
- console.error('Failed to access microphone:', error);
- throw error;
- });
}
- joinRoom(roomId: string, playerNickname: string) {
- this.validateSocket();
- requestAudioStream()
- .then((stream) => {
- this.#audioStream = stream; // stream 저장
- this.socket?.emit('joinRoom', { roomId, playerNickname });
- })
- .catch((error) => {
- console.error('Failed to access microphone:', error);
- throw error;
- });
- }
-
- // audio stream 정리를 위한 메서드
cleanupAudio() {
if (this.#audioStream) {
cleanupAudioStream(this.#audioStream);
this.#audioStream = null;
}
+ if (this.#audioSetup) {
+ this.#audioSetup.audioContext.close();
+ this.#audioSetup = null;
+ }
}
- // disconnect 시 audio도 정리
override disconnect() {
this.cleanupAudio();
super.disconnect();
diff --git a/fe/src/services/signalingSocket.ts b/fe/src/services/signalingSocket.ts
index 388bf66..afe5562 100644
--- a/fe/src/services/signalingSocket.ts
+++ b/fe/src/services/signalingSocket.ts
@@ -8,13 +8,17 @@ import { SocketService } from './SocketService';
const SIGNALING_URL = 'https://signaling.clovapatra.com';
class SignalingSocket extends SocketService {
- #peerConnections: Record = {};
- #iceCandidateQueue = new Map();
+ #peerConnections = new Map();
+ #localStream: MediaStream | null = null;
constructor() {
super();
}
+ setLocalStream(stream: MediaStream) {
+ this.#localStream = stream;
+ }
+
connect() {
if (this.socket?.connected) return;
@@ -24,155 +28,46 @@ class SignalingSocket extends SocketService {
}) as Socket;
this.setSocket(socket);
- this.setupEventListeners();
+ this.#setupEventListeners();
}
- private setupEventListeners() {
- if (!this.socket) return;
-
- this.socket.on('connect', () => {
- console.log('Signaling socket connected');
- });
-
- this.socket.on('connect_error', (error) => {
- console.error('Signaling socket connection error:', error);
- });
-
- this.socket.on('joinSuccess', () => {
- console.log('Successfully joined signaling room');
- });
-
- this.socket.on('peer-joined', async ({ peerId, userId }) => {
- try {
- const pc = await this.createPeerConnection(peerId);
- const offer = await pc.createOffer({
- offerToReceiveAudio: true,
- offerToReceiveVideo: false,
- });
- await pc.setLocalDescription(offer);
-
- this.socket?.emit('offer', {
- targetId: peerId,
- sdp: pc.localDescription!,
- });
- } catch (error) {
- console.error('Error handling peer joined:', error);
- }
- });
-
- this.socket.on('offer', async ({ targetId, sdp }) => {
- try {
- let pc = this.#peerConnections[targetId];
-
- if (!pc) {
- pc = await this.createPeerConnection(targetId);
- }
-
- await pc.setRemoteDescription(new RTCSessionDescription(sdp));
- const answer = await pc.createAnswer();
- await pc.setLocalDescription(answer);
-
- this.socket?.emit('answer', {
- targetId: targetId,
- sdp: pc.localDescription!,
- });
-
- // Process queued ICE candidates
- if (this.#iceCandidateQueue.has(targetId)) {
- const candidates = this.#iceCandidateQueue.get(targetId)!;
- for (const candidate of candidates) {
- await pc.addIceCandidate(candidate);
- }
- this.#iceCandidateQueue.delete(targetId);
- }
- } catch (error) {
- console.error('Error handling offer:', error);
- }
- });
-
- this.socket.on('answer', async ({ targetId, sdp }) => {
- try {
- const pc = this.#peerConnections[targetId];
- if (!pc) return;
-
- await pc.setRemoteDescription(new RTCSessionDescription(sdp));
-
- // Process queued ICE candidates
- if (this.#iceCandidateQueue.has(targetId)) {
- const candidates = this.#iceCandidateQueue.get(targetId)!;
- for (const candidate of candidates) {
- await pc.addIceCandidate(candidate);
- }
- this.#iceCandidateQueue.delete(targetId);
- }
- } catch (error) {
- console.error('Error handling answer:', error);
- }
- });
-
- this.socket.on('ice-candidate', async ({ targetId, candidate }) => {
- try {
- const pc = this.#peerConnections[targetId];
- if (pc && pc.remoteDescription) {
- await pc.addIceCandidate(new RTCIceCandidate(candidate));
- } else {
- if (!this.#iceCandidateQueue.has(targetId)) {
- this.#iceCandidateQueue.set(targetId, []);
- }
- this.#iceCandidateQueue
- .get(targetId)
- ?.push(new RTCIceCandidate(candidate));
- }
- } catch (error) {
- console.error('Error handling ICE candidate:', error);
- }
- });
-
- this.socket.on('peer-left', ({ peerId }) => {
- this.cleanupPeerConnection(peerId);
- });
+ joinSignalingRoom(roomId: string, userId: string) {
+ this.validateSocket();
+ console.log('Joining signaling room:', roomId, userId);
+ this.socket?.emit('join', { roomId, userId });
}
- private async createPeerConnection(
- peerId: string
- ): Promise {
- const configuration: RTCConfiguration = {
+ async #createPeerConnection(peerId: string) {
+ const config = {
iceServers: [
- {
- urls: [
- 'stun:stun.l.google.com:19302',
- 'stun:stun1.l.google.com:19302',
- ],
- },
+ { urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turn.anyfirewall.com:443?transport=tcp',
credential: 'webrtc',
username: 'webrtc',
},
],
- iceCandidatePoolSize: 0,
- bundlePolicy: 'max-bundle',
- rtcpMuxPolicy: 'require',
- iceTransportPolicy: 'all',
};
- const pc = new RTCPeerConnection(configuration);
+ const pc = new RTCPeerConnection(config);
- pc.oniceconnectionstatechange = () => {
- console.log(`ICE connection state: ${pc.iceConnectionState}`);
- if (pc.iceConnectionState === 'failed') {
- pc.restartIce();
- }
- };
-
- pc.onconnectionstatechange = () => {
- console.log(`Connection state: ${pc.connectionState}`);
- };
+ // 로컬 스트림 추가
+ if (this.#localStream) {
+ this.#localStream.getTracks().forEach((track) => {
+ pc.addTrack(track, this.#localStream!);
+ });
+ }
- pc.onsignalingstatechange = () => {
- console.log(`Signaling state: ${pc.signalingState}`);
+ // 원격 스트림 처리
+ pc.ontrack = (event) => {
+ console.log('Received remote track');
+ const audio = new Audio();
+ audio.srcObject = event.streams[0];
+ audio.autoplay = true;
+ document.body.appendChild(audio);
};
+ // ICE 후보 전송
pc.onicecandidate = (event) => {
if (event.candidate) {
this.socket?.emit('ice-candidate', {
@@ -182,40 +77,71 @@ class SignalingSocket extends SocketService {
}
};
- pc.ontrack = (event) => {
- try {
- const audioElement = new Audio();
- audioElement.autoplay = true;
- audioElement.srcObject = event.streams[0];
- document.body.appendChild(audioElement);
-
- console.log('Audio element created and playing');
- } catch (error) {
- console.error('Error setting up audio element:', error);
- }
- };
-
- this.#peerConnections[peerId] = pc;
+ this.#peerConnections.set(peerId, pc);
return pc;
}
- joinSignalingRoom(roomId: string, userId: string) {
- this.validateSocket();
- this.socket?.emit('join', { roomId, userId });
- }
+ #setupEventListeners() {
+ if (!this.socket) return;
- private cleanupPeerConnection(peerId: string) {
- const pc = this.#peerConnections[peerId];
- if (pc) {
- pc.close();
- delete this.#peerConnections[peerId];
- }
+ // 새로운 피어 참여
+ this.socket.on('peer-joined', async ({ peerId }) => {
+ console.log('New peer joined:', peerId);
+ const pc = await this.#createPeerConnection(peerId);
+ const offer = await pc.createOffer();
+ await pc.setLocalDescription(offer);
+
+ this.socket?.emit('offer', {
+ targetId: peerId,
+ sdp: pc.localDescription,
+ });
+ });
+
+ // Offer 처리
+ this.socket.on('offer', async ({ targetId, sdp }) => {
+ console.log('Received offer from:', targetId);
+ const pc = await this.#createPeerConnection(targetId);
+ await pc.setRemoteDescription(new RTCSessionDescription(sdp));
+
+ const answer = await pc.createAnswer();
+ await pc.setLocalDescription(answer);
+
+ this.socket?.emit('answer', {
+ targetId,
+ sdp: pc.localDescription,
+ });
+ });
+
+ // Answer 처리
+ this.socket.on('answer', async ({ targetId, sdp }) => {
+ console.log('Received answer from:', targetId);
+ const pc = this.#peerConnections.get(targetId);
+ if (pc) {
+ await pc.setRemoteDescription(new RTCSessionDescription(sdp));
+ }
+ });
+
+ // ICE 후보 처리
+ this.socket.on('ice-candidate', async ({ targetId, candidate }) => {
+ const pc = this.#peerConnections.get(targetId);
+ if (pc) {
+ await pc.addIceCandidate(new RTCIceCandidate(candidate));
+ }
+ });
+
+ // 피어 연결 해제
+ this.socket.on('peer-left', ({ peerId }) => {
+ const pc = this.#peerConnections.get(peerId);
+ if (pc) {
+ pc.close();
+ this.#peerConnections.delete(peerId);
+ }
+ });
}
override disconnect() {
- Object.keys(this.#peerConnections).forEach(
- this.cleanupPeerConnection.bind(this)
- );
+ this.#peerConnections.forEach((pc) => pc.close());
+ this.#peerConnections.clear();
super.disconnect();
}
}