Module

raccoonback·2020년 6월 29일
2

javascript

목록 보기
10/11
post-thumbnail

모듈은 JavaScript 코드를 패키징하는 형식으로, 파일을 경계로 내부 구현을 숨길 수 있고 재사용성을 높여준다.

왜 모듈이 필요한가?

앞서 모듈은 JavaScript 코드를 패키징한다고 했는데, 무슨 의미일까?

하나씩 천천히 살펴보자.

파일에 자바스크립트 코드를 작성했다고 해서 그 파일이 모듈이 되는 것은 아니다. 즉, 파일에 담긴 자바스크립트 코드가 모듈로 동작할 수 있게끔 정의하여 사용해야만 한다.

모듈이 아닌 일반 자바스크립트를 이용하는 경우에 어떠한 문제가 발생하는지 알아보자.

// test.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
</head>
<body>
    <div>Hello World</div>
    <script src="foo.js"></script>
    <script src="bar.js"></script>
</body>
</html>
// foo.js
const foo = '철수';
// bar.js
const bar = '미애';
console.log(foo); // '철수' 출력

분명히 bar.js 파일에는 foo 변수가 존재하지 않는다. 하지만, test.html에서 두 자바스크립트 파일을 로드하였기 때문에, foo.js, bar.js 파일에 선언된 foo, bar는 전역 변수로 사용되게 된다.

따라서, 분리된 두 파일 일지라도 변수/함수가 모두 전역에 선언되어 잘못된 참조를 하는 것뿐만 아니라 기존 값을 덮어씌우게 되는 불안정한 코드를 만들게 된다.

실제로 이러한 문제는 ES6 이전에 대두됐고, 이러한 문제를 해결하고자 모듈 이라는 패턴이 등장하게 되었다.

모듈 핵심 기능

ES6+를 지원하는 브라우저에서는 type="module" 속성을 가진 <script> 태그를 이용해 모듈을 로드하고 실행할 수 있다.

그럼 일반 자바스크립와 다르게 모듈의 핵심 기능은 무엇일까?

이제 하나씩 알아보자.

모듈 레벨 스코프

모듈은 자신만의 스코프를 가지게 되는데, 이를 통해 내부 구현을 캡슐화하여 숨길 수 있어 다른 모듈에 대한 접근을 제한한다.

또한, 일반 자바스크립트에서 발생했던 중복된 변수명, 덮어쓰기 같은 문제를 해결할 수 있다.

// test.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
</head>
<body>
    <div>Hello World</div>
    <script type="module" src="foo.js"></script>
</body>
</html>
// foo.js
import * as bar from './bar.js';

const foo = '철수';
console.log(foo, bar.foo);
// bar.js
export const foo = 'foo 덮어쓰기';

Chrome 개발자 도구를 통해 확인해보면, foo, bar 모두 자신만의 Module 스코프를 가진다는 것을 알 수 있다.

오직 한 번의 평가

위에서도 보았듯이 모듈은 자신만의 스코프를 갖는데, 동일한 모듈을 여러 곳에서 사용하면 어떻게 될까?

모듈은 최초 호출 시에만 평가가 이루어지고 외부에서 평가된 동일한 모듈을 참조하게 된다.

// test.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
</head>
<body>
    <div>Hello World</div>
    <script type="module" src="bar.js"></script>
    <script type="module" src="baz.js"></script>
</body>
</html>
// foo.js
console.log("foo 평가!");
export const foo = {
    name: 'foo'
};
// bar.js
import {foo} from './foo.js';

console.log("bar 평가!");

console.log("bar 에서의 변경전 foo:", foo);

foo.name = "bar";

console.log("bar 에서의 변경후 foo:", foo);
// baz.js
import {foo} from './foo.js';

console.log("baz 평가!");

console.log("baz 에서의 변경전 foo:", foo);

foo.name = "baz";

console.log("baz 에서의 변경후 foo:", foo);

