지난번 포스트를 작성하고, 꽤 시간이 흘렀다.
이번 시즌에는 내가 잘못 알고 있던 점들과 새롭게 배운 사실들, 그리고 개선 포인트들을 기록해보려고 한다.
https://yeomyeom.tistory.com/148
First Load JS 축소 및 번들 최적화 - 시즌 1
회사 프로젝트의 개선 항목 중에서 입사를 하고 나서부터 오늘날까지 간곡히 해결하고 싶었던 문제가 있었다. 바로 홈 화면에서의 First Load JS를 줄이는 것이었다. 입사 당시, 회사 서비스의 속도
yeomyeom.tistory.com
(지난 시즌의 글은 위에서 확인할 수 있다.)
지난 글의 마지막을 보면, 더 이상 개선을 할 수 있을지 의문이 든다고 하면서 마무리를 했었다.
하지만, 이번 개선 작업을 마무리하고 난 후에 지난 시즌의 결과물을 보니, 어떤 점들을 개선해야 할지 보인다는 점이 놀랍다.

예전에 커피챗을 하면서 어렴풋이 기억에 남는 말이 있었다.
chunks를 main, framework, _app 처럼 3개로 chunks를 쪼갠게 아니라 더 쪼개지 않았다면 최적화되지 않았다는...
정확한 문장은 기억나지는 않지만 저런 늬앙스의 말씀을 해주셨다. 그때 당시에는 나의 내공이 깊지 못하여 그분의 의도를 완벽히 이해하지는 못했지만, 그 말을 꼭 기억을 하고 1년이 지난 지금에서야 이해를 할 수 있었다.
시즌 1의 결과물을 보면 First Load JS shared by all에는 chunks/framework, chunks/main, chunks/pages/_app, 그리고 other 이 있다. 큼직한 chunks는 단 3개뿐이었다. 이점을 이해하기 위해서 파고 들어가보았다.
그 전에 개선 전 상태가 어떤지 진단을 해보자.
개선 전 상태
_app.js의 Parsed size는 약 791 KB, Gzipped size는 약 248 KB로 큰 편이었다.

Parsed size와 Gzipped size는 성능에 아주 큰 영향을 미친다.
Parsed size
- 의미: Webpack이 불필요한 코드를 제거하고(Tree Shaking), 공백을 없애고 변수명을 줄인(Minification) 후, 브라우저가 실제로 읽고 해석해야 하는 크기입니다.
- 상태: Minified O, Gzip X.
- 영향: 사용자의 기기 성능에 영향을 줍니다.
- 이 크기가 클수록 브라우저가 코드를 해석(Parsing)하고 컴파일하는 데 시간이 오래 걸린다. (메인 스레드 부하 ↑)
- TBT (Total Blocking Time)와 직결된다.
Gzipped size
- 의미: Parsed Size 상태의 파일을 Gzip 알고리즘으로 압축하여 서버에서 클라이언트로 보내는 크기입니다.
- 상태: Minified O, Gzip O.
- 영향: 네트워크 속도에 영향을 줍니다.
- 이 크기가 작을수록 사용자가 파일을 다운로드하는 시간(TTFB ~ Download)이 빨라진다.
- LCP (Largest Contentful Paint)와 직결된다.
간단하게 정리해보면 이렇게 볼 수 있다.
| 종류 | 상태 | 무엇에 영향을 주는가? | 누구를 위해서 줄여야 하나? |
| Stat | 날 것 (Raw) | 개발자의 디스크 용량 | (참고용 지표) |
| Parsed | 압축됨 (Minified) | JavaScript 실행 속도 (TBT) | 저사양 기기 사용자 |
| Gzipped | 전송용 압축 (Gzip) | 다운로드 속도 (LCP) | 느린 인터넷 사용자 |
// TODO: 이 부분도 자세하게 보면 재미있는 구석이 많아서, 다음에 딥하게 새로운 포스팅으로 다뤄볼 것이다.
구글 크롬팀의 Alex Russel이 작성한 글을 바탕으로 아래와 같은 결론이 나온다.
"전 세계 평균적인 모바일 기기(Moto G4급)와 3G/4G 네트워크를 기준으로 역산했을 때, 5초 안에 인터랙션(TTI)이 가능하려면 JS는 약 170kB(Gzipped)이내여야 한다는 계산이 나옵니다."
https://infrequently.org/2017/10/can-you-afford-it-real-world-web-performance-budgets/
현재의 Gzipped size는 약 250 kB. 더 줄일 구석이 많다는 얘기이다.

