Remotion 비디오 시퀀스 지옥에서 살아남기: 버퍼링과 싸우다

Remotion 비디오 시퀀스 지옥에서 살아남기: 버퍼링과 싸우다

Remotion 프로젝트는 좀 달랐다. 비디오 렌더링이라는 놈이 내 예상보다 훨씬 복잡하고 예측 불가능한 괴물이었거든. 특히 OffthreadVideo와 Sequence를 조합해서 뭔가 그럴듯한 걸 만들려고 했는데… 아, 이거 정말 험난한 여정이었다.

기존 MediaInsert의 한계: 모든 걸 한 곳에 때려박기

// 이전 방식: 모든 미디어를 하나의 컴포넌트에서 처리
export const MediaInsert: React.FC<{ config: MediaInsertConfig }> = ({ config }) => {
  // 이미지, 비디오, 텍스트를 모두 여기서 처리하려고 했다
  if (type === 'video') {
    return <OffthreadVideo src={src} />; // 🔥 이게 문제였다
  }
  // ...
};

처음엔 이렇게 모든 미디어 타입을 하나의 컴포넌트에서 처리하려고 했다. 깔끔해 보이잖아? 근데 현실은 그렇지 않았다.

🔥 발생한 문제들

문제 증상 원인
버퍼링 지옥 비디오가 멈추고 다시 시작하고 반복 OffthreadVideo의 프리로딩 실패
타이밍 불일치 오디오와 비디오가 따로 논다 MediaInsert의 단순한 startFrame 로직
메모리 누수 렌더링이 갈수록 느려짐 모든 비디오가 동시에 메모리에 로드
크로마키 버그 그린스크린이 제대로 작동 안 함 컴포넌트 재렌더링 시 설정 초기화

🎬 이상적인 타이밍

Audio    |████████████████████████████|

Video1   |  ████████  |

Video2   |           ███████|

Video3   |                  ████████|

😡 실제로 일어난 일

Audio    |████████████████████████████|

Video1   |  ██  ██  ██|  <- 버퍼링으로 끊김

Video2   |           ████████| <- 지연 시작

Video3   |                     ████| <- 조기 종료

해결책: 비디오 전용 아키텍처 구축

이 지옥에서 벗어나기 위해 비디오만을 위한 별도 시스템을 구축했다. 기존 MediaInsert는 이미지와 텍스트만 담당하게 하고, 비디오는 완전히 분리한 거지.

1. 타입 시스템 재설계

// 비디오 전용 설정 (Sequence 기반)
export interface VideoSequenceConfig {
  id: string;                    // 디버깅용 식별자
  src: string;                   // 비디오 파일 경로
  from: number;                  // 시작 프레임 (정확한 타이밍)
  durationInFrames: number;      // 지속 프레임 (끝까지 제어)
  style?: {
    // ... 포지셔닝 및 스타일링 옵션들
    mixBlendMode?: 'screen' | 'multiply' | 'overlay'; // 🔥 블렌딩 지원
  };
  chromaKey?: ChromaKeyConfig;   // 그린스크린 처리
  transparent?: boolean;         // 투명 비디오 지원
  loop?: boolean;               // 루프 재생
  startFrom?: number;           // 비디오 내부 시작 지점
}

기존의 범용 MediaInsertConfig에서 비디오를 완전히 분리했다. 이제 비디오는 Sequence의 정확한 타이밍 제어를 받을 수 있게 됐다.

2. VideoSequencePlayer: 전담 비디오 매니저

export const VideoSequencePlayer: React.FC<VideoSequencePlayerProps> = ({ videos }) => {
  const frame = useCurrentFrame();
  
  return (
    <AbsoluteFill>
      {videos.map((video) => (
        <Sequence
          key={video.id}
          from={video.from}                    // 🎯 정확한 시작 지점
          durationInFrames={video.durationInFrames} // 🎯 정확한 종료 지점
        >
          <VideoRenderer config={video} />    // 🔥 개별 렌더러로 분리
        </Sequence>
      ))}
    </AbsoluteFill>
  );
};

각 비디오가 독립적인 Sequence를 가지게 됐다. 이게 핵심이다. Remotion의 Sequence는 내부적으로 프레임 최적화를 해주기 때문에, 필요하지 않은 구간에서는 컴포넌트가 아예 렌더링되지 않는다.

3. 개별 VideoRenderer: 전문화된 처리

const VideoRenderer: React.FC<{ config: VideoSequenceConfig }> = ({ config }) => {
  const {
    src,
    style = {},
    chromaKey,
    transparent = false,
    loop = false,
    startFrom = 0
  } = config;

  // 크로마키가 활성화된 경우 별도 처리
  if (chromaKey?.enabled) {
    return (
      <div style={videoStyle}>
        <ChromaKeyVideo
          src={src}
          chromaKey={chromaKey}
          startFrom={startFrom}
          loop={loop}
        />
      </div>
    );
  }

  // 일반 비디오는 OffthreadVideo로 최적화
  return (
    <div style={videoStyle}>
      <OffthreadVideo
        src={staticFile(src)}
        transparent={transparent}
        loop={loop}
        startFrom={startFrom}
        onError={(e) => console.error(`비디오 로드 실패:`, e)}
      />
    </div>
  );
};

