require는 어떻게 동작할까?

Node.js를 사용하며 문득 require에 대해 궁금증이 생겼습니다. 대부분 자주 사용하는 코드를 모듈 형식으로 만들어 **module.exports**를 사용해서 객체 인스턴스를 내보내고 이를 다른 파일에서 **require**를 통해서 사용하게 되는데 대부분 여러 파일에서 모듈을 require해 사용하게 됩니다. 이때 여러파일에서 중복되는 require는 계속해서 새로운 인스턴스를 생성하는지, 그게 아니라면 어떻게 동작되는지 궁금해서 공부하며 찾아본 내용을 포스팅합니다.

Node.js의 모듈 로딩 시스템

Node.js는 간단한 모듈 로딩 시스템을 갖고 있습니다. Node.js에서 파일과 모듈은 일대일로 대응하며 각 파일은 별도의 모듈로 처리됩니다. 그렇기 때문에 여러곳에서 하나의 파일에 작성된 모듈을 필요로 할때 동일한 인스턴스를 사용할 수 있도록 합니다.

즉, 모듈을 require할 때마다 새로운 인스턴스가 생성되는 것이 아니라 캐싱된 객체 인스턴스를 재사용하는 것 입니다.

Node.js 공식 Documentation에서 확인할 수 있듯이 한번 로딩(require)된 모듈은 **require.cache**라는 객체에 캐싱됩니다. key값으로 해당 모듈 파일의 경로를 갖게 되는데 key값이 삭제된다면 다음 require 요청시 다시 재로딩 하게됩니다. 다음 코드를 통해서 require.cache에 캐싱된 모듈을 확인해보겠습니다.

1
2
3
4
5
// foo.js

module.exports = {
foo: "bar"
};
1
2
3
4
5
6
7
8
9
// index.js

var foo = require('./foo');

console.log('---------- require.cache ----------')
console.log(require.cache);

console.log('---------- require.cache keys ----------')
console.log(Object.keys(require.cache));

foo.js 와 index.js 파일을 통해 확인한 결과는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
kimjongmin:~/work/require_test $node index.js

---------- require.cache ----------
{ '/Users/kimjongmin/work/require_test/index.js':
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/kimjongmin/work/require_test/index.js',
loaded: false,
children: [ [Object] ],
paths:
[ '/Users/kimjongmin/work/require_test/node_modules',
'/Users/kimjongmin/work/node_modules',
'/Users/kimjongmin/node_modules',
'/Users/node_modules',
'/node_modules' ] },
'/Users/kimjongmin/work/require_test/foo.js':
Module {
id: '/Users/kimjongmin/work/require_test/foo.js',
exports: { foo: 'bar' },
parent:
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/kimjongmin/work/require_test/index.js',
loaded: false,
children: [Object],
paths: [Object] },
filename: '/Users/kimjongmin/work/require_test/foo.js',
loaded: true,
children: [],
paths:
[ '/Users/kimjongmin/work/require_test/node_modules',
'/Users/kimjongmin/work/node_modules',
'/Users/kimjongmin/node_modules',
'/Users/node_modules',
'/node_modules' ] } }
---------- require.cache keys ----------
[ '/Users/kimjongmin/work/require_test/index.js',
'/Users/kimjongmin/work/require_test/foo.js' ]

위의 결과에서 확인할 수 있듯이 require.cache 객체는 key값으로 해당 모듈 파일의 경로를 사용해 모듈을 캐싱하고 있습니다.

require가 갖는 문제점

이제 require를 통해 모듈을 로딩할 경우 파일의 경로를 캐시 키로 사용하여 다른 여러 파일에서 동일한 파일을 필요로하는 경우 동일한 캐싱 된 모듈을 사용하는 것을 알게되었습니다.

이로인해 불필요한 메모리 사용을 피할 수 있습니다. 어찌보면 한번 로딩된 후 재사용되기 때문에 싱글 톤과 같이 동작한다고도 생각할 수 있습니다. 그러나 이러한 모듈의 캐싱 방식이 다음과 같이 제대로 동작하지 않는 경우가 있습니다.

  • 파일 이름의 잘못된 대 / 소문자 사용
  • 다른 모듈이 NPM에서 동일한 모듈을 설치할 때

