현재 사내에서 진행하고 있는 프로젝트는 vue-cli, 즉 webpack으로 번들링 하며 진행하고 있다. 거의 1년 가까이를 사용했는데 한줄평은 느리다
한 마디로 설명이 가능하다. 간혹가다 맥북이 과부화가 걸리는 상황에서는 거의 1분가까이 번들링 되는 참사가 벌어지기도 했다.
그래서 Vite로 마이그레이션을 진행하기로 결정했다. 이 전에 webpack이 등장한 배경과 Vite가 만들어지기까지를 살펴보면 도입과정에서 큰 도움이 될 것 같아서 한 번 정리하고자 했다.
js는 모듈이 없는 상태로 탄생했다. js는 그저 보조 스크립트 수준의 언어로의 역할을 기대하면서 만들어졌기 때문이다. 하지만 모듈이 없어 큰 문제가 발생했다. 지금은 여러 파일을 구분해 모듈을 불러와 코드를 작성하는 형태이지만, 과거에는 스크립트 하나에 엄청난 양의 코드를 작성하곤 했다. 코드라인이 수백줄을 넘어간다면,,, 벌써부터 머리가 아프지 않은가..??
이후에 이런 모듈 시스템을 도입한 것은 서버에서부터였다.
Node라는 서버 환경에서 모듈 시스템인 CommonJs가 도입되었다. 흔히 require
문을 볼 수 있는데 이것이 cjs의 문법이다.
// math.js
const add = (a, b) => a + b
const subtract = (a, b) => a - b
// 함수를 모듈로 내보냅니다.
module.exports = {
add,
subtract,
}
// app.js
const math = require('./math') // ./math 모듈을 가져옵니다.
const sum = math.add(5, 3)
const difference = math.subtract(5, 3)
console.log(`Sum: ${sum}`) // Sum: 8
console.log(`Difference: ${difference}`) // Difference: 2
위의 코드처럼 math.js파일에서 함수를 선언하고, require문법을 사용해서 모듈을 가져올 수 있다.
앞서 cjs는 Node에서, 즉 서버에서 동작한다. 초창기에 등장했을 당시 브라우저에서는 사용할 수가 없었다. 또한 브라우저에서는 비동기적으로 모듈을 불러오는 게 중요했는데 cjs는 이에 부합하지 않았다.
서버에서는 동기적으로 작동하는게 왜 좋을까??
서버의 대부분 요청은 DB나 외부 API의존성을 띄고 있다. 그렇기 때문에 위에서 아래로 내려가는 코드의 흐름이 자연스럽다. 그리고 서버 단의 작업은 네트워크와 강력한 하드웨어로 처리되기 때문에 속도면에서 크게 걱정할 케이스가 많지 않다. 그래서 딱히 동기적인 작업을 걱정할 필요가 없다.
반면 브라우저
에서는 사용자 마다 다른 환경(기기, 네트워크 환경 등)을 지니고 있기 때문에, 일관성 있는 속도를 보장하기는 어렵다. 때문에 좋은 사용자 경험을 위해서라면 비동기적으로 작업하는 게 좋다.
AMD(Asynchronous Module Definition), 이름에서 알 수 있듯이 비동기로 모듈을 불러오기 위한 방식이다. 브라우저에 좀 더 적합한 방식을 추구한 결과물이라고 볼 수 있다. 그러나 이 역시 실사용하기에는 단점들이 몇 가지 존재한다.
// 모듈 정의
define('moduleName', ['dependency1', 'dependency2'], function (dep1, dep2) {
const myModule = {
greet: function () {
console.log('Hello from AMD!')
},
}
return myModule // 모듈 내보내기
})
// 모듈 가져오기
require(['moduleName'], function (moduleName) {
moduleName.greet() // Hello from AMD!
})
define
을 이용하여 모듈을 정의해서 내보내고, require
를 사용하여 모듈을 가져와 사용하는데, 문법이 그리 이쁘지않다. 좀 복잡스럽기도 하다.
define
으로 정의할 때 의존성을 주입해주어야하고 콜백 함수도 넣어줘야한다.. 의존성이 많아질 수록 의존성 배열의 길이는 길어지며, 콜백 함수를 연달아 넣으면 흔히 말하는 콜백 지옥
에 빠지기 쉽사리다.
모듈의 수가 많아지면 성능은 급속도로 떨어진다. 비동기로 모듈을 가져오기는 하지만 모듈을 하나하나 가져오기 때문이다. 하나하나 모듈을 가져올 때 마다 그에 상응하는 네트워크 요청을 한다. 모듈의 수만큼 네트워크 요청이 늘어나면 당연히 성능은 떨어진다. 게다가 네트워크 요청 하나마다 비용이 얼마나 크겠는가.
또 하나 문제는 이렇게 받아온 모듈이 비동기적으로 오는 건 좋은데, 순서를 맞춰야하는 케이스라면 문제가 된다. 서로 의존성이 있는 모듈일 경우 순서를 보장해야 하는데 AMD는 이를 맞춰주지는 않는다. 특히 라이브러리 같은 경우 여러 의존성으로 얽혀있기 때문에 더 난감하다.
이후 JavaScript는 이 흐름을 지원할 수 있도록, 최신 브라우저에서 기본적으로 모듈 기능을 지원하기 시작했다. 정적 분석을 하여 코드 실행 전에 의존성 그래프를 생성하여 보다 합리적인 동작을 실행한다.
의존성 그래프를 그릴 때는 import/export
로 파악한다.
// math.js
export const add = (a, b) => a + b
export const subtract = (a, b) => a - b
// utils.js
import { add } from './math.js'
export const calculate = (x, y) => add(x, y) * 2
// main.js
import { calculate } from './utils.js'
import { subtract } from './math.js'
const result = calculate(5, 3)
const difference = subtract(10, 4)
빌드 도구는 이 관계를 분석해 최적의 로딩 순서와 번들링을 결정한다. 중요한 사실은 2번의 경우는 특정 모듈의 특정 함수만 의존하고 있다는 사실인데, 이는 트리 쉐이킹을 할 수 있고 더 작은 번들링 작업을 할 수 있다는 것이다.
그림처럼 의존하고 있는 모듈의 특정 element를 의존할 수록 용량은 증가하여, 번들링 작업이 오래 걸릴 수 있다.
esbuild는 CommonJS와는 달리 브라우저에서 호환성이 좋다.
그 이유를 예제를 통해 알아보자.
// main.js
import { logB } from './B.js';
import { logC } from './C.js';
console.log('Main loaded');
logB();
logC();
// B.js
import { logD } from './D.js';
export function logB() {
console.log('B loaded');
logD();
}
// C.js
export function logC() {
console.log('C loaded');
}
// D.js
export function logD() {
console.log('D loaded');
}
다음 코드의 의존 트리는 아래와 같다.
main.js
├── B.js
│ └── D.js
└── C.js
브라우저가 모든 import를 정적 분석
하고 병렬 다운로드 시작
main.js에서 B.js와 C.js를 import하므로 B.js, C.js가 동시에 다운로드됨.
B.js에서 D.js를 import하므로, D.js도 다운로드됨.
모든 다운로드 완료 후 실행
D.js 다운로드가 완료될 때까지 B.js는 실행되지 않음.
실행 순서:
결국 다음과 같이 예제에서 살펴볼 수 있듯이, 비동기적으로 동작하는 말은 비동기적으로 파일을 브라우저에서 fetching하는 것이고 실행 방식은 정적 분석을 하여 의존성에 따라 순서를 보장하여 일관성있게 동작하게 되는 것이다.
이렇듯 많은 점들을 개선했지만 아직 CommonJS에서 esmodule로 대체하지 않은 라이브러리도 많이 있다.
esmodule은 2015년에 도입되었다. 그렇기 때문에 비교적 최근으로 볼 수 있는데, TypeScript 또한 4.7버전에서야 esmodule을 지원하기 시작했다. https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7.html
기존 npm패키지들이 CommonJS기반으로 작성되었던 터라 전환하기에는 무리가 있을 수 있다. 그렇기 때문에 간단한 설정들로 esmodule방식을 사용할 수 있다.
{
"type": "module"
}
다음과 같이 설정하면 js는 기본적으로 esmodule방식으로 동작한다.
{
"name": "my-library",
"exports": {
"require": "./index.cjs", // CommonJS 환경에서 `require()` 시 사용
"import": "./index.mjs" // ESM 환경에서 `import` 시 사용
}
}
package.json에서 exports 필드를 활용하면, CommonJS와 ESM을 자동으로 구분할 수 있다.
지금까지 JavaScript에서 모듈이 필요한 이유와 이를 보완하기 위한 여러 도구들의 탄생과정에 대해 알아봤다.
CommonJS (CJS)
AMD (Asynchronous Module Definition)
ESModule (ESM)
다음 글에는 JavaScript의 번들러에 대해 알아보자.
Reference
링크드인으로 이야기를 주고 받고 싶으시다면 언제든지 편하게 연락주세요. 🙇♂️