Skip to content

Conversation

Doosies
Copy link
Collaborator

@Doosies Doosies commented Feb 19, 2024

🔮 resolved #520

변경 사항

고민과 해결 과정

1️⃣채팅 페이지를 나왔을 때 카메라 권한이 꺼지도록 수정 / signalling server에서 offer, answer, candidate 주고 받는 함수 하나로 통일

❗ peerConnection: WebRTC에서 상대방과 맺어진 연결정보

리팩토링을 하기 이전의 진행과정은 아래와 같음

  1. 페이지 진입시 카메라/마이크 권한을 얻고 peerConnection에 전달
  2. 카메라를 끄면 stream.enabled만 true, false로 변경
  3. 페이지 나가면 아무것도 안함

위의 1, 2, 3번이 모두 문제가 됐기에 관련 훅들을 모두 리팩토링 해줌
리팩토링을 하면서 사용자가 실제로 권한을 요청 했을때만 권한을 얻도록 수정함
또, 카메라나 마이크를 끄면 실제로 권한을 없애줌

그런데 카메라 권한을 얻은 후 peerConnection에 다시 추가하려면 재협상 과정이 필요했음
재협상은 offer, answer, candidate를 다시 주고 받는 과정인데 이 이벤트들을 나눠두면 재협상 과정이 번거로웠음
그래서 connection 이라는 하나의 이벤트로 합쳐줌
그러고서 재협상이 필요할 경우 아래의 이벤트 리스너로 그 과정을 진행해 주었음

// 이 리스너는 재협상이 필요한 상황에서 호출됨
this.peerConnection.addEventListener('negotiationneeded', async () => {
  // 만약 아직 peerConnection이 초기화되지 않았거나, 연결된적이 없다면 리턴함
  if (this.peerConnection?.signalingState !== 'stable' || !this.peerConnection?.remoteDescription) {
    return;
  }
  // 재협상 시작
  const offer = await this.peerConnection?.createOffer();
  await this.setLocalDescription(offer);
  this.socketManager.emit('connection', { roomName, description: this.peerConnection?.localDescription });
});

참조: addTrack을 할 경우 재협상이 필요

이제 권한을 얻을때는 navigator.mediaDevices.getUserMedia({ audio, video }) 를 호출하면 되고
권한을 제거할때는 위 getUserMedia 함수의 리턴값인 MediaStream.getTracks()로 track을 얻어와 track.stop()을 호출하면됨

결과

useMediaStream훅 안의 아래의 함수를 호출하면 모든 권한을 제거해줌

const disconnectMediaStream = () => {
  localStream.getTracks().forEach(track => {
    track.stop();
    localStream.removeTrack(track);
  });
};
2️⃣WebRTC 한번 더 리팩토링

사실 전에 WebRTC를 리팩토링 했을때도 사용하는게 조금 까다로웠음
왜냐하면 MediaStream을 다루려면 useVideoRef, useControllMedia을 사용해야 했기 때문
따라서 useMediaStream에서 이를 모두 다룰 수 있도록 리팩토링 해줌.
그 결과 MediaStream을 통해 카메라나 마이크 권한을 얻거나 제거하는 등의 행위는 모두 useMediaStream을 통해 이뤄짐

(선택) 테스트 결과

