주문 생성되면 후처리(알림 발송, 통계 갱신 등)를 이벤트로 느슨하게 붙이려고 EventEmitter를 쓰고 있어요. 리스너 안에서 await 할 게 있어서 async 함수로 등록했습니다.
orderEvents.on('created', async (order) => {
await sendNotification(order) // 여기서 reject되면?
await updateStats(order)
})
orderEvents.emit('created', order)문제는 리스너 안에서 에러가 나면 어떤 땐 그냥 조용히 묻혀서 알림이 안 갔는데도 아무 로그가 없고, 어떤 땐 unhandledRejection으로 프로세스가 죽습니다. emit 하는 쪽에서 try/catch로 감싸도 안 잡히고요. 이벤트 기반으로 짜면 안 되는 건가요?
댓글 2
원칙은 하나예요. 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 리스너의 에러는 리스너가 책임진다'는 건 무조건이에요.