매주 데모데이에서 한 주간 개발한 것에 대해 발표하는 시간을 가졌다. 우리 팀은 완성된 결과에 대해 시연 영상을 찍어서 넣고 있었는데, 데모 전날 화상회의 페이지를 개발하는 팀원이 시연 영상을 찍을 때 화상회의 페이지 사이즈를 잘 조절해야한다고 했었다. 그래서 화상회의 페이지를 살펴보았는데 아래와 같은 문제가 있었다.
🚨 발견한 문제는 다음과 같았다.
- 사용자마다 보이는 비디오 레이아웃이 다르다.
- 한 화면에 참가자 모두의 비디오가 나오지 않는다.
- 영상을 찍을 당시에는 아니지만, 그 때 당시에는 스크롤을 막아둬서 다른 사람의 비디오는 다 잘렸었다.
- 비디오 비율이 4:3이 아닌 경우도 있다.
- 비디오 사이즈가 모두 다른 문제가 있다.
해당 페이지 개발 팀원은 말하는 사용자가 비디오가 커지게 만들고 싶다고 했었다. 추후 이렇게 수정할 생각이었고, 위 상황에서는 말하는 사용자가 아닌 자신의 비디오를 일단 크게 만들어둔 상황이었다.
면접 스터디를 위한 서비스이기 때문에 가장 중요한 건 스터디룸 기능이었다. 그리고 가장 중요한 페이지는 화상회의 페이지, 즉 참가자들과 소통하는 비디오 레이아웃 페이지였다.
나는 다른 기능을 개발하는 것보다 화상회의 페이지를 사용자가 사용하기 편하게 개선하는게 가장 우선순위가 높은 일이라 생각했다. 그래서 어떻게 개선할 것인지에 대해 고민했고, 개선 목표를 세웠다.
개선 목표
- 모든 참가자의 비디오를 동일한 크기로 표시한다.
- 화면의 크기에 맞춰 레이아웃을 자동으로 조정되게 한다.
- 최대한 화면의 공간을 활용하면서 적절하게 비디오가 배치되도록 한다.
- 비디오를 동일하게 표시하되, 발언하는 사람을 표시할 수 있게 비디오에 효과를 준다.
위와 같은 목표를 세웠다.
처음 팀원이 말하는 사람의 비디오를 커지게 만들기로 했지만 이 방식에 대해 고민한 결과 여러 사용자가 말할 때 처리하는 것과 계속해서 번갈아가며 대화하게 되면 리플로우, 리페인트가 발생하기 때문에 성능에 좋지 않을거라 생각했다. 따라서 모든 사용자의 비디오 사이즈는 동일하게 설정하고, 말하는 사람의 비디오에 효과를 주어 누가 발언하는지에 대해 알 수 있게 구현하는게 더 나은 선택이라 판단했다.
처음에 가장 먼저 한 생각은 너비를 고려하는 것이었다. 화면에 비디오 2개를 넣는다 생각하면 현재 화면의 너비를 2로 나눈 값을 비디오의 너비로 설정해 가로로 2개의 비디오를 정렬하고, 화면의 너비가 특정 사이즈보다 작아진다면 세로로 정렬되게 구현하는 방법이었다.
그러나 이렇게 너비만 고려하게 되면 아래와 같은 문제가 발생했다.
너비만 고려하는게 아닌 높이를 고려해서 레이아웃을 설정할 방법을 찾아야했다.
너비만 고려하는게 아니라 높이만 고려해야겠다해서 높이만 생각하고 구현했었다. 그런데 높이만 고려했을 때는 비디오가 잘리는게 없이 잘 구현되었지만 화면 사이즈에 비해 비디오 사이즈가 작아보이는 경우도 있었다.
그래서 화면에 다 보이게 하면서도 적절한 비디오 사이즈로 조절하기 위해서는 너비와 높이 둘 다 고려해서 계산해주는게 필요하다 생각했다.
비디오가 4:3 비율이기 때문에 너비를 구할 때 현재 높이가 h라면, 해당 높이에 비디오 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))))]
`;
}
};
100vh-140px
와 같은 계산의 경우는 현재 보이는 높이에서 상단 하단 바를 제외하고 갭도 넣어 비디오가 들어가는 실제 높이를 계산했다.위와 같이 모든 사람의 비디오를 동일한 사이즈로 설정하였다. 이렇게 해서 UI가 깔끔해졌지만, 아쉬운 점이 있었다. 바로 누가 말하고 있는지 표시가 안된다는 점이었다.
이를 해결하기 위해 줌에서 말하는 사람의 비디오에 테두리를 주는거처럼 말하는 사람의 비디오에 테두리 효과를 줘서 누군가 말하고 있다는 표시를 나타내는 방법을 택했다.
피어 간의 오디오 및 비디오 스트림을 설정하고 관리하는 데 사용되는 객체인 RTCPeerConnection
의 getStats()에서 다양한 통계 정보를 제공해준다. 이를 통해 오디오 레벨 정보를 계속해서 받아와서 말하는 중인지 아닌지를 체크하기로 했다.
그래서 오디오를 감지하는 훅을 만들고 이 훅에서 0.1초마다 연결된 상대가 말하는 중인지 확인하도록 구현했다. 구현하고나서 비디오에 테두리가 생성되었고 이를 확인한 후 작업을 완료했다. 그리고 다시 한 번 더 테스트를 했는데 동작하지 않는 문제가 있었다.
오디오 정보를 받아오는게 잘못된건지 확인하기 위해 console.log로 관찰한 오디오 상태를 출력했다.
console.log가 있으면 정상 동작하는데, 지우면 되지 않는 이유
console.log
는 디버깅 도구 이상의 역할을 한다. 상태 객체를 로깅하면 리액트 배치 업데이트 전략이 수정되어 상태 업데이트가 즉각적으로 처리된다. 리액트에서는 상태 업데이트를 묶어서 처리하는데 console.log로 상태를 출력하게 되면, 자바스크립트 엔진에게 해당 시점 상태 값을 즉시 체크하도록 요청하게 된다.
📌 console.log가 상태 업데이트를 즉각적으로 처리하게 되어 비디오 테두리가 생겼는데, console.log를 제거하니 비디오 테두리가 생기지 않았다. 즉, 상태 업데이트에 문제가 있어 테두리가 생기지 않는다는 것을 알았다.
상대방이 말을 해도 테두리가 표시되고 있지 않으므로 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
}
};
const [speakingStates, setSpeakingStates] = useState<AudioLevels>({});
부분에서 speakingStates에 저장되는 값이 아무것도 없었다.useEffect에서 peerConnections 값이 변경되면(누군가 화상회의에 들어와서 연결이 추가되면) speakingStates에 저장해서 말하는 상태를 확인해야한다. 그런데 이 부분이 동작하지 않고 있었다. 그렇다면 peerConnections이 변경되는 것을 감지하지 못하고 있다는 뜻이었다.
const peerConnections = useRef<{ [key: string]: RTCPeerConnection }>({});
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
의 연결 수를 확인한다.useEffect(() => {
// 로컬 오디오 처리 로직
if (connectionCount > 0) {
// peer 연결이 있을 때만 오디오 레벨 모니터링 시작
Object.entries(peerConnections.current).forEach(([peerId, connection]) => {
// 오디오 레벨 모니터링 로직
});
};
}, [connectionCount, localStream, audioThreshold]);
실시간으로 peer 연결 변경 시 오디오 레벨 모니터링이 시작되었고, 참가자들의 말하는 상태 표시도 잘 동작되었다.
useRef
가 리렌더링 시에도 값을 유지해서 peerConnections를 useRef로 관리했는데 useEffect에서 변경을 감지하지 못한다.
.current
값이 변경되어도 객체 참조는 유지된다는 사실을 알게 되었다.- useEffect에서도 의존성 배열이
얕은 비교
를 수행한다는 사실을 알게 되었다. 이러한 특성을 고려해서 상태 관리를 설계하는게 중요하다는 것을 알았다.useRef, useEffect에 대해 알고 쓰고 있다고 생각했는데, 제대로 알고 쓰는게 중요하다는 것을 깨달았다.
console.log
로 실제 문제를 파악하기 어려운 console.log의즉각 상태 업데이트 처리
에 대해서 배울 수 있었다.
- 문제가 생기면 무조건 console.log만 사용했는데, DevTools와 같은 도구도 사용하거나 브레이크 포인트를 걸어서 실제로 코드를 실행해보는 방법을 사용해서 문제를 찾는 방법이 있다는 것도 알게 되었다.
👍 최고 에오 🐽