더북(TheBook)

콘솔

$ node require
require가 가장 위에 오지 않아도 됩니다.
require.cache입니다.
[Object: null prototype] {
  'C:\\Users\\zerocho\\require.js': {
    id: '.',
    path: 'C:\\Users\\zerocho',
    exports: '저를 찾아보세요.',
    filename: 'C:\\Users\\zerocho\\require.js',
    loaded: false,
    children: [ [Object] ],
    paths: [
      'C:\\Users\\zerocho\\node_modules',
      'C:\\Users\\node_modules',
      'C:\\node_modules'
    ]
  },
  'C:\\Users\\zerocho\\var.js': {
    id: 'C:\\Users\\zerocho\\var.js',
    path: 'C:\\Users\\zerocho',
    exports: { odd: 'CJS 홀수입니다', even: 'CJS 짝수입니다' },
    filename: 'C:\\Users\\zerocho\\var.js',
    loaded: true,
    children: [],
    paths: [ 
      'C:\\Users\\zerocho\\node_modules',
      'C:\\Users\\node_modules',
      'C:\\node_modules'
    ]
  }
}
require.main입니다.
true
C:\\Users\\zerocho\\require.js

콘솔에 나오는 경로는 이 책과 다를 것입니다. 위 예제에서 알아야 할 점은 require가 반드시 파일 최상단에 위치할 필요가 없고, module.exports도 최하단에 위치할 필요가 없다는 것입니다. 자바스크립트 문법에 어긋나지 않는 선에서 아무 곳에서나 사용해도 됩니다.

require.cache 객체에 require.js나 var.js 같은 파일 이름이 속성명으로 들어 있는 것을 볼 수 있습니다. 속성값으로는 각 파일의 모듈 객체가 들어 있습니다. 한 번 require한 파일은 require.cache에 저장되므로 다음 번에 require할 때는 새로 불러오지 않고 require.cache에 있는 것이 재사용됩니다.

만약 새로 require하길 원한다면 require.cache의 속성을 제거하면 됩니다. 다만, 프로그램의 동작이 꼬일 수 있으므로 권장하지는 않습니다. 속성을 자세히 살펴보면 module.exports했던 부분(exports)이나 로딩 여부(loaded), 자식(children) 모듈 관계를 찾을 수 있습니다.

require.main은 노드 실행 시 첫 모듈을 가리킵니다. 현재 node require로 실행했으므로 require.js가 require.main이 됩니다. require.main 객체의 모양은 require.cache의 모듈 객체와 같습니다. 현재 파일이 첫 모듈인지 알아보려면 require.main === module을 해보면 됩니다. node require로 실행한 경우, var.js에서 require.main === module을 실행하면 false가 반환될 것입니다. 첫 모듈의 이름을 알아보려면 require.main.filename으로 확인하면 됩니다.

모듈을 사용할 때는 주의해야 할 점이 있습니다. 만약 두 모듈 dep1과 dep2가 있고 이 둘이 서로를 require한다면 어떻게 될까요?

dep1.js

const dep2 = require('./dep2');
console.log('require dep2', dep2);
module.exports = () => {
  console.log('dep2', dep2);
};

dep2.js

const dep1 = require('./dep1');
console.log('require dep1', dep1);
module.exports = () => {
  console.log('dep1', dep1);
};

dep-run.js를 만들어 두 모듈을 실행해보겠습니다.

dep-run.js

const dep1 = require('./dep1');
const dep2 = require('./dep2');

dep1();
dep2();

코드가 위에서부터 실행되므로 require('./dep1')이 먼저 실행됩니다. dep1.js에서는 제일 먼저 require('./dep2')가 실행되는데요. 다시 dep2.js에서는 require('./dep1')이 실행됩니다. 다시 dep1.js는 require('./dep2')를 실행합니다. 이 과정이 무한 반복되므로 어떻게 될지 궁금할 겁니다. 실제로 실행해봅시다.

콘솔

