Spring Boot 환경에서 WebSocket을 이용해 채팅을 구현해보기 위한 포스트이다.
WebSocket 프로토콜은 두 가지 유형의 메세지를 정의하고 있지만 그 메세지의 내용까지는 정의하고 있지 않는다.
STOMP (Simple Text Oriented Messaging Protocol)은 메세징 전송을 효율적으로 하기 위해 탄생한 프로토콜이고, 기본적으로 pub / sub 구조로 되어있어 메세지를 전송하고 메세지를 받아 처리하는 부분이 확실히 정해져 있기 때문에 개발자 입장에서 명확하게 인지하고 개발할 수 있는 이점이 있다. 한 줄로 정의하자면, STOMP 프로토콜은 WebSocket 위에서 동작하는 프로토콜로써 클라이언트와 서버가 전송할 메세지의 유형, 형식, 내용들을 정의하는 매커니즘이다.
또한 STOMP를 이용하면 메세지의 헤더에 값을 줄 수 있어 헤더 값을 기반으로 통신 시 인증 처리를 구현하는 것도 가능하며 STOMP 스펙에 정의한 규칙만 잘 지키면 여러 언어 및 플랫폼 간 메세지를 상호 운영할 수 있다.
다음과 같은 의존성을 추가해주어야 한다.
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:sockjs-client:1.1.2'
implementation 'org.webjars:stomp-websocket:2.3.3-1'
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
//endpoint를 /stomp로 하고, allowedOrigins를 "*"로 하면 페이지에서
//Get /info 404 Error가 발생한다. 그래서 아래와 같이 2개의 계층으로 분리하고
//origins를 개발 도메인으로 변경하니 잘 동작하였다.
//이유는 왜 그런지 아직 찾지 못함
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/stomp/chat")
.setAllowedOrigins("http://localhost:8090")
.withSockJS();
}
/*어플리케이션 내부에서 사용할 path를 지정할 수 있음*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/pub");
registry.enableSimpleBroker("/sub");
}
}
@EnableWebSocketMessageBroker
-> Stomp를 사용하기 위해 선언하는 어노테이션
setApplicationDestinationPrefixes
-> Client에서 SEND 요청을 처리
enableSimpleBroker
-> 해당 경로로 SimpleBroker를 등록. SimpleBroker는 해당하는 경로를 SUBSCRIBE하는 Client에게 메세지를 전달하는 간단한 작업을 수행
enableStompBrokerRelay
-> SimpleBroker의 기능과 외부 Message Broker(RabbitMQ, ActiveMQ, ...)에 메세지를 전달하는 기능을 가짐
@Slf4j
@RequiredArgsConstructor
@Controller
public class StompChatController {
private final ChatService chatService;
private final SimpMessagingTemplate template;
@MessageMapping(value = "/chat/enter")
public void enter(ChatMessageCreateDto message){
log.info("enter()");
message.setMessage(message.getLoginId() + "님이 채팅방에 참여하였습니다.");
template.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
}
@MessageMapping(value = "/chat/message")
public void message(ChatMessageCreateDto message){
log.info("message(message = {})", message);
chatService.create(message);
template.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
}
@MessageMapping("/sub/chat/room/{roomId}")
@SendTo("/pub/chat/room/{roomId}")
public List<ChatMessage> handleMessages(List<ChatMessage> messages) {
return messages;
}
}
@Slf4j
@RequiredArgsConstructor
@Controller
@RequestMapping("/chat")
public class ChatRoomController {
private final UserService userService;
private final ChatService chatService;
/**
* 오픈 채팅 목록을 불러옴
* @param model
* @return
*/
@GetMapping("/list")
public String getList(Model model) {
log.info("getList()");
List<ChatRoomDto> list = chatService.findAllRooms();
model.addAttribute("rooms", list);
return "chat/list";
}
/**
* 새로운 오픈 채팅방을 개설함
* @param name
* @param principal
* @return
*/
@PostMapping("/room/create")
public String create(@RequestParam("name") String name, Principal principal) {
log.info("create(name = {})", name);
String loginId = "";
if(principal != null) {
loginId = principal.getName();
}
chatService.create(name, loginId);
return "redirect:/chat/list";
}
/**
* 특정 오픈 채팅방을 불러옴
* @param id
* @param principal
* @param model
* @return
*/
@GetMapping("/room/{id}")
@PreAuthorize("isAuthenticated()")
public String room(@PathVariable("id") long id, Principal principal, Model model) {
log.info("room(id = {})", id);
User user = userService.findUserByLoginId(principal.getName());
model.addAttribute("user", user);
ChatRoom room = chatService.getRoom(id);
model.addAttribute("room", room);
return "chat/room";
}
}
@Data
public class ChatMessageCreateDto {
private long roomId;
private String loginId;
private String message;
}
public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
@Query("select new com.example.forest.dto.chat.ChatRoomDto(cr.id, cr.name, u as creator, cr.modifiedTime) "
+ " from ChatRoom cr, User u "
+ " where cr.creatorId = u.id "
+ " order by cr.modifiedTime desc")
List<ChatRoomDto> findAllRooms();
@Query("select new com.example.forest.dto.chat.ChatRoomDto(cr.id, cr.name, u as creator, cr.modifiedTime) "
+ " from ChatRoom cr, User u "
+ " where cr.creatorId = u.id "
+ " and lower(cr.name) LIKE lower('%' || :keyword || '%') "
+ " order by cr.modifiedTime desc")
List<ChatRoomDto> findAllRoomsByKeyword(@Param("keyword") String keyword);
}
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity6"
layout:decorate="~{layout/base_layout}">
<div layout:fragment="main" class="mt-5 p-4">
<div class="container">
<div id="chatting-room" class="border border-dark rounded-2 w-50" style="margin: 0 auto;">
<div class="p-2">
<div>
<h1 th:text="${room.name}"></h1>
</div>
<div id="messages" class=" overflow-auto" style="height: 700px;">
</div>
<div class="input-group mt-1">
<input type="text" id="msg" class="form-control" placeholder="메세지를 입력하세요.">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="btnSend">전송</button>
</div>
</div>
<div class="mt-1">
<a href="/chat/list" class="btn btn-outline-danger">Exit</a>
</div>
</div>
</div>
<input type="hidden" id="roomId" th:value="${room.id}" />
<input type="hidden" id="loginId" th:value="${#authentication.principal.username}"/>
</div>
</div>
<th:block layout:fragment="myscripts">
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="/js/chat/chat-message.js"></script>
</th:block>
</html>
document.addEventListener("DOMContentLoaded", function () {
const roomId = document.querySelector('input#roomId').value;;
const loginId = document.querySelector('input#loginId').value;
console.log(roomId + ", " + loginId);
const sockJs = new SockJS("/stomp/chat");
// SockJS를 내부에 들고 있는 stomp를 내어줌
const stomp = Stomp.over(sockJs);
// connection이 맺어지면 실행
stomp.connect({}, function () {
console.log("STOMP Connection");
stomp.subscribe("/sub/chat/room/" + roomId, function (chat) {
loadChatMessages();
});
stomp.send('/pub/chat/enter', {}, JSON.stringify({roomId: roomId, loginId: loginId}));
});
// 메세지 전송했을 때 처리하는 메서드
const sendMessage = (e) => {
const msg = document.querySelector('input#msg');
console.log(loginId + ":" + msg.value);
stomp.send('/pub/chat/message', {}, JSON.stringify({roomId: roomId, message: msg.value, loginId: loginId}));
msg.value = '';
};
const sendBtn = document.querySelector('button#btnSend');
sendBtn.addEventListener('click', sendMessage);
// 날짜 데이터 formatting
const formatDateTime = (dateTimeString) => {
const options = { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' };
return new Date(dateTimeString).toLocaleDateString('ko-KR', options);
}
// 채팅 메세지를 불러오기 위한 메서드
const loadChatMessages = async () => {
const url = `/api/v1/chat/messages/${roomId}`;
const response = await axios.get(url);
console.log(response.data);
let htmlStr = '';
for(let chat of response.data) {
const formattedTime = formatDateTime(chat.createdTime);
if(chat.sender.loginId == loginId) {
htmlStr += `
<div class="d-flex align-items-center alert alert-warning border border-dark">
<div class="flex-shrink-0">
<img src="https://storage.googleapis.com/itwill-forest-bucket/bd64b031-42c8-4229-a734-b8d25f0fd9f0" alt="..." style="width: 50px; height: 50px;">
</div>
<div class="flex-grow-1 ms-3">
<h5>${chat.sender.nickname}</h5>
<p>${chat.content}</p>
<p>${formattedTime}</p>
</div>
</div>
`;
} else {
htmlStr += `
<div class="d-flex align-items-center alert alert-primary border border-dark">
<div class="flex-shrink-0">
<img src="https://storage.googleapis.com/itwill-forest-bucket/bd64b031-42c8-4229-a734-b8d25f0fd9f0" alt="..." style="width: 50px; height: 50px;">
</div>
<div class="flex-grow-1 ms-3">
<h5>${chat.sender.nickname}</h5>
<p>${chat.content}</p>
<p>${formattedTime}</p>
</div>
</div>
`;
}
}
// 채팅 내역을 불러올 때 가장 최근 메세지가 보이도록 함
const messageArea = document.querySelector('div#messages');
messageArea.innerHTML = htmlStr;
messageArea.scrollTop = messageArea.scrollHeight;
};
// 채팅 메세지를 불러옴
loadChatMessages();
});
//Get /info 404 Error가 발생한다. 그래서 아래와 같이 2개의 계층으로 분리하고
해당 내용에서 저도 동일증상 겪었네요
maven빌드, spring-websocket 4.3.25.RELEASE
registry.addEndpoint("/websocket").setAllowedOrigins("").addInterceptors(new HttpHandshakeInterceptor());
=> wscat -c ws://{IP:PORT}/websocket/websocket
registry.addEndpoint("/test").setAllowedOrigins("").addInterceptors(new HttpHandshakeInterceptor());
=> wscat -c ws://{IP:PORT}/test/websocket
이런식으로 뒤에 websocket이 붙어야 정상동작 되더라고요
이전 버전에서는
=> wscat -c ws://{IP:PORT}/websocket 으로 정상동작 되었구요
혹시 해결하셨으면 답글 부탁드립니다.
글 잘 봤습니다.