Cloudflare Stream API 연동 매뉴얼
이 매뉴얼은 Cloudflare Dashboard를 사용하지 않고, 자체 구축한 Admin 페이지에서 API를 통해 동영상을 관리하는 개발자를 위해 작성되었습니다.
사전 준비 (Prerequisites)
API를 호출하기 위해 Cloudflare 대시보드에서 다음 두 가지 정보를 먼저 확보해야 합니다.
- Account ID: Cloudflare 대시보드 URL의
dash.cloudflare.com/뒤에 있는 문자열, 또는 Stream 메뉴 우측 사이드바에서 확인 가능. - API Token:
- Manage account > Account API tokens > Create Token
- 템플릿 중 Read and write to Cloudflare Stream and Images 을 선택
업로드 방식 결정 (Architecture)
동영상 파일은 크기가 크기 때문에 업로드 방식을 신중히 선택해야 합니다.
| 방식 | 설명 | 추천 시나리오 |
| A. Simple Upload | 단일 HTTP POST 요청으로 파일 전송 (최대 200MB). | 프로필 이미지, 1분 미만 짧은 영상. |
| B. Direct Creator Upload (추천) | Admin 프론트엔드에서 CF로 직접 업로드. (서버 대역폭 절약) | 가장 일반적인 구축 방식. |
| C. Tus Resumable Upload | 네트워크 끊김 시 이어올리기 지원, 대용량 파일 최적화. | 200MB 이상, 인터넷이 불안정한 환경. |
이 가이드에서는 실무에서 가장 많이 쓰이는 B (Direct Creator Upload) 와 C (Tus) 방식을 다룹니다.
시나리오 A: Direct Creator Upload (일회용 URL 방식)
이 방식은 보안상 안전하며, 백엔드 서버의 대역폭을 소모하지 않고 Admin 브라우저에서 Cloudflare로 바로 파일을 쏘는 방식입니다.
Step 1: 업로드 URL 발급 요청 (Backend)
Admin 페이지 사용자가 “업로드” 버튼을 누르면, 백엔드 서버는 Cloudflare에게 “업로드 할 주소를 달라”고 요청합니다.
- Endpoint:
POST https://api.cloudflare.com/client/v4/accounts/{account_id}/stream/direct_upload - Headers:
Authorization: Bearer<YOUR_API_TOKEN>
- Body (JSON):
maxDurationSeconds: 영상 최대 길이 제한expiry: URL 만료 시간 (옵션)requireSignedURLs: 비공개 영상 여부 (옵션)- 더 많은 옵션: https://developers.cloudflare.com/api/resources/stream/subresources/direct_upload/methods/create/
Request 예시 (cURL):
curl -X POST https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/stream/direct_upload \
-H "Authorization: Bearer <API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"maxDurationSeconds": 3600,
"creator": "admin-user-01",
"meta": {"name": "product_demo_v1.mp4"}
}'
Response (성공 시):
{
"result": {
"uploadURL": "https://upload.cloudflarestream.com/fc33ac543eb9046a......",
"uid": "fc33ac543eb......",
"watermark": null,
"scheduledDeletion": null
},
"success": true,
"errors": [],
"messages": []
}
핵심: 여기서 받은
uploadURL을 프론트엔드(Admin 페이지)로 전달합니다.
동영상 업로드 예시 (cURL):
curl -X POST \
--form file=@/filepath/filename.mp4 \
https://upload.cloudflarestream.com/fc33ac543eb9046a......
Step 2: 파일 업로드 (Frontend / Admin Page)
프론트엔드는 백엔드로부터 받은 uploadURL로 파일을 POST 합니다. (이때는 API Token이 필요 없습니다.)
HTML Form 예시:
<form action="YOUR_UPLOAD_URL_HERE" method="post" enctype="multipart/form-data">
<input type="file" name="file" id="file"/>
<input type="submit" value="Upload"/>
</form>
- AJAX나 Fetch API를 사용하여 진행률(Progress bar)을 구현할 수 있습니다.
시나리오 B: Tus 프로토콜 업로드 (대용량/이어올리기)
파일이 매우 크거나(GB 단위), 네트워크가 불안정할 경우 tus 프로토콜을 사용해야 합니다. 직접 구현하기보다는 tus-js-client 라이브러리를 사용하는 것이 좋습니다.
Frontend 구현 (tus-js-client 사용)
- 라이브러리 설치:npm install tus-js-client
- 업로드 코드 작성:
var fs = require(“fs”);
var tus = require(“tus-js-client”);
// Specify location of file you would like to upload below
var path = __dirname + “/test.mp4”;
var file = fs.createReadStream(path);
var size = fs.statSync(path).size;
var mediaId = “”;
var options = {
endpoint: “https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/stream”,
headers: {
Authorization: “Bearer <API_TOKEN>”,
},
chunkSize: 50 * 1024 * 1024, // Required a minimum chunk size of 5 MB. Here we use 50 MB.
retryDelays: [0, 3000, 5000, 10000, 20000], // Indicates to tus-js-client the delays after which it will retry if the upload fails.
metadata: {
name: “test.mp4”,
filetype: “video/mp4”,
// Optional if you want to include a watermark
// watermark: ‘<WATERMARK_UID>’,
},
uploadSize: size,
onError: function (error) {
throw error;
},
onProgress: function (bytesUploaded, bytesTotal) {
var percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
console.log(bytesUploaded, bytesTotal, percentage + “%”);
},
onSuccess: function () {
console.log(“Upload finished”);
},
onAfterResponse: function (req, res) {
return new Promise((resolve) => {
var mediaIdHeader = res.getHeader(“stream-media-id”);
if (mediaIdHeader) {
mediaId = mediaIdHeader;
}
resolve();
});
},
};
var upload = new tus.Upload(file, options);
upload.start();
영상 상태 확인 (Webhooks)
업로드가 끝났다고 바로 재생할 수 있는 것은 아닙니다. Cloudflare 내부에서 인코딩(Encoding) 과정이 필요합니다. Admin 페이지에서 “처리 중…” 상태를 표시하려면 Webhook이 필수입니다.
Webhook 설정
API로 설정합니다.
curl -X PUT --header 'Authorization: Bearer <API_TOKEN>' \
https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/stream/webhook \
--data '{"notificationUrl":"<WEBHOOK_NOTIFICATION_URL>"}'
주요 이벤트 타입
stream.created: 영상이 생성됨 (업로드 직후)stream.ready: 인코딩 완료, 재생 가능 상태 (가장 중요)
Payload 예시 (Server가 받을 데이터):
{
"uid": "d440056...",
"status": {
"state": "ready",
"pctComplete": "100.000000"
},
"meta": {
"name": "product_demo.mp4"
},
"created": "2023-01-01T10:00:00.000000Z"
}
서버는 이 웹훅을 받으면 DB의 해당 영상 상태를 Processing -> Active로 업데이트하면 됩니다.
재생 및 관리 (Playback & Management)
영상 목록 조회 (Admin 페이지용)
Admin 리스트 페이지에 뿌려줄 데이터입니다.
- Endpoint:
GET https://api.cloudflare.com/client/v4/accounts/{account_id}/stream - Parameters:
?status=ready&page=1&per_page=50
영상 재생 (사용자 페이지용)
업로드 완료 후 획득한 uid (Video ID)를 사용합니다.
- HLS/DASH URL (커스텀 플레이어용):
https://customer-<CODE>.cloudflarestream.com/<UID>/manifest/video.m3u8
- iFrame Embed (가장 쉬운 방법):<iframe
src=”https://customer-<CODE>cloudflarestream.com/<UID>/iframe”
style=”border: none”
allow=”accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;”
allowfullscreen=”true”>
</iframe>
요약
Admin 페이지 개발 시 다음 흐름을 따르십시오.
- Backend:
Direct Upload URL발급 API 구현. - Frontend: 발급받은 URL로 파일
POST(또는 TUS 사용) 구현. - Backend: Cloudflare Webhook 수신 API (
POST /webhook/stream) 구현하여 DB 상태 업데이트. - Frontend: DB에 저장된
uid를 이용하여 Player 연동.
백엔드 개발
Python (Flask/Requests 사용) 예제로 설명합니다.
백엔드는 두 가지 역할을 합니다:
- Direct Upload URL 발급 (200MB 미만 파일용)
- TUS Upload URL 발급 (200MB 이상 대용량 파일용)
⚠️ 중요: 보안 설정 (공통 사항)
코드에 API 키를 직접 하드코딩하지 마십시오. .env 파일이나 환경 변수에서 불러오는 방식을 권장합니다.
CLOUDFLARE_ACCOUNT_ID: 계정 IDCLOUDFLARE_API_TOKEN: Stream 권한이 있는 API 토큰
1. Direct Upload URL 발급 (200MB 미만)
Simple Upload 방식을 위한 일회용 업로드 URL을 발급합니다.
설치:
pip install flask flask-cors requests python-dotenv
코드 (app.py):
import os
import requests
from flask import Flask, request, jsonify
from flask_cors import CORS
from dotenv import load_dotenv
load_dotenv()
app = Flask(__name__)
CORS(app)
ACCOUNT_ID = os.getenv("CLOUDFLARE_ACCOUNT_ID")
API_TOKEN = os.getenv("CLOUDFLARE_API_TOKEN")
@app.route('/api/get-upload-url', methods=['POST'])
def get_direct_upload_url():
if not ACCOUNT_ID or not API_TOKEN:
return jsonify({"error": "Missing Cloudflare credentials"}), 500
url = f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/stream/direct_upload"
headers = {
"Authorization": f"Bearer {API_TOKEN}",
"Content-Type": "application/json"
}
payload = {
"maxDurationSeconds": 3600,
"requireSignedURLs": False
}
try:
response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
result = response.json().get("result")
return jsonify({"uploadURL": result.get("uploadURL"), "uid": result.get("uid")})
except requests.exceptions.RequestException as e:
return jsonify({"error": str(e)}), 500
if __name__ == '__main__':
app.run(port=5000, debug=True)
2. TUS Upload URL 발급 (200MB 이상)
대용량 파일을 위한 TUS 업로드 URL을 발급합니다.
코드 (app.py에 추가):
@app.route('/api/get-tus-url', methods=['POST', 'OPTIONS'])
def get_tus_url():
# CORS preflight 처리
if request.method == 'OPTIONS':
response = app.make_response('')
response.status_code = 200
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = '*'
response.headers['Access-Control-Expose-Headers'] = 'Location'
return response
if not ACCOUNT_ID or not API_TOKEN:
return jsonify({"error": "Missing Cloudflare credentials"}), 500
# 파일 크기 가져오기
data = request.get_json() or {}
file_size = data.get('fileSize')
if not file_size:
return jsonify({"error": "Missing fileSize in request"}), 400
# Cloudflare Stream TUS endpoint (direct_user=true 파라미터 필수)
cf_endpoint = f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/stream?direct_user=true"
headers = {
"Authorization": f"Bearer {API_TOKEN}",
"Tus-Resumable": "1.0.0",
"Upload-Length": str(file_size),
}
try:
# Cloudflare에 TUS 업로드 생성 요청
cf_response = requests.post(cf_endpoint, headers=headers)
cf_response.raise_for_status()
# Location 헤더에서 업로드 URL 가져오기
upload_url = cf_response.headers.get('Location')
if not upload_url:
return jsonify({"error": "No Location header in response"}), 500
# Location 헤더로 업로드 URL 반환
response = app.make_response('')
response.status_code = 200
response.headers['Location'] = upload_url
response.headers['Access-Control-Expose-Headers'] = 'Location'
response.headers['Access-Control-Allow-Origin'] = '*'
return response
except requests.exceptions.RequestException as e:
return jsonify({"error": str(e)}), 500
프론트엔드 개발
백엔드에서 uploadURL을 받아왔다는 가정하에, 프론트엔드(Admin 페이지)에서 실제로 파일을 업로드하는 두 가지 방식을 안내합니다.
두 방식 모두 공통적으로 “1. 백엔드에서 uploadURL 가져오기” -> “2. Cloudflare로 업로드하기” 의 흐름을 가집니다.
Simple Form 방식 (Fetch API 사용)
별도의 라이브러리 없이 브라우저 내장 기능만으로 구현합니다. 구현이 가장 쉽지만, 네트워크가 끊기면 처음부터 다시 올려야 합니다. (200MB 미만 파일 권장)
HTML 구조:
<h3>Simple Upload (Max 200MB)</h3>
<input type="file" id="simpleFileInput" accept="video/*">
<button onclick="startSimpleUpload()">업로드 시작</button>
<div id="simpleStatus">대기 중...</div>
JavaScript 구현:
async function startSimpleUpload() {
const fileInput = document.getElementById('simpleFileInput');
const statusDiv = document.getElementById('simpleStatus');
const file = fileInput.files[0];
if (!file) {
alert("파일을 선택해주세요.");
return;
}
try {
statusDiv.innerText = "1. 업로드 URL 요청 중...";
// [Step 1] 백엔드 API를 호출하여 Cloudflare uploadURL을 받아옵니다.
// (앞서 작성한 Node.js/Python 백엔드 엔드포인트)
const response = await fetch('/api/get-upload-url', { method: 'POST' });
const data = await response.json();
const uploadURL = data.uploadURL; // 백엔드가 넘겨준 1회용 URL
statusDiv.innerText = "2. Cloudflare로 전송 중...";
// [Step 2] 받아온 URL로 파일을 직접 POST 합니다.
const formData = new FormData();
formData.append('file', file);
const cfResponse = await fetch(uploadURL, {
method: 'POST',
body: formData
});
if (cfResponse.ok) {
statusDiv.innerText = "✅ 업로드 완료! (인코딩 대기 중)";
// 여기서 cfResponse.headers.get("stream-media-id") 등으로 ID 확인 가능
} else {
statusDiv.innerText = "❌ 업로드 실패";
}
} catch (error) {
console.error(error);
statusDiv.innerText = "에러 발생: " + error.message;
}
}
Tus 방식 (Resumable Upload – 추천)
tus-js-client 라이브러리를 사용합니다. 업로드 중 인터넷이 끊겨도 재연결 시 멈춘 곳부터 이어올리기가 가능하며, 대용량 파일(GB 단위)도 안정적으로 처리합니다.
라이브러리 추가 (CDN 또는 npm):
<script src="https://cdn.jsdelivr.net/npm/tus-js-client@latest/dist/tus.min.js"></script>
HTML 구조:
<h3>Tus Resumable Upload (대용량 가능)</h3>
<input type="file" id="tusFileInput" accept="video/*">
<button onclick="startTusUpload()">Tus 업로드 시작</button>
<progress id="progressBar" value="0" max="100" style="width:100%"></progress>
<div id="tusStatus">0%</div>
JavaScript 구현:
async function startTusUpload() {
const fileInput = document.getElementById('tusFileInput');
const statusDiv = document.getElementById('tusStatus');
const progressBar = document.getElementById('progressBar');
const file = fileInput.files[0];
if (!file) {
alert("파일을 선택해주세요.");
return;
}
statusDiv.innerText = "업로드 URL 요청 중...";
// [Step 1] 백엔드에서 uploadURL 가져오기
// 주의: TUS를 쓸 때도 보안을 위해 Direct Upload URL을 사용하는 것이 좋습니다.
const response = await fetch('/api/get-upload-url', { method: 'POST' });
const data = await response.json();
const uploadURL = data.uploadURL;
// [Step 2] Tus 클라이언트 설정
// uploadURL 자체가 TUS 엔드포인트 역할을 합니다.
const upload = new tus.Upload(file, {
endpoint: uploadURL,
retryDelays: [0, 3000, 5000, 10000, 20000], // 실패 시 재시도 간격
chunkSize: 50 * 1024 * 1024, // 50MB 단위로 쪼개서 전송 (서버 부하 조절)
metadata: {
filename: file.name,
filetype: file.type
},
onError: function(error) {
console.log("Failed because: " + error);
statusDiv.innerText = "에러: " + error;
},
onProgress: function(bytesUploaded, bytesTotal) {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
progressBar.value = percentage;
statusDiv.innerText = `업로드 중: ${percentage}%`;
},
onSuccess: function() {
console.log("Download %s from %s", upload.file.name, upload.url);
statusDiv.innerText = "✅ 업로드 성공! 처리 준비 중...";
// TUS는 onSuccess 시점의 upload.url에 영상 ID가 포함되어 있지 않을 수 있으므로
// 백엔드에서 미리 받은 uid를 사용하거나, Header를 확인해야 합니다.
}
});
// 업로드 시작 (이전에 중단된 적이 있다면 자동으로 이어올리기 시도)
upload.findPreviousUploads().then(function (previousUploads) {
if (previousUploads.length) {
upload.resumeFromPreviousUpload(previousUploads[0]);
}
upload.start();
});
}
방식 비교 요약
| 특징 | Simple (Fetch/Form) | Tus (Resumable) |
| 구현 난이도 | 쉬움 (라이브러리 불필요) | 중간 (라이브러리 필요) |
| 네트워크 안정성 | 낮음 (끊기면 처음부터 다시) | 높음 (이어올리기 지원) |
| 대용량 파일 | 비추천 (타임아웃 위험) | 강력 추천 (GB 단위 가능) |
| 추천 대상 | 1분 미만 짧은 클립, 프로필 영상 | 강의 영상, 영화, 고화질 자료 |
Webhook
Cloudflare Stream의 인코딩(처리) 과정은 비동기적으로 일어납니다. 따라서 업로드가 끝난 후 “영상이 재생 준비가 되었습니다(Ready)” 라는 신호를 받기 위해 Webhook 서버가 필요합니다.
가장 중요한 점은 보안(Signature Verification) 입니다. 아무나 내 서버에 “영상 완료됨”이라고 가짜 요청을 보내면 안 되기 때문에, Cloudflare가 보낸 요청이 맞는지 검증하는 로직을 반드시 포함해야 합니다.
사전 준비 (Webhook Secret)
먼저 Cloudflare Dashboard에서 Webhook을 생성하고 비밀키를 얻어야 합니다.
Request 예시 (cURL):
curl -X PUT --header 'Authorization: Bearer <API_TOKEN>' \
https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/stream/webhook \
--data '{"notificationUrl":"<WEBHOOK_NOTIFICATION_URL>"}'
Response (성공 시):
{
"result": {
"notificationUrl": "http://www.your-service-webhook-handler.com",
"modified": "2019-01-01T01:02:21.076571Z"
"secret": "85011ed3a913c6ad5f9cf6c5573cc0a7"
},
"success": true,
"errors": [],
"messages": []
}
notificationURL: 내 백엔드 API 주소 (예: https://api.mydomain.com/webhook/stream)
응답으로 받은 secret (예: 85011ed3a913c6ad5f9cf6c5573cc0a7)을 복사해 둡니다.
Node.js (Express) 샘플 코드
Express를 사용할 경우, 서명 검증을 위해 Raw Body(가공되지 않은 원본 데이터) 가 필요합니다. 아래 코드는 이를 처리하는 미들웨어 설정이 포함되어 있습니다.
설치:
npm install express dotenv
코드 (webhook.js):
require('dotenv').config();
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.CF_STREAM_WEBHOOK_SECRET; // .env에서 가져옴
// ⚠️ 중요: 서명 검증을 위해 Raw Body를 보존해야 합니다.
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf;
}
}));
app.post('/webhook/stream', (req, res) => {
const signatureHeader = req.headers['webhook-signature'];
if (!signatureHeader) {
return res.status(400).send('Missing Signature Header');
}
// 1. 헤더 파싱 (time과 signature 분리)
// 헤더 형식 예: "time=1234567890,sig1=abcd..."
const elements = signatureHeader.split(',');
const time = elements.find(e => e.startsWith('time=')).split('=')[1];
const sig1 = elements.find(e => e.startsWith('sig1=')).split('=')[1];
// 2. 검증용 문자열 생성
const stringToSign = `${time}.${req.rawBody}`;
// 3. HMAC SHA-256 해시 생성
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
const digest = hmac.update(stringToSign).digest('hex');
// 4. 서명 비교 (Timing Attack 방지를 위해 timingSafeEqual 사용 권장)
// 여기서는 간단히 문자열 비교로 처리하지만, 실무에선 crypto.timingSafeEqual 사용 추천
if (digest !== sig1) {
console.error('❌ 서명 불일치! 위조된 요청일 수 있습니다.');
return res.status(401).send('Invalid Signature');
}
// 5. 비즈니스 로직 처리
const event = req.body;
const uid = event.uid;
const status = event.status.state; // 'ready', 'queued', 'inprogress' 등
console.log(`🔔 Webhook 수신: Video [${uid}] is [${status}]`);
if (status === 'ready') {
// ✅ DB 업데이트 로직 (예시)
// updateVideoStatus(uid, 'ACTIVE');
console.log(`>> DB 업데이트 완료: 영상(${uid})이 활성화되었습니다.`);
}
// Cloudflare에게 잘 받았다고 응답 (200 OK)
res.status(200).send('OK');
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
Python (Flask) 샘플 코드
Python은 hmac 라이브러리를 사용하여 서명을 검증합니다.
설치:
pip install flask python-dotenv
코드 (webhook.py):
import os
import hmac
import hashlib
from flask import Flask, request, jsonify
from dotenv import load_dotenv
load_dotenv()
app = Flask(__name__)
WEBHOOK_SECRET = os.getenv("CF_STREAM_WEBHOOK_SECRET")
@app.route('/webhook/stream', methods=['POST'])
def handle_stream_webhook():
signature_header = request.headers.get('Webhook-Signature')
if not signature_header:
return "Missing Signature", 400
# 1. 헤더 파싱
# "time=12345,sig1=abcd" 형태를 딕셔너리로 변환
parts = {k: v for k, v in [x.split('=') for x in signature_header.split(',')]}
timestamp = parts.get('time')
signature = parts.get('sig1')
# 2. 검증용 문자열 생성 (받은 그대로의 bytes 데이터 필요)
request_body = request.get_data()
string_to_sign = f"{timestamp}.".encode('utf-8') + request_body
# 3. HMAC SHA-256 해시 생성
generated_sig = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
string_to_sign,
hashlib.sha256
).hexdigest()
# 4. 서명 비교
if not hmac.compare_digest(generated_sig, signature):
print("❌ 서명 불일치! 위조된 요청입니다.")
return "Invalid Signature", 401
# 5. 비즈니스 로직 처리
data = request.json
uid = data.get('uid')
status = data.get('status', {}).get('state')
print(f"🔔 Webhook 수신: Video [{uid}] is [{status}]")
if status == 'ready':
# ✅ DB 업데이트 로직 수행
# db.update_status(uid, 'active')
print(f">> DB 업데이트 완료: 영상({uid}) 재생 가능")
return "OK", 200
if __name__ == '__main__':
app.run(port=3000)
요약
- 보안:
Webhook-Signature헤더와Raw Body를 조합해 해시를 만들고, Secret과 대조하여 위변조를 방지합니다. - 이벤트:
status.state가ready가 되었을 때 DB를 업데이트하여 사용자에게 영상을 노출합니다. - 응답: 서버는 처리가 끝나면 반드시 200 OK를 리턴해야 Cloudflare가 재전송(Retry)을 하지 않습니다.
에러 대응
운영 환경에서 “성공하는 케이스”만 고려하면 나중에 큰 낭패를 봅니다. 동영상 서비스는 파일 크기가 크고 네트워크 변수가 많아 방어적인 코딩(Defensive Programming) 이 필수입니다.
크게 1. 업로드 중 실패 (Frontend) 와 2. 인코딩/처리 중 실패 (Backend Webhook) 두 가지 시점으로 나누어 대응 로직을 정리해 드립니다.
프론트엔드: 업로드 중단 및 실패 대응
사용자가 브라우저를 닫거나, 와이파이가 끊기거나, 파일이 규격에 맞지 않는 경우입니다.
A. TUS 라이브러리의 재시도(Retry) 옵션 활용
앞서 작성한 TUS 코드에 재시도 로직이 이미 포함되어 있습니다. 하지만 에러가 발생했을 때 사용자에게 “무엇이 잘못되었는지” 명확히 알려주는 UX 처리가 중요합니다.
const upload = new tus.Upload(file, {
endpoint: uploadURL,
retryDelays: [0, 1000, 3000, 5000], // 네트워크 일시 단절 시 4번 재시도
onError: function(error) {
console.error("Upload Failed:", error);
// 🚨 UX 대응: 에러 메시지를 분석하여 사용자에게 안내
if (error.originalRequest && error.originalRequest.status === 413) {
alert("파일이 너무 큽니다. (최대 용량 초과)");
} else if (error.message.includes("NetworkError")) {
alert("네트워크 연결이 불안정합니다. 잠시 후 다시 시도해주세요.");
} else {
alert("업로드 중 알 수 없는 오류가 발생했습니다. 새로고침 후 다시 시도해주세요.");
}
// UI 상태 업데이트: '재시도 버튼' 노출
document.getElementById('retryBtn').style.display = 'block';
},
// ... 기타 설정
});
B. 브라우저 이탈 방지 (beforeunload)
사용자가 업로드 중에 실수로 탭을 닫거나 뒤로 가기를 누르는 것을 방지합니다.
window.addEventListener("beforeunload", function (e) {
// 업로드가 진행 중일 때만 경고
if (isUploading) {
e.preventDefault();
e.returnValue = ""; // Chrome에서는 표준 메시지가 표시됨
}
});
백엔드: 인코딩 실패 대응 (Webhook 확장)
업로드는 성공했는데, Cloudflare 내부에서 인코딩하다가 죽는 경우입니다. (예: 파일이 깨져있거나, 지원하지 않는 코덱인 경우).
이 경우 Cloudflare는 status.state 값을 error로 해서 Webhook을 보냅니다.
Webhook 핸들러 수정 (Node.js 예시)
기존 ready 상태만 체크하던 로직에 error 처리를 추가합니다.
// ... (서명 검증 로직은 동일) ...
// 5. 비즈니스 로직 처리 (확장됨)
const event = req.body;
const uid = event.uid;
const status = event.status.state; // 'ready', 'queued', 'error' 등
const errReason = event.status.errorReasonCode || event.status.errorReasonText; // 에러 상세 사유
console.log(`🔔 Webhook: Video [${uid}] status is [${status}]`);
if (status === 'ready') {
// [성공] 서비스 활성화
await db.updateVideoStatus(uid, 'ACTIVE');
} else if (status === 'error') {
// [실패] 🚨 여기가 핵심입니다.
console.error(`❌ 인코딩 실패! UID: ${uid}, Reason: ${errReason}`);
// 1. DB 상태를 'FAILED'로 변경 (사용자에게 "처리에 실패했습니다" 표시용)
await db.updateVideoStatus(uid, 'FAILED', errReason);
// 2. 관리자(운영팀)에게 알림 발송 (Slack, Email 등)
// sendSlackAlert(`영상 인코딩 실패: ${uid} - ${errReason}`);
} else if (status === 'downloading') {
// (URL로 업로드 시) 다운로드 중
await db.updateVideoStatus(uid, 'PROCESSING');
}
res.status(200).send('OK');
3. “안전망” 구축: Polling (Cron Job)
Webhook은 99.9% 신뢰할 수 있지만, 내 서버가 점검 중이거나 다운되었을 때 Webhook을 놓칠 수 있습니다. Cloudflare가 몇 번 재시도하긴 하지만, 영원히 재시도하진 않습니다.
따라서 “고아 상태(Stuck)” 가 된 영상을 구제하기 위해 주기적인 배치 작업(Cron)이 필요합니다.
로직 설명:
- DB에서 업로드한 지 30분이 지났는데도 여전히
Processing상태인 영상들을 찾습니다. - Cloudflare API로 해당 영상들의 현재 상태를 조회합니다.
- 상태가
ready나error로 변해있다면 DB를 업데이트합니다.
Python (Scheduler) 예시:
import requests
# 가상의 DB 모듈
from myapp.models import Video
def check_stuck_videos():
# 1. 30분 넘게 처리 중인 영상 조회
stuck_videos = Video.objects.filter(status='PROCESSING', created_at__lte=time_30_mins_ago)
for video in stuck_videos:
# 2. Cloudflare API로 상태 조회
url = f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/stream/{video.uid}"
resp = requests.get(url, headers=HEADERS)
if resp.status_code == 200:
cf_data = resp.json()['result']
current_state = cf_data['status']['state'] # ready, error 등
# 3. 상태 동기화
if current_state == 'ready':
video.status = 'ACTIVE'
video.save()
print(f"복구됨: {video.uid} -> ACTIVE")
elif current_state == 'error':
video.status = 'FAILED'
video.save()
print(f"복구됨: {video.uid} -> FAILED")
4. 에러 발생 시 사용자 경험 (UX) 시나리오
개발자가 아닌 최종 사용자(고객) 입장에서의 시나리오는 다음과 같아야 합니다.
- 업로드 실패 시 (Frontend):
- “네트워크 문제로 업로드가 중단되었습니다. [이어올리기] 버튼을 눌러주세요.” (TUS 활용)
- 인코딩 실패 시 (Backend):
- Admin 페이지 리스트에 [처리 실패 ⚠️] 라벨 표시.
- 마우스 오버 시 “파일 형식이 손상되었습니다. 다시 인코딩해서 올려주세요.” 툴팁 제공.
- 해당 영상은 플레이어에서 재생 시도 자체를 막아야 함.
요약 체크리스트
- ✅ Frontend:
tus.onError핸들러에서 에러 종류별 메시지 분기 처리. - ✅ Frontend:
beforeunload이벤트로 실수로 탭 닫기 방지. - ✅ Backend: Webhook에서
status === 'error'일 때 DB 업데이트 및 관리자 알림. - ✅ Backend: Webhook 누락 대비용 스케줄러(Cron) 구현.
Singed URL
Cloudflare Stream에서 보안이 필요한 영상(유료 강의, 사내 교육 자료 등)은 Signed URL(서명된 URL) 방식을 사용해야 합니다.
이 방식의 핵심은 “내 서버가 발급한 ‘출입증(Token)’을 가진 사람만 영상을 볼 수 있다” 는 것입니다. 출입증에는 유효기간(예: 1시간)과 특정 규칙(예: 특정 IP만 허용)을 심을 수 있습니다.
영상 잠그기 (Locking the Video)
영상을 업로드할 때나 업로드 후에 “이 영상은 토큰이 필요해”라고 설정해야 합니다.
- 업로드 시:
requireSignedURLs: true설정 (앞서 업로드 API 설명 참조) - 업로드 후 (API):curl -X POST https://api.cloudflare.com/client/v4/accounts/{ACCOUN_ID}/stream/{uid} \
… -d ‘{“requireSignedURLs”: true}’
이 설정이 켜지면, 기존의 공개 URL로 접속 시 403 Forbidden 에러가 발생합니다.
옵션 1: /token 엔드포인트 사용하기
테스트 목적 또는 하루에 1000개 이하의 토큰을 생성할 때는 이 방법을 추천합니다.
토큰을 만들 때마다 Cloudflare API 콜이 필요합니다.
토큰의 기본 유효시간은 1시간입니다.
cURL 예제
curl --request POST \
https://api.cloudflare.com/client/v4/accounts/{account_id}/stream/{video_uid}/token \
--header "Authorization: Bearer <API_TOKEN>"
응답 예:
{
"result": {
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImNkYzkzNTk4MmY4MDc1ZjJlZjk2MTA2ZDg1ZmNkODM4In0.eyJraWQiOiJjZGM5MzU5ODJmODA3NWYyZWY5NjEwNmQ4NWZjZDgzOCIsImV4cCI6IjE2MjE4ODk2NTciLCJuYmYiOiIxNjIxODgyNDU3In0.iHGMvwOh2-SuqUG7kp2GeLXyKvMavP-I2rYCni9odNwms7imW429bM2tKs3G9INms8gSc7fzm8hNEYWOhGHWRBaaCs3U9H4DRWaFOvn0sJWLBitGuF_YaZM5O6fqJPTAwhgFKdikyk9zVzHrIJ0PfBL0NsTgwDxLkJjEAEULQJpiQU1DNm0w5ctasdbw77YtDwdZ01g924Dm6jIsWolW0Ic0AevCLyVdg501Ki9hSF7kYST0egcll47jmoMMni7ujQCJI1XEAOas32DdjnMvU8vXrYbaHk1m1oXlm319rDYghOHed9kr293KM7ivtZNlhYceSzOpyAmqNFS7mearyQ"
},
"success": true,
"errors": [],
"messages": []
}
token 값을 프론트엔드에 전달한다.
옵션2: 서명 키 사용하기
서명키를 사용하면 매번 스트림 API를 콜 할 필요가 없습니다.
Step1: /Strean/key 엔드포인트에서 키 얻기
cURL 예제:
curl --request POST \
"https://api.cloudflare.com/client/v4/accounts/{account_id}/stream/keys" \
--header "Authorization: Bearer <API_TOKEN>"
Response 예:
{
"result": {
"id": "8f926b2b01f383510025a78a4dcbf6a",
"pem": "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBemtHbXhCekFGMnBIMURiWmgyVGoyS3ZudlBVTkZmUWtNeXNCbzJlZzVqemRKTmRhCmtwMEphUHhoNkZxOTYveTBVd0lBNjdYeFdHb3kxcW1CRGhpdTVqekdtYW13NVgrYkR3TEdTVldGMEx3QnloMDYKN01Rb0xySHA3MDEycXBVNCtLODUyT1hMRVVlWVBrOHYzRlpTQ2VnMVdLRW5URC9oSmhVUTFsTmNKTWN3MXZUbQpHa2o0empBUTRBSFAvdHFERHFaZ3lMc1Vma2NsRDY3SVRkZktVZGtFU3lvVDVTcnFibHNFelBYcm9qaFlLWGk3CjFjak1yVDlFS0JCenhZSVEyOVRaZitnZU5ya0t4a2xMZTJzTUFML0VWZkFjdGkrc2ZqMkkyeEZKZmQ4aklmL2UKdHBCSVJZVDEza2FLdHUyYmk0R2IrV1BLK0toQjdTNnFGODlmTHdJREFRQUJBb0lCQUYzeXFuNytwNEtpM3ZmcgpTZmN4ZmRVV0xGYTEraEZyWk1mSHlaWEFJSnB1MDc0eHQ2ZzdqbXM3Tm0rTFVhSDV0N3R0bUxURTZacy91RXR0CjV3SmdQTjVUaFpTOXBmMUxPL3BBNWNmR2hFN1pMQ2wvV2ZVNXZpSFMyVDh1dGlRcUYwcXpLZkxCYk5kQW1MaWQKQWl4blJ6UUxDSzJIcmlvOW1KVHJtSUUvZENPdG80RUhYdHpZWjByOVordHRxMkZrd3pzZUdaK0tvd09JaWtvTgp2NWFOMVpmRGhEVG0wdG1Vd0tLbjBWcmZqalhRdFdjbFYxTWdRejhwM2xScWhISmJSK29PL1NMSXZqUE16dGxOCm5GV1ZEdTRmRHZsSjMyazJzSllNL2tRVUltT3V5alY3RTBBcm5vR2lBREdGZXFxK1UwajluNUFpNTJ6aTBmNloKdFdvwdju39xOFJWQkwxL2tvWFVmYk00S04ydVFadUdjaUdGNjlCRDJ1S3o1eGdvTwowVTBZNmlFNG9Cek5GUW5hWS9kayt5U1dsQWp2MkgraFBrTGpvZlRGSGlNTmUycUVNaUFaeTZ5cmRkSDY4VjdIClRNRllUQlZQaHIxT0dxZlRmc00vRktmZVhWY1FvMTI1RjBJQm5iWjNSYzRua1pNS0hzczUyWE1DZ1lFQTFQRVkKbGIybDU4blVianRZOFl6Uk1vQVo5aHJXMlhwM3JaZjE0Q0VUQ1dsVXFZdCtRN0NyN3dMQUVjbjdrbFk1RGF3QgpuTXJsZXl3S0crTUEvU0hlN3dQQkpNeDlVUGV4Q3YyRW8xT1loMTk3SGQzSk9zUythWWljemJsYmJqU0RqWXVjCkdSNzIrb1FlMzJjTXhjczJNRlBWcHVibjhjalBQbnZKd0k5aUpGVUNnWUVBMjM3UmNKSEdCTjVFM2FXLzd3ekcKbVBuUm1JSUczeW9UU0U3OFBtbHo2bXE5eTVvcSs5aFpaNE1Fdy9RbWFPMDF5U0xRdEY4QmY2TFN2RFh4QWtkdwpWMm5ra0svWWNhWDd3RHo0eWxwS0cxWTg3TzIwWWtkUXlxdjMybG1lN1JuVDhwcVBDQTRUWDloOWFVaXh6THNoCkplcGkvZFhRWFBWeFoxYXV4YldGL3VzQ2dZRUFxWnhVVWNsYVlYS2dzeUN3YXM0WVAxcEwwM3h6VDR5OTBOYXUKY05USFhnSzQvY2J2VHFsbGVaNCtNSzBxcGRmcDM5cjIrZFdlemVvNUx4YzBUV3Z5TDMxVkZhT1AyYk5CSUpqbwpVbE9ldFkwMitvWVM1NjJZWVdVQVNOandXNnFXY21NV2RlZjFIM3VuUDVqTVVxdlhRTTAxNjVnV2ZiN09YRjJyClNLYXNySFVDZ1lCYmRvL1orN1M3dEZSaDZlamJib2h3WGNDRVd4eXhXT2ZMcHdXNXdXT3dlWWZwWTh4cm5pNzQKdGRObHRoRXM4SHhTaTJudEh3TklLSEVlYmJ4eUh1UG5pQjhaWHBwNEJRNTYxczhjR1Z1ZSszbmVFUzBOTDcxZApQL1ZxUWpySFJrd3V5ckRFV2VCeEhUL0FvVEtEeSt3OTQ2SFM5V1dPTGJvbXQrd3g0NytNdWc9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=",
"jwk": "eyJ1c2UiOiJzaWciLCJrdHkiOiJSU0EiLCJraWQiOiI4ZjkyNmIyYjAxZjM4MzUxNzAwMjVhNzhhNGRjYmY2YSIsImFsZyI6IlJTMjU2IiwibiI6InprR214QnpBRjJwSDFEYlpoMlRqMkt2bnZQVU5GZlFrTXlzQm8yZWc1anpkSk5kYWtwMEphUHhoNkZxOTZfeTBVd0lBNjdYeFdHb3kxcW1CRGhpdTVqekdtYW13NVgtYkR3TEdTVldGMEx3QnloMDY3TVFvTHJIcDcwMTJxcFU0LUs4NTJPWExFVWVZUGs4djNGWlNDZWcxV0tFblREX2hKaFVRMWxOY0pNY3cxdlRtR2tqNHpqQVE0QUhQX3RxRERxWmd5THNVZmtjbEQ2N0lUZGZLVWRrRVN5b1Q1U3JxYmxzRXpQWHJvamhZS1hpNzFjak1yVDlFS0JCenhZSVEyOVRaZi1nZU5ya0t4a2xMZTJzTUFMX0VWZkFjdGktc2ZqMkkyeEZKZmQ4aklmX2V0cEJJUllUMTNrYUt0dTJiaTRHYi1XUEstS2hCN1M2cUY4OWZMdyIsImUiOiJBUUFCIiwiZCI6IlhmS3FmdjZuZ3FMZTktdEo5ekY5MVJZc1ZyWDZFV3RreDhmSmxjQWdtbTdUdmpHM3FEdU9henMyYjR0Um9mbTN1MjJZdE1UcG16LTRTMjNuQW1BODNsT0ZsTDJsX1VzNy1rRGx4OGFFVHRrc0tYOVo5VG0tSWRMWlB5NjJKQ29YU3JNcDhzRnMxMENZdUowQ0xHZEhOQXNJcllldUtqMllsT3VZZ1Q5MEk2MmpnUWRlM05oblN2MW42MjJyWVdURE94NFpuNHFqQTRpS1NnMl9sbzNWbDhPRU5PYlMyWlRBb3FmUld0LU9OZEMxWnlWWFV5QkRQeW5lVkdxRWNsdEg2Zzc5SXNpLU04ek8yVTJjVlpVTzdoOE8tVW5mYVRhd2xnei1SQlFpWTY3S05Yc1RRQ3VlZ2FJQU1ZVjZxcjVUU1Ai2odx5iT0xSX3BtMWFpdktyUSIsInAiOiI5X1o5ZUpGTWI5X3E4UlZCTDFfa29YVWZiTTRLTjJ1UVp1R2NpR0Y2OUJEMnVLejV4Z29PMFUwWTZpRTRvQnpORlFuYVlfZGsteVNXbEFqdjJILWhQa0xqb2ZURkhpTU5lMnFFTWlBWnk2eXJkZEg2OFY3SFRNRllUQlZQaHIxT0dxZlRmc01fRktmZVhWY1FvMTI1RjBJQm5iWjNSYzRua1pNS0hzczUyWE0iLCJxIjoiMVBFWWxiMmw1OG5VYmp0WThZelJNb0FaOWhyVzJYcDNyWmYxNENFVENXbFVxWXQtUTdDcjd3TEFFY243a2xZNURhd0JuTXJsZXl3S0ctTUFfU0hlN3dQQkpNeDlVUGV4Q3YyRW8xT1loMTk3SGQzSk9zUy1hWWljemJsYmJqU0RqWXVjR1I3Mi1vUWUzMmNNeGNzMk1GUFZwdWJuOGNqUFBudkp3STlpSkZVIiwiZHAiOiIyMzdSY0pIR0JONUUzYVdfN3d6R21QblJtSUlHM3lvVFNFNzhQbWx6Nm1xOXk1b3EtOWhaWjRNRXdfUW1hTzAxeVNMUXRGOEJmNkxTdkRYeEFrZHdWMm5ra0tfWWNhWDd3RHo0eWxwS0cxWTg3TzIwWWtkUXlxdjMybG1lN1JuVDhwcVBDQTRUWDloOWFVaXh6THNoSmVwaV9kWFFYUFZ4WjFhdXhiV0ZfdXMiLCJkcSI6InFaeFVVY2xhWVhLZ3N5Q3dhczRZUDFwTDAzeHpUNHk5ME5hdWNOVEhYZ0s0X2NidlRxbGxlWjQtTUswcXBkZnAzOXIyLWRXZXplbzVMeGMwVFd2eUwzMVZGYU9QMmJOQklKam9VbE9ldFkwMi1vWVM1NjJZWVdVQVNOandXNnFXY21NV2RlZjFIM3VuUDVqTVVxdlhRTTAxNjVnV2ZiN09YRjJyU0thc3JIVSIsInFpIjoiVzNhUDJmdTB1N1JVWWVubzIyNkljRjNBaEZzY3NWam55NmNGdWNGanNIbUg2V1BNYTU0dS1MWFRaYllSTFBCOFVvdHA3UjhEU0NoeEhtMjhjaDdqNTRnZkdWNmFlQVVPZXRiUEhCbGJudnQ1M2hFdERTLTlYVF8xYWtJNngwWk1Mc3F3eEZuZ2NSMF93S0V5Zzh2c1BlT2gwdlZsamkyNkpyZnNNZU9fakxvIn0=",
"created": "2021-06-15T21:06:54.763937286Z"
},
"success": true,
"errors": [],
"messages": []
}
id, pem 을 저장합니다.
Step 2: 키를 이용해서 token 만들기
Node.js 예제
jsonwebtoken 라이브러리를 사용합니다.
설치:
npm install jsonwebtoken dotenv
코드 (token-gen.js):
require('dotenv').config();
const jwt = require('jsonwebtoken');
// 환경변수 또는 직접 입력
const PRIVATE_KEY = process.env.CF_STREAM_PRIVATE_KEY; // pem 값
const KEY_ID = process.env.CF_STREAM_KEY_ID; // id 값
const VIDEO_UID = "VIDEO_UID_HERE"; // 잠그려는 영상 ID
function generateSignedToken(videoUid) {
// 1. 만료 시간 설정 (현재 시간 + 1시간)
// Math.floor(Date.now() / 1000) -> 현재 초(seconds)
const expiresIn = Math.floor(Date.now() / 1000) + 3600;
// 2. 페이로드 구성 (문서의 data 객체와 동일)
const payload = {
sub: videoUid, // 영상 ID (Subject)
kid: KEY_ID, // Key ID (Payload에도 포함)
exp: expiresIn, // 만료 시간
// 3. 접근 제어 규칙 (Access Rules) - Worker 예제와 동일하게 구성
accessRules: [
{
type: "ip.geoip.country",
action: "allow",
country: ["GB"], // 영국만 허용
},
{
type: "any",
action: "block", // 나머지는 차단
},
],
};
// 4. 토큰 서명 (RS256 알고리즘)
const token = jwt.sign(payload, PRIVATE_KEY, {
algorithm: 'RS256',
header: {
kid: KEY_ID, // 헤더에도 Key ID 필수
},
// jsonwebtoken 라이브러리는 payload에 'exp'가 있으면 자동으로 처리하지만,
// 명시적으로 넣었으므로 옵션에서는 제외하거나 그대로 둡니다.
});
return token;
}
// 실행 및 확인
const token = generateSignedToken(VIDEO_UID);
console.log("✅ Generated Token:", token);
console.log(`🔗 Signed URL: https://customer-<YOUR_CODE>.cdn.cloudflare.net/${token}/${VIDEO_UID}/manifest/video.m3u8`);
Python 예제
PyJWT 라이브러리를 사용합니다.
설치:
pip install pyjwt cryptography python-dotenv
코드 (token_gen.py):
import os
import time
import jwt
from dotenv import load_dotenv
load_dotenv()
# PEM 형식의 Private Key (문자열 그대로 가져와야 함)
PRIVATE_KEY = os.getenv("CF_STREAM_PRIVATE_KEY")
KEY_ID = os.getenv("CF_STREAM_KEY_ID")
VIDEO_UID = "VIDEO_UID_HERE"
def generate_signed_token(video_uid):
# 1. 만료 시간 설정 (현재 시간 + 1시간)
expires_in = int(time.time()) + 3600
# 2. 페이로드 구성 (Worker 예제의 data 부분)
payload = {
"sub": video_uid, # 영상 ID
"kid": KEY_ID, # Key ID
"exp": expires_in, # 만료 시간
# 3. 접근 제어 규칙 (Access Rules)
"accessRules": [
{
"type": "ip.geoip.country",
"action": "allow",
"country": ["GB"] # 영국만 허용
},
{
"type": "any",
"action": "block" # 나머지 차단
}
]
}
# 4. 헤더 설정
headers = {
"kid": KEY_ID, # 헤더에 Key ID 명시
"alg": "RS256"
}
# 5. 토큰 서명
token = jwt.encode(
payload,
PRIVATE_KEY,
algorithm="RS256",
headers=headers
)
return token
if __name__ == "__main__":
token = generate_signed_token(VIDEO_UID)
# Python 버전에 따라 jwt.encode가 bytes를 리턴할 수 있으므로 decode 처리
if isinstance(token, bytes):
token = token.decode('utf-8')
print(f"✅ Generated Token: {token}")
print(f"🔗 Signed URL: https://customer-<YOUR_CODE>.cdn.cloudflare.net/{token}/{VIDEO_UID}/manifest/video.m3u8")
프론트엔드: 재생 URL 구성
백엔드로부터 받은 token을 URL 사이에 끼워 넣으면 됩니다.
URL 패턴
- HLS (Manifest): https://customer-{CODE}.cloudflarestream.com/{TOKEN}/manifest/video.m3u8
- DASH: https://customer-{CODE}.cloudflarestream.com/{TOKEN}/manifest/video.mpd
- iFrame (Embed): https://customer-{CODE}.cloudflarestream.com/{TOKEN}/iframe
JavaScript 예시 (Video.js 등 플레이어 사용 시)
// 1. 백엔드에서 토큰을 요청
const response = await fetch(`/api/video-token/${videoId}`);
const { token } = await response.json();
// 2. URL 조립
const signedSrc = `https://customer-xxxxx.cloudflarestream.com/${token}/manifest/video.m3u8`;
// 3. 플레이어 로드
player.src({
src: signedSrc,
type: 'application/x-mpegURL'
});
iFrame Embed 예시
<iframe
src="https://customer-<CODE>.cloudflarestream.com/eyJhbGciOiJSUzI1NiIsImtpZCI6ImNkYzkzNTk4MmY4MDc1ZjJlZjk2MTA2ZDg1ZmNkODM4In0.eyJraWQiOiJjZGM5MzU5ODJmODA3NWYyZWY5NjEwNmQ4NWZjZDgzOCIsImV4cCI6IjE2MjE4ODk2NTciLCJuYmYiOiIxNjIxODgyNDU3In0.iHGMvwOh2-SuqUG7kp2GeLXyKvMavP-I2rYCni9odNwms7imW429bM2tKs3G9INms8gSc7fzm8hNEYWOhGHWRBaaCs3U9H4DRWaFOvn0sJWLBitGuF_YaZM5O6fqJPTAwhgFKdikyk9zVzHrIJ0PfBL0NsTgwDxLkJjEAEULQJpiQU1DNm0w5ctasdbw77YtDwdZ01g924Dm6jIsWolW0Ic0AevCLyVdg501Ki9hSF7kYST0egcll47jmoMMni7ujQCJI1XEAOas32DdjnMvU8vXrYbaHk1m1oXlm319rDYghOHed9kr293KM7ivtZNlhYceSzOpyAmqNFS7mearyQ/iframe"
style="border: none;"
height="720"
width="1280"
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
allowfullscreen="true"
></iframe>
고급 보안: Access Rules (선택 사항)
토큰을 누군가 탈취해서 다른 사람에게 줄 수도 있습니다. 이를 막기 위해 토큰 생성 시 Payload에 accessRules를 추가할 수 있습니다.
예시: “한국(KR)에서 접속하는 IP만 허용”
{
"accessRules": [
{
"type": "ip.geoip.country",
"action": "allow",
"country": ["KR"]
},
{
"type": "any",
"action": "block"
}
]
}
이렇게 하면 토큰이 유출되어도 해외에서는 재생되지 않습니다.
request_body = request.get_data()
string_to_sign = f"{timestamp}.".encode('utf-8') + request_body
# 3. HMAC SHA-256 해시 생성
generated_sig = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
string_to_sign,
hashlib.sha256
).hexdigest()
# 4. 서명 비교
if not hmac.compare_digest(generated_sig, signature):
print("❌ 서명 불일치! 위조된 요청입니다.")
return "Invalid Signature", 401
# 5. 비즈니스 로직 처리
data = request.json
uid = data.get('uid')
status = data.get('status', {}).get('state')
print(f"🔔 Webhook 수신: Video [{uid}] is [{status}]")
if status == 'ready':
# ✅ DB 업데이트 로직 수행
# db.update_status(uid, 'active')
print(f">> DB 업데이트 완료: 영상({uid}) 재생 가능")
return "OK", 200
if __name__ == '__main__':
app.run(port=3000)
요약
- 보안:
Webhook-Signature헤더와Raw Body를 조합해 해시를 만들고, Secret과 대조하여 위변조를 방지합니다. - 이벤트:
status.state가ready가 되었을 때 DB를 업데이트하여 사용자에게 영상을 노출합니다. - 응답: 서버는 처리가 끝나면 반드시 200 OK를 리턴해야 Cloudflare가 재전송(Retry)을 하지 않습니다.
에러 대응
운영 환경에서 “성공하는 케이스”만 고려하면 나중에 큰 낭패를 봅니다. 동영상 서비스는 파일 크기가 크고 네트워크 변수가 많아 방어적인 코딩(Defensive Programming) 이 필수입니다.
크게 1. 업로드 중 실패 (Frontend) 와 2. 인코딩/처리 중 실패 (Backend Webhook) 두 가지 시점으로 나누어 대응 로직을 정리해 드립니다.
프론트엔드: 업로드 중단 및 실패 대응
사용자가 브라우저를 닫거나, 와이파이가 끊기거나, 파일이 규격에 맞지 않는 경우입니다.
A. TUS 라이브러리의 재시도(Retry) 옵션 활용
앞서 작성한 TUS 코드에 재시도 로직이 이미 포함되어 있습니다. 하지만 에러가 발생했을 때 사용자에게 “무엇이 잘못되었는지” 명확히 알려주는 UX 처리가 중요합니다.
const upload = new tus.Upload(file, {
endpoint: uploadURL,
retryDelays: [0, 1000, 3000, 5000], // 네트워크 일시 단절 시 4번 재시도
onError: function(error) {
console.error("Upload Failed:", error);
// 🚨 UX 대응: 에러 메시지를 분석하여 사용자에게 안내
if (error.originalRequest && error.originalRequest.status === 413) {
alert("파일이 너무 큽니다. (최대 용량 초과)");
} else if (error.message.includes("NetworkError")) {
alert("네트워크 연결이 불안정합니다. 잠시 후 다시 시도해주세요.");
} else {
alert("업로드 중 알 수 없는 오류가 발생했습니다. 새로고침 후 다시 시도해주세요.");
}
// UI 상태 업데이트: '재시도 버튼' 노출
document.getElementById('retryBtn').style.display = 'block';
},
// ... 기타 설정
});
B. 브라우저 이탈 방지 (beforeunload)
사용자가 업로드 중에 실수로 탭을 닫거나 뒤로 가기를 누르는 것을 방지합니다.
window.addEventListener("beforeunload", function (e) {
// 업로드가 진행 중일 때만 경고
if (isUploading) {
e.preventDefault();
e.returnValue = ""; // Chrome에서는 표준 메시지가 표시됨
}
});
백엔드: 인코딩 실패 대응 (Webhook 확장)
업로드는 성공했는데, Cloudflare 내부에서 인코딩하다가 죽는 경우입니다. (예: 파일이 깨져있거나, 지원하지 않는 코덱인 경우).
이 경우 Cloudflare는 status.state 값을 error로 해서 Webhook을 보냅니다.
Webhook 핸들러 수정 (Node.js 예시)
기존 ready 상태만 체크하던 로직에 error 처리를 추가합니다.
// ... (서명 검증 로직은 동일) ...
// 5. 비즈니스 로직 처리 (확장됨)
const event = req.body;
const uid = event.uid;
const status = event.status.state; // 'ready', 'queued', 'error' 등
const errReason = event.status.errorReasonCode || event.status.errorReasonText; // 에러 상세 사유
console.log(`🔔 Webhook: Video [${uid}] status is [${status}]`);
if (status === 'ready') {
// [성공] 서비스 활성화
await db.updateVideoStatus(uid, 'ACTIVE');
} else if (status === 'error') {
// [실패] 🚨 여기가 핵심입니다.
console.error(`❌ 인코딩 실패! UID: ${uid}, Reason: ${errReason}`);
// 1. DB 상태를 'FAILED'로 변경 (사용자에게 "처리에 실패했습니다" 표시용)
await db.updateVideoStatus(uid, 'FAILED', errReason);
// 2. 관리자(운영팀)에게 알림 발송 (Slack, Email 등)
// sendSlackAlert(`영상 인코딩 실패: ${uid} - ${errReason}`);
} else if (status === 'downloading') {
// (URL로 업로드 시) 다운로드 중
await db.updateVideoStatus(uid, 'PROCESSING');
}
res.status(200).send('OK');
3. “안전망” 구축: Polling (Cron Job)
Webhook은 99.9% 신뢰할 수 있지만, 내 서버가 점검 중이거나 다운되었을 때 Webhook을 놓칠 수 있습니다. Cloudflare가 몇 번 재시도하긴 하지만, 영원히 재시도하진 않습니다.
따라서 “고아 상태(Stuck)” 가 된 영상을 구제하기 위해 주기적인 배치 작업(Cron)이 필요합니다.
로직 설명:
- DB에서 업로드한 지 30분이 지났는데도 여전히
Processing상태인 영상들을 찾습니다. - Cloudflare API로 해당 영상들의 현재 상태를 조회합니다.
- 상태가
ready나error로 변해있다면 DB를 업데이트합니다.
Python (Scheduler) 예시:
import requests
# 가상의 DB 모듈
from myapp.models import Video
def check_stuck_videos():
# 1. 30분 넘게 처리 중인 영상 조회
stuck_videos = Video.objects.filter(status='PROCESSING', created_at__lte=time_30_mins_ago)
for video in stuck_videos:
# 2. Cloudflare API로 상태 조회
url = f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/stream/{video.uid}"
resp = requests.get(url, headers=HEADERS)
if resp.status_code == 200:
cf_data = resp.json()['result']
current_state = cf_data['status']['state'] # ready, error 등
# 3. 상태 동기화
if current_state == 'ready':
video.status = 'ACTIVE'
video.save()
print(f"복구됨: {video.uid} -> ACTIVE")
elif current_state == 'error':
video.status = 'FAILED'
video.save()
print(f"복구됨: {video.uid} -> FAILED")
4. 에러 발생 시 사용자 경험 (UX) 시나리오
개발자가 아닌 최종 사용자(고객) 입장에서의 시나리오는 다음과 같아야 합니다.
- 업로드 실패 시 (Frontend):
- “네트워크 문제로 업로드가 중단되었습니다. [이어올리기] 버튼을 눌러주세요.” (TUS 활용)
- 인코딩 실패 시 (Backend):
- Admin 페이지 리스트에 [처리 실패 ⚠️] 라벨 표시.
- 마우스 오버 시 “파일 형식이 손상되었습니다. 다시 인코딩해서 올려주세요.” 툴팁 제공.
- 해당 영상은 플레이어에서 재생 시도 자체를 막아야 함.
요약 체크리스트
- ✅ Frontend:
tus.onError핸들러에서 에러 종류별 메시지 분기 처리. - ✅ Frontend:
beforeunload이벤트로 실수로 탭 닫기 방지. - ✅ Backend: Webhook에서
status === 'error'일 때 DB 업데이트 및 관리자 알림. - ✅ Backend: Webhook 누락 대비용 스케줄러(Cron) 구현.
Singed URL
Cloudflare Stream에서 보안이 필요한 영상(유료 강의, 사내 교육 자료 등)은 Signed URL(서명된 URL) 방식을 사용해야 합니다.
이 방식의 핵심은 “내 서버가 발급한 ‘출입증(Token)’을 가진 사람만 영상을 볼 수 있다” 는 것입니다. 출입증에는 유효기간(예: 1시간)과 특정 규칙(예: 특정 IP만 허용)을 심을 수 있습니다.
영상 잠그기 (Locking the Video)
영상을 업로드할 때나 업로드 후에 “이 영상은 토큰이 필요해”라고 설정해야 합니다.
- 업로드 시:
requireSignedURLs: true설정 (앞서 업로드 API 설명 참조) - 업로드 후 (API):curl -X POST https://api.cloudflare.com/client/v4/accounts/{ACCOUN_ID}/stream/{uid} \
… -d ‘{“requireSignedURLs”: true}’
이 설정이 켜지면, 기존의 공개 URL로 접속 시 403 Forbidden 에러가 발생합니다.
옵션 1: /token 엔드포인트 사용하기
테스트 목적 또는 하루에 1000개 이하의 토큰을 생성할 때는 이 방법을 추천합니다.
토큰을 만들 때마다 Cloudflare API 콜이 필요합니다.
토큰의 기본 유효시간은 1시간입니다.
cURL 예제
curl --request POST \
https://api.cloudflare.com/client/v4/accounts/{account_id}/stream/{video_uid}/token \
--header "Authorization: Bearer <API_TOKEN>"
응답 예:
{
"result": {
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImNkYzkzNTk4MmY4MDc1ZjJlZjk2MTA2ZDg1ZmNkODM4In0.eyJraWQiOiJjZGM5MzU5ODJmODA3NWYyZWY5NjEwNmQ4NWZjZDgzOCIsImV4cCI6IjE2MjE4ODk2NTciLCJuYmYiOiIxNjIxODgyNDU3In0.iHGMvwOh2-SuqUG7kp2GeLXyKvMavP-I2rYCni9odNwms7imW429bM2tKs3G9INms8gSc7fzm8hNEYWOhGHWRBaaCs3U9H4DRWaFOvn0sJWLBitGuF_YaZM5O6fqJPTAwhgFKdikyk9zVzHrIJ0PfBL0NsTgwDxLkJjEAEULQJpiQU1DNm0w5ctasdbw77YtDwdZ01g924Dm6jIsWolW0Ic0AevCLyVdg501Ki9hSF7kYST0egcll47jmoMMni7ujQCJI1XEAOas32DdjnMvU8vXrYbaHk1m1oXlm319rDYghOHed9kr293KM7ivtZNlhYceSzOpyAmqNFS7mearyQ"
},
"success": true,
"errors": [],
"messages": []
}
token 값을 프론트엔드에 전달한다.
옵션2: 서명 키 사용하기
서명키를 사용하면 매번 스트림 API를 콜 할 필요가 없습니다.
Step1: /Strean/key 엔드포인트에서 키 얻기
cURL 예제:
curl --request POST \
"https://api.cloudflare.com/client/v4/accounts/{account_id}/stream/keys" \
--header "Authorization: Bearer <API_TOKEN>"
Response 예:
{
"result": {
"id": "8f926b2b01f383510025a78a4dcbf6a",
"pem": "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBemtHbXhCekFGMnBIMURiWmgyVGoyS3ZudlBVTkZmUWtNeXNCbzJlZzVqemRKTmRhCmtwMEphUHhoNkZxOTYveTBVd0lBNjdYeFdHb3kxcW1CRGhpdTVqekdtYW13NVgrYkR3TEdTVldGMEx3QnloMDYKN01Rb0xySHA3MDEycXBVNCtLODUyT1hMRVVlWVBrOHYzRlpTQ2VnMVdLRW5URC9oSmhVUTFsTmNKTWN3MXZUbQpHa2o0empBUTRBSFAvdHFERHFaZ3lMc1Vma2NsRDY3SVRkZktVZGtFU3lvVDVTcnFibHNFelBYcm9qaFlLWGk3CjFjak1yVDlFS0JCenhZSVEyOVRaZitnZU5ya0t4a2xMZTJzTUFML0VWZkFjdGkrc2ZqMkkyeEZKZmQ4aklmL2UKdHBCSVJZVDEza2FLdHUyYmk0R2IrV1BLK0toQjdTNnFGODlmTHdJREFRQUJBb0lCQUYzeXFuNytwNEtpM3ZmcgpTZmN4ZmRVV0xGYTEraEZyWk1mSHlaWEFJSnB1MDc0eHQ2ZzdqbXM3Tm0rTFVhSDV0N3R0bUxURTZacy91RXR0CjV3SmdQTjVUaFpTOXBmMUxPL3BBNWNmR2hFN1pMQ2wvV2ZVNXZpSFMyVDh1dGlRcUYwcXpLZkxCYk5kQW1MaWQKQWl4blJ6UUxDSzJIcmlvOW1KVHJtSUUvZENPdG80RUhYdHpZWjByOVordHRxMkZrd3pzZUdaK0tvd09JaWtvTgp2NWFOMVpmRGhEVG0wdG1Vd0tLbjBWcmZqalhRdFdjbFYxTWdRejhwM2xScWhISmJSK29PL1NMSXZqUE16dGxOCm5GV1ZEdTRmRHZsSjMyazJzSllNL2tRVUltT3V5alY3RTBBcm5vR2lBREdGZXFxK1UwajluNUFpNTJ6aTBmNloKdFdvwdju39xOFJWQkwxL2tvWFVmYk00S04ydVFadUdjaUdGNjlCRDJ1S3o1eGdvTwowVTBZNmlFNG9Cek5GUW5hWS9kayt5U1dsQWp2MkgraFBrTGpvZlRGSGlNTmUycUVNaUFaeTZ5cmRkSDY4VjdIClRNRllUQlZQaHIxT0dxZlRmc00vRktmZVhWY1FvMTI1RjBJQm5iWjNSYzRua1pNS0hzczUyWE1DZ1lFQTFQRVkKbGIybDU4blVianRZOFl6Uk1vQVo5aHJXMlhwM3JaZjE0Q0VUQ1dsVXFZdCtRN0NyN3dMQUVjbjdrbFk1RGF3QgpuTXJsZXl3S0crTUEvU0hlN3dQQkpNeDlVUGV4Q3YyRW8xT1loMTk3SGQzSk9zUythWWljemJsYmJqU0RqWXVjCkdSNzIrb1FlMzJjTXhjczJNRlBWcHVibjhjalBQbnZKd0k5aUpGVUNnWUVBMjM3UmNKSEdCTjVFM2FXLzd3ekcKbVBuUm1JSUczeW9UU0U3OFBtbHo2bXE5eTVvcSs5aFpaNE1Fdy9RbWFPMDF5U0xRdEY4QmY2TFN2RFh4QWtkdwpWMm5ra0svWWNhWDd3RHo0eWxwS0cxWTg3TzIwWWtkUXlxdjMybG1lN1JuVDhwcVBDQTRUWDloOWFVaXh6THNoCkplcGkvZFhRWFBWeFoxYXV4YldGL3VzQ2dZRUFxWnhVVWNsYVlYS2dzeUN3YXM0WVAxcEwwM3h6VDR5OTBOYXUKY05USFhnSzQvY2J2VHFsbGVaNCtNSzBxcGRmcDM5cjIrZFdlemVvNUx4YzBUV3Z5TDMxVkZhT1AyYk5CSUpqbwpVbE9ldFkwMitvWVM1NjJZWVdVQVNOandXNnFXY21NV2RlZjFIM3VuUDVqTVVxdlhRTTAxNjVnV2ZiN09YRjJyClNLYXNySFVDZ1lCYmRvL1orN1M3dEZSaDZlamJib2h3WGNDRVd4eXhXT2ZMcHdXNXdXT3dlWWZwWTh4cm5pNzQKdGRObHRoRXM4SHhTaTJudEh3TklLSEVlYmJ4eUh1UG5pQjhaWHBwNEJRNTYxczhjR1Z1ZSszbmVFUzBOTDcxZApQL1ZxUWpySFJrd3V5ckRFV2VCeEhUL0FvVEtEeSt3OTQ2SFM5V1dPTGJvbXQrd3g0NytNdWc9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=",
"jwk": "eyJ1c2UiOiJzaWciLCJrdHkiOiJSU0EiLCJraWQiOiI4ZjkyNmIyYjAxZjM4MzUxNzAwMjVhNzhhNGRjYmY2YSIsImFsZyI6IlJTMjU2IiwibiI6InprR214QnpBRjJwSDFEYlpoMlRqMkt2bnZQVU5GZlFrTXlzQm8yZWc1anpkSk5kYWtwMEphUHhoNkZxOTZfeTBVd0lBNjdYeFdHb3kxcW1CRGhpdTVqekdtYW13NVgtYkR3TEdTVldGMEx3QnloMDY3TVFvTHJIcDcwMTJxcFU0LUs4NTJPWExFVWVZUGs4djNGWlNDZWcxV0tFblREX2hKaFVRMWxOY0pNY3cxdlRtR2tqNHpqQVE0QUhQX3RxRERxWmd5THNVZmtjbEQ2N0lUZGZLVWRrRVN5b1Q1U3JxYmxzRXpQWHJvamhZS1hpNzFjak1yVDlFS0JCenhZSVEyOVRaZi1nZU5ya0t4a2xMZTJzTUFMX0VWZkFjdGktc2ZqMkkyeEZKZmQ4aklmX2V0cEJJUllUMTNrYUt0dTJiaTRHYi1XUEstS2hCN1M2cUY4OWZMdyIsImUiOiJBUUFCIiwiZCI6IlhmS3FmdjZuZ3FMZTktdEo5ekY5MVJZc1ZyWDZFV3RreDhmSmxjQWdtbTdUdmpHM3FEdU9henMyYjR0Um9mbTN1MjJZdE1UcG16LTRTMjNuQW1BODNsT0ZsTDJsX1VzNy1rRGx4OGFFVHRrc0tYOVo5VG0tSWRMWlB5NjJKQ29YU3JNcDhzRnMxMENZdUowQ0xHZEhOQXNJcllldUtqMllsT3VZZ1Q5MEk2MmpnUWRlM05oblN2MW42MjJyWVdURE94NFpuNHFqQTRpS1NnMl9sbzNWbDhPRU5PYlMyWlRBb3FmUld0LU9OZEMxWnlWWFV5QkRQeW5lVkdxRWNsdEg2Zzc5SXNpLU04ek8yVTJjVlpVTzdoOE8tVW5mYVRhd2xnei1SQlFpWTY3S05Yc1RRQ3VlZ2FJQU1ZVjZxcjVUU1Ai2odx5iT0xSX3BtMWFpdktyUSIsInAiOiI5X1o5ZUpGTWI5X3E4UlZCTDFfa29YVWZiTTRLTjJ1UVp1R2NpR0Y2OUJEMnVLejV4Z29PMFUwWTZpRTRvQnpORlFuYVlfZGsteVNXbEFqdjJILWhQa0xqb2ZURkhpTU5lMnFFTWlBWnk2eXJkZEg2OFY3SFRNRllUQlZQaHIxT0dxZlRmc01fRktmZVhWY1FvMTI1RjBJQm5iWjNSYzRua1pNS0hzczUyWE0iLCJxIjoiMVBFWWxiMmw1OG5VYmp0WThZelJNb0FaOWhyVzJYcDNyWmYxNENFVENXbFVxWXQtUTdDcjd3TEFFY243a2xZNURhd0JuTXJsZXl3S0ctTUFfU0hlN3dQQkpNeDlVUGV4Q3YyRW8xT1loMTk3SGQzSk9zUy1hWWljemJsYmJqU0RqWXVjR1I3Mi1vUWUzMmNNeGNzMk1GUFZwdWJuOGNqUFBudkp3STlpSkZVIiwiZHAiOiIyMzdSY0pIR0JONUUzYVdfN3d6R21QblJtSUlHM3lvVFNFNzhQbWx6Nm1xOXk1b3EtOWhaWjRNRXdfUW1hTzAxeVNMUXRGOEJmNkxTdkRYeEFrZHdWMm5ra0tfWWNhWDd3RHo0eWxwS0cxWTg3TzIwWWtkUXlxdjMybG1lN1JuVDhwcVBDQTRUWDloOWFVaXh6THNoSmVwaV9kWFFYUFZ4WjFhdXhiV0ZfdXMiLCJkcSI6InFaeFVVY2xhWVhLZ3N5Q3dhczRZUDFwTDAzeHpUNHk5ME5hdWNOVEhYZ0s0X2NidlRxbGxlWjQtTUswcXBkZnAzOXIyLWRXZXplbzVMeGMwVFd2eUwzMVZGYU9QMmJOQklKam9VbE9ldFkwMi1vWVM1NjJZWVdVQVNOandXNnFXY21NV2RlZjFIM3VuUDVqTVVxdlhRTTAxNjVnV2ZiN09YRjJyU0thc3JIVSIsInFpIjoiVzNhUDJmdTB1N1JVWWVubzIyNkljRjNBaEZzY3NWam55NmNGdWNGanNIbUg2V1BNYTU0dS1MWFRaYllSTFBCOFVvdHA3UjhEU0NoeEhtMjhjaDdqNTRnZkdWNmFlQVVPZXRiUEhCbGJudnQ1M2hFdERTLTlYVF8xYWtJNngwWk1Mc3F3eEZuZ2NSMF93S0V5Zzh2c1BlT2gwdlZsamkyNkpyZnNNZU9fakxvIn0=",
"created": "2021-06-15T21:06:54.763937286Z"
},
"success": true,
"errors": [],
"messages": []
}
id, pem 을 저장합니다.
Step 2: 키를 이용해서 token 만들기
Node.js 예제
jsonwebtoken 라이브러리를 사용합니다.
설치:
npm install jsonwebtoken dotenv
코드 (token-gen.js):
require('dotenv').config();
const jwt = require('jsonwebtoken');
// 환경변수 또는 직접 입력
const PRIVATE_KEY = process.env.CF_STREAM_PRIVATE_KEY; // pem 값
const KEY_ID = process.env.CF_STREAM_KEY_ID; // id 값
const VIDEO_UID = "VIDEO_UID_HERE"; // 잠그려는 영상 ID
function generateSignedToken(videoUid) {
// 1. 만료 시간 설정 (현재 시간 + 1시간)
// Math.floor(Date.now() / 1000) -> 현재 초(seconds)
const expiresIn = Math.floor(Date.now() / 1000) + 3600;
// 2. 페이로드 구성 (문서의 data 객체와 동일)
const payload = {
sub: videoUid, // 영상 ID (Subject)
kid: KEY_ID, // Key ID (Payload에도 포함)
exp: expiresIn, // 만료 시간
// 3. 접근 제어 규칙 (Access Rules) - Worker 예제와 동일하게 구성
accessRules: [
{
type: "ip.geoip.country",
action: "allow",
country: ["GB"], // 영국만 허용
},
{
type: "any",
action: "block", // 나머지는 차단
},
],
};
// 4. 토큰 서명 (RS256 알고리즘)
const token = jwt.sign(payload, PRIVATE_KEY, {
algorithm: 'RS256',
header: {
kid: KEY_ID, // 헤더에도 Key ID 필수
},
// jsonwebtoken 라이브러리는 payload에 'exp'가 있으면 자동으로 처리하지만,
// 명시적으로 넣었으므로 옵션에서는 제외하거나 그대로 둡니다.
});
return token;
}
// 실행 및 확인
const token = generateSignedToken(VIDEO_UID);
console.log("✅ Generated Token:", token);
console.log(`🔗 Signed URL: https://customer-<YOUR_CODE>.cdn.cloudflare.net/${token}/${VIDEO_UID}/manifest/video.m3u8`);
Python 예제
PyJWT 라이브러리를 사용합니다.
설치:
pip install pyjwt cryptography python-dotenv
코드 (token_gen.py):
import os
import time
import jwt
from dotenv import load_dotenv
load_dotenv()
# PEM 형식의 Private Key (문자열 그대로 가져와야 함)
PRIVATE_KEY = os.getenv("CF_STREAM_PRIVATE_KEY")
KEY_ID = os.getenv("CF_STREAM_KEY_ID")
VIDEO_UID = "VIDEO_UID_HERE"
def generate_signed_token(video_uid):
# 1. 만료 시간 설정 (현재 시간 + 1시간)
expires_in = int(time.time()) + 3600
# 2. 페이로드 구성 (Worker 예제의 data 부분)
payload = {
"sub": video_uid, # 영상 ID
"kid": KEY_ID, # Key ID
"exp": expires_in, # 만료 시간
# 3. 접근 제어 규칙 (Access Rules)
"accessRules": [
{
"type": "ip.geoip.country",
"action": "allow",
"country": ["GB"] # 영국만 허용
},
{
"type": "any",
"action": "block" # 나머지 차단
}
]
}
# 4. 헤더 설정
headers = {
"kid": KEY_ID, # 헤더에 Key ID 명시
"alg": "RS256"
}
# 5. 토큰 서명
token = jwt.encode(
payload,
PRIVATE_KEY,
algorithm="RS256",
headers=headers
)
return token
if __name__ == "__main__":
token = generate_signed_token(VIDEO_UID)
# Python 버전에 따라 jwt.encode가 bytes를 리턴할 수 있으므로 decode 처리
if isinstance(token, bytes):
token = token.decode('utf-8')
print(f"✅ Generated Token: {token}")
print(f"🔗 Signed URL: https://customer-<YOUR_CODE>.cdn.cloudflare.net/{token}/{VIDEO_UID}/manifest/video.m3u8")
프론트엔드: 재생 URL 구성
백엔드로부터 받은 token을 URL 사이에 끼워 넣으면 됩니다.
URL 패턴
- HLS (Manifest): https://customer-{CODE}.cloudflarestream.com/{TOKEN}/manifest/video.m3u8
- DASH: https://customer-{CODE}.cloudflarestream.com/{TOKEN}/manifest/video.mpd
- iFrame (Embed): https://customer-{CODE}.cloudflarestream.com/{TOKEN}/iframe
JavaScript 예시 (Video.js 등 플레이어 사용 시)
// 1. 백엔드에서 토큰을 요청
const response = await fetch(`/api/video-token/${videoId}`);
const { token } = await response.json();
// 2. URL 조립
const signedSrc = `https://customer-xxxxx.cloudflarestream.com/${token}/manifest/video.m3u8`;
// 3. 플레이어 로드
player.src({
src: signedSrc,
type: 'application/x-mpegURL'
});
iFrame Embed 예시
<iframe
src="https://customer-<CODE>.cloudflarestream.com/eyJhbGciOiJSUzI1NiIsImtpZCI6ImNkYzkzNTk4MmY4MDc1ZjJlZjk2MTA2ZDg1ZmNkODM4In0.eyJraWQiOiJjZGM5MzU5ODJmODA3NWYyZWY5NjEwNmQ4NWZjZDgzOCIsImV4cCI6IjE2MjE4ODk2NTciLCJuYmYiOiIxNjIxODgyNDU3In0.iHGMvwOh2-SuqUG7kp2GeLXyKvMavP-I2rYCni9odNwms7imW429bM2tKs3G9INms8gSc7fzm8hNEYWOhGHWRBaaCs3U9H4DRWaFOvn0sJWLBitGuF_YaZM5O6fqJPTAwhgFKdikyk9zVzHrIJ0PfBL0NsTgwDxLkJjEAEULQJpiQU1DNm0w5ctasdbw77YtDwdZ01g924Dm6jIsWolW0Ic0AevCLyVdg501Ki9hSF7kYST0egcll47jmoMMni7ujQCJI1XEAOas32DdjnMvU8vXrYbaHk1m1oXlm319rDYghOHed9kr293KM7ivtZNlhYceSzOpyAmqNFS7mearyQ/iframe"
style="border: none;"
height="720"
width="1280"
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
allowfullscreen="true"
></iframe>
고급 보안: Access Rules (선택 사항)
토큰을 누군가 탈취해서 다른 사람에게 줄 수도 있습니다. 이를 막기 위해 토큰 생성 시 Payload에 accessRules를 추가할 수 있습니다.
예시: “한국(KR)에서 접속하는 IP만 허용”
{
"accessRules": [
{
"type": "ip.geoip.country",
"action": "allow",
"country": ["KR"]
},
{
"type": "any",
"action": "block"
}
]
}
이렇게 하면 토큰이 유출되어도 해외에서는 재생되지 않습니다.