OPEN HYPER STEP
← 목록으로 (master-project)
MASTER-PROJECT · 20 / 50
master-project
CHAPTER 20 / 50
읽기 약 2
FUNCTION

실시간 알림: 토스트 + 벨 아이콘


핵심 개념

sonner 토스트 + Supabase Realtime + 벨 아이콘 + 읽음 처리 — 사용자 피드백·재참여 기능.

본문

sonner 토스트 셋업

BASH📋 코드 (1줄)
pnpm add sonner
TSX📋 코드 (4줄)
// src/app/layout.tsx
import { Toaster } from 'sonner'

<Toaster richColors position="top-right" />
TSX📋 코드 (11줄)
// 사용
import { toast } from 'sonner'

toast.success('생성 완료!')
toast.error('실패: 잠시 후 재시도')
toast.loading('생성 중...')
toast.promise(saveData(), {
  loading: '저장 중...',
  success: '저장 완료',
  error: '저장 실패',
})

notifications 테이블

SQL📋 코드 (15줄)
create table notifications (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references users(id) on delete cascade,
  type text not null,  -- 'generation_done', 'subscription_renewed', etc.
  title text not null,
  body text,
  data jsonb,
  read_at timestamptz,
  created_at timestamptz default now()
);

create index idx_notif_user on notifications(user_id, read_at, created_at desc);

alter table notifications enable row level security;
create policy "users own" on notifications for all using (auth.uid() = user_id);

벨 아이콘 + 카운트

TSX📋 코드 (77줄)
// src/components/notification-bell.tsx
'use client'
import { useEffect, useState } from 'react'
import { Bell } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createClient } from '@/lib/supabase/client'
import { formatDistanceToNow } from 'date-fns'
import { ko } from 'date-fns/locale'

export function NotificationBell() {
  const supabase = createClient()
  const [notifs, setNotifs] = useState<any[]>([])
  const unreadCount = notifs.filter(n => !n.read_at).length

  useEffect(() => {
    const load = async () => {
      const { data } = await supabase
        .from('notifications')
        .select('*')
        .order('created_at', { ascending: false })
        .limit(20)
      setNotifs(data ?? [])
    }
    load()

    // 실시간 구독
    const ch = supabase.channel('notifications')
      .on('postgres_changes',
        { event: 'INSERT', schema: 'public', table: 'notifications' },
        payload => setNotifs(prev => [payload.new, ...prev])
      )
      .subscribe()

    return () => { supabase.removeChannel(ch) }
  }, [])

  const markAllRead = async () => {
    await supabase.from('notifications').update({ read_at: new Date().toISOString() }).is('read_at', null)
    setNotifs(prev => prev.map(n => ({ ...n, read_at: new Date().toISOString() })))
  }

  return (
    <Popover>
      <PopoverTrigger className="relative">
        <Bell className="h-5 w-5" />
        {unreadCount > 0 && (
          <span className="absolute -top-1 -right-1 h-4 w-4 bg-red-500 text-white text-[10px] rounded-full flex items-center justify-center">
            {unreadCount}
          </span>
        )}
      </PopoverTrigger>
      <PopoverContent className="w-80 p-0">
        <div className="flex justify-between p-3 border-b">
          <h3 className="font-semibold">알림</h3>
          {unreadCount > 0 && (
            <button onClick={markAllRead} className="text-xs text-primary">모두 읽음</button>
          )}
        </div>
        <ul className="max-h-80 overflow-y-auto">
          {notifs.length === 0 ? (
            <li className="p-6 text-center text-sm text-muted-foreground">알림이 없습니다</li>
          ) : (
            notifs.map(n => (
              <li key={n.id} className={`p-3 border-b ${!n.read_at ? 'bg-primary/5' : ''}`}>
                <p className="font-medium text-sm">{n.title}</p>
                {n.body && <p className="text-xs text-muted-foreground mt-1">{n.body}</p>}
                <time className="text-[10px] text-muted-foreground">
                  {formatDistanceToNow(new Date(n.created_at), { locale: ko, addSuffix: true })}
                </time>
              </li>
            ))
          )}
        </ul>
      </PopoverContent>
    </Popover>
  )
}

알림 발송 (Server Action)

TS📋 코드 (8줄)
// 생성 완료 시 알림
await supabase.from('notifications').insert({
  user_id: userId,
  type: 'generation_done',
  title: 'AI 카피 생성 완료',
  body: '결과를 확인해보세요',
  data: { generation_id: gen.id },
})

Push 알림 (Web Push)

BASH📋 코드 (5줄)
# 큰 서비스 단계
pnpm add web-push

# Service Worker 등록 → Push subscription 저장
# 백엔드에서 webpush.sendNotification()

이메일 알림 (Resend)

TS📋 코드 (10줄)
// 큰 이벤트 (결제·구독 만료) 이메일
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)

await resend.emails.send({
  from: 'My SaaS <noreply@mysaas.com>',
  to: user.email,
  subject: '구독이 갱신되었습니다',
  html: '<p>...</p>',
})

다음 챕터

CH.21 "설정 페이지: 프로필/알림/결제".


AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude

무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6

내 마스터 프로젝트의 실시간 알림 구현 부분을 분석해서
실전 적용 + 개선 우선순위 3가지를 알려줘.
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년 한국 1인 개발자 시장의
실시간 알림 구현 트렌드와 차별화 포인트를 정리해줘.

⭐ 이것만 기억하세요
실시간 알림: 토스트 + 벨 아이콘 이 3가지만 확실히 잡으세요
1.sonner = 즉각 피드백 (toast)
2.Supabase Realtime = 실시간 알림 표시
3.다음 챕터에서 설정 페이지


공유하기
진행도 20 / 50