Server-Sent Events(SSE)를 활용한 실시간 알림 테스트 코드 및 기능 구현

JunSeok·2024년 9월 12일
1

지식 기록

목록 보기
11/12
post-thumbnail

목적

실시간 알림 기능을 구현하기 위해 서버와의 실시간 통신을 구축한다.

실시간 통신 방법

Polling

클라이언트가 주기적으로 서버에 요청을 보내는 방식.

장점

  • 구현이 간편하고 추가적인 서버 설정이 필요 없다.
  • 일정하게 갱신되는 서버 데이터의 경우 유용하게 사용할 수 있는 방식이다.

단점

  • 주기적인 요청으로 발생할 수 있는 불필요한 요청과 응답으로 인해 서버에 부하가 생길 수 있다.
  • 요청을 보내는 주기 만큼의 지연이 발생하여 실시간 데이터를 기대하기 힘들다.
  • 주기가 짧아질수록 서버 및 네트워크 부하가 높아질 수 있다.
  • HTTP 통신 시 매번 보내는 요청과 응답 헤더의 크기가 불필요하게 크다.

사용 예시

일정 시간 간격으로 데이터를 업데이트하는 경우에 유용하다.
ex) 실시간 야구 문자 중계, 뉴스 헤드라인

Long Polling

클라이언트가 서버에 요청을 보내면, 서버는 클라이언트의 요청에 바로 응답하지 않고 업데이트가 발생할 때까지 기다린다.
업데이트가 발생하거나 설정된 시간이 지나면 서버는 응답을 보내고 클라이언트는 응답을 받아 연결을 종료한 뒤 곧바로 다시 요청을 보내 다음 응답을 기다린다.

장점

  • polling 보다 데이터의 업데이트에 반응하는 속도가 빨라질 수 있고 서버의 부담이 줄어들 수 있다.
  • 실시간성이 필요한 적은 수의 클라이언트와 연결되어 있는 경우 사용하는 것이 좋다.

단점

  • 클라이언트의 요청 주기가 짧다면 일반 polling과 별 차이가 없으며, 다수의 클라이언트에게 동시에 이벤트가 발생될 경우 한 번에 응답을 하고 요청을 다시 보내야 하기 때문에 서버에 순간적인 부담이 가해질 수 있다.
  • HTTP 통신 시 매번 보내는 요청과 응답 헤더의 크기가 불필요하게 크다.

사용 예시

ex) 적은 인원의 채팅 앱, 알림

WebSocket

웹소켓 프로토콜을 사용하면 클라이언트와 서버가 지속적인 연결을 통해 서로가 원할 때 데이터를 주고 받을 수 있다.
즉 웹소켓은 데이터의 송수신을 동시에 처리하는 양방향 통신 기법이다.

기존 HTTP 요청 응답 방식은 요청한 그 클라이언트에만 응답이 가능했는데, ws 프로토콜을 통해 웹소켓 포트에 접속해있는 모든 클라이언트에게 이벤트 방식으로 응답할 수 있다.

프레임으로 구성된 메시지라는 논리적 단위로 송수신하며, 메시지에 포함될 수 있는 교환 가능한 메시지는 텍스트와 바이너리이다.

장점

  • 최초 접속시 HTTP 프로토콜을 통해 연결하기 때문에 별도의 포트 없이 기존의 포트(HTTP: 80, HTTPS: 443) 를 사용할 수 있어 추가 방화벽을 열지 않아도 양방향 통신이 가능하고 HTTP 규격인 CORS 적용, 인증 등을 기존과 동일하게 보장받을 수 있다.
  • 기존 HTTP 통신과 달리 Connection을 지속하므로 요청 및 응답 헤더와 같은 연결 비용을 줄일 수 있다.

