웹 비디오에서 프레임 단위 정밀도의 한계

currentTime, 부동소수점, epsilon 비교

작성일: 2025. 12. 116 min

개요

영상 플레이어를 개발하면서 타임스탬프를 비교할 일이 생겼다. 현재 재생 시점과 저장된 타임스탬프가 같은지 판별해야 했는데, ===로 비교하면 거의 항상 불일치했다. Math.round()를 써도 경계에서 어긋났다.

이 글은 왜 웹 비디오에서 타임스탬프 비교가 근본적으로 어려운지, 그리고 어떻게 우회했는지를 정리한다.

video.currentTime

HTML5 Video API에서 현재 재생 위치를 가져오는 방법은 video.currentTime이다. 이 값은 초 단위의 IEEE 754 부동소수점(double)이다.

https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/currentTime

javascript
console.log(video.currentTime);
// 3.456789123456789

비디오 컨테이너(MP4 등) 내부에서는 시간을 정수 기반으로 표현한다. 소수점을 쓰면 오차가 생기니까, "1초를 N칸으로 쪼개고 몇 번째 칸인지"를 정수 쌍으로 저장하는 방식이다. MP4에서는 이걸 TimeValue / TimeScale이라고 부른다.

  • TimeScale: 1초를 몇 칸으로 쪼갤지 정하는 단위다. MP4에서는 주로 90000을 사용하는데, MPEG-2 Transport Stream(방송 표준)의 90kHz 클럭에서 온 관례다. 이 값이 24fps/25fps/30fps의 공배수라서 어떤 프레임레이트(fps, 초당 프레임 수)든 나누어떨어진다.
  • TimeValue: 그 칸에서 몇 번째 위치인지를 나타내는 정수다.

예를 들어 25fps 비디오의 13번 프레임은 13/25 = 0.52초에 시작한다. 이걸 MP4 내부에서는 0.52 × 90000 = 46800으로 변환해서 정수 쌍 (46800, 90000)으로 저장한다. 46800/90000 = 0.52로 오차 없이 복원할 수 있다.

문제는 브라우저가 이 정수 기반 타임스탬프를 JavaScript에 노출할 때 초 단위 double로 변환한다는 점이다. 이 변환 과정에서 정밀도가 깎인다.

부동소수점이 프레임 비교를 망치는 과정

