콘텐츠로 이동

DNS 심화 & CDN

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


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
}'

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로 캐시되는 것 방지

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로 패턴 차단

상황: 서버는 정상인데 헬스 체크가 실패하여 Failover 발생.

원인:

  • 헬스 체크 IP 대역이 서버 보안 그룹에서 차단됨
  • /health 엔드포인트가 DB 연결까지 확인하다가 타임아웃
Route 53 헬스 체크 IP 대역: 54.183.x.x, 54.228.x.x 등
→ 보안 그룹에서 이 대역을 허용해야 함
// 얕은 헬스 체크 엔드포인트 (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 });
});

상황: 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"}
]