Node.js Event Architecture

콜백과 이벤트 핸들링의 진화

`Node.js` 개발자들에게 익숙한 콜백은, 과거 `Node`에서 이벤트 처리의 핵심이었습니다. `JavaScript`가 일급 객체를 지원하므로 콜백을 자유롭게 전달할 수 있었죠. 하지만 시간이 흐르면서, 프로미스(Promise)와 `async/await` 구문이 등장하여 비동기 코드 처리 방식이 크게 달라졌습니다.

 

ES6 이전의 동기와 비동기 코드

`ES6` 이전에는 비동기 및 동기 코드를 혼합해 사용하는 것이 일반적이지 않았습니다. 예를 들어, `fileSize` 함수는 문자열 인수를 받고, 비동기 `fs.stat`을 사용해 파일 크기를 반환합니다. 하지만 동기적 에러 처리(예: 잘못된 인수 타입)와 비동기적 에러 처리가 혼합되어 있어 이해하기 어려운 코드가 되곤 했습니다.

javascriptCopy code
function fileSize(fileName, cb) {
  if (typeof fileName !== 'string') {
    return cb(new TypeError('argument should be string')); // 동기적
  }
  fs.stat(fileName, (err, stats) => {
    if (err) { return cb(err); } // 비동기적
    cb(null, stats.size); // 비동기적
  });
}

 

프로미스의 도입과 그 장점

`Node.js`의 이벤트 아키텍처에서 프로미스의 도입은 비동기 프로그래밍 방식에 혁신을 가져왔습니다. `ES6` 이후 `JavaScript`에서는 프로미스(Promise)를 기본적으로 제공하게 되었는데, 이는 콜백 방식의 복잡성과 "콜백 지옥" 문제를 해결하는 데 큰 도움이 되었습니다.

 

`readFileAsArray` 함수를 예로 들어보면, 프로미스를 사용하면서 코드의 가독성과 유지 보수성이 크게 향상되었습니다. 이 함수는 파일을 읽어 각 줄을 배열로 반환하는 기능을 하는데, 프로미스를 사용하면 비동기 처리의 성공(then)과 실패(catch)를 명확하게 구분할 수 있습니다.

javascriptCopy code
readFileAsArray('./numbers.txt')
  .then(lines => {
    const numbers = lines.map(Number);
    const oddNumbers = numbers.filter(n => n % 2 === 1);
    console.log('Odd numbers count:', oddNumbers.length);
  })
  .catch(console.error);

이처럼 프로미스를 사용하면 에러 처리가 쉬워지고, 비동기 코드의 흐름이 더 명확해집니다. `then` 메소드는 성공적인 비동기 연산의 결과를 다루고, `catch` 메소드는 에러를 처리합니다.

 

Async/Await의 등장

프로미스는 많은 문제를 해결했지만, 여전히 `.then`과 `.catch` 체이닝이 코드를 다소 장황하게 만들 수 있습니다. 이에 대한 해결책으로 `async/await` 문법이 도입되었습니다. 이 문법을 사용하면 비동기 코드를 동기 코드처럼 간결하고 명료하게 작성할 수 있습니다.

 

예를 들어, `countOdd` 함수는 파일에서 홀수의 개수를 세는 함수입니다. `async/await`를 사용하면 다음과 같이 코드를 작성할 수 있습니다.

javascriptCopy code
async function countOdd() {
  try {
    const lines = await readFileAsArray('./numbers');
    const numbers = lines.map(Number);
    const oddCount = numbers.filter(n => n % 2 === 1).length;
    console.log('Odd numbers count:', oddCount);
  } catch(err) {
    console.error(err);
  }
}
countOdd();

여기서 `await` 키워드는 프로미스의 완료를 기다리며, 비동기 함수가 마치 동기 함수처럼 작동하도록 합니다. `try/catch` 블록은 에러를 잡아내고 처리합니다.

 

이벤트 에미터(EventEmitter)의 중요성과 활용

