마이크로 프론트엔드에서의 캐싱과 CDN
마이크로 프론트엔드 아키텍처에서 캐싱은 모놀리식 프론트엔드보다 훨씬 미묘합니다. 셸과 여러 리모트 매니페스트, 그리고 이들이 참조하는 청크가 각각 다른 배포 주기와 신선도에 대한 허용도를 가지고 있습니다. 이 글에서는 Mintel에서 우리가 어떻게 접근해 왔는지, 과거에 무엇이 고장 났었는지, 그리고 아직 완전히 해결하지 못한 것이 무엇인지 다룹니다.
2026년 6월 업데이트: 이후 마이크로 프론트엔드를 위한 캐싱 전략 최적화에 대한 후속 글을 작성했습니다. 성능 개선과 대역폭 비용 절감을 위해 어떤 변화를 적용했는지 다룹니다.
기술 스택
우리는 Webpack Module Federation을 이용해 약 30개의 마이크로 프론트엔드를 운영합니다. 셸은 S3에 배포되고 CloudFront를 통해 제공되는 순수 정적 Jamstack 앱이며, 가장 바깥쪽에는 모든 트래픽 앞에 Akamai가 위치합니다. 리모트는 고정된 잘 알려진 URL에 존재합니다.
배포는 빌드된 dist 디렉터리를 MFE별로 S3의 특정 하위 디렉터리에 복사하는 단일 rclone 명령으로 이루어집니다. 셸과 각 리모트는 독립적인 팀이 관리하는 별개의 배포 단위입니다.
에셋 유형별 캐시 설정
에셋마다 캐싱 요구사항이 다릅니다. 현재 어떤 설정을 하고 있는지, 그리고 그 이유는 무엇인지 살펴보겠습니다.
index.html - no-cache
셸의 index.html은 모든 것을 부트스트랩합니다. 이 파일이 오래된(stale) 상태라면 하위 모든 것이 잘못될 수 있습니다. 우리는 배포 과정의 일부로 자동화하여 S3 객체 메타데이터를 통해 이 파일에 Cache-Control: no-cache 헤더를 설정합니다.
no-cache가 파일이 캐시되지 않는다는 뜻은 아닙니다. CDN이나 브라우저가 해당 파일을 제공하기 전에 반드시 오리진에 재검증을 해야 한다는 의미입니다. 오리진이 304를 반환하면 캐시된 복사본이 제공되지만, 콘텐츠가 변경되었다면 새로운 복사본이 반환됩니다.
remoteEntry.js - 절대 캐시하지 않음
remoteEntry.js는 Module Federation 매니페스트입니다. 셸에 리모트 청크가 어디에 있는지 알려줍니다. 리모트를 배포하면 이 파일의 내용은 바뀌지만 파일명은 그대로 유지됩니다.
오래된 remoteEntry.js는 두 가지 방식으로 실패합니다. 분명한 경우는 오류입니다. 이전 배포의 청크를 가리키는데 그 청크가 이미 교체되었다면 런타임 오류가 발생합니다. 더 미묘한 경우는 사용자가 새로운 매니페스트를 받을 때까지 앱의 새 버전을 보지 못한다는 점입니다. 이 때문에 리모트 팀이 수정 사항이나 기능을 배포해도 매니페스트가 오래된 탓에 사용자는 계속 이전 코드를 실행하게 됩니다.
Akamai 레이어에서는 remoteEntry.js에 Cache-Control: no-store, max-age=0을 설정해 브라우저가 캐시하지 못하도록 합니다. 또한 동적 리모트 로딩을 module-federation-import-remote 패키지를 통해 사용하고 있는데, 이 패키지는 기본적으로 remoteEntry.js URL에 캐시 버스팅용 쿼리 파라미터를 추가합니다. CloudFront 배포에서 쿼리 스트링을 캐시 키에 포함하므로, 파일이 CloudFront에 캐시되어 있더라도 각 요청이 고유한 URL로 캐시를 우회해 S3에서 최신 버전을 가져오도록 보장합니다.
청크 - 별도 헤더 없음
remoteEntry.js가 참조하는 JS 청크는 Webpack의 contenthash 치환을 통해 콘텐츠 주소 지정(content-addressed)됩니다. 파일 내용이 바뀌면 해시가 바뀌고 파일명도 함께 바뀝니다. 따라서 청크는 공격적으로 캐시해도 됩니다. 새로운 배포가 새로운 파일명을 생성하므로 CDN은 이를 자동으로 새로운 에셋으로 인식합니다.
output.filename에서 이를 설정하는 방법은 간단합니다:
output: {
filename: '[name].[contenthash].js',
}팀이 리모트의 에셋 파일명에 contenthash를 설정하는 것을 잊어버린 경우도 있었습니다. 예측 가능한 이름으로 배포된 청크가 캐시되면서, 캐시 TTL이 자연스럽게 만료될 때까지 이후 배포 내용이 사용자에게 반영되지 않았습니다.
현재 청크에는 별도의 Cache-Control 헤더를 설정하지 않습니다. 그러면 CloudFront는 S3에서 전달된 Last-Modified나 ETag 헤더를 기반으로 한 휴리스틱 TTL에 따라 이 파일들을 캐시합니다. Akamai 레이어에서는 오리진 응답에 대해 no-store 동작을 설정하므로, 모든 캐싱은 브라우저나 CloudFront에서만 이루어집니다.
404 처리
우리의 셸은 싱글 페이지 앱입니다. 라우팅은 클라이언트 측에서 이루어집니다. 사용자가 특정 경로로 직접 이동하거나 새로고침하면 CDN은 S3에서 해당 경로의 파일을 찾고, 아무것도 찾지 못하면 기본적으로 404를 반환합니다.
해결 방법은 오리진의 4xx 응답에 대해 CloudFront가 index.html을 제공하도록 설정하는 것입니다. AWS는 이를 CloudFront 배포 설정의 사용자 정의 오류 응답으로 문서화하고 있습니다.
Akamai는 알려진 API나 MPA 경로와 일치하지 않는 모든 요청을 CloudFront로 프록시하므로, 이 폴백 동작은 CloudFront 레이어에서 처리되며 모든 MFE 경로에 적용됩니다.
사용자 요청 흐름
리모트는 React.lazy와 동적 import로 감싸 지연 로딩됩니다. 셸은 사용자가 해당 리모트가 필요한 곳으로 이동할 때만 리모트의 remoteEntry.js를 가져옵니다.
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"
}
하지만 우리는 이 방식을 채택하지 않았습니다. 1년 넘게 전에 이미 이 패턴을 파악해 아키텍처 청사진에 향후 가능한 방향으로 추가해 두었음에도 말입니다. 이 방식이라면 contenthash 설정을 빠뜨린 팀의 실수를 잡아낼 수 있겠지만, 이는 어디까지나 실행 규율의 문제이지 30개의 MFE를 마이그레이션할 이유는 되지 못합니다. 각 CDN 레이어에서 더 공격적인 캐싱을 하면 성능 향상과 대역폭 비용 절감에도 도움이 되겠지만, 캐시 적중률과 대역폭 비용에 대한 상세 분석 없이는 그 효과를 정량화하기 어렵고, 현재 방식에서도 청크에 immutable 헤더를 설정함으로써 비슷한 효과를 얻을 수 있습니다.
디스커버리 서비스를 두면 카나리 릴리스나 배포 단위의 기능 플래그 같은 더 복잡한 배포 패턴도 가능해지겠지만, 이 모든 것은 복잡성과 운영 부담을 더합니다. 대부분의 경우 애플리케이션 로직 내에서 기능 플래그를 처리할 수 있어, 사용자가 실행 중인 코드를 더 쉽게 파악할 수 있습니다. 카나리 릴리스가 진정으로 유용하려면 자동화된 모니터링과 알림 전략에 시간 투자를 해야 하기도 합니다.
디스커버리 서비스는 여전히 향후 가능한 선택지로 남아 있지만, 당장의 과제는 더 단순한 배포 방식을 유지하면서 현재 캐싱 전략을 개선하는 데 집중되어 있습니다.
글을 무작위로 읽기