Caching & CDNs with micro-frontends

Alex O'Callaghan

마이크로 프론트엔드에서의 캐싱과 CDN

원문은 Alex O'Callaghan님이 에 게재했습니다. 이 블로그 구독하기

마이크로 프론트엔드 아키텍처에서의 캐싱은 모놀리식 프론트엔드보다 훨씬 미묘합니다. 셸(shell), 여러 리모트 매니페스트, 그리고 이들이 참조하는 청크가 각각 다른 배포 주기와 stale 상태에 대한 허용도를 가지고 있습니다. 이 글에서는 Mintel에서 우리가 어떻게 접근해 왔는지, 과거에 무엇이 고장났었는지, 그리고 아직 완전히 해결하지 못한 부분은 무엇인지 다룹니다.

2026년 6월 업데이트: 이후 마이크로 프론트엔드를 위한 캐싱 전략 최적화에 대한 후속 글을 작성했으며, 성능 개선과 대역폭 비용 절감을 위해 우리가 적용한 변경 사항을 다룹니다.

우리 스택

우리는 Webpack Module Federation을 이용해 약 30개의 마이크로 프론트엔드를 운영하고 있습니다. 셸은 S3에 배포되어 CloudFront를 통해 서빙되는 순수 정적 Jamstack 앱이며, 가장 바깥 계층에는 모든 것의 앞에 Akamai가 위치해 있습니다. 리모트는 고정된 잘 알려진 URL에 존재합니다.

UserAkamaiCloudFrontPython servicesDjango MPAS3 bucketMFE routesAPI routesMPA routes

배포는 빌드된 dist 디렉터리를 MFE별로 S3의 특정 하위 디렉터리로 복사하는 단일 rclone 명령으로 이루어집니다. 셸과 각 리모트는 독립적인 팀이 유지 관리하는 독립적인 배포 단위입니다.

에셋 유형별 설정 방법

에셋마다 캐싱 요구사항이 다릅니다. 현재 어떤 설정을 하고 있는지, 그리고 왜 그렇게 하는지 살펴보겠습니다.

index.html - no-cache

셸의 index.html이 모든 것을 부트스트랩합니다. 이 파일이 stale 상태라면 그 하위에서 일어나는 모든 것이 잘못될 수 있습니다. 우리는 배포 과정의 일부로 자동화하여 S3 객체 메타데이터를 통해 이 파일에 Cache-Control: no-cache 헤더를 설정합니다.

no-cache는 파일이 캐시되지 않는다는 뜻이 아닙니다. CDN이나 브라우저가 파일을 제공하기 전에 반드시 오리진에 재검증해야 한다는 의미입니다. 오리진이 304를 반환하면 캐시된 복사본이 제공되지만, 콘텐츠가 변경되었다면 새로운 복사본이 반환됩니다.

remoteEntry.js - never cache

remoteEntry.js는 Module Federation 매니페스트입니다. 셸에게 리모트의 청크를 어디서 찾을 수 있는지 알려줍니다. 리모트를 배포하면 이 파일의 내용은 바뀌지만 파일명은 바뀌지 않습니다.

stale한 remoteEntry.js는 두 가지 실패 모드를 가집니다. 눈에 띄는 경우는 오류입니다. 이전 배포의 청크를 가리키는데 그 청크들이 이미 교체되었다면 런타임 오류가 발생합니다. 더 미묘한 경우는 사용자가 새로운 매니페스트를 받기 전까지 새 버전의 앱을 보지 못한다는 점입니다. 이 때문에 리모트 팀이 수정 사항이나 기능을 배포해도 매니페스트가 stale한 탓에 사용자는 계속 이전 코드를 실행하게 됩니다.

