Lighthouse 27점에서 65점까지 — 측정 도구부터 만든 성능 최적화
프로덕션 페이지의 Lighthouse 모바일 점수가 27점이라는 리포트를 받았습니다. 이럴 때 가장 하기 쉬운 일은 아는 처방을 순서대로 적용하는 것입니다 — 이미지 최적화, 코드 스플리팅, 폰트 preload. 하지만 그렇게 고친 것 중 무엇이 실제로 점수를 올렸는지는 끝까지 알 수 없습니다.
그래서 첫 커밋은 최적화가 아니라 측정 스크립트였습니다. 결과적으로 이 순서가 이 작업에서 가장 중요한 결정이었습니다. 실제 병목 셋 중 하나는 우리 코드가 아니었고, 하나는 "이미 고쳤다"고 믿었던 것이 배포 환경에서만 깨져 있었고, 하나는 눈으로 짐작한 요소가 아예 LCP가 아니었기 때문입니다.
0. 측정: 추측을 지우는 도구
pnpm perf 한 방에 페이지별 모바일(4G 스로틀)·데스크톱 리포트가 나오게 했습니다. 핵심은 인증이 필요한 페이지도 측정된다는 점입니다.
// scripts/perf/lighthouse-audit.mjs
const DEFAULT_PAGES = ['/ko/login', '/ko/workspace/studio', '/ko/workspace/studio/project'];
const AUTH_FREE = new Set(['/ko/login', '/en/login']);
// dev-api oauth2 토큰을 자동 발급해 Cookie 헤더로 전달.
// 자격증명·토큰은 출력하지 않고, 헤더는 임시 파일로 넘긴 뒤 삭제한다.
이 도구는 뒤에서 두 번 저를 구했고, 한 번은 배신했습니다(마지막 절에서 다룹니다).
첫 측정으로 나온 병목은 셋이었습니다.
| 병목 | 규모 | 소관 |
|---|---|---|
| 서드파티 마케팅 스크립트 로더 | 메인스레드 6.3초 점유 | 코드 아님 (태그 매니저) |
| 아이콘 공유 청크 | 638KB | 코드 |
| 웹폰트 (한글 풀세트 3중량) | ~810KB | 코드 |
1. 가장 큰 병목이 코드가 아닐 때
TBT 3345ms의 주범은 태그 매니저가 주입하는 외부 CTA 스크립트였습니다. 저장소를 다 뒤져도 이 스크립트는 나오지 않습니다 — 마케팅 팀이 GTM 컨테이너에서 관리하는 태그니까요.
해결도 코드가 아니라 GTM에서 했습니다. 태그를 첫 사용자 인터랙션까지 지연 로드하는 스텁으로 교체하는 것입니다.
// GTM 태그 — 원래 스크립트를 즉시 로드하지 않고 첫 인터랙션에 붙인다
['pointerdown', 'pointermove', 'touchstart', 'wheel', 'keydown'].forEach((ev) =>
addEventListener(ev, loadHubSpot, { capture: true, once: true })
);
효과는 즉각적이었습니다. 모바일 27 → 53(TBT 3345 → 207ms), 데스크톱 66 → 98(LCP 7.8s → 0.9s). 코드 한 줄 안 바꾸고 얻은 점수입니다.
여기서 한 번 밟은 함정이 있습니다. 처음 스텁의 이벤트 목록에 scroll이 있었습니다. 그리고 프로젝트 목록 페이지는 라우트 진입 시 스크롤을 상단으로 리셋하고, 가상화 리스트가 스크롤을 프로그래매틱하게 건드립니다. 결과 — 사용자가 아무것도 안 했는데 게이트가 뚫려 스크립트가 로드됐습니다. 트레이스에서 추적 픽셀 요청을 보고 알았습니다.
scroll → wheel로 교체하니 해결됐습니다. 사용자의 실제 스크롤은 wheel·touchstart·pointermove가 이미 커버하고, 프로그래매틱 scrollTo()는 wheel을 발화하지 않습니다. 그 페이지 모바일 점수는 49 → 64(TBT 686 → 187ms)로 올랐습니다.
지연 로드 게이트에
scroll리스너를 넣지 말 것. "첫 인터랙션"을 뜻하려면 사용자 입력 장치 이벤트만 써야 합니다.scroll은 입력이 아니라 결과이고, 코드도 그걸 발생시킵니다.
2. 트리셰이킹이 안 되는 아이콘 — 원인이 문법에 있을 때
아이콘 청크는 638KB였습니다. "쓰는 아이콘만 남기면 되지 않나?" — 안 됩니다. 디자인 시스템의 아이콘 컴포넌트가 이렇게 생겼기 때문입니다.
import * as IconIndex from './icons'; // 250개 전부
const Icon = IconIndex[iconName]; // 문자열 동적 조회
번들러는 iconName에 어떤 문자열이 올지 정적으로 알 수 없으므로 전부 포함해야 합니다. 트리셰이킹이 실패하는 게 아니라, 애초에 성립할 수 없는 형태입니다(실제 사용은 72개뿐이었습니다).
API를 name 문자열에서 컴포넌트 참조로 바꾸는 게 정공법인데, 이건 all-or-nothing 대공사입니다. import *가 한 곳이라도 남으면 이득이 0이고, 아이콘 이름을 public prop으로 받는 primitive들(Alert·ListItem 등)은 breaking change가 됩니다. 그래서 이번엔 개별 파일 크기를 공략했습니다. 진짜 원인은 문법이 아니라 병리적인 SVG 몇 개였습니다.
flag_es.svg— 520KB. 국장(coat of arms)이 579개 path로 그려져 있었습니다. 24×24로 렌더되는 아이콘에서요.flag_in.svg— base64 래스터 이미지가 SVG 안에 임베드돼 있었습니다.
이 둘은 오픈소스 표준 국기(MIT)의 간소화본을 가져와 디자인 시스템의 원형 크롭 컨벤션(rx=12, 24×24)으로 이식하고, 나머지는 svgo를 돌렸습니다. 소스 836KB → 96KB, 청크 638KB → 300KB(뒤이은 국기 4종 추가 정리로 254KB raw).
svgo에는 함정이 하나 있습니다.
# removeViewBox:false 필수 — 기본 preset이 viewBox를 지워 로고/비정사각 아이콘 크롭을 깨뜨린다
npx svgo --multipass -p 2 --disable=removeViewBox icon.svg
그리고 아이콘 컴포넌트가 size 지정 시 viewBox를 0 0 24 24로 강제하므로, 원본이 512×342 좌표계라면 24-space 안에 맞춰 그려야 합니다.
<g transform="translate(-6 0) scale(0.0702)"> <!-- 24/342 -->
검증은 눈으로. 24px·96px 두 크기에서 before/after 스크린샷을 대조했습니다. 파일 크기가 목적이 아니라 "같아 보이면서 작아지는 것"이 목적이니까요.
3. 폰트 810KB → 140KB, 그리고 배포에서만 터진 404
한글 웹폰트는 글자 수 때문에 무겁습니다. 3중량 풀세트가 ~810KB였습니다. 답은 알려져 있습니다 — dynamic subset: 폰트를 유니코드 구간별로 쪼개고 @font-face마다 unicode-range를 선언해, 브라우저가 실제로 쓰인 글자가 속한 조각만 내려받게 하는 것.
3중량 × 92분할 = 276개 woff2로 갈았습니다. 실측 결과 페이지당 11개 정도(10~20KB)만 로드됩니다. 초기 폰트 비용 ~810KB → ~140KB.
배포하고 나서 폰트가 전부 시스템 폰트로 뜨는 걸 발견했습니다. 서브셋 276개 전량 404.
원인은 이 저장소의 알려진 함정이었습니다. 이 앱은 assetPrefix(경로 prefix)를 붙여 서빙되는데, 플레인 CSS의 url('/fonts/...')에는 그 prefix가 자동으로 붙지 않습니다. 브라우저는 루트 /fonts/...를 요청하고, nginx에는 그 경로에 대한 이 앱 매칭이 없어 다른 upstream으로 갑니다.
여기서 소름 돋는 대목 — 이 버그는 그전부터 있었습니다. 구 풀 폰트는 fallback upstream 쪽에도 같은 파일이 있어서 우연히 200으로 응답하고 있었을 뿐입니다. 서브셋 경로는 거기에 없으니 비로소 404로 드러난 겁니다.
진단 결정타: 삭제한 구 폰트 /fonts/Pretendard-Regular.woff2 가 여전히 200
→ 이 앱이 아니라 fallback upstream이 서빙하고 있다는 증거
플레인 CSS로는 JS 함수를 호출할 수 없으니 prefix를 붙일 방법이 없습니다. vanilla-extract의 globalFontFace로 옮겨 URL을 직접 조립했습니다.
// app/styles/fonts.css.ts
WEIGHTS.forEach(({ file, weight }) => {
PRETENDARD_SUBSET_RANGES.forEach((unicodeRange, index) => {
globalFontFace('Pretendard', {
fontWeight: weight,
fontDisplay: 'swap',
src: `url(${prefixStaticBaseUrl}/fonts/woff2-dynamic-subset/${file}.subset.${index}.woff2) format('woff2')`,
unicodeRange,
});
});
});
layout.tsx의 import '@/styles/fonts.css'는 모듈 해석상 fonts.css.ts로 그대로 resolve되니 소비처는 무변경입니다. 검증은 prefix를 넣은 빌드에서 산출 CSS를 세는 것으로 했습니다 — URL 276개 전부 prefix 부착, 무prefix 잔여 0.
로컬에서는 절대 재현되지 않는 클래스의 버그입니다. dev는 assetPrefix가 비어 있으니까요.
public/자산을 CSSurl()로 참조하는 변경은 배포 환경에서 확인해야 하고, 그때 "삭제한 구 파일이 여전히 200인지" 를 함께 봐야 합니다.
여기까지가 1차입니다. studio 홈 모바일 27 → 65점(FCP 8.9→4.6s, LCP 19.1→5.5s, TBT 3345→19ms, 전송 2188→1324KB).
4. 초기 번들의 미사용 JS — 렌더도 안 되는데 실려 있는 것들
2차는 "초기 번들에 있는데 실제로 안 쓰이는 것"을 찾는 작업입니다. 여기서도 도구를 먼저 만들었습니다. 번들 분석기 트리맵을 파싱해 초기 번들 여부를 판정하는 스크립트입니다.
// scripts/perf/analyze-bundle.mjs
const data = JSON.parse(html.match(/window\.chartData\s*=\s*(\[[\s\S]*?\]);/)[1]);
// 청크의 isInitialByEntrypoint에 true가 하나라도 있으면 초기 번들
const isInitial = (c) => Object.values(c.isInitialByEntrypoint || {}).some(Boolean);
측정 자체에 함정이 셋 있었습니다. 전부 "틀린 숫자를 자신 있게 보고할" 종류입니다.
- prod env로 빌드해야 한다 —
NEXT_PUBLIC_ENV=production ANALYZE=true. 개발 툴 게이트 때문에 dev 빌드는 실사용자 번들과 다릅니다. - 워크트리에서
node_modules를 심링크하면 안 된다 — 다른 체크아웃의 워크스페이스 패키지 소스로 해석돼, 이미 최적화한 아이콘이 옛 크기로 잡혔습니다. - 캐시된 트리맵은 stale — 전날 산출물이 최적화 머지 이전 빌드라, 이미 뺀 모듈이 아직 초기 번들에 있는 것처럼 보고할 뻔했습니다. 재사용 전에 산출물 생성 시각과 머지 이력을 대조해야 합니다.
찾아낸 것들:
① 스피너 하나가 애니메이션 라이브러리 75KB를 11개 진입점에 끌고 왔다. 디자인 시스템 Loading 컴포넌트가 lottie를 정적 import하고 있었고, 그 스피너는 9곳 이상에서 쓰입니다. 렌더러를 분리해 React.lazy로 지연 로드하니 진입점 11 → 0. fallback은 null로 두고 치수 wrapper는 유지해 CLS는 0입니다.
② 개발용 툴바가 프로덕션 번들에 102KB 있었다 — 렌더도 안 되면서. 이게 이 작업에서 가장 배운 게 많은 항목입니다. 게이트는 분명히 걸려 있었습니다.
// 이 코드는 prod에서 절대 렌더되지 않는다. 그런데 번들에는 남는다.
{['development', 'qa'].includes(process.env.NEXT_PUBLIC_ENV) && <DevTool />}
원인이 두 겹입니다.
.includes()는 빌드타임 상수폴딩이 안 된다. 번들러는process.env.X를 문자열 리터럴로 치환하는데,===비교는 그 자리에서false로 접혀 dead branch가 제거되지만.includes()는 배열 메서드 호출이라 접히지 않습니다. 실제로 같은 파일의 다른 툴은=== 'development'라서 prod에서 자동 제거되고 있었습니다.- 모듈 최상위 side-effect가 있으면 tree-shaking도 안 된다. 그 라이브러리는 import되는 순간 스타일을 주입합니다. 번들러는 "import했지만 안 쓴다"고 판단해도 side-effect 때문에 지울 수 없습니다.
즉 ===로 고쳐도 안 빠집니다. dead-code 제거가 아니라 실제 코드 분할이 필요합니다. 그런데 여기서 세 번째 함정이 나왔습니다 — 이 컴포넌트를 렌더하는 곳이 Server Component(layout.tsx)였습니다. Server Component에서 dynamic(ssr:false)는 금지고, ssr:false 없는 dynamic()은 RSC에서 client 코드 분할이 안 돼 초기 번들에 그대로 남습니다(직접 시도해서 확인했습니다). 답은 얇은 client 래퍼입니다.
'use client';
import dynamic from 'next/dynamic';
const Agentation = dynamic(() => import('agentation').then((m) => m.Agentation), { ssr: false });
export default function DevOverlay() {
if (!['development', 'qa'].includes(process.env.NEXT_PUBLIC_ENV)) return null;
return <Agentation />;
}
prod 초기 번들 102KB → 0.
③ 열지도 않은 검색 모달이 캐러셀 라이브러리 84KB를 전 라우트에 실었다. 전역 다이얼로그 라우터가 대부분의 모달은 dynamic으로 갖고 있었는데 검색 모달만 정적 import였고, 그 안의 아바타 섹션이 swiper를 씁니다. 체인 하나로 모든 페이지가 84KB를 받고 있었습니다. 다이얼로그 라우터 한 곳만 고쳐서 해결됐습니다 — 검색 버튼은 스토어 오프너(openModal({ type: 'SearchModal' }))만 쓰므로 컴포넌트를 import하지 않기 때문입니다.
누적: lottie 75 + 국기 10 + 개발툴바 24 + swiper 34 + 무거운 모달 12 ≈ ~155KB gz 감량.
한 가지는 의도적으로 남겼습니다. 파일 크기 상수를 컴포넌트 모듈에서 import하는 훅이 있어서, 그 모달을 dynamic으로 바꿔도 모듈이 초기 번들에 붙잡혀 있었습니다. 상수 추출이 선행돼야 하는 6KB — 지금 하면 이득 없이 diff만 커집니다.
5. LCP 요소를 두 번 잘못 짚었다
이제 LCP(Largest Contentful Paint)입니다. 부끄럽지만 유용한 기록이라 그대로 씁니다. 두 번 연속 틀렸습니다.
- 오타겟 1: 화면에서 가장 커 보이는 아바타 캐러셀에
priority를 넣었습니다. 실제 LCP는 그 위에 있는 최근 영상 카드였습니다(리포트상 top 좌표 672 vs 969). - 오타겟 2: 이번엔 카드에 넣었습니다. 그런데 잘못된 소비처에 넣었습니다.
두 번째가 더 교훈적입니다. CardItem 하나를 두 곳이 공유하고 있었습니다.
CardItem ←─ ProjectsList (Swiper) … 홈 "최근 영상"
←─ CardList (useWindowVirtualizer) … /project 상세 그리드
가상화 그리드 쪽에만 priority를 전달했으니, 홈의 첫 카드는 여전히 loading="lazy"였습니다. 코드는 머지됐지만 홈 LCP는 1픽셀도 안 움직였습니다.
교훈: LCP 요소는 눈으로 추정하지 말고 리포트의 node path와 snippet으로 특정한다. 셀렉터가 div.swiper-slide > …면 홈(Swiper)이고, virtualizedRow[0] > DIV[3]면 그리드입니다. 후자를 보고 알게 된 사실이 하나 더 있습니다 — LCP 요소는 첫 행의 col3 카드였습니다. 첫 카드 하나에만 priority를 주면 못 잡습니다.
// 소비처마다 "첫 요소"의 정의가 다르다
priority={index === 0} // 홈: Swiper map
priority={virtualRow.index === 0} // 그리드: 첫 행 전체(LCP가 col0이 아니었다)
// next/image는 priority와 loading 동시 지정 시 경고 → priority면 loading을 생략
{...(priority ? { priority: true } : { loading: 'lazy' as const })}
6. priority의 천장 — resourceLoadDelay가 LCP의 90%일 때
priority를 정확한 요소에 넣고도 프로젝트 목록 LCP는 7.5초였습니다. phase를 분해해 보니 답이 나왔습니다 — resourceLoadDelay가 LCP의 약 90%. 이미지가 느리게 다운로드되는 게 아니라, 요청 자체가 아주 늦게 시작되고 있었습니다.
이유는 구조적입니다.
HTML 도착 → JS 청크 다운로드 → 가상화 마운트 → <img> 생성 → 그제야 요청 시작
↑ priority는 여기서부터만 효력
목록이 dynamic(ssr:false) + window 가상화라 이미지 엘리먼트가 클라이언트 JS 이후에야 존재합니다. priority(eager + fetchpriority=high)는 엘리먼트가 생긴 다음의 우선순위일 뿐이니, 이 구조에서는 천장이 있습니다.
그래서 요청 시작 시점 자체를 앞으로 당겼습니다. 목록은 이미 서버에서 prefetch하고 있으니, 그 데이터에서 첫 행 썸네일 URL을 읽어 서버가 <link rel=preload as=image>를 방출하면 됩니다. 클라이언트 JS를 기다리지 않습니다.
export function preloadFirstRowThumbnails(content) {
if (!content?.length) return;
for (const item of content.slice(0, MAX_FIRST_ROW)) {
let href = null;
try {
href = getImagePath(item.thumbnailPath);
} catch {
// preload는 best-effort — URL 조립이 실패해도 렌더를 절대 깨지 않고 스킵
}
if (href) ReactDOM.preload(href, { as: 'image', fetchPriority: 'high' });
}
}
성립 조건이 하나 있습니다. preload의 href가 실제 img src와 정확히 일치해야 합니다. 이 썸네일은 원격 raw URL(unoptimized)이라 일치했습니다. 만약 이미지 최적화를 거쳤다면 실제 src는 /_next/image?url=...이 되어 preload가 미스나고, 그러면 같은 이미지를 두 번 받는 순손실이 됩니다.
측정 결과(배포 후 데스크톱): Performance 56 → 72, LCP 7562ms → 3486ms(−54%), 지배 원인이던 resourceLoadDelay 13568ms → 66ms(−99.5%).
그리고 여기서 멈췄습니다. 남은 병목은 썸네일 원본이 547KB PNG라는 것인데, FE가 할 수 있는 게 없었습니다. 스토리지가 on-the-fly 변환을 지원하지 않고(쿼리 파라미터를 붙여도 동일 원본을 반환하는 걸 실측했습니다), 이미지 최적화 재활성화는 별개의 회귀 위험이 있습니다. 업로드 시점에 작은 WebP를 만드는 BE 작업이 정답입니다. 모바일에서는 이 547KB가 4G 대역폭에서 JS와 경합하므로, preload 장수를 늘리는 것도 그 전에는 위험합니다.
최적화에서 제일 어려운 판단은 "여기서 멈추고 남은 건 다른 팀 몫으로 넘긴다"입니다. FE에서 억지로 더 짜내면 대개 회귀를 사고 이득은 작습니다.
7. 측정 도구가 배신할 때
마지막으로, 앞서 예고한 배신입니다. 인증이 필요한 페이지를 봇 토큰으로 측정하면 데이터를 못 불러와 에러 폴백이 렌더되는데, 그러면 에러 메시지 <p> 태그가 LCP 요소로 잡힙니다. before/after 둘 다 같은 에러 상태이니 점수 비교는 무의미하고, 심지어 "악화"로 보이기도 합니다.
summary의 점수만 보면 정확히 오판하게 됩니다. 그래서 인증 라우트를 측정할 때는 리포트의 실제 LCP 요소 snippet을 반드시 확인합니다.
# LCP 요소가 무엇으로 잡혔는지 확인 — <p>면 그 측정은 무효
jq '.audits["lcp-breakdown-insight"].details.items[] | select(.type=="node") | .snippet' \
perf-reports/<ts>/*.report.json
정리
숫자로는 studio 홈 모바일 27 → 65점, 초기 번들 ~155KB gz 감량, 프로젝트 목록 LCP −54%입니다. 하지만 실제로 남는 교훈은 대부분 숫자가 아니었습니다.
- 측정 도구를 먼저 만든다. 가장 큰 병목이 우리 코드가 아닐 수도 있고, 이미 고쳤다고 믿는 것이 배포에서만 깨져 있을 수도 있습니다.
- 트리셰이킹이 안 되는 형태를 알아본다. 문자열 동적 조회 레지스트리는 번들러가 손댈 수 없습니다. API를 못 바꾸면 개별 파일 크기를 공략합니다.
- env 게이트는
===로 쓴다..includes()는 상수폴딩되지 않고, 모듈 side-effect가 있으면===로 고쳐도 안 빠집니다 — 그때는 진짜 코드 분할. - 로컬에서 재현 불가능한 클래스를 구분한다. assetPrefix·nginx 경계·CDN fallback은 배포에서만 드러나고, "지운 파일이 아직 200"이 결정적 단서입니다.
- LCP 요소는 리포트로 특정한다. 커 보이는 것에
priority를 주는 건 추측이고, 저는 두 번 틀렸습니다. 공유 컴포넌트라면 어느 소비처인지까지 봐야 합니다. - phase를 분해한다. load-delay가 지배적이면 다운로드가 아니라 요청 시작 시점이 문제이고, 그건 이미지 속성이 아니라 렌더 아키텍처의 문제입니다.
- 지연 로드 게이트에
scroll을 쓰지 않는다. 코드도 스크롤을 발생시킵니다.
성능 작업이 "아는 처방 적용하기"가 아니라 "측정 → 원인 규명 → 최소 수정 → 재측정"의 루프라는 건 다 아는 얘기입니다. 실제로 해보고 배운 건, 저 루프에서 가장 자주 실패하는 지점이 측정과 원인 규명 사이 — 리포트를 자기 직관으로 번역하는 그 한 걸음이라는 것이었습니다.