Next14로 Server-Sent Events(SSE) 구현하기

문다현·2024년 11월 5일
1
post-thumbnail

들어가기에 앞서 sse를 next로 구현하였지만 구현 파일이 클라이언트 컴포넌트로 구성되어 있으므로, React로 구현함과 크게 다르지 않을 것이다. 리액트 유저들도 충분히 참고할만 하다
.
.
.

발단

웹사이트 내에서 알림 기능을 구현을 해야했다.
커뮤니티성 웹사이트는 회원 간의 소통이 중요한데, 알림 기능을 통해 새로운 댓글이나 포스트에 대한 정보를 신속하게 전달하면 참여를 유도하고, 결국은 커뮤니티 활성화에 기여하지 않을까라는 생각이었다.

"그냥 api 호출로 어떻게 안되겠니...🥹"
라 생각도 했었지만 조금만 생각해보면,

1. api 호출 주기에 따라 알림 확인하는 시간이 한없이 지연될수 있다. 10분마다 호출하면 최대 10분 늦게 알림을 받을 수 있다는 점이다. UX에 매우 부정적인 영향일 것이다
2. 새로고침을 하거나, 새로운 페이지로 갈 때마다 api를 호출해야하는데 생각만 해도 비효율적이다. 특히 유저 수가 늘어날 수록 서버 부하가 증가할 것이다
3. 실시간이라는 취지에 어긋난다

실시간 기능 구현에 무지했던 나는 당연히 어영부영 말로만 듣던 웹소켓을 쓸거라 생각했었지만 SSE를 쓰게 되었다.

🔫 잠깐 비교! WebSocket vs SSE

특징웹소켓 (WebSockets)서버 전송 이벤트 (SSE)
통신 방식양방향 통신단방향 통신
데이터 흐름클라이언트 <-> 서버서버 -> 클라이언트
사용 사례채팅, 게임 등뉴스 피드, 알림 등
자동 재연결미지원지원

채팅과 같이 클라이언트 쪽에서도 서버를 통해 데이터를 전달해야하는 상황이 아니라,
오는 정보를 실시간으로 받기만 하면 되기 때문에 SSE가 더 적합하겠다는 판단이 섰다

일반적인 http req-res의 동작방식은(우리가 흔히하는 api통신) 클라 -> 서버로 데이터를 요청하면, 서버가 응답을 보내주고 클라가 받으면 연결이 종료된다
만약 업데이트를 받고 싶다면, 그럴 때마다 서버한테 다시 요청을 또 보내고 받으면 또 끊는다

반면 sse는 클라이언트가 서버에게 연결 요청을 보내면, 그 연결을 계속 유지한 상태로 서버가 데이터를 스트리밍 방식으로 전송을 해주는 것이다. 연속적이기 때문에 http 요청과 응답을 반복하는 오버헤드가 감소한다. 또, SSE는 네트워크 연결이 끊어질 경우, 기본적으로 자동 재연결 기능을 제공한다. 클라이언트가 연결을 잃을 경우, SSE는 자동으로 재연결을 시도하므로 재연결 로직을 별도로 작성할 필요가 없다.

동작 방식에 큰 도움을 주는 자료사진이다

🔫 구현과정

브라우저 기본 api인 EventSource의 메소드다.

open은 기본적으로 제공해주는 이벤트이다. 연결되면 리스너 등록이 된다

원래는 기본 api를 쓰려했으나 EventSourcePolyfill라는 라이브러리를 추가적으로 사용하였다. 브라우저 기본 api인 EventSource와 거의 동일하지만, 결정적으로 커스텀 헤더를 지원한다. 사용자 맞춤 알림을 띄울려면 인증토큰을 보내야하므로 저 라이브러리 사용은 불가피할거같다
브라우저 기본에서는 왜 지원을 안하는가!!

    const eventSource = new EventSource(`${apiUrl}`, {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });



서버에서는 이렇게 커스텀을 해서 보내준다. 간단해 보이지만 계속 소통하면서 힘겨웠던 부분이기도 하다...ㅎ

emitter.send(SseEmitter.event()
                            .id(eventId.toString())  // Set event ID
                            .name(eventType)  // Set custom event name
                            .data(String.format("{"message": "%s", "data": %s, "eventId": %d}", message, data, eventId)));

id에는 event(알림)를 unique하게 분리할 수 있게 지정한 id가 들어간다. 추후에 이 id를 request body로 보내서 알림 읽음 처리도 구현하였다. 알림list map 돌릴 때에도 index로 사용할 수 있다.

name에는 event type 즉, 커스텀 이벤트명을 보낸다. 우리는 Post와 Comment 이렇게 두 커스텀 이벤트를 만들었다.
알림이 오는 경우는 크게 2가지인데
1) 팔로우한 사람이 새로운 포스트를 올렸을 때 => Post 이벤트 발생!
2) 나의 글에 새로운 댓글이 달렸을 때 => Comment 이벤트 발생!

