본문 바로가기

프로젝트 개발일지/와우 디자인 시스템

[와우 디자인 시스템 개발기] 번들링 및 성능 최적화 (barrel file vs multiple entry point)

프로젝트를 진행하며 처음에 프로젝트 세팅한 거에서 조금 더 개선해 볼 만한 것들이 있어 번들링 최적화를 진행하게 되었다.

 

번들링 최적화

처음에 내가 세팅한 wow-ui 폴더를 살펴보도록 하자.
 
아래와 같이 components 폴더 내부에 각각 컴포넌트별 폴더를 생성했고, 이를 index.ts라는 배럴 파일을 통해 export 하도록 세팅했었다.

export { default as Box } from "./Box";
export { default as Button } from "./Button";

 
package.json 쪽에도 살펴보자면, 아래와 같았다.
즉, 모든 타입, cjs 방식, esm 방식 각각 다 하나의 파일에서 export 하도록 되어 있었다.

"exports": {
    "./styles.css": "./dist/styles.css",
    ".": {
      "types": "./dist/index.d.ts",
      "require": "./dist/index.cjs",
      "import": "./dist/index.js"
    },
  },

 
이렇게 하면 만약 사용자가 Box 컴포넌트만 쓰고 싶어서 사용하더라도 import 할 때 모든 컴포넌트가 들어 있는 index.js 파일을 불러오게 된다.


이 부분을 다른 팀원 분이 코드 리뷰 때 짚어주셨고, 어떻게 해결할지에 대해서 고민하고 있었다.

그러다가, vite 공식 문서를 읽던 중 아래와 같은 글을 발견했다.

우리가 원하는 대로 동작하지 않는 이유는 배럴 파일에서 모든 컴포넌트를 불러오기 때문이었다.
나는 그렇다면 한 파일만 진입점으로 지정해 주는 게 아니라, 컴포넌트별로 개별로 진입점을 설정해 주면 되지 않는가?라는 생각이 들었다.
 
그래서, rollup 공식 문서를 보면서 여러 파일을 진입점으로 지정할 수 있는지 찾아보게 되었다.
아래 글을 읽어보면 알다시피 input은 string[], 즉 여러 진입점을 지정해 줄 수 있었다.

 
우리가 원하는 대로 모든 환경에서 트리쉐이킹이 되도록 동작하기 위해서는 배럴 파일을 사용하는 것이 아니라, 각 컴포넌트별로 진입점을 지정해 주면 된다는 결론으로 수렴하게 되었다.
 
지금까지 얘기한 부분이 이해가 되지 않는다면 아래 링크에 있는 댓글을 한 번 읽어봐도 좋을 것 같다.
https://github.com/GDSC-Hongik/wow-design-system/pull/4#issuecomment-2102153249
 
어쨌든, 위에서 언급한 것처럼 변경하기 위해서 먼저 배럴 파일을 삭제했다.
그러고는, rollup 설정 파일에서 진입점으로 아래와 같이 여러 컴포넌트를 지정해 주었다.

input: { Box: "./src/components/Box", Button: "./src/components/Button" },

 
또한, package.json에서도 아래와 같이 컴포넌트별로 export 속성을 지정해 주었다.

"exports": {
    "./styles.css": "./dist/styles.css",
    "./Box": {
      "types": "./dist/Box/index.d.ts",
      "require": "./dist/Box.cjs",
      "import": "./dist/Box.js"
    },
    "./Button": {
      "types": "./dist/Button/index.d.ts",
      "require": "./dist/Button.cjs",
      "import": "./dist/Button.js"
    }
  },

 
이렇게 하니 우리가 원하는 대로 모든 환경에서 잘 트리쉐이킹되도록 번들링을 할 수 있었다.


그래서 아래와 같이 모든 컴포넌트 파일이 있는 index.js 파일을 불러오는 것이 아니라, 내가 사용하려는 Checkbox, TextField, Switch, Chip 이렇게만 불러와서 사용할 수 있게 된다.

 

그런데, 이렇게 필요한 컴포넌트만 불러오는 거까지 확인은 했는데 진짜 유의미한 성능 차이가 있을까?라는 생각이 들었다.

