작성일 댓글 남기기

Cloudflare Stream API

Cloudflare Stream API 연동 매뉴얼

이 매뉴얼은 Cloudflare Dashboard를 사용하지 않고, 자체 구축한 Admin 페이지에서 API를 통해 동영상을 관리하는 개발자를 위해 작성되었습니다.


사전 준비 (Prerequisites)

API를 호출하기 위해 Cloudflare 대시보드에서 다음 두 가지 정보를 먼저 확보해야 합니다.

  1. Account ID: Cloudflare 대시보드 URL의 dash.cloudflare.com/ 뒤에 있는 문자열, 또는 Stream 메뉴 우측 사이드바에서 확인 가능.
  2. 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에게 “업로드 할 주소를 달라”고 요청합니다.

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 사용)

  1. 라이브러리 설치:npm install tus-js-client
  2. 업로드 코드 작성:
    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)를 사용합니다.

  1. HLS/DASH URL (커스텀 플레이어용):
    • https://customer-<CODE>.cloudflarestream.com/<UID>/manifest/video.m3u8
  2. 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 페이지 개발 시 다음 흐름을 따르십시오.

  1. Backend: Direct Upload URL 발급 API 구현.
  2. Frontend: 발급받은 URL로 파일 POST (또는 TUS 사용) 구현.
  3. Backend: Cloudflare Webhook 수신 API (POST /webhook/stream) 구현하여 DB 상태 업데이트.
  4. Frontend: DB에 저장된 uid를 이용하여 Player 연동.

백엔드 개발


Python (Flask/Requests 사용) 예제로 설명합니다.

백엔드는 두 가지 역할을 합니다:

  1. Direct Upload URL 발급 (200MB 미만 파일용)
  2. TUS Upload URL 발급 (200MB 이상 대용량 파일용)

⚠️ 중요: 보안 설정 (공통 사항)

코드에 API 키를 직접 하드코딩하지 마십시오. .env 파일이나 환경 변수에서 불러오는 방식을 권장합니다.

  • CLOUDFLARE_ACCOUNT_ID: 계정 ID
  • CLOUDFLARE_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)

요약

  1. 보안: Webhook-Signature 헤더와 Raw Body를 조합해 해시를 만들고, Secret과 대조하여 위변조를 방지합니다.
  2. 이벤트: status.stateready가 되었을 때 DB를 업데이트하여 사용자에게 영상을 노출합니다.
  3. 응답: 서버는 처리가 끝나면 반드시 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)이 필요합니다.

로직 설명:

  1. DB에서 업로드한 지 30분이 지났는데도 여전히 Processing 상태인 영상들을 찾습니다.
  2. Cloudflare API로 해당 영상들의 현재 상태를 조회합니다.
  3. 상태가 readyerror로 변해있다면 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) 시나리오

개발자가 아닌 최종 사용자(고객) 입장에서의 시나리오는 다음과 같아야 합니다.

  1. 업로드 실패 시 (Frontend):
    • “네트워크 문제로 업로드가 중단되었습니다. [이어올리기] 버튼을 눌러주세요.” (TUS 활용)
  2. 인코딩 실패 시 (Backend):
    • Admin 페이지 리스트에 [처리 실패 ⚠️] 라벨 표시.
    • 마우스 오버 시 “파일 형식이 손상되었습니다. 다시 인코딩해서 올려주세요.” 툴팁 제공.
    • 해당 영상은 플레이어에서 재생 시도 자체를 막아야 함.

요약 체크리스트

  1. Frontend: tus.onError 핸들러에서 에러 종류별 메시지 분기 처리.
  2. Frontend: beforeunload 이벤트로 실수로 탭 닫기 방지.
  3. Backend: Webhook에서 status === 'error' 일 때 DB 업데이트 및 관리자 알림.
  4. 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)

요약

  1. 보안: Webhook-Signature 헤더와 Raw Body를 조합해 해시를 만들고, Secret과 대조하여 위변조를 방지합니다.
  2. 이벤트: status.stateready가 되었을 때 DB를 업데이트하여 사용자에게 영상을 노출합니다.
  3. 응답: 서버는 처리가 끝나면 반드시 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)이 필요합니다.

로직 설명:

  1. DB에서 업로드한 지 30분이 지났는데도 여전히 Processing 상태인 영상들을 찾습니다.
  2. Cloudflare API로 해당 영상들의 현재 상태를 조회합니다.
  3. 상태가 readyerror로 변해있다면 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) 시나리오

개발자가 아닌 최종 사용자(고객) 입장에서의 시나리오는 다음과 같아야 합니다.

  1. 업로드 실패 시 (Frontend):
    • “네트워크 문제로 업로드가 중단되었습니다. [이어올리기] 버튼을 눌러주세요.” (TUS 활용)
  2. 인코딩 실패 시 (Backend):
    • Admin 페이지 리스트에 [처리 실패 ⚠️] 라벨 표시.
    • 마우스 오버 시 “파일 형식이 손상되었습니다. 다시 인코딩해서 올려주세요.” 툴팁 제공.
    • 해당 영상은 플레이어에서 재생 시도 자체를 막아야 함.

요약 체크리스트

  1. Frontend: tus.onError 핸들러에서 에러 종류별 메시지 분기 처리.
  2. Frontend: beforeunload 이벤트로 실수로 탭 닫기 방지.
  3. Backend: Webhook에서 status === 'error' 일 때 DB 업데이트 및 관리자 알림.
  4. 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"
}
]
}

이렇게 하면 토큰이 유출되어도 해외에서는 재생되지 않습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다