단점

  • 연결을 계속 유지하는 만큼 서버의 부하가 생길 수 있고, 많은 사용자들이 동시에 접속해 있을 수록 유지해야 하는 TCP 연결이 많아져 메시지들이 오가는 빈도가 높다면 네트워크 대역폭과 CPU의 사용량 또한 증가할 수 있다.
  • 서버의 설계에 따라 구현이 복잡해질 수 있다. 특히 로드밸런싱이 적용된 서버에서는 이를 위해 고려하고 설정할 부분이 많아진다.
  • HTTP와는 다른 프로토콜이기 때문에 처음 사용할 때 구현 및 학습 난이도가 높다.
  • HTML5 이후에 등장했기 때문에, 이전의 기술로 구현된 서비스에서는 Socket.io와 같은 라이브러리를 사용해야 한다.
  • 웹소켓은 문자열들을 주고 받을 수 있게 해줄 뿐 그 이상의 일은 하지 못한다.

사용 예시

주식, 채팅 등 연속된 데이터를 빠르게 노출해야 하는 실시간 서비스 분야에 활용된다.
ex) 온라인 게임, 실시간 협업 도구에서 여러 사용자의 동시 문서 편집, 화상 채팅 및 회의에서 실시간 데이터 송수신

SSE(Server Sent Event)

SSE를 사용하면 서버에서 클라이언트로의 실시간 단방향 통신을 할 수 있다.

한 번의 HTTP 요청을 통해 연결이 이루어지면, 그 이후로 별도의 요청 없이 실시간으로 서버에서 클라이언트로 데이터를 송신할 수 있다.

메시지에 포함될 수 있는 교환 가능한 메시지는 텍스트이다.

장점

  • HTML5 표준 기술로서 모든 브라우저가 지원한다.(IE는 사라졌으니 말이다.)
  • HTTP 프로토콜 기반으로 동작하기 때문에 학습 및 구현 난이도가 비교적 낮다.
  • 자동 재연결 기능이 있다.(기본값은 3000ms)

단점

  • 단방향 통신이다.
  • HTTP/1.1의 경우 브라우저당 6개의 접속만을 허가하며 HTTP/2에서는 100개까지의 접속을 허용한다.

사용 예시

ex) 주식 거래, 뉴스 피드, 실시간 알림

SSE 선택 이유

기능에 따라 양방향 통신이 필요하지 않은 경우도 많다. 예를 들어, 서버에서 시간이 걸리는 작업의 진행 상태를 보여주는 진행 바(progress bar), 실시간 소식을 전하는 SNS, 뉴스, 주식 거래 서비스, 또는 기타 실시간 모니터링과 같은 경우가 그렇다.

내가 구현하려는 실시간 알림 기능 역시 양방향 통신이 불필요하다. 클라이언트가 지속적으로 데이터를 보낼 필요 없이, 서버에서 업데이트가 발생할 때만 실시간으로 데이터를 보내주면 충분하기 때문이다.

즉, 단방향 통신으로도 충분한 서비스에는 SSE가 적합하고, 양방향 통신이 필수적인 경우에는 웹소켓을 사용하는 것이 더 적절하다.

SSE 동작 및 사용 방법

동작 순서

  • 클라이언트가 서버에 SSE로 통신하자는 요청을 보낸다.
  • 서버는 이 요청을 수신하고 수락했음을 알리는 메시지를 보낸다.
  • 클라이언트는 이를 받고, 서버가 보내주는 데이터들에 반응할 준비를 한다.
  • 이 시점부터 서버는 정해진 이벤트가 있을 때마다 클라이언트에게 메시지를 보낸다. (단방향 통신이기 때문에 클라이언트는 이에 응답을 할 수는 없다.)
  • 클라이언트는 서버로부터 메시지가 도착할 때마다 이에 반응하여 필요한 작업을 한다.
  • 이 과정들은 하나의 연결 안에서 계속 이루어지고, 만약 연결이 끊기면 클라이언트는 자동으로 재연결을 요청하여 통신을 재개한다.
  • 필요 작업을 마치면 클라이언트 또는 서버에서 상대방에게 종료를 통보하는 메시지를 보냄으로써 연결이 끝나게 된다.