그래서, 또 한 번의 실험을 했다.

 

아래 레포지토리에서 두 가지 경우의 수를 테스트해봤다.

하나는 배럴 파일을 사용해서 모든 컴포넌트를 내보내는 방식, 다른 하나는 multiple entry point, 즉 컴포넌트별로 진입점을 설정해 주는 방식이다.

https://github.com/ghdtjgus76/barrel-file-bundling-performance-test

 

테스트를 해보기 전에 궁금했던 점은 배럴 파일을 이용해서 번들링 하더라도 트리쉐이킹이 잘 작동한다면 우리가 원하는 컴포넌트만 불러오지 않을까? 였다.

이 부분에 대해서는 진짜 많은 사람들이 궁금해했었다.

아래 터보레포 PR에서도 관련한 내용을 확인할 수 있었다.

https://github.com/vercel/turbo/pull/7580

결국 터보레포에서도 우리처럼 multiple entry point를 사용했다.

 

다들 궁금한 사람들만 많았고 확실한 답을 주는 레퍼런스가 없어서 내가 직접 실험해 보기로 했다.

 

결론부터 말하자면, 나라면 multiple entry point를 쓸 거 같다.

물론 rollup input, package.json exports 속성을 자동으로 생성해 주는 스크립트를 도입한다는 전제하에...

만약 이 스크립트를 도입하지 않을 거라면 깔끔하게 배럴 쓰는 걸 추천...

 

그래서, 내가 비교한 건 딱 두 가지이다.

1. 모든 컴포넌트를 불러와서 사용하는 경우

2. 몇 개의 컴포넌트만 불러와서 사용하는 경우

 

위에 언급했던 레포에서는 50개의 다른 텍스트필드 컴포넌트를 작성해 뒀고, 1번 테스트를 위해 50개의 텍스트필드 모두를 import 해와서 테스트했고, 2번 테스트를 위해서 50개 중 10개만 import 해서 테스트했다.

그리고 이 테스트는 @next/bundle-analyzer를 이용해 결과를 비교했다.

 

1번 결과부터 알아보자.

안타깝게도 multiple entry point가 좀 더 큰 번들 사이즈를 갖는다.

이는 배럴 파일의 경우 한 파일에 모든 컴포넌트들을 다 넣어두기 때문에 번들링 과정에서 공통적인 코드가 삭제되기 때문이다.

하지만, multiple entry point는 중복된 코드들도 있을 수 있지만 별도의 파일로 번들링 되기 때문에 배럴만큼 효과적으로 공통 코드가 삭제되지 않는다.

또한, 배럴은 하나의 파일로 관리하기 때문에 만약 모든 컴포넌트들을 사용할 거라면 multiple entry point보다 효과적인 방법일 것이다.

처음에 파일을 불러온 후 캐싱해 둘 수 있고, 이후 어떤 컴포넌트를 불러오든 별도의 네트워크 요청 없이 불러올 수 있을 것이다.

 

근데 주목해야 하는 점은, 이 코드는 똑같은 텍스트필드 컴포넌트 50개를 생성해서 테스트한 것이기 때문에 사실상 공통 코드 덩어리인데, 배럴과 multiple entry point 간의 차이가 클 수밖에 없다.

ui 컴포넌트 라이브러리를 개발할 때는 이 정도로 극단적으로 공통 코드가 많지 않을 것이기 때문에 배럴과 multiple entry point app 크기 차이가 훨씬 덜할 것이다.

 

이제 2번 결과를 알아보도록 하자.

전체 50개의 컴포넌트들 중에서 10개의 컴포넌트만 불러와서 사용했을 때의 모습이다.

바로 결과가 한눈에 보인다.

내가 예상했던 것처럼 배럴 파일을 쓰게 되면 모든 컴포넌트를 불러오게 되어 불필요한 파일이 삭제되지 않았다.

그래서 배럴 방식은 컴포넌트를 50개를 불러오나 10개를 불러오나 그렇게 큰 차이는 없었다.

하지만 multiple entry point의 경우 50개일 때와 10개일 때를 확실히 구분할 정도로 큰 차이가 있었다.

 

