Spring boot + Stomp

FineLee·2023년 8월 6일
0

java

목록 보기
1/1

Spring boot의 web socket + stomp 를 이용한 실시간 알림 기능 구현

프로젝트를 진행하면서 실시간으로 도움요청을 주고 받는 기능이 필요하여 해당 기술(웹소켓)을 사용하게 되었다.
학교에서 해본 프로젝트에서는 웹소켓이 아닌 소켓통신이었기때문에
다소 생소한 경험이되었다.
아직 테스트 전이라서 기본적인 기능 구현에 있어서의 고민 과정을 담아보았다.

사용자A가 도움 요청

요청 값

  • user세션 (or jwt) 에서 유저 정보 뽑아낼 수 있게
  • longitude
  • latitude
  • helpcategory

return

근데 이게 수락이 될때까지 계속 기다리는 건 어떻게 만들지?

→ 웹소켓

  • helpAccept (수락 여부)를 계속 로딩해서 true가 될때까지..
  • 수락시 → B 의 정보 (번호, id ,이름,성별 등)

프론트 예시

사용자 A

import React, { useEffect, useState } from 'react';
import Stomp from 'stompjs';

const UserA = () => {
  const [stompClient, setStompClient] = useState(null);
  const [helpRequest, setHelpRequest] = useState(null);
  const [isAccepted, setIsAccepted] = useState(false);

  useEffect(() => {
    const socket = new WebSocket('ws://localhost:8080/socket');

    const stomp = Stomp.over(socket);
    stomp.connect({}, () => {
      stomp.subscribe('/topic/request', (message) => {
        const helpRequestData = JSON.parse(message.body);
        setHelpRequest(helpRequestData);
      });
      stomp.subscribe('/user/queue/acceptHelp', (message) => {
        setIsAccepted(true);
      });
    });

    setStompClient(stomp);

    return () => {
      if (stomp) {
        stomp.disconnect();
      }
    };
  }, []);

  const handleRequestHelp = () => {
    // 도움 요청 로직
    // stompClient.send('/app/requestHelp', {}, JSON.stringify({ userId: 'UserA', message: 'Help me!' }));
  };

  return (
    <div>
      <button onClick={handleRequestHelp}>Request Help</button>
      {helpRequest ? <p>New Help Request: {helpRequest.message}</p> : null}
      {isAccepted ? <p>Your help request has been accepted!</p> : null}
    </div>
  );
};

export default UserA;

사용자 A가 "Request Help" 버튼을 클릭하면 /app/requestHelp 경로로 도움 요청을 보내고,
도움 요청이 수락되었을 때 /user/queue/acceptHelp 경로로부터 메시지를 수신하여 isAccepted 상태를 업데이트한다. 이렇게 함으로써 사용자 A는 도움 요청의 수락 여부를 계속해서 확인하며 알림을 받을 수 있다.

요청 보낼때는 A의 정보를 같이 보내야함
기존 api 요청 값들과는 다르게 따로 선언해둬서
형식에 맞게 보내야 함

사용자 B의 도움 수락

요청 값

  • b의 정보
  • helpAccept 여부
  • help id

return

  • true라면 a의 정보

  • a의 위치

  • false라면 db 값만 변경

import React, { useEffect, useState } from 'react';
import Stomp from 'stompjs';

const HelpRequest = () => {
  const [stompClient, setStompClient] = useState(null);
  const [helpRequest, setHelpRequest] = useState(null);

  useEffect(() => {
    const socket = new WebSocket('ws://localhost:8080/socket'); // WebSocket 서버 주소

    const stomp = Stomp.over(socket);
    stomp.connect({}, () => {
      stomp.subscribe('/topic/request', (message) => {
        const helpRequestData = JSON.parse(message.body);
        setHelpRequest(helpRequestData);
      });
    });

    setStompClient(stomp);

    return () => {
      if (stomp) {
        stomp.disconnect();
      }
    };
  }, []);

  const handleAccept = () => {
    if (stompClient && helpRequest) {
      const helpId = helpRequest.helpId;

      // 도움 요청 수락 처리 로직
      stompClient.send('/app/acceptHelp/' + helpId, {}, helpId);
    }
  };

  return (
    <div>
      {helpRequest ? (
        <div>
          <p>New Help Request: {helpRequest.message}</p>
          <button onClick={handleAccept}>Accept</button>
        </div>
      ) : (
        <p>No new help requests</p>
      )}
    </div>
  );
};

