실시간 알림 기능을 구현하기 위해 서버와의 실시간 통신을 구축한다.
클라이언트가 주기적으로 서버에 요청을 보내는 방식.
일정 시간 간격으로 데이터를 업데이트하는 경우에 유용하다.
ex) 실시간 야구 문자 중계, 뉴스 헤드라인
클라이언트가 서버에 요청을 보내면, 서버는 클라이언트의 요청에 바로 응답하지 않고 업데이트가 발생할 때까지 기다린다.
업데이트가 발생하거나 설정된 시간이 지나면 서버는 응답을 보내고 클라이언트는 응답을 받아 연결을 종료한 뒤 곧바로 다시 요청을 보내 다음 응답을 기다린다.
ex) 적은 인원의 채팅 앱, 알림
웹소켓 프로토콜을 사용하면 클라이언트와 서버가 지속적인 연결을 통해 서로가 원할 때 데이터를 주고 받을 수 있다.
즉 웹소켓은 데이터의 송수신을 동시에 처리하는 양방향 통신 기법이다.
기존 HTTP 요청 응답 방식은 요청한 그 클라이언트에만 응답이 가능했는데, ws 프로토콜을 통해 웹소켓 포트에 접속해있는 모든 클라이언트에게 이벤트 방식으로 응답할 수 있다.
프레임으로 구성된 메시지라는 논리적 단위로 송수신하며, 메시지에 포함될 수 있는 교환 가능한 메시지는 텍스트와 바이너리이다.
주식, 채팅 등 연속된 데이터를 빠르게 노출해야 하는 실시간 서비스 분야에 활용된다.
ex) 온라인 게임, 실시간 협업 도구에서 여러 사용자의 동시 문서 편집, 화상 채팅 및 회의에서 실시간 데이터 송수신
SSE를 사용하면 서버에서 클라이언트로의 실시간 단방향 통신을 할 수 있다.
한 번의 HTTP 요청을 통해 연결이 이루어지면, 그 이후로 별도의 요청 없이 실시간으로 서버에서 클라이언트로 데이터를 송신할 수 있다.
메시지에 포함될 수 있는 교환 가능한 메시지는 텍스트이다.
ex) 주식 거래, 뉴스 피드, 실시간 알림
기능에 따라 양방향 통신이 필요하지 않은 경우도 많다. 예를 들어, 서버에서 시간이 걸리는 작업의 진행 상태를 보여주는 진행 바(progress bar), 실시간 소식을 전하는 SNS, 뉴스, 주식 거래 서비스, 또는 기타 실시간 모니터링과 같은 경우가 그렇다.
내가 구현하려는 실시간 알림 기능 역시 양방향 통신이 불필요하다. 클라이언트가 지속적으로 데이터를 보낼 필요 없이, 서버에서 업데이트가 발생할 때만 실시간으로 데이터를 보내주면 충분하기 때문이다.
즉, 단방향 통신으로도 충분한 서비스에는 SSE가 적합하고, 양방향 통신이 필수적인 경우에는 웹소켓을 사용하는 것이 더 적절하다.
Accept: text/event-stream
Content-Type: text/event-stream
Connection: keep-alive
import React, { useEffect, useState } from 'react';
function Notifications() {
const [messages, setMessages] = useState([]);
useEffect(() => {
// 서버의 SSE 엔드포인트를 설정.
const eventSource = new EventSource('http://서버주소/sse-endpoint');
// onmessage는 이벤트 이름을 따로 명시하지 않은 모든 데이터를 처리한다.
eventSource.onmessage = function(event) {
const newMessage = JSON.parse(event.data); // 이벤트 데이터는 JSON 형식일 수 있습니다.
setMessages(prevMessages => [...prevMessages, newMessage]);
};
// 연결 성공시 실행
eventSource.onopen = function() {}
// 연결이 끊기거나 에러 발생시 실행
eventSource.onerror = function() {}
// 이벤트 이름을 명시한 메시지를 받을 때 사용
// 아래 예시는 이벤트 이름이 customEvent인 메시지를 처리하는 경우
eventSource.addEventListenr('customEvent', function(e) {})
// 컴포넌트 언마운트 시 EventSource 연결 종료.
return () => {
eventSource.close();
};
}, []);
return (
<div>
<h2>알림</h2>
<ul>
{messages.map((msg, index) => (
<li key={index}>{msg}</li>
))}
</ul>
</div>
);
}
export default Notifications;
[FE] 기능 테스트와 TDD 및 테스트 코드 작성 가이드라인 참고
실시간 알림 테스트를 위해 서버로부터 특정 시간 후에 메시지를 수신하는 상황을 구현하고자 한다.
그러나 실제 API 요청을 통한 테스트는 API 개발이 완료된 후에만 가능하므로, 백엔드 개발과 병렬적으로 진행하기 어렵다는 문제가 있다. 또한 테스트 과정에서 불필요한 요청으로 인한 비용 발생과 API 의존성 문제도 고려해야 한다.
그래서 실제 API 요청을 통한 테스트가 아니라 네트워크 수준에서 mocking한 API를 이용하여 테스트하고자 한다. 이를 위해 MSW 라이브러리를 사용한다.
EventSource API는 클라이언트가 서버로부터 지속적인 데이터를 받는 것을 처리한다. 서버가 클라이언트에 이벤트를 계속해서 푸시하는 방식인데, 이 방식을 테스트 환경에서 mocking해야 한다.
실제 서버는 지속적으로 데이터를 전송해 주지만, mocking 상황에서는 실제 서버가 없기 때문에 서버가 데이터를 스트리밍하는 것을 흉내내야 한다.
MSW 공식문서에서 data streaming을 mocking하는 recipe를 제공하고 있는데, 그 방법으로 ReadableStream을 말한다.
Streams API의 ReadableStream 인터페이스는 바이트 데이터를 읽을 수 있는 Stream을 제공한다.
즉 EventSource API가 클라이언트에서 서버의 데이터를 실시간으로 받는 역할이라면, 서버에서 데이터를 생성하고 전달하는 역할은 ReadableStream API가 한다.
Content-Type: text/event-stream
헤더와 함께 클라이언트에 응답.실제 서버처럼 시간차를 두고 데이터를 순차적으로 전송할 수 있는 흐름을 제어하기 위해 setTimeout을 이용하여 1초, 4초, 7초 마다 메시지가 도착하도록 만들었다.
import { HttpHandler, HttpResponse, http } from 'msw';
import { API_END_POINT } from '@/constants/api';
const realTimeNotificationData = [
'id:1_1722845403085\nevent:notification\ndata:{ "notificationId": 1, "message": "경매에 올린 test가 낙찰되었습니다.", "type": "AUCTION_SUCCESS", "auctionId": 59}\n\n',
'id:1_1722845403090\nevent:notification\ndata:{ "notificationId": 2, "message": "경매에 올린 test가 미낙찰되었습니다.", "type": "AUCTION_FAILURE"}\n\n',
'id:1_1722845403175\nevent:notification\ndata:{ "notificationId": 3, "message": "축하합니다! 입찰에 참여한 경매 test의 낙찰자로 선정되었습니다.", "type": "AUCTION_WINNER", "auctionId": 62}\n\n',
];
const encoder = new TextEncoder();
export const realTimeNotificationsHandler: HttpHandler = http.get(
`${API_END_POINT.REALTIME_NOTIFICATIONS}`,
() => {
const stream = new ReadableStream({
start(controller) {
realTimeNotificationData.forEach((message, idx) => {
setTimeout(() => {
controller.enqueue(encoder.encode(message));
}, [1000, 4000, 7000][idx]);
});
},
});
return new HttpResponse(stream, {
headers: {
'Content-Type': 'text/event-stream',
},
});
},
);
어느 화면에서나 실시간 알림이 발생해야 하기 때문에 모든 페이지가 공유하는 GlobalLayout
컴포넌트를 작성하고 해당 컴포넌트에서 SSE 작업을 수행한다.
render 메서드와 userEvent는 거의 매번 사용하기 때문에 setup 함수를 만들어 사용한다.
const setup = () => {
const utils = render(<GlobalLayout />);
const user = userEvent.setup();
return {
user,
...utils,
};
};
EventSource는 브라우저의 내장 객체로, 브라우저가 아닌 Node.js 테스트 환경에는 존재하지 않는다. 그래서 테스트를 실행해보면 EventSource is not defined
에러가 발생한다.
Node.js에서는 브라우저의 window 객체 대신 global 객체가 사용되는데, 테스트 환경에서 EventSource와 같은 브라우저 전용 객체를 사용하기 위해서는 EventSource를 mocking하여 global 객체에 직접 추가해줘야 한다.
아래 설정을 통해, 테스트 환경에서 EventSource
를 사용하는 코드가 실제 브라우저의 EventSource
대신 우리가 정의한 EventSource
를 사용하게 된다.
또한 addEventListener를 조작하여 내가 원하는 이벤트에서 원하는 데이터가 원하는 시간 후에 메시지를 수신하도록 만들었다.
나는 EventSource
객체를 GlobalLayout
컴포넌트 테스트 파일에서만 사용하기 때문에 해당 테스트 파일에서 정의하여 사용 범위를 제한했다.
const realTimeNotificationData = [
'id:1_1722845403085\nevent:notification\ndata:{ "notificationId": 1, "message": "경매에 올린 test가 낙찰되었습니다.", "type": "AUCTION_SUCCESS", "auctionId": 59}\n\n',
'id:1_1722845403090\nevent:notification\ndata:{ "notificationId": 2, "message": "경매에 올린 test가 미낙찰되었습니다.", "type": "AUCTION_FAILURE"}\n\n',
'id:1_1722845403175\nevent:notification\ndata:{ "notificationId": 3, "message": "축하합니다! 입찰에 참여한 경매 test의 낙찰자로 선정되었습니다.", "type": "AUCTION_WINNER", "auctionId": 62}\n\n',
];
describe('Layout 알림 테스트', () => {
// 테스트 시작 전에 EventSource 모킹
beforeAll(() => {
global.EventSource = vi.fn(() => ({
addEventListener: vi.fn((event, callback) => {
if (event === 'notification') {
realTimeNotificationData.forEach((messageString, idx) => {
// data 값 추출하고 callback이 처리.
const dataMatch = messageString.match(/data:(.*)\n/);
if (dataMatch && dataMatch[1]) {
const message = JSON.parse(dataMatch[1].trim()); // 문자열을 객체로 변환
setTimeout(() => {
callback({ data: JSON.stringify(message) }); // JSON 문자열로 다시 전송
}, [1000, 4000, 7000][idx]);
}
});
}
}),
close: vi.fn(),
})) as unknown as typeof EventSource; // 타입스크립트가 global.EventSource의 타입이 브라우저의 EventSource 타입과 같다고 간주한다.
});
}
test('실시간 알림이 도착하면 전체 화면에 알림 팝업이 발생하고, 그 안에 제목, 메시지, 버튼이 존재한다.', async () => {
render(<GlobalLayout />);
// 1초 후에 발생하는 popup을 찾기 timeout을 1100ms로 설정한다.
const popup = await screen.findByLabelText(
/알림 박스/,
{},
{ timeout: 1100 },
);
const title = screen.getByRole('heading', { name: /제목/ });
const message = screen.getByLabelText(/메시지/);
const button = screen.getByRole('button', {
name: /경매 참여자 목록 보러가기/,
});
expect(popup).toContainElement(title);
expect(popup).toContainElement(message);
expect(popup).toContainElement(button);
});
test('팝업이 발생하고 바깥 배경을 클릭하면 팝업이 사라진다.', async () => {
const { user } = setup();
const popup = await screen.findByLabelText(
/알림 박스/,
{},
{ timeout: 1100 },
);
const popupBackground = screen.getByLabelText('팝업 배경');
await user.click(popupBackground);
expect(popup).not.toBeInTheDocument();
});
mock 데이터에서 두 번째 알림의 경우 버튼의 종류가 확인 버튼이다.
확인 버튼을 테스트하기 위해 두 번째 알림을 기다리려면 최소 4초를 기다리는 과정이 필요하다.
처음에 나는 findByLabelText의 timeout의 기능을 바로 알지 못해 5000ms로 설정을 해놓고 두 번째 알림을 기다렸다. 하지만 내가 원하는 두 번째 알림이 아닌 첫 번째 알림을 찾았다.
그 이유는 findByLabelText의 timeout의 기능은 설정한 시간 내에 가장 먼저 찾은 요소를 선택하기 때문이다.
그래서 4초 후에 오는 두 번째 알림을 받으려면 우선 1초 후에 오는 메시지를 찾고, setTimeout으로 3초 간 기다린다음 작업을 해야 한다.
여기서 일반적인 setTimeout을 이용하면 안된다. setTimeout은 비동기작업이기 때문에 리액트의 상태 변화나 DOM 작업을 기다리지 않기 때문이다.
이를 해결하기 위해 act와 Promise를 결합하여 setTimeout을 사용한다.
RTL의 act 함수는 리액트 내의 상태나 DOM이 업데이트되는 동안 발생하는 사이드 이펙트를 묶어서 처리한다. act는 비동기 함수도 지원하는데, 비동기 함수 내의 비동기 작업이 완료될 때까지 기다린다.
기본 테스트 시간을 넘어가는 경우, test의 timeout 시간을 넉넉히 잡아 테스트를 실행한다.
test(
'팝업 버튼이 확인 버튼인 경우 버튼 클릭시 팝업이 사라진다.',
{ timeout: 8000 },
async () => {
const { user } = setup();
// 첫 번째 알림을 찾고, 알림을 닫는다.
const popup = await screen.findByLabelText(
/알림 박스/,
{},
{ timeout: 1100 },
);
const popupBackground = screen.getByLabelText('팝업 배경');
await user.click(popupBackground);
await act(async () => {
await new Promise((resolve) => {
setTimeout(resolve, 3000);
});
});
// 두 번째 알림을 찾아 동작을 수행.
const button = screen.getByRole('button', {
name: /확인/,
});
await user.click(button);
expect(popup).not.toBeInTheDocument();
},
);
render component에서 useNavigate hook을 사용하는 경우 useNavigate mocking이 필수이다.
나는 다른 테스트에서도 useNavigate hook을 사용했기 때문에 setupTests.ts 파일에서 mocking하고 export하여 모든 테스트 파일에서 사용했다.
// setupTests.ts
export const mockedUseNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
// importActual은 실제 모듈의 원래 구현을 가져오는 기능을 한다.
// 일부 기능은 실제 동작을 그대로 유지하면서 useNavigate만 모킹하려는 경우에 유용하다.
const mod =
await vi.importActual<typeof import('react-router-dom')>(
'react-router-dom',
);
return {
...mod,
// useNavigate 훅이 호출될 때마다 mockedUseNavigate 라는 모의 함수가 호출된다.
useNavigate: () => mockedUseNavigate,
};
});
mocking한 useNavigate를 import하고 mockedUseNavigate가 실행되었음을 확인한다.
import { mockedUseNavigate } from '@/setupTests';
test('확인 버튼이 아닌 알림인 경우, 버튼 클릭시 특정 화면으로 이동한다.', async () => {
const { user } = setup();
await screen.findByLabelText(/알림 박스/, {}, { timeout: 1100 });
const button = screen.getByRole('button', {
name: /경매 참여자 목록 보러가기/,
});
await user.click(button);
expect(mockedUseNavigate).toHaveBeenCalledOnce();
});
test(
'알림 확인 도중 또 다른 실시간 알림 발생시 팝업을 닫자마자 새로운 팝업이 발생한다.',
{ timeout: 8000 },
async () => {
const { user } = setup();
await screen.findByLabelText(/알림 박스/, {}, { timeout: 1100 });
await act(async () => {
await new Promise((resolve) => {
setTimeout(resolve, 3000);
});
});
const button = screen.getByRole('button', {
name: /경매 참여자 목록 보러가기/,
});
await user.click(button);
const box = await screen.findByLabelText(
/알림 박스/,
{},
{ timeout: 1100 },
);
expect(box).toBeInTheDocument();
},
);
useEffect 내에서 SSE 연결을 하고 notification event로 메시지를 수신하여 notifications 배열 상태에 저장한다.
위에서 mocking한 API handler와 동일한 URL로 요청을 하면 MSW가 이를 감지하여 내가 설정한 데이터를 전송해준다.
간단한 작업이기 때문에 useSSE hook을 만들어주었다.
import { useEffect, useState } from 'react';
export const useSSE = <T>(url: string) => {
const [state, setState] = useState<T[]>([]);
useEffect(() => {
const eventSource = new EventSource(url);
eventSource.onopen = () => {
};
eventSource.onerror = (error) => {
};
eventSource.addEventListener('notification', (e) => {
const data = JSON.parse(e.data);
setState((prev) => [...prev, data]);
});
return () => eventSource.close();
}, [url]);
return { state, setState };
};
// GlobalLayout
import { useEffect, useState } from 'react';
import { API_END_POINT } from '@/constants/api';
import { Outlet } from 'react-router-dom';
import Popup from '../common/Popup';
import RealTimeNotification from './RealTimeNotification';
import type { RealTimeNotificationType } from 'Notification';
import { useSSE } from '@/hooks/useSSE';
const GlobalLayout = () => {
const { state: notifications, setState: setNotifications } =
useSSE<RealTimeNotificationType>(`${API_END_POINT.REALTIME_NOTIFICATIONS}`);
const [currentNotification, setCurrentNotification] =
useState<RealTimeNotificationType | null>(null);
const closePopup = () => {
setCurrentNotification(null);
};
useEffect(() => {
const showNextNotification = () => {
setCurrentNotification(notifications[0]);
setNotifications((prev) => prev.slice(1));
};
if (currentNotification === null && notifications.length > 0) {
showNextNotification();
}
}, [currentNotification, notifications, setNotifications]);
return (
<div className="flex justify-center w-full h-screen">
<div className="relative w-[46rem] min-w-[23rem] h-full">
<Outlet />
{currentNotification && (
<Popup onClose={closePopup}>
<RealTimeNotification
onClose={closePopup}
notification={currentNotification}
/>
</Popup>
)}
</div>
</div>
);
};
export default GlobalLayout;
테스트 코드를 보면 getByRole, getByLabelText 등의 접근성 handler로 요소를 찾는 것을 알 수 있다.
RTL(React Testing Library)은 어플리케이션의 접근성을 높이고 사용자가 원하는 방식으로 구성요소를 사용하는 방식에 가까운 테스트를 수행할 수 있도록 하여 테스트를 통해 실제 사용자가 어플리케이션을 사용할 때 어플리케이션이 작동할 것이라는 확신을 주는 것을 목표로 하기 때문이다.
이를 위해 팝업 코드 작성시 적절한 role을 사용하고 aria-label을 작성하는 등 접근성 향상에 공을 들여야 한다.
// 코드를 간소화하여 role과 접근성만 강조했다.
const Popup = () => {
return createPortal(
<div>
<div aria-label="팝업 배경">
<div aria-label="알림 박스" >
<div>
<h2 aria-label="알림 제목" >
{title}
</h2>
<div aria-label="알림 메시지" >
{message}
</div>
</div>
<Button
ariaLabel={buttonName}
type="button"
hoverColor="black"
className="w-full py-3"
color="cheeseYellow"
>
{buttonName}
</Button>
</div>
</div>
</div>,
document.body,
);
};
MDN EventSource
MDN ReadbleStream
MDN Streams API
MSW Streaming
Using server-sent events
테코톡 주드 SSE
[실전 프로젝트] React와 SSE
SSE 얄코
React SSE 실시간 알림 구현하기 (폴리필 문제해결)
리액트 실시간 알림 SSE 헤더에 토큰 담아 보내기
서버사이드이벤트(SSE)
실시간 서버 데이터 구독하기
javascriptInfo SSE
polling / long polling / SSE / websocket 정리
SSE(Server Sent Event)로 실시간 알림 받아오기
SSE 실시간 알림 기능 구현
알림 기능을 구현해보자 - SSE(Server-Sent-Events)!
웹소켓을 알아봅시다
테코톡 코일 웹소켓