AIREITER

fal genmedia CLI: 배치 큐, 재시도, 비용 통제

마지막 업데이트: 2026-10-06 01:33:44

fal genmedia CLI는 모델 탐색, 스키마 확인, 비동기 요청 ID 처리, 다운로드, JSON 영수증 저장까지 맡는다. 하지만 대량 작업을 안정적으로 돌리려면 그 위에 별도 제어 계층이 필요하다. 동시 실행 수를 제한하고, 일시적 오류만 재시도하며, 완료된 작업은 건너뛰고, 비동기 작업의 진행 중 요청 ID까지 기록하는 오케스트레이터다.

한 번 설치하고, 실행은 항상 재현 가능하게

macOS와 Linux에서는 프로젝트 README에 다음 설치 방법이 안내돼 있다.

curl https://genmedia.sh/install -fsS | bash
genmedia setup --non-interactive --api-key "$FAL_KEY" --no-auto-update

Windows에서는 안내된 PowerShell 설치 명령을 사용한다.

irm https://genmedia.sh/install.ps1 | iex
genmedia setup --non-interactive --api-key "$env:FAL_KEY"

genmedia CLI README에 따르면 genmedia는 FAL_KEY, JSON 출력, 비대화형 설정, 선택 가능한 백그라운드 업데이트 확인을 지원한다. CI에서는 키를 스크립트나 명령 로그에 넣지 말고 러너의 시크릿 저장소를 통해 주입하는 편이 안전하다.

배치를 시작하기 전에는 엔드포인트를 고정하고, 현재 적용되는 계약을 직접 확인한다.

genmedia models "image to video" --json
genmedia schema bytedance/seedance-2.0/image-to-video --json
genmedia pricing bytedance/seedance-2.0/image-to-video --json

fal 가이드가 권하는 흐름은 models → schema → run → status → download다. 한 영상 엔드포인트에서 통하던 플래그가 다른 엔드포인트에서도 작동하리라 가정하면 안 된다. workflow skill도 검증 오류가 발생했을 때 스키마를 다시 확인하라고 명시한다.

셸 루프 대신 큐로 배치 설계하기

공식 genmedia 자료에는 비동기 실행 방법이 나와 있지만, CSV 500행을 읽어 실행하는 네이티브 명령이나 자동 재시도 정책까지 보장하지는 않는다. CLI는 공급자 특성을 아는 실행기로 두고, 큐 제어는 별도 래퍼가 담당하도록 설계하는 것이 좋다.

재개 가능한 작업 레코드에는 최소한 다음 필드가 필요하다.

필드필요한 이유
id재개와 중복 제거에 쓰는 안정적인 입력 식별자
endpoint사용한 정확한 모델 경로
prompt재현성과 감사 추적
statuspending, submitted, complete, failed, 또는 skipped
request_idgenmedia status에 필요한 값
attempts통제되지 않는 재시도 방지
output결정론적인 로컬 저장 경로
error실패한 행을 후속 조치 가능한 상태로 만듦

시간이 오래 걸리는 image-to-video 또는 영상 작업에는 --async를 사용한다. 반환된 request_id는 즉시 저장하고, 엔드포인트 ID와 요청 ID를 함께 넣어 상태를 조회한다.

genmedia run bytedance/seedance-2.0/image-to-video \
  --image_url "$IMAGE_URL" \
  --prompt "Slow product turn on a studio table; no text or logo" \
  --duration 4 --resolution 720p --aspect_ratio 16:9 \
  --async --json > logs/shot-001-submit.json

REQUEST_ID=$(jq -r '.request_id' logs/shot-001-submit.json)
genmedia status bytedance/seedance-2.0/image-to-video "$REQUEST_ID" \
  --download "outputs/{request_id}_{index}.{ext}" --json > logs/shot-001-result.json

{request_id}_{index}.{ext} 패턴을 쓰면 서로 다른 작업이 아무 경고 없이 같은 파일을 덮어쓸 위험을 줄일 수 있다. JSON도 별도의 임시 디렉터리로 빼기보다 미디어 파일 옆에 보관하자.

