inflearn logo
강의

講義

知識共有

「AIポチポチの時代」原理で突破するNode.jsとCS Part1 - V8とコア解体新書

【参考資料】実践完全マスター:EventEmitterを活用した10の強力な実務パターン

[46강] EventEmitter를 활용한 10가지 패턴 중 플러그인 아키텍처

解決済みの質問

16

byeong

投稿した質問数 3

0

사례 2번 예시에서 Plugin 클래스도 EventEmitter를 상속받는 이유가 궁금합니다.

 

Application의 인스턴스를 주입받아 해당 인스턴스를 내부에서 활용하고 있는데 Plugin 클래스 자체도 EventEmitter를 상속받는 이유가 궁금합니다.

javascript node.js 컴퓨터-구조 frontend backend

回答 2

0

nhcodingstudio

안녕하세요 byeong님, 질문해 주셔서 감사합니다. 말씀하신 대로 현재 코드만 보면 Plugin1 클래스가 굳이 EventEmitter를 상속받을 필요가 없어 보이는 것이 맞습니다. app의 이벤트만 구독하고 있고, 정작 플러그인 내부에서는 this.emit()을 한 번도 사용하지 않고 있기 때문입니다.

그럼에도 실무에서 이러한 플러그인 구조를 설계할 때 관례적으로, 혹은 의도적으로 EventEmitter를 상속받도록 하는 데에는 컴퓨터 공학과 아키텍처 관점의 명확한 이유가 있습니다. 현재 byeong님이 의문을 가지신 형태의 뼈대 코드를 보면, 플러그인이 앱의 이벤트만 듣고 있을 뿐 스스로 이벤트를 방출하지 않는데 왜 굳이 상속을 받아야 하는지 충분히 의문이 드실 수 있습니다.JavaScript

const EventEmitter = require('events');

// [현재 byeong님이 의문을 가지신 형태의 뼈대 코드]
// this.emit()을 사용하지 않는데 왜 굳이 상속을 받을까요?
class Plugin1 extends EventEmitter {
  constructor(app) {
    super();
    // app의 이벤트만 듣고(on), 스스로 방출(emit)하지 않음
    app.on('appStarted', () => {
      console.log('앱 시작을 감지했습니다. 플러그인 초기화 중...');
    });
  }
}

우선 가장 큰 이유는 전역 이벤트 버스의 병목을 방지하고 책임을 분리하기 위함입니다. 만약 플러그인이 EventEmitter가 아니라면, 플러그인 내부에서 발생하는 모든 알림을 메인 app 객체를 통해 발생시켜야 합니다. 예를 들어 app.emit('plugin1_완료')와 같은 방식으로 처리해야 합니다. 이러한 방식은 규모가 작을 때는 큰 문제가 없지만, 플러그인이 수십 개로 늘어나면 app 객체 하나가 시스템 전체의 이벤트를 모두 감당하게 됩니다. 이를 전역 이벤트 버스에 강하게 결합되었다고 표현하며, 유지보수를 어렵게 만드는 안티패턴으로 분류합니다. 메인 앱 객체가 모든 플러그인의 이벤트를 짊어지는 것은 피해야 하며, 각 플러그인이 독립적인 이벤트 주체가 되어 자신의 상태만 스스로 방출하도록 책임을 분리하는 것이 실무에서 권장하는 패턴입니다.JavaScript

// ❌ [안티패턴: 전역 이벤트 버스에 강하게 결합된 형태]
// 메인 app 객체가 모든 플러그인의 이벤트를 짊어집니다.
app.emit('plugin1_download_complete', data);
app.emit('plugin2_db_connected', data);
app.emit('plugin3_auth_success', data);

// ✅ [권장 패턴: 책임이 분리된 형태]
// 각 플러그인이 독립적인 이벤트 주체가 되어 자신의 상태만 스스로 방출합니다.
plugin1.emit('download_complete', data);
plugin2.emit('db_connected', data);
plugin3.emit('auth_success', data);

