요약

runmile.co.kr 접속 시 static.runmile.co.kr JS 번들에서 대량 CORS 에러 발생. CloudFront 캐시 키에 Origin이 없어 staging의 ACAO 헤더가 운영 요청에 재사용된 것이 원인이었다.

환경: AWS CloudFront (Managed-CachingOptimized + Managed-CORS-With-Preflight), S3, Astro 날짜: 2026-02-28 소요 시간: 캐시 무효화로 20분 내 복구, 근본 원인 조치까지 약 30분

상황

runmile.co.kr 운영 환경에서 페이지 로드 시 _astro/ 경로의 JS 번들이 대량으로 차단된다는 리포트가 들어왔다. 브라우저 콘솔에 net::ERR_FAILED가 쏟아지고 있었고, 페이지 기능이 정상 동작하지 않는 상태였다.

스크린샷 2026 02 27 오전 10.31.34 스크린샷 2026 02 27 오전 10.34.21

초기에 runmile web 상용 버킷 권한을 확인해달라는 요청이 있었다. 하지만 내 생각에 상용 버킷 권한이 이상했으면, 진작부터 똑같은 에러가 발생했을 것이었다. 또한, 버킷 권한 IaC로 관리하고 있기 때문에 테섭/스테이징 정책과 동일하다. 즉 버킷 권한이 정말 문제였다면 테섭/스테이징에서도 똑같은 이슈가 발생했어야 할거라고 생각했다.

그래서 직접 runmile.co.kr 에 접속해 네트워크를 분석해보았다.

관찰한 사실

에러가 발생한 응답을 확인했더니 이상한 값이 있었다.

x-cache: Hit from cloudfront
Access-Control-Allow-Origin: https://staging.runmile.co.kr

운영(runmile.co.kr) 요청인데, ACAO에 staging.runmile.co.kr이 찍혀 있었다. 캐시 HIT 상태에서. 스크린샷 2026 02 27 오전 10.43.00 1

이 부분에서 직감적으로 캐시가 뭔가 잘못됐다는 걸 느끼긴 했는데 근본 원인을 찾고 싶었다.

당시 배포 E21DGQ46A3LF89 (static.runmile.co.kr)에 적용된 정책을 확인해보면 다음과 같았다.

  • CachePolicy: Managed-CachingOptimizedOrigin 헤더를 cache key에 포함하지 않음
  • ResponseHeadersPolicy: Managed-CORS-With-PreflightOriginOverride=false (S3 응답의 CORS 헤더를 그대로 통과)

그리고 staging 승격 정책을 지키기 위해서 staging workflow가 운영 static 자산 경로를 공유하고 있는 상황이다.

STATIC_BUCKET: runmile-web-production-static-assets
STATIC_ASSET_PREFIX: https://static.runmile.co.kr

가설과 검증 과정 (Investigation)

가설 1: S3 CORS 설정 문제

ACAO가 잘못 돌아오는 거니 S3 버킷의 CORS 설정 자체가 틀렸을 것이라 처음 의심했다.

S3 버킷 CORS 설정 확인 → runmile.co.krstaging.runmile.co.kr 모두 AllowedOrigins에 포함되어 있었다.

결과: 기각. S3는 요청 Origin에 맞는 ACAO를 올바르게 반환하고 있었다. 또한 만약 실제로 CORS가 등록된 게 아니라서 문제가 발생한 것이었다면 배포하기 전 부터 이 에러가 발생했어야 한다.

가설 2: ResponseHeadersPolicy OriginOverride=false 오동작

Managed-CORS-With-PreflightOriginOverride=false가 예상과 다르게 동작하는 게 아닐까.

OriginOverride=false의 의미를 확인했다. CloudFront가 원본(S3)의 ACAO를 덮어쓰지 않고, S3가 반환한 값을 그대로 통과시킨다는 의미다.

예를 들어 S3가 ACAO: https://runmile.co.kr를 반환하면:

  • OriginOverride=false → CloudFront가 S3 값을 그대로 통과시켜 최종 응답도 ACAO: https://runmile.co.kr
  • OriginOverride=true → CloudFront가 S3 값을 무시하고 자신이 설정한 값으로 덮어씀

