화상회의 페이지 개선하기

드뮴·2025년 1월 6일
9

🪴 개발일지

목록 보기
2/6
post-thumbnail

화상회의 페이지에서 발견한 문제

매주 데모데이에서 한 주간 개발한 것에 대해 발표하는 시간을 가졌다. 우리 팀은 완성된 결과에 대해 시연 영상을 찍어서 넣고 있었는데, 데모 전날 화상회의 페이지를 개발하는 팀원이 시연 영상을 찍을 때 화상회의 페이지 사이즈를 잘 조절해야한다고 했었다. 그래서 화상회의 페이지를 살펴보았는데 아래와 같은 문제가 있었다.

🚨 발견한 문제는 다음과 같았다.

  • 사용자마다 보이는 비디오 레이아웃이 다르다.
  • 한 화면에 참가자 모두의 비디오가 나오지 않는다.
    • 영상을 찍을 당시에는 아니지만, 그 때 당시에는 스크롤을 막아둬서 다른 사람의 비디오는 다 잘렸었다.
  • 비디오 비율이 4:3이 아닌 경우도 있다.
  • 비디오 사이즈가 모두 다른 문제가 있다.

해당 페이지 개발 팀원은 말하는 사용자가 비디오가 커지게 만들고 싶다고 했었다. 추후 이렇게 수정할 생각이었고, 위 상황에서는 말하는 사용자가 아닌 자신의 비디오를 일단 크게 만들어둔 상황이었다.


개선 목표를 세우자

면접 스터디를 위한 서비스이기 때문에 가장 중요한 건 스터디룸 기능이었다. 그리고 가장 중요한 페이지는 화상회의 페이지, 즉 참가자들과 소통하는 비디오 레이아웃 페이지였다.

나는 다른 기능을 개발하는 것보다 화상회의 페이지를 사용자가 사용하기 편하게 개선하는게 가장 우선순위가 높은 일이라 생각했다. 그래서 어떻게 개선할 것인지에 대해 고민했고, 개선 목표를 세웠다.

개선 목표

  • 모든 참가자의 비디오를 동일한 크기로 표시한다.
  • 화면의 크기에 맞춰 레이아웃을 자동으로 조정되게 한다.
  • 최대한 화면의 공간을 활용하면서 적절하게 비디오가 배치되도록 한다.
  • 비디오를 동일하게 표시하되, 발언하는 사람을 표시할 수 있게 비디오에 효과를 준다.

위와 같은 목표를 세웠다.

처음 팀원이 말하는 사람의 비디오를 커지게 만들기로 했지만 이 방식에 대해 고민한 결과 여러 사용자가 말할 때 처리하는 것과 계속해서 번갈아가며 대화하게 되면 리플로우, 리페인트가 발생하기 때문에 성능에 좋지 않을거라 생각했다. 따라서 모든 사용자의 비디오 사이즈는 동일하게 설정하고, 말하는 사람의 비디오에 효과를 주어 누가 발언하는지에 대해 알 수 있게 구현하는게 더 나은 선택이라 판단했다.


레이아웃을 동적으로 조절하려면?

화면의 너비를 고려해서 비디오 너비를 정하기

처음에 가장 먼저 한 생각은 너비를 고려하는 것이었다. 화면에 비디오 2개를 넣는다 생각하면 현재 화면의 너비를 2로 나눈 값을 비디오의 너비로 설정해 가로로 2개의 비디오를 정렬하고, 화면의 너비가 특정 사이즈보다 작아진다면 세로로 정렬되게 구현하는 방법이었다.

그러나 이렇게 너비만 고려하게 되면 아래와 같은 문제가 발생했다.

  • 화면 높이가 줄어들면 비디오 하단이 잘리는 문제가 발생했다.
  • 비디오가 3개인 경우라면 잘리는게 아닌 한 비디오는 보이지 않는 문제도 생겼다.

너비만 고려하는게 아닌 높이를 고려해서 레이아웃을 설정할 방법을 찾아야했다.

화면의 높이도 고려해서 레이아웃 설정하기

너비만 고려하는게 아니라 높이만 고려해야겠다해서 높이만 생각하고 구현했었다. 그런데 높이만 고려했을 때는 비디오가 잘리는게 없이 잘 구현되었지만 화면 사이즈에 비해 비디오 사이즈가 작아보이는 경우도 있었다.

그래서 화면에 다 보이게 하면서도 적절한 비디오 사이즈로 조절하기 위해서는 너비와 높이 둘 다 고려해서 계산해주는게 필요하다 생각했다.