1. _app.js를 줄여야 했던 이유
Next.js 페이지 로드 순서를 생각해보아야 한다.
- _app.js 다운로드 및 실행 (필수, 모든 페이지 공통)
- 페이지별 JS 다운로드 및 실행
- 화면 렌더링
_app.js는 모든 페이지의 진입점이다.
어떤 페이지를 반드시 먼저 로드하는 리소스이므로 크기가 클수록 모든 페이지의 초기 렌더링이 지연된다.
| 문제 | 설명 |
| Render Blocking | _app.js가 완전히 로드될 때까지 페이지 렌더링 불가 |
| FCP 지연 | First Contentful Paint가 _app.js 크기에 비례하여 지연 |
| 모든 페이지 영향 | 홈, 로그인, 상세페이지 등 모든 페이지의 진입 시간이 동일하게 느려짐 |
그러면 어떻게 해결해야할까?
방법 1: 코드 삭제
_app.js에 포함된 것들:
- Firebase Auth <- 로그인에 필수
- Sentry <- 에러 모니터링에 필수
- MUI <- UI 컴포넌트에 필수
- Tanstack Query <- 서버상태 관리에 필수
삭제할 수 있는 코드는 없었다.
방법 2: 청크 분리
삭제할 수 없다면, "분리해서 동시에(Parallel)" 로딩하는 전략을 선택했다.
HTTP/2 · HTTP/3 프로토콜은 여러 파일을 동시에 주고받는 데 최적화되어 있으므로, 이를 적극 활용했다.

