JavaScript

CommonJS와 ES Modules: 자바스크립트 모듈 시스템

Chrysans 2025. 3. 10. 17:48
728x90
반응형

 

자바스크립트 모듈 이미지

 

목차

  1. 들어가며: 자바스크립트 모듈의 필요성
  2. 모듈 시스템의 역사
  3. CommonJS: 작동 방식과 특징
  4. ES Modules: 작동 방식과 특징
  5. CommonJS와 ES Modules 비교
  6. 실제 구현 사례와 코드 예시
  7. 호환성 문제와 트러블슈팅
  8. 모범 사례
  9. 결론

들어가며: 자바스크립트 모듈의 필요성

웹 개발 생태계가 점점 복잡해짐에 따라, 코드를 구조화하고 관리하는 방법이 중요해졌습니다. 초기 자바스크립트는 모듈 시스템 없이 모든 코드가 전역 네임스페이스를 공유했습니다. 이는 대규모 애플리케이션에서 여러 문제를 야기했습니다:

  • 전역 변수 충돌: 다른 스크립트에서 동일한 이름의 변수나 함수를 사용할 때 충돌 발생
  • 의존성 관리 어려움: 스크립트 간의 의존 관계를 명확히 표현하기 어려움
  • 코드 재사용성 저하: 모듈화 없이는 코드 조각을 쉽게 재사용하기 어려움
  • 유지보수 복잡성 증가: 코드베이스가 커질수록 관리가 어려워짐

이러한 문제들을 해결하기 위해 자바스크립트 모듈 시스템이 발전해왔습니다. 오늘날 널리 사용되는 두 가지 주요 모듈 시스템인 CommonJS와 ES Modules에 대해 자세히 알아보겠습니다.


모듈 시스템의 역사

모듈 이전의 시대

자바스크립트가 처음 등장했을 때는 모듈 시스템이 없었습니다. 코드 분리를 위해 개발자들은 다음과 같은 방법을 사용했습니다:

<!-- 여러 스크립트 파일 로드 -->
<script src="utils.js"></script>
<script src="app.js"></script>

이 방식에서는 모든 스크립트의 변수와 함수가 전역 스코프에 노출되었고, 로드 순서가 중요했습니다. app.jsutils.js의 함수를 사용한다면, utils.js가 먼저 로드되어야 했죠.

네임스페이스 패턴

전역 변수 충돌을 줄이기 위해 객체를 네임스페이스로 사용하는 패턴이 등장했습니다:

// myLibrary.js
var MyLibrary = {
  utility: function() {
    // ...
  },
  anotherFeature: function() {
    // ...
  }
};

// 다른 파일에서
MyLibrary.utility();

즉시 실행 함수 표현식(IIFE)

모듈과 비슷한 격리된 스코프를 만들기 위해 즉시 실행 함수 표현식(IIFE)이 널리 사용되었습니다:

// module.js
var MyModule = (function() {
  // private 변수
  var privateVar = 'I am private';

  // public API 반환
  return {
    publicMethod: function() {
      console.log(privateVar);
    }
  };
})();

// 사용법
MyModule.publicMethod(); // "I am private" 출력
console.log(MyModule.privateVar); // undefined - 접근 불가

이 패턴은 클로저를 활용해 비공개 변수를 캡슐화하면서도 공개 API를 제공했습니다. 그러나 의존성 관리는 여전히 수동이었습니다.

AMD와 RequireJS

브라우저 환경에서 비동기 모듈 로딩을 위해 AMD(Asynchronous Module Definition)가 등장했으며, RequireJS가 이를 구현했습니다:

// math.js
define([], function() {
  return {
    add: function(a, b) { return a + b; },
    subtract: function(a, b) { return a - b; }
  };
});

// app.js
define(['math'], function(math) {
  console.log(math.add(2, 3)); // 5
});

CommonJS의 등장

2009년, 서버 사이드 자바스크립트 환경인 Node.js가 등장하면서 CommonJS 모듈 시스템이 도입되었습니다. 이는 동기적 모듈 로딩에 중점을 두었습니다.

ES Modules의 등장

드디어 2015년, ECMAScript 6(ES2015)에서 자바스크립트 언어 자체에 내장된 공식 모듈 시스템인 ES Modules가 도입되었습니다. 이로써 브라우저와 Node.js 환경에서 동일한 모듈 시스템을 사용할 가능성이 열렸습니다.