복구 가능한 실패에만 재시도 적용하기

실무에서 시작점으로 삼기 좋은 정책은 다음과 같다.

  1. 네트워크 타임아웃, 연결 재설정, HTTP 429, 일시적인 5xx 응답만 재시도한다.
  2. 2초, 4초, 8초, 16초, 32초처럼 지수 백오프를 적용하고, 작은 무작위 지연을 더한다.
  3. 행별 시도 횟수에 상한을 둔다. 예를 들어 제출은 네 번, 일정 시간 창 안의 상태 조회는 다섯 번으로 제한한다.
  4. 401/403 인증 오류, 422 스키마 검증 오류, 안전성 거부, 잘못된 JSON은 재시도하지 않는다.
  5. 제출 요청이 공급자에게 도달했을 가능성이 있는 상태에서 타임아웃됐다면, 두 번째 유료 요청을 만들기 전에 저장된 요청 레코드를 먼저 확인한다.

이 정책은 래퍼가 담당해야 한다. 공개 genmedia README는 명령과 라이프사이클 작업을 설명하지만, 자동 재시도를 보장한다고 말하지는 않는다. 422가 발생하면 validation_errors를 확인하고 genmedia schema를 다시 실행한 뒤, 무작정 재제출하지 말고 지목된 필드를 수정한다.

복사해서 시작할 수 있는 Python 배치 래퍼

아래 래퍼는 동기식 이미지 배치를 위한 출발점이다. 인수 목록과 함께 subprocess.run을 사용하고, 완료된 출력 경로는 건너뛰며, 동시 작업 수를 제한하고, 일시적 프로세스 실패를 재시도한 뒤, 원자적으로 매니페스트를 쓴다. 오래 걸리는 영상 작업은 폴링 전에 비동기 제출 정보를 저장해야 한다. 순서는 submit -> write endpoint/request_id -> restart -> poll saved request_id -> download이며, 요청 ID가 없을 때만 다시 제출한다.

#!/usr/bin/env python3
import concurrent.futures as pool
import json, os, random, subprocess, tempfile, threading, time
from pathlib import Path

ENDPOINT = "fal-ai/flux/dev"
OUT = Path("outputs/images")
LOG = Path("outputs/logs")
MAX_WORKERS = 3
MAX_ATTEMPTS = 4
MANIFEST_LOCK = threading.Lock()

JOBS = [
    {"id": "shoe-001", "prompt": "Black running shoe, clean studio product photo", "file": "shoe-001.png"},
    {"id": "shoe-002", "prompt": "Black running shoe on wet pavement at dawn", "file": "shoe-002.png"},
]

OUT.mkdir(parents=True, exist_ok=True)
LOG.mkdir(parents=True, exist_ok=True)
MANIFEST = Path("outputs/manifest.json")
PREVIOUS = json.loads(MANIFEST.read_text()) if MANIFEST.exists() else {"results": []}
STATE = {r["id"]: r for r in PREVIOUS.get("results", [])}
DONE = {k: r for k, r in STATE.items() if r.get("status") == "complete"}
MAX_REQUESTS = len(JOBS) * MAX_ATTEMPTS
if MAX_REQUESTS > 100:
    raise SystemExit(f"request ceiling exceeded: {MAX_REQUESTS}")

def save_result(result):
    with MANIFEST_LOCK:
        STATE[result["id"]] = result
        payload = {"endpoint": ENDPOINT, "max_requests": MAX_REQUESTS,
                   "results": list(STATE.values())}
        fd, tmp = tempfile.mkstemp(dir=MANIFEST.parent, prefix="manifest.", text=True)
        with os.fdopen(fd, "w") as f:
            json.dump(payload, f, indent=2)
        os.replace(tmp, MANIFEST)

TRANSIENT_WORDS = ("429", "500", "502", "503", "504", "timeout", "temporarily", "connection")

