작성일 댓글 남기기

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"
}
]
}

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

작성일 댓글 남기기

GraphQL 보안

GraphQL에서 **보안(Security)**는 클라이언트와 서버 간의 통신에서 중요한 역할을 합니다. GraphQL API는 많은 자유도를 제공하기 때문에 인증(Authentication), 권한 관리(Authorization), 쿼리 제한(Query Complexity) 등을 통해 서버를 보호하고 데이터의 무결성을 유지하는 것이 매우 중요합니다. 보안을 적절히 설정하지 않으면 서버가 악의적인 요청에 취약해질 수 있습니다.

GraphQL 보안의 주요 요소는 다음과 같습니다:

  1. 인증(Authentication): 클라이언트가 누구인지 확인하는 절차.
  2. 권한 관리(Authorization): 클라이언트가 요청한 데이터를 접근할 권한이 있는지 확인.
  3. 쿼리 제한(Query Complexity): 매우 복잡하거나 대량의 요청으로 서버 자원이 남용되지 않도록 제한.
  4. 입력 검증(Input Validation): 잘못된 데이터나 악의적인 입력을 방지.
  5. 중복된 데이터 요청 방지: 과도한 요청을 처리하지 않도록 설정.

각각의 항목을 자세히 살펴보겠습니다.


1. 인증(Authentication)

인증은 클라이언트가 누구인지 확인하는 과정입니다. GraphQL API는 보통 HTTP 요청을 통해 사용되므로, 기존 REST API에서 사용하는 인증 방식인 JWT (JSON Web Token), OAuth 등을 그대로 사용할 수 있습니다.

JWT 인증 예시:

JWT는 클라이언트가 로그인할 때 서버에서 발급하는 토큰으로, 클라이언트는 이후의 모든 요청에 이 토큰을 HTTP 헤더에 포함하여 인증을 수행합니다.

  1. JWT 발급 과정:
    • 클라이언트가 로그인 정보를 서버에 보내면, 서버는 사용자를 인증한 후 JWT 토큰을 발급하여 클라이언트에 반환합니다.
  2. JWT를 HTTP 헤더에 포함하여 요청:
    • 이후 클라이언트는 이 토큰을 Authorization 헤더에 포함해 서버에 요청을 보냅니다.
# HTTP 요청에 Authorization 헤더 추가
{
"Authorization": "Bearer <JWT_TOKEN>"
}

서버 측 인증 처리:

GraphQL 서버에서는 요청이 들어올 때 HTTP 헤더에서 JWT 토큰을 확인하고, 이를 검증한 후 Context에 사용자 정보를 추가하여 리졸버에서 사용할 수 있게 합니다.

const { ApolloServer } = require('apollo-server');
const jwt = require('jsonwebtoken');

const getUserFromToken = (token) => {
try {
if (token) {
return jwt.verify(token, 'your-secret-key'); // 토큰 검증
}
return null;
} catch (err) {
return null;
}
};

const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => {
const token = req.headers.authorization || '';
const user = getUserFromToken(token.replace('Bearer ', ''));
return { user };
},
});

위 코드에서는 context 함수에서 HTTP 헤더에서 토큰을 추출하고, 이를 검증한 후 사용자 정보를 리졸버에 전달합니다.


2. 권한 관리(Authorization)

권한 관리는 인증된 클라이언트가 요청한 자원에 접근할 권한이 있는지를 확인하는 과정입니다. GraphQL에서는 리졸버 레벨에서 권한을 체크할 수 있습니다. 특정 사용자 그룹이나 권한을 가진 사용자만 특정 필드에 접근할 수 있도록 제한할 수 있습니다.

권한 관리 예시:

  1. Role-based Authorization: 사용자에게 역할(관리자, 사용자 등)을 부여하고, 각 역할에 따라 접근할 수 있는 리소스를 제어합니다.
const resolvers = {
Query: {
secretData: (parent, args, context) => {
// 인증되지 않은 사용자에게는 에러 반환
if (!context.user) {
throw new Error('Not authenticated');
}

// 관리자만 접근할 수 있도록 제한
if (context.user.role !== 'admin') {
throw new Error('Not authorized');
}

return "This is secret data!";
},
},
};

위 코드에서 context.user.role을 통해 사용자의 역할을 확인하고, admin 역할이 아닌 사용자에게는 접근을 차단합니다.

  1. Field-level Authorization: GraphQL의 강력한 점은 각 필드에서 권한 관리를 세밀하게 할 수 있다는 점입니다. 예를 들어, 특정 필드에만 권한을 적용할 수 있습니다.
const resolvers = {
User: {
email: (parent, args, context) => {
// 사용자 자신 또는 관리자가 아닌 경우, 이메일 정보를 숨김
if (context.user.id !== parent.id && context.user.role !== 'admin') {
return null;
}
return parent.email;
},
},
};

이 예시에서는 사용자 자신의 이메일이거나 관리자인 경우에만 이메일을 반환하고, 그렇지 않으면 null을 반환하여 접근을 차단합니다.


