JavaScript로 개발 중에 코드상으로 아무런 문제가 없어 보이는데 다음과 같은 에러가 발생한다면 그 원인으로 순환 의존성을 의심해 볼 수 있습니다.

TypeError: [변수명] is not a function
ReferenceError: Cannot access '[변수명]' before initialization

순환 의존성이란?

image1

둘 이상의 모듈이 순환으로 참조하여 무한 루프를 형성하는 구조를 말합니다. 순환 의존성이 프로그램상에서 동작하는 방식은 예측하기 쉽지 않기 때문에 가능하면 피해주는 것이 좋습니다.

그런데도 의도치 않게 순환 의존성이 형성될 수 있습니다. 저 역시 코드에 숨어있는 순환 의존성으로 인해 에러의 원인을 찾아 헤맸던 경우가 있었습니다. 당시 원인을 모른 채 그 소스 코드를 github reop에 저장해 두었는데 이후 살펴보니 그 원인은 순환 의존성에 있었습니다.

당시 소스 코드 보기

이번 글에서는 CJS(CommonJS)와 ESM(ES Module)에서 순환 의존성을 어떤 방식으로 처리하고 있는지를 예시와 함께 분석해 보겠습니다. CJS와 ESM의 개요에 대해서는 지난 글을 참고하시길 바랍니다.

예시 코드는 이곳에서 확인할 수 있습니다.

이 글에서 전달하고자 하는 메시지는 “순환 의존성을 피하라”이지, “순환 의존성을 잘 이해하고 활용하라”가 아닙니다.

아래에서 사용할 예시에서 해결 방안으로 제시하는 것들은 순환 의존성의 구조를 유지한다는 것을 가정한 해결 방안입니다. 더 좋은 해결 방안은 순환을 끊고 의존성 구조를 단방향으로 전환하는 것입니다.

CJS의 순환 의존성

require를 따라 순환하여 한 사이클을 돌아온 경우 해당 모듈을 다시 실행하지 않고 미완성된 exports를 반환합니다. 이는 모듈이 무한 루프로 실행되는 것을 방지하기 위함입니다. 그 과정에서 내보내는 변수와 받아오는 변수 간의 연결이 끊어질 수 있습니다.

CJS 예시 1

첫 번째 예시는 Module A와 Module B가 서로를 참조하여 순환 의존성이 발생하는 상황입니다. Module A에서 변수 a가 선언되고, Module B에서 변수 b가 선언됩니다. 각각의 모듈에서 ab를 모두 불러와서 값을 출력해 봅니다.

/* moduleA.cjs */
const { b } = require("./moduleB.cjs");

const a = 100;

exports.a = a;

console.log(`In module A, a == ${a} and b == ${b}`);

/* moduleB.cjs */
const { a } = require("./moduleA.cjs");

const b = 5000;

exports.b = b;

console.log(`In module B, a == ${a} and b == ${b}`);

/* index.js */
require("./moduleA.cjs");

예상 출력 결과

In module B, a == 100 and b == 5000
In module A, a == 100 and b == 5000

실제 출력 결과

In module B, a == undefined and b == 5000
In module A, a == 100 and b == 5000

실행 과정

문제가 나타나는 지점까지의 실행 과정을 한 줄 한 줄 살펴봅시다.

  • Module A
  • 실행: const { b } = require("./moduleB.cjs");
    • Module B
    • 실행: const { a } = require("./moduleA.cjs");
      • Module A
      • 모듈 중복 호출로 인해 미완성 exports 반환
        반환: {}
    • const { a } = {}; 에서 a의 값은 undefined
    • 실행: const b = 5000;
    • 실행: exports.b = b;
    • 실행: console.log(`In module B, a == ${a} and b == ${b}`);
      출력: In module B, a == undefined and b == 5000

Module A가 중복으로 호출되는 시점에서 코드를 다시 실행하지 않고 기존에 Module A가 실행된 시점까지의 exports만 반환합니다. 해당 시점에서는 exports.a가 할당되기 전이기 때문에 undefined가 됩니다.

해결 방안

이 문제를 해결하기 위해서는 Module A에서 require 호출 시점에 미리 exports.a가 할당되어 있도록 수정합니다. 즉, requireexports 이후로 순서를 바꿉니다.

수정된 코드:

/* moduleA.cjs */
// 기존 require 위치

const a = 100;

exports.a = a;