이러한 책임 분리는 자연스럽게 거시적 이벤트와 미시적 이벤트의 분리로 이어집니다. 실무의 플러그인 아키텍처는 대부분 계층 구조를 가지며, 코어인 app은 앱의 시작이나 종료, 치명적인 에러와 같은 거시적인 이벤트만 관리하는 것이 바람직합니다. 반대로 데이터베이스 연결 완료나 파일 다운로드 50% 진행 등과 같은 미시적인 이벤트는 해당 플러그인이 직접 관리하도록 설계해야 합니다. 이렇게 Plugin1이 독자적인 EventEmitter가 되면, 시스템의 다른 컴포넌트들이 코어 앱을 거치지 않고 Plugin1의 특정 동작에만 리스너를 등록할 수 있어 전체적인 코드의 결합도가 크게 낮아집니다. 즉, 코어 앱은 시스템 부팅이나 정지와 같은 시스템 전체의 거시적 생명주기만 관리하고, 외부 모듈은 앱을 거치지 않고 플러그인의 다운로드 진행률이나 전용 DB 연결 완료와 같은 미시적 상태 변화만 직접 구독하게 됩니다.JavaScript

// [거시적 이벤트 vs 미시적 이벤트 분리 예시]

// 1. 코어(app)는 시스템 전체의 거시적 생명주기만 관리
app.on('start', () => console.log('시스템 부팅'));
app.on('fatalError', (err) => console.error('시스템 정지', err));

// 2. 외부 모듈은 app을 거치지 않고 Plugin1의 미시적 상태 변화만 직접 구독
plugin1.on('downloadProgress', (percent) => {
  console.log(`Plugin1 다운로드 진행률: ${percent}%`);
});
plugin1.on('dbConnected', () => {
  console.log('Plugin1 전용 DB 연결 완료');
});

결합도의 감소는 상태 캡슐화와 유연한 확장성 측면에서도 매우 중요한 의미를 가집니다. 객체의 내부 상태를 숨기고 필요한 인터페이스만 노출하는 캡슐화의 원칙에 따라, 플러그인이 직접 이벤트를 방출할 수 있다면 훨씬 자연스러운 확장이 가능해집니다. 예를 들어 아래의 코드처럼 Plugin1 클래스가 EventEmitter를 상속받고 생성자에서 super()를 호출한 뒤 코어 앱의 시작 이벤트를 수신한다고 가정해 보겠습니다. 이렇게 상태 캡슐화가 적용된 플러그인 내부 구조를 살펴보면, 코어 앱의 이벤트를 수신하여 필요한 작업을 처리한 뒤 플러그인 내부의 상태 변화를 자신만의 이벤트로 외부 구독자에게 알리게 됩니다.JavaScript

// [상태 캡슐화가 적용된 Plugin1 내부 구조]
class Plugin1 extends EventEmitter {
  constructor(app) {
    super();
    // 코어 앱의 이벤트를 수신
    app.on('appStarted', this.handleEvent.bind(this));
  }

  handleEvent(data) {
    console.log('플러그인1 준비 중...', data);
    // 플러그인 내부의 상태 변화를 자신만의 이벤트로 외부(구독자)에 알림
    this.emit('pluginReady', { status: 'ok' });
  }
}

해당 코드를 살펴보면, 코어 앱의 시작 이벤트를 수신하여 필요한 작업을 마친 플러그인이 내부적으로 준비 중이라는 로그를 출력합니다. 그리고 최종적으로 이벤트를 호출하여 준비가 완료되었음을 해당 플러그인에 의존성이 있는 외부 모듈들에게만 알리게 됩니다. 이러한 방식으로 코드를 설계하면, 외부 모듈들은 메인 app의 방대한 이벤트 목록을 확인할 필요 없이 플러그 객체와 직접 소통할 수 있으므로 시스템이 훨씬 유연해집니다. 외부 모듈 관점에서의 유연한 확장성을 생각해보면, 외부 모듈은 코어 앱의 거대한 이벤트를 전부 알 필요 없이 플러그인의 준비 완료 이벤트만 기다리면 됩니다. 이후 앱이 실행되어 플러그인의 핸들러가 연쇄적으로 트리거되면, 플러그인이 준비되었다는 신호를 받아 후속 작업을 시작할 수 있습니다.JavaScript

// [외부 모듈 관점에서의 유연한 확장성]
const app = new AppCore();
const plugin1 = new Plugin1(app);

// 외부 모듈 A는 app의 거대한 이벤트를 알 필요 없이, 
// plugin1의 'pluginReady' 이벤트만 기다리면 됩니다.
plugin1.on('pluginReady', (result) => {
  if (result.status === 'ok') {
    console.log('외부 모듈 A: Plugin1이 준비되었으므로 후속 작업을 시작합니다.');
    startModuleA();
  }
});

// 앱 실행 (이때 plugin1의 handleEvent가 연쇄적으로 트리거됨)
app.emit('appStarted', { time: Date.now() });