`Node.js`에서 이벤트 에미터는 객체 간의 통신과 이벤트 관리에 핵심적인 역할을 합니다. 이벤트 에미터는 `Node.js`의 비동기 이벤트 기반 아키텍처의 중심이며, 이는 `Node.js`가 효율적인 이벤트 처리를 가능하게 하는 주요 요소입니다.

 

이벤트 에미터의 기본 개념

`Node.js`의 모든 이벤트 기반 아키텍처는 `EventEmitter` 클래스를 기반으로 합니다. 이 클래스를 사용하여, 개발자는 커스텀 이벤트를 생성하고, 이에 대한 리스너를 등록할 수 있습니다. 이벤트 에미터의 기본적인 사용법은 다음과 같습니다:

 

클래스 상속: `EventEmitter`를 상속받아 새로운 클래스를 만듭니다. 이를 통해 해당 클래스의 인스턴스는 이벤트를 발생시키고 감지할 수 있는 능력을 갖춥니다.\

const EventEmitter = require('events');

class MyEmitter extends EventEmitter {}

const myEmitter = new MyEmitter();

 

 

이벤트 리스너 등록: .`on()` 메소드를 사용하여 특정 이벤트에 대한 리스너(콜백 함수)를 등록합니다. 이벤트가 발생하면 이 리스너가 호출됩니다.

myEmitter.on('event', () => {
  console.log('An event occurred!');
});

 

이벤트 발생: .emit() 메소드를 사용하여 이벤트를 발생시킵니다. 이 때 등록된 리스너들이 실행됩니다.

myEmitter.emit('event');

 

이벤트 에미터의 사용법

이벤트 에미터는 단순한 이벤트 발생과 리스닝 외에도 다양한 고급 기능을 제공합니다:

  • 동기 vs 비동기: 이벤트 에미터는 동기적으로도, 비동기적으로도 작동할 수 있습니다. 이벤트 리스너 내에서 비동기 작업을 수행할 수 있으며, 이는 이벤트 처리의 유연성을 높여줍니다.
  • 에러 처리: 에러 이벤트('error')는 특별히 처리되며, 리스너가 등록되지 않은 에러 이벤트는 예외를 발생시킵니다. 이를 통해 예외 상황을 효과적으로 관리할 수 있습니다.
myEmitter.on('error', (err) => {
  console.error('An error occurred:', err);
});
  • 한 번만 실행되는 이벤트 리스너: `.once()` 메소드를 사용하여 이벤트 리스너를 한 번만 실행되도록 설정할 수 있습니다. 이는 일회성 이벤트 처리에 유용합니다.
myEmitter.once('event', () => {
  console.log('This will only happen once.');
});

 

리스너의 추가와 제거: `.addListener()` 또는 `.on()`으로 리스너를 추가하고, `.removeListener()` 또는 `.off()`로 리스너를 제거할 수 있습니다. 이를 통해 동적으로 이벤트 리스너를 관리할 수 있습니다.

 

작업 실행 모니터링 클래스 예시

`WithLog` 클래스는 `EventEmitter`를 상속받아, 특정 작업의 시작과 끝에 이벤트를 발생시키는 기능을 구현합니다. 이 클래스를 통해 작업 실행 전후의 상태를 로깅할 수 있습니다.

const EventEmitter = require('events');

class WithLog extends EventEmitter {
  execute(taskFunc) {
    console.log('Before executing');
    this.emit('begin');
    taskFunc();
    this.emit('end');
    console.log('After executing');
  }
}

const withLog = new WithLog();

withLog.on('begin', () => console.log('About to execute'));
withLog.on('end', () => console.log('Done with execute'));

withLog.execute(() => console.log('*** Executing task ***'));

이 코드에서 `WithLog` 클래스는 `execute` 메소드를 통해 제공된 함수를 실행하기 전과 후에 `begin`과 `end` 이벤트를 발생시킵니다. 이벤트 리스너는 해당 이벤트가 발생할 때마다 콘솔에 로그를 출력합니다.

 

비동기 작업과 이벤트 에미터

