
알림 시스템 설계: 수백만 유저에게 알림 보내기
알림 하나 보내는 건 쉽지만, 알림이 많아지면서 중복 방지, 선호도 관리, 재시도까지 고려하면 완전히 다른 문제가 된다.

알림 하나 보내는 건 쉽지만, 알림이 많아지면서 중복 방지, 선호도 관리, 재시도까지 고려하면 완전히 다른 문제가 된다.
왜 CPU는 빠른데 컴퓨터는 느릴까? 80년 전 고안된 폰 노이만 구조의 혁명적인 아이디어와, 그것이 남긴 치명적인 병목현상에 대해 정리했습니다.

프링글스 통(Stack)과 맛집 대기 줄(Queue). 가장 기초적인 자료구조지만, 이걸 모르면 재귀 함수도 메시지 큐도 이해할 수 없습니다.

ChatGPT는 질문에 답하지만, AI Agent는 스스로 계획하고 도구를 사용해 작업을 완료한다. 이 차이가 왜 중요한지 정리했다.

결제 API 연동이 끝이 아니었다. 중복 결제 방지, 환불 처리, 멱등성까지 고려하니 결제 시스템이 왜 어려운지 뼈저리게 느꼈다.

알림 기능을 처음 설계할 때는 정말 쉬워 보였다. 유저가 어떤 이벤트를 트리거하면 → 다른 유저에게 알림을 보낸다. 끝. 코드로 치면 10줄이면 충분할 것 같았다.
그런데 막상 뚜껑을 열어보니 완전히 다른 세계였다. 한 명에게 알림 하나 보내는 건 쉽다. 그런데 알림이 많아지면 이야기가 달라진다. 대규모 알림 시스템에서는 누군 푸시를 원하고 누군 이메일을 원하고, 어떤 알림은 즉시 보내야 하고 어떤 건 묶어서 보내야 하고, 실패하면 재시도해야 하고, 중복은 막아야 하고... 생각할 게 산더미다.
결국 깨달았다. 알림 시스템은 단순한 메시지 전송이 아니라, 거대한 물류 시스템을 설계하는 것과 같다는 걸.
처음 마주한 질문은 "어떤 방식으로 알림을 보낼 건가?"였다. 유저마다 선호하는 채널이 다르니까.
푸시 알림(Push Notification): 모바일 앱에서 가장 즉각적이고 강력한 채널. FCM(Firebase Cloud Messaging)으로 안드로이드에, APNs(Apple Push Notification service)로 iOS에 보낸다. 실시간성이 생명이라 지연이 생기면 의미가 없어진다.
이메일(Email): 긴 내용을 담을 수 있고, 나중에 다시 확인할 수 있는 채널. SendGrid나 AWS SES 같은 서비스를 쓴다. 푸시보다는 덜 급한 알림에 적합하다.
SMS: 가장 확실한 도달률을 보장하지만 비용이 제일 비싸다. Twilio 같은 서비스를 쓴다. 정말 중요한 알림(결제 확인, 보안 경고)에만 쓴다.
인앱 알림(In-app Notification): 앱 안에서만 보이는 알림. 알림 센터에 쌓여있다가 유저가 앱을 열 때 확인한다. 가장 부담 없는 채널.
여기서 핵심은 같은 이벤트라도 유저마다 다른 채널로 보내야 한다는 점이었다. 누군가 내 게시물에 댓글을 달았을 때, 어떤 유저는 푸시로, 어떤 유저는 이메일로, 어떤 유저는 아예 받지 않을 수도 있다.
interface NotificationChannel {
type: 'push' | 'email' | 'sms' | 'in_app';
enabled: boolean;
provider?: string; // 'fcm', 'apns', 'sendgrid', 'twilio'
}
interface UserPreferences {
userId: string;
channels: {
comment: NotificationChannel[];
like: NotificationChannel[];
follow: NotificationChannel[];
marketing: NotificationChannel[];
};
quietHours?: {
start: string; // "22:00"
end: string; // "08:00"
timezone: string;
};
}
두 번째로 부딪힌 벽은 "알림이 많아지면 동시에 어떻게 보내지?"였다.
처음 생각은 단순했다. 이벤트가 발생하면 → 대상 유저 리스트를 뽑고 → for문 돌면서 알림을 보내면 되잖아? 그런데 이 방식은 유저가 100명일 때나 가능한 얘기였다.
만약 인기 크리에이터가 새 콘텐츠를 올렸고, 팔로워가 100만 명이라면? for문이 100만 번 도는 동안 서버는 멈춰버린다. 더 큰 문제는 알림 전송 중 하나가 실패하면(네트워크 오류, API 제한 등) 전체가 막힌다는 것.
답은 메시지 큐(Message Queue)였다. 이건 마치 우체국 시스템과 같다. 편지를 보내려는 사람(Producer)이 우체통에 넣으면, 우체국 직원(Consumer)들이 각자 편지를 하나씩 가져가서 배달한다.
[이벤트 발생]
↓
[Producer: 알림 생성]
↓
[Message Queue: RabbitMQ/Kafka/SQS]
↓ ↓ ↓
[Consumer 1][Consumer 2][Consumer 3] ... [Consumer N]
↓ ↓ ↓
[FCM] [SendGrid] [Twilio]
Producer는 메시지를 큐에 넣기만 하고 바로 다음 일을 할 수 있다. Consumer들은 독립적으로 메시지를 처리한다. 하나가 실패해도 다른 Consumer들은 계속 일한다. 부하가 늘어나면 Consumer를 더 띄우면 된다. 완벽한 분산 처리.
# Producer (FastAPI 예제)
from celery import Celery
import redis
celery_app = Celery('notifications', broker='redis://localhost:6379/0')
@app.post("/api/post/{post_id}/like")
async def like_post(post_id: str, user_id: str):
# 좋아요 처리
post = await db.posts.find_one({"_id": post_id})
author_id = post["author_id"]
# 알림 큐에 메시지 추가 (비동기)
celery_app.send_task('send_notification', args=[{
'type': 'like',
'recipient_id': author_id,
'actor_id': user_id,
'post_id': post_id,
'timestamp': datetime.utcnow().isoformat()
}])
return {"status": "success"}
# Consumer (Celery Worker)
@celery_app.task(bind=True, max_retries=3)
def send_notification(self, event):
try:
recipient_id = event['recipient_id']
prefs = get_user_preferences(recipient_id)
# 템플릿으로 메시지 생성
message = render_template('like_notification', event)
# 유저 설정에 따라 적절한 채널로 전송
for channel in prefs.get_enabled_channels(event['type']):
if channel == 'push':
send_push(recipient_id, message)
elif channel == 'email':
send_email(recipient_id, message)
except Exception as exc:
# 실패시 재시도 (exponential backoff)
raise self.retry(exc=exc, countdown=2 ** self.request.retries)
세 번째 깨달음은 "같은 이벤트라도 채널마다 메시지 형식이 다르다"는 것.
푸시 알림은 짧고 강렬해야 한다: "김철수님이 회원님의 게시물을 좋아합니다" 이메일은 길고 상세해야 한다: HTML 템플릿에 이미지도 들어가고 링크도 여러 개. SMS는 초간결해야 한다: "새 댓글 도착. 확인하기: app.com/p/123"
처음엔 각 알림마다 메시지를 하드코딩했는데, 문구를 바꾸고 싶을 때마다 코드를 수정해야 했다. 다국어는 더 악몽이었다.
템플릿 시스템을 도입하니 세상이 달라졌다. 템플릿은 DB나 파일로 관리하고, 코드는 변수만 채워 넣는다.
interface NotificationTemplate {
id: string;
type: 'like' | 'comment' | 'follow' | 'mention';
channel: 'push' | 'email' | 'sms' | 'in_app';
locale: string;
subject?: string; // 이메일용
title: string; // 푸시/인앱용
body: string;
htmlBody?: string; // 이메일용 HTML
variables: string[]; // ['actor_name', 'post_title']
}
// DB에 저장된 템플릿 예시
const templates = {
like_push_ko: {
title: "새로운 좋아요",
body: "{{actor_name}}님이 회원님의 {{post_title}}을 좋아합니다",
variables: ['actor_name', 'post_title']
},
like_email_ko: {
subject: "{{actor_name}}님이 게시물을 좋아합니다",
htmlBody: `
<h2>안녕하세요!</h2>
<p><strong>{{actor_name}}</strong>님이
게시물 <a href="{{post_url}}">{{post_title}}</a>을 좋아합니다.</p>
`,
variables: ['actor_name', 'post_title', 'post_url']
}
}
function renderTemplate(templateId: string, variables: Record<string, string>) {
const template = getTemplate(templateId);
let rendered = template.body;
for (const [key, value] of Object.entries(variables)) {
rendered = rendered.replace(new RegExp(`{{${key}}}`, 'g'), value);
}
return rendered;
}
네 번째 함정은 "알림이 너무 많으면 유저가 끈다"는 것.
활발한 커뮤니티에서는 하루에 수십 개의 알림이 올 수 있다. 게시물에 댓글 50개가 달리면 푸시 50개? 유저는 앱을 삭제할 것이다.
배칭(Batching)이 답이었다. 같은 종류의 알림은 일정 시간 동안 모아서 한 번에 보낸다.
interface NotificationBatch {
userId: string;
type: 'comment' | 'like' | 'follow';
events: Array<{
actorId: string;
timestamp: Date;
metadata: any;
}>;
firstEventTime: Date;
shouldSendAt: Date; // 첫 이벤트 + 5분 후
}
// 배칭 로직
async function handleNewEvent(event) {
const existingBatch = await redis.get(`batch:${event.userId}:${event.type}`);
if (existingBatch) {
// 기존 배치에 추가
existingBatch.events.push(event);
await redis.set(`batch:${event.userId}:${event.type}`, existingBatch);
} else {
// 새 배치 생성
const batch = {
userId: event.userId,
type: event.type,
events: [event],
firstEventTime: new Date(),
shouldSendAt: new Date(Date.now() + 5 * 60 * 1000) // 5분 후
};
await redis.set(`batch:${event.userId}:${event.type}`, batch);
// 5분 후 전송 스케줄
scheduleNotification(batch.shouldSendAt, batch);
}
}
// 배치 메시지 예시
// 개별: "김철수님이 댓글을 남겼습니다"
// 배치: "김철수님 외 12명이 회원님의 게시물에 댓글을 남겼습니다"
레이트 리미팅(Rate Limiting)도 중요했다. 아무리 중요한 알림이라도 1분에 10개씩 보내면 스팸이다.
from datetime import datetime, timedelta
import redis
redis_client = redis.Redis()
def check_rate_limit(user_id: str, channel: str, limit: int, window_seconds: int):
"""
Token bucket 알고리즘
예: 1시간에 푸시 알림 최대 20개
"""
key = f"ratelimit:{user_id}:{channel}"
current = redis_client.get(key)
if current is None:
# 첫 요청
redis_client.setex(key, window_seconds, 1)
return True
if int(current) >= limit:
return False # 제한 초과
redis_client.incr(key)
return True
# 사용 예
if check_rate_limit(user_id, 'push', limit=20, window_seconds=3600):
send_push_notification(user_id, message)
else:
# 큐에 넣어서 나중에 배치로 전송
queue_for_batch(user_id, message)
다섯 번째 교훈은 "모든 알림이 동등하지 않다"는 것.
결제 실패 알림과 누군가 내 프로필을 봤다는 알림이 같은 급일 수 없다. 보안 경고는 즉시 보내야 하지만, 마케팅 알림은 며칠 지연돼도 괜찮다.
우선순위 시스템을 도입했다.
enum NotificationPriority {
CRITICAL = 0, // 보안, 결제 실패 - 즉시 전송, 재시도 적극적
HIGH = 1, // 멘션, 다이렉트 메시지 - 몇 분 내 전송
NORMAL = 2, // 좋아요, 팔로우 - 배칭 가능
LOW = 3 // 마케팅, 추천 - 배칭 + 조용한 시간대에만
}
interface NotificationJob {
id: string;
userId: string;
type: string;
priority: NotificationPriority;
payload: any;
createdAt: Date;
maxRetries: number;
retryCount: number;
}
// 우선순위별로 다른 큐 사용
const queues = {
critical: new Queue('notifications:critical', {
defaultJobOptions: { attempts: 5, backoff: { type: 'exponential', delay: 1000 } }
}),
high: new Queue('notifications:high', {
defaultJobOptions: { attempts: 3, backoff: { type: 'exponential', delay: 2000 } }
}),
normal: new Queue('notifications:normal', {
defaultJobOptions: { attempts: 2, backoff: { type: 'fixed', delay: 60000 } }
}),
low: new Queue('notifications:low', {
defaultJobOptions: { attempts: 1 } // 실패해도 그냥 넘어감
})
};
여섯 번째 현실은 "외부 서비스는 언제든 실패한다"는 것.
FCM이 503 에러를 뱉거나, SendGrid API가 타임아웃 나거나, 네트워크가 끊기거나. 한 번의 실패로 알림을 포기하면 안 된다.
Exponential Backoff로 재시도한다. 실패할수록 대기 시간을 늘려서, 서비스가 복구될 시간을 준다.
async function sendWithRetry(
sendFn: () => Promise<void>,
maxRetries: number = 3,
baseDelay: number = 1000
) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await sendFn();
return { success: true };
} catch (error) {
const isLastAttempt = attempt === maxRetries - 1;
// 재시도 불가능한 에러 (잘못된 토큰, 유저가 앱 삭제 등)
if (isNonRetriableError(error)) {
await handlePermanentFailure(error);
return { success: false, reason: 'non_retriable' };
}
if (isLastAttempt) {
await logFailure(error);
return { success: false, reason: 'max_retries' };
}
// Exponential backoff: 1초, 2초, 4초, 8초...
const delay = baseDelay * Math.pow(2, attempt);
await sleep(delay);
}
}
}
function isNonRetriableError(error: any): boolean {
// FCM: Invalid registration token
if (error.code === 'messaging/invalid-registration-token') return true;
// SendGrid: Invalid email
if (error.code === 400 && error.message.includes('invalid email')) return true;
// Twilio: Invalid phone number
if (error.code === 21211) return true;
return false;
}
마지막 깨달음은 "알림을 보냈다고 끝이 아니다"는 것.
정말로 유저에게 도달했는가? 읽었는가? 클릭했는가? 어떤 알림이 가장 효과적인가? 데이터 없이는 답할 수 없다.
추적해야 할 메트릭들:
interface NotificationMetrics {
notificationId: string;
userId: string;
type: string;
channel: string;
priority: NotificationPriority;
// 전송 단계
queuedAt: Date;
sentAt?: Date;
deliveredAt?: Date; // 외부 서비스가 확인
// 유저 행동
viewedAt?: Date;
clickedAt?: Date;
actionTaken?: string;
// 결과
status: 'queued' | 'sent' | 'delivered' | 'failed' | 'bounced';
failureReason?: string;
// 메타
deviceType?: string;
osVersion?: string;
appVersion?: string;
}
// 분석 쿼리 예시
async function getNotificationStats(type: string, days: number) {
const results = await db.metrics.aggregate([
{
$match: {
type: type,
queuedAt: { $gte: new Date(Date.now() - days * 86400000) }
}
},
{
$group: {
_id: "$channel",
total: { $sum: 1 },
sent: { $sum: { $cond: [{ $ne: ["$sentAt", null] }, 1, 0] } },
delivered: { $sum: { $cond: [{ $ne: ["$deliveredAt", null] }, 1, 0] } },
viewed: { $sum: { $cond: [{ $ne: ["$viewedAt", null] }, 1, 0] } },
clicked: { $sum: { $cond: [{ $ne: ["$clickedAt", null] }, 1, 0] } }
}
}
]);
return results.map(r => ({
channel: r._id,
deliveryRate: (r.sent / r.total * 100).toFixed(2) + '%',
reachRate: (r.delivered / r.sent * 100).toFixed(2) + '%',
openRate: (r.viewed / r.delivered * 100).toFixed(2) + '%',
ctr: (r.clicked / r.viewed * 100).toFixed(2) + '%'
}));
}
돌이켜보면 알림 시스템 설계는 메시지를 보내는 게 아니라 대규모 물류 센터를 운영하는 것이었다.
한 줄로 보내던 알림이 수백만 개가 되자, 설계의 모든 레이어가 중요해졌다. 큐가 없으면 터지고, 템플릿이 없으면 관리가 지옥이고, 우선순위가 없으면 중요한 알림이 묻히고, 데이터가 없으면 개선할 수 없다.
핵심은 유저 경험이었다. 기술적으로 완벽하게 보내도, 유저가 원하지 않거나 타이밍이 안 맞으면 스팸일 뿐이다. 알림 시스템의 성공은 "얼마나 많이 보냈는가"가 아니라 "얼마나 가치 있는 알림을 적절한 순간에 보냈는가"로 측정된다.
이제는 안다. 알림 하나를 보내는 것과 시스템을 설계하는 것은 완전히 다른 차원의 문제라는 것을.