
저희 서비스에 Next.js 기반 레거시 앱이 하나 있는데, 이 앱의 홈 화면을 Chrome DevTools의 Lighthouse로 측정해 보았더니 Performance 73점이라는 기대 이하의 결과가 나왔습니다. 점수 손실의 거의 전부가 LCP에서 발생하고 있었고, LCP 요소를 확인해 보니 홈 상단의 배너 슬라이드 이미지였습니다. 인사이트 패널에서 가장 큰 비중을 차지하는 항목 두 가지도 모두 이미지 관련 항목이었습니다.
두 인사이트는 각각 다음을 의미합니다.
- image-delivery: 이미지가 적절한 포맷·해상도·압축으로 전달되지 않을 때 잡히는 인사이트
- cache-insight: 정적 자원의
Cache-Control정책이 충분히 길지 않을 때 잡히는 인사이트
| 항목 | Estimated Savings | 영향 지표 |
|---|---|---|
| image-delivery | 1,712 KiB | LCP −3.4s |
| cache-insight | 2,488 KiB | LCP −4.25s |
원인은 next.config.ts에 무심코 켜진 채 방치되어 있던 한 줄이었습니다.
images: {
unoptimized: true, // 이미지 최적화 비활성화
domains: [
// 도메인 설정..
],
// 기타 설정...
}images.unoptimized: true는 Next.js의 이미지 최적화를 전부 비활성화하는 옵션입니다. 이 옵션이 켜지면 <Image> 컴포넌트가 일반 <img>처럼 동작해 원본 URL을 그대로 사용하고, WebP 변환과 디바이스별 리사이즈도 일어나지 않습니다. 이 한 줄을 제거하고 지원이 중단된 domains를 remotePatterns로 마이그레이션한 뒤 다시 측정해 보았습니다.
| 지표 | Before | After |
|---|---|---|
| Performance Score | 73 | 93 |
| LCP | 8.4s | 2.3s |
| image-delivery | 1,712 KiB | 41 KiB |
| cache-insight | 2,488 KiB | 10 KiB |
점수는 73점에서 93점으로 올랐고, 두 인사이트도 표에서 거의 사라졌습니다. 여기까지는 단순한 개선 사례였습니다. 그런데 의문이 하나 남았습니다. image-delivery가 줄어든 것은 Next.js 자체 프록시가 이미지를 WebP로 변환하고 리사이즈하면서 응답 크기가 줄어든 결과입니다. 반면 cache-insight가 함께 줄어든 이유는 분명하지 않았습니다. 이미지 크기를 줄였을 뿐, 캐시 정책에는 손대지 않았기 때문입니다.
이 의문을 따라 Lighthouse 소스 코드를 추적해 본 결과, 진짜 원인은 응답 크기도 캐시 TTL도 아닌 must-revalidate라는 디렉티브 한 단어였습니다. 이 글은 그 메커니즘을 추적한 기록입니다.
cache-insight 인사이트는 무엇을 측정하는가
Lighthouse v13의 cache-insight는 페이지가 로드한 정적 자원(이미지, 스크립트, 스타일시트, 폰트, 미디어)의 캐시 정책을 검사해, 더 길게 캐시할 수 있어 보이는 응답을 표로 보여줍니다. v12까지 같은 역할을 하던 uses-long-cache-ttl이 v13부터 cache-insight라는 이름으로 바뀐 것이며, 실제 코드에도 replacesAudits: ['uses-long-cache-ttl'](링크)로 "이전 audit을 대체한다"고 명시되어 있습니다.
각 항목은 다음과 같이 표시됩니다.
- URL: 검사된 자원
- Cache TTL:
Cache-Control: max-age=...또는Expires헤더에서 추출한 캐시 유효 시간 - Transfer Size: 실제 전송된 바이트
- Estimated Savings (wastedBytes): 캐시가 충분히 길지 않아 재방문 시 다시 받게 될 것으로 추정되는 바이트
여기서 핵심은 마지막 항목입니다. Lighthouse는 다음 공식으로 wastedBytes를 추정합니다.
wastedBytes = (1 − cacheHitProbability) × transferSize
cacheHitProbability는 Chrome UMA(User Metrics Analysis, Chrome 사용자 통계 수집 시스템) 통계 기반의 함수로, max-age 값이 길수록 1에 가까워집니다. 즉 max-age가 짧으면 wastedBytes가 transferSize에 가까워져 표 상단에 올라가고, 길면 0에 가까워져 표에서 사라지는 구조입니다.
직관적으로는 "이미지 자체가 작아지면 transferSize가 작아지고, 그에 따라 wastedBytes도 줄어 지표에서 빠진 것 아닌가" 정도가 자연스러운 가설이지만, 실제로는 그렇게 단순하지 않았습니다.
두 가지 가설 검토
처음 떠오른 가설은 두 가지였습니다.
가설 1: /_next/image 응답이 더 긴 캐시 헤더를 갖는 것 아닐까
/_next/image의 응답 헤더를 직접 확인하면 그렇지 않다는 것을 곧바로 알 수 있습니다.
$ curl -sI "https://.../_next/image?url=...&w=1920&q=75"
HTTP/2 200
content-type: image/webp
cache-control: public, max-age=60, must-revalidate
etag: 95myX4nEbx4zwRbV-4FZT52KuOLjxYep9dx5q0iL9SA/_next/image로 변환된 이미지의 캐시 시간은 60초에 불과합니다. 비교를 위해 같은 페이지에서 로드되는 외부 스크립트 clarity.js(웹 분석 도구 Microsoft Clarity의 트래킹 스크립트)를 보면, max-age=86400(1일)이 설정되어 있는데도 cache-insight 표에 단독으로 남아 있었습니다. unoptimized: true를 적용하고 Lighthouse 측정 결과를 JSON으로 export한 후, cache-insight 항목을 확인해 보면 다음과 같습니다.
// audits["cache-insight"].details.items (이미지 최적화 후 결과)
[
{
"url": "https://scripts.clarity.ms/0.8.59/clarity.js",
"cacheLifetimeMs": 86400000, // 1일
"totalBytes": 26799,
"wastedBytes": 10719.6
}
]1일 캐시인 응답도 잡아내는 Lighthouse가 60초 캐시를 잡지 않을 이유는 없습니다.
가설 2: 이미지가 작아져 wastedBytes가 무시할 만큼 줄어든 것 아닐까
그럴듯해 보이는 가설이었습니다. 하지만 이 가설이 성립하려면 Lighthouse 내부에 wastedBytes 또는 transferSize가 일정 수준 이하면 지표에서 빼는 기준이 존재해야 합니다. 소스를 살펴보면 그런 기준은 존재하지 않기 때문에, 이 가설도 성립하지 않았습니다.
두 가설 모두 성립하지 않았으므로, 남은 선택지는 소스를 직접 살펴보는 것뿐이었습니다.
진짜 메커니즘은 cachingDisabled() 게이트
Lighthouse v13의 cache-insight는 core/audits/insights/cache-insight.js에서 표만 만들고, 실제 판정은 package.json이 의존하는 @paulirish/trace_engine@0.0.61 안의 models/trace/insights/Cache.js(링크)에서 수행합니다.
Cache.js의 generateInsight() 함수는 응답을 표에 포함시키기 전 다음 다섯 단계의 게이트를 통과시킵니다.
- 캐시 가능한 리소스 타입(Font, Image, Media, Script, Stylesheet)이고 status 200/203/206인가
must-revalidate/no-cache/no-store/private가 없는가- 명시적
max-age=0또는 만료된expires가 아닌가 - TTL이 30일 미만인가
- 적중 확률이 92.5% 이하인가
이 중 두 번째 게이트가 이번 케이스의 핵심이었습니다.
// Cache.js (trace_engine 0.0.61) line 132–146
function cachingDisabled(headers, parsedCacheControl) {
const cacheControl = headers?.get("cache-control") ?? null;
const pragma = headers?.get("pragma") ?? null;
if (!cacheControl && pragma?.includes("no-cache")) {
return true;
}
if (
parsedCacheControl &&
(parsedCacheControl["must-revalidate"] ||
parsedCacheControl["no-cache"] ||
parsedCacheControl["no-store"] ||
parsedCacheControl["private"])
) {
return true;
}
return false;
}// generateInsight() line 164
if (cachingDisabled(headers, parsedDirectives)) { continue; }응답 헤더에 must-revalidate, no-cache, no-store, private 중 하나라도 포함되어 있으면 그 요청은 continue로 즉시 건너뜁니다. wastedBytes 계산도, transferSize 비교도, max-age 평가도 일어나지 않습니다.
/_next/image 응답에는 must-revalidate가 포함되어 있습니다. 따라서 모든 /_next/image 요청은 두 번째 게이트에서 전부 제외됩니다. clarity.js는 public, max-age=86400만 있고 must-revalidate가 없어 게이트를 통과했습니다. 표에 단 한 줄만 남아 있는 것은 그 때문입니다.
| 응답 | Cache-Control | 게이트 통과 | 표에 잡힘 |
|---|---|---|---|
| Before / 외부 CDN 이미지 | (헤더 없음, ttl=0) | 통과 | 54건 |
After / /_next/image | max-age=60, must-revalidate | 두 번째 게이트에서 컷 | 0건 |
After / clarity.js | max-age=86400 | 통과 | 1건 (10.7 KiB) |
살아남은 항목으로 검산
진짜 메커니즘을 확인하려면, 살아남은 항목의 wastedBytes가 공식과 일치하는지 보면 됩니다.
Cache.js의 wastedBytes 계산식은 다음과 같습니다.
// line 183–185
const transferSize = req.args.data.encodedDataLength || 0;
const wastedBytes = (1 - cacheHitProbability) * transferSize;cacheHitProbability는 Chrome UMA 통계에서 가져온 11개 기준점을 선형 보간해 구합니다. 각 기준점은 실제 사용자들의 캐시 보존 기간을 짧은 순으로 정렬했을 때 0%, 10%, 20%, ..., 100% 지점에 해당하는 시간 값입니다.
// line 97
const RESOURCE_AGE_IN_HOURS_DECILES = [
0,
0.2,
1,
3,
8,
12,
24,
48,
72,
168,
8760,
Infinity,
];clarity.js의 max-age=86400 (24시간)을 대입해 보면 다음과 같습니다.
- 24시간은 6번째 기준점(24시간)에 정확히 해당합니다.
- 하위 점은 12시간으로 P=0.5, 상위 점은 24시간으로 P=0.6입니다.
- 선형 보간:
0.5 + (24−12)/(24−12) × (0.6−0.5) = 0.6. wastedBytes = (1 − 0.6) × 26,799 = 10,719.6.
실제 Lighthouse JSON의 값은 10719.6으로, 공식과 정확히 일치합니다.
적중 확률 분포 살펴보기
이 11개 기준점은 Chrome UMA의 HttpCache.StaleEntry.Validated.Age 분포(2017년 데이터)에서 가져온 값입니다. 직관과 어긋나는 수치가 몇 가지 있어 한 번 짚어볼 만합니다.
| max-age | 적중 확률 P | wastedRatio (1 − P) |
|---|---|---|
| 0초 (헤더 없음) | 0.0 | 1.0 (100% wasted) |
| 60초 | ≈ 0.008 | ≈ 0.99 |
| 1일 | 0.6 | 0.4 |
| 1주 | 0.9 | 0.1 |
| 30일 이상 | (별도 컷에서 제외) | - |
흥미로운 점은 6개월 캐시가 3개월 캐시의 2배로 평가되지 않는다는 것입니다. 1주를 넘기면 한계 효용이 급격히 떨어지고, 30일 이상은 "충분히 길다"고 보아 별도 컷으로 표에서 제외됩니다.
// line 174–176
const ttlDays = ttl / 86400;
if (ttlDays >= 30) { continue; }또 한 가지 짚어볼 만한 컷은 임계치 92.5%입니다.
// line 39
const IGNORE_THRESHOLD_IN_PERCENT = 0.925;cacheHitProbability가 92.5%를 넘으면 표에서 제외됩니다. 다만 데실 보간 결과가 92.5%에 도달하는 시점이 이미 30일 컷에 가까워서, 실무적으로는 30일 컷이 먼저 작동하는 경우가 많습니다.
Next/Image는 왜 must-revalidate를 사용하는가
여기까지 오면 자연스럽게 떠오르는 의문은 "그래서 Next.js는 왜 굳이 must-revalidate를 붙이는가"입니다. 응답 헤더를 다시 보면 단서가 있습니다.
cache-control: public, max-age=60, must-revalidate
etag: 95myX4nEbx4zwRbV-4FZT52KuOLjxYep9dx5q0iL9SA
ETag가 함께 포함되어 있습니다. Next.js의 이미지 최적화 응답은 ETag 기반 검증을 의도적으로 강제하는 정책을 따릅니다.
max-age=60: 60초 동안은 fresh, 캐시에서 그대로 사용must-revalidate: 60초가 지나면 반드시 ETag 검증을 거쳐야 함- ETag 매치 시 304 Not Modified 응답으로 본문 재전송 없음
결과적으로 클라이언트는 60초 후에 매번 conditional GET을 보내지만, 원본이 바뀌지 않았다면 본문은 다시 받지 않습니다. 짧은 max-age 자체보다 "재검증을 강제한다"는 정책이 핵심입니다.
이 설계 자체가 잘못된 것은 아닙니다. 이미지 최적화 결과는 원본 변경에 민감하기 때문에, 클라이언트가 stale한 응답을 그대로 사용하는 위험을 기본값으로 떠안기보다는 conditional GET 비용을 감수하는 쪽을 선택한 것입니다. 다만 이 설계의 결과로 Lighthouse 표에서 지속적으로 제외된다는 부수 효과는 알아둘 만합니다.
images.minimumCacheTTL을 늘리면 Next.js가 응답에 설정하는 max-age 값은 함께 늘어나지만, 기본 동작에서 must-revalidate는 그대로 유지됩니다. 즉 캐시 TTL을 1년으로 설정해도 cache-insight 표는 여전히 비어 있을 가능성이 높습니다.
실제로 캐시 효율을 높이려면
한 가지 짚어둘 점은, 이번 사례에서 LCP가 8.4초에서 2.3초로 개선된 것이 거의 전부 이미지 크기 감소(WebP 변환 + 리사이즈) 덕분이지 캐시 정책 개선 덕분이 아니라는 것입니다. 응답의 캐시 정책은 여전히 max-age=60에 must-revalidate가 포함되어 있고, 60초가 지나면 매번 conditional GET이 발생합니다. Lighthouse 표가 비었다고 해서 캐시가 완벽해진 것은 아니며, 오히려 메트릭이 사라진 탓에 남아 있는 캐시 튜닝 여지가 가려질 위험이 있습니다.
Lighthouse 점수만 보고 끝낼 것이 아니라 재방문 LCP와 대역폭을 실제로 개선하고 싶다면, 다음 옵션들을 검토할 수 있습니다.
images.minimumCacheTTL을 늘립니다. Next.js 16.0 이전 기본값은 60초였고, 16.0부터는 4시간(14400초)으로 상향되었습니다(PR #84105). 대부분의 경우 이미지 원본이 분 단위로 바뀌지 않으니, 1일 또는 그 이상으로 늘려도 무방합니다. 표에는 여전히 잡히지 않지만 실제 재방문 시 304 검증 빈도가 줄어 LCP가 개선됩니다.- 외부 CDN에 이미지 최적화를 위임합니다. Cloudinary, imgix,
ImageKit 같은 서비스나 CloudFront + Lambda@Edge 조합을 사용하면 이미지 최적화
결과 자체에 긴 max-age와 불변성 보장(파일명 해시 등)을 적용할 수 있습니다. 이
경우
next.config.ts에images.loader를custom으로 설정하고 외부 URL 패턴으로 라우팅합니다. - 원본 CDN의 캐시 헤더를 정비합니다. Before 데이터에서 외부 CDN
이미지 응답에는
Cache-Control자체가 없거나 max-age가 0이었습니다. 원본 자체에public, max-age=2592000정도(30일 미만이면 Lighthouse 표에 영향, 이상이면 컷)를 적용해 두면 next/image가 upstream 응답의 캐시 정책을 일정 부분 반영합니다(Next 버전에 따라 동작 차이가 있을 수 있습니다).
핵심은 "메트릭 표가 비어 있다 = 캐시 효율이 좋다"는 등식이 성립하지 않는다는 점을 인지하는 것입니다. 메트릭은 합성지표일 뿐이고, 실제 사용자 경험은 별도로 측정해야 합니다.
정리
-
cache-insight 메트릭이 사라진 진짜 원인은 캐시 개선이 아니라
입니다. 60초 TTL이라도 must-revalidate가 함께 포함되면 cache-insight 표에는 잡히지 않습니다. LCP 개선은 이미지 크기 감소 덕분이지 캐시 정책 개선 덕분이 아닙니다.must-revalidate한 단어 - Lighthouse의 합성 메트릭에는 디렉티브 단위의 컷오프가 따로 존재합니다. 점수만 보고 "해결됨"이라고 판단하기 전에, 메트릭이 사라진 이유가 실제 개선 때문인지 게이트 컷 때문인지 한 번 의심해 볼 가치가 있습니다.
- Next/Image의 기본 정책은 안전한 보수적 설계지만, Lighthouse 점수와는 분리해서 평가해야 합니다. 실제 캐시 효율을 개선하려면
minimumCacheTTL조정이나 외부 CDN 위임을 함께 검토할 필요가 있습니다.
마무리
이번 사례에서 배운 점은 단순합니다. Lighthouse 메트릭은 절대값이 아니라 합성지표이고, 합성지표에는 게이트 컷이 따로 존재한다는 것입니다. 표에서 사라진 항목이 "해결된 항목"이라고 단정하기 전에, 사라진 이유가 실제 개선 때문인지 컷오프 때문인지 한 번 들여다볼 필요가 있습니다. 이번에는 must-revalidate 한 단어가 그 이유였습니다.
검증에 사용한 자료는 다음과 같습니다.
- Lighthouse 13.0.2:
core/audits/insights/cache-insight.js @paulirish/trace_engine0.0.61:models/trace/insights/Cache.js,models/trace/insights/Statistics.js
참고 자료
Related Posts

Lighthouse 접근성 점수가 움직이지 않은 이유
Lighthouse 자동 검사가 잡는 영역과 잡지 않는 영역을 구분하고, 마크업 4건을 정리해 접근성 점수를 84점에서 95점까지 올린 과정을 다룹니다.

대용량 엑셀 다운로드: 클라이언트에서 서버로
클라이언트 기반 엑셀 생성의 한계를 극복하고 SSE를 활용한 서버 사이드 아키텍처로 성능과 사용자 경험을 개선한 과정을 다룹니다.

SCSS Mixin으로 SSR 플리커링 줄이기
JavaScript 반응형 로직을 SCSS Mixin으로 전환하여 SSR 환경의 플리커링(FOUC)을 근본적으로 해결한 과정을 다룹니다.