R2 3D Tiles 보안 — 방식별 상세 비교

6가지 보안 방식 각각의 동작 원리, 구현 코드, 장단점, 클라이언트 연동 방법을 상세히 비교한다. 업데이트: 2026-04-06


핵심 요약

구분내용
📖 정의Cloudflare R2 보안 방식 6가지의 동작 원리, 코드, 장단점 상세 비교
💡 핵심Worker JWT Proxy가 최적이며, Access는 코드리스 대안, HMAC은 헤더 불가 환경 보완
🎯 대상R2 보안 방식을 구체적으로 구현하려는 개발자
⚠️ 주의각 방식의 플랜별 제한(CPU 시간, 요청 수)을 반드시 확인해야 한다

목차

  1. 방식 A: Worker + R2 Binding (JWT Auth Proxy)
  2. 방식 B: Cloudflare Access (Zero Trust)
  3. 방식 C: R2 Presigned URL
  4. 방식 D: Worker HMAC Signed URL
  5. 방식 E: Public Bucket + WAF / Rate Limiting
  6. 방식 F: Cloudflare Tunnel + Private Network
  7. 클라이언트 연동 (CesiumJS / Three.js)
  8. 종합 비교 테이블

1. 방식 A: Worker + R2 Binding (JWT Auth Proxy)

동작 원리

Cloudflare Worker가 R2 버킷 앞에 인증 프록시로 배치된다. R2 버킷은 완전 비공개로 유지되며, Worker만이 내부 바인딩을 통해 직접 접근한다.

sequenceDiagram
    participant C as 클라이언트 (CesiumJS)
    participant W as Cloudflare Worker (엣지)
    participant CA as Cache API (CDN)
    participant R as R2 Bucket (비공개)

    C->>W: GET /tilesets/proj-a/tile.glb<br/>Authorization: Bearer {JWT}
    
    W->>CA: 캐시 조회 (URL 키)
    alt 캐시 히트
        CA-->>W: 캐시된 응답
        W-->>C: 200 + 타일 데이터
    else 캐시 미스
        W->>W: JWT 서명/만료 검증 (jose)
        W->>W: projectId ↔ 경로 권한 체크
        W->>R: R2 Binding으로 직접 조회
        R-->>W: 파일 데이터
        W->>CA: 캐시 저장 (비동기)
        W-->>C: 200 + 타일 데이터
    end

wrangler.toml 설정

name = "r2-tileset-auth-proxy"
main = "src/index.mjs"
compatibility_date = "2024-09-23"
 
# R2 버킷 바인딩 — Worker에서 env.TILE_BUCKET으로 접근
[[r2_buckets]]
binding = "TILE_BUCKET"
bucket_name = "my-3dtiles-bucket"
 
# 공개 환경 변수
[vars]
ALLOWED_ORIGIN = "https://my-viewer-app.com"
JWT_ISSUER = "https://my-auth-server.com"
 
# 비밀 키는 CLI로 별도 저장
# wrangler secret put JWT_SECRET

Worker 코드 (완전한 예시)

// src/index.mjs
// jose 라이브러리: npm install jose (Workers 호환 ESM)
import { jwtVerify } from 'jose';
 
