들어가며

1편에서는 1-hop 방식의 구조적 한계를 발견하고, BFS 기반 연결 컴포넌트 모델로 바운더리 그룹을 재정의한 과정을 담았다. 1편을 먼저 읽으면 contactId가 어떻게 정의되고 어떤 구조에서 계산되는지 맥락이 더 잘 잡힌다.

서버가 contactId를 안정적으로 계산할 수 있게 된 뒤, 클라이언트 쪽에서 두 가지 문제가 드러났다.

경계선 근처에서 아바타를 조금만 움직여도 화상 연결이 끊겼다 재연결

아바타가 움직일 때마다 화면 전체가 re-render

두 문제의 원인은 달랐지만 뿌리는 같았다. 서로 다른 관심사가 하나의 단위에 묶여 있었다.

연결 단위와 가시성 단위는 다르다.


문제: contactId가 바뀔 때마다 LiveKit이 재연결되다

LiveKit의 연결 단위

Moyo는 WebRTC SFU로 LiveKit을 사용한다. SFU(Selective Forwarding Unit)는 각 참여자의 미디어 스트림을 중계 서버가 받아 필요한 대상에게만 전달하는 구조다. P2P 방식과 달리 참여자가 늘어도 개인 업로드 부담이 크게 늘지 않아 다자 화상 회의에 적합하다. 오픈소스이면서 서버 직접 배포가 가능하고 React SDK가 잘 정비되어 있어 선택했다.

LiveKit의 연결 단위는 룸(room) 이다. 클라이언트는 roomId와 서버에서 발급받은 LiveKit 접속 토큰(JWT)으로 특정 룸에 입장하고, 같은 룸에 있는 참여자들끼리 스트림이 교환된다. roomId가 바뀌면 기존 WebRTC 연결을 끊고, 새 roomId로 토큰을 재발급받아 새 룸에 입장하는 전체 과정이 처음부터 다시 실행된다.

roomId 변경은 값 하나를 바꾸는 게 아니라 연결 전체를 재시작하는 작업이다.

LiveKit 접속 토큰은 JWT다

LiveKit은 자체 인증 시스템이 없다. 클라이언트가 게임 서버에 토큰을 요청하면, 게임 서버가 LiveKit API 시크릿으로 서명한 JWT를 발급한다. 이 토큰 안에 roomId, 허용 권한, 만료 시간이 담겨 있어 LiveKit은 별도 인증 단계 없이 입장 가능 여부를 검증할 수 있다.

이 글에서 "토큰"은 모두 이 LiveKit 접속 토큰(JWT)을 가리킨다.

원래 설계: 로비에서 contactId를 roomId로 사용

Moyo에는 로비 외에도 개인 데스크, 소희의실 등 여러 공간이 있었다. 각 공간마다 다른 LiveKit 룸에 연결해야 했기 때문에, getEffectiveRoomId라는 함수로 상황에 맞는 roomId를 결정했다.

// use-livekit.ts — roomId 결정 로직
export const getEffectiveRoomId = (
  roomId: string, // 현재 공간의 기본 roomId
  contactId: string | null | undefined, // 바운더리 그룹 식별자
  breakoutRoomId?: string | null, // 세미나실의 내부 소회의실 ID
): string => {
  // 1순위: 소회의실에 배정된(입장 중인) 경우 해당 룸으로
  if (breakoutRoomId) {
    return breakoutRoomId;
  }
 
  // 2순위: 로비 또는 개인 데스크존에서 contactId가 있으면 → contactId를 roomId로 사용
  // 의도: 같은 바운더리 그룹끼리 같은 LiveKit 룸에 입장시키기
  if ((roomId === "lobby" || roomId === "desk zone") && contactId) {
    return contactId;
  }
 
  // 그 외: 공간 자체의 roomId 그대로 사용
  return roomId;
};

이 함수를 useLivekit 훅에서 호출해 setConfig에 전달했다. contactIduseEffect 의존성 배열에 포함되어 있었기 때문에, 값이 바뀔 때마다 setConfig가 재호출되는 구조였다.

"같은 그룹끼리 같은 방에 넣자"는 의도는 맞았다. 그러나 contactId가 얼마나 자주 바뀌는지, 그리고 그 변화가 LiveKit에 어떤 비용을 발생시키는지는 고려하지 않은 설계였다.


원인: 연결과 가시성이 같은 값에 묶여 있었다

contactId는 언제 바뀌는가?