아 그리고 @next/bundle-analyzer를 사용하면 Stat, Parsed, Gzipped 세 상태로 분석해서 볼 수 있는데, Stat은 번들이 파일 시스템에 저장될 때의 크기를 보여준다.

Parsed는 번들이 메모리로 로드되고 브라우저가 자바스크립트 코드를 분석하는 과정에서의 크기를 보여준다.

Gzipped는 번들이 네트워크를 통해 전송될 때의 크기를 의미한다.

그래서 Gzipped 크기를 줄이면 네트워크 전송 속도가 빨라지고 Parsed 크기를 줄이면 브라우저에서의 실행 성능이 향상된다.

 

위에서 node.html, client.html도 나오는데, node.html은 node.js 환경에서 번들을 분석하는 데 사용된다.

즉, 서버 측에서 주로 실행되는 것과 관련이 있고, client.html은 브라우저, 즉 클라이언트 측에서 주로 실행되는 것과 관련이 있다.

 

내가 생각했을 때 디자인 시스템 유저들이 우리 디자인 시스템 모든 컴포넌트를 가져다 쓰기보다는 필요한 몇몇 컴포넌트를 쓸 가능성이 높다고 판단해 multiple entry point 방식으로 결정했다.

 

성능 최적화 

마지막으로, 아주 사소한 성능 최적화도 진행해 보았다.

파일 import 시 확장자 명시하는 부분 관련 문제인데, 이번에도 vite 공식 문서를 읽어보다가, 아래와 같은 글을 발견하게 되었다.

 

확장자를 명시하지 않으면 저렇게 여러 확장자들을 탐색하면서 파일을 찾는다고 한다.

그래서, 성능적으로 좋지 않은 결과를 불러온다는 사실을 알게 되었고, 어떻게 하면 우리 프로젝트에서도 이 문제를 최소화할 수 있을까 하는 방법에 대해서 고민해 보았다.

 

기존 rollup 설정 파일에서도 우리가 많이 사용하는 확장자들을 명시하고 사용하고 있었다.

모듈을 찾을 때 아래 확장자를 사용하도록 설정해 주는 부분이었다.

const extensions = [".js", ".jsx", ".tsx", ".ts"];

export default {
  // ... 
  plugins: [
    resolve({ extensions }),
    // ...
  ],
};

 

기존에는 순서 상관없이 우리가 쓸 확장자를 나열해 놓았었다.

하지만, 모듈을 찾을 때 우리가 지정한 확장자 배열을 순서대로 탐색한다는 것을 알게 되어 보다 많이 사용하는 확장자를 앞에 두면 성능적으로 문제가 덜 생기지 않을까 하는 생각이 들었다.

물론, 확장자를 명시해 주는 것이 제일 좋겠지만, 개발하다 보면 실수로 빼먹는 경우도 있을 수 있으니까,,,

function resolveModulePath(modulePath, extensions) {
  // 확장자를 순서대로 탐색합니다.
  for (const ext of extensions) {
    // 현재 확장자를 추가하여 경로를 만듭니다.
    const fullPath = modulePath + ext;
    // 해당 경로에 파일이 존재하는지 확인합니다.
    if (fileExists(fullPath)) {
      // 파일이 존재하면 해당 경로를 반환합니다.
      return fullPath;
    }
  }
  // 모든 확장자에 대해 파일을 찾지 못한 경우 에러를 발생시킵니다.
  throw new Error(`Module not found: ${modulePath}`);
}

 

어쨌든 그래서, 컴포넌트 개발을 하는 wow-ui에서는 아무래도 tsx 확장자를 제일 많이 사용할 것 같아 tsx, ts, js, jsx 순으로 지정해 주었고, 나머지 패키지에서는 ts, tsx, js, jsx 순으로 지정해 주게 되었다.

 

이렇게 문제가 다 사라진 듯했으나,,, 아직 남아있었다.
컴포넌트를 다 작성했다고 가정해 보자.
그럼 이제 rollup 설정 파일 가서 진입점에 해당 컴포넌트도 추가해주어야 하고, package.json 파일 가서 exports 속성에 해당 컴포넌트 내용을 또 추가해줘야 한다.
너무 비효율적이지 않을까?라는 생각에 자동화 스크립트를 작성하기로 마음먹었다.