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에서 메시지를 보낼 경우에
특정한 처리를 하기위한 인터셉터를 지정 할 수 있음
- 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>
연결 테스트