Akamai 계층에서는 remoteEntry.jsCache-Control: no-store, max-age=0을 설정해 브라우저에 캐시되지 않도록 합니다. 또한 module-federation-import-remote 패키지를 통한 동적 리모트 로딩을 사용하고 있으며, 이 패키지는 기본적으로 remoteEntry.js URL에 캐시 버스팅용 쿼리 파라미터를 추가합니다. CloudFront 배포가 캐시 키에 쿼리 스트링을 포함하므로, 파일이 CloudFront에 캐시되어 있더라도 각 요청은 캐시를 우회하고 S3에서 최신 버전을 가져오는 고유한 URL을 갖게 됩니다.

Chunks - 별도 헤더 없음

remoteEntry.js가 참조하는 JS 청크는 Webpack의 contenthash 치환을 통해 콘텐츠 주소 지정됩니다. 파일 내용이 바뀌면 해시도 바뀌고, 따라서 파일명도 바뀝니다. 즉, 청크를 공격적으로 캐시해도 됩니다. 새로운 배포가 새로운 파일명을 생성하므로 CDN은 이를 자동으로 새로운 에셋으로 인식합니다.

output.filename에서 이를 설정하는 방법은 간단합니다:

output: {
  filename: '[name].[contenthash].js',
}

팀이 리모트의 에셋 파일명에 contenthash를 설정하는 것을 잊은 경우도 있었습니다. 청크가 예측 가능한 이름으로 배포되어 캐시되었고, 캐시 TTL이 자연스럽게 만료될 때까지 이후 배포 내용이 사용자에게 반영되지 않았습니다.

현재 청크에는 명시적인 Cache-Control 헤더를 설정하지 않고 있습니다. 그러면 CloudFront는 S3에서 오는 Last-ModifiedETag 헤더를 기반으로 한 휴리스틱 TTL에 따라 이 파일들을 캐시합니다. Akamai 계층에서는 오리진 응답에 대해 no-store 동작을 설정하므로, 모든 캐싱은 브라우저 내부나 CloudFront에서만 이루어집니다.

404 처리

우리의 셸은 싱글 페이지 앱입니다. 라우팅은 클라이언트 측에서 이루어집니다. 사용자가 특정 경로로 직접 이동하거나 새로고침하면 CDN은 S3에서 해당 경로의 파일을 찾고, 아무것도 찾지 못하면 기본적으로 404를 반환합니다.

해결 방법은 CloudFront가 오리진의 4xx 응답에 대해 index.html을 제공하도록 설정하는 것입니다. AWS는 이를 CloudFront 배포 설정의 사용자 정의 오류 응답으로 문서화하고 있습니다.

Akamai는 알려진 API나 MPA 경로에 해당하지 않는 모든 요청을 CloudFront로 프록시하므로, 이 폴백 동작은 CloudFront 계층에서 처리되며 모든 MFE 경로에 적용됩니다.

사용자 요청 흐름

리모트는 React.lazy와 동적 import로 감싸 지연 로딩됩니다. 셸은 사용자가 해당 리모트가 필요한 곳으로 이동할 때만 리모트의 remoteEntry.js를 가져옵니다.

UserBrowser cacheAkamaiCloudFrontS3GET /shell/index.htmlindex.htmlGET /remote-a/remoteEntry.js?t=1234remoteEntry.jsGET /remote-a/chunk.abc123.jschunk.abc123.jsStore chunk.abc123.jsGET /remote-a/chunk.xyz789.jsServed from browser cacheNew chunks are fetched; previously loaded chunks can be served from the browser cache.

CloudFront/S3 장애

위의 구성은 시간이 지나며 점진적으로 발전해 왔으며, 일부는 장애 대응 과정에서 만들어졌습니다. 2025년 1월, AWS 이슈로 인해 CloudFront 오리진이 S3에서 콘텐츠를 가져오지 못하기 시작했고, 그 결과 404 NoSuchBucket 오류가 발생했습니다. CloudFront는 S3의 4xx 응답에 대해 index.html을 제공하도록 설정되어 있었는데, 이는 일반적인 SPA catch-all 설정이었기 때문에 해당 오류들은 Akamai에 도달하기 전에 200 응답으로 변환되었습니다. Akamai는 뭔가 잘못되었다는 것을 알 방법이 없었고, 이를 그대로 정상적으로 캐시했습니다. AWS가 복구된 후에도 사용자들은 Akamai 엣지와 자체 브라우저 캐시 모두에 캐시된 잘못된 응답을 계속 제공받았습니다.