false일 때는 S3가 ACAO를 올바르게 돌려줘야 한다는 전제가 있다.

→ 그렇다면 S3는 Origin별로 올바른 ACAO를 반환하고 있는데, 왜 캐시 HIT 상황에서 엉뚱한 ACAO가 나올까?

결과: ResponseHeadersPolicy 자체는 의도대로 동작하고 있었다. 다만 여기서 단서가 보였다.

가설 1에서 S3는 정상이고, 가설 2에서 ResponseHeadersPolicy도 정상이라는 게 확인됐다. 두 구성요소가 모두 정상이라면, 잘못된 ACAO가 나오는 경우는 하나뿐이다. S3에 요청 자체가 가지 않은 것. 따라서 애초에 ResponseHeadersPolicy가 적용되지 않는 상황이라는 거다. 생각해보면 이건 x-cache: Hit 에서 이미 증명된 사실이다.

전환점

캐시 HIT 응답에는 원본(S3)이 개입하지 않는다. CloudFront는 캐시된 응답 객체를 그대로 반환할 뿐이고, S3에 요청 자체를 하지 않는다. 즉 OriginOverride=false도, S3의 올바른 ACAO 반환도, 캐시 HIT에서는 아무 의미가 없다.

캐시 키에 Origin이 없다면, runmile.co.kr 요청과 staging.runmile.co.kr 요청은 CloudFront 관점에서 동일한 객체다. staging이 먼저 캐시를 채우면, ACAO: https://staging.runmile.co.kr가 담긴 응답이 운영 요청에도 그대로 반환된다.

근본 원인

CloudFront 캐시 키에 Origin이 없어, runmile과 staging이 같은 캐시 객체를 공유했다. staging이 먼저 캐시를 채운 ACAO가 운영 요청에 재사용되면서 CORS mismatch가 발생했다.

Managed-CachingOptimizedOrigin을 cache key에 포함하지 않는다. CORS에 의존하는 자산을 이 정책으로 캐싱하면서, staging/운영이 같은 CDN 배포와 같은 S3 버킷을 공유한 것이 조합되어 이 문제가 만들어졌다.

전체 문제 상황을 다이어그램으로 나타내면 아래와 같다. 핵심은 staging 서버에서 이미 그 객체를 요청했기 때문에 CF에는 staging으로 등록되어 있을 거라는 것이다.

sequenceDiagram
    autonumber
    participant U as User Browser (runmile.co.kr)
    participant CF as CloudFront (static.runmile.co.kr)
    participant S3 as S3 Origin

    Note over CF: CachePolicy: Origin 미포함 (CachingOptimized)
    Note over CF: ResponseHeadersPolicy: OriginOverride=false

    U->>CF: GET /_astro/client.js<br/>Origin: https://runmile.co.kr
    CF-->>U: Cache HIT (staging 요청이 먼저 채운 객체)<br/>ACAO: https://staging.runmile.co.kr
    U-->>U: CORS mismatch → JS 차단 net::ERR_FAILED

해결 방법

즉시 조치: 캐시 무효화

운영에서 1회 요청이 들어오면 S3가 runmile.co.kr ACAO를 반환하고 그것이 캐시될 것이므로, CDN 캐시를 무효화하면 복구될 거라고 판단했다. CloudFront 캐시 무효화 후 CORS 에러가 해소됨을 확인했다.

근본 수정: 캐시 정책 교체

캐시 무효화로 인해 상용 환경이 정상 동작한다는 것은, 곧 반대로 staging 서버에서 에러가 발생하고 있을 거라고 예상할 수 있다. 접속해보니 마찬가지로 똑같은 에러가 발생하고 있었다.

현 상황을 요약하자면, 같은 버킷을 스테이징&상용이 바라보고 있어 Origin별로 다른 CORS 헤더가 생성되는데, CloudFront 캐시 키에 Origin이 없어 캐시 오염이 생기고 있다고 볼 수 있다.

