priority를 줬는데 fetchpriority가 없다 — 선언만 남은 최적화 2건, 측정이 뒤집은 판정 3건
전에 Lighthouse 27점을 65점으로 올린 기록을 썼습니다. 그때 마지막 절의 제목이 "측정 도구가 배신할 때"였는데, 이번에는 제가 도구를 배신했습니다 — 넣어둔 최적화 두 개가 선언만 있고 실제로는 아무 일도 하지 않고 있었습니다.
같은 코드베이스를 6페이지 × 2폼팩터로 다시 감사했습니다. 나온 것을 순서대로 적습니다.
| 페이지 | 모바일 | LCP |
|---|---|---|
| studio 홈 | 47 | 12127ms |
| project | 62 | 11362ms |
| templates | 63 | 13659ms |
| inquiry | 66 | 3346ms (SI 19513ms) |
1. priority는 fetchpriority를 붙이지 않는다
templates 페이지 LCP가 13.7초였습니다. lcpLoadDelay가 11107ms — 이미지가 다운로드를 시작하기까지 11초를 기다렸다는 뜻입니다. LCP 요소를 리포트에서 확인했습니다.
<!-- lcp-discovery-insight 의 node.snippet -->
<img alt="" loading="lazy" width="225" height="400" decoding="async" src="…/11238.png">
loading="lazy". 그리고 같은 리포트의 체크리스트:
FAIL priorityHinted: fetchpriority=high should be applied
FAIL requestDiscoverable: Request is discoverable in initial document
FAIL eagerlyLoaded: LCP resources should not use loading=lazy
lazy는 제가 의도한 것이었습니다(그리드 전체를 eager로 받으면 대역폭을 다투니까). 문제는 선두 항목 판정 기준이 데스크톱 첫 행(5열)에 맞춰 5개였고, 모바일 2열 masonry에서는 LCP 요소가 6번째(index 5)라 lazy로 남았다는 것. 이건 상수 하나 고치면 됩니다.
의외였던 건 다른 쪽입니다. 프로젝트 카드에는 이미 priority를 배선해 뒀는데도 priorityHinted: false가 나왔습니다. 소스를 열었습니다.
// node_modules/next/dist/shared/lib/get-img-props.js
function getImgProps({ src, sizes, unoptimized, priority = false, preload = false,
loading, /* … */ fetchPriority, decoding = 'async', /* … */ }) {
// …
let isLazy = !priority && !preload && (loading === 'lazy' || typeof loading === 'undefined');
// …
return { props: { loading: loadingFinal, fetchPriority, /* … */ }, preload: preload || priority };
}
priority와 fetchPriority는 완전히 별개의 파라미터입니다. priority는 두 가지만 합니다 — isLazy 판정(→ loading 속성 생략)과 preload 링크 방출. fetchPriority는 그냥 passthrough라, 호출부에서 명시하지 않으면 <img>에 fetchpriority 속성이 붙지 않습니다.
즉 <Image priority> 하나로 "eager + high priority"가 된다고 믿고 있었는데, 실제로는 eager만 되고 우선순위 힌트는 없었습니다.
// 선언은 있는데 효과가 없던 코드
<Image src={src} priority={isFirst} />
// 고친 것 — priority와 loading 동시 지정은 경고이므로 분기로
{...(isFirst
? { priority: true, fetchPriority: 'high' as const }
: { loading: 'lazy' as const })}
loading="eager"만으로도 부족합니다. eager는 "지금 받아라"일 뿐 우선순위가 아니어서 Low로 큐잉됩니다. templates의 LCP 이미지는 Low 우선순위 썸네일 19장(1524KB) 뒤에 서 있었습니다.
[우선순위] — LCP 이미지가 Low 면 큐잉 지연 의심
2295KB 67건 Low ← LCP 이미지가 여기
397KB 63건 VeryHigh
146KB 9건 High
masonry나 가상화 그리드는 JS로 배치되기 때문에 크롬의 "뷰포트 안 이미지 자동 승격"도 늦게 걸립니다. 명시가 필요합니다.
2. webpackPreload 매직 코멘트가 무효였다
두 번째 사례는 더 조용했습니다.
홈 페이지 LCP 요소가 이미지가 아니라 자동 오픈되는 안내 모달의 히어로 텍스트였습니다(이건 4절에서 다룹니다). 모달은 dynamic(ssr:false)로 로드되니 하이드레이션 이후에야 발견되는 늦은 요청이 됩니다. 청크를 초기 로드와 병렬로 받게 만들면 되겠다고 생각했고, 한 줄이면 될 것 같았습니다.
await import(/* webpackPreload: true */ './index');
webpack 문서상 이건 부모 청크가 로드될 때 <link rel="preload">를 함께 emit합니다. 커밋 직전에, 프로덕션 빌드 산출물을 확인했습니다.
NEXT_PUBLIC_ENV=production pnpm portal:build
cd apps/portal/.next/static/chunks
grep -l 'as="script"' *.js # → 0건
grep -o 'preload[A-Za-z]*' webpack-*.js | sort -u # → 아무것도 없음
청크 preload 런타임이 아예 생성되지 않았습니다. 이 Next 빌드에서 그 매직 코멘트는 무효였습니다. 에러도 경고도 없고, 코드에는 그럴싸한 선언과 주석만 남습니다 — 1절과 정확히 같은 클래스입니다.
이 두 건을 연달아 겪고 나서 규칙을 하나 만들었습니다. "선언형 최적화는 빌드 산출물이나 런타임 DOM에서 그 선언의 흔적을 찾은 뒤에 커밋한다." prop 하나, 코멘트 하나로 끝나는 최적화일수록 검증이 싸고, 그래서 더 자주 건너뛰게 됩니다.
대신 정적 import로 갔습니다. 그리고 스코프를 실측으로 확인했습니다.
청크: static/chunks/7819-…js | gz 8KB
초기(initial)인 엔트리포인트: 1
+ app/[locale]/workspace/studio/(main)/page
초기가 아닌 엔트리포인트: 0
홈 엔트리포인트 하나에서만 initial이 됩니다. 매직 코멘트가 동작했다면 부모 청크(워크스페이스 공통 셸) 범위라 홈이 아닌 페이지에도 15KB를 얹었을 테니, 결과적으로 스코프도 더 정확해졌습니다.
3. simulated과 observed는 다른 시계다
리포트를 파다가 숫자가 안 맞는 걸 발견했습니다. LCP breakdown의 단계 합이 2초쯤인데 최상위 LCP는 13.7초였습니다. 처음엔 리포트가 깨진 줄 알았습니다.
// metrics audit 의 같은 항목
{
largestContentfulPaint: 13659, // simulated
observedLargestContentfulPaint: 2091, // observed
lcpLoadDelay: 11107
}
Lighthouse 기본 실행은 simulated throttling(Lantern)입니다. 최상위 metric은 시뮬레이션 값이고, observed* 필드는 그 실행에서 실제로 걸린 시간입니다. insight의 단계 값은 대체로 observed 기준이라 최상위 metric과 시계가 다릅니다. 버그가 아닙니다.
같이 걸린 함정이 하나 더 있었습니다. Lighthouse 13부터 일부 audit 키에 -insight 접미가 붙었습니다.
report.audits['prioritize-lcp-image'] // → undefined (구 키)
report.audits['lcp-discovery-insight'] // → 있음
구 키로 조회하면 에러 없이 빈 결과가 나옵니다. 처음 파싱했을 때 "해당 없음"으로 읽고 넘어갈 뻔했습니다. 지금은 두 이름을 모두 시도하는 파싱 스크립트를 두고 씁니다.
4. 자동 오픈 모달이 LCP를 잡고 있었다
홈 모바일 LCP 12.1초. LCP 요소는 페이지 콘텐츠가 아니라 진입 시 자동으로 열리는 What's New 모달의 <h2> 였습니다.
처음 판단은 "재방문자에게는 안 뜨니 측정 편향이고, 조치 불필요"였습니다. 확인해 보기로 하고, 감사 스크립트에 쿠키 주입 플래그를 넣었습니다.
# 첫 방문 전용 UI를 억제해 재방문자 기준으로 재기
pnpm perf -- --pages /ko/workspace/studio --cookie whatsNewSeenVersion=<버전>
같은 빌드에서 조건별 2회씩 측정했습니다.
| 조건 | 1회차 | 2회차 | LCP 요소 |
|---|---|---|---|
| 모달 억제 | 4808ms | 4064ms | 페이지 배너 텍스트 |
| 대조 | 3055ms | 13755ms | 13755ms 회차는 모달 h2 |
억제 조건은 안정적(4.1~4.8초)인데 대조는 양극단입니다. 모달이 LCP를 잡는 회차에서 약 +10초가 붙습니다.
여기서 제 판단이 절반만 맞았다는 게 드러났습니다. 재방문자에게 없는 건 사실이지만, 첫 방문자에게는 실재하는 10초짜리 LCP입니다. 그리고 그건 지표 문제만이 아닙니다 — 4G에서 페이지를 읽는 중에 오버레이가 10초 뒤에 튀어나온다는 뜻입니다.
더 중요한 발견은 단발 측정으로는 이게 안 보인다는 것입니다. 대조 1회차(3055ms)가 그날 측정한 모든 값 중 가장 좋았습니다. 그 회차만 봤다면 "홈은 문제없다"로 결론냈을 겁니다.
왜 상호작용 게이팅을 고르지 않았나
LCP 기록은 첫 사용자 입력(탭·클릭·키·스크롤) 시점에 멈춥니다. 그래서 "첫 상호작용에 모달을 열기"는 지표를 확실히 고칩니다. 검토했지만 채택하지 않았습니다.
폴백 타이머("N초 뒤엔 무조건 열기")를 넣으면 그 늦은 페인트가 다시 LCP 후보가 되어 수정이 무효화됩니다. 즉 "무조건 노출"과 "LCP 미오염"은 동시에 만족시킬 수 없습니다. 상호작용 없이 나가는 방문자는 그 방문에 안내를 못 보게 됩니다.
노출률을 지키는 쪽을 골랐습니다 — 모달을 더 일찍 열기. 청크가 홈 페이지 청크에 함께 실려 오면 늦은 별도 요청이 사라집니다. 대가는 홈 초기 JS +15KB gz이고, 대부분 사용자가 어차피 받게 되는 바이트입니다.
5. loading="lazy"가 영구 no-op인 그리드
브랜드 킷 설정 화면에서 타일을 CSS background-image로 그리고 있었습니다. background-image는 네이티브 lazy 대상이 아니니 <img loading="lazy">로 바꾸면 화면 밖 타일 전송을 미룰 수 있겠다고 생각했습니다. 바꾸고 측정했습니다.
alt="이미지 (3).png" loading=lazy 표시 77x77 원본 736x1296 top=435 [뷰포트내]
alt="PERSO Studio.drawio.png" loading=lazy 표시 77x77 원본 2431x2271 top=797 [화면밖]
스크롤 전 스토리지 이미지 요청: 2건
스크롤 후: 2건 (증가 0)
화면 밖 타일(top=797, 뷰포트 620)도 즉시 로드됐습니다. 크롬의 lazy 임계거리는 뷰포트 아래 수천 px이고, 78px 타일 그리드는 20장을 채워도 3행(~270px)이라 항상 임계 안에 들어옵니다. 이 규모에서 lazy는 영구 no-op입니다.
그래서 이 변경을 성능 개선으로 보고하지 않고 다시 규정했습니다 — 실효는 alt 텍스트(이전엔 background-image라 스크린리더에 아무 정보가 없었습니다)와 decoding="async"입니다. a11y 수정이지 성능 수정이 아닙니다.
정작 그 화면의 실제 문제는 다른 데 있었습니다.
| 표시 크기 | 원본 | 전송 |
|---|---|---|
| 88×88 | 736×1296 | 1293KB |
| 88×88 | 2431×2271 | 555KB |
타일 2장에 1847KB. 88px 슬롯에 MB 단위 원본을 그리고 있었고, 디코드 메모리만 장당 22MB입니다. 이건 lazy로 고칠 수 있는 종류가 아닙니다.
6. 바이트가 그대로면 순서를 바꿔도 편차에 묻힌다
수정을 배포하고 재감사했습니다.
| 페이지 | 지표 | 전 | 후 |
|---|---|---|---|
| inquiry | Speed Index | 19513ms | 4985ms (-74%) |
| project | desktop 점수 | 76 | 82~84 |
| templates | 모바일 LCP | 13659ms | 10762ms … 12686ms |
inquiry는 명확합니다. templates는 아닙니다 — 같은 커밋에서 10762ms와 12686ms가 나왔고, TTFB는 517ms와 1986ms로 4배 차이가 났습니다. 이 폭 안에서는 "2.9초 개선"을 주장할 수 없습니다.
이유는 단순합니다. templates에 한 것은 순서 재정렬이고 전송량 2839KB는 그대로입니다. 1.6MB를 8장이 나눠 받게 됐을 뿐이라, 대역폭 상황이 바뀌면 결과가 흔들립니다.
그래서 전후 비교 스크립트를 만들 때 "개선/악화" 라벨을 일부러 넣지 않았습니다. 숫자와 부호만 내고, 하단에 판정 규칙을 출력합니다.
> 델타에 "개선/악화" 라벨을 붙이지 않은 이유: 단일 측정 편차가 크다.
바이트를 줄이지 않은 변경(우선순위 재정렬 등)은 2회 이상 측정해
회당 편차보다 큰지 확인 후 판정할 것.
7. 측정하고 "하지 않기"로 끝낸 것
inquiry의 Speed Index 19.5초는 문의 폼이 지배했습니다. 폼은 이 페이지의 주 콘텐츠인데 서드파티 임베드라 하이드레이션 뒤에야 시작됩니다.
embed/v2.js 요청 10873ms → 폼 정의 JSON 11407~11655ms → 렌더 완료 11794ms
observed Speed Index 11539ms ← 폼이 그려진 시점과 일치
초기 HTML에 <link rel="preload">를 넣어 다운로드를 하이드레이션에서 분리했습니다. 이게 -74%를 만들었습니다.
다음 단계로 "서버 HTML에 <script async>를 직접 심어 실행까지 앞당기기"를 검토했습니다. 그 전에 bootup-time을 봤습니다.
793ms total / 237ms script js.hsforms.net/forms/embed/v2.js
이 237ms를 하이드레이션 창 안으로 끌어오면 앱 하이드레이션과 메인스레드를 다투게 됩니다. TBT/TTI가 되레 나빠질 수 있고, 이미 얻은 -74%에 비해 남은 이득은 작습니다. 보류하기로 했습니다.
판단 축이 하나 생겼습니다 — "무엇을 앞당기는가". preload는 다운로드만 앞당기므로 안전합니다. async는 실행까지 앞당기므로 그 스크립트의 main-thread 시간을 먼저 재야 합니다.
"측정했고, 그래서 하지 않기로 했다"도 산출물입니다. 측정값과 판단 근거를 남기는 것 자체가 다음 사람의 시간을 아낍니다.
8. 부분 증거로 부정 단정한 실수
이 세션에서 제가 틀린 방식은 하나로 수렴합니다. "없다 / 불가능하다"는 결론을 좁은 증거 하나로 내렸습니다.
아이콘 사용량. 아이콘 배럴 248개 중 실사용이 77개뿐이니 삭제로 청크를 줄일 수 있다고 보고했습니다. 근거는 iconName="X" 속성 형태 grep 하나였습니다. 데이터 객체의 iconName: 'X', 삼항, leftIcon/rightIcon prop을 전부 놓쳤습니다. 전수로 세니 239개가 참조되고 있었고, 진짜 미참조는 9~10개(12KB)였습니다. 청크가 69% 미사용인 건 사실이지만 그건 죽은 아이콘이 아니라 다른 페이지에서 쓰는 아이콘이고, 삭제로는 회수할 수 없습니다.
"FE 레버가 없다". 이미지 원본 크기 문제를 두고 "FE에서 손댈 수 없고 BE 썸네일이 유일한 해결"이라고 여러 번 말했습니다. 나중에 next.config.js를 열어 보니, 그 파일 주석에 원인 규명(remotePatterns의 서브도메인 미매칭)과 절감 실측(306KB PNG → 96px WebP 5.98KB, -98%)이 이미 적혀 있었습니다. 제가 쓰지 않은 주석도 아니었습니다.
셀렉터 0건. 폼이 렌더됐는지 확인하려고 #hubspotForm form을 조회했더니 0건이라 "미렌더"로 판단했습니다. 실제로는 컨테이너 안에 iframe이 생기고 폼은 그 안에 있었습니다. iframe 내부로 들어가니 hidden 필드 5개가 정상적으로 채워져 있었습니다.
세 건의 공통 구조는 이렇습니다.
"X가 없다/안 된다"를 보고하기 전:
1. 검색 패턴을 하나만 쓰지 않았는가 (속성 형태 · 객체 리터럴 · 삼항 · 별칭 prop)
2. 건드릴 파일의 기존 주석·ADR을 먼저 읽었는가
3. 존재 ≠ 실행 — 그 훅/함수를 이 호출부가 실제로 부르는가
4. 셀렉터 0건 = 부재가 아니라 스코프 밖(iframe · shadow DOM)일 수 있다
정리
선언과 실제를 대조한 표입니다.
| 선언한 것 | 실제 | 확인 방법 |
|---|---|---|
<Image priority> = eager + high priority |
eager만. fetchpriority 미부착 |
라이브러리 소스 + priorityHinted 체크리스트 |
webpackPreload 코멘트로 병렬 로드 |
런타임 미생성. 완전 무효 | prod 빌드 산출물 grep |
loading="lazy"로 화면 밖 전송 절감 |
작은 그리드는 no-op | 스크롤 전후 요청 수 |
| 모달은 재방문자엔 없으니 무해 | 첫 방문자에겐 +10초 | 쿠키로 조건 분리, 2회씩 |
| 아이콘 248개 중 77개만 사용 | 239개 참조. 미참조 9개 | 리터럴 전수 + import 식별자 |
남은 것 세 개를 원칙으로 적어 둡니다.
prop 하나로 끝나는 최적화가 가장 위험합니다. 비용이 싸서 검증을 건너뛰고, 실패해도 조용하고, 코드에는 확신에 찬 주석이 남습니다. 선언의 흔적을 산출물에서 찾은 뒤에 커밋하세요.
단발 측정으로 판정하지 마세요. 바이트를 줄이지 않은 변경은 회당 편차에 묻힙니다. 그날 가장 좋게 나온 값이 "문제없음"의 근거가 될 뻔했습니다.
"없다"는 가장 검증하기 쉬운 주장입니다. grep 한 번 더, 주석 한 번 더, 호출부 한 단계 더 내려가면 대부분 뒤집힙니다. 저는 이 세션에서 세 번 뒤집혔습니다.