본문 바로가기

Back-end/Node.js

모듈의 순환 종속성으로 알아보는 require() vs import

* 이 글은 Mario Casciaro, Luciano Mammino가 저서한 <Node.js 디자인 패턴 바이블> 서적을 참고한 게시글입니다.

 

서적 정보 : http://www.kyobobook.co.kr/product/detailViewKor.laf?mallGb=KOR&ejkGb=KOR&barcode=9788931464283

 

Node.js 디자인 패턴 바이블 - 교보문고

검증된 패턴과 기술을 이용한 수준 높은 Node.js | 이 책은 이미 Node.js를 처음 접한 후 이제 생산성, 디자인 품질 및 확장성 측면에서 최대한 활용하고자 하는 개발자를 대상으로 합니다. 이 책은

www.kyobobook.co.kr

 


 

 

require와 import 함수(정확하게는 구문이 맞는 표현이지 싶다)의 차이에 대해 공부하게 되었는데,

 

이를 순환 종속성을 기반으로 설명해보려고 한다.

 

 

 

0.  module.exports

 

먼저 module.exports 변수에 대해 간단히 짚고 넘어가자.

 

모듈에서 구현한 함수, 변수 등 여러 모듈들은 module.exports 라는 변수에 할당이 되지 않으면 외부로 노출되지 않는다. 

module.exports = {
loaded: true,
...
}

위와 같이 외부에서 참조할 수 있도록 module.exports 변수 안에 할당을 해주어야 하며,

 

흔히 알고있는 변수 'exports'는 module.exports의 초기값에 대한 참조 (call by reference)일 뿐이다. 

 

쉽게 말해, 아래와 같이 exports 값 자체를 재할당하는 경우, 

// 아래에 선언된 exports.a만 실제로 반환되며 외부로 노출된다.
module.exports = {
a: () => console.log("Bye")
}
// 재할당을 해도 module.exports에 영향을 주지 못한다.
exports = () => {
console.log("Hello")
}

실제 module.exports값에 아무런 영향을 주지 않으며, 기존 module.exports만 외부로 노출된다.

 

 

1.  require()

 

우선 순환 종속성에 대해 이야기하기 전에, require()의 두 특성에 대해 간단히 이야기하자면,

 

 

  1. require() 함수는 동기적이다.
  2. require() 함수는 재참조를 대비해 모듈을 캐싱해서 보관한다.

 

 

먼저 동기적이라는 의미는, 참조하는 모듈이 비동기적으로 초기화가 되는 경우 require()로 참조를 요청할 때,

 

모듈이 제대로 초기화가 되었는지 보장할 수 없다는 의미이다.

 

쉽게 말해 아래와 같이

// async.js
// 100ms 후에 모듈 노출
setTimeout(() => {
module.exports = {
done: () => console.log("done")
};
}, 100);
// async_test.js
const { done } = require("./async");
// undefined
console.log(done);
// TypeError: done is not a function
done();

 

async.js에서 100ms 후에 done이라는 모듈을 노출하고, async_test.js에서 done을 참조하여 출력하고 호출을 해봐도 원하는 값을 얻을 수 없다.

 

(+) 과거의 Node.js는 require()가 비동기 버전이었지만 큰 복잡성 때문에 제거되었다고 한다. 하지만 비동기 방식으로 적용할 수 있는 방법이 있다고 하는데, 기회(?)가 된다면 포스팅해보고자 한다.

 

 

 

두 번째 특징은 모듈을 캐싱해서 보관한다는 것인데, 이는 require.cache라는 변수를 출력해보면 쉽게 알 수 있다.

// cache.js
module.exports = {
isInCache: true
};
// cache_test.js
const { isInCache } = require("./cache");
console.log(require.cache);

 

출력된 결과를 살펴보면,

 

가운에 즈음에 우리가 노출시켰던 isInCache 변수가 보인다

 

require.cache에는 참조하는 모듈의 경로, 파일명, 참조하는 다른 부모 모듈, 필요로 하는 자식 모듈 등 모듈의 종속성을 포함한 다양한 정보를 가지고 있다. 

 

