JavaScript

CommonJS와 ES Modules의 Tree-shaking, 왜 ES Modules인가?

남희정 2024. 9. 11. 21:18

2024.08.10 - [Development Environment] - Why Vite? JS 모듈화의 역사, CJS, ESM, Webpack 

2024.08.17 - [Development Environment] - 2016, Left-pad 사건에 대한 Rich Harris(Rollup)의 글로 알아보는 번들링의 중요성

 

위 글과 함께 차례대로 보게 된다면 더욱 이해가 잘 될 것이다. 

이전 글에서 ES Modules에 비해 CommonJS의 단점으로 Tree-shaking이 어렵다는 점을 꼽았다. 단순히 import와 require만이 차이점은 아닐 것이다. 정확히 어떤 이유로 그렇게 말을 하는지, 동작 방식을 파헤쳐보면서 이유를 알아보자.

 

Tree-shaking의 정의와 역사

  • 코드를 최적화 할 때 적용되는 쓸모없는 코드 제거 기술이다. 단일 라이브러리에 대한 dead-code 제거 기법과 대조된다.
  • dead-code 제거 : 주로 실행되지 않는 코드를 제거, 코드의 세부적인 부분.
    ex) 조건문에서 항상 false인 분기, 변수 할당 후 사용되지 않는 경우, 반복문에서 반복하지 않는 코드 등
    컴파일러 수준에서 코드 최적화를 위해 이루어지는 경우가 많다. => 상대적으로 작은 영향, 정적 언어에서 많이 사용(C, C++, Java)
  • Tree-shaking : 모듈 수준에서 모듈 전체를 대상으로 사용되지 않는 코드를 제거.
    import/export와 같은 모듈의 구조를 분석해 사용되지 않는 코드를 제거한다. 번들링 과정에서 이루어짐
    => 전체 번들 크기 감소, 더 큰 영향, 동적 언어에서 특히 중요(JavaScript, Python)
  • 동적 언어에서의 dead-code 제거는 정적 언어에서보다 더욱 어려운 문제였다.
    Tree-shaking은 1990년대 LISP(동적 언어)에서 처음 언급되었다. (프로그램의 모든 가능한 실행 흐름은 함수 호출 트리로 표현할 수 있으므로 호출되지 않는 함수를 제거 할 수 있다는 아이디어)
    이후 2010년대에 특히 JavaScript 모듈 번들러에서 널리 사용되기 시작했다. (Rich Harris의 Rollup) Rich Harris의 설명을 들으면 훨씬 명확하게 이해할 수 있다. 그는 Dead code elimination과 Tree shaking은 같은 목표를 갖고 있더라도 둘은 서로 다르다고 말한다. 
계란을 깨서 내용물을 쏟아내는 대신, 계란을 믹싱 볼에 넣고 깨서 케이크를 만들었다고 상상해 보세요. 케이크를 오븐에서 꺼낸 후, 계란 껍질 조각을 제거해야 하지만, 꽤 까다로워서 계란 껍질의 대부분이 그대로 남게 됩니다. 죽은 코드 제거는 완성된 제품을 가져와서 원치 않는 부분을 불완전하게 제거하는 것으로 구성됩니다.

반면에 Tree-shaking은 정반대의 질문입니다. 즉, 케이크를 만들고 싶다고 가정했을 때 믹싱 볼에 어떤 재료를 넣어야 할까요? dead-code를 제외하는 대신 쓰는 코드만 포함하는 것입니다. 이는 사용자가 사용하지 않는 코드를 다운로드하지 못하도록 방지하는 문제에 대한 보다 논리적인 접근 방식입니다.

 

