개인화 인트로를 본편에 붙여 DM으로 보내는 도구
하루 500건의 인트로를 본편에 붙여 DM으로 내보내기
사람 손으로는 못 붙이는 500건
B2B 아웃바운드 마케팅 캠페인의 영상 발송을 자동화하는 작업이었습니다. 잠재 고객마다 이름을 부르는 39초짜리 인트로를 녹화하고, 그 뒤에 3분짜리 소개 본편을 붙여 인스타그램 DM으로 보내는 흐름입니다.
처리량은 하루 500건입니다. 인트로는 사람마다 다르지만 뒤에 붙는 본편은 500번 모두 같은 파일입니다. 링크를 받은 사람이 어디까지 봤고 어디서 이탈했는지도 기록으로 남아야 했습니다. 여기에 비용 제약이 붙습니다. 하루 500개의 영상을 만들어 30일 동안 보관하고 스트리밍하면서, 사람 손은 들어가지 않고 월 고정비는 낮게 유지되어야 했습니다. 이 조건들이 설계를 결정했습니다.

재인코딩을 피해 간 선택들
FFmpeg concat demuxer + -c copy
두 입력의 인코딩 파라미터가 같으면 디코딩 없이 컨테이너만 다시 씁니다. 3분짜리 본편을 매번 재인코딩하면 CPU가 5배로 뛰는데, 그 파일은 500번 모두 바이트가 같아서 재인코딩할 이유가 없어요. 파라미터가 하나라도 다르면 concat 은 오디오 싱크가 깨진 파일을 조용히 뱉습니다. 그래서 ffprobe 로 먼저 비교하고, 어긋나면 39초짜리 인트로만 본편 규격으로 정규화합니다.
Cloudflare Queues + Containers
스티치 한 건이 수십 초라 웹훅 응답 시간 안에 끝낼 수 없습니다. 인제스트는 검증하고 큐에 넣고 202를 돌려주고 끝, 무거운 일은 전부 큐 뒤에서 돕니다. 재시도 권한도 전부 큐가 갖습니다. 컨테이너에는 Cloudflare 바인딩이 없습니다. env.VIDEOS 도 env.DB 도 없고 환경변수와 아웃바운드 인터넷뿐이에요. 이 제약이 아래 presigned PUT 설계를 강제했습니다.
R2 호스팅 + 자체 /s/:id.mp4 Range 스트리밍
egress 가 $0 입니다. 같은 트래픽을 S3 + CloudFront 로 내보내면 월 $81, 원래 브리프의 1080p 가정대로면 $270이 나옵니다. 링크를 더 많이 공유해도 비용이 0이라 공유를 아낄 이유가 없어집니다. 모바일 사파리는 206 Content-Range 가 정확하지 않으면 스크럽은 물론 재생 자체를 거부합니다. Range 파싱을 직접 구현하고 유닛 테스트를 붙였습니다.
D1 + INSERT ... ON CONFLICT DO NOTHING RETURNING
자동화 도구의 재시도와 녹화 도구의 재발행이 같은 intro_url 을 두 번 보냅니다. 읽고 나서 쓰면 동시 요청 둘이 모두 통과해요. 한 번의 왕복으로 원자적으로 끝내고, 실제로 INSERT 를 이긴 쪽만 큐에 넣습니다. D1은 트랜잭션이 넉넉하지 않아서 SQL 한 문장에 멱등성을 담아야 했습니다.
YouTube 업로드 대신 R2, 다만 인터페이스는 남김
원래 요구된 목적지는 YouTube 였습니다. 그런데 업로드 1건이 1,600 쿼터 유닛, 하루 기본 할당량이 10,000 유닛입니다. 하루 6건이 천장이에요. 비용 문제가 아니라 아예 막히는 문제였습니다. 쿼터 확장이 승인될 수도 있어서 YouTubeTarget 을 실제 resumable upload 와 OAuth 갱신까지 구현해 두고 쿼터 가드로 막아 뒀습니다. 켜는 일이 통합 작업이 아니라 DELIVERY_TARGET 한 줄이 되도록요.

