TypeScript로 안정적인 비동기 AI 비디오 파이프라인 설계하기
요약
AI 비디오 생성 API를 프로덕션 환경에서 안정적으로 운영하기 위한 비동기 파이프라인 설계 방법을 다룹니다. 상태 머신 도입, 로컬 및 외부 작업 ID의 분리, 서버 측 설정 검증을 통해 공급자 의존성을 낮추고 시스템 안정성을 높이는 아키텍처를 제시합니다.
핵심 포인트
- 상태 머신을 사용하여 공급자별 상태를 내부 표준 어휘로 매핑
- 로컬 ID와 외부 ID를 분리하여 권한 검사 및 멱등성 유지
- 모델 설정값 검증을 서버 측에서 수행하여 오류 방지
- 공급자 중립적인 어댑터 패턴을 통한 확장성 확보
AI 비디오 API를 호출하는 것은 데모에서는 간단해 보입니다:
- 프롬프트 전송.
- 응답 대기.
- 비디오 표시.
하지만 프로덕션 환경에서는 모델이 원래 HTTP 요청에서 비디오를 반환하는 경우는 거의 없습니다. 대신 원격 작업(remote job)을 생성하고, 외부 작업 ID(external task ID)를 제공하며, 공급자별 상태(provider-specific states)를 거쳐 마침내 미디어 URL이나 오류 중 하나를 반환합니다. 이 과정 동안 애플리케이션은 사용자 상태(user state), 결제 상태(billing state), 검열 상태(moderation state), 그리고 공급자 상태(provider state)를 일관되게 유지해야 합니다.
이 글에서는 MICT와 같은 AI 이미지 및 단편 비디오 작업 공간 제품을 위한 공급자 중립적인 레퍼런스 아키텍처를 제시합니다. 코드 샘플은 MICT의 프로덕션 구현에 대한 문자 그대로의 설명이라기보다는 예시적입니다. 유용한 부분은 특정 API 호출이 아니라, 그 호출 주변을 둘러싼 경계(boundaries)들의 집합입니다.
실제 워크플로우는 상태 머신이다
안정적인 생성 요청은 '로딩'과 '완료'보다 더 많은 단계를 거칩니다:
request
-> authenticate
-> validate model settings
...
이를 명시적인 상태 머신(state machine)으로 취급하면 모든 계층이 공유된 어휘(vocabulary)를 갖게 됩니다. 간결한 내부 집합만으로 충분한 경우가 많습니다:
type GenerationStatus =
| "running"
| "processing"
...
공급자들은 엣지(edge)에서 자체적인 어휘를 유지할 수 있습니다:
function mapProviderState(state: string): GenerationStatus {
switch (state) {
case "waiting":
...
중요한 설계 선택은 제품의 나머지 부분이 모든 공급자의 철자(spelling)에 따라 분기하지 않는다는 것입니다. 어댑터가 그 역할을 한 번 수행합니다.
로컬 및 외부 작업 ID를 분리 유지하라
공급자를 호출하기 전에 자체적인 작업을 생성하고 영속화(persist)하십시오.
const taskId = createLocalTaskId();
await persistGeneration({
...
로컬 ID는 귀하의 제품에 속합니다. 다음 용도로 사용하세요:
- 권한 검사(authorization checks);
- 생성 기록(generation history);
- 크레딧 거래(credit transactions);
- 지원 참조(support references);
- 분석(analytics);
- 멱등성(idempotency).
외부 ID는 공급자에게 속합니다. 원격 작업을 조회하거나 취소할 때만 사용하십시오.
이러한 분리는 공급업체가 변경되거나, 특정 공급업체가 일시적으로 교체되거나, 또는 외부 작업 ID(external task ID)를 전혀 받지 못한 로컬 작업(local job)을 참조하는 지원 티켓(support ticket)이 발생할 때 매우 중요합니다.
비용이 발생하기 전에 설정값 검증하기
서로 다른 비디오 모델들은 모드(mode), 지속 시간(duration), 해상도(resolution), 종횡비(aspect ratio), 출력 개수(output count), 오디오 설정(audio settings)의 다양한 조합을 허용합니다. 클라이언트가 이러한 조합을 임의로 만들어내도록 방치하지 마십시오.
기능(capabilities) 정보는 서버 측 모델 설정(server-side model configuration)에 유지하십시오:
type ModelConfig = {
modes: Array<"t2v" | "i2v">;
durations: number[];
...
그 다음, 중재(moderation), 과금(billing), 또는 공급업체 제출(provider submission)이 이루어지기 전에 지원되지 않는 값을 거부하십시오:
if (!config.modes.includes(mode)) {
throw new RequestError("Unsupported generation mode");
}
...
클라이언트 측 검증(Client-side validation)은 사용자 피드백을 주는 데 유용합니다. 하지만 서버 측 검증(Server-side validation)은 귀하의 비용과 데이터를 보호하는 경계선입니다.
생성 전에 중재(moderation)를 배치하기
입력 중재(Input moderation)는 공급업체 작업(provider task)이 생성되기 전과 크레딧(credits)이 차감되기 전에 수행되어야 합니다.
const moderation = await moderateRequest({
userId,
model,
...
이렇게 하면 귀하의 자체 정책(policy)에 의해 거부될 요청에 대해 상위 공급업체(upstream provider)에 비용을 지불하는 상황을 방지할 수 있습니다. 또한 UI가 정책 결정(policy decision)과 기술적 실패(technical failure)를 구분할 수 있게 해줍니다.
출력 중재(Output moderation)는 별도의 게이트(gate)입니다. 공급업체는 귀하의 애플리케이션이 배포해서는 안 되는 미디어를 성공적으로 생성할 수도 있습니다. 출력물은 해당 게이트를 통과할 때까지 completed 상태가 되어서는 안 됩니다.
provider success
-> extract result URL
-> moderate result
...
입력 중재, 공급업체 정책 오류(provider policy errors), 그리고 출력 중재를 하나의 일반적인 "생성 실패(generation failed)" 토스트(toast) 메시지로 통합하지 마십시오. 이들은 서로 다른 재시도 규칙(retry rules)과 지원 경로(support paths)를 가집니다.
크레딧 작업의 멱등성(idempotency) 보장하기
AI 생성 제품에서 가장 치명적인 버그는 종종 금융 상태(financial state)와 관련된 버그입니다:
- 제공업체가 요청을 거부했으나 크레딧은 차감된 경우;
- 폴링 (polling)이 두 번 실행되어 환불이 두 번 이루어진 경우;
- 제공업체의 작업이 시작된 후 데이터베이스 쓰기에 실패한 경우;
- 네트워크 타임아웃 (network timeout) 이후 브라우저가 요청을 재시도한 경우.
모든 결제에는 안정적인 소스 키 (source key)가 있어야 합니다:
await decreaseCredits({
userId,
amount: creditCost,
...
환불은 동일한 소스를 참조해야 합니다:
await refundCreditsBySource({
sourceType: "generation_charge",
sourceId: taskId,
...
데이터베이스는 관련 소스 또는 트랜잭션 키 (transaction key)에 대해 유일성 (uniqueness)을 강제해야 합니다. 애플리케이션 레벨의 if (!refunded) 체크는 도움이 되지만, 동시성 폴링 (concurrent polling) 상황에서는 그것만으로는 충분하지 않습니다.
최소 두 가지의 환불 경로가 필요합니다:
- 제공업체 작업을 생성할 수 없는 경우.
- 제공업체가 작업을 수락했으나, 이후 최종 실패 (terminal failure) 상태에 도달한 경우.
try {
const external = await provider.createTask(modelId, input);
await attachExternalTaskId({ taskId, externalTaskId: external.id });
...
폴링 중 최종 실패 (terminal failure)가 발생한 경우:
if (status === "failed") {
await refundByGenerationSource(taskId);
await markGenerationFailed(taskId, publicErrorMessage);
...
환불이 멱등성 (idempotent)을 갖추면, 반복적인 폴링 (polling)은 훨씬 덜 두려운 작업이 됩니다.
폴링은 지루해야 한다
웹소켓 (WebSockets)은 체감 응답성을 개선할 수 있지만, 몇 초 또는 몇 분이 소요되는 제공업체 작업의 경우 폴링 (polling)이 종종 가장 견고한 첫 번째 구현 방식입니다.
상태 엔드포인트 (status endpoint)는 다음을 수행해야 합니다:
- 요청 인증 (authenticate);
- 로컬 생성 (local generation) 데이터 로드;
- 해당 데이터가 사용자에게 속하는지 확인;
- 로컬 최종 상태 (local terminal states)인 경우 즉시 반환;
- 최종 상태가 아닌 작업에 대해서만 제공업체에 쿼리 (query);
- 결과 정규화 (normalize);
- 로컬 상태 업데이트;
- 작고 안정적인 응답 반환.
type StatusResponse = {
status: GenerationStatus;
progress?: number;
...
클라이언트는 제공자(provider)의 원시 페이로드(raw payload)를 가질 필요가 없습니다. 원시 페이로드는 구현 세부 사항을 유출하며, 프론트엔드 동작이 불안정한 제3자 스키마(third-party schemas)에 의존하게 만듭니다.
합리적인 브라우저 루프(browser loop)는 다음과 같습니다:
async function waitForGeneration(taskId: string) {
while (true) {
const result = await getStatus(taskId);
...
프로덕션 코드에서는 컴포넌트가 언마운트(unmount)되거나, 탭이 숨겨져 폴링(polling)이 일시 중지 또는 느려질 때, 그리고 클라이언트 측 최대 대기 시간을 적용할 때 폴링을 중단해야 합니다. 브라우저가 요청을 중단하더라도 서버에서는 작업이 계속 진행될 수 있습니다.
제공자 결과값을 방어적으로 파싱하기
단일 제공자라 하더라도 모델에 따라 서로 다른 결과 형태를 반환할 수 있습니다:
{ "resultUrls": ["https://..."] }
{ "video_url": "https://..." }
{ "output": [{ "url": "https://..." }] }
결과 추출 로직을 어댑터(adapter) 내부에 유지하고, 비어 있지 않은 문자열만 허용하십시오:
function extractResultUrl(raw: string): string | undefined {
const result = JSON.parse(raw);
const candidates = [
...
사용 가능한 결과 URL 없이 success라고 말하는 제공자는 완료된 사용자 경험(user experience)이라고 할 수 없습니다. 작업을 종료되지 않은 상태(non-terminal)로 유지하거나, 명확하게 진단된 실패 경로로 이동시키십시오.
늦게 도착한 성공이 실패를 덮어쓰지 않도록 하기
비동기 시스템에서는 어색한 순서가 발생할 수 있습니다:
- 작업이 성공한 것으로 나타남;
- 출력 모더레이션(output moderation) 시작;
- 다른 프로세스가 생성을 실패로 표시;
- 늦게 도착한 업데이트가 완료로 표시하려고 시도함.
조건부 업데이트(conditional update)를 사용하십시오:
update generations
set status = 'completed', result_url = $1
where task_id = $2
...
만약 업데이트가 영향을 미친 행(row)이 0개라면, 현재 상태를 다시 불러오고 해당 상태를 반환하십시오. 최종적인 안전 결정(terminal safety decisions)은 늦게 도착한 성공 이벤트보다 우선해야 합니다.
에러 카테고리 보존하기
사용자는 에러의 종류에 따라 서로 다른 조치를 취해야 합니다:
| 에러 카테고리 | 사용자 조치 |
|---|---|
| 크레딧 부족 (insufficient credits) | 크레딧 추가 또는 더 저렴한 설정 선택 |
| ... |
안정적인 에러 코드(error codes)를 반환하고, 사용자에게 안전한 메시지를 별도로 유지하십시오:
throw new RequestError(
"지금은 이 생성을 시작할 수 없습니다.",
"provider_unavailable",
...
상위 계정 잔액(upstream account balances), 내부 모델 경로(internal model routes), 또는 가공되지 않은 정책 메시지(raw policy messages)를 브라우저에 노출하지 마십시오.
테스트해야 할 항목
해피 패스(happy-path) 비디오만으로는 충분하지 않습니다. 상태 전이(state transitions)를 테스트하십시오:
- 잘못된 모델 설정(invalid model settings)은 크레딧을 차감하지 않아야 함;
- 입력 모더레이션(input moderation) 거부 시 크레딧을 차감하지 않아야 함;
- 프로바이더(provider) 생성 실패 시 1회 환불되어야 함;
- 프로바이더의 최종 실패(terminal failure) 시 1회 환불되어야 함;
- 반복적인 상태 폴링(status polling)이 환불을 중복 발생시키지 않아야 함;
- 한 사용자가 다른 사용자의 작업을 읽을 수 없어야 함;
- URL이 없는 프로바이더 성공은 완료(completed) 상태가 되지 않아야 함;
- 출력 모더레이션(output moderation) 실패는 나중에 발생한 성공에 의해 덮어씌워질 수 없어야 함;
- 최종 로컬 상태(terminal local states)가 프로바이더에 계속 쿼리하지 않아야 함;
- 알 수 없는 프로바이더 상태는 복구 가능한 상태(recoverable)로 유지되어야 함.
가장 좋은 테스트는 불변량(invariants)을 대상으로 합니다:
수락된 생성 1회 <= 차감된 비용 1회
실패한 유료 생성 1회 <= 환불 1회
완료(completed) => 사용 가능한 결과 URL
...
더 큰 교훈
AI 비디오 인터페이스는 창의적인 UI가 결합된 분산 시스템(distributed system)입니다. 모델 호출은 단지 하나의 단계일 뿐입니다. 검증(validation), 권한 부여(authorization), 과금(billing), 폴링(polling), 모더레이션(moderation), 그리고 장애 복구(failure recovery)와 같은 다른 모든 단계가 명확한 경계(explicit boundary)를 가질 때 제품은 신뢰할 수 있게 됩니다.
작은 내부 상태 머신(state machine)부터 시작하십시오. 프로바이더 관련 어휘(provider vocabulary)는 가장자리(edge)에 두십시오. 모든 차감 항목에 안정적인 소스(stable source)를 부여하십시오. 환불은 멱등성(idempotent)을 유지하도록 만드십시오. 모더레이션을 사전 점검용 체크박스가 아닌 라이프사이클(lifecycle)의 일부로 취급하십시오. 그런 다음 폴링을 의도적으로 지루하게 만드십시오.
그러한 토대는 생성 데모(generation demo)보다 덜 화려하지만, 데모를 실제 제품으로 만들어 주는 핵심입니다.
공개 사항: 이 기사는 본 아키텍처의 제품 컨텍스트로 한 번 언급된 MICT를 위해 작성되었습니다. 초안 작성 및 편집에는 AI 도구가 활용되었으며, 코드, 주장 및 최종 텍스트는 출판 전 검토를 거쳤습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기