📦 패키지 최적화를 통해 런타임 성능 향상 시키기

개요

처음 시작은 도커 최적화에서 시작되었다.  사내 프로젝트 진행 중 yarn-berry 마이그레이션 후 CI/CD를 진행하는 도중. 회사에서 사용되는 gitlab의 저버전 이슈로 인해 ci 중 캐싱이 되지 않고, 필요 이상의 .yarn 내부의 패키지와 dev module들이 포함되어 올라가는 이슈가 발생하여 빌드 최적화에 관심을 두게 되었다.
그 후 운영 단계에서 최적화는 반드시 거쳐 가야 하는 순번이라 생각했고, 이번 기회에 번들링 최적화를 진행하게 되었다.

 

Bundle Size 최적화

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

 

웹 애플리케이션에선 HTML, CSS , Javascript로 구성되고 모두 따로 요청하게 될시 서버-클라이언트간 요청 교환 횟수가 늘어나 결국엔 응답시간이 느려지고 UX가 떨어지게 되는데, 이런 필요한 파일들만 하나로 묶는 행위를 번들링이라고 한다.

Next.js에선 기본적으로 페이지별로 Code Splitting이 지원되어 필요한 스크립트만 번들링된다. React서도 당연히 가능하다. ([1], [2])

 

Learn Next.js | Next.js by Vercel - The React Framework

Next.js by Vercel is the full-stack React framework for the web.

nextjs.org

 

2-3. 라우트 코드스플리팅 하기 · GitBook

2-3. 라우트 코드스플리팅 하기 준비하기 우선, 우리가 이전 섹션에서 작업한 SplitMe 관련 코드는 날려주세요. 연습삼아 한번 해본것이기 때문에 더 이상 해당 코드들은 필요가 없어졌습니다. 그

react-router.vlpt.us

 

리액트 Code-splitting 실적용 사례

서론 아래의 링크에서 내가 왜 리액트같은 SPA 에서 을 해야하는지에 대해 서술하였지만, 이번에는 내가 현재 진행하고 있는 프로젝트 내부에서 코드분할 전 후 를 비교하며 결과를 기록하려한

hwani.dev

 

Bundle은 왜 중요할까?


  1. 모듈 검색 시간의 단축
    모듈을 import한다는 건, 파일과 파일이 서로 참조되고 있다는 것.

    ➡️ import 시킨 파일 검색
    ➡️ 파일에서 내가 가져온 모듈을 읽어야함
    ➡️ 다 읽고나서 모듈 종료

    위 과정을 반복 수행하게 되는데, 이처럼 한 파일에 많은 파일을 참조하고 참조된 파일에 많은 모듈들이 존재한다면 소요되는 시간은 점점 늘어나게 된다.
    이는 곧 빌드 타임과 런타임 환경에서 더 많은 시간을 소요하게 된다는 점이다.
  2. 번들 크기 감소
    번들은 참조 관계가 있는 파일을 하나로 모아주는 과정인데, tree-shaking을 통해 불필요한 파일과 코드는 알아서 제거고, 번들 크기와 앱의 크기를 줄여 DX와 UX 개선이 요구된다.
  3. 비용감소 및 개선 점수 향상
    브라우져 최초 로드 시 줄어든 js 다운로드로 인해 네트워크 비용 감소 및 시간 개선과 동적 import 시 웹 사이트를 열 때 로드되는 js의 크기에 엄청난 영향을 미치게된다. [lighthouse의 성능 점수에도 영향을 미치는 것을 확인해볼 수도 있다.]
  4. 이미지 번들
    필자가 사용하는 Next.js에선 자체적으로 이미지 최적화 서버가 내장되어 있어 이 부분은 본 글과는 연관성이 없어 제외했다.
[번들링 최적화 사례]

 

문제점 파악과 최적화 작업

bundle-analyzer


번들링된 파일의 구성과 어디서 얼마나 용량을 차지하는지 시각화해주는 도구로 next/bundle-analyzer를 사용했다.

 

@next/bundle-analyzer

Use `webpack-bundle-analyzer` in your Next.js project. Latest version: 14.0.3, last published: 15 days ago. Start using @next/bundle-analyzer in your project by running `npm i @next/bundle-analyzer`. There are 171 other projects in the npm registry using @

www.npmjs.com

Next.js 사용시 최신버전(v13 이상)에서는 자체적인 최적화 기능이 도입되어 더 빠른 콜드 부팅과 빌드 속도를 제공해준다고한다. 
하지만 필자는 일반적으로 번들러를 사용한 javascript 프로젝트시 적용해 볼 수 있도록 소개해보고자 한다.

 

배럴(barrel) 파일 제거


먼저 배럴 파일은 단일 파일에서 여러 모듈을 그룹화해서 내보내는 방법이다. 그룹화된 모듈에 액세스할 수 있는 중앙화된 위치를 제공함으로써 그룹화된 모듈을 더 쉽게 가져올 수 있다.

 

배럴(barrel) 구조


// lodash 라이브러리 사용시 vscode의 quick import로 배럴 구조로 import되는 모습
import { isEmpty, isNumber, ... } from "lodash"