Akamai에서 퍼지(purge)는 느리고 고통스럽습니다. 경로에 glob을 써서 패턴에 맞는 모든 것을 한 번에 지울 수 없고, 구체적인 URL이 필요합니다. 수십 개의 MFE와 수백 개의 JS 청크 파일이 있는 상황에서 이는 압박 속에서 현실적인 선택지가 아닙니다. 결국 우리는 워룸에 모여 로딩 상태에 멈춰 있는 퍼지 요청들을 처리하느라 허둥댔고, TTL이 자연스럽게 만료되면서 몇 시간에 걸쳐 캐시가 점차 무효화되는 과정을 지켜봐야 했습니다. 이 방법은 브라우저에 잘못된 응답이 캐시된 사용자에게는 도움도 되지 않았습니다.

우리가 최종적으로 찾은 탈출구는 주요 MFE들의 Webpack 설정에서 contenthash 길이를 변경한 뒤 재배포하는 것이었습니다. contenthash 길이를 바꾸면 생성되는 모든 파일명이 바뀌어, CDN이 캐시된 잘못된 응답을 제공하는 대신 이를 새로운 에셋으로 인식하도록 강제할 수 있었습니다. 이 방법은 통했지만, 압박 속에서 즉흥적으로 찾은 방법이었을 뿐 문서화된 런북 절차는 아니었습니다.

그 이후로 우리는 MFE 에셋에 대한 Akamai 캐싱을 비활성화하고 S3 버킷에 다중 리전 페일오버를 추가해 같은 상황에 다시 처할 위험을 줄였습니다. 또한 변경 사항이 빠르게 반영되고 잘못된 폴백 응답이 오래 캐시되지 않도록 index.html에 명시적으로 no-cache를 설정하기 시작했습니다.

“잘못된 응답이 캐시되면 어떻게 할 것인가”라는 질문에 대한 솔직한 답은 여전히 깔끔한 해결책이 없다는 것입니다. contenthash 길이 트릭은 모든 것을 급하게 무효화해야 할 때 파일명을 일괄적으로 강제 변경하는 우리의 최후의 수단으로 남아 있습니다.

캐싱 전략 개선하기

이 글을 쓰는 과정은 현재 캐싱 전략이 어떻게 동작하는지 돌아보는 유용한 계기가 되었습니다. 캐싱 설정은 시스템이 잘 동작하고 있을 때 자주 다시 들여다보는 영역이 아니며, 현재 구성도 실제로는 그런대로 잘 버텨 왔습니다.

하지만 우리의 캐시 설정은 index.html에 대한 S3 객체 메타데이터와 remoteEntry.js에 대한 Akamai 규칙으로 나뉘어 있고, 다른 에셋에는 명시적인 캐시 헤더가 전혀 없습니다. 전체 캐싱 정책을 한눈에 파악할 수 있는 단일 지점이 없습니다. 또한 이전 장애의 고통에 대한 강력한 대응으로 바깥쪽 Akamai 계층에서는 전혀 캐시하지 않고 있는데, 이는 성능을 해치고 대역폭 비용을 증가시키고 있습니다.

더 깔끔한 접근 방식은 모든 에셋 유형에 대해 S3 객체 메타데이터를 통해 오리진에서 명시적인 Cache-Control 헤더를 설정하고, CDN 계층을 캐싱 정책을 정의하는 곳이 아니라 오리진 헤더를 존중하는 캐시로 취급하는 것입니다. 그렇게 하면 Akamai 계층을 교체하거나 재구성하더라도 캐싱 동작이 오리진을 따라가며 조용히 사라지지 않습니다.

