기록

WebRTC 팀 모드의 참가자 상태 소유권

TL;DR

  • WebRTC 화면을 하나 더 붙일 때 핵심 문제는 컴포넌트 재사용이 아니라 상태 소유권이었다.
  • 이벤트 핸들러의 목적지는 이벤트 시점이 아니라 conference.on()에 등록되는 시점에 굳는다.
  • LocalMedia는 장치 단위가 아니라 연결 단위로 소유해야 한다.
  • 큰 화면 타일은 자동 선택과 사용자 선택이 같은 상태를 쓰면 서로 덮어쓴다.

기존 화상룸에는 전체 참가자를 보여주는 화면이 있었다. 여기에 같은 방 참가자 중 일부만 따로 들어가는 팀 모드 화면을 추가했다. 기술 조건은 이랬다.

항목 조건
SDK @connectlive/connectlive-web-sdk 0.0.28
라우팅 Next 11 Pages Router
전체 방 roomIdConference 생성
팀 방 roomId-groupId로 별도 Conference 생성
참가자 상태 SDK 이벤트를 Redux 배열에 반영

기존 화면의 이벤트 처리, 로컬 미디어 생성, 타일 컴포넌트를 재사용하는 방향으로 시작했다. 실제 문제는 세 상태의 소유권이 서로 다르다는 점이었다.

이벤트 목적지

기존 이벤트 훅은 참가자 입장, 퇴장, remote video publish/unpublish 이벤트를 받아 Redux 배열에 넣었다. 팀 모드에서도 같은 이벤트를 받되, 목적지 배열만 달라지면 됐다. 그래서 하나의 훅 안에서 모드 플래그로 분기했다.

export function useConferenceEvents() {
  const dispatch = useDispatch();
  const isSubRoom = useSelector((state) => state.room.isSubRoom);

const participantEntered = () => async (evt) => { if (isSubRoom) { dispatch(addSubRoomParticipant(evt.remoteParticipant)); } else { dispatch(addParticipant(evt.remoteParticipant)); } }; const bindEvents = (conference: Conference) => { conference.on("participantEntered", participantEntered()); };

return { bindEvents };
}

이 구조의 문제는 isSubRoom을 읽는 시점이다. Conference는 EventEmitter 기반 객체다. conference.on()은 전달받은 함수를 리스너 목록에 저장하고, 이후 이벤트가 오면 그 함수를 호출한다. 리스너 함수가 클로저로 잡은 isSubRoom 값은 등록 당시 렌더의 값이다.

render
  -> isSubRoom 읽기
  -> participantEntered 함수 생성
  -> conference.on()에 등록
  -> 이후 이벤트 도착
  -> 등록 당시 isSubRoom으로 dispatch

팀 모드 URL을 바로 열거나 새로고침하면 플래그 초기값은 false다. 이 상태에서 팀 방 Conference에 등록된 핸들러는 이벤트가 와도 전체 방 배열에 dispatch한다. 팀 모드 화면이 읽는 배열은 비어 있다. 이벤트 목적지는 런타임 플래그가 아니라 연결 객체의 정체성에서 나온다. 팀 방 Conference에 등록한 핸들러는 항상 팀 방 배열에 써야 하고, 전체 방 Conference에 등록한 핸들러는 항상 전체 방 배열에 써야 한다. 그래서 훅을 나눴다.

function useMainConferenceEvents() {
  return {
    participantEntered: (evt) => dispatch(addParticipant(evt.remoteParticipant)),
  };
}

function useSubRoomConferenceEvents() {
  return {
    participantEntered: (evt) => dispatch(addSubRoomParticipant(evt.remoteParticipant)),
  };
}

중복은 생긴다. 대신 등록 시점에 이미 정해진 값을 런타임 플래그로 다시 묻지 않는다.

연결 수명

페이지가 바뀐다고 WebRTC 연결이 자동으로 끊기지는 않는다. React 컴포넌트는 언마운트되어도 SDK 객체는 명시적으로 disconnect하기 전까지 살아 있을 수 있다. 따라서 방에 연결하는 effect에는 cleanup이 필요하다.

useEffect(() => {
  const conference = createConference(roomId);
  bindEvents(conference);
  conference.connect();

return () => {
    conference.disconnect();
  };
}, [roomId]);

화면 전환은 연결 수명과 별개다. 이 구분이 없으면 첫 화면의 Conference가 팀 모드 화면으로 이동한 뒤에도 이벤트를 계속 dispatch할 수 있다.

LocalMedia 소유권

팀 모드 화면도 카메라와 마이크를 publish해야 한다. 문제는 만든 LocalMedia와 정리 코드가 다른 상태를 보고 있었다는 점이다.

const enterSubRoom = async () => {
  const media = await ConnectLive.createLocalMedia({
    video: true,
    audio: true,
  });

conference.publish([media]); };