더 나아가 이러한 구조는 메모리 누수를 예방하고 가비지 컬렉션을 원활하게 처리하는 과정과도 깊게 맞닿아 있습니다. 모든 이벤트를 메인 app 객체에 연결해 두면, 특정 플러그인의 생명주기가 종료되어 메모리에서 해제되어야 할 때 문제가 발생합니다. app 객체에 해당 플러그인의 리스너가 여전히 등록되어 있기 때문에, 가비지 컬렉터가 플러그인의 메모리를 정상적으로 회수하지 못하여 메모리 누수가 발생할 위험이 높습니다. 하지만 플러그인이 자체적인 EventEmitter로 독립하게 되면, 자신의 생명주기에 맞추어 이벤트를 해제하고 메모리를 안전하게 관리하는 작업이 훨씬 수월해집니다. 만약 코어 앱이 플러그인의 콜백 참조를 강하게 쥐고 있다면, 플러그인 객체의 사용이 끝나도 가비지 컬렉션을 방해하여 메모리에서 지워지지 않습니다. 반면 자체 이벤트로 독립하게 되면 플러그인 내부에서 이벤트를 관리하므로, 생명주기 종료 시 외부 구독자를 쉽게 초기화하고 자신은 가비지 컬렉터의 정상적인 수집 대상이 될 수 있어 메모리 관리가 용이해집니다.JavaScript

// ❌ [가비지 컬렉션(GC) 방해 예시]
// app이 plugin1의 콜백(참조)을 강하게 쥐고 있어, plugin1 객체가 사용 끝나도 메모리에서 지워지지 않음
app.on('data', plugin1.processData); 

// ✅ [자체 이벤트로 독립하여 메모리 관리가 용이해진 경우]
// plugin1 내부에서 관리되므로, 생명주기 종료 시 외부 구독자를 쉽게 초기화하고 
// 자신은 가비지 컬렉터의 정상적인 수집 대상이 될 수 있음
plugin1.removeAllListeners();

이러한 가비지 컬렉션 및 메모리 관리 측면에서 실무적인 디테일을 한 가지 더 덧붙여 보겠습니다. 앞서 예시로 보여드린 코드의 인라인 바인딩 부분처럼 .bind(this)를 직접 사용하면 이벤트가 등록될 때마다 새로운 함수 참조가 생성됩니다. 이렇게 되면 추후 플러그인이 종료될 때 콜백을 제거하려 해도 해제할 함수의 정확한 참조값을 찾을 수 없어 메모리 누수가 발생할 수 있습니다. 즉, 인라인으로 바인드를 사용할 경우 메모리 상에 새로운 익명 함수가 생성되며, 나중에 플러그인을 제거할 때 또 다른 새로운 함수가 생성되기 때문에 등록했던 리스너와 메모 주소가 일치하지 않아 이벤트가 정상적으로 해제되지 않는 참조 분실 위험이 존재합니다.JavaScript

// ❌ [인라인 bind 사용으로 인한 참조 분실 위험]
class BadPlugin {
  constructor(app) {
    // bind() 호출 시 메모리 상에 '새로운' 익명 함수가 생성됩니다.
    app.on('appStarted', this.handleEvent.bind(this)); 
  }
  
  destroy(app) {
    // 여기서 또 다른 '새로운' 함수가 생성되므로, 
    // 등록했던 리스너와 참조(메모리 주소)가 일치하지 않아 이벤트가 해제(remove)되지 않습니다!
    app.removeListener('appStarted', this.handleEvent.bind(this)); 
  }
}

따라서 메모리를 더욱 완벽하게 관리하려면 아래와 같이 바인딩된 함수를 인스턴스 변수로 캐싱하고 종료 시점에 명확히 해제하는 패턴을 사용하는 것이 좋습니다. 이러한 참조 캐싱을 통한 안전한 메모리 해제 패턴은 먼저 바인딩된 함수의 참조, 즉 메모리 주소를 인스턴스 변수에 저장하여 유지합니다. 그런 다음 저장된 참조를 사용하여 이벤트를 등록하고, 플러그인 종료나 해제 시점에 호출되는 메서드에서 등록할 때 사용했던 정확히 동일한 참조를 찾아 안전하게 이벤트를 해제합니다. 마지막으로 플러그인이 완전히 종료되었음을 자신을 구독하는 외부 모듈에 알리게 됩니다.JavaScript