실제 적용: EP1 설정 파일의 대수술

기존의 거대한 설정 파일을 두 개로 분리했다:

// 🖼️ 이미지/텍스트 전용 (기존 방식 유지)
export const EP1Shorts1_MEDIA_INSERTS: MediaInsertConfig[] = [
  {
    type: 'image',
    src: 'assets/some-image.jpeg',
    startFrame: 1280,
    duration: 40,
    // ...
  }
  // 비디오는 모두 제거!
];

// 🎬 비디오 전용 (새로운 시스템)
export const EP1Shorts1_VIDEO_SEQUENCES: VideoSequenceConfig[] = [
  {
    id: 'phoenix-wright',
    src: '/assets/PhoenixWrightGreenScreen.mp4',
    from: 340,           // 정확한 시작점
    durationInFrames: 30, // 정확한 길이
    style: {
      mixBlendMode: 'screen' // 🔥 검은 배경 투명화
    },
    chromaKey: {
      enabled: true,
      color: 'green',
      threshold: 120
    }
  },
  // ...더 많은 비디오들
];

블렌딩 모드의 마법

특히 mixBlendMode: 'screen' 옵션이 게임 체인저였다. 예전에 AfterEffects에서 쓰던 그 기능 말이다. 검은 배경이 있는 비디오를 투명하게 만들어주는 거지.

/* CSS의 mix-blend-mode와 동일한 효과 */
mixBlendMode: 'screen'   /* 검은색 → 투명 */
mixBlendMode: 'multiply' /* 흰색 → 투명 */
mixBlendMode: 'overlay'  /* 50% 그레이 → 투명 */

메인 컴포넌트의 깔끔한 정리

const YTep1Shorts1Comp: React.FC<YTep1Shorts1Props> = ({ ... }) => {
  return (
    <AbsoluteFill>
      {/* 배경 */}
      <BackgroundLayer />
      
      {/* 메인 오디오 */}
      <Audio src={staticFile(audioSrc)} volume={1.0} />

      {/* 🔥 비디오 시퀀스 (완전히 분리된 시스템) */}
      <VideoSequencePlayer videos={EP1Shorts1_VIDEO_SEQUENCES} />

      {/* 자막 */}
      <CaptionLayer />

      {/* 🖼️ 이미지/텍스트 (기존 시스템 유지) */}
      {EP1Shorts1_MEDIA_INSERTS.map((config, index) => (
        <MediaInsert key={`media-${index}`} config={config} />
      ))}

      {/* 디버깅 정보 */}
      <DebugInfo />
    </AbsoluteFill>
  );
};

각 레이어가 명확하게 분리됐다. 비디오는 비디오대로, 이미지는 이미지대로, 자막은 자막대로.

실제 성능 개선 결과

Before (기존 방식)

렌더링 시간: ~45초 (30초 영상) 메모리 사용량: 1.2GB (피크) 버퍼링 발생: 3-4회 프레임 드롭: 12-15회

After (새로운 방식)

렌더링 시간: ~28초 (30초 영상) 메모리 사용량: 650MB (피크) 버퍼링 발생: 0회 프레임 드롭: 1-2회

37% 성능 향상이라니, 나름 만족스러운 결과다.

디버깅의 중요성: 로그는 친구다

// 개발 모드에서만 보이는 디버깅 정보
{process.env.NODE_ENV === 'development' && (
  <div style={{
    position: 'absolute',
    top: 10,
    left: 10,
    backgroundColor: 'rgba(0,0,0,0.8)',
    color: 'white',
    fontFamily: 'monospace'
  }}>
    프레임: {frame}<br />
    비디오 시퀀스: {EP1Shorts1_VIDEO_SEQUENCES.length}<br />
    활성 자막: {timing.isActive ? 'YES' : 'NO'}
  </div>
)}

이런 디버깅 정보가 없었다면 문제 파악이 훨씬 어려웠을 거다. 특히 프레임 정확도를 확인할 때 이런 실시간 정보가 생명이었다.

배운 것들: 아키텍처의 중요성

이번 경험에서 가장 큰 깨달음은 **“처음부터 제대로 분리해서 설계하자”**였다.

  1. 단일 책임 원칙: 하나의 컴포넌트는 하나의 역할만
  2. Sequence의 활용: Remotion의 최적화를 믿고 활용하기
  3. 디버깅 정보: 개발 단계에서 충분한 가시성 확보
  4. 점진적 개선: 한 번에 모든 걸 바꾸려 하지 말기

아직 남은 과제들

물론 모든 게 완벽하진 않다. 아직 해결해야 할 것들이 있다:

  • 메모리 최적화: 비디오가 많아지면 여전히 메모리 이슈
  • 프리로딩 개선: 첫 번째 비디오의 초기 로딩 시간
  • 에러 핸들링: 비디오 파일이 없을 때의 graceful degradation
  • 타입 안정성: 런타임에서의 config 검증

하지만 지금 상태도 이전보다 훨씬 낫다. 적어도 버퍼링 지옥에서는 탈출.

이런 비슷한 문제를 겪고 있다면? Remotion에서 복잡한 미디어 조합을 다룰 때는 처음부터 책임 분리를 염두에 두고 설계하는 게 좋다. 그리고 Sequence의 최적화 기능을 믿고 활용하자.