게임 에셋 CDN 확장: 출시일 트래픽 급증에서 살아남기 위한 런북
핵심 요약
확인하세요: 게임 에셋 CDN 확장 런북 — 출시일 트래픽 급증에 대비해 캐시 계층 설계, 오리진 실딩, stale-while-revalidate, 지역별 캐시 적중률 모니터링, 서킷 브레이커 폴백까지 단계별로 대응하고 예방하는 실전 가이드와 체크리스트.
CDN이 곧 무너진다 — 그 징후를 읽는 법
모든 게임 개발자는 같은 출시일 시나리오를 두려워합니다. Steam 페이지가 공개되고, 동시 접속자가 10,000명을 넘어가면, 갑자기 텍스처 다운로드가 평소 12ms의 p99 지연 시간에서 200ms로 멈춰 버립니다. 플레이어들은 모델이 누락되었다고 신고합니다. 패치 다운로드는 43%에서 멈춥니다. 모니터링 대시보드가 빨간색으로 변하지만 어떤 레이어가 실패하고 있는지 전혀 알 수 없습니다.
이것은 가상의 이야기가 아닙니다. 전 세계에서 가장 널리 사용되는 오픈소스 CDN 네트워크 중 하나인 cdnjs는 최근 하루 90억 건의 요청을 처리하기 위해 Cloudflare의 개발자 플랫폼으로 전체 인프라를 마이그레이션했습니다. 이 마이그레이션 이야기는 단일 4K 텍스처 팩 업데이트가 몇 분 만에 테라바이트의 트래픽을 발생시킬 수 있는 게임 에셋 전송에 직접 적용되는 아키텍처 패턴을 보여줍니다.
핵심 교훈: 게임 에셋을 위한 CDN 확장은 더 많은 대역폭을 구매하는 것이 아닙니다. 트래픽 급증이 장애가 아니라 아무 일도 아니게 만들기 위해 캐시 계층, 폴백 로직, 오리진 실딩을 설계하는 것입니다.
이 런북은 CDN이 포화 상태일 때 무엇이 무너지는지, Discord가 분노로 가득 차기 전에 포화를 감지하는 방법, 프로덕션에서 문제를 해결하는 방법, 그리고 재발 방지를 위한 아키텍처 설계 방법을 다룹니다.
CDN이 포화되면 무엇이 무너지는가
게임 에셋 전송은 일반 웹 콘텐츠와 비교할 때 독특한 트래픽 프로파일을 가집니다. 장애 모드를 이해하려면 이 프로파일을 이해해야 합니다.
트래픽 형태 문제
일반적인 인디 멀티플레이어 게임에서는 다음과 같은 트래픽 패턴이 나타납니다.
- 기준선: 로비 에셋, UI 스프라이트, 설정 JSON에 대해 초당 50-200건의 요청
- 패치 데이 스파이크: Steam이 자동 업데이트를 실행하면서 3분 창 안에 초당 15,000-80,000건의 요청
- 지역별 캐스케이드: 아시아-태평양 플레이어는 북미보다 8-12시간 후에 CDN에 도달하여 두 번째 웨이브를 생성
- 에셋 버전 폭증: 각 패치는 캐시된 객체를 무효화하여 새 해시에 대한 오리진 풀을 강제함
cdnjs가 Cloudflare 인프라로 마이그레이션했을 때도 비슷한 버전 폭증 문제에 직면했습니다. npm 스타일 버전 관리는 모든 라이브러리 업데이트가 새 캐시 키를 생성한다는 뜻이었고, 4,200개 이상의 라이브러리가 매일 업데이트되면서 오리진 실딩 설계는 단순 정적 콘텐츠가 아니라 지속적인 캐시 교체를 처리해야 했습니다.
세 가지 장애 모드
1. 오리진 풀 포화
엣지 캐시가 미스되면(새 패치, 콜드 캐시, 캐시 만료) 모든 요청이 오리진 서버에 도달합니다. 1Gbps 처리량을 가진 단일 오리진은 대략 1,250명의 동시 사용자에게 1MB 에셋 다운로드를 제공할 수 있습니다. 각각 2GB 패치를 다운로드하는 80,000명의 동시 플레이어를 처리하려면 대부분의 인디 개발 환경이 갖추지 못한 오리진 용량이 필요합니다.
2. 캐시 스탬피드
가장 많이 요청되는 에셋이 엣지 캐시에서 만료되면(TTL 설정 오류, 배포로 인한 퍼지) 수천 개의 엣지 노드가 동시에 동일한 객체를 오리진에 요청합니다. 이것이 'thundering herd' 문제이며, 몇 초 만에 오리진을 다운시킵니다.
3. 지역별 엣지 기아
북미 엣지 노드는 캐시가 잘 채워져 있습니다. 싱가포르 엣지 노드는 APAC 플레이어가 12,000명뿐이어서 캐시 적중률이 60%입니다. 그러던 어느 일본 유튜버가 여러분의 게임을 소개하고 그 숫자가 하룻밤 사이에 300,000명으로 뛰면 상황이 바뀝니다. 엣지 노드는 대규모로 오리진에서 풀링하고, APAC 플레이어는 2~4초의 로딩 시간을 겪는 동안 북미 플레이어는 40ms를 경험합니다.
감지 신호
# Cloudflare API: check cache hit ratio by region (run every 60 seconds)
curl -s -X POST "https://api.cloudflare.com/client/v4/graphql" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": "{
viewer {
zones(filter: {zoneTag: \"YOUR_ZONE\"}) {
httpRequests1hGroups(limit: 24, filter: {date_gt: \"2025-01-01\"}) {
dimensions { datetime, cacheStatus, clientCountryName }
sum { requests, bytes }
}
}
}
}"
}' | jq '.data.viewer.zones[0].httpRequests1hGroups[] |
select(.dimensions.cacheStatus == "miss") |
{region: .dimensions.clientCountryName, misses: .sum.requests}'
안정 상태 기간 동안 특정 지역의 캐시 미스율이 **8%**를 초과한다면, 여러분은 패치 하나만으로 오리진에 홍수가 날 수 있는 상황입니다.
즉시 조치: 지금 당장 해야 할 일
CDN에 불이 붙었다면, 플레이어들이 리뷰 폭탄을 터뜨리기 전에 15분의 시간이 있습니다. 다음은 문제 분류 순서입니다.
1단계: 오리진 실딩 활성화
대부분의 CDN 제공업체는 '오리진 실드(origin shield)' 또는 '실딩' 기능을 제공합니다. 엣지 노드와 오리진 사이의 중간 캐싱 계층입니다. 캐시 미스 발생 시 200개의 엣지 노드가 각각 독립적으로 오리진에 접근하는 대신, 실드 노드만 오리진에 접근하여 응답을 분배합니다.
설정 예시(일반 CDN API):
{
"shielding": {
"enabled": true,
"shield_region": "us-east-1",
"fallback_shield_region": "eu-west-1",
"shield_ttl_override": 86400,
"pass_on_shield_error": false
}
}
이 변경 하나만으로 캐시 스탬피드 동안 오리진 부하를 95% 줄일 수 있습니다. cdnjs 마이그레이션도 이와 유사한 실딩 로직에 의존했습니다. 그들의 오리진 서버는 시간당 수백만 건의 직접 풀링이 수천 건의 실드 기반 요청으로 줄어드는 것을 경험했습니다.
2단계: 정적 콘텐츠의 에셋 TTL 확장
4K 텍스처, 오디오 뱅크, 메시 파일은 패치 사이에 변경되지 않습니다. 1시간 TTL을 설정할 이유가 없습니다.
# nginx origin server: aggressive caching for immutable game assets
location /assets/v*/ {
# Version-prefixed paths mean new versions get new URLs
# No need to purge — old URLs stay cached forever
add_header Cache-Control "public, max-age=31536000, immutable";
add_header CDN-Cache-Control "max-age=31536000";
}
# Short TTL only for manifest files that change each patch
location /manifest.json {
add_header Cache-Control "public, max-age=60, stale-while-revalidate=300";
}
cdnjs 아키텍처의 핵심 통찰: 에셋을 URL 경로에 버전화하고 쿼리 문자열을 사용하지 마십시오. 많은 CDN 노드는 ?v=2와 ?v=3을 동일한 캐시 키로 취급합니다. 대신 /assets/v2/texture_pack.bin을 사용하십시오.
3단계: Stale-While-Revalidate 활성화
이것은 출시일 트래픽에서 가장 영향력 있는 단일 설정입니다. 캐시된 에셋이 만료되면 CDN은 백그라운드에서 새 버전을 가져오는 동안 만료된 버전을 요청한 플레이어에게 제공합니다. 플레이어는 1,200ms 응답 대신 12ms 응답을 받게 됩니다.
Cache-Control: public, max-age=3600, stale-while-revalidate=86400
이것은 CDN에 다음과 같이 알립니다: "이 에셋은 1시간 동안 유효합니다. 그 후에는 백그라운드에서 재검증하는 동안 최대 24시간 동안 만료된 버전을 제공합니다."
보안에 민감하지 않은 게임 에셋(로비 배경, 코스메틱 프리뷰, 오디오 스템)의 경우 이는 안전하며 체감 지연 시간을 극적으로 줄여줍니다.
4단계: 서킷 브레이커 폴백 구현
CDN 오리진이 정말로 과부하 상태라면, 게임 클라이언트는 멈춘 로딩 화면이 아니라 우아한 성능 저하 경로가 필요합니다.
// C# Unity: CDN circuit breaker with local fallback
public class AssetLoader
{
private const int MAX_RETRIES = 3;
private const int TIMEOUT_MS = 5000;
private static int _failureCount = 0;
private static DateTime _circuitOpened = DateTime.MinValue;
private static readonly TimeSpan CIRCUIT_RESET = TimeSpan.FromMinutes(2);
public async Task<byte[]> LoadAsset(string assetPath)
{
// Circuit breaker: skip CDN if recent failures exceeded threshold
if (_failureCount >= MAX_RETRIES &&
DateTime.UtcNow - _circuitOpened < CIRCUIT_RESET)
{
Debug.LogWarning($"CDN circuit open — loading {assetPath} from local cache");
return LoadFromLocalStorage(assetPath);
}
try
{
using var client = new HttpClient { Timeout = TimeSpan.FromMilliseconds(TIMEOUT_MS) };
var response = await client.GetAsync($"https://cdn.yourgame.com/{assetPath}");
response.EnsureSuccessStatusCode();
_failureCount = 0; // Reset on success
return await response.Content.ReadAsByteArrayAsync();
}
catch (Exception ex)
{
_failureCount++;
if (_failureCount >= MAX_RETRIES)
_circuitOpened = DateTime.UtcNow;
Debug.LogWarning($"CDN fetch failed ({_failureCount}/{MAX_RETRIES}): {ex.Message}");
return LoadFromLocalStorage(assetPath);
}
}
private byte[] LoadFromLocalStorage(string assetPath)
{
// Ship a minimal "emergency asset pack" with your game binary
// This covers the 20 most critical assets: UI, default textures, lobby music
var localPath = Path.Combine(Application.streamingAssetsPath, "fallback", assetPath);
return File.Exists(localPath) ? File.ReadAllBytes(localPath) : Array.Empty<byte>();
}
}
이 패턴은 CDN이 완전히 다운되더라도 게임이 계속 작동하도록 보장합니다. 플레이어는 몇 분 동안 낮은 해상도의 텍스처를 볼 수 있지만, 여전히 플레이할 수 있습니다.
예방: 다중 계층 캐싱 아키텍처
즉시 조치는 출시일을 구합니다. 아키텍처는 그럴 필요 자체를 없애줍니다.
3계층 패턴
Cloudflare Workers로의 cdnjs 마이그레이션은 수십억 건의 요청으로 확장되는 캐싱 아키텍처를 입증했습니다. 게임 에셋에 맞게 조정하면 다음과 같습니다.
1계층 — 엣지 캐시(CDN PoP)
- 요청의 95-99% 처리
- TTL: 버전이 지정된 에셋은 365일, 매니페스트는 60초
- 텍스처, 메시, 오디오, 셰이더 포함
2계층 — 실드/미드티어 캐시
- 엣지 노드의 캐시 미스를 가로챔
- TTL: 엣지와 동일하지만 오리진 프록시 역할
- 오리진 부하를 95% 이상 줄임
3계층 — 오리진 서버
- 에셋 생성, URL 서명, 매니페스트 제공
- 레이트 리밋과 실딩으로 보호
- 전체 트래픽의 <0.1%만 처리해야 함
버전화된 에셋 파이프라인
다음은 캐시 무효화 폭풍을 방지하는 에셋 버전 관리 워크플로우입니다.
# Python: asset pipeline that generates cache-safe versioned URLs
import hashlib
import json
import os
def build_asset_manifest(asset_dir: str, cdn_base: str) -> dict:
"""
Walk asset directory, hash each file, and produce a manifest
with versioned URLs that CDN edge nodes can cache forever.
"""
manifest = {"version": "", "assets": {}}
for root, _, files in os.walk(asset_dir):
for filename in sorted(files):
filepath = os.path.join(root, filename)
relative_path = os.path.relpath(filepath, asset_dir)
# Content hash — identical files get identical URLs
with open(filepath, "rb") as f:
file_hash = hashlib.sha256(f.read()).hexdigest()[:12]
# Version in the PATH, not query string
# CDN treats /assets/a3f9b2c1e8d4/texture.bin as a unique object
versioned_url = f"{cdn_base}/assets/{file_hash}/{relative_path}"
manifest["assets"][relative_path] = {
"url": versioned_url,
"hash": file_hash,
"size": os.path.getsize(filepath),
}
# Manifest version = hash of the entire asset set
all_hashes = "".join(
a["hash"] for a in sorted(manifest["assets"].values(), key=lambda x: x["url"])
)
manifest["version"] = hashlib.sha256(all_hashes.encode()).hexdigest()[:16]
return manifest
# Usage
manifest = build_asset_manifest("./build/assets", "https://cdn.yourgame.com")
with open("./build/manifest.json", "w") as f:
json.dump(manifest, f, indent=2)
print(f"Manifest version: {manifest['version']}")
print(f"Total assets: {len(manifest['assets'])}")
# Output:
# Manifest version: a8f3e1c92b4d7061
# Total assets: 2,847
이 접근 방식을 사용하면:
- 이전 에셋은 절대 퍼지되지 않습니다. 고유 URL이 있으므로 엣지에 무기한 캐시됩니다.
- 새 에셋은 새 URL을 받습니다. CDN은 첫 요청 시 자동으로 캐시합니다.
- 변경되는 유일한 파일은 매니페스트입니다. 60초 TTL을 가진 작은 JSON 파일입니다.
이것은 정확히 cdnjs가 라이브러리 버전 관리를 대규모로 처리하는 방식입니다. 각 라이브러리 버전은 고유한 URL 경로를 가지므로 CDN은 존재하는 CDN 작업 중 가장 비용이 많이 들고 오류가 발생하기 쉬운 퍼지 작업을 전혀 수행할 필요가 없습니다.
이 아키텍처 패턴은 게임 로직과 함께 구성 데이터를 제공해야 하는 Dedicated Server를 운영하는 경우 특히 중요합니다. Unreal Engine Dedicated Server 에셋 스트리핑을 마스터하는 방법에 대한 가이드에서 다루었듯이, 정적 에셋을 서버 핵심 데이터와 분리하는 것은 규모가 커질수록 효과가 커지는 기초 최적화입니다.
지리적 분산: 지역별 캐스케이드 해결
cdnjs 마이그레이션은 단순 엣지 노드 수보다 지능형 라우팅이 더 중요하다는 것을 보여주었습니다. 라우팅 로직이 APAC 요청을 캐시 미스 시 미국 오리진으로 보낸다면 300개의 PoP를 보유하는 것은 의미가 없습니다.
스마트 오리진 선택
{
"origin_rules": [
{
"name": "us-primary",
"origin_server": "origin-us.yourgame.com",
"regions": ["NA", "SA"],
"health_check": "/health",
"failover_origin": "origin-eu.yourgame.com"
},
{
"name": "eu-primary",
"origin_server": "origin-eu.yourgame.com",
"regions": ["EU", "AF"],
"health_check": "/health",
"failover_origin": "origin-us.yourgame.com"
},
{
"name": "apac-primary",
"origin_server": "origin-apac.yourgame.com",
"regions": ["AS", "OC"],
"health_check": "/health",
"failover_origin": "origin-us.yourgame.com"
}
]
}
주요 클라우드 제공업체에서 지역별 오리진 서버는 각각 월 20~40달러입니다. 지역별 오리진 3개를 운영하는 비용은 북미 오리진이 성능 저하 상태로 4시간 동안 APAC 트래픽을 처리해야 하는 단 한 건의 장애와 그로 인한 플레이어 이탈보다 저렴합니다.
이러한 다중 지역 장애 조치 아키텍처는 하이버네이션 전략으로 낭비 없는 서버를 설계하는 방법에 대한 분석에서 논의한 내용과 맞닿아 있습니다. 즉, 유휴 인프라에 비용을 지불하지 않으면서도 확장에 대비하는 원칙입니다.
게임 에셋 CDN 확장 모범 사례
1. 에셋은 쿼리 문자열이 아닌 URL 경로에 버전화하세요.
/assets/{hash}/texture.bin은 캐시 고유성을 보장합니다. ?v=2는 그렇지 않습니다. 많은 CDN 노드는 캐시 키에서 쿼리 매개변수를 제거하므로 오래된 콘텐츠나 깨진 캐시가 발생할 수 있습니다.
2. 매니페스트 TTL과 에셋 TTL을 분리하세요.
매니페스트 파일은 stale-while-revalidate와 함께 30-60초 TTL을 가져야 합니다. 에셋 파일은 immutable과 함께 1년 TTL을 가져야 합니다. 이 차이는 원활한 패치 배포와 캐시 스탬피드 사이의 차이를 만듭니다.
3. 게임 바이너리와 함께 폴백 에셋 팩을 제공하세요. 가장 중요한 에셋 50-100개(UI, 기본 스킨, 로비 환경)는 200-500MB 비상 팩으로 게임 설치 폴더 안에 있어야 합니다. CDN에 연결할 수 없을 때 서킷 브레이커 로직이 이 팩으로 폴백합니다.
4. 전역이 아닌 지역별로 캐시 적중률을 모니터링하세요. 전역 97% 적중률은 동남아시아의 72% 적중률을 가릴 수 있습니다. 지역별 모니터링은 지역 엣지 기아가 플레이어 신고 사고가 되기 전에 발견할 수 있게 해줍니다.
5. 출시 전에 CDN을 부하 테스트하세요. 출시 중이 아니라요. k6, Locust 또는 Vegeta와 같은 도구를 사용하여 예상 출시일 트래픽 패턴을 CDN 엔드포인트에 시뮬레이션하십시오. 50,000명의 가상 사용자가 매니페스트와 상위 20개 에셋을 요청하는 10분 테스트는 실제 플레이어가 발견하기 전에 잘못 설정된 TTL, 누락된 실딩, 오리진 병목 현상을 드러낼 것입니다.
# k6: simulate 50,000 concurrent players hitting the asset manifest
cat <<'EOF' > cdn_load_test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 10000 }, // Ramp to 10K VUs
{ duration: '3m', target: 50000 }, // Spike to 50K VUs
{ duration: '5m', target: 50000 }, // Sustain
{ duration: '2m', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<200'], // 95th percentile under 200ms
http_req_failed: ['rate<0.01'], // Less than 1% errors
},
};
export default function () {
const manifestRes = http.get('https://cdn.yourgame.com/manifest.json');
check(manifestRes, {
'manifest status 200': (r) => r.status === 200,
'manifest under 100ms': (r) => r.timings.duration < 100,
'cache HIT': (r) => r.headers['Cf-Cache-Status'] === 'HIT',
});
// Simulate a player downloading 5 random assets
for (let i = 0; i < 5; i++) {
const assetPath = `assets/placeholder_${Math.floor(Math.random() * 100)}/mesh.bin`;
const assetRes = http.get(`https://cdn.yourgame.com/${assetPath}`);
check(assetRes, {
'asset under 500ms': (r) => r.timings.duration < 500,
});
}
sleep(1);
}
EOF
k6 run cdn_load_test.js
직접 구축할 때와 플랫폼을 사용할 때
위에서 설명한 전체 다중 계층 캐싱 아키텍처를 구축하는 것은 전담 인프라 엔지니어가 있는 팀에게 충분히 가능한 일입니다. 구성 요소는 문서화가 잘 되어 있고, CDN 제공업체는 원시 기본 요소를 제공합니다.
하지만 세 명의 개발자로 구성된 팀이 게임을 출시한다고 가정할 때, 오리진 실딩, 지역별 장애 조치, 에셋 버전 관리 파이프라인, 클라이언트의 서킷 브레이커 로직을 구축하는 데 4-6주를 쓰는 것은 게임플레이에 4-6주를 쓰지 못한다는 뜻입니다. horizOn은 자체 백엔드 스택의 일부로 에셋 전송 인프라를 처리하므로 운영 오버헤드 없이 동일한 다중 지역 캐싱과 자동 장애 조치를 제공합니다. 에셋을 업로드하면 플랫폼이 버전 관리, 엣지 배포, 상태 모니터링을 즉시 처리합니다.
이 글의 아키텍처 원칙은 인프라 선택과 관계없이 여전히 중요합니다. 버전화된 URL 경로가 왜 중요한지, stale-while-revalidate가 왜 스탬피드를 방지하는지, 지역별 오리진이 왜 지연 시간을 줄이는지 이해하면 Cloudflare Workers를 직접 구성하든 관리형 백엔드 서비스를 평가하든 정보에 입각한 결정을 내릴 수 있습니다.
다음 단계: 다음 패치 전에 부하 테스트 실행
다음 패치 날짜를 정하십시오. 2주 전에 위의 k6 스크립트를 CDN 엔드포인트에 실행해 보십시오. 시뮬레이션된 출시 규모에서 p95 지연 시간이 200ms를 초과한다면 고칠 시간이 있습니다. 지속 단계에서 캐시 적중률이 90% 아래로 떨어지는 것을 발견하면 오리진 실딩을 활성화하고 에셋 TTL을 늘리십시오.
원활한 출시와 출시일 재해의 차이는 거의 게임 코드가 아닙니다. 모든 플레이어가 처음 5분 안에 다운로드하는 2GB의 에셋을 제공하는 인프라가 그 차이를 만듭니다. 그것을 제대로 갖추면 나머지는 게임플레이일 뿐입니다.