8 minute read

1. 글을 쓰게 된 계기

React 코드를 작성할 때는 Hooks와 컴포넌트를 가져오기 위해 importexport를 사용합니다. 하지만 React 프로젝트의 webpack 설정에서는 requiremodule.exports를 사용하여 다른 모듈을 불러옵니다. 두 방법은 각각 ESModules와 CommonJS 라고 불리는데, 두 방법이 어떤 차이점이 있는지? 또 각각을 어떠한 경우에 사용하는지? 궁금하여 글을 쓰게 되었습니다.

2. 모듈 시스템이 있기 전에는…

자바스크립트의 한계점

초기 자바스크립트에서는 모듈이라는 개념이 없었다고 합니다. 여러 개의 자바스크립트 파일로 나누어서 로드하여도 마치 하나의 파일 안에 있는 것처럼 여겨집니다. 아래 코드를 실행하면 다른 파일임에도 변수 x가 중복되어 에러가 발생합니다.

<!DOCTYPE html>
<script src="src1.js"></script>
<script src="src2.js"></script>
// src1.js
const x = 1;

// src2.js
const x = 2; // Uncaught SyntaxError: Identifier 'x' has already been declared

이런 현상이 발생하는 이유는, 여러 파일로 분리하여도 모든 식별자는 전역 레벨 스코프에 선언되는 자바스크립트의 기본 특성 때문입니다. 그렇기 때문에 여러 파일로 분리하여 코드를 작성하는 경우에

  • 같은 식별자 이름을 사용하지 못합니다.
  • 어떤 파일을 먼저 로드할지의 순서가 중요해집니다.

이와 같은 이유로 개발과 유지보수가 힘들어집니다. 즉시 실행 함수 표현식(IIFE)을 이용하여 식별자의 스코프를 제한하는 방법이 있지만, 역시 번거로울 수 있는 방법입니다.

서버사이드 자바스크립트에 대한 논의

브라우저에서 페이지 전체를 다시 요청하지 않고 일부 데이터만 요청할 수 있는 기술인 Ajax가 부상하면서, 자바스크립트가 점점 더 많이 쓰이게 되었습니다. 그러면서 브라우저 밖에서도 충분히 쓸 수 있을 정도로 브라우저 자바스크립트 엔진(V8)도 성능이 좋아졌습니다. 그럼에 따라 자바스크립트를 일반적인 범용 언어로 사용할 수 있도록 만들자는 논의가 나오기 시작합니다. 이 중심에 있던 프로젝트가 CommonJS 입니다.

CommonJS는 웹 브라우저 밖의 자바스크립트를 위한 모듈 생태계의 규칙을 설립하기 위한 프로젝트이다. (wikipedia)

이 프로젝트에서의 주요 논점 중의 하나는 바로 모듈화였습니다. 브라우저 언어였던 자바스크립트를 브라우저 밖에서 사용하는 데에는 다음과 같은 문제점이 있었습니다.

  • 서로 호환되는 표준 라이브러리가 없었습니다.
  • 데이터베이스에 연결할 수 있는 표준 인터페이스가 없었습니다.
  • 다른 모듈을 삽입하는 표준적인 방법이 없었습니다.
  • 코드를 패키징해서 배포하고 설치하는 방법이 필요했습니다.
  • 의존성 문제까지 해결하는 공통 패키지 모듈 저장소가 필요했습니다.

위와 같은 문제점들은 결국 모듈화의 문제와 관련이 있었습니다. 그래서 CommonJS 프로젝트에서는 서버사이드에서 모듈을 어떻게 정의하고 사용할 것인가를 정하였고, 그것이 모듈 시스템입니다. 이 글에서는 CommonJS 프로젝트의 모듈 시스템만 다룰 것이므로, CommonJS 모듈 시스템을 CommonJS로 줄여서 부르겠습니다.

3. CommonJS

CommonJS에서 자바스크립트의 스코프 문제를 해결하기 위해 만들어진 모듈은 다음과 같습니다.

  1. 스코프 : 모든 모듈은 자신만의 독립적인 실행 영역이 있어야 한다.
  2. 정의 : 모듈의 정의는 exports 객체를 이용한다.
  3. 사용 : 모듈 사용은 require 함수를 이용한다.

식별자가 모듈 레벨 스코프(각 모듈의 파일로 스코프가 제한)로 선언되어, 모듈 간의 격리된 환경을 제공합니다. 또한 CommonJS의 모듈 명세는 모든 파일이 로컬 디스크에 있어 필요할 때 바로 불러올 수 있는 상황을 전제로 합니다. 다시 말해 CommonJS는 서버사이드 자바스크립트 환경을 전제로 합니다.

