시뮬레이터 스트리밍을 H.264로 전환했더니 성능이 떨어졌습니다. 지연을 해결한 방법은 다음과 같습니다.

발행: (2026년 6월 10일 PM 04:06 GMT+9)
10 분 소요
원문: Dev.to

출처: Dev.to

이전 글에서 나는 tapflow가 iOS 시뮬레이터를 브라우저로 스트리밍하는 방식을 설명했다: 시뮬레이터의 IOSurface에서 프레임을 꺼내고, Mac에서 JPEG로 인코딩한 뒤, 약 30fps로 WebSocket을 통해 전송한다.
JPEG는 인터랙티브 스트리밍에 아주 좋은 특성을 하나 가지고 있다: 모든 프레임이 독립적이며 즉시 디코딩된다. 버퍼도 없고, 프레임 간 의존성도 없다. 로컬호스트에서는 마치 시뮬레이터를 직접 터치하는 느낌이다.
하지만 또 하나 끔찍한 단점이 있다: 크기다. 스크롤되는 화면을 전체 프레임 JPEG로 저장하면 약 590KB가 된다. LAN에서는 초당 1216 MB 정도가 되며, 압력이 걸리면 릴레이가 초당 1627프레임을 놓치기 시작한다—눈에 보이는 티어링이 발생한다.

그래서 우리는 당연히 H.264로 전환했다. 정지 화면에서는 대역폭이 약 140배, 스크롤 중에는 5배 정도 줄어들었다. 프레임 손실은 거의 사라졌다.
그런데 스트림은 오히려 더 나빠 보였다.

이 글은 왜 그런지, 그리고 H.264를 “직접 터치하는 느낌”으로 되돌린 두 가지 해결책에 대해 다룬다.

무엇인가를 건드리기 전에 나는 감이 아니라 수치가 필요했다. 그래서 파이프라인을 끝까지 계측했다—각 단계별로 디코드→프레젠트와 glass→glass(캡처 타임스탬프부터 화면 표시까지) 지연을 실시간으로 보고하는 패널을 만들었다.

한 가지 주의할 점을 계속 강조하겠다: glass→glass 절대값은 캡처와 디스플레이가 같은 시계(로컬호스트)에서만 유효하다. decode→present는 같은 머신 내의 차이이므로 어느 환경에서도 유효하므로, 교차 환경 주장에서는 이 값을 주로 사용할 것이다.

아래는 로컬호스트에서 측정한, 중요한 기준선이다:

Pathdecode→present p50/p95 (ms)
JPEG still12.4 / 15.4
JPEG scroll9.4 / 11.6
H.264 (WebCodecs) still267 / 274

H.264 디코드는 하드웨어 디코더임에도 불구하고 JPEG보다 약 20배 느렸다. 그게 이해가 안 됐지만, 디코더가 실제로 무엇을 하고 있는지 살펴보니 이유가 보였다.

전송 지연은 거의 없었고(~1 ms), 입력 큐도 비어 있었다. 지연은 전부 디코더 내부에 있었는데, 첫 프레임을 내보내기 전에 약 8프레임을 버퍼링하고 있었다.

이는 DPB(Decoded Picture Buffer) 때문이다. B‑프레임이 존재할 경우 디코더는 현재 프레임을 디스플레이 순서대로 출력하기 위해 미래 프레임이 도착하기를 기다린다. 그래서 레벨이 허용하는 최대까지 버퍼링한다.

하지만 우리 인코더는 베이스라인 H.264이며 B‑프레임을 사용하지 않는다. 재정렬 깊이가 0이어야 하는데, 비트스트림이 이를 알려주지 않아서 디코더는 레벨 5.0의 최악 상황인 max_dec_frame_buffering≈8프레임을 가정하고 버퍼링했다.

문제는 SPS(Sequence Parameter Set) 안에 있는 VUI의 bitstream_restriction 플래그에 있었다. 우리 VideoToolbox 인코더가 이를 설정하지 않았기 때문에 디코더는 기본값을 사용한 것이다.

해결책은 SPS를 다시 쓰고 누락된 선언을 삽입하는 것이었다:

max_num_reorder_frames = 0
max_dec_frame_buffering = num_ref_frames

이 작업은 에이전트에서, 키프레임 SPS가 Mac을 떠나기 전에 수행한다—그래서 아래 단계의 모든 디코더가 혜택을 본다, 특정 브라우저 경로만이 아니다.

