자바스크립트 모듈 시스템 (.cjs, .mjs)

#CJS #ESM #JavaScript #Module

본격적으로 디자인 패턴을 알아보기에 앞서, 먼저 자바스크립트 ES6의 문법을 짚어보는 챕터가 있었다. 그 중 모듈 시스템에 대해 알아보았다.


자바스크립트 모듈 시스템

1. CJS와 ESM의 구분

CJS (CommonJS) : Node.js의 전통적인 방식으로, require를 통해 모듈을 동기적으로 불러오는 서버 사이드 중심의 모듈 시스템입니다. **ESM **(ES Modules) : 자바스크립트 표준 방식으로, import를 통해 모듈을 비동기적으로 불러오며 트리쉐이킹과 브라우저 환경에 최적화된 모듈 시스템입니다.

자바스크립트에는 .cjs.mjs라는 확장자가 있다. 이 확장자들은 자바스크립트에 모듈 개념을 도입하면서 생겨났다.

원래 자바스크립트는 모듈 문법이 없어, commonJS의 require, export 기능을 사용해 모듈처럼 구현하여 사용했다(CJS). 그리고 좀 더 편리한 사용을 위해 ES6부터 import, export를 사용하는 모듈 문법이 도입되었다(ESM).

하지만 새롭게 모듈 문법을 도입하며 문제가 발생했는데, commonJS와 ES6의 모듈 시스템의 동작 방식이 너무 달라 둘을 구분 할 필요가 생긴 것이다. 그래서 도입된 것이 바로 확장자 구분으로, CJS를 사용하는 경우 .cjs를, ESM을 사용하는 경우 .mjs를 사용하게 되었다.

모듈 확장자의 우선순위를 결정하는 때는 크게 두 가지이다. 하나는 빌드를 할 때, 다른 하나는 브라우저에서 동작할 때 이다.

2. 빌드 시 모듈 시스템

모듈 시스템의 우선순위

빌드를 할 때는 무조건 확장자를 우선으로 한다. 확장자가 .js인 경우에는 package.json의 type이 module인 경우 .mjs를, 그렇지 않은 경우 .cjs를 우선으로 한다.

Node.js/빌드 도구의 결정 로직 1. 확장자 최우선: .mjs는 ESM, .cjs는 CJS. 2. .js 확장자일 때: 가장 가까운 부모의 package.json 내 “type” 필드를 확인 - “type”: “module” → ESM으로 처리 - “type”: “commonjs” 또는 필드 없음 → CJS로 처리

모듈 시스템 간 차이점

빌드를 할 때 두 확장자가 가지는 차이점은 로드 방식이다. .cjs의 경우 런타임에 모듈을 가져와 빌드 시점에는 해당 모듈이 사용되는지 안되는지 알 수 없다. 반면, .mjs의 경우 정적으로 모듈을 가져와 빌드 시점에 해당 모듈이 사용되는지 여부를 알 수 있다.

따라서 .mjs의 경우 빌드 후 번들링 할 때 트리쉐이킹이 가능하다. 트리쉐이킹은 사용되지 않는 모듈(Export된 함수나 변수)을 제거하는 것을 의미한다.

3. 브라우저 동작 시 모듈 시스템

모듈 시스템의 우선순위

브라우저에서 동작할 때는 script 태그 안의 type을 우선으로 하며 확장자는 중요하지 않다. type이 module인 경우에는 확장자가 .cjs여도 브라우저는 ES6 문법으로 파일을 읽으려고 하고, 이런 경우 런타임 에러가 발생하게 된다. type을 지정하지 않으면 브라우저는 CJS 방식으로 파일을 읽는다.

script 태그 안에 nomodule 속성이 있는 경우, 최신 브라우저는 해당 파일을 읽지않고 건너뛴다. 따라서 호환성을 위해 type=“module”을 사용한 태그와 nomodule 속성을 사용한 태그를 함께 배치하는 경우가 많다.

<script type="module" src="code.new.js"></script> 
<script nomodule src="code.old.js"></script>

