Sangcho.log
dev

priority를 줬는데 fetchpriority가 없다 — 선언만 남은 최적화 2건, 측정이 뒤집은 판정 3건

16 min read|

전에 Lighthouse 27점을 65점으로 올린 기록을 썼습니다. 그때 마지막 절의 제목이 "측정 도구가 배신할 때"였는데, 이번에는 제가 도구를 배신했습니다 — 넣어둔 최적화 두 개가 선언만 있고 실제로는 아무 일도 하지 않고 있었습니다.

같은 코드베이스를 6페이지 × 2폼팩터로 다시 감사했습니다. 나온 것을 순서대로 적습니다.

페이지 모바일 LCP
studio 홈 47 12127ms
project 62 11362ms
templates 63 13659ms
inquiry 66 3346ms (SI 19513ms)

1. priorityfetchpriority를 붙이지 않는다

templates 페이지 LCP가 13.7초였습니다. lcpLoadDelay11107ms — 이미지가 다운로드를 시작하기까지 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 };
}

priorityfetchPriority완전히 별개의 파라미터입니다. 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 한 번 더, 주석 한 번 더, 호출부 한 단계 더 내려가면 대부분 뒤집힙니다. 저는 이 세션에서 세 번 뒤집혔습니다.