Skip to content

Commit 391eb6a

Browse files
authored
Merge pull request #321 from prgrms-web-devcourse-final-project/refactor/sse-singleton
[refactor] SSE 싱글톤 적용
2 parents f9bcd36 + 0dd3655 commit 391eb6a

File tree

5 files changed

+97
-106
lines changed

5 files changed

+97
-106
lines changed

src/App.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import PrivateRoute from './routes/PrivateRoute';
66
import YouTubeAudioPlayer from './components/YouTubeAudioPlayer';
77
import { useSheetStore } from './store/sheetStore';
88
import KaKaoRedirection from '@/components/KaKaoRedirection';
9-
import { useSSE } from '@/hooks/useSSE';
109
import { useYotube } from '@/hooks/useYoutube';
1110
import PublicRoute from '@/routes/PublicRoute';
1211
import CardDetailModalTemp from '@/components/modalSheet/CardDetailModalTemp';
@@ -28,7 +27,6 @@ import {
2827
function App() {
2928
const { isRequestSendingSheetOpen, isRequestReceivingSheetOpen } = useSheetStore();
3029

31-
useSSE(); // SSE연결
3230
useYotube();
3331

3432
return (

src/components/ChatConnectLoadingSheet/ChatConnectLoadingSheet.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,45 @@ import ChatRequestFailSheet from '@/components/ChatConnectLoadingSheet/ChatReque
66
import ChatRequestMessage from '@/components/ChatConnectLoadingSheet/ChatRequestMessage';
77
import ChatRequestButton from '@/components/ChatConnectLoadingSheet/ChatRequestButton';
88
import { useSheetStore } from '@/store/sheetStore';
9+
import { getSSE } from '@/utils/sseClient';
10+
import { useAuthStore } from '@/store/authStore';
11+
import { useChatStore } from '@/store/chatStore';
12+
import { useNavigate } from 'react-router';
913

1014
export default function ChatConnectLoadingSheet({ type }: { type: 'sending' | 'receiving' }) {
11-
const { isChatConnectFail, setChatConnectFail } = useSheetStore();
15+
const { isChatConnectFail, setChatConnectFail, closeAllSheets } = useSheetStore();
1216
const [timeLeft, setTimeLeft] = useState(60); // 남은 시간
17+
const { isAuthenticated, accessToken } = useAuthStore();
18+
19+
const { setCurrentChatRoomId } = useChatStore();
20+
const navigate = useNavigate();
21+
22+
useEffect(() => {
23+
if (!accessToken || !isAuthenticated) return;
24+
const es = getSSE(accessToken);
25+
26+
const onFail = () => {
27+
// console.log('⛔ SSE: 채팅 거절 수신!', JSON.parse(event.data));
28+
setChatConnectFail(true);
29+
};
30+
31+
es.addEventListener('fail', onFail);
32+
33+
const onAccept = (event: any) => {
34+
// console.log('✅ SSE: 채팅방으로 이동!', JSON.parse(event.data));
35+
const { chatRoomId } = JSON.parse(event.data);
36+
37+
setCurrentChatRoomId(chatRoomId);
38+
closeAllSheets();
39+
navigate(`/chatroom/${chatRoomId}`);
40+
};
41+
42+
es.addEventListener('accept', onAccept);
43+
return () => {
44+
es.removeEventListener('fail', onFail);
45+
};
46+
}, [accessToken, isAuthenticated, setChatConnectFail]);
47+
1348
//타이머 60초
1449
useEffect(() => {
1550
const endTime = new Date().getTime() + 60 * 1000; // 현재 시간 + 60초

src/hooks/useSSE.ts

Lines changed: 24 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,117 +1,39 @@
11
import { useAuthStore } from '@/store/authStore';
2-
import { useChatStore } from '@/store/chatStore';
3-
import { useModalStore } from '@/store/modalStore';
42
import { useSheetStore } from '@/store/sheetStore';
5-
import { EventSourcePolyfill } from 'event-source-polyfill';
6-
import { useEffect, useRef } from 'react';
7-
import { useNavigate } from 'react-router';
3+
import { closeSSE, getSSE } from '@/utils/sseClient';
4+
import { useEffect } from 'react';
85

9-
export const useSSE = () => {
10-
const navigate = useNavigate();
11-
const { openSheet, closeSheet, setRequesterInfo, setChatConnectFail, closeAllSheets } =
12-
useSheetStore();
13-
const { openModal } = useModalStore();
14-
const { setCurrentChatRoomId } = useChatStore();
6+
export function useSSE() {
157
const { isAuthenticated, accessToken } = useAuthStore();
16-
const eventSourceRef = useRef<EventSourcePolyfill | null>(null);
17-
const reconnectAttemptsRef = useRef(0); // 재연결 횟수 저장
18-
8+
const { openSheet, closeSheet, setRequesterInfo } = useSheetStore();
199
useEffect(() => {
20-
let timeoutId: NodeJS.Timeout;
21-
// 로그인 상태가 아니거나 토큰이 없으면 SSE 연결을 하지 않음
22-
if (!isAuthenticated || !accessToken) {
23-
// console.log('로그아웃상태이거나 토큰이 없어서 SSE 연결을 해제합니다.');
24-
eventSourceRef.current?.close(); // 혹시 연결이 살아있으면 종료
10+
// 토큰이 없거나 로그인 상태가 아니면 끊기
11+
if (!accessToken || !isAuthenticated) {
12+
closeSSE();
2513
return;
2614
}
15+
const es = getSSE(accessToken); // 토큰 있으면 연결 보장(토큰 바뀌면 재연결)
2716

28-
const connectSSE = () => {
29-
// 최대 재연결 횟수 초과 시 종료
30-
if (reconnectAttemptsRef.current >= 3) {
31-
openModal({
32-
title: '⚠️ 알림 연결이 불안정합니다.',
33-
message: '새로고침으로 복구할 수 있어요.',
34-
onConfirm: () => window.location.reload(),
35-
});
36-
// console.warn('🚫 SSE: 최대 재연결 횟수(3번) 초과, 더 이상 재연결하지 않습니다.');
37-
return;
38-
}
39-
40-
// console.log(`🔌 SSE: 연결 시도 중... (재연결 횟수: ${reconnectAttemptsRef.current})`);
41-
42-
// 기존 연결이 있다면 종료
43-
eventSourceRef.current?.close();
44-
45-
// 로그인 상태와 토큰을 한 번 더 검증
46-
if (!isAuthenticated || !accessToken) {
47-
// console.log('⛔ SSE 연결 시도 중단: 로그아웃 상태거나 토큰 없음');
48-
return;
49-
}
50-
51-
// SSE 연결
52-
eventSourceRef.current = new EventSourcePolyfill(
53-
`${import.meta.env.VITE_API_URL}/api/alert/connect`,
54-
{
55-
headers: { Authorization: `Bearer ${accessToken}` },
56-
},
57-
);
58-
59-
const eventSource = eventSourceRef.current;
60-
61-
eventSource.addEventListener('open', () => {
62-
// console.log('✅ SSE: 연결 성공!');
63-
reconnectAttemptsRef.current = 0; // 연결 성공하면 재연결 횟수 초기화
64-
});
65-
66-
eventSource.addEventListener('alarm', (event: any) => {
67-
// console.log('📩 SSE: 채팅 요청 수신!', JSON.parse(event.data));
68-
const { emotionRecordId, nickname } = JSON.parse(event.data);
69-
setRequesterInfo(emotionRecordId, nickname);
70-
openSheet('isRequestReceivingSheetOpen');
71-
});
72-
73-
eventSource.addEventListener('cancel', () => {
74-
// console.log('🚨 SSE: 채팅 취소 수신!', JSON.parse(event.data));
75-
closeSheet('isRequestReceivingSheetOpen');
76-
});
77-
78-
eventSource.addEventListener('fail', () => {
79-
// console.log('⛔ SSE: 채팅 거절 수신!', JSON.parse(event.data));
80-
setChatConnectFail(true);
81-
});
82-
83-
eventSource.addEventListener('accept', (event: any) => {
84-
// console.log('✅ SSE: 채팅방으로 이동!', JSON.parse(event.data));
85-
const { chatRoomId } = JSON.parse(event.data);
86-
87-
setCurrentChatRoomId(chatRoomId);
88-
closeAllSheets();
89-
navigate(`/chatroom/${chatRoomId}`);
90-
});
91-
92-
eventSource.addEventListener('error', () => {
93-
console.error('❌ SSE: 오류 발생!');
17+
// 📩 채팅 요청 받았을 때를 감지
18+
const onAlarm = (event: any) => {
19+
const { emotionRecordId, nickname } = JSON.parse(event.data);
20+
setRequesterInfo(emotionRecordId, nickname);
21+
openSheet('isRequestReceivingSheetOpen');
22+
};
9423

95-
eventSource.close();
24+
// 🚨 채팅 요청 취소를 받았을 때를 감지
25+
es.addEventListener('alarm', onAlarm);
9626

97-
if (reconnectAttemptsRef.current < 3) {
98-
reconnectAttemptsRef.current += 1;
99-
// console.warn(
100-
// `⚠️ SSE: 재연결 시도 중... (남은 재연결 횟수: ${3 - reconnectAttemptsRef.current})`,
101-
// );
102-
timeoutId = setTimeout(connectSSE, 1000);
103-
} else {
104-
console.error('🚫 SSE: 최대 재연결 횟수 초과. 더 이상 재연결하지 않습니다.');
105-
}
106-
});
27+
const onCancel = () => {
28+
console.log('🚨 SSE: 채팅 취소 수신!');
29+
closeSheet('isRequestReceivingSheetOpen');
10730
};
10831

109-
connectSSE();
32+
es.addEventListener('cancel', onCancel);
11033

11134
return () => {
112-
// console.log('🔴 SSE: 연결 해제');
113-
clearTimeout(timeoutId);
114-
eventSourceRef.current?.close();
35+
es.removeEventListener('alarm', onAlarm);
36+
es.removeEventListener('cancel', onCancel);
11537
};
116-
}, [isAuthenticated, accessToken]);
117-
};
38+
}, [accessToken, isAuthenticated]);
39+
}

src/routes/PrivateRoute.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import { useSSE } from '@/hooks/useSSE';
12
import { useAuthStore } from '@/store/authStore';
23
import { Navigate, Outlet } from 'react-router';
34

45
const PrivateRoute = () => {
5-
66
const { isAuthenticated } = useAuthStore();
7+
useSSE();
78

89
return isAuthenticated ? <Outlet /> : <Navigate to="/" replace />;
910
};

src/utils/sseClient.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { EventSourcePolyfill } from 'event-source-polyfill';
2+
3+
let es: EventSource | null = null; // 하나만 유지
4+
let savedToken: string | null = null; // 마지막에 쓴 토큰
5+
const SSE_URL = `${import.meta.env.VITE_API_URL}/api/alert/connect`;
6+
7+
// 토큰으로 EventSource 가져오기
8+
export function getSSE(token: string) {
9+
// 처음이면 생성
10+
if (!es) {
11+
savedToken = token;
12+
es = new EventSourcePolyfill(SSE_URL, {
13+
headers: { Authorization: `Bearer ${token}` },
14+
});
15+
return es!;
16+
}
17+
18+
// 토큰이 바뀌면: 끊고 새로 만들기(=재연결)
19+
if (savedToken !== token) {
20+
es.close();
21+
savedToken = token;
22+
es = new EventSourcePolyfill(SSE_URL, {
23+
headers: { Authorization: `Bearer ${token}` },
24+
});
25+
}
26+
27+
return es!;
28+
}
29+
30+
// 로그아웃 등에서 끊을 때
31+
export function closeSSE() {
32+
es?.close();
33+
es = null;
34+
savedToken = null;
35+
}

0 commit comments

Comments
 (0)