React와 SSE(Server Sent Events)로 실시간 채팅 구현하기 (24.01.05 내용추가)

RuLu·2023년 10월 19일
12

Etc.

목록 보기
10/13
post-thumbnail

팀바팀 서비스를 우리가 직접 사용하며 느낀점은 팀 피드.. 실시간이었으면 어땠을까? 였다. 우리는 프로젝트하면서 팀 피드에서 토론을 했는데 다른 팀원의 의견을 보기위해서는 새로고침이나 창 바꿈이 필요하여 팀 피드를 사용하는 것이 현실적으로 많이 불편했다.
🥸우리도 우리 서비스가 불편한데 사용자는 만족할 수 있을까?🥸
라는 의견에서부터 나온 팀 피드 실시간화!🎊
이 글에서는 SSE를 채택한 이유와 어떻게 구현했는지를 작성할 예정이다.

왜 SSE 였을까?

사실 SSE를 사용하는 것 보다 웹소켓을 사용하는 편이 다양한 문제를 만날 필요 없긴 하다.(당연함;; 웹소켓이 SSE 상위호환이니까) 그럼에도 팀바팀은 SSE를 선택했는데.. 아래에서 다른 방식도 설명하면서 이유를 정리해보겠다.

HTTP 프로토콜 사용

HTTP 프로토콜을 사용하는 실시간 구현 방식은 Server-Sent-Event(SSE)/ Polling / Long Polling으로 3가지이다.

이 방식들의 공통적인 특징은 사용자가 서버에게 HTTP 요청하는 단방향 통신이라는 것이다. 따라서 서버는 언제나 클라이언트의 요청에 대한 응답만을 내려준다.

따라서 실시간 서비스에서는 빈번한 HTTP요청이 일어나기 때문에 서버의 부하가 심해질 가능성이 매우매우매우매우 높다.

SSE (Server-Sent-Event) 방식

SSE는 1요청에 1응답이라는 전통적인 HTTP방식과 약간 다르다. 한번 연결 요청을 보내면 이벤트가 발생할 때마다 서버에서 응답을 자유롭게 보낼 수 있기 때문이다. 그렇지만 여전히 HTTP프로토콜을 이용하기 때문에 훨씬 가볍고 구현 비용이 낮다. (+ 구현하기 정말 쉬운편!)

Polling 방식

1요청 1응답이라는 전통적인 HTTP방식이지만 이제 일정한 주기를 설정하여 서버에게 주기마다 요청을 보내는 방식이다. 주기로 요청하기 때문에 실시간 이벤트가 필요없는 상황에서도 계속 요청을 보내기 때문에 실시간 방식중에 가장 효율이 떨어진다. (애초부터 이 친구는 선택할 생각도 없었다.)

Long Polling 방식

1요청 1응답이라는 전통적인 HTTP방식이며 폴링을 발전시킨 형태이다. 클라이언트에서 서버에 먼저 요청을 보내서 연결을 유지한 후에, 이벤트가 발생하면 응답을 보내고 연결을 종료한다. 응답을 받은 클라이언트가 다시 서버에 요청을 보내는 방식이다.

WebSocket 프로토콜

websocket 프로토콜은 클라이언트에 의해 요청을 받는 방식이 아닌, 서버가 내용을 클라이언트에 보내는 표준화된 방식을 제공하는 양방향 통신이다. 훨씬 효율적이지만 HTTP와는 독립적인 프로토콜을 사용하고 별도의 연결을 만들어야한다. 또한 CORS을 구성할 때 보안상의 이유로 추가 구성이 필요할 수 도 있다.

(아래는 실시간 통신 방식들을 잘 정리한 내용이라 첨부했다.)

그래서 결론!

처음 우리가 생각한 것은 websocket이였다. sse와 폴링.롱폴링의 문제점을 대부분 보완하기도 하였고 실시간 채팅같이 빈번한 데이터 이동에는 더 적합하기 때문이다. 그러나 실 사용자가 서버에 부담을 주기엔 너무나도 작고 소중한데 굳이 웹소켓을 써야만 할까라는 의문이 들었다. 또한 이후 자주 변경이 일어나지 않는 일정이나 팀링크도 후에 확장을 하게 되어 실시간를 붙인다면 SSE이가 더 적합하다고 생각했다.(한번의 연결로 여러 이벤트를 받을 수 있는 특징 때문에). 마지막으로 웹소켓을 사용하면 HTTP와 다른 프로토콜을 사용하게 되어 지금 작동하는 피드 조회API와 피드 작성, 공지작성 API를 사용할 수 없게 된다..

즉, 확장성과 그만한 비용을 지불하기엔 우리서비스는 아직 작다는 것이 SSE를 선택한 이유였다.
(그리고 소켓을 사용할 기회는 많지만 SSE를 사용할 기회는 별로 없기 때문에 이번 기회에 경험하고 싶었다ㅋㅋ)

SSE 구현하기

MDN을 보면 이렇게 말한다

Developing a web application that uses [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) is straightforward.

“매우 간단합니다.” 두둥탁! 얼마나 쉬우면 간단하다고 할까 근데 진짜 간단했음

일단 서버에서 이벤트를 감지할 때 보내주는 형식은 다음과 같다

id: 1_new_thread_2023-10-17T15:11:55.676769492
event: new_thread
data: {"teamPlaceId": 1,"ThreadId": 1637,"status": "WRITE"}

