Temporal 워크플로 시그널: AI 영상 웹훅에서 보내기
영상 작업은 Temporal 액티비티에서 시작하고, 웹훅 수신기가 워크플로 ID로 보내는 시그널을 기다리세요. 시그널과 별개로 실행을 읽는 타이머도 두세요.

Temporal 워크플로 시그널은 클라이언트가 워크플로 ID를 지정해, 실행 중인 워크플로에 보내는 비동기 메시지입니다. 워크플로는 시그널 핸들러에서 이를 처리하는데, 핸들러는 워크플로의 상태를 바꿀 수 있지만 값을 반환할 수는 없고, 보낸 쪽은 워크플로가 처리할 때까지 기다리지 않습니다. 그래서 AI 영상 렌더링 같은 외부 작업이 웹훅을 보낼 때 워크플로를 깨우는 방법으로는 시그널이 자연스럽습니다.
여기서 외부 작업은 Sume Format 실행입니다. 액티비티가 실행을 시작하고, Sume가 결과를 POST하면 웹훅 수신기가 워크플로에 시그널을 보내며, POST가 오지 않는 실행은 타이머가 처리합니다. Temporal 관련 내용은 Temporal의 메시지 전달, Python 메시지 전달, 워크플로 정의, 액티비티, 재시도 정책 페이지에서, Sume 관련 내용은 Format 호출하기 (영문), 실행과 결과 (영문), Run 웹훅 (영문)에서 가져왔습니다. 모두 2026-09-27에 확인했습니다. Sume에는 Temporal 전용 연동이 없으며, 액티비티가 Sume의 HTTPS API를 직접 호출합니다.
API 호출은 왜 워크플로가 아니라 액티비티에 두나요?
Temporal은 워크플로 코드를 재생(replay)할 수 있도록 결정적이어야 한다고 요구하며, API 호출은 액티비티에 두라고 안내합니다. 액티비티는 재생 경로 밖에서 실행되고 자동으로 재시도됩니다. 기본 재시도 정책은 1초 간격에서 시작해 백오프 계수 2.0, 시도 횟수 무제한이며, 그래서 Temporal은 액티비티를 멱등하게 만들라고 권장합니다.
유료 API라면 이 점이 중요합니다. 재시도된 액티비티가 영상을 한 번 더 사서는 안 됩니다. 워크플로 ID와 같은 비즈니스 ID로 만든 Idempotency-Key를 Sume에 보내세요. 같은 키에 같은 본문이면 200과 원래 실행이 반환되고, 두 번째 실행도 두 번째 청구도 없습니다. 같은 키에 다른 본문이면 409 idempotency_conflict이므로, 본문 전체도 그 ID로 만드세요.
start_run(order_id)는/v1/formats/acme/product-promo/runs로 POST합니다. 워크플로 ID로도 쓰는 문자열인Idempotency-Key: video-<order_id>와https://hooks.example.com/sume?workflow_id=video-<order_id>같은communication.webhook_url을 함께 보내고,data.id를 반환합니다.read_run(run_id)는/v1/format-runs/{run_id}를 GET하고, 영수증에서status,primary_output_url,error만 반환합니다.
워크플로는 시그널을 어떻게 기다리나요?
워크플로는 실행을 시작한 뒤, 시그널 핸들러가 그 실행의 ID를 기록할 때까지 workflow.wait_condition에서 대기합니다. timeout을 주면 시간이 다 됐을 때 wait_condition이 asyncio.TimeoutError를 던지며, 워크플로는 어느 쪽이든 실행을 한 번 읽습니다.
import asyncio
from datetime import timedelta
from temporalio import workflow
@workflow.defn
class VideoWorkflow:
def __init__(self) -> None:
self.ended_run_id: str | None = None
@workflow.signal
def run_ended(self, run_id: str) -> None:
self.ended_run_id = run_id # a repeated delivery sets the same value
@workflow.run
async def run(self, order_id: str) -> dict:
t = timedelta(seconds=30)
run_id = await workflow.execute_activity(start_run, order_id, start_to_close_timeout=t)
try:
await workflow.wait_condition(
lambda: self.ended_run_id == run_id, timeout=timedelta(minutes=100))
except asyncio.TimeoutError:
pass # no webhook came: canceled, or every delivery refused
return await workflow.execute_activity(read_run, run_id, start_to_close_timeout=t)웹훅 수신기는 시그널을 어떻게 보내나요?
먼저 원본 본문에 대한 Sume의 서명을 검증합니다. 빈 서명 시크릿은 거부하고, <timestamp>.<raw_body>에 대한 HMAC-SHA256을 계산해 sume-v1= 항목 중 하나라도 상수 시간 비교로 일치하는지, 타임스탬프가 오 분 범위 안에 있는지 확인하세요. 이 코드는 FastAPI·Django에서 Python 웹훅 HMAC 검증하기에 있습니다. 그런 다음 워크플로 ID로 시그널을 보냅니다. 수신기는 보통 워크플로 클래스를 import하지 않으므로, Temporal의 타입 없는 get_workflow_handle을 쓰고 시그널 이름을 넘깁니다.
signal은 Temporal 서버가 시그널을 받아들이면 반환하며, 워크플로에 전달될 때까지 기다리지 않습니다. 그래서 수신기는 Sume가 시도마다 주는 10초 안에2xx로 응답할 수 있습니다.- 시그널은 아직 닫히지 않은 워크플로 실행에만 전달되며, 시그널이 실패하면
RPCError가 발생하고 워크플로가 없을 때는 상태가NOT_FOUND입니다. 이미 사라진 워크플로라면2xx로 응답하세요. 그러지 않으면 Sume가 재시도하며, 시도는 최대 10회입니다. 다른 오류라면2xx가 아닌 응답을 보내 재시도를 받으세요. - 각 시그널은 워크플로의 Event History에 기록되고 핸들러는 값을 저장하기만 하므로,
wait_condition이 실행되기 전에 도착한 웹훅도 사라지지 않습니다. - 실행 ID만 보내세요. Sume 영수증은 1 MiB에 이를 수 있지만, Temporal의 셀프 호스팅 기본값은 페이로드가 256 KB면 경고합니다. 영수증은 대신
read_run이 가져옵니다.
from temporalio.client import Client
async def on_verified_delivery(client: Client, workflow_id: str, event: dict) -> None:
handle = client.get_workflow_handle(workflow_id) # from ?workflow_id=
await handle.signal("run_ended", event["run_id"])수신기에서 Signal-With-Start를 써야 하나요?
아니요, 쓰지 마세요. Signal-With-Start는 시그널을 보내면서, 워크플로가 아직 실행 중이 아니면 워크플로 실행을 시작합니다. Python에서는 start_workflow에 start_signal을 넘깁니다. 웹훅 수신기에서 이를 쓰면 늦게 오거나 반복된 전달이, 이미 있는 영상을 위한 새 워크플로가 되어 버립니다. Signal-With-Start는 작업을 만들어야 하는 메시지에만 쓰고, 결과는 일반 시그널로 보내세요.
워크플로는 얼마나 기다려야 하나요?
Sume 자체의 기한을 넘길 만큼 기다리세요. 아직 진행 중인 실행은 created_at으로부터 90분 뒤에 failed로 강제 종료되므로, 타임아웃을 100분으로 두면 워크플로가 읽을 때쯤에는 실행이 끝나 있습니다. 타임아웃은 Temporal 타이머를 만들고 타이머는 영속적으로 저장되므로, 워커가 재시작돼도 대기는 이어집니다. 이 타이머는 시그널을 보내지 않는 실행도 잡아냅니다.
| 실행에 일어난 일 | 웹훅 | 워크플로가 보는 것 |
|---|---|---|
| 완료 또는 실패 | 서명된 POST 한 번, 최대 10회 시도까지 재시도 | 시그널 |
| 취소 | 보내지 않음 | 타임아웃. read_run에 canceled가 보임 |
| 엔드포인트가 10회 시도를 모두 거부 | 전달 실패. 실행은 그대로 | 타임아웃. read_run에 결과가 있음 |
출처
- Format 호출하기 (영문)
- 실행과 결과 (영문)
- Run 웹훅 (영문)
- Temporal: 워크플로 메시지 전달 (2026-09-27 확인)
- Temporal: 워크플로 메시지 전달, Python SDK (2026-09-27 확인)
- Temporal Python API: temporalio.workflow (2026-09-27 확인)
- Temporal: 워크플로 정의 (2026-09-27 확인)
- Temporal: Temporal 액티비티란? (2026-09-27 확인)
- Temporal: 재시도 정책 (2026-09-27 확인)
- Temporal: 타이머, Python SDK (2026-09-27 확인)
- Temporal: 셀프 호스팅 기본값과 한도 (2026-09-27 확인)
관련 글
연동 카테고리의 다른 글
- ngrok·Cloudflare Tunnel로 Sume 웹훅 로컬 테스트하기
Sume는 localhost 웹훅 URL을 거부합니다. ngrok이나 Cloudflare Quick Tunnel로 핸들러를 노출하고, 서명된 테스트를 보낸 뒤, 실제 이벤트를 다시 보내세요.
- Python Text-to-Video API: 제출, 폴링, 다운로드
텍스트로 영상을 만드는 Sume API를 Python Requests로 호출하세요. POST /v1/videos 후 타임아웃을 두고 폴링하고, content 경로가 리다이렉트하는 MP4를 스트리밍하세요.
- n8n 오디오 텍스트 변환: HTTP Request 노드로 전사하기
n8n에서 오디오를 텍스트로 전사하세요. HTTP Request 노드로 녹음 파일의 공개 URL을 보내고, Job이 끝날 때까지 Wait 노드를 반복한 뒤 텍스트를 매핑합니다.
- Python으로 URL의 파일을 S3 버킷에 업로드하는 방법
URL의 응답을 임시 파일 없이 boto3의 upload_fileobj로 바로 스트리밍하세요. 생성된 영상이라면 웹훅을 받을 때 복사하고 바이트를 확인하세요.
작성자 Sume