모듈
은 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); // '철수' 출력
뿐만 아니라 이전 일반 자바스크립트에서는 this
가 window
객체를 참조하지만, 모듈
은 엄격 모드(use strict)
로 실행되기 때문에 모듈 최상위 레벨의 this
는 undefined
이다.
const foo = '철수';
console.log(this); // undefined 출력
기존 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'
]
}
여기서 exports
는 module
객체의 프로퍼티인 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
함수는 네 가지 방식으로 전달되는 인자를 이용해서 모듈을 탐색한다.
인자로 전달한 모듈
경로가 정확하지 않아 찾지 못했다면, .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
방식은 세 가지로 구분되어 실행된다.
우선, 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.json
의 main
필드를 "./test/bar.js"
합한, '../untitled3/test/bar.js'
경로로 모듈을 탐색해 로드한다.
다음으로 package.json
의 main
필드의 경로를 추가해도 찾지 못했다면, 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
에러를 던진다.
require()
함수 인자가 '/'
, '../'
또는 './'
로 시작하지 않으면, 모듈
을 발견할 때까지 현재 모듈의 상위 디렉토리
에서 파일 시스템의 루트 디렉토리
까지 /node_modules
를 추가하여 탐색한다.
즉, require('foo')
함수를 호출하면 다음과 같이 /node_modules
를 추가하여 루트 디렉토리
까지 탐색한다.
위 세가지 방법으로 발견하지 못한다면, NODE_PATH 환경변수
또는 GLOBAL_FOLDERS
목록을 검색한다.
NODE_PATH 환경변수
OS 환경마다 다르고, GLOBAL_FOLDERS
목록은 기본적으로 아래와 같다.
여기서 $HOME은 사용자의 홈 디렉토리를 의미하고, $PREFIX는 Node.js가 구성한 node_prefix 경로이다.
ES6 이전에는 브라우저 환경에서 사용 가능한 표준 모듈 시스템이 없었다.
이후 Javascript는 모듈 시스템에 대한 필요성을 느끼고, ES6부터 ESM
라는 Javascript 자체 모듈 시스템을 지원하기 시작하였다.
ESM
은 자신만의 독립적인 실행 영역인 독자적인 스코프를 가진다.
뿐만 아니라 기본적으로 내부 코드를 비공개하여 캡슐화하고, 엄격 모드(use strict)에서 실행한다. 따라서, 변수, 함수, 클래스를 노출하기 위해서는 export
이용해 정의해야만 한다.
이제 ESM
으로 모듈
을 import
하고 export
하는 방법을 하나씩 살펴보자.
export
사용 방법은 크게 named export
와 default export
로 나뉜다.
또한 export ~ from
를 이용하면, 여러 모듈
을 패키징하여 외부에 필요한 API만 선별적으로 노출할 수 있다.
named export
는 선언한 변수, 함수, 클래스 각각에 함께 선언할 수 있고, 한꺼번에 선언하는 방법도 있다.
또한, named export
은 as
로 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
은 모듈
에서 외부로 노출하는 개체중에 기본값을 지정하는 기능으로, import
과정에서 모듈의 Destructuring
({ }
)없이 기본값을 로드할 수 있다. 또한, default export
은 import
과정에서 이름 제약을 없애 버린다.
주의해야 할 점은 반드시 모듈 내부에서 한 번만 선언해야 한다는 것이다.
const foo = '철수';
export default foo;
// or
export default function bar() {
return '미애';
}
// or
const foo = '철수';
function bar() {
return '미애';
}
export {
foo as default, bar
}
export ~ from
은 여러 모듈
을 패키징하여 외부에 필요한 API만 선별적으로 노출해야 하는 경우에 매우 유용하다.
어떠한 기능을 제공하는 여러 모듈을 패키징해서 외부에 공개해야 하는 상황을 생각해보자.
우선, package.json
의 main
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
는 외부 모듈을 불러오기 위해 사용되는데, export
에 따라 형식이 조금씩 다르다.
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
은 Destructuring
없이 바로 기본값을 의미하는 개체를 바로 불러올 수 있고, 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());
모듈
을 사용하는 스크립트는 반드시 <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>
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와 ESM의 문법적인 차이점은 물론이고 로드되는 시점도 다르다.
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은 코드를 실행하기 전에 구문 분석해서 사용할 모듈을 미리 불러온다.
// 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"}