const CACHE_TTL = 3600; // 타일 CDN 캐시: 1시간
 
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
 
    // ── 1. CORS Preflight 처리 ──
    if (request.method === 'OPTIONS') {
      return new Response(null, {
        status: 204,
        headers: corsHeaders(env),
      });
    }
 
    // ── 2. Cache API 확인 (CDN 캐싱) ──
    const cacheKey = new Request(url.toString(), { method: 'GET' });
    const cache = caches.default;
 
    const cachedResponse = await cache.match(cacheKey);
    if (cachedResponse) {
      return cachedResponse;
    }
 
    // ── 3. JWT 검증 ──
    const authHeader = request.headers.get('Authorization') ?? '';
    if (!authHeader.startsWith('Bearer ')) {
      return jsonError('Unauthorized: Missing Bearer token', 401, env);
    }
 
    const token = authHeader.slice(7);
 
    let payload;
    try {
      const secret = new TextEncoder().encode(env.JWT_SECRET);
      const { payload: jwtPayload } = await jwtVerify(token, secret, {
        issuer: env.JWT_ISSUER,
        algorithms: ['HS256'],
      });
      payload = jwtPayload;
    } catch (err) {
      return jsonError(`Forbidden: ${err.code ?? 'Invalid token'}`, 403, env);
    }
 
    // ── 4. 경로 기반 projectId 권한 검증 ──
    const objectKey = url.pathname.slice(1);
 
    if (payload.projectId && !objectKey.startsWith(`tilesets/${payload.projectId}/`)) {
      return jsonError('Forbidden: Path not allowed for this project', 403, env);
    }
 
    // ── 5. R2 Binding으로 파일 가져오기 ──
    const object = await env.TILE_BUCKET.get(objectKey);
 
    if (object === null) {
      return jsonError('Not Found', 404, env);
    }
 
    // ── 6. 응답 구성 ──
    const responseHeaders = new Headers(corsHeaders(env));
    object.writeHttpMetadata(responseHeaders);
    responseHeaders.set('ETag', object.httpEtag);
    responseHeaders.set('Cache-Control', `public, max-age=${CACHE_TTL}`);
 
    const response = new Response(object.body, {
      status: 200,
      headers: responseHeaders,
    });
 
    // ── 7. CDN 캐시에 저장 (비동기) ──
    ctx.waitUntil(cache.put(cacheKey, response.clone()));
 
    return response;
  },
};
 
function corsHeaders(env) {
  return {
    'Access-Control-Allow-Origin': env.ALLOWED_ORIGIN ?? '*',
    'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
    'Access-Control-Allow-Headers': 'Authorization, Content-Type',
    'Access-Control-Max-Age': '86400',
  };
}
 
function jsonError(message, status, env) {
  return new Response(JSON.stringify({ error: message }), {
    status,
    headers: { 'Content-Type': 'application/json', ...corsHeaders(env) },
  });
}

플랜별 제한

구분무료(Free)Standard ($5/월)
요청 수100,000건/일10,000,000건/월 포함
CPU 시간10ms/요청30,000,000 ms/월 포함
초과 시서비스 중단$0.30/100만 건

JWT 검증 + R2 Binding 조합은 CPU 집약적이지 않아 대부분 무료 플랜 10ms 내에서 처리 가능하다. jose 라이브러리 첫 호출에 약 2~5ms 추가된다.

장단점

장점단점
R2 버킷 완전 비공개 유지Worker 코드 개발·배포 필요
파일 수 무관, 토큰 1개로 전체 처리JWT 발급 서버 별도 필요
Egress 비용 $0무료 플랜 10만 요청/일 한도
CDN 캐싱으로 R2 읽기 비용 절감
projectId 기반 다중 프로젝트 권한 분리

2. 방식 B: Cloudflare Access (Zero Trust)

동작 원리

R2 버킷을 커스텀 도메인으로 공개한 뒤, Cloudflare Access가 인증 게이트웨이 역할을 수행한다. 코드 없이 대시보드 설정만으로 구현된다.

sequenceDiagram
    participant C as 클라이언트
    participant A as Cloudflare Access (엣지)
    participant R as R2 Bucket (커스텀 도메인)

    alt Service Token 방식 (CesiumJS 등)
        C->>A: GET /tileset.json<br/>CF-Access-Client-Id: xxx<br/>CF-Access-Client-Secret: yyy
        A->>A: Service Token 검증
        A->>R: 요청 전달
        R-->>C: 200 + 타일 데이터
    else 브라우저 OTP 방식
        C->>A: GET /tileset.json (쿠키 없음)
        A-->>C: 302 → 로그인 페이지
        C->>A: OTP 입력
        A-->>C: CF_Authorization 쿠키 발급
        C->>A: GET /tileset.json (쿠키 포함)
        A->>R: 요청 전달
        R-->>C: 200 + 타일 데이터
    end

설정 단계

1. R2 대시보드 → 버킷 → 설정 → 커스텀 도메인 → "assets.mydomain.com" 연결
   (프록시 활성화 필수: 주황색 구름 아이콘)

2. Zero Trust 대시보드 → Access → Applications → "Add an application"
   → 유형: "Self-hosted"
   → Application domain: "assets.mydomain.com"

3. 접근 정책 설정
   → Action: Allow
   → Rule: "Emails ending in @mycompany.com" 또는 특정 이메일 목록

4. 프로그래밍 접근용 Service Token 추가
   → Zero Trust → Access → Service Auth → Service Tokens → "Create Service Token"

