[Project] Hobbyt - WebSocket 과 stomp 이용하여 알림 구현하기

Heera1·2023년 2월 8일
0

[Project] Hobbyt

목록 보기
6/6

typeScript, Next.js, WebSocket, stomp 사용

SSE가 아니라 WebSocket을 사용하는 이유

알림 기능을 만들 때 서버와 클라이언트의 양방향 통신이 필요없다보니 SSE를 주로 사용하는 것 같다. 이번 프로젝트의 같은 경우 실시간 채팅 기능도 있기 때문에 어차피 WebSocket을 사용해야 해서 알림 기능까지 WebSocket으로 구현하기로 하였다.


시작 전에...

npm i @types/sockjs-clientnpm i @stomp/stompjs 를 다운 받아준다. (typescript를 사용하기 때문에 타스용으로 다운)

  • index.tsx 페이지에서 로그인의 상태를 저장한 isLogin이 true일 때 WebSocket에 연결하도록 한다.

어느 페이지에서든 알람을 받을 수 있도록 index.tsx 에서 웹소켓을 연결해줬다. 사실 이 부분에 대해 고민이 많았다. next.js의 경우 기본적으로 보여주고 싶은 레이아웃의 경우 _app.tsx에 작성하기 때문에 거기에서 웹 소켓을 연결해서 알림이 온 상태유무에 따라 컴포넌트를 보였다가 안 보였다가 하게끔 구현하면 되려나 생각하고 있었는데 생각대로 되지 않았다.

상태 관리툴을 recoil을 사용해서인지 RecoilRoot를 불러오는 페이지에서 recoil에 저장한 값들을 불러올 수가 없어서 실패했기 때문이다. (userId라던가, 로그인 상태 유무를 저장한 state를 recoil로 불러왔어야 했다.)

웹 소켓엔 연결이 됐는데... 들어온 메세지를 가지고 알림을 띄우는 것에서 많은 시간을 쓰게 되었다.

===> 수정 23.06.12
웹소켓 연결과 구독하는 함수를 다른 파일로 나눠 해당 문제를 해결했다.(맨아래 참고)

(1) WebSocket 서버에 연결한다. (참고)

  • 웹소켓은 양방향 통신이기 때문에 클라이언트 측에서도 ws모듈을 설정해야 한다.



(2) stompimport하고, Client 객체를 만든다.

  • brokerURL : endpoint 작성하면 된다. http의 경우 ws로 https의 경우 wws를 붙여서 사용한다.
  • beforeConnect : 서버에 연결을 요청하기 전에 실행되는 함수이다. 서버를 끈 상태에서 실행해도 실행된다. 이 경우엔 연결이 잘 되고 있나 확인하기 위해 추가해서 넣었다.
  • connectHeaders : 서버에서 요구하는 값을 보내주면 된다. 이 경우엔 연결시 http 요청이 아닌 ws 요청으로 들어가기 때문에 보안에 걸린다고 token을 헤더에 넣어달라고 하셨기 때문에 아래처럼 진행하였다.
    (Authorization 부분에 string 타입에 string||null이 들어갈 수 없다고 떠서 토큰을 ${token} 으로 삽입했다.)
  • debug : stomp의 프로토콜을 받아와 디버깅하기 쉬우라고 만든 것 같다. 더 공부가 필요하다.
  • reconnectDelay :
  • heartbeatIncoming :
  • heartbeatOutgoing :

	const token = localStorage.getItem("authorization");

    const client = new StompJs.Client({
      brokerURL: "ws://어쩌구/endpoint", //endpoint 넣는 곳
      beforeConnect: () => {
        console.log("beforeConnect");
      },
      connectHeaders: {
        Authorization: `${token}`, // 우리 프로젝트의 경우 토큰이 없으면 보안에 걸려서 헤더 함께 보낸다
      },
      debug(str) {
        console.log(`debug`, str);
      },
      reconnectDelay: 50000, // 자동 재연결
      heartbeatIncoming: 4000,
      heartbeatOutgoing: 4000,
    });