모듈의 리로드를 위해 해당 키값을 찾아 삭제한 후, 다시 참조를 하는 방식으로 활용할 수도 있겠지만 조금 위험해보인다..

 

 

사실 모듈 캐싱의 중요성은 다른 곳에 있다. 그건 바로..

 

 

2.  require()와 순환 종속성 

 

다음과 같은 3개의 파일 a.js / b.js / main.js가 있다고 가정해보자.

// a.js
exports.loaded = false;
const b = require("./b");
module.exports = {
b,
loaded: true
};
// b.js
exports.loaded = false;
const a = require("./a");
module.exports = {
a,
loaded: true
};
// 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와 b는 각각 서로를 다시 참조하는 코드들이고, main에서는 a와 b에 어떤 값들이 담겨 있는지 체크하는 파일이다.

 

우선 main의 실행 결과부터 살펴보자면,

 

엥..?

 

분명 실제 코드로 의도한 바와 다르게 출력되었음을 알 수 있는데, 왜 이런 현상이 발생했는지 순서대로 살펴보자.

 

 

  1. main.js가 실행되고, require("./a")가 수행된다. a로 제어권이 넘어간다.
  2. a.js는 우선 loaded를 false로 할당한다.
  3. 이후 require("./b")를 만나고, b로 제어권이 넘어간다.
  4. b.js는 우선 loaded를 false로 할당한다.

 

우선 여기서 끊어보겠다. 아직은 쉽게도 a와 b에서 모두 exports 안에 loaded: false라는 변수를 담아두었을 뿐이다.

 

계속 진행해보면,

 

 

   5. b.js는 require("./a")로 다시 a.js를 참조한다. 여기서 순환이 발생한다.

   6. a는 이미 캐싱되었기 때문에, 캐시 안의 a의 노출 값들이 b가 가진 a가 된다.

 

 

잠시 스톱... 슬슬 헷갈려오는데 쉽게 말하자면,

 

5번 과정에서 b가 다시 a를 불러오는 시점에 require.cache에는 이미 a에 대한 정보와 a의 변수 loaded(false)가 들어있고,

 

캐시에 역시 저장되어 있으므로 b가 가진 a는

// a in b.js
const a = {
loaded: false
}

 

가 되는 것이다. 계속 진행해보자.

 

 

   7. b.js는 마지막으로 loaded의 값을 true로 바꾼다.

   8. b.js는 모두 실행되었고, 제어권이 다시 a.js로 반환된다. (b.js가 가지고 있던 제어권은 3번 과정의 a.js로 부터 왔다)

 

 

자, 이 시점에서 모두 완료된 b의 module.exports 변수를 출력해보자.

// b.js
exports.loaded = false;
const a = require("./a");
module.exports = {
a,
loaded: true
};
// 이 시점에서 출력해보자.
console.log(module.exports);

 

8번 과정 직후 b의 module.exports

 

예상했던대로 loaded: false로 갖는 a와 loaded: true를 가지고 있다. 계속 가보자.

 

 

   9. a.js는 마지막으로 loaded의 값을 true로 바꾼다.

 

 

여기서도 똑같이 출력을 해보자.

// a.js
exports.loaded = false;
const b = require("./b");
module.exports = {
b,
loaded: true
};
// 여기서 출력해보자.
console.log(module.exports);

 

9번 과정 직후 a의 module.exports

 

b가 마지막으로 module.exports에 할당했던 값을 b로, 본인(a)의 loaded는 true로 가지고 있는 모습이다. 

 

그리고 이 두 a, b의 module.exports는 main의 a, b로 바로 복사가 된다. ( = 캐싱 )

 

여기서 알 수 있는 것은, 모듈 b는 모듈 a가 완전히 초기화가 되지 않은 상태를 가지고 있고,

 

main에서 require("./b")가 실행될 때 이 잘못된 정보들이 전파가 된다는 것이다.

 

 

뭐.. 사실 순서대로 체크해보고 다시 코드를 보면 당연하다고 느껴질 수 있지만, 굉장히 큰 프로젝트의 경우 이런 문제가 발생하지 않으리란 보장은 없다고 생각한다. 

 