CommonJS: 작동 방식과 특징

CommonJS는 Node.js에서 기본으로 사용되는 모듈 시스템입니다.

기본 구문

// math.js - 모듈 내보내기
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

module.exports = {
  add,
  subtract
};

// 또는 개별적으로 내보내기
exports.add = add;
exports.subtract = subtract;
// app.js - 모듈 가져오기
const math = require('./math');
console.log(math.add(2, 3)); // 5

// 구조 분해 할당으로 특정 기능만 가져오기
const { subtract } = require('./math');
console.log(subtract(5, 2)); // 3

CommonJS의 주요 특징

  1. 동기적 로딩: 모듈이 순차적으로 로드되며, 모듈 로딩이 완료될 때까지 다음 코드가 실행되지 않습니다.
  2. 런타임 평가: 모듈이 실행 시점에 평가됩니다.
  3. 단일 내보내기 객체: module.exports는 하나의 객체나 값만 내보낼 수 있습니다.
  4. 캐싱: 모듈은 처음 require될 때 한 번만 실행되고 캐시됩니다. 이후 require 호출은 캐시된 결과를 반환합니다.
  5. 순환 의존성 처리: 모듈 A가 모듈 B를 require하고, 모듈 B가 다시 모듈 A를 require하는 순환 의존성을 처리할 수 있습니다.

CommonJS의 작동 방식

Node.js에서 require()를 호출하면 다음과 같은 과정이 발생합니다:

  1. 모듈 경로 해석 (상대 경로, 절대 경로, 패키지 이름)
  2. 모듈이 이미 캐시에 있는지 확인
  3. 캐시에 없다면 모듈 파일 로드
  4. 모듈 코드를 특별한 함수 래퍼로 감싸서 실행
  5. module.exports 객체 반환

Node.js가 모듈을 실행할 때 사용하는 함수 래퍼는 다음과 같습니다:

(function(exports, require, module, __filename, __dirname) {
  // 모듈 코드가 여기에 들어갑니다
});

이 래퍼는 모듈에 프라이빗 스코프를 제공하고 exports, require, module, __filename, __dirname과 같은 특수 변수를 주입합니다.


ES Modules: 작동 방식과 특징

ES Modules(ESM)은 ECMAScript 표준의 일부로, 모든 현대 브라우저와 최신 버전의 Node.js에서 지원됩니다.

기본 구문

// math.mjs - 모듈 내보내기
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;

// 기본 내보내기
export default function multiply(a, b) {
  return a * b;
}
// app.mjs - 모듈 가져오기
import { add, subtract } from './math.mjs';
import multiply from './math.mjs'; // 기본 내보내기 가져오기

console.log(add(2, 3)); // 5
console.log(multiply(2, 3)); // 6

// 전체 모듈 가져오기
import * as math from './math.mjs';
console.log(math.subtract(5, 2)); // 3

ES Modules의 주요 특징

  1. 정적 구조: import/export 구문은 코드 최상위 레벨에서만 사용할 수 있으며, 조건부나 함수 내부에서 사용할 수 없습니다.
  2. 비동기 로딩: 브라우저 환경에서는 기본적으로 비동기적으로 로드됩니다.
  3. 컴파일 타임 분석: 모듈 의존성이 실행 전에 분석되어 트리쉐이킹(사용하지 않는 코드 제거)과 같은 최적화가 가능합니다.
  4. 단일 바인딩: export된 바인딩은 라이브 연결을 유지합니다.
  5. 여러 내보내기: 여러 개의 명명된 내보내기와 하나의 기본 내보내기를 지원합니다.
  6. 확장자 필수: 모듈 경로에 확장자를 명시해야 합니다(브라우저 환경에서).
  7. 항상 strict 모드: 모듈은 항상 strict 모드에서 실행됩니다.

 

ES Modules의 작동 방식

ES Modules는 다음 세 단계로 처리됩니다:

  1. 구성 단계: 모듈 그래프를 생성하고 모든 import/export를 식별합니다.
  2. 인스턴스화 단계: 메모리에 모듈 환경과 바인딩을 설정하지만, 아직 값은 할당하지 않습니다.
  3. 평가 단계: 모듈 코드를 실행하고 실제 값을 바인딩에 할당합니다.

 

