node.js의 module.exports와 exports

😢 이 페이지는 다음 주소로 변경될 예정입니다.

lazlojuly의 글 Node.js module.exports vs. exports을 번역했다.


node.js의 module.exports와 exports

(노트: 이 글은 Node.js 6.1.0 릴리즈 이후에 작성되었습니다.)

요약

  • module.exportsrequire() 함수를 사용했을 때 반환 받는 변수라고 생각해봅시다. 비어 있는 객체가 기본값이며 어떤 것으로도 자유롭게 변경할 수 있습니다.
  • exports 자체는 절대 반환되지 않습니다! exports는 단순히 module.exports를 참조하고 있습니다. 이 편리한 변수를 사용하면 모듈을 작성할 때 더 적은 양의 코드로 작성할 수 있습니다. 이 변수의 프로퍼티를 사용하는 방법도 안전하고 추천하는 방법입니다.
exports.method = function () { /* ... */ }
// vs.
module.exports.method = function () { /* ... */ }

간단한 모듈 예제

먼저 예제로 사용할 코드가 필요합니다. 간단한 계산기로 시작합니다.

// calculator.js

module.exports.add = (a, b) => a + b

다음처럼 사용할 수 있습니다.

// app-use-calculator.js

const calculator = require('./calculator.js')
console.log(calculator.add(2, 2) // 출력: 4

모듈 감싸기

Node.js는 require()로 모듈을 불러올 때 함수 래퍼(wrapper) 형태를 사용해 내부적으로 감싸서 호출합니다.

(function (exports, require, module, __filename, __dirname) {
  // calculator.js의 내용은 여기에 추가해 실행합니다.
  module.exports.add = (a, b) => a + b
});

module 객체

변수 “module”은 객체로 현재 모듈을 나타냅니다. 이 변수는 각 모듈에 지역적이며 비공개(private) 입니다. (모듈 코드에서만 접근할 수 있습니다.)

// calcualtor-printed.js

module.exports.add = (a, b) => a + b
console.log(module)

// 이 모듈을 require('./calculator-printed.js')로 호출하면 다음과 같은 결과를 볼 수 있습니다.
//
// Module {
//   id: '/Users/laz/repos/module-exports/calculator-printed.js',
//   exports: { add: [Function] },
//   parent: 
//     Module { ... }
//   filename: '/Users/laz/repos/module-exports/calculator-printed.js',
//   loaded: false,
//   children: [],
//   paths: [ ... ]
// }

module.exports

  • 객체 참조로 require() 호출을 하면 받는 값입니다.
  • Node.js에 의해 자동으로 생성됩니다.
  • 일반 JavaScript 객체를 참조합니다.
  • 또한 기본값은 비어 있습니다. (앞서 코드에서는 add() 메소드를 추가했습니다.)

다음 두 방법으로 module.exports를 사용합니다.

  1. 공개(public) 메소드를 붙여 사용합니다. (앞서 작성한 예제가 이 방법입니다.)
  2. 직접 작성한 객체나 함수로 대체하는 방식을 사용합니다.

왜 대체하는 방식으로 사용할까요? 대체하는 방식으로 사용하면 다른 클래스를 임의적으로 수정해 반환하는 것도 가능합니다. 다음 ES2015 예제를 보겠습니다.

// calculator-base.js

module.exports = class Calculator {
  add(a, b) {
    return a + b
  }
  substract(a, b) {
    return a - b
  }
}

이 calculator-base 예제에서는 클래스를 내보냈습니다. 다음 예제에서는 “Calcaulator” 클래스를 확장한 후, 클래스의 개체를 내보냅니다.

// calculator-advanced.js

const Calculator = require('./calculator-base.js')

class AdvancedCalculator extends Calculator {
  multiply(a, b) {
    return a * b
  }
  devide(a, b) {
    return a / b
  }
}

module.exports = new AdvancedCalculator()
// app-use-advanced-calculator.js

const calculator = require('calculator-advanced.js')

console.log(calculator.add(2, 2))      // 출력: 4
console.log(calculator.multiply(3, 3)) // 출력: 9

exports 별칭(alias)

  • exports는 편의 변수로 모듈 작성자가 코드를 덜 작성하도록 돕습니다.
  • 이 변수의 프로퍼티를 사용하는 방식은 안전하고 추천하는 방법입니다. (예를 들면 exports.add = function () { /* ... */ } 식으로)
  • exportsrequire() 함수에서 반환되지 않습니다. (module.exports는 반환하지만요!)

좋은 예제와 나쁜 예제를 확인합니다.

// calculator-exports-exmaples.js

// 좋음
module.exports = {
  add(a, b) { return a + b }
}

// 좋음
module.exports.subtract = (a, b) => a - b

// 가능함
exports = module.exports

// 위에서 작성한 코드보다 간단하고 짧은 코드
exports.multiply = (a, b) => a * b

// 나쁨, exports는 바깥으로 전달되지 않음
exports = {
  divide(a, b) { return a / b }
}

노트: module.exports를 함수나 객체로 대체하는 경우는 일반적인 접근법입니다. 이렇게 대체하면서도 여전히 exports 축약을 사용하고 싶다면 exports를 새로 대체할 객체를 가리키도록 설정해야 합니다. (위에서도 이런 방법을 사용헀습니다.)

exports = module.exports = {}
exports.method = function () { /* ... */ }

결론

변수명은 exports지만 실제로 내보내지 않는다는 사실은 좀 혼란스러울 수 있습니다. 막 node.js를 시작한 사람이라면 특히 그럴겁니다. 공식 문서에서도 이 부분이 조금 이상합니다.

exports와 module.exports의 관계가 마술처럼 느껴진다면 exports는 무시하고 module.exports만 사용하도록 지침을 드립니다.

제 경우엔 이런 코드가 마술이라고 생각하지 않습니다. 개발자라면 사용하는 플랫폼과 언어가 어떻게 동작하는지 깊게 이해하려는 노력을 항상 해야합니다. 이처럼 깊게 이해하는 과정에서 프로그래머는 값진 자신감과 지식을 얻으며 코드 품질, 시스템 구조와 생산성에 긍정적인 영향을 주게 됩니다.

제 글을 읽어주셔서 감사합니다. 의견이나 생각은 언제나 환영하니 덧글로 남겨주세요.

Node.js의 Events `EventEmitter` 번역

😢 이 페이지는 다음 주소로 변경될 예정입니다.

EventEmitter는 Node.JS에 내장되어 있는 일종의 옵저버 패턴 구현이다. node 뿐만 아니라 대부분의 프레임워크나 라이브러리에서 이 구현을 쓰거나 유사한 구현을 활용하고 있는 경우가 많다. DOM Event Listener를 사용해본 경험이 있다면 사실 특별하게 새로운 기능은 아니지만, 요즘 이 패턴으로 작성된 라이브러리가 많고 특히 node 코어 라이브러리도 이 구현을 사용한 경우가 많아 살펴볼 만한 내용이다.

물론 Node 뿐만 아니라 front-end 환경에서도 Olical/EventEmitter와 같은 구현을 통해 손쉽게 활용할 수 있다.

이 글은 Node.js v5.5.0의 Events 문서를 기준으로 번역했고 버전에 따라 내용이 변경될 수 있다.


이벤트

Node.js의 코어 API 대부분은 관용적으로 비동기 이벤트 기반 아키텍처를 사용해서 만들어졌다. (“에미터 emitter”로 불리는) 어떤 종류의 객체를 이벤트 이름으로 정의된 특정 이벤트에 정기적으로 전달해 “리스너 listener”로 불리는 함수 객체를 실행한다.

예를 들어 net.Server 객체는 매번 사용자가 접속할 때마다 이벤트를 호출하고 fs.ReadStream은 파일을 열 때마다 이벤트를 호출한다. stream은 어떤 데이터든 데이터를 읽을 수 있는 상황이 되면 이벤트를 호출한다.

이벤트를 내보내는 모든 객체는 EventEmitter 클래스의 인스턴스다. 이 객체는 하나 이상의 함수를 이벤트로 사용할 수 있도록 이름을 넣어 추가하는 eventEmiter.on() 함수를 사용할 수 있다. 이벤트 이름은 일반적으로 카멜 케이스로 작성된 문자열이지만 JavaScript의 프로퍼티 키로 사용할 수 있는 모든 문자열을 사용할 수 있다.

EventEmitter 객체로 이벤트를 호출할 때, 해당 이벤트에 붙어 있는 모든 함수는 동기적으로 호출된다. 호출을 받은 리스너가 반환하는 결과는 어떤 값이든 무시되고 폐기된다.

다음은 EventEmitter 인스턴스를 단일 리스너와 함께 작성한 예다. eventEmitter.on() 메소드는 리스너를 등록하는데 사용한다. 그리고 eventEmitter.emit() 메소드를 통해 등록한 이벤트를 호출할 수 있다.

const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
  EventEmitter.call(this);
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});
myEmitter.emit('event');

