Spring Boot

SpringBoot + Vue3 + STOMP + mongoDB 간단 실시간 채팅 구현

pows1011 2024. 5. 8. 14:16

 

Gradle 의존성 추가

// mongoDB
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
// web socket
implementation 'org.springframework.boot:spring-boot-starter-websocket'

 

MongoDB 설정

spring:
  data:
    mongodb:
      host: localhost
      port: 27017
      authentication-database: admin
      username: MongoDB 아이디
      password: MongoDB 비밀번호
      database: MongoDB 데이터베이스명

 

 

STOMP 설정

import com.practice.common.socket.WebSocketBrokerInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
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
@RequiredArgsConstructor
public class WebSocketBrokerConfig  implements WebSocketMessageBrokerConfigurer {  // WebSocketMessageBrokerConfigurer 를 상속받아 STOMP 로 메시지 처리 방법을 구성

    private final WebSocketBrokerInterceptor interceptor;
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) { // 메시지를 중간에서 라우팅할 때 사용하는 메시지 브로커를 구성
        registry.enableSimpleBroker("/sub");  // 해당 주소를 구독하는 클라이언트에게 메시지를 보낸다, 즉 인자에는 구독 요청의 prefix를 넣고
        // 클라이언트에서 1번 채널을 구독하고자 할 때는 /sub/1형식과 같은 규칙을 따라야 한다.
        registry.setApplicationDestinationPrefixes("/pub");     // 메시지 발행 요청의 Prefix를 넣는다 즉, /pub로 시작하는 메시지만 해당 Broker에서 받아서 처리한다.
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(interceptor); // WebSocket이 연결되거나, sub/pub/send등 Client에서 메시지를 보내게 될 때 interceptor를 통해 핸들링
    }

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

}
  • @EnableWebSocketMessageBroker
    • 해당 프로젝트에서 WebSocket을 사용하겠다는 것을 나타냄
  • implements WebSocketMessageBrokerConfigurer
    • STOMP를 사용하기 위한 설정이 들어있는 인터페이스를 상속받아서 구현
  • configureMessageBroker
    • 메시지를 중간에서 라우팅 하기 위해 사용하는 메시지 브로커
    • 메시지 브로커에서 데이터를 주고 받기 위한 주소를 설정할 수 있음
    • ex ) 메시지를 전송 = /pub/~~~ , 받은 메시지를 구독한 사람들에게 전송  = /sub/~~~
  • configureClientInboundChannel
    • WebScoket이 연결되거나, sub/pub/send 등 client에서 메시지를 보낼 경우에
      특정한 처리를 하기위한 인터셉터를 지정 할 수 있음
  • registerStompEndpoints
    • 클라이언트에서 Websocket에 접속할 수 있는 endpint를 지정하기 위한 용도
    • /ws/init 일경우 ws://localhost:8080/ws/init이라는 주소로 소켓서버에 연결이 가능
    • setAllowedOrigins로 접근 가능한 host를 지정할 수 있음.

 

 

 

ChatDTO  ( Message 관련 Request 데이터를 받기 위한 Class )

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Data
@RequiredArgsConstructor
@AllArgsConstructor
public class ChatDto {
    private String message;
    private String writer;

    private Long memberNo;
    private Long roomNo;
}

 

 

Message Controller ( pub Message Mapping )

import com.practice.common.socket.Chat;
import com.practice.common.socket.ChatDto;
import com.practice.service.ChatService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Slf4j
@RestController
@RequiredArgsConstructor
public class MessageController {

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

    @MessageMapping("/room/{roomNo}/entered") // 해당 경로 + config에 설정한 prefix값 /pub가 합쳐셔 /pub/roomNo/message가 주소가된다.
    public void entered(@DestinationVariable(value = "roomNo") Long roomNo, final ChatDto message) {

        final String payload = message.getWriter() + "님이 입장하셨습니다.";

        List<Chat> chat = chatService.findChat(roomNo);
        for (Chat item : chat) {
            userMessageTemplate(item.getMessage(), roomNo, item.getMemberNo(), message.getMemberNo());
        }
        roomMessageTemplate(payload, message.getMemberNo(), roomNo);

    }


