팝업 예약 서비스 프로젝트 스팟잇(Spot it)
을 개발하면서, 상황에 맞춰 사용자에게 알림을 보내는 기능을 구현해야 하는 과제가 주어졌습니다.
알림 기능이 MVP의 주요 기능에 포함되고 빠른 구현이 필요했기에, 팀 내부에서는 비교적 단순한 방식인 폴링을 사용하기로 결정했죠.
저 역시 폴링 방식이 구현 속도가 빠르다는 점에는 동의했지만, 리소스가 불필요하게 소모된다는 단점을 알고도 차선책을 택해야 한다는 점이 아쉬웠습니다.
그래서 다양한 실시간 통신 기술을 조사했고, 그 중 SSE(Server-Sent Events)를 활용한 구현을 제안하여 최종 MVP에 포함시키기까지의 과정을 정리해 보려 합니다.
폴링(Polling)
'없음'
)을 보냅니다.롱폴링(Long Polling)
오버헤드 : 처리 시간, 메모리, 네트워크 대역폭 등이 추가로 소모되는 현상
HTTP 오버헤드 : 요청·응답마다 헤더, 쿠키 등의 메타데이터 전송과 파싱에 드는 비용
http://www.sample.com/
과 같은 형식이 아니라 ws://www.sample.com/
과 같은 형식이 됩니다.SSE는 서버의 데이터를 실시간으로, 지속적으로 스트리밍하는 기술입니다.
서버가 하나의 긴 HTTP 연결을 통해 클라이언트에 데이터를 푸시합니다 새로운 정보가 있을 때 서버는 클라이언트에 데이터를 전송하기 때문에 클라이언트가 계속해서 요청을 보낼 필요가 없습니다.
HTML5 표준안이며, 어느정도 웹소켓의 역할을 할 수 있으면서 더 가볍습니다.
양방향이 아니기 때문에 요청시 ajax로 쉽게 이요할 수 있습니다.
재접속처리와 같은 저수준의 처리가 자동으로 지원됩니다.
- 연결이 끊어지면 EventSource가 오류를 발생시키고 자동으로 다시 연결을 시도합니다.
IE를 제외한 브라우저 대부분을 지원합니다.(Polyfill로 IE사용 가능)
SSE는 서버와 클라이언트 관계에서 Pub/Sub 패턴을 구현한 예시로 이해하면 쉽다!
Publisher (발행자)
: Spring Boot 서버. 새로운 데이터(이벤트)가 생기면 이를 발행
Subscriber (구독자)
: 클라이언트(브라우저). 서버의 특정 주소(토픽)를 구독하고 있다가, 발행된 데이터를 받아서 처리.
Topic/Channel (토픽/채널)
: 클라이언트가 구독을 요청하는 SSE 엔드포인트 URL (/api/events)
구현하려는 알림 기능은 다음과 같은 특성을 가집니다.
실시간 전송은 Socket을 통해서도 구현할 수 있습니다. 하지만 서비스 특성상 팝업 현장에서 모바일 브라우저로 접속하는 사용자가 대부분일 것으로 예상되었습니다.
이 경우 Socket은
반면, SSE(Server-Sent Events)는
따라서 서비스 환경과 요구사항을 고려했을 때, 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('연결 해제');
};
}, []);
}