ES Modules vs. CommonJS

자바스크립트 애플리케이션이 복잡해지면서 코드를 모듈화하는 방법이 중요해졌다.
이에 두 가지 주요 모듈 시스템인 CommonJS와 ES Modules가 등장했다.
이 두 시스템은 각각 고유한 특성과 사용 사례를 가지고 있다.

JavaScript 모듈 시스템의 선택은 프로젝트의 요구 사항, 타겟 환경, 그리고 기존 코드베이스에 크게 의존한다.
최신 프로젝트에서는 ES Modules의 채택이 증가하는 추세이지만, CommonJS는 Node.js 생태계에서 여전히 중요한 역할을 하고 있다.

두 시스템의 장단점을 이해하고, 필요에 따라 적절한 시스템을 선택하거나 하이브리드 접근 방식을 채택하는 것이 좋다. 또한, 점진적으로 ES Modules로 마이그레이션하는 전략을 고려할 수 있으며, 이를 통해 모던 JavaScript의 이점을 활용하면서 기존 코드의 호환성도 유지할 수 있다.

ES Modules (ESM)

역사 및 배경

ES Modules는 ECMAScript 2015(ES6)에서 JavaScript의 공식 표준 모듈 시스템으로 도입되었다. 웹 브라우저와 서버 환경 모두에서 일관된 모듈화 방식을 제공하기 위해 설계되었으며, JavaScript 언어 자체에 내장된 기능이다.

핵심 특징 및 문법

  1. 정적 구조
    ESM의 가장 중요한 특징 중 하나는 정적 구조이다.
    모든 가져오기와 내보내기가 파일 최상위에서 정적으로 선언되어야 한다.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    // math.js
    export const PI = 3.14159;
    export function add(a, b) {
      return a + b;
    }
    export default class Calculator {
      // 클래스 구현
    }
    
    // app.js
    import Calculator, { PI, add } from './math.js';
    
  2. 명명된 내보내기와 기본 내보내기
    ESM은 두 가지 유형의 내보내기를 지원한다:

    1
    2
    3
    4
    5
    6
    
    // named exports
    export const name = 'value';
    export function myFunction() { /* … */ }
    
    // default export (파일당 하나만 가능)
    export default function() { /* … */ }
    
  3. 비동기 로딩 지원
    ESM은 import() 함수를 통한 동적 가져오기를 지원하여 필요할 때만 모듈을 로드할 수 있다:

    1
    2
    3
    4
    5
    6
    7
    
    // 필요할 때만 로드
    if (condition) {
      import('./heavy-module.js')
        .then(module => {
          // 모듈 사용
        });
    }
    

ES Modules 권장 상황

  1. 새 프로젝트 시작 시: 미래 호환성 고려
  2. 브라우저 환경에서 실행되는 코드: 네이티브 지원
  3. 트리 쉐이킹이 중요한 경우: 번들 크기 최적화
  4. Deno 기반 프로젝트: 기본 지원
  5. 최신 문법과 기능을 활용하는 프로젝트: Top-level await 등

CommonJS (CJS)

역사 및 배경

CommonJS는 2009년에 개발되었으며, 주로 브라우저 외부 환경(특히 서버 사이드)에서 JavaScript를 실행하기 위한 모듈 시스템이다.
Node.js가 이 시스템을 기본 모듈 시스템으로 채택하면서 널리 사용되기 시작했다.

핵심 특징 및 문법

  1. 동적 구조
    CommonJS는 런타임에 모듈을 로드하는 동적 구조를 가지고 있어 조건부 로딩이 쉽다:

    1
    2
    3
    4
    5
    
    // 조건부 모듈 로딩
    if (process.env.NODE_ENV === 'development') {
      const devModule = require('./dev-module');
      devModule.setup();
    }
    
  2. 내보내기 방식
    CommonJS에서는 module.exports 또는 exports 객체를 통해 값을 내보낸다:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    // 단일 값 내보내기
    module.exports = function() { /* … */ };
    
    // 객체로 여러 값 내보내기
    exports.name = 'value';
    exports.myFunction = function() { /* … */ };
    
    // 또는
    module.exports = {
      name: 'value',
      myFunction: function() { /* … */ }
    };
    
  3. 가져오기 방식
    CommonJS에서는 require() 함수를 사용하여 모듈을 가져온다:

    1
    2
    
    const fs = require('fs');
    const { myFunction } = require('./my-module');
    

CommonJS 권장 상황

  1. 레거시 Node.js 코드베이스: 호환성 유지
  2. 빠른 프로토타이핑: 간단한 require() 구문
  3. 동적 모듈 로딩이 필요한 경우: 런타임 경로 결정
  4. Node.js 특화 도구 및 유틸리티: 내장 모듈과 호환성
  5. 오래된 패키지와의 호환성이 중요한 경우: 생태계 통합