비디오가 4:3 비율이기 때문에 너비를 구할 때 현재 높이가 h라면, 해당 높이에 비디오 2개를 가로로 정렬하고 계산을 다음과 같이 진행했다.

  • 비디오는 화면에 가로로 2개가 정렬된다.
    • 첫번째로 가능한 너비는 화면 너비의 50%이다. (gap, margin을 고려하지 않고 가정한 너비)
    • 두번째로 가능한 너비는 h*(4/3)이다. 화면 높이가 h가 되었다면 비디오의 높이도 h를 넘어서는 안된다. 그렇기 때문에 비디오의 높이가 h라고 가정한다면 비율에 맞게 너비는 h*(4/3)으로 계산한다.
  • 위의 두 값 중 더 작은 값을 비디오 너비로 결정해서 너비, 높이에 관계 없이 비디오가 잘리지 않게 설정했다.

위와 같은 방식으로 비디오가 1개일 때부터 5개일 때 한 줄에 비디오가 몇 개가 올지 결정한 후 계산을 진행해주었다. (스터디 룸의 최대 참가자 수는 5명으로 제한되기 때문에 5개의 비디오까지 계산해주었다.)

비디오의 너비를 계산해주는 코드 작성하기

const getVideoLayoutClass = (count: number) => {
  switch (count) {
    case 1:
      return "w-[calc(min(100%,((100vh-140px)*(4/3))))]";
    case 2:
      return `w-[calc(min(100%,((100vh-146px)*(2/3))))]
              sm:w-[calc(min(calc(50%-0.375rem),((100vh-140px)*(4/3))))]
             `;
    case 3:
      return `w-[calc(min(100%,((100vh-152px)*(4/9))))]
              md:w-[calc(min(calc(50%-0.75rem),((100vh-146px)*(2/3))))]
              2xl:w-[calc(min(calc(33.3%-0.75rem),((100vh-140px)*(4/3))))] 
             `;
    case 4:
      return `w-[calc(min(100%,((100vh-158px)*(1/3))))]
              md:w-[calc(min(calc(50%-0.375rem),((100vh-146px)*(2/3))))]
             `;
    case 5:
      return `w-[calc(min(100%,((100vh-164px)*(4/15))))]
              xs:w-[calc(min(calc(50%-0.375rem),((100vh-152px)*(4/9))))]
              2xl:w-[calc(min(calc(33.3%-0.75rem),((100vh-146px)*(2/3))))]
             `;
  }
};
  • 비디오 개수인 count에 따라 너비를 반환해주도록 했다.
  • 화면의 너비에 따라 비디오를 최대 한 줄에 몇개씩 보여줄지도 고려했기 때문에 너비 사이즈에 따라 계산을 조금씩 다르게 했다.
  • 100vh-140px와 같은 계산의 경우는 현재 보이는 높이에서 상단 하단 바를 제외하고 갭도 넣어 비디오가 들어가는 실제 높이를 계산했다.

비디오 레이아웃을 동적으로 조정되게 구현한 결과

PR 링크
[Refactor] 비디오 레이아웃 동적으로 조정 | 세션 페이지 일부 디자인 변경

  • 일관된 비디오 크기로 사용자 입장에서도 혼란이 없어졌고, UI가 깔끔해졌다.
  • 화면 크기에 따라 자연스럽게 레이아웃 전환이 이루어지고, 화면 내에서 최대한 공간을 활용해서 비디오 사이즈를 조절하고 있다.
  • 비디오 비율이 4:3이 유지되어 모든 사용자의 비디오를 정상적인 비율로 볼 수 있고 특정 사용자의 영상이 잘리거나 안 보이는 문제도 없다.

말하는 사람의 비디오에 효과를 주자

위와 같이 모든 사람의 비디오를 동일한 사이즈로 설정하였다. 이렇게 해서 UI가 깔끔해졌지만, 아쉬운 점이 있었다. 바로 누가 말하고 있는지 표시가 안된다는 점이었다.

이를 해결하기 위해 줌에서 말하는 사람의 비디오에 테두리를 주는거처럼 말하는 사람의 비디오에 테두리 효과를 줘서 누군가 말하고 있다는 표시를 나타내는 방법을 택했다.

누가 말하는지 어떻게 알 수 있을까?