// 배럴 사용으로 모든 모듈을 일괄적으로 가져왔음
import { module1, module2, module3 } from "./utils";

 

이는 쉽게 액세스 가능한 인터페이스를 제공하고 코드 구성과 유지보수를 향상시켜 많이 사용되는 구조이다. 인기 있는 아이콘 및 컴포넌트 라이브러리에는 entry barrel 파일이 최대 10,000개의 export가 있다고한다.

 

배럴 파일의 문제점?


모든 require(...)import '...'에는 자바스크립트 런타임에 숨겨진 비용이 있다. 수천 개의 다른 항목을 가져오는 배럴 파일에서 단일 내보내기를 사용하려는 경우, 불필요한 다른 모듈을 가져오는 대가를 지불하고 있는 것입니다.

이러한 속도 저하는 특히 서버리스 환경에서 로컬 개발 및 프로덕션 성능 모두에 영향을 미치게되고, 웹을 시작할 때마다 모든 것을 다시 가져와야 합니다.

 

트리 쉐이킹(tree-shaking)이 안되나?


트리 쉐이킹은 번들러의 기능이고, 자바스크립트 런타임 기능이 아니다. 따라서 external로 표시되어 있으면 블랙 박스로 남아있게 되고, 번들러는 런타임에 종속성이 필요하기 때문에 barrel 파일을 라이브러리 내부에서 최적화 되어 트리 쉐이킹 할 수 없게 된다.

 

 

라이브러리를 애플리케이션 코드와 함께 번들로 제공하기로 선택할때 가져오려는 라이브러리의 package.jsonsideEffect가 없는 경우 트리 쉐이킹이 작동한다.
하지만 모든 모듈을 컴파일하고 전체 모듈을 그래프 분석한 다음 트리 쉐이킹을 올바르게 수행하려면 더 많은 시간이 걸리고 이로써 빌드 속도의 저하가 발생한다.

 

 

lodash 라이브러리 개선


진행하고 있는 프로젝트에서 lodash라이브러리가 꽤 많은 사이즈를 차지하고 있었다. 특히나 lodash를 사용하고 있지 않은 파일이 적을 정도로 많은 비중을 차지하는 라이브러리였다. 

 

 

가장 단순한 접근 방법으로 lodash에서 필요한 모듈을 개별적으로 import하여 진행하기만 했다.

 

 

결과적으로 lodash 모듈의 전체를 번들되던 과정에서 필요한 lodash 내부의 기능들만 사용하여 번들링되다보니 유의미한 결과를 볼수 있었다.

 

빌드시간 비교


  • 번들링 사이즈 감소
    • 페이지에서 사용된 lodash 번들링 사이즈 ➡️ 139.88 KB ➡️ 68.85 KB [50.78% 개선]
    • zip으로 존재하는 lodash 번들링 사이즈 ➡️ 531.35 KB ➡️ 58.02 KB [89.07% 개선]
  • 빌드시간 감소
    • lodash 최적화 전 ➡️ 34.341s ➡️ 32.03s [+-2 ~ 3s 개선]

 

lottie 라이브러리 개선 및 동적 할당(선택)


해당 파트에서 '선택'으로 분류한이유는 웹팩 번들링 단계에서 플러그인 추가시 이미지 최적화를 같이 진행할수 있는 경우도 있기 때문에, 필요한 경우가 아니라면 번들링 단계에대한 최적화를 위해 동적 import 방식을 따르지 않아도 된다. 필요에 따라서  최적화를 선택해서 진행하길 바란다.

 

현재 사용되는 로띠 라이브러리 중 react-lottielottie-react로 변경하게 되었다. (변경한 이유에 대해서 나중에 작성할 예정입니다.)

기존에 로띠 사용시, LottieControl을 기본적으로 불러와서 json 파일을 데이터(props.animationData) 그대로 주입하는 방식으로 진행했다.

// 로띠가 사용되는 컴포넌트
// Popup.tsx
import LottieControl from '@common/LottieControl/LottieControl';
import * as checked from '@images/lottie/checked.json';
// LottieControl.tsx
import React from 'react';
import { Options } from 'react-lottie';
import LottieControlView from './LottieControlView';

export interface Props {...}