사용 방법

아래 코드는 main.js 모듈에서 exports1.jsexports2.js 모듈을 가져와서 사용하는 예제입니다.

// exports1.js
const user = "foo";
module.exports = user;

// exports2.js
const user = "bar";
module.exports = user;

// main.js
const ex1 = require("./exports1.js");
const ex2 = require("./exports2.js");

console.log(ex1, ex2); // foo bar

node.js 환경에서 main.js 파일을 실행할 경우, 브라우저에서처럼 식별자 중복 오류가 발생하지 않고 각 파일로 스코프가 제한된 것을 확인할 수 있습니다. 서버사이드에서 모듈 시스템을 활용하면 전역 스코프 오염을 막아주어서 편하게 파일을 분리하여 사용할 수 있습니다.

module 객체

module 객체는 현재 모듈에 대한 정보를 가지고 있는 객체입니다.

// main.js
console.log(module);

output :
{
  id: '.',
  path: '~',
  exports: {},
  filename: '~/main.js',
  loaded: false,
  children: [],
  paths: [Array]
}
  • path, paths, filename : 파일 시스템에서 현재 모듈 파일의 위치를 나타냅니다.
  • exports : 현재 모듈 밖으로 내보내기 위한 프로퍼티입니다. 모듈 레벨 스코프를 가지고 있기 때문에 외부에서 접근할 수 없기 때문입니다. 프로퍼티의 값으로 객체, 함수, 원시값 모두 가능합니다. 예시에서는 module.exports에 할당한 것이 없기 때문에 빈 객체로 표시됩니다.
  • loaded : 현재 모듈을 다른 모듈로 내보냈는지 여부를 나타냅니다. 예시에서는 main.js 모듈을 가져오는 모듈이 존재하지 않아 false 입니다.
  • children : 현재 모듈에서 다른 모듈을 가져온 경우, 이 프로퍼티에 가져온 모듈의 module 객체가 저장됩니다. 다른 모듈과의 의존성 관계를 알 수 있습니다.

require 객체

require 객체는 모듈 시스템을 관리하는 객체입니다. require 객체를 출력해보면 어떤 방식으로 CommonJS가 동작하는지 알 수 있습니다.

// exports1.js
const user = "foo";
module.exports = user;

// exports2.js
const user = "bar";
module.exports = user;

// main.js
const ex1 = require("./exports1.js");
const ex2 = require("./exports2.js");
console.log(require);
output :
[Function: require] {
  resolve: [Function: resolve] { paths: [Function: paths] },
  main: {
    id: '.',
    path: '~',
    exports: {},
    filename: '~/main.js',
    loaded: false,
    children: [ [Object], [Object] ],
    paths: [Array]
  },
  extensions: [Object: null prototype] {
    '.js': [Function (anonymous)],
    '.json': [Function (anonymous)],
    '.node': [Function (anonymous)]
  },
  cache: [Object: null prototype] {
    '~/main.js': {
      id: '.',
      path: '~',
      exports: {},
      filename: '~/main.js',
      loaded: false,
      children: [Array],
      paths: [Array]
    },
    '~/exports1.js': {
      id: '~/exports1.js',
      path: '~',
      exports: 'foo',
      filename: '~/exports1.js',
      loaded: true,
      children: [],
      paths: [Array]
    },
    '~/exports2.js': {
      id: '~/exports2.js',
      path: '~',
      exports: 'bar',
      filename: '~/exports2.js',
      loaded: true,
      children: [],
      paths: [Array]
    }
  }
}
  • main : 최상위 모듈을 뜻합니다. 다른 모듈을 처음으로 가져오는 시작 지점(엔트리 포인트)이자 모듈(의존성) 트리의 루트 노드입니다. children 프로퍼티에 하위 모듈의 참조를 저장하고, 이에 접근할 수 있습니다.
  • cache : 각 모듈의 정보를 저장하고 있는 프로퍼티입니다. 각 모듈이 require 함수를 통해 처음으로 가져와질 때, module 객체가 만들어지고 이 프로퍼티에 저장됩니다. 모듈이 다시 가져와질 때, 또 다른 모듈 객체가 생성되는 것이 아니라 이미 만들어진 모듈 객체의 참조를 반환합니다(싱글톤 패턴).

위의 예제에서는 main.js 모듈에서 exports1.js, exports2.js 모듈을 가져오고 있습니다. main 프로퍼티는 main.js 모듈의 module 객체를 가리킵니다. children 프로퍼티에는 가져온 두 모듈의 module 객체의 참조가 담겨있습니다. 모든 module 객체는 cache 프로퍼티에서 확인할 수 있는데, exports 프로퍼티에서 각각의 모듈이 내보내는 값을 확인할 수 있습니다.