브라우저 결정 로직

  • 확장자: 중요하지 않음.
  • 결정권: HTML 태그의 **type=“module”**이 절대적임.

모듈 시스템 간 차이점

브라우저에서 동작할 때 ESM을 사용하는 경우 기능이 많이 개선된다.

  1. scope를 전역이 아닌 파일로 할당한다.

  2. 기본적으로 strict 문법을 적용한다.

  3. 비동기 로딩을 지원하여 script 태그에 defer 속성을 적용하지 않아도 된다. defer 속성은 비동기로 파일을 다운로드 받는 것으로, html을 읽다가 script 태그를 만나도 파싱을 멈추지 않고 백그라운드에서 .js 파일을 다운로드 받은 뒤, html 파싱이 끝나면 파일을 실행한다.

  4. 의존성 그래프 관리를 해준다. 즉, 모듈을 한 번만 다운로드 받아도 된다. 의존성 그래프는 import의 모든 재귀적 호출을 뜻하는 말이다. 브라우저는 import 문을 발견하면 해당 모듈이 의존하고 있는 다른 파일들을 추적하여 재귀적으로 모두 다운로드하는데, 이 과정에서 똑같은 모듈이 여러 번 호출되더라도 브라우저는 이를 기억했다가 딱 한 번만 실행한다.

ESM 정적 가져오기의 문제점

의존성 그래프 즉, 파일 상단에 import를 호출하여 모듈을 가지고 오는 방식정적 가져오기라고 한다. 정적 가져오기의 문제점은, 모든 모듈을 다 가지고 오기 전에는 먼저 가지고 온 모듈도 실행되지 않는다는 점이다. 작은 프로젝트에서는 괜찮지만 프로젝트의 규모가 커질 경우 모듈의 크기도 비대해져 사용자가 페이지에 접속했을 때 빈 화면을 보게되는 경우가 생길 수 있다.

이 때 필요한 것이 동적 가져오기이다. 동적 가져오기란 말 그래도 모듈을 런타임에 동적으로 가지고 오는 것으로, 바닐라 JS에서는 promise 기반으로 구현한다.

import("./heavyModule.js")
	.then((module) => {
		module.action(); 
		...

ESM 동적 가져오기

동적 가져오기는 프로그램이 이미 실행 중인 런타임에 필요한 시점에만 모듈을 가져오기 때문에, 초기 실행 속도를 획기적으로 줄일 수 있다. 동적 가져오기를 적용하면 정적 가져오기의 의존성 그래프와는 다른 그래프가 생성돼 저장된다(Code Splitting). 동적 가져오기는 이벤트가 들어오는 사용자 상호작용에 따라 적용할 수도 있고, 컴포넌트가 화면에 보일 때에 맞춰 적용할 수도 있다.

리액트의 경우에는 동적 가져오기를 React.lazySuspense를 이용하여 구현한다. React.lazy는 컴포넌트를 필요한 때에 import하는 기능이고, Suspense는 React.lazy로 로딩되는 컴포넌트가 뜨기 전 Spinner와 같은 대체 UI를 표시하는 기능이다.

import React, { Suspense, lazy } from 'react';

// 1. 정적 가져오기: 메인 번들에 포함됨 (초기 로딩 시 다운로드)
import Header from './components/Header';

// 2. React.lazy: 별도의 파일로 쪼개짐 (실제 렌더링될 때 다운로드)
const HeavyChart = lazy(() => import('./components/HeavyChart'));

function App() {
  return (
    <div>
      <Header />
      
      {/* 3. Suspense로 감싸서 로딩 상태 처리 */}
      <Suspense fallback={<div>차트를 불러오는 중...</div>}>
        <HeavyChart />
      </Suspense>
    </div>
  );
}

요약

  1. .mjs와 .cjs는 Node.js에서 ESM과 CJS를 구분하기 위한 명시적 약속이다.
  2. 브라우저는 확장자보다 HTML의 type=“module” 속성을 통해 모듈 여부를 판단한다.
  3. 성능 최적화를 위해 정적 가져오기를 지양하고, React.lazy 같은 동적 가져오기로 의존성 그래프를 쪼개는 것이 중요하다.