어떤 객체든 상속을 통해 EventEmitter가 될 수 있다. 위에서 작성한 예는 util.inherits() 메소드를 사용했으며 이는 프로토타입으로 상속하는 방법으로 전통적인 Node.js 스타일이다. ES6 클래스 문법으로도 다음과 같이 사용할 수 있다:

const EventEmitter = require('events');

class MyEmitter extends EventEmitter {}

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});
myEmitter.emit('event');

인자와 this를 리스너에 전달하기

eventEmitter.emit() 메소드는 인자로 받은 값을 리스너 함수로 전달한다. 이 과정에서 기억해야 할 부분이 있는데 일반적으로 EventEmitter를 통해 호출되는 리스너 함수 내에서는 this가 이 리스너 함수를 부착한 EventEmitter를 참조하도록 의도적으로 구현되어 있다.

const myEmitter = new MyEmitter();
myEmitter.on('event', function(a, b) {
  console.log(a, b, this);
    // Prints:
    //   a b MyEmitter {
    //     domain: null,
    //     _events: { event: [Function] },
    //     _eventsCount: 1,
    //     _maxListeners: undefined }
});
myEmitter.emit('event', 'a', 'b');

ES6의 Arrow 함수를 리스너로 사용하는 것은 가능하지만 이 기능의 명세대로 이 함수 내에서의 this는 더이상 EventEmitter 인스턴스를 참조하지 않는다:

const myEmitter = new MyEmitter();
myEmitter.on('event', (a, b) => {
  console.log(a, b, this);
    // Prints: a b {}
});
myEmitter.emit('event', 'a', 'b');

비동기 vs. 동기

EventListener는 모든 리스너를 등록한 순서대로 동기적으로 처리한다. 즉 이벤트를 적절한 순서로 처리하는 것을 보장해 경쟁 조건(race condition)이나 로직 오류를 피하는 것이 중요하다. 이 모든 것이 적절하게 구현되었을 때, setImmediate()이나 process.nextTick()메소드를 사용해 리스너 함수를 비동기도 동작하도록 전환할 수 있다.

const myEmitter = new MyEmitter();
myEmitter.on('event', (a, b) => {
  setImmediate(() => {
    console.log('this happens asynchronously');
  });
});
myEmitter.emit('event', 'a', 'b');

단 한 번만 동작하는 이벤트

eventEmitter.on() 메소드로 등록된 리스너는 이벤트 이름이 호출되는 매 횟수만큼 실행된다.

const myEmitter = new MyEmitter();
var m = 0;
myEmitter.on('event', () => {
  console.log(++m);
});
myEmitter.emit('event');
  // Prints: 1
myEmitter.emit('event');
  // Prints: 2

eventEmitter.once()메소드로 등록한 리스너는 호출한 직후 제거되어 다시 호출해도 실행되지 않는다.

const myEmitter = new MyEmitter();
var m = 0;
myEmitter.once('event', () => {
  console.log(++m);
});
myEmitter.emit('event');
  // Prints: 1
myEmitter.emit('event');
  // Ignored

오류 이벤트

EventEmitter 인스턴스에서 오류가 발생했을 때의 전형적인 동작은 'error' 이벤트를 호출하는 것이다. 이 경우는 Node.js에서 특별한 경우로 다뤄진다.

오류가 발생한 EventEmitter'error' 이벤트로 등록된 리스너가 하나도 없는 경우에는 오류가 던져지고(thrown) 스택 추적이 출력되며 Node.js의 프로세스가 종료된다.

const myEmitter = new MyEmitter();
myEmitter.emit('error', new Error('whoops!'));
  // Throws and crashes Node.js