def run_one(job):
    target = OUT / job["file"]
    receipt = LOG / f"{job['id']}.json"
    if job["id"] in DONE and target.exists() and target.stat().st_size > 0 and receipt.exists():
        return DONE[job["id"]]

    cmd = ["genmedia", "run", ENDPOINT, "--prompt", job["prompt"],
           "--num_images", "1", "--download", str(target), "--json"]
    last_error = ""
    for attempt in range(1, MAX_ATTEMPTS + 1):
        try:
            p = subprocess.run(cmd, text=True, capture_output=True, timeout=900)
            raw = p.stdout.strip()
            if p.returncode != 0:
                last_error = p.stderr[-1000:] or raw[-1000:]
                if not any(w in last_error.lower() for w in TRANSIENT_WORDS):
                    break
                if attempt < MAX_ATTEMPTS:
                    time.sleep((2 ** attempt) + random.random())
                continue
            try:
                data = json.loads(raw) if raw else {}
            except json.JSONDecodeError as exc:
                return {**job, "status": "failed", "attempts": attempt, "error": f"invalid JSON: {exc}"}
            if target.exists():
                (LOG / f"{job['id']}.json").write_text(json.dumps(data, indent=2))
                return {**job, "status": "complete", "attempts": attempt, "output": str(target)}
            last_error = p.stderr[-1000:] or raw[-1000:]
            if not any(w in last_error.lower() for w in TRANSIENT_WORDS):
                break
        except (subprocess.TimeoutExpired, OSError) as exc:
            last_error = str(exc)
        if attempt < MAX_ATTEMPTS:
            time.sleep((2 ** attempt) + random.random())
    return {**job, "status": "failed", "attempts": MAX_ATTEMPTS, "error": last_error}

results = []
with pool.ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
    futures = [executor.submit(run_one, job) for job in JOBS]
    for future in pool.as_completed(futures):
        result = future.result()
        results.append(result)
        save_result(result)

print(json.dumps(results, indent=2))

영상 작업에서는 ENDPOINT를 바꾸고 해당 엔드포인트 스키마에 맞는 플래그를 추가한다. image-to-video 체인이라면 로컬 이미지를 genmedia upload ./frame.png --json로 한 번 업로드하고, 반환된 URL을 영상 작업에 전달한 뒤 두 레코드를 모두 매니페스트에 남긴다. 이 래퍼가 genmedia 자체의 지출 추정이나 최대 지출 제한 기능을 제공한다고 주장하는 것은 아니다. 중복된 로컬 작업을 막고 재시도 횟수를 제한할 뿐이다.

유료 생성을 시작하기 전 비용 게이트 두기

genmedia pricing <endpoint_id> --json은 가격 조회 명령일 뿐, 예약이나 예산 상한 기능은 아니다. 배치 실행 전에 가격을 확인하고, 행 수, 행당 출력 수, 해상도·길이 설정, 최대 재시도 횟수를 기준으로 보수적인 상한을 계산해야 한다.

현실적으로 적용하기 좋은 게이트는 다음과 같다.

통제 항목구현 방법
행 수 하드 캡매니페스트가 승인된 건수를 넘으면 시작을 거부한다
모델 티어저렴하거나 빠른 엔드포인트로 초안을 만들고, QA를 마친 결과만 최종 렌더링한다
출력 수 제한기본값에 맡기지 말고 num_images를 명시한다
재시도 예산최초 시도와 재시도를 분리해 집계한다
재개검증된 로컬 출력이 있는 행은 건너뛴다
취소적절한 경우 대기 중인 작업에 genmedia status ... --cancel을 사용한다

모델 가격과 엔드포인트 제공 여부는 바뀔 수 있으므로 가격 조회 응답도 매니페스트와 함께 기록한다. 공급자가 비교 가능한 단위를 제공하지 않는다면, 이를 청구 금액이 아니라 요청 수 상한으로 표기해야 한다.

fal genmedia CLI, Replicate CLI, 커스텀 스크립트의 선택 기준

