STOMP는 Simple Text Oriented Messaging Protocol의 약자로 메시지 전송을 위한 프로토콜 입니다.
기본적인 WebSocket 과 가장 크게 다른 점은 기존의 WebSocket 만을 사용한 통신은 발신자와 수신자를 Spring 단에서 직접 관리를 해야만 했습니다. 즉, webSocketHandler 를 만들어서 WebSocket 통신을 하는 사용자들을 저장하고 이를 직접 관리하며 클라이언트에서 들어오는 메시지를 다른 사용자에게 전달하는 코드를 직접 구현해야만 했습니다.
하지만 STOMP는 다릅니다. STOMP 는 pub/sub 기반으로 동작하기 때문에 메시지의 송,수신에 대한 처리를 명확하게 정의 할 수 있습니다. 이말인 즉슨 추가적으로 코드 작업할 필요 없이 @MessagingMapping 같은 어노테이션을 사용하여 메시지 발행 시 엔드포인트만 조정해줌으로써 훨씬 쉽게 메시지 전송/수신이 가능합니다.
pub ,sub 의 개념
- pub : 송신, sub : 수신
- 채팅방 생성 : pub/sub 구현을 위한 Topic 생성 -> 즉 채팅방과 그에 맞는 주제 혹은 채팅방 명을 생각하면 됩니다.
- 채팅방 입장 : Topic 구독(sub) -> 해당 채팅방을 웹 소켓이 연결되어있는 동안 구독합니다.
- 구독의 개념은 해당 채팅방을 지속적으로 바라본다고 생각하면 좋습니다.
- 지속적으로 연결되고 바라보고 있기 때문에 새로운 채팅이 송신(pub) 되면 이를 수신(구독,sub)할 수 있습니다.
- 채팅방 메시지 수신 : 해당 Topic으로 메시지 송신(pub) -> 해당 채팅방으로 메시지를 송신(pub) 합니다.
//websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
//sockjs
implementation 'org.webjars:sockjs-client:1.5.1'
//stomp
implementation 'org.webjars:stomp-websocket:2.3.4'
//gson
implementation 'com.google.code.gson:gson:2.9.0'
package org.codej.websocket.config;
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 SpringConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// stomp 접속 url -> /ws-stomp
registry.addEndpoint("ws-steomp")//연결될 엔드포인트
.withSockJS(); //SocketJS를 연결한다는 설정
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 메시지를 구독하는 요청 url -> 메시지를 받을 때
registry.enableSimpleBroker("/sub");
// 메시지를 발행하는 요청 url -> 메시지를 보낼 때
registry.setApplicationDestinationPrefixes("/pub");
}
}
package org.codej.websocket.domain;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class ChatDto {
// 메시지 타입 : 입장 채팅
// 메시지 타입에 따라서 동작하는 구조가 달라진다.
// 입장과 퇴장 ENTER 과 LEAVE 의 경우 입장/퇴장 이벤트 처리가 실행되고,
// TALK 는 말 그대로 해당 채팅방을 sub 하고 있는 모든 클라이언트에게 전달됩니다.
public enum MessageType{
ENTER, TALK,LEAVE
}
private MessageType type; //메시지 타입
private String roomId;// 방 번호
private String sender;//채팅을 보낸 사람
private String message;// 메세지
private String time; // 채팅 발송 시간
}
package org.codej.websocket.domain;
import lombok.Data;
import java.util.HashMap;
import java.util.UUID;
@Data
public class ChatRoom {
private String roomId; // 채팅방 아이디
private String roomName;// 채팅방 이름
private long userCount; // 채팅방 인원수
private HashMap<String,String> userList = new HashMap<>();
public ChatRoom create(String roomName){
ChatRoom chatRoom = new ChatRoom();
chatRoom.roomId = UUID.randomUUID().toString();
chatRoom.roomName = roomName;
return chatRoom;
}
@PostConstruct
: 이 어노테이션은 의존성 주입이 이루어진 후 초기화 작업이 필요한 메서드에 사용됩니다.package org.codej.websocket.repository;
import lombok.extern.slf4j.Slf4j;
import org.codej.websocket.domain.ChatRoom;
import org.springframework.stereotype.Repository;
import javax.annotation.PostConstruct;
import java.util.*;
@Repository
@Slf4j
//추후 DB와 연결 시 Service 와 Repository 로 분리 예정
public class ChatRepository {
private Map<String, ChatRoom> chatRoomMap;
@PostConstruct
public void init(){
chatRoomMap = new HashMap<>();
}
// 전체 채팅방 조회
public List<ChatRoom> findAllRoom(){
//채팅방 생성 순서를 최근순으로 반환
List chatRooms = new ArrayList<>(chatRoomMap.values());
Collections.reverse(chatRooms);
return chatRooms;
}
// roomId 기준으로 채팅방 찾기
public ChatRoom findByRoomId(String roomId){
return chatRoomMap.get(roomId);;
}
// roomName 으로 채팅방 만들기
public ChatRoom createChatRoom(String roomName){
//채팅방 이름으로 채팅 방 생성후
ChatRoom chatRoom = new ChatRoom().create(roomName);
//map에 채팅방 아이디와 만들어진 채팅룸을 저장
chatRoomMap.put(chatRoom.getRoomId(), chatRoom);
return chatRoom;
}
// 채팅방 인원 +1
public void increaseUser(String roomId){
ChatRoom chatRoom = chatRoomMap.get(roomId);
chatRoom.setUserCount(chatRoom.getUserCount()+1);
}
// 채팅방 인원 -1
public void decreaseUser(String roomId){
ChatRoom chatRoom = chatRoomMap.get(roomId);
chatRoom.setUserCount(chatRoom.getUserCount()-1);
}
//채팅방 유저 리스트에 유저추가
public String addUser(String roomId, String userName){
ChatRoom chatRoom = chatRoomMap.get(roomId);
String userUUID = UUID.randomUUID().toString();
//아이디 중복 확인 후 userList에 추가
chatRoom.getUserList().put(userUUID,userName);
return userUUID;
}
// 채팅방 유저 이름 중복 확인
public String isDuplicateName(String roomId,String username){
ChatRoom chatRoom = chatRoomMap.get(roomId);
String temp = username;
// 만약 username이 중복이라면 랜덤한 숫자를 붙여준다.
// 이 때 랜덤한 숫자를 붙였을때 getUserList 안에 있는 닉네임이라면 다시 랜덤한 숫자 붙이기
while(chatRoom.getUserList().containsValue(temp)){
int ranNum = (int) (Math.random() * 100) + 1;
temp = username+ranNum;
}
return temp;
}
// 채팅방 유저 리스트 삭제
public void deleteUser(String roomId,String userUUID){
ChatRoom chatRoom = chatRoomMap.get(roomId);
chatRoom.getUserList().remove(userUUID);
}
// 채팅방 userName 조회
public String getUserName(String roomId,String userUUID){
ChatRoom chatRoom = chatRoomMap.get(roomId);
return chatRoom.getUserList().get(userUUID);
}
//채팅방 전체 userList 조회
public List<String> getUserList(String roomId){
List<String> list = new ArrayList<>();
ChatRoom chatRoom = chatRoomMap.get(roomId);
chatRoom.getUserList().forEach((key,value) -> list.add(value));
return list;
}
}
package org.codej.websocket.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.codej.websocket.domain.ChatRoom;
import org.codej.websocket.repository.ChatRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
@Slf4j
@RequiredArgsConstructor
public class ChatRoomController {
// ChatRepository Bean 가져오기
private final ChatRepository repository;
// 채팅 리스트 확인
// "/" 로 요청이 들어오면 전체 채팅방 리스트를 담아서 return
@GetMapping("/")
public String ChatRoomList(Model model){
model.addAttribute("list",repository.findAllRoom());
log.info("Show All CharList : {}",repository.findAllRoom());
return "roomList";
}
// 채팅방 생성 (리스트로 리다이렉트)
@PostMapping("/chat/createroom")
public String createRoom(@RequestParam String roomName, RedirectAttributes rttr){
ChatRoom chatRoom = repository.createChatRoom(roomName);
log.info("Create ChatRoom : {}",chatRoom);
rttr.addFlashAttribute("roomName" , chatRoom);
return "redirect:/";
}
// 채팅방 입장 화면
// 파라미터로 넘어오는 roomId를 확인 후 해당 roomId 를 기준으로
// 채팅방을 찾아서 클라이언트를 chatroom 으로 보낸다.
@GetMapping("/chat/joinroom")
public String joinRoom(String roomId,Model model){
log.info("roomId : {}",roomId);
model.addAttribute("room",repository.findByRoomId(roomId));
return "chatroom";
}
}
@MessageMapping
: 이 어노테이션은 Stomp 에서 들어오는 message를 서버에서 발송(pub) 한 메시지가 도착하는 엔드 포인트 입니다.@MessageMapping
에 의해서 아래의 해당 어노테이션이 달린 메서드가 실행됩니다.converAndSend()
: 이 메서드는 매개변수로 각각 메시지의 도착지점과 객체를 넣어줍니다. 이를 통해서 도착지점 즉 sub 되는 지점으로 인자로 들어온 객체를 Message 객체로 변환하여 해당 도착지점을 sub 하고 있는 모든 사용자에게 메시지를 보내줍니다.package org.codej.websocket.controller;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.message.SimpleMessage;
import org.codej.websocket.domain.ChatDto;
import org.codej.websocket.domain.ChatRoom;
import org.codej.websocket.repository.ChatRepository;
import org.codej.websocket.service.ChatService;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
import java.util.ArrayList;
import java.util.List;
@Controller
@Slf4j
@RequiredArgsConstructor
public class ChatController {
// 아래에서 사용되는 convertAndSend 를 사용하기 위해서 서언
// convertAndSend 는 객체를 인자로 넘겨주면 자동으로 Message 객체로 변환 후 도착지로 전송한다.
private final SimpMessageSendingOperations template;
private final ChatRepository repository;
// MessageMapping 을 통해 websocket 으로 들어오는 메시지를 발신 처리합니다.
// 이 때 클라이언트에서는 /pub/chat/message 로 요청을 하게 되고 이것을 controller 가 받아서 처리합니다.
// 처리가 완료되면 /sub/chat/room/roomId 로 메시지가 전송됩니다.
@MessageMapping("/chat/enterUser")
public void enterUser(@Payload ChatDto chat, SimpMessageHeaderAccessor headerAccessor){
//채팅방 유저 +1;
repository.increaseUser(chat.getRoomId());
//채팅방에 유저 추가 및 UserUUID 반환
String userUUID = repository.addUser(chat.getRoomId(), chat.getSender());
//반환 결과를 socket session 에 userUUID 로 저장
headerAccessor.getSessionAttributes().put("userUUID",userUUID);
headerAccessor.getSessionAttributes().put("roomId",chat.getRoomId());
chat.setMessage(chat.getSender() + "님이 입장하셨습니다.");
template.convertAndSend("/sub/chat/room/"+chat.getRoomId(),chat);
}
//해당유저
@MessageMapping("/chat/sendMessage")
public void sendMessage(@Payload ChatDto chat){
log.info("chat : {}",chat);
chat.setMessage(chat.getMessage());
template.convertAndSend("/sub/chat/room/"+chat.getRoomId(),chat);
}
//유저 퇴장 시에는 EventListener 를 통해서 유저 퇴장을 확인
@EventListener
public void webSocketDisconnectListener(SessionDisconnectEvent event){
log.info("DisconnectEvent : {}",event);
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
// stomp 세션에 있던 uuid 와 roomId 를 확인하여 채팅방 유저 리스트와 room에서 해당 유저를 삭제
String userUUID = (String) headerAccessor.getSessionAttributes().get("userUUID");
String roomId = (String) headerAccessor.getSessionAttributes().get("roomId");
log.info("headAccessor : {}",headerAccessor);
// 채팅방 유저 -1
repository.decreaseUser(roomId);
//채팅방 유저 리스트에서 UUID 유저 닉네임 조회 및 리스트에서 유저 삭제
String userName = repository.getUserName(roomId, userUUID);
repository.deleteUser(roomId,userUUID);
if(userName != null){
log.info("User Disconnected : " + userName);
ChatDto chat = ChatDto.builder()
.type(ChatDto.MessageType.LEAVE)
.sender(userName)
.message(userName + "님이 퇴장하였습니다.")
.build();
template.convertAndSend("/sub/chat/room/" + roomId,chat);
}
}
// 채팅에 참여한 유저 리스트 반환
@GetMapping("/chat/uselist")
@ResponseBody
public List<String> userList(String roomId){
return repository.getUserList(roomId);
}
// 채팅에 참여한 유저 닉네임 중복 확인
@GetMapping("/chat/duplicateName")
@ResponseBody
public String isDuplicateName(@RequestParam("roomId")String roomId ,
@RequestParam("username")String username){
String userName = repository.isDuplicateName(roomId, username);
log.info("DuplicateName : {}", userName);
return userName;
}
}
'use strict';
// document.write("<script src='jquery-3.6.1.js'></script>")
document.write("<script\n" +
" src=\"https://code.jquery.com/jquery-3.6.1.min.js\"\n" +
" integrity=\"sha256-o88AwQnZB+VDvE9tvIXrMQaPlFFSUTR+nldQm1LuPXQ=\"\n" +
" crossorigin=\"anonymous\"></script>")
let usernamePage = document.querySelector('#username-page');
let chatPage = document.querySelector('#chat-page');
let usernameForm = document.querySelector('#usernameForm');
let messageForm = document.querySelector('#messageForm');
let messageInput = document.querySelector('#message');
let messageArea = document.querySelector('#messageArea');
let connectingElement = document.querySelector('.connecting');
let stompClient = null;
let username = null;
let colors = [
'#2196F3', '#32c787', '#00BCD4', '#ff5652',
'#ffc107', '#ff85af', '#FF9800', '#39bbb0'
];
// roomId 파라미터 가져오기
const url = new URL(location.href).searchParams;
const roomId = url.get('roomId');
function connect(event){
username = document.querySelector('#name').value.trim();
// username 중복 확인
isDuplicateName();
// usernamePage 에 hidden 속성 추가해서 가리고 chatPage를 등장시킴
usernamePage.classList.add('hidden');
chatPage.classList.remove('hidden');
// 연결하고자하는 Socket 의 endPoint
let socket = new SockJS('/ws-stomp');
stompClient = Stomp.over(socket);
stompClient.connect({},onConnected,onError);
event.preventDefault();
}
function onConnected() {
// sub 할 url => /sub/chat/room/roomId 로 구독한다
stompClient.subscribe('/sub/chat/room/' + roomId, onMessageReceived);
// 서버에 username 을 가진 유저가 들어왔다는 것을 알림
// /pub/chat/enterUser 로 메시지를 보냄
stompClient.send("/pub/chat/enterUser",
{},
JSON.stringify({
"roomId": roomId,
sender: username,
type: 'ENTER'
})
)
connectingElement.classList.add('hidden');
}
// 유저 닉네임 중복 확인
function isDuplicateName() {
$.ajax({
type: "GET",
url: "/chat/duplicateName",
data: {
"username": username,
"roomId": roomId
},
success: function (data) {
console.log("함수 동작 확인 : " + data);
username = data;
}
})
}
// 유저 리스트 받기
// ajax 로 유저 리스를 받으며 클라이언트가 입장/퇴장 했다는 문구가 나왔을 때마다 실행된다.
function getUserList() {
const $list = $("#list");
$.ajax({
type: "GET",
url: "/chat/userlist",
data: {
"roomId": roomId
},
success: function (data) {
let users = "";
for (let i = 0; i < data.length; i++) {
//console.log("data[i] : "+data[i]);
users += "<li class='dropdown-item'>" + data[i] + "</li>"
}
$list.html(users);
}
})
}
function onError(error) {
connectingElement.textContent = 'Could not connect to WebSocket server. Please refresh this page to try again!';
connectingElement.style.color = 'red';
}
// 메시지 전송때는 JSON 형식을 메시지를 전달한다.
function sendMessage(event) {
let messageContent = messageInput.value.trim();
if (messageContent && stompClient) {
let chatMessage = {
"roomId": roomId,
sender: username,
message: messageInput.value,
type: 'TALK'
};
stompClient.send("/pub/chat/sendMessage", {}, JSON.stringify(chatMessage));
messageInput.value = '';
}
event.preventDefault();
}
// 메시지를 받을 때도 마찬가지로 JSON 타입으로 받으며,
// 넘어온 JSON 형식의 메시지를 parse 해서 사용한다.
function onMessageReceived(payload) {
//console.log("payload 들어오냐? :"+payload);
let chat = JSON.parse(payload.body);
let messageElement = document.createElement('li');
if (chat.type === 'ENTER') { // chatType 이 enter 라면 아래 내용
messageElement.classList.add('event-message');
chat.content = chat.sender + chat.message;
getUserList();
} else if (chat.type === 'LEAVE') { // chatType 가 leave 라면 아래 내용
messageElement.classList.add('event-message');
chat.content = chat.sender + chat.message;
getUserList();
} else { // chatType 이 talk 라면 아래 내용
messageElement.classList.add('chat-message');
let avatarElement = document.createElement('i');
let avatarText = document.createTextNode(chat.sender[0]);
avatarElement.appendChild(avatarText);
avatarElement.style['background-color'] = getAvatarColor(chat.sender);
messageElement.appendChild(avatarElement);
let usernameElement = document.createElement('span');
let usernameText = document.createTextNode(chat.sender);
usernameElement.appendChild(usernameText);
messageElement.appendChild(usernameElement);
}
let contentElement = document.createElement('p');
// 만약 s3DataUrl 의 값이 null 이 아니라면 => chat 내용이 파일 업로드와 관련된 내용이라면
// img 를 채팅에 보여주는 작업
if(chat.s3DataUrl != null){
let imgElement = document.createElement('img');
imgElement.setAttribute("src", chat.s3DataUrl);
imgElement.setAttribute("width", "300");
imgElement.setAttribute("height", "300");
let downBtnElement = document.createElement('button');
downBtnElement.setAttribute("class", "btn fa fa-download");
downBtnElement.setAttribute("id", "downBtn");
downBtnElement.setAttribute("name", chat.fileName);
downBtnElement.setAttribute("onclick", `downloadFile('${chat.fileName}', '${chat.fileDir}')`);
contentElement.appendChild(imgElement);
contentElement.appendChild(downBtnElement);
}else{
// 만약 s3DataUrl 의 값이 null 이라면
// 이전에 넘어온 채팅 내용 보여주기기
let messageText = document.createTextNode(chat.message);
contentElement.appendChild(messageText);
}
messageElement.appendChild(contentElement);
messageArea.appendChild(messageElement);
messageArea.scrollTop = messageArea.scrollHeight;
}
function getAvatarColor(messageSender) {
let hash = 0;
for (let i = 0; i < messageSender.length; i++) {
hash = 31 * hash + messageSender.charCodeAt(i);
}
let index = Math.abs(hash % colors.length);
return colors[index];
}
usernameForm.addEventListener('submit', connect, true)
messageForm.addEventListener('submit', sendMessage, true)
안녕하세요 글 너무 잘 봤습니다! 혹시 html부분도 함께 올려주실 수 있을까요? 아래에 git은 없는 주소로 나와서용..