Case Study · 11

재입장하면 먹통, 가끔 무음 — 실기기 미디어 결함의 근본 원인 추적

"나갔다 들어오면 마이크·카메라 토글이 안 먹는다", "소리가 들릴 때도 있고 안 들릴 때도 있다" 같은 실기기 결함을 증상 단위로 막지 않고 근본 원인까지 내려가 끊었습니다. 재입장 고착은 이전 세션 로컬 트랙이 계속 쌓이던 것이 원인이었고, 간헐 무음은 오디오 라우팅이 한 번도 시작되지 않던 구조 결함 등 네 가지가 겹친 것이었습니다. Fold6 재입장 데드락은 react-native-webrtc 네이티브 executor까지 내려가 패치로 해결했습니다.

Product
MeetMate (Mobile)
Role
Frontend Engineer
Period
2026.07.28 — 2026.09.04
Stack
React Native 0.77 react-native-webrtc lib-jitsi-meet react-native-incall-manager
Implementation AI-paired (Claude agent)
  • 재입장 후 마이크·카메라 토글 **먹통 → 정상 전파** — 퇴장할 때 로컬 트랙을 해제하는 코드가 정의만 되어 있고 실제로는 한 번도 호출되지 않아, 이전 세션 트랙이 쌓여 조용히 성공하던 것이 원인이었습니다. 앞서 관찰자 쪽에서 시도한 수정 5건이 모두 효과가 없었던 이유도 여기서 밝혀졌습니다.
  • 간헐 무음 **구조 원인 4겹 제거** — 오디오 라우팅 시작 호출이 앱 전체에 0건, 네이티브에서만 스테레오를 강제, 마이크 획득 실패를 조용히 삼킴, 트랙이 죽었을 때 복구 장치 없음. 라우팅 통로를 하나로 만들고 `react-native-incall-manager`로 BT·유선 이어폰까지 연결했습니다(계약 테스트 43건).
  • Fold6 재입장 시 마이크·카메라 **gum.timeout → 정상 획득** — 블로킹되는 카메라 dispose가 getUserMedia와 같은 단일 스레드 executor를 막고 있었습니다. JS로는 풀 수 없는 문제라 patch-package로 네이티브 dispose를 별도 스레드로 옮겼습니다.
  • 알림센터·재난문자로 생기던 **유령 퇴장 → 0** — iOS inactive 상태와 잠깐의 background 전환에서는 퇴장을 보내지 않고, 7.5분 grace 타이머를 넘겼을 때만 퇴장하도록 바꿨습니다. 백그라운드에서는 카메라만 멈추고 오디오는 유지하며, PiP와 Android 포그라운드 서비스를 추가했습니다.
  • 단위 테스트 **175 → 1,685건** — 수정할 때마다 원인을 재현하는 회귀 테스트를 함께 남겼습니다.

배경

모바일 앱을 실제 수업에 투입하자 시뮬레이터에서는 보이지 않던 결함이 한꺼번에 보고됐습니다. “강사가 나갔다 들어오면 그 뒤로 마이크 상태가 안 바뀐다”, “재난문자 확인을 눌렀더니 회의실에서 나가졌다”, “Fold6에서 재입장하면 장치 화면이 무한 로딩된다”, “에어팟도 버즈도 소리가 들렸다 안 들렸다 한다” 같은 보고였습니다.

공통점은 재현이 간헐적이고, 증상이 나타나는 곳과 원인이 있는 곳이 다르다는 점이었습니다. 처음에는 관찰자 쪽 표시 로직을 다섯 번 고쳤지만 전혀 나아지지 않았습니다.

접근

방향을 바꿔 증상이 아니라 송신 쪽을 의심했습니다. 웹 레퍼런스 구현과 비교하고 git 이력을 이등분해 추적한 끝에, 퇴장 경로가 로컬 트랙을 한 번도 해제하지 않았다는 사실을 찾았습니다. 재입장할 때마다 트랙 배열에 새 트랙이 쌓였고, 토글은 배열의 첫 번째, 즉 이전 세션 트랙을 골랐습니다. 이미 회의에서 분리된 트랙이라 mute 호출이 예외 없이 “조용히 성공”했고 신호는 아무 데도 가지 않았습니다.

