OPEN HYPER STEP
← 목록으로 (stack-analysis)
STACK-ANALYSIS · 64 / 90
stack-analysis
CHAPTER 64 / 90
읽기 약 2
FUNCTION

알림 시스템: 이메일/푸시/인앱


핵심 개념

이메일(Resend)·웹 푸시(VAPID)·인앱 알림·선호도 관리 — 통합 알림.

본문

알림 데이터 모델

SQL📋 코드 (33줄)
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()
);

통합 알림 서비스

TYPESCRIPT📋 코드 (43줄)
// 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)

TYPESCRIPT📋 코드 (30줄)
// 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)

TYPESCRIPT📋 코드 (67줄)
// 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 (실시간)

TSX📋 코드 (29줄)
'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