관련 있는 데이터와 함수들을 모아 하나의 파일로 묶은 것을 모듈이라고 하며, 소프트웨어 개발에서 애플리케이션을 여러 개의 독립적인 "모듈" 단위로 분할하고, 이를 재사용 가능하게 관리하는 것을 모듈 시스템이라고 부릅니다.
어플리케이션의 코드는 방대한데, 이를 하나의 파일로 관리해야만 한다면 유지보수가 굉장히 난해해질겁니다.
이를 개선하기 위해 나온 아이디어가 모듈시스템이라고 생각해도 좋습니다.
이런 아이디어에서 나온 개념이다보니, 모듈은 모듈별로 분리되는 Scope를 가집니다.
(그렇다면 css도 모듈로 뽑아낼 수 있을까요? https://ocahs.tistory.com/40 에 정답이 있으니 궁금한 분은 확인해보세요)
오늘은 자바스크립트의 2가지 모듈시스템인 ESM과 CJS에 대해 간단히 알아보도록 하겠습니다.
ESModule(ESM) vs CommonJS(CJS)
우선, 표로 정리하고 시작합시다.
| ESM | CJS | |
| 문법 | import, export | require(), module.exports |
| 동작 시점 | 정적 (코드 파싱 시점에 import/export 관계가 확정됨) | 동적 (실행 중 require() 호출 시 로드됨) |
| 모듈 로딩의 동기성 | 비동기적 | 동기적 |
| 스코프 | 파일마다 독립적인 모듈 스코프 | 파일마다 함수로 감싸져 독립 스코프 |
| 브라우저 지원 | 지원 (<script type="module">) | 미지원 |
| 순환 참조 | 지원 (초기 바인딩이 걸려 있음) | 지원 (실행 시점에 객체를 참조) |
| 캐싱 | 한 번 실행 후 결과를 캐싱 | 한 번 실행 후 결과를 캐싱 |
| 사용 환경 | 현대 JS (브라우저, Node 12+) | Node.js 초창기 기본 모듈 시스템 |
ESM이든 CJS든 캐싱은 언어 차원에서 보장됩니다.
언어 차원에서 보장된다는 의미는, ECAMAScript의 표준으로 제정되어 있다라는 의미입니다.
즉, JS파일(모듈)이 평가되고, 최초 1회 실행되는 시점에 해당 모듈 객체를 메모리에 캐싱해둡니다.
이를 통해 다른 곳에서 import 해서 사용해와도 같은 모듈 객체를 재사용하게 됩니다.
여기서 계속 "모듈 객체"라고 언급하고 있는데, 그럼 모듈은 객체일까요?
모듈은 읽기 전용 객체다
모듈 객체는 정확히 모듈 네임스페이스 객체입니다.
이는 읽기 전용의 객체이며, 바인딩 자체를 수정할 수 없습니다. 또한 프로퍼티를 추가하거나 삭제할 수도 없습니다.
그래서 모듈 객체의 프로퍼티도 "직접" 수정하는 것은 불가능합니다.
반드시 모듈 객체 내의 다른 함수를 통해 프로퍼티를 수정해야합니다.
마치 클래스의 private 변수를 수정하는 방식과 같습니다.
여튼, 모듈은 객체이기 때문에 그 값은 원시값이 아닌 참조값이고,
어떤 파일에서든 같은 모듈을 import 해올 때는 같은 참조를 바라보게 됩니다.
그래서.. 반드시 객체 형식으로 export 하고, import 해와야한다.
잘못된 코드는 아래과 같습니다.
import * from "example.js"
모듈을 지칭할 객체명이 필요하여, as로 가져와야합니다.
import * as Example from "example.js"
혹은 모듈 객체를 풀어서 필요한 부분만 가져올 수도 있겠죠. (이게 트리쉐이킹을 유발하기 좋습니다.)
import {exampleFunc} from "example.js"
require은 객체를 가져오는게 아니어보이는데.. (오해)
CJS에서의 require도 모듈 객체를 통채로 가져옵니다. ESM과 차이점은 항상 "통채로" 가져올 수밖에 없다는 것 뿐입니다.
// example.js
module.exports = { foo: 123 };
//혹은
exports.foo = 123; //module.exports에 프로퍼티를 추가하는 것이나 마찬가지입니다.
// main.js
const example = require("./example.js");
console.log(example.foo); // 123
CJS의 등장과 그 이후 ESM의 표준화 (왜 CJS와 ESM의 특징이 달랐는가?)
태초의 JS에는 모듈시스템이라는게 없었습니다.
아주 단순한 웹의 형태였기 때문에, <script> 태그로도 충분했습니다.
그리고 공유해야하는 변수는 전역 변수를 통해 공유했습니다. (window 객체에 바인딩)
다만 JS를 서버에서도 활용하자는 아이디어에서 착안한 Node.js (자바스크립트 런타임)이 만들어지면서, CJS가 먼저 등장했습니다.
특히 서버 컴퓨터 내에서는 파일을 하나의 컴퓨터에서 I/O 과정을 거쳐 가져오면 되었으므로, CJS는 CJS는 동기적으로 모듈을 가져오도록 설계되었습니다.
이를 역으로 브라우저 환경에서도 사용하려고 했어요. 자바스크립트 파일을 모듈로 나눠서 로딩해오려고 했죠.
다만 브라우저 환경에서는 네트워크를 타야만 했고, 동기적인 처리는 매우 큰 비효율을 초래했기 때문에, ESM은 비동기적으로 모듈을 가져오도록 설계해야했습니다.
이 과정에서 우후죽순 다양한 모듈 로드 방식이 나오면서,ECMAScript 위원회에서 직접 나서 언어 차원의 표준 모듈 시스템을 설계했습니다. 그게 ESM이고요.
이후 Node 측에서는 자바스크립트 표준 모듈 시스템이 나왔으니, 안 따를 수는 없어서 계속 눈치만 보다가 Node 12+ 버전부터는 ESM을 지원하면서 .mjs 파일이 가능해졌습니다. 이를 통해 Node에서도 트리쉐이킹이 가능해졌고,
또한 이런 과정 덕분에, 브라우저와 Node간에 같은 모듈시스템(ESM)을 쓸 수 있게 되었고
Node·브라우저 공용 코드를 작성할 수 있게 되었습니다.
ESM이 비동기적으로 모듈을 로드해오면 문제가 발생하지 않을까?
import 구문 자체는 비동기적으로 모듈 객체를 불러오는 것은 맞습니다.
따라서 이런 생각을 할 수도 있습니다.
비동기적이면, import가 되지 않았는데 그걸 어떻게 활용해서 해당 모듈(js 파일)을 평가하고 실행하지?
하지만 모든 import 구문의 로드가 끝난 후에, 모듈 내의 다른 코드들을 실행하기 때문에 문제는 없습니다.
//해당 파일은 main.js 라고 가정
import * as A from "A.js"
import * as B from "B.js"
//아래의 코드는 import가 전부 완료된 이후에 평가,실행된다.
console.log(A.title);
console.log(B.name);
정확히는 다음과 같은 과정을 거칩니다.
--평가 과정
0. main.js 소스코드를 읽으면서, AST 생성함. (이미 main.js 파일은 로드되었다고 가정)
1. main.js 의 평가 시작 -> import 구문 발견
2. import 대상 모듈 파일을 불러옴
3. 모듈 내부를 평가 단계(정적 분석)만 수행 - A.js 와 B.js 의존성 그래프(AST) 생성
--실행 과정
1. 모듈의 의존성 순서에 맞게 코드를 실행 (A.js 및 B.js 실행)
2. main.js의 코드 실행 : console.log 실행됨
따라서 문제가 발생하지 않습니다.
번외) 번들링과 청킹
뜬금없이 번들링과 청킹을 이야기하는 것 같지만, 평가 과정이라는 이야기가 나와서 언급해보고자 합니다.
사실 js 파일들을 하나의 파일로 합치는 것을 (즉, 모듈들을 전부 모아 하나의 파일로 합치는 것을) 번들링이라고 합니다.
그리고 필요에 따라, 이렇게 번들링된 코드 조각 중 일부의 코드(보통 모듈 단위)를 자르는 것이 청킹입니다.
필요에 따라 먼저 로드하거나, 나중에 로드하기 위해서 보통 번들러의 옵션을 통해 청킹을 해두곤 합니다.
번들러에서는 오직 실행은 안 일어나고, 평가만 일어납니다.
번외의 번외) 다른 언어로 구현된 번들러가 존재할 수 있는 이유?
번들러는 대부분 Node.js 플랫폼에서 실행될 수밖에 없는데 (그래야 HMR 기능을 누리기도 하고)
종종 SWC나 ESbuild 같이 JS로 만들어지지 않고 다른 언어로 만들어진 번들러가 존재하곤 합니다.
이는 FFI(Foreign Function Interface)를 사용했기 때문입니다.
FFI는 한 프로그래밍 언어(이하 A)에서 다른 프로그래밍 언어(이하 B)의 코드를 호출하기 위한 인터페이스를 말합니다.
즉, Node.js 에서는 FFI를 통해 다른 언어로 빌드(컴파일)된 코드를 실행할 수 있게 됩니다. 마치 JS 함수를 실행하는 것처럼요.
SWC를 예시로 들면, Rust로 작성된 JS 파서가 소스를 토큰화(tokenize) → AST 생성하고... 번들링하는 로직이 들어있을겁니다.
이 SWC를 Node에서 FFI로 호출하여 빌드를 맡기는 형식입니다.
번외 2) 브라우저 퍼포먼스 탭의 JS Evaluation
여기서 JS Evaluation 라는 용어에 속으면 안됩니다.
실제로는 평가 과정만을 의미하지 않으며, 평가 - 실행의 일련의 과정을 의미합니다.
'Develop > Javascript' 카테고리의 다른 글
| AJAX - 네트워크 요청을 비동기적으로 처리하기 위한 (0) | 2025.11.15 |
|---|---|
| 이벤트 (Event) (0) | 2025.05.25 |
| 클래스 (Class) (0) | 2025.04.13 |
| 프로토타입 (ProtoType) (0) | 2025.02.11 |
| 클로저 (Closure) (0) | 2025.02.06 |