JavaScript 모듈과 번들러 (1)

전병민·2023년 5월 23일
0

Once upon a time in...

목록 보기
1/1

UPDATE: 2023.05.23

최근 저는 JavaScript 의 모듈과 번들러에 대해서 관심을 가지고 학습하고 있어요. 처음으로 NPM 에 배포를 해보았는데, 그동안 모듈이니 번들러니 이름만 많이 들어봤지 제대로 학습해본 적은 없다는 것을 깨닫게 되었어요. 그도 그럴 것이 저는 모듈과 번들러에 대해서 자세히 알지 않아도 개발 입문에는 큰 무리가 없는 시점에 공부를 시작했거든요. 이미 JavaScript 모듈 표준이 잘 쓰이고 있었고, 당시에는 Create-React-App 이 리액트의 표준처럼 사용되고 있었기 때문에 번들러를 직접 구성할 필요가 없었던 것도 한 몫 했죠. 생각해보면 리액트 공식 문서에 번들러에 대한 내용이 있었지만 공부를 시작하는 입장에서 공감되지 않는 내용들 투성이라 기피했던 것도 사실인 것 같아요.

하지만 無 에서부터 package.json , 트랜스파일러, 번들러까지 직접 구성해보았던 경험으로 JavaScript 의 모듈과 번들링이 더이상 기피할 대상이 아니라는 것을 알게 되었어요. 많은 자료들과 오픈소스, 예제들을 참고한다면 쉽게 할 것 같았지만 실제로는 대부분 저에게 없는 지식을 요구하는 작업들이었어요. 그래서 JavaScript 의 모듈과 번들러 대해서 파헤쳐보기로 했죠.




태초의 JavaScript

호랑이가 담배 피던 시절, 브라우저에서 JavaScript 는 두 가지 방식으로 작동했어요.

<script src="features.js"></script>

먼저 프로젝트에 대해 하나의 거대한 JavaScript 를 만들어서 사용하는 방식이에요. 이것은 스코프 충돌, 크기, 가독성, 유지보수에 문제를 발생시킬 수 있었어요. 거대한 프로젝트에서 계속 이런 방식으로 일할 수는 없었어요.

...
<script src="feature1.js"></script>
<script src="feature2.js"></script>
<script src="feature3.js"></script>
<script src="feature4.js"></script>
...

두 번째로, 각 기능에 대한 스크립트를 HTML 에 추가하는 방식이에요. 이 방식은 협업 관점에서는 조금 더 나았지만 여전히 스코프 충돌이 발생했고, 너무 많은 스크립트로 인해 네트워크의 병목을 유발하는 원인이 될 수 있었어요. 왜냐하면 HTTP/1.1 에서는 하나의 요청에 대해 응답을 받아야 다음 요청을 할 수 있었거든요.

모듈과 HTTP

HTTP/1.1Keep-Alive 가 기본 설정이라 하나의 TCP 세션 안에서 여러 요청을 할 수는 있었어요. 이걸로 요청마다 TCP 세션을 만들지 않아도 되기 때문에 어느정도의 성능을 보장할 수 있었지만 요청에 대한 응답이 와야 다음 요청을 할 수 있었어요. 여전히 너무 많은 스크립트가 네트워크 병목 현상으로 이어질 수 있다는 것을 의미했죠. 파이프라인이라는 기술로 일부 해결할 수는 있었지만 실전에서 다루기 까다로워 잘 사용하지 않았다고 해요.

HTTP/2multiplex 로 하나의 연결 안에서 하나 이상의 요청을 병렬적으로 처리할수 있도록 했어요. 네트워크 병목 현상은 HTTP/2 출시 이후 '일부' 해결 되었다고 볼 수 있어요. 하지만 성능 최적화와 관련해서 여전히 webpack 과 같은 번들러가 유효해요.


우선 스코프 충돌부터 해결 해보자, IIFE

IIFE(Immediately invoked function expression) 는 대규모 프로젝트에서 스코프 충돌 문제를 해결했어요. 스크립트 파일을 즉시 실행 함수로 감싸면 스코프 충돌에 대한 걱정없이 파일을 안전하게 연결하거나 결합할 수 있었어요. 예를 들면 이렇게요.

module.js

var MODULE = (function() {
  var message = "Hello, module!";
  
  function sayHelloModule() {
    console.log(message);
  }
  
  return { 
    sayHelloModule 
  };
})();

script.js

(function() {
  var message = "Hello, script!";
  
  function sayHelloScript() {
    console.log(message);
  }
  
  sayHelloScript();
  
  MODULE.sayHelloModule();
})();

index.html

<html>
<head>
  <script src="module.js"></script>
  <script src="script.js"></script>
</head>
<body>
</body>
</html>

message 라는 변수를 전역 스코프를 오염시키지 않으면서 각각의 파일에서만 사용되는 변수로 사용할 수 있었죠. 이유는 아시다시피 var 는 전역 스코프 혹은 함수 스코프를 갖기 때문이에요.

// 에러가 발생하는 코드
<html>
<head>
  <script src="script.js"></script>
  <script src="module.js"></script>
</head>
<body>
</body>
</html>

하지만 개발자는 모듈과 스크립트간 의존성 순서를 알고 있어야 했어요. 모듈을 사용하는 스크립트보다 모듈의 정의가 앞서야 했죠. 위와 같은 코드는 에러가 발생해요. 이것은 특히 스크립트가 겹치고 중첩될 수 있는 방식으로 많은 종속성을 갖기 시작하면서 대규모 프로젝트에서 관리하기 어려울 수 있었어요.

