[nextjs] bundle-analyzer를 사용한 최적화 일기

pds·2023년 5월 6일
8

TIL

목록 보기
54/60

Bundle size 최적화하기

bundle

여러 개의 파일을 하나로 묶어서 처리하는 것을 의미한다. JavaScript 파일들을 묶어서 생성되며, 웹 애플리케이션에서 필요한 리소스를 단일 파일로 묶어 관리하고 전달하는 데 사용된다.

웹 애플리케이션의 경우 HTML, CSS, Javascript로 구성되는데 이들을 따로 모두 요청하게 되면 서버-클라이언트 요청 교환 횟수가 늘어나고 응답시간이 느려질 수 있는데 이런 필요한 파일들을 하나로 묶어 사용해 크기를 줄이고 요청횟수를 줄인다.

일반적인 react앱과 달리 nextjs앱은 기본적으로 코드스플리팅을 지원해 페이지별로 필요한 스크립트만 번들링하게 된다.

하지만 그렇다하더라도 불필요한 스크립트가 같이 번들링되는 일이 있어 따로 최적화할 수 있다면 해야하는 것 같다.

하던 토이프로젝트를 빌드해 "빛집"을 돌려보았다.

직접적으로 성능점수에 영향을 주진 않지만 페이지 로드 속도를 개선할 수 있다고 한다.


불필요한 스크립트 줄이기

Unused JavaScript can slow down your page load speed

  • JavaScriptrendering block을 일으킬 경우 브라우저는 페이지 렌더링에 필요한 다른 모든 작업을 진행하기 전에 스크립트를 다운로드, 구문 분석, 컴파일 및 평가해야 한다.

  • 비동기인 경우에도 다운로드하면서 다른 리소스와 대역폭 사용을 두고 경쟁해서 성능에 영향을 미칠 수 있고 불필요한 파일을 로드하면 사용자 데이터 사용량에 영향을 줄 수 있다.

어떻게 줄일까?

  • 불필요한 모듈 삭제 및 불필요한 모듈 import 제거

  • Code Splitting

Nextjs가 코드스플리팅을 통해 페이지별로 필요한 모듈들만 번들링 해 제공하여 초기 로드 속도를 향상시킨다고 하니 추가적으로 불필요한 파일을 찾아 제거하고 클라이언트 사이드에서만 필요한 모듈들을 선정해 동적으로 로드시키는 것은 꽤 중요해 보인다.


bundle-analyzer로 번들 파악하기

번들 파일이 어떻게 구성되었는지 쉽게 파악하게 해주는 시각화 도구라고 한다.

yarn add -D @next/bundle-analyzer

const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: true,
  openAnalyzer: true,
});

next.config.js에 포함시켜서 빌드하면 된다.

저 위의 코드는 무작정 빌드할 때 실행시키는 경우고

따로 환경변수 설정을 해서 analyze 스크립트를 만들어 따로 사용하면 원할 때 프로파일링 할 수 있을 것 같다.

빌드하면 이렇게 analyze라는 폴더에 html결과물이 생긴다.


client.html

클라이언트 번들링 결과를 시각적으로 분석한 결과물

edge.html

서버에서만 필요한 모듈의 번들링 결과를 시각적으로 분석한 결과물

nodejs.html

서버 사이드 렌더링 시 필요한 모듈의 번들링 결과를 시각적으로 분석한 결과물


개선로그


client.html에 사용자에게 전달될 모든 번들 파일과 사용하는 패키지가 시각화 되어있다.
역시 차트랑 에디터 라이브러리는 크기가 크구나!

여기서 어떤 것들은 아예 애플리케이션에 필요 없을 수도 있고 어떤 것들은 빌드 시점에 필요없음에도 불필요하게 포함되어있을 가능성이 있다

차트랑 에디터는 이미 dynamic import 처리를 해두었다.

차트의 경우 몇 종류를 사용하지 않는데 비해 커서 클라이언트 측에서 로드할 때 영향이 있지 않을까 싶어 tree shaking이라는 것을 적용 가능한지 여부를 파악하고 적용을 고려해보면 좋을 것 같다

lighthouse에서 최적화할 수 있다고 보여줬던 것들 위주로 먼저 확인해보자.

이녀석 위의 client.html에도 그렇고 server.html에도 포함되어있었다.

확인해보니 crypto-browserify라는 친구 하나로 구성되어있는데 그런걸 안쓰는데? 라고 생각했지만

on-demand-revalidation을 애플리케이션 내부의 사용자만 할 수 있게끔 의도하느라 임시토큰을 사용해 api route를 호출하기 위해 jsonwebtoken라이브러리를 사용했는데 이게 꽤 사이즈가 컸다.