서버는 아바타 위치가 변경될 때마다 BFS를 실행해 모든 사용자의 contactId를 재계산한다. 아바타가 바운더리 경계 근처에 있으면 한 칸 이동만으로도 연결 컴포넌트 구성이 바뀌고 contactId가 달라진다. 경계 근처에서는 아바타를 움직일 때마다 이 과정이 반복됐다.

이 구조에서 contactId는 두 가지 역할을 동시에 맡고 있었다.

  1. 가시성 단위: 어떤 사람들과 화상을 보여줄지 결정하는 논리적 식별자
  2. 연결 단위: LiveKit 룸에 접속하기 위한 물리적 키

두 역할의 변화 빈도는 달랐다. 가시성은 아바타 이동마다 바뀔 수 있지만, 물리적 연결은 그렇게 자주 바뀌면 안 된다. "같은 서버에서 화상을 주고받는다"는 사실은 변하지 않는데, 경계를 넘을 때마다 연결을 끊고 다시 맺고 있었다.

연결을 끊지 않고 가시성만 바꿀 수 있어야 했다.


해결 방향 검토

두 가지 방향이 논의됐다.

선택지 1: 클라이언트 UI 필터링

roomId를 로비 단위로 고정하고, 클라이언트가 contactId를 기준으로 화상에 보여줄 참여자를 필터링한다.

  • 장점: LiveKit 연결이 끊기지 않는다. 현재 공간과 바운더리 그룹에 속한 참여자만 화면에 표시해 불필요한 컴포넌트 렌더링이 없다. 구현이 단순하다.
  • 단점: 같은 룸에 있는 모든 참여자의 미디어 스트림이 클라이언트에 전달된다. 화면에 표시하지 않아도 스트림은 구독하게 된다. Moyo 로비 규모(수십 명)에서는 허용 가능한 비용으로 판단했다.

선택지 2: LiveKit Participant Metadata — 왜 선택하지 않았나

공식 문서에 따르면 LiveKit의 participant metadata를 이용해 참여자 속성을 동기화할 수 있다. contactId를 메타데이터에 저장하고 클라이언트가 participantMetadataChanged 이벤트를 구독하면, 같은 contactId를 가진 참여자만 표시할 수 있다. 처음 이 방향을 고려했지만 채택하지 않았다.

이유 1: contactId는 저주파 상태가 아니다

공식 문서는 metadata를 "저주파 상태 동기화에 적합하다"고 명시하며, 고주파 업데이트가 필요한 경우에는 data packet을 권장한다.

contactId는 저주파가 아니다. 아바타가 경계 근처에서 이동할 때마다 BFS가 실행되고 즉시 변경될 수 있다. metadata는 "몇 초에 한 번" 수준의 상태 동기화를 전제로 설계되어 있는데, contactId는 그 범위를 벗어난다.

이유 2: contactId는 상태(state)가 아니라 파생 결과(derived result)다

contactId는 현재 사용자들의 위치 관계에서 계산되는 값이다. 이미 게임 소켓을 통해 BFS 결과가 클라이언트에 전달되고 있었다. 이것을 LiveKit 메타데이터로 다시 복제하면 두 개의 상태 소스가 생기고, 동기화 타이밍 문제가 발생한다.

이유 3: metadata 변경의 부작용

LiveKit에서 메타데이터가 변경되면 participantMetadataChanged 이벤트가 발생하고, 이는 participant update 흐름을 타게 된다. speaking detection, track lifecycle 같은 내부 처리에도 영향이 생긴다. contactId가 이동마다 바뀐다는 것을 감안하면, 의도하지 않은 이벤트가 지속적으로 발생하는 상황을 만드는 셈이다.

활용 사례metadata 적합 여부
role (host / guest)✅ 적합 — 세션 내내 유지
team (A / B)✅ 적합 — 고정 또는 드물게 변경
displayName, avatarType✅ 적합 — 설정 시 한 번
contactId (바운더리 그룹)❌ 부적합 — 이동마다 변경되는 파생 결과

contactId를 metadata로 관리하는 것은 LiveKit metadata의 설계 의도와 맞지 않는 오용에 가깝다.

결정

"누가 누구를 볼 수 있느냐"는 LiveKit의 책임이 아니다. LiveKit은 미디어 전송, 트랙 관리, speaking detection을 담당하고, 가시성 판단은 도메인 로직으로 처리해야 한다. contactId는 이미 게임 소켓을 통해 클라이언트에 있었다. 굳이 LiveKit에 실어 보낼 이유가 없었다.

