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>
)}
이런 디버깅 정보가 없었다면 문제 파악이 훨씬 어려웠을 거다. 특히 프레임 정확도를 확인할 때 이런 실시간 정보가 생명이었다.
배운 것들: 아키텍처의 중요성
이번 경험에서 가장 큰 깨달음은 **“처음부터 제대로 분리해서 설계하자”**였다.
- 단일 책임 원칙: 하나의 컴포넌트는 하나의 역할만
- Sequence의 활용: Remotion의 최적화를 믿고 활용하기
- 디버깅 정보: 개발 단계에서 충분한 가시성 확보
- 점진적 개선: 한 번에 모든 걸 바꾸려 하지 말기
아직 남은 과제들
물론 모든 게 완벽하진 않다. 아직 해결해야 할 것들이 있다:
- 메모리 최적화: 비디오가 많아지면 여전히 메모리 이슈
- 프리로딩 개선: 첫 번째 비디오의 초기 로딩 시간
- 에러 핸들링: 비디오 파일이 없을 때의 graceful degradation
- 타입 안정성: 런타임에서의 config 검증
하지만 지금 상태도 이전보다 훨씬 낫다. 적어도 버퍼링 지옥에서는 탈출.
이런 비슷한 문제를 겪고 있다면? Remotion에서 복잡한 미디어 조합을 다룰 때는 처음부터 책임 분리를 염두에 두고 설계하는 게 좋다. 그리고 Sequence의 최적화 기능을 믿고 활용하자.