CommonJS와 ES Modules 비교

 

특징 CommonJS   ES Modules
구문 require()module.exports importexport
로딩 방식 동기적 비동기적 (브라우저)
평가 시점 런타임 컴파일 타임 + 런타임
바인딩 값 복사 라이브 바인딩
내보내기 타입 단일 객체 명명된 내보내기 + 기본 내보내기
조건부 로딩 지원 (동적 require) 제한적 (동적 import() 함수 필요)
순환 참조 처리 부분적 실행 객체 라이브 바인딩
파일 확장자 선택적 필수 (브라우저)
this 값 모듈 객체 undefined
환경 주로 Node.js 브라우저 + Node.js

 

바인딩 동작의 차이

CommonJS - 값 복사

// lib.js
let count = 0;
const increment = () => count++;
const getCount = () => count;

module.exports = {
  increment,
  getCount,
  count // 내보낼 때 값이 복사됨
};

// main.js
const lib = require('./lib');
console.log(lib.count); // 0
lib.increment();
console.log(lib.count); // 여전히 0 (복사된 값)
console.log(lib.getCount()); // 1 (함수 호출 결과는 최신값)

ES Modules - 라이브 바인딩

// lib.mjs
export let count = 0;
export const increment = () => count++;
export const getCount = () => count;

// main.mjs
import { count, increment, getCount } from './lib.mjs';
console.log(count); // 0
increment();
console.log(count); // 1 (라이브 바인딩으로 값이 업데이트됨)
console.log(getCount()); // 1

순환 의존성 처리 차이

CommonJS의 순환 의존성 처리

 

  • 부분적인 객체 노출: CommonJS는 모듈이 완전히 로드되기 전에도 부분적으로 완성된 객체를 노출합니다.
  • 처리 과정:
    • moduleA를 로드하기 시작
    • moduleA는 비어있는 exports 객체를 생성
    • moduleA가 moduleB를 require
    • moduleB가 비어있는 exports 객체를 생성
    • moduleB가 moduleA를 require (이미 로딩 중인 상태)
    • moduleB는 moduleA의 불완전한(빈) exports 객체를 받음
    • moduleB가 자신의 exports를 완성
    • moduleA의 로딩이 계속되어 자신의 exports를 완성
  • 특징:
    • 순환 참조 시 일부 참조가 undefined일 수 있음
    • 모듈 로딩 순서에 의존적
    • 동기적 로딩 방식

 

// a.js
console.log('a.js 시작');
exports.done = false;
const b = require('./b.js');
console.log('b.done =', b.done);
exports.done = true;
console.log('a.js 종료');

// b.js
console.log('b.js 시작');
exports.done = false;
const a = require('./a.js');
console.log('a.done =', a.done); // false - 아직 완료되지 않은 a 모듈의 부분적인 상태
exports.done = true;
console.log('b.js 종료');

// main.js
console.log('main.js 시작');
const a = require('./a.js');
const b = require('./b.js');
console.log('main.js 종료');

출력 결과:

main.js 시작
a.js 시작
b.js 시작
a.done = false
b.js 종료
b.done = true
a.js 종료
main.js 종료

ES Modules의 순환 의존성 처리

 

  • 호이스팅(Hoisting)과 링킹(Linking): ESM은 모듈을 세 단계(구문 분석, 인스턴스화, 평가)로 처리합니다.
  • 처리 과정:
    • 모든 모듈의 구조를 먼저 분석
    • 모듈 그래프(dependency graph)를 구성
    • 모든 export와 import를 연결(링킹)
    • 모듈 코드를 실행(평가)
  • 특징:
    • 선언이 호이스팅되어 참조 오류 감소
    • 모듈 간 의존성을 더 명확하게 파악 가능
    • 비동기적 로딩 방식
    • 정적 분석 가능

 

// a.mjs
console.log('a.mjs 시작');
export let done = false;
import { done as bDone } from './b.mjs';
console.log('b.done =', bDone);
done = true;
console.log('a.mjs 종료');

// b.mjs
console.log('b.mjs 시작');
export let done = false;
import { done as aDone } from './a.mjs';
console.log('a.done =', aDone);
done = true;
console.log('b.mjs 종료');

