SSE(Server Sent Event)로 실시간 알림 받아오기

배준형·2023년 10월 4일
2
post-thumbnail

서문

잡다 서비스에서는 특정 공고에 지원하거나, 역검 응시 결과를 제출해야 하거나, 잡다 매치 서비스를 신청/해제하거나 등 사용자의 행동에 따라 알림을 보내주고 있다. 그런데, 평소처럼 작업하다가 다른 탭으로 갔다가 다시 잡다 웹에 돌아오면 동일한 API를 여러번 호출하고 있음을 확인했다

v2 라고 적힌 API는 알림 조회 API인데, 아무런 행동하지 않아도 계속 호출하고 있었다. 알림 내용을 확인하기 위해 호출하는 게 아니라 새로운 알림이 있는지 없는지 여부만 확인하기 위해 호출하고 있었는데, 비효율적이다. 코드 상 확인해보니 polling 방식으로 API를 30초 마다 호출하고 있었고, 같은 소속 백엔드 개발자가 확인 시 하루동안 약 30만 건의 API 통신이 발생한 것으로 확인됐다. 유저 정보 조회 API(하루 약 8만 건 호출)와 비교해보면 22만 건의 통신이 더 발생하고 있는 것이다. 물론 이벤트가 발생해서 더 호출될 수 있는 것을 감안하더라도 이벤트가 발생하지 않는데 호출한 건 수는 분명 존재한다.

이 경우 Polling 대신 SSE를 활용하면 불필요하게 호출하던 API 통신을 충분히 줄일 수 있다.


Polling, SSE란?

Polling

클라이언트에서 주기적으로 서버에게 Event가 발생했는지를 확인하는 방법이다. 구현하기 쉽고 대부분의 웹 브라우저에서 활용할 수 있지만 일정한 간격으로 서버에 요청을 보내기에 불필요한 트래픽을 발생시킬 수 있다.

주로 Timer를 이용한 Polling 방식으로 구현된다.

// React Code
useEffect(() => {
  const fetchData = async () => {
    try {
      const response = await fetch('/api/data');
      if (response.ok) {
        // ...
      }
    } catch (error) {
      // ...
    }
  };

  // fetchData 함수를 호출하여 데이터 업데이트 (5초 간격으로 실행)
  const pollingInterval = setInterval(fetchData, 5000);

  return () => clearInterval(pollingInterval);
}, []);

SSE(Server Sent Event)

SSE는 브라우저의 간섭없이 서버에서 브라우저 단방향으로 Event를 전송하는 기법이다. 브라우저의 요청 없이도 언제든 서버가 새로운 데이터를 보내는 것이 가능하다. 그래서 사용자가 실시간 정보를 필요로 하는 거의 대부분의 어플리케이션에 사용할 수 있다. HTML5의 표준에 포함되어 있고, HTTP 프로토콜 기반으로 동작하며 Internet Explore를 제외한 거의 모든 브라우저가 SSE를 지원한다.

useEffect(() => {
  const eventSource = new EventSource('/api/sse');

  eventSource.addEventListener('message', (event) => {
    // 서버에서 데이터가 전송될 때 호출되는 이벤트 핸들러
    console.log(event.data);
  });

  eventSource.addEventListener('error', (error) => {
    // SSE 연결 오류 처리
    console.error('SSE Error:', error);
    eventSource.close(); // 연결을 닫기
  });

  // 컴포넌트가 언마운트되면 SSE 연결을 닫기
  return () => {
    eventSource.close();
  };
}, []);

기존 코드 SSE로 수정하기

기존 코드

Mobx에서 API 호출 코드를 작성하고, 이를 setInterval 내부에서 호출하도록 작성 돼있다.

// store.ts
constructor() {
  reaction(
    () => this._isRunning,
    () => this.runTimer(),
  );
}

private runTimer() {
  if (!this._isRunning && this._timerId) {
    clearInterval(this._timerId);
    return;
  }

  this._timerId = setInterval(this.fetchNotifications, this._interval);
}

activate(interval = 30000) {
  this.fetchNotifications();
  this._interval = interval;
  this._isRunning = true;
}

// component.tsx
useEffect(() => {
  if (isLogin) {
    if (!isRunning) activate();
  } else {
    // ...
  }
}, [isLogin]);
  • mobx reaction(구독 기능) 함수를 통해 isRunning 값이 바뀌면 runTimer() 함수를 실행한다.
  • runTimer에서는 isRunning 값이 false라면 clearInterval 해주고, true라면 setInterval을 실행시켜 polling을 구현했다.

호출되는 순간을 정리하자면,

  1. 어플리케이션 진입 시 1회
  2. 알림 보기위해 알림 아이콘 클릭 시 1회
  3. 30초 마다 1회

이렇게 호출한다. 이러니 탭을 켜놓고 다른 작업을 하다 돌아오면 여러번 호출하게 되고, 탭을 여러개 띄워놨다면 배로 늘어나게 되었을 것이다.

