Node.js 의 Callback, EventEmitter

Chang-__-·2023년 3월 10일
0

Callback

Node.js 에 아주 기본적인 메커니즘이 콜백이다. 콜백이란 비동기 작업의 결과를 가지고 런타임에 의해 호출되는 함수이다. 실제로 콜백 없이는 Promise도 존재할 수 없으며 그렇게 되면 async/await 또한 존재할 수 없다.

function addSync(a, b, cb) {
	cb(a + b)
}
console.log('before')
addSync(1, 2, result => console.log(result))
console.log('after')

addSync 함수가 동기적으로 동작하며 출력결과는 이렇게 나온다.

before
3
after

그러면 만약에 비동기적으로 동작하려면 어떻게 해야할까?

function addAsync(a, b, cb){
	setTimeout(() => cb(a + b), 100)
}
console.log('before')
addAsync(1, 2, result => console.log(result))
console.log('after')

위의 코드는 출력이 이렇게 된다.

before
after
3

setTimeout() 함수는 비동기 작업을 실행시켜서 콜백의 실행이 끝날 때 까지 기다리지 않고 즉시 반환되어 addAsync() 함수로 이벤트 루프의 제어를 돌려준다.
여기서 중요한 부분은 비동기 요청이 전달된 후 즉시 제어를 이벤트 루프에 돌려주고 큐에 있는 새로운 이벤트가 처리될 수 있도록 하기 때문이다.

sync? async?

import { readFile } from 'fs'

const cache = new Map()

function inconsistentRead (filename, cb) {
  if (cache.has(filename)) {
    cb(cache.get(filename))
  } else {
    readFile(filename, 'utf8', (err, data) => {
      cache.set(filename, data)
      cb(data)
    })
  }
}

위의 코드를 확인 해보면 inconsistentRead() 함수는 critical 할 여지가 있다.
파일이 처음 읽혀지고 캐싱될 때까지는 비동기적으로 동작하지만 캐시에 이미 있는 로직처리는 동기적이기 때문이다.

만약에 이런 코드로 inconsistentRead() 함수가 있다고 가정을 해보자.

function createFileReader (filename) {
  const listeners = []
  inconsistentRead(filename, value => {
    listeners.forEach(listener => {
      listener(value)
    })
  })
  return {
    onDataReady: listener => listeners.push(listener)
  }
}

const reader1 = createFileReader('data.txt')
reader1.onDataReady(data => {
  console.log(`First call data: ${data}`)

  const reader2 = createFileReader('data.txt')
  reader2.onDataReady(data => {
    console.log(`Second call data: ${data}`)
  })
})

이 코드를 실행하면 아래와 같이 실행 된다.

First call data: some data

왜 첫번째 콜백만 호출 되고 두번째 콜백만 호출 될까?
1. reader1 이 생성 되는 동안 inconsistentRead() 함수는 캐시가 없으므로 readFile()을 비동기적으로 실행한다.
2. reader2 는 reader1 과 같은 이벤트 루프 사이클에서 생성이 된다. 이 때 reader1은 캐시가 있으므로 동기적으로 처리가 되는데, listener를 reader2의 생성후에 등록을 하게 되는데 이들이 호출이 될 수 없다.

그렇다면 어떻게 해야하나?
방법은 두개인데 완전 동기로 만들거나 완전 비동기로 만들어야한다.
완전 동기로 만드는 방법은 readFileSync를 사용하면 된다. 하지만 만약에 파일이 클 경우에는 문제가 될 수 있다. 그렇다면 비동기로 만들어 줘야하는데 캐시로직을

 if (cache.has(filename)) {
    process.nextTick(() => cb(cache.get(filename)))
  }

이렇게 바꿔주면 된다. 참고로 process.nextTick은 node.js 의 이벤트 루프에 마이크로 테스크라고 불리며 현재의 작업이 완료된 후에 바로 실행되고 다른 I/O 이벤트가 발생하기 전에 실행된다.

EventEmitter

또 중요한 패턴이 하나가 더 있는데 관찰자 패턴이다. 이는 Node.js의 반응적 특성을 모델링하고 콜백을 완벽하게 보완하는 해결책이다. EventEmitter도 이 패턴을 사용한다.

EventEmitter 클래스를 사용하여 특정 유형의 이벤트가 발생되면 호출되는 하나 이상의 함수를 리스너로 등록 할 수 있다.

EventEmitter는 events 코어 모듈로 부터 import 해서 가져 올 수 있다.

import { EventEmitter } from 'events'
const emitter = new EventEmitter()

메소드는 4가지가 있는데
on: 주어진 이벤트 유형에 대해 새로운 리스너를 등록 할 수 있다.
once: 첫 이벤트가 전달된 후 제거되는 새로운 리스너를 등록한다.
emit: 새 이벤트를 생성하고 리스너에게 전달할 매개변수들을 제공한다.
remove: 이벤트 유형에 대한 리스너를 제거한다.

다음과 같은 클래스가 있다고 가정을 해보자.

import { EventEmitter } from 'events'
import { readFile } from 'fs'

class FindRegex extends EventEmitter {
  constructor (regex) {
    super()
    this.regex = regex
    this.files = []
  }

  addFile (file) {
    this.files.push(file)
    return this
  }

  find () {
    for (const file of this.files) {
      readFile(file, 'utf8', (err, content) => {
        if (err) {
          return this.emit('error', err)
        }

        this.emit('fileread', file)

        const match = content.match(this.regex)
        if (match) {
          match.forEach(elem => this.emit('found', file, elem))
        }
      })
    }
    return this
  }
}

이 클래스에서 find() 메서드를 보면 'error', 'fileread', 'found' 에 대해서 이벤트를 emit 하고 있다. (참고로 class 에서 eventemitter를 사용하려면 상속받으면 된다. super() 도 constructor 에서 반드시 사용해야함.)
클래스에서 이벤트를 emit 했으니 구독을하는 방법은 다음과 같다.

const findRegexInstance = new FindRegex(/hello \w+/)
findRegexInstance
  .addFile('fileA.txt')
  .addFile('fileB.json')
  .find()
  .on('fileread', file => console.log(`${file} was read`))
  .on('found', (file, match) => console.log(`Matched "${match}" in file ${file}`))
  .on('error', err => console.error(`Error emitted ${err.message}`))

on 을 사용하여 emit 된 각각의 이벤트에 구독을 한다.

EventEmitter의 메모리 누수

관찰 가능한 주체들에 구독을 하고 더이상 필요해지지 않을때 이것은 메모리 누수 현상으로 이어질 수 있다.
Node.js에서는 EventEmitter 리스너의 등록을 해지 해줘야 이점이 방지가 된다.
기본적인 변수들은 가비지컬렉터가 돌아서 메모리를 해지해주지만 eventemitter 에선 removeListener 라는 함수를 통해 구독을 해지할 수 있다.

0개의 댓글