React를 기반으로한 프로젝트에서 실시간 알림 기능을 구현하면서, SockJS와 Stomp를 사용했다. 이들을 이용해 알림 기능을 구현하는 과정을 살펴보자.
구현 과정을 나열하기에 앞서, 실시간 알림 기능을 구현할 수 있는 방법에는 어떤 방식이 있는지 부터 간단히 알아보자.
알림 기능을 구현해봤거나 검색을 통해 알아본 경험이 있는 사람들이라면, 아래의 방식을들 한 번쯤은 접해봤을 것이다.
1. 웹소켓 (WebSocket)
웹소켓은 양방향 통신 채널을 제공하는 프로토콜로, 클라이언트와 서버 간 실시간 데이터 교환을 가능하게 해줌으로써, 클라이언트와 서버를 서로 연결해 별도의 HTTP 요청 없이 데이터를 수신할 수 있다.
실시간, 양방향 통신을 지원하면서 지연 시간이 다소 짧다는 장점을 가지고 있다.
클라이언트 <-----> 서버 (양방향 통신)
2. SSE (Server-Sent Events)
SSE는 웹소켓과는 달리 서버에서 클라이언트로 단방향 데이터를 실시간으로 전송하는 기술이다. 즉, 클라이언트는 서버로 데이터를 요청할 수 없으며 서버에서 전달하는 데이터의 수신만 가능하다.
웹소켓을 이용한 방식보다 비교적 구현이 간단하지만, 양방향 통신은 지원되지 않는다.
클라이언트 <----- 서버 (단방향 통신)
3. 폴링 (Polling)
클라이언트가 일정 주기마다 서버에 요청을 보내 새로운 데이터가 있는지 확인하고, 이를 받아오는 방식이다.
대부분의 웹 환경에서 작동하며 구현이 비교적 쉽다는 장점이 있지만, 일정 시간마다 계속해서 서버에 요청을 보내기 때문에 이 부분에서 불필요한 요청으로 인한 리소스 낭비가 발생할 수 있기 때문에 데이터의 실시간성이 중요하지 않거나 데이터의 갱신이 특정 주기를 갖는다면 고려해볼 수 있다.
클라이언트 -----> 서버 (주기적인 요청)
클라이언트 <----- 서버
4. 롱 폴링 (Long-Polling)
3항에서 언급한 폴링과 비슷하지만, 조금 더 긴 유지 시간을 갖는다는 점에서 차이가 있다. 이는 클라이언트가 서버로 요청을 보내면 서버에서 새로운 데이터가 발생할 때 까지 응답을 지연시켜 대기하는 방식이다.
폴링 방식보다 실시간성이 높으며, 웹소켓을 사용할 수 없는 환경에서 사용하기 유용하다. 데이터의 실시간 전달이 중요하면서 잦은 변경이 일어나지 않을 때 롱 폴링 방식을 고려해볼 수 있다.
클라이언트 -----> 서버
// 대기 //
클라이언트 <----- 서버
5. 라이브러리 사용
Socket.io와 같은 라이브러리를 사용하여 간단하게 알림 기능을 구현할 수 있다. 대부분의 라이브러리는 웹소켓과 폴백의 메커니즘의 결합과 높은 브라우저 호환성을 제공하기에 처음 기능을 구현하는 사람이라면 사용하기 적합하다.
위에서 언급한 5가지의 방식 중 필자는 SockJS
와 Stomp
라는 라이브러리 사용 방식을 채택했다.
이유는,
SockJS는 웹소켓 프로토콜의 대안으로 실시간 통신을 구현하기 위한 JavaScript 라이브러리이다. 웹소켓은 클라이언트와 서버 간의 양방향/실시간 통신을 지원하지만, 모든 브라우저에서 지원되지 않는다는 단점을 가진다.
SockJS는 웹소켓 API를 모방하여 웹소켓이 지원되지 않는 브라우저에서도 양방향 통신을 가능하게 하기 위해 개발되었다.
다양한 통신 프로토콜을 제공하여 웹소켓이 지원되지 않는 브라우저의 경우 다른 메커니즘 (예를 들어, long polling) 을 이용해 클라이언트와 서버간의 연결을 유지하도록 한다.
사용 방법이 궁금하다면 sockjs github를 참고해보자.
STOMP (Streaming Text Oriented Messaging Protocl) 는 간단한 텍스트 기반의 메시지 프로토콜로, 메시지 브로커와 메시즈를 송수신하는 클라이언트 간의 상호작용에 있어 메시지 전송, 구독, 핸들링 처리 등을 지원한다.
일반적으로 웹소켓과 같은 양방향 프로토콜을 기반으로 동작하며, 텍스트 기반이기 때문에 HTTP와 유사한 명령어와 헤더를 사용하여 웹 애플리케이션에서 비교적 쉽게 구현하고 사용할 수 있고, 상당한 유연성을 제공하여 다양한 언어와 프레임워크에서 사용이 가능하다는 장점을 가진다.
stmop docs, stmopjs github를 통해 간단한 사용 방식과 라이브러리에 대한 정보를 확인할 수 있다.
구현 방식에 대해 알아보았으니 이제 직접 구현해보자.
우선 필요한 패키지를 설치한다.
yarn add sockjs-client @stomp/stompjs
필자는 프로젝트에서 yarn을 사용하기에 yarn add 명령어를 통해 패키지를 설치했다.
패키지를 설치했다면 다음과 같이 코드를 작성해볼 수 있다. 필자는 관련 메서드를 쉽게 관리하고, instance를 사용하기 위해 클래스 문법으로 작성했다.
// websocket.ts
// import
import SockJS from 'sockjs-client';
import { Client, StompSubscription } from '@stomp/stompjs';
import { useNotificationStore } from '@/store/notificateStore';
import { URL } from '@/constants/url';
import { NotificationData } from '@/types/notification';
export class WebSocketService {
private client: Client;
private connected: boolean = false; // 연결 여부
constructor() {
this.client = new Client({
// 프로젝트의 end-point에 따라 url 설정
webSocketFactory: () => new SockJS(URL.API_BASE_URL + 'ws-endpoint'),
// 연결 성공시
onConnect: () => {
this.connected = true;
this.subscribeToNotifications();
console.log('STOMP 연결 성공');
},
// 연결 실패시
onDisconnect: () => {
this.connected = false;
},
});
// 연결 에러 핸들링
this.client.onStompError = (err) => {
console.error('STOMP 에러 : ', err);
};
}
private subscribeToNotifications(): void {
this.client.subscribe('서버 구독 URL', (message) => {
// 알림, 채팅 데이터가 있을 경우 JSON 파싱
if (message.body) {
const notification: NotificationData = JSON.parse(message.body);
// zustand store에 새로운 데이터 추가
useAddNotification()(notification);
// zustand store의 알림 여부 true
useSetHasPendingNotifications()(true);
}
return;
});
}
public setToken(token: string): void {
this.client.configure({
connectHeaders: {
Authorization: token,
},
});
}
public connect(): void {
if (!this.connected) {
this.client.activate();
}
}
public disconnect(): void {
if (this.connected) {
this.client.deactivate();
}
}
}
new SockJS()
를 통해 서버 엔드포인트와 연결하고, Stomp의 subscribe
로 제공된 url 주소를 구독한다. Stomp는 pub/sub 방식을 제공하는데, 이는 pub(publisher)가 메시지를 전송하면 해당 pub를 구독하는 모든 sub(subscliber)에게 메시지를 전달하는 구조이다.
아래 3개의 메소드를 살펴보면, setToken
메소드는 Stomp의 configure
를 통해 connectHeaders에 Authorization 헤더로 토큰을 추가하여 웹소켓 연결 시 사용자 인증 정보를 서버에 전달한다.
connect
메소드는 this.connected가 false일 경우 즉, 아직 연결이 안되었을 때, activate()
함수를 호출해 서버와 연결을 시도하며, disconnect
는 서버와 연결을 해제하는 기능을 수행하게 된다.
위에서 작성된 코드를 보면 필자는 zustand
를 이용하여 새로운 알림 데이터를 추가하거나, 알림 여부의 상태를 변경하는 등의 작업을 수행하여 이를 통해 알림 관련 UI를 렌더링 했다.
// notificateStore.ts
import { create } from 'zustand';
import { NotificationData } from '@/types/notification';
interface NotificationState {
notifications: NotificationData[];
hasPendingNotifications: boolean;
setHasPendingNotifications: (hasPending: boolean) => void;
addNotification: (notification: NotificationData) => void;
deleteNotification: (notifiactionId: number) => void;
deleteAllNotifications: (notificationIds: number[]) => void;
}
export const useNotificationStore = create<NotificationState>((set) => ({
notifications: [], // 데이터 기본값은 빈 배열
hasPendingNotifications: false, // 알림 여부 기본값 false
// 알림 여부
setHasPendingNotifications: (hasPending: boolean) =>
set(() => ({
hasPendingNotifications: hasPending,
})),
// 기존 데이터 배열에 새 데이터 추가
addNotification: (notification) =>
set((state) => ({
notifications: [...state.notifications, notification],
})),
// 알림 단일 삭제
deleteNotification: (notifiactionId: number) =>
set((state) => ({
notifications: state.notifications.filter(
(notification) => notification.notificationId !== notifiactionId,
),
})),
// 알림 전체 삭제
deleteAllNotifications: (notificationIds: number[]) =>
set((state) => ({
notifications: state.notifications.filter(
(notification) => !notificationIds.includes(notification.notificationId),
),
})),
}));
// actions export ...
알림 데이터의 초기값은 빈 배열로, 알림 데이터의 유무는 false로 설정하였고, actions 함수를 정의하여 데이터의 상태를 다룰 수 있도록 했다.
다시 웹소캣 코드로 돌아가보면
private subscribeToNotifications(): void {
this.client.subscribe('서버 구독 URL', (message) => {
// 알림, 채팅 데이터가 있을 경우
if (message.body) {
const notification: NotificationData = JSON.parse(message.body);
// zustand store에 새로운 데이터 추가
useAddNotification()(notification);
// zustand store의 알림 여부 true
useSetHasPendingNotifications()(true);
}
return;
});
}
연결 성공시 실행되는 subscribeToNotifications 함수에서 데이터가 있을 경우, 작성한 store의 actions 함수를 호출하여 새로운 알림 데이터를 추가해줬다.
이를 이용해 UI 컴포넌트와 알림 페이지 컴포넌트를 작성했고, 결과물은 아래와 같이 잘 나왔다.
새로운 댓글을 작성했을 때 알림 데이터 유무가 true로 변경됨에 따라 메인 페이지의 헤더에 빨간색 점이 생기고, 알림 페이지에 새로운 알림이 정상적으로 등록되는 모습을 볼 수 있다.
이렇게 해서 알림 기능을 순조롭게 구현한줄 알았지만, 늘 그렇듯 개발 세계는 나를 가만히 두지 않았다. 한 가지 문제가 발생했는데, 바로 새로고침시 웹소켓 연결이 끊어져 버린다는 것이다.
이에 대해 찾아보니 나와 같은 문제를 겪은 사람들이 꽤나 많았고, 이는 당연한 문제라고 한다.
웹소켓 연결은 HTTP 연결에 대한 양방향 통신 채널을 생성하는데, 이는 브라우저의 세션과 연결되게 된다.
우리가 웹 페이지를 새로고침 하게되면 현재 로드된 페이지를 종료하고, 관련된 모든 리소스 (스크립트, 세션 등) 을 초기화 하는데 이 때 세션이 닫히면서 웹소켓 연결도 자연스럽게 끊기게 되는 것이다.
그럼 이를 해결하려면 어떻게 해야할까?
생각해본 방법으로는
위 두가지가 있었는데, 유저의 새로고침을 방지하는 것 보다는 재연결을 해주는것이 사용자 경험 측면에서 더 바람직 할 것 같다고 생각해 2항의 방법을 채택하기로 했다.
기존에는 웹소켓 연결을 위에서 작성한 class 메소드를 사용하여 사용자가 서비스에 로그인시 connect, 로그아웃시 disconnect를 각각 호출하는 방식을 사용했다.
이를 연결이 끊겼을 경우 자동으로 재연결 하게 하려면 어떻게 해야할까?
브라우저의 새로고침은 모든 페이지에서 이루어질 수 있는 문제였기에 서비스의 모든 컴포넌트에 대해 이 문제를 해결해야 했기에, App
컴포넌트를 건드려 보기로 했다.
App
컴포넌트가 마운트 될 때 웹소켓을 연결한다면 페이지를 새로고침해도 App
은 새롭게 마운트되고 이 때 자연스럽게 재연결 될 것 이라고 생각했다.
// websocketInstance.ts
import { WebSocketService } from './websocket';
export const webSocketInstance = new WebSocketService();
우선 웹소켓 class를 전역으로 사용하기 위해 instance 파일을 만들어주고
// App.tsx
import React, { useEffect } from 'react';
import { useAuthToken } from './store/authStore';
import { webSocketInstance } from './services/websocketInstance';
// import ...
// 생략
const App = () => {
const token = useAuthToken();
useEffect(() => {
// 비로그인시 return
if (!token) return;
if (token) {
webSocketInstance.setToken(token);
webSocketInstance.connect();
} else {
webSocketInstance.disconnect();
}
// 컴포넌트 언마운트시 연결 해제
return () => {
webSocketInstance.disconnect();
};
}, [token]);
return (
// return ...
);
};
export default App;
기존 로그인, 로그아웃을 통해 연결 및 연결 해제를 관리하던 코드를 삭제하고,
App
컴포넌트 내에 useEffect
를 사용하여 App
이 마운트 될 때 토큰의 유무 (사용자의 로그인 유무) 를 확인, 토큰이 존재할 경우 (로그인 사용자) connect 해주는 방식의 코드를 추가했다.
그 결과
페이지를 새로고침 했을 때 재연결에 성공하는걸 확인할 수 있었다.