R2 3D Tiles 보안 — 방식별 상세 비교
6가지 보안 방식 각각의 동작 원리, 구현 코드, 장단점, 클라이언트 연동 방법을 상세히 비교한다. 업데이트: 2026-04-06
핵심 요약
| 구분 | 내용 |
|---|---|
| 📖 정의 | Cloudflare R2 보안 방식 6가지의 동작 원리, 코드, 장단점 상세 비교 |
| 💡 핵심 | Worker JWT Proxy가 최적이며, Access는 코드리스 대안, HMAC은 헤더 불가 환경 보완 |
| 🎯 대상 | R2 보안 방식을 구체적으로 구현하려는 개발자 |
| ⚠️ 주의 | 각 방식의 플랜별 제한(CPU 시간, 요청 수)을 반드시 확인해야 한다 |
목차
- 방식 A: Worker + R2 Binding (JWT Auth Proxy)
- 방식 B: Cloudflare Access (Zero Trust)
- 방식 C: R2 Presigned URL
- 방식 D: Worker HMAC Signed URL
- 방식 E: Public Bucket + WAF / Rate Limiting
- 방식 F: Cloudflare Tunnel + Private Network
- 클라이언트 연동 (CesiumJS / Three.js)
- 종합 비교 테이블
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_SECRETWorker 코드 (완전한 예시)
// 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 Token | One-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.Resource에 headers를 설정하면 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 JWT | B. Access | C. Presigned | D. HMAC | E. WAF | F. Tunnel |
|---|---|---|---|---|---|---|
| 보안 수준 | 높음 | 높음 | 중간 | 중간 | 낮음 | 매우 높음 |
| 3D Tiles 적합 | 최적 | 적합 | 부적합 | 적합 | 보조 | 내부 전용 |
| 코드 필요 | Worker | 없음 | 서버 | Worker | 없음 | 없음 |
| 파일별 처리 | 불필요 | 불필요 | 필요 | prefix 가능 | 불필요 | 불필요 |
| R2 상태 | 비공개 | 공개 | 비공개 | 비공개 | 공개 | 비공개 |
| 외부 사용자 | 가능 | 가능 | 가능 | 가능 | 가능 | 불가 |
| 월 비용 | 5 | 7/인 | 무료 tier | 5 | $0 | $0 |
| CesiumJS 연동 | Resource headers | Service Token headers | URL 교체 | URL 파라미터 | 없음 | WARP 필요 |
권장 조합
| 상황 | 권장 구성 |
|---|---|
| B2B SaaS (다중 프로젝트) | A (Worker JWT) + E (WAF Rate Limiting) |
| 소규모 팀 (50명 이하) | B (Access Free) |
| iframe/img 등 헤더 불가 환경 | D (HMAC Signed URL) |
| 개발/스테이징 | F (Tunnel + WARP) |