피어 간의 오디오 및 비디오 스트림을 설정하고 관리하는 데 사용되는 객체인 RTCPeerConnection의 getStats()에서 다양한 통계 정보를 제공해준다. 이를 통해 오디오 레벨 정보를 계속해서 받아와서 말하는 중인지 아닌지를 체크하기로 했다.

그래서 오디오를 감지하는 훅을 만들고 이 훅에서 0.1초마다 연결된 상대가 말하는 중인지 확인하도록 구현했다. 구현하고나서 비디오에 테두리가 생성되었고 이를 확인한 후 작업을 완료했다. 그리고 다시 한 번 더 테스트를 했는데 동작하지 않는 문제가 있었다.

console.log에 가려진 오류

오디오 정보를 받아오는게 잘못된건지 확인하기 위해 console.log로 관찰한 오디오 상태를 출력했다.

  • 말을 하는 중이면 true, 말을 하지 않으면 false로 출력이 문제 없이 이루어졌다.
  • 그래서 또 다시 오류가 없다 판단하고 console.log 코드를 지웠는데 기능이 제대로 동작하지 않는다는 것을 알았다.

console.log가 있으면 정상 동작하는데, 지우면 되지 않는 이유
console.log는 디버깅 도구 이상의 역할을 한다. 상태 객체를 로깅하면 리액트 배치 업데이트 전략이 수정되어 상태 업데이트가 즉각적으로 처리된다. 리액트에서는 상태 업데이트를 묶어서 처리하는데 console.log로 상태를 출력하게 되면, 자바스크립트 엔진에게 해당 시점 상태 값을 즉시 체크하도록 요청하게 된다.

📌 console.log가 상태 업데이트를 즉각적으로 처리하게 되어 비디오 테두리가 생겼는데, console.log를 제거하니 비디오 테두리가 생기지 않았다. 즉, 상태 업데이트에 문제가 있어 테두리가 생기지 않는다는 것을 알았다.


상태 업데이트가 제대로 되지 않았던 이유

  • VideoLayout 컴포넌트에서는 useAudioDetector를 호출해서 연결된 상대의 오디오를 계속해서 확인한다.
  • 말하는 상태를 받아와서 이 값을 VideoContainer 컴포넌트로 전달해서 말하는 중이라면 테두리 표시를 해주도록 했다.

상대방이 말을 해도 테두리가 표시되고 있지 않으므로 useAudioDetector에서 전달해주는 speakingStates에 문제가 있는 것이었다. 상대가 말하는지 받아오는 정보는 문제가 없는데 왜 문제가 생기는지 useAudioDetector 훅을 다시 살펴봤다.

export const useAudioDetector = ({
  peerConnections,
  audioThreshold = -35,
}: UseAudioDetectorProps) => {
  const [speakingStates, setSpeakingStates] = useState<AudioLevels>({});
  const intervalRefs = useRef<{ [key: string]: NodeJS.Timeout }>({});

  useEffect(() => {
    Object.entries(peerConnections.current).forEach(([peerId, connection]) => {
      if (!intervalRefs.current[peerId]) {
        intervalRefs.current[peerId] = setInterval(async () => {
          try {
	        // getStats로 오디오 레벨 정보 받아오기
            // 피어의 오디오 레벨 정보 저장
          } catch (error) {
            console.error(error);
          }
        }, TIMER_INTERVAL);
      }
    })

    return () => {
      Object.values(intervalRefs.current).forEach(interval => {
        clearInterval(interval);
      })
      intervalRefs.current = {};
    }
  }, [peerConnections, audioThreshold]);

  return {
    speakingStates
  }
};

  • VideoLayout 컴포넌트를 React DevTools에서 확인했다. peerConnections 부분을 확인했을 때 화상회의에 참여한 사람들이 잘 나타나고 있었다.
  • hooks 부분에서 useAudioDetector의 상태를 확인했는데 const [speakingStates, setSpeakingStates] = useState<AudioLevels>({}); 부분에서 speakingStates에 저장되는 값이 아무것도 없었다.

useEffect에서 peerConnections 값이 변경되면(누군가 화상회의에 들어와서 연결이 추가되면) speakingStates에 저장해서 말하는 상태를 확인해야한다. 그런데 이 부분이 동작하지 않고 있었다. 그렇다면 peerConnections이 변경되는 것을 감지하지 못하고 있다는 뜻이었다.

useEffect는 왜 peerConnections의 변경을 감지하지 못할까?

