배경
v4 재구축과 폴리시 라운드를 마친 뒤 실제 수업·회의에 투입되자, 보고되는 버그의 결이 달라졌습니다. 렌더나 성능이 아니라 “내 화면은 맞는데 남의 화면이 틀리다” 는 종류였습니다. 호스트가 채팅 권한을 바꿔도 참가자에겐 반영이 안 되고, 카메라·마이크를 끄고 들어온 참가자가 다른 사람 그리드에서 사라지고, 화이트보드 undo 한 번에 보드 전체가 지워졌습니다.
가장 심각했던 건 모바일이었습니다. 제어용 WebSocket은 끊기면 그냥 끝이라, 화면을 잠갔다 켜거나 Wi-Fi에서 LTE로 넘어가는 순간 영상은 Jitsi가 복구해도 채팅·멤버 목록·호스트 제어는 영구히 멈췄습니다. 이 버그들은 대부분 race나 서버 환경 변종에 의존해서 재현이 들쭉날쭉했습니다.
접근
먼저 제어 채널에 재연결 상태머신을 붙였습니다. 예기치 않은 close면 지수 백오프로 재접속 → 재인증 → 재조인 → 미디어 세션 id 재통보까지 자동으로 이어지고, 명시적 퇴장은 재연결하지 않습니다. 25초 heartbeat로 half-open 소켓을 감지해 강제로 끊고 다시 붙이며, online / visibilitychange 이벤트를 백스톱 트리거로 걸었습니다. 요청 타임아웃은 5s → 15s로 모바일에 관대하게 바꾸되 자동 재시도는 넣지 않았습니다 — mute·kick 같은 명령이 두 번 적용되면 안 되기 때문입니다.
재연결 경로 자체의 버그도 같이 잡았습니다. 재조인 직전의 정리 단계가 앱 수명 동안 살아 있어야 할 알림 구독까지 지워서, 첫 재연결 이후 모든 재연결·실패 알림이 영구 무음이 되던 문제였습니다. Jitsi 쪽 fence는 “60초 내 5회 시도면 영구 실패”였는데, 성공 시 리셋하면 데스크톱 idle-timeout 재조인 폭주가 다시 열립니다. 그래서 fence는 그대로 누적하되 진짜 네트워크 복귀(online)에서만 리셋하도록 해, 데스크톱 방어를 유지하면서 모바일만 자동 복구되게 했습니다.
재연결은 “다시 붙는다”보다 “무엇을 다시 하지 않는가”가 더 중요했습니다. 자동 재시도 없는 타임아웃, 성공이 아닌 online 기준 fence 리셋 둘 다 이중 적용과 폭주를 막기 위한 선택이었습니다.
참석자 상태는 v3 wire 프로토콜과 하나씩 대조하며 정리했습니다. 유령 타일을 막으려고 넣었던 reconcile이 오히려 Jitsi 매핑이 느린 모바일·패드 참석자를 유령으로 오판해 영구 삭제하고 있었고, 한번 퇴장 처리되면 되살릴 경로가 없었습니다. 퇴장 판정은 “id가 있는데 endpoint가 사라진 경우”로 좁히고, 매핑·트랙 도착 시 복구 경로를 새로 열었으며, 초기 roster 재시도 창을 3단계에서 6단계(최대 10초)로 넓혔습니다. 카메라·마이크를 모두 끈 참가자는 트랙을 발행하지 않는 게 정상이라 판정 대상에서 뺐습니다.
같은 방식으로 나머지를 정리했습니다. 과거형 이벤트 정규화가 ‘e’로 끝나는 동사(Disabled/Enabled)를 과하게 잘라 dispatch가 누락되던 문제, 서버가 id 변종을 보낼 때 닉네임·PIN이 조용히 no-op 되던 문제, 마이크 off 입장자가 ON으로 보이던 비대칭(마이크는 플래그·카메라는 트랙 기반), 전체 카메라 OFF→ON에서 실제 트랙을 다시 켜지 않던 문제, 늦게 들어온 학생이 출석 프롬프트를 못 받던 문제까지. 매핑되지 않은 broadcast는 클래스당 1회 진단 로그를 남기도록 해 “이벤트가 안 온다” 류 증상을 현장에서 바로 확인할 수 있게 했습니다.
화이트보드는 undo가 의도적으로 로컬만 바꾸고 있었고, 전제였던 “서버가 stroke 단위 삭제 미지원”은 틀렸습니다. undo/redo 전후 스냅샷 diff를 단일 stroke 삭제·재추가로 broadcast하고, 수신부는 서버가 확실히 보존하는 필드만 기준으로 단일 삭제를 판정해 어떤 경우에도 전체 clear로 떨어지지 않게 했습니다. 본인 요청의 ACK가 같은 핸들러로 돌아와 내 보드만 지워지던 마지막 경로까지 막았습니다.
모바일 리소스는 백그라운드 절전이 화자 전환마다 다시 수신을 켜며 무효화되던 문제를 막고, 저사양 기기(pointer: coarse + 좁은 폭)에선 페이지네이션은 그대로 둔 채 수신 레벨에서만 타일 수 ≤8 · 해상도 ≤360p 상한을 걸었습니다. iOS는 AudioContext resume, 언마운트 시 비디오 srcObject 해제로 디코더 누수를 정리했습니다.
결과
- 제어 채널 재연결 없음 → 지수 백오프 + 25s heartbeat + online/visibility 백스톱. 모바일 순단 후 채팅·멤버·호스트 제어 자동 복원.
- “남의 화면에서만 틀린” 동기화 회귀 22건 정정 (+5,433 / -292 lines), 대부분 회귀 테스트 동반 — 유령 타일 방어만 27 케이스.
- 화이트보드 undo 보드 전체 삭제 → 마지막 stroke만 제거, 다른 참가자·본인 화면 모두 일치.
- 저사양 모바일 동시 수신 타일 25 → 8, 해상도 상한 360p. 백그라운드 절전 유지.
- 초기 roster 재시도 창 3 → 6단계(최대 10초) — 느린 모바일 참석자 누락 해소.
- 단위 테스트 2,323 → 2,516 (2026.06.30 → 07.13).
배운 점
“내 화면은 맞다”는 실시간 협업에서 아무 의미가 없었습니다. optimistic 갱신 덕에 버그를 만든 쪽은 정상으로 보이고, 피해는 항상 다른 참가자 화면에서 일어납니다. 그래서 수정마다 송신 측·수신 측·본인 에코 세 갈래를 따로 테스트로 박는 습관이 생겼습니다.
또 하나: 방어 로직이 회귀의 원인이 되는 경우가 많았습니다. 유령 타일 reconcile이 느린 참석자를 지우고, 재연결 fence가 정상 모바일을 가두고, 정리 단계가 구독을 지웠습니다. 방어를 넣을 땐 “이게 오판하면 되돌릴 경로가 있는가” 를 같이 설계해야 한다는 걸 배웠습니다.