클라이언트가 추가 수정 삭제 를 할 때 사용되게 의도한 부분인데 빌드 시 번들에 포함되어있었던 것이다.

export const getJWTPayload = async (token: string, key: string) => {
  try {
    const jwt = await import('jsonwebtoken');
    const decodedJwt = jwt.decode(token) as { [key: string]: string | undefined };
    return decodedJwt[key];
  } catch {
    return undefined;
  }
};

유틸 함수 한군데서만 jsonwebtokenimport하여 사용하고 있어 대략 이런식으로 함수들에서 dynamic import 처리를 해주었다.

노란박스(메인페이지) 외에도 해당 모듈을 사용하는 페이지의 번들 사이즈가 매우 줄어들었음을 알 수 있다.


_app 번들의 lodash가 딱 눈에 띈다. lodashsnake_case api 응답을 camelCase로 변경하는데 사용했었는데 그걸 사용하는 axios관련 모듈이 공통적으로 사용되었기에 포함되었을 것이다.

camelCase로 api가 모두 변경되었기 때문에 클라이언트에서도 더 이상 필요하지 않아 의존성 자체를 지웠다.

프로파일러에 lodash Gzipped Size가 24.11kb라고 되어있었는데 진짜 그만큼 줄어들었다.

빌드 시 공통적으로 페이지에 포함되는 _app 번들을 다시 살펴보면 아이콘 컴포넌트가 보이는데 사이즈가 매우크다.

중간에 함정 두개 때문에 저런 사이즈를 가지는 것으로 보인다.

그런데 페이지 공통으로 포함될만한 아이콘이 글로벌 네비게이션 바에서 사용하는 다크모드 관련 2개 정도 뿐인데 왜 모든 아이콘만큼의 사이즈를 가질까?

소셜아이콘 사이즈도 결국 줄이긴 해야겠지만 근본적인 원인을 먼저 해결해야한다

import Ax from '@/public/icons/ax.svg';
import Bell from '@/public/icons/bell.svg';
import Bubble from '@/public/icons/bubble.svg';
import Check from '@/public/icons/check.svg';
import Clock from '@/public/icons/clock.svg';
import CompactDown from '@/public/icons/compactdown.svg';
import Darkmode from '@/public/icons/darkmode.svg';
import Dice from '@/public/icons/dice.svg';
import ExceptButton from '@/public/icons/exceptbutton.svg';
import Google from '@/public/icons/google.svg';
import Kakao from '@/public/icons/kakao.svg';
import Lightmode from '@/public/icons/lightmode.svg';
import Logo from '@/public/icons/logo.svg';
import Naver from '@/public/icons/naver.svg';
import O from '@/public/icons/o.svg';
import Ox from '@/public/icons/ox.svg';
import Play from '@/public/icons/play.svg';
import Selected from '@/public/icons/select.svg';
import Triangle from '@/public/icons/triangle.svg';
import VerticalDot from '@/public/icons/verticaldot.svg';
import Zoomin from '@/public/icons/zoomin.svg';

const Icons = {
  Lightmode,
  Darkmode,
  Bell,
  Bubble,
  Dice,
  Ox,
  Check,
  ExceptButton,
  VerticalDot,
  Zoomin,
  O,
  Ax,
  Triangle,
  Naver,
  Kakao,
  Google,
  Clock,
  Play,
  CompactDown,
  Selected,
  Logo,
};

export default Icons;
export type IconType = keyof typeof Icons;
import styled from 'styled-components';

import Icons, { IconType } from '../Icons';

export const iconTypes = Object.keys(Icons) as IconType[];

export interface IconProps {
  icon: IconType;
  color?: string;
  size?: string;
  className?: string;
}

const IconLayout = styled.div<Pick<IconProps, 'color' | 'size'>>`
  display: inline-block;
  & > svg {
    fill: ${(props) => props.color || 'currentColor' || 'var(--color-text)'};
    width: ${(props) => props.size || '24px'};
    height: auto;
  }
`;

const Icon = ({ icon, color, size, className = '' }: IconProps) => {
  const SVGIcon = Icons[icon];
  return (
    <IconLayout color={color} size={size}>
      <SVGIcon className={className} viewBox="0 0 24 24" />
    </IconLayout>
  );
};

이런식으로 아이콘 컴포넌트들을 한군데 모아두고 존재하는 타입에 맞춰 사용하게끔 아이콘 컴포넌트에서 참조해 사용하는데 import 시 필요한 것만 불러와 사용하는게 아니라 Icons 객체에 포함되는 모든 아이콘이 전부다 빌드할 때 포함되어서 그런 것이다.

