Beenslab Blog

Node.js 이벤트 리스너 안의 비동기 함수, 어디까지 안전할까? (EventEmitter with async)

beenchangseo·2025년 7월 16일Hits

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 상태 간 정합성 불일치

이벤트 핸들러는 다음과 같은 로직을 가지고 있었습니다:

  1. 이벤트 데이터에서 누락된 카테고리가 있으면 → gRPC로 전체 잔고 조회
  2. 조회된 잔고와 이벤트 시퀀스를 비교하여 변화가 있는 경우에만 클라이언트로 전송

💥 문제 발생 시나리오

mermaid1

문제 요약: 이벤트는 순차 처리되었지만, DB 조회 시점에는 이미 최신 상태가 반영되어 있어 이벤트와 DB 상태 간 시간차로 인한 정합성 불일치 발생


해결 방안

✅ 핵심 해결책: 이벤트 중심 아키텍처로 전환

개선된 설계 원칙:

  1. 최초 접속 시점에 한해 gRPC로 사용자 잔고를 snapshot으로 가져옴
  2. 이후 발생하는 잔고 변화는 오직 이벤트 기반으로만 반영
  3. gRPC 호출 제거로 _balanceListener()동기 함수로 변경
  4. 이벤트 리스너 내부에서 완전한 동기 처리 구조 구현

🎯 결과

  • 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);
  });
});

핵심 요약

🎯 주요 교훈

  1. 이벤트 리스너 내에서 async 함수를 사용할 수는 있지만, 순차 처리와 상태 정합성을 반드시 함께 고려해야 한다.

  2. 이벤트 기반 데이터 흐름에서는 "이벤트만이 진실"이어야 하며, 외부 조회 결과로 상태를 덮어쓰는 것은 위험할 수 있다.

  3. 가능하다면 비동기 처리를 제거하고 동기 흐름으로 단순화하는 것이 가장 확실한 안정화 방법이다.

🔑 핵심 원칙

  • 단순성 우선: 복잡한 비동기 처리보다는 동기 처리로 단순화
  • 이벤트 중심: DB 상태보다는 이벤트 스트림을 신뢰의 기준으로
  • 순서 보장: 비동기 함수 사용 시 반드시 직렬화 처리 구현
  • 에러 처리: 모든 비동기 처리에 대한 적절한 에러 핸들링

마무리

비동기 함수 자체가 문제가 아니라, 그것이 처리하는 데이터와 시스템 설계가 일치하지 않을 때 진짜 문제가 생깁니다.

실시간 시스템에서의 데이터 정확성은 곧 사용자 신뢰와 직결됩니다. 이벤트 기반 아키텍처를 설계할 때는 단순함과 일관성을 우선시하고, 복잡한 비동기 처리보다는 명확한 데이터 흐름을 만드는 것이 중요합니다.

이 경험이 여러분의 Node.js 이벤트 기반 시스템 설계에 도움이 되기를 바랍니다.


📚 관련 자료