Chrome 개발자 도구를 통해 확인해보면, 처음 실행 시 bar.js 가 참조하고 있는 foo.js 모듈의 평가가 먼저 이루어지는 것을 확인할 수 있다.

또한, 평가가 이루진 foo.js는 다음의 baz.js 평가 과정에 다시 평가하지 않는다.

뿐만 아니라, bar.js, baz.js평가된 동일한 foo.js를 참조하고 있기 때문에 foo 객체 name 프로퍼티 값 변경시 다른 모듈에도 영향을 미치게 된다.

엄격 모드로 실행

모듈은 항상 엄격 모드(use strict)로 실행된다.

따라서, 아래 예제와 같이 선언되지 않은 변수를 참조할 수 없게 된다.

// test.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
</head>
<body>
    <div>Hello World</div>
    <script src="foo.js"></script>
    <script src="bar.js"></script>
</body>
</html>
// foo.js
const foo = '철수';
// bar.js
const bar = '미애';
console.log(foo); // '철수' 출력

뿐만 아니라 이전 일반 자바스크립트에서는 thiswindow 객체를 참조하지만, 모듈엄격 모드(use strict)로 실행되기 때문에 모듈 최상위 레벨의 thisundefined이다.

const foo = '철수';
console.log(this); // undefined 출력

CommonJS

기존 JavaScript는 브라우저에서만 사용 가능한 언어였지만, 점차 Javascript를 일반적인 범용 언어로 사용하기 위한 움직임이 시작되었다.

CommonJS는 JavaScript를 브라우저에서뿐만 아니라 서버 애플리케이션 개발에서도 사용하기 위해 자바스크립트의 표준적인 명세를 만들고자 조직한 그룹이다.

CommonJS는 브라우저에서 동작하던 Javascript가 범용적인 언어로서의 체계를 갖추기 위해서는 모듈화 시스템이 필요하였다. 표준화된 모듈화 방법이 없었기 때문에, 라이브러리 간에 호환을 맞추기 힘들었고 npm 과 같은 패키지 관리자를 개발할 수가 없었다.

따라서, CommonJS는 표준화된 모듈화 방법을 제시하였는데, 대표적으로 Node.js가 이를 채택하여 명세를 따르고 있다. 기본적으로 Node.js는 ECMAScript Modules 모듈화 방식도 실험적으로 지원하는 데, CommonJS는 .js 확장자를 사용하고 ESM은 .mjs를 사용한다.

CommonJS에서 말하는 모듈을 구성하기 위해서는 세 가지가 필요하다.

아래 예제를 통해서 알아보자.

// foo.js
console.log("foo 평가!");
exports.foo = {
    name: 'foo'
};
// bar.js
const {foo} = require('./foo.js');

console.log("bar 평가!");

const common = 'bar';
console.log("bar common:", common);

console.log("bar 에서의 변경전 foo:", foo);

foo.name = "bar";

console.log("bar 에서의 변경후 foo:", foo);
// baz.js
const {foo} = require('./foo.js');

console.log("baz 평가!");

const common = 'baz';
console.log("baz common:", common);

console.log("baz 에서의 변경전 foo:", foo);

foo.name = "baz";

console.log("baz 에서의 변경후 foo:", foo);
// run.js
require('./bar.js');
require('./baz.js');

// foo 평가!
// bar 평가!
// bar common: bar
// bar 에서의 변경전 foo: { name: 'foo' }
// bar 에서의 변경후 foo: { name: 'bar' }
// baz 평가!
// baz common: baz
// baz 에서의 변경전 foo: { name: 'bar' }
// baz 에서의 변경후 foo: { name: 'baz' }

위 예제는 Node.js에서 CommonJS 방식을 이용한 방법이다.

모듈은 자신만의 독립적인 실행 영역, 즉 독자적인 스코프를 가진다.

Module마다 독자적인 스코프를 가진 덕분에, bar.js, baz.js에서 각자 선언한 동일한 이름의 common 변수가 자신에 스코프에서 충돌없이 잘 동작하는 것을 확인할 수 있다.