Dead code 제거와 Tree-shaking의 차이를 보여주는 이미지 (출처 : https://so-so.dev/web/tree-shaking-module-system/)

 

Dead-code 제거와 Tree-shaking의 차이는 알겠다. 그것이 정적 언어와 동적 언어의 특징에서 어떻게 작용하는지 좀 더 자세히 알고싶다. 그리고 동적언어인 JS에서 Dead code와 Tree shaking의 차이가 CommonJS와 ES Modules에서 어떻게 적용되기에 이런 차이점이 있다고 말하는걸까? 정적 언어와 동적 언어에 대해 좀 더 알아보자.

정적 언어와 동적 언어

  • 정적 언어 (Static Languages)
    - C, C++, Java, Go, Rust
    - 컴파일 타임(프로그램을 실행하기 전에 코드 => 기계어로 변환되는 단계)
    - 컴파일러가 프로그램 코드를 실행하기 , 코드 전체를 분석하고 미리 오류를 찾거나 최적화를 수행.
    - 변수 타입이 미리 고정되고 코드의 구조도 결정되기에 대부분의 정보가 미리 확정된다.
    - C++같은 언어에서는 변수를 선언하고 타입을 명시해야한다.
    🌟 정적 분석을 통해 해당 코드가 실행되지 않거나 사용되지 않는지를 시행 전에 분석하여 실행할 수 있기에 dead code를 식별하고 제거하는 것이 가능한 것이다.
  • 동적 언어 (Dynamic Languages)
    - JavaScript, Python, Ruby, PHP
    - 런타임 : 프로그램이 실제로 실행되는 순간. 동적 언어에서는 프로그램이 실행될 때 변수의 타입이나 코드의 흐름이 결정된다.
    - 변수의 타입도 실행 중에 바뀔 수 있고, 코드의 일부를 실행 중에 새로 생성해서 사용할 수도 있다.
    - 위에 따라서 코드 작성이 유연하다.
    - JavaScript에서는 변수를 선언할 때 타입을 명시하지 않는다. (오류 발생 가능성이 높다 => 타입스크립트가 나온 이유)
    🌟 JS와 같은 모듈 기반 언어는 의존성 그래프를 통해 사용되지 않는 모듈을 제거하고 실제 필요한 모듈만 사용할 수 있다.

동적 언어인 JS에서 ES Module을 사용할 때 모듈의 의존성 그래프를 어떻게 탐색하는지, 필요 없는 모듈을 제거하는 방식을 구체적으로 알아보자.

ES Modules 시스템 파헤치기

  • ESM 시스템은 구성, 인스턴스화, 평가 세 단계로 이루어진다.

ESM 시스템 3단계 요약 (출처: https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/)

  • 1. 구성
    가장 첫 단계로 로드해야 하는 모듈을 파악하기 위해 종속성 트리를 구성한다.

    그래프의 시작점이 될 파일을 명시하고, 시작점에서 import 문을 따라가며 종속성 트리를 생성한다.

    import로 연결된 파일 자체는 브라우저가 사용할 수 없으므로
    Module Record (export, import 정보가 담긴 데이터) 구조로 변환한다.
    => 이 과정에서 모든 파일을 찾아서 load하고, Module Record로 변환하기 위해 구문분석(Parsing)을 수행한다.

import로 연결된 파일들을로 종속성 트리를 구성 (출처: https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/)
Module Record 구문 분석 (출처: https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/)

  • 2. 인스턴스화
    Module Records를 Module instance로변환한다.
    Module instance는 code와 state라는 두 가지를 결합한 상태이다.
    code : 그 자체로 일련의 지침, 레시피. 그 자체로는 아무것도 할 수 없고 사용할 값이 필요하다. 
    state : 특정 시점에서의 변수의 실제 값. 메모리라고 표현하는게 좀더 정확하다.

    즉 import할 모든 값을 할당할 메모리 공간을 찾는 과정이고, export / import 모두 해당 메모리를 가리키도록 한다.

Module Instance (code + state) (출처: https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/)

  • 3. 평가
    코드를 실행하여 변수의 실제 값으로 메모리를 채우는 과정
    각 단계는 개별적으로, 또 비동기적으로 수행될 수 있다.

 

ESM이 import, export를 통해 구문을 분석하고 파싱하여 적절한 메모리를 할당하고 사용한다는 것은 알겠다. 그럼 CJS는 저 방식이 아니라는 말인가?

CommonJS와 ES Modules의 동작 방식 차이

  • CommonJS는 동적 구조를 갖고 있으며 동기적으로 동작한다.  
  • require() 함수를 사용하여 모듈을 가져오고, module.exports를 사용하여 모듈을 내보낸다. require()함수는 코드의 어느 위치에서든 호출할 수 있다. 해당 코드가 실행될 때 그 순간에 모듈을 가져오는 방식, 그리고  해당 모듈이 로드될 때까지 코드 실행은 중단된다. 

require (출처 : https://so-so.dev/web/tree-shaking-module-system/)


위의 코드를 보자. lib.someFunc를 통해 lib에 접근할 때, lib는 동적인 값이기 때문에 property lookup을 수행해야 한다.

 

  • ESM은 이와 반대로 lib를 가져올 때 lib의 정보를 정적으로 파악할 수 있기 때문에 access를 최적화할 수 있다.
    CommonJS와 달리 export문은 무조건 ESM 최상위 레벨에만 위치할 수 있다. 또한 JS 표준으로 브라우저 환경에서 비동기 로드를 지원하기에 브라우저에서 모듈을 로드할 때 페이지 로딩 속도를 저하시키지 않기 때문에 중요하다. 이것만 보아도 ESM이 브라우저에 최적화되어 있다고 느껴진다.

import / export (출처 : https://so-so.dev/web/tree-shaking-module-system/)

 

Bindings, Not Values

  • CommonJS는 require로 로드한 모듈의 값을 그 순간에 불러와 사용한다. 그런데 이는 같은 메모리를 바라보지 않아서 export 된 쪽에서 값을 변경해도 require한 쪽에서는 변경된 값으로 사용할 수 없다! => Value에 초점을 맞춘 것.

CJS 동작 방식 (https://so-so.dev/web/tree-shaking-module-system/)

 

  • 반면, ESM은 import와 export 모두 같은 메모리 주소를 바라보고 있어서 export한 곳에서 값을 변경하면 import한 곳에서도 변경한 값이 반영된다. => Binding에 초점을 맞춘 것. (Tree shaking이 훨씬 수월한 이유가 확실하게 느껴진다.)

 

ESM 동작 방식 (출처 : https://so-so.dev/web/tree-shaking-module-system/)

 

결론

Rollup 번들러 관점에서 보았을 때 ESM을 선택한 이유를 알게되었기에 좋았다. ESM이 Tree-shaking에 훨씬 수월할 수 밖에 없는 점. 브라우저에 최적화되었다는 점 또한. 최근에 표준에 맞게 ESM을 기준으로 만들어지는 라이브러리도 많지만 Webpack, Nodejs의 경우 여전히 버전에 따라 CJS를 빼놓을 수 없는 경우가 많다. 그리고 서버 환경에서까지 ESM이 적절하지는 않을 것 같다. 오로지 브라우저 관점으로 바라보았다.. 결국 어느 것이 더욱 적절한지는 각 프로젝트의 요구사항과 환경에 따라 달라질 것이고 그에 따라 선택하면 된다.

 


ES modules: A cartoon deep-dive

Tree Shaking 관점에서의 CommonJS와 ES Module

Tree Shaking과 Module System

Tree-shaking versus dead code elimination

CommonJS가 번들을 더 크게 만드는 방법 

Tree Shaking   

Tree shaking