본문 바로가기

공부 기록/React & etc.

webpack and yarn magic against duplicates in bundles

원문 : https://www.developerway.com/posts/webpack-and-yarn-magic-against-duplicates-in-bundles

 

webpack and yarn magic against duplicates in bundles

This page describes the theory and some technical details behind the webpack-deduplication-plugin plugin, which helped us reduce javascript size in Jira by ~10%.

www.developerway.com

* 이 글은 온전히 제가 해당 포스트를 더 잘 이해하기 위하여 매우매우 많은 의역을 한 번역문입니다. 내용 상의 문제가 있는 경우 댓글로 알려주시면 감사하겠습니다.


들어가기에 앞서

이 글은 최신 디펜던시와 번들 도구에 대한 입문서가 아닙니다. 따라서 최신 프론트엔드 프로젝트에서 npm, yarn, webpack과 같은 도구들이 어떻게 사용되는지 당신이 어느 정도 알고 있으며, 그 밑단에서 벌어지는 일들에 대해 더욱 깊은 이해를 원하고 있다고 가정해보겠습니다.

 

이 글에 사용된 용어들 :

- 직접(명시적) 의존성 : 프로젝트가 명확하게 의존하는 패키지 - 일반적으로 yarn add {package-name} 명령어로 설치합니다. 이에 대한 전체 목록은 프로젝트 루트 경로에 있는 package.json의 dependencies 필드에서 확인할 수 있습니다.

 

- 전이적 의존성 : 프로젝트가 암시적으로 의존하는 패키지 - 프로젝트에 직접적으로 정의되어 있는 디펜던시가 의존하고 있는 의존성으로서, 이를 package.json에서 찾아보기는 힘듭니다. 그러나 yarn.lock과 같은 파일에서는 확인 가능합니다.

 

- 중복된 의존성 : 잘못된 버전의 전이적 의존성 - 예를 들어 어떤 한 디펜던시가 4.0.0 버전의 버튼 패키지를 전이적 의존성으로 갖고 있고 동시에 다른 디펜던시가 동일한 버튼 패키지의 3.0.0 버전을 의존할 때 두 버전 모두 설치가 되며, 이에 따라 버튼 의존성이 중복될 것입니다.

 

- 중복 제거 : 중복된 의존성의 시맨틱 버전을 통한 제거 과정 - 일반적으로 같은 메이저 버전 안에 있는 범위의 버전들은 상당한 변화를 수반하지는 않습니다. 그리고 이 범위 안의 가장 최신 버전만이 설치됩니다. 예를 들어 4.0.0 버전과 4.5.6 버전의 버튼에 대한 의존성은 중복 제거되어 4.5.6 버전만 설치될 것입니다.

 

yarn.lock 파일 : yarn을 기반으로 한 프로젝트의 모든 직접적 및 전이적 의존성의 전체 목록과 그 정확한 버전을 담고 있는, 자동 생성 파일

 


의존성 중복이 문제인 이유

 

npm 패키지를 이용하는 어떤 중-대형급 프로젝트에서도 중복된 의존성은 피할 수 없는 문제입니다. 어떤 프로젝트가 열댓 개의 직접 의존성을 가지고 있을 때를 생각해보죠. 이때 그 열댓 개의 의존성은 모두 각각의 종속성을 가지고 있어서 프로젝트에 설치된 모든(직접 및 전이) 의존성은 백여 개에 달할 수 있고, 따라서 의존성 중복이 일어날 확률도 높아질 것입니다.

 

이렇게 중복이 일어난 의존성들은 함께 번들링되어 최종 배포까지 넘어갈 거예요. 최종 자바스크립트의 사이즈를 줄이기 위해서는 이러한 중복을 최대한 제거하는 것이 중요할 것입니다. 여기서 우리는 중복 제거 프로세스를 떠올리고, 이를 실행시켜야 합니다.

 


yarn에서의 중복 제거

 