// ✅ [참조 캐싱을 통한 안전한 메모리 해제 패턴]
class Plugin1 extends EventEmitter {
  constructor(app) {
    super();
    this.app = app;
    // 1. 바인딩된 함수의 참조(메모리 주소)를 인스턴스 변수에 저장하여 유지(Caching)
    this.boundHandleEvent = this.handleEvent.bind(this);
    // 2. 저장된 참조를 사용하여 이벤트 등록
    this.app.on('appStarted', this.boundHandleEvent);
  }

  // 플러그인 종료(해제) 시점에 호출되는 메서드
  destroy() {
    // 3. 등록할 때 사용했던 '정확히 동일한 참조'를 찾아 안전하게 이벤트 해제
    this.app.removeListener('appStarted', this.boundHandleEvent);
    // 4. 플러그인이 완전히 종료되었음을 자신을 구독하는 외부 모듈에 알림
    this.emit('pluginDestroyed');
  }

  handleEvent(data) {
    console.log('플러그인1 준비 중...', data);
    this.emit('pluginReady', { status: 'ok' });
  }
}

이렇게 구현하면 byeong님께서도 코어 객체와 플러그인의 분리가 어떻게 안전한 메모리 관리와 생명주기 제어로 이어지는지 더 명확하게 확인하실 수 있을 것입니다. 생명주기 제어 및 메모리 해제 실행 관점에서 보면, 플러그인 사용 완료 후 메모리에서 정리해야 할 때 종료 메서드를 호출하게 됩니다. 그러면 내부적으로 이벤트 리스너 제거가 정상 동작하여 코어 앱과 플러그인 간의 강한 참조 연결 고리가 끊어지며, 결과적으로 V8 엔진의 가비지 컬렉터가 플러그인 인스턴스를 정상적으로 회수하게 됩니다.JavaScript

// [생명주기 제어 및 메모리 해제 실행 관점]
const plugin = new Plugin1(app);

// ... 플러그인 사용 완료 후 ...

// 플러그인을 메모리에서 정리해야 할 때 호출
plugin.destroy(); 
// 내부적으로 app.removeListener가 정상 동작하여 
// 코어 앱(app)과 플러그인(plugin) 간의 강한 참조 연결 고리가 끊어짐.
// 결과적으로 V8 엔진의 가비지 컬렉터(GC)가 plugin 인스턴스를 정상적으로 회수합니다.

여기에 실무적인 팁을 하나 덧붙이자면, Node.js의 EventEmitter는 기본적으로 하나의 이벤트에 10개 이상의 리스너가 등록될 경우 메모리 누수 가능성을 경고하는 에러를 발생시킵니다. 만약 단일 app 객체에 모든 플러그인의 이벤트를 집중시킨다면 이 경고 메시지를 매우 빈번하게 마주하게 될 것입니다. 따라서 플러그인별로 EventEmitter를 분리하는 것은 이러한 경고를 사전에 방지하고 시스템의 안정성을 높이는 매우 중요한 실무 테크닉입니다. 만약 이벤트 에미터를 분리하지 않고 코어 앱에 전부 몰아넣어서 동일한 이벤트에 열 개가 넘는 리스너가 등록된다면, 노드 런타임 콘솔에는 이벤트 에미터 메모리 누수 가능성을 경고하는 에러 메시지가 실제로 발생하게 됩니다.JavaScript

// [MaxListenersExceededWarning 경고 발생 상황]
// 만약 EventEmitter를 분리하지 않고 app에 전부 몰아넣는다면...

for (let i = 0; i < 15; i++) {
  // 동일한 'appStarted' 이벤트에 10개가 넘는 리스너가 등록됨
  app.on('appStarted', () => {
    console.log(`플러그인 ${i} 초기화`);
  });
}
// ⚠️ Node.js 런타임 콘솔에 다음과 같은 경고가 발생합니다:
// (node:12345) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 
// 11 appStarted listeners added. Use emitter.setMaxListeners() to increase limit

결론적으로 현재의 뼈대 코드만 보면 당장 상속이 불필요해 보이는 것이 사실입니다. 하지만 해당 설계는 플러그인 역시 언제든 독립적인 이벤트 발행 주체가 될 수 있다는 향후 확장성과, 안전한 메모리 관리까지 미리 고려한 아키텍처라고 이해하시면 됩니다.

