무거운 번들과 렌더 블로킹 걷어내기 — Lighthouse 60점대에서 90점대로
커진 JS 번들과 렌더를 막는 리소스가 초기 화면을 붙잡고 있었다. 코드 스플리팅·번들 축소·렌더 블로킹 제거·이미지/폰트 최적화로 Lighthouse 성능 점수를 60점대에서 90점대로 끌어올린 작업.
커진 JS 번들과 렌더를 막는 리소스가 초기 화면을 붙잡고 있었다. 코드 스플리팅·번들 축소·렌더 블로킹 제거·이미지/폰트 최적화로 Lighthouse 성능 점수를 60점대에서 90점대로 끌어올린 작업.
느린 초기 로딩과 렌더링을 잡은 작업이다. 첫 화면이 뜨기까지 눈에 띄게 느렸고, Lighthouse 성능 점수가 60점대에 머물러 있었다. 원인은 서버가 아니라 브라우저가 내려받고 파싱하는 쪽 — 커진 번들과 렌더를 막는 리소스에 있었다. 이 지연이 사용자 이탈로 이어질 만큼 심각해서 최우선으로 손봤다.
무작정 고치기 전에 측정부터 했다. Lighthouse로 점수와 항목별 지적을 보고, DevTools Performance로 타임라인을 훑으며 초기 로딩·렌더링에서 시간이 새는 구간을 짚었다.
크게 네 방향이 보였다. JS 번들이 너무 크고, 렌더를 막는 리소스가 앞단을 붙잡고 있고, 첫 화면에 필요 없는 코드까지 한꺼번에 내려가고, 이미지와 폰트가 최적화 없이 실려 있었다.
Lighthouse 점수는 랩 데이터다. 정해진 기기와 네트워크를 흉내 내서 한 번 돌린 결과인데, 실제 사용자의 기기와 네트워크, 캐시 상태는 훨씬 제각각이다. 그래서 총점만 보고 달리다 보면 점수에 잘 잡히는 항목 위주로 고치게 되고, 90점을 만들었는데 정작 체감은 그대로인 일이 생긴다.
총점 대신 지표별로 무엇이 무엇에 붙어 있는지를 보는 편이 낫다.
| 지표 | 느려지는 이유 | 손댈 곳 |
|---|---|---|
| LCP | 가장 큰 요소가 늦게 그려짐 | 렌더 블로킹, 이미지, 폰트 |
| TBT | 메인 스레드가 JS 파싱·실행에 묶임 | 번들 크기, 코드 스플리팅 |
| CLS | 늦게 도착한 리소스가 레이아웃을 밀어냄 | 이미지 크기 지정, 폰트 교체 |
이렇게 나눠두면 번들을 줄였는데 LCP가 그대로인 상황에서 당황할 일이 없다. 번들이 건드리는 건 TBT 쪽이니, LCP가 안 움직였다면 원인이 다른 데 있다는 뜻이다.
한 번에 하나씩 바꾸고 다시 재는 것도 중요하다. 네 가지를 한꺼번에 손대면 점수는 오르는데 무엇이 먹혔는지를 알 수 없다. 그러면 다음에 비슷한 상황이 와도 재사용할 게 없다.
첫 화면에 당장 필요하지 않은 것들을 갈라냈다. 라우트 단위, 그리고 무거운 컴포넌트 단위로 dynamic import / React.lazy를 걸어, 진입 시점에 꼭 필요한 코드만 먼저 내려가도록 했다. 모달이나 하단 영역처럼 나중에 등장하는 건 그때 로드한다.
다만 쪼갤수록 좋아지는 건 아니다. 눈에 보이는 대로 전부 갈라놓으면 요청 수가 늘고, 청크가 다른 청크를 부르는 워터폴이 생긴다. 갈라낼 값어치가 있는 건 첫 화면 경로에 없으면서 덩치가 큰 것들이고, 작은 컴포넌트를 쪼개서 아끼는 몇 KB보다 요청 하나가 더 비싸다.
지연 로딩에는 대가가 따른다. 모달을 lazy로 돌리면 사용자가 처음 열 때 그 순간 로딩이 보인다. 초기 로딩을 줄이려다 상호작용 쪽을 느리게 만드는 셈이다. 첫 화면이 그려지고 브라우저가 한가해진 뒤에 미리 받아두면 초기 payload는 줄이면서 이 지연도 피할 수 있다.
번들을 열어보니 실제로 쓰는 기능에 비해 과한 라이브러리가 몇 개 있었다. 가벼운 대안으로 바꾸거나 직접 구현으로 덜어냈고, tree-shaking이 먹도록 import 방식을 정리하고 중복 의존성을 걷어냈다.
교체 판단에는 기준이 필요했다. 라이브러리를 걷어내고 직접 구현하면 번들은 줄어들지만 엣지 케이스 처리와 유지보수가 우리 몫이 된다. 날짜나 통화 포맷처럼 로케일과 예외가 얽힌 영역은 특히 그렇다. 쓰는 기능이 그 라이브러리의 일부에 불과하고 그 범위가 분명히 좁을 때 정도가 교체할 만한 경우다.
import를 정리한다고 tree-shaking이 항상 먹지도 않는다. 패키지가 CommonJS로만 배포됐거나 sideEffects 표기가 없으면 걷히지 않고, 배럴 파일을 거쳐 import하면 모듈이 통째로 끌려온다. 앞의 둘은 패키지 쪽 사정이라 우리 코드를 고쳐서 해결되지 않는다. 안 되는 건 안 된다고 확인하고 넘어가는 것도 일이다.
첫 페인트를 막는 동기 스크립트와 CSS를 정리했다. 당장 필요 없는 스크립트는 뒤로 미루고, 위쪽 화면에 필요 없는 스타일은 나중에 불러오도록 나눴다.
스크립트를 미룰 때는 defer와 async를 구분해야 한다. async는 받는 대로 실행하기 때문에 순서가 보장되지 않는다. 서로 의존하는 스크립트에 걸면 간헐적으로 깨지는데, 네트워크 상태에 따라 재현되는 버그라 찾기가 고약하다. 순서가 필요한 곳에는 defer를 쓴다.
CSS 쪽은 더 밀어붙일 여지가 있지만 비용도 같이 커진다. 첫 화면에 필요한 스타일만 따로 인라인하면 확실히 빨라지는데, 그 목록이 화면이 바뀔 때마다 달라진다는 게 문제다. 추출을 자동화해두지 않으면 몇 달 뒤 실제 화면과 어긋나서 깜빡임이 생긴다.
이미지는 포맷과 크기를 맞추고, 화면 밖 이미지는 지연 로딩으로 돌렸다. 다만 가장 큰 이미지에는 지연 로딩을 걸면 안 된다. LCP 요소인 경우가 많은데 lazy를 걸면 브라우저가 그걸 늦게 발견해서 지표가 오히려 나빠진다. 접히는 선 위쪽은 미루는 게 아니라 우선순위를 올려주는 쪽이 맞다.
이미지에 폭과 높이를 명시해두는 것도 같이 필요하다. 이건 속도가 아니라 CLS 쪽 문제다. 크기를 모르면 브라우저가 자리를 비워둘 수 없어서 이미지가 도착하는 순간 아래 내용이 밀린다. 읽고 있던 사용자 입장에서는 느린 것보다 이게 더 거슬린다.
폰트는 preload와 font-display: swap으로 텍스트가 늦게 뜨는 걸 막았다. 여기도 맞바꾼 게 있다. swap은 대체 폰트로 먼저 그린 다음 교체하는 방식이라, 두 폰트의 글자 폭이 다르면 교체하는 순간 레이아웃이 흔들린다. 텍스트가 안 보이는 시간을 없애는 대신 한 번 출렁이는 걸 받아들인 것이다.
이 작업들을 거치며 Lighthouse 성능 점수가 60점대에서 90점대로 올라갔다. 숫자보다 체감이 확실했다 — 진입하면 첫 화면이 훨씬 빨리 뜬다. 네트워크가 느린 환경일수록 차이가 컸다.