콘텐츠로 이동

DNS 심화 & CDN

전제 지식: L2 dns-basics.md에서 DNS 쿼리 과정(Recursive/Iterative), A/CNAME/MX/TXT 레코드, TTL, Route 53 Hosted Zone 개념을 다뤘다. 이 문서는 그 위에서 시작한다.


0. 왜 이 토픽이 필요한가 — 선행 기술의 한계와 등장 메커니즘

섹션 제목: “0. 왜 이 토픽이 필요한가 — 선행 기술의 한계와 등장 메커니즘”

L2 dns-basics에서 다룬 기본 DNS와 origin-only 콘텐츠 전달 모델은 두 가지 한계를 동시에 가진다.

한계 1 — 신뢰 부재 (RFC 1034/1035, 1987년). 평문 UDP/53 + 16비트 트랜잭션 ID 만으로 응답을 식별. 2008년 Dan Kaminsky가 발표한 cache poisoning은 Recursive Resolver에 위조 응답을 주입하는 데 평균 10초 안팎이면 충분함을 실증했다. 임시 패치(source port 무작위화로 엔트로피 ~32비트 확장)는 공격 비용을 늘렸을 뿐 근본 해결이 아니다 (출처: Kaminsky-style cache poisoning empirical study — ScienceDirect).

한계 2 — 거리 = 지연 (origin-only). 한국 사용자가 us-east-1 origin에 직접 도달하면 RTT 약 150ms에 TCP·TLS 핸드셰이크가 곱해져 TTFB가 쉽게 500ms를 넘는다. RUM 기반 벤치마크에서 동일한 100KB PNG의 p95 TTFB가 Cox Communications 기준 Akamai 441.5ms, CloudFront 404.4ms, Fastly 357.6ms, Cloudflare 332.6ms로 측정됐다 (출처: Cloudflare Edge Network Benchmark). DNS 조회 자체도 10–50ms로 모바일 첫 페이지 로드의 10–30%를 차지한다 (출처: How TLD DNS latency shapes CDN TTFB — CDNsun). Google web.dev의 권고 TTFB는 0.8초 이하 (출처: TTFB — web.dev).

이 토픽이 해결하는 메커니즘.

한계해결 도구메커니즘 (본문 위치)
위조 응답(Kaminsky)DNSSECRRSIG 서명 + DS 체인으로 Resolver가 진위 검증 → §1
평문 노출 (ISP 가시성)DoH/DoTDNS 메시지를 TLS/HTTPS 안으로 래핑 → §2
origin 단일 지점 RTTCDN edge cache사용자 가까운 PoP에서 응답, p95 TTFB 수백 ms → 수십 ms → §4–6
origin 장애 = 서비스 중단stale serving + Failoverstale-if-error·Route 53 Failover로 가용성 유지 → §3-5, §8-5b

이 토픽이 사라지면 무엇이 깨지는가: 피싱과 MITM에 사실상 무방비(L2 web-security와의 신뢰 가정 붕괴), 글로벌 서비스 p95 TTFB 1초+ 진입으로 Core Web Vitals(LCP) 광범위 미달, 단일 region 장애 = 전 사용자 down.


1. DNSSEC — DNS에 서명을 추가하다

섹션 제목: “1. DNSSEC — DNS에 서명을 추가하다”

DNS는 설계 당시 보안을 고려하지 않았다. Recursive Resolver가 캐시에서 응답을 돌려줄 때 그 응답이 진짜인지 위조된 것인지 확인할 방법이 없다. 이 약점을 악용하는 공격이 DNS Cache Poisoning이다.

공격자가 Resolver 캐시에 위조 응답을 주입
→ 클라이언트는 bank.com → 공격자 IP 로 안내됨
→ 피싱 사이트로 유도, 사용자는 인지 불가

DNSSEC(DNS Security Extensions)은 DNS 응답에 디지털 서명을 추가하여 응답의 진위와 무결성을 보장한다. 암호화는 하지 않는다 — 내용은 평문이지만, 변조 여부를 검증할 수 있다.

레코드역할
DNSKEY존(Zone)의 공개키. ZSK(Zone Signing Key)와 KSK(Key Signing Key) 두 종류
RRSIG각 RRset(레코드 집합)에 대한 디지털 서명
DS (Delegation Signer)부모 존에 저장되는 자식 존 KSK의 해시. 신뢰 체인의 연결 고리

ZSK vs KSK

  • ZSK: 실제 DNS 레코드(A, CNAME 등)를 서명. 주기적으로 교체(보통 30일).
  • KSK: ZSK 공개키(DNSKEY 레코드)를 서명. 교체 주기가 더 길다(보통 1년). KSK의 해시가 DS 레코드로 부모 존에 등록된다.

1-3. 체인 오브 트러스트 (Chain of Trust)

섹션 제목: “1-3. 체인 오브 트러스트 (Chain of Trust)”

DNSSEC의 신뢰는 루트(.)에서 시작하여 TLD → 도메인으로 전파된다.

루트(.) Zone
├── DNSKEY (루트 KSK, ZSK)
├── RRSIG (루트 ZSK로 서명된 레코드들)
└── DS for .com ←── .com KSK의 해시
.com Zone
├── DNSKEY (.com KSK, ZSK)
├── RRSIG (.com ZSK로 서명된 레코드들)
└── DS for example.com ←── example.com KSK의 해시
example.com Zone
├── DNSKEY (example.com KSK, ZSK)
├── A 레코드 → 93.184.216.34
└── RRSIG (example.com ZSK로 서명된 A 레코드)

Resolver가 example.com의 A 레코드를 검증하는 흐름:

  1. example.com에서 A 레코드 + RRSIG 수신
  2. example.com의 DNSKEY(ZSK)로 RRSIG 검증
  3. example.com의 DNSKEY(KSK)가 유효한지 확인 → .com에서 DS 레코드 조회
  4. .com의 DS 레코드와 example.com KSK 해시가 일치하는지 확인
  5. .com의 DNSKEY 역시 루트의 DS로 검증
  6. 루트는 IANA가 관리하는 Trust Anchor(공개키)로 검증 — 브라우저/OS에 하드코딩
Terminal window
# DNSSEC 검증 확인 (dig +dnssec)
dig +dnssec example.com A
# DS 레코드 조회
dig DS example.com @a.gtld-servers.net
# DNSKEY 조회
dig DNSKEY example.com
# RRSIG 확인
dig +dnssec +multi A example.com

예상 출력 (DNSSEC 활성화된 도메인):

;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2
;; "ad" 플래그(Authenticated Data)가 있으면 Resolver가 DNSSEC 검증 성공
;; ANSWER SECTION:
example.com. 3600 IN A 93.184.216.34
example.com. 3600 IN RRSIG A 8 2 3600 (
20250215000000 20250115000000 12345 example.com.
abc123def456ghi789== )
# RRSIG 필드 설명:
# A → 서명 대상 레코드 타입
# 8 → 서명 알고리즘 (8 = RSA/SHA-256)
# 2 → 레이블 수
# 3600 → 원본 TTL
# 20250215000000 → 서명 만료 시각
# 12345 → Key Tag (서명에 사용된 DNSKEY 식별자)

DNSSEC 검증이 왜 DNS Cache Poisoning을 막는가:

캐시 포이즈닝 시도:
공격자가 "example.com → 10.1.2.3" (위조 응답) 주입 시도
DNSSEC 없을 때:
Resolver는 응답의 진위를 확인할 방법이 없음 → 공격 성공
DNSSEC 있을 때:
1. 위조 응답에는 RRSIG가 없거나 잘못된 서명이 있음
2. Resolver: example.com의 ZSK(DNSKEY)로 RRSIG 검증
3. 서명이 맞지 않음 → SERVFAIL 반환 → 공격 실패
핵심: 공격자는 example.com의 ZSK 개인키 없이는 유효한 RRSIG를 만들 수 없음

📖 더 보기: DNSSEC Basics — Internet Society — RRSIG/DNSKEY/DS의 역할과 Chain of Trust 시각 자료. 이 섹션의 1-2~1-3 심화 학습용

  • 키 교체(Key Rollover): ZSK 교체 시 기존 키와 새 키를 동시에 게시하는 기간(Pre-publish) 필요. 캐시 TTL보다 길게 유지.
  • 부정적 응답 보호: 존재하지 않는 도메인 쿼리에 대해 NSEC/NSEC3 레코드로 서명된 “없음” 응답을 제공.
  • NSEC3: NSEC의 존 열거(Zone Walking) 취약점을 해결. 레코드 이름을 해시하여 응답.

전통적인 DNS는 UDP 53번 포트로 평문 전송된다. ISP, 네트워크 관리자, 중간자가 DNS 쿼리를 볼 수 있다.

클라이언트 → "api.example.com 어디야?" → Resolver (평문, 누구나 볼 수 있음)
  • 포트: 853 TCP
  • 방식: DNS 메시지를 TLS 1.3으로 래핑
  • 특징: 기존 DNS 프로토콜 구조 그대로, TLS 레이어만 추가
  • 단점: 853 포트가 차단된 환경에서 우회 불가. ISP나 방화벽이 식별하기 쉬움.
Terminal window
# kdig로 DoT 테스트 (knot-dnsutils 패키지)
kdig -d @1.1.1.1 +tls-ca example.com A
# curl로 DoH 테스트
curl -s "https://cloudflare-dns.com/dns-query?name=example.com&type=A" \
-H "accept: application/dns-json"
  • 포트: 443 TCP (일반 HTTPS와 동일)
  • 방식: HTTP/2 또는 HTTP/3 위에서 DNS 쿼리를 JSON 또는 binary wire format으로 전송
  • 엔드포인트 예시:
    • Cloudflare: https://1.1.1.1/dns-query
    • Google: https://8.8.8.8/dns-query
  • 장점: 443 포트를 사용하므로 차단이 어렵고 일반 HTTPS 트래픽과 구분이 어려움
  • 단점: 기존 네트워크 모니터링 도구와 충돌 가능성. 기업 환경에서 보안 정책 우회 우려.
Firefox: 기본값으로 Cloudflare DoH 사용 (Trusted Recursive Resolver)
Chrome: 시스템 DNS가 DoH 지원하면 자동 업그레이드
Android 9+: Private DNS 설정으로 DoT 지원
iOS/macOS: DNS-over-HTTPS profile 설치 가능
항목DoTDoH
포트853443
프로토콜DNS + TLSDNS + HTTPS
식별 용이성쉬움 (전용 포트)어려움 (HTTPS와 혼재)
기업 환경 적합성높음낮음 (정책 우회 우려)
개인 프라이버시좋음더 좋음
구현 복잡도낮음높음

3. DNS 기반 로드밸런싱 — Route 53 라우팅 정책

섹션 제목: “3. DNS 기반 로드밸런싱 — Route 53 라우팅 정책”

Route 53은 단순한 DNS 서버가 아니다. 헬스 체크와 결합하여 지능형 트래픽 라우팅 플랫폼으로 동작한다.

가장 기본적인 방식. 하나의 레코드에 여러 IP를 등록하면 랜덤하게 반환한다.

api.example.com → [1.2.3.4, 5.6.7.8] (무작위 순서로 반환)

헬스 체크 불가. 장애 시 자동 제외 없음.

트래픽 비율을 직접 제어한다. A/B 테스트, 카나리 배포에 적합.

api.example.com
├── 1.2.3.4 (Weight: 90) → 90% 트래픽
└── 5.6.7.8 (Weight: 10) → 10% 트래픽 (새 버전 카나리)

가중치는 절대값이 아닌 비율. Weight 70 + 30이면 70%, 30% 분배.

Terminal window
# Route 53 CLI로 Weighted 레코드 생성
aws route53 change-resource-record-sets \
--hosted-zone-id Z1234567890 \
--change-batch '{
"Changes": [{
"Action": "CREATE",
"ResourceRecordSet": {
"Name": "api.example.com",
"Type": "A",
"SetIdentifier": "primary",
"Weight": 90,
"TTL": 60,
"ResourceRecords": [{"Value": "1.2.3.4"}]
}
}]
}'

3-3. Latency-based 라우팅 (지연시간)

섹션 제목: “3-3. Latency-based 라우팅 (지연시간)”

클라이언트에서 각 AWS 리전까지의 측정된 지연시간을 기준으로 가장 빠른 리전으로 라우팅한다.

Seoul 사용자 → ap-northeast-2 (도쿄) 리전 서버
London 사용자 → eu-west-1 (아일랜드) 리전 서버

실측 데이터 기반이므로 지리적 위치와 항상 일치하지는 않는다. 해저 케이블 라우팅, 피어링 상황에 따라 달라진다.

클라이언트의 IP 기반 위치로 라우팅한다. 지연시간과 달리 콘텐츠 규정 준수(법적 제약), 언어별 서비스에 사용.

대한민국 → kr.example.com (한국어 서비스)
유럽 → eu.example.com (GDPR 준수 서버)
기본값 → default.example.com (위치 불명 트래픽)

주의: 기본값(Default) 레코드를 반드시 설정해야 한다. 미설정 시 매핑되지 않는 지역에서 NXDOMAIN 반환.

Active-Passive 고가용성 구성. Primary 헬스 체크 실패 시 Secondary로 자동 전환.

api.example.com
├── Primary: 1.2.3.4 (헬스 체크 연결)
│ └── 헬스 체크 실패 시 ↓
└── Secondary: 5.6.7.8 (fallback)

Route 53 헬스 체크 설정:

{
"Type": "HTTPS",
"ResourcePath": "/health",
"FullyQualifiedDomainName": "api.example.com",
"RequestInterval": 30,
"FailureThreshold": 3
}
  • RequestInterval: 30초마다 체크
  • FailureThreshold: 3회 연속 실패 시 Unhealthy 판정
  • 복구는 반대로 3회 연속 성공 시 Healthy

3-6. Geoproximity 라우팅 (Traffic Policy)

섹션 제목: “3-6. Geoproximity 라우팅 (Traffic Policy)”

지리적 위치 + **Bias(편향값)**로 라우팅 경계를 조정한다. 특정 리전으로 더 많은 트래픽을 유도하거나 줄일 수 있다.

Bias +50: 해당 리전의 서비스 반경을 확장 (더 많은 트래픽 흡수)
Bias -50: 해당 리전의 서비스 반경을 축소

Traffic Flow 시각화 도구에서 지도 기반으로 확인 가능.

Terminal window
# CloudWatch 알람과 연동하여 SNS 알림
aws route53 create-health-check \
--caller-reference "my-health-check-$(date +%s)" \
--health-check-config '{
"IPAddress": "1.2.3.4",
"Port": 443,
"Type": "HTTPS",
"ResourcePath": "/health",
"FullyQualifiedDomainName": "api.example.com",
"RequestInterval": 30,
"FailureThreshold": 3
}'

3.99 새 캐시·신뢰체인 시스템 만났을 때 — 전이 분석 체크리스트