어떤 프로젝트에서, 직접적 의존성 중 어떤 것들이 modal-dialog@3.0.0와 button@2.5.0 패키지를 의존하고 있다고 예를 들어보겠습니다. 이때 modal-dialog는 button@2.4.1 패키지를 전이적 의존성으로 가지고 있습니다. 이 중복된 의존성들을 그냥 내버려둔다면 프로젝트 안에 버튼 패키지의 두 버전이 공존하게 되겠죠.

 

 

 

그리고 yarn.lock 파일은 아마 아래와 같은 모습일 겁니다.

modal-dialog@^3.0.0:
  version"3.0.0"
  resolved"exact-link-to-where-download-modal-dialog-3.0.0-from"
  dependencies:
    button@^2.4.1

button@^2.5.0:
  version"2.5.0"
  resolved"exact-link-to-where-download-2.5.0-version-from"

button@^2.4.1:
  version"2.4.1"
  resolved"exact-link-to-where-download-2.4.1-version-from"

 

 

자, 우리는 시맨틱 버전 button@2.4.1과 button@2.5.0은 호환된다는 것을 알고 있죠. 따라서 우리는 yarn에게 이 두 버전의 중복을 제거하여 button@2.5.0만 사용하게끔 만들 수 있습니다. 프로젝트 관점에서는 아래와 같이 표현할 수 있습니다.

 

 

 

yarn.lock 파일은 아래와 같을 겁니다.

modal-dialog@^3.0.0:
  version"3.0.0"
  resolved"exact-link-to-where-download-modal-dialog-3.0.0-from"
  dependencies:
    button@^2.4.1

button@^2.4.1, button@^2.5.0:
  version"2.5.0"
  resolved"exact-link-to-where-download-2.5.0-version-from"

 

 


호환되지 않는 버전에 대한 yarn에서의 중복 제거

 

위와 같은 중복 제거 방법은 우리가 중복을 제거하기 위해 사용하는 거의 유일한 방법입니다. 이 방법은 잘 통하기는 해요. 하지만 우리의 프로젝트가 중복 불가능한(호환되지 않는) 전이적 의존성을 가지고 있다면 어떻게 될까요? 만약 우리 프로젝트가 modal-dialog@3.0.0과 button@2.5.0, editor@5000.0.0 패키지를 직접 의존하고 있고, 이때 전이적 의존성으로 button@1.3.0과 button@1.0.0을 가지고 있을 때는 어떻게 해야 할까요?

 

위에서 살펴본 방식을 그대로 적용하면, 1.x.x 버전은 호환이 가능할 것이고, 중복 제거도 가능할 겁니다. 프로젝트 관점에서 보면 아래와 같이 표현할 수 있습니다.

 

 

 

yarn.lock 파일은 아래와 같습니다.

modal-dialog@^3.0.0:
  version"3.0.0"
  resolved"exact-link-to-where-download-modal-dialog-3.0.0-from"
  dependencies:
    button@^1.0.0

editor@^5000.0.0:
  version"5000.0.0"
  resolved"exact-link-to-where-download-editor-5000.0.0-from"
  dependencies:
    button@^1.3.0

button@^2.5.0:
  version"2.5.0"
  resolved"exact-link-to-where-download-2.5.0-version-from"

button@^1.0.0, button@^1.3.0:
  version"1.3.0"
  resolved"exact-link-to-where-download-1.3.0-version-from"

 

 

버튼 패키지가 두 번 정의된 건 어쩔 수 없습니다. modal-dialog와 editor 패키지가 2.x.x 버전의 버튼 패키지를 가지고 있고 중복 제거가 가능하다면, 버전을 업그레이드하는 방법 밖에는 없을 겁니다. 보통은 이렇게 두 개의 버전을 동시에 가지고 있는 채로 더이상 개선을 도모하지는 않아요.

 

그러나, 이렇게 두 가지 버전의 버튼 패키지가 어떻게 동시에 설치되고 번들링되는지 제대로 파악해보고, 더 깊이 살펴보면 어떨까요?

 


의존성의 중복 설치

 