Node.js 프로세스가 멈추는 것을 막기 위해서는 process.on('uncaughtException') 이벤트에 리스너를
등록하거나 domain 모듈을 사용할 수 있다. (하지만 domain 모듈은 더이상 사용하지 않는다(deprecated))

const myEmitter = new MyEmitter();

process.on('uncaughtException', (err) => {
  console.log('whoops! there was an error');
});

myEmitter.emit('error', new Error('whoops!'));
  // Prints: whoops! there was an error

개발자가 항상 'error' 이벤트에 리스너를 등록하는 것이 가장 좋은 방법이다:

const myEmitter = new MyEmitter();
myEmitter.on('error', (err) => {
  console.log('whoops! there was an error');
});
myEmitter.emit('error', new Error('whoops!'));
  // Prints: whoops! there was an error

클래스: EventEmitter

EventEmitter 클래스는 events 모듈에 의해서 정의되고 제공된다.

const EventEmitter = require('events');

모든 EventEmitters는 새로운 이벤트를 등록할 때마다 'newListner' 이벤트를 호출하고 리스너를 제거할 때마다 'removeListner'를 호출한다.

이벤트: ‘newListener’

  • event {String|Symbol} 이벤트명
  • listener {Function} 이벤트 처리 함수

EventEmitter 인스턴스는 인스턴스 자신의 'newListener' 이벤트를 리스너를 내부 리스너 배열에 추가하기 전에 호출한다.

'newListener' 이벤트에 리스너가 전달되기 위해 이벤트 명칭과 추가될 리스너의 참조가 전달된다.

실제로 리스너가 추가되기 전에 이 이벤트가 호출된다는 점으로 인해 다음과 같은 중대한 부작용이 나타날 수 있다. 어떤 추가적인 리스너든 동일한 명칭의 리스너를 'newListener' 콜백에서 먼저 등록한다면 추가하려는 해당 리스너가 실제로 등록되기 전에 이 함수가 먼저 추가될 것이다.

const myEmitter = new MyEmitter();
// Only do this once so we don't loop forever
myEmitter.once('newListener', (event, listener) => {
  if (event === 'event') {
    // Insert a new listener in front
    myEmitter.on('event', () => {
      console.log('B');
    });
  }
});
myEmitter.on('event', () => {
  console.log('A');
});
myEmitter.emit('event');
  // Prints:
  //   B
  //   A

이벤트: ‘removeListener’

  • event {String|Symbol} 이벤트명
  • listener {Function} 이벤트 처리 함수

'removeListener' 이벤트는 리스너가 제거된 후에 실행된다.

EventEmitter.listenerCount(emitter, event)

안정성: 0 – 추천 안함: emitter.listenerCount()을 대신 사용할 것.

인자로 넘긴 emitter에 해당 event가 리스너를 몇 개나 갖고 있는지 확인하는 클래스 메소드다.

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {});
myEmitter.on('event', () => {});
console.log(EventEmitter.listenerCount(myEmitter, 'event'));
  // Prints: 2

EventEmitter.defaultMaxListeners

기본값으로 한 이벤트에 최대 10개 리스너를 등록할 수 있다. 이 제한은 각각의 EventEmitter의 인스턴스에서 emitter.setMaxListeners(n) 메소드로 지정할 수 있다. 모든 EventEmitter 인스턴스의 기본값을 변경하려면 EventEmitter.defaultMaxListeners 프로퍼티를 사용할 수 있다.

EventEmitter.defaultMaxListeners 설정을 변경할 때는 모든 EventEmitter 인스턴스에게 영향을 주기 때문에 이 변경 이전에 만든 부분에 대해서도 주의해야 한다. 하지만 emitter.setMaxListeners(n)를 호출해서 설정한 값이 있다면 EventEmitter.defaultMaxListeners의 값보다 우선으로 적용된다.

참고로 이 값은 강제적인 제한이 아니다. EventEmitter 인스턴스는 제한된 값보다 더 많은 리스너를 추가할 수 있지만 EventEmitter 메모리 누수의 가능성이 있는 것으로 보고 stderr를 통해 경고를 보내 개발자가 문제를 인지할 수 있게 한다. 어떤 EventEmitteremitter.getMaxListeners()emitter.setmaxListeners() 메소드를 사용해 이 경고를 임시로 피할 수 있다:

emitter.setMaxListeners(emitter.getMaxListeners() + 1);
emitter.once('event', () => {
  // do stuff
  emitter.setMaxListeners(Math.max(emitter.getMaxListeners() - 1, 0));
});

emitter.addListener(event, listener)

emitter.on(event, listener)의 별칭이다.

emitter.emit(event[, arg1][, arg2][, …])

event에 등록된 리스너를 등록된 순서에 따라 동기적으로 호출한다. 제공되는 인자를 각각 리스너로 전달한다.

이벤트가 존재한다면 true, 그 외에는 false를 반환한다.

emitter.getMaxListeners()

현재 EventEmitter에 지정된 최대 리스너 수를 반환한다. 기본값은 EventEmitter.defaultMaxListeners이며 emitter.setMaxListeners(n)로 변경했을 경우에는 그 값을 반환한다.

emitter.listenerCount(event)

  • event {Value} The type of event
  • event {Value} 이벤트 이름

해당 event 이름에 등록되어 있는 리스너의 수를 반환한다.

emitter.listeners(event)

해당 event에 등록된 리스너 배열의 사본을 반환한다.

server.on('connection', (stream) => {
  console.log('someone connected!');
});
console.log(util.inspect(server.listeners('connection')));
  // Prints: [ [Function] ]

emitter.on(event, listener)

listener 함수를 지정한 event의 리스너 배열 가장 끝에 추가한다. listener가 이미 추가되어 있는 함수인지 확인하는 과정이 없다. 같은 조합의 eventlistener를 여러 차례 추가했다면 추가한 만큼 여러번 호출된다.

server.on('connection', (stream) => {
  console.log('someone connected!');
});

EventEmitter의 참조를 반환하기 때문에 연속해서 호출하는 것이(chaining) 가능하다.

emitter.once(event, listener)

일회성 listener 함수를 event에 등록한다. 이 이벤트는 다음 차례 event가 호출될 때 한 번만 실행한 후 제거된다.

server.once('connection', (stream) => {
  console.log('Ah, we have our first user!');
});

EventEmitter의 참조를 반환하기 때문에 연속해서 호출하는 것이 가능하다.

emitter.removeAllListeners([event])

모든 리스너, 또는 지정한 event의 리스너를 제거한다.

코드 다른 곳에 추가되어 있는 리스너를 제거하는 것은 좋지 않은 방법이다. 특히 EventEmitter 인스턴스가 다른 컴포넌트나 모듈에서 생성되었을 때는 더 유의해야 한다. (예를 들어, 소켓이나 파일 스트림 등)

EventEmitter의 참조를 반환하기 때문에 연속해서 호출하는 것이 가능하다.

emitter.removeListener(event, listener)

특정 event에 등록되어 있는 특정 listener를 제거한다.

var callback = (stream) => {
  console.log('someone connected!');
};
server.on('connection', callback);
// ...
server.removeListener('connection', callback);

removeListener는 한 인스턴스 내에 존재하는 리스너 배열에서 해당 리스너를 하나 제거한다. 만약 한 리스너를 여러 차례 특정 event에 등록해서 배열 내 여러 개 존재한다면, 그 모든 리스너를 지우기 위해서는 removeListener를 여러 번 호출해야 한다.

이 메소드로 리스너가 하나 지워진 후에 등록되어 있는 리스너의 위치 인덱스가 변경되는데 리스너가 내부 배열로 관리되기 때문이다. 이 동작은 리스너가 호출되는 순서에는 영향을 주지 않지만 emitter.listeners() 메소드로 생성한 리스너 배열의 사본이 있다면 다시 생성해야 할 필요가 있다.

EventEmitter의 참조를 반환하기 때문에 연속해서 호출하는 것이 가능하다.

emitter.setMaxListeners(n)

EventEmitters의 기본값인 10보다 더 많은 리스너가 등록되어 있다면 경고가 출력된다. 이 함수는 어디에서 메모리 누수가 발생하는지 찾는데 유용하다. 명백하게도 모든 이벤트가 10개의 리스너 제한이 필요한 것은 아니다. emitter.setMaxListeners() 메소드를 사용하면 특정 EventEmitter 인스턴스에 대해 그 제한을 변경할 수 있다. Infinity (또는 0)을 지정하면 리스너를 숫자 제한 없이 등록할 수 있다.