// main.mjs
console.log('main.mjs 시작');
import './a.mjs';
import './b.mjs';
console.log('main.mjs 종료');

ES Modules는 이러한 순환 의존성을 라이브 바인딩을 통해 처리하지만, 모듈 실행 순서가 복잡해질 수 있습니다.

 


실제 구현 사례와 코드 예시

프로젝트 구조 설정

최신 Node.js 프로젝트에서는 두 가지 모듈 시스템을 함께 사용하는 경우가 많습니다. 다음은 package.json 설정 예시입니다:

{
  "name": "modern-js-project",
  "version": "1.0.0",
  "type": "module", // ES Modules를 기본으로 사용
  "exports": {
    ".": {
      "import": "./dist/index.js", // ES Modules 형식
      "require": "./dist/index.cjs" // CommonJS 형식
    }
  },
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts"
  }
}

동적 임포트 활용하기

ES Modules에서는 동적 임포트를 사용하여 조건부로 모듈을 로드할 수 있습니다:

// modern.js
async function loadModule() {
  if (someCondition) {
    const { feature } = await import('./feature-a.js');
    return feature;
  } else {
    const { alternative } = await import('./feature-b.js');
    return alternative;
  }
}

// 사용 예시
loadModule().then(feature => {
  feature.doSomething();
});

 

하이브리드 패키지 만들기

라이브러리 개발자는 종종 CommonJS와 ES Modules를 모두 지원하는 패키지를 만들고 싶어합니다. TypeScript와 번들러를 사용한 예제입니다:

// src/index.ts
export const sum = (a: number, b: number): number => a + b;

export const multiply = (a: number, b: number): number => a * b;

export default {
  sum,
  multiply
};

tsup이나 rollup 같은 번들러로 두 형식으로 빌드:

npx tsup src/index.ts --format cjs,esm --dts

결과물:

  • dist/index.js (ESM)
  • dist/index.cjs (CommonJS)
  • dist/index.d.ts (타입 정의)

Node.js에서 ES Modules 사용하기

Node.js에서 ES Modules를 사용하는 방법은 여러 가지가 있습니다:

  1. 파일 확장자를 .mjs로 지정
  2. package.json"type": "module" 추가
  3. --input-type=module 플래그 사용
// utils.mjs
export const formatDate = (date) => {
  return new Intl.DateTimeFormat('ko-KR').format(date);
};

// main.mjs
import { formatDate } from './utils.mjs';

console.log(formatDate(new Date())); // 예: 2023. 9. 21.

호환성 문제와 트러블슈팅

일반적인 문제와 해결책

  1. __dirname 및 __filename 없음 (ES Modules)

ES Modules에서는 CommonJS의 __dirname__filename이 없습니다. 대신:

import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

console.log(__dirname); // 현재 디렉토리 경로
  1. require is not defined (브라우저 환경)

브라우저에서 CommonJS를 사용하려면 Webpack, Browserify 같은 번들러가 필요합니다.

 

  1. 동적 import() 문법

ES Modules에서 조건부 모듈 로딩:

// CommonJS 방식
if (condition) {
  const module = require('./module');
  module.doSomething();
}

// ES Modules 방식 (async 함수 내부에서)
if (condition) {
  const module = await import('./module.js');
  module.doSomething();
}
  1. 확장자 문제

ES Modules는 종종 확장자를 필요로 합니다:

// CommonJS - 확장자 생략 가능
const module = require('./module');

// ES Modules - 일반적으로 확장자 필요
import module from './module.js';
  1. JSON 모듈
// CommonJS
const data = require('./data.json');

// ES Modules (Node.js 17.5+)
import data from './data.json' assert { type: 'json' };

 

Node.js에서 CommonJS와 ES Modules 혼합 사용

Node.js 환경에서 두 모듈 시스템을 혼합해서 사용할 때 발생하는 문제:

// ESM에서 CommonJS 모듈 가져오기
import cjsModule from 'cjs-module'; // 기본적으로 작동

// CommonJS에서 ESM 모듈 가져오기
const esmModule = require('esm-module'); // 직접 불가능, 대안 필요

 

해결책: dynamic import 사용 (완전 하지 않음)

// commonjs-file.cjs
async function loadESM() {
  const esmModule = await import('esm-module');
  return esmModule;
}

