Nest.js EventEmitter에서 Error 발생 시 서버 종료 현상

@beygee· June 16, 2023 · 5 min read

댓글로 한 분께서 23년 8월 부로 https://github.com/nestjs/event-emitter/pull/936/commits/e322cb820f528640545e2e5685bdb141bfdc9142 해당 PR 에 의해 suppressErrors 옵션이 추가되어 (Default: true) 이벤트 수신자에서 에러 발생 시에도 서버 종료가 되지 않는것으로 확인됨을 알려주셨습니다.

Overview.

nest.js 서버에서 이벤트 아키텍처를 이용할 일이 있어서 @nestjs/event-emitter 라이브러리를 사용하고 있었습니다. 프로덕션 환경에서 eventEmitter로 발행한 이벤트를 수신하여 비즈니스 로직을 처리하는 과정에서 런타임 에러가 발생했는데, 이게 서버 중단 현상을 초래했었습니다. 😅

예를 들어 아래와 같은 event.fired 이벤트 수신 과정에 문제가 생겼다고 가정해봅시다.

@OnEvent('event.fired', { async: true })
public async handleEvent(event: Event) {
  throw new Error('Event Error Fired!')
}

그럼 아래 로그와 같이 런타임 에러가 발생하여 이후 서버는 아무 요청도 받을 수 없게 됩니다. log1

nest.js 패키지에서 이런 에러 핸들링이 되지 않았던 걸까요?

예외 처리

nest.js exception.filter docs에 따르면, 요청, 응답과 같은 Execution Context 생애주기에서 발생하는 에러는 ExceptionFilter에서 에러를 캐치하여 예외를 처리할 수 있습니다.

하지만 EventEmitter와 같이 Context를 벗어난 곳에서는 따로 예외를 처리해주지 않습니다. 옆 동네 @nestjs/schedule 같은 경우는 어떨까요?

Cron의 경우

@Cron("*/5 * * * * *")
public async handleCron() {
  throw new Error("Cronjob Error!")
}

위 코드를 돌려보면 아래와 같은 로그가 출력됩니다. log2

한번에 서버가 종료되지 않고, 기본 nest.js loggerConsoleLoggerError 구문이 출력된 것을 볼 수 있습니다. CronjobeventEmitter 둘 다 Execution Context 외부의 실행환경인데 왜 하나는 예외 처리가 되어있고 다른 하나는 서버가 중단될까요? Cronjob의 경우는 누군가 이미 이슈를 제기하였고, 이에 따라 이 변경점으로 수정이 되었습니다.

하지만 EventEmitter의 경우는 대응되고 있지 않았습니다. 그래서 PR을 보내기 전 OnEvent 데코레이터를 급하게 수정할 필요가 있었습니다. 데코레이터 사용 경험을 그대로 살리면서 안전하게 서버가 중단되지 않도록 해야했습니다.

데코레이터 수정

// on-safe-event.decorator.ts

import { applyDecorators, Logger } from "@nestjs/common"
import { OnEvent, OnEventType } from "@nestjs/event-emitter"
import { OnEventOptions } from "@nestjs/event-emitter/dist/interfaces"

function _OnSafeEvent() {
  return function (target: any, key: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value

    const metaKeys = Reflect.getOwnMetadataKeys(descriptor.value)
    const metas = metaKeys.map(key => [
      key,
      Reflect.getMetadata(key, descriptor.value),
    ])

    descriptor.value = async function (...args: any[]) {
      try {
        await originalMethod.call(this, ...args)
      } catch (err) {
        Logger.error(err, err.stack, "OnSafeEvent")
      }
    }
    metas.forEach(([k, v]) => Reflect.defineMetadata(k, v, descriptor.value))
  }
}

export function OnSafeEvent(
  event: OnEventType,
  options?: OnEventOptions | undefined
) {
  return applyDecorators(OnEvent(event, options), _OnSafeEvent())
}

함수 데코레이터를 만들어 내부에서 try-catch로 감싸 예외처리를 해주고, 에러 발생 시 Logger로 출력하도록 합니다. 이후에 기존의 nestjs/event-emitterOnEvent와 데코레이터를 합성시켜주면 간단하게 구현할 수 있습니다.

적용 후

이후에 OnEvent를 사용하던 곳에 OnSafeEvent로 변경을 해주면 됩니다.

@OnSafeEvent('event.fired', { async: true })
async handleEvent(event: Event) {
  throw new Error('Event Error Fired!')
}

@Timeout(3000)
async handleTimeout() {
  this.eventEmitter.emit('event.fired')
}

@Timeout(4000)
async handleTimeout2() {
  this.eventEmitter.emit('event.fired')
}

그러면 다음과 같이 에러를 만나도 중단되지 않고 Logger로 에러가 처리되는 모습을 볼 수 있습니다.

log3

이로써 EventHandler 환경에서도 예상치 못한 에러가 발생했을 경우도 서버에 치명적인 손상을 주지 않으면서 대처가 가능하게 되었습니다. 물론 nest.js CQRS 환경에서도 이와 비슷한 이슈가 발생한다고는 합니다 🦦 현 상황에서는 각각의 아키텍처에 맞게 에러 테스트를 해보면서 서버를 견고하게 만들 필요가 있어보입니다.

process.nextTick이나 setTimeout과 같이 Node.js Event Loop의 Timers phasemicrotask queue에서 처리되는 비동기 콜백함수 내부에서 발생하는 에러를 적절히 처리해야하는 상황도 생길 수 있을 것 같습니다.

References.

@beygee
미션 달성을 위해 실험적인 도전부터 안정적인 설계까지 구현하는 것을 즐겨합니다.