Never give up

High-Performance Camera Handling: FFmpeg Pipeline & Codec Hell 본문

Side project

High-Performance Camera Handling: FFmpeg Pipeline & Codec Hell

대기만성 개발자 2026. 6. 9. 10:40
반응형

이번에는 하드웨어의 냉혹한 현실에 대해 다루고자 합니다

 

화면도 없고 리소스가 제한된 기기에서 고화질 카메라 피드를 끊김 없이 뽑아내려면 하드웨어 프레임을 캡처하고

 

극도로 제한된 CPU 바운드 내에서 압축하여 실시간으로 WebRTC 미디어 파이프라인에 밀어 넣어야 합니

 

만약 인코딩 설정이 아주 조금이라도 어긋나면 기기의 CPU 점유율은 순식간에 100%를 찍어버리고

 

프레임 드랍, 발열 스로틀링, 그리고 커넥션 타임아웃등이 발생합니다

 

이 문제를 해결하기 위해 FFmpeg Child Processes, 플랫폼 독립적인 하드웨어 드라이버 추상화

 

그리고 초저지연 UDP 루프백을 이용해 오버헤드가 거의 없는 비디오 파이프라인을 구축한 과정을 공유해 보겠습니다

 

크로스 플랫폼 제약: 하드웨어 디바이스 노드의 추상화

로컬 서버를 다양한 환경에 배포할 때, 물리적인 카메라 주변기기와 인터페이스를 맞추는 것은 시작부터 큰 산이었습니다

(운영체제마다 카메라 하드웨어를 처리하는 커널 레이어와 포맷 파라미터가 완전히 다름)

 

이러한 디바이스 노드에서 압축되지 않은 원시 프레임(YUV나 MJPEG)을 직접 읽어올 수는 있지만

 

이를 WebRTC 피어 연결에 그대로 쏘아 보내는 것은 불가능합니다

 

WebRTC는 정확한 RTP패킷 안에 특정 압축 포맷이 담겨있어야 하고

 

타임스탬프, 컬러 스페이스, 페이로드 타입까지 아주 엄격하게 통제 됩니다

 

그렇다고 이 고빈도 프레임들을 Node.js의 메인 이벤트 루프 안에서 파싱하는 것은 범죄 행위(?)입니다

 

JS 단에서 무거운 행렬 변환이나 비디오 패킷 파싱을 시도하는 순간 이벤트 루프는 완전히 멈춰버리고

 

기기는 네트워크 heartbeat나 시그널링 메시지를 처리하지 못해 뻗어버릴 겁니다

 

해결책은 최적화된 네이티브 바이너리인 FFmpeg 자식 프로세스에 이 무거운 짐을 전부 떠넘기는 것이었죠

 

필자는 라즈베리 파이를 가지고있지 않아 우분투 노트북에서 전체 런타임을 검증했습니다

 

밑바닥 코드베이스가 OS별 argument(Windows, macOS, Linux)를 알아서 매끄럽게 스위칭해 주도록 격리해 둔 덕분에

 

이 파이프라인을 우분투 기반의 라즈베리 파이에 올렸을 때도 런타임 수정 단 한 줄 없이 작동가능합니다

 

코덱 미스매치 정복: H.264의 패배와 VP8의 승리

처음에는 하드웨어 가속 지원이 빵빵한 H.264(libx264)를 사용하려고 했습니다만

 

심각한 프레임 드롭과 블랙 스크린이 발생했습니다

 

WebRTC 시그널링 SDP negotiation은 분명 성공적으로 끝나는데

 

실제 미디어가 시작되자마자 검은 화면만 출력되더군요

 

근본적인 원인은 Node.js WebRTC 미디어 엔진과 들어오는 RTP 스트림 프로필 간의 엄격한 코덱 미스매칭이었습니다

 

웹 브라우저에서는 히든 레이어가 알아서 처리하지만

 

Node.js WebRTC 런타임은 H.264 프로필 변형이나 패킷화 불일치는 가차 없습니다

 

인코딩된 RTP 페이로드가 미디어 파이프라인이 예상한 값과 아주 미세하게라도 다르면

 

엔진은 크래시를 내지도 않고 그냥 패킷을 조용히 버려버립니다

(커넥션은 붙어있는데 비디오 화면만 깜깜하게 나오는 미칠 노릇인 상황이 되는 거죠)

 

이 삽질을 끝내고 크로스 플랫폼에서의 확실한 예측 가능성을 확보하기 위해(귀찮아서)

 

저는 전체 생태계를 VP8 코덱(libvpx)으로 통일하기로 전략적 결정을 내렸습니다

 

사실 H.264를 탐색할 때는 하드웨어 가속 프로필을 쓰는 게 인코딩 효율 면에서 무조건 우월해 보였습니다

 

하지만 깊이 있는 아키텍처 평가를 해보니 두 가지 거대한 장기적 지뢰가 숨어있더군요

  1. SDP 협상의 지옥화: 파편화가 극심한 브라우저 클라이언트 환경 전반에 걸쳐 명시적인 H.264 하드웨어 가속 profile-level-ids를 강제하는 것은, SDP 협상 로직을 유지보수 지옥
  2. 라이선스 및 저작권: 프로덕트 규모가 커질 때 H.264 인코더를 임베딩하면 엄격한 MPEG-LA 저작권료 및 라이선스 비용 의무가 발생

반면 WebRTC의 근본이자 오픈 소스인 VP8을 선택함으로써 협상 위험과 법적 리스크로부터 회피했습니다

(물론 소프트웨어 기반 인코딩이 기기 발열 한계를 넘지 않도록 FFmpeg 최적화 튜닝이 필요했습니다)

 

UDP 위에서 VP8 FFmpeg 파이프라인 구동하기