    @MessageMapping("/room/{roomNo}")
    public void sendMessage(@DestinationVariable(value = "roomNo") Long roomNo, ChatDto message) {
        chatService.insertMessage(message);
        roomMessageTemplate(message.getMessage(), message.getMemberNo(), roomNo);
    }

    @MessageMapping("/room/{roomNo}/leave")
    public void leave(@DestinationVariable(value = "roomNo") Long roomNo, ChatDto message) {
        final String payload = message.getWriter() + "님이 방을 떠났습니다.";
        roomMessageTemplate(payload, message.getMemberNo(), roomNo);
    }


    private void roomMessageTemplate(String message, Long memberNo, Long roomNo) {
        Map<String, Object> headerMap = messageWriterHeader(memberNo);

        simpMessagingTemplate.convertAndSend(DEFAULT_URL + roomNo, message, headerMap);
    }

    private void userMessageTemplate(String message, Long roomNo, Long chatWriterNo, Long memberNo) {
        Map<String, Object> headerMap = messageWriterHeader(chatWriterNo);

        simpMessagingTemplate.convertAndSend(DEFAULT_URL + roomNo + "/" + memberNo, message, headerMap);
    }

    private Map<String, Object> messageWriterHeader(Long writerNo) {
        Map<String, Object> headerMap = new HashMap<>();
        headerMap.put("memberNo", writerNo);
        return headerMap;
    }
}
  • MessageController에서는 소켓 통신의 pub에 대한 주소Mapping을 해주는 곳이다.
  • @MessageMapping이라는 어노테이션을 사용하며, 지정한 주소 앞에 /pub ( config에서 설정해준 Prefix )값이 생략되어 있다 생각하면 쉽다. 실제 프론트에서 소켓 연결을 할때는 /pub/ Mapping주소를 입력해주면 통신이 가능하다.
    • ex ) /pub/room/1/lentered = 1번 채팅방에 입장
    • ex ) /pub/room/2 = 2번 채팅방에 메시지를 전송
  • 해당 프로젝트에서는 간단하게 채팅 입장 / 메시지 전송 / 채팅 퇴장에 대한 Mapping을 설정해놓음
  • 각각 맵핑별로 전송하는 메시지들이 있는데, 전송하는 메시지들의 주소는 /sub ( config에 설정한 Prefix ) 로 시작하는 주소로 보낸다고 보면 쉽다.
    • ex ) /sub/room/1 = 1번 채팅방을 입장한 모두에게 메시지를 전송
    • ex ) /sub/room/1/ 17 ( memberNo임 ) = 1번 채팅방에 memberNo 17번을 구독하고있는 사용자에게 채팅을 전송이런식으로 유동적으로 특정 사용자들만 주소를 구독하게하여 채팅을 채팅방 전체가아닌 개별 사용자에게만 전송 시킬 수도 있다.

 

MongoDB Entity

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import org.springframework.data.mongodb.core.mapping.Document;

import javax.persistence.Id;

@ToString
@NoArgsConstructor
@Getter
@Document(collection  ="chat" )
public class Chat {

    @Id
    private String id;
    private String message;
    private String writer;
    private Long memberNo;
    private Long roomNo;
}
  • @Document(colletion)을 통해 데이터베이스의 어떤 컬렉션에 데이터를 넣을것인지 선정

 

MongoDB Repository , ChatService

채팅을 저장하거나 각 채팅방별 기존에 저장되어있는 채팅을 가져오기 위한 용도

public interface ChatRepository extends MongoRepository<Chat,String> {


    public List<Chat> findChatByRoomNo(Long roomNo);
}

 