사용 방법

  • 클라이언트가 브라우저일 경우, SSE 연결 요청을 위해 Web API의 EventSource 인터페이스를 사용한다.
  • 클라이언트는 지정된 URL로 서버에게 요청을 보낸다.
    - SSE 통신을 통해 이벤트 스트림 타입의 메시지를 수신하겠다는 의미의 헤더가 실린다.
    - Accept: text/event-stream
  • 이를 수신한 서버는 이벤트 스트림 타입의 메시지임을 명시하는 헤더를 실은 응답을 보낸다.
    - Content-Type: text/event-stream
    - 연결을 계속 유지한다는 항목도 실린다. 이 헤더 덕분에 클라이언트가 요청을 보내면서 만들어진 연결이 서버가 응답을 보낸 이후로도 계속 유지되는 것이다.
    - Connection: keep-alive
    - 서버의 첫 번째 응답 이후로는 메시지에 HTTP 헤더가 실릴 필요가 없게 된다.
  • 서버는 지정된 이벤트가 발생할 때마다 실시간으로 클라이언트에게 메시지를 보내고 클라이언트에서는 각 메시지를 받아 특정 작업을 실행한다.
  • 서버에서는 이벤스 소스에 이벤트 이름을 기입하여 토픽 기반으로 여러 이벤트를 발행할 수 있고, 클라이언트에서는 이벤트 리스너 설정을 통해 해당 토픽 이벤트를 구독할 수 있다.
  • EventSource는 retry를 사용하여 자동으로 재접속할 수 있다.
    - 클라이언트가 따로 구현하지 않아도 연결이 비정상적으로 끊길 때마다 자동으로 연결 요청을 보낸다.
    - 연결이 끊어질 시 재접속을 몇 ms 후에 시도할 지 지정할 수 있고, 기본값은 3000ms이다.
    - 메시지에 id를 지정할 수도 있는데, 연결이 끊어졌다가 재개될 경우 클라이언트가 마지막에 받은 id를 Last-Event-ID 헤더에 담아 요청에 실어보냄으로써 서버는 그 다음에 해당하는 메시지부터 보낼 수 있다.
  • 클라이언트에서 연결 종료할 때는 EventSource 객체의 close 메서드를 호출한다.
  • 서버에서 연결 종료할 때는 전송을 중단하거나 합의된 메시지를 보내서 연결을 통지한다.
    - 서버에서 이벤트 소스의 이름을 end로 하고, 클라이언트에서는 end의 이벤트를 실행할 때 close 메서드를 호출하는 방법이 있다.

클라이언트 예시 코드

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 및 테스트 코드 작성 가이드라인 참고

  1. 실사간 알림 기능이 제공하는 서비스에 대한 use case coverage 작성.
  2. MSW 사용하여 API mocking.
  3. 테스트 환경 설정 및 코드 작성.
  4. 기능 구현.

use case coverage 작성

  • 실시간 알림이 도착하면 전체 화면에 알림 팝업이 발생하고, 그 안에 제목, 메시지, 버튼이 존재한다.
  • 팝업이 발생하고 바깥 배경을 클릭하면 팝업이 사라진다.
  • 팝업 버튼이 확인 버튼인 경우 버튼 클릭시 팝업이 사라진다.
  • 팝업 버튼이 아닌 확인 버튼이 아닌 경우, 버튼 클릭시 특정 화면으로 이동한다.
  • 알림 확인 도중 또 다른 실시간 알림 발생시 팝업을 닫자마자 새로운 팝업이 발생한다.

API mocking.

실시간 알림 테스트를 위해 서버로부터 특정 시간 후에 메시지를 수신하는 상황을 구현하고자 한다.

