사실 CommonJS는 표준이 아니에요.
조금 역사를 이야기할 필요가 있는데, 아시다시피 JavaScript는 프론트 언어였습니다. 그런데 프론트에서는 HTML 문서 상에 script 태그를 이용해서 JavaScript 코드를 전역 상태로 사용하곤 했죠. 그러다 보니 당연히 모듈이 필요없었어요. 모든 게 하나의 전역 코드니깐요.
그렇지만 백엔드에서도 JavaScript가 쓰이기 시작하면서 모듈 시스템에 대한 고민이 생겨났고, 그 결과 사용자들이 고안해낸 게 CommonJS였죠. 그렇지만 이 CommonJS는 표준이 되지는 못했습니다.
아마도 그 이유는, ECMAScript가 프론트, 백에 따라 같은 언어, 다른 표기로 나뉘는 걸 꺼렸기 때문인 거 같아요. 그래서 ECMAScript는 ESM이라는 표준 모듈 시스템을 만들었습니다.
이제부터 ESM이 CommonJS와 다른 점들을 이야기해보려고 해요.
경험적으로 뭐가 다른지는 이미 다들 아는 게 많겠지만요.
if (condition) {
import module1 from 'module1';
} else {
import module2 from 'module2';
}
첫째로 ESM은 비동기 임포트가 불가능하다는 점이 차이점입니다.
위처럼 작성하는 것은 불가능해요.
이는 불필요한 제약처럼 보일 수 있지만, static import ( 정적 임모트 ) 를 통해 사용하지 않는 코드 제거 ( tree shaking )와 같이 코드 최적화를 해줄 수 있는 종속성 정적 분석을 가능하게 해준다고 해요.
말이 조금 어렵죠?
쉽게 말하면 코드 최적화란, 사용하지 않는 부분을 없애주는, 경량화라고 생각하면 좋을 거 같아요. 그렇지만 코드가 비동기적으로 실행되는 CommonJS에서는 실행 단계에서 전체 코드 형태를 알 수 없으니 필요 없는 부분을 걸러낼 수가 없죠.
이 부분에 대해서 더 얘기하려면 일단 모듈 종속성이라는 걸 더 깊게 이야기해야 할 거 같아요.
ESM이 어떻게 동작하고 순환 종속성을 다루는지 이해하기 위해서는 ES 모듈을 사용할 때 JavaScript가 어떻게 파싱되고 평가되는지 더 알아봐야 합니다.
인터프리터는 모듈이 실행되어야 할 코드의 순서와 함께 모듈 간에 어떠한 종속성을 갖는지 이해하기 위해서 기본적으로 종속성 그래프를 필요로 합니다. Node 인터프리터가 실행되면, 일반적으로 JavaScript 파일 형식으로 실행할 코드가 전달됩니다. 파일은 종속성 확인을 위한 진입점 (entry point)입니다.
인터프리터는 진입점에서부터 필요한 모든 코드가 탐색되고 평가될 때까지 import 구문을 재귀적인 깊이 우선 탐색으로 찾습니다.
쉽게 말하자면,
ESM은 모듈 종속성 관계를 추적하기 위해서 이런 세 단계를 따르고 있다고 해요.
CommonJS와 유사해보여도 여기에는 근본적인 차이가 있는데,
CommonJS는 동적이기 때문에 종속성을 모두 파악하기 전에 파일이 읽힘과 동시에 실행된다는거죠.
if (a === true) {
const { aModule } = require('a.js');
}
동적인 건 뭐고 정적인 게 뭔지 많이 헷갈릴 거에요.
코드가 동적이다, 정적이다 라고 하면 당연히 헷갈릴 법 해요.
그러니깐 이 특성을 보면 좋을 거 같아요.
CommonJS는 코드 중간에 require()을 사용해도 되지만, ESM에서는 코드 중간에 import가 불가능해요.
마치 var에서 호이스팅이 일어나는 것처럼, import가 무엇보다도 우선시되는 거죠.
그래서 언제든 다른 모듈을 부를 수 있는 CommonJS는 동적이고, ESM은 반대로 정적이라는 거에요.
이런 차이 때문에 두 방식에서는 종속성의 차이가 발생해요.
종속성은, 쉽게 말하면 어떤 파일이 어떤 파일을 부르고 있는지, 그 관계를 말해요.
ESM에서는 서로에 대한 완전한 참조를 가지게 돼요. 이 부분이 CommonJS와는 다르다네요.
아니, 그러면 CommonJS에서는 완전하지 못한 참조라는 걸까요?
네, 맞습니다. CommonJS에서는 시간 차가 발생해요.
가령 a가 b를 부르고, b가 a를 부르는 구조로 되어 있을 때,
a는 b를 가지고 있지만, a가 가진 b에는 a가 없다는 거죠.
왜냐하면, a가 먼저 b를 불렸기 때문에 b에는 아직 a가 없죠.
a = { b : { } } // a가 가지고 있는 종속성
b = { a : { b } } // b가 가지고 있는 종속성
ESM에서는 이런 문제가 발생하지 않아요.
서로의 관계를 모두 파악한 다음에 종속성을 표현하기 때문에 이런 문제가 발생하지 않죠.
실행은 이런 종속성 관계가 모두 파악된 다음에 일어납니다.
각 단계 별로 더 깊게 살펴보면,
main.js -> a.js -> b.js // 차례대로 모두 방문한다.
main.js -> b.js // b는 이미 방문한 지점이므로 무시된다.
b.js -> a.js // a는 이미 방문한 지점이므로 무시된다.
인스턴스화 단계에서는 인터프리터가 이전 단계에서 얻어진 트리 구조를 따라 아래에서 위로 움직입니다. 인터프리터는 모든 모듈에서 익스포트된 속성을 먼저 찾고 나서 메모리에 익스포트된 이름의 맵을 만듭니다.
b = { loaded : <uninitalized> a : <uninitalized> } // a file을 참조하고 있다.
a = { loaded : <uninitalized> b : <uninitalized> }
main
모든 인터턴스화 단계를 거쳤다고 해도 이름을 추적하여 링크를 만든 것일 뿐, 실질적인 코드가 있는 것은 아니다. 따라서 평가를 할 필요가 있는데, 이 단계에서는 DFS를 후위 깊이 우선 탐색으로 하여 아래에서 위로 올라가는 방식으로 평가한다. 이런 경우 마지막 파일은 entryPoint가 된다. 이는 비즈니스 로직을 수행하기 전에 익스포트된 모든 값이 초기화되는 것을 보장한다.
이로 인해, 또한, 상호 참조하고 있는 모듈 간의 문제도 해결할 수 있어요.
이는 CommonJS에서 서로 코드를 가져와서 읽고 있는 것과 달리,
ESM은 참조를 사용하고 있기 때문이죠.
ES6는 이미 대중적이므로, 특이할 사항만 더 적어보도록 할게요.
export를 사용하는 것은, 자동 임포트, 자동 완성, 리팩토링 툴을 지원할 수 있게 해준다.
하지만 export default 구문은 그럴 수 없다.export default는 단일 책임 원칙을 권장하고, 깔끔한 하나의 인터페이스만을 제공하기에 적합
하다. 사용자의 관점에서 봤을 때 바인딩을 위한 이름을 정확히 알 필요 없다는 것도 장점
이다.이 외에도 몇 가지 특징들이 더 있긴 하지만, 실제로 접할 일은 아마 없을 거라고 생각해요.
즉, ESM과 CommonJS는 코드 취향의 문제가 아니라는 걸로 결론내립니다.
잘못된 내용이 있으면 언제든지 경청하겠습니다. :)