(물론 현재 node버전에서는 친절하게 module exports inside circular dependency 에러를 잘 뱉어준다!)

 

과연 ESM의 import는 무엇이 다를지 살펴보자.

 

 

 

3.  import

 

import 구문은 다음과 같은 특징을 가지고 있다.

 

  1. 모듈 식별자는 실행 중에는 생성될 수 없다.
  2. import 구문은 반드시 모든 파일의 최상위에 선언되어야 하며, 제어권이 넘나드는 코드 내부에서는 동작하지 않는다.

 

사실, 두 특징이 서로 같은 말이다.

 

즉, import 구문은 모든 코드에서 최상위에 있어야 하며, 코드가 실행될 때 가장 먼저 실행된다. 

 

여기서 바로 require()와는 차이가 있다.

 

순환 종속성을 야기하던 상황은 사실 require()가 코드 중간에 끼어 들어가 제어권을 참조하는 코드로 넘겨주고 하는 과정에서 발생했다고 볼 수 있는데, import 구문은 애초에 그걸 막는 것이다. 

 

즉, import는 static(정적)이라는 큰 특징을 가지고 있다.

 

 

(+) 그렇다고 import를 동적(혹은 비동기적)으로 쓸 수 없는 것은 아니다.

// 동적(비동기적)으로 모듈 불러오기
import("./temp.mjs").then((temp) => {
console.log(temp);
});

위와 같이 함수로도 제공되어 있다. 자세한 건 다음에 살펴보겠다.

 

 

 

4.  import와 순환 종속성

 

import가 모듈을 참조할 때는 다음과 같은 과정을 거친다.

 

 

  1. 파싱(parsing) : 모든 import 구문을 찾고, 재귀적으로 각 파일로부터 모든 모듈의 내용을 적재한다. (on memory)
  2. 인스턴스(instance)화 : export된 모든 개체들의 참조를 메모리에 유지한다. 1단계에서 파싱된 import 구문과 이번 단계에서 얻어진 export에 대한 참조들을 이용해 이들 간의 종속성 관계(linking)을 추적한다. 여기까지 어떠한 Javascript 코드도 실행되지 않는다.
  3. 평가(evaluate) : 코드를 실행하여 2단계에서 얻어진 인스턴스들의 실제 값을 채운다. 이제서야 메인 코드를 실행할 준비가 되었다.

 

이 ESM의 import와 CommonJS의 require()의 명백한 차이점이 여기서 드러난다.

 

앞서 살펴보았듯, CommonJS의 require()는 실행되는 순간 참조되는 파일을 실행시키고, 또 새로운 require()를 만나면 참조되는 파일을 실행시키고, ... 반복한다.

 

허나, ESM의 import 구문은 종속성 그래프가 완전해질 때까지는 어떠한 js 코드도 실행되지 않는다.

 

 

(+) 또 다른 차이점으로는, export된 개체들에 대해 CommonJS의 require()로는 값 그대로 복사(얕은 복사)가 된다는 것이고, ESM의 import 구문으로는 read-only-live 바인딩이 된다는 점이다. 쉽게 말해 require()로 얻어진 참조값들은 작성자의 코드안에서 새로운 변수가 되어 값을 변경해도 원시 모듈은 이를 알 수 없지만, import로 얻어진 참조값들은 코드 내에서 변경이 불가능(read-only)하며 오직 원시 모듈에서 바꿀 수 있고, 이는 참조한 다른 코드들에도 전파(live)된다.

 

 

 

이제 ESM import를 통해 순환 종속성이 해결되는 과정에 대해 살펴보자.

 

우선 앞서 작성했던 3개의 파일 a.js / b.js / main.js을 이제는 다음과 같이 export/import 구문으로 변경해서 작성했다.

// a.mjs
import * as bModule from "./b.mjs";
export let loaded = false;
export const b = bModule;
loaded = true;
// b.mjs
import * as aModule from "./a.mjs";
export let loaded = false;
export const a = aModule;
loaded = true;
// main.mjs
import * as a from "./a.mjs";
import * as b from "./b.mjs";
console.log("a ->", a);
console.log("b ->", b);