그러나 실제 API 요청을 통한 테스트는 API 개발이 완료된 후에만 가능하므로, 백엔드 개발과 병렬적으로 진행하기 어렵다는 문제가 있다. 또한 테스트 과정에서 불필요한 요청으로 인한 비용 발생과 API 의존성 문제도 고려해야 한다.

그래서 실제 API 요청을 통한 테스트가 아니라 네트워크 수준에서 mocking한 API를 이용하여 테스트하고자 한다. 이를 위해 MSW 라이브러리를 사용한다.

어떤 API를 mocking해야 하는가?

EventSource API는 클라이언트가 서버로부터 지속적인 데이터를 받는 것을 처리한다. 서버가 클라이언트에 이벤트를 계속해서 푸시하는 방식인데, 이 방식을 테스트 환경에서 mocking해야 한다.

실제 서버는 지속적으로 데이터를 전송해 주지만, mocking 상황에서는 실제 서버가 없기 때문에 서버가 데이터를 스트리밍하는 것을 흉내내야 한다.

MSW 공식문서에서 data streaming을 mocking하는 recipe를 제공하고 있는데, 그 방법으로 ReadableStream을 말한다.

ReadableStream을 사용하여 API mocking

Streams APIReadableStream 인터페이스는 바이트 데이터를 읽을 수 있는 Stream을 제공한다.

즉 EventSource API가 클라이언트에서 서버의 데이터를 실시간으로 받는 역할이라면, 서버에서 데이터를 생성하고 전달하는 역할은 ReadableStream API가 한다.

  1. 백엔드 개발자와 EventSource 메시지 이벤트 이름과 메시지 타입을 합의하여 mock data 생성.
  2. ReadableStream 생성자를 통해 응답값으로 Stream을 생성.
  3. TextEncoder의 encode 메서드를 이용하여 문자열 데이터를 UTF-8 형식의 바이트 데이터로 변환.
  4. controller의 enqueue 메서드를 이용하여 바이트 데이터를 Stream에 저장.
  5. Stream을 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',
      },
    });
  },
);

테스트 환경 설정 및 코드 작성

테스트 환경 설정

rendering component

어느 화면에서나 실시간 알림이 발생해야 하기 때문에 모든 페이지가 공유하는 GlobalLayout 컴포넌트를 작성하고 해당 컴포넌트에서 SSE 작업을 수행한다.

setup 함수 정의

render 메서드와 userEvent는 거의 매번 사용하기 때문에 setup 함수를 만들어 사용한다.

const setup = () => {
    const utils = render(<GlobalLayout />);
    const user = userEvent.setup();

    return {
      user,
      ...utils,
    };
};

EventSource API mocking

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();
    },
  );

기능 구현

SSE 연결 및 custom hook 작성

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 };
};

알림이 발생시 팝업 발생

목표
  • 알림이 발생하면 팝업이 발생하고, 바깥이나 버튼을 클릭하면 팝업을 닫는다.
  • 팝업 읽는 도중 새로운 알림이 발생하면, 팝업을 닫자마자 새로운 팝업이 생성된다.
구현
  1. currentNotification 변수 생성
  2. useSSE가 리턴한 notifications 배열과 currentNotification을 dependency로 가지는 useEffect 생성
  3. currentNotification이 null이면서 notificaions 값이 있을 때 currentNotification 값을 notifications의 첫번째 값으로 설정하고 notifications의 첫번째 값을 제거.
// 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이 없는 경우 aria-label을 명시하여 해당 tag가 무슨 역할을 하는 지 명확히 한다.
  • 제목은 heading tag를 사용한다.
  • 이미지를 사용하는 경우 img 태그를 사용하고 alt 속성을 반드시 명시한다.
  • 버튼을 사용하는 경우 button 태그를 사용한다.
// 코드를 간소화하여 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)!
웹소켓을 알아봅시다
테코톡 코일 웹소켓

profile
최선을 다한다는 것은 할 수 있는 한 가장 핵심을 향한다는 것

0개의 댓글