next.js stompjs 웹소켓 사용하기

dobby·2024년 6월 5일
0
post-thumbnail

토론 서비스에서 토론을 하기 위해선 실시간으로 사용자가 소통할 수 있어야 한다.
단순 투표나 게시글로 소통하는게 아니고 채팅으로 이야기를 나누는 서비스이기 때문에 웹소켓으로 실시간 서비스를 제공하고자 한다.


기본적으로 클라이언트-서버 관계는 무상태로 연결되어 있다.
그렇기에 요청마다 연결-종료의 과정을 거쳐야 한다.

클라이언트가 연속해서 서버에게 요청을 보내는 경우엔 네트워크 리소스 낭비가 발생하게 될 것이다.
또한 실시간 서비스의 경우는 A 클라이언트가 보낸 요청에 대해 B, C, D 클라이언트도 어떤 이벤트가 발생했는지 알 수 있어야 한다.

실시간 서비스는 단순 http 요청에서는 특정 클라이언트의 이벤트 발생을 알아챌 수 없기 때문에 서버에게 이벤트가 발생했는지 지속해서 요청을 보내 확인해야 한다.

위의 요청을 polling 방식이라고 한다.

서버의 evnet를 client로 보내는 4가지 방법

Polling

일정 시간마다 서버에 요청을 보내 데이터가 갱신되었는지 확인하여 갱신된 값이 있다면 데이터를 응답받는 방식이다.
일반적인 클라이언트-서버 관계에서 주로 사용하는 방법이다.

Polling 방식은 클라이언트가 계속적으로 request를 보내기 때문에 서버에 부담이 될 수 있다.
그렇기에 실시간 정도의 빠른 응답을 기대하기 어려우며 http 오버헤드가 발생하게 된다.

Polling은 일정하게 갱신되는 서버 데이터의 경우에 유용하게 사용할 수 있다.

Long-Polling

요청을 보낸 후 서버에서 응답을 보내줄 때까지 기다리는 방법이다.
즉, 서버와의 연결 지속 시간을 길게하는 방식이다.
서버의 응답이 없으면 대기하고, 이벤트가 존재하여 서버가 응답 메시지를 보내면 연결을 해제한다.

실시간 메시지 전달에 사용할 수 있으며 polling 방식보다는 서버 부담이 줄어들지만, 이벤트 발생 간격이 좁다면 Polling과 차이가 없다.

SSE, Server-Side-Event

클라이언트와 서버가 한 번 연결을 맺고 난 후 일정시간 동안 서버에서 변경이 발생할 때마다 데이터를 전송하는 방법이다.

Long Polling 방식에 비해 다시 요청을 안해도 된다는 장점을 가지며, 서버의 데이터를 실시간 및 지속적으로 streaming하는 기술이다.

WebSocket

주로 실시간 서비스에서 사용하는 양방향 통신 프로토콜이다.
클라이언트와 서버가 HTTP 기반으로 HandShaking을 한 후, ws 프로토콜을 통해 상호간 응답 하는 방식이다.

websocket의 경우 ws 프로토콜을 통해 웹소켓 포트에 접속해 있는 모든 클라이언트에게 이벤트 방식으로 응답할 수 있다. (broadcast)

WebSocket 적용하기

채팅의 경우 websocket을 사용하여 구현하는 것이 일반적이다.

나는 서버와 같은 Stompjs 를 사용하여 socket을 연결하려고 한다.
아무래도 같은 라이브러리, 버전을 사용하는 것이 호환성이 좋기 때문이다.

참고로 나는 next.js 14 버전과 typescript, yarn berry를 사용하고 있다.

먼저 라이브러리를 설치해준다.

$ yarn add @stomp/stompjs

라이브러리 설치는 @stomp/stompjs@sockjs-client 둘 다 해주었는데, 실제 사용은 @stomp/stompjs만 했다.

stomp의 플로우는 connect, subscribe, publish, disconnect 로 나뉜다.

  • connect는 서버와 클라이언트가 http handshake를 통해 연결을 맺는다.
  • subscribe는 웹소켓을 통해 서버로부터 메시지를 전달받기 위해 구독한다.
  • publish는 웹소켓을 통해 클라이언트에서 서버로 메시지를 전달하기 위해 메시지를 발행한다.
  • disconnect는 서버와 클라이언트간의 연결을 종료한다.

websoket 적용 시 사용하는 용어

  • app: WebSocket으로의 접속을 위한 포인트가 되어 메시지를 실제로 보낼 때 사용된다.
  • topic: 1:N의 연결
  • queue: 1:1의 연결
  • user: 메시지를 보내기 위한 사용자를 특정한다.

우선 플로우에 대한 함수를 먼저 보이고, 이후 합쳐진 코드를 올리겠다.

connect

먼저 stompjs를 import 해주고, 서버와의 연결을 맺기 위한 코드를 작성해준다.

나는 메시지 전송 함수를 따로 빼놨기 때문에 useRef로 stompjs를 저장해주었다.

import * as StompJs from '@stomp/stompjs';

const client = useRef<StompJs.Client>();
const connect = () => {
      console.log('Connecting...');
      client.current = new StompJs.Client({
        brokerURL: 'ws://웹소켓 서버 BASE_URL/ws',
        connectHeaders: {
          Authorization: `Bearer ${tokenManager.getToken()}`,
        },
        reconnectDelay: 200,
        onConnect: () => {
          console.log('connected');
          subscribeError();
          subscribe();
        },
        onWebSocketError: (error) => {
          console.log('Error with websocket', error);
        },
        onStompError: (frame) => {
          console.dir(`Broker reported error: ${frame.headers.message}`);
          console.dir(`Additional details: ${frame}`);
        },
      });
      console.log('Activating...');
      client.current.activate();
    };