오래된 버전의 yarn이나 npm을 통해 의존성을 설치할 때(pnpm이나 yarn 2.0은 여기에서는 논외로 합시다.), npm은 루트 node_modules에 이르기까지 가능한 모든 것을 호스팅합니다. 만약 우리 프로젝트의 editor와 modal-dialog 패키지 모두 tooltip 패키지의 중복된 버전을 의존하고, 우리 프로젝트 자체는 tooltip 패키지를 의존하지 않을 때, npm은 프로젝트 루트에 tooltip 패키지를 설치할 것입니다.

 

 

 

node_modules 폴더 내부는 아래와 같은 구조가 될 거예요.

/node_modules
  /editor
  /modal-dialog
  /tooltip

 

 

그럼으로써, 우리는 프로젝트 내부에 단 하나의 tooltip 패키지 버전만 가지게 된다는 걸 확신할 수 있습니다. 완전히 다른 두 개의 의존성이 약간씩 다른 버전의 패키지를 의존하고 있다고 하더라도요.

 

그 버전들이 호환 가능한 시맨틱 버전이 아니라서 쉽게 중복 제거가 불가능한 경우가 아니라면 말이죠. 기본적으로, 위에서 살펴봤던 button 패키지를 가진 프로젝트는 다음과 같아요.

 

 

비록 yarn.lock 레벨에서 의존성이 중복되고 공식적으로 yarn.lock 내부에 오직 두 개의 button 패키지만 가지고 있다고 하더라도, button@1.3.0을 의존성으로 가지는 패키지는 모두 각각의 복사본을 설치하게 됩니다.

 


 

중복된 의존성과 webpack

 

자, 프로젝트가 webpack과 함께 번들링되었을 때, 위와 같은 상황을 정확히 어떻게 처리하는 걸까요? 실제로는 처리하지 않아요.(오래 전에 웹팩 중복 플러그인이 있었지만 webpack 2.0이후로 삭제되었습니다.)

 

웹팩은 우리 파일과 의존성을 밑단에서 그래프화합니다. 이때 normal node resolution algorithm을 통해 node_modules에 설치되고 필요로 하는 것들을 기반으로 하죠.

요약: 파일에서 “import Button from ‘button’”을 하는 매순간마다 node는 이 폴더의 부모 폴더로부터 가장 가까운 node_modules에서 이 button을 찾으려 할 겁니다. modal_dialog도 마찬가지예요. webpack 관점에서 button에 대한 최종 요청은 아래와 같습니다.

 

- project/node_modules/editor/node_modules/button/index.js → editor 패키지에서 요청될 때

- project/node_modules/modal-dialog/node_modules/button/index.js → modal-dialog 패키지에서 요청될 때

 

webpack은 이 두 가지가 정확히 동일한지 확인하지 않아요. 고유한 파일로서 같은 번들 안에 묶어놓을 겁니다. 우리의 중복된 버튼이 두 배로 중복되었습니다!

 


webpack에서의 중복 - 첫번째 시도

 

두 개의 button이 완전히 동일할 때, 이런 생각이 드실 거예요. webpack이 두 개가 완전히 같다는 것을 인지하도록 속이는 것이 가능할까? 사실 이건 가능하고, 심지어 엄청나게 간단합니다.

 

webpack은 어마어마하게 유동적이예요. 상상할 수 있는 거의 모든 것들(심지어 상상할 수 없는 것들까지도)에 접근할 수 있는 풍부한 플러그인 인터페이스를 제공합니다. 핵심적으로 굉장히 많은 기능들이 플러그인으로 내장되어 있고, 다른 사람들이 사용할 수 있도록 많은 것들을 export 합니다.

 

그 플러그인들 중 하나인 NormalModuleReplacementPlugin은 정규식에 기반하여 빌드되는 동안 한 파일을 다른 파일로 변경할 수 있게 해줍니다. 바로 우리가 원하던 거잖아요! 나머지 신경써야할 부분은 코딩 뿐입니다.

 

먼저 중복된 의존성을 모두 찾아내야 합니다. node_modules 내의 모든 패키지 목록을 가져와서 설치 경로에 node_modules가 여러 번 들어있는 것들을 필터링하고(위의 yarn install 챕터의 모든 중첩된 패키지들), package.json의 버전 별로 그룹핑합니다.

 

 

 