(3) 연결됐을 때 실행할 함수와 에러 처리할 함수를 만든다.

  • onConnect : 연결되면 실행하는 함수이다. 함수 안에서 subscribe를 사용해 구독할 채널과 연결하면 된다.
  • subscribe : 구독하는 채널에 대한 메세지를 받을 수 있는 함수이다.
  • onstompError : 에러 발생 시 처리하는 함수이다.

    // 연결됐을 때 실행할 함수
    client.onConnect = function (frame) {
      client.subscribe("/message", message => { //구독하는 채널
        const datas = JSON.parse(message.body);
        console.log("message", datas);
      });
      client.subscribe(`/alarm/${userId}`, message => { //구독하는 채널
        const datas = JSON.parse(message.body); // 응답 데이터를 JSON 형식으로 저장
        const alarms = document.querySelector("#alarm");//id 가 alarm 이라는 태그를 찾고
        const alarm = document.createElement("li"); // li 태그를 생성한다
        alarm.innerText = `{datas.sender}님께서 ${datas.title}에 댓글을 남겼습니다.` // li 태그의 내용
        alarms?.appendChild(alarm); // 자식 태그로 삽입한다.
      });
    };
  
	//에러 처리 함수
    client.onStompError = function (frame) {
      console.log(`Broker reported error`, frame.headers.message);
      console.log(`Additional details:${frame.body}`);
    };

return (
    <>
      <Navbar />
      <Main>
        <div
          id="alarm"
          className="ml-10 list-none bg-gray-400 border-2 border-red-500"
        />
        <MainContent>
          <BestBlog />
          <BestBlogger />
          <BestProduct />
        </MainContent>
      </Main>
      <Footer />
    </>
  );

(4) 클라이언트를 활성화한다.

  • activate : 클라이언트 활성화 함수이다.
  • deactivate : 클라이언트 비활성화 함수이다. 활성화 연결이 있는 경우 다시 연결 및 연결을 중지한다.

    // 클라이언트 활성화
    client.activate();

작성완료 코드

