Advanced (업데이트: 2026. 6. 2.)

Claude Code로 프로덕션 알림 시스템 만들기

Claude Code로 DB, API, React, 읽지 않음, 멱등성, 재시도까지 갖춘 알림 시스템을 설계합니다.

Claude Code로 프로덕션 알림 시스템 만들기

알림 시스템은 벨 아이콘 하나로 끝나지 않습니다. 프로덕션에서 문제를 만드는 것은 중복 발송, 틀린 읽지 않음 수, 메일 폭주, 재시도 불가, 개인정보가 들어간 메시지입니다. 이 글은 Claude Code에 어떤 경계를 주어야 실제 운영 가능한 알림 시스템이 나오는지, DB schema부터 API, React UI, 테스트까지 복사 가능한 예제로 설명합니다.

핵심은 앱 내부 알림을 진실의 원천으로 두는 것입니다. email과 push는 먼저 DB에 저장된 알림을 기준으로, 사용자 설정과 중요도, rate limit을 통과했을 때만 delivery queue에 넣습니다. 외부 제공자가 장애가 나도 사용자는 앱에 돌아왔을 때 알림을 볼 수 있어야 합니다.

공식 문서는 브라우저 알림에 MDN Notifications API, API route에 Next.js Route Handlers, DDL에 PostgreSQL CREATE TABLE을 참고하세요. 메일을 붙일 때는 Resend Next.js 가이드 같은 공식 문서를 보되, 요청 핸들러에서 직접 보내지 말고 queue worker에서 처리하는 것이 안전합니다. Claude Code 기본 흐름은 Claude Code 공식 문서에 있습니다.

시스템 경계

알림은 네 층으로 나누면 안정적입니다.

역할흔한 장애
이벤트 소스결제 실패, 댓글, 작업 완료같은 webhook이 여러 번 도착
알림 서비스저장, 중복 제거, 읽음 상태, 설정, 제한규칙이 UI에 흩어짐
배송 큐email, push, webhook 재시도외부 장애 때 알림 손실
UI알림 센터, 배지, 브라우저 알림탭과 기기마다 수가 다름

처음부터 push를 중심으로 만들지 마세요. 브라우저 알림은 권한과 HTTPS가 필요하고, 모바일에서는 Service Worker가 필요한 경우가 많습니다. 메일도 비용, 수신 거부, 스팸 신고, 전달률 문제가 있습니다. 따라서 먼저 앱 내부 알림을 저장하고, 외부 채널은 별도의 부작용으로 다루는 것이 좋습니다.

Claude Code에는 이렇게 요청합니다.

Build a notification system for Next.js App Router and PostgreSQL.
In-app notifications are the source of truth. Email and push only go into a delivery queue.
Requirements: unread state, idempotency_key, batching, user preferences, rate limit, retry queue, privacy-safe payload.
Implement DB schema, service, route handler, React notification center, and Vitest tests.

멱등성은 같은 처리를 두 번 실행해도 결과가 늘어나지 않는 성질입니다. 예를 들어 같은 결제 실패 webhook이 두 번 와도 알림은 하나여야 합니다. 배칭은 짧은 시간에 발생한 여러 알림을 묶는 것입니다. retry queue는 외부 전송 실패를 나중에 다시 실행할 수 있도록 저장하는 대기열입니다.

주요 유스케이스

첫 번째는 SaaS 운영 알림입니다. 결제 실패, 동기화 실패, 백그라운드 작업 실패는 앱 내부 알림과 메일이 모두 필요할 수 있습니다. 하지만 실패 작업이 1분에 100번 발생해도 100통을 보내면 안 됩니다. critical은 즉시 보내고 나머지는 5분 digest로 묶습니다.

두 번째는 협업 알림입니다. mention, 댓글, review 요청은 읽지 않음 상태가 중요합니다. 사용자는 개별 읽음, 전체 읽음, 안정적인 배지를 기대합니다. 외부 알림에는 댓글 전문이 아니라 짧은 요약과 로그인 후 볼 수 있는 URL만 넣습니다.

