개요

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-bucket

Wrangler 설정 (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 만료 후 재요청이 거부되는가?