섹션 제목: “3.99 새 캐시·신뢰체인 시스템 만났을 때 — 전이 분석 체크리스트”

DNS+CDN의 핵심 원리(Chain of Trust, 다단 캐시, stale-while-revalidate)는 다른 도메인에서도 동일하게 반복된다.

원리DNS+CDNOS 페이지 캐시DB 쿼리 캐시HTTP 캐시OAuth/JWT
Chain of TrustDNSSEC: Root → TLD → zone커널 → fs → block deviceDB → MV → 결과 캐시TLS chain → ETagIdP → Access Token → API
다단 캐시 (계층)browser → OS → resolver → authorityL1/L2/L3 cache → page cache → swapshared_buffer → OS cache → diskbrowser → CDN edge → originIdP cache → API gateway → service
stale 패턴TTL 만료 후 RFC 8767 serve-staledirty page writeback 지연MV refresh intervalstale-while-revalidaterefresh token grace period
무효화 전파TTL 자연 만료 또는 즉시 (low TTL)fsync, msyncTRUNCATE, REFRESH MVCache-Control: no-cache, purge APIrevocation list

새 캐시 시스템을 만났을 때 5가지 진단 질문

  1. 신뢰의 근원(Root)은 누구이고 위임 체인은? — DNSSEC: Root KSK. CDN: Origin cert. JWT: 서명 키
  2. 캐시 계층은 몇 단계? 각 TTL은? — TTL이 길수록 hit ratio↑·전파 지연↑ (trade-off)
  3. stale 응답을 허용하는가? — 운영 안정성↑ vs 데이터 freshness↓
  4. 무효화는 push인가 pull인가? — pull: TTL 만료 대기 (간단·전파 느림). push: 즉시 (빠름·인프라 복잡)
  5. 장애 시 fallback 경로는? — 권한 서버 다운 시 stale serve? Origin 다운 시 CDN 캐시만?
Terminal window
# (1) TTL 변동에 따른 전파 시간 측정
# 현재 TTL 확인
dig +short TTL example.com A
# TTL을 60초로 줄이고 변경 적용
aws route53 change-resource-record-sets ... # TTL=60
# 다른 지역 resolver에서 변경 확인 시간 측정
for i in 1 2 3 4 5; do
dig @8.8.8.8 example.com A; sleep 30
done
# 검증 질문: 전파가 정확히 TTL만큼 걸리는가? (실제는 +/-50%)
# (2) Cache Policy 변경 효과 측정
# Cache-Control 헤더 변경 후 CloudFront 응답에서 X-Cache 헤더 확인
curl -I https://my-cdn.example.com/asset.js | grep -i x-cache
# X-Cache: Hit from cloudfront ← edge HIT
# X-Cache: Miss from cloudfront ← origin fetch
# 검증 질문: hit ratio가 80%+ 인가? 아니면 cache key 설계 재검토
# (3) stale-while-revalidate 동작 실측
# Cache-Control: max-age=60, stale-while-revalidate=300 설정 후
# 60초 후 첫 요청 → stale 응답(빠름) + 백그라운드 갱신
# 검증 질문: 응답 시간이 origin 직접 fetch 대비 90%+ 빠른가?

(참고: RFC 8767 — Serving Stale Data, Cloudflare cache-control 공식 가이드)

4. CDN 아키텍처 — 엣지에서 콘텐츠를 제공하다

섹션 제목: “4. CDN 아키텍처 — 엣지에서 콘텐츠를 제공하다”

사용자와 가까운 **엣지 서버(POP, Point of Presence)**에 콘텐츠를 캐시하여 오리진 서버 부하를 줄이고 응답 속도를 높인다.

[사용자] → [엣지 서버(서울)] → [오리진 서버(us-east-1)]
캐시 히트 시 여기서 응답 (RTT 수십ms)
캐시 미스 시 오리진에서 가져와 캐시 후 응답

CDN 없이:

  • 서울 사용자 → 미국 오리진: ~150ms RTT × 왕복 수회 = 수백ms 지연

CDN 있을 때:

  • 서울 사용자 → 서울 엣지: ~5ms RTT
  • 엣지 → 오리진: 백그라운드에서 캐시 갱신
L1 캐시 (메모리): 엣지 서버 RAM — 가장 빠름, 용량 제한
L2 캐시 (디스크): 엣지 서버 SSD — L1 미스 시 조회
Regional Cache: 중간 계층 — 여러 엣지가 공유 (CloudFront의 Regional Edge Cache)
Origin: 원본 서버 — 모든 캐시 미스의 최종 목적지

CloudFront의 3계층:

사용자
└→ Edge Location (전 세계 450+ 개)
└→ Regional Edge Cache (12개, 더 큰 캐시)
└→ Origin (S3, ALB, EC2 등)
Cache Hit Ratio = 캐시에서 응답한 요청 수 / 전체 요청 수
목표: 90%+ (정적 자산 기준)

히트율을 높이는 방법:

  • TTL 늘리기 (versioned URL과 병행)
  • 불필요한 쿼리 파라미터 제거 (Cache Key 정규화)
  • 동일 콘텐츠가 여러 URL로 캐시되는 것 방지

실무에서 CDN 선택은 “가장 빠른 CDN”이 아니라 팀의 기존 인프라, 커스터마이징 필요도, 보안 우선순위에 따라 결정된다.

기준AWS CloudFrontCloudflareFastly
AWS 통합최강 (S3, ALB, Lambda@Edge 네이티브)별도 설정 필요별도 설정 필요
글로벌 PoP450+ Edge Location300+ PoP, TTFB 최하위 95th 우수소수 대형 PoP (고성능)
캐시 무효화전파 15~60초, 비용 발생즉시 퍼지 (Global Purge)즉시 퍼지 + Surrogate-Key 태그 단위
보안WAF 별도 구매, Shield Standard 기본WAF·DDoS·Bot 관리 통합 제공WAF 옵션 (Next-gen WAF)
에지 컴퓨팅CloudFront Functions / Lambda@EdgeCloudflare Workers (V8 Isolate, 범용)Compute@Edge (WASM 기반, 저지연)
비용 구조데이터 전송량·요청 수 과금, 인벨리데이션 비용무료 플랜 ~ 엔터프라이즈종량제 (트래픽·요청), 실시간 분석 포함
커스터마이징VCL 불가, Function 제약 (ES5.1, 1ms)Workers: 전체 Request 조작 가능VCL(Varnish) + Compute@Edge로 최고 수준
실시간 분석CloudWatch (집계형)Analytics (실시간, 대시보드 우수)실시간 로그 스트리밍, 세부 분석 업계 최고

선택 결정 기준:

✓ AWS 스택 중심(ECS/EKS/S3) + Lambda 엣지 로직 필요 → CloudFront
✓ 보안(DDoS/Bot/WAF) 통합 + 무료 시작 + 글로벌 속도 → Cloudflare
✓ VCL/WASM 고급 캐시 제어 + 실시간 분석 + API/스트리밍 → Fastly

결정-결과 적용 예 (이 기준을 어떻게 쓰는가): 한국 사용자 70%인 ALB+S3 SSR 스택에서 “AWS 통합 + Lambda@Edge로 GeoIP 헤더 주입 필요”가 결정 인자였다면 CloudFront를 선택한다. 결과 검증은 x-cache: Hit from cloudfront 비율(>90% 목표)과 RUM 기반 p95 TTFB(서울 사용자 기준 §0의 벤치마크값에 근접한지)로 한다. 반대로 “원천 콘텐츠 차단(DDoS) + Bot 관리가 1순위, AWS 종속 약함”이라면 같은 기준이 Cloudflare로 끌고 가며, 이때는 Always Online을 켜고 §8-5b의 stale 위험 콘텐츠 목록을 다시 검토해야 한다.

