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]);
+}