실시간 통신 기술 비교하기 / SSE 알림 기능 구현하기

jade·2025년 8월 15일
0
post-thumbnail

🌱 intro

팝업 예약 서비스 프로젝트 스팟잇(Spot it)을 개발하면서, 상황에 맞춰 사용자에게 알림을 보내는 기능을 구현해야 하는 과제가 주어졌습니다.
알림 기능이 MVP의 주요 기능에 포함되고 빠른 구현이 필요했기에, 팀 내부에서는 비교적 단순한 방식인 폴링을 사용하기로 결정했죠.

저 역시 폴링 방식이 구현 속도가 빠르다는 점에는 동의했지만, 리소스가 불필요하게 소모된다는 단점을 알고도 차선책을 택해야 한다는 점이 아쉬웠습니다.
그래서 다양한 실시간 통신 기술을 조사했고, 그 중 SSE(Server-Sent Events)를 활용한 구현을 제안하여 최종 MVP에 포함시키기까지의 과정을 정리해 보려 합니다.

✏️ 실시간 통신 기술 비교하기

Polling / Long Polling

폴링(Polling)

  • 클라이언트가 일정 주기마다 HTTP 요청을 보내 서버의 상태를 확인하는 방식입니다.
  • 데이터가 없어도 서버는 즉시 응답(예: '없음')을 보냅니다.

롱폴링(Long Polling)

  • 서버에서 연결을 일정 시간 유지하는 방식입니다.
    1. 클라이언트가 서버로 HTTP 요청을 보냅니다.
    2. 서버는 전송할 데이터가 생길 때까지 요청을 보관합니다.
    3. 새 데이터가 생기면 서버가 응답을 보내고 연결을 종료합니다.
    4. 클라이언트는 응답을 받으면 즉시 새로운 요청을 보내 위 과정을 반복합니다.

장점

  • 호환성 : 이전 브라우저와 HTTP/1.1에서도 동작하며, 특별한 프로토콜 지원이 필요 없습니다.
  • 간단한 구현 : 표준 HTTP 요청을 활용하므로 WebSocket 서버나 별도의 메시징 서버 없이도 기존 웹서버에서 처리할 수 있습니다.
    초기 단계에서 실시간 기능을 빠르게 구현할 때 비용과 복잡성을 줄일 수 있습니다.

단점

  • 서버 리소스 부담 : 롱폴링은 요청을 오래 유지하므로 서버 메모리와 연결 자원을 점유합니다. 동시에 많은 사용자가 연결하면 커넥션 한도에 빨리 도달할 수 있습니다.
  • 네트워크 오버헤드 : 요청마다 HTTP 헤더·쿠키를 전송해야 하므로, 데이터가 작아도 불필요한 전송 비용이 발생합니다.
  • 타임아웃 및 재연결 문제 : 일부 브라우저, 프록시, 로드밸런서에서 긴 HTTP 연결을 끊을 수 있습니다. 이 경우 재요청이 필요하며 순간적인 응답 지연이 생길 수 있습니다.
  • 폴링(Short Polling) 한정 : 데이터가 없어도 주기적으로 요청하므로 네트워크 트래픽이 커지고 실시간성이 떨어집니다.

오버헤드 : 처리 시간, 메모리, 네트워크 대역폭 등이 추가로 소모되는 현상
HTTP 오버헤드 : 요청·응답마다 헤더, 쿠키 등의 메타데이터 전송과 파싱에 드는 비용

어떤 상황에 사용할까?

  • WebSocket, SSE가 지원되지 않는 레거시 환경
  • 실시간성이 절대적으로 중요하지 않고, 데이터 변경 빈도가 낮은 서비스

WebSocket

  • 웹소켓은 HTML5 표준 기술로, 사용자의 브라우저와 서버 사이의 동적인 양방향 연결 채널을 구성합니다.
    - 기존 HTTP 요청-응답 방식은 요청한 클라이언트에게만 응답이 가능했지만, ws프로토콜을 통해 웹소켓포트에 접속한 모든 클라이언트에게 이벤트 방식으로 응답합니다.
  • 웹소켓 프로토콜은 접속확립에 HTTP를 사용하지만 이후의 통신은 WebSocket 독자의 프로토콜로 이루어집니다.
  • 헤더가 작아 오버헤드가 적습니다.
  • 장시간 접속으로 전제로 하므로, 접속한 상태라면 클라이언트, 서버로부터 데이터 송신이 가능합니다.
  • 데이터 송신, 수신마다 각각 커넥션을 맺지 않고 하나의 커넥션으로 데이터를 송수신할 수 있습니다.
  • 통신시에 지정되는 URL은 http://www.sample.com/ 과 같은 형식이 아니라 ws://www.sample.com/ 과 같은 형식이 됩니다.

