본문 바로가기
코딩 공부/web & Java

[Spring / WebSocket] WebSocket와 STOMP

by 현장 2024. 10. 5.

WebSocket

https://velog.io/@msung99/%EC%9B%B9%EC%86%8C%EC%BC%93%EC%9D%B4%EB%9E%80

WebSocket은 클라이언트와 서버를 연결하고 실시간으로 통신이 가능하게 하는 프로토콜입니다.

기존 HTTP 통신은 요청을 보내야만 요청을 받는 단방향 통신이고, Stateless(상태를 저장하지 않는) 방식이였습니다. 하지만 WebSocket은 양방향 통신으로 연결이 이루어지면 클라이언트가 별도의 요청을 보내지 않아도 데이터를 송신할 수 있으며, 상태를 유지하는 Stateful 프로토콜입니다.

기존 HTTP 같이 양쪽 방향으로 송수신이 가능한 양방향 통신이지만 한 번에 하나의 전송만 이루어지도록 설정된 것을 반이중 통신(Half Duplex)이라하고, WebSocket 같이 데이터를 동시에 양방향으로 송수신 할 수 있는 것을 전이중 통신(Full Duplex)라고 합니다.

만약 Notion, Google Docs 같이 여러 사용자가 동시에 한 문서를 편집하면 새로고침을 누르지 않아도 실시간으로 다른 사용자들이 편집한 부분이 자동적으로 적용되는 모습도 WebSocket을 이용한 기술입니다.

WebSocket은 최초 연결 요청 시 HTTP를 통해 웹 서버에 요청(HandShake)한다. 이후 HandShake 성공 시, 통신 프로토콜이 WebSocket(ws)로 변경된다.

Handshake란?
핸드셰이크는 통신에서 연결을 설정하기 위한 과정입니다. 이때 두 통신 장치 간 데이터 교환 규칙, 속도, 보안 설정 등의 파라미터를 협상하며, 핸드셰이크의 목적은 아래와 같습니다.

▪️ 연결 설정 : 통신을 시작하기 전에 두 장치는 서로 연결되어 있음을 확인하고 연결을 설정합니다.
▪️ 파라미터 협상 : 통신에 사용되는 속도, 프로토콜, 데이터 형식 등의 파라미터를 협상하고 동기화합니다.
▪️ 인증 및 보안 : 필요한 경우 두 장치는 인증을 하고 보안 관련 파라미터를 설정합니다.

STOMP(Simple/Stream Text Oriented Message Protocol)

WebSocket 위에서 동작하는 프로토콜로써 클라이언트와 서버가 전송할 메세지의 유형, 형식, 내용들을 정의하는 매커니즘입니다. 

 

규격을 갖춘 메시지를 보낼 수 있는 텍스트 기반 프로토콜로 Publisher, Broker, Subscriber를 따로 두어 처리 (pub / sub 구조)합니다. 또한, 연결시에 헤더를 추가하여 인증 처리 구현이 가능하며, STOMP 스펙에 정의한 규칙만 잘 지키면 여러 언어 및 플랫폼 간 메세지를 상호 운영할 수 있습니다.

 

예를 들어, 우체통(Topic)이 있다고 하였을때, 집배원(Publisher) 신문(message)을 우체통에 배달하는 행위가 있고, 우체통에 신문이 배달되는 것을 기다렸다가 빼서 보는 구독자(Subscriber)의 행위가 있다.

  • 채팅방 생성: pub/sub 구현을 위한 Topic 생성
  • 채팅방 입장: Topic 구독
  • 채팅방에서 메세지를 송수신: 해당 Topic으로 메세지를 송신(pub), 메세지를 수신(sub)

 

Publish, Subscrib command 요청 Frame에는 메세지가 무엇이고, 누가 받아서 처리할지에 대한 Header 정보가 포함되어 있습니다. 이런 명령어들은 destination 헤더를 요구하는데 어디에 전송할지, 혹은 어디에서 메세지를 구독할 것 인지를 나타내며, 위와 같은 과정을 통해 STOMP는 Publish-Subscribe 매커니즘을 제공합니다.

 

즉, Broker를 통해 타 사용자들에게 메세지를 보내거나 서버가 특정 작업을 수행하도록 메세지를 보낼 수 있게 됩다.

🏷️ 사용한 코드

✅ Gradle 의존성 추가

// websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'

✅ Spring 코드

@RequiredArgsConstructor
@EnableWebSocketMessageBroker
@Configuration
public class WebSocketBrokerConfig implements WebSocketMessageBrokerConfigurer {

    private final JwtTokenProvider jwtTokenProvider; // JWT를 검증하는 로직을 가진 서비스

    // 메시지를 중간에서 라우팅할 때 사용하는 메시지 브로커를 구성
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 메시지를 전송할 브로커 경로
        // 클라이언트에서 1번 채널을 구독하고자 할 때는 /sub/1형식과 같은 규칙을 따라야 한다.
        registry.enableSimpleBroker("/sub");

        // 클라이언트가 메시지를 전송할 경로.
        // 즉, /pub로 시작하는 메시지만 해당 Broker에서 받아서 처리한다.
        registry.setApplicationDestinationPrefixes("/pub");
    }

    // 클라이언트에서 WebSocket에 접속할 수 있는 endpoint를 지정
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // 클라이언트가 연결할 엔드포인트
        registry.addEndpoint("/ws/init")  // ex) ws://localhost:8080/ws/init
                .setAllowedOriginPatterns("*");
    }

    // JWT를 사용하기 때문에 Token을 검사
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new JwtChannelInterceptor(jwtTokenProvider));
    }
}
@EnableWebSocketMessageBroker란?
메시지 브로커가 지원하는 ‘WebSocket 메시지 처리’를 활성화한다.
@Slf4j
@RequiredArgsConstructor
@RestController
public class ChatController {

