JS 모듈 시스템과 순환 참조 문제

landvibe
10 min readFeb 9, 2019

자바스크립트의 모듈 시스템이 동작하는 방식을 이해해보자. 그리고 순환 참조 시 발생할 수 있는 문제와 해결책을 알아보자.

JS Module System and Circular Dependencies

노드를 포함한 자바스크립트 생태계에는 여러 가지 모듈 시스템이 있지만 ES6에 정식으로 포함된 ESM을 기준으로 알아보자. 여기서 설명하는 대부분의 내용은 commonJS와 같은 다른 모듈 시스템에도 해당되는 내용이다.

아래의 모든 코드는 깃헙 저장소에서 확인할 수 있다. 저장소에서 코드를 내려받고 아래 명령어를 실행하자.

npm install
npm start

이제 실습을 위한 준비가 끝났다.

ESM이 동작하는 기본 방식

basic 폴더는 ESM이 동작하는 기본 방식을 설명한다. 폴더에는 5개의 파일이 있고, 그 내용은 아래와 같다.

// index.html
<html>
<head>
<script type="module" src="/basic/index.js"></script>
</head>
</html>
// index.js
import './a.js';
// a.js
import { sayHello } from './b.js';
import { sayHello2 } from './c.js';
console.log('module_a');
sayHello();
sayHello2();
// b.js
console.log('module_b');
export const sayHello = () => {
console.log('hello~!');
}
// c.js
import { sayHello } from './b.js';
console.log('module_c');
export const sayHello2 = () => {
sayHello();
sayHello();
}

브라우저가 index.html을 실행할 때 콘솔에 출력되는 로그를 생각해보자.

http://localhost:5000/basic에 접속하면 출력되는 로그를 확인할 수 있다.

module_b
module_c
module_a
hello~!
hello~!
hello~!

코드가 실행되는 순서는 다음과 같다. 참고로 모듈을 평가한다는 의미는 해당 파일의 코드를 위에서부터 순서대로 실행한다는 의미다.

  • index.js 모듈이 실행될 때 a.js 모듈을 평가(evaluation)한다.
  • a.js에서 b.js 모듈을 평가한다.
  • b.js 모듈의 module_b 로그가 출력된다.
  • b.js 모듈은 sayHello 함수를 내보내고 평가를 종료한다.
  • a.js에서 c.js 모듈을 평가한다.
  • c.js에서 b.js 모듈을 가져올 때는 b.js 모듈이 다시 평가되지 않는다.
  • c.js 모듈의 module_c 로그가 출력된다.
  • c.js 모듈은 sayHello2 함수를 내보내고 평가를 종료한다.
  • a.js 모듈의 module_a 로그가 출력된다.
  • a.js 모듈에서 sayHello, sayHello2 함수를 호출하고 평가를 종료한다.

여기서 주목할 점은 각 모듈은 최초 한 번만 평가된다는 점이다. b.js 모듈은 두 곳에서 import 하지만 한 번만 평가된다.

순환 참조

자바스크립트 모듈 시스템에서는 순환 참조를 허용한다. cd-pass 폴더는 순환 참조를 설명한다.

// index.js
import './a.js';
// a.js
import { sayHello } from './b.js';
export const NAME = 'mike';
console.log('module_a');
sayHello();
// b.js
import { NAME } from './a.js';
console.log('module_b');
export const sayHello = () => {
console.log('hello~!', NAME);
};

a.js 모듈과 b.js 모듈은 서로를 참조하지만 위 코드는 에러 없이 실행된다. http://localhost:5000/cd-pass에 접속하면 출력되는 로그를 확인할 수 있다.

module_b
module_a
hello~! mike

코드가 실행되는 순서는 다음과 같다.

  • index.js 모듈이 실행될 때 a.js 모듈을 평가한다.
  • a.js 모듈에서 b.js 모듈을 평가한다.
  • b.js 모듈에서 a.js 모듈을 가져온다(a.js 모듈은 평가되지 않는다).
  • b.js 모듈의 module_b 로그가 출력된다.
  • b.js 모듈은 sayHello 함수를 내보내고 평가를 종료한다.
  • a.js 모듈은 NAME 변수를 내보낸다.
  • a.js 모듈의 module_a 로그가 출력된다.
  • a.js 모듈에서 sayHello 함수를 호출하고 평가를 종료한다.

b.js 모듈에서 a.js 모듈을 가져온 시점에는 아직 a.js 모듈이 NAME 변수를 내보내지 않았다. 그런데 sayHello 함수는 `hello~! undefined`를 출력하지 않고 어떻게 `hello~! mike`를 출력한 것일까?

모든 모듈은 모듈 객체를 갖고 있다. 그리고 모듈이 내보내는 변수와 함수는 모듈 객체에 추가된다. sayHello 함수에서 mike를 출력할 수 있는 이유는 NAME 변수에 접근할 때 모듈 객체로부터 해당 값을 가져오기 때문이다. 즉, b.js 모듈의 sayHello 함수의 코드는 아래와 같다고 이해할 수 있다.

export const sayHello = () => {
console.log('hello~!', aModuleObject.NAME);
};

위에서 코드가 실행되는 순서를 설명할 때 `내보낸다`라는 표현은 사실 `모듈 객체에 추가된다`라고 이해할 수 있다. 모듈 객체를 통해서 코드의 실행 순서를 다시 설명하면 아래와 같다.

  • index.js 모듈이 실행될 때 a.js 모듈을 평가한다.
  • a.js 모듈에서 b.js 모듈을 평가한다.
  • b.js 모듈에서 a.js 모듈을 가져온다(a.js 모듈은 평가되지 않는다).
  • b.js 모듈의 module_b 로그가 출력된다.
  • b.js 모듈은 sayHello 함수를 bModuleObject에 추가하고 평가를 종료한다.
  • a.js 모듈은 NAME 변수를 aModuleObject에 추가한다.
  • a.js 모듈의 module_a 로그가 출력된다.
  • a.js 모듈에서 bModuleObject.sayHello 함수를 호출하고 평가를 종료한다.