다음으로, 같은 패키지의 모든 요소들을 목록의 가장 첫번째 요소로 대체합니다.

 

 

이게 끝입니다! 이 솔루션은 잘 작동할 것이고, 안전하며, Jira의 번들 크기를 최대 10% 줄여줄 겁니다.

 

 

전체 구현은 말그대로 100 라인 뿐이었습니다. 하지만 이 접근법으로 만족하고 카피하는 것에 주의하세요. 이 글은 아직 끝나지 않았습니다!

 


webpack에서의 중복 제거 - 실제 솔루션

 

위에서 살펴본 솔루션이 안전하고 괜찮기는 하지만(우리는 그걸 운영계로 배포하기 전에 야무지게 테스트했습니다.), 아쉽게도 side-effect가 있었습니다. 웹팩이 생성해낸 것들이 그때그때 달라지게 된 겁니다. 재빌드를 할 때마다 웹팩은 중복된 모듈의 일부를 이동시키거나, 내부적인 id를 새로 생성했습니다. 우리가 작성한 코드 단에서 가능한 모둔 원인들은 아주 빠르게 제거되었어요. webpack 내부에서 뭔가 이상한 일이 일어나는 것 같습니다.

 

왜 이렇게 된 건지 디버깅하고, 그 이유를 이해하고, 이 문제를 해결할 솔루션을 release하는 데 일주일이 걸렸고, NormalModuleReplacementPlugin과 webpack 내부에 대해 깊이 탐색해보는 데 이를 적절해 설명하는 데에 아주 방대한 아티클이 필요했습니다. 그와중에 발견했던 yarn과 webpack에 대한 가장 흥미로운 것들에 대해서 여기 나열해볼게요(특별한 순서 없이). 여러분이 알고 계시면 도움이 될 거 같아요.

 


발견한 내용들과 기타 궁금한 점들

비결정론적인 행동은 비결정론적으로 재현된다.

여러분이 어떤 작은 종합적 예에서 비결정론적인 부분을 재현하려고 하면, 대부분은 재현하지 못할 겁니다. 제 토이 프로젝트에서 전체 @atlaskit/editor를 import할 때는 재현이 가능했지만, 몇 개의 Atlaskit 컴포넌트를 조합하는 것만으로도 불가능해졌습니다. webpack 분할 코드를 만들기 위해서 최소한으로 청크 크기를 줄이면서 수동으로 비동기 import를 해도 안 됐어요.

 

근데 희한한 게, 파일 요청을 덮어쓰기 위해 우리가 listen해야 하는 hook의 순서는 항상 다르지만, 작은 예제에서의 최종 결과(중복제거 없이)는 결정론적이었습니다.

 

NormalModuleReplacementPlugin은 그 목적으로 만들어진 게 아닙니다.

먼저, NormalModuleReplacementPlugin은 결과에 대한 요청 속성에만 RegExp를 실행하고 요청만 대체합니다. 그러나 모듈의 근원에 대한 정보를 담고 있는 결과 객체에는 더 많은 속성들이 있습니다(이론적으로 같이 대체되어야 합니다). 그 중 하나는 요청이 실제로 시작된 컨텍스트입니다. 그리고 파일이 상대적으로 요청되면 요청 속성에 상대 경로도 포함됩니다.

{
  request:"./styled",
  context:"/project/node_modules/editor/node_modules/button"
}

 

파일의 최종적인 경로는 두 가지 모두에 대한 해법입니다. 그리고 중복된 것들을 적절히 탐지해내기 위해 두 가지 모두를 확인해야 하고 중복된 정보를 가지고 있는 모든 필드를 대체해야 합니다(컨텍스트를 포함해). NormalModuleReplacementPlugin은 하지 않는 일이죠.

 

그럼에도 불구하고, NormalModuleReplacementPlugin은 필요한 경우 사용할 수 있습니다.

위에서 살펴본 모든 것들이 사실 완전히 정답은 아니며, 그나마 두 가지 모두에서 절대 경로로 요청을 대체하는 게 가장 나은 방법이기 때문입니다.

 

