FFmpeg 서버사이드 영상 합성
포토부스 앱을 생각해보자. 사용자가 웹캠으로 4장의 영상을 촬영하면, 이걸 하나의 "네컷 사진" 영상으로 합성해야 한다. 브라우저에서 Canvas API로 실시간 합성을 할 수도 있지만, 최종 결과물을 깔끔하게 인코딩하고 프레임 이미지를 씌우는 작업은 서버에서 FFmpeg로 처리하는 게 훨씬 안정적이다.
이 글에서는 Node.js 서버에서 FFmpeg를 프로그래밍적으로 제어하는 방법을 정리한다. 특히 fluent-ffmpeg 라이브러리를 사용해서 여러 영상을 하나로 합성하는 complex filter 구성에 초점을 맞춘다.
FFmpeg란
FFmpeg는 영상/음성 변환의 스위스 아미 나이프다. 거의 모든 포맷의 미디어 파일을 읽고, 변환하고, 합성할 수 있는 CLI 도구이자 라이브러리다. 보통 터미널에서 이런 식으로 쓴다:
ffmpeg -i input.mp4 -vf "scale=1280:720" output.mp4
문제는 이걸 Node.js 서버에서 동적으로 실행해야 할 때다. 사용자마다 다른 입력 파일, 다른 레이아웃, 다른 옵션이 필요한데, 쉘 명령어를 문자열로 조합하면 유지보수가 지옥이 된다. 인젝션 위험도 있고.
fluent-ffmpeg
fluent-ffmpeg는 FFmpeg CLI를 Node.js의 빌더 패턴으로 감싼 라이브러리다. 명령어를 문자열로 조합하는 대신 메서드 체이닝으로 구성할 수 있다.
npm install fluent-ffmpeg
npm install -D @types/fluent-ffmpeg # TypeScript 타입
npm install -D ffmpeg-static # 시스템에 FFmpeg 없을 때
기본 사용법
import Ffmpeg from 'fluent-ffmpeg';
// 단순 변환
Ffmpeg('input.mp4')
.outputOptions(['-c:v libx264', '-crf 23'])
.output('output.mp4')
.on('end', () => console.log('완료'))
.on('error', (err) => console.error(err))
.run();
CLI에서 ffmpeg -i input.mp4 -c:v libx264 -crf 23 output.mp4를 실행하는 것과 동일하다. 차이점은 에러 핸들링, 진행률 콜백, 동적 옵션 구성이 자연스럽다는 것이다.
FFmpeg 바이너리 경로 설정
서버 환경에 따라 FFmpeg 바이너리 위치가 다를 수 있다. 세 가지 전략이 있다:
// 1. 환경 변수로 지정
if (process.env.FFMPEG_PATH) {
Ffmpeg.setFfmpegPath(process.env.FFMPEG_PATH);
}
// 2. ffmpeg-static 패키지 사용 (개발 환경)
const ffmpegStatic = require('ffmpeg-static');
Ffmpeg.setFfmpegPath(ffmpegStatic);
// 3. 시스템 PATH에 있으면 별도 설정 불필요
ffmpeg-static은 플랫폼별 FFmpeg 바이너리를 npm 패키지로 제공한다. 개발 환경에서 편하지만, 프로덕션에서는 시스템에 설치된 FFmpeg를 쓰는 게 낫다. 버전 관리가 더 명확하고 바이너리 크기도 줄일 수 있다.
우선순위를 두면 좋다: 환경 변수 → ffmpeg-static → 시스템 PATH 순으로 탐색.
ffprobe로 영상 정보 추출
영상을 합성하려면 먼저 각 입력 영상의 해상도, FPS, 길이 같은 메타데이터를 알아야 한다. FFmpeg에 내장된 ffprobe 도구가 이걸 해준다.
interface VideoInfo {
duration: number; // 초
width: number;
height: number;
fps: number;
codec: string;
bitrate?: number;
}
async function getVideoInfo(filePath: string): Promise<VideoInfo> {
return new Promise((resolve, reject) => {
Ffmpeg.ffprobe(filePath, (err, metadata) => {
if (err) {
reject(new Error(`Failed to probe video: ${err.message}`));
return;
}
const videoStream = metadata.streams.find(
(s) => s.codec_type === 'video'
);
if (!videoStream) {
reject(new Error('No video stream found'));
return;
}
// 프레임 레이트 파싱: "30/1" 형태로 올 수 있음
let fps = 30;
if (videoStream.r_frame_rate) {
const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
fps = den ? num / den : num;
}
resolve({
duration: metadata.format.duration || 0,
width: videoStream.width || 0,
height: videoStream.height || 0,
fps: Math.round(fps),
codec: videoStream.codec_name || 'unknown',
bitrate: metadata.format.bit_rate
? parseInt(String(metadata.format.bit_rate), 10)
: undefined,
});
});
});
}
ffprobe가 반환하는 metadata에는 streams와 format 두 객체가 있다. streams는 비디오/오디오 스트림 각각의 정보, format은 컨테이너 레벨 정보(전체 길이, 비트레이트 등)다. 영상에 여러 스트림이 있을 수 있으므로 codec_type === 'video'로 비디오 스트림만 필터링한다.
프레임 레이트가 "30000/1001" 같은 분수 형태로 오는 경우가 있다(NTSC 호환 29.97fps). 그래서 분자/분모를 나눠 계산한 뒤 Math.round()로 정수화한다.
Complex Filter: 여러 영상 합성
FFmpeg의 진짜 힘은 complex filter에 있다. 여러 입력을 받아서 크기 조절, 위치 배치, 오버레이를 한 번에 처리할 수 있다.
개념
일반 필터(-vf)는 단일 입력 → 단일 출력만 가능하다. 여러 영상을 합치려면 -filter_complex를 써야 한다.
input0 ──→ [scale] ──→ [overlay] ──→ output
input1 ──→ [scale] ──┘
background ──────────┘
각 단계에 라벨을 붙여서 연결한다. [0:v]는 첫 번째 입력의 비디오 스트림, [bg]는 내가 만든 배경 같은 식이다.
네컷 영상 합성 예시
4개의 웹캠 영상을 2×2 그리드로 배치하고, 위에 프레임 이미지를 씌우는 filter를 만들어보자.
[영상0] [영상1] 프레임 이미지
[영상2] [영상3] + (투명 PNG) = 최종 결과
이걸 FFmpeg filter_complex 문법으로 표현하면:
[0:v]scale=320:240,hflip[p0]; # 입력0: 크기 조절 + 좌우 반전
[1:v]scale=320:240,hflip[p1]; # 입력1
[2:v]scale=320:240,hflip[p2]; # 입력2
[3:v]scale=320:240,hflip[p3]; # 입력3
[4:v]scale=640:480[frame]; # 프레임 이미지
color=black:640x480:d=1:r=30,format=yuv420p[bg]; # 검정 배경
[bg][p0]overlay=0:0[v0]; # 배경에 영상0 배치
[v0][p1]overlay=320:0[v1]; # 영상1 배치
[v1][p2]overlay=0:240[v2]; # 영상2 배치
[v2][p3]overlay=320:240[v_final]; # 영상3 배치
[v_final][frame]overlay=0:0[out] # 프레임 오버레이
각 단계 상세 설명
1단계: 입력 스케일링
[0:v]scale=320:240,hflip[p0]
[0:v]: 첫 번째 입력 파일의 비디오 스트림scale=320:240: 지정 크기로 리사이즈hflip: 좌우 반전 (웹캠은 거울상이므로)[p0]: 이 결과에 "p0" 라벨을 붙임
웹캠 영상은 보통 거울상(mirrored)으로 녹화된다. 셀카처럼 보는 건 괜찮지만 결과물에서는 원래 방향으로 돌려놓는 게 자연스럽다. 그래서 hflip을 적용한다. 물론 이건 선택적이다.
2단계: 배경 생성
color=black:640x480:d=1:r=30,format=yuv420p[bg]
color=black: 검정색 단색 영상 생성640x480: 최종 출력 해상도d=1: 길이 1초 (overlay에서 shortest 옵션 안 쓰면 입력 길이에 맞춰짐)r=30: 30fpsformat=yuv420p: 호환성을 위한 픽셀 포맷 강제
왜 배경을 만들어야 할까? overlay 필터는 항상 "위에 올리는" 동작이라, 밑바탕이 필요하다. 빈 캔버스를 만들어두고 그 위에 하나씩 쌓아가는 구조다.
format=yuv420p은 거의 모든 플레이어/브라우저에서 재생 가능한 픽셀 포맷이다. 이걸 빼먹으면 일부 환경에서 재생이 안 될 수 있다.
3단계: 순차적 오버레이
[bg][p0]overlay=0:0[v0];
[v0][p1]overlay=320:0[v1];
- 첫 번째
overlay: 배경 위에 p0을 (0,0)에 배치 - 두 번째
overlay: 이전 결과 위에 p1을 (320,0)에 배치
overlay 필터는 항상 2개의 입력을 받는다: 밑에 깔리는 것(main)과 위에 올리는 것(overlay). 그래서 여러 영상을 배치하려면 순차적으로 쌓아야 한다. 한 번에 4개를 올릴 수 없다.
각 overlay의 x, y 좌표가 그리드 위치를 결정한다:
- (0, 0): 좌상단
- (320, 0): 우상단
- (0, 240): 좌하단
- (320, 240): 우하단
4단계: 프레임 오버레이
[v_final][frame]overlay=0:0[out]
모든 영상이 배치된 결과 위에 프레임 이미지(투명 PNG)를 전체 화면 크기로 씌운다. 이 프레임에 장식, 테두리, 로고 같은 디자인 요소가 들어간다.
코드로 구현
filter 문자열을 동적으로 생성하는 함수:
interface CompositionInput {
videoPath: string;
x: number; // overlay x 좌표
y: number; // overlay y 좌표
width: number; // 스케일 후 너비
height: number; // 스케일 후 높이
flipHorizontal?: boolean;
}
interface CompositionOptions {
outputWidth: number;
outputHeight: number;
fps: number;
backgroundColor: string;
framePath?: string;
}
function buildCompositionFilter(
inputs: CompositionInput[],
options: CompositionOptions,
hasFrame: boolean,
): { filters: string[]; outputLabel: string } {
const { outputWidth, outputHeight, fps } = options;
const filters: string[] = [];
// 1. 각 비디오 스케일 + 반전
inputs.forEach((input, i) => {
const flip = input.flipHorizontal ? ',hflip' : '';
filters.push(
`[${i}:v]scale=${input.width}:${input.height}${flip}[p${i}]`
);
});
// 2. 프레임 이미지 스케일
if (hasFrame) {
const frameIndex = inputs.length;
filters.push(
`[${frameIndex}:v]scale=${outputWidth}:${outputHeight}[frame]`
);
}
// 3. 배경 생성
filters.push(
`color=black:${outputWidth}x${outputHeight}:d=1:r=${fps},format=yuv420p[bg]`
);
// 4. 순차 오버레이
let prev = '[bg]';
inputs.forEach((input, i) => {
const isLast = i === inputs.length - 1;
const nextLabel = isLast
? (hasFrame ? '[v_final]' : '[out]')
: `[v${i}]`;
filters.push(
`${prev}[p${i}]overlay=${input.x}:${input.y}${nextLabel}`
);
prev = `[v${i}]`;
});
// 5. 프레임 오버레이
if (hasFrame) {
filters.push(`[v_final][frame]overlay=0:0[out]`);
}
return { filters, outputLabel: 'out' };
}
핵심은 prev 변수로 이전 단계의 출력 라벨을 추적하는 것이다. 각 overlay의 결과가 다음 overlay의 입력이 되는 체이닝 구조다.
fluent-ffmpeg로 실행
빌드한 filter를 complexFilter()에 넘기고 출력 옵션을 설정한다:
async function composeVideos(
inputs: CompositionInput[],
options: CompositionOptions,
outputPath: string,
): Promise<void> {
return new Promise((resolve, reject) => {
const command = Ffmpeg();
// 모든 비디오 입력 추가
inputs.forEach((input) => {
command.input(input.videoPath);
});
// 프레임 이미지 입력 추가
if (options.framePath) {
command.input(options.framePath);
}
// filter_complex 빌드
const filter = buildCompositionFilter(
inputs, options, !!options.framePath
);
command
.complexFilter(filter.filters, filter.outputLabel)
.outputOptions([
'-c:v libx264', // H.264 코덱
'-preset medium', // 인코딩 속도/품질 균형
'-crf 23', // 품질 (낮을수록 고품질)
'-pix_fmt yuv420p', // 브라우저 호환 포맷
`-r ${options.fps}`, // 출력 FPS
'-movflags +faststart', // 웹 스트리밍 최적화
'-an', // 오디오 제거
])
.output(outputPath)
.on('start', (cmd) => {
console.log(`FFmpeg command: ${cmd}`);
})
.on('progress', (progress) => {
if (progress.percent) {
console.log(`Processing: ${progress.percent.toFixed(1)}%`);
}
})
.on('error', (err) => {
reject(new Error(`Video composition failed: ${err.message}`));
})
.on('end', () => resolve())
.run();
});
}
출력 옵션 상세
| 옵션 | 설명 |
|---|---|
-c:v libx264 | H.264 코덱. 사실상 웹 영상의 표준 |
-preset medium | 인코딩 속도와 압축률의 트레이드오프. ultrafast~veryslow 중 선택 |
-crf 23 | Constant Rate Factor. 0(무손실) |
-pix_fmt yuv420p | 4:2:0 크로마 서브샘플링. 모든 브라우저/플레이어 호환 |
-movflags +faststart | moov atom을 파일 앞으로 이동. 다운로드 완료 전 재생 시작 가능 |
-an | 오디오 스트림 제거 |
preset과 crf의 관계: preset은 "같은 품질을 달성하는 데 얼마나 시간을 쓸 것인가"를 결정한다. medium은 합리적인 기본값이다. 서버 CPU가 충분하면 slow로, 빠른 응답이 필요하면 fast로 조절한다. crf가 같으면 preset을 바꿔도 시각적 품질은 거의 동일하고, 파일 크기만 약간 달라진다.
faststart가 중요한 이유: MP4 파일은 기본적으로 메타데이터(moov atom)가 파일 끝에 위치한다. 브라우저가 영상을 재생하려면 이 메타데이터를 먼저 읽어야 하는데, 파일 끝에 있으면 전체를 다운로드할 때까지 재생이 시작되지 않는다. +faststart는 인코딩 후 moov atom을 파일 앞으로 옮겨서 프로그레시브 재생을 가능하게 한다.
임시 파일 관리
서버에서 영상 처리를 하면 임시 파일이 쌓인다. 입력 파일 다운로드, 중간 결과물, 최종 결과물 등. 이걸 제대로 관리하지 않으면 디스크가 꽉 찬다.
import { promises as fs } from 'fs';
import * as path from 'path';
import { randomUUID } from 'crypto';
class TempFileUtil {
private readonly tempDir: string;
private readonly createdFiles = new Set<string>();
private readonly createdDirs = new Set<string>();
constructor(tempDir: string) {
this.tempDir = tempDir;
}
async ensureDir(): Promise<void> {
await fs.mkdir(this.tempDir, { recursive: true });
}
// 세션별 독립된 디렉토리
async createSessionDir(sessionId: string): Promise<string> {
const dir = path.join(this.tempDir, sessionId);
await fs.mkdir(dir, { recursive: true });
this.createdDirs.add(dir);
return dir;
}
// 유니크한 임시 파일 경로 생성
getTempFilePath(ext: string, prefix = 'temp'): string {
const filename = `${prefix}-${randomUUID()}${ext}`;
const filePath = path.join(this.tempDir, filename);
this.createdFiles.add(filePath);
return filePath;
}
// 추적된 모든 파일/디렉토리 정리
async cleanup(): Promise<void> {
for (const file of this.createdFiles) {
try { await fs.unlink(file); } catch { /* 이미 삭제됨 */ }
}
for (const dir of this.createdDirs) {
try { await fs.rm(dir, { recursive: true }); } catch { /* 무시 */ }
}
this.createdFiles.clear();
this.createdDirs.clear();
}
}
핵심 패턴은 생성한 파일을 Set으로 추적하는 것이다. 작업이 끝나면 cleanup()으로 한 번에 정리한다. 세션별로 디렉토리를 분리하면 동시에 여러 요청을 처리할 때 파일이 섞이지 않는다.
에러가 발생해도 cleanup이 실행되어야 하므로, 호출부에서 try-finally로 감싸는 게 안전하다:
const tempUtil = new TempFileUtil('/tmp/video-processing');
try {
await tempUtil.ensureDir();
const outputPath = tempUtil.getTempFilePath('.mp4', 'composed');
await composeVideos(inputs, options, outputPath);
// outputPath를 S3에 업로드 등
} finally {
await tempUtil.cleanup();
}
타임아웃 처리
영상 처리는 시간이 오래 걸릴 수 있다. 무한히 기다릴 수는 없으니 타임아웃을 설정해야 한다.
const TIMEOUT_MS = 600_000; // 10분
const command = Ffmpeg();
// ... 입력, 필터 설정 ...
const timer = setTimeout(() => {
command.kill('SIGKILL');
reject(new Error('Video composition timeout'));
}, TIMEOUT_MS);
command
.on('end', () => {
clearTimeout(timer);
resolve();
})
.on('error', (err) => {
clearTimeout(timer);
reject(err);
})
.run();
command.kill('SIGKILL')로 FFmpeg 프로세스를 강제 종료한다. SIGTERM을 먼저 보내고 일정 시간 후 SIGKILL을 보내는 graceful shutdown도 가능하지만, 영상 처리가 타임아웃됐다는 건 이미 비정상 상황이므로 즉시 종료가 낫다.
이벤트와 진행률
fluent-ffmpeg는 여러 이벤트를 제공한다:
command
.on('start', (commandLine) => {
// 실행될 FFmpeg 명령어 전체를 볼 수 있음
// 디버깅에 매우 유용
logger.debug(`FFmpeg: ${commandLine}`);
})
.on('progress', (progress) => {
// progress.percent: 0~100
// progress.currentFps: 현재 처리 FPS
// progress.timemark: 처리된 시간 위치
logger.debug(`${progress.percent?.toFixed(1)}%`);
})
.on('stderr', (line) => {
// FFmpeg의 stderr 출력 (상세 로그)
})
.on('error', (err, stdout, stderr) => {
// stderr에 에러 원인이 담겨 있는 경우가 많음
logger.error(`FFmpeg error: ${err.message}`);
logger.error(`stderr: ${stderr}`);
})
.on('end', () => {
logger.log('Complete');
});
start 이벤트가 가장 유용하다. 여기서 출력되는 명령어를 터미널에 복붙해서 직접 실행해보면 디버깅이 훨씬 쉬워진다. complex filter가 복잡해질수록 이 디버깅 방법이 빛을 발한다.
NestJS 통합
NestJS에서는 이 로직을 모듈로 구조화한다:
// ffmpeg.config.ts
import { registerAs } from '@nestjs/config';
export default registerAs('ffmpeg', () => ({
ffmpegPath: process.env.FFMPEG_PATH,
ffprobePath: process.env.FFPROBE_PATH,
tempDir: process.env.FFMPEG_TEMP_DIR || '/tmp/video-processing',
timeout: parseInt(process.env.FFMPEG_TIMEOUT || '600000', 10),
}));
// ffmpeg.module.ts
@Module({
imports: [ConfigModule.forFeature(ffmpegConfig)],
providers: [FFmpegService],
exports: [FFmpegService],
})
export class FFmpegModule {}
registerAs로 네임스페이스 분리된 설정을 만들고, 서비스에서 @Inject(ffmpegConfig.KEY)로 타입 안전하게 주입받는다. 환경 변수 파싱이 한 곳에 집중되어 있어서 관리가 쉽다.
서비스는 OnModuleInit을 구현해서 앱 시작 시 FFmpeg 경로 설정과 임시 디렉토리 생성을 자동으로 처리한다.
실전 팁
입력 순서가 중요하다
FFmpeg에서 [0:v], [1:v]의 숫자는 input() 호출 순서와 일치한다. 프레임 이미지를 마지막에 추가하면 인덱스가 inputs.length가 된다. 순서가 틀리면 엉뚱한 영상이 잘못된 위치에 배치된다.
overlay의 shortest 옵션
기본적으로 overlay는 모든 입력이 끝날 때까지 실행된다. 입력 영상들의 길이가 다르면 짧은 영상이 끝난 후 검정 화면이 나올 수 있다. overlay=0:0:shortest=1로 가장 짧은 입력에 맞출 수 있다.
디버깅: filter_complex 시각화
filter가 복잡해지면 데이터 흐름을 그려보는 게 도움이 된다:
input0 → [scale,hflip] → [p0] ─────────────────┐
input1 → [scale,hflip] → [p1] ──────────────┐ │
input2 → [scale,hflip] → [p2] ───────────┐ │ │
input3 → [scale,hflip] → [p3] ────────┐ │ │ │
frame → [scale] → [frame] ──┐ │ │ │ │
│ │ │ │ │
color=black → [bg] ──────────────────┼──┼──┼──┼───┤
│ │ │ │ │
overlay(bg+p0) → [v0] ──────────────┼──┼──┼──┘ │
overlay(v0+p1) → [v1] ──────────────┼──┼──┘ │
overlay(v1+p2) → [v2] ──────────────┼──┘ │
overlay(v2+p3) → [v_final] ─────────┤ │
overlay(v_final+frame) → [out] ─────┘ │
메모리 주의
FFmpeg 프로세스는 입력 수와 해상도에 비례해서 메모리를 사용한다. 4개의 1080p 영상을 합성하면 수 GB의 메모리를 사용할 수 있다. 동시 요청 수를 제한하는 큐 시스템을 도입하는 게 좋다.
왜 서버사이드 합성인가
브라우저에서도 Canvas API + captureStream() + MediaRecorder로 영상 합성이 가능하다. 하지만 서버사이드 FFmpeg와 비교하면 트레이드오프가 명확하다.
브라우저 합성은 서버 비용이 없고 실시간 미리보기가 자연스럽지만, 코덱 지원이 제한적이고 탭이 닫히면 작업이 중단된다. 인코딩 품질도 FFmpeg에 비해 떨어지고, 모바일 디바이스에서는 성능 문제가 심각하다. FFmpeg는 거의 모든 코덱/포맷을 지원하고, complex filter로 정교한 레이아웃 합성이 가능하며, 결과물의 품질과 일관성이 보장된다. 대신 서버 CPU 리소스를 소비하고 결과물을 받기까지 대기 시간이 발생한다.
결론적으로, 실시간 미리보기는 브라우저 Canvas로, 최종 결과물 인코딩은 서버 FFmpeg로 처리하는 하이브리드 구성이 가장 현실적이다.
정리
- 브라우저 Canvas는 미리보기용, 최종 인코딩은 서버 FFmpeg로 분리하는 게 품질과 안정성 모두에서 유리하다
- complex filter의 핵심은 라벨 기반 체이닝이고, overlay는 항상 2-입력이라 순차적으로 쌓아야 한다
- 임시 파일 추적(Set) + try-finally cleanup, 타임아웃 kill, faststart 플래그는 프로덕션 필수 설정이다
관련 문서
- Canvas captureStream - 브라우저에서 Canvas → MediaStream
- MediaRecorder API - 브라우저에서 영상 녹화
- S3 Presigned URL - 합성된 영상을 S3에 업로드