$ node dep-run
require dep1 {}
require dep2 [Function (anonymous)]
dep2 [Function (anonymous)]
dep1 {}
(node:29044) Warning: Accessing non-existent property 'Symbol(nodejs.util.inspect.custom)' of module exports inside circular dependency
(Use `node --trace-warnings ...` to show where the warning was created)
...

놀랍게도 dep1module.exports가 함수가 아니라 빈 객체로 표시됩니다. 이러한 현상을 순환 참조(circular dependency)라고 부릅니다. 이렇게 순환 참조가 있을 경우에는 순환 참조되는 대상을 빈 객체로 만듭니다. 이때 에러가 발생하지 않고(Warning은 에러가 아니라 경고입니다) 조용히 빈 객체로 변경되므로 예기치 못한 동작이 발생할 수 있습니다. 따라서 순환 참조가 발생하지 않도록 구조를 잘 잡는 것이 중요합니다.

노트 존재하지 않는 모듈을 불러오려 시도할 때

존재하지 않는 모듈을 불러올 때는 다음 에러가 발생합니다. 모듈명을 보고 분석하면 됩니다.

Error: Cannot find module '모듈명' { code: 'MODULE_NOT_FOUND' }

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '모듈명' imported from 모듈명

이어서 ECMAScript 모듈을 알아봅시다.

 

 

2.4.2 ECMAScript 모듈

ECMAScript 모듈(이하 ES 모듈)은 공식적인 자바스크립트 모듈 형식입니다. 노드에서 아직까지는 CommonJS 모듈을 많이 쓰긴 하지만 ES 모듈이 표준으로 정해지면서 점점 ES 모듈을 사용하는 비율이 늘어나고 있습니다. 브라우저에서도 ES 모듈을 사용할 수 있어 브라우저와 노드 모두에 같은 모듈 형식을 사용할 수 있다는 것이 장점입니다.

이전 절의 코드를 ES 모듈 스타일로 바꿔보겠습니다.

var.mjs

export const odd = 'MJS 홀수입니다';
export const even = 'MJS 짝수입니다';

func.mjs

import { odd, even } from './var.mjs';

function checkOddOrEven(num) {
  if (num % 2) {  // 홀수면
    return odd;
  }
  return even;
}

export default checkOddOrEven;

index.mjs

import { odd, even } from './var.mjs';
import checkNumber from './func.mjs';

function checkStringOddOrEven(str) {
  if (str.length % 2) {  // 홀수면
    return odd;
  }
  return even;
}

console.log(checkNumber(10));
console.log(checkStringOddOrEven('hello'));

콘솔

$ node index.mjs
MJS 짝수입니다
MJS 홀수입니다

requireexports, module.exports가 각각 import, export, export default로 바뀌었습니다. 꽤 차이가 있으므로 단순히 글자만 바꿔서는 제대로 동작하지 않을 수 있습니다. ES 모듈의 importexport defaultrequiremodule처럼 함수나 객체가 아니라 문법 그 자체입니다.

파일도 js 대신 mjs 확장자로 변경되었습니다. js 확장자에서 import를 사용하면 SyntaxError: Cannot use import statement outside a module 에러가 발생합니다. mjs 확장자 대신 js 확장자를 사용하면서 ES 모듈을 사용하려면 4장에서 배울 package.json에 type: "module" 속성을 넣으면 됩니다.

CommonJS 모듈과는 다르게 import 시 파일 경로에서 js, mjs 같은 확장자는 생략할 수 없습니다. 또한 폴더 내부에서 index.js도 생략할 수 없습니다.

표로 두 모듈 형식의 차이를 정리해봤습니다.

▼ 표 2-2 CommonJS 모듈과 ECMAScript 모듈의 차이

차이점

CommonJS 모듈

ECMAScript 모듈

문법

require('./a');

module.exports = A;

const A = require('./a');

exports.C = D;

const E = F; exports.E = E;

const { C, E } = require('./b');

import './a.mjs';

export default A;

import a from './a.mjs';

export const C = D;

const E = F; export { E };