if (isLogin) {
    const token = localStorage.getItem("authorization");
  
    // 웹 소켓 연결
    const webSocket = new WebSocket("ws://어쩌구/endpoint");
    webSocket.onopen = function () {
      console.log("웹소켓 연결 성공");
    };

    //stomp 채널 연결 
    const client = new StompJs.Client({
      brokerURL: "ws://어쩌구/endpoint",
      beforeConnect: () => {
        console.log("beforeConnect");
      },
      connectHeaders: {
        Authorization: `${token}`, // 토큰이 없으면 보안에 걸려서 헤더에 보냄
      },
      debug(str) {
        console.log(`debug`, str);
      },
      reconnectDelay: 5000, // 자동 재연결
      heartbeatIncoming: 4000,
      heartbeatOutgoing: 4000,
    });

(수정중)

디엠은 아직 api가 나오지 않아 구독전이다.



view component 만들기

웹소켓에 연결되면 onConnect 함수가 실행되고, subscribe 함수가 실행되면서 채널에 구독을 하게 된다. 구독하는 채널은 총 3가지이지만 우선 message 채널과 alarm 채널만 구독을 하였다.

우리 프로젝트 같은 경우 알림이 오는 경우가 총 3가지인데 블로그에 댓글이 달렸을 때, 판매중인 상품에 주문이 들어왔을 때, 주문이 취소되었을 때이다. 알림으로 들어오는 데이터를 JSON 형식으로 바꿔 datas에 저장하고 datas의 type에 따라 innerText가 변경되게끔 구현하였다.

id가 alarm인 div 태그를 하나 만들어주고, 그 태그 밑으로 li 태그들이 추가되는 방법으로 구현하였다. li 태그들에 이름이 alarm-list인 class를 붙여주었고, li 태그를 클릭시 notice 페이지로 이동하도록 onclick 이벤트를 추가하였다.

onclik 이벤트의 if문의 경우, alarm(li 태그들)은 타입이 Element | null 이기도 하고, remove를 사용했을 때 여러 리스트중 하나만 지워지기 때문에 alarm의 부모 태그인 alarms(div 태그)가 null 이 아니라면 div 태그를 지운다는 조건을 걸어주었다. (remove를 사용한 이유는 아래의 에러와 관련이 있다.)

client.subscribe(`/alarm/${userId}`, message => {
        const datas = JSON.parse(message.body);
        console.log("alarm", JSON.parse(message.body));
        console.log("alarm2", datas);
        const alarms = document.querySelector("#alarm");
        const alarm = document.createElement("li");
        // const alarm = document.createElement("li");
        // // 부모요소 id #alarm에 alarm 이라는 자식 요소 추가하기
        // const alarms = document.querySelector("#alarm")?.append(alarm);

        if (datas) {
          if (datas.type === "POST_COMMENT") {
            alarm.innerText = `${datas.sender} 님께서 ${datas.title}에 댓글을 남겼습니다.`;
          } else if (datas.type === "ORDER_CANCEL") {
            alarm.innerText = `${datas.sender} 님께서 ${datas.title} 주문을 취소하였습니다.`;
          } else if (datas.type === "SALE_ORDER") {
            alarm.innerText = `${datas.sender} 님께서 ${datas.title} 주문을 했습니다.`;
          }
        }
  
        alarms?.appendChild(alarm);
        alarm.setAttribute("class", "alarm-list");

        alarm.onclick = e => {
          e.preventDefault();
          router.replace("/notice");
          if (alarms !== null) {
            alarms.remove();
          }
        };
      });



하지만 여기서 문제가 발생했다. innerText가 2개~3개씩 뜨면서 점점 늘어나는 문제가 생겼다. 웹소켓이 여러개가 연결되었거나, 구독이 여러개가 되었거나... 의 문제인 거 같은데 아직 해결을 못하고 있다.


문제 해결!!! (ver.23.06.12)

드디어 innerText가 여러개씩 뜨는 문제를 해결했다. 내 생각으론 연결의 문제가 있는 거 같아 제대로된 연결 방법을 찾아보기 위해 이런저런 문서를 보다가 아이디어를 얻었다.

알림과 실시간 채팅 기능을 구현하면서 제일 고민이 됐던 건 두 가지였다.
(1) index.tsx에서 모든 코드를 작성하면 다른 페이지에서 알람 컴포넌트가 뜨지 않음
(2) 채팅 페이지에 접속할 때 채팅 서버를 구독하게끔 해야하는데 client를 채팅 페이지에서 불러와 사용할 수가 없음

두 문제점에 대한 해결책으로 index.tsx에 작성했던 코드를 삭제하고 웹소켓과 stomp에 접속하는 코드를 분리해서 stomp.js 파일로 만들어주고, 채널을 구독하고 데이터를 받아오는 코드를 stompAlarmSubscibe.tsx 파일로 분리해줬다. stomp.js 파일은client를 export 한다.

typeof window !== "undefined"?
전역 websocket 클래스는 브라우저 전용 기능이라 서버에 없다.
그렇기 때문에 SSR인 Next.js에서 사용하려면 코드가 브라우저에서 실행될 때 인스턴스화를 방지해야한다. (참고)

//stomp.js
import * as StompJS from "@stomp/stompjs";

let token;

if (typeof window !== "undefined") {
  token = localStorage.getItem("authorization");

  const webSocket = new WebSocket("wss://~");
  webSocket.onopen = function () {
    console.log("웹소켓 연결 성공");
  };
}

const client = new StompJS.Client({
  brokerURL: "wss://~",
  beforeConnect: () => {
    console.log("beforeConnect");
  },
  connectHeaders: {
    Authorization: `Bearer ${token}`,
  },
  debug(str) {
    console.log(`str`, str);
  },
  reconnectDelay: 50000, // 자동 재연결
  heartbeatIncoming: 4000,
  heartbeatOutgoing: 4000,
});

export default client;

알람 view component 전역 페이지에 띄우기

알람 view component를 Next.js의 layout을 이용해 전 페이지에서 띄우게끔 코드를 수정하였다.

//page/layout.tsx
import React from "react";

export default function Layout({ children }: any) {
  return <div className="relative border-4 border-red-500">{children}</div>;
}
//_app.tsx
import Layout from "./layout";
import StompAlarmSubscibe from "../src/components/Websoket/stompAlarmSubscibe";

export default function App({ Component, pageProps }: AppProps) {
  const router = useRouter();
  return (
    <RecoilRoot>
      <div
        className={`${
          !router.pathname.includes("/oauth") &&
          "max-w-[80rem] md:w-full sm:w-full h-screen"
        } m-auto`}
      >
        <Layout>
          <StompAlarmSubscibe />
        </Layout>
        <Component {...pageProps} />
        <TopButton />
      </div>
    </RecoilRoot>
  );
}

stompAlarmSubscibe.tsx 파일은 위에서 언급했던 client를 가져와 로그인중인 상태라면 messagealarm/userId 채널에 구독하고, idalarmdiv 태그를 생성 후, 알람의 type에 따라 innerText가 삽입되는 함수이다.

코드가 굉장히 정신없는데 우선은 실시간 채팅을 구현하기 위해 웹소켓이 제대로 연결해놔야해서 리펙토링은 나중으로 미뤄야할 것 같다.

간단하게 정리해보자면

    1. 로그인 상태라면 client(stomp)에 연결하고, /message/alarm/${userId} 채널을 구독한다.
    1. id가 alarm인 태그를 찾고 이를 alarms라고 한다.
      alarmsundefined가 아니고, datas가 있다면 datas의 type에 따라 다른 작업을 한다.
      2-1. datas.type"POST_COMMENT" 이라면 alarm(li 태그)에 type 맞는 innerTextonClick 이벤트를 추가한다.
      2-2. onClick 이벤트는 cuurentTargetid를 가져오고, 가져온 id와 같은 id를 가지고 있는 태그를 찾아낸다.
      2-3. 태그를 찾아내면 그 태그의 부모노드에서 해당 id를 가진 자식을 제거한다.
      2-4. 해당 페이지로 이동할 수 있도록 router.push를 한다.

처음에는 랜덤으로 id를 부여하는 함수가 없었다. li태그들에 alarm-list라는 클래스만 부여했는데 이렇게 할 경우 알람을 클릭해서 그 페이지로 이동했을 때 클릭한 알람이 더 이상 필요가 없는데도 삭제할 방법이 없어서 li 태그들에 랜덤으로 id를 부여하고, 클릭한 태그의 currentTarget id와 똑같은 id를 가진 태그를 삭제하는 방식으로 변경하게 되었다.

//stompAlarmSubscibe.tsx
export default function StompAlarmSubscibe() {
  const router = useRouter();
  const isLogin = useRecoilValue(LoginState);
  const userId = useRecoilValue(UserIdState);

  if (isLogin) {
    client.onConnect = () => {
      client.subscribe("/message", message => {
        const datas = JSON.parse(message.body);
        console.log("message", datas);
      });
      client.subscribe(`/alarm/${userId}`, message => {
        const datas = JSON.parse(message.body);
        const alarms = document.querySelector("#alarm");
        const alarm = document.createElement("li");
        const alarmList = document.querySelectorAll(".alarm-list");

        if (alarms && datas) {
          if (datas.type === "POST_COMMENT") {
            alarm.innerText = `${datas.sender} 님께서 ${datas.title}에 댓글을 남겼습니다.`;
            alarm.addEventListener("click", (e: any) => {
              const { id } = e.currentTarget;
              const clickList = document.getElementById(`${id}`);
              clickList?.parentNode?.removeChild(clickList);
              router.push(`/blog/${datas.receiverId}/post/${datas.redirectId}`);
            });
          } else if (datas.type === "ORDER_CANCEL") {
            alarm.innerText = `${datas.sender} 님께서 ${datas.title} 주문을 취소하였습니다.`;
            alarm.addEventListener("click", (e: any) => {
              const { id } = e.currentTarget;
              const clickList = document.getElementById(`${id}`);
              clickList?.parentNode?.removeChild(clickList);
              router.push(
                `/mypage/${datas.receiverId}/orderdetail/${datas.receiverId}/ordermanagement/${datas.redirectId}`,
              );
            });
          } else if (datas.type === "SALE_ORDER") {
            alarm.innerText = `${datas.sender} 님께서 ${datas.title} 주문을 했습니다.`;
            alarm.addEventListener("click", (e: any) => {
              const { id } = e.currentTarget;
              const clickList = document.getElementById(`${id}`);
              clickList?.parentNode?.removeChild(clickList);
              router.push(
                `/mypage/${datas.receiverId}/orderdetail/${datas.receiverId}/ordermanagement/${datas.redirectId}`,
              );
            });
          }
        }
        alarms?.appendChild(alarm);
        alarm.setAttribute("class", "alarm-list");
        alarm.setAttribute("id", randomId(1, 10));
      });
    };

    // 연결 실패했을 때 실행할 함수
    client.onStompError = frame => {
      console.log(`========> Broker reported error`, frame.headers.message);
      console.log(`========> Additional details:${frame.body}`);
    };

    // 클라이언트 활성화
    client.activate();
  }

  const randomId = (min: number, max: number) => {
    const num = Math.floor(Math.random() * (max - min)) + min;
    return `${num}`;
  };

  return (
    <div
      id="alarm"
      className="p-2 list-none max-w-[24rem] cursor-pointer absolute right-0 z-50 mt-[4rem] md:mt-2"
    />
  );
}

참고한 문서

https://velog.io/@tlatldms/Spring-Boot-STOMP-JWT-Socket-%EC%9D%B8%EC%A6%9D%ED%95%98%EA%B8%B0

https://velog.io/@cksal5911/WebSoket-stompJSReact-%EC%B1%84%ED%8C%85-1

profile
웹 개발자

0개의 댓글