Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

Node.js의 순환 의존성

코드를 모듈화해서 작성하다 보면 서로 간에 의존성을 갖게 되는데 이러다 보면 순환 의존성을 가질 수 있다. A 파일이 B를 참조하는데 B 파일이 다시 A 파일을 참조할 수 있고 좀 더 복잡하게는 의도치 않게 A가 B를 참조하고 B가 C를 참조하는데 C가 다시 A를 참조할 수 있다. 이를 순환 의존성(Circular Dependencies)라고 부른다. 순환 의존성이 있으면 서로 계속 호출하는 무한 호출 상황이 벌어지거나 언어나 런타임에 따라 오류를 발생시키지도 한다.

Node.js에서는 순환 의존성을 허용한다. 그래서 require()로 각 파일을 불러와서 사용하다가 순환 의존성을 가지더라도 런타임에서 오류를 뱉어내거나 하는 경우는 발생하지 않는다. 물론 기본적으로 코드가 순환 의존성을 가진다는 것은 각 모듈의 관심사가 제대로 분리되지 않았다는 신호로 볼 수 있고 일차적으로 이 부분을 다시 점검해 보아야 한다. 하지만 어쩔 수 없이 순환 의존성을 갖게 되는 때도 있고 설계가 잘못된 것은 인지했지만 바로 전체를 리팩토링하긴 어려운 때도 있다.

Node.js를 오랫동안 써왔고 순환 의존성을 허용하는 것도 알고 있었지만 사실 많이 겪어보진 않았고 그렇다 보니 Node.js의 순환 의존성을 처리하는 방식에 대해서 깊게 보지 않았다. 전에 프로젝트를 하면서 약간 겪었었는데 그때 좀 바쁘기도 해서 자세히 보지 못했고 순환 의존성을 끊는 방향으로 처리했다. 이번에 또 순환 의존성 문제를 만났는데 해결이 쉽지 않아서 Node.js가 순환 의존성을 처리하는 방법을 자세히 보게 되었다.

앞에 얘기한 대로 순환 의존성이 발생하면 일차적으로는 모듈화의 설계를 의심해봐야 하고 이로써 해결할 수 있다면 재설계를 해야 한다. 하지만 어쩔 수 없는 때도 있는데 대표적으로 모델 간의 관계를 맺어줄 때이다. 예를 들어 posts.jscomments.js가 있고 각 파일에서 모델을 정의하고 있다고 할 때(어떤 ORM 라이브러리를 사용하든지) posts.js에서는 comments.js의 모델 객체를 가져와서 hasMany로 선언해야 하고 comments.js에서는 posts.js의 모델 객체를 가져와서 belongsTo로 선언해야 한다. 이런 경우 설계의 문제가 아니지만 모듈 간에 순환 의존성이 생기게 된다. ORM에 따라서 관계정의 부분에서 이러한 문제를 해결하는 방법 등을 제공하기도 하지만 기본적으로 순환 의존성이 생기기 좋은 구조다.

CommonJS의 Modules

현재 Node.js는 CommonJS의 모듈을 따르고 있다. 이제는 ES2015에 Modules 명세가 있으므로 이 명세에 호환성을 맞추기 위해서 준비하고 있지만, 현재는 CommonJS의 Modules이다. require로 모듈을 불러오고 불리는 곳에서는 module.exportsexports로 외부에 인터페이스를 여는 방식이 CommonJS의 Modules이다.

순환 의존성을 설명하기 전에 CommonJS의 Modules를 좀 더 살펴보자. Node.js에서 대부분의 I/O는 비동기로 동작하지만 require는 동기로 동작한다. Node.js가 코드를 실행하다가 const s = require('./something');를 만나면 something.js 파일을 모두 실행할 때까지 이 파일의 실행은 멈추고 그 결과를 받아서 s에 할당한 뒤 다음 줄의 코드를 실행한다.

불리는 파일 쪽에서 원하는 인터페이스를 지정할 때는 module.exportsexports를 사용한다.

// 하나의 함수를 공개하는 경우
module.exports = () => {};

// 객체를 공개하는 경우
module.exports = {
  doSomething: () => {}
}

JavaScript 파일 하나당 module.exports는 한 번만 사용 가능하다. 여러 번 작성하더라도 마지막의 module.exports만 유효하다. 실제로 require()를 실행할 때 반환되는 객체는 module.exports의 객체가 된다. 이는 모듈에 이미 정의된 객체이므로 빈 js 파일을 require()해도 {}가 나오게 된다.

반면 exportsmodules.exports의 별칭 같은 느낌으로 동작하고 한 객체를 만들어서 반환하는 대신 여러 번 사용할 수 있는 용도로 사용한다.

exports.b = () => {};
exports.c = () => {};

이 코드를 실행하면 {b: [Function], c: [Function]}가 반환된다.

module.exports = { a: 1 };

exports.b = 2;

그러면 위 코드를 require()하면 어떻게 될까? module.exports가 실제 반환되는 객체이므로 이때는 { a: 1 }가 된다.