이런 모습에서

{
  request:"./styled",
  context:"/project/node_modules/editor/node_modules/button"
}

 

이렇게 변경됩니다.

{
  request:"/project/node_modules/modal-dialog/node_modules/button/styled",
  context:"/project/node_modules/editor/node_modules/button"
}

 

webpack은 이에 대해서도 적절하게 번들링할 수 있어요.

 

단순히 표면적으로 대체해선 안 됩니다.

이론적으로 동일한 패키지라 해도 실제로는 그렇지 않습니다. 이 차이점을 “전이적 의존성의 전이적 의존성”이라 합니다. 가장 단순한 예시를 설명해드릴게요.

  • 루트 경로에 button@2.x와 icon@3.x 패키지가 있습니다.
  • editor 패키지는 button@1.x를 전이적으로 의존하고 있고, 이 button 패키지는 icon@1.x를 전이적으로 의존하고 있습니다.
  • modal-dialog 패키지는 button@1.x를 전이적으로 의존하고 있고, 이 button 패키지는 icon@1.x를 전이적으로 의존하고 있습니다. 게다가 icon@2.x를 전이적으로 의존하고 있습니다. (지금까지 읽고 계신 분들에게 찬사를 보냅니다! 😂)

 

 

 

폴더 구조는 아래와 같이 표현할 수 있을 겁니다.

/node_modules
  /editor
    /node_modules
      /button-1.3.0
      /icon-1.0.0 // 위의 버튼 패키지와 같은 레벨에 있음
  /modal-dialog
    /node_modules
      /button-1.3.0
        /node_modules
          /icons-1.0.0 // 위의 레벨에 다른 icon이 있으므로 button 내에 중첩됨
      /icon-2.0.0
  /button-2.5.0

 

icon@1.x에 대한 최종 요청은 아래와 같습니다.

/project/node_modules/editor/node_modules/icon-1.0.0
/project/node_modules/modal_dialog/node_modules/button-1.3.0/node_modules/icon-1.0.0

 

button@1.x가 중복되어 있다는 걸 생각해볼 때, modal_dialog 안에 있는 button을 editor에 있는 button으로 대체해야 될 것 같아요. 이걸 단순히 startsWith로 대체한다면…

/project/node_modules/modal_dialog/node_modules/button-1.3.0

 

그리고 이것도요.

/project/node_modules/editor/node_modules/button-1.3.0

 

 

마지막 icon의 경로는 아래와 같이 변경될 겁니다.

/project/node_modules/editor/node_modules/button-1.3.0/node_modules/icon-1.0.0

마치며

자, 비결정론적으로 작동되는 결정적인 원인이 뭐였죠? 아래의 내용을 종합하면 될 겁니다.

  • webpack 단에서 hook들의 그때그때 다른 순서
  • 표면적으로만 대체된 초기 string 값
  • 첫번째 의존성에 대한 요청만 판별하고 대체하는 것
  • 여기서 언급하지 않은, 모든 모듈이 해결되지는 않은 예외 사례들

 

이제는 해결됐습니다! 플러그인은 지라에서 야무지게 테스트되었고 모두가 이를 이용할 수 있어요. 그럼으로써 번들 크기를 조금 줄일 수 있게 되었습니다. 지라에서는 이슈 보기 페이지에서 전체적으로 약 10퍼센트 정도 번들 크기를 줄이고 TTI를 약 300ms 개선했습니다. 아래 링크에서 이 플러그인을 사용할 수 있습니다!

 

https://github.com/atlassian-labs/webpack-deduplication-plugin

 

GitHub - atlassian-labs/webpack-deduplication-plugin: Plugin for webpack that de-duplicates transitive dependencies in yarn and

Plugin for webpack that de-duplicates transitive dependencies in yarn and webpack-based projects. - atlassian-labs/webpack-deduplication-plugin

github.com

'공부 기록 > React & etc.' 카테고리의 다른 글

State VS Ref  (2) 2024.02.18
Attribute props VS Children props  (2) 2024.02.04