require 함수

require 함수로 특정 모듈을 가져오는 경우, 해당 파일을 실행하면서 해당 모듈의 module 객체가 생성됩니다. 또한 생성된 모듈의 module.exports를 반환합니다.

// exports1.js
console.log("exports1.js started");
const user = "foo";
module.exports = user;
console.log("exports1.js ended");

// main.js
const ex1 = require("./exports1.js");
console.log(ex1);
console.log(module);
output :
exports1.js started
exports1.js ended
foo
{
  id: '.',
  path: '~',
  exports: {},
  filename: '~/main.js',
  loaded: false,
  children: [
    {
      id: '~/exports1.js',
      path: '~',
      exports: 'foo',
      filename: '~/exports1.js',
      loaded: true,
      children: [],
      paths: [Array]
    }
  ], // 가져온 모듈의 module 객체 정보를 가지고 있음
  paths: [Array]
}

require("./exports1.js")로 모듈을 가져올 때 exports1.js 파일을 실행하는 것을 출력문을 통해 알 수 있습니다. 그리고 exports1.js 모듈의 module 객체가 해당 모듈을 가져온 main.js 모듈 객체에 저장됩니다.

cache 프로퍼티에서 말한 것처럼, 특정 모듈을 첫 번째로 가져올 때만 코드가 평가됩니다. 다시 말해서, 모듈이 첫 번째 호출될 때 module 객체가 생성되어 require.cache에 저장되고 모듈이 재호출되는 경우 이미 생성된 객체로 처리합니다.

// exports1.js
console.log("exports1.js started");
const user = "foo";
const ex2 = require("./exports2.js");
module.exports = user;
console.log("exports1.js ended");

// exports2.js
console.log("exports2.js started");
const user = "bar";
module.exports = user;
console.log("exports2.js ended");

// main.js
const ex1 = require("./exports1.js");
const ex2 = require("./exports2.js");
console.log(ex1, ex2);

// exports1.js started
// exports2.js started
// exports2.js ended
// exports1.js ended
// foo bar

exports2.js 모듈은 exports1.js 모듈 안에서 첫 번째 불리고, main.js 모듈에서 두 번째 불립니다. 두 번째 불리는 경우에는 코드가 평가되지 않고, 캐시에 있는 값으로 처리합니다.

동기적 실행

모듈을 가져오는 과정에서 동기적으로 동작합니다.

// exports1.js
console.log("exports1 called");

// main.js
const ex1 = require("./exports1.js");
console.log("main called");

// exports1 called
// main called

main.js 모듈에서는 exports1.js 모듈이 다 처리될 때까지 뒤의 자바스크립트 코드를 실행하지 않고 기다립니다. 서버사이드에서는 크게 문제가 되지 않을 수 있지만, 브라우저에서는 웹 서버로부터 각 모듈을 동기적으로 가져오는 것은 사용자 경험을 악화시킬 수 있습니다. 앞의 모듈 요청이 지연되는 경우, 뒤의 요청은 무한히 기다리게 될 것입니다.

CommonJS 프로젝트 내에서 동기적인 처리 문제를 해결하자는 목소리가 있었지만 합의하지 못하고 나와서 새로운 프로젝트를 만든 그룹이 AMD(Asynchronous Module Definition) 입니다. 이름에서 알 수 있듯이 비동기적인 모듈을 지향했습니다.

4. CommonJS를 브라우저에서 사용하기

CommonJS는 서버사이드 자바스크립트 환경에서는 잘 동작하지만 브라우저(클라이언트사이드) 에서는 동작하지 않습니다. 아래 예시처럼 require을 찾을 수 없다고 나옵니다.

// exports1.js
const user = "foo";
module.exports = user;

// exports2.js
const user = "bar";
module.exports = user;

// main.js
const ex1 = require("./exports1.js");
const ex2 = require("./exports2.js");

<!DOCTYPE html>
<script src="main.js"></script>

Screenshot 2023-10-28 at 9 56 03 PM

브라우저에서 CommonJS를 직접적으로 사용할 수 없기 때문에, 모듈 로더모듈 번들러를 이용해 브라우저에서 실행할 수 있는 코드로 변환하는 과정이 필요합니다.

모듈 로더

모듈 로더를 이용하여 서버사이드에서 작성된 여러 모듈을 런타임에 웹 서버로부터 각각 받아와서 브라우저에서 실행하는 방법입니다.

  1. 브라우저에서 웹 서버로부터 모듈 로더 파일을 다운로드합니다.
  2. 모듈 로더는 메인 모듈을 웹 서버로부터 다운로드하고 실행합니다.
  3. 다른 모듈을 가져와야 할 경우, 모듈 로더는 웹 서버로부터 동적으로 모듈을 다운로드합니다.