나는 module.exports를 더 선호하는 편이다. 해당 파일이 어떤 인터페이스를 노출하는지 더 명확하다고 생각하고 코드를 보기도 쉽다고 생각하기 때문이다.

Node.js의 순환 의존성

Node.js가 순환 의존성을 어떻게 처리하는지 보기 위해 간단한 예제를 보자.

// a.js
console.log('a.js 시작');

const b = require('./b');

module.exports = {
  call: () => {
    console.log('a.js의 call에서의 b: ', b);
  }
};
// b.js
console.log('b.js 시작');

const a = require('./a');

module.exports = {
  call: () => {
    console.log('b.js의 call에서의 a: ', a);
  }
};

위 두 파일이 있다고 해보자. a.jsb.js를 사용하고 b.js에서는 a.js를 불러오고 있다. 두 파일 모두 module.exports를 사용하고 있고 각 call 함수에서 불러온 객체를 출력하고 있다.

이 상황에서 a.js이 실행된다고 생각해보자. a.js에서 const b = require('./b');를 만나면 코드 실행을 멈추고 b.js를 실행한다. b.js에서 const a = require('./a');를 만나면 코드 실행을 멈추고 a.js를 실행해야 하는데 a.js를 실행하는 중이었으므로 여기서 문제가 생긴다. 여기서 서로 계속 호출하는 등의 문제가 생길 수 있지만 Node.js에서는 이런 문제는 생기지 않는다. 실제로 실행해 보자.

// index.js
const a = require('./a');
const b = require('./b');

a.call();
b.call();

index.js에서는 a.js를 먼저 불러오고 b.js를 불러온다. Node.js를 require()한 파일을 캐싱하므로 실제로는 require('./a')부분에서 두 파일이 모두 읽히고 require('./b')에서는 캐싱된 내용을 가져오게 된다.

$ node index.js
a.js 시작
b.js 시작
a.js의 call에서의 b:  { call: [Function: call] }
b.js의 call에서의 a:  {}

의도대로라면 b.js에서도 { call: [Function: call] }가 나와야 하는데 실제로는 {}가 나왔다. 이게 Node.js가 순환 의존성을 처리하는 방식인데 좀 더 자세히 설명해 보면 다음과 같다.

  1. index.js에서 require('./a')를 만나고 코드 실행을 멈춘 뒤 a.js를 실행한다.
  2. a.js를 실행하면서 require('./b')를 만나고 코드 실행을 멈춘 뒤 b.js를 실행한다.
  3. b.js를 실행하면서 require('./a')를 만나고 코드 실행을 멈춘 뒤 a.js를 실행한다.
  4. 이때 이미 a.js는 실행 중 멈춘 상태라서 실행을 할 수 없다. 그러면 module.exports의 기본 객체인 {}를 반환한다.
  5. b.jsa{}를 할당하고 코드 실행을 마무리한다.
  6. a.jsb.js가 내보낸 객체를 받아서 b에 할당하고 이어서 코드 실행을 한다.

이렇게 동작하기 때문에 순환 의존성이 있다고 하더라도 실행할 때 오류가 발생하지 않는다. 물론 b.js에서 받은 객체에는 call 함수가 없으므로 이를 실행하려고 하면 함수가 없다고 오류가 발생할 것이다.

실행하는 파일인 index.js에서 require의 순서를 바꿔보자.

// index.js
const b = require('./b');
const a = require('./a');


a.call();
b.call();

이번엔 require('./b')를 먼저 실행했다. 이렇게 하면 결과가 어떻게 나올까.

$ node index.js
b.js 시작
a.js 시작
a.js의 call에서의 b:  {}
b.js의 call에서의 a:  { call: [Function: call] }

아까와는 반대로 이번에는 a.js에서의 b가 빈 객체가 되었다.

순환 의존성의 어려운 점

순환 의존성을 지원하기는 하지만 실제로 발생하면 문제가 꽤 있다.

  • 실행할 때 오류가 발생하는 게 아니므로 순환 의존성이 존재한다고 하더라도 알아차리기가 어렵다.
  • 위에서 본대로 a <-> b 관계에서 require('./a')를 하면 a.js는 정상적인 객체를 받고 b.js가 빈객체를 받는다. 그러므로 테스트 코드를 다 작성하더라도 각 require하는 쪽에서는 보통 문제가 발생하지 않는다. 그래서 애플리케이션을 돌릴 때는 XXX in not a function같은 오류를 볼 수 있지만, 해당 파일을 실제로 돌려보면 아주 잘 돌아간다.
  • index.js에서 require의 순서를 바꿨을 때 빈 객체를 받는 파일이 달라진 것처럼 node로 실행하는 엔트리 파일에서 require하는 순서에 따라 어느 파일에서 빈 객체로 오류가 발생할지 알 수가 없다. require 순서를 바꾸는 것만으로도 다른 곳에서 오류가 발생할 수 있다.

