React에서 안전한 SSE(Server-Sent Events) 연결 관리하기

oversleep·2025년 8월 18일

실시간 알림 기능을 구현하면서 겪었던 SSE 연결 문제와 해결 과정을 공유합니다.

🎯 목표

  • 로그인한 사용자에게 실시간 알림 제공
  • 연결 실패 시 자동 재연결
  • 새로고침 시에도 안정적인 연결 유지
  • 다른 탭과의 상태 동기화

🚨 초기 문제점

1. useEffect cleanup 함수 반환 오류

// ❌ 잘못된 코드
useEffect(() => {
  const connectSSE = async () => {
    // SSE 연결 로직...
    
    return () => {  // ❌ async 함수 내부에서 cleanup 반환
      eventSource.close();
    };
  };

  connectSSE(); // ❌ cleanup 함수가 반환되지 않음
}, []);

문제: async 함수에서 cleanup 함수를 반환해도 useEffect가 받지 못함

2. 무한 재연결 루프

// ❌ 위험한 코드
eventSource.onerror = () => {
  setTimeout(() => {
    updateToken(); // sseToken 변경 → useEffect 재실행 → 무한 루프
  }, 5000);
};

3. 새로고침 시 상태 초기화

새로고침할 때 로컬스토리지의 user-store가 갑자기 비워지는 현상 발생

🔍 원인 분석

API 인터셉터의 예상치 못한 부작용

// 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 인터셉터 중복 실행 위험

✅ 해결 방안

1. useRef로 안전한 참조 관리

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

2. 사전 토큰 검증 제거

// ✅ 개선된 코드
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);
    }
  };
};

장점:

  • 중복 요청 제거
  • axios 인터셉터 개입 방지
  • 단순하고 안전한 로직

핵심 아이디어

// ❌ 기존 방식: 2단계 검증
1. 토큰이 유효한가?API 요청으로 확인
2. 유효하면 → 실제 SSE 연결 시도

// ✅ 개선된 방식: 1단계로 통합
1. 바로 SSE 연결 시도 → 성공/실패로 토큰 유효성 자동 판단

왜 이게 더 합리적인가?

1. 어차피 해야 할 일

  • 토큰이 유효하든 아니든 결국 SSE 연결을 시도해야 함
  • 연결 성공 = 토큰 유효
  • 연결 실패 = 토큰 무효 (또는 서버 문제)

2. 중복 작업 제거

// ❌ 같은 엔드포인트에 2번 요청
checkSseTokenValid('/api/notification/subscribe?token=abc'); // 1차
new EventSource('/api/notification/subscribe?token=abc');   // 2차

// ✅ 1번만 요청
new EventSource('/api/notification/subscribe?token=abc');   // 끝!

3. 결과는 동일

  • 유효한 토큰: 어차피 연결 성공
  • 무효한 토큰: 어차피 연결 실패 → 재시도

실생활 비유

🚗 자동차 시동 걸기

  • ❌ "연료가 있는지 먼저 확인하고, 있으면 시동 걸기"
  • ✅ "바로 시동 걸어보기 → 안 걸리면 연료/배터리 문제"

🏠 문 열기

  • ❌ "열쇠가 맞는지 먼저 확인하고, 맞으면 문 열기"
  • ✅ "바로 열쇠로 열어보기 → 안 열리면 잘못된 열쇠"

결론: 굳이 미리 확인할 필요 없이, 바로 시도해보고 결과로 판단하는 것이 더 효율적!

SSE 연결 시도 자체가 곧 토큰 유효성 검증

3. API 인터셉터 개선

// ✅ 상태 정리 추가
} catch (e) {
  console.log('토큰 재발급 실패:', e);
  clearAuthTokens(); // ← 토큰과 유저 상태 모두 정리
  window.location.href = '/login';
  return Promise.reject(e);
}

4. 다중 탭 동기화

// 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 vs After

항목BeforeAfter
API 요청 수2회 (검증 + 연결)1회 (연결만)
메모리 누수발생 가능방지됨
재연결 안정성무한 루프 위험안정적
다중 탭 지원없음지원됨

재연결 정책

  • 간격: 5초 (일반적인 알림 서비스 표준)
  • 조건: 로그인 상태 + 토큰 존재
  • 중단: 로그아웃, 컴포넌트 언마운트, 연결 성공

🎓 배운 점

  1. useEffect cleanup은 반드시 useEffect에서 직접 반환해야 함
  2. useRef로 비동기 상태 안전하게 관리하기
  3. 불필요한 사전 검증보다는 실패 시 재시도가 더 효율적
  4. API 인터셉터의 부작용 고려하기
  5. 다중 탭 환경에서의 상태 동기화 중요성

🚀 사용법

// 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>
  );
}
profile
궁금한 것, 했던 것, 시행착오 그리고 기억하고 싶은 것들을 기록합니다.

0개의 댓글