H.264 스트리밍을 JPEG 스크린샷으로 대체했더니 더 잘 작동했다
(blog.helix.ml)- Helix는 클라우드 상에서 자율 코딩 에이전트가 작동하는 화면을 사용자에게 보여주는 AI 플랫폼으로, 안정적인 원격 화면 전송이 핵심임
- 기업 네트워크의 UDP 차단과 방화벽 제약으로 WebRTC 기반 스트리밍이 실패하자, 팀은 WebSocket 기반 H.264 파이프라인을 구축했으나 불안정한 Wi-Fi 환경에서 지연이 심각하게 발생함
- 복잡한 인코딩·디코딩 구조 대신, 단순히 JPEG 스크린샷을 HTTP로 주기적으로 전송하는 방식이 훨씬 안정적이고 효율적임을 발견
- 이 방식은 대역폭 사용량이 적고, 손상된 프레임 복구가 필요 없으며, 네트워크 품질에 따라 자동으로 화질과 프레임 속도를 조정함
- 결과적으로 Helix는 좋은 연결에서는 H.264, 나쁜 연결에서는 JPEG 폴링으로 전환하는 하이브리드 구조를 채택해, 단순하지만 실용적인 원격 스트리밍 시스템을 완성함
Helix의 스트리밍 문제와 제약
- Helix는 클라우드 샌드박스에서 작동하는 AI 코딩 에이전트의 화면을 실시간으로 공유해야 하는 플랫폼
- 사용자는 마치 원격 데스크톱처럼 AI가 코드를 작성하는 과정을 시청함
- 초기에는 WebRTC를 사용했으나, 기업 네트워크의 UDP 차단으로 연결이 실패함
- TURN 서버, STUN/ICE, 커스텀 포트 등은 모두 방화벽 정책에 의해 차단됨
- 이에 따라 HTTPS(443 포트)만 사용하는 WebSocket 기반 H.264 스트리밍 파이프라인을 직접 구현
- GStreamer + VA-API로 하드웨어 인코딩, WebCodecs로 브라우저 디코딩
- 60fps, 40Mbps, 100ms 미만의 지연을 달성
네트워크 지연과 성능 저하
- 커피숍 등 불안정한 네트워크 환경에서 영상이 멈추거나 수십 초 지연되는 문제가 발생
- TCP 기반 WebSocket은 패킷 손실 시 프레임이 순차적으로 지연되어 실시간성이 붕괴
- 비트레이트를 낮춰도 지연은 해결되지 않고, 화질만 저하됨
- 키프레임만 전송하는 방식도 시도했으나, Moonlight 프로토콜이 P-프레임을 요구해 실패
JPEG 스크린샷 방식의 발견
- 디버깅 중
/screenshot?format=jpeg&quality=70엔드포인트를 호출하자 즉시 선명한 이미지가 로드됨- 150KB 크기의 JPEG 한 장이 지연 없이 표시됨
- 단순히 HTTP 요청을 반복해 스크린샷을 갱신하자 5fps 수준의 부드러운 화면 갱신이 가능
- 결국 복잡한 비디오 파이프라인 대신, 주기적 JPEG 요청(fetch loop) 방식으로 전환
JPEG 방식의 장점
- H.264 대비 주요 비교 항목
- 대역폭: H.264는 40Mbps 고정, JPEG는 100~500Kbps로 변동
- 상태 관리: H.264는 상태 의존적, JPEG는 완전한 독립 프레임
- 복구성: H.264는 키프레임 대기 필요, JPEG는 다음 프레임으로 즉시 복구
-
복잡도: H.264는 수개월 개발, JPEG는
fetch()루프 몇 줄로 구현
- 네트워크 품질이 나쁠수록 단순한 JPEG 방식이 더 안정적이고 효율적
하이브리드 전환 구조
- Helix는 두 방식을 RTT(왕복 지연 시간) 기준으로 자동 전환
- RTT < 150ms → H.264 스트리밍
- RTT > 150ms → JPEG 폴링
- 연결 복구 시 사용자가 클릭해 재전환
- 입력 이벤트(키보드·마우스)는 WebSocket으로 계속 전송되어 상호작용성 유지
- 서버는
{"set_video_enabled": false}메시지로 비디오 전송을 중단하고 스크린샷 모드로 전환
전환 불안정(oscillation) 문제와 해결
- 전송 중단 후 WebSocket 트래픽이 줄어들면 지연이 낮아져 자동으로 다시 비디오 모드로 전환되는 무한 루프 발생
- 해결책: 스크린샷 모드 진입 후에는 사용자 클릭 전까지 고정 유지
- UI에 “대역폭 절약을 위해 비디오 일시 중지됨” 메시지 표시
JPEG 지원 문제와 빌드 과정
- Wayland용 스크린샷 도구 grim이 Ubuntu 기본 패키지에서 JPEG 지원이 비활성화되어 있음
-
grim -t jpeg실행 시 “jpeg support disabled” 오류 발생
-
- 이를 해결하기 위해 Dockerfile에서 libjpeg-turbo8-dev를 포함해 grim을 소스에서 직접 빌드
최종 아키텍처
- 좋은 연결: 60fps H.264, 하드웨어 가속
- 나쁜 연결: 2~10fps JPEG 폴링, 완전한 신뢰성
- 스크린샷 품질은 전송 시간에 따라 자동 조정
- 500ms 초과 시 품질 -10%, 300ms 미만 시 +5%, 최소 2fps 유지
주요 교훈
- 단순한 해법이 복잡한 시스템보다 낫다 — 3개월의 H.264 개발보다 2시간의 JPEG 해킹이 실용적
- 우아한 성능 저하(graceful degradation) 가 사용자 경험의 핵심
- WebSocket은 입력 전송에 최적, 영상 전송에는 필수 아님
- Ubuntu 패키지는 기능 누락 가능성 — 필요 시 직접 빌드
- 최적화 전 측정 필수 — 복잡한 스트리밍이 유일한 해법은 아님
오픈소스 공개
- Helix는 오픈소스로 제공되며, 핵심 구현은 다음과 같음
-
api/cmd/screenshot-server/main.go— 스크린샷 서버 -
MoonlightStreamViewer.tsx— 적응형 클라이언트 로직 -
websocket-stream.ts— 비디오 전환 제어
-
- Helix는 실제 환경에서도 작동하는 AI 인프라를 목표로 개발 중임
Hacker News 의견들
-
네트워크가 나쁠 때 JPEG이 줄어드는 건 UDP 때문이 아니라 TCP 구현 방식 때문임
JPEG은 버퍼링이나 혼잡 제어 문제를 해결하지 않음. 아마도 프레임 전송을 최소화하는 구조로 구현했을 가능성이 큼
h.264는 JPEG보다 부호화 효율이 높음. 동일한 크기라면 h.264 IDR 프레임이 더 좋은 품질을 낼 수 있음
근본적인 문제는 대역폭 추정 부재임. TCP 환경에서도 초기 대역폭 프로브와 전송 지연 감지를 통해 비트레이트를 조정할 수 있음
가능하다면 WebRTC를 쓰는 게 낫고, 방화벽 회피용으로는 WebSocket을 쓰는 게 좋음- 기사에 나온 폴링 코드에서는 이전 JPEG 다운로드가 끝나야 다음 요청을 보내는 구조였음. UDP가 없어도 이런 루프는 가능함
- 아마도 프레임을 완전히 직렬화해서 한 번에 하나씩만 요청하는 구조거나, 매번 새로운 GET 요청으로 새 연결을 여는 방식일 가능성이 큼
- 흥미로운 점은, 시각적으로 동일한 JPEG이라도 용량이 10~15% 수준으로 줄어들 수 있다는 것임. 2000년대 후반 웹 성능 최적화 작업을 하며 이런 효율 개선이 매우 보람찼음
-
글의 형식 문제나 LLM 스타일을 제쳐두더라도, 내용 전반이 잘못된 부분이 많음
10Mbps면 정적인 화면에는 충분해야 함. 문제는 인코딩 설정이 잘못됐거나 인코더 품질이 낮은 것임
“키프레임만 보내자”는 접근은 비효율적이며, 대신 짧은 keyframe 간격을 설정하면 됨
결국 문제는 TCP 단일 연결로 전체 스트림을 밀어 넣는 구조임. 이런 상황에 맞춘 DASH 같은 솔루션이 이미 존재함- AI가 쓴 글이 왜 상단에 오르는지 이해가 안 됨. 정성 없이 쓴 글을 읽는 건 시간 낭비라는 생각임
- Apple에서는 DASH가 지원되지 않음. HLS가 대안이 될 수 있지만, ffmpeg가 없으면 구현이 매우 힘듦
- 이 글은 오히려 LLM 스타일이 거의 느껴지지 않음. 근거 없는 비판은 설득력이 없음
- 기존 도구를 익히는 데 드는 시간과 비용이 크기 때문에, “잘못된 재발명”이라도 상황에 따라 더 효율적일 수 있음
-
VNC가 1998년부터 해온 방식을 참고하면 좋을 것 같음
클라이언트 풀 모델을 유지하면서 프레임버퍼를 타일 단위로 나누고, 변경된 부분만 전송하는 구조임
정적인 코딩 화면에서는 대역폭을 크게 줄일 수 있음. 스크롤 감지도 추가하면 더 효율적일 것임- 여러 제안 중 이게 가장 현실적인 출발점 같음. 40Mbps를 기본으로 잡은 건 문제 접근 자체가 잘못된 것 같음
- 글에서 미숙함이 느껴졌음. 이런 접근이 오픈소스로도 가능한지 궁금함
- neko 프로젝트를 먼저 살펴보길 권함. VNC보다 연결 지연과 백프레셔 문제를 훨씬 잘 처리함
- VNC 방식을 복제하는 게 가장 자연스러운 첫 시도일 것 같음. Moonlight처럼 게임용 저지연 솔루션을 쓰는 건 오히려 부적절함
-
예전에 비디오 인코딩을 다뤄봤는데, 40Mbps는 블루레이급 품질임
단순한 텍스트 스트리밍에는 과도함. Claude와 대화해본 결과, 30FPS, GOP 2초, 평균 1Mbps 정도면 충분하다는 결론이 나왔음
최악의 경우에도 1.2Mbps면 충분히 안정적인 품질을 유지할 수 있음 -
이 글의 핵심 문제는 h.264 최소 대역폭을 너무 높게 설정한 것임
H.264는 JPEG보다 훨씬 효율적임. 1Mbps부터 시작해 조정했어야 함
키프레임만 쓰는 건 오히려 비효율적임- 글에서 “10Mbps로 낮췄더니 30초 지연이 생겼다”고 했지만, 이는 인코딩 설정 문제일 가능성이 큼
- JPEG도 버퍼링을 통해 재생 대기열을 만들면 끊김 문제를 완화할 수 있음. 요즘 플레이어는 네트워크 품질을 실시간으로 감시함
-
나였다면 완전히 다르게 접근했을 것임
10Mbps는 과도하고, YouTube의 코딩 영상은 1080p에서도 0.6Mbps 수준임. 충분히 선명함
차라리 1fps로 줄이거나 keyframe 간격을 조정하는 게 낫다고 생각함- 글의 문체와 논리 전개가 LLM 냄새가 남. 코드도 비슷한 수준일 듯함
- 1fps로는 부족할 수 있음. 모든 프레임을 keyframe으로 만드는 설정이 필요함
- 하지만 어떤 사람에게는 YouTube 화질도 참을 수 없을 정도로 거슬릴 수 있음
-
브라우저로 실시간 비디오를 스트리밍하는 건 정말 고통스러운 일임
JPEG 스크린샷이 잘 작동한다면 그대로 두는 게 나음
gstreamer나 Moonlight 같은 스택은 백프레셔와 오류 전파를 이해하지 못하면 디버깅이 지옥임
NVIDIA Video Codec SDK + WebSocket + MediaSource Extensions 조합이 현실적인 대안임
하지만 글이 LLM 생성물이라면, 저자는 이런 내부 구조를 이해할 의지가 없을 것 같음- 이런 복잡한 시스템을 단일 목적으로 다뤄야 할 때는 오히려 LLM이 유용하게 쓰일 수 있음
-
예전에 5초마다 스크린샷을 찍는 프로그램을 썼는데, 하드디스크가 금방 가득 찼음
이미지 대부분이 동일하다는 걸 깨닫고, 변경된 부분만 저장하는 알고리즘을 고민하다가
결국 내가 비디오 압축을 재발명하고 있었다는 걸 깨달음
ffmpeg 한 줄로 해결했고, 저장 공간을 98% 절약했음 -
LLM이 타이핑하는 영상을 40Mbps로 스트리밍한다는 게 비정상적으로 과도한 대역폭임
- 게다가 60fps로 “컴퓨터가 타이핑하는 장면”을 본다는 것도 이상함. 문제 도메인을 전혀 이해하지 못한 접근 같음
-
HN에서 좋은 답변을 얻는 유일한 방법은 틀린 글을 올리는 것임
틀렸지만 흥미로운 글이야말로 토론을 이끌어내는 완벽한 균형을 맞춘 사례라고 생각함