장점

  • 최소한의 오버헤드와 지연시간으로 거의 즉각적인 통신이 가능합니다.
  • 많은 수의 동시연결을 효율적으로 처리하므로 트래픽이 많은 애플리케이션에 맞게 확장이 가능합니다.

단점

  • 웹소켓 프로토콜을 처리하기위해 전이중 연결과 새로운 웹소켓서버가 필요합니다.

어떤 상황에 필요할까?

  • 실시간으로 양방향 데이터 통신이 필요한 경우
  • 많은 수의 동시접속자를 수용해야하는 경우
  • 브라우저에서 TCP 기반의 통신으로 확장해야 하는 경우.

SSE(Server Sent Event)

  • SSE는 서버의 데이터를 실시간으로, 지속적으로 스트리밍하는 기술입니다.

  • 서버가 하나의 긴 HTTP 연결을 통해 클라이언트에 데이터를 푸시합니다 새로운 정보가 있을 때 서버는 클라이언트에 데이터를 전송하기 때문에 클라이언트가 계속해서 요청을 보낼 필요가 없습니다.

  • HTML5 표준안이며, 어느정도 웹소켓의 역할을 할 수 있으면서 더 가볍습니다.

  • 양방향이 아니기 때문에 요청시 ajax로 쉽게 이요할 수 있습니다.

  • 재접속처리와 같은 저수준의 처리가 자동으로 지원됩니다.
    - 연결이 끊어지면 EventSource가 오류를 발생시키고 자동으로 다시 연결을 시도합니다.

  • IE를 제외한 브라우저 대부분을 지원합니다.(Polyfill로 IE사용 가능)

SSE는 서버와 클라이언트 관계에서 Pub/Sub 패턴을 구현한 예시로 이해하면 쉽다!
Publisher (발행자): Spring Boot 서버. 새로운 데이터(이벤트)가 생기면 이를 발행
Subscriber (구독자): 클라이언트(브라우저). 서버의 특정 주소(토픽)를 구독하고 있다가, 발행된 데이터를 받아서 처리.
Topic/Channel (토픽/채널): 클라이언트가 구독을 요청하는 SSE 엔드포인트 URL (/api/events)

어떤 상황에 필요할까?

  • 효율적인 단방향 통신이 필요한 경우
  • 실시간 데이터 스트리밍에 HTTP를 사용하려는 경우

✅ 왜 SSE 방식을 선택하였는가?

구현하려는 알림 기능은 다음과 같은 특성을 가집니다.

  1. 서버 → 클라이언트 방향으로 데이터 전송이 필요
  2. 클라이언트에서 서버로의 데이터 전송은 불필요
  3. 실시간성이 중요

실시간 전송은 Socket을 통해서도 구현할 수 있습니다. 하지만 서비스 특성상 팝업 현장에서 모바일 브라우저로 접속하는 사용자가 대부분일 것으로 예상되었습니다.

이 경우 Socket은

  • 배터리 소모가 크고
  • 기본적으로 자동 재접속 기능을 지원하지 않는다는 단점이 있습니다.

반면, SSE(Server-Sent Events)

  • 단방향 전송에 특화되어 있어 리소스 소모가 적고
  • 연결이 끊겨도 기본적으로 3초마다 자동 재접속을 지원합니다.

따라서 서비스 환경과 요구사항을 고려했을 때, SSE가 보다 적합한 방식이라고 판단하여 선택하였습니다.


🚀 클라이언트측 SSE 구현

  • 브라우저는 EventSource를 통해 서버와 단방향 스트림을 열고, 서버는 이벤트가 생길 때마다 text/event-stream 포맷으로 푸시합니다.

연결 및 데이터 수신

1️⃣ 알림 컴포넌트가 마운트 되었을 때, 단한번 서버로 연결을 요청합니다.

 
const eventSourceRef = useRef<EventSource | null>(null);