WebKit 버그 리포트(#52697)에 구체적인 사례가 기록되어 있다. WebKit은 Safari의 브라우저 엔진이지만, 이 문제는 WebKit에 국한되지 않고 부동소수점을 사용하는 모든 브라우저에서 동일하게 발생한다.

WebKit Bug 52697 - Frame accurate seeking isn't always accurate

25fps 비디오의 13번 프레임 시작 시간은 46800/90000이다. 이 값을 배정밀도(double)로 표현하면 0.52000000000000002가 되지만, 브라우저 내부 엔진이 이를 단정밀도(float)로 처리할 경우 0.5199999809265137이 된다.

이 두 값으로 프레임의 TimeValue를 역산하면

1325×90000=46799.999...\frac{13}{25} \times 90000 = 46799.999... 46799.999=46799\lfloor 46799.999 \rfloor = 46799

가 된다. 프레임은 연속적인 시간 범위를 차지하기 때문에, 특정 시점이 몇 번째 프레임인지 알려면 내림(floor)을 해야 한다. 프레임 13은 TimeValue 46800에서 시작하는데, 부동소수점 오차로 46799가 되면서 프레임 12의 범위에 속하게 된다. 결과적으로 봤을 때 한 프레임이 밀리게 된다.

정수 기반 API 부재

W3C Media & Entertainment Interest Group에서도 이 문제를 논의했다. 프레임 단위 탐색이 필요한 실제 사용 사례를 모아서 브라우저 벤더들에게 API 개선을 요구하자는 취지로 열린 이슈인데, 2018년에 열리고 아직까지 Open 상태다.

https://github.com/w3c/media-and-entertainment/issues/4

W3C - Frame accurate seeking of HTML5 MediaElement

해당 이슈에서 지적된 이유는 여러 가지다.

첫째, 브라우저 JavaScript API에서 비디오의 실제 프레임레이트를 얻을 방법이 없다. <video> 요소에는 fps를 반환하는 속성이 없다. 이슈 본문에서도 "the framerate of the video, which is not exposed to Web applications"라고 지적하고 있다. VFR(Variable Frame Rate) 영상이라면 프레임마다 duration이 다르기 때문에 단일 fps 값 자체가 무의미하다.

W3C 이슈 댓글 - currentTime만으로는 현재 프레임을 특정할 수 없다

이 이슈에서는 currentFrameTime이라는 새 속성을 추가해 현재 표시된 프레임의 정확한 시간을 반환하자는 제안도 있었지만, 이 속성은 추가되지 않았다.

둘째, JavaScript와 비디오 렌더링은 서로 다른 스레드에서 실행된다. requestVideoFrameCallback API 문서에서도

requestVideoFrameCallback - 메인 스레드와 컴포지터 스레드 분리

라고 설명하고 있다. currentTime을 읽는 시점과 실제로 화면에 그려진 프레임이 일치한다는 보장이 없다.

https://web.dev/articles/requestvideoframecallback-rvfc

GOP(Group of Pictures) 구조가 만드는 추가 제약

YouTube IFrame API처럼 서버에서 영상을 스트리밍으로 받아 재생하는 환경에서는, 부동소수점 오차와 별개로 비디오 압축 구조 자체가 프레임 정밀 탐색을 어렵게 만든다.

GOP는 영상 프레임들을 묶는 압축 단위다. 매 프레임을 통째로 저장하면 용량이 너무 크기 때문에, 하나의 완전한 이미지(I-frame)를 기준으로 나머지 프레임은 차이만 저장하는 방식으로 압축한다. 비디오 프레임은 세 가지 유형으로 나뉜다.

프레임 유형설명
I-frame내부(intra)만으로 완성되는 프레임, 다른 프레임 참조 없이 독립적으로 디코딩할 수 있다.
P-frame이전 프레임을 예측(predicted)해서 바뀐 부분만 저장한다. 이전 I/P-frame이 있어야 디코딩할 수 있다.
B-frame앞뒤 프레임을 양방향(bi-directional)으로 참조해서 차이만 저장한다.

영상에서 특정 시점으로 탐색(seek)하면, 브라우저가 바로 그 프레임을 보여줄 수 있을 것 같지만 그렇지 않다. 해당 시점의 프레임이 P-frame이나 B-frame이라면, 차이 데이터만 있기 때문에 기준이 되는 I-frame을 먼저 찾아야 한다. 가장 가까운 I-frame까지 돌아간 뒤, 거기서부터 원하는 시점까지의 프레임을 순서대로 디코딩해야 한다.

I-frame은 보통 60-120 프레임(30fps 기준 2-4초)마다 존재한다. 스트리밍 환경에서는 용량을 줄이기 위해 I-frame 간격을 더 길게 잡는 경향이 있어서, 프레임 정확 탐색은 더욱 어려워진다.

epsilon 비교로 우회

프레임 단위 정확한 비교가 불가능하다면, "충분히 가까우면 같다"고 판단하는 방식으로 우회할 수 있다. epsilon(ε)은 수학에서 아주 작은 양을 뜻하는데, 두 값의 차이가 이 ε보다 작으면 같다고 간주하는 비교 방식을 epsilon 비교라고 한다.

javascript
// 정확한 비교 — 거의 항상 false
if (currentTime === targetTime) { ... }

// epsilon 비교
const EPSILON = 0.1; // 초
if (Math.abs(currentTime - targetTime) < EPSILON) {
  // 허용 오차 내면 같은 프레임으로 판단
}

너무 작으면 같은 프레임인데도 다르다고 판정하고, 너무 크면 다른 프레임을 같다고 판정하기 때문에 epsilon 값을 어떻게 정할 것인가가 중요하다. 여기서는 0.1초를 epsilon으로 설정했다. 60fps 기준으로 6프레임에 해당하는 넉넉한 값이다. 이 값을 선택한 근거는 다음과 같다.

typescript
const isSameTimestamp = Math.abs(currentTimeInSeconds - timeInSeconds) < 0.1;

이 epsilon은 같은 타임스탬프의 중복 클릭을 판별하고, 반복 seek를 방지하는 데 사용했다. "방금 한 동작을 또 하는 건지"를 체크하는 용도이기 때문에 0.1초는 적절한 값이라 생각했다. 반면 이 값을 프레임 단위(1/60 = 0.0167초)로 줄이면 부동소수점 오차와 YouTube API의 setInterval 폴링 간격(250ms) 때문에 오탐이 빈번하게 발생한다.

그래서 중복 seek 방지에도 같은 epsilon을 적용했다.

typescript
// 0.1초 이내의 seek 요청은 무시
if (lastSeekTarget !== null && Math.abs(time - lastSeekTarget) < 0.1) {
  return;
}

YouTube IFrame API는 seekTo() 호출 후 실제로 해당 시점에 도달하기까지 지연이 있다. 그 사이에 같은 위치로 반복 seek가 발생하면 플레이어가 불안정해진다. epsilon 범위 내의 요청을 무시함으로써 이 문제를 방지했다.

근본적 한계

epsilon 비교는 우회책이지 완벽한 해결책은 아니다. 웹 비디오에서 프레임 단위 100% 정확도는 현재 브라우저 아키텍처에서 달성할 수 없다.

requestVideoFrameCallback API는 비디오가 새 프레임을 그릴 때마다 콜백을 실행해주는 API다. 이 콜백에서 제공하는 metadata.mediaTimecurrentTime보다 정밀도가 높다. currentTime이 오디오 재생 시계를 기준으로 시간을 반환하는 반면, mediaTime은 실제 프레임의 원본 타임스탬프(PTS)를 직접 가져오기 때문이다.

하지만 이것도 여전히 float 기반이다. 그리고 프레임은 컴포지터 스레드에서 그려지기 때문에, JavaScript에서 콜백을 받는 시점과 실제 화면에 프레임이 나타나는 시점 사이에 1 vsync(약 16ms) 정도의 차이가 존재한다.

결국 웹 비디오 환경에서 타임스탬프를 비교해야 한다면, 정확한 일치가 아니라 "어디까지 허용할 것인가"를 용도에 맞게 결정해야 한다. 중복 클릭 판별과 반복 seek 방지라는 용도에서 0.1초는 충분히 합리적인 선택이었다.

참고 자료