전형적 오결정 신호 (Inversion):

  • x-cache: Miss 비율이 30% 초과로 고착 → cache key에 Authorization·user-agent·랜덤 쿼리스트링 포함 의심 (§5-4 Cache Policy 재설계)
  • 무효화(/*)를 일주일에 수회 호출 → versioned URL 미적용 신호. 비용보다 origin 부하 spike가 위험 (§6-3)
  • CloudFront에서 결제 API에 stale-if-error를 켜놓고 §8-5b “stale 위험 콘텐츠” 항목을 못 본 경우 → 가용성 확보 대신 정합성 사고
기준AWS Route 53Cloudflare DNS
쿼리 속도평균 ~20ms (글로벌)평균 ~11ms (세계 최속급)
AWS 연동EC2/ELB/S3 Alias 레코드 네이티브 지원별도 API 연동 필요
고급 라우팅Weighted/Latency/Geo/Failover/GeoproximityLoad Balancing(유료), Traffic Steering
DNSSEC지원 (콘솔에서 활성화)지원 (원클릭 활성화, 무료)
DoH/DoTResolver Query Logging만 지원1.1.1.1 DoH/DoT 기본 제공
DDoS 보호AWS Shield Standard 포함Cloudflare 네트워크 레벨 보호 내장
가격Hosted Zone $0.50/월 + 쿼리당 과금무료 플랜 제공, 유료는 Load Balancing 추가
헬스 체크전용 헬스 체크 + CloudWatch 알람 연동 강력제한적 (유료 플랜에서 고급 모니터링)

선택 결정 기준:

✓ AWS 전체 스택 + Route 53 Failover/Geoproximity 라우팅 필요 → Route 53
✓ 빠른 쿼리 속도 + 무료 DNSSEC + DoH/DoT + 비AWS 인프라 → Cloudflare DNS

출처: Cloudflare DNS vs AWS Route 53 — DEV Community, CDN Comparison — Trackit.io


Distribution
├── Origin (어디서 가져올 것인가)
│ ├── S3 Bucket
│ ├── ALB / EC2
│ ├── API Gateway
│ └── Custom HTTP 서버
└── Behavior (어떤 요청에 어떻게 반응할 것인가)
├── Path Pattern 매칭
├── Cache Policy
├── Origin Request Policy
└── Response Headers Policy

S3 Origin:

{
"DomainName": "my-bucket.s3.amazonaws.com",
"S3OriginConfig": {
"OriginAccessIdentity": "origin-access-identity/cloudfront/ABCDEF"
}
}

OAI(Origin Access Identity) 또는 OAC(Origin Access Control)를 사용하면 S3 버킷을 CloudFront를 통해서만 접근 가능하게 잠글 수 있다.

ALB Origin (SSR 서버):

{
"DomainName": "my-alb.ap-northeast-2.elb.amazonaws.com",
"CustomOriginConfig": {
"HTTPSPort": 443,
"OriginProtocolPolicy": "https-only",
"OriginSSLProtocols": ["TLSv1.2"]
}
}

하나의 Distribution에서 경로별로 다른 오리진과 캐시 정책을 적용할 수 있다.

/* → Default Behavior (SPA index.html, S3 오리진)
/api/* → API Behavior (ALB 오리진, 캐시 비활성화)
/static/* → Static Behavior (S3 오리진, 캐시 1년)
/images/* → Image Behavior (S3 오리진, 이미지 최적화)

경로 매칭 우선순위: 가장 구체적인 패턴이 우선. /*는 최후 fallback.

Cache Key를 무엇으로 구성할지 정의한다. Cache Key가 같으면 동일한 캐시 엔트리로 처리.

{
"CachePolicyConfig": {
"Name": "api-cache-policy",
"DefaultTTL": 0,
"MaxTTL": 31536000,
"MinTTL": 0,
"ParametersInCacheKeyAndForwardedToOrigin": {
"EnableAcceptEncodingGzip": true,
"EnableAcceptEncodingBrotli": true,
"HeadersConfig": {
"HeaderBehavior": "none"
},
"CookiesConfig": {
"CookieBehavior": "none"
},
"QueryStringsConfig": {
"QueryStringBehavior": "whitelist",
"QueryStrings": {
"Quantity": 1,
"Items": ["version"]
}
}
}
}
}

관리형 Cache Policy:

  • CachingOptimized: 정적 파일 최적화 (기본 TTL 86400초)
  • CachingDisabled: 동적 콘텐츠 (캐시하지 않음)
  • CachingOptimizedForUncompressedObjects: 압축 없는 파일용

오리진에 전달할 헤더, 쿠키, 쿼리스트링을 제어한다. Cache Key와 독립적으로 설정 가능.

Cache Key에는 포함하지 않지만 오리진에는 전달해야 하는 것들:
- Authorization 헤더 (캐시 키에 넣으면 유저별로 캐시 분리됨)
- CloudFront-Viewer-Country 헤더 (지역 정보)
- User-Agent (오리진에서 참고용)

응답 헤더: Cache-Control: max-age=3600
→ 1시간 후 자동 만료, 오리진에서 재조회

장점: 구현 간단, 비용 없음 단점: 배포 즉시 반영 불가, TTL 동안 구버전 서빙

Terminal window
# 특정 경로 무효화
aws cloudfront create-invalidation \
--distribution-id E1234567890 \
--paths "/index.html" "/app.js"
# 전체 무효화 (주의: 비용 발생 + 일시적 오리진 부하 증가)
aws cloudfront create-invalidation \
--distribution-id E1234567890 \
--paths "/*"

비용: 월 1,000개 무효화 경로까지 무료, 이후 경로당 $0.005. /*는 1개 경로로 계산.

전파 지연: 무효화 완료까지 보통 15~60초. 그 전까지 엣지에 따라 구버전 서빙 가능.

파일 이름이나 쿼리스트링에 콘텐츠 해시를 포함시켜 변경 시 새 URL로 접근하게 한다.

변경 전: /static/app.js → Cache-Control: max-age=31536000
변경 후: /static/app.abc123.js → 새 파일, 새 캐시 엔트리
index.html: Cache-Control: max-age=0, must-revalidate
→ 항상 최신 index.html을 가져오고, index.html이 새 해시 파일을 참조

Webpack/Vite 설정:

vite.config.ts
export default {
build: {
rollupOptions: {
output: {
entryFileNames: "assets/[name].[hash].js",
chunkFileNames: "assets/[name].[hash].js",
assetFileNames: "assets/[name].[hash][extname]",
},
},
},
};
Cache-Control: max-age=60, stale-while-revalidate=300
→ 60초: 캐시에서 즉시 응답 (fresh)
→ 60~360초: 캐시에서 응답 + 백그라운드 갱신 (stale이지만 서빙)
→ 360초 이후: 반드시 오리진에서 재조회

사용자는 항상 빠른 응답을 받고, 백그라운드에서 조용히 갱신된다.

콘텐츠 유형에 따른 전략:
정적 자산 (JS, CSS, 이미지)
├── 해시가 포함된 파일명? → max-age=31536000 (1년)
└── 해시 없음 → max-age=3600 + Invalidation on deploy
HTML 파일 (index.html)
└── no-cache 또는 max-age=0, must-revalidate
API 응답
├── 공통 데이터 (설정, 목록) → max-age=60~300
├── 사용자별 데이터 → private, no-store
└── 실시간 데이터 → no-cache, no-store

7. 엣지 컴퓨팅 — CloudFront Functions와 Lambda@Edge

섹션 제목: “7. 엣지 컴퓨팅 — CloudFront Functions와 Lambda@Edge”

7-1. 엣지에서 코드를 실행하는 이유

섹션 제목: “7-1. 엣지에서 코드를 실행하는 이유”

오리진 서버에 요청이 도달하기 전에 엣지에서 로직을 처리하면:

  • 오리진 부하 감소
  • 지연시간 최소화
  • 지역별 맞춤 처리
클라이언트
↓ [Viewer Request] ← CloudFront Functions / Lambda@Edge
Edge Location
↓ [Origin Request] ← Lambda@Edge만
Regional Edge Cache / Origin
↓ [Origin Response] ← Lambda@Edge만
Edge Location
↓ [Viewer Response] ← CloudFront Functions / Lambda@Edge
클라이언트
  • 런타임: JavaScript (ES5.1 제한)
  • 실행 위치: 모든 엣지 로케이션 (450+개)
  • 최대 실행시간: 1ms
  • 메모리: 2MB
  • 용도: URL 리다이렉트, 헤더 추가/수정, A/B 테스트 쿠키 설정, 간단한 인증
// CloudFront Function 예시: SPA 라우팅 처리
function handler(event) {
var request = event.request;
var uri = request.uri;
// 파일 확장자가 없는 경로는 index.html로 리다이렉트
if (!uri.includes(".")) {
request.uri = "/index.html";
}
return request;
}
// A/B 테스트 쿠키 설정
function handler(event) {
var request = event.request;
var cookies = request.cookies;
if (!cookies["ab-variant"]) {
// 새 방문자에게 랜덤으로 A 또는 B 배정
var variant = Math.random() < 0.5 ? "A" : "B";
request.cookies["ab-variant"] = { value: variant };
}
return request;
}
  • 런타임: Node.js 또는 Python
  • 실행 위치: Regional Edge Cache (12개 리전)
  • 최대 실행시간: Viewer 이벤트 5초, Origin 이벤트 30초
  • 메모리: 최대 10GB
  • 용도: 복잡한 인증/인가, 이미지 리사이징, A/B 테스트, 동적 콘텐츠 생성
// Lambda@Edge: Origin Request — 인증 헤더 추가
exports.handler = async (event) => {
const request = event.Records[0].cf.request;
// JWT 검증 (Viewer Request에서 했다면 Origin Request에서는 헤더 추가만)
request.headers["x-internal-token"] = [
{
key: "X-Internal-Token",
value: process.env.INTERNAL_TOKEN,
},
];
return request;
};
// Lambda@Edge: 이미지 리사이징 (Origin Response)
const sharp = require("sharp");
exports.handler = async (event) => {
const response = event.Records[0].cf.response;
const request = event.Records[0].cf.request;
const params = new URLSearchParams(request.querystring);
const width = parseInt(params.get("w") || "800");
if (response.status === "200" && response.body) {
const imageBuffer = Buffer.from(response.body, "base64");
const resized = await sharp(imageBuffer).resize(width).toBuffer();
response.body = resized.toString("base64");
response.bodyEncoding = "base64";
}
return response;
};

7-5. CloudFront Functions vs Lambda@Edge 선택 기준

섹션 제목: “7-5. CloudFront Functions vs Lambda@Edge 선택 기준”
항목CloudFront FunctionsLambda@Edge
실행 시간< 1ms5~30초
실행 위치모든 엣지 (450+)Regional (12)
언어JS (ES5.1)Node.js, Python
비용$0.1 / 1M 실행$0.6 / 1M 실행
사용 사례URL 재작성, 헤더 조작인증, 이미지 처리
네트워크 접근불가가능
환경변수불가가능

8-1. TTL 설정 실수 — “마이그레이션 실패”

섹션 제목: “8-1. TTL 설정 실수 — “마이그레이션 실패””

상황: 서버를 새 IP로 마이그레이션하면서 DNS TTL을 변경하지 않음.

기존 TTL: 86400초 (24시간)
새 IP로 레코드 변경 → 24시간 동안 일부 사용자는 구 IP로 접근
구 서버는 이미 종료됨 → 연결 실패

교훈: 마이그레이션 전 최소 TTL의 2배 전부터 TTL을 낮춰야 한다.

마이그레이션 계획:
T-48h: TTL을 86400 → 300으로 변경 (캐시 만료 대기)
T-0: IP 변경. 최대 5분 내 전파
T+1h: 안정 확인 후 TTL 다시 상향

8-2. Propagation Delay — “변경했는데 왜 안 바뀌지?”

섹션 제목: “8-2. Propagation Delay — “변경했는데 왜 안 바뀌지?””

상황: DNS 레코드를 변경했는데 일부 사용자에게는 아직 구버전이 보임.

원인:

  1. 이전 TTL 동안 캐시된 레코드가 만료되지 않음
  2. ISP Resolver가 TTL을 무시하고 더 오래 캐시하는 경우
  3. OS 레벨 캐시 (nscd, dnsmasq)
Terminal window
# 전파 상태 확인
dig api.example.com @8.8.8.8 # Google DNS
dig api.example.com @1.1.1.1 # Cloudflare DNS
dig api.example.com @168.126.63.1 # KT DNS
dig api.example.com +trace # 전체 경로 추적
# OS DNS 캐시 초기화 (macOS)
sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder
# Linux (systemd-resolved)
sudo systemctl restart systemd-resolved

8-3. NXDOMAIN Flood — “DNS 서버 과부하”

섹션 제목: “8-3. NXDOMAIN Flood — “DNS 서버 과부하””

상황: 봇이 존재하지 않는 서브도메인을 대량으로 쿼리하여 Authoritative 서버에 과부하.

공격: random123.example.com, random456.example.com, ...
→ 캐시 미스 → Authoritative 서버 직접 조회 → 과부하

대응:

  • Wildcard 레코드: *.example.com → 안내 페이지 IP로 NXDOMAIN 대신 응답 캐시
  • Rate Limiting: Route 53 Resolver에서 쿼리 제한
  • DNS Firewall: Route 53 Resolver DNS Firewall로 패턴 차단

8-4. Route 53 헬스 체크 오탐 (silent failure 포함)

섹션 제목: “8-4. Route 53 헬스 체크 오탐 (silent failure 포함)”

상황 A — 정상인데 Unhealthy 판정(가시 실패): 서버는 정상인데 헬스 체크가 실패하여 Failover 발생.

상황 B — 비정상인데 Healthy 표시(silent failure, 더 위험): 사용자는 503을 받고 있는데 Route 53 콘솔에는 “Healthy”. 절반 이상의 실제 장애가 이 패턴이다. /health 엔드포인트가 항상 200을 반환(예: nginx default page)하거나 DB 다운 상태도 200을 보내는 얕은 체크가 원인.

원인 분류:

  • (A) 헬스 체크 IP 대역이 서버 보안 그룹에서 차단됨
  • (A) /health 엔드포인트가 DB 연결까지 확인하다가 타임아웃
  • (B) /health가 의존성 무관하게 200 고정 응답 (nginx welcome page, 정적 파일 매핑 사고)
  • (B) 도메인 헤더 미설정으로 ALB가 default target group 응답을 반환
Route 53 헬스 체크 IP 대역: 54.183.x.x, 54.228.x.x 등
→ 보안 그룹에서 이 대역을 허용해야 함

silent failure 진단 명령 (Route 53 콘솔이 거짓말할 때):

Terminal window
# 1. 헬스 체크가 실제로 무엇을 받고 있는지 — 응답 본문까지 확인
aws route53 get-health-check-last-failure-reason \
--health-check-id abcdef12-3456-7890-abcd-ef1234567890
# 예상: 실패 시 "HTTP 503" 또는 "Resolved IP: connection refused"
# 출력이 비어 있고 status는 Healthy인데 사용자가 에러 → 다음 단계로
# 2. 헬스 체크가 실제로 어떤 URL을 치는지 외부에서 동일하게 재현
curl -v -H "Host: api.example.com" https://1.2.3.4/health
# 본문이 nginx welcome page거나 정적 HTML이면 silent failure 확정
# 3. /health/deep과 /health/shallow 응답 일치 여부
curl -s https://api.example.com/health/shallow | jq .
curl -s https://api.example.com/health/deep | jq .
# deep이 503인데 shallow가 200이면 Route 53이 shallow만 보는 구조 — 의도된 분리인지 재확인
# 4. 마지막 30분 응답 코드 분포 (ALB target group)
aws cloudwatch get-metric-statistics \
--namespace AWS/ApplicationELB \
--metric-name HTTPCode_Target_5XX_Count \
--dimensions Name=LoadBalancer,Value=app/my-alb/xxx \
--start-time $(date -u -v-30M +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 60 --statistics Sum
# 5xx > 0인데 Route 53이 Healthy → 헬스 체크 path와 사용자 경로 mismatch
// 얕은 헬스 체크 엔드포인트 (DB 의존 없음)
app.get("/health/shallow", (req, res) => {
res.status(200).json({ status: "ok", timestamp: Date.now() });
});
// 깊은 헬스 체크 (내부 모니터링용)
app.get("/health/deep", async (req, res) => {
const dbOk = await checkDatabase();
const cacheOk = await checkRedis();
const status = dbOk && cacheOk ? 200 : 503;
res.status(status).json({ db: dbOk, cache: cacheOk });
});

8-5. DNSSEC 키 롤오버 실패 — “갑자기 SERVFAIL이 쏟아진다”

섹션 제목: “8-5. DNSSEC 키 롤오버 실패 — “갑자기 SERVFAIL이 쏟아진다””

상황: ZSK 또는 KSK를 교체했는데 일부 Resolver에서 SERVFAIL이 반환되기 시작.

장애 메커니즘:

[ZSK 롤오버 실패 시나리오]
1. 새 ZSK를 생성하고 기존 ZSK를 즉시 삭제 (Pre-publish 기간 생략)
2. 캐시에 아직 구 ZSK(DNSKEY)가 남아 있는 Resolver
→ 새 RRSIG를 구 ZSK로 검증 시도 → 서명 불일치 → SERVFAIL
[KSK 롤오버 실패 시나리오]
1. 새 KSK의 DS 레코드를 부모 존(.com)에 등록
2. 부모 존 DS 전파 전에 구 KSK를 삭제
3. 구 DS ↔ 새 DNSKEY(KSK) 해시 불일치 → Chain of Trust 단절 → SERVFAIL
전파 완료까지 수 시간 ~ 최대 24시간 장애 지속 가능

진단:

Terminal window
# 검증 실패 여부 확인 (ad 플래그 없으면 DNSSEC 검증 실패)
dig +dnssec example.com A @8.8.8.8
# DS 레코드가 새 KSK 해시와 일치하는지 확인
dig DS example.com @a.gtld-servers.net
dig DNSKEY example.com @ns1.example.com
# 비검증 Resolver와 비교 (SERVFAIL vs 정상 응답이면 DNSSEC 문제)
dig example.com A @8.8.4.4 +cd # +cd: 검증 비활성화

안전한 롤오버 절차 (RFC 7583 기준):

ZSK Pre-Publish 방법:
T-0: 새 ZSK 생성, DNSKEY 존에 게시 (기존 ZSK는 유지)
T+TTL(DNSKEY): 캐시 만료 확인
T+TTL×2: 새 ZSK로 서명 전환, 구 ZSK RRSIG 제거
T+TTL×3: 구 ZSK DNSKEY 제거 (총 기간: DNSKEY TTL × 3 이상)
KSK Double-DS 방법:
T-0: 새 KSK 생성, DNSKEY 존에 추가 (구 KSK 유지)
T+1: 레지스트리에 새 DS 등록 (구 DS도 유지)
T+DS_TTL: 새 DS 전파 확인 후 구 KSK 제거
T+DS_TTL×2: 레지스트리에서 구 DS 제거

교훈: DNSSEC 키 교체는 “즉각 교체”가 아니라 중복 게시(Pre-publish) 기간이 반드시 필요. 성급한 구 키 삭제가 전체 도메인 불통의 원인이 된다.

출처: RFC 7583 — DNSSEC Key Rollover Timing Considerations, Why Does DNSSEC Cause SERVFAIL — NameSilo Blog


8-5b. CDN 오리진 전면 장애 — stale 서빙으로 버티기

섹션 제목: “8-5b. CDN 오리진 전면 장애 — stale 서빙으로 버티기”

상황: 오리진 서버(ALB 또는 EC2)가 전면 다운. CloudFront·Cloudflare 등 CDN이 어떻게 동작하는가?

기본 동작 (설정 없을 때):

오리진 5xx 응답 또는 연결 실패
→ CDN 기본값: 에러 응답을 사용자에게 그대로 전달 (503/502)
→ 캐시된 콘텐츠가 있어도 만료된 경우 서빙 안 함

stale-if-error 설정으로 가용성 확보:

Cache-Control: max-age=300, stale-if-error=86400
해석:
→ 300초: 정상 캐시 서빙 (fresh)
→ 오리진 장애 발생 시: 만료 후 최대 86400초(24시간)까지 구버전 서빙
→ 사용자: 에러 대신 구버전 응답을 받음 (정적 페이지, 상품 목록 등 유용)

CloudFront 설정:

{
"DefaultCacheBehavior": {
"CachePolicyId": "...",
"OriginRequestPolicyId": "..."
},
"CustomErrorResponses": [
{
"ErrorCode": 502,
"ResponsePagePath": "/maintenance.html",
"ResponseCode": "200",
"ErrorCachingMinTTL": 30
},
{
"ErrorCode": 503,
"ResponsePagePath": "/maintenance.html",
"ResponseCode": "200",
"ErrorCachingMinTTL": 30
}
]
}

Cloudflare 설정 (Always Online):

Cloudflare > 도메인 > Caching > Always Online: ON
→ 오리진 전면 장애 시 Cloudflare가 마지막으로 크롤한 캐시 버전 서빙
→ 기본값은 OFF. 활성화 시에도 동적 페이지(API 응답)는 서빙 불가

주의 사항:

stale 서빙이 적합한 콘텐츠:
✓ 정적 HTML 페이지, 상품 목록, 블로그 포스트
✓ 공개 API 응답 (실시간성 낮은 데이터)
stale 서빙이 위험한 콘텐츠:
✗ 결제/주문 API (구버전 가격·재고 표시)
✗ 사용자 인증 응답 (권한 상태 불일치)
✗ 실시간 재고·환율 데이터

출처: Serve stale content — Google Cloud CDN Docs, Serving stale content — Fastly Docs


상황: VPC 내부에서는 Private Hosted Zone, 외부에서는 Public Hosted Zone을 사용하는데 레코드가 불일치.

Public: api.example.com → 52.1.2.3 (ALB 공개 IP)
Private: api.example.com → 10.0.1.50 (내부 ALB DNS)
Lambda 함수가 내부에서 api.example.com 호출
→ Private Zone 적용 → 10.0.1.50 → 정상
→ Private Zone 없는 환경에서 → 52.1.2.3 → NAT 통해 외부로 나갔다 돌아옴 → 비용 + 지연

8.5 트러블슈팅 (CloudFront & Route53)

섹션 제목: “8.5 트러블슈팅 (CloudFront & Route53)”

🔧 배포 후에도 구버전 콘텐츠가 보임 — CloudFront 캐시 미제거

섹션 제목: “🔧 배포 후에도 구버전 콘텐츠가 보임 — CloudFront 캐시 미제거”

증상:

Terminal window
# 배포 후 브라우저에서 구버전 JS 파일이 계속 반환됨
curl -I https://cdn.example.com/app.js
# Cache-Control: max-age=31536000
# Age: 3600 ← 1시간 전 캐시된 파일이 반환됨
# x-cache: Hit from cloudfront

원인: app.js 파일명에 콘텐츠 해시가 없어 배포해도 파일명이 동일하고, CloudFront 캐시가 만료되지 않았다. 또는 캐시 무효화(Invalidation)를 /app.js로 했지만 실제 CloudFront에 캐시된 경로가 /app.js?v=1 처럼 쿼리스트링을 포함한 경우 무효화가 적용되지 않는다.

해결:

Terminal window
# 1. 즉시 무효화 (와일드카드 사용)
aws cloudfront create-invalidation \
--distribution-id E1234567890 \
--paths "/*"
# 응답 예시:
# {
# "Invalidation": {
# "Id": "I3EINBK10X87YF",
# "Status": "InProgress",
# "CreateTime": "2025-01-15T12:00:00Z",
# "InvalidationBatch": {"Paths": {"Quantity": 1, "Items": ["/*"]}}
# }
# }
# 2. 무효화 완료 대기 확인
aws cloudfront wait invalidation-completed \
--distribution-id E1234567890 \
--id I3EINBK10X87YF
echo "Invalidation complete"
# 3. 근본 해결: 콘텐츠 해시 기반 파일명 사용
# vite.config.ts
# output: { entryFileNames: 'assets/[name].[hash].js' }
# → 파일 변경 시 자동으로 다른 URL → 무효화 불필요

🔧 Route 53 Failover가 작동하지 않음 — 헬스 체크 IP 차단

섹션 제목: “🔧 Route 53 Failover가 작동하지 않음 — 헬스 체크 IP 차단”

증상:

서버가 실제로 503인데 Route 53 헬스 체크 상태가 "Healthy"로 표시됨
→ Failover 라우팅이 발동하지 않아 사용자에게 에러 노출

원인: Route 53 헬스 체크는 특정 IP 대역(54.183.0.0/16, 54.228.0.0/16 등)에서 실행된다. 이 IP 대역이 EC2 보안 그룹에서 차단되어 있으면 헬스 체크 요청이 서버에 도달하지 못하고, Route 53은 타임아웃을 “Healthy”로 오해하거나 단순히 응답을 받지 못해 Unhealthy 판정이 지연된다.

해결:

Terminal window
# 1. Route 53 헬스 체크 IP 대역 확인
curl -s https://ip-ranges.amazonaws.com/ip-ranges.json \
| jq '.prefixes[] | select(.service == "ROUTE53_HEALTHCHECKS") | .ip_prefix'
# 출력:
# "54.183.255.128/26"
# "54.228.16.0/26"
# "54.232.40.64/26"
# ... (15개 내외)
# 2. 보안 그룹에 Route 53 IP 허용 추가
aws ec2 authorize-security-group-ingress \
--group-id sg-xxxxxx \
--protocol tcp \
--port 443 \
--cidr 54.183.255.128/26
# 3. 헬스 체크 엔드포인트를 경량으로 분리 (DB 의존 없음)
# GET /health/shallow → 200 OK {"status": "ok"} ← Route 53용
# GET /health/deep → DB/Redis 확인 ← 내부 모니터링용
# 4. 헬스 체크 상태 확인
aws route53 get-health-check-status \
--health-check-id abcdef12-3456-7890-abcd-ef1234567890 \
--query 'HealthCheckObservations[*].{Region:Region,Status:StatusReport.Status}'

🔧 CloudFront 배포 후 SPA에서 새로고침 시 403/404

섹션 제목: “🔧 CloudFront 배포 후 SPA에서 새로고침 시 403/404”

증상:

https://app.example.com/users/123 에서 새로고침 시:
AccessDenied - This XML file does not appear to have any style information associated with it.
# 또는:
The specified key does not exist.

원인: SPA는 클라이언트 라우팅을 사용하므로 /users/123 경로에 해당하는 파일이 S3에 없다. CloudFront가 S3에서 해당 파일을 찾지 못하면 S3가 403(OAC 설정 시) 또는 404를 반환한다.

해결:

Terminal window
# CloudFront 에러 응답 설정 (AWS CLI)
aws cloudfront update-distribution \
--id E1234567890 \
--distribution-config '{
"CustomErrorResponses": {
"Quantity": 2,
"Items": [
{
"ErrorCode": 403,
"ResponsePagePath": "/index.html",
"ResponseCode": "200",
"ErrorCachingMinTTL": 0
},
{
"ErrorCode": 404,
"ResponsePagePath": "/index.html",
"ResponseCode": "200",
"ErrorCachingMinTTL": 0
}
]
}
}'
# 또는 CloudFront Function으로 처리 (더 효율적)
# Viewer Request 이벤트:
# 파일 확장자 없는 경로 → /index.html로 uri 변경
# → S3에 도달하기 전에 처리되어 에러 자체가 발생하지 않음

9. 프론트엔드 → 플랫폼 브릿지: SPA/SSR 배포와 CDN

섹션 제목: “9. 프론트엔드 → 플랫폼 브릿지: SPA/SSR 배포와 CDN”
빌드 산출물:
dist/
index.html ← 항상 최신 (no-cache)
assets/
app.abc123.js ← 콘텐츠 해시 포함 (1년 캐시)
style.def456.css
logo.ghi789.png

S3 + CloudFront 배포 파이프라인:

Terminal window
# 1. 빌드
npm run build
# 2. S3 동기화 (해시 파일은 캐시 1년)
aws s3 sync dist/ s3://my-bucket/ \
--exclude "index.html" \
--cache-control "max-age=31536000,public,immutable"
# 3. index.html은 별도로 no-cache
aws s3 cp dist/index.html s3://my-bucket/index.html \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "text/html"
# 4. CloudFront 캐시에서 index.html 무효화
aws cloudfront create-invalidation \
--distribution-id E1234567890 \
--paths "/index.html"

9-2. SPA에서 클라이언트 라우팅 처리

섹션 제목: “9-2. SPA에서 클라이언트 라우팅 처리”

React Router 등 클라이언트 라우팅을 사용하면 /users/123 같은 경로로 직접 접근 시 S3가 404를 반환한다.

CloudFront 에러 페이지 설정:

{
"CustomErrorResponses": [
{
"ErrorCode": 403,
"ResponsePagePath": "/index.html",
"ResponseCode": "200",
"ErrorCachingMinTTL": 0
},
{
"ErrorCode": 404,
"ResponsePagePath": "/index.html",
"ResponseCode": "200",
"ErrorCachingMinTTL": 0
}
]
}

또는 CloudFront Function으로 처리:

function handler(event) {
var request = event.request;
var uri = request.uri;
// 정적 파일 확장자가 있으면 그대로
if (uri.match(/\.(js|css|png|jpg|svg|ico|woff2|json|xml|txt)$/)) {
return request;
}
// 그 외는 index.html로
request.uri = "/index.html";
return request;
}
Next.js SSR 아키텍처:
CloudFront
├── /static/* → S3 (정적 자산, 캐시 1년)
├── /_next/static/* → S3 (Next.js 빌드 산출물)
└── /* → ALB → ECS/EC2 (Next.js 서버, SSR)

Cache-Control 전략:

// Next.js pages에서 캐시 제어
export async function getServerSideProps(context) {
context.res.setHeader(
"Cache-Control",
"public, s-maxage=60, stale-while-revalidate=300",
);
// s-maxage: CDN 캐시 TTL (60초)
// stale-while-revalidate: 최대 300초까지 stale 허용
return { props: { data: await fetchData() } };
}

브라우저가 실제 요청 전에 미리 DNS 조회와 TCP/TLS 연결을 수행하도록 힌트를 준다.

<!-- DNS 조회만 미리 수행 (외부 도메인, 실제 연결 여부 불확실) -->
<link rel="dns-prefetch" href="https://cdn.example.com" />
<link rel="dns-prefetch" href="https://analytics.example.com" />
<!-- DNS + TCP + TLS 핸드셰이크까지 미리 수행 (곧 사용할 것이 확실한 경우) -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />

언제 사용하는가:

dns-prefetch:
- 외부 CDN 도메인
- 분석 스크립트 도메인
- A/B 테스트 서비스
- 연결 여부가 불확실한 외부 서비스
preconnect:
- Google Fonts (반드시 사용)
- 메인 API 서버
- 이미지 CDN (첫 화면에 이미지가 많을 때)
- 비디오 스트리밍 서버

주의: preconnect는 연결 비용이 있다. 실제로 사용하지 않을 도메인에 남용하면 오히려 리소스 낭비.

<!-- 권장: 핵심 리소스에만 preconnect, 나머지는 dns-prefetch -->
<link rel="preconnect" href="https://api.example.com" crossorigin />
<link rel="dns-prefetch" href="https://cdn.analytics.com" />
// Navigation Timing API로 DNS 조회 시간 측정
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.entryType === "resource") {
const dnsTime = entry.domainLookupEnd - entry.domainLookupStart;
const connectTime = entry.connectEnd - entry.connectStart;
console.log(`${entry.name}: DNS=${dnsTime}ms, Connect=${connectTime}ms`);
}
});
});
observer.observe({ entryTypes: ["resource", "navigation"] });

□ 마이그레이션 시 TTL을 사전에 낮췄는가? (최소 현재 TTL만큼 전)
□ CloudFront 배포 시 index.html에 no-cache 설정했는가?
□ 정적 자산에 콘텐츠 해시가 포함되어 있는가?
□ S3 버킷이 CloudFront 외 직접 접근을 차단하고 있는가? (OAC 설정)
□ HTTPS 강제 설정이 있는가? (HTTP → HTTPS 리다이렉트)
□ Cache Hit Ratio가 90% 이상인가? (CloudFront 메트릭 확인)
□ Route 53 헬스 체크 IP 대역이 보안 그룹에서 허용되는가?
□ TTL 만료 후 오리진 트래픽 급증(Dog Pile Effect)에 대비했는가?
□ CloudFront 에러율(5xx) 알람이 설정되어 있는가?
□ DNS Failover 테스트를 주기적으로 수행하는가?
□ DNSSEC가 활성화되어 있는가?
□ DNS over HTTPS/TLS를 지원하는 Resolver를 사용하는가?
□ Route 53 Resolver DNS Firewall로 알려진 악성 도메인을 차단하는가?
□ CloudFront에 WAF(Web Application Firewall)가 연결되어 있는가?
□ 보안 헤더(HSTS, CSP 등)를 Response Headers Policy로 추가했는가?

주제핵심 포인트
DNSSECDNS 응답에 디지털 서명. DS→DNSKEY→RRSIG의 체인 오브 트러스트
DoH/DoTDNS 평문 노출 해소. DoH는 443 포트로 차단 어려움
Route 53단순/가중치/지연시간/지리/Failover 라우팅 + 헬스 체크
CDN 계층Edge → Regional Edge → Origin 3계층 캐시
CloudFrontDistribution > Origin + Behavior + Cache Policy 구조
캐시 무효화Versioned URL + index.html no-cache 조합이 베스트 프랙티스
엣지 컴퓨팅단순 변환은 CF Functions, 복잡한 로직은 Lambda@Edge
DNS 장애TTL 사전 조정, 전파 확인 도구, 헬스 체크 IP 허용
SPA/SSRindex.html no-cache, 정적 자산 해시 1년, CloudFront Function으로 SPA 라우팅
dns-prefetch외부 도메인 사전 조회, preconnect는 확실히 사용할 도메인에만


Terminal window
# 1. DNSSEC 검증 여부 확인
dig +dnssec cloudflare.com A

예상 출력:

;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
;; ANSWER SECTION:
cloudflare.com. 265 IN A 104.16.132.229
cloudflare.com. 265 IN RRSIG A 13 2 300 (
20250201000000 20250115000000 34505 cloudflare.com.
abc123def456...== )
# "ad" 플래그 = Authenticated Data → DNSSEC 검증 성공
Terminal window
# 2. DNS 전파 상태 확인 (여러 Resolver 비교)
for dns in 8.8.8.8 1.1.1.1 168.126.63.1; do
echo -n "DNS $dns: "
dig +short api.example.com @$dns
done

예상 출력:

DNS 8.8.8.8: 52.1.2.3
DNS 1.1.1.1: 52.1.2.3
DNS 168.126.63.1: 52.1.2.3 ← 모두 동일하면 전파 완료
Terminal window
# 3. CloudFront 캐시 히트 여부 확인
curl -sI https://cdn.example.com/app.abc123.js | grep -E "x-cache|Age|Cache"

예상 출력 (캐시 히트):

x-cache: Hit from cloudfront
Age: 7200
Cache-Control: max-age=31536000, public, immutable

예상 출력 (캐시 미스):

x-cache: Miss from cloudfront
Age: 0
Cache-Control: max-age=31536000, public, immutable
Terminal window
# 4. Route 53 레코드 조회 및 TTL 확인
aws route53 list-resource-record-sets \
--hosted-zone-id Z1234567890 \
--query 'ResourceRecordSets[?Name==`api.example.com.`]' \
--output table

예상 출력:

-------------------------------------------------
| ListResourceRecordSets |
+----+------+----------------------+-----+------+
|Name|Type |Value |TTL |Weight|
+----+------+----------------------+-----+------+
|api.|A |52.1.2.3 |60 |90 |
|api.|A |52.4.5.6 |60 |10 |
+----+------+----------------------+-----+------+
Terminal window
# 5. CloudFront 무효화 상태 확인
aws cloudfront list-invalidations \
--distribution-id E1234567890 \
--query 'InvalidationList.Items[0:3].{Id:Id,Status:Status,Created:CreateTime}'

예상 출력:

[
{"Id": "I3ABC123", "Status": "Completed", "Created": "2025-01-15T10:00:00Z"},
{"Id": "I3DEF456", "Status": "InProgress", "Created": "2025-01-15T12:00:00Z"}
]