CORS 대량 장애 - CloudFront 캐시 키 설정 문제
운영 환경 JS 번들에서 CORS 에러가 쏟아지고 있었다. 응답 헤더를 열어보니 Access-Control-Allow-Origin: https://staging.example.com이 찍혀 있었다. 운영 요청에 스테이징 ACAO가 붙어 나온 것이다. S3 CORS 설정도, CloudFront ResponseHeadersPolicy도 정상이었다. 문제는 캐시 키에 Origin이 없어 staging이 먼저 채운 캐시 객체가 운영 요청에 그대로 반환된 것이었다. 캐시 무효화로 20분 이내에 복구하고, 캐시 정책 교체로 재발을 차단했다.
환경: AWS CloudFront (Managed-CachingOptimized + Managed-CORS-With-Preflight), S3, Astro
운영 환경에 스테이징 ACAO가 찍혀 있었다
운영(example.com)에서 페이지 로드 시 _astro/ 경로 JS 번들이 대량 차단된다는 리포트가 들어왔다. 브라우저 콘솔에 net::ERR_FAILED가 쏟아지고 있었다. 초기에는 S3 버킷 권한 문제라는 의견이 있었지만, 버킷은 IaC로 관리 중이고 스테이징과 운영이 동일한 정책을 공유한다. 버킷 권한이 원인이라면 스테이징에서도 같은 에러가 났어야 한다.
직접 네트워크 탭을 열어 에러 응답 헤더를 확인했다.
x-cache: Hit from cloudfront
Access-Control-Allow-Origin: https://staging.example.com
두 가지가 눈에 들어왔다. 첫째, x-cache: Hit — S3에 요청이 가지 않고 CloudFront 캐시에서 바로 반환된 것이다. 둘째, ACAO가 staging.example.com — 운영 요청인데 스테이징 origin이 허용 값으로 박혀 있다. S3가 틀린 값을 반환한 게 아니라, 스테이징 요청이 채운 캐시 객체가 그대로 운영 요청에 반환된 것이다.
캐시 키에 Origin이 없으면 staging과 production은 같은 객체다
당시 static.example.com 배포에 적용된 정책은 이랬다.
| 정책 | 설정 | 의미 |
|---|---|---|
| CachePolicy | Managed-CachingOptimized | Origin 헤더를 cache key에 포함하지 않음 |
| ResponseHeadersPolicy | Managed-CORS-With-Preflight (OriginOverride=false) | S3 응답의 ACAO를 그대로 통과 |
그리고 staging은 운영 정적 자산 경로를 공유하고 있었다.
STATIC_BUCKET: prod-static-assets
STATIC_ASSET_PREFIX: https://static.example.com
이 두 조건이 합쳐지면 어떤 일이 벌어지는지 단계별로 따라가면 명확해진다.
S3는 요청 Origin에 맞는 ACAO를 올바르게 반환한다. example.com 요청에는 ACAO: https://example.com, staging.example.com 요청에는 ACAO: https://staging.example.com을 돌려준다. OriginOverride=false이므로 CloudFront는 이 값을 그대로 통과시킨다. 여기까지는 정상이다.
문제는 캐시 HIT에서 발생한다. 캐시 HIT 응답에는 S3가 개입하지 않는다. CloudFront는 캐시된 응답 객체를 반환할 뿐이고, S3에 요청 자체를 하지 않는다. 즉 OriginOverride=false도, S3의 올바른 ACAO 반환도, 캐시 HIT에서는 작동하지 않는다.
Managed-CachingOptimized 캐시 키에는 Origin이 없다. CloudFront 관점에서 https://example.com의 요청과 https://staging.example.com의 요청은 같은 객체다. staging이 먼저 캐시를 채우면 ACAO: https://staging.example.com이 담긴 응답이 운영 요청에도 그대로 반환된다.
sequenceDiagram autonumber participant U as User (example.com) participant CF as CloudFront (static.example.com) participant S3 as S3 Origin Note over CF: CachePolicy: Origin 미포함 Note over CF: ResponseHeadersPolicy: OriginOverride=false U->>CF: GET /_astro/client.js<br/>Origin: https://example.com CF-->>U: Cache HIT (staging이 먼저 채운 객체)<br/>ACAO: https://staging.example.com U-->>U: CORS mismatch → JS 차단 (net::ERR_FAILED)
캐시 무효화로 즉시 복구, 캐시 정책 교체로 근본 수정
즉시 조치: 캐시 무효화
운영에서 1회 요청이 들어오면 S3가 example.com ACAO를 반환하고 그 값이 캐시된다. 따라서 현재 캐시를 무효화하면 다음 요청부터 올바른 ACAO가 채워질 것이라 판단했다.
CloudFront 캐시 무효화 후 CORS 에러가 해소됨을 확인했다. 복구까지 소요 시간: 20분 이내.
근본 수정: Origin을 캐시 키에 포함
캐시 무효화는 증상 해소다. 재발 조건은 그대로 남아 있다. 실제로 무효화 직후 확인하니 staging에서 동일한 에러가 발생하고 있었다. staging에서 한 번 요청이 들어와 캐시가 staging 값으로 채워지는 순간 운영 에러가 재현된다.
근본 원인은 Origin이 다른 두 환경의 요청을 CloudFront가 동일한 객체로 취급한다는 것이다. 해결책은 Origin을 캐시 키에 포함시켜 Origin별로 캐시 객체를 분리하는 것이다.
Managed-CachingOptimized 대신 Origin을 캐시 키에 포함한 커스텀 정책으로 교체했다.
# Before
CachePolicy: Managed-CachingOptimized
# HeadersConfig에 Origin 미포함 → Origin별 캐시 분리 없음
# After
CachePolicy: custom_static_cache
# HeadersConfig:
# HeaderBehavior: whitelist
# Headers: [Origin]
# → Origin 헤더를 cache key에 포함, Origin별 캐시 객체 분리교체 후 Origin별 응답을 교차 검증했다.
| 요청 Origin | 응답 ACAO |
|---|---|
https://example.com | https://example.com |
https://staging.example.com | https://staging.example.com |
Playwright로 브라우저 콘솔을 검증하니 운영/staging 모두 CORS 에러 0건이었다.
flowchart TD A[static.example.com 요청] --> B{캐시 키에 Origin 포함?} B -- 아니오 --> C[운영/staging 동일 캐시 공유] C --> D[먼저 캐시된 ACAO 재사용] D --> E{요청 Origin == ACAO?} E -- 아니오 --> F[CORS 차단] E -- 예 --> G[정상] B -- 예 --> H[Origin별 캐시 분리] H --> I[Origin별 올바른 ACAO 반환] I --> G
이 장애가 남긴 것
CORS가 Origin에 의존한다면, 캐시 키에도 Origin을 포함해야 한다. Managed-CachingOptimized는 성능 최적화 목적의 정책이며 CORS를 인식하지 않는다. staging과 운영이 같은 CDN 배포와 같은 버킷을 공유하는 구조에서 이 정책을 쓰면, 어느 환경이 먼저 캐시를 채우는지에 따라 다른 환경이 CORS 에러를 겪는 비결정적(non-deterministic) 장애가 만들어진다.
OriginOverride=false는 캐시 미스 시에만 의미가 있다. 캐시 HIT에서는 S3에 요청 자체를 하지 않으므로, S3의 올바른 ACAO 반환이나 ResponseHeadersPolicy가 개입할 여지가 없다. CORS 디버깅 시 x-cache 헤더를 가장 먼저 확인해야 한다. HIT라면 S3와 무관하다.
환경 간 공유 자산은 잠재적 간섭 지점이다. 한쪽 환경의 요청이 다른 쪽의 캐시 상태에 영향을 줄 수 있다. 그리고 이런 간섭은 재현이 어렵다 — 어떤 환경이 먼저 요청했느냐에 따라 결과가 달라지기 때문이다.
앞으로의 방향
현재는 캐시 정책 교체로 캐시 오염을 방지하고 있지만, 구조적으로는 staging과 운영이 같은 버킷을 바라보는 것 자체가 간섭의 근원이다. 장기 해결책은 버킷 분리다.
다만 Astro 특성상 빌드 시 CDN 주소가 이미지 내부에 포함된다. staging 이미지를 운영에 그대로 승격하는 배포 정책을 유지하면서 버킷을 분리하려면, 빌드 구조 자체를 변경해야 한다. 이 부분은 배포 파이프라인 개선과 함께 검토하는 장기 과제로 남겨둔다.
참고 자료
정보 공백 요약
| # | 필요한 정보 | 이유 | 위치 |
|---|---|---|---|
| 1 | 브라우저 콘솔 스크린샷 (net::ERR_FAILED 에러 목록) | “대량 CORS 에러” 주장의 시각적 증거 | 오프닝 섹션 |
| 2 | 캐시 정책 교체 전후 CORS 에러 건수 비교 | ”교체 후 재발 없음”의 정량적 근거 | 해결 방법 섹션 |