2024-02-19.12.12.05.mov
 PASS  src/events/events.gateway.spec.ts
  EventsGateway
    ✓ should be defined (8 ms)
    handleConnection()
      ✓ 소켓이 들어오면 로그 찍힘 (2 ms)
    handleDisconnect()
      ✓ 기본: Disconnected 로그 찍힘 (1 ms)
      ✓ socket이 비정상(users에 존재하지 않는 socket.id): 로그 찍힘 (1 ms)
      ✓ socket이 비정상(socketRooms에 존재하지 않는 roomId): 로그 찍힘 (1 ms)
      ✓ socket이 정상: 유저 목록에서 제거됨 (1 ms)
      ✓ socket이 정상(host): 방 제거, 방에 "hostExit" 이벤트 보냄 (1 ms)
      ✓ socket이 정상(guest): 방에서 유저 제거, 방에 "userExit" 이벤트 보냄 (2 ms)
    generateRoomName 이벤트[handleCreateRoomEvent()] on
      ✓ roomName 랜덤하게 생성 후 해당 소켓에 "roomNameGenerated" 이벤트 보냄 (1 ms)
    createRoom 이벤트 [handleSetRoomPassword()] on
      ✓ socket.join(roomId)하고 users에 id추가, socketRooms에 유저 추가함 (1 ms)
    joinRoom 이벤트 [handleJoinRoomEvent()] on
      ✓ 이벤트와 함께온 roomId가 잘못됨: 해당 소켓에 "joinRoomFailed발생" 이벤트 보냄 (1 ms)
      ✓ 이벤트와 함께온 비밀번호가 틀림: 해당 소켓에 "joinRoomFailed발생" 이벤트 보냄
      ✓ 방이 꽉참: 해당 소켓에 "roomFull" 이벤트 보냄 (1 ms)
      ✓ 이벤트 성공적으로 발생:
          1. users와 socketRooms에 추가 
          2. socket.join(roomId) 실행됨
          3. 방에 welcome 이벤트 발생
          4. 해당 소켓에 "RoomSuccess" 이벤트 보냄 (1 ms)
    connection 이벤트 (offer, answer, candidate) <------------------------------새로 추가된 테스트
      ✓ description(offer, answer)이 있을 때: 방에 description 보냄 (1 ms)
      ✓ candidate가 있을 때: 방에 candidate 보냄
    checkRoomExist 이벤트 [handleCheckRoomExistEvent()] on
      ✓ roomId에 해당하는 방이 존재함: 해당 소켓에 "roomExist" 이벤트 보냄
      ✓ roomId에 해당하는 방이 존재하지 않음: 해당 소켓에 "roomNotExist" 이벤트 보냄 (1 ms)

Test Suites: 1 passed, 1 total
Tests:       18 passed, 18 total
Snapshots:   0 total
Time:        1.306 s, estimated 2 s
Ran all test suites related to changed files.

Watch Usage: Press w to show more.
 RERUN  src/business/hooks/webRTC/__tests__/useMediaStream.spec.ts x2

 ✓ src/business/hooks/webRTC/__tests__/useMediaStream.spec.ts (10)
   ✓ useMediaStream 훅 (10)
     ✓ toggleMediaOnOff 함수 테스트 (4)
       ✓ mediaInfoChannel이 open이면 반대 상태를 상대에게 전달한다.
       ✓ mediaInfoChannel이 closed면 상태를 상대에게 전달하지 않는다.
       ✓ mediaEnabled가 true일 때, 해당하는 type의 track을 멈춘다.(video)
       ✓ mediaEnabled가 false일 때, 해당하는 type의 track을 추가한다.(video)
     ✓ changeMediaTrack 함수 테스트 (3)
       ✓ id가 존재할 때, 해당하는 type 상태의 id를 변경한다.
       ✓ id가 존재하지 않을 때, 해당하는 type 상태의 id를 변경하지 않는다.
       ✓ id와 관계없이 해당하는 type의 track을 멈추고 새로운 track을 추가한다.
     ✓ disconnectMediaStream 함수 테스트 (1)
       ✓ localStream의 track을 모두 멈추고 제거한다.
     ✓ replacePeerconnectionTrack 함수 테스트 (1)
       ✓ 해당하는 type의 track을 변경한다.
     ✓ localStream, remoteStream 변수 테스트 (1)
       ✓ localStream, remoteStream 변수를 반환한다.

 Test Files  1 passed (1)
      Tests  10 passed (10)
   Start at  12:29:17
   Duration  258ms


 PASS  Waiting for file changes...
       press h to show help, press q to quit

ex) 베이스 브랜치에 포함되기 위한 코드는 모두 정상적으로 동작해야 합니다. 결과물에 대한 스크린샷, GIF, 혹은 라이브 데모가 가능하도록 샘플API를 첨부할 수도 있습니다.

- 기존대로 하면 리렌더링이 돼야 로딩상태를 벗어남
- 문제가 있는 방법이기에 stream 상태를 기반으로 loading 상태를 줌
- 결국 sdp, ice를 주고 받는 과정인데 이벤트를 나눌 필요가 없었음
- 그래서 connection이라는 이벤트로 통일시켜줌
- 이렇게 한 이유는 재협상 할 때 이 이벤트를 사용하기 위해서임
- 이 전 리팩토링에서는 의존성을 한방향으로 풀어서 보기 쉽게 리팩토링함
- 이번에는 훅 안의 함수가 어떤 기능을 하는지 정확히 알 수 있도록 리팩토링함
- localStream의 트랙을 모두 stop함으로써 이 문제를 해결함
- zustand 목업에 MediaStream목업을 추가함
- rootDir/__mocks가 추가되어 있지 않아서 에러가 뜸
- 그래서 추가해줌
- await을 붙여주지 않았음
- 인수로 받는 replacePeerconnection를 사용하도록 변경
@Doosies Doosies requested review from kimyu0218 and HeoJiye February 19, 2024 03:15
@Doosies Doosies self-assigned this Feb 19, 2024
Comment on lines 134 to 158

