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

알림 시스템: 실시간 + 배치


핵심 개념

in-app·email·push·digest·선호도·rate limit — 알림 폭주 방지.

본문

알림 우선순위

📋 코드 (14줄)
[Real-time (즉시)]
- 멘션, 답글, DM
- 인앱 + 푸시

[Important (5분 이내)]
- 새 팔로워, 좋아요 (첫 5개만)
- 인앱 + 푸시

[Digest (시간/일별)]
- 댓글 좋아요, 일반 활동
- 이메일 다이제스트

[Off]
- 사용자가 끈 항목

데이터 모델

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

통합 알림 함수

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

TYPESCRIPT📋 코드 (54줄)
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);
  });
}

이메일 다이제스트 (일별)

TYPESCRIPT📋 코드 (36줄)
// 매일 아침 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

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