이번 포스팅은 최적화와 관련된 이야기 입니다.
솔직히 말하여 처음으로 최적화를 진행하다보니, 몰이해적인 부분과 실제로는 최적화를 하지 못했는데 한 척(?) 한 것일 수도 있습니다.
언제나 그렇듯 피드백과 건전한 토론문화는 지향합니다. 특히 이번 글에 한해서 많은 댓글이 달리기를 제 스스로 희망하고 있습니다.
또한 항상 빌었듯, 제가 고민을 했던 시간(그리고 억까를 당했던 시간)만큼 다른 프론트엔드 개발자 분들의 시간을 절약해주기를 기원합니다.
우선 제일 문제가 되었던 것은 dev mode 에서의 컴파일 시간입니다. 아래의 사진을 살펴봅시다.
다른 페이지로 넘어가는데 있어서 너무나도 오랜 시간이 걸립니다. (평균 1500ms 정도 걸리는 듯 합니다.)
개발을 하는 데에 있어 너무나도 불편했던 사항이었고, 추후 프로덕션에서도 이렇게 시간이 걸린다면 모든 유저는 탈주하고 말 것입니다. 아무리 PoC 를 개발한다고 하더라도, 이렇게 오랜 시간이 걸리면 프론트엔드의 문제가 아니라 우리 회사의 솔루션에 문제가 있다고 오해를 살 여지도 있습니다.
그래서 인생에서 처음으로 프로젝트를 최적화를 해보기로 했습니다.
본격적으로 들어가기 앞서, 현재 프로젝트는 yarn workspace 를 기반으로 한 monorepo 로 구성되어 있음을 밝힙니다. 따라서 글에서 잘 안된다고 밝혔던 것도 단순 Next.js 기반 프로젝트라면 잘 될 수도 있습니다.
Next.js와 Next.js의 빌드단계에서 차용한 그림을 살펴봅시다. Next.js 에서 빌드란 총 4가지 과정을 의미합니다.
우리가 집중적으로 살펴봐야 할 단계는 3번 그리고 4번 단계입니다. 앗, 벌써 눈치를 채셨나요? 1번에만 집중하고 싶었던 저였지만, 결과적으로는 3번과 4번 단계에 집중을 할 수 밖에 없었습니다. 왜냐하면 제 역량 안에서는 도저히 컴파일 단계 최적화와 관련된 자료를 찾아보기가 어려웠기 때문입니다. 🥲
혹시 컴파일 시간을 줄여주는데 도움이 되는 자료 또는 키워드가 있다면 적극적으로 공유해주세요.
공유주신다면 제가 정리하여 많은 다른 분들의 시간을 줄여주는데 꼭 기여하도록 하겠습니다.
최적화는 말로만 해서는 안됩니다. 지표를 바탕으로 측정을 하여 수치화가 필수적인 영역이 바로 최적화입니다. 프론트엔드 분야에는 여러 최적화 지표를 측정할 수 있는 도구가 있습니다.
next/bundler-analyzer
를 사용했습니다. 이 도구는 클라이언트(client.html
) / 서버(edge.html
) / SSR(nodejs.html
) 에서의 모듈링 결과를 시각적으로 분석하여 나타내 줍니다. 예를 들어 서버 모듈링 결과인 edge.html
은 다음과 같이 나왔습니다.next build
명령어 입니다. 최적화 이전 명령어를 입력하면 나오는 결과물은 다음과 같습니다.각각의 지표들은 다음과 같은 뜻을 가집니다.
Route : 동적 라우트를 포함한 페이지의 라우트
Size : 클라이언트 브라우저에서 페이지에 접근했을 때 필요한 자바스크립트 번들의 크기
First Load JS : 각 페이지마다 필요한 자바스크립트 번들 크기 + Fist Load JS shared by all(앱 전반에 사용되는 공용 코드, 프레임워크 코드, 웹팩 코드, CSS 등)의 크기
각각의 사이즈가 꽤 커보이나요? 저는 여기서 First Load JS 를 줄여보기로 했습니다. 그래야만 각 페이지에 접근했을때 빠르게 컴파일과 빌드가 된다고 생각했습니다. 이제 제가 열심히 적용해본 몇몇 해결책을 살펴보러 가봅시다.
sideEffects
첫번째 해결책은 Tree Shaking을 이용한 해결책 입니다.
Tree Shaking 이란 코드를 빌드할 때 실제로 쓰이지 않는 코드들을 제외한다는 뜻입니다. 위의 사진처럼 죽은 나뭇잎들을 흔들어서 떨어트리는데에서 비롯된 단어라고 합니다.
그러면 Tree Shaking 을 어떻게 구현할 수 있을까요? 다행히도 Next.js 는 기본적으로 tree-shaking 을 지원을 하고 있습니다만 작동하지 않는 것 처럼 보입니다.
import {A} from 'B-Library'; // B-Library 의 모든 코드를 불러옴
왜 그럴까요? 혹시 모를 sideEffect
(부작용) 이 있을까봐 그렇습니다. 모든 코드를 불러와야지만 되는 부작용이 있을까봐 그렇지요. 이를 해결하는 방법은 간단합니다. package.json 에 sideEffects 를 false 로 선언하면 됩니다.
{
"name": "nextjs.example",
"version": "0.0.1",
"sideEffects": false
}
특히 중요한것은, monorepo 상에서는 참조하고 있는 모든 패키지에 sideEffects : false
를 기입해줘야 한다는 사실입니다. 아래와 같이요.
자 이제 빌드 사이즈가 얼마나 최적화 되었는지 살펴볼까요?
First Load JS
가 전체적으로 30% 이상 줄어들었고, 특정 페이지의 경우 50% 이상 줄어든 것을 확인할 수 있습니다. 와우 단지 코드 한줄만 추가했을 뿐인데, 엄청난 최적화가 이루어졌습니다. 👍
두번째 해결책은 Dynamic Loading
입니다. 클라이언트에서 처음에 렌더링하기에는 큰 용량을 차지하는 번들을 나중에 로드하자 라는 차원에서 최적화 수단으로 자주 이루어집니다. 그러면 어떤 것을 Dynamic Loading 해야 할까요? 이때 bundle-analyzer 를 사용해봅시다.
누가봐도 엄청난 lottie 의 크기가 보입니다. Lottie 를 최적화해 봅시다. 현재 Lottie 는 공통 ui 폴더에 작성되어 있습니다. dynamic import 를 사용하기 위해서는 export default 로 바꿔줄 필요가 있습니다.
현재 저는 제가 작성한 경로 깔끔하게 관리하기 에 따라 export 구문만 사용중에 있었습니다.
따라서 아래와 같이 우선 Lottie.dynamic.tsx
를 만들어줍시다.
import NativeLottie, {
LottieComponentProps as NativeLottieProps,
} from 'lottie-react';
type LottieProps = NativeLottieProps & {};
export default function Lottie(props: LottieProps) {
return <NativeLottie {...props} />;
}
이후 실제로 Lottie 를 사용하는 곳에서 next/dynamic
을 사용하여 Dynamic Import 를 해주면 됩니다.
const DynamicLottie = dynamic(
() => import('@ui/common/src/dynamic/lottie.atom'),
{
loading: () => <p>Loading ...</p>,
},
);
또한 mui 의 LineChart
역시 Dynamic Import 를 했습니다.
이렇게 되면 최종적으로 얼만큼 번들사이즈가 감소할까요?
딱 Lottie
와 LineChart
를 이용하는 라우트들에서 그 만큼 First Load JS 가 줄어든 것을 확인할 수 있습니다
또한 Lottie 에 한하여, Lottie 를 사용하고 있는 sign/sign-up 컴포넌트의 경우에 모듈사이즈는 37%가량 줄어들고, 컴파일 시간은 75% 가량 줄어들게 됐습니다.
결과적으로, 현재까지 6개의 페이지를 개발하는 동안 처음에는 2231 KB 였던 First Load JS 가 1280.3KB 가 되어 43% 가량의 번들 용량을 줄이는데 성공 했습니다.
하지만 여전히 미흡한 점들이 많이 보입니다.
컴파일 단계를 최적화할려고 했지만 컴파일 단계는 거의 최적화되지 못했습니다. Dynamic Loading 을 통해 Lottie
만 불러오도록 만든게 거의 유일한 성과입니다.
심지어 LineChart
를 사용하는 페이지의 경우에는 Dynamic Loading 을 사용하고 있음에도 컴파일 단계에서 그대로 모듈을 다 불러오고 있습니다.
[추정 원인] : LineChart
를 선언하는 곳에서 export default
구문을 미사용중입니다. 따라서 무조건 LineChart 와 관련된 모든 모듈을 다 불러와야 하는것이 아닐까 추정하고 있습니다.
import {
LineChart as MUILineChart,
LineChartProps as MUILineChartProps,
} from '@mui/x-charts/LineChart';
/*
* This Component could be only used in client components
*/
type LineChartProps = MUILineChartProps & {};
export default function LineChart(props: LineChartProps) {
return <MUILineChart {...props} />;
}
이런 문제가 되는 점들을 어떻게 해결할 수 있을지에 대해 가감없이 댓글을 달아주신다면 정말 감사하겠습니다. 상술했듯 정리하여 최적화 2탄을 내는데 많은 도움이 될 것 같습니다!