EventEmitter의 참조를 반환하기 때문에 연속해서 호출하는 것이 가능하다.

Express, Koa, Hapi 장단점 비교

😢 이 페이지는 다음 주소로 변경될 예정입니다.

nodejs로 개발을 한다면 Express, Koa, Hapi 중 하나는 꼭 접하게 된다. 내 경우는 Express를 맨 처음 접해서 가장 익숙하지만 generator를 지원하는 koa에 대한 이야기도 들어봤고 hapi도 최근 react나 angular와 함께 사용하는 얘기를 자주 들을 수 있었다.

어떤 차이가 있는지 검색하다가 간단하게 정리된 Jonathan Glock의 글 Node.js Framework Comparison: Express vs. Koa vs. Hapi을 접하게 되었고 장단점 부분만 간단하게 번역했다. 원문에는 비교 코드도 포함되어 있어서 코드를 보고 싶다면 원문을 살펴보길 권한다.

Thank you Airpair for giving me the opportunity to translate this article. If you want to check the original, please visit Node.js Framework Comparison: Express vs. Koa vs. Hapi page.

각 프레임워크의 장단점

Express

Node.js 프레임워크 중 커뮤니티가 가장 크다. 거의 5년 가량 개발되어 가장 성숙했고 StrongLoop에 의해 관리되고 있다. 서버를 쉽게 실행/운영할 수 있다. 내장된 라우터로 코드를 쉽게 재사용 가능하다.

수작업으로 해줘야 하는 부분이 많다. 내장된 에러 핸들링이 없어서 미들웨어를 잃어버릴 수 있다. 한 문제를 해결하기 위해 여러 방법으로 접근할 수 있다. Express는 스스로를 완고하다고 표현하는데 이 부분은 양날의 검이며 초보인 경우에는 단점으로 작용한다. 다른 프레임워크에 비해 메모리를 많이 차지한다.

Koa

메모리를 덜먹고 표현력이 좋다. 다른 프레임워크에 비해 미들웨어 작성이 쉽다. 기본적으로 뼈대 프레임워크라서 제공되는 미들웨어와 함께 사용해야만 하는 Express와 Hapi와 달리, 개발자가 필요한 미들웨어만 구성해 사용할 수 있다. ES6를 도입하고 있어 ES6 제너레이터를 사용할 수 있다.

여전히 불안정하고 많은 양의 개발이 진행중이다. ES6를 사용하기 위해 최신 버전의 node.js를 사용해야 한다. (주, 이 문제는 지금도 해당하는지 모르겠음.) 미들웨어를 직접 작성할 수 있는게 장점일 수 있지만 단점일 수도 있다. 예제서 살펴본 라우터는 훨씬 다양한 옵션을 다뤄야 한다.

Hapi

코드보다 설정을 더 많이 해야 해서 정말 좋은 프레임워크인지 말이 많다. 견고함과 재사용성을 요구하는 큰 규모 팀에서는 흔하게 사용한다. 월마트랩에서 만들고 이름있는 회사에서 많이 쓰고 있어서 검증되었다고 보는 편이다. 좋은 프레임워크로 계속 성장할 것으로 보인다.

Hapi는 크고 복잡한 어플리케이션에 특성화 되어 있다. 보일러플레이트로 작성해야 할 코드가 많아서 작은 웹앱에서는 쓰기 불편하고, 예제 및 hapi로 작성된 오픈소스 앱도 적다. 이 프레임워크를 선택하면 서드파티 미들웨어에 기대는 쪽보다 개발자가 직접 작성해야 할 부분이 더 많을 것이다.


위 프레임워크 중 Express만 경험해봐서 각각 예제 코드가 살펴보는데 도움되었다. Koa는 tj가 노드를 떠난다는 글 쓴 이후로 시들할줄 알았는데 (그 핑계로 Koa를 딱히 살펴보지 않았는데) 여전히 잘 관리되고 있었다. 다양한 라이브러리가 매일같이 쏟아져 나와 봐야할 것도 많긴 하지만 잘 정착하는 프레임워크도 늘어나고 있어 커뮤니티가 잘 성숙하고 있다는 인상을 준다.

각 프레임워크 웹사이트