const LottieControl = (props: Props) => {
  const animationLoopOptions: Options = {
    loop: true,
    autoplay: true,
    animationData: props.animationData,
    rendererSettings: {
      preserveAspectRatio: 'xMidYMid slice',
    },
  };

  const newProps = {
    ...props,
    animationLoopOptions,
  };

  return <LottieControlView {...newProps} />;

 

이렇게 번들 타임에 비교적 크기가 큰 로띠 파일을 빌드 타임에 번들링하게 될 때 필요 이상의 크기와 시간을 잡아먹게 되고, 로띠 파일을 import 하는 컴포넌트의 런타임에도 영향이 가게 된다.
따라서 빌드 타임과 런타임 환경에 영향을 가지 않게 하고, import 되는 컴포넌트에 부담을 덜게 하기 위해 아래와 같이 로띠 파일명만 주입받아 동적 할당할 수 있도록 커스텀 훅을 만들었다.

// useLottieJson.ts
import { useEffect, useState } from 'react';
export default (jsonPath: string) => {
  const [animationData, setAnimationData] = useState<string>(null);

  useEffect(() => {
	import('../public/images/lottie/' + jsonPath, {
      assert: { type: 'json' },
    }).then(setAnimationData);
  }, []);

  return animationData;
};

 

최적화 후

Loading.tsx:  51.36KB ➡️ 15.08KB

LottieControl.tsx: 5.07KB ➡️ 908B

PointExchangeFinish.tsx: 1.6MB ➡️ 18.71KB

 

PointGiveFinish.tsx: 1.92MB ➡️ 10.45KB

 

 

lottie 라이브러리 동적 할당 결과물


번들링 개선 사이즈: 3.57643MB ➡️ 33.79KB ➡️ [99.06% 개선]

 

고민해볼점


이미지를 public 폴더에 관리 or react처럼 src 폴더로 관리에 따라 번들링에 영향이 얼마나 갈까 고민을 해보았다. 두 방법 모두 장단점이 있고, 필자는 Next(12.2)를 사용 중에 있어 src 폴더를 default로 사용하고 있지 않고 진행해 왔다.
찾아보니 두 방법에 대해 의견을 물어보니 GPT에서도 취향 차이라고 답변을 해주고는 있었다.

 

만약 src를 생성해서 관리하게 되었다면, next.js에서 제공하는 이미지 최적화 기능을 제대로 사용하지 못할 것 같다라는 생각을 했다. src 내부에 이미지를 관리하게 된다면 모듈 번들러에서 이미지를 최적화하는 플러그인을 넣게 될 것이고, 그렇게 되면 압축된 이미지에 Next의 이미지 최적화까지 같이 진행될 때 화질뿐만이 아니라 Next 서버에서 관리되는 이미지 최적화 파일에 대한 관리가 의도대로 되지 않을 거라는 생각이 들었기 때문이다.
만약 Next와 React에서 이미지를 관리하는 방식에 대해 궁금하다면 하단 참고 링크를 보기 바란다.

 

정리


  • 번들링 최적화로 인해 많은 DX, UX를 모두 개선 시킬 수 있었고, 특히 로띠 라이브러리의 구조 개선을 통해서 눈에 띄는 개선을 할 수 있게 되었다.
  • 번들러가 트리 쉐이킹을 알아서 해준다고만 생각하여 크게 신경 쓰지 않았던 부분에서 개선 사항을 찾을 수가 있었고, 이번 기회에 최적화와 번들링에 대해서 더 이해 할 수 있게 되었다.
  • develop 레벨보다 production 레벨에서 더욱 차이를 느낄 수 있다는 점에서 새로운 경험이었다.
  • bundle-analyzer를 통해 코드 레벨에서 개선 사항을 파악 할 수 있었고, 불필요한 파일 구조, 리팩터링이 반드시 필요한 구조에 대해 파악 할 수 있었다.

 

참고


- (번역) Next.js에서 패키지 가져오기를 최적화한 방법

 

(번역) Next.js에서 패키지 가져오기를 최적화한 방법

원문 : https://vercel.com/blog/how-we-optimized-package-imports-in-next-js40% 더 빨라진 콜드 부팅 및 28% 더 빨라진 빌드 속도최신 버전의 Next.js에서는 패키지 가져오기를 최적화했습니다. 이로

velog.io

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

 

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

자잘자잘

velog.io

- Bundle이란?

 

Bundle이란?

모듈은 정말 무한하다고 할 수 있을 정도로 많고, 그렇기에 복잡합니다. 그런데, 이러한 모듈들이 서로 관계가 있기 때문에 서로 참조가 되고, 비로소 프로그램이 완성되는 거겠죠.그렇다면 저

velog.io

- Barrel files and why you should STOP using them now

 

Performance

Performance content on DEV Community

dev.to

- Dynamic import with json file doesn't work typescript

 

Dynamic import with json file doesn't work typescript

So I write a function like this to get test data for multiple environment: export class DataHelper { public static async getTestData(fileName: string): Promise<any> { return await impor...

stackoverflow.com

- [React] Create-react-app 프로젝트에서 이미지 경로를 설정하는 4가지 방법

 

[React] Create-react-app 프로젝트에서 이미지 경로를 설정하는 4가지 방법

create-react-app 프로젝트에서 public, src에있는 이미지를 사용할 때, jsx, css 에서 각각 어떻게 불러올 수 있는지 정리해본다.

velog.io

- [React] 리액트에서 이미지 경로 설정하기 (public, src 디렉토리 차이)

 

[React] 리액트에서 이미지 경로 설정하기 (public, src 디렉토리 차이)

public 디렉토리 VS. src 디렉토리

bokjiho.medium.com

- React와 Next js 이미지 경로 여러가지 방법

 

React와 Next js 이미지 경로 여러가지 방법

Nextjs에서 이미지 경로는 어떻게 설정해야할까? 나의 경우는 public폴더내 images라는 폴더를 새로 생성하여 아이콘들을 넣어두었다. nextjs에서는 public을 절대경로로 인식하고있는 것이 특징이다.

dubaiyu.tistory.com