(+) export/import 구문이 포함된 코드는 EMS 환경에서 실행할 수 있으므로 파일 확장자를 .mjs로 변경하고,

      node 실행 옵션에 --experimental-modules 옵션을 추가해주면 된다.

 

 

우선 출력 결과를 먼저 살펴보자.

 

기존 CommonJS의 require() 기법과 다르게 a, b가 서로 완전한 형태를 가지고 있다

 

 

모듈이 적재되는 과정을 앞서 설명한 ESM의 3단계(파싱, 인스턴스화, 평가) 과정을 통해 알아보겠다.

 

 

  1. 파싱 : 실행 파일인 main.js으로 부터 출발하여 import 구문을 모두 찾는다.

 

처음엔 main의 첫 import를, 이후 참조된 a의 import를, 다시 main의 import들도 모두 찾는다.

 

깊이 우선 탐색(dfs)을 생각하면 이해하기 쉬운데(그게 더 어렵나), import를 만날 때마다 참조하는 코드(a)를 들어가서 그 코드의 import를 또 살펴본다(b). 이런 식으로 계속 들어가 더이상 import가 없을 때 바로 그 직전 파일의 다른 import를 탐색하고, 순차적으로 올라와 main의 두번째 import(b)를 찾아서 위 과정을 반복한다. 

 

중간에 이미 방문했던 모듈은 역시 탐색 기법처럼 visited(방문)를 표시하여 다시 방문하지 않도록 한다.

 

깊이 우선 탐색 등 탐색 기법의 가장 큰 특징은, 그래프를 순회하였을 때 최종 결과를 트리(Minimum Spanning Tree = MST) 형태로 반환한다는 것이다. 쉽게 말해, 루트(main)으로 부터 출발해 모든 참조 모듈들을 기존 종속성(그래프)을 제거하여 선형 구조(트리)로 변형하는 것이다.

 

 

   2. 인스턴스화 : 인터프리터가 1단계에서 만들어진 트리의 리프노드(여기선 b)에서 출발해 main으로 거슬러 올라가면서 모든 export된 개체들을 찾고, 이를 exports(Map)에 달아둔다.

 

 

인터프리터는 b에서 출발하여 loaded와 a를 exports.b에 각각 달아두고, a로 넘어가 같은 과정을 수행한다.

여기서는 각 개체의 이름들의 실제 참조 경로(memory trace)만 exports 맵에 저장시키고, 값은 아직 할당하지 않은 상태로 유지한다.

이후 이 참조 경로들을 import를 하는 모듈들에게 전파한다.

 

 

   3. 평가 : 모든 파일의 코드가 실행된다. 1단계에서 얻어진 트리에서 이번엔 후위 깊이 우선 탐색(postorder dfs)으로 실행한다.

 

 

후위 깊이 우선 탐색 순서로 실행시킨다는 의미는, 모든 자식을 다 실행시키고 마지막에 main을 실행시킨다는 의미이다. 

 

먼저 b쪽을 보자.

 

b가 먼저 실행되며, loaded가 false로 평가된다.

b에서 export하는 a는 2단계에서 얻은 a의 참조 경로로 평가된다.

loaded가 true로 바뀌고, 모든 평가가 완료된다.

 

다음은 a다.

 

a가 실행되고, loaded가 false로 평가된다.

a에서 export하는 b는 역시 b의 참조 경로로 평가된다.

loaded가 true로 바뀌고, 모든 평가가 완료된다.

 

 

이렇게 ESM의 참조 모듈들은 모두 참조 경로를 통해 추적이 되고, 순환 종속성이 존재하는 상황에서도 모든 모듈이 다른 모듈의 최신 상태를 유지하고 있음을 알 수 있다. 

 

 

 

.. 꽤 길게 써진 것 같다. ESM쪽은 그림을 그려서 첨부했다면 좀더 나은 설명이 되었을 수도 있을 것 같다...다음엔 꼭...