Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.

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

기술 뉴스 #75 : 17-04-01

웹개발 관련

  • Pretty Printing : 서비스 워커 자바스크립트 라이브러리를 개발하면서 디버깅 목적의 로깅 메시지를 보기가 어려워서 console로 로깅을 더 보기 좋게 하는 방법을 연구한 글이다. console.group()console.groupCollapsed()로 로그 메시지를 그룹으로 묶어주고 보기 좋게 하고 색을 지정해서 읽기 쉽게 지정했다.(영어)
  • You don’t need a fancy framework to use GraphQL with React : React 애플리케이션에서 GraphQL을 이용할 때 Relay나 Apollo를 이용하지 않고 직접 구현해서 사용하는 방법을 설명하는 글이다. 프레임워크가 필요 없는 것은 아니지만, 보통은 필요 이상의 기능이 프레임워크에 포함되어 있으므로 간단한 기능은 직접 구현해서 사용할 수 있다고 설명하고 있다.(영어)
  • 10 React mini-patterns : React를 사용하면서
  • (React Router) v4로 마이그레이션 해보자! : React Router가 v4로 버전업이 올라가면서 기존과는 많이 달라졌는데 v3로 작성된 코드를 v4로 바꾼 과정을 설명한 글이다. v3를 쓰고 있어서 v4로 변경하려고 고민 중이라면 참고할 만하다. 참고로 React Router v4는 완전히 새로 작성되었으므로 프로젝트에 올라온 마이그레이션 문서 요청에 대해 커미터가 단순 마이그레이션으로 올릴 수 없고 새로 고민하면서 코드를 작성해야 한다고 댓글을 달기도 했다.(한국어)
  • Vue.js 살펴보기 : Vue.js의 구조와 간단한 DOM 작성과 컴포넌트 및 이벤트 바인딩을 간단하게 설명하는 글이다.(한국어)
  • (번역) 과연 Vue.js가 앵귤러나 리엑트보다 좋을까? : Vue.js Is Good, But Is It Better Than Angular Or React?의 번역 글로 Vue.js를 간단히 소개하며 특징적인 기능을 Angular나 React와 코드와 간단히 비교하면서 Vue.js가 어떻게 접근하는지를 보여주고 있다. Angular나 React를 해본 적이 있다면 간단히 보면서 Vue.js의 특징을 이해할 수 있다.(한국어)
  • Why WebAssembly is Faster Than asm.js : WebAssembly가 왜 asm.js보다 빠른지를 설명한 글이다. WebAssembly 코드의 사이즈가 작아서 다운로드 시간이 적게 걸리고 파싱 시간도 짧아지면서 asm.js보다 더 많은 CPU 기능을 지원하고 새로 만든 Binaryen 옵티마이저로 속도를 높였다는 설명이다.(영어)

그 밖의 프로그래밍 관련

  • Micro - a microservices toolkit : Go로 작성된 마이크로서비스 툴킷인 Micro를 소개하는 글이다. 마이크로서비스 아키텍처에서는 각 서비스 간의 호출이나 관리가 쉽지 않은데 RPC에 기반을 둔 Micro가 이를 어떻게 해결하는지 설명하고 있다. Sidecar라는 프로젝트로 HTTP 인터페이스도 제공해서 Go가 아닌 다른 언어로도 Micro를 이용할 수 있다.(영어)
  • Docker and OOM(Out Of Memory) Killer : Out of Memory로 Docker 컨테이너가 죽는 현상에 대해서 분석한 글이다. 어떤 과정을 통해서 커널이 Docker 컨테이너를 죽이게 되고 이를 방지하기 위해서는 어떤 옵션을 설정해야 하는지가 잘 나와 있다. 이런 메모리 오류는 추적하기가 쉽지 않은데 자세히 나와 있어서 비슷한 문제를 겪을 때 큰 도움이 될 거 같다.(한국어)

볼만한 링크

  • 회사에서 무엇을 배울 것인가? : 많이들 고민하는 "제너럴리스트가 맞나 스페셜리스트가 맞나"에 대한 주제를 논리적으로 잘 푼 글이다. 이제 T자형 인재보다는 주변 내용을 어느 정도는 알고 있어야 하지만 가장 중요한 것은 배움의 속도이고 그 배움의 속도를 높이기 위해서 별도의 공부도 필요하지만 회사 내에서도 다양한 공부가 필요하다고 설명하고 있다. 제너럴리스트를 원하는 경우 회사에서 직원을 제너럴리스트로 보고 있는지 스페셜리스트로 보고 있는지를 판단할 수 있는 요소까지 설명하고 있다.(한국어)
  • SaaS: 국내 도입이 시급합니다. : SaaS로 수익을 올리는 회사들이 국내에는 많지 않음을 지적하고 SaaS의 접근 방식과 장점을 설명한 글이다. 나는 국내에서 SaaS가 어려운 이유는 시장 규모와 인식 + SaaS가 지역적인 경계가 없는 경우가 많아서라고 생각하므로 도입의 문제라고는 생각하지 않지만, SaaS 업체들이 취하고 있는 접근방법에 대한 설명을 읽어볼 만하다.(한국어)

IT 업계 뉴스

프로젝트

  • guetzli : 구글이 공개한 JPEG로 libjpeg보다 20~30% 적은 용량의 이미지로 인코딩한다.
  • containerd : Docker에서 공개한 컨테이너 런타임
  • Open Arrow : Open color를 만든 heeyeun님이 만든 프로젝트로 폰트마다 다른 화살표의 글리프프때문에 통일된 화살표를 제공하기 위해 화살표 심폴 폰트를 제공하는 프로젝트다.
  • HTTP Status Code Badges : HTTP 상태 코드를 백지 형태로 제공하는 사이트로 Cloudflare를 통해 제공하고 있다.
  • git-standup : 전날 Git에서 어떤 작업을 했는지 모여주는 프로젝트로 멀티 프로젝트에서도 stadup 명령어를 사용하면 전날 커밋한 내역을 한꺼번에 볼 수 있다.
  • travis-watch : 현재 프로젝트의 커밋을 기준으로 Travis CI의 빌드 결과를 터미널에서 볼 수 있는 CLI 프로그램.
  • Reactide : Electron 기반으로 만들어진 React 전용 IDE

버전 업데이트

2017/04/01 22:44 2017/04/01 22:44