3. 쿼리 제한(Query Complexity)

GraphQL의 유연한 쿼리 특성상 클라이언트가 너무 복잡한 쿼리나 너무 많은 데이터를 한 번에 요청하면 서버에 과부하를 줄 수 있습니다. 이를 방지하기 위해 **쿼리 복잡성 제한(Query Complexity)**과 **쿼리 깊이 제한(Query Depth Limiting)**을 설정할 수 있습니다.

1) 쿼리 깊이 제한(Query Depth Limiting)

클라이언트가 깊게 중첩된 쿼리를 요청할 때, 서버 자원을 많이 사용할 수 있습니다. 이를 방지하기 위해 쿼리의 최대 깊이를 제한할 수 있습니다.

javascript코드 복사const depthLimit = require('graphql-depth-limit');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(5)] // 최대 5단계 중첩까지 허용
});

위 코드에서는 **depthLimit(5)**를 사용하여 쿼리의 최대 중첩 깊이를 5로 제한합니다.

2) 쿼리 복잡성(Query Complexity)

쿼리 복잡성은 쿼리의 각 필드에 가중치를 부여하여 클라이언트가 너무 많은 리소스를 요구하지 않도록 제한하는 방식입니다. 예를 들어, 필드 하나를 가져오는 데 많은 자원이 소모된다면 그 필드에 높은 가중치를 부여할 수 있습니다.

const { getComplexity, simpleEstimator } = require('graphql-query-complexity');

const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
requestDidStart: () => ({
didResolveOperation({ request, document }) {
const complexity = getComplexity({
schema,
operationName: request.operationName,
query: document,
variables: request.variables,
estimators: [
simpleEstimator({ defaultComplexity: 1 }),
],
});

if (complexity > 100) {
throw new Error(`Query too complex: ${complexity}. Maximum allowed complexity: 100`);
}
},
}),
},
],
});

이 예시는 쿼리 복잡도가 100을 초과하면 에러를 반환하도록 설정하는 방식입니다. 이를 통해 클라이언트가 서버에 과도한 요청을 보내지 않도록 할 수 있습니다.


4. 입력 검증(Input Validation)

입력 검증은 클라이언트에서 잘못된 데이터나 악의적인 데이터를 서버로 보내지 못하도록 방지하는 보안 기법입니다. GraphQL에서는 스키마를 통해 데이터 타입을 엄격하게 정의할 수 있어 기본적인 타입 검증은 자동으로 이루어집니다.

기본적인 입력 검증:

type Mutation {
createUser(name: String!, email: String!): User
}

위의 스키마는 nameemail을 필수 값으로 정의하고 있으며, 입력 값이 없거나 잘못된 타입이 들어오면 GraphQL이 자동으로 에러를 반환합니다.

그러나 추가적인 검증이 필요한 경우, 리졸버 내에서 직접 검증 로직을 추가할 수 있습니다.

커스텀 입력 검증 예시:

const resolvers = {
Mutation: {
createUser: (parent, { name, email }) => {
if (!email.includes('@')) {
throw new Error('Invalid email format');
}

return User.create({ name, email });
},
},
};

위 코드는 이메일 형식을 직접 검증하고, 잘못된 형식이면 에러를 반환합니다.


5. 중복된 데이터 요청 방지 (Rate Limiting)

GraphQL API는 자유도가 높기 때문에 클라이언트가 여러 번 동일한 요청을 보내거나, 매우 빠르게 연속된 요청을 보낼 수 있습니다. 이를 방지하기 위해 Rate Limiting을 적용하여 일정 시간 내에 요청할 수 있는 횟수를 제한할 수 있습니다.

Rate Limiting 예시 (express-rate-limit 사용):

const rateLimit = require('express-rate-limit');
const { ApolloServer } = require('apollo-server-express');

const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분 동안
max: 100, // 최대 100개의 요청 허용
message: "Too many requests, please try again later.",
});

const app = require('express')();
app.use(limiter);

const server = new ApolloServer({
typeDefs,
resolvers
});

server.applyMiddleware({ app });

이 코드는 15분 동안 100개의 요청만 허용하는 방식으로, 그 이상 요청이 들어오면 에러 메시지를 반환합니다. 이를 통해 서버 자원 남용을 방지할 수 있습니다.


결론

GraphQL API의 보안을 강화하기 위해서는 다음과 같은 요소를 고려해야 합니다:

  1. 인증(Authentication): JWT 등을 사용해 클라이언트를 인증.
  2. 권한 관리(Authorization): 각 리졸버에서 사용자의 역할에 따라 접근을 제한.
  3. 쿼리 제한(Query Complexity, Query Depth): 클라이언트가 과도한 데이터를 요청하지 않도록 쿼리의 복잡성이나 깊이를 제한.
  4. 입력 검증(Input Validation): 잘못된 데이터가 서버에 전달되지 않도록 철저히 검증.
  5. Rate Limiting: 동일한 클라이언트가 너무 많은 요청을 보내지 못하도록 제한.