세 번째는 콘텐츠와 매출 운영입니다. ClaudeCodeLab 같은 사이트에서는 발행 전 점검, CTA 링크 오류, 무료 PDF 전달, 상품 구매 후 안내, 상담 리마인드를 알림으로 관리할 수 있습니다. 관련 주제는 Claude Code 생산성 팁Claude Code 폼 검증도 함께 보면 좋습니다.

네 번째는 관리자 감사 알림입니다. 권한 변경, API key 생성, 데이터 export는 나중에 추적할 수 있어야 합니다. 본문에는 민감한 값을 넣지 말고 인증된 감사 기록으로 링크합니다.

DB Schema

아래 PostgreSQL schema는 앱 내부 알림, 배송 큐, 사용자 설정, rate limit을 분리합니다.

CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE notification_preferences (
  user_id text PRIMARY KEY,
  in_app_enabled boolean NOT NULL DEFAULT true,
  email_enabled boolean NOT NULL DEFAULT false,
  push_enabled boolean NOT NULL DEFAULT false,
  digest_minutes integer NOT NULL DEFAULT 5,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE notifications (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id text NOT NULL,
  event_name text NOT NULL,
  title text NOT NULL,
  body text NOT NULL,
  target_url text,
  severity text NOT NULL DEFAULT 'info',
  data jsonb NOT NULL DEFAULT '{}'::jsonb,
  idempotency_key text,
  batch_key text,
  read_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX notifications_user_idempotency_unique
  ON notifications (user_id, idempotency_key)
  WHERE idempotency_key IS NOT NULL;

CREATE INDEX notifications_user_created_idx
  ON notifications (user_id, created_at DESC);

CREATE INDEX notifications_user_unread_idx
  ON notifications (user_id, created_at DESC)
  WHERE read_at IS NULL;

CREATE TABLE notification_delivery_queue (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  notification_id uuid NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
  channel text NOT NULL CHECK (channel IN ('email', 'push')),
  status text NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending', 'sending', 'sent', 'failed')),
  attempts integer NOT NULL DEFAULT 0,
  max_attempts integer NOT NULL DEFAULT 5,
  available_at timestamptz NOT NULL DEFAULT now(),
  locked_at timestamptz,
  last_error text,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX notification_delivery_queue_pick_idx
  ON notification_delivery_queue (status, available_at, created_at);

CREATE TABLE notification_rate_limits (
  user_id text NOT NULL,
  channel text NOT NULL,
  window_start timestamptz NOT NULL,
  count integer NOT NULL DEFAULT 0,
  PRIMARY KEY (user_id, channel, window_start)
);

읽음 상태는 read_at 하나로 충분합니다. is_read를 함께 두면 언젠가 둘이 어긋납니다. 삭제가 필요하면 물리 삭제보다 archived_at을 추가하는 편이 queue와 audit에 더 안전합니다.

알림 서비스

필요한 패키지를 설치합니다.

npm install pg
npm install -D @types/pg vitest

lib/notification-service.ts는 알림 저장, 중복 방지, 외부 큐 적재, retry job 처리를 담당합니다.

import { Pool, type PoolClient } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

export type NotificationSeverity = "info" | "success" | "warning" | "critical";
export type ExternalChannel = "email" | "push";

export type NotificationRow = {
  id: string;
  user_id: string;
  event_name: string;
  title: string;
  body: string;
  target_url: string | null;
  severity: NotificationSeverity;
  data: Record<string, unknown>;
  idempotency_key: string | null;
  batch_key: string | null;
  read_at: string | null;
  created_at: string;
};

type Preferences = {
  in_app_enabled: boolean;
  email_enabled: boolean;
  push_enabled: boolean;
  digest_minutes: number;
};

export type CreateNotificationInput = {
  userId: string;
  eventName: string;
  title: string;
  body: string;
  targetUrl?: string;
  severity?: NotificationSeverity;
  data?: Record<string, unknown>;
  resourceId?: string;
  idempotencyKey?: string;
  batchKey?: string;
};

export function makeIdempotencyKey(input: {
  userId: string;
  eventName: string;
  resourceId?: string;
}) {
  if (!input.resourceId) return null;
  return `${input.userId}:${input.eventName}:${input.resourceId}`;
}

export function shouldQueueExternal(input: {
  channel: ExternalChannel;
  severity: NotificationSeverity;
  preferences: Pick<Preferences, "email_enabled" | "push_enabled">;
}) {
  if (input.channel === "email" && !input.preferences.email_enabled) return false;
  if (input.channel === "push" && !input.preferences.push_enabled) return false;
  return input.severity === "warning" || input.severity === "critical";
}

async function ensurePreferences(client: PoolClient, userId: string) {
  const result = await client.query<Preferences>(
    `
    INSERT INTO notification_preferences (user_id)
    VALUES ($1)
    ON CONFLICT (user_id) DO UPDATE
      SET updated_at = notification_preferences.updated_at
    RETURNING in_app_enabled, email_enabled, push_enabled, digest_minutes
    `,
    [userId],
  );
  return result.rows[0];
}

async function consumeRateLimit(
  client: PoolClient,
  userId: string,
  channel: ExternalChannel,
  limitPerMinute: number,
) {
  const result = await client.query<{ count: number }>(
    `
    INSERT INTO notification_rate_limits (user_id, channel, window_start, count)
    VALUES ($1, $2, date_trunc('minute', now()), 1)
    ON CONFLICT (user_id, channel, window_start)
    DO UPDATE SET count = notification_rate_limits.count + 1
    RETURNING count
    `,
    [userId, channel],
  );
  return result.rows[0].count <= limitPerMinute;
}

async function queueDelivery(
  client: PoolClient,
  notificationId: string,
  channel: ExternalChannel,
  availableAt: Date,
) {
  await client.query(
    `
    INSERT INTO notification_delivery_queue (notification_id, channel, available_at)
    VALUES ($1, $2, $3)
    `,
    [notificationId, channel, availableAt],
  );
}

export async function createNotification(input: CreateNotificationInput) {
  const client = await pool.connect();
  const severity = input.severity ?? "info";
  const idempotencyKey =
    input.idempotencyKey ??
    makeIdempotencyKey({
      userId: input.userId,
      eventName: input.eventName,
      resourceId: input.resourceId,
    });

  try {
    await client.query("BEGIN");
    const preferences = await ensurePreferences(client, input.userId);
    if (!preferences.in_app_enabled) {
      await client.query("COMMIT");
      return null;
    }

    const result = await client.query<NotificationRow & { inserted: boolean }>(
      `
      WITH inserted AS (
        INSERT INTO notifications (
          user_id, event_name, title, body, target_url, severity,
          data, idempotency_key, batch_key
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)
        ON CONFLICT (user_id, idempotency_key)
          WHERE idempotency_key IS NOT NULL
        DO NOTHING
        RETURNING *, true AS inserted
      )
      SELECT * FROM inserted
      UNION ALL
      SELECT *, false AS inserted
      FROM notifications
      WHERE user_id = $1
        AND idempotency_key = $8
        AND $8 IS NOT NULL
        AND NOT EXISTS (SELECT 1 FROM inserted)
      LIMIT 1
      `,
      [
        input.userId,
        input.eventName,
        input.title,
        input.body,
        input.targetUrl ?? null,
        severity,
        JSON.stringify(input.data ?? {}),
        idempotencyKey,
        input.batchKey ?? null,
      ],
    );

    const notification = result.rows[0];
    if (!notification) {
      throw new Error("Notification insert failed");
    }

    if (notification.inserted) {
      for (const channel of ["email", "push"] as const) {
        const allowedByPreference = shouldQueueExternal({
          channel,
          severity,
          preferences,
        });
        if (!allowedByPreference) continue;

        const allowedByRateLimit = await consumeRateLimit(
          client,
          input.userId,
          channel,
          channel === "email" ? 5 : 20,
        );
        if (!allowedByRateLimit) continue;

        const delayMs = input.batchKey ? preferences.digest_minutes * 60_000 : 0;
        await queueDelivery(
          client,
          notification.id,
          channel,
          new Date(Date.now() + delayMs),
        );
      }
    }

    await client.query("COMMIT");
    return notification;
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}

export async function listNotifications(userId: string, unreadOnly = false, limit = 30) {
  const result = await pool.query<NotificationRow>(
    `
    SELECT *
    FROM notifications
    WHERE user_id = $1
      AND ($2::boolean = false OR read_at IS NULL)
    ORDER BY created_at DESC
    LIMIT $3
    `,
    [userId, unreadOnly, Math.min(limit, 100)],
  );
  return result.rows;
}

export async function markNotificationRead(userId: string, notificationId: string) {
  const result = await pool.query<NotificationRow>(
    `
    UPDATE notifications
    SET read_at = COALESCE(read_at, now()), updated_at = now()
    WHERE user_id = $1 AND id = $2
    RETURNING *
    `,
    [userId, notificationId],
  );
  return result.rows[0] ?? null;
}

export async function markAllNotificationsRead(userId: string) {
  const result = await pool.query(
    `
    UPDATE notifications
    SET read_at = COALESCE(read_at, now()), updated_at = now()
    WHERE user_id = $1 AND read_at IS NULL
    `,
    [userId],
  );
  return result.rowCount ?? 0;
}

export type DeliveryJob = {
  id: string;
  notification_id: string;
  channel: ExternalChannel;
  attempts: number;
  max_attempts: number;
};

export async function claimNextDeliveryJob() {
  const result = await pool.query<DeliveryJob>(
    `
    UPDATE notification_delivery_queue
    SET status = 'sending', attempts = attempts + 1, locked_at = now(), updated_at = now()
    WHERE id = (
      SELECT id
      FROM notification_delivery_queue
      WHERE status = 'pending' AND available_at <= now()
      ORDER BY available_at ASC, created_at ASC
      FOR UPDATE SKIP LOCKED
      LIMIT 1
    )
    RETURNING id, notification_id, channel, attempts, max_attempts
    `,
  );
  return result.rows[0] ?? null;
}

export async function finishDeliveryJob(job: DeliveryJob, error?: unknown) {
  if (!error) {
    await pool.query(
      "UPDATE notification_delivery_queue SET status = 'sent', updated_at = now() WHERE id = $1",
      [job.id],
    );
    return;
  }

  const retryDelayMs = Math.min(30 * 60_000, 60_000 * 2 ** job.attempts);
  const failedPermanently = job.attempts >= job.max_attempts;

  await pool.query(
    `
    UPDATE notification_delivery_queue
    SET status = $2,
        available_at = $3,
        last_error = $4,
        updated_at = now()
    WHERE id = $1
    `,
    [
      job.id,
      failedPermanently ? "failed" : "pending",
      new Date(Date.now() + retryDelayMs),
      error instanceof Error ? error.message : String(error),
    ],
  );
}

중복 이벤트는 기존 알림을 반환하고 새 배송 job을 만들지 않습니다. 이 동작이 없으면 webhook 재전송 때마다 메일이 늘어납니다.

API Route

app/api/notifications/route.ts를 만듭니다. 예제는 복사하기 쉽도록 x-demo-user-id를 사용하지만, 실제 서비스에서는 세션 인증으로 바꿔야 합니다.

import { NextRequest, NextResponse } from "next/server";
import {
  createNotification,
  listNotifications,
  markAllNotificationsRead,
  markNotificationRead,
} from "@/lib/notification-service";

export const runtime = "nodejs";

function requireDemoUserId(request: NextRequest) {
  const userId = request.headers.get("x-demo-user-id");
  if (!userId) {
    throw new Error("Missing x-demo-user-id header. Replace this with real auth.");
  }
  return userId;
}

export async function GET(request: NextRequest) {
  try {
    const userId = requireDemoUserId(request);
    const unreadOnly = request.nextUrl.searchParams.get("unread") === "1";
    const notifications = await listNotifications(userId, unreadOnly);
    return NextResponse.json({ notifications });
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : "Unknown error" },
      { status: 401 },
    );
  }
}

export async function POST(request: NextRequest) {
  try {
    const userId = requireDemoUserId(request);
    const body = await request.json();
    const notification = await createNotification({
      userId,
      eventName: String(body.eventName),
      title: String(body.title),
      body: String(body.body),
      targetUrl: body.targetUrl ? String(body.targetUrl) : undefined,
      severity: body.severity,
      resourceId: body.resourceId ? String(body.resourceId) : undefined,
      batchKey: body.batchKey ? String(body.batchKey) : undefined,
      data: typeof body.data === "object" && body.data ? body.data : {},
    });

    return NextResponse.json({ notification }, { status: 201 });
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : "Unknown error" },
      { status: 400 },
    );
  }
}

