Node.js 이벤트 리스너 안의 비동기 함수, 어디까지 안전할까? (EventEmitter with async)
Node.js 이벤트 리스너 내 비동기 함수, 어디까지 안전할까?
들어가며
Node.js 기반 서버에서 EventEmitter를 이용한 실시간 이벤트 처리는 WebSocket, 알림 시스템, Redis Pub/Sub, 스트리밍 데이터 처리 등에서 핵심적인 역할을 합니다. 하지만 이벤트 리스너 내부에서 비동기 함수(async)를 사용할 때는 데이터 정합성과 처리 순서에 대한 세심한 주의가 필요합니다.
이 글은 실제 운영 중인 거래소 시스템에서 발생했던 **"클라이언트로 빈 잔고 객체가 전송되는 문제"**를 통해, 이벤트 리스너 내 비동기 함수 처리 시 고려해야 할 핵심 사항들을 정리한 경험담입니다.
문제 상황
🚨 증상: 빈 객체가 클라이언트로 전송
운영 중인 거래소 시스템에서 사용자 수가 증가하면서, 잔고 변경 이벤트를 수신하는 WebSocket 채널에서 간헐적으로 빈 객체 {}가 응답으로 전송되는 현상이 발생했습니다.
// 정상적인 경우
{
"userId": "12345",
"balance": {
"KRW": 1000000,
"BTC": 0.5
},
"sequence": 1001
}
// 문제 상황
{} // 빈 객체가 전송됨
🏗️ 기존 시스템 구조
// 이벤트 수신 및 처리 구조
this.balanceNotifier.on('balance', (data) => {
this._balanceListener(data).catch((err) => {
logUtil.info('error in notification handler', err);
});
});
시스템 구성 요소:
- 이벤트 수신 주체:
balanceNotifier(RabbitMQ로부터 잔고 증감분 수신) - 이벤트 처리 대상: 사용자 WebSocket 핸들러 클래스
- 로직 처리 함수:
_balanceListener(data)(비동기 함수) - 데이터 조회: 내부 gRPC 호출을 통한 사용자 잔고 상태 확인
원인 분석
🔍 문제의 핵심: 이벤트와 DB 상태 간 정합성 불일치
이벤트 핸들러는 다음과 같은 로직을 가지고 있었습니다:
- 이벤트 데이터에서 누락된 카테고리가 있으면 → gRPC로 전체 잔고 조회
- 조회된 잔고와 이벤트 시퀀스를 비교하여 변화가 있는 경우에만 클라이언트로 전송
💥 문제 발생 시나리오