두 모듈 시스템의 비교 분석

  1. 로딩 메커니즘

    • ES Modules: 정적 로딩을 기본으로 하며, 코드 실행 전에 모듈 구조를 분석한다. 이는 더 나은 트리 쉐이킹과 최적화를 가능하게 한다.
    • CommonJS: 동적 로딩을 사용하여 런타임에 모듈을 로드한다. 이는 조건부 로딩에 유리하지만 정적 분석을 어렵게 만든다.
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    // ESM - 정적 구조로 인해 이런 동적 가져오기는 기본적으로 불가능
    import { feature } from './module.js';  // 항상 로드됨
    
    // 동적 가져오기는 별도 문법 필요
    async function loadFeature() {
      const module = await import('./module.js');
      return module.feature;
    }
    
    // CommonJS - 런타임에 결정되는 동적 로딩 가능
    if (condition) {
      const feature = require(`./features/${featureName}`);
      feature.activate();
    }
    
  2. 문법 및 사용성

    • ES Modules:
      • 명확한 import/export 문법
      • 구조 분해를 통한 부분 가져오기가 간결함
      • 기본 내보내기와 명명된 내보내기 구분이 명확함
      • 항상 import 문이 파일 최상위에 위치해야 함
    • CommonJS:
      • require()/module.exports 문법 사용
      • 함수 호출 기반이라 코드 어디서나 사용 가능
      • 구조 분해를 통한 가져오기가 약간 더 장황할 수 있음
      • 내보내기 구분이 덜 명확함 (모두 객체 속성으로 취급)
  3. 비동기 처리

    • ES Modules:
      • 기본적으로 모듈을 동기적으로 로드
      • 동적 가져오기(import())를 통한 비동기 로딩 지원
      • Top-level await 지원 (ES2022부터)
    • CommonJS:
      • 기본적으로 동기적 로딩만 지원
      • 비동기 로딩을 위한 추가적인 패턴 필요
      • Top-level await 미지원
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    // ESM - Top-level await (ES2022+)
    // database.js
    export const db = await initDatabase();
    
    // CommonJS - Top-level await 불가능
    // database.js
    let db;
    initDatabase().then(result => {
      db = result;
    });
    module.exports = { get db() { return db; } };
    
  4. 순환 의존성 처리

    • ES Modules:
      • 순환 참조를 더 잘 처리함
      • 바인딩을 참조로 가져와 실시간 값 반영
    • CommonJS:
      • 순환 참조 처리가 제한적임
      • 모듈이 완전히 평가되기 전에 참조하면 빈 객체 반환 가능성
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    // ESM 순환 참조 예제
    // a.js
    import * as b from './b.js';
    export let done = false;
    console.log('b.done in a:', b.done);
    setTimeout(() => {
      done = true;
    }, 100);
    
    // b.js
    import * as a from './a.js';
    export let done = false;
    console.log('a.done in b:', a.done);
    setTimeout(() => {
      done = true;
    }, 200);
    
    // 참조가 실시간으로 업데이트됨
    
  5. 브라우저 및 환경 지원

    • ES Modules:
      • 현대적인 모든 브라우저에서 네이티브 지원
      • Node.js에서 공식 지원 (확장자.mjs 또는 package.json의 “type”: “module”)
      • Deno에서 기본 모듈 시스템
    • CommonJS:
      • 브라우저에서 직접 지원하지 않음 (번들러 필요)
      • Node.js의 기본 모듈 시스템
      • Deno에서는 지원하지 않음
  6. 파일 확장자 및 경로

    • ES Modules:
      • Node.js에서 파일 확장자를 명시해야 함
      • URL 기반 경로 시스템 (브라우저 호환성)
    • CommonJS:
      • Node.js에서 파일 확장자 생략 가능
      • 파일 시스템 기반 경로
    1
    2
    3
    4
    5
    
    // ESM - 확장자 필수
    import { feature } from './module.js';
    
    // CommonJS - 확장자 생략 가능
    const feature = require('./module');
    
  7. 빌드 도구 통합

    • ES Modules:
      • 트리 쉐이킹에 최적화됨
      • 정적 분석 가능성으로 더 나은 최적화
      • 번들 사이즈 축소에 유리
    • CommonJS:
      • 동적 구조로 인해 트리 쉐이킹이 어려움
      • 번들 사이즈가 더 클 수 있음
      • 일부 최적화 기법 적용 제한
  8. 성능 특성

    • ES Modules:
      • 정적 구조로 인한 컴파일 타임 최적화 가능
      • 더 나은 트리 쉐이킹으로 번들 크기 감소
      • 병렬 로딩 가능
    • CommonJS:
      • 동적 로딩으로 필요한 코드만 로드 가능
      • 런타임 평가로 인한 일부 성능 오버헤드
      • 모듈 캐싱으로 중복 로딩 방지

문법 및 사용성

Import vs. Require