export async function PATCH(request: NextRequest) {
  const userId = requireDemoUserId(request);
  const body = await request.json().catch(() => ({}));

  if (body.notificationId) {
    const notification = await markNotificationRead(userId, String(body.notificationId));
    return NextResponse.json({ notification });
  }

  const updated = await markAllNotificationsRead(userId);
  return NextResponse.json({ updated });
}

요청 body의 userId를 믿으면 안 됩니다. 알림은 개인의 읽지 않음 상태를 포함하므로, 소유자는 반드시 인증 계층에서 정해야 합니다.

React 알림 센터

UI는 앱 내부 알림을 중심에 둡니다. 데스크톱 브라우저 알림은 사용자가 버튼을 눌렀을 때만 권한을 요청합니다.

"use client";

import { useEffect, useMemo, useState } from "react";

type AppNotification = {
  id: string;
  title: string;
  body: string;
  target_url: string | null;
  severity: "info" | "success" | "warning" | "critical";
  read_at: string | null;
  created_at: string;
};

const demoHeaders = {
  "x-demo-user-id": "demo-user-1",
};

export function NotificationCenter() {
  const [items, setItems] = useState<AppNotification[]>([]);
  const [open, setOpen] = useState(false);
  const [desktopEnabled, setDesktopEnabled] = useState(false);

  const unreadCount = useMemo(
    () => items.filter((item) => item.read_at === null).length,
    [items],
  );

  async function loadNotifications() {
    const response = await fetch("/api/notifications", {
      headers: demoHeaders,
      cache: "no-store",
    });
    if (!response.ok) return;
    const data = (await response.json()) as { notifications: AppNotification[] };
    setItems(data.notifications);
  }

  async function markRead(notificationId: string) {
    await fetch("/api/notifications", {
      method: "PATCH",
      headers: {
        ...demoHeaders,
        "content-type": "application/json",
      },
      body: JSON.stringify({ notificationId }),
    });
    setItems((current) =>
      current.map((item) =>
        item.id === notificationId
          ? { ...item, read_at: item.read_at ?? new Date().toISOString() }
          : item,
      ),
    );
  }

  async function markAllRead() {
    await fetch("/api/notifications", {
      method: "PATCH",
      headers: {
        ...demoHeaders,
        "content-type": "application/json",
      },
      body: JSON.stringify({ all: true }),
    });
    const now = new Date().toISOString();
    setItems((current) => current.map((item) => ({ ...item, read_at: item.read_at ?? now })));
  }

  async function enableDesktopNotifications() {
    if (!("Notification" in window)) return;
    const permission = await Notification.requestPermission();
    setDesktopEnabled(permission === "granted");
  }

  useEffect(() => {
    loadNotifications();
    const timer = window.setInterval(loadNotifications, 30_000);
    return () => window.clearInterval(timer);
  }, []);

  useEffect(() => {
    if (!desktopEnabled || document.visibilityState === "visible") return;
    const newest = items.find((item) => item.read_at === null);
    if (!newest) return;
    new Notification(newest.title, {
      body: newest.body,
      tag: newest.id,
    });
  }, [desktopEnabled, items]);

  return (
    <div className="notification-center">
      <button type="button" onClick={() => setOpen((value) => !value)}>
        Notifications
        {unreadCount > 0 ? <span aria-label={`${unreadCount} unread`}>{unreadCount}</span> : null}
      </button>

      {open ? (
        <section aria-label="Notification center">
          <div>
            <button type="button" onClick={markAllRead} disabled={unreadCount === 0}>
              Mark all read
            </button>
            <button type="button" onClick={enableDesktopNotifications}>
              Enable desktop alerts
            </button>
          </div>

          {items.length === 0 ? (
            <p>No notifications.</p>
          ) : (
            <ul>
              {items.map((item) => (
                <li key={item.id} data-unread={item.read_at === null}>
                  <strong>{item.title}</strong>
                  <p>{item.body}</p>
                  <small>{new Date(item.created_at).toLocaleString()}</small>
                  <div>
                    {item.target_url ? <a href={item.target_url}>Open</a> : null}
                    {item.read_at === null ? (
                      <button type="button" onClick={() => markRead(item.id)}>
                        Mark read
                      </button>
                    ) : null}
                  </div>
                </li>
              ))}
            </ul>
          )}
        </section>
      ) : null}
    </div>
  );
}