문제 요약: 이벤트는 순차 처리되었지만, DB 조회 시점에는 이미 최신 상태가 반영되어 있어 이벤트와 DB 상태 간 시간차로 인한 정합성 불일치 발생
해결 방안
✅ 핵심 해결책: 이벤트 중심 아키텍처로 전환
개선된 설계 원칙:
- 최초 접속 시점에 한해 gRPC로 사용자 잔고를 snapshot으로 가져옴
- 이후 발생하는 잔고 변화는 오직 이벤트 기반으로만 반영
- gRPC 호출 제거로
_balanceListener()를 동기 함수로 변경 - 이벤트 리스너 내부에서 완전한 동기 처리 구조 구현
🎯 결과
- DB 상태와 이벤트 시퀀스 불일치 문제 해결
- 빈 객체 전송 현상 완전 제거
- 시스템 복잡도 감소 및 성능 향상
안전한 비동기 처리 패턴
이벤트 리스너에서 비동기 함수 사용이 불가피한 경우, 반드시 직렬 처리 전략을 적용해야 합니다.
🔧 방법 1: 유저별 Promise Queue
class EventHandler {
private eventQueue: { [userId: string]: Promise<any> } = {};
handleBalanceEvent(userId: string, data: any) {
if (!this.eventQueue[userId]) {
this.eventQueue[userId] = Promise.resolve();
}
this.eventQueue[userId] = this.eventQueue[userId]
.then(() => this._balanceListener(data))
.catch(err => {
logUtil.error('event processing error', err);
});
}
private async _balanceListener(data: any) {
// 비동기 처리 로직
await this.processBalanceUpdate(data);
}
}
특징:
- 유저별 독립적인 Promise 체인 생성
- 각 유저의 이벤트가 절대로 동시 실행되지 않도록 보장
- 메모리 효율적이고 구현이 간단
🔧 방법 2: async.queue 라이브러리 활용
import async from 'async';
class EventHandler {
private userQueues: { [userId: string]: async.QueueObject<any> } = {};
handleBalanceEvent(userId: string, data: any) {
if (!this.userQueues[userId]) {
this.userQueues[userId] = async.queue(async (task, done) => {
try {
await this._balanceListener(task);
done();
} catch (error) {
done(error);
}
}, 1); // concurrency: 1로 직렬 처리
}
this.userQueues[userId].push(data);
}
private async _balanceListener(data: any) {
// 비동기 처리 로직
await this.processBalanceUpdate(data);
}
}
특징:
- 라이브러리의 안정성과 다양한 옵션 활용
- 큐 상태 모니터링 및 에러 핸들링 개선
- 복잡한 워크플로우에 적합
실무 적용 가이드
📋 이벤트 리스너 내 비동기 함수 사용 판단 기준
| 상황 | 비동기 함수 사용 가능 여부 | 필수 고려사항 |
|---|---|---|
| 네트워크 요청 포함 (DB, gRPC 등) | ❌ 반드시 직렬화 처리 필요 | Promise Queue 또는 async.queue |
| 순서에 민감한 상태 업데이트 | ❌ 엄격한 순서 보장 필요 | 동기 처리 권장 |
| 단순 로깅 또는 부작용 없는 로직 | ✅ 상대적으로 안전 | 에러 처리만 충분히 |
| 클라이언트 상태 전달 포함 | ❌ 정확한 순서 보장 필수 | 이벤트 기반 아키텍처 권장 |
🎯 Best Practices
1. 이벤트 중심 설계 원칙
// ✅ 좋은 예: 이벤트만을 통한 상태 관리
class BalanceManager {
private balanceState: Map<string, Balance> = new Map();
// 최초 한 번만 DB에서 로드
async initializeBalance(userId: string) {
const balance = await this.loadBalanceFromDB(userId);
this.balanceState.set(userId, balance);
}
// 이후 모든 변경은 이벤트로만
handleBalanceEvent(userId: string, delta: BalanceDelta) {
const currentBalance = this.balanceState.get(userId);
const newBalance = this.applyDelta(currentBalance, delta);
this.balanceState.set(userId, newBalance);
// 클라이언트로 전송 (동기 처리)
this.sendToClient(userId, newBalance);
}
}
2. 에러 처리 및 모니터링
// 이벤트 리스너 에러 처리
this.balanceNotifier.on('balance', (data) => {
try {
this._balanceListener(data);
} catch (error) {
// 동기 에러 처리
logUtil.error('sync event processing error', error);
this.sendErrorToClient(data.userId, error);
}
});
// 비동기 함수 필요 시 Promise 에러 처리
this.balanceNotifier.on('balance', (data) => {
this.handleAsyncEvent(data).catch(error => {
logUtil.error('async event processing error', error);
this.sendErrorToClient(data.userId, error);
});
});
핵심 요약
🎯 주요 교훈
-
이벤트 리스너 내에서
async함수를 사용할 수는 있지만, 순차 처리와 상태 정합성을 반드시 함께 고려해야 한다. -
이벤트 기반 데이터 흐름에서는 "이벤트만이 진실"이어야 하며, 외부 조회 결과로 상태를 덮어쓰는 것은 위험할 수 있다.
-
가능하다면 비동기 처리를 제거하고 동기 흐름으로 단순화하는 것이 가장 확실한 안정화 방법이다.
🔑 핵심 원칙
- 단순성 우선: 복잡한 비동기 처리보다는 동기 처리로 단순화
- 이벤트 중심: DB 상태보다는 이벤트 스트림을 신뢰의 기준으로
- 순서 보장: 비동기 함수 사용 시 반드시 직렬화 처리 구현
- 에러 처리: 모든 비동기 처리에 대한 적절한 에러 핸들링
마무리
비동기 함수 자체가 문제가 아니라, 그것이 처리하는 데이터와 시스템 설계가 일치하지 않을 때 진짜 문제가 생깁니다.
실시간 시스템에서의 데이터 정확성은 곧 사용자 신뢰와 직결됩니다. 이벤트 기반 아키텍처를 설계할 때는 단순함과 일관성을 우선시하고, 복잡한 비동기 처리보다는 명확한 데이터 흐름을 만드는 것이 중요합니다.
이 경험이 여러분의 Node.js 이벤트 기반 시스템 설계에 도움이 되기를 바랍니다.