From 010b7cb424b82a7f68d63ac8d3a1b75165ab4554 Mon Sep 17 00:00:00 2001 From: seoJing Date: Wed, 3 Jun 2026 15:19:57 +0900 Subject: [PATCH] feat: subscribe to notification SSE --- src/app/layout.tsx | 6 +- .../providers/notification-sse-provider.tsx | 13 +++ src/features/notification/index.ts | 1 + .../model/use-notification-sse.ts | 89 +++++++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/app/providers/notification-sse-provider.tsx create mode 100644 src/features/notification/model/use-notification-sse.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 83af816..226a924 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { NotificationSSEProvider } from '@/app/providers/notification-sse-provider'; import { QueryProvider } from '@/app/providers/query-provider'; import { ThemeProvider } from '@/app/providers/theme-provider'; import { PwaUpdatePrompt } from '@/shared/ui/pwa-update-prompt'; @@ -48,7 +49,10 @@ export default function RootLayout({ - {children} + + + {children} + diff --git a/src/app/providers/notification-sse-provider.tsx b/src/app/providers/notification-sse-provider.tsx new file mode 100644 index 0000000..33adb96 --- /dev/null +++ b/src/app/providers/notification-sse-provider.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { useNotificationSSE } from '@/features/notification'; +import { useAuthStore } from '@/shared/model/auth-store'; + +export function NotificationSSEProvider() { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const userId = useAuthStore((state) => state.userId); + + useNotificationSSE({ enabled: isAuthenticated || Boolean(userId) }); + + return null; +} diff --git a/src/features/notification/index.ts b/src/features/notification/index.ts index 7eca8e7..b7d67fb 100644 --- a/src/features/notification/index.ts +++ b/src/features/notification/index.ts @@ -5,4 +5,5 @@ export { useMarkNotificationRead, useNotifications, } from './model/use-notifications'; +export { useNotificationSSE } from './model/use-notification-sse'; export type { NotificationItem } from './api/notification-api'; diff --git a/src/features/notification/model/use-notification-sse.ts b/src/features/notification/model/use-notification-sse.ts new file mode 100644 index 0000000..6cca65e --- /dev/null +++ b/src/features/notification/model/use-notification-sse.ts @@ -0,0 +1,89 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { backendProxyEndpoint, endpoints } from '@/lib/endpoint'; +import { notificationKeys } from './use-notifications'; + +type UseNotificationSSEOptions = { + enabled?: boolean; +}; + +const BASE_RECONNECT_DELAY_MS = 1_000; +const MAX_RECONNECT_DELAY_MS = 30_000; + +export function useNotificationSSE({ + enabled = true, +}: UseNotificationSSEOptions = {}) { + const queryClient = useQueryClient(); + const queryClientRef = useRef(queryClient); + + useEffect(() => { + queryClientRef.current = queryClient; + }, [queryClient]); + + useEffect(() => { + if (!enabled || typeof EventSource === 'undefined') return; + + let es: EventSource | null = null; + let reconnectTimer: ReturnType | null = null; + let reconnectAttempts = 0; + let closed = false; + + const clearReconnectTimer = () => { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + }; + + const connect = () => { + clearReconnectTimer(); + es?.close(); + + es = new EventSource( + backendProxyEndpoint(endpoints.notifications.subscribe), + ); + + es.addEventListener('open', () => { + reconnectAttempts = 0; + }); + + es.addEventListener('notification', () => { + queryClientRef.current.invalidateQueries({ + queryKey: notificationKeys.all, + }); + }); + + es.addEventListener('ping', () => { + // keep-alive 이벤트는 연결 유지용이므로 별도 처리하지 않는다. + }); + + es.addEventListener('error', () => { + if ( + closed || + reconnectTimer || + !es || + es.readyState !== EventSource.CLOSED + ) { + return; + } + + const delay = Math.min( + BASE_RECONNECT_DELAY_MS * 2 ** reconnectAttempts, + MAX_RECONNECT_DELAY_MS, + ); + reconnectAttempts += 1; + reconnectTimer = setTimeout(connect, delay); + }); + }; + + connect(); + + return () => { + closed = true; + clearReconnectTimer(); + es?.close(); + }; + }, [enabled]); +}