loadESM().then(module => {
  // 사용 가능
});

//동적 임포트 사용: 완전히 에러 없이 사용 가능한 것은 아닙니다
//이 방법은 동작할 수 있지만, 비동기적으로 작동하므로 top-level에서 동기적인 require와 같은 방식으로 사용할 수 없습니다.

 


모범 사례

  1. 새 프로젝트에는 ES Modules 사용: 신규 프로젝트는 ES Modules를 기본으로 사용하는 것이 좋습니다.
  2. 확장자 명시: .mjs(ES Modules) 및 .cjs(CommonJS) 확장자를 사용하여 모듈 유형을 명확히 합니다.
  3. 패키지 메타데이터 활용:
    {
      // .js 파일들이 기본적으로 ESM으로 해석됩니다. 반대로 "type": "commonjs"로 설정하면 CommonJS로 해석됩니다.
      "type": "module", 
      
      // 이를 통해 패키지 진입점을 모듈 유형별로 다르게 설정하거나, 특정 하위경로만 외부에 노출할 수 있습니다.
      "exports": {
      // dual package hazard 해결: ESM과 CommonJS를 모두 지원하는 패키지를 만들 때 사용됩니다.
        "import": "./dist/index.js",
        "require": "./dist/index.cjs"
      }
    }
  4. 정적 임포트 사용: 가능하면 동적 import()보다 정적 import 구문을 선호합니다.
  5. 트리쉐이킹 최적화: ES Modules의 정적 분석 기능을 활용한 번들 최적화를 적용합니다.

안티패턴

  1. 모듈 시스템 혼합 사용: 한 프로젝트 내에서 모듈 시스템을 혼합하면 혼란스러울 수 있습니다.
  2. 확장자 생략에 의존: Node.js의 확장자 추론에 의존하면 호환성 문제가 발생할 수 있습니다.
  3. ESM과 CJS 간 순환 의존성: 두 모듈 시스템 간의 순환 의존성은 예측하기 어려운 동작을 유발할 수 있습니다.

 


결론

자바스크립트 모듈 시스템은 역사적으로 많은 발전을 거쳐왔으며, 현재는 CommonJS와 ES Modules라는 두 가지 주요 시스템이 공존하고 있습니다.

CommonJS는 Node.js에서 오랫동안 사용되어 온 표준이며, 직관적인 동기 로딩 방식과 런타임 평가 특성을 가지고 있습니다. 반면 ES Modules는 ECMAScript 표준의 일부로, 정적 분석이 가능하고 브라우저와 Node.js 환경에서 모두 사용할 수 있습니다.

두 시스템은 각각의 장단점이 있으며, 어떤 시스템을 선택할지는 프로젝트 요구사항, 대상 환경, 그리고 사용하는 라이브러리와 도구에 따라 달라집니다. 최신 프로젝트에서는 ES Modules를 우선적으로 고려하되, 필요에 따라 두 시스템의 호환성을 고려하는 것이 좋습니다.

모듈 시스템은 단순히 코드를 분리하는 메커니즘을 넘어, 자바스크립트 애플리케이션의 구조와 성능에 영향을 미치는 중요한 요소입니다. 각 모듈 시스템의 작동 방식과 차이점을 이해함으로써, 더 효율적이고 유지보수하기 쉬운 코드를 작성할 수 있습니다.

용어 설명

  • 트리쉐이킹(Tree Shaking): 사용되지 않는 코드를 제거하는 최적화 기술
  • 바인딩(Binding): 변수나 함수를 식별자에 연결하는 과정
  • 정적 분석(Static Analysis): 코드를 실행하지 않고 구조를 분석하는 과정
  • 동적 임포트(Dynamic Import): 런타임에 조건부로 모듈을 로드하는 기능
  • 번들러(Bundler): Webpack, Rollup 같이 여러 모듈을 하나로 묶는 도구
  • 네임스페이스(Namespace): 이름 충돌을 방지하기 위한 이름 공간
  • IIFE(Immediately Invoked Function Expression): 정의되자마자 즉시 실행되는 함수
  • AMD(Asynchronous Module Definition): 비동기 모듈 정의 방식
  • CJS: CommonJS의 약자
  • ESM: ECMAScript Modules의 약자
728x90
반응형