// agent-core/utils/sps.ts — rewrite the SPS to declare zero reordering
function rewriteLowLatencySps(sps: Uint8Array): Uint8Array {
  const bits = new BitstreamWriter(parseSps(sps))
  bits.vui.bitstreamRestriction = true
  bits.vui.maxNumReorderFrames = 0
  bits.vui.maxDecFrameBuffering = bits.numRefFrames
  return serialize(bits)
}

로컬호스트에서의 결과는 다음과 같다:

Pathdecode→present p50/p95 (ms)
H.264 WebCodecs still (before)267 / 274
H.264 WebCodecs still (after)2.5 / 4
H.264 WebCodecs scroll (after)2.1 / 3.9

267 ms → 2.5 ms, 약 100배 개선됐다. 인코더가 재정렬 깊이를 0이라고 선언하지 않아 디코더가 스스로 버퍼링한 것이었다. 선언 하나만으로 해결됐다.

브라우저는 재작성된 SPS를 받았음을 확인한다—이제 bitstreamRestriction: true, maxNumReorderFrames: 0을 보고한다.

Fix 1은 WebCodecs 경로에만 적용된다. 그리고 WebCodecs에는 강제 조건이 있다: 보안 컨텍스트(HTTPS 또는 localhost)에서만 동작한다.

LAN에서 tapflow를 plain http://:4000으로 사용하던 팀은 비보안 컨텍스트이기 때문에 브라우저가 WebCodecs를 사용할 수 없었다. 당시 대체 수단은 MSE(Media Source Extensions)였다: H.264 스트림을 muxer를 통해 <video> 요소에 공급하는 방식이다.

문제는 <video> 요소가 버퍼라는 점이다. 미디어 재생을 위해 설계된 지터 버퍼가 포함돼 있어, 인터랙티브 스트리밍에서는 제거할 수 없는 구조적 지연을 만든다. 로컬호스트에서 MSE 계층을 강제로 사용해 측정한 결과는 다음과 같다:

Pathdecode→present p50/p95 (ms)
H.264 MSE still239 / 254
H.264 MSE scroll229 / 244

같은 재정렬 = 0 스트림을 WebCodecs가 2.5 ms에 디코드한 반면, MSE는 약 235 ms가 걸렸다. SPS 수정은 디코더 DPB에만 영향을 주고, 미디어 요소 버퍼에는 전혀 영향을 주지 못한다. 이미 muxer의 flushingTime을 0으로 설정했지만 더 줄일 수 있는 여지는 없었다.

그래서 MSE를 포기하고 제거했다.

이제 디코더 레이어는 두 단계로 구성되며, 환경에 따라 자동으로 선택된다:

// pickDecoder — secure → WebCodecs, otherwise WASM
export function pickDecoder(): Decoder | null {
  if (isSecureContext && 'VideoDecoder' in window) {
    return new WebCodecsDecoder()      // HW, lowest latency
  }
  if (webgl2Available && wasmSupported) {
    return new WASMDecoder()           // tinyh264, zero-buffer
  }
  return null                          // → fall back to JPEG
}

비보안 LAN‑HTTP 환경에서는 H.264를 WASM(tinyh264)으로 디코드한다. 소프트웨어 디코더이므로 CPU를 사용하지만, 미디어 요소 버퍼가 전혀 없다는 장점이 있다. 즉, plain HTTP에서도 JPEG와 같은 즉시성을 H.264의 대역폭 절감 효과와 함께 얻을 수 있다.

로컬호스트(가장 최악의 경우—인코더와 디코더가 같은 Mac에 있음)에서 측정한 결과는 다음과 같다:

Pathdecode→present p50/p95 (ms)
H.264 WASM still8.7 / 30.4
H.264 WASM scroll14.3 / 37.9

JPEG 기준선(12.4 / 9.4 ms)과 거의 동등한 수준이다. MSE를 없앤 덕분에 muxer 의존성도 완전히 제거했다.

단 하나의 제약이 있다: tinyh264는 베이스라인 H.264만 디코드한다. iOS는 이미 베이스라인으로 인코드하고, Android는 scrcpy를 베이스라인(profile:int=1)으로 고정해 두 플랫폼 모두 동일한 HTTP→WASM 경로를 사용한다. 고프로파일은 여전히 보안 환경의 WebCodecs에서 사용할 수 있다.

스위치를 하면서 드러난 미묘한 차이점이 있다. JPEG에서는 모든 프레임이 키프레임이므로, 압력이 걸려 프레임이 손실돼도 다음 프레임이 독립적으로 표시된다. H.264에서는 P‑프레임을 놓치면 이후 모든 P‑프레임이 디코더가 받지 못한 프레임을 참조하게 된다. zero‑buffer 디코

0 조회
Back to Blog

관련 글

더 보기 »