export default HelpRequest;

acceptHelp 함수는 사용자 B가 도움을 수락할 때 호출되며, 해당 도움 요청의 고유한 helpId 값을 인자로 받습니다. 그리고 해당 helpId 값을 /app/acceptHelp/{helpId} 경로로 전송하여 사용자 A에게 도움 요청 수락 메시지를 보낸다.

사용자 A의 입장에서는 이러한 도움 요청 수락 메시지를 웹 소켓을 통해 받아 처리하도록 설정한 후, 필요한 로직을 추가하면 된다. 이렇게 함으로써 사용자 B가 어떤 도움 요청을 수락하는지 정확하게 식별하고, 사용자 A에게 해당 정보를 실시간으로 알릴 수 있게 된다.

위의 코드에서 handleAccept 함수 내부에서 stompClient.send를 호출할 때 도움 요청의 helpId 값을 사용하여 경로를 생성하도록 수정되었다. 이제 사용자 A는 도움 요청이 수락될 때 해당 helpId 값을 전송하며, 사용자 B가 수락한 도움 요청을 어떤 것인지 구별할 수 있다.

수락 보낼때 B의 정보 보내줘야한다. - 토큰을 이용해 해석한 유저 정보 보내면 됨

생각해야 하는 것

  • 창을 나가더라도 정보는 저장되어있어야 함
  • 수락 여부
  • 평가 완료시 리워드 획득 : 이게 알람으로 가는것까지는 힘들고 걍 새로고침..

소켓 통신 구현

1. 의존성 추가

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-websocket</artifactId>
    <version>6.0.10</version>
</dependency>

2. 코드 작성 시작

TextWebSocketHandler를 상속받아 아래의 메서드를 사용할 수 있다.

  • WebSocketHandler의 주요 메소드

    Spring을 활용한 웹소켓 요청 처리

    - WebSocketHandler 인터페이스를 구현한 핸들러 클래스를 작성해서 구현한다.

    void handlerMwssage(WebSocketSession session, WebSocketMessage message)

    - 클라이언트로부터 메세지가 도착하면 실행된다.

    void afterConnectionEstablished(WebSocketSession session)

    - 클라이언트와 연결이 완료되고, 통신할 준비가 되면 실행된다.

    void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus)

    - 클라이언트와 연결이 종료되면 실행된다.

    void handleTransportError(WebSocketSession session, Throwable exception)

    - 메세지 전송중 에러가 발생하면 실행된다.

    - 웹소켓 요청을 처리할 때 자주 사용하는 클래스

    - TextWebSocketHandler

    - 텍스트 메세지 전용의 웹소켓핸들러를 구현할 때 사용한다.

    - handlerTextMessage(WebSocketSession session, TextMessage message)

    - 텍스트 메세지를 받았을때 실행된다.

    - BinaryWebSocketHandler

    - 바이너리 메세지 전용의 웹소켓핸들러를 구현할 때 사용한다.

    - handleBinaryMessage(WebSocketSession session, BinaryMessage message)

    - 바이너리 메세지를 받았을 때 실행된다.

3. WebSocket 연결

최초 웹 소켓 서버에 연결하면, 웹 소켓 서버에 연결된 다른 사용자들에게 접속 여부를 전달해주는 로직을 구현해보자.

채팅방에 이미 들어와있는 사용자에게 신규 멤버가 들어왔다는 것을 알려주는 것이다.

해당 로직을 구현하기 위해서는 기존 접속 사용자의 웹 소켓 세션을 전부 관리하고 있어야 한다.