초기 버전은 30초 polling이면 충분합니다. WebSocket은 나중에 추가해도 됩니다. 먼저 읽음 상태와 중복 방지를 안정화하세요.

테스트

notification-service.test.ts는 중복과 정책을 먼저 고정합니다.

import { describe, expect, it } from "vitest";
import { makeIdempotencyKey, shouldQueueExternal } from "./notification-service";

describe("notification policy", () => {
  it("builds a stable idempotency key from user, event, and resource", () => {
    expect(
      makeIdempotencyKey({
        userId: "user_1",
        eventName: "invoice.payment_failed",
        resourceId: "invoice_9",
      }),
    ).toBe("user_1:invoice.payment_failed:invoice_9");
  });

  it("does not build an idempotency key without a resource id", () => {
    expect(
      makeIdempotencyKey({
        userId: "user_1",
        eventName: "system.notice",
      }),
    ).toBeNull();
  });

  it("queues email only when preferences and severity allow it", () => {
    expect(
      shouldQueueExternal({
        channel: "email",
        severity: "warning",
        preferences: { email_enabled: true, push_enabled: false },
      }),
    ).toBe(true);

    expect(
      shouldQueueExternal({
        channel: "email",
        severity: "info",
        preferences: { email_enabled: true, push_enabled: false },
      }),
    ).toBe(false);

    expect(
      shouldQueueExternal({
        channel: "push",
        severity: "critical",
        preferences: { email_enabled: true, push_enabled: false },
      }),
    ).toBe(false);
  });
});

