FFmpeg란?
FFmpeg는 멀티미디어 데이터(비디오, 오디오 등)의 디코딩, 인코딩, 변환, 스트리밍, 필터링 등을 지원하는 강력한 오픈 소스 라이브러리입니다. 다양한 포맷을 지원하며, 명령줄에서 제어할 수 있어 영상 및 오디오 처리에 널리 사용됩니다. FFmpeg의 주요 장점 중 하나는 거의 모든 멀티미디어 파일을 변환할 수 있다는 점이며, 고성능의 비디오 및 오디오 처리가 가능합니다.
FFmpeg is the leading multimedia framework, able to decode, encode, transcode, mux, demux, stream, filter and play pretty much anything that humans and machines have created. It supports the most obscure ancient formats up to the cutting edge. No matter if they were designed by some standards committee, the community or a corporation. It is also highly portable: FFmpeg compiles, runs, and passes our testing infrastructure FATE across Linux, Mac OS X, Microsoft Windows, the BSDs, Solaris, etc. under a wide variety of build environments, machine architectures, and configurations.
[참고] https://www.ffmpeg.org/about.html
멀티미디어 데이터(비디오, 오디오 등) 다루기
이전 회사에서 영상 업로드를 구현할 일이 있었다.
기존에 사진 파일을 AWS S3에 저장하여 사용하고 있었어서 영상도 리사이징해서 S3에 저장하면 되겠다는 단순한 생각으로 작업을 시작하였다가 영상 인코딩 관련해서 꽤 애를 먹었다.
가장 큰 문제는 용량과 확장자였다. 테스트로 찍어본 30초짜리 영상도 100MB가 훌쩍 넘어갔다. 영상이 길어지면 용량은 비례하여 커지는데 용량을 줄이기 위해 영상 길이에 제한을 두기에는 서비스(개인 트레이닝 운동) 특성 상 불가능하였고, 용량 상관없이 업로드하고 서버에서 압축하여 용량을 조절하는 걸로 정해졌다. 물론 이전 회사의 개발팀은 단 2명이고, 백엔드는 나혼자였으므로 모든 건 나의 결정이었지만 말이다. 그래서 결국 구현 후 서비스를 어느 정도 해보고 300MB 제한을 두었다. 서버 한대에서 api는 물론 스케줄링 잡, 이미지 리사이징, 영상 인코딩 등을 모두 돌리기엔 무리가 있었고, 극 초기 스타트업에 서비스 론칭한 지도 얼마 안 돼서 서버를 늘리거나 할 여건이 되지 않았다.
이미지 리사이징에 사용하던 sharp 라이브러리를 이용하면 되겠지 싶어서 확장자는 mp4로 설정하는데 에러가 났다.
sharp 라이브러리를 확인해보니 ‘images’ 변환 하는 모듈이었다. 분명 이미지 변환하는 라이브러리인 걸 알고 있었지만 망각해 버리고 만 것이다.
The typical use case for this high speed Node-API module is to convert large images in common formats to smaller, web-friendly JPEG, PNG, WebP, GIF and AVIF images of varying dimensions.
[참고] https://www.npmjs.com/package/sharp
반성하고 영상 변환은 어떻게 하는지 찾아보기 시작했다.
영상 인코딩, 디코딩, 컨버팅 등을 하기 위해선 FFmpeg를 사용하며, Node.js에서는 fluent-ffmpeg 등의 라이브러리 사용하여 구현이 가능하였다. FFmpeg 설치는 필수지만, fluent-ffmpeg 라이브러리는 선택 사항이다. fluent-ffmpeg는 FFmpeg의 복잡한 명령어를 사용하기 쉽게 도와주는 래퍼(wrapper) 라이브러리이기 때문이다.
2000년에 시작된 오픈소스 프로젝트인 만큼 인코더, 디코더 등을 서비스 하는 소프트웨어에서 대부분 사용되고, 그만큼 제공하는 옵션이 많았다. 난 회사에서 필요로 하는 용량 압축과 mp4 변환을 위해 챗GPT와 함께 FFmpeg 옵션을 수정해가며 구현하였다.
(아래는 예시로, 회사 코드와는 다릅니다.)
import { S3 } from 'aws-sdk';
import { PrismaService } from '@src/prisma/prisma.service';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs';
import { promisify } from 'util';
import path from 'path';
import { spawn } from 'child_process';
import { UploadFileType } from '@common/enum';
const unlinkAsync = promisify(fs.unlink);
export class VideoProcessorService {
constructor(
private readonly prisma: PrismaService,
private readonly s3: S3,
) {}
// 비디오 파일 인코딩 함수
// FFmpeg를 사용하여 비디오를 인코딩하고, 인코딩된 파일 경로를 반환
async encodeVideo(signedUrl: string): Promise<string> {
// 임시 파일 저장 경로 생성
const tempDir = os.tmpdir();
const outputPath = path.join(tempDir, `output_${Date.now()}.mp4`);
// FFmpeg 인코딩 옵션 설정
const ffmpegOption = [
'-i', signedUrl, // 입력 파일 URL (signed URL로 가져옴)
'-vf', 'scale=400:-2', // 비디오 필터: 너비 400px로 고정, 높이는 비율에 맞게 자동 조정
'-b:v', '500k', // 비디오 비트레이트를 500kbps로 설정 (영상 품질)
'-vcodec', 'libx264', // 비디오 코덱 libx264 사용 (H.264 표준)
'-preset', 'medium', // 인코딩 속도와 압축률의 균형을 맞춘 'medium' 프리셋 사용
'-crf', '20', // 품질 설정 (낮을수록 더 높은 품질, 0~51 사이. 20은 고품질에 적합)
'-movflags', 'faststart', // 웹 스트리밍을 위해 MP4 파일의 메타데이터를 파일 앞부분에 위치시킴
'-f', 'mp4', // 출력 파일 형식을 MP4로 설정
'-loglevel', 'error', // 로그 레벨을 'error'로 설정하여 에러만 출력
outputPath // 출력 파일 경로 (임시 디렉토리)
];
// FFmpeg 프로세스 실행
const ffmpegProcess = spawn('ffmpeg', ffmpegOption);
// 프로세스 완료 여부 확인
return new Promise((resolve, reject) => {
let ffmpegErrorOccurred = false;
// 에러 발생 시 로그 출력
ffmpegProcess.stderr.on('data', (data) => {
console.error(`FFmpeg stderr: ${data}`);
ffmpegErrorOccurred = true;
});
// 프로세스가 종료될 때
ffmpegProcess.on('close', async (code) => {
if (code === 0 && !ffmpegErrorOccurred) {
// 에러 없이 정상적으로 종료되면 출력 파일 경로 반환
resolve(outputPath);
} else {
// 실패 시 파일 삭제 후 에러 반환
if (fs.existsSync(outputPath)) await unlinkAsync(outputPath);
reject(new Error('Video encoding failed.'));
}
});
});
}
// S3에 파일 업로드
// 인코딩된 비디오 파일을 S3 버킷에 업로드하고, 업로드된 파일 URL 반환
async uploadToS3(bucket: string, s3Key: string, buffer: Buffer | Readable, contentType: string, uploadPath: string): Promise<string> {
const params = {
Bucket: bucket, // S3 버킷명
Key: uploadPath, // S3 경로
Body: buffer, // 업로드할 파일 버퍼 또는 스트림
ACL: 'public-read', // 공개 읽기 권한 설정
ContentType: contentType, // 파일 타입 설정 (여기서는 'video/mp4')
};
const { Location } = await this.s3.upload(params).promise();
return Location; // 업로드된 파일의 S3 URL 반환
}
// DB 업데이트 함수
// 처리된 비디오 파일 정보를 데이터베이스에 업데이트
async updateDatabase(fileId: string, uploadedLink: string, s3Key: string) {
const updatedData = {
uploadedLink, // 업로드된 비디오 파일 링크
s3Key, // S3에 저장된 파일 키
};
await this.prisma.uploadedFile.update({
where: { id: fileId }, // 업데이트할 파일 ID
data: updatedData, // 업데이트할 데이터
});
}
// 기존 원본 파일 S3에서 삭제
// 처리 완료 후 S3에 저장된 원본 비디오 파일 삭제
async deleteS3Object(bucket: string, key: string) {
const params = { Bucket: bucket, Key: key };
await this.s3.deleteObject(params).promise();
}
// 비디오 파일 처리 함수
// S3에 저장된 비디오 파일을 인코딩하고, 다시 S3에 업로드한 후 데이터베이스 업데이트
async processVideoFile(bucket: string, files: UploadedFile[]): Promise<void> {
for (const file of files) {
const s3Key = uuidv4(); // 새로 생성된 S3 키
const fileSubPath = file.uploadedLink.split('/')[6];
const fileName = file.uploadedLink.split('/')[7];
const originExt = path.extname(fileName).slice(1);
let uploadedLink = '';
// 비디오 파일에 대한 signed URL 생성
const signedUrl = await this.s3.getSignedUrlPromise('getObject', {
Bucket: bucket,
Key: `origin/${fileSubPath}/${fileName}`,
Expires: 60 * 30, // URL 유효기간 30분
});
// 비디오 인코딩 처리
const encodedFilePath = await this.encodeVideo(signedUrl);
// 인코딩된 파일을 S3에 업로드
const originStream = fs.createReadStream(encodedFilePath);
uploadedLink = await this.uploadToS3(bucket, s3Key, originStream, 'video/mp4', `origin/${fileSubPath}/${s3Key}.mp4`);
// 업로드 완료 후 임시 파일 삭제
originStream.on('close', async () => {
await unlinkAsync(encodedFilePath);
});
// DB 업데이트
await this.updateDatabase(file.id, uploadedLink, s3Key);
// 기존 S3 파일 삭제
await this.deleteS3Object(bucket, `origin/${fileSubPath}/${fileName}`);
}
}
}
위 예시 코드의 FFmpeg 인코딩 옵션
- i <input>: 입력 파일 또는 스트림을 지정합니다. 이 코드에서는 signedUrl을 입력으로 사용합니다.
- vf 'scale=400:-2': 비디오 필터를 사용하여 너비를 400px로 설정하고, 높이는 비율을 유지하며 자동 조정합니다.
- b:v 500k: 비디오 비트레이트(bitrate)를 500kbps로 설정합니다. 비트레이트는 비디오 품질과 파일 크기에 영향을 미치는 요소입니다.
- vcodec libx264: H.264 코덱(libx264)을 사용하여 비디오를 인코딩합니다. H.264는 널리 사용되는 고압축 비디오 코덱입니다.
- preset medium: 인코딩 속도와 품질의 균형을 맞추는 프리셋입니다. 다른 옵션으로는 ultrafast, fast, slow 등이 있으며, 속도와 품질의 트레이드오프를 결정합니다.
- crf 20: CRF(Constant Rate Factor)는 비디오 품질을 설정하는 옵션입니다. 값이 낮을수록 더 높은 품질이며, 0은 무손실을 의미합니다. 일반적으로 18~28 사이를 권장합니다.
- movflags faststart: 이 옵션은 웹 스트리밍을 위해 MP4 파일을 최적화하는 옵션입니다. 이를 사용하면 스트리밍 중 파일이 빠르게 시작됩니다.
- loglevel error: 로그 레벨을 에러로 설정하여, 에러 메시지만 출력되도록 합니다.
임시 파일 생성 없이 S3 업로드할 수 없을까?
위 코드에서는 FFmpeg의 출력을 outputPath 경로의 시스템 내부에 저장 후, AWS S3 업로드가 완료되거나 에러 발생 시 임시 저장된 파일을 삭제하는 I/O 작업이 존재한다.
하지만 디스크 I/O를 줄이기 위해 임시 파일을 생성하지 않고 FFmpeg 출력을 바로 S3에 업로드하는 방식으로 구현하는 방식도 고려해볼 수 있다.
스트림 방식의 장점: I/O 성능 측면
스트림 방식에서 효율성의 핵심은 디스크 I/O 작업을 줄이는 것이다. 파일을 디스크에 기록하고 다시 읽어와서 S3에 업로드하는 기존 방식과 비교했을 때, 스트림을 사용하면 메모리에서 바로 데이터를 전송할 수 있기 때문에, 디스크 읽기/쓰기를 생략한다. 즉, 디스크에 저장하고 다시 불러오는 단계가 없어 입출력(I/O) 성능이 향상된다.
스트림 방식의 단점: 메모리 사용 측면
스트림 방식의 단점은 메모리 사용량이 늘어날 수 있다는 점이다. 대용량 파일의 경우, 데이터를 디스크 대신 메모리에서 바로 처리하므로 메모리 부담이 증가한다.
하지만 스트림 방식이 단순히 데이터를 메모리에 전체 저장하는 방식이 아니라, 조각 단위로 데이터를 처리한다. 스트림 방식은 chunk 단위로 데이터를 처리하기 때문에, 한 번에 큰 메모리 블록을 차지하지 않고 지속적으로 데이터를 전송할 수 있다. 그러므로 스트림 방식이 메모리 사용량이 급격하게 늘어나지 않으면서도 효율적인 처리 방식을 제공할 수 있다.
성능 vs 메모리 트레이드오프
- 성능 면에서 효율적: 디스크 I/O 작업이 줄어들어 성능이 향상될 수 있다. 특히 파일이 크고 입출력 속도가 중요한 경우에 유리하다.
- 메모리 면에서의 트레이드오프: 비록 메모리 사용량이 증가할 수 있지만, 스트림은 데이터를 조각(chunks)으로 처리하므로 전체 파일을 메모리에 로드하지 않기 때문에, 큰 파일도 일정한 메모리 내에서 처리가 가능하다. 하지만 메모리 리소스가 제한적인 환경에서는 주의가 필요하다.
결론적으로, 스트림 방식은 디스크 I/O 성능을 크게 개선할 수 있는 반면, 메모리 사용량은 상대적으로 증가할 수 있다. 따라서 두 방식 간의 선택은 시스템의 메모리 자원과 성능 요구 사항에 따라 달라진다.
스트림을 이용한 예시
import { S3 } from 'aws-sdk';
import { PrismaService } from '@src/prisma/prisma.service';
import { v4 as uuidv4 } from 'uuid';
import { spawn } from 'child_process';
import { PassThrough } from 'stream';
import { UploadFileType } from '@common/enum';
export class VideoProcessorService {
constructor(
private readonly prisma: PrismaService,
private readonly s3: S3,
) {}
// 비디오 파일 인코딩 함수
// FFmpeg를 사용하여 비디오를 인코딩하고, S3에 바로 업로드
async encodeVideoToS3(signedUrl: string, s3Key: string, bucket: string, fileSubPath: string): Promise<string> {
const passThrough = new PassThrough(); // FFmpeg 출력 스트림을 받기 위한 PassThrough 스트림
// FFmpeg 인코딩 옵션 설정 (출력은 스트림으로 전달됨)
const ffmpegOption = [
'-i', signedUrl, // 입력 파일 URL (signed URL로 가져옴)
'-vf', 'scale=400:-2', // 비디오 필터: 너비 400px로 고정, 높이는 비율에 맞게 자동 조정
'-b:v', '500k', // 비디오 비트레이트를 500kbps로 설정 (영상 품질)
'-vcodec', 'libx264', // 비디오 코덱 libx264 사용 (H.264 표준)
'-preset', 'medium', // 인코딩 속도와 압축률의 균형을 맞춘 'medium' 프리셋 사용
'-crf', '20', // 품질 설정 (낮을수록 더 높은 품질, 0~51 사이. 20은 고품질에 적합)
'-movflags', 'faststart', // 웹 스트리밍을 위해 MP4 파일의 메타데이터를 파일 앞부분에 위치시킴
'-f', 'mp4', // 출력 파일 형식을 MP4로 설정
'-loglevel', 'error', // 로그 레벨을 'error'로 설정하여 에러만 출력
'pipe:1', // FFmpeg 출력물을 stdout으로 스트리밍
];
const ffmpegProcess = spawn('ffmpeg', ffmpegOption);
// FFmpeg stdout을 PassThrough로 연결 (스트림으로 전달)
ffmpegProcess.stdout.pipe(passThrough);
// S3 업로드 처리
const uploadParams = {
Bucket: bucket,
Key: `origin/${fileSubPath}/${s3Key}.mp4`,
Body: passThrough, // 스트림을 바로 S3에 업로드
ContentType: 'video/mp4',
ACL: 'public-read', // 퍼블릭 읽기 권한
};
return new Promise((resolve, reject) => {
this.s3.upload(uploadParams, (err, data) => {
if (err) {
reject(err); // 에러 발생 시 리젝트
} else {
resolve(data.Location); // 성공 시 S3에 업로드된 파일 URL 반환
}
});
});
}
// DB 업데이트 함수
// 처리된 비디오 파일 정보를 데이터베이스에 업데이트
async updateDatabase(fileId: string, uploadedLink: string, s3Key: string) {
const updatedData = {
uploadedLink, // 업로드된 비디오 파일 링크
s3Key, // S3에 저장된 파일 키
};
await this.prisma.uploadedFile.update({
where: { id: fileId }, // 업데이트할 파일 ID
data: updatedData, // 업데이트할 데이터
});
}
// 기존 원본 파일 S3에서 삭제
// 처리 완료 후 S3에 저장된 원본 비디오 파일 삭제
async deleteS3Object(bucket: string, key: string) {
const params = { Bucket: bucket, Key: key };
await this.s3.deleteObject(params).promise();
}
// 비디오 파일 처리 함수
// S3에 저장된 비디오 파일을 인코딩하고, 다시 S3에 업로드한 후 데이터베이스 업데이트
async processVideoFile(bucket: string, files: UploadedFile[]): Promise<void> {
for (const file of files) {
const s3Key = uuidv4(); // 새로 생성된 S3 키
const fileSubPath = file.uploadedLink.split('/')[6];
const fileName = file.uploadedLink.split('/')[7];
let uploadedLink = '';
// 비디오 파일에 대한 signed URL 생성
const signedUrl = await this.s3.getSignedUrlPromise('getObject', {
Bucket: bucket,
Key: `origin/${fileSubPath}/${fileName}`,
Expires: 60 * 30, // URL 유효기간 30분
});
// 비디오 인코딩 처리 및 S3에 바로 업로드
uploadedLink = await this.encodeVideoToS3(signedUrl, s3Key, bucket, fileSubPath);
// DB 업데이트
await this.updateDatabase(file.id, uploadedLink, s3Key);
// 기존 S3 파일 삭제
await this.deleteS3Object(bucket, `origin/${fileSubPath}/${fileName}`);
}
}
}
FFmpeg 사용을 위한 Docker 및 Docker Compose 세팅
FFmpeg 사용을 위해서는 운영 시스템에 FFmpeg가 설치되어 있어야한다. fluent-ffmpeg 등의 라이브러리가 있다고 하더라도 FFmpeg를 바로 사용할 수 없다.
fluent-ffmpeg 등은 FFmpeg 바이너리를 Node.js에서 쉽게 사용할 수 있도록 하는 래퍼(wrapper) 라이브러리로, 내부적으로 시스템에 설치된 FFmpeg 실행 파일을 호출하는 역할을 하기 때문에 FFmpeg를 사용하기 위해선 Windows, Linux, Mac 등의 시스템에 FFmpeg가 설치되어 있어야 한다. 설치 파일 또는 Homebrew 등으로 직접 시스템에 FFmpeg를 설치하거나 Docker를 이용하여 설정 가능하다.
- Node.js와 FFmpeg 환경 설정을 위한 Dockerfile, docker-compose.yml 예시
# Dockerfile
FROM node:lts
# FFmpeg 설치
RUN apt-get update && apt-get install -y ffmpeg
# 앱 디렉토리 설정
WORKDIR /usr/src/app
# 의존성 설치
COPY package*.json ./
RUN npm install
# 앱 소스 복사
COPY . .
# 앱 실행
CMD [ "npm", "start" ]
# docker-compose.yml
version: '3'
services:
app:
build: .
ports:
- '3000:3000'
volumes:
- .:/app
- Docker Compose 실행법
docer compose up -d
이어서… 이미지 및 영상 처리 작업의 부하를 줄이기 위해 BullMQ 사용하기
https://jangjiyu.tistory.com/95
NestJS에서 이미지 및 영상 처리 작업의 부하를 줄이기 위해 BullMQ 사용
BullMQ를 사용하는 이유BullMQ는 Node.js 애플리케이션에서 비동기 작업 처리를 위해 Redis 기반의 큐 시스템을 제공하는 도구입니다. 주로 백그라운드 작업 처리나 시간이 오래 걸리는 작업을 메인 서
jangjiyu.tistory.com
'나의 개발일지 > node.js' 카테고리의 다른 글
NestJS에서 이미지 및 영상 처리 작업의 부하를 줄이기 위해 BullMQ 사용 (0) | 2024.10.07 |
---|---|
nodejs(expressjs) 환경변수 validation (0) | 2024.02.14 |
NestJS 환경변수 관리 - process.env보단 ConfigModule을 (0) | 2024.02.07 |
[nodejs | 노드js] package.json (0) | 2022.07.31 |
[nodejs | 노드js] 모듈(module), 내장 객체 (0) | 2022.07.27 |