stack-analysis
CHAPTER 88 / 90
읽기 약 2분
FUNCTION
알림 시스템: 실시간 + 배치
핵심 개념
in-app·email·push·digest·선호도·rate limit — 알림 폭주 방지.
본문
알림 우선순위
[Real-time (즉시)]
- 멘션, 답글, DM
- 인앱 + 푸시
[Important (5분 이내)]
- 새 팔로워, 좋아요 (첫 5개만)
- 인앱 + 푸시
[Digest (시간/일별)]
- 댓글 좋아요, 일반 활동
- 이메일 다이제스트
[Off]
- 사용자가 끈 항목데이터 모델
CREATE TABLE notifications (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
type VARCHAR NOT NULL,
title VARCHAR NOT NULL,
body TEXT,
link VARCHAR,
data JSONB,
read_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX ON notifications(user_id, created_at DESC);
CREATE INDEX ON notifications(user_id, created_at DESC) WHERE read_at IS NULL;
CREATE TABLE notification_preferences (
user_id UUID NOT NULL,
type VARCHAR NOT NULL,
channel VARCHAR NOT NULL, -- 'in_app', 'email', 'push'
enabled BOOLEAN DEFAULT true,
PRIMARY KEY (user_id, type, channel)
);
-- 묶기 (예: 좋아요 5개 → "5명이 좋아요")
CREATE TABLE notification_aggregates (
user_id UUID,
type VARCHAR,
target_id UUID,
count INT DEFAULT 1,
actors JSONB, -- [{id, name}, ...]
last_at TIMESTAMP,
notified_at TIMESTAMP,
PRIMARY KEY (user_id, type, target_id)
);통합 알림 함수
async function notify(input: {
userId: string;
type: string;
title: string;
body?: string;
link?: string;
data?: any;
actorId?: string; // 좋아요 누른 사람
targetId?: string; // 대상 게시물
aggregable?: boolean; // 묶기 가능
}) {
// 1. 선호도 확인
const prefs = await db.notificationPreference.findMany({
where: { userId: input.userId, type: input.type },
});
const allowed = prefs.reduce((acc, p) => ({ ...acc, [p.channel]: p.enabled }), {} as any);
// 2. Rate limit 검증
const recentCount = await redis.incr(`notif_rate:${input.userId}`);
if (recentCount === 1) await redis.expire(`notif_rate:${input.userId}`, 60);
if (recentCount > 30) {
// 1분에 30개 이상 — 묶기 모드
await aggregate(input);
return;
}
// 3. Aggregable인 경우 묶기 처리
if (input.aggregable && input.targetId && input.actorId) {
return aggregate(input);
}
// 4. 채널별 발송
if (allowed.in_app !== false) {
const notif = await db.notification.create({ data: input });
io.to(`user:${input.userId}`).emit('notification', notif);
}
if (allowed.email) {
await emailQueue.add('notification', input);
}
if (allowed.push) {
await pushQueue.add('send', input);
}
}묶기 (Aggregation)
async function aggregate(input: any) {
const key = { userId: input.userId, type: input.type, targetId: input.targetId };
return db.$transaction(async (tx) => {
const existing = await tx.notificationAggregate.findUnique({ where: { ...key as any } });
const actor = { id: input.actorId, name: input.data?.actorName };
const actors = existing
? [actor, ...(existing.actors as any[]).filter(a => a.id !== input.actorId).slice(0, 4)]
: [actor];
const updated = await tx.notificationAggregate.upsert({
where: { ...key as any },
create: {
...key, count: 1, actors, lastAt: new Date(),
},
update: {
count: { increment: 1 },
actors,
lastAt: new Date(),
},
});
// 5분 이내 다시 알림 안 보냄
if (updated.notifiedAt && Date.now() - updated.notifiedAt.getTime() < 5 * 60 * 1000) {
return;
}
// 묶은 알림 생성
const titleMap: any = {
like: updated.count === 1
? `${actors[0].name}님이 좋아요`
: `${actors[0].name}님 외 ${updated.count - 1}명이 좋아요`,
// ...
};
const notif = await tx.notification.create({
data: {
userId: input.userId,
type: input.type,
title: titleMap[input.type],
link: input.link,
data: { actors, count: updated.count },
},
});
await tx.notificationAggregate.update({
where: { ...key as any },
data: { notifiedAt: new Date() },
});
io.to(`user:${input.userId}`).emit('notification', notif);
});
}이메일 다이제스트 (일별)
// 매일 아침 9시 — 미읽은 알림 모아서 1통
const digestScheduler = scheduler.upsertJobScheduler(
'daily-digest',
{ pattern: '0 9 * * *' },
{ name: 'digest' }
);
const digestWorker = new Worker('digest', async () => {
const usersWithDigest = await db.notificationPreference.findMany({
where: { type: 'digest', channel: 'email', enabled: true },
select: { userId: true },
distinct: ['userId'],
});
for (const { userId } of usersWithDigest) {
const unread = await db.notification.findMany({
where: {
userId,
readAt: null,
createdAt: { gt: new Date(Date.now() - 86400 * 1000) },
},
orderBy: { createdAt: 'desc' },
take: 20,
});
if (unread.length === 0) continue;
const user = await db.user.findUnique({ where: { id: userId } });
await emailQueue.add('digest', {
to: user!.email,
notifications: unread,
total: unread.length,
});
}
});인앱 알림 UI
function NotificationDropdown({ userId }: { userId: string }) {
const { data, refetch } = useQuery({
queryKey: ['notifications'],
queryFn: () => fetch('/api/notifications?limit=20').then(r => r.json()),
});
useEffect(() => {
socket.on('notification', () => refetch());
return () => { socket.off('notification'); };
}, []);
const markAllRead = async () => {
await fetch('/api/notifications/read-all', { method: 'POST' });
refetch();
};
return (
<Dropdown>
<DropdownHeader>
<span>알림</span>
<button onClick={markAllRead}>모두 읽음</button>
</DropdownHeader>
{data?.items.map(n => (
<NotificationItem
key={n.id}
notification={n}
onClick={() => markRead(n.id)}
/>
))}
</Dropdown>
);
}다음 챕터
CH.89 "프로필/설정/개인정보".
AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude
무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6
내 프로젝트의 알림 시스템 부분을 분석해서 실전 분석 + 개선 우선순위를 알려줘.
ChatGPT
무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro
알림 시스템 관련 실제 서비스 5개를 비교 분석해서 패턴 추출를 알려줘.
Gemini
무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro
내 코드베이스에서 알림 시스템 최적화 가능 위치를 보고해줘.
Grok
무료: Grok 4.1 / SuperGrok $30/mo
2026년 한국 풀스택 시장의 알림 시스템 트렌드를 솔직히 알려줘.
⭐ 이것만 기억하세요
알림 시스템: 실시간 + 배치는 이 3가지만 확실히 잡으세요
1.알림 우선순위 — Real-time / Important / Digest / Off
2.묶기(aggregation) = 같은 종류 5분간 1건으로 통합
3.이메일 다이제스트 = 미읽음 알림 일별 1통
공유하기
진행도 88 / 90