diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 5cc02716..1f7f5e3b 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -19,7 +19,7 @@ export const Providers = ({ children, hasRefreshToken }: Props) => { - + {children} diff --git a/src/hooks/use-notification/use-notification-connect-sse/index.ts b/src/hooks/use-notification/use-notification-connect-sse/index.ts deleted file mode 100644 index 098279e0..00000000 --- a/src/hooks/use-notification/use-notification-connect-sse/index.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -import { useQueryClient } from '@tanstack/react-query'; -import Cookies from 'js-cookie'; - -import { API } from '@/api'; -import { groupKeys } from '@/lib/query-key/query-key-group'; -import { notificationKeys } from '@/lib/query-key/query-key-notification'; -import { userKeys } from '@/lib/query-key/query-key-user'; -import { useAuth } from '@/providers/provider-auth'; -import { NotificationItem } from '@/types/service/notification'; - -export const useConnectSSE = (hasRefreshToken: boolean) => { - const [receivedNewNotification, setReceivedNewNotification] = useState(false); - const { isAuthenticated } = useAuth(); - - const eventSourceRef = useRef(null); - const retryRefreshRef = useRef(false); - const queryClient = useQueryClient(); - - // SSE 연결 진입점 - const connect = () => { - if (!isAuthenticated) { - console.log('[DEBUG] SSE - 인증되지 않음'); - return; - } - - const token = Cookies.get('accessToken'); - if (!token) { - console.log('[DEBUG] SSE - 토큰 없음'); - return; - } - - setupSSEConnection(token); - }; - - // SSE 연결 해제 함수 - const disconnect = () => { - if (eventSourceRef.current) { - console.log('[DEBUG] SSE - 연결 정리'); - eventSourceRef.current.close(); - eventSourceRef.current = null; - } - retryRefreshRef.current = false; - }; - - // SSE 재연결 시도 함수 - const reconnect = async () => { - if (!hasRefreshToken || retryRefreshRef.current) return; - - retryRefreshRef.current = true; - console.log('[DEBUG] SSE - 토큰 갱신 시도'); - - try { - await API.authService.refresh(); - const token = Cookies.get('accessToken'); - if (token) { - setupSSEConnection(token); - } - } catch (error) { - console.error('[DEBUG] SSE - 토큰 갱신 실패:', error); - disconnect(); - } - }; - - // SSE 연결 설정 함수 - const setupSSEConnection = (token: string) => { - // 기존 연결 정리 - if (eventSourceRef.current) { - console.log('[DEBUG] SSE - 기존 연결 정리'); - eventSourceRef.current.close(); - eventSourceRef.current = null; - } - console.log('[DEBUG] SSE - 연결 시도'); - - const es = new EventSource( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/notifications/subscribe?accessToken=${token}`, - ); - - eventSourceRef.current = es; - - es.addEventListener('connect', (event) => { - console.log('[DEBUG] SSE - 연결 확인:', event.data); - retryRefreshRef.current = false; - }); - - es.addEventListener('notification', (event) => { - try { - const data = JSON.parse(event.data) as NotificationItem; - console.log('[DEBUG] SSE - 수신 성공:', data); - setReceivedNewNotification(true); - - queryClient.invalidateQueries({ queryKey: notificationKeys.unReadCount() }); - queryClient.invalidateQueries({ queryKey: notificationKeys.list() }); - - switch (data.type) { - case 'FOLLOW': - queryClient.invalidateQueries({ queryKey: userKeys.me() }); - queryClient.invalidateQueries({ queryKey: userKeys.item(data.user.id) }); - break; - case 'GROUP_CREATE': - case 'GROUP_DELETE': - queryClient.invalidateQueries({ queryKey: groupKeys.lists() }); - break; - case 'GROUP_JOIN': - case 'GROUP_LEAVE': - case 'GROUP_JOIN_APPROVED': - case 'GROUP_JOIN_REJECTED': - case 'GROUP_JOIN_KICKED': - if (data.group) { - queryClient.invalidateQueries({ queryKey: groupKeys.detail(String(data.group.id)) }); - } - break; - case 'GROUP_JOIN_REQUEST': - if (data.group) { - queryClient.invalidateQueries({ - queryKey: groupKeys.joinRequests(String(data.group.id), 'PENDING'), - }); - } - break; - } - } catch (error) { - console.error('[DEBUG] SSE - 데이터 파싱 실패:', error); - } - }); - - es.onerror = async (_error) => { - console.log('[DEBUG] SSE - 연결 오류 발생'); - es.close(); - reconnect(); // ✅ 재연결 함수 호출 - }; - }; - - // 알림 수신 후 3초 뒤 receivedNewNotification이 false로 변경됨 - useEffect(() => { - if (!receivedNewNotification) return; - - const timer = setTimeout(() => { - setReceivedNewNotification(false); - }, 3000); - - return () => clearTimeout(timer); - }, [receivedNewNotification]); - - return { receivedNewNotification, connect, disconnect }; -}; diff --git a/src/hooks/use-sse/index.ts b/src/hooks/use-sse/index.ts new file mode 100644 index 00000000..5cc64bad --- /dev/null +++ b/src/hooks/use-sse/index.ts @@ -0,0 +1,2 @@ +export { useSSEConnect } from './use-sse-connect'; +export { useSSEEvent } from './use-sse-event'; diff --git a/src/hooks/use-sse/use-sse-connect/index.ts b/src/hooks/use-sse/use-sse-connect/index.ts new file mode 100644 index 00000000..4d198b07 --- /dev/null +++ b/src/hooks/use-sse/use-sse-connect/index.ts @@ -0,0 +1,122 @@ +import { useEffect, useRef, useState } from 'react'; + +import Cookies from 'js-cookie'; + +import { API } from '@/api'; +import { NotificationItem } from '@/types/service/notification'; + +export const useSSEConnect = () => { + const [data, setData] = useState(null); + + const eventSourceRef = useRef(null); + const retryRefreshRef = useRef(false); + const isMountedRef = useRef(true); + + // SSE 연결 진입점 + const connect = () => { + const token = Cookies.get('accessToken'); + if (!token) { + console.log('[DEBUG] SSE - 토큰 없음'); + return; + } + + setupSSEConnection(token); + }; + + // SSE 연결 해제 함수 + const disconnect = () => { + if (eventSourceRef.current) { + console.log('[DEBUG] SSE - 연결 정리'); + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + retryRefreshRef.current = false; + }; + + // SSE 재연결 시도 함수 + const reconnect = async () => { + retryRefreshRef.current = true; + console.log('[DEBUG] SSE - 토큰 갱신 시도'); + + try { + await API.authService.refresh(); + const token = Cookies.get('accessToken'); + if (token) { + setupSSEConnection(token); + } + } catch (error) { + console.error('[DEBUG] SSE - 토큰 갱신 실패:', error); + disconnect(); + } + }; + + // SSE 연결 설정 함수 + const setupSSEConnection = (token: string) => { + if (!isMountedRef.current) { + console.log('[DEBUG] SSE - 언마운트된 컴포넌트, 연결 중단'); + return; + } + + // 1. 기존 연결 정리 + if (eventSourceRef.current) { + console.log('[DEBUG] SSE - 기존 연결 정리'); + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + + // 2. SSE 연결 시도 + console.log('[DEBUG] SSE - 연결 시도'); + const es = new EventSource( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/notifications/subscribe?accessToken=${token}`, + ); + + eventSourceRef.current = es; + + // 3. SSE 연결 성공 시 + es.addEventListener('connect', (event) => { + console.log('[DEBUG] SSE - 연결 확인:', event.data); + retryRefreshRef.current = false; + }); + + // 4. SSE 이벤트 수신 시 + es.addEventListener('notification', (event) => { + try { + const receivedData = JSON.parse(event.data) as NotificationItem; + setData(receivedData); + console.log('[DEBUG] SSE - 수신 성공:', receivedData); + } catch (error) { + console.error('[DEBUG] SSE - 데이터 파싱 실패:', error); + } + }); + + // 5. SSE 연결 오류 발생 시 + es.onerror = async (_error) => { + console.log('[DEBUG] SSE - 연결 오류 발생'); + es.close(); + if (retryRefreshRef.current) return; + reconnect(); + }; + }; + + useEffect(() => { + isMountedRef.current = true; + connect(); + return () => { + isMountedRef.current = false; + disconnect(); + }; + }, []); + + // 알림 수신 후 3초 뒤 data가 null로 변경됨 + useEffect(() => { + if (!data) return; + + const timer = setTimeout(() => { + setData(null); + }, 3000); + + return () => clearTimeout(timer); + }, [data]); + + return { data }; +}; diff --git a/src/hooks/use-sse/use-sse-event/index.ts b/src/hooks/use-sse/use-sse-event/index.ts new file mode 100644 index 00000000..390e94be --- /dev/null +++ b/src/hooks/use-sse/use-sse-event/index.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; + +import { QueryKey, useQueryClient } from '@tanstack/react-query'; + +import { groupKeys } from '@/lib/query-key/query-key-group'; +import { notificationKeys } from '@/lib/query-key/query-key-notification'; +import { userKeys } from '@/lib/query-key/query-key-user'; +import { NotificationItem } from '@/types/service/notification'; + +const SSE_INVALIDATION_MAP: Partial< + Record QueryKey[]> +> = { + FOLLOW: (data) => [userKeys.me(), userKeys.item(data.user.id)], + GROUP_CREATE: () => [groupKeys.lists()], + GROUP_DELETE: () => [groupKeys.lists()], + GROUP_JOIN: (data) => (data.group ? [groupKeys.detail(String(data.group.id))] : []), + GROUP_LEAVE: (data) => (data.group ? [groupKeys.detail(String(data.group.id))] : []), + GROUP_JOIN_APPROVED: (data) => (data.group ? [groupKeys.detail(String(data.group.id))] : []), + GROUP_JOIN_REJECTED: (data) => (data.group ? [groupKeys.detail(String(data.group.id))] : []), + GROUP_JOIN_KICKED: (data) => (data.group ? [groupKeys.detail(String(data.group.id))] : []), + GROUP_JOIN_REQUEST: (data) => + data.group ? [groupKeys.joinRequests(String(data.group.id), 'PENDING')] : [], +}; + +export const useSSEEvent = (data: NotificationItem | null) => { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!data) return; + queryClient.invalidateQueries({ queryKey: notificationKeys.unReadCount() }); + queryClient.invalidateQueries({ queryKey: notificationKeys.list() }); + + const getQueryKeys = SSE_INVALIDATION_MAP[data.type]; + getQueryKeys?.(data).forEach((queryKey) => { + queryClient.invalidateQueries({ queryKey }); + }); + }, [data]); +}; diff --git a/src/providers/provider-notification/index.tsx b/src/providers/provider-notification/index.tsx index f12c4377..f23b0e66 100644 --- a/src/providers/provider-notification/index.tsx +++ b/src/providers/provider-notification/index.tsx @@ -1,7 +1,7 @@ -import { createContext, useContext, useEffect } from 'react'; +import { createContext, useContext } from 'react'; import { useGetNotificationUnreadCount } from '@/hooks/use-notification'; -import { useConnectSSE } from '@/hooks/use-notification/use-notification-connect-sse'; +import { useSSEConnect, useSSEEvent } from '@/hooks/use-sse'; interface NotificationContextType { unReadCount: number; @@ -18,20 +18,14 @@ export const useNotification = () => { interface NotificationProviderProps { children: React.ReactNode; - hasRefreshToken: boolean; } -export const NotificationProvider = ({ children, hasRefreshToken }: NotificationProviderProps) => { +export const NotificationProvider = ({ children }: NotificationProviderProps) => { const { data: unReadCount = 0 } = useGetNotificationUnreadCount(); - const { receivedNewNotification, connect, disconnect } = useConnectSSE(hasRefreshToken); - - useEffect(() => { - connect(); - return () => { - disconnect(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const { data: receivedData } = useSSEConnect(); + useSSEEvent(receivedData); + + const receivedNewNotification = !!receivedData; return ( diff --git a/src/types/service/notification.ts b/src/types/service/notification.ts index 067558ec..703ce08d 100644 --- a/src/types/service/notification.ts +++ b/src/types/service/notification.ts @@ -9,35 +9,22 @@ export type NotificationType = | 'GROUP_JOIN_REJECTED' | 'GROUP_JOIN_KICKED'; -type NotificationTypeWithoutGroup = 'FOLLOW'; -type NotificationTypeWithGroup = Exclude; - -interface BaseNotification { +export interface NotificationItem { id: number; message: string; readAt: string | null; createdAt: string; + type: NotificationType; user: { id: number; nickname: string; }; -} - -interface NotificationWithoutGroup extends BaseNotification { - type: NotificationTypeWithoutGroup; - group: null; -} - -interface NotificationWithGroup extends BaseNotification { - type: NotificationTypeWithGroup; group: { id: number; title: string; } | null; } -export type NotificationItem = NotificationWithoutGroup | NotificationWithGroup; - export interface NotificationList { notifications: NotificationItem[]; nextCursor: number | null;