Worker를 지나지 않는 영상 바이트
흐름은 한 줄로 말하면 이렇습니다. 자동화 시나리오가 인트로 URL을 던지면, 인제스트 Worker 가 검증하고 중복을 걸러 큐에 넣고 밀리초 만에 응답합니다. 큐 컨슈머가 메시지를 하나씩 집어 hash(job_id) % POOL_SIZE 로 컨테이너 슬롯을 고르고, HMAC 으로 서명한 POST /stitch 를 보냅니다. 컨테이너는 인트로를 내려받아 ffprobe 로 본편과 비교하고, 이어 붙이고, 썸네일을 뽑아 R2로 올립니다. 컨슈머는 결과를 D1에 적고 서명된 콜백을 쏩니다.
여기서 한 가지가 눈에 띌 겁니다. 완성된 영상 바이트가 Worker 를 한 번도 지나지 않습니다. 컨테이너에는 R2 바인딩이 없으니, 컨슈머가 30분짜리 presigned S3 PUT URL 을 발급하고 컨테이너가 그 URL 로 직접 스트리밍합니다. 서명 키는 Worker 밖으로 나가지 않고, 유출된 URL 도 30분 뒤 만료되며 자기가 지명한 키 하나에만 쓸 수 있습니다.
반대 방향도 같습니다. 잠재 고객이 /w/:id 를 열면 딜리버리 Worker 가 D1에서 잡 행을 읽어 OG 태그가 박힌 랜딩을 서버 렌더링하고, 영상 자체는 /s/:id.mp4 가 R2 바인딩으로 Range 스트리밍합니다. 재생 이벤트는 sendBeacon 으로 /e 에 들어와 D1에 적히고, 익명 상태로 분석 도구에 전달됩니다.