Claude Code에는 중복 webhook, 설정 off, rate limit 초과, queue 실패, 다른 사용자의 notificationId 읽음 처리 공격 테스트도 추가하라고 지시하세요.

운영상 함정

첫 번째 함정은 개인정보를 본문에 많이 넣는 것입니다. 메일과 push는 잠금 화면, 전달 메일, provider 로그에 보일 수 있습니다. 짧은 요약과 인증된 링크만 넣으세요.

두 번째는 unread count를 브라우저에서만 계산하는 것입니다. 여러 탭과 기기가 섞이면 바로 어긋납니다. 서버의 read_at IS NULL을 기준으로 삼습니다.

세 번째는 앱 내부 기록 자체를 rate limit으로 막는 것입니다. 감사 알림이 사라질 수 있습니다. 외부 배송을 제한하되 DB record는 남기는 편이 안전합니다.

네 번째는 sending job이 영원히 남는 것입니다. 오래 잠긴 job을 pending으로 되돌리는 모니터가 필요합니다.

수익 흐름과 실측

알림은 무료 PDF 전달, 상품 구매 후 안내, CTA 링크 오류, 상담 리마인드처럼 매출 흐름에도 도움이 됩니다. 더 넓은 운영 템플릿은 ClaudeCodeLab 제품에서 확인할 수 있습니다.

이 코드를 로컬 Next.js 프로젝트에 붙이고 같은 resourceIdinvoice.payment_failed를 두 번 POST했습니다. notifications는 한 행만 남았고 queue job도 첫 번째만 생성되었습니다. email 설정을 끄면 앱 내부 알림만 남았습니다. UI보다 먼저 idempotency_keyread_at를 정한 것이 가장 효과적이었습니다.

정리

프로덕션 알림은 UI보다 데이터 일관성이 먼저입니다. 앱 내부 알림을 기준으로 삼고, email/push는 queue로 보내며, unread state, 멱등성, batching, preferences, rate limit, privacy, retry, tests를 처음부터 요구하세요. 그러면 Claude Code는 단순한 toast가 아니라 운영 가능한 알림 기반을 만들 수 있습니다.

#Claude Code #알림 시스템 #Next.js #PostgreSQL #React
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.