세 도구는 해결하는 계층이 다르다. Replicate 공식 CLI는 예측 실행과 스트리밍, 모델 스키마 확인, 업로드, 학습, 모델 관리를 위한 명령을 제공한다. 반면 작업의 시작점이 fal 엔드포인트 탐색이고, fal의 큐 및 다운로드 라이프사이클을 다뤄야 한다면 genmedia가 더 적합하다.

선택지잘 맞는 용도주요 트레이드오프
fal genmedia CLIfal 중심의 모델 검색, 스키마 조회, 비동기 작업, 다운로드, 에이전트 셸 사용공급자 종속적이며, 배치 정책은 여전히 CLI 밖에서 관리해야 함
Replicate CLIReplicate 예측, 스트리밍, 모델·스키마 작업, 학습 명령카탈로그와 라이프사이클이 다른 공급자임. fal 엔드포인트 ID나 플래그가 그대로 통할 것으로 기대하면 안 됨
커스텀 Python/HTTP 스크립트멀티 공급자 라우팅, 승인 게이트, 데이터베이스 상태, 큐, 과금 정책인증, 스키마 변경, 폴링, 다운로드, 오류 처리를 직접 책임져야 함

추천은 단순하다. 탐색에는 genmedia를 직접 쓰고, fal만 사용하는 프로덕션 배치에는 작은 Python 래퍼를 더하면 된다. 공급자 전환이 실제 요구사항일 때만 커스텀 공급자 추상화로 옮기자. 래퍼가 더 “엔터프라이즈”처럼 보인다는 이유만으로 갈 필요는 없다.

출력물을 기록으로 남기고 QA까지 마무리하기

공개 workflow skill은 목표, 노드 ID, 엔드포인트 ID, 요청 ID, 입력 URL, 출력 URL, 내려받은 파일, 결함 메모를 담은 간결한 매니페스트를 권한다. 생성된 이름의 파일만 가득한 폴더보다 훨씬 유용하다.

배치를 승인하기 전에 다음을 확인한다.

  1. 모든 complete 행에 로컬 파일과 JSON 영수증이 있다.
  2. failed 행이 아무 흔적 없이 누락되지 않았다.
  3. 이미지의 크기가 예상과 일치하며 파일 크기가 0이 아니다.
  4. 영상이 정상적으로 열리고 예상한 길이, 해상도, 프레임 레이트를 갖는다. 이 확인에는 ffprobe가 적합하다.
  5. 보관할 에셋에는 프롬프트와 엔드포인트 ID가 유지돼 있다.
  6. 같은 매니페스트를 다시 실행했을 때 중복 파일이 생성되는 대신 작업이 건너뛰어진다.

fal 가이드도 생성 미디어를 JSON 메타데이터 가까이에 보관할 것을 강조한다. 이 습관은 나중에 공급자를 옮길 때도 도움이 된다. 파일명만 보고 실행 이력을 복원하려 애쓰는 대신 출력, 요청, 비용 기록을 비교할 수 있기 때문이다.

FAQ

fal genmedia CLI만으로 수백 개 프롬프트를 네이티브 배치 처리할 수 있나?

문서화된 명령은 모델 실행, 비동기 상태 처리, JSON 출력, 업로드, 다운로드를 제공한다. 하지만 수백 개 프롬프트를 재개 가능한 배치로 처리하려면 매니페스트 리더와 동시성 제어기가 여전히 필요하다.

genmedia가 Replicate CLI보다 저렴한가?

CLI가 공급자의 모델 가격을 결정하지는 않는다. 실제 워크로드를 기준으로 특정 엔드포인트 가격, 출력 설정, 재시도 횟수, 전송 방식을 비교해야 한다. 모델 카탈로그가 다른 상황에서 도구 수준으로 어느 쪽이 더 저렴하다고 단정하는 것은 의미가 없다.

가장 짧은 경로를 택한다면, 탐색과 실행에는 genmedia를 사용하고 배치에는 매니페스트 기반 래퍼를 추가하자. 멀티 공급자 라우팅이나 중앙화된 작업 상태가 필요할 때만 커스텀 스크립트를 선택하면 된다.