우리는 청크에 대해 전혀 캐시 헤더를 설정하지 않고 있어, 얼마나 오래 캐시할지를 CDN과 브라우저의 휴리스틱에 의존하고 있습니다. 콘텐츠 해시가 적용된 청크에 명시적인 Cache-Control: max-age=31536000, immutable 헤더를 설정하고 Akamai가 오리진 캐시를 존중하도록 다시 활성화하면, 이들을 불변 에셋으로 공격적이고 정확하게 캐시할 수 있어 좋은 개선이 될 것입니다. 하지만 현재 구성에서는 모든 팀이 빌드 출력 파일명을 contenthash를 사용하도록 올바르게 설정했다는 보장이 없습니다. 그러나 두 문제를 한 번에 해결할 수 있는 다른 접근 방식도 있는데, 이는 더 큰 아키텍처 변경을 필요로 합니다.

대안: 버전이 포함된 URL과 디스커버리 서비스

지금까지의 모든 내용은 리모트가 고정된 잘 알려진 URL에 존재한다는 전제를 깔고 있습니다. 이는 가장 단순한 배포 모델이지만, 파일을 제자리에서 변경하고 있을 때 절대 오랫동안 안전하게 캐시할 수 없다는 점에서 remoteEntry.js 캐싱이 어려운 근본 원인이기도 합니다.

더 견고한 접근 방식은 URL 자체에 버전을 포함하는 것입니다:

https://cdn.example.com/remote-a/v1.4.2/remoteEntry.js

버전이 포함된 경로를 사용하면 remoteEntry.js도 다른 청크와 마찬가지로 콘텐츠 주소 지정 파일이 됩니다. max-age=31536000, immutable로 캐시할 수 있습니다. 이전 버전은 S3에 무기한 남아 있으므로, 세션 중인 사용자가 배포로 인해 깨지지 않습니다. 롤백도 재배포가 아니라 매니페스트가 이전 버전을 가리키도록 하는 것으로 이루어집니다.

이를 구현하려면 셸이 리모트 URL을 하드코딩해서는 안 됩니다. 디스커버리 서비스가 필요합니다. 셸이 부팅 시점에 각 리모트의 현재 URL을 가져오기 위해 호출하는 무언가입니다:

{
  "remote-a": "https://cdn.example.com/remote-a/v1.4.2/remoteEntry.js",
  "remote-b": "https://cdn.example.com/remote-b/v2.1.0/remoteEntry.js"
}

MFE 디스커버리 서비스

하지만 우리는 이렇게 하지 않고 있습니다. 1년 넘게 전에 이 패턴을 식별해 향후 가능한 방향으로 아키텍처 청사진에 추가해 두었음에도 그렇습니다. 이 방식은 contenthash 설정을 빠뜨린 팀을 걸러낼 수 있겠지만, 그건 규율의 문제이지 30개의 MFE를 마이그레이션할 이유는 아닙니다. 각 CDN 계층에서 더 공격적인 캐싱을 하면 성능 개선과 대역폭 비용 절감에도 도움이 되겠지만, 캐시 적중률과 대역폭 비용에 대한 상세 분석 없이는 그 효과를 정량화하기 어렵고, 현재 접근 방식에서 청크에 immutable 헤더를 설정하는 것으로도 비슷한 효과를 얻을 수 있습니다.

디스커버리 서비스가 있으면 카나리 릴리스나 배포 수준의 기능 플래그 같은 더 복잡한 배포 패턴도 가능해지겠지만, 이는 모두 복잡성과 운영 오버헤드를 더합니다. 대부분의 경우 우리는 애플리케이션 로직 자체 내에서 기능 플래그를 적용할 수 있어, 사용자가 실행 중인 코드를 더 쉽게 파악할 수 있습니다. 카나리 릴리스 역시 진정으로 유용하려면 자동화된 모니터링 및 알림 전략에 대한 시간 투자가 필요합니다.

디스커버리 서비스는 여전히 잠재적인 향후 선택지로 남아 있지만, 당장의 조치는 더 단순한 배포 방식을 유지하면서 현재 캐싱 전략을 개선하는 데 중점을 두고 있습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글