[React/Next.js] SSE를 활용한 실시간 알림 구현 (+ 슬라이딩 애니메이션 CSS)

문지은·2023년 12월 12일
2
post-thumbnail
post-custom-banner

SSE (Server-Sent Events)

  • 서버에서 클라이언트로 단방향으로 실시간 이벤트를 전송하는 웹 기술로, 서버에서 발생하는 업데이트나 알림 등을 실시간으로 클라이언트에 전달할 수 있다.
    • 단방향 통신이기 때문에 서버에서 클라이언트로만 데이터를 전송할 수 있다.
  • 클라이언트는 HTTP 프로토콜을 통해 SSE 연결을 설정하고, 서버는 HTTP 응답을 유지한 상태에서 데이터를 전송한다.
  • 재연결 기능을 제공하기 때문에 연결이 끊어졌을 때 자동으로 다시 연결한다.
    • 클라이언트와 서버 간에 지속적인 연결을 유지하고, 서버가 필요한 시점에만 데이터를 전송하는 방식이다.

WebSocket과의 차이점

  • SSE와 WebSocket의 가장 큰 차이점은 데이터의 흐름
    • SSE는 서버에서 클라이언트로 데이터를 전송하는 단방향 통신 방식
    • WebSocket은 양방향 통신을 지원하여 서버와 클라이언트가 양방향을 주고받을 수 있다.
    • 따라서, SSE는 주로 서버에서 클라이언트로 일방적인 데이터 전송이 필요한 주가 업데이트나 실시간 알림 메시지에 적합하고 WebSocket은 양방향 통신이 필요한 실시간 채팅 등에 사용된다.
  • SSE는 웹 기술이기 때문에 HTTP 프로토콜 위에서 동작한다.
    • 또한, HTTP 연결을 유지한 상태에서 재연결이나 추가 설정 없이 서버로부터 지속적인 데이터 스트림을 받을 수 있다.
  • 반면, WebSocket은 독립적인 프로토콜을 사용하고, HTTP와는 별도의 연결을 만들어 데이터를 주고받는다.
  • SSE는 CORS(Cross-Origin Resource Sharing)을 통해 다른 도메인에서도 데이터를 수신할 수 있다.
    • WebSocket도 동일한 도메인 간 통신을 제공하지만, 보안상의 이유로 추가 구성이 필요할 수 있다.
SSEWebSocket

SSE를 활용한 실시간 알림 구현하기

SSE를 구현하기 위해서는 서버 측과 클라이언트 측에서의 설정과 처리 로직을 구현해야 한다.

  • 서버 측에서는 SSE 프로토콜을 생성하고 클라이언트에게 전송할 이벤트 데이터를 준비해야 한다.
  • 클라이언트 측에서는 EventSource 객체를 생성하고 이벤트를 처리하는 로직을 작성해야한다.
    • EventSource 객체는 SSE 프로토콜을 처리하고 이벤트를 수신하는 기능을 한다.

Next.js + Spring Boot로 진행했던 프로젝트에서 실시간 알림 기능을 구현하였는데, 클라이언트(Next.js)에서 작성했던 코드를 공유하고자 한다.

구현 화면

슬라이딩 애니메이션 CSS (SCSS)

  • 구현화면을 보면, 알림이 도착하면 오른쪽 하단에서 알림이 나타났다가 사라지도록 구현하였다.
  • 라이브러리를 사용하지 않고 애니메이션을 적용하기 위해 CSS 코드를 직접 작성하였다.
    • 알림이 도착했을 때 오른쪽에서 나타나는 애니메이션 slideInRight 을 적용한다.
    • 알림이 사라질 때 오른쪽으로 사라지는 애니메이션 slideOutRight 을 적용한다.
