개요
SVG, WebP 등의 무료 웹 에셋과 유료 다운로드 상품을 Cloudflare 인프라 하나로 통합 관리한다. R2, Workers, Pages를 조합해 파일 저장, 라우팅, 쇼케이스를 서버리스로 구성한다.
예상 URL:
plain text
<https://cdn.mydomain.com/icons/arrow-right.svg>
<https://cdn.mydomain.com/emoticons/happy.webp>Cloudflare
| 구성 요소 | 역할 |
|---|---|
| Cloudflare R2 | 에셋 및 유료 ZIP 파일 저장 (Egress 무료) |
| Cloudflare Workers | 요청 라우팅, 파일 반환, 인증, 서명 URL 발급 |
| Cloudflare Pages | 쇼케이스 및 카탈로그 사이트 호스팅 |
| Cloudflare CDN | 정적 파일 캐싱 및 원본 요청 감소 |
R2는 S3 호환 API를 지원하며 데이터 송신 요금이 없다. Workers는 상시 서버 없이 R2 바인딩으로 파일을 직접 읽거나 서명 URL을 발급한다.
저장소
접근 정책에 따라 경로를 분리한다.
plain text
my-asset-bucket/
├── public/ # 공개 에셋 (icons/, emoticons/, lib.js)
└── private/ # 유료 상품 (all-assets-v1.zip)public/과 private/ 구분은 키 명명 규칙일 뿐이며, 실제 권한 제어는 Worker 로직에서 처리한다. 무료 에셋은 CDN 캐시로 배포하고 유료 ZIP은 비공개 상태로 인증된 요청에만 서명 URL을 발급한다.
무료 에셋 배포 및 수집 억제
공개 웹 에셋은 클라이언트로 전송되는 특성상 완전한 복제 방지가 불가능하다. 대량 크롤링 비용을 높이는 방향으로 설계한다.
- 디렉터리 리스팅 차단: 전체 파일 목록 노출 방지
- 노출 제어: 쇼케이스에서 검색/페이지네이션으로 부분 노출
- 트래픽 제어: Cloudflare WAF 및 Rate Limiting 적용
Referer검증: 타 사이트의 무단 핫링크를 일부 완화하는 용도로만 사용 (헤더 위조가 가능하므로 보안 인증 대체 불가)
유료 다운로드 흐름
plain text
결제 완료 → 주문/인증 검증 → Worker에서 만료 서명 URL 생성(예: 600초) → 다운로드 리다이렉트서명 URL이 외부에 노출되어도 만료 후에는 다운로드가 차단된다.
프로젝트 설정
사전 준비 및 버킷 생성
bash
mkdir asset-cdn && cd asset-cdn
npm init -y
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
npm install -D wrangler
npx wrangler r2 bucket create my-asset-bucketWrangler 설정 (wrangler.toml)
toml
name = "asset-cdn"
main = "src/index.js"
compatibility_date = "2026-10-21"
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-asset-bucket"
[vars]
CF_ACCOUNT_ID = "YOUR_CLOUDFLARE_ACCOUNT_ID"Secret 등록
서명 URL 발급용 R2 S3 API 키를 등록한다.
bash
npx wrangler secret put R2_ACCESS_KEY_ID
npx wrangler secret put R2_SECRET_ACCESS_KEY무료 에셋 Worker 구현 (src/index.js)
javascript
const ALLOWED_ORIGINS = new Set(["<https://mydomain.com>", "<http://localhost:3000>"]);
const ALLOWED_EXTENSIONS = new Set([".svg", ".webp", ".png", ".apng"]);
function isAllowedOrigin(request) {
const referer = request.headers.get("Referer");
if (!referer) return false;
try {
return ALLOWED_ORIGINS.has(new URL(referer).origin);
} catch {
return false;
}
}
function contentTypeFor(key) {
const ext = key.slice(key.lastIndexOf(".")).toLowerCase();
const types = {
".svg": "image/svg+xml",
".webp": "image/webp",
".png": "image/png",
".apng": "image/apng"
};
return types[ext] || "application/octet-stream";
}
export default {
async fetch(request, env) {
if (!["GET", "HEAD"].includes(request.method)) {
return new Response("Method Not Allowed", { status: 405 });
}
const url = new URL(request.url);
// 에셋 로더 스크립트 서빙
if (url.pathname === "/lib.js") {
if (!isAllowedOrigin(request)) return new Response("Forbidden", { status: 403 });
const script = `
window.loadMyAsset = async function(name) {
if (!/^[a-zA-Z0-9_./-]+$/.test(name) || name.includes("..") || name.startsWith("/")) {
throw new Error("Invalid asset name");
}
const res = await fetch("<https://cdn.mydomain.com/assets/>" + name);
if (!res.ok) throw new Error("Asset request failed");
return await res.text();
};
`;
return new Response(script, {
headers: { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "public, max-age=3600" }
});
}
// 에셋 조회 처리
if (!url.pathname.startsWith("/assets/")) return new Response("Not Found", { status: 404 });
if (!isAllowedOrigin(request)) return new Response("Forbidden", { status: 403 });
const name = url.pathname.slice("/assets/".length);
if (!name || name.includes("..") || name.startsWith("/")) {
return new Response("Invalid asset path", { status: 400 });
}
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
if (!ALLOWED_EXTENSIONS.has(ext)) return new Response("Unsupported file type", { status: 415 });
const key = `public/${name}`;
const object = await env.MY_BUCKET.get(key);
if (!object) return new Response("Not Found", { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("ETag", object.httpEtag);
headers.set("Content-Type", contentTypeFor(key));
headers.set("Cache-Control", "public, max-age=31536000, immutable");
headers.set("X-Content-Type-Options", "nosniff");
return new Response(request.method === "HEAD" ? null : object.body, { headers });
}
}; 주의: immutable 캐시를 적용할 경우 파일 업데이트 시 새 파일명을 사용하거나 별도 캐시 무효화가 필요하다. 웹사이트 연동
html
<script src="<https//cdn.mydomain.com/lib.js>"></script>
<div id="icon-container"></div>
<script>
loadMyAsset("icons/arrow-right.svg")
.then(svgCode => {
document.querySelector("#icon-container").innerHTML = svgCode;
})
.catch(console.error);
</script> 보안 참고: 외부 사용자가 업로드할 수 있는 SVG를 innerHTML로 직접 삽입할 때는 스크립트 실행 위험이 있으므로 별도의 살균(Sanitize) 과정을 거쳐야 한다. 유료 다운로드 Worker 구현 (src/paid-download.js)
javascript
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
function getS3Client(env) {
return new S3Client({
region: "auto",
endpoint: `https://${env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY
}
});
}
export async function handlePaidDownload(request, env) {
// 실제 세션 및 결제 검증 로직 구현 필요
const user = await authenticateUser(request, env);
if (!user) return new Response("Unauthorized", { status: 401 });
const hasPurchased = await verifyPurchase(user.id, "all-assets-v1", env);
if (!hasPurchased) return new Response("Payment Required", { status: 403 });
const s3 = getS3Client(env);
const command = new GetObjectCommand({
Bucket: "my-asset-bucket",
Key: "private/all-assets-v1.zip",
ResponseContentDisposition: 'attachment; filename="all-assets-v1.zip"'
});
const signedUrl = await getSignedUrl(s3, command, { expiresIn: 600 });
return Response.redirect(signedUrl, 302);
}배포 및 체크리스트
bash
npx wrangler deploy배포 후 Cloudflare 대시보드에서 커스텀 도메인(cdn.mydomain.com)을 Worker에 라우팅한다.
- 허용된 도메인/경로에서만 파일이 반환되는가?
- 미지원 확장자 및 상위 경로(
..) 탐색 시도시 차단되는가? - 유료 파일 다운로드 시 세션/구매 인증이 올바르게 동작하는가?
- 서명 URL 만료 후 재요청이 거부되는가?