더 빠른 마이크로 프론트엔드: 성능을 위한 CDN 동작 최적화
원문은 Alex O'Callaghan님이 에 게재했습니다. 이 블로그 구독하기
최근 CDN 레이어를 Akamai에서 CloudFront로 마이그레이션하고, 마이크로프론트엔드 아키텍처의 캐싱 전략을 개선하는 작업을 마쳤다. 성능 지표가 전반적으로 크게 개선됐는데, 특히 Time To First Byte(TTFB)는 p90 기준 54% 감소했고, 그 결과 Largest Contentful Paint(LCP)도 p90 기준 약 36% 줄었다.
몇 달 전 마이크로프론트엔드 아키텍처를 위한 캐싱 전략에 대해 글을 쓴 적이 있다. 당시에는 장애 이후 Akamai 캐싱을 비활성화한 상태였고, Akamai -> CloudFront -> S3로 이어지는 3홉(hop) 구조를 사용하고 있었다. CDN 레이어의 명시적인 캐시 규칙에 의존해 캐싱을 제어하고 있었으며, 대부분의 에셋에는 명시적인 캐시 제어가 설정되어 있지 않았고 캐시 동작은 S3 메타데이터, Akamai 규칙, CDN/브라우저 휴리스틱에 분산되어 있었다.
Akamai 제거: 3홉에서 2홉으로
Akamai는 오랫동안 아키텍처에서 제거하고 싶었던 복잡도 요소였다. 나머지 인프라 대부분은 AWS에 있고, CloudFront를 통한 마이크로프론트엔드 아키텍처가 추가된 이후 Akamai는 지연 시간과 복잡성을 더하는 추가 홉에 불과했다.
인프라 팀은 수년간 쌓인 다양한 Akamai 규칙을 CloudFront로 포팅하는 복잡한 작업을 처리하며, 레거시 TLS 버전 지원부터 기본 보안 동작 변경까지 여러 이슈를 해결했다. 트래픽은 3주에 걸쳐 Akamai에서 CloudFront로 점진적으로 전환됐다.
이 글에서는 이번 마이그레이션이 마이크로프론트엔드 아키텍처에 어떤 영향을 미쳤는지, 그리고 그와 함께 캐싱 동작을 개선하기 위해 어떤 변화를 줬는지에 집중하려 한다.
이전
이후
오리진 라우팅: 404 폴백 대신 명시적 로직으로
이전에는 싱글 페이지 애플리케이션 라우트에 대해 index.html 파일로 폴백하기 위해 CloudFront의 커스텀 오류 응답에 의존했다. 즉, 사용자가 정적 에셋에 해당하지 않는 경로를 요청할 때마다 요청이 오리진으로 전달되어 오브젝트를 찾지 못한 뒤 200 응답 코드와 함께 index.html로 폴백되는 방식이었다.
어차피 기존 Akamai 엣지 규칙들을 처리하기 위한 CloudFront Function을 만들고 있던 참이라, 같은 함수 안에 SPA 폴백 로직을 구현하기로 했다. 이를 통해 요청이 오리진에 도달하기 전에 엣지에서 라우팅을 처리할 수 있게 됐다.
function handler(event) {
var request = event.request;
var uri = request.uri || '/';
var qs = request.querystring || {};
// ... other routing logic, omitted for simplicity
// SPA fallback: if the path is extensionless, rewrite to index.html
var lastSegment = uri.split('/').pop();
if (lastSegment === '' || lastSegment.indexOf('.') === -1) {
request.uri = '/path/to/index.html';
}
return request;
}덕분에 동작이 더 명시적이고 예측 가능해졌고, 정적 에셋을 요청할 때 오리진의 실제 오류를 숨길 위험도 줄일 수 있었다. 이는 이전 CloudFront/S3 장애 당시 우리에게 영향을 줬던 문제다.
또한 SPA 경로 요청마다 오리진에 두 번 요청하던 과정을 없애 TTFB 성능 개선에도 도움이 됐다.
에셋 유형별 명시적 캐시 헤더
마이크로프론트엔드 배포에서는 S3에서 서빙되는 에셋이 세 가지 유형으로 나뉜다:
index.html- 호스트 셸의 진입점으로, 가변적이며 셸이 배포될 때마다 변경된다.remoteEntry.js- 각 마이크로프론트엔드의 진입점으로, 역시 가변적이며 마이크로프론트엔드가 업데이트될 때 변경된다.assets- 각 마이크로프론트엔드의 JS 청크와 에셋으로, 불변이며 콘텐츠 해시가 포함된 이름으로 제공된다.
CDN 규칙, S3 메타데이터, 브라우저 휴리스틱이 뒤섞인 방식 대신, 이제는 S3에 업로드하는 시점에 에셋 유형별로 캐시 헤더를 명시적으로 설정하는 방식으로 바꿨다.
배포 파이프라인 단계에서 이제 각 오브젝트에 Cache-Control 메타데이터를 다음과 같이 설정한다:
# Upload assets with immutable caching
rclone copy -vv \
--exclude 'index.html' \
--exclude 'remoteEntry.js' \
-M --metadata-set 'Cache-Control=max-age=31536000, immutable' \
./dist $DEST
# Upload remoteEntry.js with short caching
rclone copy -vv \
-M --metadata-set 'Cache-Control=max-age=30, s-maxage=86400' \
./dist/remoteEntry.js $DEST
# Upload index.html with no caching
rclone copy -vv \
-M --metadata-set 'Cache-Control=no-cache' \
./dist/index.html $DEST에셋
마이크로프론트엔드 빌드에서 생성되는 모든 에셋은 Webpack의 [contenthash]를 파일명에 사용한다. 즉, 청크의 내용이 조금이라도 바뀌면 새로운 파일명이 생성된다. 덕분에 오래된 콘텐츠를 서빙할 걱정 없이 해당 에셋들에 긴 캐시 수명을 설정할 수 있다.
remoteEntry.js
remoteEntry.js 파일은 각 마이크로프론트엔드의 진입점으로, 로드해야 할 다양한 JS 청크를 가리킨다. 각 마이크로프론트엔드가 독립적으로 배포되므로 파일명은 잘 알려진 고정 경로에 있어야 한다. 브라우저가 오래된 릴리스를 너무 오래 캐시하지 않도록 최종 사용자에게는 짧은 캐시 수명을 설정한다. s-maxage 지시어를 사용하면 CloudFront 같은 공유 캐시가 파일을 더 오래 캐시해 오리진 부하를 줄일 수 있다.
remoteEntry.js가 업데이트될 때 CloudFront에 캐시된 버전이 무효화되어 사용자가 다음 요청 시 새 버전을 받을 수 있도록 배포 파이프라인에 CloudFront 무효화 단계를 추가했다:
aws cloudfront create-invalidation \
--distribution-id $DISTRIBUTION_ID \
--paths '/path/to/remoteEntry.js'참고: 일부 고객은 자체 프록시를 통해 우리 애플리케이션에 접속하는데, 이 프록시들이 s-maxage 지시어를 기준으로 캐시할 수 있다. 우리 CDN만 해당 파일을 더 오래 캐시하고 하위 소비자들은 항상 짧은 캐시 수명을 적용받도록, CloudFront 응답에서 Cache-Control 헤더의 s-maxage를 제거한다.
index.html
index.html의 경우 브라우저가 항상 서버에 최신 버전을 확인하도록 Cache-Control: no-cache를 설정한다. 이제 CloudFront Function 로직 덕분에 무효화해야 할 index.html 캐시 키가 하나뿐이라 remoteEntry.js와 유사한 방식을 적용해 더 최적화할 수도 있겠지만, 셸 업데이트가 빠르게 반영되도록 일단 기존 no-cache 동작을 유지했다.
영향 측정
프로덕션 RUM 데이터를 수집하기 위해 Grafana Faro를 사용하고 있으며, 이를 통해 변경 사항이 성능 지표에 미친 영향을 모니터링했다. 트래픽이 Akamai에서 새로운 CloudFront 배포로 마이그레이션되면서 페이지 로드 관련 지표 전반에서 개선이 나타나기 시작했다.
| 지표 | p50 | p75 | p90 |
|---|---|---|---|
| TTFB (ms) | 1493 -> 507 (-66%) | 2484 -> 1074 (-57%) | 4483 -> 2041 (-54%) |
| FCP (ms) | 2284 -> 1140 (-50%) | 3524 -> 1988 (-44%) | 5308 -> 3440 (-35%) |
| LCP (ms) | 4312 -> 2688 (-38%) | 6040 -> 3788 (-37%) | 8608 -> 5516 (-36%) |
특히 TTFB 감소가 FCP와 LCP 개선으로 이어졌다. 다만 TTFB와 LCP 사이의 간격도 줄어들고 있어, 캐싱 개선 덕분에 다른 리소스들도 브라우저나 CDN 캐시에서 더 빨리 로드되고 있음을 알 수 있다.
| 지표 | 이전 (Akamai) | 이후 (CloudFront) |
|---|---|---|
| TTFB | 2484ms | 1074ms |
| FCP | 3524ms | 1988ms |
| LCP | 6040ms | 3788ms |
Faro 데이터에서 TTFB 요청의 구성 요소를 세분화해보면 request_duration이 크게 감소한 것을 확인할 수 있다. 아쉽게도 Faro는 기본적으로 <script> 태그 리소스의 요청 시간을 계측하지 않아, 에셋 캐싱이 요청 로드 시간을 얼마나 개선했는지는 정확히 알 수 없다.
| 지표 | 이전 (Akamai) | 이후 (CloudFront) |
|---|---|---|
| DNS | 56ms | 58ms |
| Connection | 47ms | 33ms |
| Cache | 4ms | 4ms |
| Request | 1056ms | 184ms |
| Waiting | 11ms | 22ms |
Faro에서 에셋 요청 시간은 볼 수 없지만, CloudFront의 오브젝트별 캐시 통계를 통해 캐싱 효과를 확인할 수 있다. 마이크로프론트엔드 에셋으로 필터링해보면 엣지 캐시 히트율이 52%에서 95%로 상승했다. 캐시 가능한 두 에셋 유형 모두 거의 100%에 도달했다. remoteEntry.js는 63%에서, 불변의 콘텐츠 해시 청크는 48%에서 상승했다.
| 지표 | 이전 (Akamai) | 이후 (CloudFront) |
|---|---|---|
| remoteEntry.js | 63% | 100% |
| 불변 청크 | 48% | 100% |
| 전체 MFE 요청 | 52% | 95% |
이러한 개선의 대부분은 재검증을 없앤 덕분이다. 이전에는 명시적인 캐시 헤더가 없어 에셋 요청의 약 40%가 재검증이었다. CloudFront가 파일을 가지고 있으면서도 거의 모든 요청마다 오리진에 확인(304 Not Modified)을 했던 것이다. 콘텐츠 해시가 포함된 청크에 Cache-Control: immutable을, remoteEntry.js에 s-maxage를 설정하면서 이러한 왕복이 진정한 캐시 히트로 바뀌었다. 이를 통해 오리진 대역폭이 줄어 비용도 절감되고 성능도 개선됐다.
Faro는 각 세션에 사용자의 이전 세션에 대한 참조를 태그로 남기므로, 첫 방문(콜드 캐시) 로드와 재방문(웜 캐시) 로드를 구분할 수 있다. 새로운 캐싱 전략의 혜택은 재방문 사용자가 가장 크게 받을 것으로 예상할 수 있다. First Contentful Paint를 보면 재방문 사용자가 신규 사용자보다 마이그레이션 전에는 약 17% 빨랐고, 이후에는 격차가 약 29%로 더 벌어졌다.
| 지표 | 이전 (Akamai) | 이후 (CloudFront) |
|---|---|---|
| 신규 사용자 | 2296ms | 1156ms |
| 재방문 사용자 | 1912ms | 816ms |
LCP에서는 비슷한 변화를 보지 못했는데, LCP는 JS 로드 속도보다는 애플리케이션 렌더링 로직과 데이터 페칭 API 호출에 의해 좌우되기 때문이다. 신규 사용자와 재방문 사용자 모두 약 38% 동일하게 개선됐다.
앞으로의 과제
프론트엔드 애플리케이션 전반에서 성능을 더 개선할 여지는 여전히 많다. Web Vitals에서는 p75 기준 LCP가 2.5초 미만일 때 ‘양호’하다고 제안한다. 현재 약 3.8초인 우리는 아직 개선할 여지가 있다.
우리 아키텍처는 분산된 팀이 빠르게 기능을 개발할 수 있도록 설계됐으며, SPA와 마이크로프론트엔드가 이를 가능하게 하지만 성능 측면에서는 비용이 따른다. 플랫폼 레벨에서는 index.html 캐싱이나 FCP를 위한 스켈레톤 로딩 화면을 HTML 파일에 인라인으로 옮기는 등 더 개선할 부분이 있다. 궁극적으로 LCP를 더 줄이려면 제품 팀이 프론트엔드와 백엔드 로직 모두에서 초기 렌더링까지의 크리티컬 패스를 최적화하는 데 집중해야 한다.
또한 배포 파이프라인에 캐시 무효화 단계를 도입하면서 복잡성이 다소 추가됐다. CloudFront 캐시 태그는 캐시 무효화 로직을 구성하는 유연한 방법을 제공하며, 이를 활용해 로직을 단순화하고 필요 시 캐시된 에셋을 무효화하는 능력을 개선하는 방안을 검토 중이다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기