지도 타일 보안 구현 예제
R2 + Cloudflare Worker, S3 + CloudFront, Cesium ion 환경에서 3D Tiles를 안전하게 제공하는 실전 구현 패턴을 다룬다. 업데이트: 2026-04-03
핵심 요약
| 구분 | 내용 |
|---|---|
| 📖 정의 | API Key / JWT 인증 Worker, CesiumJS 연동, S3+CloudFront Signed Cookie, Cesium ion 설정의 완성 코드 |
| 💡 핵심 | R2는 Worker Proxy + API Key, S3는 CloudFront + Signed Cookie 조합이 표준이다 |
| 🎯 대상 | R2 또는 S3에 3D Tiles를 저장하고 CesiumJS로 서비스하는 개발자 |
| ⚠️ 주의 | Secret Key, API Key 값은 반드시 환경 변수(wrangler secret)로 관리한다 |
예제 목록
| # | 예제 | 스택 |
|---|---|---|
| 1 | Worker 프로젝트 초기 설정 | wrangler.toml + TypeScript 타입 |
| 2 | R2 + Worker + API Key | Cloudflare Worker (JS) |
| 3 | R2 + Worker + JWT | Cloudflare Worker (JS, Web Crypto API) |
| 4 | CesiumJS 클라이언트 통합 | CesiumJS Resource |
| 5 | Three.js / 3D Tiles Renderer 클라이언트 통합 | Three.js, NASA AMMOS |
| 6 | Fetch Interceptor (전역 헤더 주입) | Vanilla JS |
| 7 | AWS S3 + CloudFront + Signed Cookie | CloudFront + Node.js |
| 8 | Cesium ion Access Token 설정 | Cesium ion |
| 9 | 보안 체크리스트 | — |
예제 1 — Worker 프로젝트 초기 설정
1-1. 프로젝트 생성
npm install -g wrangler
npm create cloudflare@latest tile-proxy
cd tile-proxy1-2. wrangler.toml 설정
name = "tile-proxy"
main = "src/index.ts"
compatibility_date = "2026-03-20"
# R2 버킷 바인딩 (Worker 코드에서 env.TILE_BUCKET으로 접근)
[[r2_buckets]]
binding = "TILE_BUCKET"
bucket_name = "my-3dtiles-bucket" # Private 버킷
[vars]
ALLOWED_ORIGIN = "https://example.com"
# VALID_API_KEY 와 JWT_SECRET 은 반드시 Secret으로 등록
# Rate Limiting 사용 시 KV 바인딩 추가
# [[kv_namespaces]]
# binding = "RATE_KV"
# id = "your-kv-namespace-id"1-3. 비밀 키 등록
# API Key 등록
npx wrangler secret put VALID_API_KEY
# JWT 사용 시
npx wrangler secret put JWT_SECRET1-4. TypeScript 타입 정의
// src/types.ts
export interface Env {
TILE_BUCKET: R2Bucket;
VALID_API_KEY: string;
JWT_SECRET?: string;
ALLOWED_ORIGIN: string;
RATE_KV?: KVNamespace;
}예제 2 — R2 + Worker + API Key
R2 Private 버킷 앞에 API Key 검증 게이트를 구현한 완성 코드다.
// src/index.js
// 환경 변수(env): TILE_BUCKET (R2 바인딩), VALID_API_KEY (Secret), ALLOWED_ORIGIN
export default {
async fetch(request, env) {
// 1. CORS Preflight 처리
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders(env.ALLOWED_ORIGIN) });
}
// 2. GET 요청만 허용
if (request.method !== "GET") {
return new Response("Method Not Allowed", { status: 405 });
}
// 3. API Key 검증
const apiKey = request.headers.get("X-API-Key");
if (!apiKey || apiKey !== env.VALID_API_KEY) {
return new Response("Unauthorized", { status: 401 });
}
// 4. URL에서 R2 오브젝트 키 추출 (/tiles/path/to/file.b3dm → path/to/file.b3dm)
const url = new URL(request.url);
const objectKey = url.pathname.replace(/^\/tiles\//, "");
if (!objectKey) return new Response("Not Found", { status: 404 });
// 5. R2에서 파일 조회
const object = await env.TILE_BUCKET.get(objectKey);
if (!object) return new Response("Not Found", { status: 404 });
// 6. 응답 헤더 구성 및 반환
const headers = new Headers(corsHeaders(env.ALLOWED_ORIGIN));
object.writeHttpMetadata(headers); // Content-Type, ETag 자동 설정
headers.set("Cache-Control", "public, max-age=86400");
return new Response(object.body, { headers });
},
};
function corsHeaders(origin) {
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "X-API-Key, Content-Type",
"Access-Control-Max-Age": "86400",
};
}npx wrangler deploy예제 3 — R2 + Worker + JWT
사용자 로그인이 있는 서비스에서 JWT를 검증하는 패턴이다. 외부 라이브러리 없이 Web Crypto API로 구현한다.
// src/index.js
// 환경 변수(env): TILE_BUCKET (R2 바인딩), JWT_SECRET (Secret), ALLOWED_ORIGIN
async function verifyJWT(token, secret) {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw", encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false, ["verify"]
);
const data = encoder.encode(`${headerB64}.${payloadB64}`);
const signature = Uint8Array.from(
atob(signatureB64.replace(/-/g, "+").replace(/_/g, "/")),
c => c.charCodeAt(0)
);
const valid = await crypto.subtle.verify("HMAC", key, signature, data);
if (!valid) return null;
const payload = JSON.parse(atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/")));
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null; // 만료
return payload;
} catch {
return null;
}
}
export default {
async fetch(request, env) {
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders(env.ALLOWED_ORIGIN) });
}
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return new Response("Unauthorized", { status: 401 });
}
const payload = await verifyJWT(authHeader.slice(7), env.JWT_SECRET);
if (!payload) {
return new Response("Unauthorized: Invalid or expired token", { status: 401 });
}
const url = new URL(request.url);
const objectKey = url.pathname.replace(/^\/tiles\//, "");
const object = await env.TILE_BUCKET.get(objectKey);
if (!object) return new Response("Not Found", { status: 404 });
const headers = new Headers(corsHeaders(env.ALLOWED_ORIGIN));
object.writeHttpMetadata(headers);
return new Response(object.body, { headers });
},
};
function corsHeaders(origin) {
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Max-Age": "86400",
};
}JWT 발급 서버 (Node.js)
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET; // Worker와 동일한 시크릿
function issueToken(userId, allowedPaths = []) {
return jwt.sign({ sub: userId, allowedPaths }, JWT_SECRET, { expiresIn: "1h" });
}
// Express 라우트 예시
app.post("/api/tile-token", async (req, res) => {
const { userId, projectId } = req.body;
const user = await getUserFromDB(userId);
if (!user || !user.hasAccessTo(projectId)) {
return res.status(403).json({ error: "Access denied" });
}
const allowedPaths = await getTilePathsForProject(projectId);
// 예: ["models/building-A/", "models/building-B/"]
res.json({ token: issueToken(user.id, allowedPaths) });
});예제 4 — CesiumJS 클라이언트 통합
Cesium.Resource를 사용하면 인증 헤더가 tileset.json이 참조하는 모든 하위 타일에 자동으로 전파된다.
API Key 방식
import * as Cesium from "cesium";
const tilesetResource = new Cesium.Resource({
url: "https://tile-proxy.example.workers.dev/tiles/building/tileset.json",
headers: { "X-API-Key": API_KEY },
});
const tileset = await Cesium.Cesium3DTileset.fromUrl(tilesetResource);
viewer.scene.primitives.add(tileset);JWT 방식 (토큰 자동 갱신 포함)
async function getValidToken() {
let token = localStorage.getItem("tile_token");
const expiry = Number(localStorage.getItem("tile_token_expiry"));
if (!token || Date.now() > expiry - 600_000) { // 만료 10분 전 갱신
const { token: newToken } = await fetch("/api/auth/tile-token").then(r => r.json());
token = newToken;
localStorage.setItem("tile_token", token);
localStorage.setItem("tile_token_expiry", String(Date.now() + 3_600_000));
}
return token;
}
const token = await getValidToken();
const tilesetResource = new Cesium.Resource({
url: "https://tile-proxy.example.workers.dev/tiles/building/tileset.json",
headers: { Authorization: `Bearer ${token}` },
});
const tileset = await Cesium.Cesium3DTileset.fromUrl(tilesetResource);
viewer.scene.primitives.add(tileset);Terrain 및 Imagery에도 동일 적용
// Terrain (지형 데이터)
const terrainProvider = await Cesium.CesiumTerrainProvider.fromUrl(
new Cesium.Resource({
url: "https://tile-proxy.example.workers.dev/terrain",
headers: { "X-API-Key": API_KEY },
})
);
// Imagery (배경 지도 타일)
// 참고: new UrlTemplateImageryProvider()는 v1.104부터 deprecated
// fromUrl static method 사용 권장
const imageryResource = new Cesium.Resource({
url: "https://tile-proxy.example.workers.dev/imagery/{z}/{x}/{y}.png",
headers: { "X-API-Key": API_KEY },
});
const imageryProvider = await Cesium.UrlTemplateImageryProvider.fromUrl(imageryResource);예제 5 — Three.js / 3D Tiles Renderer 클라이언트 통합
CesiumJS 외에 Three.js 기반 뷰어에서 인증 헤더를 설정하는 방법이다.
Three.js GLTFLoader
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
// setRequestHeader로 모든 요청에 헤더 적용
loader.setRequestHeader({
'Authorization': `Bearer ${jwtToken}`,
});
loader.load(
'https://tile-proxy.example.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),
);3D Tiles Renderer JS (NASA AMMOS)
Three.js 생태계에서 3D Tiles를 처리하는 라이브러리이다. fetchOptions로 모든 타일 요청에 헤더를 일괄 적용할 수 있다.
import { TilesRenderer } from '3d-tiles-renderer';
const renderer = new TilesRenderer();
// fetchOptions로 모든 타일 요청에 헤더 적용
renderer.fetchOptions = {
headers: {
'Authorization': `Bearer ${jwtToken}`,
},
mode: 'cors',
};
renderer.setTileset('https://tile-proxy.example.workers.dev/tilesets/proj-a/tileset.json');
scene.add(renderer.group);CesiumJS의
Cesium.Resource와 마찬가지로,fetchOptions에 설정한 헤더는 tileset.json 이후 모든 자식 타일 요청에도 자동 적용된다.
예제 6 — Fetch Interceptor (전역 헤더 주입)
특정 도메인의 모든 fetch 요청에 헤더를 자동 추가하는 전역 패턴이다. CesiumJS, Three.js 등 라이브러리 내부의 fetch 호출에도 적용된다.
const PROTECTED_ORIGIN = 'https://tile-proxy.example.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를 덮어쓰므로 모든 요청에 영향을 미친다. 반드시 도메인 체크 조건을 포함해야 한다. 라이브러리별 헤더 설정이 가능한 경우(Cesium.Resource,GLTFLoader.setRequestHeader등)에는 해당 방법을 우선 사용한다.
예제 7 — AWS S3 + CloudFront + Signed Cookie
대규모 트래픽 환경에서 S3를 Private으로 유지하며 3D Tiles를 제공하는 패턴이다.
구조
클라이언트 → CloudFront (Signed Cookie 검증) → S3 (비공개, OAC 제한)
1단계: S3 버킷 정책 — CloudFront OAC만 허용
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-tiles-bucket/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789:distribution/DISTRIBUTION_ID"
}
}
}]
}2단계: Signed Cookie 발급 (Node.js 서버)
import { getSignedCookies } from "@aws-sdk/cloudfront-signer";
async function issueTileCookies() {
return getSignedCookies({
keyPairId: process.env.CLOUDFRONT_KEY_PAIR_ID,
privateKey: process.env.CLOUDFRONT_PRIVATE_KEY,
policy: JSON.stringify({
Statement: [{
Resource: "https://tiles.example.com/tiles/*",
Condition: {
DateLessThan: { "AWS:EpochTime": Math.floor(Date.now() / 1000) + 3600 },
},
}],
}),
});
// 반환: { "CloudFront-Policy": "...", "CloudFront-Signature": "...", "CloudFront-Key-Pair-Id": "..." }
}
app.post("/api/login", async (req, res) => {
// 인증 로직...
const cookies = await issueTileCookies();
for (const [name, value] of Object.entries(cookies)) {
res.cookie(name, value, {
httpOnly: true, secure: true, sameSite: "none",
domain: ".example.com", maxAge: 3600 * 1000,
});
}
res.json({ success: true });
});3단계: CesiumJS — 쿠키 포함 요청
// Signed Cookie는 브라우저가 자동으로 포함
// CesiumJS Resource에는 fetchOptions가 없으므로, XMLHttpRequest 기반 설정을 사용한다
// 방법 1: Resource.fetch를 오버라이드하지 않고, 서버가 같은 도메인이면 쿠키 자동 포함
// (Same-Origin이면 별도 설정 없이 동작)
const tilesetResource = new Cesium.Resource({
url: "https://tiles.example.com/tiles/building/tileset.json",
});
// 방법 2: Cross-Origin인 경우 — CesiumJS의 전역 요청 설정 활용
// Resource._Implementations의 loadWithXhr를 래핑하여 withCredentials 적용
const originalLoadWithXhr = Cesium.Resource._Implementations.loadWithXhr;
Cesium.Resource._Implementations.loadWithXhr = function (url, responseType, method, data, headers, deferred, overrideMimeType) {
// tiles.example.com 도메인 요청에만 withCredentials 적용
if (url.includes("tiles.example.com")) {
const xhr = new XMLHttpRequest();
xhr.open(method || "GET", url, true);
xhr.withCredentials = true;
if (responseType) xhr.responseType = responseType;
if (headers) {
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value);
}
}
xhr.onload = function () { deferred.resolve(xhr.response); };
xhr.onerror = function () { deferred.reject(new Error("Request failed")); };
xhr.send(data);
} else {
return originalLoadWithXhr(url, responseType, method, data, headers, deferred, overrideMimeType);
}
};
const tileset = await Cesium.Cesium3DTileset.fromUrl(tilesetResource);⚠️
Cesium.Resource에는fetchOptions파라미터가 존재하지 않는다. Cross-Origin 쿠키 전송이 필요한 경우 위와 같이 XHR 레벨에서withCredentials를 설정해야 한다.
💡 Signed Cookie는
/tiles/*경로 전체에 한 번 적용되므로 수천 개 타일 요청에도 추가 발급이 필요 없다. Presigned URL 대비 월등히 효율적이다.
예제 8 — Cesium ion Access Token 설정
Cesium ion 호스팅 에셋을 사용하는 경우 Access Token 설정 및 도메인 제한 방법이다.
Cesium ion 대시보드 설정
1. cesium.com → Access Tokens → "Create Token"
2. 설정:
- Name: "Production Viewer"
- Scopes: assets:read, assets:list
- Allowed URLs: https://example.com
3. Token 복사 (1회만 전체 표시)
CesiumJS 초기화
import * as Cesium from "cesium";
Cesium.Ion.defaultAccessToken = import.meta.env.VITE_CESIUM_TOKEN;
// .env.local: VITE_CESIUM_TOKEN=eyJhbGciOiJIUzI1NiJ9...
const tileset = await Cesium.Cesium3DTileset.fromIonAssetId(12345);
viewer.scene.primitives.add(tileset);서버 측 임시 토큰 발급 (1시간 유효)
const response = await fetch("https://api.cesium.com/v1/assets/12345/endpoint", {
headers: { Authorization: `Bearer ${process.env.CESIUM_MASTER_TOKEN}` },
});
const { accessToken, endpoint } = await response.json();
// 클라이언트에게 accessToken + endpoint 반환보안 체크리스트
구현 완료 후 확인 항목이다.
| 항목 | 확인 방법 | 통과 기준 |
|---|---|---|
| R2/S3 직접 접근 차단 | 브라우저에서 원본 URL 직접 입력 | 403 또는 접근 불가 |
| 인증 없이 Worker 요청 | curl https://proxy.url/tiles/file.b3dm | 401 Unauthorized |
| 잘못된 Key/Token | curl -H "X-API-Key: wrong" | 401 Unauthorized |
| CORS — 허용되지 않은 오리진 | 브라우저 DevTools Network 탭 | CORS 오류 발생 |
| Rate Limiting 동작 | 빠른 연속 요청 100+ | 429 Too Many Requests |
| 환경 변수 노출 | 소스 코드, git log, GitHub 검색 | Secret 값 없음 |
| 토큰 만료 후 요청 | 만료된 JWT로 요청 | 401 반환 및 클라이언트 갱신 |
전체 흐름 요약 (R2 + Worker)
flowchart TD A[사용자 브라우저<br/>CesiumJS] -->|1 로그인 요청| B[백엔드 서버] B -->|2 JWT 발급| A A -->|3 GET tileset.json<br/>Bearer JWT| C[Cloudflare Worker] C -->|4 Rate Limit 검사| D{제한 초과?} D -->|Yes| H[429 Too Many Requests] D -->|No| E{JWT 검증<br/>만료·경로 확인} E -->|실패| I[401 / 403] E -->|통과| F[R2 Private Bucket] F -->|5 파일 스트리밍| C C -->|6 CORS 헤더 추가 후 응답| A A -->|7 헤더 자동 전파<br/>하위 타일 자동 로드| G[모든 타일 파일] G --> C
문서 탐색
| 이전 | 다음 |
|---|---|
| 보안 방법 상세 | — |