id는 sse가 타임아웃으로 중간에 끊기면 알아서 브라우저가 다시 연결을 시도하는데 이때 어디까지 이벤트를 받았어요~라고 서버한테 알려주는 값이다. 즉 브라우저가 알아서 보내주기 때문에 프론트에서 id값에 접근할 수 없다.

event는 프론트와 백엔드가 합의보고 값을 정할 수 있다. 말 그대로 어떤 이벤트가 발생했는지 알려주는 필드이다. 이걸 사용하면 하나의 connect로 동시에 여러가지의 이벤트를 처리할 수 있다(굿)

data는 말그대로 어떤 이벤트 내용이다.

사용방법

useEffect(() => {
	//SSE연결 로직
	const eventSource = new EventSource('/api/어쩌고저쩌고/sse연결 할 url');
	
	eventSource.addEventListener('new_thread', () => {
		//'new_thread' 이벤트가 오면 할 동작
	});

	eventSource.onerror = () => {
		//에러 발생시 할 동작
		eventSource.close(); //연결 끊기
	};
	
	return () => {
		eventSource.close();
	};
  
}, []);

주의사항: onerror을 사용하면 타임아웃으로 연결 끊겼을 때 또한 에러로 인식한다. 때문에 닫아버리면 브라우저가 자동으로 재연결시도하지 않더라..

헤더 추가하기

우리는 로그인 한 사용자만 팀 피드를 이용할 수 있어야한다. + 해당 팀플레이스에 소속된 사람인지 아닌지 확인도 해줘야한다. 그래서 header에 토큰을 담아서 보내야했다. 하지만 EventSource는 Header을 담을 수 없다.. 때문에 event-source-polyfill이라는 라이브러리를 사용했다. 이외 나머지는 동일하다.

const eventSource = new EventSourcePolyfill(
      '/api/어쩌고저쩌고/sse연결 할 url'',
      {
        headers: {
          Authorization: `Bearer ${localStorage.getItem(
            LOCAL_STORAGE_KEY.ACCESS_TOKEN,
          )}`,
        },
      },
    );

문제 발생!

우리는 JWT방식의 인증을 사용하기 때문에 연결도중 토큰이 만료되었다면 새로운 토큰을 담아서 보내야했다.

https://github.com/Yaffle/EventSource/issues/137

근데 여기에서 알려준 코드대로 해보니 무한 연결 시도와 무한 에러가 발생했다.🥲 그래서 이렇게 재귀 방식으로 호출하는 것이 문제라고 생각했기 때문에 연결을 아예 끊고 새로운 토큰을 헤더에 담아서 새로 연결을 시도 하는 방식을 생각했다.

결론은 이 방식이 맞았지만 내가 window.addEventListener(’storage’)를 이용해서 변경된 토큰을 받아오려는 삽질을 해서 한 6시간동안 못고쳤다ㅋㅋ.ㅋ.. (storage이벤트는 동일한 페이지에서 발생할 경우 감지하지 못한다. 근데 react는 SPA이기 때문에 당근 작동안함)

우리팀의 경우는 이 문제를 accessToken을 전역 상태로 두어서 변화를 감지하는 방식으로 해결했다.

그래서 총 코드는 아래와 같다.

문제 해결🥳

export const useSSE = (teamPlaceId: number) => {
  const queryClient = useQueryClient();
  const { accessToken } = useToken();

  useEffect(() => {
    const connect = () => {
      if (!teamPlaceId) {
        return;
      }
      const eventSource = new EventSourcePolyfill(
        baseUrl + `/api/team-place/${teamPlaceId}/subscribe`,
        {
          headers: {
            Authorization: `Bearer ${accessToken}`,
          },
        },
      );

      eventSource.addEventListener('new_thread', () => {
        queryClient.invalidateQueries(['threadData', teamPlaceId]);
      });

      return () => {
        eventSource.close();
      };
    };

    return connect();
  }, [queryClient, teamPlaceId, accessToken]);
};

24.01.05 추가)
위 코드에서 return connect(); 를 하지않으면 sse 가 닫히지 않고 매번 새로운 커넷션을 만들게 된다. 팀바팀의 팀플레이스 전환처럼 기존 sse가 닫힌 후 새로운 sse 커넥션이 생겨야하는 요구사항이 있다면 잊지않고 close을 해주도록 하자.

후기

진짜 sse.. 하면서 정말 채팅은 아니다 싶었다. 애초부터 실시간을 생각안하고 설계한 문제도 있지만 안되는게 너무많아.. 하지만 확장을 생각해보면? sse가 맞는 것 같기도 하고!

레벨 5때는 원하는 사람들끼리 프로젝트를 이어가기로 했는데 그때 채팅은 웹소켓으로 전환시키고 sse는 다른 기능에 붙이자는 이야기를 간단하게 했다. 아무튼 재미는 있었음!

profile
프론트엔드 개발자 루루

5개의 댓글

comment-user-thumbnail
2023년 10월 23일

팀바팀 데모데이 때 sse 방식을 설명들었는데 블로그로 보니 더 이해가 잘 되었어요 👍👍👍

1개의 답글
comment-user-thumbnail
2023년 10월 25일

안녕하세요~ 데모데이때 구경갔던 자스민입니당. 블로그가 있었군용! 잘보고 갑니다👍

2개의 답글