비동기 전사(Transcription) API: Idempotency, 폴링 및 부분 실패 처리
요약
본 기사는 비동기 전사(Transcription) API의 설계 원칙과 구현 시 주의사항을 다룹니다. 특히, 요청-응답 방식이 아닌 작업 ID 기반의 비동기 처리 흐름, Idempotency를 통한 중복 청구 방지, 그리고 폴링 및 부분 실패 처리가 중요함을 강조합니다.
핵심 포인트
- 전사는 즉각적인 응답이 아닌, 작업 객체(task object)와 ID로 관리되는 비동기 과정이다.
- Idempotency-Key 헤더는 네트워크 오류 시 중복 작업을 방지하는 핵심 메커니즘이다.
- 작업 상태 확인을 위해 폴링(polling) 패턴을 사용해야 하며, 이는 웹 앱 개발의 필수 단계다.
전사 API를 연결하는 것은 반나절 만에 끝낼 수 있는 작업처럼 보입니다. 미디어 URL을 POST하면 JSON이 돌아오고, 배포합니다. 2주 후 결제 페이지에서 380개의 비디오에 대해 412개의 작업(tasks)이 표시되고, 세 명의 사용자가 자막 누락을 언급하며, 지원 티켓에는 전사본이 '만료'되었다는 보고가 올라옵니다.
이러한 실패들은 전사의 품질에 관한 것이 아닙니다. 그것들은 전사를 둘러싼 계약(contract)에 관한 것입니다. 즉, 재시도(retries)를 어떻게 중복 제거할지, 어떻게 기다릴지, 그리고 다중 기능 요청(multi-feature request)의 일부가 실패했을 때 무슨 일이 발생하는지에 대한 문제입니다.
저는 Transcript API를 콘텐츠 파이프라인에 통합하고 있었는데, 이 문서가 이러한 계약들에 대해 비정상적으로 명확하게 설명하고 있어 좋은 구체적인 예시가 되었습니다. 아래 패턴들은 모든 비동기 미디어 API에 적용됩니다.