이 최종 아키텍처를 요약하자면 첫째, 코어 앱은 거시적 생명주기 관리 및 플러그인 로드를 담당합니다. 둘째, 이벤트 에미터를 상속받은 플러그인은 미시적 도메인 로직 처리 및 자체 이벤트를 발행합니다. 셋째, 외부 모듈들은 코어 앱을 거치지 않고 필요한 플러그인의 이벤트만 직접 구독합니다. 결과적으로 이러한 구조는 낮은 결합도와 높은 응집도를 바탕으로 안전한 가비지 컬렉션 메모리 관리를 달성하게 해줍니다.

학습을 진행하시면서 추가로 궁금한 점이 생기신다면 언제든 질문 남겨주시기 바랍니다.

참고해주세요!

JavaScr

0

communityai8509

안녕하세요, 인프런 AI 인턴입니다. byeong님.

플러그인 아키텍처에서 Plugin 클래스가 EventEmitter를 상속받는 이유는 주로 확장성과 모듈성 때문입니다. 즉, Plugin 클래스가 독립적으로 이벤트를 발송하고 수신할 수 있는 기능을 부여받아 자신만의 이벤트 시스템을 관리할 수 있게 됩니다. 이를 통해 Plugin 클래스는 자체적인 이벤트 프로세싱 로직을 가질 수 있으며, 다른 플러그인이나 외부로부터 메시지를 수신하거나, 직접 이벤트를 발송하여 상호작용 할 수 있습니다.

Plugin 클래스가 EventEmitter를 상속받는 장점:

  1. 독립적인 이벤트 처리: Plugin이 자체 이벤트를 관리하는데 유용합니다. 다른 플러그인에서 발생한 이벤트에 대응하거나, 자신의 상태 변경을 이벤트로 외부에 알릴 수 있습니다.

  2. 유연한 상호작용: 앱 전반의 이벤트 시스템에 종속되지 않고, 자체적인 이벤트 흐름을 정의하고 관리할 수 있습니다. 이는 플러그인 간 느슨한 결합을 보장합니다.

  3. 모듈성 및 재사용성: EventEmitter를 이용해 개별 플러그인의 독립성과 모듈성을 확보하면서도, 필요에 따라 이벤트를 통해 상호작용하거나 반응할 수 있는 구조를 가질 수 있습니다.

이러한 설계는 기본적으로 플러그인이 독자적인 기능 단위로 확장성을 제공하면서도, 메인 앱이나 다른 구성 요소와 원활하게 상호작용할 수 있도록 해줍니다. 플러그인 개발자는 필요한 이벤트를 정의하고, 외부로 노출하여 언제든지 메인 프로그램 또는 다른 플러그인과의 통신을 쉽게 설계할 수 있습니다.

이와 관련하여 추가적인 설명이 있는 게시물을 추천합니다:

저는 질문자님의 이해를 돕고자 지식공유자님의 기존 답변을 참고하여 유사한 답변 링크를 추천해드리고 있습니다.
현재 베타 기능으로 답변이 만족스럽지 않을 수 있는 점 양해 부탁드립니다. 🙏
추가적으로 궁금한 점이 있으시면, 이어서 질문해 주세요. 곧 지식공유자께서 답변해 주실 것입니다.

컴퓨터구조론에 관해서

0

3

1

메모리 동적할당시 메모리창 빨간 글씨

0

10

2

강의가 누락된것 같습니다.

0

35

2

섹션3에 대한 문의사항

0

49

2

쿼터스 스케메틱에 대한 질문

0

26

2

examtopics와 krdumps 차이가 나는데요 ㅠ

0

43

1

추가 강의 있으면 좋겠어요.

0

49

2

용어 발음법이 계속 바뀌는 것 같은데 이런 부분들 개선이 가능할까요...?

0

44

1

vscode 다루는법을 모르겠습니다

0

22

1

counter01.html은 어디있을까요?

1

16

1

리눅스로 진행해도 될까요?

0

33

2

존재하지 않는 일기 url입력 시 alert이 두 번 떠요

0

37

1

useState 직접 구현 부분에서 질문이 있습니다.

1

43

1

FIFO 질문

0

25

2

학습을 하고 블로그에 정리를 해도 괜찮을까요?

1

41

1

Design a Toast Notification System 미션 관련 질문드립니다.

0

67

1

교재(3쇄)와 강의 내용 문의

0

36

2

데이터베이스 13번 문제

0

48

2

call stack 표현이 잘못표현된것이 아닌가요?

0

81

2

혹시 다음 강의 제작 예정된 것들이 있을까요?

0

95

1

1강 질문

0

73

2

ai가 만든 강의인가요?

0

159

1

3강 질문

0

84

2

2강 nodejs 3단계 설명 질문

0

95

1