음, 결국 즉시 실행 함수로 모듈을 조금 더 잘 활용할 수는 있게 되었지만 조금 더 근본적인 문제를 해결해줄 수 있는 모듈 시스템이 필요했어요.


동기적 모듈 시스템의 등장, CommonJS

V8 엔진 출시 이후, CommonJS 가 개발되었어요. 이는 자바스크립트를 브라우저 외부 환경의 서버나 컴퓨터에서 사용하기 위한 모듈 시스템으로 개발되었죠. CommonJS 에서는 별도의 구성 없이 requireexports 함수로 필요한 파일에서 모듈을 불러와 사용할 수 있었어요. 스코프 충돌 문제도 해결했죠.

module.js

var message = 'Hello, module!'
  
function sayHelloModule() {
  console.log(message);
}

module.exports = sayHelloModule

script.js

var sayHelloModule = require("./module")
var message = 'Hello, script!'

function sayHelloScript() {
  console.log(message);  
}

sayHelloScript();
sayHelloModule();

다만, 안타깝게도 CommonJS 은 태생부터가 비동기적 로드를 고려하지 않았기 때문에 브라우저에서 사용할 수 없었어요. 아예 불가능한 것은 아니었지만 제시되는 방법들이 모두 임시방편일 뿐이었고, 오히려 JavaScript 런타임인 Node.jsCommonJS 의 명세를 따르면서 NPM 과 함께 파죽지세로 성장해갔죠.

결과적으로 JavaScript 가 가장 일반적으로 사용되는 브라우저에서는 CommonJS 가 최적의 모듈 시스템은 아니었던 거에요. 브라우저에서도 모듈을 사용하기 위한 방법이 필요했고 이때, CommonJS 의 문법을 브라우저가 이해할 수 있도록 번들링해주는 최초의 JavaScript 번들러 Browserify 가 탄생했어요.


CommonJS의 대항마, 비동기적 모듈 시스템 AMD

CommonJS 와 반대로 AMD(Asynchronous Module Definition) 는 브라우저 환경에서 비동기적으로 모듈을 로드하기 위해서 만들어졌어요. AMDrequiredefine 함수로 필요한 파일에서 모듈을 불러와 사용할 수 있었어요. AMD 명세로 구현된 대표적인 모듈 로더는 RequireJS 가 있어요.

AMD 의 문법을 직접 작성할 일은 거의 없을 거 같아 자세히 소개하지는 않을게요. 하지만 Rollup 이라는 번들러는 ECMAScript Module 을 지원하지 않는 브라우저 호환성을 위해 모듈 로더로써 RequireJS 를 지원하기도 해요.


공식 모듈 시스템, ECMAScript Module

ECMAScript 2015 에서 공식적으로 모듈 시스템이 도입되었어요. ES 모듈은 JavaScript 모듈 시스템의 표준이기 때문에 모든 최신 브라우저에서 지원돼요. 만약 ES 모듈을 지원하는 브라우저라면 이전까지 사용되었던 webpack 이나 RequireJS 와 같은 커스텀 모듈 로더가 필요 없어진 셈이죠. ES 모듈은 importexport 키워드를 사용하여 모듈을 정의하고 사용해요. 당연히 모듈 각각은 독립적인 스코프를 가져요.

module.js

const message = 'Hello, module!'
  
function sayHelloModule() {
  console.log(message);
}

export { sayHelloModule }

script.js

import { sayHelloModule } from './module'

const message = 'Hello, script!'

function sayHelloScript() {
  console.log(message);  

}

sayHelloScript();
sayHelloModule();

index.html

<html>
<head>
  <script type="module" src="script.js"></script>
</head>
<body>
</body>
</html>

ES 모듈은 정적인 모듈 구조를 가지고 있어요. 이는 모듈 간의 의존성 관계가 런타임이 아니라 정적으로 분석될 수 있다는 것을 의미해요. 모듈의 의존성을 정적으로 알 수 있기 때문에, 순환 참조와 같은 문제가 발생하면 빌드 단계에서 바로 감지하여 오류를 보고할 수 있어요. 또 정적 분석 될 수 있다는 특성 덕분에 webpack, Rollup, Parcel 과 같은 번들러로 트리 쉐이킹(사용되지 않는 코드를 제거하여 번들 크기를 최소화하는 기술)도 가능해요. 또 특정 모듈을 필요한 시점에 동적으로 로드할 수 있는 지연 로딩이라는 기능도 제공했어요. 이 기능은 Code Splitting 기법과 연관이 있어요.


그렇게 현재...

작성일 기준으로 JavaScript 에서 ECMAScript Module 이 나온지 벌써 8년이 지났어요. ES 모듈 의 등장 이후 JavaScript 진영에서 다양한 번들링 및 빌드 툴들이 탄생했고 파죽지세로 성장하기 시작했어요. webpack, Rollup, Parcel 부터 esbuild, Vite, Snowpack, wmr, Turbopack 까지. 다음 포스트에서는 우선 webpack, Rollup, Parcel 에 대해서 알아볼게요.




참고

webpack 공식문서
JavaScript 번들러로 본 조선시대 붕당의 이해
BTooling.report

profile
JavaScript/React 개발자

0개의 댓글