한 페이지에 사용이 기대되는 아이콘 개수가 매우 적으며 향후 svg를 추가할 때 마다 번들의 사이즈가 커질 것을 고려해 전부 코드스플리팅 해주었다.

import dynamic from 'next/dynamic';

const Ax = dynamic(() => import('@/public/icons/ax.svg'));
...

Logo 컴포넌트도 네비게이션 바에 사용되었는데 사이즈가 컸다.

로고 자체를 그냥 svg 내용물로 컴포넌트화 해서 사용하고 있었는데 이 svg자체만으로 25kb라서 그랬다.

before

const Logo = ({ size = 'normal' }: LogoProps) => (
  <svg width={LOGO_SIZE[size]} viewBox="0 0 52 16" fill="none">
    <path
      d="M1.85405 13V0.616H6.91205L8.82005 7.15H8.96405L10.8361 ... 매우김

after

const Logo = ({ size = 'normal' }: LogoProps) => (
  <Image src="/images/logo.svg" 
         alt="로고" 
         width={LOGO_SIZE[size][0]} 
         height={LOGO_SIZE[size][1]} 
         priority />
);

export default Logo;

next/image를 사용해 이미지처럼 처리해 next가 최적화 하게 하였고 페이지 첫 화면에 반드시 바로 보이기 때문에 priority 설정을 해주었다.

공통 번들 크기가 비약적으로 개선되었다. 슬슬 빨간색이 줄어들고 노란 녀석들도 보인다.

명확하게 보이는 것들만 개선했는데도 번들사이즈가 많이 줄었다.

이제 위에서 사이즈 공간을 꽤 차지했었던 modal, Menu의 내용물, 페이지가 뜨고 호출되는 동작 같이 페이지를 만들 때는 필요없지만 클라이언트에서 상호작용으로 사용되는 부분들을 코드 스플리팅 해주었다.

최적화 전 사진 대비 최적화 후 사진을 보기까지 기능이나 라이브러리가 중간중간 추가되었음에도 최종적으로 많이 개선된 모습이다.

페이지 공통 초기 번들 크기가 263kb에서 137kb로 개선되었다.

이 파일의 크기가 작을수록 초기 페이지 로드 시간이 줄어든다고 하니 매우 유효한 최적화 작업이지 않았을까 생각한다.


결과 요약

전/후 결과가 완전히 독립적이고 일관성 있는 환경이라고는 생각안하지만 Lighthouse TBT 지표도 비교해보았다.

TBT(Total Blocking Time)

TBT는 페이지 로딩 중에 브라우저가 메인 스레드에서 처리해야 하는 작업의 양을 측정하는 지표입니다.

How to improve your TBT score (by lighthouse)

Unnecessary JavaScript loading, parsing, or execution. While analyzing your code in the Performance panel you might discover that the main thread is doing work that isn't really necessary to load the page. Reducing JavaScript payloads with code splitting, removing unused code, or efficiently loading third-party JavaScript should improve your TBT score.


최적화 전


최적화 후


최대한 유사한 환경으로 테스트하기 위해 게스트 브라우저로 테스트했고 최적화 대상이 아니었던 파일들의 응답시간이 비슷할 때 기준으로 비교해보았다.

공통 번들인 _app.js 의 크기가 1/3으로 줄었는데 그만큼 Content Download 속도도 개선되었다고 할 수 있는 것 같다.

성능 진단 TBT 탭에서의 긴 메인스레드 작업 진단 지표에서도 테스트 할 때 마다 차이가 있었으나 최적화한 번들 파일들이 더 이상 식별되지 않게 되었다.


추가적으로 서버측에서 프리렌더링으로 보여지는 부분에 필요 없고 사용자 상호작용으로 보여지는 컴포넌트들을 코드스플리팅 해주었다.


최적화로 얻은 것

  • 페이지 공통으로 사용되는 번들 크기를 263KB => 138KB로 개선했고 그 중 개발자가 컨트롤 가능하다고 보여지는 번들인 _app의 크기를 186KB => 62KB 로 개선했다.

  • 정확하지는 않으나 초기 페이지 로드 시 Content-Download 속도의 개선으로 Total Blocking Time 시간이 줄어들 확률이 높아질 것이다.


더 얻고 싶은 것

tree-shaking을 적용할 만한 모듈이 있는지 찾아 적용할 수 있다면 사용하지 않는 부분 제거해보기


References

profile
강해지고 싶은 주니어 프론트엔드 개발자

0개의 댓글