NestJS Cron와 Firebase를 활용한 프로덕션 레디 스케줄 푸시 알림 시스템 구축

발행: (2025년 12월 16일 오전 03:00 GMT+9)
7 min read
원문: Dev.to

Source: Dev.to

즉시 전송에서 정밀 스케줄링까지

시간대 복잡성, 데이터베이스 레이스 컨디션, 모바일 앱 데이터 계약을 처리하는 신뢰성 높은 cron 기반 알림 스케줄러 구축.

Firebase 푸시 알림 서버가 이미 BullMQ 큐를 통해 수십만 명의 사용자에게 대량 전송을 처리하고 있다면, 다음 논리적인 단계는 예약 전송이다. 간단해 보이죠? cron 작업과 스케줄링 테이블만 추가하면 된다.

하지만 제가 겪은 것은 시간대 지옥, TypeORM의 NULL 비교 꼬리, 커서 페이지네이션 버그, 그리고 데이터베이스 스키마와 모바일 앱 데이터 계약 사이의 미묘한 차이를 탐험하는 흥미진진한 여정이었다. 이제는 하루에 수천 건의 시간 기반 알림을 놓치지 않고 처리하는 프로덕션 레디 예약 알림 시스템을 어떻게 구축했는지 소개한다.

시작점: 작동 중인 대량 알림 시스템

스케줄링에 들어가기 전에 배경을 설명하겠다. 나는 이미 견고한 대량 알림 인프라를 갖추고 있었다:

// firebase.controller.ts - Existing mass notification endpoint
@Post('send-to-conditional-users')
async sendConditionalNotifications(
  @Query() query: SendMultiNotificationDto,
  @Body() body?: SendDataDto,
): Promise {
  const jobId = `conditional-${uuidv4()}`;

  const jobData: ConditionalNotificationParams = {
    ...query,
    jobId,
    chunkSize: 500,
    chunkDelay: 2000,
    data: body || {},
  };

  // Add to BullMQ queue - processed asynchronously
  await this.pushQueue.add('send-conditional-notification', jobData, {
    jobId,
    removeOnComplete: true,
    removeOnFail: false,
  });

  return CommonResponseDto.messageSendSuccess();
}

핵심 구성 요소

  • BullMQ – 비동기 작업 처리
  • 커서 기반 페이지네이션 – 효율적인 DB 조회
  • 청크 전송 (청크당 500 토큰, 2초 지연)
  • 실패 메시지 자동 재시도
  • Redis – 작업 큐 관리

문제점: 이 알림들을 특정 미래 시점에 실행하도록 어떻게 스케줄링할까?

목표: 전문가 수준의 알림 스케줄링

요구 사항

  • 예약 알림을 MySQL에 저장
  • NestJS cron을 사용해 매분 대기 중인 알림을 확인
  • 예약 시점에 기존 알림 파이프라인을 트리거
  • 모든 필터 옵션(성별, 연령, 플랫폼 등) 지원
  • 실행 상태(pending, processing, completed) 추적
  • 한국 표준시(KST)를 정확히 처리

구상한 아키텍처

┌─────────────────┐
│  Schedule API   │ ──► INSERT into MySQL
│ (Create/Update) │
└─────────────────┘

┌──────────────────────┐
│  MySQL Schedule DB   │
│  scheduled_send_date │
└──────────────────────┘
        ↓ Every minute
┌──────────────────────┐
│   NestJS Cron Job    │
│ @Cron(EVERY_MINUTE) │
└──────────────────────┘
        ↓ Found match?
┌──────────────────────┐
│    BullMQ Queue      │
│ (Existing Pipeline)  │
└──────────────────────┘

┌──────────────────────┐
│  Firebase Worker     │
│  (Send to users)     │
└──────────────────────┘

구현: 엣지 케이스를 넘어선 여정

1단계: 스케줄 테이블 생성

// push-notification-schedule.entity.ts
@Entity({ name: 'push_notification_schedule' })
@Index(['sent_yn', 'scheduled_send_date'])  // Critical for cron queries
@Index(['job_id'])
export class PushNotificationSchedule {
  @PrimaryGeneratedColumn({ type: 'int' })
  seq: number;

  @Column({ type: 'varchar', length: 200, nullable: true })
  job_id: string; // BullMQ job ID once queued

  @Column({ type: 'varchar', length: 200 })
  title: string;

  @Column({ type: 'text' })
  content: string;