처음 stomjs 객체를 생성할 때 에러 발생 시 처리도 작성해줄 수 있다. (onWebSocketError, onStompError)
연결이 끝나면 subscribe하도록 해주었고, client.current.activate()를 통해 활성화해준다.
onConnectsubscribeError()는 아래에서 설명!

subscribe

서버로부터 오는 메시지를 받기 위해 subscribe(구독) 해준다.

    const subscribe = () => {
      console.log('Subscribing...');
      client.current?.subscribe(`/topic/agoras/${agoraId}/chats`, (received_message: StompJs.IFrame) => {
        console.log(`> Received message: ${received_message.body}`);

        pushMessage(received_message.body, 'received');
      });
    };

publish

클라이언트에서 서버로 메시지 전송하기
나는 메시지 전송 시 publish하도록 했다.

  const sendMessage = () => {
    if (message.trim().length < 1) return;

    if (client.current) {
      client.current?.publish({
        destination: `/app/agoras/${agoraId}/chats`,
        body: JSON.stringify({
          type: 'CHAT',
          message,
        }),
      });

      pushMessage('send');
      console.log(`> Send message: ${message}`);
    }
    setMessage('');
  };

일단 웹소켓 연결 및 메시지 전송/받기에 대한 함수는 이렇게인데, 나는 백엔드분이 에러 메시지를 추가 구독함으로 받을 수 있도록 따로 빼두셔서 그것도 함께 구독을 해야 한다.

    const subscribeError = () => {
      console.log('Subscribing Error...');
      client.current?.subscribe('/user/queue/errors', (received_message: StompJs.IFrame) => {
        console.log(`> Received message: ${received_message.body}`);
      });
    };

onConnect에서 나왔던 subscribeError 함수가 위의 코드다.

전체 코드는 다음과 같다.

import * as StompJs from '@stomp/stompjs';
import React, { useEffect, useRef } from 'react';

  const client = useRef<StompJs.Client>();
  const agoraId = usePathname().split('/').pop() as string;

  const sendMessage = () => {
    if (message.trim().length < 1) return;

    if (client.current) {
      client.current?.publish({
        destination: `/app/agoras/${agoraId}/chats`,
        body: JSON.stringify({
          type: 'CHAT',
          message,
        }),
      });

      pushMessage('send');
      console.log(`> Send message: ${message}`);
    }
    setMessage('');
  };

// 최초 렌더링 시 실행
  useEffect(() => {
    const disconnect = () => {
      client.current?.deactivate();
      console.log('Disconnected');
    };

    const subscribe = () => {
      console.log('Subscribing...');
      client.current?.subscribe(`/topic/agoras/${agoraId}/chats`, (received_message: StompJs.IFrame) => {
        console.log(`> Received message: ${received_message.body}`);

        pushMessage(received_message.body, 'received');
      });
    };

    const subscribeError = () => {
      console.log('Subscribing Error...');
      client.current?.subscribe('/user/queue/errors', (received_message: StompJs.IFrame) => {
        console.log(`> Received message: ${received_message.body}`);
      });
    };

    const connect = () => {
      console.log('Connecting...');
      client.current = new StompJs.Client({
        brokerURL: 'ws://웹소켓 서버 BASE_URL/ws',
        connectHeaders: {
          Authorization: `Bearer ${tokenManager.getToken()}`,
        },
        reconnectDelay: 200,
        onConnect: () => {
          console.log('connected');
          subscribeError();
          subscribe();
        },
        onWebSocketError: (error) => {
          console.log('Error with websocket', error);
        },
        onStompError: (frame) => {
          console.dir(`Broker reported error: ${frame.headers.message}`);
          console.dir(`Additional details: ${frame}`);
        },
      });
      console.log('Activating...');
      client.current.activate();
    };

    if (tokenManager.getToken() !== undefined) {
      connect();
    } else {
      console.error('Token is not found');
      // 토큰 발급
      // POST /api/v1/temp-user
    }

    return () => disconnect();
  }, [agoraId]);

한 번에 다 할려고 하면 어디에서 문제인지 확인하기 어렵고, 에러가 발생하면 더 하기 싫어진다.
이전에 했던 프로젝트에서 한 팀원이 한 단계씩 확인하지 않고 전체 복사하기로 웹소켓을 구현하려고 해서 어디가 문제인지 파악하고 해결하는데 시간이 너무 오래 걸렸었다.

그러니, 연결, 구독, 메시지 발행 이 과정을 하나 하나 잘 되는지 확인하는게 매우 중요하다!!

네트워크로 소켓 연결 확인하기

이 모든 과정이 잘 되고 있는지 확인하기 위해선 console로의 출력으로는 한계가 있다.
콘솔 출력은 잘 되지만 연결이 안되고 있을 수도 있기 때문이다.

확인하는 방법은 생각보다 간단하다.

개발자 도구의 네트워크 탭으로 들어가서, 이름이 ws인 요청 클릭 후 Message 탭으로 들어가면 아래 사진처럼 연결, 구독, 전송이 잘 되고 있는지 확인할 수 있다.

사진 오른쪽이 잘린건, Authorization 옆에 토큰이 보여서 혹시 몰라 잘랐다.
위 사진처럼 CONNECT, CONNECTED, SUBSCRIBE 등이 뜨는건 잘 연결 되었고, 구독도 하고 있다는 뜻이다.
여기서 publish를 하면 SEND도 뜰 것이다.


이상, 끝!

profile
성장통을 겪고 있습니다.

0개의 댓글