선택지 1(UI 필터링) 방향으로 결정했다. 핵심은 setConfig에서 contactId를 제거하고 roomId를 고정하되, 가시성 계산을 별도 훅으로 분리하는 것이었다.


구현: getEffectiveRoomId에서 contactId 분기 제거와 useVisibleUsers

getEffectiveRoomId에서 contactId 분기를 제거

수정은 getEffectiveRoomId 한 곳에 집중됐다. 로비와 desk zone에서 contactIdroomId로 반환하던 분기를 제거했다.

// 수정 후 — use-livekit.ts
// contactId 파라미터 자체를 제거 — roomId 결정에 더 이상 관여하지 않음
export const getEffectiveRoomId = (
  roomId: string,
  breakoutRoomId?: string | null,
): string => {
  // 브레이크아웃 룸에 배정된 경우 해당 룸 유지
  if (breakoutRoomId) {
    return breakoutRoomId;
  }
 
  // 로비/desk zone도 공간 자체의 roomId를 그대로 사용
  // 이전: contactId가 있으면 contactId를 반환했음 → 재연결 원인
  return roomId;
};

useLivekit 훅에서도 contactIdgetEffectiveRoomId에 넘기던 부분을 제거했다.

// use-livekit.ts — setConfig 호출부 수정
useEffect(() => {
  if (!roomId || !userId || !nickname) return;
 
  setConfig({
    // contactId 인자가 빠진 getEffectiveRoomId → 로비에서 "lobby" roomId 그대로 유지
    roomId: getEffectiveRoomId(roomId),
    userId,
    nickname,
  });
}, [roomId, userId, nickname]);
// contactId는 의존성 배열에서도 제거
// → contactId가 바뀌어도 setConfig가 재호출되지 않음
// → LiveKit 재연결 없음

roomId"lobby"이면 getEffectiveRoomId"lobby"를 그대로 반환한다. 로비에 있는 모든 사람이 같은 "lobby" 룸에 연결되고, contactId가 바뀌어도 roomId는 바뀌지 않는다.

이 변경 하나로 아바타 이동에 의한 LiveKit 재연결은 완전히 사라졌다.

이제 로비의 모든 사람이 같은 룸에 연결된다. 화상 화면에 누구를 보여줄지 결정하는 역할이 별도로 필요해졌다.

useVisibleUsers: 가시성 계산을 훅으로 분리

contactId는 게임 소켓을 통해 여전히 Zustand store에 들어온다. useVisibleUsers는 store의 contactIdusers를 보고 현재 보여줄 참여자 Set을 계산한다.

// use-visible-users.ts
 
// contactId가 같은 사람들의 id만 추출해 Set으로 반환
const computeVisibleUsers = (
  contactId: string | null,
  users: User[],
): Set<string> | null => {
  // 아직 바운더리 그룹에 속하지 않은 경우 (혼자 있거나 초기화 전)
  if (!contactId) return null;
 
  return new Set(
    users
      .filter((u) => u.contactId === contactId) // 같은 그룹만 추출
      .map((u) => u.id),
  );
};
 
export function useVisibleUsers(): Set<string> | null {
  const [visibleUsers, setVisibleUsers] = useState<Set<string> | null>(null);
 
  useEffect(() => {
    // store를 직접 subscribe해서 contactId 또는 users가 바뀔 때마다 재계산
    // useSelector와 달리 컴포넌트 외부에서도 store 변화를 감지할 수 있다
    const unsubscribe = useUserStore.subscribe((state) => {
      const next = computeVisibleUsers(state.contactId, state.users);
      setVisibleUsers(next);
    });
 
    return unsubscribe; // 컴포넌트 언마운트 시 구독 해제
  }, []);
 
  return visibleUsers;
}

화상 컴포넌트는 이 훅이 반환하는 Set을 보고 각 참여자를 표시할지 결정한다.

// 화상 컴포넌트 (사용 예)
const visibleUsers = useVisibleUsers();
 
// LiveKit에서 받은 participants 중 visibleUsers에 포함된 사람만 렌더링
const visibleParticipants = participants.filter(
  (p) => visibleUsers?.has(p.identity) ?? false,
);

연결 단위(roomId)와 가시성 단위(contactId)가 분리됐다. 아바타가 경계선을 넘어도 LiveKit 연결은 그대로고, 화상에 보이는 참여자 목록만 바뀐다.

subscribe와 observer 패턴

