개발 취재록

아이폰에서만 영상이 끊기고 리더와 싱크가 계속 어긋날 때

무용 연습용 영상 동기화 웹앱(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>는 이 추정 시각에 맞춰 보정된다. 자기 currentTimeexp와 비교해 차이가 나면 재생 속도(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초는 큰 차이라는 요구였다.

최종 구현 보고는 다음과 같다. 아래는 세션에 남은 설명을 정리한 것이며 코드 원문을 복원한 것은 아니다.

  1. hardSeek의 목표를 exp + seekComp로 앞당긴다.
  2. seekComp를 0.45초로 시작하고 착지 후 잔차로 갱신한다.
  3. 구현 보고의 표현대로 “착지측정 0.8초 후”를 두고, 쿨다운 1초로 연속 보정을 제한한다. 재동기화 임계값은 0.30초로 둔다.

이 변경 뒤 개발자는 "됐다!"고 답했고, 이후에는 동선만 보는 버튼을 없애도 되겠다고 요청했다. 이것이 기록에 남은 성공 확인이다. AI가 제시한 0.5초→0.05초는 시뮬레이션 결과이며 실기기 측정 결과가 아니다.

이 사례에서 가져갈 순서는 비교 모드로 조사 범위를 좁히기 → 오차 추세를 표시하기 → 보정 후 잔차에 맞춰 목표 시각을 바꾸기다. 대조군은 범인을 즉시 확정해 주지 않았다. 대신 틀린 예측을 드러냈고, 다음에 무엇을 측정할지 정해 줬다.

취재 후기 — 손잡이보다 계기판

*아래 대사는 세션의 관찰과 진단을 바탕으로 재구성한 것이다.*

박도은
우리 무용 연습실 웹앱 있잖아. 리더 노트북이 음악을 틀면 다들 자기 폰으로 같은 안무 영상이랑 대형을 맞춰 보는 거. 그런데 내 아이폰 12 Pro에서만 영상이 뚝뚝 끊겨.
정하늘
음악은 연습실 스피커 하나로 나가고, 폰 영상은 음소거라 타이밍만 따라가면 돼. 리더가 0.2초마다 재생 위치를 보내면, 폰은 자기 영상 시각과 비교해서 재생 속도를 바꾸거나 그 지점으로 건너뛰어.
정바다
화면 그리는 루프가 너무 바쁜 건 아닐까? 초당 60번 도는 루프에서 매번 재생 속도(playbackRate)를 조금씩 바꾸고 화면 크기까지 읽고 있거든. 잦은 속도 변경이 끊김을 만드는지 의심돼.
정하늘
맞는지는 걷어내 보면 알아. 속도는 0.015 이상 차이 날 때만 바꾸고, 크기 읽기와 preservesPitch(속도를 바꿔도 음높이를 유지하는 설정) 재설정은 루프에서 빼자. 이게 원인이면 같은 폰에서 끊김이 사라져야 해.
박도은
고친 버전을 같은 폰에서 다시 틀어 봤는데, 끊기는 건 그대로야.
박도현
이 수정만으로는 해결되지 않았어. 루프 부담이 일부 영향을 줬는지까지는 모르지만, 수상한 코드를 고쳤다는 이유만으로 증상을 해결했다고 할 수는 없어.
정바다
그럼 메모리가 의심돼. 109MB 영상을 조각으로 받아서 Blob, 그러니까 통째로 받은 영상 데이터로 합치고 있어. 합치는 동안 메모리 부담이 커진다는 가설은 어때?
정하늘
폰에서는 통째로 받지 말고 스트리밍으로 틀어 보자. 그래도 끊기면 큰 Blob만 원인으로 지목할 수는 없어. 이 비교만으로 메모리 영향을 전부 배제할 수는 없고.
박도은
스트리밍으로 바꿔도 여전히 아쉬워. 나중에 화질을 360p까지 낮춰서 스트리밍했는데도 끊길 때가 있어.
박도현
큰 파일이 있다는 근거만으로 메모리 원인이라고 확신할 수는 없어. 메모리 측정 결과도 없고, 스트리밍 전환 뒤에도 증상이 남았으니 다른 경로를 살펴보자.
정바다
그럼 스트리밍의 점프가 범인이야. 리더와 벌어져서 건너뛰기(seek)를 하면 스트리밍은 그 지점을 새로 받느라 멈칫하거든. 미리 다 받아 두면 건너뛰기가 즉시라 안 끊길 거야.
정하늘
그럼 두 갈래로 나눠 보자. 참여할 때 '미리 받기'를 골라 Blob으로 재생해 보고, 영상을 아예 끄고 동선만 리더 시각에 맞춰 그리는 모드도 켜 봐.
박도은
미리 받아도 똑같이 끊겨. 그런데 동선만 켜면 매끄러워. 영상은 리더와 맞았다 싶으면 다시 끊기고 느려지는 걸 계속 반복해.
정하늘
미리 받으면 해결된다는 예측은 틀렸어. 영상 쪽을 봐야 한다는 건 알았지만, 영상을 끈 모드로 디코더까지 무죄라고 할 수는 없어. 미리 받은 게 어느 파일이었는지도 확인 못 했고.
박도현
비교 모드가 범인을 찍어 준 건 아니야. 대신 틀린 예측 하나를 지우고, 다음에 볼 곳을 우리 동기화 보정 코드로 좁혀 줬어.
정바다
그럼 보정이 진동하는 거야. 속도를 올렸다 내렸다 하니 느려졌다 맞았다를 반복하지. 속도는 1배로 고정하고, 0.75초 넘게 벌어질 때만 건너뛰자.
박도은
점프는 없어졌어. 대신 리더랑 벌어진 채로 안 붙어. 기준이 0.75초라 보정이 아예 안 걸리는 것 같아.
정바다
그럼 0.15초 넘게 벌어지면 속도를 ±6%로 딱 한 번 바꾸고, 0.04초 안으로 붙으면 되돌리자. 계산으로 돌려 보니 속도는 두 번만 바뀌고 4초쯤이면 붙어.
박도은
폰에서는 매우 끊겨. 계산에서는 매끄럽게 붙는다던 게 실제 폰에서는 완전히 달라.
정바다
이 실험에서는 배속 보정을 넣자 심하게 끊겼어. 비1배속 자체가 문제인지는 아직 가설이지만, 일단 배속 보정은 빼 보자. 속도는 다시 1배로 고정하고, 건너뛰는 기준만 0.35초로 낮추자.
박도은
이번엔 쉬지 않고 계속 점프해. 오차가 벌어지는 건지 좁혀지는 건지 눈으로는 모르겠어.
박도현
기준값만 세 번 돌렸지, 보정한 뒤에 오차가 어디에 머무는지는 한 번도 안 봤어. 계기판 없이 손잡이만 돌리면 맞춰도 왜 맞았는지 모르고 끝나.
정하늘
주소 뒤에 ?debug=1을 붙이면 화면 아래에 리더와의 오차 Δ, 오차가 벌어지는지 좁혀지는지, 속도, 점프 기준이 뜨게 하자. 동시에 오차가 벌어질 때만 점프하고, 이미 점프 중이면 다시 요청하지 않게 바꿨어.
박도은
떴어. Δ가 0.45에서 0.5초 사이를 계속 왔다 갔다 해. 더 커지지는 않고 거기서 맴돌아.
정바다
그럼 이렇게 설명돼. 리더의 지금 시각으로 건너뛰면, 건너뛰기가 끝날 즈음 리더는 이미 0.47초쯤 앞에 가 있어. 잔차가 기준 0.35보다 크면 다시 건너뛰려 할 수 있겠지. 다만 방금 넣은 추세 조건까지 고려한 발생 기록은 더 필요해.
정하늘
그 0.47은 건너뛰는 데 걸린 시간을 직접 잰 게 아니라, 머무는 오차에서 거꾸로 추정한 값이야. 기준을 1초로 올려 0.5초 뒤처짐을 그냥 두는 방법도 있긴 해.
박도은
춤은 음악과 동작이 맞는 게 제일 중요해. 동선은 좀 어긋나도 되지만, 영상이 0.5초 늦는 건 댄스에선 꽤 큰 차이라 그냥 둘 순 없어.
정하늘
그럼 목표를 앞당기자. 리더의 추정 시각 exp로 건너뛰지 말고, 늦게 착지하는 만큼을 미리 더한 exp + seekComp로 건너뛰는 거야.
박도현
늦게 착지하는 양은 기기나 상황마다 다를 텐데, 0.45초를 박아 두면 다른 폰에서 또 어긋나지 않을까?
정하늘
그래서 고정값이 아니야. seekComp는 0.45초에서 시작해 착지 뒤 남은 오차로 고쳐 가. 측정에는 0.8초 대기를 두었어. 점프 기준은 0.30초, 연속 점프는 1초 간격으로 막고.
박도은
됐다! 이제 영상 없이 동선만 보는 버튼도 없애도 될 것 같아.
정바다
0.5초가 0.05초로 줄었으니 열 배 개선이네. 이걸 실기기 개선 수치로 봐도 될까?
박도현
그 0.05는 계산으로 돌려 본 값이야. 폰에서는 "됐다"까지만 확인했고, 그때 Δ가 얼마였는지는 적어 두지 않았어.
정하늘
보정 기준값을 또 돌리고 싶어지면, 먼저 보정한 뒤 오차가 어디에 머무는지 화면에 띄워. 이번엔 '0.5초 근처에 머문다'는 숫자가 목표 시각을 앞당기는 실험으로 이어졌어.