대 / 소문자 구분

Windows 및 macOS는 기본적으로 파일 시스템에서 대 / 소문자를 구분하지 않습니다. 따라서 “foo.js” 라는 파일과 “FOO.js” 라는 파일을 검색 할 경우, 이 두 검색은 실제 파일 이름의 대소 문자와 상관없이 같은 폴더에서 동일한 파일을 찾습니다.

그러나 Node.js에서는 대/ 소문자를 구별하기 때문에 파일 이름을 두 개의 개별 모듈로 취급하므로 “foo.js”와 “FOO.js”가 같은 파일이라는 것을 알지 못합니다.

이 때문에 Windows와 macOS 모두에서 require 호출의 객체 캐시를 쉽게 파기 할 수 있습니다. 다음의 예시 코드에서 쉽게 확인할 수 있습니다.

1
2
3
4
5
// foo.js

module.exports = {
foo: "bar"
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// index.js

var foo = require('./foo');
var FOO = require('./FOO');

console.log('---------- require.cache keys ----------')
console.log(Object.keys(require.cache));

FOO.foo = 'different bar';

console.log('---- foo object ----');
console.log(JSON.stringify(foo, null, 2));

console.log('---- FOO object ----');
console.log(JSON.stringify(FOO, null, 2));

console.log('---- foo object ----');
console.log(JSON.stringify(foo, null, 2));

결과는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kimjongmin:~/work/require_test $node index.js

---------- require.cache keys ----------
[ '/Users/kimjongmin/work/require_test/index.js',
'/Users/kimjongmin/work/require_test/foo.js',
'/Users/kimjongmin/work/require_test/FOO.js' ]
---- foo object ----
{
"foo": "bar"
}
---- FOO object ----
{
"foo": "different bar"
}
---- foo object ----
{
"foo": "bar"
}

결과에서 확인 가능하듯이 require된 모듈은 key값으로 해당 모듈 파일의 경로를 사용해 캐싱되고 있습니다. require시 대 / 소문자를 구분해 key로 사용하기 때문에 2개의 객체가 생성되었으나, 결과적으로는 파일 시스템에 도달하면 같은 파일이 2번 반환된 것입니다. 즉, 같은 파일에 서로 다른 모듈로 2개가 생성되어 있는 것 입니다.

require 문에 파일 이름을 잘못 입력 한 것과 관련된 다른 문제도 있습니다. 대 / 소문자를 구분하는 파일 시스템에 배포하는 경우 실제 파일과 동일하게 처리되지 않은 버전은 파일을 찾지 못합니다.

NPM 모듈 종속성

모듈 캐싱이 제대로 작동하지 않는 상황은 NPM에서 둘 이상의 **모듈 종속성이 같은 모듈을 설치**할 때 입니다. 즉, 프로젝트가 NPM의 “Foo”와 “Bar”에 의존하고 Foo와 Bar가 둘 다 “Baz”에 의존하면 NPM (버전 2 이하)은에 의존하는 각 모듈에 대해 “Baz”의 다른 사본을 설치합니다.

NPM 버전 3 에서는 종속성 목록을 병합하여 문제를 해결하고 있습니다. Foo와 Bar가 둘 다 동일한 Baz의 버전에 의존하면 하나의 사본만 설치합니다.

그러나, Foo와 Bar가 Baz의 서로 다른 (서로 호환되지 않는) 버전을 사용한다면, 여전히 두 버전을 모두 설치하며, 이 경우 모듈 캐시를 공유하지 않습니다.

마치며

반복되는 코드를 모듈화 하거나 각 기능 별로 모듈화 하게되면 결국 다른 파일에서 require를 통해 사용하게 되는데, 이때마다 어떤식으로 동작하게 되는지 궁금했었습니다. 이번 포스팅을 작성하면서 이에 대한 궁금증을 해결할 수 있었고, 결과적으로 한번 로딩된 모듈은 캐싱되어 사용되기 때문에 각기 파일마다 require를 많이 한다고해서 크게 걱정할 필요는 없을 것 같습니다.

또한, 필요에 의해 (필요한 상황이 있을지 모르겠지만…) require.cache에 고의적으로 캐싱된 모듈을 지우고 다시 새로 로딩하여 사용할 수도 있을것 같습니다.