세션 아이디를 key, 세션을 value로 저장하는 map 자료구조 정의한다.

웹소켓 자체는 잘 동작한다.

이제 이걸 이용해서 api 를 작성해보자.

4. 이제 api를 작성해야지

5. jwt 를 websocket 에서 어떻게 전달할까?

Spring Boot - WebSocket & JWT & Spring Security 토큰 인증

jwt 전달하는 방법을 찾다보니 이 시점에서 기존의 웹소켓 방식에서 벗어나...
자연스레 STOMP 활용을 해야한다는걸 깨닫게 되었다.....(막막)

6. STOMP 구조와 어노테이션 사용

STOMP는 HTTP와 비슷하게 frame 기반 프로토콜 command, header, body로 이루어져 있다.


(다른 블로그 긁어옴 출처는 아래 하이퍼링크)

<STOMP frame 구조>

COMMAND
header1:value1
header2:value2
Body^@

Stomp (스트리밍 텍스트 지향 메시지 프로토콜)

💙 주요 징
  • @Controller → @MessageMapping으로 연결한 후 브로커에다가 보내는데 브로커는 메모리도 가능하고 RabbitMQ, ActiveMq등도 사용이 가능하다.
  • spring은 브로커에 대한 tcp 연결을 유지하고 연결된 websocket client에게 메시지를 전달한다.
  • client는 메시지를 받고 또 메시지를 수신한다.
  • client에서 메시지를 보내면 @MessageMapping에서 받아서 처리한다.
  • 메시지를 받을 endpoint는 /endpoint/..., /endpoint/** 등을 지원한다.
  • 서버의 모든 메시지는 특정 클라이언트 구독에 대한 응답이어야 하며 서버 메시지의 subscription-id 헤더는 클라이언트 구독의 id 헤더와 동일해야한다.
💙 장점
  • raw websocket보다 더 많은 프로그래밍 모델을 지원
  • 여러 브로커(카프카, 등등)을 사용가능
  • spring framework를 사용하면 사용가능
  • 메시지 포맷을 정할 필요가 없다.
  • 애플리케이션 로직은 여러 @Controller 인스턴스로 구성될 수 있으며 주어진 연결에 대해 단일 WebSocketHandler를 사용하여 원시 WebSocket 메시지를 처리하는 대신 STOMP 대상 헤더를 기반으로 메시지를 라우팅할 수 있습니다.
  • STOMP는 커맨드, 헤더, 바디로 이루어진 프레임 단위를 정의해두었다.
  • 웹소켓만 사용할 때(왼쪽) 오고 가는 데이터는 오직 날것의 메시지 뿐이다.
  • STOMP를 사용할 때(오른쪽)는 커맨드, 헤더, 바디의 형태로 데이터가 오고간다.

스프링이 STOMP 프로토콜을 사용하고 있을 떄의 동작 흐름에 대해 살펴보자

  • 메시지를 보내려는 발신자, 메시지를 받으려는 구독자가 있다. 구독자는 /topic 이라는 경로를 구독하고 있다.
  • 발신자는 /topic을 destination 헤더로 넣어 메시지를 메시지 브로커를 통해 구독자들에게 곧바로 송신할 수 있다
  • 또는 서버 내에서 어떤 가공처리가 필요하다면 /app 경로로 메시지를 송신할 수 있다.
    • 서버가 가공처리가 끝난 데이터를 /topic이라는 경로를 담아 메시지 브로커에게 전달하면

    •  메시지 브로커는 전달받은 메시지를 /topic을 구독하는 구독자들에게 최종적으로 전달한다.

package com.websocket;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/queue","/topic");
        registry.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/gs-guide-websocket")
                .withSockJS();
    }
}
  • configureMessageBroker 메소드는 STOMP에서 사용하는 메시지 브로커를 설정하는 메소드이다.
    • enableSimpleBroker : 내장 메시지 브로커를 사용하기 위한 메소드이다.파라미터로 지정한 prefix(/queue 또는 /topic)가 붙은 메시지를 발행할 경우, 메시지 브로커가 이를 처리하게 된다.
      • /queue prefix는 메시지가 1대1로 송신될 때,/topic prefix는 메시지가 1대다로 브로드캐스팅될 때 사용하는게 컨밴션이다.
    • setApplicationDestinationPrefixes : 메시지 핸들러로 라우팅되는 prefix(/app)를 파라미터로 지정할 수 있다.> 메시지 가공 처리가 필요한 경우, 가공 핸들러로 메시지를 라우팅 되도록하는 설정이다.

Message Controller 클래스


@Controller
public class ChatController {

    @MessageMapping("info")
    @SendToUser("/queue/info")
    public String info(String message, SimpMessageHeaderAccessor messageHeaderAccessor) {
        User talker = messageHeaderAccessor.getSessionAttributes().get(SESSION).get(USER_SESSION_KEY); 
        return message;
    }

    @MessageMapping("chat")
    @SendTo("/topic/message")
    public String chat(String message, SimpMessageHeaderAccessor messageHeaderAccessor) {
        User talker = messageHeaderAccessor.getSessionAttributes().get(SESSION).get(USER_SESSION_KEY); 
        if(talker == null) throw new UnAuthenticationException("로그인한 사용자만 채팅에 참여할 수 있습니다.");
        return message;
    }

    @MessageMapping("bye")
    @SendTo("/topic/bye")
    public User bye(String message) {
        User talker = messageHeaderAccessor.getSessionAttributes().get(SESSION).get(USER_SESSION_KEY);
        return talker;
    }
}

@MessageMapping 어노테이션에 발행하는 경로를, @SendTo와 @SendToUser 어노테이션에 구독 경로를 작성합니다. 예를 들어, 특정 사용자가 chat 이라는 경로로 메세지를 보내면 /topic/message 라는 토픽을 구독하는 사용자들에게 모두 메세지를 뿌리는 것이다.

여기서 주목할 것은 @SendTo와 @SendToUser 이다. SendTo 는 1 : n 으로 메세지를 뿌릴 때 사용하는 구조이며 보통 경로가 /topic 으로 시작한다. 반면에 SendToUser 는 1 : 1 으로 메세지를 보낼 때 사용하는 구조이며 보통 경로가 /queue 로 시작한다.

(참고)

registry.enableSimpleBroker("/topic", "/queue");
: 메세지브로커를 등록하는 코드> 보통 /topic 과 /queue 를 사용하는데,
/topic 은 한명이 message 를 발행했을 때 해당 토픽을 구독하고 있는 n명에게 메세지를 뿌려야 하는 경우에 사용한다.
반면에 /queue 는 한명이 message 를 발행했을 때 발행한 한 명에게 다시 정보를 보내는 경우에 사용한다. 저는 두 개의 경우 모두 사용하기 때문에 /topic, /queue 를 모두 등록했다.

registry.setApplicationDestinationPrefixes("/");
: 도착경로에 대한 prefix 를 설정> 예를 들어,

 registry.setApplicationDestinationPrefixes("/app"); 이라고 설정해두면 /topic/hello 라는 토픽에 대해 구독을 신청했을 때 실제 경로는 /app/topic/hello 가 되는 것이다.

코드에 적용한 전체적인 동작로직 (프론트 참고)

예를 들어, 여러 사용자가 도움 요청 상태를 확인하고 도움을 수락하는 상황을 생각해보겠습니다. 각 사용자는 자신이 수락하려는 도움 요청의 고유 ID를 알고 있어야 합니다. 이를 위해 다음과 같은 방식으로 구성할 수 있습니다.

  1. 사용자 A가 도움을 요청하고 해당 요청의 고유 ID를 생성합니다.
  2. /topic/request/{helpRequestId} 경로로 메시지를 보냅니다.
  3. 사용자 B들은 /topic/request/{helpRequestId} 경로를 구독하여 도움 요청 상태를 확인합니다.
  4. 사용자 B가 특정 도움 요청을 수락하면, /app/acceptHelp/{helpRequestId} 경로로 메시지를 보냅니다.
  5. 해당 메시지를 수신한 서버에서는 도움 요청의 수락 상태를 업데이트하고, 해당 정보를 /topic/request/{helpRequestId} 경로로 다시 보냅니다.
  6. 사용자 A는 자신의 도움 요청 상태를 /topic/request/{helpRequestId} 경로를 통해 확인합니다.

이렇게 하면 다수의 사용자가 서로 다른 도움 요청에 대한 정보를 구독하고 상호작용할 수 있게 됩니다. 사용자 B들이 /queue/acceptHelp 경로를 구독할 필요는 없습니다. 대신, 도움 요청의 고유 ID를 활용하여 해당 도움 요청에 대한 정보를 주고받을 수 있습니다.

사용자 A가 요청한 도움 요청의 수락 결과를 받기 위한 경로

@SendTo("/topic/request/{helpRequestId}")을 사용하여 사용자 A의 구독 경로인 /topic/request/{helpRequestId}로 메시지를 보낼 수 있습니다. 이렇게 하면 사용자 A가 특정 도움 요청의 상태 업데이트를 실시간으로 받을 수 있습니다.

사용자 A의 도움 요청 수락 결과를 개별 큐로 보내기

@SendToUser("/queue/acceptHelp")은 특정 사용자의 개별 큐로 메시지를 보내는 역할을 합니다. 이 경우, 사용자 A가 도움 요청을 수락하면, 사용자 A의 개별 큐로 결과 메시지를 전송합니다. 다른 사용자들과 별개로 사용자 A만을 대상으로 메시지를 보낼 수 있습니다.

예를 들어, 사용자 A가 도움 요청을 수락하면 도움 요청 수락 결과를 @SendTo("/topic/request/{helpRequestId}") 경로로 모든 구독자에게 전송하면서 동시에 @SendToUser("/queue/acceptHelp") 경로로 사용자 A의 개별 큐로도 결과 메시지를 보낼 수 있습니다. 이렇게 하면 사용자 A는 전체 구독 경로를 통해 모든 사용자와 공유되는 메시지를 받으면서 동시에 개별 큐를 통해 자신에게만 해당 메시지를 받을 수 있게 됩니다.

문자 api 구현

도움 요청을 성공하면 문자 발송을 해야하기 때문에 해당 api 적용이 필요해서 일단 긁어뒀다 ( 다른 팀원께서 진행함 )

네이버 클라우드 플랫폼 문자 발송 API 사용하기 (Spring Boot)

[Spring Boot] SMS 전송 - NAVER SMS API 연동

하다보니 더 알아보면 좋을것들

  • 웹소켓과 HTTP 동작방식의 차이
  • Rabbit MQ
  • 동기와 비동기

결과 코드

(아직 테스트 중이라서 미첨부 합니다.)
-> 테스트 실패해서 다른 방법을 쓰기로 했습니다.
그건 다음 글에서...ㅠ


레퍼런스

소켓통신 말고,,SSE? 라는게 있다.

sse?

STOMP websocket

STOMP와 WebSocket으로 아주 간단한 메시징 시스템 만들기

스프링부트 웹소켓 stomp를 이용한 실시간 알림 구현

WebSocket & Spring

jwt 관련 stomp 핸들러

Spring Boot - WebSocket & JWT & Spring Security 토큰 인증

Low 한 websocket

Spring 실시간 알림(webSocket)

[개발일지7] 스프링, 소켓을 이용한 채팅 & 실시간 알림 구현

[Spring] WebSocket sockJS Q&A 실시간 알림 구현하기 (2)

마카로닉스

gpt

ChatGPT

ChatGPT

profile
해송의 벨로그

1개의 댓글

comment-user-thumbnail
2023년 8월 6일

공감하며 읽었습니다. 좋은 글 감사드립니다.

답글 달기