import { C, E } from './b.mjs';

확장자

js

cjs

js(package.json에 type: "module" 필요)

mjs

확장자 생략

가능

불가능

다이내믹 임포트

require 사용

import 사용

index 생략

가능(require('./folder'))

불가능(import './folder/index.mjs')

top level await

불가능

가능

__filename,

__dirname,

require, module.exports,

exports

사용 가능(2.4.5절 참고)

사용 불가능(__filename 대신 import.meta.url 사용)

서로 간 호출

가능

 

 

2.4.3 서로 다른 모듈 불러오기

현재 자바스크립트 생태계에는 CommonJS 모듈과 ES 모듈 두 방식이 공존하고 있습니다. 그런데 서로 다른 모듈을 불러오는 것이 원활하지 않다는 문제가 있었습니다. ES 모듈에서 CommonJS 모듈을 불러올 때는 에러가 발생하지 않는데 CommonJS 모듈에서 ES 모듈을 불러올 때는 에러가 발생하곤 했습니다. 하지만 노드 23 버전에서는 일부 제한 사항을 제외하고는 서로를 불러올 수 있습니다.

먼저 ES 모듈에서 CommonJS 모듈을 불러와봅시다.

loadCJS.mjs

import { odd, even } from './var.js';
import func from './func.js';
console.log(odd);
console.log(even);
console.log(func);

콘솔

$ node loadCJS.mjs
CJS 홀수입니다
CJS 짝수입니다
[Function: checkOddOrEven]

CommonJS 모듈에서 CommonJS 모듈을 불러오는 것과 별다른 차이 없이 ES 모듈에서 CommonJS 모듈을 불러올 수 있습니다.

이번에는 CommonJS 모듈에서 ES 모듈 파일을 불러와봅시다.

loadESM.js

const { odd, even } = require('./var.mjs');
const func = require('./func.mjs');
console.log(odd);
console.log(even);
console.log(func);

콘솔

$ node loadESM
MJS 홀수입니다
MJS 짝수입니다
[Module: null prototype] {
  __esModule: true,
  default: [Function: checkOddOrEven]
}

export const로 내보냈던 oddeven은 기존 방식과 동일하게 불러올 수 있습니다. 다만 func처럼 export default로 내보낸 것을 불러올 때는 객체 형식으로 불러오게 되고, 실제 값은 default라는 이름의 속성에 들어 있습니다.

다만 ES 모듈을 불러올 수 없는 경우도 있습니다. top level await 코드가 ES 모듈에 들어 있으면 CommonJS 모듈이 불러올 수 없습니다.

asyncFunc.mjs

await function topLevelAwait() {
  console.log('topLevelAwait');
}

loadESM2.js

const asyncFunc = require('./asyncFunc.mjs');

콘솔

$ node loadESM2
node:internal/modules/esm/module_job:513
      throw new ERR_REQUIRE_ASYNC_MODULE(filename, parentFilename);
      ^

Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.
...
  code: 'ERR_REQUIRE_ASYNC_MODULE'
}

top level await 문법을 사용하는 파일을 불러올 때는 ERR_REQUIRE_ASYNC_MODULE이라는 에러가 발생합니다. 따라서 ES 모듈을 불러올 때는 항상 해당 문법이 존재하는지를 확인한 후 불러와야 합니다. 참고로 어떤 파일에서 top level await을 사용하는지 확인하려면 node --experimental-print-required-tla loadESM2 명령어를 실행하면 됩니다.

 

 

2.4.4 다이내믹 임포트

2.4.2절의 표 2-2에서 CommonJS 모듈과 ES 모듈을 비교할 때, CommonJS 모듈에서는 다이내믹 임포트(Dynamic Import, 동적 불러오기)를 위해 require를 사용하고, ES 모듈에서는 import를 사용한다고 설명했습니다. 다이내믹 임포트가 무엇이고 서로 어떻게 다르게 사용하는지 알아봅시다.

dynamic.js

const a = false;
if (a) {
    require('./func');
}
console.log('성공');