5. ⚠️ R2 r2.dev 공개 URL 반드시 비활성화
   (미비활성화 시 Access 우회 가능)

Service Token vs One-Time Pin

구분Service TokenOne-Time Pin (OTP)
대상기계 (CesiumJS, 스크립트)사람 (직원, 파트너)
인증 방식HTTP 헤더에 Client-Id/Secret이메일로 6자리 코드 수신
CesiumJS 적합성적합부적합 (자동화 불가)
보안 주의Secret 유출 시 전체 버킷 노출코드 10분 유효, 상대적 안전

CF_Authorization 쿠키 동작

1. 사용자 접근 → 유효한 CF_Authorization 쿠키 없음 → 로그인 페이지로 리디렉션
2. OTP/IdP 인증 완료 → CF_Authorization (JWT 쿠키) 발급
   → HttpOnly + Secure 속성 (JavaScript 접근 차단)
3. 이후 모든 요청에 쿠키 자동 포함 → 엣지에서 검증
4. SSO: 동일 팀 도메인의 다른 앱에도 자동 인증

장단점

장점단점
코드 없이 대시보드 설정만으로 구현Service Token Secret 노출 시 전체 버킷 위험
무료 50명까지 지원R2 버킷은 공개 상태 (Access가 보호)
IdP(Google, Okta, Azure AD) 연동 가능사용자 수 증가 시 비용 선형 증가 ($7/인/월)
SSO 기능 내장projectId 기반 세분화 권한 불가

3. 방식 C: R2 Presigned URL

동작 원리

AWS SDK v3의 S3 호환 API를 사용하여 개별 파일에 대한 시간 제한 서명 URL을 생성한다.

sequenceDiagram
    participant C as 클라이언트 (CesiumJS)
    participant S as 백엔드 서버
    participant R as R2 Bucket

    loop 파일 수만큼 반복 (수백~수천 회)
        C->>S: tile_N.b3dm의 presigned URL 요청
        S->>S: AWS SDK로 URL 서명 생성
        S-->>C: 서명된 URL 반환
        C->>R: 서명된 URL로 직접 요청
        R-->>C: 파일 데이터
    end

    Note over C,R: 파일 1,000개 = 서버 왕복 1,000번 = 렌더링 지연 폭증

코드 예시 (Node.js)

import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 
const r2Client = new S3Client({
  region: 'auto',
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
  },
});
 
async function createPresignedUrl(bucketName, objectKey, expiresIn = 3600) {
  const command = new GetObjectCommand({
    Bucket: bucketName,
    Key: objectKey,
  });
  return getSignedUrl(r2Client, command, { expiresIn });
}

제한사항

항목내용
최대 만료 시간7일 (604,800초)
prefix 단위 서명불가 — 개별 파일에 대해서만 서명 가능
3D Tiles 적합성부적합 — 파일 수만큼 서버 왕복 필요

장단점

장점단점
구현이 간단 (S3 호환 API)파일마다 개별 발급 필요 → 병목
R2 버킷 비공개 유지 가능prefix 단위 서명 불가
시간 제한 자동 만료대량 요청 시 Class B 무료 tier 빠른 소진

4. 방식 D: Worker HMAC Signed URL

동작 원리

Worker에서 crypto.subtle(내장 API)을 사용하여 URL에 HMAC-SHA256 서명을 붙인다. Authorization 헤더를 사용할 수 없는 환경(iframe, <img> 태그 등)에서 유용하다.

sequenceDiagram
    participant S as 서버 (URL 생성)
    participant C as 클라이언트
    participant W as Cloudflare Worker
    participant R as R2 Bucket

    S->>S: HMAC-SHA256(경로 + 타임스탬프, secret)
    S-->>C: /tile.glb?verify={timestamp}-{hmac}
    C->>W: GET /tile.glb?verify=...
    W->>W: HMAC 재계산 → 일치 확인
    W->>W: 타임스탬프 만료 확인
    W->>R: R2 Binding으로 파일 조회
    R-->>W: 파일 데이터
    W-->>C: 200 + 타일 데이터

Worker 코드 (핵심 부분)

// 검증 로직 (타이밍 공격 방지: crypto.subtle.verify 사용)
const verifyParam = url.searchParams.get('verify');
const dashIndex = verifyParam.indexOf('-');
const timestamp = Number(verifyParam.slice(0, dashIndex));
const providedHmac = verifyParam.slice(dashIndex + 1);
 
