개발 취재 노트

아이폰 영상 동기화가 계속 점프하고 0.5초 늦을 때

무용 연습실에서 리더 노트북이 음악이 담긴 안무 영상을 틀면, 멤버들이 각자 폰으로 같은 지점의 영상과 대형(동선)을 맞춰 보는 웹앱(formation)을 만들고 있었다. 아이폰 12 Pro에서 영상이 리더와 맞았다가 끊기고 느려지기를 반복했다. seek(재생 위치로 순간 이동)이 끝날 때까지 리더가 앞으로 가서 뒤처진다는 가설에 따라, 목적지에 보정값을 더한 뒤 해결됐다는 확인을 받았다. 다만 seek 시간 자체를 측정하거나 기기 성능을 원인에서 완전히 배제한 실험은 아니었다.

결론

아이폰에서 <video>가 리더와 계속 어긋나고 끊긴다면, 배속 보정과 스트리밍 미리받기는 이 사례에서 효과가 없었다. 효과가 있었던 것은 seek 목적지를 리더의 현재 추정 시각 exp에서 exp + seekComp로 앞당기는 것이었다. 미리받기(blob)로 바꿔도 끊김은 그대로였고 , 배속을 없애거나 ±6%로 바꿔도 끊김·미보정이 남았다 . ?debug=1로 관찰한 오차가 0.45~0.5초에 머물자, seek이 끝날 때까지의 지연 때문에 매번 뒤처진다는 가설을 세웠다. 보정값을 0.45초에서 시작하고 seek 후 남은 오차로 갱신하는 구현을 넣은 뒤 아이폰에서 "됐다!"는 확인을 얻었다.

핵심 변경을 식으로 옮기면 다음과 같다. 실제 소스를 복원한 것이 아니라 구현 설명을 요약한 것이다.

이전 seek 목표 = exp
변경 seek 목표 = exp + seekComp
seekComp 초기값 = 0.45초
이후 seek 착지 잔차로 seekComp 갱신

exp는 뷰어가 sync 메시지를 받아 추정한 리더의 현재 재생 시각이다. 기록에는 받은 표본을 EMA(지수 이동 평균)로 평활한다는 언급만 있고, 계수는 남아 있지 않다 . 아래 스케치는 기록에서 복원한 코드가 아니라 같은 아이디어를 따라 하려는 사람을 위한 예시이며, 계수 0.5와 측정 시점은 직접 조정해야 한다.

// 예시(복원 아님): 리더 시각 추정 + seekComp 학습
// applySync에서: 받은 리더 시각 t, 수신 시각 now(초)로 오프셋을 평활
offset = offset === null ? t - now : offset * 0.8 + (t - now) * 0.2;
const exp = () => performance.now() / 1000 + offset;   // 리더 현재 추정 시각

function hardSeek() {
  video.currentTime = exp() + seekComp;
  landAt = performance.now() + 800;   // 요청 0.8초 뒤에 착지 오차를 잰다(가정)
}
// 정지 중이 아닐 때, landAt 이후 한 번만:
const resid = exp() - video.currentTime;     // 양수 = 아직 뒤처짐
seekComp = Math.max(0, seekComp + resid * 0.5);

0.47초는 seek 소요시간을 직접 잰 값이 아니다. 실제로 관찰한 것은 동기화 오차의 범위였고, 이를 seek 지연으로 해석한 것은 가설이다. 해결 후의 실제 Δ·comp 수치는 기록되지 않았다. 따라서 실기기에서 오차가 0.05초로 줄었다고 주장할 수는 없다.

상황

만들던 것은 무용 대형·동선 편집기 formation의 연습실 실시간 동기화 기능이다. 구성은 세 부분이다.

문제가 된 곳은 뷰어가 <video>를 리더에 맞추는 보정 로직이다. 영상이 리더와 벌어졌을 때 쓸 수 있는 도구는 둘이다. 하나는 playbackRate(재생 배속)를 살짝 바꿔 서서히 따라잡는 것이고, 다른 하나는 currentTime을 바꿔 목표 위치로 점프하는 seek이다.

증상

아이폰 12 Pro에서 360p 스트리밍 영상도 간헐적으로 끊겼다. 미리 받아 재생해도 같았고, 영상을 빼고 동선만 보여 주는 모드는 매끄러웠다. 증상은 "싱크가 맞았다 싶으면 다시 끊겨서 느려진다"는 반복이었다.

