AI 영상 생성 가이드: Runway·Veo·Sora 계열
text-to-video / image-to-video API를 프로덕션 파이프라인에 붙이는 개발자용 완전 가이드
2024~2025년을 거치며 AI 영상 생성은 "데모용 짧은 클립"에서 "제품에 넣을 수 있는 컴포넌트"로 넘어왔다. Runway Gen-3/Gen-4, Google Veo, OpenAI Sora, 그리고 Kling·Luma·Pika 같은 후발 주자들이 모두 API 또는 그에 준하는 프로그래밍 접근을 제공하기 시작하면서, 이제 영상 생성은 이미지 생성과 똑같은 방식으로 백엔드 워크플로우에 편입된다. 즉 프롬프트와 파라미터를 던지고, 비동기 잡(job)을 폴링하고, 완성된 mp4 URL을 받아 스토리지에 넣는 패턴이다.
이 가이드는 디자이너 튜토리얼이 아니라 개발자가 이 모델들을 자기 서비스에 붙일 때 실제로 마주치는 것들을 다룬다. 세 계열의 작동 방식 차이, 비동기 잡 처리, image-to-video로 출력 일관성을 잡는 법, 프롬프트를 구조화하는 법, 비용·레이트리밋 관리, 그리고 가장 많이 터지는 함정들이다. 모델 이름과 정확한 가격·해상도는 공급사가 자주 바꾸므로 구체 수치는 항상 공식 문서로 재확인하고, 이 글은 변하지 않는 아키텍처와 패턴에 집중한다.
핵심 정신 모델 하나만 먼저 잡고 가자. AI 영상 생성 API는 거의 전부 다음 형태다.
POST /generate → { job_id } (즉시 반환, 생성은 백그라운드)
GET /tasks/{id} → { status, output } (PENDING → RUNNING → SUCCEEDED/FAILED)
동기 응답을 기대하고 짜면 100% 타임아웃난다. 영상 한 편 생성은 수십 초~수 분이 걸린다. 이 비동기 잡 모델을 어떻게 안정적으로 다루느냐가 이 글의 절반이다.
| 계열 | 대표 모델 | 접근 방식 | 강점 |
|---|---|---|---|
| Runway | Gen-3 Alpha / Gen-4 | 공식 REST API (Developer Platform) | image-to-video, 카메라 컨트롤, 성숙한 API |
| Google Veo | Veo 2 / Veo 3 | Gemini API · Vertex AI | 고해상도, 오디오 동시 생성(Veo 3), GCP 통합 |
| OpenAI Sora | Sora / Sora 2 | OpenAI API · Sora 앱 | 장면 일관성, 물리 시뮬레이션, ChatGPT 생태계 |
| 후발 | Kling, Luma Dream Machine, Pika | 각자 API / fal.ai·Replicate 경유 | 가성비, 빠른 반복, 통합 게이트웨이 |
1. 세 계열의 작동 방식과 선택 기준
먼저 "어떤 걸 쓸까"를 정하려면 세 계열이 근본적으로 어떻게 다른지를 알아야 한다. 표면적으로는 다 text-to-video지만, 접근 경로·생성 모델·제약이 다르다.
Runway 계열
Runway는 영상 생성 도구 중 개발자 API가 가장 먼저, 가장 성숙하게 나온 축이다. Gen-3 Alpha, Gen-4가 대표 모델이고 dev.runwayml.com의 Developer Platform에서 API 키를 발급받는다. 특징은 image-to-video가 1급 시민이라는 점 — 시작 프레임(첫 이미지)을 주고 거기서부터 모션을 생성하는 흐름이 안정적이라, 일관된 캐릭터/제품을 영상화할 때 사실상 표준 경로다.
Google Veo 계열
Veo는 두 가지 경로로 접근한다. 빠르게 붙이려면 Gemini API(generativelanguage.googleapis.com, @google/genai SDK), 엔터프라이즈·쿼터·리전 제어가 필요하면 Vertex AI. Veo 3 계열의 큰 차별점은 오디오를 영상과 함께 생성한다는 것(효과음·대사·앰비언스). 즉 무음 클립에 따로 사운드를 입힐 필요가 줄어든다. GCP를 이미 쓰는 팀이면 IAM·스토리지·빌링이 그대로 이어져서 운영 부담이 적다.
OpenAI Sora 계열
Sora는 장면 일관성과 물리적 그럴듯함에서 강하다. OpenAI API로 노출되며(모델군은 sora 계열), 기존에 OpenAI를 쓰던 백엔드라면 동일한 인증·SDK 패턴으로 붙는다. ChatGPT/Sora 앱 생태계와 묶이는 것도 장점이자 제약(콘텐츠 정책이 빡빡한 편).
선택 기준 (실무 의사결정 트리)
image-to-video로 캐릭터/제품 일관성이 핵심?
└─ Yes → Runway (시작 프레임 워크플로우가 가장 성숙)
오디오까지 한 번에 필요? GCP 이미 사용 중?
└─ Yes → Veo (Veo 3 오디오 동시 생성 + Vertex 통합)
긴 장면 일관성/물리 시뮬레이션이 핵심? OpenAI 스택?
└─ Yes → Sora
가성비/빠른 A·B 비교/여러 모델 한 게이트웨이로?
└─ fal.ai 또는 Replicate (아래 9번 섹션)
베스트 프랙티스: 초기에 한 모델에 코드를 강결합하지 말 것. 입력(프롬프트·시드 이미지·길이·종횡비)과 출력(mp4 URL)을 추상화한 provider 인터페이스를 두고 어댑터로 갈아끼우게 설계하라. 모델 품질·가격은 분기마다 뒤집힌다.
함정: "text-to-video 데모가 멋지니 우리 제품도 text-to-video"라고 단정하지 말 것. 실서비스에서 출력 일관성을 잡으려면 거의 항상 image-to-video로 가게 된다(섹션 5). 처음부터 시드 이미지 파이프라인을 염두에 두고 설계하라.
2. API 키 발급과 첫 호출 — 비동기 잡 모델 이해
모든 영상 생성 API의 공통 골격은 **"제출 → 폴링 → 다운로드"**다. 동기 응답이 오는 줄 알고 짜면 안 된다.
Runway 첫 호출 (Node, 공식 SDK)
Runway는 공식 SDK(@runwayml/sdk)를 제공한다. 환경변수 RUNWAYML_API_SECRET를 읽는다.
import RunwayML from '@runwayml/sdk';
const client = new RunwayML(); // RUNWAYML_API_SECRET 자동 사용
// image-to-video: 시작 프레임 + 텍스트 프롬프트
const task = await client.imageToVideo.create({
model: 'gen3a_turbo',
promptImage: 'https://cdn.example.com/start-frame.jpg', // 공개 URL 또는 data URI
promptText: 'slow dolly-in, cinematic, soft morning light',
ratio: '1280:768',
duration: 5, // 초
});
console.log(task.id); // 여기서 끝나는 게 아니다 — 잡 ID일 뿐
create는 즉시 잡 ID를 반환한다. 실제 영상은 아직 만들어지지 않았다.
폴링 (SDK 헬퍼가 없다고 가정한 raw 패턴)
async function waitForTask(client, taskId, { timeoutMs = 8 * 60_000, intervalMs = 5000 } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const t = await client.tasks.retrieve(taskId);
if (t.status === 'SUCCEEDED') return t.output; // [mp4 URL, ...]
if (t.status === 'FAILED') throw new Error(`Task failed: ${t.failure ?? 'unknown'}`);
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error('Task polling timed out');
}
const output = await waitForTask(client, task.id);
console.log(output); // 다운로드 가능한 영상 URL
Veo 첫 호출 (Gemini API, @google/genai)
import { GoogleGenAI } from '@google/genai';
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
let op = await ai.models.generateVideos({
model: 'veo-3.0-generate-001', // 정확한 모델 ID는 공식 문서 확인
prompt: 'a paper boat drifting down a rain gutter, macro, shallow depth of field',
});
// long-running operation 폴링
while (!op.done) {
await new Promise((r) => setTimeout(r, 8000));
op = await ai.operations.getVideosOperation({ operation: op });
}
const video = op.response.generatedVideos[0];
Veo도 동일하게 operation 객체를 폴링한다. 패턴은 같고 SDK 메서드 이름만 다르다.
공통 함정
- 생성 시간이 길다: 클립 한 편이 수십 초~수 분. HTTP 요청-응답 한 사이클 안에서 끝내려 하지 말 것. 반드시 백그라운드 잡으로 분리하라(섹션 4).
- 출력 URL은 만료된다: 많은 공급사가 결과 mp4를 서명된, 시간제한 URL로 준다(예: 24~48시간). 받자마자 본인 스토리지(S3/R2/GCS)로 복사하지 않으면 나중에 깨진 링크가 된다. 이건 이미지 생성에서도 똑같이 겪는 함정의 영상판이다.
- API 키를 클라이언트에 절대 노출 금지: 영상 생성은 호출당 비용이 크다(이미지보다 10~100배). 프론트에서 직접 호출하면 키 탈취 = 즉시 청구 폭탄. 항상 백엔드 프록시를 거쳐라.
3. 프롬프트 엔지니어링 — 영상은 이미지 프롬프트와 다르다
영상 프롬프트의 핵심은 "무엇이 보이는가" + "무엇이 움직이는가" + "카메라가 어떻게 움직이는가" 세 축을 분리해서 쓰는 것이다. 이미지 프롬프트(피사체·스타일·조명)에 모션과 카메라가 추가된다.
구조화 템플릿
[Shot type] of [subject] [action/motion],
[setting/environment],
[camera movement],
[lighting],
[style/look],
[duration/pacing cues]
예시:
Medium close-up of a barista pouring latte art,
in a sunlit specialty coffee shop,
slow push-in, locked-off then subtle handheld,
warm golden-hour light through the window,
shot on 35mm film, shallow depth of field,
calm and deliberate pacing
카메라 무브먼트 어휘 (모델이 잘 알아듣는 표준 용어)
| 의도 | 프롬프트 용어 |
|---|---|
| 천천히 다가감 | slow push-in, dolly in |
| 멀어짐 | pull back, dolly out |
| 수평 이동 | tracking shot, pan left/right |
| 수직 이동 | tilt up/down, crane shot |
| 공중 | aerial, drone shot, overhead top-down |
| 고정 | locked-off, static shot |
| 손맛 | handheld, subtle camera shake |
프로그래매틱 프롬프트 빌더 (재사용·일관성)
사람이 매번 자유 텍스트를 쓰면 결과가 들쭉날쭉하다. 백엔드에서 구조화된 입력 → 프롬프트로 조립하라.
type ShotSpec = {
subject: string;
action: string;
setting: string;
camera: 'push-in' | 'pull-back' | 'tracking' | 'static' | 'aerial';
lighting: string;
look: string;
};
const CAMERA: Record<ShotSpec['camera'], string> = {
'push-in': 'slow cinematic push-in',
'pull-back': 'smooth pull back revealing the scene',
'tracking': 'lateral tracking shot following the subject',
'static': 'locked-off static camera',
'aerial': 'high aerial drone shot',
};
function buildPrompt(s: ShotSpec): string {
return [
`${s.subject} ${s.action}`,
s.setting,
CAMERA[s.camera],
s.lighting,
s.look,
].filter(Boolean).join(', ');
}
함정
- 모션 욕심: 한 클립에 "달리고, 점프하고, 폭발하고, 카메라도 회전"을 다 넣으면 모델이 뭉갠다. 클립당 한 가지 주요 동작 + 한 가지 카메라 무브가 안전선이다. 복잡한 시퀀스는 여러 클립으로 쪼개 이어붙여라.
- 부정 프롬프트 의존: "no blur, no distortion" 같은 부정어는 모델에 따라 무시되거나 오히려 그 단어를 끌어온다. 긍정 서술로 원하는 걸 직접 묘사하라("sharp focus, stable subject").
- 고유명사·실존 인물·브랜드: 대부분 정책상 차단되거나 이상하게 나온다. 회피 설계 필수.
- 텍스트 렌더링: 영상 안에 정확한 글자(로고·자막)를 박는 건 여전히 약하다. 글자는 후처리(편집 단계)에서 오버레이하라.
4. 비동기 잡 파이프라인 설계 — 큐·워커·웹훅
영상 생성은 수 분짜리 비동기 작업이라 HTTP 핸들러 안에서 끝낼 수 없다. 프로덕션에서는 반드시 잡 큐 + 워커 구조로 분리한다.
아키텍처
[Client] ──POST /videos──▶ [API]
│ enqueue job (status=queued)
▼
[Job Queue] (BullMQ / SQS / pg-boss / Cloud Tasks)
│
▼
[Worker] ──create──▶ [Video API]
│ poll or webhook
▼
download mp4 ──▶ [Object Storage]
│ update job (status=done, url=...)
▼
notify client (SSE / webhook / push)
워커 의사 코드 (BullMQ 예)
import { Worker } from 'bullmq';
new Worker('video-gen', async (job) => {
const { spec } = job.data;
// 1) 제출
const task = await provider.create(spec);
await db.jobs.update(job.id, { providerTaskId: task.id, status: 'running' });
// 2) 폴링 (워커가 살아있는 동안)
const output = await provider.waitForTask(task.id, { timeoutMs: 10 * 60_000 });
// 3) 결과를 우리 스토리지로 복사 (서명 URL 만료 대비)
const permanentUrl = await copyToStorage(output.videoUrl, `videos/${job.id}.mp4`);
// 4) 상태 갱신
await db.jobs.update(job.id, { status: 'done', url: permanentUrl });
return { url: permanentUrl };
}, {
connection: redis,
concurrency: 3, // 레이트리밋 고려 (섹션 7)
});
폴링 vs 웹훅
- 폴링: 공급사가 웹훅을 안 주면 워커가 직접
GET /tasks/{id}를 주기적으로 친다. 구현 간단, 단 워커가 그동안 점유된다. 지수 백오프(5s → 8s → 12s …)로 호출 수를 줄여라. - 웹훅: Runway 등 일부는 완료 시 콜백을 준다. 워커가 잡을 들고 대기할 필요가 없어 효율적. 단 공개 엔드포인트 + 서명 검증이 필요하고, 로컬 개발 시 터널(ngrok 등)이 필요.
함정
- 워커 크래시 = 잃어버린 잡: 워커가 폴링 도중 죽으면 잡이 공중에 뜬다.
providerTaskId를 반드시 DB에 먼저 저장하고, 재시작 시 "running인데 providerTaskId 있는" 잡을 이어서 폴링하는 복구 로직을 넣어라. - 멱등성: 잡 재시도 시
provider.create를 또 부르면 영상이 두 번 생성되고 두 번 청구된다.providerTaskId가 이미 있으면 create를 건너뛰고 폴링부터 재개하라. - 타임아웃 일관성: 워커 타임아웃 > 공급사 최대 생성 시간 > 폴링 deadline 순서가 어긋나면 멀쩡한 잡을 실패 처리한다. 가장 바깥(워커)을 가장 길게 잡아라.
- 프론트 UX: 사용자에게 "수 분 걸림"을 명시하고, 큐 위치·진행 상태를 보여줘라. 스피너만 돌리면 이탈한다. SSE나 폴링으로 status를 노출하라.
5. Image-to-Video — 출력 일관성을 잡는 실전 핵심
실서비스에서 가장 중요한 기법. text-to-video는 매번 다른 사람·다른 제품이 나온다. 같은 캐릭터/제품을 여러 클립에 걸쳐 유지하려면 시작 프레임 이미지를 고정하고 거기서 모션만 생성하는 image-to-video로 가야 한다.
워크플로우
1. 시드 이미지 생성/준비 (제품 컷, 캐릭터 키비주얼)
└─ 이미지 생성 모델(Nano Banana/Imagen/SDXL 등) 또는 실제 촬영물
2. 그 이미지를 promptImage(시작 프레임)로 전달
3. promptText로 모션·카메라만 지시
4. 여러 클립이면 같은 시드 이미지 또는 직전 클립의 마지막 프레임을 다음 시작 프레임으로
Runway image-to-video (가장 성숙한 경로)
const task = await client.imageToVideo.create({
model: 'gen3a_turbo',
promptImage: signedUrlToSeedImage, // 우리가 준비한 첫 프레임
promptText: 'the product slowly rotates 180 degrees, studio lighting, locked-off camera',
ratio: '1280:768',
duration: 5,
});
핵심: promptText에서 "무엇이 보이는가"를 다시 묘사하지 말 것. 그건 이미지가 이미 정한다. 텍스트는 모션과 카메라만 담당한다. "a red sneaker"를 또 쓰면 모델이 이미지와 충돌하는 새 신발을 그리려 한다.
첫/끝 프레임 보간 (지원 모델 한정)
일부 모델은 start frame + end frame 두 장을 주면 그 사이를 자연스럽게 채운다. A 상태 → B 상태 전환(닫힌 문 → 열린 문)에 강력하다. 지원 여부는 모델마다 다르니 문서 확인.
클립 체이닝 — 긴 영상 만들기
대부분 모델은 한 번에 5~10초 내외만 생성한다. 더 긴 영상은 클립을 이어붙인다.
async function generateLongClip(seedImage: string, beats: ShotSpec[]) {
const clips: string[] = [];
let currentSeed = seedImage;
for (const beat of beats) {
const out = await provider.imageToVideo({ image: currentSeed, prompt: buildPrompt(beat) });
clips.push(out.url);
currentSeed = await extractLastFrame(out.url); // ffmpeg로 마지막 프레임 추출
}
return concatWithFfmpeg(clips); // 아래 섹션 8
}
# 마지막 프레임 추출 (ffmpeg)
ffmpeg -sseof -0.1 -i clip.mp4 -frames:v 1 -q:v 2 last_frame.jpg
함정
- 시드 이미지 종횡비 불일치: 시드 이미지가 16:9인데 영상을 9:16으로 요청하면 크롭/왜곡된다. 시드 이미지와 출력 ratio를 처음부터 맞춰라.
- 체이닝 누적 드리프트: 마지막 프레임을 계속 시드로 쓰면 클립이 갈수록 흐려지고 색이 바랜다. 3~4클립마다 원본 키비주얼로 리셋하거나, 마지막 프레임을 가볍게 샤픈/리컬러 후 재투입하라.
- 이미지 입력 형식: 공개 URL만 받는 곳, base64 data URI만 받는 곳, 둘 다 받는 곳이 갈린다. 공개 URL을 받는 경우 그 URL이 공급사 서버에서 접근 가능해야 한다(사설망/인증 필요 URL은 실패).
6. 파라미터 — 길이·종횡비·해상도·시드·프레임레이트
영상 생성 파라미터는 모델마다 이름이 다르지만 개념은 공통이다. 각 파라미터가 품질·비용·호환성에 미치는 영향을 이해해야 한다.
핵심 파라미터 매트릭스
| 파라미터 | 역할 | 실무 주의점 |
|---|---|---|
duration | 클립 길이(초) | 길수록 비용·실패율↑. 모델별 상한 존재(보통 5~10초대) |
ratio/aspect_ratio | 종횡비 | 플랫폼별로 미리 고정(16:9 유튜브, 9:16 릴스/숏츠, 1:1 피드) |
resolution | 해상도 | 고해상도일수록 비용·시간↑. 미리보기는 저해상도, 최종만 고해상도 |
seed | 난수 시드 | 재현성의 핵심. 같은 시드+프롬프트=비슷한 결과. A/B·재생성에 필수 |
fps | 프레임레이트 | 후처리 합성 시 클립 간 fps 일치 필수. 보통 24/30 |
motion/cfg | 모션 강도·프롬프트 충실도 | 모델별 명칭 상이. 과하면 왜곡, 약하면 정적 |
시드 활용 — 재현성과 점진적 개선
시드를 고정하면 "이 결과에서 프롬프트만 살짝 바꿔 개선"이 가능해진다.
// 1차: 시드 미지정 → 마음에 드는 결과의 시드를 기록
const v1 = await provider.create({ prompt, seed: undefined });
console.log(v1.seed); // 예: 482913
// 2차: 같은 시드 + 프롬프트 미세 조정 → 구도 유지하며 디테일만 변경
const v2 = await provider.create({ prompt: prompt + ', warmer lighting', seed: 482913 });
시드를 로깅하지 않으면 "아까 그 좋았던 결과"를 영영 못 찾는다. 모든 생성의 시드를 DB에 저장하라.
종횡비를 제품 레벨에서 enum으로
const RATIOS = {
youtube: '16:9',
reels: '9:16',
feed: '1:1',
} as const;
function resolveRatio(platform: keyof typeof RATIOS, provider: 'runway' | 'veo') {
const r = RATIOS[platform];
// 공급사마다 표기가 '16:9' vs '1280:768'처럼 다름 → 어댑터에서 변환
return PROVIDER_RATIO_MAP[provider][r];
}
함정
- 종횡비 표기 불일치: 한 곳은
'16:9', 다른 곳은'1280:768'같은 픽셀 쌍을 요구한다. provider 어댑터에서 정규화하라. 잘못 넣으면 400 에러 또는 무음 크롭. - 해상도 올리면 다른 결과: 같은 시드·프롬프트라도 해상도를 바꾸면 디테일이 달라진다. 최종 해상도로 시드를 확정한 뒤 그 시드를 고정하라.
- fps 불일치 합성 사고: 24fps 클립과 30fps 클립을 그냥 concat하면 재생 속도·끊김이 생긴다. 합성 전 ffmpeg로 fps를 통일하라(섹션 8).
- "무제한 길이" 환상: 어떤 모델도 한 호출로 1분짜리를 안정적으로 못 만든다. 길이는 항상 클립 체이닝으로 늘려라(섹션 5).
7. 비용·레이트리밋·할당량 관리
영상 생성은 이미지보다 한두 자릿수 비싸다. 비용 거버넌스 없이 풀어두면 사고가 난다. 정확한 단가는 공급사가 자주 바꾸므로 구조를 잡는 데 집중한다.
비용 모델 이해
대부분 "생성된 영상 초 × 해상도 계수" 또는 크레딧 기반으로 과금한다. 즉:
- 길이를 1초만 줄여도 비용이 비례해서 준다
- 미리보기를 저해상도로, 최종 승인본만 고해상도로 → 2단계 생성이 비용을 크게 아낀다
- 실패한 잡도 부분 과금될 수 있음(공급사별 정책 확인)
2단계 생성 패턴 (preview → final)
// 1단계: 싸고 빠른 미리보기 (저해상도, 짧게)
const preview = await provider.create({ ...spec, resolution: 'low', duration: 3 });
// 사용자가 OK 하면 ↓
// 2단계: 같은 시드로 고품질 최종본
const final = await provider.create({ ...spec, resolution: 'high', seed: preview.seed });
시드를 물려주므로 미리보기와 최종본 구도가 일관된다.
예산 가드 (cost-guard)
호출 전에 누적 비용을 추정·차단하는 게이트를 둬라. (AINorm 내부에서도 LLM 비용에 같은 패턴을 쓴다.)
async function guardedGenerate(spec: ShotSpec, userId: string) {
const estCost = estimateCost(spec); // 초×해상도 계수
const spent = await getMonthlySpend(userId);
if (spent + estCost > BUDGET_LIMIT) {
throw new BudgetExceededError(`예산 초과: ${spent}+${estCost} > ${BUDGET_LIMIT}`);
}
const result = await provider.create(spec);
await recordSpend(userId, estCost);
return result;
}
레이트리밋 다루기
동시 잡 수·분당 요청에 제한이 있다. 워커 concurrency를 그 한도 아래로 두고, 429를 받으면 지수 백오프 + Retry-After 존중으로 재시도하라.
async function withRetry(fn: () => Promise<any>, max = 4) {
for (let i = 0; i < max; i++) {
try { return await fn(); }
catch (e) {
if (e.status === 429 && i < max - 1) {
const wait = e.retryAfter ?? Math.min(1000 * 2 ** i, 30_000);
await new Promise((r) => setTimeout(r, wait));
continue;
}
throw e;
}
}
}
함정
- 재시도가 곧 재청구: 429가 아니라 타임아웃으로 보고 재시도하면, 실제로는 첫 잡이 진행 중인데 두 번째를 또 만들어 이중 과금된다. 멱등키(섹션 4)로 막아라.
- 프론트 직접 호출 = 키 유출 = 무한 청구: 다시 강조. 영상 API 키는 절대 클라이언트에 두지 마라.
- 저장 비용 망각: 생성 비용만 보고 스토리지·CDN 송출 비용을 빼먹는다. mp4는 무겁다. 라이프사이클 정책(오래된 미리보기 자동 삭제)을 걸어라.
- 무료 크레딧 만료 후 갑작스런 과금: 평가 단계에서 무료/프로모션 크레딧으로 짜다가 프로덕션에서 단가에 놀란다. 처음부터 유료 단가로 단위경제를 계산하라.
8. 후처리 — ffmpeg로 합치고·자르고·오디오 입히기
생성된 클립은 거의 항상 후처리가 필요하다. 클립 이어붙이기, fps 통일, 오디오 추가, 자막·로고 오버레이. 표준 도구는 ffmpeg이고, 백엔드 워커에서 바로 돌린다.
여러 클립 이어붙이기 (concat)
# 1) 파일 목록 작성
cat > list.txt <<'EOF'
file 'clip1.mp4'
file 'clip2.mp4'
file 'clip3.mp4'
EOF
# 2) 무손실 concat (코덱·해상도·fps가 동일할 때)
ffmpeg -f concat -safe 0 -i list.txt -c copy out.mp4
주의: -c copy는 모든 클립이 같은 코덱·해상도·fps일 때만 안전하다. 서로 다른 모델/설정으로 만든 클립이면 먼저 재인코딩으로 정규화해야 한다.
# 정규화: 해상도·fps·코덱 통일 후 concat
for f in clip1 clip2 clip3; do
ffmpeg -i $f.mp4 -vf scale=1280:720 -r 30 -c:v libx264 -pix_fmt yuv420p ${f}_norm.mp4
done
부드러운 전환(crossfade)
# 두 클립 사이 0.5초 크로스페이드 (xfade 필터)
ffmpeg -i a.mp4 -i b.mp4 -filter_complex \
"[0][1]xfade=transition=fade:duration=0.5:offset=4.5" out.mp4
오디오 입히기 (Veo 외 무음 모델용)
Veo 3는 오디오를 함께 내지만, Runway·Sora·후발 모델 다수는 무음 영상을 낸다. BGM·효과음을 붙여라.
# 영상 + 음악, 영상 길이에 맞춰 오디오 컷
ffmpeg -i video.mp4 -i music.mp3 -map 0:v -map 1:a \
-c:v copy -c:a aac -shortest out.mp4
자막·로고 오버레이 (생성으로는 약한 텍스트는 여기서)
ffmpeg -i video.mp4 -i logo.png -filter_complex \
"overlay=W-w-20:H-h-20" branded.mp4 # 우하단 로고
웹 배포용 인코딩
# faststart로 메타데이터를 앞으로 → 스트리밍 시 즉시 재생 시작
ffmpeg -i out.mp4 -c:v libx264 -crf 23 -preset medium \
-pix_fmt yuv420p -movflags +faststart web.mp4
함정
-c copy맹신: 코덱이 다른 클립을 그냥 copy concat하면 재생이 깨지거나 음성이 어긋난다. 의심되면 무조건 재인코딩.yuv420p누락: 일부 생성 영상은 브라우저/사파리가 못 읽는 픽셀 포맷일 수 있다. 웹용은-pix_fmt yuv420p를 명시하라.+faststart빠뜨림: 이게 없으면 영상이 전부 다운로드된 후에야 재생 시작 → 체감 매우 느림.- 워커에 ffmpeg 미설치: 컨테이너 이미지에 ffmpeg를 안 넣고 배포하면 런타임에서
command not found. Dockerfile에apt-get install -y ffmpeg또는 정적 바이너리 포함.
9. 통합 게이트웨이 — fal.ai·Replicate로 여러 모델 한 번에
공급사마다 SDK·인증·잡 모델이 제각각이라 직접 다 붙이면 유지보수가 지옥이다. fal.ai, Replicate 같은 게이트웨이는 여러 영상 모델(Runway·Kling·Luma·Veo 등 일부)을 하나의 일관된 인터페이스로 노출한다. 빠른 비교·프로토타이핑·멀티모델 폴백에 좋다.
fal.ai 패턴 (JS)
import { fal } from '@fal-ai/client';
fal.config({ credentials: process.env.FAL_KEY });
const result = await fal.subscribe('fal-ai/kling-video/v1/standard/image-to-video', {
input: {
image_url: seedImageUrl,
prompt: 'gentle camera push-in, cinematic lighting',
duration: '5',
},
logs: true,
onQueueUpdate: (u) => console.log(u.status), // 진행 상황 콜백
});
console.log(result.data.video.url);
fal.subscribe는 큐 제출 + 폴링을 내부에서 처리해 준다. 직접 폴링 루프를 안 짜도 된다는 게 큰 장점. 모델 슬러그(fal-ai/kling-video/...)만 바꾸면 다른 모델로 갈아탄다.
Replicate 패턴 (Python)
import replicate
output = replicate.run(
"some-org/some-video-model:version-hash",
input={
"prompt": "a hot air balloon rising over misty mountains, aerial",
"aspect_ratio": "16:9",
},
)
print(output) # 영상 URL
Replicate도 모델별 버전 해시만 바꾸면 교체된다. replicate.run은 동기처럼 보이지만 내부적으로 잡을 폴링한다(오래 걸리면 webhook 모드 권장).
게이트웨이를 쓸 때의 추상화
앞서 강조한 provider 인터페이스를 게이트웨이로 구현하면 멀티모델 폴백이 깔끔해진다.
interface VideoProvider {
imageToVideo(input: { image: string; prompt: string; ratio: string; duration: number }): Promise<{ url: string; seed?: number }>;
}
async function generateWithFallback(input, providers: VideoProvider[]) {
for (const p of providers) {
try { return await p.imageToVideo(input); }
catch (e) { console.warn('provider failed, trying next', e); }
}
throw new Error('all providers failed');
}
직접 연동 vs 게이트웨이 트레이드오프
| 측면 | 직접 공식 API | 게이트웨이(fal/Replicate) |
|---|---|---|
| 최신 기능 접근 | 가장 빠름 | 게이트웨이가 따라올 때까지 지연 |
| 단가 | 보통 더 쌈 | 마진 얹혀 약간 비쌈 |
| 멀티모델 비교 | 각각 따로 구현 | 슬러그만 교체 |
| 잡/폴링 처리 | 직접 구현 | 상당 부분 추상화됨 |
| 벤더 락인 | 공급사별 | 게이트웨이 의존 |
함정
- 게이트웨이 최신성 지연: 새 모델(예: 막 나온 버전)은 공식 API에 먼저 뜨고 게이트웨이엔 늦게 올라온다. 최신 기능이 중요하면 직접 연동.
- 모델 슬러그/버전 핀: Replicate는 버전 해시로 핀하라. 핀 안 하면 모델이 업데이트되며 출력이 바뀌어 회귀가 생긴다.
- 이중 마진 + 이중 장애점: 게이트웨이가 다운되면 그 뒤 모델이 멀쩡해도 못 쓴다. 핵심 서비스는 직접 연동 폴백을 하나 두는 게 안전하다.
10. 콘텐츠 정책·안전·법적 함정
영상 생성은 이미지보다 정책 리스크가 크다. 사람·브랜드·실존 인물·딥페이크 우려 때문에 모든 공급사가 강한 필터를 건다. 제품에 넣기 전에 반드시 설계해야 한다.
흔한 차단/실패 사유
- 실존 인물·유명인: 거의 항상 차단되거나 의도적으로 닮지 않게 나온다
- 브랜드·로고·저작권 캐릭터: 차단 또는 왜곡
- 미성년자 관련: 매우 엄격. 사람을 다루는 서비스면 특히 주의
- 폭력·성적·정치적 민감 콘텐츠: 모델별 정책 상이, 대체로 차단
- 워터마크·출처 표시: 다수 공급사가 출력에 가시/비가시 워터마크(예: C2PA 출처 메타데이터)를 넣는다. 이를 제거하는 건 정책 위반일 수 있음
사용자 입력을 받는 서비스의 방어 설계
사용자가 자유 프롬프트를 넣게 한다면 사전 필터링 + 사후 검수 2단을 둬라(AINorm이 다른 도메인에서 쓰는 화이트리스트+텍스트 스캔 2단 패턴과 동일).
async function moderatedGenerate(userPrompt: string, userId: string) {
// 1) 사전: 텍스트 모더레이션 (금지어·인물·브랜드 스캔)
const pre = await moderateText(userPrompt);
if (!pre.allowed) throw new PolicyError(pre.reason);
// 2) 생성
const out = await provider.create({ prompt: userPrompt });
// 3) 사후: 생성 결과 자체도 검수 (선택, 고위험 서비스)
// + 감사 로그에 userId·prompt·결과 기록 (악용 추적용)
await auditLog({ userId, prompt: userPrompt, output: out.url });
return out;
}
운영·법적 체크리스트
- 출처 고지: AI로 생성한 영상임을 고지해야 하는 플랫폼/관할이 늘고 있다(공개 배포 시). 메타데이터·워터마크를 임의 제거하지 말 것
- 상업적 사용권: 플랜·공급사마다 상업적 사용 가능 범위가 다르다. ToS의 상업 라이선스 조항을 반드시 확인
- 사용자 생성 콘텐츠 책임: 사용자가 부적절한 영상을 만들면 누구 책임인가. ToS·신고/차단 기능을 미리 갖춰라
- 데이터 보존: 생성 프롬프트·출력을 공급사가 학습에 쓰는지, 옵트아웃이 있는지 확인(민감 도메인이면 특히)
함정
- "테스트에선 통과"의 함정: 개발자 본인이 넣는 점잖은 프롬프트는 다 통과한다. 실사용자의 악의적/경계 입력을 가정하고 적대적으로 테스트하라
- 무음 정책 거부: 어떤 API는 정책 위반 시 에러가 아니라 빈 결과나 흐릿한 대체물을 준다. status만 보고 성공으로 처리하면 깨진 결과가 배포된다. 출력 검증을 넣어라
- 워터마크 제거 유혹: 클라이언트가 "워터마크 빼달라"고 해도 정책·법적으로 위험. ToS를 근거로 거절하는 게 맞다
11. 프로덕션 체크리스트와 엔드투엔드 예제
지금까지의 패턴을 하나로 묶은 출시 전 체크리스트와 전체 흐름 예제다. 그대로 따라 만들면 제품에 붙는다.
출시 전 체크리스트
[ ] API 키는 백엔드에만, 환경변수로. 클라이언트 노출 0
[ ] 비동기 잡 큐 + 워커 분리 (HTTP 핸들러 안에서 생성 X)
[ ] providerTaskId를 create 직후 DB 저장 (워커 크래시 복구)
[ ] 멱등키로 이중 생성/이중 과금 차단
[ ] 생성된 mp4를 즉시 본인 스토리지로 복사 (서명 URL 만료 대비)
[ ] 시드를 모든 생성에 로깅 (재현성)
[ ] preview(저해상도) → final(고해상도) 2단계로 비용 절감
[ ] 예산 가드 + 레이트리밋 재시도(429, 백오프)
[ ] 종횡비/fps를 provider 어댑터에서 정규화
[ ] ffmpeg 후처리 파이프라인 (concat·오디오·faststart·yuv420p)
[ ] 사용자 입력 모더레이션 (사전 필터 + 감사 로그)
[ ] provider 인터페이스 추상화 (모델 교체·폴백 대비)
[ ] 프론트에 "수 분 소요" 명시 + 진행 상태 노출
[ ] 실패/타임아웃/정책거부 각각의 UX 처리
엔드투엔드 흐름 (요약 코드)
// 1) API: 잡 접수만 하고 즉시 반환
app.post('/api/videos', async (req, res) => {
const { spec } = req.body;
const pre = await moderateText(spec.prompt);
if (!pre.allowed) return res.status(400).json({ error: pre.reason });
const job = await db.jobs.create({ spec, status: 'queued', userId: req.user.id });
await queue.add('video-gen', { jobId: job.id, spec }, { jobId: job.id }); // 멱등키
res.status(202).json({ jobId: job.id }); // 202 Accepted
});
// 2) 워커: 생성 → 폴링 → 스토리지 복사 → 상태 갱신
new Worker('video-gen', async (job) => {
const { jobId, spec } = job.data;
const existing = await db.jobs.get(jobId);
let taskId = existing.providerTaskId;
if (!taskId) { // 멱등: 이미 제출됐으면 재제출 안 함
const est = estimateCost(spec);
await assertBudget(existing.userId, est);
const task = await withRetry(() => provider.create(spec));
taskId = task.id;
await db.jobs.update(jobId, { providerTaskId: taskId, status: 'running' });
await recordSpend(existing.userId, est);
}
const out = await provider.waitForTask(taskId, { timeoutMs: 10 * 60_000 });
const url = await copyToStorage(out.videoUrl, `videos/${jobId}.mp4`);
const web = await postProcess(url); // ffmpeg: faststart, yuv420p, 오디오
await db.jobs.update(jobId, { status: 'done', url: web, seed: out.seed });
}, { connection: redis, concurrency: 3 });
// 3) 상태 조회 (프론트가 폴링 또는 SSE)
app.get('/api/videos/:id', async (req, res) => {
const job = await db.jobs.get(req.params.id);
res.json({ status: job.status, url: job.url ?? null });
});
마지막 베스트 프랙티스
- 작게 시작, 추상화는 미리: 처음엔 한 모델만 붙이되 provider 인터페이스 뒤에 숨겨라. 분기마다 더 나은 모델이 나온다
- 시드·프롬프트·파라미터·결과를 전부 로깅: "좋았던 그 결과"의 재현·디버깅·비용 분석의 토대
- 품질 게이트는 사람: 자동 생성 후 공개 전 사람 검수 단계를 두는 게 현재로선 가장 안전하다. 무음 정책 거부·이상 출력은 자동으로 다 못 거른다
- 비용을 처음부터 단위경제로: 무료 크레딧이 아니라 유료 단가 기준으로 "영상 1편당 원가"를 계산해 두면 나중에 안 놀란다