들어가며

2편에서 contactId가 바뀔 때마다 LiveKit이 재연결되는 문제를 해결했다. roomId를 고정하고 가시성 계산을 useVisibleUsers로 분리했다.

그러나 테스트 중 두 가지 증상이 남아 있었다.

바운더리 안에서 이동하는데도 연결이 순간적으로 끊겼다가 다시 연결

연결은 유지되는데 바운더리 가시선이 이동 중에 잠깐 사라짐

두 증상은 원인이 달랐다. 하나는 서버 tick 타이밍 문제, 하나는 클라이언트 렌더 조건 문제였다.


파생 이슈 1: 경계를 스치면 연결이 끊기다

원인: tick 시점의 순간적인 경계 이탈

서버는 일정 주기(tick)마다 각 아바타의 위치를 기준으로 BFS를 실행해 contactId를 재계산한다. 아바타가 경계 근처에서 이동할 때, 실제로 그룹을 이탈할 의도가 없어도 tick 시점의 좌표가 경계를 순간적으로 벗어날 수 있다.

아바타 이동 경로: 경계 안 → 경계 스침(1틱) → 경계 안 복귀

기존 구현은 이탈을 감지하는 즉시 leaveGroup(유저의 contactId를 null로 설정)을 호출하고 pruneInactiveGroups(비활성 그룹의 contactId 매핑 만료)로 그룹 매핑을 만료시켰다. 1틱의 경계 이탈만으로도 contactId가 null로 떨어졌다가 다음 틱에 새 contactId가 배정됐다.

해결: 연속 3틱 이탈일 때만 확정 처리

일시적인 경계 이탈을 즉시 처리하지 않고, INACTIVE_TICKS_TO_EXPIRE 기준으로 연속 3틱 이탈 상태가 유지됐을 때만 확정 처리하도록 수정했다.

// boundaryTracker.service.ts
const INACTIVE_TICKS_TO_EXPIRE = 3; // tick당 100ms → 약 0.3초
 
handleBoundaryCheck(userId: string, isInGroup: boolean) {
  if (isInGroup) {
    // 그룹 안에 있으면 카운트 초기화
    this.inactiveCounts.delete(userId);
    return;
  }
 
  const count = (this.inactiveCounts.get(userId) ?? 0) + 1;
 
  if (count >= INACTIVE_TICKS_TO_EXPIRE) {
    // 연속 3틱 이탈 → 확정 처리
    this.leaveGroup(userId);
    this.pruneInactiveGroups();
    this.inactiveCounts.delete(userId);
  } else {
    // 아직 확정 안 됨 — 카운트만 증가
    this.inactiveCounts.set(userId, count);
  }
}

장점: 경계에서의 일시적 튐으로 contactId가 즉시 null로 떨어지는 현상이 사라졌다. 경계 근처에서 체감하는 "끊겼다 연결됐다"가 크게 줄었다.

단점: 실제로 그룹을 이탈한 뒤에도 최대 약 0.3초(100ms × 3)는 이전 contactId가 유지된다. 의도된 지연이다.


파생 이슈 2: 연결은 유지되는데 가시선이 깜빡이다

원인: 렌더 조건에 avatar.state가 포함됐다

바운더리 가시선은 boundary.renderer.ts에서 그린다. 초기 구현에서 선 계산 조건에 avatar.state === "idle"이 포함되어 있었다.

// 수정 전 — boundary.renderer.ts
// 이동 중인 아바타는 선 계산에서 제외됐다
if (user.contactId && avatar.state === "idle") {
  drawBoundaryLine(user);
}

아바타가 이동하면 state"walk"로 바뀐다. contactId가 유지되고 있어도 이 조건에 걸려 선이 사라졌다. 연결 자체는 끊기지 않았지만, 이동할 때마다 선이 깜빡이는 것처럼 보였다.

"연결이 끊겼다"는 체감을 주는 데 가시선의 영향이 컸다. 실제 연결 상태(contactId)와 화면에 표현되는 상태(선 렌더링)가 일치하지 않았던 것이다.

해결: state 조건 제거

로비에서 contactId가 있는 유저는 아바타 상태와 무관하게 선 계산에 포함하도록 변경했다.

// 수정 후 — boundary.renderer.ts
// contactId 유무만으로 판단 — avatar.state는 선 표시 여부에 무관
if (user.contactId) {
  drawBoundaryLine(user);
}

장점: "연결은 유지되는데 선만 사라지는" 시각적 불일치가 사라졌다.

단점: 선 표시가 더 자주 그려져 렌더 비용이 약간 증가한다.


마치며

2편에서 연결 아키텍처를 바로잡았지만, 그 위에서 새로운 증상이 드러났다. 두 파생 이슈는 모두 "실제 상태"와 "감지 또는 표현 타이밍" 사이의 불일치에서 비롯됐다.

  • tick 이탈 감지: 실제 의도(이탈 여부)와 tick 시점 판정 결과 사이의 간격 → 연속 3틱 확인 방식으로 완화
  • 렌더 조건: 실제 연결 상태(contactId)와 렌더 조건(avatar.state) 사이의 불일치 → 조건 단순화로 해결

두 해결책 모두 "상태를 더 정확하게 반영하는 것"이 아니라 "불필요한 조건을 제거하거나 반응 타이밍을 늦추는 것"이 핵심이었다.


세 편을 돌아보면, 각 해결책이 다음 문제를 드러내는 구조였다.

1편에서 1-hop 판정을 BFS로 바꿔 그룹 경계를 올바르게 정의했다. 그러자 2편의 문제가 보였다. contactId가 바뀔 때마다 LiveKit이 재연결되고 있었던 것이다. 연결과 가시성을 같은 단위로 묶어서 생긴 구조 문제였다. roomId를 고정하고 가시성 계산을 분리하자, 이번엔 3편의 문제가 드러났다. 경계 근처에서의 tick 타이밍과 렌더 조건이라는 더 미세한 불일치였다.

세 문제는 각각 달랐지만 공통된 패턴이 있었다.

개념이 섞이면 증상이 섞인다. 연결과 가시성이 같은 변수에 묶여 있을 때, 한쪽을 고쳐도 다른 쪽이 깨졌다. 그룹 이탈과 렌더링 조건이 avatar.state 하나에 의존할 때, 한쪽이 맞으면 다른 쪽이 틀렸다. 문제를 빠르게 고치는 것보다 개념을 먼저 분리하는 편이 결국 더 빨랐다.

정확도보다 안정감이 중요한 구간이 있다. 연속 3틱 확인 방식은 이탈 감지를 의도적으로 늦춘다. 실시간 서비스에서 "빠른 정확성"이 오히려 사용자 체감을 해칠 수 있다는 것을 배웠다. 시스템이 옳은 것과 사용자가 느끼기에 안정적인 것은 다를 수 있다는 것을 배운 작업이었다.

참고