처음에는 스트리밍 seek의 리버퍼링을 의심하며 미리받기를 시도했다. 그러나 바로 다음 테스트에서도 문제가 재현됐다. 이 결과는 스트리밍만으로 증상을 설명하기 어렵다는 단서였을 뿐, 디코딩·메모리·기기 성능을 하나씩 검증해 모두 배제한 실험은 아니었다. 조사 초점은 영상의 동기화 보정으로 옮겨졌다.

그 뒤에도 보정 방법을 바꿀 때마다 다른 문제가 남았다.

보정 시도실기기 결과
미세 배속 보정을 없애고 0.75초 이상 벌어질 때만 seek점프는 없어졌지만 보정이 안 됨.
고정 ±6% 배속에 히스테리시스 적용변경 횟수를 줄였지만 "매우 끊겨".
배속 보정을 다시 없애고 seek 임계값을 0.35초로 축소계속 점프함.

원인을 좁힌 관찰과 가설

전환점은 "차이가 벌어지는지 좁혀지는지 체크할 수 있어?"라는 질문이었다. 이에 ?debug=1 화면에 오차 Δ, 벌어짐·좁혀짐·정체 추세, 배속, seek 임계값을 표시했다. 당시 정의에서 Δ가 양수면 뷰어가 리더보다 뒤처진 상태다. seek 중에는 다시 seek하지 않는 가드와, 벌어지는 추세일 때만 seek하는 조건도 함께 넣었다.

아이폰에서 본 값은 "0.45~0.5 사이에서 계속 왔다 갔다"였다. 오차가 계속 쌓이는 게 아니라 일정한 범위에 머문다는 관찰이다. 이 범위를 seek 지연이 남기는 바닥값으로 해석했다.

이 해석을 1배속 기준의 단순 모델로 풀면 이렇다. 리더가 10.00초일 때 그 위치로 seek을 요청하고, 뷰어가 그 위치에서 재생을 이어 가기까지 0.47초가 걸린다고 하자. 그동안 리더는 10.47초에 가 있다. 요청할 때의 목표에는 도착했지만, 도착한 시점에는 다시 0.47초 뒤처져 있다. 이는 원인을 설명하기 위한 가정이며, 별도 타이머로 잰 결과는 아니다.

이 모델에서는 임계값 0.35초가 남는 오차보다 작아서 재보정이 끝없이 반복된다. 반대로 임계값을 올리면 점프는 줄어도 간격은 줄지 않는다. 임계값을 1.0초로 올리는 변경이 먼저 보고됐고, 0.5초 뒤처진 채 매끄럽게 유지될 것으로 예상했다. 이 단계의 실기기 결과는 별도로 보고되지 않았다.

리더 쪽을 0.5초 늦추자는 제안도 나왔다. 하지만 기준을 늦추면 뷰어도 그 기준에서 다시 늦기 때문에 상대 간격은 그대로라는 이유로 기각됐다. 여기서 검토한 것은 방송 기준이나 재생 전체를 늦추는 방식이며, 오디오만 따로 늦추는 방식까지 불가능하다고 확인한 것은 아니다. 최종 목표도 분명해졌다. 동선이 0.5초 어긋나는 것은 괜찮지만 음악과 영상 속 동작은 맞아야 하고, 댄스 영상에서 0.5초는 큰 차이라는 것이다.

배속 보정이 끊겼다는 관찰도 범위를 지켜 읽어야 한다. 히스테리시스를 적용한 실기기 테스트는 실패했지만, "아이폰에서는 1배속이 아니면 무조건 끊긴다"는 것까지 확인한 것은 아니다. 대안으로 거론된 preservesPitch=false 실험도 이 해결 과정에서는 실행되지 않았다.

해결

목표를 seek을 요청한 시점의 리더 위치에서 seek이 끝났을 때 리더가 있을 것으로 예상되는 위치로 바꿨다. 구현 설명에 따르면 hardSeek의 목적지에 seekComp를 더하고, 이후 착지 잔차로 보정값을 학습했다. 고정된 0.5초를 모든 기기에 적용하지 않고 잔차를 반영하도록 설계했다.

당시 설정은 다음과 같다. 수치는 당시 구현 요약이며, 다른 기기에서도 최적인 값으로 검증된 것은 아니다.