예를 들어 나중에 운 없게 staging 에서 한번 요청해서 CDN에 캐시가 staging으로 들어가는 순간 상용 환경에는 똑같은 에러가 재현된다.

따라서 근본적으로 문제를 해결하려면, 스테이징과 프로덕션의 요청을 각각 분리해야한다. 즉 각 요청을 CloudFront가 다르게 인식하게 해야 한다. 스테이징과 프로덕션의 차이는 곧 Origin (staging.runmile.co.kr vs runmile.co.kr) 이므로, 이 값을 인식하게끔 해줘야 한다.

CF입장에서 설명하자면 같은 객체에 대한 요청이더라도 Origin이 다르면 다른 요청으로 인식할 수 있게 해야 한다. 따라서 CF CachePolicy를 수정해준다.

# Before
CachePolicy: Managed-CachingOptimized
# HeadersConfig에 Origin 미포함 → Origin별 캐시 분리 없음
 
# After
CachePolicy: runmile_static_cache_prod
# HeadersConfig:
#   HeaderBehavior: whitelist
#   Headers: [Origin]
# → Origin 헤더를 cache key에 포함, Origin별 캐시 객체 분리

배포 E21DGQ46A3LF89CachePolicyIdrunmile_static_cache_prod로 변경.

교차 검증:

요청 Origin응답 ACAO
https://runmile.co.krhttps://runmile.co.kr
https://staging.runmile.co.krhttps://staging.runmile.co.kr

Playwright로 브라우저 콘솔 검증해보니 runmile/staging 모두 CORS 에러 0건임을 확인했다.

캐시 키 관점 로직을 전/후 비교한 다이어그램이다.

flowchart TD
    A[Request to static.runmile.co.kr] --> B{Cache key에 Origin 포함?}
    B -- 아니오 --> C[runmile/staging 요청이 같은 캐시 객체 공유]
    C --> D[먼저 캐시된 ACAO 값 재사용]
    D --> E{요청 Origin == ACAO ?}
    E -- 아니오 --> F[CORS 차단 발생]
    E -- 예 --> G[정상]
    B -- 예 --> H[Origin별 캐시 분리]
    H --> I[Origin별 올바른 ACAO 반환]
    I --> G

교훈

  • CORS가 Origin에 의존한다면, cache key에도 Origin을 포함해야 한다. Managed-CachingOptimized는 성능 최적화 목적의 정책이며 CORS-aware하지 않다. CORS를 사용하는 자산에 Origin 없이 이 정책을 쓰는 건 위험할 수 있다. 특히 이번 케이스의 경우 같은 정적 자산 버킷을 staging, production 두 환경이 같이 쓰기 때문에 origin 구분이 매우 중요했지만 놓친 점이었다.
  • OriginOverride=false는 캐시 미스 시에만 의미가 있다. 캐시 HIT에서는 S3에 요청 자체를 하지 않으므로, S3의 올바른 ACAO 반환도 일어나지 않는다. CORS 디버깅 시 x-cache 헤더를 가장 먼저 확인해야 한다.
  • 환경 간 공유 자산은 잠재적 간섭 지점이다. staging과 운영이 같은 CDN 배포를 쓰면, 한쪽의 요청이 다른 쪽의 캐시 상태에 영향을 줄 수 있다. 장기적으로는 분리하면 좋을 것 같다.

후속 조치

staging/prod static 도메인·버킷 분리를 검토하여 환경 간 간섭 구조 자체를 제거하는 것이 가장 좋은 방법이다. 다만, 상용 배포 정책 상 스테이징의 docker 이미지를 그대로 사용해야 하는데 Astro를 쓰는 프론트엔드 특성상 빌드 시 생기는 해시값 + CDN 주소 값 자체가 이미지 안에 들어가고 있어 스테이징/상용 간 다른 버킷을 사용하기가 어려운 상황이었다. 따라서 현재는 캐시 정책 교체로 대응하고, 버킷 분리는 배포 구조 개선 시 함께 검토하는 장기 과제로 남긴다.

참고 자료