실무를 해보면 취준 때 별로 중요하지 않게 생각했던 것들이 ‘중요한 것이었구나’ 느낄 때가 많다. 그 중 하나가 모듈 시스템이다. 모듈 시스템은 모든 곳에서 쓰일 뿐만 아니라, npm 라이브러리를 직접 만들어보면서 번들링의 기초 개념이라는 것을 알게 되었다.
javascript.info 를 읽어보며 그동안 대충 알고 넘어갔던 부분들을 핵심만 정리해봤다. 면접 질문에 잘 나오지 않아서 간과하고 넘어갔던 신입 개발자분들이 읽어보면 도움이 될 것 같다.
모듈은 스코프를 가진다. (일반 스크립트 파일과의 차이점)
모듈은 한 번만 평가되고 동시에 이 모듈을 사용하려는 모든 모듈에 내보내진다. 예를 들어, 아래의 모듈을 여러 곳에서 사용해도 얼럿 창은 단 한번만 나타난다.
alert("모듈이 평가되었습니다!");
일반 스크립트의 this가 전역 객체인 것과 달리, 모듈 최상위 레벨의 this는 undefined이다.
모듈 스크립트는 항상 defer를 붙인 것같이 지연 실행된다.
따라서 모듈 스크립트는 HTML 문서가 완전히 준비될 때까지 대기 상태에 있다가 HTML 문서가 완전히 만들어진 이후에 실행된다. 모듈의 크기가 아주 작아서 HTML보다 빨리 불러온 경우에도 그렇다. 즉, 모듈 스크립트는 항상 완전한 HTML 페이지를 ‘볼 수’ 있고 문서 내 요소에도 접근할 수 있다.
- 즉, 다음의 실행 순서는 undefined, object이다.
// 모듈
// 모듈 스크립트는 지연 실행되기 때문에 페이지가 모두 로드되고 난 다음에 alert 함수가 실행되므로
// 얼럿창에 object가 정상적으로 출력된다.
<script type="module">
alert(typeof button);
</script>
// 일반 스크립트
// 일반 스크립트는 페이지가 완전히 구성되기 전이라도 바로 실행된다.
<script>
alert(typeof button);
</script>
<button id="button">Button</button>
그렇기 때문에 module을 불러오기까지 로딩 스피너같은 걸 띄워주어 사용자의 혼란을 예방할 수 있다.
인라인 스크립트에 async를 붙여봤자 아무런 의미가 없다. async는 스크립트를 비동기로 다운받아서 바로 실행할 때 사용하는데, 인라인 스크립트는 다운받을 게 없이 이미 HTML에 포함되어 있기 떄문이다.
반면, 모듈 스크립트에서는 async를 인라인 스크립트에도 적용할 수 있다. 왜일까?
💡 모듈은 내부적으로 항상 모듈 dependency graph(parse) 과정을 거친다. 즉, 인라인이어도 실행 전에 어느 정도 준비 과정이 있다. 그래서 브라우저는 모듈을 다운로드할 필요가 없더라도
async,defer같은 모듈 실행 스케줄링 방식에 따라 실행 시점을 제어하도록 허용한다.
이런 특징은 광고나 문서 레벨 이벤트 리스너, 카운터 같이 어디에도 종속되지 않는 기능을 구현할 때 유용하게 사용할 수 있다.
모듈이 저장되어있는 원격 서버가 Access-Control-Allow-Origin: * 헤더를 제공해야만 외부 모듈을 불러올 수 있다. 참고로 * 대신 페치(fetch)를 허용할 도메인을 명시할 수도 있다.
Node.js나 번들링 툴은 경로가 없어도 해당 모듈을 찾을 수 있는 방법을 알 수 있지만, 브라우저는 경로 없는 모듈을 지원하지 않는다.
nomodule 키워드를 통해 type=module을 지원하지 않는 브라우저에 대응할 수 있다.
<script type="module">
alert("모던 브라우저를 사용하고 계시군요.");
</script>
<script nomodule>
alert("type=module을 해석할 수 있는 브라우저는 nomodule 타입의 스크립트는 넘어갑니다. 따라서 이 alert 문은 실행되지 않습니다.");
alert("오래된 브라우저를 사용하고 있다면 type=module이 붙은 스크립트는 무시됩니다. 대신 이 alert 문이 실행됩니다.");
</script>
중복된 외부 스크립트는 무시되고, 한 번만 호출된다.
모듈을 단독으로 사용하는 경우는 흔치 않다. 모듈을 여러 개 파일로 나눠도 Webpack, Vite, esbuild 같은 번들러가 모든 파일을 하나(또는 몇 개)로 압축해서 배포용 파일로 만들어준다.
번들러의 역할
HTML의 <script type="module">에 넣을 주요(main) 모듈(진입점 역할을 하는 모듈)을 선택한다.
주요 모듈에 의존하고 있는 모듈 분석을 시작으로 모듈 간의 의존 관계를 파악한다.
모듈 전체를 한데 모아 하나의 큰 파일을 만든다(설정에 따라 여러 개의 파일을 만드는 것도 가능하다 ⇒ 코드 스플리팅). 이 과정에서 import문이 번들러 내 함수로 대체되므로 기존 기능은 그대로 유지된다. 하나로 합치면 초기 로딩이 너무 느려지는 경우 사용한다.
이런 과정 중에 변형이나 최적화도 함께 수행된다.
도달 가능하지 않은 코드 삭제
내보내진 모듈 중 쓰임처가 없는 모듈을 삭제 - 가지치기(tree-shaking)
console, debugger 같은 개발 관련 코드 삭제
// vite.config.js
export default {
esbuild: {
drop: ['console', 'debugger'],
},
};
최신 자바스크립트 문법이 사용된 경우 바벨(Babel)을 사용해 동일한 기능을 하는 낮은 버전의 스크립트로 변환
공백 제거, 변수 이름 줄이기 등 ⇒ 산출물의 크기를 줄임
즉, 번들링 과정이 끝나면 기존 스크립트에서 import, export가 사라지기 때문에 type="module"이 필요 없어진다. 하지만 네이티브 모듈도 당연히 사용 가능하다.
<!-- 웹팩과 같은 툴로 번들링 과정을 거친 스크립트인 bundle.js -->
<!-- 일반 스크립트처럼 취급할 수 있음 -->
<script src="bundle.js"></script>
한꺼번에 모든 걸 가져오는 방식을 사용하면 코드가 짧아진다. 그런데도 어떤 걸 가져올 땐 그 대상을 구체적으로 명시하는 게 좋다.
// 📁 main.js
import * as say from './say.js';
say.sayHi('John');
say.sayBye('John');
웹팩(webpack)과 같은 모던 빌드 툴은 로딩 속도를 높이기 위해 모듈들을 한데 모으는 번들링과 최적화를 수행한다. 이 과정에서 사용하지 않는 리소스가 삭제되기도 한다. 빌드 툴은 실제 사용되는 함수가 무엇인지 파악해, 그렇지 않은 함수는 최종 번들링 결과물에 포함하지 않는다. 이 과정에서 불필요한 코드가 제거되기 때문에 빌드 결과물의 크기가 작아진다. 이런 최적화 과정은 **'가지치기(tree-shaking)’**라고 불린다.
이름을 간결하게 써줄 수 있다. say.sayHi()보다 sayHi()가 더 간결하다.
어디서 어떤 게 쓰이는지 명확하기 때문에 코드 구조를 파악하기가 쉬워 리팩토링이나 유지보수에 도움이 된다.
export에도 as를 사용할 수 있다.
export default를 사용하면 '해당 모듈엔 개체가 하나만 있다’는 사실을 명확히 나타낼 수 있다.
파일당 최대 하나의 default export가 있을 수 있으므로 내보낼 개체엔 이름이 없어도 괜찮다.
export default class { // 클래스 이름이 없음
constructor() { ... }
}
export default function(user) { // 함수 이름이 없음
alert(`Hello, ${user}!`);
}
// 이름 없이 배열 형태의 값을 내보냄
export default ['Jan', 'Feb', 'Mar','Apr', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
default를 붙이지 않았다면 개체에 이름이 없는 경우 에러가 발생한다.
export class { // 에러! (default export가 아닌 경우엔 이름이 꼭 필요합니다.)
constructor() {}
}
default 붙이면 가져올 때 이름을 맘대로 바꿀 수 있다.
named export와 default export를 같은 모듈에서 동시에 사용해도 문제는 없다. 그런데 실무에선 이렇게 섞어 쓰는 사례가 흔치 않다. 한 파일엔 named export나 default export 둘 중 하나만 사용한다.
흔치 않지만 user.js라는 모듈에 default export 하나와 다수의 named export가 있다고 해보자.
// 📁 user.js
export default class User {
constructor(name) {
this.name = name;
}
}
export function sayHi(user) {
alert(`Hello, ${user}!`);
}
아래와 같은 방식을 사용하면 default export와 named export를 동시에 가져올 수 있다.
// 📁 main.js
import {default as User, sayHi} from './user.js';
new User('John');
* 를 사용해 모든 것을 객체 형태로 가져오는 방법도 있는데, 이 경우엔 default 프로퍼티는 정확히 default export를 가리킨다.
// 📁 main.js
import * as user from './user.js';
let User = user.default; // default export
new User('John');
export ... from ... 문법을 사용하면 가져온 개체를 즉시 다시 내보내기(re-export) 할 수 있다. 이름을 바꿔서 다시 내보낼 수 있다.
// 📁 auth/index.js
// login과 logout을 가지고 온 후 바로 내보냅니다.
import {login, logout} from './helpers.js';
export {login, logout};
// User를 가져온 후 바로 내보냅니다.
import User from './user.js';
export {User};
...
위 코드와 아래 코드는 동일하다.
// 📁 auth/index.js
// login과 logout을 가지고 온 후 바로 내보냅니다.
export {login, logout} from './helpers.js';
// User 가져온 후 바로 내보냅니다.
export {default as User} from './user.js';
...
기본 내보내기를 다시 내보낼 때는 주의해야 할 점들이 있다.
// 📁 user.js
export default class User {
// ...
}
User를 export User from './user.js'로 다시 내보내기 할 때 문법 에러가 발생한다. default export를 다시 내보내려면 export {default as User}를 사용해야 한다.
export * from './user.js'를 사용해 모든 걸 한 번에 다시 내보내면 default export는 무시되고, named export만 다시 내보내진다. 두 가지를 동시에 다시 내보내고 싶다면 두 문을 동시에 사용해야 한다.
export * from './user.js'; // named export를 다시 내보내기
export {default} from './user.js'; // default export를 다시 내보내기
import(module) 표현식은 모듈을 읽고 이 모듈이 내보내는 것들을 모두 포함하는 객체를 담은 이행된 프라미스를 반환한다. 호출은 코드 내 어디서나 가능하다.
let modulePath = prompt("어떤 모듈을 불러오고 싶으세요?");
import(modulePath)
.then(obj => <모듈 객체>)
.catch(err => <로딩 에러, e.g. 해당하는 모듈이 없는 경우>)
async 함수 안에서 let module = await import(modulePath)와 같이 사용하는 것도 가능하다.
// 📁 say.js
export function hi() {
alert(`안녕하세요.`);
}
export function bye() {
alert(`안녕히 가세요.`);
}
let {hi, bye} = await import('./say.js');
hi();
bye();
say.js에 default export를 추가해보자.
// 📁 say.js
export default function() {
alert("export default한 모듈을 불러왔습니다!");
}
default export 한 모듈을 사용하려면 아래와 같이 모듈 객체의 default 프로퍼티를 사용하면 된다.
let obj = await import('./say.js');
let say = obj.default;
// let {default: say} = await import('./say.js'); 같이 한 줄로 줄일 수도 있다.
say();
<!doctype html>
<script>
async function load() {
let say = await import('./say.js');
say.hi(); // 안녕하세요.
say.bye(); // 안녕히 가세요.
say.default(); // export default한 모듈을 불러왔다!
}
</script>
<button onclick="load()">클릭해주세요,</button>
동적 import는 일반 스크립트에서도 동작한다. script type="module"가 없어도 된다.
import()는 함수 호출은 아니다. super()처럼 괄호를 쓰는 특별한 문법 중 하나다. 따라서 import를 변수에 복사한다거나 call/apply를 사용하는 것은 불가능하다.
이 와중에 오타 발견해서 모던 자바스크립트 튜토리얼에 PR을 올렸다 ,,ㅋㅋㅋ
당분간은 자바스크립트에서 모르거나 익숙하지 않은 부분들 위주로 올릴 예정이다.