목차
- CommonJS
- 직접 만드는 모듈 로더
- 해결(resolving) 알고리즘
- 모듈 캐시
- 순환 종속성
CommonJS
CommonJS는 Node.js의 첫 번째 내장 모듈 시스템입니다.
- require로 모듈을 임포트하게 해 줍니다.
- exports와 module.exports는 특별한 변수로서 현재 모듈에서 공개될 기능들을 내보내기 위해 사용
이제 모듈 로더를 간단하게 만들어 봅시다.
직접 만드는 모듈 로더
const fs = require('fs');
function loadModule(filename, module, require) {
const wrappedSrc = `
(function (module, exports, require){
${fs.readFileSync(filename, 'utf8')}
})(module, module.exports, require)
`;
eval(wrappedSrc);
}
function require(moduleName) {
console.log('Require invoked for module: ' + moduleName);
const id = require.resolve(moduleName); // (1)
if (require.cache[id]) {
// (2)
return require.cache[id].exports;
}
const module = {
//(3)
exports: {},
id,
};
//캐시 업데이트
require.cache[id] = module; // (4)
//모듈 로드
loadModule(id, module, require); // (5)
//익스포트되는 변수 반환
return module.exports; // (6)
}
require.cache = {};
require.resolve = (moduleName) => {
/* 모듈이름으로 id로 불리게 되는 모듈의 전체경로를 찾아냄 */
};
코드 설명
- 모듈 이름을 입력으로 받아 수행하는 첫 번째 일은 우리가 id라고 부르는 모듈의 전체경로를 알아내는 (resolve) 것입니다. 이 작업은 이를 해결하기 위해 관련 알고리즘을 구현하고 있는 require.resolve()에 위임됩니다(나중에 설명할 것입니다).
- 모듈이 이미 로드된 경우 캐시 된 모듈을 사용합니다. 이 경우 즉시 반환합니다.
- 모듈이 아직 로드되지 않은 경우 최초 로드를 위해서 환경을 설정합니다. 특히 빈 객체 리터럴을 통 해 초기화된 exports 속성을 가지는 module 객체를 만듭니다. 이 객체는 불러올 모듈의 코드에서의 public API를 익스포트 하는데 사용됩니다.
- 최초 로드 후에 module 객체가 캐시 됩니다.
- 모듈 소스코드는 해당 파일에서 읽어오며, 코드는 앞에서 살펴본 방식으로 평가됩니다. 방금 생성한 module 객체와 require() 함수의 참조를 모듈에 전달합니다. 모듈은 module.exports 객체를 조작하 거나 대체하여 public API를 내보냅니다.
- 마지막으로, 모듈의 public API를 나타내는 module.exports의 내용이 호출자에게 반환됩니다.
module.exports vs exports
exports는 module.export의 참조일 뿐 즉 아래 코드에서 exports를 참조합니다.
const module = {
//(3)
exports: {}, // < 에를 참조
id,
};
const exports = module.exports;
참조만 하기 때문에 첫 번째 형식의 코드는 잘못된 것을 알 수 있습니다. 왜냐? 참조 값을 바꿔버리기 때문이죠
두 번째 형식은 exports에 속성을 추가하기 때문에 가능하고요
exports = () => {}; // ❌
exports.hello = () => {}; // ✅
module.exports = () => {}; // ✅
해결(resolving) 알고리즘
종속성 지옥이라는 용어는 프로그램의 종속성이 서로 공통된 라이브러리에 의존하지만 호환되지 않는 서로 다른 버전을 필요로 하는 상황을 나타냅니다. 예를 들어 현재 프로젝트에서는 리액트 18V을 사용하는데 다른 UI라이브러이에서는 16.8V을 사용할 경우가 있을 수 있습니다.
Node.js는 로드되는 위치에 따라 다른 버전의 모듈을 로드할 수 있도록 하여 이 문제를 우아하게 해결합니다. 이 특징의 장점은 패키지 매니저(npm)가 애플리케이션의 종속성을 구성하는 방식과 require() 함수에서 사용하는 해결(resolving) 알고리즘에도 적용됩니다.
이제 이 알고리즘을 알아보겠습니다.
require.resolve(name)는 모듈 이름을 입력으로 사용하여 모듈 전체의 경로를 반환합니다. 아래 코드처럼요
이 id는 모듈을 고유하게 식별하데 사용됩니다.
const path = require('path');
const resolveId = require.resolve(path.join(__dirname, 'test.js'));
console.log(resolveId); ///Users/ysy/node/node-design-pattern/module-system/test.js
해결 알고리즘은 크게 세 가지로 나눌 수 있습니다.
- 파일 모듈: moduleName이 / 로 시작하면 모듈에 대한 절대 경로라고 간주되어 그대로 반환됩니다. ./ 로 시작하면 moduleName은 상대 경로로 간주되며, 이는 요청한 모듈로부터 시작하여 계산됩니다.
- 코어 모듈: moduleName이 / 또는 ./ 로 시작하지 않으면 알고리즘은 먼저 코어 Node.js 모듈 내에서 검색을 시도합니다.
- 패키지 모듈: moduleName과 일치하는 코어 모듈이 없는 경우, 요청 모듈의 경로에서 시작하여 디렉터리 구조를 탐색하여 올라가면서 node_nodules 디렉터리를 찾고 그 안에서 일치하는 모듈을 계속 찾습니다. 알고리즘은 파일 시스템의 루트에 도달할 때까지 디렉토리 트리를 올라가면서 node_modules 디렉터리를 탐색하여 계속 일치하는 모듈을 찾습니다.
위의 방식으로 아래 파일 트리에 적용시켜 보겠습니다.
- /myApp/foo.js에서 require('depA’)를 호출할 경우 /myApp/node_modules/depA/index.js 로드됩니다.
- /myApp/node_modules/depB/bar.js에서 require('depA’)를 호출할 경우 /myApp/node_ modules/depB/node_modules/depA/index.js가 로드됩니다.
- /myApp/node_modules/depC/foobar.js에서 require('depA’)를 호출할 경우 /myApp/node_ modules/depC/node_modules/depA/index.js가 로드됩니다.
모듈 캐시
require()로 불러온 모듈은 처음 로드될 때만 로드되고 이후에는 캐시가 되어있는 모듈을 가져옵니다. 이는 성능에서 매우 효율적이지만 다음과 같은 영향이 있습니다.
- 모듈 종속성 내에서 순환을 가질 수 있습니다.
- 일정한 패키지 내에서 동일한 모듈이 필요할 때 얼마간 동일한 인스턴스가 항상 반환된다는 것을 보장합니다.
순환 종속성
순환 종속성이란 서로 다른 모듈이 서로를 불러오는 것을 말합니다. 아래 그림처럼요.
모듈은 main.js에 a.js와 b.js를 require로 불러옵니다. 하지만 a,b.js도 서로를 불러오고 있습니다.
아래는 코드입니다.
// main.js
const a = require('./a');
const b = require('./b');
console.log(`a -> ${JSON.stringify(a, null, 2)}`);
console.log(`b -> ${JSON.stringify(b, null, 2)}`);
// a.js
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, // 이전 export문을 오버라이드
};
실행 결과입니다. 여기서 결론부터 말하자면 b에서 a를 호출할 때 a.js가 다 로드되지 않은 채로 불러오게 됩니다. 이는 캐시가 되게 되고 main에서 b를 호출한 결과는 a가 불안전한 상태로 로드된 것을 알 수 있습니다.
그림으로 단계별 해석을 해보겠습니다.
해당 그림의 단계별 설명
- main.js에서 처리가 시작되고 즉각적으로 require로 a.js를 불러옵니다.
- 모듈 a.js는 처음으로 내보내지는 값인 loaded false로 설정합니다.
- 이 시점에서 모듈 a.js는 모듈 b.js를 require로 불러옵니다.
- a.js에서와 같이 b.js에서 내보내지는 값인 loaded를 false로 설정합니다.
- b.js는 require로 a.js를 불러옵니다(순환).
- a.js는 이미 처리되었기 때문에 이때 내보내지는 값은 즉시 모듈 b.js의 범위로 복사됩니다.
- 모듈 b.js는 마지막으로 loaded의 값을 true로 바꿉니다.
- 이제 b.js는 완전히 실행되었고 제어는 a.js로 반환됩니다. 현재 모듈 b.js의 상태 값을 복사하여 a.js의 범위에 갖습니다.
- 모듈 a.js의 마지막 단계는 loaded의 값을 true로 바꾸는 것입니다.
- 모듈 a.js는 현재 완전히 실행되었고 제어는 main.js로 반환됩니다. main.js는 현재 모듈 a.js의 상태를 복사하여 내부 범위에 갖습니다.
- main.js는 require로 b.js를 불러오고 즉각적으로 캐시 된 것을 로드합니다.
- 현재 모듈 b.js의 상태가 모듈 main.js로 복사되었고 우리는 모든 모듈이 마지막 상태를 그림에서 볼 수 있습니다.
이 에로부터 CommonJS에서 발생할 수 있는 순환종속성 문제를 알아보았습니다. ESM이 순환 종석성 문제를 어떻게 다루는지 알아보겠습니다.
'백엔드 > [NodeJS] 디자인 패턴' 카테고리의 다른 글
ESM:(ECMAScript Module) with import, from (2) | 2024.01.03 |
---|