콘솔

$ node dynamic
성공

dynamic.js에서 require('./func')는 실행되지 않습니다. if문이 false라서 실행되지 않으니까요. 이렇게 조건부로 모듈을 불러오는 것을 다이내믹 임포트라고 합니다.

dynamic.mjs

const a = false;
if (a) {
    import './func.mjs';
}
console.log('성공');

콘솔

$ node dynamic.mjs
file:///C:/Users//zerocho/dynamic.mjs:3
    import './func.mjs';
           ^^^^^^^^^^^^

SyntaxError: Unexpected string
...

하지만 ES 모듈은 if문 안에서 import하는 것이 불가능합니다. 이럴 때 다이내믹 임포트를 사용합니다. dynamic.mjs를 다음과 같이 수정해봅시다.

dynamic.mjs

const a = true;
if (a) {
    const m1 = await import('./func.mjs');
    console.log(m1);
    const m2 = await import('./var.mjs');
    console.log(m2);
}

콘솔

$ node dynamic.mjs
[Module: null prototype] { default: [Function: checkOddOrEven] }
[Module: null prototype] { even: 'MJS 짝수입니다', odd: 'MJS 홀수입니다' }

import라는 함수를 사용해서 모듈을 동적으로 불러올 수 있습니다. importPromise를 반환하기에 await이나 then을 붙여야 합니다. 위 코드에서는 async 함수를 사용하지 않았는데, ES 모듈의 최상위 스코프에서는 async 함수 없이도 await할 수 있습니다. CommonJS 모듈에서는 안 됩니다.

결괏값도 눈여겨볼 필요가 있습니다. 이전 절과 마찬가지로 export default의 경우 import할 때도 객체 안에서 default라는 속성 이름으로 import됩니다. 참고로 CommonJS 모듈에서 module.exports로 내보낸 것도 default라는 속성 이름으로 import됩니다.

 

 

2.4.5 __filename, __dirname

노드에서는 파일 사이에 모듈 관계가 있는 경우가 많으므로, 현재 파일의 경로를 알아야 하는 경우가 있습니다. 노드는 __filename, __dirname이라는 키워드로 경로에 대한 정보를 제공합니다. 파일에 __filename__dirname을 넣어두면 실행 시 현재 파일 경로와 현재 디렉터리 경로로 바뀝니다.

filename.js

console.log(__filename);
console.log(__dirname);

콘솔

$ node filename
C:\Users\zerocho\filename.js
C:\Users\zerocho

이 책 예제의 경로는 여러분의 경로와 다를 것입니다. 또한, 윈도가 아니라면 \ 대신 /로 디렉터리 경로가 구분될 수 있습니다. 이렇게 얻은 정보를 사용해서 경로 처리를 할 수도 있습니다. 하지만 경로가 문자열로 반환되기도 하고, \/ 같은 경로 구분자 문제도 있으므로 보통은 이를 해결해주는 path 모듈(2.6.2절 참조)과 함께 씁니다.

참고로 ES 모듈에서는 __filename__dirname을 사용할 수 없습니다. 대신 import.meta.filename으로 파일 경로, import.meta.dirname으로 디렉터리 경로를 가져올 수 있습니다.

filename.mjs

console.log(import.meta.filename);
console.log(import.meta.dirname);
console.log('__filename은 에러');
console.log(__filename);

콘솔

$ node filename.mjs
C:/Users/zerocho/filename.mjs
C:/Users/zerocho 
__filename은 에러
file:///C:/Users/zerocho/filename.mjs:4
console.log(__filename);
            ^

ReferenceError: __filename is not defined in ES module scope
(생략)

CommonJS 모듈에서 사용했던 require 함수나 module 객체는 따로 선언하지 않았음에도 사용할 수 있었습니다. 이것이 어떻게 가능할까요? 바로 노드에서 기본적으로 제공하는 내장 객체이기 때문입니다. 다음 절에서는 내장 객체를 자세히 알아보겠습니다.