// 수정된 require 위치
const { b } = require("./moduleB.cjs");

console.log(`In module A, a == ${a} and b == ${b}`);

CJS 예시 2

예시 2는 함수 a(), b(), A()가 정의되어 b()a()를 호출하고 A()b()를 호출합니다. a()A()는 Module A에서 정의되고 b()는 Module B에서 정의됩니다.

/* moduleA.cjs */
const { b } = require("./moduleB.cjs");

exports.a = () => {
  console.log("function 'a' executed");
};

exports.A = () => {
  b();
  console.log("function 'A' executed");
};

/* moduleB.cjs */
const { a } = require("./moduleA.cjs");

exports.b = () => {
  a();
  console.log("function 'b' executed");
};

/* index.js */
const { A } = require("./moduleA.cjs");
A();

예상 출력 결과

function 'a' executed
function 'b' executed
function 'A' executed

실제 출력 결과

TypeError: a is not a function

실행 과정

문제가 나타나는 지점까지의 실행 과정을 한 줄 한 줄 살펴봅시다.

  • Module A
  • 실행: const { b } = require("./moduleB.cjs");
    • Module B
    • 실행 const { a } = require("./moduleA.cjs");
      • Module A
      • 모듈 중복 실행으로 인해 미완성 exports 반환
        반환: {}
    • const { a } = {} 에서 a의 값은 undefined
    • 실행: exports.b = () => { a(); console.log("b"); };
      a의 값을 적용하면 exports.b = () => { (undefined)(); console.log("b"); };가 된다.

변수 b의 연결이 끊어져서 undefined가 되고, 이후 b()A() 내부에서 호출될 때 undefined는 함수가 아니기에 TypeError가 발생합니다.

해결 방안 1

첫 번째 예시와 동일한 방법으로 해결할 수 있습니다. require 호출 시점을 exports 뒤로 미룹니다.

수정된 코드:

/* moduleA.cjs */
// 기존 reqruie 위치

exports.a = () => {
  console.log("function 'a' executed");
};

exports.A = () => {
  b();
  console.log("function 'A' executed");
};

// 수정된 require 위치
const { b } = require("./moduleB.cjs");

해결 방안 2

예시 2는 예시 1과는 다르게 top-level의 즉시 실행 코드가 아닌 함수로 이뤄져 있습니다. 실무에서도 대부분의 모듈은 이런 형태로 구성됩니다. 함수를 선언하면 어떤 코드가 실행될지 정의만 하고 즉시 실행하지는 않는다는 점을 활용할 수 있습니다.

수정된 코드:

/* moduleB.cjs */
// 기존 코드
// const { a } = require("./moduleA.cjs");
// 수정된 코드
const moduleA = require("./moduleA.cjs");

exports.b = () => {
  // a() -> moduleA.a()
  moduleA.a();
  console.log("function 'b' executed");
};

기존 코드에서 require("./moduleA.cjs")의 반환 값을 구조 분해 할당을 하지 않고, 전체 반환 값을 하나의 변수로 할당합니다. 이는 exports 객체의 속성 하나만을 복사하느냐, 객체 자체를 복사하느냐의 차이입니다. JavaScript에서 객체를 복사하는 경우 내부 값 전체의 복사가 아닌 레퍼런스 값만을 복사합니다. require 하는 시점에는 moduleA.a의 값은 undefined이지만, moduleA의 레퍼런스 값으로 연결성은 유지되기에 이후 정의되는 a가 반영될 수 있습니다.

ESM의 순환 의존성

ESM에서는 파싱 과정에서 exportimport 변수들에 대한 메모리 할당이 먼저 이뤄집니다. 그렇기에 CJS와는 다르게 내보내는 변수와 받아오는 변수의 연결성이 끊어지는 상황은 발생하지 않습니다. 다만 모듈의 실행 순서에 따라 값이 할당되기 전에 접근을 시도하는 상황이 발생할 수 있으며, 이 경우 ReferenceError가 발생합니다.

ESM 예시 1

CJS 예시 1과 동일한 코드이며, ESM 문법으로만 변형되었습니다.

/* moduleA.mjs */
import { b } from "./moduleB.mjs";

export const a = 100;

console.log(`In module A, a == ${a} and b == ${b}`);

/* moduleB.mjs */
import { a } from "./moduleA.mjs";

export const b = 5000;