모듈 정의

모듈 정의는 exports/module.exports 객체를 이용한다.

exports, module.exports 이용한 모듈 정의 방법은 아래와 같다.

exports.foo = {
    name: 'foo'
};

// or

exports.foo = foo;

// or

module.exports = { foo };

// or

module.exports.foo = foo;

모듈Module라는 객체를 이용해 id, path, filename, exports 등의 모듈 정보를 가진다. 즉, Module 객체는 exports를 이용해 모듈을 정의하며, 외부 모듈은 해당 모듈에서 제공하는 기능을 사용할 수 있다.

앞선 예제의 foo.js 모듈의 module 객체를 살펴보면 아래와 같다.

Module {
  id: '/Users/koseungbin/WebstormProjects/test/foo.js',
  path: '/Users/koseungbin/WebstormProjects/test',
  exports: { foo: { name: 'foo' } },
  parent: Module {
    id: '/Users/koseungbin/WebstormProjects/test/bar.js',
    path: '/Users/koseungbin/WebstormProjects/test',
    exports: {},
    parent: Module {
      id: '.',
      path: '/Users/koseungbin/WebstormProjects/test',
      exports: {},
      parent: null,
      filename: '/Users/koseungbin/WebstormProjects/test/run.js',
      loaded: false,
      children: [Array],
      paths: [Array]
    },
    filename: '/Users/koseungbin/WebstormProjects/test/bar.js',
    loaded: false,
    children: [ [Circular] ],
    paths: [
      '/Users/koseungbin/WebstormProjects/test/node_modules',
      '/Users/koseungbin/WebstormProjects/node_modules',
      '/Users/koseungbin/node_modules',
      '/Users/node_modules',
      '/node_modules'
    ]
  },
  filename: '/Users/koseungbin/WebstormProjects/test/foo.js',
  loaded: false,
  children: [],
  paths: [
    '/Users/koseungbin/WebstormProjects/test/node_modules',
    '/Users/koseungbin/WebstormProjects/node_modules',
    '/Users/koseungbin/node_modules',
    '/Users/node_modules',
    '/node_modules'
  ]
}

여기서 exportsmodule 객체의 프로퍼티인 module.exports와 동일한 객체를 참조한다.

module.exports === exports // true

또한, module.exports = { foo } 같은 정의는 주의해서 사용해야 한다.

모듈 평가 시점에 Module 객체의 exports 프로퍼티는 빈 객체를 할당받는다. 그런데 모듈 마지막 쯤에 module.exports = { foo }와 같이 정의하면 새로운 객체가 할당되기 때문에, 이전에 정의했던 것들이 모두 사라지는 현상이 발생한다.

exports.foo = {
    name: 'foo'
};

module.exports = { };
console.log(module.exports); // { }

모듈 사용

모듈 사용은 require 함수를 이용한다.

require 함수는 다른 모듈을 로드하기 위해 사용되는데 사용 방법은 아래와 같다.

const foo = require('./foo.js');

CommonJS 명세를 기반으로 개발된 NodeJS에서의 require 함수는 네 가지 방식으로 전달되는 인자를 이용해서 모듈을 탐색한다.

File Modules

인자로 전달한 모듈 경로가 정확하지 않아 찾지 못했다면, .js, .json 또는 .node 확장자를 추가한 파일명을 가지고 모듈을 탐색한다.

// foo.js
exports.foo = {
    name: 'foo'
};
// bar.js
const foo = require('./foo');

console.log(foo);
// { foo: { name: 'foo' } }

'./foo' 경로에 대한 모듈을 찾지 못해, .js 확장자를 추가한 './foo.js' 파일명으로 모듈을 탐색한다.

Folders as Modules

Folders as Modules 방식은 세 가지로 구분되어 실행된다.

우선, require() 함수 인자로 전달한 폴더 경로에 package.json에서 해당 패키지의 진입점을 의미하는 main 필드를 추가해 탐색을 진행한다.

