2022. 4. 20. 22:34ㆍ책/Node.js 디자인 패턴 바이블
4. CommonJS 모듈
Node.js의 첫 번째 내장 모듈 시스템으로, CommonJS 명세의 주요 개념 두 가지는 아래와 같다.
- require : 로컬 파일 시스템으로부터 모듈을 import 한다
- exports, module.exports : 현재 모듈에서 공개될 기능들을 내보낸다
물론 내용은 훨씬 더 많지만 현재까지는 이 정보만으로 충분하다.
1) 직접 만드는 모듈 로더
Node.js에서 CommonJS 작동 원리를 이해하기 위해 비슷한 시스템을 만들어보자. require( ) 함수의 원래 기능 중 일부를 모방해서 만들어볼텐데, 물론 100% 동일한 것은 아니다.
// Node.js에서 fs는 파일시스템에 접근해 I/O 작업을 처리하는 모듈이다
// fs.readFileSync( path, options )
// 경로상의 파일을 동기적으로 읽어들인다
function loadModule(filename, module, require) {
const wrapperSrc =
`(function (module, exports, require) {
${fs.readFileSync(filename, 'utf8')}
})(module, module.exports, require)`
eval(wrapperSrc);
// eval : 문자로 표현 된 코드를 평가해 값을 반환한다
// 사실 eval함수는 사용이 금지돼있으나 교육용으로 쓴 예제일 뿐임
}
// 캐시 : 컴퓨터 과학에서 데이터나 값을 미리 복사해 놓는 임시 장소를 가리킨다.
function require(moduleName) {
console.log(`호출된 모듈 : ${moduleName}`);
// 모듈의 전체 경로를 알아낸다(resolve)
// 물론 이 작업은 require.resolve()로 정의된 함수에 위임된다.
const id = require.resolve(moduleName);
if (require.cache[id]) {
return require.cache[id].exports
} // 모듈이 이미 로드된 경우 캐시된 모듈을 사용한다.
// 모듈 메타데이터
// 모듈이 아직 로드되지 않았다면 최초 로드를 위한 환경을 설정한다
// (빈 객체 리터럴로 exports 프로퍼티를 가지는 module 객체 생성)
// 여기서 만든 모듈 객체는 이후 불러올 모듈 내부의 public API를 export하는데 사용된다
const module = {
exports: {},
id
};
// 캐시 업데이트
// 최초 로드 후 module 객체가 캐시된다
require.cache[id] = module;
// 모듈 로드(위에서 정의돼있다)
// 방금 만든 module 객체와 require()함수를 인수로 전달한다
// 모듈 소스코드를 읽어서 eval함수로 평가 후 리턴한다
// 모듈은 module.exports 객체를 조작/대체하여 public API를 내보낸다
loadModule(id, module, require);
// export되는 변수 반환
// 즉, 모듈의 public API를 나타내는 module.exports를 반환
return module.exports;
}
require.cache = {};
require.resolve = (moduleName) => {
// 모듈 전체 경로를 찾아낸다(resolve)
}
2) 모듈 정의
위에서 require함수를 정의했는데, 핵심은 module.exports를 리턴하면서 모듈내 리소스가 공개된다는 것이다. 즉, 우리가 모듈을 만들때 공개하고 싶은 코드가 있다면 module.exports 변수에 담아야 한다는 것이다.
3) module.exports vs exports
exports는 module.exports를 참조하는 변수이다. 단순한 코드로 표현하자면 아래와 같은 형태이다.
const module = { exports: {} };
const exports = module.exports;
function require(path) {
... return module.exports;
}
이 상황에서 exports = 'hello' 라고 재할당한다면, exports 변수 자체의 값을 바꾸는 것 뿐, 모듈의 공개와는 전혀 관계없는 코드가 되버린다. 따라서 exports.hello = console.log('ㅎㅇ') 처럼 exports가 참조하는 객체에 새로운 프로퍼티를 추가해야 한다.
4) require 함수는 동기적이다
위에서도 설명했지만 require( )함수는 동기적으로 동작한다. 따라서 모듈 정의시 동기적으로 코드를 작성해야 한다. 즉, 아래와 같은 용법은 틀린 것.
setTimeout( () => { module.exports = function() { ... } }, 1000 );
사실 Node.js 초창기엔 비동기 버전의 require( )를 사용했으나 과도한 복잡성으로 인해 얼마안가 제거됐다. 예를 들어, 만약 모듈을 비동기적으로 초기화해야되는 과정이 있다면, 이 경우 require를 사용해 모듈을 로드한다고 한들 실제로 사용할 준비가 된다는 보장이 없게된다. 이 외에도 장점대비 복잡성이 너무 커지게 된 것이다.
5) resolving 알고리즘
위에서 정의한 require.resolve( ) 함수는 모듈이름을 받아서 모듈의 전체 경로를 반환한다고 했다, 그런데 모듈이 한두개도 아닐텐데 어떻게 고유하게 식별할 수 있을까?
이에 대한 답으로 Node.js는 모듈이 로드되는 위치에 따라 다른 버전의 모듈을 로드할 수 있도록 한다.
이 특성이 패키지 매니저(npm, yarn)가 애플리케이션 종속성을 구성하는 방식과 require.resolve( ) 함수에 적용되는데, 이 알고리즘의 개요는 아래와 같다.
- 파일 모듈 : moduleName이 '/'로 시작하면 모듈에 대한 절대 경로로 간주되어 그대로 반환. './'로 시작하면 상대 경로로 간주.
- 코어 모듈 : moduleName이 '/' 혹은 './'로 시작하지 않으면 코어 Node.js 모듈 내에서 검색을 시도
- 패키지 모듈 : moduleName과 일치하는 코어 모듈이 없으면 디렉토리 구조를 탐색해 올라가면서 node_modules 디렉토리를 찾는다. 그 후 일치하는 모듈을 계속 검색. 알고리즘은 파일 시스템의 루트에 도달할 때까지 디렉토리 트리를 올라가면서 일치하는 모듈을 탐색.
이 알고리즘이 실제로 어떻게 적용되는지 보자. 우리가 항상 보게되는 node_modules 디렉토리는 실제로 패키지 매니저가 각 패키지의 종속성을 설치하는 곳이다. 그리고 아래와 같은 디렉토리 구조를 가지고 있다고 하자. 현재 myApp, depB, depC 모두 개별적인 버전의 depA에 종속성을 가지고 있다. 이러한 상황을 종속성 지옥이라고 하는데, Node.js는 이를 해결 알고리즘을 통해 우아하게 풀어낼 수 있는 것이다.
※ 종속성 지옥(dependency hell) : 프로그램의 종속성이 서로 공통된 라이브러리에 의존하지만 상호 호환되지않는 서로 다른 버전을 필요로 하는 상황.
6) 모듈 캐시
위에서 만든 require함수를 다시한번 살펴보자. 중간에 if (require.cache[id]) { return require.cache[id].exports } 문이 들어가 있는데, 즉 모듈이 이미 로드된 경우 캐시된 모듈을 사용한다는 뜻이다. 만약 A라는 모듈을 불러낸 상태에서 A를 다시 불러낸다면 캐시를 뒤져서 이미 로드된 버전을 퉤 내뱉는다.
이건 분명 성능을 위해 중요한 과정이다. 그러나 이 때문에 모듈 종속성 내에서 순환을 가질 수 있게 된다(즉 순환 참조가 발생할 수 있다는 뜻) 순환참조가 발생하면 어떻게 되길레 굳이 언급하고 있는걸까? 바로 밑에서 살펴보자
7) 순환 참조
a모듈이 b모듈을 참조하고, b모듈이 a모듈을 참조하는 상황을 순환 참조라고 한다. 코드를 잘못 짠 탓이 크다고 생각하겠지만, 실제로 프로젝트를 진행하다보면 쉽게 발생할 수 있는 일이다. 그렇다면 CommonJS에선 순환참조를 어떻게 처리할까?
// a.js
exports.loaded = false; // module.exports = { loaded: false }와 같다
const b = require('./b');
module.exports = {
b,
loaded: true // 이전 export문을 오버라이드
};
// b.js
exports.loaded = false;
const a = require('./a');
module.exports = {
a,
loaded: true
};
위와 같은 상황이 있다고 하자. 만약 main.js 파일에서 a모듈과 b모듈을 순차적으로 로드하면 어떤 결과가 나올까?
// main.js
const a = require('./a'); // a를 먼저 불러보자(내부적으로 b를 불러오겠군)
const b = require('./b'); // b를 그 다음 불러보면?
console.log('a ->', JSON.stringify(a, null, 2));
console.log('b ->', JSON.stringify(b, null, 2));
a -> {
"b": { "a": { "loaded": false }, "loaded": true },
"loaded": true
}
b -> {
"a": { "loaded": false },
"loaded": true
}
// 오잉? b모듈이 a모듈을 참조하고 있잖아?
// a모듈의 평가결과가 위에 버젓이 있는데
// b모듈이 불러온 a모듈이랑 생김새가 안맞는데??
// (a모듈을 불완전한 상태로 참조했는데??)
이게 어떻게 된 일일까? 자세한 과정은 아래 그림과 같다. 천천히 살펴보면 누구를 먼저 로드하느냐에 따라서 어떤게 불완전한 상태로 참조되는지 달라지게 된다는 것을 알 수 있다. 이처럼 CommonJS에선 모듈이 로드되는 순서가 매우 중요하며, 프로젝트 규모가 큰 상황이면 생각보다 쉽게 발생하는 상황이기 때문에 매우 헷갈리는 상황이 발생하고 만다.
이처럼 CommonJS를 사용중이라면 모듈을 로드함에 있어 애플리케이션에 어떤 영향을 미칠지 신중히 생각해봐야 한다.
'책 > Node.js 디자인 패턴 바이블' 카테고리의 다른 글
[2장] 모듈 시스템(4) (0) | 2022.04.23 |
---|---|
[2장] 모듈 시스템(3) (0) | 2022.04.20 |
[2장] 모듈 시스템 (0) | 2022.04.19 |
[1장] Node.js 플랫폼 (0) | 2022.04.16 |