지도 타일 보안 구현 예제

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)로 관리한다

예제 목록

#예제스택
1Worker 프로젝트 초기 설정wrangler.toml + TypeScript 타입
2R2 + Worker + API KeyCloudflare Worker (JS)
3R2 + Worker + JWTCloudflare Worker (JS, Web Crypto API)
4CesiumJS 클라이언트 통합CesiumJS Resource
5Three.js / 3D Tiles Renderer 클라이언트 통합Three.js, NASA AMMOS
6Fetch Interceptor (전역 헤더 주입)Vanilla JS
7AWS S3 + CloudFront + Signed CookieCloudFront + Node.js
8Cesium ion Access Token 설정Cesium ion
9보안 체크리스트

예제 1 — Worker 프로젝트 초기 설정

1-1. 프로젝트 생성

npm install -g wrangler
npm create cloudflare@latest tile-proxy
cd tile-proxy

1-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_SECRET

1-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 등)에는 해당 방법을 우선 사용한다.


대규모 트래픽 환경에서 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"
      }
    }
  }]
}
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.b3dm401 Unauthorized
잘못된 Key/Tokencurl -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

문서 탐색

이전다음
보안 방법 상세

참고 자료