항목당시 설정
seekComp 초기값0.45초.
RESYNC_THRESHOLD0.30초.
seek 쿨다운1초.
착지 측정seek 0.8초 후(기준 시점은 기록에 없음).
정지 중 학습측정을 리셋.
보정값 저장따로 저장하지 않고 세션마다 0.45초에서 시작.

기록에 남은 것은 "착지 잔차로 seekComp를 학습한다"는 설명과 "seek 0.8초 후 측정"이라는 값뿐이다. 실제 갱신 계수, 제한 범위, 0.8초를 잴 기준 시점(seek 요청인지 seeked 이벤트인지)은 남아 있지 않다 . 위 스케치는 그 빈칸을 요청 시점 기준으로 메운 예시다. 같은 방식을 구현하려면 seek 요청 시각과 0.8초 뒤 잔차를 직접 기록해 갱신 계수를 정해야 한다. 기기가 느려 0.8초 안에 seek이 끝나지 않으면 잔차가 왜곡될 수 있다는 점도 구현자가 확인할 부분이다. 이는 당시 확인된 사실이 아니라 주의점이다.

산술만 확인하면, 실제 지연이 0.5초이고 seekComp가 0.45초일 때 첫 seek의 잔차는 0.05초다.

시뮬레이션에서는 지연을 0.5초로 두고 보정값 0.45초를 적용했을 때 첫 seek의 잔차가 0.05초로 나왔다. "10배 개선"은 이 시뮬레이션의 결과다. 실제 기기에서의 확인은 "됐다!"였고, 해결 후 Δ·comp 값은 기록되지 않았다. 디버그 화면 예시에 나온 Δ +0.03s, comp 0.47도 실측이 아니다.

이후 이제 영상을 끌 필요가 없겠다며 동선만 보기 버튼을 없애기로 했다. 체감상 해결됐다는 판단을 뒷받침하지만, 남은 오차의 크기나 여러 기기의 동시 재생 정확도를 증명하는 수치는 아니다. 이 사건에서 확인된 성과는 보정을 넣은 뒤 해결됐다는 확인까지다.

취재 후기 — 도착했는데 또 늦었다

아래는 추적 과정을 네 인물로 재구성한 대본이다.