복사 경로를 지키는 일은 ffprobe 를 어떻게 읽느냐에 달려 있었습니다
두 mp4 를 concat demuxer 에 넣고 -c copy 로 붙입니다. 대개는 잘 붙어요. 그래서 더 위험합니다. 파라미터가 어긋난 입력을 넣어도 FFmpeg 은 에러를 내지 않고, 뒷부분 오디오가 서서히 밀린 파일을 만들어 냅니다. 반대편 극단도 흔합니다. 안전하게 가자며 매번 전체를 재인코딩하는 것인데, 그러면 500번 모두 똑같은 3분짜리 본편을 500번 다시 인코딩하게 됩니다.
ffprobe 로 두 입력의 코덱, 해상도, fps, pix_fmt, profile, 오디오 코덱·샘플레이트·채널을 전부 비교해 불일치 목록을 만들고, 목록이 비었을 때만 -c copy 로 갑니다. 어긋나면 39초짜리 인트로만 본편 파라미터로 정규화하고, 3분짜리 본편은 어떤 경우에도 재인코딩하지 않습니다. fps 비교에는 avg_frame_rate 가 아니라 r_frame_rate 를 먼저 씁니다. 실제 30fps CFR 인트로가 avg=4365000/141839(30.77)로 보고돼서, 그대로 뒀다면 모든 작업이 재인코딩 경로로 갔을 겁니다. 그리고 encode_path 를 잡 행과 로그에 남겨, 상류 내보내기 프리셋이 흔들리면 청구서보다 먼저 보이게 했습니다.
복사 경로는 파일 I/O 라 10초, 재인코딩 경로는 1 vCPU 에서 60~90초입니다. 비교를 느슨하게 하면 깨진 영상이 고객에게 가고, 비교를 포기하면 컴퓨트가 7배가 됩니다. 엄격하게 비교하되 재인코딩 대상을 인트로 39초로 한정하면, 최악의 경우에도 비용이 전체 재인코딩의 1/5 수준에서 멈춥니다.
컨테이너 요금은 CPU가 아니라 켜져 있던 시간으로 매겨집니다
처리량이 부족하면 max_concurrency 를 올립니다. 그래도 안 되면 슬롯을 늘립니다. 그리고 콜드 스타트가 아까우니 sleepAfter 를 넉넉히 15분쯤 줍니다. 셋 다 자연스러운 판단인데, 셋 다 이 시스템에서는 틀렸어요.
500건 버스트로 측정해 보고 세 가지를 바꿨습니다. 첫째, max_concurrency 는 POOL_SIZE × MAX_CONCURRENT_STITCHES 를 넘으면 의미가 없습니다. 초과분은 컨테이너 안 세마포어에서 대기할 뿐이라, 4에서 32로 올렸을 때 드레인 시간은 30%만 줄었는데 슬롯 대기는 1.1초에서 44.5초로 늘었습니다. 큐를 컨테이너 안으로 옮긴 셈이죠. 둘째, 슬롯보다 레인을 늘렸습니다. 작업은 hash(job_id) % POOL_SIZE 로 배정돼 라운드로빈이 아니라 균등랜덤이라, 슬롯을 늘려도 balls-in-bins 쏠림은 그대로입니다. POOL_SIZE 8 / 레인 4가 POOL_SIZE 16 / 레인 2를 인스턴스 절반으로 이겼습니다(8.0분 대 8.9분). 셋째, sleepAfter 를 2분으로 줄이고 대신 20초마다 스토리지를 건드리는 keepalive 를 넣어 인코딩 중인 슬롯이 회수되지 않게 했습니다.
메모리와 디스크는 인스턴스가 프로비저닝돼 있는 내내 과금되고 vCPU 만 실사용분입니다. 하루 500건을 8슬롯에 나누면 슬롯당 작업 간격이 7.7분이라, 15분 타임아웃은 영원히 만료되지 않고 8슬롯이 24시간 깨어 있습니다. 계산하면 월 $250 대 $37 이에요. 실제 컴퓨트는 하루 1.84 vCPU-시간, 상시 vCPU 하나가 92% 놀고 있는 수준입니다. 즉 이건 컴퓨트 예산 문제가 아니라 프로비저닝 문제였고, 그래서 손잡이도 동시성이 아니라 잠드는 시간이었습니다.
유저가 할 수 있는 일
슬롯 고정의 대가와 갇힌 이벤트
솔직히 걸리는 곳이 몇 군데 있습니다.
작업을 슬롯에 해시로 고정한 것은 재시도가 따뜻한 인스턴스로 돌아가게 하려는 선택이었는데, 그 대가로 쏠림을 떠안았습니다. 레인을 늘려 흡수했지만 이건 증상을 덮은 쪽에 가깝습니다. 다시 만든다면 슬롯을 고정하는 대신 컨슈머 쪽에 짧은 작업 큐를 두고 비어 있는 슬롯에 밀어 넣는 방식을 먼저 재 보겠습니다. 다만 레인 전략은 복사 경로가 우세할 때만 성립합니다. 재인코딩이 늘면 CPU 바운드가 되어 1 vCPU 에 4레인은 서로 밀어내니, 그때는 레인을 2로 되돌리거나 2 vCPU 인스턴스로 옮겨야 합니다.
D1을 잡 상태 저장소로 쓴 것도 다시 볼 대목입니다. 큐 메시지와 D1 행이 각자 살아 있다 보니, 메시지가 재시도를 소진하고 사라지면 행만 queued 로 남아 영원히 처리 중인 것처럼 보였습니다. DLQ 컨슈머를 붙여 행을 실패로 정리하고 콜백까지 쏘게 막았지만, 원래는 두 상태가 갈라질 수 없는 구조였어야 합니다.
분석은 미국 리전에 쌓입니다. 이름이 붙은 개인의 전용 페이지에서 나온 이벤트라, 익명 캡처라도 EU·영국 대상 캠페인이라면 개인정보로 볼 여지가 있습니다. /e 앞에 동의 배너를 두는 것까지가 기술이 할 수 있는 일이고, 그다음은 법률 판단이라 문서에 그렇게 적어 두었습니다.
마지막으로 테스트는 멱등성, Range 파싱, 설정 검증, 그리고 컨테이너 이미지 안에서 도는 ffmpeg 스모크 하나입니다. 실제 프로덕션 인코딩 조합을 넓게 훑지는 못했어요. 아름답게 다 덮고 있지는 않지만, 적어도 돈이 새는 지점과 영상이 깨지는 지점에는 각각 하나씩 걸어 뒀습니다.