// 만료 확인
if (Date.now() / 1000 > timestamp + EXPIRY_SECONDS) {
  return new Response('URL expired', { status: 403 });
}
 
// HMAC 검증
const dataToVerify = `${url.pathname}${timestamp}`;
const receivedMac = Buffer.from(providedHmac, 'base64url');
 
const isValid = await crypto.subtle.verify(
  'HMAC',
  key,
  receivedMac,
  encoder.encode(dataToVerify),
);

prefix 와일드카드 서명

제한적으로 가능하다. 서명 대상을 파일 경로 대신 prefix로 설정하면 디렉토리 단위 서명이 가능하지만, 표준 방식이 아니므로 보안 설계에 주의가 필요하다.

// prefix 기반 서명 — 한 번의 서명으로 prefix 하위 전체 파일 접근 가능
const dataToSign = `/tilesets/${projectId}/`;
// 검증 시: 요청 경로가 해당 prefix로 시작하는지 확인

장단점

장점단점
추가 라이브러리 불필요 (crypto.subtle 내장)URL에 서명 파라미터 노출
헤더 불가 환경에서 사용 가능서명 URL 발급 서버 별도 필요
prefix 단위 서명 가능 (커스텀 구현)prefix 서명 시 토큰 유출 위험 확대

5. 방식 E: Public Bucket + WAF / Rate Limiting

동작 원리

R2 버킷을 커스텀 도메인으로 공개하고, Cloudflare WAF와 Rate Limiting으로 보호한다. 완전한 인증이 아닌 보조 보안 레이어이다.

flowchart TD
    REQ["클라이언트 요청"] --> DDoS["DDoS 방어 (자동)"]
    DDoS --> BOT["Bot Management"]
    BOT --> WAF["WAF Custom Rules<br/>(Referer, IP, 확장자 체크)"]
    WAF --> RL["Rate Limiting<br/>(IP당 요청 제한)"]
    RL --> R2["R2 Public Bucket"]
    
    WAF -- "차단" --> BLOCK["403 Forbidden"]
    RL -- "초과" --> BLOCK

WAF Custom Rules 예시

# Cloudflare 대시보드 → Security → WAF → Custom Rules

# 예시 1: 특정 도메인 Referer만 허용
조건: http.referer ne "https://my-viewer-app.com"
Action: Block

# 예시 2: 특정 IP 범위만 허용
조건: not ip.src in {203.0.113.0/24}
Action: Block

# 예시 3: 3D 타일 확장자만 허용
조건: not http.request.uri.path matches "\.(b3dm|i3dm|glb|gltf|json)$"
Action: Block

Rate Limiting 플랜별 차이

항목무료 플랜Pro/Business 이상
규칙 수1개다수
계산 기간10초만10초~1시간
추적 방식IP 기반만IP + 쿠키 + 헤더 + JA3 지문

장단점

장점단점
대시보드 설정만으로 구현Referer 위조 가능 → 강력한 인증 불가
DDoS 방어 자동 포함IP 기반 제한은 NAT 환경에서 문제
다른 방식과 조합하여 보조 레이어로 활용단독 사용 시 보안 수준 낮음

6. 방식 F: Cloudflare Tunnel + Private Network

동작 원리

Cloudflare Tunnel로 R2 접근을 사설 네트워크로 구성하고, WARP 클라이언트를 통해서만 접근을 허용한다.

flowchart TD
    USER["내부 사용자 디바이스"] --> WARP["WARP 클라이언트<br/>(Zero Trust 인증)"]
    WARP --> ZT["Cloudflare Zero Trust Network"]
    ZT --> TUNNEL["cloudflared Tunnel"]
    TUNNEL --> R2["내부 서버 / R2 연결 프록시"]

적합한 사용 사례

  • 개발팀 내부에서만 3D Tiles 데이터 검토
  • 운영 환경 배포 전 내부 미리보기
  • R2 버킷 관리 작업

장단점

장점단점
가장 높은 보안 수준 (사설 네트워크)외부 사용자(고객) 접근 불가
무료 50명까지 지원WARP 클라이언트 설치 필요
VPN 대체 가능50명 초과 시 유료 플랜 필요

7. 클라이언트 연동 (CesiumJS / Three.js)

7-1. CesiumJS — Cesium.Resource 헤더 주입

