Vana Blog

Node.js Design Pattern - 관찰자 패턴(The observer pattern)

April 10, 2019

관찰자 패턴은 상태 변화가 일어날 때 관찰자에게 알릴 수 있는 객체를 정의하는 것.

EventEmitter 클래스

관찰자 패턴은 이미 Node.js 코어에 내장되어 있으며 EventEmitter클래스를 통해 사용할 수 있다. EventEmitter클래스를 사용하여 특정 유형의 이벤트가 발생되면 호출될 하나 이상의 함수를 Listener로 등록할 수 있다.

const EventEmitter = require('events').EventEmitter;
const eeInstance = new EventEmitter();

EventEmitter의 필수 메소드

method Description
on(event, lisener) 주어진 이벤트 유형(문자열)에 대해 새로운 listener를 등록할 수 있다.
once(event, listener) 첫 이벤트가 전달된 후 제거되는 새로운 listener를 등록한다.
emit(event, [arg1], […]) 새 이벤트를 생성하고 listener에게 전달할 추가적인 인자들을 지원한다.
removeListener(event, listener) 지정된 이벤트 유형에 대한 listener를 제거한다.

EventEmitter 생성 및 사용

새로운 인스턴스를 만들어 바로 사용하는 방법.

const EventEmitter = require('events').EventEmitter;
const fs = require('fs');
function findPattern(files, regex) {
const emitter = new EventEmitter();
files.forEach(function(file) {
fs.readFile(file, 'utf8', (err, content) => {
if(err)
return emitter.emit('error', err);
emitter.emit('fileread', file);
let match;
if(match = content.match(regex))
match.forEach(elem => emitter.emit('found', file, elem));
});
});
return emitter;
}
// event listener 등록
findPattern(['fileA.txt', 'fileB.json'], /hello \w+/g)
.on('fileread', file => console.log(file + ' was read'))
.on('found', (file, match) => console.log('Matched "' + match + '"in file ' + file))
.on('error', err => console.log('Error emitted: ' + err.message));
view raw findPattern01.js hosted with ❤ by GitHub

오류 전파

EventEmitter는 이벤트가 비동기적으로 발생할 경우, 이벤트 루프에서 손실될 수 있기 때문에 callback과 같이 예외가 발생해도 바로 throw할 수 없다. 대신 error라는 특수한 이벤트를 발생 시키고 Error 객체를 인자로 전달한다.

Node.js는 특별한 방식으로 에러 이벤트를 처리하고 예외를 자동으로 throw하며, 이에 연결된 리스너가 없는 경우 프로그램을 종료하므로 항상 에러 이벤트에 대한 리스너를 등록하는 것이 좋다.

관찰 가능한 객체 만들기

findPattern 객체가 EventEmitter의 기능을 상속받아 사용하는 방법. Node.js에서 일반적인 패턴이다. EventEmitter를 확장하는 객체는 stream등이 있다.

const EventEmitter = require('events').EventEmitter;
const fs = require('fs');
class FindPattern extends EventEmitter {
constructor (regex) {
super();
this.regex = regex;
this.files = [];
}
addFile (file) {
this.files.push(file);
return this;
}
find() {
this.files.forEach( file => {
fs.readFile(file, 'utf8', (err, content) => {
if (err) {
return this.emit('error', err);
}
this.emit('fileread', file);
let match = null;
if(match = content.match(this.regex)) {
match.forEach(elem => this.emit('found', file, elem));
}
});
});
return this;
}
}
const findPatternObject = new FindPattern(/hello \w+/);
findPatternObject
.addFile('fileA.txt')
.addFile('fileB.json')
.find()
.on('found', (file, match) => console.log(`Matched "${match}" in file ${file}`))
.on('error', err => console.log(`Error emitted ${err.message}`));
view raw findPattern02.js hosted with ❤ by GitHub

동기 및 비동기 이벤트

이벤트는 callback과 마찬가지로 동기식 또는 비동기식으로 생성될 수 있다. 그러나 동일한 EventEmitter에서 두 방식을 혼용해서는 안된다. 동기/비동기 이벤트의 차이점은 리스너를 등록하는 방법이다.

방식 설명
동기 EventEmitter함수가 이벤트를 내보내기 전에 모든 리스너가 등록되어야 함.
비동기 EventEmitter가 초기화된 후에도 프로그램은 새로운 리스너를 등록할 수 있음.
const EventEmitter = require('events').EventEmitter;
 
class SyncEmit extends EventEmitter {
  constructor() {
    super();
    this.emit('ready');
  }
}
 
const syncEmit = new SyncEmit();
syncEmit.on('ready', () => console.log('Object is ready to be used'));

위의 코드는 ready 이벤트가 비동기적을 발생했다면 동작하겠지만, 동기적으로 생성되면 이벤트가 이미 전송된 후 리스너가 등록되어 리스너가 호출되지 않는다.

EventEmitter vs callback

결과가 비동기 방식으로 반환되어야 하는 경우에는 callback 사용. 일어난 무엇인가를 전달 할 때 Event 사용.

// event
function helloEvents() {
  const eventEmitter = new EventEmitter();
  setTimeout(() => eventEmitter.emit('hello', 'hello world'), 100);
  return eventEmitter;
}
 
// callback
function helloCallback(callback) {
  setTimeout(() => callback('hello world'), 100);
}

EventEmitter는 동일한 이벤트가 여러 번 발생할 수도, 전혀 발생하지 않을 수도, 한번만 발생할 수도 있지만 callback은 작업의 성공 여부와 상관없이 정확히 한번 호출 되어야한다. event 발생에 초점을 맞춘다면 결과를 callback으로 전달 받기 보다 EventEmitter로 받는 편이 더 명확하다.

callback과 EventEmitter의 결합

패턴 예) glob 스타일 파일 검색 라이브러리

// main method
glob(pattern, [options], callback)
 
// 사용 에
const glob = require('glob');
glob('data/*.txt', (error, file) => console.log(`All files found: ${JSON.stringify(files)}`))
.on('match', match => console.log(`Match found: ${match}`));

module.exports = glob
var fs = require('fs')
function Glob (pattern, options, cb) {
if (typeof options === 'function') {
cb = options
options = null
}
if (options && options.sync) {
if (cb)
throw new TypeError('callback provided to sync glob')
return new GlobSync(pattern, options)
}
...
this.matches = new Array(n)
if (typeof cb === 'function') {
cb = once(cb)
this.on('error', cb)
this.on('end', function (matches) {
cb(null, matches)
})
}
...
function done () {
--self._processing
if (self._processing <= 0) {
if (sync) {
process.nextTick(function () {
self._finish()
})
} else {
self._finish()
}
}
}
}
view raw node_glob.js hosted with ❤ by GitHub


Vana Yun

Written by Vana Yun