useEffect(() => {
    if (!isLoggedIn) return;
    if (eventSourceRef.current) return;

    const eventSource = new EventSource(
      `${process.env.NEXT_PUBLIC_API_URL}/notifications/stream`,
      { withCredentials: true }
    );
    eventSourceRef.current = eventSource;
   
   ...
   
},[])

2️⃣ 연결 직후 서버는 더미이벤트를 한번 보내 브라우저 onOpen 이벤트가 트리거 되도록 하여 연결 초기의 안정성을 확보합니다.

  • 브라우저에서는 초기 연결직후 서버가 아무 바이트도 보내지 않으면, 네트워크 탭에서 pending으로 보이지만 클라이언트에서는 연결이 활성화되었는지 판단이 어려웠습니다.

  • 즉, TCP 연결은 성립했지만 SSE 스트림이 ‘활성’ 상태로 인식되지 않았기 때문입니다.

왜 이런가?

  • SSE는 라인 기반 스트리밍이며, 브라우저 구현체는 최소 한 라인의 SSE 프레임(주석 포함)을 수신해야 ‘연결 활성’ 상태로 전이하는 경우가 있습니다.
  • 일부 중간 프록시/로드밸런서는 바디가 전혀 흘러가지 않는 idle 연결을 조기 종료하거나 버퍼링할 수 있습니다.
  • 초기 한 바이트라도 보내야 버퍼 플러시가 일어나고, 브라우저가 파서를 기동하여 onopen/readyState를 정상화합니다.

3️⃣ 서버에서는 2가지 종류의 알림을 송신합니다.

  • 💗 하트비트 이벤트 : event.type === 'ping'
    - 로드 밸런서 등의 타임아웃을 방지하기 위해 실제 보낼 데이터가 없더라도 주기적으로 의미 없는 데이터를 보내서 연결이 살아있음을 알립니다.

  • 📨 실제 알림 이벤트 : event.type === 'notification'
    - 실제 UI에 업데이트 될 알림 메세지 입니다.

// 연결유지용
eventSource.addEventListener('ping', event =>
      console.log('💗 PING', event.data)
    );

// 알림 수신용
eventSource.addEventListener('notification', event => {
      try {
        const notification = JSON.parse(event.data);
        addNotification(notification);
      } catch (error) {
        console.warn('알림 메세지 데이터 파싱실패', error);
      }
    });

연결 종료하기

브라우저에서 SSE 연결은 EventSource를 통해 이루어지며, 이 연결은 현재 열린 페이지의 생명주기에 종속됩니다. 따라서 사용자가 다른 페이지로 이동하거나, 새로고침하거나, 탭을 닫으면 브라우저는 해당 페이지와 관련된 리소스(JS 객체, 네트워크 연결 등)를 정리하며, 이 과정에서 SSE 연결도 함께 종료됩니다.

MPA에서는 페이지 이동 시 브라우저가 전체를 새로 로드하므로, 기존 SSE 연결이 자동으로 끊어집니다.반면 SPA에서는 클라이언트 라우팅으로 페이지 전환이 이루어져 JS 런타임이 유지되기 때문에, 한 번 생성한 SSE 연결이 계속 살아있게 됩니다.

스팟잇 서비스는 React 기반의 SPA 환경에서 구현되어 있습니다.SSE 연결은 알림을 담당하는 컴포넌트의 생명주기와 맞물려 동작하도록 구성해야 합니다.
즉, 해당 컴포넌트가 페이지 이동으로 인해 화면에서 unmount되는 시점에 SSE 연결을 명시적으로 끊어주는 것이 중요합니다.

만약 연결을 적절히 종료하지 않으면, 백그라운드에서 불필요한 연결이 유지되거나 메모리 누수가 발생할 수 있습니다.

useEffect(() => {
  
  ... 
  
    eventSource.onerror = error => {
      if (eventSource.readyState === EventSource.CLOSED) {
        console.log('SSE 연결 정상 종료(페이지 이탈/새로고침)');
      } else {
        console.error('SSE 오류', error);
      }
    };

    // 새로고침시 정상 종료
    const handleUnload = () => eventSource.close();
    window.addEventListener('beforeunload', handleUnload);

    // 페이지 언마운트 시 연결 해제
    return () => {
      window.removeEventListener('beforeunload', handleUnload);
      eventSource.close();
      eventSourceRef.current = null;
      console.log('연결 해제');
    };
  }, []);
}

구현 화면

참고자료

profile
keep on pushing

0개의 댓글