대표적인 모듈 로더로는 RequireJS가 있습니다. RequireJS를 이용하여 위에서 했던 것처럼 main.js 모듈에서 exports1.jsexports2.js 모듈을 불러오는 코드를 작성하면 아래와 같습니다.

<!DOCTYPE html>
<script data-main="scripts/main" src="scripts/require.js"></script>
// exports1.js
define([], function () {
  return {
    someFunction: function () {
      return "Function from exports1.js";
    },
  };
});

// exports2.js
define([], function () {
  return {
    anotherFunction: function () {
      return "Function from exports2.js";
    },
  };
});


<div class="code-header code-border">
  <button class="copy-code-button" title="Copy code to clipboard">
    <img class="copy-code-image" src="/assets/images/copy.png" />
  </button>
</div>



// main.js
require(["exports1", "exports2"], function (ex1, ex2) {
  // exports1.js 모듈 사용
  console.log(ex1.someFunction());
  // exports2.js 모듈 사용
  console.log(ex2.anotherFunction());
});

Screenshot 2023-10-27 at 12 06 39 PM

Screenshot 2023-10-27 at 12 07 00 PM

Screenshot 2023-10-27 at 12 07 35 PM

모듈 로더는 main.js 에서 각각 exports1.js, exports2.js 를 동적으로 웹 서버에서 요청하는 방식으로 모듈을 가져옵니다. Network 탭에서 여러 요청을 통해 각각의 자바스크립트 파일(모듈)을 로드하고, Element 탭에서 script 태그에 각각 적용한 것을 볼 수 있습니다.

여전히 브라우저에서는 모듈화를 지원하지 않았기 때문에 모듈 간 충돌이 일어날 수 있습니다. 모듈 로더 내부적으로 즉시 실행 함수 표현식(IIFE)와 같은 방법으로 모듈 레벨 스코프를 생성하여 모듈 간 충돌을 막고, 전역 스코프 오염을 방지하였습니다.

script 태그에 async 속성이 붙어있는데, RequireJS는 CommonJS 모듈이 아니라 AMD 모듈 로더이기 때문입니다. 위에서 소개한 AMD 프로젝트의 AMD 모듈은 비동기적으로 웹 서버로부터 모듈을 불러옵니다. 반면에 CommonJS 모듈 로더는 모듈을 동기적으로 불러올 것입니다. 이 예제에서는 확인할 수 없지만 exports1.js 모듈을 로드하는 작업이 막히면 exports2.js 모듈을 로드하는 작업은 처리되지 못할 것입니다.

모듈 번들러

모듈 로더와는 다르게 서버사이드에서 작성된 여러 모듈을 빌드타임에 빌드하여 정적 번들 파일로 만들고 이를 브라우저에서 실행하는 방법입니다.

브라우저에서는 빌드된 번들 파일 이외에는 로드하지 않습니다. 빌드타임에 모듈 간의 의존 관계를 파악하고 require 함수를 지우고 객체로 변환하는 과정을 거치기 때문에 브라우저에서 실행할 수 있습니다. 일반적으로 하나의 번들 파일로 만드는 과정에서 서로 다른 식별자로 바뀌기 때문에 스코프 오염이 발생하지 않습니다. 대표적으로 Browserify, webpack 가 있습니다.

5. 정리

  • 모듈 시스템이 나오기 전에 브라우저에서는 자바스크립트 파일을 나눠서 사용하는 것에 많은 불편함이 있었습니다. 즉, 자바스크립트는 모듈화를 지원하지 않았습니다.
  • CommonJS 프로젝트에서는 자바스크립트를 브라우저 밖에서 범용적으로 쓰기를 원했습니다. 가장 큰 걸림돌은 모듈화의 부재였습니다.
  • 서버사이드에서 먼저 require, module 객체를 이용한 CommonJS 모듈 시스템을 개발하였습니다.
  • 브라우저(클라이언트사이드)에서는 CommonJS를 직접적으로 사용할 수 없었기 때문에, 모듈 로더나 모듈 번들러를 이용하여 변환된 코드로 브라우저에서 사용할 수 있었습니다.
  • ES6 이후 자바스크립트 언어 자체에서 표준 모듈 시스템(ESModules)을 지원함에 따라, CommonJS를 변환할 필요없이 브라우저 네이티브로 모듈 시스템을 사용할 수 있습니다.(다음 편에서)

6. Reference

Leave a comment