// package.json
{
  "name": "untitled3",
  "main": "./test/bar.js"
   // ...
}
// test/bar.js
exports.bar = {
    name: 'test/bar'
};
// foo.js
const bar = require('../untitled3');

console.log(bar);
// { bar: { name: 'test/bar' } }

require('../untitled3') 에서의 './' 경로와 package.jsonmain 필드를 "./test/bar.js" 합한, '../untitled3/test/bar.js' 경로로 모듈을 탐색해 로드한다.

다음으로 package.jsonmain 필드의 경로를 추가해도 찾지 못했다면, require() 함수 인자로 전달한 폴더 경로에서 index.js, index.node 파일을 탐색해 로드한다.

// test/index.js
exports.index = {
    name: 'test/index'
};
// foo.js
const index = require('./test');
console.log(index);
// { index: { name: 'test/index' } }

'./test' 경로에 있는 디렉토리에서 index.js 파일을 발견하여 로드한다.

마지막으로 발견하지 못한다면, Cannot find module 에러를 던진다.

Loading from node_modules Folders

require() 함수 인자가 '/', '../' 또는 './'로 시작하지 않으면, 모듈을 발견할 때까지 현재 모듈의 상위 디렉토리에서 파일 시스템의 루트 디렉토리까지 /node_modules를 추가하여 탐색한다.

즉, require('foo') 함수를 호출하면 다음과 같이 /node_modules를 추가하여 루트 디렉토리까지 탐색한다.

  • /Users/koseungbin/WebstormProjects/untitled/node_modules/foo.js
  • /Users/koseungbin/WebstormProjects/node_modules/foo.js
  • /Users/koseungbin/node_modules/foo.js
  • /Users/node_modules/foo.js
  • /node_modules/foo.js

Loading from the global folders

위 세가지 방법으로 발견하지 못한다면, NODE_PATH 환경변수 또는 GLOBAL_FOLDERS 목록을 검색한다.

NODE_PATH 환경변수 OS 환경마다 다르고, GLOBAL_FOLDERS 목록은 기본적으로 아래와 같다.

  1. $HOME/.node_modules
  2. $HOME/ .node_libraries
  3. $PREFIX/lib/node

여기서 $HOME은 사용자의 홈 디렉토리를 의미하고, $PREFIX는 Node.js가 구성한 node_prefix 경로이다.

ECMAScript Modules

ES6 이전에는 브라우저 환경에서 사용 가능한 표준 모듈 시스템이 없었다.

이후 Javascript는 모듈 시스템에 대한 필요성을 느끼고, ES6부터 ESM라는 Javascript 자체 모듈 시스템을 지원하기 시작하였다.

ESM은 자신만의 독립적인 실행 영역인 독자적인 스코프를 가진다.

뿐만 아니라 기본적으로 내부 코드를 비공개하여 캡슐화하고, 엄격 모드(use strict)에서 실행한다. 따라서, 변수, 함수, 클래스를 노출하기 위해서는 export 이용해 정의해야만 한다.

이제 ESM으로 모듈import하고 export하는 방법을 하나씩 살펴보자.

export

export 사용 방법은 크게 named exportdefault export로 나뉜다.

또한 export ~ from를 이용하면, 여러 모듈을 패키징하여 외부에 필요한 API만 선별적으로 노출할 수 있다.

named export

named export는 선언한 변수, 함수, 클래스 각각에 함께 선언할 수 있고, 한꺼번에 선언하는 방법도 있다.

또한, named exportas로 alias하지 않은 경우에 선언된 변수명 그대로 외부에 export된다.

// 선언과 동시에 노출
export const foo = '철수';

// 선언과 동시에 노출
export function bar() {
    return '미애';
}
const foo = '철수';

function bar() {
    return '미애';
}

// 한꺼번에 노출
export {
    foo, bar
}

한 번에 노출하는 경우, 외부에 노출할 변수, 함수의 별칭을 as 키워드로 지정할 수도 있다.

