푸시 한 번에 수십만 명 — 메시지 유실 없는 알림 시스템을 어떻게 만들었나
RabbitMQ + BullMQ + NestJS로 구현한 분산 알림 파이프라인 설계기
문제는 이렇게 시작됐다
서비스가 성장하면서 알림 요구사항이 복잡해졌습니다.
- 마케팅팀이 보내는 수십만 명 대상 일괄 앱푸시 or 이메일
- 사용자 행동에 반응하는 실시간 서비스 푸시
- 날짜와 시간을 지정하는 예약 발송
- FCM, APNs, 웹 푸시, 이메일, SMS를 모두 커버하는 멀티채널 전송
처음에는 각 서비스가 직접 FCM API를 호출했습니다. 그러다 문제가 터지기 시작했습니다.
"마케팅 배치 발송이 시작되면 일반 알림이 수십 분씩 밀린다."
이유는 단순했습니다. 하나의 Queue를 모든 서비스가 공유했기 때문입니다. 100만 건의 배치 Job이 쌓이면 그 뒤에 들어온 실시간 알림은 전부 대기해야 했습니다.
또 다른 문제도 있었습니다.
"FCM API가 잠깐 불안정해졌는데, 그 사이에 들어온 알림이 다 날아갔다."
재시도 로직이 없는 직접 호출 구조에서는 외부 서비스가 잠깐 흔들려도 데이터가 유실됐습니다.
이 두 가지 문제를 해결하기 위해 알림 서비스를 별도 시스템으로 분리하고, RabbitMQ → BullMQ → 외부 서비스로 이어지는 파이프라인을 새로 설계했습니다.
전체 구조: 두 개의 큐, 두 개의 앱
┌──────────────────────────────────────────┐
│ 타 서비스들 (service-a, service-b, ...) │
└───────────────────┬──────────────────────┘
│ RabbitMQ ← "일단 받아두는" 버퍼
▼
┌─────────────────┐
│ Worker │ NestJS Microservice
│ (큐 소비자) │
└────────┬────────┘
│ BullMQ + Valkey Cluster ← "단계별 처리" 파이프라인
▼
┌─────────────────────────────┐
│ app-push │ DB 저장, Redis 캐시, 디바이스 분류
│ └▶ send-push │ FCM / APNs / Web 전송
│ └▶ update-push │ 결과 반영
│ └▶ delete-push │ 만료 토큰 정리
└─────────────────────────────┘
│
FCM · APNs · AWS SES · Twilio · Kakao
┌─────────────────┐
│ Server │ NestJS REST API
│ (HTTP API) │ 알림함 조회, 읽음 처리, 예약 수정
└─────────────────┘
핵심 아이디어는 두 종류의 큐를 역할에 따라 분리하는 것입니다.
- RabbitMQ: 외부 서비스로부터 메시지를 받는 "입구 버퍼". Worker가 다운되어 있어도 메시지가 대기합니다.
- BullMQ: 내부 처리 파이프라인. 단계별로 Job을 분리해 각 단계의 실패가 독립적으로 관리됩니다.
Server와 Worker는 코드를 직접 공유하지 않습니다. 두 앱은 큐를 통해서만 통신하며, 공통으로 쓰는 타입·엔티티·상수는 packages/shared 라는 내부 패키지로 분리합니다.
noti-service/ ← pnpm workspace 루트
├── server/ NestJS REST API
├── worker/ NestJS Microservice
└── packages/
└── shared/ @noti-service/shared
└── src/
├── common/
│ ├── constant/
│ │ └── queue.constants.ts ← BullMQ/RabbitMQ Queue 이름 전체 정의
│ ├── bull-mq/interface/
│ │ └── bull-mq.interface.ts ← AddJob, UpdateReservationDate 등 Job 인터페이스
│ ├── enum/
│ │ └── common.enum.ts ← BOOLEAN, CUSTOM_LOGGER, OS_TYPE 등 공통 Enum
│ ├── dao/ ← 공통 Repository 기반 클래스
│ └── typeorm-ex/ ← @EntityRepository 커스텀 데코레이터
└── modules/
├── push/
│ ├── entity/ ← PushInfoEntity, PushHistoryEntity
│ └── constant/ ← 푸시 관련 상수
├── email/
│ ├── entity/ ← EmailInfoEntity, EmailHistoryEntity
│ └── constant/
└── sms/
├── entity/ ← SmsHistoryEntity
└── constant/
shared 패키지가 없다면 server와 worker에 동일한 코드가 중복됩니다. 특히 Queue 이름 상수가 두 앱에서 각자 다르게 정의된다면, Queue 이름 오타 하나로 Job이 영원히 소비되지 않는 버그가 생깁니다. queue.constants.ts 한 파일이 진실의 원천(source of truth)이 되므로 이런 문제를 원천 차단합니다.
// packages/shared/src/common/constant/queue.constants.ts
// server와 worker 모두 이 상수를 import해서 사용
export const APP_PUSH_BULL_NAME = {
APP_PUSH: 'app-push-{push-1}',
BATCH_APP_PUSH: 'batch-app-push-{push-2}',
ADMIN_APP_PUSH: 'admin-app-push-{push-3}',
// ...16개
} as const
export const RABBIT_MQ = {
USER_APP_PUSH_QUEUE: 'user_app_push_queue',
MARKETING_APP_PUSH_QUEUE: 'marketing_app_push_queue',
USER_EMAIL_QUEUE: 'user_email_queue',
// ...
} as const
BullMQ Job의 타입도 shared에서 정의합니다. server가 Job을 등록할 때와 worker가 Job을 소비할 때 동일한 인터페이스를 바라보므로, 페이로드 형태가 달라지면 컴파일 단계에서 바로 잡힙니다.
// packages/shared/src/common/bull-mq/interface/bull-mq.interface.ts
export interface AddJob extends BullName, BullAction {
data: BullUuid & BullData
opt?: JobsOptions // delay, priority 등
}
export interface UpdateReservationDate extends BullName, BullAction, BullJobId {
data: BullUuid & BullData
opt: Required<Pick<JobsOptions, 'delay' | 'priority'>>
}
pnpm workspace의 catalog: 기능으로 세 패키지의 의존성 버전을 루트 pnpm-workspace.yaml 한 곳에서 관리합니다. @nestjs/common: "^11.1.16" 같은 버전을 각 패키지에서 따로 선언할 필요 없이 catalog: 키워드 하나로 동기화됩니다.
1. 인바운드: "일단 받아두기"
타 서비스는 RabbitMQ에 패턴(pattern)을 명시해 메시지를 발행합니다. Worker는 @EventPattern 데코레이터로 각 패턴을 핸들러에 연결합니다.
// push-message.controller.ts
@EventPattern(USER_APP_PUSH_RABBIT_MQ_JOB.SEND_USER_APP_PUSH)
async handleSendUserAppPush(@PushPayload() data: SendUserAppPushDto) {
await this.appPushService.createAppPushByUser(data)
}
@EventPattern(MARKETING_APP_PUSH_RABBIT_MQ_JOB.RESERVED_SEND_MARKETING_APP_PUSH)
async handleReservedSendMarketingAppPush(@PushPayload() data: ReservedSendUserAppPush) {
await this.appPushService.createReservedAppPush(data)
}
이 레이어에서는 메시지를 파싱하고 BullMQ에 넘기는 것 외에는 아무것도 하지 않습니다. 처리 책임을 Worker로 미루는 것이 포인트입니다.
덕분에 Worker가 배포 중 잠시 내려가도 RabbitMQ가 메시지를 들고 기다립니다. 재기동하면 밀린 메시지부터 이어서 처리합니다.
2. 파이프라인: "단계별로 쪼개기"
가장 중요한 설계 결정입니다.
Push 전송을 하나의 긴 함수로 처리하면 어떤 문제가 생길까요? FCM 응답을 기다리는 동안 DB 업데이트가 블로킹되고, DB 업데이트가 느려지면 FCM 전송도 지연됩니다. 어느 한 단계가 실패하면 전체를 재시도해야 합니다.
대신 역할별로 4개의 독립된 BullMQ Queue로 분리했습니다.
① app-push Queue
├─ DB에 push_info / push_history 레코드 생성
├─ Redis Sorted Set에 알림함 데이터 캐싱
└─ 디바이스 타입(FCM/APNs/Web)별 분류 → ②로 전달
② send-push Queue
├─ Firebase Admin SDK로 FCM multicast 전송
├─ node-apn으로 APNs 전송 (Prod/Sandbox 듀얼)
└─ Redis Pub/Sub으로 웹 푸시 발행 → ③으로 전달
③ update-push Queue
├─ FCM/APNs 응답 파싱 (성공 / 실패 분류)
├─ push_history.status 업데이트 (success/failed)
└─ push_info 최종 상태 집계 → ④로 전달
④ delete-push Queue
└─ 전송 실패 사유에 따라 만료 토큰 삭제
각 단계는 처리 완료 후 다음 Queue에 Job을 추가하며 체인을 만듭니다.
// SendPushService.sendToFcm()
const { responses } = await fcmApp.sendEachForMulticast(message)
// 전송 완료 → 결과를 분류해 다음 단계로 넘김
await this.bullMqService.addJob({
name: UPDATE_PUSH_BULL_NAME.UPDATE_PUSH,
action: UPDATE_PUSH_BULL_ACTION.SEND_RESULT,
data: { uuid, info_id, success_list, failed_list },
})
이 구조의 장점은 명확합니다.
- ②가 느려도 ③은 독립적으로 동작합니다. FCM API가 지연되어도 이미 전송된 건들의 상태 업데이트는 막히지 않습니다.
- 특정 단계의 재시도가 그 단계에 국한됩니다. DB 저장이 실패하면 전송 시도 없이 ①만 재시도합니다.
- 단계별로 concurrency와 retry 정책을 다르게 설정할 수 있습니다.
3. 격리: "서비스끼리 간섭 없애기"
하나의 Queue를 공유하면 생기는 문제를 앞서 말했습니다. 해결책은 서비스 이름마다 독립된 Queue를 두는 것입니다.
// PushUtilService.getPushConsumerName()
getPushConsumerName({ service_name, is_admin }) {
switch (service_name) {
case SERVICE_NAME.MAIN_SERVICE:
return is_admin
? APP_PUSH_BULL_NAME.ADMIN_APP_PUSH // admin-app-push-{push-3}
: APP_PUSH_BULL_NAME.APP_PUSH // app-push-{push-1}
case SERVICE_NAME.ADMIN_BATCH:
return APP_PUSH_BULL_NAME.BATCH_APP_PUSH // batch-app-push-{push-2}
case SERVICE_NAME.SAMPLE_SERVICE_A:
return APP_PUSH_BULL_NAME.SAMPLE_A_APP_PUSH // sample-a-app-push-{push-5}
case SERVICE_NAME.SAMPLE_SERVICE_B:
return APP_PUSH_BULL_NAME.SAMPLE_B_APP_PUSH // sample-b-app-push-{push-9}
// ...
}
}
어드민 배치가 아무리 많은 Job을 쏟아내도 batch-app-push-{push-2} Queue만 가득 찹니다. 메인 서비스의 실시간 알림은 app-push-{push-1} Queue에서 독립적으로 처리됩니다.
Queue 이름의 {push-1} 같은 해시태그는 Valkey(Redis Cluster) 환경을 위한 장치입니다. 관련된 Queue들이 같은 클러스터 슬롯에 배치되도록 강제해, MULTI/EXEC 같은 원자적 명령이 클러스터에서도 동작합니다.
현재 운영 중인 Queue만 Push 48개, Email 26개, SMS 21개 — 총 95개입니다. 복잡해 보이지만 getPushConsumerName() 하나가 모든 라우팅을 결정하므로 새 서비스 추가는 switch 케이스 하나를 추가하는 일입니다.
4. 배치: "수십만 건을 타임아웃 없이 처리하기"
마케팅 발송에서 수십만 명을 한 번에 처리하려 하면 DB 쿼리 타임아웃, FCM API 제한, 메모리 부족 등 다양한 문제가 터집니다.
해결법은 단순합니다. 5000명 단위로 쪼개서 각각 독립된 BullMQ Job으로 만드는 것입니다.
const batches = this.pushUtilService.getBatches(userHistoryList, 5000)
// 총 배치 수를 DB에 기록해 진행 상황 추적
await this.pushInfoRepository.set(
{ info_id: pushInfo.info_id },
{ job_id: `${pushInfo.info_id}-${batches.length}` }
)
// 각 배치를 독립된 Job으로 Queue에 등록
for (const historyBatch of batches) {
await this.bullMqService.addJob({
name: sendQueueName,
action: SEND_PUSH_BULL_ACTION.SEND_FCM,
data: { uuid, info_id, history_list: historyBatch },
})
}
20만 명이면 40개의 Job이 생깁니다. 그 중 특정 Job이 실패해도 나머지 39개는 계속 진행됩니다. push_info.job_id의 {info_id}-{총배치수}로 전체 발송이 완료됐는지 추적합니다.
5. 외부 서비스: "플랫폼마다 다른 처리"
FCM — 앱별 멀티 인스턴스
서비스 A 앱과 서비스 B 앱은 각각 다른 Firebase 프로젝트를 씁니다. FirebaseProvider가 앱 이름에 따라 다른 인스턴스를 반환합니다.
const app = this.fcm.appMessaging(app_name) // 'service-a' or 'service-b'
const { responses } = await app.sendEachForMulticast({
notification: { body, title: subtitle },
data: { uuid, info_id, noti_id, web_url, android_link },
android: { ttl: APP_PUSH_TTL },
tokens, // 배치에 속한 모든 토큰
})
sendEachForMulticast는 토큰 배열을 받아 각각의 성공·실패를 responses[i].success로 알려줍니다. 성공 리스트와 실패 리스트를 분류해 다음 update-push Queue로 넘깁니다.
APNs — Sandbox/Production 듀얼 처리
iOS 디바이스는 개발 빌드는 Sandbox APNs, 배포 빌드는 Production APNs를 씁니다. Sandbox 토큰이 Production 서버로 오는 경우를 대비해 Sandbox 실패 시 Production으로 자동 재시도하는 로직을 포함했습니다.
const notification = new apn.Notification()
notification.expiry = Math.floor(Date.now() / 1000) + ttl
notification.badge = 1
notification.alert = { body, subtitle }
notification.topic = this.apns.appTopic(app_name)
notification.payload = { uuid, info_id, noti_id, ios_link, web_url }
const result = await this.apns.send(notification, tokens)
AWS SES — 반송 이메일 자동 차단
이메일은 발송 후 반송(Bounce)이 쌓이면 SES 발송 평판이 떨어집니다. SES → SQS → Worker로 이어지는 비동기 파이프라인으로 반송 이메일을 감지하고 DynamoDB에 기록합니다. 이후 발송 전 Bounce 체크 단계에서 이 목록을 조회해 차단합니다.
6. 알림함 캐시: "DB를 직접 때리지 않기"
모바일 앱의 알림함(벨 아이콘)은 사용자가 열 때마다 조회됩니다. 이걸 매번 push_history 테이블을 조회하면 DB에 부담이 큽니다.
Redis Sorted Set을 사용해 사용자별 알림 목록을 캐싱합니다.
Key: user:{user_id}:{notiName} ← 예: user:134404:noti
Type: Sorted Set
Score: timestamp ← 최신순 정렬 기준
Value: {noti_id}:{timestamp}
Data: Hash noti:{noti_id} → JSON ← 알림 상세 데이터
알림함 조회는 ZRANGE ... BYSCORE LIMIT로 페이지네이션하고, Hash에서 상세 데이터를 MGET으로 가져옵니다. DB 조회 없이 Redis만으로 처리됩니다.
7. 예약 발송: "BullMQ delay 하나로 해결"
BullMQ의 delay 옵션은 Job을 즉시 처리하지 않고 지정 시각까지 대기시킵니다. 예약 발송 구현이 놀라울 만큼 단순해집니다.
// 예약 등록
const delayMs = new Date(reserved_at).getTime() - Date.now()
await this.bullMqService.addJob({
name: queueName,
data: payload,
opt: { delay: delayMs },
})
// 예약 취소 또는 수정
const existJob = await this.bullMqService.getJob({ name: queueName, jobId })
await existJob.remove() // 기존 Job 제거
await this.bullMqService.addJob({ ..., opt: { delay: newDelayMs } }) // 새 Job 등록
예약 Job의 ID를 push_info.job_id에 저장해두면 나중에 꺼내 취소하거나 시간을 바꿀 수 있습니다.
8. 장애 대응: "문제가 생기면 바로 알기"
슬랙 알림
모든 Consumer는 @OnWorkerEvent('failed') 훅으로 실패를 감지합니다.
@OnWorkerEvent('failed')
async onFailedSlackMsg(job: Job) {
await this.utilService.sendBullFailedSlackMsg(job)
}
Job이 실패하면 슬랙 채널에 Queue 이름, Job ID, 에러 메시지, payload가 전송됩니다. 문제의 원인을 코드 없이도 파악할 수 있습니다.
1000명 이상 대량 발송이 시작될 때는 슬랙에 시작 알림을 먼저 보냅니다. 발송 중 이슈가 생기면 시작 알림과 실패 알림을 연관지어 빠르게 판단할 수 있습니다.
Bull Board
/bullmq-board 엔드포인트로 모든 Queue의 상태를 웹 UI로 확인할 수 있습니다. 실패한 Job을 클릭하면 스택 트레이스와 payload를 볼 수 있고, 버튼 하나로 재시도할 수 있습니다.
결국 무엇을 얻었나
이 시스템을 만들면서 핵심으로 삼은 원칙은 하나입니다.
"어디서 무슨 문제가 생겨도 메시지는 유실되지 않는다."
🔴 문제 → ✅ 해결
Worker 재배포 중 메시지가 날아간다
→ RabbitMQ가 인바운드 버퍼로 메시지를 들고 대기. Worker가 재기동되면 이어서 처리.
마케팅 배치 발송이 실시간 알림을 수십 분 지연시킨다
→ 서비스별 BullMQ Queue를 완전 격리. Push 48개 · Email 26개 · SMS 21개, 총 95개 Queue로 서로 간섭 없음.
수십만 건 발송 시 타임아웃이 발생한다
→ 5000명 단위 배치 Job으로 분할. 특정 배치 실패 시 나머지는 중단 없이 진행.
FCM 장애 시 전체 파이프라인이 멈춘다
→ 4단계 Queue 분리(app-push → send-push → update-push → delete-push). 각 단계가 독립적으로 재시도.
Valkey Cluster 환경에서 원자적 Job 조회가 깨진다
→ Queue 이름에 해시태그({push-1}) 적용. 관련 Queue가 같은 클러스터 슬롯에 고정.
운영 중 장애를 뒤늦게 인지한다
→ @OnWorkerEvent('failed') 훅으로 실패 즉시 슬랙 알림. Bull Board UI로 언제든 시각적 확인 및 수동 재시도.
메시지 큐를 쓴다고 신뢰성이 자동으로 생기지는 않습니다. 인바운드 버퍼와 내부 파이프라인을 분리하고, 서비스별로 격리하고, 단계별로 쪼개는 구조적인 결정이 쌓여서 만들어집니다.

'개발노트 > 개발기록' 카테고리의 다른 글
| MSA 환경에서 Nginx Ingress에서 AWS ALB Ingress Controller로 전환 실전 가이드 (1) | 2026.03.15 |
|---|---|
| k8s pod OOMkill 발생하는 이유 (0) | 2023.06.28 |
| 알리페이 결제연동 토큰 발급 및 연동하기 (0) | 2023.06.22 |
| 데이터독 적용 및 세팅 (0) | 2023.06.05 |
| Apple OAuth 탈퇴 개선 (0) | 2023.06.02 |