본문 바로가기
Q&A해결2026년 8월 16일1분 읽기

EventEmitter 리스너를 async로 만들었더니 에러가 조용히 사라지고 서버가 죽기도 합니다

e
emitter_kyungmin
조회 343댓글 2

주문 생성되면 후처리(알림 발송, 통계 갱신 등)를 이벤트로 느슨하게 붙이려고 EventEmitter를 쓰고 있어요. 리스너 안에서 await 할 게 있어서 async 함수로 등록했습니다.

orderEvents.on('created', async (order) => {
  await sendNotification(order)  // 여기서 reject되면?
  await updateStats(order)
})
orderEvents.emit('created', order)

문제는 리스너 안에서 에러가 나면 어떤 땐 그냥 조용히 묻혀서 알림이 안 갔는데도 아무 로그가 없고, 어떤 땐 unhandledRejection으로 프로세스가 죽습니다. emit 하는 쪽에서 try/catch로 감싸도 안 잡히고요. 이벤트 기반으로 짜면 안 되는 건가요?

댓글 2

김김영삼2개월 전
이벤트 기반이 문제가 아니라, EventEmitter가 async 리스너를 위해 만들어진 물건이 아니라서 생기는 일이에요. emit()은 리스너들을 동기적으로 순서대로 호출하고 각 리스너가 리턴하는 값을 그냥 버립니다. async 함수는 Promise를 리턴하는데 emit이 그걸 안 받아주니까, 리스너 안에서 reject가 나도 emit 호출부로 전달될 방법이 없어요. 그래서 emit을 try/catch로 감싸도 안 잡히는 겁니다. 그 reject는 아무도 안 받은 채로 붕 떠서 unhandledRejection이 되고, Node 버전/타이밍에 따라 조용히 묻히거나 프로세스를 죽이는 거고요.

원칙은 하나예요. async 리스너는 자기 안에서 에러를 끝까지 스스로 처리해야 합니다. 밖으로 새어나가게 두면 안 돼요.

orderEvents.on('created', async (order) => {
try {
await sendNotification(order)
await updateStats(order)
} catch (e) {
logger.error('order created 후처리 실패', e)
// 필요하면 재시도 큐에 넣기
}
})

이렇게 리스너마다 try/catch로 감싸는 게 기본입니다.

그런데 한 발 물러서서 보면, 후처리가 실패하면 안 되는(알림 꼭 가야 하고 통계 꼭 맞아야 하는) 일이라면 EventEmitter로 느슨하게 붙이는 것 자체가 안 맞아요. EventEmitter는 같은 프로세스 안에서, 실패해도 그만인 fire-and-forget 알림에나 어울립니다. 프로세스가 죽으면 대기 중이던 이벤트는 그냥 증발하고, 재시도나 순서 보장, 최소 한 번 실행 같은 게 하나도 없거든요.

주문 후처리처럼 유실되면 안 되는 거면 잡 큐(BullMQ 등)로 바꾸세요. 주문 생성 트랜잭션에서 잡을 큐에 넣고, 워커가 꺼내 처리하되 실패하면 자동 재시도하고, 그래도 안 되면 dead-letter로 빼서 나중에 볼 수 있게요. 이러면 프로세스가 죽어도 잡이 큐에 남아 있어 안 잃습니다. 트랜잭션이랑 정확히 엮어야 하면 transactional outbox 패턴까지 가고요.

정리하면, 급하게는 리스너 안을 try/catch로 감싸 죽는 것부터 막고, 중요한 후처리는 이벤트 대신 잡 큐로 옮기는 걸 권합니다. 어느 쪽이든 'async 리스너의 에러는 리스너가 책임진다'는 건 무조건이에요.
eemitter_kyungmin(guest)2개월 전
아 emit이 Promise를 그냥 버린다는 걸 이제 이해했네요. 일단 try/catch로 다 감싸서 죽는 건 막았고, 알림이랑 정산 통계는 말씀대로 큐로 옮기는 작업 잡았습니다. 명쾌한 설명 감사합니다 🙏
Ctrl+Enter로 등록