import com.practice.common.socket.Chat;
import com.practice.common.socket.ChatDto;
import com.practice.common.socket.ChatRepository;
import com.practice.jooq.dao.ChatDao;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@RequiredArgsConstructor
@Service
@Slf4j
public class ChatService {

    private final ChatDao chatDao;
    private final ChatRepository chatRepository;
    private final ObjectMapper objectMapper;


    @Transactional
    public void insertMessage(ChatDto reqData) {
        Chat entity = objectMapper.convertValue(reqData, Chat.class);
        chatRepository.insert(entity);
    }

    @Transactional(readOnly = true)
    public List<Chat> findChat(Long roomNo) {

        return chatRepository.findChatByRoomNo(roomNo);
    }


}

 

 

 

 

 

프론트엔드 설정 ( Vue 3  )

 

 

 

<script setup>
import { onBeforeMount, onMounted, reactive } from 'vue';
import { useStore } from 'vuex';
import { useRouter, useRoute } from 'vue-router';
const { replace, push } = useRouter();
const { dispatch, commit, getters } = useStore();
import { Client } from '@stomp/stompjs';
import { isEmpty } from '@/common/utils/Util';
const { params } = useRoute();
const router = useRouter();
const param = reactive({
  messageList: [],
  message: '',
  roomNo: params.roomNo,   // Vue Router에서 선택한 채팅방의 번호를 받아옴
  writer: '',
  memberNo: -1
});

const messageList = reactive([]);

let websocketClient = '';

onBeforeMount(async () => {
  param.memberNo = getters['member/getMemberNo'];
  if (isEmpty(param.memberNo)) {   // 값이 비어있거나 0보다 작다면 로그인화면으로
    alert('로그인이 필요합니다');
    await router.replace('/member/login');
    return;
  }
  param.writer = getters['member/getMemberName'];
});
onMounted(() => {
  connect();
});

const send = async () => {
  if (!websocketClient) return;

  await websocketClient.publish({
    destination: `/pub/room/${param.roomNo}`,
    body: JSON.stringify({
      message: param.message,
      roomNo: param.roomNo,
      memberNo: param.memberNo
    })
  });
  param.message = '';
};

const close = async () => {
  if (!websocketClient) return;

  await websocketClient.publish({
    destination: `/pub/room/${param.roomNo}/leave`,
    body: JSON.stringify({
      writer: param.writer,
      roomNo: param.roomNo,
      memberNo: param.memberNo
    })
  });
  await router.replace('/room');
};
const connect = () => {
  const url = 'ws://localhost:8080/ws/init';
  websocketClient = new Client({
    brokerURL: url,
    onConnect: async () => {
      await websocketClient.subscribe(`/sub/room/${param.roomNo}/${param.memberNo}`, msg => {
        messageBinding(msg);
      });  // 기존의 저장된 메시지들을 입장 멘트가 출력되기전에 먼저 가져옴.
      // 입장자가 구독한 sub 주소별로 메시지 개별 전송이 가능한 점을 이용.
      await websocketClient.subscribe(`/sub/room/${param.roomNo}`, msg => {
        messageBinding(msg);
      }); // 입장한 채팅방에 전송되는 메시지들을 출력받기 위한 서버를 구독
      await websocketClient.publish({
        destination: `/pub/room/${param.roomNo}/entered`,
        body: JSON.stringify({ writer: param.writer, memberNo: param.memberNo })
      }); // 나 포함 해당 채팅방을 구독중인 사용자들에게 입장 메시지를 출력
    },
    onWebSocketError: () => {}
  });
  websocketClient.activate();
};

const messageBinding = msg => { // 서버에서 넘어온 메시지 정보를 바인딩하기위한 함수
  const messageBody = {
    memberNo: Number(msg.headers.memberNo),
    text: msg.body
  };
  messageList.push(messageBody);
};
</script>

 

 

 

 

연결 테스트