// 외부에서는 'foos', 'bars'로 참조한다.
export {
    foo as foos, bar as bars
}

default export

default export모듈에서 외부로 노출하는 개체중에 기본값을 지정하는 기능으로, import 과정에서 모듈의 Destructuring({ })없이 기본값을 로드할 수 있다. 또한, default exportimport 과정에서 이름 제약을 없애 버린다.

주의해야 할 점은 반드시 모듈 내부에서 한 번만 선언해야 한다는 것이다.

const foo = '철수';
export default foo;

// or 

export default function bar() {
    return '미애';
}

// or

const foo = '철수';

function bar() {
    return '미애';
}

export {
    foo as default, bar
}

export ~ from ~

export ~ from은 여러 모듈을 패키징하여 외부에 필요한 API만 선별적으로 노출해야 하는 경우에 매우 유용하다.

어떠한 기능을 제공하는 여러 모듈을 패키징해서 외부에 공개해야 하는 상황을 생각해보자.

우선, package.jsonmain field를 통해 외부에서 접근 가능한 진입점을 index.js 파일명으로 설정했다고 가정해보자.

외부에 모든 모듈을 노출하는 것보다, 필수적인 요소만 공개 API로 노출하는 것이 내부 구현 변경에 대한 유연성을 제공함으로써 궁극적으로 캡슐화가 가능해진다. 즉, 패키지 진입점인 index.js에 필수적인 요소만 공개 API로 노출하면 될 것이다.

따라서, index.js 모듈은 외부에 제공할 API를 수집하는 과정에서 단순히 import하여 바로 export하는 기능이 필요한데, 이는 export ~ from 구문을 통해 가능하다.

물론 아래와 같이 import 후에 별도로 export 할 수 있다.

import {foo, bar} from './foo.js';
export {foo, bar};

하지만, 동일한 기능을 한 줄로 실행할 수 있다.

export {foo, bar} from './foo.js';

또한, default export 대해 export ~ from 사용하는 경우 조심해야 한다.

default export를 동시에 Destructuring 구문에서 사용하기 위해서는 기본값을 의미하는 개체에 default as를 추가해야만 한다.

// foo.js
const foo = '철수';

function bar() {
    return '미애';
}

export {
    foo as default, bar
}
// bar.js
export {default as foo, bar} from './foo.js';

또한, 모든 개체를 내보는 경우 *는 기본값을 무시하기 때문에, 별도의 export {default} from '경로'를 추가로 해주어야 한다.

import

import는 외부 모듈을 불러오기 위해 사용되는데, export에 따라 형식이 조금씩 다르다.

named export load

named export를 불러오는 경우에는 아래와 같이 Destructuring을 이용해서 불러올 수 있고, 전체를 모두 불러오기 위해서 *를 이용할 수도 있다.(default export한 기본 개체는 무시)

// foo.js
const foo = '철수';

function bar() {
    return '미애';
}

export {
    foo, bar
}
// bar.js

// Destructuring 이용
import {foo, bar} from './foo.js';

// or

// 모든 개체 로드
import * as Foo from './foo.js';
console.log(Foo.foo, Foo.bar());
// 철수 미애

뿐만 아니라, export와 같이 불러온 모듈의 개체를 alias할 수도 있다.

import {foo as foos, bar as bars} from './foo.js';
console.log(foos, bars());
// 철수 미애

default export load

default exportDestructuring없이 바로 기본값을 의미하는 개체를 바로 불러올 수 있고, Destructuring에 포함해서 불러올 수도 있다.

// foo.js
const foo = '철수';

function bar() {
    return '미애';
}

export {
    foo as default, bar
}
// bar.js

// Destructuring 없이 로드
import foo, {bar} from './foo.js';
console.log(foo, bar());

// or

// Destructuring 포함해 로드
import {default as foo, bar} from './foo.js';
console.log(foo, bar());

// or

// 모든 개체를 로드하는 경우
import * as Foo from './foo.js';
console.log(Foo.default, Foo.bar());