이벤트 에미터는 비동기 작업을 모니터링하는 데에도 사용할 수 있습니다. 예를 들어, 파일 시스템의 비동기 작업을 추적하는 `WithTime` 클래스를 구현할 수 있습니다.

const fs = require('fs');

class WithTime extends EventEmitter {
  execute(asyncFunc, ...args) {
    console.time('execute');
    this.emit('begin');
    asyncFunc(...args, (err, data) => {
      if (err) {
        return this.emit('error', err);
      }

      this.emit('data', data);
      console.timeEnd('execute');
      this.emit('end');
    });
  }
}

const withTime = new WithTime();

withTime.on('begin', () => console.log('About to execute'));
withTime.on('data', (data) => console.log('Data:', data));
withTime.on('end', () => console.log('Done with execute'));

withTime.execute(fs.readFile, __filename);

이 코드에서 `WithTime` 클래스는 `execute` 메소드를 통해 파일 읽기와 같은 비동기 함수를 실행하고, 시작(begin), 데이터 수신(data), 완료(end), 에러(error) 등의 다양한 이벤트를 발생시킵니다. 각 이벤트에 따라 적절한 로깅 또는 에러 처리를 수행할 수 있습니다.

 

에러 이벤트의 처리

`WithTime` 클래스의 예를 들어보겠습니다. 이 클래스는 비동기 함수를 실행하고, 이 과정에서 발생하는 이벤트를 처리합니다.

class WithTime extends EventEmitter {
  execute(asyncFunc, ...args) {
    console.time('execute');
    asyncFunc(...args, (err, data) => {
      if (err) {
        return this.emit('error', err); // 에러 이벤트 발생
      }

      console.timeEnd('execute');
    });
  }
}

const withTime = new WithTime();

// 잘못된 파일 경로로 인한 에러 발생
withTime.execute(fs.readFile, ''); // BAD CALL

위 코드에서 잘못된 파일 경로를 제공하면 `fs.readFile`은 에러를 반환합니다. 이 경우, `WithTime` 클래스는 에러 이벤트('error')를 발생시킵니다. 이 이벤트가 처리되지 않으면 `Node.js`는 에러를 발생시키고 프로그램이 종료됩니다.

 

에러 리스너의 등록

에러를 적절히 처리하기 위해서는 에러 이벤트에 대한 리스너를 등록해야 합니다.

withTime.on('error', (err) => {
  console.error('An error occurred:', err);
});

이 리스너는 `error` 이벤트가 발생했을 때 실행되며, 에러 정보를 로깅합니다. 이를 통해 애플리케이션이 비정상적으로 종료되는 것을 방지할 수 있습니다.

 

uncaughtException 리스너의 사용

모든 에러를 캐치하지 못할 경우, `process` 객체의 `uncaughtException` 이벤트 리스너를 사용하여 처리할 수 있습니다.

process.on('uncaughtException', (err) => {
  console.error('Something went unhandled:', err);
  process.exit(1); // 강제 종료
});

이 리스너는 처리되지 않은 예외가 발생할 때 실행되며, 로그를 남기고 프로세스를 종료합니다. 이는 마지막 수단으로 사용되어야 하며, 정상적인 에러 처리 흐름을 대체하는 용도로 사용되어서는 안 됩니다.

리스너의 우선 순위와 관리

이벤트 에미터에서는 리스너의 등록 순서에 따라 실행 순서가 결정됩니다. 만약 특정 리스너를 먼저 실행하고 싶다면, `prependListener` 메소드를 사용할 수 있습니다.

withTime.prependListener('data', (data) => {
  console.log(`Characters: ${data.toString().length}`);
});

withTime.on('data', (data) => {
  console.log(`Length: ${data.length}`);
});

'Backend > Node.js' 카테고리의 다른 글

Node.js child_process  (1) 2024.01.08
Node.JS) Module Caching 을 알아보자  (0) 2023.09.14
Node.js TDD 단위테스트, 통합테스트  (0) 2023.07.14
Node.js 작동방식 및 Event loop  (0) 2023.06.26
Node.js  (0) 2023.06.26