| 이점 | 설명 |
| 병렬 다운로드 | HTTP/2, HTTP/3에서 여러 파일을 동시에 받음 |
| 파싱 분산 | 브라우저가 작은 파일들을 더 빨리 파싱 |
| 캐시 효율 | Firebase만 업데이트 시, firebase 청크만 다시 다운로드 |
| 우선순위 제어 | 중요한 청크 먼저 로드 가능 |
청크를 분리했을 때는 캐싱 효과도 적극적으로 얻을 수 있었다.
청크로 쪼갠 Firebase Auth 나, Sentry, MUI, Tanstack Query 등은 사실 거의 변경될 일이 없었다.
부분 업데이트 시 변경된 청크만 다시 다운로드하면 되어 재방문 성능이 향상될 수 있었다.
캐싱 전략
우리는 Google Cloud의 CDN 서비스를 사용하여 전 세계 사용자에게 빠르게 콘텐츠를 전송하고 있다.
기존 설정에서는 정적 리소스(JS, CSS, 이미지 등)의 CDN 캐싱 시간을 1시간(3600초)으로 설정해두고 있었습니다.
간혹 배포를 했는데 사용자가 계속 예전 버전의 화면을 보고 있던 불안감 때문이었다고 이해했다.
1시간으로 설정했을 때, 일어나는 일들을 분석해보았다.
사용자가 1시간 뒤에 다시 접속하면, 브라우저는 파일이 변경되었는지 확인하기 위해 서버에 요청(304 Not Modified 확인)을 보낸다.
데이터 전송은 없더라도 네트워크 왕복 시간(Round Trip Time)은 발생한다.
CDN 캐시가 만료되면 CDN은 원본 서버에서 다시 파일을 가져와야 하는 Cache Fill 과정이 발생하는데,
이는 컴퓨팅 비용과 전송 비용을 발생시킨다.
즉, 변하지 않는 파일을 매시간 변했는지 확인하는 과정은 낭비라고 판단을 했다.
하지만, 1년으로 설정하면 업데이트가 안되는 문제는 없을지 걱정이 되었었다.
이 걱정을 해결해 주는 것이 Next.js(Webpack)의 해시 기반 파일명이다.
앞서 _app-1234abcd.js를 보았던 것처럼 빌드한 파일들은 파일명에 해시값이 포함된다.
- 코드의 변경이 없다면, 파일명도 그대로 -> 브라우저는 1년동안 캐시된 파일을 사용함.
- 코드의 변경이 있다면, 파일명이 _app-5678efgh.js로 변경됨 -> 브라우저는 완전히 새로운 파일로 인식하여 새로 다운로드함.
따라서 정적 파일에 대해서 Immutable(불변) 전략을 취할 수 있었다.
파일의 내용이 바뀌면 이름이 변경되기 때문에 같은 이름의 파일은 영원히 내용이 같다는 것을 보장할 수 있기 때문이다.
JS, CSS, 이미지와 같은 정적 리소스는 Cache-Control: Public, max-age=31536000, immutable로 설정했다.
파일명에 해시가 있어서 배포 시 이름이 바뀌기 때문에, 최대한 길게 캐싱하여 재방문 속도를 극대화하도록 했다.
특히, splitChunks로 분리한 MUI, Firebase 같은 Vendor 청크는 변경 빈도가 낮아 캐시 효율이 매우 높다.
HTML 문서(Pages)는 Cache-Control: no-cache, no-store, must-revalidate으로 설정했다.
HTML 파일에는 예를 들어, 아래의 코드처럼 최신 JS 파일들의 경로를 담고 있다.
<script src="/_next/static/chunks/_app-1234abcd.js"></script>
그래서 HTML이 캐시가 되어버린다면 구버전 JS 파일을 찾게 되고, 이는 ChunkLoadError같은 에러를 발생시킬 수 있다.
따라서 HTML은 항상 최신 상태를 유지하도록 강제했다.
CDN을 설정할 때는 주의할 점이 있었는데, CDN 설정이 어플리케이션 헤더보다 우선한다는 점이었다.
또한 혹시 모를 ChunkLoadError를 방지하기 위해서 전역 에러 핸들러를 추가하여,
사용자 경험을 해치지 않도록 window 레벨에서 에러를 감지하고 1회 새로고침을 발생시켜 최신 리소스를 받아오도록 했다.
결론적으로 재방문 로딩 속도가 향상되고, 비용도 절감되고, 배포의 안정성까지 잡을 수 있었다.
결과
splitChunks 전략 적용을 통해 초기 로딩 시, 가장 먼저 내려받는 핵심 파일인 _app.js의 크기를 획기적으로 감량했다.
| 지표 (Size Type) | 최적화 전 (Before) | 최적화 후 (After) | 감소량 (Reduction) | 감소율 (%) |
| Stat Size | 2.56 MB | 575.89 KB | -1.98 MB | 77.5% |
| Parsed Size | 791.83 KB | 207.56 KB | -584.27 KB | 73.8% |
| Gzipped Size | 248.24 KB | 66.73 KB | -181.51 KB | 73.1% |
Gzipped Size 감소 (-73.1%): 네트워크 로딩 속도 향상
- 248KB였던 파일을 66KB로 줄임으로써, 다운로드 시간이 약 1/4 수준으로 단축되었다.
- 특히 느린 네트워크 환경(3G/LTE)에서 사용자가 화면을 보기까지 걸리는 시간(FCP)을 크게 앞당겼다.
Parsed Size 감소 (-73.8%): 메인 스레드 부하 감소 (TBT 개선)
- 브라우저가 압축을 풀고 실제로 실행해야 하는 코드가 약 791KB에서 207KB로 줄었다.
- 자바스크립트 해석을 위해 메인 스레드가 멈추는 시간(Blocking Time)을 최소화하여,
초기 로딩 시 버벅임 없이 즉각적인 반응이 가능해졌다.



이렇게 First Load JS 축소 및 번들최적화의 시즌 2도 마무리가 되었다.
시즌 1의 소감을 말할 때, 시즌 2의 작업이 시즌 1만큼의 효과를 불러올지 모르겠다고 했었다.
하지만, 막상 시즌 2를 진행하다보니 시즌 1에 비해 더 많은 것들을 배울 수 있었다고 생각한다.
시즌 3가 있을지는 현재의 나의 내공으로는 알 수가 없다.
하지만, 분명 시즌 3가 될만한 컨텐츠가 있다고 생각하고 항상 배우려는 자세로 앞으로 나아갈 것이다.
참고자료
'Front-End' 카테고리의 다른 글
| First Load JS 축소 및 번들 최적화 - 시즌 1 (2) | 2025.11.18 |
|---|---|
| 리렌더링 최적화 - 2편 (0) | 2025.02.17 |
| 리렌더링 최적화 - 1편 (2) | 2025.01.26 |
| 토스페이먼츠 결제 연동하기 (0) | 2025.01.14 |
| [TIL] LCP 최적화 도전하기..! (1) | 2024.10.11 |