  @Column({ type: 'datetime' })
  scheduled_send_date: Date; // When to send

  @Column({ type: 'datetime', precision: 6, nullable: true })
  actual_send_start_date: Date | null; // When actually sent

  @Column({ type: 'datetime', precision: 6, nullable: true })
  actual_send_end_date: Date | null;

  @Column({ type: 'int', default: 0 })
  total_send_count: number; // How many sent

  @Column({ type: 'tinyint', width: 1, default: 0 })
  sent_yn: number; // 0: pending, 1: completed

  // Filter fields (same as immediate API)
  @Column({ type: 'varchar', length: 1, nullable: true })
  push_onoff: string; // 'Y' = only subscribers

  @Column({ type: 'varchar', length: 1, nullable: true })
  marketing_onoff: string;

  @Column({ type: 'varchar', length: 20, nullable: true })
  topic: string; // FCM topic

  // Mobile app deep‑link data
  @Column({ type: 'varchar', length: 50, nullable: true })
  division: string; // e.g., 'bible'

  @Column({ type: 'int', nullable: true })
  version: number;

  @Column({ type: 'int', nullable: true })
  bible_code: number; // Database uses snake_case

  @Column({ type: 'int', nullable: true })
  chapter: number;

  @Column({ type: 'int', nullable: true })
  section: number;

  @Column({ type: 'varchar', length: 500, nullable: true })
  landing_url: string;

  @CreateDateColumn({ type: 'datetime' })
  regdate: Date;
}

핵심 설계 결정

  • sent_yn 플래그는 중복 실행을 방지한다.
  • job_id는 큐에 넣은 BullMQ 작업을 추적한다.
  • actual_send_start_date / actual_send_end_date는 마이크로초 정밀도로 분석에 활용한다.
  • bible_code와 같은 필드는 성경 앱의 딥링크를 지원한다.

2단계: 작동하지 않았던 순진한 Cron 구현

첫 번째 시도는 논리적으로 보였다:

// scheduler.service.ts - First attempt (broken!)
@Cron(CronExpression.EVERY_MINUTE)
async handleScheduledPushNotifications() {
  const nowKST = moment.tz('Asia/Seoul').startOf('minute').toDate();

  // Find schedules within ±1 minute window
  const startWindow = moment(nowKST).subtract(1, 'minutes').toDate();
  const endWindow = moment(nowKST).add(1, 'minutes').toDate();

  const pendingSchedules = await this.scheduleRepository
    .createQueryBuilder('schedule')
    .where('schedule.sent_yn = :sentYn', { sentYn: 0 })
    .andWhere('schedule.job_id = :jobId', { jobId: null }) // ❌ BUG!
    .andWhere('schedule.scheduled_send_date BETWEEN :start AND :end', {
      start: startWindow,
      end: endWindow,
    })
    .getMany();

  for (const schedule of pendingSchedules) {
    await this.processSchedule(schedule);
  }
}

private async processSchedule(schedule: PushNotificationSchedule) {
  const jobId = `scheduled-${schedule.seq}-${uuidv4()}`;

  // Mark as processing
  await this.scheduleRepository.update(
    {
      seq: schedule.seq,
      sent_yn: 0,
      job_id: null, // ❌ BUG!
    },
    {
      job_id: jobId,
      sent_yn: 1,
    },
  );

  // Queue the job
  const jobData = { /* ...build payload from schedule... */ };
  await this.pushQueue.add('send-scheduled-notification', jobData, { jobId });
}

무엇이 잘못됐는가

  1. TypeORM의 NULL 비교 함정
    schedule.job_id = :jobIdjobId: null을 전달하면 WHERE job_id = NULL이 생성되는데, 이는 언제나 거짓이다. 올바른 SQL은 WHERE job_id IS NULL이며, TypeORM에서는 .isNull()이나 raw 조건을 사용해야 한다.

  2. update 호출에서도 동일한 문제
    WHERE 부분에 { job_id: null }을 넣으면 job_id = NULL이 되어 해당 행이 매치되지 않으며, 스케줄이 그대로 남아 있다.

이 버그들 때문에 cron 작업이 대기 중인 스케줄을 전혀 잡아내지 못했고, 그 결과 전송이 누락되었다.

Back to Blog

관련 글

더 보기 »

Dev tools 허브 API

제가 만든 제출물: Xano AI-Powered Backend Challenge https://dev.to/challenges/xano-2025-11-20: Production-Ready Public API 제목: DevTools Resource…