const peerConnections = useRef<{ [key: string]: RTCPeerConnection }>({});
  • peerConnections는 useRef로 저장한다.
    • useState가 아닌 useRef로 저장한 이유는 WebRTC 실시간 처리가 필요했다. useState는 상태 업데이트가 비동기적이고, 각 업데이트마다 리렌더링이 발생하는 점이 문제였다.
    • useRef는 리렌더링되어도 값을 유지하기 때문에 WebRTC 연결을 컴포넌트 리렌더링과 관계 없이 지속되어야하므로 useRef를 사용했다.

useRef는 컴포넌트 생명주기 동안 동일한 객체 참조를 유지한다. 즉, .current 속성만 변경되며 객체 자체는 변경되지 않는다. useRef가 반환하는 객체의 메모리 주소는 변경되지 않는다.

useEffect(() => {
  ...
}, [peerConnections]);
  • 리액트에서는 의존성 배열의 각 값을 이전과 비교할 때 얕은 비교를 수행한다.
  • peerConnections는 useRef로 선언되었고, 리액트에서는 객체의 경우 참조 비교를 수행한다. useRef가 반환한 객체의 참조는 항상 동일하기 때문에 .current의 값이 변경되어도 의존성 비교에서 변경이 없다고 판단한다.

useEffect 내부에 debugger;를 넣었는데 실행되지 않는 것을 발견했다. 즉, 의존성 배열에 peerConnections를 넣고 변경되면 실행되게 했는데 peerConnections의 변경을 알지 못했기 때문에 실행되지 않은 것이었다.

연결 상태 모니터링하는 방법을 사용하기

useRef로 저장된 peerConnections의 변경을 useEffect가 감지할 수 없었기 때문에 이를 감지할 수 있도록 모니터링 로직을 작성하기로 했다.

const [connectionCount, setConnectionCount] = useState(0);

useEffect(() => {
  const checkConnections = setInterval(() => {
    const currentCount = Object.keys(peerConnections.current).length;
    if (currentCount !== connectionCount) {
      setConnectionCount(currentCount);
    }
  }, CHECK_PEERCONNECTION);

  return () => clearInterval(checkConnections);
}, [peerConnections, connectionCount]);
  • 주기적으로 peerConnections.current의 연결 수를 확인한다.
  • 변경이 감지되면 state 업데이트를 통해 useEffect를 실행한다.
useEffect(() => {
  // 로컬 오디오 처리 로직

  if (connectionCount > 0) {
    // peer 연결이 있을 때만 오디오 레벨 모니터링 시작
  	Object.entries(peerConnections.current).forEach(([peerId, connection]) => {
    	// 오디오 레벨 모니터링 로직 
  	});
  };
}, [connectionCount, localStream, audioThreshold]);
  • connectionCount 상태 변화를 통해 peer 연결 변경을 감지하게 했다.
  • 연결 수가 변경될 때마다 오디오 모니터링 로직을 재설정하도록 했다.

말하고 있는 사람에게 테두리 효과를 준 결과

PR링크
[Feat] 말하는 사람의 비디오에 테두리 추가

실시간으로 peer 연결 변경 시 오디오 레벨 모니터링이 시작되었고, 참가자들의 말하는 상태 표시도 잘 동작되었다.

useRef가 리렌더링 시에도 값을 유지해서 peerConnections를 useRef로 관리했는데 useEffect에서 변경을 감지하지 못한다.

  • .current 값이 변경되어도 객체 참조는 유지된다는 사실을 알게 되었다.
  • useEffect에서도 의존성 배열이 얕은 비교를 수행한다는 사실을 알게 되었다. 이러한 특성을 고려해서 상태 관리를 설계하는게 중요하다는 것을 알았다.

useRef, useEffect에 대해 알고 쓰고 있다고 생각했는데, 제대로 알고 쓰는게 중요하다는 것을 깨달았다.

console.log로 실제 문제를 파악하기 어려운 console.log의 즉각 상태 업데이트 처리에 대해서 배울 수 있었다.

  • 문제가 생기면 무조건 console.log만 사용했는데, DevTools와 같은 도구도 사용하거나 브레이크 포인트를 걸어서 실제로 코드를 실행해보는 방법을 사용해서 문제를 찾는 방법이 있다는 것도 알게 되었다.
profile
안녕하세오

4개의 댓글

comment-user-thumbnail
2025년 1월 13일

👍 최고 에오 🐽

1개의 답글
comment-user-thumbnail
2025년 1월 13일

정말 유익해요

1개의 답글

관련 채용 정보