이는 꽤 고통스러운 문제이므로 앞에서 말한 대로 순환 의존성을 없애는 것이 가장 좋은 방법이다. 위 예제는 설명을 위해서 간단하게 구성한 것이지만 실제 코드에서는 A -> B -> C -> A처럼 더 복잡한 순환 의존성이 생길 수 있다. 순환 호환성 관련 API의 문제에 대해서 코어 팀에서도 인지하고 있는듯하지만 이 동작을 바꾸면 호환성에 큰 문제가 생기므로 바꾸지 않고 그대로 유지할 가능성이 크다.

순환 의존성 해결책

애플리케이션을 실행할 때 순환 의존성에 문제가 있으면 오류가 발생하지만 이를 겪기 전에 차단하려면 madge 같은 모듈로 순환 의존성을 검사해 볼 수 있다.

$ madge --circular index.js
Processed 3 files (267ms)

✖ Found 1 circular dependency!

1) a > b

이게 큰 해결책은 아니지만, 문제의 가능성이 있는 부분을 확인해 볼 수 있다.

지연 선언

순환 의존성에 문제가 생기는 경우 참조하는 모듈을 module.exprts 보다 나중에 선언해서 해결할 수 있다.

// a.js
console.log('a.js 시작');

module.exports = {
  call: () => {
    console.log('a.js의 call에서의 b: ', b);
  }
};

const b = require('./b'); // 나중에 선언한다.

예제를 위해서 a.js만 변경했다. 보통 require 문을 파일의 상단에 두는 것이 일반적이지만 순환 의존성 해결을 위해서 하단에 선언한다. 이렇게 하면 a.jsmodule.exports를 이미 정의했으므로 b.jsrequire() 할 때 순환 의존성 문제가 발생하지 않는다.

$ node index.js
a.js 시작
b.js 시작
a.js의 call에서의 b:  { call: [Function: call] }
b.js의 call에서의 a:  { call: [Function: call] }

실제로 실행해보면 빈 객체 되신 잘 정의된 것을 볼 수 있다.

물론 이 방법은 모듈 자체에서 참조해서 써야 하거나 하면 사용할 수 없으므로 모든 경우에 사용할 수도 없고 보통 require 문을 파일 상단에 선언한다는 관례를 어겼으므로 동작에는 문제가 없더라도 다른 사람이 코드를 읽을 때 불편할 가능성이 크다.

exports의 사용

앞에서 CommonJS를 설명하면서 module.exportsexports에 대해서 설명했는데 Node.js의 순환 의존성을 좀 더 자세히 말하면 exports로 순환 의존성을 지원한다고 할 수 있다. module.exports API로 순환 의존성을 지원하는 것이 아니라 exports API로 순환 의존성을 지원한다고 보는데 더 맞는다고 할 수 있다. 순환 의존성에 대한 설명은 공식 문서에서도 볼 수 있다.

// a.js
console.log('a.js 시작');
const b = require('./b');

exports.call = () => {
  console.log('a.js의 call에서의 b: ', b);
};
// b.js
console.log('b.js 시작');
const a = require('./a');

exports.call = () => {
  console.log('b.js의 call에서의 a: ', a);
};

두 파일은 module.exports 대신 exports를 사용하도록 바꾸었다.

$ node index.js
a.js 시작
b.js 시작
a.js의 call에서의 b:  { call: [Function] }
b.js의 call에서의 a:  { call: [Function] }

여전히 순환 의존성을 가지고 있지만, 정상적으로 두 파일이 실행되고 의도한 객체를 가지고 있음을 볼 수 있다.

좀 더 설명하자면 Node.js에서 각 모듈인 JavaScript 파일은 하나의 module.exports를 가지는데 앞에서 얘기한 대로 이는 기본이 빈 객체 {}이다. 그래서 모듈 내에서 module.exports를 선언하면 이 객체를 재정의하는 거라고 볼 수 있다. 이 모듈을 require()하는 경우 module.exports 객체가 반환되는데 exports API는 동적으로 이 객체에 프로퍼티를 추가한다. 그러므로 결과적으로 양쪽 모듈이 다 실행이 끝나고 나면 정상적인 객체가 완성되게 된다. 여기서 a.jsb.js 상단에서 require()한 객체를 출력해보면 처음엔 빈 객체로 받은 뒤에 프로퍼티가 채워지는 것을 볼 수 있다.

$ node index.js
a.js 시작
b.js 시작
b.js에서의 a:  {}
a.js에서의 b:  { call: [Function] }
a.js의 call에서의 b:  { call: [Function] }
b.js의 call에서의 a:  { call: [Function] }

이 방식을 사용하는 경우 exports는 항상 프로퍼티를 추가하는 방식이므로, 즉 exports = {}가 아니라 exports.somthing = {}이 되어야 하므로 API가 변경될 수 있지만, 현재 이 방식이 Node.js가 지원하는 순환 의존성이라고 할 수 있다.

(물론 ES6의 Modules가 지원되기 시작하면 어떻게 달라질지는 아직 다 알 수 없다.)

2017/04/10 04:26 2017/04/10 04:26