모듈이란?
컴퓨터 과학에서 '모듈'이란 '독립된 하나의 소프트웨어 또는 하드웨어의 단위'를 의미한다(출처: 위키백과. 모듈은 다른 소프트웨어와 구분되는 독립적인 하나의 소프트웨어라는 뜻이다.
정의가 좀 어렵다. 자바스크립트 생태계의 모듈 관련 여러 자료들을 읽어보면서 나름대로 추상화해 본 정의는 '독립된 네임스페이스를 갖는 공간'이다. '독립된 네임스페이스'란 쉽게 얘기해서 한 모듈에서 사용한 식별자 이름을 다른 모듈에서 그대로 사용했을 때 문제가 되지 않게 구분되어 있다는 의미를 갖는다. 즉, 모듈은 '같은 식별자 이름을 쓰더라도 문제가 생기지 않는 독립된 공간'을 의미한다고 볼 수 있다(주관이 들어간 정의라 팩트 체크가 좀 더 필요하다).
왜 모듈이라는 게 필요할까?라고 누군가 묻는다면, 나는 '소프트웨어가 갈수록 크고 복잡해지기 때문이다'라고 답변할 것 같다. 최근 들어 소프트웨어의 기술 수준이 올라가고, 사용자의 숫자도 많아지고, 요구하는 사용성의 기준도 높아지면서, 소프트웨어는 계속해서 더 크고 복잡해지기 시작했다. 하나의 서비스가 동작하는 데 있어서 최소 수십 개에서, 규모에 따라 수백-수천 개의 파일이 필요해졌다.
물론 '파일이 많아진다'는 것은 기본적으로 '관심사의 분리'라는 맥락이 깔려있다고 생각한다. 하나의 파일에 모든 코드를 다 모아놓더라도 동작을 시키는 데에는 문제가 없을 테니까(아마 코드 라인 수가 수만 줄이 될지도 모른다). 하지만, 이런 식으론 프로젝트를 효율적으로 관리하고 유지보수하는 게 어렵기 때문에, 프로젝트가 커질수록 관심사에 따라 파일을 쪼개고, 각 파일의 목적을 분명히 하는 게 더 중요한 과제가 된다. 그리고, 그 과정에서 쪼개놓은 파일의 네임 스페이스가 겹치는 건 개발에 있어 큰 장애물이 된다.
모듈은 이런 상황에 좋은 대안이 된다. 자바스크립트의 모듈은 파일 단위로 네임 스페이스를 좁혀주기 때문에 규모가 큰 프로젝트를 관심사에 따라 파일을 분리하며 관리는 데 유용하다.
Node.js의 모듈 시스템: Common JS
Node.js는 2009년 처음 등장 시점부터 자체적인 모듈 시스템을 갖추고 있었다. 참고로, 자바스크립트 언어에서 모듈 시스템을 정식으로 지원하게 된 것은 2015년 발표된 ES6 버전 부터니까, Node.js의 모듈 시스템이 약 6년가량 빨랐던 셈이다.
자바스크립트는 언어적 태생이 웹 브라우저에서의 간단한 사용자 이벤트를 처리하기 위함이었기 때문에 굳이 파일을 분리해가면서 관리할 필요가 적었고, 모듈 시스템의 니즈가 크지 않았다. 하지만, Node.js의 출시는 자바스크립트 언어를 브라우저 이외의 환경에서 동작 가능하게 만들자는 것이고, (그 당시 기준) 복잡도가 훨씬 높은 개발 영역(서버, 데스크톱 앱 등)에도 자바스크립트가 활용될 수 있도록 하자는 취지였기 때문에, Node.js는 처음 등장부터 모듈 시스템의 니즈가 확실했다.
그런 맥락에서 처음 등장한 Node.js가 채택한 모듈 시스템이 바로 Common JS 모듈 시스템이다. Common JS가 적용되는 Node.js에서는 별도 처리를 하지 않더라도 하나의 자바스크립트 파일이 하나의 모듈이 된다. 따라서, Node.js 런타임으로 실행한 아래의 코드는 네임 스페이스가 고유하기 때문에 문제없이 동작한다.
/* example1.js 파일 */
const myVar = 1;
console.log(myVar);
/* example2.js 파일 */
const myVar = 2;
console.log(myVar);
node example1.js
와 node example2.js
로 각각 실행했을 때 변수명의 충돌 없이 잘 실행되는 게 확인된다.
만약에 하나의 모듈 파일에 있는 값을 외부에 공개하고 싶다면 module.exports
키워드를 사용할 수 있다. 아래와 같이 값을 공개하고, 값을 사용해야 하는 곳에서는 require
키워드로 값을 공개한 모듈의 경로를 넣어주면 값을 받아올 수 있다. module.exports
는 값을 할당한 모양을 보면 직관적으로 알겠지만, module.exports
는 객체 형태로 값을 외부에 공개한다.
/* example1.js */
const myVar = 'example1 파일에서 export 된 변수입니다'
module.exports = {
myVar // myVar: myVar
}
/* example2.js */
const myVar = 'example2 파일의 고유 변수입니다'
const example1 = require('./example1') // .js는 생략 가능
console.log(example1.myVar, myVar) // example1 파일에서 export 된 변수입니다 example2 파일의 고유 변수입니다
이렇게 하나의 자바스크립트 파일을 모듈로 간주되고, module.exports
와 require
로 필요한 값을 외부에 공개 및 참조해서 사용하면서 전체 모듈 시스템이 관리된다.
module.exports
와 exports
Node.js의 모듈 시스템은 자바스크립트에서 제공하는 표준 문법이 아니다. 즉, exports
객체를 값으로 갖는 module
객체도 Node.js 런타임의 내장 객체인 셈이고(브라우저에서 다양한 API나 내장 객체-메서드를 제공해주는 것과 비슷한 개념이라고 이해했다), 내장 객체에 값을 동작하면 런타임에 사전 구현되어 있는 동작을 통해 다른 모듈에서 값에 접근할 수 있도록 처리된다.
확인을 위해 아래 코드를 입력하고 node
로 실행해보면 module
객체의 실체를 알 수 있다.
{
id: '.',
path: '/Users/youngjaeko/Desktop/temp',
exports: {},
filename: '/Users/youngjaeko/Desktop/temp/example1.js',
loaded: false,
children: [],
paths: [
'/Users/youngjaeko/Desktop/temp/node_modules',
'/Users/youngjaeko/Desktop/node_modules',
'/Users/youngjaeko/node_modules',
'/Users/node_modules',
'/node_modules'
],
[Symbol(kIsMainSymbol)]: true,
[Symbol(kIsCachedByESMLoader)]: false,
[Symbol(kIsExecuting)]: true
}
이것이 Node.js에 적용된 Common JS 모듈 시스템의 실체이다. 각각에 대해선 아직 깊게 이해가 있진 않지만, 중요한 것은 module.exports
에 빈 객체가 정의되어 있다는 것이다.
브라우저 런타임을 생각해 보더라도, window
객체의 프로퍼티로 등록된 변수는 어떤 스코프에서도 접근이 가능하며, 심지어 다른 파일이더라도 전역이 공유된다면 전부 접근할 수 있다. module.exports
도 마찬가지로 모듈로 동작하는 자바스크립트 파일들이 (require
라는 키워드를 통해) 공통으로 접근할 수 있는 객체라고 이해하면 module.export
의 역할이 상대적으로 명료해진다.
참고로, Node.js는 module.exports
와 구분되게 exports
라는 별도의 객체를 갖는다. exports
는 module.exports
와는 다른 식별자이지만 하나의 객체를 참조한다. Pseudo-code 형태로 둘의 관계를 표현해 보면 아래와 같은 관계이다.
const module = {
exports: {}
};
const exports = module.exports;
console.log(module.exports === exports) // true
exports
와 module.exports
는 같은 참조를 갖기 때문에, exports
와 module.exports
의 참조를 바꾸지 않는 형태로 각각 값을 할당하면 둘은 같은 값을 유지하게 된다.
exports.temp1 = 1;
module.exports.temp2 = 2;
console.log(exports, module.exports, exports === module.exports)
// { temp1: 1, temp2: 2 } { temp1: 1, temp2: 2 } true
하지만, 아래와 같이 module.exports
와 exports
둘 중 하나의 참조가 바뀌도록 값이 할당될 경우 둘의 연결은 끊어지며, 이 경우 module.exports
에 정의된 값이 외부에 공개된다.
// 같은 참조를 유지하는 경우
exports.temp1 = 1;
module.exports.temp2 = 2;
// 참조가 달라지는 경우
exports = { temp3: 3 } // 새로운 객체로 참조가 바뀌기 때문에 `module.exports`와의 연결이 끊어진다
따라서, 별도의 의도가 있는 게 아니라면 exports
를 쓸 때 module.exports
와의 연결이 끊어지지 않도록 관리되는 게 좋으며, 가급적 객체를 직접 할당하기 보다는 점 표기법으로 프로퍼티를 추가하는 방식으로 사용하는 게 적절하다.
간단하게 찾아보니, 보통 exports
는 아래와 같이 ES Module 시스템의 Named export를 구현할 때 많이 쓰는 것 같고,
/* ES Module */
// 하나의 파일에서도 여러 식별자를 export 할 수 있다
export const myVar1 = 1;
export const myVar2 = 2;
/* Common JS */
// 마찬가지로, exports로 개별 식별자를 정의해 외부로 공개할 수 있다.
exports.myVar1 = 1;
exports.myVar2 = 2;
module.exports
는 ES Module 시스템의 Default export를 구현할 때 많이 사용되는 것 같다.
/* ES Module */
// 식별자 이름 없이도 값을 바로 default export 할 수 있다
export default 1
/* Common JS */
// module.exports는 객체지만 객체가 아닌 값을 가질 수도 있으며, 이 경우 require의 결과로 숫자 1이 반환된다.
module.exports = 1;
물론 module.exports.myVar
처럼 사용해서 개별 값을 외부 공개할 수도 있지만, 편의성과 코드 가독성을 생각했을 때 가급적 Named export를 구현할 땐 exports
객체를 사용하는 것 같다.
Node.js에서 ES Module 사용하기
Node.js는 기본적으로 2009년 첫 등장부터 고유한 Common JS라는 모듈 시스템을 갖추고 있었다. 그러다가 2015년 ES6 발표 이후 자바스크립트 언어의 표준 문법으로 모듈 시스템이 들어왔고(흔히 ES Module이라고 부른다), 자연스럽게 브라우저 환경에서의 자바스크립트 구현에선 ES Module, Node.js 환경에서는 Common JS 모듈 시스템이 사용되는 게 일반적으로 굳어졌던 것 같다.
하지만, Node.js 런타임은 자바스크립트로 이루어져 있으며, 자바스크립트의 최신 문법을 함께 지원하기 때문에, ES Module 시스템을 사용하는 게 가능하다. 아직까진 좀 더 오랜 시간 적용되어 왔으며, 구현된 레거시도 많은 Common JS가 조금 더 일반적인 느낌이 있지만, 점점 ES Module을 사용하는 쪽으로 트렌드가 서서히 이동하는 트렌드이기 때문에, 함께 알아두는 게 좋다.
Node.js에서 ES Module 시스템을 사용하기 위해선 모듈로 사용하고 싶은 파일의 확장자를 .mjs
(타입스크립트는 .mts
)로 정의하거나, 또는 프로젝트 루트 디렉토리에 있는 package.json
파일에 "type": "module"
을 정의해주면 된다(마치 브라우저 런타임에서 ES Module 시스템을 사용할 때 script
태그에 type="module"
을 정의하는 것과 유사하다).
Common JS와 ES Module 방식에는 몇 가지 차이점이 있다. 먼저, Common JS에서는 require
로 경로를 입력할 때 파일 확장자를 생략할 수 있었지만, ES Module 방식에선 import
를 위한 경로 지정 시 파일 확장자를 전부 입력해줘야 한다. 그리고, ES Module에선 Common JS 문법인 require
를 사용할 수 없기 때문에, 혹시라도 ES Module 방식으로 설정된 프로젝트에서 Common JS 모듈의 값을 가져와야 한다면 createRequire
를 통해 require
함수를 생성해서 사용해줘야 한다.
import { createRequire } from 'module'; // module에서 createRequire 함수 불러오기
const require = createRequire(import.meta.url); // createRequire 함수로 require 함수 정의하기
const fs = require('fs');
console.log(fs.readFileSync('./example.txt', 'utf8'));
참고로 createRequire
의 인자로 전달된 import.meta.url
은 ES Modules(ESM)에서 현재 모듈의 URL(파일 경로) 정보를 제공하는 특수 속성이며, 즉 createRequire
의 인자로는 현재 모듈의 경로가 전달되어야 함을 의미한다.
또한, ES Module의 파일 로드는 비동기로 동작되며, 파일 로드가 끝나지 않을 경우 다른 코드를 미리 실행하고 있다가 로드가 끝나면 해당 값을 참조할 수 있게 된다는 특징이 있다. 반면, Common JS에서는 파일 로드가 동기적으로 수행된다.
이외에도 ES Module은 Top-level await 사용이 가능하지만, Common JS는 이를 지원하지 않는다는 특징도 기억하자(ES Module 시스템에선 async
를 붙여준 함수 내부가 아닌, 전역에서도 await
실행이 가능하다).
정말 간략하게만 언급했지만, 깊게 들어가면 ES Module과 Common JS에는 여러 차이가 있으며 필요에 맞게 모듈 시스템을 적절히 사용해야 할 의무가 개발자에게 주어진다. 기회가 되면 더 깊게 파보면서 공부해 봐야겠다.
결론
Node와 Express를 공부하려고 마음먹고 이런저런 자료를 찾아보니, 커리큘럼 상 가장 처음 소개되는 내용이 대부분 모듈 시스템이었다. 그래서, 처음 시작하는 도전자의 마음으로 다시 이것저것 자료도 찾아보고 공부한 내용을 정리해 봤다.
React를 지금 수준까지 익히는 데에도 많은 시간과 노력이 들었는데, 새로운 기술을 익히려고 다시금 출발하려니 사실 좀 막막하다. 그래도 차근차근 잘 배우면서 한 발씩 잘 나아가봐야겠다. 끝.