console.log(`In module B, a == ${a} and b == ${b}`);

/* index.js */
import "./moduleA.mjs";

예상 출력 결과

In module B, a == 100 and b == 5000
In module A, a == 100 and b == 5000

실제 출력 결과

console.log(`In module B, a == ${a} and b == ${b}`);
                                 ^

ReferenceError: Cannot access 'a' before initialization

실행 과정

문제가 나타나는 지점까지의 실행 과정을 살펴봅시다.

파싱 과정에서 import 문을 따라 아래와 같이 모듈의 관계가 구성됩니다.

index.js -> moduleA.mjs -> moduleB.mjs -> moduleA.mjs (중복)

중복되는 모듈을 제외하고 가장 하위 모듈인 Module B가 먼저 실행되고, 그다음 Module A가 실행됩니다.

Module B의 실행 과정:

  • Import 변수 선언: import { a } from "./moduleA.mjs";
    Module A는 아직 실행 전이기에 a는 아직 값이 할당되기 전이다.
  • 실행: export const b = 5000;
  • 실행: console.log(`In module B, a == ${a} and b == ${b}`);
    아직 할당 전의 변수인 a에 접근을 시도한다.
    에러 발생: ReferenceError: Cannot access 'a' before initialization

해당 에러 메시지는 선언한 변수에 값이 할당되기 전에 접근을 시도하는 경우에 나타납니다. 예를 들면 아래의 경우죠.

console.log(x); // ReferenceError: Cannot access 'x' before initialization
const x = 10;

/* 또는 */

let x;
console.log(x); // ReferenceError: Cannot access 'x' before initialization
x = 10;

/* var로 선언된 변수의 경우 hoisting 원리에 의해 undefined로 나타난다. */

console.log(x); // undefined
var x = 10;

즉, import를 해왔지만, 해당 모듈이 실행되기 전이라면 해당 변수가 선언은 되었지만, 아직 값이 할당되기 전의 상태라고 간주하는 겁니다.

이러한 문제는 순환 의존성에서만 나타나는 문제입니다. 순환 의존성이 아니라면 아직 실행되지 않은 모듈을 import 하는 상황은 발생하지 않습니다.

해결 방안

문제가 발생하는 코드가 변수 할당 이후에 실행되도록 바꿔줍니다.

/* moduleB.mjs */
import { a } from "./moduleA.mjs";

export const b = 5000;

// setTimeout을 적용
setTimeout(() => {
  console.log(`In module B, a == ${a} and b == ${b}`);
}, 0);

setTimeout에 timeout을 0ms로 하면 마치 즉시 실행할 것처럼 보이지만, 실제로는 Callback Queue에 들어가서 Call Stack에 올라와 있는 명령이 다 끝나기를 기다립니다. 따라서 실행 시점이 뒤로 미뤄집니다. 자세한 내용은 Event Loop 관련 내용을 찾아보시기를 바랍니다.

ESM 예시 2

CJS 예시 2와 동일한 코드이며, ESM 문법으로만 변형되었습니다.

/* moduleA.mjs */
import { b } from "./moduleB.mjs";

export const a = () => {
  console.log("function 'a' executed");
};

export const A = () => {
  b();
  console.log("function 'A' executed");
};

/* moduleB.mjs */
import { a } from "./moduleA.mjs";

export const b = () => {
  a();
  console.log("function 'b' executed");
};

/* index.js */
import { A } from "./moduleA.mjs";
A();

예상 출력 결과

function 'a' executed
function 'b' executed
function 'A' executed

실제 출력 결과

function 'a' executed
function 'b' executed
function 'A' executed

이번 예제에서는 정상적으로 동작합니다. 모듈 내의 top-level에서 즉시 실행하는 코드가 없고 전부 함수의 형태로 존재하기 때문입니다. ESM은 모듈 간의 변수 연결성은 확실히 보장되기에 실행 순서에만 문제가 없으면 정상적으로 동작합니다.

결론

앞서 언급했듯이 위의 예시에서 해결 방안으로 제시하는 것들은 순환 의존성의 구조를 유지한다는 것을 가정한 해결 방안이며, 이러한 해결 방안 역시 좋지 않은 형태입니다. 더 좋은 해결 방안은 순환을 끊고 의존성 구조를 단방향으로 전환하는 것입니다. 순환 의존성은 가능하면 피합시다.

업데이트:

댓글남기기