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에 고의적으로 캐싱된 모듈을 지우고 다시 새로 로딩하여 사용할 수도 있을것 같습니다.

module.exports와 exports 차이 이해하기

모듈이란?

모듈이란 관련된 코드들을 하나의 코드 단위로 캡슐화 하는 것을 말합니다. Node.js 에서 예시를 살펴보겠습니다.
다음과 같은 greeting.js 라는 파일이 있습니다. 이 파일은 두개의 함수를 포함하고 있습니다.

1
2
3
4
5
6
7
8
// greetings.js
sayHelloInEnglish = function() {
return "Hello";
};

sayHelloInSpanish = function() {
return "Hola";
};

모듈 추출하기(exporting)

gretting.js 의 코드가 다른 파일에서 사용될 때 그 효용성이 증가할 것입니다. 이러한 일을 하기 위해서는 다음과 같은 3가지의 단계를 거쳐야 합니다.

1.greeting.js 파일의 코드 첫 부분에 다음과 같은 코드가 존재해야 합니다.

1
2
// greetings.js
var exports = module.exports = {};

2.다른 파일에서 exports 객체를 사용하기를 원한다면 greeting.js 파일에서 다음과 같이 작성해야 합니다.

1
2
3
4
5
6
7
8
9
// greetings.js
// var exports = module.exports = {};

exports.sayHelloInEnglish = function() {
return "HELLO";
};
exports.sayHelloInSpanish = function() {
return "Hola";
};

위의 코드에서 exports 를 module.exports 로 대체할 수 있으며 같은 결과를 얻을 수 있습니다. 이 부분이 잘 이해가 가지 않는다면 exports 와 module.exports 가 같은 객체를 참조한다고 기억하기 바랍니다.

3.module.exports 의 현재 값은 다음과 같습니다.

1
2
3
4
5
6
7
8
9
module.exports = {
sayHelloInEnglish: function() {
return "HELLO";
},

sayHelloInSpanish: function() {
return "Hola";
}
};

모듈 사용하기(importing)

main.js 라는 새로운 파일에서 greeting.js 의 메소드를 사용 할 수 있도록 import 하는 과정은 다음과 같습니다.

1.먼저 require이라는 키워드는 Node.js 에서 module(모듈)을 import(추가) 하기 위해 사용합니다. require는 다음과 같이 정의되어 있습니다.

1
2
3
4
5
6
var require = function(path) {

// ...

return module.exports;
};

2.main.js에서 greetings.js를 require 합니다.

1
2
// main.js
var greetings = require("./greetings.js");

위의 코드는 아래와 동일한 코드 입니다.

1
2
3
4
5
6
7
8
9
10
// main.js
var greetings = {
sayHelloInEnglish: function() {
return "HELLO";
},

sayHelloInSpanish: function() {
return "Hola";
}
};

3.main.js 에서 greeting.js 의 값과 메소드에 접근할 수 있습니다.

1
2
3
4
5
6
7
8
// main.js
var greetings = require("./greetings.js");

// "Hello"
greetings.sayHelloInEnglish();

// "Hola"
greetings.sayHelloInSpanish();

중요 포인트

require 키워드는 object 를 반환합니다. 그리고 module.exports 와 exports 는 call by reference 로 동일한 객체를 바라보고 있고, 리턴되는 값은 항상 module.exports 입니다.

모듈은 기본적으로 객체이고, 이 객체를 module.exports, exports 모두 바라보고 있는데, 최종적으로 return 되는 것은 무조건 module.exports 라는 것입니다.

1
2
3
4
5
6
7
8
9
var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res) {
res.render('index', { title: 'Express' });
});

module.exports = router;

위의 소스는 다음과 같이 해석할 수 있습니다.
express.Router() 가 리턴한 “객체”에 일부 프로퍼티를 수정한 뒤, 이 객체 자체를 모듈로 리턴한 것입니다.