이번에는 리액트 프로젝트에서 내가 진행했던 번들 사이즈 최적화에 대해 이야기 해보려 합니다.
그렇다면 왜 나는 번들 사이즈를 최적화하려 했을까?
우선, 우리 서비스를 프로덕션 레벨에서의 성능을 확인해보기 위해 build를 해보았습니다.
위 스크린샷을 보면 root 밑의 assets/index-UgkYlaLr.js 하나의 파일로 번들 파일 전체를 차지하고 있는 것을 볼 수 있고, 밑에 스크린샷의 로그에서는 index-UgkYlaLr.js의 크기가 704.19KB인걸로 매우 크다는 걸 알 수 있죠. 로그에 나와있는 경고에도 500KB가 넘는다고 알려주며 코드 스플리팅을 해서 사이즈를 줄이라고 권장하고 있습니다.
코드 스플리팅이란 무엇인가?
코드 스플리팅은 말그대로 한국어로 번역하면 코드 분할입니다. 우리가 코드 분할을 해야 하는 이유는 웹 페이지의 렌더링과 관련이 있습니다.
우선 우리가 웹 페이지를 보기까지 아주 크게 바라본다면
1. 서버와 연결
2. 서버와 통신
3. 응답 파일 해석
4. 웹 페이지 그리기
위 4가지의 과정으로 이루어지게 됩니다. 간단하게 보면 아래 그림처럼 볼 수 있겠네요.
서버와 연결 / 서버와 통신 / 웹 페이지 그리기는 추후에 "검색창에 www.google.com을 입력하면 어떤 일이 일어날까?"라는 주제로 좀 더 자세히 다뤄보고 현재 글에서는 응답 파일 해석 부분을 집중해서 보려합니다.
응답 파일 해석
DOM 생성
모든 브라우저는 서버에서 HTML을 응답받으면 먼저 브라우저가 이해할 수 있는 방식으로 나누는데요, 이러한 과정을 파싱이라고 합니다. 웹 페이지를 로딩하는 가장 첫번째 과정이죠.
브라우저는 HTML을 분리하여 DOM(Document Object Model)이라고 불리는 트리를 만듭니다. DOM은 웹 페이지를 표현하는 뼈대이자 JavaScript를 통해 동적으로 변경할 수 있는 API를 제공합니다. DOM을 다 만들었다면 브라우저는 DOMContentLoaded라는 브라우저의 이벤트를 발생 시키는데요, 이 이벤트를 최대한 빨리 발생시키는 것이 좋은 웹 성능을 위한 조건이라고 합니다.
CSSOM 생성
웹 페이지는 HTML만 있는 것이 아니죠. 보통 HTML은 CSS와 JS도 포함하고 있습니다. 여기서 브라우저는 HTML을 파싱하면서 <link> 태그를 만나면 요청되는데, DOM과 마찬가지로 CSS도 브라우저가 이해할 수 있는 형식인 CSSOM(CSS Object Model) 이라고 불리는 트리를 만듭니다.
DOM이 웹 페이지의 뼈대라면, CSS는 웹 페이지를 꾸미는 옷이라고 볼 수 있을 것 같아요. 색상, 크기, 위치, 배치 등을 결정하는 정보들이 있고 CSSOM 생성은 다른 스레드에서 이루어져서 DOM 생성 과정을 방해하지 않는다고 합니다.
JavaScript 실행하기
이제 우리가 스플리팅을 해야 하는 가장 중요한 부분입니다. JavaScript는 브라우저가 HTML 파싱 중 <script> 태그를 만다면 요청이 되는데요, JS는 CSS와 다르게 코드를 해석하고 실행이 완료 될 때까지 DOM 생성을 멈춰 버립니다. JS가 너무 커서 실행이 오래 걸린다면 그 만큼 HTML 파싱과 DOM 생성이 지연되고, 성능에도 안 좋은 영향을 끼치게 되겠죠? 그래서 우리는 async, defer등을 사용하여 최적화 할 수 있습니다.
Render Tree로 합치기
Html, CSS, JS를 처리하여 DOM / CSSOM을 만들었다면, 브라우저에게 최종적으로 어떤 요소를 어떻게 그릴지 알려줘야 하는데, 이를 위해 DOM과 CSSOM을 합쳐서 Render Tree를 만듭니다.
Render Tree는 브라우저가 DOM과 CSSOM을 합쳐 최종적으로 그려야 할 요소와 스타일만 계산합니다. CSS와 JS는 render-blocking resourse라고도 하는데, JS는 DOM의 생성 과정을 늦추고, CSS는 CSSOM이 모두 생성돼야 Render Tree를 만들 수 있기 때문이라고 합니다. 게다가 CSSOM은 JS의 실행을 멈추기도 한다죠. CSS와 JS 파일이 너무 많거나 크면 Render Tree를 만드는 시점이 지연되어 웹 로딩 속도가 늦어지게 되는데 이러한 render-blocking resourse를 최대한 제거하거나 최적화 하는 것은 웹 성능을 위해 아주 중요한 작업입니다.
이후 진행되는 Reflow, Repaint, Composite 부분은 추후에 더 자세히 다루도록 하고, 여기서 중요한 것은 render-blocking resourse를 최적화 하는 것에 우리는 집중해야 합니다.
코드 스플리팅 그거 어떻게 하는건데?
JS는 CSS와 다르게 코드를 해석하고 실행이 완료 될 때까지 DOM 생성을 멈춰 버립니다. 이 말은 즉, JS의 크기가 크다면 그 만큼 코드를 해석하고 실행이 완료되는데 시간이 오래 걸린다는 뜻이고, DOM 생성을 더욱 늦춰 유저에게 빠르게 페이지 제공을 할 수 없겠죠. 특히나 리액트와 같은 SPA로 개발된 프로젝트는 빌드하면 하나의 JS 파일로 번들이 됩니다. 그렇다면 이 하나의 JS 파일의 크기가 어마어마하겠죠..? 이를 해결하는 방법으로는 코드 스플리팅이 있습니다. 그렇다면 코드 스플리팅은 어떻게 하는 걸까요?
Dynamic Import()
자바스크립트는 전역 스코프만 가능했던 es5에서 es6로 넘어오면서 모듈화가 가능해졌습니다. 흔히 아는 import, export를 통해 가져오고 내보낼 수 있는데요. 여기서 우리는 `const {useDebounce} = await import('./hooks');`와 같이 동적 임포트 방식을 사용할 수 있습니다. 이러한 Dynamic Import는 Promise를 반환하며, async/await을 통한 사용도 가능합니다. 하지만 주의할 점으로는 import() 는 함수 호출과 문법이 유사해 보이지만 함수 호출이 아니며, super()처럼 괄호를 사용하는 특별한 문법 중 하나입니다. 따라서 import를 변수에 복사하거나 call/apply 등의 사용은 불가능합니다.
하지만 Dynamic Import를 통해 컴포넌트를 불러오는 것은 불가능합니다. 컴포넌트를 불러오기 위해서는 React의 lazy를 사용해야 하는데요.
const Chat = React.lazy(() => import('./Chat'));
위 처럼 React.lazy() 는 import() 구문을 반환하는 콜백함수를 인자로 받습니다. 다이나믹 임포트로 불러와지는 모듈은 ReactComponent를 포함하며, default export를 가진 모듈이어야 합니다. 그리고 불러온 컴포넌트를 반환합니다.
추가로 React.lazy로 불러온 컴포넌트는 단독으로 쓰일 수 없고, React.Suspense 컴포넌트로 하위에서 렌더링되어야 합니다.
import { Suspense } from 'react';
const Chat = React.lazy(() => import('./Chat'));
const HomePage = () => {
return (
<Suspense fallback={<div>채팅방 가져오는 중,,,</div>}>
<Chat />
</Suspense>
);
}
Suspense 컴포넌트는 fallback prop을 필수로 가지며, fallback prop은 로딩 컴포넌트로 사용할 컴포넌트를 받을 수 있으며 null과 Boolean 값도 받을 수 있습니다. 그렇다면 이를 우리의 프로젝트에 한 번 적용해 보겠습니다.
페이지 단위 Spliting
위 스크린샷은 우리 짤뮤니티 서비스의 간단한 페이지 구조도입니다.
메인 페이지와 좋아요 한 짤, 업로드 한 짤, 이미지 업로드, 관리자 페이지 대표적으로 총 5개의 페이지가 존재합니다. 사용자는 처음 짤뮤니티 서비스에 들어오게 되면 메인 페이지를 만나게되구요.
그렇다면 여기서 생각해 볼 수 있습니다. 모든 페이지를 한번에 모두 가져오는 것이 아닌, 사용자에게 가장 먼저 보여지는 메인 페이지만 로드하여 보여줄 수 있지 않을까요? 그렇게 되면 처음 로드되어 받아올 JS 번들 사이즈를 줄일 수 있을 것 같습니다.
위 스크린샷은 짤뮤니티 서비스의 라우팅 구조도를 VSCode에서 가져온 것입니다. TanStack Router를 사용하여 구조를 잡은 것이라 조금 생소할 수 있지만 lazy라는 키워드에 집중하시면 조금 이해를 높일 수 있을 것 같습니다. TanStack Router는 lazy 키워드를 사용하면 자동으로 코드 스플리팅을 지원해주는데요, 공통으로 들어갈 컴포넌트는 `_layout-with-chat.tsx` 안에 구현이 되어있고 내부 `Outlet`을 통해 메인, 좋아요 한 짤, 업로드 한 짤 이 나눠지며 인증이 필요한 페이지 같은 경우는 `_authentication.tsx` 파일 안에 구성이 되어있습니다.
이처럼 lazy 키워드를 사용하여 스플리팅을 적용하고 나면 routetree.gen.ts에 lazy가 적용되어 있는 걸 볼 수 있습니다.
이렇게 스플리팅을 하고 나면 메인 페이지에 해당하는 JS 번들 파일만 로드 할 것이며, 유저에게 보다 빠르게 UI를 보여줄 수 있게 됩니다.
그렇다면 이제 페이지 단위가 아닌 컴포넌트 단위로 스플리팅을 어떻게 진행해주면 좋을지 살펴보겠습니다.
컴포넌트 단위 Spliting
`_layout-with-chat`은 채팅 컴포넌트를 기본적으로 가지고 있어 '메인/좋아요 한 짤/업로드 한 짤' 페이지로 페이지가 이동 되더라도 채팅 컴포넌트는 계속 유지가 됩니다. 하지만 채팅 컴포넌트는 사용자가 메인 페이지에 들어온다고 하더라도 버튼 클릭에 의한 인터랙션이 있어야만 채팅 UI가 나타나게 되고 있습니다.
위 스크린샷을 보면 우측에 채팅 UI가 나타나는 것을 볼 수 있는데, 이 UI는 '채팅방 숨기기/채팅방 보기' 토글 버튼을 클릭해야 열리고, 짤에 호버하여 화살표 아이콘을 누르면 열립니다. 그렇다면 이 채팅 컴포넌트도 한번에 로드하여 보여줄 필요가 없겠죠? 그 이유는 유저가 채팅방을 활성화 시키지 않을 수 있기 때문입니다. 그렇다면 불필요한 리소스가 되죠.
이를 위해 스플리팅을 적용해보겠습니다.
이번 스플리팅은 TanStack Router와 관계없이 위에서 설명했던 Dynamic Import, lazy를 사용한 것입니다.
`isChatOpen`은 boolean 값으로 해당 값이 참이 되어야 Chat 컴포넌트가 페이지에 나타나게 되며 이때 Chat 컴포넌트를 동적으로 임포트하게 됩니다. 그러면 JS의 초기 번들 사이즈가 더욱 줄어들어 유저에게 보다 빠른 인터랙션을 제공할 수 있겠죠?
이제 번들된 파일의 사이즈를 확인해봅시다.
번들사이즈가 704KB -> 567KB로 줄어 들었으며, 하나의 assets/index.~~~.js 파일로 되어있던 번들 파일이 나뉘었습니다.
그렇다면 실제로 렌더링 성능에 효과가 있었는지 확인해 볼까요?
스플리팅 된 코드는 203KB의 JS만 요청하고 있으며 DomContentLoaded도 126밀리초로 스플리팅 되지 않은 코드는 229KB의 JS를 요청하고 있으며 DomContentLoaded도 140 밀리초로 더 많은 용량과 더 느린 속도를 가진 것을 볼 수 있습니다.
Lighthouse의 LCP 성능도 `Fast 3G 기준` 1.7초에서 1.4초로 렌더링 성능이 개선이 되었습니다.
그렇다면 무조건 스플리팅 하는 것이 좋을까요? 🧐
답은 X 입니다.
너무 작은 파일을 스플리팅하면 오히려 네트워크 요청 횟수가 늘어나게 되고 실험결과 오히려 요청하는 데이터 량이 늘어나는 것을 확인할 수 있었습니다.
또한, 글 맨위의 빌드 로그의 경고에서 `manualChunks`를 사용하여 수동으로 스플리팅을 진행하라고 해서 나름대로 관련이 있다고 생각한 서드 파티 라이브러리끼리 엮어서 스플리팅도 해보고, 모든 라이브러리를 독립적으로 스플리팅하여 쪼개보기도 하였지만 오히려 LCP 성능에는 아무 변화가 없었으며 요청되는 JS 파일의 용량이 늘어나는 것을 확인 할 수 있었습니다. 이 부분은 확실하게 서드 파티 라이브러리가 서로 어떤 의존성을 가지고 있는지 파악을 한 상태에서 적용해야 효과가 있다는 것을 알았습니다. 이를 통해 마냥 번들 사이즈를 줄이는게 좋지 않다는 것도 알게 되었습니다.
역시 뭐든지 과하면 좋지 않은 것 같습니다.. 이 경험을 통해 이전 프로젝트, 이후에 진행될 프로젝트에도 적절하게 스플리팅을 적용하여 렌더링 성능을 최적화 해봐야겠습니다.
'React' 카테고리의 다른 글
useState 동작원리로 알아보는 자바스크립트 기본기 (2) | 2024.10.15 |
---|---|
0.1초라도 빠르게 데이터 보여주는 방법 by TanStack Query(prefetchQuery) (0) | 2024.05.29 |
프론트엔드 성능 최적화(Image 압축 및 확장자 변경 자동화) (0) | 2024.04.13 |
프론트엔드 성능 최적화(Font) (0) | 2024.04.12 |
human error를 줄여 동료 개발자의 경험을 개선해보자! with console.log() (0) | 2024.04.08 |
같은 실수를 반복하지 않고, 한 번 학습한 내용을 오래 기억하기 위해 개발하면서 겪었던 트러블 슈팅과 학습한 내용을 정리하고 기록합니다 🧑💻