같은 방식으로 재입장 경로 전체를 다시 점검했습니다. 절전에서 돌아와 다시 입장할 때 죽은 소켓의 연결 객체를 재사용하던 문제, 끝나지 않는 퇴장 요청 때문에 카메라 반납이 뒤로 밀리던 문제, 장치 열거와 로컬 트랙 생성에 타임아웃이 없어 무한 스피너가 되던 문제를 각각 끊었습니다. 카메라는 퇴장 요청보다 먼저 반납하고, 대기 지점마다 상한을 걸었습니다.

Fold6만 남았습니다. 실기기 진단 결과 카메라와 마이크가 모두 gum.timeout이었습니다. 원인은 react-native-webrtc Android가 getUserMedia와 트랙 dispose를 같은 단일 스레드 executor에서 처리한다는 데 있었습니다. 전면 카메라가 여러 개인 폴더블은 Camera2 세션을 닫는 데 오래 걸리고, 그동안 재입장 요청이 뒤에 줄을 섰습니다.

JS에서 재시도해 봐야 막힌 스레드에 요청을 더 쌓을 뿐입니다. 막힌 곳이 네이티브라면 고치는 곳도 네이티브여야 했습니다.

블로킹되는 dispose를 별도 스레드로 옮기는 패치를 patch-package로 남겨 설치할 때 자동으로 적용되게 했습니다. 같은 시기에 웹 사용자의 영상이 멈췄다 움직이기를 반복하던 Fold6 stutter는 모바일 하드웨어 VP9 디코더의 약점 때문이었습니다. VP8을 우선하도록 코덱 순서를 바꿔 해결했습니다.

간헐 무음은 세 갈래(경로 진단, 라우팅, 웹과 전수 대조)로 나눠 조사했고, 최근 회귀가 아니라 오래된 구조 결함이 네 겹 겹쳐 있다는 결론을 냈습니다. 오디오 라우팅이 아예 시작되지 않아 OEM마다 수화기나 스피커로 제각각 출력됐고, 네이티브에서만 스테레오와 고비트레이트를 강제하고 있었습니다. 마이크 획득 실패는 조용히 무시됐고, 수신 전화 같은 인터럽션으로 트랙이 죽어도 감지하는 리스너가 없었습니다. 라우팅은 통로를 하나로 모았고, 세션 중에 명시적으로 선택한 출력만 다시 적용하게 했습니다. 지난주에 고른 ‘스피커’가 오늘 연결한 에어팟을 빼앗지 않도록 하기 위해서입니다. iOS에서는 라우팅 시작이 오히려 에어팟과 충돌해 네이티브 오디오 세션 설정으로 대신했습니다. 트랙이 죽으면 1.2초 뒤 재획득하고, 재획득은 세션당 3회로 제한했습니다.

생명주기도 손봤습니다. 알림센터·제어센터·재난문자처럼 잠깐 앱을 벗어나는 상황에서는 퇴장을 보내지 않고, 7.5분 grace를 넘겼을 때만 퇴장합니다. 백그라운드에서는 카메라만 멈추고, PiP와 Android 포그라운드 서비스로 회의를 이어 갑니다.

결과

  • 재입장 후 마이크·카메라 토글 고착 해소 — 본인 화면과 상대 화면 모두 정상 전파.
  • Fold6 재입장 gum.timeout 해소(네이티브 executor 패치), 웹 사용자 영상 stutter 해소(VP8 우선).
  • 간헐 무음 구조 원인 4겹 제거, BT·유선 이어폰 라우팅 연결, 마이크 획득 실패를 사용자에게 표시(계약 테스트 43건).
  • 브리프 인터럽트로 인한 유령 퇴장 제거, 백그라운드 카메라 일시정지, PiP, 포그라운드 서비스.
  • 단위 테스트 175 → 1,685건.

배운 점

증상이 나타나는 곳에서 고치면 안 되는 결함이 있다는 것을 배웠습니다. 재입장 고착은 관찰자 화면에서 드러났지만 원인은 송신 쪽이었고, 관찰자 쪽 수정 다섯 번이 모두 헛수고였습니다. “조용히 성공하는 호출”은 에러 로그를 남기지 않기 때문에 데이터가 실제로 어디로 흐르는지를 끝까지 따라가는 것 말고는 찾을 방법이 없었습니다.

또 하나, 간헐적인 결함은 대개 원인이 하나가 아니었습니다. 무음 문제를 원인 하나로 설명하려 했다면 네 겹 가운데 하나만 고치고 “가끔 재발”하는 상태로 남았을 것입니다. 갈래를 나눠 따로 조사한 뒤 합쳐 본 것이 문제를 한 번에 닫을 수 있었던 이유였습니다.