require는 Node.js에서 사용되는 CommonJS 모듈 시스템의 키워드로, 동기적으로 모듈을 로드하며 프로그램의 어느 지점에서나 호출할 수 있다. 반면에 import는 ES6에서 도입된 모듈 시스템의 키워드로, 코드 실행 전에 모듈을 미리 로드하며 파일의 시작 부분에서만 사용할 수 있다. 따라서 프로젝트의 환경과 요구 사항에 따라 적절한 키워드를 선택하여 사용하는 것이 중요하다.

Import

ES6(ES2015)에서 도입된 모듈 시스템으로, JavaScript의 공식 표준 모듈 시스템. 정적 임포트 방식을 사용하며, 브라우저에서 기본적으로 지원된다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 기본 가져오기
import module from './module';

// 부분 가져오기
import { function1, function2 } from './module';

// 모든 것을 객체로 가져오기
import * as moduleObject from './module';

// 이름 변경하여 가져오기
import { originalName as newName } from './module';

// 가져오기와 실행만 하기
import './module';
Require (CommonJS)

Node.js에서 기본적으로 사용되는 모듈 시스템으로, 동적 임포트를 지원한다. 런타임에 모듈을 로드할 수 있다.

1
2
3
4
5
6
7
8
9
// 기본 가져오기
const module = require('./module');

// 구조 분해 할당으로 부분 가져오기
const { function1, function2 } = require('./module');

// 동적 경로로 가져오기
const moduleName = 'myModule';
const module = require(`./${moduleName}`);
Import와 Require의 비교 분석
특성import (ES Modules)require (CommonJS)
문법정적이고 선언적인 문법함수 호출 방식의 동적 문법
로딩 시점파일의 시작 부분에서 정적으로 로드코드 실행 중 어느 시점에서나 동적으로 로드 가능
비동기 지원비동기 로딩 지원 (import())동기적 로딩만 지원
브라우저 지원모던 브라우저에서 기본 지원브라우저에서 직접 사용 불가 (번들러 필요)
트리 쉐이킹지원 (사용하지 않는 코드 제거 가능)지원하지 않음
순환 참조더 나은 순환 참조 처리부분적인 순환 참조 처리
캐싱모듈 단위로 캐싱파일 경로 기반 캐싱
조건부 로딩정적 분석으로 인해 제한적조건부 로딩 가능
Node.js 지원Node.js 13.2.0부터 실험적 지원기본적으로 지원
번들링번들러 지원 필요 없음번들러 필요
메모리 사용더 효율적 (정적 분석 가능)상대적으로 덜 효율적

모듈 시스템 전환 및 호환성

Node.js에서 두 시스템 함께 사용하기

최신 Node.js에서는 두 모듈 시스템을 모두 지원하며, 다음과 같은 방법으로 구분한다:

  1. 파일 확장자 기반:

    • .mjs: ES 모듈로 처리
    • .cjs: CommonJS 모듈로 처리
    • .js: package.json의 “type” 필드에 따라 결정
  2. package.json 설정:

    1
    2
    3
    
    {
      "type": "module"  // 기본값은 "commonjs"
    }
    
  3. 상호 호출:

    • ES 모듈에서 CommonJS 모듈 가져오기: 일반 import 구문 사용
    • CommonJS에서 ES 모듈 가져오기: 동적 import() 함수 사용

하이브리드 패키지 배포

패키지를 두 모듈 시스템 모두에 호환되게 배포하는 방법:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "name": "my-package",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

ES Modules vs. CommonJS 특성 비교

특성ES Modules (ESM)CommonJS (CJS)
표준화ECMAScript 공식 표준커뮤니티 규약
도입 시기ECMAScript 2015 (ES6)2009년
주요 환경브라우저, Node.js, DenoNode.js
문법import/exportrequire()/module.exports
로딩 방식정적 (분석 시점)동적 (런타임)
파일 확장자필수 (Node.js)선택적
경로 형식URL 기반파일 시스템 기반
비동기 로딩기본 지원 (import())기본 미지원 (별도 패턴 필요)
Top-level await지원 (ES2022+)미지원
트리 쉐이킹최적화됨제한적
순환 참조 처리완전 지원 (실시간 바인딩)제한적 지원
브라우저 지원네이티브 지원불가 (번들러 필요)
조건부 로딩제한적 (동적 import() 필요)용이함
모듈 스코프파일 스코프함수 스코프
전역 객체없음 (import.meta 사용)__dirname, __filename
호이스팅가져오기가 호이스팅됨require()는 호이스팅되지 않음
코드 분할용이함어려움
빌드 도구 최적화높음중간
번들 크기작음 (트리 쉐이킹)큼 (전체 모듈 포함)
레거시 코드베이스적음많음
Node.js 기본 방식v13.2.0+ 지원, 기본은 아님기본 시스템
실행 환경항상 strict 모드strict 모드 선택적
프로퍼티 접근정적 바인딩 (내보낸 이후 변경 불가)객체 참조 (내보낸 후 변경 가능)

참고 및 출처