@keyframes slideInRight {
  from {
    transform: translateX(100%);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

@keyframes slideOutRight {
  from {
    transform: translateX(0);
    opacity: 1;
  }
  to {
    transform: translateX(100%);
    opacity: 0;
  }
}

.background {
  position: fixed;
  bottom: 5px;
  right: 5px;
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 9999;
  width: 400px;
}

.slide-in {
  animation: slideInRight 0.5s ease forwards;
}

.slide-out {
  animation: slideOutRight 0.5s ease forwards;
}

EventSource 객체 생성

  • EventSource 객체 생성 시 서버로 accessToken을 헤더에 담아 함께 보내줘야 해서 event-source-polyfill 이라는 라이브러리를 사용하였다.
  • heartbeatTimeout 은 SSE 연결 시간을 의미하며, 적용해주지 않으면 45초가 기본 값으로 설정된다.
    • 서버로부터 이벤트가 발생하지 않으면 45초마다 재연결을 시도하니 연결 시간을 24시간 (86400000ms)로 설정하였다.
    • 만약 서버에서 먼저 연결을 끊어도 오류가 발생하며 재연결을 시도하니 서버에서도 추가 설정이 필요하다. (서버 개발자와 협의하여 결정할 것 !)
  • 세 가지 다른 유형의 이벤트(newNotice, statusChange, newApply)에 대한 이벤트 리스너를 작성하였다.
    • 이벤트 수신 시, slide-in 애니메이션을 적용하고, react-query 의 invalidateQueries 메서드를 활용하여 특정 상태들을 무효화하여 자동으로 업데이트 하도록 설정하였다.
    • 알림이 도착하고 5초 이후에는 slide-out 애니메이션을 적용하여 알림이 자동으로 사라지도록 구현하였다.
  • useEffect 훅 return 값으로 eventSource.close() 를 작성하여 컴포넌트가 언마운트 될 때 SSE 연결을 닫도록 설정하였다.
import React, { useEffect, useState } from 'react';
import { useQueryClient } from 'react-query';
import Cookies from 'js-cookie';
import { EventSourcePolyfill, NativeEventSource } from 'event-source-polyfill';
import { INewNotice, IEmergency, INoticeAdmin } from '@/types/Notice';
import styles from './NewNotice.module.scss';

function NewNotice() {
  const accessToken = Cookies.get('accessToken');
  const queryClient = useQueryClient();

  const [newNotice, setNewNotice] = useState<INewNotice>();
  const [newStatus, setStatus] = useState<IEmergency>();
  const [newApply, setNewApply] = useState<INoticeAdmin>();
  const [animationClass, setAnimationClass] = useState(styles['slide-in']);

  useEffect(() => {
    const EventSource = EventSourcePolyfill || NativeEventSource;
    const eventSource = new EventSource('API_URL', {
      headers: {
        Authorization: `Bearer ${accessToken}`,
        Connetction: 'keep-alive',
        Accept: 'text/event-stream',
      },
      heartbeatTimeout: 86400000,
    });

    // eslint-disable-next-line
    eventSource.addEventListener('connect', (event: any) => {
      const { data: receivedConnectData } = event;
      if (receivedConnectData === 'SSE 연결이 완료되었습니다.') {
        console.log('SSE CONNECTED');
      } else {
        console.log(event);
      }
    });

    // eslint-disable-next-line
    eventSource.addEventListener('newNotice', (event: any) => {
      const newNoticeInfo: INewNotice = JSON.parse(event.data);
      setNewNotice(newNoticeInfo);
      setAnimationClass(styles['slide-in']);  // 슬라이드 애니메이션
      queryClient.invalidateQueries('noticeCnt'); // 쪽지수 업데이트
      queryClient.invalidateQueries('noticeList'); // 쪽지리스트 업데이트
      queryClient.invalidateQueries(['unreadReceiveList', 0]); // 안읽은 쪽지리스트 업데이트

      const slideOutTimer = setTimeout(() => {
        setAnimationClass(styles['slide-out']);

        const clearNoticeTimer = setTimeout(() => {
          setNewNotice(undefined);
        }, 500);

        return () => clearTimeout(clearNoticeTimer);
      }, 5000);

      return () => clearTimeout(slideOutTimer);
    });

    // eslint-disable-next-line
    eventSource.addEventListener('statusChange', (event: any) => {
      const newNoticeInfo: IEmergency = JSON.parse(event.data);
      setStatus(newNoticeInfo);
      setAnimationClass(styles['slide-in']);  // 슬라이드 애니메이션
      queryClient.invalidateQueries('noticeCnt'); // 쪽지수 업데이트
      queryClient.invalidateQueries('noticeList'); // 쪽지리스트 업데이트
      queryClient.invalidateQueries(['unreadReceiveList']); // 안읽은 쪽지리스트 업데이트
      queryClient.invalidateQueries('apiCount'); // 상태 수 업데이트
      queryClient.invalidateQueries('apiStatuslist 전체'); // 상태 리스트 업데이트
      queryClient.invalidateQueries(['apiStatus']); // 상태 리스트 업데이트

      // 5초 후에 알림 언마운트하고 상태 비우기
      const slideOutTimer = setTimeout(() => {
        setAnimationClass(styles['slide-out']);

        const clearStatusTimer = setTimeout(() => {
          setStatus(undefined);
        }, 500);

        return () => clearTimeout(clearStatusTimer);
      }, 5000);

      return () => clearTimeout(slideOutTimer);
    });

    // eslint-disable-next-line
    eventSource.addEventListener('newApply', (event: any) => {
      const newApplyInfo: INoticeAdmin = JSON.parse(event.data);
      setNewApply(newApplyInfo);
      setAnimationClass(styles['slide-in']);  // 슬라이드 애니메이션
      queryClient.invalidateQueries('noticeCnt'); // 쪽지수 업데이트
      queryClient.invalidateQueries('noticeList'); // 쪽지리스트 업데이트
      queryClient.invalidateQueries(['unreadReceiveList']); // 안읽은 쪽지리스트 업데이트
      queryClient.invalidateQueries(['provideApplyList']); // 제공 신청 리스트 업데이트
      queryClient.invalidateQueries(['useApplyList']); // 제공 신청 리스트 업데이트

      // 5초 후에 알림 언마운트하고 상태 비우기
      const slideOutTimer = setTimeout(() => {
        setAnimationClass(styles['slide-out']);

        const clearStatusTimer = setTimeout(() => {
          setStatus(undefined);
        }, 500);

        return () => clearTimeout(clearStatusTimer);
      }, 5000);

      return () => clearTimeout(slideOutTimer);
    });

    return () => {
      eventSource.close();
      console.log('SSE CLOSED');
    };
    // eslint-disable-next-line
  }, []);

  const handleClose = () => {
    setAnimationClass(styles['slide-out']);
  };
	
	if (!newNotice && !newStatus && !newApply) {
    return null;
  }

	if (newNotice) {
    return (
      <div className={`${styles.background} ${animationClass}`}>
        ...
      </div>
    );
  }
	
	if (newStatus) {
    return (
      <div className={`${styles.background} ${animationClass}`}>
        ...
      </div>
    );
  }

	if (newApply) {
    return (
      <div className={`${styles.background} ${animationClass}`}>
        ...
      </div>
    );
  }

}

export default NewNotice;
  • 작성한 컴포넌트를 NavBar 컴포넌트 안에 추가하여 사용자가 모든 페이지에서 실시간 알림을 받을 수 있도록 하였다.

References

실시간 데이터 전송 방법 Server-Sent Events(SSE)와 웹소켓 차이
알림 기능을 구현해보자 - SSE(Server-Sent-Events)!

profile
코드로 꿈을 펼치는 개발자의 이야기, 노력과 열정이 가득한 곳 🌈
post-custom-banner

4개의 댓글

comment-user-thumbnail
2023년 12월 20일

도움이 많이 되었습니다.

1개의 답글
comment-user-thumbnail
2024년 2월 13일

잘 읽었습니다! 도움이 됐어요

1개의 답글