stack-analysis
CHAPTER 64 / 90
읽기 약 2분
FUNCTION
알림 시스템: 이메일/푸시/인앱
핵심 개념
이메일(Resend)·웹 푸시(VAPID)·인앱 알림·선호도 관리 — 통합 알림.
본문
알림 데이터 모델
CREATE TABLE notifications (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
type VARCHAR NOT NULL, -- 'mention', 'like', 'invitation', ...
title VARCHAR NOT NULL,
body TEXT,
link VARCHAR,
read_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX ON notifications(user_id, created_at DESC);
CREATE INDEX ON notifications(user_id) WHERE read_at IS NULL;
CREATE TABLE notification_preferences (
user_id UUID NOT NULL,
type VARCHAR NOT NULL,
email BOOLEAN DEFAULT true,
push BOOLEAN DEFAULT true,
inapp BOOLEAN DEFAULT true,
PRIMARY KEY (user_id, type)
);
CREATE TABLE push_subscriptions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
endpoint VARCHAR NOT NULL,
p256dh VARCHAR NOT NULL,
auth VARCHAR NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);통합 알림 서비스
// services/notification.service.ts
type NotificationInput = {
userId: string;
type: string;
title: string;
body?: string;
link?: string;
channels?: Array<'email' | 'push' | 'inapp'>;
};
export async function notify(input: NotificationInput) {
const prefs = await db.notificationPreference.findUnique({
where: { userId_type: { userId: input.userId, type: input.type } },
});
const channels = input.channels ?? ['email', 'push', 'inapp'];
// 1. 인앱 (DB 저장)
if (channels.includes('inapp') && (prefs?.inapp ?? true)) {
const notif = await db.notification.create({
data: {
userId: input.userId,
type: input.type,
title: input.title,
body: input.body,
link: input.link,
},
});
// 실시간 푸시 (Socket.io)
io.to(`user:${input.userId}`).emit('notification', notif);
}
// 2. 이메일
if (channels.includes('email') && (prefs?.email ?? true)) {
await emailQueue.add('notification', { ...input });
}
// 3. 웹 푸시
if (channels.includes('push') && (prefs?.push ?? true)) {
await pushQueue.add('send', { ...input });
}
}이메일 (Resend)
// workers/email.worker.ts
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY!);
const emailWorker = new Worker('email', async (job) => {
const { userId, type, title, body, link } = job.data;
const user = await db.user.findUnique({ where: { id: userId } });
await resend.emails.send({
from: 'noreply@example.com',
to: user!.email,
subject: title,
react: NotificationEmailTemplate({ title, body, link }), // React Email
});
});
// React Email 템플릿
import { Html, Body, Text, Button } from '@react-email/components';
export function NotificationEmailTemplate({ title, body, link }: any) {
return (
<Html>
<Body>
<Text>{body}</Text>
{link && <Button href={link}>자세히 보기</Button>}
</Body>
</Html>
);
}웹 푸시 (Service Worker + VAPID)
// 1. VAPID 키 생성 (1회)
// pnpm dlx web-push generate-vapid-keys
// 2. 구독 (클라이언트)
async function subscribeToPush() {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(sub),
});
}
// 3. 서버 발송
import webpush from 'web-push';
webpush.setVapidDetails(
'mailto:admin@example.com',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!,
);
const pushWorker = new Worker('push', async (job) => {
const { userId, title, body, link } = job.data;
const subs = await db.pushSubscription.findMany({ where: { userId } });
await Promise.all(subs.map(async (sub) => {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: { p256dh: sub.p256dh, auth: sub.auth },
},
JSON.stringify({ title, body, url: link }),
);
} catch (err: any) {
// 410 Gone — 구독 만료
if (err.statusCode === 410) {
await db.pushSubscription.delete({ where: { id: sub.id } });
}
}
}));
});
// 4. Service Worker (public/sw.js)
self.addEventListener('push', (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon-192.png',
data: { url: data.url },
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(self.clients.openWindow(event.notification.data.url));
});인앱 알림 UI (실시간)
'use client';
import { useEffect, useState } from 'react';
import { socket } from '@/lib/socket';
export function NotificationBell({ userId }: { userId: string }) {
const [count, setCount] = useState(0);
const [items, setItems] = useState<Notification[]>([]);
useEffect(() => {
fetch('/api/notifications?unread=true').then(r => r.json()).then(d => {
setCount(d.count);
setItems(d.items);
});
socket.on('notification', (n) => {
setItems(prev => [n, ...prev].slice(0, 20));
setCount(c => c + 1);
});
return () => { socket.off('notification'); };
}, [userId]);
return (
<button>
🔔 {count > 0 && <span className="badge">{count}</span>}
<Dropdown items={items} />
</button>
);
}다음 챕터
CH.65 "대시보드: Recharts + 실시간 데이터".
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.통합 알림 서비스 = 이메일·푸시·인앱 모두 한 함수
2.사용자 선호도(notification_preferences) 우선 — 채널별 끄기 가능
3.Web Push는 VAPID + Service Worker — 무료 + 모바일 지원
공유하기
진행도 64 / 90