박도은
연습실에서 리더 노트북이 음악이 든 안무 영상을 틀면, 다들 자기 폰으로 같은 장면과 대형을 맞춰 보는 웹앱 있잖아. 내 아이폰 12 Pro에서 영상이 리더랑 맞았다가 끊기고 느려지기를 계속 반복해.
정하늘
폰마다 받은 리더 재생 시각에 맞춰 자기 <video>를 따라가게 하는 구조였지. 벌어지면 배속을 살짝 바꾸거나, seek으로 그 위치까지 순간 이동하고.
정바다
360p인데 끊긴다면 스트리밍 탓 같아. seek할 때마다 그 구간 조각을 다시 받느라 잠깐 멈추는 걸 리버퍼링이라고 하잖아. 파일을 폰에 미리 받아 두면 사라지는지 확인해 보자.
정하늘
그럼 두 가지를 해 보면 갈리겠다. 미리 받은 파일로 재생해 보고, 영상을 끄고 동선만 그리는 모드도 켜서 비교해 봐.
박도은
둘 다 해 봤어. 미리 받아도 똑같이 끊기고, 동선만 모드는 전혀 안 끊기고 매끄러워.
정하늘
미리 받아도 끊기니 영상 스트리밍만으로 설명하긴 어렵겠어. 동선만 모드는 리더 시각에 바로 그리니까, 다음에는 <video>를 맞추는 보정 로직을 바꿔 비교해 보자.
박도현
미리받기만으로 해결된다는 예상은 빗나갔어. 비교 실험으로 다음에 확인할 곳은 좁혔지만, 디코딩이나 기기 성능까지 모두 배제한 건 아니야.
정바다
그럼 배속 보정을 의심해 보자. 오차가 조금만 생겨도 playbackRate, 즉 재생 속도를 계속 조금씩 바꾸고 있잖아. 배속 보정을 빼고 0.75초 넘게 벌어질 때만 한 번 점프하자.
박도은
점프는 없어졌어. 그런데 리더랑 조금 벌어진 채로 더는 안 붙어. 0.75초 안쪽이라 그냥 두는 것 같아.
박도현
점프를 줄이는 것만으로는 동기화가 해결되지 않았네. 이번에는 작은 오차도 줄이면서 끊김이 생기는지 함께 확인해야겠어.
정바다
그럼 배속을 6%만 올리되 붙이기 시작할 때 한 번, 다 붙었을 때 한 번만 바꾸자. 이렇게 켜고 끄는 기준을 따로 두는 걸 히스테리시스라고 해. 시뮬레이션에선 9초 동안 배속 변경 두 번으로 수렴했어.
박도은
폰에 올려 보니 매우 끊겨. 시뮬레이션이랑은 완전히 딴판이야.
박도현
시뮬레이션은 로직이 설계대로 도는지만 보여 줘. 그 배속을 기기가 어떻게 받아들이는지는 폰에 돌려 봐야 알 수 있어.
정하늘
배속을 드물게 바꿔도 이번 테스트에서는 끊겼어. 우선 이 보정 방식은 빼자.
정바다
배속은 이번 시도에서 포기하자. 1배속으로 고정하고, 점프 기준만 0.35초로 낮추면 적어도 붙기는 붙겠지.
박도은
이번엔 쉬지 않고 계속 점프해. 0.35를 영영 못 줄이는 것 같아. 오차가 벌어지는 중인지 좁혀지는 중인지부터 볼 수 없을까?
정하늘
좋아, 짐작 대신 숫자를 보자. 주소 뒤에 ?debug=1을 붙이면 화면 아래에 리더와의 오차 Δ와 추세가 뜨게 할게. Δ가 양수면 폰이 뒤처진 거야.
박도은
봤어. 0.45에서 0.5초 사이를 계속 왔다 갔다 해. 점점 커지지도 않고, 0 쪽으로 내려가지도 않아.
정하늘
한곳에 머문다는 게 단서야. 리더가 10초일 때 10초로 뛰는데 도착까지 0.47초쯤 걸린다고 해 봐. 도착하면 리더는 벌써 10.47초야. 기준이 0.35니까 또 뛰고, 또 늦게 도착하는 거지.
박도현
그 가설대로라면 0.35초 기준은 남는 오차보다 작아서 점프를 반복하게 돼. 다만 관찰한 건 오차 범위야. 0.47초가 실제 seek 시간인지는 아직 직접 재지 않았어.
정바다
그럼 기준을 1초로 올려서 0.5초 늦은 채 매끄럽게 두자. 아니면 차라리 리더 쪽을 0.5초 늦춰 버리면 폰이 따라잡은 것처럼 보이지 않을까?
정하늘
기준을 올리면 점프는 멈추지만 간격은 그대로야. 재생과 방송을 함께 늦추면 폰도 그 기준을 따라가니 간격은 그대로일 거야. 오디오만 따로 늦추는 건 다른 방법이지만, 여기서는 폰의 보정을 먼저 고쳐 보자.
박도현
게다가 우리 목표는 끊김을 없애는 게 아니라, 음악과 영상 속 동작을 맞추는 거야. 동선이 0.5초 틀리는 건 괜찮아도, 춤에서 음악과 동작이 0.5초 어긋나면 너무 커.
정하늘
그럼 목적지를 바꾸자. 지금 리더 위치 exp가 아니라, 점프가 끝날 즈음 리더가 가 있을 exp + seekComp로 뛰는 거야. seekComp는 0.45초에서 시작하고, 도착 뒤 남은 오차로 계속 고쳐 가.
박도은
이제 됐어! 영상을 끄고 동선만 볼 필요도 없을 것 같아. 그 버튼은 빼도 되겠어.
정하늘
기록할 땐 구분하자. 0.47초는 오차가 머문 범위를 보고 해석한 값이지 점프 시간을 직접 잰 게 아니야. 0.05초까지 줄었다는 것도 시뮬레이션 결과고, 고친 뒤의 실제 Δ 숫자는 남기지 못했어.
박도현
그리고 이번엔 앞질러 seek한 뒤 "됐다"는 확인이 나왔지만, 그게 seek 지연이 원인이었다는 증명은 아니야. 원인을 확정하려면 seek 시작부터 seeked 이벤트까지 직접 재 봐야 해.
정바다
결국 내가 처음에 스트리밍을 탓한 것도, 배속을 탓한 것도 틀렸고, 지금 가설도 아직 가설이네. 증상이 그럴듯하다고 원인이 확정되진 않는다는 걸 배웠어.