이걸 SSE를 이용하면 어플리케이션 진입 시 1회, 변경 사항이 있을 때만 이벤트를 받아서 처리하도록 할 수 있고, 불필요한 API 호출을 전부 줄일 수 있다. SSE 방식대로 API 호출을 바꿔보자.


SSE 방식

subscribeNotification() {
  this.unsubscribeNotification();

  const accessToken = getAccessToken();
  if (!accessToken) return;

  this.eventStream = new EventSource(url, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  });

  this.eventStream.addEventListener(eventType, (event) => {
    const data: SSEEventData = JSON.parse(event.data);
    this.count = data.count;
    this.notifications.unshift(data.notification);
  });
}

unsubscribeNotification() {
  if (this.eventStream) {
    this.eventStream.close();
    this.eventStream = null;
  }
}
  • subscribe 하기 전에 기존 연결이 있다면 끊고 다시 연결.
  • mobx 전역 변수 중 notification이 배열로 존재했고, SSE 이벤트를 받아올 때 변경된 사항을 보내주도록 백엔드 개발자와 협의돼서 새로운 내용만 배열 앞쪽에 추가(기존 로직에 따름 - 최신의 알림이 앞쪽에 오도록)

다만, 한가지 문제가 있었던게 알림 조회 API는 로그인이 돼있는 유저에게만 보여지고, 요청 headers에 access token을 포함시켜 줘야 한다. 그래서 EventSource options에 headers를 추가했더니 에러가 발생했고, 이는 SSE로직 구현 중 EventSource에 headers담기 여기 블로그 글을 보고 해결했다.

import { EventSourcePolyfill } from 'event-source-polyfill';

// ...

subscribeNotification() {
  this.unsubscribeNotification();

  const accessToken = getAccessToken();
  if (!accessToken) return;

  this.eventStream = new EventSourcePolyfill(url, { // EventSource 대신 EventSourcePolyfill 사용
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  });

  this.eventStream.addEventListener(eventType, (event) => {
    const data: SSEEventData = JSON.parse(event.data);
    this._count = data.count;
    this._notifications.unshift(data.notification);
  });
}

이후 네트워크 탭을 확인해보자

EventSource로 연결한 SSE는 EventStream이라는 탭이 보여서 SSE로 구분할 수 있다. 그 외 일반적으로 fetch 한 네트워크를 살펴보면 Method에 따라 다르긴 하지만 EventStream 탭은 없다.


Polling 방식 vs SSE 방식 비교

Polling 방식

  • 스웨거로 Notification 이벤트를 발생시켰을 때 반응이 확인되지 않고 Polling 시 확인됨
  • 30초 간격으로 계속 API를 호출해 새로운 데이터가 있는지 확인

SSE 방식

  • 스웨거로 Notification 이벤트를 발생시켰을 때 실시간 반응
  • 새로운 이벤트가 발생할 때마다 서버에서 이벤트를 받고, 이를 사용하여 코드에 따라 실시간으로 확인이 가능하고 불필요한 API 호출이 없음.

결론

실시간으로 데이터를 받아와야할 때 SSE 방식을 사용할 수 있다. Polling, SSE, Web Socket 등 다양한 방법이 있지만, 여기선 알림에 대해 개선했고 데이터를 받기만 하면 되므로 SSE 방식을 사용했다. 알림 외에도 주식이나 코인 트레이딩 차트, 뉴스 속보, 인앱 알림 등 실시간으로 확인해야 할 데이터가 있다면 SSE 사용이 가능하다.

위의 예시 코드에서 더 개선될 부분은 있고, 코드를 좀 더 깔끔하게 수정이 가능하겠지만 SSE 적용을 우선으로 코드를 작성했다. 회사에 합류하고 나서 가장 먼저 개선이 가능할 것 같은 부분이었는데, 백엔드 개발자와 협의가 되어야 하고, 이러한 개선 외에도 신규 기능이나 직접적인 사용성 개선 등의 프로젝트가 많아서 적용이 늦어졌다.

글을 작성한 시점에는 SSE가 잘 작동하는지 여부만 확인이 가능한데, 적용 후 한 달 정도 후에 기존과 비교했을 때 얼마나 차이가 줄어들었는지 확인해보고 싶다.

수정

SSE 방식으로 수정한 후 API 호출 수는 약 3.87M → 823K 만큼 감소하여 약 78% 개선되었습니다. 완전히 동일한 조건 하에 이루어진 비교는 아니지만, 유의미한 효과는 있었다고 판단됩니다.


변경 전

  • 기간: 2023.09.20 17:00 ~ 2023.09.27 17:00
  • 총 호출 수: 3,870,000 회
  • 초당 호출 수: 6.4 회


변경 후

  • 기간: 2023.10.17 17:00 ~ 2023.10.24 17:00
  • 총 호출 수: 823,000 회
  • 초당 호출 수: 1.4 회


참조

profile
프론트엔드 개발자 배준형입니다.

0개의 댓글