아이폰에서만 영상이 끊기고 리더와 싱크가 계속 어긋날 때
무용 연습용 영상 동기화 웹앱(formation)을 만들고 있었다. 연습실에서 한 사람(리더)이 노트북으로 음악과 안무 영상을 틀면, 나머지 멤버들은 각자 폰으로 같은 영상과 대형(무용수 위치) 오버레이를 맞춰 보는 프로그램이다. 다른 기기는 괜찮은데 아이폰 12 Pro에서만 영상이 끊기고, 리더와 맞았다 싶으면 다시 느려지며 어긋나기를 반복했다.
결론
여러 기기의 영상을 동기화하다 아이폰에서만 끊긴다면, 화질을 더 낮추기 전에 영상 보정 전후의 오차를 보자. 이 사례에서는 미리받기(Blob)도 끊겼지만 동선만 표시하면 매끄러웠다. 이후 리더와의 오차가 0.45~0.5초에 머무는 것을 확인하고, seek 목표를 현재 리더 시각보다 앞당기는 보정을 적용하자 개발자가 "됐다!"고 답했다.
적용한 핵심은 exp 대신 exp + seekComp로 seek하고, 착지 후 남은 오차로 seekComp를 갱신하는 방식이었다. 기록상 초기 보정값은 0.45초, 재동기화 임계값은 0.30초, seek 쿨다운은 1초였다. 이 값은 해당 실험의 설정이며, 모든 아이폰에 적용할 정답은 아니다.
단, 관측된 0.45~0.5초는 동기화 오차이지 별도로 측정한 seek 소요시간이 아니다. 이를 seek 지연으로 해석한 것은 당시 AI의 가설이다. 보정 후 성공 응답은 있지만 실제 개선된 오차 수치는 보고되지 않았다.
상황
리더 노트북에서 도는 로컬 서버가 중계 역할을 한다. 리더 화면은 200ms마다 재생 위치·상태를 서버로 보내고, 서버는 이를 SSE로 모든 뷰어 폰에 뿌린다. 뷰어는 이 값으로 "지금 리더가 몇 초를 재생 중인지"(exp)를 추정한다.
뷰어의 음소거 <video>는 이 추정 시각에 맞춰 보정된다. 자기 currentTime을 exp와 비교해 차이가 나면 재생 속도(playbackRate)를 조금 바꾸거나 해당 시각으로 점프(seek)한다. 처음에는 이 보정이 화면 그리기 루프 안에서 초당 약 60회 돌았고, 도중에 220ms 간격으로 줄였다. 대형 오버레이는 매 프레임 그려지며, 영상이 켜져 있으면 video.currentTime을 기준으로 그린다.
영상 공급 방식은 조사 중에 여러 번 바뀌었다. 원본 mp4를 통째로 받아 메모리(Blob)에서 재생하는 방식, 스트리밍, 그리고 화질을 기기별로 자동 조절하는 HLS까지 붙었다. 나중에는 참여 화면에서 "스트리밍"과 "미리 받기(Blob)"를 고르고, 영상 없이 동선만 보는 버튼도 생겼다.
증상
개발자는 아이폰 12 Pro에서 360p 스트리밍도 끊긴다고 했다. 더 구체적인 묘사는 "맞았다 싶으면 다시 끊겨서 느려지고"의 반복이었다. 미리 받아 재생해도 같았지만 영상 없이 동선만 표시하면 매끄러웠다.
원인을 좁힌 과정
두 번의 초기 진단은 해결로 이어지지 않았다
첫 진단은 렌더 루프였다. AI는 잦은 배속 변경, 레이아웃 크기 읽기, 반복되는 preservesPitch 대입을 지목했다. 이를 정리하고 배속 변경에 문턱을 넣었다고 보고했지만, 개발자의 답은 "해결되지 못했네"였다. 최적화가 무의미했다는 증거는 아니지만, 이 변경으로 원인을 해결했다는 판단은 틀렸다.
두 번째는 큰 Blob의 메모리 부담이었다. AI는 109MB 영상과 청크 결합 과정의 메모리 사용을 근거로 "범인 확정에 가까워"라고 말하고 모바일을 스트리밍으로 바꿨다. 그러나 스트리밍 전환 뒤에도 개발자는 재생이 아쉽다고 했고, 나중에는 360p에서도 끊김을 보고했다. 메모리 프로파일 없이 내린 확신이 해결보다 먼저 나왔다.
대조군이 지운 것은 '확신'이었다
중간에 만든 두 기능이 비교 조건이 됐다. 동선만 모드는 영상 소스를 분리하고 영상 보정을 건너뛰며 리더 추정 시각으로 동선을 그렸다. 미리받기 모드는 mp4를 받아 Blob으로 재생했다. 따라서 두 모드 모두 한 변수만 바꾼 엄밀한 실험은 아니었다.
| 비교 조건 | 원본에서 확인한 결과 | 여기까지 말할 수 있다 |
|---|---|---|
| 스트리밍 | 360p에서도 끊김 보고 | 고화질 부담만으로 설명하기 부족하다. |
| 미리받기(Blob) | 여전히 같은 문제 | 재생 중 영상 다운로드 지연만을 원인으로 삼기 어렵다. 동기화 통신까지 없앤 실험은 아니다. |
| 동선만 표시 | 매끄러움 | 영상이 빠진 경로는 정상이다. 영상 재생과 보정 경로를 우선 조사할 근거가 된다. |
여기서 "대역폭·메모리·디코딩을 모두 기각했다"고 쓰면 한 발 더 나간다. 영상 없는 모드로 디코더의 정상 여부를 검증할 수는 없다. 또한 AI가 직전에 14MB짜리 360p 파일을 전제로 미리받기를 권했지만, 미리받기는 활성 영상의 원본 mp4를 받는 구조였고 개발자는 실험 때 어떤 파일이었는지 확인해 주지 않았다. 14MB에서도 재현됐으니 메모리는 무관하다는 결론 역시 이 기록으로 확정할 수 없다.
실제로 반박된 것은 AI의 "미리받기라면 끊김이 사라진다"는 예측이었다. 두 대조군은 모든 하위 원인을 지우는 대신, 조사 우선순위를 영상 동기화 보정으로 옮겼다.
보정을 약하게 해도, 강하게 해도 실패했다
배속 보정을 빼고 오차가 0.75초를 넘을 때만 seek하게 하자 개발자는 점프가 없어졌지만 보정이 안 된다고 했다. 다음에는 보정 시작과 종료에 서로 다른 기준을 두는 히스테리시스와 고정 ±6% 배속을 넣었고, AI의 시뮬레이션은 수렴했지만 실기기 응답은 "매우 끊겨"였다.
다시 배속 보정을 제거하고 seek 임계값을 0.35초로 내리자 계속 점프했다. 개발자는 이때 "차이가 벌어지는지 좁혀지는지"를 보여 달라고 했다. 임계값을 더 조정하기 전에 오차의 움직임을 보자는 전환이었다.
해결
오차 추세를 계측하면서 보정 조건도 함께 바꿨다. 오차가 0.35초를 넘고 계속 벌어질 때만 seek하며, !video.seeking으로 진행 중인 seek의 중복 요청을 막았다고 보고했다. ?debug=1 화면에는 리더와의 오차 Δ, 오차 추세, 배속, seek 임계값을 표시했다. 개발자가 읽어 준 값은 0.45~0.5초 사이의 반복이었다.
AI는 이를 "현재 리더 시각으로 seek해도, 완료될 때 리더가 이미 앞으로 가 있다"는 구조로 해석했다. 이 가정에서는 목표 시각을 그대로 따라가는 seek이 잔차를 남기고, 그 잔차보다 낮은 임계값은 재차 seek을 유발할 수 있다. 다만 직전 변경에는 추세 조건과 seek 진행 중 가드도 있었으므로, 이 설명만으로 당시 반복 seek의 정확한 발생 조건까지 확인된 것은 아니다. 약 0.47초 역시 관측 오차에서 추정한 값이다.
임계값을 1초로 올려 0.5초 뒤처짐을 그대로 두는 방안과, 동선을 영상 대신 리더 시각에 맞춰 그리는 방안도 나왔지만 개발자는 거절했다. 댄스 영상에서는 음악과 동작이 맞는 것이 중요하고, 0.5초는 큰 차이라는 요구였다.
최종 구현 보고는 다음과 같다. 아래는 세션에 남은 설명을 정리한 것이며 코드 원문을 복원한 것은 아니다.
hardSeek의 목표를exp + seekComp로 앞당긴다.seekComp를 0.45초로 시작하고 착지 후 잔차로 갱신한다.- 구현 보고의 표현대로 “착지측정 0.8초 후”를 두고, 쿨다운 1초로 연속 보정을 제한한다. 재동기화 임계값은 0.30초로 둔다.
이 변경 뒤 개발자는 "됐다!"고 답했고, 이후에는 동선만 보는 버튼을 없애도 되겠다고 요청했다. 이것이 기록에 남은 성공 확인이다. AI가 제시한 0.5초→0.05초는 시뮬레이션 결과이며 실기기 측정 결과가 아니다.
이 사례에서 가져갈 순서는 비교 모드로 조사 범위를 좁히기 → 오차 추세를 표시하기 → 보정 후 잔차에 맞춰 목표 시각을 바꾸기다. 대조군은 범인을 즉시 확정해 주지 않았다. 대신 틀린 예측을 드러냈고, 다음에 무엇을 측정할지 정해 줬다.
취재 후기 — 손잡이보다 계기판
*아래 대사는 세션의 관찰과 진단을 바탕으로 재구성한 것이다.*
playbackRate)를 조금씩 바꾸고 화면 크기까지 읽고 있거든. 잦은 속도 변경이 끊김을 만드는지 의심돼. preservesPitch(속도를 바꿔도 음높이를 유지하는 설정) 재설정은 루프에서 빼자. 이게 원인이면 같은 폰에서 끊김이 사라져야 해. ?debug=1을 붙이면 화면 아래에 리더와의 오차 Δ, 오차가 벌어지는지 좁혀지는지, 속도, 점프 기준이 뜨게 하자. 동시에 오차가 벌어질 때만 점프하고, 이미 점프 중이면 다시 요청하지 않게 바꿨어. Δ가 0.45에서 0.5초 사이를 계속 왔다 갔다 해. 더 커지지는 않고 거기서 맴돌아. exp로 건너뛰지 말고, 늦게 착지하는 만큼을 미리 더한 exp + seekComp로 건너뛰는 거야. seekComp는 0.45초에서 시작해 착지 뒤 남은 오차로 고쳐 가. 측정에는 0.8초 대기를 두었어. 점프 기준은 0.30초, 연속 점프는 1초 간격으로 막고. Δ가 얼마였는지는 적어 두지 않았어.