개발 취재 노트
아이폰 영상 동기화가 계속 점프하고 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의 연습실 실시간 동기화 기능이다. 구성은 세 부분이다.
- 리더 페이지: 리더 노트북이 음악이 들어 있는 안무 영상을 재생하고, 200ms마다(초당 5회) 현재 재생 시각과 상태를 서버로 보낸다.
- 서버: 노트북에서 도는 Node 서버다. 리더가 보낸 sync를 SSE(서버가 브라우저로 이벤트를 계속 밀어주는 방식)로 모든 뷰어에 중계하고, 영상 파일을 서빙한다.
- 뷰어 페이지(
viewer.html): 멤버들의 폰에서 돈다. 받은 sync로 리더의 현재 시각을 추정하고(exp), 폰의<video>를 그 시각에 맞춘다. 영상 위에는 캔버스로 대형(동선) 오버레이를 그린다. 영상을 끄고 동선만 보는 모드도 있었다.
문제가 된 곳은 뷰어가 <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_THRESHOLD | 0.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도 실측이 아니다.
이후 이제 영상을 끌 필요가 없겠다며 동선만 보기 버튼을 없애기로 했다. 체감상 해결됐다는 판단을 뒷받침하지만, 남은 오차의 크기나 여러 기기의 동시 재생 정확도를 증명하는 수치는 아니다. 이 사건에서 확인된 성과는 보정을 넣은 뒤 해결됐다는 확인까지다.
취재 후기 — 도착했는데 또 늦었다
아래는 추적 과정을 네 인물로 재구성한 대본이다.
<video>를 따라가게 하는 구조였지. 벌어지면 배속을 살짝 바꾸거나, seek으로 그 위치까지 순간 이동하고. <video>를 맞추는 보정 로직을 바꿔 비교해 보자. playbackRate, 즉 재생 속도를 계속 조금씩 바꾸고 있잖아. 배속 보정을 빼고 0.75초 넘게 벌어질 때만 한 번 점프하자. ?debug=1을 붙이면 화면 아래에 리더와의 오차 Δ와 추세가 뜨게 할게. Δ가 양수면 폰이 뒤처진 거야. exp가 아니라, 점프가 끝날 즈음 리더가 가 있을 exp + seekComp로 뛰는 거야. seekComp는 0.45초에서 시작하고, 도착 뒤 남은 오차로 계속 고쳐 가. Δ 숫자는 남기지 못했어. seeked 이벤트까지 직접 재 봐야 해.