더 빠른 마이크로 프론트엔드: 성능을 위한 CDN 동작 최적화
CDN 레이어를 Akamai에서 CloudFront로 마이그레이션을 막 완료했고, 마이크로 프론트엔드 아키텍처를 위한 캐싱 전략 개선에도 시간을 투자했습니다. 성능 지표가 크게 개선됐는데, 특히 Time To First Byte(TTFB)는 p90 기준 54% 감소했고, 그 영향으로 Largest Contentful Paint(LCP)도 p90 기준 약 36% 줄어들었습니다.
몇 달 전 마이크로 프론트엔드 아키텍처를 위한 캐싱 전략에 대해 글을 쓴 적이 있습니다. 당시에는 장애 이후 Akamai 캐싱을 비활성화한 상태였고, Akamai -> CloudFront -> S3로 이어지는 3홉 구조였으며, CDN 레이어의 명시적 캐시 규칙에 의존해 캐싱을 제어하고 있었습니다. 대부분의 에셋에는 명시적인 캐시 제어가 적용되어 있지 않았고, 캐시 동작은 S3 메타데이터와 Akamai 규칙, CDN/브라우저 휴리스틱에 분산되어 있었습니다.
Akamai 제거: 3홉에서 2홉으로
Akamai는 오랫동안 아키텍처에서 제거하고 싶었던 추가적인 복잡성이었습니다. 나머지 인프라 대부분이 AWS에 있는데, CloudFront를 통한 마이크로 프론트엔드 아키텍처가 추가된 이후로는 Akamai가 지연 시간과 복잡성을 더하는 불필요한 홉이었습니다.
인프라 팀은 수년간 쌓인 다양한 Akamai 규칙을 CloudFront로 포팅하는 복잡한 작업을 진행하며, 레거시 TLS 버전 지원부터 기본 보안 동작 변경까지 여러 이슈를 해결했습니다. 트래픽은 3주에 걸쳐 점진적으로 Akamai에서 CloudFront로 전환했습니다.
이 글에서는 이번 마이그레이션이 마이크로 프론트엔드 아키텍처에 어떤 영향을 미쳤는지, 그리고 그 과정에서 캐싱 동작을 개선하기 위해 함께 적용한 변경 사항에 집중해 설명하겠습니다.
이전
이후
오리진 라우팅: 404 폴백 대신 명시적 로직으로
이전에는 단일 페이지 애플리케이션 라우트에 대해 CloudFront의 사용자 지정 오류 응답을 이용해 index.html 파일로 폴백하고 있었습니다. 즉, 사용자가 정적 에셋에 해당하지 않는 라우트를 요청할 때마다 요청이 오리진으로 전달되어 누락된 객체를 확인한 뒤, 200 응답 코드와 함께 index.html로 폴백되는 방식이었습니다.
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 지시어를 기준으로 캐시할 수 있습니다. 그래서 CloudFront 응답에서 Cache-Control 헤더의 s-maxage를 제거합니다. 이렇게 하면 자체 CDN만 해당 파일을 더 오래 캐시하고, 하위 소비자들은 항상 짧은 캐시 수명을 적용받게 됩니다.
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 호출에 의해 좌우되기 때문입니다. LCP에서는 신규 사용자와 재방문 사용자 모두 동일하게 약 38% 개선됐습니다.
다음 단계
프론트엔드 애플리케이션 전반에서 성능 측면에 개선할 여지는 여전히 많습니다. Web Vitals는 p75 기준 LCP가 2.5초 미만이면 “양호”로 봅니다. 현재 약 3.8초인 만큼 개선 여지가 남아 있습니다.
저희 아키텍처는 분산된 팀이 기능을 빠르게 개발할 수 있도록 설계됐으며, SPA와 마이크로 프론트엔드가 이를 가능하게 하지만 성능을 대가로 치릅니다. 플랫폼 차원에서는 index.html 캐싱을 비롯해 FCP를 위해 스켈레톤 로딩 화면을 HTML 파일에 인라인으로 포함하는 등 추가로 개선할 부분이 있습니다. 궁극적으로 LCP를 더 줄이기 위해서는 프로덕트 팀이 프론트엔드와 백엔드 로직 전반에서 초기 렌더링까지의 크리티컬 패스를 최적화하는 데 집중해야 합니다.
또한 캐시 무효화 단계를 도입하면서 배포 파이프라인에 다소 복잡성이 추가됐습니다. CloudFront 캐시 태그는 캐시 무효화 로직을 구조화하는 유연한 방법을 제공하며, 이를 활용해 로직을 단순화하고 필요 시 캐시된 에셋을 무효화하는 능력을 개선하는 방안을 검토 중입니다.
글을 무작위로 읽기