실시간 알림 기능을 구현하면서 겪었던 SSE 연결 문제와 해결 과정을 공유합니다.
// ❌ 잘못된 코드
useEffect(() => {
const connectSSE = async () => {
// SSE 연결 로직...
return () => { // ❌ async 함수 내부에서 cleanup 반환
eventSource.close();
};
};
connectSSE(); // ❌ cleanup 함수가 반환되지 않음
}, []);
문제: async 함수에서 cleanup 함수를 반환해도 useEffect가 받지 못함
// ❌ 위험한 코드
eventSource.onerror = () => {
setTimeout(() => {
updateToken(); // sseToken 변경 → useEffect 재실행 → 무한 루프
}, 5000);
};
새로고침할 때 로컬스토리지의 user-store가 갑자기 비워지는 현상 발생
// axios 인터셉터에서
} catch (e) {
console.log('토큰 재발급 실패:', e);
window.location.href = '/login'; // ← 문제!
return Promise.reject(e);
}
문제 흐름:
1. SSE 토큰 검증을 위한 axios 요청
2. 401 에러 발생
3. 토큰 재발급 시도 실패
4. 강제 페이지 이동 (상태 초기화 없이)
// ❌ 중복 요청 발생
const connectSSE = async () => {
// 1차: 토큰 검증 요청
const isValid = await checkSseTokenValid(token);
if (!isValid) return;
// 2차: 실제 SSE 연결 요청
const eventSource = new EventSource(...);
};
같은 엔드포인트에 2번 요청하는 비효율성과 axios 인터셉터 중복 실행 위험
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeConnection = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
};
useEffect(() => {
const connectSSE = async () => {
closeConnection(); // 기존 연결 정리
if (!isLoggedIn || !sseToken) return;
const eventSource = new EventSource(url);
eventSourceRef.current = eventSource;
// 이벤트 핸들러 설정...
};
connectSSE();
// ✅ useEffect에서 직접 cleanup 반환
return () => {
closeConnection();
};
}, [isLoggedIn, sseToken]);
// ✅ 개선된 코드
const connectSSE = async () => {
// 토큰 검증 없이 바로 연결 시도
const eventSource = new EventSource(
`${API_BASE_URL}/api/notification/subscribe?token=${sseToken}`
);
eventSource.onerror = (event) => {
closeConnection();
// 실패 원인에 관계없이 재시도
if (isLoggedIn && sseToken) {
reconnectTimeoutRef.current = setTimeout(() => {
connectSSE(); // 직접 재호출
}, 5000);
}
};
};
장점:
// ❌ 기존 방식: 2단계 검증
1. 토큰이 유효한가? → API 요청으로 확인
2. 유효하면 → 실제 SSE 연결 시도
// ✅ 개선된 방식: 1단계로 통합
1. 바로 SSE 연결 시도 → 성공/실패로 토큰 유효성 자동 판단
// ❌ 같은 엔드포인트에 2번 요청
checkSseTokenValid('/api/notification/subscribe?token=abc'); // 1차
new EventSource('/api/notification/subscribe?token=abc'); // 2차
// ✅ 1번만 요청
new EventSource('/api/notification/subscribe?token=abc'); // 끝!
🚗 자동차 시동 걸기
🏠 문 열기
결론: 굳이 미리 확인할 필요 없이, 바로 시도해보고 결과로 판단하는 것이 더 효율적!
SSE 연결 시도 자체가 곧 토큰 유효성 검증
// ✅ 상태 정리 추가
} catch (e) {
console.log('토큰 재발급 실패:', e);
clearAuthTokens(); // ← 토큰과 유저 상태 모두 정리
window.location.href = '/login';
return Promise.reject(e);
}
// localStorage 변경 감지
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'sse_token') {
setSseToken(e.newValue);
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
export function useNotificationSSE(isLoggedIn: boolean) {
const [sseToken, setSseToken] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('sse_token');
}
return null;
});
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setUnreadCount = useNotificationStore((s) => s.setUnreadCount);
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const SSE_CONFIG = {
RECONNECT_DELAY: 5000, // 5초 재연결 간격
} as const;
const closeConnection = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
};
// 다른 탭과 동기화
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'sse_token') {
setSseToken(e.newValue);
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
// SSE 연결 관리
useEffect(() => {
const connectSSE = async () => {
closeConnection();
if (!isLoggedIn || !sseToken) return;
const eventSource = new EventSource(
`${API_BASE_URL}/api/notification/subscribe?token=${encodeURIComponent(sseToken)}`,
{ withCredentials: true }
);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
console.log('✅ SSE 연결 성공');
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setUnreadCount(data.countUnreadNotifications);
} catch (e) {
console.error('알림 데이터 파싱 실패:', e);
}
};
eventSource.onerror = () => {
console.error('❌ SSE 연결 오류');
closeConnection();
if (isLoggedIn && sseToken) {
reconnectTimeoutRef.current = setTimeout(() => {
console.log('🔄 SSE 재연결 시도');
connectSSE();
}, SSE_CONFIG.RECONNECT_DELAY);
}
};
};
connectSSE();
return () => {
closeConnection();
};
}, [isLoggedIn, sseToken]);
const updateToken = () => {
const newToken = localStorage.getItem('sse_token');
setSseToken(newToken);
};
const reconnect = () => {
closeConnection();
setTimeout(() => {
const currentToken = localStorage.getItem('sse_token');
setSseToken(currentToken);
}, 100);
};
return {
updateToken,
reconnect,
isConnected: !!(
isLoggedIn &&
sseToken &&
eventSourceRef.current?.readyState === EventSource.OPEN
),
};
}
| 항목 | Before | After |
|---|---|---|
| API 요청 수 | 2회 (검증 + 연결) | 1회 (연결만) |
| 메모리 누수 | 발생 가능 | 방지됨 |
| 재연결 안정성 | 무한 루프 위험 | 안정적 |
| 다중 탭 지원 | 없음 | 지원됨 |
// Layout.tsx
function Layout() {
const isLoggedIn = useUserStore((s) => s.isLoggedIn);
const { isConnected, reconnect } = useNotificationSSE(isLoggedIn);
return (
<div>
{/* 연결 상태 표시 */}
<div>알림 연결: {isConnected ? '✅' : '❌'}</div>
{/* 수동 재연결 버튼 */}
<button onClick={reconnect}>재연결</button>
<Outlet />
</div>
);
}