순환 참조의 문제 1

순환 참조가 허용되지만 잘못 사용하면 에러가 발생할 수 있다. cd-fail-1 폴더는 순환 참조에서 에러가 발생하는 경우를 설명한다.

// index.js
import './a.js';
// a.js
import { sayHello } from './b.js';
console.log('module_a');
sayHello();
export const NAME = 'mike'; ❶
// b.js
import { NAME } from './a.js';
console.log('module_b');
export const sayHello = () => {
console.log('hello~!', NAME);
};

이전 코드와 다른 점은 NAME 변수를 내보내는 시점(❶)이 sayHello 함수 호출 이후라는 점이다. sayHello 함수를 호출할 때는 aModuleObject에 NAME 속성이 없으므로 에러가 발생한다.

이 문제는 단순히 NAME 변수를 내보내는 코드를 이전처럼 위로 올려주면 된다.

자바스크립트는 객체에서 존재하지 않는 속성을 가져올 때 에러가 발생하지 않고 undefined가 반환된다. 하지만 ESM에서는 모듈에서 없는 속성을 가져올 때 에러가 발생한다. 명시적으로 에러가 발생하기 때문에 순환 참조 문제를 빠르게 인식할 수 있다.

commonJS에서는 일반적인 객체처럼 undefined가 반환되고, 웹팩으로 번들링하면 마찬가지로 undefined가 반환된다. 명시적으로 에러가 발생하지 않기 때문에 순환 참조 문제를 쉽게 알아차리기 힘들다. 따라서 commonJS나 웹팩을 사용하는 프로젝트에서 순환 참조 문제를 만나면 ‘아~ 이게 순환 참조 때문이구나’라고 깨닫는 것이 중요하면서 힘든 일이다.

순환 참조의 문제 2

cd-fail-2 폴더는 순환 참조에서 에러가 발생하는 또 다른 경우를 설명한다.

// index.js
import './b.js';
// a.js
import { sayHello } from './b.js';
export const NAME = 'mike';
console.log('module_a');
sayHello();
// b.js
import { NAME } from './a.js';
console.log('module_b');
export const sayHello = () => {
console.log('hello~!', NAME);
};

cd-pass 폴더의 코드와 다른 점은 index.js에서 a.js 모듈이 아니라 b.js 모듈을 가져온다는 점이다. 코드가 실행되는 순서는 다음과 같다.

  • index.js 모듈이 실행될 때 b.js 모듈을 평가한다.
  • b.js 모듈에서 a.js 모듈을 평가한다.
  • a.js 모듈에서 b.js 모듈을 가져온다(b.js 모듈은 평가되지 않는다).
  • a.js 모듈은 NAME 변수를 aModuleObject에 추가한다.
  • a.js 모듈의 module_a 로그가 출력된다.
  • a.js 모듈에서 bModuleObject.sayHello 함수 호출을 시도하지만 해당 함수는 존재하지 않아서 에러가 발생한다.

이 문제는 index.js 파일에서 a.js 모듈을 가져오도록 수정하면 해결된다. 즉, 순환 참조가 존재하는 경우 모듈의 평가 순서가 중요하다. 여기서 보여준 예제는 간단하기 때문에 쉽게 문제를 해결했지만, 실제 프로젝트에서 순환 참조 문제를 만나면 하루 종일 (혹은 며칠간) 시달릴 수 있다.

순환 참조 문제를 해결하기 위해 코드를 이리저리 움직이다 보면 운 좋게 에러가 사라질 수 있다. 코드를 이렇게 저렇게 변경하니까 에러가 사라졌다. 그런데 이게 왜 되는 건지 모르겠다… 이런 경험 한 번씩 있을 거다.

순환 참조 문제 해결하기

순환 참조 문제는 모듈의 평가 순서를 정해주면 대부분 해결된다. 여기서 설명하는 방법은 immer와 mobx를 만든 Michel Weststrate이 설명한 방법이다.

그 방법은 다음과 같다. 모듈의 평가 순서를 정의하는 파일을 만든다. 그리고 모듈을 가져올 때는 항상 그 파일로부터 가져온다. cd-solution 폴더에서 이 방법으로 구현된 코드를 확인할 수 있다.

// index.js
import { NAME, sayHello } from './modules.js'; ❶
// modules.js
export * from './b.js';
export * from './a.js';
// a.js
import { sayHello } from './modules.js'; ❷
export const NAME = 'mike';
console.log('module_a');
sayHello();
// b.js
import { NAME } from './modules.js'; ❸
console.log('module_b');
export const sayHello = () => {
console.log('hello~!', NAME);
};

modules.js 파일에서 모듈의 평가 순서를 결정한다. 모듈을 가져올 때(❶❷❸)는 항상 modules.js 파일을 통해서 가져온다. 이렇게 하면 순환 참조에서 모듈의 평가 순서 때문에 발생하는 에러를 방지할 수 있다.

규모가 큰 프로젝트에서 모든 모듈을 한 파일로 모은 다는 것은 말이 안 된다. 대부분의 프로젝트에서 common 등의 이름을 가진 폴더로 공통 모듈을 모아놓을 것이다. common 폴더에 있는 모듈의 평가 순서만 정해줘도 순환 참조 문제의 상당 부분을 해결할 수 있다.

--

--