// or

// 기본 개체 로드 
// '*' 이용한 모든 개체 로드(default export한 기본 개체는 무시);
import foo, * as Bar from './foo.js';
console.log(foo, Bar.bar());

브라우저에서 ESM 사용

모듈을 사용하는 스크립트는 반드시 <script>type="module" 속성을 추가해야 한다.

다른 모듈이 동일한 모듈을 여러번 참조한다 해도, 모든 모듈은 반드시 한 번만 평가된다.

<script type="module" src="foo.js"></script>

다른 도메인에서 불러오는 경우

다른 도메인으로 부터 모듈을 가져오는 경우, CORS 에러가 발생할 것이다.

만약 모듈을 제공하는 서버는 응답 헤더에 Access-Control-Allow-Origin: * 포함한다고 가정해보면, <script> 태그에 crossorigin="use-credentials" 속성 추가해서 해결할 수 있다.

구체적으로, cross-origin 요청에도 자격 증명을 쿠키에 실어 보낼 수 있기 때문에 다른 도메인으로 부터 모듈을 로드할 수 있다.

<script type="module" src="https://something/foo.js" crossorigin="use-credentials"></script> 

nomodule

type="module" 속성을 해석하지 못하는 구식의 브라우저는 해당 <script> 태그를 실행하지 않고 무시한다.

nomodule 속성을 사용하면, type="module" 속성을 가진 <script> 태그가 해석되지 않았을 경우에 nomodule 속성을 가진 <script> 태그가 실행된다.

결과적으로 구식 브라우저와 실행할 스크립트를 정하여 호환성을 유지할 수 있다.

<script type="module" src="foo-for-the-latest-version.js"></script>

// 위 스크립트가 실행되지 않았을 경우에 호출
<script nomodule src="foo-for-old-versions.js"></script>

지연 실행

<script> 태그에서 defer 속성은 DOM Tree가 완전하게 구성되어 API를 통해 DOM 조작을 할 수 있는 시점까지 스크립트 실행을 지연시킨다.

<script> 태그의 type="module" 속성은 디폴트로 defer 속성을 사용한다.

// first.js
console.log("first");

// second.js
console.log("second");

// third.js
console.log("third");
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
</head>
<body>
    <script type="module">
        console.log('second');
    </script>

    <script defer src="third.js"></script>

    <script src="first.js"></script>

    <script type="module" src="fourth.js"></script>
</body>
</html>

// first
// second
// third
// fourth

type="module", defer 속성이 있는 <script> 태그는 DOM Tree 완성될 때까지 모듈 실행이 지연된다.

따라서 가장 먼저 first.js 모듈이 실행되고, 이후에는 순차적으로 inline script, third.js, fourth.js 모듈이 실행된다.

CommonJS VS ESM

CommonJS와 ESM의 문법적인 차이점은 물론이고 로드되는 시점도 다르다.

CommonJS 모듈 로드 시점

CommonJS는 ESM과 다르게 실행 시점에 필요한 필요한 종속 모듈을 로드한다.

// foo.js
console.log("foo 평가!");

exports.foo = {
    name: 'foo'
};
// bar.js
console.log("bar 평가!");

// 실행하다가 필요한 시점에 모듈 로드한다.
const foo = require('./foo'); 

console.log(foo);

// bar 평가!
// foo 평가!
// { foo: { name: 'foo' } }

ESM 모듈 로드 시점

ESM은 코드를 실행하기 전에 구문 분석해서 사용할 모듈을 미리 불러온다.

// foo.js
console.log("foo 평가!");

export const foo = {
    name: 'foo'
};
// bar.js
console.log("bar 평가!");

// 사용할 모듈을 실행 전에 미리 로드한다.
import {foo} from './foo.js';

console.log(foo);

// foo 평가!
// bar 평가!
// {name: "foo"}

참고 자료

profile
한번도 실수하지 않은 사람은, 한번도 새로운 것을 시도하지 않은 사람이다.

0개의 댓글