Cesium.Resourceheaders를 설정하면 tileset.json 로드 후 모든 자식 타일(.b3dm, .glb)에도 자동으로 동일 헤더가 전파된다. 내부적으로 getDerivedResource()가 부모 Resource의 headers를 상속한다.

// 방식 A (JWT) 사용 시
const tilesetResource = new Cesium.Resource({
  url: 'https://worker.mydomain.workers.dev/tilesets/proj-a/tileset.json',
  headers: {
    'Authorization': `Bearer ${jwtToken}`,
  },
  // 토큰 만료(401) 시 자동 재시도
  retryCallback: async (resource, error) => {
    if (error.statusCode === 401) {
      const newToken = await refreshToken();
      resource.headers['Authorization'] = `Bearer ${newToken}`;
      return true; // 재시도
    }
    return false;
  },
  retryAttempts: 1,
});
 
const tileset = await Cesium.Cesium3DTileset.fromUrl(tilesetResource);
viewer.scene.primitives.add(tileset);
// 방식 B (Cloudflare Access Service Token) 사용 시
const tilesetResource = new Cesium.Resource({
  url: 'https://assets.mydomain.com/tilesets/proj-a/tileset.json',
  headers: {
    'CF-Access-Client-Id': 'your-client-id.access',
    'CF-Access-Client-Secret': 'your-secret',
  },
});
 
const tileset = await Cesium.Cesium3DTileset.fromUrl(tilesetResource);

7-2. Three.js GLTFLoader

import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
 
const loader = new GLTFLoader();
 
// setRequestHeader로 헤더 설정
loader.setRequestHeader({
  'Authorization': `Bearer ${jwtToken}`,
});
 
loader.load(
  'https://worker.mydomain.workers.dev/models/building.glb',
  (gltf) => scene.add(gltf.scene),
  (progress) => console.log(`Loading: ${(progress.loaded / progress.total * 100).toFixed(1)}%`),
  (error) => console.error('Load error:', error),
);

7-3. 3D Tiles Renderer JS (NASA AMMOS)

Three.js 생태계에서 3D Tiles를 처리하는 라이브러리이다.

import { TilesRenderer } from '3d-tiles-renderer';
 
const renderer = new TilesRenderer();
 
// fetchOptions로 모든 타일 요청에 헤더 적용
renderer.fetchOptions = {
  headers: {
    'Authorization': `Bearer ${jwtToken}`,
  },
  mode: 'cors',
};
 
renderer.setTileset('https://worker.mydomain.workers.dev/tilesets/proj-a/tileset.json');
scene.add(renderer.group);

7-4. Fetch Interceptor (전역 헤더 주입)

특정 도메인의 모든 fetch 요청에 헤더를 자동 추가하는 패턴이다. Cesium/Three.js 내부 요청도 포함된다.

const PROTECTED_ORIGIN = 'https://worker.mydomain.workers.dev';
const originalFetch = globalThis.fetch;
 
globalThis.fetch = function (input, init = {}) {
  const url = typeof input === 'string'
    ? input
    : input instanceof Request ? input.url : String(input);
 
  if (url.startsWith(PROTECTED_ORIGIN)) {
    init = {
      ...init,
      headers: {
        ...init.headers,
        'Authorization': `Bearer ${getToken()}`,
      },
    };
  }
  return originalFetch(input, init);
};

⚠️ 전역 fetch를 덮어쓰므로 모든 요청에 영향을 미친다. 도메인 체크를 반드시 포함해야 한다.


8. 종합 비교 테이블

구분A. Worker JWTB. AccessC. PresignedD. HMACE. WAFF. Tunnel
보안 수준높음높음중간중간낮음매우 높음
3D Tiles 적합최적적합부적합적합보조내부 전용
코드 필요Worker없음서버Worker없음없음
파일별 처리불필요불필요필요prefix 가능불필요불필요
R2 상태비공개공개비공개비공개공개비공개
외부 사용자가능가능가능가능가능불가
월 비용57/인무료 tier5$0$0
CesiumJS 연동Resource headersService Token headersURL 교체URL 파라미터없음WARP 필요

권장 조합

상황권장 구성
B2B SaaS (다중 프로젝트)A (Worker JWT) + E (WAF Rate Limiting)
소규모 팀 (50명 이하)B (Access Free)
iframe/img 등 헤더 불가 환경D (HMAC Signed URL)
개발/스테이징F (Tunnel + WARP)

문서 탐색


참고 자료