이 글은 아래 원문을 번역한 글로 의역이 있을 수 있습니다. 정확한 의미를 파악하고 싶으신 분은 원문을 참고해주시기 바랍니다.
원문: https://indepth.dev/posts/1483/demistifying-webpacks-import-function-using-dynamic-arguments
저작권 정보: https://indepth.dev/
웹팩 import 함수의 신비함: 동적 인수 사용하기
웹팩의 유명한 셀링 포인트임에도 불구하고, import 함수는 개발자들이 잘 알지 못하는 많은 숨겨진 세부 특징과 기능들을 가지고 있습니다. 예를 들면, import 함수 동적 표현식을 받을 수 있고, 그것으로 이미 잘 알려져 있는 lazy loading과 같은 기능을 해낼 수 있습니다. 동적 표현식은 원시 문자열(예: `import('./path/to/file.js')`)이 아닌 것들이라고 생각할 수 있습니다. 동적 표현식의 예로는 다음과 같은 것들이 있습니다: `import ('./animals/' + 'cat' + '.js')`, `import('./animals' + animalName + '.js')` 여기서 `animalName`은 런타임이나 컴파일 타임에 알 수 있습니다. 이 글에서는 import 함수와 관련된 동적 표현식의 개념에 대해 자세히 알아보겠습니다. 글을 다 읽고 나면 웹팩 기능이 제공하는 가능성의 범위에 대해 더 잘 알게 되기를 바랍니다.
인수가 정적일 때 import 함수가 어떻게 동작하는지에 대한 기본적인 이해(새로운 청크를 생성합니다)를 제외하곤 특별한 사전 지식은 필요하지 않습니다. 모듈과 청크 등의 개념을 설명하는 웹팩의 번들링 과정에 대한 심층적인 관점이라는 글도 있지만, 읽지 않아도 이 글을 이해하는데 큰 문제는 없습니다.
글 전반에 걸쳐 라이브 예제(StackBiltz 앱)와 다이어그램을 사용할 예정입니다. 그럼 시작해보겠습니다!
동적 인수의 의미
비록 컴파일 타임에는 값이 정해지지 않았지만, 동적 인수를 import() 함수와 함께 사용함으로써 lazy loading을 할 수 있습니다. SystemJS와 달리, 웹팩은 런타임에 임의의 모듈을 로드할 수 없기 때문에, 런타임에 값을 알 수 있다는 사실은 가능한 모든 값을 미리 준비하도록 웹팩에 제약을 가합니다. 다음 절에서 import 함수에서 사용자가 지정할 수 있는 옵션을 알아보도록 하겠습니다.
지금은 import 함수의 인수에 집중해보겠습니다. 이후에 나올 모든 절은 animals라는 폴더가 있고, 그 안에 동물과 관련된 파일이 있는 동일한 예제를 기반으로 합니다.
├── animals
│ ├── cat.js
│ ├── dog.js
│ ├── fish.js
│ └── lion.js
├── index.js
각 예제는 import 함수를 `import('./animals/${fileName}.js')` 이렇게 사용합니다. `./animals/${fileName}.js`에서 `${fileName}` 부분은 동적인 부분을 나타내고, 기본적으로 `/.*/`로 대체됩니다(글로벌 패턴이라고 생각할 수 있습니다). 동적인 부분은 여러 부분 있을 수 있습니다. 제공된 인수는 나중에 고려해야 할 파일을 결정하는 데 사용되는 RegExp 객체가 됩니다. 순회는 제공된 경로의 첫 번째 정적 부분(예제의 경우 `./animals`)에서 시작하여 각 단계에서 현제 폴더에 있는 파일들을 읽고, RegExp 객체로 테스트합니다. 또한 중첩 폴더를 순회(기본 설정)할 수 있으며, 파일이 제대로 탐색되면, 웹팩은 선택된 모드에 따라서 진행합니다. 이 예제에서 결과로 만들어진 RegExp는 `/^\\.\\/.*\\.js$/`가 되고, animals 폴더에 있는 모든 파일(예: `regExp.test('./cat.js')`)을 테스트하게 됩니다.
순회 및 파일 탐색은 컴파일 타임에 수행된다는 것을 한 번 짚고 넘어가겠습니다.
참고로 동적인 부분을 대체할 RegExp 값과 중첩 폴더 탐색 여부는 구성 파일에서 설정할 수 있습니다.
// webpack.config.js
module: {
parser: {
javascript: {
wrappedContextRegExp: /.*/,
wrappedContextRecursive: true
}
}
}
`wrappedContextRecursive`는 중첩된 폴더도 순회할지 여부(예를 들면 `animals/aquatic/` 폴더 내부도 고려할지, 하지 않을지)를 결정하며, `wrappedContextRegExp`으로 표현식의 동적인 부분을 무엇으로 대체할지 웹팩에 지정할 수 있습니다.
기본 설정에 따르면 초기 표현식 `./animals/${fileName}.js`는 `./animals/.*.js`가 됩니다.
다음 절에서는 한 번 파일들이 고려되면 어떤 일이 일어나는지 살펴보겠습니다.
시작해봅시다!
lazy 모드
이 절의 예제는 여기에서 확인할 수 있습니다(서버를 시작해야 합니다).
이 모드는 기본 모드이며, 명시적으로 지정할 필요가 없습니다. 아래와 같은 폴더 구조가 있다고 가정해봅시다.
├── animals
│ ├── cat.js
│ ├── dog.js
│ ├── fish.js
│ └── lion.js
└── index.js
코드에서 아래와 같이 `import` 함수를 사용한다고 하면,
// In this example, the page shows an `input` tag and a button.
// The user is supposed to type an animal name and when the button is pressed,
// the chunk whose name corresponds to the animal name will be loaded.
let fileName;
// Here the animal name is written by the user.
document.querySelector('input').addEventListener('input', ev => {
fileName = ev.target.value;
});
// And here the chunk is loaded. Notice how the chunk depends on the animal name
// written by the user.
document.getElementById('demo').addEventListener('click', () => {
import(/* webpackChunkName: 'animal' */ `./animals/${fileName}.js`)
.then(m => {
console.warn('CHUNK LOADED!', m);
m.default();
})
.catch(console.warn);
});
웹팩은 `animals` 폴더에 있는 각 파일에 대해 청크를 생성할 것입니다. 이는 `lazy` 모드의 특징입니다. 이 예제에서는 사용자가 입력창에 동물 이름을 치고 버튼을 클릭하면, 해당 이름에 해당하는 청크가 로드될 것입니다. 만약 `animals` 폴더에서 해당하는 동물 이름을 찾을 수 없으면 에러가 발생합니다. 이쯤 되면 웹팩이 여러 개의 청크를 만들어도 결국 경로가 일치하는 청크는 하나뿐일 텐데 자원 낭비가 아닌지 의문이 들 수 있습니다. 하지만 실제로는 그렇지 않습니다. 모든 가능한 청크는 브라우저가 필요로 하지 않는 한(import 함수의 경로와 존재하는 파일의 경로가 일치하지 않는 한) 브라우저로 전송되지 않고 서버에 저장되어 있는 파일일 뿐이기 때문입니다.
컴파일 시 경로를 알 수 있는 정적 import 상황(예: `import('./animals/cat.js')`)처럼 하나의 청크만 생성될 때, import 경로가 동적일 때, 로드된 청크는 캐시 되므로 같은 청크를 여러 번 요청하여 중요한 자원이 낭비되는 경우는 없습니다.
정확히 말하면, 웹팩은 로드된 청크를 map에 저장해둡니다. 앞에서 언급했듯이 이미 로드된 청크가 요구되면 웹팩은 바로 map에서 검색합니다. map의 키는 청크의 id이고 값은 청크의 상태에 따라 달라집니다. `0`: 청크가 로드된 상태 / `Promise`: 청크가 현재 로드 중인 상태 / `undefined`: 청크가 어디에서도 요청된 적 없는 상태
다음은 이 예제를 시각화한 것입니다.
위 다이어그램은 여기에서 확인할 수 있습니다.
이 다이어그램에서 main 부모 청크(`index` 파일)와 함께 4개의 청크(`animals` 폴더 안의 각 파일 당 하나)가 만들어지는 것을 볼 수 있습니다. 애플리케이션에서 다른 하위 청크를 가져오고 통합하는데 필수적인 로직이 포함되어있기 때문에 (루트) 부모 청크를 갖는 것이 중요합니다.
웹팩이 내부적으로 이 동작을 처리하는 방법은 키가 파일 이름(예제의 경우, 키들이 `animals` 폴더에 있는 파일들의 이름입니다)이고, 값이 배열(보시다시피 배열의 패턴은 `{ filename: [moduleId, chunkId] }` 입니다)인 map을 사용하는 것입니다. 이런 종류의 배열은 웹팩에게 아주 유용한 정보를 갖고 있습니다. chunk id(관련된 파일을 불러올 HTTP 요청에 사용됩니다)나 module id(청크 로드가 완료되자마자 요구한 모듈을 알 수 있습니다), 그리고 마지막으로 module export 타입(웹팩이 ES module 이외의 다른 유형의 모듈을 사용할 때 호환할 수 있게 합니다)입니다. 이 map 개념은 모듈을 추적하고 우리가 사용하는 모드와 무관하게 그들의 특성을 사용하는 데 사용됩니다.
배열의 예제를 보고 싶으면 이 절을 시작할 때 안내한 링크(혹은 여기)에서 StackBiltz 앱을 열어서 `npm run build`를 실행하여 확인할 수 있습니다. `dist/main.js` 파일을 열어보면 앞에서 이야기한 map을 볼 수 있습니다.
var map = {
"./cat.js": [
2,
0
],
"./dog.js": [
3,
1
],
"./fish.js": [
4,
2
],
"./lion.js": [
5,
3
]
};
다시 말하자면 이 객체는 다음 패턴을 따릅니다. `{ filename: [moduleId, chunkId] }` 구체적으로 만약 사용자가 `cat`을 치고 버튼을 누르면, id가 `2`인 청크가 로드되고 청크가 준비되는 즉시 id가 `0`인 모듈을 사용합니다.
또한 배열에 모듈의 export 타입이 지정된 경우를 살펴볼 필요가 있습니다. 이 상황에서 `cat.js` 파일은 CommonJS 모듈이고 나머지는 ES module입니다.
// cat.js
module.exports = () => console.log('CAT');
이 새로운 예제에 대한 StackBlitz 앱은 여기에서 확인할 수 있습니다.
`npm run build`를 실행하고 `dist/main.js`를 확인해보면, map이 조금 달라졌을 겁니다.
var map = {
"./cat.js": [
2,
7,
0
],
"./dog.js": [
3,
9,
1
],
"./fish.js": [
4,
9,
2
],
"./lion.js": [
5,
9,
3
]
};
이 패턴은 다음과 같습니다. `{ filename: [noduleId, moduleExportMode, chunkId] }` 모듈의 내보내기 타임을 기반으로 웹팩은 청크가 로드된 다음 모듈을 로드하는 방법을 알고 있습니다. 기본적으로 `9`는 단순 ES module을 나타내며 `moduleId`가 필요합니다. `7`은 CommonJS를 뜻하고 이 경우에 웹팩은 가짜 ES module을 만들어야 합니다.
실제로 확인해보고 싶으면, 마지막에 제공된 예제를 열고 서버를 시작하면 됩니다. 만약 `cat` 모듈을 사용하고 싶다면 버튼을 누른 후 해당 모듈이 포함된 청크에 대한 새 요청을 확인해야 합니다.
아마 보셨다시피, 콘솔은 `cat`이라는 이름의 모듈이 포함된 청크가 로드되었음을 알려줍니다. `fish` 모듈을 사용하고 싶은 경우에도 동일한 단계를 수행하여 가져올 수 있습니다.
import 함수에서 패턴이 일치하는 각 파일마다 동일한 현상이 발생합니다.
eager 모드
원한다면 StackBlitz 데모를 여기에서 확인할 수 있습니다(먼저 `npm run build`를 실행하는 것이 안전합니다).
먼저 이 절에서 사용할 예제를 살펴보겠습니다.
let fileName;
// Here the animal name is written by the user.
document.querySelector('input').addEventListener('input', ev => {
fileName = ev.target.value;
});
// Here the chunk that depends on `fileName` is loaded.
document.getElementById('demo').addEventListener('click', () => {
import(/* webpackChunkName: 'animal', webpackMode: 'eager' */ `./animals/${fileName}.js`)
.then(m => {
console.warn('FILE LOADED!', m);
m.default();
})
.catch(console.warn);
});
보다시피 모드는 매직 코멘트 `webpackMode: 'eager'`로 설정할 수 있습니다.
`eager` 모드를 사용하면, 추가적인 청크가 생성되지 않습니다. import의 패턴에 일치하는 모든 모듈은 main 청크의 일부가 됩니다. 좀 더 구체적으로 말하면, 같은 파일 구조에서,
├── animals
│ ├── cat.js
│ ├── dog.js
│ ├── fish.js
│ └── lion.js
└── index.js
이는 어떤 모듈도 실제로 실행되지 않는 것을 제외하면, 마치 현재 모듈이 animals 폴더 내부에 있는 모듈들을 직접적으로 요구하는 것과 같습니다. 그것들은 모듈의 객체 혹은 배열에 배치될 것이고, 버튼이 눌리면, 추가적인 네트워크 요청이나 다른 비동기 동작 없이 바로 그 모듈을 실행하고 불러올 것입니다.
`npm run build`를 실행한 후 `dist/main.js` 파일을 열면, 아래와 같은 map 객체를 볼 수 있을 것입니다.
var map = {
"./cat.js": 2,
"./dog.js": 3,
"./fish.js": 4,
"./lion.js": 5
};
각 값은 모듈의 ID를 나타내며, 아래로 조금 스크롤하면 다음과 같은 모듈이 나타납니다.
/* 2 */ // -> The `cat.js` file
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {},
/* 3 */ // -> The `dog.js` file
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {}
즉, 이 방법의 장점은, 요구할 때, `lazy` 모드를 사용할 때 발생하는 각 모듈에 대한 추가적인 HTTP 요청 없이 즉시 실행할 수 있다는 것입니다.
이는 다음 예에서 확인할 수 있습니다. 서버를 시작한 후, `animals` 폴더에 있는 모듈을 요구해봅시다. 그림과 같이 네트워크 패널에 어떤 요청도 나타나지 않고 각 기존 모듈이 적절하게 실행될 것입니다.
마지막으로 이 모드의 동작을 다이어그램으로 요약하면 다음과 같습니다.
위 다이어그램은 여기에서 확인할 수 있습니다.
lazy-once 모드
이 절의 StackBlitz 앱은 여기에서 확인할 수 있습니다.
이전 절에서 수동으로 모드를 지정하는 방법을 보았으니, 웹팩에 `lazy-once` 모드를 사용하길 원한다고 알리는 것은 그다지 놀랍지 않을 것입니다.
/*
The same file structure is assumed:
├── animals
│ ├── cat.js
│ ├── dog.js
│ ├── fish.js
│ └── lion.js
└── index.js
*/
let fileName;
// Here the user chooses the name of the module.
document.querySelector('input').addEventListener('input', ev => {
fileName = ev.target.value;
});
// When clicked, the chunk will be loaded and the module that matches with the `fileName`
// variable will be executed and retrieved.
document.getElementById('demo').addEventListener('click', () => {
import(/* webpackChunkName: 'animal', webpackMode: 'lazy-once' */ `./animals/${fileName}.js`)
.then(m => {
console.warn('FILE LOADED!', m);
m.default();
})
.catch(console.warn);
});
이 경우는 import의 표현식과 일치하는 모든 모듈이 메인 청크가 아닌 하위 청크에 추가된다는 것을 제외하면, 이전 절에서 보았던 것과 꽤 유사합니다.
`npm run build`가 실행되면, `dist` 폴더에 두 가지 파일이 있어야 합니다. main 청크인 `main.js`와 `animals/` 폴더 내부의 파일들의 모든 모듈이 있는 청크인 `animal.js`입니다. 이런 방식의 장점은 import의 표현식과 일치하는 모든 가능한 모듈들로 main 청크를 과부하시키지 않으면서 다른 청크에 넣어 lazy loading을 할 수 있다는 것입니다. 사용자가 모듈을 로드하기 위해 버튼을 누르면 전체 청크가 네트워크를 통해 요청되고, 준비되면 사용자가 요청한 모듈이 실행됩니다. 또한, 새로 로드된 청크에 포함된 모든 모듈은 웹팩에 의해 등록될 것입니다. 흥미로는 점은 만약 사용자가 방금 로드된 청크에 속한 다른 모듈을 요구하면, 네트워크를 통한 추가적인 요청은 없을 것이라는 점입니다. 이는 청크가 웹팩 내부적으로 유지되는 캐시에서 제공되고, 요구된 모듈은 웹팩이 저장해 놓은 배열 또는 객체를 통해서 검색되기 때문입니다.
예제에서 한 번 시도해봅시다. 먼저 `cat`을 치고 버튼을 누르겠습니다. 네트워크 탭을 보면, 앞서 설명한 것처럼 필요한 모든 모듈을 포함한 `animal` 청크를 요청하고 있습니다.
또한, `cat` 모듈이 실제로 실행되었습니다.
이제 `lion` 모듈을 사용하려고 하면, 새 요청이 아니라 `lion` 모듈이 실행되었음을 확인하는 메시지가 표시됩니다.
지금까지의 내용을 다이어그램으로 표현하면 다음과 같습니다.
위 다이어그램은 여기에서 확인할 수 있습니다.
week 모드
이 절은 특수성이 있어 마지막까지 아껴두었습니다. `week` 모드로 import를 사용하는 것은, 웹팩에 우리가 사용하고자 하는 자원들이 이미 준비가 되어있다고 말하는 것입니다. 이는 해당 자원이 지금쯤 다른 어느 곳에서 로드(요구되고 사용되었음)되었어야 함을 암시하고, 그래서 `week` import가 사용되었을 때, 이것은 어떠한 fetch 기술(예를 들면, 청크를 로드하기 위해 네터워크 요청을 하는 것)도 행하지 않고 웹팩이 모듈을 추적하기 위해 사용하는 데이터 구조의 모듈만을 사용합니다.
우리는 이 `week` 모드가 어떤 건지 더 잘 이해하기 위해서, 처음에는 오류를 발생시키는 간단한 예에서 시작해서 확장시켜 나가도록 하겠습니다.
// Here the user types the name of the module
document.querySelector('input').addEventListener('input', ev => {
fileName = ev.target.value;
});
// Here that module is retrieved directly if possible, otherwise
// an error will be thrown.
document.getElementById('demo').addEventListener('click', () => {
import(/* webpackChunkName: 'animal', webpackMode: 'weak' */ `./animals/${fileName}.js`)
.then(m => {
console.warn('FILE LOADED!', m);
m.default();
})
.catch(console.warn);
});
StackBlitz 앱으로 만든 예제는 여기에서 확인할 수 있습니다(서버를 시작하려면 `npm run build`와 `npm run start`를 실행해야 합니다).
아직 자세하게 설명하진 않았지만, 그저 다른 절에서 했듯이, import 함수가 작동되기를 원하는 모드를 `week`로 설정한 것입니다.
입력에 `cat`을 입력한 후 버튼을 누르면, 콘솔에 에러가 표시됩니다.
이는 당연합니다. 위에서 언급했듯이, `week` import는 자원을 사용할 수 있게 웹팩이 뭔가를 해야 하는 게 아니라 자원이 이미 사용할 준비가 되어 있다고 기대하기 때문입니다. 현재 우리가 하고 있는 방식은 `cat` 모듈이 다른 곳에서 로드되지 않고 그래서 에러가 발생하는 것입니다.
이 문제를 빠르게 해결하기 위해, 파일 시작 부분에 `import * as c from './animals/cat';`를 추가할 수 있습니다.
// index.js
import * as c from './animals/cat';
let fileName;
/* ... */
만약 `npm run build`와 `npm run start`를 다시 실행하고 같은 단계를 밟는다면, `cat` 모듈이 성공적으로 실행되는 것을 볼 수 있습니다. 그러나, `cat` 모듈 이외의 다른 모듈로 시도하면, 동일한 에러가 발생합니다.
이 기능을 사용해서 모듈을 미리 로드하도록 강제하고 특정 시점에 모듈에 접근할 수 있도록 할 수 있습니다. 그렇지 않으면 에러가 발생할 것입니다.
다른 모드들과 다르게, 모듈은 현재 청크나 하위 청크, 자체 청크에 추가되지 않습니다. 이 경우 웹팩이 하는 일은 import의 표현식과 일치하는 모듈이 존재하는지 추적하고, 필요하다면(ES module인 경우엔 필요 없음) 모듈의 export 타입을 추적하는 것입니다. 예를 들면,
var map = {
"./cat.js": 1,
"./dog.js": null,
"./fish.js": null,
"./lion.js": null
};
위의 map(`dist/main.js` 파일에 있음 - 유일하게 생성된 파일)에서는 `cat` 모듈이 앱에서 사용되는 것이 확실합니다. 그러나, 그것이 `cat` 모듈이 사용할 수 있다는 것을 보장하는 것은 아닙니다. 그러므로 위에서 map 객체의 역할은 모듈이 프로젝트에서 조금이라도 사용되는지를 추적하는 것입니다. 다른 말로 표현하면 모듈의 존재를 추적하는 것입니다. 값이 null인 다른 모듈을 고아 모듈이라고 합니다.
모듈이 존재하지만 사용할 수 없는 경우가 있습니다. 다음 예제를 생각해봅시다.
let fileName;
// Here the user chooses the name of the file.
document.querySelector('input').addEventListener('input', ev => {
fileName = ev.target.value;
});
// Requesting the module that should already be available.
document.getElementById('demo').addEventListener('click', () => {
import(/* webpackChunkName: 'animal', webpackMode: 'weak' */ `./animals/${fileName}.js`)
.then(m => {
console.warn('FILE LOADED!', m);
m.default();
})
.catch(console.warn);
});
// Dynamically loading the `cat.js` module.
document.getElementById('load-cat').addEventListener('click', () => {
import('./animals/cat.js').then(m => {
console.warn('CAT CHUNK LOADED');
});
});
이 예제에 대한 StackBlitz 앱은 여기에서 확인할 수 있습니다.
`import('./animals/cat.js')` 구문에서, 모듈이 앱에 존재함을 알 수 있지만, 사용 가능하게 만들기 위해서는, `#load-cat` 버튼을 먼저 눌러야 합니다. 클릭함으로써 청크를 가져오고 `cat` 모듈이 접근 가능하게 되는데, 청크가 로드되면 거기에 있는 모든 모듈이 전체 애플리케이션에서 사용할 수 있게 되기 때문입니다.
`load cat chunk`를 먼저 누르지 않고 바로 `cat` 모듈을 요구해 볼 수도 있는데, 모듈을 사용할 수 없다는 오류를 마주하게 될 것입니다.
그러나 `cat` 청크를 먼저 로드한 다음 모듈을 요구하면, 모든 것은 정상적으로 작동할 것입니다.
이 절에서 기억해야 할 점은, `week` 모드를 사용할 때, 웹팩은 리소스가 이미 준비되어 있기를 기대한다는 것입니다. 모듈이 극복해야 할 필터에는 세 가지가 있습니다. import 표현식과 일치해야 하고, 앱을 통해 사용되어야 하며(예: 직접적으로 import 하기, 또는 청크로 import 하기), 사용 가능해야 합니다(즉, 다른 곳에서 이미 로드되었어야 한다).
결론
이 글에서 우리는 import 함수가 단순히 청크를 생성하는 것 이상의 것을 할 수 있다는 것을 배웠습니다. 여기서 import를 동적 인수와 함께 사용하는 것이 더 의미 있어지기를 바랍니다.
읽어주셔서 감사합니다!
다이어그램들은 Excalidraw로 만들어졌습니다.
글을 검토하고 귀중한 의견을 제공해준 Max Koretskyi에게 특별히 감사드립니다.
'성능' 카테고리의 다른 글
Towards an animation smoothness metric (한글) (2) | 2022.09.21 |
---|---|
The Complete Guide to Lazy Loading Images - 1 (한글) (0) | 2022.05.18 |
Tree-Shaking: A Reference Guide (한글) (0) | 2021.07.28 |
Introducing the Memory Inspector(한글) (0) | 2021.07.21 |
Trash talk: the Orinoco garbage collector(한글) (0) | 2021.07.14 |