@SubscribeMessage<HumanClientEvent>('offer')
handleOfferEvent(
@SubscribeMessage<HumanClientEvent>('connection')
handleConnectionEvent(
socket: Socket,
[sdp, roomName]: [RTCSessionDescription, string],
{
description,
candidate,
roomName,
}: {
description?: RTCSessionDescription;
candidate?: RTCIceCandidate;
roomName: string;
},
) {
this.logger.debug(`🚀 Offer Received from ${socket.id}`);
this.eventEmitToRoom(socket, roomName, 'offer', sdp);
}

@SubscribeMessage<HumanClientEvent>('answer')
handleAnswerEvent(
socket: Socket,
[sdp, roomName]: [RTCSessionDescription, string],
) {
this.logger.debug(`🚀 Answer Received from ${socket.id}`);
this.eventEmitToRoom(socket, roomName, 'answer', sdp);
}

@SubscribeMessage<HumanClientEvent>('candidate')
handleCandidateEvent(
socket: Socket,
[candidate, roomName]: [RTCIceCandidate, string],
) {
this.logger.debug(`🚀 Candidate Received from ${socket.id}`);
this.eventEmitToRoom(socket, roomName, 'candidate', candidate);
try {
if (description) {
this.logger.debug(`🚀 ${description.type} Received from ${socket.id}`);
this.eventEmitToRoom(socket, roomName, 'connection', { description });
} else if (candidate) {
this.logger.debug(`🚀 Candidate Received from ${socket.id}`);
this.eventEmitToRoom(socket, roomName, 'connection', { candidate });
}
} catch (error) {
this.logger.error(`🚀 Error in handleMessageEvent : ${error}`);
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 signalling 서버에서 sdp를 주고 받는 과정을 connection 이벤트 하나로 간소화함
-> 결국 description 혹은 candidate를 주고 받는 과정이기 때문에 하나로 합침

Comment on lines +39 to +45
window.MediaStream = vi.fn().mockReturnValue({
getTracks: vi.fn().mockReturnValue([
{ enabled: true, id: 'test' },
{ enabled: true, id: 'test' },
]),
});

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 만약 zustand를 초기화 할 때 MediaStream을 사용한다면 테스트를 진행할 수 없기에 zustand를 초기화 하기 이전에 MediaStream을 모킹해줌

{ type: 'audio', onOrOff: audioTrack?.enabled },
{ type: 'video', onOrOff: videoTrack?.enabled },
{ type: 'video', onOrOff: myVideoOn },
{ type: 'audio', onOrOff: myMicOn },
]),
);
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 이제 권한을 끄면 enabled를 바꾸는게 아니라 아예 트랙을 제거 해버리기 때문에 트랙에서 가져오는게 아니라 상태에서 바로 가져오도록 수정함

Comment on lines +50 to +74
const toggleMediaOnOff = async ({
type,
replacePeerconnection = true,
}: {
type: 'audio' | 'video';
replacePeerconnection?: boolean;
}) => {
const mediaEnabled = type === 'video' ? myVideoEnabled : myMicEnabled;
const toggleMediaState = type === 'video' ? toggleMyVideo : toggleMyMic;

if (mediaInfoChannel?.readyState === 'open') {
mediaInfoChannel.send(JSON.stringify([{ type, onOrOff: !mediaEnabled }]));
}

toggleMediaState();

if (mediaEnabled) {
stopTracks(type);
} else {
const stream = type === 'video' ? await getVideoStream() : await getAudioStream();

setLocalStream(localStream);
addTracks(stream, replacePeerconnection);
}
};
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 만약 type(audio | video)가 켜져있다면 stopTracks()로 권한을 제거하고 트랙도 제거함
꺼져있다면 새롭게 카메라나 오디오를 얻어온 후 stream에 적용하고, peerConnection에도 적용해줌

Comment on lines +96 to +101
const disconnectMediaStream = () => {
localStream.getTracks().forEach(track => {
track.stop();
localStream.removeTrack(track);
});
};
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 MediaStream에 존재하는 모든 권한을 제거