    private final ChatService chatService;
    // 해당 객체를 통해 메시지 브로커로 데이터를 전송한다
    private final SimpMessagingTemplate simpMessagingTemplate;
    final String DEFAULT_URL = "/sub/chat/room/";

    // 채팅룸 접속 주소
    @MessageMapping("/chat/room/{chatRoomId}/entered")
    public void enteredChatRoom(
            @DestinationVariable(value = "chatRoomId") Long chatRoomId
    ) {
        log.info("# roomId : {}", chatRoomId);
        // 기존 채팅 내역 가져옴
        List<ChatMessageResponse> chatMessageList
                = chatService.chatMessageListByChatRoomId(chatRoomId)
                .stream()
                .map(ChatMessageResponse::fromChatMessageDto)
                .toList();

        log.info("# chatMessageList : {}", chatMessageList);

        for (ChatMessageResponse message : chatMessageList) {
            System.out.println("메시지 전송: " + message.getMessage());
            messageTemplate(chatRoomId, message);
        }
    }
    
    // 메세지 전송
    @MessageMapping("/chat/room/{chatRoomId}")
    public void sendMessage(
            @DestinationVariable(value = "chatRoomId") Long chatRoomId,
            SendMessageRequest request
    ) {
        log.info("# chatRoomId : {}", chatRoomId);
        log.info("# message : {}", request.getMessage());
        // db에 저장
        ChatMessageDto chatMessageDto =
                chatService.newChatMessage(request.getChatRoomId(), request.getSendUserId(), request.getMessage());
        
        // 채팅 메세지를 chatRoomId로 템플릿에 전송
        ChatMessageResponse response = ChatMessageResponse.fromChatMessageDto(chatMessageDto);

        messageTemplate(chatRoomId, response);
    }
    
    // 템플릿 셋팅
    private void messageTemplate(Long chatRoomId, ChatMessageResponse message) {
        // 위에 설정한 디폴드 URL과 chatRoomId를 합친 경로로 브로커에게 메세지 전송
        simpMessagingTemplate.convertAndSend(DEFAULT_URL + chatRoomId, message);
    }
}

✅ Frontend 코드

  import { Client } from '@stomp/stompjs';
  import apiClient from '../../../config/authConfig';
  import { useAuthStore } from '../../../store/authStore';
  
  let websocketClient = '';
  
  const authStore = useAuthStore();
  const userId = computed(() => authStore.userId);
  
  const chatRoomList = ref([]);
  const chatMessageList = ref([]);
  
  const nowChatUserId = ref('');
  const nowChatRoomId = ref(0);
  const message = ref('');

  const sendMessage = async () => {
    // 채팅방 선택 안된 상태에서 메세지 전송한 경우 알림
    if (!websocketClient) {
      alert('채팅방을 선택해 주세요.');
      return;
    }

    await websocketClient.publish({
      destination: `/pub/chat/room/${nowChatRoomId.value}`,
      body: JSON.stringify({
        sendUserId: userId.value,
        chatRoomId: nowChatRoomId.value,
        message: message.value
      })
    });


    message.value = '';
  }

  const connect = (chatRoomId, user1, user2, date) => {
    setActive(chatRoomId);

    // 다른 채팅을 눌렀을 때, 이전에 연결된 웹소켓 종료
    if (websocketClient && websocketClient.active) {
      websocketClient.deactivate();  // 연결을 종료
      nowChatUserId.value = '';
      console.log('연결 종료');
    }

    nowChatRoomId.value = chatRoomId;
    chatMessageList.value = [];
   
    // 주소 설정으로 지금 주소가 http인 경우 ws, https인 경우 wss 사용
    const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
    // 배포 환경에 따른 포트 설정 wss의 경우 default가 443이기 때문세 설정
    const port = window.location.protocol === 'https:' ? '' : ':8080';
    // 위의 내용을 합쳐서 주서 설정
    const url = `${protocol}${window.location.hostname}${port}/ws/init`;

    const token = authStore.token;

    websocketClient = new Client({
      brokerURL: url,
      connectHeaders: {
        Authorization: `Bearer ${token}`
      },
      onConnect: async () => {
        console.log('onConnect 실행')
        
        await websocketClient.subscribe(`/sub/chat/room/${chatRoomId}`, async msg => {
          try {
            const messageBody = JSON.parse(msg.body);
            chatMessageList.value.push(messageBody);
          } catch (error) {
            console.log('메세지를 가져오는데 에러가 발생했습니다. ', error);
            alert('메세지를 가져오는데 에러가 발생했습니다.');
          }

          await nextTick();
        });
        
        // 연결 되면 이전 메세지 내용 가져오기
        await websocketClient.publish({
          destination: `/pub/chat/room/${chatRoomId}/entered`,
          body: JSON.stringify({ chatRoomId: chatRoomId })
        });

      },
      onStompError: (frame) => {
        console.error('STOMP error:', frame);
      },
      onWebSocketError: (error) => {
        console.error('웹 소켓 error:', error);
      }
    });

    websocketClient.activate();
  };

useAutnStore와 관련된 내용은 여기에 들어가시면 나옵니다.

Stomp.js란?
JavaScript에서 STOMP 프로토콜을 사용하여 WebSocket 기반의 통신에 도움을 주는 라이브러리입니다.