Zustand의 subscribe는 observer 패턴의 구현이다. store(subject)가 상태 변경을 감지하면 등록된 콜백(observer)을 호출한다. React의 useSelector가 렌더 사이클에 묶여 있는 반면, subscribe는 렌더 사이클 밖에서도 동작해 이벤트 핸들러나 외부 로직에서 store 변화를 직접 구독할 수 있다.

subscribeWithSelector로 구독 범위 좁히기

초기 구현은 useUserStore.subscribe(state => ...) 형태로 전체 store를 구독했다. store의 어느 필드가 바뀌어도 콜백이 실행되는 구조였다. useVisibleUsers가 실제로 필요한 건 contactIdusers 두 필드뿐이었지만, 무관한 상태 변경에도 computeVisibleUsers가 재실행됐다.

subscribeWithSelector 미들웨어를 적용하면 selector가 반환하는 값이 실제로 달라졌을 때만 listener가 호출된다.

// 개선 후 — 필요한 필드가 변경됐을 때만 재계산
const unsubscribe = useUserStore.subscribe(
  (state) => ({ contactId: state.contactId, users: state.users }), // selector
  ({ contactId, users }) => {
    setVisibleUsers(computeVisibleUsers(contactId, users));
  },
  { equalityFn: shallow }, // 얕은 비교 — 참조가 달라도 값이 같으면 호출 안 됨
);

contactIdusers가 실제로 변경됐을 때만 computeVisibleUsers가 실행된다.


남은 문제: 아바타 이동 시 불필요한 re-render

연결 깜빡임은 해결됐지만, 아바타 이동마다 전체 컴포넌트가 re-render되는 문제가 남아 있었다.

원인은 위치 상태 업데이트 방식에 있었다.

// 기존 — PLAYER_MOVED마다 users 배열 전체를 새 참조로 교체
socket.on("PLAYER_MOVED", ({ userId, x, y }) => {
  setUsers((prev) =>
    // 좌표가 변한 한 명만 갱신하지만
    // map()은 항상 새 배열을 반환 → users 전체가 새 참조가 됨
    prev.map((u) => (u.id === userId ? { ...u, x, y } : u)),
  );
});

PLAYER_MOVED는 로비에 있는 모든 사람의 이동마다 발생한다. 50명이 있으면 초당 수십 번 이 이벤트가 발생하고, 매번 users 배열 전체가 새 참조로 교체된다. 이름, contactId, 연결 여부처럼 좌표와 무관한 정보를 사용하는 컴포넌트까지 re-render됐다.

React 린트 경고도 이 방향에서 나타났다. useEffect 안에서 setState를 호출하는 패턴은 종종 잘못된 상태 모델의 신호다. 이벤트 기반 setState가 단방향 데이터 흐름 밖에서 작동하면 의도치 않은 re-render 사이클이 생기기 쉽다.

position 상태를 sync에서 subscribe로

왜 users 배열에 좌표를 담으면 안 됐는가

users 배열에는 성격이 다른 두 종류의 정보가 섞여 있었다.

정보변화 빈도변경 원인
이름, contactId, 연결 여부낮음바운더리 그룹 변경, 입퇴장
x, y 좌표매우 높음아바타 이동마다

두 정보가 같은 배열에 담겨 있었기 때문에, 아바타 좌표가 바뀔 때마다 이름·contactId를 사용하는 컴포넌트까지 re-render됐다.

positionStore 분리

// position-store.ts
interface PositionStore {
  positions: Map<string, { x: number; y: number }>; // userId → 좌표 맵
  updatePosition: (userId: string, pos: { x: number; y: number }) => void;
}
 
const usePositionStore = create<PositionStore>((set) => ({
  positions: new Map(),
  updatePosition: (userId, pos) =>
    set((state) => {
      // 기존 Map을 복사해 해당 userId 좌표만 교체
      // users 배열은 전혀 건드리지 않음
      const next = new Map(state.positions);
      next.set(userId, pos);
      return { positions: next };
    }),
}));
// 수정 후 소켓 핸들러 — 해당 userId의 좌표만 갱신
socket.on("PLAYER_MOVED", ({ userId, x, y }) => {
  // store에 직접 접근해 좌표만 갱신
  // users 배열은 변경되지 않으므로 users를 구독하는 컴포넌트는 re-render 없음
  usePositionStore.getState().updatePosition(userId, { x, y });
});

PLAYER_MOVED가 발생해도 users 배열은 바뀌지 않는다. 아바타 위치가 필요한 컴포넌트는 usePositionStore를 구독하고, 나머지는 useUserStore를 구독한다. 각 컴포넌트는 자신이 실제로 필요한 변화에만 반응한다.

