프론트와 백엔드 공부를 하면서 상당히 혼동을 줬던 개념들이 포함된 개념이다.
브라우저에서 다른 자바스크립트 파일에 선언한 변수들이 같은 공간에서 존재하던 문제도 겪었고 현재 파일에서 다른 파일을 불러올 때 exports/require, export/import 등을 사용해서 불러오는데 무엇을 어느 상황에서 사용해야 하는지, 그리고 왜 babel을 써야 export/import를 사용할 수 있는가..? 등등 여러 문제가 있었다.
그래서 이번 포스팅에서는 모듈이 무엇이고 어떻게 발전해왔으며 프론트와 백엔드에서 어떤 차이가 있는지를 모두 다뤄볼 예정이다.
모듈
모듈을 검색해보면 다음과 같다.
1. (명사) 모듈, 교과목 단위(특히 영국 대학에서 한 교육 과정의 일부가 되는 단위)
2. (명사) 컴퓨터 모듈(특정 기능을 하는 컴퓨터 시스템이나 프로그램의 단위)
3. (명사) 모듈, 조립 부품(기계·가구·건물 등을 구성하는 규격화된 부품)
각각 뜻은 다르지만, 공통적으로 어떤 기능적인 단위를 의미하는 것은 분명하다.
우리가 알아볼 모듈은, 어떤 기능들(그것에 대한 코드)이 모여있는 하나의 파일로 생각할 수 있다.
대개 하나의 모듈은, 여러 기능을 포함한 하나의 클래스이거나 특정 목적을 가진 라이브러리 하나로 구성된다.
사람을 구현한 Person 클래스가 있다면 모듈이 될 수 있고, 흔히 npm install로 받는 의존성들도 모듈에 해당한다.
그래서 결국 모듈은 하나의 파일를 의미한다.
왜 모듈이 중요한가
모듈에 대해 이해하지 못한 상태에서 브라우저에서 겪을 수 있는 문제를 통해 먼저 살펴보자.
// index.html
<!DOCTYPE html>
<html lang="en">
<body>
<h1>hello world!</h1>
</body>
<script src="./first.js"></script>
<script src="./second.js"></script>
</html>
// first.js
const name = 'Kim';
// second.js
const name = 'Lee';
console.log(name);
second.js 스크립트에서 이미 선언된 name 변수를 이용했다는 에러를 발생시키는데, 스크립트 태그로 분리된 것 처럼 보이는 first.js와 second.js가 사실 연관되어 있음을 알 수 있다.
현재 first.js와 second.js가 같은 공간을 공유하기 때문에, 같은 변수를 선언하면 변수 충돌이 일어난다.
first, second로 다른 파일을 만들었다는 것은, 개발자가 두 파일이 다른 공간을 가졌기를 의도했을 것이다.
이렇게 다른 파일이 같은 공간을 이용한다면, 사용한 모든 변수를 기억해야 하며 구현한 기능이 변수를 재사용함으로서 다르게 동작할 수도 있겠다.
심지어 라이브러리를 가져다 쓰는 상황에서는 라이브러리에 무슨 변수가 선언되어있는지를 열심히 걱정해야 할 듯 하다.
이제 이 예시를 통해 하나의 파일이 독립된 하나의 공간을 가져야 한다. 라는 필요성을 느끼게 되었다.
그래서 각 파일이 모듈이 되면 어떤 장점들이 있는가?
1.유지보수성 용이
- 앞서 하나의 모듈은 어떤 기능들을 모아뒀다고 했다. 그러므로 모듈화가 잘 되었다면 테스트, 통합, 수정 등의 유지보수에 용이하다.
2.네임스페이스화
- 네임스페이스란, 하나의 공간에 하나의 개체가 존재한다는 것이다.
- 즉, 자바스크립트 파일마다 각각 고유한 공간을 가질 수 있게 된다.
3.재사용 용이
- 하나의 모듈로 인해 반복을 줄이고 재사용성을 늘릴 수 있다.
- 예를 들면 타이어 모듈을 금호 타이어, 한국 타이어 등등의 모듈을 구현할 때 재사용 할 수 있다.
4.교체 용이
- 3번과 비슷한 개념이지만, 각 모듈은 독립적이므로 필요하다면 모듈을 다른 모듈로 교체할 수도 있다.
- 예를 들면 컴퓨터에 다양한 SSD, 그래픽카드들을 추가할 수 있다. 단, 이를 위해서는 미리 표준을 만들고 표준을 따르도록 제작되어야 한다.
장점을 요약하자면 모듈 내에서의 응집도는 증가하는 방향이며, 모듈 간 결합도는 낮아지는 방향이기 때문에 그에 따른 효과들을 기대할 수 있다.
자바스크립트에서 모듈
자바스크립트(브라우저, Node 환경을 모두 포함)에서 모듈이 가지는 특징을 정리하면 다음과 같다.
1.엄격모드(strict mode)로 실행된다.
엄격모드에 대해서는 MDN을 참고하면 되겠다. 엄격모드에서의 주요 특징으로는 자바스크립트의 느슨한 문법을 기반으로 실수하기 쉬운 사항들을 오류로 발생시켜주고, 기존의 최상위 this가 브라우저에서 window, Node 환경에서 global이 아닌 undefined 값을 가지도록 한다.
2.모듈 레벨 스코프를 가진다.
위에서 배운대로, 모듈은 자신의 스코프를 가진다. 그래서 모듈을 외부에 공개하지 않는다면 외부에서 모듈 내부로 접근할 수 없다. 모듈을 외부에 공개하기 위해서는 define / exports / export 키워드를 이용하며 해당 모듈을 가져오기 위해서는 require / import 키워드를 이용한다.
3.최초 호출시 단 한번만 평가된다.
a.js 모듈을 재사용하면서 b.js, c.js 등등 여러 모듈에서 불러올 수 있다. 모듈은 최초 호출시에만 실행되며 그 결과를 모듈을 이용하려는 모든 다른 모듈에 내보내준다.
// a.js
console.log('hello world!');
exports.person = {};
// b.js
const a = require('./a.js');
a.person.name = 'Kim';
// c.js
const { person } = require('./a.js');
console.log(person.name);
// index.js
const b = require('./b.js');
const c = require('./c.js');
index.js에서 b.js, c.js를 호출하고 b.js, c.js에서 a.js를 각각 호출하는 구조이다. index.js를 실행하면 b.js에서 person 객체에 이름 속성을 추가하며, c.js에서는 person 객체에 이름이 존재하는지 확인한다. 그리고 실행 결과로 'hello world!' 그리고 'Kim'이 출력되는데, a.js가 2번 호출되었지만 최초 호출시에만 평가되므로 'hello world!'는 한 번 출력되며, a.js의 평가 이후 내보내지는 person 객체는 어떤 모듈에서 호출되던지 간에 동일하므로 b.js에서 person 객체에 name 속성을 부여하고 c.js에서 person 객체의 name 속성을 이용할 수 있다.
브라우저 환경에서의 스크립트 모듈화와 특징
브라우저에서는 script 태그에 type='module' 속성을 추가해야 각 스크립트를 모듈로 만들 수 있다.
브라우저 환경에서는 index.html 등의 html 파일에 script 태그를 이용해 자바스크립트 코드를 실행한다. 이 때 type='module' 속성을 추가하지 않고 <script> 태그와 코드만을 이용하면 각 스크립트가 모듈화되지 않고 같은 스코프를 공유하게 된다. 그렇게 되면 위에서 다룬 변수 충돌 문제가 발생할 것이다.
브라우저 환경에서 스크립트를 type='module' 속성을 통해 모듈화를 이루고 그로 인해 추가되는 기능들을 알아보자.
(사실 먼저 AMD, CommonJS 와 같은 방법을 다뤄야 하는데, 어쩌다보니 이부분을 적게 되었다..)
1. 지연실행
모듈 스크립트는 지연 실행되는데, 마치 defer 속성처럼 동작한다. defer 속성에 대해서는 defer, async 포스팅에 정리해두었다. 그래서 defer의 특징에 따라 스크립트 다운 시 HTML 파싱을 멈추지 않으며 DOM이 만들어진 이후 DOMContentLoaded 이벤트가 발생하기 직전에 모듈 스크립트가 실행된다. 또한 모듈 스크립트 간의 순서를 유지하면서 실행을 보장할 수 있게 된다. DOMContentLoaded 이벤트 발생 전에 모듈 스크립트가 실행되므로, 항상 모듈 스크립트는 완전한 DOM(완전한 페이지)를 볼 수 있다. 만약 특정 태그에 이벤트리스너를 달아준다면, 항상 모든 태그가 생성되고 난 후 코드가 실행되므로 오류를 걱정하지 않아도 된다. 그러나 만약 특정 태그가 모듈 스크립트에 정의된 기능들을 기반으로 동작해야 한다면, (예를 들어 결제 버튼 태그라던가.) 모듈 스크립트가 완전히 불러와지기 전까지는 로더 등을 통해 사용자의 혼란을 예방해야 한다.
추가로 모듈 스크립트와는 달리 일반 스크립트는 바로 실행되므로 주의해야 한다.
<!DOCTYPE html>
<html lang="en">
<body>
<h1>hello world!</h1>
</body>
<script type="module">
console.log('hello, this is module script!');
</script>
<script>
console.log('hello world! this is normal script');
</script>
</html>
모듈 스크립트와 일반 스크립트 동작의 차이를 이해하자.
2. 인라인 스크립트의 비동기 처리가 가능하다.
스크립트의 비동기 처리를 위해서는 async 속성을 이용하는데, async 속성은 외부 스크립트에만 사용이 가능하다. 여기서 외부 스크립트는 src='a.js'와 같은 속성을 이용한 스크립트이며 인라인 스크립트는 HTML의 <script> 태그 안에 직접 코드를 작성한 스크립트를 의미한다. 모듈 스크립트에서는 인라인 스크립트에 async 속성을 이용하여 비동기 처리를 할 수 있다. asnyc 속성이 그러하듯이, 실행순서를 보장하지 않으므로 광고나 서드파티 스크립트 등으로 이용할 수 있다.
3. 외부 스크립트와의 관계
src 속성의 값이 동일한 외부 스크립트는 한 번만 실행됨을 보장하며, 완전히 다른 오리진(출처)로부터 모듈 스크립트를 불러올 때 CORS 헤더를 필요로 한다. CORS에 따르면 현재 브라우저에서 다른 오리진으로부터 데이터를 받아오려면 다른 오리진(다른 서버)이 Access-Control-Allow-Origin 헤더로 *값 혹은 허용할 도메인으로 현재 도메인을 명시해둬야 한다.
<!-- a.js는 한번만 실행된다. -->
<script type="module" src="a.js"></script>
<script type="module" src="a.js"></script>
<!-- another-site.com이 Access-Control-Allow-Origin을 통해 허용해야 한다.-->
<!-- 그렇지 않으면 스크립트는 실행되지 않는다.-->
<script type="module" src="http://another-site.com/their.js"></script>
4. 외부 모듈을 불러올 때 경로를 명시해야 한다.
경로로는 절대경로 혹은 상대경로를 사용할 수 있다.
import hello from './hello.js';
// 가능
import hello from 'hello.js';
// 불가능
5. nomodule 속성
구식 브라우저에서는 type='module'을 해석하지 못할 수 있는데, nomodule 속성으로 이 상황을 대비할 수 있다.
<script type="module">
alert("모던한 브라우저입니다.");
</script>
<script nomodule>
alert("type=module을 해석할 수 있는 브라우저는 nomodule 타입의 스크립트를 실행하지 않습니다.")
alert("구식 브라우저에서는 type=module이 붙은 스크립트가 무시되며 nomodule 속성을 가진 스크립트가 실행됩니다.");
</script>
여기까지 브라우저 환경에서 모듈 스크립트가 가지는 특징들을 정리해 보았다.
import/export를 사용하는 방식은 ES2015에서 도입된 모듈 시스템으로, 이를 다루기 전에 ES6 이전 모듈화를 위해 등장했던 AMD, CommonJS, UMD 방식에 대해 알아보겠다.
CommonJS
자바스크립트가 브라우저에 종속된 언어에서 node를 통해 서버에서도 사용 가능한 언어가 되면서, 노드에서 채택한 방식이다. 일반적으로 서버(node 환경)에서는 CommonJS를 사용하며 프론트(브라우저 환경)에서는 AMD와 더 관련있다고 생각하면 된다.
CommonJS에서는 모듈을 불러올 때 require, 모듈을 내보낼 때 module.exports, exports를 사용한다.
// hello.js
const helloWorld = 'hello World';
module.exports = { helloWorld };
// world.js
const hello = require('./hello.js');
console.log(hello.helloWorld);
내보낼 때 module 예약어를 이용하는데, 이는 현재 모듈(현재 파일)에 대한 정보를 가진 객체다.
해당 객체를 console.log(module)을 찍어보면
모듈 객체가 출력되는데, id, path, exports, filename 등등의 프로퍼티가 있으며 exports 프로퍼티에 주목해보자.
exports 프로퍼티의 값에 해당하는 객체 내에 helloWorld 변수가 프로퍼티로 선언되어 있다.
exports 와 module.exports
// hello.js
const helloWorld = 'hello World';
module.exports = { helloWorld }; // module.exports
exports.helloWorld = helloWorld; // exports
모듈을 내보내는 방법으로 exports, module.export를 사용할 수 있다.
module 객체에서 봤듯이, module 객체의 exports 프로퍼티는 값으로 빈 객체를 참조한다.
require 키워드를 통해 외부 모듈을 불러올 때는 항상 module.exports 객체를 리턴받는다.
exports 키워드는 module.exports를 참조한다.
exports 키워드가 module.exports를 참조하므로, exports와 . 을 사용하면 module.exports 객체의 프로퍼티를 생성할 수 있다.
require 키워드는 module.exports를 참조하므로 결국 exports.property로 내보낸 각각의 기능은 module.exports의 프로퍼티가 되어 require로 불러올 수 있게 되고, module.exports에 직접 객체를 할당하면(ex. module.exports={helloWorld}) 역시나 require는 module.exports를 참조하므로 동일하게 동작한다.
// hello.js
const helloWorld = 'hello World!';
exports.helloWorld = helloWorld;
// world.js
const { helloWorld } = require('./hello.js'); // 꼭 exports
const hello = require('./hello.js');
console.log(helloWorld); // hello World!
console.log(hello.helloWorld); // hello World!
종종 require에서 exports 키워드로 내보낸 대상에 대해서는 {}로 가져오고 module.exports로 내보낸 대상에 대해서는 변수 자체로 가져오곤 하는데, require가 module.exports를 참조하며 module.exports가 객체이며 exports는 객체 프로퍼티를 생성함을 이해하면 원하는대로 가져오기를 수행할 수 있다.
AMD (Asynchronous Module Definition)
직독직해하면 비동기적인 모듈 선언 이라는 의미이다. RequireJS 라는 스크립트가 AMD 스펙을 구현해두었으며, CommonJS가 노드에서 사랑받는 반면, AMD는 브라우저 환경에서 사랑받는다. 브라우저에서 스크립트를 비동기적으로 실행하기 위함이다. define, require 함수를 이용하며 예시를 통해 확인해보자.
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<script src="require.js"></script>
</body>
</html>
require.js를 다운받아서 스크립트에 넣어두고, 모듈 코드를 작성한다.
// exModule.js
define(['hello', 'world'], function(h,w) {
return {
a: h,
b: w,
printA : () => console.log('a'),
}
});
// example.js
require(['exModule'], function (exModule) {
console.log(exModule.a); // hello
console.log(exModule.b); // world
exModule.printA(); // a
});
다음과 같이 모듈 exModule을 만들고 define 함수를 통해 정의한다. require를 통해 첫번째 인자 'exModule' 모듈이 로드되었을 때 변수 exModule로 그것을 받고 define에 정의해 둔 모듈의 프로퍼티들을 이용할 수 있다.
UMD (Universal Module Definition)
모듈 시스템이 AMD, CommonJS로 나뉘면서 서로 호환이 되지 않았는데, 어떤 모듈을 쓰던지 동작하게 하기 위해 UMD가 등장했다.
AMD가 define을 사용하며 CommonJS가 module.exports를 사용하는 차이를 기반으로 UMD 모듈을 선언할 수 있다.
다음은 공식 UMD 소스코드이다.
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define([], factory);
} else if (typeof module === 'object' && module.exports) {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory();
} else {
// Browser globals (root is window)
root.returnExports = factory();
}
}(typeof self !== 'undefined' ? self : this, function () {
// Just return a value to define the module export.
// This example returns an object, but the module
// can return a function as the exported value.
return {};
}));
함수는 즉시실행함수로 구현되어 있다. 즉 선언과 동시에 실행되며 모듈이 AMD인지 CommonJS인지 혹은 브라우저 방식인지에 따라 달리 실행된다. 차근차근 살펴보면 if문의 처음에 해당하는 AMD인 경우, 두번째에 해당하는 CommonJS인 경우에는 factory 부분이 각자의 콜백 함수 또는 모듈 객체가 된다. factory 콜백으로는 객체를 생성하는 함수를 인수로 넣었으므로, 각각 define([], {}) / module.exports={} 를 수행한다.
반면 else에 해당하는 브라우저 방식의 경우엔 root에 해당하는 인수로 들어온 this가 window이기 때문에 root도 window가 되며 window.myModule에 factory의 실행결과 객체가 담기게 된다.
이렇게 UMD 소스코드에서는 각각의 환경에서 모두 모듈개념을 사용할 수 있도록 돕는다.
현재의 브라우저와 노드 환경, 자잘한 팁
현재는 브라우저에서는 ES2015(ES6)에서 등장한 ESModule을 기반으로 하며, 노드 환경에서는 CommonJS를 기반으로 한다.
ESModule에 대해서는 다음 포스팅에서 알아볼 것이고, 노드 환경에 대해서만 가볍게 다뤄보겠다.
node 환경에서 자바스크립트 코드를 실행하면 CommonJS가 기반이므로 module.exports를 사용할 수 있다. 그러나 export, import 또한 사용할 수 있다. package.json에서 "type" 속성을 "module"로 지정하면 된다. type 속성은 commonjs, module 2가지 옵션을 설정 할 수 있는데 기본값이 commonjs이며 module 옵션을 설정하면 node 환경에서 export, import 키워드를 이용할 수 있다. 단, module 옵션을 설정하면 더이상 module.exports, require 방식을 사용할 수 없다.
// package.json
{
"name": "node",
"version": "1.0.0",
"description": "",
"main": "b.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"type": "module"
}
혹은 babel과 같은 트랜스파일러를 통해 import 문을 commonJS 방식으로 트랜스파일링 해줄 수 있다. 이 때 바벨을 사용하기 위해선 바벨 환경을 설정해야 하며, 흔히 사용하는 babel/preset-env를 부여하거나 @babel/plugin-transform-modules-commonjs 옵션을 사용하고 node 명령어 대신 babel-node 명령어를 통해 자바스크립트 파일을 실행하면 되겠다.
지금까지 자바스크립트가 브라우저에 종속된 언어에서 서버에서 사용할 수 있는 언어가 되면서 주요한 이슈였던 모듈 시스템에 대해서 알아보았다. 다음 ES Module 포스팅에서는 ES Module이 무엇이며 노드환경에서 채택된 CommonJS와의 차이, import, export 사용법 등등에 대해 정리해보겠다.
참고한 사이트
https://github.com/baeharam/Must-Know-About-Frontend/blob/main/Notes/javascript/module.md
https://www.zerocho.com/category/JavaScript/post/5b67e7847bbbd3001b43fd73
https://ko.javascript.info/modules-intro
https://stackoverflow.com/questions/7137397/module-exports-vs-exports-in-node-js
'JavaScript > theory' 카테고리의 다른 글
[함수형] 커링 Currying을 배워보자. (0) | 2022.06.01 |
---|---|
[자바스크립트] 마이크로 태스크 큐의 비동기 작업 처리와 렌더링 시점을 알아보자. (1) | 2022.04.14 |
자바스크립트의 this는 어떻게 결정되는가? (0) | 2022.01.25 |
자바스크립트의 undefined, null에 대해 알아보자. (0) | 2022.01.19 |
자바스크립트 클래스란? (0) | 2022.01.18 |