const cleanup = () => {
  conference?.disconnect();
  localMedia?.stop();
  localMedia?.video?.detach();
};

media는 팀 모드 진입 함수 안에서 새로 만든 값이다. 반면 cleanup은 전체 방에서 쓰던 localMedia 상태를 읽는다. 장치는 하나일 수 있다. 하지만 WebRTC 연결은 두 개다. 전체 방과 팀 방은 각각 자기 Conference를 만들고, 각각 자기 LocalMedia를 publish한다. 그러면 정리도 연결 단위로 해야 한다.

const media = await ConnectLive.createLocalMedia({ video: true, audio: true });
dispatch(setSubRoomLocalMedia(media));

const cleanup = () => {
  conference?.disconnect();
  subRoomLocalMedia?.stop();
  subRoomLocalMedia?.video?.detach();
};

정리 코드는 자기가 만든 리소스를 정리해야 한다. 만드는 쪽이 지역 변수에만 들고 있으면 언마운트 시점에 접근할 수 없다. 만드는 순간 정리할 위치도 함께 정해야 한다.

Focus Tile 소유권

팀 모드 화면에는 한 참가자를 크게 보여주는 focus tile이 있었다. 기본값은 기준 참가자이고, 사용자가 아래 타일을 누르면 focus가 바뀐다. 초기 구현은 boolean 하나로 내 영상 표시 여부를 들고 있었다.

const [showsMyVideo, setShowsMyVideo] = useState(false);

const onClickMyTile = () => setShowsMyVideo((prev) => !prev);

이 값에는 두 의미가 섞였다.

의미 쓰는 곳
사용자가 내 타일을 눌렀다 click handler
큰 화면에 내 영상이 떠 있다 render/effect

다른 사람 타일을 눌러 focus가 바뀌어도 showsMyVideo는 여전히 true일 수 있다. 이후 local media 참조가 바뀌면 effect가 다시 내 영상을 큰 화면에 붙인다. 먼저 값의 의미를 “현재 큰 화면이 내 영상인가”로 좁혔다.

const onClickMyTile = () => setShowsMyVideo(true);

const focusRemote = (video: RemoteVideo) => {
  setShowsMyVideo(false);
  setFocusedVideo(video);
};

하지만 이것만으로는 자동 선택과 사용자 선택의 충돌이 남는다. 참가자 배열이 바뀌면 자동 선택 effect가 다시 돌고, 사용자가 고른 focus를 덮을 수 있다. 사용자 개입 여부는 별도 상태여야 한다.

const [pinnedByUser, setPinnedByUser] = useState(false);

useEffect(() => { if (pinnedByUser) return; setFocusedVideo(findDefaultVideo(participants)); }, [pinnedByUser, participants]);

const focusByUser = (video: Video) => {
  setPinnedByUser(true);
  setFocusedVideo(video);
};

큰 화면의 소유권은 상황에 따라 달라진다.

초기 진입
  -> 자동 선택이 focus를 소유

사용자 클릭 이후
  -> 사용자 선택이 focus를 소유

자동 선택 effect는 사용자가 개입하기 전까지만 실행되어야 한다.

세 상태의 공통점

세 문제는 모두 같은 구조였다.

상태 잘못 둔 기준 실제 소유권
참가자 배열 모드 플래그 이벤트를 등록한 Conference
LocalMedia 장치가 하나라는 사실 media를 만든 연결
Focus Tile 현재 표시 여부 boolean 자동 선택 또는 사용자 선택

상태 소유권이 정해지는 시점도 다르다.

상태 소유권이 정해지는 시점
이벤트 목적지 리스너 등록 시점
LocalMedia 생성 시점
Focus Tile 사용자 개입 시점

이 차이를 무시하면 하나의 flag나 하나의 store 칸을 여러 화면이 공유하게 된다. 공유 자체가 문제는 아니지만, 쓰는 주체가 둘 이상이면 반드시 우선순위와 수명을 정해야 한다.

정리

WebRTC 팀 모드에서 어려웠던 부분은 실시간 통신 자체보다 상태 소유권이었다. 컴포넌트를 재사용할 수 있어도 상태를 재사용할 수 있는 것은 아니다. 전체 방과 팀 방은 같은 SDK 타입을 쓰지만 서로 다른 연결이고, 서로 다른 참가자 배열과 LocalMedia를 가져야 한다. 실시간 화면을 하나 더 만들 때는 구현보다 먼저 세 질문을 적는 편이 안전하다.

  1. 이 값은 누가 만드는가.
  2. 이 값은 누가 정리하는가.
  3. 이 값은 자동 로직과 사용자 입력 중 누가 덮어쓸 수 있는가.

답이 다르면 상태도 나뉘어야 한다. 한 칸에 두 의미를 넣으면 나중에 실행된 코드가 이긴다.

관련 글