미디어 인코딩 오버헤드를 철저히 격리하기 위해 로컬 서버가 child_process.spawn을 이용해

 

최적화된 FFmpeg 프로세스를 실시간으로 관리하도록 설계했습니다

 

이때 raw 버퍼를 표준 I/O 스트림(Pipe)으로 넘기면 V8 엔진에 엄청난 가비지 컬렉션(GC) 부하를 주게 됩니다

 

이를 피하기 위해 패킷 크기가 고정된 초저지연 UDP 루프백 채널(127.0.0.1)을 활용하여

 

패킷화가 완료된 VP8 RTP 스트림을 Node.js 런타임으로 다이렉트 패스했습니다

[Physical Camera Hardware] 
              │
              ▼ (Dynamic Driver Abstraction)
       [FFmpeg Process] (Spawned via child_process)
              │  (VP8 Real-Time Encoding + RTP Packetization)
              ▼
   [UDP Loopback] (127.0.0.1:${this.UDP_PORT}?pkt_size=1200)
              │
              ▼
     [Node.js WebRTC Runtime] (media plane)
              │
              ▼  (Outbound Encrypted Stream)
       [Remote Browser]

 

통제된 스폰(Spawn) 패턴

시그널링 핸드셰이크가 끝나면 최적화된 동적 VP8 실행 파이프라인을 가동합니다.

import { spawn } from 'child_process';

// 현재 구동 중인 OS 플랫폼 명세를 다이렉트 추상화
const { formatDriver, formatParam, device } = getPlatformSpecs();

this.ffmpegProcess = spawn("ffmpeg", [
  "-loglevel", "error",       // 자질구레한 로그는 끄고, 치명적인 실패만 파이프

  "-f", formatDriver,         // 플랫폼별 드라이버 (예: v4l2, avfoundation 등)
  formatParam, "mjpeg",       // 입력 포맷 제약 조건
  "-video_size", "1280x720",  // 캡처 해상도 베이스라인 (720p)
  "-framerate", "30",         // 타겟 FPS 제한
  "-i", device,               // 동적 하드웨어 소스 노드 경로

  "-pix_fmt", "yuv420p",      // WebRTC 표준인 YUV420 Planar 컬러 스페이스 강제
  "-vcodec", "libvpx",        // 순수 VP8 인코딩 엔진 적용
  "-deadline", "realtime",    // 버퍼 없는 실시간 인코딩 강제
  "-cpu-used", "5",           // 속도 vs 화질 트레이드오프 (높을수록 CPU 소모 급감)
  "-g", "30",                 // 정기적인 키프레임 생성을 위한 GOP 사이즈 설정
  "-keyint_min", "30",        // IDR/Keyframe 사이의 최소 인터벌
  "-f", "rtp",                // 아웃풋 포맷을 raw RTP로 지정
  "-payload_type", "96",      // WebRTC 바인딩을 위한 동적 RTP 페이로드 타입 매핑
  `rtp://127.0.0.1:${this.UDP_PORT}?pkt_size=1200`, // 최적화된 로컬 UDP MTU 스트리밍
]);

WebRTC 안정성을 위한 하부 파라미터 미세 조정

FFmpeg의 로우 레벨 플래그들을 딥하게 파고들며 패킷 드롭을 지워버린 핵심 튜닝 포인트들입니다

  • -pix_fmt yuv420p: 카메라의 원시 MJPEG 스트림을 거부하고, YUV422을 YUV420샘플링으로 강제 전환해 주어야만 WebRTC 디코딩 정합성이 보장
  • -deadline realtime -cpu-used 5: VP8 인코더에게 무거운 공간 압축 제거, 압축 밀도에서 아주 미세한 손해를 보는 대신 CPU 오버헤드를 극적으로 낮춰 성능 및 발열 제어
  • -g 30 -keyint_min 30: 30프레임마다 엄격하게 키프레임을 강제 주입해서 무선 네트워크 환경에서 패킷 하나가 깨지더라도 화면이 길게 뭉개지는 현상을 막고, 1초 이내에 스트림 화질을 유지
  • pkt_size=1200: 나가는 RTP 패킷 크기를 1200 바이트로 제한. 일반적인 네트워크 MTU 한계선(1500 바이트)보다 넉넉히 낮추어, 네트워크 레이어에서의 IP 단편화를 원천 차단하고 패킷 손실과 지터를 줄임

 

초저지연 UDP 루프백 리스닝

루프백 채널의 반대편에서는 동적 로컬 포트에 바인딩된 전용 리스닝 소켓을 열고 대기합니다

 

FFmpeg이 원시 카메라 비트를 규격에 맞는 완벽한 VP8 RTP 페이로드(타입 96)로 압축하는

 

무거운 작업을 다 처리해 주기 때문에 Node.js의 이벤트루프가 뻗는 현상은 없어집니다

 

그저 패킷을 받아서 전달만 해주는 역할만 수행하면 되죠

import * as dgram from 'dgram';

const udpSocket = dgram.createSocket('udp4');

udpSocket.on('message', (rtpPacket) => {
  if (this.webRTCController.isConnected) {
    // 이미 패킷화가 완료된 VP8 RTP 프레임을 피어 커넥션 트랙에 그대로 다이렉트 라이팅
    this.videoTrack.writeRtp(rtpPacket);
  }
});

udpSocket.bind(this.UDP_PORT, '127.0.0.1');

이렇게 메인 Node.js 이벤트 루프는 아무런 방해를 받지 않습니다

 

빠른 로컬 UDP 포트로 패킷을 흡수해 WebRTC 전송 파이프라인으로 토스해 줄 뿐이죠

 

결과적으로 720p @ 30 FPS 스트림을 고정하면서도 200ms 미만의 초저지연과 프레임 드롭 0을 달성할 수 있었습니다

반응형
Comments