요런 식으로 짜임을 구성할 것이다.

data는 Json형식으로 올줄 알았는데 그렇게 구현이 안되어서 찾아봤는데

라고 한다... 즉 string으로 받아, 프론트에서 파싱해서 사용해야한다.

이제 다시 프론트쪽 코드를 보자

    eventSource.addEventListener("Post", async (e) => {
      await revalidateNotifications();
      const messageEvent = e as MessageEvent;
      const parsedData = JSON.parse(messageEvent.data);
      setNewNoti({
        ...parsedData.data,
        type: "Post",
      });
    });

받은 string형식의 data를 Json으로 파싱하여 상태로 관리한다.
이 newNoti는 밑에 5초가량 지속되는 토스트를 띄우기 위함이다.

💘 트러블 슈팅1) revalidate

데이터 통신을 잘 이어지고...밑의 토스트도 잘 뜨는데, 알림목록 + 알림개수가 갱신이 안되는 문제가 있었다.

알림목록 + 알림개수에 대한 데이터는 상위 컴포넌트(서버 컴포넌트)에서 패칭을 해오기 때문에 하위에서 발생하는 일들이 동기화가 안 될수도 있겠군!

이라고 생각해서

❎ 첫번째시도

  		<Notification
                notification={notifications}
                unreadCount={unreadCount}
                key={unreadCount}
          />

key값 설정해주기. key값이 바뀌면 즉 unique한 id값이 변경되면 해당 컴포넌트를 리렌더링한다.

하지만 해결되지 않았다.
지금 내가 한 것은 상위 -> 하위로 알리기 이지만, 정작 필요한 것은 하위 -> 상위 알리기 이기 때문이다.

✅ 두번째시도

revalidate data하기
revalidateTag이라는 next의 기능이 있다.
태그에 연결된 데이터를 무효화하는 데 사용되는 기능으로, 데이터의 실시간성을 유지하는 데 도움을 준다고 한다.
데이터가 변경될 때마다 관련된 캐시를 수동으로 업데이트하여 최신 정보를 사용할 수 있도록 하는 데 유용하다

"너무 지금을 위한 기능이다"

"use server";
import { revalidateTag } from "next/cache";

export async function revalidateNotifications() {
  revalidateTag("notification");
}

알림기능과 관련되어 있는 api요청에 notification 태그를 달고, 이벤트 리스너 등록할때마다 새로운 데이터를 가져올 수 있게 revalidate 하였다.

결과는 성공!

💘 트러블 슈팅2) 이벤트 타입 오류


별안간 ts에러 등장!!
잘 읽어보니 문제는 당연했다. Post와 Comment는 내가 직접 만든 커스텀 이벤트이므로, 이벤트 타입이 맞지 않다고 오류를 발생시키고 있었다


이렇게 개발자가 컴퓨터보다 해당 타입에 대한 확신이 있을때는?

타입단언을 쓰면 된다!

const messageEvent = e as MessageEvent;

요렇게 말이다

전체 코드

import { EventSourcePolyfill, NativeEventSource } from "event-source-polyfill";

function Notification() {
  const EventSource = EventSourcePolyfill || NativeEventSource;
  const eventSourceRef = useRef<EventSource | null>(null); 

  useEffect(() => {
    // 새로운 SSE 연결 생성
    const eventSource = new EventSource(`${apiUrl}`, {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });

    eventSourceRef.current = eventSource; 
    eventSource.addEventListener("open", (e) => {
      console.log(e, "The connection has been established.");
    });

    eventSource.addEventListener("Post", async (e) => {
      await revalidateNotifications();
      const messageEvent = e as MessageEvent;
      const parsedData = JSON.parse(messageEvent.data);
      setNewNoti({
        ...parsedData.data,
        type: "Post",
        eventId: parsedData.eventId,
      });
    });

    eventSource.addEventListener("Comment", async (e) => {
   //Post와 유사한 코드
    });

    return () => {
      console.log("Closing SSE connection");
      eventSource.close(); // 컴포넌트 언마운트 시 SSE 연결 종료
    };
  }, [token]);

결론

새로운 기술을 도입하고, 또 서버와 이렇게 받을 값에 대해 논의하고 맞춰가는 과정이 너무 재밌었다.
ref는 써도써도 잘 모르겠는 기분이었는데, 이번에 뭔가 한걸음 가까워진 것 같다
다음에는 useRef를 쓴 나의 코드들을 다 모아놓고 더 깊은 이해를 해볼까 한다

📚 참고

https://medium.com/deliveryherotechhub/what-is-server-sent-events-sse-and-how-to-implement-it-904938bffd73
https://developer.mozilla.org/en-US/docs/Web/API/EventSource
https://tiaz.dev/flask/1

profile
기록 남기기

1개의 댓글

comment-user-thumbnail
2024년 11월 9일

좋은 내용이네요 잘보고 갑니다!

답글 달기