다른 접근 방식들

positionStore 외에도 위치 동기화 방법을 여러 가지 검토했다.

Direct DOM Mutation (Ref 직접 조작)

React 상태를 거치지 않고 ref.current.style.transform을 직접 갱신하는 방식이다.

socket.on("PLAYER_MOVED", ({ id, x, y }) => {
  const el = avatarRefs.current.get(id);
  if (el) el.style.transform = `translate(${x}px, ${y}px)`;
});

re-render가 아예 없고 requestAnimationFrame과 결합하면 60fps를 보장할 수 있다. 다만 React 렌더 트리와 상태 불일치가 생길 수 있고, 다른 컴포넌트가 position을 참조할 때 별도 동기 로직이 필요하다. position이 렌더링에만 사용되고 다른 로직에서 참조되지 않는 경우에 적합하다.

External Store (zustand atom 단위 구독)

position을 userId 단위 atom으로 분리하면, 변경된 유저의 아바타 하나만 re-render된다.

// userId별로 atom을 분리하면 해당 유저 컴포넌트만 re-render
const pos = usePositionStore((s) => s.positions.get(peerId));

selector 설계를 잘못하면 결국 전체 re-render가 발생할 수 있어 주의가 필요하다. position을 다른 로직(충돌 감지, 근접 판단 등)에서도 참조해야 할 때 적합하다.

WebRTC DataChannel P2P 직접 전송

서버를 거치지 않고 peer 간 직접 position을 전송하는 방식이다. 서버 홉이 제거되어 지연이 가장 낮지만, N명이면 N-1개 채널을 관리해야 하고 NAT/방화벽 환경에서 연결 실패 가능성이 있다. 참가자 수가 소규모(~10명 이하)이고 이미 WebRTC 연결이 있을 때 적합하다.

전략 비교

기준setState 전체 교체positionStore (현재)Direct DOMExternal StoreP2P DataChannel
re-render 범위전체위치 구독자만없음해당 유저만없음~해당 유저
구현 복잡도낮음중간낮음중간높음
다른 로직에서 참조용이용이어려움용이별도 구현 필요
지연(latency)보통보통최저보통최저

Moyo에서는 position이 근접 판단에도 사용되어 React 상태로 관리할 필요가 있었고, 구현 복잡도를 감안해 positionStore 방식을 선택했다.

subscribe 방식은 성능 개선이 아니라 상태 모델 개선이다

이 변경의 본질은 성능 최적화가 아니다.

기존 구조에서 users 배열은 "좌표가 변했다"는 사실을 표현하기 위해 "모든 사용자 정보가 바뀌었다"고 store에 알리고 있었다. store를 구독하는 쪽은 실제로 달라진 게 무엇인지 알 수 없어서 전부 다시 계산했다.

positionStore를 분리하고 updatePosition으로만 갱신하면, 좌표 변경은 좌표 구독자에게만 전달된다. 상태의 변화 범위가 코드에 명시적으로 드러난다.

useEffect에서 setState하는 패턴을 제거하고 store에서 직접 incremental 업데이트를 처리하게 바꾼 것은 단방향 데이터 흐름 원칙을 되찾는 작업이었다.


마치며

이 작업에서 두 가지를 배웠다.

첫째, 연결(connection)과 가시성(visibility)은 별개의 관심사다. getEffectiveRoomId가 로비에서 contactId를 roomId로 반환하던 것은 두 관심사를 하나의 값으로 처리하려 했기 때문이다. LiveKit은 미디어 전송과 트랙 관리를 담당하고, "누가 누구를 볼 수 있느냐"는 도메인 로직이다. getEffectiveRoomId에서 contactId 분기를 제거하고 가시성 계산을 useVisibleUsers로 분리하자 깜빡임이 사라졌고, 각 책임이 명확해졌다.

둘째, 상태 모델이 틀리면 성능 문제처럼 보인다. PLAYER_MOVED마다 전체 배열을 교체한 것은 성능 실수가 아니라 상태 설계의 문제였다. 변화 빈도가 다른 데이터를 같은 store에 넣으면, 그 store를 구독하는 모든 곳이 불필요하게 반응한다. store를 분리하고 incremental 업데이트를 적용하자 re-render가 줄었다.

본문에 포함된 코드는 이해를 돕기 위해 일부 발췌 및 단순화한 예시이며 실제 구현과 일부 차이가 있을 수 있다.

경계선에서 발생하는 추가적인 재연결 문제는 3편에서 이어서 다룬다.

참고