Comment on lines +47 to +66
export async function handleWebRTCConnectionSetup({
description,
candidate,
roomName,
}: { roomName: string } & WebRTCConnectionSetupParams) {
const webRTC = WebRTC.getInstance(HumanSocketManager.getInstance());

webRTC.setLocalDescription(answerSdp);
// offer & answer를 주고받는 과정
if (description) {
await webRTC.setRemoteDescription(description);

socketManager.emit('answer', answerSdp, roomName);
// 상대방이 보낸 offer에 대해 answer를 생성하고, 이를 다시 상대방에게 보냄
if (description.type === 'offer') {
sendCreatedSDP(roomName, 'answer');
}
}
// 위의 과정이 끝나고 ice candidate를 주고받는 과정
else if (candidate) {
await webRTC.addIceCandidate(candidate);
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 만약 connection 이벤트가 발동하면 이 함수가 실행돼서 offer, answer, candidate를 주고 받게됨

Comment on lines +58 to +67
this.peerConnection.addEventListener('negotiationneeded', async () => {
// 만약 아직 peerConnection이 초기화되지 않았거나, 연결된적이 없다면 리턴함
if (this.peerConnection?.signalingState !== 'stable' || !this.peerConnection?.remoteDescription) {
return;
}

const offer = await this.peerConnection?.createOffer();
await this.setLocalDescription(offer);
this.socketManager.emit('connection', { roomName, description: this.peerConnection?.localDescription });
});
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 이 부분이 재협상이 필요하다고 브라우저가 알려줬을 때 실행되는 핸들러

  • offer를 생성하며 재협상 과정을 시작함

Comment on lines -48 to +54
{loading && <div className="absolute skeleton w-h-full"></div>}{' '}
{!stream?.active && <div className="absolute skeleton w-h-full rounded-[55px] "></div>}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 기존에 CamBox UI가 이상했던 부분을 여기서 수정해줌

  • 스켈레톤에 rounded를 줘서video와 같은 모양을 만들어줌
  • stream?.active를 사용해 실제로 stream이 오고 있지 않을때만 스켈레톤을 보이게 해줌

Comment on lines 47 to 52
useEffect(() => {
return () => {
endWebRTC();
disconnectMediaStream();
};
}, []);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 HumanChatPage를 나갈 때 MediaStream의 모든 권한을 제거해줌

@@ -33,6 +33,6 @@

},
"typeRoots": ["./node_modules/@types", "./src/@types"],
"include": ["src", "@types", ".src/setup-vitest.ts"],
"include": ["src", "@types", ".src/setup-vitest.ts", "__mocks__"],
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 /mocks 안에 있는 폴더들은 vi를 인식하지 못하므로 추가해줌

@Doosies Doosies changed the title #520 채팅 페이지를 나왔을 때 카메라 권한이 꺼지도록 수정 Fe/bugfix/#520 채팅 페이지를 나왔을 때 카메라 권한이 꺼지도록 수정 Feb 19, 2024
@Doosies Doosies changed the title Fe/bugfix/#520 채팅 페이지를 나왔을 때 카메라 권한이 꺼지도록 수정 Fe/bugfix,refactor/#520 채팅 페이지를 나왔을 때 카메라 권한이 꺼지도록 수정 Feb 19, 2024
@Doosies Doosies added this to the version 1.0.0 milestone Feb 19, 2024
@Doosies Doosies removed this from the version 1.0.0 milestone Feb 19, 2024
Comment on lines -135 to +136
@SubscribeMessage<HumanClientEvent>('offer')
handleOfferEvent(
@SubscribeMessage('connection')
handleConnectionEvent(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 현재 HumanClientEvent를 npm에서 가져와 쓰고있는데 이럴경우 수정해야 할 때 큰 번거로움이 존재함

  • 빌드가 되질 않아서 일단 저 타입을 사용하지 않음

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 이거 이번에 다른 방식으로 고쳐봐야겠다

- 그래서 일단 저 타입을 사용하지 않음
@Doosies Doosies force-pushed the #520-채팅-페이지를-나왔을-때-카메라-권한이-꺼지도록-수정 branch from 2be8e8f to 7cd5a67 Compare February 19, 2024 04:11
Copy link
Collaborator

@kimyu0218 kimyu0218 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WebRTC 천재 👍👍

Comment on lines -135 to +136
@SubscribeMessage<HumanClientEvent>('offer')
handleOfferEvent(
@SubscribeMessage('connection')
handleConnectionEvent(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 이거 이번에 다른 방식으로 고쳐봐야겠다

@Doosies Doosies merged commit 514ca9e into dev Feb 19, 2024
@Doosies Doosies deleted the #520-채팅-페이지를-나왔을-때-카메라-권한이-꺼지도록-수정 branch February 19, 2024 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: ✅ Done
Development

Successfully merging this pull request may close these issues.

🐞 채팅 페이지를 나왔음에도 카메라 권한이 꺼지지 않는 버그
3 participants