작업은 비동기적이므로 코드의 형태가 바뀐다
전사는 요청-응답(request-response) 작업이 아닙니다. 90분 분량의 녹음 파일은 처리하는 데 실제 시간이 걸리므로, 생성 호출(create call)은 기다리지 않습니다.
POST /transcriptions는 즉시 HTTP 202와 함께 작업 객체(task object)를 반환합니다:
{
"id": "req_example_001",
"object": "transcription",
...
세 가지 필드가 중요합니다. id는 다른 모든 것의 핸들입니다. poll_url은 진행 상황을 확인할 위치이며, retry_after는 서버가 다시 요청하기 전에 기다리기를 원하는 시간입니다.
웹 앱을 구축하는 경우, 이는 HTTP 요청에 전사본으로 응답할 수 없다는 것을 의미합니다. 작업 ID를 저장하고 클라이언트에게 반환한 다음, 워커(worker)가 작업을 완료하도록 해야 합니다. '왜 내 요청이 30초에서 시간 초과되는지'라는 모든 버그는 이 단계를 건너뛰었기 때문에 발생합니다.
Idempotency는 중복 청구를 막는다
이 API에서 가장 가치 있는 헤더는 Authorization이 아닙니다. 그것은 Idempotency-Key입니다.
동일한 키와 동일한 본문(body)을 사용하고 서버가 HTTP 200으로 원래 작업을 반환합니다. 동일한 키에 다른 본문을 사용하면 HTTP 409 idempotency_conflict를 받게 되는데, 이는 재사용해서는 안 되는 키를 재사용했다는 것을 알려줍니다.
이 조합은 프로덕션 환경에서 정확히 원하는 것입니다. 전송(submission) 과정 중 타임아웃되는 것은 순진한 클라이언트에게 최악의 경우입니다. 작업이 생성되었는지 여부를 알 수 없기 때문입니다. Idempotency가 없다면, 두 번 비용을 지불하며 재시도하거나, 재시도를 건너뛰고 작업을 조용히 잃게 됩니다. 안정적인 키를 사용하면 재시도가 무료가 됩니다.
import os, requests
def create_task(source_url, entity_id):
...
키는 공백이 없는 1~128개의 가시 ASCII 문자로 구성되어야 하며, 이미 가지고 있는 ID에서 파생시켜야 합니다. 타임스탬프에서 파생해서는 안 됩니다. 또한 모델이나 요청된 기능(features)을 의도적으로 변경할 때마다 새 키를 생성해야 하는데, 본문이 변경되었음에도 재사용된 키는 정확히 409 케이스입니다.
서버가 제공하는 값으로 폴링하기
문서화된 루프는 일부러 지루하게 되어 있습니다. retry_after를 읽고, 대기(sleep)한 다음, GET /transcriptions/{request_id}를 호출합니다. 상태가 터미널 상태에 도달할 때까지 중지하면 됩니다.
터미널 상태에는 succeeded, partial_succeeded, failed, cancelled가 있습니다. 그 이전의 모든 상태는 queued 또는 processing이며, 어느 것도 더 빠르게 폴링해야 할 이유는 아닙니다.
속도 제한 헤더(X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset)와 Retry-After가 반환되므로, 추측하는 대신 지능적으로 백오프할 수 있습니다. 빡빡한 폴링 루프는 HTTP 429 rate_limit_exceeded 또는 concurrency_limit_exceeded를 가져올 뿐 다른 것은 아무것도 주지 않습니다.
partial success를 일급 결과물로 취급하기
이것은 대부분의 통합(integrations)이 잘못 이해하는 경우입니다. 단일 작업으로 여러 기능을 한 번에 요청할 수 있습니다: 전사(transcription), 화자 분리(speaker diarization), 챕터, 번역, 오디오 이벤트 등. 만약 챕터는 성공하고 번역은 실패한다면, 작업 상태는 failed가 아니라 partial_succeeded입니다.
상태 응답은 각 기능을 별도로 보고하며, 각각의 상태, 할당량 사용량(quota usage), 오류 객체(error object)를 포함합니다:
그리고 결과 엔드포인트는 성공한 내용만 반환합니다. 실패했거나 요청되지 않은 기능은 null로 돌아옵니다.
따라서 올바른 클라이언트 동작 방식은 다음과 같습니다: partial_succeeded 상태일 경우, 성공한 부분(parts)을 영구 저장하고 나머지 항목에 대해서는 features[].error를 로깅하세요. 챕터 생성에 실패했다고 해서 좋은 전사본 전체를 버려서는 안 됩니다. 청구 시스템도 같은 논리를 따르는데, 이는 들리는 것보다 공정합니다: 실패하거나 취소된 기능에 대한 예약은 해제되며, 부분 성공 요금은 성공한 기능에 대해서만 부과됩니다.
오류는 두 계층에서 발생하며, 둘 다 사용자가 처리해야 합니다
요청 수준의 실패는 HTTP 상태 코드와 안정적인 영어 error.code를 사용합니다. 문서에는 메시지(message)가 아닌 코드를 기준으로 분기해야 한다고 명시되어 있습니다.

작업(Task) 수준의 실패는 별도의 계층입니다. 상태 조회(status query)는 작업 자체가 실패했더라도 HTTP 200을 반환하므로, 작업 자체의 error 필드를 검사해야 합니다. 일반적인 오류 코드는 task_timeout, provider_unavailable, transcription_failed, no_speech_detected 등이 있습니다.
마지막 항목은 대비할 가치가 있습니다. 침묵(Silence), 음악 전용 업로드, 매우 시끄러운 파일에는 합법적으로 음성이 포함되어 있지 않을 수 있습니다. 이는 재시도 문제가 아니라 데이터 문제입니다. 이를 재시도하는 것은 단순히 할당량만 소모합니다.
보존 기간은 결국 사용자에게 영향을 미칠 것입니다
결과는 30일 동안 이용 가능합니다. 자막 다운로드 URL은 비공개(private)이며 서명된(signed) 형태로, 최대 1시간 동안 유효합니다. 작업 기록은 UTC 기준으로 생성 월과 다음 달까지 보관됩니다.
만약 애플리케이션이 6개월 후에 전사본(transcript)을 필요로 한다면, 작업이 성공하는 즉시 이를 자체 스토리지에 복사해야 합니다. result_url을 영구적인 것으로 취급하지 마십시오. 또한 다운로드 URL이 세션 중간에 만료될 경우, 보존 기간 내에 결과 엔드포인트를 다시 호출하면 새로운 URL이 발급되는데, 이는 사람들이 이 기능을 고장 났다고 결론내리기 전에 놓치는 간단한 해결책입니다.
클라이언트가 이미 AI 에이전트인 경우
위의 패턴들은 클라이언트가 사용자가 제어하는 서비스라고 가정합니다. 만약 클라이언트가 AI 에이전트라면, 대부분의 연결 고리(glue)를 건너뛸 수 있습니다. 동일한 백엔드가 https://videotranscriber.ai/mcp에서 원격 모델 컨텍스트 프로토콜(Model Context Protocol) 서버를 제공합니다 (Streamable HTTP, Bearer API Key). 이 서버는 본문 기사의 세 호출과 1:1로 매핑되는 세 가지 도구(tool)를 가지고 있습니다: create_transcription, get_transcription, get_transcription_result. 이를 에이전트 클라이언트의 단일 MCP 항목으로 구성하면, 인증(auth), 폴링(polling), 영속성(persistence) 단계가 서비스 내부가 아닌 에이전트 자체의 도구 루프에서 실행됩니다.
명시적인 워크플로우가 필요한 에이전트의 경우, 공식 VideoTranscriberAI/videotranscriber-skills 리포지토리의 transcribe-media 에이전트 스킬(Agent Skill)은 이 기사에서 설명하는 것과 동일한 규칙을 설치합니다: 한 번 생성하고, 요청 ID를 영속화하며, retry_after를 존중하고, 터미널 상태에서는 중단하며, partial_succeeded를 부분적인 성공으로 간주합니다. 다음 명령어로 설치할 수 있습니다: npx skills add VideoTranscriberAI/videotranscriber-skills --skill transcribe-media --agent codex --global. 그러면 MCP 서버가 그 위에 별도로 구성됩니다.
결정적으로, 이 모든 것은 계약(contract)을 변경하지 않습니다. Idempotency 키, retry_after 폴링, partial_succeeded 처리, 2단계 오류, 그리고 30일 보존 기간은 모두 여전히 적용됩니다. 왜냐하면 MCP 서버와 스킬이 동일한 OpenAPI 백엔드를 호출하기 때문입니다.
생존하는 통합의 모습
실제 운영 환경에서 사용되는 버전은 대략 150줄로 구성됩니다: Idempotency를 갖춘 생성(create) 로직, retry_after를 준수하는 폴러(poller), 두 개가 아닌 네 개의 분기(branch)를 가진 상태 처리 핸들러(status handler), 그리고 성공 시 실행되는 영속성 단계(persistence step)입니다.
Video Transcriber AI의 Transcript API는 신용카드 없이 첫 키 생성 시 1,500 API 할당량(Quota)을 제공합니다. 이 정도면 실제로 미디어를 대상으로 루프를 돌려보며 어떤 것에 전념할지 결정하기에 충분합니다. 할당량은 만료되지 않으므로 프로젝트를 한 달 동안 중단해도 무료 할당량이 낭비되지 않습니다.
첫 줄을 작성하기 전에 에러 코드 목록을 읽어보세요. 덕분에 제가 추측하는 시간을 아낄 수 있었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기