Outsider's Dev Story

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

기술 뉴스 #76 : 17-04-15

웹개발 관련

  • Twitter Lite and High Performance React Progressive Web Apps at Scale : Twitter Lite를 React를 사용해서 PWA로 만들면서 성능 개선을 한 얘기를 정리해서 보여주고 있다. 코드 분리를 통한 성능 개선과 성능 병목 부분을 해결한 부분, React에 맞게 최적화한 부분 등 다양하게 개선 지점을 설명하고 있고 개선 전과 개선 후의 차이를 보여준다.(영어)
  • Chrome headless mode : Chrome 59 버전에서 서버 등에서 headless 모드를 지원하기 시작했다. 이 소식 덕분에 Phantom.js 메인테이너는 프로젝트 관리를 그만두겠다고 선언했다.(영어)
  • ES6 modules support lands in browsers: is it time to rethink bundling? : Safari부터 ES6 modules가 들어오기 시작했으므로 기존에 프로덕션을 위해서 JavaScript 파일을 번들링하는 대신 ES6 modules를 도입할 준비를 해야 한다고 활용방법을 설명한 글이다. Safari Preview 버전으로 ES6 modules가 어떻게 동작하고 ES6 modules를 지원하지 않는 브라우저까지 함께 지원하려면 어떻게 해야 하는지 실용적인 예제와 함께 설명하고 있다.(영어)
  • Retiring Octane : 자바스크립트 벤치마크 도구인 Octane이 2012년에 릴리스 된 후 자바스크립트 엔진 생태계에 좋은 영향을 끼쳤지만, 현재는 오히려 Octane의 점수를 올리려고 오히려 실제 웹 애플리케이션에는 성능 저하가 발생할 수 있는 최적화가 많아져서 더이상 도움이 안 된다고 생각하고 Octane을 퇴출하기로 결정했다.(영어)
  • TypeScript at Slack : Slack이 자사의 Electron에 기반을 둔 데스크톱 애플리케이션의 코드 베이스를 TypeScript로 갈아타게 된 과정을 설명한 글이다. TypeScript가 JavaScript의 슈퍼 셋이므로 기존 코드를 TypeScript 기반으로 바꾸고 점진적으로 타입을 적용해 가는 데 어려움이 없었고 정적 분석을 통해서 기존에 몰랐던 많은 버그를 발견했다는 내용이다.(영어)
  • Nginx reaches 33.3% web server market share while Apache falls below 50% : 웹서버 점유율에서 Nginx가 33.3%까지 올라왔고 Apache 웹서버가 50% 이하까지 내려왔다는 분석 글이다.(영어)
  • FedEx SoundTrack : FedEx에서 택배의 출발지와 도착지점을 선택하면 배송되는 과정을 시각적으로 보여주면서 사운드까지 그에 맞춰서 보여주는 사이트이다. 택배가 이동하는 과정을 재미있게 볼 수 있게 웹으로 아주 잘 만든 사이트이다.(영어)

그 밖의 프로그래밍 관련

  • 네 Python은 느립니다, 하지만 저는 신경쓰지 않습니다 : Python이 느리다는 비판이 많지만 실제로 회사에서 중요한 것은 언어의 속도가 아니라 개발자가 코드를 작성하는 시간이 더 중요하다고 얘기하는 글이다. 그 근거로 사업에서 중요한 것은 언어의 성능보다는 서비스 출시를 앞당기는 것이 중요하므로 개발자의 생산성이 더 중요하고 Python이 객관적으로 생산성이 좋다는 근거를 보여주고 마이크로서비스도 속도보다는 개발속도를 높이기 위한 것이라고 설명하고 있다. 내 주력언어가 Python은 아니지만, 이 글에서 주장하는 내용에 동의한다.(한국어)
  • Learn Redis the hard way (in production) : trivago에서 2010년부터 Redis를 사용하면서 개선해 나간 과정을 설명한 글이다. 트레픽이 증가하면서 connection과 timeout 오류를 겪기 시작했고 이 문제를 해결하기 위해서 다양한 시도과정도 잘 나와 있고 이 문제를 어느 정도 해결한 뒤에 싱글 스레드인 Redis에서 문제를 일으키는 명령어를 정리하고 twemproxy를 도입하고 샤딩하는 과정까지 Redis의 동작 방식을 차근차근 이해해가는 과정과 이러한 개선작업이 어떤 효과가 있는지 잘 나와 있다.(영어)
  • SSH and terminal background : 터미널에서 SSH로 서버에 접속할 때 확실히 인지할 수 있도록 ~/.ssh/config를 수정해서 SSH 접속 시 배경색을 바꾸도록 하는 팁을 설명한 글이다. 서버 왔다 갔다 할 때 헷갈리는 경우가 많으므로 유용한 팁이다.(영어)

볼만한 링크

  • Source Han Serif 본명조 : 본고딕에 이어 본명조 폰트가 오픈소스로 나왔다. 같은 폰트로 구글에서는 Noto Serif CJK라고 나왔다.(한국어)
  • 다국어 상황을 고려한 UI 디자인 : Dropbox의 디자이너인 John Saito가 쓴 글의 번역글로 디자인을 할 때 다국어를 적용하면 디자인에 문제가 생기는 것을 방지하기 위해 사전에 어떤 고려를 해야 하는지를 정리한 글이다. 글자 수가 달라지는 것을 고려하기 위해 글자 수를 계산할 수 있는 팁도 설명하고 언어마다 어순이 달라지므로 문장에 UI 요소를 섞거나 특정 지역의 메타포를 적용하지 않는 등의 팁이 정리되어 있다.(한국어)

IT 업계 뉴스

프로젝트

  • Latency : 도메인을 입력하면 대륙별로 지연시간을 시각적으로 보여주는 웹사이트.
  • prettier : JavaScript 포매터
  • AutoDraw : 구글의 실험적인 프로젝트로 사람이 그리고자 한 그림을 찾아서 더 좋은 모양으로 그려주는 웹사이트.
  • ReactXP : Microsoft에서 만든 크로스 플랫폼 앱을 만들 수 있는 라이브러리로 React Native 기반이다.
  • Nuxt.js : Next.js처럼 서버 렌더링이 가능한 Vue.js 애플리케이션을 만들 수 있는 프레임워크

버전 업데이트

2017/04/15 22:43 2017/04/15 22:43

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